Skip to content

[PEP 771] #13170

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pip/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import List, Optional

__version__ = "25.0.dev0"
__version__ = "25.0.dev0+pep-771"


def main(args: Optional[List[str]] = None) -> int:
Expand Down
1 change: 1 addition & 0 deletions src/pip/_internal/metadata/_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
("Requires-Python", False),
("Requires-External", True),
("Project-URL", True),
("Default-Extra", True),
("Provides-Extra", True),
("Provides-Dist", True),
("Obsoletes-Dist", True),
Expand Down
15 changes: 15 additions & 0 deletions src/pip/_internal/metadata/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,13 +442,28 @@ def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requiremen

For modern .dist-info distributions, this is the collection of
"Requires-Dist:" entries in distribution metadata.

In case, no "Extra" is specified, will use "Default-Extra" as specified
per PEP 771.
"""
raise NotImplementedError()

def iter_raw_dependencies(self) -> Iterable[str]:
"""Raw Requires-Dist metadata."""
return self.metadata.get_all("Requires-Dist", [])

def iter_default_extras(self) -> Iterable[NormalizedName]:
"""Extras provided by this distribution.

For modern .dist-info distributions, this is the collection of
"Default-Extra:" entries in distribution metadata.

The return value of this function is expected to be normalised names,
per PEP 771, with the returned value being handled appropriately by
`iter_dependencies`.
"""
raise NotImplementedError()

def iter_provided_extras(self) -> Iterable[NormalizedName]:
"""Extras provided by this distribution.

Expand Down
9 changes: 9 additions & 0 deletions src/pip/_internal/metadata/importlib/_dists.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,22 @@ 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_default_extras(self) -> Iterable[NormalizedName]:
return [
canonicalize_name(extra)
for extra in self.metadata.get_all("Default-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]:
if not extras:
extras = list(self.iter_default_extras())

contexts: Sequence[Dict[str, str]] = [{"extra": e} for e in extras]
for req_string in self.metadata.get_all("Requires-Dist", []):
# strip() because email.message.Message.get_all() may return a leading \n
Expand Down
6 changes: 6 additions & 0 deletions src/pip/_internal/metadata/pkg_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,13 +239,19 @@ def _metadata_impl(self) -> email.message.Message:
return feed_parser.close()

def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requirement]:
extras = extras or self._dist.default_extras_require

if extras:
relevant_extras = set(self._extra_mapping) & set(
map(canonicalize_name, extras)
)
extras = [self._extra_mapping[extra] for extra in relevant_extras]

return self._dist.requires(extras)

def iter_default_extras(self) -> Iterable[NormalizedName]:
return self._dist.default_extras_require or []

def iter_provided_extras(self) -> Iterable[NormalizedName]:
return self._extra_mapping.keys()

Expand Down
6 changes: 6 additions & 0 deletions src/pip/_internal/operations/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,12 @@ def _prepare_linked_requirement(
self.build_isolation,
self.check_build_deps,
)

# Setting up the default-extra if necessary
default_extras = frozenset(dist.metadata.get_all("Default-Extra", []))
req.extras = req.extras or default_extras
req.req.extras = req.extras or default_extras

return dist

def save_linked_requirement(self, req: InstallRequirement) -> None:
Expand Down
9 changes: 8 additions & 1 deletion src/pip/_internal/req/constructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@
from pip._internal.utils.urls import path_to_url
from pip._internal.vcs import is_url, vcs

EXPLICIT_EMPTY_EXTRAS = 'explicit-no-default-extras'

__all__ = [
"install_req_from_editable",
"install_req_from_line",
"parse_editable",
"EXPLICIT_EMPTY_EXTRAS"
]

logger = logging.getLogger(__name__)
Expand All @@ -48,7 +51,11 @@ def _strip_extras(path: str) -> Tuple[str, Optional[str]]:
path_no_extras = m.group(1)
extras = m.group(2)
else:
path_no_extras = path
if '[]' in path:
extras = f'[{EXPLICIT_EMPTY_EXTRAS}]'
path_no_extras = path.replace('[]', '')
else:
path_no_extras = path

return path_no_extras, extras

Expand Down
14 changes: 8 additions & 6 deletions src/pip/_internal/resolution/legacy/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
InstallRequirement,
check_invalid_constraint_type,
)
from pip._internal.req.constructors import EXPLICIT_EMPTY_EXTRAS
from pip._internal.req.req_set import RequirementSet
from pip._internal.resolution.base import BaseResolver, InstallRequirementProvider
from pip._internal.utils import compatibility_tags
Expand Down Expand Up @@ -552,12 +553,13 @@ def add_req(subreq: Requirement, extras_requested: Iterable[str]) -> None:
set(req_to_install.extras) - set(dist.iter_provided_extras())
)
for missing in missing_requested:
logger.warning(
"%s %s does not provide the extra '%s'",
dist.raw_name,
dist.version,
missing,
)
if missing != EXPLICIT_EMPTY_EXTRAS:
logger.warning(
"%s %s does not provide the extra '%s'",
dist.raw_name,
dist.version,
missing,
)

