chore(lsprotocol): migrate to 2025.0.0 and cleanup artifacts

The changes align the project with the 2025.0.0 lsprotocol
release, removing the old backport and updating type hints
in the protocol hooks to use Sequence where appropriate. The
dist-info and packaging metadata for older lsprotocol
versions are replaced with the new 2025.0.0 artifacts.

- Remove exceptiongroup backport used on Python <3.11
- Use Sequence instead of List in LS protocol hooks
- Replace old dist-info with 2025.0.0 metadata
This commit is contained in:
Christoph Brandau
2026-09-03 08:39:12 +02:00
parent a0a0d38fe5
commit 53ebc5d055
101 changed files with 13838 additions and 7624 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ __title__ = "packaging"
__summary__ = "Core utilities for Python packages"
__uri__ = "https://github.com/pypa/packaging"
__version__ = "26.2"
__version__ = "26.3"
__author__ = "Donald Stufft and individual contributors"
__email__ = "donald@stufft.io"
+5 -5
View File
@@ -34,7 +34,7 @@ class EMachine(enum.IntEnum):
S390 = 22
Arm = 40
X8664 = 62
AArc64 = 183
AArch64 = 183
class ELFFile:
@@ -57,8 +57,8 @@ class ELFFile:
self.encoding = ident[5] # Data structure encoding (endianness).
try:
# e_fmt: Format for program header.
# p_fmt: Format for section header.
# e_fmt: Format for the ELF header.
# p_fmt: Format for a program header.
# p_idx: Indexes to find p_type, p_offset, and p_filesz.
e_fmt, self._p_fmt, self._p_idx = {
(1, 1): ("<HHIIIIIHHH", "<IIIIIIII", (0, 1, 4)), # 32-bit LSB.
@@ -81,8 +81,8 @@ class ELFFile:
_,
self.flags, # Processor-specific flags.
_,
self._e_phentsize, # Size of section.
self._e_phnum, # Number of sections.
self._e_phentsize, # Size of a program header entry.
self._e_phnum, # Number of program headers.
) = self._read(e_fmt)
except struct.error as e:
raise ELFInvalid("unable to parse machine and section information") from e
+34 -18
View File
@@ -7,10 +7,14 @@ import os
import re
import sys
import warnings
from typing import Generator, Iterator, NamedTuple, Sequence
from typing import TYPE_CHECKING, NamedTuple
from ._elffile import EIClass, EIData, ELFFile, EMachine
if TYPE_CHECKING:
import types
from collections.abc import Generator, Iterator, Sequence
EF_ARM_ABIMASK = 0xFF000000
EF_ARM_ABI_VER5 = 0x05000000
EF_ARM_ABI_FLOAT_HARD = 0x00000400
@@ -26,8 +30,6 @@ _ALLOWED_ARCHS = {
}
# `os.PathLike` not a generic type until Python 3.9, so sticking with `str`
# as the type for `path` until then.
@contextlib.contextmanager
def _parse_elf(path: str) -> Generator[ELFFile | None, None, None]:
try:
@@ -136,11 +138,11 @@ def _glibc_version_string_ctypes() -> str | None:
# glibc.
return None
# Call gnu_get_libc_version, which returns a string like "2.5"
# Call gnu_get_libc_version, which returns a string like "2.5".
gnu_get_libc_version.restype = ctypes.c_char_p
version_str: str = gnu_get_libc_version()
# py2 / py3 compatibility:
if not isinstance(version_str, str):
# A c_char_p restype comes back as bytes, so decode to text.
version_str: str | bytes = gnu_get_libc_version()
if isinstance(version_str, bytes):
version_str = version_str.decode("ascii")
return version_str
@@ -179,30 +181,44 @@ def _get_glibc_version() -> _GLibCVersion:
# From PEP 513, PEP 600
@functools.lru_cache(maxsize=1)
def _get_manylinux_module() -> types.ModuleType | None:
"""Return the ``_manylinux`` C extension module, or None if unavailable.
The result is cached for the lifetime of the process, since the presence
of the module does not change while running.
"""
try:
return __import__("_manylinux")
except ImportError:
return None
def _is_compatible(arch: str, version: _GLibCVersion) -> bool:
sys_glibc = _get_glibc_version()
if sys_glibc < version:
return False
# Check for presence of _manylinux module.
try:
import _manylinux # noqa: PLC0415
except ImportError:
manylinux_mod = _get_manylinux_module()
if manylinux_mod is None:
return True
if hasattr(_manylinux, "manylinux_compatible"):
result = _manylinux.manylinux_compatible(version[0], version[1], arch)
if hasattr(manylinux_mod, "manylinux_compatible"):
result = manylinux_mod.manylinux_compatible(version[0], version[1], arch)
if result is not None:
return bool(result)
return True
if version == _GLibCVersion(2, 5) and hasattr(_manylinux, "manylinux1_compatible"):
return bool(_manylinux.manylinux1_compatible)
if version == _GLibCVersion(2, 5) and hasattr(
manylinux_mod, "manylinux1_compatible"
):
return bool(manylinux_mod.manylinux1_compatible)
if version == _GLibCVersion(2, 12) and hasattr(
_manylinux, "manylinux2010_compatible"
manylinux_mod, "manylinux2010_compatible"
):
return bool(_manylinux.manylinux2010_compatible)
return bool(manylinux_mod.manylinux2010_compatible)
if version == _GLibCVersion(2, 17) and hasattr(
_manylinux, "manylinux2014_compatible"
manylinux_mod, "manylinux2014_compatible"
):
return bool(_manylinux.manylinux2014_compatible)
return bool(manylinux_mod.manylinux2014_compatible)
return True
+5 -2
View File
@@ -10,10 +10,13 @@ import functools
import re
import subprocess
import sys
from typing import Iterator, NamedTuple, Sequence
from typing import TYPE_CHECKING, NamedTuple
from ._elffile import ELFFile
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
class _MuslVersion(NamedTuple):
major: int
@@ -81,5 +84,5 @@ if __name__ == "__main__": # pragma: no cover
print("plat:", plat)
print("musl:", _get_musl_version(sys.executable))
print("tags:", end=" ")
for t in platform_tags(re.sub(r"[.-]", "_", plat.split("-", 1)[-1])):
for t in platform_tags([re.sub(r"[.-]", "_", plat.split("-", 1)[-1])]):
print(t, end="\n ")
+33 -8
View File
@@ -7,9 +7,10 @@ the implementation.
from __future__ import annotations
import ast
from typing import List, Literal, NamedTuple, Sequence, Tuple, Union
from collections.abc import Sequence
from typing import Literal, NamedTuple, Union
from ._tokenizer import DEFAULT_RULES, Tokenizer
from ._tokenizer import DEFAULT_RULES, ParserSyntaxError, Tokenizer
class Node:
@@ -67,7 +68,14 @@ class Value(Node):
__slots__ = ()
def serialize(self) -> str:
return f'"{self}"'
value = str(self)
if '"' not in value:
return f'"{value}"'
if "'" not in value:
return f"'{value}'"
raise ValueError(
"Cannot serialize marker value containing both quote characters"
)
class Op(Node):
@@ -79,9 +87,9 @@ class Op(Node):
MarkerLogical = Literal["and", "or"]
MarkerVar = Union[Variable, Value]
MarkerItem = Tuple[MarkerVar, Op, MarkerVar]
MarkerItem = tuple[MarkerVar, Op, MarkerVar]
MarkerAtom = Union[MarkerItem, Sequence["MarkerAtom"]]
MarkerList = List[Union["MarkerList", MarkerAtom, MarkerLogical]]
MarkerList = list[Union["MarkerList", MarkerAtom, MarkerLogical]]
class ParsedRequirement(NamedTuple):
@@ -264,10 +272,19 @@ def _parse_version_many(tokenizer: Tokenizer) -> str:
parsed_specifiers = ""
while tokenizer.check("SPECIFIER"):
span_start = tokenizer.position
parsed_specifiers += tokenizer.read().text
specifier = tokenizer.read().text
parsed_specifiers += specifier
if tokenizer.check("VERSION_PREFIX_TRAIL", peek=True):
message = ".* suffix can only be used with `==` or `!=` operators"
if specifier.startswith("!=") or (
specifier.startswith("==") and not specifier.startswith("===")
):
message = (
".* suffix cannot be used with pre-release, post-release, "
"dev or local versions"
)
tokenizer.raise_syntax_error(
".* suffix can only be used with `==` or `!=` operators",
message,
span_start=span_start,
span_end=tokenizer.position + 1,
)
@@ -354,7 +371,15 @@ def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar: # noqa: RET503
if tokenizer.check("VARIABLE"):
return process_env_var(tokenizer.read().text.replace(".", "_"))
elif tokenizer.check("QUOTED_STRING"):
return process_python_str(tokenizer.read().text)
token = tokenizer.read()
try:
return process_python_str(token.text)
except (SyntaxError, ValueError) as exc:
raise ParserSyntaxError(
"Invalid quoted string",
source=tokenizer.source,
span=(token.position, token.position + len(token.text)),
) from exc
else:
tokenizer.raise_syntax_error(
message="Expected a marker variable or quoted string"
+836
View File
@@ -0,0 +1,836 @@
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
"""Private version-range helpers used by :mod:`packaging.specifiers`."""
from __future__ import annotations
import enum
import functools
from typing import (
TYPE_CHECKING,
Any,
Final,
)
from .version import InvalidVersion, Version
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Iterator, Sequence
from typing import Union
# Total-order key for comparing two boundaries (boundary-vs-boundary only).
# The post slot may be ``_BOUNDARY_INF`` for an AFTER_POSTS boundary.
_BoundaryOrderSuffix = tuple[int, int, int, Union[int, float], int, int]
_BoundaryOrderKey = tuple[int, tuple[int, ...], _BoundaryOrderSuffix, float]
__all__ = [
"FULL_RANGE",
"bounds_for_spec",
"coerce_version",
"filter_by_ranges",
"intersect_ranges",
"intersect_specifier_bounds",
"least_version_above",
"matches_bounds_only",
"range_is_empty",
"ranges_are_prerelease_only",
"resolve_prereleases",
"standard_ranges",
"wildcard_ranges",
]
#: The smallest possible PEP 440 version. No valid version is less than this.
MIN_VERSION: Final[Version] = Version("0.dev0")
#: The smallest non-pre-release version, i.e. the nearest non-pre-release at or
#: above the ``-inf`` floor.
MIN_RELEASE: Final[Version] = Version("0")
#: Sorts above any real post number and any local label, so a boundary can be
#: ordered above the version family it covers when two boundaries are compared.
_BOUNDARY_INF: Final[float] = float("inf")
class BoundaryKind(enum.Enum):
"""Where a boundary marker sits in the version ordering."""
AFTER_LOCALS = enum.auto() # after V+local, before V.post0
AFTER_POSTS = enum.auto() # after V.postN, before next release
@functools.total_ordering
class BoundaryVersion:
"""A point on the version line between two real PEP 440 versions.
Relative to a base version V::
V < V+local < AFTER_LOCALS(V) < V.post0 < AFTER_POSTS(V)
AFTER_LOCALS is the upper bound of ``<=V``, ``==V``, ``!=V`` (no
local), and the lower bound of the upper-side range of ``!=V``.
AFTER_POSTS is the lower bound of ``>V`` (V final or pre-release),
excluding V's post-releases per PEP 440.
"""
__slots__ = (
"_cached_dev",
"_cached_epoch",
"_cached_post",
"_cached_pre",
"_cached_trimmed_release",
"kind",
"version",
)
def __init__(self, version: Version, kind: BoundaryKind) -> None:
self.version = version
self.kind = kind
self._cached_trimmed_release = trim_release(version.release)
self._cached_epoch = version.epoch
self._cached_pre = version.pre
self._cached_post = version.post
self._cached_dev = version.dev
def _is_family(self, other: Version) -> bool:
"""Is ``other`` a version that this boundary sorts above?"""
if other.epoch != self._cached_epoch:
return False
# Inline release-trim comparison: other.release matches the
# trimmed release iff its leading slice is equal and any extra
# components are zero. Avoids trim_release's tuple allocation.
other_release = other.release
trimmed_release = self._cached_trimmed_release
trimmed_length = len(trimmed_release)
if len(other_release) < trimmed_length:
return False
if other_release[:trimmed_length] != trimmed_release:
return False
for i in range(trimmed_length, len(other_release)):
if other_release[i] != 0:
return False
if other.pre != self._cached_pre:
return False
if self.kind == BoundaryKind.AFTER_LOCALS:
# Local family: same public version, any local label.
return other.post == self._cached_post and other.dev == self._cached_dev
# Post family: V itself + any post-release of V.
return other.dev == self._cached_dev or other.post is not None
def _order_key(self) -> _BoundaryOrderKey:
"""Sort key placing this boundary just above the versions it covers.
It extends ``V``'s comparison key ``(epoch, release, suffix)`` with
a trailing ``_BOUNDARY_INF`` local component, so the key sorts after
``V`` and every ``V+local`` (whose keys carry a real, finite local
segment). ``suffix`` is the 6-int comparison suffix
``(pre_rank, pre_n, post_rank, post_n, dev_rank, dev_n)``.
For an AFTER_POSTS boundary the suffix is replaced with one whose
post number is ``_BOUNDARY_INF``, so the key also sorts after every
``V.postN``. An AFTER_LOCALS boundary uses ``V``'s suffix unchanged.
"""
version_key = self.version._key
suffix: _BoundaryOrderSuffix = version_key[2]
if self.kind == BoundaryKind.AFTER_POSTS:
suffix = (suffix[0], suffix[1], 1, _BOUNDARY_INF, 1, 0)
return version_key[0], version_key[1], suffix, _BOUNDARY_INF
def __eq__(self, other: object) -> bool:
# Key off the order key so equality matches the ``<`` / ``>`` order:
# ``AFTER_POSTS(1.0)`` and ``AFTER_POSTS(1.0.post1)`` are the same point.
if isinstance(other, BoundaryVersion):
return self._order_key() == other._order_key()
return NotImplemented
def __lt__(self, other: BoundaryVersion | Version) -> bool:
if isinstance(other, BoundaryVersion):
return self._order_key() < other._order_key()
# boundary < other_version iff V < other AND other not in family.
# The cheap V >= other path short-circuits before the family check.
if not (self.version < other):
return False
return not self._is_family(other)
def __gt__(self, other: BoundaryVersion | Version) -> bool:
# Defined directly to bypass functools.total_ordering's
# NotImplemented round-trip on reflected ``Version < boundary``.
if isinstance(other, BoundaryVersion):
return self._order_key() > other._order_key()
if self.version >= other:
return True
return self._is_family(other)
def __hash__(self) -> int:
# Keyed to ``__eq__`` (the order key), so equal boundaries hash equal.
return hash(self._order_key())
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.version!r}, {self.kind.name})"
if TYPE_CHECKING:
_VersionOrBoundary = Union[Version, BoundaryVersion, None]
@functools.total_ordering
class LowerBound:
"""Lower bound of a version range.
A version *v* of ``None`` means unbounded below (-inf).
At equal versions, ``[v`` sorts before ``(v`` because an inclusive
bound starts earlier.
"""
__slots__ = ("_above", "inclusive", "version")
def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None:
self.version = version
self.inclusive = inclusive
# Pre-bind a predicate "is parsed at or above this lower
# bound?" for the hot filter / contains loops. One direct
# call per check, no operator-dispatch chain.
if version is None:
self._above: Callable[[Version], bool] | None = None
elif isinstance(version, BoundaryVersion):
# >V produces an AFTER_POSTS lower bound; the upper-side
# range of !=V produces an AFTER_LOCALS lower bound.
if version.kind == BoundaryKind.AFTER_POSTS:
self._above = _make_above_after_posts(version.version)
else:
self._above = _make_above_after_locals(version.version)
elif inclusive:
self._above = version.__le__
else:
self._above = version.__lt__
def __eq__(self, other: object) -> bool:
if not isinstance(other, LowerBound):
return NotImplemented
return self.version == other.version and self.inclusive == other.inclusive
def __lt__(self, other: LowerBound) -> bool:
if not isinstance(other, LowerBound):
return NotImplemented
# -inf < anything (except -inf itself).
if self.version is None:
return other.version is not None
if other.version is None:
return False
if self.version != other.version:
return self.version < other.version
# [v < (v: inclusive starts earlier.
return self.inclusive and not other.inclusive
def __hash__(self) -> int:
return hash((self.version, self.inclusive))
def __repr__(self) -> str:
bracket = "[" if self.inclusive else "("
return f"<{self.__class__.__name__} {bracket}{self.version!r}>"
@functools.total_ordering
class UpperBound:
"""Upper bound of a version range.
A version *v* of ``None`` means unbounded above (+inf).
At equal versions, ``v)`` sorts before ``v]`` because an exclusive
bound ends earlier.
"""
__slots__ = ("_below", "inclusive", "version")
def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None:
self.version = version
self.inclusive = inclusive
# Pre-bind a predicate "is parsed at or below this upper
# bound?". See LowerBound for the rationale.
if version is None:
self._below: Callable[[Version], bool] | None = None
elif isinstance(version, BoundaryVersion):
# Standard specifiers only ever produce AFTER_LOCALS upper
# bounds (from <=V / ==V / !=V with no local).
if version.kind == BoundaryKind.AFTER_LOCALS:
self._below = _make_below_after_locals(version.version)
else:
# An AFTER_POSTS upper is not produced by any specifier, but
# range algebra reaches it: complementing ``>V`` flips the
# ``AFTER_POSTS(V)`` lower into this upper bound.
self._below = version.__ge__
elif inclusive:
self._below = version.__ge__
else:
self._below = version.__gt__
def __eq__(self, other: object) -> bool:
if not isinstance(other, UpperBound):
return NotImplemented
return self.version == other.version and self.inclusive == other.inclusive
def __lt__(self, other: UpperBound) -> bool:
if not isinstance(other, UpperBound):
return NotImplemented
# Nothing < +inf (except +inf itself).
if self.version is None:
return False
if other.version is None:
return True
if self.version != other.version:
return self.version < other.version
# v) < v]: exclusive ends earlier.
return not self.inclusive and other.inclusive
def __hash__(self) -> int:
return hash((self.version, self.inclusive))
def __repr__(self) -> str:
bracket = "]" if self.inclusive else ")"
return f"<{self.__class__.__name__} {self.version!r}{bracket}>"
if TYPE_CHECKING:
#: A single contiguous interval as a (lower, upper) bound pair.
Interval = tuple[LowerBound, UpperBound]
NEG_INF: Final[LowerBound] = LowerBound(None, False)
POS_INF: Final[UpperBound] = UpperBound(None, False)
FULL_RANGE: Final[tuple[Interval]] = ((NEG_INF, POS_INF),)
def trim_release(release: tuple[int, ...]) -> tuple[int, ...]:
"""Strip trailing zeros from a release tuple for normalized comparison."""
end = len(release)
while end > 1 and release[end - 1] == 0:
end -= 1
return release if end == len(release) else release[:end]
def _next_prefix_dev0(version: Version) -> Version:
"""Smallest version in the next prefix: 1.2 -> 1.3.dev0."""
release = (*version.release[:-1], version.release[-1] + 1)
return Version.from_parts(epoch=version.epoch, release=release, dev=0)
def _base_dev0(version: Version) -> Version:
"""The .dev0 of a version's base release: 1.2 -> 1.2.dev0."""
return Version.from_parts(epoch=version.epoch, release=version.release, dev=0)
def coerce_version(version: Version | str) -> Version | None:
if not isinstance(version, Version):
try:
version = Version(version)
except InvalidVersion:
return None
return version
def _make_above_after_posts(version: Version) -> Callable[[Version], bool]:
"""Predicate ``parsed > AFTER_POSTS(V)`` for a lower bound.
Per PEP 440, ``>V`` excludes V's post-releases unless V is itself
a post-release. AFTER_POSTS sits above V and every V.postN (with
or without local), and just below the next release.
"""
version_ge = version.__ge__
version_epoch = version.epoch
version_pre = version.pre
version_release_trimmed = trim_release(version.release)
trimmed_length = len(version_release_trimmed)
def above(parsed: Version) -> bool:
if version_ge(parsed):
return False
# parsed > V cmpkey-wise: above the boundary iff NOT in V's
# post family.
if parsed.epoch != version_epoch:
return True
parsed_release = parsed.release
if len(parsed_release) < trimmed_length:
return True
if parsed_release[:trimmed_length] != version_release_trimmed:
return True
for i in range(trimmed_length, len(parsed_release)):
if parsed_release[i] != 0:
return True
if parsed.pre != version_pre:
return True
# Same release and pre as V: parsed is in V's post family (V itself,
# V+local, or V.postN), which the boundary sits above. A V.devN
# (different dev, no post) sorts before V and was already caught by
# ``version_ge`` above, so the answer here is always "not above".
return False
return above
def _make_above_after_locals(version: Version) -> Callable[[Version], bool]:
"""Predicate ``parsed > AFTER_LOCALS(V)`` for a lower bound.
Used by the upper-side range of ``!=V`` (when V has no local
segment). AFTER_LOCALS sits above V and every ``V+local`` but
just below ``V.post0``.
"""
version_ge = version.__ge__
version_epoch = version.epoch
version_pre = version.pre
version_post = version.post
version_dev = version.dev
version_release_trimmed = trim_release(version.release)
trimmed_length = len(version_release_trimmed)
def above(parsed: Version) -> bool:
if version_ge(parsed):
return False
# parsed > V cmpkey-wise: above the boundary iff NOT in V's
# local family (same public version, any local segment).
if parsed.epoch != version_epoch:
return True
parsed_release = parsed.release
if len(parsed_release) < trimmed_length:
return True
if parsed_release[:trimmed_length] != version_release_trimmed:
return True
for i in range(trimmed_length, len(parsed_release)):
if parsed_release[i] != 0:
return True
if parsed.pre != version_pre:
return True
if parsed.post != version_post:
return True
return parsed.dev != version_dev
return above
def _make_below_after_locals(version: Version) -> Callable[[Version], bool]:
"""Predicate ``parsed <= AFTER_LOCALS(V)`` for an upper bound.
Used by ``<=V``, ``==V``, ``!=V`` (no local). ``parsed`` is at or
below the boundary when it is at or below V cmpkey-wise, or when
it is in V's local family.
"""
version_ge = version.__ge__
version_epoch = version.epoch
version_pre = version.pre
version_post = version.post
version_dev = version.dev
version_release_trimmed = trim_release(version.release)
trimmed_length = len(version_release_trimmed)
def below(parsed: Version) -> bool:
if version_ge(parsed):
return True
# parsed > V cmpkey-wise: below the boundary iff in V's local
# family.
if parsed.epoch != version_epoch:
return False
parsed_release = parsed.release
if len(parsed_release) < trimmed_length:
return False
if parsed_release[:trimmed_length] != version_release_trimmed:
return False
for i in range(trimmed_length, len(parsed_release)):
if parsed_release[i] != 0:
return False
if parsed.pre != version_pre:
return False
if parsed.post != version_post:
return False
return parsed.dev == version_dev
return below
def least_version_above(boundary: BoundaryVersion) -> Version | None:
"""Smallest real version strictly above *boundary*, or ``None`` if none exists."""
base = boundary.version
if boundary.kind == BoundaryKind.AFTER_LOCALS:
# AFTER_LOCALS(V) sits just below V.post0, so its least successor is
# V.post0.dev0 (V.dev(N+1) if V has a dev, V.post(N+1).dev0 if a post).
if base.dev is not None:
return base.__replace__(dev=base.dev + 1, local=None)
next_post = (base.post + 1) if base.post is not None else 0
return base.__replace__(post=next_post, dev=0, local=None)
# AFTER_POSTS(V): a pre-release V steps to the next pre-release's .dev0;
# a final-release AFTER_POSTS has no least successor.
if base.pre is not None:
kind, number = base.pre
return base.__replace__(pre=(kind, number + 1), post=None, dev=0, local=None)
return None
def range_is_empty(lower: LowerBound, upper: UpperBound) -> bool:
"""True when the range defined by *lower* and *upper* contains no versions.
A boundary lower sits just below the next real version, so an ordered pair
is still empty when the upper excludes that least successor:
``(AFTER_POSTS(1.0a1), 1.0a2.dev0)`` holds no version.
"""
if upper.version is None:
return False
if lower.version is None:
# Nothing sorts below MIN_VERSION, so an exclusive upper at or below it
# leaves an empty floor interval such as ``(-inf, 0.dev0)``.
return (
not upper.inclusive
and isinstance(upper.version, Version)
and upper.version <= MIN_VERSION
)
if isinstance(lower.version, BoundaryVersion):
successor = least_version_above(lower.version)
if successor is not None:
if upper.version == successor:
return not upper.inclusive
return upper.version < successor
if lower.version == upper.version:
return not (lower.inclusive and upper.inclusive)
return lower.version > upper.version
def intersect_ranges(
left: Sequence[Interval],
right: Sequence[Interval],
) -> list[Interval]:
"""Intersect two sorted, non-overlapping range lists (two-pointer merge)."""
result: list[Interval] = []
left_index = right_index = 0
while left_index < len(left) and right_index < len(right):
left_lower, left_upper = left[left_index]
right_lower, right_upper = right[right_index]
lower = max(left_lower, right_lower)
upper = min(left_upper, right_upper)
if not range_is_empty(lower, upper):
result.append((lower, upper))
# Advance whichever side has the smaller upper bound.
if left_upper < right_upper:
left_index += 1
else:
right_index += 1
return result
def filter_by_ranges(
ranges: Sequence[Interval],
iterable: Iterable[Any],
key: Callable[[Any], Version | str] | None,
prereleases: bool | None,
region: Sequence[Interval] = (),
) -> Iterator[Any]:
"""Filter *iterable* against precomputed version *ranges*.
With ``prereleases=None``, the PEP 440 default applies: pre-releases are
excluded unless no final matches, in which case buffered pre-releases come
out at the end. A pre-release inside the opt-in ``region`` is the exception:
it is force-admitted in place, as ``prereleases=True`` would yield it. A
force-admitted pre-release is not a final, so it never suppresses the buffer.
"""
if prereleases is None:
prerelease_buffer: list[Any] = []
found_final = False
if len(ranges) == 1:
# Hot path: most specifiers and small SpecifierSets reduce to
# a single contiguous range.
lower, upper = ranges[0]
above = lower._above
below = upper._below
for item in iterable:
parsed = coerce_version(item if key is None else key(item))
if parsed is None:
continue
if above is not None and not above(parsed):
continue
if below is not None and not below(parsed):
continue
if not parsed.is_prerelease:
found_final = True
yield item
elif region and matches_bounds_only(region, parsed):
yield item
elif not found_final:
prerelease_buffer.append(item)
if not found_final:
yield from prerelease_buffer
return
for item in iterable:
parsed = coerce_version(item if key is None else key(item))
if parsed is None:
continue
for lower, upper in ranges:
above = lower._above
if above is not None and not above(parsed):
break
below = upper._below
if below is None or below(parsed):
if not parsed.is_prerelease:
found_final = True
yield item
elif region and matches_bounds_only(region, parsed):
yield item
elif not found_final:
prerelease_buffer.append(item)
break
if not found_final:
yield from prerelease_buffer
return
exclude_prereleases = prereleases is False
if len(ranges) == 1:
# Hot path: most specifiers and small SpecifierSets reduce to
# a single contiguous range.
lower, upper = ranges[0]
above = lower._above
below = upper._below
for item in iterable:
parsed = coerce_version(item if key is None else key(item))
if parsed is None:
continue
if exclude_prereleases and parsed.is_prerelease:
continue
if above is not None and not above(parsed):
continue
if below is None or below(parsed):
yield item
return
for item in iterable:
parsed = coerce_version(item if key is None else key(item))
if parsed is None:
continue
if exclude_prereleases and parsed.is_prerelease:
continue
for lower, upper in ranges:
above = lower._above
if above is not None and not above(parsed):
break
below = upper._below
if below is None or below(parsed):
yield item
break
def _nearest_release_above_prerelease(version: Version) -> Version:
"""Smallest non-pre-release at or above a pre-release *version*."""
if version.pre is not None:
# An a/b/rc pre-release drops to its final release, which outranks
# every post-release of that pre-release (1.0a1.post0 -> 1.0).
return version.__replace__(pre=None, post=None, dev=None, local=None)
# A dev-only release keeps its post-release (1.0.post0.dev0 -> 1.0.post0,
# whose final 1.0 sorts below it).
return version.__replace__(dev=None, local=None)
def _lowest_release_at_or_above(value: Version | BoundaryVersion | None) -> Version:
"""Smallest non-pre-release version at or above *value*.
``None`` is the ``-inf`` floor, whose nearest non-pre-release is
:data:`MIN_RELEASE`.
"""
if value is None:
return MIN_RELEASE
if isinstance(value, BoundaryVersion):
inner_version = value.version
if inner_version.is_prerelease:
return _nearest_release_above_prerelease(inner_version)
# AFTER_LOCALS(1.0) -> nearest non-pre is 1.0.post0
# AFTER_LOCALS(1.0.post0) -> nearest non-pre is 1.0.post1
next_post = (inner_version.post + 1) if inner_version.post is not None else 0
return inner_version.__replace__(post=next_post, local=None)
if not value.is_prerelease:
return value
return _nearest_release_above_prerelease(value)
def ranges_are_prerelease_only(ranges: Sequence[Interval]) -> bool:
"""True when every range in *ranges* contains only pre-releases.
Used to detect unsatisfiable specifier sets when ``prereleases=False``:
if every range is pre-release-only, every contained version is excluded.
"""
for lower, upper in ranges:
nearest = _lowest_release_at_or_above(lower.version)
if upper.version is None or nearest < upper.version:
return False
if nearest == upper.version and upper.inclusive:
return False
return True
def wildcard_ranges(op: str, base: Version) -> list[Interval]:
"""Ranges for ==V.* and !=V.*.
==1.2.* -> [1.2.dev0, 1.3.dev0); !=1.2.* -> complement.
"""
lower = _base_dev0(base)
upper = _next_prefix_dev0(base)
if op == "==":
return [(LowerBound(lower, True), UpperBound(upper, False))]
# !=
return [
(NEG_INF, UpperBound(lower, False)),
(LowerBound(upper, True), POS_INF),
]
def standard_ranges(op: str, version: Version, has_local: bool) -> list[Interval]:
"""Ranges for the standard PEP 440 operators (no wildcard, no ===).
*has_local* indicates whether the spec string included a ``+local``
segment; relevant only for ``==`` / ``!=`` to decide whether the
upper bound includes V's local family.
"""
if op == ">=":
return [(LowerBound(version, True), POS_INF)]
if op == "<=":
return [
(
NEG_INF,
UpperBound(BoundaryVersion(version, BoundaryKind.AFTER_LOCALS), True),
)
]
if op == ">":
if version.dev is not None:
# >V.devN: dev versions have no post-releases, so the
# next real version is V.dev(N+1).
lower_bound = version.__replace__(dev=version.dev + 1, local=None)
return [(LowerBound(lower_bound, True), POS_INF)]
if version.post is not None:
# >V.postN: next real version is V.post(N+1).dev0.
lower_bound = version.__replace__(post=version.post + 1, dev=0, local=None)
return [(LowerBound(lower_bound, True), POS_INF)]
# >V (final or pre-release V): exclude V itself, V+local, and
# every V.postN per PEP 440.
return [
(
LowerBound(BoundaryVersion(version, BoundaryKind.AFTER_POSTS), False),
POS_INF,
)
]
if op == "<":
# <V excludes pre-releases of V when V is not a pre-release.
# V.dev0 is the earliest pre-release of V.
bound = (
version if version.is_prerelease else version.__replace__(dev=0, local=None)
)
if bound <= MIN_VERSION:
return []
return [(NEG_INF, UpperBound(bound, False))]
# ==, !=: local versions of V match when the spec has no local segment.
after_locals = BoundaryVersion(version, BoundaryKind.AFTER_LOCALS)
upper = version if has_local else after_locals
if op == "==":
return [(LowerBound(version, True), UpperBound(upper, True))]
if op == "!=":
return [
(NEG_INF, UpperBound(version, False)),
(LowerBound(upper, False), POS_INF),
]
if op == "~=":
prefix = version.__replace__(release=version.release[:-1])
return [
(LowerBound(version, True), UpperBound(_next_prefix_dev0(prefix), False))
]
raise ValueError(f"Unknown operator: {op!r}") # pragma: no cover
def bounds_for_spec(op: str, version_str: str, version: Version) -> list[Interval]:
"""Ranges for one specifier's ``(op, version_str)``.
Dispatches between the wildcard and standard builders. ``version`` is the
parsed ``version_str`` (its base, without the trailing ``.*``, for
wildcards). ``===`` is not handled here; its match is a literal string
compared in :mod:`packaging.specifiers`.
"""
if version_str.endswith(".*"):
return wildcard_ranges(op, version)
return standard_ranges(op, version, "+" in version_str)
def intersect_specifier_bounds(
per_specifier_ranges: Iterable[Sequence[Interval]],
) -> Sequence[Interval]:
"""Intersect each specifier's ranges into a single sequence.
Short-circuits once the running intersection is empty, since no later
specifier can revive it. Callers must pass at least one specifier.
"""
result: Sequence[Interval] | None = None
for sub in per_specifier_ranges:
if result is None:
result = sub
else:
result = intersect_ranges(result, sub)
if not result:
break
if result is None: # pragma: no cover - callers guard non-empty input
raise RuntimeError("intersect_specifier_bounds called with no specifiers")
return result
def matches_bounds_only(ranges: Sequence[Interval], version: Version) -> bool:
"""Whether ``version`` falls within any of ``ranges``.
The pure bounds membership test, for a single already-parsed version with
no pre-release policy applied. ``ranges`` are sorted and non-overlapping,
so a version below one range's lower bound is below every later range too.
"""
for lower, upper in ranges:
above = lower._above
if above is not None and not above(version):
return False
below = upper._below
if below is None or below(version):
return True
return False
def resolve_prereleases(
configured: bool | None, autodetected: bool | None
) -> bool | None:
"""Resolve a specifier's effective default pre-release policy.
An explicit ``configured`` value wins; otherwise an autodetected ``True``
propagates and anything else falls back to the PEP 440 default (``None``).
"""
if configured is not None:
return configured
if autodetected:
return True
return None
+10 -3
View File
@@ -3,13 +3,18 @@ from __future__ import annotations
import contextlib
import re
from dataclasses import dataclass
from typing import Generator, Mapping, NoReturn
from typing import TYPE_CHECKING, NoReturn
from .specifiers import Specifier
if TYPE_CHECKING:
from collections.abc import Generator, Mapping
@dataclass
class Token:
__slots__ = ("name", "position", "text")
name: str
text: str
position: int
@@ -84,7 +89,7 @@ DEFAULT_RULES: dict[str, re.Pattern[str]] = {
"VERSION_PREFIX_TRAIL": re.compile(r"\.\*"),
"VERSION_LOCAL_LABEL_TRAIL": re.compile(r"\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*"),
"WS": re.compile(r"[ \t]+"),
"END": re.compile(r"$"),
"END": re.compile(r"\Z"),
}
@@ -95,6 +100,8 @@ class Tokenizer:
matches.
"""
__slots__ = ("next_token", "position", "rules", "source")
def __init__(
self,
source: str,
@@ -135,7 +142,7 @@ class Tokenizer:
def expect(self, name: str, *, expected: str) -> Token:
"""Expect a certain token name next, failing with a syntax error otherwise.
The token is *not* read.
The token is read and returned.
"""
if not self.check(name):
raise self.raise_syntax_error(f"Expected {expected}")
+38 -5
View File
@@ -4,7 +4,7 @@ import re
from collections.abc import Mapping, Sequence
from .errors import _ErrorCollector
from .requirements import Requirement
from .requirements import InvalidRequirement, Requirement
__all__ = [
"CyclicDependencyGroup",
@@ -28,12 +28,16 @@ def __dir__() -> list[str]:
class DuplicateGroupNames(ValueError):
"""
The same dependency groups were defined twice, with different non-normalized names.
.. versionadded:: 26.1
"""
class CyclicDependencyGroup(ValueError):
"""
The dependency group includes form a cycle.
.. versionadded:: 26.1
"""
def __init__(self, requested_group: str, group: str, include_group: str) -> None:
@@ -50,6 +54,10 @@ class CyclicDependencyGroup(ValueError):
f"{requested_group}: {reason}"
)
# Support pickling; ``args`` does not match ``__init__``'s signature.
def __reduce__(self) -> tuple[type[CyclicDependencyGroup], tuple[str, str, str]]:
return (self.__class__, (self.requested_group, self.group, self.include_group))
# 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
@@ -58,6 +66,8 @@ class InvalidDependencyGroupObject(ValueError):
"""
A member of a dependency group was identified as a dict, but was not in a valid
format.
.. versionadded:: 26.1
"""
@@ -67,6 +77,12 @@ class InvalidDependencyGroupObject(ValueError):
class DependencyGroupInclude:
"""
A reference to another dependency group by name.
.. versionadded:: 26.1
"""
__slots__ = ("include_group",)
def __init__(self, include_group: str) -> None:
@@ -91,6 +107,8 @@ class DependencyGroupResolver:
:param dependency_groups: A mapping, as provided via pyproject
``[dependency-groups]``.
.. versionadded:: 26.1
"""
def __init__(
@@ -227,9 +245,10 @@ class DependencyGroupResolver:
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))
# valid PEP 508 Dependency Specifier. Collect InvalidRequirement
# if it throws that.
with errors.collect(InvalidRequirement):
elements.append(Requirement(item))
elif isinstance(item, Mapping):
if tuple(item.keys()) != ("include-group",):
errors.error(
@@ -239,10 +258,22 @@ class DependencyGroupResolver:
)
else:
include_group = item["include-group"]
elements.append(DependencyGroupInclude(include_group=include_group))
if not isinstance(include_group, str):
msg = (
"Dependency group include-group value is not a string: "
f"{item!r}"
)
errors.error(TypeError(msg))
else:
elements.append(
DependencyGroupInclude(include_group=include_group)
)
else:
errors.error(TypeError(f"Invalid dependency group item: {item!r}"))
if errors.errors:
return ()
self._parsed_groups[group] = tuple(elements)
return self._parsed_groups[group]
@@ -261,6 +292,8 @@ def resolve_dependency_groups(
:param dependency_groups: the parsed contents of the ``[dependency-groups]`` table
from ``pyproject.toml``
:param groups: the name of the group(s) to resolve
.. versionadded:: 26.1
"""
resolver = DependencyGroupResolver(dependency_groups)
return tuple(str(r) for group in groups for r in resolver.resolve(group))
+32 -8
View File
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Protocol, TypeVar
if TYPE_CHECKING: # pragma: no cover
import sys
from collections.abc import Collection
from urllib.parse import SplitResult
if sys.version_info >= (3, 11):
from typing import Self
@@ -83,7 +84,7 @@ _PEP610_USER_PASS_ENV_VARS_REGEX = re.compile(
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)
user_pass, netloc_no_user_pass = netloc.rsplit("@", 1)
if user_pass in safe_user_passwords:
return netloc
if _PEP610_USER_PASS_ENV_VARS_REGEX.match(user_pass):
@@ -109,8 +110,15 @@ def _strip_url(url: str, safe_user_passwords: Collection[str]) -> str:
)
def _file_url_has_absolute_path(parsed_url: SplitResult) -> bool:
return parsed_url.path.startswith("/")
class DirectUrlValidationError(Exception):
"""Raised when when input data is not spec-compliant."""
"""Raised when when input data is not spec-compliant.
.. versionadded:: 26.1
"""
context: str | None = None
message: str
@@ -146,6 +154,8 @@ class _DirectUrlRequiredKeyError(DirectUrlValidationError):
@dataclasses.dataclass(frozen=True, init=False)
class VcsInfo:
"""The version control information of a :class:`DirectUrl`."""
vcs: str
commit_id: str
requested_revision: str | None = None
@@ -173,6 +183,8 @@ class VcsInfo:
@dataclasses.dataclass(frozen=True, init=False)
class ArchiveInfo:
"""The archive information of a :class:`DirectUrl`."""
hashes: Mapping[str, str] | None = None
def __init__(
@@ -219,6 +231,8 @@ class ArchiveInfo:
@dataclasses.dataclass(frozen=True, init=False)
class DirInfo:
"""The local directory information of a :class:`DirectUrl`."""
editable: bool | None = None
def __init__(
@@ -237,7 +251,10 @@ class DirInfo:
@dataclasses.dataclass(frozen=True, init=False)
class DirectUrl:
"""A class representing a direct URL."""
"""A class representing a direct URL.
.. versionadded:: 26.1
"""
url: str
archive_info: ArchiveInfo | None = None
@@ -277,11 +294,18 @@ class DirectUrl:
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",
)
if direct_url.dir_info is not None:
parsed_url = urllib.parse.urlsplit(direct_url.url)
if parsed_url.scheme != "file":
raise DirectUrlValidationError(
"URL scheme must be file:// when dir_info is present",
context="url",
)
if not _file_url_has_absolute_path(parsed_url):
raise DirectUrlValidationError(
"File URL must be absolute when dir_info is present",
context="url",
)
# XXX subdirectory must be relative, can we, should we validate that here?
return direct_url
+18 -6
View File
@@ -48,7 +48,7 @@ def __dir__() -> list[str]:
return __all__
license_ref_allowed = re.compile("^[A-Za-z0-9.-]*$")
license_ref_allowed = re.compile("^[A-Za-z0-9.-]+$")
NormalizedLicenseExpression = NewType("NormalizedLicenseExpression", str)
"""
@@ -64,7 +64,7 @@ class InvalidLicenseExpression(ValueError):
>>> canonicalize_license_expression("invalid")
Traceback (most recent call last):
...
packaging.licenses.InvalidLicenseExpression: Invalid license expression: 'invalid'
packaging.licenses.InvalidLicenseExpression: Unknown license: 'invalid'
"""
@@ -148,9 +148,18 @@ def canonicalize_license_expression(
# Take a final pass to check for unknown licenses/exceptions.
normalized_tokens = []
for token in tokens:
last_license_start = False
for index, token in enumerate(tokens):
if token in {"or", "and", "with", "(", ")"}:
if token == "with" and (
not last_license_start
or index + 1 == len(tokens)
or tokens[index + 1] in {"or", "and", "with", "(", ")"}
):
message = f"Invalid license expression: {raw_license_expression!r}"
raise InvalidLicenseExpression(message)
normalized_tokens.append(token.upper())
last_license_start = False
continue
if normalized_tokens and normalized_tokens[-1] == "WITH":
@@ -159,6 +168,7 @@ def canonicalize_license_expression(
raise InvalidLicenseExpression(message)
normalized_tokens.append(EXCEPTIONS[token]["id"])
last_license_start = False
else:
if token.endswith("+"):
final_token = token[:-1]
@@ -168,15 +178,17 @@ def canonicalize_license_expression(
suffix = ""
if final_token.startswith("licenseref-"):
if not license_ref_allowed.match(final_token):
message = f"Invalid licenseref: {final_token!r}"
license_ref_id = final_token[len("licenseref-") :]
if suffix or not license_ref_allowed.match(license_ref_id):
message = f"Invalid licenseref: {token!r}"
raise InvalidLicenseExpression(message)
normalized_tokens.append(license_refs[final_token] + suffix)
normalized_tokens.append(license_refs[final_token])
else:
if final_token not in LICENSES:
message = f"Unknown license: {final_token!r}"
raise InvalidLicenseExpression(message)
normalized_tokens.append(LICENSES[final_token]["id"] + suffix)
last_license_start = True
normalized_expression = " ".join(normalized_tokens)
+106 -22
View File
@@ -4,11 +4,13 @@
from __future__ import annotations
import functools
import operator
import os
import platform
import sys
from typing import AbstractSet, Callable, Literal, Mapping, TypedDict, Union, cast
from collections.abc import Set as AbstractSet
from typing import TYPE_CHECKING, Callable, Literal, TypedDict, Union, cast
from ._parser import MarkerAtom, MarkerList, Op, Value, Variable
from ._parser import parse_marker as _parse_marker
@@ -16,6 +18,9 @@ from ._tokenizer import ParserSyntaxError
from .specifiers import InvalidSpecifier, Specifier
from .utils import canonicalize_name
if TYPE_CHECKING:
from collections.abc import Mapping
__all__ = [
"Environment",
"EvaluateContext",
@@ -40,6 +45,8 @@ 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)
.. versionadded:: 25.0
"""
MARKERS_ALLOWING_SET = {"extras", "dependency_groups"}
@@ -68,8 +75,17 @@ class UndefinedComparison(ValueError):
"""
class UndefinedEnvironmentName(ValueError):
"""Raised when evaluating a marker that references a missing environment key."""
class UndefinedEnvironmentName(KeyError):
"""Raised when evaluating a marker that references a missing environment key.
Subclasses :class:`KeyError` so that code catching the bare ``KeyError`` that
a missing environment lookup historically produced keeps working.
.. versionchanged:: 26.3
Now subclasses :class:`KeyError` (was :class:`ValueError`) and is raised by
:meth:`Marker.evaluate` for missing environment keys, where a bare
``KeyError`` was raised before.
"""
class Environment(TypedDict):
@@ -152,16 +168,30 @@ class Environment(TypedDict):
def _normalize_extras(
result: MarkerList | MarkerAtom | str,
) -> MarkerList | MarkerAtom | str:
if isinstance(result, list):
return [_normalize_extras(r) for r in result]
if not isinstance(result, tuple):
return result
lhs, op, rhs = result
if isinstance(lhs, Variable) and lhs.value == "extra":
if isinstance(lhs, Variable) and lhs.value == "extra" and isinstance(rhs, Value):
normalized_extra = canonicalize_name(rhs.value)
rhs = Value(normalized_extra)
elif isinstance(rhs, Variable) and rhs.value == "extra":
elif isinstance(rhs, Variable) and rhs.value == "extra" and isinstance(lhs, Value):
normalized_extra = canonicalize_name(lhs.value)
lhs = Value(normalized_extra)
elif (
isinstance(rhs, Variable)
and rhs.value in MARKERS_ALLOWING_SET
and isinstance(lhs, Value)
):
# PEP 685 (extras) / PEP 735 (dependency_groups): the set-valued membership
# literal must also be normalized. evaluate() already canonicalizes both
# operands for these keys (see _normalize), so normalizing the literal at
# parse time keeps __str__/__eq__/__hash__ consistent with evaluate() -- e.g.
# Marker('"Foo" in extras') and Marker('"foo" in extras') must compare and
# hash equal (the membership variable is always the right-hand operand).
lhs = Value(canonicalize_name(lhs.value))
return lhs, op, rhs
@@ -178,16 +208,14 @@ def _format_marker(
) -> str:
assert isinstance(marker, (list, tuple, str))
# Sometimes we have a structure like [[...]] which is a single item list
# where the single item is itself it's own list. In that case we want skip
# the rest of this function so that we don't get extraneous () on the
# outside.
# Unwrap a redundant [[...]] wrapper, but keep the nesting context so a
# nested group keeps the parentheses its and/or precedence needs.
if (
isinstance(marker, list)
and len(marker) == 1
and isinstance(marker[0], (list, tuple))
):
return _format_marker(marker[0])
return _format_marker(marker[0], first=first)
if isinstance(marker, list):
inner = (_format_marker(m, first=False) for m in marker)
@@ -251,6 +279,15 @@ def _normalize(
return lhs, rhs
def _lookup_environment(
environment: dict[str, str | AbstractSet[str]], key: str
) -> str | AbstractSet[str]:
try:
return environment[key]
except KeyError:
raise UndefinedEnvironmentName(key) from None
def _evaluate_markers(
markers: MarkerList, environment: dict[str, str | AbstractSet[str]]
) -> bool:
@@ -264,14 +301,20 @@ def _evaluate_markers(
if isinstance(lhs, Variable):
environment_key = lhs.value
lhs_value = environment[environment_key]
lhs_value = _lookup_environment(environment, environment_key)
rhs_value = rhs.value
else:
lhs_value = lhs.value
environment_key = rhs.value
rhs_value = environment[environment_key]
rhs_value = _lookup_environment(environment, environment_key)
assert isinstance(lhs_value, str), "lhs must be a string"
if not isinstance(lhs_value, str):
raise UndefinedComparison(
f"Set-valued marker {environment_key!r} can only be used "
f'with the membership form (e.g. "<name>" in '
f"{environment_key}); it cannot appear on the left-hand "
f"side of {op.serialize()!r}."
)
lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key)
groups[-1].append(_eval_op(lhs_value, op, rhs_value, key=environment_key))
elif marker == "or":
@@ -292,10 +335,14 @@ def _format_full_version(info: sys._version_info) -> str:
return version
def default_environment() -> Environment:
"""Return the default marker environment for the current Python process.
@functools.cache
def _cached_default_environment() -> Environment:
"""Build the default marker environment for the current Python process.
This is the base environment used by :meth:`Marker.evaluate`.
The values are derived from process-constant data (the running interpreter
and the host platform), so this is cached and built only once. The result is
shared between callers and must never be mutated; :func:`default_environment`
returns a fresh copy.
"""
iver = _format_full_version(sys.implementation.version)
implementation_name = sys.implementation.name
@@ -314,6 +361,22 @@ def default_environment() -> Environment:
}
def default_environment() -> Environment:
"""Return the default marker environment for the current Python process.
This is the base environment used by :meth:`Marker.evaluate`. A fresh copy
is returned on every call so callers may freely mutate the result; a shallow
copy suffices because all values are immutable strings.
.. versionchanged:: 26.3
The environment is computed once per process and cached, since it is
derived from process-constant data. Patching ``platform``/``sys``/``os``
after the first call has no effect; pass an explicit ``environment`` to
:meth:`Marker.evaluate` to evaluate against different values.
"""
return cast("Environment", dict(_cached_default_environment()))
class Marker:
"""Represents a parsed dependency marker expression.
@@ -421,11 +484,19 @@ class Marker:
raise TypeError(f"Cannot restore Marker from {state!r}")
def __and__(self, other: Marker) -> Marker:
"""Combine this marker with another using ``and``.
.. versionadded:: 26.1
"""
if not isinstance(other, Marker):
return NotImplemented
return self._from_markers([self._markers, "and", other._markers])
def __or__(self, other: Marker) -> Marker:
"""Combine this marker with another using ``or``.
.. versionadded:: 26.1
"""
if not isinstance(other, Marker):
return NotImplemented
return self._from_markers([self._markers, "or", other._markers])
@@ -454,19 +525,23 @@ class Marker:
is missing from the evaluation environment.
:returns: ``True`` if the marker matches, otherwise ``False``.
.. versionchanged:: 25.0
Added the ``context`` parameter, which influences which marker names
are considered valid.
"""
current_environment = cast(
"dict[str, str | AbstractSet[str]]", default_environment()
)
if context == "lock_file":
current_environment.update(
extras=frozenset(), dependency_groups=frozenset()
)
current_environment |= {
"extras": frozenset(),
"dependency_groups": frozenset(),
}
elif context == "metadata":
current_environment["extra"] = ""
if environment is not None:
current_environment.update(environment)
current_environment |= environment
if "extra" in current_environment:
# The API used to allow setting extra to None. We need to handle
# this case for backwards compatibility. Also skip running
@@ -479,6 +554,16 @@ class Marker:
)
def _pep440_python_full_version(python_full_version: str) -> str:
"""
Work around platform.python_version() returning something that is not PEP 440
compliant for non-tagged Python builds.
"""
if python_full_version.endswith("+"):
return f"{python_full_version}local"
return python_full_version
def _repair_python_full_version(
env: dict[str, str | AbstractSet[str]],
) -> dict[str, str | AbstractSet[str]]:
@@ -487,6 +572,5 @@ def _repair_python_full_version(
compliant for non-tagged Python builds.
"""
python_full_version = cast("str", env["python_full_version"])
if python_full_version.endswith("+"):
env["python_full_version"] = f"{python_full_version}local"
env["python_full_version"] = _pep440_python_full_version(python_full_version)
return env
+143 -53
View File
@@ -6,6 +6,7 @@ import email.parser
import email.policy
import keyword
import pathlib
import re
import typing
from typing import (
Any,
@@ -22,6 +23,7 @@ from .errors import ExceptionGroup, _ErrorCollector
if typing.TYPE_CHECKING:
from .licenses import NormalizedLicenseExpression
from .version import Version
T = typing.TypeVar("T")
@@ -42,7 +44,10 @@ def __dir__() -> list[str]:
class InvalidMetadata(ValueError):
"""A metadata field contains invalid data."""
"""A metadata field contains invalid data.
.. versionadded:: 23.2
"""
field: str
"""The name of the field that contains invalid data."""
@@ -51,6 +56,10 @@ class InvalidMetadata(ValueError):
self.field = field
super().__init__(message)
# Support pickling; ``args`` does not match ``__init__``'s signature.
def __reduce__(self) -> tuple[type[InvalidMetadata], tuple[str, str]]:
return (self.__class__, (self.field, self.args[0]))
# The RawMetadata class attempts to make as few assumptions about the underlying
# serialization formats as possible. The idea is that as long as a serialization
@@ -125,11 +134,17 @@ class RawMetadata(TypedDict, total=False):
# Metadata 2.4 - PEP 639
license_expression: str
""".. versionadded:: 24.2"""
license_files: list[str]
""".. versionadded:: 24.2"""
# Metadata 2.5 - PEP 794
import_names: list[str]
""".. versionadded:: 26.0"""
import_namespaces: list[str]
""".. versionadded:: 26.0"""
# Metadata 2.6 - PEP 808 (no new fields, behavior change for Dynamic)
# 'keywords' is special as it's a string in the core metadata spec, but we
@@ -225,13 +240,19 @@ def _get_payload(msg: email.message.Message, source: bytes | str) -> str:
# and we don't need to deal with it.
if isinstance(source, str):
payload = msg.get_payload()
assert isinstance(payload, str)
# A multipart payload makes get_payload() return a list of messages
# rather than a str; route it to ``unparsed``.
if not isinstance(payload, str):
raise ValueError("payload is not a string") # noqa: TRY004
return payload
# If our source is a bytes, then we're managing the encoding and we need
# to deal with it.
else:
bpayload = msg.get_payload(decode=True)
assert isinstance(bpayload, bytes)
# A multipart payload makes get_payload(decode=True) return None;
# route it to ``unparsed``.
if not isinstance(bpayload, bytes):
raise ValueError("payload in an invalid encoding") # noqa: TRY004
try:
return bpayload.decode("utf8", "strict")
except UnicodeDecodeError as exc:
@@ -287,11 +308,19 @@ _EMAIL_TO_RAW_MAPPING = {
_RAW_TO_EMAIL_MAPPING = {raw: email for email, raw in _EMAIL_TO_RAW_MAPPING.items()}
# A bare "\r" makes the email generator raise ``HeaderWriteError``, and on
# CPython releases without the CVE-2024-6923 fix any ``str.splitlines``
# boundary ends the header line, so fold all of them, not just "\n".
_LINE_BOUNDARY_RE = re.compile(r"\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]")
# This class is for writing RFC822 messages
class RFC822Policy(email.policy.EmailPolicy):
"""
This is :class:`email.policy.EmailPolicy`, but with a simple ``header_store_parse``
implementation that handles multi-line values, and some nice defaults.
.. versionadded:: 26.0
"""
utf8 = True
@@ -300,7 +329,7 @@ class RFC822Policy(email.policy.EmailPolicy):
def header_store_parse(self, name: str, value: str) -> tuple[str, str]:
size = len(name) + 2
value = value.replace("\n", "\n" + " " * size)
value = _LINE_BOUNDARY_RE.sub("\n" + " " * size, value)
return (name, value)
@@ -310,6 +339,8 @@ class RFC822Message(email.message.EmailMessage):
This is :class:`email.message.EmailMessage` with two small changes: it defaults to
our `RFC822Policy`, and it correctly writes unicode when being called
with `bytes()`.
.. versionadded:: 26.0
"""
def __init__(self) -> None:
@@ -511,8 +542,20 @@ _NOT_FOUND = object()
# Keep the two values in sync.
_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]
_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]
_VALID_METADATA_VERSIONS = [
"1.0",
"1.1",
"1.2",
"2.1",
"2.2",
"2.3",
"2.4",
"2.5",
"2.6",
]
_MetadataVersion = Literal[
"1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6"
]
_REQUIRED_ATTRS = frozenset(["metadata_version", "name", "version"])
@@ -572,9 +615,7 @@ class _Validator(Generic[T]):
def _invalid_metadata(
self, msg: str, cause: Exception | None = None
) -> InvalidMetadata:
exc = InvalidMetadata(
self.raw_name, msg.format_map({"field": repr(self.raw_name)})
)
exc = InvalidMetadata(self.raw_name, msg)
exc.__cause__ = cause
return exc
@@ -586,67 +627,82 @@ class _Validator(Generic[T]):
def _process_name(self, value: str) -> str:
if not value:
raise self._invalid_metadata("{field} is a required field")
raise self._invalid_metadata(f"{self.raw_name!r} is a required field")
# Validate the name as a side-effect.
try:
utils.canonicalize_name(value, validate=True)
except utils.InvalidName as exc:
raise self._invalid_metadata(
f"{value!r} is invalid for {{field}}", cause=exc
f"{value!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
else:
return value
def _process_version(self, value: str) -> version_module.Version:
def _process_version(self, value: str) -> Version:
if not value:
raise self._invalid_metadata("{field} is a required field")
raise self._invalid_metadata(f"{self.raw_name!r} is a required field")
try:
return version_module.parse(value)
except version_module.InvalidVersion as exc:
raise self._invalid_metadata(
f"{value!r} is invalid for {{field}}", cause=exc
f"{value!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
def _process_summary(self, value: str) -> str:
"""Check the field contains no newlines."""
if "\n" in value:
raise self._invalid_metadata("{field} must be a single line")
"""Check the field contains no line breaks."""
if _LINE_BOUNDARY_RE.search(value):
raise self._invalid_metadata(f"{self.raw_name!r} must be a single line")
return value
def _process_description_content_type(self, value: str) -> str:
content_types = {"text/plain", "text/x-rst", "text/markdown"}
invalid_msg = (
f"{self.raw_name!r} must be one of {list(content_types)}, not {value!r}"
)
message = email.message.EmailMessage()
message["content-type"] = value
try:
message["content-type"] = value
# The email parser can raise IndexError on malformed RFC 2231
# parameters such as "text/plain; x*".
except (ValueError, IndexError) as exc:
msg = f"{value!r} is not a valid content type for {self.raw_name!r}"
raise self._invalid_metadata(msg, cause=exc) from exc
content_type_header = message["content-type"]
if content_type_header.defects:
defect = content_type_header.defects[0]
msg = (
f"{value!r} is not a valid content type for {self.raw_name!r}: {defect}"
)
raise self._invalid_metadata(msg, cause=defect) from defect
content_type, parameters = (
# Defaults to `text/plain` if parsing failed.
message.get_content_type().lower(),
message["content-type"].params,
content_type_header.params,
)
# Check if content-type is valid or defaulted to `text/plain` and thus was
# not parseable.
if content_type not in content_types or content_type not in value.lower():
raise self._invalid_metadata(
f"{{field}} must be one of {list(content_types)}, not {value!r}"
)
raise self._invalid_metadata(invalid_msg)
charset = parameters.get("charset", "UTF-8")
if charset != "UTF-8":
if charset.lower() != "utf-8":
raise self._invalid_metadata(
f"{{field}} can only specify the UTF-8 charset, not {charset!r}"
f"{self.raw_name!r} can only specify the UTF-8 charset, not {charset!r}"
)
markdown_variants = {"GFM", "CommonMark"}
variant = parameters.get("variant", "GFM") # Use an acceptable default.
if content_type == "text/markdown" and variant not in markdown_variants:
raise self._invalid_metadata(
f"valid Markdown variants for {{field}} are {list(markdown_variants)}, "
f"not {variant!r}",
f"valid Markdown variants for {self.raw_name!r} are "
f"{list(markdown_variants)}, not {variant!r}",
)
return value
def _process_dynamic(self, value: list[str]) -> list[str]:
for dynamic_field in map(str.lower, value):
dynamic_fields = list(map(str.lower, value))
for dynamic_field in dynamic_fields:
if dynamic_field in {"name", "version", "metadata-version"}:
raise self._invalid_metadata(
f"{dynamic_field!r} is not allowed as a dynamic field"
@@ -655,7 +711,7 @@ class _Validator(Generic[T]):
raise self._invalid_metadata(
f"{dynamic_field!r} is not a valid dynamic field"
)
return list(map(str.lower, value))
return dynamic_fields
def _process_provides_extra(
self,
@@ -667,7 +723,7 @@ class _Validator(Generic[T]):
normalized_names.append(utils.canonicalize_name(name, validate=True))
except utils.InvalidName as exc:
raise self._invalid_metadata(
f"{name!r} is invalid for {{field}}", cause=exc
f"{name!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
else:
return normalized_names
@@ -677,7 +733,7 @@ class _Validator(Generic[T]):
return specifiers.SpecifierSet(value)
except specifiers.InvalidSpecifier as exc:
raise self._invalid_metadata(
f"{value!r} is invalid for {{field}}", cause=exc
f"{value!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
def _process_requires_dist(
@@ -690,7 +746,7 @@ class _Validator(Generic[T]):
reqs.append(requirements.Requirement(req))
except requirements.InvalidRequirement as exc:
raise self._invalid_metadata(
f"{req!r} is invalid for {{field}}", cause=exc
f"{req!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
else:
return reqs
@@ -700,7 +756,7 @@ class _Validator(Generic[T]):
return licenses.canonicalize_license_expression(value)
except ValueError as exc:
raise self._invalid_metadata(
f"{value!r} is invalid for {{field}}", cause=exc
f"{value!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
def _process_license_files(self, value: list[str]) -> list[str]:
@@ -708,23 +764,24 @@ class _Validator(Generic[T]):
for path in value:
if ".." in path:
raise self._invalid_metadata(
f"{path!r} is invalid for {{field}}, "
f"{path!r} is invalid for {self.raw_name!r}, "
"parent directory indicators are not allowed"
)
if "*" in path:
raise self._invalid_metadata(
f"{path!r} is invalid for {{field}}, paths must be resolved"
f"{path!r} is invalid for {self.raw_name!r}, paths must be resolved"
)
if (
pathlib.PurePosixPath(path).is_absolute()
or pathlib.PureWindowsPath(path).is_absolute()
):
raise self._invalid_metadata(
f"{path!r} is invalid for {{field}}, paths must be relative"
f"{path!r} is invalid for {self.raw_name!r}, paths must be relative"
)
if pathlib.PureWindowsPath(path).as_posix() != path:
raise self._invalid_metadata(
f"{path!r} is invalid for {{field}}, paths must use '/' delimiter"
f"{path!r} is invalid for {self.raw_name!r}, "
"paths must use '/' delimiter"
)
paths.append(path)
return paths
@@ -736,17 +793,17 @@ class _Validator(Generic[T]):
for identifier in name.split("."):
if not identifier.isidentifier():
raise self._invalid_metadata(
f"{name!r} is invalid for {{field}}; "
f"{name!r} is invalid for {self.raw_name!r}; "
f"{identifier!r} is not a valid identifier"
)
elif keyword.iskeyword(identifier):
raise self._invalid_metadata(
f"{name!r} is invalid for {{field}}; "
f"{name!r} is invalid for {self.raw_name!r}; "
f"{identifier!r} is a keyword"
)
if semicolon and private.lstrip() != "private":
raise self._invalid_metadata(
f"{import_name!r} is invalid for {{field}}; "
f"{import_name!r} is invalid for {self.raw_name!r}; "
"the only valid option is 'private'"
)
return value
@@ -761,6 +818,12 @@ class Metadata:
metadata fields instead of only using built-in types. Any invalid metadata
will cause :exc:`InvalidMetadata` to be raised (with a
:py:attr:`~BaseException.__cause__` attribute as appropriate).
.. versionadded:: 23.2
.. versionchanged:: 24.0
Optional attributes now return None when the field is absent instead of
raising.
"""
_raw: RawMetadata
@@ -769,8 +832,9 @@ class Metadata:
def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> Metadata:
"""Create an instance from :class:`RawMetadata`.
If *validate* is true, all metadata will be validated. All exceptions
related to validation will be gathered and raised as an :class:`ExceptionGroup`.
If *validate* is true, all metadata will be validated and all related
exceptions will be gathered and raised as an :class:`ExceptionGroup`;
otherwise, validation happens per attribute when it is accessed.
"""
ins = cls()
ins._raw = data.copy() # Mutations occur due to caching enriched values.
@@ -829,20 +893,32 @@ class Metadata:
raw, unparsed = parse_email(data)
if validate:
with _ErrorCollector().on_exit("unparsed") as collector:
with _ErrorCollector().on_exit("invalid or unparsed metadata") 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:
validated = cls.from_raw(raw, validate=validate)
except ExceptionGroup as exc_group:
# The no-branch pragmas cover arcs only seen by Python 3.9.
for exc in exc_group.exceptions: # pragma: no branch
# A required field reported above as unparsed is absent
# from `raw`, so skip from_raw's duplicate "missing"
# complaint.
if not (
isinstance(exc, InvalidMetadata)
and exc.field in unparsed
and _EMAIL_TO_RAW_MAPPING.get(exc.field) not in raw
):
collector.error(exc)
else:
if not collector.errors: # pragma: no branch
return validated
try:
return cls.from_raw(raw, validate=validate)
except ExceptionGroup as exc_group:
raise ExceptionGroup(
"invalid or unparsed metadata", exc_group.exceptions
) from None
return cls.from_raw(raw, validate=validate)
metadata_version: _Validator[_MetadataVersion] = _Validator()
""":external:ref:`core-metadata-metadata-version`
@@ -853,7 +929,7 @@ class Metadata:
""":external:ref:`core-metadata-name`
(required; validated using :func:`~packaging.utils.canonicalize_name` and its
*validate* parameter)"""
version: _Validator[version_module.Version] = _Validator()
version: _Validator[Version] = _Validator()
""":external:ref:`core-metadata-version` (required)"""
dynamic: _Validator[list[str] | None] = _Validator(
added="2.2",
@@ -889,9 +965,15 @@ class Metadata:
license_expression: _Validator[NormalizedLicenseExpression | None] = _Validator(
added="2.4"
)
""":external:ref:`core-metadata-license-expression`"""
""":external:ref:`core-metadata-license-expression`
.. versionadded:: 24.2
"""
license_files: _Validator[list[str] | None] = _Validator(added="2.4")
""":external:ref:`core-metadata-license-file`"""
""":external:ref:`core-metadata-license-file`
.. versionadded:: 24.2
"""
classifiers: _Validator[list[str] | None] = _Validator(added="1.1")
""":external:ref:`core-metadata-classifier`"""
requires_dist: _Validator[list[requirements.Requirement] | None] = _Validator(
@@ -919,9 +1001,15 @@ class Metadata:
obsoletes_dist: _Validator[list[str] | None] = _Validator(added="1.2")
""":external:ref:`core-metadata-obsoletes-dist`"""
import_names: _Validator[list[str] | None] = _Validator(added="2.5")
""":external:ref:`core-metadata-import-name`"""
""":external:ref:`core-metadata-import-name`
.. versionadded:: 26.0
"""
import_namespaces: _Validator[list[str] | None] = _Validator(added="2.5")
""":external:ref:`core-metadata-import-namespace`"""
""":external:ref:`core-metadata-import-namespace`
.. versionadded:: 26.0
"""
requires: _Validator[list[str] | None] = _Validator(added="1.1")
"""``Requires`` (deprecated)"""
provides: _Validator[list[str] | None] = _Validator(added="1.1")
@@ -932,6 +1020,8 @@ class Metadata:
def as_rfc822(self) -> RFC822Message:
"""
Return an RFC822 message with the metadata.
.. versionadded:: 26.0
"""
message = RFC822Message()
self._write_metadata(message)
+46 -8
View File
@@ -14,9 +14,14 @@ from typing import (
TypeVar,
cast,
)
from urllib.parse import urlparse
from urllib.parse import unquote, urlparse
from .markers import Environment, Marker, default_environment
from .markers import (
Environment,
Marker,
_pep440_python_full_version,
default_environment,
)
from .specifiers import SpecifierSet
from .tags import create_compatible_tags_selector, sys_tags
from .utils import (
@@ -45,6 +50,7 @@ __all__ = [
"PackageVcs",
"PackageWheel",
"Pylock",
"PylockSelectError",
"PylockUnsupportedVersionError",
"PylockValidationError",
"is_valid_pylock_path",
@@ -99,7 +105,10 @@ 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):
if not isinstance(value, expected_type) or (
# Special case: bool is a subclass of int, but TOML distinguishes the two
expected_type is int and isinstance(value, bool)
):
raise PylockValidationError(
f"Unexpected type {type(value).__name__} "
f"(expected {expected_type.__name__})",
@@ -255,7 +264,8 @@ def _url_name(url: str | None) -> str | None:
if not url:
return None
url_path = urlparse(url).path
return url_path.rsplit("/", 1)[-1]
# The last path component is percent-encoded, so decode it to the file name
return unquote(url_path.rsplit("/", 1)[-1])
def _validate_hashes(hashes: Mapping[str, Any]) -> Mapping[str, Any]:
@@ -306,7 +316,10 @@ class PylockUnsupportedVersionError(PylockValidationError):
class PylockSelectError(Exception):
"""Base exception for errors raised by :meth:`Pylock.select`."""
"""Base exception for errors raised by :meth:`Pylock.select`.
.. versionadded:: 26.1
"""
@dataclass(frozen=True, init=False)
@@ -460,7 +473,10 @@ class PackageSdist:
@property
def filename(self) -> str:
"""Get the filename of the sdist."""
"""Get the filename of the sdist.
.. versionadded:: 26.1
"""
filename = self.name or _path_name(self.path) or _url_name(self.url)
if not filename:
raise PylockValidationError("Cannot determine sdist filename")
@@ -737,6 +753,7 @@ class Pylock:
tags: Sequence[Tag] | None = None,
extras: Collection[str] | None = None,
dependency_groups: Collection[str] | None = None,
prefer_sdist_predicate: Callable[[NormalizedName], bool] | None = None,
) -> Iterator[
tuple[
Package,
@@ -758,11 +775,23 @@ class Pylock:
The *dependency_groups* parameter represents the groups to install. If
unspecified, the default groups are used.
The *prefer_sdist_predicate* parameter is called for packages with a source
distribution. If it returns ``True``, the source distribution is selected
before attempting wheel compatibility. If no source distribution is
available, wheel selection proceeds as usual without calling the predicate.
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`).
.. versionadded:: 26.1
.. versionchanged:: 26.3
Added the *prefer_sdist_predicate* parameter.
"""
compatible_tags_selector = create_compatible_tags_selector(tags or sys_tags())
compatible_tags_selector = create_compatible_tags_selector(
tags if tags is not None else sys_tags()
)
# #. Gather the extras and dependency groups to install and set ``extras`` and
# ``dependency_groups`` for marker evaluation, respectively.
@@ -782,7 +811,7 @@ class Pylock:
),
),
)
env_python_full_version = (
env_python_full_version = _pep440_python_full_version(
environment["python_full_version"]
if environment
else default_environment()["python_full_version"]
@@ -870,6 +899,15 @@ class Pylock:
elif package.archive is not None:
yield package, package.archive
# - Else if source preference selects an available
# :ref:`pylock-packages-sdist`:
elif (
package.sdist is not None
and prefer_sdist_predicate is not None
and prefer_sdist_predicate(package.name)
):
yield package, package.sdist
# - Else if there are entries for :ref:`pylock-packages-wheels`:
elif package.wheels:
# #. Look for the appropriate wheel file based on
File diff suppressed because it is too large Load Diff
+87 -24
View File
@@ -3,14 +3,17 @@
# for complete details.
from __future__ import annotations
from typing import Iterator
from typing import TYPE_CHECKING
from ._parser import parse_requirement as _parse_requirement
from ._tokenizer import ParserSyntaxError
from .markers import Marker, _normalize_extra_values
from .specifiers import SpecifierSet
from .specifiers import InvalidSpecifier, SpecifierSet
from .utils import canonicalize_name
if TYPE_CHECKING:
from collections.abc import Iterator
__all__ = [
"InvalidRequirement",
"Requirement",
@@ -24,6 +27,8 @@ def __dir__() -> list[str]:
class InvalidRequirement(ValueError):
"""
An invalid requirement was found, users should refer to PEP 508.
.. versionadded:: 16.1
"""
@@ -34,6 +39,18 @@ class Requirement:
URL, and extras. Raises InvalidRequirement on a badly-formed requirement
string.
.. versionadded:: 16.1
.. versionchanged:: 22.0
Added equality (``__eq__``) and hashing (``__hash__``) so requirements
can be compared and stored in sets / dicts.
.. versionchanged:: 23.2
Equality and hashing began canonicalizing requirement names, so
requirements whose names differ only by normalization (e.g.
``Requirement("Foo")`` vs ``Requirement("foo")``) now compare and hash
equal.
Instances are safe to serialize with :mod:`pickle`. They use a stable
format so the same pickle can be loaded in future packaging releases.
@@ -43,6 +60,16 @@ class Requirement:
be unpickled with future releases. Backward compatibility with pickles
from packaging < 26.2 is supported but may be removed in a future
release.
.. versionchanged:: 26.3
The dedicated pickle support introduced in 26.2 did not preserve the
specifier's explicit :attr:`~packaging.specifiers.SpecifierSet.prereleases`
override; it is now included again.
Equality and hashing normalize requirement names, extras, and
equivalent specifiers. The string representation still preserves the
parsed name and extras spelling.
"""
# TODO: Can we test whether something is contained within a requirement?
@@ -50,6 +77,8 @@ class Requirement:
# the thing as well as the version? What about the markers?
# TODO: Can we normalize the name and extra name?
__slots__ = ("extras", "marker", "name", "specifier", "url")
def __init__(self, requirement_string: str) -> None:
try:
parsed = _parse_requirement(requirement_string)
@@ -58,8 +87,11 @@ class Requirement:
self.name: str = parsed.name
self.url: str | None = parsed.url or None
self.extras: set[str] = set(parsed.extras or [])
self.specifier: SpecifierSet = SpecifierSet(parsed.specifier)
self.extras: set[str] = set(parsed.extras)
try:
self.specifier: SpecifierSet = SpecifierSet(parsed.specifier)
except InvalidSpecifier as e:
raise InvalidRequirement(str(e)) from e
self.marker: Marker | None = None
if parsed.marker is not None:
self.marker = Marker.__new__(Marker)
@@ -83,29 +115,44 @@ 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 __getstate__(self) -> tuple[str, bool | None]:
# Return the requirement string for compactness and stability, paired
# with the specifier's explicit prereleases override, which is not
# captured by the string form. Re-parsed on load to reconstruct all
# other fields.
return (str(self), self.specifier._prereleases)
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):
# Format (26.2): just the requirement string.
requirement_string: str = state
prereleases: bool | None = None
elif (
isinstance(state, tuple)
and len(state) == 2
and isinstance(state[0], str)
and (state[1] is None or isinstance(state[1], bool))
):
# New format (26.3+): (requirement string, specifier prereleases).
requirement_string, prereleases = state
elif isinstance(state, dict) and state.keys() >= set(self.__slots__):
# Old format (packaging <= 26.1, no __slots__): plain __dict__.
self.__dict__.update(state)
for key in self.__slots__:
setattr(self, key, state[key])
return
raise TypeError(f"Cannot restore Requirement from {state!r}")
else:
raise TypeError(f"Cannot restore Requirement from {state!r}")
try:
tmp = Requirement(requirement_string)
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.specifier._prereleases = prereleases
self.marker = tmp.marker
def __str__(self) -> str:
return "".join(self._iter_parts(self.name))
@@ -114,15 +161,31 @@ class Requirement:
return f"<{self.__class__.__name__}({str(self)!r})>"
def __hash__(self) -> int:
return hash(tuple(self._iter_parts(canonicalize_name(self.name))))
# Mirror __eq__ by hashing the canonical specifier object rather than
# its raw string. ``_iter_parts`` yields ``str(self.specifier)``, which
# is non-canonical, so trailing-zero-equivalent requirements such as
# ``foo==1.0.0`` and ``foo==1.0.0.0`` (which compare equal) would
# otherwise hash differently, breaking the hash/__eq__ invariant.
return hash(
(
canonicalize_name(self.name),
frozenset(canonicalize_name(e) for e in self.extras),
self.specifier,
self.url,
self.marker,
)
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Requirement):
return NotImplemented
# Extras must be normalized before comparison as per PEP 685.
self_extras = frozenset(canonicalize_name(e) for e in self.extras)
other_extras = frozenset(canonicalize_name(e) for e in other.extras)
return (
canonicalize_name(self.name) == canonicalize_name(other.name)
and self.extras == other.extras
and self_extras == other_extras
and self.specifier == other.specifier
and self.url == other.url
and self.marker == other.marker
File diff suppressed because it is too large Load Diff
+181 -60
View File
@@ -12,13 +12,10 @@ import struct
import subprocess
import sys
import sysconfig
from collections.abc import Iterable, Iterator, Sequence
from importlib.machinery import EXTENSION_SUFFIXES
from typing import (
TYPE_CHECKING,
Iterable,
Iterator,
Sequence,
Tuple,
TypeVar,
cast,
)
@@ -26,15 +23,17 @@ from typing import (
from . import _manylinux, _musllinux
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from typing import AbstractSet
from collections.abc import Callable
from collections.abc import Set as AbstractSet
__all__ = [
"INTERPRETER_SHORT_NAMES",
"AppleVersion",
"InvalidTag",
"PythonVersion",
"Tag",
"TooManyTagsError",
"UnsortedTagsError",
"android_platforms",
"compatible_tags",
@@ -47,6 +46,7 @@ __all__ = [
"mac_platforms",
"parse_tag",
"platform_tags",
"pure_python_tags",
"sys_tags",
]
@@ -58,7 +58,18 @@ def __dir__() -> list[str]:
logger = logging.getLogger(__name__)
PythonVersion = Sequence[int]
AppleVersion = Tuple[int, int]
"""
A sequence of integers describing a Python version, e.g. ``(3, 13)``.
.. versionadded:: 20.0
"""
AppleVersion = tuple[int, int]
"""
A ``(major, minor)`` integer pair describing an Apple OS version.
.. versionadded:: 24.2
"""
_T = TypeVar("_T")
INTERPRETER_SHORT_NAMES: dict[str, str] = {
@@ -82,6 +93,25 @@ _32_BIT_INTERPRETER = _compute_32_bit_interpreter()
class UnsortedTagsError(ValueError):
"""
Raised when a tag component is not in sorted order per PEP 425.
.. versionadded:: 26.1
"""
class InvalidTag(ValueError):
"""
Raised when an interpreter component is not an identifier, a tag component
is empty, or a tag does not have exactly three components.
.. versionadded:: 26.3
"""
class TooManyTagsError(ValueError):
"""
Raised when a compressed tag set exceeds the configured limit.
.. versionadded:: 26.3
"""
@@ -200,7 +230,9 @@ class Tag:
raise TypeError(f"Cannot restore Tag from {state!r}")
def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]:
def parse_tag(
tag: str, *, validate_order: bool = False, limit: int | None = None
) -> frozenset[Tag]:
"""
Parses the provided tag (e.g. `py3-none-any`) into a frozenset of
:class:`Tag` instances.
@@ -212,29 +244,70 @@ def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]:
If **validate_order** is true, compressed tag set components are checked
to be in sorted order as required by PEP 425.
If **limit** is not ``None``, the compressed tag set can generate at most
that many tags.
: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.
:param int | None limit: The maximum number of tags to parse.
:raises UnsortedTagsError: If **validate_order** is true and any compressed tag
set component is not in sorted order.
:raises InvalidTag: If the interpreter field is not an identifier; if the
interpreter, ABI, or platform field (or any member of a compressed tag
set) is empty; or if the tag does not have exactly three components.
:raises TooManyTagsError: If **limit** is not ``None`` and the compressed tag
set would generate more than **limit** tags.
:raises ValueError: If **limit** is negative.
.. versionadded:: 26.1
The *validate_order* parameter.
.. versionadded:: 26.3
Raises :class:`InvalidTag` when an interpreter component is not an
identifier, a tag component is empty, or a tag does not have exactly
three components.
Added the *limit* parameter. Raises :class:`TooManyTagsError` if the compressed
tag set would generate more than *limit* tags.
"""
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("."):
tags.add(Tag(interpreter, abi, platform_))
return frozenset(tags)
if limit is not None and limit < 0:
raise ValueError("limit must be non-negative")
component_parts = [component.split(".") for component in tag.split("-")]
for parts in component_parts:
if "" in parts:
component = ".".join(parts)
raise InvalidTag(f"Tag {tag!r} has an empty component: {component!r}")
if validate_order and parts != sorted(parts):
component = ".".join(parts)
raise UnsortedTagsError(
f"Tag component {component!r} is not in sorted order per PEP 425"
)
tag_count = 1
for parts in component_parts:
tag_count *= len(parts)
if limit is not None and tag_count > limit:
raise TooManyTagsError(
f"Compressed tag set would generate {tag_count} tags, exceeding "
f"limit {limit}"
)
try:
interpreters, abis, platforms = component_parts
except ValueError as exc:
raise InvalidTag(f"Tag {tag!r} must have exactly three components") from exc
for interpreter in interpreters:
if not interpreter.isidentifier():
raise InvalidTag(f"Tag {tag!r} has an invalid interpreter: {interpreter!r}")
return frozenset(
Tag(interpreter, abi, platform_)
for interpreter in interpreters
for abi in abis
for platform_ in platforms
)
def _get_config_var(name: str, warn: bool = False) -> int | str | None:
@@ -355,6 +428,8 @@ def cpython_tags(
: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``.
.. versionadded:: 20.0
"""
if not python_version:
python_version = sys.version_info[:2]
@@ -364,8 +439,10 @@ def cpython_tags(
if abis is None:
abis = _cpython_abis(python_version, warn) if len(python_version) > 1 else []
abis = list(abis)
# 'abi3' and 'none' are explicitly handled later.
for explicit_abi in ("abi3", "none"):
threading = _is_threaded_cpython(abis)
# Stable ABIs and 'none' are explicitly handled later.
explicit_abis = ("abi3", "abi3t", "none") if threading else ("abi3", "none")
for explicit_abi in explicit_abis:
try:
abis.remove(explicit_abi)
except ValueError: # noqa: PERF203
@@ -376,10 +453,8 @@ def cpython_tags(
for platform_ in platforms:
yield Tag(interpreter, abi, platform_)
threading = _is_threaded_cpython(abis)
use_abi3 = _abi3_applies(python_version, threading)
use_abi3t = _abi3t_applies(python_version, threading)
if use_abi3:
yield from (Tag(interpreter, "abi3", platform_) for platform_ in platforms)
if use_abi3t:
@@ -417,7 +492,7 @@ def _generic_abi() -> list[str]:
# => graalpy_38_native
ext_suffix = _get_config_var("EXT_SUFFIX", warn=True)
if not isinstance(ext_suffix, str) or ext_suffix[0] != ".":
if not isinstance(ext_suffix, str) or not ext_suffix.startswith("."):
raise SystemError("invalid sysconfig.get_config_var('EXT_SUFFIX')")
parts = ext_suffix.split(".")
if len(parts) < 3:
@@ -426,7 +501,10 @@ def _generic_abi() -> list[str]:
soabi = parts[1]
if soabi.startswith("cpython"):
# non-windows
abi = "cp" + soabi.split("-")[1]
cpython_parts = soabi.split("-")
if len(cpython_parts) < 2 or not cpython_parts[1]:
raise SystemError("invalid sysconfig.get_config_var('EXT_SUFFIX')")
abi = "cp" + cpython_parts[1]
elif soabi.startswith("cp"):
# windows
abi = soabi.split("-")[0]
@@ -469,6 +547,8 @@ def generic_tags(
: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``.
.. versionadded:: 20.0
"""
if not interpreter:
interp_name = interpreter_name()
@@ -498,6 +578,30 @@ def _py_interpreter_range(py_version: PythonVersion) -> Iterator[str]:
yield f"py{_version_nodot((py_version[0], minor))}"
def pure_python_tags(
python_version: PythonVersion | None = None,
) -> Iterator[Tag]:
"""
Yields the pure-Python tags compatible with ``python_version``.
The tags use the ``"none"`` ABI and ``"any"`` platform, so their
generation does not depend on the running platform.
.. versionadded:: 26.3
:param Sequence python_version: A one- or two-item sequence representing the
compatible version of Python. Defaults to
``sys.version_info[:2]``.
:raises ValueError: If ``python_version`` is an empty sequence.
"""
if python_version is None:
python_version = sys.version_info[:2]
elif not python_version:
raise ValueError("python_version must contain at least one item")
for version in _py_interpreter_range(python_version):
yield Tag(version, "none", "any")
def compatible_tags(
python_version: PythonVersion | None = None,
interpreter: str | None = None,
@@ -520,6 +624,8 @@ def compatible_tags(
``"cp38"``. Defaults to the current interpreter.
:param Iterable platforms: Iterable of compatible platforms. Defaults to the
platforms compatible with the current system.
.. versionadded:: 20.0
"""
if not python_version:
python_version = sys.version_info[:2]
@@ -529,8 +635,7 @@ def compatible_tags(
yield Tag(version, "none", platform_)
if interpreter:
yield Tag(interpreter, "none", "any")
for version in _py_interpreter_range(python_version):
yield Tag(version, "none", "any")
yield from pure_python_tags(python_version)
def _mac_arch(arch: str, is_32bit: bool = _32_BIT_INTERPRETER) -> str:
@@ -548,12 +653,12 @@ def _mac_binary_formats(version: AppleVersion, cpu_arch: str) -> list[str]:
if cpu_arch == "x86_64":
if version < (10, 4):
return []
formats.extend(["intel", "fat64", "fat32"])
formats.extend(["intel", "fat64", "fat3"])
elif cpu_arch == "i386":
if version < (10, 4):
return []
formats.extend(["intel", "fat32", "fat"])
formats.extend(["intel", "fat3", "fat"])
elif cpu_arch == "ppc64":
# TODO: Need to care about 32-bit PPC for ppc64 through 10.2?
@@ -564,7 +669,7 @@ def _mac_binary_formats(version: AppleVersion, cpu_arch: str) -> list[str]:
elif cpu_arch == "ppc":
if version > (10, 6):
return []
formats.extend(["fat32", "fat"])
formats.extend(["fat3", "fat"])
if cpu_arch in {"arm64", "x86_64"}:
formats.append("universal2")
@@ -598,29 +703,33 @@ def mac_platforms(
- 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:
version = cast("AppleVersion", tuple(map(int, version_str.split(".")[:2])))
if version == (10, 16):
# When built against an older macOS SDK, Python will report macOS 10.16
# instead of the real version.
version_str = subprocess.run(
[
sys.executable,
"-sS",
"-c",
"import platform; print(platform.mac_ver()[0])",
],
check=True,
env={"SYSTEM_VERSION_COMPAT": "0"},
stdout=subprocess.PIPE,
text=True,
).stdout
version = cast("AppleVersion", tuple(map(int, version_str.split(".")[:2])))
if arch is None:
arch = _mac_arch(cpu_arch)
.. versionadded:: 20.0
"""
if version is None or arch is None:
version_str, _, cpu_arch = platform.mac_ver()
if version is None:
version = cast("AppleVersion", tuple(map(int, version_str.split(".")[:2])))
if version == (10, 16):
# When built against an older macOS SDK, Python will report macOS 10.16
# instead of the real version.
version_str = subprocess.run(
[
sys.executable,
"-sS",
"-c",
"import platform; print(platform.mac_ver()[0])",
],
check=True,
env={"SYSTEM_VERSION_COMPAT": "0"},
stdout=subprocess.PIPE,
text=True,
).stdout
version = cast(
"AppleVersion", tuple(map(int, version_str.split(".")[:2]))
)
if arch is None:
arch = _mac_arch(cpu_arch)
if (10, 0) <= version < (11, 0):
# Prior to Mac OS 11, each yearly release of Mac OS bumped the
@@ -642,7 +751,6 @@ def mac_platforms(
for binary_format in binary_formats:
yield f"macosx_{major_version}_{minor_version}_{binary_format}"
if version >= (11, 0):
# Mac OS 11 on x86_64 is compatible with binaries from previous releases.
# Arm64 support was introduced in 11.0, so no Arm binaries from previous
# releases exist.
@@ -681,6 +789,8 @@ def ios_platforms(
.. note::
Behavior of this method is undefined if invoked on non-iOS platforms
without providing explicit version and multiarch arguments.
.. versionadded:: 24.2
"""
if version is None:
# if iOS is the current platform, ios_ver *must* be defined. However,
@@ -740,6 +850,8 @@ def android_platforms(
e.g. ``arm64_v8a``. Defaults to the current system's ABI , as returned by
``sysconfig.get_platform``. Hyphens and periods will be replaced with
underscores.
.. versionadded:: 25.0
"""
if platform.system() != "Android" and (api_level is None or abi is None):
raise TypeError(
@@ -776,10 +888,10 @@ def _linux_platforms(is_32bit: bool = _32_BIT_INTERPRETER) -> Iterator[str]:
linux = "linux_armv8l"
_, arch = linux.split("_", 1)
archs = {"armv8l": ["armv8l", "armv7l"]}.get(arch, [arch])
yield from _manylinux.platform_tags(archs)
yield from _musllinux.platform_tags(archs)
for arch in archs:
yield f"linux_{arch}"
yield from _manylinux.platform_tags(archs)
yield from _musllinux.platform_tags(archs)
def _emscripten_platforms() -> Iterator[str]:
@@ -798,6 +910,8 @@ def _generic_platforms() -> Iterator[str]:
def platform_tags() -> Iterator[str]:
"""
Yields the :attr:`~Tag.platform` tags for the running interpreter.
.. versionadded:: 21.1
"""
if platform.system() == "Darwin":
return mac_platforms()
@@ -821,6 +935,8 @@ def interpreter_name() -> str:
be returned when appropriate.
This typically acts as the prefix to the :attr:`~Tag.interpreter` tag.
.. versionadded:: 20.0
"""
name = sys.implementation.name
return INTERPRETER_SHORT_NAMES.get(name) or name
@@ -833,6 +949,8 @@ def interpreter_version(*, warn: bool = False) -> str:
This typically acts as the suffix to the :attr:`~Tag.interpreter` tag.
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
.. versionadded:: 20.0
"""
version = _get_config_var("py_version_nodot", warn=warn)
return str(version) if version else _version_nodot(sys.version_info[:2])
@@ -867,15 +985,18 @@ def sys_tags(*, warn: bool = False) -> Iterator[Tag]:
.. versionchanged:: 21.3
Added the `pp3-none-any` tag (:issue:`311`).
.. versionchanged:: 27.0
.. versionchanged:: 26.1
Added the `abi3t` tag (:issue:`1099`).
.. versionchanged:: 26.3
Native ``linux_*`` platform tags are now ordered before ``manylinux``
and ``musllinux`` tags (:issue:`160`).
"""
interp_name = interpreter_name()
if interp_name == "cp":
yield from cpython_tags(warn=warn)
else:
yield from generic_tags()
yield from generic_tags(warn=warn)
if interp_name == "pp":
interp = "pp3"
+84 -10
View File
@@ -5,9 +5,9 @@
from __future__ import annotations
import re
from typing import NewType, Tuple, Union, cast
from typing import NewType, Union, cast
from .tags import Tag, UnsortedTagsError, parse_tag
from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag
from .version import InvalidVersion, Version, _TrimmedRelease
__all__ = [
@@ -28,29 +28,42 @@ def __dir__() -> list[str]:
return __all__
BuildTag = Union[Tuple[()], Tuple[int, str]]
BuildTag = Union[tuple[()], tuple[int, str]]
"""
A wheel build tag: an empty tuple, or a ``(build number, build tag suffix)`` pair.
.. versionadded:: 20.9
"""
NormalizedName = NewType("NormalizedName", str)
"""
A :class:`typing.NewType` of :class:`str`, representing a normalized name.
.. versionadded:: 20.4
"""
class InvalidName(ValueError):
"""
An invalid distribution name; users should refer to the packaging user guide.
.. versionadded:: 23.2
"""
class InvalidWheelFilename(ValueError):
"""
An invalid wheel filename was found, users should refer to PEP 427.
.. versionadded:: 20.9
"""
class InvalidSdistFilename(ValueError):
"""
An invalid sdist filename was found, users should refer to the packaging user guide.
.. versionadded:: 20.9
"""
@@ -58,9 +71,12 @@ class InvalidSdistFilename(ValueError):
_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)
_normalized_regex = re.compile(r"[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+)(.*)", re.ASCII)
# PEP 427: Valid characters for an escaped project name in a wheel filename.
# Requires at least one character so an empty project name is rejected.
_wheel_name_regex = re.compile(r"^[\w._]+\Z", re.UNICODE)
def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
@@ -87,6 +103,14 @@ def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
'oslo-concurrency'
>>> canonicalize_name("requests")
'requests'
.. versionadded:: 16.2
.. versionchanged:: 20.4
The return type was changed to :class:`NormalizedName`.
.. versionchanged:: 23.2
Added the *validate* keyword parameter.
"""
if validate and not _validate_regex.fullmatch(name):
raise InvalidName(f"name is invalid: {name!r}")
@@ -102,16 +126,27 @@ 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).
Check if a name is a normalized project name (i.e. a valid name that
:func:`canonicalize_name` would roundtrip to the same value).
The roundtrip only characterizes normalized names for *valid* names. A name
must start and end with an ASCII letter or digit, which
:func:`canonicalize_name` does not enforce: it leaves a leading or trailing
hyphen in place, so such a name roundtrips without being normalized.
:param str name: The name to check.
>>> from packaging.utils import is_normalized_name
>>> from packaging.utils import canonicalize_name, is_normalized_name
>>> is_normalized_name("requests")
True
>>> is_normalized_name("Django")
False
>>> canonicalize_name("_not_legal")
'-not-legal'
>>> is_normalized_name("-not-legal") # roundtrips, but not a valid name
False
.. versionadded:: 23.2
"""
return _normalized_regex.fullmatch(name) is not None
@@ -145,6 +180,14 @@ def canonicalize_version(
>>> canonicalize_version('1.4.0.0.0')
'1.4'
.. versionadded:: 17.1
.. versionchanged:: 21.0
The return type was narrowed to :class:`str`.
.. versionchanged:: 22.0
Added the *strip_trailing_zero* keyword parameter.
"""
if isinstance(version, str):
try:
@@ -196,8 +239,18 @@ def parse_wheel_filename(
>>> not build
True
.. versionadded:: 20.9
.. versionchanged:: 23.2
Raises :class:`InvalidWheelFilename` when the version component is invalid.
.. versionadded:: 26.1
The *validate_order* parameter.
.. versionchanged:: 26.3
Raises :class:`InvalidWheelFilename` when an interpreter component is
not an identifier, a tag set component is empty, or the project name is
empty.
"""
if not filename.endswith(".whl"):
raise InvalidWheelFilename(
@@ -214,7 +267,7 @@ def parse_wheel_filename(
parts = filename.split("-", dashes - 2)
name_part = parts[0]
# See PEP 427 for the rules on escaping the project name.
if "__" in name_part or re.match(r"^[\w\d._]*$", name_part, re.UNICODE) is None:
if "__" in name_part or _wheel_name_regex.match(name_part) is None:
raise InvalidWheelFilename(f"Invalid project name: {filename!r}")
name = canonicalize_name(name_part)
@@ -243,6 +296,10 @@ def parse_wheel_filename(
f"Invalid wheel filename (compressed tag set components must be in "
f"sorted order per PEP 425): {filename!r}"
) from None
except InvalidTag:
raise InvalidWheelFilename(
f"Invalid wheel filename (invalid tag component): {filename!r}"
) from None
return (name, version, build, tags)
@@ -255,8 +312,10 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, 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.
with an sdist extension (``.zip`` or ``.tar.gz``), if it does not
contain a dash separating the name and the version of the distribution,
if the project name is empty, or if the version portion is not a valid
version.
>>> from packaging.utils import parse_sdist_filename
>>> from packaging.version import Version
@@ -266,6 +325,17 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]:
>>> ver == Version('1.0')
True
.. versionadded:: 20.9
.. versionchanged:: 21.0
Added support for ``.zip`` source distributions.
.. versionchanged:: 23.2
Raises :class:`InvalidSdistFilename` when the version component is invalid.
.. versionchanged:: 26.3
Raises :class:`InvalidSdistFilename` on an empty project name.
.. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name
"""
if filename.endswith(".tar.gz"):
@@ -283,6 +353,10 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]:
name_part, sep, version_part = file_stem.rpartition("-")
if not sep:
raise InvalidSdistFilename(f"Invalid sdist filename: {filename!r}")
if not name_part:
raise InvalidSdistFilename(
f"Invalid sdist filename (empty project name): {filename!r}"
)
name = canonicalize_name(name_part)
+32 -11
View File
@@ -18,7 +18,6 @@ from typing import (
Literal,
NamedTuple,
SupportsInt,
Tuple,
TypedDict,
Union,
)
@@ -67,13 +66,13 @@ def __dir__() -> list[str]:
return __all__
LocalType = Tuple[Union[int, str], ...]
LocalType = tuple[Union[int, str], ...]
CmpLocalType = Tuple[Tuple[int, str], ...]
CmpSuffix = Tuple[int, int, int, int, int, int]
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],
tuple[int, tuple[int, ...], CmpSuffix],
tuple[int, tuple[int, ...], CmpSuffix, CmpLocalType],
]
VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool]
@@ -288,10 +287,15 @@ def _validate_pre(value: object, /) -> tuple[Literal["a", "b", "rc"], int] | Non
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:
# The letter must be a string before it can be normalized.
if (
isinstance(letter, str)
and (normalized := normalize_pre(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]
return (normalized, number) # type: ignore[return-value]
msg = f"pre must be a tuple of ('a'|'b'|'rc', non-negative int), got {value}"
raise InvalidVersion(msg)
@@ -411,9 +415,16 @@ 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:
is_simple = _SIMPLE_VERSION_INDICATORS.issuperset(version)
except TypeError:
raise InvalidVersion(f"Invalid version: {version!r}") from None
if is_simple:
try:
self._release = tuple(map(int, version.split(".")))
except AttributeError:
raise InvalidVersion(f"Invalid version: {version!r}") from None
except ValueError:
# Empty parts (from "1..2", ".1", etc.) are invalid versions.
# Any other ValueError (e.g. int str-digits limit) should
@@ -433,7 +444,10 @@ class Version(_BaseVersion):
return
# Validate the version and parse it into pieces
match = self._regex.fullmatch(version)
try:
match = self._regex.fullmatch(version)
except TypeError:
raise InvalidVersion(f"Invalid version: {version!r}") from None
if not match:
raise InvalidVersion(f"Invalid version: {version!r}")
self._epoch = int(match.group("epoch")) if match.group("epoch") else 0
@@ -1041,6 +1055,8 @@ class Version(_BaseVersion):
>>> Version("1.2.3").major
1
.. versionadded:: 20.0
"""
return self.release[0] if len(self.release) >= 1 else 0
@@ -1052,6 +1068,8 @@ class Version(_BaseVersion):
2
>>> Version("1").minor
0
.. versionadded:: 20.0
"""
return self.release[1] if len(self.release) >= 2 else 0
@@ -1063,6 +1081,8 @@ class Version(_BaseVersion):
3
>>> Version("1").micro
0
.. versionadded:: 20.0
"""
return self.release[2] if len(self.release) >= 3 else 0
@@ -1079,6 +1099,7 @@ class _TrimmedRelease(Version):
self._post = version._post
self._local = version._local
self._key_cache = version._key_cache
self._hash_cache = version._hash_cache
return
super().__init__(version) # pragma: no cover