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"