From 4c69ab2a2c5103d468f9dae77453f06d0813359a Mon Sep 17 00:00:00 2001 From: Max W Chase Date: Sun, 17 Jan 2021 23:01:24 -0500 Subject: [PATCH] Support URL constraints in the new resolver Fixes #8253 --- docs/html/user_guide.rst | 8 +- news/8253.feature.rst | 1 + src/pip/_internal/models/link.py | 6 + src/pip/_internal/req/constructors.py | 16 + src/pip/_internal/req/req_install.py | 4 +- .../_internal/resolution/resolvelib/base.py | 29 +- .../resolution/resolvelib/candidates.py | 4 +- .../resolution/resolvelib/factory.py | 41 ++ tests/functional/test_install_direct_url.py | 23 + tests/functional/test_install_reqs.py | 20 +- tests/functional/test_new_resolver.py | 514 +++++++++++++++++- tests/functional/test_new_resolver_hashes.py | 104 ++++ 12 files changed, 744 insertions(+), 26 deletions(-) create mode 100644 news/8253.feature.rst diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index 61e0cdf0769..c36ac520528 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -254,9 +254,11 @@ Constraints Files Constraints files are requirements files that only control which version of a requirement is installed, not whether it is installed or not. Their syntax and -contents is nearly identical to :ref:`Requirements Files`. There is one key -difference: Including a package in a constraints file does not trigger -installation of the package. +contents is a subset of :ref:`Requirements Files`, with several kinds of syntax +not allowed: constraints must have a name, they cannot be editable, and they +cannot specify extras. In terms of semantics, there is one key difference: +Including a package in a constraints file does not trigger installation of the +package. Use a constraints file like so: diff --git a/news/8253.feature.rst b/news/8253.feature.rst new file mode 100644 index 00000000000..196e4dd9613 --- /dev/null +++ b/news/8253.feature.rst @@ -0,0 +1 @@ +Add the ability for the new resolver to process URL constraints. diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py index 6f9a3244383..86d0be4079d 100644 --- a/src/pip/_internal/models/link.py +++ b/src/pip/_internal/models/link.py @@ -240,3 +240,9 @@ def is_hash_allowed(self, hashes): assert self.hash is not None return hashes.is_hash_allowed(self.hash_name, hex_digest=self.hash) + + +# TODO: Relax this comparison logic to ignore, for example, fragments. +def links_equivalent(link1, link2): + # type: (Link, Link) -> bool + return link1 == link2 diff --git a/src/pip/_internal/req/constructors.py b/src/pip/_internal/req/constructors.py index 810fc085988..b5e7e7c3e14 100644 --- a/src/pip/_internal/req/constructors.py +++ b/src/pip/_internal/req/constructors.py @@ -461,3 +461,19 @@ def install_req_from_parsed_requirement( user_supplied=user_supplied, ) return req + + +def install_req_from_link_and_ireq(link, ireq): + # type: (Link, InstallRequirement) -> InstallRequirement + return InstallRequirement( + req=ireq.req, + comes_from=ireq.comes_from, + editable=ireq.editable, + link=link, + markers=ireq.markers, + use_pep517=ireq.use_pep517, + isolated=ireq.isolated, + install_options=ireq.install_options, + global_options=ireq.global_options, + hash_options=ireq.hash_options, + ) diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py index 5eba2c2ae74..2c5434830d8 100644 --- a/src/pip/_internal/req/req_install.py +++ b/src/pip/_internal/req/req_install.py @@ -840,8 +840,8 @@ def check_invalid_constraint_type(req): problem = "" if not req.name: problem = "Unnamed requirements are not allowed as constraints" - elif req.link: - problem = "Links are not allowed as constraints" + elif req.editable: + problem = "Editable requirements are not allowed as constraints" elif req.extras: problem = "Constraints cannot have extras" diff --git a/src/pip/_internal/resolution/resolvelib/base.py b/src/pip/_internal/resolution/resolvelib/base.py index 81fee9b9e3e..d42bca8bfa4 100644 --- a/src/pip/_internal/resolution/resolvelib/base.py +++ b/src/pip/_internal/resolution/resolvelib/base.py @@ -4,7 +4,7 @@ from pip._vendor.packaging.utils import canonicalize_name from pip._vendor.packaging.version import _BaseVersion -from pip._internal.models.link import Link +from pip._internal.models.link import Link, links_equivalent from pip._internal.req.req_install import InstallRequirement from pip._internal.utils.hashes import Hashes @@ -20,24 +20,26 @@ def format_name(project, extras): class Constraint: - def __init__(self, specifier, hashes): - # type: (SpecifierSet, Hashes) -> None + def __init__(self, specifier, hashes, links): + # type: (SpecifierSet, Hashes, FrozenSet[Link]) -> None self.specifier = specifier self.hashes = hashes + self.links = links @classmethod def empty(cls): # type: () -> Constraint - return Constraint(SpecifierSet(), Hashes()) + return Constraint(SpecifierSet(), Hashes(), frozenset()) @classmethod def from_ireq(cls, ireq): # type: (InstallRequirement) -> Constraint - return Constraint(ireq.specifier, ireq.hashes(trust_internet=False)) + links = frozenset([ireq.link]) if ireq.link else frozenset() + return Constraint(ireq.specifier, ireq.hashes(trust_internet=False), links) def __nonzero__(self): # type: () -> bool - return bool(self.specifier) or bool(self.hashes) + return bool(self.specifier) or bool(self.hashes) or bool(self.links) def __bool__(self): # type: () -> bool @@ -49,10 +51,16 @@ def __and__(self, other): return NotImplemented specifier = self.specifier & other.specifier hashes = self.hashes & other.hashes(trust_internet=False) - return Constraint(specifier, hashes) + links = self.links + if other.link: + links = links.union([other.link]) + return Constraint(specifier, hashes, links) def is_satisfied_by(self, candidate): # type: (Candidate) -> bool + # Reject if there are any mismatched URL constraints on this package. + if self.links and not all(_match_link(link, candidate) for link in self.links): + return False # We can safely always allow prereleases here since PackageFinder # already implements the prerelease logic, and would have filtered out # prerelease candidates if the user does not expect them. @@ -94,6 +102,13 @@ def format_for_error(self): raise NotImplementedError("Subclass should override") +def _match_link(link, candidate): + # type: (Link, Candidate) -> bool + if candidate.source_link: + return links_equivalent(link, candidate.source_link) + return False + + class Candidate: @property def project_name(self): diff --git a/src/pip/_internal/resolution/resolvelib/candidates.py b/src/pip/_internal/resolution/resolvelib/candidates.py index 035e118d022..ccd1129dfc9 100644 --- a/src/pip/_internal/resolution/resolvelib/candidates.py +++ b/src/pip/_internal/resolution/resolvelib/candidates.py @@ -8,7 +8,7 @@ from pip._vendor.pkg_resources import Distribution from pip._internal.exceptions import HashError, MetadataInconsistent -from pip._internal.models.link import Link +from pip._internal.models.link import Link, links_equivalent from pip._internal.models.wheel import Wheel from pip._internal.req.constructors import ( install_req_from_editable, @@ -155,7 +155,7 @@ def __hash__(self): def __eq__(self, other): # type: (Any) -> bool if isinstance(other, self.__class__): - return self._link == other._link + return links_equivalent(self._link, other._link) return False @property diff --git a/src/pip/_internal/resolution/resolvelib/factory.py b/src/pip/_internal/resolution/resolvelib/factory.py index 3181d575336..f740734061d 100644 --- a/src/pip/_internal/resolution/resolvelib/factory.py +++ b/src/pip/_internal/resolution/resolvelib/factory.py @@ -32,6 +32,7 @@ from pip._internal.models.link import Link from pip._internal.models.wheel import Wheel from pip._internal.operations.prepare import RequirementPreparer +from pip._internal.req.constructors import install_req_from_link_and_ireq from pip._internal.req.req_install import InstallRequirement from pip._internal.resolution.base import InstallRequirementProvider from pip._internal.utils.compatibility_tags import get_supported @@ -264,6 +265,46 @@ def find_candidates( if ireq is not None: ireqs.append(ireq) + for link in constraint.links: + if not ireqs: + # If we hit this condition, then we cannot construct a candidate. + # However, if we hit this condition, then none of the requirements + # provided an ireq, so they must have provided an explicit candidate. + # In that case, either the candidate matches, in which case this loop + # doesn't need to do anything, or it doesn't, in which case there's + # nothing this loop can do to recover. + break + if link.is_wheel: + wheel = Wheel(link.filename) + # Check whether the provided wheel is compatible with the target + # platform. + if not wheel.supported(self._finder.target_python.get_tags()): + # We are constrained to install a wheel that is incompatible with + # the target architecture, so there are no valid candidates. + # Return early, with no candidates. + return () + # Create a "fake" InstallRequirement that's basically a clone of + # what "should" be the template, but with original_link set to link. + # Using the given requirement is necessary for preserving hash + # requirements, but without the original_link, direct_url.json + # won't be created. + ireq = install_req_from_link_and_ireq(link, ireqs[0]) + candidate = self._make_candidate_from_link( + link, + extras=frozenset(), + template=ireq, + name=canonicalize_name(ireq.name) if ireq.name else None, + version=None, + ) + if candidate is None: + # _make_candidate_from_link returns None if the wheel fails to build. + # We are constrained to install this wheel, so there are no valid + # candidates. + # Return early, with no candidates. + return () + + explicit_candidates.add(candidate) + # If none of the requirements want an explicit candidate, we can ask # the finder for candidates. if not explicit_candidates: diff --git a/tests/functional/test_install_direct_url.py b/tests/functional/test_install_direct_url.py index 23273774d16..e28a7e9b57e 100644 --- a/tests/functional/test_install_direct_url.py +++ b/tests/functional/test_install_direct_url.py @@ -1,5 +1,7 @@ import re +import pytest + from pip._internal.models.direct_url import DIRECT_URL_METADATA_NAME, DirectUrl from tests.lib import _create_test_package, path_to_url @@ -46,3 +48,24 @@ def test_install_archive_direct_url(script, data, with_wheel): assert req.startswith("simple @ file://") result = script.pip("install", req) assert _get_created_direct_url(result, "simple") + + +@pytest.mark.network +def test_install_vcs_constraint_direct_url(script, with_wheel): + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text( + "git+https://github.com/pypa/pip-test-package" + "@5547fa909e83df8bd743d3978d6667497983a4b7" + "#egg=pip-test-package" + ) + result = script.pip("install", "pip-test-package", "-c", constraints_file) + assert _get_created_direct_url(result, "pip_test_package") + + +def test_install_vcs_constraint_direct_file_url(script, with_wheel): + pkg_path = _create_test_package(script, name="testpkg") + url = path_to_url(pkg_path) + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text(f"git+{url}#egg=testpkg") + result = script.pip("install", "testpkg", "-c", constraints_file) + assert _get_created_direct_url(result, "testpkg") diff --git a/tests/functional/test_install_reqs.py b/tests/functional/test_install_reqs.py index d559e94be18..d8de6248b7d 100644 --- a/tests/functional/test_install_reqs.py +++ b/tests/functional/test_install_reqs.py @@ -405,7 +405,7 @@ def test_constraints_constrain_to_local_editable( expect_error=(resolver_variant == "2020-resolver"), ) if resolver_variant == "2020-resolver": - assert 'Links are not allowed as constraints' in result.stderr + assert 'Editable requirements are not allowed as constraints' in result.stderr else: assert 'Running setup.py develop for singlemodule' in result.stdout @@ -419,12 +419,8 @@ def test_constraints_constrain_to_local(script, data, resolver_variant): 'install', '--no-index', '-f', data.find_links, '-c', script.scratch_path / 'constraints.txt', 'singlemodule', allow_stderr_warning=True, - expect_error=(resolver_variant == "2020-resolver"), ) - if resolver_variant == "2020-resolver": - assert 'Links are not allowed as constraints' in result.stderr - else: - assert 'Running setup.py install for singlemodule' in result.stdout + assert 'Running setup.py install for singlemodule' in result.stdout 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): expect_error=(resolver_variant == "2020-resolver"), ) if resolver_variant == "2020-resolver": - assert 'Links are not allowed as constraints' in result.stderr + assert 'Cannot install singlemodule 0.0.1' in result.stderr, str(result) + assert ( + 'because these package versions have conflicting dependencies.' + in result.stderr + ), str(result) else: assert ('Running setup.py install for singlemodule' in result.stdout), str(result) @@ -489,7 +489,7 @@ def test_install_with_extras_from_constraints(script, data, resolver_variant): expect_error=(resolver_variant == "2020-resolver"), ) if resolver_variant == "2020-resolver": - assert 'Links are not allowed as constraints' in result.stderr + assert 'Constraints cannot have extras' in result.stderr else: result.did_create(script.site_packages / 'simple') @@ -521,7 +521,7 @@ def test_install_with_extras_joined(script, data, resolver_variant): expect_error=(resolver_variant == "2020-resolver"), ) if resolver_variant == "2020-resolver": - assert 'Links are not allowed as constraints' in result.stderr + assert 'Constraints cannot have extras' in result.stderr else: result.did_create(script.site_packages / 'simple') result.did_create(script.site_packages / 'singlemodule.py') @@ -538,7 +538,7 @@ def test_install_with_extras_editable_joined(script, data, resolver_variant): expect_error=(resolver_variant == "2020-resolver"), ) if resolver_variant == "2020-resolver": - assert 'Links are not allowed as constraints' in result.stderr + assert 'Editable requirements are not allowed as constraints' in result.stderr else: result.did_create(script.site_packages / 'simple') result.did_create(script.site_packages / 'singlemodule.py') diff --git a/tests/functional/test_new_resolver.py b/tests/functional/test_new_resolver.py index 16f9f4f4216..78a21a3c115 100644 --- a/tests/functional/test_new_resolver.py +++ b/tests/functional/test_new_resolver.py @@ -10,7 +10,9 @@ create_basic_sdist_for_package, create_basic_wheel_for_package, create_test_package_with_setup, + path_to_url, ) +from tests.lib.path import Path from tests.lib.wheel import make_wheel @@ -45,6 +47,24 @@ def assert_editable(script, *args): f"{args!r} not all found in {script.site_packages_path!r}" +@pytest.fixture() +def make_fake_wheel(script): + + def _make_fake_wheel(name, version, wheel_tag): + wheel_house = script.scratch_path.joinpath("wheelhouse") + wheel_house.mkdir() + wheel_builder = make_wheel( + name=name, + version=version, + wheel_metadata_updates={"Tag": []}, + ) + wheel_path = wheel_house.joinpath(f"{name}-{version}-{wheel_tag}.whl") + wheel_builder.save_to(wheel_path) + return wheel_path + + return _make_fake_wheel + + def test_new_resolver_can_install(script): create_basic_wheel_for_package( script, @@ -641,8 +661,8 @@ def test_new_resolver_constraint_no_specifier(script): "Unnamed requirements are not allowed as constraints", ), ( - "req @ https://example.com/dist.zip", - "Links are not allowed as constraints", + "-e git+https://example.com/dist.git#egg=req", + "Editable requirements are not allowed as constraints", ), ( "pkg[extra]", @@ -1278,3 +1298,493 @@ def test_new_resolver_no_fetch_no_satisfying(script): "myuberpkg", ) assert "Processing " not in result.stdout, str(result) + + +def test_new_resolver_does_not_install_unneeded_packages_with_url_constraint(script): + archive_path = create_basic_wheel_for_package( + script, + "installed", + "0.1.0", + ) + not_installed_path = create_basic_wheel_for_package( + script, + "not_installed", + "0.1.0", + ) + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("not_installed @ " + path_to_url(not_installed_path)) + + (script.scratch_path / "index").mkdir() + archive_path.rename(script.scratch_path / "index" / archive_path.name) + + script.pip( + "install", + "--no-cache-dir", "--no-index", + "--find-links", script.scratch_path / "index", + "-c", constraints_file, + "installed" + ) + + assert_installed(script, installed="0.1.0") + assert_not_installed(script, "not_installed") + + +def test_new_resolver_installs_packages_with_url_constraint(script): + installed_path = create_basic_wheel_for_package( + script, + "installed", + "0.1.0", + ) + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("installed @ " + path_to_url(installed_path)) + + script.pip( + "install", + "--no-cache-dir", "--no-index", + "-c", constraints_file, + "installed" + ) + + assert_installed(script, installed="0.1.0") + + +def test_new_resolver_reinstall_link_requirement_with_constraint(script): + installed_path = create_basic_wheel_for_package( + script, + "installed", + "0.1.0", + ) + + cr_file = script.scratch_path / "constraints.txt" + cr_file.write_text("installed @ " + path_to_url(installed_path)) + + script.pip( + "install", + "--no-cache-dir", "--no-index", + "-r", cr_file, + ) + + script.pip( + "install", + "--no-cache-dir", "--no-index", + "-c", cr_file, + "-r", cr_file, + ) + # TODO: strengthen assertion to "second invocation does no work" + # I don't think this is true yet, but it should be in the future. + + assert_installed(script, installed="0.1.0") + + +def test_new_resolver_prefers_url_constraint(script): + installed_path = create_basic_wheel_for_package( + script, + "test_pkg", + "0.1.0", + ) + not_installed_path = create_basic_wheel_for_package( + script, + "test_pkg", + "0.2.0", + ) + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("test_pkg @ " + path_to_url(installed_path)) + + (script.scratch_path / "index").mkdir() + not_installed_path.rename(script.scratch_path / "index" / not_installed_path.name) + + script.pip( + "install", + "--no-cache-dir", "--no-index", + "--find-links", script.scratch_path / "index", + "-c", constraints_file, + "test_pkg" + ) + + assert_installed(script, test_pkg="0.1.0") + + +def test_new_resolver_prefers_url_constraint_on_update(script): + installed_path = create_basic_wheel_for_package( + script, + "test_pkg", + "0.1.0", + ) + not_installed_path = create_basic_wheel_for_package( + script, + "test_pkg", + "0.2.0", + ) + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("test_pkg @ " + path_to_url(installed_path)) + + (script.scratch_path / "index").mkdir() + not_installed_path.rename(script.scratch_path / "index" / not_installed_path.name) + + script.pip( + "install", + "--no-cache-dir", "--no-index", + "--find-links", script.scratch_path / "index", + "test_pkg" + ) + + assert_installed(script, test_pkg="0.2.0") + + script.pip( + "install", + "--no-cache-dir", "--no-index", + "--find-links", script.scratch_path / "index", + "-c", constraints_file, + "test_pkg" + ) + + assert_installed(script, test_pkg="0.1.0") + + +@pytest.mark.parametrize("version_option", ["--constraint", "--requirement"]) +def test_new_resolver_fails_with_url_constraint_and_incompatible_version( + script, version_option, +): + not_installed_path = create_basic_wheel_for_package( + script, + "test_pkg", + "0.1.0", + ) + not_installed_path = create_basic_wheel_for_package( + script, + "test_pkg", + "0.2.0", + ) + + url_constraint = script.scratch_path / "constraints.txt" + url_constraint.write_text("test_pkg @ " + path_to_url(not_installed_path)) + + version_req = script.scratch_path / "requirements.txt" + version_req.write_text("test_pkg<0.2.0") + + result = script.pip( + "install", + "--no-cache-dir", "--no-index", + "--find-links", script.scratch_path, + "--constraint", url_constraint, + version_option, version_req, + "test_pkg", + expect_error=True, + ) + + assert "Cannot install test_pkg" in result.stderr, str(result) + assert ( + "because these package versions have conflicting dependencies." + ) in result.stderr, str(result) + + assert_not_installed(script, "test_pkg") + + # Assert that pip works properly in the absence of the constraints file. + script.pip( + "install", + "--no-cache-dir", "--no-index", + "--find-links", script.scratch_path, + version_option, version_req, + "test_pkg" + ) + + +def test_new_resolver_ignores_unneeded_conflicting_constraints(script): + version_1 = create_basic_wheel_for_package( + script, + "test_pkg", + "0.1.0", + ) + version_2 = create_basic_wheel_for_package( + script, + "test_pkg", + "0.2.0", + ) + create_basic_wheel_for_package( + script, + "installed", + "0.1.0", + ) + + constraints = [ + "test_pkg @ " + path_to_url(version_1), + "test_pkg @ " + path_to_url(version_2), + ] + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("\n".join(constraints)) + + script.pip( + "install", + "--no-cache-dir", "--no-index", + "--find-links", script.scratch_path, + "-c", constraints_file, + "installed" + ) + + assert_not_installed(script, "test_pkg") + assert_installed(script, installed="0.1.0") + + +def test_new_resolver_fails_on_needed_conflicting_constraints(script): + version_1 = create_basic_wheel_for_package( + script, + "test_pkg", + "0.1.0", + ) + version_2 = create_basic_wheel_for_package( + script, + "test_pkg", + "0.2.0", + ) + + constraints = [ + "test_pkg @ " + path_to_url(version_1), + "test_pkg @ " + path_to_url(version_2), + ] + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("\n".join(constraints)) + + result = script.pip( + "install", + "--no-cache-dir", "--no-index", + "--find-links", script.scratch_path, + "-c", constraints_file, + "test_pkg", + expect_error=True, + ) + + assert ( + "Cannot install test_pkg because these package versions have conflicting " + "dependencies." + ) in result.stderr, str(result) + + assert_not_installed(script, "test_pkg") + + # Assert that pip works properly in the absence of the constraints file. + script.pip( + "install", + "--no-cache-dir", "--no-index", + "--find-links", script.scratch_path, + "test_pkg", + ) + + +def test_new_resolver_fails_on_conflicting_constraint_and_requirement(script): + version_1 = create_basic_wheel_for_package( + script, + "test_pkg", + "0.1.0", + ) + version_2 = create_basic_wheel_for_package( + script, + "test_pkg", + "0.2.0", + ) + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("test_pkg @ " + path_to_url(version_1)) + + result = script.pip( + "install", + "--no-cache-dir", "--no-index", + "--find-links", script.scratch_path, + "-c", constraints_file, + "test_pkg @ " + path_to_url(version_2), + expect_error=True, + ) + + assert "Cannot install test-pkg 0.2.0" in result.stderr, str(result) + assert ( + "because these package versions have conflicting dependencies." + ) in result.stderr, str(result) + + assert_not_installed(script, "test_pkg") + + # Assert that pip works properly in the absence of the constraints file. + script.pip( + "install", + "--no-cache-dir", "--no-index", + "--find-links", script.scratch_path, + "test_pkg @ " + path_to_url(version_2), + ) + + +@pytest.mark.parametrize("editable", [False, True]) +def test_new_resolver_succeeds_on_matching_constraint_and_requirement(script, editable): + if editable: + source_dir = create_test_package_with_setup( + script, + name="test_pkg", + version="0.1.0" + ) + else: + source_dir = create_basic_wheel_for_package( + script, + "test_pkg", + "0.1.0", + ) + + req_line = "test_pkg @ " + path_to_url(source_dir) + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text(req_line) + + if editable: + last_args = ("-e", source_dir) + else: + last_args = (req_line,) + + script.pip( + "install", + "--no-cache-dir", "--no-index", + "-c", constraints_file, + *last_args, + ) + + assert_installed(script, test_pkg="0.1.0") + if editable: + assert_editable(script, "test-pkg") + + +def test_new_resolver_applies_url_constraint_to_dep(script): + version_1 = create_basic_wheel_for_package( + script, + "dep", + "0.1.0", + ) + version_2 = create_basic_wheel_for_package( + script, + "dep", + "0.2.0", + ) + + base = create_basic_wheel_for_package(script, "base", "0.1.0", depends=["dep"]) + + (script.scratch_path / "index").mkdir() + base.rename(script.scratch_path / "index" / base.name) + version_2.rename(script.scratch_path / "index" / version_2.name) + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("dep @ " + path_to_url(version_1)) + + script.pip( + "install", + "--no-cache-dir", "--no-index", + "-c", constraints_file, + "--find-links", script.scratch_path / "index", + "base", + ) + + assert_installed(script, dep="0.1.0") + + +def test_new_resolver_handles_compatible_wheel_tags_in_constraint_url( + script, make_fake_wheel +): + initial_path = make_fake_wheel("base", "0.1.0", "fakepy1-fakeabi-fakeplat") + + constrained = script.scratch_path / "constrained" + constrained.mkdir() + + final_path = constrained / initial_path.name + + initial_path.rename(final_path) + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("base @ " + path_to_url(final_path)) + + result = script.pip( + "install", + "--implementation", "fakepy", + '--only-binary=:all:', + "--python-version", "1", + "--abi", "fakeabi", + "--platform", "fakeplat", + "--target", script.scratch_path / "target", + "--no-cache-dir", "--no-index", + "-c", constraints_file, + "base", + ) + + dist_info = Path("scratch", "target", "base-0.1.0.dist-info") + result.did_create(dist_info) + + +def test_new_resolver_handles_incompatible_wheel_tags_in_constraint_url( + script, make_fake_wheel +): + initial_path = make_fake_wheel("base", "0.1.0", "fakepy1-fakeabi-fakeplat") + + constrained = script.scratch_path / "constrained" + constrained.mkdir() + + final_path = constrained / initial_path.name + + initial_path.rename(final_path) + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("base @ " + path_to_url(final_path)) + + result = script.pip( + "install", + "--no-cache-dir", "--no-index", + "-c", constraints_file, + "base", + expect_error=True, + ) + + assert ( + "Cannot install base because these package versions have conflicting " + "dependencies." + ) in result.stderr, str(result) + + assert_not_installed(script, "base") + + +def test_new_resolver_avoids_incompatible_wheel_tags_in_constraint_url( + script, make_fake_wheel +): + initial_path = make_fake_wheel("dep", "0.1.0", "fakepy1-fakeabi-fakeplat") + + constrained = script.scratch_path / "constrained" + constrained.mkdir() + + final_path = constrained / initial_path.name + + initial_path.rename(final_path) + + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("dep @ " + path_to_url(final_path)) + + index = script.scratch_path / "index" + index.mkdir() + + index_dep = create_basic_wheel_for_package(script, "dep", "0.2.0") + + base = create_basic_wheel_for_package( + script, "base", "0.1.0" + ) + base_2 = create_basic_wheel_for_package( + script, "base", "0.2.0", depends=["dep"] + ) + + index_dep.rename(index / index_dep.name) + base.rename(index / base.name) + base_2.rename(index / base_2.name) + + script.pip( + "install", + "--no-cache-dir", "--no-index", + "-c", constraints_file, + "--find-links", script.scratch_path / "index", + "base", + ) + + assert_installed(script, base="0.1.0") + assert_not_installed(script, "dep") diff --git a/tests/functional/test_new_resolver_hashes.py b/tests/functional/test_new_resolver_hashes.py index 854b66418ae..02397616ab7 100644 --- a/tests/functional/test_new_resolver_hashes.py +++ b/tests/functional/test_new_resolver_hashes.py @@ -1,7 +1,9 @@ import collections import hashlib +import json import pytest +from pip._vendor.packaging.utils import canonicalize_name from pip._internal.utils.urls import path_to_url from tests.lib import create_basic_sdist_for_package, create_basic_wheel_for_package @@ -11,6 +13,30 @@ ) +def assert_installed(script, **kwargs): + ret = script.pip('list', '--format=json') + installed = set( + (canonicalize_name(val['name']), val['version']) + for val in json.loads(ret.stdout) + ) + expected = set((canonicalize_name(k), v) for k, v in kwargs.items()) + assert expected <= installed, \ + "{!r} not all in {!r}".format(expected, installed) + + +def assert_not_installed(script, *args): + ret = script.pip("list", "--format=json") + installed = set( + canonicalize_name(val["name"]) + for val in json.loads(ret.stdout) + ) + # None of the given names should be listed as installed, i.e. their + # intersection should be empty. + expected = set(canonicalize_name(k) for k in args) + assert not (expected & installed), \ + "{!r} contained in {!r}".format(expected, installed) + + def _create_find_links(script): sdist_path = create_basic_sdist_for_package(script, "base", "0.1.0") wheel_path = create_basic_wheel_for_package(script, "base", "0.1.0") @@ -204,3 +230,81 @@ def test_new_resolver_hash_intersect_empty_from_constraint(script): "from some requirements." ) assert message in result.stderr, str(result) + + +@pytest.mark.parametrize("constrain_by_hash", [False, True]) +def test_new_resolver_hash_requirement_and_url_constraint_can_succeed( + script, constrain_by_hash, +): + wheel_path = create_basic_wheel_for_package(script, "base", "0.1.0") + + wheel_hash = hashlib.sha256(wheel_path.read_bytes()).hexdigest() + + requirements_txt = script.scratch_path / "requirements.txt" + requirements_txt.write_text( + """ + base==0.1.0 --hash=sha256:{wheel_hash} + """.format( + wheel_hash=wheel_hash, + ), + ) + + constraints_txt = script.scratch_path / "constraints.txt" + constraint_text = "base @ {wheel_url}\n".format(wheel_url=path_to_url(wheel_path)) + if constrain_by_hash: + constraint_text += "base==0.1.0 --hash=sha256:{wheel_hash}\n".format( + wheel_hash=wheel_hash, + ) + constraints_txt.write_text(constraint_text) + + script.pip( + "install", + "--no-cache-dir", + "--no-index", + "--constraint", constraints_txt, + "--requirement", requirements_txt, + ) + + assert_installed(script, base="0.1.0") + + +@pytest.mark.parametrize("constrain_by_hash", [False, True]) +def test_new_resolver_hash_requirement_and_url_constraint_can_fail( + script, constrain_by_hash, +): + wheel_path = create_basic_wheel_for_package(script, "base", "0.1.0") + other_path = create_basic_wheel_for_package(script, "other", "0.1.0") + + other_hash = hashlib.sha256(other_path.read_bytes()).hexdigest() + + requirements_txt = script.scratch_path / "requirements.txt" + requirements_txt.write_text( + """ + base==0.1.0 --hash=sha256:{other_hash} + """.format( + other_hash=other_hash, + ), + ) + + constraints_txt = script.scratch_path / "constraints.txt" + constraint_text = "base @ {wheel_url}\n".format(wheel_url=path_to_url(wheel_path)) + if constrain_by_hash: + constraint_text += "base==0.1.0 --hash=sha256:{other_hash}\n".format( + other_hash=other_hash, + ) + constraints_txt.write_text(constraint_text) + + result = script.pip( + "install", + "--no-cache-dir", + "--no-index", + "--constraint", constraints_txt, + "--requirement", requirements_txt, + expect_error=True, + ) + + assert ( + "THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE." + ) in result.stderr, str(result) + + assert_not_installed(script, "base", "other")