Skip to content

Commit 6c2d358

Browse files
authored
Merge pull request #7135 from pytest-dev/terminalwriter
2 parents 4d43976 + e40bf1d commit 6c2d358

File tree

14 files changed

+496
-89
lines changed

14 files changed

+496
-89
lines changed

changelog/7135.breaking.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
Pytest now uses its own ``TerminalWriter`` class instead of using the one from the ``py`` library.
2+
Plugins generally access this class through ``TerminalReporter.writer``, ``TerminalReporter.write()``
3+
(and similar methods), or ``_pytest.config.create_terminal_writer()``.
4+
5+
The following breaking changes were made:
6+
7+
- Output (``write()`` method and others) no longer flush implicitly; the flushing behavior
8+
of the underlying file is respected. To flush explicitly (for example, if you
9+
want output to be shown before an end-of-line is printed), use ``write(flush=True)`` or
10+
``terminal_writer.flush()``.
11+
- Explicit Windows console support was removed, delegated to the colorama library.
12+
- Support for writing ``bytes`` was removed.
13+
- The ``reline`` method and ``chars_on_current_line`` property were removed.
14+
- The ``stringio`` and ``encoding`` arguments was removed.
15+
- Support for passing a callable instead of a file was removed.

src/_pytest/_io/__init__.py

Lines changed: 6 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,8 @@
1-
from typing import List
2-
from typing import Sequence
1+
from .terminalwriter import get_terminal_width
2+
from .terminalwriter import TerminalWriter
33

