pydantic--pydantic-ai
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
2416 行
74 KiB
Python
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
|