diff --git a/news/10703.feature.rst b/news/10703.feature.rst new file mode 100644 index 00000000000..4094f41405c --- /dev/null +++ b/news/10703.feature.rst @@ -0,0 +1 @@ +Start using Rich for presenting error messages in a consistent format. diff --git a/src/pip/_internal/cli/base_command.py b/src/pip/_internal/cli/base_command.py index 5c41488ce3c..81c443adf49 100644 --- a/src/pip/_internal/cli/base_command.py +++ b/src/pip/_internal/cli/base_command.py @@ -165,16 +165,16 @@ def exc_logging_wrapper(*args: Any) -> int: status = run_func(*args) assert isinstance(status, int) return status - except PreviousBuildDirError as exc: - logger.critical(str(exc)) + except DiagnosticPipError as exc: + logger.error("[present-diagnostic]", exc) logger.debug("Exception information:", exc_info=True) - return PREVIOUS_BUILD_DIR_ERROR - except DiagnosticPipError as exc: + return ERROR + except PreviousBuildDirError as exc: logger.critical(str(exc)) logger.debug("Exception information:", exc_info=True) - return ERROR + return PREVIOUS_BUILD_DIR_ERROR except ( InstallationError, UninstallationError, diff --git a/src/pip/_internal/exceptions.py b/src/pip/_internal/exceptions.py index 8a17996892f..952e063d789 100644 --- a/src/pip/_internal/exceptions.py +++ b/src/pip/_internal/exceptions.py @@ -3,9 +3,12 @@ import configparser import re from itertools import chain, groupby, repeat -from typing import TYPE_CHECKING, Dict, Iterator, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional, Union from pip._vendor.requests.models import Request, Response +from pip._vendor.rich.console import Console, ConsoleOptions, RenderResult +from pip._vendor.rich.markup import escape +from pip._vendor.rich.text import Text if TYPE_CHECKING: from hashlib import _Hash @@ -22,13 +25,21 @@ def _is_kebab_case(s: str) -> bool: return re.match(r"^[a-z]+(-[a-z]+)*$", s) is not None -def _prefix_with_indent(prefix: str, s: str, indent: Optional[str] = None) -> str: - if indent is None: - indent = " " * len(prefix) +def _prefix_with_indent( + s: Union[Text, str], + console: Console, + *, + prefix: str, + indent: str, +) -> Text: + if isinstance(s, Text): + text = s else: - assert len(indent) == len(prefix) - message = s.replace("\n", "\n" + indent) - return f"{prefix}{message}\n" + text = console.render_str(s) + + return console.render_str(prefix, overflow="ignore") + console.render_str( + f"\n{indent}", overflow="ignore" + ).join(text.split(allow_blank=True)) class PipError(Exception): @@ -36,12 +47,14 @@ class PipError(Exception): class DiagnosticPipError(PipError): - """A pip error, that presents diagnostic information to the user. + """An error, that presents diagnostic information to the user. This contains a bunch of logic, to enable pretty presentation of our error messages. Each error gets a unique reference. Each error can also include additional context, a hint and/or a note -- which are presented with the main error message in a consistent style. + + This is adapted from the error output styling in `sphinx-theme-builder`. """ reference: str @@ -49,48 +62,103 @@ class DiagnosticPipError(PipError): def __init__( self, *, - message: str, - context: Optional[str], - hint_stmt: Optional[str], - attention_stmt: Optional[str] = None, - reference: Optional[str] = None, kind: 'Literal["error", "warning"]' = "error", + reference: Optional[str] = None, + message: Union[str, Text], + context: Optional[Union[str, Text]], + hint_stmt: Optional[Union[str, Text]], + note_stmt: Optional[Union[str, Text]] = None, + link: Optional[str] = None, ) -> None: - # Ensure a proper reference is provided. if reference is None: assert hasattr(self, "reference"), "error reference not provided!" reference = self.reference assert _is_kebab_case(reference), "error reference must be kebab-case!" - super().__init__(f"{reference}: {message}") - self.kind = kind + self.reference = reference + self.message = message self.context = context - self.reference = reference - self.attention_stmt = attention_stmt + self.note_stmt = note_stmt self.hint_stmt = hint_stmt - def __str__(self) -> str: - return "".join(self._string_parts()) - - def _string_parts(self) -> Iterator[str]: - # Present the main message, with relevant context indented. - yield f"{self.message}\n" - if self.context is not None: - yield f"\n{self.context}\n" + self.link = link - # Space out the note/hint messages. - if self.attention_stmt is not None or self.hint_stmt is not None: - yield "\n" + super().__init__(f"<{self.__class__.__name__}: {self.reference}>") - if self.attention_stmt is not None: - yield _prefix_with_indent("Note: ", self.attention_stmt) + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__}(" + f"reference={self.reference!r}, " + f"message={self.message!r}, " + f"context={self.context!r}, " + f"note_stmt={self.note_stmt!r}, " + f"hint_stmt={self.hint_stmt!r}" + ")>" + ) + def __rich_console__( + self, + console: Console, + options: ConsoleOptions, + ) -> RenderResult: + colour = "red" if self.kind == "error" else "yellow" + + yield f"[{colour} bold]{self.kind}[/]: [bold]{self.reference}[/]" + yield "" + + if not options.ascii_only: + # Present the main message, with relevant context indented. + if self.context is not None: + yield _prefix_with_indent( + self.message, + console, + prefix=f"[{colour}]×[/] ", + indent=f"[{colour}]│[/] ", + ) + yield _prefix_with_indent( + self.context, + console, + prefix=f"[{colour}]╰─>[/] ", + indent=f"[{colour}] [/] ", + ) + else: + yield _prefix_with_indent( + self.message, + console, + prefix="[red]×[/] ", + indent=" ", + ) + else: + yield self.message + if self.context is not None: + yield "" + yield self.context + + if self.note_stmt is not None or self.hint_stmt is not None: + yield "" + + if self.note_stmt is not None: + yield _prefix_with_indent( + self.note_stmt, + console, + prefix="[magenta bold]note[/]: ", + indent=" ", + ) if self.hint_stmt is not None: - yield _prefix_with_indent("Hint: ", self.hint_stmt) + yield _prefix_with_indent( + self.hint_stmt, + console, + prefix="[cyan bold]hint[/]: ", + indent=" ", + ) + + if self.link is not None: + yield "" + yield f"Link: {self.link}" # @@ -115,15 +183,13 @@ class MissingPyProjectBuildRequires(DiagnosticPipError): def __init__(self, *, package: str) -> None: super().__init__( - message=f"Can not process {package}", - context=( + message=f"Can not process {escape(package)}", + context=Text( "This package has an invalid pyproject.toml file.\n" "The [build-system] table is missing the mandatory `requires` key." ), - attention_stmt=( - "This is an issue with the package mentioned above, not pip." - ), - hint_stmt="See PEP 518 for the detailed specification.", + note_stmt="This is an issue with the package mentioned above, not pip.", + hint_stmt=Text("See PEP 518 for the detailed specification."), ) @@ -134,16 +200,13 @@ class InvalidPyProjectBuildRequires(DiagnosticPipError): def __init__(self, *, package: str, reason: str) -> None: super().__init__( - message=f"Can not process {package}", - context=( + message=f"Can not process {escape(package)}", + context=Text( "This package has an invalid `build-system.requires` key in " - "pyproject.toml.\n" - f"{reason}" - ), - hint_stmt="See PEP 518 for the detailed specification.", - attention_stmt=( - "This is an issue with the package mentioned above, not pip." + f"pyproject.toml.\n{reason}" ), + note_stmt="This is an issue with the package mentioned above, not pip.", + hint_stmt=Text("See PEP 518 for the detailed specification."), ) diff --git a/src/pip/_internal/utils/logging.py b/src/pip/_internal/utils/logging.py index a4b828a84e1..1c0cd8e261e 100644 --- a/src/pip/_internal/utils/logging.py +++ b/src/pip/_internal/utils/logging.py @@ -4,28 +4,28 @@ import logging.handlers import os import sys +import threading +from dataclasses import dataclass from logging import Filter -from typing import IO, Any, Callable, Iterator, Optional, TextIO, Type, cast - +from typing import IO, Any, ClassVar, Iterator, List, Optional, TextIO, Type + +from pip._vendor.rich.console import ( + Console, + ConsoleOptions, + ConsoleRenderable, + RenderResult, +) +from pip._vendor.rich.highlighter import NullHighlighter +from pip._vendor.rich.logging import RichHandler +from pip._vendor.rich.segment import Segment +from pip._vendor.rich.style import Style + +from pip._internal.exceptions import DiagnosticPipError from pip._internal.utils._log import VERBOSE, getLogger from pip._internal.utils.compat import WINDOWS from pip._internal.utils.deprecation import DEPRECATION_MSG_PREFIX from pip._internal.utils.misc import ensure_dir -try: - import threading -except ImportError: - import dummy_threading as threading # type: ignore - - -try: - from pip._vendor import colorama -# Lots of different errors can come from this, including SystemError and -# ImportError. -except Exception: - colorama = None - - _log_state = threading.local() subprocess_logger = getLogger("pip.subprocessor") @@ -119,78 +119,63 @@ def format(self, record: logging.LogRecord) -> str: return formatted -def _color_wrap(*colors: str) -> Callable[[str], str]: - def wrapped(inp: str) -> str: - return "".join(list(colors) + [inp, colorama.Style.RESET_ALL]) - - return wrapped - - -class ColorizedStreamHandler(logging.StreamHandler): - - # Don't build up a list of colors if we don't have colorama - if colorama: - COLORS = [ - # This needs to be in order from highest logging level to lowest. - (logging.ERROR, _color_wrap(colorama.Fore.RED)), - (logging.WARNING, _color_wrap(colorama.Fore.YELLOW)), - ] - else: - COLORS = [] - - def __init__(self, stream: Optional[TextIO] = None, no_color: bool = None) -> None: - super().__init__(stream) - self._no_color = no_color - - if WINDOWS and colorama: - self.stream = colorama.AnsiToWin32(self.stream) - - def _using_stdout(self) -> bool: - """ - Return whether the handler is using sys.stdout. - """ - if WINDOWS and colorama: - # Then self.stream is an AnsiToWin32 object. - stream = cast(colorama.AnsiToWin32, self.stream) - return stream.wrapped is sys.stdout - - return self.stream is sys.stdout - - def should_color(self) -> bool: - # Don't colorize things if we do not have colorama or if told not to - if not colorama or self._no_color: - return False - - real_stream = ( - self.stream - if not isinstance(self.stream, colorama.AnsiToWin32) - else self.stream.wrapped +@dataclass +class IndentedRenderable: + renderable: ConsoleRenderable + indent: int + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + segments = console.render(self.renderable, options) + lines = Segment.split_lines(segments) + for line in lines: + yield Segment(" " * self.indent) + yield from line + yield Segment("\n") + + +class RichPipStreamHandler(RichHandler): + KEYWORDS: ClassVar[Optional[List[str]]] = [] + + def __init__(self, stream: Optional[TextIO], no_color: bool) -> None: + super().__init__( + console=Console(file=stream, no_color=no_color, soft_wrap=True), + show_time=False, + show_level=False, + show_path=False, + highlighter=NullHighlighter(), ) - # If the stream is a tty we should color it - if hasattr(real_stream, "isatty") and real_stream.isatty(): - return True - - # If we have an ANSI term we should color it - if os.environ.get("TERM") == "ANSI": - return True - - # If anything else we should not color it - return False - - def format(self, record: logging.LogRecord) -> str: - msg = super().format(record) - - if self.should_color(): - for level, color in self.COLORS: - if record.levelno >= level: - msg = color(msg) - break - - return msg + # Our custom override on Rich's logger, to make things work as we need them to. + def emit(self, record: logging.LogRecord) -> None: + style: Optional[Style] = None + + # If we are given a diagnostic error to present, present it with indentation. + if record.msg == "[present-diagnostic]" and len(record.args) == 1: + diagnostic_error: DiagnosticPipError = record.args[0] # type: ignore[index] + assert isinstance(diagnostic_error, DiagnosticPipError) + + renderable: ConsoleRenderable = IndentedRenderable( + diagnostic_error, indent=get_indentation() + ) + else: + message = self.format(record) + renderable = self.render_message(record, message) + if record.levelno is not None: + if record.levelno >= logging.ERROR: + style = Style(color="red") + elif record.levelno >= logging.WARNING: + style = Style(color="yellow") + + try: + self.console.print(renderable, overflow="ignore", crop=False, style=style) + except Exception: + self.handleError(record) - # The logging module says handleError() can be customized. def handleError(self, record: logging.LogRecord) -> None: + """Called when logging is unable to log some output.""" + exc_class, exc = sys.exc_info()[:2] # If a broken pipe occurred while calling write() or flush() on the # stdout stream in logging's Handler.emit(), then raise our special @@ -199,7 +184,7 @@ def handleError(self, record: logging.LogRecord) -> None: if ( exc_class and exc - and self._using_stdout() + and self.console.file is sys.stdout and _is_broken_pipe_error(exc_class, exc) ): raise BrokenStdoutLoggingError() @@ -275,7 +260,7 @@ def setup_logging(verbosity: int, no_color: bool, user_log_file: Optional[str]) "stderr": "ext://sys.stderr", } handler_classes = { - "stream": "pip._internal.utils.logging.ColorizedStreamHandler", + "stream": "pip._internal.utils.logging.RichPipStreamHandler", "file": "pip._internal.utils.logging.BetterRotatingFileHandler", } handlers = ["console", "console_errors", "console_subprocess"] + ( @@ -333,8 +318,8 @@ def setup_logging(verbosity: int, no_color: bool, user_log_file: Optional[str]) "console_subprocess": { "level": level, "class": handler_classes["stream"], - "no_color": no_color, "stream": log_streams["stderr"], + "no_color": no_color, "filters": ["restrict_to_subprocess"], "formatter": "indent", }, diff --git a/tests/functional/test_no_color.py b/tests/functional/test_no_color.py index 9ead9996ad8..4094bdd167a 100644 --- a/tests/functional/test_no_color.py +++ b/tests/functional/test_no_color.py @@ -2,13 +2,16 @@ Test specific for the --no-color option """ import os +import shutil import subprocess +import sys import pytest from tests.lib import PipTestEnvironment +@pytest.mark.skipif(shutil.which("script") is None, reason="no 'script' executable") def test_no_color(script: PipTestEnvironment) -> None: """Ensure colour output disabled when --no-color is passed.""" # Using 'script' in this test allows for transparently testing pip's output @@ -19,12 +22,13 @@ def test_no_color(script: PipTestEnvironment) -> None: # 'script' and well as the mere use of the same. # # This test will stay until someone has the time to rewrite it. - command = ( - "script --flush --quiet --return /tmp/pip-test-no-color.txt " - '--command "pip uninstall {} noSuchPackage"' - ) + pip_command = "pip uninstall {} noSuchPackage" + if sys.platform == "darwin": + command = f"script -q /tmp/pip-test-no-color.txt {pip_command}" + else: + command = f'script -q /tmp/pip-test-no-color.txt --command "{pip_command}"' - def get_run_output(option: str) -> str: + def get_run_output(option: str = "") -> str: cmd = command.format(option) proc = subprocess.Popen( cmd, @@ -33,8 +37,6 @@ def get_run_output(option: str) -> str: stderr=subprocess.PIPE, ) proc.communicate() - if proc.returncode: - pytest.skip("Unable to capture output using script: " + cmd) try: with open("/tmp/pip-test-no-color.txt") as output_file: @@ -43,7 +45,5 @@ def get_run_output(option: str) -> str: finally: os.unlink("/tmp/pip-test-no-color.txt") - assert "\x1b" in get_run_output(option=""), "Expected color in output" - assert "\x1b" not in get_run_output( - option="--no-color" - ), "Expected no color in output" + assert "\x1b[3" in get_run_output(""), "Expected color in output" + assert "\x1b[3" not in get_run_output("--no-color"), "Expected no color in output" diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 45e6bee10da..8f8224dc817 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -1,8 +1,10 @@ """Tests the presentation style of exceptions.""" +import io import textwrap import pytest +from pip._vendor import rich from pip._internal.exceptions import DiagnosticPipError @@ -57,26 +59,66 @@ class DerivedError(DiagnosticPipError): assert str(exc_info.value) == "error reference must be kebab-case!" +def rendered_in_ascii(error: DiagnosticPipError, *, color: bool = False) -> str: + with io.BytesIO() as stream: + console = rich.console.Console( + force_terminal=False, + file=io.TextIOWrapper(stream, encoding="ascii", newline=""), + color_system="truecolor" if color else None, + ) + console.print(error) + return stream.getvalue().decode("ascii") + + class TestDiagnosticPipErrorPresentation_ASCII: def test_complete(self) -> None: err = DiagnosticPipError( reference="test-diagnostic", message="Oh no!\nIt broke. :(", context="Something went wrong\nvery wrong.", - attention_stmt="You did something wrong, which is what caused this error.", + note_stmt="You did something wrong, which is what caused this error.", hint_stmt="Do it better next time, by trying harder.", ) - assert str(err) == textwrap.dedent( + assert rendered_in_ascii(err) == textwrap.dedent( """\ + error: test-diagnostic + Oh no! It broke. :( Something went wrong very wrong. - Note: You did something wrong, which is what caused this error. - Hint: Do it better next time, by trying harder. + note: You did something wrong, which is what caused this error. + hint: Do it better next time, by trying harder. + """ + ) + + def test_complete_color(self) -> None: + err = DiagnosticPipError( + reference="test-diagnostic", + message="Oh no!\nIt broke.", + context="Something went wrong\nvery wrong.", + note_stmt="You did something wrong.", + hint_stmt="Do it better next time, by trying harder.", + ) + + def esc(code: str = "0") -> str: + return f"\x1b[{code}m" + + assert rendered_in_ascii(err, color=True) == textwrap.dedent( + f"""\ + {esc("1;31")}error{esc("0")}: {esc("1")}test-diagnostic{esc("0")} + + Oh no! + It broke. + + Something went wrong + very wrong. + + {esc("1;35")}note{esc("0")}: You did something wrong. + {esc("1;36")}hint{esc("0")}: Do it better next time, by trying harder. """ ) @@ -85,17 +127,19 @@ def test_no_context(self) -> None: reference="test-diagnostic", message="Oh no!\nIt broke. :(", context=None, - attention_stmt="You did something wrong, which is what caused this error.", + note_stmt="You did something wrong, which is what caused this error.", hint_stmt="Do it better next time, by trying harder.", ) - assert str(err) == textwrap.dedent( + assert rendered_in_ascii(err) == textwrap.dedent( """\ + error: test-diagnostic + Oh no! It broke. :( - Note: You did something wrong, which is what caused this error. - Hint: Do it better next time, by trying harder. + note: You did something wrong, which is what caused this error. + hint: Do it better next time, by trying harder. """ ) @@ -104,19 +148,21 @@ def test_no_note(self) -> None: reference="test-diagnostic", message="Oh no!\nIt broke. :(", context="Something went wrong\nvery wrong.", - attention_stmt=None, + note_stmt=None, hint_stmt="Do it better next time, by trying harder.", ) - assert str(err) == textwrap.dedent( + assert rendered_in_ascii(err) == textwrap.dedent( """\ + error: test-diagnostic + Oh no! It broke. :( Something went wrong very wrong. - Hint: Do it better next time, by trying harder. + hint: Do it better next time, by trying harder. """ ) @@ -125,19 +171,21 @@ def test_no_hint(self) -> None: reference="test-diagnostic", message="Oh no!\nIt broke. :(", context="Something went wrong\nvery wrong.", - attention_stmt="You did something wrong, which is what caused this error.", + note_stmt="You did something wrong, which is what caused this error.", hint_stmt=None, ) - assert str(err) == textwrap.dedent( + assert rendered_in_ascii(err) == textwrap.dedent( """\ + error: test-diagnostic + Oh no! It broke. :( Something went wrong very wrong. - Note: You did something wrong, which is what caused this error. + note: You did something wrong, which is what caused this error. """ ) @@ -146,16 +194,18 @@ def test_no_context_no_hint(self) -> None: reference="test-diagnostic", message="Oh no!\nIt broke. :(", context=None, - attention_stmt="You did something wrong, which is what caused this error.", + note_stmt="You did something wrong, which is what caused this error.", hint_stmt=None, ) - assert str(err) == textwrap.dedent( + assert rendered_in_ascii(err) == textwrap.dedent( """\ + error: test-diagnostic + Oh no! It broke. :( - Note: You did something wrong, which is what caused this error. + note: You did something wrong, which is what caused this error. """ ) @@ -164,16 +214,18 @@ def test_no_context_no_note(self) -> None: reference="test-diagnostic", message="Oh no!\nIt broke. :(", context=None, - attention_stmt=None, + note_stmt=None, hint_stmt="Do it better next time, by trying harder.", ) - assert str(err) == textwrap.dedent( + assert rendered_in_ascii(err) == textwrap.dedent( """\ + error: test-diagnostic + Oh no! It broke. :( - Hint: Do it better next time, by trying harder. + hint: Do it better next time, by trying harder. """ ) @@ -182,12 +234,14 @@ def test_no_hint_no_note(self) -> None: reference="test-diagnostic", message="Oh no!\nIt broke. :(", context="Something went wrong\nvery wrong.", - attention_stmt=None, + note_stmt=None, hint_stmt=None, ) - assert str(err) == textwrap.dedent( + assert rendered_in_ascii(err) == textwrap.dedent( """\ + error: test-diagnostic + Oh no! It broke. :( @@ -202,12 +256,219 @@ def test_no_hint_no_note_no_context(self) -> None: message="Oh no!\nIt broke. :(", context=None, hint_stmt=None, - attention_stmt=None, + note_stmt=None, ) - assert str(err) == textwrap.dedent( + assert rendered_in_ascii(err) == textwrap.dedent( """\ + error: test-diagnostic + Oh no! It broke. :( """ ) + + +def rendered(error: DiagnosticPipError, *, color: bool = False) -> str: + with io.StringIO() as stream: + console = rich.console.Console( + force_terminal=False, + file=stream, + color_system="truecolor" if color else None, + ) + console.print(error) + return stream.getvalue() + + +class TestDiagnosticPipErrorPresentation_Unicode: + def test_complete(self) -> None: + err = DiagnosticPipError( + reference="test-diagnostic", + message="Oh no!\nIt broke. :(", + context="Something went wrong\nvery wrong.", + note_stmt="You did something wrong, which is what caused this error.", + hint_stmt="Do it better next time, by trying harder.", + ) + + assert rendered(err) == textwrap.dedent( + """\ + error: test-diagnostic + + × Oh no! + │ It broke. :( + ╰─> Something went wrong + very wrong. + + note: You did something wrong, which is what caused this error. + hint: Do it better next time, by trying harder. + """ + ) + + def test_complete_color(self) -> None: + err = DiagnosticPipError( + reference="test-diagnostic", + message="Oh no!\nIt broke.", + context="Something went wrong\nvery wrong.", + note_stmt="You did something wrong.", + hint_stmt="Do it better next time, by trying harder.", + ) + + def esc(code: str = "0") -> str: + return f"\x1b[{code}m" + + assert rendered(err, color=True) == textwrap.dedent( + f"""\ + {esc("1;31")}error{esc("0")}: {esc("1")}test-diagnostic{esc("0")} + + {esc("31")}×{esc("0")} Oh no! + {esc("31")}│{esc("0")} It broke. + {esc("31")}╰─>{esc("0")} Something went wrong + {esc("31")} {esc("0")} very wrong. + + {esc("1;35")}note{esc("0")}: You did something wrong. + {esc("1;36")}hint{esc("0")}: Do it better next time, by trying harder. + """ + ) + + def test_no_context(self) -> None: + err = DiagnosticPipError( + reference="test-diagnostic", + message="Oh no!\nIt broke. :(", + context=None, + note_stmt="You did something wrong, which is what caused this error.", + hint_stmt="Do it better next time, by trying harder.", + ) + + assert rendered(err) == textwrap.dedent( + """\ + error: test-diagnostic + + × Oh no! + It broke. :( + + note: You did something wrong, which is what caused this error. + hint: Do it better next time, by trying harder. + """ + ) + + def test_no_note(self) -> None: + err = DiagnosticPipError( + reference="test-diagnostic", + message="Oh no!\nIt broke. :(", + context="Something went wrong\nvery wrong.", + note_stmt=None, + hint_stmt="Do it better next time, by trying harder.", + ) + + assert rendered(err) == textwrap.dedent( + """\ + error: test-diagnostic + + × Oh no! + │ It broke. :( + ╰─> Something went wrong + very wrong. + + hint: Do it better next time, by trying harder. + """ + ) + + def test_no_hint(self) -> None: + err = DiagnosticPipError( + reference="test-diagnostic", + message="Oh no!\nIt broke. :(", + context="Something went wrong\nvery wrong.", + note_stmt="You did something wrong, which is what caused this error.", + hint_stmt=None, + ) + + assert rendered(err) == textwrap.dedent( + """\ + error: test-diagnostic + + × Oh no! + │ It broke. :( + ╰─> Something went wrong + very wrong. + + note: You did something wrong, which is what caused this error. + """ + ) + + def test_no_context_no_hint(self) -> None: + err = DiagnosticPipError( + reference="test-diagnostic", + message="Oh no!\nIt broke. :(", + context=None, + note_stmt="You did something wrong, which is what caused this error.", + hint_stmt=None, + ) + + assert rendered(err) == textwrap.dedent( + """\ + error: test-diagnostic + + × Oh no! + It broke. :( + + note: You did something wrong, which is what caused this error. + """ + ) + + def test_no_context_no_note(self) -> None: + err = DiagnosticPipError( + reference="test-diagnostic", + message="Oh no!\nIt broke. :(", + context=None, + note_stmt=None, + hint_stmt="Do it better next time, by trying harder.", + ) + + assert rendered(err) == textwrap.dedent( + """\ + error: test-diagnostic + + × Oh no! + It broke. :( + + hint: Do it better next time, by trying harder. + """ + ) + + def test_no_hint_no_note(self) -> None: + err = DiagnosticPipError( + reference="test-diagnostic", + message="Oh no!\nIt broke. :(", + context="Something went wrong\nvery wrong.", + note_stmt=None, + hint_stmt=None, + ) + + assert rendered(err) == textwrap.dedent( + """\ + error: test-diagnostic + + × Oh no! + │ It broke. :( + ╰─> Something went wrong + very wrong. + """ + ) + + def test_no_hint_no_note_no_context(self) -> None: + err = DiagnosticPipError( + reference="test-diagnostic", + message="Oh no!\nIt broke. :(", + context=None, + hint_stmt=None, + note_stmt=None, + ) + + assert rendered(err) == textwrap.dedent( + """\ + error: test-diagnostic + + × Oh no! + It broke. :( + """ + ) diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index 7b90d5dcc70..4f0447931dd 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -6,8 +6,8 @@ from pip._internal.utils.logging import ( BrokenStdoutLoggingError, - ColorizedStreamHandler, IndentingFormatter, + RichPipStreamHandler, indent_log, ) from pip._internal.utils.misc import captured_stderr, captured_stdout @@ -142,7 +142,7 @@ def test_broken_pipe_in_stderr_flush(self) -> None: record = self._make_log_record() with captured_stderr() as stderr: - handler = ColorizedStreamHandler(stream=stderr) + handler = RichPipStreamHandler(stream=stderr, no_color=True) with patch("sys.stderr.flush") as mock_flush: mock_flush.side_effect = BrokenPipeError() # The emit() call raises no exception. @@ -165,7 +165,7 @@ def test_broken_pipe_in_stdout_write(self) -> None: record = self._make_log_record() with captured_stdout() as stdout: - handler = ColorizedStreamHandler(stream=stdout) + handler = RichPipStreamHandler(stream=stdout, no_color=True) with patch("sys.stdout.write") as mock_write: mock_write.side_effect = BrokenPipeError() with pytest.raises(BrokenStdoutLoggingError): @@ -180,7 +180,7 @@ def test_broken_pipe_in_stdout_flush(self) -> None: record = self._make_log_record() with captured_stdout() as stdout: - handler = ColorizedStreamHandler(stream=stdout) + handler = RichPipStreamHandler(stream=stdout, no_color=True) with patch("sys.stdout.flush") as mock_flush: mock_flush.side_effect = BrokenPipeError() with pytest.raises(BrokenStdoutLoggingError):