项目文件夹

文件
wehub-resource-sync 5a558eb09e
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
chore: import upstream snapshot with attribution
2026-07-13 13:25:44 +08:00

1064 行
38 KiB
Python

"""Tests for attachment support in CLI export and import."""
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from opik.cli.exports.project import (
_download_attachment_file,
_fetch_attachments,
export_traces,
)
from opik.cli.imports.project import (
_upload_attachments_for_trace,
import_traces_from_directory,
)
# ─────────────────────────────────────────────────────────────────────────────
# Helpers shared across test classes
# ─────────────────────────────────────────────────────────────────────────────
def _rest_attachment(file_name, mime_type="image/png", file_size=1024):
"""Simulate a REST API Attachment response object."""
return SimpleNamespace(
file_name=file_name,
mime_type=mime_type,
file_size=file_size,
link="http://storage.example.com/presigned-url",
)
def _make_attachment_client(trace_attachments=None, span_attachments=None):
"""Build a mock AttachmentClient with configurable attachment lists.
Args:
trace_attachments: list of dicts with keys accepted by _rest_attachment()
span_attachments: mapping of {span_id: [dicts]} for span-level attachments
"""
client = MagicMock()
def _get_list(project_name, entity_id, entity_type):
if entity_type == "trace":
return [_rest_attachment(**a) for a in (trace_attachments or [])]
elif entity_type == "span":
span_atts = (span_attachments or {}).get(entity_id, [])
return [_rest_attachment(**a) for a in span_atts]
return []
client.get_attachment_list.side_effect = _get_list
client.download_attachment.return_value = iter([b"binary content"])
return client
def _make_trace(trace_id, name="test-trace"):
"""Simulate a trace object as returned by the REST API."""
t = MagicMock()
t.id = trace_id
t.name = name
t.model_dump.return_value = {
"id": trace_id,
"name": name,
"start_time": "2026-01-01T00:00:00Z",
"end_time": "2026-01-01T00:00:01Z",
"input": {},
"output": {},
"metadata": None,
"tags": None,
"feedback_scores": None,
"error_info": None,
"usage": None,
}
return t
def _make_span(span_id, trace_id, parent_span_id=None):
"""Simulate a span object."""
s = MagicMock()
s.id = span_id
s.model_dump.return_value = {
"id": span_id,
"trace_id": trace_id,
"parent_span_id": parent_span_id,
"name": f"span-{span_id}",
"type": "general",
"start_time": "2026-01-01T00:00:00Z",
"end_time": "2026-01-01T00:00:01Z",
"input": {},
"output": {},
"metadata": None,
"tags": None,
"feedback_scores": None,
"usage": None,
}
return s
def _make_opik_client(traces, spans_by_trace_id, attachment_client=None):
"""Build a mock opik.Opik client for export tests.
Args:
traces: list of trace mock objects
spans_by_trace_id: mapping of {trace_id: [span_mock]}
attachment_client: optional mock AttachmentClient
"""
client = MagicMock()
# Paginated traces response — first call returns traces, second returns empty
page1 = MagicMock()
page1.content = traces
page1.total = len(traces)
page2 = MagicMock()
page2.content = []
page2.total = len(traces)
client.rest_client.traces.get_traces_by_project.side_effect = [page1, page2]
# Bulk span fetch (Phase 2 in 3-phase export): collect all spans and set trace_id
all_spans = []
for tid, spans in spans_by_trace_id.items():
for span in spans:
span.trace_id = tid
all_spans.append(span)
span_page1 = MagicMock()
span_page1.content = all_spans
span_page2 = MagicMock()
span_page2.content = []
client.rest_client.spans.get_spans_by_project.side_effect = [span_page1, span_page2]
client.get_attachment_client.return_value = attachment_client or MagicMock()
return client
def _write_trace_file(project_dir: Path, trace_data: dict) -> Path:
"""Write a trace JSON file and return its path."""
project_dir.mkdir(parents=True, exist_ok=True)
trace_id = trace_data["trace"]["id"]
path = project_dir / f"trace_{trace_id}.json"
path.write_text(json.dumps(trace_data))
return path
def _write_attachment_file(
project_dir: Path,
entity_type: str,
entity_id: str,
file_name: str,
content: bytes = b"file data",
) -> Path:
"""Write a binary attachment file and return its path."""
path = project_dir / "attachments" / entity_type / entity_id / file_name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
return path
# ─────────────────────────────────────────────────────────────────────────────
# Tests: _fetch_attachments()
# ─────────────────────────────────────────────────────────────────────────────
class TestFetchAttachments:
def test_fetch_attachments__trace_entity__returns_metadata(self):
att_client = _make_attachment_client(
trace_attachments=[
{"file_name": "img.png", "mime_type": "image/png", "file_size": 500}
]
)
result = _fetch_attachments(att_client, "proj", "trace-1", [])
assert len(result) == 1
assert result[0] == {
"entity_type": "trace",
"entity_id": "trace-1",
"file_name": "img.png",
"mime_type": "image/png",
"file_size": 500,
}
def test_fetch_attachments__span_entity__returns_metadata(self):
att_client = _make_attachment_client(
span_attachments={
"span-1": [
{"file_name": "data.csv", "mime_type": "text/csv", "file_size": 200}
]
}
)
result = _fetch_attachments(att_client, "proj", "trace-1", ["span-1"])
assert len(result) == 1
assert result[0] == {
"entity_type": "span",
"entity_id": "span-1",
"file_name": "data.csv",
"mime_type": "text/csv",
"file_size": 200,
}
def test_fetch_attachments__trace_and_span__returns_both(self):
att_client = _make_attachment_client(
trace_attachments=[{"file_name": "trace.png"}],
span_attachments={
"span-1": [{"file_name": "span.txt", "mime_type": "text/plain"}]
},
)
result = _fetch_attachments(att_client, "proj", "trace-1", ["span-1"])
assert len(result) == 2
types = {a["entity_type"] for a in result}
assert types == {"trace", "span"}
def test_fetch_attachments__no_attachments__returns_empty_list(self):
att_client = _make_attachment_client()
result = _fetch_attachments(att_client, "proj", "trace-1", ["span-1"])
assert result == []
def test_fetch_attachments__multiple_spans__collects_all(self):
att_client = _make_attachment_client(
span_attachments={
"span-a": [{"file_name": "a.png"}],
"span-b": [{"file_name": "b.png"}, {"file_name": "c.png"}],
}
)
result = _fetch_attachments(att_client, "proj", "trace-1", ["span-a", "span-b"])
assert len(result) == 3
file_names = {a["file_name"] for a in result}
assert file_names == {"a.png", "b.png", "c.png"}
def test_fetch_attachments__conflict_409__skips_entity(self):
from opik.rest_api.core.api_error import ApiError
att_client = MagicMock()
att_client.get_attachment_list.side_effect = ApiError(
status_code=409, body="conflict"
)
# 409 is silently skipped; the function returns what it could collect
result = _fetch_attachments(att_client, "proj", "trace-1", ["span-1"])
assert result == []
def test_fetch_attachments__unexpected_api_error__raises(self):
from opik.rest_api.core.api_error import ApiError
att_client = MagicMock()
att_client.get_attachment_list.side_effect = ApiError(
status_code=400, body="bad request"
)
import pytest
with pytest.raises(ApiError):
_fetch_attachments(att_client, "proj", "trace-1", [])
def test_fetch_attachments__trace_and_spans__calls_get_list_for_each(self):
att_client = _make_attachment_client()
_fetch_attachments(att_client, "proj", "trace-1", ["span-a", "span-b"])
calls = att_client.get_attachment_list.call_args_list
# One call for the trace, one per span
assert len(calls) == 3
entity_types = [c[0][2] for c in calls] # 3rd positional arg
assert entity_types.count("trace") == 1
assert entity_types.count("span") == 2
# ─────────────────────────────────────────────────────────────────────────────
# Tests: _download_attachment_file()
# ─────────────────────────────────────────────────────────────────────────────
class TestDownloadAttachmentFile:
_ATT = {
"entity_type": "trace",
"entity_id": "trace-123",
"file_name": "img.png",
"mime_type": "image/png",
"file_size": 12,
}
def test_download_attachment_file__single_chunk__writes_bytes_to_correct_path(
self, tmp_path
):
att_client = MagicMock()
att_client.download_attachment.return_value = iter([b"hello world"])
result = _download_attachment_file(
att_client, "proj", self._ATT, tmp_path, force=False
)
assert result is True
dest = tmp_path / "attachments" / "trace" / "trace-123" / "img.png"
assert dest.exists()
assert dest.read_bytes() == b"hello world"
def test_download_attachment_file__multiple_chunks__concatenates_bytes(
self, tmp_path
):
att_client = MagicMock()
att_client.download_attachment.return_value = iter(
[b"chunk1", b"chunk2", b"chunk3"]
)
_download_attachment_file(att_client, "proj", self._ATT, tmp_path, force=False)
dest = tmp_path / "attachments" / "trace" / "trace-123" / "img.png"
assert dest.read_bytes() == b"chunk1chunk2chunk3"
def test_download_attachment_file__existing_file_no_force__skips_download(
self, tmp_path
):
att_client = MagicMock()
dest = tmp_path / "attachments" / "trace" / "trace-123" / "img.png"
dest.parent.mkdir(parents=True)
dest.write_bytes(b"original")
result = _download_attachment_file(
att_client, "proj", self._ATT, tmp_path, force=False
)
assert result is True
att_client.download_attachment.assert_not_called()
assert dest.read_bytes() == b"original" # unchanged
def test_download_attachment_file__existing_file_with_force__overwrites(
self, tmp_path
):
att_client = MagicMock()
att_client.download_attachment.return_value = iter([b"new content"])
dest = tmp_path / "attachments" / "trace" / "trace-123" / "img.png"
dest.parent.mkdir(parents=True)
dest.write_bytes(b"old content")
result = _download_attachment_file(
att_client, "proj", self._ATT, tmp_path, force=True
)
assert result is True
att_client.download_attachment.assert_called_once()
assert dest.read_bytes() == b"new content"
def test_download_attachment_file__os_error__returns_false(self, tmp_path):
att_client = MagicMock()
att_client.download_attachment.side_effect = OSError("S3 unavailable")
result = _download_attachment_file(
att_client, "proj", self._ATT, tmp_path, force=False
)
assert result is False
def test_download_attachment_file__nested_entity_id__creates_parent_directories(
self, tmp_path
):
att_client = MagicMock()
att_client.download_attachment.return_value = iter([b"data"])
att = dict(self._ATT, entity_id="a/b/c", file_name="deep.txt")
_download_attachment_file(att_client, "proj", att, tmp_path, force=False)
dest = tmp_path / "attachments" / "trace" / "a/b/c" / "deep.txt"
assert dest.exists()
def test_download_attachment_file__span_entity__saved_under_span_directory(
self, tmp_path
):
att_client = MagicMock()
att_client.download_attachment.return_value = iter([b"span bytes"])
att = {
"entity_type": "span",
"entity_id": "span-xyz",
"file_name": "output.txt",
"mime_type": "text/plain",
"file_size": 10,
}
_download_attachment_file(att_client, "proj", att, tmp_path, force=False)
dest = tmp_path / "attachments" / "span" / "span-xyz" / "output.txt"
assert dest.exists()
assert dest.read_bytes() == b"span bytes"
# ─────────────────────────────────────────────────────────────────────────────
# Tests: _upload_attachments_for_trace()
# ─────────────────────────────────────────────────────────────────────────────
class TestUploadAttachmentsForTrace:
"""Unit tests for the _upload_attachments_for_trace helper.
This private helper is tested directly (rather than solely via
import_traces_from_directory) because it contains security-critical path
validation logic (traversal prevention) and ID-translation rules that are
difficult to observe through the public API without significant fixture
overhead. The integration-level behaviour (attachment uploads wired into
the full import flow) is covered by TestImportProjectsUploadsAttachments.
"""
def test_uploads_trace_attachment__with_new_trace_id__happyflow(self, tmp_path):
client = MagicMock()
project_dir = tmp_path / "my-project"
_write_attachment_file(
project_dir, "trace", "old-trace", "img.png", b"image data"
)
attachments = [
{
"entity_type": "trace",
"entity_id": "old-trace",
"file_name": "img.png",
"mime_type": "image/png",
}
]
_upload_attachments_for_trace(
client=client,
project_dir=project_dir,
attachments=attachments,
new_trace_id="new-trace",
span_id_map={},
project_name="proj",
)
client.queue_attachment_upload.assert_called_once_with(
entity_type="trace",
entity_id="new-trace",
project_name="proj",
file_path=str(
project_dir / "attachments" / "trace" / "old-trace" / "img.png"
),
file_name="img.png",
mime_type="image/png",
)
def test_uploads_span_attachment__with_translated_span_id__happyflow(
self, tmp_path
):
client = MagicMock()
project_dir = tmp_path / "my-project"
_write_attachment_file(project_dir, "span", "old-span", "data.csv", b"csv data")
attachments = [
{
"entity_type": "span",
"entity_id": "old-span",
"file_name": "data.csv",
"mime_type": "text/csv",
}
]
_upload_attachments_for_trace(
client=client,
project_dir=project_dir,
attachments=attachments,
new_trace_id="new-trace",
span_id_map={"old-span": "new-span"},
project_name="proj",
)
client.queue_attachment_upload.assert_called_once_with(
entity_type="span",
entity_id="new-span",
project_name="proj",
file_path=str(
project_dir / "attachments" / "span" / "old-span" / "data.csv"
),
file_name="data.csv",
mime_type="text/csv",
)
def test_uploads_attachments__trace_and_span__both_uploaded(self, tmp_path):
client = MagicMock()
project_dir = tmp_path / "my-project"
_write_attachment_file(project_dir, "trace", "old-trace", "img.png")
_write_attachment_file(project_dir, "span", "old-span", "data.csv")
attachments = [
{
"entity_type": "trace",
"entity_id": "old-trace",
"file_name": "img.png",
"mime_type": "image/png",
},
{
"entity_type": "span",
"entity_id": "old-span",
"file_name": "data.csv",
"mime_type": "text/csv",
},
]
_upload_attachments_for_trace(
client=client,
project_dir=project_dir,
attachments=attachments,
new_trace_id="new-trace",
span_id_map={"old-span": "new-span"},
project_name="proj",
)
assert client.queue_attachment_upload.call_count == 2
def test_upload_attachment__span_not_in_id_map__skips(self, tmp_path):
client = MagicMock()
project_dir = tmp_path / "my-project"
_write_attachment_file(project_dir, "span", "unknown-span", "file.txt")
attachments = [
{
"entity_type": "span",
"entity_id": "unknown-span",
"file_name": "file.txt",
"mime_type": "text/plain",
}
]
_upload_attachments_for_trace(
client=client,
project_dir=project_dir,
attachments=attachments,
new_trace_id="new-trace",
span_id_map={}, # unknown-span not in map
project_name="proj",
)
client.queue_attachment_upload.assert_not_called()
def test_upload_attachment__file_missing_on_disk__skips(self, tmp_path):
client = MagicMock()
project_dir = tmp_path / "my-project"
# No file written on disk
attachments = [
{
"entity_type": "trace",
"entity_id": "old-trace",
"file_name": "missing.png",
"mime_type": "image/png",
}
]
_upload_attachments_for_trace(
client=client,
project_dir=project_dir,
attachments=attachments,
new_trace_id="new-trace",
span_id_map={},
project_name="proj",
)
client.queue_attachment_upload.assert_not_called()
def test_upload_attachment__missing_required_keys__skips(self, tmp_path):
client = MagicMock()
project_dir = tmp_path / "my-project"
attachments = [
{"entity_type": "trace"}, # missing entity_id and file_name
]
_upload_attachments_for_trace(
client=client,
project_dir=project_dir,
attachments=attachments,
new_trace_id="new-trace",
span_id_map={},
project_name="proj",
)
client.queue_attachment_upload.assert_not_called()
# ─────────────────────────────────────────────────────────────────────────────
# Integration tests: export_traces() writes attachments to JSON and disk
# ─────────────────────────────────────────────────────────────────────────────
TRACE_ID = "00000000-0000-7000-0000-000000000001"
SPAN_ID = "00000000-0000-7000-0000-000000000002"
class TestExportTracesWritesAttachmentMetadata:
"""Verify that export_traces() embeds attachment metadata in the JSON file."""
def _run_export(self, client, project_dir, include_attachments=True, force=False):
export_traces(
client=client,
project_name="test-project",
project_dir=project_dir,
max_results=None,
filter_string=None,
format="json",
debug=False,
force=force,
show_progress=False,
include_attachments=include_attachments,
)
def test_export_traces__with_attachment__key_present_in_json(self, tmp_path):
trace = _make_trace(TRACE_ID)
span = _make_span(SPAN_ID, TRACE_ID)
att_client = _make_attachment_client(
trace_attachments=[
{"file_name": "img.png", "mime_type": "image/png", "file_size": 100}
]
)
client = _make_opik_client(
traces=[trace],
spans_by_trace_id={TRACE_ID: [span]},
attachment_client=att_client,
)
self._run_export(client, tmp_path, include_attachments=True)
trace_file = tmp_path / f"trace_{TRACE_ID}.json"
assert trace_file.exists()
data = json.loads(trace_file.read_text())
assert "attachments" in data
assert len(data["attachments"]) == 1
att = data["attachments"][0]
assert att["entity_type"] == "trace"
assert att["entity_id"] == TRACE_ID
assert att["file_name"] == "img.png"
assert att["mime_type"] == "image/png"
def test_export_traces__no_attachments__list_is_empty(self, tmp_path):
trace = _make_trace(TRACE_ID)
att_client = _make_attachment_client() # no attachments
client = _make_opik_client(
traces=[trace],
spans_by_trace_id={TRACE_ID: []},
attachment_client=att_client,
)
self._run_export(client, tmp_path, include_attachments=True)
data = json.loads((tmp_path / f"trace_{TRACE_ID}.json").read_text())
assert data["attachments"] == []
def test_export_traces__no_attachments_flag__omits_metadata(self, tmp_path):
trace = _make_trace(TRACE_ID)
att_client = _make_attachment_client(
trace_attachments=[{"file_name": "img.png"}]
)
client = _make_opik_client(
traces=[trace],
spans_by_trace_id={TRACE_ID: []},
attachment_client=att_client,
)
self._run_export(client, tmp_path, include_attachments=False)
data = json.loads((tmp_path / f"trace_{TRACE_ID}.json").read_text())
# attachments key present but empty (include_attachments=False → att_client is None)
assert data["attachments"] == []
# And get_attachment_client was never called
client.get_attachment_client.assert_not_called()
def test_export_traces__attachment_download__writes_to_correct_path(self, tmp_path):
trace = _make_trace(TRACE_ID)
att_client = _make_attachment_client(
trace_attachments=[
{"file_name": "photo.jpg", "mime_type": "image/jpeg", "file_size": 50}
]
)
att_client.download_attachment.return_value = iter([b"jpeg bytes"])
client = _make_opik_client(
traces=[trace],
spans_by_trace_id={TRACE_ID: []},
attachment_client=att_client,
)
self._run_export(client, tmp_path, include_attachments=True)
dest = tmp_path / "attachments" / "trace" / TRACE_ID / "photo.jpg"
assert dest.exists()
assert dest.read_bytes() == b"jpeg bytes"
def test_export_traces__span_attachment__binary_downloaded(self, tmp_path):
trace = _make_trace(TRACE_ID)
span = _make_span(SPAN_ID, TRACE_ID)
att_client = _make_attachment_client(
span_attachments={
SPAN_ID: [
{
"file_name": "span_output.txt",
"mime_type": "text/plain",
"file_size": 10,
}
]
}
)
att_client.download_attachment.return_value = iter([b"span text"])
client = _make_opik_client(
traces=[trace],
spans_by_trace_id={TRACE_ID: [span]},
attachment_client=att_client,
)
self._run_export(client, tmp_path, include_attachments=True)
dest = tmp_path / "attachments" / "span" / SPAN_ID / "span_output.txt"
assert dest.exists()
assert dest.read_bytes() == b"span text"
def test_export_traces__existing_attachment_file_no_force__skips_download(
self, tmp_path
):
trace = _make_trace(TRACE_ID)
att_client = _make_attachment_client(
trace_attachments=[{"file_name": "img.png"}]
)
# Pre-create the attachment file
dest = tmp_path / "attachments" / "trace" / TRACE_ID / "img.png"
dest.parent.mkdir(parents=True)
dest.write_bytes(b"already here")
client = _make_opik_client(
traces=[trace],
spans_by_trace_id={TRACE_ID: []},
attachment_client=att_client,
)
self._run_export(client, tmp_path, include_attachments=True, force=False)
# File unchanged; download_attachment was never called
att_client.download_attachment.assert_not_called()
assert dest.read_bytes() == b"already here"
# ─────────────────────────────────────────────────────────────────────────────
# Integration tests: import_traces_from_directory() uploads attachments
# ─────────────────────────────────────────────────────────────────────────────
_TRACE_WITH_ATTACHMENTS = {
"trace": {
"id": "orig-trace-id",
"name": "test-trace",
"start_time": "2026-01-01T00:00:00Z",
"end_time": "2026-01-01T00:00:01Z",
"input": {},
"output": {},
"metadata": None,
"tags": None,
"feedback_scores": None,
"error_info": None,
"thread_id": None,
},
"spans": [
{
"id": "orig-span-id",
"trace_id": "orig-trace-id",
"parent_span_id": None,
"name": "child-span",
"type": "general",
"start_time": "2026-01-01T00:00:00Z",
"end_time": "2026-01-01T00:00:00.500000Z",
"input": {},
"output": {},
"metadata": None,
"tags": None,
"feedback_scores": None,
"usage": None,
}
],
"attachments": [
{
"entity_type": "trace",
"entity_id": "orig-trace-id",
"file_name": "trace_img.png",
"mime_type": "image/png",
"file_size": 100,
},
{
"entity_type": "span",
"entity_id": "orig-span-id",
"file_name": "span_data.csv",
"mime_type": "text/csv",
"file_size": 50,
},
],
"downloaded_at": "2026-01-01T00:01:00",
"project_name": "test-project",
}
def _make_import_client(new_trace_id="new-trace-id", new_span_id="new-span-id"):
"""Build a mock opik.Opik client for import tests."""
client = MagicMock()
mock_trace = MagicMock()
mock_trace.id = new_trace_id
client.trace.return_value = mock_trace
mock_span = MagicMock()
mock_span.id = new_span_id
client.span.return_value = mock_span
# flush() is called once per project after all traces are processed.
client.flush.return_value = True
return client
class TestImportProjectsUploadsAttachments:
"""Integration tests verifying attachment uploads during project import."""
def test_import_projects__trace_attachment__uploaded_with_new_trace_id(
self, tmp_path
):
client = _make_import_client(new_trace_id="new-trace", new_span_id="new-span")
project_dir = tmp_path / "test-project"
_write_trace_file(project_dir, _TRACE_WITH_ATTACHMENTS)
_write_attachment_file(
project_dir, "trace", "orig-trace-id", "trace_img.png", b"img bytes"
)
_write_attachment_file(
project_dir, "span", "orig-span-id", "span_data.csv", b"csv bytes"
)
import_traces_from_directory(
client=client,
project_dir=project_dir,
project_name="test-project",
dry_run=False,
name_pattern=None,
debug=False,
include_attachments=True,
)
upload_calls = client.queue_attachment_upload.call_args_list
# Verify trace attachment uses new trace ID
trace_call = next(
c for c in upload_calls if c.kwargs.get("entity_type") == "trace"
)
assert trace_call.kwargs["entity_id"] == "new-trace"
assert trace_call.kwargs["file_name"] == "trace_img.png"
assert trace_call.kwargs["mime_type"] == "image/png"
def test_import_projects__span_attachment__uploaded_with_new_span_id(
self, tmp_path
):
client = _make_import_client(new_trace_id="new-trace", new_span_id="new-span")
project_dir = tmp_path / "test-project"
_write_trace_file(project_dir, _TRACE_WITH_ATTACHMENTS)
_write_attachment_file(project_dir, "trace", "orig-trace-id", "trace_img.png")
_write_attachment_file(project_dir, "span", "orig-span-id", "span_data.csv")
import_traces_from_directory(
client=client,
project_dir=project_dir,
project_name="test-project",
dry_run=False,
name_pattern=None,
debug=False,
include_attachments=True,
)
upload_calls = client.queue_attachment_upload.call_args_list
span_call = next(
c for c in upload_calls if c.kwargs.get("entity_type") == "span"
)
assert span_call.kwargs["entity_id"] == "new-span"
assert span_call.kwargs["file_name"] == "span_data.csv"
def test_import_projects__multiple_attachments__all_uploaded(self, tmp_path):
client = _make_import_client()
project_dir = tmp_path / "test-project"
_write_trace_file(project_dir, _TRACE_WITH_ATTACHMENTS)
_write_attachment_file(project_dir, "trace", "orig-trace-id", "trace_img.png")
_write_attachment_file(project_dir, "span", "orig-span-id", "span_data.csv")
import_traces_from_directory(
client=client,
project_dir=project_dir,
project_name="test-project",
dry_run=False,
name_pattern=None,
debug=False,
include_attachments=True,
)
assert client.queue_attachment_upload.call_count == 2
def test_import_projects__no_attachments_flag__skips_all_uploads(self, tmp_path):
client = _make_import_client()
project_dir = tmp_path / "test-project"
_write_trace_file(project_dir, _TRACE_WITH_ATTACHMENTS)
_write_attachment_file(project_dir, "trace", "orig-trace-id", "trace_img.png")
import_traces_from_directory(
client=client,
project_dir=project_dir,
project_name="test-project",
dry_run=False,
name_pattern=None,
debug=False,
include_attachments=False,
)
# queue_attachment_upload must not be called at all
client.queue_attachment_upload.assert_not_called()
def test_import_projects__attachment_files_missing__trace_still_imported(
self, tmp_path
):
"""Missing attachment files emit a warning but don't abort the trace import."""
client = _make_import_client()
project_dir = tmp_path / "test-project"
_write_trace_file(project_dir, _TRACE_WITH_ATTACHMENTS)
# Do NOT write attachment files — simulate export with --no-attachments
import_traces_from_directory(
client=client,
project_dir=project_dir,
project_name="test-project",
dry_run=False,
name_pattern=None,
debug=False,
include_attachments=True,
)
# Trace and span must still be created
assert client.trace.call_count == 1
assert client.span.call_count == 1
# No uploads because files don't exist
client.queue_attachment_upload.assert_not_called()
def test_import_projects__dry_run__skips_uploads(self, tmp_path):
client = _make_import_client()
project_dir = tmp_path / "test-project"
_write_trace_file(project_dir, _TRACE_WITH_ATTACHMENTS)
_write_attachment_file(project_dir, "trace", "orig-trace-id", "trace_img.png")
import_traces_from_directory(
client=client,
project_dir=project_dir,
project_name="test-project",
dry_run=True,
name_pattern=None,
debug=False,
include_attachments=True,
)
client.queue_attachment_upload.assert_not_called()
def test_import_projects__no_attachments_key__imports_normally(self, tmp_path):
"""Trace files from before attachment support (no 'attachments' key) import fine."""
trace_data = {
"trace": {
"id": "legacy-trace",
"name": "legacy",
"start_time": "2026-01-01T00:00:00Z",
"end_time": "2026-01-01T00:00:01Z",
"input": {},
"output": {},
"metadata": None,
"tags": None,
"feedback_scores": None,
"error_info": None,
"thread_id": None,
},
"spans": [],
"downloaded_at": "2026-01-01T00:01:00",
"project_name": "test-project",
# NOTE: no "attachments" key
}
client = _make_import_client()
project_dir = tmp_path / "test-project"
_write_trace_file(project_dir, trace_data)
import_traces_from_directory(
client=client,
project_dir=project_dir,
project_name="test-project",
dry_run=False,
name_pattern=None,
debug=False,
include_attachments=True,
)
assert client.trace.call_count == 1
att_client = client.get_attachment_client.return_value
att_client.upload_attachment.assert_not_called()
# ─────────────────────────────────────────────────────────────────────────────
# CLI flag tests: --no-attachments is accepted and propagates correctly
# ─────────────────────────────────────────────────────────────────────────────
class TestNoAttachmentsCliFlag:
"""Verify --no-attachments is wired through the CLI commands."""
def test_export_traces__no_attachments_flag__accepted(self):
from click.testing import CliRunner
from opik.cli.exports.project import export_traces_command
runner = CliRunner()
with patch("opik.cli.exports.project.export_project_traces") as mock_export:
result = runner.invoke(
export_traces_command,
["--no-attachments"],
obj={"workspace": "ws", "project_name": "my-project", "api_key": None},
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
_, kwargs = mock_export.call_args
assert kwargs.get("include_attachments") is False
def test_export_traces__default__includes_attachments(self):
from click.testing import CliRunner
from opik.cli.exports.project import export_traces_command
runner = CliRunner()
with patch("opik.cli.exports.project.export_project_traces") as mock_export:
result = runner.invoke(
export_traces_command,
[],
obj={"workspace": "ws", "project_name": "my-project", "api_key": None},
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
_, kwargs = mock_export.call_args
assert kwargs.get("include_attachments") is True
def test_import_traces__no_attachments_flag__accepted(self):
from click.testing import CliRunner
from opik.cli.imports import import_group
runner = CliRunner()
# Patch _import_by_type where it is *defined* so the import command
# calls the mock rather than the real function.
with patch("opik.cli.imports._import_by_type") as mock_import:
result = runner.invoke(
import_group,
["my-workspace", "my-project", "traces", "--no-attachments"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert mock_import.called
_, kwargs = mock_import.call_args
assert kwargs.get("include_attachments") is False
def test_import_traces__default__includes_attachments(self):
from click.testing import CliRunner
from opik.cli.imports import import_group
runner = CliRunner()
with patch("opik.cli.imports._import_by_type") as mock_import:
result = runner.invoke(
import_group,
["my-workspace", "my-project", "traces"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert mock_import.called
_, kwargs = mock_import.call_args
assert kwargs.get("include_attachments") is True