available_requested = sorted(
set(dist.iter_provided_extras()) & set(req_to_install.extras)
Expand Down
16 changes: 10 additions & 6 deletions src/pip/_internal/resolution/resolvelib/candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
install_req_from_line,
)
from pip._internal.req.req_install import InstallRequirement
from pip._internal.req.constructors import EXPLICIT_EMPTY_EXTRAS
from pip._internal.utils.direct_url_helpers import direct_url_from_link
from pip._internal.utils.misc import normalize_version_info

Expand Down Expand Up @@ -310,6 +311,8 @@ def __init__(
version=version,
)

template.extras = ireq.extras

def _prepare_distribution(self) -> BaseDistribution:
preparer = self._factory.preparer
return preparer.prepare_linked_requirement(self._ireq, parallel_builds=True)
Expand Down Expand Up @@ -513,12 +516,13 @@ def iter_dependencies(self, with_requires: bool) -> Iterable[Optional[Requiremen
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,
)
if extra != EXPLICIT_EMPTY_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(
Expand Down
11 changes: 9 additions & 2 deletions src/pip/_internal/resolution/resolvelib/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ def _make_candidate_from_link(
base: Optional[BaseCandidate] = self._make_base_candidate_from_link(
link, template, name, version
)
extras = extras if extras else template.extras
if not extras or base is None:
return base
return self._make_extras_candidate(base, extras, comes_from=template)
Expand Down Expand Up @@ -482,6 +483,9 @@ def _make_requirements_from_install_req(
(or link) and one with the extra. This allows centralized constraint
handling for the base, resulting in fewer candidate rejections.
"""
if ireq.comes_from is not None and hasattr(ireq.comes_from, "extra"):
requested_extras = requested_extras or ireq.comes_from.extras

if not ireq.match_markers(requested_extras):
logger.info(
"Ignoring %s: markers '%s' don't match your environment",
Expand All @@ -504,6 +508,9 @@ def _make_requirements_from_install_req(
name=canonicalize_name(ireq.name) if ireq.name else None,
version=None,
)

extras = ireq.extras or list(cand.dist.iter_default_extras())

if cand is None:
# There's no way we can satisfy a URL requirement if the underlying
# candidate fails to build. An unnamed URL must be user-supplied, so
Expand All @@ -517,10 +524,10 @@ def _make_requirements_from_install_req(
else:
# require the base from the link
yield self.make_requirement_from_candidate(cand)
if ireq.extras:
if extras:
# require the extras on top of the base candidate
yield self.make_requirement_from_candidate(
self._make_extras_candidate(cand, frozenset(ireq.extras))
self._make_extras_candidate(cand, frozenset(extras))
)

def collect_root_requirements(
Expand Down
4 changes: 3 additions & 1 deletion src/pip/_internal/resolution/resolvelib/requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pip._vendor.packaging.specifiers import SpecifierSet
from pip._vendor.packaging.utils import NormalizedName, canonicalize_name

from pip._internal.req.constructors import install_req_drop_extras
from pip._internal.req.constructors import install_req_drop_extras, EXPLICIT_EMPTY_EXTRAS
from pip._internal.req.req_install import InstallRequirement

from .base import Candidate, CandidateLookup, Requirement, format_name
Expand Down Expand Up @@ -128,6 +128,8 @@ class SpecifierWithoutExtrasRequirement(SpecifierRequirement):
def __init__(self, ireq: InstallRequirement) -> None:
assert ireq.link is None, "This is a link, not a specifier"
self._ireq = install_req_drop_extras(ireq)
if ireq.extras:
self._ireq.extras = {EXPLICIT_EMPTY_EXTRAS}
self._equal_cache: Optional[str] = None
self._hash: Optional[int] = None
self._extras = frozenset(canonicalize_name(e) for e in self._ireq.extras)
Expand Down
33 changes: 24 additions & 9 deletions src/pip/_vendor/distlib/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
#
"""Implementation of the Metadata for Python packages PEPs.

Supports all metadata formats (1.0, 1.1, 1.2, 1.3/2.1 and 2.2).
Supports all metadata formats (1.0, 1.1, 1.2, 1.3/2.1, 2.2, 2.3, 2.4 and 2.5).
"""
from __future__ import unicode_literals

Expand Down Expand Up @@ -89,13 +89,20 @@ class MetadataInvalidError(DistlibException):

_643_FIELDS = _566_FIELDS + _643_MARKERS

# PEP 771
_771_MARKERS = ('Default-Extra')

_771_FIELDS = _643_FIELDS + _771_MARKERS


_ALL_FIELDS = set()
_ALL_FIELDS.update(_241_FIELDS)
_ALL_FIELDS.update(_314_FIELDS)
_ALL_FIELDS.update(_345_FIELDS)
_ALL_FIELDS.update(_426_FIELDS)
_ALL_FIELDS.update(_566_FIELDS)
_ALL_FIELDS.update(_643_FIELDS)
_ALL_FIELDS.update(_771_FIELDS)

EXTRA_RE = re.compile(r'''extra\s*==\s*("([^"]+)"|'([^']+)')''')

Expand All @@ -115,6 +122,8 @@ def _version2fieldlist(version):
# return _426_FIELDS
elif version == '2.2':
return _643_FIELDS
elif version == '2.5':
return _771_FIELDS
raise MetadataUnrecognizedVersionError(version)


Expand All @@ -125,7 +134,7 @@ def _has_marker(keys, markers):
return any(marker in keys for marker in markers)

keys = [key for key, value in fields.items() if value not in ([], 'UNKNOWN', None)]
possible_versions = ['1.0', '1.1', '1.2', '1.3', '2.1', '2.2'] # 2.0 removed
possible_versions = ['1.0', '1.1', '1.2', '1.3', '2.1', '2.2', '2.5'] # 2.0 removed

# first let's try to see if a field is not part of one of the version
for key in keys:
Expand All @@ -148,6 +157,9 @@ def _has_marker(keys, markers):
if key not in _643_FIELDS and '2.2' in possible_versions:
possible_versions.remove('2.2')
logger.debug('Removed 2.2 due to %s', key)
if key not in _771_FIELDS and '2.5' in possible_versions:
possible_versions.remove('2.5')
logger.debug('Removed 2.5 due to %s', key)
# if key not in _426_FIELDS and '2.0' in possible_versions:
# possible_versions.remove('2.0')
# logger.debug('Removed 2.0 due to %s', key)
Expand All @@ -165,16 +177,19 @@ def _has_marker(keys, markers):
is_2_1 = '2.1' in possible_versions and _has_marker(keys, _566_MARKERS)
# is_2_0 = '2.0' in possible_versions and _has_marker(keys, _426_MARKERS)
is_2_2 = '2.2' in possible_versions and _has_marker(keys, _643_MARKERS)
if int(is_1_1) + int(is_1_2) + int(is_2_1) + int(is_2_2) > 1:
raise MetadataConflictError('You used incompatible 1.1/1.2/2.1/2.2 fields')
is_2_5 = '2.5' in possible_versions and _has_marker(keys, _771_MARKERS)

if int(is_1_1) + int(is_1_2) + int(is_2_1) + int(is_2_2) + int(is_2_5) > 1:
raise MetadataConflictError('You used incompatible 1.1/1.2/2.1/2.2/2.5 fields')

# we have the choice, 1.0, or 1.2, 2.1 or 2.2
# - 1.0 has a broken Summary field but works with all tools
# - 1.1 is to avoid
# - 1.2 fixes Summary but has little adoption
# - 2.1 adds more features
# - 2.2 is the latest
if not is_1_1 and not is_1_2 and not is_2_1 and not is_2_2:
# - 2.2 adds more features
# - 2.5 is the latest
if not is_1_1 and not is_1_2 and not is_2_1 and not is_2_2 and not is_2_5:
# we couldn't find any specific marker
if PKG_INFO_PREFERRED_VERSION in possible_versions:
return PKG_INFO_PREFERRED_VERSION
Expand All @@ -184,10 +199,10 @@ def _has_marker(keys, markers):
return '1.2'
if is_2_1:
return '2.1'
# if is_2_2:
# return '2.2'
if is_2_2:
return '2.2'

return '2.2'
return '2.5'


# This follows the rules about transforming keys as described in
Expand Down
13 changes: 11 additions & 2 deletions src/pip/_vendor/packaging/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ class RawMetadata(TypedDict, total=False):
license_expression: str
license_files: list[str]

# Metadata 2.5 - PEP 771
default_extra: list[str]


_STRING_FIELDS = {
"author",
Expand Down Expand Up @@ -463,8 +466,8 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:


# Keep the two values in sync.
_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"]
_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"]
_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]
_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]

_REQUIRED_ATTRS = frozenset(["metadata_version", "name", "version"])

Expand Down Expand Up @@ -861,3 +864,9 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata:
"""``Provides`` (deprecated)"""
obsoletes: _Validator[list[str] | None] = _Validator(added="1.1")
"""``Obsoletes`` (deprecated)"""
# PEP 771 lets us define a default `extras_require` if none is passed by the
# user.
default_extra: _Validator[list[utils.NormalizedName] | None] = _Validator(
added="2.5",
)
""":external:ref:`core-metadata-default-extra`"""
6 changes: 6 additions & 0 deletions src/pip/_vendor/pkg_resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3070,6 +3070,8 @@ def requires(self, extras: Iterable[str] = ()):
dm = self._dep_map
deps: list[Requirement] = []
deps.extend(dm.get(None, ()))

extras = extras or self.default_extras_require
for ext in extras:
try:
deps.extend(dm[safe_extra(ext)])
Expand Down Expand Up @@ -3322,6 +3324,10 @@ def clone(self, **kw: str | int | IResourceProvider | None):
def extras(self):
return [dep for dep in self._dep_map if dep]

@property
def default_extras_require(self):
return self._parsed_pkg_info.get_all('Default-Extra') or []


class EggInfoDistribution(Distribution):
def _reload_version(self):
Expand Down
Loading