Skip to content

Commit 2185778

Browse files
committed
Implement PEP 685 extra normalization in resolver
All extras from user input or dependant package metadata are properly normalized for comparison and resolution. This ensures requests for extras from a dependant can always correctly find the normalized extra in the dependency, even if the requested extra name is not normalized. Note that this still relies on the declaration of extra names in the dependency's package metadata to be properly normalized when the package is built, since correct comparison between an extra name's normalized and non-normalized forms requires change to the metadata parsing logic, which is only available in packaging 22.0 and up, which pip does not use at the moment.
1 parent d9ec9e3 commit 2185778

File tree

6 files changed

+42
-13
lines changed

6 files changed

+42
-13
lines changed

news/11649.bugfix.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Normalize extras according to :pep:`685` from package metadata in the resolver
2+
for comparison. This ensures extras are correctly compared and merged as long
3+
as the package providing the extra(s) is built with values normalized according
4+
to the standard. Note, however, that this *does not* solve cases where the
5+
package itself contains unnormalized extra values in the metadata.

src/pip/_internal/resolution/resolvelib/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
CandidateVersion = Union[LegacyVersion, Version]
1313

1414

15-
def format_name(project: str, extras: FrozenSet[str]) -> str:
15+
def format_name(project: NormalizedName, extras: FrozenSet[NormalizedName]) -> str:
1616
if not extras:
1717
return project
1818
canonical_extras = sorted(canonicalize_name(e) for e in extras)

src/pip/_internal/resolution/resolvelib/candidates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ class ExtrasCandidate(Candidate):
423423
def __init__(
424424
self,
425425
base: BaseCandidate,
426-
extras: FrozenSet[str],
426+
extras: FrozenSet[NormalizedName],
427427
) -> None:
428428
self.base = base
429429
self.extras = extras

src/pip/_internal/resolution/resolvelib/factory.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def __init__(
112112
self._editable_candidate_cache: Cache[EditableCandidate] = {}
113113
self._installed_candidate_cache: Dict[str, AlreadyInstalledCandidate] = {}
114114
self._extras_candidate_cache: Dict[
115-
Tuple[int, FrozenSet[str]], ExtrasCandidate
115+
Tuple[int, FrozenSet[NormalizedName]], ExtrasCandidate
116116
] = {}
117117

118118
if not ignore_installed:
@@ -138,7 +138,9 @@ def _fail_if_link_is_unsupported_wheel(self, link: Link) -> None:
138138
raise UnsupportedWheel(msg)
139139

140140
def _make_extras_candidate(
141-
self, base: BaseCandidate, extras: FrozenSet[str]
141+
self,
142+
base: BaseCandidate,
143+
extras: FrozenSet[NormalizedName],
142144
) -> ExtrasCandidate:
143145
cache_key = (id(base), extras)
144146
try:
@@ -151,7 +153,7 @@ def _make_extras_candidate(
151153
def _make_candidate_from_dist(
152154
self,
153155
dist: BaseDistribution,
154-
extras: FrozenSet[str],
156+
extras: FrozenSet[NormalizedName],
155157
template: InstallRequirement,
156158
) -> Candidate:
157159
try:
@@ -166,7 +168,7 @@ def _make_candidate_from_dist(
166168
def _make_candidate_from_link(
167169
self,
168170
link: Link,
169-
extras: FrozenSet[str],
171+
extras: FrozenSet[NormalizedName],
170172
template: InstallRequirement,
171173
name: Optional[NormalizedName],
172174
version: Optional[CandidateVersion],
@@ -244,12 +246,12 @@ def _iter_found_candidates(
244246
assert template.req, "Candidates found on index must be PEP 508"
245247
name = canonicalize_name(template.req.name)
246248

247-
extras: FrozenSet[str] = frozenset()
249+
extras: FrozenSet[NormalizedName] = frozenset()
248250
for ireq in ireqs:
249251
assert ireq.req, "Candidates found on index must be PEP 508"
250252
specifier &= ireq.req.specifier
251253
hashes &= ireq.hashes(trust_internet=False)
252-
extras |= frozenset(ireq.extras)
254+
extras |= frozenset(canonicalize_name(e) for e in ireq.extras)
253255

254256
def _get_installed_candidate() -> Optional[Candidate]:
255257
"""Get the candidate for the currently-installed version."""
@@ -325,7 +327,7 @@ def is_pinned(specifier: SpecifierSet) -> bool:
325327
def _iter_explicit_candidates_from_base(
326328
self,
327329
base_requirements: Iterable[Requirement],
328-
extras: FrozenSet[str],
330+
extras: FrozenSet[NormalizedName],
329331
) -> Iterator[Candidate]:
330332
"""Produce explicit candidates from the base given an extra-ed package.
331333
@@ -392,7 +394,7 @@ def find_candidates(
392394
explicit_candidates.update(
393395
self._iter_explicit_candidates_from_base(
394396
requirements.get(parsed_requirement.name, ()),
395-
frozenset(parsed_requirement.extras),
397+
frozenset(canonicalize_name(e) for e in parsed_requirement.extras),
396398
),
397399
)
398400

@@ -452,7 +454,7 @@ def _make_requirement_from_install_req(
452454
self._fail_if_link_is_unsupported_wheel(ireq.link)
453455
cand = self._make_candidate_from_link(
454456
ireq.link,
455-
extras=frozenset(ireq.extras),
457+
extras=frozenset(canonicalize_name(e) for e in ireq.extras),
456458
template=ireq,
457459
name=canonicalize_name(ireq.name) if ireq.name else None,
458460
version=None,

src/pip/_internal/resolution/resolvelib/requirements.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class SpecifierRequirement(Requirement):
4343
def __init__(self, ireq: InstallRequirement) -> None:
4444
assert ireq.link is None, "This is a link, not a specifier"
4545
self._ireq = ireq
46-
self._extras = frozenset(ireq.extras)
46+
self._extras = frozenset(canonicalize_name(e) for e in ireq.extras)
4747

4848
def __str__(self) -> str:
4949
return str(self._ireq.req)

tests/functional/test_install_extras.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44

55
import pytest
66

7-
from tests.lib import PipTestEnvironment, ResolverVariant, TestData
7+
from tests.lib import (
8+
PipTestEnvironment,
9+
ResolverVariant,
10+
TestData,
11+
create_basic_wheel_for_package,
12+
)
813

914

1015
@pytest.mark.network
@@ -223,3 +228,20 @@ def test_install_extra_merging(
223228
if not fails_on_legacy or resolver_variant == "2020-resolver":
224229
expected = f"Successfully installed pkga-0.1 simple-{simple_version}"
225230
assert expected in result.stdout
231+
232+
233+
def test_install_extras(script: PipTestEnvironment) -> None:
234+
create_basic_wheel_for_package(script, "a", "1", depends=["b", "dep[x-y]"])
235+
create_basic_wheel_for_package(script, "b", "1", depends=["dep[x_y]"])
236+
create_basic_wheel_for_package(script, "dep", "1", extras={"x-y": ["meh"]})
237+
create_basic_wheel_for_package(script, "meh", "1")
238+
239+
script.pip(
240+
"install",
241+
"--no-cache-dir",
242+
"--no-index",
243+
"--find-links",
244+
script.scratch_path,
245+
"a",
246+
)
247+
script.assert_installed(a="1", b="1", dep="1", meh="1")

0 commit comments

Comments
 (0)