The finder collects a block only when it can find a >>> in it. Measured on issue-83-doctest-namespace, four ways of writing a runnable Python block with no prompt:
| Form |
Collected |
```python |
0 |
```{doctest} |
0 |
```{testcode} |
0 |
```{code-block} python |
0 |
Control, the same file with >>> added and nothing else changed: 1. So the prompt is the sole determinant, and no directive form escapes it.
The reason this matters is not stylistic. A page whose code a reader is meant to select and paste cannot carry >>>, because the prompt breaks paste, and it cannot carry inline expected output, because that puts an assertion into the text the reader just pasted. The page is the product; the checking has to happen somewhere the reader never sees. libtmux has 16 such pages and hand-built its own collector for them.
The proposal: no new syntax
Sphinx already defines these semantics and has 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.comment
So {testcode} runs and does not check output, and {testcode} with :hide: runs and does not render. A page then reads as prose with a visible {testcode} a reader can copy, and a hidden {testcode} :hide: doing the asserting — and it stays an ordinary Sphinx document rather than a format only one project's collector understands.
{testoutput} supplies expected output for the {testcode} above it, and takes :options: and :hide: of its own.
What already exists here
TestDirective.run in src/doctest_docutils.py is a near-verbatim port of Sphinx's and already implements the hide rule — the if self.name in {"testsetup", "testcleanup"} or "hide" in self.options branch is already there. setup() registers testsetup, testcleanup, doctest and tab, but not testcode or testoutput. The finder predicate already accepts any literal_block or comment carrying testnodetype, so a registered testcode node would be collected.
The actual work
Collection is the cheap half. Evaluation is the work, and one constraint decides the design.
CPython's DocTestRunner.__run executes exec(compile(example.source, filename, "single", compileflags, True), test.globs) — the module-level compile, mode hardcoded to single. Two measured consequences for a synthesized doctest.Example: a multi-statement body fails with "multiple statements found while compiling a single statement", and a bare expression auto-echoes, so server.new_session() reports unexpected output. Both are wrong for testcode, which Sphinx runs in exec mode.
Sphinx's own answer is to monkeypatch the module global — doctest.compile = self.compile, with the comment "we monkey-patch the compile it uses" — and flip self.type between single and exec. That is process-global mutable state. Sphinx is a build tool and can afford it; this is a pytest11 entry-point plugin loaded into everyone's session and cannot.
Solving that without leaving global state mutated is the heart of the change.
Fallback for projects that want no directive
myst_fence_as_directive maps a bare language fence onto a directive name — see the field at myst_parser/config/main.py#L265-L273. A project that would rather write ```python than ```{testcode} can route it through the directive that way. This repo sets it nowhere today.
Known gap, explicitly not requested
Re-running one block against a fresh namespace, so a page showing if server.is_alive(): can have both branches checked from one visible block. No surveyed project offers it and libtmux keeps this locally. Recorded so the gap is known rather than discovered later.
Refs #83, #90.
The finder collects a block only when it can find a
>>>in it. Measured onissue-83-doctest-namespace, four ways of writing a runnable Python block with no prompt:```python```{doctest}```{testcode}```{code-block} pythonControl, the same file with
>>>added and nothing else changed: 1. So the prompt is the sole determinant, and no directive form escapes it.The reason this matters is not stylistic. A page whose code a reader is meant to select and paste cannot carry
>>>, because the prompt breaks paste, and it cannot carry inline expected output, because that puts an assertion into the text the reader just pasted. The page is the product; the checking has to happen somewhere the reader never sees. libtmux has 16 such pages and hand-built its own collector for them.The proposal: no new syntax
Sphinx already defines these semantics and has for years.
TestcodeDirectiveaccepts ahideflag atsphinx/ext/doctest.py#L173-L177, and the directive turns a hidden block into a comment node rather than a literal block atsphinx/ext/doctest.py#L92-L93:So
{testcode}runs and does not check output, and{testcode}with:hide:runs and does not render. A page then reads as prose with a visible{testcode}a reader can copy, and a hidden{testcode} :hide:doing the asserting — and it stays an ordinary Sphinx document rather than a format only one project's collector understands.{testoutput}supplies expected output for the{testcode}above it, and takes:options:and:hide:of its own.What already exists here
TestDirective.runinsrc/doctest_docutils.pyis a near-verbatim port of Sphinx's and already implements the hide rule — theif self.name in {"testsetup", "testcleanup"} or "hide" in self.optionsbranch is already there.setup()registerstestsetup,testcleanup,doctestandtab, but nottestcodeortestoutput. The finder predicate already accepts anyliteral_blockorcommentcarryingtestnodetype, so a registered testcode node would be collected.The actual work
Collection is the cheap half. Evaluation is the work, and one constraint decides the design.
CPython's
DocTestRunner.__runexecutesexec(compile(example.source, filename, "single", compileflags, True), test.globs)— the module-levelcompile, mode hardcoded tosingle. Two measured consequences for a synthesizeddoctest.Example: a multi-statement body fails with "multiple statements found while compiling a single statement", and a bare expression auto-echoes, soserver.new_session()reports unexpected output. Both are wrong for testcode, which Sphinx runs inexecmode.Sphinx's own answer is to monkeypatch the module global —
doctest.compile = self.compile, with the comment "we monkey-patch the compile it uses" — and flipself.typebetweensingleandexec. That is process-global mutable state. Sphinx is a build tool and can afford it; this is apytest11entry-point plugin loaded into everyone's session and cannot.Solving that without leaving global state mutated is the heart of the change.
Fallback for projects that want no directive
myst_fence_as_directivemaps a bare language fence onto a directive name — see the field atmyst_parser/config/main.py#L265-L273. A project that would rather write```pythonthan```{testcode}can route it through the directive that way. This repo sets it nowhere today.Known gap, explicitly not requested
Re-running one block against a fresh namespace, so a page showing
if server.is_alive():can have both branches checked from one visible block. No surveyed project offers it and libtmux keeps this locally. Recorded so the gap is known rather than discovered later.Refs #83, #90.