Voice et bot modif

This commit is contained in:
pi 2026-06-16 17:09:34 +00:00
parent 189d56026b
commit 7333a22bcd
10774 changed files with 634644 additions and 933308 deletions

View file

@ -6,7 +6,7 @@ __title__ = "packaging"
__summary__ = "Core utilities for Python packages"
__uri__ = "https://github.com/pypa/packaging"
__version__ = "26.0"
__version__ = "26.2"
__author__ = "Donald Stufft and individual contributors"
__email__ = "donald@stufft.io"

View file

@ -27,6 +27,34 @@ class Node:
def serialize(self) -> str:
raise NotImplementedError
def __getstate__(self) -> str:
# Return just the value string for compactness and stability.
return self.value
def _restore_value(self, value: object) -> None:
if not isinstance(value, str):
raise TypeError(
f"Cannot restore {self.__class__.__name__} value from {value!r}"
)
self.value = value
def __setstate__(self, state: object) -> None:
if isinstance(state, str):
# New format (26.2+): just the value string.
self._restore_value(state)
return
if isinstance(state, tuple) and len(state) == 2:
# Old format (packaging <= 26.0, __slots__): (None, {slot: value}).
_, slot_dict = state
if isinstance(slot_dict, dict) and "value" in slot_dict:
self._restore_value(slot_dict["value"])
return
if isinstance(state, dict) and "value" in state:
# Old format (packaging <= 25.0, no __slots__): plain __dict__.
self._restore_value(state["value"])
return
raise TypeError(f"Cannot restore {self.__class__.__name__} from {state!r}")
class Variable(Node):
__slots__ = ()

View file

@ -2,68 +2,32 @@
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
import typing
"""Backward-compatibility shim for unpickling Version objects serialized before
packaging 26.1.
Old pickles reference ``packaging._structures.InfinityType`` and
``packaging._structures.NegativeInfinityType``. This module provides minimal
stand-in classes so that ``pickle.loads()`` can resolve those references.
The deserialized objects are not used for comparisons ``Version.__setstate__``
discards the stale ``_key`` cache and recomputes it from the core version fields.
"""
from __future__ import annotations
@typing.final
class InfinityType:
__slots__ = ()
"""Stand-in for the removed ``InfinityType`` used in old comparison keys."""
def __repr__(self) -> str:
return "Infinity"
def __hash__(self) -> int:
return hash(repr(self))
def __lt__(self, other: object) -> bool:
return False
def __le__(self, other: object) -> bool:
return False
def __eq__(self, other: object) -> bool:
return isinstance(other, self.__class__)
def __gt__(self, other: object) -> bool:
return True
def __ge__(self, other: object) -> bool:
return True
def __neg__(self: object) -> "NegativeInfinityType":
return NegativeInfinity
Infinity = InfinityType()
@typing.final
class NegativeInfinityType:
__slots__ = ()
"""Stand-in for the removed ``NegativeInfinityType`` used in old comparison keys."""
def __repr__(self) -> str:
return "-Infinity"
def __hash__(self) -> int:
return hash(repr(self))
def __lt__(self, other: object) -> bool:
return True
def __le__(self, other: object) -> bool:
return True
def __eq__(self, other: object) -> bool:
return isinstance(other, self.__class__)
def __gt__(self, other: object) -> bool:
return False
def __ge__(self, other: object) -> bool:
return False
def __neg__(self: object) -> InfinityType:
return Infinity
Infinity = InfinityType()
NegativeInfinity = NegativeInfinityType()

View file

@ -75,7 +75,7 @@ DEFAULT_RULES: dict[str, re.Pattern[str]] = {
re.VERBOSE,
),
"SPECIFIER": re.compile(
Specifier._operator_regex_str + Specifier._version_regex_str,
Specifier._specifier_regex_str,
re.VERBOSE | re.IGNORECASE,
),
"AT": re.compile(r"\@"),

View file

@ -0,0 +1,302 @@
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from .errors import _ErrorCollector
from .requirements import Requirement
__all__ = [
"CyclicDependencyGroup",
"DependencyGroupInclude",
"DependencyGroupResolver",
"DuplicateGroupNames",
"InvalidDependencyGroupObject",
"resolve_dependency_groups",
]
def __dir__() -> list[str]:
return __all__
# -----------
# Error Types
# -----------
class DuplicateGroupNames(ValueError):
"""
The same dependency groups were defined twice, with different non-normalized names.
"""
class CyclicDependencyGroup(ValueError):
"""
The dependency group includes form a cycle.
"""
def __init__(self, requested_group: str, group: str, include_group: str) -> None:
self.requested_group = requested_group
self.group = group
self.include_group = include_group
if include_group == group:
reason = f"{group} includes itself"
else:
reason = f"{include_group} -> {group}, {group} -> {include_group}"
super().__init__(
"Cyclic dependency group include while resolving "
f"{requested_group}: {reason}"
)
# in the PEP 735 spec, the tables in dependency group lists were described as
# "Dependency Object Specifiers", but the only defined type of object was a
# "Dependency Group Include" -- hence the naming of this error as "Object"
class InvalidDependencyGroupObject(ValueError):
"""
A member of a dependency group was identified as a dict, but was not in a valid
format.
"""
# ------------------------
# Object Model & Interface
# ------------------------
class DependencyGroupInclude:
__slots__ = ("include_group",)
def __init__(self, include_group: str) -> None:
"""
Initialize a DependencyGroupInclude.
:param include_group: The name of the group referred to by this include.
"""
self.include_group = include_group
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.include_group!r})"
class DependencyGroupResolver:
"""
A resolver for Dependency Group data.
This class handles caching, name normalization, cycle detection, and other
parsing requirements. There are only two public methods for exploring the data:
``lookup()`` and ``resolve()``.
:param dependency_groups: A mapping, as provided via pyproject
``[dependency-groups]``.
"""
def __init__(
self,
dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]],
) -> None:
errors = _ErrorCollector()
self.dependency_groups = _normalize_group_names(dependency_groups, errors)
# a map of group names to parsed data
self._parsed_groups: dict[
str, tuple[Requirement | DependencyGroupInclude, ...]
] = {}
# a map of group names to their ancestors, used for cycle detection
self._include_graph_ancestors: dict[str, tuple[str, ...]] = {}
# a cache of completed resolutions to Requirement lists
self._resolve_cache: dict[str, tuple[Requirement, ...]] = {}
errors.finalize("[dependency-groups] data was invalid")
def lookup(self, group: str) -> tuple[Requirement | DependencyGroupInclude, ...]:
"""
Lookup a group name, returning the parsed dependency data for that group.
This will not resolve includes.
:param group: the name of the group to lookup
"""
group = _normalize_name(group)
with _ErrorCollector().on_exit(
f"[dependency-groups] data for {group!r} was malformed"
) as errors:
return self._parse_group(group, errors)
def resolve(self, group: str) -> tuple[Requirement, ...]:
"""
Resolve a dependency group to a list of requirements.
:param group: the name of the group to resolve
"""
group = _normalize_name(group)
with _ErrorCollector().on_exit(
f"[dependency-groups] data for {group!r} was malformed"
) as errors:
return self._resolve(group, group, errors)
def _resolve(
self, group: str, requested_group: str, errors: _ErrorCollector
) -> tuple[Requirement, ...]:
"""
This is a helper for cached resolution to strings. It preserves the name of the
group which the user initially requested in order to present a clearer error in
the event that a cycle is detected.
:param group: The normalized name of the group to resolve.
:param requested_group: The group which was used in the original, user-facing
request.
"""
if group in self._resolve_cache:
return self._resolve_cache[group]
parsed = self._parse_group(group, errors)
resolved_group = []
for item in parsed:
if isinstance(item, Requirement):
resolved_group.append(item)
elif isinstance(item, DependencyGroupInclude):
include_group = _normalize_name(item.include_group)
# if a group is cyclic, record the error
# otherwise, follow the include_group reference
#
# this allows us to examine all includes in a group, even in the
# presence of errors
if include_group in self._include_graph_ancestors.get(group, ()):
errors.error(
CyclicDependencyGroup(
requested_group, group, item.include_group
)
)
else:
self._include_graph_ancestors[include_group] = (
*self._include_graph_ancestors.get(group, ()),
group,
)
resolved_group.extend(
self._resolve(include_group, requested_group, errors)
)
else: # pragma: no cover
raise NotImplementedError(
f"Invalid dependency group item after parse: {item}"
)
# in the event that errors were detected, present the group as empty and do not
# cache the result
# this ensures that repeated access to a cyclic group will raise multiple errors
if errors.errors:
return ()
self._resolve_cache[group] = tuple(resolved_group)
return self._resolve_cache[group]
def _parse_group(
self, group: str, errors: _ErrorCollector
) -> tuple[Requirement | DependencyGroupInclude, ...]:
# short circuit -- never do the work twice
if group in self._parsed_groups:
return self._parsed_groups[group]
if group not in self.dependency_groups:
errors.error(LookupError(f"Dependency group '{group}' not found"))
return ()
raw_group = self.dependency_groups[group]
if isinstance(raw_group, str):
errors.error(
TypeError(
f"Dependency group {group!r} contained a string rather than a list."
)
)
return ()
if not isinstance(raw_group, Sequence):
errors.error(
TypeError(f"Dependency group {group!r} is not a sequence type.")
)
return ()
elements: list[Requirement | DependencyGroupInclude] = []
for item in raw_group:
if isinstance(item, str):
# packaging.requirements.Requirement parsing ensures that this is a
# valid PEP 508 Dependency Specifier
# raises InvalidRequirement on failure
elements.append(Requirement(item))
elif isinstance(item, Mapping):
if tuple(item.keys()) != ("include-group",):
errors.error(
InvalidDependencyGroupObject(
f"Invalid dependency group item: {item!r}"
)
)
else:
include_group = item["include-group"]
elements.append(DependencyGroupInclude(include_group=include_group))
else:
errors.error(TypeError(f"Invalid dependency group item: {item!r}"))
self._parsed_groups[group] = tuple(elements)
return self._parsed_groups[group]
# --------------------
# Functional Interface
# --------------------
def resolve_dependency_groups(
dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], /, *groups: str
) -> tuple[str, ...]:
"""
Resolve a dependency group to a tuple of requirements, as strings.
:param dependency_groups: the parsed contents of the ``[dependency-groups]`` table
from ``pyproject.toml``
:param groups: the name of the group(s) to resolve
"""
resolver = DependencyGroupResolver(dependency_groups)
return tuple(str(r) for group in groups for r in resolver.resolve(group))
# ----------------
# internal helpers
# ----------------
_NORMALIZE_PATTERN = re.compile(r"[-_.]+")
def _normalize_name(name: str) -> str:
return _NORMALIZE_PATTERN.sub("-", name).lower()
def _normalize_group_names(
dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]],
errors: _ErrorCollector,
) -> dict[str, Sequence[str | Mapping[str, str]]]:
original_names: dict[str, list[str]] = {}
normalized_groups: dict[str, Sequence[str | Mapping[str, str]]] = {}
for group_name, value in dependency_groups.items():
normed_group_name = _normalize_name(group_name)
original_names.setdefault(normed_group_name, []).append(group_name)
normalized_groups[normed_group_name] = value
for normed_name, names in original_names.items():
if len(names) > 1:
errors.error(
DuplicateGroupNames(
"Duplicate dependency group names: "
f"{normed_name} ({', '.join(names)})"
)
)
return normalized_groups

View file

@ -0,0 +1,325 @@
from __future__ import annotations
import dataclasses
import re
import urllib.parse
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
if TYPE_CHECKING: # pragma: no cover
import sys
from collections.abc import Collection
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
__all__ = [
"ArchiveInfo",
"DirInfo",
"DirectUrl",
"DirectUrlValidationError",
"VcsInfo",
]
def __dir__() -> list[str]:
return __all__
_T = TypeVar("_T")
class _FromMappingProtocol(Protocol): # pragma: no cover
@classmethod
def _from_dict(cls, d: Mapping[str, Any]) -> Self: ...
_FromMappingProtocolT = TypeVar("_FromMappingProtocolT", bound=_FromMappingProtocol)
def _json_dict_factory(data: list[tuple[str, Any]]) -> dict[str, Any]:
return {key: value for key, value in data if value is not None}
def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None:
"""Get a value from the dictionary and verify it's the expected type."""
if (value := d.get(key)) is None:
return None
if not isinstance(value, expected_type):
raise DirectUrlValidationError(
f"Unexpected type {type(value).__name__} "
f"(expected {expected_type.__name__})",
context=key,
)
return value
def _get_required(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T:
"""Get a required value from the dictionary and verify it's the expected type."""
if (value := _get(d, expected_type, key)) is None:
raise _DirectUrlRequiredKeyError(key)
return value
def _get_object(
d: Mapping[str, Any], target_type: type[_FromMappingProtocolT], key: str
) -> _FromMappingProtocolT | None:
"""Get a dictionary value from the dictionary and convert it to a dataclass."""
if (value := _get(d, Mapping, key)) is None: # type: ignore[type-abstract]
return None
try:
return target_type._from_dict(value)
except Exception as e:
raise DirectUrlValidationError(e, context=key) from e
_PEP610_USER_PASS_ENV_VARS_REGEX = re.compile(
r"^\$\{[A-Za-z0-9-_]+\}(:\$\{[A-Za-z0-9-_]+\})?$"
)
def _strip_auth_from_netloc(netloc: str, safe_user_passwords: Collection[str]) -> str:
if "@" not in netloc:
return netloc
user_pass, netloc_no_user_pass = netloc.split("@", 1)
if user_pass in safe_user_passwords:
return netloc
if _PEP610_USER_PASS_ENV_VARS_REGEX.match(user_pass):
return netloc
return netloc_no_user_pass
def _strip_url(url: str, safe_user_passwords: Collection[str]) -> str:
"""url with user:password part removed unless it is formed with
environment variables as specified in PEP 610, or it is a safe user:password
such as `git`.
"""
parsed_url = urllib.parse.urlsplit(url)
netloc = _strip_auth_from_netloc(parsed_url.netloc, safe_user_passwords)
return urllib.parse.urlunsplit(
(
parsed_url.scheme,
netloc,
parsed_url.path,
parsed_url.query,
parsed_url.fragment,
)
)
class DirectUrlValidationError(Exception):
"""Raised when when input data is not spec-compliant."""
context: str | None = None
message: str
def __init__(
self,
cause: str | Exception,
*,
context: str | None = None,
) -> None:
if isinstance(cause, DirectUrlValidationError):
if cause.context:
self.context = (
f"{context}.{cause.context}" if context else cause.context
)
else:
self.context = context # pragma: no cover
self.message = cause.message
else:
self.context = context
self.message = str(cause)
def __str__(self) -> str:
if self.context:
return f"{self.message} in {self.context!r}"
return self.message
class _DirectUrlRequiredKeyError(DirectUrlValidationError):
def __init__(self, key: str) -> None:
super().__init__("Missing required value", context=key)
@dataclasses.dataclass(frozen=True, init=False)
class VcsInfo:
vcs: str
commit_id: str
requested_revision: str | None = None
def __init__(
self,
*,
vcs: str,
commit_id: str,
requested_revision: str | None = None,
) -> None:
object.__setattr__(self, "vcs", vcs)
object.__setattr__(self, "commit_id", commit_id)
object.__setattr__(self, "requested_revision", requested_revision)
@classmethod
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
# We can't validate vcs value because is not closed.
return cls(
vcs=_get_required(d, str, "vcs"),
requested_revision=_get(d, str, "requested_revision"),
commit_id=_get_required(d, str, "commit_id"),
)
@dataclasses.dataclass(frozen=True, init=False)
class ArchiveInfo:
hashes: Mapping[str, str] | None = None
def __init__(
self,
*,
hashes: Mapping[str, str] | None = None,
) -> None:
object.__setattr__(self, "hashes", hashes)
@classmethod
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
hashes = _get(d, Mapping, "hashes") # type: ignore[type-abstract]
if hashes is not None and not all(isinstance(h, str) for h in hashes.values()):
raise DirectUrlValidationError(
"Hash values must be strings", context="hashes"
)
legacy_hash = _get(d, str, "hash")
if legacy_hash is not None:
if "=" not in legacy_hash:
raise DirectUrlValidationError(
"Invalid hash format (expected '<algorithm>=<hash>')",
context="hash",
)
hash_algorithm, hash_value = legacy_hash.split("=", 1)
if hashes is None:
# if `hashes` are not present, we can derive it from the legacy `hash`
hashes = {hash_algorithm: hash_value}
else:
# if `hashes` are present, the legacy `hash` must match one of them
if hash_algorithm not in hashes:
raise DirectUrlValidationError(
f"Algorithm {hash_algorithm!r} used in hash field "
f"is not present in hashes field",
context="hashes",
)
if hashes[hash_algorithm] != hash_value:
raise DirectUrlValidationError(
f"Algorithm {hash_algorithm!r} used in hash field "
f"has different value in hashes field",
context="hash",
)
return cls(hashes=hashes)
@dataclasses.dataclass(frozen=True, init=False)
class DirInfo:
editable: bool | None = None
def __init__(
self,
*,
editable: bool | None = None,
) -> None:
object.__setattr__(self, "editable", editable)
@classmethod
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
return cls(
editable=_get(d, bool, "editable"),
)
@dataclasses.dataclass(frozen=True, init=False)
class DirectUrl:
"""A class representing a direct URL."""
url: str
archive_info: ArchiveInfo | None = None
vcs_info: VcsInfo | None = None
dir_info: DirInfo | None = None
subdirectory: str | None = None # XXX Path or str?
def __init__(
self,
*,
url: str,
archive_info: ArchiveInfo | None = None,
vcs_info: VcsInfo | None = None,
dir_info: DirInfo | None = None,
subdirectory: str | None = None,
) -> None:
object.__setattr__(self, "url", url)
object.__setattr__(self, "archive_info", archive_info)
object.__setattr__(self, "vcs_info", vcs_info)
object.__setattr__(self, "dir_info", dir_info)
object.__setattr__(self, "subdirectory", subdirectory)
@classmethod
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
direct_url = cls(
url=_get_required(d, str, "url"),
archive_info=_get_object(d, ArchiveInfo, "archive_info"),
vcs_info=_get_object(d, VcsInfo, "vcs_info"),
dir_info=_get_object(d, DirInfo, "dir_info"),
subdirectory=_get(d, str, "subdirectory"),
)
if (
bool(direct_url.vcs_info)
+ bool(direct_url.archive_info)
+ bool(direct_url.dir_info)
) != 1:
raise DirectUrlValidationError(
"Exactly one of vcs_info, archive_info, dir_info must be present"
)
if direct_url.dir_info is not None and not direct_url.url.startswith("file://"):
raise DirectUrlValidationError(
"URL scheme must be file:// when dir_info is present",
context="url",
)
# XXX subdirectory must be relative, can we, should we validate that here?
return direct_url
@classmethod
def from_dict(cls, d: Mapping[str, Any], /) -> Self:
"""Create and validate a DirectUrl instance from a JSON dictionary."""
return cls._from_dict(d)
def to_dict(
self,
*,
generate_legacy_hash: bool = False,
strip_user_password: bool = True,
safe_user_passwords: Collection[str] = ("git",),
) -> Mapping[str, Any]:
"""Convert the DirectUrl instance to a JSON dictionary.
:param generate_legacy_hash: If True, include a legacy `hash` field in
`archive_info` for backward compatibility with tools that don't
support the `hashes` field.
:param strip_user_password: If True, strip user:password from the URL
unless it is formed with environment variables as specified in PEP
610, or it is a safe user:password such as `git`.
:param safe_user_passwords: A collection of user:password strings that
should not be stripped from the URL even if `strip_user_password` is
True.
"""
res = dataclasses.asdict(self, dict_factory=_json_dict_factory)
if generate_legacy_hash and self.archive_info and self.archive_info.hashes:
hash_algorithm, hash_value = next(iter(self.archive_info.hashes.items()))
res["archive_info"]["hash"] = f"{hash_algorithm}={hash_value}"
if strip_user_password:
res["url"] = _strip_url(self.url, safe_user_passwords)
return res
def validate(self) -> None:
"""Validate the DirectUrl instance against the specification.
Raises :class:`DirectUrlValidationError` if invalid.
"""
self.from_dict(self.to_dict())

View file

@ -0,0 +1,94 @@
from __future__ import annotations
import contextlib
import dataclasses
import sys
import typing
__all__ = ["ExceptionGroup"]
def __dir__() -> list[str]:
return __all__
if sys.version_info >= (3, 11): # pragma: no cover
from builtins import ExceptionGroup
else: # pragma: no cover
class ExceptionGroup(Exception):
"""A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11.
If :external:exc:`ExceptionGroup` is already defined by Python itself,
that version is used instead.
"""
message: str
exceptions: list[Exception]
def __init__(self, message: str, exceptions: list[Exception]) -> None:
self.message = message
self.exceptions = exceptions
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})"
@dataclasses.dataclass
class _ErrorCollector:
"""
Collect errors into ExceptionGroups.
Used like this:
collector = _ErrorCollector()
# Add a single exception
collector.error(ValueError("one"))
# Supports nesting, including combining ExceptionGroups
with collector.collect():
raise ValueError("two")
collector.finalize("Found some errors")
Since making a collector and then calling finalize later is a common pattern,
a convenience method ``on_exit`` is provided.
"""
errors: list[Exception] = dataclasses.field(default_factory=list, init=False)
def finalize(self, msg: str) -> None:
"""Raise a group exception if there are any errors."""
if self.errors:
raise ExceptionGroup(msg, self.errors)
@contextlib.contextmanager
def on_exit(self, msg: str) -> typing.Generator[_ErrorCollector, None, None]:
"""
Calls finalize if no uncollected errors were present.
Uncollected errors are raised normally.
"""
yield self
self.finalize(msg)
@contextlib.contextmanager
def collect(self, *err_cls: type[Exception]) -> typing.Generator[None, None, None]:
"""
Context manager to collect errors into the error list.
Must be inside loops, as only one error can be collected at a time.
"""
error_classes = err_cls or (Exception,)
try:
yield
except ExceptionGroup as error:
self.errors.extend(error.exceptions)
except error_classes as error:
self.errors.append(error)
def error(
self,
error: Exception,
) -> None:
"""Add an error to the list."""
self.errors.append(error)

View file