4-
from py.io import TerminalWriter as BaseTerminalWriter # noqa: F401
54

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 if we have markup support"""
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"))
5+
__all__ = [
6+
"TerminalWriter",
7+
"get_terminal_width",
8+
]

src/_pytest/_io/terminalwriter.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""Helper functions for writing to terminals and files."""
2+
import os
3+
import shutil
4+
import sys
5+
import unicodedata
6+
from functools import lru_cache
7+
from typing import Optional
8+
from typing import Sequence
9+
from typing import TextIO
10+
11+
12+
# This code was initially copied from py 1.8.1, file _io/terminalwriter.py.
13+
14+
15+
def get_terminal_width() -> int:
16+
width, _ = shutil.get_terminal_size(fallback=(80, 24))
17+
18+
# The Windows get_terminal_size may be bogus, let's sanify a bit.
19+
if width < 40:
20+
width = 80
21+
22+
return width
23+
24+
25+
@lru_cache(100)
26+
def char_width(c: str) -> int:
27+
# Fullwidth and Wide -> 2, all else (including Ambiguous) -> 1.
28+
return 2 if unicodedata.east_asian_width(c) in ("F", "W") else 1
29+
30+
31+
def get_line_width(text: str) -> int:
32+
text = unicodedata.normalize("NFC", text)
33+
return sum(char_width(c) for c in text)
34+
35+
36+
def should_do_markup(file: TextIO) -> bool:
37+
if os.environ.get("PY_COLORS") == "1":
38+
return True
39+
if os.environ.get("PY_COLORS") == "0":
40+
return False
41+
return (
42+
hasattr(file, "isatty")
43+
and file.isatty()
44+
and os.environ.get("TERM") != "dumb"
45+
and not (sys.platform.startswith("java") and os._name == "nt")
46+
)
47+
48+
49+
class TerminalWriter:
50+
_esctable = dict(
51+
black=30,
52+
red=31,
53+
green=32,
54+
yellow=33,
55+
blue=34,
56+
purple=35,
57+
cyan=36,
58+
white=37,
59+
Black=40,
60+
Red=41,
61+
Green=42,
62+
Yellow=43,
63+
Blue=44,
64+
Purple=45,
65+
Cyan=46,
66+
White=47,
67+
bold=1,
68+
light=2,
69+
blink=5,
70+
invert=7,
71+
)
72+
73+
def __init__(self, file: Optional[TextIO] = None) -> None:
74+
if file is None:
75+
file = sys.stdout
76+
if hasattr(file, "isatty") and file.isatty() and sys.platform == "win32":
77+
try:
78+
import colorama
79+
except ImportError:
80+
pass
81+
else:
82+
file = colorama.AnsiToWin32(file).stream
83+
assert file is not None
84+
self._file = file
85+
self.hasmarkup = should_do_markup(file)
86+
self._current_line = ""
87+
self._terminal_width = None # type: Optional[int]
88+
89+
@property
90+
def fullwidth(self) -> int:
91+
if self._terminal_width is not None:
92+
return self._terminal_width
93+
return get_terminal_width()
94+
95+
@fullwidth.setter
96+
def fullwidth(self, value: int) -> None:
97+
self._terminal_width = value
98+
99+
@property
100+
def width_of_current_line(self) -> int:
101+
"""Return an estimate of the width so far in the current line."""
102+
return get_line_width(self._current_line)
103+
104+
def markup(self, text: str, **markup: bool) -> str:
105+
for name in markup:
106+
if name not in self._esctable:
107+
raise ValueError("unknown markup: {!r}".format(name))
108+
if self.hasmarkup:
109+
esc = [self._esctable[name] for name, on in markup.items() if on]
110+
if esc:
111+
text = "".join("\x1b[%sm" % cod for cod in esc) + text + "\x1b[0m"
112+
return text
113+
114+
def sep(
115+
self,
116+
sepchar: str,
117+
title: Optional[str] = None,
118+
fullwidth: Optional[int] = None,
119+
**markup: bool
120+
) -> None:
121+
if fullwidth is None:
122+
fullwidth = self.fullwidth
123+
# the goal is to have the line be as long as possible
124+
# under the condition that len(line) <= fullwidth
125+
if sys.platform == "win32":
126+
# if we print in the last column on windows we are on a
127+
# new line but there is no way to verify/neutralize this
128+
# (we may not know the exact line width)
129+
# so let's be defensive to avoid empty lines in the output
130+
fullwidth -= 1
131+
if title is not None:
132+
# we want 2 + 2*len(fill) + len(title) <= fullwidth
133+
# i.e. 2 + 2*len(sepchar)*N + len(title) <= fullwidth
134+
# 2*len(sepchar)*N <= fullwidth - len(title) - 2
135+
# N <= (fullwidth - len(title) - 2) // (2*len(sepchar))
136+
N = max((fullwidth - len(title) - 2) // (2 * len(sepchar)), 1)
137+
fill = sepchar * N
138+
line = "{} {} {}".format(fill, title, fill)
139+
else:
140+
# we want len(sepchar)*N <= fullwidth
141+
# i.e. N <= fullwidth // len(sepchar)
142+
line = sepchar * (fullwidth // len(sepchar))
143+
# in some situations there is room for an extra sepchar at the right,
144+
# in particular if we consider that with a sepchar like "_ " the
145+
# trailing space is not important at the end of the line
146+
if len(line) + len(sepchar.rstrip()) <= fullwidth:
147+
line += sepchar.rstrip()
148+
149+
self.line(line, **markup)
150+
151+
def write(self, msg: str, *, flush: bool = False, **markup: bool) -> None:
152+
if msg:
153+
current_line = msg.rsplit("\n", 1)[-1]
154+
if "\n" in msg:
155+
self._current_line = current_line
156+
else:
157+
self._current_line += current_line
158+
159+
msg = self.markup(msg, **markup)
160+
161+
self._file.write(msg)
162+
if flush:
163+
self.flush()
164+
165+
def line(self, s: str = "", **markup: bool) -> None:
166+
self.write(s, **markup)
167+
self.write("\n")
168+
169+
def flush(self) -> None:
170+
self._file.flush()
171+
172+
def _write_source(self, lines: Sequence[str], indents: Sequence[str] = ()) -> None:
173+
"""Write lines of source code possibly highlighted.
174+
175+
Keeping this private for now because the API is clunky. We should discuss how
176+
to evolve the terminal writer so we can have more precise color support, for example
177+
being able to write part of a line in one color and the rest in another, and so on.
178+
"""
179+
if indents and len(indents) != len(lines):
180+
raise ValueError(
181+
"indents size ({}) should have same size as lines ({})".format(
182+
len(indents), len(lines)
183+
)
184+
)
185+
if not indents:
186+
indents = [""] * len(lines)
187+
source = "\n".join(lines)
188+
new_lines = self._highlight(source).splitlines()
189+
for indent, new_line in zip(indents, new_lines):
190+
self.line(indent + new_line)
191+
192+
def _highlight(self, source: str) -> str:
193+
"""Highlight the given source code if we have markup support."""
194+
if not self.hasmarkup:
195+
return source
196+
try:
197+
from pygments.formatters.terminal import TerminalFormatter
198+
from pygments.lexers.python import PythonLexer
199+
from pygments import highlight
200+
except ImportError:
201+
return source
202+
else:
203+
highlighted = highlight(
204+
source, PythonLexer(), TerminalFormatter(bg="dark")
205+
) # type: str
206+
return highlighted

src/_pytest/config/argparsing.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import py
1717

18+
import _pytest._io
1819
from _pytest.compat import TYPE_CHECKING
1920
from _pytest.config.exceptions import UsageError
2021

@@ -466,7 +467,7 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter):
466467
def __init__(self, *args: Any, **kwargs: Any) -> None:
467468
"""Use more accurate terminal width via pylib."""
468469
if "width" not in kwargs:
469-
kwargs["width"] = py.io.get_terminal_width()
470+
kwargs["width"] = _pytest._io.get_terminal_width()
470471
super().__init__(*args, **kwargs)
471472

472473
def _format_action_invocation(self, action: argparse.Action) -> str:

src/_pytest/pastebin.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
""" submit failure or test session information to a pastebin service. """
22
import tempfile
3+
from io import StringIO
34
from typing import IO
45

56
import pytest
@@ -99,11 +100,10 @@ def pytest_terminal_summary(terminalreporter):
99100
msg = rep.longrepr.reprtraceback.reprentries[-1].reprfileloc
100101
except AttributeError:
101102
msg = tr._getfailureheadline(rep)
102-
tw = _pytest.config.create_terminal_writer(
103-
terminalreporter.config, stringio=True
104-
)
103+
file = StringIO()
104+
tw = _pytest.config.create_terminal_writer(terminalreporter.config, file)
105105
rep.toterminal(tw)
106-
s = tw.stringio.getvalue()
106+
s = file.getvalue()
107107
assert len(s)
108108
pastebinurl = create_new_paste(s)
109109
tr.write_line("{} --> {}".format(msg, pastebinurl))

src/_pytest/python.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1406,7 +1406,7 @@ def _showfixtures_main(config, session):
14061406

14071407
def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None:
14081408
for line in doc.split("\n"):
1409-
tw.write(indent + line + "\n")
1409+
tw.line(indent + line)
14101410

14111411

14121412
class Function(PyobjMixin, nodes.Item):

src/_pytest/reports.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ def longreprtext(self):
8282
8383
.. versionadded:: 3.0
8484
"""
85-
tw = TerminalWriter(stringio=True)
85+
file = StringIO()
86+
tw = TerminalWriter(file)
8687
tw.hasmarkup = False
8788
self.toterminal(tw)
88-
exc = tw.stringio.getvalue()
89+
exc = file.getvalue()
8990
return exc.strip()
9091

9192
@property

src/_pytest/runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ def show_test_item(item):
120120
used_fixtures = sorted(getattr(item, "fixturenames", []))
121121
if used_fixtures:
122122
tw.write(" (fixtures used: {})".format(", ".join(used_fixtures)))
123+
tw.flush()
123124

124125

125126
def pytest_runtest_setup(item):

src/_pytest/setuponly.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ def _show_fixture_action(fixturedef, msg):
6868
if hasattr(fixturedef, "cached_param"):
6969
tw.write("[{}]".format(fixturedef.cached_param))
7070

71+
tw.flush()
72+
7173
if capman:
7274
capman.resume_global_capture()
7375

0 commit comments

Comments
 (0)