Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Copy over TerminalWriter from py, simplify and optimize it #7135

Merged
merged 26 commits into from May 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
276405a
terminalwriter: vendor TerminalWriter from py
bluetech Apr 29, 2020
3014d9a
terminalwriter: auto-format
bluetech Apr 29, 2020
5e2d820
terminalwriter: fix lints
bluetech Apr 29, 2020
1d596b2
terminalwriter: move Win32ConsoleWriter definition under win32 condit…
bluetech Apr 29, 2020
c749e44
terminalwriter: remove custom win32 screen width code
bluetech Apr 29, 2020
6c1b6a0
terminalwriter: simplify get_terminal_width()
bluetech Apr 29, 2020
9a59970
terminalwriter: optimize get_line_width() a bit
bluetech Apr 29, 2020
b6cc90e
terminalwriter: remove support for writing bytes directly
bluetech Apr 29, 2020
a681972
terminalwriter: remove unused function ansi_print
bluetech Apr 29, 2020
0528307
terminalwriter: remove unused function TerminalWriter.reline
bluetech Apr 29, 2020
dac05cc
terminalwriter: remove support for passing callable as file in Termin…
bluetech Apr 29, 2020
94a57d2
io: combine _io.TerminalWriter and _io.terminalwriter.TerminalWriter
bluetech Apr 29, 2020
66ee755
terminalwriter: remove TerminalWriter's stringio argument
bluetech Apr 29, 2020
8d2d1c4
terminalwriter: inline function _escaped
bluetech Apr 29, 2020
f6564a5
terminalwriter: remove win32 specific code in favor of relying on col…
bluetech Apr 29, 2020
e8fc5f9
terminalwriter: add type annotations
bluetech Apr 29, 2020
8e04d35
terminalwriter: remove unneeded hasattr use
bluetech Apr 29, 2020
d9b4364
terminalwriter: inline function _update_chars_on_current_line
bluetech Apr 29, 2020
1bc4170
terminalwriter: don't flush implicitly; add explicit flushes
bluetech Apr 29, 2020
dd32c72
terminalwriter: remove unused property chars_on_current_line
bluetech Apr 29, 2020
d5584c7
terminalwriter: compute width_of_current_line lazily
bluetech Apr 29, 2020
0e36596
testing/io: port TerminalWriter tests from py
bluetech Apr 29, 2020
bafc9bd
testing: merge code/test_terminal_writer.py into io/test_terminalwrit…
bluetech Apr 30, 2020
414a87a
config/argparsing: use our own get_terminal_width()
bluetech Apr 30, 2020
d8558e8
terminalwriter: clean up markup function a bit
bluetech Apr 30, 2020
e40bf1d
Add a changelog for TerminalWriter changes
bluetech Apr 30, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 15 additions & 0 deletions changelog/7135.breaking.rst
@@ -0,0 +1,15 @@
Pytest now uses its own ``TerminalWriter`` class instead of using the one from the ``py`` library.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great changelog. 👍

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
@@ -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
@@ -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":
bluetech marked this conversation as resolved.
Show resolved Hide resolved
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
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
@@ -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
Expand Up @@ -1424,7 +1424,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
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
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
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