From e8ed97a86fc1d05fb348014a85d6d666ba4d1c2e Mon Sep 17 00:00:00 2001 From: Yoruxyv Date: Fri, 25 Sep 2026 14:21:28 +0700 Subject: [PATCH] Fix NUMBER and ELLIPSIS doctest interaction Co-authored-by: ChatGPT --- AUTHORS | 1 + changelog/13327.bugfix.rst | 1 + src/_pytest/doctest.py | 123 ++++++++++++++++++++++++++++++++++++- testing/test_doctest.py | 121 ++++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 changelog/13327.bugfix.rst diff --git a/AUTHORS b/AUTHORS index e2fad5e8364..6fe70b90163 100644 --- a/AUTHORS +++ b/AUTHORS @@ -205,6 +205,7 @@ Grigorii Eremeev (budulianin) Guido Wesdorp Guoqiang Zhang Hamza Mobeen +Hans Valerie Harald Armin Massa Harshna Henk-Jaap Wagenaar diff --git a/changelog/13327.bugfix.rst b/changelog/13327.bugfix.rst new file mode 100644 index 00000000000..d0a015db333 --- /dev/null +++ b/changelog/13327.bugfix.rst @@ -0,0 +1 @@ +The ``NUMBER`` doctest option now works with ``ELLIPSIS`` when ellipsized output contains additional numeric values. diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 04d707113cc..f8b08259a97 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -645,7 +645,21 @@ def remove_prefixes(regex: re.Pattern[str], txt: str) -> str: got = remove_prefixes(self._bytes_literal_re, got) if allow_number: - got = self._remove_unwanted_precision(want, got) + number_got = self._remove_unwanted_precision(want, got) + if not (optionflags & doctest.ELLIPSIS): + got = number_got + elif super().check_output(want, number_got, optionflags): + return True + else: + want = str(want.encode("ASCII", "backslashreplace"), "ASCII") + got = str(got.encode("ASCII", "backslashreplace"), "ASCII") + if not (optionflags & doctest.DONT_ACCEPT_BLANKLINE): + want = re.sub(r"(?m)^\s*?$", "", want) + got = re.sub(r"(?m)^[^\S\n]+$", "", got) + if optionflags & doctest.NORMALIZE_WHITESPACE: + want = " ".join(want.split()) + got = " ".join(got.split()) + got = self._remove_unwanted_precision_with_ellipsis(want, got) return super().check_output(want, got, optionflags) @@ -673,6 +687,113 @@ def _remove_unwanted_precision(self, want: str, got: str) -> str: offset += w.end() - w.start() - (g.end() - g.start()) return got + def _remove_unwanted_precision_with_ellipsis(self, want: str, got: str) -> str: + """Remove unwanted precision from non-ellipsis expected output.""" + marker = "\0" + while marker in want or marker in got: + marker = chr(ord(marker) + 1) + + def mask_numbers( + text: str, + ) -> tuple[str, list[re.Match[str]]]: + matches = list(self._number_re.finditer(text)) + parts: list[str] = [] + offset = 0 + + for match in matches: + parts.extend((text[offset : match.start()], marker)) + offset = match.end() + + parts.append(text[offset:]) + return "".join(parts), matches + + masked_got, got_numbers = mask_numbers(got) + + def match_numbers( + position: int, + want_numbers: list[re.Match[str]], + ) -> list[tuple[int, int, str]] | None: + number_index = masked_got[:position].count(marker) + matched_got_numbers = got_numbers[ + number_index : number_index + len(want_numbers) + ] + + replacements: list[tuple[int, int, str]] = [] + for want_number, got_number in zip( + want_numbers, matched_got_numbers, strict=True + ): + normalized = self._remove_unwanted_precision( + want_number.group(), got_number.group() + ) + if normalized != want_number.group(): + return None + + replacements.append( + ( + got_number.start(), + got_number.end(), + want_number.group(), + ) + ) + + return replacements + + want_chunks = want.split("...") + if len(want_chunks) == 1: + return got + + chunks = [mask_numbers(chunk) for chunk in want_chunks] + replacements: list[tuple[int, int, str]] = [] + start = 0 + end = len(masked_got) + + first_chunk, first_numbers = chunks[0] + if first_chunk: + if not masked_got.startswith(first_chunk): + return got + + chunk_replacements = match_numbers(0, first_numbers) + if chunk_replacements is None: + return got + + replacements.extend(chunk_replacements) + start = len(first_chunk) + + last_chunk, last_numbers = chunks[-1] + if last_chunk: + position = len(masked_got) - len(last_chunk) + if position < start or not masked_got.endswith(last_chunk): + return got + + chunk_replacements = match_numbers(position, last_numbers) + if chunk_replacements is None: + return got + + replacements.extend(chunk_replacements) + end = position + + for chunk, want_numbers in chunks[1:-1]: + position = masked_got.find(chunk, start, end) + chunk_replacements = None + + while position >= 0: + chunk_replacements = match_numbers(position, want_numbers) + if chunk_replacements is not None: + break + + position = masked_got.find(chunk, position + 1, end) + + if position < 0 or chunk_replacements is None: + return got + + replacements.extend(chunk_replacements) + start = position + len(chunk) + + for start, stop, replacement in sorted(replacements, reverse=True): + got = got[:start] + replacement + got[stop:] + + return got + return LiteralsOutputChecker diff --git a/testing/test_doctest.py b/testing/test_doctest.py index a322e9eb2a3..16d5694dde4 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -2,12 +2,14 @@ from __future__ import annotations from collections.abc import Callable +import doctest import inspect from pathlib import Path import sys import textwrap from _pytest.doctest import _get_checker +from _pytest.doctest import _get_number_flag from _pytest.doctest import _is_main_py from _pytest.doctest import _is_mocked from _pytest.doctest import _is_setup_py @@ -1238,6 +1240,125 @@ def test_number_precision(self, pytester, config_mode): reprec = pytester.inline_run() reprec.assertoutcome(passed=1) + @pytest.mark.parametrize( + ("actual", "expected"), + [ + ("value=1.0001, hidden=2.0", "value=1.0, ..."), + ("prefix hidden=2.0 final=3.0001", "prefix ... final=3.0"), + ( + "prefix final=2.0 hidden final=3.0001 extra suffix", + "prefix ... final=3.0 ... suffix", + ), + ( + "prefix middle=2.0 hidden middle=2.99 suffix", + "prefix...middle=3.0...suffix", + ), + ], + ) + def test_number_and_ellipsis( + self, pytester: Pytester, actual: str, expected: str + ) -> None: + pytester.maketxtfile( + test_doc=f""" + >>> print({actual!r}) # doctest: +NUMBER, +ELLIPSIS + {expected} + """ + ) + reprec = pytester.inline_run() + reprec.assertoutcome(passed=1) + + @pytest.mark.parametrize( + ("actual", "expected"), + [ + ("prefix", "prefix ... value=1.0"), + ("prefix hidden=2.0 final=4.0", "prefix ... final=3.0"), + ("prefix=2.0 hidden suffix", "prefix=3.0 ... suffix"), + ("wrong hidden=2.0 final=3.0001", "prefix ... final=3.0"), + ("prefix hidden=2.0 wrong=3.0001", "prefix ... final=3.0"), + ( + "prefix middle=2.0 hidden middle=4.0 suffix", + "prefix ... middle=3.0 ... suffix", + ), + ], + ) + def test_number_and_ellipsis_non_match( + self, pytester: Pytester, actual: str, expected: str + ) -> None: + pytester.maketxtfile( + test_doc=f""" + >>> print({actual!r}) # doctest: +NUMBER, +ELLIPSIS + {expected} + """ + ) + reprec = pytester.inline_run() + reprec.assertoutcome(failed=1) + + @pytest.mark.parametrize( + ("want", "got"), + [ + ("... value=3.0", "hidden=2.0 value=2.99"), + ("value=3.0 ...", "value=2.99 hidden=2.0"), + ], + ) + def test_number_and_ellipsis_edge_chunks(self, want: str, got: str) -> None: + checker = _get_checker() + optionflags = doctest.ELLIPSIS | _get_number_flag() + + assert checker.check_output(want, got, optionflags) + + def test_number_and_ellipsis_blankline(self) -> None: + checker = _get_checker() + optionflags = doctest.ELLIPSIS | _get_number_flag() + + assert checker.check_output( + "value=1.0\n\n...\nfinal=3.0\n", + "value=1.0001\n\nhidden=2.0\nfinal=3.0001\n", + optionflags, + ) + + def test_number_and_ellipsis_ascii_canonicalization(self) -> None: + checker = _get_checker() + optionflags = doctest.ELLIPSIS | _get_number_flag() + + assert checker.check_output( + r"\u1234 value=1.0 ...", + "\u1234 value=1.0001 hidden=2.0", + optionflags, + ) + + def test_number_and_ellipsis_dont_accept_blankline(self) -> None: + checker = _get_checker() + optionflags = ( + doctest.ELLIPSIS | doctest.DONT_ACCEPT_BLANKLINE | _get_number_flag() + ) + + assert not checker.check_output( + "value=1.0\n\n...\nfinal=3.0\n", + "value=1.0001\n\nhidden=2.0\nfinal=3.0001\n", + optionflags, + ) + + def test_number_and_ellipsis_marker_collision(self) -> None: + checker = _get_checker() + optionflags = doctest.ELLIPSIS | _get_number_flag() + + assert checker.check_output( + "prefix\0 value=1.0 ...\n", + "prefix\0 value=1.0001 hidden=2.0\n", + optionflags, + ) + + def test_number_ellipsis_and_normalize_whitespace(self, pytester: Pytester) -> None: + pytester.maketxtfile( + test_doc=""" + >>> text = "left = 1.0001 hidden=2.0 right = 3.0001" + >>> print(text) # doctest: +NUMBER, +ELLIPSIS, +NORMALIZE_WHITESPACE + left = 1.0 ... right = 3.0 + """ + ) + reprec = pytester.inline_run() + reprec.assertoutcome(passed=1) + @pytest.mark.parametrize( "expression,output", [