Skip to content

Honour Sphinx's testcode and testoutput, including :hide: #91

Description

@tony

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.

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