Skip to content

Commit 4209ad6

Browse files
authored
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
1 parent 3b58285 commit 4209ad6

File tree

7 files changed

+252
-49
lines changed

7 files changed

+252
-49
lines changed

changelog/6658.improvement.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Code is now highlighted in tracebacks when ``pygments`` is installed.
2+
3+
Users are encouraged to install ``pygments`` into their environment and provide feedback, because
4+
the plan is to make ``pygments`` a regular dependency in the future.

src/_pytest/_code/code.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,21 +1039,58 @@ def __init__(
10391039
self.reprfileloc = filelocrepr
10401040
self.style = style
10411041

1042+
def _write_entry_lines(self, tw: TerminalWriter) -> None:
1043+
"""Writes the source code portions of a list of traceback entries with syntax highlighting.
1044+
1045+
Usually entries are lines like these:
1046+
1047+
" x = 1"
1048+
"> assert x == 2"
1049+
"E assert 1 == 2"
1050+
1051+
This function takes care of rendering the "source" portions of it (the lines without
1052+
the "E" prefix) using syntax highlighting, taking care to not highlighting the ">"
1053+
character, as doing so might break line continuations.
1054+
"""
1055+
1056+
indent_size = 4
1057+
1058+
def is_fail(line):
1059+
return line.startswith("{} ".format(FormattedExcinfo.fail_marker))
1060+
1061+
if not self.lines:
1062+
return
1063+
1064+
# separate indents and source lines that are not failures: we want to
1065+
# highlight the code but not the indentation, which may contain markers
1066+
# such as "> assert 0"
1067+
indents = []
1068+
source_lines = []
1069+
for line in self.lines:
1070+
if not is_fail(line):
1071+
indents.append(line[:indent_size])
1072+
source_lines.append(line[indent_size:])
1073+
1074+
tw._write_source(source_lines, indents)
1075+
1076+
# failure lines are always completely red and bold
1077+
for line in (x for x in self.lines if is_fail(x)):
1078+
tw.line(line, bold=True, red=True)
1079+
10421080
def toterminal(self, tw: TerminalWriter) -> None:
10431081
if self.style == "short":
10441082
assert self.reprfileloc is not None
10451083
self.reprfileloc.toterminal(tw)
1046-
for line in self.lines:
1047-
red = line.startswith("E ")
1048-
tw.line(line, bold=True, red=red)
1084+
self._write_entry_lines(tw)
10491085
if self.reprlocals:
10501086
self.reprlocals.toterminal(tw, indent=" " * 8)
10511087
return
1088+
10521089
if self.reprfuncargs:
10531090
self.reprfuncargs.toterminal(tw)
1054-
for line in self.lines:
1055-
red = line.startswith("E ")
1056-
tw.line(line, bold=True, red=red)
1091+
1092+
self._write_entry_lines(tw)
1093+
10571094
if self.reprlocals:
10581095
tw.line("")
10591096
self.reprlocals.toterminal(tw)

src/_pytest/_io/__init__.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,39 @@
1-
# Reexport TerminalWriter from here instead of py, to make it easier to
2-
# extend or swap our own implementation in the future.
3-
from py.io import TerminalWriter as TerminalWriter # noqa: F401
1+
from typing import List
2+
from typing import Sequence
3+
4+
from py.io import TerminalWriter as BaseTerminalWriter # noqa: F401
5+
6+
7+
class TerminalWriter(BaseTerminalWriter):
8+
def _write_source(self, lines: List[str], indents: Sequence[str] = ()) -> None:
9+
"""Write lines of source code possibly highlighted.
10+
11+
Keeping this private for now because the API is clunky. We should discuss how
12+
to evolve the terminal writer so we can have more precise color support, for example
13+
being able to write part of a line in one color and the rest in another, and so on.
14+
"""
15+
if indents and len(indents) != len(lines):
16+
raise ValueError(
17+
"indents size ({}) should have same size as lines ({})".format(
18+
len(indents), len(lines)
19+
)
20+
)
21+
if not indents:
22+
indents = [""] * len(lines)
23+
source = "\n".join(lines)
24+
new_lines = self._highlight(source).splitlines()
25+
for indent, new_line in zip(indents, new_lines):
26+
self.line(indent + new_line)
27+
28+
def _highlight(self, source):
29+
"""Highlight the given source code according to the "code_highlight" option"""
30+
if not self.hasmarkup:
31+
return source
32+
try:
33+
from pygments.formatters.terminal import TerminalFormatter
34+
from pygments.lexers.python import PythonLexer
35+
from pygments import highlight
36+
except ImportError:
37+
return source
38+
else:
39+
return highlight(source, PythonLexer(), TerminalFormatter(bg="dark"))

testing/code/test_terminal_writer.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import re
2+
from io import StringIO
3+
4+
import pytest
5+
from _pytest._io import TerminalWriter
6+
7+
8+
@pytest.mark.parametrize(
9+
"has_markup, expected",
10+
[
11+
pytest.param(
12+
True, "{kw}assert{hl-reset} {number}0{hl-reset}\n", id="with markup"
13+
),
14+
pytest.param(False, "assert 0\n", id="no markup"),
15+
],
16+
)
17+
def test_code_highlight(has_markup, expected, color_mapping):
18+
f = StringIO()
19+
tw = TerminalWriter(f)
20+
tw.hasmarkup = has_markup
21+
tw._write_source(["assert 0"])
22+
assert f.getvalue().splitlines(keepends=True) == color_mapping.format([expected])
23+
24+
with pytest.raises(
25+
ValueError,
26+
match=re.escape("indents size (2) should have same size as lines (1)"),
27+
):
28+
tw._write_source(["assert 0"], [" ", " "])

testing/conftest.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import re
12
import sys
3+
from typing import List
24

35
import pytest
6+
from _pytest.pytester import RunResult
47
from _pytest.pytester import Testdir
58

69
if sys.gettrace():
@@ -78,6 +81,12 @@ def sep(self, sep, line=None):
7881
def write(self, msg, **kw):
7982
self.lines.append((TWMock.WRITE, msg))
8083

84+
def _write_source(self, lines, indents=()):
85+
if not indents:
86+
indents = [""] * len(lines)
87+
for indent, line in zip(indents, lines):
88+
self.line(indent + line)
89+
8190
def line(self, line, **kw):
8291
self.lines.append(line)
8392

@@ -125,3 +134,64 @@ def runtest(self):
125134
def testdir(testdir: Testdir) -> Testdir:
126135
testdir.monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1")
127136
return testdir
137+
138+
139+
@pytest.fixture(scope="session")
140+
def color_mapping():
141+
"""Returns a utility class which can replace keys in strings in the form "{NAME}"
142+
by their equivalent ASCII codes in the terminal.
143+
144+
Used by tests which check the actual colors output by pytest.
145+
"""
146+
147+
class ColorMapping:
148+
COLORS = {
149+
"red": "\x1b[31m",
150+
"green": "\x1b[32m",
151+
"yellow": "\x1b[33m",
152+
"bold": "\x1b[1m",
153+
"reset": "\x1b[0m",
154+
"kw": "\x1b[94m",
155+
"hl-reset": "\x1b[39;49;00m",
156+
"function": "\x1b[92m",
157+
"number": "\x1b[94m",
158+
"str": "\x1b[33m",
159+
"print": "\x1b[96m",
160+
}
161+
RE_COLORS = {k: re.escape(v) for k, v in COLORS.items()}
162+
163+
@classmethod
164+
def format(cls, lines: List[str]) -> List[str]:
165+
"""Straightforward replacement of color names to their ASCII codes."""
166+
return [line.format(**cls.COLORS) for line in lines]
167+
168+
@classmethod
169+
def format_for_fnmatch(cls, lines: List[str]) -> List[str]:
170+
"""Replace color names for use with LineMatcher.fnmatch_lines"""
171+
return [line.format(**cls.COLORS).replace("[", "[[]") for line in lines]
172+
173+
@classmethod
174+
def format_for_rematch(cls, lines: List[str]) -> List[str]:
175+
"""Replace color names for use with LineMatcher.re_match_lines"""
176+
return [line.format(**cls.RE_COLORS) for line in lines]
177+
178+
@classmethod
179+
def requires_ordered_markup(cls, result: RunResult):
180+
"""Should be called if a test expects markup to appear in the output
181+
in the order they were passed, for example:
182+
183+
tw.write(line, bold=True, red=True)
184+
185+
In Python 3.5 there's no guarantee that the generated markup will appear
186+
in the order called, so we do some limited color testing and skip the rest of
187+
the test.
188+
"""
189+
if sys.version_info < (3, 6):
190+
# terminal writer.write accepts keyword arguments, so
191+
# py36+ is required so the markup appears in the expected order
192+
output = result.stdout.str()
193+
assert "test session starts" in output
194+
assert "\x1b[1m" in output
195+
pytest.skip("doing limited testing because lacking ordered markup")
196+
197+
return ColorMapping

0 commit comments

Comments
 (0)