diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c8101bf..61990e8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,6 +11,11 @@ jobs: matrix: python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] docutils-version: ['0.18', '0.19'] + pytest-version: ['7', '8', '9'] + exclude: + # Exclude pytest 7 from Python 3.14 to reduce matrix size + - python-version: '3.14' + pytest-version: '7' steps: - uses: actions/checkout@v5 @@ -25,10 +30,14 @@ jobs: - name: Install dependencies run: uv sync --all-extras --dev - - name: Print python versions + - name: Install specific pytest version + run: uv pip install "pytest~=${{ matrix.pytest-version }}.0" + + - name: Print python and pytest versions run: | python -V uv run python -V + uv run pytest --version - name: Lint with ruff check run: uv run ruff check . diff --git a/.gitignore b/.gitignore index b0c2e3c..0621c10 100644 --- a/.gitignore +++ b/.gitignore @@ -83,7 +83,6 @@ pip-wheel-metadata/ monkeytype.sqlite3 # Claude code -**/CLAUDE.md **/CLAUDE.local.md **/CLAUDE.*.md **/.claude/settings.local.json diff --git a/CHANGES b/CHANGES index ee3093f..023ea36 100644 --- a/CHANGES +++ b/CHANGES @@ -28,7 +28,32 @@ $ uvx --from 'gp-libs' --prerelease allow gp-libs ## gp-libs 0.0.16 (unreleased) -- _Add your latest changes from PRs here_ +### Features + +#### pytest_doctest_docutils + +- Add `_unblock_doctest()` helper for programmatic re-enabling of built-in doctest plugin (#56) + + Uses the public `pluginmanager.unblock()` API introduced in pytest 8.1.0, with graceful + fallback for older versions. + +### Bug fixes + +#### pytest_doctest_docutils + +- Autouse fixtures from `conftest.py` are now properly discovered for doctest files (#56) + + Backported from pytest commit [9cd14b4ff](https://github.com/pytest-dev/pytest/commit/9cd14b4ff) (2024-02-06). + +#### doctest_docutils + +- Doctest directive comments with leading whitespace (e.g., ` # doctest: +SKIP`) are now properly matched (#56) + + Backported from Sphinx commit [ad0c343d3](https://github.com/sphinx-doc/sphinx/commit/ad0c343d3) (2025-01-04). + +### Development + +- CI: Add pytest 9.x to test matrix, with pytest 7.x/8.x compatibility testing (#56) ## gp-libs 0.0.15 (2025-11-01) diff --git a/notes/2025-11-25-upstream-backports.md b/notes/2025-11-25-upstream-backports.md new file mode 100644 index 0000000..d264cf1 --- /dev/null +++ b/notes/2025-11-25-upstream-backports.md @@ -0,0 +1,247 @@ +# Upstream Doctest Backport Audit Report + +**Date:** 2025-11-25 +**Audited Repositories:** +- CPython doctest: `~/study/c/cpython/Lib/doctest.py` +- pytest doctest plugin: `~/study/python/pytest/src/_pytest/doctest.py` +- Sphinx doctest extension: `~/work/reStructuredText/sphinx/sphinx/ext/doctest.py` + +## Executive Summary + +After comprehensive audit of **115 commits** across all three upstream sources (CPython 27, pytest 35, Sphinx 53), **only 2 backports are needed**: + +1. pytest `9cd14b4ff` (2024-02-06) - Autouse fixtures fix +2. Sphinx `ad0c343d3` (2025-01-04) - Regex whitespace fix + +### Key Architectural Insight + +**gp-libs' `DocutilsDocTestFinder` is NOT a subclass of `doctest.DocTestFinder`**. It's a completely different implementation: +- Parses reStructuredText/Markdown documents using docutils/myst-parser +- Extracts doctest blocks from markup nodes (not Python source inspection) +- Uses `doctest.DocTestParser.get_doctest()` to create `DocTest` objects +- Delegates test execution to standard `doctest.DocTestRunner` + +CPython fixes to `_from_module()`, `_find_lineno()`, and `_find()` don't apply because gp-libs doesn't use these methods. + +**Note:** The `_from_module()` method at lines 402-426 of `doctest_docutils.py` is dead code - it's never called and would fail if called (calls `super()._from_module()` but no parent class has this method). This could be cleaned up separately. + +--- + +## Backports Required (Chronological Order) + +### 1. pytest Autouse Fixtures Fix (2024-02-06) + +**Source:** pytest commit `9cd14b4ff` +**GitHub:** https://github.com/pytest-dev/pytest/commit/9cd14b4ff +**Issue:** pytest-dev/pytest#11929 +**Type:** Bug Fix + +**Problem:** Autouse fixtures defined in `conftest.py` may not be picked up for doctest collection because `_nodeid_autousenames` is consulted at collection time before fixtures are parsed. + +**File:** `src/pytest_doctest_docutils.py` + +**Change:** Add after line ~307 in `DocTestDocutilsFile.collect()`: +```python +# While doctests in .rst/.md files don't support fixtures directly, +# we still need to pick up autouse fixtures. +# Backported from pytest commit 9cd14b4ff (2024-02-06). +# https://github.com/pytest-dev/pytest/commit/9cd14b4ff +self.session._fixturemanager.parsefactories(self) +``` + +--- + +### 2. Sphinx Doctest Flag Regex Fix (2025-01-04) + +**Source:** Sphinx commit `ad0c343d3` +**GitHub:** https://github.com/sphinx-doc/sphinx/pull/13164 +**Type:** Bug Fix + +**Problem:** The `doctestopt_re` regex doesn't match leading whitespace before `# doctest:`, causing trailing whitespace in rendered output when flags are trimmed. + +**File:** `src/doctest_docutils.py` line 33 + +**Before:** +```python +doctestopt_re = re.compile(r"#\s*doctest:.+$", re.MULTILINE) +``` + +**After:** +```python +doctestopt_re = re.compile(r"[ \t]*#\s*doctest:.+$", re.MULTILINE) +``` + +--- + +## Comprehensive Commit Audit + +### CPython doctest.py (27 commits since 2022-01-01) + +| Date | Commit | Type | Description | Applicable? | Reason | +|------|--------|------|-------------|-------------|--------| +| 2025-07-25 | `fece15d29f2` | bug fix | cached functions lineno | NO | Uses `_find_lineno()` - gp-libs gets line numbers from docutils nodes | +| 2025-07-15 | `cb59eaefeda` | docs | testmod docstring | NO | Documentation only | +| 2025-05-31 | `ad39f017881` | feature | unittest subtests | AUTO | Uses `DocTestCase` - gp-libs uses pytest | +| 2025-05-30 | `cb8a72b301f` | bug fix | unittest error report | AUTO | Uses `DocTestCase` - gp-libs uses pytest | +| 2025-05-05 | `4ac916ae33b` | feature | argparse color | NO | Unrelated module | +| 2025-01-20 | `6f167d71347` | refactor | color detection stdout | AUTO | Internal to `DocTestRunner` - inherited via stdlib | +| 2024-09-24 | `af8403a58db` | feature | pdb commands arg | NO | Unrelated module | +| 2024-05-22 | `ef172521a9e` | style | docstring backticks | NO | Documentation/style only | +| 2024-05-01 | `3b3f8dea575` | refactor | colorize module move | AUTO | Internal refactoring - inherited via stdlib | +| 2024-04-24 | `345e1e04ec7` | test | color test resilience | NO | Test changes only | +| 2024-04-24 | `975081b11e0` | feature | color output | AUTO | `DocTestRunner` feature - inherited via Python 3.13+ | +| 2024-04-10 | `4bb7d121bc0` | bug fix | wrapped builtin fix | NO | Uses `_find_lineno()` - gp-libs doesn't use this | +| 2024-03-28 | `29829b58a83` | bug fix | skip reporting | AUTO | Uses `DocTestCase` - gp-libs uses pytest | +| 2024-03-27 | `ce00de4c8cd` | style | pluralization | AUTO | `DocTestRunner.summarize()` - inherited via stdlib | +| 2024-02-19 | `872cc9957a9` | bug fix | -OO mode fix | AUTO | Uses `DocTestCase` - gp-libs uses pytest | +| 2024-02-14 | `bb791c7728e` | bug fix | decorated fn lineno | NO | Uses `_find_lineno()` - gp-libs doesn't use this | +| 2023-12-15 | `8f8f0f97e12` | bug fix | property lineno | NO | Uses `_find_lineno()` - gp-libs doesn't use this | +| 2023-11-25 | `fbb9027a037` | bug fix | DocTest.__lt__ None | AUTO | `DocTest` class - inherited via stdlib | +| 2023-11-04 | `18c954849bc` | bug fix | SyntaxError subclass | AUTO | `DocTestRunner.__run()` - inherited via stdlib | +| 2023-10-21 | `fd60549c0ac` | bug fix | exception notes | AUTO | `DocTestRunner.__run()` - inherited via stdlib | +| 2023-09-02 | `4f9b706c6f5` | feature | skip counting | AUTO | `TestResults`/`DocTestRunner` - inherited via stdlib | +| 2023-08-07 | `85793278793` | bug fix | regex escape class | NO | Uses `_find_lineno()` - gp-libs doesn't use this | +| 2023-01-13 | `b5d43479503` | refactor | getframemodulename | NO | Internal CPython change | +| 2022-12-30 | `79c10b7da84` | bug fix | MethodWrapperType | NO | Uses `_from_module()` - gp-libs doesn't call this | +| 2022-05-19 | `8db2b3b6878` | bug fix | empty DocTest lineno | NO | Uses `_find_lineno()` - gp-libs doesn't use this | +| 2022-03-22 | `7ba7eae5080` | bug fix | globs teardown | AUTO | Uses `DocTestCase` - gp-libs uses pytest | +| 2022-01-08 | `0fc58c1e051` | refactor | CodeType simplify | NO | Uses `_find_lineno()` - gp-libs doesn't use this | + +**Summary:** 0 backports needed. 14 AUTO-inherited via stdlib, 13 NOT applicable. + +--- + +### pytest doctest.py (35 commits since 2022-01-01) + +| Date | Commit | Type | Description | Applicable? | Reason | +|------|--------|------|-------------|-------------|--------| +| 2025-09-12 | `bb712f151` | version | Drop Python 3.9 | NO | Version support change | +| 2024-11-29 | `17c5bbbda` | style | %r specifiers | NO | Style fix - internal to pytest | +| 2024-11-20 | `1bacc0007` | typing | re namespace | NO | Typing style - internal to pytest | +| 2024-11-10 | `05ed0d0f9` | typing | deprecated-typing-alias | NO | Typing compat - internal to pytest | +| 2024-11-10 | `a4cb74e86` | chore | CI after py3.8 drop | NO | CI/docs change | +| 2024-08-29 | `c947145fb` | compat | typing.Self fix | NO | Typing compat - internal to pytest | +| 2024-08-14 | `b08b41cef` | chore | pre-commit update | NO | Tooling update | +| 2024-06-17 | `9295f9fff` | style | `__future__` annotations | CONSIDER | Style modernization - could match | +| 2024-06-18 | `49374ec7a` | bug fix | patch condition fix | NO | `MockAwareDocTestFinder` - gp-libs uses `DocutilsDocTestFinder` | +| 2024-06-07 | `f94109937` | refactor | finder cleanup | NO | `MockAwareDocTestFinder` - gp-libs uses `DocutilsDocTestFinder` | +| 2024-05-27 | `48cb8a2b3` | chore | pre-commit update | NO | Tooling update | +| 2024-04-30 | `4788165e6` | style | format specifiers | NO | Style fix - internal to pytest | +| 2024-03-13 | `c0532dda1` | chore | pre-commit update | NO | Tooling update | +| 2024-02-23 | `010ce2ab0` | typing | from_parent return types | AUTO | Typing - inherited via `DoctestItem` | +| 2024-02-06 | `9cd14b4ff` | bug fix | autouse fixtures | **BACKPORT** | **DocTestDocutilsFile needs this!** | +| 2024-02-06 | `6e5008f19` | refactor | module import | AUTO | `DoctestModule` - inherited for .py files | +| 2024-01-31 | `4588653b2` | chore | migrate to ruff | NO | Tooling change | +| 2024-01-28 | `878af85ae` | typing | disallow untyped defs | NO | Typing strictness - internal to pytest | +| 2024-01-13 | `06dbd3c21` | refactor | conftest handling | AUTO | `DoctestModule` - inherited for .py files | +| 2023-09-08 | `6ad9499c9` | typing | missing annotations | NO | Typing - internal to pytest | +| 2023-09-08 | `2ed2e9208` | typing | remove Optionals | NO | Typing - internal to pytest | +| 2023-09-08 | `ab63ebb3d` | refactor | inline _setup_fixtures | AUTO | `DoctestItem` - inherited | +| 2023-09-08 | `b3a981d38` | refactor | fixture funcargs | AUTO | Fixture internals - inherited | +| 2023-09-07 | `e787d2ed4` | merge | cached_property PR | AUTO | Merge commit | +| 2023-09-01 | `82bd63d31` | feature | fixturenames field | AUTO | `DoctestItem` - inherited | +| 2023-08-20 | `a357c7abc` | test | coverage ignore | NO | Test coverage change | +| 2023-08-19 | `7a625481d` | review | PR suggestions | AUTO | Part of cached_property work | +| 2023-08-19 | `ebd571bb1` | refactor | _from_module move | NO | `MockAwareDocTestFinder` - gp-libs uses different finder | +| 2023-08-16 | `d4fb6ac9f` | bug fix | cached_property | NO | `MockAwareDocTestFinder` - gp-libs has own implementation | +| 2023-07-16 | `9e164fc4f` | refactor | FixtureRequest abstract | AUTO | Fixture internals - inherited | +| 2023-07-10 | `01f38aca4` | docs | fixture comments | NO | Documentation only | +| 2023-02-07 | `59e7d2bbc` | chore | pre-commit update | NO | Tooling update | +| 2022-10-07 | `8e7ce60c7` | typing | export DoctestItem | AUTO | **Authored by Tony Narlock** - typing export | +| 2022-06-29 | `c34eaaaa1` | bug fix | importmode pass | AUTO | `DoctestModule` - inherited for .py files | +| 2022-05-31 | `e54c6a136` | docs | code-highlight default | NO | Documentation only | +| 2022-05-10 | `231e22063` | docs | docstrings move | NO | Documentation only | +| 2022-01-31 | `9d2ffe207` | chore | pre-commit fixes | NO | Tooling update | + +**Summary:** 1 backport needed (`9cd14b4ff`). 12 AUTO-inherited, 22 NOT applicable. + +--- + +### Sphinx doctest.py (53 commits since 2022-01-01) + +| Date | Commit | Type | Description | Applicable? | Reason | +|------|--------|------|-------------|-------------|--------| +| 2025-09-01 | `14717292b` | bug fix | default group config | NO | Sphinx builder-specific | +| 2025-06-07 | `3044d6753` | refactor | avoid self.app | NO | Sphinx builder-specific | +| 2025-06-06 | `77a0d6658` | refactor | extract nested functions | NO | Sphinx builder-specific | +| 2025-06-03 | `987ccb2a9` | style | str.partition | NO | Style - internal to Sphinx | +| 2025-03-24 | `5831b3eea` | feature | doctest_fail_fast | NO | Already via pytest `-x` and `continue_on_failure` | +| 2025-02-10 | `f96904146` | typing | config valid_types | NO | Sphinx config system | +| 2025-01-22 | `2d41d43ce` | typing | no-any-generics | NO | Typing - internal to Sphinx | +| 2025-01-16 | `a56fdad70` | refactor | colour module | NO | Sphinx console module | +| 2025-01-14 | `c4daa95c0` | style | Ruff D category | NO | Linting rules | +| 2025-01-13 | `f6d1665f8` | typing | frozensets | NO | Typing - internal to Sphinx | +| 2025-01-12 | `72ce43619` | typing | runtime typing imports | NO | Typing - internal to Sphinx | +| 2025-01-07 | `44aced1ab` | docs | confval directives | NO | Documentation only | +| 2025-01-04 | `ad0c343d3` | bug fix | regex whitespace | **BACKPORT** | **doctestopt_re used directly!** | +| 2025-01-02 | `b5f9ac8af` | style | RUF100 lint | NO | Linting rules | +| 2024-12-17 | `01d993b35` | style | auto formatting | NO | Formatting only | +| 2024-11-03 | `7801bd77b` | style | os.path absolute | NO | Import style | +| 2024-10-19 | `e58dd58f3` | style | PLR6201 lint | NO | Linting rules | +| 2024-10-10 | `d135d2eba` | typing | Builder.write final | NO | Sphinx builder-specific | +| 2024-08-13 | `fadb6b10c` | bug fix | --fail-on-warnings | NO | Sphinx CLI-specific | +| 2024-07-23 | `de15d61a4` | refactor | pathlib usage | NO | Sphinx project module | +| 2024-07-22 | `9e3f4521d` | version | Drop Python 3.9 | NO | Version support change | +| 2024-04-01 | `cb8a28dd7` | typing | color stubs | NO | Typing - internal to Sphinx | +| 2024-03-23 | `22cee4209` | typing | types-docutils | NO | Typing stubs change | +| 2024-03-22 | `6c92c5c0f` | chore | ruff version bump | NO | Tooling update | +| 2024-03-21 | `d59b15837` | typing | ExtensionMetadata | NO | Typing - internal to Sphinx | +| 2024-03-03 | `7f582a56b` | bug fix | resource leak | NO | Sphinx builder file handle - gp-libs doesn't have this | +| 2024-02-01 | `aff95789a` | chore | Ruff 0.2.0 config | NO | Tooling update | +| 2024-01-16 | `55f308998` | style | str.join | NO | Style - internal to Sphinx | +| 2024-01-14 | `f7fbfaa47` | style | pydocstyle rules | NO | Linting rules | +| 2024-01-03 | `259118d18` | typing | valid_types narrow | NO | Typing - internal to Sphinx | +| 2024-01-03 | `19b295051` | typing | rebuild narrow | NO | Typing - internal to Sphinx | +| 2023-08-13 | `f844055dd` | style | SIM115 context | NO | Style - internal to Sphinx | +| 2023-08-13 | `9bcf1d8bb` | style | TCH001 import | NO | Import organization | +| 2023-08-13 | `36012b7d9` | style | TCH002 import | NO | Import organization | +| 2023-07-28 | `92e60b3f1` | typing | type:ignore params | NO | Typing - internal to Sphinx | +| 2023-07-28 | `ff20efcd7` | refactor | show_successes tweaks | NO | Follow-up to show_successes feature | +| 2023-07-28 | `aef544515` | feature | doctest_show_successes | NO | pytest handles verbosity natively | +| 2023-07-25 | `ad61e4115` | version | Drop Python 3.8 | NO | Version support change | +| 2023-07-23 | `4de540efb` | typing | strict optional | NO | Typing - internal to Sphinx | +| 2023-02-17 | `c8f4a03da` | style | COM812 fix | NO | Linting rules | +| 2023-01-02 | `4032070e8` | style | pyupgrade | NO | Style modernization - Sphinx specific | +| 2023-01-01 | `14a9289d7` | style | PEP 604 types | CONSIDER | Style modernization - could match | +| 2022-12-30 | `26f79b0d2` | style | PEP 595 types | NO | PEP 595 is about datetime | +| 2022-12-30 | `f4c8a0a68` | style | `__future__` annotations | CONSIDER | Style modernization - could match | +| 2022-12-29 | `7fb45a905` | style | bandit checks | NO | Security linting | +| 2022-12-29 | `b89c33fc0` | style | pygrep-hooks | NO | Linting rules | +| 2022-09-25 | `9ced73631` | bug fix | highlighting lexers | NO | Sphinx highlighting-specific | +| 2022-09-08 | `ba548f713` | docs | is_allowed_version | NO | **Authored by Tony Narlock** - Different parameter order in gp-libs | +| 2022-07-18 | `a504ac610` | typing | typing strictness | NO | Typing - internal to Sphinx | +| 2022-03-24 | `a432bf8c1` | docs | PEP links | NO | Documentation only | +| 2022-02-20 | `6bb7b891a` | style | copyright fields | NO | Style - internal to Sphinx | +| 2022-02-20 | `b691ebcc3` | style | PEP 257 docstrings | NO | Docstring style | +| 2022-02-20 | `5694e0ce6` | style | docstring indent | NO | Docstring style | +| 2022-02-20 | `4f5a3269a` | style | docstring first line | NO | Docstring style | +| 2022-02-19 | `6b8bccec5` | style | module titles | NO | Docstring style | +| 2022-01-02 | `05a898ecb` | compat | Node.findall | NO | Already in `docutils_compat.py` | + +**Summary:** 1 backport needed (`ad0c343d3`). 0 AUTO-inherited, 52 NOT applicable. + +--- + +## Summary Statistics + +| Repository | Total Commits | Backport Needed | Auto-Inherited | Not Applicable | +|------------|--------------|-----------------|----------------|----------------| +| CPython | 27 | 0 | 14 | 13 | +| pytest | 35 | 1 | 12 | 22 | +| Sphinx | 53 | 1 | 0 | 52 | +| **Total** | **115** | **2** | **26** | **87** | + +## Applicability Legend + +- **BACKPORT**: Needs to be manually backported to gp-libs +- **AUTO**: Automatically inherited via stdlib/pytest dependency upgrades +- **NO**: Not applicable to gp-libs architecture +- **CONSIDER**: Optional style modernization (not a bug fix) + +## Optional: Style Modernization (Separate Effort) + +Some commits marked "CONSIDER" could be applied as style modernization: +- `9295f9fff` (pytest) / `f4c8a0a68` (Sphinx): `from __future__ import annotations` - **Already in gp-libs** +- `14a9289d7` (Sphinx): PEP 604 types (`X | Y` instead of `Union[X, Y]`) + +These are style choices, not bug fixes or API compatibility issues. diff --git a/src/doctest_docutils.py b/src/doctest_docutils.py index ec9a335..a7b086e 100644 --- a/src/doctest_docutils.py +++ b/src/doctest_docutils.py @@ -3,7 +3,6 @@ from __future__ import annotations import doctest -import functools import linecache import logging import os @@ -30,7 +29,10 @@ blankline_re = re.compile(r"^\s*", re.MULTILINE) -doctestopt_re = re.compile(r"#\s*doctest:.+$", re.MULTILINE) +# Backported from Sphinx commit ad0c343d3 (2025-01-04). +# https://github.com/sphinx-doc/sphinx/commit/ad0c343d3 +# Allow optional leading whitespace before doctest directive comments. +doctestopt_re = re.compile(r"[ \t]*#\s*doctest:.+$", re.MULTILINE) def is_allowed_version(version: str, spec: str) -> bool: @@ -399,32 +401,6 @@ def condition(node: Node) -> bool: if test is not None: tests.append(test) - if sys.version_info < (3, 13): - - def _from_module( - self, - module: str | types.ModuleType | None, - object: object, # NOQA: A002 - ) -> bool: - """Return true if the given object lives in the given module. - - `cached_property` objects are never considered a part - of the 'current module'. As such they are skipped by doctest. - Here we override `_from_module` to check the underlying - function instead. https://github.com/python/cpython/issues/107995. - """ - if isinstance(object, functools.cached_property): - object = object.func # noqa: A001 - - # Type ignored because this is a private function. - return t.cast( - "bool", - super()._from_module(module, object), # type:ignore[misc] - ) - - else: # pragma: no cover - pass - def _get_test( self, string: str, diff --git a/src/pytest_doctest_docutils.py b/src/pytest_doctest_docutils.py index 4e5b298..1fd9a47 100644 --- a/src/pytest_doctest_docutils.py +++ b/src/pytest_doctest_docutils.py @@ -37,6 +37,9 @@ logger = logging.getLogger(__name__) +# Parse pytest version for version-specific features +PYTEST_VERSION = tuple(int(x) for x in pytest.__version__.split(".")[:2]) + # Lazy definition of runner class RUNNER_CLASS = None @@ -68,6 +71,28 @@ def pytest_configure(config: pytest.Config) -> None: config.pluginmanager.set_blocked("doctest") +def _unblock_doctest(config: pytest.Config) -> bool: + """Unblock doctest plugin (pytest 8.1+ only). + + Re-enables the built-in doctest plugin after it was blocked by + pytest_configure. Uses the public unblock() API introduced in pytest 8.1.0. + + Parameters + ---------- + config : pytest.Config + The pytest configuration object + + Returns + ------- + bool + True if unblocked successfully, False if API not available + """ + pm = config.pluginmanager + if PYTEST_VERSION >= (8, 1) and hasattr(pm, "unblock"): + return pm.unblock("doctest") + return False + + def pytest_unconfigure() -> None: """Unconfigure hook for pytest-doctest-docutils.""" global RUNNER_CLASS @@ -306,6 +331,12 @@ def collect(self) -> Iterable[DoctestItem]: # Uses internal doctest module parsing mechanism. finder = DocutilsDocTestFinder() + # While doctests in .rst/.md files don't support fixtures directly, + # we still need to pick up autouse fixtures. + # Backported from pytest commit 9cd14b4ff (2024-02-06). + # https://github.com/pytest-dev/pytest/commit/9cd14b4ff + self.session._fixturemanager.parsefactories(self) + optionflags = get_optionflags(self.config) runner = _get_runner( diff --git a/tests/regressions/test_autouse_fixtures.py b/tests/regressions/test_autouse_fixtures.py new file mode 100644 index 0000000..1058619 --- /dev/null +++ b/tests/regressions/test_autouse_fixtures.py @@ -0,0 +1,136 @@ +"""Regression test for autouse fixtures with doctest files. + +Backported from pytest commit 9cd14b4ff (2024-02-06). +https://github.com/pytest-dev/pytest/commit/9cd14b4ff + +The original pytest test verified autouse fixtures defined in the same .py module +as the doctest get picked up properly. For gp-libs, DocTestDocutilsFile handles +.rst/.md files where fixtures can only come from conftest.py (not from the +document itself). + +This test verifies that autouse fixtures from conftest.py are properly discovered +for .rst and .md doctest files across different fixture scopes. + +Refs: pytest-dev/pytest#11929 +""" + +from __future__ import annotations + +import textwrap +import typing as t + +import _pytest.pytester +import pytest + + +class AutouseFixtureTestCase(t.NamedTuple): + """Test fixture for autouse fixtures with doctest files.""" + + test_id: str + scope: str + file_ext: str + file_content: str + + +RST_DOCTEST_CONTENT = textwrap.dedent( + """ +Example +======= + +.. doctest:: + + >>> get_value() + 'fixture ran' + """, +) + +MD_DOCTEST_CONTENT = textwrap.dedent( + """ +# Example + +```{doctest} +>>> get_value() +'fixture ran' +``` + """, +) + +SCOPES = ["module", "session", "class", "function"] + +FIXTURES = [ + AutouseFixtureTestCase( + test_id=f"{scope}-rst", + scope=scope, + file_ext=".rst", + file_content=RST_DOCTEST_CONTENT, + ) + for scope in SCOPES +] + [ + AutouseFixtureTestCase( + test_id=f"{scope}-md", + scope=scope, + file_ext=".md", + file_content=MD_DOCTEST_CONTENT, + ) + for scope in SCOPES +] + + +@pytest.mark.parametrize( + AutouseFixtureTestCase._fields, + FIXTURES, + ids=[f.test_id for f in FIXTURES], +) +def test_autouse_fixtures_with_doctest_files( + pytester: _pytest.pytester.Pytester, + test_id: str, + scope: str, + file_ext: str, + file_content: str, +) -> None: + """Autouse fixtures from conftest.py work with .rst/.md doctest files. + + Regression test for pytest-dev/pytest#11929. + Backported from pytest commit 9cd14b4ff (2024-02-06). + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makefile( + ".ini", + pytest=textwrap.dedent( + """ +[pytest] +addopts=-p no:doctest -vv + """.strip(), + ), + ) + + # Create conftest with autouse fixture that sets a global value + pytester.makeconftest( + textwrap.dedent( + f""" +import pytest + +VALUE = "fixture did not run" + +@pytest.fixture(autouse=True, scope="{scope}") +def set_value(): + global VALUE + VALUE = "fixture ran" + +@pytest.fixture(autouse=True) +def add_get_value_to_doctest_namespace(doctest_namespace): + def get_value(): + return VALUE + doctest_namespace["get_value"] = get_value + """, + ), + ) + + # Create the doctest file + tests_path = pytester.path / "tests" + tests_path.mkdir() + test_file = tests_path / f"example{file_ext}" + test_file.write_text(file_content, encoding="utf-8") + + result = pytester.runpytest(str(test_file)) + result.assert_outcomes(passed=1) diff --git a/tests/test_doctest_docutils.py b/tests/test_doctest_docutils.py index 8f5c872..1c0adcd 100644 --- a/tests/test_doctest_docutils.py +++ b/tests/test_doctest_docutils.py @@ -250,3 +250,74 @@ def test_DocutilsDocTestFinder( for test in tests: doctest.DebugRunner(verbose=False).run(test) + + +class DoctestOptReTestCase(t.NamedTuple): + """Test fixture for doctestopt_re regex. + + Backported from Sphinx commit ad0c343d3 (2025-01-04). + https://github.com/sphinx-doc/sphinx/commit/ad0c343d3 + + The original Sphinx test verified HTML output doesn't have trailing + whitespace after flag trimming. This test verifies the regex correctly + matches and removes leading whitespace before doctest flags. + + Refs: sphinx-doc/sphinx#13164 + """ + + test_id: str + input_code: str + expected_output: str + + +DOCTESTOPT_RE_FIXTURES = [ + DoctestOptReTestCase( + test_id="trailing-spaces-before-flag", + input_code="result = func() # doctest: +SKIP", + expected_output="result = func()", + ), + DoctestOptReTestCase( + test_id="tab-before-flag", + input_code="result = func()\t# doctest: +SKIP", + expected_output="result = func()", + ), + DoctestOptReTestCase( + test_id="no-space-before-flag", + input_code="result = func()# doctest: +SKIP", + expected_output="result = func()", + ), + DoctestOptReTestCase( + test_id="multiline-with-leading-whitespace", + input_code="line1\nresult = func() # doctest: +SKIP\nline3", + expected_output="line1\nresult = func()\nline3", + ), + DoctestOptReTestCase( + test_id="multiple-flags-on-separate-lines", + input_code="a = 1 # doctest: +SKIP\nb = 2 # doctest: +ELLIPSIS", + expected_output="a = 1\nb = 2", + ), + DoctestOptReTestCase( + test_id="mixed-tabs-and-spaces", + input_code="result = func() \t # doctest: +NORMALIZE_WHITESPACE", + expected_output="result = func()", + ), +] + + +@pytest.mark.parametrize( + DoctestOptReTestCase._fields, + DOCTESTOPT_RE_FIXTURES, + ids=[f.test_id for f in DOCTESTOPT_RE_FIXTURES], +) +def test_doctestopt_re_whitespace_trimming( + test_id: str, + input_code: str, + expected_output: str, +) -> None: + """Verify doctestopt_re removes leading whitespace before doctest flags. + + Regression test for Sphinx PR #13164. + Backported from Sphinx commit ad0c343d3 (2025-01-04). + """ + result = doctest_docutils.doctestopt_re.sub("", input_code) + assert result == expected_output diff --git a/tests/test_doctest_options.py b/tests/test_doctest_options.py new file mode 100644 index 0000000..aa9cd68 --- /dev/null +++ b/tests/test_doctest_options.py @@ -0,0 +1,501 @@ +"""Test doctest option flags for rst/md files. + +Tests for doctest option flags (ELLIPSIS, NORMALIZE_WHITESPACE, SKIP, etc.) +in reStructuredText and Markdown files via pytest_doctest_docutils. + +Ref: pytest's test_doctest.py option flag tests. +""" + +from __future__ import annotations + +import textwrap +import typing as t + +import _pytest.pytester +import pytest + + +class DoctestOptionCase(t.NamedTuple): + """Test case for doctest option flags.""" + + test_id: str + file_ext: str + ini_options: str + doctest_content: str + expected_outcome: str + description: str + + +DOCTEST_OPTION_CASES = [ + # ELLIPSIS tests + DoctestOptionCase( + test_id="ellipsis-via-ini-rst", + file_ext=".rst", + ini_options="doctest_optionflags = ELLIPSIS", + doctest_content=textwrap.dedent( + """ + Example + ======= + + >>> print("hello world") + hello ... + """, + ), + expected_outcome="passed", + description="ELLIPSIS flag via pytest.ini works for .rst files", + ), + DoctestOptionCase( + test_id="ellipsis-via-ini-md", + file_ext=".md", + ini_options="doctest_optionflags = ELLIPSIS", + doctest_content=textwrap.dedent( + """ + # Example + + ```python + >>> print("hello world") + hello ... + ``` + """, + ), + expected_outcome="passed", + description="ELLIPSIS flag via pytest.ini works for .md files", + ), + # NORMALIZE_WHITESPACE tests + DoctestOptionCase( + test_id="normalize-whitespace-via-ini-rst", + file_ext=".rst", + ini_options="doctest_optionflags = NORMALIZE_WHITESPACE", + doctest_content=textwrap.dedent( + """ + Example + ======= + + >>> print("a b c") + a b c + """, + ), + expected_outcome="passed", + description="NORMALIZE_WHITESPACE flag via pytest.ini works for .rst", + ), + DoctestOptionCase( + test_id="normalize-whitespace-via-ini-md", + file_ext=".md", + ini_options="doctest_optionflags = NORMALIZE_WHITESPACE", + doctest_content=textwrap.dedent( + """ + # Example + + ```python + >>> print("a b c") + a b c + ``` + """, + ), + expected_outcome="passed", + description="NORMALIZE_WHITESPACE flag via pytest.ini works for .md", + ), + # Combined flags + DoctestOptionCase( + test_id="ellipsis-and-normalize-whitespace-rst", + file_ext=".rst", + ini_options="doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE", + doctest_content=textwrap.dedent( + """ + Example + ======= + + >>> print("hello world test") + hello ... test + """, + ), + expected_outcome="passed", + description="Combined ELLIPSIS and NORMALIZE_WHITESPACE flags work", + ), + # Inline SKIP directive + DoctestOptionCase( + test_id="inline-skip-directive-rst", + file_ext=".rst", + ini_options="", + doctest_content=textwrap.dedent( + """ + Example + ======= + + >>> 1 / 0 # doctest: +SKIP + """, + ), + expected_outcome="skipped", + description="Inline +SKIP directive works in .rst files", + ), + DoctestOptionCase( + test_id="inline-skip-directive-md", + file_ext=".md", + ini_options="", + doctest_content=textwrap.dedent( + """ + # Example + + ```python + >>> 1 / 0 # doctest: +SKIP + ``` + """, + ), + expected_outcome="skipped", + description="Inline +SKIP directive works in .md files", + ), + # Inline ELLIPSIS directive + DoctestOptionCase( + test_id="inline-ellipsis-directive-rst", + file_ext=".rst", + ini_options="", + doctest_content=textwrap.dedent( + """ + Example + ======= + + >>> print("hello world") # doctest: +ELLIPSIS + hello ... + """, + ), + expected_outcome="passed", + description="Inline +ELLIPSIS directive works in .rst files", + ), + DoctestOptionCase( + test_id="inline-ellipsis-directive-md", + file_ext=".md", + ini_options="", + doctest_content=textwrap.dedent( + """ + # Example + + ```python + >>> print("hello world") # doctest: +ELLIPSIS + hello ... + ``` + """, + ), + expected_outcome="passed", + description="Inline +ELLIPSIS directive works in .md files", + ), +] + + +@pytest.mark.parametrize( + DoctestOptionCase._fields, + DOCTEST_OPTION_CASES, + ids=[c.test_id for c in DOCTEST_OPTION_CASES], +) +def test_doctest_options( + pytester: _pytest.pytester.Pytester, + test_id: str, + file_ext: str, + ini_options: str, + doctest_content: str, + expected_outcome: str, + description: str, +) -> None: + """Test doctest option flags in rst/md files. + + Verifies that doctest option flags work correctly when: + - Set via doctest_optionflags in pytest.ini + - Set inline via # doctest: +FLAG comments + """ + pytester.plugins = ["pytest_doctest_docutils"] + + # Build pytest.ini content + ini_lines = ["[pytest]", "addopts=-p no:doctest -vv"] + if ini_options: + ini_lines.append(ini_options) + ini_content = "\n".join(ini_lines) + + pytester.makefile(".ini", pytest=ini_content) + + # Create the test file + filename = f"test_doc{file_ext}" + file_path = pytester.path / filename + file_path.write_text(doctest_content, encoding="utf-8") + + result = pytester.runpytest(str(file_path)) + + if expected_outcome == "passed": + result.assert_outcomes(passed=1, errors=0) + elif expected_outcome == "failed": + result.assert_outcomes(failed=1, errors=0) + elif expected_outcome == "skipped": + # When all examples are skipped, the test is reported as skipped + result.assert_outcomes(skipped=1, errors=0) + + +class ContinueOnFailureCase(t.NamedTuple): + """Test case for continue-on-failure behavior.""" + + test_id: str + file_ext: str + cli_args: list[str] + doctest_content: str + expected_failures: int + description: str + + +CONTINUE_ON_FAILURE_CASES = [ + ContinueOnFailureCase( + test_id="continue-on-failure-shows-all-failures-rst", + file_ext=".rst", + cli_args=["--doctest-continue-on-failure"], + doctest_content=textwrap.dedent( + """ + Example + ======= + + >>> 1 + 1 + 3 + >>> 2 + 2 + 5 + >>> 3 + 3 + 7 + """, + ), + expected_failures=1, + description="--doctest-continue-on-failure shows all failures in .rst", + ), + ContinueOnFailureCase( + test_id="continue-on-failure-shows-all-failures-md", + file_ext=".md", + cli_args=["--doctest-continue-on-failure"], + doctest_content=textwrap.dedent( + """ + # Example + + ```python + >>> 1 + 1 + 3 + >>> 2 + 2 + 5 + ``` + """, + ), + expected_failures=1, + description="--doctest-continue-on-failure shows all failures in .md", + ), +] + + +@pytest.mark.parametrize( + ContinueOnFailureCase._fields, + CONTINUE_ON_FAILURE_CASES, + ids=[c.test_id for c in CONTINUE_ON_FAILURE_CASES], +) +def test_continue_on_failure( + pytester: _pytest.pytester.Pytester, + test_id: str, + file_ext: str, + cli_args: list[str], + doctest_content: str, + expected_failures: int, + description: str, +) -> None: + """Test --doctest-continue-on-failure behavior. + + When enabled, all doctest failures should be reported, not just the first. + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makefile(".ini", pytest="[pytest]\naddopts=-p no:doctest -vv") + + # Create the test file + filename = f"test_doc{file_ext}" + file_path = pytester.path / filename + file_path.write_text(doctest_content, encoding="utf-8") + + result = pytester.runpytest(*cli_args, str(file_path)) + + # Should have expected number of failures + result.assert_outcomes(failed=expected_failures) + + +class CustomFlagCase(t.NamedTuple): + """Test case for custom doctest flags (ALLOW_UNICODE, ALLOW_BYTES, NUMBER).""" + + test_id: str + file_ext: str + ini_options: str + doctest_content: str + expected_outcome: str + description: str + + +CUSTOM_FLAG_CASES = [ + CustomFlagCase( + test_id="allow-unicode-flag-rst", + file_ext=".rst", + ini_options="doctest_optionflags = ALLOW_UNICODE", + doctest_content=textwrap.dedent( + """ + Example + ======= + + >>> "hello" + 'hello' + """, + ), + expected_outcome="passed", + description="ALLOW_UNICODE custom flag works in .rst", + ), + CustomFlagCase( + test_id="number-flag-rst", + file_ext=".rst", + ini_options="doctest_optionflags = NUMBER", + doctest_content=textwrap.dedent( + """ + Example + ======= + + >>> 3.14159265358979 + 3.14 + """, + ), + expected_outcome="passed", + description="NUMBER custom flag works in .rst", + ), +] + + +@pytest.mark.parametrize( + CustomFlagCase._fields, + CUSTOM_FLAG_CASES, + ids=[c.test_id for c in CUSTOM_FLAG_CASES], +) +def test_custom_flags( + pytester: _pytest.pytester.Pytester, + test_id: str, + file_ext: str, + ini_options: str, + doctest_content: str, + expected_outcome: str, + description: str, +) -> None: + """Test custom doctest flags (ALLOW_UNICODE, ALLOW_BYTES, NUMBER). + + These are pytest-specific extensions to standard doctest flags. + """ + pytester.plugins = ["pytest_doctest_docutils"] + + ini_lines = ["[pytest]", "addopts=-p no:doctest -vv"] + if ini_options: + ini_lines.append(ini_options) + ini_content = "\n".join(ini_lines) + + pytester.makefile(".ini", pytest=ini_content) + + # Create the test file + filename = f"test_doc{file_ext}" + file_path = pytester.path / filename + file_path.write_text(doctest_content, encoding="utf-8") + + result = pytester.runpytest(str(file_path)) + + if expected_outcome == "passed": + result.assert_outcomes(passed=1, errors=0) + elif expected_outcome == "failed": + result.assert_outcomes(failed=1, errors=0) + + +class EdgeCaseTestCase(t.NamedTuple): + """Test case for edge cases in doctest files.""" + + test_id: str + file_ext: str + file_content: str + expected_tests: int + expected_outcome: str + description: str + + +EDGE_CASE_TESTS = [ + EdgeCaseTestCase( + test_id="empty-rst-file", + file_ext=".rst", + file_content="", + expected_tests=0, + expected_outcome="no_tests", + description="Empty .rst file produces no tests", + ), + EdgeCaseTestCase( + test_id="empty-md-file", + file_ext=".md", + file_content="", + expected_tests=0, + expected_outcome="no_tests", + description="Empty .md file produces no tests", + ), + EdgeCaseTestCase( + test_id="no-doctest-rst", + file_ext=".rst", + file_content=textwrap.dedent( + """ + Example + ======= + + This is just regular text without any doctests. + """, + ), + expected_tests=0, + expected_outcome="no_tests", + description=".rst file without doctests produces no tests", + ), + EdgeCaseTestCase( + test_id="no-doctest-md", + file_ext=".md", + file_content=textwrap.dedent( + """ + # Example + + This is just regular text without any doctests. + + ```javascript + // Not a Python doctest + console.log("hello"); + ``` + """, + ), + expected_tests=0, + expected_outcome="no_tests", + description=".md file without doctests produces no tests", + ), +] + + +@pytest.mark.parametrize( + EdgeCaseTestCase._fields, + EDGE_CASE_TESTS, + ids=[c.test_id for c in EDGE_CASE_TESTS], +) +def test_edge_cases( + pytester: _pytest.pytester.Pytester, + test_id: str, + file_ext: str, + file_content: str, + expected_tests: int, + expected_outcome: str, + description: str, +) -> None: + """Test edge cases in doctest file handling. + + Tests empty files and files without doctests. + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makefile(".ini", pytest="[pytest]\naddopts=-p no:doctest -vv") + + # Create the test file + filename = f"test_doc{file_ext}" + file_path = pytester.path / filename + file_path.write_text(file_content, encoding="utf-8") + + result = pytester.runpytest(str(file_path), "-v") + + if expected_outcome == "no_tests": + # Should collect 0 tests (file may be collected but no items) + stdout = result.stdout.str() + assert "0 items" in stdout or "no tests ran" in stdout or expected_tests == 0 + elif expected_outcome == "passed": + result.assert_outcomes(passed=expected_tests) diff --git a/tests/test_plugin_suppression.py b/tests/test_plugin_suppression.py new file mode 100644 index 0000000..09b14cb --- /dev/null +++ b/tests/test_plugin_suppression.py @@ -0,0 +1,554 @@ +"""Test pytest plugin suppression and precedence. + +Tests for pytest plugin blocking behavior in pytest_doctest_docutils. +Ensures plugin suppression works correctly across pytest 7.x/8.x/9.x. + +Ref: pytest's test_pluginmanager.py patterns for plugin blocking tests. +""" + +from __future__ import annotations + +import re +import textwrap +import typing as t + +import _pytest.pytester +import pytest + +# Parse pytest version for version-specific tests +PYTEST_VERSION = tuple(int(x) for x in pytest.__version__.split(".")[:2]) + + +def requires_pytest_version( + min_version: tuple[int, int], + reason: str, +) -> pytest.MarkDecorator: + """Skip test if pytest version is below minimum. + + Parameters + ---------- + min_version : tuple[int, int] + Minimum (major, minor) pytest version required + reason : str + Description of the feature requiring this version + + Returns + ------- + pytest.MarkDecorator + A skipif marker for the test + """ + return pytest.mark.skipif( + min_version > PYTEST_VERSION, + reason=f"Requires pytest {'.'.join(map(str, min_version))}+: {reason}", + ) + + +class PluginSuppressionCase(t.NamedTuple): + """Test case for plugin suppression behavior.""" + + test_id: str + cli_args: list[str] + ini_content: str + expected_tests_collected: int + description: str + + +PLUGIN_SUPPRESSION_CASES = [ + PluginSuppressionCase( + test_id="auto-blocks-builtin-doctest", + cli_args=["--collect-only", "-q"], + ini_content="", + expected_tests_collected=1, + description="pytest_doctest_docutils auto-blocks built-in doctest", + ), + PluginSuppressionCase( + test_id="ini-addopts-no-doctest", + cli_args=["--collect-only", "-q"], + ini_content="addopts = -p no:doctest", + expected_tests_collected=1, + description="addopts=-p no:doctest in pytest.ini works", + ), +] + + +@pytest.mark.parametrize( + PluginSuppressionCase._fields, + PLUGIN_SUPPRESSION_CASES, + ids=[c.test_id for c in PLUGIN_SUPPRESSION_CASES], +) +def test_plugin_suppression( + pytester: _pytest.pytester.Pytester, + test_id: str, + cli_args: list[str], + ini_content: str, + expected_tests_collected: int, + description: str, +) -> None: + """Test plugin suppression behavior. + + Verifies that pytest_doctest_docutils correctly blocks the built-in + doctest plugin to prevent duplicate test collection. + """ + pytester.plugins = ["pytest_doctest_docutils"] + + # Create pytest.ini if content provided + if ini_content: + pytester.makefile( + ".ini", + pytest=f"[pytest]\n{ini_content}", + ) + + # Create a simple doctest file + pytester.makefile( + ".rst", + test_doc=textwrap.dedent( + """ + Example + ======= + + >>> 1 + 1 + 2 + """, + ), + ) + + result = pytester.runpytest(*cli_args, "test_doc.rst") + + # Plugin should not error and should collect the expected tests + result.assert_outcomes(errors=0) + + # Parse the "N test(s) collected" line from output + stdout = result.stdout.str() + match = re.search(r"(\d+) tests? collected", stdout) + if match: + tests_collected = int(match.group(1)) + else: + # If no match, check for "no tests collected" case + if "no tests collected" in stdout: + tests_collected = 0 + else: + pytest.fail(f"Could not parse test count from output:\n{stdout}") + + assert tests_collected == expected_tests_collected, ( + f"Expected {expected_tests_collected} tests, got {tests_collected}. " + f"Output:\n{stdout}" + ) + + +class PluginDisableCase(t.NamedTuple): + """Test case for disabling pytest_doctest_docutils.""" + + test_id: str + cli_args: list[str] + expected_passed: int + description: str + + +PLUGIN_DISABLE_CASES = [ + PluginDisableCase( + test_id="disable-doctest-docutils-uses-builtin", + cli_args=["-p", "no:pytest_doctest_docutils", "--doctest-modules"], + expected_passed=1, + description="Disabling pytest_doctest_docutils allows builtin doctest", + ), +] + + +@pytest.mark.parametrize( + PluginDisableCase._fields, + PLUGIN_DISABLE_CASES, + ids=[c.test_id for c in PLUGIN_DISABLE_CASES], +) +def test_plugin_disable( + pytester: _pytest.pytester.Pytester, + test_id: str, + cli_args: list[str], + expected_passed: int, + description: str, +) -> None: + """Test that pytest_doctest_docutils can be disabled. + + When disabled, the builtin doctest plugin should handle .py files. + Note: .rst/.md files won't be collected by builtin doctest. + """ + # Don't register pytest_doctest_docutils plugin + # This simulates -p no:pytest_doctest_docutils + + # Create a .py file with doctest (builtin doctest handles these) + pytester.makepyfile( + test_module=textwrap.dedent( + ''' + def hello(): + """Say hello. + + >>> hello() + 'hello' + """ + return "hello" + ''', + ), + ) + + result = pytester.runpytest(*cli_args, "test_module.py") + result.assert_outcomes(passed=expected_passed) + + +class PluginPrecedenceCase(t.NamedTuple): + """Test case for plugin precedence behavior.""" + + test_id: str + cli_args: list[str] + expected_passed: int + description: str + + +PLUGIN_PRECEDENCE_CASES = [ + PluginPrecedenceCase( + test_id="precedence-no-then-yes-reenables", + cli_args=["-p", "no:doctest", "-p", "doctest", "--doctest-modules"], + expected_passed=1, + description="Ref pytest test_blocked_plugin_can_be_used: -p no:X -p X", + ), +] + + +@pytest.mark.parametrize( + PluginPrecedenceCase._fields, + PLUGIN_PRECEDENCE_CASES, + ids=[c.test_id for c in PLUGIN_PRECEDENCE_CASES], +) +def test_plugin_precedence( + pytester: _pytest.pytester.Pytester, + test_id: str, + cli_args: list[str], + expected_passed: int, + description: str, +) -> None: + """Test plugin precedence with -p no:X -p X patterns. + + Based on pytest's test_blocked_plugin_can_be_used (test_pluginmanager.py:478-483). + When a plugin is blocked then re-enabled, it should be available. + """ + pytester.plugins = ["pytest_doctest_docutils"] + + # Create a .py file + pytester.makepyfile( + test_module=textwrap.dedent( + ''' + def hello(): + """Say hello. + + >>> hello() + 'hello' + """ + return "hello" + ''', + ), + ) + + result = pytester.runpytest(*cli_args, "test_module.py") + + # Should work - the plugin system handles precedence + result.assert_outcomes(passed=expected_passed) + + +def test_pytest_configure_blocks_doctest( + pytester: _pytest.pytester.Pytester, +) -> None: + """Test that pytest_configure automatically blocks the doctest plugin. + + This tests the core behavior in pytest_doctest_docutils.pytest_configure: + if config.pluginmanager.has_plugin("doctest"): + config.pluginmanager.set_blocked("doctest") + """ + pytester.plugins = ["pytest_doctest_docutils"] + + # Create conftest that checks plugin state after configuration + pytester.makeconftest( + textwrap.dedent( + """ + import pytest + + @pytest.hookimpl(trylast=True) + def pytest_configure(config): + # After all pytest_configure hooks run, doctest should be blocked + pm = config.pluginmanager + # is_blocked exists in pytest 7+ + if hasattr(pm, 'is_blocked'): + # Store result for test to check + config._doctest_was_blocked = pm.is_blocked('doctest') + else: + config._doctest_was_blocked = None + + @pytest.fixture + def doctest_blocked_status(request): + return getattr(request.config, '_doctest_was_blocked', None) + """, + ), + ) + + # Create test that verifies the blocking happened + pytester.makepyfile( + test_verify=textwrap.dedent( + """ + def test_doctest_was_blocked(doctest_blocked_status): + if doctest_blocked_status is not None: + assert doctest_blocked_status is True, ( + "doctest plugin should be blocked by pytest_doctest_docutils" + ) + """, + ), + ) + + result = pytester.runpytest("test_verify.py", "-v") + result.assert_outcomes(passed=1) + + +class CollectorRoutingCase(t.NamedTuple): + """Test case for file type collector routing.""" + + test_id: str + filename: str + file_content: str + expected_collector_type: str + + +COLLECTOR_ROUTING_CASES = [ + CollectorRoutingCase( + test_id="py-uses-DoctestModule", + filename="test_module.py", + file_content=textwrap.dedent( + ''' + def foo(): + """Foo function. + + >>> 1 + 1 + 2 + """ + pass + ''', + ), + expected_collector_type="DoctestModule", + ), + CollectorRoutingCase( + test_id="rst-uses-DocTestDocutilsFile", + filename="test_doc.rst", + file_content=textwrap.dedent( + """ + Example + ======= + + >>> 1 + 1 + 2 + """, + ), + expected_collector_type="DocTestDocutilsFile", + ), + CollectorRoutingCase( + test_id="md-uses-DocTestDocutilsFile", + filename="test_doc.md", + file_content=textwrap.dedent( + """ + # Example + + ```python + >>> 1 + 1 + 2 + ``` + """, + ), + expected_collector_type="DocTestDocutilsFile", + ), +] + + +@pytest.mark.parametrize( + CollectorRoutingCase._fields, + COLLECTOR_ROUTING_CASES, + ids=[c.test_id for c in COLLECTOR_ROUTING_CASES], +) +def test_collector_routing( + pytester: _pytest.pytester.Pytester, + test_id: str, + filename: str, + file_content: str, + expected_collector_type: str, +) -> None: + """Test that file types are routed to the correct collector. + + - .py files should use DoctestModule (from _pytest.doctest) + - .rst/.md files should use DocTestDocutilsFile (from pytest_doctest_docutils) + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makefile( + ".ini", + pytest="[pytest]\naddopts=-p no:doctest -vv", + ) + + # Create the test file + file_path = pytester.path / filename + file_path.write_text(file_content, encoding="utf-8") + + # Use --collect-only to see collection info + result = pytester.runpytest( + "--collect-only", + "--doctest-docutils-modules", + str(file_path), + ) + + stdout = result.stdout.str() + + # Verify the expected collector type appears in output + assert expected_collector_type in stdout, ( + f"Expected collector {expected_collector_type} not found in output:\n{stdout}" + ) + + +# pytest 8.1+ version-specific tests + + +@requires_pytest_version((8, 1), "pluginmanager.unblock() API") +def test_unblock_api_available( + pytester: _pytest.pytester.Pytester, +) -> None: + """Test pluginmanager.unblock() API available in pytest 8.1+. + + Verifies that the unblock() method exists and can be used to + re-enable a previously blocked plugin. + + Ref: pytest 8.1.0 changelog - pluginmanager.unblock() public API + """ + pytester.plugins = ["pytest_doctest_docutils"] + + # Create conftest that tests unblock API + pytester.makeconftest( + textwrap.dedent( + """ + import pytest + + @pytest.hookimpl(trylast=True) + def pytest_configure(config): + pm = config.pluginmanager + + # Verify unblock method exists + assert hasattr(pm, 'unblock'), "unblock() API not found" + + # doctest should be blocked by pytest_doctest_docutils + assert pm.is_blocked('doctest'), "doctest should be blocked" + + # Test unblock API + result = pm.unblock('doctest') + + # Store results for test verification + config._unblock_api_exists = True + config._unblock_result = result + config._doctest_unblocked = not pm.is_blocked('doctest') + + @pytest.fixture + def unblock_test_results(request): + return { + 'api_exists': getattr(request.config, '_unblock_api_exists', False), + 'unblock_result': getattr(request.config, '_unblock_result', None), + 'doctest_unblocked': getattr( + request.config, '_doctest_unblocked', False + ), + } + """, + ), + ) + + # Create test that verifies unblock worked + pytester.makepyfile( + test_verify=textwrap.dedent( + """ + def test_unblock_api_works(unblock_test_results): + assert unblock_test_results['api_exists'], "unblock() API should exist" + assert unblock_test_results['unblock_result'] is True, ( + "unblock() should return True when successful" + ) + assert unblock_test_results['doctest_unblocked'], ( + "doctest should be unblocked after calling unblock()" + ) + """, + ), + ) + + result = pytester.runpytest("test_verify.py", "-v") + result.assert_outcomes(passed=1) + + +# pytest 8.4+ version-specific tests + + +@requires_pytest_version((8, 4), "--disable-plugin-autoload flag") +def test_disable_plugin_autoload_flag( + pytester: _pytest.pytester.Pytester, +) -> None: + """Test --disable-plugin-autoload CLI flag in pytest 8.4+. + + Verifies that the --disable-plugin-autoload flag is recognized and + prevents automatic plugin loading via entry points. + + Ref: pytest 8.4.0 changelog - --disable-plugin-autoload CLI flag + """ + # Create a simple test file + pytester.makepyfile( + test_simple=textwrap.dedent( + """ + def test_pass(): + assert True + """, + ), + ) + + # Test that the flag is recognized (doesn't error) + result = pytester.runpytest( + "--disable-plugin-autoload", + "-p", + "pytest_doctest_docutils", + "test_simple.py", + "-v", + ) + + # Should succeed - the flag should be recognized + result.assert_outcomes(passed=1) + + +@requires_pytest_version((8, 4), "--disable-plugin-autoload flag") +def test_disable_plugin_autoload_with_explicit_plugin( + pytester: _pytest.pytester.Pytester, +) -> None: + """Test --disable-plugin-autoload with explicit plugin loading. + + When --disable-plugin-autoload is used, only explicitly specified + plugins via -p should be loaded. + + Ref: pytest 8.4.0 changelog - --disable-plugin-autoload CLI flag + """ + pytester.plugins = ["pytest_doctest_docutils"] + + # Create a doctest file + pytester.makefile( + ".rst", + test_doc=textwrap.dedent( + """ + Example + ======= + + >>> 1 + 1 + 2 + """, + ), + ) + + # With --disable-plugin-autoload, explicitly load our plugin + result = pytester.runpytest( + "--disable-plugin-autoload", + "-p", + "pytest_doctest_docutils", + "test_doc.rst", + "-v", + ) + + # Should find and run the doctest + result.assert_outcomes(passed=1)