Files
2026-07-13 13:32:05 +08:00

606 lines
22 KiB
Python

import asyncio
import json
import re
import os
from typing import Dict, Any
from functools import wraps
import inspect
from deepeval.utils import get_or_create_event_loop
def is_generate_mode() -> bool:
"""
Check if schema generation mode is enabled.
Can be enabled via environment variable: GENERATE_SCHEMAS=true pytest ...
Returns:
True if schemas should be generated, False if they should be asserted.
"""
return os.environ.get("GENERATE_SCHEMAS", "").lower() in (
"true",
"1",
"yes",
)
def _compute_tools_used(obj: Dict[str, Any]) -> bool:
"""
Compute whether tools were used in a trace object.
Returns True if any of these conditions hold:
- non-empty root.toolSpans
- non-empty root.toolsCalled
- any AI message with non-empty tool_calls
- any baseSpan[*].toolsCalled non-empty
"""
# Check root.toolsCalled
if obj.get("toolsCalled") and len(obj["toolsCalled"]) > 0:
return True
# Check AI messages with tool_calls in various locations
def check_messages(messages):
if not messages:
return False
for msg in messages:
if isinstance(msg, dict) and msg.get("type") == "ai":
# LangChain drift: tool_calls may appear either at top-level or under additional_kwargs
tool_calls = msg.get("tool_calls", [])
if (
tool_calls
and isinstance(tool_calls, list)
and len(tool_calls) > 0
):
return True
additional = msg.get("additional_kwargs", {})
if isinstance(additional, dict):
tc2 = additional.get("tool_calls", [])
if tc2 and isinstance(tc2, list) and len(tc2) > 0:
return True
return False
# Check root input/output messages
if obj.get("input") and isinstance(obj["input"], dict):
if check_messages(obj["input"].get("messages")):
return True
if obj.get("output") and isinstance(obj["output"], dict):
if check_messages(obj["output"].get("messages")):
return True
# Check baseSpans
for span in obj.get("baseSpans", []):
if isinstance(span, dict):
if span.get("toolsCalled") and len(span["toolsCalled"]) > 0:
return True
# Also check messages inside baseSpans
if span.get("input") and isinstance(span["input"], dict):
if check_messages(span["input"].get("messages")):
return True
if span.get("output") and isinstance(span["output"], dict):
if check_messages(span["output"].get("messages")):
return True
return False
def assert_json_object_structure(
expected_json_obj: Dict[str, Any], actual_json_obj: Dict[str, Any]
) -> bool:
"""
Validate that actual_json_obj matches the structure and data types of expected_json_obj.
Rules:
- Dicts: keys must match (with allowed drift for LangChain v1.x fields).
- Lists: compared pairwise (same length required), EXCEPT for unordered paths.
- Primitives: types must match exactly. Int/float are interchangeable.
- Preserves no-tools semantics: if expected implies no tools, actual must have no tools.
Unordered list paths (order-insensitive comparison):
- root.baseSpans, root.llmSpans, root.toolSpans
- Any path ending with .toolsCalled or .tool_calls
"""
# Paths where list ordering is not guaranteed (async/parallel execution)
UNORDERED_SPAN_PATHS = {"root.baseSpans", "root.llmSpans", "root.toolSpans"}
def _is_unordered_path(path: str) -> bool:
"""Check if the path should use unordered comparison."""
if path in UNORDERED_SPAN_PATHS:
return True
# toolsCalled can appear at root or nested in baseSpans
if path.endswith(".toolsCalled"):
return True
# tool_calls appear inside AI messages at various nesting levels
if path.endswith(".tool_calls"):
return True
return False
def _normalize_tool_call(call_dict: Dict[str, Any]) -> tuple:
"""
Normalize a tool call for matching purposes.
Returns (tool_name, frozenset(arg_keys)).
"""
if not isinstance(call_dict, dict):
return (None, frozenset())
# toolsCalled format: {"name": ..., "inputParameters": {...}}
# tool_calls format: {"name": ..., "args": {...}}
name = call_dict.get("name", "")
args = call_dict.get("inputParameters") or call_dict.get("args") or {}
if isinstance(args, dict):
return (name, frozenset(args.keys()))
return (name, frozenset())
# def _normalize_tool_call(call_dict: Dict[str, Any]) -> tuple:
# if not isinstance(call_dict, dict):
# return (None, ())
# name = call_dict.get("name", "")
# args = call_dict.get("inputParameters") or call_dict.get("args") or {}
# if not isinstance(args, dict):
# return (name, ())
# items = []
# for k, v in args.items():
# if isinstance(v, (str, int, float, bool)) or v is None:
# items.append((k, v))
# else:
# items.append((k, "__nonprimitive__"))
# return (name, tuple(sorted(items)))
def _normalize_span(span_dict: Dict[str, Any]) -> tuple:
if not isinstance(span_dict, dict):
return (None, None)
span_type = span_dict.get("type", span_dict.get("spanType", ""))
span_name = span_dict.get("name", "")
return (span_type, span_name)
def _match_unordered_lists(
expected_list: list,
actual_list: list,
path: str,
compare_fn,
) -> bool:
"""
Match elements from expected_list to actual_list without requiring order.
Each expected element must find exactly one unmatched actual element
with the same normalized key.
"""
is_tool_call_list = path.endswith(".toolsCalled") or path.endswith(
".tool_calls"
)
# Normalize elements
if is_tool_call_list:
expected_keys = [_normalize_tool_call(e) for e in expected_list]
actual_keys = [_normalize_tool_call(a) for a in actual_list]
else:
expected_keys = [_normalize_span(e) for e in expected_list]
actual_keys = [_normalize_span(a) for a in actual_list]
# Track which actual elements have been matched
matched_actual_indices = set()
for exp_idx, exp_key in enumerate(expected_keys):
found_match = False
for act_idx, act_key in enumerate(actual_keys):
if act_idx in matched_actual_indices:
continue
if exp_key == act_key:
# Found a match - now do deep structural comparison
if compare_fn(
actual_list[act_idx],
expected_list[exp_idx],
f"{path}[expected={exp_idx} matched actual={act_idx}]",
):
matched_actual_indices.add(act_idx)
found_match = True
break
# If structure doesn't match, try next candidate with same key
# (there may be multiple elements with the same normalized key)
if not found_match:
# Try to find ANY element with matching key for error reporting
matching_keys = [
i
for i, k in enumerate(actual_keys)
if k == exp_key and i not in matched_actual_indices
]
if not matching_keys:
print(
f"❌ No matching element at '{path}' for expected[{exp_idx}]:"
)
print(f" Expected key: {exp_key}")
available = [
actual_keys[i]
for i in range(len(actual_keys))
if i not in matched_actual_indices
]
print(f" Available keys: {available}")
return False
return True
# Validate tools-used invariant at the top level before detailed comparison.
# This ensures we never mask a regression where tools appear unexpectedly.
expected_tools_used = (
_compute_tools_used(expected_json_obj)
if isinstance(expected_json_obj, dict)
else _compute_tools_used(expected_json_obj[0])
)
actual_tools_used = (
_compute_tools_used(actual_json_obj)
if isinstance(actual_json_obj, dict)
else _compute_tools_used(actual_json_obj[0])
)
if expected_tools_used != actual_tools_used:
print("❌ Tools-used invariant violation:")
print(f" Expected tools_used: {expected_tools_used}")
print(f" Actual tools_used: {actual_tools_used}")
if not expected_tools_used and actual_tools_used:
print(" Regression: tools were called when none were expected")
else:
print(
" Regression: no tools were called when tools were expected"
)
return False
def _require_dict_keys(d: Any, required_keys: set, path: str) -> bool:
if not isinstance(d, dict):
print(
f"❌ Type mismatch at '{path}': expected dict, got {type(d).__name__}"
)
print(f" Value: {d}")
return False
missing = required_keys - set(d.keys())
if missing:
print(f"❌ Missing required keys at '{path}': {missing}")
return False
return True
def _require_str_field(d: Dict[str, Any], key: str, path: str) -> bool:
v = d.get(key)
if not isinstance(v, str):
print(
f"❌ Type mismatch at '{path}.{key}': expected str, got {type(v).__name__}"
)
print(f" Value: {v}")
return False
return True
def _compare(actual: Any, expected: Any, path: str = "root") -> bool:
# Dict vs Dict
if isinstance(expected, dict):
if not isinstance(actual, dict):
print(f"❌ Type mismatch at '{path}':")
print(" Expected: dict")
print(f" Got: {type(actual).__name__}")
print(f" Value: {actual}")
return False
# Filter out keys to ignore globally
keys_to_ignore = {"tokenIntervals"}
expected_keys = set(expected.keys()) - keys_to_ignore
actual_keys = set(actual.keys()) - keys_to_ignore
# Schema drift handling for LangChain v1.x (narrow allowlist)
schema_drift_config = {
# response_metadata gained new fields in v1.x
".response_metadata": {
"allowed_extra": {"model_provider", "service_tier"},
"allowed_missing": set(),
},
}
allowed_extras = set()
allowed_missing = set()
for suffix, config in schema_drift_config.items():
if path.endswith(suffix):
allowed_extras = config.get("allowed_extra", set())
allowed_missing = config.get("allowed_missing", set())
break
# Keys that are allowed to be extra on message objects
# usage_metadata was added in later LangChain versions
if re.search(r"\.messages\[\d+\]$", path):
allowed_extras = allowed_extras | {"usage_metadata"}
# In LangChain v1.x, tool_calls moved from additional_kwargs to top-level
# on AI messages. Allow tool_calls to be missing from additional_kwargs.
if re.search(r"\.messages\[\d+\]\.additional_kwargs$", path):
allowed_missing = allowed_missing | {"tool_calls"}
# At root level, toolsCalled key presence can vary due to tracer behavior.
# The tools-used invariant check above ensures semantic correctness.
# Evidence: test_multiple_tools, test_async_parallel_tools showed key
# presence flipping while tools_used semantics remained consistent.
if path == "root":
allowed_extras = allowed_extras | {"toolsCalled"}
allowed_missing = allowed_missing | {"toolsCalled"}
# Check for missing or extra keys (accounting for schema drift)
missing_keys = expected_keys - actual_keys - allowed_missing
extra_keys = actual_keys - expected_keys - allowed_extras
if missing_keys:
print(f"❌ Missing keys at '{path}': {missing_keys}")
return False
if extra_keys:
print(f"❌ Extra keys at '{path}': {extra_keys}")
return False
# Compare keys that exist in both (skip allowed_missing keys not in actual)
for key in expected_keys:
if key not in actual_keys and key in allowed_missing:
continue
# Skip toolsCalled comparison at root since semantics are checked above
if path == "root" and key == "toolsCalled":
# Still validate structure if both have it
if key in actual_keys and key in expected_keys:
if not _compare(
actual[key], expected[key], f"{path}.{key}"
):
return False
continue
if not _compare(actual[key], expected[key], f"{path}.{key}"):
return False
return True
# List vs List
if isinstance(expected, list):
if not isinstance(actual, list):
print(f"❌ Type mismatch at '{path}':")
print(" Expected: list")
print(f" Got: {type(actual).__name__}")
print(f" Value: {actual}")
return False
# For unordered paths (parallel/async tool calls and spans),
# use order-insensitive matching instead of pairwise comparison.
if _is_unordered_path(path):
# Require exact cardinality for unordered lists (spans + tool calls)
if len(actual) != len(expected):
print(
f"❌ Length mismatch at '{path}': expected {len(expected)}, got {len(actual)}"
)
return False
return _match_unordered_lists(expected, actual, path, _compare)
# For ordered arrays, require exact length and pairwise match
if len(actual) != len(expected):
print(
f"❌ Length mismatch at '{path}': expected {len(expected)}, got {len(actual)}"
)
return False
for idx, (actual_elem, expected_elem) in enumerate(
zip(actual, expected)
):
if not _compare(actual_elem, expected_elem, f"{path}[{idx}]"):
return False
return True
# Primitives: exact type match, except int/float interchangeable
number_types = (int, float)
if (
type(expected) in number_types
and type(actual) in number_types
and not isinstance(actual, bool)
and not isinstance(expected, bool)
):
return True
if type(actual) is not type(expected):
print(f"❌ Type mismatch at '{path}':")
print(f" Expected: {type(expected).__name__}")
print(f" Got: {type(actual).__name__}")
print(f" Expected value: {expected}")
print(f" Actual value: {actual}")
return False
return True
return _compare(actual_json_obj, expected_json_obj)
def load_trace_data(file_path: str):
with open(file_path, "r") as file:
return json.load(file)
# Global storage for trace dicts - shared across all imports
_TRACE_STORAGE: Dict[str, Dict[str, Any]] = {}
def _store_trace_for_upload(trace_dict: Dict[str, Any]):
"""Store trace dict for upload by conftest.py hook."""
# Get current test nodeid from pytest environment
nodeid = os.environ.get("PYTEST_CURRENT_TEST", "")
if nodeid:
# PYTEST_CURRENT_TEST format: "path/to/test.py::TestClass::test_method (call)"
# Strip the phase suffix
nodeid = nodeid.rsplit(" ", 1)[0]
if not nodeid:
return
# Store in module-level dict
_TRACE_STORAGE[nodeid] = trace_dict
def get_stored_trace(nodeid: str) -> Dict[str, Any]:
"""Retrieve and remove a stored trace dict."""
return _TRACE_STORAGE.pop(nodeid, None)
def generate_trace_json(json_path: str):
"""
Decorator that generates and saves trace data to a JSON file.
Usage:
@generate_trace_json("path/to/output.json")
async def my_function():
await some_llm_app("input")
Args:
json_path: Path where the trace JSON will be saved
"""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
from deepeval.tracing.trace_test_manager import (
trace_testing_manager,
)
try:
trace_testing_manager.test_name = json_path
result = await func(*args, **kwargs)
actual_dict = await trace_testing_manager.wait_for_test_dict()
with open(json_path, "w") as f:
json.dump(actual_dict, f, indent=2)
return result
finally:
trace_testing_manager.test_name = None
trace_testing_manager.test_dict = None
@wraps(func)
def sync_wrapper(*args, **kwargs):
from deepeval.tracing.trace_test_manager import (
trace_testing_manager,
)
try:
trace_testing_manager.test_name = json_path
result = func(*args, **kwargs)
# For sync functions, we need to handle the async wait differently
loop = get_or_create_event_loop()
actual_dict = loop.run_until_complete(
trace_testing_manager.wait_for_test_dict()
)
with open(json_path, "w") as f:
json.dump(actual_dict, f, indent=2)
return result
finally:
trace_testing_manager.test_name = None
trace_testing_manager.test_dict = None
if inspect.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator
def _assert_trace_capture_succeeded(
actual_dict: Dict[str, Any], expected_dict: Dict[str, Any], json_path: str
) -> None:
"""Sanity guard against silent no-op trace capture.
``trace_testing_manager.wait_for_test_dict()`` returns ``{}`` after a
timeout when nothing populated ``test_dict`` (e.g. the integration's
OTel spans were routed to OTLP instead of REST, so
``trace_manager.end_trace`` — the only writer — never ran). If the
expected schema also happens to be ``{}`` (e.g. a freshly-created
empty file pending generation), the structural compare passes
trivially and the test gives false confidence.
This guard makes that situation loud: an empty actual_dict is
treated as a hard failure regardless of expected content, with a
pointer to the most likely cause and the schema regeneration
command. It does NOT replace the structural compare — it runs
BEFORE it, since once ``actual_dict`` is empty the compare has
nothing meaningful to say.
"""
if actual_dict != {}:
return
raise AssertionError(
"Trace capture produced an empty dict for " f"{json_path!r}.\n"
)
def assert_trace_json(json_path: str):
"""
Decorator that tests trace data against an expected JSON file.
Usage:
@pytest.mark.asyncio
@test_trace_json("path/to/expected.json")
async def test_my_function():
await some_llm_app("input")
Args:
json_path: Path to the expected trace JSON file
Raises:
AssertionError: If the actual trace doesn't match the expected structure
"""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
from deepeval.tracing.trace_test_manager import (
trace_testing_manager,
)
try:
trace_testing_manager.test_name = json_path
result = await func(*args, **kwargs)
actual_dict = await trace_testing_manager.wait_for_test_dict()
expected_dict = load_trace_data(json_path)
# Store trace for upload (does not mutate)
_store_trace_for_upload(actual_dict)
_assert_trace_capture_succeeded(
actual_dict, expected_dict, json_path
)
assert assert_json_object_structure(expected_dict, actual_dict)
return result
finally:
trace_testing_manager.test_name = None
trace_testing_manager.test_dict = None
@wraps(func)
def sync_wrapper(*args, **kwargs):
from deepeval.tracing.trace_test_manager import (
trace_testing_manager,
)
try:
trace_testing_manager.test_name = json_path
result = func(*args, **kwargs)
# For sync functions, we need to handle the async wait differently
loop = get_or_create_event_loop()
actual_dict = loop.run_until_complete(
trace_testing_manager.wait_for_test_dict()
)
expected_dict = load_trace_data(json_path)
# Store trace for upload (does not mutate)
_store_trace_for_upload(actual_dict)
_assert_trace_capture_succeeded(
actual_dict, expected_dict, json_path
)
assert assert_json_object_structure(expected_dict, actual_dict)
return result
finally:
trace_testing_manager.test_name = None
trace_testing_manager.test_dict = None
if inspect.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator