Skip to content

Commit 03a87c2

Browse files
committed
Improve DiagnosticPipError presentation
Borrow error presentation logic from sphinx-theme-builder, and exhaustively test both the unicode and non-unicode presentation. Utilise rich for colours and presentation logic handling, with tests to ensure that colour degradation happens cleanly, and that the content is stylized exactly as expected. Catch diagnostic errors eagerly, and present them using rich. While this won't include the pretty presentation in user logs, those files will contain the entire traceback upto that line.
1 parent bbc7021 commit 03a87c2

File tree

3 files changed

+384
-54
lines changed

3 files changed

+384
-54
lines changed

src/pip/_internal/cli/base_command.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from optparse import Values
1111
from typing import Any, Callable, List, Optional, Tuple
1212

13+
from pip._vendor import rich
14+
1315
from pip._internal.cli import cmdoptions
1416
from pip._internal.cli.command_context import CommandContextMixIn
1517
from pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter
@@ -165,16 +167,16 @@ def exc_logging_wrapper(*args: Any) -> int:
165167
status = run_func(*args)
166168
assert isinstance(status, int)
167169
return status
168-
except PreviousBuildDirError as exc:
169-
logger.critical(str(exc))
170+
except DiagnosticPipError as exc:
171+
rich.print(exc, file=sys.stderr)
170172
logger.debug("Exception information:", exc_info=True)
171173

172-
return PREVIOUS_BUILD_DIR_ERROR
173-
except DiagnosticPipError as exc:
174+
return ERROR
175+
except PreviousBuildDirError as exc:
174176
logger.critical(str(exc))
175177
logger.debug("Exception information:", exc_info=True)
176178

