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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:44 +08:00
commit 5a558eb09e
11579 changed files with 1795921 additions and 0 deletions
@@ -0,0 +1,62 @@
from unittest import mock
import pytest
from opik.api_objects import opik_client
from opik.rest_api import core as rest_api_core
from opik.rest_api.types.agent_blueprint_public import AgentBlueprintPublic
from opik.decorator.context_manager import start_as_current_trace
def make_raw_blueprint(blueprint_id="bp-1", name=None, values=None, description=None):
if values is None:
values = []
return AgentBlueprintPublic(
id=blueprint_id,
name=name,
type="blueprint",
values=values,
description=description,
)
@pytest.fixture
def mock_rest_client():
client = mock.Mock()
client.agent_configs = mock.Mock()
client.agent_configs.create_agent_config.return_value = None
client.agent_configs.get_latest_blueprint.side_effect = rest_api_core.ApiError(
status_code=404, body="not found"
)
client.agent_configs.get_blueprint_by_env.side_effect = rest_api_core.ApiError(
status_code=404, body="not found"
)
client.agent_configs.get_blueprint_by_id.return_value = make_raw_blueprint()
client.projects.retrieve_project.return_value = mock.Mock(id="proj-test")
return client
@pytest.fixture
def mock_opik_client(mock_rest_client):
client = opik_client.Opik.__new__(opik_client.Opik)
client._rest_client = mock_rest_client
client._project_name = "test-project"
return client
@pytest.fixture(autouse=True)
def clear_caches():
yield
from opik.api_objects.agent_config.cache import get_global_registry
get_global_registry().clear()
@pytest.fixture(autouse=True)
def fake_track_context():
"""Push a fake trace so _get_or_create_from_backend's @track guard passes in all unit tests."""
with mock.patch.object(
opik_client, "get_global_client", return_value=mock.Mock(spec=opik_client.Opik)
):
with start_as_current_trace(name="test-trace"):
yield
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,470 @@
from typing import List
from unittest import mock
import pytest
from opik.api_objects.agent_config.blueprint import Blueprint
from opik.api_objects.prompt.text.prompt import Prompt
from opik.api_objects.prompt.chat.chat_prompt import ChatPrompt
from opik.api_objects.prompt.base_prompt import BasePrompt
from opik.rest_api.types.agent_blueprint_public import AgentBlueprintPublic
from opik.rest_api.types.agent_config_value_public import AgentConfigValuePublic
from opik.rest_api.types.prompt_version_detail import PromptVersionDetail
def _make_raw_blueprint(
blueprint_id="bp-1",
values=None,
description=None,
bp_type="blueprint",
envs=None,
created_by=None,
created_at=None,
):
if values is None:
values = [
AgentConfigValuePublic(key="temperature", type="float", value="0.6"),
AgentConfigValuePublic(key="name", type="string", value="agent"),
]
return AgentBlueprintPublic(
id=blueprint_id,
type=bp_type,
values=values,
description=description,
envs=envs,
created_by=created_by,
created_at=created_at,
)
class TestBlueprintProperties:
@pytest.mark.parametrize(
"kwargs,attr,expected",
[
({"blueprint_id": "bp-42"}, "id", "bp-42"),
({"description": "test desc"}, "description", "test desc"),
({"bp_type": "mask"}, "type", "mask"),
({"envs": ["prod", "staging"]}, "envs", ["prod", "staging"]),
({"created_by": "user-1"}, "created_by", "user-1"),
],
)
def test_property__returns_value(self, kwargs, attr, expected):
bp = Blueprint(_make_raw_blueprint(**kwargs))
assert getattr(bp, attr) == expected
class TestBlueprintValueResolution:
def test_without_field_types__values_are_inferred_from_backend_type(self):
bp = Blueprint(_make_raw_blueprint())
assert bp["temperature"] == 0.6
assert isinstance(bp["temperature"], float)
assert bp["name"] == "agent"
assert isinstance(bp["name"], str)
def test_with_field_types__deserializes_values(self):
bp = Blueprint(
_make_raw_blueprint(),
field_types={"temperature": float, "name": str},
)
assert bp["temperature"] == 0.6
assert bp["name"] == "agent"
@pytest.mark.parametrize(
"backend_type,value_str,py_type,expected",
[
("string", "true", bool, True),
("integer", "42", int, 42),
],
)
def test_with_field_types__type_deserialization(
self, backend_type, value_str, py_type, expected
):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(key="val", type=backend_type, value=value_str)
]
)
bp = Blueprint(raw, field_types={"val": py_type})
assert bp["val"] == expected
class TestBlueprintDictLikeAccess:
def test_get__existing_key(self):
bp = Blueprint(_make_raw_blueprint())
assert bp.get("temperature") == 0.6
def test_get__missing_key__returns_default(self):
bp = Blueprint(_make_raw_blueprint())
assert bp.get("missing", 42) == 42
def test_get__missing_key_no_default__returns_none(self):
bp = Blueprint(_make_raw_blueprint())
assert bp.get("missing") is None
def test_getitem__existing_key(self):
bp = Blueprint(_make_raw_blueprint())
assert bp["name"] == "agent"
def test_getitem__missing_key__raises_key_error(self):
bp = Blueprint(_make_raw_blueprint())
with pytest.raises(KeyError):
_ = bp["missing"]
def test_keys__returns_all_keys(self):
bp = Blueprint(_make_raw_blueprint())
assert set(bp.keys()) == {"temperature", "name"}
def test_values__returns_deep_copy(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(key="items", type="string", value="[1, 2, 3]"),
]
)
bp = Blueprint(raw, field_types={"items": List[int]})
vals = bp.values
vals["items"].append(4)
assert bp.values["items"] == [1, 2, 3]
class TestBlueprintPromptResolution:
def test_prompt_field__resolves_to_prompt_object(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(
key="system_prompt", type="prompt", value="abc12345"
),
]
)
mock_rest = mock.Mock()
version_detail = mock.Mock()
version_detail.template_structure = "text"
prompt_detail = mock.Mock()
prompt_detail.name = "my-prompt"
prompt_detail.requested_version = version_detail
mock_rest.prompts.get_prompt_by_commit.return_value = prompt_detail
fake_prompt = mock.Mock(spec=Prompt)
with mock.patch(
"opik.api_objects.prompt.text.prompt.Prompt.from_fern_prompt_version",
return_value=fake_prompt,
):
bp = Blueprint(
raw,
field_types={"system_prompt": Prompt},
rest_client_=mock_rest,
)
assert bp["system_prompt"] is fake_prompt
mock_rest.prompts.get_prompt_by_commit.assert_called_once_with("abc12345")
def test_chat_prompt_field__resolves_to_chat_prompt_object(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(key="messages", type="prompt", value="bcd23456"),
]
)
mock_rest = mock.Mock()
version_detail = mock.Mock()
version_detail.type = "mustache"
prompt_detail = mock.Mock()
prompt_detail.name = "chat-prompt"
prompt_detail.template_structure = "chat"
prompt_detail.requested_version = version_detail
mock_rest.prompts.get_prompt_by_commit.return_value = prompt_detail
fake_chat_prompt = mock.Mock(spec=ChatPrompt)
with mock.patch(
"opik.api_objects.prompt.chat.chat_prompt.ChatPrompt.from_fern_prompt_version",
return_value=fake_chat_prompt,
):
bp = Blueprint(
raw,
field_types={"messages": ChatPrompt},
rest_client_=mock_rest,
)
assert bp["messages"] is fake_chat_prompt
def test_base_prompt_annotation__chat_structure__resolves_to_chat_prompt(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(key="p", type="prompt", value="cde34567"),
]
)
mock_rest = mock.Mock()
version_detail = mock.Mock()
version_detail.type = "mustache"
prompt_detail = mock.Mock()
prompt_detail.name = "any-prompt"
prompt_detail.template_structure = "chat"
prompt_detail.requested_version = version_detail
mock_rest.prompts.get_prompt_by_commit.return_value = prompt_detail
fake_chat_prompt = mock.Mock(spec=ChatPrompt)
with mock.patch(
"opik.api_objects.prompt.chat.chat_prompt.ChatPrompt.from_fern_prompt_version",
return_value=fake_chat_prompt,
):
bp = Blueprint(
raw,
field_types={"p": BasePrompt},
rest_client_=mock_rest,
)
assert bp["p"] is fake_chat_prompt
def test_base_prompt_annotation__text_structure__resolves_to_prompt(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(key="p", type="prompt", value="def45678"),
]
)
mock_rest = mock.Mock()
version_detail = mock.Mock()
version_detail.template_structure = "text"
prompt_detail = mock.Mock()
prompt_detail.name = "any-prompt"
prompt_detail.requested_version = version_detail
mock_rest.prompts.get_prompt_by_commit.return_value = prompt_detail
fake_prompt = mock.Mock(spec=Prompt)
with mock.patch(
"opik.api_objects.prompt.text.prompt.Prompt.from_fern_prompt_version",
return_value=fake_prompt,
):
bp = Blueprint(
raw,
field_types={"p": BasePrompt},
rest_client_=mock_rest,
)
assert bp["p"] is fake_prompt
def test_prompt_resolution_fails__raises(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(
key="system_prompt", type="prompt", value="badbad00"
),
]
)
mock_rest = mock.Mock()
mock_rest.prompts.get_prompt_by_commit.side_effect = Exception("network error")
with pytest.raises(Exception, match="network error"):
Blueprint(
raw,
field_types={"system_prompt": Prompt},
rest_client_=mock_rest,
)
def test_prompt_version_field__resolves_to_prompt_version_detail(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(
key="version", type="prompt_commit", value="pv111111"
),
]
)
mock_rest = mock.Mock()
fake_version_detail = mock.Mock(spec=PromptVersionDetail)
prompt_detail = mock.Mock()
prompt_detail.requested_version = fake_version_detail
mock_rest.prompts.get_prompt_by_commit.return_value = prompt_detail
bp = Blueprint(
raw,
field_types={"version": PromptVersionDetail},
rest_client_=mock_rest,
)
assert bp["version"] is fake_version_detail
mock_rest.prompts.get_prompt_by_commit.assert_called_once_with("pv111111")
def test_prompt_version_field__resolution_fails__raises(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(
key="version", type="prompt_commit", value="badbad00"
),
]
)
mock_rest = mock.Mock()
mock_rest.prompts.get_prompt_by_commit.side_effect = Exception("not found")
with pytest.raises(Exception, match="not found"):
Blueprint(
raw,
field_types={"version": PromptVersionDetail},
rest_client_=mock_rest,
)
def test_prompt_field_type_declared_as_chatprompt__chat_template_structure__resolves_to_chat_prompt(
self,
):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(key="p", type="prompt", value="aaa11111"),
]
)
mock_rest = mock.Mock()
version_detail = mock.Mock()
prompt_detail = mock.Mock()
prompt_detail.name = "my-prompt"
prompt_detail.template_structure = "chat"
prompt_detail.requested_version = version_detail
mock_rest.prompts.get_prompt_by_commit.return_value = prompt_detail
fake_chat_prompt = mock.Mock(spec=ChatPrompt)
with mock.patch(
"opik.api_objects.prompt.chat.chat_prompt.ChatPrompt.from_fern_prompt_version",
return_value=fake_chat_prompt,
):
bp = Blueprint(
raw,
field_types={"p": ChatPrompt},
rest_client_=mock_rest,
)
assert bp["p"] is fake_chat_prompt
assert isinstance(bp["p"], ChatPrompt)
def test_two_prompt_fields__makes_exactly_one_api_call_per_prompt(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(key="p1", type="prompt", value="aaaaaaaa"),
AgentConfigValuePublic(key="p2", type="prompt", value="bbbbbbbb"),
]
)
mock_rest = mock.Mock()
def _commit_side_effect(commit):
v = mock.Mock()
d = mock.Mock()
d.name = f"name-{commit}"
d.template_structure = "text"
d.requested_version = v
return d
mock_rest.prompts.get_prompt_by_commit.side_effect = _commit_side_effect
with mock.patch(
"opik.api_objects.prompt.text.prompt.Prompt.from_fern_prompt_version",
return_value=mock.Mock(spec=Prompt),
):
Blueprint(
raw,
field_types={"p1": Prompt, "p2": Prompt},
rest_client_=mock_rest,
)
assert mock_rest.prompts.get_prompt_by_commit.call_count == 2
class TestBlueprintPromptResolutionWithoutFieldTypes:
def test_prompt_type__resolves_to_prompt_object(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(
key="system_prompt", type="prompt", value="abc12345"
),
]
)
mock_rest = mock.Mock()
version_detail = mock.Mock()
prompt_detail = mock.Mock()
prompt_detail.name = "my-prompt"
prompt_detail.template_structure = "text"
prompt_detail.requested_version = version_detail
mock_rest.prompts.get_prompt_by_commit.return_value = prompt_detail
fake_prompt = mock.Mock(spec=Prompt)
with mock.patch(
"opik.api_objects.prompt.text.prompt.Prompt.from_fern_prompt_version",
return_value=fake_prompt,
):
bp = Blueprint(raw, rest_client_=mock_rest)
assert bp["system_prompt"] is fake_prompt
def test_chat_prompt_type__resolves_to_chat_prompt_object(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(key="messages", type="prompt", value="bcd23456"),
]
)
mock_rest = mock.Mock()
version_detail = mock.Mock()
prompt_detail = mock.Mock()
prompt_detail.name = "chat-prompt"
prompt_detail.template_structure = "chat"
prompt_detail.requested_version = version_detail
mock_rest.prompts.get_prompt_by_commit.return_value = prompt_detail
fake_chat_prompt = mock.Mock(spec=ChatPrompt)
with mock.patch(
"opik.api_objects.prompt.chat.chat_prompt.ChatPrompt.from_fern_prompt_version",
return_value=fake_chat_prompt,
):
bp = Blueprint(raw, rest_client_=mock_rest)
assert bp["messages"] is fake_chat_prompt
def test_prompt_commit_type__resolves_to_prompt_version_detail(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(
key="version", type="prompt_commit", value="pv111111"
),
]
)
mock_rest = mock.Mock()
fake_version_detail = mock.Mock(spec=PromptVersionDetail)
prompt_detail = mock.Mock()
prompt_detail.requested_version = fake_version_detail
mock_rest.prompts.get_prompt_by_commit.return_value = prompt_detail
bp = Blueprint(raw, rest_client_=mock_rest)
assert bp["version"] is fake_version_detail
mock_rest.prompts.get_prompt_by_commit.assert_called_once_with("pv111111")
def test_prompt_resolution_fails__raises(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(
key="system_prompt", type="prompt", value="badbad00"
),
]
)
mock_rest = mock.Mock()
mock_rest.prompts.get_prompt_by_commit.side_effect = Exception("network error")
with pytest.raises(Exception, match="network error"):
Blueprint(raw, rest_client_=mock_rest)
def test_without_rest_client__prompt_stays_raw_string(self):
raw = _make_raw_blueprint(
values=[
AgentConfigValuePublic(
key="system_prompt", type="prompt", value="ver-111"
),
]
)
bp = Blueprint(raw)
assert bp["system_prompt"] == "ver-111"
@@ -0,0 +1,357 @@
import threading
import time
from unittest import mock
import pytest
from opik.api_objects.agent_config.cache import (
CacheRefreshThread,
SharedCacheRegistry,
SharedConfigCache,
)
@pytest.fixture
def registry():
r = SharedCacheRegistry()
yield r
r.clear()
class TestSharedCacheRegistry:
def test_get__same_key__returns_same_instance(self, registry):
a = registry.get("proj", None, None)
b = registry.get("proj", None, None)
assert a is b
def test_get__different_key__returns_different_instance(self, registry):
a = registry.get("proj-a", None, None)
b = registry.get("proj-b", None, None)
assert a is not b
def test_clear__empties_registry(self, registry):
registry.get("proj", None, None)
registry.clear()
# After clear, a new call returns a fresh instance
fresh = registry.get("proj", None, None)
assert fresh.blueprint_id is None
def test_clear__stops_thread(self, registry):
registry.ensure_refresh_thread_started()
assert registry._thread is not None and registry._thread.is_alive()
registry.clear()
assert registry._thread is None
def test_ensure_refresh_thread_started__starts_thread(self, registry):
assert registry._thread is None
registry.ensure_refresh_thread_started()
assert registry._thread is not None
assert registry._thread.is_alive()
def test_ensure_refresh_thread_started__second_call_noop(self, registry):
registry.ensure_refresh_thread_started()
first_thread = registry._thread
registry.ensure_refresh_thread_started()
assert registry._thread is first_thread
def test_stop_refresh_thread__stops_and_nulls(self, registry):
registry.ensure_refresh_thread_started()
thread = registry._thread
registry.stop_refresh_thread()
thread.join(timeout=2)
assert not thread.is_alive()
assert registry._thread is None
def test_concurrent_get__returns_same_instance(self, registry):
results = [None] * 10
barrier = threading.Barrier(10)
def fetch(idx):
barrier.wait()
results[idx] = registry.get("proj", None, None)
threads = [threading.Thread(target=fetch, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert all(r is results[0] for r in results)
class TestSharedConfigCacheThreadSafety:
def test_apply__concurrent_reads_see_consistent_dict(self):
cache = SharedConfigCache(ttl_seconds=300)
errors = []
stop = threading.Event()
def writer():
for i in range(100):
bp = mock.Mock()
bp.id = f"bp-{i}"
bp._values = {f"key-{j}": f"val-{i}" for j in range(5)}
cache.update(bp)
def reader():
while not stop.is_set():
vals = cache.values
unique_vals = set(vals.values())
if len(unique_vals) > 1:
errors.append(f"Inconsistent values: {vals}")
break
writer_thread = threading.Thread(target=writer)
reader_thread = threading.Thread(target=reader)
reader_thread.start()
writer_thread.start()
writer_thread.join()
stop.set()
reader_thread.join()
assert errors == [], f"Found inconsistencies: {errors}"
def test_register_fields__concurrent__all_fields_present(self):
cache = SharedConfigCache()
barrier = threading.Barrier(4)
def register(prefix: str):
barrier.wait()
cache.register_fields({f"{prefix}.f1": str, f"{prefix}.f2": int})
threads = [
threading.Thread(target=register, args=(f"Class{i}",)) for i in range(4)
]
for t in threads:
t.start()
for t in threads:
t.join()
all_types = cache.all_field_types
assert len(all_types) == 8
for i in range(4):
assert f"Class{i}.f1" in all_types
assert f"Class{i}.f2" in all_types
class TestRefreshCallback:
def test_set_refresh_callback__first_writer_wins(self):
cache = SharedConfigCache()
cb1 = mock.Mock()
cb2 = mock.Mock()
cache.set_refresh_callback(cb1)
cache.set_refresh_callback(cb2)
cache.try_background_refresh()
cb1.assert_called_once()
cb2.assert_not_called()
def test_try_background_refresh__no_callback__noop(self):
cache = SharedConfigCache()
cache.try_background_refresh()
def test_try_background_refresh__callback_returns_blueprint__applies(self):
cache = SharedConfigCache(ttl_seconds=300)
bp = mock.Mock()
bp.id = "bp-new"
bp._values = {"A.x": 42}
cache.set_refresh_callback(lambda: bp)
cache.try_background_refresh()
assert cache.blueprint_id == "bp-new"
assert cache.values == {"A.x": 42}
assert not cache.is_stale()
def test_try_background_refresh__callback_returns_none__no_change(self):
cache = SharedConfigCache()
cache.set_refresh_callback(lambda: None)
cache.try_background_refresh()
assert cache.blueprint_id is None
assert cache.values == {}
def test_try_background_refresh__callback_raises__no_crash(self):
cache = SharedConfigCache()
cache.set_refresh_callback(mock.Mock(side_effect=RuntimeError("boom")))
cache.try_background_refresh()
assert cache.blueprint_id is None
class TestRefreshPolicy:
"""Verify which cache lookups get a background refresh callback and which do not.
Tests use a local SharedCacheRegistry to avoid interference with the global
singleton used by init_cache_entry.
"""
def _make_bp(self, bp_id: str, values: dict) -> mock.Mock:
bp = mock.Mock()
bp.id = bp_id
bp._values = values
return bp
def test_latest_lookup__refresh_callback_registered(self, registry):
manager = mock.Mock()
bp = self._make_bp("bp-latest", {"K.v": "v1"})
cache = registry.get("proj", None, None, None)
cache.update(bp)
cache.set_refresh_callback(
lambda: manager.get_blueprint(env=None, mask_id=None, field_types={})
)
registry.ensure_refresh_thread_started()
assert cache._refresh_callback is not None
def test_env_lookup__refresh_callback_registered(self, registry):
manager = mock.Mock()
bp = self._make_bp("bp-env", {"K.v": "v1"})
cache = registry.get("proj", "prod", None, None)
cache.update(bp)
cache.set_refresh_callback(
lambda: manager.get_blueprint(env="prod", mask_id=None, field_types={})
)
registry.ensure_refresh_thread_started()
assert cache._refresh_callback is not None
def test_version_lookup__no_refresh_callback(self, registry):
bp = self._make_bp("bp-v1", {"K.v": "v1"})
cache = registry.get("proj", None, None, "v1")
cache.update(bp)
# version-pinned: no refresh callback registered
assert cache._refresh_callback is None
def test_masked_lookup__no_refresh_callback(self, registry):
bp = self._make_bp("bp-masked", {"K.v": "v1"})
cache = registry.get("proj", None, "mask-abc", None)
cache.update(bp)
# masked: no refresh callback registered
assert cache._refresh_callback is None
def test_latest_and_version__separate_cache_entries(self, registry):
bp_latest = self._make_bp("bp-latest", {"K.v": "latest"})
bp_v1 = self._make_bp("bp-v1", {"K.v": "v1"})
latest_cache = registry.get("proj", None, None, None)
latest_cache.update(bp_latest)
version_cache = registry.get("proj", None, None, "v1")
version_cache.update(bp_v1)
assert latest_cache is not version_cache
assert latest_cache.blueprint_id == "bp-latest"
assert version_cache.blueprint_id == "bp-v1"
def test_latest__background_refresh_updates_cache(self, registry):
bp_new = self._make_bp("bp-refreshed", {"K.v": "refreshed"})
cache = registry.get("proj", None, None, None)
cache._ttl_seconds = 0
cache.set_refresh_callback(lambda: bp_new)
cache.try_background_refresh()
assert cache.blueprint_id == "bp-refreshed"
assert cache.values == {"K.v": "refreshed"}
def test_version__no_background_refresh_even_when_stale(self):
bp = self._make_bp("bp-v1", {"K.v": "v1"})
cache = SharedConfigCache(ttl_seconds=0)
cache.update(bp)
# No refresh callback registered for version-pinned cache
assert cache._refresh_callback is None
cache.try_background_refresh()
assert cache.blueprint_id == "bp-v1"
def test_init_cache_entry__latest__registers_refresh(self, registry):
"""init_cache_entry with version=None, mask_id=None must register a refresh callback."""
import opik.api_objects.agent_config.cache as cache_mod
manager = mock.Mock()
bp = self._make_bp("bp-latest", {"K.v": "v1"})
with mock.patch.object(cache_mod, "_registry", registry):
cache_mod.init_cache_entry(
"proj", None, None, {}, manager, blueprint=bp, version=None
)
cache = registry.get("proj", None, None, None)
assert cache._refresh_callback is not None
def test_init_cache_entry__version__no_refresh(self, registry):
"""init_cache_entry with version set must NOT register a refresh callback."""
import opik.api_objects.agent_config.cache as cache_mod
manager = mock.Mock()
bp = self._make_bp("bp-v1", {"K.v": "v1"})
with mock.patch.object(cache_mod, "_registry", registry):
cache_mod.init_cache_entry(
"proj", None, None, {}, manager, blueprint=bp, version="v1"
)
cache = registry.get("proj", None, None, "v1")
assert cache._refresh_callback is None
def test_init_cache_entry__masked__no_refresh(self, registry):
"""init_cache_entry with mask_id set must NOT register a refresh callback."""
import opik.api_objects.agent_config.cache as cache_mod
manager = mock.Mock()
bp = self._make_bp("bp-masked", {"K.v": "v1"})
with mock.patch.object(cache_mod, "_registry", registry):
cache_mod.init_cache_entry(
"proj", None, "mask-abc", {}, manager, blueprint=bp, version=None
)
cache = registry.get("proj", None, "mask-abc", None)
assert cache._refresh_callback is None
class TestCacheRefreshThread:
def test_stops_on_close(self):
thread = CacheRefreshThread(get_caches=list, interval_seconds=0.01)
thread.start()
assert thread.is_alive()
thread.close()
thread.join(timeout=2)
assert not thread.is_alive()
def test_refreshes_stale_cache(self, registry):
bp = mock.Mock()
bp.id = "bp-bg"
bp._values = {"K.v": "refreshed"}
callback = mock.Mock(return_value=bp)
cache = registry.get("bg-proj", None, None)
cache._ttl_seconds = 0
cache.set_refresh_callback(callback)
thread = CacheRefreshThread(
get_caches=lambda: list(registry._caches.values()),
interval_seconds=0.05,
)
thread.start()
try:
time.sleep(0.3)
assert callback.call_count >= 1
assert cache.values == {"K.v": "refreshed"}
finally:
thread.close()
thread.join(timeout=2)
def test_skips_non_stale_caches(self, registry):
bp = mock.Mock()
bp.id = "bp-init"
bp._values = {"K.v": "initial"}
cache = registry.get("fresh-proj", None, None)
cache.update(bp)
callback = mock.Mock()
cache.set_refresh_callback(callback)
thread = CacheRefreshThread(
get_caches=lambda: list(registry._caches.values()),
interval_seconds=0.05,
)
thread.start()
try:
time.sleep(0.2)
callback.assert_not_called()
finally:
thread.close()
thread.join(timeout=2)
@@ -0,0 +1,406 @@
from unittest import mock
import pytest
from opik.api_objects.agent_config import types
from opik.api_objects.agent_config.config import ConfigManager
from opik.api_objects.agent_config.blueprint import Blueprint
from opik.rest_api import core as rest_api_core
from opik.rest_api.types.agent_blueprint_public import AgentBlueprintPublic
from opik.rest_api.types.agent_config_value_public import AgentConfigValuePublic
def _make_raw_blueprint(blueprint_id="bp-1", values=None, description=None):
if values is None:
values = [
AgentConfigValuePublic(key="temp", type="float", value="0.6"),
AgentConfigValuePublic(key="name", type="string", value="agent"),
]
return AgentBlueprintPublic(
id=blueprint_id, type="blueprint", values=values, description=description
)
@pytest.fixture
def mock_rest_client():
client = mock.Mock()
client.agent_configs = mock.Mock()
client.agent_configs.create_agent_config.return_value = None
client.agent_configs.get_latest_blueprint.return_value = _make_raw_blueprint()
client.projects.retrieve_project.return_value = mock.Mock(id="proj-default")
return client
@pytest.fixture
def agent_config(mock_rest_client):
return ConfigManager(
project_name="my-project",
rest_client_=mock_rest_client,
)
class TestConfigManagerProperties:
def test_project_name(self, agent_config):
assert agent_config.project_name == "my-project"
class TestConfigManagerGetBlueprint:
def test_get_blueprint__returns_blueprint(self, agent_config, mock_rest_client):
mock_rest_client.projects.retrieve_project.return_value = mock.Mock(id="proj-1")
mock_rest_client.agent_configs.get_latest_blueprint.return_value = (
_make_raw_blueprint()
)
result = agent_config.get_blueprint()
assert isinstance(result, Blueprint)
assert result.id == "bp-1"
def test_get_blueprint__with_env__routes_to_get_blueprint_by_env(
self, agent_config, mock_rest_client
):
mock_rest_client.projects.retrieve_project.return_value = mock.Mock(id="proj-1")
mock_rest_client.agent_configs.get_blueprint_by_env.return_value = (
_make_raw_blueprint()
)
agent_config.get_blueprint(env="prod")
mock_rest_client.agent_configs.get_blueprint_by_env.assert_called_once_with(
env_name="prod",
project_id="proj-1",
mask_id=None,
request_options=None,
)
mock_rest_client.agent_configs.get_latest_blueprint.assert_not_called()
def test_get_blueprint__with_mask_id__passes_mask_id(
self, agent_config, mock_rest_client
):
mock_rest_client.projects.retrieve_project.return_value = mock.Mock(id="proj-1")
mock_rest_client.agent_configs.get_latest_blueprint.return_value = (
_make_raw_blueprint()
)
agent_config.get_blueprint(mask_id="mask-1")
mock_rest_client.agent_configs.get_latest_blueprint.assert_called_once_with(
project_id="proj-1",
mask_id="mask-1",
request_options=None,
)
def test_get_blueprint__with_field_types__resolves_values(
self, agent_config, mock_rest_client
):
mock_rest_client.projects.retrieve_project.return_value = mock.Mock(id="proj-1")
mock_rest_client.agent_configs.get_latest_blueprint.return_value = (
_make_raw_blueprint()
)
result = agent_config.get_blueprint(field_types={"temp": float, "name": str})
assert result["temp"] == 0.6
assert result["name"] == "agent"
def test_get_blueprint__without_field_types__infers_types_from_backend(
self, agent_config, mock_rest_client
):
mock_rest_client.projects.retrieve_project.return_value = mock.Mock(id="proj-1")
mock_rest_client.agent_configs.get_latest_blueprint.return_value = (
_make_raw_blueprint()
)
result = agent_config.get_blueprint()
assert result["temp"] == 0.6
assert isinstance(result["temp"], float)
assert result["name"] == "agent"
assert isinstance(result["name"], str)
def test_get_blueprint__not_found__returns_none(
self, agent_config, mock_rest_client
):
mock_rest_client.projects.retrieve_project.return_value = mock.Mock(id="proj-1")
mock_rest_client.agent_configs.get_latest_blueprint.side_effect = (
rest_api_core.ApiError(status_code=404, body="not found")
)
result = agent_config.get_blueprint()
assert result is None
@pytest.mark.parametrize(
"mask_id",
["mask-1", "mask-2", None],
ids=["mask_1", "mask_2", "no_mask"],
)
def test_get_blueprint__mask_id__passed_to_backend(
self, agent_config, mock_rest_client, mask_id
):
mock_rest_client.projects.retrieve_project.return_value = mock.Mock(id="proj-1")
mock_rest_client.agent_configs.get_latest_blueprint.return_value = (
_make_raw_blueprint()
)
agent_config.get_blueprint(mask_id=mask_id)
mock_rest_client.agent_configs.get_latest_blueprint.assert_called_once_with(
project_id="proj-1",
mask_id=mask_id,
request_options=None,
)
def test_get_blueprint__env_with_mask_id__routes_to_get_blueprint_by_env(
self, agent_config, mock_rest_client
):
mock_rest_client.projects.retrieve_project.return_value = mock.Mock(id="proj-1")
mock_rest_client.agent_configs.get_blueprint_by_env.return_value = (
_make_raw_blueprint()
)
agent_config.get_blueprint(env="prod", mask_id="mask-1")
mock_rest_client.agent_configs.get_blueprint_by_env.assert_called_once_with(
env_name="prod",
project_id="proj-1",
mask_id="mask-1",
request_options=None,
)
class TestConfigManagerGetBlueprintByName:
def test_get_blueprint_by_name__returns_blueprint(
self, agent_config, mock_rest_client
):
mock_rest_client.projects.retrieve_project.return_value = mock.Mock(id="proj-1")
mock_rest_client.agent_configs.get_blueprint_by_name.return_value = (
_make_raw_blueprint(blueprint_id="bp-specific")
)
result = agent_config.get_blueprint(name="v1")
assert isinstance(result, Blueprint)
assert result.id == "bp-specific"
mock_rest_client.agent_configs.get_blueprint_by_name.assert_called_once_with(
project_id="proj-1", name="v1", mask_id=None, request_options=None
)
def test_get_blueprint_by_name__not_found__returns_none(
self, agent_config, mock_rest_client
):
mock_rest_client.projects.retrieve_project.return_value = mock.Mock(id="proj-1")
mock_rest_client.agent_configs.get_blueprint_by_name.side_effect = (
rest_api_core.ApiError(status_code=404, body="not found")
)
result = agent_config.get_blueprint(name="nonexistent")
assert result is None
class TestConfigManagerCreateBlueprint:
def test_create_blueprint__happy_path__calls_backend_and_returns_blueprint(
self, agent_config, mock_rest_client
):
mock_rest_client.agent_configs.get_blueprint_by_id.return_value = (
_make_raw_blueprint(blueprint_id="bp-new")
)
result = agent_config.create_blueprint(
fields_with_values={
"temperature": types.FieldValueSpec(float, 0.6),
"name": types.FieldValueSpec(str, "agent"),
}
)
mock_rest_client.agent_configs.create_agent_config.assert_called_once()
call_kwargs = mock_rest_client.agent_configs.create_agent_config.call_args[1]
blueprint = call_kwargs["blueprint"]
assert blueprint.type == "blueprint"
assert blueprint.values is not None
assert blueprint.id is not None
assert isinstance(result, Blueprint)
def test_create_blueprint__bool_field__serialized_as_boolean_type(
self, agent_config, mock_rest_client
):
mock_rest_client.agent_configs.get_blueprint_by_id.return_value = (
_make_raw_blueprint()
)
agent_config.create_blueprint(
fields_with_values={"flag": types.FieldValueSpec(bool, False)}
)
call_kwargs = mock_rest_client.agent_configs.create_agent_config.call_args[1]
blueprint = call_kwargs["blueprint"]
flag_param = [v for v in blueprint.values if v.key == "flag"][0]
assert flag_param.type == "boolean"
assert flag_param.value == "false"
def test_create_blueprint__with_project_name__passes_project_to_backend(
self, agent_config, mock_rest_client
):
mock_rest_client.agent_configs.get_blueprint_by_id.return_value = (
_make_raw_blueprint()
)
agent_config.create_blueprint(
fields_with_values={"temp": types.FieldValueSpec(float, 0.5)}
)
call_kwargs = mock_rest_client.agent_configs.create_agent_config.call_args[1]
assert call_kwargs["project_name"] == "my-project"
def test_create_blueprint__with_parameters__returns_blueprint(
self, agent_config, mock_rest_client
):
mock_rest_client.agent_configs.get_blueprint_by_id.return_value = (
_make_raw_blueprint(blueprint_id="bp-new")
)
result = agent_config.create_blueprint(
parameters={"temp": 0.6, "name": "agent"}
)
assert isinstance(result, Blueprint)
assert result.id == "bp-new"
call_kwargs = mock_rest_client.agent_configs.create_agent_config.call_args[1]
bp = call_kwargs["blueprint"]
keys = {v.key for v in bp.values}
assert "temp" in keys
assert "name" in keys
def test_create_blueprint__with_fields_with_values__returns_blueprint(
self, agent_config, mock_rest_client
):
mock_rest_client.agent_configs.get_blueprint_by_id.return_value = (
_make_raw_blueprint()
)
result = agent_config.create_blueprint(
fields_with_values={"temp": types.FieldValueSpec(float, 0.6)}
)
assert isinstance(result, Blueprint)
call_kwargs = mock_rest_client.agent_configs.create_agent_config.call_args[1]
keys = {v.key for v in call_kwargs["blueprint"].values}
assert "temp" in keys
def test_create_blueprint__with_description__passes_description(
self, agent_config, mock_rest_client
):
mock_rest_client.agent_configs.get_blueprint_by_id.return_value = (
_make_raw_blueprint()
)
agent_config.create_blueprint(parameters={"temp": 0.6}, description="v1")
call_kwargs = mock_rest_client.agent_configs.create_agent_config.call_args[1]
assert call_kwargs["blueprint"].description == "v1"
class TestConfigManagerCreateMask:
def test_create_mask__happy_path__calls_backend_with_mask_type(
self, agent_config, mock_rest_client
):
agent_config.create_mask(
fields_with_values={"temperature": types.FieldValueSpec(float, 0.3)}
)
call_kwargs = mock_rest_client.agent_configs.update_agent_config.call_args[1]
blueprint = call_kwargs["blueprint"]
assert blueprint.type == "mask"
assert blueprint.values is not None
def test_create_mask__returns_mask_id(self, agent_config, mock_rest_client):
result = agent_config.create_mask(
fields_with_values={"temperature": types.FieldValueSpec(float, 0.3)}
)
assert isinstance(result, str)
mock_rest_client.agent_configs.get_blueprint_by_id.assert_not_called()
def test_create_mask__sends_under_blueprint_key(
self, agent_config, mock_rest_client
):
agent_config.create_mask(
fields_with_values={"temperature": types.FieldValueSpec(float, 0.3)}
)
call_kwargs = mock_rest_client.agent_configs.update_agent_config.call_args[1]
assert "blueprint" in call_kwargs
assert call_kwargs["blueprint"].id is not None
def test_create_mask__with_parameters__returns_mask_id(
self, agent_config, mock_rest_client
):
result = agent_config.create_mask(parameters={"temp": 0.3})
assert isinstance(result, str)
def test_create_mask__with_description__passes_description(
self, agent_config, mock_rest_client
):
agent_config.create_mask(
fields_with_values={"temperature": types.FieldValueSpec(float, 0.3)},
description="variant-A",
)
call_kwargs = mock_rest_client.agent_configs.update_agent_config.call_args[1]
assert call_kwargs["blueprint"].description == "variant-A"
def test_create_mask__with_project_name__passes_project_to_backend(
self, agent_config, mock_rest_client
):
agent_config.create_mask(
fields_with_values={"temp": types.FieldValueSpec(float, 0.5)}
)
call_kwargs = mock_rest_client.agent_configs.update_agent_config.call_args[1]
assert call_kwargs["project_name"] == "my-project"
class TestResolveFieldsWithValues:
def test_none_value__included_with_str_type(self):
result = ConfigManager._resolve_fields_with_values(
parameters={"temp": 0.5, "name": None},
fields_with_values=None,
)
assert result["temp"] == types.FieldValueSpec(float, 0.5)
assert result["name"] == types.FieldValueSpec(str, None)
def test_all_none_parameters__included_with_str_type(self):
result = ConfigManager._resolve_fields_with_values(
parameters={"a": None, "b": None},
fields_with_values=None,
)
assert result == {
"a": types.FieldValueSpec(str, None),
"b": types.FieldValueSpec(str, None),
}
def test_fields_with_values_takes_precedence_over_parameters(self):
explicit = {"x": types.FieldValueSpec(int, 1)}
result = ConfigManager._resolve_fields_with_values(
parameters={"x": 99},
fields_with_values=explicit,
)
assert result is explicit
def test_create_blueprint__none_parameter__included_in_payload_with_string_type(
self, agent_config, mock_rest_client
):
mock_rest_client.agent_configs.get_blueprint_by_id.return_value = (
_make_raw_blueprint()
)
agent_config.create_blueprint(parameters={"temp": 0.6, "name": None})
call_kwargs = mock_rest_client.agent_configs.create_agent_config.call_args[1]
values_by_key = {v.key: v for v in call_kwargs["blueprint"].values}
assert "temp" in values_by_key
assert "name" in values_by_key
assert values_by_key["name"].type == "string"
assert values_by_key["name"].value is None
@@ -0,0 +1,35 @@
from opik.api_objects.agent_config.context import (
agent_config_context,
get_active_config_mask,
)
class TestConfigContext:
def test_no_context__returns_none(self):
assert get_active_config_mask() is None
def test_inside_context__returns_active_mask(self):
with agent_config_context("mask-1"):
assert get_active_config_mask() == "mask-1"
def test_after_context_exit__returns_none(self):
with agent_config_context("mask-1"):
pass
assert get_active_config_mask() is None
def test_nested_contexts__inner_wins_then_restores_outer(self):
with agent_config_context("outer"):
assert get_active_config_mask() == "outer"
with agent_config_context("inner"):
assert get_active_config_mask() == "inner"
assert get_active_config_mask() == "outer"
assert get_active_config_mask() is None
def test_exception_inside_context__mask_still_resets(self):
try:
with agent_config_context("mask-err"):
assert get_active_config_mask() == "mask-err"
raise ValueError("test error")
except ValueError:
pass
assert get_active_config_mask() is None
@@ -0,0 +1,445 @@
import json
from typing import List, Dict, Callable, Optional
from unittest import mock
import pytest
from opik.api_objects import type_helpers
from opik.api_objects.prompt.base_prompt import BasePrompt
from opik.api_objects.prompt.text.prompt import Prompt
from opik.api_objects.prompt.chat.chat_prompt import ChatPrompt
from opik.rest_api.types.prompt_version_detail import PromptVersionDetail
class TestIsPromptType:
@pytest.mark.parametrize(
"py_type",
[BasePrompt, Prompt, ChatPrompt],
ids=["BasePrompt", "Prompt", "ChatPrompt"],
)
def test_prompt_classes__returns_true(self, py_type):
assert type_helpers.is_prompt_type(py_type) is True
@pytest.mark.parametrize(
"py_type",
[str, int, float, bool, object, list],
ids=["str", "int", "float", "bool", "object", "list"],
)
def test_non_prompt_types__returns_false(self, py_type):
assert type_helpers.is_prompt_type(py_type) is False
def test_custom_subclass_of_base_prompt__returns_true(self):
class MyPrompt(BasePrompt):
@property
def name(self):
return ""
@property
def commit(self):
return None
@property
def version_id(self):
return ""
@property
def metadata(self):
return None
@property
def type(self):
return None
@property
def id(self):
return None
@property
def description(self):
return None
@property
def change_description(self):
return None
@property
def tags(self):
return None
def format(self, *args, **kwargs):
return ""
def __internal_api__to_info_dict__(self):
return {}
assert type_helpers.is_prompt_type(MyPrompt) is True
class TestBackendTypeToPythonType:
@pytest.mark.parametrize(
"backend_type, expected",
[
("string", str),
("integer", int),
("float", float),
("boolean", bool),
],
ids=["string", "integer", "float", "boolean"],
)
def test_primitive_types__returns_python_type(self, backend_type, expected):
assert type_helpers.backend_type_to_python_type(backend_type) is expected
@pytest.mark.parametrize(
"backend_type",
["prompt", "prompt_commit", "unknown", ""],
ids=["prompt", "prompt_commit", "unknown", "empty"],
)
def test_non_primitive_types__returns_none(self, backend_type):
assert type_helpers.backend_type_to_python_type(backend_type) is None
class TestIsSupportedType:
@pytest.mark.parametrize(
"py_type, expected",
[
(str, True),
(int, True),
(float, True),
(bool, True),
(List[str], True),
(List[int], True),
(List[float], True),
(List[bool], True),
(Dict[str, float], True),
(Dict[str, bool], True),
(Dict[str, int], True),
(Dict[str, str], True),
(Prompt, True),
(ChatPrompt, True),
(BasePrompt, True),
(PromptVersionDetail, True),
],
ids=[
"str",
"int",
"float",
"bool",
"List[str]",
"List[int]",
"List[float]",
"List[bool]",
"Dict[str,float]",
"Dict[str,bool]",
"Dict[str,int]",
"Dict[str,str]",
"Prompt",
"ChatPrompt",
"BasePrompt",
"PromptVersionDetail",
],
)
def test_supported_types__returns_true(self, py_type, expected):
assert type_helpers.is_supported_type(py_type) is expected
@pytest.mark.parametrize(
"py_type",
[
list,
dict,
Callable,
object,
Dict[int, str],
],
ids=["bare_list", "bare_dict", "Callable", "object", "Dict[int,str]"],
)
def test_unsupported_types__returns_false(self, py_type):
assert type_helpers.is_supported_type(py_type) is False
def test_custom_class__returns_false(self):
class Foo:
pass
assert type_helpers.is_supported_type(Foo) is False
def test_list_of_custom_class__returns_false(self):
class Bar:
pass
assert type_helpers.is_supported_type(List[Bar]) is False
class TestPythonTypeToBackendType:
@pytest.mark.parametrize(
"py_type, expected_backend_type",
[
(str, "string"),
(int, "integer"),
(float, "float"),
(bool, "boolean"),
(List[str], "string"),
(Dict[str, int], "string"),
],
ids=["str", "int", "float", "bool", "List", "Dict"],
)
def test_known_types__returns_correct_backend_type(
self, py_type, expected_backend_type
):
assert (
type_helpers.python_type_to_backend_type(py_type) == expected_backend_type
)
def test_unsupported_type__raises_type_error(self):
with pytest.raises(TypeError):
type_helpers.python_type_to_backend_type(object)
@pytest.mark.parametrize(
"py_type",
[Prompt, ChatPrompt, BasePrompt],
ids=["Prompt", "ChatPrompt", "BasePrompt"],
)
def test_prompt_types__return_prompt(self, py_type):
assert type_helpers.python_type_to_backend_type(py_type) == "prompt"
def test_prompt_version_type__returns_prompt_commit(self):
assert (
type_helpers.python_type_to_backend_type(PromptVersionDetail)
== "prompt_commit"
)
class TestPythonValueToBackendValue:
@pytest.mark.parametrize(
"value, py_type, expected",
[
("hello", str, "hello"),
(42, int, "42"),
(0, int, "0"),
(0.6, float, "0.6"),
(0.0, float, "0.0"),
(True, bool, "true"),
(False, bool, "false"),
],
ids=[
"str",
"int",
"int_zero",
"float",
"float_zero",
"bool_true",
"bool_false",
],
)
def test_primitives__serialized_correctly(self, value, py_type, expected):
assert type_helpers.python_value_to_backend_value(value, py_type) == expected
@pytest.mark.parametrize(
"value, py_type, expected_parsed",
[
([1, 2, 3], List[int], [1, 2, 3]),
([], List[str], []),
({"a": 1, "b": 2}, Dict[str, int], {"a": 1, "b": 2}),
({}, Dict[str, str], {}),
],
ids=["list", "empty_list", "dict", "empty_dict"],
)
def test_collections__serialized_as_json(self, value, py_type, expected_parsed):
result = type_helpers.python_value_to_backend_value(value, py_type)
assert json.loads(result) == expected_parsed
@pytest.mark.parametrize(
"py_type",
[Prompt, ChatPrompt, BasePrompt],
ids=["Prompt", "ChatPrompt", "BasePrompt"],
)
def test_prompt_value__returns_commit(self, py_type):
prompt = mock.Mock()
prompt.commit = "abc12345"
assert type_helpers.python_value_to_backend_value(prompt, py_type) == "abc12345"
def test_prompt_version_value__returns_commit(self):
version = mock.Mock(spec=PromptVersionDetail)
version.commit = "pv123456"
assert (
type_helpers.python_value_to_backend_value(version, PromptVersionDetail)
== "pv123456"
)
@pytest.mark.parametrize(
"py_type",
[str, int, float, bool, List[str], Dict[str, int]],
ids=["str", "int", "float", "bool", "List[str]", "Dict[str,int]"],
)
def test_none_value__returns_none(self, py_type):
assert type_helpers.python_value_to_backend_value(None, py_type) is None
class TestBackendValueToPythonValue:
@pytest.mark.parametrize(
"value, py_type, expected",
[
("hello", str, "hello"),
("42", int, 42),
("42.0", int, 42),
(42, int, 42),
("0.6", float, 0.6),
(0.6, float, 0.6),
("true", bool, True),
("1", bool, True),
("yes", bool, True),
("false", bool, False),
("0", bool, False),
(True, bool, True),
],
ids=[
"str",
"int_from_str",
"int_from_float_str",
"int_native",
"float_from_str",
"float_native",
"bool_true_str",
"bool_one_str",
"bool_yes_str",
"bool_false_str",
"bool_zero_str",
"bool_native",
],
)
def test_primitives__deserialized_correctly(self, value, py_type, expected):
assert type_helpers.backend_value_to_python_value(value, py_type) == expected
@pytest.mark.parametrize(
"value, py_type, expected",
[
("[1, 2, 3]", List[int], [1, 2, 3]),
([1, 2, 3], List[int], [1, 2, 3]),
('{"a": 1}', Dict[str, int], {"a": 1}),
({"a": 1}, Dict[str, int], {"a": 1}),
],
ids=["list_from_json", "list_native", "dict_from_json", "dict_native"],
)
def test_collections__deserialized_correctly(self, value, py_type, expected):
assert type_helpers.backend_value_to_python_value(value, py_type) == expected
def test_none__returns_none(self):
assert type_helpers.backend_value_to_python_value(None, str) is None
@pytest.mark.parametrize(
"py_type",
[Prompt, ChatPrompt, BasePrompt],
ids=["Prompt", "ChatPrompt", "BasePrompt"],
)
def test_prompt_type__returns_raw_version_id_string(self, py_type):
result = type_helpers.backend_value_to_python_value("ver-xyz", py_type)
assert result == "ver-xyz"
@pytest.mark.parametrize(
"py_type",
[Prompt, ChatPrompt, BasePrompt],
ids=["Prompt", "ChatPrompt", "BasePrompt"],
)
def test_prompt_type__none_value__returns_none(self, py_type):
assert type_helpers.backend_value_to_python_value(None, py_type) is None
def test_prompt_version_type__returns_raw_version_id_string(self):
result = type_helpers.backend_value_to_python_value(
"ver-pv-xyz", PromptVersionDetail
)
assert result == "ver-pv-xyz"
def test_prompt_version_type__none_value__returns_none(self):
assert (
type_helpers.backend_value_to_python_value(None, PromptVersionDetail)
is None
)
class TestRoundTrip:
@pytest.mark.parametrize(
"value, py_type",
[
("hello", str),
(42, int),
(0, int),
(0.6, float),
(0.0, float),
(True, bool),
(False, bool),
],
ids=[
"str",
"int",
"int_zero",
"float",
"float_zero",
"bool_true",
"bool_false",
],
)
def test_serialize_then_deserialize__recovers_original(self, value, py_type):
backend_value = type_helpers.python_value_to_backend_value(value, py_type)
restored = type_helpers.backend_value_to_python_value(backend_value, py_type)
assert restored == value
assert isinstance(restored, py_type)
class TestUnwrapOptional:
@pytest.mark.parametrize(
"py_type, expected",
[
(Optional[str], str),
(Optional[int], int),
(Optional[float], float),
(Optional[bool], bool),
],
ids=["Optional[str]", "Optional[int]", "Optional[float]", "Optional[bool]"],
)
def test_optional_primitives__returns_inner_type(self, py_type, expected):
assert type_helpers.unwrap_optional(py_type) is expected
@pytest.mark.parametrize(
"py_type",
[str, int, float, bool, List[str], Dict[str, int]],
ids=["str", "int", "float", "bool", "List[str]", "Dict[str,int]"],
)
def test_non_optional__returns_none(self, py_type):
assert type_helpers.unwrap_optional(py_type) is None
def test_union_with_multiple_types__returns_none(self):
# Union[str, int] is not Optional so must not be unwrapped
import typing
assert type_helpers.unwrap_optional(typing.Union[str, int]) is None
class TestIsSupportedTypeOptional:
@pytest.mark.parametrize(
"py_type",
[Optional[str], Optional[int], Optional[float], Optional[bool]],
ids=["Optional[str]", "Optional[int]", "Optional[float]", "Optional[bool]"],
)
def test_optional_primitives__returns_true(self, py_type):
assert type_helpers.is_supported_type(py_type) is True
def test_optional_unsupported__returns_false(self):
assert type_helpers.is_supported_type(Optional[object]) is False
def test_union_with_multiple_types__returns_false(self):
import typing
assert type_helpers.is_supported_type(typing.Union[str, int]) is False
class TestPythonTypeToBackendTypeOptional:
@pytest.mark.parametrize(
"py_type, expected",
[
(Optional[str], "string"),
(Optional[int], "integer"),
(Optional[float], "float"),
(Optional[bool], "boolean"),
],
ids=["Optional[str]", "Optional[int]", "Optional[float]", "Optional[bool]"],
)
def test_optional_primitives__returns_inner_backend_type(self, py_type, expected):
assert type_helpers.python_type_to_backend_type(py_type) == expected
@@ -0,0 +1,391 @@
import json
from unittest.mock import Mock
import pytest
from opik.api_objects.annotation_queue.annotation_queue import (
TracesAnnotationQueue,
ThreadsAnnotationQueue,
)
from opik.rest_api.types import (
trace_public,
trace_thread,
)
from opik.exceptions import OpikException
class TestTracesAnnotationQueueProperties:
def test_id__returns_id(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
assert queue.id == "queue-123"
def test_name__returns_name(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
assert queue.name == "test_queue"
def test_scope__returns_trace(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
assert queue.scope == "trace"
def test_items_count__cached_value__returns_cached_count(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
items_count=10,
)
assert queue.items_count == 10
mock_rest_client.annotation_queues.get_annotation_queue_by_id.assert_not_called()
def test_items_count__no_cached_value__fetches_from_backend(self):
mock_rest_client = Mock()
mock_rest_client.annotation_queues.get_annotation_queue_by_id.return_value = (
Mock(items_count=25)
)
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
items_count=None,
)
assert queue.items_count == 25
mock_rest_client.annotation_queues.get_annotation_queue_by_id.assert_called_once_with(
"queue-123"
)
class TestThreadsAnnotationQueueProperties:
def test_scope__returns_thread(self):
mock_rest_client = Mock()
queue = ThreadsAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
assert queue.scope == "thread"
class TestTracesAnnotationQueueUpdate:
def test_update__happyflow(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
description="old description",
)
queue.update(
name="new_name",
description="new description",
instructions="new instructions",
)
mock_rest_client.annotation_queues.update_annotation_queue.assert_called_once_with(
id="queue-123",
name="new_name",
description="new description",
instructions="new instructions",
comments_enabled=None,
feedback_definition_names=None,
)
assert queue.name == "new_name"
assert queue.description == "new description"
assert queue.instructions == "new instructions"
def test_update__partial_update__only_specified_fields_updated(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
description="old description",
instructions="old instructions",
)
queue.update(description="new description")
mock_rest_client.annotation_queues.update_annotation_queue.assert_called_once_with(
id="queue-123",
name=None,
description="new description",
instructions=None,
comments_enabled=None,
feedback_definition_names=None,
)
assert queue.name == "test_queue"
assert queue.description == "new description"
assert queue.instructions == "old instructions"
class TestTracesAnnotationQueueDelete:
def test_delete__happyflow(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
queue.delete()
mock_rest_client.annotation_queues.delete_annotation_queue_batch.assert_called_once_with(
ids=["queue-123"]
)
class TestTracesAnnotationQueueAddTraces:
def test_add_traces__single_trace_in_list__happyflow(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
items_count=5,
)
trace = Mock(spec=trace_public.TracePublic)
trace.id = "trace-1"
queue.add_traces([trace])
mock_rest_client.annotation_queues.add_items_to_annotation_queue.assert_called_once_with(
id="queue-123", ids=["trace-1"]
)
assert queue._items_count is None
def test_add_traces__multiple_traces__happyflow(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
traces = [
Mock(spec=trace_public.TracePublic, id="trace-1"),
Mock(spec=trace_public.TracePublic, id="trace-2"),
Mock(spec=trace_public.TracePublic, id="trace-3"),
]
queue.add_traces(traces)
mock_rest_client.annotation_queues.add_items_to_annotation_queue.assert_called_once_with(
id="queue-123", ids=["trace-1", "trace-2", "trace-3"]
)
def test_add_traces__trace_without_id__raises_exception(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
trace = Mock(spec=trace_public.TracePublic)
trace.id = None
with pytest.raises(OpikException) as exc_info:
queue.add_traces([trace])
assert "no id" in str(exc_info.value)
def test_add_traces__empty_list__no_api_call(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
queue.add_traces([])
mock_rest_client.annotation_queues.add_items_to_annotation_queue.assert_not_called()
class TestThreadsAnnotationQueueAddThreads:
def test_add_threads__single_thread_in_list__happyflow(self):
mock_rest_client = Mock()
queue = ThreadsAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
items_count=5,
)
thread = Mock(spec=trace_thread.TraceThread)
thread.thread_model_id = "thread-model-1"
queue.add_threads([thread])
mock_rest_client.annotation_queues.add_items_to_annotation_queue.assert_called_once_with(
id="queue-123", ids=["thread-model-1"]
)
assert queue._items_count is None
def test_add_threads__multiple_threads__happyflow(self):
mock_rest_client = Mock()
queue = ThreadsAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
thread1 = Mock(spec=trace_thread.TraceThread)
thread1.thread_model_id = "thread-model-1"
thread2 = Mock(spec=trace_thread.TraceThread)
thread2.thread_model_id = "thread-model-2"
threads = [thread1, thread2]
queue.add_threads(threads)
mock_rest_client.annotation_queues.add_items_to_annotation_queue.assert_called_once_with(
id="queue-123", ids=["thread-model-1", "thread-model-2"]
)
def test_add_threads__thread_without_thread_model_id__raises_exception(self):
mock_rest_client = Mock()
queue = ThreadsAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
thread = Mock(spec=trace_thread.TraceThread)
thread.thread_model_id = None
with pytest.raises(OpikException) as exc_info:
queue.add_threads([thread])
assert "thread_model_id" in str(exc_info.value)
class TestTracesAnnotationQueueRemoveTraces:
def test_remove_traces__single_trace_in_list__happyflow(self):
mock_rest_client = Mock()
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
items_count=5,
)
trace = Mock(spec=trace_public.TracePublic)
trace.id = "trace-1"
queue.remove_traces([trace])
mock_rest_client.annotation_queues.remove_items_from_annotation_queue.assert_called_once_with(
id="queue-123", ids=["trace-1"]
)
assert queue._items_count is None
class TestThreadsAnnotationQueueRemoveThreads:
def test_remove_threads__single_thread_in_list__happyflow(self):
mock_rest_client = Mock()
queue = ThreadsAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
items_count=5,
)
thread = Mock(spec=trace_thread.TraceThread)
thread.thread_model_id = "thread-model-1"
queue.remove_threads([thread])
mock_rest_client.annotation_queues.remove_items_from_annotation_queue.assert_called_once_with(
id="queue-123", ids=["thread-model-1"]
)
assert queue._items_count is None
class TestTracesAnnotationQueueGetItems:
def test_get_items__happyflow(self):
mock_rest_client = Mock()
mock_rest_client.traces.search_traces.return_value = [
json.dumps({"id": "trace-1", "start_time": "2024-01-01T00:00:00Z"}).encode(
"utf-8"
)
+ b"\n",
json.dumps({"id": "trace-2", "start_time": "2024-01-01T00:00:00Z"}).encode(
"utf-8"
),
]
queue = TracesAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
items = queue.get_items()
assert [item.id for item in items] == ["trace-1", "trace-2"]
assert all(isinstance(item, trace_public.TracePublic) for item in items)
class TestThreadsAnnotationQueueGetItems:
def test_get_items__happyflow(self):
mock_rest_client = Mock()
mock_rest_client.traces.search_trace_threads.return_value = [
json.dumps({"id": "thread-1"}).encode("utf-8") + b"\n",
json.dumps({"id": "thread-2"}).encode("utf-8"),
]
queue = ThreadsAnnotationQueue(
id="queue-123",
name="test_queue",
project_id="project-123",
rest_client=mock_rest_client,
)
items = queue.get_items()
assert [item.id for item in items] == ["thread-1", "thread-2"]
assert all(isinstance(item, trace_thread.TraceThread) for item in items)
@@ -0,0 +1,102 @@
from opik.api_key.opik_api_key import DELIMITER_CHAR, parse_api_key
from opik.logging_messages import (
PARSE_API_KEY_EMPTY_EXPECTED_ATTRIBUTES,
PARSE_API_KEY_TOO_MANY_PARTS,
)
import pytest
@pytest.mark.parametrize("raw_key", ["", None])
def test_parse_api_key__empty_key(raw_key, capture_log):
opik_api_key = parse_api_key(raw_key)
assert opik_api_key is None
def test_parse_api_key__one_part():
raw_key = "some API key"
opik_api_key = parse_api_key(raw_key)
assert opik_api_key is not None
assert opik_api_key.api_key == raw_key
assert opik_api_key.short_api_key == raw_key
def test_parse_api_key__no_expected_attributes(capture_log):
raw_key = "some API key"
opik_api_key = parse_api_key(raw_key + DELIMITER_CHAR)
assert opik_api_key is not None
assert opik_api_key.api_key == raw_key
assert opik_api_key.short_api_key == raw_key
assert (
PARSE_API_KEY_EMPTY_EXPECTED_ATTRIBUTES % (raw_key + DELIMITER_CHAR)
in capture_log.messages
)
def test_parse_api_key__too_many_parts(capture_log):
raw_key = "some API key" + DELIMITER_CHAR + "one" + DELIMITER_CHAR + "two"
opik_api_key = parse_api_key(raw_key)
assert opik_api_key is None
assert PARSE_API_KEY_TOO_MANY_PARTS % (3, raw_key) in capture_log.messages
def test_parse_api_key__happy_path__with_padding():
# attributes: {"baseUrl": "https://www.comet.com"}
raw_key = (
"Et1RBc4nd1ef3LfyJvhyB34Po"
+ DELIMITER_CHAR
+ "eyJiYXNlVXJsIjoiaHR0cHM6Ly93d3cuY29tZXQuY29tIn0="
)
opik_api_key = parse_api_key(raw_key)
assert opik_api_key is not None
assert opik_api_key.api_key == raw_key
assert opik_api_key.short_api_key == "Et1RBc4nd1ef3LfyJvhyB34Po"
assert opik_api_key.base_url == "https://www.comet.com"
assert opik_api_key["baseUrl"] == "https://www.comet.com"
def test_parse_api_key__happy_path__no_padding():
# attributes: {"baseUrl": "https://www.comet.com"}
raw_key = (
"Et1RBc4nd1ef3LfyJvhyB34Po"
+ DELIMITER_CHAR
+ "eyJiYXNlVXJsIjoiaHR0cHM6Ly93d3cuY29tZXQuY29tIn0"
)
opik_api_key = parse_api_key(raw_key)
assert opik_api_key is not None
assert opik_api_key.api_key == raw_key
assert opik_api_key.short_api_key == "Et1RBc4nd1ef3LfyJvhyB34Po"
assert opik_api_key.base_url == "https://www.comet.com"
assert opik_api_key["baseUrl"] == "https://www.comet.com"
@pytest.mark.parametrize(
"raw_key",
[
"Et1RBc4nd1ef3LfyJvhyB34Po"
+ DELIMITER_CHAR
+ "eyJiYXNlVXJsIjoiaHR0cHM6Ly93d3cuY29tZXQuY29tIn0===",
"Et1RBc4nd1ef3LfyJvhyB34Po"
+ DELIMITER_CHAR
+ "eyJiYXNlVXJsIjoiaHR0cHM6Ly93d3cuY29tZXQuY29tIn0==",
],
)
def test_parse_api_key__happy_path__wrong_padding(raw_key):
# attributes: {"baseUrl": "https://www.comet.com"}
opik_api_key = parse_api_key(raw_key)
assert opik_api_key is not None
assert opik_api_key.api_key == raw_key
assert opik_api_key.short_api_key == "Et1RBc4nd1ef3LfyJvhyB34Po"
assert opik_api_key.base_url == "https://www.comet.com"
assert opik_api_key["baseUrl"] == "https://www.comet.com"
@@ -0,0 +1,51 @@
"""
Common test data constants for attachment tests.
This module contains base64-encoded and binary test data for various file formats
used across attachment decoder tests.
"""
import base64
# PNG Test Data
# 1x1 transparent PNG image
PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
# JPEG Test Data
# Minimal valid JPEG image (1x1 red pixel)
JPEG_BASE64 = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCRAP/Z"
# PDF Test Data
# Minimal valid PDF document with "Hello World"
PDF_BYTES: bytes = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\n2 0 obj\n<<\n/Type /Pages\n/Kids [3 0 R]\n/Count 1\n>>\nendobj\n3 0 obj\n<<\n/Type /Page\n/Parent 2 0 R\n/Resources <<\n/Font <<\n/F1 4 0 R\n>>\n>>\n/MediaBox [0 0 612 792]\n/Contents 5 0 R\n>>\nendobj\n4 0 obj\n<<\n/Type /Font\n/Subtype /Type1\n/BaseFont /Helvetica\n>>\nendobj\n5 0 obj\n<<\n/Length 44\n>>\nstream\nBT\n/F1 24 Tf\n100 700 Td\n(Hello World) Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f\n0000000015 00000 n\n0000000074 00000 n\n0000000131 00000 n\n0000000277 00000 n\n0000000356 00000 n\ntrailer\n<<\n/Size 6\n/Root 1 0 R\n>>\nstartxref\n448\n%%EOF"
PDF_BASE64 = base64.b64encode(PDF_BYTES).decode("utf-8")
# GIF Test Data
# Minimal GIF89a image
GIF89_BYTES: bytes = (
b"GIF89a\x01\x00\x01\x00\x00\xff\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x00;"
)
GIF89_BASE64 = base64.b64encode(GIF89_BYTES).decode("utf-8")
# WebP Test Data
# Minimal WebP image
WEBP_BYTES: bytes = b"RIFF\x1a\x00\x00\x00WEBPVP8 \x0e\x00\x00\x000\x01\x00\x9d\x01*\x01\x00\x01\x00\x00\x00\x00\x00"
WEBP_BASE64 = base64.b64encode(WEBP_BYTES).decode("utf-8")
# SVG Test Data
# Simple SVG image with a red circle
SVG_BYTES: bytes = b'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="40" fill="red"/></svg>'
SVG_BASE64 = base64.b64encode(SVG_BYTES).decode("utf-8")
# JSON Test Data
JSON_BYTES: bytes = b'{"key": "value", "number": 123, "array": [1, 2, 3]}'
JSON_BASE64 = base64.b64encode(JSON_BYTES).decode("utf-8")
# Plain Text Test Data
PLAIN_TEXT_BYTES: bytes = b"This is just plain text without special markers"
PLAIN_TEXT_BASE64 = base64.b64encode(PLAIN_TEXT_BYTES).decode("utf-8")
# Random Binary Data (octet-stream)
RANDOM_BINARY_BYTES: bytes = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a"
RANDOM_BINARY_BASE64 = base64.b64encode(RANDOM_BINARY_BYTES).decode("utf-8")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,240 @@
import base64
import uuid
import pytest
from opik.api_objects.attachment import base64_normalizer
from . import constants
def _to_urlsafe(standard_b64: str) -> str:
"""Re-encode a standard-base64 string into URL-safe form for fixtures."""
return base64.urlsafe_b64encode(base64.b64decode(standard_b64)).decode("utf-8")
# Bytes whose base64 encoding contains '+' / '/' (so URL-safe encoding contains
# '-' / '_'). Appended to fixtures to guarantee we actually exercise the
# URL-safe path rather than falling through the "no '-'/'_'" early exit.
_URLSAFE_FORCING_TAIL = b"\xfb\xff\xfe" * 8
def _urlsafe_image_fixture(signature: bytes) -> str:
"""Build a URL-safe base64 string that (a) decodes to bytes starting with
``signature`` and (b) is guaranteed to contain '-' or '_' so the
detector's URL-safe-alphabet pre-check actually runs.
"""
encoded = base64.urlsafe_b64encode(signature + _URLSAFE_FORCING_TAIL).decode(
"utf-8"
)
assert "-" in encoded or "_" in encoded, "fixture failed to force URL-safe chars"
return encoded
# ---------------------------------------------------------------------------
# urlsafe_to_standard_base64
# ---------------------------------------------------------------------------
class TestUrlsafeToStandardBase64:
def test_urlsafe_to_standard_base64__dash_and_underscore_chars__replaced_with_plus_and_slash(
self,
):
assert (
base64_normalizer.urlsafe_to_standard_base64("ab-cd_ef==") == "ab+cd/ef=="
)
def test_urlsafe_to_standard_base64__already_standard_alphabet__returns_unchanged(
self,
):
value = "iVBORw0KGgo+AB/CD=="
assert base64_normalizer.urlsafe_to_standard_base64(value) == value
def test_urlsafe_to_standard_base64__urlsafe_chars_with_padding__padding_preserved(
self,
):
assert base64_normalizer.urlsafe_to_standard_base64("ab-_==") == "ab+/=="
def test_urlsafe_to_standard_base64__empty_string__returns_empty(self):
assert base64_normalizer.urlsafe_to_standard_base64("") == ""
# ---------------------------------------------------------------------------
# is_urlsafe_base64_image
# ---------------------------------------------------------------------------
class TestIsUrlsafeBase64Image:
@pytest.mark.parametrize(
"signature",
[
b"\x89PNG\r\n\x1a\n",
b"\xff\xd8\xff",
b"GIF87a",
b"GIF89a",
b"RIFF\x00\x00\x00\x00WEBP",
],
ids=["png", "jpeg", "gif87a", "gif89a", "webp"],
)
def test_is_urlsafe_base64_image__urlsafe_encoded_image_signature__returns_true(
self, signature
):
"""Every supported image signature, when URL-safe-base64-encoded, is detected."""
urlsafe = _urlsafe_image_fixture(signature)
assert base64_normalizer.is_urlsafe_base64_image(urlsafe) is True
def test_is_urlsafe_base64_image__urlsafe_encoded_real_png__returns_true(self):
"""End-to-end sanity check using a real PNG fixture from constants.py."""
urlsafe = _to_urlsafe(constants.PNG_BASE64)
# The real PNG fixture happens to contain '+' / '/' in its standard
# encoding, so its URL-safe form contains '-' / '_' — exercise that.
assert "-" in urlsafe or "_" in urlsafe
assert base64_normalizer.is_urlsafe_base64_image(urlsafe) is True
def test_is_urlsafe_base64_image__standard_alphabet_image__returns_false(self):
"""Standard-alphabet base64 should short-circuit to False — nothing to rewrite."""
assert base64_normalizer.is_urlsafe_base64_image(constants.PNG_BASE64) is False
def test_is_urlsafe_base64_image__string_below_min_length__returns_false(self):
assert base64_normalizer.is_urlsafe_base64_image("ab-_cd==") is False
def test_is_urlsafe_base64_image__uuid_string__returns_false(self):
"""UUIDs share the URL-safe alphabet and contain '-', but aren't images."""
for _ in range(5):
assert base64_normalizer.is_urlsafe_base64_image(str(uuid.uuid4())) is False
def test_is_urlsafe_base64_image__urlsafe_non_image_binary__returns_false(self):
"""Non-image binary in URL-safe base64 (e.g. PDF) must be left alone."""
urlsafe_pdf = _to_urlsafe(constants.PDF_BASE64)
assert base64_normalizer.is_urlsafe_base64_image(urlsafe_pdf) is False
def test_is_urlsafe_base64_image__plain_text_with_dashes__returns_false(self):
text = "hello-world this is not base64-encoded at all"
assert base64_normalizer.is_urlsafe_base64_image(text) is False
def test_is_urlsafe_base64_image__non_base64_chars__returns_false(self):
# Contains '$' which is not in either alphabet
assert (
base64_normalizer.is_urlsafe_base64_image("iVBORw0KGgo$$$abcdefgh") is False
)
def test_is_urlsafe_base64_image__empty_string__returns_false(self):
assert base64_normalizer.is_urlsafe_base64_image("") is False
# ---------------------------------------------------------------------------
# normalize_urlsafe_base64_images_in_place
# ---------------------------------------------------------------------------
class TestNormalizeUrlsafeBase64ImagesInPlace:
def test_normalize_urlsafe_base64_images_in_place__top_level_dict_with_image__image_rewritten(
self,
):
urlsafe = _to_urlsafe(constants.PNG_BASE64)
payload = {"data": urlsafe}
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
assert "-" not in payload["data"]
assert "_" not in payload["data"]
# Round-trips back to the original PNG bytes
original_bytes = base64.b64decode(constants.PNG_BASE64)
assert base64.b64decode(payload["data"]) == original_bytes
def test_normalize_urlsafe_base64_images_in_place__list_with_image__image_rewritten(
self,
):
urlsafe = _to_urlsafe(constants.JPEG_BASE64)
payload = ["leading text", urlsafe]
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
assert payload[0] == "leading text"
assert "-" not in payload[1] and "_" not in payload[1]
def test_normalize_urlsafe_base64_images_in_place__deeply_nested_image__image_rewritten(
self,
):
urlsafe = _to_urlsafe(constants.PNG_BASE64)
payload = {
"role": "user",
"parts": [
{"text": "hello"},
{"inline_data": {"data": urlsafe, "mime_type": "image/png"}},
],
}
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
normalized = payload["parts"][1]["inline_data"]["data"]
assert "-" not in normalized and "_" not in normalized
assert base64.b64decode(normalized) == base64.b64decode(constants.PNG_BASE64)
def test_normalize_urlsafe_base64_images_in_place__uuid_values__left_untouched(
self,
):
uid = str(uuid.uuid4())
payload = {"trace_id": uid, "nested": [{"span_id": uid}]}
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
assert payload["trace_id"] == uid
assert payload["nested"][0]["span_id"] == uid
def test_normalize_urlsafe_base64_images_in_place__standard_base64_image__left_untouched(
self,
):
"""If the value is already standard-alphabet base64, it stays byte-for-byte identical."""
payload = {"data": constants.PNG_BASE64}
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
assert payload["data"] == constants.PNG_BASE64
def test_normalize_urlsafe_base64_images_in_place__non_image_binary__left_untouched(
self,
):
"""URL-safe non-image binary (e.g. PDF) is not rewritten — only images are."""
urlsafe_pdf = _to_urlsafe(constants.PDF_BASE64)
payload = {"data": urlsafe_pdf}
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
assert payload["data"] == urlsafe_pdf
def test_normalize_urlsafe_base64_images_in_place__non_string_leaves__ignored(self):
payload = {"a": 1, "b": None, "c": True, "d": 1.5, "e": [None, 2, False]}
snapshot = {"a": 1, "b": None, "c": True, "d": 1.5, "e": [None, 2, False]}
base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
assert payload == snapshot
def test_normalize_urlsafe_base64_images_in_place__mutated_payload__returns_none(
self,
):
urlsafe = _to_urlsafe(constants.PNG_BASE64)
payload = {"data": urlsafe}
result = base64_normalizer.normalize_urlsafe_base64_images_in_place(payload)
assert result is None
assert payload["data"] != urlsafe # was mutated
def test_normalize_urlsafe_base64_images_in_place__empty_containers__no_change(
self,
):
empty_dict: dict = {}
empty_list: list = []
base64_normalizer.normalize_urlsafe_base64_images_in_place(empty_dict)
base64_normalizer.normalize_urlsafe_base64_images_in_place(empty_list)
assert empty_dict == {}
assert empty_list == []
def test_normalize_urlsafe_base64_images_in_place__scalar_root__no_raise(self):
"""Scalar roots are valid inputs (no-op) — the walker should not raise."""
base64_normalizer.normalize_urlsafe_base64_images_in_place("plain string")
base64_normalizer.normalize_urlsafe_base64_images_in_place(42)
base64_normalizer.normalize_urlsafe_base64_images_in_place(None)
@@ -0,0 +1,481 @@
import os
import tempfile
from typing import Generator
from unittest import mock
import pytest
from opik.api_objects import attachment
from opik.api_objects.attachment import converters
from opik.message_processing import messages
@pytest.fixture
def original_file() -> Generator[str, None, None]:
"""Create a file with test content and clean up after test."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write("test content")
path = f.name
yield path
if os.path.exists(path):
os.unlink(path)
@pytest.mark.parametrize(
"attachment_data,expected",
[
(attachment.Attachment(data="test.png", file_name=None), "image/png"),
(attachment.Attachment(data="test.png", file_name="test.jpg"), "image/jpeg"),
(
attachment.Attachment(
data="test.pdf", file_name=None, content_type="image/jpeg"
),
"image/jpeg",
),
],
)
def test_guess_attachment_type(attachment_data: attachment.Attachment, expected: str):
mimetype = converters.guess_attachment_type(attachment_data)
assert mimetype == expected
def test_attachment_to_message__no_temp_copy(original_file: str):
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
attachment_data = attachment.Attachment(data=original_file, create_temp_copy=False)
message = converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
assert message == messages.CreateAttachmentMessage(
file_path=original_file,
file_name=os.path.basename(original_file),
mime_type="text/plain",
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
encoded_url_override="aHR0cHM6Ly9leGFtcGxlLmNvbQ==",
)
def test_attachment_to_message__file_name(original_file: str):
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
attachment_data = attachment.Attachment(
data=original_file, file_name="test.jpg", create_temp_copy=False
)
message = converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
assert message == messages.CreateAttachmentMessage(
file_path=original_file,
file_name="test.jpg",
mime_type="image/jpeg",
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
encoded_url_override="aHR0cHM6Ly9leGFtcGxlLmNvbQ==",
)
def test_attachment_to_message__content_type(original_file: str):
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
attachment_data = attachment.Attachment(
data=original_file, content_type="image/jpeg", create_temp_copy=False
)
message = converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
assert message == messages.CreateAttachmentMessage(
file_path=original_file,
file_name=os.path.basename(original_file),
mime_type="image/jpeg",
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
encoded_url_override="aHR0cHM6Ly9leGFtcGxlLmNvbQ==",
)
def test_attachment_to_message__create_temp_copy(original_file: str):
"""Test that create_temp_copy creates a temporary file and sets delete_after_upload."""
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
attachment_data = attachment.Attachment(
data=original_file,
file_name="test.txt",
content_type="text/plain",
create_temp_copy=True,
)
message = converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
# The file_path should be different from the original (it's a temp copy)
assert message.file_path != original_file
assert message.file_path.endswith(".txt")
assert os.path.exists(message.file_path)
# delete_after_upload should be True when create_temp_copy is used
assert message.delete_after_upload is True
# Other fields should be preserved
assert message.file_name == "test.txt"
assert message.mime_type == "text/plain"
assert message.entity_type == "trace"
assert message.entity_id == entity_id
assert message.project_name == project_name
# Verify the temp file has the same content
with open(message.file_path, "r") as f:
assert f.read() == "test content"
# Clean up the temp copy
os.unlink(message.file_path)
def test_attachment_to_message__create_temp_copy_fails_on_open__uses_original_file_and_does_not_delete(
original_file: str,
):
"""
Test that when create_temp_copy is True but opening the source file fails,
the original file is used and delete_after_upload is False.
"""
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
attachment_data = attachment.Attachment(
data=original_file,
file_name="test.txt",
content_type="text/plain",
create_temp_copy=True,
)
# Patch open to fail when trying to read the source file for copying
original_open = open
def mock_open_fail_on_rb(file, mode="r", *args, **kwargs):
if mode == "rb" and file == original_file:
raise PermissionError("Cannot read source file")
return original_open(file, mode, *args, **kwargs)
with mock.patch("builtins.open", side_effect=mock_open_fail_on_rb):
message = converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
# The file_path should be the original file (fallback behavior)
assert message.file_path == original_file
# delete_after_upload should be False to prevent deleting the original file
assert message.delete_after_upload is False
# Other fields should be preserved
assert message.file_name == "test.txt"
assert message.mime_type == "text/plain"
assert message.entity_type == "trace"
assert message.entity_id == entity_id
assert message.project_name == project_name
# Original file should still exist
assert os.path.exists(original_file)
# Clean up the temp copy
os.unlink(message.file_path)
def test_attachment_to_message__create_temp_copy_fails_with_delete_after_upload_true__still_does_not_delete(
original_file: str,
):
"""
Test that when create_temp_copy fails and delete_after_upload was explicitly True,
delete_after_upload is still set to False to protect the original file.
"""
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
attachment_data = attachment.Attachment(
data=original_file,
file_name="test.txt",
content_type="text/plain",
create_temp_copy=True,
)
with mock.patch("shutil.copyfileobj", side_effect=IOError("Disk full")):
message = converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
delete_after_upload=True, # Explicitly set to True
)
# Even though delete_after_upload was True, it should be False after failure
assert message.delete_after_upload is False
assert message.file_path == original_file
def test_attachment_to_message__bytes_data():
"""Test that bytes data is written to a temp file and marked for deletion."""
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
data = b"binary content here"
attachment_data = attachment.Attachment(
data=data,
file_name="test.bin",
content_type="application/octet-stream",
)
message = converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
# A temp file should be created
assert os.path.exists(message.file_path)
assert message.file_path != "test.bin"
# delete_after_upload should be True for bytes data
assert message.delete_after_upload is True
# Verify file content matches the bytes
with open(message.file_path, "rb") as f:
assert f.read() == data
# Other fields should be preserved
assert message.file_name == "test.bin"
assert message.mime_type == "application/octet-stream"
assert message.entity_type == "trace"
assert message.entity_id == entity_id
assert message.project_name == project_name
assert message.encoded_url_override == "aHR0cHM6Ly9leGFtcGxlLmNvbQ=="
# Clean up
os.unlink(message.file_path)
def test_attachment_to_message__bytes_data_without_file_name():
"""Test bytes data without file_name uses temp file basename."""
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
data = b"some binary data"
attachment_data = attachment.Attachment(
data=data,
content_type="image/png",
)
message = converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="span",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
# file_name should be the basename of the temp file
assert message.file_name == os.path.basename(message.file_path)
assert message.delete_after_upload is True
assert message.mime_type == "image/png"
assert message.entity_type == "span"
# Clean up
os.unlink(message.file_path)
def test_attachment_to_message__bytes_data_infers_mime_type_from_file_name():
"""Test that a mime type is inferred from file_name when data is bytes."""
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
data = b"\x00\x01\x02\x03"
attachment_data = attachment.Attachment(
data=data,
file_name="image.png",
)
message = converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
assert message.file_name == "image.png"
assert message.mime_type == "image/png"
assert message.delete_after_upload is True
# Clean up
os.unlink(message.file_path)
def test_attachment_to_message__bytes_data_default_mime_type():
"""Test that bytes data without file_name or content_type use a binary mime type."""
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
data = b"\x00\x01\x02\x03"
attachment_data = attachment.Attachment(
data=data,
)
message = converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
# Should use default binary mime type
assert message.mime_type == "application/octet-stream"
assert message.delete_after_upload is True
# Clean up
os.unlink(message.file_path)
def test_attachment_to_message__bytes_data_write_fails__error_reraised():
"""Test behavior when _write_file_like_to_temp_file fails and error reraised."""
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
data = b"test data"
attachment_data = attachment.Attachment(
data=data,
file_name="test.bin",
content_type="application/octet-stream",
)
with mock.patch(
"opik.api_objects.attachment.converters._write_file_like_to_temp_file",
side_effect=OSError("Disk full"),
):
with pytest.raises(OSError):
converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
def test_attachment_to_message__base64_string(request):
"""Test that base64-encoded string is decoded and written to temp file."""
import base64
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
original_content = b"This is test content for base64 encoding"
base64_string = base64.b64encode(original_content).decode("utf-8")
attachment_data = attachment.Attachment(
data=base64_string,
file_name="test.txt",
content_type="text/plain",
)
message = converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
request.addfinalizer(
lambda: os.path.exists(message.file_path) and os.unlink(message.file_path)
)
# A temp file should be created
assert os.path.exists(message.file_path)
assert message.file_path != base64_string
# delete_after_upload should be True for base64 data
assert message.delete_after_upload is True
# Verify file content matches the decoded bytes
with open(message.file_path, "rb") as f:
assert f.read() == original_content
# Other fields should be preserved
assert message.file_name == "test.txt"
assert message.mime_type == "text/plain"
assert message.entity_type == "trace"
assert message.entity_id == entity_id
assert message.project_name == project_name
def test_attachment_to_message__invalid_data_raises_error():
"""Test that invalid attachment data (not bytes, file, or base64) raises ValueError."""
url_override = "https://example.com"
entity_id = "123"
project_name = "test-project"
invalid_data = "not-a-file-and-not-base64!@#$%"
attachment_data = attachment.Attachment(
data=invalid_data,
file_name="test.txt",
)
with pytest.raises(
ValueError,
match="Attachment data must be bytes, an existing file path, or a valid base64-encoded string",
):
converters.attachment_to_message(
attachment_data=attachment_data,
entity_type="trace",
entity_id=entity_id,
project_name=project_name,
url_override=url_override,
)
@@ -0,0 +1,286 @@
import base64
import os
import re
import pytest
from opik.api_objects.attachment import attachment, decoder_base64
from opik.api_objects.attachment import decoder_helpers
from . import constants
@pytest.fixture
def decoder():
"""Create a Base64AttachmentDecoder instance."""
return decoder_base64.Base64AttachmentDecoder()
def test_decode_png_success(decoder, files_to_remove):
"""Test successful decoding of PNG image."""
result = decoder.decode(constants.PNG_BASE64, context="input")
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
assert isinstance(result, attachment.Attachment)
assert result.content_type == "image/png"
assert result.file_name.startswith("input-attachment-")
assert result.file_name.endswith(".png")
assert os.path.exists(result.data)
def test_decode_jpeg_success(decoder, files_to_remove):
"""Test successful decoding of JPEG image."""
result = decoder.decode(constants.JPEG_BASE64, context="output")
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
assert isinstance(result, attachment.Attachment)
assert result.content_type == "image/jpeg"
assert result.file_name.startswith("output-attachment-")
assert result.file_name.endswith(".jpg") or result.file_name.endswith(".jpeg")
assert os.path.exists(result.data)
def test_decode_pdf_success(decoder, files_to_remove):
"""Test successful decoding of PDF document."""
result = decoder.decode(constants.PDF_BASE64, context="metadata")
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
assert isinstance(result, attachment.Attachment)
assert result.content_type == "application/pdf"
assert result.file_name.startswith("metadata-attachment-")
assert result.file_name.endswith(".pdf")
assert os.path.exists(result.data)
def test_decode_gif_success(decoder, files_to_remove):
"""Test successful decoding of GIF image."""
result = decoder.decode(constants.GIF89_BASE64, context="input")
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
assert isinstance(result, attachment.Attachment)
assert result.content_type == "image/gif"
assert result.file_name.endswith(".gif")
assert os.path.exists(result.data)
def test_decode_json_success(decoder, files_to_remove):
"""Test successful decoding of JSON data."""
result = decoder.decode(constants.JSON_BASE64, context="input")
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
assert isinstance(result, attachment.Attachment)
assert result.content_type == "application/json"
assert result.file_name.endswith(".json")
assert os.path.exists(result.data)
def test_decode_invalid_base64(decoder):
"""Test that an invalid base64 string returns None."""
invalid_base64 = "this is not valid base64!@#$%"
result = decoder.decode(invalid_base64, context="input")
assert result is None
def test_decode_non_string_input(decoder):
"""Test that non-string input returns None."""
result = decoder.decode(12345, context="input") # type: ignore
assert result is None
def test_decode_dict_input(decoder):
"""Test that dict input returns None."""
result = decoder.decode({"key": "value"}, context="input") # type: ignore
assert result is None
def test_decode_none_input(decoder):
"""Test that None input returns None."""
result = decoder.decode(None, context="input") # type: ignore
assert result is None
def test_decode_empty_string(decoder):
"""Test that empty string returns None."""
result = decoder.decode("", context="input")
assert result is None
def test_decode_octet_stream_returns_none(decoder):
"""Test that unrecognizable binary data (octet-stream) returns None."""
# Random binary data that won't match any known format
result = decoder.decode(constants.RANDOM_BINARY_BASE64, context="input")
assert result is None
def test_decode_plain_text_returns_none(decoder):
"""Test that plain text returns None."""
result = decoder.decode(constants.PLAIN_TEXT_BASE64, context="input")
assert result is None
def test_decode_creates_temp_file_with_correct_extension(decoder, files_to_remove):
"""Test that temporary file is created with the correct extension."""
result = decoder.decode(constants.PNG_BASE64, context="input")
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
# Check that the temp file path ends with .png (suffix parameter)
assert result.data.endswith("png")
assert os.path.exists(result.data)
# Verify file content
with open(result.data, "rb") as f:
content = f.read()
assert content[:8] == b"\x89PNG\r\n\x1a\n"
def test_decode_attachment_properties(decoder, files_to_remove):
"""Test that Attachment object has all required properties."""
result = decoder.decode(constants.PNG_BASE64, context="input")
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
assert hasattr(result, "data")
assert hasattr(result, "file_name")
assert hasattr(result, "content_type")
assert result.data is not None
assert result.file_name is not None
assert result.content_type is not None
def test_decode_filename_format(decoder, files_to_remove):
"""Test that the filename follows the expected format."""
result = decoder.decode(constants.PNG_BASE64, context="input")
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
# check that the filename matches a backend pattern
pattern = re.compile(decoder_helpers.ATTACHMENT_FILE_NAME_REGEX)
assert bool(pattern.fullmatch(result.file_name)) is True
def test_decode_different_contexts(decoder, files_to_remove):
"""Test that different contexts are reflected in the filename."""
contexts = ["input", "output", "metadata"]
results = []
for ctx in contexts:
result = decoder.decode(constants.PNG_BASE64, context=ctx)
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
assert result.file_name.startswith(f"{ctx}-attachment-")
results.append(result)
def test_decode_multiple_calls_create_unique_files(decoder, files_to_remove):
"""Test that multiple decode calls create unique filenames."""
result1 = decoder.decode(constants.PNG_BASE64, context="input")
result2 = decoder.decode(constants.PNG_BASE64, context="input")
assert result1 is not None
assert result2 is not None
# Register for cleanup
files_to_remove.append(result1.data)
files_to_remove.append(result2.data)
assert result1.file_name != result2.file_name
assert result1.data != result2.data
assert os.path.exists(result1.data)
assert os.path.exists(result2.data)
def test_decode_webp_success(decoder, files_to_remove):
"""Test successful decoding of WebP image."""
result = decoder.decode(constants.WEBP_BASE64, context="input")
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
assert result.content_type == "image/webp"
assert result.file_name.endswith(".webp")
def test_decode_svg_success(decoder, files_to_remove):
"""Test successful decoding of SVG image."""
result = decoder.decode(constants.SVG_BASE64, context="input")
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
assert result.content_type == "image/svg+xml"
assert result.file_name.endswith(".svg")
def test_decode_short_base64_data(decoder):
"""Test that very short base64 (< 4 bytes decoded) returns None."""
# Encode just 2 bytes
short_data = b"ab"
short_base64 = base64.b64encode(short_data).decode("utf-8")
result = decoder.decode(short_base64, context="input")
assert result is None
def test_decode_base64_with_whitespace(decoder, files_to_remove):
"""Test that base64 with whitespace is handled correctly."""
png_base64 = constants.PNG_BASE64
# Add whitespace
png_base64_with_whitespace = f" {png_base64} \n"
result = decoder.decode(png_base64_with_whitespace.strip(), context="input")
assert result is not None
# Register for cleanup
files_to_remove.append(result.data)
assert result.content_type == "image/png"
def test_decode_corrupted_base64_padding(decoder, files_to_remove):
"""Test base64 with incorrect padding.
Note: Python's base64 decoder is lenient and may still decode
strings with missing padding, so this test verifies proper handling.
"""
# Valid base64 but with missing padding
corrupted_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" # Missing padding
result = decoder.decode(corrupted_base64, context="input")
# Python's base64 decoder is lenient, so this might still succeed
# If it succeeds, verify the result is valid and clean up
if result is not None:
files_to_remove.append(result.data)
@@ -0,0 +1,221 @@
import base64
from opik.api_objects.attachment import decoder_helpers
from . import constants
class TestDetectMimeType:
"""Test suite for detect_mime_type() function."""
def test_detect_png(self):
"""Test PNG image detection using magic bytes."""
# PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
png_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
assert decoder_helpers.detect_mime_type(png_data) == "image/png"
def test_detect_jpeg(self):
"""Test JPEG image detection using magic bytes."""
# Real JPEG SOI (FFD8) followed by an APP0/JFIF marker (FFE0), then
# body bytes, then FFD9 end marker.
jpeg_data = b"\xff\xd8\xff\xe0" + b"\x00" * 100 + b"\xff\xd9"
assert decoder_helpers.detect_mime_type(jpeg_data) == "image/jpeg"
def test_detect_jpeg_without_end_marker(self):
"""Test JPEG without proper end marker should not be detected as JPEG."""
# JPEG start but no proper end
jpeg_data = b"\xff\xd8" + b"\x00" * 100
assert decoder_helpers.detect_mime_type(jpeg_data) != "image/jpeg"
def test_detect_gif87a(self):
"""Test GIF87a format detection."""
gif_data = b"GIF87a" + b"\x00" * 100
assert decoder_helpers.detect_mime_type(gif_data) == "image/gif"
def test_detect_gif89a(self):
"""Test GIF89a format detection."""
assert decoder_helpers.detect_mime_type(constants.GIF89_BYTES) == "image/gif"
def test_detect_pdf(self):
"""Test PDF document detection."""
assert (
decoder_helpers.detect_mime_type(constants.PDF_BYTES) == "application/pdf"
)
def test_detect_webp(self):
"""Test WebP image detection."""
assert decoder_helpers.detect_mime_type(constants.WEBP_BYTES) == "image/webp"
def test_detect_svg(self):
"""Test SVG image detection."""
assert decoder_helpers.detect_mime_type(constants.SVG_BYTES) == "image/svg+xml"
def test_detect_svg_with_uppercase(self):
"""Test SVG detection with an uppercase tag."""
svg_data = b"<SVG>content</SVG>"
assert decoder_helpers.detect_mime_type(svg_data) == "image/svg+xml"
def test_detect_svg_with_mixed_case(self):
"""Test SVG detection with mixed case tag."""
svg_data = b"<SvG>content</SvG>"
assert decoder_helpers.detect_mime_type(svg_data) == "image/svg+xml"
def test_detect_mp4(self):
"""Test MP4 video detection."""
# MP4: ftyp at offset 4
mp4_data = b"\x00\x00\x00\x20ftypisom" + b"\x00" * 100
assert decoder_helpers.detect_mime_type(mp4_data) == "video/mp4"
def test_detect_json_object(self):
"""Test JSON object detection."""
json_data = b'{"key": "value", "number": 123}'
assert decoder_helpers.detect_mime_type(json_data) == "application/json"
def test_detect_json_array(self):
"""Test JSON array detection."""
json_data = b'["item1", "item2", "item3"]'
assert decoder_helpers.detect_mime_type(json_data) == "application/json"
def test_detect_json_with_whitespace(self):
"""Test JSON detection with leading whitespace."""
json_data = b' \n\t{"key": "value"}'
assert decoder_helpers.detect_mime_type(json_data) == "application/json"
def test_detect_invalid_json_like(self):
"""Test that invalid JSON-like content is not detected as JSON."""
# UTF-8 decoding should fail
invalid_data = b"\xff\xfe{invalid}"
assert (
decoder_helpers.detect_mime_type(invalid_data) == "application/octet-stream"
)
def test_detect_short_data(self):
"""Test that data shorter than 4 bytes returns octet-stream."""
short_data = b"abc"
assert (
decoder_helpers.detect_mime_type(short_data) == "application/octet-stream"
)
def test_detect_empty_data(self):
"""Test that empty data returns octet-stream."""
empty_data = b""
assert (
decoder_helpers.detect_mime_type(empty_data) == "application/octet-stream"
)
def test_detect_unknown_binary(self):
"""Test that unknown binary data returns octet-stream."""
assert (
decoder_helpers.detect_mime_type(constants.RANDOM_BINARY_BYTES)
== "application/octet-stream"
)
def test_detect_plain_text_not_json(self):
"""Test that plain text (non-JSON) returns octet-stream."""
assert (
decoder_helpers.detect_mime_type(constants.PLAIN_TEXT_BYTES)
== "application/octet-stream"
)
def test_real_png_base64(self):
"""Test with a real minimal PNG image (1x1 transparent pixel)."""
# 1x1 transparent PNG
png_data = base64.b64decode(constants.PNG_BASE64)
assert decoder_helpers.detect_mime_type(png_data) == "image/png"
def test_real_jpeg_base64(self):
"""Test with a real minimal JPEG image."""
# Minimal valid JPEG (1x1 red pixel)
jpeg_data = base64.b64decode(constants.JPEG_BASE64)
assert decoder_helpers.detect_mime_type(jpeg_data) == "image/jpeg"
def test_detect_svg_with_long_content(self):
"""Test SVG detection with content longer than 1024 bytes."""
# Create SVG content longer than 1024 bytes
long_content = "x" * 2000
svg_data = (
f'<svg xmlns="http://www.w3.org/2000/svg">{long_content}</svg>'.encode(
"utf-8"
)
)
assert decoder_helpers.detect_mime_type(svg_data) == "image/svg+xml"
def test_detect_webp_invalid(self):
"""Test that RIFF without WEBP marker is not detected as WebP."""
# RIFF but not WEBP
riff_data = b"RIFF\x00\x00\x00\x00XXXX" + b"\x00" * 100
assert decoder_helpers.detect_mime_type(riff_data) != "image/webp"
def test_detect_mp4_short_data(self):
"""Test that data too short for MP4 detection is not detected as MP4."""
short_mp4 = b"\x00\x00\x00\x20ftyp" # Only 9 bytes
assert decoder_helpers.detect_mime_type(short_mp4) != "video/mp4"
class TestGetFileExtension:
"""Test suite for get_file_extension() function."""
def test_get_extension_png(self):
"""Test PNG extension extraction."""
assert decoder_helpers.get_file_extension("image/png") == "png"
def test_get_extension_jpeg(self):
"""Test JPEG extension extraction (should return 'jpg' not 'jpe')."""
result = decoder_helpers.get_file_extension("image/jpeg")
# The function should return 'jpg' for image/jpeg
assert result in ("jpg", "jpeg")
def test_get_extension_pdf(self):
"""Test PDF extension extraction."""
assert decoder_helpers.get_file_extension("application/pdf") == "pdf"
def test_get_extension_gif(self):
"""Test GIF extension extraction."""
assert decoder_helpers.get_file_extension("image/gif") == "gif"
def test_get_extension_svg(self):
"""Test SVG extension extraction from 'image/svg+xml'."""
assert decoder_helpers.get_file_extension("image/svg+xml") == "svg"
def test_get_extension_webp(self):
"""Test WebP extension extraction."""
assert decoder_helpers.get_file_extension("image/webp") == "webp"
def test_get_extension_mp4(self):
"""Test MP4 extension extraction."""
assert decoder_helpers.get_file_extension("video/mp4") == "mp4"
def test_get_extension_json(self):
"""Test JSON extension extraction."""
assert decoder_helpers.get_file_extension("application/json") == "json"
def test_get_extension_with_parameters(self):
"""Test extension extraction with MIME type parameters."""
assert decoder_helpers.get_file_extension("image/jpeg; charset=utf-8") == "jpeg"
def test_get_extension_empty_string(self):
"""Test that an empty MIME type returns 'bin'."""
assert decoder_helpers.get_file_extension("") == "bin"
def test_get_extension_none(self):
"""Test that a None MIME type returns 'bin'."""
# This will fail the "if not mime_type:" check
result = decoder_helpers.get_file_extension("")
assert result == "bin"
def test_get_extension_invalid_format(self):
"""Test that an invalid MIME type format returns 'bin'."""
assert decoder_helpers.get_file_extension("invalidmimetype") == "bin"
def test_get_extension_unknown_type(self):
"""Test that an unknown MIME type extracts subtype."""
assert (
decoder_helpers.get_file_extension("application/x-custom-type")
== "x-custom-type"
)
def test_get_extension_removes_leading_dot(self):
"""Test that leading dots are removed from extensions."""
# mimetypes.guess_extension can return extensions with leading dots
result = decoder_helpers.get_file_extension("text/plain")
assert not result.startswith(".")
@@ -0,0 +1,87 @@
import datetime
import pytest
from opik.rest_api import TracePublic
from opik.api_objects.conversation import conversation_factory
@pytest.mark.parametrize(
"traces,expected_discussion",
[
(
[
TracePublic(
input={"x": "test input 997"},
output={"output": "test output"},
start_time=datetime.datetime.now(),
)
],
[
{"role": "user", "content": "test input 997"},
{"role": "assistant", "content": "test output"},
],
),
( # test that traces are sorted by time - the first trace should be first
[
TracePublic(
input={"x": "test input 3"},
output={"output": "test output"},
start_time=datetime.datetime.now(),
),
TracePublic(
input={"x": "test input 1"},
output={"output": "test output"},
start_time=datetime.datetime.now() - datetime.timedelta(seconds=10),
),
TracePublic(
input={"x": "test input 2"},
output={"output": "test output"},
start_time=datetime.datetime.now() - datetime.timedelta(seconds=5),
),
],
[
{"role": "user", "content": "test input 1"},
{"role": "assistant", "content": "test output"},
{"role": "user", "content": "test input 2"},
{"role": "assistant", "content": "test output"},
{"role": "user", "content": "test input 3"},
{"role": "assistant", "content": "test output"},
],
),
( # test that trace's input or output are filtered out if it isn't in the expected format
[
TracePublic(
input={"y": "test input 1"}, # wrong input
output={"output": "test output"},
start_time=datetime.datetime.now(),
),
TracePublic(
input={"x": "test input 2"},
output={"result": "test output"}, # wrong output
start_time=datetime.datetime.now(),
),
],
[
{"role": "assistant", "content": "test output"},
{"role": "user", "content": "test input 2"},
],
),
],
)
def test_create_conversation_from_traces(traces, expected_discussion):
def input_transform(input):
if "x" not in input:
return None
return input["x"]
def output_transform(output):
if "output" not in output:
return None
return output["output"]
discussion = conversation_factory.create_conversation_from_traces(
traces, input_transform, output_transform
)
assert discussion.as_json_list() == expected_discussion
@@ -0,0 +1,668 @@
import copy
import pytest
from unittest.mock import Mock, patch
from opik import exceptions
from opik.api_objects import opik_client
from opik.api_objects.dashboard import types
from opik.api_objects.dashboard.dashboard import Dashboard
from opik.rest_api.types import dashboard_public as dp
class FakeDashboardsApi:
def __init__(self, store):
self._store = store
self.update_calls = []
self.deleted = False
def update_dashboard(
self, dashboard_id, *, name=None, type=None, description=None, config=None
):
self.update_calls.append(
{"name": name, "type": type, "description": description, "config": config}
)
if config is not None:
self._store["config"] = config
if name is not None:
self._store["name"] = name
if description is not None:
self._store["description"] = description
return self._make()
def get_dashboard_by_id(self, dashboard_id):
return self._make()
def delete_dashboard(self, dashboard_id):
self.deleted = True
def _make(self):
return dp.DashboardPublic(
id=self._store["id"],
name=self._store["name"],
type=self._store.get("type"),
description=self._store.get("description"),
config=self._store["config"],
)
class FakeRest:
def __init__(self, store):
self.dashboards = FakeDashboardsApi(store)
def _make_dashboard(config, dashboard_type="multi_project", project_id=None):
store = {"id": "d1", "name": "My dash", "type": dashboard_type, "config": config}
rest = FakeRest(store)
public = dp.DashboardPublic(
id="d1",
name="My dash",
type=dashboard_type,
config=copy.deepcopy(config),
project_id=project_id,
)
client = Mock(spec=opik_client.Opik)
return (
Dashboard(dashboard_public=public, rest_client=rest, client=client),
rest,
store,
)
def _config_with_legacy_widget():
return {
"version": 4,
"mysteryTopLevel": "keep-me",
"sections": [
{
"id": "s1",
"title": "Overview",
"widgets": [
{
"id": "old",
"type": "future_widget",
"config": {"unknownField": 123},
"futureWidgetProp": "x",
}
],
"layout": [{"i": "old", "x": 0, "y": 0, "w": 2, "h": 2}],
}
],
"lastModified": 111,
}
def test_add_widget__preserves_unknown_fields_round_trip():
dashboard, rest, _ = _make_dashboard(_config_with_legacy_widget())
widget_id = dashboard.add_widget(
types.DashboardWidget(
type=types.WidgetType.TEXT_MARKDOWN,
title="Notes",
config=types.TextMarkdownConfig(content="hello"),
),
)
config = dashboard.config
assert config["mysteryTopLevel"] == "keep-me"
old = next(w for w in config["sections"][0]["widgets"] if w["id"] == "old")
assert old["futureWidgetProp"] == "x"
assert old["config"]["unknownField"] == 123
assert widget_id in [w["id"] for w in config["sections"][0]["widgets"]]
assert widget_id in [i["i"] for i in config["sections"][0]["layout"]]
assert config["version"] == 4
assert config["lastModified"] != 111
def test_add_widget__project_scoped_injects_project_id():
config = {
"version": 4,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, _, _ = _make_dashboard(config, project_id="proj-xyz")
widget_id = dashboard.add_widget(
types.DashboardWidget(
type=types.WidgetType.PROJECT_STATS_CARD,
config=types.ProjectStatsCardConfig(
metric=types.StatsCardMetric.TRACE_COUNT
),
),
)
widget = dashboard.config["sections"][0]["widgets"][0]
assert widget["id"] == widget_id
assert widget["config"]["projectId"] == "proj-xyz"
def test_add_widget__project_scoped_without_dashboard_project_raises():
config = {
"version": 4,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, rest, _ = _make_dashboard(config, project_id=None)
with pytest.raises(exceptions.DashboardValidationError, match="project-scoped"):
dashboard.add_widget(
types.DashboardWidget(type=types.WidgetType.PROJECT_STATS_CARD),
)
assert rest.dashboards.update_calls == []
def test_add_widget__incompatible_type_raises_before_write():
config = {
"version": 4,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, rest, _ = _make_dashboard(config, dashboard_type="multi_project")
with pytest.raises(exceptions.DashboardValidationError, match="not supported"):
dashboard.add_widget(
types.DashboardWidget(type=types.WidgetType.EXPERIMENT_LEADERBOARD)
)
assert rest.dashboards.update_calls == []
def test_add_widget__unknown_section_raises():
config = {
"version": 4,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, _, _ = _make_dashboard(config)
with pytest.raises(exceptions.DashboardValidationError, match="not found"):
dashboard.add_widget(
types.DashboardWidget(type=types.WidgetType.TEXT_MARKDOWN),
section_id="missing",
)
def test_mutation__unknown_version_refused():
config = {
"version": 5,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, rest, _ = _make_dashboard(config)
with pytest.raises(exceptions.DashboardValidationError, match="schema version 5"):
dashboard.add_widget(types.DashboardWidget(type=types.WidgetType.TEXT_MARKDOWN))
assert rest.dashboards.update_calls == []
def test_reads_never_fail__on_unknown_widget_type():
dashboard, _, _ = _make_dashboard(_config_with_legacy_widget())
state = dashboard.state
assert len(state.sections) == 1
assert state.sections[0].widgets[0].type == "future_widget"
def test_update_widget__merges_config():
config = {
"version": 4,
"sections": [
{
"id": "s1",
"title": "t",
"widgets": [
{"id": "w1", "type": "text_markdown", "config": {"content": "a"}}
],
"layout": [{"i": "w1", "x": 0, "y": 0, "w": 2, "h": 4}],
}
],
"lastModified": 1,
}
dashboard, rest, _ = _make_dashboard(config)
dashboard.update_widget("w1", title="New", config={"content": "b"})
widget = dashboard.config["sections"][0]["widgets"][0]
assert widget["title"] == "New"
assert widget["config"]["content"] == "b"
assert len(rest.dashboards.update_calls) == 1
assert rest.dashboards.update_calls[0]["config"] is not None
def test_remove_widget__removes_widget_and_layout():
config = {
"version": 4,
"sections": [
{
"id": "s1",
"title": "t",
"widgets": [
{"id": "w1", "type": "text_markdown", "config": {}},
{"id": "w2", "type": "text_markdown", "config": {}},
],
"layout": [
{"i": "w1", "x": 0, "y": 0, "w": 2, "h": 4},
{"i": "w2", "x": 2, "y": 0, "w": 2, "h": 4},
],
}
],
"lastModified": 1,
}
dashboard, _, _ = _make_dashboard(config)
dashboard.remove_widget("w1")
section = dashboard.config["sections"][0]
assert [w["id"] for w in section["widgets"]] == ["w2"]
assert [i["i"] for i in section["layout"]] == ["w2"]
def test_remove_widget__missing_raises():
config = {
"version": 4,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, _, _ = _make_dashboard(config)
with pytest.raises(exceptions.DashboardValidationError, match="not found"):
dashboard.remove_widget("ghost")
def test_rename__sends_name_only():
dashboard, rest, _ = _make_dashboard(_config_with_legacy_widget())
dashboard.rename("Renamed")
assert dashboard.name == "Renamed"
assert rest.dashboards.update_calls[-1]["name"] == "Renamed"
assert rest.dashboards.update_calls[-1]["config"] is None
def test_set_description__sends_description_only():
dashboard, rest, _ = _make_dashboard(_config_with_legacy_widget())
dashboard.set_description("desc")
assert dashboard.description == "desc"
assert rest.dashboards.update_calls[-1]["description"] == "desc"
assert rest.dashboards.update_calls[-1]["config"] is None
def test_add_section__appends_and_returns_id():
config = {
"version": 4,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, _, _ = _make_dashboard(config)
new_id = dashboard.add_section("Second")
section_ids = [s["id"] for s in dashboard.config["sections"]]
assert new_id in section_ids
assert len(section_ids) == 2
def test_delete__calls_rest():
dashboard, rest, _ = _make_dashboard(_config_with_legacy_widget())
dashboard.delete()
assert rest.dashboards.deleted is True
# --- Opik client-level methods ---
def test_create_dashboard__default_section_and_config():
client = opik_client.Opik()
captured = {}
def fake_create(*, name, config, type, description, project_id, project_name):
captured.update(
name=name,
config=config,
type=type,
description=description,
project_id=project_id,
project_name=project_name,
)
return dp.DashboardPublic(id="d1", name=name, type=type, config=config)
with patch.object(
client._rest_client.dashboards, "create_dashboard", side_effect=fake_create
):
dashboard = client.create_dashboard(
name="Prod", type=types.DashboardType.MULTI_PROJECT
)
assert dashboard.name == "Prod"
assert captured["type"] == "multi_project"
assert captured["config"]["version"] == types.DASHBOARD_VERSION
assert len(captured["config"]["sections"]) == 1
assert captured["config"]["sections"][0]["title"] == "Overview"
assert "lastModified" in captured["config"]
def test_create_dashboard__with_provided_sections():
client = opik_client.Opik()
captured = {}
def fake_create(*, name, config, type, description, project_id, project_name):
captured["config"] = config
return dp.DashboardPublic(id="d1", name=name, type=type, config=config)
section = types.DashboardSection(
title="Custom",
widgets=[types.DashboardWidget(id="w1", type=types.WidgetType.TEXT_MARKDOWN)],
layout=[types.DashboardLayoutItem(id="w1", x=0, y=0, w=2, h=4)],
)
with patch.object(
client._rest_client.dashboards, "create_dashboard", side_effect=fake_create
):
client.create_dashboard(name="Prod", sections=[section])
assert captured["config"]["sections"][0]["title"] == "Custom"
assert captured["config"]["sections"][0]["widgets"][0]["id"] == "w1"
def test_create_dashboard__project_scoped_widget_with_project_id_injects():
client = opik_client.Opik()
def fake_create(*, name, config, type, description, project_id, project_name):
return dp.DashboardPublic(id="d1", name=name, type=type, config=config)
section = types.DashboardSection(
title="S",
widgets=[
types.DashboardWidget(
id="w1",
type=types.WidgetType.PROJECT_STATS_CARD,
config=types.ProjectStatsCardConfig(
metric=types.StatsCardMetric.TRACE_COUNT
),
)
],
layout=[types.DashboardLayoutItem(id="w1", x=0, y=0, w=1, h=2)],
)
with patch.object(
client._rest_client.dashboards, "create_dashboard", side_effect=fake_create
):
dashboard = client.create_dashboard(
name="Prod",
type=types.DashboardType.MULTI_PROJECT,
project_id="proj-123",
sections=[section],
)
widget = dashboard.config["sections"][0]["widgets"][0]
assert widget["config"]["projectId"] == "proj-123"
def test_create_dashboard__project_scoped_widget_with_project_name_only_does_not_raise():
client = opik_client.Opik()
def fake_create(*, name, config, type, description, project_id, project_name):
return dp.DashboardPublic(id="d1", name=name, type=type, config=config)
section = types.DashboardSection(
title="S",
widgets=[
types.DashboardWidget(
id="w1",
type=types.WidgetType.PROJECT_STATS_CARD,
config=types.ProjectStatsCardConfig(
metric=types.StatsCardMetric.TRACE_COUNT
),
)
],
layout=[types.DashboardLayoutItem(id="w1", x=0, y=0, w=1, h=2)],
)
# project_name provided but not project_id — should not raise
with patch.object(
client._rest_client.dashboards, "create_dashboard", side_effect=fake_create
):
client.create_dashboard(
name="Prod",
type=types.DashboardType.MULTI_PROJECT,
project_name="Default Project",
sections=[section],
)
def test_create_dashboard__project_scoped_widget_without_any_project_raises():
client = opik_client.Opik()
section = types.DashboardSection(
title="S",
widgets=[
types.DashboardWidget(id="w1", type=types.WidgetType.PROJECT_STATS_CARD)
],
layout=[types.DashboardLayoutItem(id="w1", x=0, y=0, w=1, h=2)],
)
with patch.object(
client._rest_client.dashboards, "create_dashboard"
) as mock_create:
with pytest.raises(exceptions.DashboardValidationError, match="project-scoped"):
client.create_dashboard(
name="Prod",
type=types.DashboardType.MULTI_PROJECT,
sections=[section],
)
mock_create.assert_not_called()
def test_create_dashboard__incompatible_widget_raises_before_create():
client = opik_client.Opik()
section = types.DashboardSection(
title="Custom",
widgets=[
types.DashboardWidget(id="w1", type=types.WidgetType.EXPERIMENT_LEADERBOARD)
],
layout=[types.DashboardLayoutItem(id="w1", x=0, y=0, w=6, h=6)],
)
with patch.object(
client._rest_client.dashboards, "create_dashboard"
) as mock_create:
with pytest.raises(exceptions.DashboardValidationError, match="not supported"):
client.create_dashboard(
name="Prod",
type=types.DashboardType.MULTI_PROJECT,
sections=[section],
)
mock_create.assert_not_called()
def test_replace_sections__injects_project_id_for_project_scoped_widgets():
config = {
"version": 4,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, _, _ = _make_dashboard(config, project_id="proj-abc")
new_section = types.DashboardSection(
title="t",
id="s1",
widgets=[
types.DashboardWidget(
id="w1",
type=types.WidgetType.PROJECT_STATS_CARD,
config=types.ProjectStatsCardConfig(
metric=types.StatsCardMetric.TRACE_COUNT
),
)
],
layout=[types.DashboardLayoutItem(id="w1", x=0, y=0, w=1, h=2)],
)
dashboard.replace_sections([new_section])
widget = dashboard.config["sections"][0]["widgets"][0]
assert widget["config"]["projectId"] == "proj-abc"
def test_replace_sections__project_scoped_widget_without_dashboard_project_raises():
config = {
"version": 4,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, rest, _ = _make_dashboard(config, project_id=None)
new_section = types.DashboardSection(
title="t",
id="s1",
widgets=[
types.DashboardWidget(id="w1", type=types.WidgetType.PROJECT_STATS_CARD)
],
layout=[types.DashboardLayoutItem(id="w1", x=0, y=0, w=1, h=2)],
)
with pytest.raises(exceptions.DashboardValidationError, match="project-scoped"):
dashboard.replace_sections([new_section])
assert rest.dashboards.update_calls == []
def test_replace_sections__incompatible_widget_raises_before_write():
config = {
"version": 4,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, rest, _ = _make_dashboard(config, dashboard_type="multi_project")
section = types.DashboardSection(
title="Custom",
widgets=[
types.DashboardWidget(id="w1", type=types.WidgetType.EXPERIMENT_LEADERBOARD)
],
layout=[types.DashboardLayoutItem(id="w1", x=0, y=0, w=6, h=6)],
)
with pytest.raises(exceptions.DashboardValidationError, match="not supported"):
dashboard.replace_sections([section])
assert rest.dashboards.update_calls == []
def test_get_dashboard__returns_wrapper():
client = opik_client.Opik()
public = dp.DashboardPublic(
id="d1",
name="My dash",
type="multi_project",
config={"version": 4, "sections": []},
)
with patch.object(
client._rest_client.dashboards, "get_dashboard_by_id", return_value=public
):
dashboard = client.get_dashboard("d1")
assert dashboard.id == "d1"
assert dashboard.name == "My dash"
def test_delete_dashboard__calls_rest():
client = opik_client.Opik()
with patch.object(
client._rest_client.dashboards, "delete_dashboard"
) as mock_delete:
client.delete_dashboard("d1")
mock_delete.assert_called_once_with("d1")
def test_add_widget__null_id_is_replaced():
config = {
"version": 4,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, _, _ = _make_dashboard(config)
widget_id = dashboard.add_widget(
{"id": None, "type": "text_markdown", "config": {"content": "hi"}}
)
assert widget_id != "None"
assert widget_id is not None
layout_ids = [i["i"] for i in dashboard.config["sections"][0]["layout"]]
assert widget_id in layout_ids
def test_add_widget__partial_size_raises():
config = {
"version": 4,
"sections": [{"id": "s1", "title": "t", "widgets": [], "layout": []}],
"lastModified": 1,
}
dashboard, rest, _ = _make_dashboard(config)
with pytest.raises(exceptions.DashboardValidationError, match="'w' and 'h'"):
dashboard.add_widget(
types.DashboardWidget(type=types.WidgetType.TEXT_MARKDOWN),
size={"w": 2},
)
assert rest.dashboards.update_calls == []
def test_update_widget__null_config_from_api_does_not_raise():
config = {
"version": 4,
"sections": [
{
"id": "s1",
"title": "t",
"widgets": [{"id": "w1", "type": "text_markdown", "config": None}],
"layout": [{"i": "w1", "x": 0, "y": 0, "w": 2, "h": 4}],
}
],
"lastModified": 1,
}
dashboard, _, _ = _make_dashboard(config)
dashboard.update_widget("w1", config={"content": "new"})
widget = dashboard.config["sections"][0]["widgets"][0]
assert widget["config"]["content"] == "new"
def test_find_dashboards__paginates_and_respects_max_results():
from opik.api_objects.dashboard import rest_operations
from opik.rest_api.types import dashboard_page_public as dpp
pages = {
1: [
dp.DashboardPublic(
id=f"d{i}", name=f"d{i}", config={"version": 4, "sections": []}
)
for i in range(100)
],
2: [
dp.DashboardPublic(
id="d100", name="d100", config={"version": 4, "sections": []}
)
],
}
class PaginatedApi:
def __init__(self):
self.calls = []
def find_dashboards(self, *, page, size, name, project_id, sorting, filters):
self.calls.append(page)
return dpp.DashboardPagePublic(content=pages.get(page, []))
class Rest:
def __init__(self):
self.dashboards = PaginatedApi()
rest = Rest()
result = rest_operations.find_dashboards(
rest_client=rest, client=Mock(spec=opik_client.Opik), max_results=100
)
assert len(result) == 100
# only the first page is needed to satisfy max_results
assert rest.dashboards.calls == [1]
assert all(isinstance(d, Dashboard) for d in result)
@@ -0,0 +1,191 @@
"""Ported from apps/opik-frontend/src/lib/dashboard/layout.test.ts.
Keeps the SDK auto-layout behavior identical to the frontend so widgets added
via the SDK are positioned the same way the UI would position them.
"""
import pytest
from opik.api_objects.dashboard import layout
from opik.api_objects.dashboard.types import DashboardLayoutItem, WidgetType
def _item(id: str, x: int, y: int, w: int, h: int) -> DashboardLayoutItem:
return DashboardLayoutItem(id=id, x=x, y=y, w=w, h=h)
def test_grid_constants__match_frontend():
assert layout.GRID_COLUMNS == 6
assert layout.MAX_WIDGET_HEIGHT == 12
assert layout.MIN_WIDGET_WIDTH == 1
assert layout.MIN_WIDGET_HEIGHT == 1
@pytest.mark.parametrize(
"widget_type,expected",
[
(WidgetType.PROJECT_METRICS.value, {"w": 2, "h": 4, "minW": 2, "minH": 4}),
(WidgetType.PROJECT_STATS_CARD.value, {"w": 1, "h": 2, "minW": 1, "minH": 2}),
(WidgetType.TEXT_MARKDOWN.value, {"w": 2, "h": 4, "minW": 1, "minH": 4}),
("unknown-type", {"w": 2, "h": 2, "minW": 1, "minH": 1}),
],
)
def test_get_widget_size_config__per_type(widget_type, expected):
assert layout.get_widget_size_config(widget_type) == expected
@pytest.mark.parametrize(
"layout_items,expected",
[
([], [0, 0, 0, 0, 0, 0]),
([_item("w1", 0, 0, 2, 3)], [3, 3, 0, 0, 0, 0]),
(
[_item("w1", 0, 0, 2, 3), _item("w2", 2, 0, 2, 5)],
[3, 3, 5, 5, 0, 0],
),
(
[_item("w1", 0, 0, 2, 3), _item("w2", 1, 3, 2, 2)],
[3, 5, 5, 0, 0, 0],
),
([_item("w1", 0, 0, 6, 4)], [4, 4, 4, 4, 4, 4]),
(
[_item("w1", 0, 2, 2, 3), _item("w2", 2, 0, 2, 2)],
[5, 5, 2, 2, 0, 0],
),
],
)
def test_get_column_heights(layout_items, expected):
assert layout.get_column_heights(layout_items) == expected
@pytest.mark.parametrize(
"w,h,heights,expected",
[
(2, 3, [0, 0, 0, 0, 0, 0], {"x": 0, "y": 0}),
(2, 2, [3, 3, 0, 0, 0, 0], {"x": 2, "y": 0}),
(2, 2, [3, 3, 5, 5, 4, 4], {"x": 0, "y": 3}),
(6, 1, [2, 2, 3, 3, 2, 2], {"x": 0, "y": 3}),
(2, 3, [5, 5, 2, 2, 8, 8], {"x": 2, "y": 2}),
(1, 2, [3, 0, 2, 4, 1, 5], {"x": 1, "y": 0}),
],
)
def test_find_first_available_position(w, h, heights, expected):
assert layout.find_first_available_position(w, h, heights) == expected
def test_calculate_layout_for_adding_widget__first_widget_to_empty_layout():
result = layout.calculate_layout_for_adding_widget(
[], WidgetType.TEXT_MARKDOWN.value, "widget-1"
)
assert len(result) == 1
assert result[0].id == "widget-1"
assert (result[0].x, result[0].y, result[0].w, result[0].h) == (0, 0, 2, 4)
def test_calculate_layout_for_adding_widget__custom_size():
result = layout.calculate_layout_for_adding_widget(
[], WidgetType.TEXT_MARKDOWN.value, "widget-1", size={"w": 3, "h": 5}
)
assert result[0].w == 3
assert result[0].h == 5
def test_calculate_layout_for_adding_widget__clamps_oversized_custom_size():
result = layout.calculate_layout_for_adding_widget(
[],
WidgetType.TEXT_MARKDOWN.value,
"widget-1",
size={"w": 100, "h": 100},
)
assert result[0].w == layout.GRID_COLUMNS
assert result[0].h == layout.MAX_WIDGET_HEIGHT
def test_calculate_layout_for_adding_widget__clamps_undersized_custom_size():
result = layout.calculate_layout_for_adding_widget(
[],
WidgetType.PROJECT_METRICS.value,
"widget-1",
size={"w": 1, "h": 1},
)
size_config = layout.get_widget_size_config(WidgetType.PROJECT_METRICS.value)
assert result[0].w == size_config["minW"]
assert result[0].h == size_config["minH"]
def test_calculate_layout_for_adding_widget__second_widget_placed_next_to_first():
existing = [_item("widget-1", 0, 0, 2, 4)]
result = layout.calculate_layout_for_adding_widget(
existing, WidgetType.TEXT_MARKDOWN.value, "widget-2"
)
assert len(result) == 2
assert result[1].id == "widget-2"
assert (result[1].x, result[1].y) == (2, 0)
def test_calculate_layout_for_adding_widget__sets_min_and_max_constraints():
result = layout.calculate_layout_for_adding_widget(
[], WidgetType.PROJECT_METRICS.value, "widget-1"
)
assert result[0].min_w == 2
assert result[0].min_h == 4
assert result[0].max_w == layout.GRID_COLUMNS
assert result[0].max_h == layout.MAX_WIDGET_HEIGHT
def test_calculate_layout_for_adding_widget__optimal_position_with_complex_layout():
existing = [_item("widget-1", 0, 0, 3, 4), _item("widget-2", 3, 0, 3, 2)]
result = layout.calculate_layout_for_adding_widget(
existing, WidgetType.PROJECT_STATS_CARD.value, "widget-3"
)
assert result[2].y == 2
def test_normalize_layout__empty():
assert layout.normalize_layout([]) == []
def test_normalize_layout__clamps_position_to_grid_bounds():
normalized = layout.normalize_layout([_item("w1", 7, -1, 2, 3)])
assert normalized[0].x == 4
assert normalized[0].y == 0
def test_normalize_layout__clamps_width_to_grid_columns():
normalized = layout.normalize_layout([_item("w1", 0, 0, 10, 3)])
assert normalized[0].w == layout.GRID_COLUMNS
def test_normalize_layout__clamps_height_to_max():
normalized = layout.normalize_layout([_item("w1", 0, 0, 2, 20)])
assert normalized[0].h == layout.MAX_WIDGET_HEIGHT
def test_normalize_layout__enforces_minimum_dimensions():
normalized = layout.normalize_layout([_item("w1", 0, 0, 0, 0)])
assert normalized[0].w == layout.MIN_WIDGET_WIDTH
assert normalized[0].h == layout.MIN_WIDGET_HEIGHT
def test_normalize_layout__applies_widget_specific_constraints():
widgets = [{"id": "w1", "type": WidgetType.PROJECT_METRICS.value}]
normalized = layout.normalize_layout([_item("w1", 0, 0, 1, 2)], widgets)
assert normalized[0].min_w == 2
assert normalized[0].min_h == 4
assert normalized[0].w == 2
assert normalized[0].h == 4
def test_remove_widget_from_layout():
items = [_item("w1", 0, 0, 2, 3), _item("w2", 2, 0, 2, 3), _item("w3", 4, 0, 2, 3)]
result = layout.remove_widget_from_layout(items, "w2")
assert [item.id for item in result] == ["w1", "w3"]
# original not mutated
assert len(items) == 3
def test_remove_widget_from_layout__non_existent_id_is_noop():
items = [_item("w1", 0, 0, 2, 3)]
result = layout.remove_widget_from_layout(items, "ghost")
assert len(result) == 1
assert result[0].id == "w1"
@@ -0,0 +1,104 @@
import pytest
from opik import exceptions
from opik.api_objects.dashboard import types
from ....testlib import assert_equal
def test_widget_serialization__uses_camelcase_and_enum_values():
widget = types.DashboardWidget(
type=types.WidgetType.PROJECT_STATS_CARD,
title="Traces",
config=types.ProjectStatsCardConfig(metric=types.StatsCardMetric.TRACE_COUNT),
)
result = widget.to_jsonable()
assert result["type"] == "project_stats_card"
assert result["title"] == "Traces"
assert "id" in result
assert_equal(
{
"source": "traces",
"metric": "trace_count",
},
result["config"],
)
def test_project_metrics_config__defaults_and_breakdown_camelcase():
config = types.ProjectMetricsConfig(
metric_type=types.ProjectMetricType.DURATION,
breakdown=types.BreakdownConfig(
field=types.BreakdownField.METADATA, metadata_key="provider"
),
)
result = config.to_jsonable()
assert result["metricType"] == "DURATION"
assert result["chartType"] == "line"
assert_equal({"field": "metadata", "metadataKey": "provider"}, result["breakdown"])
def test_breakdown_config__metadata_field_requires_metadata_key():
with pytest.raises(exceptions.DashboardValidationError):
types.BreakdownConfig(field=types.BreakdownField.METADATA)
def test_breakdown_config__non_metadata_field_does_not_require_key():
config = types.BreakdownConfig(field=types.BreakdownField.TAGS)
assert config.to_jsonable() == {"field": "tags"}
def test_leaderboard_config__enable_ranking_requires_ranking_metric():
with pytest.raises(exceptions.DashboardValidationError):
types.ExperimentLeaderboardConfig(enable_ranking=True)
def test_leaderboard_config__enable_ranking_with_metric_ok():
config = types.ExperimentLeaderboardConfig(
enable_ranking=True, ranking_metric="pass_rate"
)
assert config.to_jsonable()["rankingMetric"] == "pass_rate"
@pytest.mark.parametrize("value", [0, 101, 500, "0", "200"])
def test_leaderboard_config__max_rows_out_of_range_raises(value):
with pytest.raises(exceptions.DashboardValidationError):
types.ExperimentLeaderboardConfig(max_rows=value)
@pytest.mark.parametrize("value", [1, 100, "10", 50])
def test_leaderboard_config__max_rows_in_range_ok(value):
config = types.ExperimentLeaderboardConfig(max_rows=value)
assert config.to_jsonable()["maxRows"] == value
@pytest.mark.parametrize("value", [0, 101])
def test_feedback_scores_config__max_experiments_out_of_range_raises(value):
with pytest.raises(exceptions.DashboardValidationError):
types.ExperimentsFeedbackScoresConfig(max_experiments_count=value)
def test_dashboard_state__defaults_to_known_version():
state = types.DashboardState()
result = state.to_jsonable()
assert result["version"] == types.DASHBOARD_VERSION
assert "lastModified" in result
assert result["sections"] == []
def test_widget_accepts_raw_dict_config():
widget = types.DashboardWidget(type="future_widget", config={"someCamelField": 1})
assert widget.to_jsonable()["config"] == {"someCamelField": 1}
def test_models_preserve_unknown_fields_on_parse():
state = types.DashboardState.model_validate(
{
"version": 4,
"lastModified": 1,
"sections": [],
"mysteryField": "keep",
}
)
assert state.to_jsonable()["mysteryField"] == "keep"
@@ -0,0 +1,169 @@
from unittest import mock
import pytest
from opik import exceptions
from opik.api_objects.dashboard import types, validation
def _section(section_id, widgets, layout_ids):
return {
"id": section_id,
"title": "t",
"widgets": [{"id": w, "type": "text_markdown", "config": {}} for w in widgets],
"layout": [{"i": i, "x": 0, "y": 0, "w": 1, "h": 1} for i in layout_ids],
}
def test_validate_structure__valid_passes():
state = {"version": 4, "sections": [_section("s1", ["w1"], ["w1"])]}
validation.validate_structure(state)
def test_validate_structure__widget_without_layout_raises():
state = {"version": 4, "sections": [_section("s1", ["w1"], [])]}
with pytest.raises(exceptions.DashboardValidationError, match="without a layout"):
validation.validate_structure(state)
def test_validate_structure__orphan_layout_item_raises():
state = {"version": 4, "sections": [_section("s1", [], ["ghost"])]}
with pytest.raises(exceptions.DashboardValidationError, match="missing widgets"):
validation.validate_structure(state)
def test_validate_structure__duplicate_widget_ids_across_sections_raises():
state = {
"version": 4,
"sections": [
_section("s1", ["dup"], ["dup"]),
_section("s2", ["dup"], ["dup"]),
],
}
with pytest.raises(exceptions.DashboardValidationError, match="Duplicate widget"):
validation.validate_structure(state)
def test_validate_structure__duplicate_section_ids_raises():
state = {
"version": 4,
"sections": [_section("s1", ["w1"], ["w1"]), _section("s1", ["w2"], ["w2"])],
}
with pytest.raises(exceptions.DashboardValidationError, match="Duplicate section"):
validation.validate_structure(state)
def test_validate_widget_for_dashboard__incompatible_type_raises():
widget = {"type": "experiment_leaderboard", "config": {}}
with pytest.raises(exceptions.DashboardValidationError, match="not supported"):
validation.validate_widget_for_dashboard(widget, "multi_project")
def test_validate_widget_for_dashboard__compatible_type_ok():
widget = {"type": "project_metrics", "config": {"metricType": "TRACE_COUNT"}}
validation.validate_widget_for_dashboard(widget, "multi_project")
def test_validate_widget_for_dashboard__no_dashboard_type_skips_compatibility():
widget = {"type": "experiment_leaderboard", "config": {}}
validation.validate_widget_for_dashboard(widget, None)
def test_validate_widget_for_dashboard__unknown_metric_warns_not_raises():
widget = {"type": "project_metrics", "config": {"metricType": "not_a_metric"}}
with mock.patch.object(validation.LOGGER, "warning") as mock_warning:
validation.validate_widget_for_dashboard(widget, "multi_project")
mock_warning.assert_called_once()
assert "Unknown project_metrics metricType" in mock_warning.call_args[0][0]
def test_validate_widget_for_dashboard__dynamic_feedback_metric_no_warning():
widget = {
"type": "project_stats_card",
"config": {"metric": "feedback_scores.helpfulness"},
}
with mock.patch.object(validation.LOGGER, "warning") as mock_warning:
validation.validate_widget_for_dashboard(widget, "multi_project")
mock_warning.assert_not_called()
def test_validate_writable_version__known_version_ok():
validation.validate_writable_version(4)
validation.validate_writable_version(None)
def test_validate_writable_version__unknown_version_raises():
with pytest.raises(exceptions.DashboardValidationError, match="schema version 5"):
validation.validate_writable_version(5)
def test_as_widget_dict__from_model__returns_jsonable():
widget = types.DashboardWidget(
id="w1", type=types.WidgetType.TEXT_MARKDOWN, config=types.TextMarkdownConfig()
)
result = validation.as_widget_dict(widget)
assert result["id"] == "w1"
assert result["type"] == "text_markdown"
def test_as_widget_dict__from_dict__returns_same_dict():
d = {"id": "w1", "type": "text_markdown", "config": {}}
result = validation.as_widget_dict(d)
assert result is d
def test_as_widget_dict__invalid_type__raises():
with pytest.raises(
exceptions.DashboardValidationError, match="Expected a DashboardWidget"
):
validation.as_widget_dict(42)
def test_as_section_dicts__from_models__returns_jsonable():
section = types.DashboardSection(title="S1")
result = validation.as_section_dicts([section])
assert len(result) == 1
assert result[0]["title"] == "S1"
assert "id" in result[0]
def test_as_section_dicts__from_dicts__returns_same_dicts():
d = {"id": "s1", "title": "S1", "widgets": [], "layout": []}
result = validation.as_section_dicts([d])
assert result[0] is d
def test_as_section_dicts__invalid_type__raises():
with pytest.raises(
exceptions.DashboardValidationError, match="Expected a DashboardSection"
):
validation.as_section_dicts(["not-a-section"])
def test_inject_project_id__project_scoped_widget_injects():
widget = {"type": "project_stats_card", "config": {"metric": "trace_count"}}
validation.inject_project_id(widget, "proj-123")
assert widget["config"]["projectId"] == "proj-123"
def test_inject_project_id__project_metrics_injects():
widget = {"type": "project_metrics", "config": {"metricType": "DURATION"}}
validation.inject_project_id(widget, "proj-abc")
assert widget["config"]["projectId"] == "proj-abc"
def test_inject_project_id__non_project_widget_untouched():
widget = {"type": "text_markdown", "config": {"content": "hi"}}
validation.inject_project_id(widget, "proj-123")
assert "projectId" not in widget["config"]
def test_inject_project_id__no_project_id_raises():
widget = {"type": "project_stats_card", "config": {}}
with pytest.raises(exceptions.DashboardValidationError, match="project-scoped"):
validation.inject_project_id(widget, None)
def test_inject_project_id__no_project_id_for_non_project_widget_ok():
widget = {"type": "text_markdown", "config": {}}
validation.inject_project_id(widget, None)
@@ -0,0 +1,432 @@
import pandas as pd
import pandas.testing
import json
import tempfile
import os
from opik.api_objects.dataset import converters
from opik.api_objects.dataset.dataset_item import DatasetItem
from ....testlib import ANY_BUT_NONE
def test_from_pandas__all_columns_from_dataframe_represent_all_dataset_item_fields():
data_for_dataframe = {
"id": ["id-1", "id-2"],
"input": [{"input-key-1": "input-1"}, {"input-key-2": "input-2"}],
"expected_output": [
{"expected-output-key-1": "expected-output-1"},
{"expected-output-key-2": "expected-output-2"},
],
"metadata": [{"metadata-key-1": "v1"}, {"metadata-key-2": "v2"}],
"span_id": ["span-id-1", "span-id-2"],
"trace_id": ["trace-id-1", "trace-id-2"],
"source": ["some-source-1", "some-source-2"],
}
EXPECTED_ITEMS = [
DatasetItem(
id="id-1",
input={"input-key-1": "input-1"},
expected_output={"expected-output-key-1": "expected-output-1"},
metadata={"metadata-key-1": "v1"},
span_id="span-id-1",
trace_id="trace-id-1",
source="some-source-1",
),
DatasetItem(
id="id-2",
input={"input-key-2": "input-2"},
expected_output={"expected-output-key-2": "expected-output-2"},
metadata={"metadata-key-2": "v2"},
span_id="span-id-2",
trace_id="trace-id-2",
source="some-source-2",
),
]
dataframe = pd.DataFrame(data_for_dataframe)
actual_items = converters.from_pandas(
dataframe=dataframe, keys_mapping={}, ignore_keys=[]
)
assert actual_items == EXPECTED_ITEMS
def test_from_pandas__only_input_presented_in_dataframe__items_are_constructed_with_default_values_for_missing_fields():
data_for_dataframe = {
"input": [{"input-key-1": "input-1"}, {"input-key-2": "input-2"}],
}
EXPECTED_ITEMS = [
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-1": "input-1"},
source="sdk",
),
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-2": "input-2"},
source="sdk",
),
]
dataframe = pd.DataFrame(data_for_dataframe)
actual_items = converters.from_pandas(
dataframe=dataframe, keys_mapping={}, ignore_keys=[]
)
assert actual_items == EXPECTED_ITEMS
def test_from_pandas__dataframe_column_does_not_have_the_same_name_as_dataset_item_field__keys_mapping_is_used():
data_for_dataframe = {
"Input column name": [{"input-key-1": "input-1"}, {"input-key-2": "input-2"}],
}
EXPECTED_ITEMS = [
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-1": "input-1"},
source="sdk",
),
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-2": "input-2"},
source="sdk",
),
]
dataframe = pd.DataFrame(data_for_dataframe)
actual_items = converters.from_pandas(
dataframe=dataframe, keys_mapping={"Input column name": "input"}, ignore_keys=[]
)
assert actual_items == EXPECTED_ITEMS
def test_from_pandas__dataframe_contains_extra_column_not_needed_for_dataset_item__ignore_keys_is_used():
data_for_dataframe = {
"input": [{"input-key-1": "input-1"}, {"input-key-2": "input-2"}],
"some-extra-column": [1, 2],
}
EXPECTED_ITEMS = [
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-1": "input-1"},
source="sdk",
),
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-2": "input-2"},
source="sdk",
),
]
dataframe = pd.DataFrame(data_for_dataframe)
actual_items = converters.from_pandas(
dataframe=dataframe, keys_mapping={}, ignore_keys=["some-extra-column"]
)
assert actual_items == EXPECTED_ITEMS
def test_to_pandas__with_keys_mapping__span_id_trace_id_and_source_ignored__happyflow():
EXPECTED_DATAFRAME = pd.DataFrame(
{
"id": ["id-1", "id-2"],
"input": [{"input-key-1": "input-1"}, {"input-key-2": "input-2"}],
"Customized expected output": [
{"expected-output-key-1": "expected-output-1"},
{"expected-output-key-2": "expected-output-2"},
],
"metadata": [{"metadata-key-1": "v1"}, {"metadata-key-2": "v2"}],
}
)
input_items = [
DatasetItem(
id="id-1",
input={"input-key-1": "input-1"},
expected_output={"expected-output-key-1": "expected-output-1"},
metadata={"metadata-key-1": "v1"},
span_id="span-id-1",
trace_id="trace-id-1",
source="some-source-1",
),
DatasetItem(
id="id-2",
input={"input-key-2": "input-2"},
expected_output={"expected-output-key-2": "expected-output-2"},
metadata={"metadata-key-2": "v2"},
span_id="span-id-2",
trace_id="trace-id-2",
source="some-source-2",
),
]
actual_dataframe = converters.to_pandas(
input_items, keys_mapping={"expected_output": "Customized expected output"}
)
# check_like ignores columns and rows order
pandas.testing.assert_frame_equal(
actual_dataframe, EXPECTED_DATAFRAME, check_like=True
)
def test_from_json__all_columns_from_dataframe_represent_all_dataset_item_fields():
input_json = """
[
{
"id": "id-1",
"input": {"input-key-1": "input-1"},
"expected_output": {"expected-output-key-1": "expected-output-1"},
"metadata": {"metadata-key-1": "v1"}
},
{
"id": "id-2",
"input": {"input-key-2": "input-2"},
"expected_output": {"expected-output-key-2": "expected-output-2"},
"metadata": {"metadata-key-2": "v2"}
}
]
"""
EXPECTED_ITEMS = [
DatasetItem(
id="id-1",
input={"input-key-1": "input-1"},
expected_output={"expected-output-key-1": "expected-output-1"},
metadata={"metadata-key-1": "v1"},
),
DatasetItem(
id="id-2",
input={"input-key-2": "input-2"},
expected_output={"expected-output-key-2": "expected-output-2"},
metadata={"metadata-key-2": "v2"},
),
]
actual_items = converters.from_json(input_json, keys_mapping={}, ignore_keys=[])
assert actual_items == EXPECTED_ITEMS
def test_from_json__only_input_presented_in_json__items_are_constructed_with_default_values_for_missing_fields():
input_json = """
[
{
"input": {"input-key-1": "input-1"}
},
{
"input": {"input-key-2": "input-2"}
}
]
"""
EXPECTED_ITEMS = [
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-1": "input-1"},
source="sdk",
),
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-2": "input-2"},
source="sdk",
),
]
actual_items = converters.from_json(input_json, keys_mapping={}, ignore_keys=[])
assert actual_items == EXPECTED_ITEMS
def test_from_json__json_objects_contain_extra_key_not_needed_for_dataset_item__ignore_keys_is_used():
input_json = """
[
{
"input": {"input-key-1": "input-1"},
"extra_key": 42
},
{
"input": {"input-key-2": "input-2"},
"extra_key": 4242
}
]
"""
EXPECTED_ITEMS = [
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-1": "input-1"},
source="sdk",
),
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-2": "input-2"},
source="sdk",
),
]
actual_items = converters.from_json(
input_json, keys_mapping={}, ignore_keys=["extra_key"]
)
assert actual_items == EXPECTED_ITEMS
def test_from_json__json_objects_dont_have_the_same_name_as_dataset_item_field__keys_mapping_is_used():
input_json = """
[
{
"JSON input key": {"input-key-1": "input-1"}
},
{
"JSON input key": {"input-key-2": "input-2"}
}
]
"""
EXPECTED_ITEMS = [
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-1": "input-1"},
source="sdk",
),
DatasetItem(
id=ANY_BUT_NONE,
input={"input-key-2": "input-2"},
source="sdk",
),
]
actual_items = converters.from_json(
input_json, keys_mapping={"JSON input key": "input"}, ignore_keys=[]
)
assert actual_items == EXPECTED_ITEMS
def test_to_json__with_keys_mapping__span_id_trace_id_and_source_ignored__happyflow():
EXPECTED_JSON = """
[
{
"id": "id-1",
"input": {"input-key-1": "input-1"},
"Customized expected output": {"expected-output-key-1": "expected-output-1"},
"metadata": {"metadata-key-1": "v1"}
},
{
"id": "id-2",
"input": {"input-key-2": "input-2"},
"Customized expected output": {"expected-output-key-2": "expected-output-2"},
"metadata": {"metadata-key-2": "v2"}
}
]
"""
input_items = [
DatasetItem(
id="id-1",
input={"input-key-1": "input-1"},
expected_output={"expected-output-key-1": "expected-output-1"},
metadata={"metadata-key-1": "v1"},
span_id="span-id-1",
trace_id="trace-id-1",
source="some-source-1",
),
DatasetItem(
id="id-2",
input={"input-key-2": "input-2"},
expected_output={"expected-output-key-2": "expected-output-2"},
metadata={"metadata-key-2": "v2"},
span_id="span-id-2",
trace_id="trace-id-2",
source="some-source-2",
),
]
actual_json = converters.to_json(
input_items, keys_mapping={"expected_output": "Customized expected output"}
)
assert json.loads(actual_json) == json.loads(EXPECTED_JSON)
def test_from_jsonl_file__happyflow():
jsonl_content = """
{"input": {"user_question": "What is the capital of France?"}, "expected_output": {"assistant_answer": "The capital of France is Paris."}}
{"input": {"user_question": "How many planets are in our solar system?"}, "expected_output": {"assistant_answer": "There are 8 planets in our solar system: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune."}}
"""
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
temp_file.write(jsonl_content)
temp_file_path = temp_file.name
try:
result = converters.from_jsonl_file(
temp_file_path, keys_mapping={}, ignore_keys=[]
)
assert result[0].input == {"user_question": "What is the capital of France?"}
assert result[0].expected_output == {
"assistant_answer": "The capital of France is Paris."
}
assert result[1].input == {
"user_question": "How many planets are in our solar system?"
}
assert result[1].expected_output == {
"assistant_answer": "There are 8 planets in our solar system: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune."
}
finally:
os.unlink(temp_file_path)
def test_from_jsonl_file__empty_file():
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
temp_file_path = temp_file.name
try:
result = converters.from_jsonl_file(
temp_file_path, keys_mapping={}, ignore_keys=[]
)
assert isinstance(result, list)
assert len(result) == 0
finally:
os.unlink(temp_file_path)
def test_from_jsonl_file__file_with_empty_lines():
jsonl_content = """
{"input": {"user_question": "What is the capital of France?"}, "expected_output": {"assistant_answer": "The capital of France is Paris."}}
{"input": {"user_question": "How many planets are in our solar system?"}, "expected_output": {"assistant_answer": "There are 8 planets in our solar system: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune."}}
"""
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
temp_file.write(jsonl_content)
temp_file_path = temp_file.name
try:
result = converters.from_jsonl_file(
temp_file_path, keys_mapping={}, ignore_keys=[]
)
assert len(result) == 2
assert result[0].input == {"user_question": "What is the capital of France?"}
assert result[0].expected_output == {
"assistant_answer": "The capital of France is Paris."
}
assert result[1].input == {
"user_question": "How many planets are in our solar system?"
}
assert result[1].expected_output == {
"assistant_answer": "There are 8 planets in our solar system: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune."
}
finally:
os.unlink(temp_file_path)
@@ -0,0 +1,165 @@
from unittest.mock import Mock
from opik.api_objects.dataset.dataset import Dataset
def test_insert_deduplication__two_dicts_passed_with_the_same_content__only_one_is_inserted():
mock_rest_client = Mock()
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
)
item_dict = {
"input": {"key": "value", "key2": "value2"},
"expected_output": {"key": "value", "key2": "value2"},
"metadata": {"key": "value", "key2": "value2"},
}
# Insert the identical items
dataset.insert([item_dict, item_dict])
assert mock_rest_client.datasets.create_or_update_dataset_items.call_count == 1, (
"create_or_update_dataset_items should be called only once"
)
call_args = mock_rest_client.datasets.create_or_update_dataset_items.call_args
inserted_items = call_args[1]["items"]
assert len(inserted_items) == 1, "Only one item should be inserted"
def test_insert_deduplication__two_dicts_passed_with_the_different_content__both_are_inserted():
mock_rest_client = Mock()
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
)
item_dict1 = {
"input": {"key": "value1"},
"expected_output": {"key": "output1"},
"metadata": {"key": "meta1"},
}
item_dict2 = {
"input": {"key": "value2"},
"expected_output": {"key": "output2"},
"metadata": {"key": "meta2"},
}
# Insert the different items
dataset.insert([item_dict1, item_dict2])
assert mock_rest_client.datasets.create_or_update_dataset_items.call_count == 1, (
"create_or_update_dataset_items should be called only once"
)
call_args = mock_rest_client.datasets.create_or_update_dataset_items.call_args
inserted_items = call_args[1]["items"]
assert len(inserted_items) == 2, "Two items should be inserted"
def test_insert_deduplication__three_dicts_passed__one_unique__two_duplicates__two_different_items_are_inserted():
mock_rest_client = Mock()
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
)
item_dict1 = {
"input": {"key": "value1"},
"expected_output": {"key": "output1"},
"metadata": {"key": "meta1"},
}
item_dict2 = {
"input": {"key": "value2"},
"expected_output": {"key": "output2"},
"metadata": {"key": "meta2"},
}
# Insert 3 items: one unique and two duplicates
dataset.insert([item_dict1, item_dict2, item_dict1])
assert mock_rest_client.datasets.create_or_update_dataset_items.call_count == 1, (
"create_or_update_dataset_items should be called only once"
)
call_args = mock_rest_client.datasets.create_or_update_dataset_items.call_args
inserted_rest_items = call_args[1]["items"]
assert len(inserted_rest_items) == 2, "Two items should be inserted"
def test_update__happyflow():
mock_rest_client = Mock()
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
)
initial_item = {
"input": {"key": "initial_value"},
"expected_output": {"key": "initial_output"},
"metadata": {"key": "initial_metadata"},
}
dataset.insert([initial_item])
assert mock_rest_client.datasets.create_or_update_dataset_items.call_count == 1, (
"create_or_update_dataset_items should be called once for insertion"
)
insert_call_args = (
mock_rest_client.datasets.create_or_update_dataset_items.call_args
)
inserted_items = insert_call_args[1]["items"]
assert len(inserted_items) == 1, "One item should be inserted"
# Create an updated version of the item
updated_item = {
"id": inserted_items[0].id,
"input": {"key": "updated_value"},
"expected_output": {"key": "updated_output"},
"metadata": {"key": "updated_metadata"},
}
# Update the item
dataset.update([updated_item])
# Check that create_or_update_dataset_items was called twice in total (once for insertion, once for update)
assert mock_rest_client.datasets.create_or_update_dataset_items.call_count == 2, (
"create_or_update_dataset_items should be called twice in total"
)
# Get the arguments passed to create_or_update_dataset_items for update
update_call_args = (
mock_rest_client.datasets.create_or_update_dataset_items.call_args
)
updated_rest_items = update_call_args[1]["items"]
# Check that one item was updated
assert len(updated_rest_items) == 1, "One item should be updated"
# Verify the content of the updated item
assert updated_rest_items[0].data["input"] == {"key": "updated_value"}, (
"Input should be updated"
)
assert updated_rest_items[0].data["expected_output"] == {"key": "updated_output"}, (
"Expected output should be updated"
)
assert updated_rest_items[0].data["metadata"] == {"key": "updated_metadata"}, (
"Metadata should be updated"
)
@@ -0,0 +1,178 @@
from unittest.mock import Mock
from opik.api_objects.dataset.dataset import Dataset
from opik.rest_api.types.dataset_public import DatasetPublic
def test_dataset_items_count__cached_value__returns_cached_count():
"""Test that dataset_items_count returns cached value when available."""
mock_rest_client = Mock()
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
dataset_items_count=5,
)
count = dataset.dataset_items_count
assert count == 5
mock_rest_client.datasets.get_dataset_by_id.assert_not_called()
def test_dataset_items_count__no_cached_value__fetches_from_backend():
"""Test that dataset_items_count fetches from backend when cache is None."""
mock_rest_client = Mock()
mock_dataset_public = DatasetPublic(name="test_dataset", dataset_items_count=10)
mock_rest_client.datasets.get_dataset_by_id.return_value = mock_dataset_public
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
dataset_items_count=None,
)
count = dataset.dataset_items_count
assert count == 10
mock_rest_client.datasets.get_dataset_by_id.assert_called_once_with(id=dataset.id)
def test_dataset_items_count__fetched_once__cached_for_subsequent_calls():
"""Test that dataset_items_count is fetched once and then cached."""
mock_rest_client = Mock()
mock_dataset_public = DatasetPublic(name="test_dataset", dataset_items_count=10)
mock_rest_client.datasets.get_dataset_by_id.return_value = mock_dataset_public
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
dataset_items_count=None,
)
count1 = dataset.dataset_items_count
count2 = dataset.dataset_items_count
count3 = dataset.dataset_items_count
assert count1 == 10
assert count2 == 10
assert count3 == 10
mock_rest_client.datasets.get_dataset_by_id.assert_called_once()
def test_delete__invalidates_cached_count():
"""Test that delete() invalidates the cached count."""
mock_rest_client = Mock()
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
dataset_items_count=5,
)
assert dataset.dataset_items_count == 5
dataset.delete(["item1", "item2"])
mock_dataset_public = DatasetPublic(name="test_dataset", dataset_items_count=3)
mock_rest_client.datasets.get_dataset_by_id.return_value = mock_dataset_public
count = dataset.dataset_items_count
assert count == 3
mock_rest_client.datasets.get_dataset_by_id.assert_called_once()
def test_update__invalidates_cached_count():
"""Test that update() invalidates the cached count."""
mock_rest_client = Mock()
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
dataset_items_count=5,
)
assert dataset.dataset_items_count == 5
updated_item = {
"id": "item1",
"input": {"key": "updated_value"},
"expected_output": {"key": "updated_output"},
}
dataset.update([updated_item])
mock_dataset_public = DatasetPublic(name="test_dataset", dataset_items_count=5)
mock_rest_client.datasets.get_dataset_by_id.return_value = mock_dataset_public
count = dataset.dataset_items_count
assert count == 5
mock_rest_client.datasets.get_dataset_by_id.assert_called_once()
def test_insert__invalidates_cached_count():
"""Test that insert() invalidates the cached count."""
mock_rest_client = Mock()
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
dataset_items_count=5,
)
assert dataset.dataset_items_count == 5
new_items = [
{
"input": {"key": "value1"},
"expected_output": {"key": "output1"},
},
{
"input": {"key": "value2"},
"expected_output": {"key": "output2"},
},
]
dataset.insert(new_items)
mock_dataset_public = DatasetPublic(name="test_dataset", dataset_items_count=7)
mock_rest_client.datasets.get_dataset_by_id.return_value = mock_dataset_public
count = dataset.dataset_items_count
assert count == 7
mock_rest_client.datasets.get_dataset_by_id.assert_called_once()
def test_backend_returns_none_count__property_returns_none():
"""Test that if backend returns None for count, property returns None."""
mock_rest_client = Mock()
mock_dataset_public = DatasetPublic(name="test_dataset", dataset_items_count=None)
mock_rest_client.datasets.get_dataset_by_id.return_value = mock_dataset_public
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
dataset_items_count=None,
)
count = dataset.dataset_items_count
assert count is None
mock_rest_client.datasets.get_dataset_by_id.assert_called_once()
@@ -0,0 +1,122 @@
import unittest
from unittest.mock import Mock, patch
from opik.api_objects.dataset import dataset
from opik.rest_api.core.api_error import ApiError
class TestDatasetRateLimitRetry(unittest.TestCase):
"""Test rate limit retry behavior for dataset operations using public API."""
def _create_dataset_with_mock_client(self) -> tuple[dataset.Dataset, Mock]:
"""Create a Dataset instance with a mocked REST client."""
mock_rest_client = Mock()
dataset_obj = dataset.Dataset(
name="test_dataset",
description="test",
project_name="Test project",
rest_client=mock_rest_client,
)
return dataset_obj, mock_rest_client
@patch("opik.api_objects.rest_helpers._sleep")
def test_insert__429_with_retry_after_header__retries_with_correct_delay(
self, mock_sleep: Mock
) -> None:
"""Test that 429 errors with RateLimit-Reset header are retried with correct delay."""
dataset_obj, mock_rest_client = self._create_dataset_with_mock_client()
# First call raises 429 with rate limit headers, second call succeeds
rate_limit_error = ApiError(
status_code=429,
headers={
"RateLimit-Reset": "5", # 5 seconds retry after
},
body="Rate limit exceeded",
)
mock_rest_client.datasets.create_or_update_dataset_items.side_effect = [
rate_limit_error,
None, # Success on second attempt
]
# Execute using public API
dataset_obj.insert([{"input": "test"}])
# Verify retry behavior
assert mock_rest_client.datasets.create_or_update_dataset_items.call_count == 2
mock_sleep.assert_called_once_with(5.0)
@patch("opik.api_objects.rest_helpers._sleep")
def test_insert__429_without_header__uses_fallback_delay(
self, mock_sleep: Mock
) -> None:
"""Test that 429 errors without headers use fallback 1 second delay."""
dataset_obj, mock_rest_client = self._create_dataset_with_mock_client()
# First two calls raise 429 without headers, third succeeds
rate_limit_error = ApiError(
status_code=429,
headers={},
body="Rate limit exceeded",
)
mock_rest_client.datasets.create_or_update_dataset_items.side_effect = [
rate_limit_error,
rate_limit_error,
None, # Success on third attempt
]
# Execute using public API
dataset_obj.insert([{"input": "test"}])
# Verify fallback delay: always 1 second when no header
assert mock_rest_client.datasets.create_or_update_dataset_items.call_count == 3
assert mock_sleep.call_count == 2
# Both retries should use 1 second delay
assert all(call[0][0] == 1 for call in mock_sleep.call_args_list)
def test_insert__non_429_error__raises_immediately(self) -> None:
"""Test that non-429 errors are raised immediately without retry."""
dataset_obj, mock_rest_client = self._create_dataset_with_mock_client()
# Simulate a 500 error
error = ApiError(
status_code=500,
headers={},
body="Internal server error",
)
mock_rest_client.datasets.create_or_update_dataset_items.side_effect = error
# Execute & Verify
with self.assertRaises(ApiError) as context:
dataset_obj.insert([{"input": "test"}])
assert context.exception.status_code == 500
# Should only try once for non-429 errors
assert mock_rest_client.datasets.create_or_update_dataset_items.call_count == 1
@patch("opik.api_objects.rest_helpers._sleep")
def test_delete__429_with_retry_after_header__retries_with_correct_delay(
self, mock_sleep: Mock
) -> None:
"""Test that delete operation also handles 429 errors correctly."""
dataset_obj, mock_rest_client = self._create_dataset_with_mock_client()
# First call raises 429 with rate limit headers, second call succeeds
rate_limit_error = ApiError(
status_code=429,
headers={
"RateLimit-Reset": "3",
},
body="Rate limit exceeded",
)
mock_rest_client.datasets.delete_dataset_items.side_effect = [
rate_limit_error,
None, # Success on second attempt
]
# Execute using public API
dataset_obj.delete(["item-id-1"])
# Verify retry behavior
assert mock_rest_client.datasets.delete_dataset_items.call_count == 2
mock_sleep.assert_called_once_with(3.0)
@@ -0,0 +1,74 @@
"""Unit tests for stream_dataset_items() in rest_operations."""
from unittest.mock import Mock, patch
from opik.api_objects.dataset import rest_operations
from opik.rest_api.types import dataset_item as rest_dataset_item
_SHADOW_WARNING = (
"Dataset item data contains keys that shadow DatasetItem fields and will be ignored: %s. "
"Rename these keys in your dataset to preserve them."
)
def _make_rest_item(item_id: str, data: dict) -> rest_dataset_item.DatasetItem:
return rest_dataset_item.DatasetItem(
id=item_id,
source="sdk",
data=data,
)
def test_stream_dataset_items__colliding_id_key__uses_real_id_and_warns():
real_id = "real-uuid-1234"
rest_item = _make_rest_item(real_id, {"id": "COLLISION", "question": "What?"})
mock_rest_client = Mock()
with (
patch(
"opik.api_objects.dataset.rest_operations.rest_stream_parser.read_and_parse_stream",
side_effect=[[rest_item], []],
),
patch.object(rest_operations.LOGGER, "warning") as mock_warn,
):
items = list(
rest_operations.stream_dataset_items(
rest_client=mock_rest_client,
dataset_name="test-dataset",
project_name=None,
)
)
assert len(items) == 1
assert items[0].id == real_id
mock_warn.assert_called_once_with(_SHADOW_WARNING, ["id"])
def test_stream_dataset_items__colliding_id_key__warning_emitted_only_once():
"""Warning is logged once per stream even when multiple items have the collision."""
items_data = [
_make_rest_item(f"uuid-{i}", {"id": f"hotpot-{i}", "question": "Q?"})
for i in range(3)
]
mock_rest_client = Mock()
with (
patch(
"opik.api_objects.dataset.rest_operations.rest_stream_parser.read_and_parse_stream",
side_effect=[items_data, []],
),
patch.object(rest_operations.LOGGER, "warning") as mock_warn,
):
result = list(
rest_operations.stream_dataset_items(
rest_client=mock_rest_client,
dataset_name="test-dataset",
project_name=None,
)
)
assert len(result) == 3
mock_warn.assert_called_once_with(_SHADOW_WARNING, ["id"])
@@ -0,0 +1,513 @@
"""Unit tests for test_suite.converters module."""
import json
import os
import tempfile
import pandas as pd
import pandas.testing
import pytest
from unittest import mock
from opik.api_objects.dataset import dataset_item
from opik.api_objects.dataset.test_suite import converters
# ---------------------------------------------------------------------------
# evaluators_to_assertions
# ---------------------------------------------------------------------------
def test_evaluators_to_assertions__multiple_evaluators__concatenates():
e1 = mock.MagicMock()
e1.assertions = ["A1"]
e2 = mock.MagicMock()
e2.assertions = ["A2", "A3"]
assert converters.evaluators_to_assertions([e1, e2]) == ["A1", "A2", "A3"]
def test_evaluators_to_assertions__empty_list__returns_empty():
assert converters.evaluators_to_assertions([]) == []
# ---------------------------------------------------------------------------
# version_evaluators_to_assertions
# ---------------------------------------------------------------------------
def test_version_evaluators_to_assertions__llm_judge__extracts_assertions():
from opik.evaluation.suite_evaluators import LLMJudge
judge = LLMJudge(assertions=["Response is accurate"], track=False)
config = judge.to_config().model_dump(by_alias=True)
evaluator_item = mock.MagicMock()
evaluator_item.type = "llm_judge"
evaluator_item.config = config
assert converters.version_evaluators_to_assertions([evaluator_item]) == [
"Response is accurate"
]
def test_version_evaluators_to_assertions__none__returns_empty():
assert converters.version_evaluators_to_assertions(None) == []
def test_version_evaluators_to_assertions__non_llm_judge__skipped():
evaluator_item = mock.MagicMock()
evaluator_item.type = "custom_scorer"
assert converters.version_evaluators_to_assertions([evaluator_item]) == []
# ---------------------------------------------------------------------------
# version_policy_to_execution_policy
# ---------------------------------------------------------------------------
def test_version_policy_to_execution_policy__converts():
policy = mock.MagicMock()
policy.runs_per_item = 5
policy.pass_threshold = 3
assert converters.version_policy_to_execution_policy(policy) == {
"runs_per_item": 5,
"pass_threshold": 3,
}
def test_version_policy_to_execution_policy__none__returns_default():
assert converters.version_policy_to_execution_policy(None) == {
"runs_per_item": 1,
"pass_threshold": 1,
}
# ---------------------------------------------------------------------------
# dataset_item_to_suite_item_dict (DatasetItem → TestSuiteItem)
# ---------------------------------------------------------------------------
def test_dataset_item_to_suite_item_dict__all_fields():
item = dataset_item.DatasetItem(
id="item-1",
description="Test item",
question="What is 2+2?",
execution_policy=dataset_item.ExecutionPolicyItem(
runs_per_item=3, pass_threshold=2
),
)
assert converters.dataset_item_to_suite_item_dict(item) == {
"id": "item-1",
"data": {"question": "What is 2+2?"},
"assertions": [],
"description": "Test item",
"execution_policy": {"runs_per_item": 3, "pass_threshold": 2},
}
def test_dataset_item_to_suite_item_dict__minimal():
item = dataset_item.DatasetItem(id="item-1", question="Hello")
result = converters.dataset_item_to_suite_item_dict(item)
assert result == {"id": "item-1", "data": {"question": "Hello"}, "assertions": []}
assert "description" not in result
assert "execution_policy" not in result
def test_dataset_item_to_suite_item_dict__with_evaluators__extracts_assertions():
from opik.evaluation.suite_evaluators import LLMJudge
judge = LLMJudge(assertions=["Is correct"], track=False)
config = judge.to_config().model_dump(by_alias=True)
item = dataset_item.DatasetItem(
id="item-2",
evaluators=[
dataset_item.EvaluatorItem(
name="llm_judge", type="llm_judge", config=config
),
],
question="Hello",
)
assert converters.dataset_item_to_suite_item_dict(item)["assertions"] == [
"Is correct"
]
# ---------------------------------------------------------------------------
# suite_item_dict_to_dataset_item (TestSuiteItem → DatasetItem)
# ---------------------------------------------------------------------------
def test_suite_item_dict_to_dataset_item__all_fields():
item = {
"id": "item-1",
"data": {"question": "Hello", "context": "test"},
"assertions": ["Is polite"],
"description": "A test case",
"execution_policy": {"runs_per_item": 5, "pass_threshold": 3},
}
ds_item = converters.suite_item_dict_to_dataset_item(item)
assert ds_item.id == "item-1"
assert ds_item.get_content() == {"question": "Hello", "context": "test"}
assert ds_item.description == "A test case"
assert ds_item.execution_policy is not None
assert ds_item.execution_policy.runs_per_item == 5
assert ds_item.execution_policy.pass_threshold == 3
assert ds_item.evaluators is not None
assert len(ds_item.evaluators) == 1
assert ds_item.evaluators[0].type == "llm_judge"
def test_suite_item_dict_to_dataset_item__minimal__generates_id():
ds_item = converters.suite_item_dict_to_dataset_item(
{"data": {"question": "Hello"}}
)
assert ds_item.id is not None
assert len(ds_item.id) > 0
assert ds_item.get_content() == {"question": "Hello"}
assert ds_item.evaluators is None
assert ds_item.execution_policy is None
# ---------------------------------------------------------------------------
# to_json / from_json
# ---------------------------------------------------------------------------
SAMPLE_ITEMS = [
{
"id": "item-1",
"data": {"question": "How do I get a refund?", "context": "Premium user"},
"assertions": ["Response is polite"],
"description": "Refund scenario",
"execution_policy": {"runs_per_item": 3, "pass_threshold": 2},
},
{
"id": "item-2",
"data": {"question": "Is my account hacked?"},
"assertions": [],
},
]
def test_to_json__happyflow():
EXPECTED = [
{
"id": "item-1",
"data": {"question": "How do I get a refund?", "context": "Premium user"},
"assertions": ["Response is polite"],
"description": "Refund scenario",
"execution_policy": {"runs_per_item": 3, "pass_threshold": 2},
},
{
"id": "item-2",
"data": {"question": "Is my account hacked?"},
"assertions": [],
},
]
assert json.loads(converters.to_json(SAMPLE_ITEMS)) == EXPECTED
def test_to_json__empty_list():
assert json.loads(converters.to_json([])) == []
def test_from_json__happyflow():
json_str = json.dumps(
[
{"data": {"question": "Hello"}, "assertions": ["Is polite"]},
{"data": {"question": "Bye"}},
]
)
EXPECTED = [
{"data": {"question": "Hello"}, "assertions": ["Is polite"]},
{"data": {"question": "Bye"}},
]
assert converters.from_json(json_str, {}, []) == EXPECTED
def test_from_json__with_keys_mapping():
json_str = json.dumps(
[
{"test_data": {"question": "Hello"}, "checks": ["Is polite"]},
]
)
EXPECTED = [{"data": {"question": "Hello"}, "assertions": ["Is polite"]}]
assert (
converters.from_json(
json_str, {"test_data": "data", "checks": "assertions"}, []
)
== EXPECTED
)
def test_from_json__with_ignore_keys():
json_str = json.dumps(
[
{"data": {"question": "Hello"}, "internal_note": "skip this"},
]
)
EXPECTED = [{"data": {"question": "Hello"}}]
assert converters.from_json(json_str, {}, ["internal_note"]) == EXPECTED
def test_from_json__non_array__raises_value_error():
json_str = json.dumps({"data": {"question": "Hello"}})
with pytest.raises(ValueError, match="must be an array"):
converters.from_json(json_str, {}, [])
# ---------------------------------------------------------------------------
# to_pandas / from_pandas
# ---------------------------------------------------------------------------
def test_to_pandas__happyflow():
EXPECTED = pd.DataFrame(
[
{
"id": "item-1",
"data": {
"question": "How do I get a refund?",
"context": "Premium user",
},
"assertions": ["Response is polite"],
"description": "Refund scenario",
"execution_policy": {"runs_per_item": 3, "pass_threshold": 2},
},
{
"id": "item-2",
"data": {"question": "Is my account hacked?"},
"assertions": [],
},
]
)
pandas.testing.assert_frame_equal(converters.to_pandas(SAMPLE_ITEMS), EXPECTED)
def test_to_pandas__empty_list():
assert len(converters.to_pandas([])) == 0
def test_from_pandas__happyflow():
dataframe = pd.DataFrame(
[
{"data": {"question": "Hello"}, "assertions": ["Is polite"]},
{"data": {"question": "Bye"}},
]
)
EXPECTED = [
{"data": {"question": "Hello"}, "assertions": ["Is polite"]},
{"data": {"question": "Bye"}},
]
assert converters.from_pandas(dataframe, {}, []) == EXPECTED
def test_from_pandas__with_keys_mapping():
dataframe = pd.DataFrame([{"test_data": {"question": "Hello"}}])
EXPECTED = [{"data": {"question": "Hello"}}]
assert converters.from_pandas(dataframe, {"test_data": "data"}, []) == EXPECTED
def test_from_pandas__nan_values__skipped():
dataframe = pd.DataFrame(
[
{"data": {"question": "Hello"}, "assertions": ["Is polite"]},
{"data": {"question": "Bye"}, "assertions": float("nan")},
]
)
result = converters.from_pandas(dataframe, {}, [])
assert result[0] == {"data": {"question": "Hello"}, "assertions": ["Is polite"]}
assert result[1] == {"data": {"question": "Bye"}}
# ---------------------------------------------------------------------------
# from_jsonl_file
# ---------------------------------------------------------------------------
def test_from_jsonl_file__happyflow():
jsonl_content = (
'{"data": {"question": "What is 2+2?"}, "assertions": ["Is correct"]}\n'
'{"data": {"question": "Capital of France?"}}\n'
)
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write(jsonl_content)
path = f.name
try:
EXPECTED = [
{"data": {"question": "What is 2+2?"}, "assertions": ["Is correct"]},
{"data": {"question": "Capital of France?"}},
]
assert converters.from_jsonl_file(path, {}, []) == EXPECTED
finally:
os.unlink(path)
def test_from_jsonl_file__empty_lines__skipped():
jsonl_content = '{"data": {"question": "Q1"}}\n\n{"data": {"question": "Q2"}}\n\n'
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write(jsonl_content)
path = f.name
try:
assert len(converters.from_jsonl_file(path, {}, [])) == 2
finally:
os.unlink(path)
def test_from_jsonl_file__with_keys_mapping():
jsonl_content = '{"test_data": {"question": "Hello"}}\n'
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write(jsonl_content)
path = f.name
try:
EXPECTED = [{"data": {"question": "Hello"}}]
assert converters.from_jsonl_file(path, {"test_data": "data"}, []) == EXPECTED
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# Round-trip tests
# ---------------------------------------------------------------------------
def test_adapter_roundtrip__suite_to_dataset_to_suite__preserves_all_fields():
original = {
"id": "item-1",
"data": {"question": "Hello", "context": "Premium"},
"assertions": ["Is polite", "Is helpful"],
"description": "Test case",
"execution_policy": {"runs_per_item": 3, "pass_threshold": 2},
}
ds_item = converters.suite_item_dict_to_dataset_item(original)
recovered = converters.dataset_item_to_suite_item_dict(ds_item)
assert recovered["id"] == original["id"]
assert recovered["data"] == original["data"]
assert sorted(recovered["assertions"]) == sorted(original["assertions"])
assert recovered["description"] == original["description"]
assert recovered["execution_policy"] == original["execution_policy"]
def test_adapter_roundtrip__two_cycles__stable():
original = {
"id": "item-1",
"data": {"question": "Hello"},
"assertions": ["Is polite"],
"execution_policy": {"runs_per_item": 5, "pass_threshold": 3},
}
suite_item = original
for _ in range(2):
ds_item = converters.suite_item_dict_to_dataset_item(suite_item)
suite_item = converters.dataset_item_to_suite_item_dict(ds_item)
assert suite_item["data"] == original["data"]
assert suite_item["assertions"] == original["assertions"]
assert suite_item["execution_policy"] == original["execution_policy"]
def test_json_roundtrip__export_import_export__stable():
json_str_1 = converters.to_json(SAMPLE_ITEMS)
imported = converters.from_json(json_str_1, {}, [])
json_str_2 = converters.to_json(imported)
assert json.loads(json_str_1) == json.loads(json_str_2)
def test_json_roundtrip__import_export_import__stable():
json_str = json.dumps(
[
{
"data": {"question": "Hello"},
"assertions": ["Is polite"],
"description": "Test",
"execution_policy": {"runs_per_item": 3, "pass_threshold": 2},
},
]
)
items_1 = converters.from_json(json_str, {}, [])
exported = converters.to_json(items_1)
items_2 = converters.from_json(exported, {}, [])
assert items_1 == items_2
def test_pandas_roundtrip__export_import_export__stable():
df_1 = converters.to_pandas(SAMPLE_ITEMS)
imported = converters.from_pandas(df_1, {}, [])
df_2 = converters.to_pandas(imported)
pandas.testing.assert_frame_equal(df_1, df_2)
def test_full_roundtrip__json_through_adapters__two_cycles():
"""JSON → from_json → adapter → adapter → to_json, repeated twice."""
original_json = json.dumps(
[
{
"id": "item-1",
"data": {"question": "Refund?", "tier": "premium"},
"assertions": ["Is polite", "No hallucination"],
"description": "Refund scenario",
"execution_policy": {"runs_per_item": 3, "pass_threshold": 2},
},
]
)
current_json = original_json
for _ in range(2):
suite_items = converters.from_json(current_json, {}, [])
ds_items = [converters.suite_item_dict_to_dataset_item(i) for i in suite_items]
suite_items = [converters.dataset_item_to_suite_item_dict(i) for i in ds_items]
current_json = converters.to_json(suite_items)
EXPECTED = {
"id": "item-1",
"data": {"question": "Refund?", "tier": "premium"},
"assertions": ["Is polite", "No hallucination"],
"description": "Refund scenario",
"execution_policy": {"runs_per_item": 3, "pass_threshold": 2},
}
final = json.loads(current_json)
assert len(final) == 1
result = final[0]
assert result["id"] == EXPECTED["id"]
assert result["data"] == EXPECTED["data"]
assert sorted(result["assertions"]) == sorted(EXPECTED["assertions"])
assert result["description"] == EXPECTED["description"]
assert result["execution_policy"] == EXPECTED["execution_policy"]
@@ -0,0 +1,317 @@
"""Unit tests for test suite result file generation."""
import json
import os
from opik.api_objects.dataset.test_suite import (
suite_result_constructor,
types as suite_types,
)
from opik.api_objects.dataset.test_suite.report_processors import file_writer
from opik.api_objects.dataset import dataset_item
from opik.evaluation import evaluation_result, test_result, test_case
from opik.evaluation.metrics import score_result
def _make_test_result(
dataset_item_id: str,
trial_id: int,
scores: list[tuple[str, float]],
task_output: dict | None = None,
dataset_item_content: dict | None = None,
execution_policy: dict | None = None,
task_execution_time: float | None = None,
scoring_time: float | None = None,
) -> test_result.TestResult:
ds_item = None
if execution_policy is not None:
ds_item = dataset_item.DatasetItem(
id=dataset_item_id,
execution_policy=dataset_item.ExecutionPolicyItem(
runs_per_item=execution_policy.get("runs_per_item"),
pass_threshold=execution_policy.get("pass_threshold"),
),
)
return test_result.TestResult(
test_case=test_case.TestCase(
trace_id=f"trace-{dataset_item_id}-{trial_id}",
dataset_item_id=dataset_item_id,
task_output=task_output or {"input": "test", "output": "result"},
dataset_item_content=dataset_item_content or {"question": "What?"},
dataset_item=ds_item,
),
score_results=[
score_result.ScoreResult(name=name, value=value) for name, value in scores
],
trial_id=trial_id,
task_execution_time=task_execution_time,
scoring_time=scoring_time,
)
def _make_suite_result(
test_results_list: list[test_result.TestResult],
suite_name: str | None = None,
total_time: float | None = None,
) -> suite_types.TestSuiteResult:
eval_result = evaluation_result.EvaluationResult(
experiment_id="exp-123",
dataset_id="dataset-456",
experiment_name="my-experiment",
test_results=test_results_list,
experiment_url="http://example.com/experiment/exp-123",
trial_count=1,
)
return suite_result_constructor.build_suite_result(
eval_result,
suite_name=suite_name,
total_time=total_time,
)
class TestToReportDict:
def test_to_report_dict__single_item_with_mixed_scores__returns_correct_structure(
self,
):
test_results_list = [
_make_test_result(
dataset_item_id="item-1",
trial_id=0,
scores=[("Is polite", True), ("Is helpful", False)],
task_output={"input": "hi", "output": "hello"},
dataset_item_content={"question": "hi"},
execution_policy={"runs_per_item": 1, "pass_threshold": 1},
task_execution_time=1.234,
scoring_time=0.567,
)
]
suite_result = _make_suite_result(test_results_list, suite_name="My Suite")
result = suite_result.to_report_dict()
assert result["suite_passed"] is False
assert result["items_passed"] == 0
assert result["items_total"] == 1
assert result["pass_rate"] == 0.0
assert result["experiment_id"] == "exp-123"
assert result["experiment_name"] == "my-experiment"
assert result["experiment_url"] == "http://example.com/experiment/exp-123"
assert result["suite_name"] == "My Suite"
assert "generated_at" in result
assert len(result["items"]) == 1
item = result["items"][0]
assert item["dataset_item_id"] == "item-1"
assert item["passed"] is False
assert item["runs_passed"] == 0
assert item["execution_policy"] == {"runs_per_item": 1, "pass_threshold": 1}
assert len(item["runs"]) == 1
run = item["runs"][0]
assert run["trial_id"] == 0
assert run["passed"] is False
assert run["input"] == "hi"
assert run["output"] == "hello"
assert run["trace_id"] == "trace-item-1-0"
assert run["task_execution_time_seconds"] == 1.234
assert run["scoring_time_seconds"] == 0.567
assert len(run["assertions"]) == 2
assert run["assertions"][0]["name"] == "Is polite"
assert run["assertions"][0]["passed"] is True
assert run["assertions"][1]["name"] == "Is helpful"
assert run["assertions"][1]["passed"] is False
def test_to_report_dict__all_items_pass__suite_passed_true(self):
test_results_list = [
_make_test_result(
dataset_item_id="item-1",
trial_id=0,
scores=[("A1", True)],
execution_policy={"runs_per_item": 1, "pass_threshold": 1},
),
_make_test_result(
dataset_item_id="item-2",
trial_id=0,
scores=[("A1", True)],
execution_policy={"runs_per_item": 1, "pass_threshold": 1},
),
]
suite_result = _make_suite_result(test_results_list)
result = suite_result.to_report_dict()
assert result["suite_passed"] is True
assert result["items_passed"] == 2
assert result["pass_rate"] == 1.0
def test_to_report_dict__with_total_time__includes_rounded_value(self):
test_results_list = [
_make_test_result(
dataset_item_id="item-1",
trial_id=0,
scores=[("A1", True)],
execution_policy={"runs_per_item": 1, "pass_threshold": 1},
),
]
suite_result = _make_suite_result(test_results_list, total_time=12.3456)
result = suite_result.to_report_dict()
assert result["total_time_seconds"] == 12.346
def test_to_report_dict__scoring_failed__marks_assertion_failed_with_reason(self):
test_results_list = [
test_result.TestResult(
test_case=test_case.TestCase(
trace_id="trace-1",
dataset_item_id="item-1",
task_output={"output": "test"},
dataset_item_content={},
dataset_item=dataset_item.DatasetItem(
id="item-1",
execution_policy=dataset_item.ExecutionPolicyItem(
runs_per_item=1,
pass_threshold=1,
),
),
),
score_results=[
score_result.ScoreResult(
name="A1",
value=0,
scoring_failed=True,
reason="Model error",
),
],
trial_id=0,
)
]
suite_result = _make_suite_result(test_results_list)
result = suite_result.to_report_dict()
assertion = result["items"][0]["runs"][0]["assertions"][0]
assert assertion["passed"] is False
assert assertion["scoring_failed"] is True
assert assertion["reason"] == "Model error"
class TestSaveReport:
def test_save_report__valid_input__writes_json_file(self, tmp_path):
test_results_list = [
_make_test_result(
dataset_item_id="item-1",
trial_id=0,
scores=[("A1", True)],
execution_policy={"runs_per_item": 1, "pass_threshold": 1},
),
]
suite_result = _make_suite_result(
test_results_list, suite_name="Test Suite", total_time=5.0
)
output_path = str(tmp_path / "report.json")
result_path = file_writer.save_report(suite_result, output_path)
assert result_path == output_path
assert os.path.exists(output_path)
with open(output_path) as f:
data = json.load(f)
assert data["suite_name"] == "Test Suite"
assert data["suite_passed"] is True
assert len(data["items"]) == 1
def test_save_report__no_path__uses_experiment_name(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
test_results_list = [
_make_test_result(
dataset_item_id="item-1",
trial_id=0,
scores=[("A1", True)],
execution_policy={"runs_per_item": 1, "pass_threshold": 1},
),
]
suite_result = _make_suite_result(test_results_list)
result_path = file_writer.save_report(suite_result)
assert "my-experiment" in os.path.basename(result_path)
assert result_path.endswith(".json")
assert os.path.exists(result_path)
def test_save_report__nested_path__creates_parent_directories(self, tmp_path):
output_path = str(tmp_path / "nested" / "dir" / "report.json")
test_results_list = [
_make_test_result(
dataset_item_id="item-1",
trial_id=0,
scores=[("A1", True)],
execution_policy={"runs_per_item": 1, "pass_threshold": 1},
),
]
suite_result = _make_suite_result(test_results_list)
result_path = file_writer.save_report(suite_result, output_path)
assert os.path.exists(result_path)
class TestBuildDefaultReportPath:
def test_build_default_report_path__unsafe_characters__replaces_with_underscore(
self,
):
path = file_writer.build_default_report_path("my/suite:name")
assert os.path.basename(path) == "my_suite_name.json"
def test_build_default_report_path__safe_characters__keeps_unchanged(self):
path = file_writer.build_default_report_path("my-suite_v1.0")
assert os.path.basename(path) == "my-suite_v1.0.json"
def test_build_default_report_path__spaces__replaces_with_underscore(self):
path = file_writer.build_default_report_path("my suite")
assert os.path.basename(path) == "my_suite.json"
class TestTestSuiteResultMethods:
def test_to_dict__passing_suite__returns_dict_with_suite_passed_true(self):
test_results_list = [
_make_test_result(
dataset_item_id="item-1",
trial_id=0,
scores=[("A1", True)],
execution_policy={"runs_per_item": 1, "pass_threshold": 1},
),
]
suite_result = _make_suite_result(test_results_list)
result = suite_result.to_dict()
assert isinstance(result, dict)
assert result["suite_passed"] is True
assert "items" in result
def test_to_report_dict__save_to_file__produces_valid_json(self, tmp_path):
test_results_list = [
_make_test_result(
dataset_item_id="item-1",
trial_id=0,
scores=[("A1", True)],
execution_policy={"runs_per_item": 1, "pass_threshold": 1},
),
]
suite_result = _make_suite_result(test_results_list)
output_path = str(tmp_path / "result.json")
path = file_writer.save_report(suite_result, output_path)
assert os.path.exists(path)
with open(path) as f:
data = json.load(f)
assert data["suite_passed"] is True
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
import types
import pytest
from opik.api_objects import experiment
from tests.conftest import random_chars
def fake_prompt(with_postfix: bool = False):
postfix = random_chars()
def __internal_api__to_info_dict__():
return {
"name": fake_prompt_obj.name,
"version": {
"template": fake_prompt_obj.prompt,
},
}
fake_prompt_obj = types.SimpleNamespace(
__internal_api__version_id__="some-prompt-version-id",
prompt="some-prompt-value",
name="some-prompt-name",
__internal_api__to_info_dict__=__internal_api__to_info_dict__,
)
if with_postfix:
fake_prompt_obj.prompt += postfix
fake_prompt_obj.__internal_api__version_id__ += postfix
fake_prompt_obj.name += postfix
return fake_prompt_obj
@pytest.mark.parametrize(
argnames="input_kwargs,expected",
argvalues=[
(
{"experiment_config": None, "prompts": None},
{"metadata": None, "prompt_versions": None},
),
(
{"experiment_config": {}, "prompts": None},
{"metadata": None, "prompt_versions": None},
),
(
{"experiment_config": None, "prompts": [fake_prompt()]},
{
"metadata": {"prompts": {"some-prompt-name": "some-prompt-value"}},
"prompt_versions": [{"id": "some-prompt-version-id"}],
},
),
(
{"experiment_config": {}, "prompts": [fake_prompt()]},
{
"metadata": {"prompts": {"some-prompt-name": "some-prompt-value"}},
"prompt_versions": [{"id": "some-prompt-version-id"}],
},
),
(
{"experiment_config": {"some-key": "some-value"}, "prompts": None},
{"metadata": {"some-key": "some-value"}, "prompt_versions": None},
),
(
{
"experiment_config": "NOT-DICT-VALUE-THAT-WILL-BE-IGNORED-AND-REPLACED-WITH-DICT-WITH-PROMPT",
"prompts": [fake_prompt()],
},
{
"metadata": {"prompts": {"some-prompt-name": "some-prompt-value"}},
"prompt_versions": [{"id": "some-prompt-version-id"}],
},
),
],
)
def test_experiment_build_metadata_from_prompt_versions(input_kwargs, expected):
metadata, prompt_versions = experiment.build_metadata_and_prompt_versions(
**input_kwargs
)
assert metadata == expected["metadata"]
assert prompt_versions == expected["prompt_versions"]
def test_check_prompt_args_with_none_arguments():
result = experiment.handle_prompt_args(prompt=None, prompts=None)
assert result is None
def test_check_prompt_args_with_none_and_empty_list():
result = experiment.handle_prompt_args(prompt=None, prompts=[])
assert result is None
def test_check_prompt_args_with_single_prompt():
mock_prompt = fake_prompt(with_postfix=True)
result = experiment.handle_prompt_args(prompt=mock_prompt, prompts=None)
assert isinstance(result, list)
assert len(result) == 1
assert result[0] == mock_prompt
def test_check_prompt_args_with_prompts_list():
mock_prompt_1 = fake_prompt(with_postfix=True)
mock_prompt_2 = fake_prompt(with_postfix=True)
prompts = [mock_prompt_1, mock_prompt_2]
result = experiment.handle_prompt_args(prompt=None, prompts=prompts)
assert result == prompts
def test_check_prompt_args_with_both_prompt_and_prompts():
mock_prompt = fake_prompt(with_postfix=True)
mock_prompt_list = [
fake_prompt(with_postfix=True),
fake_prompt(with_postfix=True),
]
result = experiment.handle_prompt_args(prompt=mock_prompt, prompts=mock_prompt_list)
assert isinstance(result, list)
assert result == mock_prompt_list
@@ -0,0 +1,587 @@
import pytest
from opik.api_objects.prompt import ChatPromptTemplate, PromptType
from opik import exceptions
def test_chat_prompt_template__format__simple_text_message__happyflow():
"""Test basic formatting of a simple text message."""
messages = [
{
"role": "user",
"content": "Hi, my name is {{name}} and I live in {{city}}.",
}
]
tested = ChatPromptTemplate(messages)
result = tested.format({"name": "Harry", "city": "London"})
assert result == [
{"role": "user", "content": "Hi, my name is Harry and I live in London."}
]
def test_chat_prompt_template__format__multiple_messages__happyflow():
"""Test formatting multiple messages with different roles."""
messages = [
{"role": "system", "content": "You are a helpful assistant in {{location}}."},
{"role": "user", "content": "What is the capital of {{country}}?"},
{"role": "assistant", "content": "The capital is {{capital}}."},
]
tested = ChatPromptTemplate(messages)
result = tested.format(
{
"location": "London",
"country": "France",
"capital": "Paris",
}
)
assert result == [
{"role": "system", "content": "You are a helpful assistant in London."},
{"role": "user", "content": "What is the capital of France?"},
{"role": "assistant", "content": "The capital is Paris."},
]
def test_chat_prompt_template__format__multimodal_content__happyflow():
"""Test formatting messages with multimodal content (text + image) when vision is supported."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this {{object}}:"},
{"type": "image_url", "image_url": {"url": "{{image_url}}"}},
],
}
]
tested = ChatPromptTemplate(messages)
# Format with vision supported to get structured content
result = tested.format(
{"object": "painting", "image_url": "https://example.com/image.jpg"},
supported_modalities={"vision": True},
)
assert result == [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this painting:"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/image.jpg"},
},
],
}
]
def test_chat_prompt_template__format__jinja2_template__happyflow():
"""Test formatting with Jinja2 template type."""
messages = [
{
"role": "user",
"content": "Hi, my name is {{ name }} and I live in {{ city }}.",
}
]
tested = ChatPromptTemplate(messages, template_type=PromptType.JINJA2)
result = tested.format({"name": "Harry", "city": "London"})
assert result == [
{"role": "user", "content": "Hi, my name is Harry and I live in London."}
]
def test_chat_prompt_template__format__jinja2_with_control_flow():
"""Test Jinja2 formatting with control flow."""
messages = [
{
"role": "user",
"content": """
{% if is_wizard %}
{{ name }} is a wizard who lives in {{ city }}.
{% else %}
{{ name }} is a muggle who lives in {{ city }}.
{% endif %}
""",
}
]
tested = ChatPromptTemplate(messages, template_type=PromptType.JINJA2)
wizard_result = tested.format(
{"name": "Harry", "city": "London", "is_wizard": True}
)
assert (
"Harry is a wizard who lives in London." in wizard_result[0]["content"].strip()
)
muggle_result = tested.format(
{"name": "Dudley", "city": "Surrey", "is_wizard": False}
)
assert (
"Dudley is a muggle who lives in Surrey." in muggle_result[0]["content"].strip()
)
def test_chat_prompt_template__format__jinja2_with_loops():
"""Test Jinja2 formatting with loops."""
messages = [
{
"role": "user",
"content": """
{{ name }}'s friends are:
{% for friend in friends %}
- {{ friend }}
{% endfor %}
""",
}
]
tested = ChatPromptTemplate(messages, template_type=PromptType.JINJA2)
result = tested.format({"name": "Harry", "friends": ["Ron", "Hermione", "Neville"]})
content = result[0]["content"]
assert "Harry's friends are:" in content
assert "- Ron" in content
assert "- Hermione" in content
assert "- Neville" in content
def test_chat_prompt_template__format__empty_content():
"""Test formatting with empty content."""
messages = [
{"role": "user", "content": ""},
]
tested = ChatPromptTemplate(messages)
result = tested.format({})
assert result == [{"role": "user", "content": ""}]
def test_chat_prompt_template__format__message_without_role__skipped():
"""Test that messages without a role are skipped."""
messages = [
{"role": "user", "content": "Hello {{name}}"},
{"content": "This message has no role"},
{"role": "assistant", "content": "Hi there!"},
]
tested = ChatPromptTemplate(messages)
result = tested.format({"name": "Harry"})
# Only messages with roles should be included
assert result == [
{"role": "user", "content": "Hello Harry"},
{"role": "assistant", "content": "Hi there!"},
]
def test_chat_prompt_template__required_modalities__text_only():
"""Test required_modalities returns empty set for text-only messages."""
messages = [
{"role": "user", "content": "Simple text message"},
]
tested = ChatPromptTemplate(messages)
result = tested.required_modalities()
assert result == set()
def test_chat_prompt_template__required_modalities__with_vision():
"""Test required_modalities detects vision modality."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image:"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/img.jpg"},
},
],
}
]
tested = ChatPromptTemplate(messages)
result = tested.required_modalities()
assert "vision" in result
def test_chat_prompt_template__required_modalities__multiple_messages():
"""Test required_modalities across multiple messages."""
messages = [
{"role": "user", "content": "Text only"},
{
"role": "user",
"content": [
{"type": "text", "text": "With image:"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/img.jpg"},
},
],
},
]
tested = ChatPromptTemplate(messages)
result = tested.required_modalities()
assert "vision" in result
def test_chat_prompt_template__format__unsupported_modality_replaced_with_placeholder():
"""Test that unsupported modalities are replaced with placeholders."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image:"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/img.jpg"},
},
],
}
]
tested = ChatPromptTemplate(messages)
# Format with vision not supported
result = tested.format({}, supported_modalities={"vision": False})
assert len(result) == 1
# When vision is not supported, image should be replaced with placeholder
content = result[0]["content"]
assert isinstance(content, str)
assert "<<<image>>>" in content
assert "<<</image>>>" in content
assert "Describe this image:" in content
def test_chat_prompt_template__format__supported_modality_preserved():
"""Test that supported modalities are preserved as structured content."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image:"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/img.jpg"},
},
],
}
]
tested = ChatPromptTemplate(messages)
# Format with vision supported
result = tested.format({}, supported_modalities={"vision": True})
assert result == [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image:"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/img.jpg"},
},
],
}
]
def test_chat_prompt_template__format__multimodal_with_template_variables():
"""Test multimodal content with template variables in both text and image URL."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Analyze this {{object}}:"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/{{filename}}"},
},
],
}
]
tested = ChatPromptTemplate(messages)
result = tested.format(
{"object": "diagram", "filename": "diagram.png"},
supported_modalities={"vision": True},
)
assert result == [
{
"role": "user",
"content": [
{"type": "text", "text": "Analyze this diagram:"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/diagram.png"},
},
],
}
]
def test_chat_prompt_template__format__image_with_detail_parameter():
"""Test that image detail parameter is preserved during formatting."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Analyze:"},
{
"type": "image_url",
"image_url": {"url": "{{url}}", "detail": "high"},
},
],
}
]
tested = ChatPromptTemplate(messages)
result = tested.format(
{"url": "https://example.com/img.jpg"}, supported_modalities={"vision": True}
)
assert result == [
{
"role": "user",
"content": [
{"type": "text", "text": "Analyze:"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/img.jpg",
"detail": "high",
},
},
],
}
]
def test_chat_prompt_template__messages_property():
"""Test that messages property returns the original messages."""
messages = [
{"role": "user", "content": "Hello {{name}}"},
{"role": "assistant", "content": "Hi there!"},
]
tested = ChatPromptTemplate(messages)
assert tested.messages == messages
def test_chat_prompt_template__format__override_template_type():
"""Test that template_type can be overridden in format call."""
messages = [
{"role": "user", "content": "Name: {{name}}, City: {{city}}"},
]
# Create with Mustache default
tested = ChatPromptTemplate(messages, template_type=PromptType.MUSTACHE)
# Override with Jinja2 in format
result = tested.format(
{"name": "Harry", "city": "London"},
template_type=PromptType.JINJA2,
)
assert result == [{"role": "user", "content": "Name: Harry, City: London"}]
def test_chat_prompt_template__format__one_placeholder_used_multiple_times():
"""Test formatting with the same placeholder used multiple times."""
messages = [
{
"role": "user",
"content": "My name is {{name}}. I repeat, my name is {{name}}.",
}
]
tested = ChatPromptTemplate(messages)
result = tested.format({"name": "Harry"})
assert result == [
{"role": "user", "content": "My name is Harry. I repeat, my name is Harry."}
]
def test_chat_prompt_template__format__empty_messages_list():
"""Test formatting with empty messages list."""
messages = []
tested = ChatPromptTemplate(messages)
result = tested.format({})
assert result == []
def test_chat_prompt_template__format__message_with_missing_content():
"""Test formatting when message has no content field."""
messages = [
{"role": "user"}, # No content field
]
tested = ChatPromptTemplate(messages)
result = tested.format({})
assert result == [{"role": "user", "content": ""}]
def test_chat_prompt_template__format__passed_arguments_not_in_template__error_raised():
"""Test that extra format arguments not in template raise an error."""
messages = [
{"role": "user", "content": "Hi, my name is {{name}}, I live in {{city}}."}
]
tested = ChatPromptTemplate(messages, validate_placeholders=True)
with pytest.raises(
exceptions.PromptPlaceholdersDontMatchFormatArguments
) as exc_info:
tested.format({"name": "Harry", "city": "London", "nemesis_name": "Voldemort"})
assert exc_info.value.format_arguments == set(["name", "city", "nemesis_name"])
assert exc_info.value.prompt_placeholders == set(["name", "city"])
assert exc_info.value.symmetric_difference == set(["nemesis_name"])
def test_chat_prompt_template__format__some_placeholders_missing__error_raised():
"""Test that missing required placeholders raise an error."""
messages = [
{"role": "user", "content": "Hi, my name is {{name}}, I live in {{city}}."}
]
tested = ChatPromptTemplate(messages, validate_placeholders=True)
with pytest.raises(
exceptions.PromptPlaceholdersDontMatchFormatArguments
) as exc_info:
tested.format({"name": "Harry"})
assert exc_info.value.format_arguments == set(["name"])
assert exc_info.value.prompt_placeholders == set(["name", "city"])
assert exc_info.value.symmetric_difference == set(["city"])
def test_chat_prompt_template__format__placeholders_mismatch_both_ways__error_raised():
"""Test error when some placeholders are missing AND extra arguments provided."""
messages = [
{"role": "user", "content": "Hi, my name is {{name}}, I live in {{city}}."}
]
tested = ChatPromptTemplate(messages, validate_placeholders=True)
with pytest.raises(
exceptions.PromptPlaceholdersDontMatchFormatArguments
) as exc_info:
tested.format({"name": "Harry", "nemesis_name": "Voldemort"})
assert exc_info.value.format_arguments == set(["name", "nemesis_name"])
assert exc_info.value.prompt_placeholders == set(["name", "city"])
assert exc_info.value.symmetric_difference == set(["city", "nemesis_name"])
def test_chat_prompt_template__format__multimodal_placeholders__validates_all():
"""Test that placeholders in multimodal content are validated."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this {{object}}:"},
{"type": "image_url", "image_url": {"url": "{{image_url}}"}},
],
}
]
tested = ChatPromptTemplate(messages, validate_placeholders=True)
# Missing image_url placeholder
with pytest.raises(
exceptions.PromptPlaceholdersDontMatchFormatArguments
) as exc_info:
tested.format({"object": "painting"})
assert exc_info.value.format_arguments == set(["object"])
assert exc_info.value.prompt_placeholders == set(["object", "image_url"])
assert exc_info.value.symmetric_difference == set(["image_url"])
def test_chat_prompt_template__format__multiple_messages_placeholders__validates_all():
"""Test that placeholders across multiple messages are validated."""
messages = [
{"role": "system", "content": "You are an assistant in {{location}}."},
{"role": "user", "content": "What is the capital of {{country}}?"},
]
tested = ChatPromptTemplate(messages, validate_placeholders=True)
# Missing country placeholder
with pytest.raises(
exceptions.PromptPlaceholdersDontMatchFormatArguments
) as exc_info:
tested.format({"location": "London"})
assert exc_info.value.format_arguments == set(["location"])
assert exc_info.value.prompt_placeholders == set(["location", "country"])
assert exc_info.value.symmetric_difference == set(["country"])
def test_chat_prompt_template__format__validation_disabled__no_error():
"""Test that validation can be disabled."""
messages = [
{"role": "user", "content": "Hi, my name is {{name}}, I live in {{city}}."}
]
tested = ChatPromptTemplate(messages, validate_placeholders=False)
# Should not raise an error even though city is missing
result = tested.format({"name": "Harry"})
# The template will leave unformatted placeholders
assert result == [
{"role": "user", "content": "Hi, my name is Harry, I live in {{city}}."}
]
def test_chat_prompt_template__format__jinja2_no_validation():
"""Test that Jinja2 templates don't validate placeholders."""
messages = [
{"role": "user", "content": "Hi, my name is {{ name }}, I live in {{ city }}."}
]
tested = ChatPromptTemplate(messages, template_type=PromptType.JINJA2)
# Jinja2 templates don't validate, so this should not raise an error
# Jinja2 will just render missing variables as empty
result = tested.format({"name": "Harry"})
# Should render successfully (Jinja2 handles missing variables gracefully)
assert len(result) == 1
assert result[0]["role"] == "user"
@@ -0,0 +1,59 @@
from opik.api_objects.prompt.mask_context import (
get_active_prompt_masks,
get_mask_for_prompt,
prompt_mask_context,
)
class TestPromptMaskContext:
def test_no_context__returns_none(self):
assert get_active_prompt_masks() is None
def test_inside_context__returns_masks_dict(self):
masks = {"prompt-1": "mask-a", "prompt-2": "mask-b"}
with prompt_mask_context(masks):
assert get_active_prompt_masks() == masks
def test_get_mask_for_prompt__prompt_in_dict__returns_mask_id(self):
masks = {"prompt-uuid": "mask-uuid"}
with prompt_mask_context(masks):
assert get_mask_for_prompt("prompt-uuid") == "mask-uuid"
def test_get_mask_for_prompt__prompt_not_in_dict__returns_none(self):
masks = {"other-prompt": "mask-abc"}
with prompt_mask_context(masks):
assert get_mask_for_prompt("unknown-prompt") is None
def test_get_mask_for_prompt__no_context__returns_none(self):
assert get_mask_for_prompt("any-prompt-id") is None
def test_after_exit__resets_to_none(self):
with prompt_mask_context({"prompt-1": "mask-1"}):
pass
assert get_active_prompt_masks() is None
def test_context_with_none_masks__returns_none(self):
with prompt_mask_context(None):
assert get_active_prompt_masks() is None
assert get_mask_for_prompt("any-id") is None
def test_exception_inside__resets(self):
try:
with prompt_mask_context({"p": "m"}):
assert get_active_prompt_masks() == {"p": "m"}
raise ValueError("boom")
except ValueError:
pass
assert get_active_prompt_masks() is None
def test_nested_contexts__inner_wins_then_restores_outer(self):
outer = {"p1": "m1"}
inner = {"p2": "m2"}
with prompt_mask_context(outer):
assert get_active_prompt_masks() == outer
with prompt_mask_context(inner):
assert get_active_prompt_masks() == inner
assert get_mask_for_prompt("p2") == "m2"
assert get_mask_for_prompt("p1") is None
assert get_active_prompt_masks() == outer
assert get_active_prompt_masks() is None
@@ -0,0 +1,500 @@
"""Unit tests for the prompt client-side cache."""
import time
from typing import Optional
from unittest import mock
import pytest
from opik import config as opik_config
from opik.api_objects.prompt import prompt_cache
from opik.api_objects.prompt.prompt_cache import (
PromptCache,
get_global_cache,
)
def _get_ttl() -> int:
return opik_config.OpikConfig().prompt_cache_ttl_seconds
@pytest.fixture(autouse=True)
def clear_caches_after_test():
yield
get_global_cache().clear()
@pytest.fixture
def cache():
c = PromptCache()
yield c
c.clear()
def _make_mock_prompt(
name: str = "my-prompt",
commit: str = "abc123",
prompt_id: Optional[str] = None,
) -> mock.Mock:
p = mock.Mock()
p.name = name
p.commit = commit
p.__internal_api__prompt_id__ = prompt_id
return p
class TestPromptCache:
def test_get__missing_key__returns_none(self, cache):
assert cache.get(("p", None, None, "text")) is None
def test_get_or_fetch__cache_miss__fetches_and_caches(self, cache):
p = _make_mock_prompt()
fetch_fn = mock.Mock(return_value=p)
result = cache.get_or_fetch(
("p", None, None, "text"), fetch_fn, ttl_seconds=300
)
assert result is p
fetch_fn.assert_called_once()
def test_get_or_fetch__cache_hit__does_not_call_fetch_fn(self, cache):
p = _make_mock_prompt()
fetch_fn = mock.Mock(return_value=p)
cache.get_or_fetch(("p", None, None, "text"), fetch_fn, ttl_seconds=300)
fetch_fn.reset_mock()
result = cache.get_or_fetch(
("p", None, None, "text"), fetch_fn, ttl_seconds=300
)
assert result is p
fetch_fn.assert_not_called()
def test_get_or_fetch__fetch_returns_none__returns_none(self, cache):
fetch_fn = mock.Mock(return_value=None)
result = cache.get_or_fetch(
("missing", None, None, "text"), fetch_fn, ttl_seconds=300
)
assert result is None
def test_clear__populated_cache__removes_all_entries(self, cache):
p = _make_mock_prompt()
cache.get_or_fetch(
("p", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=None
)
cache.clear()
assert cache.get(("p", None, None, "text")) is None
def test_clear__running_refresh_thread__stops_thread(self, cache):
p = _make_mock_prompt()
cache.get_or_fetch(
("p", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=300
)
assert cache._thread is not None and cache._thread.is_alive()
cache.clear()
assert cache._thread is None
def test_refresh_thread__unpinned_entry__starts_thread(self, cache):
p = _make_mock_prompt()
cache.get_or_fetch(
("p", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=300
)
assert cache._thread is not None
assert cache._thread.is_alive()
def test_refresh_thread__pinned_entry__does_not_start_thread(self, cache):
p = _make_mock_prompt()
cache.get_or_fetch(
("p", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=None
)
assert cache._thread is None
def test_get_or_fetch__different_keys__returns_separate_entries(self, cache):
p1 = _make_mock_prompt(commit="c1")
p2 = _make_mock_prompt(commit="c2")
cache.get_or_fetch(
("p", "c1", None, "text"), mock.Mock(return_value=p1), ttl_seconds=None
)
cache.get_or_fetch(
("p", "c2", None, "text"), mock.Mock(return_value=p2), ttl_seconds=None
)
assert cache.get(("p", "c1", None, "text")) is p1
assert cache.get(("p", "c2", None, "text")) is p2
class TestBackgroundRefresh:
def test_refresh__stale_entry__updates_prompt(self):
new_prompt = _make_mock_prompt(commit=None)
callback = mock.Mock(return_value=new_prompt)
cache = get_global_cache()
base = time.monotonic()
with mock.patch("opik.api_objects.prompt.prompt_cache.time") as mock_time:
mock_time.monotonic.return_value = base
prompt_cache.get_or_fetch("p", None, None, "text", callback)
callback.reset_mock()
callback.return_value = new_prompt
mock_time.monotonic.return_value = base + _get_ttl() + 1
mock_time.sleep = mock.Mock()
cache._refresh_stale_entries()
assert callback.call_count >= 1
assert cache.get(("p", None, None, "text", None, None)) is new_prompt
def test_refresh__non_stale_entry__skips_callback(self):
p = _make_mock_prompt(commit=None)
callback = mock.Mock(return_value=p)
cache = get_global_cache()
base = time.monotonic()
with mock.patch("opik.api_objects.prompt.prompt_cache.time") as mock_time:
mock_time.monotonic.return_value = base
prompt_cache.get_or_fetch("p", None, None, "text", callback)
callback.reset_mock()
mock_time.monotonic.return_value = base + 0.2
cache._refresh_stale_entries()
callback.assert_not_called()
def test_refresh__callback_raises__thread_survives(self):
p = _make_mock_prompt(commit=None)
callback = mock.Mock(side_effect=[p, RuntimeError("boom")])
cache = get_global_cache()
base = time.monotonic()
with mock.patch("opik.api_objects.prompt.prompt_cache.time") as mock_time:
mock_time.monotonic.return_value = base
prompt_cache.get_or_fetch("p", None, None, "text", callback)
mock_time.monotonic.return_value = base + _get_ttl() + 1
cache._refresh_stale_entries()
assert cache._thread.is_alive()
class TestGetOrFetch:
def test_get_or_fetch__cache_miss__calls_fetch_fn(self):
p = _make_mock_prompt()
fetch_fn = mock.Mock(return_value=p)
result = prompt_cache.get_or_fetch("p", None, None, "text", fetch_fn)
assert result is p
fetch_fn.assert_called_once()
def test_get_or_fetch__fetch_returns_none__returns_none(self):
fetch_fn = mock.Mock(return_value=None)
result = prompt_cache.get_or_fetch("missing", None, None, "text", fetch_fn)
assert result is None
def test_get_or_fetch__cache_hit__does_not_call_fetch_fn(self):
p = _make_mock_prompt()
fetch_fn = mock.Mock(return_value=p)
prompt_cache.get_or_fetch("p", None, None, "text", fetch_fn)
fetch_fn.reset_mock()
result = prompt_cache.get_or_fetch("p", None, None, "text", fetch_fn)
assert result is p
fetch_fn.assert_not_called()
def test_get_or_fetch__pinned_commit__no_refresh_thread(self):
p = _make_mock_prompt(commit="abc")
fetch_fn = mock.Mock(return_value=p)
prompt_cache.get_or_fetch("p", "abc", None, "text", fetch_fn)
cache = get_global_cache()
assert cache._thread is None
def test_get_or_fetch__unpinned_commit__starts_refresh_thread(self):
p = _make_mock_prompt(commit=None)
fetch_fn = mock.Mock(return_value=p)
prompt_cache.get_or_fetch("p", None, None, "text", fetch_fn)
cache = get_global_cache()
assert cache._thread is not None
assert cache._thread.is_alive()
def test_get_or_fetch__cache_hit__returns_same_object(self):
p = _make_mock_prompt()
fetch_fn = mock.Mock(return_value=p)
prompt_cache.get_or_fetch("p", None, "proj", "text", fetch_fn)
first = prompt_cache.get_or_fetch("p", None, "proj", "text", fetch_fn)
second = prompt_cache.get_or_fetch("p", None, "proj", "text", fetch_fn)
assert first is second is p
def test_get_or_fetch__different_keys__returns_separate_entries(self):
p1 = _make_mock_prompt(commit="c1")
p2 = _make_mock_prompt(commit="c2")
prompt_cache.get_or_fetch("p", "c1", None, "text", mock.Mock(return_value=p1))
prompt_cache.get_or_fetch("p", "c2", None, "text", mock.Mock(return_value=p2))
r1 = prompt_cache.get_or_fetch("p", "c1", None, "text", mock.Mock())
r2 = prompt_cache.get_or_fetch("p", "c2", None, "text", mock.Mock())
assert r1 is p1
assert r2 is p2
class TestGetOrFetchVersionSelector:
"""Tests for the ``version`` parameter on the module-level ``get_or_fetch``."""
def test_get_or_fetch__different_versions__return_separate_entries(self):
p1 = _make_mock_prompt(commit="commitA")
p2 = _make_mock_prompt(commit="commitB")
fetch1 = mock.Mock(return_value=p1)
fetch2 = mock.Mock(return_value=p2)
first = prompt_cache.get_or_fetch("p", None, None, "text", fetch1, version="v1")
second = prompt_cache.get_or_fetch(
"p", None, None, "text", fetch2, version="v2"
)
assert first is p1
assert second is p2
fetch1.assert_called_once()
fetch2.assert_called_once()
def test_get_or_fetch__same_version__second_call_hits_cache(self):
p = _make_mock_prompt()
fetch_fn = mock.Mock(return_value=p)
first = prompt_cache.get_or_fetch(
"p", None, None, "text", fetch_fn, version="v2"
)
second = prompt_cache.get_or_fetch(
"p", None, None, "text", fetch_fn, version="v2"
)
assert first is second is p
fetch_fn.assert_called_once()
def test_get_or_fetch__commit_pin_and_version_pin__do_not_collide(self):
p_commit = _make_mock_prompt(commit="abc12345")
p_version = _make_mock_prompt(commit="def67890")
fetch_commit = mock.Mock(return_value=p_commit)
fetch_version = mock.Mock(return_value=p_version)
from_commit = prompt_cache.get_or_fetch(
"p", "abc12345", None, "text", fetch_commit
)
from_version = prompt_cache.get_or_fetch(
"p", None, None, "text", fetch_version, version="v3"
)
assert from_commit is p_commit
assert from_version is p_version
fetch_commit.assert_called_once()
fetch_version.assert_called_once()
def test_get_or_fetch__version_selector__starts_refresh_thread(self):
# Sequential versions can be reassigned by the backend if the underlying
# version is deleted and recreated, so they must follow the normal TTL
# refresh (not pinned indefinitely).
p = _make_mock_prompt()
fetch_fn = mock.Mock(return_value=p)
prompt_cache.get_or_fetch("p", None, None, "text", fetch_fn, version="v1")
cache = get_global_cache()
assert cache._thread is not None
assert cache._thread.is_alive()
class TestPromptCacheEdgeCases:
def test_get_or_fetch__multiple_unpinned_inserts__reuses_single_refresh_thread(
self, cache
):
p = _make_mock_prompt()
cache.get_or_fetch(
("a", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=300
)
thread1 = cache._thread
cache.get_or_fetch(
("b", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=300
)
assert cache._thread is thread1
def test_clear__on_empty_cache__is_noop(self, cache):
cache.clear()
assert cache.get(("any", None, None, "text")) is None
def test_refresh__pinned_entry__not_refreshed_even_after_ttl(self, cache):
p = _make_mock_prompt()
callback = mock.Mock(return_value=p)
base = time.monotonic()
with mock.patch("opik.api_objects.prompt.prompt_cache.time") as mock_time:
mock_time.monotonic.return_value = base
cache.get_or_fetch(("p", "v1", None, "text"), callback, ttl_seconds=None)
callback.reset_mock()
mock_time.monotonic.return_value = base + 0.2
cache._refresh_stale_entries()
callback.assert_not_called()
assert cache.get(("p", "v1", None, "text")) is p
def test_get_or_fetch__fetch_returns_none__not_cached(self, cache):
fetch_fn = mock.Mock(return_value=None)
cache.get_or_fetch(("p", None, None, "text"), fetch_fn, ttl_seconds=300)
assert cache.get(("p", None, None, "text")) is None
def test_refresh__callback_returns_none__preserves_original_prompt(self):
original = _make_mock_prompt(commit=None)
callback = mock.Mock(side_effect=[original, None])
cache = get_global_cache()
base = time.monotonic()
with mock.patch("opik.api_objects.prompt.prompt_cache.time") as mock_time:
mock_time.monotonic.return_value = base
prompt_cache.get_or_fetch("p", None, None, "text", callback)
mock_time.monotonic.return_value = base + _get_ttl() + 1
cache._refresh_stale_entries()
assert cache.get(("p", None, None, "text", None, None)) is original
def test_lru_eviction__oldest_entry_removed_when_max_size_exceeded(self):
cache = PromptCache(max_size=3)
prompts = [_make_mock_prompt(commit=f"c{i}") for i in range(4)]
for i, p in enumerate(prompts):
cache.get_or_fetch(
(f"p{i}", f"c{i}", None, "text"),
mock.Mock(return_value=p),
ttl_seconds=None,
)
assert cache.get(("p0", "c0", None, "text")) is None
assert cache.get(("p1", "c1", None, "text")) is prompts[1]
assert cache.get(("p2", "c2", None, "text")) is prompts[2]
assert cache.get(("p3", "c3", None, "text")) is prompts[3]
cache.clear()
def test_lru_eviction__access_refreshes_position(self):
cache = PromptCache(max_size=3)
prompts = [_make_mock_prompt(commit=f"c{i}") for i in range(3)]
for i, p in enumerate(prompts):
cache.get_or_fetch(
(f"p{i}", f"c{i}", None, "text"),
mock.Mock(return_value=p),
ttl_seconds=None,
)
# Access p0 so it becomes most-recently-used
cache.get(("p0", "c0", None, "text"))
# Insert a 4th entry — p1 (the actual LRU) should be evicted, not p0
p3 = _make_mock_prompt(commit="c3")
cache.get_or_fetch(
("p3", "c3", None, "text"),
mock.Mock(return_value=p3),
ttl_seconds=None,
)
assert cache.get(("p0", "c0", None, "text")) is prompts[0]
assert cache.get(("p1", "c1", None, "text")) is None
assert cache.get(("p2", "c2", None, "text")) is prompts[2]
assert cache.get(("p3", "c3", None, "text")) is p3
cache.clear()
def test_clear__called_twice__is_safe(self, cache):
p = _make_mock_prompt()
cache.get_or_fetch(
("p", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=300
)
cache.clear()
cache.clear()
assert cache._thread is None
class TestPromptAutoInjection:
"""Test that get_prompt injects prompts into the active trace/span context via opik_prompts."""
@staticmethod
def _make_prompt_with_info_dict(name="my-prompt", commit="abc123", info_dict=None):
p = _make_mock_prompt(name=name, commit=commit)
p.__internal_api__to_info_dict__ = mock.Mock(
return_value=info_dict or {"name": name, "version": {"commit": commit}}
)
return p
def _call_get_prompt(self, cache_return_value):
from opik.api_objects import opik_client
client = opik_client.Opik()
with mock.patch(
"opik.api_objects.prompt.prompt_cache.get_or_fetch",
return_value=cache_return_value,
):
return client.get_prompt(
name="my-prompt", commit="abc123", project_name=None
)
def test_get_prompt__in_track_context__injects_into_metadata(self):
info_dict = {"name": "my-prompt", "version": {"commit": "abc123"}}
mock_prompt = self._make_prompt_with_info_dict(info_dict=info_dict)
mock_trace_data = mock.Mock()
mock_trace_data.metadata = None
mock_span_data = mock.Mock()
mock_span_data.metadata = None
with (
mock.patch(
"opik.context_storage.get_trace_data", return_value=mock_trace_data
),
mock.patch(
"opik.context_storage.top_span_data", return_value=mock_span_data
),
):
self._call_get_prompt(mock_prompt)
mock_trace_data.update.assert_called_once_with(
metadata={"opik_prompts": [info_dict]}
)
mock_span_data.update.assert_called_once_with(
metadata={"opik_prompts": [info_dict]}
)
def test_get_prompt__appends_to_existing_prompts(self):
existing_prompt_info = {"name": "old-prompt", "version": {"commit": "old123"}}
new_info_dict = {"name": "my-prompt", "version": {"commit": "abc123"}}
mock_prompt = self._make_prompt_with_info_dict(info_dict=new_info_dict)
mock_trace_data = mock.Mock()
mock_trace_data.metadata = {
"opik_prompts": [existing_prompt_info],
"other_key": "value",
}
mock_span_data = mock.Mock()
mock_span_data.metadata = {"opik_prompts": [existing_prompt_info]}
with (
mock.patch(
"opik.context_storage.get_trace_data", return_value=mock_trace_data
),
mock.patch(
"opik.context_storage.top_span_data", return_value=mock_span_data
),
):
self._call_get_prompt(mock_prompt)
mock_trace_data.update.assert_called_once_with(
metadata={"opik_prompts": [existing_prompt_info, new_info_dict]}
)
mock_span_data.update.assert_called_once_with(
metadata={"opik_prompts": [existing_prompt_info, new_info_dict]}
)
def test_get_prompt__no_track_context__no_error(self):
"""When there is no active trace context, injection silently does nothing."""
mock_prompt = self._make_prompt_with_info_dict()
with (
mock.patch("opik.context_storage.get_trace_data", return_value=None),
mock.patch("opik.context_storage.top_span_data", return_value=None),
):
result = self._call_get_prompt(mock_prompt)
assert result is mock_prompt
def test_get_prompt__none_result__no_injection(self):
with (
mock.patch("opik.context_storage.get_trace_data") as mock_get_trace,
mock.patch("opik.context_storage.top_span_data") as mock_top_span,
):
result = self._call_get_prompt(None)
assert result is None
mock_get_trace.assert_not_called()
mock_top_span.assert_not_called()
@@ -0,0 +1,793 @@
"""Unit tests for PromptClient to verify API endpoint selection logic."""
from unittest import mock
from typing import Optional
import pytest
from opik import exceptions
from opik.api_objects import opik_client as opik_client_module
from opik.api_objects.prompt import client as prompt_client
from opik.api_objects.prompt import prompt_cache
from opik.api_objects.prompt import types as prompt_types
from opik.rest_api import core as rest_api_core
from opik.rest_api.types import prompt_version_detail
@pytest.fixture
def mock_rest_client():
"""Create a mock REST client."""
client = mock.Mock()
client.prompts = mock.Mock()
return client
@pytest.fixture
def client(mock_rest_client):
"""Create a PromptClient with a mock REST client."""
return prompt_client.PromptClient(mock_rest_client)
def _make_mock_version(
template: str = "test template",
version_type: str = "mustache",
metadata: Optional[dict] = None,
version_number: Optional[str] = "v1",
tags: Optional[list] = None,
environments: Optional[list] = None,
) -> prompt_version_detail.PromptVersionDetail:
"""Helper to create a mock PromptVersionDetail."""
return prompt_version_detail.PromptVersionDetail(
id="version-id",
prompt_id="prompt-id",
template=template,
type=version_type,
metadata=metadata,
commit="abc123",
version_number=version_number,
tags=tags,
environments=environments,
template_structure="text",
)
def _make_404_error() -> rest_api_core.ApiError:
"""Helper to create a 404 ApiError."""
error = rest_api_core.ApiError(status_code=404, body=None)
return error
class TestPromptClientEndpointSelection:
"""Tests to verify that PromptClient calls the correct REST endpoint based on parameters."""
def test_create_new_prompt_with_container_params_calls_create_prompt(
self, client, mock_rest_client
):
"""When creating a new prompt with id/description/tags, should call create_prompt endpoint.
Also asserts that version_number survives the tag-rebuild branch."""
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
_make_404_error(),
_make_mock_version(tags=None),
]
result = client.create_prompt(
name="test-prompt",
prompt="test template",
metadata=None,
type=prompt_types.PromptType.MUSTACHE,
id="custom-id",
description="A test prompt",
tags=["test", "unit"],
)
mock_rest_client.prompts.create_prompt.assert_called_once()
mock_rest_client.prompts.create_prompt_version.assert_not_called()
call_kwargs = mock_rest_client.prompts.create_prompt.call_args[1]
assert call_kwargs["name"] == "test-prompt"
assert call_kwargs["id"] == "custom-id"
assert call_kwargs["description"] == "A test prompt"
assert call_kwargs["tags"] == ["test", "unit"]
assert result.version_number == "v1"
assert result.tags == ["test", "unit"]
def test_create_new_prompt_without_container_params_calls_create_version(
self, client, mock_rest_client
):
"""When creating a new prompt without id/description/tags, should call create_prompt_version.
Also asserts that version_number from the create response is surfaced."""
mock_rest_client.prompts.retrieve_prompt_version.side_effect = _make_404_error()
mock_rest_client.prompts.create_prompt_version.return_value = (
_make_mock_version()
)
result = client.create_prompt(
name="test-prompt",
prompt="test template",
metadata=None,
type=prompt_types.PromptType.MUSTACHE,
)
mock_rest_client.prompts.create_prompt_version.assert_called_once()
mock_rest_client.prompts.create_prompt.assert_not_called()
assert result.version_number == "v1"
def test_update_existing_prompt_always_calls_create_version(
self, client, mock_rest_client
):
"""When updating an existing prompt, should always call create_prompt_version regardless of params."""
existing_version = _make_mock_version(template="old template")
mock_rest_client.prompts.retrieve_prompt_version.return_value = existing_version
mock_rest_client.prompts.create_prompt_version.return_value = (
_make_mock_version(template="new template")
)
client.create_prompt(
name="test-prompt",
prompt="new template",
metadata=None,
type=prompt_types.PromptType.MUSTACHE,
id="custom-id",
description="Updated description",
tags=["updated"],
)
mock_rest_client.prompts.create_prompt_version.assert_called_once()
mock_rest_client.prompts.create_prompt.assert_not_called()
def test_create_new_prompt_with_only_id_calls_create_prompt(
self, client, mock_rest_client
):
"""When creating a new prompt with only id parameter, should call create_prompt."""
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
_make_404_error(),
_make_mock_version(),
]
client.create_prompt(
name="test-prompt",
prompt="test template",
metadata=None,
type=prompt_types.PromptType.MUSTACHE,
id="custom-id",
)
mock_rest_client.prompts.create_prompt.assert_called_once()
mock_rest_client.prompts.create_prompt_version.assert_not_called()
def test_create_new_prompt_with_only_description_calls_create_prompt(
self, client, mock_rest_client
):
"""When creating a new prompt with only description parameter, should call create_prompt."""
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
_make_404_error(),
_make_mock_version(),
]
client.create_prompt(
name="test-prompt",
prompt="test template",
metadata=None,
type=prompt_types.PromptType.MUSTACHE,
description="A test prompt",
)
mock_rest_client.prompts.create_prompt.assert_called_once()
mock_rest_client.prompts.create_prompt_version.assert_not_called()
def test_create_new_prompt_with_only_tags_calls_create_prompt(
self, client, mock_rest_client
):
"""When creating a new prompt with only tags parameter, should call create_prompt.
Tags-only takes the rebuild branch — assert version_number survives it
(regression for the bug where tag injection dropped version_number)."""
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
_make_404_error(),
_make_mock_version(tags=None),
]
result = client.create_prompt(
name="test-prompt",
prompt="test template",
metadata=None,
type=prompt_types.PromptType.MUSTACHE,
tags=["test"],
)
mock_rest_client.prompts.create_prompt.assert_called_once()
mock_rest_client.prompts.create_prompt_version.assert_not_called()
assert result.version_number == "v1"
assert result.tags == ["test"]
class TestInternalCreateMask:
"""Tests for __internal__create_mask method."""
def test_create_mask_calls_create_prompt_version_with_mask_type(
self, client, mock_rest_client
):
expected = _make_mock_version()
mock_rest_client.prompts.create_prompt_version.return_value = expected
result = client._PromptClient__internal_api__create_mask(
name="test-prompt",
prompt="masked template",
)
mock_rest_client.prompts.create_prompt_version.assert_called_once()
call_kwargs = mock_rest_client.prompts.create_prompt_version.call_args[1]
assert call_kwargs["name"] == "test-prompt"
assert call_kwargs["version"].template == "masked template"
assert call_kwargs["version"].version_type == "mask"
assert result == expected
def test_create_mask_passes_all_parameters(self, client, mock_rest_client):
mock_rest_client.prompts.create_prompt_version.return_value = (
_make_mock_version()
)
client._PromptClient__internal_api__create_mask(
name="test-prompt",
prompt="masked template",
type=prompt_types.PromptType.JINJA2,
metadata={"key": "value"},
template_structure="chat",
project_name="my-project",
change_description="mask for testing",
)
call_kwargs = mock_rest_client.prompts.create_prompt_version.call_args[1]
assert call_kwargs["name"] == "test-prompt"
assert call_kwargs["template_structure"] == "chat"
assert call_kwargs["project_name"] == "my-project"
version = call_kwargs["version"]
assert version.template == "masked template"
assert version.version_type == "mask"
assert version.type == prompt_types.PromptType.JINJA2
assert version.metadata == {"key": "value"}
assert version.change_description == "mask for testing"
def test_create_mask_defaults(self, client, mock_rest_client):
mock_rest_client.prompts.create_prompt_version.return_value = (
_make_mock_version()
)
client._PromptClient__internal_api__create_mask(
name="test-prompt",
prompt="template",
)
call_kwargs = mock_rest_client.prompts.create_prompt_version.call_args[1]
assert call_kwargs["template_structure"] == "text"
assert call_kwargs["project_name"] is None
version = call_kwargs["version"]
assert version.type == prompt_types.PromptType.MUSTACHE
assert version.metadata is None
assert version.change_description is None
class TestGetPromptByVersionSelector:
"""Tests for the new sequential ``version`` parameter (e.g. ``"v3"``)."""
@pytest.fixture(autouse=True)
def clear_global_cache(self):
yield
prompt_cache.get_global_cache().clear()
def test_get_prompt__version_arg__threads_through_as_version_number(
self, client, mock_rest_client
):
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
_make_mock_version()
)
client.get_prompt(name="my-prompt", version="v3")
mock_rest_client.prompts.retrieve_prompt_version.assert_called_once()
call_kwargs = mock_rest_client.prompts.retrieve_prompt_version.call_args[1]
assert call_kwargs["name"] == "my-prompt"
assert call_kwargs["version_number"] == "v3"
assert call_kwargs["commit"] is None
def test_get_prompt__commit_and_version_both_set__raises_value_error(
self, client, mock_rest_client
):
with pytest.raises(ValueError, match=r"Provide either `commit` or `version`"):
client.get_prompt(name="my-prompt", commit="abc12345", version="v1")
mock_rest_client.prompts.retrieve_prompt_version.assert_not_called()
def test_get_prompt_with_cache__commit_and_version_both_set__raises_value_error(
self, client, mock_rest_client
):
from opik.api_objects.prompt.text import prompt as text_prompt_module
with pytest.raises(ValueError, match=r"Provide either `commit` or `version`"):
client.get_prompt_with_cache(
name="my-prompt",
commit="abc12345",
project_name=None,
template_structure="text",
prompt_cls=text_prompt_module.Prompt,
version="v1",
)
def test_get_prompt_with_cache__commit_and_environment__raises_value_error(
self, client, mock_rest_client
):
from opik.api_objects.prompt.text import prompt as text_prompt_module
with pytest.raises(ValueError, match="mutually exclusive"):
client.get_prompt_with_cache(
name="my-prompt",
commit="abc12345",
project_name=None,
template_structure="text",
prompt_cls=text_prompt_module.Prompt,
environment="staging",
)
def test_get_prompt_with_cache__version_and_environment__raises_value_error(
self, client, mock_rest_client
):
from opik.api_objects.prompt.text import prompt as text_prompt_module
with pytest.raises(ValueError, match="mutually exclusive"):
client.get_prompt_with_cache(
name="my-prompt",
commit=None,
project_name=None,
template_structure="text",
prompt_cls=text_prompt_module.Prompt,
version="v1",
environment="staging",
)
def test_get_prompt_with_cache__different_versions__do_not_collide_in_cache(
self, client, mock_rest_client
):
from opik.api_objects.prompt.text import prompt as text_prompt_module
v1 = _make_mock_version(template="content for v1")
v2 = _make_mock_version(template="content for v2")
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [v1, v2]
first = client.get_prompt_with_cache(
name="my-prompt",
commit=None,
project_name=None,
template_structure="text",
prompt_cls=text_prompt_module.Prompt,
version="v1",
)
second = client.get_prompt_with_cache(
name="my-prompt",
commit=None,
project_name=None,
template_structure="text",
prompt_cls=text_prompt_module.Prompt,
version="v2",
)
assert first is not None and first.prompt == "content for v1"
assert second is not None and second.prompt == "content for v2"
assert mock_rest_client.prompts.retrieve_prompt_version.call_count == 2
def test_get_prompt_with_cache__commit_and_version_pin__do_not_collide(
self, client, mock_rest_client
):
from opik.api_objects.prompt.text import prompt as text_prompt_module
by_commit = _make_mock_version(template="content by commit")
by_version = _make_mock_version(template="content by version")
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
by_commit,
by_version,
]
commit_result = client.get_prompt_with_cache(
name="my-prompt",
commit="abc12345",
project_name=None,
template_structure="text",
prompt_cls=text_prompt_module.Prompt,
)
version_result = client.get_prompt_with_cache(
name="my-prompt",
commit=None,
project_name=None,
template_structure="text",
prompt_cls=text_prompt_module.Prompt,
version="v3",
)
assert commit_result is not None
assert commit_result.prompt == "content by commit"
assert version_result is not None
assert version_result.prompt == "content by version"
assert mock_rest_client.prompts.retrieve_prompt_version.call_count == 2
def test_get_prompt_with_cache__same_version_twice__second_hits_cache(
self, client, mock_rest_client
):
from opik.api_objects.prompt.text import prompt as text_prompt_module
v2 = _make_mock_version(template="v2 content")
mock_rest_client.prompts.retrieve_prompt_version.return_value = v2
first = client.get_prompt_with_cache(
name="my-prompt",
commit=None,
project_name=None,
template_structure="text",
prompt_cls=text_prompt_module.Prompt,
version="v2",
)
second = client.get_prompt_with_cache(
name="my-prompt",
commit=None,
project_name=None,
template_structure="text",
prompt_cls=text_prompt_module.Prompt,
version="v2",
)
assert first is second
assert mock_rest_client.prompts.retrieve_prompt_version.call_count == 1
class TestGetPromptWithCacheBypass:
"""Tests for no_cache parameter in Opik.get_prompt()."""
@pytest.fixture(autouse=True)
def clear_global_cache(self):
yield
prompt_cache.get_global_cache().clear()
@pytest.fixture
def opik_client(self, mock_rest_client):
client = opik_client_module.Opik()
client._rest_client = mock_rest_client
return client
@pytest.mark.parametrize(
"no_cache, expected_extra_calls",
[
(False, 0),
(True, 1),
],
ids=["no_cache_false__uses_cache", "no_cache_true__hits_backend"],
)
def test_get_prompt__second_call_after_cache_warm__respects_no_cache(
self, opik_client, mock_rest_client, no_cache, expected_extra_calls
):
version = _make_mock_version()
mock_rest_client.prompts.retrieve_prompt_version.return_value = version
opik_client.get_prompt(name="my-prompt", commit=None, project_name=None)
call_count_after_warm = (
mock_rest_client.prompts.retrieve_prompt_version.call_count
)
opik_client.get_prompt(
name="my-prompt", commit=None, project_name=None, no_cache=no_cache
)
assert (
mock_rest_client.prompts.retrieve_prompt_version.call_count
== call_count_after_warm + expected_extra_calls
)
def test_get_prompt__no_cache_true__returns_fresh_value_from_backend(
self, opik_client, mock_rest_client
):
old_version = _make_mock_version(template="old template")
new_version = _make_mock_version(template="new template")
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
old_version,
new_version,
]
opik_client.get_prompt(name="my-prompt", commit=None, project_name=None)
result = opik_client.get_prompt(
name="my-prompt", commit=None, project_name=None, no_cache=True
)
assert result is not None
assert result.prompt == "new template"
def test_get_prompt__no_cache_true__backend_returns_none__returns_none(
self, opik_client, mock_rest_client
):
mock_rest_client.prompts.retrieve_prompt_version.side_effect = _make_404_error()
result = opik_client.get_prompt(
name="missing-prompt", commit=None, project_name=None, no_cache=True
)
assert result is None
class TestPromptEnvironment:
"""Unit tests for the prompt ``environment`` plumbing."""
@pytest.fixture(autouse=True)
def clear_global_cache(self):
yield
prompt_cache.get_global_cache().clear()
def test_get_prompt__forwards_environment_to_retrieve(
self, client, mock_rest_client
):
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
_make_mock_version()
)
client.get_prompt(name="env-prompt", environment="staging")
mock_rest_client.prompts.retrieve_prompt_version.assert_called_once()
call_kwargs = mock_rest_client.prompts.retrieve_prompt_version.call_args[1]
assert call_kwargs["environment"] == "staging"
def test_get_prompt__commit_and_environment__raises_value_error(
self, client, mock_rest_client
):
with pytest.raises(ValueError, match="mutually exclusive"):
client.get_prompt(
name="env-prompt", commit="abc12345", environment="staging"
)
mock_rest_client.prompts.retrieve_prompt_version.assert_not_called()
def test_get_prompt__version_and_environment__raises_value_error(
self, client, mock_rest_client
):
with pytest.raises(ValueError, match="mutually exclusive"):
client.get_prompt(name="env-prompt", version="v3", environment="staging")
mock_rest_client.prompts.retrieve_prompt_version.assert_not_called()
def test_get_prompt_with_cache__different_environments__not_cached_together(
self, mock_rest_client
):
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
staging_version = prompt_version_detail.PromptVersionDetail(
id="staging-id",
prompt_id="prompt-id",
template="staging template",
type="mustache",
metadata=None,
commit="aaa",
template_structure="text",
environments=["staging"],
)
production_version = prompt_version_detail.PromptVersionDetail(
id="prod-id",
prompt_id="prompt-id",
template="prod template",
type="mustache",
metadata=None,
commit="bbb",
template_structure="text",
environments=["production"],
)
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
staging_version,
production_version,
]
staging = opik_client.get_prompt(name="env-prompt", environment="staging")
production = opik_client.get_prompt(name="env-prompt", environment="production")
assert staging is not None and production is not None
assert staging.commit == "aaa"
assert production.commit == "bbb"
assert mock_rest_client.prompts.retrieve_prompt_version.call_count == 2
def test_set_prompt_environments__sets_via_latest_version(self, mock_rest_client):
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
_make_mock_version()
)
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
opik_client.set_prompt_environments("env-prompt", ["staging"])
mock_rest_client.prompts.retrieve_prompt_version.assert_called_once()
retrieve_kwargs = mock_rest_client.prompts.retrieve_prompt_version.call_args[1]
assert retrieve_kwargs["name"] == "env-prompt"
mock_rest_client.prompts.set_prompt_version_environment.assert_called_once_with(
version_id="version-id",
environments=["staging"],
)
def test_set_prompt_environments__empty_list_clears(self, mock_rest_client):
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
_make_mock_version()
)
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
opik_client.set_prompt_environments("env-prompt", [])
mock_rest_client.prompts.set_prompt_version_environment.assert_called_once_with(
version_id="version-id",
environments=[],
)
def test_set_prompt_environments__multiple_envs_in_one_call(self, mock_rest_client):
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
_make_mock_version()
)
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
opik_client.set_prompt_environments("env-prompt", ["staging", "production"])
mock_rest_client.prompts.set_prompt_version_environment.assert_called_once_with(
version_id="version-id",
environments=["staging", "production"],
)
def test_set_prompt_environments__deduplicates_input(self, mock_rest_client):
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
_make_mock_version()
)
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
opik_client.set_prompt_environments(
"env-prompt", ["staging", "staging", "production"]
)
mock_rest_client.prompts.set_prompt_version_environment.assert_called_once_with(
version_id="version-id",
environments=["staging", "production"],
)
def test_set_prompt_environments__forwards_project_name(self, mock_rest_client):
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
_make_mock_version()
)
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
opik_client.set_prompt_environments(
"env-prompt", ["staging"], project_name="my-project"
)
mock_rest_client.prompts.retrieve_prompt_version.assert_called_once()
retrieve_kwargs = mock_rest_client.prompts.retrieve_prompt_version.call_args[1]
assert retrieve_kwargs["name"] == "env-prompt"
assert retrieve_kwargs["project_name"] == "my-project"
def test_set_prompt_environments__forwards_version(self, mock_rest_client):
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
_make_mock_version()
)
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
opik_client.set_prompt_environments("env-prompt", ["staging"], version="v3")
mock_rest_client.prompts.retrieve_prompt_version.assert_called_once()
retrieve_kwargs = mock_rest_client.prompts.retrieve_prompt_version.call_args[1]
assert retrieve_kwargs["name"] == "env-prompt"
# The SDK forwards ``version`` as the wire-level ``version_number`` field —
# ``commit`` is no longer part of this method's surface.
assert retrieve_kwargs["version_number"] == "v3"
assert "commit" not in retrieve_kwargs
mock_rest_client.prompts.set_prompt_version_environment.assert_called_once_with(
version_id="version-id",
environments=["staging"],
)
def test_set_prompt_environments__prompt_not_found__raises_prompt_not_found(
self, mock_rest_client
):
mock_rest_client.prompts.retrieve_prompt_version.side_effect = (
rest_api_core.ApiError(status_code=404, body=None)
)
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
with pytest.raises(exceptions.PromptNotFoundError, match="missing-prompt"):
opik_client.set_prompt_environments("missing-prompt", ["staging"])
mock_rest_client.prompts.set_prompt_version_environment.assert_not_called()
def test_set_prompt_environments__version_not_found__raises_prompt_not_found_with_version(
self, mock_rest_client
):
mock_rest_client.prompts.retrieve_prompt_version.side_effect = (
rest_api_core.ApiError(status_code=404, body=None)
)
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
with pytest.raises(exceptions.PromptNotFoundError, match="v7"):
opik_client.set_prompt_environments("env-prompt", ["staging"], version="v7")
mock_rest_client.prompts.set_prompt_version_environment.assert_not_called()
# The backend reports an unknown environment as 404 or 409 from the
# workspace-registry check; both must surface as EnvironmentNotFoundError.
@pytest.mark.parametrize("status_code", [404, 409])
def test_set_prompt_environments__environment_not_found__raises_environment_not_found(
self, mock_rest_client, status_code
):
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
_make_mock_version()
)
mock_rest_client.prompts.set_prompt_version_environment.side_effect = (
rest_api_core.ApiError(status_code=status_code, body=None)
)
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
with pytest.raises(exceptions.EnvironmentNotFoundError, match="unknown-env"):
opik_client.set_prompt_environments("env-prompt", ["unknown-env"])
def test_set_prompt_environments__other_api_error__bubbles_up(
self, mock_rest_client
):
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
_make_mock_version()
)
mock_rest_client.prompts.set_prompt_version_environment.side_effect = (
rest_api_core.ApiError(status_code=500, body=None)
)
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
with pytest.raises(rest_api_core.ApiError):
opik_client.set_prompt_environments("env-prompt", ["staging"])
def test_set_prompt_environments__invalidates_cache(self, mock_rest_client):
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
_make_mock_version()
)
opik_client = opik_client_module.Opik()
opik_client._rest_client = mock_rest_client
resolved_project = opik_client._resolve_project_name(None)
cache = prompt_cache.get_global_cache()
cache.clear()
try:
sentinel = mock.MagicMock()
cache.get_or_fetch(
key=("env-prompt", None, resolved_project, "text", "staging"),
fetch_fn=lambda: sentinel,
ttl_seconds=None,
)
cache.get_or_fetch(
key=("other-prompt", None, resolved_project, "text", "staging"),
fetch_fn=lambda: sentinel,
ttl_seconds=None,
)
opik_client.set_prompt_environments("env-prompt", ["production"])
assert (
cache.get(("env-prompt", None, resolved_project, "text", "staging"))
is None
), "stale entry for the updated prompt should be evicted"
assert (
cache.get(("other-prompt", None, resolved_project, "text", "staging"))
is sentinel
), "entries for unrelated prompts must not be evicted"
finally:
cache.clear()
@@ -0,0 +1,180 @@
"""Tests for Prompt and ChatPrompt resilience when backend sync fails."""
from unittest.mock import patch, MagicMock
import httpx
import pytest
from opik.rest_api.core import ApiError
from opik.api_objects.prompt.text.prompt import Prompt
from opik.api_objects.prompt.chat.chat_prompt import ChatPrompt
_API_ERRORS = [
pytest.param(ApiError(status_code=500, body="Internal Server Error"), id="api_500"),
pytest.param(ApiError(status_code=503, body="Service Unavailable"), id="api_503"),
pytest.param(httpx.ConnectError("Connection refused"), id="connect_error"),
pytest.param(httpx.TimeoutException("Request timed out"), id="timeout"),
]
def _mock_opik_client():
"""Patch the opik_client.get_client_cached() used inside sync_with_backend."""
return patch(
"opik.api_objects.opik_client.get_client_cached",
return_value=MagicMock(),
)
class TestPromptSyncFailure:
"""Prompt should be created locally even when sync_with_backend fails with API errors."""
@pytest.mark.parametrize("error", _API_ERRORS)
def test_prompt_created_despite_backend_failure(self, error):
with (
_mock_opik_client(),
patch(
"opik.api_objects.prompt.client.PromptClient.create_prompt",
side_effect=error,
),
):
prompt = Prompt(name="test-prompt", prompt="Hello {{name}}")
assert prompt.name == "test-prompt"
assert prompt.prompt == "Hello {{name}}"
assert prompt.commit is None
assert prompt.synced is False
assert prompt.format(name="World") == "Hello World"
def test_sync_returns_false_on_api_error(self):
with (
_mock_opik_client(),
patch(
"opik.api_objects.prompt.client.PromptClient.create_prompt",
side_effect=ApiError(status_code=500),
),
):
prompt = Prompt(name="test-prompt", prompt="Hello {{name}}")
result = prompt.sync_with_backend()
assert result is False
assert prompt.synced is False
def test_sync_returns_true_on_success(self):
mock_version = MagicMock()
mock_version.commit = "abc123"
mock_version.prompt_id = "pid"
mock_version.id = "vid"
mock_version.change_description = None
mock_version.tags = []
with (
_mock_opik_client(),
patch(
"opik.api_objects.prompt.client.PromptClient.create_prompt",
return_value=mock_version,
),
):
prompt = Prompt(name="test-prompt", prompt="Hello {{name}}")
assert prompt.commit == "abc123"
assert prompt.synced is True
# Re-sync also succeeds
with (
_mock_opik_client(),
patch(
"opik.api_objects.prompt.client.PromptClient.create_prompt",
return_value=mock_version,
),
):
assert prompt.sync_with_backend() is True
assert prompt.synced is True
def test_non_api_error_propagates(self):
with (
_mock_opik_client(),
patch(
"opik.api_objects.prompt.client.PromptClient.create_prompt",
side_effect=ValueError("bad value"),
),
):
with pytest.raises(ValueError, match="bad value"):
Prompt(name="test-prompt", prompt="Hello {{name}}")
class TestChatPromptSyncFailure:
"""ChatPrompt should be created locally even when sync_with_backend fails with API errors."""
MESSAGES = [{"role": "user", "content": "Hello {{name}}"}]
@pytest.mark.parametrize("error", _API_ERRORS)
def test_chat_prompt_created_despite_backend_failure(self, error):
with (
_mock_opik_client(),
patch(
"opik.api_objects.prompt.client.PromptClient.create_prompt",
side_effect=error,
),
):
prompt = ChatPrompt(name="test-chat", messages=self.MESSAGES)
assert prompt.name == "test-chat"
assert prompt.template == self.MESSAGES
assert prompt.commit is None
assert prompt.synced is False
def test_sync_returns_false_on_api_error(self):
with (
_mock_opik_client(),
patch(
"opik.api_objects.prompt.client.PromptClient.create_prompt",
side_effect=ApiError(status_code=500),
),
):
prompt = ChatPrompt(name="test-chat", messages=self.MESSAGES)
result = prompt.sync_with_backend()
assert result is False
assert prompt.synced is False
def test_sync_returns_true_on_success(self):
mock_version = MagicMock()
mock_version.commit = "abc123"
mock_version.prompt_id = "pid"
mock_version.id = "vid"
mock_version.change_description = None
mock_version.tags = []
with (
_mock_opik_client(),
patch(
"opik.api_objects.prompt.client.PromptClient.create_prompt",
return_value=mock_version,
),
):
prompt = ChatPrompt(name="test-chat", messages=self.MESSAGES)
assert prompt.commit == "abc123"
assert prompt.synced is True
with (
_mock_opik_client(),
patch(
"opik.api_objects.prompt.client.PromptClient.create_prompt",
return_value=mock_version,
),
):
assert prompt.sync_with_backend() is True
assert prompt.synced is True
def test_non_api_error_propagates(self):
with (
_mock_opik_client(),
patch(
"opik.api_objects.prompt.client.PromptClient.create_prompt",
side_effect=ValueError("bad value"),
),
):
with pytest.raises(ValueError, match="bad value"):
ChatPrompt(name="test-chat", messages=self.MESSAGES)
@@ -0,0 +1,138 @@
import pytest
from opik.api_objects.prompt import PromptTemplate, PromptType
from opik import exceptions
def test_prompt__format__happyflow():
PROMPT_TEMPLATE = "Hi, my name is {{name}}, I live in {{city}}."
tested = PromptTemplate(PROMPT_TEMPLATE)
result = tested.format(name="Harry", city="London")
assert result == "Hi, my name is Harry, I live in London."
def test_prompt__format__one_placeholder_used_multiple_times():
PROMPT_TEMPLATE = (
"Hi, my name is {{name}}, I live in {{city}}. I repeat, my name is {{name}}"
)
tested = PromptTemplate(PROMPT_TEMPLATE)
result = tested.format(name="Harry", city="London")
assert (
result == "Hi, my name is Harry, I live in London. I repeat, my name is Harry"
)
def test_prompt__format__passed_arguments_that_are_not_in_template__error_raised_with_correct_report_info():
PROMPT_TEMPLATE = "Hi, my name is {{name}}, I live in {{city}}."
tested = PromptTemplate(PROMPT_TEMPLATE)
with pytest.raises(
exceptions.PromptPlaceholdersDontMatchFormatArguments
) as exc_info:
tested.format(name="Harry", city="London", nemesis_name="Voldemort")
assert exc_info.value.format_arguments == set(["name", "city", "nemesis_name"])
assert exc_info.value.prompt_placeholders == set(
[
"name",
"city",
]
)
assert exc_info.value.symmetric_difference == set(["nemesis_name"])
def test_prompt__format__some_placeholders_dont_have_corresponding_format_arguments__error_raised_with_correct_report_info():
PROMPT_TEMPLATE = "Hi, my name is {{name}}, I live in {{city}}."
tested = PromptTemplate(PROMPT_TEMPLATE)
with pytest.raises(
exceptions.PromptPlaceholdersDontMatchFormatArguments
) as exc_info:
tested.format(name="Harry")
assert exc_info.value.format_arguments == set(["name"])
assert exc_info.value.prompt_placeholders == set(["name", "city"])
assert exc_info.value.symmetric_difference == set(["city"])
def test_prompt__format__some_placeholders_dont_have_corresponding_format_arguments_AND_there_are_format_arguments_that_are_not_in_the_template__error_raised_with_correct_report_info():
PROMPT_TEMPLATE = "Hi, my name is {{name}}, I live in {{city}}."
tested = PromptTemplate(PROMPT_TEMPLATE)
with pytest.raises(
exceptions.PromptPlaceholdersDontMatchFormatArguments
) as exc_info:
tested.format(name="Harry", nemesis_name="Voldemort")
assert exc_info.value.format_arguments == set(["name", "nemesis_name"])
assert exc_info.value.prompt_placeholders == set(["name", "city"])
assert exc_info.value.symmetric_difference == set(["city", "nemesis_name"])
def test_prompt__format_jinja2__happyflow():
PROMPT_TEMPLATE = "Hi, my name is {{ name }}, I live in {{ city }}."
tested = PromptTemplate(PROMPT_TEMPLATE, type=PromptType.JINJA2)
result = tested.format(name="Harry", city="London")
assert result == "Hi, my name is Harry, I live in London."
def test_prompt__format_jinja2__with_control_flow():
PROMPT_TEMPLATE = """
{% if is_wizard %}
{{ name }} is a wizard who lives in {{ city }}.
{% else %}
{{ name }} is a muggle who lives in {{ city }}.
{% endif %}
"""
tested = PromptTemplate(PROMPT_TEMPLATE, type=PromptType.JINJA2)
wizard_result = tested.format(name="Harry", city="London", is_wizard=True)
assert "Harry is a wizard who lives in London." in wizard_result.strip()
muggle_result = tested.format(name="Dudley", city="Surrey", is_wizard=False)
assert "Dudley is a muggle who lives in Surrey." in muggle_result.strip()
def test_prompt__format_jinja2__with_loops():
PROMPT_TEMPLATE = """
{{ name }}'s friends are:
{% for friend in friends %}
- {{ friend }}
{% endfor %}
"""
tested = PromptTemplate(PROMPT_TEMPLATE, type=PromptType.JINJA2)
result = tested.format(name="Harry", friends=["Ron", "Hermione", "Neville"])
assert "Harry's friends are:" in result
assert "- Ron" in result
assert "- Hermione" in result
assert "- Neville" in result
def test_prompt__format_jinja2__with_filters():
PROMPT_TEMPLATE = "{{ name | upper }} lives in {{ city | lower }}."
tested = PromptTemplate(PROMPT_TEMPLATE, type=PromptType.JINJA2)
result = tested.format(name="Harry", city="LONDON")
assert result == "HARRY lives in london."
def test_prompt__format__none_values_render_as_empty_strings() -> None:
PROMPT_TEMPLATE = "Primary: {{primary}} Secondary: {{secondary}}"
tested = PromptTemplate(PROMPT_TEMPLATE)
result = tested.format(primary="cat", secondary=None)
assert result == "Primary: cat Secondary: "
@@ -0,0 +1,41 @@
import datetime
from opik.api_objects.span.span_data import SpanData
from ....testlib import assert_equal, ANY_BUT_NONE
def test_span_data__as_start_parameters__includes_parent_span_id():
span_data = SpanData(
trace_id="trace-1",
id="span-1",
parent_span_id="parent-1",
start_time=datetime.datetime.now(),
project_name="test",
name="span-name",
source="sdk",
)
expected_parameters = {
"id": "span-1",
"start_time": ANY_BUT_NONE,
"project_name": "test",
"trace_id": "trace-1",
"parent_span_id": "parent-1",
"name": "span-name",
"source": "sdk",
}
assert_equal(expected_parameters, span_data.as_start_parameters)
def test_span_data__as_start_parameters__excludes_parent_span_id_when_none():
span_data = SpanData(
trace_id="trace-1",
id="span-1",
parent_span_id=None,
start_time=datetime.datetime.now(),
project_name="test",
)
params = span_data.as_start_parameters
assert "parent_span_id" not in params
@@ -0,0 +1,395 @@
import threading
import time
from typing import List, Optional, Tuple
from unittest import mock
from opik import config as opik_config
from opik.api_objects import connection_resources
class FakeBundle:
"""Stand-in for SharedConnectionResourcesBundle that records close/flush calls."""
def __init__(self) -> None:
self.flush_timeout: Optional[int] = None
self.close_calls: List[Tuple[Optional[int], bool]] = []
self.flush_calls: List[Optional[int]] = []
@property
def closed(self) -> bool:
return len(self.close_calls) > 0
def close(self, timeout: Optional[int], *, flush: bool) -> None:
self.close_calls.append((timeout, flush))
def flush(self, timeout: Optional[int]) -> None:
self.flush_calls.append(timeout)
def _manager():
"""Returns (manager, created) where ``created`` accumulates built bundles.
A fake builder keeps these tests focused on the ref-counting lifecycle
without spinning up real transport threads.
"""
created: List[FakeBundle] = []
def builder(config, *, use_batching):
bundle = FakeBundle()
created.append(bundle)
return bundle
return connection_resources.ConnectionResourceManager(builder=builder), created
def _config(workspace: str = "default") -> opik_config.OpikConfig:
return opik_config.OpikConfig(workspace=workspace)
def test_acquire__same_config__reuses_single_bundle():
manager, created = _manager()
config = _config()
lease_a = manager.acquire(config, use_batching=True)
lease_b = manager.acquire(config, use_batching=True)
assert len(created) == 1
assert lease_a.resources is lease_b.resources is created[0]
assert manager.reference_count(config, use_batching=True) == 2
assert manager.active_connection_count() == 1
def test_acquire__distinct_configs__builds_separate_bundles():
manager, created = _manager()
config_a = _config("ws-a")
config_b = _config("ws-b")
lease_a = manager.acquire(config_a, use_batching=True)
lease_b = manager.acquire(config_b, use_batching=True)
assert len(created) == 2
assert lease_a.resources is not lease_b.resources
assert manager.active_connection_count() == 2
def test_acquire__differing_use_batching__builds_separate_bundles():
manager, created = _manager()
config = _config()
manager.acquire(config, use_batching=True)
manager.acquire(config, use_batching=False)
assert len(created) == 2
assert manager.active_connection_count() == 2
def test_release__not_last_reference__keeps_bundle_open():
manager, created = _manager()
config = _config()
lease_a = manager.acquire(config, use_batching=True)
manager.acquire(config, use_batching=True)
lease_a.release(timeout=None, close_on_zero=True)
assert not created[0].closed
# A durable release (flush=True, the default) still drains the shared queue
# so this handle's data is persisted while the bundle stays alive.
assert created[0].flush_calls == [None]
assert manager.reference_count(config, use_batching=True) == 1
def test_release__not_last_reference_with_flush__drains_shared_queue_without_closing():
# Durability contract under sharing: end(flush=True) on a handle that shares
# its bundle must flush the shared queue now — otherwise a co-located
# handle's later flush=False teardown could discard this handle's data.
manager, created = _manager()
config = _config()
lease_a = manager.acquire(config, use_batching=True)
manager.acquire(config, use_batching=True)
lease_a.release(timeout=3, flush=True, close_on_zero=True)
assert created[0].flush_calls == [3]
assert not created[0].closed
assert manager.reference_count(config, use_batching=True) == 1
def test_release__not_last_reference_flush_false__neither_flushes_nor_closes():
manager, created = _manager()
config = _config()
lease_a = manager.acquire(config, use_batching=True)
manager.acquire(config, use_batching=True)
lease_a.release(timeout=None, flush=False, close_on_zero=True)
assert created[0].flush_calls == []
assert not created[0].closed
def test_release__not_last_reference_gc_path__never_flushes():
# A GC finalizer (close_on_zero=False) must never do network I/O, even though
# it releases with flush=True by default.
manager, created = _manager()
config = _config()
lease_a = manager.acquire(config, use_batching=True)
manager.acquire(config, use_batching=True)
lease_a.release(timeout=None, close_on_zero=False)
assert created[0].flush_calls == []
assert not created[0].closed
assert manager.reference_count(config, use_batching=True) == 1
def test_release__concurrent_durable_and_teardown__no_close_during_shared_flush():
# Regression: a shared end(flush=True) pre-flushes while still holding its
# reference, so a concurrent last-release (flush=False) cannot evict + close
# the bundle and clear the queue mid-flush. The event forces the teardown to
# race the in-flight shared flush; on the buggy (decrement-then-flush) order
# the close would run while in_flush is True.
flush_started = threading.Event()
class RaceDetectBundle(FakeBundle):
def __init__(self) -> None:
super().__init__()
self.in_flush = False
self.closed_during_flush = False
def flush(self, timeout: Optional[int]) -> None:
self.in_flush = True
flush_started.set()
time.sleep(0.1) # window in which a racing close must not run
self.in_flush = False
super().flush(timeout)
def close(self, timeout: Optional[int], *, flush: bool) -> None:
if self.in_flush:
self.closed_during_flush = True
super().close(timeout, flush=flush)
bundle = RaceDetectBundle()
manager = connection_resources.ConnectionResourceManager(
builder=lambda config, *, use_batching: bundle
)
config = _config()
lease_a = manager.acquire(config, use_batching=True) # durable holder
lease_b = manager.acquire(config, use_batching=True) # fire-and-forget holder
def durable_release() -> None:
lease_a.release(timeout=None, flush=True, close_on_zero=True)
def teardown_release() -> None:
flush_started.wait(timeout=2) # release into the in-flight shared flush
lease_b.release(timeout=None, flush=False, close_on_zero=True)
threads = [
threading.Thread(target=durable_release),
threading.Thread(target=teardown_release),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert not bundle.closed_during_flush # close never ran mid-flush
assert bundle.closed # bundle still torn down once, after the flush
assert bundle.flush_calls == [None] # the durable holder drained the queue
assert manager.active_connection_count() == 0
def test_release__last_reference__closes_and_evicts():
manager, created = _manager()
config = _config()
lease_a = manager.acquire(config, use_batching=True)
lease_b = manager.acquire(config, use_batching=True)
lease_a.release(timeout=None, close_on_zero=True)
lease_b.release(timeout=None, close_on_zero=True)
assert created[0].close_calls == [(None, True)]
assert manager.reference_count(config, use_batching=True) == 0
assert manager.active_connection_count() == 0
def test_release__last_reference__forwards_timeout_and_flush():
manager, created = _manager()
lease = manager.acquire(_config(), use_batching=True)
lease.release(timeout=7, flush=False, close_on_zero=True)
assert created[0].close_calls == [(7, False)]
def test_release__not_close_on_zero__last_reference__decrements_without_closing():
# The GC-finalizer path: dropping the last reference must only decrement.
# Closing (thread joins, network flush) is never safe inside garbage
# collection, so the bundle is left cached instead.
manager, created = _manager()
config = _config()
lease = manager.acquire(config, use_batching=True)
lease.release(timeout=None, close_on_zero=False)
assert not created[0].closed
assert manager.reference_count(config, use_batching=True) == 0
assert manager.active_connection_count() == 1 # still cached, not evicted
def test_acquire__after_gc_release__reuses_cached_bundle():
# A bundle left cached by a close_on_zero=False release is reused by the
# next same-identity acquire rather than rebuilt.
manager, created = _manager()
config = _config()
lease = manager.acquire(config, use_batching=True)
lease.release(timeout=None, close_on_zero=False)
lease_again = manager.acquire(config, use_batching=True)
assert len(created) == 1
assert lease_again.resources is created[0]
assert manager.reference_count(config, use_batching=True) == 1
def test_close_all__after_gc_release__disposes_cached_bundle():
# Whatever a GC release leaves cached is still disposed at process exit.
manager, created = _manager()
config = _config()
lease = manager.acquire(config, use_batching=True)
lease.release(timeout=None, close_on_zero=False)
manager.close_all()
assert created[0].close_calls == [(None, True)]
assert manager.active_connection_count() == 0
def test_release__called_twice__decrements_once():
manager, created = _manager()
config = _config()
lease_a = manager.acquire(config, use_batching=True)
lease_b = manager.acquire(config, use_batching=True)
lease_a.release(timeout=None, close_on_zero=True)
lease_a.release(
timeout=None, close_on_zero=True
) # idempotent — must not decrement again
assert not created[0].closed
assert manager.reference_count(config, use_batching=True) == 1
lease_b.release(timeout=None, close_on_zero=True)
assert created[0].close_calls == [(None, True)]
assert manager.reference_count(config, use_batching=True) == 0
def test_acquire__after_full_release__builds_fresh_bundle():
manager, created = _manager()
config = _config()
lease = manager.acquire(config, use_batching=True)
lease.release(timeout=None, close_on_zero=True)
lease_again = manager.acquire(config, use_batching=True)
assert len(created) == 2
assert lease_again.resources is created[1]
def test_close_all__cached_bundles__closed_and_cleared():
manager, created = _manager()
manager.acquire(_config("ws-a"), use_batching=True)
manager.acquire(_config("ws-b"), use_batching=True)
manager.close_all()
assert manager.active_connection_count() == 0
# Each bundle is closed with its own configured flush timeout (None here).
assert all(bundle.close_calls == [(None, True)] for bundle in created)
def test_acquire_release__concurrent_same_config__preserves_invariants():
manager, created = _manager()
config = _config()
thread_count = 16
iterations = 50
barrier = threading.Barrier(thread_count)
errors: List[str] = []
def worker() -> None:
barrier.wait()
for _ in range(iterations):
lease = manager.acquire(config, use_batching=True)
# While a reference is held, the bundle must never be torn down —
# the manager must not hand out a closing bundle.
if lease.resources.closed:
errors.append("acquired a bundle that was already closed")
lease.release(timeout=None, close_on_zero=True)
threads = [threading.Thread(target=worker) for _ in range(thread_count)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert errors == []
assert manager.reference_count(config, use_batching=True) == 0
assert manager.active_connection_count() == 0
# Every bundle that was ever built must have been closed exactly once.
for bundle in created:
assert len(bundle.close_calls) == 1
def _bundle_with_mock_transport(flush_timeout=None):
streamer = mock.Mock()
file_upload_manager = mock.Mock()
httpx_client = mock.Mock()
bundle = connection_resources.SharedConnectionResourcesBundle(
httpx_client=httpx_client,
rest_client=mock.Mock(),
message_processor=mock.Mock(),
file_upload_manager=file_upload_manager,
replay_manager=mock.Mock(),
streamer=streamer,
flush_timeout=flush_timeout,
)
return bundle, streamer, file_upload_manager, httpx_client
def test_bundle_close__flush_true__drains_streamer_and_upload_pool():
bundle, streamer, file_upload_manager, httpx_client = _bundle_with_mock_transport()
bundle.close(5, flush=True)
# flush=True finalizes pending data: the streamer drains its queue and
# flushes uploads, and the upload pool waits for in-flight uploads.
streamer.close.assert_called_once_with(5, flush=True)
file_upload_manager.close.assert_called_once_with(wait=True)
# On a durable close the replay thread is joined and uploads drained, so the
# httpx pool is released too — eviction leaks no sockets.
httpx_client.close.assert_called_once_with()
def test_bundle_close__flush_false__stops_without_waiting():
bundle, streamer, file_upload_manager, httpx_client = _bundle_with_mock_transport()
bundle.close(None, flush=False)
streamer.close.assert_called_once_with(None, flush=False)
file_upload_manager.close.assert_called_once_with(wait=False)
# flush=False is fire-and-forget: the streamer leaves daemon threads to
# finish in-flight requests, so the shared httpx pool must NOT be closed here
# (closing it would race those requests). It's released at GC / process exit.
httpx_client.close.assert_not_called()
def test_bundle_flush__drains_streamer_without_closing():
bundle, streamer, file_upload_manager, httpx_client = _bundle_with_mock_transport()
bundle.flush(4)
# flush() drains the queue but must not tear anything down — the bundle is
# still shared by other handles.
streamer.flush.assert_called_once_with(4)
streamer.close.assert_not_called()
file_upload_manager.close.assert_not_called()
httpx_client.close.assert_not_called()
@@ -0,0 +1,75 @@
from opik.api_objects import data_helpers
def test_merge_tags_both_none():
"""Test merge_tags with both inputs None."""
result = data_helpers.merge_tags(None, None)
assert result is None
def test_merge_tags_existing_none():
"""Test merge_tags with existing tags None."""
result = data_helpers.merge_tags(None, ["new_tag"])
assert result == ["new_tag"]
def test_merge_tags_new_none():
"""Test merge_tags with new tags None."""
result = data_helpers.merge_tags(["existing_tag"], None)
assert result == ["existing_tag"]
def test_merge_tags_no_duplicates():
"""Test merge_tags with no duplicates."""
result = data_helpers.merge_tags(["tag1"], ["tag2", "tag3"])
assert result == ["tag1", "tag2", "tag3"]
def test_merge_tags_with_duplicates():
"""Test merge_tags with duplicates."""
result = data_helpers.merge_tags(["tag1", "tag2"], ["tag2", "tag3"])
assert result == ["tag1", "tag2", "tag3"]
def test_merge_tags_empty_lists():
"""Test merge_tags with empty lists."""
result = data_helpers.merge_tags([], [])
assert result is None
def test_merge_metadata_both_none():
"""Test merge_metadata with both inputs None."""
result = data_helpers.merge_metadata(None, None)
assert result is None
def test_merge_metadata_existing_none():
"""Test merge_metadata with existing metadata None."""
result = data_helpers.merge_metadata(None, {"key": "value"})
assert result == {"key": "value"}
def test_merge_metadata_new_none():
"""Test merge_metadata with new metadata None."""
result = data_helpers.merge_metadata({"key": "value"}, None)
assert result == {"key": "value"}
def test_merge_metadata_no_conflicts():
"""Test merge_metadata with no key conflicts."""
result = data_helpers.merge_metadata({"key1": "value1"}, {"key2": "value2"})
assert result == {"key1": "value1", "key2": "value2"}
def test_merge_metadata_with_conflicts():
"""Test merge_metadata with key conflicts (new values win)."""
result = data_helpers.merge_metadata(
{"key": "old_value", "other": "kept"}, {"key": "new_value"}
)
assert result == {"key": "new_value", "other": "kept"}
def test_merge_metadata_empty_dicts():
"""Test merge_metadata with empty dictionaries."""
result = data_helpers.merge_metadata({}, {})
assert result is None
@@ -0,0 +1,335 @@
"""Unit tests for the SDK ``environment`` plumbing.
Covers:
- ``Opik.trace(environment=...)`` is the only entry point that accepts an
explicit environment; the value flows into the emitted ``CreateTraceMessage``.
- Spans (``Trace.span``, ``Span.span``, ``@track``-created spans) inherit the
parent trace's environment unconditionally.
- A nested ``@track(environment=...)`` whose value differs from the enclosing
trace's environment logs a warning and is ignored — the parent trace's value
wins (mirrors how mismatched ``project_name`` is handled).
- Backwards compat: existing call sites without ``environment`` still work.
"""
from unittest.mock import MagicMock
import opik
from opik.api_objects import opik_client
from opik.message_processing import messages
from opik import dict_utils
def _capture_messages(client: opik_client.Opik) -> MagicMock:
mock_streamer = MagicMock()
client._streamer = mock_streamer
return mock_streamer
def _create_trace_messages(streamer: MagicMock):
return [
c.args[0]
for c in streamer.put.call_args_list
if isinstance(c.args[0], messages.CreateTraceMessage)
]
def _create_span_messages(streamer: MagicMock):
return [
c.args[0]
for c in streamer.put.call_args_list
if isinstance(c.args[0], messages.CreateSpanMessage)
]
def test_opik_client__no_environment_set__messages_have_none_environment_and_payload_omits_it():
client = opik_client.Opik(project_name="test-project")
streamer = _capture_messages(client)
client.trace(name="t")
client.span(name="s")
trace_msg = _create_trace_messages(streamer)[0]
span_msg = _create_span_messages(streamer)[0]
assert trace_msg.environment is None
assert span_msg.environment is None
cleaned = dict_utils.remove_none_from_dict(trace_msg.as_payload_dict())
assert "environment" not in cleaned
def test_opik_client_trace__environment_kwarg_propagates_to_create_trace_message():
client = opik_client.Opik(project_name="test-project")
streamer = _capture_messages(client)
client.trace(name="t", environment="staging")
assert _create_trace_messages(streamer)[0].environment == "staging"
def test_opik_client_trace__span_inherits_trace_environment():
client = opik_client.Opik(project_name="test-project")
streamer = _capture_messages(client)
trace = client.trace(name="t", environment="staging")
trace.span(name="s")
assert _create_span_messages(streamer)[0].environment == "staging"
def test_opik_client_trace__nested_span_inherits_environment():
client = opik_client.Opik(project_name="test-project")
streamer = _capture_messages(client)
trace = client.trace(name="t", environment="staging")
span = trace.span(name="parent-span")
span.span(name="child-span")
span_msgs = _create_span_messages(streamer)
assert len(span_msgs) == 2
assert all(m.environment == "staging" for m in span_msgs)
def test_track_decorator__environment_propagates_to_root_trace_and_spans():
client = opik_client.Opik(project_name="test-project")
streamer = _capture_messages(client)
opik_client.set_global_client(client)
@opik.track(environment="staging")
def my_fn():
return 42
my_fn()
trace_msgs = _create_trace_messages(streamer)
span_msgs = _create_span_messages(streamer)
assert trace_msgs and span_msgs
assert all(m.environment == "staging" for m in trace_msgs)
assert all(m.environment == "staging" for m in span_msgs)
def test_track_decorator__nested_environment_mismatch_warns_and_inherits_parent(
monkeypatch,
):
from opik.api_objects import helpers as opik_helpers
client = opik_client.Opik(project_name="test-project")
streamer = _capture_messages(client)
opik_client.set_global_client(client)
warnings = []
monkeypatch.setattr(
opik_helpers.LOGGER,
"warning",
lambda msg, *a, **kw: warnings.append(msg % a if a else msg),
)
@opik.track(environment="this-should-be-ignored")
def inner():
return 1
@opik.track(environment="staging")
def outer():
return inner()
outer()
span_msgs = _create_span_messages(streamer)
assert span_msgs
assert all(m.environment == "staging" for m in span_msgs)
assert any("this-should-be-ignored" in w and "staging" in w for w in warnings), (
warnings
)
def test_create_trace_message__environment_field_default_is_none_for_backwards_compat():
msg = messages.CreateTraceMessage(
trace_id="t",
project_name="p",
name=None,
start_time=None, # type: ignore[arg-type]
end_time=None,
input=None,
output=None,
metadata=None,
tags=None,
error_info=None,
thread_id=None,
last_updated_at=None,
source="sdk",
)
assert msg.environment is None
def test_update_environment__colour_on_builtin_raises_error():
from opik.exceptions import EnvironmentConfigurationError
client = opik_client.Opik(project_name="test-project")
for env_name in ("production", "staging", "development"):
try:
client.update_environment(env_name, color="#ff0000")
assert False, f"expected EnvironmentConfigurationError for {env_name!r}"
except EnvironmentConfigurationError as e:
assert env_name in str(e)
def test_update_environment__colour_on_builtin_not_called_when_no_colour():
from unittest.mock import patch
from opik.rest_api.types.environment_public import EnvironmentPublic
client = opik_client.Opik(project_name="test-project")
fake_env = EnvironmentPublic(id="abc", name="production")
with (
patch.object(client, "_find_environment_by_name", return_value=fake_env),
patch.object(
client._rest_client.environments,
"update_environment",
return_value=None,
) as mock_update,
patch.object(
client._rest_client.environments,
"get_environment_by_id",
return_value=fake_env,
),
):
result = client.update_environment("production", description="new desc")
mock_update.assert_called_once()
assert result == fake_env
def test_update_environment__colour_on_custom_env_is_allowed():
from unittest.mock import patch
from opik.rest_api.types.environment_public import EnvironmentPublic
client = opik_client.Opik(project_name="test-project")
fake_env = EnvironmentPublic(id="xyz", name="my-custom-env")
with (
patch.object(client, "_find_environment_by_name", return_value=fake_env),
patch.object(
client._rest_client.environments,
"update_environment",
return_value=None,
) as mock_update,
patch.object(
client._rest_client.environments,
"get_environment_by_id",
return_value=fake_env,
),
):
result = client.update_environment("my-custom-env", color="#123456")
assert result == fake_env
mock_update.assert_called_once()
assert mock_update.call_args.kwargs.get("color") == "#123456"
def test_create_environment__conflict_raises_environment_already_exists():
from unittest.mock import patch
from opik.exceptions import EnvironmentAlreadyExists
from opik.rest_api.errors import ConflictError
client = opik_client.Opik(project_name="test-project")
with patch.object(
client._rest_client.environments,
"create_environment",
side_effect=ConflictError(body={"message": "already exists"}),
):
try:
client.create_environment("production")
assert False, "expected EnvironmentAlreadyExists"
except EnvironmentAlreadyExists as e:
assert "production" in str(e)
def test_create_span_message__environment_field_default_is_none_for_backwards_compat():
msg = messages.CreateSpanMessage(
span_id="s",
trace_id="t",
project_name="p",
parent_span_id=None,
name=None,
start_time=None, # type: ignore[arg-type]
end_time=None,
input=None,
output=None,
metadata=None,
tags=None,
type="general",
usage=None,
model=None,
provider=None,
error_info=None,
total_cost=None,
last_updated_at=None,
source="sdk",
)
assert msg.environment is None
def _update_span_messages(streamer: MagicMock):
return [
c.args[0]
for c in streamer.put.call_args_list
if isinstance(c.args[0], messages.UpdateSpanMessage)
]
def _update_trace_messages(streamer: MagicMock):
return [
c.args[0]
for c in streamer.put.call_args_list
if isinstance(c.args[0], messages.UpdateTraceMessage)
]
def test_span_end__preserves_environment_in_update_message():
client = opik_client.Opik(project_name="test-project")
streamer = _capture_messages(client)
trace = client.trace(name="t", environment="staging")
span = trace.span(name="s")
span.end()
update_msgs = _update_span_messages(streamer)
assert len(update_msgs) == 1
assert update_msgs[0].environment == "staging"
def test_span_update__preserves_environment_in_update_message():
client = opik_client.Opik(project_name="test-project")
streamer = _capture_messages(client)
trace = client.trace(name="t", environment="production")
span = trace.span(name="s")
span.update(output={"result": "ok"})
update_msgs = _update_span_messages(streamer)
assert len(update_msgs) == 1
assert update_msgs[0].environment == "production"
def test_trace_end__preserves_environment_in_update_message():
client = opik_client.Opik(project_name="test-project")
streamer = _capture_messages(client)
trace = client.trace(name="t", environment="staging")
trace.end()
update_msgs = _update_trace_messages(streamer)
assert len(update_msgs) == 1
assert update_msgs[0].environment == "staging"
def test_trace_update__preserves_environment_in_update_message():
client = opik_client.Opik(project_name="test-project")
streamer = _capture_messages(client)
trace = client.trace(name="t", environment="production")
trace.update(output={"result": "ok"})
update_msgs = _update_trace_messages(streamer)
assert len(update_msgs) == 1
assert update_msgs[0].environment == "production"
@@ -0,0 +1,19 @@
import pytest
from opik.api_objects import helpers
@pytest.mark.parametrize(
"usage,metadata,create_metadata,expected",
[
(None, None, False, None),
(None, {"foo": "bar"}, False, {"foo": "bar"}),
({}, None, False, None),
({"foo": "bar"}, None, True, {"usage": {"foo": "bar"}}),
],
)
def test_add_usage_to_metadata(usage, metadata, create_metadata, expected):
result = helpers.add_usage_to_metadata(
usage=usage, metadata=metadata, create_metadata=create_metadata
)
assert result == expected
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
"""Sharing behaviour of Opik clients over ref-counted connection resources.
These assert only through the public client surface (`rest_client`, `trace()`,
`flush()`, `end()`): clients with a matching connection config reuse one
transport, distinct configs get their own, and ending one client does not
disable another that shares the connection. The ref-counting/eviction mechanics
themselves are covered at the manager level in
``test_connection_resource_manager.py``.
"""
import gc
import threading
import time
import weakref
from unittest import mock
from opik.api_objects import opik_client
def _make_client(**kwargs) -> opik_client.Opik:
return opik_client.Opik(_show_misconfiguration_message=False, **kwargs)
def test_get_global_client__concurrent_cold_start__creates_single_client():
# Regression: get_global_client() must create the singleton once under
# concurrency. When several threads hit the cold-start path together (e.g. a
# tracer's _opik_client property accessed from parallel pipelines), each
# building its own client would race the shared connection-resource manager
# and, under a streamer-sharing test backend, close a streamer still in use —
# hanging a later flush().
opik_client.reset_global_client(end_client=False)
thread_count = 8
barrier = threading.Barrier(thread_count)
constructed = []
results = []
results_lock = threading.Lock()
def slow_construct(*args, **kwargs):
time.sleep(0.02) # widen the window a racy implementation would lose in
client = object()
constructed.append(client)
return client
def worker() -> None:
barrier.wait() # release all threads into the cold-start path together
client = opik_client.get_global_client()
with results_lock:
results.append(client)
try:
with mock.patch.object(opik_client, "Opik", side_effect=slow_construct):
threads = [threading.Thread(target=worker) for _ in range(thread_count)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert len(constructed) == 1 # built exactly once despite the race
assert {id(client) for client in results} == {id(constructed[0])}
finally:
opik_client.reset_global_client(end_client=False)
def test_opik_clients__matching_connection_config__share_one_rest_client():
client_a = _make_client()
client_b = _make_client()
try:
# A shared connection is observable through the public REST client:
# both handles expose the very same underlying client object.
assert client_a.rest_client is client_b.rest_client
finally:
client_a.end(flush=False)
client_b.end(flush=False)
def test_opik_clients__distinct_connection_config__use_separate_rest_clients():
client_a = _make_client()
client_b = _make_client(host="http://localhost:39999/api")
try:
assert client_a.rest_client is not client_b.rest_client
finally:
client_a.end(flush=False)
client_b.end(flush=False)
def test_opik_client__logs_after_co_located_client_ended__data_still_delivered(
fake_backend,
):
keeper = _make_client()
transient = _make_client() # shares keeper's connection
# Ending one client releases only its reference; the shared transport must
# stay alive for the other handle.
transient.end(flush=False)
keeper.trace(name="after-sibling-end")
keeper.flush()
assert [trace.name for trace in fake_backend.trace_trees] == ["after-sibling-end"]
keeper.end(flush=False)
def test_opik_client__dropped_without_end__is_garbage_collected(fake_backend):
# No lingering strong reference (a cached manager entry, the GC finalizer, a
# background thread, or the global singleton) should keep a dropped client
# alive. After `del` + `gc.collect()`, its weakref must no longer resolve.
# A unique host gives this handle its own isolated bundle so the assertion is
# about this client alone.
opik_client.reset_global_client(end_client=False)
client = _make_client(host="http://localhost:39998/api")
client_ref = weakref.ref(client)
del client
gc.collect()
assert client_ref() is None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
import logging
from unittest.mock import patch
import pytest
from opik.api_objects import helpers
from opik.config import OPIK_PROJECT_DEFAULT_NAME
from opik._logging import LOG_ONCE_CACHE
WARNING_MESSAGE = (
'No project name configured. Traces are being logged to "Default Project".\n'
"Set OPIK_PROJECT_NAME environment variable or pass project_name to the Opik client\n"
"to log to a specific project.\n"
"See https://www.comet.com/docs/opik/tracing/advanced/sdk_configuration"
)
@pytest.fixture(autouse=True)
def _clear_log_once_cache():
LOG_ONCE_CACHE.discard(WARNING_MESSAGE)
yield
class TestResolveProjectName:
def test_explicit_project_name_returned(self):
result = helpers.resolve_project_name(
explicitly_passed_value="My Project",
value_from_config=OPIK_PROJECT_DEFAULT_NAME,
)
assert result == "My Project"
def test_explicit_default_project_name_no_warning(self):
with patch(
"opik.api_objects.helpers.opik_logging.log_once_at_level"
) as mock_log:
result = helpers.resolve_project_name(
explicitly_passed_value=OPIK_PROJECT_DEFAULT_NAME,
value_from_config=OPIK_PROJECT_DEFAULT_NAME,
)
assert result == OPIK_PROJECT_DEFAULT_NAME
mock_log.assert_not_called()
def test_context_project_returned(self):
result = helpers.resolve_project_name(
explicitly_passed_value=None,
value_from_config=OPIK_PROJECT_DEFAULT_NAME,
value_from_context="Context Project",
)
assert result == "Context Project"
def test_context_project_no_warning(self):
with patch(
"opik.api_objects.helpers.opik_logging.log_once_at_level"
) as mock_log:
helpers.resolve_project_name(
explicitly_passed_value=None,
value_from_config=OPIK_PROJECT_DEFAULT_NAME,
value_from_context="Context Project",
)
mock_log.assert_not_called()
def test_default_fallback_warns(self):
with patch(
"opik.api_objects.helpers.opik_logging.log_once_at_level"
) as mock_log:
result = helpers.resolve_project_name(
explicitly_passed_value=None,
value_from_config=OPIK_PROJECT_DEFAULT_NAME,
)
assert result == OPIK_PROJECT_DEFAULT_NAME
mock_log.assert_called_once_with(
logging_level=logging.WARNING,
message=WARNING_MESSAGE,
logger=helpers.LOGGER,
)
def test_non_default_config_project_no_warning(self):
with patch(
"opik.api_objects.helpers.opik_logging.log_once_at_level"
) as mock_log:
result = helpers.resolve_project_name(
explicitly_passed_value=None,
value_from_config="Custom Project",
)
assert result == "Custom Project"
mock_log.assert_not_called()
@@ -0,0 +1,163 @@
import json
import httpx
import pytest
from opik.rest_api.types import span_public as rest_api_types
from opik.api_objects import rest_stream_parser
SPANS_STREAM_JSON = [
{
"id": "0195f6f1-9da2-7630-b285-60cf5580372f",
"project_id": "0195f6f1-9c82-751f-a1ad-54fec4b5c7d8",
"trace_id": "0195f6f1-9c3f-7b65-a334-56173d19bc00",
"parent_span_id": "0195f6f1-9d9f-77c0-8063-17c59f6d9875",
"name": "synthesize",
"type": "general",
"start_time": "2025-01-03T11:03:17.875608Z",
"end_time": "2025-01-03T11:03:18.591814Z",
"input": {"query_str": "If Opik had a motto, what would it be?"},
"output": {
"output": '"Empowering continuous improvement through community-driven innovation."'
},
"created_at": "2025-04-02T14:39:44.412550Z",
"last_updated_at": "2025-04-02T14:39:44.412550Z",
"created_by": "admin",
"last_updated_by": "admin",
"duration": 716.206,
},
{
"id": "0195f6f1-9d9f-77c0-8063-17c59f6d9875",
"project_id": "0195f6f1-9c82-751f-a1ad-54fec4b5c7d8",
"trace_id": "0195f6f1-9c3f-7b65-a334-56173d19bc00",
"name": "query",
"type": "general",
"start_time": "2025-01-03T11:03:17.505783Z",
"end_time": "2025-01-03T11:03:18.591897Z",
"input": {"query_str": "If Opik had a motto, what would it be?"},
"output": {
"output": '"Empowering continuous improvement through community-driven innovation."'
},
"created_at": "2025-04-02T14:39:44.412550Z",
"last_updated_at": "2025-04-02T14:39:44.412550Z",
"created_by": "admin",
"last_updated_by": "admin",
"duration": 1086.114,
},
]
@pytest.fixture
def spans_stream_source():
spans_stream = [
f"{json.dumps(span)}\r\n".encode("utf-8") for span in SPANS_STREAM_JSON
]
yield spans_stream
def test_read_and_parse_stream__span(spans_stream_source):
spans = rest_stream_parser.read_and_parse_stream(
spans_stream_source, item_class=rest_api_types.SpanPublic
)
assert len(spans) == 2
for i, span in enumerate(spans):
expected = rest_api_types.SpanPublic.model_validate(SPANS_STREAM_JSON[i])
assert span == expected
def test_read_and_parse_stream__limit_samples(spans_stream_source):
spans = rest_stream_parser.read_and_parse_stream(
spans_stream_source, item_class=rest_api_types.SpanPublic, nb_samples=1
)
assert len(spans) == 1
expected = rest_api_types.SpanPublic.model_validate(SPANS_STREAM_JSON[0])
assert spans[0] == expected
def test_read_and_parse_full_stream__happy_flow(spans_stream_source):
spans = rest_stream_parser.read_and_parse_full_stream(
read_source=lambda current_batch_size, last_retrieved_id: spans_stream_source,
parsed_item_class=rest_api_types.SpanPublic,
max_results=10,
)
assert len(spans) == 2
for i, span in enumerate(spans):
expected = rest_api_types.SpanPublic.model_validate(SPANS_STREAM_JSON[i])
assert span == expected
def test_read_and_parse_full_stream__no_error__requested_batch_sizes_not_halved(
spans_stream_source,
):
requested_batch_sizes = []
def read_source(current_batch_size, last_retrieved_id):
requested_batch_sizes.append(current_batch_size)
return spans_stream_source
rest_stream_parser.read_and_parse_full_stream(
read_source=read_source,
parsed_item_class=rest_api_types.SpanPublic,
max_results=None,
max_endpoint_batch_size=400,
)
# Two spans returned (< page size) ends the loop after one request; the
# requested page size is the configured one, never shrunk.
assert requested_batch_sizes == [400]
def test_read_and_parse_full_stream__size_correlated_error__halves_page_and_retries_same_cursor(
spans_stream_source,
):
requested = []
calls = {"n": 0}
def read_source(current_batch_size, last_retrieved_id):
requested.append((current_batch_size, last_retrieved_id))
calls["n"] += 1
if calls["n"] == 1:
raise httpx.RemoteProtocolError("incomplete chunked read")
return spans_stream_source
spans = rest_stream_parser.read_and_parse_full_stream(
read_source=read_source,
parsed_item_class=rest_api_types.SpanPublic,
max_results=None,
max_endpoint_batch_size=400,
)
# First request at 400 fails; retried at 200 from the SAME (None) cursor.
assert requested == [(400, None), (200, None)]
assert len(spans) == 2
def test_read_and_parse_full_stream__shrink_floor_reached__reraises(
spans_stream_source,
):
def read_source(current_batch_size, last_retrieved_id):
raise httpx.ReadTimeout("timed out")
# Start at the floor so the first failure can't shrink further.
with pytest.raises(httpx.ReadTimeout):
rest_stream_parser.read_and_parse_full_stream(
read_source=read_source,
parsed_item_class=rest_api_types.SpanPublic,
max_results=None,
max_endpoint_batch_size=rest_stream_parser.MIN_ENDPOINT_BATCH_SIZE,
)
def test_read_and_parse_full_stream__non_size_correlated_error__propagates(
spans_stream_source,
):
def read_source(current_batch_size, last_retrieved_id):
raise ValueError("not a connection error")
with pytest.raises(ValueError):
rest_stream_parser.read_and_parse_full_stream(
read_source=read_source,
parsed_item_class=rest_api_types.SpanPublic,
max_results=None,
max_endpoint_batch_size=400,
)
@@ -0,0 +1,216 @@
import json
import logging
from unittest import mock
import pytest
from opik.api_objects import search_helpers
from opik.rest_api.core.api_error import ApiError
from opik.rest_api.types import span_public, trace_public
SPAN_RECORD = {
"id": "0195f6f1-9da2-7630-b285-60cf5580372f",
"project_id": "0195f6f1-9c82-751f-a1ad-54fec4b5c7d8",
"trace_id": "0195f6f1-9c3f-7b65-a334-56173d19bc00",
"name": "synthesize",
"type": "general",
"start_time": "2025-01-03T11:03:17.875608Z",
"end_time": "2025-01-03T11:03:18.591814Z",
"input": {"q": "x"},
"output": {"a": "y"},
"created_at": "2025-04-02T14:39:44.412550Z",
"last_updated_at": "2025-04-02T14:39:44.412550Z",
"created_by": "admin",
"last_updated_by": "admin",
"duration": 1.0,
}
TRACE_RECORD = {
"id": "0195f6f1-9c3f-7b65-a334-56173d19bc00",
"project_id": "0195f6f1-9c82-751f-a1ad-54fec4b5c7d8",
"name": "root",
"start_time": "2025-01-03T11:03:17.505783Z",
"end_time": "2025-01-03T11:03:18.591897Z",
"input": {"q": "x"},
"output": {"a": "y"},
"created_at": "2025-04-02T14:39:44.412550Z",
"last_updated_at": "2025-04-02T14:39:44.412550Z",
"created_by": "admin",
"last_updated_by": "admin",
"duration": 1.0,
}
def _stream(record: dict):
"""Mimic the bytes-iterator returned by the Fern-generated search clients."""
yield f"{json.dumps(record)}\r\n".encode("utf-8")
@pytest.fixture
def patched_sleep():
# Patch the rest_helpers retry-delay indirection, NOT the global time.sleep:
# a global patch turns background daemon threads' pacing sleep into a busy
# loop that can starve the interpreter and hang the suite.
with mock.patch("opik.api_objects.rest_helpers._sleep") as sleep_mock:
yield sleep_mock
def _sleep_called_with(mock_sleep: mock.Mock, seconds) -> bool:
return any(call.args == (seconds,) for call in mock_sleep.call_args_list)
def test_search_spans__429_response__retries_with_reset_header(
patched_sleep, capture_log
):
rest_client = mock.Mock()
rest_client.spans.search_spans.side_effect = [
ApiError(status_code=429, headers={"RateLimit-Reset": "2"}),
_stream(SPAN_RECORD),
]
result = search_helpers.search_spans_with_filters(
rest_client=rest_client,
trace_id=None,
project_name="proj",
filters=None,
max_results=10,
truncate=False,
)
assert len(result) == 1
assert result[0] == span_public.SpanPublic.model_validate(SPAN_RECORD)
assert rest_client.spans.search_spans.call_count == 2
assert _sleep_called_with(patched_sleep, 2)
assert any(
"search_spans" in record.message and record.levelno == logging.WARNING
for record in capture_log.records
)
def test_search_traces__429_response__retries_with_reset_header(
patched_sleep, capture_log
):
rest_client = mock.Mock()
rest_client.traces.search_traces.side_effect = [
ApiError(status_code=429, headers={"RateLimit-Reset": "3"}),
_stream(TRACE_RECORD),
]
result = search_helpers.search_traces_with_filters(
rest_client=rest_client,
project_name="proj",
filters=None,
max_results=10,
truncate=False,
)
assert len(result) == 1
assert result[0] == trace_public.TracePublic.model_validate(TRACE_RECORD)
assert rest_client.traces.search_traces.call_count == 2
assert _sleep_called_with(patched_sleep, 3)
assert any(
"search_traces" in record.message and record.levelno == logging.WARNING
for record in capture_log.records
)
def test_search_spans__non_429_error__propagates_without_retry():
rest_client = mock.Mock()
rest_client.spans.search_spans.side_effect = ApiError(
status_code=400, body="bad request"
)
with pytest.raises(ApiError) as exc_info:
search_helpers.search_spans_with_filters(
rest_client=rest_client,
trace_id=None,
project_name="proj",
filters=None,
max_results=10,
truncate=False,
)
assert exc_info.value.status_code == 400
# call_count == 1 already proves no retry happened — no need to assert on
# patched_sleep, which is polluted by background-thread sleeps.
assert rest_client.spans.search_spans.call_count == 1
def test_search_traces__non_429_error__propagates_without_retry():
rest_client = mock.Mock()
rest_client.traces.search_traces.side_effect = ApiError(
status_code=500, body="boom"
)
with pytest.raises(ApiError) as exc_info:
search_helpers.search_traces_with_filters(
rest_client=rest_client,
project_name="proj",
filters=None,
max_results=10,
truncate=False,
)
assert exc_info.value.status_code == 500
assert rest_client.traces.search_traces.call_count == 1
def test_search_spans__max_batch_size__passed_as_limit_to_rest_client():
rest_client = mock.Mock()
rest_client.spans.search_spans.return_value = _stream(SPAN_RECORD)
search_helpers.search_spans_with_filters(
rest_client=rest_client,
trace_id=None,
project_name="proj",
filters=None,
max_results=10_000,
truncate=False,
max_batch_size=400,
)
# max_results (10_000) is the total cap; max_batch_size (400) is the
# per-request page size that reaches the Fern client as ``limit``.
_, kwargs = rest_client.spans.search_spans.call_args
assert kwargs["limit"] == 400
def test_search_traces__max_batch_size__passed_as_limit_to_rest_client():
rest_client = mock.Mock()
rest_client.traces.search_traces.return_value = _stream(TRACE_RECORD)
search_helpers.search_traces_with_filters(
rest_client=rest_client,
project_name="proj",
filters=None,
max_results=10_000,
truncate=False,
max_batch_size=250,
)
_, kwargs = rest_client.traces.search_traces.call_args
assert kwargs["limit"] == 250
def test_search_spans__429_without_reset_header__falls_back_to_one_second_sleep(
patched_sleep,
):
rest_client = mock.Mock()
rest_client.spans.search_spans.side_effect = [
ApiError(status_code=429, headers={}),
_stream(SPAN_RECORD),
]
result = search_helpers.search_spans_with_filters(
rest_client=rest_client,
trace_id=None,
project_name="proj",
filters=None,
max_results=10,
truncate=False,
)
assert len(result) == 1
assert rest_client.spans.search_spans.call_count == 2
assert _sleep_called_with(patched_sleep, 1)
@@ -0,0 +1,67 @@
import datetime
from opik.api_objects import trace
from ....testlib import assert_equal, ANY_BUT_NONE
def test_trace_data__as_start_parameters__expected_parameters_are_set():
trace_data = trace.TraceData(
name="name",
project_name="project_name",
metadata={"foo": "bar"},
input={"input": "input"},
tags=["one", "two"],
created_by="evaluation",
source="sdk",
)
expected_parameters = {
"id": ANY_BUT_NONE,
"start_time": ANY_BUT_NONE,
"project_name": "project_name",
"name": "name",
"metadata": {"foo": "bar"},
"input": {"input": "input"},
"tags": ["one", "two"],
"source": "sdk",
}
assert_equal(expected_parameters, trace_data.as_start_parameters)
def test_trace_data__as_parameters__expected_parameters_are_set():
trace_data = trace.TraceData(
name="name",
end_time=datetime.datetime.now(),
metadata={"foo": "bar"},
input={"input": "input"},
output={"output": "output"},
tags=["one", "two"],
feedback_scores=[{"name": "score_name", "value": 0.5}],
project_name="project_name",
error_info={},
thread_id="thread_id",
attachments=[],
created_by="evaluation",
source="sdk",
)
expected_parameters = {
"id": ANY_BUT_NONE,
"name": "name",
"start_time": ANY_BUT_NONE,
"end_time": ANY_BUT_NONE,
"metadata": ANY_BUT_NONE,
"input": ANY_BUT_NONE,
"output": ANY_BUT_NONE,
"tags": ANY_BUT_NONE,
"feedback_scores": ANY_BUT_NONE,
"project_name": "project_name",
"error_info": ANY_BUT_NONE,
"thread_id": ANY_BUT_NONE,
"attachments": ANY_BUT_NONE,
"source": "sdk",
"environment": None,
}
assert_equal(expected_parameters, trace_data.as_parameters)