项目文件夹

文件
wehub-resource-sync 9201ef759e
Harness Compat / harness compat (push) Failing after 0s
CI / test on 3.12 (standard) (push) Has been cancelled
CI / test on 3.13 (standard) (push) Has been cancelled
CI / test on 3.14 (standard) (push) Has been cancelled
CI / test on 3.10 (all-extras) (push) Has been cancelled
CI / test on 3.11 (all-extras) (push) Has been cancelled
CI / test on 3.12 (all-extras) (push) Has been cancelled
CI / test on 3.14 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.10 (pydantic-evals) (push) Has been cancelled
CI / test on 3.11 (pydantic-evals) (push) Has been cancelled
CI / test on 3.12 (pydantic-evals) (push) Has been cancelled
CI / deploy-docs-preview (push) Has been cancelled
CI / build release artifacts (push) Has been cancelled
CI / publish to PyPI (push) Has been cancelled
CI / Send tweet (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / mypy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / test on 3.10 (standard) (push) Has been cancelled
CI / test on 3.11 (standard) (push) Has been cancelled
CI / test on 3.13 (all-extras) (push) Has been cancelled
CI / test on 3.14 (all-extras) (push) Has been cancelled
CI / test on 3.10 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.11 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.12 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-evals) (push) Has been cancelled
CI / test on 3.14 (pydantic-evals) (push) Has been cancelled
CI / test on 3.10 (lowest-versions) (push) Has been cancelled
CI / test on 3.11 (lowest-versions) (push) Has been cancelled
CI / test on 3.12 (lowest-versions) (push) Has been cancelled
CI / test on 3.13 (lowest-versions) (push) Has been cancelled
CI / test on 3.14 (lowest-versions) (push) Has been cancelled
CI / test examples on 3.11 (push) Has been cancelled
CI / test examples on 3.12 (push) Has been cancelled
CI / test examples on 3.13 (push) Has been cancelled
CI / test examples on 3.14 (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / check (push) Has been cancelled
CI / deploy-docs (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

2416 行
74 KiB
Python

"""Tests for pydantic_evals.online — online evaluation infrastructure."""
from __future__ import annotations as _annotations
import asyncio
import random
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import pytest
from ..conftest import try_import
with try_import() as imports_successful:
from pydantic_evals import _online
from pydantic_evals.dataset import increment_eval_metric, set_eval_attribute
from pydantic_evals.evaluators import EvaluationResult, Evaluator, EvaluatorContext, EvaluatorFailure
from pydantic_evals.evaluators.evaluator import EvaluatorOutput
from pydantic_evals.online import (
DEFAULT_CONFIG,
CallbackSink,
OnErrorLocation,
OnlineEvalConfig,
OnlineEvaluator,
SamplingContext,
SinkPayload,
SpanReference,
configure,
disable_evaluation,
evaluate,
run_evaluators,
wait_for_evaluations,
)
from pydantic_evals.otel.span_tree import SpanTree
with try_import() as logfire_import_successful:
from logfire.testing import CaptureLogfire
pytestmark = pytest.mark.skipif(not imports_successful(), reason='pydantic-evals not installed')
needs_logfire = pytest.mark.skipif(not logfire_import_successful(), reason='logfire not installed')
if TYPE_CHECKING or imports_successful():
@dataclass
class AlwaysTrue(Evaluator):
"""Simple evaluator that always returns True."""
def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
return True
@dataclass
class AlwaysFalse(Evaluator):
"""Simple evaluator that always returns False."""
def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
return False
@dataclass
class OutputEquals(Evaluator):
"""Evaluator that checks if output equals a value."""
value: Any
def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
return ctx.output == self.value
@dataclass
class FailingEvaluator(Evaluator):
"""Evaluator that always raises an exception."""
def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
raise ValueError('Simulated evaluator failure')
@dataclass
class AsyncEvaluator(Evaluator):
"""Async evaluator for testing."""
async def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
await asyncio.sleep(0)
return True
@dataclass
class MultiResultEvaluator(Evaluator):
"""Evaluator that returns multiple results."""
def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
return {'accuracy': True, 'score': 0.95, 'label': 'good'}
def _make_context(
*,
inputs: Any = None,
output: Any = None,
expected_output: Any = None,
metadata: Any = None,
duration: float = 0.0,
) -> EvaluatorContext[Any, Any, Any]:
"""Create an EvaluatorContext for testing."""
return EvaluatorContext(
name='test',
inputs=inputs,
output=output,
expected_output=expected_output,
metadata=metadata,
duration=duration,
_span_tree=SpanTree(),
attributes={},
metrics={},
)
class Collector:
"""Collects sink submissions for test assertions."""
def __init__(self) -> None:
self.calls: list[
tuple[list[EvaluationResult[Any]], list[EvaluatorFailure], EvaluatorContext[Any, Any, Any]]
] = []
async def __call__(
self,
results: Sequence[EvaluationResult[Any]],
failures: Sequence[EvaluatorFailure],
context: EvaluatorContext[Any, Any, Any],
) -> None:
self.calls.append((list(results), list(failures), context))
@property
def result_count(self) -> int:
return sum(len(c[0]) for c in self.calls)
class MockContextSource:
"""Mock implementation of EvaluatorContextSource for testing."""
def __init__(self, data: dict[str, EvaluatorContext[Any, Any, Any]]) -> None:
self._data = data
async def fetch(self, span: SpanReference) -> EvaluatorContext[Any, Any, Any]:
return self._data[span.span_id]
async def fetch_many(self, spans: Sequence[SpanReference]) -> list[EvaluatorContext[Any, Any, Any]]:
return [self._data[s.span_id] for s in spans]
@pytest.mark.anyio
async def test_callback_sink_sync():
"""CallbackSink works with sync callbacks."""
collected: list[tuple[list[Any], list[Any], Any]] = []
def callback(
results: Sequence[EvaluationResult[Any]],
failures: Sequence[EvaluatorFailure],
context: EvaluatorContext[Any, Any, Any],
) -> None:
collected.append((list(results), list(failures), context))
sink = CallbackSink(callback)
ctx = _make_context(output='hello')
results = [EvaluationResult(name='test', value=True, reason=None, source=AlwaysTrue().as_spec())]
await sink.submit(
SinkPayload(results=results, failures=[], context=ctx, span_reference=None, target='t'),
)
assert len(collected) == 1
assert collected[0][0] == results
assert collected[0][1] == []
assert collected[0][2] is ctx
@pytest.mark.anyio
async def test_callback_sink_async():
"""CallbackSink works with async callbacks."""
collector = Collector()
sink = CallbackSink(collector)
ctx = _make_context(output='hello')
results = [EvaluationResult(name='test', value=True, reason=None, source=AlwaysTrue().as_spec())]
await sink.submit(
SinkPayload(results=results, failures=[], context=ctx, span_reference=None, target='t'),
)
assert len(collector.calls) == 1
@pytest.mark.anyio
async def test_callback_sink_ignores_span_reference():
"""CallbackSink does not pass span_reference to the callback."""
collector = Collector()
sink = CallbackSink(collector)
ctx = _make_context(output='hello')
span_ref = SpanReference(trace_id='abc', span_id='def')
await sink.submit(
SinkPayload(results=[], failures=[], context=ctx, span_reference=span_ref, target='t'),
)
assert len(collector.calls) == 1
assert collector.result_count == 0
@pytest.mark.anyio
async def test_span_reference():
"""SpanReference stores trace and span IDs."""
ref = SpanReference(trace_id='abc123', span_id='def456')
assert ref.trace_id == 'abc123'
assert ref.span_id == 'def456'
@pytest.mark.anyio
async def test_online_evaluator_defaults():
"""OnlineEvaluator has sensible defaults."""
evaluator = AlwaysTrue()
online = OnlineEvaluator(evaluator=evaluator)
assert online.evaluator is evaluator
assert online.sample_rate is None
assert online.sink is None
assert online.max_concurrency == 10
@pytest.mark.anyio
async def test_online_evaluator_custom_config():
"""OnlineEvaluator accepts custom configuration."""
evaluator = AlwaysTrue()
collector = Collector()
sink = CallbackSink(collector)
online = OnlineEvaluator(
evaluator=evaluator,
sample_rate=0.5,
sink=sink,
max_concurrency=5,
)
assert online.sample_rate == 0.5
assert online.sink is sink
assert online.max_concurrency == 5
@pytest.mark.parametrize('max_concurrency', [0, -1])
def test_online_evaluator_invalid_max_concurrency(max_concurrency: int):
"""OnlineEvaluator rejects non-positive concurrency limits."""
with pytest.raises(ValueError, match=f'max_concurrency must be >= 1, got {max_concurrency}'):
OnlineEvaluator(evaluator=AlwaysTrue(), max_concurrency=max_concurrency)
@pytest.mark.anyio
async def test_run_evaluators_success():
"""run_evaluators returns results from all evaluators."""
ctx = _make_context(output=42)
results, failures = await run_evaluators([AlwaysTrue(), OutputEquals(value=42)], ctx)
assert len(results) == 2
assert len(failures) == 0
assert results[0].value is True
assert results[1].value is True
@pytest.mark.anyio
async def test_run_evaluators_with_failure():
"""run_evaluators collects failures separately from results."""
ctx = _make_context(output=42)
results, failures = await run_evaluators([AlwaysTrue(), FailingEvaluator()], ctx)
assert len(results) == 1
assert results[0].value is True
assert len(failures) == 1
assert 'Simulated evaluator failure' in failures[0].error_message
@pytest.mark.anyio
async def test_run_evaluators_empty():
"""run_evaluators handles empty evaluator list."""
ctx = _make_context(output=42)
results, failures = await run_evaluators([], ctx)
assert results == []
assert failures == []
@pytest.mark.anyio
async def test_run_evaluators_multi_result():
"""run_evaluators handles evaluators that return multiple results."""
ctx = _make_context(output=42)
results, failures = await run_evaluators([MultiResultEvaluator()], ctx)
assert len(results) == 3
assert len(failures) == 0
result_names = {r.name for r in results}
assert result_names == {'accuracy', 'score', 'label'}
@pytest.mark.anyio
async def test_run_evaluators_async_evaluator():
"""run_evaluators works with async evaluators."""
ctx = _make_context(output=42)
results, failures = await run_evaluators([AsyncEvaluator()], ctx)
assert len(results) == 1
assert results[0].value is True
assert len(failures) == 0
@pytest.mark.anyio
async def test_evaluate_decorator_async_basic():
"""evaluate() decorator runs evaluators on async function calls."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x * 2
result = await my_func(21)
assert result == 42
# Wait for background task to complete
await wait_for_evaluations()
assert len(collector.calls) == 1
results, _, ctx = collector.calls[0]
assert len(results) == 1
assert results[0].value is True
assert ctx.output == 42
assert ctx.inputs == {'x': 21}
@pytest.mark.anyio
async def test_evaluate_decorator_async_preserves_signature():
"""evaluate() decorator preserves the function's name and docs."""
@evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
"""My docstring."""
return x
assert my_func.__name__ == 'my_func'
assert my_func.__doc__ == 'My docstring.'
assert await my_func(42) == 42
@pytest.mark.anyio
async def test_evaluate_decorator_multiple_evaluators():
"""evaluate() decorator runs multiple evaluators."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue(), OutputEquals(value=42))
async def my_func(x: int) -> int:
return x * 2
result = await my_func(21)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) >= 1
assert collector.result_count == 2
@pytest.mark.anyio
async def test_evaluate_decorator_async_default_skips_dispatch_on_exception():
"""By default, evaluators are not dispatched when the decorated async function raises."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
raise RuntimeError(f'boom: {x}')
with pytest.raises(RuntimeError, match='boom: 42'):
await my_func(42)
await wait_for_evaluations()
assert collector.calls == []
@pytest.mark.anyio
async def test_evaluate_decorator_async_run_on_errors_dispatches():
"""`run_on_errors=True` dispatches the evaluator with the raised exception as `output`."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), run_on_errors=True))
async def my_func(x: int) -> int:
raise RuntimeError(f'boom: {x}')
with pytest.raises(RuntimeError, match='boom: 42'):
await my_func(42)
await wait_for_evaluations()
assert len(collector.calls) == 1
results, _, ctx = collector.calls[0]
assert len(results) == 1
assert results[0].value is True
assert isinstance(ctx.output, RuntimeError)
assert str(ctx.output) == 'boom: 42'
assert ctx.inputs == {'x': 42}
@pytest.mark.anyio
async def test_evaluate_decorator_async_run_on_errors_filters_evaluators():
"""When some evaluators opt in and some don't, only the opted-in ones run on error."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(
OnlineEvaluator(evaluator=AlwaysTrue(), run_on_errors=True),
AlwaysFalse(), # default run_on_errors=False
)
async def my_func(x: int) -> int:
raise RuntimeError('boom')
with pytest.raises(RuntimeError, match='boom'):
await my_func(42)
await wait_for_evaluations()
assert len(collector.calls) == 1
results, _, _ = collector.calls[0]
assert len(results) == 1
assert results[0].value is True
@pytest.mark.anyio
async def test_evaluate_decorator_sync_run_on_errors_dispatches():
"""Sync decorator: `run_on_errors=True` dispatches with the exception as `output`."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), run_on_errors=True))
def my_func(x: int) -> int:
raise RuntimeError(f'boom: {x}')
with pytest.raises(RuntimeError, match='boom: 42'):
my_func(42)
await wait_for_evaluations()
assert len(collector.calls) == 1
results, _, ctx = collector.calls[0]
assert len(results) == 1
assert isinstance(ctx.output, RuntimeError)
@pytest.mark.anyio
async def test_evaluate_decorator_sync_run_on_errors_no_event_loop():
"""Sync `run_on_errors=True` without a running loop dispatches via background thread."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), run_on_errors=True))
def my_func(x: int) -> int:
raise RuntimeError(f'boom: {x}')
from anyio.to_thread import run_sync
def call_and_swallow() -> None:
try:
my_func(42)
except RuntimeError:
pass
await run_sync(call_and_swallow)
await wait_for_evaluations()
assert len(collector.calls) == 1
results, _, ctx = collector.calls[0]
assert len(results) == 1
assert isinstance(ctx.output, RuntimeError)
@pytest.mark.anyio
async def test_evaluate_decorator_with_failure():
"""evaluate() decorator handles evaluator failures gracefully."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(FailingEvaluator())
async def my_func(x: int) -> int:
return x
result = await my_func(42)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 1
results, failures, _ = collector.calls[0]
assert len(results) == 0
assert len(failures) == 1
assert 'Simulated evaluator failure' in failures[0].error_message
@pytest.mark.anyio
async def test_sample_rate_zero_skips_evaluation():
"""sample_rate=0.0 skips all evaluations."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=0.0))
async def my_func(x: int) -> int:
return x
result = await my_func(42)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 0
@pytest.mark.anyio
async def test_sample_rate_one_always_evaluates():
"""sample_rate=1.0 always evaluates."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=1.0))
async def my_func(x: int) -> int:
return x
for _ in range(5):
await my_func(42)
await wait_for_evaluations()
assert len(collector.calls) == 5
@pytest.mark.anyio
async def test_sample_rate_callable():
"""sample_rate as a callable is evaluated each time."""
call_count = 0
collector = Collector()
def dynamic_rate(ctx: SamplingContext) -> float:
nonlocal call_count
call_count += 1
return 1.0
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=dynamic_rate))
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert call_count >= 1
assert len(collector.calls) == 1
@pytest.mark.anyio
async def test_sample_rate_callable_returning_bool():
"""sample_rate callable can return bool."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=lambda ctx: False))
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(collector.calls) == 0
@pytest.mark.anyio
async def test_disable_evaluation_context_manager():
"""disable_evaluation() suppresses all evaluators."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
with disable_evaluation():
result = await my_func(42)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 0
@pytest.mark.anyio
async def test_disable_evaluation_restores():
"""disable_evaluation() restores evaluation after exiting."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
with disable_evaluation():
await my_func(42)
# After exiting, evaluations should resume
await my_func(42)
await wait_for_evaluations()
assert len(collector.calls) == 1
@pytest.mark.anyio
async def test_config_enabled_false():
"""OnlineEvalConfig.enabled=False disables all evaluation."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector, enabled=False)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
result = await my_func(42)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 0
@pytest.mark.anyio
async def test_per_evaluator_sink_override():
"""OnlineEvaluator.sink overrides config's default_sink."""
default_collector = Collector()
override_collector = Collector()
config = OnlineEvalConfig(default_sink=default_collector)
@config.evaluate(
AlwaysTrue(), # uses default sink
OnlineEvaluator(evaluator=AlwaysFalse(), sink=override_collector), # uses override
)
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(default_collector.calls) == 1
assert len(override_collector.calls) == 1
@pytest.mark.anyio
@needs_logfire
async def test_no_sink_still_emits_otel_events(capfire: CaptureLogfire):
"""When no sink is configured, evaluators still run and emit OTel events.
Out-of-the-box OTel event emission matches how offline evals produce spans
via `logfire_span` — users don't need to register a sink to see results.
"""
config = OnlineEvalConfig() # no user sinks
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
result = await my_func(42)
assert result == 42
await wait_for_evaluations()
finished = capfire.log_exporter.get_finished_logs()
assert len(finished) == 1
attrs = dict(finished[0].log_record.attributes or {})
assert attrs['gen_ai.evaluation.name'] == 'AlwaysTrue'
assert attrs['gen_ai.evaluation.score.label'] == 'pass'
@pytest.mark.anyio
@needs_logfire
async def test_emit_otel_events_false_disables_emission(capfire: CaptureLogfire):
"""`emit_otel_events=False` suppresses the default OTel emission."""
calls: list[int] = []
def sink_cb(
results: Sequence[EvaluationResult],
failures: Sequence[EvaluatorFailure],
context: EvaluatorContext[Any, Any, Any],
) -> None:
calls.append(len(results))
config = OnlineEvalConfig(default_sink=sink_cb, emit_otel_events=False)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
# User sink still ran.
assert calls == [1]
# But no OTel events were emitted.
assert list(capfire.log_exporter.get_finished_logs()) == []
@pytest.mark.anyio
async def test_configure_updates_default_config():
"""configure() updates the global DEFAULT_CONFIG."""
original_enabled = DEFAULT_CONFIG.enabled
original_sink = DEFAULT_CONFIG.default_sink
original_rate = DEFAULT_CONFIG.default_sample_rate
original_on_max = DEFAULT_CONFIG.on_max_concurrency
original_emit = DEFAULT_CONFIG.emit_otel_events
try:
configure(enabled=False, default_sample_rate=0.5)
assert DEFAULT_CONFIG.enabled is False
assert DEFAULT_CONFIG.default_sample_rate == 0.5
assert DEFAULT_CONFIG.default_sink is original_sink
def handler(ctx: EvaluatorContext[Any, Any, Any]) -> None:
pass
configure(on_max_concurrency=handler)
assert DEFAULT_CONFIG.on_max_concurrency is handler
configure(emit_otel_events=False)
assert DEFAULT_CONFIG.emit_otel_events is False
configure(include_baggage=False)
assert DEFAULT_CONFIG.include_baggage is False
finally:
DEFAULT_CONFIG.enabled = original_enabled
DEFAULT_CONFIG.default_sink = original_sink
DEFAULT_CONFIG.default_sample_rate = original_rate
DEFAULT_CONFIG.on_max_concurrency = original_on_max
DEFAULT_CONFIG.emit_otel_events = original_emit
DEFAULT_CONFIG.include_baggage = True
@pytest.mark.anyio
async def test_configure_can_reset_to_none():
"""configure() can explicitly set fields to None to clear them."""
collector = Collector()
original_sink = DEFAULT_CONFIG.default_sink
original_metadata = DEFAULT_CONFIG.metadata
try:
configure(default_sink=collector, metadata={'key': 'value'})
assert DEFAULT_CONFIG.default_sink is collector
assert DEFAULT_CONFIG.metadata == {'key': 'value'}
# Explicitly passing None should clear the values
configure(default_sink=None, metadata=None)
assert DEFAULT_CONFIG.default_sink is None
assert DEFAULT_CONFIG.metadata is None
finally:
DEFAULT_CONFIG.default_sink = original_sink
DEFAULT_CONFIG.metadata = original_metadata
@pytest.mark.anyio
async def test_module_level_evaluate():
"""Module-level evaluate() delegates to DEFAULT_CONFIG."""
collector = Collector()
original_sink = DEFAULT_CONFIG.default_sink
try:
DEFAULT_CONFIG.default_sink = collector
@evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
result = await my_func(42)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 1
finally:
DEFAULT_CONFIG.default_sink = original_sink
@pytest.mark.anyio
async def test_context_source_fetch():
"""EvaluatorContextSource.fetch retrieves stored context data."""
source = MockContextSource(
{
'span1': _make_context(
inputs={'query': 'hello'},
output='world',
metadata={'service': 'test'},
duration=1.5,
),
}
)
ctx = await source.fetch(SpanReference(trace_id='trace1', span_id='span1'))
assert ctx.inputs == {'query': 'hello'}
assert ctx.output == 'world'
assert ctx.expected_output is None
assert ctx.metadata == {'service': 'test'}
assert ctx.duration == 1.5
@pytest.mark.anyio
async def test_context_source_fetch_many():
"""EvaluatorContextSource.fetch_many retrieves multiple contexts in batch."""
source = MockContextSource(
{
'span1': _make_context(inputs={'q': '1'}, output='a', duration=1.0),
'span2': _make_context(inputs={'q': '2'}, output='b', duration=2.0),
}
)
spans = [
SpanReference(trace_id='t', span_id='span1'),
SpanReference(trace_id='t', span_id='span2'),
]
contexts = await source.fetch_many(spans)
assert len(contexts) == 2
assert contexts[0].inputs == {'q': '1'}
assert contexts[0].output == 'a'
assert contexts[1].inputs == {'q': '2'}
assert contexts[1].output == 'b'
@pytest.mark.anyio
async def test_fetch_and_run_evaluators():
"""EvaluatorContextSource.fetch + run_evaluators works end-to-end."""
source = MockContextSource(
{
'span1': _make_context(output=42, duration=0.1),
}
)
ctx = await source.fetch(SpanReference(trace_id='t', span_id='span1'))
results, failures = await run_evaluators([OutputEquals(value=42), AlwaysTrue()], ctx)
assert len(results) == 2
assert len(failures) == 0
assert all(r.value is True for r in results)
@pytest.mark.anyio
async def test_config_metadata_passed_to_context():
"""OnlineEvalConfig.metadata is included in the EvaluatorContext."""
collected_contexts: list[EvaluatorContext[Any, Any, Any]] = []
async def sink_callback(
results: Sequence[EvaluationResult[Any]],
failures: Sequence[EvaluatorFailure],
context: EvaluatorContext[Any, Any, Any],
) -> None:
collected_contexts.append(context)
config = OnlineEvalConfig(
default_sink=sink_callback,
metadata={'service': 'test-app', 'version': '1.0'},
)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(collected_contexts) == 1
assert collected_contexts[0].metadata == {'service': 'test-app', 'version': '1.0'}
@pytest.mark.anyio
async def test_max_concurrency_respected():
"""OnlineEvaluator.max_concurrency limits concurrent evaluations."""
active = 0
max_active = 0
completed = 0
@dataclass
class SlowEvaluator(Evaluator):
async def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
nonlocal active, max_active, completed
active += 1
max_active = max(max_active, active)
await asyncio.sleep(0.05)
active -= 1
completed += 1
return True
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(OnlineEvaluator(evaluator=SlowEvaluator(), max_concurrency=2, sample_rate=1.0))
async def my_func(x: int) -> int:
return x
# Fire off several calls rapidly
tasks = [my_func(i) for i in range(5)]
await asyncio.gather(*tasks)
# Wait for all background evaluations
await wait_for_evaluations()
# The semaphore should have limited concurrency to 2
assert max_active <= 2
@pytest.mark.anyio
async def test_custom_sink_protocol():
"""Custom EvaluationSink implementations work."""
class MySink:
def __init__(self) -> None:
self.submissions: list[tuple[list[EvaluationResult[Any]], SpanReference | None]] = []
async def submit(self, payload: SinkPayload) -> None:
self.submissions.append((list(payload.results), payload.span_reference))
sink = MySink()
config = OnlineEvalConfig(default_sink=sink)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(sink.submissions) == 1
results, _ = sink.submissions[0]
assert len(results) == 1
assert results[0].value is True
@pytest.mark.anyio
async def test_bare_evaluator_uses_config_defaults():
"""Bare Evaluator passed to evaluate() uses config's default_sample_rate."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector, default_sample_rate=0.0)
@config.evaluate(AlwaysTrue()) # bare evaluator, inherits sample_rate=0.0
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(collector.calls) == 0
@pytest.mark.anyio
async def test_bare_evaluator_late_binds_config_defaults():
"""Config defaults are resolved at call time, not decoration time."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector, default_sample_rate=0.0)
@config.evaluate(AlwaysTrue()) # bare evaluator, sample_rate resolved at call time
async def my_func(x: int) -> int:
return x
# Initially sample_rate=0.0 — no evaluations
await my_func(42)
await wait_for_evaluations()
assert len(collector.calls) == 0
# Change config after decoration — should take effect
config.default_sample_rate = 1.0
await my_func(42)
await wait_for_evaluations()
assert len(collector.calls) == 1
# OnlineEvaluator with explicit sample_rate is NOT affected by config changes
collector2 = Collector()
config2 = OnlineEvalConfig(default_sink=collector2, default_sample_rate=1.0)
@config2.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=0.0))
async def my_func2(x: int) -> int:
return x
config2.default_sample_rate = 1.0 # this should NOT override the explicit 0.0
await my_func2(42)
await wait_for_evaluations()
assert len(collector2.calls) == 0 # still 0 because OnlineEvaluator has explicit sample_rate=0.0
@pytest.mark.anyio
async def test_multiple_sinks():
"""Multiple sinks receive all results."""
collector1 = Collector()
collector2 = Collector()
config = OnlineEvalConfig(default_sink=[CallbackSink(collector1), CallbackSink(collector2)])
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(collector1.calls) == 1
assert len(collector2.calls) == 1
@pytest.mark.anyio
async def test_fractional_sample_rate():
"""Fractional sample_rate evaluates a subset of calls."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=0.5))
async def my_func(x: int) -> int:
return x
# Run many times — with 0.5 rate, we should get some but not all
for _ in range(50):
await my_func(42)
await wait_for_evaluations()
# Statistically, should get roughly 25 ± some variance, but definitely not 0 or 50
assert 5 < len(collector.calls) < 45
@pytest.mark.anyio
async def test_sample_rate_callable_exception_propagates():
"""Exception in sample_rate callable propagates — it's the user's responsibility."""
collector = Collector()
def bad_rate(ctx: SamplingContext) -> float:
raise ValueError('rate error')
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=bad_rate))
async def my_func(x: int) -> int:
return x # pragma: no cover
with pytest.raises(ValueError, match='rate error'):
await my_func(42)
@pytest.mark.anyio
async def test_sample_rate_callable_exception_calls_on_sampling_error():
"""When on_sampling_error is set, sample_rate exceptions are routed there instead of propagating."""
errors: list[tuple[Exception, Evaluator]] = []
def on_sampling_error(exc: Exception, evaluator: Evaluator) -> None:
errors.append((exc, evaluator))
collector = Collector()
def bad_rate(ctx: SamplingContext) -> float:
raise ValueError('rate error')
config = OnlineEvalConfig(default_sink=collector, on_sampling_error=on_sampling_error)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=bad_rate))
async def my_func(x: int) -> int:
return x
result = await my_func(42)
assert result == 42
assert len(errors) == 1
assert isinstance(errors[0][0], ValueError)
assert str(errors[0][0]) == 'rate error'
@pytest.mark.anyio
async def test_on_sampling_error_handler_exception_suppressed():
"""If on_sampling_error itself raises, the exception is suppressed and the evaluator is skipped."""
collector = Collector()
def bad_handler(exc: Exception, evaluator: Evaluator) -> None:
raise RuntimeError('handler boom')
def bad_rate(ctx: SamplingContext) -> float:
raise ValueError('rate error')
config = OnlineEvalConfig(default_sink=collector, on_sampling_error=bad_handler)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=bad_rate))
async def my_func(x: int) -> int:
return x
result = await my_func(42)
assert result == 42
assert len(collector.calls) == 0
@pytest.mark.anyio
async def test_sink_exception_does_not_propagate():
"""Exception in a sink is logged but does not break other sinks."""
class FailingSink:
async def submit(self, payload: SinkPayload) -> None:
raise ValueError('sink error')
collector = Collector()
config = OnlineEvalConfig(default_sink=[FailingSink(), CallbackSink(collector)])
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
result = await my_func(42)
assert result == 42
await wait_for_evaluations()
# The second sink should still have received results despite the first failing
assert len(collector.calls) == 1
@pytest.mark.anyio
async def test_on_max_concurrency_exception_suppressed_when_no_on_error():
"""`on_max_concurrency` raising with no `on_error` configured is silently swallowed."""
@dataclass
class SlowEvaluator(Evaluator):
async def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
await asyncio.sleep(0.1)
return True
def bad_callback(ctx: EvaluatorContext[Any, Any, Any]) -> None:
raise ValueError('callback boom')
collector = Collector()
config = OnlineEvalConfig(default_sink=collector) # no on_error
@config.evaluate(
OnlineEvaluator(
evaluator=SlowEvaluator(),
max_concurrency=1,
sample_rate=1.0,
on_max_concurrency=bad_callback,
)
)
async def my_func(x: int) -> int:
return x
# Fire enough concurrent calls to force drops; callback raises on every drop but no
# exceptions propagate because `on_error` is None (`_call_on_error` early-returns).
tasks = [my_func(i) for i in range(5)]
await asyncio.gather(*tasks)
await wait_for_evaluations()
@pytest.mark.anyio
async def test_sink_exception_suppressed_when_no_on_error():
"""A sink raising with no `on_error` configured is silently swallowed."""
class FailingSink:
async def submit(self, payload: SinkPayload) -> None:
raise ValueError('sink boom')
config = OnlineEvalConfig(default_sink=FailingSink()) # no on_error
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
# Completes cleanly — the sink exception is absorbed by the no-op `on_error=None` path.
result = await my_func(42)
assert result == 42
await wait_for_evaluations()
@pytest.mark.anyio
async def test_shared_on_error_across_evaluators_fires_once_per_sink_failure():
"""When multiple evaluators in a group share the same `on_error` handler, a single
sink failure still only fires it once — dedup by handler identity."""
fires: list[OnErrorLocation] = []
def on_error(
exc: Exception,
ctx: EvaluatorContext[Any, Any, Any],
evaluator: Evaluator,
location: OnErrorLocation,
) -> None:
fires.append(location)
class FailingSink:
async def submit(self, payload: SinkPayload) -> None:
raise ValueError('sink boom')
config = OnlineEvalConfig(default_sink=FailingSink(), on_error=on_error)
# Two evaluators → both land in the default-sink group. Sink raises once; dedup
# ensures the shared on_error fires exactly once, not twice.
@config.evaluate(AlwaysTrue(), OutputEquals(value=42))
async def my_func(x: int) -> int:
return x * 2
await my_func(21)
await wait_for_evaluations()
assert fires == ['sink']
@pytest.mark.anyio
async def test_evaluator_returning_empty_mapping_emits_nothing():
"""An evaluator returning `{}` produces no results — the empty-batch branch skips the submit."""
collector = Collector()
@dataclass
class EmptyEvaluator(Evaluator):
def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
return {}
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(EmptyEvaluator())
async def my_func(x: int) -> int:
return x
await my_func(1)
await wait_for_evaluations()
assert collector.calls == []
@pytest.mark.anyio
async def test_sync_function_from_async_context():
"""Sync decorated function called from async context dispatches via background thread."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
def my_func(x: int) -> int:
return x * 2
result = my_func(21)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 1
results, _, ctx = collector.calls[0]
assert len(results) == 1
assert results[0].value is True
assert ctx.output == 42
@needs_logfire
@pytest.mark.anyio
async def test_span_reference_with_configured_logfire(capfire: CaptureLogfire):
"""Decorator produces valid SpanReference when logfire is configured."""
span_refs: list[SpanReference | None] = []
class SpanCaptureSink:
async def submit(self, payload: SinkPayload) -> None:
span_refs.append(payload.span_reference)
config = OnlineEvalConfig(default_sink=SpanCaptureSink())
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(span_refs) == 1
ref = span_refs[0]
assert ref is not None
assert isinstance(ref, SpanReference)
assert len(ref.trace_id) == 32
assert len(ref.span_id) == 16
assert int(ref.trace_id, 16) != 0
assert int(ref.span_id, 16) != 0
@pytest.mark.anyio
async def test_sync_decorated_function_dispatch():
"""Sync decorated function dispatches evaluators when called from async context."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
def my_func(x: int) -> int:
return x * 2
result = my_func(21)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 1
results, _, ctx = collector.calls[0]
assert len(results) == 1
assert results[0].value is True
assert ctx.output == 42
assert ctx.inputs == {'x': 21}
@pytest.mark.anyio
async def test_sync_decorated_function_disabled():
"""Disabled config doesn't dispatch evaluators for sync decorated functions."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector, enabled=False)
@config.evaluate(AlwaysTrue())
def my_func(x: int) -> int:
return x
result = my_func(42)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 0
@pytest.mark.anyio
async def test_sync_decorated_function_sample_rate_zero():
"""sample_rate=0 doesn't dispatch evaluators for sync decorated functions."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=0.0))
def my_func(x: int) -> int:
return x
result = my_func(42)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 0
@pytest.mark.anyio
async def test_sync_function_no_event_loop():
"""Sync decorated function called without an event loop dispatches via background thread."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
def my_func(x: int) -> int:
return x * 2
# Call from a thread with no running event loop to exercise _dispatch_in_background_thread
from anyio.to_thread import run_sync
result = await run_sync(my_func, 21)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 1
results, _, ctx = collector.calls[0]
assert len(results) == 1
assert results[0].value is True
assert ctx.output == 42
assert ctx.inputs == {'x': 21}
@pytest.mark.anyio
async def test_mixed_list_sink():
"""A list containing both a bare callable and a CallbackSink exercises _normalize_single_sink."""
collector1 = Collector()
collector2 = Collector()
# Passing a list with a bare callable alongside a CallbackSink triggers
# _normalize_single_sink for the callable element.
config = OnlineEvalConfig(default_sink=[collector1, CallbackSink(collector2)])
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(collector1.calls) == 1
assert len(collector2.calls) == 1
@pytest.mark.anyio
async def test_on_max_concurrency_callback():
"""on_max_concurrency is called when evaluations are dropped."""
dropped_contexts: list[EvaluatorContext[Any, Any, Any]] = []
@dataclass
class SlowEvaluator(Evaluator):
async def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
await asyncio.sleep(0.1)
return True
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(
OnlineEvaluator(
evaluator=SlowEvaluator(),
max_concurrency=1,
sample_rate=1.0,
on_max_concurrency=lambda ctx: dropped_contexts.append(ctx),
)
)
async def my_func(x: int) -> int:
return x
# Fire off several calls — only 1 can run concurrently, rest should be dropped
tasks = [my_func(i) for i in range(5)]
await asyncio.gather(*tasks)
await wait_for_evaluations()
# At least some evaluations should have been dropped
assert len(dropped_contexts) > 0
# Total dropped + completed should equal 5
assert len(dropped_contexts) + len(collector.calls) == 5
@pytest.mark.anyio
async def test_on_max_concurrency_async_callback():
"""on_max_concurrency works with async callbacks."""
dropped_count = 0
@dataclass
class SlowEvaluator(Evaluator):
async def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
await asyncio.sleep(0.1)
return True
async def on_drop(ctx: EvaluatorContext[Any, Any, Any]) -> None:
nonlocal dropped_count
dropped_count += 1
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(
OnlineEvaluator(
evaluator=SlowEvaluator(),
max_concurrency=1,
sample_rate=1.0,
on_max_concurrency=on_drop,
)
)
async def my_func(x: int) -> int:
return x
tasks = [my_func(i) for i in range(5)]
await asyncio.gather(*tasks)
await wait_for_evaluations()
assert dropped_count > 0
assert dropped_count + len(collector.calls) == 5
@pytest.mark.anyio
async def test_on_max_concurrency_config_default():
"""OnlineEvalConfig.on_max_concurrency is used when OnlineEvaluator doesn't set one."""
dropped_contexts: list[EvaluatorContext[Any, Any, Any]] = []
@dataclass
class SlowEvaluator(Evaluator):
async def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
await asyncio.sleep(0.1)
return True
collector = Collector()
config = OnlineEvalConfig(
default_sink=collector,
on_max_concurrency=lambda ctx: dropped_contexts.append(ctx),
)
@config.evaluate(OnlineEvaluator(evaluator=SlowEvaluator(), max_concurrency=1, sample_rate=1.0))
async def my_func(x: int) -> int:
return x
tasks = [my_func(i) for i in range(5)]
await asyncio.gather(*tasks)
await wait_for_evaluations()
assert len(dropped_contexts) > 0
assert len(dropped_contexts) + len(collector.calls) == 5
@pytest.mark.anyio
async def test_on_max_concurrency_evaluator_overrides_config():
"""OnlineEvaluator.on_max_concurrency overrides the config default."""
config_drops: list[EvaluatorContext[Any, Any, Any]] = []
evaluator_drops: list[EvaluatorContext[Any, Any, Any]] = []
@dataclass
class SlowEvaluator(Evaluator):
async def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
await asyncio.sleep(0.1)
return True
collector = Collector()
config = OnlineEvalConfig(
default_sink=collector,
on_max_concurrency=lambda ctx: config_drops.append(ctx),
)
@config.evaluate(
OnlineEvaluator(
evaluator=SlowEvaluator(),
max_concurrency=1,
sample_rate=1.0,
on_max_concurrency=lambda ctx: evaluator_drops.append(ctx),
)
)
async def my_func(x: int) -> int:
return x
tasks = [my_func(i) for i in range(5)]
await asyncio.gather(*tasks)
await wait_for_evaluations()
# Config handler should NOT have been called — evaluator handler overrides it
assert len(config_drops) == 0
assert len(evaluator_drops) > 0
assert len(evaluator_drops) + len(collector.calls) == 5
# --- on_error tests ---
@pytest.mark.anyio
async def test_on_error_sink_exception():
"""on_error is called with 'sink' location when sink raises."""
errors: list[tuple[Exception, OnErrorLocation]] = []
def on_error(
exc: Exception,
ctx: EvaluatorContext[Any, Any, Any],
evaluator: Evaluator,
location: OnErrorLocation,
) -> None:
errors.append((exc, location))
class FailingSink:
async def submit(self, payload: SinkPayload) -> None:
raise ValueError('sink boom')
good_collector = Collector()
config = OnlineEvalConfig(default_sink=[FailingSink(), CallbackSink(good_collector)], on_error=on_error)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(errors) == 1
assert errors[0][1] == 'sink'
# The other sink should still have received results
assert len(good_collector.calls) == 1
@pytest.mark.anyio
async def test_on_error_on_max_concurrency_exception():
"""on_error is called with 'on_max_concurrency' when on_max_concurrency callback raises."""
errors: list[tuple[Exception, OnErrorLocation]] = []
def on_error(
exc: Exception,
ctx: EvaluatorContext[Any, Any, Any],
evaluator: Evaluator,
location: OnErrorLocation,
) -> None:
errors.append((exc, location))
def bad_callback(ctx: EvaluatorContext[Any, Any, Any]) -> None:
raise ValueError('callback boom')
@dataclass
class SlowEvaluator(Evaluator):
async def evaluate(self, ctx: EvaluatorContext) -> EvaluatorOutput:
await asyncio.sleep(0.1)
return True
collector = Collector()
config = OnlineEvalConfig(default_sink=collector, on_error=on_error)
@config.evaluate(
OnlineEvaluator(
evaluator=SlowEvaluator(),
max_concurrency=1,
sample_rate=1.0,
on_max_concurrency=bad_callback,
)
)
async def my_func(x: int) -> int:
return x
tasks = [my_func(i) for i in range(5)]
await asyncio.gather(*tasks)
await wait_for_evaluations()
# At least some should have been dropped and triggered the bad callback
assert len(errors) > 0
assert all(loc == 'on_max_concurrency' for _, loc in errors)
@pytest.mark.anyio
async def test_on_error_handler_exception_suppressed():
"""on_error handler that raises is silently suppressed."""
class FailingSink:
async def submit(self, payload: SinkPayload) -> None:
raise ValueError('sink boom')
good_collector = Collector()
def bad_on_error(
exc: Exception,
ctx: EvaluatorContext[Any, Any, Any],
evaluator: Evaluator,
location: OnErrorLocation,
) -> None:
raise RuntimeError('handler boom')
config = OnlineEvalConfig(default_sink=[FailingSink(), CallbackSink(good_collector)], on_error=bad_on_error)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
result = await my_func(42)
assert result == 42
await wait_for_evaluations()
# Good sink should still have received results despite on_error raising
assert len(good_collector.calls) == 1
@pytest.mark.anyio
async def test_on_error_per_evaluator_overrides_config():
"""Per-evaluator on_error overrides the config default."""
evaluator_errors: list[OnErrorLocation] = []
class FailingSink:
async def submit(self, payload: SinkPayload) -> None:
raise ValueError('sink boom')
def config_on_error(
exc: Exception,
ctx: EvaluatorContext[Any, Any, Any],
evaluator: Evaluator,
location: OnErrorLocation,
) -> None:
pytest.fail('config on_error should not be called when per-evaluator on_error is set') # pragma: no cover
def evaluator_on_error(
exc: Exception,
ctx: EvaluatorContext[Any, Any, Any],
evaluator: Evaluator,
location: OnErrorLocation,
) -> None:
evaluator_errors.append(location)
config = OnlineEvalConfig(on_error=config_on_error)
@config.evaluate(
OnlineEvaluator(evaluator=AlwaysTrue(), sink=FailingSink(), on_error=evaluator_on_error),
)
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(evaluator_errors) == 1
assert evaluator_errors[0] == 'sink'
@pytest.mark.anyio
async def test_on_error_async_callback():
"""Async on_error callback works."""
errors: list[OnErrorLocation] = []
class FailingSink:
async def submit(self, payload: SinkPayload) -> None:
raise ValueError('sink boom')
async def async_on_error(
exc: Exception,
ctx: EvaluatorContext[Any, Any, Any],
evaluator: Evaluator,
location: OnErrorLocation,
) -> None:
await asyncio.sleep(0)
errors.append(location)
config = OnlineEvalConfig(on_error=async_on_error)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sink=FailingSink()))
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(errors) == 1
assert errors[0] == 'sink'
@pytest.mark.anyio
async def test_configure_on_sampling_error():
"""configure() can set on_sampling_error on DEFAULT_CONFIG."""
original = DEFAULT_CONFIG.on_sampling_error
try:
def handler(exc: Exception, evaluator: Evaluator) -> None:
pass
configure(on_sampling_error=handler)
assert DEFAULT_CONFIG.on_sampling_error is handler
configure(on_sampling_error=None)
assert DEFAULT_CONFIG.on_sampling_error is None
finally:
DEFAULT_CONFIG.on_sampling_error = original
@pytest.mark.anyio
async def test_configure_on_error():
"""configure() can set on_error on DEFAULT_CONFIG."""
original = DEFAULT_CONFIG.on_error
try:
def handler(
exc: Exception,
ctx: EvaluatorContext[Any, Any, Any],
evaluator: Evaluator,
location: OnErrorLocation,
) -> None:
pass
configure(on_error=handler)
assert DEFAULT_CONFIG.on_error is handler
configure(on_error=None)
assert DEFAULT_CONFIG.on_error is None
finally:
DEFAULT_CONFIG.on_error = original
# --- SamplingContext tests ---
@pytest.mark.anyio
async def test_sampling_context_passed_to_callable():
"""SamplingContext is passed to sample_rate callables with correct data."""
captured_contexts: list[SamplingContext] = []
def capture_rate(ctx: SamplingContext) -> bool:
captured_contexts.append(ctx)
return True
collector = Collector()
config = OnlineEvalConfig(
default_sink=collector,
metadata={'service': 'test'},
)
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=capture_rate))
async def my_func(x: int, y: str = 'hello') -> int:
return x
await my_func(42, y='world')
await wait_for_evaluations()
assert len(captured_contexts) == 1
ctx = captured_contexts[0]
assert ctx.inputs == {'x': 42, 'y': 'world'}
assert ctx.metadata == {'service': 'test'}
assert isinstance(ctx.evaluator, AlwaysTrue)
assert 0.0 <= ctx.call_seed < 1.0
@pytest.mark.anyio
async def test_sampling_context_call_seed_shared_across_evaluators():
"""call_seed is the same for all evaluators in a single call."""
captured_seeds: list[float] = []
def capture_rate(ctx: SamplingContext) -> bool:
captured_seeds.append(ctx.call_seed)
return True
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(
OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=capture_rate),
OnlineEvaluator(evaluator=AlwaysFalse(), sample_rate=capture_rate),
)
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(captured_seeds) == 2
assert captured_seeds[0] == captured_seeds[1]
@pytest.mark.anyio
async def test_sampling_context_input_based_sampling():
"""sample_rate callable can use inputs to decide whether to evaluate."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
def only_large_inputs(ctx: SamplingContext) -> bool:
return ctx.inputs.get('x', 0) > 10
@config.evaluate(OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=only_large_inputs))
async def my_func(x: int) -> int:
return x
await my_func(5) # should be skipped
await wait_for_evaluations()
assert len(collector.calls) == 0
await my_func(20) # should be evaluated
await wait_for_evaluations()
assert len(collector.calls) == 1
# --- correlated sampling tests ---
@pytest.mark.anyio
async def test_correlated_sampling_subset_property(monkeypatch: pytest.MonkeyPatch):
"""In correlated mode, lower-rate evaluator calls are a subset of higher-rate ones."""
# Seed the sampler so the test is deterministic. Without a seed this test
# could fail with probability ~0.9**100 ≈ 2.7e-5 when no call fires the
# low-rate (0.1) evaluator. See https://github.com/pydantic/pydantic-ai/issues/5399.
monkeypatch.setattr(_online, 'random', random.Random(0))
collector_high = Collector()
collector_low = Collector()
config = OnlineEvalConfig(sampling_mode='correlated')
@config.evaluate(
OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=0.5, sink=collector_high),
OnlineEvaluator(evaluator=AlwaysFalse(), sample_rate=0.1, sink=collector_low),
)
async def my_func(x: int) -> int:
return x
for i in range(100):
await my_func(i)
await wait_for_evaluations()
# In correlated mode, every call that ran the low-rate evaluator (0.1)
# must also have run the high-rate evaluator (0.5)
assert len(collector_low.calls) <= len(collector_high.calls)
# Sanity: we should have gotten some evaluations
assert len(collector_high.calls) > 0
assert len(collector_low.calls) > 0
@pytest.mark.anyio
async def test_correlated_sampling_max_overhead(monkeypatch: pytest.MonkeyPatch):
"""In correlated mode, total overhead probability equals max(rate_i)."""
# Seed the sampler so the `5 < calls < 40` window is deterministic.
monkeypatch.setattr(_online, 'random', random.Random(0))
collector1 = Collector()
collector2 = Collector()
collector3 = Collector()
config = OnlineEvalConfig(sampling_mode='correlated')
@config.evaluate(
OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=0.1, sink=collector1),
OnlineEvaluator(evaluator=AlwaysFalse(), sample_rate=0.1, sink=collector2),
OnlineEvaluator(evaluator=OutputEquals(value=0), sample_rate=0.1, sink=collector3),
)
async def my_func(x: int) -> int:
return x
for i in range(200):
await my_func(i)
await wait_for_evaluations()
# All three should have run on exactly the same calls (same rate, same seed)
assert len(collector1.calls) == len(collector2.calls) == len(collector3.calls)
# Should be roughly 10% of 200 = ~20, not ~27% like independent mode
assert 5 < len(collector1.calls) < 40
@pytest.mark.anyio
async def test_independent_sampling_is_default(monkeypatch: pytest.MonkeyPatch):
"""Independent mode is the default — evaluators sample independently."""
# Seed the sampler so `>0` for both collectors is deterministic.
monkeypatch.setattr(_online, 'random', random.Random(0))
collector1 = Collector()
collector2 = Collector()
config = OnlineEvalConfig() # default is 'independent'
@config.evaluate(
OnlineEvaluator(evaluator=AlwaysTrue(), sample_rate=0.5, sink=collector1),
OnlineEvaluator(evaluator=AlwaysFalse(), sample_rate=0.5, sink=collector2),
)
async def my_func(x: int) -> int:
return x
for i in range(100):
await my_func(i)
await wait_for_evaluations()
# Both should have roughly 50 each, but they should NOT be identical
# (extremely unlikely with independent sampling over 100 trials)
assert len(collector1.calls) > 0
assert len(collector2.calls) > 0
@pytest.mark.anyio
async def test_configure_sampling_mode():
"""configure() can set sampling_mode on DEFAULT_CONFIG."""
original = DEFAULT_CONFIG.sampling_mode
try:
configure(sampling_mode='correlated')
assert DEFAULT_CONFIG.sampling_mode == 'correlated'
configure(sampling_mode='independent')
assert DEFAULT_CONFIG.sampling_mode == 'independent'
finally:
DEFAULT_CONFIG.sampling_mode = original
# --- attributes/metrics tests ---
@pytest.mark.anyio
async def test_set_eval_attribute_in_async_function():
"""set_eval_attribute in an async decorated function propagates to EvaluatorContext."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
set_eval_attribute('model', 'gpt-4o')
set_eval_attribute('region', 'us-east-1')
return x * 2
result = await my_func(21)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 1
_, _, ctx = collector.calls[0]
assert ctx.attributes == {'model': 'gpt-4o', 'region': 'us-east-1'}
@pytest.mark.anyio
async def test_increment_eval_metric_in_async_function():
"""increment_eval_metric in an async decorated function propagates to EvaluatorContext."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
increment_eval_metric('tokens', 150)
increment_eval_metric('tokens', 50)
increment_eval_metric('requests', 1)
return x
result = await my_func(42)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 1
_, _, ctx = collector.calls[0]
assert ctx.metrics == {'tokens': 200, 'requests': 1}
@pytest.mark.anyio
async def test_set_eval_attribute_in_sync_function():
"""set_eval_attribute in a sync decorated function propagates to EvaluatorContext."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
def my_func(x: int) -> int:
set_eval_attribute('model', 'gpt-4o')
return x * 2
result = my_func(21)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 1
_, _, ctx = collector.calls[0]
assert ctx.attributes == {'model': 'gpt-4o'}
@pytest.mark.anyio
async def test_increment_eval_metric_in_sync_function():
"""increment_eval_metric in a sync decorated function propagates to EvaluatorContext."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
def my_func(x: int) -> int:
increment_eval_metric('tokens', 100)
return x
result = my_func(42)
assert result == 42
await wait_for_evaluations()
assert len(collector.calls) == 1
_, _, ctx = collector.calls[0]
assert ctx.metrics == {'tokens': 100}
@pytest.mark.anyio
async def test_attributes_and_metrics_empty_by_default():
"""When no attributes/metrics are set, context has empty dicts."""
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
assert len(collector.calls) == 1
_, _, ctx = collector.calls[0]
assert ctx.attributes == {}
assert ctx.metrics == {}
@pytest.mark.anyio
async def test_online_eval_suppressed_inside_task_run():
"""Online evaluation is suppressed when already inside `CURRENT_TASK_RUN`."""
from pydantic_evals._task_run import CURRENT_TASK_RUN, TaskRun
collector = Collector()
config = OnlineEvalConfig(default_sink=collector)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
# Simulate being inside Dataset.evaluate by setting CURRENT_TASK_RUN.
outer_task_run = TaskRun()
token = CURRENT_TASK_RUN.set(outer_task_run)
try:
result = await my_func(42)
assert result == 42
finally:
CURRENT_TASK_RUN.reset(token)
await wait_for_evaluations()
# Online evaluation should have been suppressed
assert len(collector.calls) == 0
@pytest.mark.anyio
async def test_metadata_not_shared_between_contexts():
"""config.metadata is copied so sinks can't corrupt the config."""
collected_contexts: list[EvaluatorContext[Any, Any, Any]] = []
async def capture_sink(
results: Sequence[EvaluationResult[Any]],
failures: Sequence[EvaluatorFailure],
context: EvaluatorContext[Any, Any, Any],
) -> None:
collected_contexts.append(context)
# Mutate the context's metadata — should NOT affect the config
if context.metadata is not None: # pragma: no branch
context.metadata['injected'] = True
config = OnlineEvalConfig(
default_sink=capture_sink,
metadata={'service': 'test'},
)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(1)
await wait_for_evaluations()
await my_func(2)
await wait_for_evaluations()
assert len(collected_contexts) == 2
# Both contexts' metadata were mutated by the sink
assert collected_contexts[0].metadata == {'service': 'test', 'injected': True}
assert collected_contexts[1].metadata == {'service': 'test', 'injected': True}
# But config metadata should be untouched — the copies are independent
assert config.metadata == {'service': 'test'}
# --- Call span / instrument-style recording ---------------------------------
@needs_logfire
@pytest.mark.anyio
async def test_call_span_default_name_and_no_args(capfire: CaptureLogfire):
"""Each decorated call opens a span named after the function; args/return are not recorded by default."""
config = OnlineEvalConfig(emit_otel_events=False)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(42)
await wait_for_evaluations()
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
call_spans = [s for s in spans if 'my_func' in s['name']]
assert len(call_spans) == 1
attrs = call_spans[0]['attributes']
assert 'x' not in attrs
assert 'return' not in attrs
@needs_logfire
@pytest.mark.anyio
async def test_call_span_extract_args_true_records_all(capfire: CaptureLogfire):
"""`extract_args=True` records every bound argument."""
config = OnlineEvalConfig(emit_otel_events=False)
@config.evaluate(AlwaysTrue(), extract_args=True)
async def my_func(x: int, label: str = 'default') -> int:
return x
await my_func(42, label='hello')
await wait_for_evaluations()
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
call_spans = [s for s in spans if 'my_func' in s['name']]
assert len(call_spans) == 1
attrs = call_spans[0]['attributes']
assert attrs['x'] == 42
assert attrs['label'] == 'hello'
@needs_logfire
@pytest.mark.anyio
async def test_call_span_extract_args_subset(capfire: CaptureLogfire):
"""Passing a list to `extract_args` records only the named arguments."""
config = OnlineEvalConfig(emit_otel_events=False)
@config.evaluate(AlwaysTrue(), extract_args=['x'])
async def my_func(x: int, secret: str) -> int:
return x
await my_func(42, secret='shh')
await wait_for_evaluations()
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
call_spans = [s for s in spans if 'my_func' in s['name']]
assert len(call_spans) == 1
attrs = call_spans[0]['attributes']
assert attrs['x'] == 42
assert 'secret' not in attrs
@needs_logfire
@pytest.mark.anyio
async def test_call_span_record_return(capfire: CaptureLogfire):
"""`record_return=True` records the function's return value on the span."""
config = OnlineEvalConfig(emit_otel_events=False)
@config.evaluate(AlwaysTrue(), record_return=True)
async def my_func(x: int) -> int:
return x * 2
await my_func(21)
await wait_for_evaluations()
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
call_spans = [s for s in spans if 'my_func' in s['name']]
assert len(call_spans) == 1
assert call_spans[0]['attributes']['return'] == 42
@needs_logfire
@pytest.mark.anyio
async def test_call_span_msg_template_and_span_name(capfire: CaptureLogfire):
"""`msg_template` formats against call args (logfire convention); `span_name` overrides the span name."""
config = OnlineEvalConfig(emit_otel_events=False)
@config.evaluate(AlwaysTrue(), msg_template='run task with {x=}', span_name='task.run', extract_args=True)
async def my_func(x: int) -> int:
return x
await my_func(7)
await wait_for_evaluations()
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
task_spans = [s for s in spans if s['name'] == 'task.run']
assert len(task_spans) == 1
attrs = task_spans[0]['attributes']
# `msg_template` keeps its raw template on the span; `logfire.msg` is the rendered form.
assert attrs['logfire.msg_template'] == 'run task with {x=}'
assert attrs['logfire.msg'] == 'run task with x=7'
@needs_logfire
@pytest.mark.anyio
async def test_evaluation_events_parented_to_call_span(capfire: CaptureLogfire):
"""Emitted `gen_ai.evaluation.result` events parent to the decorated call's span."""
config = OnlineEvalConfig() # default emit_otel_events=True
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(1)
await wait_for_evaluations()
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
call_spans = [s for s in spans if 'my_func' in s['name']]
assert len(call_spans) == 1
call_span_id = call_spans[0]['context']['span_id']
finished = capfire.log_exporter.get_finished_logs()
assert len(finished) == 1
# Event's span_context points back to the decorated call's span id,
# so the evaluator event appears nested under the function call in traces.
assert finished[0].log_record.span_id == call_span_id
def test_extract_args_without_logfire_raises(monkeypatch: pytest.MonkeyPatch):
"""Opting into arg/return recording without logfire installed raises at decoration time."""
from pydantic_evals import online as online_module
monkeypatch.setattr(online_module, '_LOGFIRE_INSTALLED', False)
with pytest.raises(RuntimeError, match='logfire'):
@online_module.evaluate(AlwaysTrue(), extract_args=True)
async def f(x: int) -> int: # pragma: no cover - decorator raises before body runs
return x
with pytest.raises(RuntimeError, match='logfire'):
@online_module.evaluate(AlwaysTrue(), record_return=True)
async def g(x: int) -> int: # pragma: no cover - decorator raises before body runs
return x
@needs_logfire
def test_extract_args_unknown_parameter_raises():
"""Naming an unknown parameter in `extract_args` fails at decoration time."""
with pytest.raises(ValueError, match='not in'):
@evaluate(AlwaysTrue(), extract_args=['nonexistent'])
async def f(x: int) -> int: # pragma: no cover - decorator raises before body runs
return x
@needs_logfire
@pytest.mark.anyio
async def test_extract_args_accepts_single_string(capfire: CaptureLogfire):
"""A bare string is treated as a one-element list of arg names."""
config = OnlineEvalConfig(default_sink=Collector(), emit_otel_events=False)
@config.evaluate(AlwaysTrue(), extract_args='x')
async def my_func(x: int, secret: str) -> int:
return x
await my_func(7, secret='shh')
await wait_for_evaluations()
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
call_spans = [s for s in spans if 'my_func' in s['name']]
assert len(call_spans) == 1
attrs = call_spans[0]['attributes']
assert attrs['x'] == 7
assert 'secret' not in attrs
@needs_logfire
@pytest.mark.anyio
async def test_extract_args_empty_iterable_records_nothing(capfire: CaptureLogfire):
"""An empty iterable for `extract_args` is treated as `False`."""
config = OnlineEvalConfig(default_sink=Collector(), emit_otel_events=False)
@config.evaluate(AlwaysTrue(), extract_args=())
async def my_func(x: int) -> int:
return x
await my_func(1)
await wait_for_evaluations()
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
call_spans = [s for s in spans if 'my_func' in s['name']]
assert len(call_spans) == 1
assert 'x' not in call_spans[0]['attributes']
@needs_logfire
@pytest.mark.anyio
async def test_sync_call_span_with_extract_args(capfire: CaptureLogfire):
"""Sync decorated functions also open a span and honour `extract_args`."""
config = OnlineEvalConfig(emit_otel_events=False)
@config.evaluate(AlwaysTrue(), extract_args=True, record_return=True)
def my_func(x: int) -> int:
return x * 2
assert my_func(21) == 42
await wait_for_evaluations()
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
call_spans = [s for s in spans if 'my_func' in s['name']]
assert len(call_spans) == 1
attrs = call_spans[0]['attributes']
assert attrs['x'] == 21
assert attrs['return'] == 42
# --- Evaluator span: parenting and result attributes -----------------------
@needs_logfire
@pytest.mark.anyio
async def test_dispatch_skipped_when_emit_off_and_no_sinks(capfire: CaptureLogfire):
"""Skip evaluator dispatch entirely when results would have nowhere to go."""
config = OnlineEvalConfig(emit_otel_events=False) # no sinks either
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(1)
await wait_for_evaluations()
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
# The call span is still created (it wraps the function), but the evaluator
# never runs because results would be discarded.
assert any('my_func' in s['name'] for s in spans)
assert not any(s['name'] == 'evaluator: {evaluator_name}' for s in spans)
assert list(capfire.log_exporter.get_finished_logs()) == []
@needs_logfire
@pytest.mark.anyio
async def test_evaluator_span_nested_under_call_span(capfire: CaptureLogfire):
"""The `evaluator: {name}` span created in `run_evaluator` parents to the call span."""
# Need a sink (or `emit_otel_events=True`) to keep dispatch active — see
# `dispatch_evaluators` skip-when-no-output short-circuit.
config = OnlineEvalConfig(default_sink=Collector(), emit_otel_events=False)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
await my_func(1)
await wait_for_evaluations()
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
call_spans = [s for s in spans if 'my_func' in s['name']]
evaluator_spans = [s for s in spans if s['name'] == 'evaluator: {evaluator_name}']
assert len(call_spans) == 1
assert len(evaluator_spans) == 1
assert evaluator_spans[0]['parent']['span_id'] == call_spans[0]['context']['span_id']
# --- Baggage propagation ---------------------------------------------------
@needs_logfire
@pytest.mark.anyio
async def test_baggage_attached_to_evaluation_event(capfire: CaptureLogfire):
"""Baggage set in the calling context propagates onto emitted evaluation events."""
from opentelemetry import baggage as ot_baggage, context as ot_context
config = OnlineEvalConfig() # emit_otel_events=True
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
bag_ctx = ot_baggage.set_baggage('tenant', 'acme')
token = ot_context.attach(bag_ctx)
try:
await my_func(1)
await wait_for_evaluations()
finally:
ot_context.detach(token)
finished = capfire.log_exporter.get_finished_logs()
assert len(finished) == 1
attrs = dict(finished[0].log_record.attributes or {})
assert attrs['tenant'] == 'acme'
@needs_logfire
@pytest.mark.anyio
async def test_baggage_disabled_via_config(capfire: CaptureLogfire):
"""`include_baggage=False` keeps baggage out of emitted events."""
from opentelemetry import baggage as ot_baggage, context as ot_context
config = OnlineEvalConfig(include_baggage=False)
@config.evaluate(AlwaysTrue())
async def my_func(x: int) -> int:
return x
bag_ctx = ot_baggage.set_baggage('tenant', 'acme')
token = ot_context.attach(bag_ctx)
try:
await my_func(1)
await wait_for_evaluations()
finally:
ot_context.detach(token)
attrs = dict(capfire.log_exporter.get_finished_logs()[0].log_record.attributes or {})
assert 'tenant' not in attrs