5a558eb09e
Backend Tests / discover-tests (push) Waiting to run
Backend Tests / ${{ matrix.name }} (push) Blocked by required conditions
Build and Publish SDK / build-and-publish (push) Waiting to run
Build Opik Docker Images / set-version (push) Waiting to run
Build Opik Docker Images / build-backend (push) Blocked by required conditions
Build Opik Docker Images / build-sandbox-executor-python (push) Blocked by required conditions
Build Opik Docker Images / build-python-backend (push) Blocked by required conditions
Build Opik Docker Images / build-frontend (push) Blocked by required conditions
Build Opik Docker Images / create-git-tag (push) Blocked by required conditions
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Waiting to run
Docs - Publish / run (push) Waiting to run
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Waiting to run
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Blocked by required conditions
Frontend Unit Tests / Test on Node 20 (push) Waiting to run
Guardrails E2E Tests / Select Python version matrix (push) Waiting to run
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Blocked by required conditions
Guardrails E2E Tests / 📢 Slack Notification (push) Blocked by required conditions
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Waiting to run
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Blocked by required conditions
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Waiting to run
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Waiting to run
Lint Opik Helm Chart / unittest-helm-chart (push) Waiting to run
Lint Opik Helm Chart / render-equality (push) Waiting to run
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Waiting to run
Python BE E2E Tests / Python BE E2E (push) Waiting to run
Python Backend Tests / run-python-backend-tests (push) Waiting to run
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
SDK E2E Libraries Integration Tests / Check Secrets (push) Waiting to run
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Blocked by required conditions
SDK E2E Libraries Integration Tests / build-opik (push) Blocked by required conditions
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Blocked by required conditions
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Waiting to run
TypeScript SDK Build & Publish / build-and-publish (push) Waiting to run
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Waiting to run
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
901 lines
33 KiB
Python
901 lines
33 KiB
Python
"""Unit tests for experiment import functionality."""
|
||
|
||
import json
|
||
import sys
|
||
import types
|
||
from pathlib import Path
|
||
from typing import Dict, Any
|
||
from unittest.mock import Mock, MagicMock, patch
|
||
import pytest
|
||
|
||
# Mock the problematic imports before importing
|
||
# Mock the prompt import that's causing issues
|
||
sys.modules["opik.api_objects.prompt.prompt"] = MagicMock()
|
||
|
||
# Now we can import normally
|
||
from opik.cli.imports.experiment import ( # noqa: E402
|
||
ExperimentData,
|
||
load_experiment_data,
|
||
recreate_experiment,
|
||
_import_traces_for_project,
|
||
)
|
||
from opik.cli.imports.utils import ( # noqa: E402
|
||
translate_trace_id as utils_translate_trace_id,
|
||
sort_spans_topologically,
|
||
)
|
||
|
||
|
||
class TestExperimentData:
|
||
"""Test ExperimentData dataclass."""
|
||
|
||
def test_experiment_data_from_dict(self) -> None:
|
||
"""Test creating ExperimentData from dictionary."""
|
||
data = {
|
||
"experiment": {
|
||
"id": "exp-123",
|
||
"name": "test-experiment",
|
||
"dataset_name": "test-dataset",
|
||
},
|
||
"items": [
|
||
{"id": "item-1", "trace_id": "trace-1"},
|
||
{"id": "item-2", "trace_id": "trace-2"},
|
||
],
|
||
"downloaded_at": "2024-01-01T00:00:00",
|
||
}
|
||
|
||
exp_data = ExperimentData.from_dict(data)
|
||
|
||
assert exp_data.experiment["id"] == "exp-123"
|
||
assert exp_data.experiment["name"] == "test-experiment"
|
||
assert len(exp_data.items) == 2
|
||
assert exp_data.downloaded_at == "2024-01-01T00:00:00"
|
||
|
||
def test_experiment_data_from_dict_minimal(self) -> None:
|
||
"""Test creating ExperimentData with minimal data."""
|
||
data = {
|
||
"experiment": {"id": "exp-123", "dataset_name": "test-dataset"},
|
||
"items": [],
|
||
}
|
||
|
||
exp_data = ExperimentData.from_dict(data)
|
||
|
||
assert exp_data.experiment["id"] == "exp-123"
|
||
assert exp_data.items == []
|
||
assert exp_data.downloaded_at is None
|
||
|
||
def test_load_experiment_data_from_file(self, tmp_path: Path) -> None:
|
||
"""Test loading experiment data from JSON file."""
|
||
experiment_file = tmp_path / "experiment_test.json"
|
||
data = {
|
||
"experiment": {
|
||
"id": "exp-123",
|
||
"name": "test-experiment",
|
||
"dataset_name": "test-dataset",
|
||
},
|
||
"items": [{"id": "item-1"}],
|
||
}
|
||
|
||
with open(experiment_file, "w") as f:
|
||
json.dump(data, f)
|
||
|
||
exp_data = load_experiment_data(experiment_file)
|
||
|
||
assert isinstance(exp_data, ExperimentData)
|
||
assert exp_data.experiment["id"] == "exp-123"
|
||
assert len(exp_data.items) == 1
|
||
|
||
|
||
class TestTranslateTraceId:
|
||
"""Test translate_trace_id function."""
|
||
|
||
def test_translate_trace_id_found(self) -> None:
|
||
"""Test translating trace ID when mapping exists."""
|
||
trace_id_map = {"old-trace-1": "new-trace-1", "old-trace-2": "new-trace-2"}
|
||
|
||
result = utils_translate_trace_id("old-trace-1", trace_id_map)
|
||
|
||
assert result == "new-trace-1"
|
||
|
||
def test_translate_trace_id_not_found(self) -> None:
|
||
"""Test translating trace ID when mapping doesn't exist."""
|
||
trace_id_map = {"old-trace-1": "new-trace-1"}
|
||
|
||
result = utils_translate_trace_id("old-trace-2", trace_id_map)
|
||
|
||
assert result is None
|
||
|
||
def test_translate_trace_id_empty_map(self) -> None:
|
||
"""Test translating trace ID with empty map."""
|
||
trace_id_map: Dict[str, str] = {}
|
||
|
||
result = utils_translate_trace_id("old-trace-1", trace_id_map)
|
||
|
||
assert result is None
|
||
|
||
def test_translate_trace_id_requires_dict(self) -> None:
|
||
"""Test that translate_trace_id requires Dict, not Optional."""
|
||
# This test verifies the type signature is correct
|
||
# If someone tries to pass None, type checker should catch it
|
||
trace_id_map: Dict[str, str] = {} # Required, not Optional
|
||
|
||
result = utils_translate_trace_id("trace-1", trace_id_map)
|
||
|
||
assert result is None
|
||
|
||
|
||
class TestRecreateExperiment:
|
||
"""Test recreate_experiment function."""
|
||
|
||
@staticmethod
|
||
def _extract_items_arg_from_call_args(call_args: Any) -> Any:
|
||
"""Helper to extract the items argument from call_args.
|
||
|
||
Handles both positional and keyword arguments.
|
||
"""
|
||
if hasattr(call_args, "args") and call_args.args:
|
||
return call_args.args[0]
|
||
if hasattr(call_args, "kwargs"):
|
||
if "items" in call_args.kwargs:
|
||
return call_args.kwargs["items"]
|
||
for value in call_args.kwargs.values():
|
||
if isinstance(value, list) and len(value) > 0:
|
||
return value
|
||
return None
|
||
|
||
@pytest.fixture
|
||
def mock_client(self) -> Mock:
|
||
"""Create a mock Opik client."""
|
||
client = Mock()
|
||
# Ensure flush returns True to indicate success
|
||
client.flush = Mock(return_value=True)
|
||
|
||
# Mock dataset
|
||
mock_dataset = Mock()
|
||
mock_dataset.name = "test-dataset"
|
||
mock_dataset.__internal_api__insert_items_as_dataclasses__ = Mock()
|
||
|
||
# Mock experiment
|
||
mock_experiment = Mock()
|
||
mock_experiment.insert = Mock()
|
||
mock_experiment.id = "exp-123"
|
||
|
||
client.get_or_create_dataset = Mock(return_value=mock_dataset)
|
||
client.create_experiment = Mock(return_value=mock_experiment)
|
||
# Mock REST client for experiment items creation
|
||
client._rest_client = Mock()
|
||
client._rest_client.experiments = Mock()
|
||
client._rest_client.experiments.create_experiment_items = Mock()
|
||
|
||
return client
|
||
|
||
@pytest.fixture
|
||
def experiment_data(self) -> ExperimentData:
|
||
"""Create sample experiment data."""
|
||
return ExperimentData(
|
||
experiment={
|
||
"id": "exp-123",
|
||
"name": "test-experiment",
|
||
"dataset_name": "test-dataset",
|
||
"type": "regular",
|
||
},
|
||
items=[
|
||
{
|
||
"trace_id": "trace-1",
|
||
"dataset_item_id": "ds-item-1",
|
||
"dataset_item_data": {
|
||
"input": "test input",
|
||
"expected_output": "test output",
|
||
},
|
||
},
|
||
{
|
||
"trace_id": "trace-2",
|
||
"dataset_item_id": "ds-item-2",
|
||
"dataset_item_data": {"input": "test input 2"},
|
||
},
|
||
],
|
||
)
|
||
|
||
def test_recreate_experiment_requires_trace_id_map(
|
||
self, mock_client: Mock, experiment_data: ExperimentData
|
||
) -> None:
|
||
"""Test that recreate_experiment requires trace_id_map (not Optional)."""
|
||
# This test verifies the type signature
|
||
trace_id_map: Dict[str, str] = {
|
||
"trace-1": "new-trace-1",
|
||
"trace-2": "new-trace-2",
|
||
}
|
||
dataset_item_id_map: Dict[str, str] = {
|
||
"ds-item-1": "new-ds-item-1",
|
||
"ds-item-2": "new-ds-item-2",
|
||
}
|
||
|
||
# Should not accept None - type checker would catch this
|
||
# We test that it works with a dict
|
||
with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers:
|
||
mock_id_helpers.generate_id = Mock(return_value="generated-id")
|
||
|
||
recreate_experiment(
|
||
mock_client,
|
||
experiment_data,
|
||
"test-project",
|
||
trace_id_map, # Required, not Optional
|
||
dataset_item_id_map, # Required for mapping dataset items
|
||
dry_run=False,
|
||
debug=False,
|
||
)
|
||
|
||
# Verify experiment items were created via REST API
|
||
assert mock_client._rest_client.experiments.create_experiment_items.called
|
||
|
||
def test_recreate_experiment_batches_dataset_items(
|
||
self, mock_client: Mock, experiment_data: ExperimentData
|
||
) -> None:
|
||
"""Test that experiment items are created in batch, not one at a time."""
|
||
with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers:
|
||
mock_id_helpers.generate_id = Mock(side_effect=["exp-item-1", "exp-item-2"])
|
||
|
||
trace_id_map = {"trace-1": "new-trace-1", "trace-2": "new-trace-2"}
|
||
dataset_item_id_map = {
|
||
"ds-item-1": "new-ds-item-1",
|
||
"ds-item-2": "new-ds-item-2",
|
||
}
|
||
|
||
recreate_experiment(
|
||
mock_client,
|
||
experiment_data,
|
||
"test-project",
|
||
trace_id_map,
|
||
dataset_item_id_map,
|
||
dry_run=False,
|
||
debug=False,
|
||
)
|
||
|
||
# Verify batch insert was called ONCE with all items
|
||
assert (
|
||
mock_client._rest_client.experiments.create_experiment_items.call_count
|
||
== 1
|
||
)
|
||
|
||
# Verify it was called with a list of items (batch)
|
||
call_args = (
|
||
mock_client._rest_client.experiments.create_experiment_items.call_args
|
||
)
|
||
assert call_args is not None
|
||
|
||
# Extract experiment_items argument from call_args
|
||
experiment_items_arg = None
|
||
if hasattr(call_args, "kwargs") and "experiment_items" in call_args.kwargs:
|
||
experiment_items_arg = call_args.kwargs["experiment_items"]
|
||
elif hasattr(call_args, "args") and call_args.args:
|
||
experiment_items_arg = call_args.args[0]
|
||
|
||
assert experiment_items_arg is not None, (
|
||
f"Could not find experiment_items in call_args: {call_args}"
|
||
)
|
||
assert len(experiment_items_arg) == 2, (
|
||
f"Expected 2 items in batch, got {len(experiment_items_arg)}"
|
||
)
|
||
|
||
def test_recreate_experiment_uses_module_names_correctly(
|
||
self, mock_client: Mock, experiment_data: ExperimentData
|
||
) -> None:
|
||
"""Test that module names (id_helpers_module) are used correctly."""
|
||
with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers:
|
||
mock_id_helpers.generate_id = Mock(return_value="generated-id")
|
||
|
||
trace_id_map = {"trace-1": "new-trace-1"}
|
||
dataset_item_id_map = {"ds-item-1": "new-ds-item-1"}
|
||
|
||
recreate_experiment(
|
||
mock_client,
|
||
experiment_data,
|
||
"test-project",
|
||
trace_id_map,
|
||
dataset_item_id_map,
|
||
dry_run=False,
|
||
debug=False,
|
||
)
|
||
|
||
# Verify id_helpers module is used (not checked for None)
|
||
assert mock_id_helpers.generate_id.called
|
||
|
||
def test_recreate_experiment_handles_empty_trace_id_map(
|
||
self, mock_client: Mock, experiment_data: ExperimentData
|
||
) -> None:
|
||
"""Test that empty trace_id_map is handled correctly."""
|
||
trace_id_map: Dict[str, str] = {} # Empty but valid
|
||
dataset_item_id_map: Dict[str, str] = {} # Empty but valid
|
||
|
||
recreate_experiment(
|
||
mock_client,
|
||
experiment_data,
|
||
"test-project",
|
||
trace_id_map,
|
||
dataset_item_id_map,
|
||
dry_run=False,
|
||
debug=False,
|
||
)
|
||
|
||
# Should still create experiment and dataset, but skip items
|
||
assert mock_client.get_or_create_dataset.called
|
||
assert mock_client.create_experiment.called
|
||
|
||
def test_recreate_experiment_dry_run(
|
||
self, mock_client: Mock, experiment_data: ExperimentData
|
||
) -> None:
|
||
"""Test dry run mode."""
|
||
trace_id_map = {"trace-1": "new-trace-1"}
|
||
dataset_item_id_map = {"ds-item-1": "new-ds-item-1"}
|
||
|
||
result = recreate_experiment(
|
||
mock_client,
|
||
experiment_data,
|
||
"test-project",
|
||
trace_id_map,
|
||
dataset_item_id_map,
|
||
dry_run=True,
|
||
debug=False,
|
||
)
|
||
|
||
assert result is True
|
||
# Should not create anything in dry run
|
||
assert not mock_client.get_or_create_dataset.called
|
||
assert not mock_client.create_experiment.called
|
||
|
||
def test_recreate_experiment_chunks_items_within_be_cap(
|
||
self, mock_client: Mock
|
||
) -> None:
|
||
# BE rejects a single ``create_experiment_items`` POST whose item
|
||
# count exceeds ``ExperimentItemsBatch``'s ``@Size(max=…)``. The
|
||
# actual cap value is BE-configured and may change over time, so
|
||
# we read it from the module-level constant the chunker uses and
|
||
# assert behavior against that, not against a hardcoded literal.
|
||
# Contract: every batch ``<= cap``, total across batches ==
|
||
# full item count, multiple batches when items > cap.
|
||
from opik.cli.imports.experiment import _EXPERIMENT_ITEMS_INSERT_BATCH_SIZE
|
||
|
||
cap = _EXPERIMENT_ITEMS_INSERT_BATCH_SIZE
|
||
# 2.5 × cap exercises the multi-batch path with a partial-last-
|
||
# batch remainder; any value above ``cap`` works for the contract.
|
||
n_items = cap * 5 // 2
|
||
experiment_data = ExperimentData(
|
||
experiment={
|
||
"id": "exp-big",
|
||
"name": "big-experiment",
|
||
"dataset_name": "test-dataset",
|
||
"type": "regular",
|
||
},
|
||
items=[
|
||
{
|
||
"trace_id": f"trace-{i}",
|
||
"dataset_item_id": f"ds-item-{i}",
|
||
"dataset_item_data": {"input": f"test input {i}"},
|
||
}
|
||
for i in range(n_items)
|
||
],
|
||
)
|
||
trace_id_map = {f"trace-{i}": f"new-trace-{i}" for i in range(n_items)}
|
||
dataset_item_id_map = {
|
||
f"ds-item-{i}": f"new-ds-item-{i}" for i in range(n_items)
|
||
}
|
||
|
||
with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers:
|
||
mock_id_helpers.generate_id = Mock(
|
||
side_effect=[f"exp-item-{i}" for i in range(n_items)]
|
||
)
|
||
|
||
recreate_experiment(
|
||
mock_client,
|
||
experiment_data,
|
||
"test-project",
|
||
trace_id_map,
|
||
dataset_item_id_map,
|
||
dry_run=False,
|
||
debug=False,
|
||
)
|
||
|
||
create_calls = mock_client._rest_client.experiments.create_experiment_items.call_args_list
|
||
# ceil(n_items / cap) batched POSTs.
|
||
expected_batches = (n_items + cap - 1) // cap
|
||
assert len(create_calls) == expected_batches, (
|
||
f"expected {expected_batches} chunked create_experiment_items "
|
||
f"calls for {n_items} items at cap={cap}, got {len(create_calls)}"
|
||
)
|
||
# Every batch must respect the BE cap; total must equal the
|
||
# full item count (nothing dropped, nothing duplicated).
|
||
batch_sizes = [
|
||
len(call.kwargs.get("experiment_items", []))
|
||
if call.kwargs.get("experiment_items") is not None
|
||
else len(call.args[0])
|
||
for call in create_calls
|
||
]
|
||
assert all(size <= cap for size in batch_sizes), (
|
||
f"every batch must respect the BE cap (={cap}), got {batch_sizes}"
|
||
)
|
||
assert sum(batch_sizes) == n_items, (
|
||
f"all {n_items} items must end up across the chunked batches, "
|
||
f"got sum={sum(batch_sizes)}"
|
||
)
|
||
|
||
|
||
class TestImportTracesWithSpans:
|
||
"""Test trace import with span parent_span_id preservation."""
|
||
|
||
@pytest.fixture
|
||
def mock_client(self) -> Mock:
|
||
"""Create a mock Opik client."""
|
||
client = Mock()
|
||
client.flush = Mock()
|
||
|
||
# Mock trace creation
|
||
mock_trace = Mock()
|
||
mock_trace.id = "new-trace-1"
|
||
client.trace = Mock(return_value=mock_trace)
|
||
|
||
# Mock span creation
|
||
mock_spans = []
|
||
for i in range(3):
|
||
mock_span = Mock()
|
||
mock_span.id = f"new-span-{i + 1}"
|
||
mock_spans.append(mock_span)
|
||
|
||
client.span = Mock(side_effect=mock_spans)
|
||
|
||
return client
|
||
|
||
def test_import_traces_preserves_span_hierarchy(
|
||
self, mock_client: Mock, tmp_path: Path
|
||
) -> None:
|
||
"""Test that span parent_span_id relationships are preserved."""
|
||
# Create test trace file with spans
|
||
projects_dir = tmp_path / "projects" / "test-project"
|
||
projects_dir.mkdir(parents=True)
|
||
|
||
trace_data = {
|
||
"trace": {
|
||
"id": "original-trace-1",
|
||
"name": "test-trace",
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
"spans": [
|
||
{
|
||
"id": "span-1",
|
||
"name": "root-span",
|
||
"parent_span_id": None, # Root span
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
{
|
||
"id": "span-2",
|
||
"name": "child-span",
|
||
"parent_span_id": "span-1", # Child of span-1
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
{
|
||
"id": "span-3",
|
||
"name": "grandchild-span",
|
||
"parent_span_id": "span-2", # Child of span-2
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
],
|
||
}
|
||
|
||
trace_file = projects_dir / "trace_original-trace-1.json"
|
||
with open(trace_file, "w") as f:
|
||
json.dump(trace_data, f)
|
||
|
||
# Import traces
|
||
trace_id_map, _ = _import_traces_for_project(
|
||
mock_client, projects_dir, "test-project", dry_run=False, debug=False
|
||
)
|
||
|
||
# Verify spans were created
|
||
assert mock_client.span.call_count == 3
|
||
|
||
# Verify spans were created in correct order (root first, then children)
|
||
span_calls = mock_client.span.call_args_list
|
||
|
||
# First span should be root (no parent_span_id)
|
||
first_call = span_calls[0]
|
||
assert first_call.kwargs.get("parent_span_id") is None
|
||
|
||
# Second span should have parent_span_id set to first span's new ID
|
||
# Note: We can't easily verify the exact ID mapping without more complex mocking,
|
||
# but we can verify that parent_span_id is being passed
|
||
second_call = span_calls[1]
|
||
# The parent_span_id should be set (not None) since span-1 was created first
|
||
# and its new ID should be in span_id_map
|
||
assert "parent_span_id" in second_call.kwargs
|
||
|
||
# Third span (grandchild) should have parent_span_id set to second span's new ID
|
||
third_call = span_calls[2]
|
||
assert "parent_span_id" in third_call.kwargs
|
||
assert third_call.kwargs.get("parent_span_id") is not None
|
||
|
||
# Verify trace was created
|
||
assert mock_client.trace.called
|
||
assert "original-trace-1" in trace_id_map
|
||
|
||
def test_import_traces_preserves_deep_hierarchy(
|
||
self, mock_client: Mock, tmp_path: Path
|
||
) -> None:
|
||
"""Test that deep hierarchies (4+ levels) are preserved correctly."""
|
||
projects_dir = tmp_path / "projects" / "test-project"
|
||
projects_dir.mkdir(parents=True)
|
||
|
||
# Create a 4-level hierarchy: root -> child -> grandchild -> great-grandchild
|
||
# Spans are intentionally in wrong order to test sorting
|
||
trace_data = {
|
||
"trace": {
|
||
"id": "original-trace-1",
|
||
"name": "test-trace",
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
"spans": [
|
||
{
|
||
"id": "span-4",
|
||
"name": "great-grandchild",
|
||
"parent_span_id": "span-3",
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
{
|
||
"id": "span-2",
|
||
"name": "child",
|
||
"parent_span_id": "span-1",
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
{
|
||
"id": "span-1",
|
||
"name": "root",
|
||
"parent_span_id": None,
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
{
|
||
"id": "span-3",
|
||
"name": "grandchild",
|
||
"parent_span_id": "span-2",
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
],
|
||
}
|
||
|
||
trace_file = projects_dir / "trace_original-trace-1.json"
|
||
with open(trace_file, "w") as f:
|
||
json.dump(trace_data, f)
|
||
|
||
# Import traces
|
||
trace_id_map, _ = _import_traces_for_project(
|
||
mock_client, projects_dir, "test-project", dry_run=False, debug=False
|
||
)
|
||
|
||
# Verify all spans were created
|
||
assert mock_client.span.call_count == 4
|
||
|
||
span_calls = mock_client.span.call_args_list
|
||
|
||
# Verify order: root -> child -> grandchild -> great-grandchild
|
||
# First span should be root (no parent)
|
||
assert span_calls[0].kwargs.get("parent_span_id") is None
|
||
|
||
# Second span should be child (has parent)
|
||
assert span_calls[1].kwargs.get("parent_span_id") is not None
|
||
|
||
# Third span should be grandchild (has parent)
|
||
assert span_calls[2].kwargs.get("parent_span_id") is not None
|
||
|
||
# Fourth span should be great-grandchild (has parent)
|
||
assert span_calls[3].kwargs.get("parent_span_id") is not None
|
||
|
||
# Verify trace was created
|
||
assert mock_client.trace.called
|
||
assert "original-trace-1" in trace_id_map
|
||
|
||
def test_import_traces_sorts_spans_correctly(
|
||
self, mock_client: Mock, tmp_path: Path
|
||
) -> None:
|
||
"""Test that spans are sorted (root spans first, then children)."""
|
||
projects_dir = tmp_path / "projects" / "test-project"
|
||
projects_dir.mkdir(parents=True)
|
||
|
||
trace_data = {
|
||
"trace": {
|
||
"id": "original-trace-1",
|
||
"name": "test-trace",
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
"spans": [
|
||
{
|
||
"id": "span-2",
|
||
"name": "child-span",
|
||
"parent_span_id": "span-1", # Child - should come after root
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
{
|
||
"id": "span-1",
|
||
"name": "root-span",
|
||
"parent_span_id": None, # Root - should come first
|
||
"input": {},
|
||
"output": {},
|
||
},
|
||
],
|
||
}
|
||
|
||
trace_file = projects_dir / "trace_original-trace-1.json"
|
||
with open(trace_file, "w") as f:
|
||
json.dump(trace_data, f)
|
||
|
||
# Import traces
|
||
_, _ = _import_traces_for_project(
|
||
mock_client, projects_dir, "test-project", dry_run=False, debug=False
|
||
) # Returns (trace_id_map, stats), but we don't need them for this test
|
||
|
||
# Verify spans were created in correct order
|
||
span_calls = mock_client.span.call_args_list
|
||
|
||
# First span should be root (no parent)
|
||
assert span_calls[0].kwargs.get("parent_span_id") is None
|
||
|
||
# Second span should have parent_span_id
|
||
assert span_calls[1].kwargs.get("parent_span_id") is not None
|
||
|
||
|
||
class TestTopologicalSort:
|
||
"""Test the topological sort function for spans."""
|
||
|
||
def test_sort_spans_simple_hierarchy(self) -> None:
|
||
"""Test sorting with a simple 2-level hierarchy."""
|
||
spans = [
|
||
{"id": "span-2", "name": "child", "parent_span_id": "span-1"},
|
||
{"id": "span-1", "name": "root", "parent_span_id": None},
|
||
]
|
||
|
||
sorted_spans = sort_spans_topologically(spans)
|
||
|
||
# Root should come first
|
||
assert sorted_spans[0]["id"] == "span-1"
|
||
assert sorted_spans[0]["parent_span_id"] is None
|
||
# Child should come second
|
||
assert sorted_spans[1]["id"] == "span-2"
|
||
assert sorted_spans[1]["parent_span_id"] == "span-1"
|
||
|
||
def test_sort_spans_multi_level_hierarchy(self) -> None:
|
||
"""Test sorting with a 4-level hierarchy (root -> child -> grandchild -> great-grandchild)."""
|
||
spans = [
|
||
{"id": "span-4", "name": "great-grandchild", "parent_span_id": "span-3"},
|
||
{"id": "span-2", "name": "child", "parent_span_id": "span-1"},
|
||
{"id": "span-1", "name": "root", "parent_span_id": None},
|
||
{"id": "span-3", "name": "grandchild", "parent_span_id": "span-2"},
|
||
]
|
||
|
||
sorted_spans = sort_spans_topologically(spans)
|
||
|
||
# Verify order: root -> child -> grandchild -> great-grandchild
|
||
assert sorted_spans[0]["id"] == "span-1"
|
||
assert sorted_spans[0]["parent_span_id"] is None
|
||
|
||
assert sorted_spans[1]["id"] == "span-2"
|
||
assert sorted_spans[1]["parent_span_id"] == "span-1"
|
||
|
||
assert sorted_spans[2]["id"] == "span-3"
|
||
assert sorted_spans[2]["parent_span_id"] == "span-2"
|
||
|
||
assert sorted_spans[3]["id"] == "span-4"
|
||
assert sorted_spans[3]["parent_span_id"] == "span-3"
|
||
|
||
def test_sort_spans_multiple_roots(self) -> None:
|
||
"""Test sorting with multiple root spans."""
|
||
spans = [
|
||
{"id": "span-3", "name": "child-of-2", "parent_span_id": "span-2"},
|
||
{"id": "span-1", "name": "root-1", "parent_span_id": None},
|
||
{"id": "span-2", "name": "root-2", "parent_span_id": None},
|
||
]
|
||
|
||
sorted_spans = sort_spans_topologically(spans)
|
||
|
||
# Both roots should come before the child
|
||
root_ids = {sorted_spans[0]["id"], sorted_spans[1]["id"]}
|
||
assert root_ids == {"span-1", "span-2"}
|
||
assert sorted_spans[0]["parent_span_id"] is None
|
||
assert sorted_spans[1]["parent_span_id"] is None
|
||
|
||
# Child should come last
|
||
assert sorted_spans[2]["id"] == "span-3"
|
||
assert sorted_spans[2]["parent_span_id"] == "span-2"
|
||
|
||
def test_sort_spans_multiple_children(self) -> None:
|
||
"""Test sorting with a root that has multiple children."""
|
||
spans = [
|
||
{"id": "span-3", "name": "child-2", "parent_span_id": "span-1"},
|
||
{"id": "span-1", "name": "root", "parent_span_id": None},
|
||
{"id": "span-2", "name": "child-1", "parent_span_id": "span-1"},
|
||
]
|
||
|
||
sorted_spans = sort_spans_topologically(spans)
|
||
|
||
# Root should come first
|
||
assert sorted_spans[0]["id"] == "span-1"
|
||
assert sorted_spans[0]["parent_span_id"] is None
|
||
|
||
# Both children should come after root (order doesn't matter for siblings)
|
||
child_ids = {sorted_spans[1]["id"], sorted_spans[2]["id"]}
|
||
assert child_ids == {"span-2", "span-3"}
|
||
assert sorted_spans[1]["parent_span_id"] == "span-1"
|
||
assert sorted_spans[2]["parent_span_id"] == "span-1"
|
||
|
||
def test_sort_spans_missing_parent(self) -> None:
|
||
"""Test sorting when a span references a non-existent parent."""
|
||
spans = [
|
||
{"id": "span-1", "name": "root", "parent_span_id": None},
|
||
{"id": "span-2", "name": "orphan", "parent_span_id": "nonexistent"},
|
||
]
|
||
|
||
sorted_spans = sort_spans_topologically(spans)
|
||
|
||
# Both should be treated as roots (orphan becomes root)
|
||
assert len(sorted_spans) == 2
|
||
# Both should have no parent or invalid parent
|
||
for span in sorted_spans:
|
||
assert (
|
||
span["parent_span_id"] is None
|
||
or span["parent_span_id"] == "nonexistent"
|
||
)
|
||
|
||
def test_sort_spans_empty_list(self) -> None:
|
||
"""Test sorting with empty list."""
|
||
spans: list = []
|
||
sorted_spans = sort_spans_topologically(spans)
|
||
assert sorted_spans == []
|
||
|
||
def test_sort_spans_single_root(self) -> None:
|
||
"""Test sorting with single root span."""
|
||
spans = [{"id": "span-1", "name": "root", "parent_span_id": None}]
|
||
sorted_spans = sort_spans_topologically(spans)
|
||
assert len(sorted_spans) == 1
|
||
assert sorted_spans[0]["id"] == "span-1"
|
||
assert sorted_spans[0]["parent_span_id"] is None
|
||
|
||
def test_sort_spans_all_have_parents(self) -> None:
|
||
"""Test sorting when all spans have parents (no explicit root).
|
||
|
||
This tests the fix for the bug where empty root_spans would cause
|
||
the function to return an empty list, silently dropping all spans.
|
||
"""
|
||
spans = [
|
||
{"id": "span-1", "name": "child-1", "parent_span_id": "span-2"},
|
||
{"id": "span-2", "name": "child-2", "parent_span_id": "span-1"},
|
||
]
|
||
|
||
sorted_spans = sort_spans_topologically(spans)
|
||
|
||
# Should not return empty list - all spans should be included
|
||
assert len(sorted_spans) == 2
|
||
# Verify all spans are present
|
||
span_ids = {span["id"] for span in sorted_spans}
|
||
assert span_ids == {"span-1", "span-2"}
|
||
|
||
def test_sort_spans_cycle(self) -> None:
|
||
"""Test sorting with a cycle in the span graph.
|
||
|
||
This tests that cycles don't cause infinite loops and all spans
|
||
are still included in the result.
|
||
"""
|
||
spans = [
|
||
{"id": "span-1", "name": "span-1", "parent_span_id": "span-2"},
|
||
{"id": "span-2", "name": "span-2", "parent_span_id": "span-3"},
|
||
{"id": "span-3", "name": "span-3", "parent_span_id": "span-1"},
|
||
]
|
||
|
||
sorted_spans = sort_spans_topologically(spans)
|
||
|
||
# Should not return empty list - all spans should be included
|
||
assert len(sorted_spans) == 3
|
||
# Verify all spans are present
|
||
span_ids = {span["id"] for span in sorted_spans}
|
||
assert span_ids == {"span-1", "span-2", "span-3"}
|
||
|
||
def test_sort_spans_disconnected_components(self) -> None:
|
||
"""Test sorting with disconnected components (multiple separate graphs).
|
||
|
||
This tests that spans not reachable from root spans are still included.
|
||
"""
|
||
spans = [
|
||
{"id": "span-1", "name": "root-1", "parent_span_id": None},
|
||
{"id": "span-2", "name": "child-1", "parent_span_id": "span-1"},
|
||
{"id": "span-3", "name": "disconnected-1", "parent_span_id": "span-4"},
|
||
{"id": "span-4", "name": "disconnected-2", "parent_span_id": "span-3"},
|
||
]
|
||
|
||
sorted_spans = sort_spans_topologically(spans)
|
||
|
||
# All spans should be included
|
||
assert len(sorted_spans) == 4
|
||
# Verify all spans are present
|
||
span_ids = {span["id"] for span in sorted_spans}
|
||
assert span_ids == {"span-1", "span-2", "span-3", "span-4"}
|
||
# Root span should come first
|
||
assert sorted_spans[0]["id"] == "span-1"
|
||
assert sorted_spans[0]["parent_span_id"] is None
|
||
|
||
|
||
class TestModuleNameUsage:
|
||
"""Test that module names are used correctly (not checked for None)."""
|
||
|
||
def test_module_names_are_modules_not_variables(self) -> None:
|
||
"""Test that dataset_item_module and id_helpers_module are modules."""
|
||
from opik.cli.imports.experiment import dataset_item_module, id_helpers_module
|
||
|
||
# Modules should exist and be importable
|
||
assert dataset_item_module is not None
|
||
assert id_helpers_module is not None
|
||
|
||
# They should be modules, not None
|
||
assert isinstance(dataset_item_module, types.ModuleType)
|
||
assert isinstance(id_helpers_module, types.ModuleType)
|
||
|
||
|
||
class TestProjectTraceImport:
|
||
"""Test importing a project's traces from its project directory."""
|
||
|
||
@pytest.fixture
|
||
def mock_client(self) -> Mock:
|
||
"""Create a minimal mock Opik client."""
|
||
client = Mock()
|
||
client.flush = Mock()
|
||
mock_trace = Mock()
|
||
mock_trace.id = "new-trace-id"
|
||
client.trace = Mock(return_value=mock_trace)
|
||
client.span = Mock()
|
||
return client
|
||
|
||
def test_trace_files_in_project_dir_are_imported(
|
||
self, mock_client: Mock, tmp_path: Path
|
||
) -> None:
|
||
"""trace_{id}.json files directly under the project dir are imported and
|
||
appear in the returned trace_id_map, and the trace is created in the
|
||
named project."""
|
||
project_dir = tmp_path / "projects" / "my-project"
|
||
project_dir.mkdir(parents=True)
|
||
|
||
trace_id = "abc123"
|
||
trace_data: Dict[str, Any] = {
|
||
"trace": {"id": trace_id, "name": "t", "input": {}, "output": {}},
|
||
"spans": [],
|
||
}
|
||
trace_file = project_dir / f"trace_{trace_id}.json"
|
||
with open(trace_file, "w") as f:
|
||
json.dump(trace_data, f)
|
||
|
||
trace_id_map, _ = _import_traces_for_project(
|
||
mock_client, project_dir, "my-project", dry_run=False, debug=False
|
||
)
|
||
|
||
# The original trace ID must appear in the returned map
|
||
assert trace_id in trace_id_map
|
||
# The trace must be created in the named project (no "default" fallback)
|
||
assert mock_client.trace.call_args.kwargs.get("project_name") == "my-project"
|
||
|
||
def test_empty_project_dir_returns_empty_map(
|
||
self, mock_client: Mock, tmp_path: Path
|
||
) -> None:
|
||
"""A project dir with no trace files yields an empty map without error."""
|
||
project_dir = tmp_path / "projects" / "empty-project"
|
||
project_dir.mkdir(parents=True)
|
||
|
||
trace_id_map, stats = _import_traces_for_project(
|
||
mock_client, project_dir, "empty-project", dry_run=False, debug=False
|
||
)
|
||
|
||
assert trace_id_map == {}
|
||
assert stats["traces"] == 0
|
||
assert not mock_client.trace.called
|