Repository: pypa/pip
Pull request #10703 (2021-12-03 14:32:09)

Improve presentation of diagnostic errors using rich
Toward #10421 

Before:

<img width="993" alt="Screenshot 2021-12-03 at 14 24 29" src="https://user-images.githubusercontent.com/3275593/144618594-222e1e34-0c0a-4e97-a1f5-b1aa280e8c11.png">

After:

<img width="994" alt="Screenshot 2021-12-03 at 14 23 53" src="https://user-images.githubusercontent.com/3275593/144618608-7efd3789-9908-4df3-97cf-efc7b6a79efb.png">


pradyunsg: Ooops, I thought I'd opened this as a draft. Well, assuming the CI passes, this is ready for review! :)
DiddiLeija: Just update `tests/functional/test_no_color.py`, because it failed:

```
>       assert "\x1b" in get_run_output(option=""), "Expected color in output"
E       AssertionError: Expected color in output
E       assert '\x1b' in 'Script started on 2021-12-03 15:22:51+00:00 [<not executed on terminal>]\nWARNING: Skipping noSuchPackage as it is not installed.\n\nScript done on 2021-12-03 15:22:51+00:00 [COMMAND_EXIT_CODE="0"]\n'
E        +  where 'Script started on 2021-12-03 15:22:51+00:00 [<not executed on terminal>]\nWARNING: Skipping noSuchPackage as it is not installed.\n\nScript done on 2021-12-03 15:22:51+00:00 [COMMAND_EXIT_CODE="0"]\n' = <function test_no_color.<locals>.get_run_output at 0x7f8774180040>(option='')
```

Seems like the behavior has changed here.

But from the linting side, everything looks good :+1:
pradyunsg: Yea, that test is going to be non-trivial to update.

The failure is basically because rich is smarter than pip's old logic about whether colours would make sense, and this breaks the assumption that the test was written with. I'm leaning toward redoing that test, to be `pty`-based to get colours working again, but I'd like to see if anyone else has opinions/thoughts on this. :)
pradyunsg: Hurray! Ready for review now that I figured out how to make the test work on MacOS and Ubuntu. :)
pradyunsg: > The failure is basically because rich is smarter than pip's old logic about whether colours would make sense, and this breaks the assumption that the test was written with. I'm leaning toward redoing that test, to be `pty`-based to get colours working again, but I'd like to see if anyone else has opinions/thoughts on this. :)

This was wrong and I'm glad that the test broke.

I've changed `ERROR` and `WARNING` log messages to no longer be red / yellow. Not sure if anyone feels strongly about this, but hey, that's what code review is supposed to be for -- and the test did the right thing. :)

uranusjr: > I've changed `ERROR` and `WARNING` log messages to no longer be red / yellow.

What are they now? (I didn’t read the change yet.)
pradyunsg: > What are they now? (I didn’t read the change yet.)

They were unstylized basically. I realised that this might not be ideal while we're transitioning over though, so I've gone ahead and readded the colours for this.
pradyunsg: Rebased, and should be good to review now!
hugovk: Good work, so much more readable than a wall of red! 🎨
ashb: > Yea, that test is going to be non-trivial to update.
> 
> The failure is basically because rich is smarter than pip's old logic about whether colours would make sense, and this breaks the assumption that the test was written with. I'm leaning toward redoing that test, to be `pty`-based to get colours working again, but I'd like to see if anyone else has opinions/thoughts on this. :)

Isn't using a pty in the test effectively re-testing Rich's own tested code?
pradyunsg: > Isn't using a pty in the test effectively re-testing Rich's own tested code?

No. It's testing that the output respects pip's own `--no-color` flag.
pradyunsg: Nudging @pypa/pip-committers for a review on this. :)
pradyunsg: Oh, and this is easiest to review commit-by-commit. The first commit is what makes the patch big.
pradyunsg: This doesn't really have any functional behaviour changes (i.e. it won't change what gets installed or stuff like that), so I am somewhat comfortable just merging this without having it go through a rigorous review from another maintainer.

I'd still _strongly_ prefer to get some more 👀 through this PR, in a manner similar to other PRs for #10421. 

hugovk: Testing this branch (macOS Monterey, Python 3.10.1, iTerm2), I get slightly different output to the example up top:

![image](https://user-images.githubusercontent.com/1324225/145713104-b55f9fa9-0e16-46b6-a6b0-e26372093511.png)

It also highlights the digits of the PEP number here. Not a big deal, I note this is how vanilla rich works:

![image](https://user-images.githubusercontent.com/1324225/145713257-e554fb14-5250-481e-80ee-e3241fa080ef.png)



Patch:
diff --git a/news/10703.feature.rst b/news/10703.feature.rst
new file mode 100644
index 000000000..4094f4140
--- /dev/null
+++ b/news/10703.feature.rst
@@ -0,0 +1,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 5c41488ce..81c443adf 100644
--- a/src/pip/_internal/cli/base_command.py
+++ b/src/pip/_internal/cli/base_command.py
@@ -165,16 +165,16 @@ class Command(CommandContextMixIn):
                     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 8a1799689..952e063d7 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 a4b828a84..1c0cd8e26 100644
--- a/src/pip/_internal/utils/logging.py
+++ b/src/pip/_internal/utils/logging.py
@@ -4,28 +4,28 @@ import logging
 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 @@ class IndentingFormatter(logging.Formatter):
         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 @@ class ColorizedStreamHandler(logging.StreamHandler):
         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",
                 },


Test patch:
diff --git a/tests/functional/test_no_color.py b/tests/functional/test_no_color.py
index 9ead9996a..4094bdd16 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 test_no_color(script: PipTestEnvironment) -> None:
             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 test_no_color(script: PipTestEnvironment) -> None:
         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 45e6bee10..8f8224dc8 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 TestDiagnosticPipErrorCreation:
         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 @@ class TestDiagnosticPipErrorPresentation_ASCII:
             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 @@ class TestDiagnosticPipErrorPresentation_ASCII:
             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 @@ class TestDiagnosticPipErrorPresentation_ASCII:
             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 @@ class TestDiagnosticPipErrorPresentation_ASCII:
             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 @@ class TestDiagnosticPipErrorPresentation_ASCII:
             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 @@ class TestDiagnosticPipErrorPresentation_ASCII:
             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 @@ class TestDiagnosticPipErrorPresentation_ASCII:
             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 7b90d5dcc..4f0447931 100644
--- a/tests/unit/test_logging.py
+++ b/tests/unit/test_logging.py
@@ -6,8 +6,8 @@ import pytest
 
 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 @@ class TestColorizedStreamHandler:
         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 @@ class TestColorizedStreamHandler:
         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 @@ class TestColorizedStreamHandler:
         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):