Skip to content

0008 — The plugin collects and reports; the runner executes

  • Status: accepted
  • Date: 2026-09-03

The pytest plugin takes over pytest_runtestloop. It hands every selected Eval to run_eval() before any item runs, then each item's runtest merely reports the Result the runner already produced. The plugin no longer executes anything.

The first version of the plugin executed each case inside its own runtest. That is the obvious shape, and it is wrong for this tool: pytest runs items strictly one after another. A per-item runtest therefore cannot be concurrent, cannot abandon one case on a timeout while others continue, and cannot share a cache or a trace collector across a run. Every capability the runner provides was unreachable from the path users actually run — pytest and evalstand run both. The summary printed - for cost on runs that really did spend money, because Runs were rebuilt from item state that never held a trace.

The risk register anticipated this as "the plugin fights the runner's async model" and prescribed falling back to a standalone runner with pytest compatibility as a stretch. That fallback is not needed. pytest_runtestloop resolves the conflict outright: only one component executes, so there is nothing left to fight over, and the promise that bare pytest collects and runs evals is preserved rather than waived.

Why not take the loop over entirely

The hook returns None rather than True, so pytest still walks the items itself. Claiming the loop would mean reimplementing -x, --maxfail, fixtures, reporting and every other plugin's expectations — all of which pytest already does well, and none of which is what this project is for. Ordinary tests and evals therefore coexist in one session, each behaving normally.

Consequences

Selection has to reach the runner. run_eval(..., only=...) filters (case_id, repeat_index) pairs before anything executes. Filtering afterwards would be a bug with a price tag: -k q1 would send thirty requests and discard twenty-nine. --collect-only executes nothing for the same reason.

A missing Result must never read as a pass. An item whose result is None raises rather than reporting success — a green tick for a case that never ran is the most misleading thing an eval tool can do.

Tracebacks must be captured at the raise. The runner catches a task's exception so one bad case cannot end the run, which destroys the traceback long before pytest reports it. Result.error_frames holds the user's own frames, formatted at capture: storing the traceback object itself would pin every frame's locals in memory and could carry an API key into the database. Frames from evalstand, asyncio and concurrent.futures are stripped, since to_thread puts a worker frame on top of every sync task's stack.

Evals run sequentially with respect to each other. --concurrency is a promise about how many calls are in flight; running four evals at once would quietly multiply it by four and trip the rate limit the flag exists to avoid.

A testing note worth recording

These tests use runpytest_subprocess, not the default in-process run. Once litellm has been imported inside a pytester inline session, importlib.metadata.entry_points() returns nothing for the rest of the process — so the next inline run cannot register pytest-cov and dies with unrecognized arguments: --no-cov. Since the runner imports litellm transitively, every inline test here would poison the ones after it. The subprocess is also the more honest check: it is what a user's shell does.

This is a pytester artifact, not a defect in the design. Real runs — pytest and evalstand run — are unaffected, which was verified directly before the integration was accepted.