Skip to content

Commit 4b8004a

Browse files
authored
Merge pull request #9775 from uranusjr/new-resolver-direct-url-with-extras
Correctly resolve requirement requested both as non-extra URL and non-URL with extras
2 parents 9ae842b + cf4e3aa commit 4b8004a

File tree

6 files changed

+169
-71
lines changed

6 files changed

+169
-71
lines changed

news/8785.bugfix.rst

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
New resolver: When a requirement is requested both via a direct URL
2+
(``req @ URL``) and via version specifier with extras (``req[extra]``), the
3+
resolver will now be able to use the URL to correctly resolve the requirement
4+
with extras.

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

+12
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@
3333
]
3434

3535

36+
def as_base_candidate(candidate: Candidate) -> Optional[BaseCandidate]:
37+
"""The runtime version of BaseCandidate."""
38+
base_candidate_classes = (
39+
AlreadyInstalledCandidate,
40+
EditableCandidate,
41+
LinkCandidate,
42+
)
43+
if isinstance(candidate, base_candidate_classes):
44+
return candidate
45+
return None
46+
47+
3648
def make_install_req_from_link(link, template):
3749
# type: (Link, InstallRequirement) -> InstallRequirement
3850
assert not template.editable, "template is editable"

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

+92-50
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextlib
12
import functools
23
import logging
34
from typing import (
@@ -16,6 +17,8 @@
1617
cast,
1718
)
1819

20+
from pip._vendor.packaging.requirements import InvalidRequirement
21+
from pip._vendor.packaging.requirements import Requirement as PackagingRequirement
1922
from pip._vendor.packaging.specifiers import SpecifierSet
2023
from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
2124
from pip._vendor.pkg_resources import Distribution
@@ -54,6 +57,7 @@
5457
ExtrasCandidate,
5558
LinkCandidate,
5659
RequiresPythonCandidate,
60+
as_base_candidate,
5761
)
5862
from .found_candidates import FoundCandidates, IndexCandidateInfo
5963
from .requirements import (
@@ -123,6 +127,15 @@ def force_reinstall(self):
123127
# type: () -> bool
124128
return self._force_reinstall
125129

130+
def _fail_if_link_is_unsupported_wheel(self, link: Link) -> None:
131+
if not link.is_wheel:
132+
return
133+
wheel = Wheel(link.filename)
134+
if wheel.supported(self._finder.target_python.get_tags()):
135+
return
136+
msg = f"{link.filename} is not a supported wheel on this platform."
137+
raise UnsupportedWheel(msg)
138+
126139
def _make_extras_candidate(self, base, extras):
127140
# type: (BaseCandidate, FrozenSet[str]) -> ExtrasCandidate
128141
cache_key = (id(base), extras)
@@ -275,6 +288,51 @@ def iter_index_candidate_infos():
275288
incompatible_ids,
276289
)
277290

