Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ Grigorii Eremeev (budulianin)
Guido Wesdorp
Guoqiang Zhang
Hamza Mobeen
Hans Valerie
Harald Armin Massa
Harshna
Henk-Jaap Wagenaar
Expand Down
1 change: 1 addition & 0 deletions changelog/13327.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``NUMBER`` doctest option now works with ``ELLIPSIS`` when ellipsized output contains additional numeric values.
123 changes: 122 additions & 1 deletion src/_pytest/doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)^<BLANKLINE>\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)

Expand Down Expand Up @@ -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


Expand Down
121 changes: 121 additions & 0 deletions testing/test_doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<BLANKLINE>\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<BLANKLINE>\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",
[
Expand Down
Loading