🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

setuptools

Package Overview
Dependencies
Maintainers
3
Versions
624
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

setuptools - pypi Package Compare versions

Comparing version
80.10.1
to
80.10.2
setuptools/_vendor/packaging-26.0.dist-info/INSTALLER

Sorry, the diff of this file is not supported yet

+3
This software is made available under the terms of *either* of the licenses
found in LICENSE.APACHE or LICENSE.BSD. Contributions to this software is made
under the terms of *both* these licenses.

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Metadata-Version: 2.4
Name: packaging
Version: 26.0
Summary: Core utilities for Python packages
Author-email: Donald Stufft <donald@stufft.io>
Requires-Python: >=3.8
Description-Content-Type: text/x-rst
License-Expression: Apache-2.0 OR BSD-2-Clause
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Typing :: Typed
License-File: LICENSE
License-File: LICENSE.APACHE
License-File: LICENSE.BSD
Project-URL: Documentation, https://packaging.pypa.io/
Project-URL: Source, https://github.com/pypa/packaging
packaging
=========
.. start-intro
Reusable core utilities for various Python Packaging
`interoperability specifications <https://packaging.python.org/specifications/>`_.
This library provides utilities that implement the interoperability
specifications which have clearly one correct behaviour (eg: :pep:`440`)
or benefit greatly from having a single shared implementation (eg: :pep:`425`).
.. end-intro
The ``packaging`` project includes the following: version handling, specifiers,
markers, requirements, tags, metadata, lockfiles, utilities.
Documentation
-------------
The `documentation`_ provides information and the API for the following:
- Version Handling
- Specifiers
- Markers
- Requirements
- Tags
- Metadata
- Lockfiles
- Utilities
Installation
------------
Use ``pip`` to install these utilities::
pip install packaging
The ``packaging`` library uses calendar-based versioning (``YY.N``).
Discussion
----------
If you run into bugs, you can file them in our `issue tracker`_.
You can also join ``#pypa`` on Freenode to ask questions or get involved.
.. _`documentation`: https://packaging.pypa.io/
.. _`issue tracker`: https://github.com/pypa/packaging/issues
Code of Conduct
---------------
Everyone interacting in the packaging project's codebases, issue trackers, chat
rooms, and mailing lists is expected to follow the `PSF Code of Conduct`_.
.. _PSF Code of Conduct: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md
Contributing
------------
The ``CONTRIBUTING.rst`` file outlines how to contribute to this project as
well as how to report a potential security issue. The documentation for this
project also covers information about `project development`_ and `security`_.
.. _`project development`: https://packaging.pypa.io/en/latest/development/
.. _`security`: https://packaging.pypa.io/en/latest/security/
Project History
---------------
Please review the ``CHANGELOG.rst`` file or the `Changelog documentation`_ for
recent changes and project history.
.. _`Changelog documentation`: https://packaging.pypa.io/en/latest/changelog/
packaging-26.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
packaging-26.0.dist-info/METADATA,sha256=M2K7fWom2iliuo2qpHhc0LrKwhq6kIoRlcyPWVgKJlo,3309
packaging-26.0.dist-info/RECORD,,
packaging-26.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
packaging-26.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
packaging-26.0.dist-info/licenses/LICENSE,sha256=ytHvW9NA1z4HS6YU0m996spceUDD2MNIUuZcSQlobEg,197
packaging-26.0.dist-info/licenses/LICENSE.APACHE,sha256=DVQuDIgE45qn836wDaWnYhSdxoLXgpRRKH4RuTjpRZQ,10174
packaging-26.0.dist-info/licenses/LICENSE.BSD,sha256=tw5-m3QvHMb5SLNMFqo5_-zpQZY2S8iP8NIYDwAo-sU,1344
packaging/__init__.py,sha256=y4lVbpeBzCGk-IPDw5BGBZ_b0P3ukEEJZAbGYc6Ey8c,494
packaging/_elffile.py,sha256=-sKkptYqzYw2-x3QByJa5mB4rfPWu1pxkZHRx1WAFCY,3211
packaging/_manylinux.py,sha256=Hf6nB0cOrayEs96-p3oIXAgGnFquv20DO5l-o2_Xnv0,9559
packaging/_musllinux.py,sha256=Z6swjH3MA7XS3qXnmMN7QPhqP3fnoYI0eQ18e9-HgAE,2707
packaging/_parser.py,sha256=U_DajsEx2VoC_F46fSVV3hDKNCWoQYkPkasO3dld0ig,10518
packaging/_structures.py,sha256=Hn49Ta8zV9Wo8GiCL8Nl2ARZY983Un3pruZGVNldPwE,1514
packaging/_tokenizer.py,sha256=M8EwNIdXeL9NMFuFrQtiOKwjka_xFx8KjRQnfE8O_z8,5421
packaging/licenses/__init__.py,sha256=TwXLHZCXwSgdFwRLPxW602T6mSieunSFHM6fp8pgW78,5819
packaging/licenses/_spdx.py,sha256=WW7DXiyg68up_YND_wpRYlr1SHhiV4FfJLQffghhMxQ,51122
packaging/markers.py,sha256=ZX-cLvW1S3cZcEc0fHI4z7zSx5U2T19yMpDP_mE-CYw,12771
packaging/metadata.py,sha256=CWVZpN_HfoYMSSDuCP7igOvGgqA9AOmpW8f3qTisfnc,39360
packaging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
packaging/pylock.py,sha256=-R1uNfJ4PaLto7Mg62YsGOHgvskuiIEqPwxOywl42Jk,22537
packaging/requirements.py,sha256=PMCAWD8aNMnVD-6uZMedhBuAVX2573eZ4yPBLXmz04I,2870
packaging/specifiers.py,sha256=EPNPimY_zFivthv1vdjZYz5IqkKGsnKR2yKh-EVyvZw,40797
packaging/tags.py,sha256=cXLV1pJD3UtJlDg7Wz3zrfdQhRZqr8jumSAKKAAd2xE,22856
packaging/utils.py,sha256=N4c6oZzFJy6klTZ3AnkNz7sSkJesuFWPp68LA3B5dAo,5040
packaging/version.py,sha256=7XWlL2IDYLwDYC0ht6cFEhapLwLWbmyo4rb7sEFj0x8,23272

Sorry, the diff of this file is not supported yet

