Skip to content

Commit e232573

Browse files
committed
Fix pathologically slow assertion diffs for large inputs (#8998)
Comparing very large strings, lists, or dataclasses in an ``assert`` could hang for a long time (sometimes minutes) while pytest built the failure diff. The cost comes from ``difflib.ndiff``: its character-level "fancy replace" step is quadratic in the size of the differing region, and the underlying ``SequenceMatcher`` is quadratic in the number of lines (a large nested structure can pretty-print to hundreds of thousands of lines). Add a deterministic size heuristic (no wall-clock timeouts, per the maintainer discussion in the issue): when the input is too large for ``ndiff`` to be fast, fall back to a coarser line-level ``unified_diff``, capped to a bounded number of lines so it always completes in milliseconds, and note this in the output. Smaller comparisons keep the existing detailed ``ndiff`` output unchanged.
1 parent 4a809d9 commit e232573

6 files changed

Lines changed: 129 additions & 4 deletions

File tree

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ Kevin J. Foley
267267
Kian Eliasi
268268
Kian-Meng Ang
269269
Kim Soo
270+
Kiril Klein
270271
Kodi B. Arfer
271272
Kojo Idrissa
272273
Kostis Anagnostopoulos

changelog/8998.bugfix.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Assertion failures comparing very large strings, lists, or dataclasses no longer hang for a long time (sometimes minutes) while building the diff.
2+
3+
When the inputs are large enough that :func:`difflib.ndiff` would be pathologically slow, pytest now falls back to a faster line-level diff and notes this in the output.

src/_pytest/assertion/_compare_sequence.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from _pytest._io.pprint import PrettyPrinter
88
from _pytest._io.saferepr import saferepr
9+
from _pytest.assertion._diff import fast_unified_diff
10+
from _pytest.assertion._diff import ndiff_too_slow
911
from _pytest.assertion._typing import _HighlightFunc
1012
from _pytest.compat import running_on_ci
1113

@@ -27,6 +29,9 @@ def _compare_eq_iterable(
2729

2830
yield ""
2931
yield "Full diff:"
32+
if ndiff_too_slow(left_formatting, right_formatting):
33+
yield from fast_unified_diff(left_formatting, right_formatting, highlighter)
34+
return
3035
# "right" is the expected base against which we compare "left",
3136
# see https://github.com/pytest-dev/pytest/issues/3333
3237
yield from highlighter(

src/_pytest/assertion/_diff.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterator
4+
from collections.abc import Sequence
5+
6+
from _pytest.assertion._typing import _HighlightFunc
7+
8+
9+
# Above this combined input size (in characters), ``difflib.ndiff`` becomes
10+
# pathologically slow: its character-level "fancy replace" step is quadratic in
11+
# the size of the differing region, so a few tens of kilobytes of differing text
12+
# can hang for minutes (see issue #8998).
13+
NDIFF_MAX_INPUT_SIZE = 10_000
14+
15+
# Above this number of lines, both ``ndiff`` and ``unified_diff`` get slow,
16+
# since the underlying ``SequenceMatcher`` is quadratic in the number of lines
17+
# (a large nested structure can pretty-print to hundreds of thousands of lines).
18+
# We both fall back and cap the fallback's input at this many lines.
19+
DIFF_MAX_LINES = 1_000
20+
21+
22+
def ndiff_too_slow(left_lines: Sequence[str], right_lines: Sequence[str]) -> bool:
23+
"""Return True if ``difflib.ndiff`` would likely be pathologically slow."""
24+
if len(left_lines) > DIFF_MAX_LINES or len(right_lines) > DIFF_MAX_LINES:
25+
return True
26+
size = sum(len(line) for line in left_lines) + sum(
27+
len(line) for line in right_lines
28+
)
29+
return size > NDIFF_MAX_INPUT_SIZE
30+
31+
32+
def fast_unified_diff(
33+
left_lines: Sequence[str],
34+
right_lines: Sequence[str],
35+
highlighter: _HighlightFunc,
36+
) -> Iterator[str]:
37+
"""Yield a fast, coarse line-level diff for inputs too large for ``ndiff``.
38+
39+
Unlike ``ndiff`` this does not produce character-level "?" guide lines, and
40+
it only diffs the first ``DIFF_MAX_LINES`` lines of each side, but it
41+
completes in milliseconds where ``ndiff`` would hang (see issue #8998).
42+
43+
"right" is the expected base against which we compare "left",
44+
see https://github.com/pytest-dev/pytest/issues/3333.
45+
"""
46+
from difflib import unified_diff
47+
48+
yield (
49+
f"Diff too large to compute in full (over {NDIFF_MAX_INPUT_SIZE} "
50+
"characters); showing a faster line-level diff instead:"
51+
)
52+
left = [line.rstrip("\n") for line in left_lines[:DIFF_MAX_LINES]]
53+
right = [line.rstrip("\n") for line in right_lines[:DIFF_MAX_LINES]]
54+
hidden = max(len(left_lines), len(right_lines)) - DIFF_MAX_LINES
55+
if hidden > 0:
56+
yield f"Diffing only the first {DIFF_MAX_LINES} lines; {hidden} more hidden"
57+
diff = unified_diff(right, left, n=3, lineterm="")
58+
# The first two lines are the always-empty "--- "/"+++ " file headers.
59+
next(diff, None)
60+
next(diff, None)
61+
yield from highlighter("\n".join(diff), lexer="diff").splitlines()

src/_pytest/assertion/compare_text.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from collections.abc import Iterator
44

55
from _pytest._io.saferepr import saferepr
6+
from _pytest.assertion._diff import fast_unified_diff
7+
from _pytest.assertion._diff import ndiff_too_slow
68
from _pytest.assertion._typing import _AssertionTextDiffStyle
79
from _pytest.assertion._typing import _HighlightFunc
810
from _pytest.assertion.highlight import dummy_highlighter
@@ -75,13 +77,15 @@ def _diff_text(
7577
left = repr(str(left))
7678
right = repr(str(right))
7779
yield "Strings contain only whitespace, escaping them using repr()"
80+
left_lines = left.splitlines(keepends)
81+
right_lines = right.splitlines(keepends)
82+
if ndiff_too_slow(left_lines, right_lines):
83+
yield from fast_unified_diff(left_lines, right_lines, highlighter)
84+
return
7885
# "right" is the expected base against which we compare "left",
7986
# see https://github.com/pytest-dev/pytest/issues/3333
8087
yield from highlighter(
81-
"\n".join(
82-
line.strip("\n")
83-
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
84-
),
88+
"\n".join(line.strip("\n") for line in ndiff(right_lines, left_lines)),
8589
lexer="diff",
8690
).splitlines()
8791

testing/test_assertion.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from _pytest.assertion import truncate
1818
from _pytest.assertion import util
1919
from _pytest.assertion._compare_any import _compare_eq_cls
20+
from _pytest.assertion._diff import ndiff_too_slow
2021
from _pytest.assertion.compare_text import _compare_eq_text
2122
from _pytest.config import Config as _Config
2223
from _pytest.monkeypatch import MonkeyPatch
@@ -459,6 +460,19 @@ def callequal(
459460
)
460461

461462

463+
class TestNdiffTooSlow:
464+
"""Heuristic guarding against pathologically slow diffs (#8998)."""
465+
466+
def test_small_input_uses_ndiff(self) -> None:
467+
assert ndiff_too_slow(["spam"], ["eggs"]) is False
468+
469+
def test_many_characters_is_too_slow(self) -> None:
470+
assert ndiff_too_slow(["a" * 6000], ["b" * 6000]) is True
471+
472+
def test_many_lines_is_too_slow(self) -> None:
473+
assert ndiff_too_slow(["x"] * 1001, ["y"]) is True
474+
475+
462476
class TestAssert_reprcompare:
463477
def test_different_types(self) -> None:
464478
assert callequal([0, 1], "foo") is None
@@ -513,6 +527,32 @@ def test_text_skipping_verbose(self) -> None:
513527
assert "- " + "a" * 50 + "eggs" in lines
514528
assert "+ " + "a" * 50 + "spam" in lines
515529

530+
def test_text_diff_large_input_skips_ndiff(self) -> None:
531+
# A single huge differing line is above the character cutoff and falls
532+
# back to a fast line-level diff instead of the pathologically slow
533+
# ndiff (#8998).
534+
left = "a" + "x" * 20000
535+
right = "b" + "y" * 20000
536+
lines = callequal(left, right, verbose=1)
537+
assert lines is not None
538+
assert any("Diff too large to compute in full" in line for line in lines)
539+
# The character-level "?" guide lines produced by ndiff must not appear.
540+
assert not any(line.startswith("? ") for line in lines)
541+
542+
def test_text_diff_many_lines_skips_ndiff(self) -> None:
543+
# Many lines are above the line cutoff and fall back, capping the
544+
# number of lines actually diffed (#8998).
545+
left = "\n".join(f"left line {i}" for i in range(2000))
546+
right = "\n".join(f"right line {i}" for i in range(2000))
547+
lines = callequal(left, right, verbose=1)
548+
assert lines is not None
549+
assert any("Diff too large to compute in full" in line for line in lines)
550+
assert any("Diffing only the first 1000 lines" in line for line in lines)
551+
assert not any(line.startswith("? ") for line in lines)
552+
# The fallback still shows which lines differ.
553+
assert "-right line 0" in lines
554+
assert "+left line 0" in lines
555+
516556
def test_multiline_text_diff(self) -> None:
517557
left = "foo\nspam\nbar"
518558
right = "foo\neggs\nbar"
@@ -673,6 +713,17 @@ def test_iterable_quiet(self) -> None:
673713
"Use -v to get more diff",
674714
]
675715

716+
def test_iterable_large_input_skips_ndiff(self) -> None:
717+
# Large iterables fall back to a fast line-level diff instead of the
718+
# pathologically slow ndiff over their pprint output (#8998).
719+
left = [f"item-{i}" for i in range(2000)]
720+
right = [f"other-{i}" for i in range(2000)]
721+
lines = callequal(left, right, verbose=1)
722+
assert lines is not None
723+
assert "Full diff:" in lines
724+
assert any("Diff too large to compute in full" in line for line in lines)
725+
assert not any(line.startswith("? ") for line in lines)
726+
676727
def test_iterable_full_diff_ci(
677728
self, monkeypatch: MonkeyPatch, pytester: Pytester
678729
) -> None:

0 commit comments

Comments
 (0)