Skip to content

Commit 0827d76

Browse files
authored
Merge pull request #12002 from uranusjr/extra-normalization
2 parents 8c24fd2 + 9ba2bb9 commit 0827d76

File tree

12 files changed

+175
-59
lines changed

12 files changed

+175
-59
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/metadata/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from .base import BaseDistribution, BaseEnvironment, FilesystemWheel, MemoryWheel, Wheel
1010

1111
if TYPE_CHECKING:
12-
from typing import Protocol
12+
from typing import Literal, Protocol
1313
else:
1414
Protocol = object
1515

@@ -50,6 +50,7 @@ def _should_use_importlib_metadata() -> bool:
5050

5151

5252
class Backend(Protocol):
53+
NAME: 'Literal["importlib", "pkg_resources"]'
5354
Distribution: Type[BaseDistribution]
5455
Environment: Type[BaseEnvironment]
5556

src/pip/_internal/metadata/base.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from pip._vendor.packaging.requirements import Requirement
2626
from pip._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
27-
from pip._vendor.packaging.utils import NormalizedName
27+
from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
2828
from pip._vendor.packaging.version import LegacyVersion, Version
2929

3030
from pip._internal.exceptions import NoneMetadataError
@@ -37,7 +37,6 @@
3737
from pip._internal.utils.compat import stdlib_pkgs # TODO: Move definition here.
3838
from pip._internal.utils.egg_link import egg_link_path_from_sys_path
3939
from pip._internal.utils.misc import is_local, normalize_path
40-
from pip._internal.utils.packaging import safe_extra
4140
from pip._internal.utils.urls import url_to_path
4241

