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:
Bruno Oliveira 2020-02-12 08:32:37 -03:00 committed by GitHub
parent 3b582858f3
commit 4209ad6fca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 252 additions and 49 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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"))

View File

@ -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"], [" ", " "])

View File

@ -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

View File

@ -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}",
]
)
)

View File

@ -49,6 +49,7 @@ deps =
numpy: numpy
pexpect: pexpect
pluggymaster: git+https://github.com/pytest-dev/pluggy.git@master
pygments
twisted: twisted
xdist: pytest-xdist>=1.13
{env:_PYTEST_TOX_EXTRA_DEP:}