diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 67f8d46185e..5a746da0f80 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -1,6 +1,8 @@ """Utilities for assertion debugging""" import collections.abc +import itertools import pprint +import re from typing import AbstractSet from typing import Any from typing import Callable @@ -193,6 +195,7 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: characters which are identical to keep the diff minimal. """ from difflib import ndiff + from wcwidth import wcwidth explanation = [] # type: List[str] @@ -220,15 +223,46 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: ] left = left[:-i] right = right[:-i] - keepends = True if left.isspace() or right.isspace(): left = repr(str(left)) right = repr(str(right)) explanation += ["Strings contain only whitespace, escaping them using repr()"] - explanation += [ - line.strip("\n") - for line in ndiff(left.splitlines(keepends), right.splitlines(keepends)) - ] + + left_split = len(left) and re.split("(\r?\n)", left) or [] + left_lines = left_split[::2] + right_split = len(right) and re.split("(\r?\n)", right) or [] + right_lines = right_split[::2] + + if any( + wcwidth(ch) <= 0 + for ch in [ch for lines in left_lines + right_lines for ch in lines] + ): + left_lines = [repr(x) for x in left_lines] + right_lines = [repr(x) for x in right_lines] + explanation += [ + "NOTE: Strings contain non-printable characters. Escaping them using repr()." + ] + else: + max_split = min(len(left_lines), len(right_lines)) + 1 + left_ends = left_split[1:max_split:2] + right_ends = right_split[1:max_split:2] + if left_ends != right_ends: + explanation += [ + "NOTE: Strings contain different line-endings. Escaping them using repr()." + ] + for idx, (left_line, right_line, left_end, right_end) in enumerate( + itertools.zip_longest( + left_lines, right_lines, left_ends, right_ends, fillvalue=None + ) + ): + if left_end == right_end: + continue + if left_end is not None: + left_lines[idx] += repr(left_end)[1:-1] + if right_end is not None: + right_lines[idx] += repr(right_end)[1:-1] + + explanation += [line.strip("\n") for line in ndiff(left_lines, right_lines)] return explanation diff --git a/testing/test_assertion.py b/testing/test_assertion.py index e975a3fea2b..d5e28070815 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -334,8 +334,14 @@ def test_multiline_text_diff(self): left = "foo\nspam\nbar" right = "foo\neggs\nbar" diff = callequal(left, right) - assert "- spam" in diff - assert "+ eggs" in diff + assert diff == [ + r"'foo\nspam\nbar' == 'foo\neggs\nbar'", + # r"NOTE: Strings contain different line-endings. Escaping them using repr().", + r" foo", + r"- spam", + r"+ eggs", + r" bar", + ] def test_bytes_diff_normal(self): """Check special handling for bytes diff (#5260)""" @@ -1007,15 +1013,36 @@ def test_many_lines(): # without -vv, truncate the message showing a few diff lines only result.stdout.fnmatch_lines( [ - "*- 1*", - "*- 3*", - "*- 5*", - "*truncated (%d lines hidden)*use*-vv*" % expected_truncated_lines, + r"> assert a == b", + r"E AssertionError: assert '000000000000...6666666666666' == '000000000000...6666666666666'", + r"E Skipping 91 identical leading characters in diff, use -v to show", + r"E 000000000", + r"E - 1*", + r"E 2*", + r"E - 3*", + r"E 4*", + r"E ", + r"*truncated (%d lines hidden)*use*-vv*" % expected_truncated_lines, ] ) result = testdir.runpytest("-vv") - result.stdout.fnmatch_lines(["* 6*"]) + result.stdout.fnmatch_lines( + [ + r"> assert a == b", + r"E AssertionError: assert ('0*0\n'\n * '5*5\n'\n '6*6')" + r" == ('0*0\n'\n '2*2\n'\n '4*4\n'\n '6*6')", + r"E 0*0", + r"E - 1*1", + r"E 2*2", + r"E - 3*3", + r"E 4*4", + r"E - 5*5", + r"E 6*6", + r"", + ], + consecutive=True, + ) monkeypatch.setenv("CI", "1") result = testdir.runpytest() @@ -1068,6 +1095,17 @@ def test_reprcompare_whitespaces(): ] +def test_reprcompare_zerowidth_and_non_printable(): + assert callequal("\x00\x1b[31mred", "\x1b[31mgreen") == [ + r"'\x00\x1b[31mred' == '\x1b[31mgreen'", + r"NOTE: Strings contain non-printable characters. Escaping them using repr().", + r"- '\x00\x1b[31mred'", + r"? ---- ^", + r"+ '\x1b[31mgreen'", + r"? + ^^", + ] + + def test_pytest_assertrepr_compare_integration(testdir): testdir.makepyfile( """ @@ -1311,13 +1349,57 @@ def test_diff(): result.stdout.fnmatch_lines( r""" *assert 'asdf' == 'asdf\n' + E AssertionError: assert 'asdf' == 'asdf\n' + E NOTE: Strings contain different line-endings. Escaping them using repr(). * - asdf - * + asdf - * ? + + * + asdf\n + * ? ++ """ ) +def test_diff_different_line_endings(): + assert callequal("asdf\n", "asdf", verbose=2) == [ + r"'asdf\n' == 'asdf'", + r"NOTE: Strings contain different line-endings. Escaping them using repr().", + r"- asdf\n", + r"? --", + r"+ asdf", + r"- ", + ] + + assert callequal("line1\r\nline2", "line1\nline2", verbose=2) == [ + r"'line1\r\nline2' == 'line1\nline2'", + r"NOTE: Strings contain different line-endings. Escaping them using repr().", + r"- line1\r\n", + r"? --", + r"+ line1\n", + r" line2", + ] + + # Only '\r' is considered non-printable + assert callequal("line1\r\nline2", "line1\nline2\r", verbose=2) == [ + r"'line1\r\nline2' == 'line1\nline2\r'", + r"NOTE: Strings contain non-printable characters. Escaping them using repr().", + r" 'line1'", + r"- 'line2'", + r"+ 'line2\r'", + r"? ++", + ] + + # More on left. + assert callequal("line1\r\nline2\r\nline3\r\n", "line1\nline2", verbose=2) == [ + r"'line1\r\nline2\r\nline3\r\n' == 'line1\nline2'", + r"NOTE: Strings contain different line-endings. Escaping them using repr().", + r"- line1\r\n", + r"? --", + r"+ line1\n", + r" line2", + r"- line3", + r"- ", + ] + + @pytest.mark.filterwarnings("default") def test_assert_tuple_warning(testdir): msg = "assertion is always true"