Share doctest namespaces across a page, and honour directive options - #87
Share doctest namespaces across a page, and honour directive options#87tony wants to merge 81 commits into
Conversation
Code reviewFound 1 issue:
gp-libs/src/doctest_docutils.py Lines 579 to 581 in c2fc2e3 The call site that supplies phase order: gp-libs/src/doctest_docutils.py Lines 1135 to 1148 in c2fc2e3 Reproduced on a 15-line page with 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #87 +/- ##
===========================================
+ Coverage 76.68% 89.38% +12.69%
===========================================
Files 15 15
Lines 1025 2326 +1301
===========================================
+ Hits 786 2079 +1293
- Misses 239 247 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Code reviewFound 1 issue:
gp-libs/src/doctest_docutils.py Lines 597 to 602 in e51fad6 |
Code reviewFound 1 issue:
gp-libs/src/pytest_doctest_docutils.py Lines 338 to 345 in 55115db |
|
Measured this branch against libtmux by pinning it through Headline: A shared name outlives the fixture that made itUnder
import pytest
class Resource:
def __init__(self, n):
self.n = n
self.alive = True
COUNT = [0]
@pytest.fixture
def resource():
COUNT[0] += 1
r = Resource(COUNT[0])
yield r
r.alive = False
@pytest.fixture(autouse=True)
def _seed(doctest_namespace, resource):
doctest_namespace["resource"] = resource
```
>>> saved = resource
>>> saved.n, saved.alive
(1, True)
```
```
>>> saved.n, saved.alive
(1, False)
>>> resource.n, resource.alive
(2, True)
>>> saved is resource
False
```$ pytest page.md -q -p no:randomly --doctest-docutils-namespace-scope=document --doctest-docutils-namespace-items=per-block
2 passedThat page passes, which is the point — it asserts the surprise rather than tripping over it. The fixture's own name is rebound fresh each block ( In libtmux this is not hypothetical. A window carried across blocks answers Sybil fixes this in five lines, and they are portableSybil aliases module scope to document scope, so pytest's own module-scoped fixture machinery gives a fixture exactly the shared-state lifetime with no custom teardown: def getparent(self, cls):
if cls is Module:
return self.parent
if cls is Session:
return self.session
Then every resolved fixture is copied back into the namespace on every item, so a fixture name can never go stale — only an object a block derived itself: def setup(self) -> None:
self._request._fillfixtures()
for name, fixture in self.funcargs.items():
self.example.namespace[name] = fixture
The surrounding design is worth reading as a whole, since it is the same shape this branch arrived at independently. One dict owned above the items at Sphinx solves only the copy half, in three lines and with a comment naming the obstacle: Where
|
| Project | Ref | What was read |
|---|---|---|
| Sybil | 10.0.1 |
Document namespace ownership, SybilFile.collect, SybilItem.getparent / setup / request_fixtures, doctest evaluator, parsers, clear-namespace, invisible-code-block |
| Sphinx | v9.1.0 |
sphinx/ext/doctest.py — TestGroup, test_group, group directive semantics, testsetup / testcleanup |
| CPython | 3479e45 |
Lib/doctest.py — DocTest.__init__ glob copying, DocTestRunner.run clear_globs, DocTestParser, subtest reporting |
| pytest | f306da7 |
_pytest/doctest.py — DoctestTextfile.collect, DoctestItem, doctest_namespace scope, --doctest-continue-on-failure |
| xdoctest | v1.3.2 |
parsing granularity, --xdoctest-style freeform vs google, per-part diagnostics |
| pytest-asyncio | v1.4.0 |
loop_scope / asyncio_mode, scoped-fixture construction, scope-mismatch surfacing |
| MyST-Parser | v5.1.0 |
fence representation in the doctree, attrs_block, myst_fence_as_directive |
| docutils | trunk | Directive.option_spec plumbing, reporting thresholds |
Downstream verification was libtmux with this branch pinned via [tool.uv.sources]: full suite green at the default block scope, and the docs/ tree measured at every combination of the two new settings.
why: DocTest.__lt__ compares names, and a block's name carries its index as text, so the trailing sort ran page.md[10] ahead of page.md[1] on any page past nine blocks. Reports read out of order, and any future sharing of state between blocks would execute them out of order too. what: - Drop the tests.sort() call; _find already appends in traversal order - Note in find() why sorting is wrong here - Add a doctest on find() covering an eleven-block page
why: Eleven blocks is the smallest page where name order and document order disagree, and the failure was silent: every block passed, just in the wrong sequence. what: - Parametrize an eleven-block page over MyST fences and reST blocks - Assert on example source, so the check survives a naming change
why: Distributed runs are the constraint that decides how doctest namespaces may be shared. libtmux already runs its docs through py.test -n auto, so a page whose blocks share state has to survive being split across workers, and that property needs a test that actually runs rather than one gated behind importorskip. what: - Add pytest-xdist to the dev and testing groups - Relock: pytest-xdist 3.8.0, execnet 2.1.2
why: A test's name becomes its pytest node id, and the finder was given the file's full path for both roles at once, so ids read page.md::/home/you/docs/page.md[0]. That id is machine-specific: a checked-in --deselect matches nothing in CI, and JUnit XML carries the developer's home directory. Refs #85. what: - Name tests by the page's base name, as pytest's DoctestTextfile does - Keep the full path as DocTest.filename so reports still resolve - Extend the find() doctest to cover a name given as a path
why: The id is what a --deselect line, a -k pattern and a JUnit report carry, so it has to be pinned where it is built rather than inferred from the finder's test names. what: - Assert the collected ids of a twelve-block page, in order - Add _write_ini, shared by the pytest-layer collection tests
why: trim-doctest-flags strips a block's inline # doctest: comments out of the code a reader sees and keeps the original on the node, but the finder parsed the trimmed copy. So +NORMALIZE_WHITESPACE written inside a .. doctest:: was removed before the parser saw it and the flag never applied. Refs #84. what: - Parse node["test"] when the directive set it, node.astext() otherwise - Leave the rendered code trimmed, which is what the option is for
why: The flag only matters where the directive trims it away, so the case has to be a .. doctest:: block rather than a plain fence. what: - Assert the parsed example carries NORMALIZE_WHITESPACE and passes - Add the same page as an end-to-end pytest option case
why: The directive parsed :options: into a flag map on the node, warned on an unknown flag name, and then nothing read it, so a block asking to be skipped or compared with ELLIPSIS ran under the session defaults instead. Refs #84. what: - Seed each parsed example's options from the directive's map - Let the example's own inline flags override, as sphinx.ext.doctest does
why: The precedence is the part worth pinning: a directive sets the block's defaults and an example's own inline flag has to override it, in that direction only. what: - Assert the flag lands on the parsed example, on and off - Add end-to-end cases for NORMALIZE_WHITESPACE, SKIP, and precedence
why: A doctest block nested in a .. note::, a list item, or a block
quote has no line of its own, and the finder stringified it before
parsing, so collecting the page died on int('None') and took the whole
file with it.
what:
- Add _node_line, walking up for the nearest ancestor carrying a line
- Pass the line to _get_test as an int
- Drop the source_lines plumbing, which nothing else read
why: The crash was fatal to the whole page, so the assertion worth making is that both forms collect at all and land in reading order. what: - Parametrize a block nested in a directive and in list items - Assert every collected block carries a positive, ascending line
why: The directive parsed :skipif: onto the node and nothing read it, so a block guarded by a condition ran regardless and failed on the very platform or version it was written to sit out. Refs #84. what: - Add _skipif, evaluating the expression against sys plus the document's starting globals, and drop the block when it is true - Add SkipifExpressionError, naming the file, line and expression when the condition cannot be evaluated
why: The option decides collection, not outcome, so the assertion is on what comes back from the finder rather than on a pass or a skip. The error path matters as much: the namespace is small on purpose, so naming something outside it is an ordinary mistake. what: - Parametrize true, false, a starting global, and a sys expression - Assert the error names the file, line, and expression - Add an end-to-end page where one of two blocks is dropped
why: HIDE is gp-libs' own flag, but it was registered as pytest configured, and a page carrying an unregistered flag name fails to parse. So a .. doctest:: holding # doctest: +HIDE raised "invalid option" under python -m doctest_docutils — on this project's own pages. what: - Register HIDE when doctest_docutils is imported, not per entry point - Return that flag from the plugin's lookup - Correct the how-to, which told readers the standalone command could not parse the marker
why: The flag only broke where the plugin was absent, so the test has to reach the finder directly rather than run a page through pytest. what: - Parse a .. doctest:: carrying +HIDE with no plugin in play - Assert the flag lands on the example
why: f-strings in a log call interpolate even when the level filters the record out, and they give every line a unique message so an aggregator cannot group them. The _find entry also pprint-dumped the whole test list, source string, globals, and seen map on every document. what: - Interpolate with %s and carry detail in extra - Replace the pprint dump with a per-document and per-block record keyed by doctest_source_file and doctest_block_type
why: A name bound in one block was invisible in the next, so a narrative page could not build state across the prose explaining it, and two blocks naming the same group collected twice under one node id. pytest's own text-file collector, sphinx.ext.doctest and Sybil all let a page share. Closes #83. what: - Group a page's blocks by namespace and merge each into one DocTest, so a namespace is one pytest item and survives distribution - A declared group is a namespace at every scope; an ungrouped block gets its own under the default scope, the page's under "document" - Splice merged sources by file position, keeping reported lines and the gutter where each block alone would put them - Add namespace_scope to the finder and testdocutils, and --namespace-scope to python -m doctest_docutils
why: Merging changes where a failure says it happened, so the line numbers and the gutter need pinning as tightly as the sharing does. The awkward cases are the ones docutils numbers oddly: a block it leaves unpositioned, a group split across an .. include::, and a reST block numbered by its last line rather than its first. what: - Assert a namespace collects as one test, per fence and directive form - Assert what each scope lets a block read, and that groups stay apart across pages - Pin reported lines and the gutter against the unmerged page - Assert the collection log carries the namespace and block type
why: The namespace scope has to be reachable from a project's pytest configuration, not only from the library, and a misspelling should stop the session once rather than error on every file collected. what: - Add --doctest-docutils-namespace-scope and the matching ini option, the command line winning over the ini - Resolve once in pytest_configure into a stash key, raising pytest.UsageError on a value that names no scope - Build the finder with the resolved scope during collection
why: Merging exists so a shared namespace survives being distributed, so the suite has to run a document-scope page under xdist rather than assert the property on paper. The option's own resolution order and its error path need pinning at the pytest layer too. what: - Add the group and distinct-group collection cases, and assert a node id runs its namespace alone - Assert a group name does not reach across pages - Cover ini, command line, command-line-beats-ini, and the usage error - Run a document-scope page under -n 2
why: Sharing changes what a reader can copy out of a page and what a failure costs them, so both how-tos have to say when a block starts empty, how to widen it, and what widening gives up. what: - Explain namespaces, groups, and the scope setting in both how-tos, with what sharing costs stated plainly - Document :options: and :skipif:, including that the condition is evaluated at collection - Show a live shared namespace in the examples page - Name the capability on both module landing pages
why: The failure this refuses is the one that reports green, so it needs a case that reruns a block whose expectation only comes true the second time — the exact shape that passed before. what: - Retry a per-block page and assert the repeat is refused, naming why
why: The page told a reader not to combine per-block with --reruns, which is now enforced rather than advised, and did not say that a run whose blocks all pass is untouched. what: - Quote the refusal a repeated block reports - Say a block that passes first time is never repeated
why: The refusal read the setting rather than the session, so per-block in a project's ini took -n away from every run — including a suite holding no page a scheduler could split. And -n on its own is a request for workers, not for a way of filling them: xdist answers it with load, so filling it in differently competes with a default, not with anything the user typed. what: - Record whether --dist was named, before xdist promotes -n to load - Answer pytest_xdist_make_scheduler, where the run left the scheduler open, with scheduling that keeps a page whole and leaves every other item a scope of its own, so a suite of Python tests still spreads - Refuse only a scheduler the run named itself, and name the page it would split - Drop the session-wide refusal
why: The previous cases pinned a refusal that fired on the setting rather than on the run, so they described behaviour the fill-in removes — a suite holding no page keeps -n now, and only a scheduler the run named is refused. what: - Re-point the refusal cases at a scheduler the run names itself - Assert -n alone is filled in rather than refused, and that a suite of Python tests still reaches more than one worker - Assert the refusal names the page it would split
why: The page described a refusal that fired on the setting, which is not what happens now: a run that named no scheduler has one filled in, and only a scheduler the run asked for by name is refused. what: - Say -n alone is answered by keeping each page whole, and that everything else still spreads across workers - Quote the refusal a named scheduler earns, which names the page
why: --tx 2*popen asks for two workers in one specification, and pytest-xdist expands the multiplier in parse_tx_spec_config before sizing any scheduler on the result. Counting the specifications read that run as a one-worker one, so the guard let --dist load through and a page's blocks landed on separate workers as a NameError instead of the UsageError the layout promises. what: - Add _worker_count, expanding an n*spec multiplier as xdist does - Count with it where the scheduler stands in and where it is refused - Say why the upstream helper is reproduced rather than imported
why: The refusal was reached only through -n, which expands to one specification per worker. Nothing exercised the spelling that packs them into one, which is where the count went wrong. what: - Refuse --tx 2*popen and a pair of 1*popen specs under --dist load - Keep --tx 1*popen running: one worker still cannot split a namespace
why: A block declaring no group is named for its page, so a group spelling that name asked for a namespace already given away. The two merged: state crossed the partition the author drew, and the page collected one node id where it wrote two. Block scope generates page[n], so a page that configured nothing could hit it. what: - Add NamespaceNameCollisionError, naming the group and the page - Raise it when a declared group takes a name the page generates - Check the names generated, not their shape, so a page whose blocks all declare a group keeps collecting
why: Both scopes generate a name a group can spell, and the default one generates page[n], so the case reaches a page that configured nothing. Nothing pinned either, nor that a page generating no name at all is left alone. what: - Refuse a group taking the page name at document scope - Refuse a group taking page[0] at the default scope - Keep collecting a page whose every block declares a group, at both
why: The page said naming a group is the author asking for a shared namespace, without saying which names a page has already spoken for. A reader meeting the refusal had nothing to read it against. what: - Name the two generated shapes a group cannot take - Say a page whose every block declares a group generates none
why: The count read a multiplier with str.partition, which agrees with pytest-xdist on the specifications it documents and disagrees on two others. A count at or below zero subtracted from the total where upstream's list repeat contributes nothing, so --tx -1*popen --tx 2*popen read a two-worker run as one and let --dist load through without the refusal. what: - Read the count with find and a slice, as parse_tx_spec_config does - Contribute max(count, 0), matching an empty list repeat - Pin the cases the two spellings disagreed on as doctests
why: Nothing exercised a multiplier at or below zero, which is where the count disagreed with the run it was sizing. what: - Refuse --tx -1*popen --tx 2*popen, a two-worker run either way
why: The refusal covered the name a page generates for a block declaring no group, and not the one lifting generates for a block gated end to end. Both are name[n], so a group spelling it still collected two tests under one node id — the case the refusal exists to stop. what: - Check a lifted name against the namespaces the page declared - Take what the name was generated for, so one error covers both - Build the message on one line, as the errors beside it do
why: A gated block lifted out of a group takes a name a group can spell, and nothing pinned that half of the refusal. what: - Refuse a group named for a block lifted out of another group - Read the two existing cases against what generated the name
why: A page collects as a pytest.Module, which is the node module scope resolves against, so a module-scoped fixture spans every block of a page. Nothing pinned it: the case beside this one runs a single block, which passes whether the fixture spans the page or sets up per item. The docs now promise the lifetime, so a test has to hold it. what: - Carry an object across two blocks of a shared page, per-block layout - Assert identity, which narrowing the scope to the item would break
why: The page said to widen a fixture's scope without saying to what, and described the object as finalized, which reads like a failure a run would show. It is not: the stale object answers, so the page goes green on a wrong value. A reader reaching for the next scope up finds class, which has no node on a page and quietly sets up per block. what: - Say module is the page, and that class and package are not - Say the stale object answers rather than raising - Name the ScopeMismatch on tmp_path, and tmp_path_factory instead - Say request.module is None, which a shared conftest.py can read - Send a reader seeding a resource from Fixtures to the cost section
a7a9119 to
ff0c68c
Compare
|
Correction to my previous comment. Recommendation 1 there — "close the fixture-lifetime gap", citing Sybil's Withdrawn: there is no fixture-lifetime gap
The staleness I reproduced was real but self-inflicted: my @pytest.fixture(scope="module")
def resource():
...The object survives every block of the page. The tell was in my own earlier numbers and I walked past it — So the correct guidance for a downstream project carrying an object across blocks is not "wait for a fix", it is "declare that fixture New measurement: per-block under
|
| Layout | -n auto |
serial |
|---|---|---|
merged |
4 failed, 27 passed | 3 failed, 28 passed |
per-block |
1 failed, 189 passed | 190 passed |
The single -n auto failure under per-block is not sharing-related, and the control proves it: running the same page at the default block scope with no sharing whatsoever produces the identical failure, same block index, same line. It is a pre-existing timing flake in that page — a time.sleep(0.3) followed by a poll with a 2s budget, where a cold shell in a freshly created pane can exceed the budget under load. A branch of libtmux that replaces those polls with a wait-for handshake passes the same page 5/5 under -n auto.
Net: sharing and full parallelism now coexist. That is the result I could not get before 7a78afa, and it is worth calling out in the PR description, because "shared namespace" and "runs under -n auto" reading as mutually exclusive is the assumption most people will arrive with.
Full libtmux suite on ff0c68c with the branch pinned through [tool.uv.sources]: 1552 passed, 1 skipped.
What is left, re-ranked
With the fixture question closed, the list shortens and reorders.
1. Collect fences that carry no >>>. Unchanged from my earlier comment and now the only item with a large downstream surface. The collection predicate requires a >>>, which excludes every page whose visible code a reader is meant to copy verbatim — a prompt breaks paste, and inline expected output puts an assertion in the reader's paste path. libtmux has 16 such pages and hand-built a page collector for them solely because this branch cannot see them. myst_fence_as_directive reaches this without changing how the pages read: register a directive whose option_spec this plugin owns, and a plain fence can carry group and scope options while still rendering as a plain fence. Worth noting the ceiling honestly — a project would still keep whatever it uses for per-page environment isolation and for assertions it does not want in the reader's copy path, so this shrinks such a harness rather than retiring it.
2. Make a shared failure cheap to debug. Sharing converts an independent failure into a dependent one, and the report should say which. Under merged, blocks after the first failure never run and are invisible in the output; under per-block they do run, and fail against a half-built namespace, which reads as several unrelated bugs. Naming the relationship — which block failed first, and which later failures are downstream of it — turns a confusing cascade into one line. A continue-on-failure mirroring pytest's own flag would also let a page surface all its genuine failures in one run instead of one per iteration.
3. Say plainly that an id is not independence. No surveyed project makes a shared block runnable alone, and none can: selecting block 2 by id will always fail once block 1 is what defines its names. per-block preserves the id for -k, --lf, --deselect and JUnit reporting, which is genuinely valuable, but the id looks like a promise of independent runnability and is not one. The per-block costs section already covers the mechanics; stating this one as a flat sentence would prevent a category of confused reports.
4. Recommend per-block wherever sharing is on. Keeping merged as the default is well justified by the measurement in the PR description. But every failure mode libtmux hit under document + merged — a monotonic id counter drifting once one server serves a whole page, two blocks colliding on a same-named object, an object killed by an earlier block — is a consequence of the collapse, and all of them vanish under per-block. merged is the safe default for someone who has not opted in; per-block is the better setting for someone who has.
Sources reviewed
Everything below was read at the ref listed rather than recalled. gp-libs is cited by symbol and commit subject rather than line anchor, since this branch rebased between my two comments and line anchors on a moving head rot.
| Project | Ref | What was read |
|---|---|---|
| gp-libs | ff0c68c |
DocTestDocutilsFile, the per-block finder arm, the items and scope options, tests/regressions/test_autouse_fixtures.py, the fixtures and per-block-costs docs, and the eight commits added since 55115db |
| Sybil | 10.0.1 |
Document namespace ownership, SybilFile.collect, SybilItem.getparent / setup / request_fixtures, the doctest evaluator, parsers, clear-namespace, invisible-code-block |
| Sphinx | v9.1.0 |
sphinx/ext/doctest.py — TestGroup, test_group, group directive semantics, testsetup / testcleanup |
| CPython | 3479e45 |
Lib/doctest.py — DocTest.__init__ glob copying, DocTestRunner.run clear_globs, DocTestParser, subtest reporting |
| pytest | f306da7 |
_pytest/doctest.py — DoctestTextfile.collect, DoctestItem, doctest_namespace scope, --doctest-continue-on-failure |
| xdoctest | v1.3.2 |
parsing granularity, --xdoctest-style freeform vs google, per-part diagnostics |
| pytest-asyncio | v1.4.0 |
loop_scope / asyncio_mode, scoped-fixture construction, scope-mismatch surfacing |
| MyST-Parser | v5.1.0 |
fence representation in the doctree, attrs_block, myst_fence_as_directive |
| docutils | trunk | Directive.option_spec plumbing, reporting thresholds |
Downstream verification throughout was libtmux with this branch pinned via [tool.uv.sources], measured at every combination of the two settings, serial and under -n auto, with a no-sharing control run for every failure before attributing it.
Relevant anchors for the corrected claim: src/sybil/integration/pytest.py#L66-L70 and src/sybil/integration/pytest.py#L72-L75 for the shim this branch does not need, src/sybil/document.py#L39 and src/sybil/document.py#L93-L103 for one dict owned above the items, sphinx/ext/doctest.py#L609-L613 for the three-line copy defeat, and Lib/doctest.py#L559 for the obstacle both are working around.
|
What libtmux would need from this plugin to test its documentation the way it wants it tested. Three asks, ranked, plus what libtmux would change on its own side. One of them turns out to be much cheaper than I thought when I raised it, because Sphinx already defines the semantics. Context for the ranking: libtmux has three documentation surfaces, and this branch already serves two of them completely.
Ask 1, blocking: collect a block that carries no
|
| Form | Collected |
|---|---|
```python |
0 |
```{doctest} |
0 |
```{testcode} |
0 |
```{code-block} python |
0 |
Control, the same file with >>> added and nothing else changed: 1 collected. So the prompt is the sole determinant, and no directive form escapes it.
The reason libtmux's how-to pages carry no prompt is not stylistic. A reader is meant to select the block and paste it into a file. A >>> prefix breaks that, and an expected-output line puts an assertion into the text the reader just pasted. The pages are the product; the checking has to happen somewhere the reader never sees.
Ask 2, strongly wanted: an assertion channel outside the visible block
This is the one that got cheaper. I originally described it as Sybil's invisible-code-block shape, which is a real answer — see the (invisible-)?code(-block)? pattern its MyST parser accepts at src/sybil/parsers/myst/codeblock.py — but it means inventing syntax.
Sphinx already has this, and has had it for years. TestcodeDirective accepts a hide flag at sphinx/ext/doctest.py#L173-L177, and the directive turns a hidden block into a comment node rather than a literal block at sphinx/ext/doctest.py#L92-L93:
nodetype: type[TextElement] = nodes.literal_block
if self.name in {'testsetup', 'testcleanup'} or 'hide' in self.options:
nodetype = nodes.commentSo {testcode} runs and does not check output, and {testcode} :hide: runs and does not render. That is asks 1 and 2 together, in one directive, with semantics Sphinx defined and downstream Sphinx tooling already renders correctly.
Which makes the concrete proposal narrower than "accept fences without prompts": honour {testcode}, and honour its :hide: flag. A page then reads as plain prose with a visible {testcode} a reader can copy, and a hidden {testcode} :hide: doing the asserting. No new syntax, no change to how the pages read, and the fallback for projects that would rather not use a directive at all is myst_fence_as_directive, a MyST setting that maps a bare language fence onto a directive name — see the field at myst_parser/config/main.py#L265-L273.
Ask 3, probably out of scope: re-run a block against a fresh namespace
libtmux's how-to harness can execute a block a second time in a different world, which is how a page showing if server.is_alive(): gets both branches checked from one visible block. I am not asking for this. It is unusual, no surveyed project offers it, and libtmux can keep it locally. Recording it only so the gap is known rather than discovered later.
What libtmux would change on its own side
None of this is a request for the plugin to accommodate libtmux's fixtures — that part already works, and the parts I previously thought were missing are not.
Add a docs/howto/conftest.py declaring server and session at scope="module". A page is a pytest.Module here, so module scope is page scope, and a how-to page's blocks then share one tmux server, which is what those pages need. Leave the root conftest.py function-scoped so docs/topics/*.md keeps a fresh server per block, which is what those pages need. I verified the two coexist in one run: two directories, two different scopes for a same-named fixture, four blocks, all passing.
Keep the per-page TMUX_TMPDIR and HOME isolation in libtmux's own harness. That is a tmux concern, not a doctest concern, and I would not want it in a doctest plugin.
For CI: -n auto with no --dist is correct and needs no flag, since the scheduler is filled in. Verified end to end — --dist load and --dist worksteal with per-block both exit 4, --dist loadfile exits 0, and the error message names all three remedies.
Net
Of the six things libtmux's how-to pages need, three are already available on this branch: a shared namespace per page, a fixture lifetime that matches it, and the ability to differ from the per-block pages next door. One is an absolute blocker, one is strongly wanted, and one is mine to keep.
The two that matter collapse into a single change if {testcode} and :hide: are the chosen spelling — which would also mean libtmux's how-to pages become ordinary Sphinx documents rather than a format only its own collector understands.
And nothing at all is needed for the 206 docstring tests or the 190 topics blocks. Those are served today, at the default settings, with no configuration.
why: A how-to page is instructions, not a transcript. A reader selects the block and pastes it, so a >>> prompt breaks the paste and an expected-output line drops an assertion into what they pasted. Pages written that way carried no prompt and the finder could not see them at all: every prompt-free form collected nothing. what: - Register Sphinx's testcode and testoutput, whose :hide: the existing directive already honours, and pair each output with the block it belongs to within its own group - Run a prompt-free body as a module body, so it takes as many statements as it likes and a bare expression stays silent, by swapping compile in a private copy of the runner loop rather than in the doctest module every session shares - Read a testsetup or testcleanup the same way when it carries no prompt, so the page Sphinx documents runs here too - Leave >>> examples on single-mode semantics, echo and all
why: The prompt-free form is a second way to reach the runner, and every existing test reached it through >>>. what: - Collect and run testcode, testoutput and :hide: at the default scope - Pin that a bare expression prints nothing and several statements run - Pin a testoutput pairing inside its group across interleaved groups - Pin both spellings of a setup body, prompt and prompt-free - Pin that the runner degrades where its seam moved
why: The form exists to be copied, so the page that describes it should be copyable, and a reader needs to know which blocks share a namespace before they write one. what: - Say what a pasteable block is, and fence one - Say testcode and >>> blocks do not see each other, at any scope - Show :hide: with a hidden block this page actually runs - Name the option that parses and does nothing, and what to use
why: A testcode block landed in a namespace called default, a name no author wrote and the one string _node_groups already reads as "no group" everywhere else. So a page collected page.md::default beside page.md[0], and a run that asked for document scope still could not have a testcode read what a prompt block above it bound — the sharing it asked for, refused on a page that spelled both forms. Pairing two answers to one testcode diverged from Sphinx in the other direction: sphinx.ext.doctest replaces the earlier output, this kept it, so a page built one way and ran another. what: - Name a page-scoped block the way an ungrouped block is named at document scope, so both forms meet there and the id reads like every other one - Split "shares its page" out of _node_groups into _page_scoped, which leaves group reading to the author's own names - Let a second testoutput replace the first, as Sphinx does, and warn, which Sphinx does not
why: The naming change decides what a page collects, and the pairing change decides which answer runs. Both were pinned to the behaviour that is going away. what: - Read a testcode page's names as the page, at every scope and layout - Join both forms at document scope and keep them apart at block scope - Pin that the last testoutput wins and that the page is warned
why: The page said the two forms never see each other, which stopped being true at document scope, and named a group the finder no longer uses. what: - Say a prompt-free block is named for its page, and why - Say document scope is where a >>> block joins it
why: Review found the record described PR #87's unmerged design as the status quo, and several claims did not survive checking against source. Every corrected claim below was re-verified by reading a pinned tag or by executing it. what: - Rewrite Context: trunk collects one DocTest per page; the groups, merge, skip lifting, exec runner and xdist scheduler are PR #87's proposal, named as such - Drop "per-block SKIPPED" from what the shape buys free, and add an outcome contract: TestReport.outcome is one scalar per item, so a mixed group either erases the skip or over-reports the whole group. Record subtests as the only sanctioned alternative and why it is not adopted - Credit sphinx.ext.doctest with the execution shape; the pytest identity is what is novel, not N DocTests per namespace - Replace the bare DocTest tuple with PlannedBlock and GroupPlan, so run_group can order phases, evaluate the gate and guarantee cleanup - Replace the compile-mode literal with a private ExecutionProfile, since PR #59's top-level await is a second policy a mode string cannot express - Move docutils node vocabulary out of the stdlib-only leaf, and move settings below the layers that read it - Add the item lifecycle contract, since half-reusing DoctestItem reintroduces the clear_globs wipe - Correct Sphinx: :options: on testcode is an unknown-option error, not a silent discard; :pyversion: is the silent one; cleanup does not run after setup failure, so always-cleanup is a divergence - Correct the parsefactories claim: conftest autouse fixtures arrive via FixtureManager.pytest_plugin_registered - Correct the nominal-subclassing claim: stdlib accepts a duck-typed parser or finder; typeshed is what demands the class - Narrow the xdist sentence: identical collection still binds - Repin Sphinx anchors to v8.2.3, the version this project resolves
why: The record proposed deprecating doctest_docutils_namespace_items, but neither that setting nor its scope twin has shipped — both live on PR #87, in no release and on no tag. Deprecating an unshipped setting fails the Published-Release Test, and there is no downstream to warn. what: - Retitle and reslug: the question is whether the shape should ship, not how to retire it - State plainly that nothing shipped, so there is no migration path, no warning and no downstream grep - Give the shape its due first: merging costs node ids, fixture lifetime and gutter locality, which is what per-block answers - Then give the four reasons against, each with its guard: an id that NameErrors when selected, a mapping that cannot cross a worker, a mapping that cannot survive a rerun, and a crash tail that has no guard at all - Note that the guards are the cost of the shape, not incidental - Record the honest limit of the alternative: no per-block outcome and no per-block node id
why: Several notes still described PR #87 as shipped, carried the superseded GroupTest model, or repeated claims later verification falsified. The true baseline is now known from trunk's source: released gp-libs appends one DocTest per matched node named page.md[k] and gives each its own copied globs. what: - Split the taxonomy rows: released gp-libs is one block, one item, isolated globals; PR #87 is a separate proposed row - Move Sphinx into the N-DocTests row and drop "unoccupied", since Sphinx already executes that shape without addressable ids - Rename the identity rule to never-source-coordinate-derived, and say an ordinal among extracted blocks satisfies it — which is what the released finder already uses - Replace GroupTest with GroupPlan in the data-flow diagram, and attribute the clone and the synthetic page to PR #87 - Separate Sphinx's three units in 20: runner call per block, shared state per group, result as process-wide counters - Drop BlockKind.node_types from the seam list and replace the rejected compile-policy callable with the private ExecutionProfile, matching ADR 0001 - Record that :pyversion: is declared on both testcode and testoutput and honoured on neither - Correct the reporter section: an observer is additive and does not silence the stream, and a system_message carries no stable code - Record why the state_classes substitution is not parse-scoped - Fix the Sphinx heading and link that disagreed on version
why: ADR 0001 now runs ordinary prompt blocks on CPython's untouched loop, which shrinks what 0002 must prove and makes 0005 orthogonal to the architecture. Review also found the notes still generalized Sphinx's per-block execution and conflated xdist's two channels. what: - 0002: scope the harness to the extended lane; ordinary blocks are the reference rather than something to differentially prove - 0005: state plainly that the floor is support policy, not core architecture, and that no part of 0001 depends on the answer - 0005: correct the Sphinx version — the group fallback for a bare, unstamped doctest_block changed in 9.0, not 9.1, and directives always stamp groups so unargumented directives are unaffected - Notes: Sphinx is per-block for its TEST phase only; setup blocks are combined into one simulated DocTest and cleanup into another - Notes: separate xdist's scheduling channel from its reporting channel — the controller sees node-id strings when scheduling and serialized reports afterwards, and reports carry arbitrary extra attributes - Notes: a nullable line is not unique to this design; Sphinx's get_line_number returns None too. What is new is per-block propagation - Notes: narrow the affinity claim to the shipped schedulers, since pytest_xdist_make_scheduler substitutes a whole Scheduling - Notes: PR #87's per-block mode is proposed, not shipped - Notes: carry the block/runtime split into the data-flow diagram
Summary
.. doctest:: intro,```{doctest} intro) share one namespace, so a narrative page can build state across the prose that explains it (Doctest blocks in one document do not share a namespace, unlike pytest text files #83). Blocks that name no group keep their own namespace unless a project opts into a page-wide one.# doctest:flag inside a directive,:options:,:skipif:, and:pyversion:— which raisedInvalidVersionand aborted collection of the page.--deselectline resolves on another checkout and JUnit XML stops carrying a home directory (pytest node IDs embed an absolute path #85).DocTest.__lt__compares names, and names carry the block index as text, so any page past nine blocks ranpage.md[10]beforepage.md[1]... note::, a list item, or a block quote — docutils leaves such a node's line unset, and reading it as a number took the whole file down.The default for a page that names no group is unchanged, and every existing example in libtmux, libvcs, tmuxp and cihai collects under the same node IDs and the same counts.
Changes by area
Namespaces
src/doctest_docutils.py: group a page's blocks by namespace and merge each into onedoctest.DocTest, spliced by file position so reported lines and the%03dgutter stay where each block alone would put them.src/pytest_doctest_docutils.py:--doctest-docutils-namespace-scopeand the matchingdoctest_docutils_namespace_scopeini option,block(default) ordocument, resolved once so a misspelling stops the session rather than erroring per file.DocutilsDocTestFinder(namespace_scope=…),testdocutils(…), andpython -m doctest_docutils --namespace-scope.*stands for every group the page declares — how a page writes one.. testsetup::for all of them... testsetup::and.. testcleanup::run before and after the rest of their group whatever order the page writes them in. Both render as comments, so authors move them out of a reader's way.Directive options
node["test"]is read, so an inline# doctest:flag survives a directive that trims it out of the rendered code — and<BLANKLINE>inside a directive now compares against the marker rather than a real blank line.node["options"]seeds each example's flags; the example's own inline flag still wins. A true:skipif:is the exception: it is a gate, and an inline-SKIPcannot reopen it.node["skipif"]is evaluated, and a true condition marks the blockSKIPrather than dropping it, so it reports like:options: +SKIP.:pyversion:passes its arguments in the order the local signature declares, and a malformed specifier stays a reporter warning.Reporting
DocTest.filenameso failures still resolve... testcleanup::written above its group still runs last while every example still reports the line it actually sits on. Anchoring the merged text on the run order instead reported examples against whichever block came first in it, and could point past the end of the file.%swithdoctest_source_fileanddoctest_block_type, replacing apprintdump of the test list, source string, globals and seen-map on every document.Dependencies
pyproject.toml:pytest-xdistin thedevandtestinggroups, so the test that pins distributed behaviour runs rather than skipping.Design decisions
Merge a namespace into one test, rather than sharing a live globals dict. Sphinx reassigns
test.globsand passesclear_globs=False; that shape breaks under process-parallel distribution, which libtmux's CI already runs over itsdocs/. A state-building page passes serially and fails withNameErrorunderpytest -n 2when items share a dict. One test per namespace has nothing to share across items.Keep the per-block default. Merging also merges fixture lifetime: one item means one function-scoped fixture setup for the whole namespace. Switching the default to page-wide took libtmux from green to four failures and libvcs to two, all of them pages whose blocks each expected a fresh
server,sessionorexample_git_repo. Sharing is therefore something a page or a project asks for.:options:are defaults;:skipif:is a gate. An example's own# doctest: -SKIPoverrides the directive's:options:, which is what Sphinx does. It cannot override a true:skipif:, because Sphinx drops a gated block before its source is ever read — and an example that could reopen one would run on exactly the interpreter or platform the condition named.A block skipped end to end leaves its namespace. It binds nothing the other blocks could read, so nothing is lost by collecting it separately — and it regains the
SKIPPEDline, the count, the JUnit entry and the node ID that merging had cost it. A block only partly skipped still has work to do and stays put.Breaking changes
Node IDs no longer carry an absolute path.
page.md::/home/you/docs/page.md[0]becomespage.md::page.md[0]. Any checked-in--deselector-kwritten against the old form needs updating — though the old form only ever matched on the machine that produced it.Two blocks naming one group collect as one item. They previously collected as two items sharing a single node ID,
page.rst::shared, which no selector could tell apart.A gated block now appears in the run.
:skipif: Trueused to remove the block during collection; it now collects and reportsSKIPPED, so a page carrying one gains an item.CHANGESis deliberately untouched — this project writes changelog entries at release time, not from a feature branch.It also ships #89's opt-in answer to that trade:
doctest_docutils_namespace_items = per-blockkeeps a node id for every block of a shared page, handing the blocks one globals mapping rather than merging them into a single test. It is off by default, and a run that never sets it is byte-identical to before the setting existed.A live mapping is a Python object, so it neither crosses a worker process nor survives an item being run twice. Where the run named no scheduler, one is filled in that keeps each page whole while everything else still spreads; a scheduler the run asked for by name that would split a page is refused, naming the page. A block that runs a second time — under
--reruns, or anything else that repeats an item — is refused rather than trusted, because it would otherwise run against the globals it already changed and report an expectation that only came true on the retry as a pass.Verification
The lexicographic sort is gone — expect no matches:
$ rg -c 'tests\.sort\(\)' src/No line number is read as a string — expect no matches:
$ rg -c 'int\(source_lines' src/No f-strings in log calls — expect no matches:
$ rg -c 'logger\.(debug|info|warning|error)\(f"' src/The untrimmed source is what gets parsed — expect one match:
$ rg -n 'node\.get\("test"\)' src/Test plan
uv run pytest --reruns 0— full suite greenuv run ruff check .anduv run ruff format .— cleanuv run mypy src tests— clean (this project configures mypy;tyis not a declared dependency)just build-docs— builds with no new warning and no broken cross-referencetest_document_scope_survives_xdist— a page-wide namespace passes underpytest -n 2test_a_failing_block_still_fails_beside_a_skipped_one— a gate never turns a broken page green, under pytest and underpython -m doctest_docutilstest_lifting_a_gated_block_moves_no_reported_line— a skipped block's removal does not shift the lines its neighbours reporttest_merging_moves_no_reported_lineandtest_merged_examples_keep_their_gutter— failure locations match the unmerged pagetest_a_group_survives_an_include— a group split across.. include::collects and runsdocs/andREADME.mdwith this branch onPYTHONPATH— zero failures, and--collect-onlynode IDs byte-identical to the base