Skip to content

Commit 7ca95eb

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 8ef91cf commit 7ca95eb

File tree

3 files changed

+390
-54
lines changed

3 files changed

+390
-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: 106 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
import configparser
44
import re
55
from itertools import chain, groupby, repeat
6-
from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union
6+
from typing import TYPE_CHECKING, Dict, List, Optional, Union
77

88
from pip._vendor.pkg_resources import Distribution
99
from pip._vendor.requests.models import Request, Response
10+
from pip._vendor.rich.console import Console, ConsoleOptions, RenderResult
11+
from pip._vendor.rich.text import Text
1012

1113
if TYPE_CHECKING:
1214
from hashlib import _Hash
@@ -23,75 +25,146 @@ def _is_kebab_case(s: str) -> bool:
2325
return re.match(r"^[a-z]+(-[a-z]+)*$", s) is not None
2426

2527

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

3445

3546
class PipError(Exception):
3647
"""The base pip error."""
3748

3849

3950
class DiagnosticPipError(PipError):
40-
"""A pip error, that presents diagnostic information to the user.
51+
"""An error, that presents diagnostic information to the user.
4152
4253
This contains a bunch of logic, to enable pretty presentation of our error
4354
messages. Each error gets a unique reference. Each error can also include
4455
additional context, a hint and/or a note -- which are presented with the
4556
main error message in a consistent style.
57+
58+
This is adapted from the error output styling in `sphinx-theme-builder`.
4659
"""
4760

4861
reference: str
4962

5063
def __init__(
5164
self,
5265
*,
53-
message: str,
54-
context: Optional[str],
55-
hint_stmt: Optional[str],
56-
attention_stmt: Optional[str] = None,
57-
reference: Optional[str] = None,
5866
kind: 'Literal["error", "warning"]' = "error",
67+
reference: Optional[str] = None,
68+
message: Union[str, Text],
69+
context: Optional[Union[str, Text]],
70+
hint_stmt: Optional[Union[str, Text]],
71+
attention_stmt: Optional[Union[str, Text]] = None,
72+
link: Optional[str] = None,
5973
) -> None:
60-
6174
# Ensure a proper reference is provided.
6275
if reference is None:
6376
assert hasattr(self, "reference"), "error reference not provided!"
6477
reference = self.reference
6578
assert _is_kebab_case(reference), "error reference must be kebab-case!"
6679

67-
super().__init__(f"{reference}: {message}")
68-
6980
self.kind = kind
81+
self.reference = reference
82+
7083
self.message = message
7184
self.context = context
7285

73-
self.reference = reference
7486
self.attention_stmt = attention_stmt
7587
self.hint_stmt = hint_stmt
7688

77-
def __str__(self) -> str:
78-
return "".join(self._string_parts())
89+
self.link = link
7990

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

86-
# Space out the note/hint messages.
87145
if self.attention_stmt is not None or self.hint_stmt is not None:
88-
yield "\n"
146+
yield ""
89147

90148
if self.attention_stmt is not None:
91-
yield _prefix_with_indent("Note: ", self.attention_stmt)
92-
149+
yield _prefix_with_indent(
150+
self.attention_stmt,
151+
console,
152+
width_offset=6,
153+
prefix="[magenta bold]note[/]: ",
154+
indent=" ",
155+
)
93156
if self.hint_stmt is not None:
94-
yield _prefix_with_indent("Hint: ", self.hint_stmt)
157+
yield _prefix_with_indent(
158+
self.hint_stmt,
159+
console,
160+
width_offset=6,
161+
prefix="[cyan bold]hint[/]: ",
162+
indent=" ",
163+
)
164+
165+
if self.link is not None:
166+
yield ""
167+
yield f"Link: {self.link}"
95168

96169

97170
#
@@ -119,12 +192,12 @@ def __init__(self, *, package: str) -> None:
119192
message=f"Can not process {package}",
120193
context=(
121194
"This package has an invalid pyproject.toml file.\n"
122-
"The [build-system] table is missing the mandatory `requires` key."
195+
R"The \[build-system] table is missing the mandatory `requires` key."
123196
),
124197
attention_stmt=(
125198
"This is an issue with the package mentioned above, not pip."
126199
),
127-
hint_stmt="See PEP 518 for the detailed specification.",
200+
hint_stmt=Text("See PEP 518 for the detailed specification."),
128201
)
129202

130203

@@ -136,12 +209,12 @@ class InvalidPyProjectBuildRequires(DiagnosticPipError):
136209
def __init__(self, *, package: str, reason: str) -> None:
137210
super().__init__(
138211
message=f"Can not process {package}",
139-
context=(
212+
context=Text(
140213
"This package has an invalid `build-system.requires` key in "
141214
"pyproject.toml.\n"
142215
f"{reason}"
143216
),
144-
hint_stmt="See PEP 518 for the detailed specification.",
217+
hint_stmt=Text("See PEP 518 for the detailed specification."),
145218
attention_stmt=(
146219
"This is an issue with the package mentioned above, not pip."
147220
),

0 commit comments

Comments
 (0)