Color the full diff that pytest shows as a diff (#11530)

Related to #11520
This commit is contained in:
Benjamin Schubert 2023-10-24 12:42:21 +01:00 committed by GitHub
parent 667b9fd7fd
commit fbe3e29a55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 105 additions and 12 deletions

View File

@ -56,6 +56,7 @@ Barney Gale
Ben Gartner Ben Gartner
Ben Webb Ben Webb
Benjamin Peterson Benjamin Peterson
Benjamin Schubert
Bernard Pratz Bernard Pratz
Bo Wu Bo Wu
Bob Ippolito 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 shutil
import sys import sys
from typing import final from typing import final
from typing import Literal
from typing import Optional from typing import Optional
from typing import Sequence from typing import Sequence
from typing import TextIO from typing import TextIO
@ -193,15 +194,21 @@ class TerminalWriter:
for indent, new_line in zip(indents, new_lines): for indent, new_line in zip(indents, new_lines):
self.line(indent + new_line) self.line(indent + new_line)
def _highlight(self, source: str) -> str: def _highlight(
"""Highlight the given source code if we have markup support.""" self, source: str, lexer: Literal["diff", "python"] = "python"
) -> str:
"""Highlight the given source if we have markup support."""
from _pytest.config.exceptions import UsageError from _pytest.config.exceptions import UsageError
if not self.hasmarkup or not self.code_highlight: if not self.hasmarkup or not self.code_highlight:
return source return source
try: try:
from pygments.formatters.terminal import TerminalFormatter 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 from pygments import highlight
import pygments.util import pygments.util
except ImportError: except ImportError:
@ -210,7 +217,7 @@ class TerminalWriter:
try: try:
highlighted: str = highlight( highlighted: str = highlight(
source, source,
PythonLexer(), Lexer(),
TerminalFormatter( TerminalFormatter(
bg=os.getenv("PYTEST_THEME_MODE", "dark"), bg=os.getenv("PYTEST_THEME_MODE", "dark"),
style=os.getenv("PYTEST_THEME"), style=os.getenv("PYTEST_THEME"),

View File

@ -7,8 +7,10 @@ from typing import Any
from typing import Callable from typing import Callable
from typing import Iterable from typing import Iterable
from typing import List from typing import List
from typing import Literal
from typing import Mapping from typing import Mapping
from typing import Optional from typing import Optional
from typing import Protocol
from typing import Sequence from typing import Sequence
from unicodedata import normalize from unicodedata import normalize
@ -33,6 +35,11 @@ _assertion_pass: Optional[Callable[[int, str, str], None]] = None
_config: Optional[Config] = None _config: Optional[Config] = None
class _HighlightFunc(Protocol):
def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
"""Apply highlighting to the given source."""
def format_explanation(explanation: str) -> str: def format_explanation(explanation: str) -> str:
r"""Format an explanation. r"""Format an explanation.
@ -189,7 +196,8 @@ def assertrepr_compare(
explanation = None explanation = None
try: try:
if op == "==": if op == "==":
explanation = _compare_eq_any(left, right, verbose) writer = config.get_terminal_writer()
explanation = _compare_eq_any(left, right, writer._highlight, verbose)
elif op == "not in": elif op == "not in":
if istext(left) and istext(right): if istext(left) and istext(right):
explanation = _notin_text(left, right, verbose) explanation = _notin_text(left, right, verbose)
@ -225,7 +233,9 @@ def assertrepr_compare(
return [summary] + explanation return [summary] + explanation
def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]: def _compare_eq_any(
left: Any, right: Any, highlighter: _HighlightFunc, verbose: int = 0
) -> List[str]:
explanation = [] explanation = []
if istext(left) and istext(right): if istext(left) and istext(right):
explanation = _diff_text(left, right, verbose) explanation = _diff_text(left, right, verbose)
@ -245,7 +255,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 # field values, not the type or field names. But this branch
# intentionally only handles the same-type case, which was often # intentionally only handles the same-type case, which was often
# used in older code bases before dataclasses/attrs were available. # used in older code bases before dataclasses/attrs were available.
explanation = _compare_eq_cls(left, right, verbose) explanation = _compare_eq_cls(left, right, highlighter, verbose)
elif issequence(left) and issequence(right): elif issequence(left) and issequence(right):
explanation = _compare_eq_sequence(left, right, verbose) explanation = _compare_eq_sequence(left, right, verbose)
elif isset(left) and isset(right): elif isset(left) and isset(right):
@ -254,7 +264,7 @@ def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
explanation = _compare_eq_dict(left, right, verbose) explanation = _compare_eq_dict(left, right, verbose)
if isiterable(left) and isiterable(right): if isiterable(left) and isiterable(right):
expl = _compare_eq_iterable(left, right, verbose) expl = _compare_eq_iterable(left, right, highlighter, verbose)
explanation.extend(expl) explanation.extend(expl)
return explanation return explanation
@ -321,7 +331,10 @@ def _surrounding_parens_on_own_lines(lines: List[str]) -> None:
def _compare_eq_iterable( def _compare_eq_iterable(
left: Iterable[Any], right: Iterable[Any], verbose: int = 0 left: Iterable[Any],
right: Iterable[Any],
highligher: _HighlightFunc,
verbose: int = 0,
) -> List[str]: ) -> List[str]:
if verbose <= 0 and not running_on_ci(): if verbose <= 0 and not running_on_ci():
return ["Use -v to get more diff"] return ["Use -v to get more diff"]
@ -346,7 +359,13 @@ def _compare_eq_iterable(
# "right" is the expected base against which we compare "left", # "right" is the expected base against which we compare "left",
# see https://github.com/pytest-dev/pytest/issues/3333 # see https://github.com/pytest-dev/pytest/issues/3333
explanation.extend( explanation.extend(
line.rstrip() for line in difflib.ndiff(right_formatting, left_formatting) highligher(
"\n".join(
line.rstrip()
for line in difflib.ndiff(right_formatting, left_formatting)
),
lexer="diff",
).splitlines()
) )
return explanation return explanation
@ -496,7 +515,9 @@ def _compare_eq_dict(
return explanation return explanation
def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]: def _compare_eq_cls(
left: Any, right: Any, highlighter: _HighlightFunc, verbose: int
) -> List[str]:
if not has_default_eq(left): if not has_default_eq(left):
return [] return []
if isdatacls(left): if isdatacls(left):
@ -542,7 +563,9 @@ def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]:
] ]
explanation += [ explanation += [
indent + line indent + line
for line in _compare_eq_any(field_left, field_right, verbose) for line in _compare_eq_any(
field_left, field_right, highlighter, verbose
)
] ]
return explanation return explanation

View File

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

View File

@ -18,12 +18,19 @@ from _pytest.pytester import Pytester
def mock_config(verbose=0): def mock_config(verbose=0):
class TerminalWriter:
def _highlight(self, source, lexer):
return source
class Config: class Config:
def getoption(self, name): def getoption(self, name):
if name == "verbose": if name == "verbose":
return verbose return verbose
raise KeyError("Not mocked out: %s" % name) raise KeyError("Not mocked out: %s" % name)
def get_terminal_writer(self):
return TerminalWriter()
return Config() return Config()
@ -1784,3 +1791,48 @@ def test_reprcompare_verbose_long() -> None:
"{'v0': 0, 'v1': 1, 'v2': 12, 'v3': 3, 'v4': 4, 'v5': 5, " "{'v0': 0, 'v1': 1, 'v2': 12, 'v3': 3, 'v4': 4, 'v5': 5, "
"'v6': 6, 'v7': 7, 'v8': 8, 'v9': 9, 'v10': 10}" "'v6': 6, 'v7': 7, 'v8': 8, 'v9': 9, 'v10': 10}"
) )
@pytest.mark.parametrize("enable_colors", [True, False])
@pytest.mark.parametrize(
("test_code", "expected_lines"),
(
(
"""
def test():
assert [0, 1] == [0, 2]
""",
[
"{bold}{red}E {light-red}- [0, 2]{hl-reset}{endline}{reset}",
"{bold}{red}E {light-green}+ [0, 1]{hl-reset}{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 {light-gray} {hl-reset} {{{endline}{reset}",
"{bold}{red}E {light-gray} {hl-reset} 'number-is-1': 1,{endline}{reset}",
"{bold}{red}E {light-green}+ 'number-is-5': 5,{hl-reset}{endline}{reset}",
],
),
),
)
def test_comparisons_handle_colors(
pytester: Pytester, color_mapping, enable_colors, test_code, expected_lines
) -> None:
p = pytester.makepyfile(test_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), consecutive=False)