Color the full diff that pytest shows as a diff (#11530)
Related to #11520
This commit is contained in:
parent
667b9fd7fd
commit
fbe3e29a55
1
AUTHORS
1
AUTHORS
|
@ -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
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Improved very verbose diff output to color it as a diff instead of only red.
|
|
@ -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"),
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue