Color the full diff that pytest shows as a diff

Previously, it would get printed all in red, which makes it hard to read
and actually understand.

However, the diffs shown are standard and have a supported lexer in
Pygments. As such, use this to color the output when pygments is
available.
This commit is contained in:
Benjamin Schubert 2023-10-21 10:27:13 +01:00
parent cdddd6d695
commit eace6c4cad
6 changed files with 123 additions and 12 deletions

View File

@ -56,6 +56,7 @@ Barney Gale
Ben Gartner
Ben Webb
Benjamin Peterson
Benjamin Schubert
Bernard Pratz
Bo Wu
Bob Ippolito

View File

@ -0,0 +1 @@
* Improved very verbose diff output to color it as a diff instead of only red.

View File

@ -3,6 +3,7 @@ import os
import shutil
import sys
from typing import final
from typing import Literal
from typing import Optional
from typing import Sequence
from typing import TextIO
@ -193,15 +194,21 @@ class TerminalWriter:
for indent, new_line in zip(indents, new_lines):
self.line(indent + new_line)
def _highlight(self, source: str) -> str:
"""Highlight the given source code if we have markup support."""
def _highlight(
self, source: str, lexer: Literal["diff", "python"] = "python"
) -> str:
"""Highlight the given source if we have markup support."""
from _pytest.config.exceptions import UsageError
if not self.hasmarkup or not self.code_highlight:
return source
try:
from pygments.formatters.terminal import TerminalFormatter
from pygments.lexers.python import PythonLexer
if lexer == "python":
from pygments.lexers.python import PythonLexer as Lexer
elif lexer == "diff":
from pygments.lexers.diff import DiffLexer as Lexer
from pygments import highlight
import pygments.util
except ImportError:
@ -210,7 +217,7 @@ class TerminalWriter:
try:
highlighted: str = highlight(
source,
PythonLexer(),
Lexer(),
TerminalFormatter(
bg=os.getenv("PYTEST_THEME_MODE", "dark"),
style=os.getenv("PYTEST_THEME"),

View File

@ -17,6 +17,7 @@ from _pytest import outcomes
from _pytest._io.saferepr import _pformat_dispatch
from _pytest._io.saferepr import saferepr
from _pytest._io.saferepr import saferepr_unlimited
from _pytest._io.terminalwriter import TerminalWriter
from _pytest.config import Config
# The _reprcompare attribute on the util module is used by the new assertion
@ -189,7 +190,8 @@ def assertrepr_compare(
explanation = None
try:
if op == "==":
explanation = _compare_eq_any(left, right, verbose)
writer = config.get_terminal_writer()
explanation = _compare_eq_any(left, right, writer, verbose)
elif op == "not in":
if istext(left) and istext(right):
explanation = _notin_text(left, right, verbose)
@ -225,7 +227,9 @@ def assertrepr_compare(
return [summary] + explanation
def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
def _compare_eq_any(
left: Any, right: Any, writer: TerminalWriter, verbose: int = 0
) -> List[str]:
explanation = []
if istext(left) and istext(right):
explanation = _diff_text(left, right, verbose)
@ -245,7 +249,7 @@ def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
# field values, not the type or field names. But this branch
# intentionally only handles the same-type case, which was often
# used in older code bases before dataclasses/attrs were available.
explanation = _compare_eq_cls(left, right, verbose)
explanation = _compare_eq_cls(left, right, writer, verbose)
elif issequence(left) and issequence(right):
explanation = _compare_eq_sequence(left, right, verbose)
elif isset(left) and isset(right):
@ -254,7 +258,7 @@ def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
explanation = _compare_eq_dict(left, right, verbose)
if isiterable(left) and isiterable(right):
expl = _compare_eq_iterable(left, right, verbose)
expl = _compare_eq_iterable(left, right, writer, verbose)
explanation.extend(expl)
return explanation
@ -321,7 +325,10 @@ def _surrounding_parens_on_own_lines(lines: List[str]) -> None:
def _compare_eq_iterable(
left: Iterable[Any], right: Iterable[Any], verbose: int = 0
left: Iterable[Any],
right: Iterable[Any],
writer: TerminalWriter,
verbose: int = 0,
) -> List[str]:
if verbose <= 0 and not running_on_ci():
return ["Use -v to get more diff"]
@ -346,7 +353,13 @@ def _compare_eq_iterable(
# "right" is the expected base against which we compare "left",
# see https://github.com/pytest-dev/pytest/issues/3333
explanation.extend(
line.rstrip() for line in difflib.ndiff(right_formatting, left_formatting)
writer._highlight(
"\n".join(
line.rstrip()
for line in difflib.ndiff(right_formatting, left_formatting)
),
lexer="diff",
).splitlines()
)
return explanation
@ -496,7 +509,9 @@ def _compare_eq_dict(
return explanation
def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]:
def _compare_eq_cls(
left: Any, right: Any, writer: TerminalWriter, verbose: int
) -> List[str]:
if not has_default_eq(left):
return []
if isdatacls(left):
@ -542,7 +557,7 @@ def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]:
]
explanation += [
indent + line
for line in _compare_eq_any(field_left, field_right, verbose)
for line in _compare_eq_any(field_left, field_right, writer, verbose)
]
return explanation

View File

@ -160,6 +160,9 @@ def color_mapping():
"red": "\x1b[31m",
"green": "\x1b[32m",
"yellow": "\x1b[33m",
"light-gray": "\x1b[90m",
"light-red": "\x1b[91m",
"light-green": "\x1b[92m",
"bold": "\x1b[1m",
"reset": "\x1b[0m",
"kw": "\x1b[94m",
@ -171,6 +174,7 @@ def color_mapping():
"endline": "\x1b[90m\x1b[39;49;00m",
}
RE_COLORS = {k: re.escape(v) for k, v in COLORS.items()}
NO_COLORS = {k: "" for k in COLORS.keys()}
@classmethod
def format(cls, lines: List[str]) -> List[str]:
@ -187,6 +191,11 @@ def color_mapping():
"""Replace color names for use with LineMatcher.re_match_lines"""
return [line.format(**cls.RE_COLORS) for line in lines]
@classmethod
def strip_colors(cls, lines: List[str]) -> List[str]:
"""Entirely remove every color code"""
return [line.format(**cls.NO_COLORS) for line in lines]
return ColorMapping

View File

@ -18,12 +18,19 @@ from _pytest.pytester import Pytester
def mock_config(verbose=0):
class TerminalWriter:
def _highlight(self, source, lexer):
return source
class Config:
def getoption(self, name):
if name == "verbose":
return verbose
raise KeyError("Not mocked out: %s" % name)
def get_terminal_writer(self):
return TerminalWriter()
return Config()
@ -1784,3 +1791,74 @@ def test_reprcompare_verbose_long() -> None:
"{'v0': 0, 'v1': 1, 'v2': 12, 'v3': 3, 'v4': 4, 'v5': 5, "
"'v6': 6, 'v7': 7, 'v8': 8, 'v9': 9, 'v10': 10}"
)
@pytest.mark.parametrize("enable_colors", [True, False])
@pytest.mark.parametrize(
("code", "expected_lines"),
(
(
"""
def test():
assert [0, 1] == [0, 2]
""",
[
"{bold}{red}E assert [0, 1] == [0, 2]{reset}",
"{bold}{red}E At index 1 diff: 1 != 2{reset}",
"{bold}{red}E Full diff:{reset}",
"{bold}{red}E {light-red}- [0, 2]{hl-reset}{endline}{reset}",
"{bold}{red}E ? ^{endline}{reset}",
"{bold}{red}E {light-green}+ [0, 1]{hl-reset}{endline}{reset}",
"{bold}{red}E ? ^{endline}{reset}",
],
),
(
"""
def test():
assert {f"number-is-{i}": i for i in range(1, 6)} == {
f"number-is-{i}": i for i in range(5)
}
""",
[
(
"{bold}{red}E AssertionError: assert "
"{{'number-is-1': 1, 'number-is-2': 2, 'number-is-3': 3, 'number-is-4': 4, 'number-is-5': 5}}"
" == {{'number-is-0': 0, 'number-is-1': 1, 'number-is-2': 2, 'number-is-3': 3, 'number-is-4': 4}}"
"{reset}"
),
"{bold}{red}E Common items:{reset}",
(
"{bold}{red}E "
"{{'number-is-1': 1, 'number-is-2': 2, 'number-is-3': 3, 'number-is-4': 4}}{reset}"
),
"{bold}{red}E Left contains 1 more item:{reset}",
"{bold}{red}E {{'number-is-5': 5}}{reset}",
"{bold}{red}E Right contains 1 more item:{reset}",
"{bold}{red}E {{'number-is-0': 0}}{reset}",
"{bold}{red}E Full diff:{reset}",
"{bold}{red}E {light-gray} {hl-reset} {{{endline}{reset}",
"{bold}{red}E {light-red}- 'number-is-0': 0,{hl-reset}{endline}{reset}",
"{bold}{red}E {light-gray} {hl-reset} 'number-is-1': 1,{endline}{reset}",
"{bold}{red}E {light-gray} {hl-reset} 'number-is-2': 2,{endline}{reset}",
"{bold}{red}E {light-gray} {hl-reset} 'number-is-3': 3,{endline}{reset}",
"{bold}{red}E {light-gray} {hl-reset} 'number-is-4': 4,{endline}{reset}",
"{bold}{red}E {light-green}+ 'number-is-5': 5,{hl-reset}{endline}{reset}",
"{bold}{red}E {light-gray} {hl-reset} }}{endline}{reset}",
],
),
),
)
def test_comparisons_handle_colors(
pytester: Pytester, color_mapping, enable_colors, code, expected_lines
) -> None:
p = pytester.makepyfile(code)
result = pytester.runpytest(
f"--color={'yes' if enable_colors else 'no'}", "-vv", str(p)
)
formatter = (
color_mapping.format_for_fnmatch
if enable_colors
else color_mapping.strip_colors
)
result.stdout.fnmatch_lines(formatter(expected_lines))