chore: import upstream snapshot with attribution
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,701 @@
|
||||
"""Shared fixtures + helpers for ``opik migrate`` e2e tests.
|
||||
|
||||
These tests drive ``opik migrate dataset`` against a real backend
|
||||
(localhost during dev, the CI-provisioned Opik in CI). They verify
|
||||
per-version fidelity end-to-end: items, item-level fields (data,
|
||||
description, tags, evaluators, execution_policy, source), version-level
|
||||
fields (suite evaluators, execution_policy, user tags, metadata), and
|
||||
display order.
|
||||
|
||||
Helpers live here so individual test files stay focused on the scenarios
|
||||
they exercise. Wire-type item reads (via ``rest_stream_parser`` directly)
|
||||
mirror what ``cli/migrate/datasets/version_replay.py`` does in production
|
||||
— the SDK dataclass strips per-item ``tags`` during reconstruction, so
|
||||
asserting tag fidelity requires the wire type.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Iterator, List, Optional, Set
|
||||
|
||||
import pytest
|
||||
|
||||
import opik
|
||||
from opik.api_objects import rest_stream_parser
|
||||
from opik.rest_api import OpikApi
|
||||
from opik.rest_api.core.api_error import ApiError
|
||||
from opik.rest_api.types import dataset_item_public, dataset_version_public
|
||||
|
||||
from ...conftest import random_chars
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project fixtures — ephemeral source + target, deleted on teardown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source_project_name(opik_client: opik.Opik) -> Iterator[str]:
|
||||
"""Create an ephemeral source project for the migration test.
|
||||
|
||||
Deleted on teardown (best-effort — tolerates already-deleted state).
|
||||
Each test gets its own project so parallel runs don't collide on
|
||||
dataset-name uniqueness (datasets are workspace-scoped, not
|
||||
project-scoped, in Opik's BE — see Slice 1's collision pre-flight).
|
||||
"""
|
||||
name = f"e2e-cli-migrate-source-{random_chars()}"
|
||||
opik_client.rest_client.projects.create_project(name=name)
|
||||
yield name
|
||||
_best_effort_delete_project(opik_client.rest_client, name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def target_project_name(opik_client: opik.Opik) -> Iterator[str]:
|
||||
"""Create an ephemeral target project for the migration test."""
|
||||
name = f"e2e-cli-migrate-target-{random_chars()}"
|
||||
opik_client.rest_client.projects.create_project(name=name)
|
||||
yield name
|
||||
_best_effort_delete_project(opik_client.rest_client, name)
|
||||
|
||||
|
||||
def _best_effort_delete_project(rest_client: OpikApi, name: str) -> None:
|
||||
try:
|
||||
project_id = rest_client.projects.retrieve_project(name=name).id
|
||||
rest_client.projects.delete_project_by_id(project_id)
|
||||
except ApiError:
|
||||
# Already gone (404) or insufficient permissions — either way
|
||||
# cleanup is non-blocking; leave the project for the next run to
|
||||
# garbage-collect or for a maintenance task to clean up.
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI invocation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_migrate_cli(
|
||||
args: List[str],
|
||||
audit_log_path: Optional[str] = None,
|
||||
extra_env: Optional[Dict[str, str]] = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Invoke ``opik migrate`` via the installed CLI entrypoint.
|
||||
|
||||
Uses subprocess (not Click's ``CliRunner``) so the test exercises the
|
||||
same code path real users hit — module import, Click group setup,
|
||||
config-chain resolution, exit-code handling, stderr routing. Returns
|
||||
the completed process so the caller can assert on ``returncode``,
|
||||
``stdout``, ``stderr``.
|
||||
|
||||
``--audit-log`` is appended when provided. Tests typically write to a
|
||||
tmp_path so the JSON can be re-read and asserted on.
|
||||
|
||||
``extra_env`` is merged into the child process environment — the resume
|
||||
E2E test uses it to put a test-only ``sitecustomize.py`` seam on
|
||||
``PYTHONPATH`` that injects a deterministic mid-cascade crash (the child
|
||||
``os._exit``s, so ``returncode`` is the hard-exit code, not a clean CLI
|
||||
exit) and redirects the checkpoint dir into a tmp path.
|
||||
"""
|
||||
cmd = [sys.executable, "-m", "opik.cli", "migrate"] + args
|
||||
if audit_log_path is not None:
|
||||
cmd.extend(["--audit-log", audit_log_path])
|
||||
env = None
|
||||
if extra_env is not None:
|
||||
env = {**os.environ, **extra_env}
|
||||
return subprocess.run(cmd, capture_output=True, text=True, env=env)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-version source seeding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_dataset_shell(
|
||||
rest_client: OpikApi,
|
||||
name: str,
|
||||
project_name: str,
|
||||
*,
|
||||
type: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Create an empty dataset (or test suite) and return its id.
|
||||
|
||||
``type='evaluation_suite'`` produces a test suite (carries version-
|
||||
level evaluators + execution_policy); omit for a plain dataset.
|
||||
Caller is responsible for seeding versions via ``apply_changes``.
|
||||
"""
|
||||
kwargs: Dict[str, Any] = {"name": name, "project_name": project_name}
|
||||
if type is not None:
|
||||
kwargs["type"] = type
|
||||
rest_client.datasets.create_dataset(**kwargs)
|
||||
ds = rest_client.datasets.get_dataset_by_identifier(
|
||||
dataset_name=name, project_name=project_name
|
||||
)
|
||||
return ds.id
|
||||
|
||||
|
||||
def apply_changes(
|
||||
rest_client: OpikApi,
|
||||
dataset_id: str,
|
||||
*,
|
||||
base_version_id: Optional[str],
|
||||
added_items: Optional[List[Dict[str, Any]]] = None,
|
||||
edited_items: Optional[List[Dict[str, Any]]] = None,
|
||||
deleted_ids: Optional[List[str]] = None,
|
||||
change_description: Optional[str] = None,
|
||||
suite_evaluators: Optional[List[Dict[str, Any]]] = None,
|
||||
suite_execution_policy: Optional[Dict[str, int]] = None,
|
||||
metadata: Optional[Dict[str, str]] = None,
|
||||
user_tags: Optional[List[str]] = None,
|
||||
override: bool = False,
|
||||
) -> str:
|
||||
"""Send ``apply_dataset_item_changes`` and return the new version id.
|
||||
|
||||
Thin wrapper over the raw REST endpoint that mirrors the BE schema's
|
||||
field names. Used to seed multi-version source datasets for migration
|
||||
tests. ``override=True`` is required for the first version (when
|
||||
``base_version_id=None``); see the BE validation in
|
||||
``DatasetItemService.applyDeltaChanges``.
|
||||
"""
|
||||
request: Dict[str, Any] = {}
|
||||
if change_description is not None:
|
||||
request["change_description"] = change_description
|
||||
if base_version_id is not None:
|
||||
request["base_version"] = base_version_id
|
||||
if added_items:
|
||||
request["added_items"] = added_items
|
||||
if edited_items:
|
||||
request["edited_items"] = edited_items
|
||||
if deleted_ids:
|
||||
request["deleted_ids"] = deleted_ids
|
||||
if suite_evaluators is not None:
|
||||
request["evaluators"] = suite_evaluators
|
||||
if suite_execution_policy is not None:
|
||||
request["execution_policy"] = suite_execution_policy
|
||||
if metadata is not None:
|
||||
request["metadata"] = metadata
|
||||
if user_tags is not None:
|
||||
request["tags"] = user_tags
|
||||
new_version = rest_client.datasets.apply_dataset_item_changes(
|
||||
id=dataset_id, request=request, override=override
|
||||
)
|
||||
return new_version.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verification helpers (read side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def chronological_versions(
|
||||
rest_client: OpikApi, dataset_id: str
|
||||
) -> List[dataset_version_public.DatasetVersionPublic]:
|
||||
"""Return every version of ``dataset_id`` oldest-first.
|
||||
|
||||
The REST endpoint returns newest-first; we paginate to exhaustion and
|
||||
reverse so tests can iterate alongside source-version order for per-
|
||||
version comparisons.
|
||||
"""
|
||||
out: List[dataset_version_public.DatasetVersionPublic] = []
|
||||
page = 1
|
||||
while True:
|
||||
resp = rest_client.datasets.list_dataset_versions(
|
||||
id=dataset_id, page=page, size=100
|
||||
)
|
||||
if not resp.content:
|
||||
break
|
||||
out.extend(resp.content)
|
||||
if len(resp.content) < 100:
|
||||
break
|
||||
page += 1
|
||||
out.reverse()
|
||||
return out
|
||||
|
||||
|
||||
def stream_items_wire(
|
||||
rest_client: OpikApi,
|
||||
*,
|
||||
dataset_name: str,
|
||||
project_name: Optional[str],
|
||||
version_hash: Optional[str],
|
||||
) -> List[dataset_item_public.DatasetItemPublic]:
|
||||
"""Read items at ``version_hash`` via the raw REST stream + wire type.
|
||||
|
||||
The SDK helper ``rest_operations.stream_dataset_items`` drops per-item
|
||||
``tags`` during dataclass reconstruction, so tests that assert tag
|
||||
fidelity must go through the wire type directly. Mirrors the same
|
||||
approach used by ``cli/migrate/datasets/version_replay.py`` in
|
||||
production.
|
||||
"""
|
||||
raw_stream = rest_client.datasets.stream_dataset_items(
|
||||
dataset_name=dataset_name,
|
||||
project_name=project_name,
|
||||
dataset_version=version_hash,
|
||||
)
|
||||
return rest_stream_parser.read_and_parse_stream(
|
||||
stream=raw_stream,
|
||||
item_class=dataset_item_public.DatasetItemPublic,
|
||||
)
|
||||
|
||||
|
||||
def item_content_hash(item: dataset_item_public.DatasetItemPublic) -> str:
|
||||
"""Full-fidelity per-item hash covering every persisted user field.
|
||||
|
||||
Mirrors the production hash in ``cli/migrate/datasets/version_replay._content_hash_for``
|
||||
so source-version vs target-version set-equality checks behave the
|
||||
same way the migration code does internally (i.e. any field change
|
||||
is treated as a content change).
|
||||
"""
|
||||
content: Dict[str, Any] = {"data": dict(item.data) if item.data else {}}
|
||||
if item.description is not None:
|
||||
content["description"] = item.description
|
||||
if item.tags is not None:
|
||||
content["tags"] = sorted(item.tags)
|
||||
if item.evaluators is not None:
|
||||
content["evaluators"] = [
|
||||
{"name": e.name, "type": e.type, "config": e.config}
|
||||
for e in item.evaluators
|
||||
]
|
||||
if item.execution_policy is not None:
|
||||
content["execution_policy"] = {
|
||||
"runs_per_item": item.execution_policy.runs_per_item,
|
||||
"pass_threshold": item.execution_policy.pass_threshold,
|
||||
}
|
||||
if item.source is not None:
|
||||
content["source"] = item.source
|
||||
return hashlib.sha256(
|
||||
json.dumps(content, sort_keys=True, default=str).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def item_hashes(items: List[dataset_item_public.DatasetItemPublic]) -> Set[str]:
|
||||
return {item_content_hash(it) for it in items}
|
||||
|
||||
|
||||
def display_order(
|
||||
items: List[dataset_item_public.DatasetItemPublic], key: str = "q"
|
||||
) -> List[Optional[Any]]:
|
||||
"""Extract one ``data`` field per item in stream order (newest-first).
|
||||
|
||||
The stream's order *is* the UI's display order, so two versions' lists
|
||||
of (e.g.) ``q`` values match iff the visible order matches.
|
||||
"""
|
||||
return [(item.data.get(key) if item.data else None) for item in items]
|
||||
|
||||
|
||||
def normalize_evaluators(evals: Optional[List[Any]]) -> List[Dict[str, Any]]:
|
||||
"""Compare-friendly form of a suite evaluator list.
|
||||
|
||||
Strips wire-type wrapping and sorts by name so identical
|
||||
configurations hash equal regardless of how the BE happened to
|
||||
serialise them.
|
||||
"""
|
||||
if not evals:
|
||||
return []
|
||||
return sorted(
|
||||
({"name": e.name, "type": e.type, "config": e.config} for e in evals),
|
||||
key=lambda d: d["name"],
|
||||
)
|
||||
|
||||
|
||||
def normalize_policy(pol: Any) -> Optional[Dict[str, int]]:
|
||||
"""Compare-friendly form of an execution_policy."""
|
||||
if pol is None:
|
||||
return None
|
||||
return {
|
||||
"runs_per_item": pol.runs_per_item,
|
||||
"pass_threshold": pol.pass_threshold,
|
||||
}
|
||||
|
||||
|
||||
def strip_be_managed_version_tags(
|
||||
tags: Optional[List[str]],
|
||||
) -> List[str]:
|
||||
"""Drop the BE-managed ``'latest'`` marker so source/target tag lists compare equal.
|
||||
|
||||
The BE auto-injects ``'latest'`` on the newest version of any dataset
|
||||
on read; the migration code filters it out before forwarding to avoid
|
||||
409 conflicts. Tests strip it on both sides for the same reason.
|
||||
"""
|
||||
return sorted(t for t in (tags or []) if t != "latest")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cascade seeding (Slice 3: experiment + traces + spans)
|
||||
#
|
||||
# Tests seed an experiment + its trace data directly via REST. We do this
|
||||
# rather than going through ``opik.evaluate`` because the BE-side wire
|
||||
# shapes are what the cascade reads from, and we want full control over
|
||||
# trace ids, span tree topology, and feedback score payloads.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def seed_experiment_with_trace_tree(
|
||||
rest_client: OpikApi,
|
||||
*,
|
||||
experiment_name: str,
|
||||
dataset_name: str,
|
||||
dataset_id: str,
|
||||
dataset_version_id: Optional[str],
|
||||
project_name: str,
|
||||
item_ids: List[str],
|
||||
experiment_config: Optional[Dict[str, Any]] = None,
|
||||
experiment_type: str = "regular",
|
||||
evaluation_method: str = "dataset",
|
||||
experiment_tags: Optional[List[str]] = None,
|
||||
spans_per_trace: int = 2,
|
||||
feedback_scores_per_trace: Optional[List[Dict[str, Any]]] = None,
|
||||
per_item_extras: Optional[List[Dict[str, Any]]] = None,
|
||||
optimization_id: Optional[str] = None,
|
||||
trace_environment: Optional[str] = None,
|
||||
span_environment: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a source experiment + one trace per ``item_id`` + a small span
|
||||
tree per trace, then attach everything via ``create_experiment_items``.
|
||||
|
||||
Returns a dict the cascade tests assert against:
|
||||
|
||||
{
|
||||
"experiment_id": str,
|
||||
"trace_ids": [str, ...], # one per item_id, same order
|
||||
"span_ids_by_trace": {trace_id: [root_span_id, child_span_id, ...]},
|
||||
"feedback_scores_by_trace": {trace_id: [score_dicts...]},
|
||||
}
|
||||
|
||||
``spans_per_trace`` controls the tree size; ``spans_per_trace >= 2``
|
||||
produces a root + child(ren) layout so the cascade has to remap
|
||||
parent_span_id. We deliberately do NOT use ``opik.evaluate`` here -- we
|
||||
want the wire shape, deterministic ids, and to assert on it without
|
||||
flush/streamer timing concerns.
|
||||
|
||||
``trace_environment`` / ``span_environment`` stamp the ClickHouse
|
||||
``environment`` column on every seeded trace / span; ``thread_id``
|
||||
groups the traces into one thread so the cascade's env preservation
|
||||
(OPIK-6695) can be round-trip asserted on traces, spans, and the
|
||||
BE-materialized thread row.
|
||||
"""
|
||||
import datetime as dt
|
||||
import opik.id_helpers as id_helpers_module
|
||||
from opik.rest_api.types.experiment_item import ExperimentItem
|
||||
from opik.rest_api.types.feedback_score_batch_item import (
|
||||
FeedbackScoreBatchItem,
|
||||
)
|
||||
from opik.rest_api.types.span_write import SpanWrite
|
||||
from opik.rest_api.types.trace_write import TraceWrite
|
||||
|
||||
if spans_per_trace < 1:
|
||||
raise ValueError("spans_per_trace must be >= 1")
|
||||
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
|
||||
trace_ids: List[str] = []
|
||||
span_ids_by_trace: Dict[str, List[str]] = {}
|
||||
feedback_scores_by_trace: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
trace_writes: List[TraceWrite] = []
|
||||
span_writes: List[SpanWrite] = []
|
||||
feedback_batch: List[FeedbackScoreBatchItem] = []
|
||||
|
||||
for index, item_id in enumerate(item_ids):
|
||||
trace_id = id_helpers_module.generate_id()
|
||||
trace_ids.append(trace_id)
|
||||
trace_writes.append(
|
||||
TraceWrite(
|
||||
id=trace_id,
|
||||
project_name=project_name,
|
||||
name=f"task-{index}",
|
||||
start_time=now,
|
||||
end_time=now + dt.timedelta(milliseconds=10),
|
||||
input={"item": item_id},
|
||||
output={"answer": f"output-{index}"},
|
||||
metadata={"item_id": item_id},
|
||||
tags=["e2e-cascade"],
|
||||
thread_id=thread_id,
|
||||
environment=trace_environment,
|
||||
)
|
||||
)
|
||||
|
||||
# Span tree: root + (spans_per_trace - 1) children of the root.
|
||||
# Children all parent on the root so the cascade has to remap
|
||||
# parent_span_id at least once.
|
||||
root_span_id = id_helpers_module.generate_id()
|
||||
span_ids_by_trace[trace_id] = [root_span_id]
|
||||
span_writes.append(
|
||||
SpanWrite(
|
||||
id=root_span_id,
|
||||
project_name=project_name,
|
||||
trace_id=trace_id,
|
||||
parent_span_id=None,
|
||||
name=f"root-{index}",
|
||||
type="general",
|
||||
start_time=now,
|
||||
end_time=now + dt.timedelta(milliseconds=10),
|
||||
input={"item": item_id},
|
||||
output={"answer": f"output-{index}"},
|
||||
environment=span_environment,
|
||||
)
|
||||
)
|
||||
for child_index in range(spans_per_trace - 1):
|
||||
child_span_id = id_helpers_module.generate_id()
|
||||
span_ids_by_trace[trace_id].append(child_span_id)
|
||||
span_writes.append(
|
||||
SpanWrite(
|
||||
id=child_span_id,
|
||||
project_name=project_name,
|
||||
trace_id=trace_id,
|
||||
parent_span_id=root_span_id,
|
||||
name=f"llm-call-{index}-{child_index}",
|
||||
type="llm",
|
||||
start_time=now + dt.timedelta(milliseconds=1),
|
||||
end_time=now + dt.timedelta(milliseconds=9),
|
||||
input={"prompt": "..."},
|
||||
output={"completion": f"output-{index}"},
|
||||
model="gpt-mock",
|
||||
provider="mock",
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 10},
|
||||
environment=span_environment,
|
||||
)
|
||||
)
|
||||
|
||||
# Optional: attach feedback scores to the trace.
|
||||
if feedback_scores_per_trace:
|
||||
scores_for_this_trace: List[Dict[str, Any]] = []
|
||||
for score in feedback_scores_per_trace:
|
||||
feedback_batch.append(
|
||||
FeedbackScoreBatchItem(
|
||||
id=trace_id,
|
||||
project_name=project_name,
|
||||
name=score["name"],
|
||||
value=score["value"],
|
||||
reason=score.get("reason"),
|
||||
source="sdk",
|
||||
)
|
||||
)
|
||||
scores_for_this_trace.append(score)
|
||||
feedback_scores_by_trace[trace_id] = scores_for_this_trace
|
||||
|
||||
rest_client.traces.create_traces(traces=trace_writes)
|
||||
rest_client.spans.create_spans(spans=span_writes)
|
||||
if feedback_batch:
|
||||
rest_client.traces.score_batch_of_traces(scores=feedback_batch)
|
||||
|
||||
# Create the experiment, then attach experiment items wiring item_id
|
||||
# to trace_id 1:1.
|
||||
import opik.id_helpers as _id_helpers
|
||||
|
||||
new_experiment_id = _id_helpers.generate_id()
|
||||
create_experiment_kwargs: Dict[str, Any] = {
|
||||
"id": new_experiment_id,
|
||||
"name": experiment_name,
|
||||
"dataset_name": dataset_name,
|
||||
"type": experiment_type,
|
||||
"evaluation_method": evaluation_method,
|
||||
"tags": experiment_tags,
|
||||
"metadata": experiment_config,
|
||||
"dataset_version_id": dataset_version_id,
|
||||
"project_name": project_name,
|
||||
}
|
||||
if optimization_id is not None:
|
||||
create_experiment_kwargs["optimization_id"] = optimization_id
|
||||
rest_client.experiments.create_experiment(**create_experiment_kwargs)
|
||||
|
||||
extras_list = per_item_extras or [{} for _ in item_ids]
|
||||
if len(extras_list) != len(item_ids):
|
||||
raise ValueError("per_item_extras must have the same length as item_ids")
|
||||
|
||||
# ``assertion_results`` are persisted via the dedicated
|
||||
# ``assertion_results.store_assertions_batch(entity_type='TRACE', ...)``
|
||||
# endpoint -- the ``ExperimentItem.assertion_results`` field is dropped
|
||||
# silently on the BE Write view (it's READ-ONLY on the Compare view,
|
||||
# computed from the underlying assertion-results entity table). Same
|
||||
# for the other per-item fidelity fields like input/output -- those
|
||||
# are BE-computed read aggregates.
|
||||
#
|
||||
# The seed builds a separate assertion-batch from each item's extras
|
||||
# before constructing the ExperimentItem write (which only carries the
|
||||
# FK fields). This mirrors how the cascade itself writes assertions.
|
||||
from opik.rest_api.types.assertion_result_batch_item import (
|
||||
AssertionResultBatchItem,
|
||||
)
|
||||
|
||||
assertion_batch: List[AssertionResultBatchItem] = []
|
||||
assertion_results_by_trace: Dict[str, List[Dict[str, Any]]] = {}
|
||||
experiment_items_to_create: List[ExperimentItem] = []
|
||||
for item_id, trace_id, extras in zip(item_ids, trace_ids, extras_list):
|
||||
per_item_assertions = extras.get("assertion_results") or []
|
||||
for ar in per_item_assertions:
|
||||
value = (
|
||||
ar.get("value") if isinstance(ar, dict) else getattr(ar, "value", None)
|
||||
)
|
||||
passed = (
|
||||
ar.get("passed")
|
||||
if isinstance(ar, dict)
|
||||
else getattr(ar, "passed", None)
|
||||
)
|
||||
reason = (
|
||||
ar.get("reason")
|
||||
if isinstance(ar, dict)
|
||||
else getattr(ar, "reason", None)
|
||||
)
|
||||
if value is None or passed is None:
|
||||
continue
|
||||
assertion_batch.append(
|
||||
AssertionResultBatchItem(
|
||||
entity_id=trace_id,
|
||||
project_name=project_name,
|
||||
name=value,
|
||||
status="passed" if passed else "failed",
|
||||
reason=reason,
|
||||
source="sdk",
|
||||
)
|
||||
)
|
||||
assertion_results_by_trace.setdefault(trace_id, []).append(
|
||||
{"value": value, "passed": passed, "reason": reason}
|
||||
)
|
||||
|
||||
# The remaining extras are READ-ONLY on the BE; we don't write
|
||||
# them. Forwarding them on the ExperimentItem create payload would
|
||||
# be silently dropped (BE Write view doesn't include them).
|
||||
experiment_items_to_create.append(
|
||||
ExperimentItem(
|
||||
id=_id_helpers.generate_id(),
|
||||
experiment_id=new_experiment_id,
|
||||
dataset_item_id=item_id,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
|
||||
rest_client.experiments.create_experiment_items(
|
||||
experiment_items=experiment_items_to_create
|
||||
)
|
||||
|
||||
if assertion_batch:
|
||||
rest_client.assertion_results.store_assertions_batch(
|
||||
entity_type="TRACE",
|
||||
assertion_results=assertion_batch,
|
||||
)
|
||||
|
||||
return {
|
||||
"experiment_id": new_experiment_id,
|
||||
"trace_ids": trace_ids,
|
||||
"span_ids_by_trace": span_ids_by_trace,
|
||||
"feedback_scores_by_trace": feedback_scores_by_trace,
|
||||
"assertion_results_by_trace": assertion_results_by_trace,
|
||||
}
|
||||
|
||||
|
||||
def find_destination_experiment(
|
||||
rest_client: OpikApi,
|
||||
*,
|
||||
destination_dataset_id: str,
|
||||
experiment_name: str,
|
||||
) -> Any:
|
||||
"""Locate the cascaded experiment at the destination by name + dataset.
|
||||
|
||||
Returns the ``ExperimentPublic``. Raises if zero or multiple match;
|
||||
the cascade is supposed to recreate one experiment per source
|
||||
experiment, so neither outcome is silently acceptable.
|
||||
"""
|
||||
page = rest_client.experiments.find_experiments(
|
||||
dataset_id=destination_dataset_id,
|
||||
page=1,
|
||||
size=100,
|
||||
name=experiment_name,
|
||||
)
|
||||
matched = [e for e in (page.content or []) if e.name == experiment_name]
|
||||
if len(matched) != 1:
|
||||
raise AssertionError(
|
||||
f"expected exactly one destination experiment named "
|
||||
f"{experiment_name!r} under dataset {destination_dataset_id}, "
|
||||
f"got {len(matched)}"
|
||||
)
|
||||
return matched[0]
|
||||
|
||||
|
||||
def destination_experiment_items(
|
||||
rest_client: OpikApi,
|
||||
*,
|
||||
experiment_id: str,
|
||||
dataset_id: str,
|
||||
) -> List[Any]:
|
||||
"""Materialise the destination experiment's items via the Compare view.
|
||||
|
||||
The cascade's source-side read uses
|
||||
``datasets.find_dataset_items_with_experiment_items`` because only the
|
||||
Compare view surfaces ``assertion_results`` / ``feedback_scores`` /
|
||||
``input`` / ``output``. We use the same endpoint for destination
|
||||
verification so tests can assert on those fields directly (the slim
|
||||
``stream_experiment_items`` Public view drops them).
|
||||
|
||||
Returns a flat list of ``ExperimentItemCompare`` -- one per source
|
||||
experiment item.
|
||||
"""
|
||||
experiment_ids_filter = json.dumps([experiment_id])
|
||||
collected: List[Any] = []
|
||||
page = 1
|
||||
while True:
|
||||
resp = rest_client.datasets.find_dataset_items_with_experiment_items(
|
||||
id=dataset_id,
|
||||
experiment_ids=experiment_ids_filter,
|
||||
page=page,
|
||||
size=100,
|
||||
)
|
||||
content = resp.content or []
|
||||
if not content:
|
||||
break
|
||||
for ds_item in content:
|
||||
for ei in ds_item.experiment_items or []:
|
||||
if ei.experiment_id == experiment_id:
|
||||
collected.append(ei)
|
||||
if len(content) < 100:
|
||||
break
|
||||
page += 1
|
||||
return collected
|
||||
|
||||
|
||||
def destination_spans_for_trace(
|
||||
rest_client: OpikApi, *, trace_id: str, project_name: str
|
||||
) -> List[Any]:
|
||||
"""Read all destination spans for one destination trace, paginating.
|
||||
|
||||
``get_spans_by_project`` requires ``project_name`` (or ``project_id``)
|
||||
on the request; without it the BE 400s. We pass the destination
|
||||
project name explicitly.
|
||||
"""
|
||||
out: List[Any] = []
|
||||
page = 1
|
||||
while True:
|
||||
resp = rest_client.spans.get_spans_by_project(
|
||||
project_name=project_name,
|
||||
trace_id=trace_id,
|
||||
page=page,
|
||||
size=100,
|
||||
)
|
||||
if not resp.content:
|
||||
break
|
||||
out.extend(resp.content)
|
||||
if len(resp.content) < 100:
|
||||
break
|
||||
page += 1
|
||||
return out
|
||||
|
||||
|
||||
def destination_feedback_scores_for_trace(
|
||||
rest_client: OpikApi, *, trace_id: str
|
||||
) -> List[Any]:
|
||||
"""Read feedback scores on a destination trace.
|
||||
|
||||
The trace's ``feedback_scores`` field on read is the authoritative
|
||||
source -- the cascade copies them implicitly because trace metadata
|
||||
isn't the only place they live (per-trace ``feedback_scores`` table).
|
||||
Today's cascade does NOT explicitly re-emit feedback scores; this
|
||||
helper lets a test assert that as an explicit known-gap or as
|
||||
"preserved if and only if the cascade adds the copy".
|
||||
"""
|
||||
trace = rest_client.traces.get_trace_by_id(id=trace_id)
|
||||
return list(trace.feedback_scores or [])
|
||||
Reference in New Issue
Block a user