You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#87 delivers a shared doctest namespace by merging a namespace's blocks into one doctest.DocTest, and therefore one pytest item. That works everywhere, including under distribution, but it spends per-block node ids for the blocks that share. #88 asks for the opposite trade and is right that Sphinx's mechanism keeps both — its objection is not answerable by argument, because the two designs are optimal under different run configurations.
This issue proposes shipping both, as orthogonal settings, so neither is imposed. It is strictly additive: no existing run changes, no stdlib or pytest class is subclassed, and the new path is refused loudly rather than silently when the run cannot support it.
The insight that makes this cheap: #87 conflated two independent choices.
Scope — what shares a namespace: a block, a declared group, or the document.
Items — whether sharing merges those blocks into a single test, or leaves each block as its own test.
doctest_docutils_namespace_scope already exists and answers the first. This issue adds a second setting answering the second, defaulting to today's behaviour.
There are exactly two ways around it, and both are sanctioned:
Merge — one DocTest holding every block's examples. This is what pytest's own text-file collector does, calling parser.get_doctest once for a whole file.
Share the mapping — assign the same dict to each block's test and disable the clear. This is what sphinx.ext.doctest does, with the two obstacles annotated in-line: # DocTest.__init__ copies the globs namespace, which we don't want and # also don't clear the globs namespace after running the doctest. The stdlib itself uses the override — DebugRunner.run passes clear_globs=False.
Neither is a hack. They differ only in what unit becomes a pytest item.
What per-block node ids buy, and what merging costs
Carried over from #88, which this issue supersedes.
A page collected with one item per block yields one id per block, and those ids are what make a block reachable:
pytest page.md::page.md[6] to re-run one block while iterating on it
--lf re-running only the block that failed, rather than the whole page
-k and --deselect reaching a block
a JUnit report naming the block that failed
The measurement that motivated #88, on a 16-block page — note this is not an argument from speed:
Layout
Wall clock
16 items, -n auto
6.73 s, 6.38 s
serial (what 1 item costs)
4.65 s, 5.10 s
Per-block items are in fact slower at that size, because xdist worker startup outweighs the parallelism. The case for keeping them is debuggability and tooling reach, not throughput.
The cost merging does not advertise
One item means one function-scoped fixture setup for the whole namespace, not one per block. That is not a detail — it is a written contract in the largest consuming project. libtmux's own contributor documentation states it verbatim:
Every code block on a page is an independent doctest. Blocks do not share a session: each one gets a fresh copy of the namespace and a fresh tmux server. A later block cannot use a pane an earlier block created — the name is simply not defined there.
and its README advertises "Fresh tmux server/session/window/pane fixtures per test".
Merging keeps that promise literally — a merged page is one test, so it still gets one fresh server per test — while inverting it in practice, because the number of tests on the page collapsed from sixteen to one. Measured on gp-libs' own suite: a page with two gated blocks went from 1 fixture setup to 3 when blocks were lifted into their own items, and back to 1 when they were marked before setup. The same arithmetic runs the other way for merging.
This is the strongest single argument for the setting proposed here, and it is the reason a project like libtmux would want per-block even at the cost of pinning a scheduler.
The constraint that shapes the design
A shared mapping is a Python object, so it does not cross processes. pytest-xdist gives every worker its own collection and its own memory (docs/how-it-works.rst), and its documentation is explicit that state is not shared across workers (docs/how-to.rst).
Measured against a shared-mapping build, on the two-block page from #83:
--dist defaults to no, and -n without --dist promotes it to load at src/xdist/plugin.py#L304-L334. loadfile keeps a file's items on one worker by splitting the scope on the nodeid's path (scheduler/loadfile.py#L35-L60). loadgroup reads the xdist_group marker on the worker and suffixes the nodeid with @group (src/xdist/remote.py#L236-L254); an item with no marker distributes as plain load.
So a plugin can emit the marker but cannot choose the scheduler. Sharing the mapping is therefore conditionally correct — and, critically, the condition is detectable.
Detection is available and public
On the controller, config.option.dist holds the real value at pytest_configure time. Verified empirically:
serial → no
-n 2 → load
-n 2 --dist loadfile → loadfile
-n 2 --dist loadgroup → loadgroup
-n auto → load
On a worker it is forced to "no" at src/xdist/remote.py#L392-L400, so the check must be gated on the public is_xdist_controller helper. That is the difference between a footgun and a supported option: the plugin can refuse to run silently-wrong.
Profiling
Neither shape is a throughput win, in either direction. A 16-block Markdown page, three runs averaged:
block scope, serial — 16 items, 635 ms
document scope (merged), serial — 1 item, 662 ms
block scope, -n auto — 16 items, 3351 ms
document scope (merged), -n auto — 1 item, 3057 ms
Serially the difference is noise. Under -n auto, worker startup dominates both by roughly 5x. This confirms the measurement in #88 and extends it: item granularity is an ergonomic choice, not a performance one, so the decision should be made on debuggability and safety alone.
Proposal
Add one setting, defaulting to current behaviour.
doctest_docutils_namespace_items, ini plus a matching command-line flag:
merged (default) — a namespace becomes one DocTest and one test. Today's behaviour, unchanged.
per-block — every block keeps its own DocTest and its own node id; each is handed the same namespace mapping, and the runner is told not to clear it between blocks.
Under per-block, the plugin additionally:
Emits an xdist_group marker per namespace during collection, so --dist loadgroup groups a document's blocks onto one worker. The marker is consumed at remote.py#L236-L254.
Registers that marker unconditionally at pytest_configure, so --strict-markers users who never opt in are unaffected — the idiom pytest-asyncio uses for its own marker.
Raises pytest.UsageError on the controller when the setting is per-block, sharing is on, and the scheduler is load — naming --dist loadfile as the fix. Erroring on an unsupported configuration is established pytest-asyncio practice.
Reports the resolved setting in pytest_report_header, so the mode is discoverable without reading source — again pytest-asyncio's pattern.
Why this is additive
stdlib doctest — nothing subclassed, nothing monkeypatched. clear_globs is a documented parameter and test.globs a documented attribute; DebugRunner already sets the precedent. One consequence to document rather than prevent: compileflags is derived from the namespace at run time via _extract_future_flags(test.globs), so a shared mapping shares __future__ flags between blocks.
_pytest.doctest — untouched. DoctestItem keeps taking one dtest per item (#L251); this changes only what doctest_docutils emits. Following syrupy's example, no core item class is subclassed — the plugin works through collection hooks and its own state.
doctest_namespace — pytest merges fixtures with self.dtest.globs.update(globs) at #L288-L293. Under per-block, seeding the mapping before the first block preserves that for every block.
existing suites — the default is merged, and the default scope is block, so nothing changes until a project opts in twice.
pytest-xdist — the incompatible combination is refused at configure time rather than producing a wrong result.
Sphinx builds — unaffected; the directive nodes are unchanged, and a ```{doctest} fence still resolves through the same docutils directive registry that reST uses.
The discipline here is the one tmux applies to its own option surface: add the new spelling, keep the old one working, and say so. See CHANGES#L1164 — "refresh-client -F has become -f (-F stays for backwards compatibility)" — and #L1300 on terminal-overrides.
Under namespace_items = per-block with sharing enabled, a document's blocks share state and each keeps its own node id (page.md::page.md[1]).
Under namespace_items = per-block, pytest <nodeid> for a block that depends on an earlier block fails informatively rather than mysteriously — the limitation is inherent, so it must be documented, not hidden.
Under namespace_items = per-block with --dist load (including bare -n auto), the session stops with a pytest.UsageError naming --dist loadfile.
Under namespace_items = per-block with --dist loadfile, a state-building page passes.
Under namespace_items = per-block with --dist loadgroup, a state-building page passes, driven by an emitted xdist_group marker.
--strict-markers passes for projects that never opt in.
doctest_namespace fixtures reach every block under both settings.
testsetup / testcleanup ordering and :skipif: / :options: semantics are identical under both settings.
python -m doctest_docutils and testdocutils() behave sanely under both settings, since neither has a scheduler.
The resolved setting appears in pytest_report_header.
doctest: Upstream updates #26 — upstream doctest updates. This issue adds a dependency on the clear_globs / test.globs contract, which is the surface doctest: Upstream updates #26 would need to re-check against a new CPython. Worth cross-linking permanently.
pytest-asyncio incompatibility #43 — pytest-asyncio incompatibility. Adjacent: both concern how gp-libs coexists with another collection-time plugin, and pytest-asyncio is also the design precedent cited above.
doctests in python open source: Position paper #10 — the position paper on doctests in open source. The scope/setting split is the kind of distinction that paper argues for; worth folding in if it is ever written up.
Is doctest_docutils_namespace_items the clearest name, or should the two settings be collapsed into a single enumeration?
Should per-block auto-upgrade the scheduler when it detects load and a single worker, or always refuse? Refusing is simpler and never surprising.
pytest_xdist_make_scheduler (newhooks.py#L95-L99) is the documented extension point for custom distribution. Supplying a scheduler is more invasive than refusing, but would remove the user-facing requirement entirely. Worth evaluating before committing to the UsageError.
Summary
#87 delivers a shared doctest namespace by merging a namespace's blocks into one
doctest.DocTest, and therefore one pytest item. That works everywhere, including under distribution, but it spends per-block node ids for the blocks that share. #88 asks for the opposite trade and is right that Sphinx's mechanism keeps both — its objection is not answerable by argument, because the two designs are optimal under different run configurations.This issue proposes shipping both, as orthogonal settings, so neither is imposed. It is strictly additive: no existing run changes, no stdlib or pytest class is subclassed, and the new path is refused loudly rather than silently when the run cannot support it.
The insight that makes this cheap: #87 conflated two independent choices.
doctest_docutils_namespace_scopealready exists and answers the first. This issue adds a second setting answering the second, defaulting to today's behaviour.Background
The reason this needs a mechanism at all is a two-line contract in the stdlib.
DocTest.__init__copies the namespace it is handed, andDocTestRunner.runclears it afterwards by default atLib/doctest.py#L1577-L1578. Between the copy and the clear, NDocTestobjects cannot share state by default.There are exactly two ways around it, and both are sanctioned:
DocTestholding every block's examples. This is what pytest's own text-file collector does, callingparser.get_doctestonce for a whole file.sphinx.ext.doctestdoes, with the two obstacles annotated in-line:# DocTest.__init__ copies the globs namespace, which we don't wantand# also don't clear the globs namespace after running the doctest. The stdlib itself uses the override —DebugRunner.runpassesclear_globs=False.Neither is a hack. They differ only in what unit becomes a pytest item.
What per-block node ids buy, and what merging costs
Carried over from #88, which this issue supersedes.
A page collected with one item per block yields one id per block, and those ids are what make a block reachable:
pytest page.md::page.md[6]to re-run one block while iterating on it--lfre-running only the block that failed, rather than the whole page-kand--deselectreaching a blockThe measurement that motivated #88, on a 16-block page — note this is not an argument from speed:
-n autoPer-block items are in fact slower at that size, because xdist worker startup outweighs the parallelism. The case for keeping them is debuggability and tooling reach, not throughput.
The cost merging does not advertise
One item means one function-scoped fixture setup for the whole namespace, not one per block. That is not a detail — it is a written contract in the largest consuming project. libtmux's own contributor documentation states it verbatim:
and its README advertises "Fresh tmux server/session/window/pane fixtures per test".
Merging keeps that promise literally — a merged page is one test, so it still gets one fresh server per test — while inverting it in practice, because the number of tests on the page collapsed from sixteen to one. Measured on gp-libs' own suite: a page with two gated blocks went from 1 fixture setup to 3 when blocks were lifted into their own items, and back to 1 when they were marked before setup. The same arithmetic runs the other way for merging.
This is the strongest single argument for the setting proposed here, and it is the reason a project like libtmux would want
per-blockeven at the cost of pinning a scheduler.The constraint that shapes the design
A shared mapping is a Python object, so it does not cross processes. pytest-xdist gives every worker its own collection and its own memory (
docs/how-it-works.rst), and its documentation is explicit that state is not shared across workers (docs/how-to.rst).Measured against a shared-mapping build, on the two-block page from #83:
2 passed-n 2, default scheduler —1 failed, 1 passed-n 2 --dist loadfile—2 passed-n 2 --dist loadgroup, noxdist_groupmarker —1 failed, 1 passed--distdefaults tono, and-nwithout--distpromotes it toloadatsrc/xdist/plugin.py#L304-L334.loadfilekeeps a file's items on one worker by splitting the scope on the nodeid's path (scheduler/loadfile.py#L35-L60).loadgroupreads thexdist_groupmarker on the worker and suffixes the nodeid with@group(src/xdist/remote.py#L236-L254); an item with no marker distributes as plainload.So a plugin can emit the marker but cannot choose the scheduler. Sharing the mapping is therefore conditionally correct — and, critically, the condition is detectable.
Detection is available and public
On the controller,
config.option.distholds the real value atpytest_configuretime. Verified empirically:no-n 2→load-n 2 --dist loadfile→loadfile-n 2 --dist loadgroup→loadgroup-n auto→loadOn a worker it is forced to
"no"atsrc/xdist/remote.py#L392-L400, so the check must be gated on the publicis_xdist_controllerhelper. That is the difference between a footgun and a supported option: the plugin can refuse to run silently-wrong.Profiling
Neither shape is a throughput win, in either direction. A 16-block Markdown page, three runs averaged:
-n auto— 16 items, 3351 ms-n auto— 1 item, 3057 msSerially the difference is noise. Under
-n auto, worker startup dominates both by roughly 5x. This confirms the measurement in #88 and extends it: item granularity is an ergonomic choice, not a performance one, so the decision should be made on debuggability and safety alone.Proposal
Add one setting, defaulting to current behaviour.
doctest_docutils_namespace_items, ini plus a matching command-line flag:merged(default) — a namespace becomes oneDocTestand one test. Today's behaviour, unchanged.per-block— every block keeps its ownDocTestand its own node id; each is handed the same namespace mapping, and the runner is told not to clear it between blocks.Under
per-block, the plugin additionally:xdist_groupmarker per namespace during collection, so--dist loadgroupgroups a document's blocks onto one worker. The marker is consumed atremote.py#L236-L254.pytest_configure, so--strict-markersusers who never opt in are unaffected — the idiom pytest-asyncio uses for its own marker.pytest.UsageErroron the controller when the setting isper-block, sharing is on, and the scheduler isload— naming--dist loadfileas the fix. Erroring on an unsupported configuration is established pytest-asyncio practice.pytest_report_header, so the mode is discoverable without reading source — again pytest-asyncio's pattern.Why this is additive
doctest— nothing subclassed, nothing monkeypatched.clear_globsis a documented parameter andtest.globsa documented attribute;DebugRunneralready sets the precedent. One consequence to document rather than prevent:compileflagsis derived from the namespace at run time via_extract_future_flags(test.globs), so a shared mapping shares__future__flags between blocks._pytest.doctest— untouched.DoctestItemkeeps taking onedtestper item (#L251); this changes only whatdoctest_docutilsemits. Following syrupy's example, no core item class is subclassed — the plugin works through collection hooks and its own state.doctest_namespace— pytest merges fixtures withself.dtest.globs.update(globs)at#L288-L293. Underper-block, seeding the mapping before the first block preserves that for every block.merged, and the default scope isblock, so nothing changes until a project opts in twice.```{doctest}fence still resolves through the same docutils directive registry that reST uses.The discipline here is the one tmux applies to its own option surface: add the new spelling, keep the old one working, and say so. See
CHANGES#L1164— "refresh-client -F has become -f (-F stays for backwards compatibility)" — and#L1300onterminal-overrides.Acceptance criteria
namespace_items = per-blockwith sharing enabled, a document's blocks share state and each keeps its own node id (page.md::page.md[1]).namespace_items = per-block,pytest <nodeid>for a block that depends on an earlier block fails informatively rather than mysteriously — the limitation is inherent, so it must be documented, not hidden.namespace_items = per-blockwith--dist load(including bare-n auto), the session stops with apytest.UsageErrornaming--dist loadfile.namespace_items = per-blockwith--dist loadfile, a state-building page passes.namespace_items = per-blockwith--dist loadgroup, a state-building page passes, driven by an emittedxdist_groupmarker.--strict-markerspasses for projects that never opt in.doctest_namespacefixtures reach every block under both settings.testsetup/testcleanupordering and:skipif:/:options:semantics are identical under both settings.python -m doctest_docutilsandtestdocutils()behave sanely under both settings, since neither has a scheduler.pytest_report_header.How this relates to the open issues
mergesetting. This issue is a follow-up to it, not a competitor; it adds the second setting on top.[N]index, which is exactly what thesharedsetting preserves for every block.:pyversion:raisesInvalidVersionat collection #86 — directive options and:pyversion:. Same construction site (_find), so they should land before this to avoid a second rewrite of that function. Both are fixed in Share doctest namespaces across a page, and honour directive options #87.doctestupdates. This issue adds a dependency on theclear_globs/test.globscontract, which is the surface doctest: Upstream updates #26 would need to re-check against a new CPython. Worth cross-linking permanently.Open questions
doctest_docutils_namespace_itemsthe clearest name, or should the two settings be collapsed into a single enumeration?per-blockauto-upgrade the scheduler when it detectsloadand a single worker, or always refuse? Refusing is simpler and never surprising.pytest_xdist_make_scheduler(newhooks.py#L95-L99) is the documented extension point for custom distribution. Supplying a scheduler is more invasive than refusing, but would remove the user-facing requirement entirely. Worth evaluating before committing to theUsageError.per-blockbe marked experimental for one release, with the deprecation-warning discipline pytest-asyncio uses when a default is going to move?