From 1a51f6bab96bf9b4ea83f669bdd0803ae035fd43 Mon Sep 17 00:00:00 2001 From: Leonardo Rochael Almida Date: Mon, 24 Jun 2024 18:05:25 +0200 Subject: [PATCH] Allow ignoring sub-dependencies Implement `--no-deps-for=pkg`` to allow ignoring sub-dependencies of specific packages, as opposed to the global `--no-deps` flag. The flag is accepted on the command line and in requirements files. Implemented in both the new and the legacy resolvers. --- src/pip/_internal/cli/cmdoptions.py | 24 ++++++ src/pip/_internal/cli/req_command.py | 2 + src/pip/_internal/commands/install.py | 1 + src/pip/_internal/req/req_file.py | 2 + .../_internal/resolution/legacy/resolver.py | 9 ++- .../resolution/resolvelib/provider.py | 8 +- .../resolution/resolvelib/resolver.py | 4 + tests/functional/test_new_resolver.py | 39 ++++++++++ tests/unit/test_req_file.py | 20 +++++ tests/unit/test_resolution_legacy_resolver.py | 78 ++++++++++++++++--- 10 files changed, 176 insertions(+), 11 deletions(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index a47f8a3f46a..f9d792e24c9 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -529,6 +529,30 @@ def only_binary() -> Option: ) +def _handle_no_deps_for( + option: object, + opt_str: str, + value: str, + parser: OptionParser, +) -> None: + # ignore_dependencies_for is a set of strings + values = value.split(",") + parser.values.ignore_dependencies_for.update(values) + + +def no_deps_for() -> PipOption: + return PipOption( + "--no-deps-for", + dest="ignore_dependencies_for", + action="callback", + callback=_handle_no_deps_for, + type="package_name", + default=set(), + help="Do not install sub dependencies of the named package or packages. " + "Accepts comma separated list of values. Can be supplied multiple times.", + ) + + platforms: Callable[..., Option] = partial( Option, "--platform", diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 92900f94ff4..c155c9787af 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -186,6 +186,7 @@ def make_resolver( force_reinstall=force_reinstall, upgrade_strategy=upgrade_strategy, py_version_info=py_version_info, + ignore_dependencies_for=options.ignore_dependencies_for, ) import pip._internal.resolution.legacy.resolver @@ -201,6 +202,7 @@ def make_resolver( force_reinstall=force_reinstall, upgrade_strategy=upgrade_strategy, py_version_info=py_version_info, + ignore_dependencies_for=options.ignore_dependencies_for, ) def get_requirements( diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index d5b06c8c785..40449bcb0f0 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -233,6 +233,7 @@ def add_options(self) -> None: ) self.cmd_opts.add_option(cmdoptions.no_binary()) self.cmd_opts.add_option(cmdoptions.only_binary()) + self.cmd_opts.add_option(cmdoptions.no_deps_for()) self.cmd_opts.add_option(cmdoptions.prefer_binary()) self.cmd_opts.add_option(cmdoptions.require_hashes()) self.cmd_opts.add_option(cmdoptions.progress_bar()) diff --git a/src/pip/_internal/req/req_file.py b/src/pip/_internal/req/req_file.py index 53ad8674cd8..acfd6d2f360 100644 --- a/src/pip/_internal/req/req_file.py +++ b/src/pip/_internal/req/req_file.py @@ -56,6 +56,7 @@ cmdoptions.find_links, cmdoptions.no_binary, cmdoptions.only_binary, + cmdoptions.no_deps_for, cmdoptions.prefer_binary, cmdoptions.require_hashes, cmdoptions.pre, @@ -225,6 +226,7 @@ def handle_option_line( options.features_enabled.extend( f for f in opts.features_enabled if f not in options.features_enabled ) + options.ignore_dependencies_for.update(opts.ignore_dependencies_for) # set finder options if finder: diff --git a/src/pip/_internal/resolution/legacy/resolver.py b/src/pip/_internal/resolution/legacy/resolver.py index 1dd0d7041bb..f1e4dc1e3b6 100644 --- a/src/pip/_internal/resolution/legacy/resolver.py +++ b/src/pip/_internal/resolution/legacy/resolver.py @@ -13,6 +13,7 @@ import logging import sys from collections import defaultdict +from collections.abc import Container from itertools import chain from typing import DefaultDict, Iterable, List, Optional, Set, Tuple @@ -126,6 +127,7 @@ def __init__( force_reinstall: bool, upgrade_strategy: str, py_version_info: Optional[Tuple[int, ...]] = None, + ignore_dependencies_for: Container[str] = frozenset(), ) -> None: super().__init__() assert upgrade_strategy in self._allowed_strategies @@ -136,6 +138,7 @@ def __init__( py_version_info = normalize_version_info(py_version_info) self._py_version_info = py_version_info + self.ignore_dependencies_for = ignore_dependencies_for self.preparer = preparer self.finder = finder @@ -542,7 +545,11 @@ def add_req(subreq: Requirement, extras_requested: Iterable[str]) -> None: requirement_set, req_to_install, parent_req_name=None ) - if not self.ignore_dependencies: + ignore_dependencies = ( + self.ignore_dependencies + or dist.canonical_name in self.ignore_dependencies_for + ) + if not ignore_dependencies: if req_to_install.extras: logger.debug( "Installing extra requirements: %r", diff --git a/src/pip/_internal/resolution/resolvelib/provider.py b/src/pip/_internal/resolution/resolvelib/provider.py index fb0dd85f112..7b7150452ee 100644 --- a/src/pip/_internal/resolution/resolvelib/provider.py +++ b/src/pip/_internal/resolution/resolvelib/provider.py @@ -1,5 +1,6 @@ import collections import math +from collections.abc import Container from functools import lru_cache from typing import ( TYPE_CHECKING, @@ -94,10 +95,12 @@ def __init__( ignore_dependencies: bool, upgrade_strategy: str, user_requested: Dict[str, int], + ignore_dependencies_for: Container[str] = frozenset(), ) -> None: self._factory = factory self._constraints = constraints self._ignore_dependencies = ignore_dependencies + self._ignore_dependencies_for = ignore_dependencies_for self._upgrade_strategy = upgrade_strategy self._user_requested = user_requested self._known_depths: Dict[str, float] = collections.defaultdict(lambda: math.inf) @@ -243,7 +246,10 @@ def is_satisfied_by(self, requirement: Requirement, candidate: Candidate) -> boo return requirement.is_satisfied_by(candidate) def get_dependencies(self, candidate: Candidate) -> Sequence[Requirement]: - with_requires = not self._ignore_dependencies + ignore_dependencies = ( + self._ignore_dependencies or candidate.name in self._ignore_dependencies_for + ) + with_requires = not ignore_dependencies return [r for r in candidate.iter_dependencies(with_requires) if r is not None] @staticmethod diff --git a/src/pip/_internal/resolution/resolvelib/resolver.py b/src/pip/_internal/resolution/resolvelib/resolver.py index c12beef0b2a..fc87554ecb0 100644 --- a/src/pip/_internal/resolution/resolvelib/resolver.py +++ b/src/pip/_internal/resolution/resolvelib/resolver.py @@ -2,6 +2,7 @@ import functools import logging import os +from collections.abc import Container from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, cast from pip._vendor.packaging.utils import canonicalize_name @@ -51,6 +52,7 @@ def __init__( force_reinstall: bool, upgrade_strategy: str, py_version_info: Optional[Tuple[int, ...]] = None, + ignore_dependencies_for: Container[str] = frozenset(), ): super().__init__() assert upgrade_strategy in self._allowed_strategies @@ -67,6 +69,7 @@ def __init__( py_version_info=py_version_info, ) self.ignore_dependencies = ignore_dependencies + self.ignore_dependencies_for = ignore_dependencies_for self.upgrade_strategy = upgrade_strategy self._result: Optional[Result] = None @@ -80,6 +83,7 @@ def resolve( ignore_dependencies=self.ignore_dependencies, upgrade_strategy=self.upgrade_strategy, user_requested=collected.user_requested, + ignore_dependencies_for=self.ignore_dependencies_for, ) 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 774311a38e8..3001a21847e 100644 --- a/tests/functional/test_new_resolver.py +++ b/tests/functional/test_new_resolver.py @@ -217,6 +217,45 @@ def test_new_resolver_ignore_dependencies(script: PipTestEnvironment) -> None: script.assert_not_installed("dep") +def test_new_resolver_ignore_dependencies_for(script: PipTestEnvironment) -> None: + create_basic_wheel_for_package( + script, + "base", + "0.1.0", + depends=["dep"], + ) + create_basic_wheel_for_package( + script, + "dep", + "0.1.0", + ) + create_basic_wheel_for_package( + script, + "base2", + "0.1.0", + depends=["dep2"], + ) + create_basic_wheel_for_package( + script, + "dep2", + "0.1.0", + ) + result = script.pip( + "install", + "--no-cache-dir", + "--no-index", + "--no-deps-for=base", + "--find-links", + script.scratch_path, + "base", + "base2", + allow_stderr_error=True, + ) + script.assert_installed(base="0.1.0", base2="0.1.0", dep2="0.1.0") + script.assert_not_installed("dep") + assert "base 0.1.0 requires dep, which is not installed." in result.stderr + + @pytest.mark.parametrize( "root_dep", [ diff --git a/tests/unit/test_req_file.py b/tests/unit/test_req_file.py index f4f98b1901c..dd83bdf2378 100644 --- a/tests/unit/test_req_file.py +++ b/tests/unit/test_req_file.py @@ -47,6 +47,7 @@ def options(session: PipSession) -> mock.Mock: index_url="default_url", format_control=FormatControl(set(), set()), features_enabled=[], + ignore_dependencies_for=set(), ) @@ -763,6 +764,25 @@ def test_join_lines(self, tmpdir: Path, finder: PackageFinder) -> None: assert finder.index_urls == ["url1", "url2"] + def test_ignore_dependencies_for(self, tmpdir: Path, options: mock.Mock) -> None: + req = tmpdir / "req1.txt" + req.write_text( + """ + --no-deps-for=foo,bar + --no-deps-for=spam,eggs + """ + ) + + list( + parse_reqfile( + req, + session=PipSession(), + options=options, + ) + ) + + assert options.ignore_dependencies_for == {"foo", "bar", "spam", "eggs"} + def test_req_file_parse_no_only_binary( self, data: TestData, finder: PackageFinder ) -> None: diff --git a/tests/unit/test_resolution_legacy_resolver.py b/tests/unit/test_resolution_legacy_resolver.py index b2f93b3d4f5..594375a0fc7 100644 --- a/tests/unit/test_resolution_legacy_resolver.py +++ b/tests/unit/test_resolution_legacy_resolver.py @@ -1,7 +1,7 @@ import email.message import logging import os -from typing import List, Optional, Type, TypeVar, cast +from typing import Any, List, Type, TypeVar, cast from unittest import mock import pytest @@ -14,6 +14,7 @@ UnsupportedPythonVersion, ) from pip._internal.metadata import BaseDistribution +from pip._internal.metadata.importlib import Distribution from pip._internal.models.candidate import InstallationCandidate from pip._internal.req.constructors import install_req_from_line from pip._internal.req.req_set import RequirementSet @@ -27,9 +28,9 @@ T = TypeVar("T") -class FakeDist(BaseDistribution): +class FakeDist(Distribution): def __init__(self, metadata: email.message.Message) -> None: - self._canonical_name = cast(NormalizedName, "my-project") + self._canonical_name = cast(NormalizedName, metadata["Name"]) self._metadata = metadata def __str__(self) -> str: @@ -45,12 +46,20 @@ def metadata(self) -> email.message.Message: def make_fake_dist( - *, klass: Type[BaseDistribution] = FakeDist, requires_python: Optional[str] = None + *, + klass: Type[BaseDistribution] = FakeDist, + **metadata_kw: str | list[str], ) -> BaseDistribution: + metadata_kw.setdefault("name", "my-project") + metadata = email.message.Message() - metadata["Name"] = "my-project" - if requires_python is not None: - metadata["Requires-Python"] = requires_python + for name, values in metadata_kw.items(): + if isinstance(values, str): + values = [values] + # 'requires_python' -> 'Requires-Python' + name = name.replace("_", "-").title() + for v in values: + metadata.add_header(name, v) # Too many arguments for "BaseDistribution" return klass(metadata) # type: ignore[call-arg] @@ -59,6 +68,7 @@ def make_fake_dist( def make_test_resolver( monkeypatch: pytest.MonkeyPatch, mock_candidates: List[InstallationCandidate], + **resolver_kw: Any, ) -> Resolver: def _find_candidates(project_name: str) -> List[InstallationCandidate]: return mock_candidates @@ -66,7 +76,7 @@ def _find_candidates(project_name: str) -> List[InstallationCandidate]: finder = make_test_finder() monkeypatch.setattr(finder, "find_all_candidates", _find_candidates) - return Resolver( + defaults = dict( # noqa: C408 finder=finder, preparer=mock.Mock(), # Not used. make_install_req=install_req_from_line, @@ -78,6 +88,9 @@ def _find_candidates(project_name: str) -> List[InstallationCandidate]: ignore_requires_python=False, upgrade_strategy="to-satisfy-only", ) + defaults.update(resolver_kw) + + return Resolver(**defaults) # type: ignore[arg-type] class TestAddRequirement: @@ -252,7 +265,7 @@ class NotWorkingFakeDist(FakeDist): def metadata(self) -> email.message.Message: raise FileNotFoundError(metadata_name) - dist = make_fake_dist(klass=NotWorkingFakeDist) # type: ignore + dist = make_fake_dist(klass=NotWorkingFakeDist) with pytest.raises(NoneMetadataError) as exc: _check_dist_requires_python( @@ -266,6 +279,53 @@ def metadata(self) -> email.message.Message: ) +class TestResolution: + """ + Test resolution of dependencies. + """ + + @pytest.mark.parametrize( + "resolver_kw, expected", + [ + pytest.param({}, ["dep1", "dep2"], id="with_deps"), + pytest.param({"ignore_dependencies": True}, [], id="no_deps"), + pytest.param( + {"ignore_dependencies_for": {"my-project"}}, [], id="no_deps_for" + ), + pytest.param( + {"ignore_dependencies_for": {"another-project"}}, + ["dep1", "dep2"], + id="no_deps_for_another", + ), + ], + ) + def test_resolve_deps( + self, + monkeypatch: pytest.MonkeyPatch, + resolver_kw: dict[str, Any], + expected: list[str], + ) -> None: + # GIVEN + preparer = mock.Mock() + preparer.prepare_linked_requirement.return_value = make_fake_dist( + requires_dist=["dep1", "dep2"], + ) + requirement_set = RequirementSet(check_supported_wheels=True) + req = install_req_from_line("my-project", user_supplied=True) + + # WHEN + resolver = make_test_resolver( + monkeypatch, + [make_mock_candidate("1.0")], + preparer=preparer, + **resolver_kw, + ) + reqs = resolver._resolve_one(requirement_set, req) + + # THEN + assert [req.name for req in reqs] == expected + + class TestYankedWarning: """ Test _populate_link() emits warning if one or more candidates are yanked.