@ -42,14 +42,25 @@ __all__ = [
"canonicalize_license_expression",
]
# Simple __dir__ implementation since there are no public submodules
def __dir__() -> list[str]:
return __all__
license_ref_allowed = re.compile("^[A-Za-z0-9.-]*$")
NormalizedLicenseExpression = NewType("NormalizedLicenseExpression", str)
"""
A :class:`typing.NewType` of :class:`str`, representing a normalized
License-Expression.
"""
class InvalidLicenseExpression(ValueError):
"""Raised when a license-expression string is invalid
>>> from packaging.licenses import canonicalize_license_expression
>>> canonicalize_license_expression("invalid")
Traceback (most recent call last):
...
@ -60,6 +71,34 @@ class InvalidLicenseExpression(ValueError):
def canonicalize_license_expression(
raw_license_expression: str,
) -> NormalizedLicenseExpression:
"""
This function takes a valid License-Expression, and returns the normalized
form of it.
The return type is typed as :class:`NormalizedLicenseExpression`. This
allows type checkers to help require that a string has passed through this
function before use.
:param str raw_license_expression: The License-Expression to canonicalize.
:raises InvalidLicenseExpression: If the License-Expression is invalid due to an
invalid/unknown license identifier or invalid syntax.
.. doctest::
>>> from packaging.licenses import canonicalize_license_expression
>>> canonicalize_license_expression("mit")
'MIT'
>>> canonicalize_license_expression("mit and (apache-2.0 or bsd-2-clause)")
'MIT AND (Apache-2.0 OR BSD-2-Clause)'
>>> canonicalize_license_expression("(mit")
Traceback (most recent call last):
...
InvalidLicenseExpression: Invalid license expression: '(mit'
>>> canonicalize_license_expression("Use-it-after-midnight")
Traceback (most recent call last):
...
InvalidLicenseExpression: Unknown license: 'Use-it-after-midnight'
"""
if not raw_license_expression:
message = f"Invalid license expression: {raw_license_expression!r}"
raise InvalidLicenseExpression(message)

View file

@ -26,8 +26,22 @@ __all__ = [
"default_environment",
]
def __dir__() -> list[str]:
return __all__
Operator = Callable[[str, Union[str, AbstractSet[str]]], bool]
EvaluateContext = Literal["metadata", "lock_file", "requirement"]
"""A ``typing.Literal`` enumerating valid marker evaluation contexts.
Valid values for the ``context`` passed to :meth:`Marker.evaluate` are:
* ``"metadata"`` (for core metadata; default)
* ``"lock_file"`` (for lock files)
* ``"requirement"`` (i.e. all other situations)
"""
MARKERS_ALLOWING_SET = {"extras", "dependency_groups"}
MARKERS_REQUIRING_VERSION = {
"implementation_version",
@ -38,25 +52,32 @@ MARKERS_REQUIRING_VERSION = {
class InvalidMarker(ValueError):
"""
An invalid marker was found, users should refer to PEP 508.
"""Raised when attempting to create a :class:`Marker` from invalid input.
This error indicates that the given marker string does not conform to the
:ref:`specification of dependency specifiers <pypug:dependency-specifiers>`.
"""
class UndefinedComparison(ValueError):
"""
An invalid operation was attempted on a value that doesn't support it.
"""Raised when evaluating an unsupported marker comparison.
This can happen when marker values are compared as versions but do not
conform to the :ref:`specification of version specifiers
<pypug:version-specifiers>`.
"""
class UndefinedEnvironmentName(ValueError):
"""
A name was attempted to be used that does not exist inside of the
environment.
"""
"""Raised when evaluating a marker that references a missing environment key."""
class Environment(TypedDict):
"""
A dictionary that represents a Python environment as captured by
:func:`default_environment`. All fields are required.
"""
implementation_name: str
"""The implementation's identifier, e.g. ``'cpython'``."""
@ -263,7 +284,7 @@ def _evaluate_markers(
return any(all(item) for item in groups)
def format_full_version(info: sys._version_info) -> str:
def _format_full_version(info: sys._version_info) -> str:
version = f"{info.major}.{info.minor}.{info.micro}"
kind = info.releaselevel
if kind != "final":
@ -272,7 +293,11 @@ def format_full_version(info: sys._version_info) -> str:
def default_environment() -> Environment:
iver = format_full_version(sys.implementation.version)
"""Return the default marker environment for the current Python process.
This is the base environment used by :meth:`Marker.evaluate`.
"""
iver = _format_full_version(sys.implementation.version)
implementation_name = sys.implementation.name
return {
"implementation_name": implementation_name,
@ -290,6 +315,27 @@ def default_environment() -> Environment:
class Marker:
"""Represents a parsed dependency marker expression.
Marker expressions are parsed according to the
:ref:`specification of dependency specifiers <pypug:dependency-specifiers>`.
:param marker: The string representation of a marker expression.
:raises InvalidMarker: If ``marker`` cannot be parsed.
Instances are safe to serialize with :mod:`pickle`. They use a stable
format so the same pickle can be loaded in future packaging releases.
.. versionchanged:: 26.2
Added a stable pickle format. Pickles created with packaging 26.2+ can
be unpickled with future releases. Backward compatibility with pickles
from packaging < 26.2 is supported but may be removed in a future
release.
"""
__slots__ = ("_markers",)
def __init__(self, marker: str) -> None:
# Note: We create a Marker object without calling this constructor in
# packaging.requirements.Requirement. If any additional logic is
@ -320,11 +366,21 @@ class Marker:
except ParserSyntaxError as e:
raise InvalidMarker(str(e)) from e
@classmethod
def _from_markers(cls, markers: MarkerList) -> Marker:
"""Create a Marker instance from a pre-parsed marker tree.
This avoids re-parsing serialised marker strings when combining markers.
"""
new = cls.__new__(cls)
new._markers = markers
return new
def __str__(self) -> str:
return _format_marker(self._markers)
def __repr__(self) -> str:
return f"<{self.__class__.__name__}('{self}')>"
return f"<{self.__class__.__name__}({str(self)!r})>"
def __hash__(self) -> int:
return hash(str(self))
@ -335,6 +391,45 @@ class Marker:
return str(self) == str(other)
def __getstate__(self) -> str:
# Return the marker expression string for compactness and stability.
# Internal Node objects are excluded; the string is re-parsed on load.
return str(self)
def __setstate__(self, state: object) -> None:
if isinstance(state, str):
# New format (26.2+): just the marker expression string.
try:
self._markers = _normalize_extra_values(_parse_marker(state))
except ParserSyntaxError as exc:
raise TypeError(f"Cannot restore Marker from {state!r}") from exc
return
if isinstance(state, dict) and "_markers" in state:
# Old format (packaging <= 26.1, no __slots__): plain __dict__.
markers = state["_markers"]
if isinstance(markers, list):
self._markers = markers
return
if isinstance(state, tuple) and len(state) == 2:
# Old format (packaging <= 26.1, __slots__): (None, {slot: value}).
_, slot_dict = state
if isinstance(slot_dict, dict) and "_markers" in slot_dict:
markers = slot_dict["_markers"]
if isinstance(markers, list):
self._markers = markers
return
raise TypeError(f"Cannot restore Marker from {state!r}")
def __and__(self, other: Marker) -> Marker:
if not isinstance(other, Marker):
return NotImplemented
return self._from_markers([self._markers, "and", other._markers])
def __or__(self, other: Marker) -> Marker:
if not isinstance(other, Marker):
return NotImplemented
return self._from_markers([self._markers, "or", other._markers])
def evaluate(
self,
environment: Mapping[str, str | AbstractSet[str]] | None = None,
@ -342,14 +437,23 @@ class Marker:
) -> bool:
"""Evaluate a marker.
Return the boolean from evaluating the given marker against the
environment. environment is an optional argument to override all or
part of the determined environment. The *context* parameter specifies what
context the markers are being evaluated for, which influences what markers
are considered valid. Acceptable values are "metadata" (for core metadata;
default), "lock_file", and "requirement" (i.e. all other situations).
Return the boolean from evaluating this marker against the environment.
The environment is determined from the current Python process unless
passed in explicitly.
:param environment: Mapping containing keys and values to override the
detected environment.
:param EvaluateContext context: The context in which the marker is
evaluated, which influences what marker names are considered valid.
Accepted values are ``"metadata"`` (for core metadata; default),
``"lock_file"``, and ``"requirement"`` (i.e. all other situations).
:raises UndefinedComparison: If the marker uses a comparison on values
that are not valid versions per the :ref:`specification of version
specifiers <pypug:version-specifiers>`.
:raises UndefinedEnvironmentName: If the marker references a value that
is missing from the evaluation environment.
:returns: ``True`` if the marker matches, otherwise ``False``.
The environment is determined from the current Python process.
"""
current_environment = cast(
"dict[str, str | AbstractSet[str]]", default_environment()

View file

@ -1,13 +1,11 @@
from __future__ import annotations
import email.feedparser
import email.header
import email.message
import email.parser
import email.policy
import keyword
import pathlib
import sys
import typing
from typing import (
Any,
@ -20,6 +18,7 @@ from typing import (
from . import licenses, requirements, specifiers, utils
from . import version as version_module
from .errors import ExceptionGroup, _ErrorCollector
if typing.TYPE_CHECKING:
from .licenses import NormalizedLicenseExpression
@ -27,26 +26,19 @@ if typing.TYPE_CHECKING:
T = typing.TypeVar("T")
if sys.version_info >= (3, 11): # pragma: no cover
ExceptionGroup = ExceptionGroup # noqa: F821
else: # pragma: no cover
__all__ = [
"ExceptionGroup", # Keep this for a bit (makes mypy happy w/ 26.0 compat)
"InvalidMetadata",
"Metadata",
"RFC822Message",
"RFC822Policy",
"RawMetadata",
"parse_email",
]
class ExceptionGroup(Exception):
"""A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11.
If :external:exc:`ExceptionGroup` is already defined by Python itself,
that version is used instead.
"""
message: str
exceptions: list[Exception]
def __init__(self, message: str, exceptions: list[Exception]) -> None:
self.message = message
self.exceptions = exceptions
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})"
def __dir__() -> list[str]:
return __all__
class InvalidMetadata(ValueError):
@ -76,8 +68,8 @@ class RawMetadata(TypedDict, total=False):
Core metadata fields that can be specified multiple times are stored as a
list or dict depending on which is appropriate for the field. Any fields
which hold multiple values in a single field are stored as a list.
which hold multiple values in a single field are stored as a list. All fields
are considered optional.
"""
# Metadata 1.0 - PEP 241
@ -641,7 +633,7 @@ class _Validator(Generic[T]):
charset = parameters.get("charset", "UTF-8")
if charset != "UTF-8":
raise self._invalid_metadata(
f"{{field}} can only specify the UTF-8 charset, not {list(charset)}"
f"{{field}} can only specify the UTF-8 charset, not {charset!r}"
)
markdown_variants = {"GFM", "CommonMark"}
@ -784,13 +776,11 @@ class Metadata:
ins._raw = data.copy() # Mutations occur due to caching enriched values.
if validate:
exceptions: list[Exception] = []
try:
collector = _ErrorCollector()
metadata_version = None
with collector.collect(InvalidMetadata):
metadata_version = ins.metadata_version
metadata_age = _VALID_METADATA_VERSIONS.index(metadata_version)
except InvalidMetadata as metadata_version_exc:
exceptions.append(metadata_version_exc)
metadata_version = None
# Make sure to check for the fields that are present, the required
# fields (so their absence can be reported).
@ -807,7 +797,7 @@ class Metadata:
field_metadata_version = cls.__dict__[key].added
except KeyError:
exc = InvalidMetadata(key, f"unrecognized field: {key!r}")
exceptions.append(exc)
collector.error(exc)
continue
field_age = _VALID_METADATA_VERSIONS.index(
field_metadata_version
@ -819,14 +809,13 @@ class Metadata:
f"{field} introduced in metadata version "
f"{field_metadata_version}, not {metadata_version}",
)
exceptions.append(exc)
collector.error(exc)
continue
getattr(ins, key)
except InvalidMetadata as exc:
exceptions.append(exc)
collector.error(exc)
if exceptions:
raise ExceptionGroup("invalid metadata", exceptions)
collector.finalize("invalid metadata")
return ins
@ -840,16 +829,13 @@ class Metadata:
raw, unparsed = parse_email(data)
if validate:
exceptions: list[Exception] = []
for unparsed_key in unparsed:
if unparsed_key in _EMAIL_TO_RAW_MAPPING:
message = f"{unparsed_key!r} has invalid data"
else:
message = f"unrecognized field: {unparsed_key!r}"
exceptions.append(InvalidMetadata(unparsed_key, message))
if exceptions:
raise ExceptionGroup("unparsed", exceptions)
with _ErrorCollector().on_exit("unparsed") as collector:
for unparsed_key in unparsed:
if unparsed_key in _EMAIL_TO_RAW_MAPPING:
message = f"{unparsed_key!r} has invalid data"
else:
message = f"unrecognized field: {unparsed_key!r}"
collector.error(InvalidMetadata(unparsed_key, message))
try:
return cls.from_raw(raw, validate=validate)

View file

@ -12,18 +12,29 @@ from typing import (
Callable,
Protocol,
TypeVar,
cast,
)
from urllib.parse import urlparse
from .markers import Marker
from .markers import Environment, Marker, default_environment
from .specifiers import SpecifierSet
from .utils import NormalizedName, is_normalized_name
from .tags import create_compatible_tags_selector, sys_tags
from .utils import (
NormalizedName,
is_normalized_name,
parse_sdist_filename,
parse_wheel_filename,
)
from .version import Version
if TYPE_CHECKING: # pragma: no cover
from collections.abc import Collection, Iterator
from pathlib import Path
from typing_extensions import Self
from .tags import Tag
_logger = logging.getLogger(__name__)
__all__ = [
@ -39,6 +50,11 @@ __all__ = [
"is_valid_pylock_path",
]
def __dir__() -> list[str]:
return __all__
_T = TypeVar("_T")
_T2 = TypeVar("_T2")
@ -222,6 +238,26 @@ def _validate_path_url(path: str | None, url: str | None) -> None:
raise PylockValidationError("path or url must be provided")
def _path_name(path: str | None) -> str | None:
if not path:
return None
# If the path is relative it MAY use POSIX-style path separators explicitly
# for portability
if "/" in path:
return path.rsplit("/", 1)[-1]
elif "\\" in path:
return path.rsplit("\\", 1)[-1]
else:
return path
def _url_name(url: str | None) -> str | None:
if not url:
return None
url_path = urlparse(url).path
return url_path.rsplit("/", 1)[-1]
def _validate_hashes(hashes: Mapping[str, Any]) -> Mapping[str, Any]:
if not hashes:
raise PylockValidationError("At least one hash must be provided")
@ -269,6 +305,10 @@ class PylockUnsupportedVersionError(PylockValidationError):
"""Raised when encountering an unsupported `lock_version`."""
class PylockSelectError(Exception):
"""Base exception for errors raised by :meth:`Pylock.select`."""
@dataclass(frozen=True, init=False)
class PackageVcs:
type: str
@ -418,6 +458,14 @@ class PackageSdist:
_validate_path_url(package_sdist.path, package_sdist.url)
return package_sdist
@property
def filename(self) -> str:
"""Get the filename of the sdist."""
filename = self.name or _path_name(self.path) or _url_name(self.url)
if not filename:
raise PylockValidationError("Cannot determine sdist filename")
return filename
@dataclass(frozen=True, init=False)
class PackageWheel:
@ -459,6 +507,14 @@ class PackageWheel:
_validate_path_url(package_wheel.path, package_wheel.url)
return package_wheel
@property
def filename(self) -> str:
"""Get the filename of the wheel."""
filename = self.name or _path_name(self.path) or _url_name(self.url)
if not filename:
raise PylockValidationError("Cannot determine wheel filename")
return filename
@dataclass(frozen=True, init=False)
class Package:
@ -538,6 +594,46 @@ class Package:
"Exactly one of vcs, directory, archive must be set "
"if sdist and wheels are not set"
)
for i, wheel in enumerate(package.wheels or []):
try:
(name, version, _, _) = parse_wheel_filename(wheel.filename)
except Exception as e:
raise PylockValidationError(
f"Invalid wheel filename {wheel.filename!r}",
context=f"wheels[{i}]",
) from e
if name != package.name:
raise PylockValidationError(
f"Name in {wheel.filename!r} is not consistent with "
f"package name {package.name!r}",
context=f"wheels[{i}]",
)
if package.version and version != package.version:
raise PylockValidationError(
f"Version in {wheel.filename!r} is not consistent with "
f"package version {str(package.version)!r}",
context=f"wheels[{i}]",
)
if package.sdist:
try:
name, version = parse_sdist_filename(package.sdist.filename)
except Exception as e:
raise PylockValidationError(
f"Invalid sdist filename {package.sdist.filename!r}",
context="sdist",
) from e
if name != package.name:
raise PylockValidationError(
f"Name in {package.sdist.filename!r} is not consistent with "
f"package name {package.name!r}",
context="sdist",
)
if package.version and version != package.version:
raise PylockValidationError(
f"Version in {package.sdist.filename!r} is not consistent with "
f"package version {str(package.version)!r}",
context="sdist",
)
try:
for i, attestation_identity in enumerate( # noqa: B007
package.attestation_identities or []
@ -633,3 +729,177 @@ class Pylock:
Raises :class:`PylockValidationError` otherwise."""
self.from_dict(self.to_dict())
def select(
self,
*,
environment: Environment | None = None,
tags: Sequence[Tag] | None = None,
extras: Collection[str] | None = None,
dependency_groups: Collection[str] | None = None,
) -> Iterator[
tuple[
Package,
PackageVcs
| PackageDirectory
| PackageArchive
| PackageWheel
| PackageSdist,
]
]:
"""Select what to install from the lock file.
The *environment* and *tags* parameters represent the environment being
selected for. If unspecified, ``packaging.markers.default_environment()`` and
``packaging.tags.sys_tags()`` are used.
The *extras* parameter represents the extras to install.
The *dependency_groups* parameter represents the groups to install. If
unspecified, the default groups are used.
This method must be used on valid Pylock instances (i.e. one obtained
from :meth:`Pylock.from_dict` or if constructed manually, after calling
:meth:`Pylock.validate`).
"""
compatible_tags_selector = create_compatible_tags_selector(tags or sys_tags())
# #. Gather the extras and dependency groups to install and set ``extras`` and
# ``dependency_groups`` for marker evaluation, respectively.
#
# #. ``extras`` SHOULD be set to the empty set by default.
# #. ``dependency_groups`` SHOULD be the set created from
# :ref:`pylock-default-groups` by default.
env = cast(
"dict[str, str | frozenset[str]]",
dict(
environment or {}, # Marker.evaluate will fill-up
extras=frozenset(extras or []),
dependency_groups=frozenset(
(self.default_groups or [])
if dependency_groups is None # to allow selecting no group
else dependency_groups
),
),
)
env_python_full_version = (
environment["python_full_version"]
if environment
else default_environment()["python_full_version"]
)
# #. Check if the metadata version specified by :ref:`pylock-lock-version` is
# supported; an error or warning MUST be raised as appropriate.
# Covered by lock.validate() which is a precondition for this method.
# #. If :ref:`pylock-requires-python` is specified, check that the environment
# being installed for meets the requirement; an error MUST be raised if it is
# not met.
if self.requires_python and not self.requires_python.contains(
env_python_full_version,
):
raise PylockSelectError(
f"python_full_version {env_python_full_version!r} "
f"in provided environment does not satisfy the Python version "
f"requirement {str(self.requires_python)!r}"
)
# #. If :ref:`pylock-environments` is specified, check that at least one of the
# environment marker expressions is satisfied; an error MUST be raised if no
# expression is satisfied.
if self.environments:
for env_marker in self.environments:
if env_marker.evaluate(
cast("dict[str, str]", environment or {}), context="requirement"
):
break
else:
raise PylockSelectError(
"Provided environment does not satisfy any of the "
"environments specified in the lock file"
)
# #. For each package listed in :ref:`pylock-packages`:
selected_packages_by_name: dict[str, tuple[int, Package]] = {}
for package_index, package in enumerate(self.packages):
# #. If :ref:`pylock-packages-marker` is specified, check if it is
# satisfied;if it isn't, skip to the next package.
if package.marker and not package.marker.evaluate(env, context="lock_file"):
continue
# #. If :ref:`pylock-packages-requires-python` is specified, check if it is
# satisfied; an error MUST be raised if it isn't.
if package.requires_python and not package.requires_python.contains(
env_python_full_version,
):
raise PylockSelectError(
f"python_full_version {env_python_full_version!r} "
f"in provided environment does not satisfy the Python version "
f"requirement {str(package.requires_python)!r} for package "
f"{package.name!r} at packages[{package_index}]"
)
# #. Check that no other conflicting instance of the package has been slated
# to be installed; an error about the ambiguity MUST be raised otherwise.
if package.name in selected_packages_by_name:
raise PylockSelectError(
f"Multiple packages with the name {package.name!r} are "
f"selected at packages[{package_index}] and "
f"packages[{selected_packages_by_name[package.name][0]}]"
)
# #. Check that the source of the package is specified appropriately (i.e.
# there are no conflicting sources in the package entry);
# an error MUST be raised if any issues are found.
# Covered by lock.validate() which is a precondition for this method.
# #. Add the package to the set of packages to install.
selected_packages_by_name[package.name] = (package_index, package)
# #. For each package to be installed:
for package_index, package in selected_packages_by_name.values():
# - If :ref:`pylock-packages-vcs` is set:
if package.vcs is not None:
yield package, package.vcs
# - Else if :ref:`pylock-packages-directory` is set:
elif package.directory is not None:
yield package, package.directory
# - Else if :ref:`pylock-packages-archive` is set:
elif package.archive is not None:
yield package, package.archive
# - Else if there are entries for :ref:`pylock-packages-wheels`:
elif package.wheels:
# #. Look for the appropriate wheel file based on
# :ref:`pylock-packages-wheels-name`; if one is not found then move
# on to :ref:`pylock-packages-sdist` or an error MUST be raised about
# a lack of source for the project.
best_wheel = next(
compatible_tags_selector(
(wheel, parse_wheel_filename(wheel.filename)[-1])
for wheel in package.wheels
),
None,
)
if best_wheel:
yield package, best_wheel
elif package.sdist is not None:
yield package, package.sdist
else:
raise PylockSelectError(
f"No wheel found matching the provided tags "
f"for package {package.name!r} "
f"at packages[{package_index}], "
f"and no sdist available as a fallback"
)
# - Else if no :ref:`pylock-packages-wheels` file is found or
# :ref:`pylock-packages-sdist` is solely set:
elif package.sdist is not None:
yield package, package.sdist
else:
# Covered by lock.validate() which is a precondition for this method.
raise NotImplementedError # pragma: no cover

View file

@ -11,6 +11,15 @@ from .markers import Marker, _normalize_extra_values
from .specifiers import SpecifierSet
from .utils import canonicalize_name
__all__ = [
"InvalidRequirement",
"Requirement",
]
def __dir__() -> list[str]:
return __all__
class InvalidRequirement(ValueError):
"""
@ -24,6 +33,16 @@ class Requirement:
Parse a given requirement string into its parts, such as name, specifier,
URL, and extras. Raises InvalidRequirement on a badly-formed requirement
string.
Instances are safe to serialize with :mod:`pickle`. They use a stable
format so the same pickle can be loaded in future packaging releases.
.. versionchanged:: 26.2
Added a stable pickle format. Pickles created with packaging 26.2+ can
be unpickled with future releases. Backward compatibility with pickles
from packaging < 26.2 is supported but may be removed in a future
release.
"""
# TODO: Can we test whether something is contained within a requirement?
@ -64,11 +83,35 @@ class Requirement:
if self.marker:
yield f"; {self.marker}"
def __getstate__(self) -> str:
# Return the requirement string for compactness and stability.
# Re-parsed on load to reconstruct all fields.
return str(self)
def __setstate__(self, state: object) -> None:
if isinstance(state, str):
# New format (26.2+): just the requirement string.
try:
tmp = Requirement(state)
except InvalidRequirement as exc:
raise TypeError(f"Cannot restore Requirement from {state!r}") from exc
self.name = tmp.name
self.url = tmp.url
self.extras = tmp.extras
self.specifier = tmp.specifier
self.marker = tmp.marker
return
if isinstance(state, dict):
# Old format (packaging <= 26.1, no __slots__): plain __dict__.
self.__dict__.update(state)
return
raise TypeError(f"Cannot restore Requirement from {state!r}")
def __str__(self) -> str:
return "".join(self._iter_parts(self.name))
def __repr__(self) -> str:
return f"<{self.__class__.__name__}('{self}')>"
return f"<{self.__class__.__name__}({str(self)!r})>"
def __hash__(self) -> int:
return hash(tuple(self._iter_parts(canonicalize_name(self.name))))

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@
from __future__ import annotations
import logging
import operator
import platform
import re
import struct
@ -13,20 +14,52 @@ import sys
import sysconfig
from importlib.machinery import EXTENSION_SUFFIXES
from typing import (
Any,
TYPE_CHECKING,
Iterable,
Iterator,
Sequence,
Tuple,
TypeVar,
cast,
)
from . import _manylinux, _musllinux
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from typing import AbstractSet
__all__ = [
"INTERPRETER_SHORT_NAMES",
"AppleVersion",
"PythonVersion",
"Tag",
"UnsortedTagsError",
"android_platforms",
"compatible_tags",
"cpython_tags",
"create_compatible_tags_selector",
"generic_tags",
"interpreter_name",
"interpreter_version",
"ios_platforms",
"mac_platforms",
"parse_tag",
"platform_tags",
"sys_tags",
]
def __dir__() -> list[str]:
return __all__
logger = logging.getLogger(__name__)
PythonVersion = Sequence[int]
AppleVersion = Tuple[int, int]
_T = TypeVar("_T")
INTERPRETER_SHORT_NAMES: dict[str, str] = {
"python": "py", # Generic.
@ -37,7 +70,19 @@ INTERPRETER_SHORT_NAMES: dict[str, str] = {
}
_32_BIT_INTERPRETER = struct.calcsize("P") == 4
# This function can be unit tested without reloading the module
# (Unlike _32_BIT_INTERPRETER)
def _compute_32_bit_interpreter() -> bool:
return struct.calcsize("P") == 4
_32_BIT_INTERPRETER = _compute_32_bit_interpreter()
class UnsortedTagsError(ValueError):
"""
Raised when a tag component is not in sorted order per PEP 425.
"""
class Tag:
@ -46,11 +91,29 @@ class Tag:
Instances are considered immutable and thus are hashable. Equality checking
is also supported.
Instances are safe to serialize with :mod:`pickle`. They use a stable
format so the same pickle can be loaded in future packaging releases.
.. versionchanged:: 26.2
Added a stable pickle format. Pickles created with packaging 26.2+ can
be unpickled with future releases. Backward compatibility with pickles
from packaging < 26.2 is supported but may be removed in a future
release.
"""
__slots__ = ["_abi", "_hash", "_interpreter", "_platform"]
def __init__(self, interpreter: str, abi: str, platform: str) -> None:
"""
:param str interpreter: The interpreter name, e.g. ``"py"``
(see :attr:`INTERPRETER_SHORT_NAMES` for mapping
well-known interpreter names to their short names).
:param str abi: The ABI that a wheel supports, e.g. ``"cp37m"``.
:param str platform: The OS/platform the wheel supports,
e.g. ``"win_amd64"``.
"""
self._interpreter = interpreter.lower()
self._abi = abi.lower()
self._platform = platform.lower()
@ -63,14 +126,25 @@ class Tag:
@property
def interpreter(self) -> str:
"""
The interpreter name, e.g. ``"py"`` (see
:attr:`INTERPRETER_SHORT_NAMES` for mapping well-known interpreter
names to their short names).
"""
return self._interpreter
@property
def abi(self) -> str:
"""
The supported ABI.
"""
return self._abi
@property
def platform(self) -> str:
"""
The OS/platform.
"""
return self._platform
def __eq__(self, other: object) -> bool:
@ -93,23 +167,69 @@ class Tag:
def __repr__(self) -> str:
return f"<{self} @ {id(self)}>"
def __setstate__(self, state: tuple[None, dict[str, Any]]) -> None:
# The cached _hash is wrong when unpickling.
_, slots = state
for k, v in slots.items():
setattr(self, k, v)
self._hash = hash((self._interpreter, self._abi, self._platform))
def __getstate__(self) -> tuple[str, str, str]:
# Return state as a 3-item tuple: (interpreter, abi, platform).
# Cache member _hash is excluded and will be recomputed.
return (self._interpreter, self._abi, self._platform)
def __setstate__(self, state: object) -> None:
if isinstance(state, tuple):
if len(state) == 3 and all(isinstance(s, str) for s in state):
# New format (26.2+): (interpreter, abi, platform)
self._interpreter, self._abi, self._platform = state
self._hash = hash((self._interpreter, self._abi, self._platform))
return
if len(state) == 2 and isinstance(state[1], dict):
# Old format (packaging <= 26.1, __slots__): (None, {slot: value}).
_, slots = state
try:
interpreter = slots["_interpreter"]
abi = slots["_abi"]
platform = slots["_platform"]
except KeyError:
raise TypeError(f"Cannot restore Tag from {state!r}") from None
if not all(
isinstance(value, str) for value in (interpreter, abi, platform)
):
raise TypeError(f"Cannot restore Tag from {state!r}")
self._interpreter = interpreter.lower()
self._abi = abi.lower()
self._platform = platform.lower()
self._hash = hash((self._interpreter, self._abi, self._platform))
return
raise TypeError(f"Cannot restore Tag from {state!r}")
def parse_tag(tag: str) -> frozenset[Tag]:
def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]:
"""
Parses the provided tag (e.g. `py3-none-any`) into a frozenset of Tag instances.
Parses the provided tag (e.g. `py3-none-any`) into a frozenset of
:class:`Tag` instances.
Returning a set is required due to the possibility that the tag is a
compressed tag set.
`compressed tag set`_, e.g. ``"py2.py3-none-any"`` which supports both
Python 2 and Python 3.
If **validate_order** is true, compressed tag set components are checked
to be in sorted order as required by PEP 425.
:param str tag: The tag to parse, e.g. ``"py3-none-any"``.
:param bool validate_order: Check whether compressed tag set components
are in sorted order.
:raises UnsortedTagsError: If **validate_order** is true and any compressed tag
set component is not in sorted order.
.. versionadded:: 26.1
The *validate_order* parameter.
"""
tags = set()
interpreters, abis, platforms = tag.split("-")
if validate_order:
for component in (interpreters, abis, platforms):
parts = component.split(".")
if parts != sorted(parts):
raise UnsortedTagsError(
f"Tag component {component!r} is not in sorted order per PEP 425"
)
for interpreter in interpreters.split("."):
for abi in abis.split("."):
for platform_ in platforms.split("."):
@ -150,12 +270,25 @@ def _abi3_applies(python_version: PythonVersion, threading: bool) -> bool:
"""
Determine if the Python version supports abi3.
PEP 384 was first implemented in Python 3.2. The threaded (`--disable-gil`)
PEP 384 was first implemented in Python 3.2. The free-threaded
builds do not support abi3.
"""
return len(python_version) > 1 and tuple(python_version) >= (3, 2) and not threading
def _abi3t_applies(python_version: PythonVersion, threading: bool) -> bool:
"""
Determine if the Python version supports abi3t.
PEP 803 was first implemented in Python 3.15 but, per PEP 803, this
returns tags going back to Python 3.2 to mirror the abi3
implementation and leave open the possibility of abi3t wheels
supporting older Python versions.
"""
return len(python_version) > 1 and tuple(python_version) >= (3, 2) and threading
def _cpython_abis(py_version: PythonVersion, warn: bool = False) -> list[str]:
py_version = tuple(py_version) # To allow for version comparison.
abis = []
@ -197,19 +330,31 @@ def cpython_tags(
warn: bool = False,
) -> Iterator[Tag]:
"""
Yields the tags for a CPython interpreter.
Yields the tags for the CPython interpreter.
The tags consist of:
- cp<python_version>-<abi>-<platform>
- cp<python_version>-abi3-<platform>
- cp<python_version>-none-<platform>
- cp<less than python_version>-abi3-<platform> # Older Python versions down to 3.2.
The specific tags generated are:
If python_version only specifies a major version then user-provided ABIs and
the 'none' ABItag will be used.
- ``cp<python_version>-<abi>-<platform>``
- ``cp<python_version>-<stable_abi>-<platform>``
- ``cp<python_version>-none-<platform>``
- ``cp<older version>-<stable_abi>-<platform>`` where "older version" is all older
minor versions down to Python 3.2 (when ``abi3`` was introduced)
If 'abi3' or 'none' are specified in 'abis' then they will be yielded at
their normal position and not at the beginning.
If ``python_version`` only provides a major-only version then only
user-provided ABIs via ``abis`` and the ``none`` ABI will be used.
The ``stable_abi`` will be either ``abi3`` or ``abi3t`` if `abi` is a
GIL-enabled ABI like `"cp315"` or a free-threaded ABI like `"cp315t"`,
respectively.
:param Sequence python_version: A one- or two-item sequence representing the
targeted Python version. Defaults to
``sys.version_info[:2]``.
:param Iterable abis: Iterable of compatible ABIs. Defaults to the ABIs
compatible with the current system.
:param Iterable platforms: Iterable of compatible platforms. Defaults to the
platforms compatible with the current system.
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
"""
if not python_version:
python_version = sys.version_info[:2]
@ -233,16 +378,27 @@ def cpython_tags(
threading = _is_threaded_cpython(abis)
use_abi3 = _abi3_applies(python_version, threading)
if use_abi3:
yield from (Tag(interpreter, "abi3", platform_) for platform_ in platforms)
yield from (Tag(interpreter, "none", platform_) for platform_ in platforms)
use_abi3t = _abi3t_applies(python_version, threading)
if use_abi3:
yield from (Tag(interpreter, "abi3", platform_) for platform_ in platforms)
if use_abi3t:
yield from (Tag(interpreter, "abi3t", platform_) for platform_ in platforms)
yield from (Tag(interpreter, "none", platform_) for platform_ in platforms)
if use_abi3 or use_abi3t:
for minor_version in range(python_version[1] - 1, 1, -1):
for platform_ in platforms:
version = _version_nodot((python_version[0], minor_version))
interpreter = f"cp{version}"
yield Tag(interpreter, "abi3", platform_)
if use_abi3:
yield Tag(interpreter, "abi3", platform_)
if use_abi3t:
# Support for abi3t was introduced in Python 3.15, but in
# principle abi3t wheels are possible for older limited API
# versions, so allow things like ("cp37", "abi3t", "platform")
yield Tag(interpreter, "abi3t", platform_)
def _generic_abi() -> list[str]:
@ -294,12 +450,25 @@ def generic_tags(
warn: bool = False,
) -> Iterator[Tag]:
"""
Yields the tags for a generic interpreter.
Yields the tags for an interpreter which requires no specialization.
The tags consist of:
- <interpreter>-<abi>-<platform>
This function should be used if one of the other interpreter-specific
functions provided by this module is not appropriate (i.e. not calculating
tags for a CPython interpreter).
The "none" ABI will be added if it was not explicitly provided.
The specific tags generated are:
- ``<interpreter>-<abi>-<platform>``
The ``"none"`` ABI will be added if it was not explicitly provided.
:param str interpreter: The name of the interpreter. Defaults to being
calculated.
:param Iterable abis: Iterable of compatible ABIs. Defaults to the ABIs
compatible with the current system.
:param Iterable platforms: Iterable of compatible platforms. Defaults to the
platforms compatible with the current system.
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
"""
if not interpreter:
interp_name = interpreter_name()
@ -335,12 +504,22 @@ def compatible_tags(
platforms: Iterable[str] | None = None,
) -> Iterator[Tag]:
"""
Yields the sequence of tags that are compatible with a specific version of Python.
Yields the tags for an interpreter compatible with the Python version
specified by ``python_version``.
The tags consist of:
- py*-none-<platform>
- <interpreter>-none-any # ... if `interpreter` is provided.
- py*-none-any
The specific tags generated are:
- ``py*-none-<platform>``
- ``<interpreter>-none-any`` if ``interpreter`` is provided
- ``py*-none-any``
:param Sequence python_version: A one- or two-item sequence representing the
compatible version of Python. Defaults to
``sys.version_info[:2]``.
:param str interpreter: The name of the interpreter (if known), e.g.
``"cp38"``. Defaults to the current interpreter.
:param Iterable platforms: Iterable of compatible platforms. Defaults to the
platforms compatible with the current system.
"""
if not python_version:
python_version = sys.version_info[:2]
@ -400,12 +579,25 @@ def mac_platforms(
version: AppleVersion | None = None, arch: str | None = None
) -> Iterator[str]:
"""
Yields the platform tags for a macOS system.
Yields the :attr:`~Tag.platform` tags for macOS.
The `version` parameter is a two-item tuple specifying the macOS version to
generate platform tags for. The `arch` parameter is the CPU architecture to
generate platform tags for. Both parameters default to the appropriate value
for the current system.
:param tuple version: A two-item tuple representing the version of macOS.
Defaults to the current system's version.
:param str arch: The CPU architecture. Defaults to the architecture of the
current system, e.g. ``"x86_64"``.
.. note::
Equivalent support for the other major platforms is purposefully not
provided:
- On Windows, platform compatibility is statically specified
- On Linux, code must be run on the system itself to determine
compatibility
"""
version_str, _, cpu_arch = platform.mac_ver()
if version is None:
@ -476,14 +668,19 @@ def ios_platforms(
version: AppleVersion | None = None, multiarch: str | None = None
) -> Iterator[str]:
"""
Yields the platform tags for an iOS system.
:param version: A two-item tuple specifying the iOS version to generate
platform tags for. Defaults to the current iOS version.
:param multiarch: The CPU architecture+ABI to generate platform tags for -
(the value used by `sys.implementation._multiarch` e.g.,
`arm64_iphoneos` or `x84_64_iphonesimulator`). Defaults to the current
multiarch value.
Yields the :attr:`~Tag.platform` tags for iOS.
:param tuple version: A two-item tuple representing the version of iOS.
Defaults to the current system's version.
:param str multiarch: The CPU architecture+ABI to be used. This should be in
the format by ``sys.implementation._multiarch`` (e.g.,
``arm64_iphoneos`` or ``x86_64_iphonesimulator``).
Defaults to the current system's multiarch value.
.. note::
Behavior of this method is undefined if invoked on non-iOS platforms
without providing explicit version and multiarch arguments.
"""
if version is None:
# if iOS is the current platform, ios_ver *must* be defined. However,
@ -585,13 +782,22 @@ def _linux_platforms(is_32bit: bool = _32_BIT_INTERPRETER) -> Iterator[str]:
yield f"linux_{arch}"
def _emscripten_platforms() -> Iterator[str]:
pyemscripten_platform_version = sysconfig.get_config_var(
"PYEMSCRIPTEN_PLATFORM_VERSION"
)
if pyemscripten_platform_version:
yield f"pyemscripten_{pyemscripten_platform_version}_wasm32"
yield from _generic_platforms()
def _generic_platforms() -> Iterator[str]:
yield _normalize_string(sysconfig.get_platform())
def platform_tags() -> Iterator[str]:
"""
Provides the platform tags for this installation.
Yields the :attr:`~Tag.platform` tags for the running interpreter.
"""
if platform.system() == "Darwin":
return mac_platforms()
@ -601,6 +807,8 @@ def platform_tags() -> Iterator[str]:
return android_platforms()
elif platform.system() == "Linux":
return _linux_platforms()
elif platform.system() == "Emscripten":
return _emscripten_platforms()
else:
return _generic_platforms()
@ -611,6 +819,8 @@ def interpreter_name() -> str:
Some implementations have a reserved, two-letter abbreviation which will
be returned when appropriate.
This typically acts as the prefix to the :attr:`~Tag.interpreter` tag.
"""
name = sys.implementation.name
return INTERPRETER_SHORT_NAMES.get(name) or name
@ -618,7 +828,11 @@ def interpreter_name() -> str:
def interpreter_version(*, warn: bool = False) -> str:
"""
Returns the version of the running interpreter.
Returns the running interpreter's version.
This typically acts as the suffix to the :attr:`~Tag.interpreter` tag.
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
"""
version = _get_config_var("py_version_nodot", warn=warn)
return str(version) if version else _version_nodot(sys.version_info[:2])
@ -630,10 +844,31 @@ def _version_nodot(version: PythonVersion) -> str:
def sys_tags(*, warn: bool = False) -> Iterator[Tag]:
"""
Returns the sequence of tag triples for the running interpreter.
Yields the sequence of tag triples that the running interpreter supports.
The order of the sequence corresponds to priority order for the
interpreter, from most to least important.
The iterable is ordered so that the best-matching tag is first in the
sequence. The exact preferential order to tags is interpreter-specific, but
in general the tag importance is in the order of:
1. Interpreter
2. Platform
3. ABI
This order is due to the fact that an ABI is inherently tied to the
platform, but platform-specific code is not necessarily tied to the ABI. The
interpreter is the most important tag as it dictates basic support for any
wheel.
The function returns an iterable in order to allow for the possible
short-circuiting of tag generation if the entire sequence is not necessary
and tag calculation happens to be expensive.
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
.. versionchanged:: 21.3
Added the `pp3-none-any` tag (:issue:`311`).
.. versionchanged:: 27.0
Added the `abi3t` tag (:issue:`1099`).
"""
interp_name = interpreter_name()
@ -649,3 +884,49 @@ def sys_tags(*, warn: bool = False) -> Iterator[Tag]:
else:
interp = None
yield from compatible_tags(interpreter=interp)
def create_compatible_tags_selector(
tags: Iterable[Tag],
) -> Callable[[Iterable[tuple[_T, AbstractSet[Tag]]]], Iterator[_T]]:
"""Create a callable to select things compatible with supported tags.
This function accepts an ordered sequence of tags, with the preferred
tags first.
The returned callable accepts an iterable of tuples (thing, set[Tag]),
and returns an iterator of things, with the things with the best
matching tags first.
Example to select compatible wheel filenames:
>>> from packaging import tags
>>> from packaging.utils import parse_wheel_filename
>>> selector = tags.create_compatible_tags_selector(tags.sys_tags())
>>> filenames = ["foo-1.0-py3-none-any.whl", "foo-1.0-py2-none-any.whl"]
>>> list(selector([
... (filename, parse_wheel_filename(filename)[-1]) for filename in filenames
... ]))
['foo-1.0-py3-none-any.whl']
.. versionadded:: 26.1
"""
tag_ranks: dict[Tag, int] = {}
for rank, tag in enumerate(tags):
tag_ranks.setdefault(tag, rank) # ignore duplicate tags, keep first
supported_tags = tag_ranks.keys()
def selector(
tagged_things: Iterable[tuple[_T, AbstractSet[Tag]]],
) -> Iterator[_T]:
ranked_things: list[tuple[_T, int]] = []
for thing, thing_tags in tagged_things:
supported_thing_tags = thing_tags & supported_tags
if supported_thing_tags:
thing_rank = min(tag_ranks[t] for t in supported_thing_tags)
ranked_things.append((thing, thing_rank))
return iter(
thing for thing, _ in sorted(ranked_things, key=operator.itemgetter(1))
)
return selector

View file

@ -7,11 +7,33 @@ from __future__ import annotations
import re
from typing import NewType, Tuple, Union, cast
from .tags import Tag, parse_tag
from .tags import Tag, UnsortedTagsError, parse_tag
from .version import InvalidVersion, Version, _TrimmedRelease
__all__ = [
"BuildTag",
"InvalidName",
"InvalidSdistFilename",
"InvalidWheelFilename",
"NormalizedName",
"canonicalize_name",
"canonicalize_version",
"is_normalized_name",
"parse_sdist_filename",
"parse_wheel_filename",
]
def __dir__() -> list[str]:
return __all__
BuildTag = Union[Tuple[()], Tuple[int, str]]
NormalizedName = NewType("NormalizedName", str)
"""
A :class:`typing.NewType` of :class:`str`, representing a normalized name.
"""
class InvalidName(ValueError):
@ -33,13 +55,39 @@ class InvalidSdistFilename(ValueError):
# Core metadata spec for `Name`
_validate_regex = re.compile(r"[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9]", re.IGNORECASE)
_normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]")
_validate_regex = re.compile(
r"[a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9]", re.IGNORECASE | re.ASCII
)
_normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]", re.ASCII)
# PEP 427: The build number must start with a digit.
_build_tag_regex = re.compile(r"(\d+)(.*)")
_build_tag_regex = re.compile(r"(\d+)(.*)", re.ASCII)
def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
"""
This function takes a valid Python package or extra name, and returns the
normalized form of it.
The return type is typed as :class:`NormalizedName`. This allows type
checkers to help require that a string has passed through this function
before use.
If **validate** is true, then the function will check if **name** is a valid
distribution name before normalizing.
:param str name: The name to normalize.
:param bool validate: Check whether the name is a valid distribution name.
:raises InvalidName: If **validate** is true and the name is not an
acceptable distribution name.
>>> from packaging.utils import canonicalize_name
>>> canonicalize_name("Django")
'django'
>>> canonicalize_name("oslo.concurrency")
'oslo-concurrency'
>>> canonicalize_name("requests")
'requests'
"""
if validate and not _validate_regex.fullmatch(name):
raise InvalidName(f"name is invalid: {name!r}")
# Ensure all ``.`` and ``_`` are ``-``
@ -53,15 +101,32 @@ def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
def is_normalized_name(name: str) -> bool:
"""
Check if a name is already normalized (i.e. :func:`canonicalize_name` would
roundtrip to the same value).
:param str name: The name to check.
>>> from packaging.utils import is_normalized_name
>>> is_normalized_name("requests")
True
>>> is_normalized_name("Django")
False
"""
return _normalized_regex.fullmatch(name) is not None
def canonicalize_version(
version: Version | str, *, strip_trailing_zero: bool = True
) -> str:
"""
Return a canonical form of a version as a string.
"""Return a canonical form of a version as a string.
This function takes a string representing a package version (or a
:class:`~packaging.version.Version` instance), and returns the
normalized form of it. By default, it strips trailing zeros from
the release segment.
>>> from packaging.utils import canonicalize_version
>>> canonicalize_version('1.0.1')
'1.0.1'
@ -77,6 +142,9 @@ def canonicalize_version(
>>> canonicalize_version('foo bar baz')
'foo bar baz'
>>> canonicalize_version('1.4.0.0.0')
'1.4'
"""
if isinstance(version, str):
try:
@ -88,7 +156,49 @@ def canonicalize_version(
def parse_wheel_filename(
filename: str,
*,
validate_order: bool = False,
) -> tuple[NormalizedName, Version, BuildTag, frozenset[Tag]]:
"""
This function takes the filename of a wheel file, and parses it,
returning a tuple of name, version, build number, and tags.
The name part of the tuple is normalized and typed as
:class:`NormalizedName`. The version portion is an instance of
:class:`~packaging.version.Version`. The build number is ``()`` if
there is no build number in the wheel filename, otherwise a
two-item tuple of an integer for the leading digits and
a string for the rest of the build number. The tags portion is a
frozen set of :class:`~packaging.tags.Tag` instances (as the tag
string format allows multiple tags to be combined into a single
string).
If **validate_order** is true, compressed tag set components are
checked to be in sorted order as required by PEP 425.
:param str filename: The name of the wheel file.
:param bool validate_order: Check whether compressed tag set components
are in sorted order.
:raises InvalidWheelFilename: If the filename in question
does not follow the :ref:`wheel specification
<pypug:binary-distribution-format>`.
>>> from packaging.utils import parse_wheel_filename
>>> from packaging.tags import Tag
>>> from packaging.version import Version
>>> name, ver, build, tags = parse_wheel_filename("foo-1.0-py3-none-any.whl")
>>> name
'foo'
>>> ver == Version('1.0')
True
>>> tags == {Tag("py3", "none", "any")}
True
>>> not build
True
.. versionadded:: 26.1
The *validate_order* parameter.
"""
if not filename.endswith(".whl"):
raise InvalidWheelFilename(
f"Invalid wheel filename (extension must be '.whl'): {filename!r}"
@ -125,11 +235,39 @@ def parse_wheel_filename(
build = cast("BuildTag", (int(build_match.group(1)), build_match.group(2)))
else:
build = ()
tags = parse_tag(parts[-1])
tag_str = parts[-1]
try:
tags = parse_tag(tag_str, validate_order=validate_order)
except UnsortedTagsError:
raise InvalidWheelFilename(
f"Invalid wheel filename (compressed tag set components must be in "
f"sorted order per PEP 425): {filename!r}"
) from None
return (name, version, build, tags)
def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]:
"""
This function takes the filename of a sdist file (as specified
in the `Source distribution format`_ documentation), and parses
it, returning a tuple of the normalized name and version as
represented by an instance of :class:`~packaging.version.Version`.
:param str filename: The name of the sdist file.
:raises InvalidSdistFilename: If the filename does not end
with an sdist extension (``.zip`` or ``.tar.gz``), or if it does not
contain a dash separating the name and the version of the distribution.
>>> from packaging.utils import parse_sdist_filename
>>> from packaging.version import Version
>>> name, ver = parse_sdist_filename("foo-1.0.tar.gz")
>>> name
'foo'
>>> ver == Version('1.0')
True
.. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name
"""
if filename.endswith(".tar.gz"):
file_stem = filename[: -len(".tar.gz")]
elif filename.endswith(".zip"):

View file

@ -4,7 +4,7 @@
"""
.. testsetup::
from packaging.version import parse, Version
from packaging.version import parse, normalize_pre, Version, _cmpkey
"""
from __future__ import annotations
@ -23,8 +23,6 @@ from typing import (
Union,
)
from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType
if typing.TYPE_CHECKING:
from typing_extensions import Self, Unpack
@ -37,7 +35,7 @@ else: # pragma: no cover
import warnings
def _deprecated(message: str) -> object:
def decorator(func: object) -> object:
def decorator(func: Callable[[...], object]) -> object:
@functools.wraps(func)
def wrapper(*args: object, **kwargs: object) -> object:
warnings.warn(
@ -62,22 +60,20 @@ _LETTER_NORMALIZATION = {
"r": "post",
}
__all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "parse"]
__all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "normalize_pre", "parse"]
def __dir__() -> list[str]:
return __all__
LocalType = Tuple[Union[int, str], ...]
CmpPrePostDevType = Union[InfinityType, NegativeInfinityType, Tuple[str, int]]
CmpLocalType = Union[
NegativeInfinityType,
Tuple[Union[Tuple[int, str], Tuple[NegativeInfinityType, Union[int, str]]], ...],
]
CmpKey = Tuple[
int,
Tuple[int, ...],
CmpPrePostDevType,
CmpPrePostDevType,
CmpPrePostDevType,
CmpLocalType,
CmpLocalType = Tuple[Tuple[int, str], ...]
CmpSuffix = Tuple[int, int, int, int, int, int]
CmpKey = Union[
Tuple[int, Tuple[int, ...], CmpSuffix],
Tuple[int, Tuple[int, ...], CmpSuffix, CmpLocalType],
]
VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool]
@ -85,15 +81,38 @@ VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool]
class _VersionReplace(TypedDict, total=False):
epoch: int | None
release: tuple[int, ...] | None
pre: tuple[Literal["a", "b", "rc"], int] | None
pre: tuple[str, int] | None
post: int | None
dev: int | None
local: str | None
def normalize_pre(letter: str, /) -> str:
"""Normalize the pre-release segment of a version string.
Returns a lowercase version of the string if not a known pre-release
identifier.
>>> normalize_pre('alpha')
'a'
>>> normalize_pre('BETA')
'b'
>>> normalize_pre('rc')
'rc'
:param letter:
.. versionadded:: 26.1
"""
letter = letter.lower()
return _LETTER_NORMALIZATION.get(letter, letter)
def parse(version: str) -> Version:
"""Parse the given version string.
This is identical to the :class:`Version` constructor.
>>> parse('1.0.dev1')
<Version('1.0.dev1')>
@ -173,7 +192,7 @@ class _BaseVersion:
# Note that ++ doesn't behave identically on CPython and PyPy, so not using it here
_VERSION_PATTERN = r"""
v?+ # optional leading v
(?:
(?a:
(?:(?P<epoch>[0-9]+)!)?+ # epoch
(?P<release>[0-9]+(?:\.[0-9]+)*+) # release segment
(?P<pre> # pre-release
@ -199,7 +218,7 @@ _VERSION_PATTERN = r"""
(?P<dev_n>[0-9]+)?
)?+
)
(?:\+
(?a:\+
(?P<local> # local version
[a-z0-9]+
(?:[._-][a-z0-9]+)*+
@ -227,12 +246,21 @@ expressions (for example, matching a version number as part of a file name). The
regular expression should be compiled with the ``re.VERBOSE`` and ``re.IGNORECASE``
flags set.
.. versionchanged:: 26.0
The regex now uses possessive qualifiers on Python 3.11 if they are
supported (CPython 3.11.5+, PyPy 3.11.13+).
:meta hide-value:
"""
# Validation pattern for local version in replace()
_LOCAL_PATTERN = re.compile(r"[a-z0-9]+(?:[._-][a-z0-9]+)*", re.IGNORECASE)
_LOCAL_PATTERN = re.compile(r"[a-z0-9]+(?:[._-][a-z0-9]+)*", re.IGNORECASE | re.ASCII)
# Fast path: If a version has only digits and dots then we
# can skip the regex and parse it as a release segment
_SIMPLE_VERSION_INDICATORS = frozenset(".0123456789")
def _validate_epoch(value: object, /) -> int:
@ -258,14 +286,12 @@ def _validate_release(value: object, /) -> tuple[int, ...]:
def _validate_pre(value: object, /) -> tuple[Literal["a", "b", "rc"], int] | None:
if value is None:
return value
if (
isinstance(value, tuple)
and len(value) == 2
and value[0] in ("a", "b", "rc")
and isinstance(value[1], int)
and value[1] >= 0
):
return value
if isinstance(value, tuple) and len(value) == 2:
letter, number = value
letter = normalize_pre(letter)
if letter in {"a", "b", "rc"} and isinstance(number, int) and number >= 0:
# type checkers can't infer the Literal type here on letter
return (letter, number) # type: ignore[return-value]
msg = f"pre must be a tuple of ('a'|'b'|'rc', non-negative int), got {value}"
raise InvalidVersion(msg)
@ -301,9 +327,9 @@ def _validate_local(value: object, /) -> LocalType | None:
class _Version(NamedTuple):
epoch: int
release: tuple[int, ...]
dev: tuple[str, int] | None
pre: tuple[str, int] | None
post: tuple[str, int] | None
dev: tuple[Literal["dev"], int] | None
pre: tuple[Literal["a", "b", "rc"], int] | None
post: tuple[Literal["post"], int] | None
local: LocalType | None
@ -329,20 +355,50 @@ class Version(_BaseVersion):
False
>>> v1 <= v2
True
:class:`Version` is immutable; use :meth:`__replace__` to change
part of a version.
Instances are safe to serialize with :mod:`pickle`. They use a stable
format so the same pickle can be loaded in future packaging releases.
.. versionchanged:: 26.2
Added a stable pickle format. Pickles created with packaging 26.2+ can
be unpickled with future releases. Backward compatibility with pickles
from packaging < 26.2 is supported but may be removed in a future
release.
"""
__slots__ = ("_dev", "_epoch", "_key_cache", "_local", "_post", "_pre", "_release")
__slots__ = (
"_dev",
"_epoch",
"_hash_cache",
"_key_cache",
"_local",
"_post",
"_pre",
"_release",
)
__match_args__ = ("_str",)
"""
Pattern matching is supported on Python 3.10+.
.. versionadded:: 26.0
:meta hide-value:
"""
_regex = re.compile(r"\s*" + VERSION_PATTERN + r"\s*", re.VERBOSE | re.IGNORECASE)
_epoch: int
_release: tuple[int, ...]
_dev: tuple[str, int] | None
_pre: tuple[str, int] | None
_post: tuple[str, int] | None
_dev: tuple[Literal["dev"], int] | None
_pre: tuple[Literal["a", "b", "rc"], int] | None
_post: tuple[Literal["post"], int] | None
_local: LocalType | None
_hash_cache: int | None
_key_cache: CmpKey | None
def __init__(self, version: str) -> None:
@ -355,23 +411,118 @@ class Version(_BaseVersion):
If the ``version`` does not conform to PEP 440 in any way then this
exception will be raised.
"""
if _SIMPLE_VERSION_INDICATORS.issuperset(version):
try:
self._release = tuple(map(int, version.split(".")))
except ValueError:
# Empty parts (from "1..2", ".1", etc.) are invalid versions.
# Any other ValueError (e.g. int str-digits limit) should
# propagate to the caller.
if "" in version.split("."):
raise InvalidVersion(f"Invalid version: {version!r}") from None
# TODO: remove "no cover" when Python 3.9 is dropped.
raise # pragma: no cover
self._epoch = 0
self._pre = None
self._post = None
self._dev = None
self._local = None
self._key_cache = None
self._hash_cache = None
return
# Validate the version and parse it into pieces
match = self._regex.fullmatch(version)
if not match:
raise InvalidVersion(f"Invalid version: {version!r}")
self._epoch = int(match.group("epoch")) if match.group("epoch") else 0
self._release = tuple(map(int, match.group("release").split(".")))
self._pre = _parse_letter_version(match.group("pre_l"), match.group("pre_n"))
self._post = _parse_letter_version(
# We can type ignore the assignments below because the regex guarantees
# the correct strings
self._pre = _parse_letter_version(match.group("pre_l"), match.group("pre_n")) # type: ignore[assignment]
self._post = _parse_letter_version( # type: ignore[assignment]
match.group("post_l"), match.group("post_n1") or match.group("post_n2")
)
self._dev = _parse_letter_version(match.group("dev_l"), match.group("dev_n"))
self._dev = _parse_letter_version(match.group("dev_l"), match.group("dev_n")) # type: ignore[assignment]
self._local = _parse_local_version(match.group("local"))
# Key which will be used for sorting
self._key_cache = None
self._hash_cache = None
@classmethod
def from_parts(
cls,
*,
epoch: int = 0,
release: tuple[int, ...],
pre: tuple[str, int] | None = None,
post: int | None = None,
dev: int | None = None,
local: str | None = None,
) -> Self:
"""
Return a new version composed of the various parts.
This allows you to build a version without going though a string and
running a regular expression. It normalizes pre-release strings. The
``release=`` keyword argument is required.
>>> Version.from_parts(release=(1,2,3))
<Version('1.2.3')>
>>> Version.from_parts(release=(0,1,0), pre=("b", 1))
<Version('0.1.0b1')>
:param epoch:
:param release: This version tuple is required
.. versionadded:: 26.1
"""
_epoch = _validate_epoch(epoch)
_release = _validate_release(release)
_pre = _validate_pre(pre) if pre is not None else None
_post = _validate_post(post) if post is not None else None
_dev = _validate_dev(dev) if dev is not None else None
_local = _validate_local(local) if local is not None else None
new_version = cls.__new__(cls)
new_version._key_cache = None
new_version._hash_cache = None
new_version._epoch = _epoch
new_version._release = _release
new_version._pre = _pre
new_version._post = _post
new_version._dev = _dev
new_version._local = _local
return new_version
def __replace__(self, **kwargs: Unpack[_VersionReplace]) -> Self:
"""
__replace__(*, epoch=..., release=..., pre=..., post=..., dev=..., local=...)
Return a new version with parts replaced.
This returns a new version (unless no parts were changed). The
pre-release is normalized. Setting a value to ``None`` clears it.
>>> v = Version("1.2.3")
>>> v.__replace__(pre=("a", 1))
<Version('1.2.3a1')>
:param int | None epoch:
:param tuple[int, ...] | None release:
:param tuple[str, int] | None pre:
:param int | None post:
:param int | None dev:
:param str | None local:
.. versionadded:: 26.0
.. versionchanged:: 26.1
The pre-release portion is now normalized.
"""
epoch = _validate_epoch(kwargs["epoch"]) if "epoch" in kwargs else self._epoch
release = (
_validate_release(kwargs["release"])
@ -395,6 +546,7 @@ class Version(_BaseVersion):
new_version = self.__class__.__new__(self.__class__)
new_version._key_cache = None
new_version._hash_cache = None
new_version._epoch = epoch
new_version._release = release
new_version._pre = pre
@ -417,6 +569,255 @@ class Version(_BaseVersion):
)
return self._key_cache
# __hash__ must be defined when __eq__ is overridden,
# otherwise Python sets __hash__ to None.
def __hash__(self) -> int:
if (cached_hash := self._hash_cache) is not None:
return cached_hash
if (key := self._key_cache) is None:
self._key_cache = key = _cmpkey(
self._epoch,
self._release,
self._pre,
self._post,
self._dev,
self._local,
)
self._hash_cache = cached_hash = hash(key)
return cached_hash
# Override comparison methods to use direct _key_cache access
# This is faster than property access, especially before Python 3.12
def __lt__(self, other: _BaseVersion) -> bool:
if isinstance(other, Version):
if self._key_cache is None:
self._key_cache = _cmpkey(
self._epoch,
self._release,
self._pre,
self._post,
self._dev,
self._local,
)
if other._key_cache is None:
other._key_cache = _cmpkey(
other._epoch,
other._release,
other._pre,
other._post,
other._dev,
other._local,
)
return self._key_cache < other._key_cache
if not isinstance(other, _BaseVersion):
return NotImplemented
return super().__lt__(other)
def __le__(self, other: _BaseVersion) -> bool:
if isinstance(other, Version):
if self._key_cache is None:
self._key_cache = _cmpkey(
self._epoch,
self._release,
self._pre,
self._post,
self._dev,
self._local,
)
if other._key_cache is None:
other._key_cache = _cmpkey(
other._epoch,
other._release,
other._pre,
other._post,
other._dev,
other._local,
)
return self._key_cache <= other._key_cache
if not isinstance(other, _BaseVersion):
return NotImplemented
return super().__le__(other)
def __eq__(self, other: object) -> bool:
if isinstance(other, Version):
if self._key_cache is None:
self._key_cache = _cmpkey(
self._epoch,
self._release,
self._pre,
self._post,
self._dev,
self._local,
)
if other._key_cache is None:
other._key_cache = _cmpkey(
other._epoch,
other._release,
other._pre,
other._post,
other._dev,
other._local,
)
return self._key_cache == other._key_cache
if not isinstance(other, _BaseVersion):
return NotImplemented
return super().__eq__(other)
def __ge__(self, other: _BaseVersion) -> bool:
if isinstance(other, Version):
if self._key_cache is None:
self._key_cache = _cmpkey(
self._epoch,
self._release,
self._pre,
self._post,
self._dev,
self._local,
)
if other._key_cache is None:
other._key_cache = _cmpkey(
other._epoch,
other._release,
other._pre,
other._post,
other._dev,
other._local,
)
return self._key_cache >= other._key_cache
if not isinstance(other, _BaseVersion):
return NotImplemented
return super().__ge__(other)
def __gt__(self, other: _BaseVersion) -> bool:
if isinstance(other, Version):
if self._key_cache is None:
self._key_cache = _cmpkey(
self._epoch,
self._release,
self._pre,
self._post,
self._dev,
self._local,
)
if other._key_cache is None:
other._key_cache = _cmpkey(
other._epoch,
other._release,
other._pre,
other._post,
other._dev,
other._local,
)
return self._key_cache > other._key_cache
if not isinstance(other, _BaseVersion):
return NotImplemented
return super().__gt__(other)
def __ne__(self, other: object) -> bool:
if isinstance(other, Version):
if self._key_cache is None:
self._key_cache = _cmpkey(
self._epoch,
self._release,
self._pre,
self._post,
self._dev,
self._local,
)
if other._key_cache is None:
other._key_cache = _cmpkey(
other._epoch,
other._release,
other._pre,
other._post,
other._dev,
other._local,
)
return self._key_cache != other._key_cache
if not isinstance(other, _BaseVersion):
return NotImplemented
return super().__ne__(other)
def __getstate__(
self,
) -> tuple[
int,
tuple[int, ...],
tuple[str, int] | None,
tuple[str, int] | None,
tuple[str, int] | None,
LocalType | None,
]:
# Return state as a 6-item tuple for compactness:
# (epoch, release, pre, post, dev, local)
# Cache members are excluded and will be recomputed on demand
return (
self._epoch,
self._release,
self._pre,
self._post,
self._dev,
self._local,
)
def __setstate__(self, state: object) -> None:
# Always discard cached values — they may contain stale references
# (e.g. packaging._structures.InfinityType from pre-26.1 pickles)
# and will be recomputed on demand from the core fields above.
self._key_cache = None
self._hash_cache = None
if isinstance(state, tuple):
if len(state) == 6:
# New format (26.2+): (epoch, release, pre, post, dev, local)
(
self._epoch,
self._release,
self._pre,
self._post,
self._dev,
self._local,
) = state
return
if len(state) == 2:
# Format (packaging 26.0-26.1): (None, {slot: value}).
_, slot_dict = state
if isinstance(slot_dict, dict):
self._epoch = slot_dict["_epoch"]
self._release = slot_dict["_release"]
self._pre = slot_dict.get("_pre")
self._post = slot_dict.get("_post")
self._dev = slot_dict.get("_dev")
self._local = slot_dict.get("_local")
return
if isinstance(state, dict):
# Old format (packaging <= 25.x, no __slots__): state is a plain
# dict with "_version" (_Version NamedTuple) and "_key" entries.
version_nt = state.get("_version")
if version_nt is not None:
self._epoch = version_nt.epoch
self._release = version_nt.release
self._pre = version_nt.pre
self._post = version_nt.post
self._dev = version_nt.dev
self._local = version_nt.local
return
raise TypeError(f"Cannot restore Version from {state!r}")
@property
@_deprecated("Version._version is private and will be removed soon")
def _version(self) -> _Version:
@ -434,6 +835,7 @@ class Version(_BaseVersion):
self._post = value.post
self._local = value.local
self._key_cache = None
self._hash_cache = None
def __repr__(self) -> str:
"""A representation of the Version that shows all internal state.
@ -441,7 +843,7 @@ class Version(_BaseVersion):
>>> Version('1.0.0')
<Version('1.0.0')>
"""
return f"<Version('{self}')>"
return f"<{self.__class__.__name__}({str(self)!r})>"
def __str__(self) -> str:
"""A string representation of the version that can be round-tripped.
@ -507,7 +909,7 @@ class Version(_BaseVersion):
return self._release
@property
def pre(self) -> tuple[str, int] | None:
def pre(self) -> tuple[Literal["a", "b", "rc"], int] | None:
"""The pre-release segment of the version.
>>> print(Version("1.2.3").pre)
@ -561,6 +963,9 @@ class Version(_BaseVersion):
def public(self) -> str:
"""The public portion of the version.
This returns a string. If you want a :class:`Version` again and care
about performance, use ``v.__replace__(local=None)`` instead.
>>> Version("1.2.3").public
'1.2.3'
>>> Version("1.2.3+abc").public
@ -574,6 +979,10 @@ class Version(_BaseVersion):
def base_version(self) -> str:
"""The "base version" of the version.
This returns a string. If you want a :class:`Version` again and care
about performance, use
``v.__replace__(pre=None, post=None, dev=None, local=None)`` instead.
>>> Version("1.2.3").base_version
'1.2.3'
>>> Version("1.2.3+abc").base_version
@ -721,7 +1130,8 @@ _local_version_separators = re.compile(r"[\._-]")
def _parse_local_version(local: str | None) -> LocalType | None:
"""
Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
Takes a string like ``"abc.1.twelve"`` and turns it into
``("abc", 1, "twelve")``.
"""
if local is not None:
return tuple(
@ -731,6 +1141,19 @@ def _parse_local_version(local: str | None) -> LocalType | None:
return None
# Sort ranks for pre-release: dev-only < a < b < rc < stable (no pre-release).
_PRE_RANK = {"a": 0, "b": 1, "rc": 2}
_PRE_RANK_DEV_ONLY = -1 # sorts before a(0)
_PRE_RANK_STABLE = 3 # sorts after rc(2)
# In local version segments, strings sort before ints per PEP 440.
_LOCAL_STR_RANK = -1 # sorts before all non-negative ints
# Pre-computed suffix for stable releases (no pre, post, or dev segments).
# See _cmpkey() for the suffix layout.
_STABLE_SUFFIX = (_PRE_RANK_STABLE, 0, 0, 0, 1, 0)
def _cmpkey(
epoch: int,
release: tuple[int, ...],
@ -739,54 +1162,70 @@ def _cmpkey(
dev: tuple[str, int] | None,
local: LocalType | None,
) -> CmpKey:
# When we compare a release version, we want to compare it with all of the
# trailing zeros removed. We will use this for our sorting key.
"""Build a comparison key for PEP 440 ordering.
Returns ``(epoch, release, suffix)`` or
``(epoch, release, suffix, local)`` so that plain tuple
comparison gives the correct order.
Trailing zeros are stripped from the release so that ``1.0.0 == 1``.
The suffix is a flat 6-int tuple that encodes pre/post/dev:
``(pre_rank, pre_n, post_rank, post_n, dev_rank, dev_n)``
pre_rank: dev-only=-1, a=0, b=1, rc=2, no-pre=3
Dev-only releases (no pre or post) get -1 so they sort before
any alpha/beta/rc. Releases without a pre-release tag get 3
so they sort after rc.
post_rank: no-post=0, post=1
Releases without a post segment sort before those with one.
dev_rank: dev=0, no-dev=1
Releases without a dev segment sort after those with one.
Local segments use ``(n, "")`` for ints and ``(-1, s)`` for strings,
following PEP 440: strings sort before ints, strings compare
lexicographically, ints compare numerically, and shorter segments
sort before longer when prefixes match. Versions without a local
segment sort before those with one (3-tuple < 4-tuple).
>>> _cmpkey(0, (1, 0, 0), None, None, None, None)
(0, (1,), (3, 0, 0, 0, 1, 0))
>>> _cmpkey(0, (1,), ("a", 1), None, None, None)
(0, (1,), (0, 1, 0, 0, 1, 0))
>>> _cmpkey(0, (1,), None, None, None, ("ubuntu", 1))
(0, (1,), (3, 0, 0, 0, 1, 0), ((-1, 'ubuntu'), (1, '')))
"""
# Strip trailing zeros: 1.0.0 compares equal to 1.
len_release = len(release)
i = len_release
while i and release[i - 1] == 0:
i -= 1
_release = release if i == len_release else release[:i]
trimmed = release if i == len_release else release[:i]
# Fast path: stable release with no local segment.
if pre is None and post is None and dev is None and local is None:
return epoch, trimmed, _STABLE_SUFFIX
# We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
# We'll do this by abusing the pre segment, but we _only_ want to do this
# if there is not a pre or a post segment. If we have one of those then
# the normal sorting rules will handle this case correctly.
if pre is None and post is None and dev is not None:
_pre: CmpPrePostDevType = NegativeInfinity
# Versions without a pre-release (except as noted above) should sort after
# those with one.
# dev-only (e.g. 1.0.dev1) sorts before all pre-releases.
pre_rank, pre_n = _PRE_RANK_DEV_ONLY, 0
elif pre is None:
_pre = Infinity
pre_rank, pre_n = _PRE_RANK_STABLE, 0
else:
_pre = pre
pre_rank, pre_n = _PRE_RANK[pre[0]], pre[1]
# Versions without a post segment should sort before those with one.
if post is None:
_post: CmpPrePostDevType = NegativeInfinity
post_rank = 0 if post is None else 1
post_n = 0 if post is None else post[1]
else:
_post = post
dev_rank = 1 if dev is None else 0
dev_n = 0 if dev is None else dev[1]
# Versions without a development segment should sort after those with one.
if dev is None:
_dev: CmpPrePostDevType = Infinity
else:
_dev = dev
suffix = (pre_rank, pre_n, post_rank, post_n, dev_rank, dev_n)
if local is None:
# Versions without a local segment should sort before those with one.
_local: CmpLocalType = NegativeInfinity
else:
# Versions with a local segment need that segment parsed to implement
# the sorting rules in PEP440.
# - Alpha numeric segments sort before numeric segments
# - Alpha numeric segments sort lexicographically
# - Numeric segments sort numerically
# - Shorter versions sort before longer versions when the prefixes
# match exactly
_local = tuple(
(i, "") if isinstance(i, int) else (NegativeInfinity, i) for i in local
)
return epoch, trimmed, suffix
return epoch, _release, _pre, _post, _dev, _local
cmp_local: CmpLocalType = tuple(
(seg, "") if isinstance(seg, int) else (_LOCAL_STR_RANK, seg) for seg in local
)
return epoch, trimmed, suffix, cmp_local