Skip to content

Commit 5cf9840

Browse files
authored
Merge pull request #10703 from pradyunsg/rich-diagnostic-error-messages
2 parents bbc7021 + fd45825 commit 5cf9840

File tree

7 files changed

+487
-177
lines changed

7 files changed

+487
-177
lines changed

news/10703.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Start using Rich for presenting error messages in a consistent format.

src/pip/_internal/cli/base_command.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,16 +165,16 @@ def exc_logging_wrapper(*args: Any) -> int:
165165
status = run_func(*args)
166166
assert isinstance(status, int)
167167
return status
168-
except PreviousBuildDirError as exc:
169-
logger.critical(str(exc))
168+
except DiagnosticPipError as exc:
169+
logger.error("[present-diagnostic]", exc)
170170
logger.debug("Exception information:", exc_info=True)
171171

172-
return PREVIOUS_BUILD_DIR_ERROR
173-
except DiagnosticPipError as exc:
172+
return ERROR
173+
except PreviousBuildDirError as exc:
174174
logger.critical(str(exc))
175175
logger.debug("Exception information:", exc_info=True)
176176

177-
return ERROR
177+
return PREVIOUS_BUILD_DIR_ERROR
178178
except (
179179
InstallationError,
180180
UninstallationError,

src/pip/_internal/exceptions.py

Lines changed: 109 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +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
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.markup import escape
11+
from pip._vendor.rich.text import Text
912

1013
if TYPE_CHECKING:
1114
from hashlib import _Hash
@@ -22,75 +25,140 @@ def _is_kebab_case(s: str) -> bool:
2225
return re.match(r"^[a-z]+(-[a-z]+)*$", s) is not None
2326

2427

25-
def _prefix_with_indent(prefix: str, s: str, indent: Optional[str] = None) -> str:
26-
if indent is None:
27-
indent = " " * len(prefix)
28+
def _prefix_with_indent(
29+
s: Union[Text, str],
30+
console: Console,
31+
*,
32+
prefix: str,
33+
indent: str,
34+
) -> Text:
35+
if isinstance(s, Text):
36+
text = s
2837
else:
29-
assert len(indent) == len(prefix)
30-
message = s.replace("\n", "\n" + indent)
31-
return f"{prefix}{message}\n"
38+
text = console.render_str(s)
39+
40+
return console.render_str(prefix, overflow="ignore") + console.render_str(
41+
f"\n{indent}", overflow="ignore"
42+
).join(text.split(allow_blank=True))
3243

3344

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

3748

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

4760
reference: str
4861

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

66-
super().__init__(f"{reference}: {message}")
67-
6879
self.kind = kind
80+
self.reference = reference
81+
6982
self.message = message
7083
self.context = context
7184

72-
self.reference = reference
73-
self.attention_stmt = attention_stmt
85+
self.note_stmt = note_stmt
7486
self.hint_stmt = hint_stmt
7587

76-
def __str__(self) -> str:
77-
return "".join(self._string_parts())
78-
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"
88+
self.link = link
8489

85-
# Space out the note/hint messages.
86-
if self.attention_stmt is not None or self.hint_stmt is not None:
87-
yield "\n"
90+
super().__init__(f"<{self.__class__.__name__}: {self.reference}>")
8891

89-
if self.attention_stmt is not None:
90-
yield _prefix_with_indent("Note: ", self.attention_stmt)
92+
def __repr__(self) -> str:
93+
return (
94+
f"<{self.__class__.__name__}("
95+
f"reference={self.reference!r}, "
96+
f"message={self.message!r}, "
97+
f"context={self.context!r}, "
98+
f"note_stmt={self.note_stmt!r}, "
99+
f"hint_stmt={self.hint_stmt!r}"
100+
")>"
101+
)
91102

103+
def __rich_console__(
104+
self,
105+
console: Console,
106+
options: ConsoleOptions,
107+
) -> RenderResult:
108+
colour = "red" if self.kind == "error" else "yellow"
109+
110+
yield f"[{colour} bold]{self.kind}[/]: [bold]{self.reference}[/]"
111+
yield ""
112+
113+
if not options.ascii_only:
114+
# Present the main message, with relevant context indented.
115+
if self.context is not None:
116+
yield _prefix_with_indent(
117+
self.message,
118+
console,
119+
prefix=f"[{colour}]×[/] ",
120+
indent=f"[{colour}]│[/] ",
121+
)
122+
yield _prefix_with_indent(
123+
self.context,
124+
console,
125+
prefix=f"[{colour}]╰─>[/] ",
126+
indent=f"[{colour}] [/] ",
127+
)
128+
else:
129+
yield _prefix_with_indent(
130+
self.message,
131+
console,
132+
prefix="[red]×[/] ",
133+
indent=" ",
134+
)
135+
else:
136+
yield self.message
137+
if self.context is not None:
138+
yield ""
139+
yield self.context
140+
141+
if self.note_stmt is not None or self.hint_stmt is not None:
142+
yield ""
143+
144+
if self.note_stmt is not None:
145+
yield _prefix_with_indent(
146+
self.note_stmt,
147+
console,
148+
prefix="[magenta bold]note[/]: ",
149+
indent=" ",
150+
)
92151
if self.hint_stmt is not None:
93-
yield _prefix_with_indent("Hint: ", self.hint_stmt)
152+
yield _prefix_with_indent(
153+
self.hint_stmt,
154+
console,
155+
prefix="[cyan bold]hint[/]: ",
156+
indent=" ",
157+
)
158+
159+
if self.link is not None:
160+
yield ""
161+
yield f"Link: {self.link}"
94162

95163

96164
#
@@ -115,15 +183,13 @@ class MissingPyProjectBuildRequires(DiagnosticPipError):
115183

116184
def __init__(self, *, package: str) -> None:
117185
super().__init__(
118-
message=f"Can not process {package}",
119-
context=(
186+
message=f"Can not process {escape(package)}",
187+
context=Text(
120188
"This package has an invalid pyproject.toml file.\n"
121189
"The [build-system] table is missing the mandatory `requires` key."
122190
),
123-
attention_stmt=(
124-
"This is an issue with the package mentioned above, not pip."
125-
),
126-
hint_stmt="See PEP 518 for the detailed specification.",
191+
note_stmt="This is an issue with the package mentioned above, not pip.",
192+
hint_stmt=Text("See PEP 518 for the detailed specification."),
127193
)
128194

129195

@@ -134,16 +200,13 @@ class InvalidPyProjectBuildRequires(DiagnosticPipError):
134200

135201
def __init__(self, *, package: str, reason: str) -> None:
136202
super().__init__(
137-
message=f"Can not process {package}",
138-
context=(
203+
message=f"Can not process {escape(package)}",
204+
context=Text(
139205
"This package has an invalid `build-system.requires` key in "
140-
"pyproject.toml.\n"
141-
f"{reason}"
142-
),
143-
hint_stmt="See PEP 518 for the detailed specification.",
144-
attention_stmt=(
145-
"This is an issue with the package mentioned above, not pip."
206+
f"pyproject.toml.\n{reason}"
146207
),
208+
note_stmt="This is an issue with the package mentioned above, not pip.",
209+
hint_stmt=Text("See PEP 518 for the detailed specification."),
147210
)
148211

149212

0 commit comments

Comments
 (0)