Wheel-Version: 1.0
Generator: flit 3.12.0
Root-Is-Purelib: true
Tag: py3-none-any
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,
)
from .markers import Marker
from .specifiers import SpecifierSet
from .utils import NormalizedName, is_normalized_name
from .version import Version
if TYPE_CHECKING: # pragma: no cover
from pathlib import Path
from typing_extensions import Self
_logger = logging.getLogger(__name__)
__all__ = [
"Package",
"PackageArchive",
"PackageDirectory",
"PackageSdist",
"PackageVcs",
"PackageWheel",
"Pylock",
"PylockUnsupportedVersionError",
"PylockValidationError",
"is_valid_pylock_path",
]
_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 _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`."""
@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
@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
@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"
)
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())
[console_scripts]
wheel=wheel._commands:main
[distutils.commands]
bdist_wheel=wheel.bdist_wheel:bdist_wheel

Sorry, the diff of this file is not supported yet

MIT License
Copyright (c) 2012 Daniel Holth <dholth@fastmail.fm> and contributors
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
Metadata-Version: 2.4
Name: wheel
Version: 0.46.3
Summary: Command line tool for manipulating wheel files
Keywords: wheel,packaging
Author-email: Daniel Holth <dholth@fastmail.fm>
Maintainer-email: Alex Grönholm <alex.gronholm@nextday.fi>
Requires-Python: >=3.9
Description-Content-Type: text/x-rst
License-Expression: MIT
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Topic :: System :: Archiving :: Packaging
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
License-File: LICENSE.txt
Requires-Dist: packaging >= 24.0
Requires-Dist: pytest >= 6.0.0 ; extra == "test"
Requires-Dist: setuptools >= 77 ; extra == "test"
Project-URL: Changelog, https://wheel.readthedocs.io/en/stable/news.html
Project-URL: Documentation, https://wheel.readthedocs.io/
Project-URL: Issue Tracker, https://github.com/pypa/wheel/issues
Project-URL: Source, https://github.com/pypa/wheel
Provides-Extra: test
wheel
=====
This is a command line tool for manipulating Python wheel files, as defined in
`PEP 427`_. It contains the following functionality:
* Convert ``.egg`` archives into ``.whl``
* Unpack wheel archives
* Repack wheel archives
* Add or remove tags in existing wheel archives
.. _PEP 427: https://www.python.org/dev/peps/pep-0427/
Historical note
---------------
This project used to contain the implementation of the setuptools_ ``bdist_wheel``
command, but as of setuptools v70.1, it no longer needs ``wheel`` installed for that to
work. Thus, you should install this **only** if you intend to use the ``wheel`` command
line tool!
.. _setuptools: https://pypi.org/project/setuptools/
Documentation
-------------
The documentation_ can be found on Read The Docs.
.. _documentation: https://wheel.readthedocs.io/
Code of Conduct
---------------
Everyone interacting in the wheel project's codebases, issue trackers, chat
rooms, and mailing lists is expected to follow the `PSF Code of Conduct`_.
.. _PSF Code of Conduct: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md
bin/wheel,sha256=OLjFs9G7MQ-f_mhuW2aLGpngsyphW_v5FIoyN88Hfk0,339
wheel-0.46.3.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
wheel-0.46.3.dist-info/METADATA,sha256=IpEKqXyonLzCCgGeJ_4xNgt5KaS9ZsoNMQ-ZpE9szTU,2410
wheel-0.46.3.dist-info/RECORD,,
wheel-0.46.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
wheel-0.46.3.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
wheel-0.46.3.dist-info/entry_points.txt,sha256=JJdtSAGTvMLbIkTVZUAMvGKO39FtWfCVF8mp_NH6e4g,110
wheel-0.46.3.dist-info/licenses/LICENSE.txt,sha256=MMI2GGeRCPPo6h0qZYx8pBe9_IkcmO8aifpP8MmChlQ,1107
wheel/__init__.py,sha256=UweKvhe4SyP7zFyDoYo8BOuwTA6q3-_WpMmY2NNO54c,59
wheel/__main__.py,sha256=_83wl9tyGU2cHiqfudpubGHdRL5uonPXnzeznznkxzs,512
wheel/_bdist_wheel.py,sha256=bpmNa7_s-CYFkVgXf9ENAYTiJ01XBhRW4pxH1T8XYsI,21729
wheel/_commands/__init__.py,sha256=fCRAQZNDyj2JLrufdgPsBlaRS_t_j_aBUMpXj09KZ4E,4432
wheel/_commands/convert.py,sha256=0wSJMU0m-6LY16Om8Wmmloy-hJWFZeOmI8hT-2Z7Qms,12743
wheel/_commands/pack.py,sha256=o3iwjfRHl7N9ul-M2kHbewLJZnqBLAWf0tzUCwoiTMw,3078
wheel/_commands/tags.py,sha256=Rv2ySVb8-qX3osKp3uJgxcIMXkjt43XUD0-zvC6KvnY,4775
wheel/_commands/unpack.py,sha256=AjDSS23XYyCSFfifnMutinrpPv-DK_2wbNHkKAUFwgM,1016
wheel/_metadata.py,sha256=BP5jC9uC1hyicp7nL4FJ2LYixNFpEJIV_uMDY1KBZBg,6188
wheel/_setuptools_logging.py,sha256=-5KC-lne0ilOUWIDfOkqapUWGMFZhuKYDIavIZiB5kM,781
wheel/bdist_wheel.py,sha256=HrzYiSzMkh5ohAAhlQnYBS1p8qbr85X6F59xqxd9kBg,1102
wheel/macosx_libfile.py,sha256=pL0wm88jRMl_4ASgGlNg_mz69Zmv5xm8JSkjLdwyvIQ,16712
wheel/metadata.py,sha256=GknOO7JJiZMlcEe_fiD7nqnDTTLd0sX_-IgipM4L3-4,757
wheel/wheelfile.py,sha256=m_g_7TNsEp-j-xnvSr5yDLEFb1nhyObueq9Q5_1_lBA,8720

Sorry, the diff of this file is not supported yet

Wheel-Version: 1.0
Generator: flit 3.12.0
Root-Is-Purelib: true
Tag: py3-none-any
"""
Wheel command-line utility.
"""
from __future__ import annotations
import argparse
import os
import sys
from argparse import ArgumentTypeError
from ..wheelfile import WheelError
def unpack_f(args: argparse.Namespace) -> None:
from .unpack import unpack
unpack(args.wheelfile, args.dest)
def pack_f(args: argparse.Namespace) -> None:
from .pack import pack
pack(args.directory, args.dest_dir, args.build_number)
def convert_f(args: argparse.Namespace) -> None:
from .convert import convert
convert(args.files, args.dest_dir, args.verbose)
def tags_f(args: argparse.Namespace) -> None:
from .tags import tags
names = (
tags(
wheel,
args.python_tag,
args.abi_tag,
args.platform_tag,
args.build,
args.remove,
)
for wheel in args.wheel
)
for name in names:
print(name)
def version_f(args: argparse.Namespace) -> None:
from .. import __version__
print(f"wheel {__version__}")
def parse_build_tag(build_tag: str) -> str:
if build_tag and not build_tag[0].isdigit():
raise ArgumentTypeError("build tag must begin with a digit")
elif "-" in build_tag:
raise ArgumentTypeError("invalid character ('-') in build tag")
return build_tag
TAGS_HELP = """\
Make a new wheel with given tags. Any tags unspecified will remain the same.
Starting the tags with a "+" will append to the existing tags. Starting with a
"-" will remove a tag (use --option=-TAG syntax). Multiple tags can be
separated by ".". The original file will remain unless --remove is given. The
output filename(s) will be displayed on stdout for further processing.
"""
def parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser()
s = p.add_subparsers(help="commands")
unpack_parser = s.add_parser("unpack", help="Unpack wheel")
unpack_parser.add_argument(
"--dest", "-d", help="Destination directory", default="."
)
unpack_parser.add_argument("wheelfile", help="Wheel file")
unpack_parser.set_defaults(func=unpack_f)
repack_parser = s.add_parser("pack", help="Repack wheel")
repack_parser.add_argument("directory", help="Root directory of the unpacked wheel")
repack_parser.add_argument(
"--dest-dir",
"-d",
default=os.path.curdir,
help="Directory to store the wheel (default %(default)s)",
)
repack_parser.add_argument(
"--build-number", help="Build tag to use in the wheel name"
)
repack_parser.set_defaults(func=pack_f)
convert_parser = s.add_parser("convert", help="Convert egg or wininst to wheel")
convert_parser.add_argument("files", nargs="*", help="Files to convert")
convert_parser.add_argument(
"--dest-dir",
"-d",
default=os.path.curdir,
help="Directory to store wheels (default %(default)s)",
)
convert_parser.add_argument("--verbose", "-v", action="store_true")
convert_parser.set_defaults(func=convert_f)
tags_parser = s.add_parser(
"tags", help="Add or replace the tags on a wheel", description=TAGS_HELP
)
tags_parser.add_argument("wheel", nargs="*", help="Existing wheel(s) to retag")
tags_parser.add_argument(
"--remove",
action="store_true",
help="Remove the original files, keeping only the renamed ones",
)
tags_parser.add_argument(
"--python-tag", metavar="TAG", help="Specify an interpreter tag(s)"
)
tags_parser.add_argument("--abi-tag", metavar="TAG", help="Specify an ABI tag(s)")
tags_parser.add_argument(
"--platform-tag", metavar="TAG", help="Specify a platform tag(s)"
)
tags_parser.add_argument(
"--build", type=parse_build_tag, metavar="BUILD", help="Specify a build tag"
)
tags_parser.set_defaults(func=tags_f)
version_parser = s.add_parser("version", help="Print version and exit")
version_parser.set_defaults(func=version_f)
help_parser = s.add_parser("help", help="Show this help")
help_parser.set_defaults(func=lambda args: p.print_help())
return p
def main() -> int:
p = parser()
args = p.parse_args()
if not hasattr(args, "func"):
p.print_help()
else:
try:
args.func(args)
return 0
except WheelError as e:
print(e, file=sys.stderr)
return 1
from __future__ import annotations
import os.path
import re
from abc import ABCMeta, abstractmethod
from collections import defaultdict
from collections.abc import Iterator
from email.message import Message
from email.parser import Parser
from email.policy import EmailPolicy
from glob import iglob
from pathlib import Path
from textwrap import dedent
from zipfile import ZipFile
from packaging.tags import parse_tag
from .. import __version__
from .._metadata import generate_requirements
from ..wheelfile import WheelFile
egg_filename_re = re.compile(
r"""
(?P<name>.+?)-(?P<ver>.+?)
(-(?P<pyver>py\d\.\d+)
(-(?P<arch>.+?))?
)?.egg$""",
re.VERBOSE,
)
egg_info_re = re.compile(
r"""
^(?P<name>.+?)-(?P<ver>.+?)
(-(?P<pyver>py\d\.\d+)
)?.egg-info/""",
re.VERBOSE,
)
wininst_re = re.compile(
r"\.(?P<platform>win32|win-amd64)(?:-(?P<pyver>py\d\.\d))?\.exe$"
)
pyd_re = re.compile(r"\.(?P<abi>[a-z0-9]+)-(?P<platform>win32|win_amd64)\.pyd$")
serialization_policy = EmailPolicy(
utf8=True,
mangle_from_=False,
max_line_length=0,
)
GENERATOR = f"wheel {__version__}"
def convert_requires(requires: str, metadata: Message) -> None:
extra: str | None = None
requirements: dict[str | None, list[str]] = defaultdict(list)
for line in requires.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("[") and line.endswith("]"):
extra = line[1:-1]
continue
requirements[extra].append(line)
for key, value in generate_requirements(requirements):
metadata.add_header(key, value)
def convert_pkg_info(pkginfo: str, metadata: Message) -> None:
parsed_message = Parser().parsestr(pkginfo)
for key, value in parsed_message.items():
key_lower = key.lower()
if value == "UNKNOWN":
continue
if key_lower == "description":
description_lines = value.splitlines()
if description_lines:
value = "\n".join(
(
description_lines[0].lstrip(),
dedent("\n".join(description_lines[1:])),
"\n",
)
)
else:
value = "\n"
metadata.set_payload(value)
elif key_lower == "home-page":
metadata.add_header("Project-URL", f"Homepage, {value}")
elif key_lower == "download-url":
metadata.add_header("Project-URL", f"Download, {value}")
else:
metadata.add_header(key, value)
metadata.replace_header("Metadata-Version", "2.4")
def normalize(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).lower().replace("-", "_")
class ConvertSource(metaclass=ABCMeta):
name: str
version: str
pyver: str = "py2.py3"
abi: str = "none"
platform: str = "any"
metadata: Message
@property
def dist_info_dir(self) -> str:
return f"{self.name}-{self.version}.dist-info"
@abstractmethod
def generate_contents(self) -> Iterator[tuple[str, bytes]]:
pass
class EggFileSource(ConvertSource):
def __init__(self, path: Path):
if not (match := egg_filename_re.match(path.name)):
raise ValueError(f"Invalid egg file name: {path.name}")
# Binary wheels are assumed to be for CPython
self.path = path
self.name = normalize(match.group("name"))
self.version = match.group("ver")
if pyver := match.group("pyver"):
self.pyver = pyver.replace(".", "")
if arch := match.group("arch"):
self.abi = self.pyver.replace("py", "cp")
self.platform = normalize(arch)
self.metadata = Message()
def generate_contents(self) -> Iterator[tuple[str, bytes]]:
with ZipFile(self.path, "r") as zip_file:
for filename in sorted(zip_file.namelist()):
# Skip pure directory entries
if filename.endswith("/"):
continue
# Handle files in the egg-info directory specially, selectively moving
# them to the dist-info directory while converting as needed
if filename.startswith("EGG-INFO/"):
if filename == "EGG-INFO/requires.txt":
requires = zip_file.read(filename).decode("utf-8")
convert_requires(requires, self.metadata)
elif filename == "EGG-INFO/PKG-INFO":
pkginfo = zip_file.read(filename).decode("utf-8")
convert_pkg_info(pkginfo, self.metadata)
elif filename == "EGG-INFO/entry_points.txt":
yield (
f"{self.dist_info_dir}/entry_points.txt",
zip_file.read(filename),
)
continue
# For any other file, just pass it through
yield filename, zip_file.read(filename)
class EggDirectorySource(EggFileSource):
def generate_contents(self) -> Iterator[tuple[str, bytes]]:
for dirpath, _, filenames in os.walk(self.path):
for filename in sorted(filenames):
path = Path(dirpath, filename)
if path.parent.name == "EGG-INFO":
if path.name == "requires.txt":
requires = path.read_text("utf-8")
convert_requires(requires, self.metadata)
elif path.name == "PKG-INFO":
pkginfo = path.read_text("utf-8")
convert_pkg_info(pkginfo, self.metadata)
if name := self.metadata.get("Name"):
self.name = normalize(name)
if version := self.metadata.get("Version"):
self.version = version
elif path.name == "entry_points.txt":
yield (
f"{self.dist_info_dir}/entry_points.txt",
path.read_bytes(),
)
continue
# For any other file, just pass it through
yield str(path.relative_to(self.path)), path.read_bytes()
class WininstFileSource(ConvertSource):
"""
Handles distributions created with ``bdist_wininst``.
The egginfo filename has the format::
name-ver(-pyver)(-arch).egg-info
The installer filename has the format::
name-ver.arch(-pyver).exe
Some things to note:
1. The installer filename is not definitive. An installer can be renamed
and work perfectly well as an installer. So more reliable data should
be used whenever possible.
2. The egg-info data should be preferred for the name and version, because
these come straight from the distutils metadata, and are mandatory.
3. The pyver from the egg-info data should be ignored, as it is
constructed from the version of Python used to build the installer,
which is irrelevant - the installer filename is correct here (even to
the point that when it's not there, any version is implied).
4. The architecture must be taken from the installer filename, as it is
not included in the egg-info data.
5. Architecture-neutral installers still have an architecture because the
installer format itself (being executable) is architecture-specific. We
should therefore ignore the architecture if the content is pure-python.
"""
def __init__(self, path: Path):
self.path = path
self.metadata = Message()
# Determine the initial architecture and Python version from the file name
# (if possible)
if match := wininst_re.search(path.name):
self.platform = normalize(match.group("platform"))
if pyver := match.group("pyver"):
self.pyver = pyver.replace(".", "")
# Look for an .egg-info directory and any .pyd files for more precise info
egg_info_found = pyd_found = False
with ZipFile(self.path) as zip_file:
for filename in zip_file.namelist():
prefix, filename = filename.split("/", 1)
if not egg_info_found and (match := egg_info_re.match(filename)):
egg_info_found = True
self.name = normalize(match.group("name"))
self.version = match.group("ver")
if pyver := match.group("pyver"):
self.pyver = pyver.replace(".", "")
elif not pyd_found and (match := pyd_re.search(filename)):
pyd_found = True
self.abi = match.group("abi")
self.platform = match.group("platform")
if egg_info_found and pyd_found:
break
def generate_contents(self) -> Iterator[tuple[str, bytes]]:
dist_info_dir = f"{self.name}-{self.version}.dist-info"
data_dir = f"{self.name}-{self.version}.data"
with ZipFile(self.path, "r") as zip_file:
for filename in sorted(zip_file.namelist()):
# Skip pure directory entries
if filename.endswith("/"):
continue
# Handle files in the egg-info directory specially, selectively moving
# them to the dist-info directory while converting as needed
prefix, target_filename = filename.split("/", 1)
if egg_info_re.search(target_filename):
basename = target_filename.rsplit("/", 1)[-1]
if basename == "requires.txt":
requires = zip_file.read(filename).decode("utf-8")
convert_requires(requires, self.metadata)
elif basename == "PKG-INFO":
pkginfo = zip_file.read(filename).decode("utf-8")
convert_pkg_info(pkginfo, self.metadata)
elif basename == "entry_points.txt":
yield (
f"{dist_info_dir}/entry_points.txt",
zip_file.read(filename),
)
continue
elif prefix == "SCRIPTS":
target_filename = f"{data_dir}/scripts/{target_filename}"
# For any other file, just pass it through
yield target_filename, zip_file.read(filename)
def convert(files: list[str], dest_dir: str, verbose: bool) -> None:
for pat in files:
for archive in iglob(pat):
path = Path(archive)
if path.suffix == ".egg":
if path.is_dir():
source: ConvertSource = EggDirectorySource(path)
else:
source = EggFileSource(path)
else:
source = WininstFileSource(path)
if verbose:
print(f"{archive}...", flush=True, end="")
dest_path = Path(dest_dir) / (
f"{source.name}-{source.version}-{source.pyver}-{source.abi}"
f"-{source.platform}.whl"
)
with WheelFile(dest_path, "w") as wheelfile:
for name_or_zinfo, contents in source.generate_contents():
wheelfile.writestr(name_or_zinfo, contents)
# Write the METADATA file
wheelfile.writestr(
f"{source.dist_info_dir}/METADATA",
source.metadata.as_string(policy=serialization_policy).encode(
"utf-8"
),
)
# Write the WHEEL file
wheel_message = Message()
wheel_message.add_header("Wheel-Version", "1.0")
wheel_message.add_header("Generator", GENERATOR)
wheel_message.add_header(
"Root-Is-Purelib", str(source.platform == "any").lower()
)
tags = parse_tag(f"{source.pyver}-{source.abi}-{source.platform}")
for tag in sorted(tags, key=lambda tag: tag.interpreter):
wheel_message.add_header("Tag", str(tag))
wheelfile.writestr(
f"{source.dist_info_dir}/WHEEL",
wheel_message.as_string(policy=serialization_policy).encode(
"utf-8"
),
)
if verbose:
print("OK")
from __future__ import annotations
import email.policy
import os.path
import re
from email.generator import BytesGenerator
from email.parser import BytesParser
from ..wheelfile import WheelError, WheelFile
DIST_INFO_RE = re.compile(r"^(?P<namever>(?P<name>.+?)-(?P<ver>\d.*?))\.dist-info$")
def pack(directory: str, dest_dir: str, build_number: str | None) -> None:
"""Repack a previously unpacked wheel directory into a new wheel file.
The .dist-info/WHEEL file must contain one or more tags so that the target
wheel file name can be determined.
:param directory: The unpacked wheel directory
:param dest_dir: Destination directory (defaults to the current directory)
"""
# Find the .dist-info directory
dist_info_dirs = [
fn
for fn in os.listdir(directory)
if os.path.isdir(os.path.join(directory, fn)) and DIST_INFO_RE.match(fn)
]
if len(dist_info_dirs) > 1:
raise WheelError(f"Multiple .dist-info directories found in {directory}")
elif not dist_info_dirs:
raise WheelError(f"No .dist-info directories found in {directory}")
# Determine the target wheel filename
dist_info_dir = dist_info_dirs[0]
name_version = DIST_INFO_RE.match(dist_info_dir).group("namever")
# Read the tags and the existing build number from .dist-info/WHEEL
wheel_file_path = os.path.join(directory, dist_info_dir, "WHEEL")
with open(wheel_file_path, "rb") as f:
info = BytesParser(policy=email.policy.compat32).parse(f)
tags: list[str] = info.get_all("Tag", [])
existing_build_number = info.get("Build")
if not tags:
raise WheelError(
f"No tags present in {dist_info_dir}/WHEEL; cannot determine target "
f"wheel filename"
)
# Set the wheel file name and add/replace/remove the Build tag in .dist-info/WHEEL
build_number = build_number if build_number is not None else existing_build_number
if build_number is not None:
del info["Build"]
if build_number:
info["Build"] = build_number
name_version += "-" + build_number
if build_number != existing_build_number:
with open(wheel_file_path, "wb") as f:
BytesGenerator(f, maxheaderlen=0).flatten(info)
# Reassemble the tags for the wheel file
tagline = compute_tagline(tags)
# Repack the wheel
wheel_path = os.path.join(dest_dir, f"{name_version}-{tagline}.whl")
with WheelFile(wheel_path, "w") as wf:
print(f"Repacking wheel as {wheel_path}...", end="", flush=True)
wf.write_files(directory)
print("OK")
def compute_tagline(tags: list[str]) -> str:
"""Compute a tagline from a list of tags.
:param tags: A list of tags
:return: A tagline
"""
impls = sorted({tag.split("-")[0] for tag in tags})
abivers = sorted({tag.split("-")[1] for tag in tags})
platforms = sorted({tag.split("-")[2] for tag in tags})
return "-".join([".".join(impls), ".".join(abivers), ".".join(platforms)])
from __future__ import annotations
import email.policy
import itertools
import os
from collections.abc import Iterable
from email.parser import BytesParser
from ..wheelfile import WheelFile
def _compute_tags(original_tags: Iterable[str], new_tags: str | None) -> set[str]:
"""Add or replace tags. Supports dot-separated tags"""
if new_tags is None:
return set(original_tags)
if new_tags.startswith("+"):
return {*original_tags, *new_tags[1:].split(".")}
if new_tags.startswith("-"):
return set(original_tags) - set(new_tags[1:].split("."))
return set(new_tags.split("."))
def tags(
wheel: str,
python_tags: str | None = None,
abi_tags: str | None = None,
platform_tags: str | None = None,
build_tag: str | None = None,
remove: bool = False,
) -> str:
"""Change the tags on a wheel file.
The tags are left unchanged if they are not specified. To specify "none",
use ["none"]. To append to the previous tags, a tag should start with a
"+". If a tag starts with "-", it will be removed from existing tags.
Processing is done left to right.
:param wheel: The paths to the wheels
:param python_tags: The Python tags to set
:param abi_tags: The ABI tags to set
:param platform_tags: The platform tags to set
:param build_tag: The build tag to set
:param remove: Remove the original wheel
"""
with WheelFile(wheel, "r") as f:
assert f.filename, f"{f.filename} must be available"
wheel_info = f.read(f.dist_info_path + "/WHEEL")
info = BytesParser(policy=email.policy.compat32).parsebytes(wheel_info)
original_wheel_name = os.path.basename(f.filename)
namever = f.parsed_filename.group("namever")
build = f.parsed_filename.group("build")
original_python_tags = f.parsed_filename.group("pyver").split(".")
original_abi_tags = f.parsed_filename.group("abi").split(".")
original_plat_tags = f.parsed_filename.group("plat").split(".")
tags: list[str] = info.get_all("Tag", [])
existing_build_tag = info.get("Build")
impls = {tag.split("-")[0] for tag in tags}
abivers = {tag.split("-")[1] for tag in tags}
platforms = {tag.split("-")[2] for tag in tags}
if impls != set(original_python_tags):
msg = f"Wheel internal tags {impls!r} != filename tags {original_python_tags!r}"
raise AssertionError(msg)
if abivers != set(original_abi_tags):
msg = f"Wheel internal tags {abivers!r} != filename tags {original_abi_tags!r}"
raise AssertionError(msg)
if platforms != set(original_plat_tags):
msg = (
f"Wheel internal tags {platforms!r} != filename tags {original_plat_tags!r}"
)
raise AssertionError(msg)
if existing_build_tag != build:
msg = (
f"Incorrect filename '{build}' "
f"& *.dist-info/WHEEL '{existing_build_tag}' build numbers"
)
raise AssertionError(msg)
# Start changing as needed
if build_tag is not None:
build = build_tag
final_python_tags = sorted(_compute_tags(original_python_tags, python_tags))
final_abi_tags = sorted(_compute_tags(original_abi_tags, abi_tags))
final_plat_tags = sorted(_compute_tags(original_plat_tags, platform_tags))
final_tags = [
namever,
".".join(final_python_tags),
".".join(final_abi_tags),
".".join(final_plat_tags),
]
if build:
final_tags.insert(1, build)
final_wheel_name = "-".join(final_tags) + ".whl"
if original_wheel_name != final_wheel_name:
del info["Tag"], info["Build"]
for a, b, c in itertools.product(
final_python_tags, final_abi_tags, final_plat_tags
):
info["Tag"] = f"{a}-{b}-{c}"
if build:
info["Build"] = build
original_wheel_path = os.path.join(
os.path.dirname(f.filename), original_wheel_name
)
final_wheel_path = os.path.join(os.path.dirname(f.filename), final_wheel_name)
with (
WheelFile(original_wheel_path, "r") as fin,
WheelFile(final_wheel_path, "w") as fout,
):
fout.comment = fin.comment # preserve the comment
for item in fin.infolist():
if item.is_dir():
continue
if item.filename == f.dist_info_path + "/RECORD":
continue
if item.filename == f.dist_info_path + "/WHEEL":
fout.writestr(item, info.as_bytes())
else:
fout.writestr(item, fin.read(item))
if remove:
os.remove(original_wheel_path)
return final_wheel_name
from __future__ import annotations
from pathlib import Path
from ..wheelfile import WheelFile
def unpack(path: str, dest: str = ".") -> None:
"""Unpack a wheel.
Wheel content will be unpacked to {dest}/{name}-{ver}, where {name}
is the package name and {ver} its version.
:param path: The path to the wheel.
:param dest: Destination directory (default to current directory).
"""
with WheelFile(path) as wf:
namever = wf.parsed_filename.group("namever")
destination = Path(dest) / namever
print(f"Unpacking to: {destination}...", end="", flush=True)
for zinfo in wf.filelist:
target_path = Path(wf.extract(zinfo, destination))
# Set permissions to the same values as they were set in the archive
# We have to do this manually due to
# https://github.com/python/cpython/issues/59999
permissions = zinfo.external_attr >> 16 & 0o777
target_path.chmod(permissions)
print("OK")
"""
Tools for converting old- to new-style metadata.
"""
from __future__ import annotations
import functools
import itertools
import os.path
import re
import textwrap
from collections.abc import Generator, Iterable, Iterator
from email.message import Message
from email.parser import Parser
from typing import Literal
from packaging.requirements import Requirement
def _nonblank(str: str) -> bool | Literal[""]:
return str and not str.startswith("#")
@functools.singledispatch
def yield_lines(iterable: Iterable[str]) -> Iterator[str]:
r"""
Yield valid lines of a string or iterable.
>>> list(yield_lines(''))
[]
>>> list(yield_lines(['foo', 'bar']))
['foo', 'bar']
>>> list(yield_lines('foo\nbar'))
['foo', 'bar']
>>> list(yield_lines('\nfoo\n#bar\nbaz #comment'))
['foo', 'baz #comment']
>>> list(yield_lines(['foo\nbar', 'baz', 'bing\n\n\n']))
['foo', 'bar', 'baz', 'bing']
"""
return itertools.chain.from_iterable(map(yield_lines, iterable))
@yield_lines.register(str)
def _(text: str) -> Iterator[str]:
return filter(_nonblank, map(str.strip, text.splitlines()))
def split_sections(
s: str | Iterator[str],
) -> Generator[tuple[str | None, list[str]], None, None]:
"""Split a string or iterable thereof into (section, content) pairs
Each ``section`` is a stripped version of the section header ("[section]")
and each ``content`` is a list of stripped lines excluding blank lines and
comment-only lines. If there are any such lines before the first section
header, they're returned in a first ``section`` of ``None``.
"""
section = None
content: list[str] = []
for line in yield_lines(s):
if line.startswith("["):
if line.endswith("]"):
if section or content:
yield section, content
section = line[1:-1].strip()
content = []
else:
raise ValueError("Invalid section heading", line)
else:
content.append(line)
# wrap up last segment
yield section, content
def safe_extra(extra: str) -> str:
"""Convert an arbitrary string to a standard 'extra' name
Any runs of non-alphanumeric characters are replaced with a single '_',
and the result is always lowercased.
"""
return re.sub("[^A-Za-z0-9.-]+", "_", extra).lower()
def safe_name(name: str) -> str:
"""Convert an arbitrary string to a standard distribution name
Any runs of non-alphanumeric/. characters are replaced with a single '-'.
"""
return re.sub("[^A-Za-z0-9.]+", "-", name)
def requires_to_requires_dist(requirement: Requirement) -> str:
"""Return the version specifier for a requirement in PEP 345/566 fashion."""
if requirement.url:
return " @ " + requirement.url
requires_dist: list[str] = []
for spec in requirement.specifier:
requires_dist.append(spec.operator + spec.version)
if requires_dist:
return " " + ",".join(sorted(requires_dist))
else:
return ""
def convert_requirements(requirements: list[str]) -> Iterator[str]:
"""Yield Requires-Dist: strings for parsed requirements strings."""
for req in requirements:
parsed_requirement = Requirement(req)
spec = requires_to_requires_dist(parsed_requirement)
extras = ",".join(sorted(safe_extra(e) for e in parsed_requirement.extras))
if extras:
extras = f"[{extras}]"
yield safe_name(parsed_requirement.name) + extras + spec
def generate_requirements(
extras_require: dict[str | None, list[str]],
) -> Iterator[tuple[str, str]]:
"""
Convert requirements from a setup()-style dictionary to
('Requires-Dist', 'requirement') and ('Provides-Extra', 'extra') tuples.
extras_require is a dictionary of {extra: [requirements]} as passed to setup(),
using the empty extra {'': [requirements]} to hold install_requires.
"""
for extra, depends in extras_require.items():
condition = ""
extra = extra or ""
if ":" in extra: # setuptools extra:condition syntax
extra, condition = extra.split(":", 1)
extra = safe_extra(extra)
if extra:
yield "Provides-Extra", extra
if condition:
condition = "(" + condition + ") and "
condition += f"extra == '{extra}'"
if condition:
condition = " ; " + condition
for new_req in convert_requirements(depends):
canonical_req = str(Requirement(new_req + condition))
yield "Requires-Dist", canonical_req
def pkginfo_to_metadata(egg_info_path: str, pkginfo_path: str) -> Message:
"""
Convert .egg-info directory with PKG-INFO to the Metadata 2.1 format
"""
with open(pkginfo_path, encoding="utf-8") as headers:
pkg_info = Parser().parse(headers)
pkg_info.replace_header("Metadata-Version", "2.1")
# Those will be regenerated from `requires.txt`.
del pkg_info["Provides-Extra"]
del pkg_info["Requires-Dist"]
requires_path = os.path.join(egg_info_path, "requires.txt")
if os.path.exists(requires_path):
with open(requires_path, encoding="utf-8") as requires_file:
requires = requires_file.read()
parsed_requirements = sorted(split_sections(requires), key=lambda x: x[0] or "")
for extra, reqs in parsed_requirements:
for key, value in generate_requirements({extra: reqs}):
if (key, value) not in pkg_info.items():
pkg_info[key] = value
description = pkg_info["Description"]
if description:
description_lines = pkg_info["Description"].splitlines()
dedented_description = "\n".join(
# if the first line of long_description is blank,
# the first line here will be indented.
(
description_lines[0].lstrip(),
textwrap.dedent("\n".join(description_lines[1:])),
"\n",
)
)
pkg_info.set_payload(dedented_description)
del pkg_info["Description"]
return pkg_info
+1
-2
Metadata-Version: 2.4
Name: setuptools
Version: 80.10.1
Version: 80.10.2
Summary: Easily download, build, install, upgrade, and uninstall Python packages

@@ -37,3 +37,2 @@ Author-email: Python Packaging Authority <distutils-sig@python.org>

Requires-Dist: pytest-perf; sys_platform != "cygwin" and extra == "test"
Requires-Dist: pyobjc<12; (sys_platform == "darwin" and python_version <= "3.9") and extra == "test"
Requires-Dist: jaraco.develop>=7.21; (python_version >= "3.9" and sys_platform != "cygwin") and extra == "test"

@@ -40,0 +39,0 @@ Requires-Dist: pytest-home>=0.5; extra == "test"

@@ -13,3 +13,3 @@ [build-system]

name = "setuptools"
version = "80.10.1"
version = "80.10.2"
authors = [

@@ -60,3 +60,2 @@ { name = "Python Packaging Authority", email = "distutils-sig@python.org" },

'pytest-perf; sys_platform != "cygwin"', # workaround for jaraco/inflect#195, pydantic/pydantic-core#773 (see #3986)
'pyobjc<12; sys_platform == "darwin" and python_version <= "3.9"', # workaround for #5105
# for tools/finalize.py

@@ -63,0 +62,0 @@ 'jaraco.develop >= 7.21; python_version >= "3.9" and sys_platform != "cygwin"',

@@ -102,1 +102,4 @@ [pytest]

ignore:Couldn't import C tracer:coverage.exceptions.CoverageWarning
# https://github.com/pypa/setuptools/issues/5167
ignore:The 'wheel.metadata' package has been made private:DeprecationWarning
Metadata-Version: 2.4
Name: setuptools
Version: 80.10.1
Version: 80.10.2
Summary: Easily download, build, install, upgrade, and uninstall Python packages

@@ -37,3 +37,2 @@ Author-email: Python Packaging Authority <distutils-sig@python.org>

Requires-Dist: pytest-perf; sys_platform != "cygwin" and extra == "test"
Requires-Dist: pyobjc<12; (sys_platform == "darwin" and python_version <= "3.9") and extra == "test"
Requires-Dist: jaraco.develop>=7.21; (python_version >= "3.9" and sys_platform != "cygwin") and extra == "test"

@@ -40,0 +39,0 @@ Requires-Dist: pytest-home>=0.5; extra == "test"

@@ -75,5 +75,2 @@

[test:sys_platform == "darwin" and python_version <= "3.9"]
pyobjc<12
[type]

@@ -80,0 +77,0 @@ pytest-mypy

@@ -262,3 +262,2 @@ LICENSE

setuptools/_vendor/backports/tarfile/compat/py38.py
setuptools/_vendor/bin/wheel
setuptools/_vendor/importlib_metadata/__init__.py

@@ -341,2 +340,3 @@ setuptools/_vendor/importlib_metadata/_adapters.py

setuptools/_vendor/packaging/py.typed
setuptools/_vendor/packaging/pylock.py
setuptools/_vendor/packaging/requirements.py

@@ -347,10 +347,10 @@ setuptools/_vendor/packaging/specifiers.py

setuptools/_vendor/packaging/version.py
setuptools/_vendor/packaging-25.0.dist-info/INSTALLER
setuptools/_vendor/packaging-25.0.dist-info/METADATA
setuptools/_vendor/packaging-25.0.dist-info/RECORD
setuptools/_vendor/packaging-25.0.dist-info/REQUESTED
setuptools/_vendor/packaging-25.0.dist-info/WHEEL
setuptools/_vendor/packaging-25.0.dist-info/licenses/LICENSE
setuptools/_vendor/packaging-25.0.dist-info/licenses/LICENSE.APACHE
setuptools/_vendor/packaging-25.0.dist-info/licenses/LICENSE.BSD
setuptools/_vendor/packaging-26.0.dist-info/INSTALLER
setuptools/_vendor/packaging-26.0.dist-info/METADATA
setuptools/_vendor/packaging-26.0.dist-info/RECORD
setuptools/_vendor/packaging-26.0.dist-info/REQUESTED
setuptools/_vendor/packaging-26.0.dist-info/WHEEL
setuptools/_vendor/packaging-26.0.dist-info/licenses/LICENSE
setuptools/_vendor/packaging-26.0.dist-info/licenses/LICENSE.APACHE
setuptools/_vendor/packaging-26.0.dist-info/licenses/LICENSE.BSD
setuptools/_vendor/packaging/licenses/__init__.py

@@ -387,2 +387,3 @@ setuptools/_vendor/packaging/licenses/_spdx.py

setuptools/_vendor/wheel/_bdist_wheel.py
setuptools/_vendor/wheel/_metadata.py
setuptools/_vendor/wheel/_setuptools_logging.py

@@ -392,34 +393,15 @@ setuptools/_vendor/wheel/bdist_wheel.py

setuptools/_vendor/wheel/metadata.py
setuptools/_vendor/wheel/util.py
setuptools/_vendor/wheel/wheelfile.py
setuptools/_vendor/wheel-0.45.1.dist-info/INSTALLER
setuptools/_vendor/wheel-0.45.1.dist-info/LICENSE.txt
setuptools/_vendor/wheel-0.45.1.dist-info/METADATA
setuptools/_vendor/wheel-0.45.1.dist-info/RECORD
setuptools/_vendor/wheel-0.45.1.dist-info/REQUESTED
setuptools/_vendor/wheel-0.45.1.dist-info/WHEEL
setuptools/_vendor/wheel-0.45.1.dist-info/entry_points.txt
setuptools/_vendor/wheel/cli/__init__.py
setuptools/_vendor/wheel/cli/convert.py
setuptools/_vendor/wheel/cli/pack.py
setuptools/_vendor/wheel/cli/tags.py
setuptools/_vendor/wheel/cli/unpack.py
setuptools/_vendor/wheel/vendored/__init__.py
setuptools/_vendor/wheel/vendored/vendor.txt
setuptools/_vendor/wheel/vendored/packaging/LICENSE
setuptools/_vendor/wheel/vendored/packaging/LICENSE.APACHE
setuptools/_vendor/wheel/vendored/packaging/LICENSE.BSD
setuptools/_vendor/wheel/vendored/packaging/__init__.py
setuptools/_vendor/wheel/vendored/packaging/_elffile.py
setuptools/_vendor/wheel/vendored/packaging/_manylinux.py
setuptools/_vendor/wheel/vendored/packaging/_musllinux.py
setuptools/_vendor/wheel/vendored/packaging/_parser.py
setuptools/_vendor/wheel/vendored/packaging/_structures.py
setuptools/_vendor/wheel/vendored/packaging/_tokenizer.py
setuptools/_vendor/wheel/vendored/packaging/markers.py
setuptools/_vendor/wheel/vendored/packaging/requirements.py
setuptools/_vendor/wheel/vendored/packaging/specifiers.py
setuptools/_vendor/wheel/vendored/packaging/tags.py
setuptools/_vendor/wheel/vendored/packaging/utils.py
setuptools/_vendor/wheel/vendored/packaging/version.py
setuptools/_vendor/wheel-0.46.3.dist-info/INSTALLER
setuptools/_vendor/wheel-0.46.3.dist-info/METADATA
setuptools/_vendor/wheel-0.46.3.dist-info/RECORD
setuptools/_vendor/wheel-0.46.3.dist-info/REQUESTED
setuptools/_vendor/wheel-0.46.3.dist-info/WHEEL
setuptools/_vendor/wheel-0.46.3.dist-info/entry_points.txt
setuptools/_vendor/wheel-0.46.3.dist-info/licenses/LICENSE.txt
setuptools/_vendor/wheel/_commands/__init__.py
setuptools/_vendor/wheel/_commands/convert.py
setuptools/_vendor/wheel/_commands/pack.py
setuptools/_vendor/wheel/_commands/tags.py
setuptools/_vendor/wheel/_commands/unpack.py
setuptools/_vendor/zipp/__init__.py

@@ -426,0 +408,0 @@ setuptools/_vendor/zipp/_functools.py

@@ -9,3 +9,3 @@ # This file is dual licensed under the terms of the Apache License, Version

__version__ = "25.0"
__version__ = "26.0"

@@ -12,0 +12,0 @@ __author__ = "Donald Stufft and individual contributors"

@@ -7,3 +7,2 @@ """

Based on: https://gist.github.com/lyssdod/f51579ae8d93c8657a5564aefc2ffbca
ELF header: https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html

@@ -10,0 +9,0 @@ """

@@ -18,3 +18,13 @@ from __future__ import annotations

_ALLOWED_ARCHS = {
"x86_64",
"aarch64",
"ppc64",
"ppc64le",
"s390x",
"loongarch64",
"riscv64",
}
# `os.PathLike` not a generic type until Python 3.9, so sticking with `str`

@@ -61,12 +71,3 @@ # as the type for `path` until then.

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)

@@ -111,3 +112,3 @@

try:
import ctypes
import ctypes # noqa: PLC0415
except ImportError:

@@ -156,3 +157,3 @@ return None

def _parse_glibc_version(version_str: str) -> tuple[int, int]:
def _parse_glibc_version(version_str: str) -> _GLibCVersion:
"""Parse glibc version.

@@ -172,11 +173,11 @@

)
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)

@@ -192,3 +193,3 @@

try:
import _manylinux
import _manylinux # noqa: PLC0415
except ImportError:

@@ -201,21 +202,22 @@ return True

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",
}

@@ -262,9 +264,7 @@

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}"

@@ -52,3 +52,3 @@ """PEP 656 support.

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)

@@ -55,0 +55,0 @@

@@ -10,3 +10,3 @@ """Handwritten parser of dependency specifiers.

import ast
from typing import NamedTuple, Sequence, Tuple, Union
from typing import List, Literal, NamedTuple, Sequence, Tuple, Union

@@ -17,2 +17,4 @@ from ._tokenizer import DEFAULT_RULES, Tokenizer

class Node:
__slots__ = ("value",)
def __init__(self, value: str) -> None:

@@ -25,3 +27,3 @@ self.value = value

def __repr__(self) -> str:
return f"<{self.__class__.__name__}('{self}')>"
return f"<{self.__class__.__name__}({self.value!r})>"

@@ -33,2 +35,4 @@ def serialize(self) -> str:

class Variable(Node):
__slots__ = ()
def serialize(self) -> str:

@@ -39,2 +43,4 @@ return str(self)

class Value(Node):
__slots__ = ()
def serialize(self) -> str:

@@ -45,2 +51,4 @@ return f'"{self}"'

class Op(Node):
__slots__ = ()
def serialize(self) -> str:

@@ -50,6 +58,7 @@ 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]]

@@ -121,3 +130,5 @@

marker = _parse_requirement_marker(
tokenizer, span_start=url_start, after="URL and whitespace"
tokenizer,
span_start=url_start,
expected="semicolon (after URL and whitespace)",
)

@@ -135,6 +146,6 @@ else:

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)"
),

@@ -147,3 +158,3 @@ )

def _parse_requirement_marker(
tokenizer: Tokenizer, *, span_start: int, after: str
tokenizer: Tokenizer, *, span_start: int, expected: str
) -> MarkerList:

@@ -156,4 +167,5 @@ """

tokenizer.raise_syntax_error(
f"Expected end or semicolon (after {after})",
f"Expected {expected} or end",
span_start=span_start,
span_end=None,
)

@@ -321,3 +333,3 @@ tokenizer.read()

def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar:
def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar: # noqa: RET503
"""

@@ -324,0 +336,0 @@ marker_var = VARIABLE | QUOTED_STRING

@@ -5,4 +5,9 @@ # This file is dual licensed under the terms of the Apache License, Version

import typing
@typing.final
class InfinityType:
__slots__ = ()
def __repr__(self) -> str:

@@ -36,3 +41,6 @@ return "Infinity"

@typing.final
class NegativeInfinityType:
__slots__ = ()
def __repr__(self) -> str:

@@ -39,0 +47,0 @@ return "-Infinity"

@@ -6,3 +6,3 @@ from __future__ import annotations

from dataclasses import dataclass
from typing import Iterator, NoReturn
from typing import Generator, Mapping, NoReturn

@@ -37,12 +37,12 @@ from .specifiers import Specifier

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(

@@ -58,6 +58,6 @@ r"""

),
"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(

@@ -84,9 +84,9 @@ r"""

),
"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"$"),
}

@@ -106,8 +106,6 @@

*,
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

@@ -182,3 +180,3 @@ self.position = 0

self, open_token: str, close_token: str, *, around: str
) -> Iterator[None]:
) -> Generator[None, None, None]:
if self.check(open_token):

@@ -185,0 +183,0 @@ open_position = self.position

@@ -37,3 +37,3 @@ #######################################################################################

from packaging.licenses._spdx import EXCEPTIONS, LICENSES
from ._spdx import EXCEPTIONS, LICENSES

@@ -84,5 +84,6 @@ __all__ = [

# 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 = []

@@ -94,3 +95,7 @@ for token in tokens:

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}"

@@ -103,7 +108,4 @@ raise InvalidLicenseExpression(message)

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}"

@@ -147,4 +149,4 @@ raise InvalidLicenseExpression(message) from None

return cast(
NormalizedLicenseExpression,
"NormalizedLicenseExpression",
normalized_expression.replace("( ", "(").replace(" )", ")"),
)

@@ -15,3 +15,3 @@

VERSION = '3.25.0'
VERSION = '3.27.0'

@@ -50,2 +50,3 @@ LICENSES: dict[str, SPDXLicense] = {

'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},

@@ -66,2 +67,4 @@ 'apache-1.1': {'id': 'Apache-1.1', '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},

@@ -81,2 +84,3 @@ 'aswf-digital-assets-1.1': {'id': 'ASWF-Digital-Assets-1.1', '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},

@@ -92,2 +96,3 @@ 'brian-gladman-2-clause': {'id': 'Brian-Gladman-2-Clause', 'deprecated': False},

'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},

@@ -184,2 +189,4 @@ 'bsd-3-clause': {'id': 'BSD-3-Clause', '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},

@@ -224,2 +231,3 @@ 'cddl-1.0': {'id': 'CDDL-1.0', 'deprecated': False},

'crossword': {'id': 'Crossword', 'deprecated': False},
'cryptoswift': {'id': 'CryptoSwift', 'deprecated': False},
'crystalstacker': {'id': 'CrystalStacker', 'deprecated': False},

@@ -236,3 +244,5 @@ 'cua-opl-1.0': {'id': 'CUA-OPL-1.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},

@@ -274,8 +284,13 @@ 'dotseqn': {'id': 'Dotseqn', '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},

@@ -332,2 +347,3 @@ 'gfdl-1.1-invariants-only': {'id': 'GFDL-1.1-invariants-only', 'deprecated': False},

'haskellreport': {'id': 'HaskellReport', 'deprecated': False},
'hdf5': {'id': 'HDF5', 'deprecated': False},
'hdparm': {'id': 'hdparm', 'deprecated': False},

@@ -373,2 +389,3 @@ 'hidapi': {'id': 'HIDAPI', 'deprecated': False},

'inner-net-2.0': {'id': 'Inner-Net-2.0', 'deprecated': False},
'innosetup': {'id': 'InnoSetup', 'deprecated': False},
'intel': {'id': 'Intel', 'deprecated': False},

@@ -383,2 +400,3 @@ 'intel-acpi': {'id': 'Intel-ACPI', 'deprecated': False},

'jasper-2.0': {'id': 'JasPer-2.0', 'deprecated': False},
'jove': {'id': 'jove', 'deprecated': False},
'jpl-image': {'id': 'JPL-image', 'deprecated': False},

@@ -409,2 +427,3 @@ 'jpnic': {'id': 'JPNIC', '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},

@@ -440,2 +459,3 @@ 'libselinux-1.0': {'id': 'libselinux-1.0', 'deprecated': False},

'makeindex': {'id': 'MakeIndex', 'deprecated': False},
'man2html': {'id': 'man2html', 'deprecated': False},
'martin-birgmeier': {'id': 'Martin-Birgmeier', 'deprecated': False},

@@ -445,2 +465,3 @@ 'mcphee-slideshow': {'id': 'McPhee-slideshow', 'deprecated': False},

'minpack': {'id': 'Minpack', 'deprecated': False},
'mips': {'id': 'MIPS', 'deprecated': False},
'miros': {'id': 'MirOS', 'deprecated': False},

@@ -450,2 +471,3 @@ 'mit': {'id': 'MIT', 'deprecated': False},

'mit-advertising': {'id': 'MIT-advertising', 'deprecated': False},
'mit-click': {'id': 'MIT-Click', 'deprecated': False},
'mit-cmu': {'id': 'MIT-CMU', 'deprecated': False},

@@ -491,2 +513,3 @@ 'mit-enna': {'id': 'MIT-enna', 'deprecated': False},

'ngpl': {'id': 'NGPL', 'deprecated': False},
'ngrep': {'id': 'ngrep', 'deprecated': False},
'nicta-1.0': {'id': 'NICTA-1.0', 'deprecated': False},

@@ -506,2 +529,3 @@ 'nist-pd': {'id': 'NIST-PD', 'deprecated': False},

'nrl': {'id': 'NRL', 'deprecated': False},
'ntia-pd': {'id': 'NTIA-PD', 'deprecated': False},
'ntp': {'id': 'NTP', 'deprecated': False},

@@ -601,2 +625,3 @@ 'ntp-0': {'id': 'NTP-0', '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},

@@ -614,2 +639,3 @@ 'sgi-b-1.1': {'id': 'SGI-B-1.1', 'deprecated': False},

'sleepycat': {'id': 'Sleepycat', 'deprecated': False},
'smail-gpl': {'id': 'SMAIL-GPL', 'deprecated': False},
'smlnj': {'id': 'SMLNJ', 'deprecated': False},

@@ -619,2 +645,3 @@ 'smppl': {'id': 'SMPPL', 'deprecated': False},

'snprintf': {'id': 'snprintf', 'deprecated': False},
'sofa': {'id': 'SOFA', 'deprecated': False},
'softsurfer': {'id': 'softSurfer', 'deprecated': False},

@@ -633,2 +660,3 @@ 'soundex': {'id': 'Soundex', 'deprecated': False},

'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},

@@ -645,2 +673,3 @@ 'sun-ppp-2000': {'id': 'Sun-PPP-2000', 'deprecated': False},

'tgppl-1.0': {'id': 'TGPPL-1.0', 'deprecated': False},
'thirdeye': {'id': 'ThirdEye', 'deprecated': False},
'threeparttable': {'id': 'threeparttable', 'deprecated': False},

@@ -652,2 +681,3 @@ 'tmate': {'id': 'TMate', 'deprecated': False},

'tpl-1.0': {'id': 'TPL-1.0', 'deprecated': False},
'trustedqsl': {'id': 'TrustedQSL', 'deprecated': False},
'ttwl': {'id': 'TTWL', 'deprecated': False},

@@ -668,2 +698,4 @@ 'ttyp0': {'id': 'TTYP0', '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},

@@ -682,2 +714,3 @@ 'urt-rle': {'id': 'URT-RLE', 'deprecated': False},

'wtfpl': {'id': 'WTFPL', 'deprecated': False},
'wwl': {'id': 'wwl', 'deprecated': False},
'wxwindows': {'id': 'wxWindows', 'deprecated': True},

@@ -724,5 +757,7 @@ 'x11': {'id': 'X11', '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},

@@ -744,2 +779,3 @@ 'ecos-exception-2.0': {'id': 'eCos-exception-2.0', '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},

@@ -751,3 +787,5 @@ 'gpl-3.0-linking-exception': {'id': 'GPL-3.0-linking-exception', '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},

@@ -762,2 +800,3 @@ 'lgpl-3.0-linking-exception': {'id': 'LGPL-3.0-linking-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},

@@ -769,2 +808,3 @@ 'ocaml-lgpl-linking-exception': {'id': 'OCaml-LGPL-linking-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},

@@ -771,0 +811,0 @@ 'qpl-1.0-inria-2004-exception': {'id': 'QPL-1.0-INRIA-2004-exception', 'deprecated': False},

@@ -11,3 +11,3 @@ # This file is dual licensed under the terms of the Apache License, Version

import sys
from typing import AbstractSet, Any, Callable, Literal, TypedDict, Union, cast
from typing import AbstractSet, Callable, Literal, Mapping, TypedDict, Union, cast

@@ -21,2 +21,3 @@ from ._parser import MarkerAtom, MarkerList, Op, Value, Variable

__all__ = [
"Environment",
"EvaluateContext",

@@ -33,2 +34,8 @@ "InvalidMarker",

MARKERS_ALLOWING_SET = {"extras", "dependency_groups"}
MARKERS_REQUIRING_VERSION = {
"implementation_version",
"platform_release",
"python_full_version",
"python_version",
}

@@ -127,18 +134,26 @@

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(

@@ -175,15 +190,16 @@ marker: list[str] | MarkerAtom | str, first: bool | None = True

"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:

@@ -194,3 +210,3 @@ pass

oper: Operator | None = _operators.get(op.serialize())
oper: Operator | None = _operators.get(op_str)
if oper is None:

@@ -205,3 +221,3 @@ raise UndefinedComparison(f"Undefined {op!r} on {lhs!r} and {rhs!r}.")

) -> 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/

@@ -212,3 +228,4 @@ # > When comparing extra names, tools MUST normalize the names being

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:

@@ -230,4 +247,2 @@ if isinstance(rhs, str): # pragma: no cover

for marker in markers:
assert isinstance(marker, (list, tuple, str))
if isinstance(marker, list):

@@ -246,9 +261,12 @@ groups[-1].append(_evaluate_markers(marker, environment))

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}")

@@ -289,2 +307,7 @@ return any(all(item) for item in groups)

# 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:

@@ -315,8 +338,8 @@ self._markers = _normalize_extra_values(_parse_marker(marker))

def __repr__(self) -> str:
return f"<Marker('{self}')>"
return f"<{self.__class__.__name__}('{self}')>"
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):

@@ -329,3 +352,3 @@ return NotImplemented

self,
environment: dict[str, str] | None = None,
environment: Mapping[str, str | AbstractSet[str]] | None = None,
context: EvaluateContext = "metadata",

@@ -353,8 +376,11 @@ ) -> bool:

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 ""

@@ -373,5 +399,5 @@ return _evaluate_markers(

"""
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

@@ -8,2 +8,3 @@ from __future__ import annotations

import email.policy
import keyword
import pathlib

@@ -23,4 +24,6 @@ import sys

from . import version as version_module
from .licenses import NormalizedLicenseExpression
if typing.TYPE_CHECKING:
from .licenses import NormalizedLicenseExpression
T = typing.TypeVar("T")

@@ -30,3 +33,3 @@

if sys.version_info >= (3, 11): # pragma: no cover
ExceptionGroup = ExceptionGroup
ExceptionGroup = ExceptionGroup # noqa: F821
else: # pragma: no cover

@@ -132,3 +135,3 @@

# 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.

@@ -139,3 +142,9 @@ # Metadata 2.4 - PEP 639

# 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 = {

@@ -173,2 +182,4 @@ "author",

"supported_platforms",
"import_names",
"import_namespaces",
}

@@ -202,10 +213,8 @@

#
# 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

@@ -216,7 +225,8 @@ # considered case sensitive or not... logically they should

# 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")

@@ -268,2 +278,4 @@ urls[label] = url

"home-page": "home_page",
"import-name": "import_names",
"import-namespace": "import_namespaces",
"keywords": "keywords",

@@ -295,2 +307,41 @@ "license": "license",

# 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]]]:

@@ -323,6 +374,6 @@ """Parse a distribution's metadata stored as email headers (e.g. from ``METADATA``).

# 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()

@@ -363,5 +414,5 @@ # We use get_all() here, even for fields that aren't multiple use,

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:

@@ -373,3 +424,3 @@ # Enable mojibake.

encoding = "utf8"
chunks.append((bin, encoding))
chunks.append((binary, encoding))

@@ -413,2 +464,7 @@ # Turn our chunks back into a Header object, then let that

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

@@ -441,3 +497,3 @@ # the value, since email *only* has strings, and our get_all() call

# 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:

@@ -459,5 +515,5 @@ unparsed[name] = value

# 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(

@@ -475,3 +531,3 @@ [description_header, payload]

# names.
return cast(RawMetadata, raw), unparsed
return cast("RawMetadata", raw), unparsed

@@ -483,4 +539,4 @@

# 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"]

@@ -540,3 +596,3 @@ _REQUIRED_ATTRS = frozenset(["metadata_version", "name", "version"])

return cast(T, value)
return cast("T", value)

@@ -556,3 +612,3 @@ def _invalid_metadata(

raise self._invalid_metadata(f"{value!r} is not a valid metadata version")
return cast(_MetadataVersion, value)
return cast("_MetadataVersion", value)

@@ -670,5 +726,3 @@ def _process_name(self, value: str) -> str:

def _process_license_expression(
self, value: str
) -> NormalizedLicenseExpression | None:
def _process_license_expression(self, value: str) -> NormalizedLicenseExpression:
try:

@@ -707,3 +761,27 @@ return licenses.canonicalize_license_expression(value)

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:

@@ -879,2 +957,6 @@ """Representation of distribution metadata.

""":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")

@@ -886,1 +968,35 @@ """``Requires`` (deprecated)"""

"""``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)

@@ -6,3 +6,3 @@ # This file is dual licensed under the terms of the Apache License, Version

from typing import Any, Iterator
from typing import Iterator

@@ -61,3 +61,3 @@ from ._parser import parse_requirement as _parse_requirement

if self.url:
yield f"@ {self.url}"
yield f" @ {self.url}"
if self.marker:

@@ -73,13 +73,8 @@ yield " "

def __repr__(self) -> str:
return f"<Requirement('{self}')>"
return f"<{self.__class__.__name__}('{self}')>"
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):

@@ -86,0 +81,0 @@ return NotImplemented

@@ -16,6 +16,6 @@ # This file is dual licensed under the terms of the Apache License, Version

import re
from typing import Callable, Iterable, Iterator, TypeVar, Union
from typing import Callable, Final, Iterable, Iterator, TypeVar, Union
from .utils import canonicalize_version
from .version import Version
from .version import InvalidVersion, Version

@@ -27,8 +27,19 @@ UnparsedVersion = Union[Version, str]

def _coerce_version(version: UnparsedVersion) -> Version:
def _coerce_version(version: UnparsedVersion) -> Version | None:
if not isinstance(version, Version):
version = Version(version)
try:
version = Version(version)
except InvalidVersion:
return None
return version
def _public_version(version: Version) -> Version:
return version.__replace__(local=None)
def _base_version(version: Version) -> Version:
return version.__replace__(pre=None, post=None, dev=None, local=None)
class InvalidSpecifier(ValueError):

@@ -47,2 +58,10 @@ """

class BaseSpecifier(metaclass=abc.ABCMeta):
__slots__ = ()
__match_args__ = ("_str",)
@property
def _str(self) -> str:
"""Internal property for match_args"""
return str(self)
@abc.abstractmethod

@@ -79,3 +98,3 @@ def __str__(self) -> str:

@prereleases.setter
@prereleases.setter # noqa: B027
def prereleases(self, value: bool) -> None:

@@ -113,2 +132,4 @@ """Setter for :attr:`prereleases`.

__slots__ = ("_prereleases", "_spec", "_spec_version")
_operator_regex_str = r"""

@@ -212,7 +233,7 @@ (?P<operator>(~=|==|!=|<=|>=|<|>|===))

_regex = re.compile(
r"^\s*" + _operator_regex_str + _version_regex_str + r"\s*$",
r"\s*" + _operator_regex_str + _version_regex_str + r"\s*",
re.VERBOSE | re.IGNORECASE,
)
_operators = {
_operators: Final = {
"~=": "compatible",

@@ -241,3 +262,3 @@ "==": "equal",

"""
match = self._regex.search(spec)
match = self._regex.fullmatch(spec)
if not match:

@@ -254,5 +275,29 @@ raise InvalidSpecifier(f"Invalid specifier: {spec!r}")

# https://github.com/python/mypy/pull/13475#pullrequestreview-1079784515
@property # type: ignore[override]
def prereleases(self) -> bool:
# Specifier version cache
self._spec_version: tuple[str, Version] | None = None
def _get_spec_version(self, version: str) -> Version | None:
"""One element cache, as only one spec Version is needed per Specifier."""
if self._spec_version is not None and self._spec_version[0] == version:
return self._spec_version[1]
version_specifier = _coerce_version(version)
if version_specifier is None:
return None
self._spec_version = (version, version_specifier)
return version_specifier
def _require_spec_version(self, version: str) -> Version:
"""Get spec version, asserting it's valid (not for === operator).
This method should only be called for operators where version
strings are guaranteed to be valid PEP 440 versions (not ===).
"""
spec_version = self._get_spec_version(version)
assert spec_version is not None
return spec_version
@property
def prereleases(self) -> bool | None:
# If there is an explicit prereleases set for this, then we'll just

@@ -263,15 +308,20 @@ # blindly use that.

# Look at all of our specifiers and determine if they are inclusive
# operators, and if they are if they are including an explicit
# prerelease.
operator, version = self._spec
if operator in ["==", ">=", "<=", "~=", "===", ">", "<"]:
# The == specifier can include a trailing .*, if it does we
# want to remove before parsing.
if operator == "==" and version.endswith(".*"):
version = version[:-2]
# Only the "!=" operator does not imply prereleases when
# the version in the specifier is a prerelease.
operator, version_str = self._spec
if operator != "!=":
# The == specifier with trailing .* cannot include prereleases
# e.g. "==1.0a1.*" is not valid.
if operator == "==" and version_str.endswith(".*"):
return False
# Parse the version, and if it is a pre-release than this
# specifier allows pre-releases.
if Version(version).is_prerelease:
# "===" can have arbitrary string versions, so we cannot parse
# those, we take prereleases as unknown (None) for those.
version = self._get_spec_version(version_str)
if version is None:
return None
# For all other operators, use the check if spec Version
# object implies pre-releases.
if version.is_prerelease:
return True

@@ -282,3 +332,3 @@

@prereleases.setter
def prereleases(self, value: bool) -> None:
def prereleases(self, value: bool | None) -> None:
self._prereleases = value

@@ -334,8 +384,14 @@

def _canonical_spec(self) -> tuple[str, str]:
operator, version = self._spec
if operator == "===" or version.endswith(".*"):
return operator, version
spec_version = self._require_spec_version(version)
canonical_version = canonicalize_version(
self._spec[1],
strip_trailing_zero=(self._spec[0] != "~="),
spec_version, strip_trailing_zero=(operator != "~=")
)
return self._spec[0], canonical_version
return operator, canonical_version
def __hash__(self) -> int:

@@ -404,3 +460,3 @@ return hash(self._canonical_spec)

normalized_prospective = canonicalize_version(
prospective.public, strip_trailing_zero=False
_public_version(prospective), strip_trailing_zero=False
)

@@ -430,3 +486,3 @@ # Get the normalized version string ignoring the trailing .*

# Convert our spec string into a Version
spec_version = Version(spec)
spec_version = self._require_spec_version(spec)

@@ -437,3 +493,3 @@ # If the specifier does not have a local segment, then we want to

if not spec_version.local:
prospective = Version(prospective.public)
prospective = _public_version(prospective)

@@ -449,3 +505,3 @@ return prospective == spec_version

# the prospective version.
return Version(prospective.public) <= Version(spec)
return _public_version(prospective) <= self._require_spec_version(spec)

@@ -456,3 +512,3 @@ def _compare_greater_than_equal(self, prospective: Version, spec: str) -> bool:

# the prospective version.
return Version(prospective.public) >= Version(spec)
return _public_version(prospective) >= self._require_spec_version(spec)

@@ -462,3 +518,3 @@ def _compare_less_than(self, prospective: Version, spec_str: str) -> bool:

# it as a version.
spec = Version(spec_str)
spec = self._require_spec_version(spec_str)

@@ -475,5 +531,8 @@ # Check to see if the prospective version is less than the spec

# not match 3.1.dev0, but should match 3.0.dev0).
if not spec.is_prerelease and prospective.is_prerelease:
if Version(prospective.base_version) == Version(spec.base_version):
return False
if (
not spec.is_prerelease
and prospective.is_prerelease
and _base_version(prospective) == _base_version(spec)
):
return False

@@ -488,3 +547,3 @@ # If we've gotten to here, it means that prospective version is both

# it as a version.
spec = Version(spec_str)
spec = self._require_spec_version(spec_str)

@@ -501,11 +560,15 @@ # Check to see if the prospective version is greater than the spec

# (e.g. >3.1 should not match 3.0.post0, but should match 3.2.post0).
if not spec.is_postrelease and prospective.is_postrelease:
if Version(prospective.base_version) == Version(spec.base_version):
return False
if (
not spec.is_postrelease
and prospective.is_postrelease
and _base_version(prospective) == _base_version(spec)
):
return False
# Ensure that we do not allow a local version of the version mentioned
# in the specifier, which is technically greater than, to match.
if prospective.local is not None:
if Version(prospective.base_version) == Version(spec.base_version):
return False
if prospective.local is not None and _base_version(
prospective
) == _base_version(spec):
return False

@@ -517,3 +580,3 @@ # If we've gotten to here, it means that prospective version is both

def _compare_arbitrary(self, prospective: Version, spec: str) -> bool:
def _compare_arbitrary(self, prospective: Version | str, spec: str) -> bool:
return str(prospective).lower() == str(spec).lower()

@@ -536,3 +599,3 @@

>>> "1.3.0a1" in Specifier(">=1.2.3")
False
True
>>> "1.3.0a1" in Specifier(">=1.2.3", prereleases=True)

@@ -551,4 +614,4 @@ True

Whether or not to match prereleases with this Specifier. If set to
``None`` (the default), it uses :attr:`prereleases` to determine
whether or not prereleases are allowed.
``None`` (the default), it will follow the recommendation from
:pep:`440` and match prereleases, as there are no other versions.

@@ -562,28 +625,11 @@ >>> Specifier(">=1.2.3").contains("1.2.3")

>>> Specifier(">=1.2.3").contains("1.3.0a1")
True
>>> Specifier(">=1.2.3", prereleases=False).contains("1.3.0a1")
False
>>> Specifier(">=1.2.3", prereleases=True).contains("1.3.0a1")
>>> Specifier(">=1.2.3").contains("1.3.0a1")
True
>>> Specifier(">=1.2.3").contains("1.3.0a1", prereleases=True)
True
"""
# Determine if prereleases are to be allowed or not.
if prereleases is None:
prereleases = self.prereleases
return bool(list(self.filter([item], prereleases=prereleases)))
# Normalize item to a Version, this allows us to have a shortcut for
# "2.0" in Specifier(">=2")
normalized_item = _coerce_version(item)
# Determine if we should be supporting prereleases in this specifier
# or not, if we do not support prereleases than we can short circuit
# logic if this version is a prereleases.
if normalized_item.is_prerelease and not prereleases:
return False
# Actually do the comparison to determine if this item is contained
# within this Specifier or not.
operator_callable: CallableOperator = self._get_operator(self.operator)
return operator_callable(normalized_item, self.version)
def filter(

@@ -599,10 +645,5 @@ self, iterable: Iterable[UnparsedVersionVar], prereleases: bool | None = None

Whether or not to allow prereleases in the returned iterator. If set to
``None`` (the default), it will be intelligently decide whether to allow
prereleases or not (based on the :attr:`prereleases` attribute, and
whether the only versions matching are prereleases).
``None`` (the default), it will follow the recommendation from :pep:`440`
and match prereleases if there are no other versions.
This method is smarter than just ``filter(Specifier().contains, [...])``
because it implements the rule from :pep:`440` that a prerelease item
SHOULD be accepted if no other versions match the given specifier.
>>> list(Specifier(">=1.2.3").filter(["1.2", "1.3", "1.5a1"]))

@@ -619,36 +660,42 @@ ['1.3']

"""
prereleases_versions = []
found_non_prereleases = False
yielded = False
found_prereleases = []
# Determine if to include prereleases by default
include_prereleases = (
prereleases if prereleases is not None else self.prereleases
)
kw = {"prereleases": prereleases if prereleases is not None else True}
# Get the matching operator
operator_callable = self._get_operator(self.operator)
# Attempt to iterate over all the values in the iterable and if any of
# them match, yield them.
# Filter versions
for version in iterable:
parsed_version = _coerce_version(version)
if self.contains(parsed_version, **kw):
# If our version is a prerelease, and we were not set to allow
# prereleases, then we'll store it for later in case nothing
# else matches this specifier.
if parsed_version.is_prerelease and not (
prereleases or self.prereleases
if parsed_version is None:
# === operator can match arbitrary (non-version) strings
if self.operator == "===" and self._compare_arbitrary(
version, self.version
):
found_prereleases.append(version)
# Either this is not a prerelease, or we should have been
# accepting prereleases from the beginning.
else:
yielded = True
yield version
elif operator_callable(parsed_version, self.version):
# If it's not a prerelease or prereleases are allowed, yield it directly
if not parsed_version.is_prerelease or include_prereleases:
found_non_prereleases = True
yield version
# Otherwise collect prereleases for potential later use
elif prereleases is None and self._prereleases is not False:
prereleases_versions.append(version)
# Now that we've iterated over everything, determine if we've yielded
# any values, and if we have not and we have any prereleases stored up
# then we will go ahead and yield the prereleases.
if not yielded and found_prereleases:
for version in found_prereleases:
yield version
# If no non-prereleases were found and prereleases weren't
# explicitly forbidden, yield the collected prereleases
if (
not found_non_prereleases
and prereleases is None
and self._prereleases is not False
):
yield from prereleases_versions
_prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$")
_prefix_regex = re.compile(r"([0-9]+)((?:a|b|c|rc)[0-9]+)")

@@ -670,3 +717,3 @@

for item in rest.split("."):
match = _prefix_regex.search(item)
match = _prefix_regex.fullmatch(item)
if match:

@@ -724,2 +771,4 @@ result.extend(match.groups())

__slots__ = ("_prereleases", "_specs")
def __init__(

@@ -778,6 +827,9 @@ self,

# prereleases, if any of them do we'll return True, otherwise False.
return any(s.prereleases for s in self._specs)
if any(s.prereleases for s in self._specs):
return True
return None
@prereleases.setter
def prereleases(self, value: bool) -> None:
def prereleases(self, value: bool | None) -> None:
self._prereleases = value

@@ -842,6 +894,6 @@

specifier._prereleases = other._prereleases
elif self._prereleases is not None and other._prereleases is None:
elif (
self._prereleases is not None and other._prereleases is None
) or self._prereleases == other._prereleases:
specifier._prereleases = self._prereleases
elif self._prereleases == other._prereleases:
specifier._prereleases = self._prereleases
else:

@@ -909,3 +961,3 @@ raise ValueError(

>>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1")
False
True
>>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True)

@@ -929,4 +981,7 @@ True

Whether or not to match prereleases with this SpecifierSet. If set to
``None`` (the default), it uses :attr:`prereleases` to determine
whether or not prereleases are allowed.
``None`` (the default), it will follow the recommendation from :pep:`440`
and match prereleases, as there are no other versions.
:param installed:
Whether or not the item is installed. If set to ``True``, it will
accept prerelease versions even if the specifier does not allow them.

@@ -940,36 +995,16 @@ >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.2.3")

>>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1")
True
>>> SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False).contains("1.3.0a1")
False
>>> SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True).contains("1.3.0a1")
True
>>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1", prereleases=True)
True
"""
# Ensure that our item is a Version instance.
if not isinstance(item, Version):
item = Version(item)
version = _coerce_version(item)
# Determine if we're forcing a prerelease or not, if we're not forcing
# one for this particular filter call, then we'll use whatever the
# SpecifierSet thinks for whether or not we should support prereleases.
if prereleases is None:
prereleases = self.prereleases
if version is not None and installed and version.is_prerelease:
prereleases = True
# We can determine if we're going to allow pre-releases by looking to
# see if any of the underlying items supports them. If none of them do
# and this item is a pre-release then we do not allow it and we can
# short circuit that here.
# Note: This means that 1.0.dev1 would not be contained in something
# like >=1.0.devabc however it would be in >=1.0.debabc,>0.0.dev0
if not prereleases and item.is_prerelease:
return False
check_item = item if version is None else version
return bool(list(self.filter([check_item], prereleases=prereleases)))
if installed and item.is_prerelease:
item = Version(item.base_version)
# We simply dispatch to the underlying specs here to make sure that the
# given version is contained within all of them.
# Note: This use of all() here means that an empty set of specifiers
# will always return True, this is an explicit design decision.
return all(s.contains(item, prereleases=prereleases) for s in self._specs)
def filter(

@@ -985,10 +1020,5 @@ self, iterable: Iterable[UnparsedVersionVar], prereleases: bool | None = None

Whether or not to allow prereleases in the returned iterator. If set to
``None`` (the default), it will be intelligently decide whether to allow
prereleases or not (based on the :attr:`prereleases` attribute, and
whether the only versions matching are prereleases).
``None`` (the default), it will follow the recommendation from :pep:`440`
and match prereleases if there are no other versions.
This method is smarter than just ``filter(SpecifierSet(...).contains, [...])``
because it implements the rule from :pep:`440` that a prerelease item
SHOULD be accepted if no other versions match the given specifier.
>>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", "1.5a1"]))

@@ -999,3 +1029,3 @@ ['1.3']

>>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.5a1"]))
[]
['1.5a1']
>>> list(SpecifierSet(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True))

@@ -1021,3 +1051,3 @@ ['1.3', '1.5a1']

# SpecifierSet thinks for whether or not we should support prereleases.
if prereleases is None:
if prereleases is None and self.prereleases is not None:
prereleases = self.prereleases

@@ -1029,28 +1059,47 @@

if self._specs:
# When prereleases is None, we need to let all versions through
# the individual filters, then decide about prereleases at the end
# based on whether any non-prereleases matched ALL specs.
for spec in self._specs:
iterable = spec.filter(iterable, prereleases=bool(prereleases))
return iter(iterable)
# If we do not have any specifiers, then we need to have a rough filter
# which will filter out any pre-releases, unless there are no final
# releases.
iterable = spec.filter(
iterable, prereleases=True if prereleases is None else prereleases
)
if prereleases is not None:
# If we have a forced prereleases value,
# we can immediately return the iterator.
return iter(iterable)
else:
filtered: list[UnparsedVersionVar] = []
found_prereleases: list[UnparsedVersionVar] = []
# Handle empty SpecifierSet cases where prereleases is not None.
if prereleases is True:
return iter(iterable)
for item in iterable:
parsed_version = _coerce_version(item)
if prereleases is False:
return (
item
for item in iterable
if (version := _coerce_version(item)) is None
or not version.is_prerelease
)
# Store any item which is a pre-release for later unless we've
# already found a final version or we are accepting prereleases
if parsed_version.is_prerelease and not prereleases:
if not filtered:
found_prereleases.append(item)
else:
filtered.append(item)
# Finally if prereleases is None, apply PEP 440 logic:
# exclude prereleases unless there are no final releases that matched.
filtered_items: list[UnparsedVersionVar] = []
found_prereleases: list[UnparsedVersionVar] = []
found_final_release = False
# If we've found no items except for pre-releases, then we'll go
# ahead and use the pre-releases
if not filtered and found_prereleases and prereleases is None:
return iter(found_prereleases)
for item in iterable:
parsed_version = _coerce_version(item)
# Arbitrary strings are always included as it is not
# possible to determine if they are prereleases,
# and they have already passed all specifiers.
if parsed_version is None:
filtered_items.append(item)
found_prereleases.append(item)
elif parsed_version.is_prerelease:
found_prereleases.append(item)
else:
filtered_items.append(item)
found_final_release = True
return iter(filtered)
return iter(filtered_items if found_final_release else found_prereleases)

@@ -16,2 +16,3 @@ # This file is dual licensed under the terms of the Apache License, Version

from typing import (
Any,
Iterable,

@@ -96,3 +97,10 @@ Iterator,

def __setstate__(self, state: tuple[None, dict[str, Any]]) -> None:
# The cached _hash is wrong when unpickling.
_, slots = state
for k, v in slots.items():
setattr(self, k, v)
self._hash = hash((self._interpreter, self._abi, self._platform))
def parse_tag(tag: str) -> frozenset[Tag]:

@@ -214,6 +222,3 @@ """

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)

@@ -224,3 +229,3 @@ # 'abi3' and 'none' are explicitly handled later.

abis.remove(explicit_abi)
except ValueError:
except ValueError: # noqa: PERF203
pass

@@ -306,7 +311,4 @@

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())

@@ -432,10 +434,7 @@ if "none" not in abis:

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

@@ -631,7 +630,3 @@ # "minor" version number. The major version was always 10.

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])

@@ -638,0 +633,0 @@

@@ -7,3 +7,2 @@ # This file is dual licensed under the terms of the Apache License, Version

import functools
import re

@@ -38,7 +37,4 @@ from typing import NewType, Tuple, Union, cast

# Core metadata spec for `Name`
_validate_regex = re.compile(
r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE
)
_canonicalize_regex = re.compile(r"[-_.]+")
_normalized_regex = re.compile(r"^([a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9])$")
_validate_regex = re.compile(r"[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9]", re.IGNORECASE)
_normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]")
# PEP 427: The build number must start with a digit.

@@ -49,14 +45,18 @@ _build_tag_regex = re.compile(r"(\d+)(.*)")

def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
if validate and not _validate_regex.match(name):
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
return _normalized_regex.fullmatch(name) is not None
@functools.singledispatch
def canonicalize_version(

@@ -84,15 +84,10 @@ version: Version | str, *, strip_trailing_zero: bool = True

"""
return str(_TrimmedRelease(str(version)) if strip_trailing_zero else version)
if isinstance(version, str):
try:
version = Version(version)
except InvalidVersion:
return str(version)
return str(_TrimmedRelease(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)
def parse_wheel_filename(

@@ -134,3 +129,3 @@ filename: str,

)
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:

@@ -137,0 +132,0 @@ build = ()

@@ -12,8 +12,55 @@ # This file is dual licensed under the terms of the Apache License, Version

import itertools
import re
from typing import Any, Callable, NamedTuple, SupportsInt, Tuple, Union
import sys
import typing
from typing import (
Any,
Callable,
Literal,
NamedTuple,
SupportsInt,
Tuple,
TypedDict,
Union,
)
from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType
if typing.TYPE_CHECKING:
from typing_extensions import Self, Unpack
if sys.version_info >= (3, 13): # pragma: no cover
from warnings import deprecated as _deprecated
elif typing.TYPE_CHECKING:
from typing_extensions import deprecated as _deprecated
else: # pragma: no cover
import functools
import warnings
def _deprecated(message: str) -> object:
def decorator(func: object) -> object:
@functools.wraps(func)
def wrapper(*args: object, **kwargs: object) -> object:
warnings.warn(
message,
category=DeprecationWarning,
stacklevel=2,
)
return func(*args, **kwargs)
return wrapper
return decorator
_LETTER_NORMALIZATION = {
"alpha": "a",
"beta": "b",
"c": "rc",
"pre": "rc",
"preview": "rc",
"rev": "post",
"r": "post",
}
__all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "parse"]

@@ -39,9 +86,9 @@

class _Version(NamedTuple):
epoch: int
release: tuple[int, ...]
dev: tuple[str, int] | None
pre: tuple[str, int] | None
post: tuple[str, int] | None
local: LocalType | None
class _VersionReplace(TypedDict, total=False):
epoch: int | None
release: tuple[int, ...] | None
pre: tuple[Literal["a", "b", "rc"], int] | None
post: int | None
dev: int | None
local: str | None

@@ -72,4 +119,12 @@

class _BaseVersion:
_key: tuple[Any, ...]
__slots__ = ()
# This can also be a normal member (see the packaging_legacy package);
# we are just requiring it to be readable. Actually defining a property
# has runtime effect on subclasses, so it's typing only.
if typing.TYPE_CHECKING:
@property
def _key(self) -> tuple[Any, ...]: ...
def __hash__(self) -> int:

@@ -120,13 +175,15 @@ return hash(self._key)

# easier for 3rd party code to reuse
# Note that ++ doesn't behave identically on CPython and PyPy, so not using it here
_VERSION_PATTERN = r"""
v?
v?+ # optional leading v
(?:
(?:(?P<epoch>[0-9]+)!)? # epoch
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
(?:(?P<epoch>[0-9]+)!)?+ # epoch
(?P<release>[0-9]+(?:\.[0-9]+)*+) # release segment
(?P<pre> # pre-release
[-_\.]?
[._-]?+
(?P<pre_l>alpha|a|beta|b|preview|pre|c|rc)
[-_\.]?
[._-]?+
(?P<pre_n>[0-9]+)?
)?
)?+
(?P<post> # post release

@@ -136,19 +193,35 @@ (?:-(?P<post_n1>[0-9]+))

(?:
[-_\.]?
[._-]?
(?P<post_l>post|rev|r)
[-_\.]?
[._-]?
(?P<post_n2>[0-9]+)?
)
)?
)?+
(?P<dev> # dev release
[-_\.]?
[._-]?+
(?P<dev_l>dev)
[-_\.]?
[._-]?+
(?P<dev_n>[0-9]+)?
)?
)?+
)
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
(?:\+
(?P<local> # local version
[a-z0-9]+
(?:[._-][a-z0-9]+)*+
)
)?+
"""
VERSION_PATTERN = _VERSION_PATTERN
_VERSION_PATTERN_OLD = _VERSION_PATTERN.replace("*+", "*").replace("?+", "?")
# Possessive qualifiers were added in Python 3.11.
# CPython 3.11.0-3.11.4 had a bug: https://github.com/python/cpython/pull/107795
# Older PyPy also had a bug.
VERSION_PATTERN = (
_VERSION_PATTERN_OLD
if (sys.implementation.name == "cpython" and sys.version_info < (3, 11, 5))
or (sys.implementation.name == "pypy" and sys.version_info < (3, 11, 13))
or sys.version_info < (3, 11)
else _VERSION_PATTERN
)
"""

@@ -166,2 +239,78 @@ A string containing the regular expression used to match a valid version.

# Validation pattern for local version in replace()
_LOCAL_PATTERN = re.compile(r"[a-z0-9]+(?:[._-][a-z0-9]+)*", re.IGNORECASE)
def _validate_epoch(value: object, /) -> int:
epoch = value or 0
if isinstance(epoch, int) and epoch >= 0:
return epoch
msg = f"epoch must be non-negative integer, got {epoch}"
raise InvalidVersion(msg)
def _validate_release(value: object, /) -> tuple[int, ...]:
release = (0,) if value is None else value
if (
isinstance(release, tuple)
and len(release) > 0
and all(isinstance(i, int) and i >= 0 for i in release)
):
return release
msg = f"release must be a non-empty tuple of non-negative integers, got {release}"
raise InvalidVersion(msg)
def _validate_pre(value: object, /) -> tuple[Literal["a", "b", "rc"], int] | None:
if value is None:
return value
if (
isinstance(value, tuple)
and len(value) == 2
and value[0] in ("a", "b", "rc")
and isinstance(value[1], int)
and value[1] >= 0
):
return value
msg = f"pre must be a tuple of ('a'|'b'|'rc', non-negative int), got {value}"
raise InvalidVersion(msg)
def _validate_post(value: object, /) -> tuple[Literal["post"], int] | None:
if value is None:
return value
if isinstance(value, int) and value >= 0:
return ("post", value)
msg = f"post must be non-negative integer, got {value}"
raise InvalidVersion(msg)
def _validate_dev(value: object, /) -> tuple[Literal["dev"], int] | None:
if value is None:
return value
if isinstance(value, int) and value >= 0:
return ("dev", value)
msg = f"dev must be non-negative integer, got {value}"
raise InvalidVersion(msg)
def _validate_local(value: object, /) -> LocalType | None:
if value is None:
return value
if isinstance(value, str) and _LOCAL_PATTERN.fullmatch(value):
return _parse_local_version(value)
msg = f"local must be a valid version string, got {value!r}"
raise InvalidVersion(msg)
# Backward compatibility for internals before 26.0. Do not use.
class _Version(NamedTuple):
epoch: int
release: tuple[int, ...]
dev: tuple[str, int] | None
pre: tuple[str, int] | None
post: tuple[str, int] | None
local: LocalType | None
class Version(_BaseVersion):

@@ -191,5 +340,16 @@ """This class abstracts handling of a project's versions.

_regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
_key: CmpKey
__slots__ = ("_dev", "_epoch", "_key_cache", "_local", "_post", "_pre", "_release")
__match_args__ = ("_str",)
_regex = re.compile(r"\s*" + VERSION_PATTERN + r"\s*", re.VERBOSE | re.IGNORECASE)
_epoch: int
_release: tuple[int, ...]
_dev: tuple[str, int] | None
_pre: tuple[str, int] | None
_post: tuple[str, int] | None
_local: LocalType | None
_key_cache: CmpKey | None
def __init__(self, version: str) -> None:

@@ -205,30 +365,82 @@ """Initialize a Version object.

"""
# Validate the version and parse it into pieces
match = self._regex.search(version)
match = self._regex.fullmatch(version)
if not match:
raise InvalidVersion(f"Invalid version: {version!r}")
self._epoch = int(match.group("epoch")) if match.group("epoch") else 0
self._release = tuple(map(int, match.group("release").split(".")))
self._pre = _parse_letter_version(match.group("pre_l"), match.group("pre_n"))
self._post = _parse_letter_version(
match.group("post_l"), match.group("post_n1") or match.group("post_n2")
)
self._dev = _parse_letter_version(match.group("dev_l"), match.group("dev_n"))
self._local = _parse_local_version(match.group("local"))
# Store the parsed out pieces of the version
self._version = _Version(
epoch=int(match.group("epoch")) if match.group("epoch") else 0,
release=tuple(int(i) for i in match.group("release").split(".")),
pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")),
post=_parse_letter_version(
match.group("post_l"), match.group("post_n1") or match.group("post_n2")
),
dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")),
local=_parse_local_version(match.group("local")),
# Key which will be used for sorting
self._key_cache = None
def __replace__(self, **kwargs: Unpack[_VersionReplace]) -> Self:
epoch = _validate_epoch(kwargs["epoch"]) if "epoch" in kwargs else self._epoch
release = (
_validate_release(kwargs["release"])
if "release" in kwargs
else self._release
)
pre = _validate_pre(kwargs["pre"]) if "pre" in kwargs else self._pre
post = _validate_post(kwargs["post"]) if "post" in kwargs else self._post
dev = _validate_dev(kwargs["dev"]) if "dev" in kwargs else self._dev
local = _validate_local(kwargs["local"]) if "local" in kwargs else self._local
# Generate a key which will be used for sorting
self._key = _cmpkey(
self._version.epoch,
self._version.release,
self._version.pre,
self._version.post,
self._version.dev,
self._version.local,
if (
epoch == self._epoch
and release == self._release
and pre == self._pre
and post == self._post
and dev == self._dev
and local == self._local
):
return self
new_version = self.__class__.__new__(self.__class__)
new_version._key_cache = None
new_version._epoch = epoch
new_version._release = release
new_version._pre = pre
new_version._post = post
new_version._dev = dev
new_version._local = local
return new_version
@property
def _key(self) -> CmpKey:
if self._key_cache is None:
self._key_cache = _cmpkey(
self._epoch,
self._release,
self._pre,
self._post,
self._dev,
self._local,
)
return self._key_cache
@property
@_deprecated("Version._version is private and will be removed soon")
def _version(self) -> _Version:
return _Version(
self._epoch, self._release, self._dev, self._pre, self._post, self._local
)
@_version.setter
@_deprecated("Version._version is private and will be removed soon")
def _version(self, value: _Version) -> None:
self._epoch = value.epoch
self._release = value.release
self._dev = value.dev
self._pre = value.pre
self._post = value.post
self._local = value.local
self._key_cache = None
def __repr__(self) -> str:

@@ -248,30 +460,33 @@ """A representation of the Version that shows all internal state.

"""
parts = []
# This is a hot function, so not calling self.base_version
version = ".".join(map(str, self.release))
# Epoch
if self.epoch != 0:
parts.append(f"{self.epoch}!")
if self.epoch:
version = f"{self.epoch}!{version}"
# Release segment
parts.append(".".join(str(x) for x in self.release))
# Pre-release
if self.pre is not None:
parts.append("".join(str(x) for x in self.pre))
version += "".join(map(str, self.pre))
# Post-release
if self.post is not None:
parts.append(f".post{self.post}")
version += f".post{self.post}"
# Development release
if self.dev is not None:
parts.append(f".dev{self.dev}")
version += f".dev{self.dev}"
# Local version segment
if self.local is not None:
parts.append(f"+{self.local}")
version += f"+{self.local}"
return "".join(parts)
return version
@property
def _str(self) -> str:
"""Internal property for match_args"""
return str(self)
@property
def epoch(self) -> int:

@@ -285,3 +500,3 @@ """The epoch of the version.

"""
return self._version.epoch
return self._epoch

@@ -302,3 +517,3 @@ @property

"""
return self._version.release
return self._release

@@ -318,3 +533,3 @@ @property

"""
return self._version.pre
return self._pre

@@ -330,3 +545,3 @@ @property

"""
return self._version.post[1] if self._version.post else None
return self._post[1] if self._post else None

@@ -342,3 +557,3 @@ @property

"""
return self._version.dev[1] if self._version.dev else None
return self._dev[1] if self._dev else None

@@ -354,4 +569,4 @@ @property

"""
if self._version.local:
return ".".join(str(x) for x in self._version.local)
if self._local:
return ".".join(str(x) for x in self._local)
else:

@@ -387,13 +602,5 @@ return None

"""
parts = []
release_segment = ".".join(map(str, self.release))
return f"{self.epoch}!{release_segment}" if self.epoch else release_segment
# Epoch
if self.epoch != 0:
parts.append(f"{self.epoch}!")
# Release segment
parts.append(".".join(str(x) for x in self.release))
return "".join(parts)
@property

@@ -471,2 +678,16 @@ def is_prerelease(self) -> bool:

class _TrimmedRelease(Version):
__slots__ = ()
def __init__(self, version: str | Version) -> None:
if isinstance(version, Version):
self._epoch = version._epoch
self._release = version._release
self._dev = version._dev
self._pre = version._pre
self._post = version._post
self._local = version._local
self._key_cache = version._key_cache
return
super().__init__(version) # pragma: no cover
@property

@@ -482,6 +703,9 @@ def release(self) -> tuple[int, ...]:

"""
# This leaves one 0.
rel = super().release
nonzeros = (index for index, val in enumerate(rel) if val)
last_nonzero = max(nonzeros, default=0)
return rel[: last_nonzero + 1]
len_release = len(rel)
i = len_release
while i > 1 and rel[i - 1] == 0:
i -= 1
return rel if i == len_release else rel[:i]

@@ -493,7 +717,2 @@

if letter:
# We consider there to be an implicit 0 in a pre-release if there is
# not a numeral associated with it.
if number is None:
number = 0
# We normalize any letters to their lower case form

@@ -505,21 +724,13 @@ letter = letter.lower()

# spelling.
if letter == "alpha":
letter = "a"
elif letter == "beta":
letter = "b"
elif letter in ["c", "pre", "preview"]:
letter = "rc"
elif letter in ["rev", "r"]:
letter = "post"
letter = _LETTER_NORMALIZATION.get(letter, letter)
return letter, int(number)
# We consider there to be an implicit 0 in a pre-release if there is
# not a numeral associated with it.
return letter, int(number or 0)
assert not letter
if number:
# We assume if we are given a number, but we are not given a letter
# then this is using the implicit post release syntax (e.g. 1.0-1)
letter = "post"
return "post", int(number)
return letter, int(number)
return None

@@ -552,9 +763,8 @@

# When we compare a release version, we want to compare it with all of the
# trailing zeros removed. So we'll use a reverse the list, drop all the now
# leading zeros until we come to something non zero, then take the rest
# re-reverse it back into the correct order and make it a tuple and use
# that for our sorting key.
_release = tuple(
reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release))))
)
# trailing zeros removed. We will use this for our sorting key.
len_release = len(release)
i = len_release
while i and release[i - 1] == 0:
i -= 1
_release = release if i == len_release else release[:i]

@@ -561,0 +771,0 @@ # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.

from __future__ import annotations
__version__ = "0.45.1"
__version__ = "0.46.3"
"""
Wheel command line tool (enable python -m wheel syntax)
Wheel command line tool (enables the ``python -m wheel`` syntax)
"""

@@ -8,5 +8,6 @@

import sys
from typing import NoReturn
def main(): # needed for console script
def main() -> NoReturn: # needed for console script
if __package__ == "":

@@ -18,8 +19,9 @@ # To be able to run 'python wheel-0.9.whl/wheel':

sys.path[0:0] = [path]
import wheel.cli
sys.exit(wheel.cli.main())
from ._commands import main as cli_main
sys.exit(cli_main())
if __name__ == "__main__":
sys.exit(main())
main()

@@ -9,2 +9,3 @@ """

import logging
import os

@@ -18,2 +19,3 @@ import re

import warnings
from collections.abc import Iterable, Sequence
from email.generator import BytesGenerator, Generator

@@ -23,13 +25,12 @@ from email.policy import EmailPolicy

from shutil import rmtree
from typing import TYPE_CHECKING, Callable, Iterable, Literal, Sequence, cast
from typing import TYPE_CHECKING, Callable, Literal, cast
from zipfile import ZIP_DEFLATED, ZIP_STORED
import setuptools
from packaging import tags
from packaging import version as _packaging_version
from setuptools import Command
from . import __version__ as wheel_version
from .metadata import pkginfo_to_metadata
from .util import log
from .vendored.packaging import tags
from .vendored.packaging import version as _packaging_version
from ._metadata import pkginfo_to_metadata
from .wheelfile import WheelFile

@@ -49,3 +50,5 @@

log = logging.getLogger("wheel")
def safe_name(name: str) -> str:

@@ -377,5 +380,5 @@ """Convert an arbitrary string to a standard distribution name

]
assert (
tag in supported_tags
), f"would build wheel with unsupported tag {tag}"
assert tag in supported_tags, (
f"would build wheel with unsupported tag {tag}"
)
return tag

@@ -382,0 +385,0 @@

@@ -8,3 +8,3 @@ from typing import TYPE_CHECKING

"v70.1 or later which contains an integrated version of this command.",
DeprecationWarning,
FutureWarning,
stacklevel=1,

@@ -11,0 +11,0 @@ )

"""
IMPORTANT: DO NOT IMPORT THIS MODULE DIRECTLY.
THIS IS ONLY KEPT IN PLACE FOR BACKWARDS COMPATIBILITY WITH
setuptools.command.bdist_wheel.
This module contains function to analyse dynamic library

@@ -3,0 +7,0 @@ headers to extract system information

@@ -1,183 +0,17 @@

"""
Tools for converting old- to new-style metadata.
"""
from warnings import warn
from __future__ import annotations
from ._metadata import convert_requirements as convert_requirements
from ._metadata import generate_requirements as generate_requirements
from ._metadata import pkginfo_to_metadata as pkginfo_to_metadata
from ._metadata import requires_to_requires_dist as requires_to_requires_dist
from ._metadata import safe_extra as safe_extra
from ._metadata import safe_name as safe_name
from ._metadata import split_sections as split_sections
import functools
import itertools
import os.path
import re
import textwrap
from email.message import Message
from email.parser import Parser
from typing import Generator, Iterable, Iterator, Literal
from .vendored.packaging.requirements import Requirement
def _nonblank(str: str) -> bool | Literal[""]:
return str and not str.startswith("#")
@functools.singledispatch
def yield_lines(iterable: Iterable[str]) -> Iterator[str]:
r"""
Yield valid lines of a string or iterable.
>>> list(yield_lines(''))
[]
>>> list(yield_lines(['foo', 'bar']))
['foo', 'bar']
>>> list(yield_lines('foo\nbar'))
['foo', 'bar']
>>> list(yield_lines('\nfoo\n#bar\nbaz #comment'))
['foo', 'baz #comment']
>>> list(yield_lines(['foo\nbar', 'baz', 'bing\n\n\n']))
['foo', 'bar', 'baz', 'bing']
"""
return itertools.chain.from_iterable(map(yield_lines, iterable))
@yield_lines.register(str)
def _(text: str) -> Iterator[str]:
return filter(_nonblank, map(str.strip, text.splitlines()))
def split_sections(
s: str | Iterator[str],
) -> Generator[tuple[str | None, list[str]], None, None]:
"""Split a string or iterable thereof into (section, content) pairs
Each ``section`` is a stripped version of the section header ("[section]")
and each ``content`` is a list of stripped lines excluding blank lines and
comment-only lines. If there are any such lines before the first section
header, they're returned in a first ``section`` of ``None``.
"""
section = None
content: list[str] = []
for line in yield_lines(s):
if line.startswith("["):
if line.endswith("]"):
if section or content:
yield section, content
section = line[1:-1].strip()
content = []
else:
raise ValueError("Invalid section heading", line)
else:
content.append(line)
# wrap up last segment
yield section, content
def safe_extra(extra: str) -> str:
"""Convert an arbitrary string to a standard 'extra' name
Any runs of non-alphanumeric characters are replaced with a single '_',
and the result is always lowercased.
"""
return re.sub("[^A-Za-z0-9.-]+", "_", extra).lower()
def safe_name(name: str) -> str:
"""Convert an arbitrary string to a standard distribution name
Any runs of non-alphanumeric/. characters are replaced with a single '-'.
"""
return re.sub("[^A-Za-z0-9.]+", "-", name)
def requires_to_requires_dist(requirement: Requirement) -> str:
"""Return the version specifier for a requirement in PEP 345/566 fashion."""
if requirement.url:
return " @ " + requirement.url
requires_dist: list[str] = []
for spec in requirement.specifier:
requires_dist.append(spec.operator + spec.version)
if requires_dist:
return " " + ",".join(sorted(requires_dist))
else:
return ""
def convert_requirements(requirements: list[str]) -> Iterator[str]:
"""Yield Requires-Dist: strings for parsed requirements strings."""
for req in requirements:
parsed_requirement = Requirement(req)
spec = requires_to_requires_dist(parsed_requirement)
extras = ",".join(sorted(safe_extra(e) for e in parsed_requirement.extras))
if extras:
extras = f"[{extras}]"
yield safe_name(parsed_requirement.name) + extras + spec
def generate_requirements(
extras_require: dict[str | None, list[str]],
) -> Iterator[tuple[str, str]]:
"""
Convert requirements from a setup()-style dictionary to
('Requires-Dist', 'requirement') and ('Provides-Extra', 'extra') tuples.
extras_require is a dictionary of {extra: [requirements]} as passed to setup(),
using the empty extra {'': [requirements]} to hold install_requires.
"""
for extra, depends in extras_require.items():
condition = ""
extra = extra or ""
if ":" in extra: # setuptools extra:condition syntax
extra, condition = extra.split(":", 1)
extra = safe_extra(extra)
if extra:
yield "Provides-Extra", extra
if condition:
condition = "(" + condition + ") and "
condition += f"extra == '{extra}'"
if condition:
condition = " ; " + condition
for new_req in convert_requirements(depends):
canonical_req = str(Requirement(new_req + condition))
yield "Requires-Dist", canonical_req
def pkginfo_to_metadata(egg_info_path: str, pkginfo_path: str) -> Message:
"""
Convert .egg-info directory with PKG-INFO to the Metadata 2.1 format
"""
with open(pkginfo_path, encoding="utf-8") as headers:
pkg_info = Parser().parse(headers)
pkg_info.replace_header("Metadata-Version", "2.1")
# Those will be regenerated from `requires.txt`.
del pkg_info["Provides-Extra"]
del pkg_info["Requires-Dist"]
requires_path = os.path.join(egg_info_path, "requires.txt")
if os.path.exists(requires_path):
with open(requires_path, encoding="utf-8") as requires_file:
requires = requires_file.read()
parsed_requirements = sorted(split_sections(requires), key=lambda x: x[0] or "")
for extra, reqs in parsed_requirements:
for key, value in generate_requirements({extra: reqs}):
if (key, value) not in pkg_info.items():
pkg_info[key] = value
description = pkg_info["Description"]
if description:
description_lines = pkg_info["Description"].splitlines()
dedented_description = "\n".join(
# if the first line of long_description is blank,
# the first line here will be indented.
(
description_lines[0].lstrip(),
textwrap.dedent("\n".join(description_lines[1:])),
"\n",
)
)
pkg_info.set_payload(dedented_description)
del pkg_info["Description"]
return pkg_info
warn(
f"The {__name__!r} package has been made private and should no longer be imported. "
f"Please either copy the code or find an alternative library to import it from, as "
f"this warning will be removed in a future version of 'wheel'.",
DeprecationWarning,
stacklevel=2,
)
from __future__ import annotations
__all__ = ["WHEEL_INFO_RE", "WheelFile", "WheelError"]
import base64
import csv
import hashlib
import logging
import os.path

@@ -13,15 +17,6 @@ import re

from wheel.cli import WheelError
from wheel.util import log, urlsafe_b64decode, urlsafe_b64encode
if TYPE_CHECKING:
from typing import Protocol, Sized, Union
from _typeshed import SizedBuffer, StrPath
from typing_extensions import Buffer
StrPath = Union[str, os.PathLike[str]]
class SizedBuffer(Sized, Buffer, Protocol): ...
# Non-greedy matching of an optional build number may be too clever (more

@@ -36,4 +31,23 @@ # invalid wheel filenames will match). Separate regex for .dist-info?

log = logging.getLogger("wheel")
def get_zipinfo_datetime(timestamp: float | None = None):
class WheelError(Exception):
pass
def urlsafe_b64encode(data: bytes) -> bytes:
"""urlsafe_b64encode without padding"""
return base64.urlsafe_b64encode(data).rstrip(b"=")
def urlsafe_b64decode(data: bytes) -> bytes:
"""urlsafe_b64decode without padding"""
pad = b"=" * (4 - (len(data) & 3))
return base64.urlsafe_b64decode(data + pad)
def get_zipinfo_datetime(
timestamp: float | None = None,
) -> tuple[int, int, int, int, int]:
# Some applications need reproducible .whl files, but they can't do this without

@@ -145,4 +159,4 @@ # forcing the timestamp of the individual ZipInfo objects. See issue #143.

def write_files(self, base_dir: str):
log.info(f"creating '{self.filename}' and adding '{base_dir}' to it")
def write_files(self, base_dir: str) -> None:
log.info("creating %r and adding %r to it", self.filename, base_dir)
deferred: list[tuple[str, str]] = []

@@ -190,3 +204,3 @@ for root, dirnames, filenames in os.walk(base_dir):

compress_type: int | None = None,
):
) -> None:
if isinstance(zinfo_or_arcname, str):

@@ -208,3 +222,3 @@ zinfo_or_arcname = ZipInfo(

)
log.info(f"adding '{fname}'")
log.info("adding %r", fname)
if fname != self.record_path:

@@ -218,3 +232,3 @@ hash_ = self._default_algorithm(data)

def close(self):
def close(self) -> None:
# Write RECORD

@@ -221,0 +235,0 @@ if self.fp is not None and self.mode == "w" and self._file_hashes:

@@ -105,3 +105,3 @@ """Translation layer between pyproject config and setuptools distribution and

see_url=f"https://packaging.python.org/en/latest/{pypa_guides}",
due_date=(2026, 2, 18), # Warning introduced on 2025-02-18
due_date=(2027, 2, 18), # Warning introduced on 2025-02-18
)

@@ -218,3 +218,3 @@

see_url=f"https://packaging.python.org/en/latest/{pypa_guides}",
due_date=(2026, 2, 18), # Introduced on 2025-02-18
due_date=(2027, 2, 18), # Introduced on 2025-02-18
)

@@ -221,0 +221,0 @@ if "file" in val:

@@ -515,3 +515,3 @@ from __future__ import annotations

see_url=f"https://packaging.python.org/en/latest/{pypa_guides}",
due_date=(2026, 3, 20), # Introduced in 2025-03-20
due_date=(2027, 2, 18), # Introduced in 2025-03-20
# Replace with InvalidConfigError after deprecation

@@ -529,3 +529,3 @@ )

see_url=f"https://packaging.python.org/en/latest/{pypa_guides}",
due_date=(2026, 3, 20), # Introduced in 2025-02-20
due_date=(2027, 2, 18), # Introduced in 2025-02-20
)

@@ -540,3 +540,3 @@

pattern=pattern,
due_date=(2026, 3, 20), # Introduced in 2025-02-20
due_date=(2027, 2, 18), # Introduced in 2025-02-20
# PEP 639 requires us to error, but as a transition period

@@ -543,0 +543,0 @@ # we will only issue a warning to give people time to prepare.

@@ -73,4 +73,6 @@ import functools

install_deps(deps, vendor)
# uv only installs non-portable shebangs (#5159)
(vendor / 'bin').rmtree()
__name__ == '__main__' and update_vendored()

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

This software is made available under the terms of *either* of the licenses
found in LICENSE.APACHE or LICENSE.BSD. Contributions to this software is made
under the terms of *both* these licenses.

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Metadata-Version: 2.4
Name: packaging
Version: 25.0
Summary: Core utilities for Python packages
Author-email: Donald Stufft <donald@stufft.io>
Requires-Python: >=3.8
Description-Content-Type: text/x-rst
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: License :: OSI Approved :: BSD License
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Typing :: Typed
License-File: LICENSE
License-File: LICENSE.APACHE
License-File: LICENSE.BSD
Project-URL: Documentation, https://packaging.pypa.io/
Project-URL: Source, https://github.com/pypa/packaging
packaging
=========
.. start-intro
Reusable core utilities for various Python Packaging
`interoperability specifications <https://packaging.python.org/specifications/>`_.
This library provides utilities that implement the interoperability
specifications which have clearly one correct behaviour (eg: :pep:`440`)
or benefit greatly from having a single shared implementation (eg: :pep:`425`).
.. end-intro
The ``packaging`` project includes the following: version handling, specifiers,
markers, requirements, tags, utilities.
Documentation
-------------
The `documentation`_ provides information and the API for the following:
- Version Handling
- Specifiers
- Markers
- Requirements
- Tags
- Utilities
Installation
------------
Use ``pip`` to install these utilities::
pip install packaging
The ``packaging`` library uses calendar-based versioning (``YY.N``).
Discussion
----------
If you run into bugs, you can file them in our `issue tracker`_.
You can also join ``#pypa`` on Freenode to ask questions or get involved.
.. _`documentation`: https://packaging.pypa.io/
.. _`issue tracker`: https://github.com/pypa/packaging/issues
Code of Conduct
---------------
Everyone interacting in the packaging project's codebases, issue trackers, chat
rooms, and mailing lists is expected to follow the `PSF Code of Conduct`_.
.. _PSF Code of Conduct: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md
Contributing
------------
The ``CONTRIBUTING.rst`` file outlines how to contribute to this project as
well as how to report a potential security issue. The documentation for this
project also covers information about `project development`_ and `security`_.
.. _`project development`: https://packaging.pypa.io/en/latest/development/
.. _`security`: https://packaging.pypa.io/en/latest/security/
Project History
---------------
Please review the ``CHANGELOG.rst`` file or the `Changelog documentation`_ for
recent changes and project history.
.. _`Changelog documentation`: https://packaging.pypa.io/en/latest/changelog/
packaging-25.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
packaging-25.0.dist-info/METADATA,sha256=W2EaYJw4_vw9YWv0XSCuyY-31T8kXayp4sMPyFx6woI,3281
packaging-25.0.dist-info/RECORD,,
packaging-25.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
packaging-25.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
packaging-25.0.dist-info/licenses/LICENSE,sha256=ytHvW9NA1z4HS6YU0m996spceUDD2MNIUuZcSQlobEg,197
packaging-25.0.dist-info/licenses/LICENSE.APACHE,sha256=DVQuDIgE45qn836wDaWnYhSdxoLXgpRRKH4RuTjpRZQ,10174
packaging-25.0.dist-info/licenses/LICENSE.BSD,sha256=tw5-m3QvHMb5SLNMFqo5_-zpQZY2S8iP8NIYDwAo-sU,1344
packaging/__init__.py,sha256=_0cDiPVf2S-bNfVmZguxxzmrIYWlyASxpqph4qsJWUc,494
packaging/_elffile.py,sha256=UkrbDtW7aeq3qqoAfU16ojyHZ1xsTvGke_WqMTKAKd0,3286
packaging/_manylinux.py,sha256=t4y_-dTOcfr36gLY-ztiOpxxJFGO2ikC11HgfysGxiM,9596
packaging/_musllinux.py,sha256=p9ZqNYiOItGee8KcZFeHF_YcdhVwGHdK6r-8lgixvGQ,2694
packaging/_parser.py,sha256=gYfnj0pRHflVc4RHZit13KNTyN9iiVcU2RUCGi22BwM,10221
packaging/_structures.py,sha256=q3eVNmbWJGG_S0Dit_S3Ao8qQqz_5PYTXFAKBZe5yr4,1431
packaging/_tokenizer.py,sha256=OYzt7qKxylOAJ-q0XyK1qAycyPRYLfMPdGQKRXkZWyI,5310
packaging/licenses/__init__.py,sha256=VsK4o27CJXWfTi8r2ybJmsBoCdhpnBWuNrskaCVKP7U,5715
packaging/licenses/_spdx.py,sha256=oAm1ztPFwlsmCKe7lAAsv_OIOfS1cWDu9bNBkeu-2ns,48398
packaging/markers.py,sha256=P0we27jm1xUzgGMJxBjtUFCIWeBxTsMeJTOJ6chZmAY,12049
packaging/metadata.py,sha256=8IZErqQQnNm53dZZuYq4FGU4_dpyinMeH1QFBIWIkfE,34739
packaging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
packaging/requirements.py,sha256=gYyRSAdbrIyKDY66ugIDUQjRMvxkH2ALioTmX3tnL6o,2947
packaging/specifiers.py,sha256=gtPu5DTc-F9baLq3FTGEK6dPhHGCuwwZetaY0PSV2gs,40055
packaging/tags.py,sha256=41s97W9Zatrq2Ed7Rc3qeBDaHe8pKKvYq2mGjwahfXk,22745
packaging/utils.py,sha256=0F3Hh9OFuRgrhTgGZUl5K22Fv1YP2tZl1z_2gO6kJiA,5050
packaging/version.py,sha256=olfyuk_DPbflNkJ4wBWetXQ17c74x3DB501degUv7DY,16676

