Skip to content

Commit 4c69ab2

Browse files
committed
Support URL constraints in the new resolver
Fixes #8253
1 parent d150cf2 commit 4c69ab2

File tree

12 files changed

+744
-26
lines changed

12 files changed

+744
-26
lines changed

docs/html/user_guide.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,11 @@ Constraints Files
254254

255255
Constraints files are requirements files that only control which version of a
256256
requirement is installed, not whether it is installed or not. Their syntax and
257-
contents is nearly identical to :ref:`Requirements Files`. There is one key
258-
difference: Including a package in a constraints file does not trigger
259-
installation of the package.
257+
contents is a subset of :ref:`Requirements Files`, with several kinds of syntax
258+
not allowed: constraints must have a name, they cannot be editable, and they
259+
cannot specify extras. In terms of semantics, there is one key difference:
260+
Including a package in a constraints file does not trigger installation of the
261+
package.
260262

261263
Use a constraints file like so:
262264

news/8253.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add the ability for the new resolver to process URL constraints.

src/pip/_internal/models/link.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,9 @@ def is_hash_allowed(self, hashes):
240240
assert self.hash is not None
241241

242242
return hashes.is_hash_allowed(self.hash_name, hex_digest=self.hash)
243+
244+
245+
# TODO: Relax this comparison logic to ignore, for example, fragments.
246+
def links_equivalent(link1, link2):
247+
# type: (Link, Link) -> bool
248+
return link1 == link2

src/pip/_internal/req/constructors.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,3 +461,19 @@ def install_req_from_parsed_requirement(
461461
user_supplied=user_supplied,
462462
)
463463
return req
464+
465+
466+
def install_req_from_link_and_ireq(link, ireq):
467+
# type: (Link, InstallRequirement) -> InstallRequirement
468+
return InstallRequirement(
469+
req=ireq.req,
470+
comes_from=ireq.comes_from,
471+
editable=ireq.editable,
472+
link=link,
473+
markers=ireq.markers,
474+
use_pep517=ireq.use_pep517,
475+
isolated=ireq.isolated,
476+
install_options=ireq.install_options,
477+
global_options=ireq.global_options,
478+
hash_options=ireq.hash_options,
479+
)

src/pip/_internal/req/req_install.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -840,8 +840,8 @@ def check_invalid_constraint_type(req):
840840
problem = ""
841841
if not req.name:
842842
problem = "Unnamed requirements are not allowed as constraints"
843-
elif req.link:
844-
problem = "Links are not allowed as constraints"
843+
elif req.editable:
844+
problem = "Editable requirements are not allowed as constraints"
845845
elif req.extras:
846846
problem = "Constraints cannot have extras"
847847

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

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from pip._vendor.packaging.utils import canonicalize_name
55
from pip._vendor.packaging.version import _BaseVersion
66

7-
from pip._internal.models.link import Link
7+
from pip._internal.models.link import Link, links_equivalent
88
from pip._internal.req.req_install import InstallRequirement
99
from pip._internal.utils.hashes import Hashes
1010

@@ -20,24 +20,26 @@ def format_name(project, extras):
2020

2121

2222
class Constraint:
23-
def __init__(self, specifier, hashes):
24-
# type: (SpecifierSet, Hashes) -> None
23+
def __init__(self, specifier, hashes, links):
24+
# type: (SpecifierSet, Hashes, FrozenSet[Link]) -> None
2525
self.specifier = specifier
2626
self.hashes = hashes
27+
self.links = links
2728

2829
@classmethod
2930
def empty(cls):
3031
# type: () -> Constraint
31-
return Constraint(SpecifierSet(), Hashes())
32+
return Constraint(SpecifierSet(), Hashes(), frozenset())
3233

3334
@classmethod
3435
def from_ireq(cls, ireq):
3536
# type: (InstallRequirement) -> Constraint
36-
return Constraint(ireq.specifier, ireq.hashes(trust_internet=False))
37+
links = frozenset([ireq.link]) if ireq.link else frozenset()
38+
return Constraint(ireq.specifier, ireq.hashes(trust_internet=False), links)
3739

3840
def __nonzero__(self):
3941
# type: () -> bool
40-
return bool(self.specifier) or bool(self.hashes)
42+
return bool(self.specifier) or bool(self.hashes) or bool(self.links)
4143

