Merge pull request #7135 from pytest-dev/terminalwriter

This commit is contained in:
Bruno Oliveira 2020-05-06 18:26:44 -03:00 committed by GitHub
commit 6c2d358918
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 496 additions and 89 deletions

View File

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

View File

@ -1,39 +1,8 @@
from typing import List
from typing import Sequence
from py.io import TerminalWriter as BaseTerminalWriter # noqa: F401
from .terminalwriter import get_terminal_width
from .terminalwriter import TerminalWriter
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 if we have markup support"""
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"))
__all__ = [
"TerminalWriter",
"get_terminal_width",
]

View File

@ -0,0 +1,206 @@
"""Helper functions for writing to terminals and files."""
import os
import shutil
import sys
import unicodedata
from functools import lru_cache
from typing import Optional
from typing import Sequence
from typing import TextIO
# This code was initially copied from py 1.8.1, file _io/terminalwriter.py.
def get_terminal_width() -> int:
width, _ = shutil.get_terminal_size(fallback=(80, 24))
# The Windows get_terminal_size may be bogus, let's sanify a bit.
if width < 40:
width = 80
return width
@lru_cache(100)
def char_width(c: str) -> int:
# Fullwidth and Wide -> 2, all else (including Ambiguous) -> 1.
return 2 if unicodedata.east_asian_width(c) in ("F", "W") else 1
def get_line_width(text: str) -> int:
text = unicodedata.normalize("NFC", text)
return sum(char_width(c) for c in text)
def should_do_markup(file: TextIO) -> bool:
if os.environ.get("PY_COLORS") == "1":
return True
if os.environ.get("PY_COLORS") == "0":
return False
return (
hasattr(file, "isatty")
and file.isatty()
and os.environ.get("TERM") != "dumb"
and not (sys.platform.startswith("java") and os._name == "nt")
)
class TerminalWriter:
_esctable = dict(
black=30,
red=31,
green=32,
yellow=33,
blue=34,
purple=35,
cyan=36,
white=37,
Black=40,
Red=41,
Green=42,
Yellow=43,
Blue=44,
Purple=45,
Cyan=46,
White=47,
bold=1,
light=2,
blink=5,
invert=7,
)
def __init__(self, file: Optional[TextIO] = None) -> None:
if file is None:
file = sys.stdout
if hasattr(file, "isatty") and file.isatty() and sys.platform == "win32":
try:
import colorama
except ImportError:
pass
else:
file = colorama.AnsiToWin32(file).stream
assert file is not None
self._file = file
self.hasmarkup = should_do_markup(file)
self._current_line = ""
self._terminal_width = None # type: Optional[int]
@property
def fullwidth(self) -> int:
if self._terminal_width is not None:
return self._terminal_width
return get_terminal_width()
@fullwidth.setter
def fullwidth(self, value: int) -> None:
self._terminal_width = value
@property
def width_of_current_line(self) -> int:
"""Return an estimate of the width so far in the current line."""
return get_line_width(self._current_line)
def markup(self, text: str, **markup: bool) -> str:
for name in markup:
if name not in self._esctable:
raise ValueError("unknown markup: {!r}".format(name))
if self.hasmarkup:
esc = [self._esctable[name] for name, on in markup.items() if on]
if esc:
text = "".join("\x1b[%sm" % cod for cod in esc) + text + "\x1b[0m"
return text
def sep(
self,
sepchar: str,
title: Optional[str] = None,
fullwidth: Optional[int] = None,
**markup: bool
) -> None:
if fullwidth is None:
fullwidth = self.fullwidth
# the goal is to have the line be as long as possible
# under the condition that len(line) <= fullwidth
if sys.platform == "win32":
# if we print in the last column on windows we are on a
# new line but there is no way to verify/neutralize this
# (we may not know the exact line width)
# so let's be defensive to avoid empty lines in the output
fullwidth -= 1
if title is not None:
# we want 2 + 2*len(fill) + len(title) <= fullwidth
# i.e. 2 + 2*len(sepchar)*N + len(title) <= fullwidth
# 2*len(sepchar)*N <= fullwidth - len(title) - 2
# N <= (fullwidth - len(title) - 2) // (2*len(sepchar))
N = max((fullwidth - len(title) - 2) // (2 * len(sepchar)), 1)
fill = sepchar * N
line = "{} {} {}".format(fill, title, fill)
else:
# we want len(sepchar)*N <= fullwidth
# i.e. N <= fullwidth // len(sepchar)
line = sepchar * (fullwidth // len(sepchar))
# in some situations there is room for an extra sepchar at the right,
# in particular if we consider that with a sepchar like "_ " the
# trailing space is not important at the end of the line
if len(line) + len(sepchar.rstrip()) <= fullwidth:
line += sepchar.rstrip()
self.line(line, **markup)
def write(self, msg: str, *, flush: bool = False, **markup: bool) -> None:
if msg:
current_line = msg.rsplit("\n", 1)[-1]
if "\n" in msg:
self._current_line = current_line
else:
self._current_line += current_line
msg = self.markup(msg, **markup)
self._file.write(msg)
if flush:
self.flush()
def line(self, s: str = "", **markup: bool) -> None:
self.write(s, **markup)
self.write("\n")
def flush(self) -> None:
self._file.flush()
def _write_source(self, lines: Sequence[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: str) -> str:
"""Highlight the given source code if we have markup support."""
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:
highlighted = highlight(
source, PythonLexer(), TerminalFormatter(bg="dark")
) # type: str
return highlighted

View File

@ -15,6 +15,7 @@ from typing import Union
import py
import _pytest._io
from _pytest.compat import TYPE_CHECKING
from _pytest.config.exceptions import UsageError
@ -466,7 +467,7 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter):
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Use more accurate terminal width via pylib."""
if "width" not in kwargs:
kwargs["width"] = py.io.get_terminal_width()
kwargs["width"] = _pytest._io.get_terminal_width()
super().__init__(*args, **kwargs)
def _format_action_invocation(self, action: argparse.Action) -> str:

