Skip to content

Commit 0f8f3d7

Browse files
authored
Merge pull request #9264 from uranusjr/new-resolver-dont-abort-on-inconsistent-candidate
Skip candidate not providing valid metadata
2 parents 0aee48f + d869e0c commit 0f8f3d7

File tree

8 files changed

+133
-35
lines changed

8 files changed

+133
-35
lines changed

news/9246.bugfix.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
New resolver: Discard a candidate if it fails to provide metadata from source,
2+
or if the provided metadata is inconsistent, instead of quitting outright.

src/pip/_internal/exceptions.py

+15
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,21 @@ def __str__(self):
151151
)
152152

153153

154+
class InstallationSubprocessError(InstallationError):
155+
"""A subprocess call failed during installation."""
156+
def __init__(self, returncode, description):
157+
# type: (int, str) -> None
158+
self.returncode = returncode
159+
self.description = description
160+
161+
def __str__(self):
162+
# type: () -> str
163+
return (
164+
"Command errored out with exit status {}: {} "
165+
"Check the logs for full command output."
166+
).format(self.returncode, self.description)
167+
168+
154169
class HashErrors(InstallationError):
155170
"""Multiple HashError instances rolled into one for reporting"""
156171

src/pip/_internal/resolution/resolvelib/candidates.py

+6-17
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def __init__(
141141
self._ireq = ireq
142142
self._name = name
143143
self._version = version
144-
self._dist = None # type: Optional[Distribution]
144+
self.dist = self._prepare()
145145

146146
def __str__(self):
147147
# type: () -> str
@@ -209,8 +209,6 @@ def _prepare_distribution(self):
209209
def _check_metadata_consistency(self, dist):
210210
# type: (Distribution) -> None
211211
"""Check for consistency of project name and version of dist."""
212-
# TODO: (Longer term) Rather than abort, reject this candidate
213-
# and backtrack. This would need resolvelib support.
214212
name = canonicalize_name(dist.project_name)
215213
if self._name is not None and self._name != name:
216214
raise MetadataInconsistent(self._ireq, "name", dist.project_name)
@@ -219,25 +217,17 @@ def _check_metadata_consistency(self, dist):
219217
raise MetadataInconsistent(self._ireq, "version", dist.version)
220218

221219
def _prepare(self):
222-
# type: () -> None
223-
if self._dist is not None:
224-
return
220+
# type: () -> Distribution
225221
try:
226222
dist = self._prepare_distribution()
227223
except HashError as e:
224+
# Provide HashError the underlying ireq that caused it. This
225+
# provides context for the resulting error message to show the
226+
# offending line to the user.
228227
e.req = self._ireq
229228
raise
230-
231-
assert dist is not None, "Distribution already installed"
232229
self._check_metadata_consistency(dist)
233-
self._dist = dist
234-
235-
@property
236-
def dist(self):
237-
# type: () -> Distribution
238-
if self._dist is None:
239-
self._prepare()
240-
return self._dist
230+
return dist
241231

242232
def _get_requires_python_dependency(self):
243233
# type: () -> Optional[Requirement]
@@ -261,7 +251,6 @@ def iter_dependencies(self, with_requires):
261251

262252
def get_install_requirement(self):
263253
# type: () -> Optional[InstallRequirement]
264-
self._prepare()
265254
return self._ireq
266255

267256

src/pip/_internal/resolution/resolvelib/factory.py

+44-8
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from pip._internal.exceptions import (
66
DistributionNotFound,
77
InstallationError,
8+
InstallationSubprocessError,
9+
MetadataInconsistent,
810
UnsupportedPythonVersion,
911
UnsupportedWheel,
1012
)
@@ -33,6 +35,7 @@
3335
ExplicitRequirement,
3436
RequiresPythonRequirement,
3537
SpecifierRequirement,
38+
UnsatisfiableRequirement,
3639
)
3740