4244
def __bool__(self):
4345
# type: () -> bool
@@ -49,10 +51,16 @@ def __and__(self, other):
4951
return NotImplemented
5052
specifier = self.specifier & other.specifier
5153
hashes = self.hashes & other.hashes(trust_internet=False)
52-
return Constraint(specifier, hashes)
54+
links = self.links
55+
if other.link:
56+
links = links.union([other.link])
57+
return Constraint(specifier, hashes, links)
5358

5459
def is_satisfied_by(self, candidate):
5560
# type: (Candidate) -> bool
61+
# Reject if there are any mismatched URL constraints on this package.
62+
if self.links and not all(_match_link(link, candidate) for link in self.links):
63+
return False
5664
# We can safely always allow prereleases here since PackageFinder
5765
# already implements the prerelease logic, and would have filtered out
5866
# prerelease candidates if the user does not expect them.
@@ -94,6 +102,13 @@ def format_for_error(self):
94102
raise NotImplementedError("Subclass should override")
95103

96104

105+
def _match_link(link, candidate):
106+
# type: (Link, Candidate) -> bool
107+
if candidate.source_link:
108+
return links_equivalent(link, candidate.source_link)
109+
return False
110+
111+
97112
class Candidate:
98113
@property
99114
def project_name(self):

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from pip._vendor.pkg_resources import Distribution
99