View File

@ -1,5 +1,6 @@
""" submit failure or test session information to a pastebin service. """
import tempfile
from io import StringIO
from typing import IO
import pytest
@ -99,11 +100,10 @@ def pytest_terminal_summary(terminalreporter):
msg = rep.longrepr.reprtraceback.reprentries[-1].reprfileloc
except AttributeError:
msg = tr._getfailureheadline(rep)
tw = _pytest.config.create_terminal_writer(
terminalreporter.config, stringio=True
)
file = StringIO()
tw = _pytest.config.create_terminal_writer(terminalreporter.config, file)
rep.toterminal(tw)
s = tw.stringio.getvalue()
s = file.getvalue()
assert len(s)
pastebinurl = create_new_paste(s)
tr.write_line("{} --> {}".format(msg, pastebinurl))

View File

@ -1406,7 +1406,7 @@ def _showfixtures_main(config, session):
def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None:
for line in doc.split("\n"):
tw.write(indent + line + "\n")
tw.line(indent + line)
class Function(PyobjMixin, nodes.Item):

View File

@ -82,10 +82,11 @@ class BaseReport:
.. versionadded:: 3.0
"""
tw = TerminalWriter(stringio=True)
file = StringIO()
tw = TerminalWriter(file)
tw.hasmarkup = False
self.toterminal(tw)
exc = tw.stringio.getvalue()
exc = file.getvalue()
return exc.strip()
@property

View File

@ -120,6 +120,7 @@ def show_test_item(item):
used_fixtures = sorted(getattr(item, "fixturenames", []))
if used_fixtures:
tw.write(" (fixtures used: {})".format(", ".join(used_fixtures)))
tw.flush()
def pytest_runtest_setup(item):

View File

@ -68,6 +68,8 @@ def _show_fixture_action(fixturedef, msg):
if hasattr(fixturedef, "cached_param"):
tw.write("[{}]".format(fixturedef.cached_param))
tw.flush()
if capman:
capman.resume_global_capture()

View File

@ -344,7 +344,7 @@ class TerminalReporter:
fspath = self.startdir.bestrelpath(fspath)
self._tw.line()
self._tw.write(fspath + " ")
self._tw.write(res, **markup)
self._tw.write(res, flush=True, **markup)
def write_ensure_prefix(self, prefix, extra="", **kwargs):
if self.currentfspath != prefix:
@ -360,8 +360,11 @@ class TerminalReporter:
self._tw.line()
self.currentfspath = None
def write(self, content, **markup):
self._tw.write(content, **markup)
def write(self, content: str, *, flush: bool = False, **markup: bool) -> None:
self._tw.write(content, flush=flush, **markup)
def flush(self) -> None:
self._tw.flush()
def write_line(self, line, **markup):
if not isinstance(line, str):
@ -438,9 +441,11 @@ class TerminalReporter:
if self.showlongtestinfo:
line = self._locationline(nodeid, *location)
self.write_ensure_prefix(line, "")
self.flush()
elif self.showfspath:
fsid = nodeid.split("::")[0]
self.write_fspath_result(fsid, "")
self.flush()
def pytest_runtest_logreport(self, report: TestReport) -> None:
self._tests_ran = True
@ -492,6 +497,7 @@ class TerminalReporter:
self._tw.write(word, **markup)
self._tw.write(" " + line)
self.currentfspath = -2
self.flush()
@property
def _is_last_item(self):
@ -540,24 +546,20 @@ class TerminalReporter:
msg = self._get_progress_information_message()
w = self._width_of_current_line
fill = self._tw.fullwidth - w - 1
self.write(msg.rjust(fill), **{color: True})
self.write(msg.rjust(fill), flush=True, **{color: True})
@property
def _width_of_current_line(self):
"""Return the width of current line, using the superior implementation of py-1.6 when available"""
try:
return self._tw.width_of_current_line
except AttributeError:
# py < 1.6.0
return self._tw.chars_on_current_line
return self._tw.width_of_current_line
def pytest_collection(self) -> None:
if self.isatty:
if self.config.option.verbose >= 0:
self.write("collecting ... ", bold=True)
self.write("collecting ... ", flush=True, bold=True)
self._collect_report_last_write = time.time()
elif self.config.option.verbose >= 1:
self.write("collecting ... ", bold=True)
self.write("collecting ... ", flush=True, bold=True)
def pytest_collectreport(self, report: CollectReport) -> None:
if report.failed:

View File

@ -1,3 +1,4 @@
import io
import operator
import os
import queue
@ -1037,10 +1038,11 @@ raise ValueError()
"""
)
excinfo = pytest.raises(ValueError, mod.f)
tw = TerminalWriter(stringio=True)
file = io.StringIO()
tw = TerminalWriter(file=file)
repr = excinfo.getrepr(**reproptions)
repr.toterminal(tw)
assert tw.stringio.getvalue()
assert file.getvalue()
def test_traceback_repr_style(self, importasmod, tw_mock):
mod = importasmod(
@ -1255,11 +1257,12 @@ raise ValueError()
getattr(excinfo.value, attr).__traceback__ = None
r = excinfo.getrepr()
tw = TerminalWriter(stringio=True)
file = io.StringIO()
tw = TerminalWriter(file=file)
tw.hasmarkup = False
r.toterminal(tw)
matcher = LineMatcher(tw.stringio.getvalue().splitlines())
matcher = LineMatcher(file.getvalue().splitlines())
matcher.fnmatch_lines(
[
"ValueError: invalid value",

View File

@ -1,28 +0,0 @@
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

@ -0,0 +1,235 @@
import io
import os
import re
import shutil
import sys
from typing import Generator
from unittest import mock
import pytest
from _pytest._io import terminalwriter
from _pytest.monkeypatch import MonkeyPatch
# These tests were initially copied from py 1.8.1.
def test_terminal_width_COLUMNS(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv("COLUMNS", "42")
assert terminalwriter.get_terminal_width() == 42
monkeypatch.delenv("COLUMNS", raising=False)
def test_terminalwriter_width_bogus(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setattr(shutil, "get_terminal_size", mock.Mock(return_value=(10, 10)))
monkeypatch.delenv("COLUMNS", raising=False)
tw = terminalwriter.TerminalWriter()
assert tw.fullwidth == 80
def test_terminalwriter_computes_width(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setattr(terminalwriter, "get_terminal_width", lambda: 42)
tw = terminalwriter.TerminalWriter()
assert tw.fullwidth == 42
def test_terminalwriter_dumb_term_no_markup(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setattr(os, "environ", {"TERM": "dumb", "PATH": ""})
class MyFile:
closed = False
def isatty(self):
return True
with monkeypatch.context() as m:
m.setattr(sys, "stdout", MyFile())
assert sys.stdout.isatty()
tw = terminalwriter.TerminalWriter()
assert not tw.hasmarkup
win32 = int(sys.platform == "win32")
class TestTerminalWriter:
@pytest.fixture(params=["path", "stringio"])
def tw(
self, request, tmpdir
) -> Generator[terminalwriter.TerminalWriter, None, None]:
if request.param == "path":
p = tmpdir.join("tmpfile")
f = open(str(p), "w+", encoding="utf8")
tw = terminalwriter.TerminalWriter(f)
def getlines():
f.flush()
with open(str(p), encoding="utf8") as fp:
return fp.readlines()
elif request.param == "stringio":
f = io.StringIO()
tw = terminalwriter.TerminalWriter(f)
def getlines():
f.seek(0)
return f.readlines()
tw.getlines = getlines # type: ignore
tw.getvalue = lambda: "".join(getlines()) # type: ignore
with f:
yield tw
def test_line(self, tw) -> None:
tw.line("hello")
lines = tw.getlines()
assert len(lines) == 1
assert lines[0] == "hello\n"
def test_line_unicode(self, tw) -> None:
msg = "b\u00f6y"
tw.line(msg)
lines = tw.getlines()
assert lines[0] == msg + "\n"
def test_sep_no_title(self, tw) -> None:
tw.sep("-", fullwidth=60)
lines = tw.getlines()
assert len(lines) == 1
assert lines[0] == "-" * (60 - win32) + "\n"
def test_sep_with_title(self, tw) -> None:
tw.sep("-", "hello", fullwidth=60)
lines = tw.getlines()
assert len(lines) == 1
assert lines[0] == "-" * 26 + " hello " + "-" * (27 - win32) + "\n"
def test_sep_longer_than_width(self, tw) -> None:
tw.sep("-", "a" * 10, fullwidth=5)
(line,) = tw.getlines()
# even though the string is wider than the line, still have a separator
assert line == "- aaaaaaaaaa -\n"
@pytest.mark.skipif(sys.platform == "win32", reason="win32 has no native ansi")
@pytest.mark.parametrize("bold", (True, False))
@pytest.mark.parametrize("color", ("red", "green"))
def test_markup(self, tw, bold: bool, color: str) -> None:
text = tw.markup("hello", **{color: True, "bold": bold})
assert "hello" in text
def test_markup_bad(self, tw) -> None:
with pytest.raises(ValueError):
tw.markup("x", wronkw=3)
with pytest.raises(ValueError):
tw.markup("x", wronkw=0)
def test_line_write_markup(self, tw) -> None:
tw.hasmarkup = True
tw.line("x", bold=True)
tw.write("x\n", red=True)
lines = tw.getlines()
if sys.platform != "win32":
assert len(lines[0]) >= 2, lines
assert len(lines[1]) >= 2, lines
def test_attr_fullwidth(self, tw) -> None:
tw.sep("-", "hello", fullwidth=70)
tw.fullwidth = 70
tw.sep("-", "hello")
lines = tw.getlines()
assert len(lines[0]) == len(lines[1])
@pytest.mark.skipif(sys.platform == "win32", reason="win32 has no native ansi")
def test_attr_hasmarkup() -> None:
file = io.StringIO()
tw = terminalwriter.TerminalWriter(file)
assert not tw.hasmarkup
tw.hasmarkup = True
tw.line("hello", bold=True)
s = file.getvalue()
assert len(s) > len("hello\n")
assert "\x1b[1m" in s
assert "\x1b[0m" in s
def test_should_do_markup_PY_COLORS_eq_1(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setitem(os.environ, "PY_COLORS", "1")
file = io.StringIO()
tw = terminalwriter.TerminalWriter(file)
assert tw.hasmarkup
tw.line("hello", bold=True)
s = file.getvalue()
assert len(s) > len("hello\n")
assert "\x1b[1m" in s
assert "\x1b[0m" in s
def test_should_do_markup_PY_COLORS_eq_0(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setitem(os.environ, "PY_COLORS", "0")
f = io.StringIO()
f.isatty = lambda: True # type: ignore
tw = terminalwriter.TerminalWriter(file=f)
assert not tw.hasmarkup
tw.line("hello", bold=True)
s = f.getvalue()
assert s == "hello\n"
class TestTerminalWriterLineWidth:
def test_init(self) -> None:
tw = terminalwriter.TerminalWriter()
assert tw.width_of_current_line == 0
def test_update(self) -> None:
tw = terminalwriter.TerminalWriter()
tw.write("hello world")
assert tw.width_of_current_line == 11
def test_update_with_newline(self) -> None:
tw = terminalwriter.TerminalWriter()
tw.write("hello\nworld")
assert tw.width_of_current_line == 5
def test_update_with_wide_text(self) -> None:
tw = terminalwriter.TerminalWriter()
tw.write("乇乂ㄒ尺卂 ㄒ卄丨匚匚")
assert tw.width_of_current_line == 21 # 5*2 + 1 + 5*2
def test_composed(self) -> None:
tw = terminalwriter.TerminalWriter()
text = "café food"
assert len(text) == 9
tw.write(text)
assert tw.width_of_current_line == 9
def test_combining(self) -> None:
tw = terminalwriter.TerminalWriter()
text = "café food"
assert len(text) == 10
tw.write(text)
assert tw.width_of_current_line == 9
@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 = io.StringIO()
tw = terminalwriter.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

@ -1255,7 +1255,7 @@ def test_help_formatter_uses_py_get_terminal_width(monkeypatch):
formatter = DropShorterLongHelpFormatter("prog")
assert formatter._width == 90
monkeypatch.setattr("py.io.get_terminal_width", lambda: 160)
monkeypatch.setattr("_pytest._io.get_terminal_width", lambda: 160)
formatter = DropShorterLongHelpFormatter("prog")
assert formatter._width == 160