Skip to content

Commit 65b0d36

Browse files
authored
Merge pull request #1984 from chludwig-haufe/fix/issue-1977_incompatible-constraint-with-extra
Make BacktrackingResolver ignore extras when dropping existing constraints
2 parents 1974580 + 069382a commit 65b0d36

File tree

6 files changed

+196
-3
lines changed

6 files changed

+196
-3
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ repos:
3434
- pip==20.3.4
3535
- build==1.0.0
3636
- pyproject_hooks==1.0.0
37+
- pytest==7.4.2
3738
- repo: https://github.com/PyCQA/bandit
3839
rev: 1.7.5
3940
hooks:

piptools/resolver.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -648,7 +648,8 @@ def _do_resolve(
648648

649649
# Collect all incompatible install requirement names
650650
cause_ireq_names = {
651-
key_from_req(cause.requirement) for cause in cause_exc.causes
651+
strip_extras(key_from_req(cause.requirement))
652+
for cause in cause_exc.causes
652653
}
653654

654655
# Looks like resolution is impossible, try to fix

piptools/utils.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from click.utils import LazyFile
2424
from pip._internal.req import InstallRequirement
2525
from pip._internal.req.constructors import install_req_from_line, parse_req_from_line
26+
from pip._internal.resolution.resolvelib.base import Requirement as PipRequirement
2627
from pip._internal.utils.misc import redact_auth_from_url
2728
from pip._internal.vcs import is_url
2829
from pip._vendor.packaging.markers import Marker
@@ -72,8 +73,20 @@ def key_from_ireq(ireq: InstallRequirement) -> str:
7273
return key_from_req(ireq.req)
7374

7475

75-
def key_from_req(req: InstallRequirement | Requirement) -> str:
76-
"""Get an all-lowercase version of the requirement's name."""
76+
def key_from_req(req: InstallRequirement | Requirement | PipRequirement) -> str:
77+
"""
78+
Get an all-lowercase version of the requirement's name.
79+
80+
**Note:** If the argument is an instance of
81+
``pip._internal.resolution.resolvelib.base.Requirement`` (like
82+
``pip._internal.resolution.resolvelib.requirements.SpecifierRequirement``),
83+
then the name might include an extras specification.
84+
Apply :py:func:`strip_extras` to the result of this function if you need
85+
the package name only.
86+
87+
:param req: the requirement the key is computed for
88+
:return: the canonical name of the requirement
89+
"""
7790
return str(canonicalize_name(req.name))
7891

7992

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ exclude = "^tests/test_data/"
9595
[[tool.mypy.overrides]]
9696
module = ["tests.*"]
9797
disallow_untyped_defs = false
98+
disallow_incomplete_defs = false
9899

99100
[tool.pytest.ini_options]
100101
addopts = [

tests/test_cli_compile.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2779,6 +2779,139 @@ def test_cli_compile_strip_extras(runner, make_package, make_sdist, tmpdir):
27792779
assert "[more]" not in out.stderr
27802780

27812781

2782+
@pytest.mark.parametrize(
2783+
("package_specs", "constraints", "existing_reqs", "expected_reqs"),
2784+
(
2785+
(
2786+
[
2787+
{
2788+
"name": "test_package_1",
2789+
"version": "1.1",
2790+
"install_requires": ["test_package_2 ~= 1.1"],
2791+
},
2792+
{
2793+
"name": "test_package_2",
2794+
"version": "1.1",
2795+
"extras_require": {"more": "test_package_3"},
2796+
},
2797+
],
2798+
"""
2799+
test_package_1 == 1.1
2800+
""",
2801+
"""
2802+
test_package_1 == 1.0
2803+
test_package_2 == 1.0
2804+
""",
2805+
"""
2806+
test-package-1==1.1
2807+
test-package-2==1.1
2808+
""",
2809+
),
2810+
(
2811+
[
2812+
{
2813+
"name": "test_package_1",
2814+
"version": "1.1",
2815+
"install_requires": ["test_package_2[more] ~= 1.1"],
2816+
},
2817+
{
2818+
"name": "test_package_2",
2819+
"version": "1.1",
2820+
"extras_require": {"more": "test_package_3"},
2821+
},
2822+
{
2823+
"name": "test_package_3",
2824+
"version": "0.1",
2825+
},
2826+
],
2827+
"""
2828+
test_package_1 == 1.1
2829+
""",
2830+
"""
2831+
test_package_1 == 1.0
2832+
test_package_2 == 1.0
2833+
test_package_3 == 0.1
2834+
""",
2835+
"""
2836+
test-package-1==1.1
2837+
test-package-2==1.1
2838+
test-package-3==0.1
2839+
""",
2840+
),
2841+
(
2842+
[
2843+
{
2844+
"name": "test_package_1",
2845+
"version": "1.1",
2846+
"install_requires": ["test_package_2[more] ~= 1.1"],
2847+
},
2848+
{
2849+
"name": "test_package_2",
2850+
"version": "1.1",
2851+
"extras_require": {"more": "test_package_3"},
2852+
},
2853+
{
2854+
"name": "test_package_3",
2855+
"version": "0.1",
2856+
},
2857+
],
2858+
"""
2859+
test_package_1 == 1.1
2860+
""",
2861+
"""
2862+
test_package_1 == 1.0
2863+
test_package_2[more] == 1.0
2864+
test_package_3 == 0.1
2865+
""",
2866+
"""
2867+
test-package-1==1.1
2868+
test-package-2==1.1
2869+
test-package-3==0.1
2870+
""",
2871+
),
2872+
),
2873+
ids=("no-extra", "extra-stripped-from-existing", "with-extra-in-existing"),
2874+
)
2875+
def test_resolver_drops_existing_conflicting_constraint(
2876+
runner,
2877+
make_package,
2878+
make_sdist,
2879+
tmpdir,
2880+
package_specs,
2881+
constraints,
2882+
existing_reqs,
2883+
expected_reqs,
2884+
) -> None:
2885+
"""
2886+
Test that the resolver will find a solution even if some of the existing
2887+
(indirect) requirements are incompatible with the new constraints.
2888+
2889+
This must succeed even if the conflicting requirement includes some extra,
2890+
no matter whether the extra is mentioned in the existing requirements
2891+
or not (cf. `issue #1977 <https://github.com/jazzband/pip-tools/issues/1977>`_).
2892+
"""
2893+
expected_requirements = {line.strip() for line in expected_reqs.splitlines()}
2894+
dists_dir = tmpdir / "dists"
2895+
2896+
packages = [make_package(**spec) for spec in package_specs]
2897+
for pkg in packages:
2898+
make_sdist(pkg, dists_dir)
2899+
2900+
with open("requirements.txt", "w") as existing_reqs_out:
2901+
existing_reqs_out.write(dedent(existing_reqs))
2902+
2903+
with open("requirements.in", "w") as constraints_out:
2904+
constraints_out.write(dedent(constraints))
2905+
2906+
out = runner.invoke(cli, ["--strip-extras", "--find-links", str(dists_dir)])
2907+
2908+
assert out.exit_code == 0, out
2909+
2910+
with open("requirements.txt") as req_txt:
2911+
req_txt_content = req_txt.read()
2912+
assert expected_requirements.issubset(req_txt_content.splitlines())
2913+
2914+
27822915
def test_resolution_failure(runner):
27832916
"""Test resolution impossible for unknown package."""
27842917
with open("requirements.in", "w") as reqs_out:

tests/test_utils.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
import sys
88
from pathlib import Path
99
from textwrap import dedent
10+
from typing import Callable
1011

1112
import pip
1213
import pytest
1314
from click import BadOptionUsage, Context, FileError
15+
from pip._internal.req import InstallRequirement
16+
from pip._internal.resolution.resolvelib.requirements import SpecifierRequirement
1417
from pip._vendor.packaging.version import Version
1518

1619
from piptools.scripts.compile import cli as compile_cli
@@ -29,6 +32,7 @@
2932
is_pinned_requirement,
3033
is_url_requirement,
3134
key_from_ireq,
35+
key_from_req,
3236
lookup_table,
3337
lookup_table_from_tuples,
3438
override_defaults_from_config_file,
@@ -285,6 +289,46 @@ def test_key_from_ireq_normalization(from_line):
285289
assert len(keys) == 1
286290

287291

292+
@pytest.mark.parametrize(
293+
("line", "expected"),
294+
(
295+
("build", "build"),
296+
("cachecontrol[filecache]", "cachecontrol"),
297+
("some-package[a-b,c_d]", "some-package"),
298+
("other_package[a.b]", "other-package"),
299+
),
300+
)
301+
def test_key_from_req_on_install_requirement(
302+
from_line: Callable[[str], InstallRequirement],
303+
line: str,
304+
expected: str,
305+
) -> None:
306+
ireq = from_line(line)
307+
result = key_from_req(ireq)
308+
309+
assert result == expected
310+
311+
312+
@pytest.mark.parametrize(
313+
("line", "expected"),
314+
(
315+
("build", "build"),
316+
("cachecontrol[filecache]", "cachecontrol[filecache]"),
317+
("some-package[a-b,c_d]", "some-package[a-b,c-d]"),
318+
("other_package[a.b]", "other-package[a-b]"),
319+
),
320+
)
321+
def test_key_from_req_on_specifier_requirement(
322+
from_line: Callable[[str], InstallRequirement],
323+
line: str,
324+
expected: str,
325+
) -> None:
326+
req = SpecifierRequirement(from_line(line))
327+
result = key_from_req(req)
328+
329+
assert result == expected
330+
331+
288332
@pytest.mark.parametrize(
289333
("line", "expected"),
290334
(

0 commit comments

Comments
 (0)