From c55b9857c60a2b2cec487d3b9c10315153ca1586 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Thu, 12 Jan 2023 17:57:26 +0000 Subject: [PATCH] Add '--ignore-constraint' option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constraint files are often shared across an organization. When developing a package included in such a constraint file, it is not possible to install the package with constraints since the constraint on the package prevents us installing a development version. ❯ cd my-amazing-package ❯ cat constraints.txt my-amazing-package==1.2.3 Jinja2==3.1.2 iso8601==1.1.0 msgpack==1.0.4 ❯ pip install -c constraints.txt . Processing /dev/my-amazing-package Preparing metadata (setup.py) ... done ERROR: Cannot install my-amazing-package 1.2.4.dev1 (from /dev/my-amazing-package) because these package versions have conflicting dependencies. The conflict is caused by: The user requested my-amazing-package 1.2.4.dev1 (from /dev/my-amazing-package) The user requested (constraint) my-amazing-package===1.2.4.dev1 To fix this you could try to: 1. loosen the range of package versions you've specified 2. remove package versions to allow pip attempt to solve the dependency conflict ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts Resolve this by allowing users to opt out of individual constraints to the 'install', 'wheel', and 'download' subcommands. This is rather manual but it's expected that tools like tox could automatically generate a value for this option when invoking 'pip install' command. ❯ pip install -c constraints.txt --ignore-constraint my-amazing-package . ❯ pip wheel -c constraints.txt --ignore-constraint my-amazing-package . ❯ pip download -c constraints.txt --ignore-constraint my-amazing-package . This is only added for the '2020-resolver' resolver, not the 'legacy-resolver' resolver, given the latter is deprecated for removal. Signed-off-by: Stephen Finucane Fixes: #7839 --- news/7839.feature.rst | 3 +++ src/pip/_internal/cli/cmdoptions.py | 13 ++++++++++++ src/pip/_internal/cli/req_command.py | 3 +++ src/pip/_internal/commands/download.py | 1 + src/pip/_internal/commands/install.py | 1 + src/pip/_internal/commands/wheel.py | 1 + .../resolution/resolvelib/provider.py | 18 ++++++++++++----- .../resolution/resolvelib/resolver.py | 5 ++++- tests/functional/test_new_resolver.py | 20 +++++++++++++++++++ tests/unit/resolution_resolvelib/conftest.py | 1 + .../resolution_resolvelib/test_provider.py | 1 + .../resolution_resolvelib/test_resolver.py | 1 + 12 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 news/7839.feature.rst diff --git a/news/7839.feature.rst b/news/7839.feature.rst new file mode 100644 index 00000000000..dcf923559e5 --- /dev/null +++ b/news/7839.feature.rst @@ -0,0 +1,3 @@ +Add the ``--ignore-constraint`` option to ``pip install``, ``pip download`` +and ``pip wheel`` commands to ignore an individual constraint from a +constraints file. diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index d05e502f908..6066441555d 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -415,6 +415,19 @@ def constraints() -> Option: ) +def ignored_constraints() -> Option: + return Option( + "--ignore-constraint", + dest="ignored_constraints", + action="append", + default=[], + metavar="package", + help="Ignore constraints for given package. This is commonly used " + "during development of a package when using a common constraints " + "file. This option be used multiple times.", + ) + + def requirements() -> Option: return Option( "-r", diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 6f2f79c6b3f..e6413806849 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -363,10 +363,13 @@ def make_resolver( ignore_requires_python=ignore_requires_python, force_reinstall=force_reinstall, upgrade_strategy=upgrade_strategy, + ignored_constraints=options.ignored_constraints, py_version_info=py_version_info, ) import pip._internal.resolution.legacy.resolver + # we intentionally don't pass ignored_constraints to this since the + # resolver is deprecated return pip._internal.resolution.legacy.resolver.Resolver( preparer=preparer, finder=finder, diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 54247a78a65..3f8610afe8f 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -37,6 +37,7 @@ class DownloadCommand(RequirementCommand): def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.ignored_constraints()) self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.global_options()) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index e944bb95a50..927f8e45185 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -72,6 +72,7 @@ class InstallCommand(RequirementCommand): def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.ignored_constraints()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index ed578aa2500..ed3b9de9b39 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -61,6 +61,7 @@ def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.no_use_pep517()) self.cmd_opts.add_option(cmdoptions.check_build_deps()) self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.ignored_constraints()) self.cmd_opts.add_option(cmdoptions.editable()) self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.src()) diff --git a/src/pip/_internal/resolution/resolvelib/provider.py b/src/pip/_internal/resolution/resolvelib/provider.py index 315fb9c8902..203950dc44a 100644 --- a/src/pip/_internal/resolution/resolvelib/provider.py +++ b/src/pip/_internal/resolution/resolvelib/provider.py @@ -84,6 +84,8 @@ class PipProvider(_ProviderBase): :params upgrade_strategy: The user-specified upgrade strategy. :params user_requested: A set of canonicalized package names that the user supplied for pip to install/upgrade. + :params ignored_constraints: A list of canonicalized package names that the + user has asked us to ignore constraints for. """ def __init__( @@ -93,12 +95,14 @@ def __init__( ignore_dependencies: bool, upgrade_strategy: str, user_requested: Dict[str, int], + ignored_constraints: Sequence[str], ) -> None: self._factory = factory self._constraints = constraints self._ignore_dependencies = ignore_dependencies self._upgrade_strategy = upgrade_strategy self._user_requested = user_requested + self._ignored_constraints = ignored_constraints self._known_depths: Dict[str, float] = collections.defaultdict(lambda: math.inf) def identify(self, requirement_or_candidate: Union[Requirement, Candidate]) -> str: @@ -223,11 +227,15 @@ def _eligible_for_upgrade(identifier: str) -> bool: return user_order is not None return False - constraint = _get_with_identifier( - self._constraints, - identifier, - default=Constraint.empty(), - ) + if identifier not in self._ignored_constraints: + constraint = _get_with_identifier( + self._constraints, + identifier, + default=Constraint.empty(), + ) + else: + constraint = Constraint.empty() + return self._factory.find_candidates( identifier=identifier, requirements=requirements, diff --git a/src/pip/_internal/resolution/resolvelib/resolver.py b/src/pip/_internal/resolution/resolvelib/resolver.py index c12beef0b2a..5c2d927b49b 100644 --- a/src/pip/_internal/resolution/resolvelib/resolver.py +++ b/src/pip/_internal/resolution/resolvelib/resolver.py @@ -2,7 +2,7 @@ import functools import logging import os -from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, cast +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Set, Tuple, cast from pip._vendor.packaging.utils import canonicalize_name from pip._vendor.resolvelib import BaseReporter, ResolutionImpossible @@ -50,6 +50,7 @@ def __init__( ignore_requires_python: bool, force_reinstall: bool, upgrade_strategy: str, + ignored_constraints: Sequence[str], py_version_info: Optional[Tuple[int, ...]] = None, ): super().__init__() @@ -68,6 +69,7 @@ def __init__( ) self.ignore_dependencies = ignore_dependencies self.upgrade_strategy = upgrade_strategy + self.ignored_constraints = ignored_constraints self._result: Optional[Result] = None def resolve( @@ -80,6 +82,7 @@ def resolve( ignore_dependencies=self.ignore_dependencies, upgrade_strategy=self.upgrade_strategy, user_requested=collected.user_requested, + ignored_constraints=self.ignored_constraints, ) if "PIP_RESOLVER_DEBUG" in os.environ: reporter: BaseReporter = PipDebuggingReporter() diff --git a/tests/functional/test_new_resolver.py b/tests/functional/test_new_resolver.py index 62e56adb2b5..abb2ea1f172 100644 --- a/tests/functional/test_new_resolver.py +++ b/tests/functional/test_new_resolver.py @@ -681,6 +681,26 @@ def test_new_resolver_constraints( script.assert_not_installed("constraint_only") +def test_new_resolver_constraint_ignored(script: PipTestEnvironment) -> None: + "We can ignore constraints. Useful when hacking on a constrained package." + create_basic_wheel_for_package(script, "pkg", "1.1.dev1") + constraints_file = script.scratch_path / "constraints.txt" + constraints_file.write_text("pkg==1.0") + script.pip( + "install", + "--no-cache-dir", + "--no-index", + "--find-links", + script.scratch_path, + "-c", + constraints_file, + "--ignore-constraint", + "pkg", + "pkg", + ) + script.assert_installed(pkg="1.1.dev1") + + def test_new_resolver_constraint_no_specifier(script: PipTestEnvironment) -> None: "It's allowed (but useless...) for a constraint to have no specifier" create_basic_wheel_for_package(script, "pkg", "1.0") diff --git a/tests/unit/resolution_resolvelib/conftest.py b/tests/unit/resolution_resolvelib/conftest.py index a4ee32444e2..4f137f132b9 100644 --- a/tests/unit/resolution_resolvelib/conftest.py +++ b/tests/unit/resolution_resolvelib/conftest.py @@ -75,4 +75,5 @@ def provider(factory: Factory) -> Iterator[PipProvider]: ignore_dependencies=False, upgrade_strategy="to-satisfy-only", user_requested={}, + ignored_constraints=[], ) diff --git a/tests/unit/resolution_resolvelib/test_provider.py b/tests/unit/resolution_resolvelib/test_provider.py index ab1dc74caa3..44426d0b4de 100644 --- a/tests/unit/resolution_resolvelib/test_provider.py +++ b/tests/unit/resolution_resolvelib/test_provider.py @@ -36,6 +36,7 @@ def test_provider_known_depths(factory: Factory) -> None: ignore_dependencies=False, upgrade_strategy="to-satisfy-only", user_requested={root_requirement_name: 0}, + ignored_constraints=[], ) root_requirement_information = build_requirement_information( diff --git a/tests/unit/resolution_resolvelib/test_resolver.py b/tests/unit/resolution_resolvelib/test_resolver.py index 87c2b5f3533..c308239b2a3 100644 --- a/tests/unit/resolution_resolvelib/test_resolver.py +++ b/tests/unit/resolution_resolvelib/test_resolver.py @@ -29,6 +29,7 @@ def resolver(preparer: RequirementPreparer, finder: PackageFinder) -> Resolver: ignore_requires_python=False, force_reinstall=False, upgrade_strategy="to-satisfy-only", + ignored_constraints=[], ) return resolver