Skip to content

Commit c99e912

Browse files
authored
Merge pull request #10538 from pradyunsg/diagnostic-errors
2 parents e46888b + a00e7bc commit c99e912

File tree

6 files changed

+380
-44
lines changed

6 files changed

+380
-44
lines changed

src/pip/_internal/cli/base_command.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from pip._internal.exceptions import (
2323
BadCommand,
2424
CommandError,
25+
DiagnosticPipError,
2526
InstallationError,
2627
NetworkConnectionError,
2728
PreviousBuildDirError,
@@ -169,6 +170,11 @@ def exc_logging_wrapper(*args: Any) -> int:
169170
logger.debug("Exception information:", exc_info=True)
170171

171172
return PREVIOUS_BUILD_DIR_ERROR
173+
except DiagnosticPipError as exc:
174+
logger.critical(str(exc))
175+
logger.debug("Exception information:", exc_info=True)
176+
177+
return ERROR
172178
except (
173179
InstallationError,
174180
UninstallationError,

src/pip/_internal/exceptions.py

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,102 @@
11
"""Exceptions used throughout package"""
22

33
import configparser
4+
import re
45
from itertools import chain, groupby, repeat
5-
from typing import TYPE_CHECKING, Dict, List, Optional, Union
6+
from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union
67

78
from pip._vendor.pkg_resources import Distribution
89
from pip._vendor.requests.models import Request, Response
910

1011
if TYPE_CHECKING:
1112
from hashlib import _Hash
13+
from typing import Literal
1214

1315
from pip._internal.metadata import BaseDistribution
1416
from pip._internal.req.req_install import InstallRequirement
1517

1618

19+
#
20+
# Scaffolding
21+
#
22+
def _is_kebab_case(s: str) -> bool:
23+
return re.match(r"^[a-z]+(-[a-z]+)*$", s) is not None
24+
25+
26+
def _prefix_with_indent(prefix: str, s: str, indent: Optional[str] = None) -> str:
27+
if indent is None:
28+
indent = " " * len(prefix)
29+
else:
30+
assert len(indent) == len(prefix)
31+
message = s.replace("\n", "\n" + indent)
32+
return f"{prefix}{message}\n"
33+
34+
1735
class PipError(Exception):
18-
"""Base pip exception"""
36+
"""The base pip error."""
37+
1938

39+
class DiagnosticPipError(PipError):
40+
"""A pip error, that presents diagnostic information to the user.
2041
42+
This contains a bunch of logic, to enable pretty presentation of our error
43+
messages. Each error gets a unique reference. Each error can also include
44+
additional context, a hint and/or a note -- which are presented with the
45+
main error message in a consistent style.
46+
"""
47+
48+
reference: str
49+
50+
def __init__(
51+
self,
52+
*,
53+
message: str,
54+
context: Optional[str],
55+
hint_stmt: Optional[str],
56+
attention_stmt: Optional[str] = None,
57+
reference: Optional[str] = None,
58+
kind: 'Literal["error", "warning"]' = "error",
59+
) -> None:
60+
61+
# Ensure a proper reference is provided.
62+
if reference is None:
63+
assert hasattr(self, "reference"), "error reference not provided!"
64+
reference = self.reference
65+
assert _is_kebab_case(reference), "error reference must be kebab-case!"
66+
67+
super().__init__(f"{reference}: {message}")
68+
69+
self.kind = kind
70+
self.message = message
71+
self.context = context
72+
73+
self.reference = reference
74+
self.attention_stmt = attention_stmt
75+
self.hint_stmt = hint_stmt
76+
77+
def __str__(self) -> str:
78+
return "".join(self._string_parts())
79+
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"
85+
86+
# Space out the note/hint messages.
87+
if self.attention_stmt is not None or self.hint_stmt is not None:
88+
yield "\n"
89+
90+
if self.attention_stmt is not None:
91+
yield _prefix_with_indent("Note: ", self.attention_stmt)
92+
93+
if self.hint_stmt is not None:
94+
yield _prefix_with_indent("Hint: ", self.hint_stmt)
95+
96+
97+
#
98+
# Actual Errors
99+
#
21100
class ConfigurationError(PipError):
22101
"""General exception in configuration"""
23102

@@ -30,6 +109,45 @@ class UninstallationError(PipError):
30109
"""General exception during uninstallation"""
31110

32111

112+
class MissingPyProjectBuildRequires(DiagnosticPipError):
113+
"""Raised when pyproject.toml has `build-system`, but no `build-system.requires`."""
114+
115+
reference = "missing-pyproject-build-system-requires"
116+
117+
def __init__(self, *, package: str) -> None:
118+
super().__init__(
119+
message=f"Can not process {package}",
120+
context=(
121+
"This package has an invalid pyproject.toml file.\n"
122+
"The [build-system] table is missing the mandatory `requires` key."
123+
),
124+
attention_stmt=(
125+
"This is an issue with the package mentioned above, not pip."
126+
),
127+
hint_stmt="See PEP 518 for the detailed specification.",
128+
)
129+
130+
131+
class InvalidPyProjectBuildRequires(DiagnosticPipError):
132+
"""Raised when pyproject.toml an invalid `build-system.requires`."""
133+
134+
reference = "invalid-pyproject-build-system-requires"
135+
136+
def __init__(self, *, package: str, reason: str) -> None:
137+
super().__init__(
138+
message=f"Can not process {package}",
139+
context=(
140+
"This package has an invalid `build-system.requires` key in "
141+
"pyproject.toml.\n"
142+
f"{reason}"
143+
),
144+
hint_stmt="See PEP 518 for the detailed specification.",
145+
attention_stmt=(
146+
"This is an issue with the package mentioned above, not pip."
147+
),
148+
)
149+
150+
33151
class NoneMetadataError(PipError):
34152
"""
35153
Raised when accessing "METADATA" or "PKG-INFO" metadata for a

src/pip/_internal/pyproject.py

Lines changed: 14 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
from pip._vendor import tomli
66
from pip._vendor.packaging.requirements import InvalidRequirement, Requirement
77

8-
from pip._internal.exceptions import InstallationError
8+
from pip._internal.exceptions import (
9+
InstallationError,
10+
InvalidPyProjectBuildRequires,
11+
MissingPyProjectBuildRequires,
12+
)
913

1014

1115
def _is_list_of_str(obj: Any) -> bool:
@@ -119,47 +123,28 @@ def load_pyproject_toml(
119123

120124
# Ensure that the build-system section in pyproject.toml conforms
121125
# to PEP 518.
122-
error_template = (
123-
"{package} has a pyproject.toml file that does not comply "
124-
"with PEP 518: {reason}"
125-
)
126126

127127
# Specifying the build-system table but not the requires key is invalid
128128
if "requires" not in build_system:
129-
raise InstallationError(
130-
error_template.format(
131-
package=req_name,
132-
reason=(
133-
"it has a 'build-system' table but not "
134-
"'build-system.requires' which is mandatory in the table"
135-
),
136-
)
137-
)
129+
raise MissingPyProjectBuildRequires(package=req_name)
138130

139131
# Error out if requires is not a list of strings
140132
requires = build_system["requires"]
141133
if not _is_list_of_str(requires):
142-
raise InstallationError(
143-
error_template.format(
144-
package=req_name,
145-
reason="'build-system.requires' is not a list of strings.",
146-
)
134+
raise InvalidPyProjectBuildRequires(
135+
package=req_name,
136+
reason="It is not a list of strings.",
147137
)
148138

149139
# Each requirement must be valid as per PEP 508
150140
for requirement in requires:
151141
try:
152142
Requirement(requirement)
153-
except InvalidRequirement:
154-
raise InstallationError(
155-
error_template.format(
156-
package=req_name,
157-
reason=(
158-
"'build-system.requires' contains an invalid "
159-
"requirement: {!r}".format(requirement)
160-
),
161-
)
162-
)
143+
except InvalidRequirement as error:
144+
raise InvalidPyProjectBuildRequires(
145+
package=req_name,
146+
reason=f"It contains an invalid requirement: {requirement!r}",
147+
) from error
163148

164149
backend = build_system.get("build-backend")
165150
backend_path = build_system.get("backend-path", [])

tests/functional/test_install.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,12 @@ def test_pep518_refuses_invalid_requires(script, data, common_wheels):
108108
expect_error=True,
109109
)
110110
assert result.returncode == 1
111-
assert "does not comply with PEP 518" in result.stderr
111+
112+
# Ensure the relevant things are mentioned.
113+
assert "PEP 518" in result.stderr
114+
assert "not a list of strings" in result.stderr
115+
assert "build-system.requires" in result.stderr
116+
assert "pyproject.toml" in result.stderr
112117

113118

114119
def test_pep518_refuses_invalid_build_system(script, data, common_wheels):
@@ -120,7 +125,12 @@ def test_pep518_refuses_invalid_build_system(script, data, common_wheels):
120125
expect_error=True,
121126
)
122127
assert result.returncode == 1
123-
assert "does not comply with PEP 518" in result.stderr
128+
129+
# Ensure the relevant things are mentioned.
130+
assert "PEP 518" in result.stderr
131+
assert "mandatory `requires` key" in result.stderr
132+
assert "[build-system] table" in result.stderr
133+
assert "pyproject.toml" in result.stderr
124134

125135

126136
def test_pep518_allows_missing_requires(script, data, common_wheels):
@@ -132,7 +142,7 @@ def test_pep518_allows_missing_requires(script, data, common_wheels):
132142
expect_stderr=True,
133143
)
134144
# Make sure we don't warn when this occurs.
135-
assert "does not comply with PEP 518" not in result.stderr
145+
assert "PEP 518" not in result.stderr
136146

137147
# We want it to go through isolation for now.
138148
assert "Installing build dependencies" in result.stdout, result.stdout

0 commit comments

Comments
 (0)