3841
if MYPY_CHECK_RUNNING:
@@ -94,6 +97,7 @@ def __init__(
9497
self._force_reinstall = force_reinstall
9598
self._ignore_requires_python = ignore_requires_python
9699

100+
self._build_failures = {} # type: Cache[InstallationError]
97101
self._link_candidate_cache = {} # type: Cache[LinkCandidate]
98102
self._editable_candidate_cache = {} # type: Cache[EditableCandidate]
99103
self._installed_candidate_cache = {
@@ -136,21 +140,40 @@ def _make_candidate_from_link(
136140
name, # type: Optional[str]
137141
version, # type: Optional[_BaseVersion]
138142
):
139-
# type: (...) -> Candidate
143+
# type: (...) -> Optional[Candidate]
140144
# TODO: Check already installed candidate, and use it if the link and
141145
# editable flag match.
146+
147+
if link in self._build_failures:
148+
# We already tried this candidate before, and it does not build.
149+
# Don't bother trying again.
150+
return None
151+
142152
if template.editable:
143153
if link not in self._editable_candidate_cache:
144-
self._editable_candidate_cache[link] = EditableCandidate(
145-
link, template, factory=self, name=name, version=version,
146-
)
154+
try:
155+
self._editable_candidate_cache[link] = EditableCandidate(
156+
link, template, factory=self,
157+
name=name, version=version,
158+
)
159+
except (InstallationSubprocessError, MetadataInconsistent) as e:
160+
logger.warning("Discarding %s. %s", link, e)
161+
self._build_failures[link] = e
162+
return None
147163
base = self._editable_candidate_cache[link] # type: BaseCandidate
148164
else:
149165
if link not in self._link_candidate_cache:
150-
self._link_candidate_cache[link] = LinkCandidate(
151-
link, template, factory=self, name=name, version=version,
152-
)
166+
try:
167+
self._link_candidate_cache[link] = LinkCandidate(
168+
link, template, factory=self,
169+
name=name, version=version,
170+
)
171+
except (InstallationSubprocessError, MetadataInconsistent) as e:
172+
logger.warning("Discarding %s. %s", link, e)
173+
self._build_failures[link] = e
174+
return None
153175
base = self._link_candidate_cache[link]
176+
154177
if extras:
155178
return ExtrasCandidate(base, extras)
156179
return base
@@ -210,13 +233,16 @@ def iter_index_candidates():
210233
for ican in reversed(icans):
211234
if not all_yanked and ican.link.is_yanked:
212235
continue
213-
yield self._make_candidate_from_link(
236+
candidate = self._make_candidate_from_link(
214237
link=ican.link,
215238
extras=extras,
216239
template=template,
217240
name=name,
218241
version=ican.version,
219242
)
243+
if candidate is None:
244+
continue
245+
yield candidate
220246

221247
return FoundCandidates(
222248
iter_index_candidates,
@@ -280,6 +306,16 @@ def make_requirement_from_install_req(self, ireq, requested_extras):
280306
name=canonicalize_name(ireq.name) if ireq.name else None,
281307
version=None,
282308
)
309+
if cand is None:
310+
# There's no way we can satisfy a URL requirement if the underlying
311+
# candidate fails to build. An unnamed URL must be user-supplied, so
312+
# we fail eagerly. If the URL is named, an unsatisfiable requirement
313+
# can make the resolver do the right thing, either backtrack (and
314+
# maybe find some other requirement that's buildable) or raise a
315+
# ResolutionImpossible eventually.
316+
if not ireq.name:
317+
raise self._build_failures[ireq.link]
318+
return UnsatisfiableRequirement(canonicalize_name(ireq.name))
283319
return self.make_requirement_from_candidate(cand)
284320

285321
def make_requirement_from_candidate(self, candidate):

src/pip/_internal/resolution/resolvelib/requirements.py

+41
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,44 @@ def is_satisfied_by(self, candidate):
158158
# already implements the prerelease logic, and would have filtered out
159159
# prerelease candidates if the user does not expect them.
160160
return self.specifier.contains(candidate.version, prereleases=True)
161+
162+
163+
class UnsatisfiableRequirement(Requirement):
164+
"""A requirement that cannot be satisfied.
165+
"""
166+
def __init__(self, name):
167+
# type: (str) -> None
168+
self._name = name
169+
170+
def __str__(self):
171+
# type: () -> str
172+
return "{} (unavailable)".format(self._name)
173+
174+
def __repr__(self):
175+
# type: () -> str
176+
return "{class_name}({name!r})".format(
177+
class_name=self.__class__.__name__,
178+
name=str(self._name),
179+
)
180+
181+
@property
182+
def project_name(self):
183+
# type: () -> str
184+
return self._name
185+
186+
@property
187+
def name(self):
188+
# type: () -> str
189+
return self._name
190+
191+
def format_for_error(self):
192+
# type: () -> str
193+
return str(self)
194+
195+
def get_candidate_lookup(self):
196+
# type: () -> CandidateLookup
197+
return None, None
198+
199+
def is_satisfied_by(self, candidate):
200+
# type: (Candidate) -> bool
201+
return False

src/pip/_internal/utils/subprocess.py

+2-6
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pip._vendor.six.moves import shlex_quote
88

99
from pip._internal.cli.spinners import SpinnerInterface, open_spinner
10-
from pip._internal.exceptions import InstallationError
10+
from pip._internal.exceptions import InstallationSubprocessError
1111
from pip._internal.utils.compat import console_to_str, str_to_display
1212
from pip._internal.utils.logging import subprocess_logger
1313
from pip._internal.utils.misc import HiddenText, path_to_display
@@ -233,11 +233,7 @@ def call_subprocess(
233233
exit_status=proc.returncode,
234234
)
235235
subprocess_logger.error(msg)
236-
exc_msg = (
237-
'Command errored out with exit status {}: {} '
238-
'Check the logs for full command output.'
239-
).format(proc.returncode, command_desc)
240-
raise InstallationError(exc_msg)
236+
raise InstallationSubprocessError(proc.returncode, command_desc)
241237
elif on_returncode == 'warn':
242238
subprocess_logger.warning(
243239
'Command "%s" had error code %s in %s',

tests/functional/test_new_resolver.py

+19
Original file line numberDiff line numberDiff line change
@@ -1218,3 +1218,22 @@ def test_new_resolver_does_not_reinstall_when_from_a_local_index(script):
12181218
assert "Installing collected packages: simple" not in result.stdout, str(result)
12191219
assert "Requirement already satisfied: simple" in result.stdout, str(result)
12201220
assert_installed(script, simple="0.1.0")
1221+
1222+
1223+
def test_new_resolver_skip_inconsistent_metadata(script):
1224+
create_basic_wheel_for_package(script, "A", "1")
1225+
1226+
a_2 = create_basic_wheel_for_package(script, "A", "2")
1227+
a_2.rename(a_2.parent.joinpath("a-3-py2.py3-none-any.whl"))
1228+
1229+
result = script.pip(
1230+
"install",
1231+
"--no-cache-dir", "--no-index",
1232+
"--find-links", script.scratch_path,
1233+
"--verbose",
1234+
"A",
1235+
allow_stderr_warning=True,
1236+
)
1237+
1238+
assert " different version in metadata: '2'" in result.stderr, str(result)
1239+
assert_installed(script, a="1")

tests/unit/test_utils_subprocess.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import pytest
88

99
from pip._internal.cli.spinners import SpinnerInterface
10-
from pip._internal.exceptions import InstallationError
10+
from pip._internal.exceptions import InstallationSubprocessError
1111
from pip._internal.utils.misc import hide_value
1212
from pip._internal.utils.subprocess import (
1313
call_subprocess,
@@ -276,7 +276,7 @@ def test_info_logging__subprocess_error(self, capfd, caplog):
276276
command = 'print("Hello"); print("world"); exit("fail")'
277277
args, spinner = self.prepare_call(caplog, log_level, command=command)
278278

279-
with pytest.raises(InstallationError) as exc:
279+
with pytest.raises(InstallationSubprocessError) as exc:
280280
call_subprocess(args, spinner=spinner)
281281
result = None
282282
exc_message = str(exc.value)
@@ -360,7 +360,7 @@ def test_info_logging_with_show_stdout_true(self, capfd, caplog):
360360
# log level is only WARNING.
361361
(0, True, None, WARNING, (None, 'done', 2)),
362362
# Test a non-zero exit status.
363-
(3, False, None, INFO, (InstallationError, 'error', 2)),
363+
(3, False, None, INFO, (InstallationSubprocessError, 'error', 2)),
364364
# Test a non-zero exit status also in extra_ok_returncodes.
365365
(3, False, (3, ), INFO, (None, 'done', 2)),
366366
])
@@ -396,7 +396,7 @@ def test_spinner_finish(
396396
assert spinner.spin_count == expected_spin_count
397397

398398
def test_closes_stdin(self):
399-
with pytest.raises(InstallationError):
399+
with pytest.raises(InstallationSubprocessError):
400400
call_subprocess(
401401
[sys.executable, '-c', 'input()'],
402402
show_stdout=True,

0 commit comments

Comments
 (0)