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
Four open issues — #83, #84, #85, #86 — all change how doctest_docutils turns a document into doctest.DocTest objects. Two of them, #83 (blocks do not share a namespace) and #85 (node ids embed an absolute path), pull toward the same implementation shortcut: emit oneDocTest per document instead of one per block. That shortcut delivers both, and silently spends something neither issue mentions — per-block addressability.
Upstream Sphinx keeps per-block DocTest objects while sharing a namespace, which is an existence proof that the mechanism is sanctioned — though Sphinx is a builder with no node ids, no -k and no workers, so it demonstrates the mechanism, not that per-block addressability transports with it. This issue asks that the namespace work land the way Sphinx does it, so sharing state and keeping one item per block stay independent choices.
Those ids are what make a single block selectable. Losing them costs:
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 page
-k and --deselect reaching a block
a JUnit report naming the block that failed
None of that is speed. Measured on a 16-block page, one item per block is in fact slower than one per document, because xdist worker startup outweighs the gain at that size:
Layout
Wall clock
16 items, -n auto
6.73 s, 6.38 s
serial (what 1 item costs)
4.65 s, 5.10 s
So the argument for keeping per-block items is debuggability and tooling, not throughput. Worth stating plainly, because a reader could otherwise assume the reverse.
Background: why one DocTest per document is the shorter path
DocTest.__init__ copies the namespace it is handed:
Adopt Sphinx's mechanism: build a DocTest per block as today, then assign the shared mapping and disable the clear. Sphinx annotates both obstacles in-line:
sphinx/ext/doctest.py#L600-L613 — # DocTest.__init__ copies the globs namespace, which we don't want → test.globs = ns, then # also don't clear the globs namespace after running the doctest → run(test, out=..., clear_globs=False)
This keeps one pytest item per block and gives blocks a shared namespace, which are the two things #83 and #85 want separately.
Compatibility
Additive on four axes, and constrained on a fifth:
stdlib doctest — nothing subclassed or monkeypatched. clear_globs is a documented parameter of DocTestRunner.run and test.globs a documented attribute; the clear itself is Lib/doctest.py#L1577-L1578. The stdlib sanctions the override: DebugRunner.run passes clear_globs=False itself. One caveat worth recording: compileflags is derived from the namespace at run time via _extract_future_flags(test.globs), so a shared mapping also shares __future__ flags between blocks.
_pytest.doctest — untouched. Its own text collector stays one-DocTest-per-file at _pytest/doctest.py#L423-L444; DoctestItem keeps taking a dtest per item at #L251.
the doctest_namespace fixture — pytest merges it with self.dtest.globs.update(globs) at #L288-L293. Seeding the shared mapping before the first block preserves that.
existing suites — default stays one namespace per block, so no suite changes behaviour until it opts in.
pytest-xdist — this is the axis the issue originally omitted, and it governs. Each worker is a separate process that performs its own collection (docs/how-it-works.rst), so a shared mapping does not cross workers. Measured against a shared-mapping build on the two-block page from Doctest blocks in one document do not share a namespace, unlike pytest text files #83: serial 2 passed; -n 2 (default scheduler) 1 failed, 1 passed; -n 2 --dist loadfile2 passed; -n 2 --dist loadgroupwithout an xdist_group marker 1 failed, 1 passed.
--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), which is the documented guarantee at docs/distribution.rst. loadgroup reads the xdist_group marker on the worker and suffixes the nodeid with @group (src/xdist/remote.py#L236-L254); an item without the marker distributes as plain load.
A plugin can emit the marker but cannot choose the scheduler. So the honest statement is: sharing requires the run to select --dist loadfile, or --dist loadgroup together with an xdist_group marker; under the default --dist load it cannot work by construction, not by implementation defect.
It is detectable. On the controller, config.option.dist holds the real value at pytest_configure time (verified: serial no, -n 2load, --dist loadfileloadfile, --dist loadgrouploadgroup, -n autoload). On a worker it is forced to "no" at src/xdist/remote.py#L392-L400, so a guard must gate on the public is_xdist_controller helper.
Cost not previously recorded
Merging has a cost this issue should state, because it strengthens the case for keeping per-block items: one item means one function-scoped fixture setup for the whole namespace. libtmux's own documentation promises the opposite — a fresh server per block — so merging silently changes that contract for every page that shares.
Profiling
Neither shape is a throughput win. A 16-block page, three runs averaged:
block scope, serial — 16 items, 635 ms
document scope, serial — 1 item, 662 ms
block scope, -n auto — 16 items, 3351 ms
document scope, -n auto — 1 item, 3057 ms
Serially the difference is noise. Under -n auto, worker startup dominates both. The choice is ergonomic in both directions, not a performance trade.
Acceptance criteria
Status is against issue-83-doctest-namespace (#87).
A document whose blocks share state passes with sharing enabled, and still collects one item per block. Open — and requires the run to select a compatible scheduler, per Compatibility above.
With sharing disabled (the default), a name bound in block 1 is not visible in block 2 — today's behaviour, unchanged. Met.
Node ids stay per block and stay machine-independent, satisfying pytest node IDs embed an absolute path #85 without removing the index: page.md::page.md[1], not an absolute path. Met.
For blocks that share no state, -k, --deselect, --lf and --junitxml each still address a single block. Met. Scoped deliberately: wherever criterion 1 applies, a block that reads a name an earlier block bound cannot pass alone under any mechanism, loadfile included — so an unscoped version of this criterion is unachievable rather than merely unimplemented.
doctest_namespace fixtures are visible in every block under both settings. Met.
Sphinx-style groups keep working: blocks in one group share, ungrouped blocks stay isolated, mixed documents behave. Met.
A block that raises does not poison later blocks with a half-built namespace, or the limitation is documented. Met and documented: by default a raise stops the namespace so the blocks below never run; --doctest-continue-on-failure runs them against the half-built namespace, producing dependent NameErrors.
testsetup / testcleanup run once per group rather than once per block. Met, matching Sphinx's ordering at sphinx/ext/doctest.py#L553 and #L620-L621.
Doctest directive options are parsed and then discarded #84 — directive options parsed then discarded. Same construction site, so a fix that re-shapes DocTest creation should land in a known order with this one to avoid two rewrites of the same function.
doctest: Upstream updates #26 — upstream doctest updates. The clear_globs / test.globs contract used here is the surface that would need re-checking against a new CPython.
Not related: #43 (pytest-asyncio) — that package does not touch doctest collection.
One DocTest per document. Simplest, and it does satisfy #83 and #85 together. It forfeits criteria 3-4 for the blocks that share — not permanently and not globally: under #87 the default still collects one item per block, and merging happens only where an author declares a group or a project opts into document scope. Sphinx faced the same choice and kept per-block objects.
Group-only sharing, no document scope. Sound, and strictly better for keeping items separate. It does not serve #83's actual reported case — two plain ```python fences — without rewriting them as {doctest} directives, which requires sphinx.ext.doctest in each consuming project's conf.py.
Summary
Four open issues — #83, #84, #85, #86 — all change how
doctest_docutilsturns a document intodoctest.DocTestobjects. Two of them, #83 (blocks do not share a namespace) and #85 (node ids embed an absolute path), pull toward the same implementation shortcut: emit oneDocTestper document instead of one per block. That shortcut delivers both, and silently spends something neither issue mentions — per-block addressability.Upstream Sphinx keeps per-block
DocTestobjects while sharing a namespace, which is an existence proof that the mechanism is sanctioned — though Sphinx is a builder with no node ids, no-kand no workers, so it demonstrates the mechanism, not that per-block addressability transports with it. This issue asks that the namespace work land the way Sphinx does it, so sharing state and keeping one item per block stay independent choices.Motivation
A page collected today yields one item per block:
$ pytest docs/topics/automation_patterns.md --collect-only -qThose ids are what make a single block selectable. Losing them costs:
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 page-kand--deselectreaching a blockNone of that is speed. Measured on a 16-block page, one item per block is in fact slower than one per document, because xdist worker startup outweighs the gain at that size:
-n autoSo the argument for keeping per-block items is debuggability and tooling, not throughput. Worth stating plainly, because a reader could otherwise assume the reverse.
Background: why one DocTest per document is the shorter path
DocTest.__init__copies the namespace it is handed:cpython/Lib/doctest.py#L562—self.globs = globs.copy()and
DocTestRunner.runempties it afterwards by default:cpython/Lib/doctest.py#L1504—run(self, test, compileflags=None, out=None, clear_globs=True)cpython/Lib/doctest.py#L1577-L1578—if clear_globs: test.globs.clear()Between the copy and the clear, N
DocTestobjects cannot share state by default. Collapsing them into one is the shortest way around both.gp-libs currently emits one per node:
gp-libs/src/doctest_docutils.py#L385-L402— the per-node loopgp-libs/src/doctest_docutils.py#L416—get_doctest(string, globs, name, filename, lineno)Proposal
Adopt Sphinx's mechanism: build a
DocTestper block as today, then assign the shared mapping and disable the clear. Sphinx annotates both obstacles in-line:sphinx/ext/doctest.py#L525-L526— one namespace per group,ns: dict[str, Any] = {}sphinx/ext/doctest.py#L600-L613—# DocTest.__init__ copies the globs namespace, which we don't want→test.globs = ns, then# also don't clear the globs namespace after running the doctest→run(test, out=..., clear_globs=False)sphinx/ext/doctest.py#L538-L549— the same pattern fortestsetup/testcleanupThe stdlib sanctions it:
DebugRunnerpassesclear_globs=Falseitself, atcpython/Lib/doctest.py#L1960-L1961.This keeps one pytest item per block and gives blocks a shared namespace, which are the two things #83 and #85 want separately.
Compatibility
Additive on four axes, and constrained on a fifth:
stdlib
doctest— nothing subclassed or monkeypatched.clear_globsis a documented parameter ofDocTestRunner.runandtest.globsa documented attribute; the clear itself isLib/doctest.py#L1577-L1578. The stdlib sanctions the override:DebugRunner.runpassesclear_globs=Falseitself. One caveat worth recording:compileflagsis derived from the namespace at run time via_extract_future_flags(test.globs), so a shared mapping also shares__future__flags between blocks._pytest.doctest— untouched. Its own text collector stays one-DocTest-per-file at_pytest/doctest.py#L423-L444;DoctestItemkeeps taking adtestper item at#L251.the
doctest_namespacefixture — pytest merges it withself.dtest.globs.update(globs)at#L288-L293. Seeding the shared mapping before the first block preserves that.existing suites — default stays one namespace per block, so no suite changes behaviour until it opts in.
pytest-xdist — this is the axis the issue originally omitted, and it governs. Each worker is a separate process that performs its own collection (
docs/how-it-works.rst), so a shared mapping does not cross workers. Measured against a shared-mapping build on the two-block page from Doctest blocks in one document do not share a namespace, unlike pytest text files #83: serial2 passed;-n 2(default scheduler)1 failed, 1 passed;-n 2 --dist loadfile2 passed;-n 2 --dist loadgroupwithout anxdist_groupmarker1 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), which is the documented guarantee atdocs/distribution.rst.loadgroupreads thexdist_groupmarker on the worker and suffixes the nodeid with@group(src/xdist/remote.py#L236-L254); an item without the marker distributes as plainload.A plugin can emit the marker but cannot choose the scheduler. So the honest statement is: sharing requires the run to select
--dist loadfile, or--dist loadgrouptogether with anxdist_groupmarker; under the default--dist loadit cannot work by construction, not by implementation defect.It is detectable. On the controller,
config.option.distholds the real value atpytest_configuretime (verified: serialno,-n 2load,--dist loadfileloadfile,--dist loadgrouploadgroup,-n autoload). On a worker it is forced to"no"atsrc/xdist/remote.py#L392-L400, so a guard must gate on the publicis_xdist_controllerhelper.Cost not previously recorded
Merging has a cost this issue should state, because it strengthens the case for keeping per-block items: one item means one function-scoped fixture setup for the whole namespace. libtmux's own documentation promises the opposite — a fresh server per block — so merging silently changes that contract for every page that shares.
Profiling
Neither shape is a throughput win. A 16-block 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. The choice is ergonomic in both directions, not a performance trade.Acceptance criteria
Status is against
issue-83-doctest-namespace(#87).page.md::page.md[1], not an absolute path. Met.-k,--deselect,--lfand--junitxmleach still address a single block. Met. Scoped deliberately: wherever criterion 1 applies, a block that reads a name an earlier block bound cannot pass alone under any mechanism,loadfileincluded — so an unscoped version of this criterion is unachievable rather than merely unimplemented.doctest_namespacefixtures are visible in every block under both settings. Met.--doctest-continue-on-failureruns them against the half-built namespace, producing dependentNameErrors.testsetup/testcleanuprun once per group rather than once per block. Met, matching Sphinx's ordering atsphinx/ext/doctest.py#L553and#L620-L621.Relationship to the open issues
[N]index, so it no longer contradicts criterion 3.DocTestcreation should land in a known order with this one to avoid two rewrites of the same function.:pyversion:raisesInvalidVersionat collection #86 —:pyversion:argument order. Independent bug, same file; listed so the sequence is deliberate.doctestupdates. Theclear_globs/test.globscontract used here is the surface that would need re-checking against a new CPython.Not related: #43 (pytest-asyncio) — that package does not touch doctest collection.
:pyversion:raisesInvalidVersionat collection #86. It takes the merge path, so criteria 2-8 land there and criterion 1 does not.Alternatives
One
DocTestper document. Simplest, and it does satisfy #83 and #85 together. It forfeits criteria 3-4 for the blocks that share — not permanently and not globally: under #87 the default still collects one item per block, and merging happens only where an author declares a group or a project opts into document scope. Sphinx faced the same choice and kept per-block objects.Group-only sharing, no document scope. Sound, and strictly better for keeping items separate. It does not serve #83's actual reported case — two plain
```pythonfences — without rewriting them as{doctest}directives, which requiressphinx.ext.doctestin each consuming project'sconf.py.References
doctest_docutils.pyper-node loop, v0.0.19 and node namingTestGroup, sharedns,test.globs = nsandclear_globs=False, wildcard*,:options:merge,skipped()*DocTest.__init__copy,DocTestRunner.run, the clear,DebugRunner.run,_extract_future_flagsDoctestTextfile.collect,DoctestItem,_check_all_skipped--distand its default,loadfilescope split,xdist_groupconsumption, worker forcesdist=no,is_xdist_controller{directive}fence resolves through the docutils directive registry, a plain fence becomes aliteral_blockwhoselineis the token's start