177-
return ERROR
179+
return PREVIOUS_BUILD_DIR_ERROR
178180
except (
179181
InstallationError,
180182
UninstallationError,

src/pip/_internal/exceptions.py

Lines changed: 100 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import configparser
44
import re
55
from itertools import chain, groupby, repeat
6-
from typing import TYPE_CHECKING, Dict, Iterator, List, Optional
6+
from typing import TYPE_CHECKING, Dict, List, Optional, Union
77

88
from pip._vendor.requests.models import Request, Response
9+
from pip._vendor.rich.console import Console, ConsoleOptions, RenderResult
10+
from pip._vendor.rich.text import Text
911

1012
if TYPE_CHECKING:
1113
from hashlib import _Hash
@@ -22,75 +24,140 @@ def _is_kebab_case(s: str) -> bool:
2224
return re.match(r"^[a-z]+(-[a-z]+)*$", s) is not None
2325

2426

25-
def _prefix_with_indent(prefix: str, s: str, indent: Optional[str] = None) -> str:
26-
if indent is None:
27-
indent = " " * len(prefix)
27+
def _prefix_with_indent(
28+
s: Union[Text, str],
29+
console: Console,
30+
*,
31+
prefix: str,
32+
indent: str,
33+
) -> Text:
34+
if isinstance(s, Text):
35+
text = s
2836
else:
29-
assert len(indent) == len(prefix)
30-
message = s.replace("\n", "\n" + indent)
31-
return f"{prefix}{message}\n"
37+
text = console.render_str(s)
38+
39+
lines = text.wrap(console, console.width - width_offset)
40+
41+
return console.render_str(prefix) + console.render_str(f"\n{indent}").join(lines)
3242

3343

3444
class PipError(Exception):
3545
"""The base pip error."""
3646

3747

3848
class DiagnosticPipError(PipError):
39-
"""A pip error, that presents diagnostic information to the user.
49+
"""An error, that presents diagnostic information to the user.
4050
4151
This contains a bunch of logic, to enable pretty presentation of our error
4252
messages. Each error gets a unique reference. Each error can also include
4353
additional context, a hint and/or a note -- which are presented with the
4454
main error message in a consistent style.
55+
56+
This is adapted from the error output styling in `sphinx-theme-builder`.
4557
"""
4658

4759
reference: str
4860

4961
def __init__(
5062
self,
5163
*,
52-
message: str,
53-
context: Optional[str],
54-
hint_stmt: Optional[str],
55-
attention_stmt: Optional[str] = None,
56-
reference: Optional[str] = None,
5764
kind: 'Literal["error", "warning"]' = "error",
65+
reference: Optional[str] = None,
66+
message: Union[str, Text],
67+
context: Optional[Union[str, Text]],
68+
hint_stmt: Optional[Union[str, Text]],
69+
attention_stmt: Optional[Union[str, Text]] = None,
70+
link: Optional[str] = None,
5871
) -> None:
59-
6072
# Ensure a proper reference is provided.
6173
if reference is None:
6274
assert hasattr(self, "reference"), "error reference not provided!"
6375
reference = self.reference
6476
assert _is_kebab_case(reference), "error reference must be kebab-case!"
6577

66-
super().__init__(f"{reference}: {message}")
67-
6878
self.kind = kind
79+
self.reference = reference
80+
6981
self.message = message
7082
self.context = context
7183

72-
self.reference = reference
7384
self.attention_stmt = attention_stmt
7485
self.hint_stmt = hint_stmt
7586

76-
def __str__(self) -> str:
77-
return "".join(self._string_parts())
87+
self.link = link
7888

79-
def _string_parts(self) -> Iterator[str]:
80-
# Present the main message, with relevant context indented.
81-
yield f"{self.message}\n"
82-
if self.context is not None:
83-
yield f"\n{self.context}\n"
89+
super().__init__(f"<{self.__class__.__name__}: {self.reference}>")
90+
91+
def __repr__(self) -> str:
92+
return (
93+
f"<{self.__class__.__name__}("
94+
f"reference={self.reference!r}, "
95+
f"message={self.message!r}, "
96+
f"context={self.context!r}, "
97+
f"attention_stmt={self.attention_stmt!r}, "
98+
f"hint_stmt={self.hint_stmt!r}"
99+
")>"
100+
)
101+
102+
def __rich_console__(
103+
self,
104+
console: Console,
105+
options: ConsoleOptions,
106+
) -> RenderResult:
107+
colour = "red" if self.kind == "error" else "yellow"
108+
109+
yield f"[{colour} bold]{self.kind}[/]: [bold]{self.reference}[/]"
110+
yield ""
111+
112+
if not options.ascii_only:
113+
# Present the main message, with relevant context indented.
114+
if self.context is not None:
115+
yield _prefix_with_indent(
116+
self.message,
117+
console,
118+
prefix=f"[{colour}]×[/] ",
119+
indent=f"[{colour}]│[/] ",
120+
)
121+
yield _prefix_with_indent(
122+
self.context,
123+
console,
124+
prefix=f"[{colour}]╰─>[/] ",
125+
indent=f"[{colour}] [/] ",
126+
)
127+
else:
128+
yield _prefix_with_indent(
129+
self.message,
130+
console,
131+
prefix="[red]×[/] ",
132+
indent=" ",
133+
)
134+
else:
135+
yield self.message
136+
if self.context is not None:
137+
yield ""
138+
yield self.context
84139

85-
# Space out the note/hint messages.
86140
if self.attention_stmt is not None or self.hint_stmt is not None:
87-
yield "\n"
141+
yield ""
88142

89143
if self.attention_stmt is not None:
90-
yield _prefix_with_indent("Note: ", self.attention_stmt)
91-
144+
yield _prefix_with_indent(
145+
self.attention_stmt,
146+
console,
147+
prefix="[magenta bold]note[/]: ",
148+
indent=" ",
149+
)
92150
if self.hint_stmt is not None:
93-
yield _prefix_with_indent("Hint: ", self.hint_stmt)
151+
yield _prefix_with_indent(
152+
self.hint_stmt,
153+
console,
154+
prefix="[cyan bold]hint[/]: ",
155+
indent=" ",
156+
)
157+
158+
if self.link is not None:
159+
yield ""
160+
yield f"Link: {self.link}"
94161

95162

96163
#
@@ -118,12 +185,12 @@ def __init__(self, *, package: str) -> None:
118185
message=f"Can not process {package}",
119186
context=(
120187
"This package has an invalid pyproject.toml file.\n"
121-
"The [build-system] table is missing the mandatory `requires` key."
188+
R"The \[build-system] table is missing the mandatory `requires` key."
122189
),
123190
attention_stmt=(
124191
"This is an issue with the package mentioned above, not pip."
125192
),
126-
hint_stmt="See PEP 518 for the detailed specification.",
193+
hint_stmt=Text("See PEP 518 for the detailed specification."),
127194
)
128195

129196

@@ -135,12 +202,12 @@ class InvalidPyProjectBuildRequires(DiagnosticPipError):
135202
def __init__(self, *, package: str, reason: str) -> None:
136203
super().__init__(
137204
message=f"Can not process {package}",
138-
context=(
205+
context=Text(
139206
"This package has an invalid `build-system.requires` key in "
140207
"pyproject.toml.\n"
141208
f"{reason}"
142209
),
143-
hint_stmt="See PEP 518 for the detailed specification.",
210+
hint_stmt=Text("See PEP 518 for the detailed specification."),
144211
attention_stmt=(
145212
"This is an issue with the package mentioned above, not pip."
146213
),

0 commit comments

Comments
 (0)