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

720 lines
20 KiB
Python

import io
import time
import asyncio
from contextlib import contextmanager
from unittest.mock import MagicMock
from types import SimpleNamespace
from typing import Callable, List, Optional, Protocol, runtime_checkable
from deepeval.constants import ProviderSlug as PS
from deepeval.metrics import BaseMetric, TaskCompletionMetric
from deepeval.models.retry_policy import create_retry_decorator
from deepeval.optimizer.types import ModuleId
from deepeval.prompt.prompt import Prompt
from deepeval.tracing.types import TraceSpanStatus
@runtime_checkable
class ApiTestCaseLike(Protocol):
name: Optional[str]
success: Optional[bool]
metrics_data: List
input: Optional[str]
actual_output: Optional[str]
expected_output: Optional[str]
context: Optional[List[str]]
retrieval_context: Optional[List[str]]
def update_metric_data(self, *args, **kwargs) -> None: ...
def update_status(self, *args, **kwargs) -> None: ...
def update_run_duration(self, *args, **kwargs) -> None: ...
def make_trace_api_like(status):
"""Shape compatible with TraceApi members that `execute` touches."""
return SimpleNamespace(
name="trace",
status=status,
error=None,
input=None,
output=None,
expected_output=None,
context=None,
retrieval_context=None,
agent_spans=[],
llm_spans=[],
retriever_spans=[],
tool_spans=[],
base_spans=[],
metrics_data=[],
)
def make_span_api_like():
return SimpleNamespace(status=None, error=None, metrics_data=[])
##########
# Models #
##########
class StubProvider:
def __init__(self, value: str) -> None:
self.value = value
class StubModelSettings:
def __init__(self, provider=None, name: str | None = None) -> None:
self.provider = provider
self.name = name
class StubPrompt:
def __init__(
self,
alias: str | None = None,
label: str | None = None,
model_settings: StubModelSettings | None = None,
) -> None:
self.alias = alias
self.label = label
self.model_settings = model_settings
class DummyModel:
def get_model_name(self):
return "dummy"
class AlwaysJsonModel:
"""
Test stub that always returns JSON text and NEVER accepts `schema=`,
so the simulator takes the JSON path (trimAndLoadJson).
Pass an `extractor` callable that takes the full prompt and returns the
JSON snippet to emit.
Usage:
- AlwaysJsonModel.balanced_json_after_anchor(anchor)
"""
def __init__(self, extractor: Callable[[str], str]):
if not callable(extractor):
raise TypeError("extractor must be a callable(prompt) -> str JSON")
self._extractor = extractor
# no support for `schema=` kwarg so we always take JSON path
def generate(self, prompt: str) -> str:
return self._extractor(prompt)
async def a_generate(self, prompt: str) -> str:
return self.generate(prompt)
def get_model_name(self) -> str:
return "always-json-stub"
@staticmethod
def balanced_json_after_anchor(anchor_text: str) -> Callable[[str], str]:
"""
Returns an extractor that finds the first balanced JSON object
after the given anchor string.
"""
def extractor(prompt: str) -> str:
anchor_index = prompt.find(anchor_text)
if anchor_index == -1:
raise ValueError(f"Anchor '{anchor_text}' not found in prompt.")
json_start_index = prompt.find("{", anchor_index)
if json_start_index == -1:
raise ValueError(
f"No opening '{{' found after anchor '{anchor_text}'."
)
brace_depth = 0
for char_index, character in enumerate(
prompt[json_start_index:], start=json_start_index
):
if character == "{":
brace_depth += 1
elif character == "}":
brace_depth -= 1
if brace_depth == 0:
json_end_index = char_index + 1
return prompt[json_start_index:json_end_index]
raise ValueError(f"Unbalanced braces after anchor '{anchor_text}'.")
return extractor
class _RecordingClient:
"""
Generic SDK-style client stub that records kwargs passed to its constructor.
Used by provider model tests to assert that we pass the correct api_key and
retry options to SDK constructors without making network calls.
"""
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def make_fake_ollama_module(client_cls=_RecordingClient):
"""
Return a fake 'ollama' module with Client / AsyncClient mocks that:
- Are MagicMocks, so tests can use assert_called_once, call_args, etc.
- Construct instances of `client_cls` when called, via side_effect.
"""
client_mock = MagicMock()
async_client_mock = MagicMock()
client_mock.side_effect = client_cls
async_client_mock.side_effect = client_cls
return SimpleNamespace(
Client=client_mock,
AsyncClient=async_client_mock,
)
def _make_fake_genai_module():
"""
Return a fake 'google.genai' module where require_dependency directly returns an instance of _RecordingClient.
"""
# Define the mock types
fake_types = SimpleNamespace(
SafetySetting=MagicMock(),
HarmCategory=SimpleNamespace(
HARM_CATEGORY_DANGEROUS_CONTENT="dangerous",
HARM_CATEGORY_HARASSMENT="harassment",
HARM_CATEGORY_HATE_SPEECH="hate_speech",
HARM_CATEGORY_SEXUALLY_EXPLICIT="sexually_explicit",
),
HarmBlockThreshold=SimpleNamespace(
BLOCK_NONE="block_none",
BLOCK_ONLY_HIGH="block_only_high", # Ensure this is included
),
)
# Return the fake genai module with the actual instances
return SimpleNamespace(
Client=_RecordingClient,
AsyncClient=_RecordingClient,
types=fake_types,
)
###########
# Metrics #
###########
class _DummyMetric(BaseMetric):
"""Simple metric that can be flagged to simulate a skip."""
def __init__(self, name="dummy", should_skip=False):
self.name = name
self.should_skip = should_skip
self.skipped = False
self.error = None
self.success = False
self.threshold = 0.5
def measure(self, test_case, *_args, **_kwargs):
if self.should_skip:
self.skipped = True
return
self.success = True
def is_successful(self) -> bool:
return bool(self.success)
class _DummyTaskCompletionMetric(TaskCompletionMetric):
"""Metric used to toggle the 'has_task_completion' path."""
def __init__(self, name="tc"):
self.name = name
self.skipped = False
self.error = None
self.success = False
self.threshold = 0.5
def measure(self, test_case, *_args, **_kwargs):
self.success = True
def is_successful(self) -> bool:
return bool(self.success)
class _SleepyMetric(BaseMetric):
"""
Test stub that can sleep in both sync and async paths.
Args:
name: display name
sleep_s: seconds to sleep (None/0 means no sleep)
should_skip: mark as skipped instead of evaluating
succeed: whether to set success=True after sleep, the default is False
"""
def __init__(
self,
name: str = "sleepy",
*,
sleep_s: float | None = None,
should_skip: bool = False,
succeed: bool = False,
):
self.name = name
self.sleep_s = sleep_s
self.should_skip = should_skip
self.succeed = succeed
# required BaseMetric fields
self.skipped = False
self.error = None
self.success = False
self.threshold = 0.5
self.score = None
self.reason = None
def measure(self, test_case, *_args, **_kwargs):
if self.should_skip:
self.skipped = True
return
if self.sleep_s:
time.sleep(self.sleep_s)
self.success = bool(self.succeed)
async def a_measure(self, test_case, *_args, **_kwargs):
if self.should_skip:
self.skipped = True
return
if self.sleep_s:
await asyncio.sleep(self.sleep_s)
self.success = bool(self.succeed)
def is_successful(self) -> bool:
return bool(self.success)
class _PerAttemptTimeoutMetric(BaseMetric):
"""
A metric that intentionally exceeds the per-attempt timeout budget to trigger
Tenacity retries. Works in both sync and async executor paths.
Use:
set sleep_s > per-attempt timeout
"""
threshold = 0.0
def __init__(self, *, sleep_s: float = 10.0):
self.sleep_s = float(sleep_s)
self.name = "_PerAttemptTimeoutMetric"
# BaseMetric.measure is wrapped with run_sync_with_timeout
def measure(self, test_case, **kwargs) -> float:
retry = create_retry_decorator(PS.OPENAI)
@retry
def slow_op():
# run_sync_with_timeout() in the retry layer enforces the per-attempt timeout
time.sleep(self.sleep_s)
return 1.0
return slow_op()
# BaseMetric.a_measure is wrapped with asyncio.wait_for
async def a_measure(self, test_case, **kwargs) -> float:
retry = create_retry_decorator(PS.OPENAI)
@retry
async def slow_op():
# resolve_effective_attempt_timeout() will bound asyncio.wait_for(...) around this
await asyncio.sleep(self.sleep_s)
return 1.0
return await slow_op()
# required by BaseMetric
def is_successful(self) -> bool:
return False
#########
# Spans #
#########
class _FakeSpan:
def __init__(self, *, input=None, output=None, metrics=None, children=None):
self.input = input
self.output = output
self.expected_output = None
self.context = None
self.retrieval_context = None
self.tools_called = None
self.expected_tools = None
self.metrics = metrics or []
self.children = children or []
self.status = TraceSpanStatus.SUCCESS
self.error = None
##########
# Traces #
##########
class _FakeTrace:
def __init__(
self, *, input=None, output=None, metrics=None, root_span=None
):
self.input = input
self.output = output
self.expected_output = None
self.context = None
self.retrieval_context = None
self.tools_called = None
self.expected_tools = None
self.metrics = metrics or []
self.root_spans = [root_span] if root_span else []
self.status = TraceSpanStatus.SUCCESS
self.error = None
self.uuid = "trace-uuid"
######################
# Progress Indicator #
######################
class DummyProgress:
"""
Tiny stub for rich.progress.Progress used to test _on_status.
Records update / advance calls.
"""
def __init__(self, tasks=None):
self.records = []
self.tasks = list(tasks) if tasks is not None else []
def update(self, task_id, **kwargs):
self.records.append(("update", task_id, kwargs))
def advance(self, task_id, amount):
self.records.append(("advance", task_id, {"amount": amount}))
def remove_task(self, task_id):
# rich removes the task from its task list and so shall we
self.records.append(("remove_task", task_id, {}))
self.tasks = [
t for t in self.tasks if getattr(t, "id", None) != task_id
]
###############
# Synthesizer #
###############
class DummyEvolutionConfig:
num_evolutions = 0
evolutions = {}
@contextmanager
def stub_synthesizer_progress_context(**kwargs):
# behave like synthesizer_progress_context: yield (progress, pbar_id)
progress = kwargs.get("progress") or DummyProgress()
pbar_id = kwargs.get("pbar_id")
yield (progress, pbar_id)
################
# Optimization #
################
class _DummyRewriter:
"""
Minimal object satisfying the Rewriter at runtime.
Used to verify set_rewriter/get_rewriter wiring.
"""
def rewrite(self, **kwargs):
# Just return the original prompt unmodified
return kwargs["old_prompt"]
async def a_rewrite(self, **kwargs):
return kwargs["old_prompt"]
class SuffixRewriter:
"""Rewriter that appends a suffix to the prompt text."""
def __init__(self, suffix: str = " CHILD") -> None:
self.suffix = suffix
self.calls = []
self.a_calls = []
def rewrite(self, *, old_prompt, feedback_diagnosis=None, **kwargs):
self.calls.append((old_prompt, feedback_diagnosis))
return Prompt(
text_template=(old_prompt.text_template or "") + self.suffix
)
async def a_rewrite(self, *, old_prompt, feedback_diagnosis=None, **kwargs):
self.a_calls.append((old_prompt, feedback_diagnosis))
return self.rewrite(
old_prompt=old_prompt, feedback_diagnosis=feedback_diagnosis
)
class AddBetterRewriter:
def rewrite(
self, *, module_id: ModuleId, old_prompt: Prompt, feedback_text: str
) -> Prompt:
return Prompt(
text_template=(
(old_prompt.text_template or "") + " BETTER"
).strip(),
messages_template=old_prompt.messages_template,
model_settings=old_prompt.model_settings,
output_type=old_prompt.output_type,
output_schema=old_prompt.output_schema,
)
class DummyRunner:
"""
Minimal runner used to verify set_runner wiring.
"""
def __init__(self):
self.model_callback = None
self.status_callback = None
def execute(self, *, prompt, goldens):
raise NotImplementedError
async def a_execute(self, *, prompt, goldens):
raise NotImplementedError
class DummyRunnerForOptimize:
"""
Runner that simulates a completed optimization run.
"""
def __init__(self):
self.model_callback = None
self.status_callback = None
self.last_execute_args = None
def execute(self, *, prompt, goldens):
self.last_execute_args = (prompt, goldens)
# Simulate an "optimized" best prompt
best = Prompt(text_template="optimized")
# Minimal but valid OptimizationResult-like payload
report = {
"optimization_id": "opt-123",
"best_id": "best",
"accepted_iterations": [],
"pareto_scores": {"best": [1.0]},
"parents": {"best": None},
"prompt_configurations": {
"best": {
"parent": None,
"prompts": {
# Arbitrary module id; just needs to be a string key
"module-1": {
"type": "TEXT", # coerces into PromptType / Literal
"text_template": "optimized",
}
},
}
},
}
return best, report
async def a_execute(self, *, prompt, goldens):
raise AssertionError("a_execute should not be called in sync optimize")
class SyncDummyRunner:
"""
Runner used to test _run_optimization(sync path).
"""
def __init__(self):
self.execute_calls = 0
self.a_execute_calls = 0
def execute(self, *, prompt, goldens):
self.execute_calls += 1
return prompt, {
"optimization_id": "sync-id",
"best_id": "root",
"accepted_iterations": [],
"pareto_scores": {"root": [1.0]},
"parents": {"root": None},
}
async def a_execute(self, *, prompt, goldens):
self.a_execute_calls += 1
return prompt, {
"optimization_id": "async-id",
"best_id": "root",
"accepted_iterations": [],
"pareto_scores": {"root": [1.0]},
"parents": {"root": None},
}
class AsyncDummyRunner:
"""
Runner used to test _run_optimization(async path).
"""
def __init__(self):
self.execute_calls = 0
self.a_execute_calls = 0
def execute(self, *, prompt, goldens):
self.execute_calls += 1
raise AssertionError(
"execute() should not be called when run_async=True"
)
async def a_execute(self, *, prompt, goldens):
self.a_execute_calls += 1
return prompt, {
"optimization_id": "opt-async",
"best_id": "root",
"accepted_iterations": [],
"pareto_scores": {"root": [1.0]},
"parents": {"root": None},
}
class StubScoringAdapter:
"""
Minimal scoring adapter stub for exercising GEPARunner and other
single-module optimization runners.
- score_pareto / score_minibatch:
returns higher scores for prompts whose text contains "CHILD"
so that "improved" children can be accepted.
"""
def __init__(self) -> None:
self.pareto_calls = []
self.a_pareto_calls = []
self.feedback_calls = []
self.a_feedback_calls = []
self.score_calls = []
self.a_score_calls = []
def _get_prompt_text(self, prompt_configuration):
if not getattr(prompt_configuration, "prompts", None):
return ""
# For GEPA/MIPROV2 we expect a single module id in `prompts`.
prompt = next(iter(prompt_configuration.prompts.values()))
return (prompt.text_template or "").strip()
def score_pareto(self, prompt_configuration, d_pareto):
self.pareto_calls.append((prompt_configuration, list(d_pareto)))
txt = self._get_prompt_text(prompt_configuration)
return [1.0] if "CHILD" in txt else [0.5]
async def a_score_pareto(self, prompt_configuration, d_pareto):
self.a_pareto_calls.append((prompt_configuration, list(d_pareto)))
return self.score_pareto(prompt_configuration, d_pareto)
def get_minibatch_feedback(
self, prompt_configuration, module_id, minibatch
):
self.feedback_calls.append(
(prompt_configuration, module_id, list(minibatch))
)
return "feedback"
async def a_get_minibatch_feedback(
self, prompt_configuration, module_id, minibatch
):
self.a_feedback_calls.append(
(prompt_configuration, module_id, list(minibatch))
)
return "feedback"
def score_minibatch(self, prompt_configuration, minibatch):
self.score_calls.append((prompt_configuration, list(minibatch)))
txt = self._get_prompt_text(prompt_configuration)
return 1.0 if "CHILD" in txt else 0.5
async def a_score_minibatch(self, prompt_configuration, minibatch):
self.a_score_calls.append((prompt_configuration, list(minibatch)))
return self.score_minibatch(prompt_configuration, minibatch)
##################
# File I/O stubs #
##################
class RecordingFile(io.StringIO):
"""
Test stub that records flush() calls and exposes a fake fileno(),
used to verify that we call flush() and os.fsync(fd) correctly.
"""
def __init__(self):
super().__init__()
self.flushed = False
self.closed_flag = False
# Arbitrary fake file descriptor; tests only check identity equality
self._fd = 42
def flush(self):
self.flushed = True
return super().flush()
def fileno(self):
return self._fd
def close(self):
self.closed_flag = True
return super().close()
class RecordingPortalockerLock:
"""
Minimal drop-in for portalocker.Lock used in tests.
It always returns a new RecordingFile and exposes the most recently
created one via the class attribute `last_file` so tests can assert on it.
"""
last_file = None
def __init__(self, *args, **kwargs):
self.file = RecordingFile()
RecordingPortalockerLock.last_file = self.file
def __enter__(self):
return self.file
def __exit__(self, exc_type, exc, tb):
self.file.close()