Experimental _pytest.ensemble API: nested configs + in-memory collection (PoC) - #14809
Experimental _pytest.ensemble API: nested configs + in-memory collection (PoC)#14809RonnyPfannschmidt wants to merge 15 commits into
_pytest.ensemble API: nested configs + in-memory collection (PoC)#14809Conversation
714974e to
8845122
Compare
|
Nice initiative. Anything that brings more isolation, hermeticity and control of side-effects seems like a good direction to me. I haven't dug into the code or details yet, so just some small comments: The name "scenario" sounds possibly too generic for this, it doesn't sound like sometime that is intended for testing pytest itself, but like an end-user feature, like pytest-bdd has
This sounds possibly nice but didn't look at the details. In due time, it would be nice to have this in a separate commit or PR to be considered separately.
👍 on the general idea.
What's the reason to use separate types than the TestReport etc. themselves? I do believe pytester has some helpers to grab those, although we don't use them as much as we should. Regarding these:
These all sound (just based on the description) look nice independent improvements. Consider submitting as separate PRs, to reduce the changes here.
These sound like they can share the benefits with the threading work.
Ideally we can have this nice stuff in pytester from the start, instead of a separate API, just to keep things simple for users (mostly plugin authors). But maybe there's good reason to have a separate API, I don't know yet. |
|
This is a initial poc It includes a number of precursor enhancements that need to be extracted and landed first As to why new types/apis - pytester is very intertwined with the problematic way to run things and completely dependent on capture- i wanted to start without that as limit to explore the details first |
8845122 to
98a1488
Compare
Extract the body of ``Config.parse`` into named phase methods (``_preparse_addopts``, ``_apply_rootdir``, ``_register_core_ini_options``, ``_load_plugins_phase``, ``_load_initial_conftests_phase``, ``_finalize_parse``) so the individual steps can be driven separately by callers that build a configuration programmatically instead of from a command line. Behavior preserving: ``parse()`` calls the phases in the same order and with the same arguments as before. The only observable change is the function name a conftest load warning is attributed to, which test_warnings asserts on. ``known_args_namespace`` gains an explicit class-level annotation because the phases that read it are now defined before the one that assigns it.
``get_config()`` hardcoded ``Path.cwd()`` as the invocation directory, which forces any programmatic caller to either chdir or accept whatever the ambient working directory happens to be. Accept an explicit ``dir=`` instead, defaulting to the previous behavior.
98a1488 to
6fec77e
Compare
``Class.from_parent`` accepted an ``obj`` keyword and dropped it on the floor, so the collector always resolved its object by looking ``name`` up on the parent's object. Keep the passed object and serve it from ``_getobj``, which makes it possible to collect a class that is not an attribute of any importable module.
``TestCaseFunction.runtest`` never calls ``pytest_pyfunc_call``, so ``PdbTrace``'s wrapper did not reach unittest test cases and ``unittest.py`` compensated by reaching into ``_pytest.debugging`` and re-deriving "is --trace active" from the raw option value. Wrap ``pytest_runtest_call`` instead, which covers both kinds of item, and drop ``maybe_wrap_pytest_function_for_tracing`` along with the cross-plugin import. The wrapper is ``trylast`` so it nests inside ``CaptureManager``'s wrapper: ``_init_pdb()`` suspends capturing, and an outer wrapper would have that immediately undone when the capture manager starts the call phase. The remaining ``usepdb`` read gets an explicit default, so that reading it does not require the debugging plugin to be registered.
Add the experimental, internal ``_pytest.ensemble`` package, starting with its configuration layer: ``ConfigSpec`` describes a nested pytest configuration as plain frozen data, and ``configured()`` turns it into a parsed *and* configured ``Config`` with paired teardown. The config is built through the same parse phases a command line invocation uses, but from the spec's explicit values only - no rootdir discovery, config file reading, conftest loading, plugin autoloading or environment variable consultation. The default plugin set is the essential core plus the plugins that give tests their usual semantics, deliberately excluding everything that renders output, captures io or installs process-global state. ``Config`` gains ``ArgsSource.SPEC`` and ``_finalize_parse(decide_args=False)`` so a programmatic caller can take positional args verbatim instead of falling back to testpaths or the invocation directory.
Add the collection and results layers of ``_pytest.ensemble``. ``build_module(name, *members)`` groups loose functions and classes into an in-memory module with an explicit name. ``EnsembleModule`` is a ``Module`` collector whose synthetic path is rootdir-relative - giving well-formed nodeids - but never touched on disk, serving the preset object from ``_getobj`` instead of going through the import chokepoint. ``collect_sources()`` feeds those collectors to the standard ``pytest_collection`` flow, so ``-k``/``-m`` deselection, parametrization, fixture scoping and ``pytest_collection_modifyitems`` all apply as usual. ``run_items()`` drives the normal runtest protocol. ``RunRecorder`` observes reports, warnings and deselections; ``RunRecord`` and ``ItemRecord`` derive typed results from the real report objects via ``pytest_report_teststatus``, with per-test lookup and an ``assert_outcomes()`` that is signature compatible with pytester's. ``Ensemble`` ties the two together as a context manager for stepwise use; ``run_tests()`` and ``collect_tests()`` are the one-shot forms.
Convert seven existing tests in testing/python/ to build their sources as real python objects and assert on typed records, instead of writing files to disk and glob-matching rendered terminal output. This includes the hand-rolled ``make_function`` harness in test_metafunc, which existed precisely because there was no API to collect an item from an in-memory object.
6fec77e to
4fc76a7
Compare
_pytest.ensemble API: nested configs + in-memory collection (PoC)
Add a benchmark contrasting the pytester harness with _pytest.ensemble across five arms - runpytest_subprocess, runpytest_inprocess, inline_run, run_tests, and a bare makepyfile so the file materialization cost can be subtracted - at 1, 10 and 100 test functions per run. Reporting fixed cost per run separately from marginal cost per test is what makes the comparison useful: pytester's cost is dominated by session bootstrap, which every one of the ~1900 pytester-based tests in our own suite pays individually. Lives in bench/, which is outside testpaths, so it does not run in CI.
configured() replicated the ini setup of Config.parse but skipped the step that prepends `addopts` and folds `--override-ini` values back into the inifile config. Every OverrideIniAction option - --strict, --strict-markers, --strict-config - and every `-o name=value` was therefore parsed into the namespace and then dropped on the floor. The failure mode is the bad one: a spec asking for --strict-markers produced a config that silently ignored it, so a test written against that spec would pass while asserting nothing. Note that a `@pytest.mark.unregistered` decorator in the enclosing test body cannot be used to check this, because MarkGenerator resolves it against the host config at decoration time; `-m` expression validation is the reachable enforcement point, and the test says so.
Three places assumed the terminal plugin is registered, which a programmatically constructed config need not load: - Config.get_terminal_writer() asserted a terminalreporter exists. It now falls back to create_terminal_writer(self), so code that needs to render something does not have to care whether anything is reporting. - create_terminal_writer() read option.color / option.code_highlight directly, but those are registered by the terminal plugin, so the fallback above would have crashed on the very configs it exists for. Read defensively; a writer is still useful without them. - debugging._enter_pdb() reached into terminalreporter._tw and read option.showcapture. Entering the debugger on a failing test in a terminal-less config raised AttributeError instead of debugging. --pdb now works end to end in such a config.
Both plugins turned their own flag into the related ones from pytest_cmdline_main: --setup-plan implies --setup-only and --setup-show, --setup-only implies --setup-show. That hook only runs for command line invocations, so a programmatically constructed config accepted the flags and then behaved as if they had not been passed - the runner reads setuponly/setupshow and saw False. Move the normalization to pytest_configure. Every consumer reads these options at runtest or report time, well after configure, so the behavior for a command line run is unchanged; configs that are configured without going through the entry point now get it too.
configured() put the caller's own list objects into the config's ini cache. Config.addinivalue_line appends to that cached list, so a ConfigSpec - a frozen dataclass, and therefore reasonably expected to be reusable - grew every time it was configured: three uses of one spec took its `markers` list from 9 entries to 17 to 25. Copy mutable values on the way in, for both plain lists and preconstructed ConfigValues.
collect_tests() returned an empty list when collection failed, with no exception and no way to reach the failed CollectReport. "Collected nothing" and "collection blew up" were therefore indistinguishable, so a test asserting the former would pass for entirely the wrong reason - and that assertion shape is common in the suite this API is meant to serve. Report the failure through whichever channel each entry point has: - collect_tests() has none, so it raises CollectError. - Ensemble.collect() stays permissive - it is the stepwise API - but the failures are now reachable as Ensemble.collect_errors. - run_tests() keeps recording them; a collection error is a legitimate outcome there. RunRecord.collect_errors filters the failed reports. Collecting nothing because nothing matched remains perfectly fine.
Calling collect() twice registered the ensemble's sources a second time, so the session ended up with every test collected twice - a stepwise use that collects, inspects, then collects again silently doubled its items and its outcome counts. A repeat call without new sources now returns what was already collected. Passing new sources still adds them, under a suffixed module name so that loose sources in a later round do not land on a module path already in the tree.
Adds `capture_output=True`, which loads the terminal plugin and gives it a buffer to render into: RunRecord.output is the text, RunRecord.stdout a LineMatcher, so fnmatch_lines assertions can be kept verbatim rather than rewritten against the structured reports. The stream is bound when the terminal reporter is constructed, not redirected around it. terminal.pytest_configure hardcoded TerminalReporter(config, sys.stdout), so a nested run would have taken the stdout of whatever was running it - and would have computed isatty() from it too. It now reads terminal_file_key from the config stash, which ConfigSpec.output populates before the config is configured; a plain command line run is unaffected. An ensemble therefore renders into its own buffer and never writes to the outer stdout at all, which a tripwire test pins down.
|
RonnyPfannschmidt#140 tests effects of testsuite porting - its promising |
Important
Substantially reworked since the first review round (@bluetech, thanks). The
package was named
_pytest.scenario; it is now_pytest.ensemble, and thesingle squashed commit is now a 7-commit stack on top of current main, so the
precursor changes can be reviewed — and landed — on their own. See
What changed since the first round at the
bottom for the full delta.
Context
pytest's own testsuite is heavily integration-based: of ~3200 real test functions in
testing/, ~61% usepytester, ~91% of those write files to disk, and roughly 1400 assertion sites glob-match rendered terminal output (vs ~270 structured ones). Tests need a full session because stdout is the only observable most of them have — there is no API to collect an item from an in-memory object, or to build aConfigwithout a real rootdir/cwd.testing/conftest.pyeven reorders tests by decompiling__code__.co_namesbecause "uses pytester" is the only available proxy for "slow".This PR is a proof of concept for fixing that at the API level. Draft — the API shape is up for discussion.
What an ensemble is
A pytest ensemble is a deliberately small pytest assembled from parts handed to it, rather than a full session discovered from a filesystem. The chamber-ensemble-vs-orchestra reading is the intended one: the reduced plugin set is a design property, not a limitation to apologise for.
Three layers, all internal and not exported from
pytest:ConfigSpecis plain frozen data describing a nested configuration;configured()turns it into a parsed and configuredConfigwith paired teardown via_ensure_unconfigure(). Built through the real parse phases, but without rootdir discovery, config files, conftests, plugin autoload, or env consultation.build_module(name, *members)groups loose functions and classes into an in-memory module with an explicit name.collect_tests()/run_tests()/ the stepwiseEnsemblecontext manager collect through the standardpytest_collectionflow — so-k/-m, parametrize, fixture scoping andpytest_collection_modifyitemsall work — via anEnsembleModulewhose_getobjserves the preset object. Nodeids derive from synthetic rootdir-relative paths that are never touched on disk.RunRecord/ItemRecord, derived from real report objects viapytest_report_teststatus, withassert_outcomes()signature-compatible with pytester and per-test lookup (record["test_x"].setup.longreprtext).The commit stack
Reviewable and landable bottom-up; every commit is independently green (full suite, mypy, and the whole pre-commit hook set):
config: split Config.parse into phase methodsconfig: let get_config() take an explicit invocation dirpython: make Class.from_parent honor a passed objdebugging: trace via pytest_runtest_call instead of pytest_pyfunc_callensemble: build a hermetic nested Config from declarative dataConfigSpec/configured()+ArgsSource.SPECensemble: collect and run in-memory sources, record typed resultstesting: convert pytester-based tests to _pytest.ensemblebench: compare pytester and ensemble run coststestpaths, never runs in CICommits 1–4 are independent of the ensemble work and I'm happy to peel any of them into their own PR on request. They are here only so the stack builds.
Commit 4 deserves a note, because it turned out to be a real bug rather than a workaround.
TestCaseFunction.runtestnever callspytest_pyfunc_call, soPdbTrace's wrapper never reached unittest test cases;unittest.pycompensated by importing_pytest.debuggingand re-deriving "is--traceactive" from the raw option. Wrappingpytest_runtest_callinstead covers both kinds of item, andmaybe_wrap_pytest_function_for_tracingplus the cross-plugin import are deleted outright. The wrapper must betrylastso it nests insideCaptureManager's — otherwise_init_pdb()'s capture suspend is immediately undone when the capture manager starts the call phase, whichtest_pdbcls_via_local_modulecatches.Validation
43 self-tests in
testing/test_ensemble.pyrun in ~0.8s with hermeticity asserted (no sys.path/sys.modules/cwd/environ mutation, no files created). Seven existing tests intesting/python/{collect,fixtures,metafunc}.pyare converted, including the hand-rolledmake_functionharness — which existed precisely because there was no API to collect an item from an in-memory object.What the harness costs
bench/ensemble_vs_pytester.pycontrasts the arms at 1, 10 and 100 test functions per run. Splitting fixed cost from marginal cost is what makes the comparison legible:makepyfile+runpytest_subprocessmakepyfile+inline_runmakepyfile+runpytest_inprocessrun_tests(ensemble)makepyfilealoneThe fixed column is the point. At one test per run — which is what most pytester-based tests are — an ensemble is ~25x cheaper than
inline_runand ~52x cheaper thanrunpytest_subprocess. By 100 tests the ratio falls to ~13x, as real test execution starts to dominate. With roughly 1900 pytester-based tests in our own suite, that bootstrap is paid ~1900 separate times.Note also what the
makepyfilearm shows: writing the module costs 0.10 ms. "pytester touches the disk" is a hermeticity argument, not a performance one — the performance argument is config construction, plugin loading, conftest discovery, capture and terminal setup.Two caveats. Ensembles skip assertion rewriting entirely (a documented limitation below, and part of why they are cheaper), and the fixed/marginal split is a two-point estimate rather than a regression fit. The absolute numbers are from one machine; the ratios are the transferable part.
Deliberate scope cuts (documented follow-ups)
capture,terminal,assertion,cacheproviderand the process-global-state plugins.capsys/capfdinside an ensemble are unavailable untilCaptureManagerbecomes stack-aware (suspend/resume is currently absolute, restoring values memoised at__init__— nested managers work only by LIFO luck today). That is the next pillar, and it is the same work the free-threading effort needs.load_conftests=True) raisesNotImplementedError; plugin objects viaConfigSpec.extra_pluginsare the replacement.filterwarnings = error) are inherited; an ensemble'sinicfg={"filterwarnings": [...]}takes precedence.RunRecordcurrently has three lookup paths (by_test,_by_name,__getitem__); that surface wants tightening before anything else builds on it.inline_runonto this API, migrate stdout outcome-assertions toRunRecord, and consider graduating the package to public.What changed since the first round
Addressing #14809 (comment) point by point:
"scenario sounds too generic / like an end-user feature." Agreed — renamed to
ensemble. Considered and rejected:subpytest,rig,embed,compose,kernel,machinery, andsetups(which collides with pytest's four existing meanings of "setup", includingdetermine_setup— the very functionconfigured()exists to bypass). Member names stay deliberately literal so the metaphor lives only in the package name:Ensemble,build_module(),collect_sources(),RunRecorder,RunRecord/ItemRecord."ConfigSpec/configured() would be nice in a separate commit or PR." Done — commit 5, on a clean boundary above the parse split.
"The three drive-by fixes look like nice independent improvements; consider separate PRs." They are now commits 1–4 and can be peeled on request. Commit 4 changed shape entirely in the process: instead of defensively defaulting the
traceoption read, it removes the read (and the cross-plugin call) by fixing the hook bypass that caused it. Only theusepdbread remains, as an explicit default — postponingtearDownis genuine unittest logic that has to know pdb is live."What's the reason to use separate types than the TestReport etc.?" They're exploratory, and the goal is the opposite of a parallel hierarchy: I want to find out whether pytester's result handling can become a thin wrapper over these. Keeping them distinct from
TestReport/RunResultfor now is what makes that experiment legible —RunRecordis an aggregate over real report objects, not a replacement for them, and nothing is scraped from rendered output."Ideally we can have this nice stuff in pytester from the start." Long-term agreed. pytester is very intertwined with the problematic way to run things and completely dependent on capture; I wanted to start without that as a limit in order to explore the details first. Rebasing
inline_runonto this is on the follow-up list above."These sound like they can share the benefits with the threading work." Yes — stack-aware
CaptureManageris exactly the same prerequisite.Per our AI contribution policy: this was researched, designed and implemented with Claude Code under my direction and review.