chore: import upstream snapshot with attribution
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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)
|
||||
Reference in New Issue
Block a user