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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 ")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+344
-830
File diff suppressed because it is too large
Load Diff
+181
-60
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user