From 3188e5ba9b02d3f6054d2748e492c550e3530412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lars=20Gr=C3=BCter?= Date: Fri, 7 Nov 2025 16:29:15 +0100 Subject: [PATCH 1/4] Ensure skipped doctest are visible when they are skipped due to `__doctest_skip__` or `__doctest_requires__`. --- pytest_doctestplus/plugin.py | 41 ++++++++++++++++++++++++++++++------ tests/test_doctestplus.py | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/pytest_doctestplus/plugin.py b/pytest_doctestplus/plugin.py index b94446b..a927874 100644 --- a/pytest_doctestplus/plugin.py +++ b/pytest_doctestplus/plugin.py @@ -879,14 +879,17 @@ def find(self, obj, name=None, module=None, globs=None, extraglobs=None): if hasattr(obj, '__doctest_skip__') or hasattr(obj, '__doctest_requires__'): - def test_filter(test): + def conditionally_insert_skip(test): + """ + Insert skip statement if `test` matches `__doctest_(skip|requires)__`. + """ for pat in getattr(obj, '__doctest_skip__', []): if pat == '*': - return False + self._prepend_skip(test) elif pat == '.' and test.name == name: - return False + self._prepend_skip(test) elif fnmatch.fnmatch(test.name, '.'.join((name, pat))): - return False + self._prepend_skip(test) reqs = getattr(obj, '__doctest_requires__', {}) for pats, mods in reqs.items(): @@ -903,14 +906,38 @@ def test_filter(test): else: continue # The pattern does not apply - if not self.check_required_modules(mods): - return False + for mod in mods: + self._prepend_importorskip(test, module=mod) return True - tests = list(filter(test_filter, tests)) + for _test in tests: + conditionally_insert_skip(_test) return tests + def _prepend_skip(self, test): + """Prepends `pytest.skip` before the doctest.""" + source = ( + "import pytest; " + "pytest.skip('listed in `__doctest_skip__`'); " + # Don't impact what's available in the namespace + "del pytest" + ) + importorskip = doctest.Example(source=source, want="") + test.examples.insert(0, importorskip) + + def _prepend_importorskip(self, test, *, module): + """Prepends `pytest.importorskip` before the doctest.""" + source = ( + "import pytest; " + # Hide output of this statement in `_`, otherwise doctests fail + f"_ = pytest.importorskip({module!r}); " + # Don't impact what's available in the namespace + "del pytest" + ) + importorskip = doctest.Example(source=source, want="") + test.examples.insert(0, importorskip) + def write_modified_file(fname, new_fname, changes, encoding=None): # Sort in reversed order to edit the lines: diff --git a/tests/test_doctestplus.py b/tests/test_doctestplus.py index 932ec94..0de69df 100644 --- a/tests/test_doctestplus.py +++ b/tests/test_doctestplus.py @@ -1542,3 +1542,44 @@ def f(): original_fixed = original.replace("1\n 2", "\n ".join(["0", "1", "2", "3"])) assert result == original_fixed + + +def test_skip_module_variable(testdir): + p = testdir.makepyfile(""" + __doctest_skip__ = ["f"] + + def f(): + ''' + >>> 1 + 2 + 5 + ''' + pass + + def g(): + ''' + >>> 1 + 1 + 2 + ''' + pass + """) + testdir.inline_run(p, '--doctest-plus').assertoutcome(passed=1, skipped=1) + + +def test_requires_module_variable(testdir): + p = testdir.makepyfile(""" + __doctest_requires__ = {("f",): ["module_that_is_not_availabe"]} + + def f(): + ''' + >>> import module_that_is_not_availabe + ''' + pass + + def g(): + ''' + >>> 1 + 1 + 2 + ''' + pass + """) + testdir.inline_run(p, '--doctest-plus').assertoutcome(passed=1, skipped=1) From 606a629830a7e60fe4676135dc692593c2961a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lars=20Gr=C3=BCter?= Date: Sat, 8 Nov 2025 15:45:57 +0100 Subject: [PATCH 2/4] Cleanup temporary hidden variable too and use "___" instead of the more commonly used "_" to avoid any potential naming collisions. Tweak `test_requires_module_variable` to check that no output is returned too. --- pytest_doctestplus/plugin.py | 6 +++--- tests/test_doctestplus.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pytest_doctestplus/plugin.py b/pytest_doctestplus/plugin.py index a927874..8f74a09 100644 --- a/pytest_doctestplus/plugin.py +++ b/pytest_doctestplus/plugin.py @@ -930,10 +930,10 @@ def _prepend_importorskip(self, test, *, module): """Prepends `pytest.importorskip` before the doctest.""" source = ( "import pytest; " - # Hide output of this statement in `_`, otherwise doctests fail - f"_ = pytest.importorskip({module!r}); " + # Hide output of this statement in `___`, otherwise doctests fail + f"___ = pytest.importorskip({module!r}); " # Don't impact what's available in the namespace - "del pytest" + "del pytest; del ___" ) importorskip = doctest.Example(source=source, want="") test.examples.insert(0, importorskip) diff --git a/tests/test_doctestplus.py b/tests/test_doctestplus.py index 0de69df..b0901f0 100644 --- a/tests/test_doctestplus.py +++ b/tests/test_doctestplus.py @@ -1567,7 +1567,10 @@ def g(): def test_requires_module_variable(testdir): p = testdir.makepyfile(""" - __doctest_requires__ = {("f",): ["module_that_is_not_availabe"]} + __doctest_requires__ = { + ("f",): ["module_that_is_not_availabe"], + ("g",): ["pytest"], + } def f(): ''' From e3a4df4710be6f2a9e78154c78038b20c4f1a72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lars=20Gr=C3=BCter?= Date: Sat, 8 Nov 2025 15:48:43 +0100 Subject: [PATCH 3/4] Note this fix in CHANGES.rst --- CHANGES.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9286d81..a753c47 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,8 @@ 1.6.0 (unreleased) ================== - +- Ensure that tests skipped with `__doctest_skip__` and `__doctest_requires__` + show up as skipped tests in Pytest's output. [#312] 1.5.0 (2025-10-17) ================== From 666e38d25ad439dca1a4fa080193cd78e4c80ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lars=20Gr=C3=BCter?= Date: Sat, 8 Nov 2025 15:54:51 +0100 Subject: [PATCH 4/4] Assert that importorskip does not affect locals() --- tests/test_doctestplus.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_doctestplus.py b/tests/test_doctestplus.py index b0901f0..71d0380 100644 --- a/tests/test_doctestplus.py +++ b/tests/test_doctestplus.py @@ -1580,6 +1580,10 @@ def f(): def g(): ''' + Test that call to `pytest.importorskip` is not visible + + >>> assert "pytest" not in locals() + >>> assert "___" not in locals() >>> 1 + 1 2 '''