Skip to content
Closed
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
3 changes: 3 additions & 0 deletions changelog/12406.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Failing string equality assertions on huge strings no longer take a very long time to report.

At the default verbosity only a handful of diff lines are displayed, but the underlying diff was always computed over the full strings, which could take hours for multi-million-line texts. The diff is now computed over at most the first 100 lines of each string (after skipping any identical leading/trailing parts), with a message indicating that ``-v`` computes and shows the full diff.
25 changes: 21 additions & 4 deletions src/_pytest/assertion/compare_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
from _pytest.compat import assert_never


# In non-verbose mode only a handful of diff lines are displayed, but
# ``difflib.ndiff``'s runtime blows up on large inputs, so computing the
# full diff can take hours only for almost all of it to be thrown away
# (#12406). Diffing this many lines is near-instant while still producing
# far more output than the display truncation will ever show.
_MAX_NON_VERBOSE_DIFF_LINES = 100


def _compare_eq_text(
left: str,
right: str,
Expand Down Expand Up @@ -75,13 +83,22 @@ def _diff_text(
left = repr(str(left))
right = repr(str(right))
yield "Strings contain only whitespace, escaping them using repr()"
left_lines = left.splitlines(keepends)
right_lines = right.splitlines(keepends)
if verbose < 1 and (
len(left_lines) > _MAX_NON_VERBOSE_DIFF_LINES
or len(right_lines) > _MAX_NON_VERBOSE_DIFF_LINES
):
yield (
f"Skipping all but the first {_MAX_NON_VERBOSE_DIFF_LINES} lines "
"of each text in diff, use -v to diff the full text"
)
left_lines = left_lines[:_MAX_NON_VERBOSE_DIFF_LINES]
right_lines = right_lines[:_MAX_NON_VERBOSE_DIFF_LINES]
# "right" is the expected base against which we compare "left",
# see https://github.com/pytest-dev/pytest/issues/3333
yield from highlighter(
"\n".join(
line.strip("\n")
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
),
"\n".join(line.strip("\n") for line in ndiff(right_lines, left_lines)),
lexer="diff",
).splitlines()

Expand Down
36 changes: 36 additions & 0 deletions testing/test_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,42 @@ def test_text_skipping_verbose(self) -> None:
assert "- " + "a" * 50 + "eggs" in lines
assert "+ " + "a" * 50 + "spam" in lines

def test_text_diff_huge_is_capped(self) -> None:
# Diffing large texts in full can take a very long time while only a
# few lines get displayed anyway, so the diff computation is capped
# in non-verbose mode (#12406).
left = "\n".join(f"spam {i}" for i in range(300))
right = "\n".join(f"eggs {i}" for i in range(300))
lines = callequal(left, right)
assert lines is not None
assert (
"Skipping all but the first 100 lines of each text in diff, "
"use -v to diff the full text" in lines
)
assert "+ spam 99" in lines
assert not any("spam 100" in line for line in lines)

def test_text_diff_huge_not_capped_verbose(self) -> None:
# With -v the full diff is computed, however long it takes.
left = "\n".join(f"spam {i}" for i in range(300))
right = "\n".join(f"eggs {i}" for i in range(300))
lines = callequal(left, right, verbose=1)
assert lines is not None
assert "+ spam 299" in lines
assert not any("Skipping" in line for line in lines)

def test_text_diff_huge_cap_after_leading_skip(self) -> None:
# A huge identical prefix is skipped first; the line cap then
# applies to the remaining differing region.
common = "\n".join(f"common {i}" for i in range(200))
left = common + "\n" + "\n".join(f"spam {i}" for i in range(300))
right = common + "\n" + "\n".join(f"eggs {i}" for i in range(300))
lines = callequal(left, right)
assert lines is not None
assert any("identical leading characters" in line for line in lines)
assert any("Skipping all but the first 100 lines" in line for line in lines)
assert not any("spam 150" in line for line in lines)

def test_multiline_text_diff(self) -> None:
left = "foo\nspam\nbar"
right = "foo\neggs\nbar"
Expand Down