Use code highlighting if pygments is installed (#6658)
* Use code highlighting if pygments is installed * Use colorama constants instead of bare ascii codes Could not find the exact equivalent of 'hl-reset' code using colorama constants though. * Refactor ASCII color handling into a fixture * Revert back to using explicit color codes * In Python 3.5 skip rest of tests that require ordered markup in colored output
This commit is contained in:
parent
3b582858f3
commit
4209ad6fca
|
@ -0,0 +1,4 @@
|
|||
Code is now highlighted in tracebacks when ``pygments`` is installed.
|
||||
|
||||
Users are encouraged to install ``pygments`` into their environment and provide feedback, because
|
||||
the plan is to make ``pygments`` a regular dependency in the future.
|
|
@ -1039,21 +1039,58 @@ class ReprEntry(TerminalRepr):
|
|||
self.reprfileloc = filelocrepr
|
||||
self.style = style
|
||||
|
||||
def _write_entry_lines(self, tw: TerminalWriter) -> None:
|
||||
"""Writes the source code portions of a list of traceback entries with syntax highlighting.
|
||||
|
||||
Usually entries are lines like these:
|
||||
|
||||
" x = 1"
|
||||
"> assert x == 2"
|
||||
"E assert 1 == 2"
|
||||
|
||||
This function takes care of rendering the "source" portions of it (the lines without
|
||||
the "E" prefix) using syntax highlighting, taking care to not highlighting the ">"
|
||||
character, as doing so might break line continuations.
|
||||
"""
|
||||
|
||||
indent_size = 4
|
||||
|
||||
def is_fail(line):
|
||||
return line.startswith("{} ".format(FormattedExcinfo.fail_marker))
|
||||
|
||||
if not self.lines:
|
||||
return
|
||||
|
||||
# separate indents and source lines that are not failures: we want to
|
||||
# highlight the code but not the indentation, which may contain markers
|
||||
# such as "> assert 0"
|
||||
indents = []
|
||||
source_lines = []
|
||||
for line in self.lines:
|
||||
if not is_fail(line):
|
||||
indents.append(line[:indent_size])
|
||||
source_lines.append(line[indent_size:])
|
||||
|
||||
tw._write_source(source_lines, indents)
|
||||
|
||||
# failure lines are always completely red and bold
|
||||
for line in (x for x in self.lines if is_fail(x)):
|
||||
tw.line(line, bold=True, red=True)
|
||||
|
||||
def toterminal(self, tw: TerminalWriter) -> None:
|
||||
if self.style == "short":
|
||||
assert self.reprfileloc is not None
|
||||
self.reprfileloc.toterminal(tw)
|
||||
for line in self.lines:
|
||||
red = line.startswith("E ")
|
||||
tw.line(line, bold=True, red=red)
|
||||
self._write_entry_lines(tw)
|
||||
if self.reprlocals:
|
||||
self.reprlocals.toterminal(tw, indent=" " * 8)
|
||||
return
|
||||
|
||||
if self.reprfuncargs:
|
||||
self.reprfuncargs.toterminal(tw)
|
||||
for line in self.lines:
|
||||
red = line.startswith("E ")
|
||||
tw.line(line, bold=True, red=red)
|
||||
|
||||
self._write_entry_lines(tw)
|
||||
|
||||
if self.reprlocals:
|
||||
tw.line("")
|
||||
self.reprlocals.toterminal(tw)
|
||||
|
|
|
@ -1,3 +1,39 @@
|
|||
# Reexport TerminalWriter from here instead of py, to make it easier to
|
||||
# extend or swap our own implementation in the future.
|
||||
from py.io import TerminalWriter as TerminalWriter # noqa: F401
|
||||
from typing import List
|
||||
from typing import Sequence
|
||||
|
||||
from py.io import TerminalWriter as BaseTerminalWriter # noqa: F401
|
||||
|
||||
|
||||
class TerminalWriter(BaseTerminalWriter):
|
||||
def _write_source(self, lines: List[str], indents: Sequence[str] = ()) -> None:
|
||||
"""Write lines of source code possibly highlighted.
|
||||
|
||||
Keeping this private for now because the API is clunky. We should discuss how
|
||||
to evolve the terminal writer so we can have more precise color support, for example
|
||||
being able to write part of a line in one color and the rest in another, and so on.
|
||||
"""
|
||||
if indents and len(indents) != len(lines):
|
||||
raise ValueError(
|
||||
"indents size ({}) should have same size as lines ({})".format(
|
||||
len(indents), len(lines)
|
||||
)
|
||||
)
|
||||
if not indents:
|
||||
indents = [""] * len(lines)
|
||||
source = "\n".join(lines)
|
||||
new_lines = self._highlight(source).splitlines()
|
||||
for indent, new_line in zip(indents, new_lines):
|
||||
self.line(indent + new_line)
|
||||
|
||||
def _highlight(self, source):
|
||||
"""Highlight the given source code according to the "code_highlight" option"""
|
||||
if not self.hasmarkup:
|
||||
return source
|
||||
try:
|
||||
from pygments.formatters.terminal import TerminalFormatter
|
||||
from pygments.lexers.python import PythonLexer
|
||||
from pygments import highlight
|
||||
except ImportError:
|
||||
return source
|
||||
else:
|
||||
return highlight(source, PythonLexer(), TerminalFormatter(bg="dark"))
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import re
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
from _pytest._io import TerminalWriter
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"has_markup, expected",
|
||||
[
|
||||
pytest.param(
|
||||
True, "{kw}assert{hl-reset} {number}0{hl-reset}\n", id="with markup"
|
||||
),
|
||||
pytest.param(False, "assert 0\n", id="no markup"),
|
||||
],
|
||||
)
|
||||
def test_code_highlight(has_markup, expected, color_mapping):
|
||||
f = StringIO()
|
||||
tw = TerminalWriter(f)
|
||||
tw.hasmarkup = has_markup
|
||||
tw._write_source(["assert 0"])
|
||||
assert f.getvalue().splitlines(keepends=True) == color_mapping.format([expected])
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape("indents size (2) should have same size as lines (1)"),
|
||||
):
|
||||
tw._write_source(["assert 0"], [" ", " "])
|
|
@ -1,6 +1,9 @@
|
|||
import re
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from _pytest.pytester import RunResult
|
||||
from _pytest.pytester import Testdir
|
||||
|
||||
if sys.gettrace():
|
||||
|
@ -78,6 +81,12 @@ def tw_mock():
|
|||
def write(self, msg, **kw):
|
||||
self.lines.append((TWMock.WRITE, msg))
|
||||
|
||||
def _write_source(self, lines, indents=()):
|
||||
if not indents:
|
||||
indents = [""] * len(lines)
|
||||
for indent, line in zip(indents, lines):
|
||||
self.line(indent + line)
|
||||
|
||||
def line(self, line, **kw):
|
||||
self.lines.append(line)
|
||||
|
||||
|
@ -125,3 +134,64 @@ def dummy_yaml_custom_test(testdir):
|
|||
def testdir(testdir: Testdir) -> Testdir:
|
||||
testdir.monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1")
|
||||
return testdir
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def color_mapping():
|
||||
"""Returns a utility class which can replace keys in strings in the form "{NAME}"
|
||||
by their equivalent ASCII codes in the terminal.
|
||||
|
||||
Used by tests which check the actual colors output by pytest.
|
||||
"""
|
||||
|
||||
class ColorMapping:
|
||||
COLORS = {
|
||||
"red": "\x1b[31m",
|
||||
"green": "\x1b[32m",
|
||||
"yellow": "\x1b[33m",
|
||||
"bold": "\x1b[1m",
|
||||
"reset": "\x1b[0m",
|
||||
"kw": "\x1b[94m",
|
||||
"hl-reset": "\x1b[39;49;00m",
|
||||
"function": "\x1b[92m",
|
||||
"number": "\x1b[94m",
|
||||
"str": "\x1b[33m",
|
||||
"print": "\x1b[96m",
|
||||
}
|
||||
RE_COLORS = {k: re.escape(v) for k, v in COLORS.items()}
|
||||
|
||||
@classmethod
|
||||
def format(cls, lines: List[str]) -> List[str]:
|
||||
"""Straightforward replacement of color names to their ASCII codes."""
|
||||
return [line.format(**cls.COLORS) for line in lines]
|
||||
|
||||
@classmethod
|
||||
def format_for_fnmatch(cls, lines: List[str]) -> List[str]:
|
||||
"""Replace color names for use with LineMatcher.fnmatch_lines"""
|
||||
return [line.format(**cls.COLORS).replace("[", "[[]") for line in lines]
|
||||
|
||||
@classmethod
|
||||
def format_for_rematch(cls, lines: List[str]) -> List[str]:
|
||||
"""Replace color names for use with LineMatcher.re_match_lines"""
|
||||
return [line.format(**cls.RE_COLORS) for line in lines]
|
||||
|
||||
@classmethod
|
||||
def requires_ordered_markup(cls, result: RunResult):
|
||||
"""Should be called if a test expects markup to appear in the output
|
||||
in the order they were passed, for example:
|
||||
|
||||
tw.write(line, bold=True, red=True)
|
||||
|
||||
In Python 3.5 there's no guarantee that the generated markup will appear
|
||||
in the order called, so we do some limited color testing and skip the rest of
|
||||
the test.
|
||||
"""
|
||||
if sys.version_info < (3, 6):
|
||||
# terminal writer.write accepts keyword arguments, so
|
||||
# py36+ is required so the markup appears in the expected order
|
||||
output = result.stdout.str()
|
||||
assert "test session starts" in output
|
||||
assert "\x1b[1m" in output
|
||||
pytest.skip("doing limited testing because lacking ordered markup")
|
||||
|
||||
return ColorMapping
|
||||
|
|
|
@ -3,7 +3,6 @@ terminal reporting of the full testing process.
|
|||
"""
|
||||
import collections
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import textwrap
|
||||
from io import StringIO
|
||||
|
@ -24,14 +23,6 @@ from _pytest.terminal import TerminalReporter
|
|||
|
||||
DistInfo = collections.namedtuple("DistInfo", ["project_name", "version"])
|
||||
|
||||
COLORS = {
|
||||
"red": "\x1b[31m",
|
||||
"green": "\x1b[32m",
|
||||
"yellow": "\x1b[33m",
|
||||
"bold": "\x1b[1m",
|
||||
"reset": "\x1b[0m",
|
||||
}
|
||||
RE_COLORS = {k: re.escape(v) for k, v in COLORS.items()}
|
||||
|
||||
TRANS_FNMATCH = str.maketrans({"[": "[[]", "]": "[]]"})
|
||||
|
||||
|
@ -889,7 +880,7 @@ def test_pass_output_reporting(testdir):
|
|||
)
|
||||
|
||||
|
||||
def test_color_yes(testdir):
|
||||
def test_color_yes(testdir, color_mapping):
|
||||
p1 = testdir.makepyfile(
|
||||
"""
|
||||
def fail():
|
||||
|
@ -900,16 +891,10 @@ def test_color_yes(testdir):
|
|||
"""
|
||||
)
|
||||
result = testdir.runpytest("--color=yes", str(p1))
|
||||
if sys.version_info < (3, 6):
|
||||
# py36 required for ordered markup
|
||||
output = result.stdout.str()
|
||||
assert "test session starts" in output
|
||||
assert "\x1b[1m" in output
|
||||
return
|
||||
color_mapping.requires_ordered_markup(result)
|
||||
result.stdout.fnmatch_lines(
|
||||
[
|
||||
line.format(**COLORS).replace("[", "[[]")
|
||||
for line in [
|
||||
color_mapping.format_for_fnmatch(
|
||||
[
|
||||
"{bold}=*= test session starts =*={reset}",
|
||||
"collected 1 item",
|
||||
"",
|
||||
|
@ -918,26 +903,25 @@ def test_color_yes(testdir):
|
|||
"=*= FAILURES =*=",
|
||||
"{red}{bold}_*_ test_this _*_{reset}",
|
||||
"",
|
||||
"{bold} def test_this():{reset}",
|
||||
"{bold}> fail(){reset}",
|
||||
" {kw}def{hl-reset} {function}test_this{hl-reset}():",
|
||||
"> fail()",
|
||||
"",
|
||||
"{bold}{red}test_color_yes.py{reset}:5: ",
|
||||
"_ _ * _ _*",
|
||||
"",
|
||||
"{bold} def fail():{reset}",
|
||||
"{bold}> assert 0{reset}",
|
||||
" {kw}def{hl-reset} {function}fail{hl-reset}():",
|
||||
"> {kw}assert{hl-reset} {number}0{hl-reset}",
|
||||
"{bold}{red}E assert 0{reset}",
|
||||
"",
|
||||
"{bold}{red}test_color_yes.py{reset}:2: AssertionError",
|
||||
"{red}=*= {red}{bold}1 failed{reset}{red} in *s{reset}{red} =*={reset}",
|
||||
]
|
||||
]
|
||||
)
|
||||
)
|
||||
result = testdir.runpytest("--color=yes", "--tb=short", str(p1))
|
||||
result.stdout.fnmatch_lines(
|
||||
[
|
||||
line.format(**COLORS).replace("[", "[[]")
|
||||
for line in [
|
||||
color_mapping.format_for_fnmatch(
|
||||
[
|
||||
"{bold}=*= test session starts =*={reset}",
|
||||
"collected 1 item",
|
||||
"",
|
||||
|
@ -946,13 +930,13 @@ def test_color_yes(testdir):
|
|||
"=*= FAILURES =*=",
|
||||
"{red}{bold}_*_ test_this _*_{reset}",
|
||||
"{bold}{red}test_color_yes.py{reset}:5: in test_this",
|
||||
"{bold} fail(){reset}",
|
||||
" fail()",
|
||||
"{bold}{red}test_color_yes.py{reset}:2: in fail",
|
||||
"{bold} assert 0{reset}",
|
||||
" {kw}assert{hl-reset} {number}0{hl-reset}",
|
||||
"{bold}{red}E assert 0{reset}",
|
||||
"{red}=*= {red}{bold}1 failed{reset}{red} in *s{reset}{red} =*={reset}",
|
||||
]
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
@ -1673,7 +1657,7 @@ class TestProgressOutputStyle:
|
|||
]
|
||||
)
|
||||
|
||||
def test_colored_progress(self, testdir, monkeypatch):
|
||||
def test_colored_progress(self, testdir, monkeypatch, color_mapping):
|
||||
monkeypatch.setenv("PY_COLORS", "1")
|
||||
testdir.makepyfile(
|
||||
test_bar="""
|
||||
|
@ -1697,14 +1681,13 @@ class TestProgressOutputStyle:
|
|||
)
|
||||
result = testdir.runpytest()
|
||||
result.stdout.re_match_lines(
|
||||
[
|
||||
line.format(**RE_COLORS)
|
||||
for line in [
|
||||
color_mapping.format_for_rematch(
|
||||
[
|
||||
r"test_bar.py ({green}\.{reset}){{10}}{green} \s+ \[ 50%\]{reset}",
|
||||
r"test_foo.py ({green}\.{reset}){{5}}{yellow} \s+ \[ 75%\]{reset}",
|
||||
r"test_foobar.py ({red}F{reset}){{5}}{red} \s+ \[100%\]{reset}",
|
||||
]
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_count(self, many_tests_files, testdir):
|
||||
|
@ -1856,12 +1839,13 @@ class TestProgressWithTeardown:
|
|||
[r"test_bar.py (\.E){5}\s+\[ 25%\]", r"test_foo.py (\.E){15}\s+\[100%\]"]
|
||||
)
|
||||
|
||||
def test_teardown_many_verbose(self, testdir: Testdir, many_files) -> None:
|
||||
def test_teardown_many_verbose(
|
||||
self, testdir: Testdir, many_files, color_mapping
|
||||
) -> None:
|
||||
result = testdir.runpytest("-v")
|
||||
result.stdout.fnmatch_lines(
|
||||
[
|
||||
line.translate(TRANS_FNMATCH)
|
||||
for line in [
|
||||
color_mapping.format_for_fnmatch(
|
||||
[
|
||||
"test_bar.py::test_bar[0] PASSED * [ 5%]",
|
||||
"test_bar.py::test_bar[0] ERROR * [ 5%]",
|
||||
"test_bar.py::test_bar[4] PASSED * [ 25%]",
|
||||
|
@ -1869,7 +1853,7 @@ class TestProgressWithTeardown:
|
|||
"test_foo.py::test_foo[14] ERROR * [100%]",
|
||||
"=* 20 passed, 20 errors in *",
|
||||
]
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_xdist_normal(self, many_files, testdir, monkeypatch):
|
||||
|
@ -2021,3 +2005,46 @@ def test_via_exec(testdir: Testdir) -> None:
|
|||
result.stdout.fnmatch_lines(
|
||||
["test_via_exec.py::test_via_exec <- <string> PASSED*", "*= 1 passed in *"]
|
||||
)
|
||||
|
||||
|
||||
class TestCodeHighlight:
|
||||
def test_code_highlight_simple(self, testdir: Testdir, color_mapping) -> None:
|
||||
testdir.makepyfile(
|
||||
"""
|
||||
def test_foo():
|
||||
assert 1 == 10
|
||||
"""
|
||||
)
|
||||
result = testdir.runpytest("--color=yes")
|
||||
color_mapping.requires_ordered_markup(result)
|
||||
result.stdout.fnmatch_lines(
|
||||
color_mapping.format_for_fnmatch(
|
||||
[
|
||||
" {kw}def{hl-reset} {function}test_foo{hl-reset}():",
|
||||
"> {kw}assert{hl-reset} {number}1{hl-reset} == {number}10{hl-reset}",
|
||||
"{bold}{red}E assert 1 == 10{reset}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_code_highlight_continuation(self, testdir: Testdir, color_mapping) -> None:
|
||||
testdir.makepyfile(
|
||||
"""
|
||||
def test_foo():
|
||||
print('''
|
||||
'''); assert 0
|
||||
"""
|
||||
)
|
||||
result = testdir.runpytest("--color=yes")
|
||||
color_mapping.requires_ordered_markup(result)
|
||||
|
||||
result.stdout.fnmatch_lines(
|
||||
color_mapping.format_for_fnmatch(
|
||||
[
|
||||
" {kw}def{hl-reset} {function}test_foo{hl-reset}():",
|
||||
" {print}print{hl-reset}({str}'''{hl-reset}{str}{hl-reset}",
|
||||
"> {str} {hl-reset}{str}'''{hl-reset}); {kw}assert{hl-reset} {number}0{hl-reset}",
|
||||
"{bold}{red}E assert 0{reset}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue