diff --git a/docs/filenames.rst b/docs/filenames.rst new file mode 100644 index 000000000..10db0b456 --- /dev/null +++ b/docs/filenames.rst @@ -0,0 +1,174 @@ +Filenames +========= + +Tools to work with filenames for SDists and wheels. + +.. versionadded:: 26.4 + +Working with filenames +---------------------- + +To parse a filename, use ``.from_filename`` on +:class:`~packaging.filenames.WheelFilename` or +:class:`~packaging.filenames.SourceDistributionFilename`: + +.. doctest:: + + >>> from packaging.filenames import WheelFilename + >>> wheel_filename = WheelFilename.from_filename("foo-1.0-1-py3-none-any.whl") + +Parsing normalizes filenames. To see if a filename was already normalized, call +``.validate()`` on the parsed filename. It collects all normalization problems +into an :external:exc:`ExceptionGroup`. Each problem has its own exception +class, so you can use ``except*`` to handle or ignore only some of them. The +classes are: + +* :class:`~packaging.filenames.InvalidProjectName`: the name parses, but is + not a valid project name. +* :class:`~packaging.filenames.NonNormalizedName`: the name is valid, but not + normalized. +* :class:`~packaging.filenames.NonNormalizedVersion`: the version is valid, + but not normalized. +* :class:`~packaging.filenames.InvalidSdistFilename` (sdist only): the + extension is ``.zip``, not ``.tar.gz``. +* :class:`~packaging.filenames.NonNormalizedBuildTag` (wheel only): the build + tag is valid, but not normalized. +* :class:`~packaging.filenames.UnsortedWheelTags` (wheel only): the parts of + the compressed tag set are not in sorted order. +* :class:`~packaging.filenames.NonNormalizedTags` (wheel only): the tags are + sorted, but not normalized. + +For example, to accept a non-normalized name and version, but reject all other +problems: + +.. code-block:: python + + from packaging.filenames import ( + NonNormalizedName, + NonNormalizedVersion, + WheelFilename, + ) + + wheel = WheelFilename.from_filename("Foo-01.0-py3-none-any.whl") + try: + wheel.validate() + except* (NonNormalizedName, NonNormalizedVersion): + pass + +On Python 3.10, catch the :class:`~packaging.errors.ExceptionGroup` backport +and process it directly (see :doc:`errors`). + +Using filenames +--------------- + +Filenames have the following properties: + +* ``name``: The normalized name. +* ``version``: The version as a :class:`~packaging.version.Version`. +* ``tags`` (wheel only): A frozenset of :class:`~packaging.tags.Tag`. +* ``build_tag`` (wheel only): An empty tuple, or a tuple of (build number, + build tag suffix). The suffix can be an empty string. +* ``variant`` (wheel only): The variant label, or ``None``. +* ``original_filename``: The filename given to ``.from_filename``, or ``None`` + if the filename was constructed directly or with ``__replace__``. + +There are also a few helper properties for wheels: + +* ``compressed_tags``: The sorted, compressed wheel tags as a string. +* ``build_str``: The build tag as a string, or an empty string. + +The immutable classes support most operations that you would expect: + +* Use :func:`copy.replace` (Python 3.13+) or ``__replace__`` to replace parts + of the filename. +* Equality and hashing work. The number of trailing zeros in the version is + significant, so ``1.0`` and ``1.0.0`` are different. The original filename + is not compared, so a parsed filename can be equal to one that fails + ``.validate()``. +* Pickling and unpickling are supported, and the pickle format is stable. + A pickle keeps the original filename, so ``.validate()`` gives the same + result after unpickling. A filename that is constructed or changed with + ``__replace__`` has no original filename, and ``.validate()`` always + passes. +* Converting to a string (or ``.to_filename()``) produces a fully normalized + filename with the standard extension (``.whl`` or ``.tar.gz``). + +Converting from older utils +--------------------------- + +The older :func:`packaging.utils.parse_wheel_filename` does not support +variant labels (:pep:`825`). To support them, replace the old form: + +.. doctest:: + + >>> from packaging.utils import parse_wheel_filename + >>> name, version, build_tag, tags = parse_wheel_filename( + ... "foo-1.0-1-py3-none-any.whl" + ... ) + +with: + +.. doctest:: + + >>> from packaging.filenames import WheelFilename + >>> wheel = WheelFilename.from_filename("foo-1.0-1-py3-none-any.whl") + >>> name, version, build_tag, tags = ( + ... wheel.name, wheel.version, wheel.build_tag, wheel.tags + ... ) + >>> wheel.variant is None + True + +To replace ``validate_order=True``, call +:meth:`WheelFilename.validate() `. +It reports unsorted tags with +:class:`~packaging.filenames.UnsortedWheelTags`. It also checks that the other +parts of the filename are normalized. To check only the tag order and +reject variant wheels, like ``parse_wheel_filename(..., validate_order=True)``, +this time showing Python 3.10+ compatible syntax: + +.. testcode:: + + import sys + + from packaging.filenames import ( + InvalidWheelFilename, + UnsortedWheelTags, + WheelFilename, + ) + + if sys.version_info < (3, 11): + from packaging.errors import ExceptionGroup + + + def parse_ordered(filename: str) -> WheelFilename: + wheel = WheelFilename.from_filename(filename) + try: + wheel.validate() + except ExceptionGroup as group: + for error in group.exceptions: + if isinstance(error, UnsortedWheelTags): + raise error from None + if wheel.variant is not None: + msg = f"Invalid wheel filename (variant wheels are not supported): {filename!r}" + raise InvalidWheelFilename(msg) + return wheel + +.. doctest:: + + >>> parse_ordered("Foo-1.0-py3.py2-none-any.whl") + Traceback (most recent call last): + ... + packaging.filenames.UnsortedWheelTags: Invalid wheel filename (compressed tag set components must be in sorted order per PEP 425): 'Foo-1.0-py3.py2-none-any.whl' + >>> parse_ordered("foo-1.0-py3-none-any-x86_64_v3.whl") + Traceback (most recent call last): + ... + packaging.filenames.InvalidWheelFilename: Invalid wheel filename (variant wheels are not supported): 'foo-1.0-py3-none-any-x86_64_v3.whl' + >>> str(parse_ordered("foo-1.0-py2.py3-none-any.whl")) + 'foo-1.0-py2.py3-none-any.whl' + +Reference +--------- + +.. automodule:: packaging.filenames + :members: + :inherited-members: diff --git a/docs/index.rst b/docs/index.rst index 368694bc5..119e8da8b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,6 +35,7 @@ The ``packaging`` library uses calendar-based versioning (``YY.N``). direct_url dependency_groups errors + filenames utils .. toctree:: diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py new file mode 100644 index 000000000..769ff6d10 --- /dev/null +++ b/src/packaging/filenames.py @@ -0,0 +1,713 @@ +# 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 __future__ import annotations + +import re +from typing import TYPE_CHECKING, TypedDict + +from .errors import _ErrorCollector +from .tags import InvalidTag, Tag, parse_tag +from .utils import ( + BuildTag, + InvalidFilename, + InvalidName, + InvalidSdistFilename, + InvalidWheelFilename, + UnsortedWheelTags, + _parse_build_tag, + _parse_sdist_filename, + _parse_wheel_filename, + _split_wheel_filename, + _validate_regex, + _variant_label_regex, + canonicalize_name, +) +from .version import InvalidVersion, Version + +if TYPE_CHECKING: + from collections.abc import Iterable + + from typing_extensions import Self, Unpack + + from .utils import NormalizedName + +__all__ = [ + "BuildTag", + "InvalidFilename", + "InvalidProjectName", + "InvalidSdistFilename", + "InvalidWheelFilename", + "NonNormalizedBuildTag", + "NonNormalizedName", + "NonNormalizedTags", + "NonNormalizedVersion", + "SourceDistributionFilename", + "UnsortedWheelTags", + "WheelFilename", +] + + +def __dir__() -> list[str]: + return __all__ + + +class InvalidProjectName(InvalidFilename): + """ + The project name in a filename can be parsed, but is not a valid project + name. + + .. versionadded:: 26.4 + """ + + +class NonNormalizedName(InvalidFilename): + """ + The project name in a filename is valid, but not normalized. + + .. versionadded:: 26.4 + """ + + +class NonNormalizedVersion(InvalidFilename): + """ + The version in a filename is valid, but not normalized. + + .. versionadded:: 26.4 + """ + + +class NonNormalizedBuildTag(InvalidWheelFilename): + """ + The build tag in a wheel filename is valid, but not normalized. + + .. versionadded:: 26.4 + """ + + +class NonNormalizedTags(InvalidWheelFilename): + """ + The tags in a wheel filename are valid, but not normalized. + + .. versionadded:: 26.4 + """ + + +class _SdistReplace(TypedDict, total=False): + name: str + version: Version | str + + +class _WheelReplace(TypedDict, total=False): + name: str + version: Version | str + build_tag: BuildTag + tags: Iterable[Tag] + variant: str | None + + +# A name in the normalized filename form, where "-" is written as "_". +_normalized_wheel_name_regex = re.compile(r"[a-z0-9]+(?:_[a-z0-9]+)*", re.ASCII) +# A release-only version with no leading zeros, which is always normalized. +_normalized_release_regex = re.compile(r"(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*))*") + + +def _is_normalized_version(version_part: str, version: Version) -> bool: + # str(version) is slow, so check the common case first. + return _normalized_release_regex.fullmatch( + version_part + ) is not None or version_part == str(version) + + +def _compress_tags(tags: frozenset[Tag]) -> str: + if not tags: + msg = "Invalid wheel filename (the tag set must have at least one tag)" + raise InvalidWheelFilename(msg) + interpreters = sorted({tag.interpreter for tag in tags}) + abis = sorted({tag.abi for tag in tags}) + platforms = sorted({tag.platform for tag in tags}) + # A compressed tag string always expands to every combination of its fields. + if len(tags) != len(interpreters) * len(abis) * len(platforms): + msg = ( + "Invalid wheel filename (the tag set cannot be compressed, it must " + f"contain every combination: {sorted(map(str, tags))!r})" + ) + raise InvalidWheelFilename(msg) + return "-".join((".".join(interpreters), ".".join(abis), ".".join(platforms))) + + +class WheelFilename: + """Represents a wheel filename and its parsed components. + + Instances are immutable and hashable. Two instances are equal if their + normalized name, normalized version, build tag, tags, and variant are equal. + + Instances are safe to serialize with :mod:`pickle`. They use a stable + format so the same pickle can be loaded in future packaging releases. + + .. versionadded:: 26.4 + """ + + __slots__ = ("_build_tag", "_filename", "_name", "_tags", "_variant", "_version") + __match_args__ = ("name", "version", "tags") + + def __init__( + self, + name: str, + version: Version | str, + tags: Iterable[Tag], + *, + build_tag: BuildTag = (), + variant: str | None = None, + ) -> None: + """Create a wheel filename from its component parts. + + :param name: The project name. It is stored in normalized form. + :param version: The version. + :param tags: The wheel tag set. It must not be empty, and it must + contain every combination of its interpreters, ABIs, and platforms. + Each tag must be valid in a filename (see + :func:`~packaging.tags.parse_tag`). + :param build_tag: Optional wheel build tag. + :param variant: The variant label (see :pep:`825`), or ``None``. + :raises InvalidWheelFilename: If the name, version, tag set, build tag, + or variant label is not valid. + """ + self._filename: str | None = None + self._set( + name=name, version=version, tags=tags, build_tag=build_tag, variant=variant + ) + + def _set(self, **kwargs: Unpack[_WheelReplace]) -> None: + if "name" in kwargs: + name = kwargs["name"] + try: + self._name = canonicalize_name(name, validate=True) + except InvalidName: + msg = f"Invalid wheel filename (invalid project name {name!r})" + raise InvalidWheelFilename(msg) from None + if "version" in kwargs: + version = kwargs["version"] + try: + self._version = ( + version if isinstance(version, Version) else Version(version) + ) + except InvalidVersion as e: + msg = f"Invalid wheel filename (invalid version {version!r})" + raise InvalidWheelFilename(msg) from e + if "build_tag" in kwargs: + build_tag = kwargs["build_tag"] + # The build tag must round-trip through the filename string. + if build_tag and ( + type(build_tag[0]) is not int + or _parse_build_tag("".join(map(str, build_tag))) != build_tag + ): + msg = f"Invalid wheel filename (invalid build tag {build_tag!r})" + raise InvalidWheelFilename(msg) + self._build_tag = build_tag + if "tags" in kwargs: + tags = frozenset(kwargs["tags"]) + # Each tag must parse back to itself, so the filename round-trips. + for tag in tags: + try: + valid = parse_tag(str(tag)) == {tag} + except InvalidTag: + valid = False + if not valid: + msg = f"Invalid wheel filename (invalid tag {str(tag)!r})" + raise InvalidWheelFilename(msg) + _compress_tags(tags) + self._tags = tags + if "variant" in kwargs: + variant = kwargs["variant"] + if variant is not None and _variant_label_regex.fullmatch(variant) is None: + msg = f"Invalid wheel filename (invalid variant label {variant!r})" + raise InvalidWheelFilename(msg) + self._variant = variant + + def __replace__(self, **kwargs: Unpack[_WheelReplace]) -> Self: + """ + __replace__(*, name=..., version=..., tags=..., build_tag=..., variant=...) + + Return a new wheel filename with parts replaced. Only the replaced + parts are checked. + + >>> from packaging.filenames import WheelFilename + >>> from packaging.tags import Tag + >>> wf = WheelFilename("foo", "1.0", {Tag("py3", "none", "any")}) + >>> wf.__replace__(version="2.0").to_filename() + 'foo-2.0-py3-none-any.whl' + + :raises InvalidWheelFilename: If a replaced part is not valid. + """ + if extra := kwargs.keys() - _WheelReplace.__optional_keys__: + msg = f"__replace__() got unexpected keyword arguments: {sorted(extra)}" + raise TypeError(msg) + new = type(self).__new__(type(self)) + for attr in WheelFilename.__slots__: + setattr(new, attr, getattr(self, attr)) + new._filename = None + new._set(**kwargs) + return new + + @property + def name(self) -> NormalizedName: + """The normalized project name.""" + return self._name + + @property + def version(self) -> Version: + """The parsed project version.""" + return self._version + + @property + def build_tag(self) -> BuildTag: + """The build tag, or an empty tuple when absent.""" + return self._build_tag + + @property + def tags(self) -> frozenset[Tag]: + """The wheel tag set.""" + return self._tags + + @property + def variant(self) -> str | None: + """The variant label (see :pep:`825`), or ``None``.""" + return self._variant + + @property + def compressed_tags(self) -> str: + """The compressed and sorted wheel tag string (interpreter-abi-platform). + + >>> from packaging.filenames import WheelFilename + >>> from packaging.tags import Tag + >>> wf = WheelFilename("foo", "1.0", {Tag("py3", "none", "any")}) + >>> wf.compressed_tags + 'py3-none-any' + >>> tags = {Tag("py3", "none", "any"), Tag("py2", "none", "any")} + >>> wf = WheelFilename("foo", "1.0", tags) + >>> wf.compressed_tags + 'py2.py3-none-any' + """ + return _compress_tags(self._tags) + + @property + def build_str(self) -> str: + """The build tag as a string, or an empty string when absent. + + >>> from packaging.filenames import WheelFilename + >>> from packaging.tags import Tag + >>> tags = {Tag("py3", "none", "any")} + >>> wf = WheelFilename("foo", "1.0", tags) + >>> wf.build_str + '' + >>> wf = WheelFilename("foo", "1.0", tags, build_tag=(1, "abc")) + >>> wf.build_str + '1abc' + """ + return "".join(map(str, self._build_tag)) + + @property + def original_filename(self) -> str | None: + """The filename passed to :meth:`from_filename`, or ``None``. + + This is ``None`` if the instance was constructed directly or with + ``__replace__``. + + >>> from packaging.filenames import WheelFilename + >>> WheelFilename.from_filename("Foo-1.0-py3-none-any.whl").original_filename + 'Foo-1.0-py3-none-any.whl' + """ + return self._filename + + def _key(self) -> tuple[object, ...]: + return ( + self._name, + str(self._version), + self._build_tag, + self._tags, + self._variant, + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, WheelFilename): + return NotImplemented + return self._key() == other._key() + + def __hash__(self) -> int: + return hash(self._key()) + + def __getstate__(self) -> str: + return self._filename or self.to_filename() + + def __setstate__(self, state: object) -> None: + if not isinstance(state, str): + raise TypeError(f"Cannot restore {type(self).__name__} from {state!r}") + other = type(self).from_filename(state) + for attr in WheelFilename.__slots__: + setattr(self, attr, getattr(other, attr)) + + def __copy__(self) -> Self: + return self + + def __deepcopy__(self, memo: dict[int, object]) -> Self: + return self + + def __str__(self) -> str: + return self.to_filename() + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(name={self._name!r}, " + f"version={str(self._version)!r}, " + f"tags={self._tags!r}, " + f"build_tag={self._build_tag!r}, " + f"variant={self._variant!r})" + ) + + def to_filename(self) -> str: + """ + Combines a project name, version, build tag, tag set, and variant label + to make a properly formatted wheel filename. + + The project name is normalized such that the non-alphanumeric + characters are replaced with ``_``. The version is normalized. The + tag set is compressed into a wheel tag string. + + >>> from packaging.filenames import WheelFilename + >>> from packaging.tags import Tag + >>> tags = {Tag("py3", "none", "any")} + >>> WheelFilename("foo-bar", "1.0", tags).to_filename() + 'foo_bar-1.0-py3-none-any.whl' + """ + name = self._name.replace("-", "_") + file_parts = [name, str(self._version)] + if self._build_tag: + file_parts.append(self.build_str) + file_parts.append(self.compressed_tags) + if self._variant is not None: + file_parts.append(self._variant) + return "-".join(file_parts) + ".whl" + + @classmethod + def from_filename(cls, filename: str, /) -> Self: + """ + This function takes the filename of a wheel file, and parses it into a + :class:`WheelFilename`. + + A filename with six ``-``-separated parts has either a build tag or a + variant label. It has a build tag if the third part starts with a digit, + since a Python tag never does. Otherwise, the last part is a variant + label (see :pep:`825`). + + Parsing accepts legacy names, versions, and tag orders. Call + :meth:`validate` on the result to require a normalized filename. + + :param str filename: The name of the wheel file. + :raises InvalidWheelFilename: If the filename in question + does not follow the :ref:`wheel specification + `. + + >>> from packaging.filenames import WheelFilename + >>> from packaging.tags import Tag + >>> wf = WheelFilename.from_filename("foo-1.0-py3-none-any.whl") + >>> wf.name + 'foo' + >>> wf.version + + >>> wf.build_tag + () + >>> wf.tags == {Tag("py3", "none", "any")} + True + """ + self = cls.__new__(cls) + self._filename = filename + ( + self._name, + self._version, + self._build_tag, + self._tags, + self._variant, + ) = _parse_wheel_filename(filename) + return self + + def validate(self) -> None: + """ + Check that the filename this was parsed from is in its normalized form. + + The name, version, build tag, and tags must be normalized. This + includes compressed tag set components in the sorted order required by + :pep:`425`. An instance that was not made by :meth:`from_filename` (or + unpickled from one) is always normalized. + + Normalization errors are collected into an + :external:exc:`ExceptionGroup`. Each problem has its own exception + class, such as :class:`NonNormalizedName` or + :class:`UnsortedWheelTags`, so ``except*`` can select the checks to act + on. + + :raises ExceptionGroup: If the filename is not normalized. + + >>> from packaging.filenames import WheelFilename + >>> WheelFilename.from_filename("foo-1.0-py3-none-any.whl").validate() + """ + filename = self._filename + if filename is None: + return + name, version_part, build, tag_str, _ = _split_wheel_filename(filename) + # This is a hot path, so it avoids the on_exit context manager. + collector = _ErrorCollector() + if _normalized_wheel_name_regex.fullmatch(name) is None: + # A valid name that fails the fast check is not normalized. + if _validate_regex.fullmatch(name) is None: + inner = f"invalid project name {name!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(InvalidProjectName(msg)) + else: + inner = f"non-normalized project name {name!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedName(msg)) + if not _is_normalized_version(version_part, self._version): + inner = f"non-normalized version {version_part!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedVersion(msg)) + if build is not None and build != self.build_str: + inner = f"non-normalized build tag {build!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedBuildTag(msg)) + # The tags were parsed already. The string is the compressed form if + # each component is lowercase, sorted, and has no duplicates. + components = [part.split(".") for part in tag_str.split("-")] + if any(part != sorted(part) for part in components): + msg = ( + "Invalid wheel filename (compressed tag set components must be " + f"in sorted order per PEP 425): {filename!r}" + ) + collector.error(UnsortedWheelTags(msg)) + elif tag_str != tag_str.lower() or any( + len(part) != len(set(part)) for part in components + ): + inner = f"non-normalized tags {tag_str!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedTags(msg)) + if collector.errors: + collector.finalize(f"Non-normalized wheel filename: {filename!r}") + + +class SourceDistributionFilename: + """Represents a source distribution filename and its parsed components. + + Instances are immutable and hashable. Two instances are equal if their + normalized name and normalized version are equal. + + Instances are safe to serialize with :mod:`pickle`. They use a stable + format so the same pickle can be loaded in future packaging releases. + + .. versionadded:: 26.4 + """ + + __slots__ = ("_filename", "_name", "_version") + __match_args__ = ("name", "version") + + def __init__(self, name: str, version: Version | str) -> None: + """Create a source distribution filename from name and version. + + :param str name: The project name. It is stored in normalized form. + :param version: The version. + :raises InvalidSdistFilename: If the name or version is not valid. + """ + self._filename: str | None = None + self._set(name=name, version=version) + + def _set(self, **kwargs: Unpack[_SdistReplace]) -> None: + if "name" in kwargs: + name = kwargs["name"] + try: + self._name = canonicalize_name(name, validate=True) + except InvalidName: + msg = f"Invalid sdist filename (invalid project name {name!r})" + raise InvalidSdistFilename(msg) from None + if "version" in kwargs: + version = kwargs["version"] + try: + self._version = ( + version if isinstance(version, Version) else Version(version) + ) + except InvalidVersion as e: + msg = f"Invalid sdist filename (invalid version {version!r})" + raise InvalidSdistFilename(msg) from e + + def __replace__(self, **kwargs: Unpack[_SdistReplace]) -> Self: + """ + __replace__(*, name=..., version=...) + + Return a new sdist filename with parts replaced. Only the replaced + parts are checked. + + >>> from packaging.filenames import SourceDistributionFilename + >>> fn = SourceDistributionFilename("foo", "1.0") + >>> fn.__replace__(version="2.0").to_filename() + 'foo-2.0.tar.gz' + + :raises InvalidSdistFilename: If a replaced part is not valid. + """ + if extra := kwargs.keys() - _SdistReplace.__optional_keys__: + msg = f"__replace__() got unexpected keyword arguments: {sorted(extra)}" + raise TypeError(msg) + new = type(self).__new__(type(self)) + for attr in SourceDistributionFilename.__slots__: + setattr(new, attr, getattr(self, attr)) + new._filename = None + new._set(**kwargs) + return new + + @property + def name(self) -> NormalizedName: + """The normalized project name.""" + return self._name + + @property + def version(self) -> Version: + """The parsed project version.""" + return self._version + + @property + def original_filename(self) -> str | None: + """The filename passed to :meth:`from_filename`, or ``None``. + + This is ``None`` if the instance was constructed directly or with + ``__replace__``. + + >>> from packaging.filenames import SourceDistributionFilename + >>> SourceDistributionFilename.from_filename("Foo-1.0.zip").original_filename + 'Foo-1.0.zip' + """ + return self._filename + + def _key(self) -> tuple[object, ...]: + return (self._name, str(self._version)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SourceDistributionFilename): + return NotImplemented + return self._key() == other._key() + + def __hash__(self) -> int: + return hash(self._key()) + + def __getstate__(self) -> str: + return self._filename or self.to_filename() + + def __setstate__(self, state: object) -> None: + if not isinstance(state, str): + raise TypeError(f"Cannot restore {type(self).__name__} from {state!r}") + other = type(self).from_filename(state) + for attr in SourceDistributionFilename.__slots__: + setattr(self, attr, getattr(other, attr)) + + def __copy__(self) -> Self: + return self + + def __deepcopy__(self, memo: dict[int, object]) -> Self: + return self + + def __str__(self) -> str: + return self.to_filename() + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(name={self._name!r}, " + f"version={str(self._version)!r})" + ) + + def to_filename(self) -> str: + """ + Combines the project name and a version to make a valid sdist filename. The + project name is normalized as required so that any run of ``-._`` + characters are replaced with ``_`` and characters are lower cased. The + version is normalized. + + >>> from packaging.filenames import SourceDistributionFilename + >>> SourceDistributionFilename("foo-bar", "1.0").to_filename() + 'foo_bar-1.0.tar.gz' + """ + name = self._name.replace("-", "_") + return f"{name}-{self._version}.tar.gz" + + @classmethod + def from_filename(cls, filename: str, /) -> Self: + """ + This function takes the filename of a sdist file (as specified + in the `Source distribution format`_ documentation), and parses + it into a :class:`SourceDistributionFilename`. + + Parsing accepts legacy names, versions, and the ``.zip`` extension. Call + :meth:`validate` on the result to require a normalized filename. + + :param str filename: The name of the sdist file. + :raises InvalidSdistFilename: If the filename does not end + with an sdist extension (``.zip`` or ``.tar.gz``), if it does not + contain a dash separating the name and the version of the distribution, + if the project name is empty, or if the version portion is not a valid + version. + + >>> from packaging.filenames import SourceDistributionFilename + >>> fn = SourceDistributionFilename.from_filename("foo-1.0.tar.gz") + >>> fn.name + 'foo' + >>> fn.version + + + .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name + """ + self = cls.__new__(cls) + self._filename = filename + self._name, self._version = _parse_sdist_filename(filename) + return self + + def validate(self) -> None: + """ + Check that the filename this was parsed from is in its normalized form. + + The extension must be ``.tar.gz``, and the name and version must be + normalized. An instance that was not made by :meth:`from_filename` (or + unpickled from one) is always normalized. + + Normalization errors are collected into an + :external:exc:`ExceptionGroup`. Each problem has its own exception + class, such as :class:`NonNormalizedName`, so ``except*`` can select + the checks to act on. + + :raises ExceptionGroup: If the filename is not normalized. + + >>> from packaging.filenames import SourceDistributionFilename + >>> SourceDistributionFilename.from_filename("foo-1.0.tar.gz").validate() + """ + filename = self._filename + if filename is None: + return + ext = ".tar.gz" if filename.endswith(".tar.gz") else ".zip" + stem = filename[: -len(ext)] + name_part, _, version_part = stem.rpartition("-") + # This is a hot path, so it avoids the on_exit context manager. + collector = _ErrorCollector() + if ext != ".tar.gz": + msg = f"Invalid sdist filename (extension must be '.tar.gz'): {filename!r}" + collector.error(InvalidSdistFilename(msg)) + if _normalized_wheel_name_regex.fullmatch(name_part) is None: + # A valid name that fails the fast check is not normalized. + if _validate_regex.fullmatch(name_part) is None: + inner = f"invalid project name {name_part!r}" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + collector.error(InvalidProjectName(msg)) + else: + inner = f"non-normalized project name {name_part!r}" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + collector.error(NonNormalizedName(msg)) + if not _is_normalized_version(version_part, self._version): + inner = f"non-normalized version {version_part!r}" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + collector.error(NonNormalizedVersion(msg)) + if collector.errors: + collector.finalize(f"Non-normalized sdist filename: {filename!r}") diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 9be9ef361..5c4750b52 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -12,6 +12,7 @@ __all__ = [ "BuildTag", + "InvalidFilename", "InvalidName", "InvalidSdistFilename", "InvalidWheelFilename", @@ -51,7 +52,15 @@ class InvalidName(ValueError): """ -class InvalidWheelFilename(ValueError): +class InvalidFilename(ValueError): + """ + An invalid filename was found, users should refer to the packaging user guide. + + .. versionadded:: 26.4 + """ + + +class InvalidWheelFilename(InvalidFilename): """ An invalid wheel filename was found, users should refer to PEP 427. @@ -59,7 +68,7 @@ class InvalidWheelFilename(ValueError): """ -class InvalidSdistFilename(ValueError): +class InvalidSdistFilename(InvalidFilename): """ An invalid sdist filename was found, users should refer to the packaging user guide. @@ -72,11 +81,6 @@ class InvalidSdistFilename(ValueError): r"[a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9]", re.IGNORECASE | re.ASCII ) _normalized_regex = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*", re.ASCII) -# PEP 427: The build number must start with a digit. -_build_tag_regex = re.compile(r"(\d+)(.*)", re.ASCII) -# PEP 427: Valid characters for an escaped project name in a wheel filename. -# Requires at least one character so an empty project name is rejected. -_wheel_name_regex = re.compile(r"^[\w._]+\Z", re.UNICODE) def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName: @@ -197,6 +201,166 @@ def canonicalize_version( return str(_TrimmedRelease(version) if strip_trailing_zero else version) +# PEP 427: The build number must start with a digit. The suffix must not, so +# the number is unambiguous. +_build_tag_regex = re.compile(r"(\d+)([a-z_.][a-z0-9_.]*)?", re.ASCII | re.IGNORECASE) +_variant_label_regex = re.compile(r"[0-9a-z._]{1,16}", re.ASCII) +# PEP 427: Valid characters for an escaped project name in a wheel filename. +# Requires at least one character so an empty project name is rejected. +_wheel_name_regex = re.compile(r"[\w.]+") + + +class UnsortedWheelTags(InvalidWheelFilename): + """ + The compressed tag set components in a wheel filename are not in the + sorted order required by :pep:`425`. + + .. versionadded:: 26.4 + """ + + +def _parse_build_tag(build: str) -> BuildTag: + """Parse a build tag string, returning an empty tuple if it is not valid.""" + build_match = _build_tag_regex.fullmatch(build) + if build_match is None: + return () + return (int(build_match[1]), build_match[2] or "") + + +# Name, version, build tag, tags, and variant label. A plain tuple is faster +# to create than a NamedTuple. +_WheelParts = tuple[str, str, "str | None", str, "str | None"] + + +def _split_wheel_filename(filename: str, *, variants: bool = True) -> _WheelParts: + """Split a wheel filename into its raw parts. + + With ``variants`` false, a seventh part or a sixth part that is not a + build number is rejected. + """ + if not filename.endswith(".whl"): + msg = f"Invalid wheel filename (extension must be '.whl'): {filename!r}" + raise InvalidWheelFilename(msg) + + stem = filename[:-4] + dashes = stem.count("-") + if dashes == 4: + name, version_part, tag_str = stem.split("-", 2) + return name, version_part, None, tag_str, None + if dashes not in {5, 6}: + msg = f"Invalid wheel filename (wrong number of parts): {filename!r}" + raise InvalidWheelFilename(msg) + if dashes == 6 and not variants: + msg = ( + "Invalid wheel filename (variant wheels are not supported, " + f"use packaging.filenames.WheelFilename instead for support): {filename!r}" + ) + raise InvalidWheelFilename(msg) + + name, version_part, third, rest = stem.split("-", 3) + + # Six parts are ambiguous: a build tag or a variant label (PEP 825). + # A build tag starts with a digit, and a Python tag never does. + if third[:1].isdigit(): + if dashes == 5: + return name, version_part, third, rest, None + tag_str, _, variant = rest.rpartition("-") + return name, version_part, third, tag_str, variant + if dashes == 6: + msg = f"Invalid wheel filename (invalid build number {third!r}): {filename!r}" + raise InvalidWheelFilename(msg) + if not variants: + msg = ( + "Invalid wheel filename (invalid build number or unsupported " + f"variant label): {filename!r}" + ) + raise InvalidWheelFilename(msg) + tag_rest, _, variant = rest.rpartition("-") + return name, version_part, None, f"{third}-{tag_rest}", variant + + +def _parse_wheel_filename( + filename: str, *, variants: bool = True, validate_order: bool = False +) -> tuple[NormalizedName, Version, BuildTag, frozenset[Tag], str | None]: + """Parse a wheel filename, accepting legacy names and versions.""" + name, version_part, build, tag_str, variant = _split_wheel_filename( + filename, variants=variants + ) + try: + tags = parse_tag(tag_str, validate_order=validate_order) + except UnsortedTagsError: + msg = ( + "Invalid wheel filename (compressed tag set components must be in " + f"sorted order per PEP 425): {filename!r}" + ) + raise UnsortedWheelTags(msg) from None + except InvalidTag: + inner = f"invalid tag component {tag_str!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) from None + + # See PEP 427 for the rules on escaping the project name. + if "__" in name or _wheel_name_regex.fullmatch(name) is None: + inner = f"invalid project name {name!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) + + try: + version = Version(version_part) + except InvalidVersion as e: + inner = f"invalid version {version_part!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) from e + + if variant is not None and _variant_label_regex.fullmatch(variant) is None: + inner = f"invalid variant label {variant!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) + + build_tag: BuildTag = () + if build is not None: + build_tag = _parse_build_tag(build) + if not build_tag: + inner = f"invalid build tag {build!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) + + return canonicalize_name(name), version, build_tag, tags, variant + + +def _parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: + """Parse a sdist filename, accepting legacy names and versions.""" + # PEP 625: Source distributions must end with .tar.gz. Parsing also + # accepts .zip for backward compatibility. + if filename.endswith(".tar.gz"): + file_stem = filename[: -len(".tar.gz")] + elif filename.endswith(".zip"): + file_stem = filename[: -len(".zip")] + else: + inner = "extension must be '.tar.gz' or '.zip'" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + raise InvalidSdistFilename(msg) + + # PEP 625: Source distributions may only have one hyphen, separating + # the name and version. Validation rejects extra hyphens via the + # normalized name check. + name_part, sep, version_part = file_stem.rpartition("-") + if not sep: + inner = "hyphen must separate name and version parts" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + raise InvalidSdistFilename(msg) + if not name_part: + msg = f"Invalid sdist filename (empty project name): {filename!r}" + raise InvalidSdistFilename(msg) + try: + version = Version(version_part) + except InvalidVersion as e: + inner = f"invalid version {version_part!r}" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + raise InvalidSdistFilename(msg) from e + return canonicalize_name(name_part), version + + def parse_wheel_filename( filename: str, *, @@ -224,7 +388,8 @@ def parse_wheel_filename( are in sorted order. :raises InvalidWheelFilename: If the filename in question does not follow the :ref:`wheel specification - `. + `, or if it has a :pep:`825` + variant label. >>> from packaging.utils import parse_wheel_filename >>> from packaging.tags import Tag @@ -252,55 +417,10 @@ def parse_wheel_filename( not an identifier, a tag set component is empty, or the project name is empty. """ - if not filename.endswith(".whl"): - raise InvalidWheelFilename( - f"Invalid wheel filename (extension must be '.whl'): {filename!r}" - ) - - filename = filename[:-4] - dashes = filename.count("-") - if dashes not in (4, 5): - raise InvalidWheelFilename( - f"Invalid wheel filename (wrong number of parts): {filename!r}" - ) - - 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 _wheel_name_regex.match(name_part) is None: - raise InvalidWheelFilename(f"Invalid project name: {filename!r}") - name = canonicalize_name(name_part) - - try: - version = Version(parts[1]) - except InvalidVersion as e: - raise InvalidWheelFilename( - f"Invalid wheel filename (invalid version): {filename!r}" - ) 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!r}" - ) - build = cast("BuildTag", (int(build_match.group(1)), build_match.group(2))) - else: - build = () - tag_str = parts[-1] - try: - tags = parse_tag(tag_str, validate_order=validate_order) - except UnsortedTagsError: - raise InvalidWheelFilename( - f"Invalid wheel filename (compressed tag set components must be in " - f"sorted order per PEP 425): {filename!r}" - ) from None - except InvalidTag: - raise InvalidWheelFilename( - f"Invalid wheel filename (invalid tag component): {filename!r}" - ) from None - return (name, version, build, tags) + name, version, build_tag, tags, _ = _parse_wheel_filename( + filename, variants=False, validate_order=validate_order + ) + return (name, version, build_tag, tags) def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: @@ -338,33 +458,4 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name """ - if filename.endswith(".tar.gz"): - file_stem = filename[: -len(".tar.gz")] - elif filename.endswith(".zip"): - file_stem = filename[: -len(".zip")] - else: - raise InvalidSdistFilename( - f"Invalid sdist filename (extension must be '.tar.gz' or '.zip'):" - f" {filename!r}" - ) - - # 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!r}") - if not name_part: - raise InvalidSdistFilename( - f"Invalid sdist filename (empty project name): {filename!r}" - ) - - name = canonicalize_name(name_part) - - try: - version = Version(version_part) - except InvalidVersion as e: - raise InvalidSdistFilename( - f"Invalid sdist filename (invalid version): {filename!r}" - ) from e - - return (name, version) + return _parse_sdist_filename(filename) diff --git a/tests/test_filenames.py b/tests/test_filenames.py new file mode 100644 index 000000000..47c108b6a --- /dev/null +++ b/tests/test_filenames.py @@ -0,0 +1,809 @@ +# 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 __future__ import annotations + +import copy +import pickle +import subprocess +import sys +import typing + +import pytest + +from packaging.errors import ExceptionGroup +from packaging.filenames import ( + InvalidFilename, + InvalidProjectName, + InvalidSdistFilename, + InvalidWheelFilename, + NonNormalizedBuildTag, + NonNormalizedName, + NonNormalizedTags, + NonNormalizedVersion, + SourceDistributionFilename, + UnsortedWheelTags, + WheelFilename, +) +from packaging.tags import Tag +from packaging.utils import canonicalize_name +from packaging.version import Version + +if typing.TYPE_CHECKING: + from packaging.filenames import BuildTag + + +@pytest.mark.parametrize( + ("name", "version", "expected_filename"), + [ + ( + "valid.name", # Name is not canonical (punctuation) + "1.0", + "valid_name-1.0.tar.gz", + ), + ( + "valid__name", # Name is not canonical (punctuation) + "1.0", + "valid_name-1.0.tar.gz", + ), + ( + "VALID_NAME", # Name is not canonical (casing) + "1.0", + "valid_name-1.0.tar.gz", + ), + ( + "valid_name", + "01.0", # Version is not canonical + "valid_name-1.0.tar.gz", + ), + ("foo", "1.0", "foo-1.0.tar.gz"), + ("foo-bar", "1.0", "foo_bar-1.0.tar.gz"), + ], +) +def test_sdist_init(name: str, version: str, expected_filename: str) -> None: + fn = SourceDistributionFilename(name, version) + assert fn.to_filename() == expected_filename + assert str(fn) == expected_filename + assert fn.name == canonicalize_name(name) + assert fn.version == Version(version) + SourceDistributionFilename.from_filename(expected_filename).validate() + assert SourceDistributionFilename.from_filename(expected_filename) == fn + + +@pytest.mark.parametrize( + ("filename", "error_message", "error_type"), + [ + ( + "bad.extension", # Bad extension + "Invalid sdist filename (extension must be '.tar.gz' or '.zip')", + InvalidSdistFilename, + ), + ( + "foo-1.0.zip", # Legacy extension + "Invalid sdist filename (extension must be '.tar.gz')", + InvalidSdistFilename, + ), + ( + "extra-hyphens-1.0-9.tar.gz", # Extra hyphens + "Invalid sdist filename (non-normalized project name 'extra-hyphens-1.0')", + NonNormalizedName, + ), + ( + "no_hyphen.tar.gz", # No hyphen + "Invalid sdist filename (hyphen must separate name and version parts)", + InvalidSdistFilename, + ), + ( + ".invalid.name-1.0.tar.gz", # Name is not valid + "Invalid sdist filename (invalid project name '.invalid.name')", + InvalidProjectName, + ), + ( + "invalid.name-1.0.tar.gz", # Name is not canonical (punctuation) + "Invalid sdist filename (non-normalized project name 'invalid.name')", + NonNormalizedName, + ), + ( + "invalid__name-1.0.tar.gz", # Name is not canonical (punctuation) + "Invalid sdist filename (non-normalized project name 'invalid__name')", + NonNormalizedName, + ), + ( + "INVALID_NAME-1.0.tar.gz", # Name is not canonical (casing) + "Invalid sdist filename (non-normalized project name 'INVALID_NAME')", + NonNormalizedName, + ), + ( + "valid_name-badversion.tar.gz", # Version is not valid + "Invalid sdist filename (invalid version 'badversion')", + InvalidSdistFilename, + ), + ( + "valid_name-01.0.tar.gz", # Version is not canonical + "Invalid sdist filename (non-normalized version '01.0')", + NonNormalizedVersion, + ), + ], +) +def test_validate_sdist_filename_invalid( + filename: str, error_message: str, error_type: type[InvalidFilename] +) -> None: + with pytest.raises((InvalidFilename, ExceptionGroup)) as e: + SourceDistributionFilename.from_filename(filename).validate() + + if isinstance(e.value, ExceptionGroup): + assert e.value.message == f"Non-normalized sdist filename: {filename!r}" + (error,) = e.value.exceptions + else: + error = e.value + assert type(error) is error_type + assert str(error) == f"{error_message}: {filename!r}" + + +def test_validate_sdist_filename_multiple_errors() -> None: + with pytest.raises(ExceptionGroup) as e: + SourceDistributionFilename.from_filename("Foo-01.0.tar.gz").validate() + + assert [type(error) for error in e.value.exceptions] == [ + NonNormalizedName, + NonNormalizedVersion, + ] + + +def test_validate_sdist_filename_zip_is_grouped() -> None: + with pytest.raises(ExceptionGroup) as e: + SourceDistributionFilename.from_filename("Foo-01.0.zip").validate() + + assert [type(error) for error in e.value.exceptions] == [ + InvalidSdistFilename, + NonNormalizedName, + NonNormalizedVersion, + ] + + +def test_sdist_from_filename_invalid_extension() -> None: + with pytest.raises(InvalidSdistFilename) as e: + SourceDistributionFilename.from_filename("foo-1.0.tgz") + assert str(e.value) == ( + "Invalid sdist filename (extension must be '.tar.gz' or '.zip'): 'foo-1.0.tgz'" + ) + + +# Wheels +@pytest.mark.parametrize( + ("filename", "name", "version", "build_tag", "tags"), + [ + ( + "foo-1.0-py3-none-any.whl", + "foo", + Version("1.0"), + (), + {Tag("py3", "none", "any")}, + ), + ( + "some_package-1.0-py3-none-any.whl", + "some-package", + Version("1.0"), + (), + {Tag("py3", "none", "any")}, + ), + ( + "foo-1.0-1000-py3-none-any.whl", + "foo", + Version("1.0"), + (1000, ""), + {Tag("py3", "none", "any")}, + ), + ( + "foo-1.0-1000abc-py3-none-any.whl", + "foo", + Version("1.0"), + (1000, "abc"), + {Tag("py3", "none", "any")}, + ), + ( + "foo-1.0-py2.py3-none-any.whl", # Sorted multiple interpreter tags + "foo", + Version("1.0"), + (), + {Tag("py2", "none", "any"), Tag("py3", "none", "any")}, + ), + ( # Sorted multiple platform tags + "numpy-1.23.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "numpy", + Version("1.23.3"), + (), + { + Tag("cp310", "cp310", "manylinux2014_x86_64"), + Tag("cp310", "cp310", "manylinux_2_17_x86_64"), + }, + ), + ], +) +def test_wheel_from_filename( + filename: str, name: str, version: Version, build_tag: BuildTag, tags: set[Tag] +) -> None: + fn = WheelFilename.from_filename(filename) + fn.validate() + assert fn.name == name + assert fn.version == version + assert fn.build_tag == build_tag + assert fn.tags == tags + assert fn.variant is None + assert fn.to_filename() == filename + + +@pytest.mark.parametrize( + ("filename", "name", "build_tag", "tags", "variant"), + [ + ( + "numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64-x86_64_v3.whl", + "numpy", + (), + {Tag("cp313", "cp313t", "musllinux_1_2_x86_64")}, + "x86_64_v3", + ), + ( + "numpy-2.3.2-1-cp313-cp313t-musllinux_1_2_x86_64-x86_64_v3.whl", + "numpy", + (1, ""), + {Tag("cp313", "cp313t", "musllinux_1_2_x86_64")}, + "x86_64_v3", + ), + ( + "numpy-2.3.2-py2.py3-none-any-null.whl", + "numpy", + (), + {Tag("py2", "none", "any"), Tag("py3", "none", "any")}, + "null", + ), + ( + "numpy-2.3.2-12ab-py3-none-any-0.a_b.whl", + "numpy", + (12, "ab"), + {Tag("py3", "none", "any")}, + "0.a_b", + ), + ( # A third part that does not start with a digit is a Python tag. + "foo-1.0-abc-py3-none-any.whl", + "foo", + (), + {Tag("abc", "py3", "none")}, + "any", + ), + ], +) +def test_wheel_from_filename_variant( + filename: str, name: str, build_tag: BuildTag, tags: set[Tag], variant: str +) -> None: + fn = WheelFilename.from_filename(filename) + fn.validate() + assert fn.name == name + assert fn.build_tag == build_tag + assert fn.tags == tags + assert fn.variant == variant + assert fn.to_filename() == filename + + +@pytest.mark.parametrize( + "filename", + [ + "foo-0-py3-none-any.whl", + "foo_bar-10.20.0-1-py2.py3-none-any.whl", + "foo-1!2.0rc1.post2.dev3+local.1-cp312-cp312-win_amd64.whl", + "foo-1.0-py3-none-any-abc.whl", + ], +) +def test_validate_wheel_filename_valid(filename: str) -> None: + WheelFilename.from_filename(filename).validate() + + +@pytest.mark.parametrize( + "filename", + ["foo-0.tar.gz", "foo_bar-10.20.0.tar.gz", "foo-1!2.0rc1.post2.dev3.tar.gz"], +) +def test_validate_sdist_filename_valid(filename: str) -> None: + SourceDistributionFilename.from_filename(filename).validate() + + +@pytest.mark.parametrize( + ("filename", "error_message", "error_type"), + [ + ( + "foo-1.0.whl", # Missing tags + "Invalid wheel filename (wrong number of parts)", + InvalidWheelFilename, + ), + ( + "foo-1.0-py3-none-any.wheel", # Incorrect file extension (`.wheel`) + "Invalid wheel filename (extension must be '.whl')", + InvalidWheelFilename, + ), + ( + "foo__bar-1.0-py3-none-any.whl", # Invalid name (`__`) + "Invalid wheel filename (invalid project name 'foo__bar')", + InvalidWheelFilename, + ), + ( + "foo#bar-1.0-py3-none-any.whl", # Invalid name (`#`) + "Invalid wheel filename (invalid project name 'foo#bar')", + InvalidWheelFilename, + ), + ( + "foobar-1.x-py3-none-any.whl", # Invalid version (`1.x`) + "Invalid wheel filename (invalid version '1.x')", + InvalidWheelFilename, + ), + ( + # Too many dashes (`-junk-more`) + "foo-1.0-200-py3-none-any-junk-more.whl", + "Invalid wheel filename (wrong number of parts)", + InvalidWheelFilename, + ), + ( + "foo-1.0-abc-py3-none-any-x86.whl", # Build number without a digit + "Invalid wheel filename (invalid build number 'abc')", + InvalidWheelFilename, + ), + ( + "foo-1.0-py3-none-any-X86.whl", # Upper case variant label + "Invalid wheel filename (invalid variant label 'X86')", + InvalidWheelFilename, + ), + ( + "foo-1.0-1-py3-none-any-abcdefghijklmnopq.whl", # Label too long + "Invalid wheel filename (invalid variant label 'abcdefghijklmnopq')", + InvalidWheelFilename, + ), + ( + "foo-1.0-1-py3-none-any-a+b.whl", # Invalid character in label + "Invalid wheel filename (invalid variant label 'a+b')", + InvalidWheelFilename, + ), + ( + "fOo-1.0-py3-none-any.whl", # Non-normalized project name + "Invalid wheel filename (non-normalized project name 'fOo')", + NonNormalizedName, + ), + ( + "_foo-1.0-py3-none-any.whl", # Leading underscore + "Invalid wheel filename (invalid project name '_foo')", + InvalidProjectName, + ), + ( + "\u00e9-1.0-py3-none-any.whl", # Non-ASCII name + "Invalid wheel filename (invalid project name '\u00e9')", + InvalidProjectName, + ), + ( + "foo-1.0-1 abc-py3-none-any.whl", # Build tag suffix with a space + "Invalid wheel filename (invalid build tag '1 abc')", + InvalidWheelFilename, + ), + ( + "foo-1.0-1\nx-py3-none-any.whl", # Build tag suffix with a newline + "Invalid wheel filename (invalid build tag '1\\nx')", + InvalidWheelFilename, + ), + ( + "foo-1.0-1\u00e9-py3-none-any.whl", # Non-ASCII build tag suffix + "Invalid wheel filename (invalid build tag '1\u00e9')", + InvalidWheelFilename, + ), + ( + "foo-01.0-py3-none-any.whl", # Non-normalized version + "Invalid wheel filename (non-normalized version '01.0')", + NonNormalizedVersion, + ), + ( + "foo-1.0-01-py3-none-any.whl", # Non-normalized build tag + "Invalid wheel filename (non-normalized build tag '01')", + NonNormalizedBuildTag, + ), + ( # Unsorted interpreter tags (py3 before py2) + "foo-1.0-py3.py2-none-any.whl", + "Invalid wheel filename (compressed tag set components must be in " + "sorted order per PEP 425)", + UnsortedWheelTags, + ), + ( + # Unsorted platform tags (manylinux_ before manylinux2014) + "numpy-1.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "Invalid wheel filename (compressed tag set components must be in " + "sorted order per PEP 425)", + UnsortedWheelTags, + ), + ( # Duplicate interpreter tags + "foo-1.0-py3.py3-none-any.whl", + "Invalid wheel filename (non-normalized tags 'py3.py3-none-any')", + NonNormalizedTags, + ), + ( # Uppercase tags + "foo-1.0-PY3-none-any.whl", + "Invalid wheel filename (non-normalized tags 'PY3-none-any')", + NonNormalizedTags, + ), + ( # Non-normalized post-release + "foo-1.0post1-py3-none-any.whl", + "Invalid wheel filename (non-normalized version '1.0post1')", + NonNormalizedVersion, + ), + ], +) +def test_validate_wheel_filename_invalid( + filename: str, error_message: str, error_type: type[InvalidFilename] +) -> None: + with pytest.raises((InvalidFilename, ExceptionGroup)) as e: + WheelFilename.from_filename(filename).validate() + + if isinstance(e.value, ExceptionGroup): + assert e.value.message == f"Non-normalized wheel filename: {filename!r}" + (error,) = e.value.exceptions + else: + error = e.value + assert type(error) is error_type + assert str(error) == f"{error_message}: {filename!r}" + + +@pytest.mark.parametrize( + ("filename", "error_types"), + [ + ( + "Foo-01.0-01-py3.py2-none-any.whl", + [ + NonNormalizedName, + NonNormalizedVersion, + NonNormalizedBuildTag, + UnsortedWheelTags, + ], + ), + ("foo-01.0-py3.py3-none-any.whl", [NonNormalizedVersion, NonNormalizedTags]), + ("_foo-01.0-py3-none-any.whl", [InvalidProjectName, NonNormalizedVersion]), + ], +) +def test_validate_wheel_filename_multiple_errors( + filename: str, error_types: list[type[InvalidFilename]] +) -> None: + with pytest.raises(ExceptionGroup) as e: + WheelFilename.from_filename(filename).validate() + + assert [type(error) for error in e.value.exceptions] == error_types + + +@pytest.mark.parametrize( + ("name", "version", "build_tag", "tags", "expected_filename"), + [ + ("valid.name", "1.0", (), None, "valid_name-1.0-py3-none-any.whl"), + ("valid__name", "1.0", (), None, "valid_name-1.0-py3-none-any.whl"), + ("VALID_NAME", "1.0", (), None, "valid_name-1.0-py3-none-any.whl"), + ("valid_name", "01.0", (), None, "valid_name-1.0-py3-none-any.whl"), + ("some-PACKAGE", "1.0", (), None, "some_package-1.0-py3-none-any.whl"), + ( + "foo-bar", + "01.0", + (42, ""), + {Tag("py2", "none", "any"), Tag("py3", "none", "any")}, + "foo_bar-1.0-42-py2.py3-none-any.whl", + ), + ], +) +def test_wheel_init( + name: str, + version: str, + build_tag: BuildTag, + tags: set[Tag] | None, + expected_filename: str, +) -> None: + fn = WheelFilename( + name, version, tags or {Tag("py3", "none", "any")}, build_tag=build_tag + ) + assert fn.to_filename() == expected_filename + assert str(fn) == expected_filename + assert fn.name == canonicalize_name(name) + assert fn.version == Version(version) + WheelFilename.from_filename(expected_filename).validate() + assert WheelFilename.from_filename(expected_filename) == fn + + +def test_parse_and_create_filename() -> None: + filename = "numpy-1.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + sorted_f = "numpy-1.23.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" + + wf = WheelFilename.from_filename(filename) + composed = wf.to_filename() + assert sorted_f == composed + + +@pytest.mark.parametrize( + ("kwargs", "error_message"), + [ + ({"name": ""}, "invalid project name ''"), + ({"name": "foo bar!"}, "invalid project name 'foo bar!'"), + ({"name": "_foo"}, "invalid project name '_foo'"), + ({"version": "not-a-version"}, "invalid version 'not-a-version'"), + ({"build_tag": (-1, "")}, "invalid build tag (-1, '')"), + ({"build_tag": (1, "2")}, "invalid build tag (1, '2')"), + ({"build_tag": (1, "-x")}, "invalid build tag (1, '-x')"), + ({"build_tag": (1.5, "")}, "invalid build tag (1.5, '')"), + ({"build_tag": (True, "")}, "invalid build tag (True, '')"), + ({"variant": ""}, "invalid variant label ''"), + ({"variant": "Bad-Label"}, "invalid variant label 'Bad-Label'"), + ({"variant": "x" * 17}, f"invalid variant label {'x' * 17!r}"), + ({"tags": set()}, "the tag set must have at least one tag"), + ({"tags": {Tag("py3", "none", "")}}, "invalid tag 'py3-none-'"), + ({"tags": {Tag("py3", "none", "a.b")}}, "invalid tag 'py3-none-a.b'"), + ({"tags": {Tag("py3", "none", "a-b")}}, "invalid tag 'py3-none-a-b'"), + ({"tags": {Tag("3py", "none", "any")}}, "invalid tag '3py-none-any'"), + ( + {"tags": {Tag("py3", "none", "any"), Tag("cp314", "cp314", "win_amd64")}}, + "the tag set cannot be compressed, it must contain every combination: " + "['cp314-cp314-win_amd64', 'py3-none-any']", + ), + ], +) +def test_wheel_init_invalid(kwargs: dict[str, typing.Any], error_message: str) -> None: + tags = {Tag("py3", "none", "any")} + args: dict[str, typing.Any] = { + "name": "foo", + "version": "1.0", + "tags": tags, + **kwargs, + } + with pytest.raises(InvalidWheelFilename) as e: + WheelFilename(**args) + assert str(e.value) == f"Invalid wheel filename ({error_message})" + + wf = WheelFilename("foo", "1.0", tags) + with pytest.raises(InvalidWheelFilename) as e: + wf.__replace__(**kwargs) + assert str(e.value) == f"Invalid wheel filename ({error_message})" + + +def test_copy_returns_self() -> None: + wf = WheelFilename("foo", "1.0", {Tag("py3", "none", "any")}) + assert copy.copy(wf) is wf + assert copy.deepcopy(wf) is wf + fn = SourceDistributionFilename("foo", "1.0") + assert copy.copy(fn) is fn + assert copy.deepcopy(fn) is fn + + +def test_replace_does_not_reparse(monkeypatch: pytest.MonkeyPatch) -> None: + wf = WheelFilename("foo", "1.0", {Tag("py3", "none", "any")}) + fn = SourceDistributionFilename("foo", "1.0") + monkeypatch.setattr(WheelFilename, "from_filename", None) + monkeypatch.setattr(SourceDistributionFilename, "from_filename", None) + assert wf.__replace__(version="2.0").version == Version("2.0") + assert fn.__replace__(version="2.0").version == Version("2.0") + + +def test_wheel_replace() -> None: + tags = {Tag("py3", "none", "any")} + wf = WheelFilename("Foo", "1.0", tags, build_tag=(1, "a"), variant="x86_64_v3") + new = wf.__replace__(version="2.0", tags={Tag("cp314", "cp314", "win_amd64")}) + assert new == WheelFilename( + "Foo", + "2.0", + {Tag("cp314", "cp314", "win_amd64")}, + build_tag=(1, "a"), + variant="x86_64_v3", + ) + assert new.name == "foo" + assert wf.__replace__(version=Version("2.0")) == wf.__replace__(version="2.0") + assert wf.__replace__(variant=None, build_tag=()).to_filename() == ( + "foo-1.0-py3-none-any.whl" + ) + with pytest.raises(TypeError, match="unexpected"): + wf.__replace__(nope=1) # type: ignore[call-arg] + + +def test_legacy_name_from_filename() -> None: + # Parsing accepts names that the constructor rejects. + wf = WheelFilename.from_filename("_foo-1.0-py3-none-any.whl") + assert wf.name == "-foo" + assert wf.__replace__(version="2.0").to_filename() == "_foo-2.0-py3-none-any.whl" + fn = SourceDistributionFilename.from_filename("_foo-1.0.tar.gz") + assert fn.__replace__(version="2.0").to_filename() == "_foo-2.0.tar.gz" + + +def test_wheel_repr() -> None: + tags = frozenset({Tag("py3", "none", "any")}) + wf = WheelFilename("Foo.Bar", "01.0", tags, build_tag=(1, "abc")) + assert repr(wf) == ( + f"WheelFilename(name='foo-bar', version='1.0', tags={tags!r}, " + "build_tag=(1, 'abc'), variant=None)" + ) + + +@pytest.mark.parametrize( + ("kwargs", "error_message"), + [ + ({"name": ""}, "invalid project name ''"), + ({"name": "foo bar"}, "invalid project name 'foo bar'"), + ({"version": "not-a-version"}, "invalid version 'not-a-version'"), + ], +) +def test_sdist_init_invalid(kwargs: dict[str, str], error_message: str) -> None: + args = {"name": "foo", "version": "1.0", **kwargs} + with pytest.raises(InvalidSdistFilename) as e: + SourceDistributionFilename(**args) + assert str(e.value) == f"Invalid sdist filename ({error_message})" + + fn = SourceDistributionFilename("foo", "1.0") + with pytest.raises(InvalidSdistFilename) as e: + fn.__replace__(**kwargs) + assert str(e.value) == f"Invalid sdist filename ({error_message})" + + +def test_sdist_replace() -> None: + fn = SourceDistributionFilename("foo", "1.0") + assert fn.__replace__(version="2.0") == SourceDistributionFilename("foo", "2.0") + assert fn.__replace__(name="bar") == SourceDistributionFilename("bar", "1.0") + with pytest.raises(TypeError, match="unexpected"): + fn.__replace__(tags=()) # type: ignore[call-arg] + + +def test_sdist_repr() -> None: + fn = SourceDistributionFilename("Foo", Version("1.0")) + assert repr(fn) == "SourceDistributionFilename(name='foo', version='1.0')" + + +def test_wheel_eq_hash() -> None: + tags = {Tag("py3", "none", "any")} + wf = WheelFilename("foo", "1.0", tags, build_tag=(1, "")) + assert wf == WheelFilename("foo", "1.0", tags, build_tag=(1, "")) + assert hash(wf) == hash(WheelFilename("foo", "1.0", tags, build_tag=(1, ""))) + assert wf == WheelFilename("Foo", "1.0", tags, build_tag=(1, "")) + assert hash(wf) == hash(WheelFilename("Foo", "1.0", tags, build_tag=(1, ""))) + assert wf != WheelFilename("foo", "1.0.0", tags, build_tag=(1, "")) + assert wf != WheelFilename("foo", "1.0", tags) + assert wf != WheelFilename( + "foo", "1.0", tags, build_tag=(1, ""), variant="x86_64_v3" + ) + assert wf != "foo-1.0-1-py3-none-any.whl" + assert len({wf, WheelFilename.from_filename(str(wf))}) == 1 + raw = WheelFilename.from_filename("Foo.Bar-01.0-1-py3-none-any.whl") + assert raw == WheelFilename("foo_bar", "1.0", tags, build_tag=(1, "")) + + +def test_sdist_eq_hash() -> None: + fn = SourceDistributionFilename("foo", "1.0") + assert fn == SourceDistributionFilename("foo", "1.0") + assert hash(fn) == hash(SourceDistributionFilename("foo", "1.0")) + assert fn != SourceDistributionFilename("foo", "1.0.0") + assert fn == SourceDistributionFilename("Foo", "01.0") + assert hash(fn) == hash(SourceDistributionFilename("Foo", "01.0")) + assert fn == SourceDistributionFilename.from_filename("foo-1.0.zip") + assert fn != "foo-1.0.tar.gz" + assert fn != WheelFilename("foo", "1.0", {Tag("py3", "none", "any")}) + + +@pytest.mark.parametrize( + "wf", + [ + WheelFilename("foo", "1.0", tags={Tag("py3", "none", "any")}), + WheelFilename( + "foo", + "1.0", + {Tag("py2", "none", "any"), Tag("py3", "none", "any")}, + build_tag=(1, "abc"), + variant="x86_64_v3", + ), + WheelFilename.from_filename("_foo.-1.0-py3-none-any.whl"), + ], +) +@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) +def test_wheel_pickle(wf: WheelFilename, protocol: int) -> None: + loaded = pickle.loads(pickle.dumps(wf, protocol)) + assert loaded == wf + assert hash(loaded) == hash(wf) + + +def test_wheel_pickle_state() -> None: + tags = {Tag("py3", "none", "any"), Tag("py2", "none", "any")} + wf = WheelFilename("Foo", "01.0", tags, build_tag=(1, "a"), variant="x86_64_v3") + state = "foo-1.0-1a-py2.py3-none-any-x86_64_v3.whl" + assert wf.__getstate__() == state + loaded = WheelFilename.__new__(WheelFilename) + loaded.__setstate__(state) + assert loaded == wf + + +@pytest.mark.parametrize("state", [None, ("foo", "1.0", ("py3-none-any",), (), None)]) +def test_wheel_setstate_invalid(state: object) -> None: + wf = WheelFilename.__new__(WheelFilename) + with pytest.raises(TypeError, match="Cannot restore WheelFilename"): + wf.__setstate__(state) + + +def test_wheel_setstate_invalid_filename() -> None: + wf = WheelFilename.__new__(WheelFilename) + with pytest.raises(InvalidWheelFilename): + wf.__setstate__("foo-1.0.whl") + + +@pytest.mark.parametrize( + "fn", + [ + SourceDistributionFilename("foo", "1.0"), + SourceDistributionFilename.from_filename("-foo--01.0.tar.gz"), + ], +) +@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) +def test_sdist_pickle(fn: SourceDistributionFilename, protocol: int) -> None: + loaded = pickle.loads(pickle.dumps(fn, protocol)) + assert loaded == fn + assert repr(loaded) == repr(fn) + assert hash(loaded) == hash(fn) + + +def test_sdist_pickle_state() -> None: + fn = SourceDistributionFilename("Foo", "01.0") + assert fn.__getstate__() == "foo-1.0.tar.gz" + loaded = SourceDistributionFilename.__new__(SourceDistributionFilename) + loaded.__setstate__("foo-1.0.tar.gz") + assert loaded == fn + + +@pytest.mark.parametrize("state", [None, ("foo", "1.0")]) +def test_sdist_setstate_invalid(state: object) -> None: + fn = SourceDistributionFilename.__new__(SourceDistributionFilename) + with pytest.raises(TypeError, match="Cannot restore SourceDistributionFilename"): + fn.__setstate__(state) + + +def test_validate_constructed() -> None: + tags = {Tag("py3", "none", "any")} + WheelFilename("Foo", "01.0", tags, build_tag=(1, "")).validate() + SourceDistributionFilename("Foo", "01.0").validate() + + +def test_validate_after_replace() -> None: + wf = WheelFilename.from_filename("Foo-01.0-py3-none-any.whl") + with pytest.raises(ExceptionGroup): + wf.validate() + wf.__replace__(build_tag=()).validate() + fn = SourceDistributionFilename.from_filename("Foo-01.0.zip") + with pytest.raises(ExceptionGroup): + fn.validate() + fn.__replace__().validate() + + +def test_original_filename() -> None: + wf = WheelFilename.from_filename("Foo-01.0-py3-none-any.whl") + assert wf.original_filename == "Foo-01.0-py3-none-any.whl" + assert pickle.loads(pickle.dumps(wf)).original_filename == wf.original_filename + assert wf.__replace__().original_filename is None + assert copy.copy(wf).original_filename == wf.original_filename + assert WheelFilename("foo", "1.0", wf.tags).original_filename is None + + fn = SourceDistributionFilename.from_filename("Foo-01.0.zip") + assert fn.original_filename == "Foo-01.0.zip" + assert pickle.loads(pickle.dumps(fn)).original_filename == fn.original_filename + assert fn.__replace__().original_filename is None + assert copy.copy(fn).original_filename == fn.original_filename + assert SourceDistributionFilename("foo", "1.0").original_filename is None + + +def test_validate_after_pickle() -> None: + wf = WheelFilename.from_filename("Foo-01.0-py3-none-any.whl") + assert wf.__getstate__() == "Foo-01.0-py3-none-any.whl" + with pytest.raises(ExceptionGroup): + pickle.loads(pickle.dumps(wf)).validate() + fn = SourceDistributionFilename.from_filename("Foo-01.0.tar.gz") + assert fn.__getstate__() == "Foo-01.0.tar.gz" + with pytest.raises(ExceptionGroup): + pickle.loads(pickle.dumps(fn)).validate() + + +def test_sdist_setstate_invalid_filename() -> None: + fn = SourceDistributionFilename.__new__(SourceDistributionFilename) + with pytest.raises(InvalidSdistFilename): + fn.__setstate__("foo-x.tar.gz") + + +@pytest.mark.parametrize("module", ["filenames", "utils"]) +def test_import_order(module: str) -> None: + code = f"import packaging.{module}; import packaging.filenames, packaging.utils" + subprocess.run([sys.executable, "-c", code], check=True) diff --git a/tests/test_utils.py b/tests/test_utils.py index c543089bc..239359086 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,8 +4,11 @@ from __future__ import annotations +import importlib + import pytest +import packaging.utils from packaging.tags import Tag from packaging.utils import ( InvalidName, @@ -196,7 +199,9 @@ def test_parse_wheel_filename( ("foobar-1.x-py3-none-any.whl"), # Invalid version (`1.x`) # Build number doesn't start with a digit (`abc`) ("foo-1.0-abc-py3-none-any.whl"), - ("foo-1.0-200-py3-none-any-junk.whl"), # Too many dashes (`-junk`) + ("foo-1.0-200-py3-none-any-junk-more.whl"), # Too many dashes + ("foo-1.0-py3-none-any-x86_64_v3.whl"), # Variant label + ("foo-1.0-200-py3-none-any-x86_64_v3.whl"), # Variant label and build ("foo-1.0--none-any.whl"), # Empty interpreter component ("foo-1.0-py3-none-.whl"), # Empty platform component ("foo-1.0-py3.-none-any.whl"), # Empty member in a compressed tag set @@ -253,3 +258,38 @@ def test_parse_sdist_filename(filename: str, name: str, version: Version) -> Non def test_parse_sdist_invalid_filename(filename: str) -> None: with pytest.raises(InvalidSdistFilename): parse_sdist_filename(filename) + + +@pytest.mark.parametrize( + ("filename", "message"), + [ + ( + "foo-1.0-abc-py3-none-any.whl", + "invalid build number or unsupported variant label", + ), + ( + "foo-1.0-200-py3-none-any-x86_64_v3.whl", + "variant wheels are not supported", + ), + ], +) +def test_parse_wheel_filename_variant_message(filename: str, message: str) -> None: + with pytest.raises(InvalidWheelFilename, match=message): + parse_wheel_filename(filename) + + +@pytest.mark.parametrize( + ("name", "module"), + [ + ("InvalidTag", "packaging.tags"), + ("Tag", "packaging.tags"), + ("UnsortedTagsError", "packaging.tags"), + ("parse_tag", "packaging.tags"), + ("InvalidVersion", "packaging.version"), + ("Version", "packaging.version"), + ], +) +def test_backward_compatible_names(name: str, module: str) -> None: + assert getattr(packaging.utils, name) is getattr( + importlib.import_module(module), name + )