Skip to content

Commit

Permalink
Implement PEP 685 on distribution objects directly
Browse files Browse the repository at this point in the history
This uses normalised names across the board for extras, with
comparisions outside this context relying on `packaging`'s support for
the corresponding comparisions.
  • Loading branch information
pradyunsg committed Oct 6, 2023
1 parent 88d8775 commit 7db6cb3
Show file tree
Hide file tree
Showing 5 changed files with 36 additions and 74 deletions.
17 changes: 4 additions & 13 deletions src/pip/_internal/metadata/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,24 +452,15 @@ def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requiremen
"""
raise NotImplementedError()

def iter_provided_extras(self) -> Iterable[str]:
def iter_provided_extras(self) -> Iterable[NormalizedName]:
"""Extras provided by this distribution.
For modern .dist-info distributions, this is the collection of
"Provides-Extra:" entries in distribution metadata.
The return value of this function is not particularly useful other than
display purposes due to backward compatibility issues and the extra
names being poorly normalized prior to PEP 685. If you want to perform
logic operations on extras, use :func:`is_extra_provided` instead.
"""
raise NotImplementedError()

def is_extra_provided(self, extra: str) -> bool:
"""Check whether an extra is provided by this distribution.
This is needed mostly for compatibility issues with pkg_resources not
following the extra normalization rules defined in PEP 685.
The return value of this function is expected to be normalised names,
per PEP 685, with the returned value being handled appropriately by
`iter_dependencies`.
"""
raise NotImplementedError()

Expand Down
13 changes: 5 additions & 8 deletions src/pip/_internal/metadata/importlib/_dists.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,14 +206,11 @@ def _metadata_impl(self) -> email.message.Message:
# until upstream can improve the protocol. (python/cpython#94952)
return cast(email.message.Message, self._dist.metadata)

def iter_provided_extras(self) -> Iterable[str]:
return self.metadata.get_all("Provides-Extra", [])

def is_extra_provided(self, extra: str) -> bool:
return any(
canonicalize_name(provided_extra) == canonicalize_name(extra)
for provided_extra in self.metadata.get_all("Provides-Extra", [])
)
def iter_provided_extras(self) -> Iterable[NormalizedName]:
return [
canonicalize_name(extra)
for extra in self.metadata.get_all("Provides-Extra", [])
]

def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requirement]:
contexts: Sequence[Dict[str, str]] = [{"extra": e} for e in extras]
Expand Down
20 changes: 13 additions & 7 deletions src/pip/_internal/metadata/pkg_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@
import logging
import os
import zipfile
from typing import Collection, Iterable, Iterator, List, Mapping, NamedTuple, Optional
from typing import (
Collection,
Iterable,
Iterator,
List,
Mapping,
NamedTuple,
Optional,
cast,
)

from pip._vendor import pkg_resources
from pip._vendor.packaging.requirements import Requirement
Expand Down Expand Up @@ -83,7 +92,7 @@ def __init__(self, dist: pkg_resources.Distribution) -> None:
def _extra_mapping(self) -> Mapping[NormalizedName, str]:
if self.__extra_mapping is None:
self.__extra_mapping = {
canonicalize_name(extra): pkg_resources.safe_extra(extra)
canonicalize_name(extra): pkg_resources.safe_extra(cast(str, extra))
for extra in self.metadata.get_all("Provides-Extra", [])
}

Expand Down Expand Up @@ -235,11 +244,8 @@ def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requiremen
extras = [self._extra_mapping[extra] for extra in relevant_extras]
return self._dist.requires(extras)

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

def is_extra_provided(self, extra: str) -> bool:
return canonicalize_name(extra) in self._extra_mapping
def iter_provided_extras(self) -> Iterable[NormalizedName]:
return list(self._extra_mapping.keys())


class Environment(BaseEnvironment):
Expand Down
57 changes: 12 additions & 45 deletions src/pip/_internal/resolution/resolvelib/candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,50 +491,6 @@ def is_editable(self) -> bool:
def source_link(self) -> Optional[Link]:
return self.base.source_link

def _warn_invalid_extras(
self,
requested: FrozenSet[str],
valid: FrozenSet[str],
) -> None:
"""Emit warnings for invalid extras being requested.
This emits a warning for each requested extra that is not in the
candidate's ``Provides-Extra`` list.
"""
invalid_extras_to_warn = frozenset(
extra
for extra in requested
if extra not in valid
# If an extra is requested in an unnormalized form, skip warning
# about the normalized form being missing.
and extra in self.extras
)
if not invalid_extras_to_warn:
return
for extra in sorted(invalid_extras_to_warn):
logger.warning(
"%s %s does not provide the extra '%s'",
self.base.name,
self.version,
extra,
)

def _calculate_valid_requested_extras(self) -> FrozenSet[str]:
"""Get a list of valid extras requested by this candidate.
The user (or upstream dependant) may have specified extras that the
candidate doesn't support. Any unsupported extras are dropped, and each
cause a warning to be logged here.
"""
requested_extras = self.extras
valid_extras = frozenset(
extra
for extra in requested_extras
if self.base.dist.is_extra_provided(extra)
)
self._warn_invalid_extras(requested_extras, valid_extras)
return valid_extras

def iter_dependencies(self, with_requires: bool) -> Iterable[Optional[Requirement]]:
factory = self.base._factory

Expand All @@ -544,7 +500,18 @@ def iter_dependencies(self, with_requires: bool) -> Iterable[Optional[Requiremen
if not with_requires:
return

valid_extras = self._calculate_valid_requested_extras()
# The user may have specified extras that the candidate doesn't
# support. We ignore any unsupported extras here.
valid_extras = self.extras.intersection(self.base.dist.iter_provided_extras())
invalid_extras = self.extras.difference(self.base.dist.iter_provided_extras())
for extra in sorted(invalid_extras):
logger.warning(
"%s %s does not provide the extra '%s'",
self.base.name,
self.version,
extra,
)

for r in self.base.dist.iter_dependencies(valid_extras):
yield from factory.make_requirements_from_spec(
str(r),
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/metadata/test_metadata_pkg_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pytest
from pip._vendor.packaging.requirements import Requirement
from pip._vendor.packaging.specifiers import SpecifierSet
from pip._vendor.packaging.utils import canonicalize_name
from pip._vendor.packaging.version import parse as parse_version

from pip._internal.exceptions import UnsupportedWheel
Expand Down Expand Up @@ -107,7 +108,7 @@ def test_wheel_metadata_works() -> None:

assert name == dist.canonical_name == dist.raw_name
assert parse_version(version) == dist.version
assert set(extras) == set(dist.iter_provided_extras())
assert {canonicalize_name(e) for e in extras} == set(dist.iter_provided_extras())
assert [require_a] == [str(r) for r in dist.iter_dependencies()]
assert [Requirement(require_a), Requirement(require_b)] == [
Requirement(str(r)) for r in dist.iter_dependencies(["also_b"])
Expand Down

0 comments on commit 7db6cb3

Please sign in to comment.