1010
from pip._internal.exceptions import HashError, MetadataInconsistent
11-
from pip._internal.models.link import Link
11+
from pip._internal.models.link import Link, links_equivalent
1212
from pip._internal.models.wheel import Wheel
1313
from pip._internal.req.constructors import (
1414
install_req_from_editable,
@@ -155,7 +155,7 @@ def __hash__(self):
155155
def __eq__(self, other):
156156
# type: (Any) -> bool
157157
if isinstance(other, self.__class__):
158-
return self._link == other._link
158+
return links_equivalent(self._link, other._link)
159159
return False
160160

161161
@property

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from pip._internal.models.link import Link
3333
from pip._internal.models.wheel import Wheel
3434
from pip._internal.operations.prepare import RequirementPreparer
35+
from pip._internal.req.constructors import install_req_from_link_and_ireq
3536
from pip._internal.req.req_install import InstallRequirement
3637
from pip._internal.resolution.base import InstallRequirementProvider
3738
from pip._internal.utils.compatibility_tags import get_supported
@@ -264,6 +265,46 @@ def find_candidates(
264265
if ireq is not None:
265266
ireqs.append(ireq)
266267

268+
for link in constraint.links:
269+
if not ireqs:
270+
# If we hit this condition, then we cannot construct a candidate.
271+
# However, if we hit this condition, then none of the requirements
272+
# provided an ireq, so they must have provided an explicit candidate.
273+
# In that case, either the candidate matches, in which case this loop
274+
# doesn't need to do anything, or it doesn't, in which case there's
275+
# nothing this loop can do to recover.
276+
break
277+
if link.is_wheel:
278+
wheel = Wheel(link.filename)
279+
# Check whether the provided wheel is compatible with the target
280+
# platform.
281+
if not wheel.supported(self._finder.target_python.get_tags()):
282+
# We are constrained to install a wheel that is incompatible with
283+
# the target architecture, so there are no valid candidates.
284+
# Return early, with no candidates.
285+
return ()
286+
# Create a "fake" InstallRequirement that's basically a clone of
287+
# what "should" be the template, but with original_link set to link.
288+
# Using the given requirement is necessary for preserving hash
289+
# requirements, but without the original_link, direct_url.json
290+
# won't be created.
291+
ireq = install_req_from_link_and_ireq(link, ireqs[0])
292+
candidate = self._make_candidate_from_link(
293+
link,
294+
extras=frozenset(),
295+
template=ireq,
296+
name=canonicalize_name(ireq.name) if ireq.name else None,
297+
version=None,
298+
)
299+
if candidate is None:
300+
# _make_candidate_from_link returns None if the wheel fails to build.
301+
# We are constrained to install this wheel, so there are no valid
302+
# candidates.
303+
# Return early, with no candidates.
304+
return ()
305+
306+
explicit_candidates.add(candidate)
307+
267308
# If none of the requirements want an explicit candidate, we can ask
268309
# the finder for candidates.
269310
if not explicit_candidates:

tests/functional/test_install_direct_url.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import re
22

3+
import pytest
4+
35
from pip._internal.models.direct_url import DIRECT_URL_METADATA_NAME, DirectUrl
46
from tests.lib import _create_test_package, path_to_url
57

@@ -46,3 +48,24 @@ def test_install_archive_direct_url(script, data, with_wheel):
4648
assert req.startswith("simple @ file://")
4749
result = script.pip("install", req)
4850
assert _get_created_direct_url(result, "simple")
51+
52+
53+
@pytest.mark.network
54+
def test_install_vcs_constraint_direct_url(script, with_wheel):
55+
constraints_file = script.scratch_path / "constraints.txt"
56+
constraints_file.write_text(
57+
"git+https://github.com/pypa/pip-test-package"
58+
"@5547fa909e83df8bd743d3978d6667497983a4b7"
59+
"#egg=pip-test-package"
60+
)
61+
result = script.pip("install", "pip-test-package", "-c", constraints_file)
62+
assert _get_created_direct_url(result, "pip_test_package")
63+
64+
65+
def test_install_vcs_constraint_direct_file_url(script, with_wheel):
66+
pkg_path = _create_test_package(script, name="testpkg")
67+
url = path_to_url(pkg_path)
68+
constraints_file = script.scratch_path / "constraints.txt"
69+
constraints_file.write_text(f"git+{url}#egg=testpkg")
70+
result = script.pip("install", "testpkg", "-c", constraints_file)
71+
assert _get_created_direct_url(result, "testpkg")

tests/functional/test_install_reqs.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ def test_constraints_constrain_to_local_editable(
405405
expect_error=(resolver_variant == "2020-resolver"),
406406
)
407407
if resolver_variant == "2020-resolver":
408-
assert 'Links are not allowed as constraints' in result.stderr
408+
assert 'Editable requirements are not allowed as constraints' in result.stderr
409409
else:
410410
assert 'Running setup.py develop for singlemodule' in result.stdout
411411

@@ -419,12 +419,8 @@ def test_constraints_constrain_to_local(script, data, resolver_variant):
419419
'install', '--no-index', '-f', data.find_links, '-c',
420420
script.scratch_path / 'constraints.txt', 'singlemodule',
421421
allow_stderr_warning=True,
422-
expect_error=(resolver_variant == "2020-resolver"),
423422
)
424-
if resolver_variant == "2020-resolver":
425-
assert 'Links are not allowed as constraints' in result.stderr
426-
else:
427-
assert 'Running setup.py install for singlemodule' in result.stdout
423+
assert 'Running setup.py install for singlemodule' in result.stdout
428424

429425

430426
def test_constrained_to_url_install_same_url(script, data, resolver_variant):
@@ -438,7 +434,11 @@ def test_constrained_to_url_install_same_url(script, data, resolver_variant):
438434
expect_error=(resolver_variant == "2020-resolver"),
439435
)
440436
if resolver_variant == "2020-resolver":
441-
assert 'Links are not allowed as constraints' in result.stderr
437+
assert 'Cannot install singlemodule 0.0.1' in result.stderr, str(result)
438+
assert (
439+
'because these package versions have conflicting dependencies.'
440+
in result.stderr
441+
), str(result)
442442
else:
443443
assert ('Running setup.py install for singlemodule'
444444
in result.stdout), str(result)
@@ -489,7 +489,7 @@ def test_install_with_extras_from_constraints(script, data, resolver_variant):
489489
expect_error=(resolver_variant == "2020-resolver"),
490490
)
491491
if resolver_variant == "2020-resolver":
492-
assert 'Links are not allowed as constraints' in result.stderr
492+
assert 'Constraints cannot have extras' in result.stderr
493493
else:
494494
result.did_create(script.site_packages / 'simple')
495495

@@ -521,7 +521,7 @@ def test_install_with_extras_joined(script, data, resolver_variant):
521521
expect_error=(resolver_variant == "2020-resolver"),
522522
)
523523
if resolver_variant == "2020-resolver":
524-
assert 'Links are not allowed as constraints' in result.stderr
524+
assert 'Constraints cannot have extras' in result.stderr
525525
else:
526526
result.did_create(script.site_packages / 'simple')
527527
result.did_create(script.site_packages / 'singlemodule.py')
@@ -538,7 +538,7 @@ def test_install_with_extras_editable_joined(script, data, resolver_variant):
538538
expect_error=(resolver_variant == "2020-resolver"),
539539
)
540540
if resolver_variant == "2020-resolver":
541-
assert 'Links are not allowed as constraints' in result.stderr
541+
assert 'Editable requirements are not allowed as constraints' in result.stderr
542542
else:
543543
result.did_create(script.site_packages / 'simple')
544544
result.did_create(script.site_packages / 'singlemodule.py')

0 commit comments

Comments
 (0)