From 39597ac34215001e51cb20e8422ace9eaa23d26b Mon Sep 17 00:00:00 2001 From: Poorva Barve Date: Tue, 15 Sep 2026 22:52:52 -0700 Subject: [PATCH] Fix doctest option flags leaking between docstrings --- AUTHORS | 1 + changelog/9924.bugfix.rst | 1 + src/_pytest/doctest.py | 11 ++++-- testing/test_doctest.py | 82 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 changelog/9924.bugfix.rst diff --git a/AUTHORS b/AUTHORS index ad4c2093892..e2fad5e8364 100644 --- a/AUTHORS +++ b/AUTHORS @@ -396,6 +396,7 @@ Pierre Sassoulas Pieter Mulder Piotr Banaszkiewicz Piotr Helm +Poorva Barve Poulami Sau Prakhar Gurunani Praneeth Kodumagulla diff --git a/changelog/9924.bugfix.rst b/changelog/9924.bugfix.rst new file mode 100644 index 00000000000..9e26bc6c5ae --- /dev/null +++ b/changelog/9924.bugfix.rst @@ -0,0 +1 @@ +Fixed doctest option flags leaking between docstrings after a failure, skip, or expected failure. diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index ea894b3f441..04d707113cc 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -297,9 +297,14 @@ def runtest(self) -> None: _check_all_skipped(self.dtest) self._disable_output_capturing_for_darwin() failures: list[doctest.DocTestFailure] = [] - # Type ignored because we change the type of `out` from what - # doctest expects. - self.runner.run(self.dtest, out=failures) # type: ignore[arg-type] + optionflags = self.runner.optionflags + try: + # Type ignored because we change the type of `out` from what + # doctest expects. + self.runner.run(self.dtest, out=failures) # type: ignore[arg-type] + finally: + # Our runner can raise before doctest restores its option flags. + self.runner.optionflags = optionflags if failures: raise MultipleDoctestFailures(failures) diff --git a/testing/test_doctest.py b/testing/test_doctest.py index a2b91bc4096..a322e9eb2a3 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -272,6 +272,35 @@ def test_doctest_outcomes(self, pytester: Pytester): ] ) + @pytest.mark.parametrize("outcome", ["skip", "xfail"]) + @pytest.mark.parametrize("continue_on_failure", [False, True]) + def test_optionflags_restored_after_outcome( + self, pytester: Pytester, outcome: str, continue_on_failure: bool + ) -> None: + pytester.makepyfile( + f""" + import pytest + + def first(): + ''' + >>> pytest.{outcome}("reason") # doctest: -ELLIPSIS + ''' + + def second(): + ''' + >>> print("foobar") + foo... + ''' + """ + ) + args = ["--doctest-modules"] + if continue_on_failure: + args.append("--doctest-continue-on-failure") + result = pytester.runpytest(*args) + result.assert_outcomes( + passed=1, skipped=int(outcome == "skip"), xfailed=int(outcome == "xfail") + ) + def test_docstring_partial_context_around_error(self, pytester: Pytester): """Test that we show some context before the actual line of a failing doctest. @@ -679,6 +708,59 @@ def nice_meth(self): reprec = pytester.inline_run(p, "--doctest-modules") reprec.assertoutcome(failed=1, passed=1) + @pytest.mark.parametrize("continue_on_failure", [False, True]) + @pytest.mark.parametrize("first_example", ["0.", "1 / 0"]) + @pytest.mark.parametrize( + "optionflags, directive, second_example, second_output, passed", + [ + pytest.param("ELLIPSIS", "+NUMBER", "1.", "0.", 0, id="enable-number"), + pytest.param( + "", "+ELLIPSIS", 'print("foobar")', "foo...", 0, id="enable-ellipsis" + ), + pytest.param( + "ELLIPSIS", + "-ELLIPSIS", + 'print("foobar")', + "foo...", + 1, + id="disable-ellipsis", + ), + ], + ) + def test_optionflags_do_not_leak_between_docstrings( + self, + pytester: Pytester, + continue_on_failure: bool, + first_example: str, + optionflags: str, + directive: str, + second_example: str, + second_output: str, + passed: int, + ) -> None: + """A failing docstring must not change another's comparison options (#9924).""" + pytester.makeini(f"[pytest]\ndoctest_optionflags = {optionflags}") + pytester.makepyfile( + f""" + def first(): + ''' + >>> {first_example} # doctest: {directive} + 2. + ''' + + def second(): + ''' + >>> {second_example} + {second_output} + ''' + """ + ) + args = ["--doctest-modules"] + if continue_on_failure: + args.append("--doctest-continue-on-failure") + result = pytester.runpytest(*args) + result.assert_outcomes(failed=2 - passed, passed=passed) + def test_ignored_whitespace(self, pytester: Pytester): pytester.makeini( """