Sorry, the diff of this file is not supported yet

Wheel-Version: 1.0
Generator: flit 3.12.0
Root-Is-Purelib: true
Tag: py3-none-any
[console_scripts]
wheel=wheel.cli:main
[distutils.commands]
bdist_wheel=wheel.bdist_wheel:bdist_wheel

Sorry, the diff of this file is not supported yet

MIT License
Copyright (c) 2012 Daniel Holth <dholth@fastmail.fm> and contributors
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
Metadata-Version: 2.3
Name: wheel
Version: 0.45.1
Summary: A built-package format for Python
Keywords: wheel,packaging
Author-email: Daniel Holth <dholth@fastmail.fm>
Maintainer-email: Alex Grönholm <alex.gronholm@nextday.fi>
Requires-Python: >=3.8
Description-Content-Type: text/x-rst
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Topic :: System :: Archiving :: Packaging
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Dist: pytest >= 6.0.0 ; extra == "test"
Requires-Dist: setuptools >= 65 ; extra == "test"
Project-URL: Changelog, https://wheel.readthedocs.io/en/stable/news.html
Project-URL: Documentation, https://wheel.readthedocs.io/
Project-URL: Issue Tracker, https://github.com/pypa/wheel/issues
Project-URL: Source, https://github.com/pypa/wheel
Provides-Extra: test
wheel
=====
This is a command line tool for manipulating Python wheel files, as defined in
`PEP 427`_. It contains the following functionality:
* Convert ``.egg`` archives into ``.whl``
* Unpack wheel archives
* Repack wheel archives
* Add or remove tags in existing wheel archives
.. _PEP 427: https://www.python.org/dev/peps/pep-0427/
Historical note
---------------
This project used to contain the implementation of the setuptools_ ``bdist_wheel``
command, but as of setuptools v70.1, it no longer needs ``wheel`` installed for that to
work. Thus, you should install this **only** if you intend to use the ``wheel`` command
line tool!
.. _setuptools: https://pypi.org/project/setuptools/
Documentation
-------------
The documentation_ can be found on Read The Docs.
.. _documentation: https://wheel.readthedocs.io/
Code of Conduct
---------------
Everyone interacting in the wheel project's codebases, issue trackers, chat
rooms, and mailing lists is expected to follow the `PSF Code of Conduct`_.
.. _PSF Code of Conduct: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md
bin/wheel,sha256=7ijYGJ2HkBAM2Ws5-L6nCHhN-DASp95DgxlQWfqY51A,333
wheel-0.45.1.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
wheel-0.45.1.dist-info/LICENSE.txt,sha256=MMI2GGeRCPPo6h0qZYx8pBe9_IkcmO8aifpP8MmChlQ,1107
wheel-0.45.1.dist-info/METADATA,sha256=mKz84H7m7jsxJyzeIcTVORiTb0NPMV39KvOIYhGgmjA,2313
wheel-0.45.1.dist-info/RECORD,,
wheel-0.45.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
wheel-0.45.1.dist-info/WHEEL,sha256=CpUCUxeHQbRN5UGRQHYRJorO5Af-Qy_fHMctcQ8DSGI,82
wheel-0.45.1.dist-info/entry_points.txt,sha256=rTY1BbkPHhkGMm4Q3F0pIzJBzW2kMxoG1oriffvGdA0,104
wheel/__init__.py,sha256=mrxMnvdXACur_LWegbUfh5g5ysWZrd63UJn890wvGNk,59
wheel/__main__.py,sha256=NkMUnuTCGcOkgY0IBLgBCVC_BGGcWORx2K8jYGS12UE,455
wheel/_bdist_wheel.py,sha256=UghCQjSH_pVfcZh6oRjzSw_TQhcf3anSx1OkiLSL82M,21694
wheel/_setuptools_logging.py,sha256=-5KC-lne0ilOUWIDfOkqapUWGMFZhuKYDIavIZiB5kM,781
wheel/bdist_wheel.py,sha256=tpf9WufiSO1RuEMg5oPhIfSG8DMziCZ_4muCKF69Cqo,1107
wheel/cli/__init__.py,sha256=Npq6_jKi03dhIcRnmbuFhwviVJxwO0tYEnEhWMv9cJo,4402
wheel/cli/convert.py,sha256=Bi0ntEXb9nTllCxWeTRQ4j-nPs3szWSEKipG_GgnMkQ,12634
wheel/cli/pack.py,sha256=CAFcHdBVulvsHYJlndKVO7KMI9JqBTZz5ii0PKxxCOs,3103
wheel/cli/tags.py,sha256=lHw-LaWrkS5Jy_qWcw-6pSjeNM6yAjDnqKI3E5JTTCU,4760
wheel/cli/unpack.py,sha256=Y_J7ynxPSoFFTT7H0fMgbBlVErwyDGcObgme5MBuz58,1021
wheel/macosx_libfile.py,sha256=k1x7CE3LPtOVGqj6NXQ1nTGYVPaeRrhVzUG_KPq3zDs,16572
wheel/metadata.py,sha256=JC4p7jlQZu2bUTAQ2fevkqLjg_X6gnNyRhLn6OUO1tc,6171
wheel/util.py,sha256=aL7aibHwYUgfc8WlolL5tXdkV4DatbJxZHb1kwHFJAU,423
wheel/vendored/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
wheel/vendored/packaging/LICENSE,sha256=ytHvW9NA1z4HS6YU0m996spceUDD2MNIUuZcSQlobEg,197
wheel/vendored/packaging/LICENSE.APACHE,sha256=DVQuDIgE45qn836wDaWnYhSdxoLXgpRRKH4RuTjpRZQ,10174
wheel/vendored/packaging/LICENSE.BSD,sha256=tw5-m3QvHMb5SLNMFqo5_-zpQZY2S8iP8NIYDwAo-sU,1344
wheel/vendored/packaging/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
wheel/vendored/packaging/_elffile.py,sha256=hbmK8OD6Z7fY6hwinHEUcD1by7czkGiNYu7ShnFEk2k,3266
wheel/vendored/packaging/_manylinux.py,sha256=P7sdR5_7XBY09LVYYPhHmydMJIIwPXWsh4olk74Uuj4,9588
wheel/vendored/packaging/_musllinux.py,sha256=z1s8To2hQ0vpn_d-O2i5qxGwEK8WmGlLt3d_26V7NeY,2674
wheel/vendored/packaging/_parser.py,sha256=4tT4emSl2qTaU7VTQE1Xa9o1jMPCsBezsYBxyNMUN-s,10347
wheel/vendored/packaging/_structures.py,sha256=q3eVNmbWJGG_S0Dit_S3Ao8qQqz_5PYTXFAKBZe5yr4,1431
wheel/vendored/packaging/_tokenizer.py,sha256=alCtbwXhOFAmFGZ6BQ-wCTSFoRAJ2z-ysIf7__MTJ_k,5292
wheel/vendored/packaging/markers.py,sha256=_TSPI1BhJYO7Bp9AzTmHQxIqHEVXaTjmDh9G-w8qzPA,8232
wheel/vendored/packaging/requirements.py,sha256=dgoBeVprPu2YE6Q8nGfwOPTjATHbRa_ZGLyXhFEln6Q,2933
wheel/vendored/packaging/specifiers.py,sha256=IWSt0SrLSP72heWhAC8UL0eGvas7XIQHjqiViVfmPKE,39778
wheel/vendored/packaging/tags.py,sha256=fedHXiOHkBxNZTXotXv8uXPmMFU9ae-TKBujgYHigcA,18950
wheel/vendored/packaging/utils.py,sha256=XgdmP3yx9-wQEFjO7OvMj9RjEf5JlR5HFFR69v7SQ9E,5268
wheel/vendored/packaging/version.py,sha256=PFJaYZDxBgyxkfYhH3SQw4qfE9ICCWrTmitvq14y3bs,16234
wheel/vendored/vendor.txt,sha256=Z2ENjB1i5prfez8CdM1Sdr3c6Zxv2rRRolMpLmBncAE,16
wheel/wheelfile.py,sha256=USCttNlJwafxt51YYFFKG7jnxz8dfhbyqAZL6jMTA9s,8411

