diff --git a/changelog/6757.improvement.rst b/changelog/6757.improvement.rst new file mode 100644 index 00000000000..ca9aa4145c7 --- /dev/null +++ b/changelog/6757.improvement.rst @@ -0,0 +1,2 @@ +When displaying a comparison between two texts in an assertion failure, if the texts differ significantly (more than 30% of lines and more than 4 lines), they are now displayed one after the other rather than in an interleaved diff (+/-) view. +In such cases, a diff is often incomprehensible, and it is easier to see the difference in full. diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index c2f0431d479..3ecb7a69eef 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -1,6 +1,7 @@ """Utilities for assertion debugging""" import collections.abc import pprint +from difflib import SequenceMatcher from typing import AbstractSet from typing import Any from typing import Callable @@ -131,6 +132,8 @@ def isiterable(obj: Any) -> bool: def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[str]]: """Return specialised explanations for some operators/operands""" verbose = config.getoption("verbose") + screen_width = config.get_terminal_reporter().screen_width + if verbose > 1: left_repr = safeformat(left) right_repr = safeformat(right) @@ -148,10 +151,10 @@ def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[ explanation = None try: if op == "==": - explanation = _compare_eq_any(left, right, verbose) + explanation = _compare_eq_any(left, right, screen_width, verbose) elif op == "not in": if istext(left) and istext(right): - explanation = _notin_text(left, right, verbose) + explanation = _notin_text(left, right, screen_width, verbose) except outcomes.Exit: raise except Exception: @@ -168,29 +171,7 @@ def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[ return [summary] + explanation -def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]: - explanation = [] # type: List[str] - if istext(left) and istext(right): - explanation = _diff_text(left, right, verbose) - else: - if issequence(left) and issequence(right): - explanation = _compare_eq_sequence(left, right, verbose) - elif isset(left) and isset(right): - explanation = _compare_eq_set(left, right, verbose) - elif isdict(left) and isdict(right): - explanation = _compare_eq_dict(left, right, verbose) - elif type(left) == type(right) and (isdatacls(left) or isattrs(left)): - type_fn = (isdatacls, isattrs) - explanation = _compare_eq_cls(left, right, verbose, type_fn) - elif verbose > 0: - explanation = _compare_eq_verbose(left, right) - if isiterable(left) and isiterable(right): - expl = _compare_eq_iterable(left, right, verbose) - explanation.extend(expl) - return explanation - - -def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: +def _diff_text(left: str, right: str, screen_width: int, verbose: int = 0) -> List[str]: """Return the explanation for the diff between text. Unless --verbose is used this will skip leading and trailing @@ -231,10 +212,62 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: explanation += ["Strings contain only whitespace, escaping them using repr()"] # "right" is the expected base against which we compare "left", # see https://github.com/pytest-dev/pytest/issues/3333 - explanation += [ - line.strip("\n") - for line in ndiff(right.splitlines(keepends), left.splitlines(keepends)) - ] + + stripped_left = "".join(left.splitlines()) + stripped_right = "".join(right.splitlines()) + s = SequenceMatcher(None, stripped_left, stripped_right) + + nlines_left = left.count("\n") + nlines_right = right.count("\n") + + if s.ratio() < 0.30 or max(nlines_left, nlines_right) < 5: + explanation += [ + line.strip("\n") + for line in ndiff(right.splitlines(keepends), left.splitlines(keepends)) + ] + else: + + def _text_header(header: str, screen_width: int, margin: int = 10) -> List[str]: + hlength = len(header) + lines = [ + "=" * int((screen_width - hlength - margin) / 2) + + header + + "=" * int((screen_width - hlength - margin) / 2) + ] + if screen_width % 2 != 0: + lines[-1] += "=" + + return lines + + explanation += _text_header(" ACTUAL ", screen_width) + explanation += list(left.split("\n")) + explanation += _text_header(" EXPECTED ", screen_width) + explanation += list(right.split("\n")) + + return explanation + + +def _compare_eq_any( + left: Any, right: Any, screen_width: int, verbose: int = 0 +) -> List[str]: + explanation = [] # type: List[str] + if istext(left) and istext(right): + explanation = _diff_text(left, right, screen_width, verbose) + else: + if issequence(left) and issequence(right): + explanation = _compare_eq_sequence(left, right, verbose) + elif isset(left) and isset(right): + explanation = _compare_eq_set(left, right, verbose) + elif isdict(left) and isdict(right): + explanation = _compare_eq_dict(left, right, verbose) + elif type(left) == type(right) and (isdatacls(left) or isattrs(left)): + type_fn = (isdatacls, isattrs) + explanation = _compare_eq_cls(left, right, screen_width, verbose, type_fn) + elif verbose > 0: + explanation = _compare_eq_verbose(left, right) + if isiterable(left) and isiterable(right): + expl = _compare_eq_iterable(left, right, verbose) + explanation.extend(expl) return explanation @@ -411,6 +444,7 @@ def _compare_eq_dict( def _compare_eq_cls( left: Any, right: Any, + screen_width: int, verbose: int, type_fns: Tuple[Callable[[Any], bool], Callable[[Any], bool]], ) -> List[str]: @@ -445,17 +479,19 @@ def _compare_eq_cls( ("%s: %r != %r") % (field, getattr(left, field), getattr(right, field)), "", "Drill down into differing attribute %s:" % field, - *_compare_eq_any(getattr(left, field), getattr(right, field), verbose), + *_compare_eq_any( + getattr(left, field), getattr(right, field), screen_width, verbose + ), ] return explanation -def _notin_text(term: str, text: str, verbose: int = 0) -> List[str]: +def _notin_text(term: str, text: str, screen_width: int, verbose: int = 0) -> List[str]: index = text.find(term) head = text[:index] tail = text[index + len(term) :] correct_text = head + tail - diff = _diff_text(text, correct_text, verbose) + diff = _diff_text(text, correct_text, screen_width, verbose) newdiff = ["%s is contained here:" % saferepr(term, maxsize=42)] for line in diff: if line.startswith("Skipping"): diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c94ea2a9319..f12073f3fc6 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -867,6 +867,9 @@ def _ensure_unconfigure(self) -> None: def get_terminal_writer(self): return self.pluginmanager.get_plugin("terminalreporter")._tw + def get_terminal_reporter(self): + return self.pluginmanager.get_plugin("terminalreporter") + def pytest_cmdline_parse( self, pluginmanager: PytestPluginManager, args: List[str] ) -> object: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 9c2665fb818..fb6e8838f46 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -365,6 +365,12 @@ def showfspath(self, value: Optional[bool]) -> None: def showlongtestinfo(self) -> bool: return self.verbosity > 0 + @property + def screen_width(self) -> int: + if self._screen_width is None: + return 80 + return self._screen_width + def hasopt(self, char: str) -> bool: char = {"xfailed": "x", "skipped": "s"}.get(char, char) return char in self.reportchars diff --git a/testing/test_assertion.py b/testing/test_assertion.py index ae5e75dbfbb..2b65473b3fb 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -22,6 +22,14 @@ def getoption(self, name): return verbose raise KeyError("Not mocked out: %s" % name) + def get_terminal_reporter(self): + class Terminal: + @property + def screen_width(self): + return 80 + + return Terminal() + return Config() @@ -360,6 +368,59 @@ def test_multiline_text_diff(self) -> None: assert "- eggs" in diff assert "+ spam" in diff + def test_multiline_long_text_diff(self): + left = r""" + ________________________________________ + / You have Egyptian flu: you're going to \ + \ be a mummy. / + ---------------------------------------- + \ ^__^ + \ (oo)\_______ + (__)\ )\/\ + ||----w | + || || + """ + + right = r""" + ________________________________________ + / You have Egyptian flu: you're going to \ + \ be a mummy. / + ---------------------------------------- + \ ^__^ + \ (oo)\_______ + (__)\ )\/\ + ||----w | + || || + """ + diff = callequal(left, right) + assert diff is not None + assert diff[1:] == [ + "=============================== ACTUAL ===============================", + "", + " ________________________________________", + " / You have Egyptian flu: you're going to \\", + " \\ be a mummy. /", + " ----------------------------------------", + " \\ ^__^", + " \\ (oo)\\_______", + " (__)\\ )\\/\\", + " ||----w |", + " || ||", + " ", + "============================== EXPECTED ==============================", + "", + " ________________________________________", + " / You have Egyptian flu: you're going to \\", + " \\ be a mummy. /", + " ----------------------------------------", + " \\ ^__^", + " \\ (oo)\\_______", + " (__)\\ )\\/\\", + " ||----w |", + " || ||", + " ", + ] + def test_bytes_diff_normal(self): """Check special handling for bytes diff (#5260)""" diff = callequal(b"spam", b"eggs")