Internals¶
This page explains how the plugin works, in enough detail to modify it confidently. It assumes familiarity with pytest’s hook system and custom collectors.
Everything lives in one module, pytest_probability/plugin.py,
registered under the pytest11 entry point named probability.
The big picture¶
pytest_collect_file (file name matches prob_pattern)
└─ BenchFile.collect()
├─ import the module under a path-unique name
├─ read setup / teardown
└─ yield BenchFunction per module-level bench_* callable
└─ BenchFunction.collect()
├─ expand parametrize marks into case variants
└─ yield BenchItem per (case, run)
(--prob-transpose reorders)
BenchItem.runtest() (one (case, run) execution)
├─ optional inter-run delay
├─ run the bench_* body; classify by exception type
├─ publish ("probability", {...}) onto item.user_properties
└─ raise StepFailures / let exceptions propagate
ProbabilityAggregator (registered in pytest_configure)
├─ pytest_runtest_logreport: rebuild stats from user_properties
├─ pytest_terminal_summary: render the fraction table
└─ pytest_sessionfinish: write the JSON report (controller only)
The load-bearing design decision is in the middle: execution publishes plain data onto the test report; aggregation consumes reports. Everything else follows from it.
Collection¶
pytest_collect_file claims any file whose name fnmatches
prob_pattern and returns a BenchFile (a pytest.File subclass).
Regular test_*.py files never match, so pytest’s own Python collector
is untouched.
BenchFile.collect() imports the module with importlib under a
name derived from the file’s stem plus a hash of its full path
(pytest_probability_mods.bench_x_1a2b3c4d), so two files with the
same stem in different directories cannot collide in sys.modules.
Import errors propagate — pytest turns them into collection errors
with a traceback, which beats silently collecting nothing. It then
yields a BenchFunction collector for every module-level bench_*
callable, in definition order — a file with none collects nothing,
silently, so a bench_helpers.py utility module is ignored rather
than reported as broken.
BenchFunction.collect() reads the function’s marks straight off
bench_fn.pytestmark — the attribute pytest.mark.* decorators stash
on the function — rather than going through Metafunc, which only
exists for functions collected by pytest’s Python collector:
Parametrize marks are expanded by
_parametrize_variants()into(id, params, marks)triples: the cartesian product across stacked decorators, processed inpytestmarkorder (bottom decorator leftmost in the composed id, matching pytest). Ids follow pytest’s default rules (value text for scalars,argnameNfor objects), withids=andpytest.param(id=...)overrides.ParameterSet(whatpytest.paramreturns) is detected by duck-typing to avoid importing private pytest modules. No parametrize marks yield the single empty variant — an unparametrized function is one case.One
BenchItemper (case, run) is emitted; case-major order by default,--prob-transposeflips the loop nesting. Each item’scaseattribute is the function-qualified row id (classify::identify_pii, or justclassifyfor the empty variant), so identical ids in different benchmarks aggregate separately.Non-parametrize marks on the function, plus per-value marks from
pytest.param(..., marks=...), are attached to every generated item. One subtlety:Node.add_marker()only acceptsMarkDecorator, butpytestmarkholds rawMarkobjects, so the plugin appends toitem.own_markersanditem.keywordsdirectly — the same two placesadd_marker()writes. pytest’s skipping/xfail machinery and-mselection read from those, so they work on bench items unmodified.
Module-level setup()/teardown() are stored on the BenchFile and
exposed as the collector’s own setup()/teardown(). pytest’s
SetupState calls a collector’s setup() before the first item in its
chain and teardown() when leaving it — which is precisely
once-per-file semantics, with no bookkeeping of our own.
indirect=True is rejected territory: it routes parameters through
fixtures, and BenchItem does not participate in the fixture system
(no FuncFixtureInfo, no request object). Wiring that up is the single
biggest possible extension to this plugin — it would also unlock
tmp_path-style fixtures inside bench functions — and should be done
deliberately, not accidentally.
Execution¶
BenchItem.runtest() calls bench_fn(**params) — a plain test body —
inside a try that classifies the outcome:
Clean return → pass. (A returned generator is rejected with a loud
TypeError: the pre-0.2.0 yield-based style would otherwise silently pass without executing.)AssertionError→ fail; the first line of the assert message is kept as the run’smessage, then the exception re-raises so pytest renders its normal traceback — pointing at the actualassert.Any other
Exception→ error; recorded as"TypeError: …"-style text, then re-raised.BaseExceptioncontrol flow (pytest.skip,pytest.fail, KeyboardInterrupt) is not caught: those runs are not samples and are never recorded.
Around the call, runtest() starts a perf_counter clock (the run’s
elapsed) and installs a _RunRecorder into a ContextVar —
record_usage()/record_cost() resolve it and append. The
finally block publishes the record whatever the outcome, which is
why usage recorded before a failing assert survives. The ContextVar
(rather than a global) keeps concurrent in-process runs isolated.
Assertion rewriting is wired in at import: _compile_bench_source
parses the module to an AST, runs it through pytest’s rewriter
(_pytest.assertion.rewrite.rewrite_asserts — one deliberate internal
import, guarded so the plugin degrades to plain asserts if it ever
moves), and compiles the result itself. The rewritten code is
self-contained (it imports its helpers at module top), and pytest’s
own pytest_runtest_protocol wrapper installs the comparison hooks
around every item — including ours — so bare asserts report operands
and diffs exactly as in test_*.py. --assert=plain and a
PYTEST_DONT_REWRITE docstring opt out, matching pytest.
The inter-run delay also lives at the top of runtest(). A
StashKey[bool] on config marks “the first benchmark item already
ran”, so the sleep happens strictly between executions. Being
config-stash-scoped makes it naturally per-process — each xdist worker
throttles its own stream.
Publishing results: why user_properties¶
runtest() ends by appending one tuple to item.user_properties:
("probability", {"case": case, "run": run_id, "outcome": "fail",
"message": ..., "error": None, "elapsed": 0.41,
"cost": 0.0002, "usage": [ ... ]})
Alternatives considered and rejected:
Record into a collector object during
runtest()— simplest, but wrong under pytest-xdist: items execute in worker processes, and the controller (which renders the summary) would aggregate nothing.A custom xdist IPC channel — needless coupling to xdist internals.
user_properties is the sanctioned escape hatch: pytest copies it onto
the TestReport, and xdist serializes reports from workers to the
controller. The constraint it imposes is that values must survive that
serialization — hence plain dicts and scalars only (usage entries
are normalized to dicts as they are recorded). If you extend the
payload, keep it JSON-shaped.
Aggregation and output¶
ProbabilityAggregator is instantiated once per session in
pytest_configure and registered as a plugin, which is how its hook
methods get called with no global state.
pytest_runtest_logreportfilters forwhen == "call"reports, unpacks the"probability"user property, and folds each run into anOrderedDict[case → CaseStats]by itsoutcomeclass. Cost sums when present, andusageentries merge into a per-model aggregate on the row. Row order is therefore first-encounter order of results.pytest_terminal_summaryrenders the table with fractions right-aligned across rows, colors by status (green/yellow/red), and appends theOverall/Cost/Tokens/Reportfooter lines (the per-modelTokens:block merges every row’s usage). It renders nothing when no benchmark items ran.pytest_sessionfinishwrites the JSON report. It returns early on xdist workers, detected by theworkerinputattribute on the config — the standard idiom, used instead of importing xdist. Raw per-run records are only retained in memory when--prob-jsonwas requested.
The row status property names the combination of the three run
classes (pass / fail / error): pass and error are the pure cases,
fail means no passes but real failures exist, flaky is reserved
for a genuine pass/fail mixture, and errored marks passes marred
only by errors. Fractions are passes/total — every run counts — and
the status word plus the (N errored) annotation say which class made
up the gap.
Invariants worth preserving¶
If you change the plugin, these are the properties the test suite pins down and users rely on:
Everything published to
user_propertiesis plain-dict/scalar (xdist serialization).Item ids are stable and predictable (
file::bench_fn::case-id[runN]) — people script against them with-kand--deselect.Parametrize ids and ordering match real pytest for the same decorators.
Outcome classes are exception-derived and exact:
AssertionError→ fail, otherException→ error,pytest.skip/control-flow → not a sample. Usage recorded before the exception is never lost.Files matching the pattern but lacking
bench_*functions collect nothing, silently.The summary section appears only when benchmark items ran; regular pytest suites see zero output difference.
The JSON report is written once, by the process that has all the results.