Skip to content

Commit fbe3e29

Browse files
Color the full diff that pytest shows as a diff (#11530)
Related to #11520
1 parent 667b9fd commit fbe3e29

File tree

6 files changed

+105
-12
lines changed

6 files changed

+105
-12
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Barney Gale
5656
Ben Gartner
5757
Ben Webb
5858
Benjamin Peterson
59+
Benjamin Schubert
5960
Bernard Pratz
6061
Bo Wu
6162
Bob Ippolito

changelog/11520.improvement.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improved very verbose diff output to color it as a diff instead of only red.

src/_pytest/_io/terminalwriter.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import shutil
44
import sys
55
from typing import final
6+
from typing import Literal
67
from typing import Optional
78
from typing import Sequence
89
from typing import TextIO
@@ -193,15 +194,21 @@ def _write_source(self, lines: Sequence[str], indents: Sequence[str] = ()) -> No
193194
for indent, new_line in zip(indents, new_lines):
194195
self.line(indent + new_line)
195196

196-
def _highlight(self, source: str) -> str:
197-
"""Highlight the given source code if we have markup support."""
197+
def _highlight(
198+
self, source: str, lexer: Literal["diff", "python"] = "python"
199+
) -> str:
200+
"""Highlight the given source if we have markup support."""
198201
from _pytest.config.exceptions import UsageError
199202

200203
if not self.hasmarkup or not self.code_highlight:
201204
return source
202205
try:
203206
from pygments.formatters.terminal import TerminalFormatter
204-
from pygments.lexers.python import PythonLexer
207+
208+
if lexer == "python":
209+
from pygments.lexers.python import PythonLexer as Lexer
210+
elif lexer == "diff":
211+
from pygments.lexers.diff import DiffLexer as Lexer
205212
from pygments import highlight
206213
import pygments.util
207214
except ImportError:
@@ -210,7 +217,7 @@ def _highlight(self, source: str) -> str:
210217
try:
211218
highlighted: str = highlight(
212219
source,
213-
PythonLexer(),
220+
Lexer(),
214221
TerminalFormatter(
215222
bg=os.getenv("PYTEST_THEME_MODE", "dark"),
216223
style=os.getenv("PYTEST_THEME"),

src/_pytest/assertion/util.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
from typing import Callable
88
from typing import Iterable
99
from typing import List
10+
from typing import Literal
1011
from typing import Mapping
1112
from typing import Optional
13+
from typing import Protocol
1214
from typing import Sequence
1315
from unicodedata import normalize
1416

@@ -33,6 +35,11 @@
3335
_config: Optional[Config] = None
3436

3537

38+
class _HighlightFunc(Protocol):
39+
def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
40+
"""Apply highlighting to the given source."""
41+
42+
3643
def format_explanation(explanation: str) -> str:
3744
r"""Format an explanation.
3845
@@ -189,7 +196,8 @@ def assertrepr_compare(
189196
explanation = None
190197
try:
191198
if op == "==":
192-
explanation = _compare_eq_any(left, right, verbose)
199+
writer = config.get_terminal_writer()
200+
explanation = _compare_eq_any(left, right, writer._highlight, verbose)
193201
elif op == "not in":
194202
if istext(left) and istext(right):
195203
explanation = _notin_text(left, right, verbose)
@@ -225,7 +233,9 @@ def assertrepr_compare(
225233
return [summary] + explanation
226234

227235

228-
def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
236+
def _compare_eq_any(
237+
left: Any, right: Any, highlighter: _HighlightFunc, verbose: int = 0
238+
) -> List[str]:
229239
explanation = []
230240
if istext(left) and istext(right):
231241
explanation = _diff_text(left, right, verbose)
@@ -245,7 +255,7 @@ def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
245255
# field values, not the type or field names. But this branch
246256
# intentionally only handles the same-type case, which was often
247257
# used in older code bases before dataclasses/attrs were available.
248-
explanation = _compare_eq_cls(left, right, verbose)
258+
explanation = _compare_eq_cls(left, right, highlighter, verbose)
249259
elif issequence(left) and issequence(right):
250260
explanation = _compare_eq_sequence(left, right, verbose)
251261
elif isset(left) and isset(right):
@@ -254,7 +264,7 @@ def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
254264
explanation = _compare_eq_dict(left, right, verbose)
255265

256266
if isiterable(left) and isiterable(right):
257-
expl = _compare_eq_iterable(left, right, verbose)
267+
expl = _compare_eq_iterable(left, right, highlighter, verbose)
258268
explanation.extend(expl)
259269

260270
return explanation
@@ -321,7 +331,10 @@ def _surrounding_parens_on_own_lines(lines: List[str]) -> None:
321331

322332

323333
def _compare_eq_iterable(
324-
left: Iterable[Any], right: Iterable[Any], verbose: int = 0
334+
left: Iterable[Any],
335+
right: Iterable[Any],
336+
highligher: _HighlightFunc,
337+
verbose: int = 0,
325338
) -> List[str]:
326339
if verbose <= 0 and not running_on_ci():
327340
return ["Use -v to get more diff"]
@@ -346,7 +359,13 @@ def _compare_eq_iterable(
346359
# "right" is the expected base against which we compare "left",
347360
# see https://github.com/pytest-dev/pytest/issues/3333
348361
explanation.extend(
349-
line.rstrip() for line in difflib.ndiff(right_formatting, left_formatting)
362+
highligher(
363+
"\n".join(
364+
line.rstrip()
365+
for line in difflib.ndiff(right_formatting, left_formatting)
366+
),
367+
lexer="diff",
368+
).splitlines()
350369
)
351370
return explanation
352371

@@ -496,7 +515,9 @@ def _compare_eq_dict(
496515
return explanation
497516

498517

499-
def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]:
518+
def _compare_eq_cls(
519+
left: Any, right: Any, highlighter: _HighlightFunc, verbose: int
520+
) -> List[str]:
500521
if not has_default_eq(left):
501522
return []
502523
if isdatacls(left):
@@ -542,7 +563,9 @@ def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]:
542563
]
543564
explanation += [
544565
indent + line
545-
for line in _compare_eq_any(field_left, field_right, verbose)
566+
for line in _compare_eq_any(
567+
field_left, field_right, highlighter, verbose
568+
)
546569
]
547570
return explanation
548571

testing/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ class ColorMapping:
160160
"red": "\x1b[31m",
161161
"green": "\x1b[32m",
162162
"yellow": "\x1b[33m",
163+
"light-gray": "\x1b[90m",
164+
"light-red": "\x1b[91m",
165+
"light-green": "\x1b[92m",
163166
"bold": "\x1b[1m",
164167
"reset": "\x1b[0m",
165168
"kw": "\x1b[94m",
@@ -171,6 +174,7 @@ class ColorMapping:
171174
"endline": "\x1b[90m\x1b[39;49;00m",
172175
}
173176
RE_COLORS = {k: re.escape(v) for k, v in COLORS.items()}
177+
NO_COLORS = {k: "" for k in COLORS.keys()}
174178

175179
@classmethod
176180
def format(cls, lines: List[str]) -> List[str]:
@@ -187,6 +191,11 @@ def format_for_rematch(cls, lines: List[str]) -> List[str]:
187191
"""Replace color names for use with LineMatcher.re_match_lines"""
188192
return [line.format(**cls.RE_COLORS) for line in lines]
189193

194+
@classmethod
195+
def strip_colors(cls, lines: List[str]) -> List[str]:
196+
"""Entirely remove every color code"""
197+
return [line.format(**cls.NO_COLORS) for line in lines]
198+
190199
return ColorMapping
191200

192201

testing/test_assertion.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,19 @@
1818

1919

2020
def mock_config(verbose=0):
21+
class TerminalWriter:
22+
def _highlight(self, source, lexer):
23+
return source
24+
2125
class Config:
2226
def getoption(self, name):
2327
if name == "verbose":
2428
return verbose
2529
raise KeyError("Not mocked out: %s" % name)
2630

31+
def get_terminal_writer(self):
32+
return TerminalWriter()
33+
2734
return Config()
2835

2936

@@ -1784,3 +1791,48 @@ def test_reprcompare_verbose_long() -> None:
17841791
"{'v0': 0, 'v1': 1, 'v2': 12, 'v3': 3, 'v4': 4, 'v5': 5, "
17851792
"'v6': 6, 'v7': 7, 'v8': 8, 'v9': 9, 'v10': 10}"
17861793
)
1794+
1795+
1796+
@pytest.mark.parametrize("enable_colors", [True, False])
1797+
@pytest.mark.parametrize(
1798+
("test_code", "expected_lines"),
1799+
(
1800+
(
1801+
"""
1802+
def test():
1803+
assert [0, 1] == [0, 2]
1804+
""",
1805+
[
1806+
"{bold}{red}E {light-red}- [0, 2]{hl-reset}{endline}{reset}",
1807+
"{bold}{red}E {light-green}+ [0, 1]{hl-reset}{endline}{reset}",
1808+
],
1809+
),
1810+
(
1811+
"""
1812+
def test():
1813+
assert {f"number-is-{i}": i for i in range(1, 6)} == {
1814+
f"number-is-{i}": i for i in range(5)
1815+
}
1816+
""",
1817+
[
1818+
"{bold}{red}E {light-gray} {hl-reset} {{{endline}{reset}",
1819+
"{bold}{red}E {light-gray} {hl-reset} 'number-is-1': 1,{endline}{reset}",
1820+
"{bold}{red}E {light-green}+ 'number-is-5': 5,{hl-reset}{endline}{reset}",
1821+
],
1822+
),
1823+
),
1824+
)
1825+
def test_comparisons_handle_colors(
1826+
pytester: Pytester, color_mapping, enable_colors, test_code, expected_lines
1827+
) -> None:
1828+
p = pytester.makepyfile(test_code)
1829+
result = pytester.runpytest(
1830+
f"--color={'yes' if enable_colors else 'no'}", "-vv", str(p)
1831+
)
1832+
formatter = (
1833+
color_mapping.format_for_fnmatch
1834+
if enable_colors
1835+
else color_mapping.strip_colors
1836+
)
1837+
1838+
result.stdout.fnmatch_lines(formatter(expected_lines), consecutive=False)

0 commit comments

Comments
 (0)