terminalwriter.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. """Helper functions for writing to terminals and files."""
  2. from __future__ import annotations
  3. from collections.abc import Sequence
  4. import os
  5. import shutil
  6. import sys
  7. from typing import final
  8. from typing import Literal
  9. from typing import TextIO
  10. import pygments
  11. from pygments.formatters.terminal import TerminalFormatter
  12. from pygments.lexer import Lexer
  13. from pygments.lexers.diff import DiffLexer
  14. from pygments.lexers.python import PythonLexer
  15. from ..compat import assert_never
  16. from .wcwidth import wcswidth
  17. # This code was initially copied from py 1.8.1, file _io/terminalwriter.py.
  18. def get_terminal_width() -> int:
  19. width, _ = shutil.get_terminal_size(fallback=(80, 24))
  20. # The Windows get_terminal_size may be bogus, let's sanify a bit.
  21. if width < 40:
  22. width = 80
  23. return width
  24. def should_do_markup(file: TextIO) -> bool:
  25. if os.environ.get("PY_COLORS") == "1":
  26. return True
  27. if os.environ.get("PY_COLORS") == "0":
  28. return False
  29. if os.environ.get("NO_COLOR"):
  30. return False
  31. if os.environ.get("FORCE_COLOR"):
  32. return True
  33. return (
  34. hasattr(file, "isatty") and file.isatty() and os.environ.get("TERM") != "dumb"
  35. )
  36. @final
  37. class TerminalWriter:
  38. _esctable = dict(
  39. black=30,
  40. red=31,
  41. green=32,
  42. yellow=33,
  43. blue=34,
  44. purple=35,
  45. cyan=36,
  46. white=37,
  47. Black=40,
  48. Red=41,
  49. Green=42,
  50. Yellow=43,
  51. Blue=44,
  52. Purple=45,
  53. Cyan=46,
  54. White=47,
  55. bold=1,
  56. light=2,
  57. blink=5,
  58. invert=7,
  59. )
  60. def __init__(self, file: TextIO | None = None) -> None:
  61. if file is None:
  62. file = sys.stdout
  63. if hasattr(file, "isatty") and file.isatty() and sys.platform == "win32":
  64. try:
  65. import colorama
  66. except ImportError:
  67. pass
  68. else:
  69. file = colorama.AnsiToWin32(file).stream
  70. assert file is not None
  71. self._file = file
  72. self.hasmarkup = should_do_markup(file)
  73. self._current_line = ""
  74. self._terminal_width: int | None = None
  75. self.code_highlight = True
  76. @property
  77. def fullwidth(self) -> int:
  78. if self._terminal_width is not None:
  79. return self._terminal_width
  80. return get_terminal_width()
  81. @fullwidth.setter
  82. def fullwidth(self, value: int) -> None:
  83. self._terminal_width = value
  84. @property
  85. def width_of_current_line(self) -> int:
  86. """Return an estimate of the width so far in the current line."""
  87. return wcswidth(self._current_line)
  88. def markup(self, text: str, **markup: bool) -> str:
  89. for name in markup:
  90. if name not in self._esctable:
  91. raise ValueError(f"unknown markup: {name!r}")
  92. if self.hasmarkup:
  93. esc = [self._esctable[name] for name, on in markup.items() if on]
  94. if esc:
  95. text = "".join(f"\x1b[{cod}m" for cod in esc) + text + "\x1b[0m"
  96. return text
  97. def sep(
  98. self,
  99. sepchar: str,
  100. title: str | None = None,
  101. fullwidth: int | None = None,
  102. **markup: bool,
  103. ) -> None:
  104. if fullwidth is None:
  105. fullwidth = self.fullwidth
  106. # The goal is to have the line be as long as possible
  107. # under the condition that len(line) <= fullwidth.
  108. if sys.platform == "win32":
  109. # If we print in the last column on windows we are on a
  110. # new line but there is no way to verify/neutralize this
  111. # (we may not know the exact line width).
  112. # So let's be defensive to avoid empty lines in the output.
  113. fullwidth -= 1
  114. if title is not None:
  115. # we want 2 + 2*len(fill) + len(title) <= fullwidth
  116. # i.e. 2 + 2*len(sepchar)*N + len(title) <= fullwidth
  117. # 2*len(sepchar)*N <= fullwidth - len(title) - 2
  118. # N <= (fullwidth - len(title) - 2) // (2*len(sepchar))
  119. N = max((fullwidth - len(title) - 2) // (2 * len(sepchar)), 1)
  120. fill = sepchar * N
  121. line = f"{fill} {title} {fill}"
  122. else:
  123. # we want len(sepchar)*N <= fullwidth
  124. # i.e. N <= fullwidth // len(sepchar)
  125. line = sepchar * (fullwidth // len(sepchar))
  126. # In some situations there is room for an extra sepchar at the right,
  127. # in particular if we consider that with a sepchar like "_ " the
  128. # trailing space is not important at the end of the line.
  129. if len(line) + len(sepchar.rstrip()) <= fullwidth:
  130. line += sepchar.rstrip()
  131. self.line(line, **markup)
  132. def write(self, msg: str, *, flush: bool = False, **markup: bool) -> None:
  133. if msg:
  134. current_line = msg.rsplit("\n", 1)[-1]
  135. if "\n" in msg:
  136. self._current_line = current_line
  137. else:
  138. self._current_line += current_line
  139. msg = self.markup(msg, **markup)
  140. try:
  141. self._file.write(msg)
  142. except UnicodeEncodeError:
  143. # Some environments don't support printing general Unicode
  144. # strings, due to misconfiguration or otherwise; in that case,
  145. # print the string escaped to ASCII.
  146. # When the Unicode situation improves we should consider
  147. # letting the error propagate instead of masking it (see #7475
  148. # for one brief attempt).
  149. msg = msg.encode("unicode-escape").decode("ascii")
  150. self._file.write(msg)
  151. if flush:
  152. self.flush()
  153. def line(self, s: str = "", **markup: bool) -> None:
  154. self.write(s, **markup)
  155. self.write("\n")
  156. def flush(self) -> None:
  157. self._file.flush()
  158. def _write_source(self, lines: Sequence[str], indents: Sequence[str] = ()) -> None:
  159. """Write lines of source code possibly highlighted.
  160. Keeping this private for now because the API is clunky. We should discuss how
  161. to evolve the terminal writer so we can have more precise color support, for example
  162. being able to write part of a line in one color and the rest in another, and so on.
  163. """
  164. if indents and len(indents) != len(lines):
  165. raise ValueError(
  166. f"indents size ({len(indents)}) should have same size as lines ({len(lines)})"
  167. )
  168. if not indents:
  169. indents = [""] * len(lines)
  170. source = "\n".join(lines)
  171. new_lines = self._highlight(source).splitlines()
  172. for indent, new_line in zip(indents, new_lines):
  173. self.line(indent + new_line)
  174. def _get_pygments_lexer(self, lexer: Literal["python", "diff"]) -> Lexer:
  175. if lexer == "python":
  176. return PythonLexer()
  177. elif lexer == "diff":
  178. return DiffLexer()
  179. else:
  180. assert_never(lexer)
  181. def _get_pygments_formatter(self) -> TerminalFormatter:
  182. from _pytest.config.exceptions import UsageError
  183. theme = os.getenv("PYTEST_THEME")
  184. theme_mode = os.getenv("PYTEST_THEME_MODE", "dark")
  185. try:
  186. return TerminalFormatter(bg=theme_mode, style=theme)
  187. except pygments.util.ClassNotFound as e:
  188. raise UsageError(
  189. f"PYTEST_THEME environment variable has an invalid value: '{theme}'. "
  190. "Hint: See available pygments styles with `pygmentize -L styles`."
  191. ) from e
  192. except pygments.util.OptionError as e:
  193. raise UsageError(
  194. f"PYTEST_THEME_MODE environment variable has an invalid value: '{theme_mode}'. "
  195. "The allowed values are 'dark' (default) and 'light'."
  196. ) from e
  197. def _highlight(
  198. self, source: str, lexer: Literal["diff", "python"] = "python"
  199. ) -> str:
  200. """Highlight the given source if we have markup support."""
  201. if not source or not self.hasmarkup or not self.code_highlight:
  202. return source
  203. pygments_lexer = self._get_pygments_lexer(lexer)
  204. pygments_formatter = self._get_pygments_formatter()
  205. highlighted: str = pygments.highlight(
  206. source, pygments_lexer, pygments_formatter
  207. )
  208. # pygments terminal formatter may add a newline when there wasn't one.
  209. # We don't want this, remove.
  210. if highlighted[-1] == "\n" and source[-1] != "\n":
  211. highlighted = highlighted[:-1]
  212. # Some lexers will not set the initial color explicitly
  213. # which may lead to the previous color being propagated to the
  214. # start of the expression, so reset first.
  215. highlighted = "\x1b[0m" + highlighted
  216. return highlighted