4342
from ._json import msg_to_json
@@ -460,6 +459,19 @@ def iter_provided_extras(self) -> Iterable[str]:
460459
461460
For modern .dist-info distributions, this is the collection of
462461
"Provides-Extra:" entries in distribution metadata.
462+
463+
The return value of this function is not particularly useful other than
464+
display purposes due to backward compatibility issues and the extra
465+
names being poorly normalized prior to PEP 685. If you want to perform
466+
logic operations on extras, use :func:`is_extra_provided` instead.
467+
"""
468+
raise NotImplementedError()
469+
470+
def is_extra_provided(self, extra: str) -> bool:
471+
"""Check whether an extra is provided by this distribution.
472+
473+
This is needed mostly for compatibility issues with pkg_resources not
474+
following the extra normalization rules defined in PEP 685.
463475
"""
464476
raise NotImplementedError()
465477

@@ -537,10 +549,11 @@ def _iter_egg_info_extras(self) -> Iterable[str]:
537549
"""Get extras from the egg-info directory."""
538550
known_extras = {""}
539551
for entry in self._iter_requires_txt_entries():
540-
if entry.extra in known_extras:
552+
extra = canonicalize_name(entry.extra)
553+
if extra in known_extras:
541554
continue
542-
known_extras.add(entry.extra)
543-
yield entry.extra
555+
known_extras.add(extra)
556+
yield extra
544557

545558
def _iter_egg_info_dependencies(self) -> Iterable[str]:
546559
"""Get distribution dependencies from the egg-info directory.
@@ -556,10 +569,11 @@ def _iter_egg_info_dependencies(self) -> Iterable[str]:
556569
all currently available PEP 517 backends, although not standardized.
557570
"""
558571
for entry in self._iter_requires_txt_entries():
559-
if entry.extra and entry.marker:
560-
marker = f'({entry.marker}) and extra == "{safe_extra(entry.extra)}"'
561-
elif entry.extra:
562-
marker = f'extra == "{safe_extra(entry.extra)}"'
572+
extra = canonicalize_name(entry.extra)
573+
if extra and entry.marker:
574+
marker = f'({entry.marker}) and extra == "{extra}"'
575+
elif extra:
576+
marker = f'extra == "{extra}"'
563577
elif entry.marker:
564578
marker = entry.marker
565579
else:
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from ._dists import Distribution
22
from ._envs import Environment
33

4-
__all__ = ["Distribution", "Environment"]
4+
__all__ = ["NAME", "Distribution", "Environment"]
5+
6+
NAME = "importlib"

src/pip/_internal/metadata/importlib/_dists.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
Wheel,
2828
)
2929
from pip._internal.utils.misc import normalize_path
30-
from pip._internal.utils.packaging import safe_extra
3130
from pip._internal.utils.temp_dir import TempDirectory
3231
from pip._internal.utils.wheel import parse_wheel, read_wheel_metadata_file
3332

@@ -208,12 +207,16 @@ def _metadata_impl(self) -> email.message.Message:
208207
return cast(email.message.Message, self._dist.metadata)
209208

210209
def iter_provided_extras(self) -> Iterable[str]:
211-
return (
212-
safe_extra(extra) for extra in self.metadata.get_all("Provides-Extra", [])
210+
return self.metadata.get_all("Provides-Extra", [])
211+
212+
def is_extra_provided(self, extra: str) -> bool:
213+
return any(
214+
canonicalize_name(provided_extra) == canonicalize_name(extra)
215+
for provided_extra in self.metadata.get_all("Provides-Extra", [])
213216
)
214217

215218
def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requirement]:
216-
contexts: Sequence[Dict[str, str]] = [{"extra": safe_extra(e)} for e in extras]
219+
contexts: Sequence[Dict[str, str]] = [{"extra": e} for e in extras]
217220
for req_string in self.metadata.get_all("Requires-Dist", []):
218221
req = Requirement(req_string)
219222
if not req.marker:

src/pip/_internal/metadata/pkg_resources.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@
2424
Wheel,
2525
)
2626

27+
__all__ = ["NAME", "Distribution", "Environment"]
28+
2729
logger = logging.getLogger(__name__)
2830

31+
NAME = "pkg_resources"
32+
2933

3034
class EntryPoint(NamedTuple):
3135
name: str
@@ -212,12 +216,16 @@ def _metadata_impl(self) -> email.message.Message:
212216

213217
def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requirement]:
214218
if extras: # pkg_resources raises on invalid extras, so we sanitize.
215-
extras = frozenset(extras).intersection(self._dist.extras)
219+
extras = frozenset(pkg_resources.safe_extra(e) for e in extras)
220+
extras = extras.intersection(self._dist.extras)
216221
return self._dist.requires(extras)
217222

218223
def iter_provided_extras(self) -> Iterable[str]:
219224
return self._dist.extras
220225

226+
def is_extra_provided(self, extra: str) -> bool:
227+
return pkg_resources.safe_extra(extra) in self._dist.extras
228+
221229

222230
class Environment(BaseEnvironment):
223231
def __init__(self, ws: pkg_resources.WorkingSet) -> None:

src/pip/_internal/req/req_install.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def __init__(
128128
if extras:
129129
self.extras = extras
130130
elif req:
131-
self.extras = {safe_extra(extra) for extra in req.extras}
131+
self.extras = req.extras
132132
else:
133133
self.extras = set()
134134
if markers is None and req:
@@ -272,7 +272,12 @@ def match_markers(self, extras_requested: Optional[Iterable[str]] = None) -> boo
272272
extras_requested = ("",)
273273
if self.markers is not None:
274274
return any(
275-
self.markers.evaluate({"extra": extra}) for extra in extras_requested
275+
self.markers.evaluate({"extra": extra})
276+
# TODO: Remove these two variants when packaging is upgraded to
277+
# support the marker comparison logic specified in PEP 685.
278+
or self.markers.evaluate({"extra": safe_extra(extra)})
279+
or self.markers.evaluate({"extra": canonicalize_name(extra)})
280+
for extra in extras_requested
276281
)
277282
else:
278283
return True

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import FrozenSet, Iterable, Optional, Tuple, Union
22

33
from pip._vendor.packaging.specifiers import SpecifierSet
4-
from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
4+
from pip._vendor.packaging.utils import NormalizedName
55
from pip._vendor.packaging.version import LegacyVersion, Version
66

77
from pip._internal.models.link import Link, links_equivalent
@@ -12,11 +12,11 @@
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
18-
canonical_extras = sorted(canonicalize_name(e) for e in extras)
19-
return "{}[{}]".format(project, ",".join(canonical_extras))
18+
extras_expr = ",".join(sorted(extras))
19+
return f"{project}[{extras_expr}]"
2020

2121

2222
class Constraint:

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

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,15 @@ def __init__(
429429
extras: FrozenSet[str],
430430
) -> None:
431431
self.base = base
432-
self.extras = extras
432+
self.extras = frozenset(canonicalize_name(e) for e in extras)
433+
# If any extras are requested in their non-normalized forms, keep track
434+
# of their raw values. This is needed when we look up dependencies
435+
# since PEP 685 has not been implemented for marker-matching, and using
436+
# the non-normalized extra for lookup ensures the user can select a
437+
# non-normalized extra in a package with its non-normalized form.
438+
# TODO: Remove this attribute when packaging is upgraded to support the
439+
# marker comparison logic specified in PEP 685.
440+
self._unnormalized_extras = extras.difference(self.extras)
433441

434442
def __str__(self) -> str:
435443
name, rest = str(self.base).split(" ", 1)
@@ -480,6 +488,50 @@ def is_editable(self) -> bool:
480488
def source_link(self) -> Optional[Link]:
481489
return self.base.source_link
482490

491+
def _warn_invalid_extras(
492+
self,
493+
requested: FrozenSet[str],
494+
valid: FrozenSet[str],
495+
) -> None:
496+
"""Emit warnings for invalid extras being requested.
497+
498+
This emits a warning for each requested extra that is not in the
499+
candidate's ``Provides-Extra`` list.
500+
"""
501+
invalid_extras_to_warn = frozenset(
502+
extra
503+
for extra in requested
504+
if extra not in valid
505+
# If an extra is requested in an unnormalized form, skip warning
506+
# about the normalized form being missing.
507+
and extra in self.extras
508+
)
509+
if not invalid_extras_to_warn:
510+
return
511+
for extra in sorted(invalid_extras_to_warn):
512+
logger.warning(
513+
"%s %s does not provide the extra '%s'",
514+
self.base.name,
515+
self.version,
516+
extra,
517+
)
518+
519+
def _calculate_valid_requested_extras(self) -> FrozenSet[str]:
520+
"""Get a list of valid extras requested by this candidate.
521+
522+
The user (or upstream dependant) may have specified extras that the
523+
candidate doesn't support. Any unsupported extras are dropped, and each
524+
cause a warning to be logged here.
525+
"""
526+
requested_extras = self.extras.union(self._unnormalized_extras)
527+
valid_extras = frozenset(
528+
extra
529+
for extra in requested_extras
530+
if self.base.dist.is_extra_provided(extra)
531+
)
532+
self._warn_invalid_extras(requested_extras, valid_extras)
533+
return valid_extras
534+
483535
def iter_dependencies(self, with_requires: bool) -> Iterable[Optional[Requirement]]:
484536
factory = self.base._factory
485537

@@ -489,18 +541,7 @@ def iter_dependencies(self, with_requires: bool) -> Iterable[Optional[Requiremen
489541
if not with_requires:
490542
return
491543

492-
# The user may have specified extras that the candidate doesn't
493-
# support. We ignore any unsupported extras here.
494-
valid_extras = self.extras.intersection(self.base.dist.iter_provided_extras())
495-
invalid_extras = self.extras.difference(self.base.dist.iter_provided_extras())
496-
for extra in sorted(invalid_extras):
497-
logger.warning(
498-
"%s %s does not provide the extra '%s'",
499-
self.base.name,
500-
self.version,
501-
extra,
502-
)
503-
544+
valid_extras = self._calculate_valid_requested_extras()
504545
for r in self.base.dist.iter_dependencies(valid_extras):
505546
requirement = factory.make_requirement_from_spec(
506547
str(r), self.base._ireq, valid_extras

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

Lines changed: 5 additions & 3 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,9 +138,11 @@ 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[str],
142144
) -> ExtrasCandidate:
143-
cache_key = (id(base), extras)
145+
cache_key = (id(base), frozenset(canonicalize_name(e) for e in extras))
144146
try:
145147
candidate = self._extras_candidate_cache[cache_key]
146148
except KeyError:

0 commit comments

Comments
 (0)