chore: import upstream snapshot with attribution
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Common test data constants for attachment tests.
|
||||
|
||||
This module contains base64-encoded and binary test data for various file formats
|
||||
used across attachment decoder tests.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
|
||||
# PNG Test Data
|
||||
# 1x1 transparent PNG image
|
||||
PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
|
||||
# JPEG Test Data
|
||||
# Minimal valid JPEG image (1x1 red pixel)
|
||||
JPEG_BASE64 = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCRAP/Z"
|
||||
|
||||
# PDF Test Data
|
||||
# Minimal valid PDF document with "Hello World"
|
||||
PDF_BYTES: bytes = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\n2 0 obj\n<<\n/Type /Pages\n/Kids [3 0 R]\n/Count 1\n>>\nendobj\n3 0 obj\n<<\n/Type /Page\n/Parent 2 0 R\n/Resources <<\n/Font <<\n/F1 4 0 R\n>>\n>>\n/MediaBox [0 0 612 792]\n/Contents 5 0 R\n>>\nendobj\n4 0 obj\n<<\n/Type /Font\n/Subtype /Type1\n/BaseFont /Helvetica\n>>\nendobj\n5 0 obj\n<<\n/Length 44\n>>\nstream\nBT\n/F1 24 Tf\n100 700 Td\n(Hello World) Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f\n0000000015 00000 n\n0000000074 00000 n\n0000000131 00000 n\n0000000277 00000 n\n0000000356 00000 n\ntrailer\n<<\n/Size 6\n/Root 1 0 R\n>>\nstartxref\n448\n%%EOF"
|
||||
PDF_BASE64 = base64.b64encode(PDF_BYTES).decode("utf-8")
|
||||
|
||||
# GIF Test Data
|
||||
# Minimal GIF89a image
|
||||
GIF89_BYTES: bytes = (
|
||||
b"GIF89a\x01\x00\x01\x00\x00\xff\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x00;"
|
||||
)
|
||||
GIF89_BASE64 = base64.b64encode(GIF89_BYTES).decode("utf-8")
|
||||
|
||||
# WebP Test Data
|
||||
# Minimal WebP image
|
||||
WEBP_BYTES: bytes = b"RIFF\x1a\x00\x00\x00WEBPVP8 \x0e\x00\x00\x000\x01\x00\x9d\x01*\x01\x00\x01\x00\x00\x00\x00\x00"
|
||||
WEBP_BASE64 = base64.b64encode(WEBP_BYTES).decode("utf-8")
|
||||
|
||||
# SVG Test Data
|
||||
# Simple SVG image with a red circle
|
||||
SVG_BYTES: bytes = b'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="40" fill="red"/></svg>'
|
||||
SVG_BASE64 = base64.b64encode(SVG_BYTES).decode("utf-8")
|
||||
|
||||
# JSON Test Data
|
||||
JSON_BYTES: bytes = b'{"key": "value", "number": 123, "array": [1, 2, 3]}'
|
||||
JSON_BASE64 = base64.b64encode(JSON_BYTES).decode("utf-8")
|
||||
|
||||
# Plain Text Test Data
|
||||
PLAIN_TEXT_BYTES: bytes = b"This is just plain text without special markers"
|
||||
PLAIN_TEXT_BASE64 = base64.b64encode(PLAIN_TEXT_BYTES).decode("utf-8")
|
||||
|
||||
# Random Binary Data (octet-stream)
|
||||
RANDOM_BINARY_BYTES: bytes = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a"
|
||||
RANDOM_BINARY_BASE64 = base64.b64encode(RANDOM_BINARY_BYTES).decode("utf-8")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
import base64
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from opik.api_objects.attachment import base64_normalizer
|
||||
from . import constants
|
||||
|
||||
|
||||
def _to_urlsafe(standard_b64: str) -> str:
|
||||
"""Re-encode a standard-base64 string into URL-safe form for fixtures."""
|
||||
return base64.urlsafe_b64encode(base64.b64decode(standard_b64)).decode("utf-8")
|
||||
|
||||
|
||||
# Bytes whose base64 encoding contains '+' / '/' (so URL-safe encoding contains
|
||||
# '-' / '_'). Appended to fixtures to guarantee we actually exercise the
|
||||
# URL-safe path rather than falling through the "no '-'/'_'" early exit.
|
||||
_URLSAFE_FORCING_TAIL = b"\xfb\xff\xfe" * 8
|
||||
|
||||
|
||||
def _urlsafe_image_fixture(signature: bytes) -> str:
|
||||
"""Build a URL-safe base64 string that (a) decodes to bytes starting with
|
||||
``signature`` and (b) is guaranteed to contain '-' or '_' so the
|
||||
detector's URL-safe-alphabet pre-check actually runs.
|
||||
"""
|
||||
encoded = base64.urlsafe_b64encode(signature + _URLSAFE_FORCING_TAIL).decode(
|
||||
"utf-8"
|
||||
)
|
||||
assert "-" in encoded or "_" in encoded, "fixture failed to force URL-safe chars"
|
||||
return encoded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# urlsafe_to_standard_base64
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUrlsafeToStandardBase64:
|
||||
def test_urlsafe_to_standard_base64__dash_and_underscore_chars__replaced_with_plus_and_slash(
|
||||
self,
|
||||
):
|
||||
assert (
|
||||
base64_normalizer.urlsafe_to_standard_base64("ab-cd_ef==") == "ab+cd/ef=="
|
||||
)
|
||||
|
||||
def test_urlsafe_to_standard_base64__already_standard_alphabet__returns_unchanged(
|
||||
self,
|
||||
):
|
||||
value = "iVBORw0KGgo+AB/CD=="
|
||||
assert base64_normalizer.urlsafe_to_standard_base64(value) == value
|
||||
|
||||
def test_urlsafe_to_standard_base64__urlsafe_chars_with_padding__padding_preserved(
|
||||
self,
|
||||
):
|
||||
assert base64_normalizer.urlsafe_to_standard_base64("ab-_==") == "ab+/=="
|
||||
|
||||
def test_urlsafe_to_standard_base64__empty_string__returns_empty(self):
|
||||
assert base64_normalizer.urlsafe_to_standard_base64("") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_urlsafe_base64_image
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsUrlsafeBase64Image:
|
||||
@pytest.mark.parametrize(
|
||||
"signature",
|
||||
[
|
||||
b"\x89PNG\r\n\x1a\n",
|
||||
b"\xff\xd8\xff",
|
||||
b"GIF87a",
|
||||
b"GIF89a",
|
||||
b"RIFF\x00\x00\x00\x00WEBP",
|
||||
],
|
||||
ids=["png", "jpeg", "gif87a", "gif89a", "webp"],
|
||||
)
|
||||
def test_is_urlsafe_base64_image__urlsafe_encoded_image_signature__returns_true(
|
||||
self, signature
|
||||
):
|
||||
"""Every supported image signature, when URL-safe-base64-encoded, is detected."""
|
||||
urlsafe = _urlsafe_image_fixture(signature)
|
||||
assert base64_normalizer.is_urlsafe_base64_image(urlsafe) is True
|
||||
|
||||
def test_is_urlsafe_base64_image__urlsafe_encoded_real_png__returns_true(self):
|
||||
"""End-to-end sanity check using a real PNG fixture from constants.py."""
|
||||
urlsafe = _to_urlsafe(constants.PNG_BASE64)
|
||||
# The real PNG fixture happens to contain '+' / '/' in its standard
|
||||
# encoding, so its URL-safe form contains '-' / '_' — exercise that.
|
||||
assert "-" in urlsafe or "_" in urlsafe
|
||||
assert base64_normalizer.is_urlsafe_base64_image(urlsafe) is True
|
||||
|
||||
def test_is_urlsafe_base64_image__standard_alphabet_image__returns_false(self):
|
||||
"""Standard-alphabet base64 should short-circuit to False — nothing to rewrite."""
|
||||
assert base64_normalizer.is_urlsafe_base64_image(constants.PNG_BASE64) is False
|
||||
|
||||
def test_is_urlsafe_base64_image__string_below_min_length__returns_false(self):
|
||||
assert base64_normalizer.is_urlsafe_base64_image("ab-_cd==") is False
|
||||
|
||||
def test_is_urlsafe_base64_image__uuid_string__returns_false(self):
|
||||
"""UUIDs share the URL-safe alphabet and contain '-', but aren't images."""
|
||||
for _ in range(5):
|
||||
assert base64_normalizer.is_urlsafe_base64_image(str(uuid.uuid4())) is False
|
||||
|
||||
def test_is_urlsafe_base64_image__urlsafe_non_image_binary__returns_false(self):
|
||||
"""Non-image binary in URL-safe base64 (e.g. PDF) must be left alone."""
|
||||
urlsafe_pdf = _to_urlsafe(constants.PDF_BASE64)
|
||||
assert base64_normalizer.is_urlsafe_base64_image(urlsafe_pdf) is False
|
||||
|
||||
def test_is_urlsafe_base64_image__plain_text_with_dashes__returns_false(self):
|
||||
text = "hello-world this is not base64-encoded at all"
|
||||
assert base64_normalizer.is_urlsafe_base64_image(text) is False
|
||||
|
||||
def test_is_urlsafe_base64_image__non_base64_chars__returns_false(self):
|
||||
# Contains '$' which is not in either alphabet
|
||||
assert (
|
||||
base64_normalizer.is_urlsafe_base64_image("iVBORw0KGgo$$$abcdefgh") is False
|
||||
)
|
||||
|
||||
def test_is_urlsafe_base64_image__empty_string__returns_false(self):
|
||||
assert base64_normalizer.is_urlsafe_base64_image("") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_urlsafe_base64_images_in_place
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalizeUrlsafeBase64ImagesInPlace:
|
||||
def test_normalize_urlsafe_base64_images_in_place__top_level_dict_with_image__image_rewritten(
|
||||
self,
|
||||
):
|
||||
urlsafe = _to_urlsafe(constants.PNG_BASE64)
|
||||
payload = {"data": urlsafe}
|
||||
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
|
||||
|
||||
assert "-" not in payload["data"]
|
||||
assert "_" not in payload["data"]
|
||||
# Round-trips back to the original PNG bytes
|
||||
original_bytes = base64.b64decode(constants.PNG_BASE64)
|
||||
assert base64.b64decode(payload["data"]) == original_bytes
|
||||
|
||||
def test_normalize_urlsafe_base64_images_in_place__list_with_image__image_rewritten(
|
||||
self,
|
||||
):
|
||||
urlsafe = _to_urlsafe(constants.JPEG_BASE64)
|
||||
payload = ["leading text", urlsafe]
|
||||
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
|
||||
|
||||
assert payload[0] == "leading text"
|
||||
assert "-" not in payload[1] and "_" not in payload[1]
|
||||
|
||||
def test_normalize_urlsafe_base64_images_in_place__deeply_nested_image__image_rewritten(
|
||||
self,
|
||||
):
|
||||
urlsafe = _to_urlsafe(constants.PNG_BASE64)
|
||||
payload = {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"text": "hello"},
|
||||
{"inline_data": {"data": urlsafe, "mime_type": "image/png"}},
|
||||
],
|
||||
}
|
||||
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
|
||||
|
||||
normalized = payload["parts"][1]["inline_data"]["data"]
|
||||
assert "-" not in normalized and "_" not in normalized
|
||||
assert base64.b64decode(normalized) == base64.b64decode(constants.PNG_BASE64)
|
||||
|
||||
def test_normalize_urlsafe_base64_images_in_place__uuid_values__left_untouched(
|
||||
self,
|
||||
):
|
||||
uid = str(uuid.uuid4())
|
||||
payload = {"trace_id": uid, "nested": [{"span_id": uid}]}
|
||||
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
|
||||
|
||||
assert payload["trace_id"] == uid
|
||||
assert payload["nested"][0]["span_id"] == uid
|
||||
|
||||
def test_normalize_urlsafe_base64_images_in_place__standard_base64_image__left_untouched(
|
||||
self,
|
||||
):
|
||||
"""If the value is already standard-alphabet base64, it stays byte-for-byte identical."""
|
||||
payload = {"data": constants.PNG_BASE64}
|
||||
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
|
||||
|
||||
assert payload["data"] == constants.PNG_BASE64
|
||||
|
||||
def test_normalize_urlsafe_base64_images_in_place__non_image_binary__left_untouched(
|
||||
self,
|
||||
):
|
||||
"""URL-safe non-image binary (e.g. PDF) is not rewritten — only images are."""
|
||||
urlsafe_pdf = _to_urlsafe(constants.PDF_BASE64)
|
||||
payload = {"data": urlsafe_pdf}
|
||||
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
|
||||
|
||||
assert payload["data"] == urlsafe_pdf
|
||||
|
||||
def test_normalize_urlsafe_base64_images_in_place__non_string_leaves__ignored(self):
|
||||
payload = {"a": 1, "b": None, "c": True, "d": 1.5, "e": [None, 2, False]}
|
||||
snapshot = {"a": 1, "b": None, "c": True, "d": 1.5, "e": [None, 2, False]}
|
||||
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
|
||||
|
||||
assert payload == snapshot
|
||||
|
||||
def test_normalize_urlsafe_base64_images_in_place__mutated_payload__returns_none(
|
||||
self,
|
||||
):
|
||||
urlsafe = _to_urlsafe(constants.PNG_BASE64)
|
||||
payload = {"data": urlsafe}
|
||||
|
||||
result = base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
|
||||
|
||||
assert result is None
|
||||
assert payload["data"] != urlsafe # was mutated
|
||||
|
||||
def test_normalize_urlsafe_base64_images_in_place__empty_containers__no_change(
|
||||
self,
|
||||
):
|
||||
empty_dict: dict = {}
|
||||
empty_list: list = []
|
||||
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place(empty_dict)
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place(empty_list)
|
||||
|
||||
assert empty_dict == {}
|
||||
assert empty_list == []
|
||||
|
||||
def test_normalize_urlsafe_base64_images_in_place__scalar_root__no_raise(self):
|
||||
"""Scalar roots are valid inputs (no-op) — the walker should not raise."""
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place("plain string")
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place(42)
|
||||
base64_normalizer.normalize_urlsafe_base64_images_in_place(None)
|
||||
@@ -0,0 +1,481 @@
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Generator
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from opik.api_objects import attachment
|
||||
from opik.api_objects.attachment import converters
|
||||
from opik.message_processing import messages
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def original_file() -> Generator[str, None, None]:
|
||||
"""Create a file with test content and clean up after test."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write("test content")
|
||||
path = f.name
|
||||
|
||||
yield path
|
||||
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attachment_data,expected",
|
||||
[
|
||||
(attachment.Attachment(data="test.png", file_name=None), "image/png"),
|
||||
(attachment.Attachment(data="test.png", file_name="test.jpg"), "image/jpeg"),
|
||||
(
|
||||
attachment.Attachment(
|
||||
data="test.pdf", file_name=None, content_type="image/jpeg"
|
||||
),
|
||||
"image/jpeg",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_guess_attachment_type(attachment_data: attachment.Attachment, expected: str):
|
||||
mimetype = converters.guess_attachment_type(attachment_data)
|
||||
assert mimetype == expected
|
||||
|
||||
|
||||
def test_attachment_to_message__no_temp_copy(original_file: str):
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
attachment_data = attachment.Attachment(data=original_file, create_temp_copy=False)
|
||||
|
||||
message = converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
|
||||
assert message == messages.CreateAttachmentMessage(
|
||||
file_path=original_file,
|
||||
file_name=os.path.basename(original_file),
|
||||
mime_type="text/plain",
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
encoded_url_override="aHR0cHM6Ly9leGFtcGxlLmNvbQ==",
|
||||
)
|
||||
|
||||
|
||||
def test_attachment_to_message__file_name(original_file: str):
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
attachment_data = attachment.Attachment(
|
||||
data=original_file, file_name="test.jpg", create_temp_copy=False
|
||||
)
|
||||
|
||||
message = converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
|
||||
assert message == messages.CreateAttachmentMessage(
|
||||
file_path=original_file,
|
||||
file_name="test.jpg",
|
||||
mime_type="image/jpeg",
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
encoded_url_override="aHR0cHM6Ly9leGFtcGxlLmNvbQ==",
|
||||
)
|
||||
|
||||
|
||||
def test_attachment_to_message__content_type(original_file: str):
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
attachment_data = attachment.Attachment(
|
||||
data=original_file, content_type="image/jpeg", create_temp_copy=False
|
||||
)
|
||||
|
||||
message = converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
|
||||
assert message == messages.CreateAttachmentMessage(
|
||||
file_path=original_file,
|
||||
file_name=os.path.basename(original_file),
|
||||
mime_type="image/jpeg",
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
encoded_url_override="aHR0cHM6Ly9leGFtcGxlLmNvbQ==",
|
||||
)
|
||||
|
||||
|
||||
def test_attachment_to_message__create_temp_copy(original_file: str):
|
||||
"""Test that create_temp_copy creates a temporary file and sets delete_after_upload."""
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
|
||||
attachment_data = attachment.Attachment(
|
||||
data=original_file,
|
||||
file_name="test.txt",
|
||||
content_type="text/plain",
|
||||
create_temp_copy=True,
|
||||
)
|
||||
|
||||
message = converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
|
||||
# The file_path should be different from the original (it's a temp copy)
|
||||
assert message.file_path != original_file
|
||||
assert message.file_path.endswith(".txt")
|
||||
assert os.path.exists(message.file_path)
|
||||
|
||||
# delete_after_upload should be True when create_temp_copy is used
|
||||
assert message.delete_after_upload is True
|
||||
|
||||
# Other fields should be preserved
|
||||
assert message.file_name == "test.txt"
|
||||
assert message.mime_type == "text/plain"
|
||||
assert message.entity_type == "trace"
|
||||
assert message.entity_id == entity_id
|
||||
assert message.project_name == project_name
|
||||
|
||||
# Verify the temp file has the same content
|
||||
with open(message.file_path, "r") as f:
|
||||
assert f.read() == "test content"
|
||||
|
||||
# Clean up the temp copy
|
||||
os.unlink(message.file_path)
|
||||
|
||||
|
||||
def test_attachment_to_message__create_temp_copy_fails_on_open__uses_original_file_and_does_not_delete(
|
||||
original_file: str,
|
||||
):
|
||||
"""
|
||||
Test that when create_temp_copy is True but opening the source file fails,
|
||||
the original file is used and delete_after_upload is False.
|
||||
"""
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
|
||||
attachment_data = attachment.Attachment(
|
||||
data=original_file,
|
||||
file_name="test.txt",
|
||||
content_type="text/plain",
|
||||
create_temp_copy=True,
|
||||
)
|
||||
|
||||
# Patch open to fail when trying to read the source file for copying
|
||||
original_open = open
|
||||
|
||||
def mock_open_fail_on_rb(file, mode="r", *args, **kwargs):
|
||||
if mode == "rb" and file == original_file:
|
||||
raise PermissionError("Cannot read source file")
|
||||
return original_open(file, mode, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.open", side_effect=mock_open_fail_on_rb):
|
||||
message = converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
|
||||
# The file_path should be the original file (fallback behavior)
|
||||
assert message.file_path == original_file
|
||||
|
||||
# delete_after_upload should be False to prevent deleting the original file
|
||||
assert message.delete_after_upload is False
|
||||
|
||||
# Other fields should be preserved
|
||||
assert message.file_name == "test.txt"
|
||||
assert message.mime_type == "text/plain"
|
||||
assert message.entity_type == "trace"
|
||||
assert message.entity_id == entity_id
|
||||
assert message.project_name == project_name
|
||||
|
||||
# Original file should still exist
|
||||
assert os.path.exists(original_file)
|
||||
|
||||
# Clean up the temp copy
|
||||
os.unlink(message.file_path)
|
||||
|
||||
|
||||
def test_attachment_to_message__create_temp_copy_fails_with_delete_after_upload_true__still_does_not_delete(
|
||||
original_file: str,
|
||||
):
|
||||
"""
|
||||
Test that when create_temp_copy fails and delete_after_upload was explicitly True,
|
||||
delete_after_upload is still set to False to protect the original file.
|
||||
"""
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
|
||||
attachment_data = attachment.Attachment(
|
||||
data=original_file,
|
||||
file_name="test.txt",
|
||||
content_type="text/plain",
|
||||
create_temp_copy=True,
|
||||
)
|
||||
|
||||
with mock.patch("shutil.copyfileobj", side_effect=IOError("Disk full")):
|
||||
message = converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
delete_after_upload=True, # Explicitly set to True
|
||||
)
|
||||
|
||||
# Even though delete_after_upload was True, it should be False after failure
|
||||
assert message.delete_after_upload is False
|
||||
assert message.file_path == original_file
|
||||
|
||||
|
||||
def test_attachment_to_message__bytes_data():
|
||||
"""Test that bytes data is written to a temp file and marked for deletion."""
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
data = b"binary content here"
|
||||
|
||||
attachment_data = attachment.Attachment(
|
||||
data=data,
|
||||
file_name="test.bin",
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
|
||||
message = converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
|
||||
# A temp file should be created
|
||||
assert os.path.exists(message.file_path)
|
||||
assert message.file_path != "test.bin"
|
||||
|
||||
# delete_after_upload should be True for bytes data
|
||||
assert message.delete_after_upload is True
|
||||
|
||||
# Verify file content matches the bytes
|
||||
with open(message.file_path, "rb") as f:
|
||||
assert f.read() == data
|
||||
|
||||
# Other fields should be preserved
|
||||
assert message.file_name == "test.bin"
|
||||
assert message.mime_type == "application/octet-stream"
|
||||
assert message.entity_type == "trace"
|
||||
assert message.entity_id == entity_id
|
||||
assert message.project_name == project_name
|
||||
assert message.encoded_url_override == "aHR0cHM6Ly9leGFtcGxlLmNvbQ=="
|
||||
|
||||
# Clean up
|
||||
os.unlink(message.file_path)
|
||||
|
||||
|
||||
def test_attachment_to_message__bytes_data_without_file_name():
|
||||
"""Test bytes data without file_name uses temp file basename."""
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
data = b"some binary data"
|
||||
|
||||
attachment_data = attachment.Attachment(
|
||||
data=data,
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
message = converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="span",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
|
||||
# file_name should be the basename of the temp file
|
||||
assert message.file_name == os.path.basename(message.file_path)
|
||||
assert message.delete_after_upload is True
|
||||
assert message.mime_type == "image/png"
|
||||
assert message.entity_type == "span"
|
||||
|
||||
# Clean up
|
||||
os.unlink(message.file_path)
|
||||
|
||||
|
||||
def test_attachment_to_message__bytes_data_infers_mime_type_from_file_name():
|
||||
"""Test that a mime type is inferred from file_name when data is bytes."""
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
data = b"\x00\x01\x02\x03"
|
||||
|
||||
attachment_data = attachment.Attachment(
|
||||
data=data,
|
||||
file_name="image.png",
|
||||
)
|
||||
|
||||
message = converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
|
||||
assert message.file_name == "image.png"
|
||||
assert message.mime_type == "image/png"
|
||||
assert message.delete_after_upload is True
|
||||
|
||||
# Clean up
|
||||
os.unlink(message.file_path)
|
||||
|
||||
|
||||
def test_attachment_to_message__bytes_data_default_mime_type():
|
||||
"""Test that bytes data without file_name or content_type use a binary mime type."""
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
data = b"\x00\x01\x02\x03"
|
||||
|
||||
attachment_data = attachment.Attachment(
|
||||
data=data,
|
||||
)
|
||||
|
||||
message = converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
|
||||
# Should use default binary mime type
|
||||
assert message.mime_type == "application/octet-stream"
|
||||
assert message.delete_after_upload is True
|
||||
|
||||
# Clean up
|
||||
os.unlink(message.file_path)
|
||||
|
||||
|
||||
def test_attachment_to_message__bytes_data_write_fails__error_reraised():
|
||||
"""Test behavior when _write_file_like_to_temp_file fails and error reraised."""
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
data = b"test data"
|
||||
|
||||
attachment_data = attachment.Attachment(
|
||||
data=data,
|
||||
file_name="test.bin",
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"opik.api_objects.attachment.converters._write_file_like_to_temp_file",
|
||||
side_effect=OSError("Disk full"),
|
||||
):
|
||||
with pytest.raises(OSError):
|
||||
converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
|
||||
|
||||
def test_attachment_to_message__base64_string(request):
|
||||
"""Test that base64-encoded string is decoded and written to temp file."""
|
||||
import base64
|
||||
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
original_content = b"This is test content for base64 encoding"
|
||||
base64_string = base64.b64encode(original_content).decode("utf-8")
|
||||
|
||||
attachment_data = attachment.Attachment(
|
||||
data=base64_string,
|
||||
file_name="test.txt",
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
message = converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
|
||||
request.addfinalizer(
|
||||
lambda: os.path.exists(message.file_path) and os.unlink(message.file_path)
|
||||
)
|
||||
|
||||
# A temp file should be created
|
||||
assert os.path.exists(message.file_path)
|
||||
assert message.file_path != base64_string
|
||||
|
||||
# delete_after_upload should be True for base64 data
|
||||
assert message.delete_after_upload is True
|
||||
|
||||
# Verify file content matches the decoded bytes
|
||||
with open(message.file_path, "rb") as f:
|
||||
assert f.read() == original_content
|
||||
|
||||
# Other fields should be preserved
|
||||
assert message.file_name == "test.txt"
|
||||
assert message.mime_type == "text/plain"
|
||||
assert message.entity_type == "trace"
|
||||
assert message.entity_id == entity_id
|
||||
assert message.project_name == project_name
|
||||
|
||||
|
||||
def test_attachment_to_message__invalid_data_raises_error():
|
||||
"""Test that invalid attachment data (not bytes, file, or base64) raises ValueError."""
|
||||
url_override = "https://example.com"
|
||||
entity_id = "123"
|
||||
project_name = "test-project"
|
||||
|
||||
invalid_data = "not-a-file-and-not-base64!@#$%"
|
||||
|
||||
attachment_data = attachment.Attachment(
|
||||
data=invalid_data,
|
||||
file_name="test.txt",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Attachment data must be bytes, an existing file path, or a valid base64-encoded string",
|
||||
):
|
||||
converters.attachment_to_message(
|
||||
attachment_data=attachment_data,
|
||||
entity_type="trace",
|
||||
entity_id=entity_id,
|
||||
project_name=project_name,
|
||||
url_override=url_override,
|
||||
)
|
||||
@@ -0,0 +1,286 @@
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from opik.api_objects.attachment import attachment, decoder_base64
|
||||
from opik.api_objects.attachment import decoder_helpers
|
||||
from . import constants
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def decoder():
|
||||
"""Create a Base64AttachmentDecoder instance."""
|
||||
return decoder_base64.Base64AttachmentDecoder()
|
||||
|
||||
|
||||
def test_decode_png_success(decoder, files_to_remove):
|
||||
"""Test successful decoding of PNG image."""
|
||||
result = decoder.decode(constants.PNG_BASE64, context="input")
|
||||
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
assert isinstance(result, attachment.Attachment)
|
||||
assert result.content_type == "image/png"
|
||||
assert result.file_name.startswith("input-attachment-")
|
||||
assert result.file_name.endswith(".png")
|
||||
assert os.path.exists(result.data)
|
||||
|
||||
|
||||
def test_decode_jpeg_success(decoder, files_to_remove):
|
||||
"""Test successful decoding of JPEG image."""
|
||||
result = decoder.decode(constants.JPEG_BASE64, context="output")
|
||||
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
assert isinstance(result, attachment.Attachment)
|
||||
assert result.content_type == "image/jpeg"
|
||||
assert result.file_name.startswith("output-attachment-")
|
||||
assert result.file_name.endswith(".jpg") or result.file_name.endswith(".jpeg")
|
||||
assert os.path.exists(result.data)
|
||||
|
||||
|
||||
def test_decode_pdf_success(decoder, files_to_remove):
|
||||
"""Test successful decoding of PDF document."""
|
||||
result = decoder.decode(constants.PDF_BASE64, context="metadata")
|
||||
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
assert isinstance(result, attachment.Attachment)
|
||||
assert result.content_type == "application/pdf"
|
||||
assert result.file_name.startswith("metadata-attachment-")
|
||||
assert result.file_name.endswith(".pdf")
|
||||
assert os.path.exists(result.data)
|
||||
|
||||
|
||||
def test_decode_gif_success(decoder, files_to_remove):
|
||||
"""Test successful decoding of GIF image."""
|
||||
result = decoder.decode(constants.GIF89_BASE64, context="input")
|
||||
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
assert isinstance(result, attachment.Attachment)
|
||||
assert result.content_type == "image/gif"
|
||||
assert result.file_name.endswith(".gif")
|
||||
assert os.path.exists(result.data)
|
||||
|
||||
|
||||
def test_decode_json_success(decoder, files_to_remove):
|
||||
"""Test successful decoding of JSON data."""
|
||||
result = decoder.decode(constants.JSON_BASE64, context="input")
|
||||
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
assert isinstance(result, attachment.Attachment)
|
||||
assert result.content_type == "application/json"
|
||||
assert result.file_name.endswith(".json")
|
||||
assert os.path.exists(result.data)
|
||||
|
||||
|
||||
def test_decode_invalid_base64(decoder):
|
||||
"""Test that an invalid base64 string returns None."""
|
||||
invalid_base64 = "this is not valid base64!@#$%"
|
||||
|
||||
result = decoder.decode(invalid_base64, context="input")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_decode_non_string_input(decoder):
|
||||
"""Test that non-string input returns None."""
|
||||
result = decoder.decode(12345, context="input") # type: ignore
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_decode_dict_input(decoder):
|
||||
"""Test that dict input returns None."""
|
||||
result = decoder.decode({"key": "value"}, context="input") # type: ignore
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_decode_none_input(decoder):
|
||||
"""Test that None input returns None."""
|
||||
result = decoder.decode(None, context="input") # type: ignore
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_decode_empty_string(decoder):
|
||||
"""Test that empty string returns None."""
|
||||
result = decoder.decode("", context="input")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_decode_octet_stream_returns_none(decoder):
|
||||
"""Test that unrecognizable binary data (octet-stream) returns None."""
|
||||
# Random binary data that won't match any known format
|
||||
result = decoder.decode(constants.RANDOM_BINARY_BASE64, context="input")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_decode_plain_text_returns_none(decoder):
|
||||
"""Test that plain text returns None."""
|
||||
result = decoder.decode(constants.PLAIN_TEXT_BASE64, context="input")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_decode_creates_temp_file_with_correct_extension(decoder, files_to_remove):
|
||||
"""Test that temporary file is created with the correct extension."""
|
||||
result = decoder.decode(constants.PNG_BASE64, context="input")
|
||||
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
# Check that the temp file path ends with .png (suffix parameter)
|
||||
assert result.data.endswith("png")
|
||||
assert os.path.exists(result.data)
|
||||
|
||||
# Verify file content
|
||||
with open(result.data, "rb") as f:
|
||||
content = f.read()
|
||||
assert content[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def test_decode_attachment_properties(decoder, files_to_remove):
|
||||
"""Test that Attachment object has all required properties."""
|
||||
result = decoder.decode(constants.PNG_BASE64, context="input")
|
||||
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
assert hasattr(result, "data")
|
||||
assert hasattr(result, "file_name")
|
||||
assert hasattr(result, "content_type")
|
||||
assert result.data is not None
|
||||
assert result.file_name is not None
|
||||
assert result.content_type is not None
|
||||
|
||||
|
||||
def test_decode_filename_format(decoder, files_to_remove):
|
||||
"""Test that the filename follows the expected format."""
|
||||
result = decoder.decode(constants.PNG_BASE64, context="input")
|
||||
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
# check that the filename matches a backend pattern
|
||||
pattern = re.compile(decoder_helpers.ATTACHMENT_FILE_NAME_REGEX)
|
||||
assert bool(pattern.fullmatch(result.file_name)) is True
|
||||
|
||||
|
||||
def test_decode_different_contexts(decoder, files_to_remove):
|
||||
"""Test that different contexts are reflected in the filename."""
|
||||
contexts = ["input", "output", "metadata"]
|
||||
results = []
|
||||
|
||||
for ctx in contexts:
|
||||
result = decoder.decode(constants.PNG_BASE64, context=ctx)
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
assert result.file_name.startswith(f"{ctx}-attachment-")
|
||||
results.append(result)
|
||||
|
||||
|
||||
def test_decode_multiple_calls_create_unique_files(decoder, files_to_remove):
|
||||
"""Test that multiple decode calls create unique filenames."""
|
||||
result1 = decoder.decode(constants.PNG_BASE64, context="input")
|
||||
result2 = decoder.decode(constants.PNG_BASE64, context="input")
|
||||
|
||||
assert result1 is not None
|
||||
assert result2 is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result1.data)
|
||||
files_to_remove.append(result2.data)
|
||||
|
||||
assert result1.file_name != result2.file_name
|
||||
assert result1.data != result2.data
|
||||
assert os.path.exists(result1.data)
|
||||
assert os.path.exists(result2.data)
|
||||
|
||||
|
||||
def test_decode_webp_success(decoder, files_to_remove):
|
||||
"""Test successful decoding of WebP image."""
|
||||
result = decoder.decode(constants.WEBP_BASE64, context="input")
|
||||
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
assert result.content_type == "image/webp"
|
||||
assert result.file_name.endswith(".webp")
|
||||
|
||||
|
||||
def test_decode_svg_success(decoder, files_to_remove):
|
||||
"""Test successful decoding of SVG image."""
|
||||
result = decoder.decode(constants.SVG_BASE64, context="input")
|
||||
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
assert result.content_type == "image/svg+xml"
|
||||
assert result.file_name.endswith(".svg")
|
||||
|
||||
|
||||
def test_decode_short_base64_data(decoder):
|
||||
"""Test that very short base64 (< 4 bytes decoded) returns None."""
|
||||
# Encode just 2 bytes
|
||||
short_data = b"ab"
|
||||
short_base64 = base64.b64encode(short_data).decode("utf-8")
|
||||
|
||||
result = decoder.decode(short_base64, context="input")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_decode_base64_with_whitespace(decoder, files_to_remove):
|
||||
"""Test that base64 with whitespace is handled correctly."""
|
||||
png_base64 = constants.PNG_BASE64
|
||||
# Add whitespace
|
||||
png_base64_with_whitespace = f" {png_base64} \n"
|
||||
|
||||
result = decoder.decode(png_base64_with_whitespace.strip(), context="input")
|
||||
|
||||
assert result is not None
|
||||
# Register for cleanup
|
||||
files_to_remove.append(result.data)
|
||||
|
||||
assert result.content_type == "image/png"
|
||||
|
||||
|
||||
def test_decode_corrupted_base64_padding(decoder, files_to_remove):
|
||||
"""Test base64 with incorrect padding.
|
||||
|
||||
Note: Python's base64 decoder is lenient and may still decode
|
||||
strings with missing padding, so this test verifies proper handling.
|
||||
"""
|
||||
# Valid base64 but with missing padding
|
||||
corrupted_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" # Missing padding
|
||||
|
||||
result = decoder.decode(corrupted_base64, context="input")
|
||||
|
||||
# Python's base64 decoder is lenient, so this might still succeed
|
||||
# If it succeeds, verify the result is valid and clean up
|
||||
if result is not None:
|
||||
files_to_remove.append(result.data)
|
||||
@@ -0,0 +1,221 @@
|
||||
import base64
|
||||
|
||||
from opik.api_objects.attachment import decoder_helpers
|
||||
|
||||
from . import constants
|
||||
|
||||
|
||||
class TestDetectMimeType:
|
||||
"""Test suite for detect_mime_type() function."""
|
||||
|
||||
def test_detect_png(self):
|
||||
"""Test PNG image detection using magic bytes."""
|
||||
# PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
|
||||
png_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
||||
assert decoder_helpers.detect_mime_type(png_data) == "image/png"
|
||||
|
||||
def test_detect_jpeg(self):
|
||||
"""Test JPEG image detection using magic bytes."""
|
||||
# Real JPEG SOI (FFD8) followed by an APP0/JFIF marker (FFE0), then
|
||||
# body bytes, then FFD9 end marker.
|
||||
jpeg_data = b"\xff\xd8\xff\xe0" + b"\x00" * 100 + b"\xff\xd9"
|
||||
assert decoder_helpers.detect_mime_type(jpeg_data) == "image/jpeg"
|
||||
|
||||
def test_detect_jpeg_without_end_marker(self):
|
||||
"""Test JPEG without proper end marker should not be detected as JPEG."""
|
||||
# JPEG start but no proper end
|
||||
jpeg_data = b"\xff\xd8" + b"\x00" * 100
|
||||
assert decoder_helpers.detect_mime_type(jpeg_data) != "image/jpeg"
|
||||
|
||||
def test_detect_gif87a(self):
|
||||
"""Test GIF87a format detection."""
|
||||
gif_data = b"GIF87a" + b"\x00" * 100
|
||||
assert decoder_helpers.detect_mime_type(gif_data) == "image/gif"
|
||||
|
||||
def test_detect_gif89a(self):
|
||||
"""Test GIF89a format detection."""
|
||||
assert decoder_helpers.detect_mime_type(constants.GIF89_BYTES) == "image/gif"
|
||||
|
||||
def test_detect_pdf(self):
|
||||
"""Test PDF document detection."""
|
||||
assert (
|
||||
decoder_helpers.detect_mime_type(constants.PDF_BYTES) == "application/pdf"
|
||||
)
|
||||
|
||||
def test_detect_webp(self):
|
||||
"""Test WebP image detection."""
|
||||
assert decoder_helpers.detect_mime_type(constants.WEBP_BYTES) == "image/webp"
|
||||
|
||||
def test_detect_svg(self):
|
||||
"""Test SVG image detection."""
|
||||
assert decoder_helpers.detect_mime_type(constants.SVG_BYTES) == "image/svg+xml"
|
||||
|
||||
def test_detect_svg_with_uppercase(self):
|
||||
"""Test SVG detection with an uppercase tag."""
|
||||
svg_data = b"<SVG>content</SVG>"
|
||||
assert decoder_helpers.detect_mime_type(svg_data) == "image/svg+xml"
|
||||
|
||||
def test_detect_svg_with_mixed_case(self):
|
||||
"""Test SVG detection with mixed case tag."""
|
||||
svg_data = b"<SvG>content</SvG>"
|
||||
assert decoder_helpers.detect_mime_type(svg_data) == "image/svg+xml"
|
||||
|
||||
def test_detect_mp4(self):
|
||||
"""Test MP4 video detection."""
|
||||
# MP4: ftyp at offset 4
|
||||
mp4_data = b"\x00\x00\x00\x20ftypisom" + b"\x00" * 100
|
||||
assert decoder_helpers.detect_mime_type(mp4_data) == "video/mp4"
|
||||
|
||||
def test_detect_json_object(self):
|
||||
"""Test JSON object detection."""
|
||||
json_data = b'{"key": "value", "number": 123}'
|
||||
assert decoder_helpers.detect_mime_type(json_data) == "application/json"
|
||||
|
||||
def test_detect_json_array(self):
|
||||
"""Test JSON array detection."""
|
||||
json_data = b'["item1", "item2", "item3"]'
|
||||
assert decoder_helpers.detect_mime_type(json_data) == "application/json"
|
||||
|
||||
def test_detect_json_with_whitespace(self):
|
||||
"""Test JSON detection with leading whitespace."""
|
||||
json_data = b' \n\t{"key": "value"}'
|
||||
assert decoder_helpers.detect_mime_type(json_data) == "application/json"
|
||||
|
||||
def test_detect_invalid_json_like(self):
|
||||
"""Test that invalid JSON-like content is not detected as JSON."""
|
||||
# UTF-8 decoding should fail
|
||||
invalid_data = b"\xff\xfe{invalid}"
|
||||
assert (
|
||||
decoder_helpers.detect_mime_type(invalid_data) == "application/octet-stream"
|
||||
)
|
||||
|
||||
def test_detect_short_data(self):
|
||||
"""Test that data shorter than 4 bytes returns octet-stream."""
|
||||
short_data = b"abc"
|
||||
assert (
|
||||
decoder_helpers.detect_mime_type(short_data) == "application/octet-stream"
|
||||
)
|
||||
|
||||
def test_detect_empty_data(self):
|
||||
"""Test that empty data returns octet-stream."""
|
||||
empty_data = b""
|
||||
assert (
|
||||
decoder_helpers.detect_mime_type(empty_data) == "application/octet-stream"
|
||||
)
|
||||
|
||||
def test_detect_unknown_binary(self):
|
||||
"""Test that unknown binary data returns octet-stream."""
|
||||
assert (
|
||||
decoder_helpers.detect_mime_type(constants.RANDOM_BINARY_BYTES)
|
||||
== "application/octet-stream"
|
||||
)
|
||||
|
||||
def test_detect_plain_text_not_json(self):
|
||||
"""Test that plain text (non-JSON) returns octet-stream."""
|
||||
assert (
|
||||
decoder_helpers.detect_mime_type(constants.PLAIN_TEXT_BYTES)
|
||||
== "application/octet-stream"
|
||||
)
|
||||
|
||||
def test_real_png_base64(self):
|
||||
"""Test with a real minimal PNG image (1x1 transparent pixel)."""
|
||||
# 1x1 transparent PNG
|
||||
png_data = base64.b64decode(constants.PNG_BASE64)
|
||||
assert decoder_helpers.detect_mime_type(png_data) == "image/png"
|
||||
|
||||
def test_real_jpeg_base64(self):
|
||||
"""Test with a real minimal JPEG image."""
|
||||
# Minimal valid JPEG (1x1 red pixel)
|
||||
jpeg_data = base64.b64decode(constants.JPEG_BASE64)
|
||||
assert decoder_helpers.detect_mime_type(jpeg_data) == "image/jpeg"
|
||||
|
||||
def test_detect_svg_with_long_content(self):
|
||||
"""Test SVG detection with content longer than 1024 bytes."""
|
||||
# Create SVG content longer than 1024 bytes
|
||||
long_content = "x" * 2000
|
||||
svg_data = (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg">{long_content}</svg>'.encode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert decoder_helpers.detect_mime_type(svg_data) == "image/svg+xml"
|
||||
|
||||
def test_detect_webp_invalid(self):
|
||||
"""Test that RIFF without WEBP marker is not detected as WebP."""
|
||||
# RIFF but not WEBP
|
||||
riff_data = b"RIFF\x00\x00\x00\x00XXXX" + b"\x00" * 100
|
||||
assert decoder_helpers.detect_mime_type(riff_data) != "image/webp"
|
||||
|
||||
def test_detect_mp4_short_data(self):
|
||||
"""Test that data too short for MP4 detection is not detected as MP4."""
|
||||
short_mp4 = b"\x00\x00\x00\x20ftyp" # Only 9 bytes
|
||||
assert decoder_helpers.detect_mime_type(short_mp4) != "video/mp4"
|
||||
|
||||
|
||||
class TestGetFileExtension:
|
||||
"""Test suite for get_file_extension() function."""
|
||||
|
||||
def test_get_extension_png(self):
|
||||
"""Test PNG extension extraction."""
|
||||
assert decoder_helpers.get_file_extension("image/png") == "png"
|
||||
|
||||
def test_get_extension_jpeg(self):
|
||||
"""Test JPEG extension extraction (should return 'jpg' not 'jpe')."""
|
||||
result = decoder_helpers.get_file_extension("image/jpeg")
|
||||
# The function should return 'jpg' for image/jpeg
|
||||
assert result in ("jpg", "jpeg")
|
||||
|
||||
def test_get_extension_pdf(self):
|
||||
"""Test PDF extension extraction."""
|
||||
assert decoder_helpers.get_file_extension("application/pdf") == "pdf"
|
||||
|
||||
def test_get_extension_gif(self):
|
||||
"""Test GIF extension extraction."""
|
||||
assert decoder_helpers.get_file_extension("image/gif") == "gif"
|
||||
|
||||
def test_get_extension_svg(self):
|
||||
"""Test SVG extension extraction from 'image/svg+xml'."""
|
||||
assert decoder_helpers.get_file_extension("image/svg+xml") == "svg"
|
||||
|
||||
def test_get_extension_webp(self):
|
||||
"""Test WebP extension extraction."""
|
||||
assert decoder_helpers.get_file_extension("image/webp") == "webp"
|
||||
|
||||
def test_get_extension_mp4(self):
|
||||
"""Test MP4 extension extraction."""
|
||||
assert decoder_helpers.get_file_extension("video/mp4") == "mp4"
|
||||
|
||||
def test_get_extension_json(self):
|
||||
"""Test JSON extension extraction."""
|
||||
assert decoder_helpers.get_file_extension("application/json") == "json"
|
||||
|
||||
def test_get_extension_with_parameters(self):
|
||||
"""Test extension extraction with MIME type parameters."""
|
||||
assert decoder_helpers.get_file_extension("image/jpeg; charset=utf-8") == "jpeg"
|
||||
|
||||
def test_get_extension_empty_string(self):
|
||||
"""Test that an empty MIME type returns 'bin'."""
|
||||
assert decoder_helpers.get_file_extension("") == "bin"
|
||||
|
||||
def test_get_extension_none(self):
|
||||
"""Test that a None MIME type returns 'bin'."""
|
||||
# This will fail the "if not mime_type:" check
|
||||
result = decoder_helpers.get_file_extension("")
|
||||
assert result == "bin"
|
||||
|
||||
def test_get_extension_invalid_format(self):
|
||||
"""Test that an invalid MIME type format returns 'bin'."""
|
||||
assert decoder_helpers.get_file_extension("invalidmimetype") == "bin"
|
||||
|
||||
def test_get_extension_unknown_type(self):
|
||||
"""Test that an unknown MIME type extracts subtype."""
|
||||
assert (
|
||||
decoder_helpers.get_file_extension("application/x-custom-type")
|
||||
== "x-custom-type"
|
||||
)
|
||||
|
||||
def test_get_extension_removes_leading_dot(self):
|
||||
"""Test that leading dots are removed from extensions."""
|
||||
# mimetypes.guess_extension can return extensions with leading dots
|
||||
result = decoder_helpers.get_file_extension("text/plain")
|
||||
assert not result.startswith(".")
|
||||
Reference in New Issue
Block a user