Skip to content

Commit fd7e17e

Browse files
authored
Backport upstream doctest fixes and add pytest 7/8/9 compatibility (#56)
why: Fix critical bugs in autouse fixture discovery and doctest directive parsing. Add pytest version compatibility with new API support. what: - Backport pytest autouse fixtures fix (9cd14b4ff, 2024-02-06) - Backport Sphinx doctestopt_re whitespace fix (ad0c343d3, 2025-01-04) - Add _unblock_doctest() helper for pytest 8.1+ unblock() API - Add pytest 9.x to CI test matrix with 7.x/8.x compatibility - Remove dead _from_module() method from DocutilsDocTestFinder - Add upstream audit report for CPython, pytest, and Sphinx
2 parents 9382afd + fc18c45 commit fd7e17e

File tree

10 files changed

+1580
-31
lines changed

10 files changed

+1580
-31
lines changed

.github/workflows/tests.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ jobs:
1111
matrix:
1212
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
1313
docutils-version: ['0.18', '0.19']
14+
pytest-version: ['7', '8', '9']
15+
exclude:
16+
# Exclude pytest 7 from Python 3.14 to reduce matrix size
17+
- python-version: '3.14'
18+
pytest-version: '7'
1419
steps:
1520
- uses: actions/checkout@v5
1621

@@ -25,10 +30,14 @@ jobs:
2530
- name: Install dependencies
2631
run: uv sync --all-extras --dev
2732

28-
- name: Print python versions
33+
- name: Install specific pytest version
34+
run: uv pip install "pytest~=${{ matrix.pytest-version }}.0"
35+
36+
- name: Print python and pytest versions
2937
run: |
3038
python -V
3139
uv run python -V
40+
uv run pytest --version
3241
3342
- name: Lint with ruff check
3443
run: uv run ruff check .

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ pip-wheel-metadata/
8383
monkeytype.sqlite3
8484

8585
# Claude code
86-
**/CLAUDE.md
8786
**/CLAUDE.local.md
8887
**/CLAUDE.*.md
8988
**/.claude/settings.local.json

CHANGES

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,32 @@ $ uvx --from 'gp-libs' --prerelease allow gp-libs
2828

2929
## gp-libs 0.0.16 (unreleased)
3030

31-
- _Add your latest changes from PRs here_
31+
### Features
32+
33+
#### pytest_doctest_docutils
34+
35+
- Add `_unblock_doctest()` helper for programmatic re-enabling of built-in doctest plugin (#56)
36+
37+
Uses the public `pluginmanager.unblock()` API introduced in pytest 8.1.0, with graceful
38+
fallback for older versions.
39+
40+
### Bug fixes
41+
42+
#### pytest_doctest_docutils
43+
44+
- Autouse fixtures from `conftest.py` are now properly discovered for doctest files (#56)
45+
46+
Backported from pytest commit [9cd14b4ff](https://github.com/pytest-dev/pytest/commit/9cd14b4ff) (2024-02-06).
47+
48+
#### doctest_docutils
49+
50+
- Doctest directive comments with leading whitespace (e.g., ` # doctest: +SKIP`) are now properly matched (#56)
51+
52+
Backported from Sphinx commit [ad0c343d3](https://github.com/sphinx-doc/sphinx/commit/ad0c343d3) (2025-01-04).
53+
54+
### Development
55+
56+
- CI: Add pytest 9.x to test matrix, with pytest 7.x/8.x compatibility testing (#56)
3257

3358
## gp-libs 0.0.15 (2025-11-01)
3459

notes/2025-11-25-upstream-backports.md

Lines changed: 247 additions & 0 deletions
Large diffs are not rendered by default.

src/doctest_docutils.py

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from __future__ import annotations
44

55
import doctest
6-
import functools
76
import linecache
87
import logging
98
import os
@@ -30,7 +29,10 @@
3029

3130

3231
blankline_re = re.compile(r"^\s*<BLANKLINE>", re.MULTILINE)
33-
doctestopt_re = re.compile(r"#\s*doctest:.+$", re.MULTILINE)
32+
# Backported from Sphinx commit ad0c343d3 (2025-01-04).
33+
# https://github.com/sphinx-doc/sphinx/commit/ad0c343d3
34+
# Allow optional leading whitespace before doctest directive comments.
35+
doctestopt_re = re.compile(r"[ \t]*#\s*doctest:.+$", re.MULTILINE)
3436

3537

3638
def is_allowed_version(version: str, spec: str) -> bool:
@@ -399,32 +401,6 @@ def condition(node: Node) -> bool:
399401
if test is not None:
400402
tests.append(test)
401403

402-
if sys.version_info < (3, 13):
403-
404-
def _from_module(
405-
self,
406-
module: str | types.ModuleType | None,
407-
object: object, # NOQA: A002
408-
) -> bool:
409-
"""Return true if the given object lives in the given module.
410-
411-
`cached_property` objects are never considered a part
412-
of the 'current module'. As such they are skipped by doctest.
413-
Here we override `_from_module` to check the underlying
414-
function instead. https://github.com/python/cpython/issues/107995.
415-
"""
416-
if isinstance(object, functools.cached_property):
417-
object = object.func # noqa: A001
418-
419-
# Type ignored because this is a private function.
420-
return t.cast(
421-
"bool",
422-
super()._from_module(module, object), # type:ignore[misc]
423-
)
424-
425-
else: # pragma: no cover
426-
pass
427-
428404
def _get_test(
429405
self,
430406
string: str,

src/pytest_doctest_docutils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737

3838
logger = logging.getLogger(__name__)
3939

40+
# Parse pytest version for version-specific features
41+
PYTEST_VERSION = tuple(int(x) for x in pytest.__version__.split(".")[:2])
42+
4043
# Lazy definition of runner class
4144
RUNNER_CLASS = None
4245

@@ -68,6 +71,28 @@ def pytest_configure(config: pytest.Config) -> None:
6871
config.pluginmanager.set_blocked("doctest")
6972

7073

74+
def _unblock_doctest(config: pytest.Config) -> bool:
75+
"""Unblock doctest plugin (pytest 8.1+ only).
76+
77+
Re-enables the built-in doctest plugin after it was blocked by
78+
pytest_configure. Uses the public unblock() API introduced in pytest 8.1.0.
79+
80+
Parameters
81+
----------
82+
config : pytest.Config
83+
The pytest configuration object
84+
85+
Returns
86+
-------
87+
bool
88+
True if unblocked successfully, False if API not available
89+
"""
90+
pm = config.pluginmanager
91+
if PYTEST_VERSION >= (8, 1) and hasattr(pm, "unblock"):
92+
return pm.unblock("doctest")
93+
return False
94+
95+
7196
def pytest_unconfigure() -> None:
7297
"""Unconfigure hook for pytest-doctest-docutils."""
7398
global RUNNER_CLASS
@@ -306,6 +331,12 @@ def collect(self) -> Iterable[DoctestItem]:
306331
# Uses internal doctest module parsing mechanism.
307332
finder = DocutilsDocTestFinder()
308333

334+
# While doctests in .rst/.md files don't support fixtures directly,
335+
# we still need to pick up autouse fixtures.
336+
# Backported from pytest commit 9cd14b4ff (2024-02-06).
337+
# https://github.com/pytest-dev/pytest/commit/9cd14b4ff
338+
self.session._fixturemanager.parsefactories(self)
339+
309340
optionflags = get_optionflags(self.config)
310341

311342
runner = _get_runner(
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Regression test for autouse fixtures with doctest files.
2+
3+
Backported from pytest commit 9cd14b4ff (2024-02-06).
4+
https://github.com/pytest-dev/pytest/commit/9cd14b4ff
5+
6+
The original pytest test verified autouse fixtures defined in the same .py module
7+
as the doctest get picked up properly. For gp-libs, DocTestDocutilsFile handles
8+
.rst/.md files where fixtures can only come from conftest.py (not from the
9+
document itself).
10+
11+
This test verifies that autouse fixtures from conftest.py are properly discovered
12+
for .rst and .md doctest files across different fixture scopes.
13+
14+
Refs: pytest-dev/pytest#11929
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import textwrap
20+
import typing as t
21+
22+
import _pytest.pytester
23+
import pytest
24+
25+
26+
class AutouseFixtureTestCase(t.NamedTuple):
27+
"""Test fixture for autouse fixtures with doctest files."""
28+
29+
test_id: str
30+
scope: str
31+
file_ext: str
32+
file_content: str
33+
34+
35+
RST_DOCTEST_CONTENT = textwrap.dedent(
36+
"""
37+
Example
38+
=======
39+
40+
.. doctest::
41+
42+
>>> get_value()
43+
'fixture ran'
44+
""",
45+
)
46+
47+
MD_DOCTEST_CONTENT = textwrap.dedent(
48+
"""
49+
# Example
50+
51+
```{doctest}
52+
>>> get_value()
53+
'fixture ran'
54+
```
55+
""",
56+
)
57+
58+
SCOPES = ["module", "session", "class", "function"]
59+
60+
FIXTURES = [
61+
AutouseFixtureTestCase(
62+
test_id=f"{scope}-rst",
63+
scope=scope,
64+
file_ext=".rst",
65+
file_content=RST_DOCTEST_CONTENT,
66+
)
67+
for scope in SCOPES
68+
] + [
69+
AutouseFixtureTestCase(
70+
test_id=f"{scope}-md",
71+
scope=scope,
72+
file_ext=".md",
73+
file_content=MD_DOCTEST_CONTENT,
74+
)
75+
for scope in SCOPES
76+
]
77+
78+
79+
@pytest.mark.parametrize(
80+
AutouseFixtureTestCase._fields,
81+
FIXTURES,
82+
ids=[f.test_id for f in FIXTURES],
83+
)
84+
def test_autouse_fixtures_with_doctest_files(
85+
pytester: _pytest.pytester.Pytester,
86+
test_id: str,
87+
scope: str,
88+
file_ext: str,
89+
file_content: str,
90+
) -> None:
91+
"""Autouse fixtures from conftest.py work with .rst/.md doctest files.
92+
93+
Regression test for pytest-dev/pytest#11929.
94+
Backported from pytest commit 9cd14b4ff (2024-02-06).
95+
"""
96+
pytester.plugins = ["pytest_doctest_docutils"]
97+
pytester.makefile(
98+
".ini",
99+
pytest=textwrap.dedent(
100+
"""
101+
[pytest]
102+
addopts=-p no:doctest -vv
103+
""".strip(),
104+
),
105+
)
106+
107+
# Create conftest with autouse fixture that sets a global value
108+
pytester.makeconftest(
109+
textwrap.dedent(
110+
f"""
111+
import pytest
112+
113+
VALUE = "fixture did not run"
114+
115+
@pytest.fixture(autouse=True, scope="{scope}")
116+
def set_value():
117+
global VALUE
118+
VALUE = "fixture ran"
119+
120+
@pytest.fixture(autouse=True)
121+
def add_get_value_to_doctest_namespace(doctest_namespace):
122+
def get_value():
123+
return VALUE
124+
doctest_namespace["get_value"] = get_value
125+
""",
126+
),
127+
)
128+
129+
# Create the doctest file
130+
tests_path = pytester.path / "tests"
131+
tests_path.mkdir()
132+
test_file = tests_path / f"example{file_ext}"
133+
test_file.write_text(file_content, encoding="utf-8")
134+
135+
result = pytester.runpytest(str(test_file))
136+
result.assert_outcomes(passed=1)

tests/test_doctest_docutils.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,74 @@ def test_DocutilsDocTestFinder(
250250

251251
for test in tests:
252252
doctest.DebugRunner(verbose=False).run(test)
253+
254+
255+
class DoctestOptReTestCase(t.NamedTuple):
256+
"""Test fixture for doctestopt_re regex.
257+
258+
Backported from Sphinx commit ad0c343d3 (2025-01-04).
259+
https://github.com/sphinx-doc/sphinx/commit/ad0c343d3
260+
261+
The original Sphinx test verified HTML output doesn't have trailing
262+
whitespace after flag trimming. This test verifies the regex correctly
263+
matches and removes leading whitespace before doctest flags.
264+
265+
Refs: sphinx-doc/sphinx#13164
266+
"""
267+
268+
test_id: str
269+
input_code: str
270+
expected_output: str
271+
272+
273+
DOCTESTOPT_RE_FIXTURES = [
274+
DoctestOptReTestCase(
275+
test_id="trailing-spaces-before-flag",
276+
input_code="result = func() # doctest: +SKIP",
277+
expected_output="result = func()",
278+
),
279+
DoctestOptReTestCase(
280+
test_id="tab-before-flag",
281+
input_code="result = func()\t# doctest: +SKIP",
282+
expected_output="result = func()",
283+
),
284+
DoctestOptReTestCase(
285+
test_id="no-space-before-flag",
286+
input_code="result = func()# doctest: +SKIP",
287+
expected_output="result = func()",
288+
),
289+
DoctestOptReTestCase(
290+
test_id="multiline-with-leading-whitespace",
291+
input_code="line1\nresult = func() # doctest: +SKIP\nline3",
292+
expected_output="line1\nresult = func()\nline3",
293+
),
294+
DoctestOptReTestCase(
295+
test_id="multiple-flags-on-separate-lines",
296+
input_code="a = 1 # doctest: +SKIP\nb = 2 # doctest: +ELLIPSIS",
297+
expected_output="a = 1\nb = 2",
298+
),
299+
DoctestOptReTestCase(
300+
test_id="mixed-tabs-and-spaces",
301+
input_code="result = func() \t # doctest: +NORMALIZE_WHITESPACE",
302+
expected_output="result = func()",
303+
),
304+
]
305+
306+
307+
@pytest.mark.parametrize(
308+
DoctestOptReTestCase._fields,
309+
DOCTESTOPT_RE_FIXTURES,
310+
ids=[f.test_id for f in DOCTESTOPT_RE_FIXTURES],
311+
)
312+
def test_doctestopt_re_whitespace_trimming(
313+
test_id: str,
314+
input_code: str,
315+
expected_output: str,
316+
) -> None:
317+
"""Verify doctestopt_re removes leading whitespace before doctest flags.
318+
319+
Regression test for Sphinx PR #13164.
320+
Backported from Sphinx commit ad0c343d3 (2025-01-04).
321+
"""
322+
result = doctest_docutils.doctestopt_re.sub("", input_code)
323+
assert result == expected_output

0 commit comments

Comments
 (0)