Skip to content

Commit a601816

Browse files
committed
Enable the use of rich for presenting output
This makes it possible to present output with rich markup, within the constraints of our logging infrastructure. Further, diagnostic errors can now by presented using rich, using their own special "[present-diagnostic]" marker string, since those need to be handled differently from regular log messages and passed directly through to rich's console object, after an indentation wrapper.
1 parent 1c2bba7 commit a601816

File tree

4 files changed

+73
-98
lines changed

4 files changed

+73
-98
lines changed

src/pip/_internal/cli/base_command.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010
from optparse import Values
1111
from typing import Any, Callable, List, Optional, Tuple
1212

13-
from pip._vendor import rich
14-
1513
from pip._internal.cli import cmdoptions
1614
from pip._internal.cli.command_context import CommandContextMixIn
1715
from pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter
@@ -168,7 +166,7 @@ def exc_logging_wrapper(*args: Any) -> int:
168166
assert isinstance(status, int)
169167
return status
170168
except DiagnosticPipError as exc:
171-
rich.print(exc, file=sys.stderr)
169+
logger.error("[present-diagnostic]", exc)
172170
logger.debug("Exception information:", exc_info=True)
173171

174172
return ERROR

src/pip/_internal/exceptions.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ def _prefix_with_indent(
3636
else:
3737
text = console.render_str(s)
3838

39-
lines = text.wrap(console, console.width - width_offset)
40-
41-
return console.render_str(prefix) + console.render_str(f"\n{indent}").join(lines)
39+
return console.render_str(prefix, overflow="ignore") + console.render_str(
40+
f"\n{indent}", overflow="ignore"
41+
).join(text.split(allow_blank=True))
4242

4343

4444
class PipError(Exception):

src/pip/_internal/utils/logging.py

+65-88
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,27 @@
44
import logging.handlers
55
import os
66
import sys
7+
import threading
8+
from dataclasses import dataclass
79
from logging import Filter
8-
from typing import IO, Any, Callable, Iterator, Optional, TextIO, Type, cast
9-
10+
from typing import IO, Any, ClassVar, Iterator, List, Optional, TextIO, Type
11+
12+
from pip._vendor.rich.console import (
13+
Console,
14+
ConsoleOptions,
15+
ConsoleRenderable,
16+
RenderResult,
17+
)
18+
from pip._vendor.rich.highlighter import NullHighlighter
19+
from pip._vendor.rich.logging import RichHandler
20+
from pip._vendor.rich.segment import Segment
21+
22+
from pip._internal.exceptions import DiagnosticPipError
1023
from pip._internal.utils._log import VERBOSE, getLogger
1124
from pip._internal.utils.compat import WINDOWS
1225
from pip._internal.utils.deprecation import DEPRECATION_MSG_PREFIX
1326
from pip._internal.utils.misc import ensure_dir
1427

15-
try:
16-
import threading
17-
except ImportError:
18-
import dummy_threading as threading # type: ignore
19-
20-
21-
try:
22-
from pip._vendor import colorama
23-
# Lots of different errors can come from this, including SystemError and
24-
# ImportError.
25-
except Exception:
26-
colorama = None
27-
28-
2928
_log_state = threading.local()
3029
subprocess_logger = getLogger("pip.subprocessor")
3130

@@ -119,78 +118,56 @@ def format(self, record: logging.LogRecord) -> str:
119118
return formatted
120119

121120

122-
def _color_wrap(*colors: str) -> Callable[[str], str]:
123-
def wrapped(inp: str) -> str:
124-
return "".join(list(colors) + [inp, colorama.Style.RESET_ALL])
125-
126-
return wrapped
127-
128-
129-
class ColorizedStreamHandler(logging.StreamHandler):
130-
131-
# Don't build up a list of colors if we don't have colorama
132-
if colorama:
133-
COLORS = [
134-
# This needs to be in order from highest logging level to lowest.
135-
(logging.ERROR, _color_wrap(colorama.Fore.RED)),
136-
(logging.WARNING, _color_wrap(colorama.Fore.YELLOW)),
137-
]
138-
else:
139-
COLORS = []
140-
141-
def __init__(self, stream: Optional[TextIO] = None, no_color: bool = None) -> None:
142-
super().__init__(stream)
143-
self._no_color = no_color
144-
145-
if WINDOWS and colorama:
146-
self.stream = colorama.AnsiToWin32(self.stream)
147-
148-
def _using_stdout(self) -> bool:
149-
"""
150-
Return whether the handler is using sys.stdout.
151-
"""
152-
if WINDOWS and colorama:
153-
# Then self.stream is an AnsiToWin32 object.
154-
stream = cast(colorama.AnsiToWin32, self.stream)
155-
return stream.wrapped is sys.stdout
156-
157-
return self.stream is sys.stdout
158-
159-
def should_color(self) -> bool:
160-
# Don't colorize things if we do not have colorama or if told not to
161-
if not colorama or self._no_color:
162-
return False
163-
164-
real_stream = (
165-
self.stream
166-
if not isinstance(self.stream, colorama.AnsiToWin32)
167-
else self.stream.wrapped
121+
@dataclass
122+
class IndentedRenderable:
123+
renderable: ConsoleRenderable
124+
indent: int
125+
126+
def __rich_console__(
127+
self, console: Console, options: ConsoleOptions
128+
) -> RenderResult:
129+
segments = console.render(self.renderable, options)
130+
lines = Segment.split_lines(segments)
131+
for line in lines:
132+
yield Segment(" " * self.indent)
133+
yield from line
134+
yield Segment("\n")
135+
136+
137+
class RichPipStreamHandler(RichHandler):
138+
KEYWORDS: ClassVar[Optional[List[str]]] = []
139+
140+
def __init__(self, stream: Optional[TextIO], no_color: bool) -> None:
141+
super().__init__(
142+
console=Console(file=stream, no_color=no_color, soft_wrap=True),
143+
show_time=False,
144+
show_level=False,
145+
show_path=False,
146+
highlighter=NullHighlighter(),
168147
)
169148

170-
# If the stream is a tty we should color it
171-
if hasattr(real_stream, "isatty") and real_stream.isatty():
172-
return True
173-
174-
# If we have an ANSI term we should color it
175-
if os.environ.get("TERM") == "ANSI":
176-
return True
177-
178-
# If anything else we should not color it
179-
return False
180-
181-
def format(self, record: logging.LogRecord) -> str:
182-
msg = super().format(record)
183-
184-
if self.should_color():
185-
for level, color in self.COLORS:
186-
if record.levelno >= level:
187-
msg = color(msg)
188-
break
189-
190-
return msg
149+
# Our custom override on rich's logger, to make things work as we need them to.
150+
def emit(self, record: logging.LogRecord) -> None:
151+
# If we are given a diagnostic error to present, present it with indentation.
152+
if record.msg == "[present-diagnostic]" and len(record.args) == 1:
153+
diagnostic_error: DiagnosticPipError = record.args[0] # type: ignore[index]
154+
assert isinstance(diagnostic_error, DiagnosticPipError)
155+
156+
renderable: ConsoleRenderable = IndentedRenderable(
157+
diagnostic_error, indent=get_indentation()
158+
)
159+
else:
160+
message = self.format(record)
161+
renderable = self.render_message(record, message)
162+
163+
try:
164+
self.console.print(renderable, overflow="ignore", crop=False)
165+
except Exception:
166+
self.handleError(record)
191167

192-
# The logging module says handleError() can be customized.
193168
def handleError(self, record: logging.LogRecord) -> None:
169+
"""Called when logging is unable to log some output."""
170+
194171
exc_class, exc = sys.exc_info()[:2]
195172
# If a broken pipe occurred while calling write() or flush() on the
196173
# stdout stream in logging's Handler.emit(), then raise our special
@@ -199,7 +176,7 @@ def handleError(self, record: logging.LogRecord) -> None:
199176
if (
200177
exc_class
201178
and exc
202-
and self._using_stdout()
179+
and self.console.file is sys.stdout
203180
and _is_broken_pipe_error(exc_class, exc)
204181
):
205182
raise BrokenStdoutLoggingError()
@@ -275,7 +252,8 @@ def setup_logging(verbosity: int, no_color: bool, user_log_file: Optional[str])
275252
"stderr": "ext://sys.stderr",
276253
}
277254
handler_classes = {
278-
"stream": "pip._internal.utils.logging.ColorizedStreamHandler",
255+
"subprocess": "logging.StreamHandler",
256+
"stream": "pip._internal.utils.logging.RichPipStreamHandler",
279257
"file": "pip._internal.utils.logging.BetterRotatingFileHandler",
280258
}
281259
handlers = ["console", "console_errors", "console_subprocess"] + (
@@ -332,8 +310,7 @@ def setup_logging(verbosity: int, no_color: bool, user_log_file: Optional[str])
332310
# from the "subprocessor" logger.
333311
"console_subprocess": {
334312
"level": level,
335-
"class": handler_classes["stream"],
336-
"no_color": no_color,
313+
"class": handler_classes["subprocess"],
337314
"stream": log_streams["stderr"],
338315
"filters": ["restrict_to_subprocess"],
339316
"formatter": "indent",

tests/unit/test_logging.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
from pip._internal.utils.logging import (
88
BrokenStdoutLoggingError,
9-
ColorizedStreamHandler,
109
IndentingFormatter,
10+
RichPipStreamHandler,
1111
indent_log,
1212
)
1313
from pip._internal.utils.misc import captured_stderr, captured_stdout
@@ -142,7 +142,7 @@ def test_broken_pipe_in_stderr_flush(self) -> None:
142142
record = self._make_log_record()
143143

144144
with captured_stderr() as stderr:
145-
handler = ColorizedStreamHandler(stream=stderr)
145+
handler = RichPipStreamHandler(stream=stderr, no_color=True)
146146
with patch("sys.stderr.flush") as mock_flush:
147147
mock_flush.side_effect = BrokenPipeError()
148148
# The emit() call raises no exception.
@@ -165,7 +165,7 @@ def test_broken_pipe_in_stdout_write(self) -> None:
165165
record = self._make_log_record()
166166

167167
with captured_stdout() as stdout:
168-
handler = ColorizedStreamHandler(stream=stdout)
168+
handler = RichPipStreamHandler(stream=stdout, no_color=True)
169169
with patch("sys.stdout.write") as mock_write:
170170
mock_write.side_effect = BrokenPipeError()
171171
with pytest.raises(BrokenStdoutLoggingError):
@@ -180,7 +180,7 @@ def test_broken_pipe_in_stdout_flush(self) -> None:
180180
record = self._make_log_record()
181181

182182
with captured_stdout() as stdout:
183-
handler = ColorizedStreamHandler(stream=stdout)
183+
handler = RichPipStreamHandler(stream=stdout, no_color=True)
184184
with patch("sys.stdout.flush") as mock_flush:
185185
mock_flush.side_effect = BrokenPipeError()
186186
with pytest.raises(BrokenStdoutLoggingError):

0 commit comments

Comments
 (0)