291+
def _iter_explicit_candidates_from_base(
292+
self,
293+
base_requirements: Iterable[Requirement],
294+
extras: FrozenSet[str],
295+
) -> Iterator[Candidate]:
296+
"""Produce explicit candidates from the base given an extra-ed package.
297+
298+
:param base_requirements: Requirements known to the resolver. The
299+
requirements are guaranteed to not have extras.
300+
:param extras: The extras to inject into the explicit requirements'
301+
candidates.
302+
"""
303+
for req in base_requirements:
304+
lookup_cand, _ = req.get_candidate_lookup()
305+
if lookup_cand is None: # Not explicit.
306+
continue
307+
# We've stripped extras from the identifier, and should always
308+
# get a BaseCandidate here, unless there's a bug elsewhere.
309+
base_cand = as_base_candidate(lookup_cand)
310+
assert base_cand is not None, "no extras here"
311+
yield self._make_extras_candidate(base_cand, extras)
312+
313+
def _iter_candidates_from_constraints(
314+
self,
315+
identifier: str,
316+
constraint: Constraint,
317+
template: InstallRequirement,
318+
) -> Iterator[Candidate]:
319+
"""Produce explicit candidates from constraints.
320+
321+
This creates "fake" InstallRequirement objects that are basically clones
322+
of what "should" be the template, but with original_link set to link.
323+
"""
324+
for link in constraint.links:
325+
self._fail_if_link_is_unsupported_wheel(link)
326+
candidate = self._make_candidate_from_link(
327+
link,
328+
extras=frozenset(),
329+
template=install_req_from_link_and_ireq(link, template),
330+
name=canonicalize_name(identifier),
331+
version=None,
332+
)
333+
if candidate:
334+
yield candidate
335+
278336
def find_candidates(
279337
self,
280338
identifier: str,
@@ -283,59 +341,48 @@ def find_candidates(
283341
constraint: Constraint,
284342
prefers_installed: bool,
285343
) -> Iterable[Candidate]:
286-
287-
# Since we cache all the candidates, incompatibility identification
288-
# can be made quicker by comparing only the id() values.
289-
incompat_ids = {id(c) for c in incompatibilities.get(identifier, ())}
290-
344+
# Collect basic lookup information from the requirements.
291345
explicit_candidates = set() # type: Set[Candidate]
292346
ireqs = [] # type: List[InstallRequirement]
293347
for req in requirements[identifier]:
294348
cand, ireq = req.get_candidate_lookup()
295-
if cand is not None and id(cand) not in incompat_ids:
349+
if cand is not None:
296350
explicit_candidates.add(cand)
297351
if ireq is not None:
298352
ireqs.append(ireq)
299353

300-
for link in constraint.links:
301-
if not ireqs:
302-
# If we hit this condition, then we cannot construct a candidate.
303-
# However, if we hit this condition, then none of the requirements
304-
# provided an ireq, so they must have provided an explicit candidate.
305-
# In that case, either the candidate matches, in which case this loop
306-
# doesn't need to do anything, or it doesn't, in which case there's
307-
# nothing this loop can do to recover.
308-
break
309-
if link.is_wheel:
310-
wheel = Wheel(link.filename)
311-
# Check whether the provided wheel is compatible with the target
312-
# platform.
313-
if not wheel.supported(self._finder.target_python.get_tags()):
314-
# We are constrained to install a wheel that is incompatible with
315-
# the target architecture, so there are no valid candidates.
316-
# Return early, with no candidates.
317-
return ()
318-
# Create a "fake" InstallRequirement that's basically a clone of
319-
# what "should" be the template, but with original_link set to link.
320-
# Using the given requirement is necessary for preserving hash
321-
# requirements, but without the original_link, direct_url.json
322-
# won't be created.
323-
ireq = install_req_from_link_and_ireq(link, ireqs[0])
324-
candidate = self._make_candidate_from_link(
325-
link,
326-
extras=frozenset(),
327-
template=ireq,
328-
name=canonicalize_name(ireq.name) if ireq.name else None,
329-
version=None,
354+
# If the current identifier contains extras, add explicit candidates
355+
# from entries from extra-less identifier.
356+
with contextlib.suppress(InvalidRequirement):
357+
parsed_requirement = PackagingRequirement(identifier)
358+
explicit_candidates.update(
359+
self._iter_explicit_candidates_from_base(
360+
requirements.get(parsed_requirement.name, ()),
361+
frozenset(parsed_requirement.extras),
362+
),
330363
)
331-
if candidate is None:
332-
# _make_candidate_from_link returns None if the wheel fails to build.
333-
# We are constrained to install this wheel, so there are no valid
334-
# candidates.
335-
# Return early, with no candidates.
364+
365+
# Add explicit candidates from constraints. We only do this if there are
366+
# kown ireqs, which represent requirements not already explicit. If
367+
# there are no ireqs, we're constraining already-explicit requirements,
368+
# which is handled later when we return the explicit candidates.
369+
if ireqs:
370+
try:
371+
explicit_candidates.update(
372+
self._iter_candidates_from_constraints(
373+
identifier,
374+
constraint,
375+
template=ireqs[0],
376+
),
377+
)
378+
except UnsupportedWheel:
379+
# If we're constrained to install a wheel incompatible with the
380+
# target architecture, no candidates will ever be valid.
336381
return ()
337382

338-
explicit_candidates.add(candidate)
383+
# Since we cache all the candidates, incompatibility identification
384+
# can be made quicker by comparing only the id() values.
385+
incompat_ids = {id(c) for c in incompatibilities.get(identifier, ())}
339386

340387
# If none of the requirements want an explicit candidate, we can ask
341388
# the finder for candidates.
@@ -351,7 +398,8 @@ def find_candidates(
351398
return (
352399
c
353400
for c in explicit_candidates
354-
if constraint.is_satisfied_by(c)
401+
if id(c) not in incompat_ids
402+
and constraint.is_satisfied_by(c)
355403
and all(req.is_satisfied_by(c) for req in requirements[identifier])
356404
)
357405

@@ -366,13 +414,7 @@ def make_requirement_from_install_req(self, ireq, requested_extras):
366414
return None
367415
if not ireq.link:
368416
return SpecifierRequirement(ireq)
369-
if ireq.link.is_wheel:
370-
wheel = Wheel(ireq.link.filename)
371-
if not wheel.supported(self._finder.target_python.get_tags()):
372-
msg = "{} is not a supported wheel on this platform.".format(
373-
wheel.filename,
374-
)
375-
raise UnsupportedWheel(msg)
417+
self._fail_if_link_is_unsupported_wheel(ireq.link)
376418
cand = self._make_candidate_from_link(
377419
ireq.link,
378420
extras=frozenset(ireq.extras),
+7-21
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,12 @@
1-
import re
2-
31
import pytest
42

5-
from pip._internal.models.direct_url import DIRECT_URL_METADATA_NAME, DirectUrl
63
from tests.lib import _create_test_package, path_to_url
7-
8-
9-
def _get_created_direct_url(result, pkg):
10-
direct_url_metadata_re = re.compile(
11-
pkg + r"-[\d\.]+\.dist-info." + DIRECT_URL_METADATA_NAME + r"$"
12-
)
13-
for filename in result.files_created:
14-
if direct_url_metadata_re.search(filename):
15-
direct_url_path = result.test_env.base_path / filename
16-
with open(direct_url_path) as f:
17-
return DirectUrl.from_json(f.read())
18-
return None
4+
from tests.lib.direct_url import get_created_direct_url
195

206

217
def test_install_find_links_no_direct_url(script, with_wheel):
228
result = script.pip_install_local("simple")
23-
assert not _get_created_direct_url(result, "simple")
9+
assert not get_created_direct_url(result, "simple")
2410

2511

2612
def test_install_vcs_editable_no_direct_url(script, with_wheel):
@@ -29,15 +15,15 @@ def test_install_vcs_editable_no_direct_url(script, with_wheel):
2915
result = script.pip(*args)
3016
# legacy editable installs do not generate .dist-info,
3117
# hence no direct_url.json
32-
assert not _get_created_direct_url(result, "testpkg")
18+
assert not get_created_direct_url(result, "testpkg")
3319

3420

3521
def test_install_vcs_non_editable_direct_url(script, with_wheel):
3622
pkg_path = _create_test_package(script, name="testpkg")
3723
url = path_to_url(pkg_path)
3824
args = ["install", f"git+{url}#egg=testpkg"]
3925
result = script.pip(*args)
40-
direct_url = _get_created_direct_url(result, "testpkg")
26+
direct_url = get_created_direct_url(result, "testpkg")
4127
assert direct_url
4228
assert direct_url.url == url
4329
assert direct_url.info.vcs == "git"
@@ -47,7 +33,7 @@ def test_install_archive_direct_url(script, data, with_wheel):
4733
req = "simple @ " + path_to_url(data.packages / "simple-2.0.tar.gz")
4834
assert req.startswith("simple @ file://")
4935
result = script.pip("install", req)
50-
assert _get_created_direct_url(result, "simple")
36+
assert get_created_direct_url(result, "simple")
5137

5238

5339
@pytest.mark.network
@@ -59,7 +45,7 @@ def test_install_vcs_constraint_direct_url(script, with_wheel):
5945
"#egg=pip-test-package"
6046
)
6147
result = script.pip("install", "pip-test-package", "-c", constraints_file)
62-
assert _get_created_direct_url(result, "pip_test_package")
48+
assert get_created_direct_url(result, "pip_test_package")
6349

6450

6551
def test_install_vcs_constraint_direct_file_url(script, with_wheel):
@@ -68,4 +54,4 @@ def test_install_vcs_constraint_direct_file_url(script, with_wheel):
6854
constraints_file = script.scratch_path / "constraints.txt"
6955
constraints_file.write_text(f"git+{url}#egg=testpkg")
7056
result = script.pip("install", "testpkg", "-c", constraints_file)
71-
assert _get_created_direct_url(result, "testpkg")
57+
assert get_created_direct_url(result, "testpkg")

tests/functional/test_new_resolver.py

+39
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
create_test_package_with_setup,
1313
path_to_url,
1414
)
15+
from tests.lib.direct_url import get_created_direct_url
1516
from tests.lib.path import Path
1617
from tests.lib.wheel import make_wheel
1718

@@ -1788,3 +1789,41 @@ def test_new_resolver_avoids_incompatible_wheel_tags_in_constraint_url(
17881789

17891790
assert_installed(script, base="0.1.0")
17901791
assert_not_installed(script, "dep")
1792+
1793+
1794+
def test_new_resolver_direct_url_with_extras(tmp_path, script):
1795+
pkg1 = create_basic_wheel_for_package(script, name="pkg1", version="1")
1796+
pkg2 = create_basic_wheel_for_package(
1797+
script,
1798+
name="pkg2",
1799+
version="1",
1800+
extras={"ext": ["pkg1"]},
1801+
)
1802+
pkg3 = create_basic_wheel_for_package(
1803+
script,
1804+
name="pkg3",
1805+
version="1",
1806+
depends=["pkg2[ext]"],
1807+
)
1808+
1809+
# Make pkg1 and pkg3 visible via --find-links, but not pkg2.
1810+
find_links = tmp_path.joinpath("find_links")
1811+
find_links.mkdir()
1812+
with open(pkg1, "rb") as f:
1813+
find_links.joinpath(pkg1.name).write_bytes(f.read())
1814+
with open(pkg3, "rb") as f:
1815+
find_links.joinpath(pkg3.name).write_bytes(f.read())
1816+
1817+
# Install with pkg2 only available with direct URL. The extra-ed direct
1818+
# URL pkg2 should be able to provide pkg2[ext] required by pkg3.
1819+
result = script.pip(
1820+
"install",
1821+
"--no-cache-dir", "--no-index",
1822+
"--find-links", str(find_links),
1823+
pkg2, "pkg3",
1824+
)
1825+
1826+
assert_installed(script, pkg1="1", pkg2="1", pkg3="1")
1827+
assert not get_created_direct_url(result, "pkg1")
1828+
assert get_created_direct_url(result, "pkg2")
1829+
assert not get_created_direct_url(result, "pkg3")

tests/lib/direct_url.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import re
2+
3+
from pip._internal.models.direct_url import DIRECT_URL_METADATA_NAME, DirectUrl
4+
5+
6+
def get_created_direct_url(result, pkg):
7+
direct_url_metadata_re = re.compile(
8+
pkg + r"-[\d\.]+\.dist-info." + DIRECT_URL_METADATA_NAME + r"$"
9+
)
10+
for filename in result.files_created:
11+
if direct_url_metadata_re.search(filename):
12+
direct_url_path = result.test_env.base_path / filename
13+
with open(direct_url_path) as f:
14+
return DirectUrl.from_json(f.read())
15+
return None

0 commit comments

Comments
 (0)