Skip to content

Commit

Permalink
Support selective ignoring of package dependencies
Browse files Browse the repository at this point in the history
  • Loading branch information
skieffer committed Jul 3, 2022
1 parent 1e05d00 commit cb61a38
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 1 deletion.
11 changes: 10 additions & 1 deletion piptools/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ def __init__(
prereleases: Optional[bool] = False,
clear_caches: bool = False,
allow_unsafe: bool = False,
cuts: Optional[Set[str]] = None,
) -> None:
"""
This class resolves a given set of constraints (a collection of
Expand All @@ -220,6 +221,7 @@ def __init__(
self.clear_caches = clear_caches
self.allow_unsafe = allow_unsafe
self.unsafe_constraints: Set[InstallRequirement] = set()
self.cuts = cuts or set()

options = self.repository.options
if "legacy-resolver" not in options.deprecated_features_enabled:
Expand Down Expand Up @@ -353,7 +355,14 @@ def _resolve_one_round(self) -> Tuple[bool, Set[InstallRequirement]]:
their_constraints: List[InstallRequirement] = []
with log.indentation():
for best_match in best_matches:
their_constraints.extend(self._iter_dependencies(best_match))
for dep in self._iter_dependencies(best_match):
# Must iterate even if we're going to cut all dependencies,
# so our dependency cache gets populated.
pair = f"{best_match.name}:{dep.name}"
if best_match.name in self.cuts or pair in self.cuts:
log.debug(f"Cutting dependency {pair}")
else:
their_constraints.append(dep)
# Grouping constraints to make clean diff between rounds
theirs = set(self._group_constraints(their_constraints))

Expand Down
9 changes: 9 additions & 0 deletions piptools/scripts/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,13 @@ def _get_default_option(option_name: str) -> Any:
default=True,
help="Add options to generated file",
)
@click.option(
"--cut-deps",
multiple=True,
help="Ignore a package's dependencies. May be used more than once. "
"Pass just the package name to ignore all of its dependencies. "
"Pass pkg-name:dep-name to ignore just one dependency.",
)
def cli(
ctx: click.Context,
verbose: int,
Expand Down Expand Up @@ -279,6 +286,7 @@ def cli(
resolver_name: str,
emit_index_url: bool,
emit_options: bool,
cut_deps: Tuple[str, ...],
) -> None:
"""Compiles requirements.txt from requirements.in specs."""
log.verbosity = verbose - quiet
Expand Down Expand Up @@ -483,6 +491,7 @@ def cli(
cache=DependencyCache(cache_dir),
clear_caches=rebuild,
allow_unsafe=allow_unsafe,
cuts=set(cut_deps),
)
results = resolver.resolve(max_rounds=max_rounds)
hashes = resolver.resolve_hashes(results) if generate_hashes else None
Expand Down
75 changes: 75 additions & 0 deletions tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,81 @@ def test_resolver__allows_unsafe_deps(
assert output == {str(line) for line in expected}


@pytest.mark.parametrize(
(
"input",
"cuts",
"expected",
),
(
# No cuts, get all recursive dependencies.
(
["flask==0.10.1"],
set(),
{
'markupsafe==0.23 (from jinja2==2.7.3->flask==0.10.1)',
'itsdangerous==0.24 (from flask==0.10.1)',
'werkzeug==0.10.4 (from flask==0.10.1)',
'flask==0.10.1',
'jinja2==2.7.3 (from flask==0.10.1)',
},
),
# Cut all of flask's dependencies. Get only flask.
(
["flask==0.10.1"],
{"flask"},
{
'flask==0.10.1',
},
),
# Cut flask's dependency on Jinja2. Get the remaining dependencies.
(
["flask==0.10.1"],
{"flask:Jinja2"},
{
'itsdangerous==0.24 (from flask==0.10.1)',
'werkzeug==0.10.4 (from flask==0.10.1)',
'flask==0.10.1',
},
),
# Again cut flask's dependency on Jinja2, but now also install another
# package that depends on Jinja2. Now we do get Jinja2, among others.
(
["flask==0.10.1", "ipython[notebook]==2.1.0"],
{"flask:Jinja2"},
{
'jinja2==2.7.3 (from ipython[notebook]==2.1.0)',
'tornado==3.2.2 (from ipython[notebook]==2.1.0)',
'itsdangerous==0.24 (from flask==0.10.1)',
'markupsafe==0.23 (from jinja2==2.7.3->ipython[notebook]==2.1.0)',
'pyzmq==2.1.12 (from ipython[notebook]==2.1.0)',
'ipython[notebook]==2.1.0',
'gnureadline==6.3.3 (from ipython[notebook]==2.1.0)',
'flask==0.10.1',
'werkzeug==0.10.4 (from flask==0.10.1)',
},
),
),
)
def test_resolver__cut_deps(
resolver,
from_line,
input,
cuts,
expected,
):
input = [line if isinstance(line, tuple) else (line, False) for line in input]
input = [from_line(req[0], constraint=req[1]) for req in input]
resolver = resolver(
input,
cuts=cuts,
)
output = resolver.resolve()
output = {str(line) for line in output}

assert output == expected


def test_resolver__max_number_rounds_reached(resolver, from_line):
"""
Resolver should raise an exception if max round has been reached.
Expand Down

0 comments on commit cb61a38

Please sign in to comment.