From 4e9f632934e7ec371455a6cc752609152393e19b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 14:46:31 +0000 Subject: [PATCH] Cap text diff computation for huge strings in non-verbose mode At the default verbosity the displayed assertion explanation is truncated to a handful of lines, but ``_diff_text`` always ran ``difflib.ndiff`` over the full strings first. For multi-million-line strings this could take hours (difflib's runtime blows up on large inputs), only for almost all of the output to be discarded. Now, when ``verbose < 1``, the diff is computed over at most the first 100 lines of each side (after the existing identical leading/trailing character skipping), with a message pointing at ``-v`` to get the full diff. This mirrors the existing behaviour of skipping identical leading/trailing characters unless ``-v`` is given, and the precedent of ``_compare_eq_iterable`` not computing a full diff at low verbosity. The message starts with "Skipping" so that ``_notin_text``'s existing filtering drops it there, like the other skip hints. Fixes #12406 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HVRWXxfTLrU4B95jeoeuHN --- changelog/12406.improvement.rst | 3 +++ src/_pytest/assertion/compare_text.py | 25 ++++++++++++++++--- testing/test_assertion.py | 36 +++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 changelog/12406.improvement.rst diff --git a/changelog/12406.improvement.rst b/changelog/12406.improvement.rst new file mode 100644 index 00000000000..a7d45b64e2d --- /dev/null +++ b/changelog/12406.improvement.rst @@ -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. diff --git a/src/_pytest/assertion/compare_text.py b/src/_pytest/assertion/compare_text.py index 08e85fc5811..f06f49d7de1 100644 --- a/src/_pytest/assertion/compare_text.py +++ b/src/_pytest/assertion/compare_text.py @@ -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, @@ -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() diff --git a/testing/test_assertion.py b/testing/test_assertion.py index c64d8da0a09..f104912e18b 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -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"