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

561 lines
20 KiB
Python

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