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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:44 +08:00
commit 5a558eb09e
11579 changed files with 1795921 additions and 0 deletions
View File
@@ -0,0 +1,275 @@
import pytest
from opik.anonymizer import factory
from opik.anonymizer import rules_anonymizer
class TestCreateAnonymizer:
"""Test suite for the create_anonymizer factory function."""
def test_create_anonymizer__single_dict_rule(self):
"""Test creating anonymizer with a single dictionary rule."""
rules = {"regex": r"\d+", "replace": "***"}
anonymizer = factory.create_anonymizer(rules)
assert isinstance(anonymizer, rules_anonymizer.RulesAnonymizer)
assert len(anonymizer.rules) == 1
assert anonymizer.max_depth == 10
result = anonymizer.anonymize("Phone: 123-456")
assert result == "Phone: ***-***"
def test_create_anonymizer__single_tuple_rule(self):
"""Test creating anonymizer with a single tuple rule."""
rules = (r"\d+", "***")
anonymizer = factory.create_anonymizer(rules)
assert isinstance(anonymizer, rules_anonymizer.RulesAnonymizer)
assert len(anonymizer.rules) == 1
result = anonymizer.anonymize("Phone: 123-456")
assert result == "Phone: ***-***"
def test_create_anonymizer__single_function_rule(self):
"""Test creating anonymizer with a single function rule."""
def uppercase_rule(text: str) -> str:
return text.upper()
rules = uppercase_rule
anonymizer = factory.create_anonymizer(rules)
assert isinstance(anonymizer, rules_anonymizer.RulesAnonymizer)
assert len(anonymizer.rules) == 1
result = anonymizer.anonymize("hello world")
assert result == "HELLO WORLD"
def test_create_anonymizer__list_of_dict_rules(self):
"""Test creating anonymizer with a list of dictionary rules."""
rules = [
{"regex": r"\d+", "replace": "[NUMBER]"},
{
"regex": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
"replace": "[EMAIL]",
},
]
anonymizer = factory.create_anonymizer(rules)
assert isinstance(anonymizer, rules_anonymizer.RulesAnonymizer)
assert len(anonymizer.rules) == 2
result = anonymizer.anonymize("Contact: test@example.com or 123")
assert result == "Contact: [EMAIL] or [NUMBER]"
def test_create_anonymizer__list_of_tuple_rules(self):
"""Test creating anonymizer with a list of tuple rules."""
rules = [
(r"\d+", "[NUMBER]"),
(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "[EMAIL]"),
]
anonymizer = factory.create_anonymizer(rules)
assert isinstance(anonymizer, rules_anonymizer.RulesAnonymizer)
assert len(anonymizer.rules) == 2
result = anonymizer.anonymize("Contact: test@example.com or 123")
assert result == "Contact: [EMAIL] or [NUMBER]"
def test_create_anonymizer__list_of_function_rules(self):
"""Test creating anonymizer with a list of function rules."""
def uppercase_rule(text: str) -> str:
return text.upper()
def replace_spaces(text: str) -> str:
return text.replace(" ", "_")
rules = [uppercase_rule, replace_spaces]
anonymizer = factory.create_anonymizer(rules)
assert isinstance(anonymizer, rules_anonymizer.RulesAnonymizer)
assert len(anonymizer.rules) == 2
result = anonymizer.anonymize("hello world")
assert result == "HELLO_WORLD"
def test_create_anonymizer__mixed_rules_list(self):
"""Test creating anonymizer with mixed rule types in a list."""
def prefix_rule(text: str) -> str:
return f"[PROCESSED] {text}"
rules = [{"regex": r"\d+", "replace": "***"}, (r"test", "TEST"), prefix_rule]
anonymizer = factory.create_anonymizer(rules)
assert isinstance(anonymizer, rules_anonymizer.RulesAnonymizer)
assert len(anonymizer.rules) == 3
result = anonymizer.anonymize("test 123")
assert result == "[PROCESSED] TEST ***"
def test_create_anonymizer__custom_max_depth(self):
"""Test creating anonymizer with custom max_depth."""
rules = {"regex": r"\d+", "replace": "***"}
anonymizer = factory.create_anonymizer(rules, max_depth=5)
assert anonymizer.max_depth == 5
def test_create_anonymizer__complex_nested_data(self):
"""Test created anonymizer with complex nested data."""
rules = [
{"regex": r"\d{3}-\d{3}-\d{4}", "replace": "[PHONE]"},
{
"regex": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
"replace": "[EMAIL]",
},
]
anonymizer = factory.create_anonymizer(rules)
data = {
"users": [
{"name": "John", "contact": "john@test.com", "phone": "123-456-7890"},
{"name": "Jane", "contact": "jane@test.org", "phone": "987-654-3210"},
]
}
result = anonymizer.anonymize(data)
expected = {
"users": [
{"name": "John", "contact": "[EMAIL]", "phone": "[PHONE]"},
{"name": "Jane", "contact": "[EMAIL]", "phone": "[PHONE]"},
]
}
assert result == expected
def test_create_anonymizer__invalid_dict_rule_missing_regex__error_raised(self):
"""Test error handling for a dictionary rule missing the 'regex' key."""
rules = {"replace": "***"}
with pytest.raises(
ValueError, match="Dictionary rule must have 'regex' and 'replace' keys"
):
factory.create_anonymizer(rules)
def test_create_anonymizer__invalid_dict_rule_missing_replace__error_raised(self):
"""Test error handling for a dictionary rule missing the 'replace' key."""
rules = {"regex": r"\d+"}
with pytest.raises(
ValueError, match="Dictionary rule must have 'regex' and 'replace' keys"
):
factory.create_anonymizer(rules)
def test_create_anonymizer__invalid_tuple_rule_wrong_length__error_raised(self):
"""Test error handling for tuple rule with the wrong length."""
rules = (r"\d+",) # Only one element
with pytest.raises(ValueError, match="Tuple rule must have exactly 2 elements"):
factory.create_anonymizer(rules)
def test_create_anonymizer__invalid_tuple_rule_too_many_elements__error_raised(
self,
):
"""Test error handling for tuple rule with too many elements."""
rules = (r"\d+", "***", "extra")
with pytest.raises(ValueError, match="Tuple rule must have exactly 2 elements"):
factory.create_anonymizer(rules)
def test_create_anonymizer__invalid_dict_rule_in_list__error_raised(self):
"""Test error handling for invalid dictionary rule in a list."""
rules = [
{"regex": r"\d+", "replace": "***"},
{"regex": r"test"}, # Missing 'replace'
]
with pytest.raises(
ValueError, match="Dictionary rule must have 'regex' and 'replace' keys"
):
factory.create_anonymizer(rules)
def test_create_anonymizer__invalid_tuple_rule_in_list__error_raised(self):
"""Test error handling for invalid tuple rule in a list."""
rules = [
(r"\d+", "***"),
(r"test",), # Wrong length
]
with pytest.raises(ValueError, match="Tuple rule must have exactly 2 elements"):
factory.create_anonymizer(rules)
def test_create_anonymizer__unsupported_rule_type_in_list__error_raised(self):
"""Test error handling for an unsupported rule type in the list."""
rules = [
{"regex": r"\d+", "replace": "***"},
123, # Invalid type
]
with pytest.raises(ValueError, match="Unsupported rule type in list"):
factory.create_anonymizer(rules)
def test_create_anonymizer__unsupported_rules_type__error_raised(self):
"""Test error handling for completely unsupported rules type."""
rules = 123 # Invalid type
with pytest.raises(ValueError, match="Unsupported rules type"):
factory.create_anonymizer(rules)
def test_create_anonymizer__empty_list(self):
"""Test creating anonymizer with an empty rules list."""
rules = []
anonymizer = factory.create_anonymizer(rules)
assert isinstance(anonymizer, rules_anonymizer.RulesAnonymizer)
assert len(anonymizer.rules) == 0
# Should return original text with no rules
result = anonymizer.anonymize("test data")
assert result == "test data"
def test_create_anonymizer__lambda_function(self):
"""Test creating anonymizer with lambda function rule."""
anonymizer = factory.create_anonymizer(
lambda text: text.replace("secret", "[REDACTED]")
)
assert isinstance(anonymizer, rules_anonymizer.RulesAnonymizer)
assert len(anonymizer.rules) == 1
result = anonymizer.anonymize("This is secret information")
assert result == "This is [REDACTED] information"
def test_create_anonymizer__real_world_pii_rules(self):
"""Test creating anonymizer with realistic PII anonymization rules."""
rules = [
# Phone numbers
{"regex": r"\b\d{3}-\d{3}-\d{4}\b", "replace": "[PHONE]"},
# Email addresses
{
"regex": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"replace": "[EMAIL]",
},
# SSN
{"regex": r"\b\d{3}-\d{2}-\d{4}\b", "replace": "[SSN]"},
# Credit card (simplified)
{"regex": r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b", "replace": "[CARD]"},
]
anonymizer = factory.create_anonymizer(rules)
text = """
Personal Information:
Name: John Doe
Email: john.doe@company.com
Phone: 555-123-4567
SSN: 123-45-6789
Credit Card: 1234 5678 9012 3456
"""
result = anonymizer.anonymize(text)
assert result != text
assert (
result
== """
Personal Information:
Name: John Doe
Email: [EMAIL]
Phone: [PHONE]
SSN: [SSN]
Credit Card: [CARD]
"""
)
@@ -0,0 +1,560 @@
from typing import Any, Dict, List
from unittest.mock import Mock
from opik.anonymizer.recursive_anonymizer import RecursiveAnonymizer
class TestRecursiveAnonymizer:
"""Test suite for RecursiveAnonymizer parameter handling and nested structure processing."""
def test_recursive_anonymizer__simple_string__calls_anonymize_text_with_correct_parameters(
self,
):
"""Test that anonymize_text is called with correct parameters for a simple string."""
class MockRecursiveAnonymizer(RecursiveAnonymizer):
def __init__(self):
super().__init__()
self.anonymize_text = Mock(return_value="[ANONYMIZED]")
def anonymize_text(self, data: str, **kwargs: Any) -> str:
return self.anonymize_text(data, **kwargs)
anonymizer = MockRecursiveAnonymizer()
# Test with initial parameters
result = anonymizer.anonymize(
"sensitive text", field_name="input", object_type=dict
)
# Verify anonymize_text was called correctly
anonymizer.anonymize_text.assert_called_once_with(
"sensitive text", field_name="input", object_type=dict
)
assert result == "[ANONYMIZED]"
def test_recursive_anonymizer__nested_dict__preserves_field_path_in_parameters(
self,
):
"""Test that field paths are correctly built and passed for nested dictionaries."""
calls_log = []
class ParameterTrackingAnonymizer(RecursiveAnonymizer):
def anonymize_text(self, data: str, **kwargs: Any) -> str:
calls_log.append(
{
"data": data,
"field_name": kwargs.get("field_name"),
"object_type": kwargs.get("object_type"),
}
)
return f"[ANON:{data}]"
anonymizer = ParameterTrackingAnonymizer()
nested_data = {
"user": {
"email": "user@example.com",
"profile": {"name": "John Doe", "phone": "555-1234"},
},
"metadata": {"api_key": "secret123"},
}
result: Dict[str, Any] = anonymizer.anonymize(
nested_data, field_name="trace", object_type="TraceMessage"
)
# Verify the correct field paths were generated
expected_calls = [
{
"data": "user@example.com",
"field_name": "trace.user.email",
"object_type": "TraceMessage",
},
{
"data": "John Doe",
"field_name": "trace.user.profile.name",
"object_type": "TraceMessage",
},
{
"data": "555-1234",
"field_name": "trace.user.profile.phone",
"object_type": "TraceMessage",
},
{
"data": "secret123",
"field_name": "trace.metadata.api_key",
"object_type": "TraceMessage",
},
]
assert len(calls_log) == 4
for expected_call in expected_calls:
assert expected_call in calls_log
# Verify the structure was preserved with anonymized content
assert result["user"]["email"] == "[ANON:user@example.com]"
assert result["user"]["profile"]["name"] == "[ANON:John Doe]"
assert result["user"]["profile"]["phone"] == "[ANON:555-1234]"
assert result["metadata"]["api_key"] == "[ANON:secret123]"
def test_recursive_anonymizer__nested_list__preserves_field_path_with_indices(self):
"""Test that field paths include list indices for nested lists."""
calls_log = []
class ParameterTrackingAnonymizer(RecursiveAnonymizer):
def anonymize_text(self, data: str, **kwargs: Any) -> str:
calls_log.append(
{
"data": data,
"field_name": kwargs.get("field_name"),
"object_type": kwargs.get("object_type"),
}
)
return f"[ANON:{data}]"
anonymizer = ParameterTrackingAnonymizer()
list_data = [
"first item",
{"nested": "nested value", "list": ["inner item 1", "inner item 2"]},
["list item 1", "list item 2"],
]
result: List[Any] = anonymizer.anonymize(
list_data, field_name="input", object_type="SpanMessage"
)
# Verify the correct field paths were generated with indices
expected_calls = [
{
"data": "first item",
"field_name": "input.0",
"object_type": "SpanMessage",
},
{
"data": "nested value",
"field_name": "input.1.nested",
"object_type": "SpanMessage",
},
{
"data": "inner item 1",
"field_name": "input.1.list.0",
"object_type": "SpanMessage",
},
{
"data": "inner item 2",
"field_name": "input.1.list.1",
"object_type": "SpanMessage",
},
{
"data": "list item 1",
"field_name": "input.2.0",
"object_type": "SpanMessage",
},
{
"data": "list item 2",
"field_name": "input.2.1",
"object_type": "SpanMessage",
},
]
assert len(calls_log) == 6
for expected_call in expected_calls:
assert expected_call in calls_log
# Verify the structure was preserved with anonymized content
assert result[0] == "[ANON:first item]"
assert result[1]["nested"] == "[ANON:nested value]"
assert result[1]["list"] == ["[ANON:inner item 1]", "[ANON:inner item 2]"]
assert result[2] == ["[ANON:list item 1]", "[ANON:list item 2]"]
def test_recursive_anonymizer__mixed_complex_structure__handles_all_parameter_combinations(
self,
):
"""Test a complex nested structure with mixed dictionaries, lists, and strings."""
calls_log = []
class ParameterTrackingAnonymizer(RecursiveAnonymizer):
def anonymize_text(self, data: str, **kwargs: Any) -> str:
calls_log.append(
{
"data": data,
"field_name": kwargs.get("field_name"),
"object_type": kwargs.get("object_type"),
"custom_param": kwargs.get("custom_param"),
}
)
return f"[{kwargs.get('field_name', 'UNKNOWN')}:{data}]"
anonymizer = ParameterTrackingAnonymizer()
complex_data = {
"messages": [
{"role": "user", "content": "Hello, my email is john@example.com"},
{
"role": "assistant",
"content": "I can help you with that",
"attachments": ["file1.txt", "file2.pdf"],
},
],
"metadata": {
"session_id": "sess_12345",
"user_data": {
"preferences": ["pref1", "pref2"],
"settings": {"theme": "dark", "language": "en"},
},
},
}
result: Dict[str, Any] = anonymizer.anonymize(
complex_data,
field_name="output",
object_type="TraceMessage",
custom_param="test_value",
)
# Verify all expected calls were made with correct parameters
expected_field_paths = [
"output.messages.0.role",
"output.messages.0.content",
"output.messages.1.role",
"output.messages.1.content",
"output.messages.1.attachments.0",
"output.messages.1.attachments.1",
"output.metadata.session_id",
"output.metadata.user_data.preferences.0",
"output.metadata.user_data.preferences.1",
"output.metadata.user_data.settings.theme",
"output.metadata.user_data.settings.language",
]
assert len(calls_log) == len(expected_field_paths)
# Verify each call has the correct parameters
for call in calls_log:
assert call["object_type"] == "TraceMessage"
assert call["custom_param"] == "test_value"
assert call["field_name"] in expected_field_paths
# Verify specific anonymization results
assert (
result["messages"][0]["content"]
== "[output.messages.0.content:Hello, my email is john@example.com]"
)
assert (
result["metadata"]["session_id"]
== "[output.metadata.session_id:sess_12345]"
)
assert (
result["metadata"]["user_data"]["settings"]["theme"]
== "[output.metadata.user_data.settings.theme:dark]"
)
def test_recursive_anonymizer__max_depth_limiting__stops_recursion_at_limit(self):
"""Test that max_depth parameter properly limits recursion depth."""
calls_log = []
class ParameterTrackingAnonymizer(RecursiveAnonymizer):
def __init__(self, max_depth: int = 2):
super().__init__(max_depth=max_depth)
def anonymize_text(self, data: str, **kwargs: Any) -> str:
calls_log.append(
{
"data": data,
"field_name": kwargs.get("field_name"),
}
)
return f"[ANON:{data}]"
anonymizer = ParameterTrackingAnonymizer(max_depth=2)
# Create a structure where strings at different depths can be tested
deeply_nested = {
"level1_text": "depth 1 - should be processed",
"level1": {
"level2_text": "depth 2 - should be processed",
"level2": {"level3_text": "depth 3 - should NOT be processed"},
},
}
result: Dict[str, Any] = anonymizer.anonymize(deeply_nested, field_name="root")
field_names = [call["field_name"] for call in calls_log]
# The recursion depth starts at 0 for the initial call, so with max_depth=2:
# - root.level1_text is at depth 1 (should be processed)
# - root.level1.level2_text is at depth 2 (exceeds max_depth=2, should NOT be processed)
# - root.level1.level2.level3_text is at depth 3+ (exceeds max_depth=2, should NOT be processed)
assert "root.level1_text" in field_names
assert len([name for name in field_names if "level2_text" in name]) == 0
assert len([name for name in field_names if "level3_text" in name]) == 0
# Verify the results
assert result["level1_text"] == "[ANON:depth 1 - should be processed]"
assert (
result["level1"]["level2_text"] == "depth 2 - should be processed"
) # Unchanged due to depth limit
assert (
result["level1"]["level2"]["level3_text"]
== "depth 3 - should NOT be processed"
) # Unchanged
def test_recursive_anonymizer__non_string_types__preserves_unchanged(self):
"""Test that non-string types are preserved without calling anonymize_text."""
calls_log = []
class ParameterTrackingAnonymizer(RecursiveAnonymizer):
def anonymize_text(self, data: str, **kwargs: Any) -> str:
calls_log.append(data)
return f"[ANON:{data}]"
anonymizer = ParameterTrackingAnonymizer()
mixed_types_data = {
"string_field": "text to anonymize",
"int_field": 42,
"float_field": 3.14,
"bool_field": True,
"none_field": None,
"nested": {
"another_string": "another text",
"number": 100,
"list_with_mixed": ["string in list", 123, False],
},
}
result: Dict[str, Any] = anonymizer.anonymize(
mixed_types_data, field_name="data"
)
# Should only anonymize strings (there are 3: "text to anonymize", "another text", "string in list")
assert len(calls_log) == 3
assert "text to anonymize" in calls_log
assert "another text" in calls_log
assert "string in list" in calls_log
# Non-string types should be preserved
assert result["int_field"] == 42
assert result["float_field"] == 3.14
assert result["bool_field"] is True
assert result["none_field"] is None
assert result["nested"]["number"] == 100
assert result["nested"]["list_with_mixed"][1] == 123
assert result["nested"]["list_with_mixed"][2] is False
# Strings should be anonymized
assert result["string_field"] == "[ANON:text to anonymize]"
assert result["nested"]["another_string"] == "[ANON:another text]"
assert result["nested"]["list_with_mixed"][0] == "[ANON:string in list]"
def test_recursive_anonymizer__empty_structures__handles_gracefully(self):
"""Test that empty dictionaries and lists are handled gracefully."""
calls_log = []
class ParameterTrackingAnonymizer(RecursiveAnonymizer):
def anonymize_text(self, data: str, **kwargs: Any) -> str:
calls_log.append(data)
return f"[ANON:{data}]"
anonymizer = ParameterTrackingAnonymizer()
empty_structures = {
"empty_dict": {},
"empty_list": [],
"mixed": {
"nested_empty_dict": {},
"nested_empty_list": [],
"text": "some text",
},
}
result: Dict[str, Any] = anonymizer.anonymize(
empty_structures, field_name="test"
)
# Should only call anonymize_text for the one string
assert len(calls_log) == 1
assert "some text" in calls_log
# Empty structures should be preserved
assert result["empty_dict"] == {}
assert result["empty_list"] == []
assert result["mixed"]["nested_empty_dict"] == {}
assert result["mixed"]["nested_empty_list"] == []
assert result["mixed"]["text"] == "[ANON:some text]"
def test_recursive_anonymizer__field_specific_anonymization__uses_field_path_for_logic(
self,
):
"""Test that anonymizers can use field paths to implement field-specific logic."""
class FieldSpecificAnonymizer(RecursiveAnonymizer):
def anonymize_text(self, data: str, **kwargs: Any) -> str:
field_name = kwargs.get("field_name", "")
# Different anonymization based on a field path
if "email" in field_name:
return "[EMAIL_REDACTED]"
elif "phone" in field_name:
return "[PHONE_REDACTED]"
elif "api_key" in field_name:
return "[API_KEY_REDACTED]"
elif field_name.endswith(".name"):
return "[NAME_REDACTED]"
else:
return data # Leave unchanged for other fields
anonymizer = FieldSpecificAnonymizer()
user_data = {
"user": {
"email": "john.doe@example.com",
"name": "John Doe",
"phone": "555-1234",
"notes": "Regular user notes",
},
"config": {
"api_key": "secret123",
"description": "Configuration description",
},
"contacts": [
{
"name": "Contact One",
"email": "contact1@example.com",
"other_info": "Some other information",
}
],
}
result: Dict[str, Any] = anonymizer.anonymize(user_data, field_name="input")
# Verify field-specific anonymization
assert result["user"]["email"] == "[EMAIL_REDACTED]"
assert result["user"]["name"] == "[NAME_REDACTED]"
assert result["user"]["phone"] == "[PHONE_REDACTED]"
assert result["user"]["notes"] == "Regular user notes" # Unchanged
assert result["config"]["api_key"] == "[API_KEY_REDACTED]"
assert (
result["config"]["description"] == "Configuration description"
) # Unchanged
assert result["contacts"][0]["name"] == "[NAME_REDACTED]"
assert result["contacts"][0]["email"] == "[EMAIL_REDACTED]"
assert (
result["contacts"][0]["other_info"] == "Some other information"
) # Unchanged
def test_recursive_anonymizer__parameter_propagation__all_kwargs_preserved(self):
"""Test that all custom kwargs are properly propagated to anonymize_text."""
calls_log = []
class ParameterTrackingAnonymizer(RecursiveAnonymizer):
def anonymize_text(self, data: str, **kwargs: Any) -> str:
calls_log.append(kwargs.copy())
return data
anonymizer = ParameterTrackingAnonymizer()
test_data = {"nested": {"text": "sample text"}}
# Pass multiple custom parameters
anonymizer.anonymize(
test_data,
field_name="test_field",
object_type="TestMessage",
custom_param1="value1",
custom_param2=42,
custom_param3={"nested": "param"},
)
# Should have one call for the text
assert len(calls_log) == 1
kwargs = calls_log[0]
# Verify all parameters were preserved
assert kwargs["field_name"] == "test_field.nested.text"
assert kwargs["object_type"] == "TestMessage"
assert kwargs["custom_param1"] == "value1"
assert kwargs["custom_param2"] == 42
assert kwargs["custom_param3"] == {"nested": "param"}
def test_recursive_anonymizer__circular_reference_protection__respects_max_depth(
self,
):
"""Test that max_depth prevents infinite recursion even with circular references."""
calls_count = 0
class CountingAnonymizer(RecursiveAnonymizer):
def __init__(self):
super().__init__(max_depth=3)
def anonymize_text(self, data: str, **kwargs: Any) -> str:
nonlocal calls_count
calls_count += 1
return f"[CALL_{calls_count}:{data}]"
anonymizer = CountingAnonymizer()
# Create a structure that tests depth limiting with strings at different levels
deep_structure = {
"text_at_level1": "depth 1 - should be processed",
"level1": {
"text_at_level2": "depth 2 - should be processed",
"level2": {
"text_at_level3": "depth 3 - should be processed",
"level3": {"text_at_level4": "depth 4 - should NOT be processed"},
},
},
}
result: Dict[str, Any] = anonymizer.anonymize(deep_structure, field_name="root")
# With max_depth=3, strings at depth 1, 2, 3 should be processed, but depth 4+ should not
# Verify the structure - strings beyond max_depth should remain unchanged
assert (
"depth 4 - should NOT be processed"
in result["level1"]["level2"]["level3"]["text_at_level4"]
)
# The strings within max_depth should be processed
# With max_depth=3, only strings at depth 1 and 2 get processed
assert calls_count == 2 # Should process the first 2 strings within max_depth
def test_recursive_anonymizer__no_field_name_provided__uses_empty_string_as_base(
self,
):
"""Test behavior when no field_name is provided in initial kwargs."""
calls_log = []
class ParameterTrackingAnonymizer(RecursiveAnonymizer):
def anonymize_text(self, data: str, **kwargs: Any) -> str:
calls_log.append(kwargs.get("field_name"))
return data
anonymizer = ParameterTrackingAnonymizer()
test_data = {"key1": "value1", "nested": {"key2": "value2"}}
# Call without field_name
anonymizer.anonymize(test_data)
# Should use empty string as a base and build paths from there
expected_field_names = [".key1", ".nested.key2"]
assert len(calls_log) == 2
for field_name in calls_log:
assert field_name in expected_field_names
@@ -0,0 +1,318 @@
import pytest
from opik.anonymizer import rules
from opik.anonymizer import rules_anonymizer
class TestRulesAnonymizer:
"""Test suite for RulesAnonymizer functionality."""
def test_init(self):
"""Test RulesAnonymizer initialization."""
regex_rule = rules.RegexRule(r"\d+", "***")
rules_ = [regex_rule]
anonymizer = rules_anonymizer.RulesAnonymizer(rules_, max_depth=5)
assert anonymizer.rules == rules_
assert anonymizer.max_depth == 5
def test_anonymize_text__single_regex_rule(self):
"""Test anonymizing text with a single regex rule."""
regex_rule = rules.RegexRule(r"\d+", "***")
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule])
result = anonymizer.anonymize_text("Phone: 123-456-7890")
assert result == "Phone: ***-***-***"
def test_anonymize_text__multiple_regex_rules(self):
"""Test anonymizing text with multiple regex rules applied sequentially."""
email_rule = rules.RegexRule(
r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "[EMAIL]"
)
phone_rule = rules.RegexRule(r"\d{3}-\d{3}-\d{4}", "[PHONE]")
anonymizer = rules_anonymizer.RulesAnonymizer([email_rule, phone_rule])
result = anonymizer.anonymize_text("Contact: user@example.com or 123-456-7890")
assert result == "Contact: [EMAIL] or [PHONE]"
def test_anonymize_text__function_rule(self):
"""Test anonymizing text with a function rule."""
def uppercase_anonymizer(text: str) -> str:
return text.upper()
function_rule = rules.FunctionRule(uppercase_anonymizer)
anonymizer = rules_anonymizer.RulesAnonymizer([function_rule])
result = anonymizer.anonymize_text("hello world")
assert result == "HELLO WORLD"
def test_anonymize_text__mixed_rules(self):
"""Test anonymizing text with mixed rule types."""
regex_rule = rules.RegexRule(r"\d+", "XXX")
function_rule = rules.FunctionRule(lambda text: text.replace(" ", "_"))
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule, function_rule])
result = anonymizer.anonymize_text("Phone 123 456")
assert result == "Phone_XXX_XXX"
def test_anonymize_text__no_rules(self):
"""Test anonymizing text with no rules returns original text."""
anonymizer = rules_anonymizer.RulesAnonymizer([])
result = anonymizer.anonymize_text("sensitive data")
assert result == "sensitive data"
def test_anonymize__string_data(self):
"""Test anonymizing string data."""
regex_rule = rules.RegexRule(r"\d+", "***")
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule])
result = anonymizer.anonymize("Phone: 123-456-7890")
assert result == "Phone: ***-***-***"
def test_anonymize__dict_data(self):
"""Test anonymizing dictionary data."""
regex_rule = rules.RegexRule(r"\d+", "***")
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule])
data = {"name": "John Doe", "phone": "123-456-7890", "address": "123 Main St"}
result = anonymizer.anonymize(data)
expected = {
"name": "John Doe",
"phone": "***-***-***",
"address": "*** Main St",
}
assert result == expected
def test_anonymize__list_data(self):
"""Test anonymizing list data."""
regex_rule = rules.RegexRule(r"\d+", "***")
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule])
data = ["Contact: 123-456-7890", "Another: 987-654-3210", "No numbers here"]
result = anonymizer.anonymize(data)
expected = ["Contact: ***-***-***", "Another: ***-***-***", "No numbers here"]
assert result == expected
def test_anonymize__nested_dict_data(self):
"""Test anonymizing nested dictionary data."""
regex_rule = rules.RegexRule(r"\d+", "***")
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule])
data = {
"user": {
"name": "John Doe",
"contact": {"phone": "123-456-7890", "email": "john@example.com"},
},
"id": "12345",
}
result = anonymizer.anonymize(data)
expected = {
"user": {
"name": "John Doe",
"contact": {"phone": "***-***-***", "email": "john@example.com"},
},
"id": "***",
}
assert result == expected
def test_anonymize__nested_list_data(self):
"""Test anonymizing nested list data."""
regex_rule = rules.RegexRule(r"\d+", "***")
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule])
data = [
["Phone: 123", "Email: user@test.com"],
{"contact": "456-789-0123"},
"Simple string with 999",
]
result = anonymizer.anonymize(data)
expected = [
["Phone: ***", "Email: user@test.com"],
{"contact": "***-***-***"},
"Simple string with ***",
]
assert result == expected
def test_anonymize__mixed_nested_data(self):
"""Test anonymizing complex mixed nested data structures."""
email_rule = rules.RegexRule(
r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "[EMAIL]"
)
phone_rule = rules.RegexRule(r"\d{3}-\d{3}-\d{4}", "[PHONE]")
anonymizer = rules_anonymizer.RulesAnonymizer([email_rule, phone_rule])
data = {
"users": [
{"name": "John Doe", "contacts": ["john@example.com", "123-456-7890"]},
{"name": "Jane Smith", "contacts": ["jane@test.org", "987-654-3210"]},
],
"admin_contact": "admin@company.com",
}
result = anonymizer.anonymize(data)
expected = {
"users": [
{"name": "John Doe", "contacts": ["[EMAIL]", "[PHONE]"]},
{"name": "Jane Smith", "contacts": ["[EMAIL]", "[PHONE]"]},
],
"admin_contact": "[EMAIL]",
}
assert result == expected
def test_anonymize__non_string_values_unchanged(self):
"""Test that non-string values are not processed."""
regex_rule = rules.RegexRule(r"\d+", "***")
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule])
data = {
"number": 12345,
"float_val": 123.45,
"bool_val": True,
"none_val": None,
"string_val": "123",
}
result = anonymizer.anonymize(data)
expected = {
"number": 12345,
"float_val": 123.45,
"bool_val": True,
"none_val": None,
"string_val": "***",
}
assert result == expected
def test_anonymize__max_depth_limiting(self):
"""Test that max_depth limits recursion depth."""
regex_rule = rules.RegexRule(r"\d+", "***")
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule], max_depth=2)
# Create deeply nested data that exceeds max_depth
data = {"level1": {"level2": {"level3": {"phone": "123-456-7890"}}}}
result = anonymizer.anonymize(data)
# At max_depth=2, level3 should not be processed
expected = {
"level1": {
"level2": {
"level3": {
"phone": "123-456-7890" # Should remain unchanged
}
}
}
}
assert result == expected
def test_anonymize__max_depth_exact_limit(self):
"""Test that max_depth allows processing exactly at the limit."""
regex_rule = rules.RegexRule(r"\d+", "***")
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule], max_depth=4)
# Create data where string is processed at depth=3 (within limit)
data = {"level1": {"level2": {"phone": "123-456-7890"}}}
result = anonymizer.anonymize(data)
# At max_depth=4, the string at depth 3 should be processed
expected = {"level1": {"level2": {"phone": "***-***-***"}}}
assert result == expected
def test_anonymize__circular_reference_protection(self):
"""Test protection against circular references through depth limiting."""
regex_rule = rules.RegexRule(r"\d+", "***")
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule], max_depth=3)
# Create circular reference
data = {"phone": "123"}
data["self"] = data
# Should not cause infinite recursion due to max_depth
result = anonymizer.anonymize(data)
# The result should have processed the string but stopped at max depth
assert result["phone"] == "***"
assert "self" in result
@pytest.mark.parametrize("rule_count", [1, 5, 10])
def test_anonymize_text__performance_with_multiple_rules(self, rule_count):
"""Test performance with varying numbers of rules."""
rules_ = []
for i in range(rule_count):
rules_.append(rules.RegexRule(f"pattern{i}", f"replacement{i}"))
anonymizer = rules_anonymizer.RulesAnonymizer(rules_)
# Test with a simple string
result = anonymizer.anonymize_text("test pattern0 and pattern5")
# Should apply all applicable rules
assert "replacement0" in result or "pattern0" in result
def test_anonymize_text__empty_string_handling(self):
"""Test handling of empty strings."""
regex_rule = rules.RegexRule(r"\d+", "***")
anonymizer = rules_anonymizer.RulesAnonymizer([regex_rule])
result = anonymizer.anonymize_text("")
assert result == ""
def test_anonymize_text__complex_regex_patterns(self):
"""Test complex regex patterns for real-world scenarios."""
# Email pattern
email_rule = rules.RegexRule(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL_REDACTED]"
)
# Credit card pattern (simplified)
cc_rule = rules.RegexRule(
r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b", "[CARD_REDACTED]"
)
# SSN pattern
ssn_rule = rules.RegexRule(r"\b\d{3}-\d{2}-\d{4}\b", "[SSN_REDACTED]")
anonymizer = rules_anonymizer.RulesAnonymizer([email_rule, cc_rule, ssn_rule])
text = """
Contact info:
Email: john.doe@example.com
Credit Card: 1234 5678 9012 3456
SSN: 123-45-6789
"""
result = anonymizer.anonymize_text(text)
assert result != text
assert (
result
== """
Contact info:
Email: [EMAIL_REDACTED]
Credit Card: [CARD_REDACTED]
SSN: [SSN_REDACTED]
"""
)
@@ -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):
comet_api_key = parse_api_key(raw_key)
assert comet_api_key is None
def test_parse_api_key__one_part():
raw_key = "some API key"
comet_api_key = parse_api_key(raw_key)
assert comet_api_key is not None
assert comet_api_key.api_key == raw_key
assert comet_api_key.short_api_key == raw_key
def test_parse_api_key__no_expected_attributes(capture_log):
raw_key = "some API key"
comet_api_key = parse_api_key(raw_key + DELIMITER_CHAR)
assert comet_api_key is not None
assert comet_api_key.api_key == raw_key
assert comet_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"
comet_api_key = parse_api_key(raw_key)
assert comet_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="
)
comet_api_key = parse_api_key(raw_key)
assert comet_api_key is not None
assert comet_api_key.api_key == raw_key
assert comet_api_key.short_api_key == "Et1RBc4nd1ef3LfyJvhyB34Po"
assert comet_api_key.base_url == "https://www.comet.com"
assert comet_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"
)
comet_api_key = parse_api_key(raw_key)
assert comet_api_key is not None
assert comet_api_key.api_key == raw_key
assert comet_api_key.short_api_key == "Et1RBc4nd1ef3LfyJvhyB34Po"
assert comet_api_key.base_url == "https://www.comet.com"
assert comet_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"}
comet_api_key = parse_api_key(raw_key)
assert comet_api_key is not None
assert comet_api_key.api_key == raw_key
assert comet_api_key.short_api_key == "Et1RBc4nd1ef3LfyJvhyB34Po"
assert comet_api_key.base_url == "https://www.comet.com"
assert comet_api_key["baseUrl"] == "https://www.comet.com"
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Unit tests for CLI functionality."""
@@ -0,0 +1,196 @@
"""Shared mock helpers for ``opik migrate`` test modules.
Used by both ``test_migrate_dataset_exclude_versions.py`` (Slice 1 paths) and
``test_migrate_dataset_version_replay.py`` (Slice 2 paths). Lives as a plain
module (not conftest.py) because these are helper classes, not pytest
fixtures.
"""
from __future__ import annotations
from typing import Dict, List, Optional
from unittest.mock import MagicMock
class _DatasetRow:
def __init__(
self,
id: str,
name: str,
description: Optional[str] = None,
items: int = 0,
type: Optional[str] = "dataset",
visibility: Optional[str] = "private",
tags: Optional[List[str]] = None,
# ``project_id=None`` represents a workspace-scoped dataset (V1
# entity, or anything left at workspace scope after auto-migration).
# Tests that want a project-scoped source pass an explicit id.
project_id: Optional[str] = None,
) -> None:
self.id = id
self.name = name
self.description = description
self.dataset_items_count = items
self.type = type
self.visibility = visibility
self.tags = tags
self.project_id = project_id
class _Page:
def __init__(self, content: List[_DatasetRow]) -> None:
self.content = content
def _named(name: str) -> MagicMock:
obj = MagicMock()
obj.name = name
return obj
def _planner_rest_client(
find_side_effects: List[_Page],
*,
target_project_exists: bool = True,
workspace_project_names: Optional[List[str]] = None,
) -> MagicMock:
"""Build a rest_client mock for direct planner unit tests."""
rest_client = MagicMock()
if target_project_exists:
target_project = MagicMock()
target_project.id = "target-project-id"
rest_client.projects.retrieve_project.return_value = target_project
else:
from opik.rest_api.core.api_error import ApiError
rest_client.projects.retrieve_project.side_effect = ApiError(
status_code=404, body={}
)
# Suggestion lookup queries find_projects on the 404 path.
candidates = [_named(name) for name in (workspace_project_names or [])]
rest_client.projects.find_projects.return_value = _Page(candidates)
rest_client.datasets.find_datasets.side_effect = find_side_effects
return rest_client
def _planner_client(rest_client: MagicMock) -> MagicMock:
"""Wrap a planner rest_client mock as an ``opik.Opik``-shaped client.
The planner now takes the high-level client (so the resolver can route
``get_project_by_id`` through ``client.get_project`` instead of the
Fern surface). Tests still build the low-level rest_client mock and
drive its side_effects, then wrap with this helper to satisfy the new
planner signature.
``client.get_project(id=...)`` delegates to
``rest_client.projects.get_project_by_id(id=...)`` so any per-test
stub on the rest_client side still flows through unchanged.
"""
client = MagicMock()
client.rest_client = rest_client
client.get_project = MagicMock(
side_effect=lambda id: rest_client.projects.get_project_by_id(id=id)
)
return client
def _build_fake_client(
*,
source_rows: List[_DatasetRow],
destination_rows: List[_DatasetRow],
items: List[Dict[str, object]],
target_project_exists: bool = True,
stale_temp_rows: Optional[List[_DatasetRow]] = None,
) -> MagicMock:
"""Construct an opik.Opik mock matching the executor's call surface.
The planner makes three workspace ``find_datasets`` lookups in a fixed
order: (1) source resolution, (2) the ``<name>_v1`` rename-target
collision pre-flight, and (3) the ``<name>__migrating`` stale-temp
lookup (OPIK-7162). A side-effect list drives all three deterministically.
``destination_rows`` feeds the ``_v1`` check; the stale-temp lookup
returns empty by default (no leftover temp) unless ``stale_temp_rows``
is supplied.
``items`` is a list of dicts; we materialize them as DatasetItem
dataclasses for the streaming mock (matches the real `__internal_api__
stream_items_as_dataclasses__` shape) so per-item fidelity assertions
have somewhere to land.
"""
from opik.api_objects.dataset import dataset_item
rest_client = MagicMock()
if target_project_exists:
target_project = MagicMock()
target_project.id = "target-project-id"
rest_client.projects.retrieve_project.return_value = target_project
else:
from opik.rest_api.core.api_error import ApiError
rest_client.projects.retrieve_project.side_effect = ApiError(
status_code=404, body={}
)
rest_client.datasets.find_datasets.side_effect = [
_Page(source_rows),
_Page(destination_rows),
_Page(stale_temp_rows or []),
]
rest_client.datasets.update_dataset = MagicMock()
rest_client.datasets.create_dataset = MagicMock()
rest_client.datasets.delete_dataset = MagicMock()
client = MagicMock()
client.rest_client = rest_client
client._workspace = "default"
# Build DatasetItem dataclasses from the provided dicts so the executor's
# dataclass-form stream returns a realistic shape. Top-level fields like
# `description` / `source` / `trace_id` / `span_id` can be passed via the
# dict (other keys get stuffed into `data`/extra).
top_level = {
"id",
"trace_id",
"span_id",
"source",
"description",
"evaluators",
"execution_policy",
}
source_items: List[dataset_item.DatasetItem] = []
for raw in items:
kwargs = {k: v for k, v in raw.items() if k in top_level and k != "id"}
data = {k: v for k, v in raw.items() if k not in top_level}
ds_item = dataset_item.DatasetItem(**kwargs, **data)
if "id" in raw:
ds_item.id = raw["id"] # type: ignore[assignment]
source_items.append(ds_item)
# MagicMock treats dunder-prefixed names as magic and blocks them by
# default; pre-attach plain MagicMocks so attribute access works.
source_dataset = MagicMock()
stream_mock = MagicMock(return_value=iter(source_items))
source_dataset.__internal_api__stream_items_as_dataclasses__ = stream_mock
dest_dataset = MagicMock()
insert_mock = MagicMock()
dest_dataset.__internal_api__insert_items_as_dataclasses__ = insert_mock
# Under the OPIK-7162 ordering the destination is written under the temp
# name ``<orig>__migrating`` for the whole copy, then promoted to the
# original name at the end. The executor resolves the destination by name
# in ``_replay_versions`` (temp name) and again in ``PromoteDestination``
# (temp name -> id). The source is never resolved via ``get_dataset`` any
# more (item reads stream by name on the rest_client), so route the temp
# name (and anything else) to the destination dataset.
source_orig_name = source_rows[0].name if source_rows else ""
temp_name = f"{source_orig_name}__migrating"
def _get_dataset(name: str, project_name: Optional[str] = None) -> MagicMock:
if name == temp_name:
return dest_dataset
return source_dataset
client.get_dataset.side_effect = _get_dataset
client.create_dataset = MagicMock()
client.delete_dataset = MagicMock()
return client, source_dataset, dest_dataset
@@ -0,0 +1,125 @@
"""Shared mock helpers for ``opik migrate prompt`` test modules.
Used by ``test_migrate_prompt_planner.py``,
``test_migrate_prompt_executor.py``, and
``test_migrate_prompt_version_replay.py``. Lives as a plain module (not
a conftest.py) because these are helper classes, not pytest fixtures.
"""
from __future__ import annotations
from typing import Any, List, Optional
from unittest.mock import MagicMock
class _PromptRow:
"""Minimal stand-in for ``PromptPublic`` rows returned by ``get_prompts``."""
def __init__(
self,
id: str,
name: str,
*,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
# None = workspace-scoped (legacy v1 prompts that never had a
# project). Tests that want a project-scoped source pass an id.
project_id: Optional[str] = None,
template_structure: Optional[str] = "text",
) -> None:
self.id = id
self.name = name
self.description = description
self.tags = tags
self.project_id = project_id
self.template_structure = template_structure
class _PromptVersionRow:
"""Stand-in for ``PromptVersionPublic`` rows from ``get_prompt_versions``.
Field set mirrors the Fern model: ``id``, ``prompt_id``, ``commit``,
``template`` (required), plus optional metadata / type / change_description
/ tags / environments / template_structure.
"""
def __init__(
self,
id: str,
prompt_id: str,
commit: str,
template: str,
*,
metadata: Optional[Any] = None,
type: Optional[str] = None,
change_description: Optional[str] = None,
tags: Optional[List[str]] = None,
environments: Optional[List[str]] = None,
template_structure: Optional[str] = "text",
) -> None:
self.id = id
self.prompt_id = prompt_id
self.commit = commit
self.template = template
self.metadata = metadata
self.type = type
self.change_description = change_description
self.tags = tags
self.environments = environments
self.template_structure = template_structure
class _Page:
def __init__(self, content: List[Any]) -> None:
self.content = content
def _named(name: str) -> MagicMock:
obj = MagicMock()
obj.name = name
return obj
def _planner_rest_client(
find_prompts_side_effects: List[_Page],
*,
target_project_exists: bool = True,
workspace_project_names: Optional[List[str]] = None,
) -> MagicMock:
"""Build a rest_client mock for direct prompt-planner unit tests.
``find_prompts_side_effects`` is the side-effect list for
``rest_client.prompts.get_prompts``; the planner calls it twice (once
for source resolution, once for the rename-collision preflight) so
callers pass two pages.
"""
rest_client = MagicMock()
if target_project_exists:
target_project = MagicMock()
target_project.id = "target-project-id"
rest_client.projects.retrieve_project.return_value = target_project
else:
from opik.rest_api.core.api_error import ApiError
rest_client.projects.retrieve_project.side_effect = ApiError(
status_code=404, body={}
)
candidates = [_named(n) for n in (workspace_project_names or [])]
rest_client.projects.find_projects.return_value = _Page(candidates)
rest_client.prompts.get_prompts.side_effect = find_prompts_side_effects
return rest_client
def _planner_client(rest_client: MagicMock) -> MagicMock:
"""Wrap a rest_client mock as an ``opik.Opik``-shaped client.
The resolver routes project-id lookups through ``client.get_project``
(high-level surface) so the project-name resolution still benefits
from any test-side stubs on ``rest_client.projects.get_project_by_id``.
"""
client = MagicMock()
client.rest_client = rest_client
client.get_project = MagicMock(
side_effect=lambda id: rest_client.projects.get_project_by_id(id=id)
)
return client
@@ -0,0 +1,313 @@
"""Tests for opik.cli.local_runner.stop and the `opik <type> stop` CLI surface."""
import json
import os
import signal
from pathlib import Path
from typing import Optional
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from opik.cli.local_runner import stop as stop_module
from opik.cli.local_runner.pairing import RunnerType
from opik.cli.local_runner.stop import do_stop
from opik.cli.main import cli
from opik.runner import pid_file
from opik.runner.pid_file import RunnerInfo
@pytest.fixture
def tmp_runners_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
runners = tmp_path / "runners"
monkeypatch.setattr(pid_file, "_RUNNERS_DIR", runners)
return runners
def _write_runner_file(
dir: Path,
runner_id: str,
runner_type: str,
project_name: str,
pid: int,
workspace: Optional[str] = None,
) -> Path:
dir.mkdir(parents=True, exist_ok=True)
path = dir / f"{runner_type}-{runner_id}.json"
path.write_text(
json.dumps(
{
"pid": pid,
"runner_id": runner_id,
"runner_type": runner_type,
"project_name": project_name,
"workspace": workspace,
"started_at": 1.0,
}
)
)
return path
class TestDoStopFilters:
def test_do_stop__no_filter__usage_error(self, tmp_runners_dir: Path) -> None:
# No --project / --runner / --all → usage error before any signaling.
with pytest.raises(Exception) as exc_info:
do_stop(
runner_type=RunnerType.CONNECT,
project_name=None,
all_flag=False,
runner_id_filter=None,
)
# click.UsageError prints "Error: ..." and is a Click exception.
assert "specify" in str(exc_info.value).lower()
def test_do_stop__no_match__prints_no_match_message(
self, tmp_runners_dir: Path, capsys: pytest.CaptureFixture
) -> None:
do_stop(
runner_type=RunnerType.CONNECT,
project_name="missing",
all_flag=False,
runner_id_filter=None,
)
out = capsys.readouterr().out
assert "No local 'connect' runners found" in out
def test_do_stop__project_filter__signals_matching_only(
self, tmp_runners_dir: Path
) -> None:
_write_runner_file(tmp_runners_dir, "r-1", "connect", "alpha", os.getpid())
_write_runner_file(tmp_runners_dir, "r-2", "connect", "beta", os.getpid())
seen = []
def fake_signal(info: RunnerInfo):
seen.append(info.runner_id)
return True, ""
with patch.object(stop_module, "_signal_until_gone", side_effect=fake_signal):
do_stop(
runner_type=RunnerType.CONNECT,
project_name="alpha",
all_flag=False,
runner_id_filter=None,
)
assert seen == ["r-1"]
# The matched runner's pid file is cleaned up.
assert not (tmp_runners_dir / "connect-r-1.json").exists()
# Non-matched is left alone.
assert (tmp_runners_dir / "connect-r-2.json").exists()
def test_do_stop__all_flag__signals_only_target_runner_type(
self, tmp_runners_dir: Path
) -> None:
_write_runner_file(tmp_runners_dir, "r-1", "connect", "alpha", os.getpid())
_write_runner_file(tmp_runners_dir, "r-2", "connect", "beta", os.getpid())
# An endpoint runner must NOT be touched by `connect stop --all`.
_write_runner_file(tmp_runners_dir, "r-3", "endpoint", "alpha", os.getpid())
seen = []
with patch.object(
stop_module,
"_signal_until_gone",
lambda info: (seen.append(info.runner_id) or (True, "")),
):
do_stop(
runner_type=RunnerType.CONNECT,
project_name=None,
all_flag=True,
runner_id_filter=None,
)
assert sorted(seen) == ["r-1", "r-2"]
assert (tmp_runners_dir / "endpoint-r-3.json").exists()
def test_do_stop__runner_id_filter__signals_matching_only(
self, tmp_runners_dir: Path
) -> None:
_write_runner_file(tmp_runners_dir, "r-1", "connect", "alpha", os.getpid())
_write_runner_file(tmp_runners_dir, "r-2", "connect", "alpha", os.getpid())
seen = []
with patch.object(
stop_module,
"_signal_until_gone",
lambda info: (seen.append(info.runner_id) or (True, "")),
):
do_stop(
runner_type=RunnerType.CONNECT,
project_name=None,
all_flag=False,
runner_id_filter="r-2",
)
assert seen == ["r-2"]
class TestSignalUntilGone:
"""Logic tests for the SIGTERM → wait → SIGKILL flow.
Real-subprocess kill is exercised by `tests/unit/runner/test_pid_file.py::TestSignalRoundtrip`;
those cases call `proc.wait()` to reap zombies, which pytest's parent-child
relationship requires. Here we mock the alive-check + signal so we can assert
the escalation order without fighting zombie state.
"""
def _make_info(self, tmp_runners_dir: Path, pid: int = 12345) -> RunnerInfo:
return RunnerInfo(
pid=pid,
runner_id="r-1",
runner_type="connect",
project_name="p",
workspace=None,
started_at=0.0,
path=tmp_runners_dir / "connect-r-1.json",
)
def test_signal_until_gone__target_exits_after_sigterm__returns_ok(
self, tmp_runners_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(stop_module, "_POLL_INTERVAL_SECONDS", 0.01)
sent_signals = []
monkeypatch.setattr(
stop_module.os, "kill", lambda pid, sig: sent_signals.append((pid, sig))
)
# First poll says alive, next says gone.
alive_seq = iter([True, False])
monkeypatch.setattr(
stop_module.pid_file, "is_pid_alive", lambda _pid: next(alive_seq)
)
ok, _ = stop_module._signal_until_gone(self._make_info(tmp_runners_dir))
assert ok
assert sent_signals == [(12345, signal.SIGTERM)]
def test_signal_until_gone__sigterm_ignored__escalates_to_sigkill(
self, tmp_runners_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(stop_module, "_SIGTERM_GRACE_SECONDS", 0.05)
monkeypatch.setattr(stop_module, "_POLL_INTERVAL_SECONDS", 0.01)
sent_signals = []
state = {"alive": True}
def fake_kill(pid: int, sig: int) -> None:
sent_signals.append((pid, sig))
if sig == signal.SIGKILL:
state["alive"] = False
monkeypatch.setattr(stop_module.os, "kill", fake_kill)
monkeypatch.setattr(
stop_module.pid_file, "is_pid_alive", lambda _pid: state["alive"]
)
ok, _ = stop_module._signal_until_gone(self._make_info(tmp_runners_dir))
assert ok
assert [sig for _, sig in sent_signals] == [signal.SIGTERM, signal.SIGKILL]
def test_signal_until_gone__already_dead_pid__returns_ok(
self, tmp_runners_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def fake_kill(_pid: int, _sig: int) -> None:
raise ProcessLookupError()
monkeypatch.setattr(stop_module.os, "kill", fake_kill)
ok, reason = stop_module._signal_until_gone(self._make_info(tmp_runners_dir))
assert ok
assert "exited" in reason
def test_signal_until_gone__permission_error__returns_failure(
self, tmp_runners_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def fake_kill(_pid: int, _sig: int) -> None:
raise PermissionError("not yours")
monkeypatch.setattr(stop_module.os, "kill", fake_kill)
ok, reason = stop_module._signal_until_gone(self._make_info(tmp_runners_dir))
assert not ok
assert "permission" in reason.lower()
class TestStopExitCode:
def test_do_stop__signal_failed__raises_systemexit_1(
self, tmp_runners_dir: Path
) -> None:
_write_runner_file(tmp_runners_dir, "r-1", "connect", "alpha", os.getpid())
with patch.object(
stop_module, "_signal_until_gone", return_value=(False, "denied")
):
with pytest.raises(SystemExit) as exc_info:
do_stop(
runner_type=RunnerType.CONNECT,
project_name="alpha",
all_flag=False,
runner_id_filter=None,
)
assert exc_info.value.code == 1
class TestConnectStopCli:
def test_cli_connect_stop__no_filter__exits_with_usage_error(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["connect", "stop"])
assert result.exit_code == 2
assert "Specify" in result.output or "specify" in result.output
def test_cli_connect_stop__with_project__routes_to_do_stop(
self, tmp_runners_dir: Path
) -> None:
runner = CliRunner()
with patch("opik.cli.local_runner.stop.do_stop") as mock_stop:
result = runner.invoke(cli, ["connect", "stop", "--project", "p"])
assert result.exit_code == 0, result.output
mock_stop.assert_called_once()
assert mock_stop.call_args.kwargs == dict(
runner_type=RunnerType.CONNECT,
project_name="p",
all_flag=False,
runner_id_filter=None,
)
def test_cli_connect_stop__all_flag__sets_all_flag_kwarg(
self, tmp_runners_dir: Path
) -> None:
runner = CliRunner()
with patch("opik.cli.local_runner.stop.do_stop") as mock_stop:
result = runner.invoke(cli, ["connect", "stop", "--all"])
assert result.exit_code == 0, result.output
assert mock_stop.call_args.kwargs["all_flag"] is True
def test_cli_connect_stop__runner_flag__sets_runner_id_filter(
self, tmp_runners_dir: Path
) -> None:
runner = CliRunner()
with patch("opik.cli.local_runner.stop.do_stop") as mock_stop:
result = runner.invoke(cli, ["connect", "stop", "--runner", "r-1"])
assert result.exit_code == 0, result.output
assert mock_stop.call_args.kwargs["runner_id_filter"] == "r-1"
class TestEndpointStopCli:
def test_cli_endpoint_stop__with_project__routes_to_do_stop(
self, tmp_runners_dir: Path
) -> None:
runner = CliRunner()
with patch("opik.cli.local_runner.stop.do_stop") as mock_stop:
result = runner.invoke(cli, ["endpoint", "stop", "--project", "p"])
assert result.exit_code == 0, result.output
mock_stop.assert_called_once()
assert mock_stop.call_args.kwargs == dict(
runner_type=RunnerType.ENDPOINT,
project_name="p",
all_flag=False,
runner_id_filter=None,
)
def test_cli_endpoint_stop__all_flag__sets_all_flag_kwarg(
self, tmp_runners_dir: Path
) -> None:
runner = CliRunner()
with patch("opik.cli.local_runner.stop.do_stop") as mock_stop:
result = runner.invoke(cli, ["endpoint", "stop", "--all"])
assert result.exit_code == 0, result.output
assert mock_stop.call_args.kwargs["all_flag"] is True
File diff suppressed because it is too large Load Diff
+445
View File
@@ -0,0 +1,445 @@
"""Tests for CLI commands."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from contextlib import contextmanager
from click.testing import CliRunner
from opik.cli import cli
class TestDownloadCommand:
"""Test the download CLI command."""
def test_export_group_help(self):
"""Test that the export group shows help."""
runner = CliRunner()
result = runner.invoke(cli, ["export", "--help"])
assert result.exit_code == 0
assert "Export data from an Opik project" in result.output
assert "dataset" in result.output
assert "traces" in result.output
assert "experiment" in result.output
def test_export_dataset_help(self):
"""Test that the export dataset command shows help."""
runner = CliRunner()
result = runner.invoke(cli, ["export", "default", "proj", "dataset", "--help"])
assert result.exit_code == 0
assert "Export a dataset by exact name" in result.output
assert "--force" in result.output
def test_export_traces_help(self):
"""Test that the export traces command shows help."""
runner = CliRunner()
result = runner.invoke(cli, ["export", "default", "proj", "traces", "--help"])
assert result.exit_code == 0
assert "Export the project's traces" in result.output
def test_export_experiment_help(self):
"""Test that the export experiment command shows help."""
runner = CliRunner()
result = runner.invoke(
cli, ["export", "default", "proj", "experiment", "--help"]
)
assert result.exit_code == 0
assert "Export an experiment by exact name" in result.output
class TestUploadCommand:
"""Test the upload CLI command."""
def test_import_group_help(self):
"""Test that the import group shows help."""
runner = CliRunner()
result = runner.invoke(cli, ["import", "--help"])
assert result.exit_code == 0
assert "Import data into an Opik project" in result.output
assert "dataset" in result.output
assert "traces" in result.output
assert "experiment" in result.output
def test_import_dataset_help(self):
"""Test that the import dataset command shows help."""
runner = CliRunner()
result = runner.invoke(cli, ["import", "default", "proj", "dataset", "--help"])
assert result.exit_code == 0
assert "Import datasets from" in result.output
assert "--dry-run" in result.output
def test_import_traces_help(self):
"""Test that the import traces command shows help."""
runner = CliRunner()
result = runner.invoke(cli, ["import", "default", "proj", "traces", "--help"])
assert result.exit_code == 0
assert "Import the project's traces" in result.output
assert "--dry-run" in result.output
def test_import_experiment_help(self):
"""Test that the import experiment command shows help."""
runner = CliRunner()
result = runner.invoke(
cli, ["import", "default", "proj", "experiment", "--help"]
)
assert result.exit_code == 0
assert "Import experiments from" in result.output
assert "--dry-run" in result.output
class TestSmokeTestCommand:
"""Test the smoke-test functionality via healthcheck command."""
def test_smoke_test_help(self):
"""Test that the healthcheck --smoke-test command shows help."""
runner = CliRunner()
result = runner.invoke(cli, ["healthcheck", "--help"])
assert result.exit_code == 0
assert "--smoke-test" in result.output
assert "--project-name" in result.output
assert "Project name for the smoke test" in result.output
assert "WORKSPACE" in result.output
assert "Run a smoke test to verify Opik integration" in result.output
def test_smoke_test_minimal_args_parsing(self):
"""Test that healthcheck --smoke-test command requires workspace value."""
runner = CliRunner()
# Test that help shows workspace is required
result = runner.invoke(cli, ["healthcheck", "--help"])
assert result.exit_code == 0
assert "WORKSPACE" in result.output
# Test that missing workspace value causes error
result = runner.invoke(cli, ["healthcheck", "--smoke-test"])
assert result.exit_code != 0
assert "Error" in result.output or "Missing" in result.output
@patch("opik.cli.healthcheck.cli.standard_check.run")
@patch("opik.cli.healthcheck.smoke_test.opik.Opik")
@patch("opik.cli.healthcheck.smoke_test.opik.start_as_current_trace")
@patch("opik.cli.healthcheck.smoke_test.opik_context.update_current_trace")
@patch("opik.cli.healthcheck.smoke_test.opik_context.update_current_span")
@patch("opik.cli.healthcheck.smoke_test.create_opik_logo_image")
@patch("opik.cli.healthcheck.smoke_test.track")
def test_smoke_test_with_workspace_and_project_name(
self,
mock_track,
mock_create_logo,
mock_update_span,
mock_update_trace,
mock_start_trace,
mock_opik_class,
mock_healthcheck_run,
):
"""Test healthcheck --smoke-test command with workspace and --project-name arguments."""
# Setup mocks
mock_client = MagicMock()
# Mock search_traces to raise an exception immediately to skip verification polling
# This prevents the verification from running and triggering real client creation
mock_client.search_traces = MagicMock(
side_effect=AttributeError("Mock client - search_traces not available")
)
mock_opik_class.return_value = mock_client
# Mock the context manager for start_as_current_trace
@contextmanager
def mock_trace_context(*args, **kwargs):
yield MagicMock()
mock_start_trace.return_value = mock_trace_context()
# Mock create_opik_logo_image to return a fake path
mock_logo_path = Path("/tmp/fake_logo.png")
mock_create_logo.return_value = mock_logo_path
# Mock the track decorator to return the function unchanged
def track_decorator(*args, **kwargs):
def decorator(func):
return func
return decorator
mock_track.side_effect = track_decorator
# Run the command
runner = CliRunner()
result = runner.invoke(
cli,
[
"healthcheck",
"--smoke-test",
"test-workspace",
"--project-name",
"test-project",
],
catch_exceptions=False,
)
# Assertions
assert result.exit_code == 0
# Verify client was created with correct arguments
mock_opik_class.assert_called_once()
call_kwargs = mock_opik_class.call_args[1]
assert call_kwargs["workspace"] == "test-workspace"
assert call_kwargs["project_name"] == "test-project"
# Verify trace was started
mock_start_trace.assert_called_once()
# Verify explicit client was flushed and ended
mock_client.flush.assert_called_once()
mock_client.end.assert_called_once()
@patch("opik.cli.healthcheck.cli.standard_check.run")
@patch("opik.cli.healthcheck.smoke_test.opik.Opik")
@patch("opik.cli.healthcheck.smoke_test.opik.start_as_current_trace")
@patch("opik.cli.healthcheck.smoke_test.opik_context.update_current_trace")
@patch("opik.cli.healthcheck.smoke_test.opik_context.update_current_span")
@patch("opik.cli.healthcheck.smoke_test.create_opik_logo_image")
@patch("opik.cli.healthcheck.smoke_test.track")
def test_smoke_test_with_default_project_name(
self,
mock_track,
mock_create_logo,
mock_update_span,
mock_update_trace,
mock_start_trace,
mock_opik_class,
mock_healthcheck_run,
):
"""Test healthcheck --smoke-test command with workspace only (uses default project name)."""
# Setup mocks
mock_client = MagicMock()
# Mock search_traces to raise an exception immediately to skip verification polling
# This prevents the verification from running and triggering real client creation
mock_client.search_traces = MagicMock(
side_effect=AttributeError("Mock client - search_traces not available")
)
mock_opik_class.return_value = mock_client
# Mock the context manager for start_as_current_trace
@contextmanager
def mock_trace_context(*args, **kwargs):
yield MagicMock()
mock_start_trace.return_value = mock_trace_context()
# Mock create_opik_logo_image to return a fake path
mock_logo_path = Path("/tmp/fake_logo.png")
mock_create_logo.return_value = mock_logo_path
# Mock the track decorator to return the function unchanged
def track_decorator(*args, **kwargs):
def decorator(func):
return func
return decorator
mock_track.side_effect = track_decorator
# Run the command without --project-name (should use default)
runner = CliRunner()
result = runner.invoke(
cli,
["healthcheck", "--smoke-test", "test-workspace"],
catch_exceptions=False,
)
# Assertions
assert result.exit_code == 0
# Verify client was created with default project name
mock_opik_class.assert_called_once()
call_kwargs = mock_opik_class.call_args[1]
assert call_kwargs["workspace"] == "test-workspace"
assert call_kwargs["project_name"] == "smoke-test-project" # Default value
# Verify trace was started with default project name
mock_start_trace.assert_called_once()
trace_call_kwargs = mock_start_trace.call_args[1]
assert trace_call_kwargs["project_name"] == "smoke-test-project"
class TestCheckPermissionsCommand:
"""Test the --check-permissions functionality via healthcheck command."""
def test_check_permissions_option__in_healthcheck_help__shown(self):
"""Test that --check-permissions option is shown in healthcheck help."""
runner = CliRunner()
result = runner.invoke(cli, ["healthcheck", "--help"])
assert result.exit_code == 0
assert "--check-permissions" in result.output
assert "WORKSPACE" in result.output
def test_check_permissions__missing_workspace__raises_error(self):
"""Test that --check-permissions without a workspace value raises an error."""
runner = CliRunner()
result = runner.invoke(cli, ["healthcheck", "--check-permissions"])
assert result.exit_code != 0
assert "Error" in result.output or "Missing" in result.output
@patch("opik.cli.healthcheck.cli.standard_check.run")
@patch("opik.cli.healthcheck.cli.check_user_permissions.run")
def test_check_permissions__workspace_provided__calls_run_with_workspace(
self,
mock_permissions_run,
mock_standard_run,
):
"""Test that --check-permissions pass the workspace to check_user_permissions.run."""
runner = CliRunner()
result = runner.invoke(
cli,
["healthcheck", "--check-permissions", "my-workspace"],
catch_exceptions=False,
)
assert result.exit_code == 0
mock_permissions_run.assert_called_once_with(
api_key=None, workspace="my-workspace"
)
@patch("opik.cli.healthcheck.cli.standard_check.run")
@patch("opik.cli.healthcheck.cli.check_user_permissions.run")
def test_check_permissions__api_key_in_cli_context__passes_api_key_to_run(
self,
mock_permissions_run,
mock_standard_run,
):
"""Test that --check-permissions forward the api_key from CLI context."""
from opik.cli import cli as opik_cli
runner = CliRunner()
result = runner.invoke(
opik_cli,
[
"--api-key",
"test-api-key",
"healthcheck",
"--check-permissions",
"my-workspace",
],
catch_exceptions=False,
)
assert result.exit_code == 0
mock_permissions_run.assert_called_once_with(
api_key="test-api-key", workspace="my-workspace"
)
@patch("opik.cli.healthcheck.cli.standard_check.run")
@patch("opik.cli.healthcheck.cli.check_user_permissions.run")
def test_check_permissions__empty_workspace__raises_bad_parameter(
self,
mock_permissions_run,
mock_standard_run,
):
"""Test that --check-permissions with an empty workspace string raises BadParameter."""
runner = CliRunner()
result = runner.invoke(
cli,
["healthcheck", "--check-permissions", ""],
)
assert result.exit_code != 0
assert (
"Error" in result.output
or "requires a non-empty workspace name" in result.output
)
mock_permissions_run.assert_not_called()
@patch("opik.cli.healthcheck.cli.standard_check.run")
@patch("opik.cli.healthcheck.cli.check_user_permissions.run")
def test_check_permissions__no_flag__not_called(
self,
mock_permissions_run,
mock_standard_run,
):
"""Test that check_user_permissions.run is not called when --check-permissions is absent."""
runner = CliRunner()
result = runner.invoke(cli, ["healthcheck"], catch_exceptions=False)
assert result.exit_code == 0
mock_permissions_run.assert_not_called()
@patch("opik.cli.healthcheck.cli.standard_check.run")
@patch("opik.cli.healthcheck.check_user_permissions.get_user_permissions")
@patch("opik.cli.healthcheck.check_user_permissions.config.OpikConfig")
def test_check_permissions__api_returns_user_and_workspace__displays_info(
self,
mock_opik_config,
mock_get_permissions,
mock_standard_run,
):
"""Test that user and workspace info from API response are printed."""
mock_config = MagicMock()
mock_config.api_key = "test-api-key"
mock_config.workspace = "my-workspace"
mock_config.url_override = "https://opik.example.com"
mock_opik_config.return_value = mock_config
mock_get_permissions.return_value = {
"user_name": "alice",
"workspace_name": "my-workspace",
"permissions": [
{"permission_name": "read", "permission_value": True},
{"permission_name": "write", "permission_value": False},
],
}
runner = CliRunner()
result = runner.invoke(
cli,
["healthcheck", "--check-permissions", "my-workspace"],
catch_exceptions=False,
)
assert result.exit_code == 0
assert "alice" in result.output
assert "my-workspace" in result.output
@patch("opik.cli.healthcheck.cli.standard_check.run")
@patch("opik.cli.healthcheck.check_user_permissions.get_user_permissions")
@patch("opik.cli.healthcheck.check_user_permissions.config.OpikConfig")
def test_check_permissions__connection_error__prints_error(
self,
mock_opik_config,
mock_get_permissions,
mock_standard_run,
):
"""Test that a ConnectionError from the API prints an error message."""
mock_config = MagicMock()
mock_config.api_key = "test-api-key"
mock_config.workspace = "my-workspace"
mock_config.url_override = "https://opik.example.com"
mock_opik_config.return_value = mock_config
mock_get_permissions.side_effect = ConnectionError("Network error: timeout")
runner = CliRunner()
result = runner.invoke(
cli,
["healthcheck", "--check-permissions", "my-workspace"],
catch_exceptions=False,
)
assert result.exit_code == 0
assert "Failed to fetch user permissions" in result.output
@patch("opik.cli.healthcheck.cli.standard_check.run")
@patch("opik.cli.healthcheck.check_user_permissions.config.OpikConfig")
def test_check_permissions__missing_api_key__raises_value_error(
self,
mock_opik_config,
mock_standard_run,
):
"""Test that missing api_key (not in CLI context and not in config) raises ValueError."""
mock_config = MagicMock()
mock_config.api_key = None
mock_config.workspace = None
mock_config.url_override = "https://opik.example.com"
mock_opik_config.return_value = mock_config
runner = CliRunner()
result = runner.invoke(
cli,
["healthcheck", "--check-permissions", "my-workspace"],
)
assert result.exit_code != 0
assert isinstance(result.exception, ValueError)
assert "API key is required" in str(result.exception)
@@ -0,0 +1,54 @@
"""Tests for the ``opik configure`` command group."""
import pathlib
from unittest import mock
from click.testing import CliRunner
from opik.cli import cli
from opik.cli import configure as configure_cli
def test_configure_status__prints_config_summary():
runner = CliRunner()
config = mock.Mock(
config_file_exists=True,
config_file_fullpath=pathlib.Path("/home/u/.opik.config"),
url_override="https://dev.comet.com/opik/api/",
workspace="my-ws",
)
with mock.patch.object(
configure_cli.opik_config, "OpikConfig", return_value=config
):
result = runner.invoke(cli, ["configure", "status"])
assert result.exit_code == 0
assert "Your Opik configuration" in result.output
assert "https://dev.comet.com/opik/api/" in result.output
assert "my-ws" in result.output
def test_configure_status__not_configured__points_to_configure():
runner = CliRunner()
config = mock.Mock(
config_file_exists=False,
config_file_fullpath=pathlib.Path("/home/u/.opik.config"),
)
with mock.patch.object(
configure_cli.opik_config, "OpikConfig", return_value=config
):
result = runner.invoke(cli, ["configure", "status"])
assert result.exit_code == 0
assert "not found" in result.output
assert "opik configure" in result.output
def test_configure_no_subcommand__runs_configurator():
runner = CliRunner()
with mock.patch.object(configure_cli, "run_interactive_configure") as spy:
result = runner.invoke(cli, ["configure", "--use-local"])
assert result.exit_code == 0
spy.assert_called_once()
assert spy.call_args.kwargs["use_local"] is True
+230
View File
@@ -0,0 +1,230 @@
from unittest.mock import MagicMock, patch
import httpx
from click.testing import CliRunner
from opik.cli.main import cli
from opik.cli.local_runner.pairing import PairingResult, RunnerType
from opik.rest_api.core.api_error import ApiError
class TestConnect:
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_connect__with_project__calls_pairing_and_supervisor(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls
):
client = MagicMock()
client.config.url_override = "https://api.test/"
mock_opik_cls.return_value = client
mock_run_pairing.return_value = PairingResult(
runner_id="r-abc",
project_name="my-proj",
project_id="p-123",
bridge_key=b"\x00" * 32,
)
runner = CliRunner()
result = runner.invoke(cli, ["connect", "--project", "my-proj"])
assert result.exit_code == 0
mock_run_pairing.assert_called_once()
call_kwargs = mock_run_pairing.call_args[1]
assert call_kwargs["project_name"] == "my-proj"
assert call_kwargs["runner_type"] == RunnerType.CONNECT
mock_launch.assert_called_once()
launch_kwargs = mock_launch.call_args[1]
assert launch_kwargs["command"] is None
def test_connect__no_project__shows_error(self):
runner = CliRunner()
# Any arg flips the `_EndpointGroup`-style fallback into the hidden _run
# subcommand, where `--project` is enforced as required by Click. Bare
# `opik connect` prints group help instead; covered by the next test.
result = runner.invoke(cli, ["connect", "--name", "test-runner"])
assert result.exit_code == 2
assert "--project" in result.output
def test_connect__no_args__shows_help(self):
runner = CliRunner()
result = runner.invoke(cli, ["connect"])
assert result.exit_code == 0
# Help body lists `stop` and the docstring mentions `--project`.
assert "stop" in result.output
assert "--project" in result.output
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_connect__network_failure__shows_clean_error(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls
):
client = MagicMock()
client.config.url_override = "https://api.test/"
mock_opik_cls.return_value = client
mock_run_pairing.side_effect = httpx.ConnectError("Connection refused")
runner = CliRunner()
result = runner.invoke(cli, ["connect", "--project", "my-proj"])
assert result.exit_code != 0
assert "Could not connect to Opik backend" in result.output
assert "https://api.test/" in result.output
assert "Run: opik configure" in result.output
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_connect__api_error__shows_error_body(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls
):
client = MagicMock()
client.config.url_override = "https://api.test/"
mock_opik_cls.return_value = client
mock_run_pairing.side_effect = ApiError(status_code=409, body="conflict")
runner = CliRunner()
result = runner.invoke(cli, ["connect", "--project", "my-proj"])
assert result.exit_code != 0
assert "conflict" in result.output
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_connect__tui_stopped_on_pairing_failure(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls
):
client = MagicMock()
client.config.url_override = "https://api.test/"
mock_opik_cls.return_value = client
mock_run_pairing.side_effect = ApiError(status_code=500, body="boom")
runner = CliRunner()
runner.invoke(cli, ["connect", "--project", "my-proj"])
tui_instance = mock_tui_cls.return_value
tui_instance.stop.assert_called_once()
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_connect__workspace_and_api_key_passed__forwarded_to_opik_constructor(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls
):
client = MagicMock()
client.config.url_override = "https://api.test/"
client.config.config_file_exists = True
mock_opik_cls.return_value = client
mock_run_pairing.return_value = PairingResult(
runner_id="r-abc",
project_name="my-proj",
project_id="p-123",
bridge_key=b"\x00" * 32,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"connect",
"--project",
"my-proj",
"--workspace",
"my-ws",
"--api-key",
"my-key",
],
)
assert result.exit_code == 0
mock_opik_cls.assert_called_once_with(
project_name="my-proj",
api_key="my-key",
workspace="my-ws",
_show_misconfiguration_message=False,
)
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_connect__local_api_key_overrides_global(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls
):
client = MagicMock()
client.config.url_override = "https://api.test/"
client.config.config_file_exists = True
mock_opik_cls.return_value = client
mock_run_pairing.return_value = PairingResult(
runner_id="r-abc",
project_name="my-proj",
project_id="p-123",
bridge_key=b"\x00" * 32,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"--api-key",
"global-key",
"connect",
"--project",
"my-proj",
"--api-key",
"local-key",
],
)
assert result.exit_code == 0
mock_opik_cls.assert_called_once_with(
project_name="my-proj",
api_key="local-key",
workspace=None,
_show_misconfiguration_message=False,
)
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_connect__no_local_api_key__falls_back_to_global(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls
):
client = MagicMock()
client.config.url_override = "https://api.test/"
client.config.config_file_exists = True
mock_opik_cls.return_value = client
mock_run_pairing.return_value = PairingResult(
runner_id="r-abc",
project_name="my-proj",
project_id="p-123",
bridge_key=b"\x00" * 32,
)
runner = CliRunner()
result = runner.invoke(
cli,
["--api-key", "global-key", "connect", "--project", "my-proj"],
)
assert result.exit_code == 0
mock_opik_cls.assert_called_once_with(
project_name="my-proj",
api_key="global-key",
workspace=None,
_show_misconfiguration_message=False,
)
+240
View File
@@ -0,0 +1,240 @@
from unittest.mock import MagicMock, patch
import httpx
from click.testing import CliRunner
from opik.cli.main import cli
from opik.cli.local_runner.pairing import PairingResult, RunnerType
from opik.rest_api.core.api_error import ApiError
class TestEndpoint:
@patch("opik.runner.snapshot.has_entrypoint", return_value=True)
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_endpoint__with_command__calls_pairing_and_supervisor(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls, _mock_ep
):
client = MagicMock()
client.config.url_override = "https://api.test/"
mock_opik_cls.return_value = client
mock_run_pairing.return_value = PairingResult(
runner_id="r-xyz",
project_name="my-proj",
project_id="p-123",
bridge_key=b"\x00" * 32,
)
runner = CliRunner()
result = runner.invoke(
cli, ["endpoint", "--project", "my-proj", "--", "echo", "hello"]
)
assert result.exit_code == 0
mock_run_pairing.assert_called_once()
call_kwargs = mock_run_pairing.call_args[1]
assert call_kwargs["project_name"] == "my-proj"
assert call_kwargs["runner_type"] == RunnerType.ENDPOINT
mock_launch.assert_called_once()
launch_kwargs = mock_launch.call_args[1]
assert launch_kwargs["command"] == ["echo", "hello"]
def test_endpoint__no_project__shows_error(self):
runner = CliRunner()
result = runner.invoke(cli, ["endpoint", "--", "echo", "hello"])
assert result.exit_code == 2
assert "--project" in result.output
def test_endpoint__no_command__shows_error(self):
runner = CliRunner()
result = runner.invoke(cli, ["endpoint", "--project", "my-proj"])
assert result.exit_code == 2
def test_endpoint__nonexistent_binary__shows_error(self):
runner = CliRunner()
result = runner.invoke(
cli,
["endpoint", "--project", "my-proj", "--", "nonexistent-binary-xyz-12345"],
)
assert result.exit_code == 2
assert "not found" in result.output.lower()
@patch("opik.runner.snapshot.has_entrypoint", return_value=True)
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_endpoint__network_failure__shows_clean_error(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls, _mock_ep
):
client = MagicMock()
client.config.url_override = "https://api.test/"
mock_opik_cls.return_value = client
mock_run_pairing.side_effect = httpx.ConnectError("Connection refused")
runner = CliRunner()
result = runner.invoke(
cli, ["endpoint", "--project", "my-proj", "--", "echo", "hello"]
)
assert result.exit_code != 0
assert "Could not connect to Opik backend" in result.output
assert "https://api.test/" in result.output
assert "Run: opik configure" in result.output
@patch("opik.runner.snapshot.has_entrypoint", return_value=True)
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_endpoint__tui_stopped_on_pairing_failure(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls, _mock_ep
):
client = MagicMock()
client.config.url_override = "https://api.test/"
mock_opik_cls.return_value = client
mock_run_pairing.side_effect = ApiError(status_code=500, body="boom")
runner = CliRunner()
runner.invoke(cli, ["endpoint", "--project", "my-proj", "--", "echo", "hello"])
tui_instance = mock_tui_cls.return_value
tui_instance.stop.assert_called_once()
@patch("opik.runner.snapshot.has_entrypoint", return_value=True)
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_endpoint__workspace_and_api_key_passed__forwarded_to_opik_constructor(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls, _mock_ep
):
client = MagicMock()
client.config.url_override = "https://api.test/"
client.config.config_file_exists = True
mock_opik_cls.return_value = client
mock_run_pairing.return_value = PairingResult(
runner_id="r-xyz",
project_name="my-proj",
project_id="p-123",
bridge_key=b"\x00" * 32,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"endpoint",
"--project",
"my-proj",
"--workspace",
"my-ws",
"--api-key",
"my-key",
"--",
"echo",
"hello",
],
)
assert result.exit_code == 0
mock_opik_cls.assert_called_once_with(
project_name="my-proj",
api_key="my-key",
workspace="my-ws",
_show_misconfiguration_message=False,
)
@patch("opik.runner.snapshot.has_entrypoint", return_value=True)
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_endpoint__local_api_key_overrides_global(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls, _mock_ep
):
client = MagicMock()
client.config.url_override = "https://api.test/"
client.config.config_file_exists = True
mock_opik_cls.return_value = client
mock_run_pairing.return_value = PairingResult(
runner_id="r-xyz",
project_name="my-proj",
project_id="p-123",
bridge_key=b"\x00" * 32,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"--api-key",
"global-key",
"endpoint",
"--project",
"my-proj",
"--api-key",
"local-key",
"--",
"echo",
"hello",
],
)
assert result.exit_code == 0
mock_opik_cls.assert_called_once_with(
project_name="my-proj",
api_key="local-key",
workspace=None,
_show_misconfiguration_message=False,
)
@patch("opik.runner.snapshot.has_entrypoint", return_value=True)
@patch("opik.cli.local_runner._run.RunnerTUI")
@patch("opik.cli.local_runner.pairing.launch_supervisor")
@patch("opik.cli.local_runner.pairing.run_pairing")
@patch("opik.cli.local_runner._run.Opik")
def test_endpoint__no_local_api_key__falls_back_to_global(
self, mock_opik_cls, mock_run_pairing, mock_launch, mock_tui_cls, _mock_ep
):
client = MagicMock()
client.config.url_override = "https://api.test/"
client.config.config_file_exists = True
mock_opik_cls.return_value = client
mock_run_pairing.return_value = PairingResult(
runner_id="r-xyz",
project_name="my-proj",
project_id="p-123",
bridge_key=b"\x00" * 32,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"--api-key",
"global-key",
"endpoint",
"--project",
"my-proj",
"--",
"echo",
"hello",
],
)
assert result.exit_code == 0
mock_opik_cls.assert_called_once_with(
project_name="my-proj",
api_key="global-key",
workspace=None,
_show_misconfiguration_message=False,
)
@@ -0,0 +1,282 @@
"""Unit tests for export error-handling fixes.
Covers three HIGH-priority fixes to prevent silent data loss during export:
HIGH-1 experiment.export_traces_by_ids: when get_trace_content raises 429,
the internal _fetch_trace_data retry decorator must kick in so the
trace is eventually exported, not silently dropped. Only genuine 404s
should be skipped without retrying.
HIGH-2 experiment.export_experiment_by_id: must re-raise on error so that
the all.py caller can set had_errors=True (previously it swallowed
all exceptions and returned a zero-stats tuple).
HIGH-3 all._fetch_experiments_page_raw: 429 must trigger a retry via the
_export_rest_retry decorator instead of silently returning ([], 0)
and losing an entire page of experiments.
"""
from unittest.mock import MagicMock, patch
import pytest
import httpx
from opik.rest_api.core.api_error import ApiError
from opik import exceptions as opik_exceptions
_EXPERIMENT_MODULE = "opik.cli.exports.experiment"
_ALL_MODULE = "opik.cli.exports.all"
# ---------------------------------------------------------------------------
# HIGH-1: export_traces_by_ids retries 429 rather than dropping the trace
# ---------------------------------------------------------------------------
class TestExportTracesByIdsRetry:
def _make_mock_trace(self, trace_id: str) -> MagicMock:
t = MagicMock()
t.model_dump.return_value = {"id": trace_id}
return t
def test_export_traces_by_ids__429_is_retried_not_dropped(self, tmp_path):
"""A 429 ApiError must trigger the retry decorator so the trace is
exported, not silently dropped."""
from opik.cli.exports.experiment import export_traces_by_ids
call_count = 0
def get_trace_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise ApiError(status_code=429, headers={})
return self._make_mock_trace("t1")
client = MagicMock()
client.get_trace_content.side_effect = get_trace_side_effect
client.search_spans.return_value = []
with patch(f"{_EXPERIMENT_MODULE}._fetch_trace_data.retry.sleep"):
exported, skipped = export_traces_by_ids(
client, ["t1"], tmp_path, "proj", None, "json", False, False
)
assert exported == 1, "429 must not silently drop the trace"
assert call_count == 2, "429 must trigger a retry"
def test_export_traces_by_ids__rate_limited_exception_is_retried(self, tmp_path):
"""OpikCloudRequestsRateLimited must also be retried."""
from opik.cli.exports.experiment import export_traces_by_ids
call_count = 0
def get_trace_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise opik_exceptions.OpikCloudRequestsRateLimited(
headers={}, retry_after=1.0
)
return self._make_mock_trace("t2")
client = MagicMock()
client.get_trace_content.side_effect = get_trace_side_effect
client.search_spans.return_value = []
with patch(f"{_EXPERIMENT_MODULE}._fetch_trace_data.retry.sleep"):
exported, skipped = export_traces_by_ids(
client, ["t2"], tmp_path, "proj", None, "json", False, False
)
assert exported == 1, "rate-limit exception must not silently drop the trace"
assert call_count == 2
def test_export_traces_by_ids__404_is_skipped_not_retried(self, tmp_path):
"""A genuine 404 (trace not found) should be skipped cleanly without retrying."""
from opik.cli.exports.experiment import export_traces_by_ids
call_count = 0
def get_trace_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
raise ApiError(status_code=404, headers={})
client = MagicMock()
client.get_trace_content.side_effect = get_trace_side_effect
exported, skipped = export_traces_by_ids(
client, ["missing-id"], tmp_path, "proj", None, "json", False, False
)
assert exported == 0, "404 trace must not be exported"
assert call_count == 1, "404 must not trigger a retry"
def test_export_traces_by_ids__success__exports_trace(self, tmp_path):
"""Happy path: trace is written to disk and counted as exported."""
from opik.cli.exports.experiment import export_traces_by_ids
client = MagicMock()
client.get_trace_content.return_value = self._make_mock_trace("t3")
client.search_spans.return_value = []
exported, skipped = export_traces_by_ids(
client, ["t3"], tmp_path, "proj", None, "json", False, False
)
assert exported == 1
# ---------------------------------------------------------------------------
# HIGH-2: export_experiment_by_id propagates errors instead of swallowing them
# ---------------------------------------------------------------------------
class TestExportExperimentByIdPropagatesErrors:
def test_export_experiment_by_id__api_error_propagates(self, tmp_path):
"""An unexpected error must propagate so the caller can set had_errors=True."""
from opik.cli.exports.experiment import export_experiment_by_id
client = MagicMock()
# Simulate a non-retriable API error (e.g. 403 Forbidden)
client.get_experiment_by_id.side_effect = ApiError(status_code=403, headers={})
with pytest.raises(ApiError):
export_experiment_by_id(
client=client,
output_dir=tmp_path,
project_name="proj",
experiment_id="exp-1",
max_traces=None,
force=True,
debug=False,
format="json",
)
def test_export_all_experiments__experiment_failure_sets_had_errors(self, tmp_path):
"""When export_experiment_by_id raises, _export_all_experiments must set
had_errors=True rather than silently counting it as skipped."""
from opik.cli.exports.all import _export_all_experiments
client = MagicMock()
experiments_dir = tmp_path / "experiments"
experiments_dir.mkdir()
# Two experiments: first succeeds, second raises.
exp1 = MagicMock()
exp1.id = "e1"
exp1.name = "exp-one"
exp2 = MagicMock()
exp2.id = "e2"
exp2.name = "exp-two"
def export_side_effect(
client, output_dir, project_name, exp_id, *args, **kwargs
):
if exp_id == "e2":
raise RuntimeError("transient failure")
m = MagicMock()
m.get_all_trace_ids.return_value = []
return {}, 1, m
with (
patch(
f"{_ALL_MODULE}._paginate_experiments",
return_value=[exp1, exp2],
),
patch(
f"{_ALL_MODULE}.export_experiment_by_id",
side_effect=export_side_effect,
),
patch(f"{_ALL_MODULE}.export_collected_trace_ids", return_value=(0, 0)),
):
_, _, _, _, had_errors = _export_all_experiments(
client=client,
project_dir=tmp_path,
project_name="proj",
project_id=None,
experiments_dir=experiments_dir,
max_results=None,
force=False,
debug=False,
format="json",
)
assert had_errors is True, "a failed experiment must set had_errors=True"
# ---------------------------------------------------------------------------
# HIGH-3: _fetch_experiments_page_raw retries 429 instead of returning [], 0
# ---------------------------------------------------------------------------
class TestFetchExperimentsPageRawRetry:
def _make_httpx_response(self, status_code: int, headers=None, json_body=None):
"""Build a minimal httpx.Response for raise_for_status() tests."""
headers = headers or {}
body = json_body or {"content": [], "total": 0}
import json as _json
# httpx.Response.raise_for_status() requires a request to be attached.
request = httpx.Request("GET", "http://test/v1/private/experiments")
response = httpx.Response(
status_code=status_code,
headers=headers,
content=_json.dumps(body).encode(),
request=request,
)
return response
def test_fetch_experiments_page_raw__429_raises_rate_limited_not_empty_list(
self,
):
"""A 429 response must raise OpikCloudRequestsRateLimited (for the retry
decorator), NOT silently return ([], 0)."""
from opik.cli.exports.all import _fetch_experiments_page_raw
client = MagicMock()
httpx_mock = MagicMock()
client.rest_client.experiments._raw_client._client_wrapper.httpx_client = (
httpx_mock
)
httpx_mock.request.return_value = self._make_httpx_response(
429, headers={"Retry-After": "5"}
)
with pytest.raises(opik_exceptions.OpikCloudRequestsRateLimited):
_fetch_experiments_page_raw.__wrapped__(client, 1, None)
def test_fetch_experiments_page_raw__429_is_retried_by_decorator(self):
"""When the underlying call raises 429, the decorator retries rather
than letting a silent ([], 0) escape to the caller."""
from opik.cli.exports.all import _fetch_experiments_page_raw
call_count = 0
def request_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return self._make_httpx_response(429, headers={"Retry-After": "1"})
return self._make_httpx_response(
200,
json_body={
"content": [{"id": "exp-1", "name": "my-exp"}],
"total": 1,
},
)
client = MagicMock()
httpx_mock = MagicMock()
client.rest_client.experiments._raw_client._client_wrapper.httpx_client = (
httpx_mock
)
httpx_mock.request.side_effect = request_side_effect
with patch(f"{_ALL_MODULE}._fetch_experiments_page_raw.retry.sleep"):
items, total = _fetch_experiments_page_raw(client, 1, None)
assert call_count == 2, "429 must trigger a retry"
assert len(items) == 1, "successful retry must return the page contents"
assert total == 1
@@ -0,0 +1,788 @@
"""Tests for rate-limiting resilience in project export.
Covers the changes introduced to handle server-side 429 throttling:
Retry wait behaviour (via export_traces public API)
- 429 with Retry-After header → sleep honours header value
- 429 with Retry-After exceeding cap → sleep falls back to 30 s
- 429 with no Retry-After → sleep is 30 s
- non-429 transient error → sleep is in exponential backoff range [2, 60] s
export_traces (page-fetch failures)
- 429 on page N → page is skipped, remaining pages still fetched, had_errors=True
- all pages succeed → had_errors=False, all traces exported
- middle page failure → subsequent pages still exported
export_traces (span-fetch failures)
- bulk span fetch raises repeatedly → traces exported with empty spans, had_errors=True
export_traces (inter-page delay)
- time.sleep NOT called between successful full pages (inter-page sleep removed)
- time.sleep not called after partial (terminal) page
export_single_project (had_errors propagation)
- returns 4-tuple; had_errors=False on clean run, True when errors occurred
- first element is 0 when no traces exported or skipped, 1 otherwise
"""
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from opik.rest_api.core.api_error import ApiError
_MODULE = "opik.cli.exports.project"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_mock_trace(trace_id: str) -> MagicMock:
t = MagicMock()
t.id = trace_id
t.name = f"trace-{trace_id}"
t.model_dump.return_value = {
"id": trace_id,
"name": f"trace-{trace_id}",
"start_time": None,
"end_time": None,
"input": {},
"output": {},
"metadata": {},
"tags": [],
"feedback_scores": [],
"error_info": None,
"thread_id": None,
"created_at": None,
"created_by": None,
"last_updated_at": None,
"last_updated_by": None,
"visibility_mode": None,
"ttft": None,
"project_name": "test-project",
}
return t
def _make_page(traces, total=None):
page = MagicMock()
page.content = traces
page.total = total if total is not None else len(traces)
return page
def _make_full_page(n=500, offset=0):
"""Return a page with exactly *n* mock traces (matches the internal page_size=500).
Use *offset* to generate distinct trace IDs across pages.
"""
return _make_page([_make_mock_trace(f"bulk-{offset + i}") for i in range(n)])
def _uuid7(n: int) -> str:
"""Deterministic, valid UUIDv7 string for trace index *n*.
Version nibble is 7 and the variant nibble is RFC-4122 (8), so
``uuid.UUID(...).version == 7`` — this is what makes the exporter build a
bounded ``trace_id`` range filter instead of falling back to a full scan.
"""
high = f"{0x0190A1B2C3D4 + n:012x}"
return f"{high[:8]}-{high[8:12]}-7000-8000-{n:012x}"
def _make_full_page_uuid7(n=500, offset=0):
"""Like ``_make_full_page`` but with UUIDv7 trace ids (bounded-scan path)."""
return _make_page([_make_mock_trace(_uuid7(offset + i)) for i in range(n)])
# ---------------------------------------------------------------------------
# Retry wait behaviour — tested via export_traces (public API)
# ---------------------------------------------------------------------------
class TestExportTracesRetryWaitBehaviour:
"""Verify that the export-specific retry decorator applies the correct wait
durations when the underlying page-fetch raises errors. Tests go through
the public export_traces entrypoint so the actual retry decorator fires;
time.sleep is patched to capture what tenacity passes to it.
"""
def _run_with_first_call_raising(self, exc, tmp_path):
"""Run export_traces where the first page fetch raises *exc* then succeeds."""
from opik.cli.exports.project import export_traces
trace = _make_mock_trace("t1")
call_count = 0
def get_traces_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise exc
return _make_page([trace])
mock_client = MagicMock()
mock_client.rest_client.traces.get_traces_by_project.side_effect = (
get_traces_side_effect
)
with patch(f"{_MODULE}._fetch_traces_page.retry.sleep") as mock_sleep:
export_traces(
client=mock_client,
project_name="proj",
project_dir=tmp_path,
max_results=None,
filter_string=None,
show_progress=False,
)
return [c.args[0] for c in mock_sleep.call_args_list]
def test_export_traces__page_fetch_429_with_retry_after_header__sleep_honours_header(
self, tmp_path
):
exc = ApiError(status_code=429, headers={"retry-after": "45"})
sleep_calls = self._run_with_first_call_raising(exc, tmp_path)
# Jitter adds 05 s on top of the header value.
assert any(45.0 <= s <= 50.0 for s in sleep_calls)
def test_export_traces__page_fetch_429_retry_after_exceeds_cap__sleep_clamped_to_max(
self, tmp_path
):
# 200 s > _EXPORT_MAX_RETRY_AFTER_SECONDS (120 s) → clamped to 120 s
exc = ApiError(status_code=429, headers={"retry-after": "200"})
sleep_calls = self._run_with_first_call_raising(exc, tmp_path)
# Jitter adds 05 s on top of the 120 s cap.
assert any(120.0 <= s <= 125.0 for s in sleep_calls)
def test_export_traces__page_fetch_429_retry_after_http_date__sleep_honours_header(
self, tmp_path
):
import email.utils
import time
# Build an HTTP-date Retry-After 60 seconds in the future.
future = email.utils.formatdate(timeval=time.time() + 60, usegmt=True)
exc = ApiError(status_code=429, headers={"retry-after": future})
sleep_calls = self._run_with_first_call_raising(exc, tmp_path)
# Should sleep approximately 60 s (allow ±2 s for execution) plus 05 s jitter.
assert any(58.0 <= s <= 67.0 for s in sleep_calls)
def test_export_traces__page_fetch_429_no_retry_after_header__sleep_is_30s(
self, tmp_path
):
exc = ApiError(status_code=429, headers={})
sleep_calls = self._run_with_first_call_raising(exc, tmp_path)
# Jitter adds 05 s on top of the 30 s fallback.
assert any(30.0 <= s <= 35.0 for s in sleep_calls)
def test_export_traces__page_fetch_non_429_transient_error__sleep_is_exponential_backoff(
self, tmp_path
):
exc = ApiError(status_code=503)
sleep_calls = self._run_with_first_call_raising(exc, tmp_path)
# Backoff is exponential starting at 2 s, capped at 60 s.
assert any(2.0 <= s <= 60.0 for s in sleep_calls)
# ---------------------------------------------------------------------------
# export_traces — page-fetch failures
# ---------------------------------------------------------------------------
class TestExportTracesPageFetchFailures:
def test_export_traces__first_page_429__skips_page_and_continues(self):
from opik.cli.exports.project import export_traces
trace_p2 = _make_mock_trace("t-p2")
mock_client = MagicMock()
with tempfile.TemporaryDirectory() as tmp:
with patch(f"{_MODULE}._fetch_traces_page") as mock_fetch:
mock_fetch.side_effect = [
ApiError(status_code=429, body="rate limited"),
_make_page([trace_p2]),
_make_page([]),
]
with patch(f"{_MODULE}.time.sleep"):
exported, skipped, had_errors = export_traces(
client=mock_client,
project_name="proj",
project_dir=Path(tmp),
max_results=None,
filter_string=None,
show_progress=False,
)
assert had_errors is True
assert exported == 1
assert skipped == 0
def test_export_traces__all_pages_succeed__happyflow(self):
from opik.cli.exports.project import export_traces
traces = [_make_mock_trace("t1"), _make_mock_trace("t2")]
mock_client = MagicMock()
with tempfile.TemporaryDirectory() as tmp:
with patch(f"{_MODULE}._fetch_traces_page") as mock_fetch:
mock_fetch.side_effect = [
_make_page(traces),
_make_page([]),
]
with patch(f"{_MODULE}.time.sleep"):
exported, skipped, had_errors = export_traces(
client=mock_client,
project_name="proj",
project_dir=Path(tmp),
max_results=None,
filter_string=None,
show_progress=False,
)
assert had_errors is False
assert exported == 2
def test_export_traces__middle_page_fails__subsequent_pages_still_exported(self):
"""A failed page in the middle must not prevent later pages from being fetched."""
from opik.cli.exports.project import export_traces
# Use page_size=2 so pages with 2 traces are "full" (2 == page_size).
# Keeps file-write count tiny (4 files) while still exercising the
# pagination loop — the loop only breaks early on a *short* page.
page1 = _make_full_page(2, offset=0)
page3 = _make_full_page(2, offset=2)
mock_client = MagicMock()
with tempfile.TemporaryDirectory() as tmp:
with patch(f"{_MODULE}._fetch_traces_page") as mock_fetch:
mock_fetch.side_effect = [
page1, # page 1: OK, 2 traces
ApiError(status_code=429), # page 2: rate limited
page3, # page 3: OK, 2 traces
_make_page([]), # page 4: end
]
with patch(f"{_MODULE}._fetch_spans_page", return_value=_make_page([])):
with patch(f"{_MODULE}.time.sleep"):
exported, skipped, had_errors = export_traces(
client=mock_client,
project_name="proj",
project_dir=Path(tmp),
max_results=None,
filter_string=None,
show_progress=False,
page_size=2,
)
assert had_errors is True
assert exported == 4 # page 1 and page 3 traces both exported
def test_export_traces__consecutive_failures_reach_cap__export_aborted(self):
"""After MAX_CONSECUTIVE_PAGE_FAILURES pages in a row fail, the loop aborts."""
from opik.cli.exports.project import (
export_traces,
MAX_CONSECUTIVE_PAGE_FAILURES,
)
mock_client = MagicMock()
# Every page raises a transient error
with tempfile.TemporaryDirectory() as tmp:
with patch(f"{_MODULE}._fetch_traces_page") as mock_fetch:
mock_fetch.side_effect = ApiError(status_code=503)
with patch(f"{_MODULE}.time.sleep"):
exported, skipped, had_errors = export_traces(
client=mock_client,
project_name="proj",
project_dir=Path(tmp),
max_results=None,
filter_string=None,
show_progress=False,
)
assert had_errors is True
assert exported == 0
# Should have stopped after MAX_CONSECUTIVE_PAGE_FAILURES attempts
assert mock_fetch.call_count == MAX_CONSECUTIVE_PAGE_FAILURES
def test_export_traces__permanent_api_error__raises_instead_of_skipping(self):
"""A 400 Bad Request must not be silently skipped — it should propagate."""
from opik.cli.exports.project import export_traces
mock_client = MagicMock()
with tempfile.TemporaryDirectory() as tmp:
with patch(f"{_MODULE}._fetch_traces_page") as mock_fetch:
mock_fetch.side_effect = ApiError(status_code=400, body="bad request")
with patch(f"{_MODULE}.time.sleep"):
with pytest.raises(ApiError) as exc_info:
export_traces(
client=mock_client,
project_name="proj",
project_dir=Path(tmp),
max_results=None,
filter_string=None,
show_progress=False,
)
assert exc_info.value.status_code == 400
# Should have raised immediately, not retried
assert mock_fetch.call_count == 1
# ---------------------------------------------------------------------------
# export_traces — span-fetch failures
# ---------------------------------------------------------------------------
class TestExportTracesSpanFetchFailures:
def test_export_traces__bulk_span_fetch_fails__traces_exported_with_empty_spans(
self,
):
"""When the bulk span page fetch keeps failing, traces are still written
(with empty span lists) and had_errors is set to True."""
from opik.cli.exports.project import export_traces
t1 = _make_mock_trace("t1")
t2 = _make_mock_trace("t2")
mock_client = MagicMock()
with tempfile.TemporaryDirectory() as tmp:
with patch(f"{_MODULE}._fetch_traces_page") as mock_trace_fetch:
mock_trace_fetch.side_effect = [
_make_page([t1, t2]),
_make_page([]),
]
with patch(f"{_MODULE}._fetch_spans_page") as mock_span_fetch:
mock_span_fetch.side_effect = ApiError(status_code=503)
with patch(f"{_MODULE}.time.sleep"):
exported, skipped, had_errors = export_traces(
client=mock_client,
project_name="proj",
project_dir=Path(tmp),
max_results=None,
filter_string=None,
show_progress=False,
)
assert had_errors is True
assert exported == 2 # both trace files written, just without spans
assert skipped == 0
# ---------------------------------------------------------------------------
# export_traces — bounded-chunk (memory) behaviour
# ---------------------------------------------------------------------------
class TestExportTracesChunking:
def test_export_traces__large_project_flushes_in_bounded_chunks(self):
"""A multi-page export writes traces incrementally, one bounded chunk at
a time, instead of buffering every trace and span before the first write.
Incremental flushing is what keeps peak memory from scaling with the
whole project (the previous all-at-once design OOM'd on large projects).
The export is driven purely through the public ``export_traces`` API,
mocking only the public REST client boundary
(``traces.get_traces_by_project`` / ``spans.get_spans_by_project``) — no
private helpers are patched. Correctness is asserted on the returned
tuple and the final ``trace_*.json`` artifacts. The bounded-chunk
property is asserted through robust invariants observed at the public
span endpoint (files already on disk when each span fetch fires), not a
brittle exact-call-order snapshot: the assertions hold regardless of the
exact chunk boundary the implementation picks.
"""
from opik.cli.exports.project import export_traces
# UUIDv7 ids so each chunk builds a bounded trace_id range filter and
# scans spans per chunk (the common, memory-bounded path).
page1 = _make_full_page_uuid7(2, offset=0)
page2 = _make_full_page_uuid7(2, offset=2)
page3 = _make_full_page_uuid7(2, offset=4)
mock_client = MagicMock()
expected_ids = [_uuid7(i) for i in range(6)]
with tempfile.TemporaryDirectory() as tmp:
project_dir = Path(tmp)
files_on_disk_at_each_span_fetch = []
def span_fetch_side_effect(*args, **kwargs):
# How many trace files already exist when a span fetch begins.
# A growing, sub-total count proves earlier chunks were flushed
# to disk before later chunks are processed (incremental), rather
# than one all-at-once sweep after buffering everything.
files_on_disk_at_each_span_fetch.append(
len(list(project_dir.glob("trace_*.json")))
)
return _make_page([])
mock_client.rest_client.traces.get_traces_by_project.side_effect = [
page1,
page2,
page3,
_make_page([]),
]
mock_client.rest_client.spans.get_spans_by_project.side_effect = (
span_fetch_side_effect
)
with patch(f"{_MODULE}.time.sleep"):
exported, skipped, had_errors = export_traces(
client=mock_client,
project_name="proj",
project_dir=project_dir,
max_results=None,
filter_string=None,
show_progress=False,
page_size=2,
)
# Public result contract.
assert (exported, skipped, had_errors) == (6, 0, False)
# Final artifacts: exactly the six expected trace files were written.
written_ids = sorted(
p.name[len("trace_") : -len(".json")]
for p in project_dir.glob("trace_*.json")
)
assert written_ids == sorted(expected_ids)
# Bounded-chunk invariants (robust to the exact chunk boundary):
# - more than one span-fetch session → work is chunked, not one sweep;
# - counts never decrease → files accumulate as chunks flush;
# - a later fetch sees files already written → earlier chunks were
# flushed to disk before the last chunk was processed, so only ~one
# chunk's worth of traces is held in memory at a time.
assert len(files_on_disk_at_each_span_fetch) > 1
assert files_on_disk_at_each_span_fetch == sorted(
files_on_disk_at_each_span_fetch
)
assert files_on_disk_at_each_span_fetch[0] == 0
assert files_on_disk_at_each_span_fetch[-1] > 0
def test_export_traces__non_uuid7_ids__span_scan_bounded_per_chunk(self):
"""When trace ids aren't UUIDv7 the span scan can't be bounded by a
trace_id range, so the backend streams back every span in the project.
Rather than cache that whole-project span set — which would reintroduce
the unbounded memory peak this chunked export exists to prevent — each
chunk runs its own scan and keeps only its own traces' spans. Peak
memory therefore stays bounded to one chunk regardless of project size;
the trade-off is one unfiltered scan per chunk on this rare fallback.
"""
from opik.cli.exports.project import export_traces
# Non-UUIDv7 ids ("bulk-N") force the unfiltered-scan fallback.
page1 = _make_full_page(2, offset=0)
page2 = _make_full_page(2, offset=2)
page3 = _make_full_page(2, offset=4)
mock_client = MagicMock()
expected_ids = [f"bulk-{i}" for i in range(6)]
# Each per-chunk scan pages GET /spans starting at page 1; a fresh page-1
# request marks a new scan session.
scan_sessions = []
def span_fetch_side_effect(*args, **kwargs):
page = kwargs.get("page")
if page == 1:
scan_sessions.append([])
scan_sessions[-1].append(page)
return _make_page([])
mock_client.rest_client.traces.get_traces_by_project.side_effect = [
page1,
page2,
page3,
_make_page([]),
]
mock_client.rest_client.spans.get_spans_by_project.side_effect = (
span_fetch_side_effect
)
with tempfile.TemporaryDirectory() as tmp:
project_dir = Path(tmp)
with patch(f"{_MODULE}.time.sleep"):
exported, skipped, had_errors = export_traces(
client=mock_client,
project_name="proj",
project_dir=project_dir,
max_results=None,
filter_string=None,
show_progress=False,
page_size=2,
)
assert (exported, skipped, had_errors) == (6, 0, False)
written_ids = sorted(
p.name[len("trace_") : -len(".json")]
for p in project_dir.glob("trace_*.json")
)
assert written_ids == sorted(expected_ids)
# One unfiltered scan per flushed chunk (3) — not a single cached
# whole-project scan — which is what keeps peak memory bounded to a chunk.
assert len(scan_sessions) == 3
def test_export_traces__span_scan_error_isolated_to_its_chunk(self):
"""A transient span-scan failure is confined to the chunk that hit it.
With per-chunk scanning there is no shared span cache to poison: the
failed chunk is exported with partial spans and had_errors is set, while
later chunks scan independently and export normally. The failure is
injected at the public REST boundary (``spans.get_spans_by_project``),
per the SDK testing guidelines, not at a private transport helper.
"""
from opik.cli.exports.project import export_traces
# Non-UUIDv7 ids force the unfiltered per-chunk scan fallback.
page1 = _make_full_page(2, offset=0)
page2 = _make_full_page(2, offset=2)
page3 = _make_full_page(2, offset=4)
mock_client = MagicMock()
expected_ids = [f"bulk-{i}" for i in range(6)]
with tempfile.TemporaryDirectory() as tmp:
project_dir = Path(tmp)
def get_spans_side_effect(*args, **kwargs):
# Fail every span page while the first chunk is being scanned
# (nothing flushed yet); once earlier chunks have written their
# files, succeed — proving the error didn't leak across chunks.
if not any(project_dir.glob("trace_*.json")):
raise ApiError(status_code=503)
return _make_page([])
mock_client.rest_client.traces.get_traces_by_project.side_effect = [
page1,
page2,
page3,
_make_page([]),
]
mock_client.rest_client.spans.get_spans_by_project.side_effect = (
get_spans_side_effect
)
# Neutralise the retry decorator's sleep (public boundary triggers
# the real 8-attempt retry) and the inter-page delay.
with patch(f"{_MODULE}._fetch_spans_page.retry.sleep"):
with patch(f"{_MODULE}.time.sleep"):
exported, skipped, had_errors = export_traces(
client=mock_client,
project_name="proj",
project_dir=project_dir,
max_results=None,
filter_string=None,
show_progress=False,
page_size=2,
)
# The failed chunk still wrote its traces (with partial spans) and the
# failure is flagged; later chunks were unaffected, so all 6 landed.
assert (exported, skipped) == (6, 0)
assert had_errors is True
written_ids = sorted(
p.name[len("trace_") : -len(".json")]
for p in project_dir.glob("trace_*.json")
)
assert written_ids == sorted(expected_ids)
# ---------------------------------------------------------------------------
# export_traces — inter-page delay
# ---------------------------------------------------------------------------
class TestExportTracesInterPageDelay:
def test_export_traces__multiple_full_pages__sleep_not_called_on_success(self):
"""sleep is NOT called between successful pages — the inter-page delay was
removed because server-side 429s are already handled by the retry decorator."""
from opik.cli.exports.project import export_traces, _PAGE_FETCH_DELAY_SECONDS
mock_client = MagicMock()
with tempfile.TemporaryDirectory() as tmp:
with patch(f"{_MODULE}._fetch_traces_page") as mock_fetch:
mock_fetch.side_effect = [
_make_full_page(2, offset=0), # page 1: full, IDs 01
_make_full_page(2, offset=2), # page 2: full, IDs 23
_make_page([]), # page 3: empty — end
]
with patch(f"{_MODULE}._fetch_spans_page", return_value=_make_page([])):
with patch(f"{_MODULE}.time.sleep") as mock_sleep:
export_traces(
client=mock_client,
project_name="proj",
project_dir=Path(tmp),
max_results=None,
filter_string=None,
show_progress=False,
page_size=2,
)
from unittest.mock import call as mock_call
delay_calls = mock_sleep.call_args_list.count(
mock_call(_PAGE_FETCH_DELAY_SECONDS)
)
assert delay_calls == 0
def test_export_traces__single_partial_page__sleep_not_called(self):
"""A partial (< 100 trace) page means we're at the end — no sleep needed."""
from opik.cli.exports.project import export_traces, _PAGE_FETCH_DELAY_SECONDS
mock_client = MagicMock()
with tempfile.TemporaryDirectory() as tmp:
with patch(f"{_MODULE}._fetch_traces_page") as mock_fetch:
# Single partial page — loop breaks immediately on short-page check
mock_fetch.side_effect = [
_make_page([_make_mock_trace("t1")]),
]
with patch(f"{_MODULE}.time.sleep") as mock_sleep:
export_traces(
client=mock_client,
project_name="proj",
project_dir=Path(tmp),
max_results=None,
filter_string=None,
show_progress=False,
)
from unittest.mock import call as mock_call
delay_calls = mock_sleep.call_args_list.count(
mock_call(_PAGE_FETCH_DELAY_SECONDS)
)
assert delay_calls == 0
# ---------------------------------------------------------------------------
# export_single_project — had_errors and project_exported propagation
# ---------------------------------------------------------------------------
class TestExportSingleProjectReturnValues:
def _make_project(self, name="test-proj"):
p = MagicMock()
p.name = name
p.id = "proj-id-1"
return p
def test_export_single_project__clean_run__had_errors_false(self):
from opik.cli.exports.project import export_single_project
mock_client = MagicMock()
with tempfile.TemporaryDirectory() as tmp:
projects_dir = Path(tmp) / "projects"
projects_dir.mkdir()
with (
patch(f"{_MODULE}.export_traces") as mock_export,
patch(f"{_MODULE}.time.sleep"),
):
mock_export.return_value = (3, 0, False)
result = export_single_project(
client=mock_client,
project_name="test-proj",
project_dir=projects_dir,
filter_string=None,
max_results=None,
force=False,
debug=False,
format="json",
)
assert len(result) == 4
proj_count, _exported, _skipped, had_errors = result
assert had_errors is False
assert proj_count == 1 # traces were exported
def test_export_single_project__run_with_errors__had_errors_true(self):
from opik.cli.exports.project import export_single_project
mock_client = MagicMock()
with tempfile.TemporaryDirectory() as tmp:
projects_dir = Path(tmp) / "projects"
projects_dir.mkdir()
with (
patch(f"{_MODULE}.export_traces") as mock_export,
patch(f"{_MODULE}.time.sleep"),
):
mock_export.return_value = (2, 1, True)
result = export_single_project(
client=mock_client,
project_name="test-proj",
project_dir=projects_dir,
filter_string=None,
max_results=None,
force=False,
debug=False,
format="json",
)
assert len(result) == 4
_proj_count, _exported, _skipped, had_errors = result
assert had_errors is True
def test_export_single_project__no_traces_found__project_count_is_zero(self):
"""When there are no traces at all, the project-exported flag must be 0."""
from opik.cli.exports.project import export_single_project
mock_client = MagicMock()
with tempfile.TemporaryDirectory() as tmp:
projects_dir = Path(tmp) / "projects"
projects_dir.mkdir()
with (
patch(f"{_MODULE}.export_traces") as mock_export,
patch(f"{_MODULE}.time.sleep"),
):
mock_export.return_value = (0, 0, False) # nothing exported or skipped
result = export_single_project(
client=mock_client,
project_name="test-proj",
project_dir=projects_dir,
filter_string=None,
max_results=None,
force=False,
debug=False,
format="json",
)
proj_count, _exported, _skipped, _had_errors = result
assert proj_count == 0
def test_export_single_project__exception_during_export__returns_had_errors_true(
self,
):
from opik.cli.exports.project import export_single_project
mock_client = MagicMock()
with tempfile.TemporaryDirectory() as tmp:
projects_dir = Path(tmp) / "projects"
projects_dir.mkdir()
with patch(f"{_MODULE}.export_traces") as mock_export:
mock_export.side_effect = RuntimeError("unexpected failure")
result = export_single_project(
client=mock_client,
project_name="test-proj",
project_dir=projects_dir,
filter_string=None,
max_results=None,
force=False,
debug=False,
format="json",
)
assert result == (0, 0, 0, True)
@@ -0,0 +1,900 @@
"""Unit tests for experiment import functionality."""
import json
import sys
import types
from pathlib import Path
from typing import Dict, Any
from unittest.mock import Mock, MagicMock, patch
import pytest
# Mock the problematic imports before importing
# Mock the prompt import that's causing issues
sys.modules["opik.api_objects.prompt.prompt"] = MagicMock()
# Now we can import normally
from opik.cli.imports.experiment import ( # noqa: E402
ExperimentData,
load_experiment_data,
recreate_experiment,
_import_traces_for_project,
)
from opik.cli.imports.utils import ( # noqa: E402
translate_trace_id as utils_translate_trace_id,
sort_spans_topologically,
)
class TestExperimentData:
"""Test ExperimentData dataclass."""
def test_experiment_data_from_dict(self) -> None:
"""Test creating ExperimentData from dictionary."""
data = {
"experiment": {
"id": "exp-123",
"name": "test-experiment",
"dataset_name": "test-dataset",
},
"items": [
{"id": "item-1", "trace_id": "trace-1"},
{"id": "item-2", "trace_id": "trace-2"},
],
"downloaded_at": "2024-01-01T00:00:00",
}
exp_data = ExperimentData.from_dict(data)
assert exp_data.experiment["id"] == "exp-123"
assert exp_data.experiment["name"] == "test-experiment"
assert len(exp_data.items) == 2
assert exp_data.downloaded_at == "2024-01-01T00:00:00"
def test_experiment_data_from_dict_minimal(self) -> None:
"""Test creating ExperimentData with minimal data."""
data = {
"experiment": {"id": "exp-123", "dataset_name": "test-dataset"},
"items": [],
}
exp_data = ExperimentData.from_dict(data)
assert exp_data.experiment["id"] == "exp-123"
assert exp_data.items == []
assert exp_data.downloaded_at is None
def test_load_experiment_data_from_file(self, tmp_path: Path) -> None:
"""Test loading experiment data from JSON file."""
experiment_file = tmp_path / "experiment_test.json"
data = {
"experiment": {
"id": "exp-123",
"name": "test-experiment",
"dataset_name": "test-dataset",
},
"items": [{"id": "item-1"}],
}
with open(experiment_file, "w") as f:
json.dump(data, f)
exp_data = load_experiment_data(experiment_file)
assert isinstance(exp_data, ExperimentData)
assert exp_data.experiment["id"] == "exp-123"
assert len(exp_data.items) == 1
class TestTranslateTraceId:
"""Test translate_trace_id function."""
def test_translate_trace_id_found(self) -> None:
"""Test translating trace ID when mapping exists."""
trace_id_map = {"old-trace-1": "new-trace-1", "old-trace-2": "new-trace-2"}
result = utils_translate_trace_id("old-trace-1", trace_id_map)
assert result == "new-trace-1"
def test_translate_trace_id_not_found(self) -> None:
"""Test translating trace ID when mapping doesn't exist."""
trace_id_map = {"old-trace-1": "new-trace-1"}
result = utils_translate_trace_id("old-trace-2", trace_id_map)
assert result is None
def test_translate_trace_id_empty_map(self) -> None:
"""Test translating trace ID with empty map."""
trace_id_map: Dict[str, str] = {}
result = utils_translate_trace_id("old-trace-1", trace_id_map)
assert result is None
def test_translate_trace_id_requires_dict(self) -> None:
"""Test that translate_trace_id requires Dict, not Optional."""
# This test verifies the type signature is correct
# If someone tries to pass None, type checker should catch it
trace_id_map: Dict[str, str] = {} # Required, not Optional
result = utils_translate_trace_id("trace-1", trace_id_map)
assert result is None
class TestRecreateExperiment:
"""Test recreate_experiment function."""
@staticmethod
def _extract_items_arg_from_call_args(call_args: Any) -> Any:
"""Helper to extract the items argument from call_args.
Handles both positional and keyword arguments.
"""
if hasattr(call_args, "args") and call_args.args:
return call_args.args[0]
if hasattr(call_args, "kwargs"):
if "items" in call_args.kwargs:
return call_args.kwargs["items"]
for value in call_args.kwargs.values():
if isinstance(value, list) and len(value) > 0:
return value
return None
@pytest.fixture
def mock_client(self) -> Mock:
"""Create a mock Opik client."""
client = Mock()
# Ensure flush returns True to indicate success
client.flush = Mock(return_value=True)
# Mock dataset
mock_dataset = Mock()
mock_dataset.name = "test-dataset"
mock_dataset.__internal_api__insert_items_as_dataclasses__ = Mock()
# Mock experiment
mock_experiment = Mock()
mock_experiment.insert = Mock()
mock_experiment.id = "exp-123"
client.get_or_create_dataset = Mock(return_value=mock_dataset)
client.create_experiment = Mock(return_value=mock_experiment)
# Mock REST client for experiment items creation
client._rest_client = Mock()
client._rest_client.experiments = Mock()
client._rest_client.experiments.create_experiment_items = Mock()
return client
@pytest.fixture
def experiment_data(self) -> ExperimentData:
"""Create sample experiment data."""
return ExperimentData(
experiment={
"id": "exp-123",
"name": "test-experiment",
"dataset_name": "test-dataset",
"type": "regular",
},
items=[
{
"trace_id": "trace-1",
"dataset_item_id": "ds-item-1",
"dataset_item_data": {
"input": "test input",
"expected_output": "test output",
},
},
{
"trace_id": "trace-2",
"dataset_item_id": "ds-item-2",
"dataset_item_data": {"input": "test input 2"},
},
],
)
def test_recreate_experiment_requires_trace_id_map(
self, mock_client: Mock, experiment_data: ExperimentData
) -> None:
"""Test that recreate_experiment requires trace_id_map (not Optional)."""
# This test verifies the type signature
trace_id_map: Dict[str, str] = {
"trace-1": "new-trace-1",
"trace-2": "new-trace-2",
}
dataset_item_id_map: Dict[str, str] = {
"ds-item-1": "new-ds-item-1",
"ds-item-2": "new-ds-item-2",
}
# Should not accept None - type checker would catch this
# We test that it works with a dict
with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers:
mock_id_helpers.generate_id = Mock(return_value="generated-id")
recreate_experiment(
mock_client,
experiment_data,
"test-project",
trace_id_map, # Required, not Optional
dataset_item_id_map, # Required for mapping dataset items
dry_run=False,
debug=False,
)
# Verify experiment items were created via REST API
assert mock_client._rest_client.experiments.create_experiment_items.called
def test_recreate_experiment_batches_dataset_items(
self, mock_client: Mock, experiment_data: ExperimentData
) -> None:
"""Test that experiment items are created in batch, not one at a time."""
with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers:
mock_id_helpers.generate_id = Mock(side_effect=["exp-item-1", "exp-item-2"])
trace_id_map = {"trace-1": "new-trace-1", "trace-2": "new-trace-2"}
dataset_item_id_map = {
"ds-item-1": "new-ds-item-1",
"ds-item-2": "new-ds-item-2",
}
recreate_experiment(
mock_client,
experiment_data,
"test-project",
trace_id_map,
dataset_item_id_map,
dry_run=False,
debug=False,
)
# Verify batch insert was called ONCE with all items
assert (
mock_client._rest_client.experiments.create_experiment_items.call_count
== 1
)
# Verify it was called with a list of items (batch)
call_args = (
mock_client._rest_client.experiments.create_experiment_items.call_args
)
assert call_args is not None
# Extract experiment_items argument from call_args
experiment_items_arg = None
if hasattr(call_args, "kwargs") and "experiment_items" in call_args.kwargs:
experiment_items_arg = call_args.kwargs["experiment_items"]
elif hasattr(call_args, "args") and call_args.args:
experiment_items_arg = call_args.args[0]
assert experiment_items_arg is not None, (
f"Could not find experiment_items in call_args: {call_args}"
)
assert len(experiment_items_arg) == 2, (
f"Expected 2 items in batch, got {len(experiment_items_arg)}"
)
def test_recreate_experiment_uses_module_names_correctly(
self, mock_client: Mock, experiment_data: ExperimentData
) -> None:
"""Test that module names (id_helpers_module) are used correctly."""
with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers:
mock_id_helpers.generate_id = Mock(return_value="generated-id")
trace_id_map = {"trace-1": "new-trace-1"}
dataset_item_id_map = {"ds-item-1": "new-ds-item-1"}
recreate_experiment(
mock_client,
experiment_data,
"test-project",
trace_id_map,
dataset_item_id_map,
dry_run=False,
debug=False,
)
# Verify id_helpers module is used (not checked for None)
assert mock_id_helpers.generate_id.called
def test_recreate_experiment_handles_empty_trace_id_map(
self, mock_client: Mock, experiment_data: ExperimentData
) -> None:
"""Test that empty trace_id_map is handled correctly."""
trace_id_map: Dict[str, str] = {} # Empty but valid
dataset_item_id_map: Dict[str, str] = {} # Empty but valid
recreate_experiment(
mock_client,
experiment_data,
"test-project",
trace_id_map,
dataset_item_id_map,
dry_run=False,
debug=False,
)
# Should still create experiment and dataset, but skip items
assert mock_client.get_or_create_dataset.called
assert mock_client.create_experiment.called
def test_recreate_experiment_dry_run(
self, mock_client: Mock, experiment_data: ExperimentData
) -> None:
"""Test dry run mode."""
trace_id_map = {"trace-1": "new-trace-1"}
dataset_item_id_map = {"ds-item-1": "new-ds-item-1"}
result = recreate_experiment(
mock_client,
experiment_data,
"test-project",
trace_id_map,
dataset_item_id_map,
dry_run=True,
debug=False,
)
assert result is True
# Should not create anything in dry run
assert not mock_client.get_or_create_dataset.called
assert not mock_client.create_experiment.called
def test_recreate_experiment_chunks_items_within_be_cap(
self, mock_client: Mock
) -> None:
# BE rejects a single ``create_experiment_items`` POST whose item
# count exceeds ``ExperimentItemsBatch``'s ``@Size(max=…)``. The
# actual cap value is BE-configured and may change over time, so
# we read it from the module-level constant the chunker uses and
# assert behavior against that, not against a hardcoded literal.
# Contract: every batch ``<= cap``, total across batches ==
# full item count, multiple batches when items > cap.
from opik.cli.imports.experiment import _EXPERIMENT_ITEMS_INSERT_BATCH_SIZE
cap = _EXPERIMENT_ITEMS_INSERT_BATCH_SIZE
# 2.5 × cap exercises the multi-batch path with a partial-last-
# batch remainder; any value above ``cap`` works for the contract.
n_items = cap * 5 // 2
experiment_data = ExperimentData(
experiment={
"id": "exp-big",
"name": "big-experiment",
"dataset_name": "test-dataset",
"type": "regular",
},
items=[
{
"trace_id": f"trace-{i}",
"dataset_item_id": f"ds-item-{i}",
"dataset_item_data": {"input": f"test input {i}"},
}
for i in range(n_items)
],
)
trace_id_map = {f"trace-{i}": f"new-trace-{i}" for i in range(n_items)}
dataset_item_id_map = {
f"ds-item-{i}": f"new-ds-item-{i}" for i in range(n_items)
}
with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers:
mock_id_helpers.generate_id = Mock(
side_effect=[f"exp-item-{i}" for i in range(n_items)]
)
recreate_experiment(
mock_client,
experiment_data,
"test-project",
trace_id_map,
dataset_item_id_map,
dry_run=False,
debug=False,
)
create_calls = mock_client._rest_client.experiments.create_experiment_items.call_args_list
# ceil(n_items / cap) batched POSTs.
expected_batches = (n_items + cap - 1) // cap
assert len(create_calls) == expected_batches, (
f"expected {expected_batches} chunked create_experiment_items "
f"calls for {n_items} items at cap={cap}, got {len(create_calls)}"
)
# Every batch must respect the BE cap; total must equal the
# full item count (nothing dropped, nothing duplicated).
batch_sizes = [
len(call.kwargs.get("experiment_items", []))
if call.kwargs.get("experiment_items") is not None
else len(call.args[0])
for call in create_calls
]
assert all(size <= cap for size in batch_sizes), (
f"every batch must respect the BE cap (={cap}), got {batch_sizes}"
)
assert sum(batch_sizes) == n_items, (
f"all {n_items} items must end up across the chunked batches, "
f"got sum={sum(batch_sizes)}"
)
class TestImportTracesWithSpans:
"""Test trace import with span parent_span_id preservation."""
@pytest.fixture
def mock_client(self) -> Mock:
"""Create a mock Opik client."""
client = Mock()
client.flush = Mock()
# Mock trace creation
mock_trace = Mock()
mock_trace.id = "new-trace-1"
client.trace = Mock(return_value=mock_trace)
# Mock span creation
mock_spans = []
for i in range(3):
mock_span = Mock()
mock_span.id = f"new-span-{i + 1}"
mock_spans.append(mock_span)
client.span = Mock(side_effect=mock_spans)
return client
def test_import_traces_preserves_span_hierarchy(
self, mock_client: Mock, tmp_path: Path
) -> None:
"""Test that span parent_span_id relationships are preserved."""
# Create test trace file with spans
projects_dir = tmp_path / "projects" / "test-project"
projects_dir.mkdir(parents=True)
trace_data = {
"trace": {
"id": "original-trace-1",
"name": "test-trace",
"input": {},
"output": {},
},
"spans": [
{
"id": "span-1",
"name": "root-span",
"parent_span_id": None, # Root span
"input": {},
"output": {},
},
{
"id": "span-2",
"name": "child-span",
"parent_span_id": "span-1", # Child of span-1
"input": {},
"output": {},
},
{
"id": "span-3",
"name": "grandchild-span",
"parent_span_id": "span-2", # Child of span-2
"input": {},
"output": {},
},
],
}
trace_file = projects_dir / "trace_original-trace-1.json"
with open(trace_file, "w") as f:
json.dump(trace_data, f)
# Import traces
trace_id_map, _ = _import_traces_for_project(
mock_client, projects_dir, "test-project", dry_run=False, debug=False
)
# Verify spans were created
assert mock_client.span.call_count == 3
# Verify spans were created in correct order (root first, then children)
span_calls = mock_client.span.call_args_list
# First span should be root (no parent_span_id)
first_call = span_calls[0]
assert first_call.kwargs.get("parent_span_id") is None
# Second span should have parent_span_id set to first span's new ID
# Note: We can't easily verify the exact ID mapping without more complex mocking,
# but we can verify that parent_span_id is being passed
second_call = span_calls[1]
# The parent_span_id should be set (not None) since span-1 was created first
# and its new ID should be in span_id_map
assert "parent_span_id" in second_call.kwargs
# Third span (grandchild) should have parent_span_id set to second span's new ID
third_call = span_calls[2]
assert "parent_span_id" in third_call.kwargs
assert third_call.kwargs.get("parent_span_id") is not None
# Verify trace was created
assert mock_client.trace.called
assert "original-trace-1" in trace_id_map
def test_import_traces_preserves_deep_hierarchy(
self, mock_client: Mock, tmp_path: Path
) -> None:
"""Test that deep hierarchies (4+ levels) are preserved correctly."""
projects_dir = tmp_path / "projects" / "test-project"
projects_dir.mkdir(parents=True)
# Create a 4-level hierarchy: root -> child -> grandchild -> great-grandchild
# Spans are intentionally in wrong order to test sorting
trace_data = {
"trace": {
"id": "original-trace-1",
"name": "test-trace",
"input": {},
"output": {},
},
"spans": [
{
"id": "span-4",
"name": "great-grandchild",
"parent_span_id": "span-3",
"input": {},
"output": {},
},
{
"id": "span-2",
"name": "child",
"parent_span_id": "span-1",
"input": {},
"output": {},
},
{
"id": "span-1",
"name": "root",
"parent_span_id": None,
"input": {},
"output": {},
},
{
"id": "span-3",
"name": "grandchild",
"parent_span_id": "span-2",
"input": {},
"output": {},
},
],
}
trace_file = projects_dir / "trace_original-trace-1.json"
with open(trace_file, "w") as f:
json.dump(trace_data, f)
# Import traces
trace_id_map, _ = _import_traces_for_project(
mock_client, projects_dir, "test-project", dry_run=False, debug=False
)
# Verify all spans were created
assert mock_client.span.call_count == 4
span_calls = mock_client.span.call_args_list
# Verify order: root -> child -> grandchild -> great-grandchild
# First span should be root (no parent)
assert span_calls[0].kwargs.get("parent_span_id") is None
# Second span should be child (has parent)
assert span_calls[1].kwargs.get("parent_span_id") is not None
# Third span should be grandchild (has parent)
assert span_calls[2].kwargs.get("parent_span_id") is not None
# Fourth span should be great-grandchild (has parent)
assert span_calls[3].kwargs.get("parent_span_id") is not None
# Verify trace was created
assert mock_client.trace.called
assert "original-trace-1" in trace_id_map
def test_import_traces_sorts_spans_correctly(
self, mock_client: Mock, tmp_path: Path
) -> None:
"""Test that spans are sorted (root spans first, then children)."""
projects_dir = tmp_path / "projects" / "test-project"
projects_dir.mkdir(parents=True)
trace_data = {
"trace": {
"id": "original-trace-1",
"name": "test-trace",
"input": {},
"output": {},
},
"spans": [
{
"id": "span-2",
"name": "child-span",
"parent_span_id": "span-1", # Child - should come after root
"input": {},
"output": {},
},
{
"id": "span-1",
"name": "root-span",
"parent_span_id": None, # Root - should come first
"input": {},
"output": {},
},
],
}
trace_file = projects_dir / "trace_original-trace-1.json"
with open(trace_file, "w") as f:
json.dump(trace_data, f)
# Import traces
_, _ = _import_traces_for_project(
mock_client, projects_dir, "test-project", dry_run=False, debug=False
) # Returns (trace_id_map, stats), but we don't need them for this test
# Verify spans were created in correct order
span_calls = mock_client.span.call_args_list
# First span should be root (no parent)
assert span_calls[0].kwargs.get("parent_span_id") is None
# Second span should have parent_span_id
assert span_calls[1].kwargs.get("parent_span_id") is not None
class TestTopologicalSort:
"""Test the topological sort function for spans."""
def test_sort_spans_simple_hierarchy(self) -> None:
"""Test sorting with a simple 2-level hierarchy."""
spans = [
{"id": "span-2", "name": "child", "parent_span_id": "span-1"},
{"id": "span-1", "name": "root", "parent_span_id": None},
]
sorted_spans = sort_spans_topologically(spans)
# Root should come first
assert sorted_spans[0]["id"] == "span-1"
assert sorted_spans[0]["parent_span_id"] is None
# Child should come second
assert sorted_spans[1]["id"] == "span-2"
assert sorted_spans[1]["parent_span_id"] == "span-1"
def test_sort_spans_multi_level_hierarchy(self) -> None:
"""Test sorting with a 4-level hierarchy (root -> child -> grandchild -> great-grandchild)."""
spans = [
{"id": "span-4", "name": "great-grandchild", "parent_span_id": "span-3"},
{"id": "span-2", "name": "child", "parent_span_id": "span-1"},
{"id": "span-1", "name": "root", "parent_span_id": None},
{"id": "span-3", "name": "grandchild", "parent_span_id": "span-2"},
]
sorted_spans = sort_spans_topologically(spans)
# Verify order: root -> child -> grandchild -> great-grandchild
assert sorted_spans[0]["id"] == "span-1"
assert sorted_spans[0]["parent_span_id"] is None
assert sorted_spans[1]["id"] == "span-2"
assert sorted_spans[1]["parent_span_id"] == "span-1"
assert sorted_spans[2]["id"] == "span-3"
assert sorted_spans[2]["parent_span_id"] == "span-2"
assert sorted_spans[3]["id"] == "span-4"
assert sorted_spans[3]["parent_span_id"] == "span-3"
def test_sort_spans_multiple_roots(self) -> None:
"""Test sorting with multiple root spans."""
spans = [
{"id": "span-3", "name": "child-of-2", "parent_span_id": "span-2"},
{"id": "span-1", "name": "root-1", "parent_span_id": None},
{"id": "span-2", "name": "root-2", "parent_span_id": None},
]
sorted_spans = sort_spans_topologically(spans)
# Both roots should come before the child
root_ids = {sorted_spans[0]["id"], sorted_spans[1]["id"]}
assert root_ids == {"span-1", "span-2"}
assert sorted_spans[0]["parent_span_id"] is None
assert sorted_spans[1]["parent_span_id"] is None
# Child should come last
assert sorted_spans[2]["id"] == "span-3"
assert sorted_spans[2]["parent_span_id"] == "span-2"
def test_sort_spans_multiple_children(self) -> None:
"""Test sorting with a root that has multiple children."""
spans = [
{"id": "span-3", "name": "child-2", "parent_span_id": "span-1"},
{"id": "span-1", "name": "root", "parent_span_id": None},
{"id": "span-2", "name": "child-1", "parent_span_id": "span-1"},
]
sorted_spans = sort_spans_topologically(spans)
# Root should come first
assert sorted_spans[0]["id"] == "span-1"
assert sorted_spans[0]["parent_span_id"] is None
# Both children should come after root (order doesn't matter for siblings)
child_ids = {sorted_spans[1]["id"], sorted_spans[2]["id"]}
assert child_ids == {"span-2", "span-3"}
assert sorted_spans[1]["parent_span_id"] == "span-1"
assert sorted_spans[2]["parent_span_id"] == "span-1"
def test_sort_spans_missing_parent(self) -> None:
"""Test sorting when a span references a non-existent parent."""
spans = [
{"id": "span-1", "name": "root", "parent_span_id": None},
{"id": "span-2", "name": "orphan", "parent_span_id": "nonexistent"},
]
sorted_spans = sort_spans_topologically(spans)
# Both should be treated as roots (orphan becomes root)
assert len(sorted_spans) == 2
# Both should have no parent or invalid parent
for span in sorted_spans:
assert (
span["parent_span_id"] is None
or span["parent_span_id"] == "nonexistent"
)
def test_sort_spans_empty_list(self) -> None:
"""Test sorting with empty list."""
spans: list = []
sorted_spans = sort_spans_topologically(spans)
assert sorted_spans == []
def test_sort_spans_single_root(self) -> None:
"""Test sorting with single root span."""
spans = [{"id": "span-1", "name": "root", "parent_span_id": None}]
sorted_spans = sort_spans_topologically(spans)
assert len(sorted_spans) == 1
assert sorted_spans[0]["id"] == "span-1"
assert sorted_spans[0]["parent_span_id"] is None
def test_sort_spans_all_have_parents(self) -> None:
"""Test sorting when all spans have parents (no explicit root).
This tests the fix for the bug where empty root_spans would cause
the function to return an empty list, silently dropping all spans.
"""
spans = [
{"id": "span-1", "name": "child-1", "parent_span_id": "span-2"},
{"id": "span-2", "name": "child-2", "parent_span_id": "span-1"},
]
sorted_spans = sort_spans_topologically(spans)
# Should not return empty list - all spans should be included
assert len(sorted_spans) == 2
# Verify all spans are present
span_ids = {span["id"] for span in sorted_spans}
assert span_ids == {"span-1", "span-2"}
def test_sort_spans_cycle(self) -> None:
"""Test sorting with a cycle in the span graph.
This tests that cycles don't cause infinite loops and all spans
are still included in the result.
"""
spans = [
{"id": "span-1", "name": "span-1", "parent_span_id": "span-2"},
{"id": "span-2", "name": "span-2", "parent_span_id": "span-3"},
{"id": "span-3", "name": "span-3", "parent_span_id": "span-1"},
]
sorted_spans = sort_spans_topologically(spans)
# Should not return empty list - all spans should be included
assert len(sorted_spans) == 3
# Verify all spans are present
span_ids = {span["id"] for span in sorted_spans}
assert span_ids == {"span-1", "span-2", "span-3"}
def test_sort_spans_disconnected_components(self) -> None:
"""Test sorting with disconnected components (multiple separate graphs).
This tests that spans not reachable from root spans are still included.
"""
spans = [
{"id": "span-1", "name": "root-1", "parent_span_id": None},
{"id": "span-2", "name": "child-1", "parent_span_id": "span-1"},
{"id": "span-3", "name": "disconnected-1", "parent_span_id": "span-4"},
{"id": "span-4", "name": "disconnected-2", "parent_span_id": "span-3"},
]
sorted_spans = sort_spans_topologically(spans)
# All spans should be included
assert len(sorted_spans) == 4
# Verify all spans are present
span_ids = {span["id"] for span in sorted_spans}
assert span_ids == {"span-1", "span-2", "span-3", "span-4"}
# Root span should come first
assert sorted_spans[0]["id"] == "span-1"
assert sorted_spans[0]["parent_span_id"] is None
class TestModuleNameUsage:
"""Test that module names are used correctly (not checked for None)."""
def test_module_names_are_modules_not_variables(self) -> None:
"""Test that dataset_item_module and id_helpers_module are modules."""
from opik.cli.imports.experiment import dataset_item_module, id_helpers_module
# Modules should exist and be importable
assert dataset_item_module is not None
assert id_helpers_module is not None
# They should be modules, not None
assert isinstance(dataset_item_module, types.ModuleType)
assert isinstance(id_helpers_module, types.ModuleType)
class TestProjectTraceImport:
"""Test importing a project's traces from its project directory."""
@pytest.fixture
def mock_client(self) -> Mock:
"""Create a minimal mock Opik client."""
client = Mock()
client.flush = Mock()
mock_trace = Mock()
mock_trace.id = "new-trace-id"
client.trace = Mock(return_value=mock_trace)
client.span = Mock()
return client
def test_trace_files_in_project_dir_are_imported(
self, mock_client: Mock, tmp_path: Path
) -> None:
"""trace_{id}.json files directly under the project dir are imported and
appear in the returned trace_id_map, and the trace is created in the
named project."""
project_dir = tmp_path / "projects" / "my-project"
project_dir.mkdir(parents=True)
trace_id = "abc123"
trace_data: Dict[str, Any] = {
"trace": {"id": trace_id, "name": "t", "input": {}, "output": {}},
"spans": [],
}
trace_file = project_dir / f"trace_{trace_id}.json"
with open(trace_file, "w") as f:
json.dump(trace_data, f)
trace_id_map, _ = _import_traces_for_project(
mock_client, project_dir, "my-project", dry_run=False, debug=False
)
# The original trace ID must appear in the returned map
assert trace_id in trace_id_map
# The trace must be created in the named project (no "default" fallback)
assert mock_client.trace.call_args.kwargs.get("project_name") == "my-project"
def test_empty_project_dir_returns_empty_map(
self, mock_client: Mock, tmp_path: Path
) -> None:
"""A project dir with no trace files yields an empty map without error."""
project_dir = tmp_path / "projects" / "empty-project"
project_dir.mkdir(parents=True)
trace_id_map, stats = _import_traces_for_project(
mock_client, project_dir, "empty-project", dry_run=False, debug=False
)
assert trace_id_map == {}
assert stats["traces"] == 0
assert not mock_client.trace.called
+208
View File
@@ -0,0 +1,208 @@
"""Tests for the ``opik mcp configure`` command."""
import pathlib
from unittest.mock import patch
from click.testing import CliRunner
from opik.cli import cli
from opik.cli import mcp as mcp_cli
from opik.config import OpikConfig
def _config(**overrides) -> OpikConfig:
values = dict(url_override="https://www.comet.com/opik/api/", workspace="acme-ai")
values.update(overrides)
return OpikConfig(**values)
class TestResolveSetupParams:
def test_cloud__no_url_flags(self):
params = mcp_cli._resolve_setup_params(
_config(api_key="key", url_override="https://www.comet.com/opik/api/")
)
assert params["use_local"] is False
assert params["self_hosted_comet"] is False
assert params["api_url"] == "https://www.comet.com/opik/api/"
def test_self_hosted_comet__detected_from_opik_api_path(self):
params = mcp_cli._resolve_setup_params(
_config(api_key="key", url_override="https://opik.acme.com/opik/api/")
)
assert params["self_hosted_comet"] is True
assert params["use_local"] is False
assert params["base_url"] == "https://opik.acme.com/"
def test_localhost__is_use_local(self):
params = mcp_cli._resolve_setup_params(
_config(api_key=None, url_override="http://localhost:5173/api/")
)
assert params["use_local"] is True
assert params["self_hosted_comet"] is False
def test_self_hosted_oss__non_opik_path_is_use_local(self):
params = mcp_cli._resolve_setup_params(
_config(api_key=None, url_override="https://opik.acme.com/api/")
)
assert params["use_local"] is True
assert params["self_hosted_comet"] is False
class TestInstallCommand:
def test_install__reads_config_and_calls_setup(self):
runner = CliRunner()
with (
patch.object(
mcp_cli.opik_config, "OpikConfig", return_value=_config(api_key="key")
),
patch.object(
mcp_cli.interactive_helpers, "is_interactive", return_value=True
),
patch.object(mcp_cli.mcp_installer, "setup_mcp_server") as setup_spy,
):
result = runner.invoke(cli, ["mcp", "configure"])
assert result.exit_code == 0
setup_spy.assert_called_once()
assert setup_spy.call_args.kwargs["api_key"] == "key"
assert setup_spy.call_args.kwargs["workspace"] == "acme-ai"
assert setup_spy.call_args.kwargs["force_local_server"] is False
def test_install__local_server_flag__forces_local(self):
runner = CliRunner()
with (
patch.object(
mcp_cli.opik_config, "OpikConfig", return_value=_config(api_key="key")
),
patch.object(
mcp_cli.interactive_helpers, "is_interactive", return_value=True
),
patch.object(mcp_cli.mcp_installer, "setup_mcp_server") as setup_spy,
):
result = runner.invoke(cli, ["mcp", "configure", "--local-server"])
assert result.exit_code == 0
setup_spy.assert_called_once()
assert setup_spy.call_args.kwargs["force_local_server"] is True
def test_install__non_interactive__errors(self):
runner = CliRunner()
with (
patch.object(
mcp_cli.interactive_helpers, "is_interactive", return_value=False
),
patch.object(mcp_cli.mcp_installer, "setup_mcp_server") as setup_spy,
):
result = runner.invoke(cli, ["mcp", "configure"])
assert result.exit_code != 0
assert "interactive terminal" in result.output
setup_spy.assert_not_called()
def test_install__no_config_user_declines__errors(self):
runner = CliRunner()
with (
patch.object(
mcp_cli.opik_config, "OpikConfig", return_value=_config(api_key=None)
),
patch.object(
mcp_cli.interactive_helpers, "is_interactive", return_value=True
),
patch.object(
mcp_cli.configure_cli, "run_interactive_configure"
) as configure_spy,
patch.object(mcp_cli.mcp_installer, "setup_mcp_server") as setup_spy,
):
result = runner.invoke(cli, ["mcp", "configure"], input="n\n")
assert result.exit_code != 0
assert "opik configure" in result.output
configure_spy.assert_not_called()
setup_spy.assert_not_called()
def test_install__no_config_user_accepts__runs_configure_then_installs(self):
runner = CliRunner()
configs = iter([_config(api_key=None), _config(api_key="new-key")])
with (
patch.object(
mcp_cli.opik_config, "OpikConfig", side_effect=lambda: next(configs)
),
patch.object(
mcp_cli.interactive_helpers, "is_interactive", return_value=True
),
patch.object(
mcp_cli.configure_cli, "run_interactive_configure"
) as configure_spy,
patch.object(mcp_cli.mcp_installer, "setup_mcp_server") as setup_spy,
):
result = runner.invoke(cli, ["mcp", "configure"], input="y\n")
assert result.exit_code == 0
configure_spy.assert_called_once_with(install_mcp=False)
setup_spy.assert_called_once()
assert setup_spy.call_args.kwargs["api_key"] == "new-key"
def test_status__lists_sdk_env_and_host_drift(self):
runner = CliRunner()
host = mcp_cli.mcp_status.HostStatus(
display_name="Claude Code",
config_path=pathlib.Path("/home/u/.claude.json"),
detected=True,
registered=True,
transport=mcp_cli.mcp_status.TRANSPORT_LOCAL,
points_to="http://localhost:5173/api/",
workspace="default",
in_sync=False,
)
with (
patch.object(
mcp_cli.opik_config, "OpikConfig", return_value=_config(api_key="key")
),
patch.object(
mcp_cli.mcp_status, "collect_host_statuses", return_value=[host]
),
):
result = runner.invoke(cli, ["mcp", "status"])
assert result.exit_code == 0
assert "Your Opik configuration" in result.output
assert "configured for 1 AI assistant" in result.output
assert "Claude Code" in result.output
assert "OUT OF SYNC with your Opik configuration" in result.output
assert "http://localhost:5173/api/" in result.output
assert "default" in result.output
def test_status__none_configured__suggests_configure(self):
runner = CliRunner()
with (
patch.object(
mcp_cli.opik_config, "OpikConfig", return_value=_config(api_key="key")
),
patch.object(mcp_cli.mcp_status, "collect_host_statuses", return_value=[]),
):
result = runner.invoke(cli, ["mcp", "status"])
assert result.exit_code == 0
assert "not configured for any AI assistant" in result.output
assert "opik mcp configure" in result.output
def test_install__local_without_api_key__proceeds(self):
runner = CliRunner()
with (
patch.object(
mcp_cli.opik_config,
"OpikConfig",
return_value=_config(
api_key=None, url_override="http://localhost:5173/api/"
),
),
patch.object(
mcp_cli.interactive_helpers, "is_interactive", return_value=True
),
patch.object(mcp_cli.mcp_installer, "setup_mcp_server") as setup_spy,
):
result = runner.invoke(cli, ["mcp", "configure"])
assert result.exit_code == 0
setup_spy.assert_called_once()
assert setup_spy.call_args.kwargs["use_local"] is True
@@ -0,0 +1,553 @@
"""Tests for ``opik migrate dataset`` checkpoint/resume (OPIK-7168).
Two layers:
* ``TestMigrationCheckpoint`` -- the checkpoint store in isolation
(``opik.cli.migrate.checkpoint``): key derivation, atomic round-trip,
in-flight tracking, corrupt/foreign-schema tolerance, delete lifecycle.
* ``TestCascadeResume`` -- the cascade's resume behaviour
(``cascade_experiments`` with a ``checkpoint``): skip already-completed
experiments, re-migrate an interrupted experiment after deleting its partial
destination data, and seed the progress callback so a resumed run reports the
right completed count instead of starting at 0.
The cascade tests reuse the elaborate REST/client fakes from
``test_migrate_dataset_experiments_cascade`` so the resume behaviour is
exercised against the same call surface the real cascade drives.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, List, Tuple
from unittest.mock import MagicMock
import pytest
from opik.cli.migrate import checkpoint as checkpoint_module
from opik.cli.migrate.checkpoint import (
SCHEMA_VERSION,
MigrationCheckpoint,
checkpoint_key,
checkpoint_path,
load_or_create,
)
from opik.cli.migrate.datasets.experiments import cascade_experiments
from .test_migrate_dataset_experiments_cascade import (
_Experiment,
_ExperimentItem,
_Trace,
_audit,
_cascade_rest_client,
_client_with_recreate_capture,
)
# ---------------------------------------------------------------------------
# Checkpoint store (unit)
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _isolate_checkpoint_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Point the checkpoint store at a tmp dir instead of the real ~/.opik.
The checkpoint now lives at a fixed per-user path (``checkpoint_dir``),
independent of cwd/--audit-log. Redirecting it here keeps the unit tests
hermetic and off the developer's home directory.
"""
cp_dir = tmp_path / "migrate-checkpoints"
cp_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(checkpoint_module, "checkpoint_dir", lambda: cp_dir)
class TestMigrationCheckpoint:
def test_checkpoint_key__stable_and_distinct_per_tuple(self) -> None:
key = checkpoint_key("ws", "proj", "ds")
# Deterministic: same tuple -> same key across calls.
assert key == checkpoint_key("ws", "proj", "ds")
# Distinct tuples -> distinct keys, including the ambiguous shift of a
# separator across the three components (the \x00 join prevents
# "a"+"bc" colliding with "ab"+"c").
assert checkpoint_key("a", "bc", "d") != checkpoint_key("ab", "c", "d")
def test_checkpoint_path__lives_in_checkpoint_dir_keyed_by_hash(self) -> None:
key = checkpoint_key("ws", "proj", "ds")
path = checkpoint_path(key)
assert path.parent == checkpoint_module.checkpoint_dir()
assert path.name == f"opik-migrate-checkpoint-{key}.json"
def test_load_or_create__no_file__starts_fresh(self, tmp_path: Path) -> None:
cp = load_or_create(
workspace="ws",
project="proj",
dataset="ds",
)
assert cp.completed_count == 0
assert cp.in_flight is None
assert not cp.path.exists()
def test_flush_then_load__round_trips_completed_and_in_flight(
self, tmp_path: Path
) -> None:
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
cp.total_experiments = 3
cp.mark_in_flight(
"src-exp-2", experiment_name="exp-2", dest_dataset_id="dest-ds"
)
cp.record_dest_trace_ids(["dest-trace-a", "dest-trace-b"])
cp.mark_completed("src-exp-1")
cp.flush()
reloaded = load_or_create(workspace="ws", project="proj", dataset="ds")
assert reloaded.total_experiments == 3
assert reloaded.completed_experiment_ids == {"src-exp-1"}
# ``mark_completed`` clears in_flight, so only the completed set survives.
assert reloaded.in_flight is None
def test_flush_preserves_in_flight_when_not_completed(self, tmp_path: Path) -> None:
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
cp.mark_in_flight(
"src-exp-2", experiment_name="exp-2", dest_dataset_id="dest-ds"
)
cp.record_dest_trace_ids(["dest-trace-a"])
cp.flush()
reloaded = load_or_create(workspace="ws", project="proj", dataset="ds")
assert reloaded.in_flight is not None
assert reloaded.in_flight.source_experiment_id == "src-exp-2"
assert reloaded.in_flight.experiment_name == "exp-2"
assert reloaded.in_flight.dest_dataset_id == "dest-ds"
assert reloaded.in_flight.dest_trace_ids == ["dest-trace-a"]
def test_load_or_create__corrupt_file__starts_fresh(self, tmp_path: Path) -> None:
# A truncated / unparseable checkpoint (e.g. an interrupted write on a
# filesystem without atomic replace) must be treated as "no progress",
# not crash the migration.
key = checkpoint_key("ws", "proj", "ds")
checkpoint_path(key).write_text('{"schema_version": 1, "comp')
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp.completed_count == 0
assert cp.in_flight is None
def test_load_or_create__non_bool_dataset_phase_done__starts_fresh(
self, tmp_path: Path
) -> None:
# ``dataset_phase_done`` gates resume-vs-full-plan, so a truthy non-bool
# (the string "false", a non-empty list) must NOT be coerced to True and
# force a resume that skips rename/create/replay. A non-bool is
# malformed -> fresh.
key = checkpoint_key("ws", "proj", "ds")
for bad in ('"false"', "[1]", "1"):
# Use the CURRENT schema so the load reaches the non-bool guard
# rather than short-circuiting on a schema-version mismatch.
checkpoint_path(key).write_text(
f'{{"schema_version": {SCHEMA_VERSION}, "dataset_phase_done": {bad}}}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp.dataset_phase_done is False
def test_load_or_create__malformed_in_flight__starts_fresh(
self, tmp_path: Path
) -> None:
# A checkpoint with the right schema_version but a wrong-shaped
# ``in_flight`` (here a bare string, or a dict missing the required
# ``source_experiment_id``) must fall back to fresh rather than crash
# the CLI with a TypeError/KeyError.
key = checkpoint_key("ws", "proj", "ds")
for bad_in_flight in ('"not-a-dict"', '{"experiment_name": "x"}'):
checkpoint_path(key).write_text(
'{"schema_version": 1, "completed_experiment_ids": ["e1"], '
f'"in_flight": {bad_in_flight}}}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp.completed_count == 0
assert cp.in_flight is None
def test_load_or_create__corrupt_completed_ids__starts_fresh(
self, tmp_path: Path
) -> None:
# ``set("abc")`` / ``set([1, 2])`` don't raise, so a corrupt
# ``completed_experiment_ids`` (a bare string, or a list with non-string
# entries) would silently seed the wrong completed set and make the
# cascade skip/re-run the wrong experiments. It must fall back to fresh.
key = checkpoint_key("ws", "proj", "ds")
for bad in ('"abc"', "[1, 2, 3]", "{}"):
checkpoint_path(key).write_text(
f'{{"schema_version": 1, "completed_experiment_ids": {bad}}}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp.completed_experiment_ids == set()
def test_load_or_create__corrupt_dest_trace_ids__starts_fresh(
self, tmp_path: Path
) -> None:
# Same silent-corruption guard for the in-flight trace-id list.
key = checkpoint_key("ws", "proj", "ds")
checkpoint_path(key).write_text(
'{"schema_version": 1, "in_flight": '
'{"source_experiment_id": "e2", "dest_trace_ids": "trace-1"}}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp.in_flight is None
assert cp.completed_count == 0
def test_load_or_create__non_string_resume_names__starts_fresh(
self, tmp_path: Path
) -> None:
# OPIK-7162: source_dataset_id / source_name / temp_dest_name are the
# resume handles that flow unvalidated into client.get_dataset(name=...)
# and stream_dataset_items(dataset_name=...). A hand-edited non-string
# would otherwise reach the API; it must fall back to fresh, matching
# the same corrupt-recovery contract as the id collections. Uses the
# CURRENT schema so the load reaches the field guard (not the schema
# short-circuit).
key = checkpoint_key("ws", "proj", "ds")
for field in ("source_dataset_id", "source_name", "temp_dest_name"):
for bad in ("[1]", "{}", "5"):
checkpoint_path(key).write_text(
f'{{"schema_version": {SCHEMA_VERSION}, '
f'"dataset_phase_done": true, "{field}": {bad}}}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
# Fell back to fresh: no phase-done carried over from the bad file.
assert cp.dataset_phase_done is False
assert getattr(cp, field) is None
def test_load_or_create__unresolvable_home__returns_none(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# A homeless environment (Path.home() raising, as in some CI/containers)
# must NOT crash the migration before it starts -- load_or_create
# returns None and the caller runs without resume support.
def _boom() -> Path:
raise RuntimeError("Could not determine home directory")
monkeypatch.setattr(checkpoint_module, "checkpoint_dir", _boom, raising=True)
assert load_or_create(workspace="ws", project="proj", dataset="ds") is None
def test_flush__write_failure__swallowed_not_raised(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# A checkpoint is only a resume aid: a read-only / full disk on flush
# must be logged and swallowed, never abort an otherwise-healthy
# migration.
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp is not None
def _readonly(*_a: Any, **_k: Any) -> None:
raise OSError("Read-only file system")
monkeypatch.setattr(Path, "mkdir", _readonly, raising=True)
cp.flush() # must not raise
def test_flush_then_load__round_trips_dest_experiment_id(
self, tmp_path: Path
) -> None:
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
cp.mark_in_flight(
"src-exp-2", experiment_name="exp-2", dest_dataset_id="dest-ds"
)
cp.record_dest_experiment_id("dest-exp-99")
cp.flush()
reloaded = load_or_create(workspace="ws", project="proj", dataset="ds")
assert reloaded.in_flight is not None
assert reloaded.in_flight.dest_experiment_id == "dest-exp-99"
def test_load_or_create__foreign_schema_version__starts_fresh(
self, tmp_path: Path
) -> None:
key = checkpoint_key("ws", "proj", "ds")
checkpoint_path(key).write_text(
'{"schema_version": 999, "completed_experiment_ids": ["src-exp-1"]}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
# A newer schema we can't interpret is ignored -> fresh start.
assert cp.completed_count == 0
def test_delete__removes_file_and_is_idempotent(self, tmp_path: Path) -> None:
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
cp.flush()
assert cp.path.exists()
cp.delete()
assert not cp.path.exists()
# Idempotent: deleting again is a no-op, not an error.
cp.delete()
def test_flush__atomic__no_stray_tmp_file_left(self, tmp_path: Path) -> None:
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
cp.flush()
# The temp file used for the atomic write must have been renamed away.
tmp_files = list(tmp_path.glob("*.tmp"))
assert tmp_files == []
# ---------------------------------------------------------------------------
# Cascade resume behaviour
# ---------------------------------------------------------------------------
def _two_experiment_rig() -> Tuple[Any, Any]:
"""Two source experiments, each with one item/trace, wired end-to-end.
Returns ``(rest_client, client)`` ready to pass to ``cascade_experiments``.
"""
exp1 = _Experiment(id="src-exp-1", name="exp-1", dataset_version_id="src-v-1")
exp2 = _Experiment(id="src-exp-2", name="exp-2", dataset_version_id="src-v-1")
item1 = _ExperimentItem(
id="i1",
experiment_id="src-exp-1",
trace_id="src-trace-1",
dataset_item_id="src-ds-item-1",
)
item2 = _ExperimentItem(
id="i2",
experiment_id="src-exp-2",
trace_id="src-trace-2",
dataset_item_id="src-ds-item-1",
)
rest_client = _cascade_rest_client(
experiments_by_dataset={"src-dataset-1": [exp1, exp2]},
items_by_experiment={"exp-1": [item1], "exp-2": [item2]},
traces_by_id={
"src-trace-1": _Trace(id="src-trace-1"),
"src-trace-2": _Trace(id="src-trace-2"),
},
spans_by_trace={"src-trace-1": [], "src-trace-2": []},
)
client = _client_with_recreate_capture(rest_client)
return rest_client, client
def _run_cascade(
rest_client: Any, client: Any, checkpoint: MigrationCheckpoint, **overrides: Any
) -> Tuple[Any, List[Tuple[int, int, str]]]:
"""Run ``cascade_experiments`` with a progress-capturing callback."""
progress_calls: List[Tuple[int, int, str]] = []
kwargs: dict = dict(
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
target_dataset_id="dest-dataset-1",
version_remap={"src-v-1": "dest-v-1"},
item_id_remap={"src-ds-item-1": "dest-ds-item-1"},
audit=_audit(),
checkpoint=checkpoint,
progress_callback=lambda c, t, label: progress_calls.append((c, t, label)),
)
kwargs.update(overrides)
result = cascade_experiments(client, rest_client, **kwargs)
return result, progress_calls
class TestCascadeResume:
def test_skip_completed__does_not_recreate_already_done_experiment(
self, tmp_path: Path
) -> None:
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
# Pretend exp-1 already migrated on a prior run.
cp.mark_completed("src-exp-1")
result, _ = _run_cascade(rest_client, client, cp)
# Only the not-yet-done experiment is recreated.
assert result.experiments_migrated == 1
assert client.create_experiment.call_count == 1
created_names = [
call.kwargs["name"] for call in client.create_experiment.call_args_list
]
assert created_names == ["exp-2"]
def test_skip_completed__all_done__no_recreation(self, tmp_path: Path) -> None:
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
cp.mark_completed("src-exp-1")
cp.mark_completed("src-exp-2")
result, _ = _run_cascade(rest_client, client, cp)
assert result.experiments_migrated == 0
client.create_experiment.assert_not_called()
def test_happyflow__marks_each_experiment_completed_and_flushes(
self, tmp_path: Path
) -> None:
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
result, _ = _run_cascade(rest_client, client, cp)
assert result.experiments_migrated == 2
assert cp.completed_experiment_ids == {"src-exp-1", "src-exp-2"}
# in_flight is cleared once the last experiment completes.
assert cp.in_flight is None
# Progress was flushed to disk (a re-run would see both done).
reloaded = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
assert reloaded.completed_experiment_ids == {"src-exp-1", "src-exp-2"}
def test_re_migrate_incomplete__deletes_partial_dest_data_before_rerun(
self, tmp_path: Path
) -> None:
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
# exp-1 finished on the prior run; exp-2 was interrupted mid-flight with
# two destination traces and its destination experiment row already
# created (id recorded before creation).
cp.mark_completed("src-exp-1")
cp.mark_in_flight(
"src-exp-2", experiment_name="exp-2", dest_dataset_id="dest-dataset-1"
)
cp.record_dest_trace_ids(["stale-dest-trace-1", "stale-dest-trace-2"])
cp.record_dest_experiment_id("stale-dest-exp")
_run_cascade(rest_client, client, cp)
# Partial traces were deleted (spans cascade on the BE), so they don't
# duplicate on the re-run.
rest_client.traces.delete_traces.assert_called_once()
deleted_trace_ids = rest_client.traces.delete_traces.call_args.kwargs["ids"]
assert set(deleted_trace_ids) == {"stale-dest-trace-1", "stale-dest-trace-2"}
# The exact recorded destination experiment row was deleted BY ID --
# cleanup never does a name lookup, so no same-named peer can be hit.
rest_client.experiments.delete_experiments_by_id.assert_called_once()
deleted_exp_ids = (
rest_client.experiments.delete_experiments_by_id.call_args.kwargs["ids"]
)
assert deleted_exp_ids == ["stale-dest-exp"]
# in_flight is cleared after cleanup so a further crash won't re-delete.
assert cp.in_flight is None
def test_re_migrate_incomplete__no_experiment_row_yet__only_traces_deleted(
self, tmp_path: Path
) -> None:
# Interruption landed after traces were flushed but before the
# experiment row was created: dest_experiment_id is None, so cleanup
# deletes the traces and skips the experiment delete entirely (no
# name lookup that could hit a peer).
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
cp.mark_in_flight(
"src-exp-1", experiment_name="exp-1", dest_dataset_id="dest-dataset-1"
)
cp.record_dest_trace_ids(["stale-dest-trace-1"])
# no record_dest_experiment_id -> dest_experiment_id stays None
_run_cascade(rest_client, client, cp)
rest_client.traces.delete_traces.assert_called_once()
rest_client.experiments.delete_experiments_by_id.assert_not_called()
def test_re_migrate_incomplete__records_dest_ids_before_backend_write(
self, tmp_path: Path
) -> None:
# OPIK-7168 (#533/#536): the destination trace ids and experiment id
# must be flushed to the checkpoint BEFORE the backend write that
# persists them, so a crash in that window still leaves them recorded
# for the next run's cleanup. We assert the checkpoint file on disk
# already carries this experiment's dest trace ids by the time the
# trace-flush happens.
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
flushed_state: dict = {}
original_flush = client.flush
def _capture_on_first_flush() -> None:
# On the first client.flush() (the trace flush), read back the
# checkpoint from disk and snapshot its in-flight trace ids.
if "dest_trace_ids" not in flushed_state:
reloaded = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
flushed_state["dest_trace_ids"] = (
list(reloaded.in_flight.dest_trace_ids)
if reloaded.in_flight
else []
)
return original_flush()
client.flush = MagicMock(side_effect=_capture_on_first_flush)
_run_cascade(rest_client, client, cp)
# By the time the backend trace flush ran, the checkpoint on disk had
# already recorded this experiment's destination trace id.
assert len(flushed_state["dest_trace_ids"]) == 1
def test_progress_seeded__resumed_run_starts_at_completed_count(
self, tmp_path: Path
) -> None:
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
cp.mark_completed("src-exp-1")
_, progress_calls = _run_cascade(rest_client, client, cp)
# First progress tick reports 1 already-completed of 2 total (not 0),
# so the bar opens at 50% rather than restarting.
assert progress_calls[0] == (1, 2, "exp-2")
# Final tick snaps to total/total "done".
assert progress_calls[-1] == (2, 2, "done")
def test_no_checkpoint__cascade_unchanged(self) -> None:
# Without a checkpoint the cascade behaves exactly as before: both
# experiments migrate, no delete calls, progress counts from 0.
rest_client, client = _two_experiment_rig()
progress_calls: List[Tuple[int, int, str]] = []
result = cascade_experiments(
client,
rest_client,
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
version_remap={"src-v-1": "dest-v-1"},
item_id_remap={"src-ds-item-1": "dest-ds-item-1"},
audit=_audit(),
progress_callback=lambda c, t, label: progress_calls.append((c, t, label)),
)
assert result.experiments_migrated == 2
rest_client.traces.delete_traces.assert_not_called()
assert progress_calls[0] == (0, 2, "exp-1")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,496 @@
"""Tests for ``opik migrate dataset`` Slice 5 — optimization cascade.
Slice 5 sits between Slice 2 (``ReplayVersions``) and Slice 3
(``CascadeExperiments``). It recreates every optimization referencing
the source dataset under the destination project and populates
``plan.optimization_id_remap`` so the experiment cascade can re-point
each experiment's ``optimization_id`` FK.
Scope this module covers (ticket AC for OPIK-6532):
* Optimization fidelity round-trip (name / objective_name / status /
metadata / studio_config carried verbatim)
* READ-ONLY aggregates (num_trials / feedback_scores / baseline_* /
best_* / total_optimization_cost) not forwarded
* Fresh client-side UUID minted per source optimization; ``id_remap``
populated with source -> destination
* Empty optimization (zero trials) round-trips and produces a remap entry
* Zero-optimization datasets are a no-op (returns empty result, no
REST call)
* Per-optimization ``migrate_optimization`` audit record emitted
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock
import pytest
from opik.cli.migrate.audit import AuditLog
from opik.cli.migrate.datasets import optimizations as optimizations_module
from opik.cli.migrate.datasets.optimizations import cascade_optimizations
# ---------------------------------------------------------------------------
# Stand-in wire shapes for the cascade's reads.
#
# The cascade reads ``OptimizationPublic`` from ``find_optimizations`` and
# writes via ``create_optimization``. Constructing the real pydantic
# models would impose ~20 fields per row; the cascade only inspects a
# handful, so we use plain objects with the fields it actually reads.
# ---------------------------------------------------------------------------
class _Optimization:
def __init__(
self,
*,
id: str,
name: Optional[str] = "opt",
objective_name: str = "accuracy",
status: str = "completed",
dataset_name: str = "MyDataset",
dataset_id: Optional[str] = "src-dataset-1",
metadata: Optional[Any] = None,
studio_config: Optional[Any] = None,
last_updated_at: Optional[Any] = None,
# READ-ONLY aggregates -- attached so we can assert the cascade
# does NOT forward them on the create call.
num_trials: Optional[int] = None,
feedback_scores: Optional[List[Any]] = None,
experiment_scores: Optional[List[Any]] = None,
baseline_objective_score: Optional[float] = None,
best_objective_score: Optional[float] = None,
baseline_duration: Optional[float] = None,
best_duration: Optional[float] = None,
baseline_cost: Optional[float] = None,
best_cost: Optional[float] = None,
total_optimization_cost: Optional[float] = None,
) -> None:
self.id = id
self.name = name
self.objective_name = objective_name
self.status = status
self.dataset_name = dataset_name
self.dataset_id = dataset_id
self.metadata = metadata
self.studio_config = studio_config
self.last_updated_at = last_updated_at
self.num_trials = num_trials
self.feedback_scores = feedback_scores
self.experiment_scores = experiment_scores
self.baseline_objective_score = baseline_objective_score
self.best_objective_score = best_objective_score
self.baseline_duration = baseline_duration
self.best_duration = best_duration
self.baseline_cost = baseline_cost
self.best_cost = best_cost
self.total_optimization_cost = total_optimization_cost
class _Page:
def __init__(self, content: List[_Optimization]) -> None:
self.content = content
def _audit() -> AuditLog:
"""Real AuditLog so the cascade's per-optimization ``record`` calls
land in a list we can assert on. The audit log carries no side
effects until ``write`` is called, so using the real type in tests
is cheaper than building a coupled mock."""
return AuditLog(command="migrate dataset", args={})
def _cascade_rest_client(
pages: List[_Page],
*,
create_side_effect: Optional[Any] = None,
) -> MagicMock:
"""Build a rest_client mock for the optimization cascade.
``find_optimizations`` is called once per page until a short page
breaks the loop. ``create_optimization`` is captured so individual
tests can assert on its kwargs.
"""
rest_client = MagicMock()
rest_client.optimizations.find_optimizations.side_effect = pages
if create_side_effect is not None:
rest_client.optimizations.create_optimization.side_effect = create_side_effect
else:
rest_client.optimizations.create_optimization.return_value = None
return rest_client
# ---------------------------------------------------------------------------
# Cascade tests
# ---------------------------------------------------------------------------
class TestCascadeOptimizations:
def test_no_source_optimizations__noop_no_remap_no_create_calls(self) -> None:
# Empty result page -> no create_optimization calls, empty
# remap, counters at zero. Slice 5 always emits the action; the
# cascade handles the zero case quietly.
rest_client = _cascade_rest_client([_Page([])])
result = cascade_optimizations(
rest_client,
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
audit=_audit(),
)
assert result.id_remap == {}
assert result.optimizations_total == 0
assert result.optimizations_migrated == 0
assert result.optimizations_skipped == 0
rest_client.optimizations.create_optimization.assert_not_called()
def test_one_optimization__fidelity_fields_forwarded_and_remap_populated(
self,
) -> None:
# Full fidelity copy of the fields the BE round-trips:
# name, objective_name, status, metadata, studio_config,
# last_updated_at. Dest dataset_name + project_name are taken
# from the action, NOT from the source row (the source name was
# renamed to <name>_v1 by the time the cascade runs).
opt = _Optimization(
id="src-opt-1",
name="overnight-tune",
objective_name="f1",
status="completed",
metadata={"trial_count": 12, "user_note": "kept"},
studio_config=None,
num_trials=12,
baseline_objective_score=0.5,
best_objective_score=0.81,
total_optimization_cost=4.20,
)
rest_client = _cascade_rest_client([_Page([opt])])
result = cascade_optimizations(
rest_client,
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
audit=_audit(),
)
rest_client.optimizations.create_optimization.assert_called_once()
kwargs = rest_client.optimizations.create_optimization.call_args.kwargs
# Fresh destination id -- not the source id.
assert kwargs["id"] != "src-opt-1"
assert kwargs["id"] == result.id_remap["src-opt-1"]
# Fidelity fields forwarded verbatim.
assert kwargs["name"] == "overnight-tune"
assert kwargs["objective_name"] == "f1"
assert kwargs["status"] == "completed"
assert kwargs["metadata"] == {"trial_count": 12, "user_note": "kept"}
# Destination scoping comes from the action, not the source row.
assert kwargs["dataset_name"] == "MyDataset"
assert kwargs["project_name"] == "DestProject"
def test_readonly_aggregates__not_forwarded_on_create(self) -> None:
# ``num_trials`` / ``feedback_scores`` / ``baseline_*`` / ``best_*``
# / ``total_optimization_cost`` are recomputed by the BE at read
# time from constituent experiments. The cascade must NOT forward
# them -- if it did, they'd be silently dropped on write today
# but might persist (with stale numbers) under a future BE
# schema change.
opt = _Optimization(
id="src-opt-1",
num_trials=99,
feedback_scores=[MagicMock()],
experiment_scores=[MagicMock()],
baseline_objective_score=0.1,
best_objective_score=0.9,
baseline_duration=10.0,
best_duration=2.0,
baseline_cost=1.0,
best_cost=0.5,
total_optimization_cost=42.0,
)
rest_client = _cascade_rest_client([_Page([opt])])
cascade_optimizations(
rest_client,
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
audit=_audit(),
)
kwargs = rest_client.optimizations.create_optimization.call_args.kwargs
for forbidden in (
"num_trials",
"feedback_scores",
"experiment_scores",
"baseline_objective_score",
"best_objective_score",
"baseline_duration",
"best_duration",
"baseline_cost",
"best_cost",
"total_optimization_cost",
):
assert forbidden not in kwargs, (
f"{forbidden} is BE-computed; cascade must not forward it"
)
def test_empty_optimization__zero_trials__still_migrated(self) -> None:
# Optimization with zero constituent experiments is still a
# user-visible row in the Optimization Studio UI; migrating it
# preserves the user's setup even if no trials ran yet.
opt = _Optimization(id="src-opt-1", num_trials=0, name="never-ran")
rest_client = _cascade_rest_client([_Page([opt])])
result = cascade_optimizations(
rest_client,
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
audit=_audit(),
)
assert result.optimizations_migrated == 1
assert "src-opt-1" in result.id_remap
rest_client.optimizations.create_optimization.assert_called_once()
def test_multiple_optimizations__each_gets_distinct_destination_id(
self,
) -> None:
opt1 = _Optimization(id="src-opt-1", name="run-a")
opt2 = _Optimization(id="src-opt-2", name="run-b")
opt3 = _Optimization(id="src-opt-3", name="run-c")
rest_client = _cascade_rest_client([_Page([opt1, opt2, opt3])])
result = cascade_optimizations(
rest_client,
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
audit=_audit(),
)
assert result.optimizations_migrated == 3
assert set(result.id_remap.keys()) == {
"src-opt-1",
"src-opt-2",
"src-opt-3",
}
# Destination ids must be unique (fresh UUIDs).
assert len(set(result.id_remap.values())) == 3
def test_audit__per_optimization_record_emitted(self) -> None:
# Each successful migration emits a ``migrate_optimization`` audit
# record carrying source_id -> destination_id mapping plus
# name / objective_name / status so users can trace what moved.
opt = _Optimization(
id="src-opt-1",
name="overnight",
objective_name="accuracy",
status="completed",
)
rest_client = _cascade_rest_client([_Page([opt])])
audit = _audit()
cascade_optimizations(
rest_client,
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
audit=audit,
)
migrate_records = [
r for r in audit.actions if r["type"] == "migrate_optimization"
]
assert len(migrate_records) == 1
record = migrate_records[0]
# Audit-level status conveys action success/failure -- must not
# be shadowed by the optimization's own status.
assert record["status"] == "ok"
assert record["source_id"] == "src-opt-1"
assert record["destination_id"] != "src-opt-1"
assert record["name"] == "overnight"
assert record["objective_name"] == "accuracy"
# Optimization's own status lives under a non-shadowing key.
assert record["optimization_status"] == "completed"
def test_pagination__exhausts_pages_until_short(self) -> None:
# find_optimizations is paginated; the cascade walks until a
# short page (< page_size). With page_size=100 in production,
# two pages of 100 and a final short page of 50 should fire
# three reads in order.
page_size = optimizations_module._OPTIMIZATION_PAGE_SIZE
page_a = _Page([_Optimization(id=f"src-{i}") for i in range(page_size)])
page_b = _Page(
[_Optimization(id=f"src-{page_size + i}") for i in range(page_size)]
)
page_c = _Page([_Optimization(id=f"src-{2 * page_size + i}") for i in range(3)])
rest_client = _cascade_rest_client([page_a, page_b, page_c])
result = cascade_optimizations(
rest_client,
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
audit=_audit(),
)
assert result.optimizations_total == 2 * page_size + 3
# 3 reads + N writes.
assert rest_client.optimizations.find_optimizations.call_count == 3
def test_create_optimization_raises__exception_propagates(self) -> None:
# The BE returns 409 on id conflict (we mint client-side UUIDs
# so within-run collisions are infinitesimal but possible across
# retries). Surface as a cascade error rather than silently
# continuing; the audit log's surrounding ``failed`` entry will
# capture partial progress via the umbrella action.
opt = _Optimization(id="src-opt-1")
rest_client = _cascade_rest_client(
[_Page([opt])],
create_side_effect=RuntimeError("409 conflict"),
)
with pytest.raises(RuntimeError, match="409 conflict"):
cascade_optimizations(
rest_client,
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
audit=_audit(),
)
def test_source_missing_id__skipped_not_remapped(self) -> None:
# Defensive path: BE returns an optimization without an id.
# Skip rather than fail -- other optimizations are still
# recoverable, and a non-zero ``optimizations_skipped`` counter
# in the audit surfaces the malformed row for investigation.
ok = _Optimization(id="src-opt-1")
bad = _Optimization(id="")
rest_client = _cascade_rest_client([_Page([ok, bad])])
result = cascade_optimizations(
rest_client,
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
audit=_audit(),
)
assert result.optimizations_migrated == 1
assert result.optimizations_skipped == 1
assert list(result.id_remap.keys()) == ["src-opt-1"]
class TestCascadeOptimizationsWithExperiments:
"""End-to-end planner-shape assertion: the optimization remap built
by Slice 5 is observable to Slice 3's experiment cascade via the
shared ``MigrationPlan``. The actual experiment-cascade FK forwarding
is asserted in ``test_migrate_dataset_experiments_cascade``; this
test only pins the wiring (action ordering + remap propagation).
"""
def test_plan_remap__populated_by_cascade__readable_by_subsequent_actions(
self,
) -> None:
from opik.cli.migrate.datasets.planner import MigrationPlan
from opik.cli.migrate.datasets.resolver import ResolvedDataset
opt = _Optimization(id="src-opt-1", name="trained")
rest_client = _cascade_rest_client([_Page([opt])])
plan = MigrationPlan(
source=ResolvedDataset(
id="src-1",
name="MyDataset",
project_name=None,
description=None,
visibility=None,
tags=None,
type="dataset",
),
target_name="MyDataset",
to_project="DestProject",
)
result = cascade_optimizations(
rest_client,
source_dataset_id="src-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
audit=_audit(),
)
plan.optimization_id_remap.update(result.id_remap)
# Subsequent CascadeExperiments would read plan.optimization_id_remap;
# the wiring works as long as result.id_remap propagates here.
assert plan.optimization_id_remap == result.id_remap
assert "src-opt-1" in plan.optimization_id_remap
# ---------------------------------------------------------------------------
# Studio config Read -> Write reconstruction
# ---------------------------------------------------------------------------
class TestStudioConfigReconstruction:
"""The Fern-generated Read and Write variants of
``OptimizationStudioConfig`` are structurally identical but
nominally distinct; the cascade has to reconstruct the Write
variant from the Read variant so the typed ``create_optimization``
parameter is satisfied. Pin the round-trip so any future Fern
regeneration that changes the shape on one side gets caught.
"""
def test_none_in_none_out(self) -> None:
from opik.cli.migrate.datasets.optimizations import (
_studio_config_public_to_write,
)
assert _studio_config_public_to_write(None) is None
def test_round_trip__fields_preserved(self) -> None:
from opik.cli.migrate.datasets.optimizations import (
_studio_config_public_to_write,
)
from opik.rest_api.types.optimization_studio_config_public import (
OptimizationStudioConfigPublic,
)
from opik.rest_api.types.studio_evaluation_public import (
StudioEvaluationPublic,
)
from opik.rest_api.types.studio_llm_model_public import (
StudioLlmModelPublic,
)
from opik.rest_api.types.studio_message_public import StudioMessagePublic
from opik.rest_api.types.studio_metric_public import StudioMetricPublic
from opik.rest_api.types.studio_optimizer_public import (
StudioOptimizerPublic,
)
from opik.rest_api.types.studio_prompt_public import StudioPromptPublic
public = OptimizationStudioConfigPublic(
dataset_name="MyDataset",
prompt=StudioPromptPublic(
messages=[StudioMessagePublic(role="system", content="you are helpful")]
),
llm_model=StudioLlmModelPublic(
model="gpt-4o", parameters={"temperature": 0.2}
),
evaluation=StudioEvaluationPublic(
metrics=[StudioMetricPublic(type="accuracy")]
),
optimizer=StudioOptimizerPublic(type="grid", parameters={"steps": 5}),
)
write = _studio_config_public_to_write(public)
assert write is not None
# Round-trip via model_dump -- the typed Write variant accepts
# extra fields so this also covers any forward-compatible BE
# additions on the Public variant.
write_dump: Dict[str, Any] = write.model_dump()
public_dump: Dict[str, Any] = public.model_dump()
assert write_dump == public_dump
@@ -0,0 +1,950 @@
"""Planner + CLI-help tests for ``opik migrate dataset``.
The planner cases (conflict, project-not-found, default flow ordering)
live here; the meaty version-replay tests live in
``test_migrate_dataset_version_replay.py`` and the cascade tests live in
``test_migrate_dataset_experiments_cascade.py``.
Shared helpers (``_DatasetRow``, ``_Page``, ``_planner_rest_client``)
come from ``_migrate_helpers``.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from opik.cli import cli
from opik.cli.migrate.audit import AuditLog
from opik.cli.migrate.checkpoint import MigrationCheckpoint
from opik.cli.migrate.datasets import planner as planner_module
from opik.cli.migrate.datasets.planner import TEMP_MIGRATION_MARKER_TAG
from opik.cli.migrate.datasets.resume import ReconstructedRemaps
from opik.cli.migrate.errors import (
ConflictError,
DatasetNotFoundError,
MigrationError,
ProjectNotFoundError,
)
from ._migrate_helpers import (
_build_fake_client,
_DatasetRow,
_Page,
_planner_client,
_planner_rest_client,
)
# ---------------------------------------------------------------------------
# Elapsed-time formatter
# ---------------------------------------------------------------------------
class TestFormatElapsed:
"""Pin the wall-clock duration renderer used in the migrate success /
failure lines. Sub-minute → one decimal of seconds; past a minute →
integer ``Mm Ss`` or ``Hh Mm Ss`` (no fractional seconds).
"""
def test_sub_minute__one_decimal_seconds(self) -> None:
from opik.cli.migrate.main import _format_elapsed
assert _format_elapsed(0.0) == "0.0s"
assert _format_elapsed(12.34) == "12.3s"
assert _format_elapsed(59.99) == "60.0s"
def test_minute_range__integer_m_s(self) -> None:
from opik.cli.migrate.main import _format_elapsed
assert _format_elapsed(60.0) == "1m 0s"
assert _format_elapsed(125.7) == "2m 5s"
def test_hour_range__integer_h_m_s(self) -> None:
from opik.cli.migrate.main import _format_elapsed
assert _format_elapsed(3600.0) == "1h 0m 0s"
assert _format_elapsed(3725.0) == "1h 2m 5s"
# ---------------------------------------------------------------------------
# OPIK-6599: loud-fail on skipped items
#
# When the cascade emits any ``skip`` audit record, the migrate must:
# 1. Finalize the audit log to ``failed`` (not ``ok``)
# 2. Print a SKIP_SUMMARY line to stderr (not stdout) so CI gates can
# grep without parsing the JSON
# 3. Exit non-zero
#
# Tests below cover ``_finalize_with_skips_or_ok`` directly so they don't
# need a fully-wired Opik client + REST server stub. The CLI is only the
# wrapper around this helper.
# ---------------------------------------------------------------------------
class TestFinalizeWithSkipsOrOk:
def _make_audit_with_skips(self) -> AuditLog:
audit = AuditLog(command="opik migrate dataset", args={})
audit.record(
type="skip",
status="skipped",
details={
"reason": "items_missing_dataset_item_remap",
"experiment_id": "src-exp-1",
"experiment_name": "exp-1",
"count": 2500,
"sample_source_ids": ["src-ds-item-1", "src-ds-item-2"],
},
)
return audit
def test_skips_present__finalizes_failed_exits_1_stderr_summary(
self, tmp_path, capsys
) -> None:
from opik.cli.migrate.main import _finalize_with_skips_or_ok
audit = self._make_audit_with_skips()
audit_path = tmp_path / "audit.json"
with pytest.raises(SystemExit) as exc:
_finalize_with_skips_or_ok(
audit,
audit_path,
name="MyDataset",
target_label="MyDataset",
target_project="DestProject",
elapsed_seconds=12.3,
)
# AC 1: non-zero exit code
assert exc.value.code == 1
captured = capsys.readouterr()
# AC 3: skip message on stderr (not stdout), with the
# machine-parseable SKIP_SUMMARY suffix
assert "SKIP_SUMMARY:" in captured.err
assert "items_skipped_missing_item=2500" in captured.err
assert "experiments_skipped=0" in captured.err
assert "items_skipped_missing_trace=0" in captured.err
assert "NOT rolled back" in captured.err
# Rollback hint names the entities the operator must remove,
# the destination project, and the rename-back step on the source.
assert "roll back manually" in captured.err
assert "DestProject" in captured.err
assert "MyDataset_v1" in captured.err
# AC 2: audit finalized to failed with skip record intact
on_disk = json.loads(audit_path.read_text())
assert on_disk["status"] == "failed"
assert any(a.get("status") == "skipped" for a in on_disk["actions"])
def test_no_skips__finalizes_ok_no_exit_no_stderr(self, tmp_path, capsys) -> None:
from opik.cli.migrate.main import _finalize_with_skips_or_ok
audit = AuditLog(command="opik migrate dataset", args={})
audit.record(type="rename_source", status="ok", details={})
audit_path = tmp_path / "audit.json"
# Happy path returns without raising; happy-path message goes to
# stdout, stderr stays clean.
_finalize_with_skips_or_ok(
audit,
audit_path,
name="MyDataset",
target_label="MyDataset",
target_project="DestProject",
elapsed_seconds=5.0,
)
captured = capsys.readouterr()
assert "SKIP_SUMMARY:" not in captured.err
on_disk = json.loads(audit_path.read_text())
assert on_disk["status"] == "ok"
def test_multiple_skip_records__totals_aggregated_by_reason(
self, tmp_path, capsys
) -> None:
from opik.cli.migrate.main import _finalize_with_skips_or_ok
# Two experiments, each contributing skips for both reasons.
audit = AuditLog(command="opik migrate dataset", args={})
for exp_id in ("src-exp-1", "src-exp-2"):
audit.record(
type="skip",
status="skipped",
details={
"reason": "items_missing_trace_remap",
"experiment_id": exp_id,
"experiment_name": exp_id,
"count": 7,
"sample_source_ids": [],
},
)
audit.record(
type="skip",
status="skipped",
details={
"reason": "items_missing_dataset_item_remap",
"experiment_id": exp_id,
"experiment_name": exp_id,
"count": 3,
"sample_source_ids": [],
},
)
with pytest.raises(SystemExit):
_finalize_with_skips_or_ok(
audit,
tmp_path / "audit.json",
name="MyDataset",
target_label="MyDataset",
target_project="DestProject",
elapsed_seconds=1.0,
)
captured = capsys.readouterr()
# 7 + 7 = 14 trace skips, 3 + 3 = 6 dataset-item skips
assert "items_skipped_missing_trace=14" in captured.err
assert "items_skipped_missing_item=6" in captured.err
# ---------------------------------------------------------------------------
# Help text
# ---------------------------------------------------------------------------
class TestMigrateHelp:
def test_migrate_group__help_invoked__lists_subcommands(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["migrate", "--help"])
assert result.exit_code == 0
assert "Migrate Opik entities" in result.output
assert "dataset" in result.output
def test_migrate_dataset__help_invoked__lists_required_flags(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["migrate", "dataset", "--help"])
assert result.exit_code == 0
assert "--to-project" in result.output
assert "--from-project" in result.output
assert "--dry-run" in result.output
def test_migrate_dataset__help_invoked__lists_exclude_experiments(self) -> None:
# OPIK-7161 AC: the opt-out flag must be discoverable in --help.
runner = CliRunner()
result = runner.invoke(cli, ["migrate", "dataset", "--help"])
assert result.exit_code == 0
assert "--exclude-experiments" in result.output
def test_migrate_dataset__exclude_experiments__cli_run_skips_and_reports(
self, tmp_path
) -> None:
# OPIK-7161: exercise the flag through the public Click entrypoint,
# not just the finalize helper, so the option -> build_dataset_plan
# -> finalize plumbing in migrate_dataset_command is covered end to
# end (per .agents/skills/python-sdk/testing.md: test the public API).
# The fake client mocks the whole rename/create/replay surface; with
# --exclude-experiments the plan carries no cascade actions, so the
# command reaches the success finalize with zero experiment work.
client, _, _ = _build_fake_client(
source_rows=[_DatasetRow(id="src-1", name="MyDataset")],
destination_rows=[],
items=[{"id": "item-a", "input": "hello"}],
)
audit_path = tmp_path / "audit.json"
runner = CliRunner()
with patch("opik.cli.migrate.main._build_client", return_value=client):
result = runner.invoke(
cli,
[
"migrate",
"dataset",
"MyDataset",
"--to-project",
"B",
"--exclude-experiments",
"--audit-log",
str(audit_path),
],
)
assert result.exit_code == 0, result.output
# User-facing output makes the intentional skip clear.
assert "--exclude-experiments" in result.output
assert "skipped" in result.output
# No experiment cascade ran: the source dataset was never queried for
# experiments (find_experiments belongs only to the cascade path).
assert client.rest_client.experiments.find_experiments.call_count == 0
# Audit log finalized ok and recorded the flag in its args.
on_disk = json.loads(audit_path.read_text())
assert on_disk["status"] == "ok"
assert on_disk["args"]["exclude_experiments"] is True
cascade_types = {a.get("type") for a in on_disk["actions"]}
assert "cascade_experiments" not in cascade_types
assert "cascade_optimizations" not in cascade_types
class TestTempDestRenameOnSuccess:
"""OPIK-7162 acceptance criteria, exercised through the public Click
entrypoint: the source keeps its name until the copy succeeds, then the
handoff runs (source -> _v1, temp -> original). A mid-run failure leaves
the source name untouched, and a re-run after failure is safe/idempotent.
All cases use ``--exclude-experiments`` so the plan is the minimal
Create -> Replay -> Rename -> Promote shape and the assertions stay
focused on the handoff, not the cascade.
"""
def _run(self, client, tmp_path, extra_args=()):
audit_path = tmp_path / "audit.json"
runner = CliRunner()
with patch("opik.cli.migrate.main._build_client", return_value=client):
result = runner.invoke(
cli,
[
"migrate",
"dataset",
"MyDataset",
"--to-project",
"B",
"--exclude-experiments",
"--audit-log",
str(audit_path),
*extra_args,
],
)
return result, audit_path
def test_success__source_renamed_to_v1_and_dest_promoted_to_original(
self, tmp_path
) -> None:
# AC: a successful migration leaves the destination under the
# original name and the source under the _v1 suffix. The two renames
# happen via update_dataset PUTs; the destination is created under
# the temp name first.
client, _, _ = _build_fake_client(
source_rows=[_DatasetRow(id="src-1", name="MyDataset")],
destination_rows=[],
items=[{"id": "item-a", "input": "hello"}],
)
result, audit_path = self._run(client, tmp_path)
assert result.exit_code == 0, result.output
rest = client.rest_client
# Destination created under the temp name (not the final name), and
# stamped with the migration marker tag so a future re-run can prove
# it's ours before discarding it.
create_kwargs = rest.datasets.create_dataset.call_args.kwargs
assert create_kwargs["name"] == "MyDataset__migrating"
assert TEMP_MIGRATION_MARKER_TAG in (create_kwargs["tags"] or [])
# Two rename PUTs: source -> _v1, then temp -> original. The promote PUT
# re-passes the source's ORIGINAL tags (marker stripped).
rename_calls = [c.kwargs for c in rest.datasets.update_dataset.call_args_list]
source_rename = next(c for c in rename_calls if c["id"] == "src-1")
assert source_rename["name"] == "MyDataset_v1"
promote = next(c for c in rename_calls if c.get("name") == "MyDataset")
assert promote["name"] == "MyDataset"
# The promote PUT must pass tags as an EXPLICIT list (never None), so the
# BE actually overwrites and drops the marker. A live backend treats
# ``tags=None`` as "leave unchanged", which would strand the marker on a
# source with no tags — so assert the concrete list, not just marker
# absence. Source here has no tags -> promote clears with [].
assert promote["tags"] == []
assert TEMP_MIGRATION_MARKER_TAG not in promote["tags"]
# Audit ends ok and records the handoff actions in order.
on_disk = json.loads(audit_path.read_text())
assert on_disk["status"] == "ok"
ok_types = [a["type"] for a in on_disk["actions"] if a.get("status") == "ok"]
assert ok_types.index("rename_source") < ok_types.index("promote_destination")
assert ok_types.index("create_destination") < ok_types.index("rename_source")
def test_success__source_tags_preserved_marker_stripped(self, tmp_path) -> None:
# When the source has real tags, the temp create adds the marker
# alongside them, and the promote re-passes exactly the source's
# originals (marker dropped, real tags kept).
client, _, _ = _build_fake_client(
source_rows=[
_DatasetRow(id="src-1", name="MyDataset", tags=["team-a", "prod"])
],
destination_rows=[],
items=[{"id": "item-a", "input": "hello"}],
)
result, _ = self._run(client, tmp_path)
assert result.exit_code == 0, result.output
rest = client.rest_client
create_tags = rest.datasets.create_dataset.call_args.kwargs["tags"]
assert set(create_tags) == {"team-a", "prod", TEMP_MIGRATION_MARKER_TAG}
promote = next(
c.kwargs
for c in rest.datasets.update_dataset.call_args_list
if c.kwargs.get("name") == "MyDataset"
)
assert promote["tags"] == ["team-a", "prod"]
assert TEMP_MIGRATION_MARKER_TAG not in promote["tags"]
def test_midrun_failure__source_name_untouched(self, tmp_path) -> None:
# AC: a migration interrupted mid-run leaves the source name
# untouched. Blow up the destination create (the first copy action);
# the source-rename PUT must never fire.
client, _, _ = _build_fake_client(
source_rows=[_DatasetRow(id="src-1", name="MyDataset")],
destination_rows=[],
items=[{"id": "item-a", "input": "hello"}],
)
client.rest_client.datasets.create_dataset.side_effect = RuntimeError(
"boom mid-copy"
)
result, audit_path = self._run(client, tmp_path)
assert result.exit_code == 1
# No update_dataset PUT touched the source id -> its name is intact.
source_touched = [
c
for c in client.rest_client.datasets.update_dataset.call_args_list
if c.kwargs.get("id") == "src-1"
]
assert source_touched == []
on_disk = json.loads(audit_path.read_text())
assert on_disk["status"] == "failed"
# The handoff actions never reached ``ok``.
ok_types = {a["type"] for a in on_disk["actions"] if a.get("status") == "ok"}
assert "rename_source" not in ok_types
assert "promote_destination" not in ok_types
def test_rerun_after_failure__discards_stale_temp_then_completes(
self, tmp_path
) -> None:
# AC: re-running after an interrupted run completes with no manual
# cleanup. A stale ``MyDataset__migrating`` from the prior failed run —
# carrying the migration marker tag that proves it's ours — is
# discovered and deleted before the destination is recreated.
client, _, _ = _build_fake_client(
source_rows=[_DatasetRow(id="src-1", name="MyDataset")],
destination_rows=[],
items=[{"id": "item-a", "input": "hello"}],
stale_temp_rows=[
_DatasetRow(
id="stale-1",
name="MyDataset__migrating",
tags=[TEMP_MIGRATION_MARKER_TAG],
)
],
)
result, audit_path = self._run(client, tmp_path)
assert result.exit_code == 0, result.output
# The stale temp was deleted by id before recreate.
client.rest_client.datasets.delete_dataset.assert_called_once_with(id="stale-1")
on_disk = json.loads(audit_path.read_text())
assert on_disk["status"] == "ok"
action_types = [
a["type"] for a in on_disk["actions"] if a.get("status") == "ok"
]
assert action_types.index("discard_stale_temp") < action_types.index(
"create_destination"
)
# ---------------------------------------------------------------------------
# Planner unit tests (no Click invocation)
# ---------------------------------------------------------------------------
class TestPlanBuilding:
def test_build_dataset_plan__default_flow__orders_create_replay_cascades_then_handoff(
self,
) -> None:
# OPIK-7162: the plan builds the destination under a temp name FIRST
# (source keeps its name), runs the copy + cascades, then does the
# name handoff LAST: rename source -> <name>_v1, promote temp ->
# <name>. The order is load-bearing on two axes:
# * CascadeOptimizations before CascadeExperiments (opt-id remap).
# * RenameSource before PromoteDestination (source-away then
# destination-in, so <name> is never held by two rows at once).
# Three find_datasets pages: source resolve, _v1 collision check,
# __migrating stale-temp lookup.
rest_client = _planner_rest_client(
[
_Page([_DatasetRow(id="src-1", name="MyDataset", description="d")]),
_Page([]),
_Page([]),
]
)
plan = planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="B",
)
types = [type(a).__name__ for a in plan.actions]
assert types == [
"CreateDestination",
"ReplayVersions",
"CascadeOptimizations",
"CascadeExperiments",
"RenameSource",
"PromoteDestination",
]
# Destination is created under the temp name, not the final name.
create = plan.actions[0]
assert create.name == "MyDataset__migrating"
replay = plan.actions[1]
assert replay.source_name == "MyDataset"
assert replay.dest_name == "MyDataset__migrating"
# Handoff: source away first, destination in second.
rename = plan.actions[4]
assert rename.from_name == "MyDataset"
assert rename.to_name == "MyDataset_v1"
promote = plan.actions[5]
assert promote.from_name == "MyDataset__migrating"
assert promote.to_name == "MyDataset"
assert plan.target_name == "MyDataset"
# New remap dict starts empty; _cascade_optimizations populates it.
assert plan.optimization_id_remap == {}
def test_build_dataset_plan__exclude_experiments__omits_both_cascades(
self,
) -> None:
# OPIK-7161: --exclude-experiments drops the experiment stage AND
# the optimization stage (optimizations are containers for the
# skipped experiments). No cascade actions, but the name handoff
# (rename + promote) still runs after the dataset + versions copy.
rest_client = _planner_rest_client(
[
_Page([_DatasetRow(id="src-1", name="MyDataset", description="d")]),
_Page([]),
_Page([]),
]
)
plan = planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="B",
exclude_experiments=True,
)
types = [type(a).__name__ for a in plan.actions]
assert types == [
"CreateDestination",
"ReplayVersions",
"RenameSource",
"PromoteDestination",
]
assert not any(
isinstance(a, planner_module.CascadeExperiments) for a in plan.actions
)
assert not any(
isinstance(a, planner_module.CascadeOptimizations) for a in plan.actions
)
def test_build_dataset_plan__exclude_experiments_default_false__keeps_cascades(
self,
) -> None:
# Default (flag off) is unchanged: both cascades still emitted.
# Guards the opt-out default so a plain migrate never silently
# starts skipping experiments.
rest_client = _planner_rest_client(
[
_Page([_DatasetRow(id="src-1", name="MyDataset")]),
_Page([]),
_Page([]),
]
)
plan = planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="B",
)
types = [type(a).__name__ for a in plan.actions]
assert types == [
"CreateDestination",
"ReplayVersions",
"CascadeOptimizations",
"CascadeExperiments",
"RenameSource",
"PromoteDestination",
]
def test_build_dataset_plan__test_suite__type_forwarded_to_destination(
self,
) -> None:
# Test suites flow through the same plan shape as plain datasets;
# the only difference is ``CreateDestination.type`` being forwarded
# so the target accepts suite-level evaluators + execution_policy
# via ``ReplayVersions``.
rest_client = _planner_rest_client(
[
_Page(
[_DatasetRow(id="src-1", name="MySuite", type="evaluation_suite")]
),
_Page([]),
_Page([]),
]
)
plan = planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MySuite",
to_project="B",
)
types = [type(a).__name__ for a in plan.actions]
assert types == [
"CreateDestination",
"ReplayVersions",
"CascadeOptimizations",
"CascadeExperiments",
"RenameSource",
"PromoteDestination",
]
replay = plan.actions[1]
assert replay.is_test_suite is True
def test_build_dataset_plan__rename_target_collides_workspace_wide__raises_conflict(
self,
) -> None:
# The eventual source-rename target "<source>_v1" collides with
# another dataset in the workspace — caught up-front so a doomed run
# never does any copy work.
rest_client = _planner_rest_client(
[
_Page([_DatasetRow(id="src-1", name="MyDataset")]),
_Page([_DatasetRow(id="other-1", name="MyDataset_v1")]),
_Page([]),
]
)
with pytest.raises(ConflictError) as exc_info:
planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="B",
)
assert "MyDataset_v1" in str(exc_info.value)
def test_build_dataset_plan__rename_target_match_is_source_itself__no_conflict(
self,
) -> None:
# When find_datasets returns the source itself for the _v1 check, we
# must not treat that as a collision — it's about to be renamed.
rest_client = _planner_rest_client(
[
_Page([_DatasetRow(id="src-1", name="MyDataset")]),
_Page([_DatasetRow(id="src-1", name="MyDataset_v1")]),
_Page([]),
]
)
# Should NOT raise: the only "match" is the source dataset itself.
plan = planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="B",
)
assert plan.target_name == "MyDataset"
def test_build_dataset_plan__marked_stale_temp__prepends_discard_action(
self,
) -> None:
# OPIK-7162 safe re-run: a leftover "<name>__migrating" carrying the
# migration marker tag (proof it's ours) is detected and a
# DiscardStaleTemp action is prepended so the re-run starts clean.
rest_client = _planner_rest_client(
[
_Page([_DatasetRow(id="src-1", name="MyDataset")]),
_Page([]),
_Page(
[
_DatasetRow(
id="stale-1",
name="MyDataset__migrating",
tags=[TEMP_MIGRATION_MARKER_TAG],
)
]
),
]
)
plan = planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="B",
)
types = [type(a).__name__ for a in plan.actions]
assert types == [
"DiscardStaleTemp",
"CreateDestination",
"ReplayVersions",
"CascadeOptimizations",
"CascadeExperiments",
"RenameSource",
"PromoteDestination",
]
discard = plan.actions[0]
assert discard.temp_id == "stale-1"
assert discard.temp_name == "MyDataset__migrating"
def test_build_dataset_plan__unmarked_name_collision__raises_conflict(
self,
) -> None:
# A dataset named "<name>__migrating" WITHOUT the migration marker is a
# real user dataset that merely shares the name — it must NOT be
# deleted. The planner aborts with ConflictError instead.
rest_client = _planner_rest_client(
[
_Page([_DatasetRow(id="src-1", name="MyDataset")]),
_Page([]),
_Page([_DatasetRow(id="user-1", name="MyDataset__migrating")]),
]
)
with pytest.raises(ConflictError) as exc_info:
planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="B",
)
assert "MyDataset__migrating" in str(exc_info.value)
assert TEMP_MIGRATION_MARKER_TAG in str(exc_info.value)
def test_build_dataset_plan__same_from_and_to_project_flag__raises_conflict(
self,
) -> None:
# Cheap early-out: user literally passed --from-project A --to-project A.
# Rejected before any lookup.
rest_client = _planner_rest_client(
[_Page([_DatasetRow(id="src-1", name="MyDataset")])]
)
with pytest.raises(ConflictError, match="same project"):
planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="A",
from_project="A",
)
def test_build_dataset_plan__omitted_flag_source_in_dest_project__raises_conflict(
self,
) -> None:
# The gap the flag-only check missed: --from-project is OMITTED, but the
# source actually lives in the destination project. resolve_source
# populates source.project_name from the row's project_id, so the
# authoritative post-resolve guard still catches it.
source_row = _DatasetRow(id="src-1", name="MyDataset", project_id="proj-A")
rest_client = _planner_rest_client([_Page([source_row])])
# project_name_for_row -> client.get_project(id="proj-A").name == "A".
proj = MagicMock()
proj.name = "A"
rest_client.projects.get_project_by_id.return_value = proj
with pytest.raises(ConflictError, match="same project"):
planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="A",
)
def test_build_dataset_plan__workspace_scoped_source__no_same_project_abort(
self,
) -> None:
# A workspace-scoped source (no project_id -> project_name is None) has
# no single project to collide with --to-project, so a workspace-scoped
# -> project migrate is legitimate and must NOT be blocked.
rest_client = _planner_rest_client(
[
_Page([_DatasetRow(id="src-1", name="MyDataset", project_id=None)]),
_Page([]),
_Page([]),
]
)
plan = planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="A",
)
assert plan.target_name == "MyDataset"
def test_build_dataset_plan__no_stale_temp__no_discard_action(self) -> None:
# The common case: no leftover temp, so no DiscardStaleTemp emitted.
rest_client = _planner_rest_client(
[
_Page([_DatasetRow(id="src-1", name="MyDataset")]),
_Page([]),
_Page([]),
]
)
plan = planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="B",
)
assert not any(
isinstance(a, planner_module.DiscardStaleTemp) for a in plan.actions
)
def test_build_dataset_plan__source_name_not_found__raises_dataset_not_found(
self,
) -> None:
rest_client = _planner_rest_client([_Page([])])
with pytest.raises(DatasetNotFoundError) as exc_info:
planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="Missing",
to_project="B",
)
assert "Missing" in str(exc_info.value)
def test_build_dataset_plan__source_name_resolves_to_many__raises_conflict(
self,
) -> None:
# Workspace uniqueness is enforced by the BE (UNIQUE
# (workspace_id, name)); if the BE invariant is somehow
# violated, surface it as ConflictError rather than silently
# picking a row.
rest_client = _planner_rest_client(
[
_Page(
[
_DatasetRow(id="a", name="MyDataset"),
_DatasetRow(id="b", name="MyDataset"),
]
)
]
)
with pytest.raises(ConflictError):
planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="B",
)
def test_build_dataset_plan__destination_project_missing__raises_project_not_found(
self,
) -> None:
rest_client = _planner_rest_client(
find_side_effects=[],
target_project_exists=False,
)
with pytest.raises(ProjectNotFoundError) as exc_info:
planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="DoesNotExist",
)
assert "DoesNotExist" in str(exc_info.value)
def test_build_dataset_plan__destination_project_missing__suggests_similar_names(
self,
) -> None:
rest_client = _planner_rest_client(
find_side_effects=[],
target_project_exists=False,
workspace_project_names=["production", "staging", "Beat", "Best"],
)
with pytest.raises(ProjectNotFoundError) as exc_info:
planner_module.build_dataset_plan(
client=_planner_client(rest_client),
name="MyDataset",
to_project="Beta",
)
message = str(exc_info.value)
assert "Beta" in message
# difflib should surface the close one-letter neighbours.
assert "Did you mean" in message
assert "Beat" in message or "Best" in message
# ---------------------------------------------------------------------------
# OPIK-7162 + OPIK-7168 integration: resume reuses the temp destination and
# finishes the pending handoff (rename source -> _v1, promote temp -> original).
# ---------------------------------------------------------------------------
class TestBuildResumePlanTempDest:
def _checkpoint(self) -> MigrationCheckpoint:
return MigrationCheckpoint(
key="k",
workspace="ws",
project="B",
dataset="MyDataset",
path=Path("/tmp/does-not-matter.json"),
dataset_phase_done=True,
source_dataset_id="src-1",
source_name="MyDataset",
temp_dest_name="MyDataset__migrating",
)
def _resume_client(self, source_id: str = "src-1") -> MagicMock:
# resolve_source(MyDataset) -> the still-unrenamed source;
# get_dataset(MyDataset__migrating) -> the temp destination.
rest_client = _planner_rest_client(
[_Page([_DatasetRow(id=source_id, name="MyDataset")])]
)
client = _planner_client(rest_client)
dest = MagicMock()
dest.id = "temp-dest-1"
client.get_dataset = MagicMock(return_value=dest)
return client
def test_resume__reuses_temp_and_appends_cascade_then_handoff(self) -> None:
# A dataset_phase_done checkpoint means create-temp/replay/optimizations
# already ran into MyDataset__migrating and the source still holds its
# original name. The resume plan must NOT re-create or re-replay; it
# resolves the temp destination, then emits the pending tail:
# CascadeExperiments -> RenameSource -> PromoteDestination.
with patch.object(
planner_module, "reconstruct_remaps", return_value=ReconstructedRemaps()
):
plan = planner_module.build_dataset_plan(
client=self._resume_client(),
name="MyDataset",
to_project="B",
resume_checkpoint=self._checkpoint(),
)
types = [type(a).__name__ for a in plan.actions]
assert types == ["CascadeExperiments", "RenameSource", "PromoteDestination"]
assert plan.is_resume is True
# The cascade + promote target the TEMP destination (promote hasn't run
# yet); the source rename moves the original name to _v1.
cascade = plan.actions[0]
assert cascade.dest_name == "MyDataset__migrating"
rename = plan.actions[1]
assert rename.from_name == "MyDataset"
assert rename.to_name == "MyDataset_v1"
promote = plan.actions[2]
assert promote.from_name == "MyDataset__migrating"
assert promote.to_name == "MyDataset"
def test_resume__source_id_mismatch__raises(self) -> None:
# If the user-supplied name now resolves to a DIFFERENT dataset than the
# interrupted run's source, resume must refuse rather than migrate the
# wrong dataset.
with patch.object(
planner_module, "reconstruct_remaps", return_value=ReconstructedRemaps()
):
with pytest.raises(MigrationError, match="different dataset"):
planner_module.build_dataset_plan(
client=self._resume_client(source_id="DIFFERENT-id"),
name="MyDataset",
to_project="B",
resume_checkpoint=self._checkpoint(),
)
@@ -0,0 +1,241 @@
"""Unit tests for read-only remap reconstruction on resume (OPIK-7168).
``reconstruct_remaps`` rebuilds version_remap / item_id_remap /
optimization_id_remap from an already-migrated destination without writing
anything. These tests mock the REST reads (versions, per-version items,
optimizations) and assert the pairing/identity/fallback logic and the
fail-loud guard on a version-count mismatch.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock
import pytest
from opik.cli.migrate.datasets.resume import (
ResumeReconstructionError,
reconstruct_remaps,
)
class _Version:
def __init__(self, id: str, version_hash: str) -> None:
self.id = id
self.version_hash = version_hash
class _Item:
def __init__(
self,
id: str,
data: Optional[Dict[str, Any]] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
evaluators: Optional[List[Any]] = None,
execution_policy: Optional[Any] = None,
trace_id: Optional[str] = None,
span_id: Optional[str] = None,
source: Optional[str] = None,
) -> None:
self.id = id
self.data = data or {"q": id}
self.description = description
self.tags = tags
self.evaluators = evaluators
self.execution_policy = execution_policy
self.trace_id = trace_id
self.span_id = span_id
self.source = source
class _Optimization:
def __init__(self, id: str, name: str) -> None:
self.id = id
self.name = name
def _page(content: List[Any]) -> MagicMock:
return MagicMock(content=content)
def _rest_client(
*,
source_versions: List[_Version],
dest_versions: List[_Version],
items_by_version_hash: Dict[str, List[_Item]],
source_optimizations: Optional[List[_Optimization]] = None,
dest_optimizations: Optional[List[_Optimization]] = None,
) -> MagicMock:
rest = MagicMock()
def _list_versions(id: str, page: int, size: int) -> MagicMock:
# reconstruct calls _iter_source_versions_chronological which lists
# NEWEST-first then reverses; return newest-first here.
if page != 1:
return _page([])
if id == "src-ds":
return _page(list(reversed(source_versions)))
return _page(list(reversed(dest_versions)))
rest.datasets.list_dataset_versions.side_effect = _list_versions
def _stream(dataset_name: str, steam_limit: int, dataset_version: str, **kw: Any):
# _stream_version_items_raw parses a byte stream via rest_stream_parser;
# tests patch that helper directly (see the patch_stream fixture), so
# this raw endpoint should never be hit.
raise AssertionError("stream_dataset_items should be patched in tests")
rest.datasets.stream_dataset_items.side_effect = _stream
def _find_opts(dataset_id: str, page: int, size: int) -> MagicMock:
if page != 1:
return _page([])
if dataset_id == "src-ds":
return _page(source_optimizations or [])
return _page(dest_optimizations or [])
rest.optimizations.find_optimizations.side_effect = _find_opts
return rest
@pytest.fixture
def patch_stream(monkeypatch: pytest.MonkeyPatch):
"""Patch ``_stream_version_items_raw`` (byte-stream parser) with a direct
lookup keyed by version_hash, so tests supply plain ``_Item`` lists."""
registry: Dict[str, List[_Item]] = {}
def _fake_stream(
rest_client: Any,
*,
dataset_name: str,
project_name: Optional[str],
version_hash: Optional[str],
) -> List[_Item]:
return registry.get(version_hash or "", [])
monkeypatch.setattr(
"opik.cli.migrate.datasets.resume._stream_version_items_raw", _fake_stream
)
return registry
def test_reconstruct__paired_versions_and_identity_items__remaps_built(
patch_stream: Dict[str, List[_Item]],
) -> None:
source_versions = [_Version("sv1", "h1"), _Version("sv2", "h2")]
# Destination hashes DIFFER from source (BE recomputes them) — pairing must
# be positional, not by hash.
dest_versions = [_Version("dv1", "dh1"), _Version("dv2", "dh2")]
# Identity: dest item ids equal source item ids (copy_from preserves them).
patch_stream["h1"] = [_Item("i1"), _Item("i2")]
patch_stream["dh1"] = [_Item("i1"), _Item("i2")]
patch_stream["h2"] = [_Item("i3")]
patch_stream["dh2"] = [_Item("i3")]
rest = _rest_client(
source_versions=source_versions,
dest_versions=dest_versions,
items_by_version_hash={},
)
remaps = reconstruct_remaps(
rest,
source_dataset_id="src-ds",
source_name="ds",
source_project_name=None,
dest_dataset_id="dst-ds",
dest_name="ds",
dest_project_name="proj",
)
assert remaps.version_remap == {"sv1": "dv1", "sv2": "dv2"}
assert remaps.item_id_remap == {"i1": "i1", "i2": "i2", "i3": "i3"}
def test_reconstruct__dest_ids_differ__content_hash_fallback_maps_them(
patch_stream: Dict[str, List[_Item]],
) -> None:
# First-version read-back case: dest item id was minted fresh, so identity
# match fails and we fall back to content-hash matching.
source_versions = [_Version("sv1", "h1")]
dest_versions = [_Version("dv1", "dh1")]
patch_stream["h1"] = [_Item("src-i1", data={"q": "same"})]
patch_stream["dh1"] = [_Item("dst-i1", data={"q": "same"})]
rest = _rest_client(
source_versions=source_versions,
dest_versions=dest_versions,
items_by_version_hash={},
)
remaps = reconstruct_remaps(
rest,
source_dataset_id="src-ds",
source_name="ds",
source_project_name=None,
dest_dataset_id="dst-ds",
dest_name="ds",
dest_project_name="proj",
)
assert remaps.item_id_remap == {"src-i1": "dst-i1"}
def test_reconstruct__version_count_mismatch__raises_reconstruction_error(
patch_stream: Dict[str, List[_Item]],
) -> None:
# Destination has fewer versions than source (e.g. crash mid-replay) — must
# fail loud rather than mis-pair.
rest = _rest_client(
source_versions=[_Version("sv1", "h1"), _Version("sv2", "h2")],
dest_versions=[_Version("dv1", "dh1")],
items_by_version_hash={},
)
with pytest.raises(ResumeReconstructionError, match="Delete the destination"):
reconstruct_remaps(
rest,
source_dataset_id="src-ds",
source_name="ds",
source_project_name=None,
dest_dataset_id="dst-ds",
dest_name="ds",
dest_project_name="proj",
)
def test_reconstruct__optimizations__matched_by_name_into_remap(
patch_stream: Dict[str, List[_Item]],
) -> None:
source_versions = [_Version("sv1", "h1")]
dest_versions = [_Version("dv1", "dh1")]
patch_stream["h1"] = []
patch_stream["dh1"] = []
rest = _rest_client(
source_versions=source_versions,
dest_versions=dest_versions,
items_by_version_hash={},
source_optimizations=[
_Optimization("so1", "opt-a"),
_Optimization("so2", "opt-b"),
],
dest_optimizations=[
_Optimization("do1", "opt-a"),
_Optimization("do2", "opt-b"),
],
)
remaps = reconstruct_remaps(
rest,
source_dataset_id="src-ds",
source_name="ds",
source_project_name=None,
dest_dataset_id="dst-ds",
dest_name="ds",
dest_project_name="proj",
)
assert remaps.optimization_id_remap == {"so1": "do1", "so2": "do2"}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,305 @@
"""Executor tests for ``opik migrate prompt``.
Drives ``execute_plan`` against fully-mocked rest_client surfaces and
asserts that each action dispatches to the right Fern call with the
right kwargs. The verbatim-commit replay path and per-version audit
shape are exercised here (the dataset-side analogue lives in
``test_migrate_dataset_version_replay.py``).
"""
from __future__ import annotations
from typing import Any, List
from unittest.mock import MagicMock
from opik.cli.migrate.audit import AuditLog
from opik.cli.migrate.prompts import executor as executor_module
from opik.cli.migrate.prompts.planner import (
CreateDestination,
MigrationPlan,
RenameSource,
ReplayVersions,
)
from opik.cli.migrate.prompts.resolver import ResolvedPrompt
from opik.rest_api.types.prompt_version_detail import PromptVersionDetail
from ._migrate_prompt_helpers import _Page, _PromptVersionRow
def _client_with_rest_mock(version_pages: List[_Page]) -> Any:
rest_client = MagicMock()
rest_client.prompts.update_prompt = MagicMock()
rest_client.prompts.create_prompt = MagicMock()
rest_client.prompts.create_prompt_version = MagicMock(
side_effect=lambda **kw: MagicMock(
id=f"dest-version-{kw['version'].commit}",
commit=kw["version"].commit,
environments=kw["version"].environments,
)
)
rest_client.prompts.get_prompt_versions.side_effect = version_pages
client = MagicMock()
client.rest_client = rest_client
return client, rest_client
def _make_plan() -> MigrationPlan:
source = ResolvedPrompt(
id="src-1",
name="MyPrompt",
project_id=None,
project_name=None,
description="orig",
tags=["a"],
template_structure="text",
)
plan = MigrationPlan(source=source, target_name="MyPrompt", to_project="B")
plan.actions = [
RenameSource(
source_id="src-1",
from_name="MyPrompt",
to_name="MyPrompt_v1",
description="orig",
tags=["a"],
),
CreateDestination(
name="MyPrompt",
project_name="B",
description="orig",
tags=["a"],
template_structure="text",
),
ReplayVersions(
source_prompt_id="src-1",
source_name_after_rename="MyPrompt_v1",
source_project_name=None,
dest_name="MyPrompt",
dest_project_name="B",
template_structure="text",
),
]
return plan
class TestExecuteActions:
def test_rename_source__re_passes_description_and_tags(self) -> None:
# The BE rename PUT wipes description (no COALESCE in the UPDATE);
# the executor must re-pass it so the source description survives.
client, rest_client = _client_with_rest_mock(
[_Page([])], # no versions
)
plan = _make_plan()
audit = AuditLog(command="opik migrate prompt", args={})
executor_module.execute_plan(client, plan, audit)
rest_client.prompts.update_prompt.assert_called_once_with(
id="src-1",
name="MyPrompt_v1",
description="orig",
tags=["a"],
)
def test_create_destination__omits_template_so_be_does_not_auto_mint_v1(
self,
) -> None:
# If we passed a template here, the BE would auto-create a v1
# with its own commit hash, defeating the verbatim-replay
# guarantee. The executor must NOT include template in the
# create_prompt call.
client, rest_client = _client_with_rest_mock([_Page([])])
plan = _make_plan()
audit = AuditLog(command="opik migrate prompt", args={})
executor_module.execute_plan(client, plan, audit)
rest_client.prompts.create_prompt.assert_called_once()
kwargs = rest_client.prompts.create_prompt.call_args.kwargs
assert kwargs["name"] == "MyPrompt"
assert kwargs["project_name"] == "B"
assert kwargs["description"] == "orig"
assert kwargs["tags"] == ["a"]
assert kwargs["template_structure"] == "text"
# Critical: no template, no type, no metadata at the container
# level — those are version-level fields handled by the replay.
assert "template" not in kwargs
assert "type" not in kwargs
assert "metadata" not in kwargs
def test_replay_versions__preserves_commit_hashes_verbatim(self) -> None:
# The verbatim-commit invariant is the architectural reason this
# slice exists: source versions are minted on the destination
# with the source's exact commit hash, made possible by the
# destination prompt's fresh id (so (workspace_id, prompt_id,
# commit) never collides).
v1 = _PromptVersionRow(
id="src-v-1",
prompt_id="src-1",
commit="aaaaaaaa",
template="hello {{name}}",
type="mustache",
)
v2 = _PromptVersionRow(
id="src-v-2",
prompt_id="src-1",
commit="bbbbbbbb",
template="hello {{name}} v2",
type="mustache",
)
# BE returns newest-first (pv.id DESC); the replay loop reverses
# to oldest-first. Pass them in newest-first order to mirror the
# BE response shape.
client, rest_client = _client_with_rest_mock([_Page([v2, v1])])
plan = _make_plan()
audit = AuditLog(command="opik migrate prompt", args={})
executor_module.execute_plan(client, plan, audit)
# Two POST /v1/private/prompts/versions calls, oldest-first.
assert rest_client.prompts.create_prompt_version.call_count == 2
first_call = rest_client.prompts.create_prompt_version.call_args_list[0]
second_call = rest_client.prompts.create_prompt_version.call_args_list[1]
first_version = first_call.kwargs["version"]
second_version = second_call.kwargs["version"]
assert isinstance(first_version, PromptVersionDetail)
assert isinstance(second_version, PromptVersionDetail)
assert first_version.commit == "aaaaaaaa"
assert second_version.commit == "bbbbbbbb"
assert first_version.template == "hello {{name}}"
assert second_version.template == "hello {{name}} v2"
def test_replay_versions__carries_environments_verbatim(self) -> None:
# Environment ownership is per-version data the BE accepts inline on
# create_prompt_version. The history mixes an env-less version, a
# non-latest version owning a single env, and a latest version owning
# multiple envs — proving single + multi env work and that a
# non-latest version's env is preserved (not just the newest one's).
v1 = _PromptVersionRow(
id="src-v-1",
prompt_id="src-1",
commit="aaaaaaaa",
template="hello",
)
v2 = _PromptVersionRow(
id="src-v-2",
prompt_id="src-1",
commit="bbbbbbbb",
template="hi",
environments=["development"],
)
v3 = _PromptVersionRow(
id="src-v-3",
prompt_id="src-1",
commit="cccccccc",
template="hey",
environments=["production", "staging"],
)
# BE returns newest-first; the loop reverses to oldest-first.
client, rest_client = _client_with_rest_mock([_Page([v3, v2, v1])])
plan = _make_plan()
audit = AuditLog(command="opik migrate prompt", args={})
executor_module.execute_plan(client, plan, audit)
calls = rest_client.prompts.create_prompt_version.call_args_list
assert calls[0].kwargs["version"].environments is None
assert calls[1].kwargs["version"].environments == ["development"]
assert calls[2].kwargs["version"].environments == ["production", "staging"]
def test_replay_versions__records_environments_in_audit(self) -> None:
v1 = _PromptVersionRow(
id="src-v-1",
prompt_id="src-1",
commit="aaaaaaaa",
template="hello",
environments=["staging", "production"],
)
client, _ = _client_with_rest_mock([_Page([v1])])
plan = _make_plan()
audit = AuditLog(command="opik migrate prompt", args={})
executor_module.execute_plan(client, plan, audit)
record = next(a for a in audit.actions if a["type"] == "replay_prompt_version")
# Environments are recorded verbatim (order is irrelevant), so compare
# order-insensitively.
assert sorted(record["source_environments"]) == ["production", "staging"]
assert sorted(record["target_environments"]) == ["production", "staging"]
def test_replay_versions__populates_prompt_version_id_remap(self) -> None:
# Slice 7 (OPIK-6575) reads ``plan.prompt_version_id_remap`` to
# remap experiment FK references. The executor must populate it.
v1 = _PromptVersionRow(
id="src-v-1",
prompt_id="src-1",
commit="aaaaaaaa",
template="hello",
)
v2 = _PromptVersionRow(
id="src-v-2",
prompt_id="src-1",
commit="bbbbbbbb",
template="hi",
)
client, rest_client = _client_with_rest_mock([_Page([v2, v1])])
plan = _make_plan()
audit = AuditLog(command="opik migrate prompt", args={})
executor_module.execute_plan(client, plan, audit)
assert plan.prompt_version_id_remap == {
"src-v-1": "dest-version-aaaaaaaa",
"src-v-2": "dest-version-bbbbbbbb",
}
def test_replay_versions__records_one_audit_entry_per_version(self) -> None:
v1 = _PromptVersionRow(
id="src-v-1",
prompt_id="src-1",
commit="aaaaaaaa",
template="hello",
)
v2 = _PromptVersionRow(
id="src-v-2",
prompt_id="src-1",
commit="bbbbbbbb",
template="hi",
)
client, _ = _client_with_rest_mock([_Page([v2, v1])])
plan = _make_plan()
audit = AuditLog(command="opik migrate prompt", args={})
executor_module.execute_plan(client, plan, audit)
per_version_records = [
a for a in audit.actions if a["type"] == "replay_prompt_version"
]
assert len(per_version_records) == 2
# All per-version records are recorded as ``ok`` (the per-version
# audit happens after a successful create_prompt_version).
assert all(a["status"] == "ok" for a in per_version_records)
assert {a["source_commit"] for a in per_version_records} == {
"aaaaaaaa",
"bbbbbbbb",
}
class TestRecordPlanned:
def test_record_planned__emits_one_planned_entry_per_action(self) -> None:
# Dry-run path: every action gets a ``planned`` record, no REST
# calls fire.
plan = _make_plan()
audit = AuditLog(command="opik migrate prompt", args={})
executor_module.record_planned(plan, audit)
statuses = [a["status"] for a in audit.actions]
types = [a["type"] for a in audit.actions]
assert statuses == ["planned", "planned", "planned"]
assert types == [
"rename_source",
"create_destination",
"replay_versions",
]
@@ -0,0 +1,244 @@
"""Planner tests for ``opik migrate prompt``.
The dataset planner tests are next door; this file tracks the same
patterns (happy-path action ordering, conflict detection, project-not-
found, source-not-found) for the prompt slice.
"""
from __future__ import annotations
import pytest
from click.testing import CliRunner
from opik.cli import cli
from opik.cli.migrate.errors import (
ConflictError,
ProjectNotFoundError,
PromptNotFoundError,
)
from opik.cli.migrate.prompts import planner as prompt_planner
from ._migrate_prompt_helpers import (
_Page,
_PromptRow,
_planner_client,
_planner_rest_client,
)
class TestMigratePromptHelp:
def test_migrate_group_help__lists_prompt_subcommand(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["migrate", "--help"])
assert result.exit_code == 0
assert "prompt" in result.output
def test_migrate_prompt_help__lists_required_flags(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["migrate", "prompt", "--help"])
assert result.exit_code == 0
assert "--to-project" in result.output
assert "--from-project" in result.output
assert "--dry-run" in result.output
class TestPlanBuilding:
def test_build_prompt_plan__default_flow__orders_rename_create_replay(
self,
) -> None:
# Action ordering invariant: the rename frees the workspace-unique
# name BEFORE the destination claims it, and the replay loop runs
# AFTER the destination container exists.
rest_client = _planner_rest_client(
[
_Page([_PromptRow(id="src-1", name="MyPrompt", description="d")]),
_Page([]), # rename-collision preflight finds nothing
]
)
plan = prompt_planner.build_prompt_plan(
client=_planner_client(rest_client),
name="MyPrompt",
to_project="B",
)
types = [type(a).__name__ for a in plan.actions]
assert types == ["RenameSource", "CreateDestination", "ReplayVersions"]
def test_build_prompt_plan__rename_re_passes_description_and_tags(
self,
) -> None:
# The BE rename PUT wipes description (UPDATE has no COALESCE on
# description); the planner forwards the source's description and
# tags into the RenameSource record so the executor re-passes them.
rest_client = _planner_rest_client(
[
_Page(
[
_PromptRow(
id="src-1",
name="MyPrompt",
description="orig description",
tags=["a", "b"],
)
]
),
_Page([]),
]
)
plan = prompt_planner.build_prompt_plan(
client=_planner_client(rest_client),
name="MyPrompt",
to_project="B",
)
rename = plan.actions[0]
assert isinstance(rename, prompt_planner.RenameSource)
assert rename.from_name == "MyPrompt"
assert rename.to_name == "MyPrompt_v1"
assert rename.description == "orig description"
assert rename.tags == ["a", "b"]
def test_build_prompt_plan__create_destination_carries_metadata(
self,
) -> None:
# CreateDestination forwards container-level metadata so the
# destination inherits description / tags / template_structure
# from the source.
rest_client = _planner_rest_client(
[
_Page(
[
_PromptRow(
id="src-1",
name="MyPrompt",
description="orig",
tags=["a"],
template_structure="chat",
)
]
),
_Page([]),
]
)
plan = prompt_planner.build_prompt_plan(
client=_planner_client(rest_client),
name="MyPrompt",
to_project="B",
)
create = plan.actions[1]
assert isinstance(create, prompt_planner.CreateDestination)
assert create.name == "MyPrompt"
assert create.project_name == "B"
assert create.description == "orig"
assert create.tags == ["a"]
assert create.template_structure == "chat"
def test_build_prompt_plan__replay_versions_carries_source_id_and_template_structure(
self,
) -> None:
rest_client = _planner_rest_client(
[
_Page(
[
_PromptRow(
id="src-1",
name="MyPrompt",
template_structure="chat",
)
]
),
_Page([]),
]
)
plan = prompt_planner.build_prompt_plan(
client=_planner_client(rest_client),
name="MyPrompt",
to_project="B",
)
replay = plan.actions[2]
assert isinstance(replay, prompt_planner.ReplayVersions)
assert replay.source_prompt_id == "src-1"
assert replay.source_name_after_rename == "MyPrompt_v1"
assert replay.dest_name == "MyPrompt"
assert replay.dest_project_name == "B"
assert replay.template_structure == "chat"
class TestPreflightErrors:
def test_build_prompt_plan__destination_project_missing__raises(
self,
) -> None:
rest_client = _planner_rest_client(
[], # no prompt lookups happen if project preflight fails
target_project_exists=False,
)
with pytest.raises(ProjectNotFoundError) as excinfo:
prompt_planner.build_prompt_plan(
client=_planner_client(rest_client),
name="MyPrompt",
to_project="DoesNotExist",
)
assert "DoesNotExist" in str(excinfo.value)
def test_build_prompt_plan__source_prompt_missing__raises(self) -> None:
rest_client = _planner_rest_client(
[_Page([])] # source lookup returns no matches
)
with pytest.raises(PromptNotFoundError) as excinfo:
prompt_planner.build_prompt_plan(
client=_planner_client(rest_client),
name="Missing",
to_project="B",
)
assert "Missing" in str(excinfo.value)
assert "workspace" in str(excinfo.value).lower()
def test_build_prompt_plan__from_project_set_and_missing__raises_with_project_scope(
self,
) -> None:
# When --from-project is passed and the source prompt is not in that
# project, the not-found error should name the project (not "the
# workspace") so the user knows where we looked.
rest_client = _planner_rest_client(
[_Page([])] # source lookup returns no matches in the project
)
with pytest.raises(PromptNotFoundError) as excinfo:
prompt_planner.build_prompt_plan(
client=_planner_client(rest_client),
name="MyPrompt",
to_project="B",
from_project="A",
)
assert "MyPrompt" in str(excinfo.value)
assert "project 'A'" in str(excinfo.value)
def test_build_prompt_plan__rename_collision__raises(self) -> None:
# Source resolves, but the rename target ``MyPrompt_v1`` is already
# taken by another prompt -> ConflictError.
rest_client = _planner_rest_client(
[
_Page([_PromptRow(id="src-1", name="MyPrompt")]),
_Page([_PromptRow(id="other-1", name="MyPrompt_v1")]),
]
)
with pytest.raises(ConflictError) as excinfo:
prompt_planner.build_prompt_plan(
client=_planner_client(rest_client),
name="MyPrompt",
to_project="B",
)
assert "MyPrompt_v1" in str(excinfo.value)
@@ -0,0 +1,358 @@
"""Tests for the migration manifest used by opik import for resumable migrations."""
import sqlite3
from pathlib import Path
import pytest
from opik.cli.migration_manifest import MigrationManifest, MANIFEST_FILENAME
@pytest.fixture
def tmp_base(tmp_path: Path) -> Path:
return tmp_path
class TestMigrationManifestLifecycle:
def test_manifest__fresh_instance__status_is_not_started(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
assert manifest.status == "not_started"
assert not manifest.is_in_progress
assert not manifest.is_completed
def test_start__fresh_manifest__status_becomes_in_progress_and_file_written(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
manifest.start()
assert manifest.is_in_progress
assert (tmp_base / MANIFEST_FILENAME).exists()
def test_complete__after_start__status_becomes_completed(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
manifest.start()
manifest.complete()
assert manifest.is_completed
assert not manifest.is_in_progress
def test_reset__after_start_with_data__all_state_cleared(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
manifest.start()
trace_file = tmp_base / "projects" / "p1" / "trace_abc.json"
trace_file.parent.mkdir(parents=True)
trace_file.touch()
manifest.mark_file_completed(trace_file)
manifest.add_trace_mapping("src-1", "dest-1")
manifest.reset()
assert manifest.status == "not_started"
assert manifest.completed_count() == 0
assert manifest.get_trace_id_map() == {}
def test_exists__before_any_save__returns_false(self, tmp_base: Path) -> None:
assert not MigrationManifest.exists(tmp_base)
def test_exists__after_start__returns_true(self, tmp_base: Path) -> None:
MigrationManifest(tmp_base).start()
assert MigrationManifest.exists(tmp_base)
def test_start__called_twice__started_at_preserved(self, tmp_base: Path) -> None:
manifest = MigrationManifest(tmp_base)
manifest.start()
conn = sqlite3.connect(str(tmp_base / MANIFEST_FILENAME))
first_started_at = conn.execute(
"SELECT value FROM status WHERE key = 'started_at'"
).fetchone()[0]
conn.close()
manifest.start() # second call must not overwrite started_at
conn = sqlite3.connect(str(tmp_base / MANIFEST_FILENAME))
second_started_at = conn.execute(
"SELECT value FROM status WHERE key = 'started_at'"
).fetchone()[0]
conn.close()
assert second_started_at == first_started_at
class TestFileTracking:
def _make_file(self, base: Path, relative: str) -> Path:
p = base / relative
p.parent.mkdir(parents=True, exist_ok=True)
p.touch()
return p
def test_is_file_completed__fresh_manifest__returns_false(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
f = self._make_file(tmp_base, "datasets/dataset_foo.json")
assert not manifest.is_file_completed(f)
def test_mark_file_completed__new_file__recorded_and_count_incremented(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
f = self._make_file(tmp_base, "datasets/dataset_foo.json")
manifest.mark_file_completed(f)
assert manifest.is_file_completed(f)
assert manifest.completed_count() == 1
def test_mark_file_completed__same_file_twice__count_remains_one(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
f = self._make_file(tmp_base, "datasets/dataset_foo.json")
manifest.mark_file_completed(f)
manifest.mark_file_completed(f)
assert manifest.completed_count() == 1
def test_mark_file_failed__new_file__recorded_with_error(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
f = self._make_file(tmp_base, "projects/p1/trace_abc.json")
manifest.mark_file_failed(f, "timeout")
assert manifest.failed_count() == 1
assert manifest.get_failed_files()[manifest.relative_path(f)] == "timeout"
def test_mark_file_completed__previously_failed_file__removed_from_failed(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
f = self._make_file(tmp_base, "projects/p1/trace_abc.json")
manifest.mark_file_failed(f, "timeout")
manifest.mark_file_completed(f)
assert manifest.failed_count() == 0
assert manifest.is_file_completed(f)
def test_relative_path__nested_file__returns_path_relative_to_base(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
f = self._make_file(tmp_base, "projects/my-project/trace_xyz.json")
assert manifest.relative_path(f) == str(
Path("projects") / "my-project" / "trace_xyz.json"
)
class TestTraceIdMapping:
def test_add_trace_mapping__single_entry__retrievable_via_get_map(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
manifest.add_trace_mapping("src-111", "dest-222")
manifest.save()
assert manifest.get_trace_id_map() == {"src-111": "dest-222"}
def test_trace_id_map__save_then_new_instance__mapping_survives(
self, tmp_base: Path
) -> None:
m1 = MigrationManifest(tmp_base)
m1.start()
m1.add_trace_mapping("src-aaa", "dest-bbb")
m1.save()
m2 = MigrationManifest(tmp_base)
assert m2.get_trace_id_map() == {"src-aaa": "dest-bbb"}
def test_get_trace_id_map__mutate_returned_dict__original_unaffected(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
manifest.add_trace_mapping("src-1", "dest-1")
manifest.save()
copy = manifest.get_trace_id_map()
copy["src-2"] = "dest-2" # mutate the copy
assert "src-2" not in manifest.get_trace_id_map()
def test_add_trace_mapping__same_src_id_twice__last_dest_wins(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
manifest.add_trace_mapping("src-1", "dest-old")
manifest.add_trace_mapping("src-1", "dest-new")
manifest.save()
assert manifest.get_trace_id_map()["src-1"] == "dest-new"
class TestDatabaseIntegrity:
def test_manifest_file__after_start_and_save__is_valid_sqlite_with_expected_tables(
self, tmp_base: Path
) -> None:
manifest = MigrationManifest(tmp_base)
manifest.start()
manifest.add_trace_mapping("a", "b")
manifest.save()
conn = sqlite3.connect(str(tmp_base / MANIFEST_FILENAME))
tables = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
conn.close()
assert {"status", "completed_files", "failed_files", "trace_id_map"} <= tables
def test_save__after_start__no_tmp_file_created(self, tmp_base: Path) -> None:
manifest = MigrationManifest(tmp_base)
manifest.start()
assert not (tmp_base / "migration_manifest.tmp").exists()
def test_mark_file_completed__duplicate_write__count_remains_one(
self, tmp_base: Path
) -> None:
"""INSERT OR IGNORE means re-flushing the same path never duplicates rows."""
manifest = MigrationManifest(tmp_base, batch_size=1)
f = tmp_base / "projects" / "p" / "trace_x.json"
f.parent.mkdir(parents=True)
f.touch()
manifest.mark_file_completed(f)
manifest.mark_file_completed(f)
assert manifest.completed_count() == 1
class TestBatchingBehavior:
"""Document and verify the crash trade-off introduced by batched writes."""
def _make_trace(self, base: Path, name: str) -> Path:
p = base / "projects" / "proj" / name
p.parent.mkdir(parents=True, exist_ok=True)
p.touch()
return p
def test_mark_file_completed__within_batch__visible_via_api_before_flush(
self, tmp_base: Path
) -> None:
"""Buffered completions are visible through the public API (which flushes
before querying) even before the batch threshold is reached."""
manifest = MigrationManifest(tmp_base, batch_size=50)
f = self._make_trace(tmp_base, "trace_001.json")
manifest.mark_file_completed(f)
# completed_count() flushes first, so the buffered write IS visible.
assert manifest.completed_count() == 1
assert manifest.is_file_completed(f)
def test_mark_file_completed__crash_before_flush__unflushed_data_lost(
self, tmp_base: Path
) -> None:
"""Simulates a crash (new instance, no save()) with batch_size > pending count.
This is the documented trade-off: up to batch_size-1 completions may be
absent from the manifest after a crash. Those files will simply be
re-imported on resume.
"""
f = self._make_trace(tmp_base, "trace_001.json")
m1 = MigrationManifest(tmp_base, batch_size=50)
m1.start() # always flushes immediately
m1.mark_file_completed(f) # buffered — NOT yet on disk
# Simulate crash: m1 is abandoned without save() or complete().
# __del__ is NOT called here because m1 is still in scope when m2 is created.
m2 = MigrationManifest(tmp_base, batch_size=50)
# The completion is not on disk — resume will re-process this file.
assert not m2.is_file_completed(f)
assert m2.completed_count() == 0
def test_mark_file_completed__batch_size_one__each_write_immediately_durable(
self, tmp_base: Path
) -> None:
"""With batch_size=1 every completion is immediately committed to disk."""
f = self._make_trace(tmp_base, "trace_001.json")
m1 = MigrationManifest(tmp_base, batch_size=1)
m1.start()
m1.mark_file_completed(f) # auto-flushes (batch_size=1)
# New instance reads from disk — must see the completion.
m2 = MigrationManifest(tmp_base, batch_size=1)
assert m2.is_file_completed(f)
assert m2.completed_count() == 1
def test_mark_file_completed__batch_threshold_reached__auto_flushed_to_disk(
self, tmp_base: Path
) -> None:
"""When pending count hits batch_size the buffer is auto-flushed."""
batch_size = 3
files = [self._make_trace(tmp_base, f"trace_{i:03d}.json") for i in range(3)]
m1 = MigrationManifest(tmp_base, batch_size=batch_size)
m1.start()
for f in files:
m1.mark_file_completed(f) # third call triggers auto-flush
# New instance must see all three completions.
m2 = MigrationManifest(tmp_base, batch_size=batch_size)
assert m2.completed_count() == 3
for f in files:
assert m2.is_file_completed(f)
class TestResumeScenario:
"""Simulate a real interrupted + resumed migration."""
def test_resume__interrupted_import__completed_files_skipped_and_id_map_available(
self, tmp_base: Path
) -> None:
"""After an interrupted run the second run skips already-flushed files.
batch_size=1 so every mark_file_completed is immediately durable —
this models the boundary between two process invocations where only
flushed data survives.
"""
trace_files = []
for i in range(3):
p = tmp_base / "projects" / "proj" / f"trace_{i:03d}.json"
p.parent.mkdir(parents=True, exist_ok=True)
p.touch()
trace_files.append(p)
m1 = MigrationManifest(tmp_base, batch_size=1)
m1.start()
m1.add_trace_mapping("src-0", "dest-0")
m1.mark_file_completed(trace_files[0])
m1.add_trace_mapping("src-1", "dest-1")
m1.mark_file_completed(trace_files[1])
# Process crashes before trace_files[2] — no complete() call.
m2 = MigrationManifest(tmp_base)
assert m2.is_in_progress
assert m2.completed_count() == 2
assert m2.is_file_completed(trace_files[0])
assert m2.is_file_completed(trace_files[1])
assert not m2.is_file_completed(trace_files[2])
id_map = m2.get_trace_id_map()
assert id_map["src-0"] == "dest-0"
assert id_map["src-1"] == "dest-1"
assert "src-2" not in id_map
def test_reset__after_completed_files__all_state_cleared(
self, tmp_base: Path
) -> None:
trace_file = tmp_base / "projects" / "p" / "trace_abc.json"
trace_file.parent.mkdir(parents=True)
trace_file.touch()
m1 = MigrationManifest(tmp_base)
m1.start()
m1.mark_file_completed(trace_file)
m2 = MigrationManifest(tmp_base)
m2.reset()
assert m2.completed_count() == 0
assert not m2.is_file_completed(trace_file)
assert m2.status == "not_started"
+723
View File
@@ -0,0 +1,723 @@
import base64
import uuid
from unittest.mock import MagicMock, patch
import click
import pytest
from opik.cli.local_runner.pairing import (
PairingResult,
RunnerType,
build_pairing_link,
generate_runner_name,
hkdf_sha256,
resolve_project_id,
run_headless,
run_pairing,
validate_runner_name,
)
from opik.rest_api.errors.not_found_error import NotFoundError
# Helpers — fill in the keyword-only args that production callers always supply
# so tests don't have to repeat them. Tests override what they actually care
# about via **overrides.
def _resolve(api, project_name, **overrides):
kwargs = dict(
create_if_missing=False,
workspace=None,
base_url=None,
config_file_exists=True,
)
kwargs.update(overrides)
return resolve_project_id(api, project_name, **kwargs)
def _headless(api, **overrides):
kwargs = dict(
api=api,
project_name="my-proj",
runner_name="r-1",
runner_type=RunnerType.ENDPOINT,
create_if_missing=False,
workspace=None,
base_url=None,
config_file_exists=True,
)
kwargs.update(overrides)
return run_headless(**kwargs)
def _link(**overrides):
kwargs = dict(
base_url="https://www.comet.com/opik/api/",
session_id="550e8400-e29b-41d4-a716-446655440000",
activation_key=b"\x00" * 32,
project_id="660e8400-e29b-41d4-a716-446655440000",
project_name="my-proj",
runner_name="r",
runner_type=RunnerType.CONNECT,
workspace=None,
)
kwargs.update(overrides)
return build_pairing_link(**kwargs)
def _pair(api, **overrides):
kwargs = dict(
api=api,
project_name="my-proj",
runner_name="r-1",
runner_type=RunnerType.ENDPOINT,
base_url="http://localhost:5173/api/",
workspace=None,
tui=None,
create_if_missing=False,
config_file_exists=True,
)
kwargs.update(overrides)
return run_pairing(**kwargs)
class TestHKDF:
def test_hkdf__pinned_vector__matches_expected(self):
ikm = bytes(range(32))
salt = uuid.UUID("00000000-0000-0000-0000-000000000001").bytes
info = b"opik-bridge-v1"
okm = hkdf_sha256(ikm=ikm, salt=salt, info=info)
assert (
okm.hex()
== "9647b7959765ecad68dfef02f31cfca7f7901a9076c15ebabb1f35d015f71198"
)
def test_hkdf__any_input__output_is_32_bytes(self):
okm = hkdf_sha256(ikm=b"\x00" * 32, salt=b"\x00" * 16, info=b"test")
assert len(okm) == 32
def test_hkdf__different_info__different_output(self):
a = hkdf_sha256(ikm=b"\x00" * 32, salt=b"\x00" * 16, info=b"a")
b = hkdf_sha256(ikm=b"\x00" * 32, salt=b"\x00" * 16, info=b"b")
assert a != b
class TestUUIDBytesOrder:
def test_uuid_bytes__known_uuid__matches_java_big_endian(self):
u = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
assert u.bytes.hex() == "550e8400e29b41d4a716446655440000"
class TestBuildPairingLink:
def test_build_pairing_link__valid_inputs__correct_payload_layout(self):
session_id = "550e8400-e29b-41d4-a716-446655440000"
project_id = "660e8400-e29b-41d4-a716-446655440000"
activation_key = b"\xaa" * 32
runner_name = "my-runner"
link = _link(
base_url="https://www.comet.com/opik/api/",
session_id=session_id,
activation_key=activation_key,
project_id=project_id,
runner_name=runner_name,
)
assert link.startswith("https://www.comet.com/opik/pair/v1?")
fragment = link.split("#", 1)[1]
padding_needed = (4 - len(fragment) % 4) % 4
payload = base64.urlsafe_b64decode(fragment + "=" * padding_needed)
assert payload[0:16] == uuid.UUID(session_id).bytes
assert payload[16:48] == activation_key
assert payload[48:64] == uuid.UUID(project_id).bytes
assert payload[64] == len(runner_name.encode("utf-8"))
name_end = 65 + payload[64]
assert payload[65:name_end] == runner_name.encode("utf-8")
# Default runner type is CONNECT (0x00)
assert payload[name_end] == 0x00
def test_build_pairing_link__endpoint_type__encodes_0x01(self):
link = _link(
base_url="http://localhost:5173/api/",
session_id="550e8400-e29b-41d4-a716-446655440000",
activation_key=b"\x00" * 32,
project_id="660e8400-e29b-41d4-a716-446655440000",
runner_name="r",
runner_type=RunnerType.ENDPOINT,
)
fragment = link.split("#", 1)[1]
padding_needed = (4 - len(fragment) % 4) % 4
payload = base64.urlsafe_b64decode(fragment + "=" * padding_needed)
# name_len=1, name="r", then type byte
assert payload[66] == 0x01
def test_build_pairing_link__cloud_url__no_double_opik_path(self):
link = _link(
base_url="https://www.comet.com/opik/api/",
session_id="550e8400-e29b-41d4-a716-446655440000",
activation_key=b"\x00" * 32,
project_id="660e8400-e29b-41d4-a716-446655440000",
runner_name="r",
)
assert "/opik/opik/" not in link
def test_build_pairing_link__localhost_url__correct_prefix(self):
link = _link(
base_url="http://localhost:5173/api/",
session_id="550e8400-e29b-41d4-a716-446655440000",
activation_key=b"\x00" * 32,
project_id="660e8400-e29b-41d4-a716-446655440000",
runner_name="r",
)
assert link.startswith("http://localhost:5173/opik/pair/v1?")
def test_build_pairing_link__always_includes_url_param(self):
link = _link(
base_url="http://localhost:5173/api/",
workspace=None,
)
# `/api` is stripped — we send the URL the user would visit in a
# browser, not the internal API endpoint.
assert "url=http%3A%2F%2Flocalhost%3A5173%2F" in link
assert "%2Fapi%2F" not in link
def test_build_pairing_link__cloud_api_url__strips_api_suffix(self):
link = _link(
base_url="https://www.comet.com/opik/api/",
workspace=None,
)
assert "url=https%3A%2F%2Fwww.comet.com%2Fopik%2F" in link
def test_build_pairing_link__url_param_precedes_workspace(self):
link = _link(
base_url="http://localhost:5173/api/",
workspace="team-a",
)
# All query params present; order is stable for FE parsing.
assert "url=" in link
assert "project=my-proj" in link
assert "workspace=team-a" in link
def test_build_pairing_link__project_name_with_special_chars__url_encoded(
self,
):
link = _link(project_name="My Project / Demo")
# Spaces and `/` must be percent-encoded so the query stays parseable.
assert "project=My%20Project%20%2F%20Demo" in link
def test_build_pairing_link__workspace_with_special_chars__url_encoded(
self,
):
# `&` and `=` in a workspace name would otherwise split the query and
# the FE's URLSearchParams would read a truncated value.
link = _link(workspace="weird&name=oops")
assert "workspace=weird%26name%3Doops" in link
class TestResolveProjectId:
def test_resolve_project_id__project_exists__returns_id(self):
api = MagicMock()
project = MagicMock()
project.id = "proj-uuid-123"
api.projects.retrieve_project.return_value = project
result = _resolve(api, "my-project")
assert result == "proj-uuid-123"
api.projects.retrieve_project.assert_called_once_with(name="my-project")
def test_resolve_project_id__project_missing__uses_404_hint(self):
from opik.rest_api.core.api_error import ApiError
# Project retrieve returns Opik's custom ErrorMessage shape.
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=404, body={"errors": ["Project not found"]}
)
with pytest.raises(click.ClickException) as exc_info:
_resolve(api, "nonexistent")
message = exc_info.value.message
assert "Could not retrieve project 'nonexistent'" in message
assert "Project not found" in message
assert "check the project name and try again" in message
def test_resolve_project_id__unauthorized__surfaces_server_message_and_docs(self):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=401,
body={"code": 401, "message": "API key should be provided"},
)
with pytest.raises(click.ClickException) as exc_info:
_resolve(api, "my-project")
message = exc_info.value.message
assert "Could not retrieve project 'my-project'" in message
assert "API key should be provided" in message
assert "Run: opik configure" in message
assert (
"https://www.comet.com/docs/opik/tracing/advanced/sdk_configuration"
in message
)
def test_resolve_project_id__forbidden__shares_auth_hint(self):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=403,
body={"code": 403, "message": "User is not allowed to access workspace"},
)
with pytest.raises(click.ClickException) as exc_info:
_resolve(api, "my-project")
message = exc_info.value.message
assert "User is not allowed to access workspace" in message
assert "Run: opik configure" in message
def test_resolve_project_id__server_error__falls_back_to_generic_hint(self):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=500,
body="Internal Server Error",
)
with pytest.raises(click.ClickException) as exc_info:
_resolve(api, "my-project")
message = exc_info.value.message
assert "Internal Server Error" in message
assert "verify your Opik configuration and connectivity" in message
assert (
"https://www.comet.com/docs/opik/tracing/advanced/sdk_configuration"
in message
)
def test_resolve_project_id__empty_body__falls_back_to_status_text(self):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(status_code=502, body=None)
with pytest.raises(click.ClickException) as exc_info:
_resolve(api, "my-project")
assert "server returned HTTP 502" in exc_info.value.message
def test_resolve_project_id__missing_and_create_flag__creates_and_resolves(self):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
created = MagicMock()
created.id = "proj-uuid-new"
api.projects.retrieve_project.side_effect = [
ApiError(status_code=404, body={"errors": ["Project not found"]}),
created,
]
result = _resolve(api, "new-project", create_if_missing=True)
assert result == "proj-uuid-new"
api.projects.create_project.assert_called_once_with(name="new-project")
assert api.projects.retrieve_project.call_count == 2
def test_resolve_project_id__known_missing__skips_initial_lookup(self):
api = MagicMock()
created = MagicMock()
created.id = "proj-uuid-new"
api.projects.retrieve_project.return_value = created
result = _resolve(
api,
"new-project",
create_if_missing=True,
project_known_missing=True,
)
assert result == "proj-uuid-new"
api.projects.create_project.assert_called_once_with(name="new-project")
# Interactive preflight already saw the 404 — only the post-create
# lookup should run.
assert api.projects.retrieve_project.call_count == 1
def test_resolve_project_id__missing_no_flag__does_not_create(self):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=404, body={"errors": ["Project not found"]}
)
with pytest.raises(click.ClickException):
_resolve(api, "missing", create_if_missing=False)
api.projects.create_project.assert_not_called()
def test_resolve_project_id__non_404_with_flag__does_not_create(self):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=401, body={"message": "unauthorized"}
)
with pytest.raises(click.ClickException):
_resolve(api, "x", create_if_missing=True)
api.projects.create_project.assert_not_called()
def test_resolve_project_id__create_fails__raises_clean_error(self):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=404, body={"errors": ["Project not found"]}
)
api.projects.create_project.side_effect = ApiError(
status_code=403, body={"message": "User cannot create projects"}
)
with pytest.raises(click.ClickException) as exc_info:
_resolve(api, "blocked", create_if_missing=True)
message = exc_info.value.message
assert "Could not create project 'blocked'" in message
assert "User cannot create projects" in message
def test_resolve_project_id__retrieval_error_with_config_context__appends_workspace_and_url(
self,
):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=404, body={"errors": ["Project not found"]}
)
with pytest.raises(click.ClickException) as exc_info:
_resolve(
api,
"missing-proj",
workspace="team-a",
base_url="https://opik.example.com/api/",
)
message = exc_info.value.message
assert "team-a" in message
assert "https://opik.example.com/api/" in message
def test_resolve_project_id__retrieval_error_without_config_file__appends_opik_configure_hint(
self,
):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=401, body={"message": "API key should be provided"}
)
with pytest.raises(click.ClickException) as exc_info:
_resolve(
api,
"my-project",
workspace="default",
base_url="https://www.comet.com/opik/api/",
config_file_exists=False,
)
message = exc_info.value.message
assert "~/.opik.config" in message
assert "Run: opik configure" in message
def test_resolve_project_id__retrieval_error_with_config_file__omits_no_config_hint(
self,
):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=500, body="Internal Server Error"
)
with pytest.raises(click.ClickException) as exc_info:
_resolve(
api,
"p",
workspace="default",
base_url="https://www.comet.com/opik/api/",
config_file_exists=True,
)
assert "~/.opik.config" not in exc_info.value.message
def test_resolve_project_id__create_fails_with_config_context__includes_workspace_and_url(
self,
):
from opik.rest_api.core.api_error import ApiError
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=404, body={"errors": ["Project not found"]}
)
api.projects.create_project.side_effect = ApiError(
status_code=403, body={"message": "User cannot create projects"}
)
with pytest.raises(click.ClickException) as exc_info:
_resolve(
api,
"blocked",
create_if_missing=True,
workspace="team-a",
base_url="https://opik.example.com/api/",
config_file_exists=False,
)
message = exc_info.value.message
assert "Could not create project 'blocked'" in message
assert "team-a" in message
assert "https://opik.example.com/api/" in message
assert "Run: opik configure" in message
class TestValidateRunnerName:
def test_validate_runner_name__valid_name__passes(self):
validate_runner_name("my-runner")
def test_validate_runner_name__empty__raises(self):
with pytest.raises(Exception, match="empty"):
validate_runner_name("")
def test_validate_runner_name__whitespace_only__raises(self):
with pytest.raises(Exception, match="empty"):
validate_runner_name(" ")
def test_validate_runner_name__over_128_chars__raises(self):
with pytest.raises(Exception, match="128 characters"):
validate_runner_name("x" * 129)
def test_validate_runner_name__over_255_utf8_bytes__raises(self):
name = "\U0001f600" * 64
with pytest.raises(Exception, match="255 UTF-8 bytes"):
validate_runner_name(name)
class TestGenerateRunnerName:
def test_generate_runner_name__explicit_name__returns_it(self):
assert generate_runner_name("my-name") == "my-name"
def test_generate_runner_name__none__generates_random_hex(self):
name = generate_runner_name(None)
assert "-" in name
hex_part = name.rsplit("-", 1)[1]
assert len(hex_part) == 6
int(hex_part, 16)
class TestRunPairing:
SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
RUNNER_ID = "11111111-2222-3333-4444-555555555555"
PROJECT_ID = "66666666-7777-8888-9999-aaaaaaaaaaaa"
def _make_api(self, project_id=None):
if project_id is None:
project_id = self.PROJECT_ID
api = MagicMock()
project = MagicMock()
project.id = project_id
api.projects.retrieve_project.return_value = project
session_resp = MagicMock()
session_resp.session_id = self.SESSION_ID
session_resp.runner_id = self.RUNNER_ID
api.pairing.create_pairing_session.return_value = session_resp
runner = MagicMock()
runner.status = "connected"
api.runners.get_runner.return_value = runner
return api
@patch("opik.cli.local_runner.pairing.time.sleep")
def test_run_pairing__happyflow__returns_result(self, mock_sleep):
api = self._make_api()
result = _pair(
api=api,
project_name="my-proj",
runner_name="test-runner",
runner_type=RunnerType.CONNECT,
base_url="http://localhost:5173/api/",
ttl_seconds=1,
)
assert isinstance(result, PairingResult)
assert result.runner_id == self.RUNNER_ID
assert result.project_name == "my-proj"
assert result.project_id == self.PROJECT_ID
assert len(result.bridge_key) == 32
@patch("opik.cli.local_runner.pairing.time.sleep")
def test_run_pairing__with_tui__calls_started_and_completed(self, mock_sleep):
api = self._make_api()
tui = MagicMock()
_pair(
api=api,
project_name="my-proj",
runner_name="test-runner",
runner_type=RunnerType.CONNECT,
base_url="http://localhost:5173/api/",
tui=tui,
ttl_seconds=1,
)
tui.pairing_started.assert_called_once()
tui.pairing_completed.assert_called_once()
@patch("opik.cli.local_runner.pairing.time.sleep")
def test_run_pairing__404_during_poll__retries_until_connected(self, mock_sleep):
api = self._make_api()
err = NotFoundError(body=None)
runner = MagicMock()
runner.status = "connected"
api.runners.get_runner.side_effect = [err, err, runner]
result = _pair(
api=api,
project_name="my-proj",
runner_name="test-runner",
runner_type=RunnerType.ENDPOINT,
base_url="http://localhost:5173/api/",
ttl_seconds=1,
)
assert result.runner_id == self.RUNNER_ID
assert api.runners.get_runner.call_count == 3
@patch("opik.cli.local_runner.pairing.time.monotonic")
@patch("opik.cli.local_runner.pairing.time.sleep")
def test_run_pairing__timeout__calls_tui_pairing_failed(
self, mock_sleep, mock_monotonic
):
api = self._make_api()
runner = MagicMock()
runner.status = "pairing"
api.runners.get_runner.return_value = runner
# Strictly-increasing clock: each call returns previous + 1000s. This
# guarantees the while-loop exits on the call immediately after
# `deadline = monotonic() + ttl_seconds` is computed, regardless of
# how many other calls (from logging, coverage, plugins, etc.) land
# on the patched monotonic before or after the test's own calls.
clock = {"t": 0.0}
def advancing_monotonic():
clock["t"] += 1000.0
return clock["t"]
mock_monotonic.side_effect = advancing_monotonic
tui = MagicMock()
with pytest.raises(click.ClickException) as exc_info:
_pair(
api=api,
project_name="my-proj",
runner_name="test-runner",
runner_type=RunnerType.CONNECT,
base_url="http://localhost:5173/api/",
tui=tui,
ttl_seconds=300,
)
assert "timed out" in exc_info.value.message
tui.pairing_failed.assert_called_once_with("timed out")
@patch("opik.cli.local_runner.pairing.time.sleep")
def test_run_pairing__keyboard_interrupt__calls_tui_pairing_failed(
self, mock_sleep
):
api = self._make_api()
api.runners.get_runner.side_effect = KeyboardInterrupt
tui = MagicMock()
with pytest.raises(KeyboardInterrupt):
_pair(
api=api,
project_name="my-proj",
runner_name="test-runner",
runner_type=RunnerType.CONNECT,
base_url="http://localhost:5173/api/",
tui=tui,
ttl_seconds=1,
)
tui.pairing_failed.assert_called_once_with("interrupted")
@patch("opik.cli.local_runner.pairing.time.sleep")
def test_run_pairing__non_404_api_error__propagates(self, mock_sleep):
from opik.rest_api.core.api_error import ApiError
api = self._make_api()
api.runners.get_runner.side_effect = ApiError(
status_code=429, body="rate limited"
)
with pytest.raises(ApiError) as exc_info:
_pair(
api=api,
project_name="my-proj",
runner_name="test-runner",
runner_type=RunnerType.CONNECT,
base_url="http://localhost:5173/api/",
ttl_seconds=1,
)
assert exc_info.value.status_code == 429
class TestRunHeadless:
SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
RUNNER_ID = "11111111-2222-3333-4444-555555555555"
PROJECT_ID = "66666666-7777-8888-9999-aaaaaaaaaaaa"
def _make_api(self):
api = MagicMock()
project = MagicMock()
project.id = self.PROJECT_ID
api.projects.retrieve_project.return_value = project
session_resp = MagicMock()
session_resp.session_id = self.SESSION_ID
session_resp.runner_id = self.RUNNER_ID
api.pairing.create_pairing_session.return_value = session_resp
api.pairing.activate_pairing_session.return_value = None
return api
def test_run_headless__creates_and_self_activates(self):
api = self._make_api()
result = _headless(
api=api,
project_name="my-proj",
runner_name="test-runner",
runner_type=RunnerType.ENDPOINT,
)
assert result.runner_id == self.RUNNER_ID
assert result.project_id == self.PROJECT_ID
assert result.bridge_key == b""
api.pairing.create_pairing_session.assert_called_once()
api.pairing.activate_pairing_session.assert_called_once()
def test_run_headless__no_polling(self):
api = self._make_api()
_headless(
api=api,
project_name="my-proj",
runner_name="test-runner",
runner_type=RunnerType.ENDPOINT,
)
# No get_runner polling — headless activates immediately
api.runners.get_runner.assert_not_called()
def test_run_headless__connect_type__raises(self):
api = self._make_api()
with pytest.raises(click.ClickException, match="not supported"):
_headless(
api=api,
project_name="my-proj",
runner_name="test-runner",
runner_type=RunnerType.CONNECT,
)
+175
View File
@@ -0,0 +1,175 @@
from unittest.mock import MagicMock, patch
import pytest
from opik.cli.local_runner.preflight import (
maybe_auto_configure,
should_create_project,
)
from opik.rest_api.core.api_error import ApiError
class TestShouldCreateProject:
def test__headless__returns_create_without_lookup(self):
api = MagicMock()
result = should_create_project(api, "any", workspace=None, headless=True)
# Headless skips preflight, so we want to create but don't yet know
# whether the project is actually missing — the resolver must look up.
assert result == (True, False)
api.projects.retrieve_project.assert_not_called()
def test__project_exists__returns_no_create(self):
api = MagicMock()
api.projects.retrieve_project.return_value = MagicMock(id="proj-1")
result = should_create_project(api, "exists", workspace="ws", headless=False)
assert result == (False, False)
def test__non_404_error__returns_no_create_to_let_downstream_format(self):
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=401, body={"message": "unauthorized"}
)
result = should_create_project(api, "x", workspace="ws", headless=False)
assert result == (False, False)
@patch("opik.cli.local_runner.preflight.sys.stdin")
def test__missing_and_not_tty__returns_no_create(self, mock_stdin):
mock_stdin.isatty.return_value = False
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=404, body={"errors": ["not found"]}
)
result = should_create_project(api, "missing", workspace="ws", headless=False)
assert result == (False, False)
@patch("opik.cli.local_runner.preflight.click.confirm", return_value=True)
@patch("opik.cli.local_runner.preflight.sys.stdin")
def test__missing_and_tty_user_confirms__returns_create_and_known_missing(
self, mock_stdin, mock_confirm
):
mock_stdin.isatty.return_value = True
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=404, body={"errors": ["not found"]}
)
result = should_create_project(api, "missing", workspace="ws", headless=False)
# Interactive preflight saw 404 and user confirmed — downstream can
# skip the redundant lookup.
assert result == (True, True)
prompt_text = mock_confirm.call_args[0][0]
assert "missing" in prompt_text
assert "ws" in prompt_text
@patch("opik.cli.local_runner.preflight.click.confirm", return_value=False)
@patch("opik.cli.local_runner.preflight.sys.stdin")
def test__missing_and_tty_user_declines__returns_no_create(
self, mock_stdin, mock_confirm
):
mock_stdin.isatty.return_value = True
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(
status_code=404, body={"errors": ["not found"]}
)
result = should_create_project(api, "missing", workspace="ws", headless=False)
assert result == (False, False)
@patch("opik.cli.local_runner.preflight.click.confirm", return_value=True)
@patch("opik.cli.local_runner.preflight.sys.stdin")
def test__no_workspace__prompt_omits_workspace_label(
self, mock_stdin, mock_confirm
):
mock_stdin.isatty.return_value = True
api = MagicMock()
api.projects.retrieve_project.side_effect = ApiError(status_code=404, body={})
should_create_project(api, "missing", workspace=None, headless=False)
prompt_text = mock_confirm.call_args[0][0]
assert "in workspace" not in prompt_text
class TestMaybeAutoConfigure:
def _patch_env(self, monkeypatch, opik_api_key=None):
if opik_api_key is None:
monkeypatch.delenv("OPIK_API_KEY", raising=False)
else:
monkeypatch.setenv("OPIK_API_KEY", opik_api_key)
def _config_probe(self, *, config_file_exists=False, api_key=None):
probe = MagicMock()
probe.config_file_exists = config_file_exists
probe.api_key = api_key
return probe
@patch("opik.cli.configure.run_interactive_configure")
def test__non_interactive_flag__skips(self, mock_configure, monkeypatch):
self._patch_env(monkeypatch)
maybe_auto_configure(api_key_arg=None, non_interactive=True, headless=False)
mock_configure.assert_not_called()
@patch("opik.cli.configure.run_interactive_configure")
def test__headless__skips(self, mock_configure, monkeypatch):
self._patch_env(monkeypatch)
maybe_auto_configure(api_key_arg=None, non_interactive=False, headless=True)
mock_configure.assert_not_called()
@patch("opik.cli.configure.run_interactive_configure")
def test__api_key_arg__skips(self, mock_configure, monkeypatch):
self._patch_env(monkeypatch)
maybe_auto_configure(api_key_arg="abc", non_interactive=False, headless=False)
mock_configure.assert_not_called()
@patch("opik.cli.configure.run_interactive_configure")
def test__env_api_key__skips(self, mock_configure, monkeypatch):
self._patch_env(monkeypatch, opik_api_key="from-env")
maybe_auto_configure(api_key_arg=None, non_interactive=False, headless=False)
mock_configure.assert_not_called()
@patch("opik.cli.configure.run_interactive_configure")
@patch("opik.cli.local_runner.preflight.sys.stdin")
def test__stdin_not_tty__skips(self, mock_stdin, mock_configure, monkeypatch):
self._patch_env(monkeypatch)
mock_stdin.isatty.return_value = False
maybe_auto_configure(api_key_arg=None, non_interactive=False, headless=False)
mock_configure.assert_not_called()
@patch("opik.cli.configure.run_interactive_configure")
@patch("opik.cli.local_runner.preflight.OpikConfig")
@patch("opik.cli.local_runner.preflight.sys.stdin")
def test__config_file_already_exists__skips(
self, mock_stdin, mock_config_cls, mock_configure, monkeypatch
):
self._patch_env(monkeypatch)
mock_stdin.isatty.return_value = True
mock_config_cls.return_value = self._config_probe(config_file_exists=True)
maybe_auto_configure(api_key_arg=None, non_interactive=False, headless=False)
mock_configure.assert_not_called()
@patch("opik.cli.configure.run_interactive_configure")
@patch("opik.cli.local_runner.preflight.OpikConfig")
@patch("opik.cli.local_runner.preflight.sys.stdin")
def test__probe_resolves_api_key_from_env_chain__skips(
self, mock_stdin, mock_config_cls, mock_configure, monkeypatch
):
# The OpikConfig probe layer can pick up an API key from elsewhere
# (e.g. .env file) even when OPIK_API_KEY isn't in os.environ. We
# treat that as "already configured enough" and skip the prompt.
self._patch_env(monkeypatch)
mock_stdin.isatty.return_value = True
mock_config_cls.return_value = self._config_probe(api_key="from-dotenv")
maybe_auto_configure(api_key_arg=None, non_interactive=False, headless=False)
mock_configure.assert_not_called()
@patch("opik.cli.configure.run_interactive_configure")
@patch("opik.cli.local_runner.preflight.OpikConfig")
@patch("opik.cli.local_runner.preflight.sys.stdin")
def test__no_config_and_interactive__runs_configure(
self, mock_stdin, mock_config_cls, mock_configure, monkeypatch
):
self._patch_env(monkeypatch)
mock_stdin.isatty.return_value = True
mock_config_cls.return_value = self._config_probe()
maybe_auto_configure(api_key_arg=None, non_interactive=False, headless=False)
mock_configure.assert_called_once()
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,440 @@
"""Unit tests verifying that item tags round-trip through CLI export/import.
Covers the regression where ``opik export`` / ``opik import`` silently dropped
tags for prompts, datasets, and experiments because the exporters used
hand-written field allowlists that omitted ``tags`` (and the importers never
forwarded them). Traces and spans already round-tripped correctly.
"""
import json
import sys
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
# The experiment/prompt import modules pull in a prompt module that is awkward
# to import in isolation; mirror the shim used by test_import_experiment.py.
sys.modules.setdefault("opik.api_objects.prompt.prompt", MagicMock())
from opik.cli.imports.prompt import import_prompts_from_directory # noqa: E402
from opik.cli.imports.dataset import import_datasets_from_directory # noqa: E402
from opik.cli.exports.prompt import export_single_prompt # noqa: E402
from opik.cli.imports.experiment import ( # noqa: E402
ExperimentData,
recreate_experiment,
)
from opik.cli.exports.utils import create_experiment_data_structure # noqa: E402
class TestPromptTagsImport:
def test_import_text_prompt__with_tags__forwards_tags(self, tmp_path: Path) -> None:
prompt_file = tmp_path / "prompt_p1.json"
prompt_file.write_text(
json.dumps(
{
"name": "greeting",
"current_version": {
"prompt": "Hello {{name}}",
"type": "mustache",
"template_structure": "text",
"tags": ["prod", "greeting"],
},
"history": [],
}
)
)
client = Mock()
client.create_prompt = Mock()
import_prompts_from_directory(
client=client,
source_dir=tmp_path,
project_name="proj",
dry_run=False,
name_pattern=None,
debug=False,
)
client.create_prompt.assert_called_once()
assert client.create_prompt.call_args.kwargs["tags"] == ["prod", "greeting"]
def test_import_chat_prompt__with_tags__forwards_tags(self, tmp_path: Path) -> None:
prompt_file = tmp_path / "prompt_c1.json"
prompt_file.write_text(
json.dumps(
{
"name": "chat",
"current_version": {
"prompt": [{"role": "user", "content": "hi"}],
"type": "mustache",
"template_structure": "chat",
"tags": ["chatty"],
},
"history": [],
}
)
)
client = Mock()
client.create_chat_prompt = Mock()
import_prompts_from_directory(
client=client,
source_dir=tmp_path,
project_name="proj",
dry_run=False,
name_pattern=None,
debug=False,
)
client.create_chat_prompt.assert_called_once()
assert client.create_chat_prompt.call_args.kwargs["tags"] == ["chatty"]
class TestDatasetTagsImport:
def _write_dataset(self, tmp_path: Path, payload: dict) -> None:
(tmp_path / "dataset_d1.json").write_text(json.dumps(payload))
def _make_client(self) -> Mock:
client = Mock()
# Force the create path (get_dataset raises -> create_dataset used).
client.get_dataset = Mock(side_effect=Exception("not found"))
created = Mock()
created.id = "new-ds-id"
client.create_dataset = Mock(return_value=created)
client.rest_client = Mock()
client.rest_client.datasets = Mock()
client.rest_client.datasets.update_dataset = Mock()
return client
def test_import_dataset__flat_format_with_tags__applies_tags(
self, tmp_path: Path
) -> None:
self._write_dataset(
tmp_path,
{"name": "ds", "tags": ["a", "b"], "items": []},
)
client = self._make_client()
import_datasets_from_directory(
client=client,
source_dir=tmp_path,
project_name="proj",
dry_run=False,
name_pattern=None,
debug=False,
)
client.rest_client.datasets.update_dataset.assert_called_once()
call = client.rest_client.datasets.update_dataset.call_args
assert call.args[0] == "new-ds-id"
assert call.kwargs["tags"] == ["a", "b"]
def test_import_dataset__nested_format_with_tags__applies_tags(
self, tmp_path: Path
) -> None:
self._write_dataset(
tmp_path,
{"dataset": {"name": "ds", "id": "x", "tags": ["c"]}, "items": []},
)
client = self._make_client()
import_datasets_from_directory(
client=client,
source_dir=tmp_path,
project_name="proj",
dry_run=False,
name_pattern=None,
debug=False,
)
client.rest_client.datasets.update_dataset.assert_called_once()
assert client.rest_client.datasets.update_dataset.call_args.kwargs["tags"] == [
"c"
]
def test_import_dataset__no_tags_field__skips_update(self, tmp_path: Path) -> None:
self._write_dataset(tmp_path, {"name": "ds", "items": []})
client = self._make_client()
import_datasets_from_directory(
client=client,
source_dir=tmp_path,
project_name="proj",
dry_run=False,
name_pattern=None,
debug=False,
)
client.rest_client.datasets.update_dataset.assert_not_called()
def test_import_dataset__empty_tags_list__clears_tags(self, tmp_path: Path) -> None:
# An explicit empty list must still call the REST update so a
# re-import can clear pre-existing tags on the destination.
self._write_dataset(tmp_path, {"name": "ds", "tags": [], "items": []})
client = self._make_client()
import_datasets_from_directory(
client=client,
source_dir=tmp_path,
project_name="proj",
dry_run=False,
name_pattern=None,
debug=False,
)
client.rest_client.datasets.update_dataset.assert_called_once()
assert client.rest_client.datasets.update_dataset.call_args.kwargs["tags"] == []
def test_import_dataset__existing_dataset_with_description__preserves_description(
self, tmp_path: Path
) -> None:
# Tags-only import must not silently null out a description the
# destination dataset already had (the backend's update PUT doesn't
# COALESCE description against the existing row).
self._write_dataset(
tmp_path,
{"name": "ds", "tags": ["a"], "items": []},
)
client = self._make_client()
# Re-import onto a dataset that already exists (get_dataset succeeds).
existing_dataset = Mock()
existing_dataset.id = "existing-ds-id"
client.get_dataset = Mock(return_value=existing_dataset)
existing_remote = Mock()
existing_remote.description = "original description"
existing_remote.visibility = "private"
client.rest_client.datasets.get_dataset_by_id = Mock(
return_value=existing_remote
)
import_datasets_from_directory(
client=client,
source_dir=tmp_path,
project_name="proj",
dry_run=False,
name_pattern=None,
debug=False,
)
client.rest_client.datasets.update_dataset.assert_called_once()
call = client.rest_client.datasets.update_dataset.call_args
assert call.kwargs["description"] == "original description"
assert call.kwargs["visibility"] == "private"
assert call.kwargs["tags"] == ["a"]
def test_import_dataset__update_dataset_raises__continues_and_inserts_items(
self, tmp_path: Path
) -> None:
# A tag-update failure must be swallowed with a warning so the rest of
# the import (item insertion, count, manifest) still proceeds.
self._write_dataset(
tmp_path,
{"name": "ds", "tags": ["a"], "items": [{"id": "1", "input": "x"}]},
)
client = self._make_client()
client.rest_client.datasets.update_dataset = Mock(side_effect=Exception("boom"))
result = import_datasets_from_directory(
client=client,
source_dir=tmp_path,
project_name="proj",
dry_run=False,
name_pattern=None,
debug=False,
)
# The import did not abort: items were still inserted and the dataset
# counted as imported.
client.create_dataset.return_value.insert.assert_called_once()
assert result["datasets"] == 1
assert result["datasets_errors"] == 0
class TestPromptTagsExport:
"""Prompt tags are container-level and are dropped by the direct
get_prompt() lookup; the exporter must recover them via search_prompts.
"""
def test_export_single_prompt__object_has_tags__skips_search(
self, tmp_path: Path
) -> None:
prompt = Mock()
prompt.id = "p1"
prompt.name = "greeting"
prompt.tags = ["prod"] # direct lookup already carries the tags
client = Mock()
# No history available; _safe_prompt_history swallows the error.
client.get_prompt_history = Mock(side_effect=Exception("no history"))
count = export_single_prompt(
client=client,
prompt=prompt,
output_dir=tmp_path,
project_name="proj",
max_results=None,
force=True,
debug=False,
format="json",
)
assert count == 1
data = json.loads((tmp_path / "prompt_p1.json").read_text())
assert data["current_version"]["tags"] == ["prod"]
# Tags were already present, so no recovery search is needed.
client.search_prompts.assert_not_called()
def test_export_single_prompt__container_only_tags__recovered_from_search(
self, tmp_path: Path
) -> None:
prompt = Mock()
prompt.id = "p1"
prompt.name = "greeting"
prompt.tags = [] # direct lookup dropped the container-level tags
candidate = Mock()
candidate.name = "greeting"
candidate.tags = ["prod", "greeting"]
client = Mock()
client.search_prompts = Mock(return_value=[candidate])
# No history available; _safe_prompt_history swallows the error.
client.get_prompt_history = Mock(side_effect=Exception("no history"))
count = export_single_prompt(
client=client,
prompt=prompt,
output_dir=tmp_path,
project_name="proj",
max_results=None,
force=True,
debug=False,
format="json",
)
assert count == 1
data = json.loads((tmp_path / "prompt_p1.json").read_text())
assert data["current_version"]["tags"] == ["prod", "greeting"]
def test_export_single_prompt__search_fails__still_exports_with_fallback(
self, tmp_path: Path
) -> None:
# Tag resolution is best-effort: a search_prompts failure is logged but
# must not abort the export — the prompt still exports with whatever tags
# the object carried.
prompt = Mock()
prompt.id = "p1"
prompt.name = "greeting"
prompt.tags = [] # nothing on the object; recovery will be attempted
client = Mock()
client.search_prompts = Mock(side_effect=Exception("api down"))
client.get_prompt_history = Mock(side_effect=Exception("no history"))
count = export_single_prompt(
client=client,
prompt=prompt,
output_dir=tmp_path,
project_name="proj",
max_results=None,
force=True,
debug=False,
format="json",
)
assert count == 1
data = json.loads((tmp_path / "prompt_p1.json").read_text())
assert data["current_version"]["tags"] == []
class TestExperimentTags:
def test_export_experiment_structure__with_tags__includes_tags(self) -> None:
experiment = Mock()
experiment.id = "exp-1"
experiment.name = "e"
experiment.dataset_name = "ds"
data_obj = Mock()
data_obj.tags = ["t1", "t2"]
experiment.get_experiment_data = Mock(return_value=data_obj)
structure = create_experiment_data_structure(experiment, [])
assert structure["experiment"]["tags"] == ["t1", "t2"]
def test_import_experiment_from_disk__with_tags__forwards_tags(self) -> None:
client = Mock()
client.flush = Mock(return_value=True)
client.get_or_create_dataset = Mock(return_value=Mock(name="ds"))
created_experiment = Mock()
created_experiment.id = "exp-123"
client.create_experiment = Mock(return_value=created_experiment)
client._rest_client = Mock()
experiment_data = ExperimentData(
experiment={
"id": "exp-123",
"name": "test-experiment",
"dataset_name": "test-dataset",
"type": "regular",
"tags": ["golden", "regression"],
},
items=[],
)
with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers:
mock_id_helpers.generate_id = Mock(return_value="generated-id")
recreate_experiment(
client,
experiment_data,
"test-project",
{}, # trace_id_map
{}, # dataset_item_id_map
dry_run=False,
debug=False,
# target_project_name defaults to None -> import-from-disk path
)
client.create_experiment.assert_called_once()
assert client.create_experiment.call_args.kwargs["tags"] == [
"golden",
"regression",
]
def test_import_experiment_from_disk__empty_tags_list__forwards_empty_tags(
self,
) -> None:
# An explicit empty list must round-trip as tags=[], not collapse to
# tags=None (which would drop a deliberately cleared tag set).
client = Mock()
client.flush = Mock(return_value=True)
client.get_or_create_dataset = Mock(return_value=Mock(name="ds"))
created_experiment = Mock()
created_experiment.id = "exp-123"
client.create_experiment = Mock(return_value=created_experiment)
client._rest_client = Mock()
experiment_data = ExperimentData(
experiment={
"id": "exp-123",
"name": "test-experiment",
"dataset_name": "test-dataset",
"type": "regular",
"tags": [],
},
items=[],
)
with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers:
mock_id_helpers.generate_id = Mock(return_value="generated-id")
recreate_experiment(
client,
experiment_data,
"test-project",
{}, # trace_id_map
{}, # dataset_item_id_map
dry_run=False,
debug=False,
)
client.create_experiment.assert_called_once()
assert client.create_experiment.call_args.kwargs["tags"] == []
@@ -0,0 +1,557 @@
"""Unit tests for matches_trace_filter in exports/trace_filter.py."""
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
from opik.cli.exports.trace_filter import matches_trace_filter
def _make_oql_module(mock_oql: MagicMock) -> ModuleType:
"""Return a fake opik.api_objects.opik_query_language module whose
OpikQueryLanguage.for_traces() returns *mock_oql*."""
mod = ModuleType("opik.api_objects.opik_query_language")
cls = MagicMock()
cls.for_traces.return_value = mock_oql
mod.OpikQueryLanguage = cls # type: ignore[attr-defined]
return mod
class TestMatchesTraceFilter:
def test_matches_trace_filter__date_time_gte__matches_trace_after_cutoff(self):
"""A trace whose created_at is after the cutoff must pass the filter."""
trace = {
"created_at": "2024-06-01T12:00:00+00:00",
}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "created_at",
"operator": ">=",
"value": "2024-01-01T00:00:00+00:00",
"type": "date_time",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'created_at >= "2024-01-01T00:00:00Z"')
assert result is True
def test_matches_trace_filter__date_time_gte__excludes_trace_before_cutoff(self):
"""A trace whose created_at is before the cutoff must be excluded."""
trace = {
"created_at": "2023-06-01T12:00:00+00:00",
}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "created_at",
"operator": ">=",
"value": "2024-01-01T00:00:00+00:00",
"type": "date_time",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'created_at >= "2024-01-01T00:00:00Z"')
assert result is False
def test_matches_trace_filter__invalid_filter__returns_true_with_warning(self):
"""An unparseable filter string must return True and log a warning."""
import opik.cli.exports.trace_filter as trace_filter_module
trace = {"name": "my-trace"}
fake_mod = ModuleType("opik.api_objects.opik_query_language")
cls = MagicMock()
cls.for_traces.side_effect = ValueError("bad filter syntax")
fake_mod.OpikQueryLanguage = cls # type: ignore[attr-defined]
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
with patch.object(trace_filter_module.logger, "warning") as mock_warn:
result = matches_trace_filter(trace, "THIS IS NOT VALID OQL")
assert result is True
mock_warn.assert_called_once()
# The warning message should contain both the filter string and the exception
warning_args = mock_warn.call_args
assert "bad filter syntax" in str(warning_args) or any(
"bad filter syntax" in str(a) for a in warning_args.args
)
def test_matches_trace_filter__string_contains__happyflow(self):
"""A string 'contains' filter must match when the substring is present."""
trace = {"name": "evaluation-run-42"}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "name",
"operator": "contains",
"value": "evaluation",
"type": "string",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'name contains "evaluation"')
assert result is True
def test_matches_trace_filter__no_expressions__returns_true(self):
"""When the filter parses to zero expressions every trace must pass."""
trace = {"name": "any-trace"}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = []
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, "")
assert result is True
# ------------------------------------------------------------------
# number type
# ------------------------------------------------------------------
def test_matches_trace_filter__number_gte__matches(self):
"""A number >= filter must match when the field value satisfies it."""
trace = {"usage": {"total_tokens": 500}}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "usage.total_tokens",
"operator": ">=",
"value": "100",
"type": "number",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'usage.total_tokens >= "100"')
assert result is True
def test_matches_trace_filter__number_lt__excludes(self):
"""A number < filter must exclude a trace that doesn't satisfy it."""
trace = {"usage": {"total_tokens": 50}}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "usage.total_tokens",
"operator": "<",
"value": "10",
"type": "number",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'usage.total_tokens < "10"')
assert result is False
# ------------------------------------------------------------------
# is_empty / is_not_empty
# ------------------------------------------------------------------
def test_matches_trace_filter__is_empty__none_field__matches(self):
"""is_empty must match when the field is absent / None."""
trace = {} # 'error' key is absent
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "error",
"operator": "is_empty",
"value": "",
"type": "string",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, "error is_empty")
assert result is True
def test_matches_trace_filter__is_empty__non_empty_field__excludes(self):
"""is_empty must exclude when the field has a value."""
trace = {"error": "something went wrong"}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "error",
"operator": "is_empty",
"value": "",
"type": "string",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, "error is_empty")
assert result is False
def test_matches_trace_filter__is_not_empty__non_empty_field__matches(self):
"""is_not_empty must match when the field is present and non-empty."""
trace = {"name": "my-trace"}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "name",
"operator": "is_not_empty",
"value": "",
"type": "string",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, "name is_not_empty")
assert result is True
def test_matches_trace_filter__is_not_empty__none_field__excludes(self):
"""is_not_empty must exclude when the field is absent."""
trace = {}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "name",
"operator": "is_not_empty",
"value": "",
"type": "string",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, "name is_not_empty")
assert result is False
# ------------------------------------------------------------------
# Additional string operators
# ------------------------------------------------------------------
def test_matches_trace_filter__string_not_contains__excludes(self):
"""not_contains must exclude when the substring is present."""
trace = {"name": "evaluation-run-42"}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "name",
"operator": "not_contains",
"value": "evaluation",
"type": "string",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'name not_contains "evaluation"')
assert result is False
def test_matches_trace_filter__string_starts_with__matches(self):
"""starts_with must match when the field begins with the prefix."""
trace = {"name": "evaluation-run-42"}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "name",
"operator": "starts_with",
"value": "evaluation",
"type": "string",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'name starts_with "evaluation"')
assert result is True
def test_matches_trace_filter__string_ends_with__matches(self):
"""ends_with must match when the field ends with the suffix."""
trace = {"name": "evaluation-run-42"}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "name",
"operator": "ends_with",
"value": "42",
"type": "string",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'name ends_with "42"')
assert result is True
# ------------------------------------------------------------------
# Nested / dotted field access
# ------------------------------------------------------------------
def test_matches_trace_filter__dotted_field__matches(self):
"""Dotted fields like 'usage.total_tokens' should be resolved correctly."""
trace = {"usage": {"total_tokens": 200}}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "usage.total_tokens",
"operator": "=",
"value": "200",
"type": "number",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'usage.total_tokens = "200"')
assert result is True
def test_matches_trace_filter__key_field_access__matches(self):
"""Expressions with a non-empty 'key' resolve via trace_dict[field][key]."""
trace = {"metadata": {"env": "production"}}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "metadata",
"operator": "=",
"value": "production",
"type": "string",
"key": "env",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'metadata.env = "production"')
assert result is True
# ------------------------------------------------------------------
# None field value → False
# ------------------------------------------------------------------
def test_matches_trace_filter__missing_field__returns_false(self):
"""When a non-empty-check expression references a missing field the trace must be excluded."""
trace = {} # 'name' is absent
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "name",
"operator": "=",
"value": "something",
"type": "string",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'name = "something"')
assert result is False
# ------------------------------------------------------------------
# Multiple expressions (AND semantics)
# ------------------------------------------------------------------
def test_matches_trace_filter__multiple_expressions__all_must_pass(self):
"""When two expressions are given both must be satisfied (AND logic)."""
trace = {
"name": "evaluation-run-42",
"created_at": "2024-06-01T12:00:00+00:00",
}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "name",
"operator": "contains",
"value": "evaluation",
"type": "string",
"key": "",
},
{
"field": "created_at",
"operator": ">=",
"value": "2024-01-01T00:00:00+00:00",
"type": "date_time",
"key": "",
},
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'name contains "evaluation"')
assert result is True
def test_matches_trace_filter__multiple_expressions__first_fails__returns_false(
self,
):
"""When the first of two expressions fails the trace must be excluded."""
trace = {
"name": "other-run",
"created_at": "2024-06-01T12:00:00+00:00",
}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "name",
"operator": "contains",
"value": "evaluation",
"type": "string",
"key": "",
},
{
"field": "created_at",
"operator": ">=",
"value": "2024-01-01T00:00:00+00:00",
"type": "date_time",
"key": "",
},
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'name contains "evaluation"')
assert result is False
# ------------------------------------------------------------------
# date_time: naive datetime field value (no tzinfo)
# ------------------------------------------------------------------
def test_matches_trace_filter__date_time_naive__treated_as_utc(self):
"""A naive datetime field value must be compared as UTC."""
from datetime import datetime
trace = {"created_at": datetime(2024, 6, 1, 12, 0, 0)} # no tzinfo
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "created_at",
"operator": ">=",
"value": "2024-01-01T00:00:00+00:00",
"type": "date_time",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'created_at >= "2024-01-01T00:00:00Z"')
assert result is True
# ------------------------------------------------------------------
# date_time: unparseable field value → result=False (not exception)
# ------------------------------------------------------------------
def test_matches_trace_filter__date_time_unparseable_value__returns_false(self):
"""An unparseable date_time field value must exclude the trace, not raise."""
trace = {"created_at": "not-a-date"}
mock_oql = MagicMock()
mock_oql.get_filter_expressions.return_value = [
{
"field": "created_at",
"operator": ">=",
"value": "2024-01-01T00:00:00+00:00",
"type": "date_time",
"key": "",
}
]
fake_mod = _make_oql_module(mock_oql)
with patch.dict(
sys.modules, {"opik.api_objects.opik_query_language": fake_mod}
):
result = matches_trace_filter(trace, 'created_at >= "2024-01-01T00:00:00Z"')
assert result is False
@@ -0,0 +1,137 @@
import httpx
from opik.configurator.mcp import detection
BASE_URL = "https://dev.comet.com/"
API_URL = "https://dev.comet.com/opik/api/"
WELL_KNOWN_URL = "https://dev.comet.com/.well-known/oauth-authorization-server/opik"
MCP_URL = "https://dev.comet.com/opik/api/v1/mcp"
_VALID_METADATA = {
"issuer": "https://dev.comet.com/opik",
"authorization_endpoint": "https://dev.comet.com/opik/oauth/authorize",
"token_endpoint": "https://dev.comet.com/opik/oauth/token",
}
class _Response:
def __init__(self, status_code, json_data=None, json_error=False):
self.status_code = status_code
self._json_data = json_data
self._json_error = json_error
def json(self):
if self._json_error:
raise ValueError("Expecting value")
return self._json_data
def _patch_client(monkeypatch, *, response=None, error=None):
"""Wire ``httpx_client.get`` to a fake client, capturing the probe URL and the
client kwargs (so tests can assert the TLS flag is forwarded)."""
captured = {}
class _Client:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def get(self, url, timeout=None):
captured["url"] = url
captured["timeout"] = timeout
if error is not None:
raise error
return response
def fake_get(**kwargs):
captured["client_kwargs"] = kwargs
return _Client()
monkeypatch.setattr(detection.httpx_client, "get", fake_get)
return captured
def test_detect__valid_oauth_metadata__returns_mcp_url(monkeypatch):
captured = _patch_client(
monkeypatch, response=_Response(200, json_data=_VALID_METADATA)
)
result = detection.detect_hosted_mcp_server(
base_url=BASE_URL, api_url=API_URL, check_tls_certificate=True
)
assert result == MCP_URL
assert captured["url"] == WELL_KNOWN_URL
def test_detect__forwards_tls_flag_without_rereading_config(monkeypatch):
captured = _patch_client(
monkeypatch, response=_Response(200, json_data=_VALID_METADATA)
)
detection.detect_hosted_mcp_server(
base_url=BASE_URL, api_url=API_URL, check_tls_certificate=False
)
assert captured["client_kwargs"]["check_tls_certificate"] is False
def test_detect__html_catch_all_200__returns_none(monkeypatch):
# The frontend SPA answers any unknown path with HTML and a 200 status.
_patch_client(monkeypatch, response=_Response(200, json_error=True))
result = detection.detect_hosted_mcp_server(
base_url=BASE_URL, api_url=API_URL, check_tls_certificate=True
)
assert result is None
def test_detect__not_found__returns_none(monkeypatch):
_patch_client(
monkeypatch, response=_Response(404, json_data={"error": "not_found"})
)
assert (
detection.detect_hosted_mcp_server(
base_url=BASE_URL, api_url=API_URL, check_tls_certificate=True
)
is None
)
def test_detect__json_without_authorization_endpoint__returns_none(monkeypatch):
_patch_client(
monkeypatch, response=_Response(200, json_data={"unrelated": "value"})
)
assert (
detection.detect_hosted_mcp_server(
base_url=BASE_URL, api_url=API_URL, check_tls_certificate=True
)
is None
)
def test_detect__non_dict_json__returns_none(monkeypatch):
_patch_client(monkeypatch, response=_Response(200, json_data=["not", "a", "dict"]))
assert (
detection.detect_hosted_mcp_server(
base_url=BASE_URL, api_url=API_URL, check_tls_certificate=True
)
is None
)
def test_detect__network_error__returns_none(monkeypatch):
_patch_client(monkeypatch, error=httpx.ConnectError("unreachable"))
assert (
detection.detect_hosted_mcp_server(
base_url=BASE_URL, api_url=API_URL, check_tls_certificate=True
)
is None
)
@@ -0,0 +1,59 @@
from opik.configurator.mcp import env
def test_build_mcp_env__cloud__api_key_and_workspace_only():
result = env.build_mcp_env(
api_key="some-key",
workspace="my-workspace",
base_url="https://www.comet.com/",
api_url="https://www.comet.com/opik/api/",
use_local=False,
self_hosted_comet=False,
)
assert result == {
"OPIK_API_KEY": "some-key",
"COMET_WORKSPACE": "my-workspace",
}
def test_build_mcp_env__self_hosted_comet__url_override_is_base_url():
result = env.build_mcp_env(
api_key="some-key",
workspace="my-workspace",
base_url="https://opik.acme.com/",
api_url="https://opik.acme.com/opik/api/",
use_local=False,
self_hosted_comet=True,
)
assert result == {
"OPIK_API_KEY": "some-key",
"COMET_WORKSPACE": "my-workspace",
"COMET_URL_OVERRIDE": "https://opik.acme.com",
}
def test_build_mcp_env__local__opik_url_is_api_url_without_api_key():
result = env.build_mcp_env(
api_key=None,
workspace="default",
base_url="http://localhost:5173/",
api_url="http://localhost:5173/api/",
use_local=True,
self_hosted_comet=False,
)
assert result == {
"COMET_WORKSPACE": "default",
"OPIK_URL": "http://localhost:5173/api/",
}
def test_build_mcp_env__workspace_passed_verbatim__not_lowercased():
result = env.build_mcp_env(
api_key="some-key",
workspace="Mixed-Case-WS",
base_url="https://www.comet.com/",
api_url="https://www.comet.com/opik/api/",
use_local=False,
self_hosted_comet=False,
)
assert result["COMET_WORKSPACE"] == "Mixed-Case-WS"
@@ -0,0 +1,360 @@
import pathlib
import subprocess
from unittest import mock
import pytest
from opik.configurator.mcp import install, spec, targets
@pytest.fixture(autouse=True)
def prefetch_run(monkeypatch):
"""Stub the pre-fetch subprocess so tests never shell out to uv."""
run_mock = mock.Mock(
return_value=subprocess.CompletedProcess([], 0, stdout="", stderr="")
)
monkeypatch.setattr(install.subprocess, "run", run_mock)
return run_mock
@pytest.fixture(autouse=True)
def no_hosted_mcp(monkeypatch):
"""Default every test to the uvx fallback: no hosted MCP server detected.
Tests that exercise the remote path override this within the test body.
"""
monkeypatch.setattr(
install.mcp_detection, "detect_hosted_mcp_server", lambda **kwargs: None
)
def _make_args(**overrides):
args = dict(
api_key="some-key",
workspace="ws",
base_url="https://www.comet.com/",
api_url="https://www.comet.com/opik/api/",
use_local=False,
self_hosted_comet=False,
check_tls_certificate=True,
force_local_server=False,
)
args.update(overrides)
return args
def _target(key, detected, install_fn):
return targets.HostTarget(
key=key,
display_name=key,
config_path=lambda: pathlib.Path("/dev/null"),
top_level_key="mcpServers",
is_detected=lambda: detected,
install=install_fn,
)
def test_setup_mcp_server__uvx_missing__does_not_install(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: None)
install_spy = mock.Mock()
monkeypatch.setattr(
targets, "HOST_TARGETS", [_target("claude-code", True, install_spy)]
)
install.setup_mcp_server(**_make_args())
install_spy.assert_not_called()
def test_setup_mcp_server__no_host_detected__does_not_install(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
install_spy = mock.Mock()
monkeypatch.setattr(
targets, "HOST_TARGETS", [_target("cursor", False, install_spy)]
)
install.setup_mcp_server(**_make_args())
install_spy.assert_not_called()
def test_setup_mcp_server__no_host__manual_config_redacts_api_key(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
monkeypatch.setattr(
targets, "HOST_TARGETS", [_target("cursor", False, mock.Mock())]
)
logger_spy = mock.Mock()
monkeypatch.setattr(install, "LOGGER", logger_spy)
install.setup_mcp_server(**_make_args())
logged = " ".join(str(call) for call in logger_spy.info.call_args_list)
assert "some-key" not in logged
assert "***REDACTED***" in logged
def test_setup_mcp_server__single_host_selected__installs(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
install_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
monkeypatch.setattr(targets, "HOST_TARGETS", [_target("cursor", True, install_spy)])
monkeypatch.setattr("builtins.input", lambda message: "y") # confirm the host
install.setup_mcp_server(**_make_args())
install_spy.assert_called_once()
spec = install_spy.call_args.args[0]
assert spec.command == "/usr/bin/uvx"
assert spec.args == ["opik-mcp"]
assert spec.env["OPIK_API_KEY"] == "some-key"
def test_setup_mcp_server__menu_lists_detected_hosts(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
monkeypatch.setattr(
targets,
"HOST_TARGETS",
[
_target("Claude Code", True, mock.Mock()),
_target("Cursor", True, mock.Mock()),
_target("VS Code Copilot", False, mock.Mock()),
],
)
prompts = []
def fake_input(message):
prompts.append(message)
return "4" # Skip (2 hosts -> 1,2 hosts, 3 all, 4 skip)
monkeypatch.setattr("builtins.input", fake_input)
install.setup_mcp_server(**_make_args())
assert "Claude Code" in prompts[0]
assert "Cursor" in prompts[0]
assert "All of the above" in prompts[0]
assert "VS Code Copilot" not in prompts[0]
def test_setup_mcp_server__select_all__installs_every_detected_host(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
claude_spy = mock.Mock(return_value=targets.InstallResult("Claude", True, "Added"))
cursor_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
monkeypatch.setattr(
targets,
"HOST_TARGETS",
[_target("Claude Code", True, claude_spy), _target("Cursor", True, cursor_spy)],
)
monkeypatch.setattr("builtins.input", lambda message: "3") # All of the above
install.setup_mcp_server(**_make_args())
claude_spy.assert_called_once()
cursor_spy.assert_called_once()
def test_setup_mcp_server__comma_separated_selection__installs_each(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
claude_spy = mock.Mock(return_value=targets.InstallResult("Claude", True, "Added"))
cursor_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
vscode_spy = mock.Mock(return_value=targets.InstallResult("VS Code", True, "Added"))
monkeypatch.setattr(
targets,
"HOST_TARGETS",
[
_target("Claude Code", True, claude_spy),
_target("Cursor", True, cursor_spy),
_target("VS Code Copilot", True, vscode_spy),
],
)
monkeypatch.setattr("builtins.input", lambda message: "1,3") # Claude + VS Code
install.setup_mcp_server(**_make_args())
claude_spy.assert_called_once()
vscode_spy.assert_called_once()
cursor_spy.assert_not_called()
def test_setup_mcp_server__invalid_menu_choice_then_valid__retries(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
claude_spy = mock.Mock(return_value=targets.InstallResult("Claude", True, "Added"))
cursor_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
monkeypatch.setattr(
targets,
"HOST_TARGETS",
[_target("Claude Code", True, claude_spy), _target("Cursor", True, cursor_spy)],
)
# invalid (non-digit), out-of-range, then a valid single choice
monkeypatch.setattr("builtins.input", mock.Mock(side_effect=["x", "99", "2"]))
install.setup_mcp_server(**_make_args())
cursor_spy.assert_called_once()
claude_spy.assert_not_called()
def test_setup_mcp_server__select_subset__installs_only_chosen(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
claude_spy = mock.Mock(return_value=targets.InstallResult("Claude", True, "Added"))
cursor_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
monkeypatch.setattr(
targets,
"HOST_TARGETS",
[_target("Claude Code", True, claude_spy), _target("Cursor", True, cursor_spy)],
)
monkeypatch.setattr("builtins.input", lambda message: "2") # only Cursor
install.setup_mcp_server(**_make_args())
claude_spy.assert_not_called()
cursor_spy.assert_called_once()
def test_setup_mcp_server__user_skips__does_not_install(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
install_spy = mock.Mock()
monkeypatch.setattr(targets, "HOST_TARGETS", [_target("cursor", True, install_spy)])
monkeypatch.setattr("builtins.input", lambda message: "n") # decline
install.setup_mcp_server(**_make_args())
install_spy.assert_not_called()
def test_setup_mcp_server__prefetches_opik_mcp_before_install(
monkeypatch, prefetch_run
):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
install_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
monkeypatch.setattr(targets, "HOST_TARGETS", [_target("cursor", True, install_spy)])
monkeypatch.setattr("builtins.input", lambda message: "y")
install.setup_mcp_server(**_make_args())
commands = [call.args[0] for call in prefetch_run.call_args_list]
assert any(cmd[1:] == ["tool", "install", "opik-mcp"] for cmd in commands)
install_spy.assert_called_once()
def test_setup_mcp_server__prefetch_failure__is_non_fatal(monkeypatch, prefetch_run):
prefetch_run.return_value = subprocess.CompletedProcess(
[], 1, stdout="", stderr="network unreachable"
)
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
install_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
monkeypatch.setattr(targets, "HOST_TARGETS", [_target("cursor", True, install_spy)])
monkeypatch.setattr("builtins.input", lambda message: "y")
install.setup_mcp_server(**_make_args())
install_spy.assert_called_once()
def test_setup_mcp_server__prefetch_raises_oserror__is_non_fatal(
monkeypatch, prefetch_run
):
prefetch_run.side_effect = OSError("uv vanished mid-flight")
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
install_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
monkeypatch.setattr(targets, "HOST_TARGETS", [_target("cursor", True, install_spy)])
monkeypatch.setattr("builtins.input", lambda message: "y")
install.setup_mcp_server(**_make_args()) # must not raise
install_spy.assert_called_once()
def test_setup_mcp_server__skip__does_not_prefetch(monkeypatch, prefetch_run):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
monkeypatch.setattr(targets, "HOST_TARGETS", [_target("cursor", True, mock.Mock())])
monkeypatch.setattr("builtins.input", lambda message: "n") # decline
install.setup_mcp_server(**_make_args())
prefetch_run.assert_not_called()
def test_setup_mcp_server__install_failure__is_reported(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
install_spy = mock.Mock(
return_value=targets.InstallResult("Cursor", False, "could not write config")
)
monkeypatch.setattr(targets, "HOST_TARGETS", [_target("cursor", True, install_spy)])
monkeypatch.setattr("builtins.input", lambda message: "y") # confirm the host
logger_spy = mock.Mock()
monkeypatch.setattr(install, "LOGGER", logger_spy)
install.setup_mcp_server(**_make_args())
install_spy.assert_called_once()
logged = " ".join(str(call) for call in logger_spy.warning.call_args_list)
assert "could not write config" in logged
def test_setup_mcp_server__hosted_detected__installs_remote_spec(monkeypatch):
monkeypatch.setattr(
install.mcp_detection,
"detect_hosted_mcp_server",
lambda **kwargs: "https://dev.comet.com/opik/api/v1/mcp",
)
install_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
monkeypatch.setattr(targets, "HOST_TARGETS", [_target("cursor", True, install_spy)])
monkeypatch.setattr("builtins.input", lambda message: "y")
install.setup_mcp_server(**_make_args())
install_spy.assert_called_once()
server_spec = install_spy.call_args.args[0]
assert isinstance(server_spec, spec.RemoteServerSpec)
assert server_spec.url == "https://dev.comet.com/opik/api/v1/mcp"
def test_setup_mcp_server__hosted_detected__does_not_prefetch(
monkeypatch, prefetch_run
):
monkeypatch.setattr(
install.mcp_detection,
"detect_hosted_mcp_server",
lambda **kwargs: "https://dev.comet.com/opik/api/v1/mcp",
)
install_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
monkeypatch.setattr(targets, "HOST_TARGETS", [_target("cursor", True, install_spy)])
monkeypatch.setattr("builtins.input", lambda message: "y")
install.setup_mcp_server(**_make_args())
prefetch_run.assert_not_called()
install_spy.assert_called_once()
def test_setup_mcp_server__force_local__skips_probe_and_installs_uvx(monkeypatch):
monkeypatch.setattr(install.shutil, "which", lambda name: "/usr/bin/uvx")
detect_spy = mock.Mock(return_value="https://dev.comet.com/opik/api/v1/mcp")
monkeypatch.setattr(install.mcp_detection, "detect_hosted_mcp_server", detect_spy)
install_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
monkeypatch.setattr(targets, "HOST_TARGETS", [_target("cursor", True, install_spy)])
monkeypatch.setattr("builtins.input", lambda message: "y")
install.setup_mcp_server(**_make_args(force_local_server=True))
detect_spy.assert_not_called()
install_spy.assert_called_once()
server_spec = install_spy.call_args.args[0]
assert isinstance(server_spec, spec.StdioServerSpec)
def test_setup_mcp_server__hosted_detected__no_uvx_still_installs(monkeypatch):
"""The remote path has no `uvx` prerequisite, unlike the local fallback."""
monkeypatch.setattr(install.shutil, "which", lambda name: None)
monkeypatch.setattr(
install.mcp_detection,
"detect_hosted_mcp_server",
lambda **kwargs: "https://dev.comet.com/opik/api/v1/mcp",
)
install_spy = mock.Mock(return_value=targets.InstallResult("Cursor", True, "Added"))
monkeypatch.setattr(targets, "HOST_TARGETS", [_target("cursor", True, install_spy)])
monkeypatch.setattr("builtins.input", lambda message: "y")
install.setup_mcp_server(**_make_args())
install_spy.assert_called_once()
@@ -0,0 +1,117 @@
import json
import pytest
from opik.configurator.mcp import json_config
SERVER_BLOCK = {
"type": "stdio",
"command": "/usr/bin/uvx",
"args": ["opik-mcp"],
"env": {"OPIK_API_KEY": "some-key"},
}
def test_merge_server_into_json_file__new_file__creates_with_entry(tmp_path):
config_path = tmp_path / "nested" / "mcp.json"
was_new = json_config.merge_server_into_json_file(
config_path=config_path,
top_level_key="mcpServers",
server_name="opik-mcp",
server_block=SERVER_BLOCK,
)
assert was_new is True
written = json.loads(config_path.read_text(encoding="utf-8"))
assert written == {"mcpServers": {"opik-mcp": SERVER_BLOCK}}
def test_merge_server_into_json_file__existing_servers__preserved(tmp_path):
config_path = tmp_path / "mcp.json"
config_path.write_text(
json.dumps(
{
"someOtherTopLevelKey": {"keep": "me"},
"mcpServers": {"other-server": {"command": "foo"}},
}
),
encoding="utf-8",
)
was_new = json_config.merge_server_into_json_file(
config_path=config_path,
top_level_key="mcpServers",
server_name="opik-mcp",
server_block=SERVER_BLOCK,
)
assert was_new is True
written = json.loads(config_path.read_text(encoding="utf-8"))
assert written["someOtherTopLevelKey"] == {"keep": "me"}
assert written["mcpServers"]["other-server"] == {"command": "foo"}
assert written["mcpServers"]["opik-mcp"] == SERVER_BLOCK
def test_merge_server_into_json_file__rerun__updates_in_place(tmp_path):
config_path = tmp_path / "mcp.json"
json_config.merge_server_into_json_file(
config_path=config_path,
top_level_key="mcpServers",
server_name="opik-mcp",
server_block={"command": "old"},
)
was_new = json_config.merge_server_into_json_file(
config_path=config_path,
top_level_key="mcpServers",
server_name="opik-mcp",
server_block=SERVER_BLOCK,
)
assert was_new is False
written = json.loads(config_path.read_text(encoding="utf-8"))
assert list(written["mcpServers"].keys()) == ["opik-mcp"]
assert written["mcpServers"]["opik-mcp"] == SERVER_BLOCK
def test_merge_server_into_json_file__invalid_json__raises(tmp_path):
config_path = tmp_path / "mcp.json"
config_path.write_text("{ // a JSONC comment\n}", encoding="utf-8")
with pytest.raises(json.JSONDecodeError):
json_config.merge_server_into_json_file(
config_path=config_path,
top_level_key="servers",
server_name="opik-mcp",
server_block=SERVER_BLOCK,
)
def test_merge_server_into_json_file__non_object_root__raises_value_error(tmp_path):
config_path = tmp_path / "mcp.json"
config_path.write_text('["not", "an", "object"]', encoding="utf-8")
with pytest.raises(ValueError):
json_config.merge_server_into_json_file(
config_path=config_path,
top_level_key="mcpServers",
server_name="opik-mcp",
server_block=SERVER_BLOCK,
)
def test_merge_server_into_json_file__empty_existing_file__treated_as_empty(tmp_path):
config_path = tmp_path / "mcp.json"
config_path.write_text("", encoding="utf-8")
was_new = json_config.merge_server_into_json_file(
config_path=config_path,
top_level_key="mcpServers",
server_name="opik-mcp",
server_block=SERVER_BLOCK,
)
assert was_new is True
written = json.loads(config_path.read_text(encoding="utf-8"))
assert written["mcpServers"]["opik-mcp"] == SERVER_BLOCK

Some files were not shown because too many files have changed in this diff Show More