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:
parent
cdddd6d695
commit
eace6c4cad
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"),
|
||||||
|
|
|
@ -17,6 +17,7 @@ from _pytest import outcomes
|
||||||
from _pytest._io.saferepr import _pformat_dispatch
|
from _pytest._io.saferepr import _pformat_dispatch
|
||||||
from _pytest._io.saferepr import saferepr
|
from _pytest._io.saferepr import saferepr
|
||||||
from _pytest._io.saferepr import saferepr_unlimited
|
from _pytest._io.saferepr import saferepr_unlimited
|
||||||
|
from _pytest._io.terminalwriter import TerminalWriter
|
||||||
from _pytest.config import Config
|
from _pytest.config import Config
|
||||||
|
|
||||||
# The _reprcompare attribute on the util module is used by the new assertion
|
# The _reprcompare attribute on the util module is used by the new assertion
|
||||||
|
@ -189,7 +190,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, 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 +227,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, writer: TerminalWriter, 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 +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
|
# 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, writer, 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 +258,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, writer, verbose)
|
||||||
explanation.extend(expl)
|
explanation.extend(expl)
|
||||||
|
|
||||||
return explanation
|
return explanation
|
||||||
|
@ -321,7 +325,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],
|
||||||
|
writer: TerminalWriter,
|
||||||
|
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 +353,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)
|
writer._highlight(
|
||||||
|
"\n".join(
|
||||||
|
line.rstrip()
|
||||||
|
for line in difflib.ndiff(right_formatting, left_formatting)
|
||||||
|
),
|
||||||
|
lexer="diff",
|
||||||
|
).splitlines()
|
||||||
)
|
)
|
||||||
return explanation
|
return explanation
|
||||||
|
|
||||||
|
@ -496,7 +509,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, writer: TerminalWriter, 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 +557,7 @@ 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, writer, 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,74 @@ 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(
|
||||||
|
("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))
|
||||||
|
|
Loading…
Reference in New Issue