Files
comet-ml--opik/sdks/python/tests/e2e/cli/test_migrate_dataset_e2e.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:25:44 +08:00

484 lines
20 KiB
Python

"""End-to-end tests for ``opik migrate dataset`` against a real Opik backend.
Covers the plain-dataset path: full version replay, plus the experiment +
trace + span cascade that rides along with the dataset. The test-suite
path lives in ``test_migrate_test_suite_e2e.py``.
Each test:
1. Seeds a multi-version source dataset directly via the REST API (so we
control every per-version delta the migration has to replay) and,
where the test calls for it, an experiment + traces + spans attached
to one of the source versions
2. Runs ``opik migrate dataset`` as a subprocess so the actual CLI
entrypoint, Click group, and exit-code handling are exercised
3. Reads back the target via the raw REST stream + wire type (the SDK
helper drops per-item tags) and asserts per-version content +
display-order fidelity, plus -- where relevant -- destination
experiment + trace + span fidelity and FK remapping
Shared helpers live in ``conftest.py``.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Iterator
import pytest
import opik
from opik import synchronization
from ...conftest import random_chars
from ...testlib import generate_project_name
from ._cascade_comparison import compare_cascade
from .conftest import (
apply_changes,
chronological_versions,
create_dataset_shell,
destination_experiment_items,
destination_feedback_scores_for_trace,
destination_spans_for_trace,
display_order,
find_destination_experiment,
item_hashes,
run_migrate_cli,
seed_experiment_with_trace_tree,
stream_items_wire,
)
# Per ``sdks/python/AGENTS.md``: every e2e module sources PROJECT_NAME from
# ``generate_project_name("e2e", __name__)`` so backend project names are
# isolated per test module + the autouse ``configure_e2e_tests_env`` fixture
# can patch ``OPIK_PROJECT_NAME`` to match.
PROJECT_NAME = generate_project_name("e2e", __name__)
@pytest.fixture
def dataset_name() -> Iterator[str]:
yield f"e2e-migrate-{random_chars()}"
class TestMigrateDatasetVersionReplay:
"""Default ``opik migrate dataset`` flow: full version-history replay.
Pin the slice 2 contract: target version count == source version
count, per-version content set-equal under hash, display order
preserved at every version.
"""
def test_three_version_dataset_with_mixed_deltas_round_trips(
self,
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
dataset_name: str,
tmp_path: Path,
) -> None:
rest = opik_client.rest_client
# Seed: 3 versions exercising every delta kind.
# v1: adds Q1, Q2, Q3.
# v2: edits Q1 (data change), adds Q4.
# v3: edits Q3 (data change), deletes Q2, adds Q5.
source_id = create_dataset_shell(rest, dataset_name, source_project_name)
# v1 of a plain dataset is created via the REST insert path (mirrors
# what ``Dataset.insert`` does at higher level). Use create_or_update
# so we get exactly one BE version with all three items.
from opik import id_helpers
from opik.rest_api.types.dataset_item_write import DatasetItemWrite
rest.datasets.create_or_update_dataset_items(
dataset_id=source_id,
items=[
DatasetItemWrite(source="manual", data={"q": "Q1", "a": "A1"}),
DatasetItemWrite(source="manual", data={"q": "Q2", "a": "A2"}),
DatasetItemWrite(source="manual", data={"q": "Q3", "a": "A3"}),
],
batch_group_id=id_helpers.generate_id(),
)
v1 = rest.datasets.list_dataset_versions(id=source_id, page=1, size=1).content[
0
]
v1_items = stream_items_wire(
rest,
dataset_name=dataset_name,
project_name=source_project_name,
version_hash=v1.version_hash,
)
by_q = {item.data["q"]: item for item in v1_items if item.data}
# v2: edit Q1's answer + add Q4.
v2_id = apply_changes(
rest,
source_id,
base_version_id=v1.id,
edited_items=[
{"id": by_q["Q1"].id, "data": {"q": "Q1", "a": "A1-EDITED"}},
],
added_items=[{"data": {"q": "Q4", "a": "A4"}, "source": "manual"}],
change_description="v2 — edit Q1, add Q4",
)
# v3: edit Q3, delete Q2, add Q5.
apply_changes(
rest,
source_id,
base_version_id=v2_id,
edited_items=[
{"id": by_q["Q3"].id, "data": {"q": "Q3", "a": "A3-EDITED"}},
],
deleted_ids=[by_q["Q2"].id],
added_items=[{"data": {"q": "Q5", "a": "A5"}, "source": "manual"}],
change_description="v3 — delete Q2, edit Q3, add Q5",
)
# Snapshot source expectations per version.
src_versions = chronological_versions(rest, source_id)
assert len(src_versions) == 3
expected_hashes = []
expected_orders = []
for v in src_versions:
items = stream_items_wire(
rest,
dataset_name=dataset_name,
project_name=source_project_name,
version_hash=v.version_hash,
)
expected_hashes.append(item_hashes(items))
expected_orders.append(display_order(items))
# ── Seed an experiment on v1 items so the cascade has something to
# round-trip. Regular-dataset experiments carry per-trace feedback
# scores (test suites carry assertion_results -- covered in
# test_migrate_test_suite_e2e.py). Each item gets one trace with a
# root + 1 LLM child span and a feedback score on the trace. ──
experiment_name = f"e2e-exp-{random_chars()}"
v1_item_ids = [by_q["Q1"].id, by_q["Q2"].id, by_q["Q3"].id]
cascade_seed = seed_experiment_with_trace_tree(
rest,
experiment_name=experiment_name,
dataset_name=dataset_name,
dataset_id=source_id,
dataset_version_id=v1.id,
project_name=source_project_name,
item_ids=v1_item_ids,
experiment_config={"runner": "e2e-cascade-test"},
experiment_tags=["e2e", "cascade"],
spans_per_trace=2, # root + 1 LLM child -> exercises parent_span_id remap
feedback_scores_per_trace=[
{"name": "correctness", "value": 0.9, "reason": "matches reference"},
{"name": "latency_p95", "value": 230.5},
],
)
# Run the migration.
audit_path = tmp_path / "audit.json"
result = run_migrate_cli(
[
"dataset",
dataset_name,
"--to-project",
target_project_name,
],
audit_log_path=str(audit_path),
)
assert result.returncode == 0, result.stdout + result.stderr
# Verify target: same version count, per-version content set-equal
# under hash, display order matches at every version.
target = rest.datasets.get_dataset_by_identifier(
dataset_name=dataset_name, project_name=target_project_name
)
tgt_versions = chronological_versions(rest, target.id)
assert len(tgt_versions) == len(src_versions), (
f"target version count {len(tgt_versions)} != source {len(src_versions)} "
"— Slice 2 contract requires N=N"
)
for src_v, tgt_v, exp_hashes, exp_order in zip(
src_versions, tgt_versions, expected_hashes, expected_orders
):
items = stream_items_wire(
rest,
dataset_name=dataset_name,
project_name=target_project_name,
version_hash=tgt_v.version_hash,
)
actual_hashes = item_hashes(items)
actual_order = display_order(items)
assert actual_hashes == exp_hashes, (
f"version {tgt_v.version_name}: target items don't match source. "
f"Missing on target: {exp_hashes - actual_hashes}; "
f"extra on target: {actual_hashes - exp_hashes}"
)
assert actual_order == exp_order, (
f"version {tgt_v.version_name}: display order diverged "
f"(source: {exp_order}, target: {actual_order})"
)
# Audit log records one per-version entry per replayed source version.
audit = json.loads(audit_path.read_text())
per_version_records = [
a for a in audit["actions"] if a["type"] == "replay_dataset_version"
]
assert len(per_version_records) == len(src_versions)
# Per-version deltas: v1=(3 adds), v2=(1 add, 1 mod), v3=(1 add, 1 mod, 1 del).
assert (
per_version_records[0]["items_added"],
per_version_records[0]["items_modified"],
per_version_records[0]["items_deleted"],
) == (3, 0, 0)
assert (
per_version_records[1]["items_added"],
per_version_records[1]["items_modified"],
per_version_records[1]["items_deleted"],
) == (1, 1, 0)
assert (
per_version_records[2]["items_added"],
per_version_records[2]["items_modified"],
per_version_records[2]["items_deleted"],
) == (1, 1, 1)
# ── Cascade fidelity ──
# The destination project should now have a copy of the source
# experiment with: a remapped dataset_version_id, fresh item ids
# carrying remapped trace ids, traces+spans landing under the
# destination project, feedback scores re-emitted on the destination
# traces, and per-item write-side fidelity (input/output) preserved.
dest_exp = find_destination_experiment(
rest,
destination_dataset_id=target.id,
experiment_name=experiment_name,
)
# FKs remapped.
assert dest_exp.id != cascade_seed["experiment_id"]
assert dest_exp.dataset_id == target.id
# The destination experiment must reference one of the target
# versions (the cascade picks the remap of v1).
target_version_ids = {v.id for v in tgt_versions}
assert dest_exp.dataset_version_id in target_version_ids
# Items: one per source item, with FRESH trace ids (disjoint from
# source). Per-item input/output/usage/cost are READ-ONLY on the BE
# (computed/aggregated from the underlying trace + span entities);
# we assert the trace + span fidelity below instead.
dest_items = destination_experiment_items(
rest,
experiment_id=dest_exp.id,
dataset_id=target.id,
)
assert len(dest_items) == len(v1_item_ids)
dest_trace_ids = {it.trace_id for it in dest_items}
assert dest_trace_ids.isdisjoint(set(cascade_seed["trace_ids"])), (
"destination experiment items should reference new trace ids, "
"not the source's"
)
# Each destination trace exists under the target project and has the
# same span shape as the source (root + 1 child = 2 spans).
for new_trace_id in dest_trace_ids:
dest_spans = destination_spans_for_trace(
rest,
trace_id=new_trace_id,
project_name=target_project_name,
)
assert len(dest_spans) == 2, (
f"trace {new_trace_id} should have 2 spans (root + child), "
f"got {len(dest_spans)}"
)
# Topological remap: exactly one root (parent_span_id=None),
# the other span points at the root via parent_span_id.
roots = [s for s in dest_spans if s.parent_span_id is None]
assert len(roots) == 1, f"trace {new_trace_id} should have one root span"
children = [s for s in dest_spans if s.parent_span_id is not None]
assert all(c.parent_span_id == roots[0].id for c in children), (
f"trace {new_trace_id} child spans should remap parent_span_id "
"to the new root id"
)
# Trace-level feedback scores re-emitted on the destination trace.
dest_scores = destination_feedback_scores_for_trace(
rest, trace_id=new_trace_id
)
score_names = {s.name for s in dest_scores}
assert score_names == {"correctness", "latency_p95"}, (
f"trace {new_trace_id}: expected feedback score names "
f"{{'correctness', 'latency_p95'}}, got {score_names}"
)
# ── Deep-equal source vs. destination ──
# Verify field-by-field that experiment + items + traces + spans
# round-trip the cascade modulo remapped IDs. Pairing strategy:
# both sides sorted by trace ``name`` (assigned by the seed as
# "task-0", "task-1", "task-2" and carried verbatim through the
# cascade), guaranteeing stable positional correspondence.
src_exp = find_destination_experiment(
rest,
destination_dataset_id=source_id,
experiment_name=experiment_name,
)
src_items_compare = destination_experiment_items(
rest,
experiment_id=cascade_seed["experiment_id"],
dataset_id=source_id,
)
# Sort both sides by trace name for stable pairing. Build a
# trace_id -> name map by reading each trace once.
src_trace_names = {
it.trace_id: rest.traces.get_trace_by_id(id=it.trace_id).name
for it in src_items_compare
}
dst_trace_names = {
it.trace_id: rest.traces.get_trace_by_id(id=it.trace_id).name
for it in dest_items
}
src_items_compare.sort(key=lambda it: src_trace_names[it.trace_id])
dest_items_sorted = sorted(
dest_items, key=lambda it: dst_trace_names[it.trace_id]
)
src_trace_ids_sorted = [it.trace_id for it in src_items_compare]
dst_trace_ids_sorted = [it.trace_id for it in dest_items_sorted]
compare_cascade(
rest_client=rest,
source_experiment=src_exp,
destination_experiment=dest_exp,
source_item_ids=v1_item_ids,
destination_item_ids=[it.dataset_item_id for it in dest_items_sorted],
source_trace_ids=src_trace_ids_sorted,
destination_trace_ids=dst_trace_ids_sorted,
source_items_compare=src_items_compare,
destination_items_compare=dest_items_sorted,
)
class TestMigrateDatasetEnvironmentPreservation:
"""OPIK-6695: the cascade must preserve the ``environment`` column on
traces, spans, and the BE-materialized ``trace_threads`` row.
Pre-2026-05-07 rows default to ``''`` in ClickHouse and replay
trivially; the regression this guards is the post-migration reset of a
non-empty ``environment`` to ``''`` because the re-emit payload didn't
carry the field.
"""
def test_environment_round_trips_on_traces_spans_and_threads(
self,
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
dataset_name: str,
tmp_path: Path,
) -> None:
from opik import id_helpers
from opik.rest_api.types.dataset_item_write import DatasetItemWrite
rest = opik_client.rest_client
# Single-version dataset with two items -> two cascaded traces.
source_id = create_dataset_shell(rest, dataset_name, source_project_name)
rest.datasets.create_or_update_dataset_items(
dataset_id=source_id,
items=[
DatasetItemWrite(source="manual", data={"q": "Q1", "a": "A1"}),
DatasetItemWrite(source="manual", data={"q": "Q2", "a": "A2"}),
],
batch_group_id=id_helpers.generate_id(),
)
v1 = rest.datasets.list_dataset_versions(id=source_id, page=1, size=1).content[
0
]
v1_items = stream_items_wire(
rest,
dataset_name=dataset_name,
project_name=source_project_name,
version_hash=v1.version_hash,
)
item_ids = [it.id for it in v1_items]
# Seed: traces tagged environment="production" + grouped into one
# thread; spans tagged environment="staging". The trace env and
# span env differ deliberately so a single shared value couldn't
# mask a per-entity bug, and the thread inherits the trace env.
experiment_name = f"e2e-env-{random_chars()}"
thread_id = f"env-thread-{random_chars()}"
seed_experiment_with_trace_tree(
rest,
experiment_name=experiment_name,
dataset_name=dataset_name,
dataset_id=source_id,
dataset_version_id=v1.id,
project_name=source_project_name,
item_ids=item_ids,
spans_per_trace=2,
trace_environment="production",
span_environment="staging",
thread_id=thread_id,
)
audit_path = tmp_path / "audit.json"
result = run_migrate_cli(
["dataset", dataset_name, "--to-project", target_project_name],
audit_log_path=str(audit_path),
)
assert result.returncode == 0, result.stdout + result.stderr
target = rest.datasets.get_dataset_by_identifier(
dataset_name=dataset_name, project_name=target_project_name
)
dest_exp = find_destination_experiment(
rest,
destination_dataset_id=target.id,
experiment_name=experiment_name,
)
dest_items = destination_experiment_items(
rest,
experiment_id=dest_exp.id,
dataset_id=target.id,
)
assert len(dest_items) == len(item_ids)
# (a) traces and (b) spans keep their source environment verbatim.
for dest_item in dest_items:
dest_trace = rest.traces.get_trace_by_id(id=dest_item.trace_id)
assert dest_trace.environment == "production", (
f"trace {dest_item.trace_id} lost environment: "
f"got {dest_trace.environment!r}"
)
dest_spans = destination_spans_for_trace(
rest,
trace_id=dest_item.trace_id,
project_name=target_project_name,
)
assert dest_spans, f"trace {dest_item.trace_id} has no destination spans"
assert all(span.environment == "staging" for span in dest_spans), (
"destination spans lost environment: "
f"{[span.environment for span in dest_spans]}"
)
# (c) the destination thread row -- materialized by the BE from the
# cascaded traces -- inherits the same environment. Polls because
# thread materialization is eventually consistent.
assert synchronization.until(
lambda: bool(
opik_client.search_threads(
project_name=target_project_name,
filter_string=f'id = "{thread_id}"',
)
),
max_try_seconds=30,
), f"destination thread {thread_id!r} never materialized"
threads = opik_client.search_threads(
project_name=target_project_name,
filter_string=f'id = "{thread_id}"',
)
assert len(threads) == 1
assert threads[0].environment == "production", (
f"destination thread {thread_id!r} lost environment: "
f"got {threads[0].environment!r}"
)