Skip to content

Share doctest namespaces without collapsing per-block node ids #88

Description

@tony

Summary

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 one DocTest 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.

Motivation

A page collected today yields one item per block:

$ pytest docs/topics/automation_patterns.md --collect-only -q
16 tests collected

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:

and DocTestRunner.run empties it afterwards by default:

Between the copy and the clear, N DocTest objects cannot share state by default. Collapsing them into one is the shortest way around both.

gp-libs currently emits one per node:

Proposal

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:

The stdlib sanctions it: DebugRunner passes clear_globs=False itself, at cpython/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_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-xdistthis 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 loadfile 2 passed; -n 2 --dist loadgroup without 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 2 load, --dist loadfile loadfile, --dist loadgroup loadgroup, -n auto load). 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).

  1. 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.
  2. With sharing disabled (the default), a name bound in block 1 is not visible in block 2 — today's behaviour, unchanged. Met.
  3. 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.
  4. 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.
  5. doctest_namespace fixtures are visible in every block under both settings. Met.
  6. Sphinx-style groups keep working: blocks in one group share, ungrouped blocks stay isolated, mixed documents behave. Met.
  7. 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.
  8. 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.

Relationship to the open issues

Not related: #43 (pytest-asyncio) — that package does not touch doctest collection.

Alternatives

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.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions