Filed against git-pull/gp-libs v0.0.19, CPython 3.14, pytest 8.4.2.
What happens
Every doctest-bearing node in a document becomes its own doctest.DocTest, and CPython gives each DocTest a private copy of the globals. A name bound in one fenced block is therefore invisible in the next, so a narrative page cannot build state across the prose that explains it.
This diverges from pytest's own text-file doctest collection, which parses the whole file as a single DocTest and lets state flow.
Root cause
DocutilsDocTestFinder._find() walks the parsed document and emits one test per matching node:
src/doctest_docutils.py#L385-L402
for idx, node in enumerate(findall(doc)(condition)):
...
test = self._get_test(
string=node.astext(),
name=test_name,
filename=name,
globs=globs,
source_lines=[str(node.line)],
)
The same globs mapping is passed to each call, but it never stays shared, because get_doctest hands it to DocTest:
src/doctest_docutils.py#L416
and CPython copies it unconditionally in DocTest.__init__:
cpython/Lib/doctest.py#L562
self.globs = globs.copy()
So the number of independent namespaces equals the number of nodes matched by condition().
How pytest differs
DoctestTextfile.collect() calls get_doctest exactly once, on the entire file text:
pytest/src/_pytest/doctest.py#L443-L446
parser = doctest.DocTestParser()
test = parser.get_doctest(text, globs, name, filename, 0)
if test.examples:
yield DoctestItem.from_parent(
One DocTest, one namespace, state flows across the document. A reader who knows --doctest-glob semantics will expect the same from a .md or .rst page and get something different.
Reproduction
1. Two fences, one document
doc.md:
# Title
```python
>>> greeting = "hello"
>>> greeting
'hello'
```
Narrative prose between the two blocks.
```python
>>> greeting.upper()
'HELLO'
```
$ pytest --doctest-docutils-modules doc.md -q --no-header
.F [100%]
012 >>> greeting.upper()
UNEXPECTED EXCEPTION: NameError("name 'greeting' is not defined")
1 failed, 1 passed in 0.24s
2. The same two blocks under pytest's own collector
doc.txt:
Title
=====
>>> greeting = "hello"
>>> greeting
'hello'
Narrative prose between the two blocks.
>>> greeting.upper()
'HELLO'
$ pytest --doctest-glob='*.txt' doc.txt -q --no-header
. [100%]
1 passed in 0.01s
Same two examples, same intervening prose, opposite outcome.
3. A shared Sphinx group does not help
groups is read from the node, but only to name the test:
src/doctest_docutils.py#L388-L392
test_name = node.get("groups")
if isinstance(test_name, list):
test_name = test_name[0]
if test_name is None or test_name == "default":
test_name = f"{name}[{idx}]"
grp.rst:
Title
=====
.. doctest:: shared
>>> greeting = "hello"
>>> greeting
'hello'
Narrative prose.
.. doctest:: shared
>>> greeting.upper()
'HELLO'
$ pytest --doctest-docutils-modules grp.rst -q --no-header
FAILED grp.rst::shared
1 failed, 1 passed in 0.03s
Both blocks declare the same group, both still get their own namespace. In sphinx.ext.doctest a group is precisely the unit that shares state, so reading the attribute and then not honouring it is the surprising part.
Why it matters
A page that explains as it goes has to choose between three bad options:
- Repeat the setup in every fence, which is noise the reader did not ask for.
- Put the whole example in one fence, which forces the explanatory sentences inside the code block, where they render as code.
- Split the narrative into separate pages.
Option 2 is the one people reach for, and it is silently wrong: a bare prose line inside a fence is accepted by the doctest parser as narrative between examples, so the suite stays green while the rendered page shows English sentences in a syntax-highlighted code block.
What would fix it
Any one of these, roughly in order of how closely it matches existing expectations:
- Share one namespace per document. Closest to
pytest's text-file behaviour and to what --doctest-glob users expect. Would change existing behaviour for anyone relying on isolation.
- Honour
groups. Nodes declaring the same group share a namespace; ungrouped nodes stay isolated. Matches sphinx.ext.doctest, is opt-in, and the attribute is already being read.
- Make it configurable, e.g. an ini option choosing per-node or per-document, defaulting to today's behaviour.
The second seems the best fit: it is backward compatible, it gives page authors an explicit way to say "these blocks belong together", and it makes an attribute that is currently decorative actually mean something.
Notes
Whichever route, per-node granularity is worth keeping for reporting: naming tests page.md[0], page.md[1] makes failures easy to locate, and that is independent of which namespace they execute in.
Filed against git-pull/gp-libs v0.0.19, CPython 3.14, pytest 8.4.2.
What happens
Every doctest-bearing node in a document becomes its own
doctest.DocTest, and CPython gives eachDocTesta private copy of the globals. A name bound in one fenced block is therefore invisible in the next, so a narrative page cannot build state across the prose that explains it.This diverges from
pytest's own text-file doctest collection, which parses the whole file as a singleDocTestand lets state flow.Root cause
DocutilsDocTestFinder._find()walks the parsed document and emits one test per matching node:src/doctest_docutils.py#L385-L402The same
globsmapping is passed to each call, but it never stays shared, becauseget_doctesthands it toDocTest:src/doctest_docutils.py#L416and CPython copies it unconditionally in
DocTest.__init__:cpython/Lib/doctest.py#L562So the number of independent namespaces equals the number of nodes matched by
condition().How pytest differs
DoctestTextfile.collect()callsget_doctestexactly once, on the entire file text:pytest/src/_pytest/doctest.py#L443-L446One
DocTest, one namespace, state flows across the document. A reader who knows--doctest-globsemantics will expect the same from a.mdor.rstpage and get something different.Reproduction
1. Two fences, one document
doc.md:$ pytest --doctest-docutils-modules doc.md -q --no-header2. The same two blocks under pytest's own collector
doc.txt:$ pytest --doctest-glob='*.txt' doc.txt -q --no-headerSame two examples, same intervening prose, opposite outcome.
3. A shared Sphinx group does not help
groupsis read from the node, but only to name the test:src/doctest_docutils.py#L388-L392grp.rst:$ pytest --doctest-docutils-modules grp.rst -q --no-headerBoth blocks declare the same group, both still get their own namespace. In
sphinx.ext.doctesta group is precisely the unit that shares state, so reading the attribute and then not honouring it is the surprising part.Why it matters
A page that explains as it goes has to choose between three bad options:
Option 2 is the one people reach for, and it is silently wrong: a bare prose line inside a fence is accepted by the doctest parser as narrative between examples, so the suite stays green while the rendered page shows English sentences in a syntax-highlighted code block.
What would fix it
Any one of these, roughly in order of how closely it matches existing expectations:
pytest's text-file behaviour and to what--doctest-globusers expect. Would change existing behaviour for anyone relying on isolation.groups. Nodes declaring the same group share a namespace; ungrouped nodes stay isolated. Matchessphinx.ext.doctest, is opt-in, and the attribute is already being read.The second seems the best fit: it is backward compatible, it gives page authors an explicit way to say "these blocks belong together", and it makes an attribute that is currently decorative actually mean something.
Notes
Whichever route, per-node granularity is worth keeping for reporting: naming tests
page.md[0],page.md[1]makes failures easy to locate, and that is independent of which namespace they execute in.