Sorry, the diff of this file is not supported yet

Wheel-Version: 1.0
Generator: flit 3.10.1
Root-Is-Purelib: true
Tag: py3-none-any
"""
Wheel command-line utility.
"""
from __future__ import annotations
import argparse
import os
import sys
from argparse import ArgumentTypeError
class WheelError(Exception):
pass
def unpack_f(args: argparse.Namespace) -> None:
from .unpack import unpack
unpack(args.wheelfile, args.dest)
def pack_f(args: argparse.Namespace) -> None:
from .pack import pack
pack(args.directory, args.dest_dir, args.build_number)
def convert_f(args: argparse.Namespace) -> None:
from .convert import convert
convert(args.files, args.dest_dir, args.verbose)
def tags_f(args: argparse.Namespace) -> None:
from .tags import tags
names = (
tags(
wheel,
args.python_tag,
args.abi_tag,
args.platform_tag,
args.build,
args.remove,
)
for wheel in args.wheel
)
for name in names:
print(name)
def version_f(args: argparse.Namespace) -> None:
from .. import __version__
print(f"wheel {__version__}")
def parse_build_tag(build_tag: str) -> str:
if build_tag and not build_tag[0].isdigit():
raise ArgumentTypeError("build tag must begin with a digit")
elif "-" in build_tag:
raise ArgumentTypeError("invalid character ('-') in build tag")
return build_tag
TAGS_HELP = """\
Make a new wheel with given tags. Any tags unspecified will remain the same.
Starting the tags with a "+" will append to the existing tags. Starting with a
"-" will remove a tag (use --option=-TAG syntax). Multiple tags can be
separated by ".". The original file will remain unless --remove is given. The
output filename(s) will be displayed on stdout for further processing.
"""
def parser():
p = argparse.ArgumentParser()
s = p.add_subparsers(help="commands")
unpack_parser = s.add_parser("unpack", help="Unpack wheel")
unpack_parser.add_argument(
"--dest", "-d", help="Destination directory", default="."
)
unpack_parser.add_argument("wheelfile", help="Wheel file")
unpack_parser.set_defaults(func=unpack_f)
repack_parser = s.add_parser("pack", help="Repack wheel")
repack_parser.add_argument("directory", help="Root directory of the unpacked wheel")
repack_parser.add_argument(
"--dest-dir",
"-d",
default=os.path.curdir,
help="Directory to store the wheel (default %(default)s)",
)
repack_parser.add_argument(
"--build-number", help="Build tag to use in the wheel name"
)
repack_parser.set_defaults(func=pack_f)
convert_parser = s.add_parser("convert", help="Convert egg or wininst to wheel")
convert_parser.add_argument("files", nargs="*", help="Files to convert")
convert_parser.add_argument(
"--dest-dir",
"-d",
default=os.path.curdir,
help="Directory to store wheels (default %(default)s)",
)
convert_parser.add_argument("--verbose", "-v", action="store_true")
convert_parser.set_defaults(func=convert_f)
tags_parser = s.add_parser(
"tags", help="Add or replace the tags on a wheel", description=TAGS_HELP
)
tags_parser.add_argument("wheel", nargs="*", help="Existing wheel(s) to retag")
tags_parser.add_argument(
"--remove",
action="store_true",
help="Remove the original files, keeping only the renamed ones",
)
tags_parser.add_argument(
"--python-tag", metavar="TAG", help="Specify an interpreter tag(s)"
)
tags_parser.add_argument("--abi-tag", metavar="TAG", help="Specify an ABI tag(s)")
tags_parser.add_argument(
"--platform-tag", metavar="TAG", help="Specify a platform tag(s)"
)
tags_parser.add_argument(
"--build", type=parse_build_tag, metavar="BUILD", help="Specify a build tag"
)
tags_parser.set_defaults(func=tags_f)
version_parser = s.add_parser("version", help="Print version and exit")
version_parser.set_defaults(func=version_f)
help_parser = s.add_parser("help", help="Show this help")
help_parser.set_defaults(func=lambda args: p.print_help())
return p
def main():
p = parser()
args = p.parse_args()
if not hasattr(args, "func"):
p.print_help()
else:
try:
args.func(args)
return 0
except WheelError as e:
print(e, file=sys.stderr)
return 1
from __future__ import annotations
import os.path
import re
from abc import ABCMeta, abstractmethod
from collections import defaultdict
from collections.abc import Iterator
from email.message import Message
from email.parser import Parser
from email.policy import EmailPolicy
from glob import iglob
from pathlib import Path
from textwrap import dedent
from zipfile import ZipFile
from .. import __version__
from ..metadata import generate_requirements
from ..vendored.packaging.tags import parse_tag
from ..wheelfile import WheelFile
egg_filename_re = re.compile(
r"""
(?P<name>.+?)-(?P<ver>.+?)
(-(?P<pyver>py\d\.\d+)
(-(?P<arch>.+?))?
)?.egg$""",
re.VERBOSE,
)
egg_info_re = re.compile(
r"""
^(?P<name>.+?)-(?P<ver>.+?)
(-(?P<pyver>py\d\.\d+)
)?.egg-info/""",
re.VERBOSE,
)
wininst_re = re.compile(
r"\.(?P<platform>win32|win-amd64)(?:-(?P<pyver>py\d\.\d))?\.exe$"
)
pyd_re = re.compile(r"\.(?P<abi>[a-z0-9]+)-(?P<platform>win32|win_amd64)\.pyd$")
serialization_policy = EmailPolicy(
utf8=True,
mangle_from_=False,
max_line_length=0,
)
GENERATOR = f"wheel {__version__}"
def convert_requires(requires: str, metadata: Message) -> None:
extra: str | None = None
requirements: dict[str | None, list[str]] = defaultdict(list)
for line in requires.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("[") and line.endswith("]"):
extra = line[1:-1]
continue
requirements[extra].append(line)
for key, value in generate_requirements(requirements):
metadata.add_header(key, value)
def convert_pkg_info(pkginfo: str, metadata: Message):
parsed_message = Parser().parsestr(pkginfo)
for key, value in parsed_message.items():
key_lower = key.lower()
if value == "UNKNOWN":
continue
if key_lower == "description":
description_lines = value.splitlines()
value = "\n".join(
(
description_lines[0].lstrip(),
dedent("\n".join(description_lines[1:])),
"\n",
)
)
metadata.set_payload(value)
elif key_lower == "home-page":
metadata.add_header("Project-URL", f"Homepage, {value}")
elif key_lower == "download-url":
metadata.add_header("Project-URL", f"Download, {value}")
else:
metadata.add_header(key, value)
metadata.replace_header("Metadata-Version", "2.4")
def normalize(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).lower().replace("-", "_")
class ConvertSource(metaclass=ABCMeta):
name: str
version: str
pyver: str = "py2.py3"
abi: str = "none"
platform: str = "any"
metadata: Message
@property
def dist_info_dir(self) -> str:
return f"{self.name}-{self.version}.dist-info"
@abstractmethod
def generate_contents(self) -> Iterator[tuple[str, bytes]]:
pass
class EggFileSource(ConvertSource):
def __init__(self, path: Path):
if not (match := egg_filename_re.match(path.name)):
raise ValueError(f"Invalid egg file name: {path.name}")
# Binary wheels are assumed to be for CPython
self.path = path
self.name = normalize(match.group("name"))
self.version = match.group("ver")
if pyver := match.group("pyver"):
self.pyver = pyver.replace(".", "")
if arch := match.group("arch"):
self.abi = self.pyver.replace("py", "cp")
self.platform = normalize(arch)
self.metadata = Message()
def generate_contents(self) -> Iterator[tuple[str, bytes]]:
with ZipFile(self.path, "r") as zip_file:
for filename in sorted(zip_file.namelist()):
# Skip pure directory entries
if filename.endswith("/"):
continue
# Handle files in the egg-info directory specially, selectively moving
# them to the dist-info directory while converting as needed
if filename.startswith("EGG-INFO/"):
if filename == "EGG-INFO/requires.txt":
requires = zip_file.read(filename).decode("utf-8")
convert_requires(requires, self.metadata)
elif filename == "EGG-INFO/PKG-INFO":
pkginfo = zip_file.read(filename).decode("utf-8")
convert_pkg_info(pkginfo, self.metadata)
elif filename == "EGG-INFO/entry_points.txt":
yield (
f"{self.dist_info_dir}/entry_points.txt",
zip_file.read(filename),
)
continue
# For any other file, just pass it through
yield filename, zip_file.read(filename)
class EggDirectorySource(EggFileSource):
def generate_contents(self) -> Iterator[tuple[str, bytes]]:
for dirpath, _, filenames in os.walk(self.path):
for filename in sorted(filenames):
path = Path(dirpath, filename)
if path.parent.name == "EGG-INFO":
if path.name == "requires.txt":
requires = path.read_text("utf-8")
convert_requires(requires, self.metadata)
elif path.name == "PKG-INFO":
pkginfo = path.read_text("utf-8")
convert_pkg_info(pkginfo, self.metadata)
if name := self.metadata.get("Name"):
self.name = normalize(name)
if version := self.metadata.get("Version"):
self.version = version
elif path.name == "entry_points.txt":
yield (
f"{self.dist_info_dir}/entry_points.txt",
path.read_bytes(),
)
continue
# For any other file, just pass it through
yield str(path.relative_to(self.path)), path.read_bytes()
class WininstFileSource(ConvertSource):
"""
Handles distributions created with ``bdist_wininst``.
The egginfo filename has the format::
name-ver(-pyver)(-arch).egg-info
The installer filename has the format::
name-ver.arch(-pyver).exe
Some things to note:
1. The installer filename is not definitive. An installer can be renamed
and work perfectly well as an installer. So more reliable data should
be used whenever possible.
2. The egg-info data should be preferred for the name and version, because
these come straight from the distutils metadata, and are mandatory.
3. The pyver from the egg-info data should be ignored, as it is
constructed from the version of Python used to build the installer,
which is irrelevant - the installer filename is correct here (even to
the point that when it's not there, any version is implied).
4. The architecture must be taken from the installer filename, as it is
not included in the egg-info data.
5. Architecture-neutral installers still have an architecture because the
installer format itself (being executable) is architecture-specific. We
should therefore ignore the architecture if the content is pure-python.
"""
def __init__(self, path: Path):
self.path = path
self.metadata = Message()
# Determine the initial architecture and Python version from the file name
# (if possible)
if match := wininst_re.search(path.name):
self.platform = normalize(match.group("platform"))
if pyver := match.group("pyver"):
self.pyver = pyver.replace(".", "")
# Look for an .egg-info directory and any .pyd files for more precise info
egg_info_found = pyd_found = False
with ZipFile(self.path) as zip_file:
for filename in zip_file.namelist():
prefix, filename = filename.split("/", 1)
if not egg_info_found and (match := egg_info_re.match(filename)):
egg_info_found = True
self.name = normalize(match.group("name"))
self.version = match.group("ver")
if pyver := match.group("pyver"):
self.pyver = pyver.replace(".", "")
elif not pyd_found and (match := pyd_re.search(filename)):
pyd_found = True
self.abi = match.group("abi")
self.platform = match.group("platform")
if egg_info_found and pyd_found:
break
def generate_contents(self) -> Iterator[tuple[str, bytes]]:
dist_info_dir = f"{self.name}-{self.version}.dist-info"
data_dir = f"{self.name}-{self.version}.data"
with ZipFile(self.path, "r") as zip_file:
for filename in sorted(zip_file.namelist()):
# Skip pure directory entries
if filename.endswith("/"):
continue
# Handle files in the egg-info directory specially, selectively moving
# them to the dist-info directory while converting as needed
prefix, target_filename = filename.split("/", 1)
if egg_info_re.search(target_filename):
basename = target_filename.rsplit("/", 1)[-1]
if basename == "requires.txt":
requires = zip_file.read(filename).decode("utf-8")
convert_requires(requires, self.metadata)
elif basename == "PKG-INFO":
pkginfo = zip_file.read(filename).decode("utf-8")
convert_pkg_info(pkginfo, self.metadata)
elif basename == "entry_points.txt":
yield (
f"{dist_info_dir}/entry_points.txt",
zip_file.read(filename),
)
continue
elif prefix == "SCRIPTS":
target_filename = f"{data_dir}/scripts/{target_filename}"
# For any other file, just pass it through
yield target_filename, zip_file.read(filename)
def convert(files: list[str], dest_dir: str, verbose: bool) -> None:
for pat in files:
for archive in iglob(pat):
path = Path(archive)
if path.suffix == ".egg":
if path.is_dir():
source: ConvertSource = EggDirectorySource(path)
else:
source = EggFileSource(path)
else:
source = WininstFileSource(path)
if verbose:
print(f"{archive}...", flush=True, end="")
dest_path = Path(dest_dir) / (
f"{source.name}-{source.version}-{source.pyver}-{source.abi}"
f"-{source.platform}.whl"
)
with WheelFile(dest_path, "w") as wheelfile:
for name_or_zinfo, contents in source.generate_contents():
wheelfile.writestr(name_or_zinfo, contents)
# Write the METADATA file
wheelfile.writestr(
f"{source.dist_info_dir}/METADATA",
source.metadata.as_string(policy=serialization_policy).encode(
"utf-8"
),
)
# Write the WHEEL file
wheel_message = Message()
wheel_message.add_header("Wheel-Version", "1.0")
wheel_message.add_header("Generator", GENERATOR)
wheel_message.add_header(
"Root-Is-Purelib", str(source.platform == "any").lower()
)
tags = parse_tag(f"{source.pyver}-{source.abi}-{source.platform}")
for tag in sorted(tags, key=lambda tag: tag.interpreter):
wheel_message.add_header("Tag", str(tag))
wheelfile.writestr(
f"{source.dist_info_dir}/WHEEL",
wheel_message.as_string(policy=serialization_policy).encode(
"utf-8"
),
)
if verbose:
print("OK")
from __future__ import annotations
import email.policy
import os.path
import re
from email.generator import BytesGenerator
from email.parser import BytesParser
from wheel.cli import WheelError
from wheel.wheelfile import WheelFile
DIST_INFO_RE = re.compile(r"^(?P<namever>(?P<name>.+?)-(?P<ver>\d.*?))\.dist-info$")
def pack(directory: str, dest_dir: str, build_number: str | None) -> None:
"""Repack a previously unpacked wheel directory into a new wheel file.
The .dist-info/WHEEL file must contain one or more tags so that the target
wheel file name can be determined.
:param directory: The unpacked wheel directory
:param dest_dir: Destination directory (defaults to the current directory)
"""
# Find the .dist-info directory
dist_info_dirs = [
fn
for fn in os.listdir(directory)
if os.path.isdir(os.path.join(directory, fn)) and DIST_INFO_RE.match(fn)
]
if len(dist_info_dirs) > 1:
raise WheelError(f"Multiple .dist-info directories found in {directory}")
elif not dist_info_dirs:
raise WheelError(f"No .dist-info directories found in {directory}")
# Determine the target wheel filename
dist_info_dir = dist_info_dirs[0]
name_version = DIST_INFO_RE.match(dist_info_dir).group("namever")
# Read the tags and the existing build number from .dist-info/WHEEL
wheel_file_path = os.path.join(directory, dist_info_dir, "WHEEL")
with open(wheel_file_path, "rb") as f:
info = BytesParser(policy=email.policy.compat32).parse(f)
tags: list[str] = info.get_all("Tag", [])
existing_build_number = info.get("Build")
if not tags:
raise WheelError(
f"No tags present in {dist_info_dir}/WHEEL; cannot determine target "
f"wheel filename"
)
# Set the wheel file name and add/replace/remove the Build tag in .dist-info/WHEEL
build_number = build_number if build_number is not None else existing_build_number
if build_number is not None:
del info["Build"]
if build_number:
info["Build"] = build_number
name_version += "-" + build_number
if build_number != existing_build_number:
with open(wheel_file_path, "wb") as f:
BytesGenerator(f, maxheaderlen=0).flatten(info)
# Reassemble the tags for the wheel file
tagline = compute_tagline(tags)
# Repack the wheel
wheel_path = os.path.join(dest_dir, f"{name_version}-{tagline}.whl")
with WheelFile(wheel_path, "w") as wf:
print(f"Repacking wheel as {wheel_path}...", end="", flush=True)
wf.write_files(directory)
print("OK")
def compute_tagline(tags: list[str]) -> str:
"""Compute a tagline from a list of tags.
:param tags: A list of tags
:return: A tagline
"""
impls = sorted({tag.split("-")[0] for tag in tags})
abivers = sorted({tag.split("-")[1] for tag in tags})
platforms = sorted({tag.split("-")[2] for tag in tags})
return "-".join([".".join(impls), ".".join(abivers), ".".join(platforms)])
from __future__ import annotations
import email.policy
import itertools
import os
from collections.abc import Iterable
from email.parser import BytesParser
from ..wheelfile import WheelFile
def _compute_tags(original_tags: Iterable[str], new_tags: str | None) -> set[str]:
"""Add or replace tags. Supports dot-separated tags"""
if new_tags is None:
return set(original_tags)
if new_tags.startswith("+"):
return {*original_tags, *new_tags[1:].split(".")}
if new_tags.startswith("-"):
return set(original_tags) - set(new_tags[1:].split("."))
return set(new_tags.split("."))
def tags(
wheel: str,
python_tags: str | None = None,
abi_tags: str | None = None,
platform_tags: str | None = None,
build_tag: str | None = None,
remove: bool = False,
) -> str:
"""Change the tags on a wheel file.
The tags are left unchanged if they are not specified. To specify "none",
use ["none"]. To append to the previous tags, a tag should start with a
"+". If a tag starts with "-", it will be removed from existing tags.
Processing is done left to right.
:param wheel: The paths to the wheels
:param python_tags: The Python tags to set
:param abi_tags: The ABI tags to set
:param platform_tags: The platform tags to set
:param build_tag: The build tag to set
:param remove: Remove the original wheel
"""
with WheelFile(wheel, "r") as f:
assert f.filename, f"{f.filename} must be available"
wheel_info = f.read(f.dist_info_path + "/WHEEL")
info = BytesParser(policy=email.policy.compat32).parsebytes(wheel_info)
original_wheel_name = os.path.basename(f.filename)
namever = f.parsed_filename.group("namever")
build = f.parsed_filename.group("build")
original_python_tags = f.parsed_filename.group("pyver").split(".")
original_abi_tags = f.parsed_filename.group("abi").split(".")
original_plat_tags = f.parsed_filename.group("plat").split(".")
tags: list[str] = info.get_all("Tag", [])
existing_build_tag = info.get("Build")
impls = {tag.split("-")[0] for tag in tags}
abivers = {tag.split("-")[1] for tag in tags}
platforms = {tag.split("-")[2] for tag in tags}
if impls != set(original_python_tags):
msg = f"Wheel internal tags {impls!r} != filename tags {original_python_tags!r}"
raise AssertionError(msg)
if abivers != set(original_abi_tags):
msg = f"Wheel internal tags {abivers!r} != filename tags {original_abi_tags!r}"
raise AssertionError(msg)
if platforms != set(original_plat_tags):
msg = (
f"Wheel internal tags {platforms!r} != filename tags {original_plat_tags!r}"
)
raise AssertionError(msg)
if existing_build_tag != build:
msg = (
f"Incorrect filename '{build}' "
f"& *.dist-info/WHEEL '{existing_build_tag}' build numbers"
)
raise AssertionError(msg)
# Start changing as needed
if build_tag is not None:
build = build_tag
final_python_tags = sorted(_compute_tags(original_python_tags, python_tags))
final_abi_tags = sorted(_compute_tags(original_abi_tags, abi_tags))
final_plat_tags = sorted(_compute_tags(original_plat_tags, platform_tags))
final_tags = [
namever,
".".join(final_python_tags),
".".join(final_abi_tags),
".".join(final_plat_tags),
]
if build:
final_tags.insert(1, build)
final_wheel_name = "-".join(final_tags) + ".whl"
if original_wheel_name != final_wheel_name:
del info["Tag"], info["Build"]
for a, b, c in itertools.product(
final_python_tags, final_abi_tags, final_plat_tags
):
info["Tag"] = f"{a}-{b}-{c}"
if build:
info["Build"] = build
original_wheel_path = os.path.join(
os.path.dirname(f.filename), original_wheel_name
)
final_wheel_path = os.path.join(os.path.dirname(f.filename), final_wheel_name)
with WheelFile(original_wheel_path, "r") as fin, WheelFile(
final_wheel_path, "w"
) as fout:
fout.comment = fin.comment # preserve the comment
for item in fin.infolist():
if item.is_dir():
continue
if item.filename == f.dist_info_path + "/RECORD":
continue
if item.filename == f.dist_info_path + "/WHEEL":
fout.writestr(item, info.as_bytes())
else:
fout.writestr(item, fin.read(item))
if remove:
os.remove(original_wheel_path)
return final_wheel_name
from __future__ import annotations
from pathlib import Path
from ..wheelfile import WheelFile
def unpack(path: str, dest: str = ".") -> None:
"""Unpack a wheel.
Wheel content will be unpacked to {dest}/{name}-{ver}, where {name}
is the package name and {ver} its version.
:param path: The path to the wheel.
:param dest: Destination directory (default to current directory).
"""
with WheelFile(path) as wf:
namever = wf.parsed_filename.group("namever")
destination = Path(dest) / namever
print(f"Unpacking to: {destination}...", end="", flush=True)
for zinfo in wf.filelist:
wf.extract(zinfo, destination)
# Set permissions to the same values as they were set in the archive
# We have to do this manually due to
# https://github.com/python/cpython/issues/59999
permissions = zinfo.external_attr >> 16 & 0o777
destination.joinpath(zinfo.filename).chmod(permissions)
print("OK")
from __future__ import annotations
import base64
import logging
log = logging.getLogger("wheel")
def urlsafe_b64encode(data: bytes) -> bytes:
"""urlsafe_b64encode without padding"""
return base64.urlsafe_b64encode(data).rstrip(b"=")
def urlsafe_b64decode(data: bytes) -> bytes:
"""urlsafe_b64decode without padding"""
pad = b"=" * (4 - (len(data) & 3))
return base64.urlsafe_b64decode(data + pad)
"""
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
"""
import enum
import os
import struct
from typing import IO, Optional, Tuple
class ELFInvalid(ValueError):
pass
class EIClass(enum.IntEnum):
C32 = 1
C64 = 2
class EIData(enum.IntEnum):
Lsb = 1
Msb = 2
class EMachine(enum.IntEnum):
I386 = 3
S390 = 22
Arm = 40
X8664 = 62
AArc64 = 183
class ELFFile:
"""
Representation of an ELF executable.
"""
def __init__(self, f: IO[bytes]) -> None:
self._f = f
try:
ident = self._read("16B")
except struct.error:
raise ELFInvalid("unable to parse identification")
magic = bytes(ident[:4])
if magic != b"\x7fELF":
raise ELFInvalid(f"invalid magic: {magic!r}")
self.capacity = ident[4] # Format for program header (bitness).
self.encoding = ident[5] # Data structure encoding (endianness).
try:
# e_fmt: Format for program header.
# p_fmt: Format for section header.
# p_idx: Indexes to find p_type, p_offset, and p_filesz.
e_fmt, self._p_fmt, self._p_idx = {
(1, 1): ("<HHIIIIIHHH", "<IIIIIIII", (0, 1, 4)), # 32-bit LSB.
(1, 2): (">HHIIIIIHHH", ">IIIIIIII", (0, 1, 4)), # 32-bit MSB.
(2, 1): ("<HHIQQQIHHH", "<IIQQQQQQ", (0, 2, 5)), # 64-bit LSB.
(2, 2): (">HHIQQQIHHH", ">IIQQQQQQ", (0, 2, 5)), # 64-bit MSB.
}[(self.capacity, self.encoding)]
except KeyError:
raise ELFInvalid(
f"unrecognized capacity ({self.capacity}) or "
f"encoding ({self.encoding})"
)
try:
(
_,
self.machine, # Architecture type.
_,
_,
self._e_phoff, # Offset of program header.
_,
self.flags, # Processor-specific flags.
_,
self._e_phentsize, # Size of section.
self._e_phnum, # Number of sections.
) = self._read(e_fmt)
except struct.error as e:
raise ELFInvalid("unable to parse machine and section information") from e
def _read(self, fmt: str) -> Tuple[int, ...]:
return struct.unpack(fmt, self._f.read(struct.calcsize(fmt)))
@property
def interpreter(self) -> Optional[str]:
"""
The path recorded in the ``PT_INTERP`` section header.
"""
for index in range(self._e_phnum):
self._f.seek(self._e_phoff + self._e_phentsize * index)
try:
data = self._read(self._p_fmt)
except struct.error:
continue
if data[self._p_idx[0]] != 3: # Not PT_INTERP.
continue
self._f.seek(data[self._p_idx[1]])
return os.fsdecode(self._f.read(data[self._p_idx[2]])).strip("\0")
return None
import collections
import contextlib
import functools
import os
import re
import sys
import warnings
from typing import Dict, Generator, Iterator, NamedTuple, Optional, Sequence, Tuple
from ._elffile import EIClass, EIData, ELFFile, EMachine
EF_ARM_ABIMASK = 0xFF000000
EF_ARM_ABI_VER5 = 0x05000000
EF_ARM_ABI_FLOAT_HARD = 0x00000400
# `os.PathLike` not a generic type until Python 3.9, so sticking with `str`
# as the type for `path` until then.
@contextlib.contextmanager
def _parse_elf(path: str) -> Generator[Optional[ELFFile], None, None]:
try:
with open(path, "rb") as f:
yield ELFFile(f)
except (OSError, TypeError, ValueError):
yield None
def _is_linux_armhf(executable: str) -> bool:
# hard-float ABI can be detected from the ELF header of the running
# process
# https://static.docs.arm.com/ihi0044/g/aaelf32.pdf
with _parse_elf(executable) as f:
return (
f is not None
and f.capacity == EIClass.C32
and f.encoding == EIData.Lsb
and f.machine == EMachine.Arm
and f.flags & EF_ARM_ABIMASK == EF_ARM_ABI_VER5
and f.flags & EF_ARM_ABI_FLOAT_HARD == EF_ARM_ABI_FLOAT_HARD
)
def _is_linux_i686(executable: str) -> bool:
with _parse_elf(executable) as f:
return (
f is not None
and f.capacity == EIClass.C32
and f.encoding == EIData.Lsb
and f.machine == EMachine.I386
)
def _have_compatible_abi(executable: str, archs: Sequence[str]) -> bool:
if "armv7l" in archs:
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)
# If glibc ever changes its major version, we need to know what the last
# minor version was, so we can build the complete list of all versions.
# For now, guess what the highest minor version might be, assume it will
# be 50 for testing. Once this actually happens, update the dictionary
# with the actual value.
_LAST_GLIBC_MINOR: Dict[int, int] = collections.defaultdict(lambda: 50)
class _GLibCVersion(NamedTuple):
major: int
minor: int
def _glibc_version_string_confstr() -> Optional[str]:
"""
Primary implementation of glibc_version_string using os.confstr.
"""
# os.confstr is quite a bit faster than ctypes.DLL. It's also less likely
# to be broken or missing. This strategy is used in the standard library
# platform module.
# https://github.com/python/cpython/blob/fcf1d003bf4f0100c/Lib/platform.py#L175-L183
try:
# Should be a string like "glibc 2.17".
version_string: Optional[str] = os.confstr("CS_GNU_LIBC_VERSION")
assert version_string is not None
_, version = version_string.rsplit()
except (AssertionError, AttributeError, OSError, ValueError):
# os.confstr() or CS_GNU_LIBC_VERSION not available (or a bad value)...
return None
return version
def _glibc_version_string_ctypes() -> Optional[str]:
"""
Fallback implementation of glibc_version_string using ctypes.
"""
try:
import ctypes
except ImportError:
return None
# ctypes.CDLL(None) internally calls dlopen(NULL), and as the dlopen
# manpage says, "If filename is NULL, then the returned handle is for the
# main program". This way we can let the linker do the work to figure out
# which libc our process is actually using.
#
# We must also handle the special case where the executable is not a
# dynamically linked executable. This can occur when using musl libc,
# for example. In this situation, dlopen() will error, leading to an
# OSError. Interestingly, at least in the case of musl, there is no
# errno set on the OSError. The single string argument used to construct
# OSError comes from libc itself and is therefore not portable to
# hard code here. In any case, failure to call dlopen() means we
# can proceed, so we bail on our attempt.
try:
process_namespace = ctypes.CDLL(None)
except OSError:
return None
try:
gnu_get_libc_version = process_namespace.gnu_get_libc_version
except AttributeError:
# Symbol doesn't exist -> therefore, we are not linked to
# glibc.
return None
# Call gnu_get_libc_version, which returns a string like "2.5"
gnu_get_libc_version.restype = ctypes.c_char_p
version_str: str = gnu_get_libc_version()
# py2 / py3 compatibility:
if not isinstance(version_str, str):
version_str = version_str.decode("ascii")
return version_str
def _glibc_version_string() -> Optional[str]:
"""Returns glibc version string, or None if not using glibc."""
return _glibc_version_string_confstr() or _glibc_version_string_ctypes()
def _parse_glibc_version(version_str: str) -> Tuple[int, int]:
"""Parse glibc version.
We use a regexp instead of str.split because we want to discard any
random junk that might come after the minor version -- this might happen
in patched/forked versions of glibc (e.g. Linaro's version of glibc
uses version strings like "2.20-2014.11"). See gh-3588.
"""
m = re.match(r"(?P<major>[0-9]+)\.(?P<minor>[0-9]+)", version_str)
if not m:
warnings.warn(
f"Expected glibc version with 2 components major.minor,"
f" got: {version_str}",
RuntimeWarning,
)
return -1, -1
return int(m.group("major")), int(m.group("minor"))
@functools.lru_cache
def _get_glibc_version() -> Tuple[int, int]:
version_str = _glibc_version_string()
if version_str is None:
return (-1, -1)
return _parse_glibc_version(version_str)
# From PEP 513, PEP 600
def _is_compatible(arch: str, version: _GLibCVersion) -> bool:
sys_glibc = _get_glibc_version()
if sys_glibc < version:
return False
# Check for presence of _manylinux module.
try:
import _manylinux
except ImportError:
return True
if hasattr(_manylinux, "manylinux_compatible"):
result = _manylinux.manylinux_compatible(version[0], version[1], arch)
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)
return True
_LEGACY_MANYLINUX_MAP = {
# CentOS 7 w/ glibc 2.17 (PEP 599)
(2, 17): "manylinux2014",
# CentOS 6 w/ glibc 2.12 (PEP 571)
(2, 12): "manylinux2010",
# CentOS 5 w/ glibc 2.5 (PEP 513)
(2, 5): "manylinux1",
}
def platform_tags(archs: Sequence[str]) -> Iterator[str]:
"""Generate manylinux tags compatible to the current platform.
:param archs: Sequence of compatible architectures.
The first one shall be the closest to the actual architecture and be the part of
platform tag after the ``linux_`` prefix, e.g. ``x86_64``.
The ``linux_`` prefix is assumed as a prerequisite for the current platform to
be manylinux-compatible.
:returns: An iterator of compatible manylinux tags.
"""
if not _have_compatible_abi(sys.executable, archs):
return
# Oldest glibc to be supported regardless of architecture is (2, 17).
too_old_glibc2 = _GLibCVersion(2, 16)
if set(archs) & {"x86_64", "i686"}:
# On x86/i686 also oldest glibc to be supported is (2, 5).
too_old_glibc2 = _GLibCVersion(2, 4)
current_glibc = _GLibCVersion(*_get_glibc_version())
glibc_max_list = [current_glibc]
# We can assume compatibility across glibc major versions.
# https://sourceware.org/bugzilla/show_bug.cgi?id=24636
#
# Build a list of maximum glibc versions so that we can
# output the canonical list of all glibc from current_glibc
# down to too_old_glibc2, including all intermediary versions.
for glibc_major in range(current_glibc.major - 1, 1, -1):
glibc_minor = _LAST_GLIBC_MINOR[glibc_major]
glibc_max_list.append(_GLibCVersion(glibc_major, glibc_minor))
for arch in archs:
for glibc_max in glibc_max_list:
if glibc_max.major == too_old_glibc2.major:
min_minor = too_old_glibc2.minor
else:
# For other glibc major versions oldest supported is (x, 0).
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 f"{legacy_tag}_{arch}"
"""PEP 656 support.
This module implements logic to detect if the currently running Python is
linked against musl, and what musl version is used.
"""
import functools
import re
import subprocess
import sys
from typing import Iterator, NamedTuple, Optional, Sequence
from ._elffile import ELFFile
class _MuslVersion(NamedTuple):
major: int
minor: int
def _parse_musl_version(output: str) -> Optional[_MuslVersion]:
lines = [n for n in (n.strip() for n in output.splitlines()) if n]
if len(lines) < 2 or lines[0][:4] != "musl":
return None
m = re.match(r"Version (\d+)\.(\d+)", lines[1])
if not m:
return None
return _MuslVersion(major=int(m.group(1)), minor=int(m.group(2)))
@functools.lru_cache
def _get_musl_version(executable: str) -> Optional[_MuslVersion]:
"""Detect currently-running musl runtime version.
This is done by checking the specified executable's dynamic linking
information, and invoking the loader to parse its output for a version
string. If the loader is musl, the output would be something like::
musl libc (x86_64)
Version 1.2.2
Dynamic Program Loader
"""
try:
with open(executable, "rb") as f:
ld = ELFFile(f).interpreter
except (OSError, TypeError, ValueError):
return None
if ld is None or "musl" not in ld:
return None
proc = subprocess.run([ld], stderr=subprocess.PIPE, text=True)
return _parse_musl_version(proc.stderr)
def platform_tags(archs: Sequence[str]) -> Iterator[str]:
"""Generate musllinux tags compatible to the current platform.
:param archs: Sequence of compatible architectures.
The first one shall be the closest to the actual architecture and be the part of
platform tag after the ``linux_`` prefix, e.g. ``x86_64``.
The ``linux_`` prefix is assumed as a prerequisite for the current platform to
be musllinux-compatible.
:returns: An iterator of compatible musllinux tags.
"""
sys_musl = _get_musl_version(sys.executable)
if sys_musl is None: # Python not dynamically linked against musl.
return
for arch in archs:
for minor in range(sys_musl.minor, -1, -1):
yield f"musllinux_{sys_musl.major}_{minor}_{arch}"
if __name__ == "__main__": # pragma: no cover
import sysconfig
plat = sysconfig.get_platform()
assert plat.startswith("linux-"), "not linux"
print("plat:", plat)
print("musl:", _get_musl_version(sys.executable))
print("tags:", end=" ")
for t in platform_tags(re.sub(r"[.-]", "_", plat.split("-", 1)[-1])):
print(t, end="\n ")
"""Handwritten parser of dependency specifiers.
The docstring for each __parse_* function contains EBNF-inspired grammar representing
the implementation.
"""
import ast
from typing import Any, List, NamedTuple, Optional, Tuple, Union
from ._tokenizer import DEFAULT_RULES, Tokenizer
class Node:
def __init__(self, value: str) -> None:
self.value = value
def __str__(self) -> str:
return self.value
def __repr__(self) -> str:
return f"<{self.__class__.__name__}('{self}')>"
def serialize(self) -> str:
raise NotImplementedError
class Variable(Node):
def serialize(self) -> str:
return str(self)
class Value(Node):
def serialize(self) -> str:
return f'"{self}"'
class Op(Node):
def serialize(self) -> str:
return str(self)
MarkerVar = Union[Variable, Value]
MarkerItem = Tuple[MarkerVar, Op, MarkerVar]
# MarkerAtom = Union[MarkerItem, List["MarkerAtom"]]
# MarkerList = List[Union["MarkerList", MarkerAtom, str]]
# mypy does not support recursive type definition
# https://github.com/python/mypy/issues/731
MarkerAtom = Any
MarkerList = List[Any]
class ParsedRequirement(NamedTuple):
name: str
url: str
extras: List[str]
specifier: str
marker: Optional[MarkerList]
# --------------------------------------------------------------------------------------
# Recursive descent parser for dependency specifier
# --------------------------------------------------------------------------------------
def parse_requirement(source: str) -> ParsedRequirement:
return _parse_requirement(Tokenizer(source, rules=DEFAULT_RULES))
def _parse_requirement(tokenizer: Tokenizer) -> ParsedRequirement:
"""
requirement = WS? IDENTIFIER WS? extras WS? requirement_details
"""
tokenizer.consume("WS")
name_token = tokenizer.expect(
"IDENTIFIER", expected="package name at the start of dependency specifier"
)
name = name_token.text
tokenizer.consume("WS")
extras = _parse_extras(tokenizer)
tokenizer.consume("WS")
url, specifier, marker = _parse_requirement_details(tokenizer)
tokenizer.expect("END", expected="end of dependency specifier")
return ParsedRequirement(name, url, extras, specifier, marker)
def _parse_requirement_details(
tokenizer: Tokenizer,
) -> Tuple[str, str, Optional[MarkerList]]:
"""
requirement_details = AT URL (WS requirement_marker?)?
| specifier WS? (requirement_marker)?
"""
specifier = ""
url = ""
marker = None
if tokenizer.check("AT"):
tokenizer.read()
tokenizer.consume("WS")
url_start = tokenizer.position
url = tokenizer.expect("URL", expected="URL after @").text
if tokenizer.check("END", peek=True):
return (url, specifier, marker)
tokenizer.expect("WS", expected="whitespace after URL")
# The input might end after whitespace.
if tokenizer.check("END", peek=True):
return (url, specifier, marker)
marker = _parse_requirement_marker(
tokenizer, span_start=url_start, after="URL and whitespace"
)
else:
specifier_start = tokenizer.position
specifier = _parse_specifier(tokenizer)
tokenizer.consume("WS")
if tokenizer.check("END", peek=True):
return (url, specifier, marker)
marker = _parse_requirement_marker(
tokenizer,
span_start=specifier_start,
after=(
"version specifier"
if specifier
else "name and no valid version specifier"
),
)
return (url, specifier, marker)
def _parse_requirement_marker(
tokenizer: Tokenizer, *, span_start: int, after: str
) -> MarkerList:
"""
requirement_marker = SEMICOLON marker WS?
"""
if not tokenizer.check("SEMICOLON"):
tokenizer.raise_syntax_error(
f"Expected end or semicolon (after {after})",
span_start=span_start,
)
tokenizer.read()
marker = _parse_marker(tokenizer)
tokenizer.consume("WS")
return marker
def _parse_extras(tokenizer: Tokenizer) -> List[str]:
"""
extras = (LEFT_BRACKET wsp* extras_list? wsp* RIGHT_BRACKET)?
"""
if not tokenizer.check("LEFT_BRACKET", peek=True):
return []
with tokenizer.enclosing_tokens(
"LEFT_BRACKET",
"RIGHT_BRACKET",
around="extras",
):
tokenizer.consume("WS")
extras = _parse_extras_list(tokenizer)
tokenizer.consume("WS")
return extras
def _parse_extras_list(tokenizer: Tokenizer) -> List[str]:
"""
extras_list = identifier (wsp* ',' wsp* identifier)*
"""
extras: List[str] = []
if not tokenizer.check("IDENTIFIER"):
return extras
extras.append(tokenizer.read().text)
while True:
tokenizer.consume("WS")
if tokenizer.check("IDENTIFIER", peek=True):
tokenizer.raise_syntax_error("Expected comma between extra names")
elif not tokenizer.check("COMMA"):
break
tokenizer.read()
tokenizer.consume("WS")
extra_token = tokenizer.expect("IDENTIFIER", expected="extra name after comma")
extras.append(extra_token.text)
return extras
def _parse_specifier(tokenizer: Tokenizer) -> str:
"""
specifier = LEFT_PARENTHESIS WS? version_many WS? RIGHT_PARENTHESIS
| WS? version_many WS?
"""
with tokenizer.enclosing_tokens(
"LEFT_PARENTHESIS",
"RIGHT_PARENTHESIS",
around="version specifier",
):
tokenizer.consume("WS")
parsed_specifiers = _parse_version_many(tokenizer)
tokenizer.consume("WS")
return parsed_specifiers
def _parse_version_many(tokenizer: Tokenizer) -> str:
"""
version_many = (SPECIFIER (WS? COMMA WS? SPECIFIER)*)?
"""
parsed_specifiers = ""
while tokenizer.check("SPECIFIER"):
span_start = tokenizer.position
parsed_specifiers += tokenizer.read().text
if tokenizer.check("VERSION_PREFIX_TRAIL", peek=True):
tokenizer.raise_syntax_error(
".* suffix can only be used with `==` or `!=` operators",
span_start=span_start,
span_end=tokenizer.position + 1,
)
if tokenizer.check("VERSION_LOCAL_LABEL_TRAIL", peek=True):
tokenizer.raise_syntax_error(
"Local version label can only be used with `==` or `!=` operators",
span_start=span_start,
span_end=tokenizer.position,
)
tokenizer.consume("WS")
if not tokenizer.check("COMMA"):
break
parsed_specifiers += tokenizer.read().text
tokenizer.consume("WS")
return parsed_specifiers
# --------------------------------------------------------------------------------------
# Recursive descent parser for marker expression
# --------------------------------------------------------------------------------------
def parse_marker(source: str) -> MarkerList:
return _parse_full_marker(Tokenizer(source, rules=DEFAULT_RULES))
def _parse_full_marker(tokenizer: Tokenizer) -> MarkerList:
retval = _parse_marker(tokenizer)
tokenizer.expect("END", expected="end of marker expression")
return retval
def _parse_marker(tokenizer: Tokenizer) -> MarkerList:
"""
marker = marker_atom (BOOLOP marker_atom)+
"""
expression = [_parse_marker_atom(tokenizer)]
while tokenizer.check("BOOLOP"):
token = tokenizer.read()
expr_right = _parse_marker_atom(tokenizer)
expression.extend((token.text, expr_right))
return expression
def _parse_marker_atom(tokenizer: Tokenizer) -> MarkerAtom:
"""
marker_atom = WS? LEFT_PARENTHESIS WS? marker WS? RIGHT_PARENTHESIS WS?
| WS? marker_item WS?
"""
tokenizer.consume("WS")
if tokenizer.check("LEFT_PARENTHESIS", peek=True):
with tokenizer.enclosing_tokens(
"LEFT_PARENTHESIS",
"RIGHT_PARENTHESIS",
around="marker expression",
):
tokenizer.consume("WS")
marker: MarkerAtom = _parse_marker(tokenizer)
tokenizer.consume("WS")
else:
marker = _parse_marker_item(tokenizer)
tokenizer.consume("WS")
return marker
def _parse_marker_item(tokenizer: Tokenizer) -> MarkerItem:
"""
marker_item = WS? marker_var WS? marker_op WS? marker_var WS?
"""
tokenizer.consume("WS")
marker_var_left = _parse_marker_var(tokenizer)
tokenizer.consume("WS")
marker_op = _parse_marker_op(tokenizer)
tokenizer.consume("WS")
marker_var_right = _parse_marker_var(tokenizer)
tokenizer.consume("WS")
return (marker_var_left, marker_op, marker_var_right)
def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar:
"""
marker_var = VARIABLE | QUOTED_STRING
"""
if tokenizer.check("VARIABLE"):
return process_env_var(tokenizer.read().text.replace(".", "_"))
elif tokenizer.check("QUOTED_STRING"):
return process_python_str(tokenizer.read().text)
else:
tokenizer.raise_syntax_error(
message="Expected a marker variable or quoted string"
)
def process_env_var(env_var: str) -> Variable:
if env_var in ("platform_python_implementation", "python_implementation"):
return Variable("platform_python_implementation")
else:
return Variable(env_var)
def process_python_str(python_str: str) -> Value:
value = ast.literal_eval(python_str)
return Value(str(value))
def _parse_marker_op(tokenizer: Tokenizer) -> Op:
"""
marker_op = IN | NOT IN | OP
"""
if tokenizer.check("IN"):
tokenizer.read()
return Op("in")
elif tokenizer.check("NOT"):
tokenizer.read()
tokenizer.expect("WS", expected="whitespace after 'not'")
tokenizer.expect("IN", expected="'in' after 'not'")
return Op("not in")
elif tokenizer.check("OP"):
return Op(tokenizer.read().text)
else:
return tokenizer.raise_syntax_error(
"Expected marker operator, one of "
"<=, <, !=, ==, >=, >, ~=, ===, in, not in"
)
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
class InfinityType:
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:
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
NegativeInfinity = NegativeInfinityType()
import contextlib
import re
from dataclasses import dataclass
from typing import Dict, Iterator, NoReturn, Optional, Tuple, Union
from .specifiers import Specifier
@dataclass
class Token:
name: str
text: str
position: int
class ParserSyntaxError(Exception):
"""The provided source text could not be parsed correctly."""
def __init__(
self,
message: str,
*,
source: str,
span: Tuple[int, int],
) -> None:
self.span = span
self.message = message
self.source = source
super().__init__()
def __str__(self) -> str:
marker = " " * self.span[0] + "~" * (self.span[1] - self.span[0]) + "^"
return "\n ".join([self.message, self.source, marker])
DEFAULT_RULES: "Dict[str, Union[str, re.Pattern[str]]]" = {
"LEFT_PARENTHESIS": r"\(",
"RIGHT_PARENTHESIS": r"\)",
"LEFT_BRACKET": r"\[",
"RIGHT_BRACKET": r"\]",
"SEMICOLON": r";",
"COMMA": r",",
"QUOTED_STRING": re.compile(
r"""
(
('[^']*')
|
("[^"]*")
)
""",
re.VERBOSE,
),
"OP": r"(===|==|~=|!=|<=|>=|<|>)",
"BOOLOP": r"\b(or|and)\b",
"IN": r"\bin\b",
"NOT": r"\bnot\b",
"VARIABLE": re.compile(
r"""
\b(
python_version
|python_full_version
|os[._]name
|sys[._]platform
|platform_(release|system)
|platform[._](version|machine|python_implementation)
|python_implementation
|implementation_(name|version)
|extra
)\b
""",
re.VERBOSE,
),
"SPECIFIER": re.compile(
Specifier._operator_regex_str + Specifier._version_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"$",
}
class Tokenizer:
"""Context-sensitive token parsing.
Provides methods to examine the input stream to check whether the next token
matches.
"""
def __init__(
self,
source: str,
*,
rules: "Dict[str, Union[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.next_token: Optional[Token] = None
self.position = 0
def consume(self, name: str) -> None:
"""Move beyond provided token name, if at current position."""
if self.check(name):
self.read()
def check(self, name: str, *, peek: bool = False) -> bool:
"""Check whether the next token has the provided name.
By default, if the check succeeds, the token *must* be read before
another check. If `peek` is set to `True`, the token is not loaded and
would need to be checked again.
"""
assert (
self.next_token is None
), f"Cannot check for {name!r}, already have {self.next_token!r}"
assert name in self.rules, f"Unknown token name: {name!r}"
expression = self.rules[name]
match = expression.match(self.source, self.position)
if match is None:
return False
if not peek:
self.next_token = Token(name, match[0], self.position)
return True
def expect(self, name: str, *, expected: str) -> Token:
"""Expect a certain token name next, failing with a syntax error otherwise.
The token is *not* read.
"""
if not self.check(name):
raise self.raise_syntax_error(f"Expected {expected}")
return self.read()
def read(self) -> Token:
"""Consume the next token and return it."""
token = self.next_token
assert token is not None
self.position += len(token.text)
self.next_token = None
return token
def raise_syntax_error(
self,
message: str,
*,
span_start: Optional[int] = None,
span_end: Optional[int] = None,
) -> NoReturn:
"""Raise ParserSyntaxError at the given position."""
span = (
self.position if span_start is None else span_start,
self.position if span_end is None else span_end,
)
raise ParserSyntaxError(
message,
source=self.source,
span=span,
)
@contextlib.contextmanager
def enclosing_tokens(
self, open_token: str, close_token: str, *, around: str
) -> Iterator[None]:
if self.check(open_token):
open_position = self.position
self.read()
else:
open_position = None
yield
if open_position is None:
return
if not self.check(close_token):
self.raise_syntax_error(
f"Expected matching {close_token} for {open_token}, after {around}",
span_start=open_position,
)
self.read()
This software is made available under the terms of *either* of the licenses
found in LICENSE.APACHE or LICENSE.BSD. Contributions to this software is made
under the terms of *both* these licenses.

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
import operator
import os
import platform
import sys
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from ._parser import (
MarkerAtom,
MarkerList,
Op,
Value,
Variable,
)
from ._parser import (
parse_marker as _parse_marker,
)
from ._tokenizer import ParserSyntaxError
from .specifiers import InvalidSpecifier, Specifier
from .utils import canonicalize_name
__all__ = [
"InvalidMarker",
"UndefinedComparison",
"UndefinedEnvironmentName",
"Marker",
"default_environment",
]
Operator = Callable[[str, str], bool]
class InvalidMarker(ValueError):
"""
An invalid marker was found, users should refer to PEP 508.
"""
class UndefinedComparison(ValueError):
"""
An invalid operation was attempted on a value that doesn't support it.
"""
class UndefinedEnvironmentName(ValueError):
"""
A name was attempted to be used that does not exist inside of the
environment.
"""
def _normalize_extra_values(results: Any) -> Any:
"""
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
def _format_marker(
marker: Union[List[str], MarkerAtom, str], first: Optional[bool] = True
) -> str:
assert isinstance(marker, (list, tuple, str))
# Sometimes we have a structure like [[...]] which is a single item list
# where the single item is itself it's own list. In that case we want skip
# the rest of this function so that we don't get extraneous () on the
# outside.
if (
isinstance(marker, list)
and len(marker) == 1
and isinstance(marker[0], (list, tuple))
):
return _format_marker(marker[0])
if isinstance(marker, list):
inner = (_format_marker(m, first=False) for m in marker)
if first:
return " ".join(inner)
else:
return "(" + " ".join(inner) + ")"
elif isinstance(marker, tuple):
return " ".join([m.serialize() for m in marker])
else:
return 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,
"==": operator.eq,
"!=": operator.ne,
">=": operator.ge,
">": operator.gt,
}
def _eval_op(lhs: str, op: Op, rhs: str) -> bool:
try:
spec = Specifier("".join([op.serialize(), rhs]))
except InvalidSpecifier:
pass
else:
return spec.contains(lhs, prereleases=True)
oper: Optional[Operator] = _operators.get(op.serialize())
if oper is None:
raise UndefinedComparison(f"Undefined {op!r} on {lhs!r} and {rhs!r}.")
return oper(lhs, rhs)
def _normalize(*values: str, key: str) -> Tuple[str, ...]:
# 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":
return tuple(canonicalize_name(v) for v in values)
# other environment markers don't have such standards
return values
def _evaluate_markers(markers: MarkerList, environment: Dict[str, str]) -> bool:
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):
lhs, op, rhs = marker
if isinstance(lhs, Variable):
environment_key = lhs.value
lhs_value = environment[environment_key]
rhs_value = rhs.value
else:
lhs_value = lhs.value
environment_key = rhs.value
rhs_value = environment[environment_key]
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([])
return any(all(item) for item in groups)
def format_full_version(info: "sys._version_info") -> str:
version = "{0.major}.{0.minor}.{0.micro}".format(info)
kind = info.releaselevel
if kind != "final":
version += kind[0] + str(info.serial)
return version
def default_environment() -> Dict[str, str]:
iver = format_full_version(sys.implementation.version)
implementation_name = sys.implementation.name
return {
"implementation_name": implementation_name,
"implementation_version": iver,
"os_name": os.name,
"platform_machine": platform.machine(),
"platform_release": platform.release(),
"platform_system": platform.system(),
"platform_version": platform.version(),
"python_full_version": platform.python_version(),
"platform_python_implementation": platform.python_implementation(),
"python_version": ".".join(platform.python_version_tuple()[:2]),
"sys_platform": sys.platform,
}
class Marker:
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.
try:
self._markers = _normalize_extra_values(_parse_marker(marker))
# The attribute `_markers` can be described in terms of a recursive type:
# MarkerList = List[Union[Tuple[Node, ...], str, MarkerList]]
#
# For example, the following expression:
# python_version > "3.6" or (python_version == "3.6" and os_name == "unix")
#
# is parsed into:
# [
# (<Variable('python_version')>, <Op('>')>, <Value('3.6')>),
# 'and',
# [
# (<Variable('python_version')>, <Op('==')>, <Value('3.6')>),
# 'or',
# (<Variable('os_name')>, <Op('==')>, <Value('unix')>)
# ]
# ]
except ParserSyntaxError as e:
raise InvalidMarker(str(e)) from e
def __str__(self) -> str:
return _format_marker(self._markers)
def __repr__(self) -> str:
return f"<Marker('{self}')>"
def __hash__(self) -> int:
return hash((self.__class__.__name__, str(self)))
def __eq__(self, other: Any) -> bool:
if not isinstance(other, Marker):
return NotImplemented
return str(self) == str(other)
def evaluate(self, environment: Optional[Dict[str, str]] = None) -> 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 environment is determined from the current Python process.
"""
current_environment = default_environment()
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 current_environment["extra"] is None:
current_environment["extra"] = ""
return _evaluate_markers(self._markers, current_environment)
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
from typing import Any, Iterator, Optional, Set
from ._parser import parse_requirement as _parse_requirement
from ._tokenizer import ParserSyntaxError
from .markers import Marker, _normalize_extra_values
from .specifiers import SpecifierSet
from .utils import canonicalize_name
class InvalidRequirement(ValueError):
"""
An invalid requirement was found, users should refer to PEP 508.
"""
class Requirement:
"""Parse a requirement.
Parse a given requirement string into its parts, such as name, specifier,
URL, and extras. Raises InvalidRequirement on a badly-formed requirement
string.
"""
# TODO: Can we test whether something is contained within a requirement?
# If so how do we do that? Do we need to test against the _name_ of
# the thing as well as the version? What about the markers?
# TODO: Can we normalize the name and extra name?
def __init__(self, requirement_string: str) -> None:
try:
parsed = _parse_requirement(requirement_string)
except ParserSyntaxError as e:
raise InvalidRequirement(str(e)) from e
self.name: str = parsed.name
self.url: Optional[str] = parsed.url or None
self.extras: Set[str] = set(parsed.extras or [])
self.specifier: SpecifierSet = SpecifierSet(parsed.specifier)
self.marker: Optional[Marker] = None
if parsed.marker is not None:
self.marker = Marker.__new__(Marker)
self.marker._markers = _normalize_extra_values(parsed.marker)
def _iter_parts(self, name: str) -> Iterator[str]:
yield name
if self.extras:
formatted_extras = ",".join(sorted(self.extras))
yield f"[{formatted_extras}]"
if self.specifier:
yield str(self.specifier)
if self.url:
yield f"@ {self.url}"
if self.marker:
yield " "
if self.marker:
yield f"; {self.marker}"
def __str__(self) -> str:
return "".join(self._iter_parts(self.name))
def __repr__(self) -> str:
return f"<Requirement('{self}')>"
def __hash__(self) -> int:
return hash(
(
self.__class__.__name__,
*self._iter_parts(canonicalize_name(self.name)),
)
)
def __eq__(self, other: Any) -> bool:
if not isinstance(other, Requirement):
return NotImplemented
return (
canonicalize_name(self.name) == canonicalize_name(other.name)
and self.extras == other.extras
and self.specifier == other.specifier
and self.url == other.url
and self.marker == other.marker
)
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
"""
.. testsetup::
from packaging.specifiers import Specifier, SpecifierSet, InvalidSpecifier
from packaging.version import Version
"""
import abc
import itertools
import re
from typing import Callable, Iterable, Iterator, List, Optional, Tuple, TypeVar, Union
from .utils import canonicalize_version
from .version import Version
UnparsedVersion = Union[Version, str]
UnparsedVersionVar = TypeVar("UnparsedVersionVar", bound=UnparsedVersion)
CallableOperator = Callable[[Version, str], bool]
def _coerce_version(version: UnparsedVersion) -> Version:
if not isinstance(version, Version):
version = Version(version)
return version
class InvalidSpecifier(ValueError):
"""
Raised when attempting to create a :class:`Specifier` with a specifier
string that is invalid.
>>> Specifier("lolwat")
Traceback (most recent call last):
...
packaging.specifiers.InvalidSpecifier: Invalid specifier: 'lolwat'
"""
class BaseSpecifier(metaclass=abc.ABCMeta):
@abc.abstractmethod
def __str__(self) -> str:
"""
Returns the str representation of this Specifier-like object. This
should be representative of the Specifier itself.
"""
@abc.abstractmethod
def __hash__(self) -> int:
"""
Returns a hash value for this Specifier-like object.
"""
@abc.abstractmethod
def __eq__(self, other: object) -> bool:
"""
Returns a boolean representing whether or not the two Specifier-like
objects are equal.
:param other: The other object to check against.
"""
@property
@abc.abstractmethod
def prereleases(self) -> Optional[bool]:
"""Whether or not pre-releases as a whole are allowed.
This can be set to either ``True`` or ``False`` to explicitly enable or disable
prereleases or it can be set to ``None`` (the default) to use default semantics.
"""
@prereleases.setter
def prereleases(self, value: bool) -> None:
"""Setter for :attr:`prereleases`.
:param value: The value to set.
"""
@abc.abstractmethod
def contains(self, item: str, prereleases: Optional[bool] = None) -> bool:
"""
Determines if the given item is contained within this specifier.
"""
@abc.abstractmethod
def filter(
self, iterable: Iterable[UnparsedVersionVar], prereleases: Optional[bool] = None
) -> Iterator[UnparsedVersionVar]:
"""
Takes an iterable of items and filters them so that only items which
are contained within this specifier are allowed in it.
"""
class Specifier(BaseSpecifier):
"""This class abstracts handling of version specifiers.
.. tip::
It is generally not required to instantiate this manually. You should instead
prefer to work with :class:`SpecifierSet` instead, which can parse
comma-separated version specifiers (which is what package metadata contains).
"""
_operator_regex_str = r"""
(?P<operator>(~=|==|!=|<=|>=|<|>|===))
"""
_version_regex_str = r"""
(?P<version>
(?:
# The identity operators allow for an escape hatch that will
# do an exact string match of the version you wish to install.
# This will not be parsed by PEP 440 and we cannot determine
# any semantic meaning from it. This operator is discouraged
# but included entirely as an escape hatch.
(?<====) # Only match for the identity operator
\s*
[^\s;)]* # The arbitrary version can be just about anything,
# we match everything except for whitespace, a
# semi-colon for marker support, and a closing paren
# since versions can be enclosed in them.
)
|
(?:
# The (non)equality operators allow for wild card and local
# versions to be specified so we have to define these two
# operators separately to enable that.
(?<===|!=) # Only match for equals and not equals
\s*
v?
(?:[0-9]+!)? # epoch
[0-9]+(?:\.[0-9]+)* # release
# You cannot use a wild card and a pre-release, post-release, a dev or
# local version together so group them with a | and make them optional.
(?:
\.\* # Wild card syntax of .*
|
(?: # pre release
[-_\.]?
(alpha|beta|preview|pre|a|b|c|rc)
[-_\.]?
[0-9]*
)?
(?: # post release
(?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*)
)?
(?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release
(?:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)? # local
)?
)
|
(?:
# The compatible operator requires at least two digits in the
# release segment.
(?<=~=) # Only match for the compatible operator
\s*
v?
(?:[0-9]+!)? # epoch
[0-9]+(?:\.[0-9]+)+ # release (We have a + instead of a *)
(?: # pre release
[-_\.]?
(alpha|beta|preview|pre|a|b|c|rc)
[-_\.]?
[0-9]*
)?
(?: # post release
(?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*)
)?
(?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release
)
|
(?:
# All other operators only allow a sub set of what the
# (non)equality operators do. Specifically they do not allow
# local versions to be specified nor do they allow the prefix
# matching wild cards.
(?<!==|!=|~=) # We have special cases for these
# operators so we want to make sure they
# don't match here.
\s*
v?
(?:[0-9]+!)? # epoch
[0-9]+(?:\.[0-9]+)* # release
(?: # pre release
[-_\.]?
(alpha|beta|preview|pre|a|b|c|rc)
[-_\.]?
[0-9]*
)?
(?: # post release
(?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*)
)?
(?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release
)
)
"""
_regex = re.compile(
r"^\s*" + _operator_regex_str + _version_regex_str + r"\s*$",
re.VERBOSE | re.IGNORECASE,
)
_operators = {
"~=": "compatible",
"==": "equal",
"!=": "not_equal",
"<=": "less_than_equal",
">=": "greater_than_equal",
"<": "less_than",
">": "greater_than",
"===": "arbitrary",
}
def __init__(self, spec: str = "", prereleases: Optional[bool] = None) -> None:
"""Initialize a Specifier instance.
:param spec:
The string representation of a specifier which will be parsed and
normalized before use.
:param prereleases:
This tells the specifier if it should accept prerelease versions if
applicable or not. The default of ``None`` will autodetect it from the
given specifiers.
:raises InvalidSpecifier:
If the given specifier is invalid (i.e. bad syntax).
"""
match = self._regex.search(spec)
if not match:
raise InvalidSpecifier(f"Invalid specifier: '{spec}'")
self._spec: Tuple[str, str] = (
match.group("operator").strip(),
match.group("version").strip(),
)
# Store whether or not this Specifier should accept prereleases
self._prereleases = prereleases
# https://github.com/python/mypy/pull/13475#pullrequestreview-1079784515
@property # type: ignore[override]
def prereleases(self) -> bool:
# If there is an explicit prereleases set for this, then we'll just
# blindly use that.
if self._prereleases is not None:
return self._prereleases
# Look at all of our specifiers and determine if they are inclusive
# operators, and if they are if they are including an explicit
# prerelease.
operator, version = self._spec
if operator in ["==", ">=", "<=", "~=", "==="]:
# The == specifier can include a trailing .*, if it does we
# want to remove before parsing.
if operator == "==" and version.endswith(".*"):
version = version[:-2]
# Parse the version, and if it is a pre-release than this
# specifier allows pre-releases.
if Version(version).is_prerelease:
return True
return False
@prereleases.setter
def prereleases(self, value: bool) -> None:
self._prereleases = value
@property
def operator(self) -> str:
"""The operator of this specifier.
>>> Specifier("==1.2.3").operator
'=='
"""
return self._spec[0]
@property
def version(self) -> str:
"""The version of this specifier.
>>> Specifier("==1.2.3").version
'1.2.3'
"""
return self._spec[1]
def __repr__(self) -> str:
"""A representation of the Specifier that shows all internal state.
>>> Specifier('>=1.0.0')
<Specifier('>=1.0.0')>
>>> Specifier('>=1.0.0', prereleases=False)
<Specifier('>=1.0.0', prereleases=False)>
>>> Specifier('>=1.0.0', prereleases=True)
<Specifier('>=1.0.0', prereleases=True)>
"""
pre = (
f", prereleases={self.prereleases!r}"
if self._prereleases is not None
else ""
)
return f"<{self.__class__.__name__}({str(self)!r}{pre})>"
def __str__(self) -> str:
"""A string representation of the Specifier that can be round-tripped.
>>> str(Specifier('>=1.0.0'))
'>=1.0.0'
>>> str(Specifier('>=1.0.0', prereleases=False))
'>=1.0.0'
"""
return "{}{}".format(*self._spec)
@property
def _canonical_spec(self) -> Tuple[str, str]:
canonical_version = canonicalize_version(
self._spec[1],
strip_trailing_zero=(self._spec[0] != "~="),
)
return self._spec[0], canonical_version
def __hash__(self) -> int:
return hash(self._canonical_spec)
def __eq__(self, other: object) -> bool:
"""Whether or not the two Specifier-like objects are equal.
:param other: The other object to check against.
The value of :attr:`prereleases` is ignored.
>>> Specifier("==1.2.3") == Specifier("== 1.2.3.0")
True
>>> (Specifier("==1.2.3", prereleases=False) ==
... Specifier("==1.2.3", prereleases=True))
True
>>> Specifier("==1.2.3") == "==1.2.3"
True
>>> Specifier("==1.2.3") == Specifier("==1.2.4")
False
>>> Specifier("==1.2.3") == Specifier("~=1.2.3")
False
"""
if isinstance(other, str):
try:
other = self.__class__(str(other))
except InvalidSpecifier:
return NotImplemented
elif not isinstance(other, self.__class__):
return NotImplemented
return self._canonical_spec == other._canonical_spec
def _get_operator(self, op: str) -> CallableOperator:
operator_callable: CallableOperator = getattr(
self, f"_compare_{self._operators[op]}"
)
return operator_callable
def _compare_compatible(self, prospective: Version, spec: str) -> bool:
# Compatible releases have an equivalent combination of >= and ==. That
# is that ~=2.2 is equivalent to >=2.2,==2.*. This allows us to
# implement this in terms of the other specifiers instead of
# implementing it ourselves. The only thing we need to do is construct
# the other specifiers.
# We want everything but the last item in the version, but we want to
# ignore suffix segments.
prefix = _version_join(
list(itertools.takewhile(_is_not_suffix, _version_split(spec)))[:-1]
)
# Add the prefix notation to the end of our string
prefix += ".*"
return self._get_operator(">=")(prospective, spec) and self._get_operator("==")(
prospective, prefix
)
def _compare_equal(self, prospective: Version, spec: str) -> bool:
# We need special logic to handle prefix matching
if spec.endswith(".*"):
# In the case of prefix matching we want to ignore local segment.
normalized_prospective = canonicalize_version(
prospective.public, strip_trailing_zero=False
)
# Get the normalized version string ignoring the trailing .*
normalized_spec = canonicalize_version(spec[:-2], strip_trailing_zero=False)
# Split the spec out by bangs and dots, and pretend that there is
# an implicit dot in between a release segment and a pre-release segment.
split_spec = _version_split(normalized_spec)
# Split the prospective version out by bangs and dots, and pretend
# that there is an implicit dot in between a release segment and
# a pre-release segment.
split_prospective = _version_split(normalized_prospective)
# 0-pad the prospective version before shortening it to get the correct
# shortened version.
padded_prospective, _ = _pad_version(split_prospective, split_spec)
# Shorten the prospective version to be the same length as the spec
# so that we can determine if the specifier is a prefix of the
# prospective version or not.
shortened_prospective = padded_prospective[: len(split_spec)]
return shortened_prospective == split_spec
else:
# Convert our spec string into a Version
spec_version = Version(spec)
# If the specifier does not have a local segment, then we want to
# act as if the prospective version also does not have a local
# segment.
if not spec_version.local:
prospective = Version(prospective.public)
return prospective == spec_version
def _compare_not_equal(self, prospective: Version, spec: str) -> bool:
return not self._compare_equal(prospective, spec)
def _compare_less_than_equal(self, prospective: Version, spec: str) -> bool:
# NB: Local version identifiers are NOT permitted in the version
# specifier, so local version labels can be universally removed from
# the prospective version.
return Version(prospective.public) <= Version(spec)
def _compare_greater_than_equal(self, prospective: Version, spec: str) -> bool:
# NB: Local version identifiers are NOT permitted in the version
# specifier, so local version labels can be universally removed from
# the prospective version.
return Version(prospective.public) >= Version(spec)
def _compare_less_than(self, prospective: Version, spec_str: str) -> bool:
# Convert our spec to a Version instance, since we'll want to work with
# it as a version.
spec = Version(spec_str)
# Check to see if the prospective version is less than the spec
# version. If it's not we can short circuit and just return False now
# instead of doing extra unneeded work.
if not prospective < spec:
return False
# This special case is here so that, unless the specifier itself
# includes is a pre-release version, that we do not accept pre-release
# versions for the version mentioned in the specifier (e.g. <3.1 should
# not match 3.1.dev0, but should match 3.0.dev0).
if not spec.is_prerelease and prospective.is_prerelease:
if Version(prospective.base_version) == Version(spec.base_version):
return False
# If we've gotten to here, it means that prospective version is both
# less than the spec version *and* it's not a pre-release of the same
# version in the spec.
return True
def _compare_greater_than(self, prospective: Version, spec_str: str) -> bool:
# Convert our spec to a Version instance, since we'll want to work with
# it as a version.
spec = Version(spec_str)
# Check to see if the prospective version is greater than the spec
# version. If it's not we can short circuit and just return False now
# instead of doing extra unneeded work.
if not prospective > spec:
return False
# This special case is here so that, unless the specifier itself
# includes is a post-release version, that we do not accept
# post-release versions for the version mentioned in the specifier
# (e.g. >3.1 should not match 3.0.post0, but should match 3.2.post0).
if not spec.is_postrelease and prospective.is_postrelease:
if Version(prospective.base_version) == Version(spec.base_version):
return False
# Ensure that we do not allow a local version of the version mentioned
# in the specifier, which is technically greater than, to match.
if prospective.local is not None:
if Version(prospective.base_version) == Version(spec.base_version):
return False
# If we've gotten to here, it means that prospective version is both
# greater than the spec version *and* it's not a pre-release of the
# same version in the spec.
return True
def _compare_arbitrary(self, prospective: Version, spec: str) -> bool:
return str(prospective).lower() == str(spec).lower()
def __contains__(self, item: Union[str, Version]) -> bool:
"""Return whether or not the item is contained in this specifier.
:param item: The item to check for.
This is used for the ``in`` operator and behaves the same as
:meth:`contains` with no ``prereleases`` argument passed.
>>> "1.2.3" in Specifier(">=1.2.3")
True
>>> Version("1.2.3") in Specifier(">=1.2.3")
True
>>> "1.0.0" in Specifier(">=1.2.3")
False
>>> "1.3.0a1" in Specifier(">=1.2.3")
False
>>> "1.3.0a1" in Specifier(">=1.2.3", prereleases=True)
True
"""
return self.contains(item)
def contains(
self, item: UnparsedVersion, prereleases: Optional[bool] = None
) -> bool:
"""Return whether or not the item is contained in this specifier.
:param item:
The item to check for, which can be a version string or a
:class:`Version` instance.
:param prereleases:
Whether or not to match prereleases with this Specifier. If set to
``None`` (the default), it uses :attr:`prereleases` to determine
whether or not prereleases are allowed.
>>> Specifier(">=1.2.3").contains("1.2.3")
True
>>> Specifier(">=1.2.3").contains(Version("1.2.3"))
True
>>> Specifier(">=1.2.3").contains("1.0.0")
False
>>> Specifier(">=1.2.3").contains("1.3.0a1")
False
>>> Specifier(">=1.2.3", prereleases=True).contains("1.3.0a1")
True
>>> Specifier(">=1.2.3").contains("1.3.0a1", prereleases=True)
True
"""
# Determine if prereleases are to be allowed or not.
if prereleases is None:
prereleases = self.prereleases
# Normalize item to a Version, this allows us to have a shortcut for
# "2.0" in Specifier(">=2")
normalized_item = _coerce_version(item)
# Determine if we should be supporting prereleases in this specifier
# or not, if we do not support prereleases than we can short circuit
# logic if this version is a prereleases.
if normalized_item.is_prerelease and not prereleases:
return False
# Actually do the comparison to determine if this item is contained
# within this Specifier or not.
operator_callable: CallableOperator = self._get_operator(self.operator)
return operator_callable(normalized_item, self.version)
def filter(
self, iterable: Iterable[UnparsedVersionVar], prereleases: Optional[bool] = None
) -> Iterator[UnparsedVersionVar]:
"""Filter items in the given iterable, that match the specifier.
:param iterable:
An iterable that can contain version strings and :class:`Version` instances.
The items in the iterable will be filtered according to the specifier.
:param prereleases:
Whether or not to allow prereleases in the returned iterator. If set to
``None`` (the default), it will be intelligently decide whether to allow
prereleases or not (based on the :attr:`prereleases` attribute, and
whether the only versions matching are prereleases).
This method is smarter than just ``filter(Specifier().contains, [...])``
because it implements the rule from :pep:`440` that a prerelease item
SHOULD be accepted if no other versions match the given specifier.
>>> list(Specifier(">=1.2.3").filter(["1.2", "1.3", "1.5a1"]))
['1.3']
>>> list(Specifier(">=1.2.3").filter(["1.2", "1.2.3", "1.3", Version("1.4")]))
['1.2.3', '1.3', <Version('1.4')>]
>>> list(Specifier(">=1.2.3").filter(["1.2", "1.5a1"]))
['1.5a1']
>>> list(Specifier(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True))
['1.3', '1.5a1']
>>> list(Specifier(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"]))
['1.3', '1.5a1']
"""
yielded = False
found_prereleases = []
kw = {"prereleases": prereleases if prereleases is not None else True}
# Attempt to iterate over all the values in the iterable and if any of
# them match, yield them.
for version in iterable:
parsed_version = _coerce_version(version)
if self.contains(parsed_version, **kw):
# If our version is a prerelease, and we were not set to allow
# prereleases, then we'll store it for later in case nothing
# else matches this specifier.
if parsed_version.is_prerelease and not (
prereleases or self.prereleases
):
found_prereleases.append(version)
# Either this is not a prerelease, or we should have been
# accepting prereleases from the beginning.
else:
yielded = True
yield version
# Now that we've iterated over everything, determine if we've yielded
# any values, and if we have not and we have any prereleases stored up
# then we will go ahead and yield the prereleases.
if not yielded and found_prereleases:
for version in found_prereleases:
yield version
_prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$")
def _version_split(version: str) -> List[str]:
"""Split version into components.
The split components are intended for version comparison. The logic does
not attempt to retain the original version string, so joining the
components back with :func:`_version_join` may not produce the original
version string.
"""
result: List[str] = []
epoch, _, rest = version.rpartition("!")
result.append(epoch or "0")
for item in rest.split("."):
match = _prefix_regex.search(item)
if match:
result.extend(match.groups())
else:
result.append(item)
return result
def _version_join(components: List[str]) -> str:
"""Join split version components into a version string.
This function assumes the input came from :func:`_version_split`, where the
first component must be the epoch (either empty or numeric), and all other
components numeric.
"""
epoch, *rest = components
return f"{epoch}!{'.'.join(rest)}"
def _is_not_suffix(segment: str) -> bool:
return not any(
segment.startswith(prefix) for prefix in ("dev", "a", "b", "rc", "post")
)
def _pad_version(left: List[str], right: List[str]) -> Tuple[List[str], List[str]]:
left_split, right_split = [], []
# Get the release segment of our versions
left_split.append(list(itertools.takewhile(lambda x: x.isdigit(), left)))
right_split.append(list(itertools.takewhile(lambda x: x.isdigit(), right)))
# Get the rest of our versions
left_split.append(left[len(left_split[0]) :])
right_split.append(right[len(right_split[0]) :])
# Insert our padding
left_split.insert(1, ["0"] * max(0, len(right_split[0]) - len(left_split[0])))
right_split.insert(1, ["0"] * max(0, len(left_split[0]) - len(right_split[0])))
return (
list(itertools.chain.from_iterable(left_split)),
list(itertools.chain.from_iterable(right_split)),
)
class SpecifierSet(BaseSpecifier):
"""This class abstracts handling of a set of version specifiers.
It can be passed a single specifier (``>=3.0``), a comma-separated list of
specifiers (``>=3.0,!=3.1``), or no specifier at all.
"""
def __init__(
self, specifiers: str = "", prereleases: Optional[bool] = None
) -> None:
"""Initialize a SpecifierSet instance.
:param specifiers:
The string representation of a specifier or a comma-separated list of
specifiers which will be parsed and normalized before use.
:param prereleases:
This tells the SpecifierSet if it should accept prerelease versions if
applicable or not. The default of ``None`` will autodetect it from the
given specifiers.
:raises InvalidSpecifier:
If the given ``specifiers`` are not parseable than this exception will be
raised.
"""
# Split on `,` to break each individual specifier into it's own item, and
# strip each item to remove leading/trailing whitespace.
split_specifiers = [s.strip() for s in specifiers.split(",") if s.strip()]
# Make each individual specifier a Specifier and save in a frozen set for later.
self._specs = frozenset(map(Specifier, split_specifiers))
# Store our prereleases value so we can use it later to determine if
# we accept prereleases or not.
self._prereleases = prereleases
@property
def prereleases(self) -> Optional[bool]:
# If we have been given an explicit prerelease modifier, then we'll
# pass that through here.
if self._prereleases is not None:
return self._prereleases
# If we don't have any specifiers, and we don't have a forced value,
# then we'll just return None since we don't know if this should have
# pre-releases or not.
if not self._specs:
return None
# Otherwise we'll see if any of the given specifiers accept
# prereleases, if any of them do we'll return True, otherwise False.
return any(s.prereleases for s in self._specs)
@prereleases.setter
def prereleases(self, value: bool) -> None:
self._prereleases = value
def __repr__(self) -> str:
"""A representation of the specifier set that shows all internal state.
Note that the ordering of the individual specifiers within the set may not
match the input string.
>>> SpecifierSet('>=1.0.0,!=2.0.0')
<SpecifierSet('!=2.0.0,>=1.0.0')>
>>> SpecifierSet('>=1.0.0,!=2.0.0', prereleases=False)
<SpecifierSet('!=2.0.0,>=1.0.0', prereleases=False)>
>>> SpecifierSet('>=1.0.0,!=2.0.0', prereleases=True)
<SpecifierSet('!=2.0.0,>=1.0.0', prereleases=True)>
"""
pre = (
f", prereleases={self.prereleases!r}"
if self._prereleases is not None
else ""
)
return f"<SpecifierSet({str(self)!r}{pre})>"
def __str__(self) -> str:
"""A string representation of the specifier set that can be round-tripped.
Note that the ordering of the individual specifiers within the set may not
match the input string.
>>> str(SpecifierSet(">=1.0.0,!=1.0.1"))
'!=1.0.1,>=1.0.0'
>>> str(SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False))
'!=1.0.1,>=1.0.0'
"""
return ",".join(sorted(str(s) for s in self._specs))
def __hash__(self) -> int:
return hash(self._specs)
def __and__(self, other: Union["SpecifierSet", str]) -> "SpecifierSet":
"""Return a SpecifierSet which is a combination of the two sets.
:param other: The other object to combine with.
>>> SpecifierSet(">=1.0.0,!=1.0.1") & '<=2.0.0,!=2.0.1'
<SpecifierSet('!=1.0.1,!=2.0.1,<=2.0.0,>=1.0.0')>
>>> SpecifierSet(">=1.0.0,!=1.0.1") & SpecifierSet('<=2.0.0,!=2.0.1')
<SpecifierSet('!=1.0.1,!=2.0.1,<=2.0.0,>=1.0.0')>
"""
if isinstance(other, str):
other = SpecifierSet(other)
elif not isinstance(other, SpecifierSet):
return NotImplemented
specifier = SpecifierSet()
specifier._specs = frozenset(self._specs | other._specs)
if self._prereleases is None and other._prereleases is not None:
specifier._prereleases = other._prereleases
elif self._prereleases is not None and other._prereleases is None:
specifier._prereleases = self._prereleases
elif self._prereleases == other._prereleases:
specifier._prereleases = self._prereleases
else:
raise ValueError(
"Cannot combine SpecifierSets with True and False prerelease "
"overrides."
)
return specifier
def __eq__(self, other: object) -> bool:
"""Whether or not the two SpecifierSet-like objects are equal.
:param other: The other object to check against.
The value of :attr:`prereleases` is ignored.
>>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0,!=1.0.1")
True
>>> (SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False) ==
... SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True))
True
>>> SpecifierSet(">=1.0.0,!=1.0.1") == ">=1.0.0,!=1.0.1"
True
>>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0")
False
>>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0,!=1.0.2")
False
"""
if isinstance(other, (str, Specifier)):
other = SpecifierSet(str(other))
elif not isinstance(other, SpecifierSet):
return NotImplemented
return self._specs == other._specs
def __len__(self) -> int:
"""Returns the number of specifiers in this specifier set."""
return len(self._specs)
def __iter__(self) -> Iterator[Specifier]:
"""
Returns an iterator over all the underlying :class:`Specifier` instances
in this specifier set.
>>> sorted(SpecifierSet(">=1.0.0,!=1.0.1"), key=str)
[<Specifier('!=1.0.1')>, <Specifier('>=1.0.0')>]
"""
return iter(self._specs)
def __contains__(self, item: UnparsedVersion) -> bool:
"""Return whether or not the item is contained in this specifier.
:param item: The item to check for.
This is used for the ``in`` operator and behaves the same as
:meth:`contains` with no ``prereleases`` argument passed.
>>> "1.2.3" in SpecifierSet(">=1.0.0,!=1.0.1")
True
>>> Version("1.2.3") in SpecifierSet(">=1.0.0,!=1.0.1")
True
>>> "1.0.1" in SpecifierSet(">=1.0.0,!=1.0.1")
False
>>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1")
False
>>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True)
True
"""
return self.contains(item)
def contains(
self,
item: UnparsedVersion,
prereleases: Optional[bool] = None,
installed: Optional[bool] = None,
) -> bool:
"""Return whether or not the item is contained in this SpecifierSet.
:param item:
The item to check for, which can be a version string or a
:class:`Version` instance.
:param prereleases:
Whether or not to match prereleases with this SpecifierSet. If set to
``None`` (the default), it uses :attr:`prereleases` to determine
whether or not prereleases are allowed.
>>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.2.3")
True
>>> SpecifierSet(">=1.0.0,!=1.0.1").contains(Version("1.2.3"))
True
>>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.0.1")
False
>>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1")
False
>>> SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True).contains("1.3.0a1")
True
>>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1", prereleases=True)
True
"""
# Ensure that our item is a Version instance.
if not isinstance(item, Version):
item = Version(item)
# Determine if we're forcing a prerelease or not, if we're not forcing
# one for this particular filter call, then we'll use whatever the
# SpecifierSet thinks for whether or not we should support prereleases.
if prereleases is None:
prereleases = self.prereleases
# We can determine if we're going to allow pre-releases by looking to
# see if any of the underlying items supports them. If none of them do
# and this item is a pre-release then we do not allow it and we can
# short circuit that here.
# Note: This means that 1.0.dev1 would not be contained in something
# like >=1.0.devabc however it would be in >=1.0.debabc,>0.0.dev0
if not prereleases and item.is_prerelease:
return False
if installed and item.is_prerelease:
item = Version(item.base_version)
# We simply dispatch to the underlying specs here to make sure that the
# given version is contained within all of them.
# Note: This use of all() here means that an empty set of specifiers
# will always return True, this is an explicit design decision.
return all(s.contains(item, prereleases=prereleases) for s in self._specs)
def filter(
self, iterable: Iterable[UnparsedVersionVar], prereleases: Optional[bool] = None
) -> Iterator[UnparsedVersionVar]:
"""Filter items in the given iterable, that match the specifiers in this set.
:param iterable:
An iterable that can contain version strings and :class:`Version` instances.
The items in the iterable will be filtered according to the specifier.
:param prereleases:
Whether or not to allow prereleases in the returned iterator. If set to
``None`` (the default), it will be intelligently decide whether to allow
prereleases or not (based on the :attr:`prereleases` attribute, and
whether the only versions matching are prereleases).
This method is smarter than just ``filter(SpecifierSet(...).contains, [...])``
because it implements the rule from :pep:`440` that a prerelease item
SHOULD be accepted if no other versions match the given specifier.
>>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", "1.5a1"]))
['1.3']
>>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", Version("1.4")]))
['1.3', <Version('1.4')>]
>>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.5a1"]))
[]
>>> list(SpecifierSet(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True))
['1.3', '1.5a1']
>>> list(SpecifierSet(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"]))
['1.3', '1.5a1']
An "empty" SpecifierSet will filter items based on the presence of prerelease
versions in the set.
>>> list(SpecifierSet("").filter(["1.3", "1.5a1"]))
['1.3']
>>> list(SpecifierSet("").filter(["1.5a1"]))
['1.5a1']
>>> list(SpecifierSet("", prereleases=True).filter(["1.3", "1.5a1"]))
['1.3', '1.5a1']
>>> list(SpecifierSet("").filter(["1.3", "1.5a1"], prereleases=True))
['1.3', '1.5a1']
"""
# Determine if we're forcing a prerelease or not, if we're not forcing
# one for this particular filter call, then we'll use whatever the
# SpecifierSet thinks for whether or not we should support prereleases.
if prereleases is None:
prereleases = self.prereleases
# If we have any specifiers, then we want to wrap our iterable in the
# filter method for each one, this will act as a logical AND amongst
# each specifier.
if self._specs:
for spec in self._specs:
iterable = spec.filter(iterable, prereleases=bool(prereleases))
return iter(iterable)
# If we do not have any specifiers, then we need to have a rough filter
# which will filter out any pre-releases, unless there are no final
# releases.
else:
filtered: List[UnparsedVersionVar] = []
found_prereleases: List[UnparsedVersionVar] = []
for item in iterable:
parsed_version = _coerce_version(item)
# Store any item which is a pre-release for later unless we've
# already found a final version or we are accepting prereleases
if parsed_version.is_prerelease and not prereleases:
if not filtered:
found_prereleases.append(item)
else:
filtered.append(item)
# If we've found no items except for pre-releases, then we'll go
# ahead and use the pre-releases
if not filtered and found_prereleases and prereleases is None:
return iter(found_prereleases)
return iter(filtered)
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
import logging
import platform
import re
import struct
import subprocess
import sys
import sysconfig
from importlib.machinery import EXTENSION_SUFFIXES
from typing import (
Dict,
FrozenSet,
Iterable,
Iterator,
List,
Optional,
Sequence,
Tuple,
Union,
cast,
)
from . import _manylinux, _musllinux
logger = logging.getLogger(__name__)
PythonVersion = Sequence[int]
MacVersion = Tuple[int, int]
INTERPRETER_SHORT_NAMES: Dict[str, str] = {
"python": "py", # Generic.
"cpython": "cp",
"pypy": "pp",
"ironpython": "ip",
"jython": "jy",
}
_32_BIT_INTERPRETER = struct.calcsize("P") == 4
class Tag:
"""
A representation of the tag triple for a wheel.
Instances are considered immutable and thus are hashable. Equality checking
is also supported.
"""
__slots__ = ["_interpreter", "_abi", "_platform", "_hash"]
def __init__(self, interpreter: str, abi: str, platform: str) -> None:
self._interpreter = interpreter.lower()
self._abi = abi.lower()
self._platform = platform.lower()
# The __hash__ of every single element in a Set[Tag] will be evaluated each time
# that a set calls its `.disjoint()` method, which may be called hundreds of
# times when scanning a page of links for packages with tags matching that
# Set[Tag]. Pre-computing the value here produces significant speedups for
# downstream consumers.
self._hash = hash((self._interpreter, self._abi, self._platform))
@property
def interpreter(self) -> str:
return self._interpreter
@property
def abi(self) -> str:
return self._abi
@property
def platform(self) -> str:
return self._platform
def __eq__(self, other: object) -> bool:
if not isinstance(other, Tag):
return NotImplemented
return (
(self._hash == other._hash) # Short-circuit ASAP for perf reasons.
and (self._platform == other._platform)
and (self._abi == other._abi)
and (self._interpreter == other._interpreter)
)
def __hash__(self) -> int:
return self._hash
def __str__(self) -> str:
return f"{self._interpreter}-{self._abi}-{self._platform}"
def __repr__(self) -> str:
return f"<{self} @ {id(self)}>"
def parse_tag(tag: str) -> FrozenSet[Tag]:
"""
Parses the provided tag (e.g. `py3-none-any`) into a frozenset of Tag instances.
Returning a set is required due to the possibility that the tag is a
compressed tag set.
"""
tags = set()
interpreters, abis, platforms = tag.split("-")
for interpreter in interpreters.split("."):
for abi in abis.split("."):
for platform_ in platforms.split("."):
tags.add(Tag(interpreter, abi, platform_))
return frozenset(tags)
def _get_config_var(name: str, warn: bool = False) -> Union[int, str, None]:
value: Union[int, str, None] = sysconfig.get_config_var(name)
if value is None and warn:
logger.debug(
"Config variable '%s' is unset, Python ABI tag may be incorrect", name
)
return value
def _normalize_string(string: str) -> str:
return string.replace(".", "_").replace("-", "_").replace(" ", "_")
def _is_threaded_cpython(abis: List[str]) -> bool:
"""
Determine if the ABI corresponds to a threaded (`--disable-gil`) build.
The threaded builds are indicated by a "t" in the abiflags.
"""
if len(abis) == 0:
return False
# expect e.g., cp313
m = re.match(r"cp\d+(.*)", abis[0])
if not m:
return False
abiflags = m.group(1)
return "t" in abiflags
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`)
builds do not support abi3.
"""
return len(python_version) > 1 and tuple(python_version) >= (3, 2) and not threading
def _cpython_abis(py_version: PythonVersion, warn: bool = False) -> List[str]:
py_version = tuple(py_version) # To allow for version comparison.
abis = []
version = _version_nodot(py_version[:2])
threading = debug = pymalloc = ucs4 = ""
with_debug = _get_config_var("Py_DEBUG", warn)
has_refcount = hasattr(sys, "gettotalrefcount")
# Windows doesn't set Py_DEBUG, so checking for support of debug-compiled
# extension modules is the best option.
# https://github.com/pypa/pip/issues/3383#issuecomment-173267692
has_ext = "_d.pyd" in EXTENSION_SUFFIXES
if with_debug or (with_debug is None and (has_refcount or has_ext)):
debug = "d"
if py_version >= (3, 13) and _get_config_var("Py_GIL_DISABLED", warn):
threading = "t"
if py_version < (3, 8):
with_pymalloc = _get_config_var("WITH_PYMALLOC", warn)
if with_pymalloc or with_pymalloc is None:
pymalloc = "m"
if py_version < (3, 3):
unicode_size = _get_config_var("Py_UNICODE_SIZE", warn)
if unicode_size == 4 or (
unicode_size is None and sys.maxunicode == 0x10FFFF
):
ucs4 = "u"
elif debug:
# Debug builds can also load "normal" extension modules.
# We can also assume no UCS-4 or pymalloc requirement.
abis.append(f"cp{version}{threading}")
abis.insert(0, f"cp{version}{threading}{debug}{pymalloc}{ucs4}")
return abis
def cpython_tags(
python_version: Optional[PythonVersion] = None,
abis: Optional[Iterable[str]] = None,
platforms: Optional[Iterable[str]] = None,
*,
warn: bool = False,
) -> Iterator[Tag]:
"""
Yields the tags for a 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.
If python_version only specifies a major version then user-provided ABIs and
the 'none' ABItag will be used.
If 'abi3' or 'none' are specified in 'abis' then they will be yielded at
their normal position and not at the beginning.
"""
if not python_version:
python_version = sys.version_info[:2]
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 = list(abis)
# 'abi3' and 'none' are explicitly handled later.
for explicit_abi in ("abi3", "none"):
try:
abis.remove(explicit_abi)
except ValueError:
pass
platforms = list(platforms or platform_tags())
for abi in abis:
for platform_ in platforms:
yield Tag(interpreter, abi, platform_)
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)
if use_abi3:
for minor_version in range(python_version[1] - 1, 1, -1):
for platform_ in platforms:
interpreter = "cp{version}".format(
version=_version_nodot((python_version[0], minor_version))
)
yield Tag(interpreter, "abi3", platform_)
def _generic_abi() -> List[str]:
"""
Return the ABI tag based on EXT_SUFFIX.
"""
# The following are examples of `EXT_SUFFIX`.
# We want to keep the parts which are related to the ABI and remove the
# parts which are related to the platform:
# - linux: '.cpython-310-x86_64-linux-gnu.so' => cp310
# - mac: '.cpython-310-darwin.so' => cp310
# - win: '.cp310-win_amd64.pyd' => cp310
# - win: '.pyd' => cp37 (uses _cpython_abis())
# - pypy: '.pypy38-pp73-x86_64-linux-gnu.so' => pypy38_pp73
# - graalpy: '.graalpy-38-native-x86_64-darwin.dylib'
# => graalpy_38_native
ext_suffix = _get_config_var("EXT_SUFFIX", warn=True)
if not isinstance(ext_suffix, str) or ext_suffix[0] != ".":
raise SystemError("invalid sysconfig.get_config_var('EXT_SUFFIX')")
parts = ext_suffix.split(".")
if len(parts) < 3:
# CPython3.7 and earlier uses ".pyd" on Windows.
return _cpython_abis(sys.version_info[:2])
soabi = parts[1]
if soabi.startswith("cpython"):
# non-windows
abi = "cp" + soabi.split("-")[1]
elif soabi.startswith("cp"):
# windows
abi = soabi.split("-")[0]
elif soabi.startswith("pypy"):
abi = "-".join(soabi.split("-")[:2])
elif soabi.startswith("graalpy"):
abi = "-".join(soabi.split("-")[:3])
elif soabi:
# pyston, ironpython, others?
abi = soabi
else:
return []
return [_normalize_string(abi)]
def generic_tags(
interpreter: Optional[str] = None,
abis: Optional[Iterable[str]] = None,
platforms: Optional[Iterable[str]] = None,
*,
warn: bool = False,
) -> Iterator[Tag]:
"""
Yields the tags for a generic interpreter.
The tags consist of:
- <interpreter>-<abi>-<platform>
The "none" ABI will be added if it was not explicitly provided.
"""
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)
platforms = list(platforms or platform_tags())
if "none" not in abis:
abis.append("none")
for abi in abis:
for platform_ in platforms:
yield Tag(interpreter, abi, platform_)
def _py_interpreter_range(py_version: PythonVersion) -> Iterator[str]:
"""
Yields Python versions in descending order.
After the latest version, the major-only version will be yielded, and then
all previous versions of that major version.
"""
if len(py_version) > 1:
yield f"py{_version_nodot(py_version[:2])}"
yield f"py{py_version[0]}"
if len(py_version) > 1:
for minor in range(py_version[1] - 1, -1, -1):
yield f"py{_version_nodot((py_version[0], minor))}"
def compatible_tags(
python_version: Optional[PythonVersion] = None,
interpreter: Optional[str] = None,
platforms: Optional[Iterable[str]] = None,
) -> Iterator[Tag]:
"""
Yields the sequence of tags that are compatible with a specific version of Python.
The tags consist of:
- py*-none-<platform>
- <interpreter>-none-any # ... if `interpreter` is provided.
- py*-none-any
"""
if not python_version:
python_version = sys.version_info[:2]
platforms = list(platforms or platform_tags())
for version in _py_interpreter_range(python_version):
for platform_ in platforms:
yield Tag(version, "none", platform_)
if interpreter:
yield Tag(interpreter, "none", "any")
for version in _py_interpreter_range(python_version):
yield Tag(version, "none", "any")
def _mac_arch(arch: str, is_32bit: bool = _32_BIT_INTERPRETER) -> str:
if not is_32bit:
return arch
if arch.startswith("ppc"):
return "ppc"
return "i386"
def _mac_binary_formats(version: MacVersion, cpu_arch: str) -> List[str]:
formats = [cpu_arch]
if cpu_arch == "x86_64":
if version < (10, 4):
return []
formats.extend(["intel", "fat64", "fat32"])
elif cpu_arch == "i386":
if version < (10, 4):
return []
formats.extend(["intel", "fat32", "fat"])
elif cpu_arch == "ppc64":
# TODO: Need to care about 32-bit PPC for ppc64 through 10.2?
if version > (10, 5) or version < (10, 4):
return []
formats.append("fat64")
elif cpu_arch == "ppc":
if version > (10, 6):
return []
formats.extend(["fat32", "fat"])
if cpu_arch in {"arm64", "x86_64"}:
formats.append("universal2")
if cpu_arch in {"x86_64", "i386", "ppc64", "ppc", "intel"}:
formats.append("universal")
return formats
def mac_platforms(
version: Optional[MacVersion] = None, arch: Optional[str] = None
) -> Iterator[str]:
"""
Yields the platform tags for a macOS system.
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.
"""
version_str, _, cpu_arch = platform.mac_ver()
if version is None:
version = cast("MacVersion", tuple(map(int, version_str.split(".")[:2])))
if version == (10, 16):
# When built against an older macOS SDK, Python will report macOS 10.16
# instead of the real version.
version_str = subprocess.run(
[
sys.executable,
"-sS",
"-c",
"import platform; print(platform.mac_ver()[0])",
],
check=True,
env={"SYSTEM_VERSION_COMPAT": "0"},
stdout=subprocess.PIPE,
text=True,
).stdout
version = cast("MacVersion", 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):
# Prior to Mac OS 11, each yearly release of Mac OS bumped the
# "minor" version number. The major version was always 10.
for minor_version in range(version[1], -1, -1):
compat_version = 10, minor_version
binary_formats = _mac_binary_formats(compat_version, arch)
for binary_format in binary_formats:
yield "macosx_{major}_{minor}_{binary_format}".format(
major=10, minor=minor_version, binary_format=binary_format
)
if version >= (11, 0):
# Starting with Mac OS 11, each yearly release bumps the major version
# number. The minor versions are now the midyear updates.
for major_version in range(version[0], 10, -1):
compat_version = major_version, 0
binary_formats = _mac_binary_formats(compat_version, arch)
for binary_format in binary_formats:
yield "macosx_{major}_{minor}_{binary_format}".format(
major=major_version, minor=0, binary_format=binary_format
)
if version >= (11, 0):
# Mac OS 11 on x86_64 is compatible with binaries from previous releases.
# Arm64 support was introduced in 11.0, so no Arm binaries from previous
# releases exist.
#
# However, the "universal2" binary format can have a
# macOS version earlier than 11.0 when the x86_64 part of the binary supports
# that version of macOS.
if arch == "x86_64":
for minor_version in range(16, 3, -1):
compat_version = 10, minor_version
binary_formats = _mac_binary_formats(compat_version, arch)
for binary_format in binary_formats:
yield "macosx_{major}_{minor}_{binary_format}".format(
major=compat_version[0],
minor=compat_version[1],
binary_format=binary_format,
)
else:
for minor_version in range(16, 3, -1):
compat_version = 10, minor_version
binary_format = "universal2"
yield "macosx_{major}_{minor}_{binary_format}".format(
major=compat_version[0],
minor=compat_version[1],
binary_format=binary_format,
)
def _linux_platforms(is_32bit: bool = _32_BIT_INTERPRETER) -> Iterator[str]:
linux = _normalize_string(sysconfig.get_platform())
if not linux.startswith("linux_"):
# we should never be here, just yield the sysconfig one and return
yield linux
return
if is_32bit:
if linux == "linux_x86_64":
linux = "linux_i686"
elif linux == "linux_aarch64":
linux = "linux_armv8l"
_, arch = linux.split("_", 1)
archs = {"armv8l": ["armv8l", "armv7l"]}.get(arch, [arch])
yield from _manylinux.platform_tags(archs)
yield from _musllinux.platform_tags(archs)
for arch in archs:
yield f"linux_{arch}"
def _generic_platforms() -> Iterator[str]:
yield _normalize_string(sysconfig.get_platform())
def platform_tags() -> Iterator[str]:
"""
Provides the platform tags for this installation.
"""
if platform.system() == "Darwin":
return mac_platforms()
elif platform.system() == "Linux":
return _linux_platforms()
else:
return _generic_platforms()
def interpreter_name() -> str:
"""
Returns the name of the running interpreter.
Some implementations have a reserved, two-letter abbreviation which will
be returned when appropriate.
"""
name = sys.implementation.name
return INTERPRETER_SHORT_NAMES.get(name) or name
def interpreter_version(*, warn: bool = False) -> str:
"""
Returns the version of the running interpreter.
"""
version = _get_config_var("py_version_nodot", warn=warn)
if version:
version = str(version)
else:
version = _version_nodot(sys.version_info[:2])
return version
def _version_nodot(version: PythonVersion) -> str:
return "".join(map(str, version))
def sys_tags(*, warn: bool = False) -> Iterator[Tag]:
"""
Returns the sequence of tag triples for the running interpreter.
The order of the sequence corresponds to priority order for the
interpreter, from most to least important.
"""
interp_name = interpreter_name()
if interp_name == "cp":
yield from cpython_tags(warn=warn)
else:
yield from generic_tags()
if interp_name == "pp":
interp = "pp3"
elif interp_name == "cp":
interp = "cp" + interpreter_version(warn=warn)
else:
interp = None
yield from compatible_tags(interpreter=interp)
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
import re
from typing import FrozenSet, NewType, Tuple, Union, cast
from .tags import Tag, parse_tag
from .version import InvalidVersion, Version
BuildTag = Union[Tuple[()], Tuple[int, str]]
NormalizedName = NewType("NormalizedName", str)
class InvalidName(ValueError):
"""
An invalid distribution name; users should refer to the packaging user guide.
"""
class InvalidWheelFilename(ValueError):
"""
An invalid wheel filename was found, users should refer to PEP 427.
"""
class InvalidSdistFilename(ValueError):
"""
An invalid sdist filename was found, users should refer to the packaging user guide.
"""
# Core metadata spec for `Name`
_validate_regex = re.compile(
r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE
)
_canonicalize_regex = re.compile(r"[-_.]+")
_normalized_regex = re.compile(r"^([a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9])$")
# PEP 427: The build number must start with a digit.
_build_tag_regex = re.compile(r"(\d+)(.*)")
def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
if validate and not _validate_regex.match(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)
def is_normalized_name(name: str) -> bool:
return _normalized_regex.match(name) is not None
def canonicalize_version(
version: Union[Version, str], *, strip_trailing_zero: bool = True
) -> str:
"""
This is very similar to Version.__str__, but has one subtle difference
with the way it handles the release segment.
"""
if isinstance(version, str):
try:
parsed = Version(version)
except InvalidVersion:
# Legacy versions cannot be normalized
return version
else:
parsed = version
parts = []
# Epoch
if parsed.epoch != 0:
parts.append(f"{parsed.epoch}!")
# Release segment
release_segment = ".".join(str(x) for x in parsed.release)
if strip_trailing_zero:
# NB: This strips trailing '.0's to normalize
release_segment = re.sub(r"(\.0)+$", "", release_segment)
parts.append(release_segment)
# Pre-release
if parsed.pre is not None:
parts.append("".join(str(x) for x in parsed.pre))
# Post-release
if parsed.post is not None:
parts.append(f".post{parsed.post}")
# Development release
if parsed.dev is not None:
parts.append(f".dev{parsed.dev}")
# Local version segment
if parsed.local is not None:
parts.append(f"+{parsed.local}")
return "".join(parts)
def parse_wheel_filename(
filename: str,
) -> Tuple[NormalizedName, Version, BuildTag, FrozenSet[Tag]]:
if not filename.endswith(".whl"):
raise InvalidWheelFilename(
f"Invalid wheel filename (extension must be '.whl'): {filename}"
)
filename = filename[:-4]
dashes = filename.count("-")
if dashes not in (4, 5):
raise InvalidWheelFilename(
f"Invalid wheel filename (wrong number of parts): {filename}"
)
parts = filename.split("-", dashes - 2)
name_part = parts[0]
# See PEP 427 for the rules on escaping the project name.
if "__" in name_part or re.match(r"^[\w\d._]*$", name_part, re.UNICODE) is None:
raise InvalidWheelFilename(f"Invalid project name: {filename}")
name = canonicalize_name(name_part)
try:
version = Version(parts[1])
except InvalidVersion as e:
raise InvalidWheelFilename(
f"Invalid wheel filename (invalid version): {filename}"
) from e
if dashes == 5:
build_part = parts[2]
build_match = _build_tag_regex.match(build_part)
if build_match is None:
raise InvalidWheelFilename(
f"Invalid build number: {build_part} in '{filename}'"
)
build = cast(BuildTag, (int(build_match.group(1)), build_match.group(2)))
else:
build = ()
tags = parse_tag(parts[-1])
return (name, version, build, tags)
def parse_sdist_filename(filename: str) -> Tuple[NormalizedName, Version]:
if filename.endswith(".tar.gz"):
file_stem = filename[: -len(".tar.gz")]
elif filename.endswith(".zip"):
file_stem = filename[: -len(".zip")]
else:
raise InvalidSdistFilename(
f"Invalid sdist filename (extension must be '.tar.gz' or '.zip'):"
f" {filename}"
)
# We are requiring a PEP 440 version, which cannot contain dashes,
# so we split on the last dash.
name_part, sep, version_part = file_stem.rpartition("-")
if not sep:
raise InvalidSdistFilename(f"Invalid sdist filename: {filename}")
name = canonicalize_name(name_part)
try:
version = Version(version_part)
except InvalidVersion as e:
raise InvalidSdistFilename(
f"Invalid sdist filename (invalid version): {filename}"
) from e
return (name, version)
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
"""
.. testsetup::
from packaging.version import parse, Version
"""
import itertools
import re
from typing import Any, Callable, NamedTuple, Optional, SupportsInt, Tuple, Union
from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType
__all__ = ["VERSION_PATTERN", "parse", "Version", "InvalidVersion"]
LocalType = Tuple[Union[int, str], ...]
CmpPrePostDevType = Union[InfinityType, NegativeInfinityType, Tuple[str, int]]
CmpLocalType = Union[
NegativeInfinityType,
Tuple[Union[Tuple[int, str], Tuple[NegativeInfinityType, Union[int, str]]], ...],
]
CmpKey = Tuple[
int,
Tuple[int, ...],
CmpPrePostDevType,
CmpPrePostDevType,
CmpPrePostDevType,
CmpLocalType,
]
VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool]
class _Version(NamedTuple):
epoch: int
release: Tuple[int, ...]
dev: Optional[Tuple[str, int]]
pre: Optional[Tuple[str, int]]
post: Optional[Tuple[str, int]]
local: Optional[LocalType]
def parse(version: str) -> "Version":
"""Parse the given version string.
>>> parse('1.0.dev1')
<Version('1.0.dev1')>
:param version: The version string to parse.
:raises InvalidVersion: When the version string is not a valid version.
"""
return Version(version)
class InvalidVersion(ValueError):
"""Raised when a version string is not a valid version.
>>> Version("invalid")
Traceback (most recent call last):
...
packaging.version.InvalidVersion: Invalid version: 'invalid'
"""
class _BaseVersion:
_key: Tuple[Any, ...]
def __hash__(self) -> int:
return hash(self._key)
# Please keep the duplicated `isinstance` check
# in the six comparisons hereunder
# unless you find a way to avoid adding overhead function calls.
def __lt__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key < other._key
def __le__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key <= other._key
def __eq__(self, other: object) -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key == other._key
def __ge__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key >= other._key
def __gt__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key > other._key
def __ne__(self, other: object) -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key != other._key
# Deliberately not anchored to the start and end of the string, to make it
# easier for 3rd party code to reuse
_VERSION_PATTERN = r"""
v?
(?:
(?:(?P<epoch>[0-9]+)!)? # epoch
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
(?P<pre> # pre-release
[-_\.]?
(?P<pre_l>alpha|a|beta|b|preview|pre|c|rc)
[-_\.]?
(?P<pre_n>[0-9]+)?
)?
(?P<post> # post release
(?:-(?P<post_n1>[0-9]+))
|
(?:
[-_\.]?
(?P<post_l>post|rev|r)
[-_\.]?
(?P<post_n2>[0-9]+)?
)
)?
(?P<dev> # dev release
[-_\.]?
(?P<dev_l>dev)
[-_\.]?
(?P<dev_n>[0-9]+)?
)?
)
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
"""
VERSION_PATTERN = _VERSION_PATTERN
"""
A string containing the regular expression used to match a valid version.
The pattern is not anchored at either end, and is intended for embedding in larger
expressions (for example, matching a version number as part of a file name). The
regular expression should be compiled with the ``re.VERBOSE`` and ``re.IGNORECASE``
flags set.
:meta hide-value:
"""
class Version(_BaseVersion):
"""This class abstracts handling of a project's versions.
A :class:`Version` instance is comparison aware and can be compared and
sorted using the standard Python interfaces.
>>> v1 = Version("1.0a5")
>>> v2 = Version("1.0")
>>> v1
<Version('1.0a5')>
>>> v2
<Version('1.0')>
>>> v1 < v2
True
>>> v1 == v2
False
>>> v1 > v2
False
>>> v1 >= v2
False
>>> v1 <= v2
True
"""
_regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
_key: CmpKey
def __init__(self, version: str) -> None:
"""Initialize a Version object.
:param version:
The string representation of a version which will be parsed and normalized
before use.
:raises InvalidVersion:
If the ``version`` does not conform to PEP 440 in any way then this
exception will be raised.
"""
# Validate the version and parse it into pieces
match = self._regex.search(version)
if not match:
raise InvalidVersion(f"Invalid version: '{version}'")
# Store the parsed out pieces of the version
self._version = _Version(
epoch=int(match.group("epoch")) if match.group("epoch") else 0,
release=tuple(int(i) for i in match.group("release").split(".")),
pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")),
post=_parse_letter_version(
match.group("post_l"), match.group("post_n1") or match.group("post_n2")
),
dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")),
local=_parse_local_version(match.group("local")),
)
# Generate a key which will be used for sorting
self._key = _cmpkey(
self._version.epoch,
self._version.release,
self._version.pre,
self._version.post,
self._version.dev,
self._version.local,
)
def __repr__(self) -> str:
"""A representation of the Version that shows all internal state.
>>> Version('1.0.0')
<Version('1.0.0')>
"""
return f"<Version('{self}')>"
def __str__(self) -> str:
"""A string representation of the version that can be rounded-tripped.
>>> str(Version("1.0a5"))
'1.0a5'
"""
parts = []
# Epoch
if self.epoch != 0:
parts.append(f"{self.epoch}!")
# Release segment
parts.append(".".join(str(x) for x in self.release))
# Pre-release
if self.pre is not None:
parts.append("".join(str(x) for x in self.pre))
# Post-release
if self.post is not None:
parts.append(f".post{self.post}")
# Development release
if self.dev is not None:
parts.append(f".dev{self.dev}")
# Local version segment
if self.local is not None:
parts.append(f"+{self.local}")
return "".join(parts)
@property
def epoch(self) -> int:
"""The epoch of the version.
>>> Version("2.0.0").epoch
0
>>> Version("1!2.0.0").epoch
1
"""
return self._version.epoch
@property
def release(self) -> Tuple[int, ...]:
"""The components of the "release" segment of the version.
>>> Version("1.2.3").release
(1, 2, 3)
>>> Version("2.0.0").release
(2, 0, 0)
>>> Version("1!2.0.0.post0").release
(2, 0, 0)
Includes trailing zeroes but not the epoch or any pre-release / development /
post-release suffixes.
"""
return self._version.release
@property
def pre(self) -> Optional[Tuple[str, int]]:
"""The pre-release segment of the version.
>>> print(Version("1.2.3").pre)
None
>>> Version("1.2.3a1").pre
('a', 1)
>>> Version("1.2.3b1").pre
('b', 1)
>>> Version("1.2.3rc1").pre
('rc', 1)
"""
return self._version.pre
@property
def post(self) -> Optional[int]:
"""The post-release number of the version.
>>> print(Version("1.2.3").post)
None
>>> Version("1.2.3.post1").post
1
"""
return self._version.post[1] if self._version.post else None
@property
def dev(self) -> Optional[int]:
"""The development number of the version.
>>> print(Version("1.2.3").dev)
None
>>> Version("1.2.3.dev1").dev
1
"""
return self._version.dev[1] if self._version.dev else None
@property
def local(self) -> Optional[str]:
"""The local version segment of the version.
>>> print(Version("1.2.3").local)
None
>>> Version("1.2.3+abc").local
'abc'
"""
if self._version.local:
return ".".join(str(x) for x in self._version.local)
else:
return None
@property
def public(self) -> str:
"""The public portion of the version.
>>> Version("1.2.3").public
'1.2.3'
>>> Version("1.2.3+abc").public
'1.2.3'
>>> Version("1.2.3+abc.dev1").public
'1.2.3'
"""
return str(self).split("+", 1)[0]
@property
def base_version(self) -> str:
"""The "base version" of the version.
>>> Version("1.2.3").base_version
'1.2.3'
>>> Version("1.2.3+abc").base_version
'1.2.3'
>>> Version("1!1.2.3+abc.dev1").base_version
'1!1.2.3'
The "base version" is the public version of the project without any pre or post
release markers.
"""
parts = []
# Epoch
if self.epoch != 0:
parts.append(f"{self.epoch}!")
# Release segment
parts.append(".".join(str(x) for x in self.release))
return "".join(parts)
@property
def is_prerelease(self) -> bool:
"""Whether this version is a pre-release.
>>> Version("1.2.3").is_prerelease
False
>>> Version("1.2.3a1").is_prerelease
True
>>> Version("1.2.3b1").is_prerelease
True
>>> Version("1.2.3rc1").is_prerelease
True
>>> Version("1.2.3dev1").is_prerelease
True
"""
return self.dev is not None or self.pre is not None
@property
def is_postrelease(self) -> bool:
"""Whether this version is a post-release.
>>> Version("1.2.3").is_postrelease
False
>>> Version("1.2.3.post1").is_postrelease
True
"""
return self.post is not None
@property
def is_devrelease(self) -> bool:
"""Whether this version is a development release.
>>> Version("1.2.3").is_devrelease
False
>>> Version("1.2.3.dev1").is_devrelease
True
"""
return self.dev is not None
@property
def major(self) -> int:
"""The first item of :attr:`release` or ``0`` if unavailable.
>>> Version("1.2.3").major
1
"""
return self.release[0] if len(self.release) >= 1 else 0
@property
def minor(self) -> int:
"""The second item of :attr:`release` or ``0`` if unavailable.
>>> Version("1.2.3").minor
2
>>> Version("1").minor
0
"""
return self.release[1] if len(self.release) >= 2 else 0
@property
def micro(self) -> int:
"""The third item of :attr:`release` or ``0`` if unavailable.
>>> Version("1.2.3").micro
3
>>> Version("1").micro
0
"""
return self.release[2] if len(self.release) >= 3 else 0
def _parse_letter_version(
letter: Optional[str], number: Union[str, bytes, SupportsInt, None]
) -> Optional[Tuple[str, int]]:
if letter:
# We consider there to be an implicit 0 in a pre-release if there is
# not a numeral associated with it.
if number is None:
number = 0
# We normalize any letters to their lower case form
letter = letter.lower()
# We consider some words to be alternate spellings of other words and
# in those cases we want to normalize the spellings to our preferred
# spelling.
if letter == "alpha":
letter = "a"
elif letter == "beta":
letter = "b"
elif letter in ["c", "pre", "preview"]:
letter = "rc"
elif letter in ["rev", "r"]:
letter = "post"
return letter, int(number)
if not letter and number:
# We assume if we are given a number, but we are not given a letter
# then this is using the implicit post release syntax (e.g. 1.0-1)
letter = "post"
return letter, int(number)
return None
_local_version_separators = re.compile(r"[\._-]")
def _parse_local_version(local: Optional[str]) -> Optional[LocalType]:
"""
Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
"""
if local is not None:
return tuple(
part.lower() if not part.isdigit() else int(part)
for part in _local_version_separators.split(local)
)
return None
def _cmpkey(
epoch: int,
release: Tuple[int, ...],
pre: Optional[Tuple[str, int]],
post: Optional[Tuple[str, int]],
dev: Optional[Tuple[str, int]],
local: Optional[LocalType],
) -> CmpKey:
# When we compare a release version, we want to compare it with all of the
# trailing zeros removed. So we'll use a reverse the list, drop all the now
# leading zeros until we come to something non zero, then take the rest
# re-reverse it back into the correct order and make it a tuple and use
# that for our sorting key.
_release = tuple(
reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release))))
)
# We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
# We'll do this by abusing the pre segment, but we _only_ want to do this
# if there is not a pre or a post segment. If we have one of those then
# the normal sorting rules will handle this case correctly.
if pre is None and post is None and dev is not None:
_pre: CmpPrePostDevType = NegativeInfinity
# Versions without a pre-release (except as noted above) should sort after
# those with one.
elif pre is None:
_pre = Infinity
else:
_pre = pre
# Versions without a post segment should sort before those with one.
if post is None:
_post: CmpPrePostDevType = NegativeInfinity
else:
_post = post
# Versions without a development segment should sort after those with one.
if dev is None:
_dev: CmpPrePostDevType = Infinity
else:
_dev = dev
if local is None:
# Versions without a local segment should sort before those with one.
_local: CmpLocalType = NegativeInfinity
else:
# Versions with a local segment need that segment parsed to implement
# the sorting rules in PEP440.
# - Alpha numeric segments sort before numeric segments
# - Alpha numeric segments sort lexicographically
# - Numeric segments sort numerically
# - Shorter versions sort before longer versions when the prefixes
# match exactly
_local = tuple(
(i, "") if isinstance(i, int) else (NegativeInfinity, i) for i in local
)
return epoch, _release, _pre, _post, _dev, _local

Sorry, the diff of this file is too big to display