Skip to content

Commit

Permalink
Merge pull request #7135 from pytest-dev/terminalwriter
Browse files Browse the repository at this point in the history
  • Loading branch information
nicoddemus authored May 6, 2020
2 parents 4d43976 + e40bf1d commit 6c2d358
Show file tree
Hide file tree
Showing 14 changed files with 496 additions and 89 deletions.
15 changes: 15 additions & 0 deletions changelog/7135.breaking.rst
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 6 additions & 37 deletions src/_pytest/_io/__init__.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,8 @@
from typing import List
from typing import Sequence
from .terminalwriter import get_terminal_width
from .terminalwriter import TerminalWriter

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 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",
]
206 changes: 206 additions & 0 deletions src/_pytest/_io/terminalwriter.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion src/_pytest/config/argparsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import py

import _pytest._io
from _pytest.compat import TYPE_CHECKING
from _pytest.config.exceptions import UsageError

Expand Down Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions src/_pytest/pastebin.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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))
2 changes: 1 addition & 1 deletion src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
5 changes: 3 additions & 2 deletions src/_pytest/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ def longreprtext(self):
.. 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
Expand Down
1 change: 1 addition & 0 deletions src/_pytest/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions src/_pytest/setuponly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading

0 comments on commit 6c2d358

Please sign in to comment.