Skip to content

Commit

Permalink
Allow ignoring sub-dependencies
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
leorochael committed Jun 24, 2024
1 parent 00c75c4 commit 1a51f6b
Show file tree
Hide file tree
Showing 10 changed files with 176 additions and 11 deletions.
24 changes: 24 additions & 0 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
2 changes: 2 additions & 0 deletions src/pip/_internal/req/req_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion src/pip/_internal/resolution/legacy/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion src/pip/_internal/resolution/resolvelib/provider.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import collections
import math
from collections.abc import Container
from functools import lru_cache
from typing import (
TYPE_CHECKING,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/pip/_internal/resolution/resolvelib/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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()
Expand Down
39 changes: 39 additions & 0 deletions tests/functional/test_new_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/test_req_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)


Expand Down Expand Up @@ -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:
Expand Down
78 changes: 69 additions & 9 deletions tests/unit/test_resolution_legacy_resolver.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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]
Expand All @@ -59,14 +68,15 @@ 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

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,
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand Down

0 comments on commit 1a51f6b

Please sign in to comment.