add updates libs
This commit is contained in:
@@ -6,7 +6,7 @@ __title__ = "packaging"
|
||||
__summary__ = "Core utilities for Python packages"
|
||||
__uri__ = "https://github.com/pypa/packaging"
|
||||
|
||||
__version__ = "25.0"
|
||||
__version__ = "26.2"
|
||||
|
||||
__author__ = "Donald Stufft and individual contributors"
|
||||
__email__ = "donald@stufft.io"
|
||||
|
||||
@@ -4,7 +4,6 @@ ELF file parser.
|
||||
This provides a class ``ELFFile`` that parses an ELF executable in a similar
|
||||
interface to ``ZipFile``. Only the read interface is implemented.
|
||||
|
||||
Based on: https://gist.github.com/lyssdod/f51579ae8d93c8657a5564aefc2ffbca
|
||||
ELF header: https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html
|
||||
"""
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ EF_ARM_ABIMASK = 0xFF000000
|
||||
EF_ARM_ABI_VER5 = 0x05000000
|
||||
EF_ARM_ABI_FLOAT_HARD = 0x00000400
|
||||
|
||||
_ALLOWED_ARCHS = {
|
||||
"x86_64",
|
||||
"aarch64",
|
||||
"ppc64",
|
||||
"ppc64le",
|
||||
"s390x",
|
||||
"loongarch64",
|
||||
"riscv64",
|
||||
}
|
||||
|
||||
|
||||
# `os.PathLike` not a generic type until Python 3.9, so sticking with `str`
|
||||
# as the type for `path` until then.
|
||||
@@ -57,16 +67,7 @@ def _have_compatible_abi(executable: str, archs: Sequence[str]) -> bool:
|
||||
return _is_linux_armhf(executable)
|
||||
if "i686" in archs:
|
||||
return _is_linux_i686(executable)
|
||||
allowed_archs = {
|
||||
"x86_64",
|
||||
"aarch64",
|
||||
"ppc64",
|
||||
"ppc64le",
|
||||
"s390x",
|
||||
"loongarch64",
|
||||
"riscv64",
|
||||
}
|
||||
return any(arch in allowed_archs for arch in archs)
|
||||
return any(arch in _ALLOWED_ARCHS for arch in archs)
|
||||
|
||||
|
||||
# If glibc ever changes its major version, we need to know what the last
|
||||
@@ -106,7 +107,7 @@ def _glibc_version_string_ctypes() -> str | None:
|
||||
Fallback implementation of glibc_version_string using ctypes.
|
||||
"""
|
||||
try:
|
||||
import ctypes
|
||||
import ctypes # noqa: PLC0415
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
@@ -150,7 +151,7 @@ def _glibc_version_string() -> str | None:
|
||||
return _glibc_version_string_confstr() or _glibc_version_string_ctypes()
|
||||
|
||||
|
||||
def _parse_glibc_version(version_str: str) -> tuple[int, int]:
|
||||
def _parse_glibc_version(version_str: str) -> _GLibCVersion:
|
||||
"""Parse glibc version.
|
||||
|
||||
We use a regexp instead of str.split because we want to discard any
|
||||
@@ -165,15 +166,15 @@ def _parse_glibc_version(version_str: str) -> tuple[int, int]:
|
||||
RuntimeWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return -1, -1
|
||||
return int(m.group("major")), int(m.group("minor"))
|
||||
return _GLibCVersion(-1, -1)
|
||||
return _GLibCVersion(int(m.group("major")), int(m.group("minor")))
|
||||
|
||||
|
||||
@functools.lru_cache
|
||||
def _get_glibc_version() -> tuple[int, int]:
|
||||
def _get_glibc_version() -> _GLibCVersion:
|
||||
version_str = _glibc_version_string()
|
||||
if version_str is None:
|
||||
return (-1, -1)
|
||||
return _GLibCVersion(-1, -1)
|
||||
return _parse_glibc_version(version_str)
|
||||
|
||||
|
||||
@@ -184,7 +185,7 @@ def _is_compatible(arch: str, version: _GLibCVersion) -> bool:
|
||||
return False
|
||||
# Check for presence of _manylinux module.
|
||||
try:
|
||||
import _manylinux
|
||||
import _manylinux # noqa: PLC0415
|
||||
except ImportError:
|
||||
return True
|
||||
if hasattr(_manylinux, "manylinux_compatible"):
|
||||
@@ -192,25 +193,26 @@ def _is_compatible(arch: str, version: _GLibCVersion) -> bool:
|
||||
if result is not None:
|
||||
return bool(result)
|
||||
return True
|
||||
if version == _GLibCVersion(2, 5):
|
||||
if hasattr(_manylinux, "manylinux1_compatible"):
|
||||
return bool(_manylinux.manylinux1_compatible)
|
||||
if version == _GLibCVersion(2, 12):
|
||||
if hasattr(_manylinux, "manylinux2010_compatible"):
|
||||
return bool(_manylinux.manylinux2010_compatible)
|
||||
if version == _GLibCVersion(2, 17):
|
||||
if hasattr(_manylinux, "manylinux2014_compatible"):
|
||||
return bool(_manylinux.manylinux2014_compatible)
|
||||
if version == _GLibCVersion(2, 5) and hasattr(_manylinux, "manylinux1_compatible"):
|
||||
return bool(_manylinux.manylinux1_compatible)
|
||||
if version == _GLibCVersion(2, 12) and hasattr(
|
||||
_manylinux, "manylinux2010_compatible"
|
||||
):
|
||||
return bool(_manylinux.manylinux2010_compatible)
|
||||
if version == _GLibCVersion(2, 17) and hasattr(
|
||||
_manylinux, "manylinux2014_compatible"
|
||||
):
|
||||
return bool(_manylinux.manylinux2014_compatible)
|
||||
return True
|
||||
|
||||
|
||||
_LEGACY_MANYLINUX_MAP = {
|
||||
_LEGACY_MANYLINUX_MAP: dict[_GLibCVersion, str] = {
|
||||
# CentOS 7 w/ glibc 2.17 (PEP 599)
|
||||
(2, 17): "manylinux2014",
|
||||
_GLibCVersion(2, 17): "manylinux2014",
|
||||
# CentOS 6 w/ glibc 2.12 (PEP 571)
|
||||
(2, 12): "manylinux2010",
|
||||
_GLibCVersion(2, 12): "manylinux2010",
|
||||
# CentOS 5 w/ glibc 2.5 (PEP 513)
|
||||
(2, 5): "manylinux1",
|
||||
_GLibCVersion(2, 5): "manylinux1",
|
||||
}
|
||||
|
||||
|
||||
@@ -252,11 +254,9 @@ def platform_tags(archs: Sequence[str]) -> Iterator[str]:
|
||||
min_minor = -1
|
||||
for glibc_minor in range(glibc_max.minor, min_minor, -1):
|
||||
glibc_version = _GLibCVersion(glibc_max.major, glibc_minor)
|
||||
tag = "manylinux_{}_{}".format(*glibc_version)
|
||||
if _is_compatible(arch, glibc_version):
|
||||
yield f"{tag}_{arch}"
|
||||
# Handle the legacy manylinux1, manylinux2010, manylinux2014 tags.
|
||||
if glibc_version in _LEGACY_MANYLINUX_MAP:
|
||||
legacy_tag = _LEGACY_MANYLINUX_MAP[glibc_version]
|
||||
if _is_compatible(arch, glibc_version):
|
||||
yield "manylinux_{}_{}_{}".format(*glibc_version, arch)
|
||||
|
||||
# Handle the legacy manylinux1, manylinux2010, manylinux2014 tags.
|
||||
if legacy_tag := _LEGACY_MANYLINUX_MAP.get(glibc_version):
|
||||
yield f"{legacy_tag}_{arch}"
|
||||
|
||||
@@ -49,7 +49,7 @@ def _get_musl_version(executable: str) -> _MuslVersion | None:
|
||||
return None
|
||||
if ld is None or "musl" not in ld:
|
||||
return None
|
||||
proc = subprocess.run([ld], stderr=subprocess.PIPE, text=True)
|
||||
proc = subprocess.run([ld], check=False, stderr=subprocess.PIPE, text=True)
|
||||
return _parse_musl_version(proc.stderr)
|
||||
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ the implementation.
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from typing import NamedTuple, Sequence, Tuple, Union
|
||||
from typing import List, Literal, NamedTuple, Sequence, Tuple, Union
|
||||
|
||||
from ._tokenizer import DEFAULT_RULES, Tokenizer
|
||||
|
||||
|
||||
class Node:
|
||||
__slots__ = ("value",)
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
self.value = value
|
||||
|
||||
@@ -20,31 +22,66 @@ class Node:
|
||||
return self.value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}('{self}')>"
|
||||
return f"<{self.__class__.__name__}({self.value!r})>"
|
||||
|
||||
def serialize(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def __getstate__(self) -> str:
|
||||
# Return just the value string for compactness and stability.
|
||||
return self.value
|
||||
|
||||
def _restore_value(self, value: object) -> None:
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(
|
||||
f"Cannot restore {self.__class__.__name__} value from {value!r}"
|
||||
)
|
||||
self.value = value
|
||||
|
||||
def __setstate__(self, state: object) -> None:
|
||||
if isinstance(state, str):
|
||||
# New format (26.2+): just the value string.
|
||||
self._restore_value(state)
|
||||
return
|
||||
if isinstance(state, tuple) and len(state) == 2:
|
||||
# Old format (packaging <= 26.0, __slots__): (None, {slot: value}).
|
||||
_, slot_dict = state
|
||||
if isinstance(slot_dict, dict) and "value" in slot_dict:
|
||||
self._restore_value(slot_dict["value"])
|
||||
return
|
||||
if isinstance(state, dict) and "value" in state:
|
||||
# Old format (packaging <= 25.0, no __slots__): plain __dict__.
|
||||
self._restore_value(state["value"])
|
||||
return
|
||||
raise TypeError(f"Cannot restore {self.__class__.__name__} from {state!r}")
|
||||
|
||||
|
||||
class Variable(Node):
|
||||
__slots__ = ()
|
||||
|
||||
def serialize(self) -> str:
|
||||
return str(self)
|
||||
|
||||
|
||||
class Value(Node):
|
||||
__slots__ = ()
|
||||
|
||||
def serialize(self) -> str:
|
||||
return f'"{self}"'
|
||||
|
||||
|
||||
class Op(Node):
|
||||
__slots__ = ()
|
||||
|
||||
def serialize(self) -> str:
|
||||
return str(self)
|
||||
|
||||
|
||||
MarkerLogical = Literal["and", "or"]
|
||||
MarkerVar = Union[Variable, Value]
|
||||
MarkerItem = Tuple[MarkerVar, Op, MarkerVar]
|
||||
MarkerAtom = Union[MarkerItem, Sequence["MarkerAtom"]]
|
||||
MarkerList = Sequence[Union["MarkerList", MarkerAtom, str]]
|
||||
MarkerList = List[Union["MarkerList", MarkerAtom, MarkerLogical]]
|
||||
|
||||
|
||||
class ParsedRequirement(NamedTuple):
|
||||
@@ -111,7 +148,9 @@ def _parse_requirement_details(
|
||||
return (url, specifier, marker)
|
||||
|
||||
marker = _parse_requirement_marker(
|
||||
tokenizer, span_start=url_start, after="URL and whitespace"
|
||||
tokenizer,
|
||||
span_start=url_start,
|
||||
expected="semicolon (after URL and whitespace)",
|
||||
)
|
||||
else:
|
||||
specifier_start = tokenizer.position
|
||||
@@ -124,10 +163,10 @@ def _parse_requirement_details(
|
||||
marker = _parse_requirement_marker(
|
||||
tokenizer,
|
||||
span_start=specifier_start,
|
||||
after=(
|
||||
"version specifier"
|
||||
expected=(
|
||||
"comma (within version specifier), semicolon (after version specifier)"
|
||||
if specifier
|
||||
else "name and no valid version specifier"
|
||||
else "semicolon (after name with no version specifier)"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -135,7 +174,7 @@ def _parse_requirement_details(
|
||||
|
||||
|
||||
def _parse_requirement_marker(
|
||||
tokenizer: Tokenizer, *, span_start: int, after: str
|
||||
tokenizer: Tokenizer, *, span_start: int, expected: str
|
||||
) -> MarkerList:
|
||||
"""
|
||||
requirement_marker = SEMICOLON marker WS?
|
||||
@@ -143,8 +182,9 @@ def _parse_requirement_marker(
|
||||
|
||||
if not tokenizer.check("SEMICOLON"):
|
||||
tokenizer.raise_syntax_error(
|
||||
f"Expected end or semicolon (after {after})",
|
||||
f"Expected {expected} or end",
|
||||
span_start=span_start,
|
||||
span_end=None,
|
||||
)
|
||||
tokenizer.read()
|
||||
|
||||
@@ -307,7 +347,7 @@ def _parse_marker_item(tokenizer: Tokenizer) -> MarkerItem:
|
||||
return (marker_var_left, marker_op, marker_var_right)
|
||||
|
||||
|
||||
def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar:
|
||||
def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar: # noqa: RET503
|
||||
"""
|
||||
marker_var = VARIABLE | QUOTED_STRING
|
||||
"""
|
||||
|
||||
@@ -2,60 +2,32 @@
|
||||
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
|
||||
# for complete details.
|
||||
|
||||
"""Backward-compatibility shim for unpickling Version objects serialized before
|
||||
packaging 26.1.
|
||||
|
||||
Old pickles reference ``packaging._structures.InfinityType`` and
|
||||
``packaging._structures.NegativeInfinityType``. This module provides minimal
|
||||
stand-in classes so that ``pickle.loads()`` can resolve those references.
|
||||
The deserialized objects are not used for comparisons — ``Version.__setstate__``
|
||||
discards the stale ``_key`` cache and recomputes it from the core version fields.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class InfinityType:
|
||||
"""Stand-in for the removed ``InfinityType`` used in old comparison keys."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "Infinity"
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(repr(self))
|
||||
|
||||
def __lt__(self, other: object) -> bool:
|
||||
return False
|
||||
|
||||
def __le__(self, other: object) -> bool:
|
||||
return False
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, self.__class__)
|
||||
|
||||
def __gt__(self, other: object) -> bool:
|
||||
return True
|
||||
|
||||
def __ge__(self, other: object) -> bool:
|
||||
return True
|
||||
|
||||
def __neg__(self: object) -> "NegativeInfinityType":
|
||||
return NegativeInfinity
|
||||
|
||||
|
||||
Infinity = InfinityType()
|
||||
|
||||
|
||||
class NegativeInfinityType:
|
||||
"""Stand-in for the removed ``NegativeInfinityType`` used in old comparison keys."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "-Infinity"
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(repr(self))
|
||||
|
||||
def __lt__(self, other: object) -> bool:
|
||||
return True
|
||||
|
||||
def __le__(self, other: object) -> bool:
|
||||
return True
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, self.__class__)
|
||||
|
||||
def __gt__(self, other: object) -> bool:
|
||||
return False
|
||||
|
||||
def __ge__(self, other: object) -> bool:
|
||||
return False
|
||||
|
||||
def __neg__(self: object) -> InfinityType:
|
||||
return Infinity
|
||||
|
||||
|
||||
Infinity = InfinityType()
|
||||
NegativeInfinity = NegativeInfinityType()
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator, NoReturn
|
||||
from typing import Generator, Mapping, NoReturn
|
||||
|
||||
from .specifiers import Specifier
|
||||
|
||||
@@ -33,16 +33,16 @@ class ParserSyntaxError(Exception):
|
||||
|
||||
def __str__(self) -> str:
|
||||
marker = " " * self.span[0] + "~" * (self.span[1] - self.span[0]) + "^"
|
||||
return "\n ".join([self.message, self.source, marker])
|
||||
return f"{self.message}\n {self.source}\n {marker}"
|
||||
|
||||
|
||||
DEFAULT_RULES: dict[str, str | re.Pattern[str]] = {
|
||||
"LEFT_PARENTHESIS": r"\(",
|
||||
"RIGHT_PARENTHESIS": r"\)",
|
||||
"LEFT_BRACKET": r"\[",
|
||||
"RIGHT_BRACKET": r"\]",
|
||||
"SEMICOLON": r";",
|
||||
"COMMA": r",",
|
||||
DEFAULT_RULES: dict[str, re.Pattern[str]] = {
|
||||
"LEFT_PARENTHESIS": re.compile(r"\("),
|
||||
"RIGHT_PARENTHESIS": re.compile(r"\)"),
|
||||
"LEFT_BRACKET": re.compile(r"\["),
|
||||
"RIGHT_BRACKET": re.compile(r"\]"),
|
||||
"SEMICOLON": re.compile(r";"),
|
||||
"COMMA": re.compile(r","),
|
||||
"QUOTED_STRING": re.compile(
|
||||
r"""
|
||||
(
|
||||
@@ -53,10 +53,10 @@ DEFAULT_RULES: dict[str, str | re.Pattern[str]] = {
|
||||
""",
|
||||
re.VERBOSE,
|
||||
),
|
||||
"OP": r"(===|==|~=|!=|<=|>=|<|>)",
|
||||
"BOOLOP": r"\b(or|and)\b",
|
||||
"IN": r"\bin\b",
|
||||
"NOT": r"\bnot\b",
|
||||
"OP": re.compile(r"(===|==|~=|!=|<=|>=|<|>)"),
|
||||
"BOOLOP": re.compile(r"\b(or|and)\b"),
|
||||
"IN": re.compile(r"\bin\b"),
|
||||
"NOT": re.compile(r"\bnot\b"),
|
||||
"VARIABLE": re.compile(
|
||||
r"""
|
||||
\b(
|
||||
@@ -75,16 +75,16 @@ DEFAULT_RULES: dict[str, str | re.Pattern[str]] = {
|
||||
re.VERBOSE,
|
||||
),
|
||||
"SPECIFIER": re.compile(
|
||||
Specifier._operator_regex_str + Specifier._version_regex_str,
|
||||
Specifier._specifier_regex_str,
|
||||
re.VERBOSE | re.IGNORECASE,
|
||||
),
|
||||
"AT": r"\@",
|
||||
"URL": r"[^ \t]+",
|
||||
"IDENTIFIER": r"\b[a-zA-Z0-9][a-zA-Z0-9._-]*\b",
|
||||
"VERSION_PREFIX_TRAIL": r"\.\*",
|
||||
"VERSION_LOCAL_LABEL_TRAIL": r"\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*",
|
||||
"WS": r"[ \t]+",
|
||||
"END": r"$",
|
||||
"AT": re.compile(r"\@"),
|
||||
"URL": re.compile(r"[^ \t]+"),
|
||||
"IDENTIFIER": re.compile(r"\b[a-zA-Z0-9][a-zA-Z0-9._-]*\b"),
|
||||
"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"$"),
|
||||
}
|
||||
|
||||
|
||||
@@ -99,12 +99,10 @@ class Tokenizer:
|
||||
self,
|
||||
source: str,
|
||||
*,
|
||||
rules: dict[str, str | re.Pattern[str]],
|
||||
rules: Mapping[str, re.Pattern[str]],
|
||||
) -> None:
|
||||
self.source = source
|
||||
self.rules: dict[str, re.Pattern[str]] = {
|
||||
name: re.compile(pattern) for name, pattern in rules.items()
|
||||
}
|
||||
self.rules = rules
|
||||
self.next_token: Token | None = None
|
||||
self.position = 0
|
||||
|
||||
@@ -174,7 +172,7 @@ class Tokenizer:
|
||||
@contextlib.contextmanager
|
||||
def enclosing_tokens(
|
||||
self, open_token: str, close_token: str, *, around: str
|
||||
) -> Iterator[None]:
|
||||
) -> Generator[None, None, None]:
|
||||
if self.check(open_token):
|
||||
open_position = self.position
|
||||
self.read()
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from .errors import _ErrorCollector
|
||||
from .requirements import Requirement
|
||||
|
||||
__all__ = [
|
||||
"CyclicDependencyGroup",
|
||||
"DependencyGroupInclude",
|
||||
"DependencyGroupResolver",
|
||||
"DuplicateGroupNames",
|
||||
"InvalidDependencyGroupObject",
|
||||
"resolve_dependency_groups",
|
||||
]
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return __all__
|
||||
|
||||
|
||||
# -----------
|
||||
# Error Types
|
||||
# -----------
|
||||
|
||||
|
||||
class DuplicateGroupNames(ValueError):
|
||||
"""
|
||||
The same dependency groups were defined twice, with different non-normalized names.
|
||||
"""
|
||||
|
||||
|
||||
class CyclicDependencyGroup(ValueError):
|
||||
"""
|
||||
The dependency group includes form a cycle.
|
||||
"""
|
||||
|
||||
def __init__(self, requested_group: str, group: str, include_group: str) -> None:
|
||||
self.requested_group = requested_group
|
||||
self.group = group
|
||||
self.include_group = include_group
|
||||
|
||||
if include_group == group:
|
||||
reason = f"{group} includes itself"
|
||||
else:
|
||||
reason = f"{include_group} -> {group}, {group} -> {include_group}"
|
||||
super().__init__(
|
||||
"Cyclic dependency group include while resolving "
|
||||
f"{requested_group}: {reason}"
|
||||
)
|
||||
|
||||
|
||||
# in the PEP 735 spec, the tables in dependency group lists were described as
|
||||
# "Dependency Object Specifiers", but the only defined type of object was a
|
||||
# "Dependency Group Include" -- hence the naming of this error as "Object"
|
||||
class InvalidDependencyGroupObject(ValueError):
|
||||
"""
|
||||
A member of a dependency group was identified as a dict, but was not in a valid
|
||||
format.
|
||||
"""
|
||||
|
||||
|
||||
# ------------------------
|
||||
# Object Model & Interface
|
||||
# ------------------------
|
||||
|
||||
|
||||
class DependencyGroupInclude:
|
||||
__slots__ = ("include_group",)
|
||||
|
||||
def __init__(self, include_group: str) -> None:
|
||||
"""
|
||||
Initialize a DependencyGroupInclude.
|
||||
|
||||
:param include_group: The name of the group referred to by this include.
|
||||
"""
|
||||
self.include_group = include_group
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.include_group!r})"
|
||||
|
||||
|
||||
class DependencyGroupResolver:
|
||||
"""
|
||||
A resolver for Dependency Group data.
|
||||
|
||||
This class handles caching, name normalization, cycle detection, and other
|
||||
parsing requirements. There are only two public methods for exploring the data:
|
||||
``lookup()`` and ``resolve()``.
|
||||
|
||||
:param dependency_groups: A mapping, as provided via pyproject
|
||||
``[dependency-groups]``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]],
|
||||
) -> None:
|
||||
errors = _ErrorCollector()
|
||||
|
||||
self.dependency_groups = _normalize_group_names(dependency_groups, errors)
|
||||
|
||||
# a map of group names to parsed data
|
||||
self._parsed_groups: dict[
|
||||
str, tuple[Requirement | DependencyGroupInclude, ...]
|
||||
] = {}
|
||||
# a map of group names to their ancestors, used for cycle detection
|
||||
self._include_graph_ancestors: dict[str, tuple[str, ...]] = {}
|
||||
# a cache of completed resolutions to Requirement lists
|
||||
self._resolve_cache: dict[str, tuple[Requirement, ...]] = {}
|
||||
|
||||
errors.finalize("[dependency-groups] data was invalid")
|
||||
|
||||
def lookup(self, group: str) -> tuple[Requirement | DependencyGroupInclude, ...]:
|
||||
"""
|
||||
Lookup a group name, returning the parsed dependency data for that group.
|
||||
This will not resolve includes.
|
||||
|
||||
:param group: the name of the group to lookup
|
||||
"""
|
||||
group = _normalize_name(group)
|
||||
|
||||
with _ErrorCollector().on_exit(
|
||||
f"[dependency-groups] data for {group!r} was malformed"
|
||||
) as errors:
|
||||
return self._parse_group(group, errors)
|
||||
|
||||
def resolve(self, group: str) -> tuple[Requirement, ...]:
|
||||
"""
|
||||
Resolve a dependency group to a list of requirements.
|
||||
|
||||
:param group: the name of the group to resolve
|
||||
"""
|
||||
group = _normalize_name(group)
|
||||
|
||||
with _ErrorCollector().on_exit(
|
||||
f"[dependency-groups] data for {group!r} was malformed"
|
||||
) as errors:
|
||||
return self._resolve(group, group, errors)
|
||||
|
||||
def _resolve(
|
||||
self, group: str, requested_group: str, errors: _ErrorCollector
|
||||
) -> tuple[Requirement, ...]:
|
||||
"""
|
||||
This is a helper for cached resolution to strings. It preserves the name of the
|
||||
group which the user initially requested in order to present a clearer error in
|
||||
the event that a cycle is detected.
|
||||
|
||||
:param group: The normalized name of the group to resolve.
|
||||
:param requested_group: The group which was used in the original, user-facing
|
||||
request.
|
||||
"""
|
||||
if group in self._resolve_cache:
|
||||
return self._resolve_cache[group]
|
||||
|
||||
parsed = self._parse_group(group, errors)
|
||||
|
||||
resolved_group = []
|
||||
|
||||
for item in parsed:
|
||||
if isinstance(item, Requirement):
|
||||
resolved_group.append(item)
|
||||
elif isinstance(item, DependencyGroupInclude):
|
||||
include_group = _normalize_name(item.include_group)
|
||||
|
||||
# if a group is cyclic, record the error
|
||||
# otherwise, follow the include_group reference
|
||||
#
|
||||
# this allows us to examine all includes in a group, even in the
|
||||
# presence of errors
|
||||
if include_group in self._include_graph_ancestors.get(group, ()):
|
||||
errors.error(
|
||||
CyclicDependencyGroup(
|
||||
requested_group, group, item.include_group
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._include_graph_ancestors[include_group] = (
|
||||
*self._include_graph_ancestors.get(group, ()),
|
||||
group,
|
||||
)
|
||||
resolved_group.extend(
|
||||
self._resolve(include_group, requested_group, errors)
|
||||
)
|
||||
else: # pragma: no cover
|
||||
raise NotImplementedError(
|
||||
f"Invalid dependency group item after parse: {item}"
|
||||
)
|
||||
|
||||
# in the event that errors were detected, present the group as empty and do not
|
||||
# cache the result
|
||||
# this ensures that repeated access to a cyclic group will raise multiple errors
|
||||
if errors.errors:
|
||||
return ()
|
||||
|
||||
self._resolve_cache[group] = tuple(resolved_group)
|
||||
return self._resolve_cache[group]
|
||||
|
||||
def _parse_group(
|
||||
self, group: str, errors: _ErrorCollector
|
||||
) -> tuple[Requirement | DependencyGroupInclude, ...]:
|
||||
# short circuit -- never do the work twice
|
||||
if group in self._parsed_groups:
|
||||
return self._parsed_groups[group]
|
||||
|
||||
if group not in self.dependency_groups:
|
||||
errors.error(LookupError(f"Dependency group '{group}' not found"))
|
||||
return ()
|
||||
|
||||
raw_group = self.dependency_groups[group]
|
||||
if isinstance(raw_group, str):
|
||||
errors.error(
|
||||
TypeError(
|
||||
f"Dependency group {group!r} contained a string rather than a list."
|
||||
)
|
||||
)
|
||||
return ()
|
||||
|
||||
if not isinstance(raw_group, Sequence):
|
||||
errors.error(
|
||||
TypeError(f"Dependency group {group!r} is not a sequence type.")
|
||||
)
|
||||
return ()
|
||||
|
||||
elements: list[Requirement | DependencyGroupInclude] = []
|
||||
for item in raw_group:
|
||||
if isinstance(item, str):
|
||||
# packaging.requirements.Requirement parsing ensures that this is a
|
||||
# valid PEP 508 Dependency Specifier
|
||||
# raises InvalidRequirement on failure
|
||||
elements.append(Requirement(item))
|
||||
elif isinstance(item, Mapping):
|
||||
if tuple(item.keys()) != ("include-group",):
|
||||
errors.error(
|
||||
InvalidDependencyGroupObject(
|
||||
f"Invalid dependency group item: {item!r}"
|
||||
)
|
||||
)
|
||||
else:
|
||||
include_group = item["include-group"]
|
||||
elements.append(DependencyGroupInclude(include_group=include_group))
|
||||
else:
|
||||
errors.error(TypeError(f"Invalid dependency group item: {item!r}"))
|
||||
|
||||
self._parsed_groups[group] = tuple(elements)
|
||||
return self._parsed_groups[group]
|
||||
|
||||
|
||||
# --------------------
|
||||
# Functional Interface
|
||||
# --------------------
|
||||
|
||||
|
||||
def resolve_dependency_groups(
|
||||
dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], /, *groups: str
|
||||
) -> tuple[str, ...]:
|
||||
"""
|
||||
Resolve a dependency group to a tuple of requirements, as strings.
|
||||
|
||||
:param dependency_groups: the parsed contents of the ``[dependency-groups]`` table
|
||||
from ``pyproject.toml``
|
||||
:param groups: the name of the group(s) to resolve
|
||||
"""
|
||||
resolver = DependencyGroupResolver(dependency_groups)
|
||||
return tuple(str(r) for group in groups for r in resolver.resolve(group))
|
||||
|
||||
|
||||
# ----------------
|
||||
# internal helpers
|
||||
# ----------------
|
||||
|
||||
|
||||
_NORMALIZE_PATTERN = re.compile(r"[-_.]+")
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
return _NORMALIZE_PATTERN.sub("-", name).lower()
|
||||
|
||||
|
||||
def _normalize_group_names(
|
||||
dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]],
|
||||
errors: _ErrorCollector,
|
||||
) -> dict[str, Sequence[str | Mapping[str, str]]]:
|
||||
original_names: dict[str, list[str]] = {}
|
||||
normalized_groups: dict[str, Sequence[str | Mapping[str, str]]] = {}
|
||||
|
||||
for group_name, value in dependency_groups.items():
|
||||
normed_group_name = _normalize_name(group_name)
|
||||
original_names.setdefault(normed_group_name, []).append(group_name)
|
||||
normalized_groups[normed_group_name] = value
|
||||
|
||||
for normed_name, names in original_names.items():
|
||||
if len(names) > 1:
|
||||
errors.error(
|
||||
DuplicateGroupNames(
|
||||
"Duplicate dependency group names: "
|
||||
f"{normed_name} ({', '.join(names)})"
|
||||
)
|
||||
)
|
||||
|
||||
return normalized_groups
|
||||
@@ -0,0 +1,325 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import re
|
||||
import urllib.parse
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
import sys
|
||||
from collections.abc import Collection
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self
|
||||
else:
|
||||
from typing_extensions import Self
|
||||
|
||||
__all__ = [
|
||||
"ArchiveInfo",
|
||||
"DirInfo",
|
||||
"DirectUrl",
|
||||
"DirectUrlValidationError",
|
||||
"VcsInfo",
|
||||
]
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return __all__
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class _FromMappingProtocol(Protocol): # pragma: no cover
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self: ...
|
||||
|
||||
|
||||
_FromMappingProtocolT = TypeVar("_FromMappingProtocolT", bound=_FromMappingProtocol)
|
||||
|
||||
|
||||
def _json_dict_factory(data: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
return {key: value for key, value in data if value is not None}
|
||||
|
||||
|
||||
def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None:
|
||||
"""Get a value from the dictionary and verify it's the expected type."""
|
||||
if (value := d.get(key)) is None:
|
||||
return None
|
||||
if not isinstance(value, expected_type):
|
||||
raise DirectUrlValidationError(
|
||||
f"Unexpected type {type(value).__name__} "
|
||||
f"(expected {expected_type.__name__})",
|
||||
context=key,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _get_required(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T:
|
||||
"""Get a required value from the dictionary and verify it's the expected type."""
|
||||
if (value := _get(d, expected_type, key)) is None:
|
||||
raise _DirectUrlRequiredKeyError(key)
|
||||
return value
|
||||
|
||||
|
||||
def _get_object(
|
||||
d: Mapping[str, Any], target_type: type[_FromMappingProtocolT], key: str
|
||||
) -> _FromMappingProtocolT | None:
|
||||
"""Get a dictionary value from the dictionary and convert it to a dataclass."""
|
||||
if (value := _get(d, Mapping, key)) is None: # type: ignore[type-abstract]
|
||||
return None
|
||||
try:
|
||||
return target_type._from_dict(value)
|
||||
except Exception as e:
|
||||
raise DirectUrlValidationError(e, context=key) from e
|
||||
|
||||
|
||||
_PEP610_USER_PASS_ENV_VARS_REGEX = re.compile(
|
||||
r"^\$\{[A-Za-z0-9-_]+\}(:\$\{[A-Za-z0-9-_]+\})?$"
|
||||
)
|
||||
|
||||
|
||||
def _strip_auth_from_netloc(netloc: str, safe_user_passwords: Collection[str]) -> str:
|
||||
if "@" not in netloc:
|
||||
return netloc
|
||||
user_pass, netloc_no_user_pass = netloc.split("@", 1)
|
||||
if user_pass in safe_user_passwords:
|
||||
return netloc
|
||||
if _PEP610_USER_PASS_ENV_VARS_REGEX.match(user_pass):
|
||||
return netloc
|
||||
return netloc_no_user_pass
|
||||
|
||||
|
||||
def _strip_url(url: str, safe_user_passwords: Collection[str]) -> str:
|
||||
"""url with user:password part removed unless it is formed with
|
||||
environment variables as specified in PEP 610, or it is a safe user:password
|
||||
such as `git`.
|
||||
"""
|
||||
parsed_url = urllib.parse.urlsplit(url)
|
||||
netloc = _strip_auth_from_netloc(parsed_url.netloc, safe_user_passwords)
|
||||
return urllib.parse.urlunsplit(
|
||||
(
|
||||
parsed_url.scheme,
|
||||
netloc,
|
||||
parsed_url.path,
|
||||
parsed_url.query,
|
||||
parsed_url.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class DirectUrlValidationError(Exception):
|
||||
"""Raised when when input data is not spec-compliant."""
|
||||
|
||||
context: str | None = None
|
||||
message: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cause: str | Exception,
|
||||
*,
|
||||
context: str | None = None,
|
||||
) -> None:
|
||||
if isinstance(cause, DirectUrlValidationError):
|
||||
if cause.context:
|
||||
self.context = (
|
||||
f"{context}.{cause.context}" if context else cause.context
|
||||
)
|
||||
else:
|
||||
self.context = context # pragma: no cover
|
||||
self.message = cause.message
|
||||
else:
|
||||
self.context = context
|
||||
self.message = str(cause)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.context:
|
||||
return f"{self.message} in {self.context!r}"
|
||||
return self.message
|
||||
|
||||
|
||||
class _DirectUrlRequiredKeyError(DirectUrlValidationError):
|
||||
def __init__(self, key: str) -> None:
|
||||
super().__init__("Missing required value", context=key)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, init=False)
|
||||
class VcsInfo:
|
||||
vcs: str
|
||||
commit_id: str
|
||||
requested_revision: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
vcs: str,
|
||||
commit_id: str,
|
||||
requested_revision: str | None = None,
|
||||
) -> None:
|
||||
object.__setattr__(self, "vcs", vcs)
|
||||
object.__setattr__(self, "commit_id", commit_id)
|
||||
object.__setattr__(self, "requested_revision", requested_revision)
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
|
||||
# We can't validate vcs value because is not closed.
|
||||
return cls(
|
||||
vcs=_get_required(d, str, "vcs"),
|
||||
requested_revision=_get(d, str, "requested_revision"),
|
||||
commit_id=_get_required(d, str, "commit_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, init=False)
|
||||
class ArchiveInfo:
|
||||
hashes: Mapping[str, str] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
hashes: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
object.__setattr__(self, "hashes", hashes)
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
|
||||
hashes = _get(d, Mapping, "hashes") # type: ignore[type-abstract]
|
||||
if hashes is not None and not all(isinstance(h, str) for h in hashes.values()):
|
||||
raise DirectUrlValidationError(
|
||||
"Hash values must be strings", context="hashes"
|
||||
)
|
||||
legacy_hash = _get(d, str, "hash")
|
||||
if legacy_hash is not None:
|
||||
if "=" not in legacy_hash:
|
||||
raise DirectUrlValidationError(
|
||||
"Invalid hash format (expected '<algorithm>=<hash>')",
|
||||
context="hash",
|
||||
)
|
||||
hash_algorithm, hash_value = legacy_hash.split("=", 1)
|
||||
if hashes is None:
|
||||
# if `hashes` are not present, we can derive it from the legacy `hash`
|
||||
hashes = {hash_algorithm: hash_value}
|
||||
else:
|
||||
# if `hashes` are present, the legacy `hash` must match one of them
|
||||
if hash_algorithm not in hashes:
|
||||
raise DirectUrlValidationError(
|
||||
f"Algorithm {hash_algorithm!r} used in hash field "
|
||||
f"is not present in hashes field",
|
||||
context="hashes",
|
||||
)
|
||||
if hashes[hash_algorithm] != hash_value:
|
||||
raise DirectUrlValidationError(
|
||||
f"Algorithm {hash_algorithm!r} used in hash field "
|
||||
f"has different value in hashes field",
|
||||
context="hash",
|
||||
)
|
||||
return cls(hashes=hashes)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, init=False)
|
||||
class DirInfo:
|
||||
editable: bool | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
editable: bool | None = None,
|
||||
) -> None:
|
||||
object.__setattr__(self, "editable", editable)
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
|
||||
return cls(
|
||||
editable=_get(d, bool, "editable"),
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, init=False)
|
||||
class DirectUrl:
|
||||
"""A class representing a direct URL."""
|
||||
|
||||
url: str
|
||||
archive_info: ArchiveInfo | None = None
|
||||
vcs_info: VcsInfo | None = None
|
||||
dir_info: DirInfo | None = None
|
||||
subdirectory: str | None = None # XXX Path or str?
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
archive_info: ArchiveInfo | None = None,
|
||||
vcs_info: VcsInfo | None = None,
|
||||
dir_info: DirInfo | None = None,
|
||||
subdirectory: str | None = None,
|
||||
) -> None:
|
||||
object.__setattr__(self, "url", url)
|
||||
object.__setattr__(self, "archive_info", archive_info)
|
||||
object.__setattr__(self, "vcs_info", vcs_info)
|
||||
object.__setattr__(self, "dir_info", dir_info)
|
||||
object.__setattr__(self, "subdirectory", subdirectory)
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
|
||||
direct_url = cls(
|
||||
url=_get_required(d, str, "url"),
|
||||
archive_info=_get_object(d, ArchiveInfo, "archive_info"),
|
||||
vcs_info=_get_object(d, VcsInfo, "vcs_info"),
|
||||
dir_info=_get_object(d, DirInfo, "dir_info"),
|
||||
subdirectory=_get(d, str, "subdirectory"),
|
||||
)
|
||||
if (
|
||||
bool(direct_url.vcs_info)
|
||||
+ bool(direct_url.archive_info)
|
||||
+ bool(direct_url.dir_info)
|
||||
) != 1:
|
||||
raise DirectUrlValidationError(
|
||||
"Exactly one of vcs_info, archive_info, dir_info must be present"
|
||||
)
|
||||
if direct_url.dir_info is not None and not direct_url.url.startswith("file://"):
|
||||
raise DirectUrlValidationError(
|
||||
"URL scheme must be file:// when dir_info is present",
|
||||
context="url",
|
||||
)
|
||||
# XXX subdirectory must be relative, can we, should we validate that here?
|
||||
return direct_url
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Mapping[str, Any], /) -> Self:
|
||||
"""Create and validate a DirectUrl instance from a JSON dictionary."""
|
||||
return cls._from_dict(d)
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
*,
|
||||
generate_legacy_hash: bool = False,
|
||||
strip_user_password: bool = True,
|
||||
safe_user_passwords: Collection[str] = ("git",),
|
||||
) -> Mapping[str, Any]:
|
||||
"""Convert the DirectUrl instance to a JSON dictionary.
|
||||
|
||||
:param generate_legacy_hash: If True, include a legacy `hash` field in
|
||||
`archive_info` for backward compatibility with tools that don't
|
||||
support the `hashes` field.
|
||||
:param strip_user_password: If True, strip user:password from the URL
|
||||
unless it is formed with environment variables as specified in PEP
|
||||
610, or it is a safe user:password such as `git`.
|
||||
:param safe_user_passwords: A collection of user:password strings that
|
||||
should not be stripped from the URL even if `strip_user_password` is
|
||||
True.
|
||||
"""
|
||||
res = dataclasses.asdict(self, dict_factory=_json_dict_factory)
|
||||
if generate_legacy_hash and self.archive_info and self.archive_info.hashes:
|
||||
hash_algorithm, hash_value = next(iter(self.archive_info.hashes.items()))
|
||||
res["archive_info"]["hash"] = f"{hash_algorithm}={hash_value}"
|
||||
if strip_user_password:
|
||||
res["url"] = _strip_url(self.url, safe_user_passwords)
|
||||
return res
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate the DirectUrl instance against the specification.
|
||||
|
||||
Raises :class:`DirectUrlValidationError` if invalid.
|
||||
"""
|
||||
self.from_dict(self.to_dict())
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import sys
|
||||
import typing
|
||||
|
||||
__all__ = ["ExceptionGroup"]
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return __all__
|
||||
|
||||
|
||||
if sys.version_info >= (3, 11): # pragma: no cover
|
||||
from builtins import ExceptionGroup
|
||||
else: # pragma: no cover
|
||||
|
||||
class ExceptionGroup(Exception):
|
||||
"""A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11.
|
||||
|
||||
If :external:exc:`ExceptionGroup` is already defined by Python itself,
|
||||
that version is used instead.
|
||||
"""
|
||||
|
||||
message: str
|
||||
exceptions: list[Exception]
|
||||
|
||||
def __init__(self, message: str, exceptions: list[Exception]) -> None:
|
||||
self.message = message
|
||||
self.exceptions = exceptions
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _ErrorCollector:
|
||||
"""
|
||||
Collect errors into ExceptionGroups.
|
||||
|
||||
Used like this:
|
||||
|
||||
collector = _ErrorCollector()
|
||||
# Add a single exception
|
||||
collector.error(ValueError("one"))
|
||||
|
||||
# Supports nesting, including combining ExceptionGroups
|
||||
with collector.collect():
|
||||
raise ValueError("two")
|
||||
collector.finalize("Found some errors")
|
||||
|
||||
Since making a collector and then calling finalize later is a common pattern,
|
||||
a convenience method ``on_exit`` is provided.
|
||||
"""
|
||||
|
||||
errors: list[Exception] = dataclasses.field(default_factory=list, init=False)
|
||||
|
||||
def finalize(self, msg: str) -> None:
|
||||
"""Raise a group exception if there are any errors."""
|
||||
if self.errors:
|
||||
raise ExceptionGroup(msg, self.errors)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def on_exit(self, msg: str) -> typing.Generator[_ErrorCollector, None, None]:
|
||||
"""
|
||||
Calls finalize if no uncollected errors were present.
|
||||
|
||||
Uncollected errors are raised normally.
|
||||
"""
|
||||
yield self
|
||||
self.finalize(msg)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def collect(self, *err_cls: type[Exception]) -> typing.Generator[None, None, None]:
|
||||
"""
|
||||
Context manager to collect errors into the error list.
|
||||
|
||||
Must be inside loops, as only one error can be collected at a time.
|
||||
"""
|
||||
error_classes = err_cls or (Exception,)
|
||||
try:
|
||||
yield
|
||||
except ExceptionGroup as error:
|
||||
self.errors.extend(error.exceptions)
|
||||
except error_classes as error:
|
||||
self.errors.append(error)
|
||||
|
||||
def error(
|
||||
self,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
"""Add an error to the list."""
|
||||
self.errors.append(error)
|
||||
@@ -34,7 +34,7 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import NewType, cast
|
||||
|
||||
from packaging.licenses._spdx import EXCEPTIONS, LICENSES
|
||||
from ._spdx import EXCEPTIONS, LICENSES
|
||||
|
||||
__all__ = [
|
||||
"InvalidLicenseExpression",
|
||||
@@ -42,14 +42,25 @@ __all__ = [
|
||||
"canonicalize_license_expression",
|
||||
]
|
||||
|
||||
|
||||
# Simple __dir__ implementation since there are no public submodules
|
||||
def __dir__() -> list[str]:
|
||||
return __all__
|
||||
|
||||
|
||||
license_ref_allowed = re.compile("^[A-Za-z0-9.-]*$")
|
||||
|
||||
NormalizedLicenseExpression = NewType("NormalizedLicenseExpression", str)
|
||||
"""
|
||||
A :class:`typing.NewType` of :class:`str`, representing a normalized
|
||||
License-Expression.
|
||||
"""
|
||||
|
||||
|
||||
class InvalidLicenseExpression(ValueError):
|
||||
"""Raised when a license-expression string is invalid
|
||||
|
||||
>>> from packaging.licenses import canonicalize_license_expression
|
||||
>>> canonicalize_license_expression("invalid")
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
@@ -60,6 +71,34 @@ class InvalidLicenseExpression(ValueError):
|
||||
def canonicalize_license_expression(
|
||||
raw_license_expression: str,
|
||||
) -> NormalizedLicenseExpression:
|
||||
"""
|
||||
This function takes a valid License-Expression, and returns the normalized
|
||||
form of it.
|
||||
|
||||
The return type is typed as :class:`NormalizedLicenseExpression`. This
|
||||
allows type checkers to help require that a string has passed through this
|
||||
function before use.
|
||||
|
||||
:param str raw_license_expression: The License-Expression to canonicalize.
|
||||
:raises InvalidLicenseExpression: If the License-Expression is invalid due to an
|
||||
invalid/unknown license identifier or invalid syntax.
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> from packaging.licenses import canonicalize_license_expression
|
||||
>>> canonicalize_license_expression("mit")
|
||||
'MIT'
|
||||
>>> canonicalize_license_expression("mit and (apache-2.0 or bsd-2-clause)")
|
||||
'MIT AND (Apache-2.0 OR BSD-2-Clause)'
|
||||
>>> canonicalize_license_expression("(mit")
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
InvalidLicenseExpression: Invalid license expression: '(mit'
|
||||
>>> canonicalize_license_expression("Use-it-after-midnight")
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
InvalidLicenseExpression: Unknown license: 'Use-it-after-midnight'
|
||||
"""
|
||||
if not raw_license_expression:
|
||||
message = f"Invalid license expression: {raw_license_expression!r}"
|
||||
raise InvalidLicenseExpression(message)
|
||||
@@ -80,16 +119,21 @@ def canonicalize_license_expression(
|
||||
|
||||
tokens = license_expression.split()
|
||||
|
||||
# Rather than implementing boolean logic, we create an expression that Python can
|
||||
# parse. Everything that is not involved with the grammar itself is treated as
|
||||
# `False` and the expression should evaluate as such.
|
||||
# Rather than implementing a parenthesis/boolean logic parser, create an
|
||||
# expression that Python can parse. Everything that is not involved with the
|
||||
# grammar itself is replaced with the placeholder `False` and the resultant
|
||||
# expression should become a valid Python expression.
|
||||
python_tokens = []
|
||||
for token in tokens:
|
||||
if token not in {"or", "and", "with", "(", ")"}:
|
||||
python_tokens.append("False")
|
||||
elif token == "with":
|
||||
python_tokens.append("or")
|
||||
elif token == "(" and python_tokens and python_tokens[-1] not in {"or", "and"}:
|
||||
elif (
|
||||
token == "("
|
||||
and python_tokens
|
||||
and python_tokens[-1] not in {"or", "and", "("}
|
||||
) or (token == ")" and python_tokens and python_tokens[-1] == "("):
|
||||
message = f"Invalid license expression: {raw_license_expression!r}"
|
||||
raise InvalidLicenseExpression(message)
|
||||
else:
|
||||
@@ -97,11 +141,8 @@ def canonicalize_license_expression(
|
||||
|
||||
python_expression = " ".join(python_tokens)
|
||||
try:
|
||||
invalid = eval(python_expression, globals(), locals())
|
||||
except Exception:
|
||||
invalid = True
|
||||
|
||||
if invalid is not False:
|
||||
compile(python_expression, "", "eval")
|
||||
except SyntaxError:
|
||||
message = f"Invalid license expression: {raw_license_expression!r}"
|
||||
raise InvalidLicenseExpression(message) from None
|
||||
|
||||
@@ -140,6 +181,6 @@ def canonicalize_license_expression(
|
||||
normalized_expression = " ".join(normalized_tokens)
|
||||
|
||||
return cast(
|
||||
NormalizedLicenseExpression,
|
||||
"NormalizedLicenseExpression",
|
||||
normalized_expression.replace("( ", "(").replace(" )", ")"),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ class SPDXException(TypedDict):
|
||||
deprecated: bool
|
||||
|
||||
|
||||
VERSION = '3.25.0'
|
||||
VERSION = '3.27.0'
|
||||
|
||||
LICENSES: dict[str, SPDXLicense] = {
|
||||
'0bsd': {'id': '0BSD', 'deprecated': False},
|
||||
@@ -46,6 +46,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'antlr-pd': {'id': 'ANTLR-PD', 'deprecated': False},
|
||||
'antlr-pd-fallback': {'id': 'ANTLR-PD-fallback', 'deprecated': False},
|
||||
'any-osi': {'id': 'any-OSI', 'deprecated': False},
|
||||
'any-osi-perl-modules': {'id': 'any-OSI-perl-modules', 'deprecated': False},
|
||||
'apache-1.0': {'id': 'Apache-1.0', 'deprecated': False},
|
||||
'apache-1.1': {'id': 'Apache-1.1', 'deprecated': False},
|
||||
'apache-2.0': {'id': 'Apache-2.0', 'deprecated': False},
|
||||
@@ -61,6 +62,8 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'artistic-1.0-cl8': {'id': 'Artistic-1.0-cl8', 'deprecated': False},
|
||||
'artistic-1.0-perl': {'id': 'Artistic-1.0-Perl', 'deprecated': False},
|
||||
'artistic-2.0': {'id': 'Artistic-2.0', 'deprecated': False},
|
||||
'artistic-dist': {'id': 'Artistic-dist', 'deprecated': False},
|
||||
'aspell-ru': {'id': 'Aspell-RU', 'deprecated': False},
|
||||
'aswf-digital-assets-1.0': {'id': 'ASWF-Digital-Assets-1.0', 'deprecated': False},
|
||||
'aswf-digital-assets-1.1': {'id': 'ASWF-Digital-Assets-1.1', 'deprecated': False},
|
||||
'baekmuk': {'id': 'Baekmuk', 'deprecated': False},
|
||||
@@ -75,6 +78,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'blessing': {'id': 'blessing', 'deprecated': False},
|
||||
'blueoak-1.0.0': {'id': 'BlueOak-1.0.0', 'deprecated': False},
|
||||
'boehm-gc': {'id': 'Boehm-GC', 'deprecated': False},
|
||||
'boehm-gc-without-fee': {'id': 'Boehm-GC-without-fee', 'deprecated': False},
|
||||
'borceux': {'id': 'Borceux', 'deprecated': False},
|
||||
'brian-gladman-2-clause': {'id': 'Brian-Gladman-2-Clause', 'deprecated': False},
|
||||
'brian-gladman-3-clause': {'id': 'Brian-Gladman-3-Clause', 'deprecated': False},
|
||||
@@ -85,6 +89,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'bsd-2-clause-freebsd': {'id': 'BSD-2-Clause-FreeBSD', 'deprecated': True},
|
||||
'bsd-2-clause-netbsd': {'id': 'BSD-2-Clause-NetBSD', 'deprecated': True},
|
||||
'bsd-2-clause-patent': {'id': 'BSD-2-Clause-Patent', 'deprecated': False},
|
||||
'bsd-2-clause-pkgconf-disclaimer': {'id': 'BSD-2-Clause-pkgconf-disclaimer', 'deprecated': False},
|
||||
'bsd-2-clause-views': {'id': 'BSD-2-Clause-Views', 'deprecated': False},
|
||||
'bsd-3-clause': {'id': 'BSD-3-Clause', 'deprecated': False},
|
||||
'bsd-3-clause-acpica': {'id': 'BSD-3-Clause-acpica', 'deprecated': False},
|
||||
@@ -176,6 +181,8 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'cc-by-sa-3.0-igo': {'id': 'CC-BY-SA-3.0-IGO', 'deprecated': False},
|
||||
'cc-by-sa-4.0': {'id': 'CC-BY-SA-4.0', 'deprecated': False},
|
||||
'cc-pddc': {'id': 'CC-PDDC', 'deprecated': False},
|
||||
'cc-pdm-1.0': {'id': 'CC-PDM-1.0', 'deprecated': False},
|
||||
'cc-sa-1.0': {'id': 'CC-SA-1.0', 'deprecated': False},
|
||||
'cc0-1.0': {'id': 'CC0-1.0', 'deprecated': False},
|
||||
'cddl-1.0': {'id': 'CDDL-1.0', 'deprecated': False},
|
||||
'cddl-1.1': {'id': 'CDDL-1.1', 'deprecated': False},
|
||||
@@ -215,6 +222,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'cpol-1.02': {'id': 'CPOL-1.02', 'deprecated': False},
|
||||
'cronyx': {'id': 'Cronyx', 'deprecated': False},
|
||||
'crossword': {'id': 'Crossword', 'deprecated': False},
|
||||
'cryptoswift': {'id': 'CryptoSwift', 'deprecated': False},
|
||||
'crystalstacker': {'id': 'CrystalStacker', 'deprecated': False},
|
||||
'cua-opl-1.0': {'id': 'CUA-OPL-1.0', 'deprecated': False},
|
||||
'cube': {'id': 'Cube', 'deprecated': False},
|
||||
@@ -226,7 +234,9 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'dl-de-by-2.0': {'id': 'DL-DE-BY-2.0', 'deprecated': False},
|
||||
'dl-de-zero-2.0': {'id': 'DL-DE-ZERO-2.0', 'deprecated': False},
|
||||
'doc': {'id': 'DOC', 'deprecated': False},
|
||||
'docbook-dtd': {'id': 'DocBook-DTD', 'deprecated': False},
|
||||
'docbook-schema': {'id': 'DocBook-Schema', 'deprecated': False},
|
||||
'docbook-stylesheet': {'id': 'DocBook-Stylesheet', 'deprecated': False},
|
||||
'docbook-xml': {'id': 'DocBook-XML', 'deprecated': False},
|
||||
'dotseqn': {'id': 'Dotseqn', 'deprecated': False},
|
||||
'drl-1.0': {'id': 'DRL-1.0', 'deprecated': False},
|
||||
@@ -263,12 +273,17 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'fsfap-no-warranty-disclaimer': {'id': 'FSFAP-no-warranty-disclaimer', 'deprecated': False},
|
||||
'fsful': {'id': 'FSFUL', 'deprecated': False},
|
||||
'fsfullr': {'id': 'FSFULLR', 'deprecated': False},
|
||||
'fsfullrsd': {'id': 'FSFULLRSD', 'deprecated': False},
|
||||
'fsfullrwd': {'id': 'FSFULLRWD', 'deprecated': False},
|
||||
'fsl-1.1-alv2': {'id': 'FSL-1.1-ALv2', 'deprecated': False},
|
||||
'fsl-1.1-mit': {'id': 'FSL-1.1-MIT', 'deprecated': False},
|
||||
'ftl': {'id': 'FTL', 'deprecated': False},
|
||||
'furuseth': {'id': 'Furuseth', 'deprecated': False},
|
||||
'fwlw': {'id': 'fwlw', 'deprecated': False},
|
||||
'game-programming-gems': {'id': 'Game-Programming-Gems', 'deprecated': False},
|
||||
'gcr-docs': {'id': 'GCR-docs', 'deprecated': False},
|
||||
'gd': {'id': 'GD', 'deprecated': False},
|
||||
'generic-xts': {'id': 'generic-xts', 'deprecated': False},
|
||||
'gfdl-1.1': {'id': 'GFDL-1.1', 'deprecated': True},
|
||||
'gfdl-1.1-invariants-only': {'id': 'GFDL-1.1-invariants-only', 'deprecated': False},
|
||||
'gfdl-1.1-invariants-or-later': {'id': 'GFDL-1.1-invariants-or-later', 'deprecated': False},
|
||||
@@ -320,6 +335,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'gtkbook': {'id': 'gtkbook', 'deprecated': False},
|
||||
'gutmann': {'id': 'Gutmann', 'deprecated': False},
|
||||
'haskellreport': {'id': 'HaskellReport', 'deprecated': False},
|
||||
'hdf5': {'id': 'HDF5', 'deprecated': False},
|
||||
'hdparm': {'id': 'hdparm', 'deprecated': False},
|
||||
'hidapi': {'id': 'HIDAPI', 'deprecated': False},
|
||||
'hippocratic-2.1': {'id': 'Hippocratic-2.1', 'deprecated': False},
|
||||
@@ -360,6 +376,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'imlib2': {'id': 'Imlib2', 'deprecated': False},
|
||||
'info-zip': {'id': 'Info-ZIP', 'deprecated': False},
|
||||
'inner-net-2.0': {'id': 'Inner-Net-2.0', 'deprecated': False},
|
||||
'innosetup': {'id': 'InnoSetup', 'deprecated': False},
|
||||
'intel': {'id': 'Intel', 'deprecated': False},
|
||||
'intel-acpi': {'id': 'Intel-ACPI', 'deprecated': False},
|
||||
'interbase-1.0': {'id': 'Interbase-1.0', 'deprecated': False},
|
||||
@@ -369,6 +386,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'isc-veillard': {'id': 'ISC-Veillard', 'deprecated': False},
|
||||
'jam': {'id': 'Jam', 'deprecated': False},
|
||||
'jasper-2.0': {'id': 'JasPer-2.0', 'deprecated': False},
|
||||
'jove': {'id': 'jove', 'deprecated': False},
|
||||
'jpl-image': {'id': 'JPL-image', 'deprecated': False},
|
||||
'jpnic': {'id': 'JPNIC', 'deprecated': False},
|
||||
'json': {'id': 'JSON', 'deprecated': False},
|
||||
@@ -394,6 +412,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'lgpl-3.0-or-later': {'id': 'LGPL-3.0-or-later', 'deprecated': False},
|
||||
'lgpllr': {'id': 'LGPLLR', 'deprecated': False},
|
||||
'libpng': {'id': 'Libpng', 'deprecated': False},
|
||||
'libpng-1.6.35': {'id': 'libpng-1.6.35', 'deprecated': False},
|
||||
'libpng-2.0': {'id': 'libpng-2.0', 'deprecated': False},
|
||||
'libselinux-1.0': {'id': 'libselinux-1.0', 'deprecated': False},
|
||||
'libtiff': {'id': 'libtiff', 'deprecated': False},
|
||||
@@ -424,14 +443,17 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'magaz': {'id': 'magaz', 'deprecated': False},
|
||||
'mailprio': {'id': 'mailprio', 'deprecated': False},
|
||||
'makeindex': {'id': 'MakeIndex', 'deprecated': False},
|
||||
'man2html': {'id': 'man2html', 'deprecated': False},
|
||||
'martin-birgmeier': {'id': 'Martin-Birgmeier', 'deprecated': False},
|
||||
'mcphee-slideshow': {'id': 'McPhee-slideshow', 'deprecated': False},
|
||||
'metamail': {'id': 'metamail', 'deprecated': False},
|
||||
'minpack': {'id': 'Minpack', 'deprecated': False},
|
||||
'mips': {'id': 'MIPS', 'deprecated': False},
|
||||
'miros': {'id': 'MirOS', 'deprecated': False},
|
||||
'mit': {'id': 'MIT', 'deprecated': False},
|
||||
'mit-0': {'id': 'MIT-0', 'deprecated': False},
|
||||
'mit-advertising': {'id': 'MIT-advertising', 'deprecated': False},
|
||||
'mit-click': {'id': 'MIT-Click', 'deprecated': False},
|
||||
'mit-cmu': {'id': 'MIT-CMU', 'deprecated': False},
|
||||
'mit-enna': {'id': 'MIT-enna', 'deprecated': False},
|
||||
'mit-feh': {'id': 'MIT-feh', 'deprecated': False},
|
||||
@@ -472,6 +494,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'netcdf': {'id': 'NetCDF', 'deprecated': False},
|
||||
'newsletr': {'id': 'Newsletr', 'deprecated': False},
|
||||
'ngpl': {'id': 'NGPL', 'deprecated': False},
|
||||
'ngrep': {'id': 'ngrep', 'deprecated': False},
|
||||
'nicta-1.0': {'id': 'NICTA-1.0', 'deprecated': False},
|
||||
'nist-pd': {'id': 'NIST-PD', 'deprecated': False},
|
||||
'nist-pd-fallback': {'id': 'NIST-PD-fallback', 'deprecated': False},
|
||||
@@ -486,6 +509,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'npl-1.1': {'id': 'NPL-1.1', 'deprecated': False},
|
||||
'nposl-3.0': {'id': 'NPOSL-3.0', 'deprecated': False},
|
||||
'nrl': {'id': 'NRL', 'deprecated': False},
|
||||
'ntia-pd': {'id': 'NTIA-PD', 'deprecated': False},
|
||||
'ntp': {'id': 'NTP', 'deprecated': False},
|
||||
'ntp-0': {'id': 'NTP-0', 'deprecated': False},
|
||||
'nunit': {'id': 'Nunit', 'deprecated': True},
|
||||
@@ -580,6 +604,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'schemereport': {'id': 'SchemeReport', 'deprecated': False},
|
||||
'sendmail': {'id': 'Sendmail', 'deprecated': False},
|
||||
'sendmail-8.23': {'id': 'Sendmail-8.23', 'deprecated': False},
|
||||
'sendmail-open-source-1.1': {'id': 'Sendmail-Open-Source-1.1', 'deprecated': False},
|
||||
'sgi-b-1.0': {'id': 'SGI-B-1.0', 'deprecated': False},
|
||||
'sgi-b-1.1': {'id': 'SGI-B-1.1', 'deprecated': False},
|
||||
'sgi-b-2.0': {'id': 'SGI-B-2.0', 'deprecated': False},
|
||||
@@ -592,10 +617,12 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'sissl-1.2': {'id': 'SISSL-1.2', 'deprecated': False},
|
||||
'sl': {'id': 'SL', 'deprecated': False},
|
||||
'sleepycat': {'id': 'Sleepycat', 'deprecated': False},
|
||||
'smail-gpl': {'id': 'SMAIL-GPL', 'deprecated': False},
|
||||
'smlnj': {'id': 'SMLNJ', 'deprecated': False},
|
||||
'smppl': {'id': 'SMPPL', 'deprecated': False},
|
||||
'snia': {'id': 'SNIA', 'deprecated': False},
|
||||
'snprintf': {'id': 'snprintf', 'deprecated': False},
|
||||
'sofa': {'id': 'SOFA', 'deprecated': False},
|
||||
'softsurfer': {'id': 'softSurfer', 'deprecated': False},
|
||||
'soundex': {'id': 'Soundex', 'deprecated': False},
|
||||
'spencer-86': {'id': 'Spencer-86', 'deprecated': False},
|
||||
@@ -609,6 +636,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'sspl-1.0': {'id': 'SSPL-1.0', 'deprecated': False},
|
||||
'standardml-nj': {'id': 'StandardML-NJ', 'deprecated': True},
|
||||
'sugarcrm-1.1.3': {'id': 'SugarCRM-1.1.3', 'deprecated': False},
|
||||
'sul-1.0': {'id': 'SUL-1.0', 'deprecated': False},
|
||||
'sun-ppp': {'id': 'Sun-PPP', 'deprecated': False},
|
||||
'sun-ppp-2000': {'id': 'Sun-PPP-2000', 'deprecated': False},
|
||||
'sunpro': {'id': 'SunPro', 'deprecated': False},
|
||||
@@ -620,12 +648,14 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'tcp-wrappers': {'id': 'TCP-wrappers', 'deprecated': False},
|
||||
'termreadkey': {'id': 'TermReadKey', 'deprecated': False},
|
||||
'tgppl-1.0': {'id': 'TGPPL-1.0', 'deprecated': False},
|
||||
'thirdeye': {'id': 'ThirdEye', 'deprecated': False},
|
||||
'threeparttable': {'id': 'threeparttable', 'deprecated': False},
|
||||
'tmate': {'id': 'TMate', 'deprecated': False},
|
||||
'torque-1.1': {'id': 'TORQUE-1.1', 'deprecated': False},
|
||||
'tosl': {'id': 'TOSL', 'deprecated': False},
|
||||
'tpdl': {'id': 'TPDL', 'deprecated': False},
|
||||
'tpl-1.0': {'id': 'TPL-1.0', 'deprecated': False},
|
||||
'trustedqsl': {'id': 'TrustedQSL', 'deprecated': False},
|
||||
'ttwl': {'id': 'TTWL', 'deprecated': False},
|
||||
'ttyp0': {'id': 'TTYP0', 'deprecated': False},
|
||||
'tu-berlin-1.0': {'id': 'TU-Berlin-1.0', 'deprecated': False},
|
||||
@@ -641,6 +671,8 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'unicode-tou': {'id': 'Unicode-TOU', 'deprecated': False},
|
||||
'unixcrypt': {'id': 'UnixCrypt', 'deprecated': False},
|
||||
'unlicense': {'id': 'Unlicense', 'deprecated': False},
|
||||
'unlicense-libtelnet': {'id': 'Unlicense-libtelnet', 'deprecated': False},
|
||||
'unlicense-libwhirlpool': {'id': 'Unlicense-libwhirlpool', 'deprecated': False},
|
||||
'upl-1.0': {'id': 'UPL-1.0', 'deprecated': False},
|
||||
'urt-rle': {'id': 'URT-RLE', 'deprecated': False},
|
||||
'vim': {'id': 'Vim', 'deprecated': False},
|
||||
@@ -654,6 +686,7 @@ LICENSES: dict[str, SPDXLicense] = {
|
||||
'widget-workshop': {'id': 'Widget-Workshop', 'deprecated': False},
|
||||
'wsuipa': {'id': 'Wsuipa', 'deprecated': False},
|
||||
'wtfpl': {'id': 'WTFPL', 'deprecated': False},
|
||||
'wwl': {'id': 'wwl', 'deprecated': False},
|
||||
'wxwindows': {'id': 'wxWindows', 'deprecated': True},
|
||||
'x11': {'id': 'X11', 'deprecated': False},
|
||||
'x11-distribute-modifications-variant': {'id': 'X11-distribute-modifications-variant', 'deprecated': False},
|
||||
@@ -695,9 +728,11 @@ EXCEPTIONS: dict[str, SPDXException] = {
|
||||
'bison-exception-1.24': {'id': 'Bison-exception-1.24', 'deprecated': False},
|
||||
'bison-exception-2.2': {'id': 'Bison-exception-2.2', 'deprecated': False},
|
||||
'bootloader-exception': {'id': 'Bootloader-exception', 'deprecated': False},
|
||||
'cgal-linking-exception': {'id': 'CGAL-linking-exception', 'deprecated': False},
|
||||
'classpath-exception-2.0': {'id': 'Classpath-exception-2.0', 'deprecated': False},
|
||||
'clisp-exception-2.0': {'id': 'CLISP-exception-2.0', 'deprecated': False},
|
||||
'cryptsetup-openssl-exception': {'id': 'cryptsetup-OpenSSL-exception', 'deprecated': False},
|
||||
'digia-qt-lgpl-exception-1.1': {'id': 'Digia-Qt-LGPL-exception-1.1', 'deprecated': False},
|
||||
'digirule-foss-exception': {'id': 'DigiRule-FOSS-exception', 'deprecated': False},
|
||||
'ecos-exception-2.0': {'id': 'eCos-exception-2.0', 'deprecated': False},
|
||||
'erlang-otp-linking-exception': {'id': 'erlang-otp-linking-exception', 'deprecated': False},
|
||||
@@ -714,13 +749,16 @@ EXCEPTIONS: dict[str, SPDXException] = {
|
||||
'gnome-examples-exception': {'id': 'GNOME-examples-exception', 'deprecated': False},
|
||||
'gnu-compiler-exception': {'id': 'GNU-compiler-exception', 'deprecated': False},
|
||||
'gnu-javamail-exception': {'id': 'gnu-javamail-exception', 'deprecated': False},
|
||||
'gpl-3.0-389-ds-base-exception': {'id': 'GPL-3.0-389-ds-base-exception', 'deprecated': False},
|
||||
'gpl-3.0-interface-exception': {'id': 'GPL-3.0-interface-exception', 'deprecated': False},
|
||||
'gpl-3.0-linking-exception': {'id': 'GPL-3.0-linking-exception', 'deprecated': False},
|
||||
'gpl-3.0-linking-source-exception': {'id': 'GPL-3.0-linking-source-exception', 'deprecated': False},
|
||||
'gpl-cc-1.0': {'id': 'GPL-CC-1.0', 'deprecated': False},
|
||||
'gstreamer-exception-2005': {'id': 'GStreamer-exception-2005', 'deprecated': False},
|
||||
'gstreamer-exception-2008': {'id': 'GStreamer-exception-2008', 'deprecated': False},
|
||||
'harbour-exception': {'id': 'harbour-exception', 'deprecated': False},
|
||||
'i2p-gpl-java-exception': {'id': 'i2p-gpl-java-exception', 'deprecated': False},
|
||||
'independent-modules-exception': {'id': 'Independent-modules-exception', 'deprecated': False},
|
||||
'kicad-libraries-exception': {'id': 'KiCad-libraries-exception', 'deprecated': False},
|
||||
'lgpl-3.0-linking-exception': {'id': 'LGPL-3.0-linking-exception', 'deprecated': False},
|
||||
'libpri-openh323-exception': {'id': 'libpri-OpenH323-exception', 'deprecated': False},
|
||||
@@ -730,12 +768,14 @@ EXCEPTIONS: dict[str, SPDXException] = {
|
||||
'llvm-exception': {'id': 'LLVM-exception', 'deprecated': False},
|
||||
'lzma-exception': {'id': 'LZMA-exception', 'deprecated': False},
|
||||
'mif-exception': {'id': 'mif-exception', 'deprecated': False},
|
||||
'mxml-exception': {'id': 'mxml-exception', 'deprecated': False},
|
||||
'nokia-qt-exception-1.1': {'id': 'Nokia-Qt-exception-1.1', 'deprecated': True},
|
||||
'ocaml-lgpl-linking-exception': {'id': 'OCaml-LGPL-linking-exception', 'deprecated': False},
|
||||
'occt-exception-1.0': {'id': 'OCCT-exception-1.0', 'deprecated': False},
|
||||
'openjdk-assembly-exception-1.0': {'id': 'OpenJDK-assembly-exception-1.0', 'deprecated': False},
|
||||
'openvpn-openssl-exception': {'id': 'openvpn-openssl-exception', 'deprecated': False},
|
||||
'pcre2-exception': {'id': 'PCRE2-exception', 'deprecated': False},
|
||||
'polyparse-exception': {'id': 'polyparse-exception', 'deprecated': False},
|
||||
'ps-or-pdf-font-exception-20170817': {'id': 'PS-or-PDF-font-exception-20170817', 'deprecated': False},
|
||||
'qpl-1.0-inria-2004-exception': {'id': 'QPL-1.0-INRIA-2004-exception', 'deprecated': False},
|
||||
'qt-gpl-exception-1.0': {'id': 'Qt-GPL-exception-1.0', 'deprecated': False},
|
||||
|
||||
@@ -8,7 +8,7 @@ import operator
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from typing import AbstractSet, Any, Callable, Literal, TypedDict, Union, cast
|
||||
from typing import AbstractSet, Callable, Literal, Mapping, TypedDict, Union, cast
|
||||
|
||||
from ._parser import MarkerAtom, MarkerList, Op, Value, Variable
|
||||
from ._parser import parse_marker as _parse_marker
|
||||
@@ -17,6 +17,7 @@ from .specifiers import InvalidSpecifier, Specifier
|
||||
from .utils import canonicalize_name
|
||||
|
||||
__all__ = [
|
||||
"Environment",
|
||||
"EvaluateContext",
|
||||
"InvalidMarker",
|
||||
"Marker",
|
||||
@@ -25,31 +26,58 @@ __all__ = [
|
||||
"default_environment",
|
||||
]
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return __all__
|
||||
|
||||
|
||||
Operator = Callable[[str, Union[str, AbstractSet[str]]], bool]
|
||||
EvaluateContext = Literal["metadata", "lock_file", "requirement"]
|
||||
"""A ``typing.Literal`` enumerating valid marker evaluation contexts.
|
||||
|
||||
Valid values for the ``context`` passed to :meth:`Marker.evaluate` are:
|
||||
|
||||
* ``"metadata"`` (for core metadata; default)
|
||||
* ``"lock_file"`` (for lock files)
|
||||
* ``"requirement"`` (i.e. all other situations)
|
||||
"""
|
||||
|
||||
MARKERS_ALLOWING_SET = {"extras", "dependency_groups"}
|
||||
MARKERS_REQUIRING_VERSION = {
|
||||
"implementation_version",
|
||||
"platform_release",
|
||||
"python_full_version",
|
||||
"python_version",
|
||||
}
|
||||
|
||||
|
||||
class InvalidMarker(ValueError):
|
||||
"""
|
||||
An invalid marker was found, users should refer to PEP 508.
|
||||
"""Raised when attempting to create a :class:`Marker` from invalid input.
|
||||
|
||||
This error indicates that the given marker string does not conform to the
|
||||
:ref:`specification of dependency specifiers <pypug:dependency-specifiers>`.
|
||||
"""
|
||||
|
||||
|
||||
class UndefinedComparison(ValueError):
|
||||
"""
|
||||
An invalid operation was attempted on a value that doesn't support it.
|
||||
"""Raised when evaluating an unsupported marker comparison.
|
||||
|
||||
This can happen when marker values are compared as versions but do not
|
||||
conform to the :ref:`specification of version specifiers
|
||||
<pypug:version-specifiers>`.
|
||||
"""
|
||||
|
||||
|
||||
class UndefinedEnvironmentName(ValueError):
|
||||
"""
|
||||
A name was attempted to be used that does not exist inside of the
|
||||
environment.
|
||||
"""
|
||||
"""Raised when evaluating a marker that references a missing environment key."""
|
||||
|
||||
|
||||
class Environment(TypedDict):
|
||||
"""
|
||||
A dictionary that represents a Python environment as captured by
|
||||
:func:`default_environment`. All fields are required.
|
||||
"""
|
||||
|
||||
implementation_name: str
|
||||
"""The implementation's identifier, e.g. ``'cpython'``."""
|
||||
|
||||
@@ -121,20 +149,28 @@ class Environment(TypedDict):
|
||||
"""
|
||||
|
||||
|
||||
def _normalize_extra_values(results: Any) -> Any:
|
||||
def _normalize_extras(
|
||||
result: MarkerList | MarkerAtom | str,
|
||||
) -> MarkerList | MarkerAtom | str:
|
||||
if not isinstance(result, tuple):
|
||||
return result
|
||||
|
||||
lhs, op, rhs = result
|
||||
if isinstance(lhs, Variable) and lhs.value == "extra":
|
||||
normalized_extra = canonicalize_name(rhs.value)
|
||||
rhs = Value(normalized_extra)
|
||||
elif isinstance(rhs, Variable) and rhs.value == "extra":
|
||||
normalized_extra = canonicalize_name(lhs.value)
|
||||
lhs = Value(normalized_extra)
|
||||
return lhs, op, rhs
|
||||
|
||||
|
||||
def _normalize_extra_values(results: MarkerList) -> MarkerList:
|
||||
"""
|
||||
Normalize extra values.
|
||||
"""
|
||||
if isinstance(results[0], tuple):
|
||||
lhs, op, rhs = results[0]
|
||||
if isinstance(lhs, Variable) and lhs.value == "extra":
|
||||
normalized_extra = canonicalize_name(rhs.value)
|
||||
rhs = Value(normalized_extra)
|
||||
elif isinstance(rhs, Variable) and rhs.value == "extra":
|
||||
normalized_extra = canonicalize_name(lhs.value)
|
||||
lhs = Value(normalized_extra)
|
||||
results[0] = lhs, op, rhs
|
||||
return results
|
||||
|
||||
return [_normalize_extras(r) for r in results]
|
||||
|
||||
|
||||
def _format_marker(
|
||||
@@ -168,25 +204,26 @@ def _format_marker(
|
||||
_operators: dict[str, Operator] = {
|
||||
"in": lambda lhs, rhs: lhs in rhs,
|
||||
"not in": lambda lhs, rhs: lhs not in rhs,
|
||||
"<": operator.lt,
|
||||
"<=": operator.le,
|
||||
"<": lambda _lhs, _rhs: False,
|
||||
"<=": operator.eq,
|
||||
"==": operator.eq,
|
||||
"!=": operator.ne,
|
||||
">=": operator.ge,
|
||||
">": operator.gt,
|
||||
">=": operator.eq,
|
||||
">": lambda _lhs, _rhs: False,
|
||||
}
|
||||
|
||||
|
||||
def _eval_op(lhs: str, op: Op, rhs: str | AbstractSet[str]) -> bool:
|
||||
if isinstance(rhs, str):
|
||||
def _eval_op(lhs: str, op: Op, rhs: str | AbstractSet[str], *, key: str) -> bool:
|
||||
op_str = op.serialize()
|
||||
if key in MARKERS_REQUIRING_VERSION:
|
||||
try:
|
||||
spec = Specifier("".join([op.serialize(), rhs]))
|
||||
spec = Specifier(f"{op_str}{rhs}")
|
||||
except InvalidSpecifier:
|
||||
pass
|
||||
else:
|
||||
return spec.contains(lhs, prereleases=True)
|
||||
|
||||
oper: Operator | None = _operators.get(op.serialize())
|
||||
oper: Operator | None = _operators.get(op_str)
|
||||
if oper is None:
|
||||
raise UndefinedComparison(f"Undefined {op!r} on {lhs!r} and {rhs!r}.")
|
||||
|
||||
@@ -196,13 +233,14 @@ def _eval_op(lhs: str, op: Op, rhs: str | AbstractSet[str]) -> bool:
|
||||
def _normalize(
|
||||
lhs: str, rhs: str | AbstractSet[str], key: str
|
||||
) -> tuple[str, str | AbstractSet[str]]:
|
||||
# PEP 685 – Comparison of extra names for optional distribution dependencies
|
||||
# PEP 685 - Comparison of extra names for optional distribution dependencies
|
||||
# https://peps.python.org/pep-0685/
|
||||
# > When comparing extra names, tools MUST normalize the names being
|
||||
# > compared using the semantics outlined in PEP 503 for names
|
||||
if key == "extra":
|
||||
assert isinstance(rhs, str), "extra value must be a string"
|
||||
return (canonicalize_name(lhs), canonicalize_name(rhs))
|
||||
# Both sides are normalized at this point already
|
||||
return (lhs, rhs)
|
||||
if key in MARKERS_ALLOWING_SET:
|
||||
if isinstance(rhs, str): # pragma: no cover
|
||||
return (canonicalize_name(lhs), canonicalize_name(rhs))
|
||||
@@ -219,8 +257,6 @@ def _evaluate_markers(
|
||||
groups: list[list[bool]] = [[]]
|
||||
|
||||
for marker in markers:
|
||||
assert isinstance(marker, (list, tuple, str))
|
||||
|
||||
if isinstance(marker, list):
|
||||
groups[-1].append(_evaluate_markers(marker, environment))
|
||||
elif isinstance(marker, tuple):
|
||||
@@ -234,18 +270,21 @@ def _evaluate_markers(
|
||||
lhs_value = lhs.value
|
||||
environment_key = rhs.value
|
||||
rhs_value = environment[environment_key]
|
||||
|
||||
assert isinstance(lhs_value, str), "lhs must be a string"
|
||||
lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key)
|
||||
groups[-1].append(_eval_op(lhs_value, op, rhs_value))
|
||||
else:
|
||||
assert marker in ["and", "or"]
|
||||
if marker == "or":
|
||||
groups.append([])
|
||||
groups[-1].append(_eval_op(lhs_value, op, rhs_value, key=environment_key))
|
||||
elif marker == "or":
|
||||
groups.append([])
|
||||
elif marker == "and":
|
||||
pass
|
||||
else: # pragma: nocover
|
||||
raise TypeError(f"Unexpected marker {marker!r}")
|
||||
|
||||
return any(all(item) for item in groups)
|
||||
|
||||
|
||||
def format_full_version(info: sys._version_info) -> str:
|
||||
def _format_full_version(info: sys._version_info) -> str:
|
||||
version = f"{info.major}.{info.minor}.{info.micro}"
|
||||
kind = info.releaselevel
|
||||
if kind != "final":
|
||||
@@ -254,7 +293,11 @@ def format_full_version(info: sys._version_info) -> str:
|
||||
|
||||
|
||||
def default_environment() -> Environment:
|
||||
iver = format_full_version(sys.implementation.version)
|
||||
"""Return the default marker environment for the current Python process.
|
||||
|
||||
This is the base environment used by :meth:`Marker.evaluate`.
|
||||
"""
|
||||
iver = _format_full_version(sys.implementation.version)
|
||||
implementation_name = sys.implementation.name
|
||||
return {
|
||||
"implementation_name": implementation_name,
|
||||
@@ -272,10 +315,36 @@ def default_environment() -> Environment:
|
||||
|
||||
|
||||
class Marker:
|
||||
"""Represents a parsed dependency marker expression.
|
||||
|
||||
Marker expressions are parsed according to the
|
||||
:ref:`specification of dependency specifiers <pypug:dependency-specifiers>`.
|
||||
|
||||
:param marker: The string representation of a marker expression.
|
||||
:raises InvalidMarker: If ``marker`` cannot be parsed.
|
||||
|
||||
Instances are safe to serialize with :mod:`pickle`. They use a stable
|
||||
format so the same pickle can be loaded in future packaging releases.
|
||||
|
||||
.. versionchanged:: 26.2
|
||||
|
||||
Added a stable pickle format. Pickles created with packaging 26.2+ can
|
||||
be unpickled with future releases. Backward compatibility with pickles
|
||||
from packaging < 26.2 is supported but may be removed in a future
|
||||
release.
|
||||
"""
|
||||
|
||||
__slots__ = ("_markers",)
|
||||
|
||||
def __init__(self, marker: str) -> None:
|
||||
# Note: We create a Marker object without calling this constructor in
|
||||
# packaging.requirements.Requirement. If any additional logic is
|
||||
# added here, make sure to mirror/adapt Requirement.
|
||||
|
||||
# If this fails and throws an error, the repr still expects _markers to
|
||||
# be defined.
|
||||
self._markers: MarkerList = []
|
||||
|
||||
try:
|
||||
self._markers = _normalize_extra_values(_parse_marker(marker))
|
||||
# The attribute `_markers` can be described in terms of a recursive type:
|
||||
@@ -297,36 +366,94 @@ class Marker:
|
||||
except ParserSyntaxError as e:
|
||||
raise InvalidMarker(str(e)) from e
|
||||
|
||||
@classmethod
|
||||
def _from_markers(cls, markers: MarkerList) -> Marker:
|
||||
"""Create a Marker instance from a pre-parsed marker tree.
|
||||
|
||||
This avoids re-parsing serialised marker strings when combining markers.
|
||||
"""
|
||||
new = cls.__new__(cls)
|
||||
new._markers = markers
|
||||
return new
|
||||
|
||||
def __str__(self) -> str:
|
||||
return _format_marker(self._markers)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Marker('{self}')>"
|
||||
return f"<{self.__class__.__name__}({str(self)!r})>"
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.__class__.__name__, str(self)))
|
||||
return hash(str(self))
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Marker):
|
||||
return NotImplemented
|
||||
|
||||
return str(self) == str(other)
|
||||
|
||||
def __getstate__(self) -> str:
|
||||
# Return the marker expression string for compactness and stability.
|
||||
# Internal Node objects are excluded; the string is re-parsed on load.
|
||||
return str(self)
|
||||
|
||||
def __setstate__(self, state: object) -> None:
|
||||
if isinstance(state, str):
|
||||
# New format (26.2+): just the marker expression string.
|
||||
try:
|
||||
self._markers = _normalize_extra_values(_parse_marker(state))
|
||||
except ParserSyntaxError as exc:
|
||||
raise TypeError(f"Cannot restore Marker from {state!r}") from exc
|
||||
return
|
||||
if isinstance(state, dict) and "_markers" in state:
|
||||
# Old format (packaging <= 26.1, no __slots__): plain __dict__.
|
||||
markers = state["_markers"]
|
||||
if isinstance(markers, list):
|
||||
self._markers = markers
|
||||
return
|
||||
if isinstance(state, tuple) and len(state) == 2:
|
||||
# Old format (packaging <= 26.1, __slots__): (None, {slot: value}).
|
||||
_, slot_dict = state
|
||||
if isinstance(slot_dict, dict) and "_markers" in slot_dict:
|
||||
markers = slot_dict["_markers"]
|
||||
if isinstance(markers, list):
|
||||
self._markers = markers
|
||||
return
|
||||
raise TypeError(f"Cannot restore Marker from {state!r}")
|
||||
|
||||
def __and__(self, other: Marker) -> Marker:
|
||||
if not isinstance(other, Marker):
|
||||
return NotImplemented
|
||||
return self._from_markers([self._markers, "and", other._markers])
|
||||
|
||||
def __or__(self, other: Marker) -> Marker:
|
||||
if not isinstance(other, Marker):
|
||||
return NotImplemented
|
||||
return self._from_markers([self._markers, "or", other._markers])
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
environment: dict[str, str] | None = None,
|
||||
environment: Mapping[str, str | AbstractSet[str]] | None = None,
|
||||
context: EvaluateContext = "metadata",
|
||||
) -> bool:
|
||||
"""Evaluate a marker.
|
||||
|
||||
Return the boolean from evaluating the given marker against the
|
||||
environment. environment is an optional argument to override all or
|
||||
part of the determined environment. The *context* parameter specifies what
|
||||
context the markers are being evaluated for, which influences what markers
|
||||
are considered valid. Acceptable values are "metadata" (for core metadata;
|
||||
default), "lock_file", and "requirement" (i.e. all other situations).
|
||||
Return the boolean from evaluating this marker against the environment.
|
||||
The environment is determined from the current Python process unless
|
||||
passed in explicitly.
|
||||
|
||||
:param environment: Mapping containing keys and values to override the
|
||||
detected environment.
|
||||
:param EvaluateContext context: The context in which the marker is
|
||||
evaluated, which influences what marker names are considered valid.
|
||||
Accepted values are ``"metadata"`` (for core metadata; default),
|
||||
``"lock_file"``, and ``"requirement"`` (i.e. all other situations).
|
||||
:raises UndefinedComparison: If the marker uses a comparison on values
|
||||
that are not valid versions per the :ref:`specification of version
|
||||
specifiers <pypug:version-specifiers>`.
|
||||
:raises UndefinedEnvironmentName: If the marker references a value that
|
||||
is missing from the evaluation environment.
|
||||
:returns: ``True`` if the marker matches, otherwise ``False``.
|
||||
|
||||
The environment is determined from the current Python process.
|
||||
"""
|
||||
current_environment = cast(
|
||||
"dict[str, str | AbstractSet[str]]", default_environment()
|
||||
@@ -337,12 +464,15 @@ class Marker:
|
||||
)
|
||||
elif context == "metadata":
|
||||
current_environment["extra"] = ""
|
||||
|
||||
if environment is not None:
|
||||
current_environment.update(environment)
|
||||
# The API used to allow setting extra to None. We need to handle this
|
||||
# case for backwards compatibility.
|
||||
if "extra" in current_environment and current_environment["extra"] is None:
|
||||
current_environment["extra"] = ""
|
||||
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
|
||||
# normalize name if extra is empty.
|
||||
extra = cast("str | None", current_environment["extra"])
|
||||
current_environment["extra"] = canonicalize_name(extra) if extra else ""
|
||||
|
||||
return _evaluate_markers(
|
||||
self._markers, _repair_python_full_version(current_environment)
|
||||
@@ -356,7 +486,7 @@ def _repair_python_full_version(
|
||||
Work around platform.python_version() returning something that is not PEP 440
|
||||
compliant for non-tagged Python builds.
|
||||
"""
|
||||
python_full_version = cast(str, env["python_full_version"])
|
||||
python_full_version = cast("str", env["python_full_version"])
|
||||
if python_full_version.endswith("+"):
|
||||
env["python_full_version"] = f"{python_full_version}local"
|
||||
return env
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import email.feedparser
|
||||
import email.header
|
||||
import email.message
|
||||
import email.parser
|
||||
import email.policy
|
||||
import keyword
|
||||
import pathlib
|
||||
import sys
|
||||
import typing
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -19,31 +18,27 @@ from typing import (
|
||||
|
||||
from . import licenses, requirements, specifiers, utils
|
||||
from . import version as version_module
|
||||
from .licenses import NormalizedLicenseExpression
|
||||
from .errors import ExceptionGroup, _ErrorCollector
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .licenses import NormalizedLicenseExpression
|
||||
|
||||
T = typing.TypeVar("T")
|
||||
|
||||
|
||||
if sys.version_info >= (3, 11): # pragma: no cover
|
||||
ExceptionGroup = ExceptionGroup
|
||||
else: # pragma: no cover
|
||||
__all__ = [
|
||||
"ExceptionGroup", # Keep this for a bit (makes mypy happy w/ 26.0 compat)
|
||||
"InvalidMetadata",
|
||||
"Metadata",
|
||||
"RFC822Message",
|
||||
"RFC822Policy",
|
||||
"RawMetadata",
|
||||
"parse_email",
|
||||
]
|
||||
|
||||
class ExceptionGroup(Exception):
|
||||
"""A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11.
|
||||
|
||||
If :external:exc:`ExceptionGroup` is already defined by Python itself,
|
||||
that version is used instead.
|
||||
"""
|
||||
|
||||
message: str
|
||||
exceptions: list[Exception]
|
||||
|
||||
def __init__(self, message: str, exceptions: list[Exception]) -> None:
|
||||
self.message = message
|
||||
self.exceptions = exceptions
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})"
|
||||
def __dir__() -> list[str]:
|
||||
return __all__
|
||||
|
||||
|
||||
class InvalidMetadata(ValueError):
|
||||
@@ -73,8 +68,8 @@ class RawMetadata(TypedDict, total=False):
|
||||
|
||||
Core metadata fields that can be specified multiple times are stored as a
|
||||
list or dict depending on which is appropriate for the field. Any fields
|
||||
which hold multiple values in a single field are stored as a list.
|
||||
|
||||
which hold multiple values in a single field are stored as a list. All fields
|
||||
are considered optional.
|
||||
"""
|
||||
|
||||
# Metadata 1.0 - PEP 241
|
||||
@@ -126,13 +121,19 @@ class RawMetadata(TypedDict, total=False):
|
||||
|
||||
# Metadata 2.3 - PEP 685
|
||||
# No new fields were added in PEP 685, just some edge case were
|
||||
# tightened up to provide better interoptability.
|
||||
# tightened up to provide better interoperability.
|
||||
|
||||
# Metadata 2.4 - PEP 639
|
||||
license_expression: str
|
||||
license_files: list[str]
|
||||
|
||||
# Metadata 2.5 - PEP 794
|
||||
import_names: list[str]
|
||||
import_namespaces: list[str]
|
||||
|
||||
|
||||
# 'keywords' is special as it's a string in the core metadata spec, but we
|
||||
# represent it as a list.
|
||||
_STRING_FIELDS = {
|
||||
"author",
|
||||
"author_email",
|
||||
@@ -165,6 +166,8 @@ _LIST_FIELDS = {
|
||||
"requires_dist",
|
||||
"requires_external",
|
||||
"supported_platforms",
|
||||
"import_names",
|
||||
"import_namespaces",
|
||||
}
|
||||
|
||||
_DICT_FIELDS = {
|
||||
@@ -193,24 +196,23 @@ def _parse_project_urls(data: list[str]) -> dict[str, str]:
|
||||
# be the missing value, then they'd have multiple '' values that
|
||||
# overwrite each other in a accumulating dict.
|
||||
#
|
||||
# The other potentional issue is that it's possible to have the
|
||||
# The other potential issue is that it's possible to have the
|
||||
# same label multiple times in the metadata, with no solid "right"
|
||||
# answer with what to do in that case. As such, we'll do the only
|
||||
# thing we can, which is treat the field as unparseable and add it
|
||||
# thing we can, which is treat the field as unparsable and add it
|
||||
# to our list of unparsed fields.
|
||||
parts = [p.strip() for p in pair.split(",", 1)]
|
||||
parts.extend([""] * (max(0, 2 - len(parts)))) # Ensure 2 items
|
||||
|
||||
#
|
||||
# TODO: The spec doesn't say anything about if the keys should be
|
||||
# considered case sensitive or not... logically they should
|
||||
# be case-preserving and case-insensitive, but doing that
|
||||
# would open up more cases where we might have duplicate
|
||||
# entries.
|
||||
label, url = parts
|
||||
label, _, url = (s.strip() for s in pair.partition(","))
|
||||
|
||||
if label in urls:
|
||||
# The label already exists in our set of urls, so this field
|
||||
# is unparseable, and we can just add the whole thing to our
|
||||
# unparseable data and stop processing it.
|
||||
# is unparsable, and we can just add the whole thing to our
|
||||
# unparsable data and stop processing it.
|
||||
raise KeyError("duplicate labels in project urls")
|
||||
urls[label] = url
|
||||
|
||||
@@ -257,6 +259,8 @@ _EMAIL_TO_RAW_MAPPING = {
|
||||
"download-url": "download_url",
|
||||
"dynamic": "dynamic",
|
||||
"home-page": "home_page",
|
||||
"import-name": "import_names",
|
||||
"import-namespace": "import_namespaces",
|
||||
"keywords": "keywords",
|
||||
"license": "license",
|
||||
"license-expression": "license_expression",
|
||||
@@ -283,6 +287,45 @@ _EMAIL_TO_RAW_MAPPING = {
|
||||
_RAW_TO_EMAIL_MAPPING = {raw: email for email, raw in _EMAIL_TO_RAW_MAPPING.items()}
|
||||
|
||||
|
||||
# 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.
|
||||
"""
|
||||
|
||||
utf8 = True
|
||||
mangle_from_ = False
|
||||
max_line_length = 0
|
||||
|
||||
def header_store_parse(self, name: str, value: str) -> tuple[str, str]:
|
||||
size = len(name) + 2
|
||||
value = value.replace("\n", "\n" + " " * size)
|
||||
return (name, value)
|
||||
|
||||
|
||||
# This class is for writing RFC822 messages
|
||||
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()`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(policy=RFC822Policy())
|
||||
|
||||
def as_bytes(
|
||||
self, unixfrom: bool = False, policy: email.policy.Policy | None = None
|
||||
) -> bytes:
|
||||
"""
|
||||
Return the bytes representation of the message.
|
||||
|
||||
This handles unicode encoding.
|
||||
"""
|
||||
return self.as_string(unixfrom, policy=policy).encode("utf-8")
|
||||
|
||||
|
||||
def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
|
||||
"""Parse a distribution's metadata stored as email headers (e.g. from ``METADATA``).
|
||||
|
||||
@@ -310,10 +353,10 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
|
||||
# We have to wrap parsed.keys() in a set, because in the case of multiple
|
||||
# values for a key (a list), the key will appear multiple times in the
|
||||
# list of keys, but we're avoiding that by using get_all().
|
||||
for name in frozenset(parsed.keys()):
|
||||
for name_with_case in frozenset(parsed.keys()):
|
||||
# Header names in RFC are case insensitive, so we'll normalize to all
|
||||
# lower case to make comparisons easier.
|
||||
name = name.lower()
|
||||
name = name_with_case.lower()
|
||||
|
||||
# We use get_all() here, even for fields that aren't multiple use,
|
||||
# because otherwise someone could have e.g. two Name fields, and we
|
||||
@@ -349,16 +392,16 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
|
||||
# can be independently encoded, so we'll need to check each
|
||||
# of them.
|
||||
chunks: list[tuple[bytes, str | None]] = []
|
||||
for bin, encoding in email.header.decode_header(h):
|
||||
for binary, _encoding in email.header.decode_header(h):
|
||||
try:
|
||||
bin.decode("utf8", "strict")
|
||||
binary.decode("utf8", "strict")
|
||||
except UnicodeDecodeError:
|
||||
# Enable mojibake.
|
||||
encoding = "latin1"
|
||||
valid_encoding = False
|
||||
else:
|
||||
encoding = "utf8"
|
||||
chunks.append((bin, encoding))
|
||||
chunks.append((binary, encoding))
|
||||
|
||||
# Turn our chunks back into a Header object, then let that
|
||||
# Header object do the right thing to turn them into a
|
||||
@@ -397,6 +440,11 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
|
||||
# of unparsed stuff.
|
||||
if raw_name in _STRING_FIELDS and len(value) == 1:
|
||||
raw[raw_name] = value[0]
|
||||
# If this is import_names, we need to special case the empty field
|
||||
# case, which converts to an empty list instead of None. We can't let
|
||||
# the empty case slip through, as it will fail validation.
|
||||
elif raw_name == "import_names" and value == [""]:
|
||||
raw[raw_name] = []
|
||||
# If this is one of our list of string fields, then we can just assign
|
||||
# the value, since email *only* has strings, and our get_all() call
|
||||
# above ensures that this is a list.
|
||||
@@ -424,7 +472,7 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
|
||||
except KeyError:
|
||||
unparsed[name] = value
|
||||
# Nothing that we've done has managed to parse this, so it'll just
|
||||
# throw it in our unparseable data and move on.
|
||||
# throw it in our unparsable data and move on.
|
||||
else:
|
||||
unparsed[name] = value
|
||||
|
||||
@@ -441,9 +489,9 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
|
||||
else:
|
||||
if payload:
|
||||
# Check to see if we've already got a description, if so then both
|
||||
# it, and this body move to unparseable.
|
||||
# it, and this body move to unparsable.
|
||||
if "description" in raw:
|
||||
description_header = cast(str, raw.pop("description"))
|
||||
description_header = cast("str", raw.pop("description"))
|
||||
unparsed.setdefault("description", []).extend(
|
||||
[description_header, payload]
|
||||
)
|
||||
@@ -456,15 +504,15 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
|
||||
# literal key names, but we're computing our key names on purpose, but the
|
||||
# way this function is implemented, our `TypedDict` can only have valid key
|
||||
# names.
|
||||
return cast(RawMetadata, raw), unparsed
|
||||
return cast("RawMetadata", raw), unparsed
|
||||
|
||||
|
||||
_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"]
|
||||
_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"]
|
||||
_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]
|
||||
_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]
|
||||
|
||||
_REQUIRED_ATTRS = frozenset(["metadata_version", "name", "version"])
|
||||
|
||||
@@ -519,7 +567,7 @@ class _Validator(Generic[T]):
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return cast(T, value)
|
||||
return cast("T", value)
|
||||
|
||||
def _invalid_metadata(
|
||||
self, msg: str, cause: Exception | None = None
|
||||
@@ -534,7 +582,7 @@ class _Validator(Generic[T]):
|
||||
# Implicitly makes Metadata-Version required.
|
||||
if value not in _VALID_METADATA_VERSIONS:
|
||||
raise self._invalid_metadata(f"{value!r} is not a valid metadata version")
|
||||
return cast(_MetadataVersion, value)
|
||||
return cast("_MetadataVersion", value)
|
||||
|
||||
def _process_name(self, value: str) -> str:
|
||||
if not value:
|
||||
@@ -585,7 +633,7 @@ class _Validator(Generic[T]):
|
||||
charset = parameters.get("charset", "UTF-8")
|
||||
if charset != "UTF-8":
|
||||
raise self._invalid_metadata(
|
||||
f"{{field}} can only specify the UTF-8 charset, not {list(charset)}"
|
||||
f"{{field}} can only specify the UTF-8 charset, not {charset!r}"
|
||||
)
|
||||
|
||||
markdown_variants = {"GFM", "CommonMark"}
|
||||
@@ -647,9 +695,7 @@ class _Validator(Generic[T]):
|
||||
else:
|
||||
return reqs
|
||||
|
||||
def _process_license_expression(
|
||||
self, value: str
|
||||
) -> NormalizedLicenseExpression | None:
|
||||
def _process_license_expression(self, value: str) -> NormalizedLicenseExpression:
|
||||
try:
|
||||
return licenses.canonicalize_license_expression(value)
|
||||
except ValueError as exc:
|
||||
@@ -683,6 +729,30 @@ class _Validator(Generic[T]):
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
def _process_import_names(self, value: list[str]) -> list[str]:
|
||||
for import_name in value:
|
||||
name, semicolon, private = import_name.partition(";")
|
||||
name = name.rstrip()
|
||||
for identifier in name.split("."):
|
||||
if not identifier.isidentifier():
|
||||
raise self._invalid_metadata(
|
||||
f"{name!r} is invalid for {{field}}; "
|
||||
f"{identifier!r} is not a valid identifier"
|
||||
)
|
||||
elif keyword.iskeyword(identifier):
|
||||
raise self._invalid_metadata(
|
||||
f"{name!r} is invalid for {{field}}; "
|
||||
f"{identifier!r} is a keyword"
|
||||
)
|
||||
if semicolon and private.lstrip() != "private":
|
||||
raise self._invalid_metadata(
|
||||
f"{import_name!r} is invalid for {{field}}; "
|
||||
"the only valid option is 'private'"
|
||||
)
|
||||
return value
|
||||
|
||||
_process_import_namespaces = _process_import_names
|
||||
|
||||
|
||||
class Metadata:
|
||||
"""Representation of distribution metadata.
|
||||
@@ -706,13 +776,11 @@ class Metadata:
|
||||
ins._raw = data.copy() # Mutations occur due to caching enriched values.
|
||||
|
||||
if validate:
|
||||
exceptions: list[Exception] = []
|
||||
try:
|
||||
collector = _ErrorCollector()
|
||||
metadata_version = None
|
||||
with collector.collect(InvalidMetadata):
|
||||
metadata_version = ins.metadata_version
|
||||
metadata_age = _VALID_METADATA_VERSIONS.index(metadata_version)
|
||||
except InvalidMetadata as metadata_version_exc:
|
||||
exceptions.append(metadata_version_exc)
|
||||
metadata_version = None
|
||||
|
||||
# Make sure to check for the fields that are present, the required
|
||||
# fields (so their absence can be reported).
|
||||
@@ -729,7 +797,7 @@ class Metadata:
|
||||
field_metadata_version = cls.__dict__[key].added
|
||||
except KeyError:
|
||||
exc = InvalidMetadata(key, f"unrecognized field: {key!r}")
|
||||
exceptions.append(exc)
|
||||
collector.error(exc)
|
||||
continue
|
||||
field_age = _VALID_METADATA_VERSIONS.index(
|
||||
field_metadata_version
|
||||
@@ -741,14 +809,13 @@ class Metadata:
|
||||
f"{field} introduced in metadata version "
|
||||
f"{field_metadata_version}, not {metadata_version}",
|
||||
)
|
||||
exceptions.append(exc)
|
||||
collector.error(exc)
|
||||
continue
|
||||
getattr(ins, key)
|
||||
except InvalidMetadata as exc:
|
||||
exceptions.append(exc)
|
||||
collector.error(exc)
|
||||
|
||||
if exceptions:
|
||||
raise ExceptionGroup("invalid metadata", exceptions)
|
||||
collector.finalize("invalid metadata")
|
||||
|
||||
return ins
|
||||
|
||||
@@ -762,16 +829,13 @@ class Metadata:
|
||||
raw, unparsed = parse_email(data)
|
||||
|
||||
if validate:
|
||||
exceptions: list[Exception] = []
|
||||
for unparsed_key in unparsed:
|
||||
if unparsed_key in _EMAIL_TO_RAW_MAPPING:
|
||||
message = f"{unparsed_key!r} has invalid data"
|
||||
else:
|
||||
message = f"unrecognized field: {unparsed_key!r}"
|
||||
exceptions.append(InvalidMetadata(unparsed_key, message))
|
||||
|
||||
if exceptions:
|
||||
raise ExceptionGroup("unparsed", exceptions)
|
||||
with _ErrorCollector().on_exit("unparsed") as collector:
|
||||
for unparsed_key in unparsed:
|
||||
if unparsed_key in _EMAIL_TO_RAW_MAPPING:
|
||||
message = f"{unparsed_key!r} has invalid data"
|
||||
else:
|
||||
message = f"unrecognized field: {unparsed_key!r}"
|
||||
collector.error(InvalidMetadata(unparsed_key, message))
|
||||
|
||||
try:
|
||||
return cls.from_raw(raw, validate=validate)
|
||||
@@ -854,9 +918,47 @@ class Metadata:
|
||||
""":external:ref:`core-metadata-provides-dist`"""
|
||||
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`"""
|
||||
import_namespaces: _Validator[list[str] | None] = _Validator(added="2.5")
|
||||
""":external:ref:`core-metadata-import-namespace`"""
|
||||
requires: _Validator[list[str] | None] = _Validator(added="1.1")
|
||||
"""``Requires`` (deprecated)"""
|
||||
provides: _Validator[list[str] | None] = _Validator(added="1.1")
|
||||
"""``Provides`` (deprecated)"""
|
||||
obsoletes: _Validator[list[str] | None] = _Validator(added="1.1")
|
||||
"""``Obsoletes`` (deprecated)"""
|
||||
|
||||
def as_rfc822(self) -> RFC822Message:
|
||||
"""
|
||||
Return an RFC822 message with the metadata.
|
||||
"""
|
||||
message = RFC822Message()
|
||||
self._write_metadata(message)
|
||||
return message
|
||||
|
||||
def _write_metadata(self, message: RFC822Message) -> None:
|
||||
"""
|
||||
Return an RFC822 message with the metadata.
|
||||
"""
|
||||
for name, validator in self.__class__.__dict__.items():
|
||||
if isinstance(validator, _Validator) and name != "description":
|
||||
value = getattr(self, name)
|
||||
email_name = _RAW_TO_EMAIL_MAPPING[name]
|
||||
if value is not None:
|
||||
if email_name == "project-url":
|
||||
for label, url in value.items():
|
||||
message[email_name] = f"{label}, {url}"
|
||||
elif email_name == "keywords":
|
||||
message[email_name] = ",".join(value)
|
||||
elif email_name == "import-name" and value == []:
|
||||
message[email_name] = ""
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
message[email_name] = str(item)
|
||||
else:
|
||||
message[email_name] = str(value)
|
||||
|
||||
# The description is a special case because it is in the body of the message.
|
||||
if self.description is not None:
|
||||
message.set_payload(self.description)
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .markers import Environment, Marker, default_environment
|
||||
from .specifiers import SpecifierSet
|
||||
from .tags import create_compatible_tags_selector, sys_tags
|
||||
from .utils import (
|
||||
NormalizedName,
|
||||
is_normalized_name,
|
||||
parse_sdist_filename,
|
||||
parse_wheel_filename,
|
||||
)
|
||||
from .version import Version
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from collections.abc import Collection, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from .tags import Tag
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"Package",
|
||||
"PackageArchive",
|
||||
"PackageDirectory",
|
||||
"PackageSdist",
|
||||
"PackageVcs",
|
||||
"PackageWheel",
|
||||
"Pylock",
|
||||
"PylockUnsupportedVersionError",
|
||||
"PylockValidationError",
|
||||
"is_valid_pylock_path",
|
||||
]
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return __all__
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_T2 = TypeVar("_T2")
|
||||
|
||||
|
||||
class _FromMappingProtocol(Protocol): # pragma: no cover
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self: ...
|
||||
|
||||
|
||||
_FromMappingProtocolT = TypeVar("_FromMappingProtocolT", bound=_FromMappingProtocol)
|
||||
|
||||
|
||||
_PYLOCK_FILE_NAME_RE = re.compile(r"^pylock\.([^.]+)\.toml$")
|
||||
|
||||
|
||||
def is_valid_pylock_path(path: Path) -> bool:
|
||||
"""Check if the given path is a valid pylock file path."""
|
||||
return path.name == "pylock.toml" or bool(_PYLOCK_FILE_NAME_RE.match(path.name))
|
||||
|
||||
|
||||
def _toml_key(key: str) -> str:
|
||||
return key.replace("_", "-")
|
||||
|
||||
|
||||
def _toml_value(key: str, value: Any) -> Any: # noqa: ANN401
|
||||
if isinstance(value, (Version, Marker, SpecifierSet)):
|
||||
return str(value)
|
||||
if isinstance(value, Sequence) and key == "environments":
|
||||
return [str(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _toml_dict_factory(data: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
return {
|
||||
_toml_key(key): _toml_value(key, value)
|
||||
for key, value in data
|
||||
if value is not None
|
||||
}
|
||||
|
||||
|
||||
def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None:
|
||||
"""Get a value from the dictionary and verify it's the expected type."""
|
||||
if (value := d.get(key)) is None:
|
||||
return None
|
||||
if not isinstance(value, expected_type):
|
||||
raise PylockValidationError(
|
||||
f"Unexpected type {type(value).__name__} "
|
||||
f"(expected {expected_type.__name__})",
|
||||
context=key,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _get_required(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T:
|
||||
"""Get a required value from the dictionary and verify it's the expected type."""
|
||||
if (value := _get(d, expected_type, key)) is None:
|
||||
raise _PylockRequiredKeyError(key)
|
||||
return value
|
||||
|
||||
|
||||
def _get_sequence(
|
||||
d: Mapping[str, Any], expected_item_type: type[_T], key: str
|
||||
) -> Sequence[_T] | None:
|
||||
"""Get a list value from the dictionary and verify it's the expected items type."""
|
||||
if (value := _get(d, Sequence, key)) is None: # type: ignore[type-abstract]
|
||||
return None
|
||||
if isinstance(value, (str, bytes)):
|
||||
# special case: str and bytes are Sequences, but we want to reject it
|
||||
raise PylockValidationError(
|
||||
f"Unexpected type {type(value).__name__} (expected Sequence)",
|
||||
context=key,
|
||||
)
|
||||
for i, item in enumerate(value):
|
||||
if not isinstance(item, expected_item_type):
|
||||
raise PylockValidationError(
|
||||
f"Unexpected type {type(item).__name__} "
|
||||
f"(expected {expected_item_type.__name__})",
|
||||
context=f"{key}[{i}]",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _get_as(
|
||||
d: Mapping[str, Any],
|
||||
expected_type: type[_T],
|
||||
target_type: Callable[[_T], _T2],
|
||||
key: str,
|
||||
) -> _T2 | None:
|
||||
"""Get a value from the dictionary, verify it's the expected type,
|
||||
and convert to the target type.
|
||||
|
||||
This assumes the target_type constructor accepts the value.
|
||||
"""
|
||||
if (value := _get(d, expected_type, key)) is None:
|
||||
return None
|
||||
try:
|
||||
return target_type(value)
|
||||
except Exception as e:
|
||||
raise PylockValidationError(e, context=key) from e
|
||||
|
||||
|
||||
def _get_required_as(
|
||||
d: Mapping[str, Any],
|
||||
expected_type: type[_T],
|
||||
target_type: Callable[[_T], _T2],
|
||||
key: str,
|
||||
) -> _T2:
|
||||
"""Get a required value from the dict, verify it's the expected type,
|
||||
and convert to the target type."""
|
||||
if (value := _get_as(d, expected_type, target_type, key)) is None:
|
||||
raise _PylockRequiredKeyError(key)
|
||||
return value
|
||||
|
||||
|
||||
def _get_sequence_as(
|
||||
d: Mapping[str, Any],
|
||||
expected_item_type: type[_T],
|
||||
target_item_type: Callable[[_T], _T2],
|
||||
key: str,
|
||||
) -> list[_T2] | None:
|
||||
"""Get list value from dictionary and verify expected items type."""
|
||||
if (value := _get_sequence(d, expected_item_type, key)) is None:
|
||||
return None
|
||||
result = []
|
||||
try:
|
||||
for item in value:
|
||||
typed_item = target_item_type(item)
|
||||
result.append(typed_item)
|
||||
except Exception as e:
|
||||
raise PylockValidationError(e, context=f"{key}[{len(result)}]") from e
|
||||
return result
|
||||
|
||||
|
||||
def _get_object(
|
||||
d: Mapping[str, Any], target_type: type[_FromMappingProtocolT], key: str
|
||||
) -> _FromMappingProtocolT | None:
|
||||
"""Get a dictionary value from the dictionary and convert it to a dataclass."""
|
||||
if (value := _get(d, Mapping, key)) is None: # type: ignore[type-abstract]
|
||||
return None
|
||||
try:
|
||||
return target_type._from_dict(value)
|
||||
except Exception as e:
|
||||
raise PylockValidationError(e, context=key) from e
|
||||
|
||||
|
||||
def _get_sequence_of_objects(
|
||||
d: Mapping[str, Any], target_item_type: type[_FromMappingProtocolT], key: str
|
||||
) -> list[_FromMappingProtocolT] | None:
|
||||
"""Get a list value from the dictionary and convert its items to a dataclass."""
|
||||
if (value := _get_sequence(d, Mapping, key)) is None: # type: ignore[type-abstract]
|
||||
return None
|
||||
result: list[_FromMappingProtocolT] = []
|
||||
try:
|
||||
for item in value:
|
||||
typed_item = target_item_type._from_dict(item)
|
||||
result.append(typed_item)
|
||||
except Exception as e:
|
||||
raise PylockValidationError(e, context=f"{key}[{len(result)}]") from e
|
||||
return result
|
||||
|
||||
|
||||
def _get_required_sequence_of_objects(
|
||||
d: Mapping[str, Any], target_item_type: type[_FromMappingProtocolT], key: str
|
||||
) -> Sequence[_FromMappingProtocolT]:
|
||||
"""Get a required list value from the dictionary and convert its items to a
|
||||
dataclass."""
|
||||
if (result := _get_sequence_of_objects(d, target_item_type, key)) is None:
|
||||
raise _PylockRequiredKeyError(key)
|
||||
return result
|
||||
|
||||
|
||||
def _validate_normalized_name(name: str) -> NormalizedName:
|
||||
"""Validate that a string is a NormalizedName."""
|
||||
if not is_normalized_name(name):
|
||||
raise PylockValidationError(f"Name {name!r} is not normalized")
|
||||
return NormalizedName(name)
|
||||
|
||||
|
||||
def _validate_path_url(path: str | None, url: str | None) -> None:
|
||||
if not path and not url:
|
||||
raise PylockValidationError("path or url must be provided")
|
||||
|
||||
|
||||
def _path_name(path: str | None) -> str | None:
|
||||
if not path:
|
||||
return None
|
||||
# If the path is relative it MAY use POSIX-style path separators explicitly
|
||||
# for portability
|
||||
if "/" in path:
|
||||
return path.rsplit("/", 1)[-1]
|
||||
elif "\\" in path:
|
||||
return path.rsplit("\\", 1)[-1]
|
||||
else:
|
||||
return path
|
||||
|
||||
|
||||
def _url_name(url: str | None) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
url_path = urlparse(url).path
|
||||
return url_path.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _validate_hashes(hashes: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if not hashes:
|
||||
raise PylockValidationError("At least one hash must be provided")
|
||||
if not all(isinstance(hash_val, str) for hash_val in hashes.values()):
|
||||
raise PylockValidationError("Hash values must be strings")
|
||||
return hashes
|
||||
|
||||
|
||||
class PylockValidationError(Exception):
|
||||
"""Raised when when input data is not spec-compliant."""
|
||||
|
||||
context: str | None = None
|
||||
message: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cause: str | Exception,
|
||||
*,
|
||||
context: str | None = None,
|
||||
) -> None:
|
||||
if isinstance(cause, PylockValidationError):
|
||||
if cause.context:
|
||||
self.context = (
|
||||
f"{context}.{cause.context}" if context else cause.context
|
||||
)
|
||||
else:
|
||||
self.context = context
|
||||
self.message = cause.message
|
||||
else:
|
||||
self.context = context
|
||||
self.message = str(cause)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.context:
|
||||
return f"{self.message} in {self.context!r}"
|
||||
return self.message
|
||||
|
||||
|
||||
class _PylockRequiredKeyError(PylockValidationError):
|
||||
def __init__(self, key: str) -> None:
|
||||
super().__init__("Missing required value", context=key)
|
||||
|
||||
|
||||
class PylockUnsupportedVersionError(PylockValidationError):
|
||||
"""Raised when encountering an unsupported `lock_version`."""
|
||||
|
||||
|
||||
class PylockSelectError(Exception):
|
||||
"""Base exception for errors raised by :meth:`Pylock.select`."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class PackageVcs:
|
||||
type: str
|
||||
url: str | None = None
|
||||
path: str | None = None
|
||||
requested_revision: str | None = None
|
||||
commit_id: str # type: ignore[misc]
|
||||
subdirectory: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
type: str,
|
||||
url: str | None = None,
|
||||
path: str | None = None,
|
||||
requested_revision: str | None = None,
|
||||
commit_id: str,
|
||||
subdirectory: str | None = None,
|
||||
) -> None:
|
||||
# In Python 3.10+ make dataclass kw_only=True and remove __init__
|
||||
object.__setattr__(self, "type", type)
|
||||
object.__setattr__(self, "url", url)
|
||||
object.__setattr__(self, "path", path)
|
||||
object.__setattr__(self, "requested_revision", requested_revision)
|
||||
object.__setattr__(self, "commit_id", commit_id)
|
||||
object.__setattr__(self, "subdirectory", subdirectory)
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
|
||||
package_vcs = cls(
|
||||
type=_get_required(d, str, "type"),
|
||||
url=_get(d, str, "url"),
|
||||
path=_get(d, str, "path"),
|
||||
requested_revision=_get(d, str, "requested-revision"),
|
||||
commit_id=_get_required(d, str, "commit-id"),
|
||||
subdirectory=_get(d, str, "subdirectory"),
|
||||
)
|
||||
_validate_path_url(package_vcs.path, package_vcs.url)
|
||||
return package_vcs
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class PackageDirectory:
|
||||
path: str
|
||||
editable: bool | None = None
|
||||
subdirectory: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
path: str,
|
||||
editable: bool | None = None,
|
||||
subdirectory: str | None = None,
|
||||
) -> None:
|
||||
# In Python 3.10+ make dataclass kw_only=True and remove __init__
|
||||
object.__setattr__(self, "path", path)
|
||||
object.__setattr__(self, "editable", editable)
|
||||
object.__setattr__(self, "subdirectory", subdirectory)
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
|
||||
return cls(
|
||||
path=_get_required(d, str, "path"),
|
||||
editable=_get(d, bool, "editable"),
|
||||
subdirectory=_get(d, str, "subdirectory"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class PackageArchive:
|
||||
url: str | None = None
|
||||
path: str | None = None
|
||||
size: int | None = None
|
||||
upload_time: datetime | None = None
|
||||
hashes: Mapping[str, str] # type: ignore[misc]
|
||||
subdirectory: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url: str | None = None,
|
||||
path: str | None = None,
|
||||
size: int | None = None,
|
||||
upload_time: datetime | None = None,
|
||||
hashes: Mapping[str, str],
|
||||
subdirectory: str | None = None,
|
||||
) -> None:
|
||||
# In Python 3.10+ make dataclass kw_only=True and remove __init__
|
||||
object.__setattr__(self, "url", url)
|
||||
object.__setattr__(self, "path", path)
|
||||
object.__setattr__(self, "size", size)
|
||||
object.__setattr__(self, "upload_time", upload_time)
|
||||
object.__setattr__(self, "hashes", hashes)
|
||||
object.__setattr__(self, "subdirectory", subdirectory)
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
|
||||
package_archive = cls(
|
||||
url=_get(d, str, "url"),
|
||||
path=_get(d, str, "path"),
|
||||
size=_get(d, int, "size"),
|
||||
upload_time=_get(d, datetime, "upload-time"),
|
||||
hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract]
|
||||
subdirectory=_get(d, str, "subdirectory"),
|
||||
)
|
||||
_validate_path_url(package_archive.path, package_archive.url)
|
||||
return package_archive
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class PackageSdist:
|
||||
name: str | None = None
|
||||
upload_time: datetime | None = None
|
||||
url: str | None = None
|
||||
path: str | None = None
|
||||
size: int | None = None
|
||||
hashes: Mapping[str, str] # type: ignore[misc]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
upload_time: datetime | None = None,
|
||||
url: str | None = None,
|
||||
path: str | None = None,
|
||||
size: int | None = None,
|
||||
hashes: Mapping[str, str],
|
||||
) -> None:
|
||||
# In Python 3.10+ make dataclass kw_only=True and remove __init__
|
||||
object.__setattr__(self, "name", name)
|
||||
object.__setattr__(self, "upload_time", upload_time)
|
||||
object.__setattr__(self, "url", url)
|
||||
object.__setattr__(self, "path", path)
|
||||
object.__setattr__(self, "size", size)
|
||||
object.__setattr__(self, "hashes", hashes)
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
|
||||
package_sdist = cls(
|
||||
name=_get(d, str, "name"),
|
||||
upload_time=_get(d, datetime, "upload-time"),
|
||||
url=_get(d, str, "url"),
|
||||
path=_get(d, str, "path"),
|
||||
size=_get(d, int, "size"),
|
||||
hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract]
|
||||
)
|
||||
_validate_path_url(package_sdist.path, package_sdist.url)
|
||||
return package_sdist
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
"""Get the filename of the sdist."""
|
||||
filename = self.name or _path_name(self.path) or _url_name(self.url)
|
||||
if not filename:
|
||||
raise PylockValidationError("Cannot determine sdist filename")
|
||||
return filename
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class PackageWheel:
|
||||
name: str | None = None
|
||||
upload_time: datetime | None = None
|
||||
url: str | None = None
|
||||
path: str | None = None
|
||||
size: int | None = None
|
||||
hashes: Mapping[str, str] # type: ignore[misc]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
upload_time: datetime | None = None,
|
||||
url: str | None = None,
|
||||
path: str | None = None,
|
||||
size: int | None = None,
|
||||
hashes: Mapping[str, str],
|
||||
) -> None:
|
||||
# In Python 3.10+ make dataclass kw_only=True and remove __init__
|
||||
object.__setattr__(self, "name", name)
|
||||
object.__setattr__(self, "upload_time", upload_time)
|
||||
object.__setattr__(self, "url", url)
|
||||
object.__setattr__(self, "path", path)
|
||||
object.__setattr__(self, "size", size)
|
||||
object.__setattr__(self, "hashes", hashes)
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
|
||||
package_wheel = cls(
|
||||
name=_get(d, str, "name"),
|
||||
upload_time=_get(d, datetime, "upload-time"),
|
||||
url=_get(d, str, "url"),
|
||||
path=_get(d, str, "path"),
|
||||
size=_get(d, int, "size"),
|
||||
hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract]
|
||||
)
|
||||
_validate_path_url(package_wheel.path, package_wheel.url)
|
||||
return package_wheel
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
"""Get the filename of the wheel."""
|
||||
filename = self.name or _path_name(self.path) or _url_name(self.url)
|
||||
if not filename:
|
||||
raise PylockValidationError("Cannot determine wheel filename")
|
||||
return filename
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class Package:
|
||||
name: NormalizedName
|
||||
version: Version | None = None
|
||||
marker: Marker | None = None
|
||||
requires_python: SpecifierSet | None = None
|
||||
dependencies: Sequence[Mapping[str, Any]] | None = None
|
||||
vcs: PackageVcs | None = None
|
||||
directory: PackageDirectory | None = None
|
||||
archive: PackageArchive | None = None
|
||||
index: str | None = None
|
||||
sdist: PackageSdist | None = None
|
||||
wheels: Sequence[PackageWheel] | None = None
|
||||
attestation_identities: Sequence[Mapping[str, Any]] | None = None
|
||||
tool: Mapping[str, Any] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: NormalizedName,
|
||||
version: Version | None = None,
|
||||
marker: Marker | None = None,
|
||||
requires_python: SpecifierSet | None = None,
|
||||
dependencies: Sequence[Mapping[str, Any]] | None = None,
|
||||
vcs: PackageVcs | None = None,
|
||||
directory: PackageDirectory | None = None,
|
||||
archive: PackageArchive | None = None,
|
||||
index: str | None = None,
|
||||
sdist: PackageSdist | None = None,
|
||||
wheels: Sequence[PackageWheel] | None = None,
|
||||
attestation_identities: Sequence[Mapping[str, Any]] | None = None,
|
||||
tool: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
# In Python 3.10+ make dataclass kw_only=True and remove __init__
|
||||
object.__setattr__(self, "name", name)
|
||||
object.__setattr__(self, "version", version)
|
||||
object.__setattr__(self, "marker", marker)
|
||||
object.__setattr__(self, "requires_python", requires_python)
|
||||
object.__setattr__(self, "dependencies", dependencies)
|
||||
object.__setattr__(self, "vcs", vcs)
|
||||
object.__setattr__(self, "directory", directory)
|
||||
object.__setattr__(self, "archive", archive)
|
||||
object.__setattr__(self, "index", index)
|
||||
object.__setattr__(self, "sdist", sdist)
|
||||
object.__setattr__(self, "wheels", wheels)
|
||||
object.__setattr__(self, "attestation_identities", attestation_identities)
|
||||
object.__setattr__(self, "tool", tool)
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
|
||||
package = cls(
|
||||
name=_get_required_as(d, str, _validate_normalized_name, "name"),
|
||||
version=_get_as(d, str, Version, "version"),
|
||||
requires_python=_get_as(d, str, SpecifierSet, "requires-python"),
|
||||
dependencies=_get_sequence(d, Mapping, "dependencies"), # type: ignore[type-abstract]
|
||||
marker=_get_as(d, str, Marker, "marker"),
|
||||
vcs=_get_object(d, PackageVcs, "vcs"),
|
||||
directory=_get_object(d, PackageDirectory, "directory"),
|
||||
archive=_get_object(d, PackageArchive, "archive"),
|
||||
index=_get(d, str, "index"),
|
||||
sdist=_get_object(d, PackageSdist, "sdist"),
|
||||
wheels=_get_sequence_of_objects(d, PackageWheel, "wheels"),
|
||||
attestation_identities=_get_sequence(d, Mapping, "attestation-identities"), # type: ignore[type-abstract]
|
||||
tool=_get(d, Mapping, "tool"), # type: ignore[type-abstract]
|
||||
)
|
||||
distributions = bool(package.sdist) + len(package.wheels or [])
|
||||
direct_urls = (
|
||||
bool(package.vcs) + bool(package.directory) + bool(package.archive)
|
||||
)
|
||||
if distributions > 0 and direct_urls > 0:
|
||||
raise PylockValidationError(
|
||||
"None of vcs, directory, archive must be set if sdist or wheels are set"
|
||||
)
|
||||
if distributions == 0 and direct_urls != 1:
|
||||
raise PylockValidationError(
|
||||
"Exactly one of vcs, directory, archive must be set "
|
||||
"if sdist and wheels are not set"
|
||||
)
|
||||
for i, wheel in enumerate(package.wheels or []):
|
||||
try:
|
||||
(name, version, _, _) = parse_wheel_filename(wheel.filename)
|
||||
except Exception as e:
|
||||
raise PylockValidationError(
|
||||
f"Invalid wheel filename {wheel.filename!r}",
|
||||
context=f"wheels[{i}]",
|
||||
) from e
|
||||
if name != package.name:
|
||||
raise PylockValidationError(
|
||||
f"Name in {wheel.filename!r} is not consistent with "
|
||||
f"package name {package.name!r}",
|
||||
context=f"wheels[{i}]",
|
||||
)
|
||||
if package.version and version != package.version:
|
||||
raise PylockValidationError(
|
||||
f"Version in {wheel.filename!r} is not consistent with "
|
||||
f"package version {str(package.version)!r}",
|
||||
context=f"wheels[{i}]",
|
||||
)
|
||||
if package.sdist:
|
||||
try:
|
||||
name, version = parse_sdist_filename(package.sdist.filename)
|
||||
except Exception as e:
|
||||
raise PylockValidationError(
|
||||
f"Invalid sdist filename {package.sdist.filename!r}",
|
||||
context="sdist",
|
||||
) from e
|
||||
if name != package.name:
|
||||
raise PylockValidationError(
|
||||
f"Name in {package.sdist.filename!r} is not consistent with "
|
||||
f"package name {package.name!r}",
|
||||
context="sdist",
|
||||
)
|
||||
if package.version and version != package.version:
|
||||
raise PylockValidationError(
|
||||
f"Version in {package.sdist.filename!r} is not consistent with "
|
||||
f"package version {str(package.version)!r}",
|
||||
context="sdist",
|
||||
)
|
||||
try:
|
||||
for i, attestation_identity in enumerate( # noqa: B007
|
||||
package.attestation_identities or []
|
||||
):
|
||||
_get_required(attestation_identity, str, "kind")
|
||||
except Exception as e:
|
||||
raise PylockValidationError(
|
||||
e, context=f"attestation-identities[{i}]"
|
||||
) from e
|
||||
return package
|
||||
|
||||
@property
|
||||
def is_direct(self) -> bool:
|
||||
return not (self.sdist or self.wheels)
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class Pylock:
|
||||
"""A class representing a pylock file."""
|
||||
|
||||
lock_version: Version
|
||||
environments: Sequence[Marker] | None = None
|
||||
requires_python: SpecifierSet | None = None
|
||||
extras: Sequence[NormalizedName] | None = None
|
||||
dependency_groups: Sequence[str] | None = None
|
||||
default_groups: Sequence[str] | None = None
|
||||
created_by: str # type: ignore[misc]
|
||||
packages: Sequence[Package] # type: ignore[misc]
|
||||
tool: Mapping[str, Any] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
lock_version: Version,
|
||||
environments: Sequence[Marker] | None = None,
|
||||
requires_python: SpecifierSet | None = None,
|
||||
extras: Sequence[NormalizedName] | None = None,
|
||||
dependency_groups: Sequence[str] | None = None,
|
||||
default_groups: Sequence[str] | None = None,
|
||||
created_by: str,
|
||||
packages: Sequence[Package],
|
||||
tool: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
# In Python 3.10+ make dataclass kw_only=True and remove __init__
|
||||
object.__setattr__(self, "lock_version", lock_version)
|
||||
object.__setattr__(self, "environments", environments)
|
||||
object.__setattr__(self, "requires_python", requires_python)
|
||||
object.__setattr__(self, "extras", extras)
|
||||
object.__setattr__(self, "dependency_groups", dependency_groups)
|
||||
object.__setattr__(self, "default_groups", default_groups)
|
||||
object.__setattr__(self, "created_by", created_by)
|
||||
object.__setattr__(self, "packages", packages)
|
||||
object.__setattr__(self, "tool", tool)
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
|
||||
pylock = cls(
|
||||
lock_version=_get_required_as(d, str, Version, "lock-version"),
|
||||
environments=_get_sequence_as(d, str, Marker, "environments"),
|
||||
extras=_get_sequence_as(d, str, _validate_normalized_name, "extras"),
|
||||
dependency_groups=_get_sequence(d, str, "dependency-groups"),
|
||||
default_groups=_get_sequence(d, str, "default-groups"),
|
||||
created_by=_get_required(d, str, "created-by"),
|
||||
requires_python=_get_as(d, str, SpecifierSet, "requires-python"),
|
||||
packages=_get_required_sequence_of_objects(d, Package, "packages"),
|
||||
tool=_get(d, Mapping, "tool"), # type: ignore[type-abstract]
|
||||
)
|
||||
if not Version("1") <= pylock.lock_version < Version("2"):
|
||||
raise PylockUnsupportedVersionError(
|
||||
f"pylock version {pylock.lock_version} is not supported"
|
||||
)
|
||||
if pylock.lock_version > Version("1.0"):
|
||||
_logger.warning(
|
||||
"pylock minor version %s is not supported", pylock.lock_version
|
||||
)
|
||||
return pylock
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Mapping[str, Any], /) -> Self:
|
||||
"""Create and validate a Pylock instance from a TOML dictionary.
|
||||
|
||||
Raises :class:`PylockValidationError` if the input data is not
|
||||
spec-compliant.
|
||||
"""
|
||||
return cls._from_dict(d)
|
||||
|
||||
def to_dict(self) -> Mapping[str, Any]:
|
||||
"""Convert the Pylock instance to a TOML dictionary."""
|
||||
return dataclasses.asdict(self, dict_factory=_toml_dict_factory)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate the Pylock instance against the specification.
|
||||
|
||||
Raises :class:`PylockValidationError` otherwise."""
|
||||
self.from_dict(self.to_dict())
|
||||
|
||||
def select(
|
||||
self,
|
||||
*,
|
||||
environment: Environment | None = None,
|
||||
tags: Sequence[Tag] | None = None,
|
||||
extras: Collection[str] | None = None,
|
||||
dependency_groups: Collection[str] | None = None,
|
||||
) -> Iterator[
|
||||
tuple[
|
||||
Package,
|
||||
PackageVcs
|
||||
| PackageDirectory
|
||||
| PackageArchive
|
||||
| PackageWheel
|
||||
| PackageSdist,
|
||||
]
|
||||
]:
|
||||
"""Select what to install from the lock file.
|
||||
|
||||
The *environment* and *tags* parameters represent the environment being
|
||||
selected for. If unspecified, ``packaging.markers.default_environment()`` and
|
||||
``packaging.tags.sys_tags()`` are used.
|
||||
|
||||
The *extras* parameter represents the extras to install.
|
||||
|
||||
The *dependency_groups* parameter represents the groups to install. If
|
||||
unspecified, the default groups are used.
|
||||
|
||||
This method must be used on valid Pylock instances (i.e. one obtained
|
||||
from :meth:`Pylock.from_dict` or if constructed manually, after calling
|
||||
:meth:`Pylock.validate`).
|
||||
"""
|
||||
compatible_tags_selector = create_compatible_tags_selector(tags or sys_tags())
|
||||
|
||||
# #. Gather the extras and dependency groups to install and set ``extras`` and
|
||||
# ``dependency_groups`` for marker evaluation, respectively.
|
||||
#
|
||||
# #. ``extras`` SHOULD be set to the empty set by default.
|
||||
# #. ``dependency_groups`` SHOULD be the set created from
|
||||
# :ref:`pylock-default-groups` by default.
|
||||
env = cast(
|
||||
"dict[str, str | frozenset[str]]",
|
||||
dict(
|
||||
environment or {}, # Marker.evaluate will fill-up
|
||||
extras=frozenset(extras or []),
|
||||
dependency_groups=frozenset(
|
||||
(self.default_groups or [])
|
||||
if dependency_groups is None # to allow selecting no group
|
||||
else dependency_groups
|
||||
),
|
||||
),
|
||||
)
|
||||
env_python_full_version = (
|
||||
environment["python_full_version"]
|
||||
if environment
|
||||
else default_environment()["python_full_version"]
|
||||
)
|
||||
|
||||
# #. Check if the metadata version specified by :ref:`pylock-lock-version` is
|
||||
# supported; an error or warning MUST be raised as appropriate.
|
||||
# Covered by lock.validate() which is a precondition for this method.
|
||||
|
||||
# #. If :ref:`pylock-requires-python` is specified, check that the environment
|
||||
# being installed for meets the requirement; an error MUST be raised if it is
|
||||
# not met.
|
||||
if self.requires_python and not self.requires_python.contains(
|
||||
env_python_full_version,
|
||||
):
|
||||
raise PylockSelectError(
|
||||
f"python_full_version {env_python_full_version!r} "
|
||||
f"in provided environment does not satisfy the Python version "
|
||||
f"requirement {str(self.requires_python)!r}"
|
||||
)
|
||||
|
||||
# #. If :ref:`pylock-environments` is specified, check that at least one of the
|
||||
# environment marker expressions is satisfied; an error MUST be raised if no
|
||||
# expression is satisfied.
|
||||
if self.environments:
|
||||
for env_marker in self.environments:
|
||||
if env_marker.evaluate(
|
||||
cast("dict[str, str]", environment or {}), context="requirement"
|
||||
):
|
||||
break
|
||||
else:
|
||||
raise PylockSelectError(
|
||||
"Provided environment does not satisfy any of the "
|
||||
"environments specified in the lock file"
|
||||
)
|
||||
|
||||
# #. For each package listed in :ref:`pylock-packages`:
|
||||
selected_packages_by_name: dict[str, tuple[int, Package]] = {}
|
||||
for package_index, package in enumerate(self.packages):
|
||||
# #. If :ref:`pylock-packages-marker` is specified, check if it is
|
||||
# satisfied;if it isn't, skip to the next package.
|
||||
if package.marker and not package.marker.evaluate(env, context="lock_file"):
|
||||
continue
|
||||
|
||||
# #. If :ref:`pylock-packages-requires-python` is specified, check if it is
|
||||
# satisfied; an error MUST be raised if it isn't.
|
||||
if package.requires_python and not package.requires_python.contains(
|
||||
env_python_full_version,
|
||||
):
|
||||
raise PylockSelectError(
|
||||
f"python_full_version {env_python_full_version!r} "
|
||||
f"in provided environment does not satisfy the Python version "
|
||||
f"requirement {str(package.requires_python)!r} for package "
|
||||
f"{package.name!r} at packages[{package_index}]"
|
||||
)
|
||||
|
||||
# #. Check that no other conflicting instance of the package has been slated
|
||||
# to be installed; an error about the ambiguity MUST be raised otherwise.
|
||||
if package.name in selected_packages_by_name:
|
||||
raise PylockSelectError(
|
||||
f"Multiple packages with the name {package.name!r} are "
|
||||
f"selected at packages[{package_index}] and "
|
||||
f"packages[{selected_packages_by_name[package.name][0]}]"
|
||||
)
|
||||
|
||||
# #. Check that the source of the package is specified appropriately (i.e.
|
||||
# there are no conflicting sources in the package entry);
|
||||
# an error MUST be raised if any issues are found.
|
||||
# Covered by lock.validate() which is a precondition for this method.
|
||||
|
||||
# #. Add the package to the set of packages to install.
|
||||
selected_packages_by_name[package.name] = (package_index, package)
|
||||
|
||||
# #. For each package to be installed:
|
||||
for package_index, package in selected_packages_by_name.values():
|
||||
# - If :ref:`pylock-packages-vcs` is set:
|
||||
if package.vcs is not None:
|
||||
yield package, package.vcs
|
||||
|
||||
# - Else if :ref:`pylock-packages-directory` is set:
|
||||
elif package.directory is not None:
|
||||
yield package, package.directory
|
||||
|
||||
# - Else if :ref:`pylock-packages-archive` is set:
|
||||
elif package.archive is not None:
|
||||
yield package, package.archive
|
||||
|
||||
# - Else if there are entries for :ref:`pylock-packages-wheels`:
|
||||
elif package.wheels:
|
||||
# #. Look for the appropriate wheel file based on
|
||||
# :ref:`pylock-packages-wheels-name`; if one is not found then move
|
||||
# on to :ref:`pylock-packages-sdist` or an error MUST be raised about
|
||||
# a lack of source for the project.
|
||||
best_wheel = next(
|
||||
compatible_tags_selector(
|
||||
(wheel, parse_wheel_filename(wheel.filename)[-1])
|
||||
for wheel in package.wheels
|
||||
),
|
||||
None,
|
||||
)
|
||||
if best_wheel:
|
||||
yield package, best_wheel
|
||||
elif package.sdist is not None:
|
||||
yield package, package.sdist
|
||||
else:
|
||||
raise PylockSelectError(
|
||||
f"No wheel found matching the provided tags "
|
||||
f"for package {package.name!r} "
|
||||
f"at packages[{package_index}], "
|
||||
f"and no sdist available as a fallback"
|
||||
)
|
||||
|
||||
# - Else if no :ref:`pylock-packages-wheels` file is found or
|
||||
# :ref:`pylock-packages-sdist` is solely set:
|
||||
elif package.sdist is not None:
|
||||
yield package, package.sdist
|
||||
|
||||
else:
|
||||
# Covered by lock.validate() which is a precondition for this method.
|
||||
raise NotImplementedError # pragma: no cover
|
||||
@@ -3,7 +3,7 @@
|
||||
# for complete details.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterator
|
||||
from typing import Iterator
|
||||
|
||||
from ._parser import parse_requirement as _parse_requirement
|
||||
from ._tokenizer import ParserSyntaxError
|
||||
@@ -11,6 +11,15 @@ from .markers import Marker, _normalize_extra_values
|
||||
from .specifiers import SpecifierSet
|
||||
from .utils import canonicalize_name
|
||||
|
||||
__all__ = [
|
||||
"InvalidRequirement",
|
||||
"Requirement",
|
||||
]
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return __all__
|
||||
|
||||
|
||||
class InvalidRequirement(ValueError):
|
||||
"""
|
||||
@@ -24,6 +33,16 @@ class Requirement:
|
||||
Parse a given requirement string into its parts, such as name, specifier,
|
||||
URL, and extras. Raises InvalidRequirement on a badly-formed requirement
|
||||
string.
|
||||
|
||||
Instances are safe to serialize with :mod:`pickle`. They use a stable
|
||||
format so the same pickle can be loaded in future packaging releases.
|
||||
|
||||
.. versionchanged:: 26.2
|
||||
|
||||
Added a stable pickle format. Pickles created with packaging 26.2+ can
|
||||
be unpickled with future releases. Backward compatibility with pickles
|
||||
from packaging < 26.2 is supported but may be removed in a future
|
||||
release.
|
||||
"""
|
||||
|
||||
# TODO: Can we test whether something is contained within a requirement?
|
||||
@@ -57,28 +76,47 @@ class Requirement:
|
||||
yield str(self.specifier)
|
||||
|
||||
if self.url:
|
||||
yield f"@ {self.url}"
|
||||
yield f" @ {self.url}"
|
||||
if self.marker:
|
||||
yield " "
|
||||
|
||||
if self.marker:
|
||||
yield f"; {self.marker}"
|
||||
|
||||
def __getstate__(self) -> str:
|
||||
# Return the requirement string for compactness and stability.
|
||||
# Re-parsed on load to reconstruct all fields.
|
||||
return str(self)
|
||||
|
||||
def __setstate__(self, state: object) -> None:
|
||||
if isinstance(state, str):
|
||||
# New format (26.2+): just the requirement string.
|
||||
try:
|
||||
tmp = Requirement(state)
|
||||
except InvalidRequirement as exc:
|
||||
raise TypeError(f"Cannot restore Requirement from {state!r}") from exc
|
||||
self.name = tmp.name
|
||||
self.url = tmp.url
|
||||
self.extras = tmp.extras
|
||||
self.specifier = tmp.specifier
|
||||
self.marker = tmp.marker
|
||||
return
|
||||
if isinstance(state, dict):
|
||||
# Old format (packaging <= 26.1, no __slots__): plain __dict__.
|
||||
self.__dict__.update(state)
|
||||
return
|
||||
raise TypeError(f"Cannot restore Requirement from {state!r}")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "".join(self._iter_parts(self.name))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Requirement('{self}')>"
|
||||
return f"<{self.__class__.__name__}({str(self)!r})>"
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(
|
||||
(
|
||||
self.__class__.__name__,
|
||||
*self._iter_parts(canonicalize_name(self.name)),
|
||||
)
|
||||
)
|
||||
return hash(tuple(self._iter_parts(canonicalize_name(self.name))))
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Requirement):
|
||||
return NotImplemented
|
||||
|
||||
|
||||
+1167
-243
File diff suppressed because it is too large
Load Diff
+337
-61
@@ -5,6 +5,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import operator
|
||||
import platform
|
||||
import re
|
||||
import struct
|
||||
@@ -13,19 +14,52 @@ import sys
|
||||
import sysconfig
|
||||
from importlib.machinery import EXTENSION_SUFFIXES
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Sequence,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
|
||||
from . import _manylinux, _musllinux
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import AbstractSet
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INTERPRETER_SHORT_NAMES",
|
||||
"AppleVersion",
|
||||
"PythonVersion",
|
||||
"Tag",
|
||||
"UnsortedTagsError",
|
||||
"android_platforms",
|
||||
"compatible_tags",
|
||||
"cpython_tags",
|
||||
"create_compatible_tags_selector",
|
||||
"generic_tags",
|
||||
"interpreter_name",
|
||||
"interpreter_version",
|
||||
"ios_platforms",
|
||||
"mac_platforms",
|
||||
"parse_tag",
|
||||
"platform_tags",
|
||||
"sys_tags",
|
||||
]
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return __all__
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PythonVersion = Sequence[int]
|
||||
AppleVersion = Tuple[int, int]
|
||||
_T = TypeVar("_T")
|
||||
|
||||
INTERPRETER_SHORT_NAMES: dict[str, str] = {
|
||||
"python": "py", # Generic.
|
||||
@@ -36,7 +70,19 @@ INTERPRETER_SHORT_NAMES: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
_32_BIT_INTERPRETER = struct.calcsize("P") == 4
|
||||
# This function can be unit tested without reloading the module
|
||||
# (Unlike _32_BIT_INTERPRETER)
|
||||
def _compute_32_bit_interpreter() -> bool:
|
||||
return struct.calcsize("P") == 4
|
||||
|
||||
|
||||
_32_BIT_INTERPRETER = _compute_32_bit_interpreter()
|
||||
|
||||
|
||||
class UnsortedTagsError(ValueError):
|
||||
"""
|
||||
Raised when a tag component is not in sorted order per PEP 425.
|
||||
"""
|
||||
|
||||
|
||||
class Tag:
|
||||
@@ -45,11 +91,29 @@ class Tag:
|
||||
|
||||
Instances are considered immutable and thus are hashable. Equality checking
|
||||
is also supported.
|
||||
|
||||
Instances are safe to serialize with :mod:`pickle`. They use a stable
|
||||
format so the same pickle can be loaded in future packaging releases.
|
||||
|
||||
.. versionchanged:: 26.2
|
||||
|
||||
Added a stable pickle format. Pickles created with packaging 26.2+ can
|
||||
be unpickled with future releases. Backward compatibility with pickles
|
||||
from packaging < 26.2 is supported but may be removed in a future
|
||||
release.
|
||||
"""
|
||||
|
||||
__slots__ = ["_abi", "_hash", "_interpreter", "_platform"]
|
||||
|
||||
def __init__(self, interpreter: str, abi: str, platform: str) -> None:
|
||||
"""
|
||||
:param str interpreter: The interpreter name, e.g. ``"py"``
|
||||
(see :attr:`INTERPRETER_SHORT_NAMES` for mapping
|
||||
well-known interpreter names to their short names).
|
||||
:param str abi: The ABI that a wheel supports, e.g. ``"cp37m"``.
|
||||
:param str platform: The OS/platform the wheel supports,
|
||||
e.g. ``"win_amd64"``.
|
||||
"""
|
||||
self._interpreter = interpreter.lower()
|
||||
self._abi = abi.lower()
|
||||
self._platform = platform.lower()
|
||||
@@ -62,14 +126,25 @@ class Tag:
|
||||
|
||||
@property
|
||||
def interpreter(self) -> str:
|
||||
"""
|
||||
The interpreter name, e.g. ``"py"`` (see
|
||||
:attr:`INTERPRETER_SHORT_NAMES` for mapping well-known interpreter
|
||||
names to their short names).
|
||||
"""
|
||||
return self._interpreter
|
||||
|
||||
@property
|
||||
def abi(self) -> str:
|
||||
"""
|
||||
The supported ABI.
|
||||
"""
|
||||
return self._abi
|
||||
|
||||
@property
|
||||
def platform(self) -> str:
|
||||
"""
|
||||
The OS/platform.
|
||||
"""
|
||||
return self._platform
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
@@ -92,16 +167,69 @@ class Tag:
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self} @ {id(self)}>"
|
||||
|
||||
def __getstate__(self) -> tuple[str, str, str]:
|
||||
# Return state as a 3-item tuple: (interpreter, abi, platform).
|
||||
# Cache member _hash is excluded and will be recomputed.
|
||||
return (self._interpreter, self._abi, self._platform)
|
||||
|
||||
def parse_tag(tag: str) -> frozenset[Tag]:
|
||||
def __setstate__(self, state: object) -> None:
|
||||
if isinstance(state, tuple):
|
||||
if len(state) == 3 and all(isinstance(s, str) for s in state):
|
||||
# New format (26.2+): (interpreter, abi, platform)
|
||||
self._interpreter, self._abi, self._platform = state
|
||||
self._hash = hash((self._interpreter, self._abi, self._platform))
|
||||
return
|
||||
if len(state) == 2 and isinstance(state[1], dict):
|
||||
# Old format (packaging <= 26.1, __slots__): (None, {slot: value}).
|
||||
_, slots = state
|
||||
try:
|
||||
interpreter = slots["_interpreter"]
|
||||
abi = slots["_abi"]
|
||||
platform = slots["_platform"]
|
||||
except KeyError:
|
||||
raise TypeError(f"Cannot restore Tag from {state!r}") from None
|
||||
if not all(
|
||||
isinstance(value, str) for value in (interpreter, abi, platform)
|
||||
):
|
||||
raise TypeError(f"Cannot restore Tag from {state!r}")
|
||||
self._interpreter = interpreter.lower()
|
||||
self._abi = abi.lower()
|
||||
self._platform = platform.lower()
|
||||
self._hash = hash((self._interpreter, self._abi, self._platform))
|
||||
return
|
||||
raise TypeError(f"Cannot restore Tag from {state!r}")
|
||||
|
||||
|
||||
def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]:
|
||||
"""
|
||||
Parses the provided tag (e.g. `py3-none-any`) into a frozenset of Tag instances.
|
||||
Parses the provided tag (e.g. `py3-none-any`) into a frozenset of
|
||||
:class:`Tag` instances.
|
||||
|
||||
Returning a set is required due to the possibility that the tag is a
|
||||
compressed tag set.
|
||||
`compressed tag set`_, e.g. ``"py2.py3-none-any"`` which supports both
|
||||
Python 2 and Python 3.
|
||||
|
||||
If **validate_order** is true, compressed tag set components are checked
|
||||
to be in sorted order as required by PEP 425.
|
||||
|
||||
:param str tag: The tag to parse, e.g. ``"py3-none-any"``.
|
||||
:param bool validate_order: Check whether compressed tag set components
|
||||
are in sorted order.
|
||||
:raises UnsortedTagsError: If **validate_order** is true and any compressed tag
|
||||
set component is not in sorted order.
|
||||
|
||||
.. versionadded:: 26.1
|
||||
The *validate_order* parameter.
|
||||
"""
|
||||
tags = set()
|
||||
interpreters, abis, platforms = tag.split("-")
|
||||
if validate_order:
|
||||
for component in (interpreters, abis, platforms):
|
||||
parts = component.split(".")
|
||||
if parts != sorted(parts):
|
||||
raise UnsortedTagsError(
|
||||
f"Tag component {component!r} is not in sorted order per PEP 425"
|
||||
)
|
||||
for interpreter in interpreters.split("."):
|
||||
for abi in abis.split("."):
|
||||
for platform_ in platforms.split("."):
|
||||
@@ -142,12 +270,25 @@ def _abi3_applies(python_version: PythonVersion, threading: bool) -> bool:
|
||||
"""
|
||||
Determine if the Python version supports abi3.
|
||||
|
||||
PEP 384 was first implemented in Python 3.2. The threaded (`--disable-gil`)
|
||||
PEP 384 was first implemented in Python 3.2. The free-threaded
|
||||
builds do not support abi3.
|
||||
"""
|
||||
return len(python_version) > 1 and tuple(python_version) >= (3, 2) and not threading
|
||||
|
||||
|
||||
def _abi3t_applies(python_version: PythonVersion, threading: bool) -> bool:
|
||||
"""
|
||||
Determine if the Python version supports abi3t.
|
||||
|
||||
PEP 803 was first implemented in Python 3.15 but, per PEP 803, this
|
||||
returns tags going back to Python 3.2 to mirror the abi3
|
||||
implementation and leave open the possibility of abi3t wheels
|
||||
supporting older Python versions.
|
||||
|
||||
"""
|
||||
return len(python_version) > 1 and tuple(python_version) >= (3, 2) and threading
|
||||
|
||||
|
||||
def _cpython_abis(py_version: PythonVersion, warn: bool = False) -> list[str]:
|
||||
py_version = tuple(py_version) # To allow for version comparison.
|
||||
abis = []
|
||||
@@ -189,19 +330,31 @@ def cpython_tags(
|
||||
warn: bool = False,
|
||||
) -> Iterator[Tag]:
|
||||
"""
|
||||
Yields the tags for a CPython interpreter.
|
||||
Yields the tags for the CPython interpreter.
|
||||
|
||||
The tags consist of:
|
||||
- cp<python_version>-<abi>-<platform>
|
||||
- cp<python_version>-abi3-<platform>
|
||||
- cp<python_version>-none-<platform>
|
||||
- cp<less than python_version>-abi3-<platform> # Older Python versions down to 3.2.
|
||||
The specific tags generated are:
|
||||
|
||||
If python_version only specifies a major version then user-provided ABIs and
|
||||
the 'none' ABItag will be used.
|
||||
- ``cp<python_version>-<abi>-<platform>``
|
||||
- ``cp<python_version>-<stable_abi>-<platform>``
|
||||
- ``cp<python_version>-none-<platform>``
|
||||
- ``cp<older version>-<stable_abi>-<platform>`` where "older version" is all older
|
||||
minor versions down to Python 3.2 (when ``abi3`` was introduced)
|
||||
|
||||
If 'abi3' or 'none' are specified in 'abis' then they will be yielded at
|
||||
their normal position and not at the beginning.
|
||||
If ``python_version`` only provides a major-only version then only
|
||||
user-provided ABIs via ``abis`` and the ``none`` ABI will be used.
|
||||
|
||||
The ``stable_abi`` will be either ``abi3`` or ``abi3t`` if `abi` is a
|
||||
GIL-enabled ABI like `"cp315"` or a free-threaded ABI like `"cp315t"`,
|
||||
respectively.
|
||||
|
||||
:param Sequence python_version: A one- or two-item sequence representing the
|
||||
targeted Python version. Defaults to
|
||||
``sys.version_info[:2]``.
|
||||
:param Iterable abis: Iterable of compatible ABIs. Defaults to the ABIs
|
||||
compatible with the current system.
|
||||
:param Iterable platforms: Iterable of compatible platforms. Defaults to the
|
||||
platforms compatible with the current system.
|
||||
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
|
||||
"""
|
||||
if not python_version:
|
||||
python_version = sys.version_info[:2]
|
||||
@@ -209,16 +362,13 @@ def cpython_tags(
|
||||
interpreter = f"cp{_version_nodot(python_version[:2])}"
|
||||
|
||||
if abis is None:
|
||||
if len(python_version) > 1:
|
||||
abis = _cpython_abis(python_version, warn)
|
||||
else:
|
||||
abis = []
|
||||
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"):
|
||||
try:
|
||||
abis.remove(explicit_abi)
|
||||
except ValueError:
|
||||
except ValueError: # noqa: PERF203
|
||||
pass
|
||||
|
||||
platforms = list(platforms or platform_tags())
|
||||
@@ -228,16 +378,27 @@ def cpython_tags(
|
||||
|
||||
threading = _is_threaded_cpython(abis)
|
||||
use_abi3 = _abi3_applies(python_version, threading)
|
||||
if use_abi3:
|
||||
yield from (Tag(interpreter, "abi3", platform_) for platform_ in platforms)
|
||||
yield from (Tag(interpreter, "none", platform_) for platform_ in platforms)
|
||||
use_abi3t = _abi3t_applies(python_version, threading)
|
||||
|
||||
if use_abi3:
|
||||
yield from (Tag(interpreter, "abi3", platform_) for platform_ in platforms)
|
||||
if use_abi3t:
|
||||
yield from (Tag(interpreter, "abi3t", platform_) for platform_ in platforms)
|
||||
|
||||
yield from (Tag(interpreter, "none", platform_) for platform_ in platforms)
|
||||
|
||||
if use_abi3 or use_abi3t:
|
||||
for minor_version in range(python_version[1] - 1, 1, -1):
|
||||
for platform_ in platforms:
|
||||
version = _version_nodot((python_version[0], minor_version))
|
||||
interpreter = f"cp{version}"
|
||||
yield Tag(interpreter, "abi3", platform_)
|
||||
if use_abi3:
|
||||
yield Tag(interpreter, "abi3", platform_)
|
||||
if use_abi3t:
|
||||
# Support for abi3t was introduced in Python 3.15, but in
|
||||
# principle abi3t wheels are possible for older limited API
|
||||
# versions, so allow things like ("cp37", "abi3t", "platform")
|
||||
yield Tag(interpreter, "abi3t", platform_)
|
||||
|
||||
|
||||
def _generic_abi() -> list[str]:
|
||||
@@ -289,21 +450,31 @@ def generic_tags(
|
||||
warn: bool = False,
|
||||
) -> Iterator[Tag]:
|
||||
"""
|
||||
Yields the tags for a generic interpreter.
|
||||
Yields the tags for an interpreter which requires no specialization.
|
||||
|
||||
The tags consist of:
|
||||
- <interpreter>-<abi>-<platform>
|
||||
This function should be used if one of the other interpreter-specific
|
||||
functions provided by this module is not appropriate (i.e. not calculating
|
||||
tags for a CPython interpreter).
|
||||
|
||||
The "none" ABI will be added if it was not explicitly provided.
|
||||
The specific tags generated are:
|
||||
|
||||
- ``<interpreter>-<abi>-<platform>``
|
||||
|
||||
The ``"none"`` ABI will be added if it was not explicitly provided.
|
||||
|
||||
:param str interpreter: The name of the interpreter. Defaults to being
|
||||
calculated.
|
||||
:param Iterable abis: Iterable of compatible ABIs. Defaults to the ABIs
|
||||
compatible with the current system.
|
||||
:param Iterable platforms: Iterable of compatible platforms. Defaults to the
|
||||
platforms compatible with the current system.
|
||||
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
|
||||
"""
|
||||
if not interpreter:
|
||||
interp_name = interpreter_name()
|
||||
interp_version = interpreter_version(warn=warn)
|
||||
interpreter = "".join([interp_name, interp_version])
|
||||
if abis is None:
|
||||
abis = _generic_abi()
|
||||
else:
|
||||
abis = list(abis)
|
||||
interpreter = f"{interp_name}{interp_version}"
|
||||
abis = _generic_abi() if abis is None else list(abis)
|
||||
platforms = list(platforms or platform_tags())
|
||||
if "none" not in abis:
|
||||
abis.append("none")
|
||||
@@ -333,12 +504,22 @@ def compatible_tags(
|
||||
platforms: Iterable[str] | None = None,
|
||||
) -> Iterator[Tag]:
|
||||
"""
|
||||
Yields the sequence of tags that are compatible with a specific version of Python.
|
||||
Yields the tags for an interpreter compatible with the Python version
|
||||
specified by ``python_version``.
|
||||
|
||||
The tags consist of:
|
||||
- py*-none-<platform>
|
||||
- <interpreter>-none-any # ... if `interpreter` is provided.
|
||||
- py*-none-any
|
||||
The specific tags generated are:
|
||||
|
||||
- ``py*-none-<platform>``
|
||||
- ``<interpreter>-none-any`` if ``interpreter`` is provided
|
||||
- ``py*-none-any``
|
||||
|
||||
:param Sequence python_version: A one- or two-item sequence representing the
|
||||
compatible version of Python. Defaults to
|
||||
``sys.version_info[:2]``.
|
||||
:param str interpreter: The name of the interpreter (if known), e.g.
|
||||
``"cp38"``. Defaults to the current interpreter.
|
||||
:param Iterable platforms: Iterable of compatible platforms. Defaults to the
|
||||
platforms compatible with the current system.
|
||||
"""
|
||||
if not python_version:
|
||||
python_version = sys.version_info[:2]
|
||||
@@ -398,12 +579,25 @@ def mac_platforms(
|
||||
version: AppleVersion | None = None, arch: str | None = None
|
||||
) -> Iterator[str]:
|
||||
"""
|
||||
Yields the platform tags for a macOS system.
|
||||
Yields the :attr:`~Tag.platform` tags for macOS.
|
||||
|
||||
The `version` parameter is a two-item tuple specifying the macOS version to
|
||||
generate platform tags for. The `arch` parameter is the CPU architecture to
|
||||
generate platform tags for. Both parameters default to the appropriate value
|
||||
for the current system.
|
||||
|
||||
:param tuple version: A two-item tuple representing the version of macOS.
|
||||
Defaults to the current system's version.
|
||||
:param str arch: The CPU architecture. Defaults to the architecture of the
|
||||
current system, e.g. ``"x86_64"``.
|
||||
|
||||
.. note::
|
||||
Equivalent support for the other major platforms is purposefully not
|
||||
provided:
|
||||
|
||||
- On Windows, platform compatibility is statically specified
|
||||
- On Linux, code must be run on the system itself to determine
|
||||
compatibility
|
||||
"""
|
||||
version_str, _, cpu_arch = platform.mac_ver()
|
||||
if version is None:
|
||||
@@ -424,14 +618,11 @@ def mac_platforms(
|
||||
text=True,
|
||||
).stdout
|
||||
version = cast("AppleVersion", tuple(map(int, version_str.split(".")[:2])))
|
||||
else:
|
||||
version = version
|
||||
|
||||
if arch is None:
|
||||
arch = _mac_arch(cpu_arch)
|
||||
else:
|
||||
arch = arch
|
||||
|
||||
if (10, 0) <= version and version < (11, 0):
|
||||
if (10, 0) <= version < (11, 0):
|
||||
# Prior to Mac OS 11, each yearly release of Mac OS bumped the
|
||||
# "minor" version number. The major version was always 10.
|
||||
major_version = 10
|
||||
@@ -477,14 +668,19 @@ def ios_platforms(
|
||||
version: AppleVersion | None = None, multiarch: str | None = None
|
||||
) -> Iterator[str]:
|
||||
"""
|
||||
Yields the platform tags for an iOS system.
|
||||
|
||||
:param version: A two-item tuple specifying the iOS version to generate
|
||||
platform tags for. Defaults to the current iOS version.
|
||||
:param multiarch: The CPU architecture+ABI to generate platform tags for -
|
||||
(the value used by `sys.implementation._multiarch` e.g.,
|
||||
`arm64_iphoneos` or `x84_64_iphonesimulator`). Defaults to the current
|
||||
multiarch value.
|
||||
Yields the :attr:`~Tag.platform` tags for iOS.
|
||||
|
||||
:param tuple version: A two-item tuple representing the version of iOS.
|
||||
Defaults to the current system's version.
|
||||
:param str multiarch: The CPU architecture+ABI to be used. This should be in
|
||||
the format by ``sys.implementation._multiarch`` (e.g.,
|
||||
``arm64_iphoneos`` or ``x86_64_iphonesimulator``).
|
||||
Defaults to the current system's multiarch value.
|
||||
|
||||
.. note::
|
||||
Behavior of this method is undefined if invoked on non-iOS platforms
|
||||
without providing explicit version and multiarch arguments.
|
||||
"""
|
||||
if version is None:
|
||||
# if iOS is the current platform, ios_ver *must* be defined. However,
|
||||
@@ -586,13 +782,22 @@ def _linux_platforms(is_32bit: bool = _32_BIT_INTERPRETER) -> Iterator[str]:
|
||||
yield f"linux_{arch}"
|
||||
|
||||
|
||||
def _emscripten_platforms() -> Iterator[str]:
|
||||
pyemscripten_platform_version = sysconfig.get_config_var(
|
||||
"PYEMSCRIPTEN_PLATFORM_VERSION"
|
||||
)
|
||||
if pyemscripten_platform_version:
|
||||
yield f"pyemscripten_{pyemscripten_platform_version}_wasm32"
|
||||
yield from _generic_platforms()
|
||||
|
||||
|
||||
def _generic_platforms() -> Iterator[str]:
|
||||
yield _normalize_string(sysconfig.get_platform())
|
||||
|
||||
|
||||
def platform_tags() -> Iterator[str]:
|
||||
"""
|
||||
Provides the platform tags for this installation.
|
||||
Yields the :attr:`~Tag.platform` tags for the running interpreter.
|
||||
"""
|
||||
if platform.system() == "Darwin":
|
||||
return mac_platforms()
|
||||
@@ -602,6 +807,8 @@ def platform_tags() -> Iterator[str]:
|
||||
return android_platforms()
|
||||
elif platform.system() == "Linux":
|
||||
return _linux_platforms()
|
||||
elif platform.system() == "Emscripten":
|
||||
return _emscripten_platforms()
|
||||
else:
|
||||
return _generic_platforms()
|
||||
|
||||
@@ -612,6 +819,8 @@ def interpreter_name() -> str:
|
||||
|
||||
Some implementations have a reserved, two-letter abbreviation which will
|
||||
be returned when appropriate.
|
||||
|
||||
This typically acts as the prefix to the :attr:`~Tag.interpreter` tag.
|
||||
"""
|
||||
name = sys.implementation.name
|
||||
return INTERPRETER_SHORT_NAMES.get(name) or name
|
||||
@@ -619,14 +828,14 @@ def interpreter_name() -> str:
|
||||
|
||||
def interpreter_version(*, warn: bool = False) -> str:
|
||||
"""
|
||||
Returns the version of the running interpreter.
|
||||
Returns the running interpreter's version.
|
||||
|
||||
This typically acts as the suffix to the :attr:`~Tag.interpreter` tag.
|
||||
|
||||
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
|
||||
"""
|
||||
version = _get_config_var("py_version_nodot", warn=warn)
|
||||
if version:
|
||||
version = str(version)
|
||||
else:
|
||||
version = _version_nodot(sys.version_info[:2])
|
||||
return version
|
||||
return str(version) if version else _version_nodot(sys.version_info[:2])
|
||||
|
||||
|
||||
def _version_nodot(version: PythonVersion) -> str:
|
||||
@@ -635,10 +844,31 @@ def _version_nodot(version: PythonVersion) -> str:
|
||||
|
||||
def sys_tags(*, warn: bool = False) -> Iterator[Tag]:
|
||||
"""
|
||||
Returns the sequence of tag triples for the running interpreter.
|
||||
Yields the sequence of tag triples that the running interpreter supports.
|
||||
|
||||
The order of the sequence corresponds to priority order for the
|
||||
interpreter, from most to least important.
|
||||
The iterable is ordered so that the best-matching tag is first in the
|
||||
sequence. The exact preferential order to tags is interpreter-specific, but
|
||||
in general the tag importance is in the order of:
|
||||
|
||||
1. Interpreter
|
||||
2. Platform
|
||||
3. ABI
|
||||
|
||||
This order is due to the fact that an ABI is inherently tied to the
|
||||
platform, but platform-specific code is not necessarily tied to the ABI. The
|
||||
interpreter is the most important tag as it dictates basic support for any
|
||||
wheel.
|
||||
|
||||
The function returns an iterable in order to allow for the possible
|
||||
short-circuiting of tag generation if the entire sequence is not necessary
|
||||
and tag calculation happens to be expensive.
|
||||
|
||||
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
|
||||
|
||||
.. versionchanged:: 21.3
|
||||
Added the `pp3-none-any` tag (:issue:`311`).
|
||||
.. versionchanged:: 27.0
|
||||
Added the `abi3t` tag (:issue:`1099`).
|
||||
"""
|
||||
|
||||
interp_name = interpreter_name()
|
||||
@@ -654,3 +884,49 @@ def sys_tags(*, warn: bool = False) -> Iterator[Tag]:
|
||||
else:
|
||||
interp = None
|
||||
yield from compatible_tags(interpreter=interp)
|
||||
|
||||
|
||||
def create_compatible_tags_selector(
|
||||
tags: Iterable[Tag],
|
||||
) -> Callable[[Iterable[tuple[_T, AbstractSet[Tag]]]], Iterator[_T]]:
|
||||
"""Create a callable to select things compatible with supported tags.
|
||||
|
||||
This function accepts an ordered sequence of tags, with the preferred
|
||||
tags first.
|
||||
|
||||
The returned callable accepts an iterable of tuples (thing, set[Tag]),
|
||||
and returns an iterator of things, with the things with the best
|
||||
matching tags first.
|
||||
|
||||
Example to select compatible wheel filenames:
|
||||
|
||||
>>> from packaging import tags
|
||||
>>> from packaging.utils import parse_wheel_filename
|
||||
>>> selector = tags.create_compatible_tags_selector(tags.sys_tags())
|
||||
>>> filenames = ["foo-1.0-py3-none-any.whl", "foo-1.0-py2-none-any.whl"]
|
||||
>>> list(selector([
|
||||
... (filename, parse_wheel_filename(filename)[-1]) for filename in filenames
|
||||
... ]))
|
||||
['foo-1.0-py3-none-any.whl']
|
||||
|
||||
.. versionadded:: 26.1
|
||||
"""
|
||||
tag_ranks: dict[Tag, int] = {}
|
||||
for rank, tag in enumerate(tags):
|
||||
tag_ranks.setdefault(tag, rank) # ignore duplicate tags, keep first
|
||||
supported_tags = tag_ranks.keys()
|
||||
|
||||
def selector(
|
||||
tagged_things: Iterable[tuple[_T, AbstractSet[Tag]]],
|
||||
) -> Iterator[_T]:
|
||||
ranked_things: list[tuple[_T, int]] = []
|
||||
for thing, thing_tags in tagged_things:
|
||||
supported_thing_tags = thing_tags & supported_tags
|
||||
if supported_thing_tags:
|
||||
thing_rank = min(tag_ranks[t] for t in supported_thing_tags)
|
||||
ranked_things.append((thing, thing_rank))
|
||||
return iter(
|
||||
thing for thing, _ in sorted(ranked_things, key=operator.itemgetter(1))
|
||||
)
|
||||
|
||||
return selector
|
||||
|
||||
+160
-27
@@ -4,15 +4,36 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import re
|
||||
from typing import NewType, Tuple, Union, cast
|
||||
|
||||
from .tags import Tag, parse_tag
|
||||
from .tags import Tag, UnsortedTagsError, parse_tag
|
||||
from .version import InvalidVersion, Version, _TrimmedRelease
|
||||
|
||||
__all__ = [
|
||||
"BuildTag",
|
||||
"InvalidName",
|
||||
"InvalidSdistFilename",
|
||||
"InvalidWheelFilename",
|
||||
"NormalizedName",
|
||||
"canonicalize_name",
|
||||
"canonicalize_version",
|
||||
"is_normalized_name",
|
||||
"parse_sdist_filename",
|
||||
"parse_wheel_filename",
|
||||
]
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return __all__
|
||||
|
||||
|
||||
BuildTag = Union[Tuple[()], Tuple[int, str]]
|
||||
|
||||
NormalizedName = NewType("NormalizedName", str)
|
||||
"""
|
||||
A :class:`typing.NewType` of :class:`str`, representing a normalized name.
|
||||
"""
|
||||
|
||||
|
||||
class InvalidName(ValueError):
|
||||
@@ -35,33 +56,77 @@ class InvalidSdistFilename(ValueError):
|
||||
|
||||
# Core metadata spec for `Name`
|
||||
_validate_regex = re.compile(
|
||||
r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE
|
||||
r"[a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9]", re.IGNORECASE | re.ASCII
|
||||
)
|
||||
_canonicalize_regex = re.compile(r"[-_.]+")
|
||||
_normalized_regex = re.compile(r"^([a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9])$")
|
||||
_normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]", re.ASCII)
|
||||
# PEP 427: The build number must start with a digit.
|
||||
_build_tag_regex = re.compile(r"(\d+)(.*)")
|
||||
_build_tag_regex = re.compile(r"(\d+)(.*)", re.ASCII)
|
||||
|
||||
|
||||
def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
|
||||
if validate and not _validate_regex.match(name):
|
||||
"""
|
||||
This function takes a valid Python package or extra name, and returns the
|
||||
normalized form of it.
|
||||
|
||||
The return type is typed as :class:`NormalizedName`. This allows type
|
||||
checkers to help require that a string has passed through this function
|
||||
before use.
|
||||
|
||||
If **validate** is true, then the function will check if **name** is a valid
|
||||
distribution name before normalizing.
|
||||
|
||||
:param str name: The name to normalize.
|
||||
:param bool validate: Check whether the name is a valid distribution name.
|
||||
:raises InvalidName: If **validate** is true and the name is not an
|
||||
acceptable distribution name.
|
||||
|
||||
>>> from packaging.utils import canonicalize_name
|
||||
>>> canonicalize_name("Django")
|
||||
'django'
|
||||
>>> canonicalize_name("oslo.concurrency")
|
||||
'oslo-concurrency'
|
||||
>>> canonicalize_name("requests")
|
||||
'requests'
|
||||
"""
|
||||
if validate and not _validate_regex.fullmatch(name):
|
||||
raise InvalidName(f"name is invalid: {name!r}")
|
||||
# This is taken from PEP 503.
|
||||
value = _canonicalize_regex.sub("-", name).lower()
|
||||
return cast(NormalizedName, value)
|
||||
# Ensure all ``.`` and ``_`` are ``-``
|
||||
# Emulates ``re.sub(r"[-_.]+", "-", name).lower()`` from PEP 503
|
||||
# Much faster than re, and even faster than str.translate
|
||||
value = name.lower().replace("_", "-").replace(".", "-")
|
||||
# Condense repeats (faster than regex)
|
||||
while "--" in value:
|
||||
value = value.replace("--", "-")
|
||||
return cast("NormalizedName", value)
|
||||
|
||||
|
||||
def is_normalized_name(name: str) -> bool:
|
||||
return _normalized_regex.match(name) is not None
|
||||
"""
|
||||
Check if a name is already normalized (i.e. :func:`canonicalize_name` would
|
||||
roundtrip to the same value).
|
||||
|
||||
:param str name: The name to check.
|
||||
|
||||
>>> from packaging.utils import is_normalized_name
|
||||
>>> is_normalized_name("requests")
|
||||
True
|
||||
>>> is_normalized_name("Django")
|
||||
False
|
||||
"""
|
||||
return _normalized_regex.fullmatch(name) is not None
|
||||
|
||||
|
||||
@functools.singledispatch
|
||||
def canonicalize_version(
|
||||
version: Version | str, *, strip_trailing_zero: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Return a canonical form of a version as a string.
|
||||
"""Return a canonical form of a version as a string.
|
||||
|
||||
This function takes a string representing a package version (or a
|
||||
:class:`~packaging.version.Version` instance), and returns the
|
||||
normalized form of it. By default, it strips trailing zeros from
|
||||
the release segment.
|
||||
|
||||
>>> from packaging.utils import canonicalize_version
|
||||
>>> canonicalize_version('1.0.1')
|
||||
'1.0.1'
|
||||
|
||||
@@ -77,23 +142,63 @@ def canonicalize_version(
|
||||
|
||||
>>> canonicalize_version('foo bar baz')
|
||||
'foo bar baz'
|
||||
|
||||
>>> canonicalize_version('1.4.0.0.0')
|
||||
'1.4'
|
||||
"""
|
||||
return str(_TrimmedRelease(str(version)) if strip_trailing_zero else version)
|
||||
|
||||
|
||||
@canonicalize_version.register
|
||||
def _(version: str, *, strip_trailing_zero: bool = True) -> str:
|
||||
try:
|
||||
parsed = Version(version)
|
||||
except InvalidVersion:
|
||||
# Legacy versions cannot be normalized
|
||||
return version
|
||||
return canonicalize_version(parsed, strip_trailing_zero=strip_trailing_zero)
|
||||
if isinstance(version, str):
|
||||
try:
|
||||
version = Version(version)
|
||||
except InvalidVersion:
|
||||
return str(version)
|
||||
return str(_TrimmedRelease(version) if strip_trailing_zero else version)
|
||||
|
||||
|
||||
def parse_wheel_filename(
|
||||
filename: str,
|
||||
*,
|
||||
validate_order: bool = False,
|
||||
) -> tuple[NormalizedName, Version, BuildTag, frozenset[Tag]]:
|
||||
"""
|
||||
This function takes the filename of a wheel file, and parses it,
|
||||
returning a tuple of name, version, build number, and tags.
|
||||
|
||||
The name part of the tuple is normalized and typed as
|
||||
:class:`NormalizedName`. The version portion is an instance of
|
||||
:class:`~packaging.version.Version`. The build number is ``()`` if
|
||||
there is no build number in the wheel filename, otherwise a
|
||||
two-item tuple of an integer for the leading digits and
|
||||
a string for the rest of the build number. The tags portion is a
|
||||
frozen set of :class:`~packaging.tags.Tag` instances (as the tag
|
||||
string format allows multiple tags to be combined into a single
|
||||
string).
|
||||
|
||||
If **validate_order** is true, compressed tag set components are
|
||||
checked to be in sorted order as required by PEP 425.
|
||||
|
||||
:param str filename: The name of the wheel file.
|
||||
:param bool validate_order: Check whether compressed tag set components
|
||||
are in sorted order.
|
||||
:raises InvalidWheelFilename: If the filename in question
|
||||
does not follow the :ref:`wheel specification
|
||||
<pypug:binary-distribution-format>`.
|
||||
|
||||
>>> from packaging.utils import parse_wheel_filename
|
||||
>>> from packaging.tags import Tag
|
||||
>>> from packaging.version import Version
|
||||
>>> name, ver, build, tags = parse_wheel_filename("foo-1.0-py3-none-any.whl")
|
||||
>>> name
|
||||
'foo'
|
||||
>>> ver == Version('1.0')
|
||||
True
|
||||
>>> tags == {Tag("py3", "none", "any")}
|
||||
True
|
||||
>>> not build
|
||||
True
|
||||
|
||||
.. versionadded:: 26.1
|
||||
The *validate_order* parameter.
|
||||
"""
|
||||
if not filename.endswith(".whl"):
|
||||
raise InvalidWheelFilename(
|
||||
f"Invalid wheel filename (extension must be '.whl'): {filename!r}"
|
||||
@@ -127,14 +232,42 @@ def parse_wheel_filename(
|
||||
raise InvalidWheelFilename(
|
||||
f"Invalid build number: {build_part} in {filename!r}"
|
||||
)
|
||||
build = cast(BuildTag, (int(build_match.group(1)), build_match.group(2)))
|
||||
build = cast("BuildTag", (int(build_match.group(1)), build_match.group(2)))
|
||||
else:
|
||||
build = ()
|
||||
tags = parse_tag(parts[-1])
|
||||
tag_str = parts[-1]
|
||||
try:
|
||||
tags = parse_tag(tag_str, validate_order=validate_order)
|
||||
except UnsortedTagsError:
|
||||
raise InvalidWheelFilename(
|
||||
f"Invalid wheel filename (compressed tag set components must be in "
|
||||
f"sorted order per PEP 425): {filename!r}"
|
||||
) from None
|
||||
return (name, version, build, tags)
|
||||
|
||||
|
||||
def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]:
|
||||
"""
|
||||
This function takes the filename of a sdist file (as specified
|
||||
in the `Source distribution format`_ documentation), and parses
|
||||
it, returning a tuple of the normalized name and version as
|
||||
represented by an instance of :class:`~packaging.version.Version`.
|
||||
|
||||
:param str filename: The name of the sdist file.
|
||||
:raises InvalidSdistFilename: If the filename does not end
|
||||
with an sdist extension (``.zip`` or ``.tar.gz``), or if it does not
|
||||
contain a dash separating the name and the version of the distribution.
|
||||
|
||||
>>> from packaging.utils import parse_sdist_filename
|
||||
>>> from packaging.version import Version
|
||||
>>> name, ver = parse_sdist_filename("foo-1.0.tar.gz")
|
||||
>>> name
|
||||
'foo'
|
||||
>>> ver == Version('1.0')
|
||||
True
|
||||
|
||||
.. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name
|
||||
"""
|
||||
if filename.endswith(".tar.gz"):
|
||||
file_stem = filename[: -len(".tar.gz")]
|
||||
elif filename.endswith(".zip"):
|
||||
|
||||
+803
-154
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user