From 9cbc68959da54a5def43ff0c14bcf55e0182a9fc Mon Sep 17 00:00:00 2001 From: Eldon Allred Date: Fri, 5 Mar 2021 23:30:01 -0500 Subject: [PATCH 01/53] Added packaging.utils.create_wheel_filename and create_sdist_filename functions --- src/packaging/utils.py | 75 +++++++++++++++++++++++++++++++++++++++++- tests/test_utils.py | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 9be9ef361..ac6e6dd10 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -5,11 +5,14 @@ from __future__ import annotations import re -from typing import NewType, cast +from typing import TYPE_CHECKING, NewType, cast from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag from .version import InvalidVersion, Version, _TrimmedRelease +if TYPE_CHECKING: + from collections.abc import Set as AbstractSet + __all__ = [ "BuildTag", "InvalidName", @@ -18,6 +21,8 @@ "NormalizedName", "canonicalize_name", "canonicalize_version", + "create_sdist_filename", + "create_wheel_filename", "is_normalized_name", "parse_sdist_filename", "parse_wheel_filename", @@ -77,6 +82,7 @@ class InvalidSdistFilename(ValueError): # 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) +_distribution_regex = re.compile(r"[^\w\d.]+", re.ASCII) def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName: @@ -197,6 +203,56 @@ def canonicalize_version( return str(_TrimmedRelease(version) if strip_trailing_zero else version) +def _join_tag_attr(tags: AbstractSet[Tag], field: str) -> str: + return ".".join(sorted({getattr(tag, field) for tag in tags})) + + +def _compress_tag_set(tags: AbstractSet[Tag]) -> str: + return "-".join(_join_tag_attr(tags, x) for x in ("interpreter", "abi", "platform")) + + +def create_wheel_filename( + name: str, version: Version, build: BuildTag | None, tags: AbstractSet[Tag] +) -> str: + """ + Combines a project name, version, build tag, and tag set + to make a properly formatted wheel filename. + + The project name is normalized such that the non-alphanumeric + characters are replaced with ``_``. The version is an instance of + :class:`~packaging.version.Version`. The build tag can be None, + an empty tuple or a two-item tuple of an integer and a string. + The tags is set of tags that will be compressed into a wheel + tag string. + + :param name: The project name + :param version: The project version + :param build: An optional two-item tuple of an integer and string + :param tags: The set of tags that apply to the wheel + + >>> from packaging.utils import create_wheel_filename + >>> from packaging.tags import Tag + >>> from packaging.version import Version + >>> version = Version("1.0") + >>> tags = {Tag("py3", "none", "any")} + >>> create_wheel_filename("foo-bar", version, None, tags) + 'foo_bar-1.0-py3-none-any.whl' + + .. versionadded:: 26.1 + """ + norm_name = _distribution_regex.sub("_", name) + compressed_tag = _compress_tag_set(tags) + + parts: tuple[str, ...] + + if build: + parts = norm_name, str(version), "".join(map(str, build)), compressed_tag + else: + parts = norm_name, str(version), compressed_tag + + return "-".join(parts) + ".whl" + + def parse_wheel_filename( filename: str, *, @@ -303,6 +359,23 @@ def parse_wheel_filename( return (name, version, build, tags) +def create_sdist_filename(name: str, version: Version) -> str: + """ + Combines the project name and a version to make a valid sdist filename. + + :param name: The project name + :param version: The project version + + >>> from packaging.utils import create_sdist_filename + >>> from packaging.version import Version + >>> "foo_bar-1.0.tar.gz" == create_sdist_filename("foo-bar", Version("1.0")) + True + + .. versionadded:: 26.1 + """ + return f"{_distribution_regex.sub('_', name)}-{version}.tar.gz" + + def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: """ This function takes the filename of a sdist file (as specified diff --git a/tests/test_utils.py b/tests/test_utils.py index c543089bc..29f733d11 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,11 +8,14 @@ from packaging.tags import Tag from packaging.utils import ( + BuildTag, InvalidName, InvalidSdistFilename, InvalidWheelFilename, canonicalize_name, canonicalize_version, + create_sdist_filename, + create_wheel_filename, is_normalized_name, parse_sdist_filename, parse_wheel_filename, @@ -108,6 +111,52 @@ def test_canonicalize_version_no_strip_trailing_zero(version: str) -> None: assert canonicalize_version(version, strip_trailing_zero=False) == version +@pytest.mark.parametrize( + ("filename", "name", "version", "build", "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_bar-1.0-42-py2.py3-none-any.whl", + "foo-bar", + Version("1.0"), + (42, ""), + {Tag("py2", "none", "any"), Tag("py3", "none", "any")}, + ), + ], +) +def test_create_wheel_filename( + filename: str, name: str, version: Version, build: BuildTag | None, tags: set[Tag] +) -> None: + assert create_wheel_filename(name, version, build, tags) == filename + + @pytest.mark.parametrize( ("filename", "name", "version", "build", "tags"), [ @@ -232,6 +281,17 @@ def test_parse_wheel_unsorted_tags_invalid_with_validate(filename: str) -> None: parse_wheel_filename(filename, validate_order=True) +@pytest.mark.parametrize( + ("filename", "name", "version"), + [ + ("foo-1.0.tar.gz", "foo", Version("1.0")), + ("foo_bar-1.0.tar.gz", "foo-bar", Version("1.0")), + ], +) +def test_create_sdist_filename(filename: str, name: str, version: Version) -> None: + assert create_sdist_filename(name, version) == filename + + @pytest.mark.parametrize( ("filename", "name", "version"), [("foo-1.0.tar.gz", "foo", Version("1.0")), ("foo-1.0.zip", "foo", Version("1.0"))], From fd4d06c21d8a992b174f7af02fadc82a2cbb8991 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 11:49:53 -0500 Subject: [PATCH 02/53] fix: always normalize names Signed-off-by: Henry Schreiner docs: cleanup Signed-off-by: Henry Schreiner --- src/packaging/utils.py | 10 +++++++--- tests/test_utils.py | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index ac6e6dd10..c29f86906 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -240,7 +240,7 @@ def create_wheel_filename( .. versionadded:: 26.1 """ - norm_name = _distribution_regex.sub("_", name) + norm_name = canonicalize_name(name).replace("-", "_") compressed_tag = _compress_tag_set(tags) parts: tuple[str, ...] @@ -361,7 +361,10 @@ def parse_wheel_filename( def create_sdist_filename(name: str, version: Version) -> str: """ - Combines the project name and a version to make a valid sdist filename. + 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 an instance of :class:`~packaging.version.Version`. :param name: The project name :param version: The project version @@ -373,7 +376,8 @@ def create_sdist_filename(name: str, version: Version) -> str: .. versionadded:: 26.1 """ - return f"{_distribution_regex.sub('_', name)}-{version}.tar.gz" + norm_name = canonicalize_name(name).replace("-", "_") + return f"{norm_name}-{version}.tar.gz" def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: diff --git a/tests/test_utils.py b/tests/test_utils.py index 29f733d11..e0d25ae4d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -122,8 +122,8 @@ def test_canonicalize_version_no_strip_trailing_zero(version: str) -> None: {Tag("py3", "none", "any")}, ), ( - "some_PACKAGE-1.0-py3-none-any.whl", - "some-package", + "some_package-1.0-py3-none-any.whl", + "some-PACKAGE", Version("1.0"), (), {Tag("py3", "none", "any")}, From cf6539a15780265bcf129d444f74a10688bf1301 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 11:56:57 -0500 Subject: [PATCH 03/53] refactor: create -> compose Signed-off-by: Henry Schreiner --- src/packaging/utils.py | 16 ++++++++-------- tests/test_utils.py | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index c29f86906..6970d4a8b 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -21,8 +21,8 @@ "NormalizedName", "canonicalize_name", "canonicalize_version", - "create_sdist_filename", - "create_wheel_filename", + "compose_sdist_filename", + "compose_wheel_filename", "is_normalized_name", "parse_sdist_filename", "parse_wheel_filename", @@ -211,7 +211,7 @@ def _compress_tag_set(tags: AbstractSet[Tag]) -> str: return "-".join(_join_tag_attr(tags, x) for x in ("interpreter", "abi", "platform")) -def create_wheel_filename( +def compose_wheel_filename( name: str, version: Version, build: BuildTag | None, tags: AbstractSet[Tag] ) -> str: """ @@ -230,12 +230,12 @@ def create_wheel_filename( :param build: An optional two-item tuple of an integer and string :param tags: The set of tags that apply to the wheel - >>> from packaging.utils import create_wheel_filename + >>> from packaging.utils import compose_wheel_filename >>> from packaging.tags import Tag >>> from packaging.version import Version >>> version = Version("1.0") >>> tags = {Tag("py3", "none", "any")} - >>> create_wheel_filename("foo-bar", version, None, tags) + >>> compose_wheel_filename("foo-bar", version, None, tags) 'foo_bar-1.0-py3-none-any.whl' .. versionadded:: 26.1 @@ -359,7 +359,7 @@ def parse_wheel_filename( return (name, version, build, tags) -def create_sdist_filename(name: str, version: Version) -> str: +def compose_sdist_filename(name: str, version: Version) -> 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 ``-._`` @@ -369,9 +369,9 @@ def create_sdist_filename(name: str, version: Version) -> str: :param name: The project name :param version: The project version - >>> from packaging.utils import create_sdist_filename + >>> from packaging.utils import compose_sdist_filename >>> from packaging.version import Version - >>> "foo_bar-1.0.tar.gz" == create_sdist_filename("foo-bar", Version("1.0")) + >>> "foo_bar-1.0.tar.gz" == compose_sdist_filename("foo-bar", Version("1.0")) True .. versionadded:: 26.1 diff --git a/tests/test_utils.py b/tests/test_utils.py index e0d25ae4d..302c7a943 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -14,8 +14,8 @@ InvalidWheelFilename, canonicalize_name, canonicalize_version, - create_sdist_filename, - create_wheel_filename, + compose_sdist_filename, + compose_wheel_filename, is_normalized_name, parse_sdist_filename, parse_wheel_filename, @@ -151,10 +151,10 @@ def test_canonicalize_version_no_strip_trailing_zero(version: str) -> None: ), ], ) -def test_create_wheel_filename( +def test_compose_wheel_filename( filename: str, name: str, version: Version, build: BuildTag | None, tags: set[Tag] ) -> None: - assert create_wheel_filename(name, version, build, tags) == filename + assert compose_wheel_filename(name, version, build, tags) == filename @pytest.mark.parametrize( @@ -288,8 +288,8 @@ def test_parse_wheel_unsorted_tags_invalid_with_validate(filename: str) -> None: ("foo_bar-1.0.tar.gz", "foo-bar", Version("1.0")), ], ) -def test_create_sdist_filename(filename: str, name: str, version: Version) -> None: - assert create_sdist_filename(name, version) == filename +def test_compose_sdist_filename(filename: str, name: str, version: Version) -> None: + assert compose_sdist_filename(name, version) == filename @pytest.mark.parametrize( From 42f69b57fe0c621e6a41ad76e2d08cc64c9c3d2c Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 12:04:51 -0500 Subject: [PATCH 04/53] tests: add a parse and compose test Signed-off-by: Henry Schreiner --- tests/test_utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index 302c7a943..78147b1f2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -257,6 +257,15 @@ def test_parse_wheel_invalid_filename(filename: str) -> None: parse_wheel_filename(filename) +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" + + name, version, build, tags = parse_wheel_filename(filename) + composed = compose_wheel_filename(name, version, build, tags) + assert sorted_f == composed + + @pytest.mark.parametrize( "filename", [ From 7bf745df5b5337b5654a7f9915ffd4b65f372852 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 12:11:32 -0500 Subject: [PATCH 05/53] fix(types): support any iterable for the tag set Signed-off-by: Henry Schreiner --- src/packaging/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 6970d4a8b..774514373 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -11,7 +11,7 @@ from .version import InvalidVersion, Version, _TrimmedRelease if TYPE_CHECKING: - from collections.abc import Set as AbstractSet + from collections.abc import Iterable __all__ = [ "BuildTag", @@ -203,16 +203,16 @@ def canonicalize_version( return str(_TrimmedRelease(version) if strip_trailing_zero else version) -def _join_tag_attr(tags: AbstractSet[Tag], field: str) -> str: +def _join_tag_attr(tags: Iterable[Tag], field: str) -> str: return ".".join(sorted({getattr(tag, field) for tag in tags})) -def _compress_tag_set(tags: AbstractSet[Tag]) -> str: +def _compress_tag_set(tags: Iterable[Tag]) -> str: return "-".join(_join_tag_attr(tags, x) for x in ("interpreter", "abi", "platform")) def compose_wheel_filename( - name: str, version: Version, build: BuildTag | None, tags: AbstractSet[Tag] + name: str, version: Version, build: BuildTag | None, tags: Iterable[Tag] ) -> str: """ Combines a project name, version, build tag, and tag set From 5e3529e9adb8a894256fd924b5d29850444015af Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Mon, 25 Nov 2024 20:59:31 +0000 Subject: [PATCH 06/53] Add packaging.filenames --- src/packaging/filenames.py | 240 +++++++++++++++++++++++++++++++++++++ src/packaging/utils.py | 10 +- tests/test_filenames.py | 234 ++++++++++++++++++++++++++++++++++++ 3 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 src/packaging/filenames.py create mode 100644 tests/test_filenames.py diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py new file mode 100644 index 000000000..fa5fdcbb6 --- /dev/null +++ b/src/packaging/filenames.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import re +from typing import Any, cast + +from .tags import parse_tag +from .utils import ( + BuildTag, + InvalidFilename, + InvalidSdistFilename, + InvalidWheelFilename, + _build_tag_regex, + canonicalize_name, + is_normalized_name, +) +from .version import InvalidVersion, Version + + +class Filename: + def __init__(self, *a: Any, **kw: Any) -> None: + raise NotImplementedError("Use a WheelFilename or SourceFilename instead") + + @classmethod + def from_filename(cls, filename: str) -> Filename: + if filename.endswith(".whl"): + return WheelFilename.from_filename(filename) + elif filename.endswith(".tar.gz"): + return SourceFilename.from_filename(filename) + else: + raise InvalidFilename( + "Invalid filename (extension must be '.whl' or '.tar.gz'): " + f"{filename!r}" + ) + + +class WheelFilename(Filename): + def __init__( + self, + name: str, + version: str, + build_tag: str | None, + python_tag: str, + abi_tag: str, + platform_tag: str, + strict: bool = True, + ) -> None: + self.original_name = name + self.original_version = version + + filename = self._to_filename( + name, version, build_tag, python_tag, abi_tag, platform_tag + ) + + # See PEP 427 for the rules on escaping the project name. + if ( + strict + and "__" in name + or re.match(r"^[\w\d._]*$", name, re.UNICODE) is None + ): + raise InvalidWheelFilename( + f"Invalid filename (invalid project name {name!r}): {filename!r}" + ) + + self.name = canonicalize_name(name).replace("-", "_") + + # Check that the name is normalized + if strict and self.original_name != self.name: + raise InvalidWheelFilename( + f"Invalid filename (non-normalized project name {name!r}): {filename!r}" + ) + + try: + self.version = Version(version) + except InvalidVersion as e: + raise InvalidWheelFilename( + f"Invalid filename (invalid version {version!r}): {filename!r}" + ) from e + + # Check that the version is normalized + if strict and version != str(self.version): + raise InvalidWheelFilename( + f"Invalid filename (non-normalized version {version!r}): {filename!r}" + ) + + if build_tag: + build_match = _build_tag_regex.match(build_tag) + if build_match is None: + raise InvalidWheelFilename( + f"Invalid filename (invalid build number {build_tag!r}): " + f"{filename!r}" + ) + self.build_tag = cast( + BuildTag, (int(build_match.group(1)), build_match.group(2)) + ) + else: + self.build_tag = () + + self.python_tag = python_tag + self.abi_tag = abi_tag + self.platform_tag = platform_tag + self.tags = parse_tag("-".join((python_tag, abi_tag, platform_tag))) + + def _to_filename( + self, + name: str, + version: str | Version, + build_tag: str | BuildTag | None, + python_tag: str, + abi_tag: str, + platform_tag: str, + ) -> str: + return ( + "-".join( + part + for part in [ + name, + str(version), + ( + "".join(str(x) for x in build_tag) + if isinstance(build_tag, tuple) + else build_tag + ), + python_tag, + abi_tag, + platform_tag, + ] + if part + ) + + ".whl" + ) + + def __str__(self) -> str: + return self._to_filename( + self.name, + self.version, + self.build_tag, + self.python_tag, + self.abi_tag, + self.platform_tag, + ) + + @classmethod + def from_filename(cls, filename: str) -> WheelFilename: + if not filename.endswith(".whl"): + raise InvalidWheelFilename( + f"Invalid filename (extension must be '.whl'): {filename!r}" + ) + + dashes = filename.count("-") + if dashes not in (4, 5): + raise InvalidWheelFilename( + f"Invalid filename (wrong number of parts): {filename!r}" + ) + + filename = filename[: -len(".whl")] + + # There is no build tag + if dashes == 4: + name, version, python_tag, abi_tag, platform_tag = filename.split("-") + build_tag = None + + # There is a build tag + if dashes == 5: + ( + name, + version, + build_tag, + python_tag, + abi_tag, + platform_tag, + ) = filename.split("-") + + return cls(name, version, build_tag, python_tag, abi_tag, platform_tag) + + +class SourceFilename(Filename): + def __init__(self, name: str, version: str, strict: bool = True) -> None: + # Store the values that were originally passed for use externally + self.original_name = name + self.original_version = version + + filename = self._to_filename(name, version) + + # Check that the name is normalized + if not is_normalized_name(canonicalize_name(name)): + raise InvalidSdistFilename( + f"Invalid filename (invalid project name {name!r}): {filename!r}" + ) + self.name = canonicalize_name(name).replace("-", "_") + # PEP 625: The name must only contain underscores + if strict and self.original_name != self.name: + raise InvalidSdistFilename( + f"Invalid filename (non-normalized project name {name!r}): {filename!r}" + ) + + # Check that the version is valid + try: + self.version = Version(version) + except InvalidVersion as e: + raise InvalidSdistFilename( + f"Invalid filename (invalid version {version!r}): {filename!r}" + ) from e + # Check that the version is normalized + if strict and version != str(self.version): + raise InvalidSdistFilename( + f"Invalid filename (non-normalized version {version!r}): {filename!r}" + ) + + def _to_filename(self, name: str, version: str | Version) -> str: + return f"{ name }-{ version }.tar.gz" + + def __str__(self) -> str: + return self._to_filename(self.name, self.version) + + @classmethod + def from_filename(cls, filename: str) -> SourceFilename: + # PEP 625: Source distributions must end with .tar.gz + if not filename.endswith(".tar.gz"): + raise InvalidSdistFilename( + f"Invalid filename (extension must be '.tar.gz'): {filename!r}" + ) + + # PEP 625: Source distributions may only have one hyphen, separating + # the name and version + if filename.count("-") > 1: + raise InvalidSdistFilename( + "Invalid filename (name and version parts can not contain hyphens): " + f"{filename!r}" + ) + + if filename.count("-") == 0: + raise InvalidSdistFilename( + "Invalid filename (hyphen must separate name and version parts): " + f"{filename!r}" + ) + + # Split the filename into name & version + name, version = filename[: -len(".tar.gz")].split("-") + + return cls(name, version) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 774514373..453d96375 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -56,7 +56,13 @@ class InvalidName(ValueError): """ -class InvalidWheelFilename(ValueError): +class InvalidFilename(ValueError): + """ + . + """ + + +class InvalidWheelFilename(InvalidFilename): """ An invalid wheel filename was found, users should refer to PEP 427. @@ -64,7 +70,7 @@ class InvalidWheelFilename(ValueError): """ -class InvalidSdistFilename(ValueError): +class InvalidSdistFilename(InvalidFilename): """ An invalid sdist filename was found, users should refer to the packaging user guide. diff --git a/tests/test_filenames.py b/tests/test_filenames.py new file mode 100644 index 000000000..834fd9d20 --- /dev/null +++ b/tests/test_filenames.py @@ -0,0 +1,234 @@ +import pytest + +from packaging.filenames import ( + Filename, + InvalidFilename, + InvalidWheelFilename, + SourceFilename, + WheelFilename, +) +from packaging.tags import Tag +from packaging.version import Version + + +def test_generic_from_filename_bad_extension(): + with pytest.raises(InvalidFilename) as e: + Filename.from_filename("something.wrong") + + assert str(e.value) == ( + "Invalid filename (extension must be '.whl' or '.tar.gz'): 'something.wrong'" + ) + + +@pytest.mark.parametrize( + "filename", ["sample_project-4.0.0.tar.gz", "sample_project-4.0.0-py3-none-any.whl"] +) +def test_generic_from_filename_passes(filename): + fn = Filename.from_filename(filename) + assert fn.name == fn.original_name == "sample_project" + assert str(fn.version) == str(fn.original_version) == "4.0.0" + + +def test_initialize_generic_filename_unimplemented(): + with pytest.raises(NotImplementedError): + Filename() + + +@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", + ), + ], +) +def test_sdist_not_strict_passes(name, version, expected_filename): + fn = SourceFilename(name, version, strict=False) + assert str(fn) == expected_filename + assert fn.original_name == name + assert fn.original_version == version + + +@pytest.mark.parametrize( + ("filename", "error_message"), + [ + ( + "bad.extension", # Bad extension + "Invalid filename (extension must be '.tar.gz')", + ), + ( + "extra-hyphens-1.0-9.tar.gz", # Extra hyphens + "Invalid filename (name and version parts can not contain hyphens)", + ), + ( + "no_hyphen.tar.gz", # No hyphen + "Invalid filename (hyphen must separate name and version parts)", + ), + ( + ".invalid.name-1.0.tar.gz", # Name is not valid + "Invalid filename (invalid project name '.invalid.name')", + ), + ( + "invalid.name-1.0.tar.gz", # Name is not canonical (punctuation) + "Invalid filename (non-normalized project name 'invalid.name')", + ), + ( + "invalid__name-1.0.tar.gz", # Name is not canonical (punctuation) + "Invalid filename (non-normalized project name 'invalid__name')", + ), + ( + "INVALID_NAME-1.0.tar.gz", # Name is not canonical (casing) + "Invalid filename (non-normalized project name 'INVALID_NAME')", + ), + ( + "valid_name-badversion.tar.gz", # Version is not valid + "Invalid filename (invalid version 'badversion')", + ), + ( + "valid_name-01.0.tar.gz", # Version is not canonical + "Invalid filename (non-normalized version '01.0')", + ), + ], +) +def test_sdist_from_filename_invalid(filename, error_message): + with pytest.raises(InvalidFilename) as e: + SourceFilename.from_filename(filename) + + assert str(e.value) == f"{error_message}: {filename!r}" + + +# 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")}, + ), + ], +) +def test_wheel_from_filename(filename, name, version, build_tag, tags): + fn = WheelFilename.from_filename(filename) + assert fn.name == name + assert fn.version == version + assert fn.build_tag == build_tag + assert fn.tags == tags + + +@pytest.mark.parametrize( + ("filename", "error_message"), + [ + ("foo-1.0.whl", "Invalid filename (wrong number of parts)"), # Missing tags + ( + "foo-1.0-py3-none-any.wheel", # Incorrect file extension (`.wheel`) + "Invalid filename (extension must be '.whl')", + ), + ( + "foo__bar-1.0-py3-none-any.whl", # Invalid name (`__`) + "Invalid filename (invalid project name 'foo__bar')", + ), + ( + "foo#bar-1.0-py3-none-any.whl", # Invalid name (`#`) + "Invalid filename (invalid project name 'foo#bar')", + ), + ( + "foobar-1.x-py3-none-any.whl", # Invalid version (`1.x`) + "Invalid filename (invalid version '1.x')", + ), + ( + # Build number doesn't start with a digit (`abc`) + "foo-1.0-abc-py3-none-any.whl", + "Invalid filename (invalid build number 'abc')", + ), + ( + "foo-1.0-200-py3-none-any-junk.whl", # Too many dashes (`-junk`) + "Invalid filename (wrong number of parts)", + ), + ( + "fOo-1.0-py3-none-any.whl", # Non-normalized project name + "Invalid filename (non-normalized project name 'fOo')", + ), + ( + "foo-01.0-py3-none-any.whl", # Non-normalized version + "Invalid filename (non-normalized version '01.0')", + ), + ], +) +def test_wheel_from_filename_invalid(filename, error_message): + with pytest.raises(InvalidWheelFilename) as e: + WheelFilename.from_filename(filename) + + assert str(e.value) == f"{error_message}: {filename!r}" + + +@pytest.mark.parametrize( + ("name", "version", "expected_filename"), + [ + ( + "valid.name", # Name is not canonical (punctuation) + "1.0", + "valid_name-1.0-py3-none-any.whl", + ), + ( + "valid__name", # Name is not canonical (punctuation) + "1.0", + "valid_name-1.0-py3-none-any.whl", + ), + ( + "VALID_NAME", # Name is not canonical (casing) + "1.0", + "valid_name-1.0-py3-none-any.whl", + ), + ( + "valid_name", + "01.0", # Version is not canonical + "valid_name-1.0-py3-none-any.whl", + ), + ], +) +def test_wheel_not_strict_passes(name, version, expected_filename): + fn = WheelFilename(name, version, None, "py3", "none", "any", strict=False) + assert str(fn) == expected_filename + assert fn.original_name == name + assert fn.original_version == version From edda63360ce32c5b530c29001b53b4fe91637d17 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 9 Mar 2026 18:31:01 -0400 Subject: [PATCH 07/53] chore: fix up typing and linting Signed-off-by: Henry Schreiner --- src/packaging/filenames.py | 31 +++++++++++++++++++++---------- tests/test_filenames.py | 33 +++++++++++++++++++++++---------- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index fa5fdcbb6..25aae0af6 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import Any, cast +from typing import cast from .tags import parse_tag from .utils import ( @@ -15,9 +15,22 @@ ) from .version import InvalidVersion, Version +__all__ = [ + "Filename", + "InvalidFilename", + "InvalidSdistFilename", + "InvalidWheelFilename", + "SourceFilename", + "WheelFilename", +] + + +def __dir__() -> list[str]: + return __all__ + class Filename: - def __init__(self, *a: Any, **kw: Any) -> None: + def __init__(self, *a: object, **kw: object) -> None: raise NotImplementedError("Use a WheelFilename or SourceFilename instead") @classmethod @@ -52,11 +65,9 @@ def __init__( ) # See PEP 427 for the rules on escaping the project name. - if ( - strict - and "__" in name - or re.match(r"^[\w\d._]*$", name, re.UNICODE) is None - ): + if (strict and "__" in name) or re.match( + r"^[\w\d._]*$", name, re.UNICODE + ) is None: raise InvalidWheelFilename( f"Invalid filename (invalid project name {name!r}): {filename!r}" ) @@ -90,7 +101,7 @@ def __init__( f"{filename!r}" ) self.build_tag = cast( - BuildTag, (int(build_match.group(1)), build_match.group(2)) + "BuildTag", (int(build_match.group(1)), build_match.group(2)) ) else: self.build_tag = () @@ -98,7 +109,7 @@ def __init__( self.python_tag = python_tag self.abi_tag = abi_tag self.platform_tag = platform_tag - self.tags = parse_tag("-".join((python_tag, abi_tag, platform_tag))) + self.tags = parse_tag(f"{python_tag}-{abi_tag}-{platform_tag}") def _to_filename( self, @@ -207,7 +218,7 @@ def __init__(self, name: str, version: str, strict: bool = True) -> None: ) def _to_filename(self, name: str, version: str | Version) -> str: - return f"{ name }-{ version }.tar.gz" + return f"{name}-{version}.tar.gz" def __str__(self) -> str: return self._to_filename(self.name, self.version) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 834fd9d20..6fb963029 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +import typing + import pytest from packaging.filenames import ( @@ -10,8 +14,11 @@ from packaging.tags import Tag from packaging.version import Version +if typing.TYPE_CHECKING: + from packaging.utils import BuildTag + -def test_generic_from_filename_bad_extension(): +def test_generic_from_filename_bad_extension() -> None: with pytest.raises(InvalidFilename) as e: Filename.from_filename("something.wrong") @@ -23,13 +30,13 @@ def test_generic_from_filename_bad_extension(): @pytest.mark.parametrize( "filename", ["sample_project-4.0.0.tar.gz", "sample_project-4.0.0-py3-none-any.whl"] ) -def test_generic_from_filename_passes(filename): +def test_generic_from_filename_passes(filename: str) -> None: fn = Filename.from_filename(filename) - assert fn.name == fn.original_name == "sample_project" - assert str(fn.version) == str(fn.original_version) == "4.0.0" + assert fn.name == fn.original_name == "sample_project" # type: ignore[attr-defined] + assert str(fn.version) == str(fn.original_version) == "4.0.0" # type: ignore[attr-defined] -def test_initialize_generic_filename_unimplemented(): +def test_initialize_generic_filename_unimplemented() -> None: with pytest.raises(NotImplementedError): Filename() @@ -59,7 +66,9 @@ def test_initialize_generic_filename_unimplemented(): ), ], ) -def test_sdist_not_strict_passes(name, version, expected_filename): +def test_sdist_not_strict_passes( + name: str, version: str, expected_filename: str +) -> None: fn = SourceFilename(name, version, strict=False) assert str(fn) == expected_filename assert fn.original_name == name @@ -107,7 +116,7 @@ def test_sdist_not_strict_passes(name, version, expected_filename): ), ], ) -def test_sdist_from_filename_invalid(filename, error_message): +def test_sdist_from_filename_invalid(filename: str, error_message: str) -> None: with pytest.raises(InvalidFilename) as e: SourceFilename.from_filename(filename) @@ -148,7 +157,9 @@ def test_sdist_from_filename_invalid(filename, error_message): ), ], ) -def test_wheel_from_filename(filename, name, version, build_tag, tags): +def test_wheel_from_filename( + filename: str, name: str, version: Version, build_tag: BuildTag, tags: set[Tag] +) -> None: fn = WheelFilename.from_filename(filename) assert fn.name == name assert fn.version == version @@ -195,7 +206,7 @@ def test_wheel_from_filename(filename, name, version, build_tag, tags): ), ], ) -def test_wheel_from_filename_invalid(filename, error_message): +def test_wheel_from_filename_invalid(filename: str, error_message: str) -> None: with pytest.raises(InvalidWheelFilename) as e: WheelFilename.from_filename(filename) @@ -227,7 +238,9 @@ def test_wheel_from_filename_invalid(filename, error_message): ), ], ) -def test_wheel_not_strict_passes(name, version, expected_filename): +def test_wheel_not_strict_passes( + name: str, version: str, expected_filename: str +) -> None: fn = WheelFilename(name, version, None, "py3", "none", "any", strict=False) assert str(fn) == expected_filename assert fn.original_name == name From 16164e1deaf2a0ad40b00a44b20923010bd9fcc6 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 9 Mar 2026 18:34:34 -0400 Subject: [PATCH 08/53] refactor: remove Filename Signed-off-by: Henry Schreiner --- src/packaging/filenames.py | 22 ++-------------------- src/packaging/utils.py | 1 - tests/test_filenames.py | 24 ------------------------ 3 files changed, 2 insertions(+), 45 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 25aae0af6..da72fa3d8 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -16,7 +16,6 @@ from .version import InvalidVersion, Version __all__ = [ - "Filename", "InvalidFilename", "InvalidSdistFilename", "InvalidWheelFilename", @@ -29,24 +28,7 @@ def __dir__() -> list[str]: return __all__ -class Filename: - def __init__(self, *a: object, **kw: object) -> None: - raise NotImplementedError("Use a WheelFilename or SourceFilename instead") - - @classmethod - def from_filename(cls, filename: str) -> Filename: - if filename.endswith(".whl"): - return WheelFilename.from_filename(filename) - elif filename.endswith(".tar.gz"): - return SourceFilename.from_filename(filename) - else: - raise InvalidFilename( - "Invalid filename (extension must be '.whl' or '.tar.gz'): " - f"{filename!r}" - ) - - -class WheelFilename(Filename): +class WheelFilename: def __init__( self, name: str, @@ -184,7 +166,7 @@ def from_filename(cls, filename: str) -> WheelFilename: return cls(name, version, build_tag, python_tag, abi_tag, platform_tag) -class SourceFilename(Filename): +class SourceFilename: def __init__(self, name: str, version: str, strict: bool = True) -> None: # Store the values that were originally passed for use externally self.original_name = name diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 453d96375..4b9c022eb 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -88,7 +88,6 @@ class InvalidSdistFilename(InvalidFilename): # 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) -_distribution_regex = re.compile(r"[^\w\d.]+", re.ASCII) def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName: diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 6fb963029..3530e28c6 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -5,7 +5,6 @@ import pytest from packaging.filenames import ( - Filename, InvalidFilename, InvalidWheelFilename, SourceFilename, @@ -18,29 +17,6 @@ from packaging.utils import BuildTag -def test_generic_from_filename_bad_extension() -> None: - with pytest.raises(InvalidFilename) as e: - Filename.from_filename("something.wrong") - - assert str(e.value) == ( - "Invalid filename (extension must be '.whl' or '.tar.gz'): 'something.wrong'" - ) - - -@pytest.mark.parametrize( - "filename", ["sample_project-4.0.0.tar.gz", "sample_project-4.0.0-py3-none-any.whl"] -) -def test_generic_from_filename_passes(filename: str) -> None: - fn = Filename.from_filename(filename) - assert fn.name == fn.original_name == "sample_project" # type: ignore[attr-defined] - assert str(fn.version) == str(fn.original_version) == "4.0.0" # type: ignore[attr-defined] - - -def test_initialize_generic_filename_unimplemented() -> None: - with pytest.raises(NotImplementedError): - Filename() - - @pytest.mark.parametrize( ("name", "version", "expected_filename"), [ From 47fd9e202b1a3eabc284c3254b8e0be584166931 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 10 Mar 2026 12:53:48 -0400 Subject: [PATCH 09/53] refactor: use filenames for utils Signed-off-by: Henry Schreiner --- src/packaging/filenames.py | 530 ++++++++++++++++++++++++++----------- src/packaging/utils.py | 313 +++------------------- tests/test_filenames.py | 60 +++-- 3 files changed, 434 insertions(+), 469 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index da72fa3d8..775a32f31 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -1,26 +1,28 @@ from __future__ import annotations import re -from typing import cast - -from .tags import parse_tag -from .utils import ( - BuildTag, - InvalidFilename, - InvalidSdistFilename, - InvalidWheelFilename, - _build_tag_regex, - canonicalize_name, - is_normalized_name, -) -from .version import InvalidVersion, Version +from typing import TYPE_CHECKING, NewType, Tuple, Union, cast + +from .tags import Tag, parse_tag +from .version import InvalidVersion, Version, _TrimmedRelease + +if TYPE_CHECKING: + from collections.abc import Iterable __all__ = [ + "BuildTag", "InvalidFilename", + "InvalidName", "InvalidSdistFilename", + "InvalidSdistFilename", + "InvalidWheelFilename", "InvalidWheelFilename", + "NormalizedName", "SourceFilename", "WheelFilename", + "canonicalize_name", + "canonicalize_version", + "is_normalized_name", ] @@ -28,206 +30,412 @@ def __dir__() -> list[str]: return __all__ +BuildTag = Union[Tuple[()], Tuple[int, str]] + +NormalizedName = NewType("NormalizedName", str) +""" +A :class:`typing.NewType` of :class:`str`, representing a normalized name. +""" + + +class InvalidName(ValueError): + """ + An invalid distribution name; users should refer to the packaging user guide. + """ + + +class InvalidFilename(ValueError): + """ + . + """ + + +class InvalidWheelFilename(InvalidFilename): + """ + An invalid wheel filename was found, users should refer to PEP 427. + """ + + +class InvalidSdistFilename(InvalidFilename): + """ + 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 | re.ASCII +) +_normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]", re.ASCII) +# PEP 427: The build number must start with a digit. +_build_tag_regex = re.compile(r"(\d+)(.*)", re.ASCII) + + +def canonicalize_name( + name: str, *, validate: bool = False, underscore: bool = False +) -> NormalizedName: + """ + This function takes a valid Python package or extra name, and returns the + normalized form of it. + + The return type is typed as :class:`NormalizedName`. This allows type + checkers to help require that a string has passed through this function + before use. + + If **validate** is true, then the function will check if **name** is a valid + distribution name before normalizing. If **underscore** is true, then hyphens + will be replaced with underscores instead of hyphens (such as for a filename). + + :param str name: The name to normalize. + :param bool validate: Check whether the name is a valid distribution name. + :param bool underscore: Replace hyphens with underscores instead of hyphens. + :raises InvalidName: If **validate** is true and the name is not an + acceptable distribution name. + + >>> from packaging.utils import canonicalize_name + >>> canonicalize_name("Django") + 'django' + >>> canonicalize_name("oslo.concurrency") + 'oslo-concurrency' + >>> canonicalize_name("oslo.concurrency", underscore=True) + 'oslo_concurrency' + >>> canonicalize_name("requests") + 'requests' + """ + if validate and not _validate_regex.fullmatch(name): + raise InvalidName(f"name is invalid: {name!r}") + if underscore: + value = name.lower().replace("-", "_").replace(".", "_") + while "__" in value: + value = value.replace("__", "_") + else: + # 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: + """ + Check if a name is already normalized (i.e. :func:`canonicalize_name` would + roundtrip to the same value). + + :param str name: The name to check. + + >>> from packaging.utils import is_normalized_name + >>> is_normalized_name("requests") + True + >>> is_normalized_name("Django") + False + """ + return _normalized_regex.fullmatch(name) is not None + + +def canonicalize_version( + version: Version | str, *, strip_trailing_zero: bool = True +) -> str: + """Return a canonical form of a version as a string. + + This function takes a string representing a package version (or a + :class:`~packaging.version.Version` instance), and returns the + normalized form of it. By default, it strips trailing zeros from + the release segment. + + >>> from packaging.utils import canonicalize_version + >>> canonicalize_version('1.0.1') + '1.0.1' + + Per PEP 625, versions may have multiple canonical forms, differing + only by trailing zeros. + + >>> canonicalize_version('1.0.0') + '1' + >>> canonicalize_version('1.0.0', strip_trailing_zero=False) + '1.0.0' + + Invalid versions are returned unaltered. + + >>> canonicalize_version('foo bar baz') + 'foo bar baz' + + >>> canonicalize_version('1.4.0.0.0') + '1.4' + """ + if isinstance(version, str): + try: + version = Version(version) + except InvalidVersion: + return str(version) + return str(_TrimmedRelease(version) if strip_trailing_zero else version) + + +def _join_tag_attr(tags: Iterable[Tag], field: str) -> str: + return ".".join(sorted({getattr(tag, field) for tag in tags})) + + +def _compress_tag_set(tags: Iterable[Tag]) -> str: + return "-".join(_join_tag_attr(tags, x) for x in ("interpreter", "abi", "platform")) + + class WheelFilename: + __slots__ = ("build_tag", "original_name", "original_version", "tags") + def __init__( self, name: str, version: str, - build_tag: str | None, - python_tag: str, - abi_tag: str, - platform_tag: str, - strict: bool = True, + build_tag: BuildTag = (), + tags: Iterable[Tag] = (), ) -> None: self.original_name = name self.original_version = version + self.build_tag = build_tag + self.tags = frozenset(tags) - filename = self._to_filename( - name, version, build_tag, python_tag, abi_tag, platform_tag - ) - - # See PEP 427 for the rules on escaping the project name. - if (strict and "__" in name) or re.match( - r"^[\w\d._]*$", name, re.UNICODE - ) is None: - raise InvalidWheelFilename( - f"Invalid filename (invalid project name {name!r}): {filename!r}" - ) - - self.name = canonicalize_name(name).replace("-", "_") - - # Check that the name is normalized - if strict and self.original_name != self.name: - raise InvalidWheelFilename( - f"Invalid filename (non-normalized project name {name!r}): {filename!r}" - ) + @property + def name(self) -> NormalizedName: + return canonicalize_name(self.original_name) + @property + def version(self) -> Version: try: - self.version = Version(version) + return Version(self.original_version) except InvalidVersion as e: - raise InvalidWheelFilename( - f"Invalid filename (invalid version {version!r}): {filename!r}" - ) from e + msg = f"Invalid wheel filename (invalid version {self.original_version!r})" + raise InvalidWheelFilename(msg) from e - # Check that the version is normalized - if strict and version != str(self.version): - raise InvalidWheelFilename( - f"Invalid filename (non-normalized version {version!r}): {filename!r}" - ) + @property + def compressed_tags(self) -> str: + return _compress_tag_set(self.tags) - if build_tag: - build_match = _build_tag_regex.match(build_tag) - if build_match is None: - raise InvalidWheelFilename( - f"Invalid filename (invalid build number {build_tag!r}): " - f"{filename!r}" - ) - self.build_tag = cast( - "BuildTag", (int(build_match.group(1)), build_match.group(2)) - ) - else: - self.build_tag = () + @property + def build_str(self) -> str: + return "".join(map(str, self.build_tag)) - self.python_tag = python_tag - self.abi_tag = abi_tag - self.platform_tag = platform_tag - self.tags = parse_tag(f"{python_tag}-{abi_tag}-{platform_tag}") - - def _to_filename( - self, - name: str, - version: str | Version, - build_tag: str | BuildTag | None, - python_tag: str, - abi_tag: str, - platform_tag: str, - ) -> str: + def __repr__(self) -> str: return ( - "-".join( - part - for part in [ - name, - str(version), - ( - "".join(str(x) for x in build_tag) - if isinstance(build_tag, tuple) - else build_tag - ), - python_tag, - abi_tag, - platform_tag, - ] - if part - ) - + ".whl" + f"{self.__class__.__name__}(name={self.original_name!r}, " + f"version={self.original_version!r}, " + f"build_tag={self.build_tag!r}, " + f"tags={self.tags!r})" ) def __str__(self) -> str: - return self._to_filename( - self.name, - self.version, - self.build_tag, - self.python_tag, - self.abi_tag, - self.platform_tag, - ) + return self.to_filename() + + def to_filename(self) -> str: + """ + Combines a project name, version, build tag, and tag set + to make a properly formatted wheel filename. + + The project name is normalized such that the non-alphanumeric + characters are replaced with ``_``. The version is an instance of + :class:`~packaging.version.Version`. The build tag can be None, + an empty tuple or a two-item tuple of an integer and a string. + The tags is set of tags that will be compressed into a wheel + tag string. + + :param name: The project name + :param version: The project version + :param build: An optional two-item tuple of an integer and string + :param tags: The set of tags that apply to the wheel + + >>> from packaging.utils import compose_wheel_filename + >>> from packaging.tags import Tag + >>> from packaging.version import Version + >>> version = Version("1.0") + >>> tags = {Tag("py3", "none", "any")} + >>> compose_wheel_filename("foo-bar", version, None, tags) + 'foo_bar-1.0-py3-none-any.whl' + + .. versionadded:: 26.1 + """ + + ctags = self.compressed_tags + name = canonicalize_name(self.original_name, underscore=True) + if self.build_tag: + return f"{name}-{self.version}-{self.build_str}-{ctags}.whl" + else: + return f"{name}-{self.version}-{ctags}.whl" @classmethod - def from_filename(cls, filename: str) -> WheelFilename: + def from_filename(cls, filename: str, /, *, strict: bool = False) -> WheelFilename: + """ + This function takes the filename of a wheel file, and parses it, + returning a tuple of name, version, build number, and tags. + + The name part of the tuple is normalized and typed as + :class:`NormalizedName`. The version portion is an instance of + :class:`~packaging.version.Version`. The build number is ``()`` if + there is no build number in the wheel filename, otherwise a + two-item tuple of an integer for the leading digits and + a string for the rest of the build number. The tags portion is a + frozen set of :class:`~packaging.tags.Tag` instances (as the tag + string format allows multiple tags to be combined into a single + string). + + :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.utils import parse_wheel_filename + >>> from packaging.tags import Tag + >>> from packaging.version import Version + >>> name, ver, build, tags = parse_wheel_filename("foo-1.0-py3-none-any.whl") + >>> name + 'foo' + >>> ver == Version('1.0') + True + >>> tags == {Tag("py3", "none", "any")} + True + >>> not build + True + """ if not filename.endswith(".whl"): - raise InvalidWheelFilename( - f"Invalid filename (extension must be '.whl'): {filename!r}" - ) + msg = f"Invalid wheel filename (extension must be '.whl'): {filename!r}" + raise InvalidWheelFilename(msg) - dashes = filename.count("-") + filestem = filename[:-4] + dashes = filestem.count("-") if dashes not in (4, 5): - raise InvalidWheelFilename( - f"Invalid filename (wrong number of parts): {filename!r}" - ) + msg = f"Invalid wheel filename (wrong number of parts): {filename!r}" + raise InvalidWheelFilename(msg) - filename = filename[: -len(".whl")] + parts = filestem.split("-", dashes - 2) + name = parts[0] + version = parts[1] + tags = parse_tag(parts[-1]) - # There is no build tag - if dashes == 4: - name, version, python_tag, abi_tag, platform_tag = filename.split("-") - build_tag = None + # See PEP 427 for the rules on escaping the project name. + if "__" in name or re.match(r"^[\w\d._]*$", name, re.UNICODE) is None: + inner = f"invalid project name: {name!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) + + try: + Version(version) + except InvalidVersion: + inner = f"invalid version: {version!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) from None - # There is a build tag if dashes == 5: - ( - name, - version, - build_tag, - python_tag, - abi_tag, - platform_tag, - ) = filename.split("-") + build_part = parts[2] + build_match = _build_tag_regex.match(build_part) + if build_match is None: + inner = f"invalid build number: {build_part!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) + build_tag = cast( + "BuildTag", (int(build_match.group(1)), build_match.group(2)) + ) + else: + build_tag = () - return cls(name, version, build_tag, python_tag, abi_tag, platform_tag) + self = cls(name, version, build_tag, tags) + + # Reconstruct the filename and check that it matches the original + if strict: + if self.original_name != self.name: + inner = f"non-normalized project name {self.original_name!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) + if self.original_version != str(self.version): + inner = f"non-normalized version {self.original_version!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) + + return self class SourceFilename: - def __init__(self, name: str, version: str, strict: bool = True) -> None: + __slots__ = ("original_name", "original_version") + + def __init__(self, name: str, version: str) -> None: # Store the values that were originally passed for use externally self.original_name = name self.original_version = version - filename = self._to_filename(name, version) + @property + def name(self) -> NormalizedName: + return canonicalize_name(self.original_name) - # Check that the name is normalized - if not is_normalized_name(canonicalize_name(name)): - raise InvalidSdistFilename( - f"Invalid filename (invalid project name {name!r}): {filename!r}" - ) - self.name = canonicalize_name(name).replace("-", "_") - # PEP 625: The name must only contain underscores - if strict and self.original_name != self.name: - raise InvalidSdistFilename( - f"Invalid filename (non-normalized project name {name!r}): {filename!r}" - ) - - # Check that the version is valid + @property + def version(self) -> Version: try: - self.version = Version(version) + return Version(self.original_version) except InvalidVersion as e: raise InvalidSdistFilename( - f"Invalid filename (invalid version {version!r}): {filename!r}" + f"Invalid filename (invalid version {self.original_version!r})" ) from e - # Check that the version is normalized - if strict and version != str(self.version): - raise InvalidSdistFilename( - f"Invalid filename (non-normalized version {version!r}): {filename!r}" - ) - def _to_filename(self, name: str, version: str | Version) -> str: + def to_filename(self) -> str: + name = canonicalize_name(self.original_name, underscore=True) + version = str(self.version) + return f"{name}-{version}.tar.gz" + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(name={self.original_name!r}, " + f"version={self.original_version!r})" + ) + def __str__(self) -> str: - return self._to_filename(self.name, self.version) + return self.to_filename() @classmethod - def from_filename(cls, filename: str) -> SourceFilename: + def from_filename(cls, filename: str, strict: bool = False) -> SourceFilename: # PEP 625: Source distributions must end with .tar.gz - if not filename.endswith(".tar.gz"): + # Non-scrict mode will allow .zip for backward compatibility + if filename.endswith(".tar.gz"): + file_stem = filename[: -len(".tar.gz")] + elif filename.endswith(".zip") and not strict: + file_stem = filename[: -len(".zip")] + else: raise InvalidSdistFilename( - f"Invalid filename (extension must be '.tar.gz'): {filename!r}" + f"Invalid SDist filename (extension must be '.tar.gz'): {filename!r}" ) + name_part, sep, version_part = file_stem.rpartition("-") # PEP 625: Source distributions may only have one hyphen, separating # the name and version - if filename.count("-") > 1: - raise InvalidSdistFilename( - "Invalid filename (name and version parts can not contain hyphens): " - f"{filename!r}" - ) + if strict and "-" in name_part: + inner = "name and version parts can not contain hyphens" + msg = f"Invalid SDist filename ({inner}): {filename!r}" + raise InvalidSdistFilename(msg) - if filename.count("-") == 0: - raise InvalidSdistFilename( - "Invalid filename (hyphen must separate name and version parts): " - f"{filename!r}" - ) - - # Split the filename into name & version - name, version = filename[: -len(".tar.gz")].split("-") + if not sep: + inner = "hyphen must separate name and version parts" + msg = f"Invalid SDist filename ({inner}): {filename!r}" + raise InvalidSdistFilename(msg) - return cls(name, version) + try: + 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 + + if strict: + try: + cname = canonicalize_name(name_part, validate=True, underscore=True) + except InvalidName: + inner = f"invalid project name {name_part!r}" + msg = f"Invalid SDist filename ({inner}): {filename!r}" + raise InvalidSdistFilename(msg) from None + if name_part != cname: + inner = f"non-normalized project name {name_part!r}" + msg = f"Invalid SDist filename ({inner}): {filename!r}" + raise InvalidSdistFilename(msg) + if version_part != str(Version(version_part)): + inner = f"non-normalized version {version_part!r}" + msg = f"Invalid SDist filename ({inner}): {filename!r}" + raise InvalidSdistFilename(msg) from None + + return cls(name_part, version_part) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 4b9c022eb..29aebf430 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -4,15 +4,27 @@ from __future__ import annotations -import re -from typing import TYPE_CHECKING, NewType, cast - -from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag -from .version import InvalidVersion, Version, _TrimmedRelease +from typing import TYPE_CHECKING + +from .filenames import ( + BuildTag, + InvalidName, + InvalidSdistFilename, + InvalidWheelFilename, + NormalizedName, + SourceFilename, + WheelFilename, + canonicalize_name, + canonicalize_version, + is_normalized_name, +) if TYPE_CHECKING: from collections.abc import Iterable + from .tags import Tag + from .version import Version + __all__ = [ "BuildTag", "InvalidName", @@ -33,189 +45,6 @@ def __dir__() -> list[str]: return __all__ -BuildTag = tuple[()] | tuple[int, str] -""" -A wheel build tag: an empty tuple, or a ``(build number, build tag suffix)`` pair. - -.. versionadded:: 20.9 -""" - -NormalizedName = NewType("NormalizedName", str) -""" -A :class:`typing.NewType` of :class:`str`, representing a normalized name. - -.. versionadded:: 20.4 -""" - - -class InvalidName(ValueError): - """ - An invalid distribution name; users should refer to the packaging user guide. - - .. versionadded:: 23.2 - """ - - -class InvalidFilename(ValueError): - """ - . - """ - - -class InvalidWheelFilename(InvalidFilename): - """ - An invalid wheel filename was found, users should refer to PEP 427. - - .. versionadded:: 20.9 - """ - - -class InvalidSdistFilename(InvalidFilename): - """ - An invalid sdist filename was found, users should refer to the packaging user guide. - - .. versionadded:: 20.9 - """ - - -# Core metadata spec for `Name` -_validate_regex = re.compile( - r"[a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9]", re.IGNORECASE | re.ASCII -) -_normalized_regex = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*", 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: - """ - This function takes a valid Python package or extra name, and returns the - normalized form of it. - - The return type is typed as :class:`NormalizedName`. This allows type - checkers to help require that a string has passed through this function - before use. - - If **validate** is true, then the function will check if **name** is a valid - distribution name before normalizing. - - :param str name: The name to normalize. - :param bool validate: Check whether the name is a valid distribution name. - :raises InvalidName: If **validate** is true and the name is not an - acceptable distribution name. - - >>> from packaging.utils import canonicalize_name - >>> canonicalize_name("Django") - 'django' - >>> canonicalize_name("oslo.concurrency") - 'oslo-concurrency' - >>> canonicalize_name("requests") - 'requests' - - .. versionadded:: 16.2 - - .. versionchanged:: 20.4 - The return type was changed to :class:`NormalizedName`. - - .. versionchanged:: 23.2 - Added the *validate* keyword parameter. - """ - if validate and not _validate_regex.fullmatch(name): - raise InvalidName(f"name is invalid: {name!r}") - # 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: - """ - Check if a name is a normalized project name (i.e. a valid name that - :func:`canonicalize_name` would roundtrip to the same value). - - The roundtrip only characterizes normalized names for *valid* names. A name - must start and end with an ASCII letter or digit, which - :func:`canonicalize_name` does not enforce: it leaves a leading or trailing - hyphen in place, so such a name roundtrips without being normalized. - - :param str name: The name to check. - - >>> from packaging.utils import canonicalize_name, is_normalized_name - >>> is_normalized_name("requests") - True - >>> is_normalized_name("Django") - False - >>> canonicalize_name("_not_legal") - '-not-legal' - >>> is_normalized_name("-not-legal") # roundtrips, but not a valid name - False - - .. versionadded:: 23.2 - """ - return _normalized_regex.fullmatch(name) is not None - - -def canonicalize_version( - version: Version | str, *, strip_trailing_zero: bool = True -) -> str: - """Return a canonical form of a version as a string. - - This function takes a string representing a package version (or a - :class:`~packaging.version.Version` instance), and returns the - normalized form of it. By default, it strips trailing zeros from - the release segment. - - >>> from packaging.utils import canonicalize_version - >>> canonicalize_version('1.0.1') - '1.0.1' - - Per PEP 625, versions may have multiple canonical forms, differing - only by trailing zeros. - - >>> canonicalize_version('1.0.0') - '1' - >>> canonicalize_version('1.0.0', strip_trailing_zero=False) - '1.0.0' - - Invalid versions are returned unaltered. - - >>> canonicalize_version('foo bar baz') - 'foo bar baz' - - >>> canonicalize_version('1.4.0.0.0') - '1.4' - - .. versionadded:: 17.1 - - .. versionchanged:: 21.0 - The return type was narrowed to :class:`str`. - - .. versionchanged:: 22.0 - Added the *strip_trailing_zero* keyword parameter. - """ - if isinstance(version, str): - try: - version = Version(version) - except InvalidVersion: - return str(version) - return str(_TrimmedRelease(version) if strip_trailing_zero else version) - - -def _join_tag_attr(tags: Iterable[Tag], field: str) -> str: - return ".".join(sorted({getattr(tag, field) for tag in tags})) - - -def _compress_tag_set(tags: Iterable[Tag]) -> str: - return "-".join(_join_tag_attr(tags, x) for x in ("interpreter", "abi", "platform")) - - def compose_wheel_filename( name: str, version: Version, build: BuildTag | None, tags: Iterable[Tag] ) -> str: @@ -245,17 +74,13 @@ def compose_wheel_filename( .. versionadded:: 26.1 """ - norm_name = canonicalize_name(name).replace("-", "_") - compressed_tag = _compress_tag_set(tags) - - parts: tuple[str, ...] - - if build: - parts = norm_name, str(version), "".join(map(str, build)), compressed_tag - else: - parts = norm_name, str(version), compressed_tag - - return "-".join(parts) + ".whl" + filename = WheelFilename( + name=name, + version=str(version), + build_tag=build or (), + tags=tags, + ) + return str(filename) def parse_wheel_filename( @@ -313,55 +138,8 @@ 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) + fname = WheelFilename.from_filename(filename) + return (fname.name, fname.version, fname.build_tag, fname.tags) def compose_sdist_filename(name: str, version: Version) -> str: @@ -381,8 +159,11 @@ def compose_sdist_filename(name: str, version: Version) -> str: .. versionadded:: 26.1 """ - norm_name = canonicalize_name(name).replace("-", "_") - return f"{norm_name}-{version}.tar.gz" + filename = SourceFilename( + name=name, + version=str(version), + ) + return str(filename) def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: @@ -420,33 +201,5 @@ 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) + fname = SourceFilename.from_filename(filename) + return (fname.name, fname.version) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 3530e28c6..5b8a49061 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -45,7 +45,7 @@ def test_sdist_not_strict_passes( name: str, version: str, expected_filename: str ) -> None: - fn = SourceFilename(name, version, strict=False) + fn = SourceFilename(name, version) assert str(fn) == expected_filename assert fn.original_name == name assert fn.original_version == version @@ -56,45 +56,45 @@ def test_sdist_not_strict_passes( [ ( "bad.extension", # Bad extension - "Invalid filename (extension must be '.tar.gz')", + "Invalid SDist filename (extension must be '.tar.gz')", ), ( "extra-hyphens-1.0-9.tar.gz", # Extra hyphens - "Invalid filename (name and version parts can not contain hyphens)", + "Invalid SDist filename (name and version parts can not contain hyphens)", ), ( "no_hyphen.tar.gz", # No hyphen - "Invalid filename (hyphen must separate name and version parts)", + "Invalid SDist filename (hyphen must separate name and version parts)", ), ( ".invalid.name-1.0.tar.gz", # Name is not valid - "Invalid filename (invalid project name '.invalid.name')", + "Invalid SDist filename (invalid project name '.invalid.name')", ), ( "invalid.name-1.0.tar.gz", # Name is not canonical (punctuation) - "Invalid filename (non-normalized project name 'invalid.name')", + "Invalid SDist filename (non-normalized project name 'invalid.name')", ), ( "invalid__name-1.0.tar.gz", # Name is not canonical (punctuation) - "Invalid filename (non-normalized project name 'invalid__name')", + "Invalid SDist filename (non-normalized project name 'invalid__name')", ), ( "INVALID_NAME-1.0.tar.gz", # Name is not canonical (casing) - "Invalid filename (non-normalized project name 'INVALID_NAME')", + "Invalid SDist filename (non-normalized project name 'INVALID_NAME')", ), ( "valid_name-badversion.tar.gz", # Version is not valid - "Invalid filename (invalid version 'badversion')", + "Invalid SDist filename (invalid version 'badversion')", ), ( "valid_name-01.0.tar.gz", # Version is not canonical - "Invalid filename (non-normalized version '01.0')", + "Invalid SDist filename (non-normalized version '01.0')", ), ], ) def test_sdist_from_filename_invalid(filename: str, error_message: str) -> None: with pytest.raises(InvalidFilename) as e: - SourceFilename.from_filename(filename) + SourceFilename.from_filename(filename, strict=True) assert str(e.value) == f"{error_message}: {filename!r}" @@ -112,7 +112,7 @@ def test_sdist_from_filename_invalid(filename: str, error_message: str) -> None: ), ( "some_package-1.0-py3-none-any.whl", - "some_package", + "some-package", Version("1.0"), (), {Tag("py3", "none", "any")}, @@ -146,45 +146,48 @@ def test_wheel_from_filename( @pytest.mark.parametrize( ("filename", "error_message"), [ - ("foo-1.0.whl", "Invalid filename (wrong number of parts)"), # Missing tags + ( + "foo-1.0.whl", # Missing tags + "Invalid wheel filename (wrong number of parts)", + ), ( "foo-1.0-py3-none-any.wheel", # Incorrect file extension (`.wheel`) - "Invalid filename (extension must be '.whl')", + "Invalid wheel filename (extension must be '.whl')", ), ( "foo__bar-1.0-py3-none-any.whl", # Invalid name (`__`) - "Invalid filename (invalid project name 'foo__bar')", + "Invalid wheel filename (invalid project name: 'foo__bar')", ), ( "foo#bar-1.0-py3-none-any.whl", # Invalid name (`#`) - "Invalid filename (invalid project name 'foo#bar')", + "Invalid wheel filename (invalid project name: 'foo#bar')", ), ( "foobar-1.x-py3-none-any.whl", # Invalid version (`1.x`) - "Invalid filename (invalid version '1.x')", + "Invalid wheel filename (invalid version: '1.x')", ), ( # Build number doesn't start with a digit (`abc`) "foo-1.0-abc-py3-none-any.whl", - "Invalid filename (invalid build number 'abc')", + "Invalid wheel filename (invalid build number: 'abc')", ), ( "foo-1.0-200-py3-none-any-junk.whl", # Too many dashes (`-junk`) - "Invalid filename (wrong number of parts)", + "Invalid wheel filename (wrong number of parts)", ), ( "fOo-1.0-py3-none-any.whl", # Non-normalized project name - "Invalid filename (non-normalized project name 'fOo')", + "Invalid wheel filename (non-normalized project name 'fOo')", ), ( "foo-01.0-py3-none-any.whl", # Non-normalized version - "Invalid filename (non-normalized version '01.0')", + "Invalid wheel filename (non-normalized version '01.0')", ), ], ) def test_wheel_from_filename_invalid(filename: str, error_message: str) -> None: with pytest.raises(InvalidWheelFilename) as e: - WheelFilename.from_filename(filename) + WheelFilename.from_filename(filename, strict=True) assert str(e.value) == f"{error_message}: {filename!r}" @@ -197,11 +200,12 @@ def test_wheel_from_filename_invalid(filename: str, error_message: str) -> None: "1.0", "valid_name-1.0-py3-none-any.whl", ), - ( - "valid__name", # Name is not canonical (punctuation) - "1.0", - "valid_name-1.0-py3-none-any.whl", - ), + # Historically, this is not allowed + # ( + # "valid__name", # Name is not canonical (punctuation) + # "1.0", + # "valid_name-1.0-py3-none-any.whl", + # ), ( "VALID_NAME", # Name is not canonical (casing) "1.0", @@ -217,7 +221,7 @@ def test_wheel_from_filename_invalid(filename: str, error_message: str) -> None: def test_wheel_not_strict_passes( name: str, version: str, expected_filename: str ) -> None: - fn = WheelFilename(name, version, None, "py3", "none", "any", strict=False) + fn = WheelFilename(name, version, (), {Tag("py3", "none", "any")}) assert str(fn) == expected_filename assert fn.original_name == name assert fn.original_version == version From ec4174ec2195d393b77d4c39f55e35df97bdc4e9 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 10 Mar 2026 13:02:34 -0400 Subject: [PATCH 10/53] refactor: remove compose_* Signed-off-by: Henry Schreiner --- src/packaging/filenames.py | 47 +++++++++++++++++++----- src/packaging/utils.py | 73 +++----------------------------------- tests/test_filenames.py | 70 +++++++++++++++++++++++++++++++++++- tests/test_utils.py | 69 ----------------------------------- 4 files changed, 113 insertions(+), 146 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 775a32f31..954347af1 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -239,11 +239,6 @@ def to_filename(self) -> str: The tags is set of tags that will be compressed into a wheel tag string. - :param name: The project name - :param version: The project version - :param build: An optional two-item tuple of an integer and string - :param tags: The set of tags that apply to the wheel - >>> from packaging.utils import compose_wheel_filename >>> from packaging.tags import Tag >>> from packaging.version import Version @@ -263,7 +258,7 @@ def to_filename(self) -> str: return f"{name}-{self.version}-{ctags}.whl" @classmethod - def from_filename(cls, filename: str, /, *, strict: bool = False) -> WheelFilename: + def from_filename(cls, filename: str, /, *, strict: bool) -> WheelFilename: """ This function takes the filename of a wheel file, and parses it, returning a tuple of name, version, build number, and tags. @@ -341,7 +336,9 @@ def from_filename(cls, filename: str, /, *, strict: bool = False) -> WheelFilena # Reconstruct the filename and check that it matches the original if strict: - if self.original_name != self.name: + if self.original_name != canonicalize_name( + self.original_name, underscore=True + ): inner = f"non-normalized project name {self.original_name!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) @@ -375,6 +372,19 @@ def version(self) -> Version: ) from e 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 an instance of :class:`~packaging.version.Version`. + + >>> from packaging.utils import compose_sdist_filename + >>> from packaging.version import Version + >>> "foo_bar-1.0.tar.gz" == compose_sdist_filename("foo-bar", Version("1.0")) + True + + .. versionadded:: 26.1 + """ name = canonicalize_name(self.original_name, underscore=True) version = str(self.version) @@ -390,7 +400,28 @@ def __str__(self) -> str: return self.to_filename() @classmethod - def from_filename(cls, filename: str, strict: bool = False) -> SourceFilename: + def from_filename(cls, filename: str, /, *, strict: bool) -> SourceFilename: + """ + This function takes the filename of a sdist file (as specified + in the `Source distribution format`_ documentation), and parses + it, returning a tuple of the normalized name and version as + represented by an instance of :class:`~packaging.version.Version`. + + :param str filename: The name of the sdist file. + :raises InvalidSdistFilename: If the filename does not end + with an sdist extension (``.zip`` or ``.tar.gz``), or if it does not + contain a dash separating the name and the version of the distribution. + + >>> from packaging.utils import parse_sdist_filename + >>> from packaging.version import Version + >>> name, ver = parse_sdist_filename("foo-1.0.tar.gz") + >>> name + 'foo' + >>> ver == Version('1.0') + True + + .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name + """ # PEP 625: Source distributions must end with .tar.gz # Non-scrict mode will allow .zip for backward compatibility if filename.endswith(".tar.gz"): diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 29aebf430..abf7cf03e 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -20,11 +20,12 @@ ) if TYPE_CHECKING: - from collections.abc import Iterable - from .tags import Tag from .version import Version +# For historical reasons, we export the filename parsing functions from this +# module, even though they are implemented in the filenames module now. + __all__ = [ "BuildTag", "InvalidName", @@ -33,8 +34,6 @@ "NormalizedName", "canonicalize_name", "canonicalize_version", - "compose_sdist_filename", - "compose_wheel_filename", "is_normalized_name", "parse_sdist_filename", "parse_wheel_filename", @@ -45,44 +44,6 @@ def __dir__() -> list[str]: return __all__ -def compose_wheel_filename( - name: str, version: Version, build: BuildTag | None, tags: Iterable[Tag] -) -> str: - """ - Combines a project name, version, build tag, and tag set - to make a properly formatted wheel filename. - - The project name is normalized such that the non-alphanumeric - characters are replaced with ``_``. The version is an instance of - :class:`~packaging.version.Version`. The build tag can be None, - an empty tuple or a two-item tuple of an integer and a string. - The tags is set of tags that will be compressed into a wheel - tag string. - - :param name: The project name - :param version: The project version - :param build: An optional two-item tuple of an integer and string - :param tags: The set of tags that apply to the wheel - - >>> from packaging.utils import compose_wheel_filename - >>> from packaging.tags import Tag - >>> from packaging.version import Version - >>> version = Version("1.0") - >>> tags = {Tag("py3", "none", "any")} - >>> compose_wheel_filename("foo-bar", version, None, tags) - 'foo_bar-1.0-py3-none-any.whl' - - .. versionadded:: 26.1 - """ - filename = WheelFilename( - name=name, - version=str(version), - build_tag=build or (), - tags=tags, - ) - return str(filename) - - def parse_wheel_filename( filename: str, *, @@ -138,34 +99,10 @@ def parse_wheel_filename( not an identifier, a tag set component is empty, or the project name is empty. """ - fname = WheelFilename.from_filename(filename) + fname = WheelFilename.from_filename(filename, strict=False) return (fname.name, fname.version, fname.build_tag, fname.tags) -def compose_sdist_filename(name: str, version: Version) -> 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 an instance of :class:`~packaging.version.Version`. - - :param name: The project name - :param version: The project version - - >>> from packaging.utils import compose_sdist_filename - >>> from packaging.version import Version - >>> "foo_bar-1.0.tar.gz" == compose_sdist_filename("foo-bar", Version("1.0")) - True - - .. versionadded:: 26.1 - """ - filename = SourceFilename( - name=name, - version=str(version), - ) - return str(filename) - - def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: """ This function takes the filename of a sdist file (as specified @@ -201,5 +138,5 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name """ - fname = SourceFilename.from_filename(filename) + fname = SourceFilename.from_filename(filename, strict=False) return (fname.name, fname.version) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 5b8a49061..61f02f95b 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -136,7 +136,7 @@ def test_sdist_from_filename_invalid(filename: str, error_message: str) -> None: def test_wheel_from_filename( filename: str, name: str, version: Version, build_tag: BuildTag, tags: set[Tag] ) -> None: - fn = WheelFilename.from_filename(filename) + fn = WheelFilename.from_filename(filename, strict=True) assert fn.name == name assert fn.version == version assert fn.build_tag == build_tag @@ -225,3 +225,71 @@ def test_wheel_not_strict_passes( assert str(fn) == expected_filename assert fn.original_name == name assert fn.original_version == version + + +@pytest.mark.parametrize( + ("filename", "name", "version", "build", "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_bar-1.0-42-py2.py3-none-any.whl", + "foo-bar", + Version("1.0"), + (42, ""), + {Tag("py2", "none", "any"), Tag("py3", "none", "any")}, + ), + ], +) +def test_compose_wheel_filename( + filename: str, name: str, version: Version, build: BuildTag | None, tags: set[Tag] +) -> None: + assert ( + WheelFilename(name, str(version), build or (), tags).to_filename() == filename + ) + + +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, strict=False) + composed = wf.to_filename() + assert sorted_f == composed + + +@pytest.mark.parametrize( + ("filename", "name", "version"), + [ + ("foo-1.0.tar.gz", "foo", Version("1.0")), + ("foo_bar-1.0.tar.gz", "foo-bar", Version("1.0")), + ], +) +def test_compose_sdist_filename(filename: str, name: str, version: Version) -> None: + assert SourceFilename(name, str(version)).to_filename() == filename diff --git a/tests/test_utils.py b/tests/test_utils.py index 78147b1f2..c543089bc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,14 +8,11 @@ from packaging.tags import Tag from packaging.utils import ( - BuildTag, InvalidName, InvalidSdistFilename, InvalidWheelFilename, canonicalize_name, canonicalize_version, - compose_sdist_filename, - compose_wheel_filename, is_normalized_name, parse_sdist_filename, parse_wheel_filename, @@ -111,52 +108,6 @@ def test_canonicalize_version_no_strip_trailing_zero(version: str) -> None: assert canonicalize_version(version, strip_trailing_zero=False) == version -@pytest.mark.parametrize( - ("filename", "name", "version", "build", "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_bar-1.0-42-py2.py3-none-any.whl", - "foo-bar", - Version("1.0"), - (42, ""), - {Tag("py2", "none", "any"), Tag("py3", "none", "any")}, - ), - ], -) -def test_compose_wheel_filename( - filename: str, name: str, version: Version, build: BuildTag | None, tags: set[Tag] -) -> None: - assert compose_wheel_filename(name, version, build, tags) == filename - - @pytest.mark.parametrize( ("filename", "name", "version", "build", "tags"), [ @@ -257,15 +208,6 @@ def test_parse_wheel_invalid_filename(filename: str) -> None: parse_wheel_filename(filename) -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" - - name, version, build, tags = parse_wheel_filename(filename) - composed = compose_wheel_filename(name, version, build, tags) - assert sorted_f == composed - - @pytest.mark.parametrize( "filename", [ @@ -290,17 +232,6 @@ def test_parse_wheel_unsorted_tags_invalid_with_validate(filename: str) -> None: parse_wheel_filename(filename, validate_order=True) -@pytest.mark.parametrize( - ("filename", "name", "version"), - [ - ("foo-1.0.tar.gz", "foo", Version("1.0")), - ("foo_bar-1.0.tar.gz", "foo-bar", Version("1.0")), - ], -) -def test_compose_sdist_filename(filename: str, name: str, version: Version) -> None: - assert compose_sdist_filename(name, version) == filename - - @pytest.mark.parametrize( ("filename", "name", "version"), [("foo-1.0.tar.gz", "foo", Version("1.0")), ("foo-1.0.zip", "foo", Version("1.0"))], From 1a15199bf8d54f04520c1dbeabe264a6af46fe05 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 10 Mar 2026 14:47:08 -0400 Subject: [PATCH 11/53] docs: add filenames Signed-off-by: Henry Schreiner --- docs/filenames.rst | 10 +++++ docs/index.rst | 1 + docs/utils.rst | 4 +- src/packaging/filenames.py | 89 +++++++++++++++++++++++++++++++------- 4 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 docs/filenames.rst diff --git a/docs/filenames.rst b/docs/filenames.rst new file mode 100644 index 000000000..0697e8c16 --- /dev/null +++ b/docs/filenames.rst @@ -0,0 +1,10 @@ +Filenames +========= + +Tools to work with filenames for SDists and wheels. + +Reference +--------- + +.. automodule:: packaging.filenames + :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/docs/utils.rst b/docs/utils.rst index 25b53b801..e3a25e5d3 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -1,7 +1,9 @@ Utilities ========= -A set of small, helper utilities for dealing with Python packages. +A set of small, helper utilities for dealing with Python packages. Most of these +functions are now in the :mod:`packaging.filenames` module, and re-exported +here. Reference --------- diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 954347af1..00e759068 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -46,7 +46,7 @@ class InvalidName(ValueError): class InvalidFilename(ValueError): """ - . + An invalid filename was found, users should refer to the packaging user guide. """ @@ -69,6 +69,7 @@ class InvalidSdistFilename(InvalidFilename): _normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]", re.ASCII) # PEP 427: The build number must start with a digit. _build_tag_regex = re.compile(r"(\d+)(.*)", re.ASCII) +_project_name_regex = re.compile(r"^[\w\d._]*$", re.UNICODE) def canonicalize_name( @@ -182,6 +183,14 @@ def _compress_tag_set(tags: Iterable[Tag]) -> str: class WheelFilename: + """Represents a wheel filename and its parsed components. + + Instances preserve the original name and version strings for round-tripping, + while exposing normalized and validated views through properties. + + .. versionadded:: 26.1 + """ + __slots__ = ("build_tag", "original_name", "original_version", "tags") def __init__( @@ -191,6 +200,13 @@ def __init__( build_tag: BuildTag = (), tags: Iterable[Tag] = (), ) -> None: + """Create a wheel filename from its component parts. + + :param name: The project name (in original, filename-style form). + :param version: The version string (in original form). + :param build_tag: Optional wheel build tag. + :param tags: The wheel tag set. + """ self.original_name = name self.original_version = version self.build_tag = build_tag @@ -198,10 +214,15 @@ def __init__( @property def name(self) -> NormalizedName: + """The normalized project name.""" return canonicalize_name(self.original_name) @property def version(self) -> Version: + """The parsed project version. + + :raises InvalidWheelFilename: If the stored version is not valid. + """ try: return Version(self.original_version) except InvalidVersion as e: @@ -210,10 +231,32 @@ def version(self) -> Version: @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", tags={Tag("py3", "none", "any")}) + >>> wf.compressed_tags + 'py3-none-any' + >>> tags = {Tag("py3", "none", "any"), Tag("cp314", "cp314", "win_amd64")} + >>> wf = WheelFilename("foo", "1.0", tags=tags) + >>> wf.compressed_tags + 'cp314.py3-cp314.none-any.win_amd64' + """ return _compress_tag_set(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 + >>> wf = WheelFilename("foo", "1.0") + >>> wf.build_str + '' + >>> wf = WheelFilename("foo", "1.0", build_tag=(1, "abc")) + >>> wf.build_str + '1abc' + """ return "".join(map(str, self.build_tag)) def __repr__(self) -> str: @@ -239,12 +282,10 @@ def to_filename(self) -> str: The tags is set of tags that will be compressed into a wheel tag string. - >>> from packaging.utils import compose_wheel_filename + >>> from packaging.filenames import WheelFilename >>> from packaging.tags import Tag - >>> from packaging.version import Version - >>> version = Version("1.0") >>> tags = {Tag("py3", "none", "any")} - >>> compose_wheel_filename("foo-bar", version, None, tags) + >>> WheelFilename("foo-bar", "1.0", None, tags).to_filename() 'foo_bar-1.0-py3-none-any.whl' .. versionadded:: 26.1 @@ -307,7 +348,7 @@ def from_filename(cls, filename: str, /, *, strict: bool) -> WheelFilename: tags = parse_tag(parts[-1]) # See PEP 427 for the rules on escaping the project name. - if "__" in name or re.match(r"^[\w\d._]*$", name, re.UNICODE) is None: + if "__" in name or _project_name_regex.match(name) is None: inner = f"invalid project name: {name!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) @@ -351,19 +392,37 @@ def from_filename(cls, filename: str, /, *, strict: bool) -> WheelFilename: class SourceFilename: + """Represents a source distribution filename and its parsed components. + + Instances preserve the original name and version strings for round-tripping, + while exposing normalized and validated views through properties. + + .. versionadded:: 26.1 + """ + __slots__ = ("original_name", "original_version") def __init__(self, name: str, version: str) -> None: + """Create a source distribution filename from name and version. + + :param str name: The project name (in original, filename-style form). + :param str version: The version string (in original form). + """ # Store the values that were originally passed for use externally self.original_name = name self.original_version = version @property def name(self) -> NormalizedName: + """The normalized project name.""" return canonicalize_name(self.original_name) @property def version(self) -> Version: + """The parsed project version. + + :raises InvalidSdistFilename: If the stored version is not valid. + """ try: return Version(self.original_version) except InvalidVersion as e: @@ -378,10 +437,9 @@ def to_filename(self) -> str: characters are replaced with ``_`` and characters are lower cased. The version is an instance of :class:`~packaging.version.Version`. - >>> from packaging.utils import compose_sdist_filename - >>> from packaging.version import Version - >>> "foo_bar-1.0.tar.gz" == compose_sdist_filename("foo-bar", Version("1.0")) - True + >>> from packaging.filenames import SourceFilename + >>> SourceFilename("foo-bar", "1.0").to_filename() + 'foo_bar-1.0.tar.gz' .. versionadded:: 26.1 """ @@ -412,13 +470,12 @@ def from_filename(cls, filename: str, /, *, strict: bool) -> SourceFilename: with an sdist extension (``.zip`` or ``.tar.gz``), or if it does not contain a dash separating the name and the version of the distribution. - >>> from packaging.utils import parse_sdist_filename - >>> from packaging.version import Version - >>> name, ver = parse_sdist_filename("foo-1.0.tar.gz") - >>> name + >>> from packaging.filenames import SourceFilename + >>> fn = SourceFilename.from_filename("foo-1.0.tar.gz", strict=True) + >>> fn.name 'foo' - >>> ver == Version('1.0') - True + >>> fn.version + .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name """ From c14bb7bd72d6e2a3830e79d4e78265b942c54233 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 10 Mar 2026 18:03:57 -0400 Subject: [PATCH 12/53] tests: fill out coverage Signed-off-by: Henry Schreiner --- tests/test_filenames.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 61f02f95b..407a644a3 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -293,3 +293,35 @@ def test_parse_and_create_filename() -> None: ) def test_compose_sdist_filename(filename: str, name: str, version: Version) -> None: assert SourceFilename(name, str(version)).to_filename() == filename + + +def test_sdist_from_filename_strict_valid() -> None: + fn = SourceFilename.from_filename("foo_bar-1.0.tar.gz", strict=True) + assert fn.name == "foo-bar" + assert fn.version == Version("1.0") + + +def test_wheel_version_property_invalid() -> None: + wf = WheelFilename("foo", "not-a-version", (), {Tag("py3", "none", "any")}) + with pytest.raises(InvalidWheelFilename, match="invalid version"): + _ = wf.version + + +def test_wheel_repr() -> None: + wf = WheelFilename("foo", "1.0", (1, "abc"), {Tag("py3", "none", "any")}) + assert "WheelFilename" in repr(wf) + assert "name='foo'" in repr(wf) + assert "version='1.0'" in repr(wf) + assert "build_tag=(1, 'abc')" in repr(wf) + assert "tags=" in repr(wf) + + +def test_sdist_version_property_invalid() -> None: + fn = SourceFilename("foo", "not-a-version") + with pytest.raises(InvalidFilename, match="invalid version"): + _ = fn.version + + +def test_sdist_repr() -> None: + fn = SourceFilename("foo", "1.0") + assert repr(fn) == "SourceFilename(name='foo', version='1.0')" From 00d445de30b6ae20a5feb7e126dd082d187d8ca5 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 12 Mar 2026 08:49:00 -0400 Subject: [PATCH 13/53] fix: require sorted tags in strict mode Signed-off-by: Henry Schreiner --- docs/filenames.rst | 2 ++ src/packaging/filenames.py | 6 ++++++ tests/test_filenames.py | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/docs/filenames.rst b/docs/filenames.rst index 0697e8c16..78b1be1ea 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -3,6 +3,8 @@ Filenames Tools to work with filenames for SDists and wheels. +.. versionadded:: 26.1 + Reference --------- diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 00e759068..d5cb0ca89 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -383,11 +383,17 @@ def from_filename(cls, filename: str, /, *, strict: bool) -> WheelFilename: inner = f"non-normalized project name {self.original_name!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) + if self.original_version != str(self.version): inner = f"non-normalized version {self.original_version!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) + if self.compressed_tags != parts[-1]: + inner = f"non-normalized tags {parts[-1]!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) + return self diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 407a644a3..bdc497f5a 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -131,6 +131,23 @@ def test_sdist_from_filename_invalid(filename: str, error_message: str) -> None: (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( @@ -183,6 +200,16 @@ def test_wheel_from_filename( "foo-01.0-py3-none-any.whl", # Non-normalized version "Invalid wheel filename (non-normalized version '01.0')", ), + ( # Unsorted interpreter tags (py3 before py2) + "foo-1.0-py3.py2-none-any.whl", + "Invalid wheel filename (non-normalized tags 'py3.py2-none-any')", + ), + ( + # Unsorted platform tags (manylinux_ before manylinux2014) + "numpy-1.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "Invalid wheel filename (non-normalized tags " + "'cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64')", + ), ], ) def test_wheel_from_filename_invalid(filename: str, error_message: str) -> None: From 996bf1c4927a8d804785703f41be0b66f7f3e3dd Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 12 Mar 2026 09:01:58 -0400 Subject: [PATCH 14/53] rename: SourceDistributionFilename Signed-off-by: Henry Schreiner --- src/packaging/filenames.py | 16 +++++++++------- src/packaging/utils.py | 4 ++-- tests/test_filenames.py | 16 ++++++++-------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index d5cb0ca89..59b0bf836 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -18,7 +18,7 @@ "InvalidWheelFilename", "InvalidWheelFilename", "NormalizedName", - "SourceFilename", + "SourceDistributionFilename", "WheelFilename", "canonicalize_name", "canonicalize_version", @@ -397,7 +397,7 @@ def from_filename(cls, filename: str, /, *, strict: bool) -> WheelFilename: return self -class SourceFilename: +class SourceDistributionFilename: """Represents a source distribution filename and its parsed components. Instances preserve the original name and version strings for round-tripping, @@ -443,8 +443,8 @@ def to_filename(self) -> str: characters are replaced with ``_`` and characters are lower cased. The version is an instance of :class:`~packaging.version.Version`. - >>> from packaging.filenames import SourceFilename - >>> SourceFilename("foo-bar", "1.0").to_filename() + >>> from packaging.filenames import SourceDistributionFilename + >>> SourceDistributionFilename("foo-bar", "1.0").to_filename() 'foo_bar-1.0.tar.gz' .. versionadded:: 26.1 @@ -464,7 +464,9 @@ def __str__(self) -> str: return self.to_filename() @classmethod - def from_filename(cls, filename: str, /, *, strict: bool) -> SourceFilename: + def from_filename( + cls, filename: str, /, *, strict: bool + ) -> SourceDistributionFilename: """ This function takes the filename of a sdist file (as specified in the `Source distribution format`_ documentation), and parses @@ -476,8 +478,8 @@ def from_filename(cls, filename: str, /, *, strict: bool) -> SourceFilename: with an sdist extension (``.zip`` or ``.tar.gz``), or if it does not contain a dash separating the name and the version of the distribution. - >>> from packaging.filenames import SourceFilename - >>> fn = SourceFilename.from_filename("foo-1.0.tar.gz", strict=True) + >>> from packaging.filenames import SourceDistributionFilename + >>> fn = SourceDistributionFilename.from_filename("foo-1.0.tar.gz", strict=True) >>> fn.name 'foo' >>> fn.version diff --git a/src/packaging/utils.py b/src/packaging/utils.py index abf7cf03e..6f44ae0d1 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -12,7 +12,7 @@ InvalidSdistFilename, InvalidWheelFilename, NormalizedName, - SourceFilename, + SourceDistributionFilename, WheelFilename, canonicalize_name, canonicalize_version, @@ -138,5 +138,5 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name """ - fname = SourceFilename.from_filename(filename, strict=False) + fname = SourceDistributionFilename.from_filename(filename, strict=False) return (fname.name, fname.version) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index bdc497f5a..455a942fe 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -7,7 +7,7 @@ from packaging.filenames import ( InvalidFilename, InvalidWheelFilename, - SourceFilename, + SourceDistributionFilename, WheelFilename, ) from packaging.tags import Tag @@ -45,7 +45,7 @@ def test_sdist_not_strict_passes( name: str, version: str, expected_filename: str ) -> None: - fn = SourceFilename(name, version) + fn = SourceDistributionFilename(name, version) assert str(fn) == expected_filename assert fn.original_name == name assert fn.original_version == version @@ -94,7 +94,7 @@ def test_sdist_not_strict_passes( ) def test_sdist_from_filename_invalid(filename: str, error_message: str) -> None: with pytest.raises(InvalidFilename) as e: - SourceFilename.from_filename(filename, strict=True) + SourceDistributionFilename.from_filename(filename, strict=True) assert str(e.value) == f"{error_message}: {filename!r}" @@ -319,11 +319,11 @@ def test_parse_and_create_filename() -> None: ], ) def test_compose_sdist_filename(filename: str, name: str, version: Version) -> None: - assert SourceFilename(name, str(version)).to_filename() == filename + assert SourceDistributionFilename(name, str(version)).to_filename() == filename def test_sdist_from_filename_strict_valid() -> None: - fn = SourceFilename.from_filename("foo_bar-1.0.tar.gz", strict=True) + fn = SourceDistributionFilename.from_filename("foo_bar-1.0.tar.gz", strict=True) assert fn.name == "foo-bar" assert fn.version == Version("1.0") @@ -344,11 +344,11 @@ def test_wheel_repr() -> None: def test_sdist_version_property_invalid() -> None: - fn = SourceFilename("foo", "not-a-version") + fn = SourceDistributionFilename("foo", "not-a-version") with pytest.raises(InvalidFilename, match="invalid version"): _ = fn.version def test_sdist_repr() -> None: - fn = SourceFilename("foo", "1.0") - assert repr(fn) == "SourceFilename(name='foo', version='1.0')" + fn = SourceDistributionFilename("foo", "1.0") + assert repr(fn) == "SourceDistributionFilename(name='foo', version='1.0')" From cd0c88177a0dd1b4e69cdc8e07d396d3a1d514c8 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 7 Apr 2026 00:10:32 -0500 Subject: [PATCH 15/53] fix: include match_args Signed-off-by: Henry Schreiner --- src/packaging/filenames.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 59b0bf836..143718496 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -192,6 +192,7 @@ class WheelFilename: """ __slots__ = ("build_tag", "original_name", "original_version", "tags") + __match_args__ = ("name", "version", "build_tag", "tags") def __init__( self, @@ -407,6 +408,7 @@ class SourceDistributionFilename: """ __slots__ = ("original_name", "original_version") + __match_args__ = ("name", "version") def __init__(self, name: str, version: str) -> None: """Create a source distribution filename from name and version. From 2df2e5378295e74a80c8ddf8aafab32ddd748913 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 12:34:04 -0400 Subject: [PATCH 16/53] fix(filenames): port the validation changes from main Carry the validate_order parameter, InvalidTag handling, the non-empty project name checks, the tighter is_normalized_name regex, and the version notes from utils.py into filenames.py after the rebase. Assisted-by: ClaudeCode:claude-fable-5-1 ClaudeCode:claude-fable-5-1 --- src/packaging/filenames.py | 101 +++++++++++++++++++++++++++++++------ src/packaging/utils.py | 4 +- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 143718496..87fad6170 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -1,9 +1,9 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, NewType, Tuple, Union, cast +from typing import TYPE_CHECKING, NewType, cast -from .tags import Tag, parse_tag +from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag from .version import InvalidVersion, Version, _TrimmedRelease if TYPE_CHECKING: @@ -14,8 +14,6 @@ "InvalidFilename", "InvalidName", "InvalidSdistFilename", - "InvalidSdistFilename", - "InvalidWheelFilename", "InvalidWheelFilename", "NormalizedName", "SourceDistributionFilename", @@ -30,35 +28,50 @@ def __dir__() -> list[str]: return __all__ -BuildTag = Union[Tuple[()], Tuple[int, str]] +BuildTag = tuple[()] | tuple[int, str] +""" +A wheel build tag: an empty tuple, or a ``(build number, build tag suffix)`` pair. + +.. versionadded:: 20.9 +""" NormalizedName = NewType("NormalizedName", str) """ A :class:`typing.NewType` of :class:`str`, representing a normalized name. + +.. versionadded:: 20.4 """ class InvalidName(ValueError): """ An invalid distribution name; users should refer to the packaging user guide. + + .. versionadded:: 23.2 """ class InvalidFilename(ValueError): """ An invalid filename was found, users should refer to the packaging user guide. + + .. versionadded:: 26.1 """ class InvalidWheelFilename(InvalidFilename): """ An invalid wheel filename was found, users should refer to PEP 427. + + .. versionadded:: 20.9 """ class InvalidSdistFilename(InvalidFilename): """ An invalid sdist filename was found, users should refer to the packaging user guide. + + .. versionadded:: 20.9 """ @@ -66,10 +79,12 @@ class InvalidSdistFilename(InvalidFilename): _validate_regex = re.compile( r"[a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9]", re.IGNORECASE | re.ASCII ) -_normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]", re.ASCII) +_normalized_regex = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*", re.ASCII) # PEP 427: The build number must start with a digit. _build_tag_regex = re.compile(r"(\d+)(.*)", re.ASCII) -_project_name_regex = re.compile(r"^[\w\d._]*$", re.UNICODE) +# 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( @@ -102,6 +117,17 @@ def canonicalize_name( 'oslo_concurrency' >>> canonicalize_name("requests") 'requests' + + .. versionadded:: 16.2 + + .. versionchanged:: 20.4 + The return type was changed to :class:`NormalizedName`. + + .. versionchanged:: 23.2 + Added the *validate* keyword parameter. + + .. versionchanged:: 26.1 + Added the *underscore* keyword parameter. """ if validate and not _validate_regex.fullmatch(name): raise InvalidName(f"name is invalid: {name!r}") @@ -122,16 +148,27 @@ def canonicalize_name( def is_normalized_name(name: str) -> bool: """ - Check if a name is already normalized (i.e. :func:`canonicalize_name` would - roundtrip to the same value). + Check if a name is a normalized project name (i.e. a valid name that + :func:`canonicalize_name` would roundtrip to the same value). + + The roundtrip only characterizes normalized names for *valid* names. A name + must start and end with an ASCII letter or digit, which + :func:`canonicalize_name` does not enforce: it leaves a leading or trailing + hyphen in place, so such a name roundtrips without being normalized. :param str name: The name to check. - >>> from packaging.utils import is_normalized_name + >>> from packaging.utils import canonicalize_name, is_normalized_name >>> is_normalized_name("requests") True >>> is_normalized_name("Django") False + >>> canonicalize_name("_not_legal") + '-not-legal' + >>> is_normalized_name("-not-legal") # roundtrips, but not a valid name + False + + .. versionadded:: 23.2 """ return _normalized_regex.fullmatch(name) is not None @@ -165,6 +202,14 @@ def canonicalize_version( >>> canonicalize_version('1.4.0.0.0') '1.4' + + .. versionadded:: 17.1 + + .. versionchanged:: 21.0 + The return type was narrowed to :class:`str`. + + .. versionchanged:: 22.0 + Added the *strip_trailing_zero* keyword parameter. """ if isinstance(version, str): try: @@ -300,7 +345,9 @@ def to_filename(self) -> str: return f"{name}-{self.version}-{ctags}.whl" @classmethod - def from_filename(cls, filename: str, /, *, strict: bool) -> WheelFilename: + def from_filename( + cls, filename: str, /, *, strict: bool, validate_order: bool = False + ) -> WheelFilename: """ This function takes the filename of a wheel file, and parses it, returning a tuple of name, version, build number, and tags. @@ -315,7 +362,14 @@ def from_filename(cls, filename: str, /, *, strict: bool) -> WheelFilename: string format allows multiple tags to be combined into a single string). + If **strict** is true, the name, version, and tags must be in their + normalized form. If **validate_order** is true, compressed tag set + components are checked to be in sorted order as required by PEP 425. + :param str filename: The name of the wheel file. + :param bool strict: Require the filename to be fully normalized. + :param bool validate_order: Check whether compressed tag set components + are in sorted order. :raises InvalidWheelFilename: If the filename in question does not follow the :ref:`wheel specification `. @@ -346,10 +400,19 @@ def from_filename(cls, filename: str, /, *, strict: bool) -> WheelFilename: parts = filestem.split("-", dashes - 2) name = parts[0] version = parts[1] - tags = parse_tag(parts[-1]) + try: + tags = parse_tag(parts[-1], validate_order=validate_order) + except UnsortedTagsError: + inner = "compressed tag set components must be in sorted order per PEP 425" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) from None + except InvalidTag: + inner = f"invalid tag component: {parts[-1]!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 _project_name_regex.match(name) is None: + if "__" in name or _wheel_name_regex.match(name) is None: inner = f"invalid project name: {name!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) @@ -476,9 +539,13 @@ def from_filename( represented by an instance of :class:`~packaging.version.Version`. :param str filename: The name of the sdist file. + :param bool strict: Require a ``.tar.gz`` extension and a fully + normalized name and version. :raises InvalidSdistFilename: If the filename does not end - with an sdist extension (``.zip`` or ``.tar.gz``), or if it does not - contain a dash separating the name and the version of the distribution. + with an sdist extension (``.zip`` or ``.tar.gz``), if it does not + contain a dash separating the name and the version of the distribution, + if the project name is empty, or if the version portion is not a valid + version. >>> from packaging.filenames import SourceDistributionFilename >>> fn = SourceDistributionFilename.from_filename("foo-1.0.tar.gz", strict=True) @@ -512,6 +579,10 @@ def from_filename( inner = "hyphen must separate name and version parts" msg = f"Invalid SDist filename ({inner}): {filename!r}" raise InvalidSdistFilename(msg) + if not name_part: + inner = "empty project name" + msg = f"Invalid SDist filename ({inner}): {filename!r}" + raise InvalidSdistFilename(msg) try: Version(version_part) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 6f44ae0d1..b33475b67 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -99,7 +99,9 @@ def parse_wheel_filename( not an identifier, a tag set component is empty, or the project name is empty. """ - fname = WheelFilename.from_filename(filename, strict=False) + fname = WheelFilename.from_filename( + filename, strict=False, validate_order=validate_order + ) return (fname.name, fname.version, fname.build_tag, fname.tags) From fc41060ad0f7ee9513857b8e640bfd82d9083df1 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 13:03:20 -0400 Subject: [PATCH 17/53] docs(filenames): resolve NormalizedName cross-references NormalizedName now lives in packaging.filenames, so bare references from the re-exporting utils module and the metadata annotations no longer resolve under nitpicky mode. Assisted-by: ClaudeCode:claude-fable-5-1 ClaudeCode:claude-fable-5-1 --- docs/conf.py | 1 + src/packaging/filenames.py | 12 ++++++------ src/packaging/metadata.py | 5 +++-- src/packaging/utils.py | 4 ++-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 28c5e995a..f8d0c4951 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -93,6 +93,7 @@ # ``from __future__ import annotations``). autodoc_type_aliases = { "Path": "pathlib.Path", + "NormalizedName": "packaging.filenames.NormalizedName", } # -- Options for extlinks ----------------------------------------------------- diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 87fad6170..3d66a43e9 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -94,9 +94,9 @@ def canonicalize_name( This function takes a valid Python package or extra name, and returns the normalized form of it. - The return type is typed as :class:`NormalizedName`. This allows type - checkers to help require that a string has passed through this function - before use. + The return type is typed as :class:`~packaging.filenames.NormalizedName`. + This allows type checkers to help require that a string has passed through + this function before use. If **validate** is true, then the function will check if **name** is a valid distribution name before normalizing. If **underscore** is true, then hyphens @@ -121,7 +121,7 @@ def canonicalize_name( .. versionadded:: 16.2 .. versionchanged:: 20.4 - The return type was changed to :class:`NormalizedName`. + The return type was changed to :class:`~packaging.filenames.NormalizedName`. .. versionchanged:: 23.2 Added the *validate* keyword parameter. @@ -353,8 +353,8 @@ def from_filename( returning a tuple of name, version, build number, and tags. The name part of the tuple is normalized and typed as - :class:`NormalizedName`. The version portion is an instance of - :class:`~packaging.version.Version`. The build number is ``()`` if + :class:`~packaging.filenames.NormalizedName`. The version portion is an + instance of :class:`~packaging.version.Version`. The build number is ``()`` if there is no build number in the wheel filename, otherwise a two-item tuple of an integer for the leading digits and a string for the rest of the build number. The tags portion is a diff --git a/src/packaging/metadata.py b/src/packaging/metadata.py index c13936614..38a3d4da7 100644 --- a/src/packaging/metadata.py +++ b/src/packaging/metadata.py @@ -23,6 +23,7 @@ if typing.TYPE_CHECKING: from collections.abc import Callable + from .filenames import NormalizedName from .licenses import NormalizedLicenseExpression from .version import Version @@ -717,7 +718,7 @@ def _process_dynamic(self, value: list[str]) -> list[str]: def _process_provides_extra( self, value: list[str], - ) -> list[utils.NormalizedName]: + ) -> list[NormalizedName]: normalized_names = [] try: for name in value: @@ -993,7 +994,7 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata: """:external:ref:`core-metadata-project-url`""" # PEP 685 lets us raise an error if an extra doesn't pass `Name` validation # regardless of metadata version. - provides_extra: _Validator[list[utils.NormalizedName] | None] = _Validator( + provides_extra: _Validator[list[NormalizedName] | None] = _Validator( added="2.1", ) """:external:ref:`core-metadata-provides-extra`""" diff --git a/src/packaging/utils.py b/src/packaging/utils.py index b33475b67..9b35f7b7a 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -54,8 +54,8 @@ def parse_wheel_filename( returning a tuple of name, version, build number, and tags. The name part of the tuple is normalized and typed as - :class:`NormalizedName`. The version portion is an instance of - :class:`~packaging.version.Version`. The build number is ``()`` if + :class:`~packaging.filenames.NormalizedName`. The version portion is an + instance of :class:`~packaging.version.Version`. The build number is ``()`` if there is no build number in the wheel filename, otherwise a two-item tuple of an integer for the leading digits and a string for the rest of the build number. The tags portion is a From d956d9e33d4caf3e7e4d53d97ae05154992dc3dd Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 13:54:42 -0400 Subject: [PATCH 18/53] feat(filenames): parse PEP 817 variant labels in wheel filenames A six-part filename has a build tag if the third part starts with a digit; otherwise the last part is a variant label. parse_wheel_filename rejects variant wheels. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 66 +++++++++++++++++++++++++------------- src/packaging/utils.py | 6 +++- tests/test_filenames.py | 61 ++++++++++++++++++++++++++++++++--- tests/test_utils.py | 4 ++- 4 files changed, 108 insertions(+), 29 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 3d66a43e9..1e6e8265b 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -82,6 +82,7 @@ class InvalidSdistFilename(InvalidFilename): _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) +_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._]+\Z", re.UNICODE) @@ -233,11 +234,11 @@ class WheelFilename: Instances preserve the original name and version strings for round-tripping, while exposing normalized and validated views through properties. - .. versionadded:: 26.1 + .. versionadded:: 26.4 """ - __slots__ = ("build_tag", "original_name", "original_version", "tags") - __match_args__ = ("name", "version", "build_tag", "tags") + __slots__ = ("build_tag", "original_name", "original_version", "tags", "variant") + __match_args__ = ("name", "version", "build_tag", "tags", "variant") def __init__( self, @@ -245,6 +246,7 @@ def __init__( version: str, build_tag: BuildTag = (), tags: Iterable[Tag] = (), + variant: str | None = None, ) -> None: """Create a wheel filename from its component parts. @@ -252,11 +254,13 @@ def __init__( :param version: The version string (in original form). :param build_tag: Optional wheel build tag. :param tags: The wheel tag set. + :param variant: The variant label (see :pep:`817`), or ``None``. """ self.original_name = name self.original_version = version self.build_tag = build_tag self.tags = frozenset(tags) + self.variant = variant @property def name(self) -> NormalizedName: @@ -310,7 +314,8 @@ def __repr__(self) -> str: f"{self.__class__.__name__}(name={self.original_name!r}, " f"version={self.original_version!r}, " f"build_tag={self.build_tag!r}, " - f"tags={self.tags!r})" + f"tags={self.tags!r}, " + f"variant={self.variant!r})" ) def __str__(self) -> str: @@ -337,12 +342,15 @@ def to_filename(self) -> str: .. versionadded:: 26.1 """ - ctags = self.compressed_tags name = canonicalize_name(self.original_name, underscore=True) + file_parts = [name, str(self.version)] if self.build_tag: - return f"{name}-{self.version}-{self.build_str}-{ctags}.whl" - else: - return f"{name}-{self.version}-{ctags}.whl" + file_parts.append(self.build_str) + file_parts.append(self.compressed_tags) + if self.variant is not None: + file_parts.append(self.variant) + filestem = "-".join(file_parts) + return f"{filestem}.whl" @classmethod def from_filename( @@ -350,7 +358,7 @@ def from_filename( ) -> WheelFilename: """ This function takes the filename of a wheel file, and parses it, - returning a tuple of name, version, build number, and tags. + returning a tuple of name, version, build number, tags, and variant. The name part of the tuple is normalized and typed as :class:`~packaging.filenames.NormalizedName`. The version portion is an @@ -360,7 +368,11 @@ def from_filename( a string for the rest of the build number. The tags portion is a frozen set of :class:`~packaging.tags.Tag` instances (as the tag string format allows multiple tags to be combined into a single - string). + string). The variant is the :pep:`817` variant label, or ``None``. + + 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. If **strict** is true, the name, version, and tags must be in their normalized form. If **validate_order** is true, compressed tag set @@ -392,22 +404,28 @@ def from_filename( raise InvalidWheelFilename(msg) filestem = filename[:-4] - dashes = filestem.count("-") - if dashes not in (4, 5): + parts = filestem.split("-") + if len(parts) not in {5, 6, 7}: msg = f"Invalid wheel filename (wrong number of parts): {filename!r}" raise InvalidWheelFilename(msg) - parts = filestem.split("-", dashes - 2) - name = parts[0] - version = parts[1] + name, version, *rest = parts + + # Six parts are ambiguous: a build tag or a variant label (PEP 817). + # A build tag starts with a digit, and a Python tag never does. + has_build = len(rest) == 5 or (len(rest) == 4 and rest[0][:1].isdigit()) + build_part = rest.pop(0) if has_build else None + variant = rest.pop() if len(rest) == 4 else None + tag_str = "-".join(rest) + try: - tags = parse_tag(parts[-1], validate_order=validate_order) + tags = parse_tag(tag_str, validate_order=validate_order) except UnsortedTagsError: inner = "compressed tag set components must be in sorted order per PEP 425" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) from None except InvalidTag: - inner = f"invalid tag component: {parts[-1]!r}" + inner = f"invalid tag component: {tag_str!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) from None @@ -424,8 +442,7 @@ def from_filename( msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) from None - if dashes == 5: - build_part = parts[2] + if build_part is not None: build_match = _build_tag_regex.match(build_part) if build_match is None: inner = f"invalid build number: {build_part!r}" @@ -437,7 +454,12 @@ def from_filename( else: build_tag = () - self = cls(name, version, build_tag, tags) + 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) + + self = cls(name, version, build_tag, tags, variant) # Reconstruct the filename and check that it matches the original if strict: @@ -453,8 +475,8 @@ def from_filename( msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) - if self.compressed_tags != parts[-1]: - inner = f"non-normalized tags {parts[-1]!r}" + if self.compressed_tags != tag_str: + inner = f"non-normalized tags {tag_str!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 9b35f7b7a..8be8745d2 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -71,7 +71,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:`817` + variant label. >>> from packaging.utils import parse_wheel_filename >>> from packaging.tags import Tag @@ -102,6 +103,9 @@ def parse_wheel_filename( fname = WheelFilename.from_filename( filename, strict=False, validate_order=validate_order ) + if fname.variant is not None: + msg = f"Invalid wheel filename (variant wheels are not supported): {filename!r}" + raise InvalidWheelFilename(msg) return (fname.name, fname.version, fname.build_tag, fname.tags) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 455a942fe..67f84c8a8 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -158,6 +158,48 @@ def test_wheel_from_filename( assert fn.version == version assert fn.build_tag == build_tag assert fn.tags == tags + assert fn.variant is None + + +@pytest.mark.parametrize( + ("filename", "build_tag", "tags", "variant"), + [ + ( + "numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64-x86_64_v3.whl", + (), + {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", + (1, ""), + {Tag("cp313", "cp313t", "musllinux_1_2_x86_64")}, + "x86_64_v3", + ), + ( + "numpy-2.3.2-py2.py3-none-any-null.whl", + (), + {Tag("py2", "none", "any"), Tag("py3", "none", "any")}, + "null", + ), + ( + "numpy-2.3.2-12ab-py3-none-any-0.a_b.whl", + (12, "ab"), + {Tag("py3", "none", "any")}, + "0.a_b", + ), + ], +) +def test_wheel_from_filename_variant( + filename: str, build_tag: BuildTag, tags: set[Tag], variant: str +) -> None: + fn = WheelFilename.from_filename(filename, strict=True) + assert fn.name == "numpy" + assert fn.version == Version("2.3.2") + assert fn.build_tag == build_tag + assert fn.tags == tags + assert fn.variant == variant + assert fn.to_filename() == filename @pytest.mark.parametrize( @@ -184,13 +226,21 @@ def test_wheel_from_filename( "Invalid wheel filename (invalid version: '1.x')", ), ( - # Build number doesn't start with a digit (`abc`) - "foo-1.0-abc-py3-none-any.whl", - "Invalid wheel filename (invalid build number: 'abc')", + # Too many dashes (`-junk-more`) + "foo-1.0-200-py3-none-any-junk-more.whl", + "Invalid wheel filename (wrong number of parts)", ), ( - "foo-1.0-200-py3-none-any-junk.whl", # Too many dashes (`-junk`) - "Invalid wheel filename (wrong number of parts)", + "foo-1.0-py3-none-any-X86.whl", # Upper case variant label + "Invalid wheel filename (invalid variant label: 'X86')", + ), + ( + "foo-1.0-1-py3-none-any-abcdefghijklmnopq.whl", # Label too long + "Invalid wheel filename (invalid variant label: 'abcdefghijklmnopq')", + ), + ( + "foo-1.0-1-py3-none-any-a+b.whl", # Invalid character in label + "Invalid wheel filename (invalid variant label: 'a+b')", ), ( "fOo-1.0-py3-none-any.whl", # Non-normalized project name @@ -341,6 +391,7 @@ def test_wheel_repr() -> None: assert "version='1.0'" in repr(wf) assert "build_tag=(1, 'abc')" in repr(wf) assert "tags=" in repr(wf) + assert "variant=None" in repr(wf) def test_sdist_version_property_invalid() -> None: diff --git a/tests/test_utils.py b/tests/test_utils.py index c543089bc..7870cfe2a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -196,7 +196,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 From 29bf7248ce7ab42874c0040f63313a0a508ddebf Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 13:59:37 -0400 Subject: [PATCH 19/53] fix(filenames): validate project names in strict wheel parsing Also add the dual-license header to the new files. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 16 +++++++++++++--- tests/test_filenames.py | 12 ++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 1e6e8265b..f597b7dda 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -1,3 +1,7 @@ +# 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 @@ -463,9 +467,15 @@ def from_filename( # Reconstruct the filename and check that it matches the original if strict: - if self.original_name != canonicalize_name( - self.original_name, underscore=True - ): + try: + cname = canonicalize_name( + self.original_name, validate=True, underscore=True + ) + except InvalidName: + inner = f"invalid project name {self.original_name!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) from None + if self.original_name != cname: inner = f"non-normalized project name {self.original_name!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 67f84c8a8..7f31a30b0 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -1,3 +1,7 @@ +# 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 typing @@ -246,6 +250,14 @@ def test_wheel_from_filename_variant( "fOo-1.0-py3-none-any.whl", # Non-normalized project name "Invalid wheel filename (non-normalized project name 'fOo')", ), + ( + "_foo-1.0-py3-none-any.whl", # Leading underscore + "Invalid wheel filename (invalid project name '_foo')", + ), + ( + "\u00e9-1.0-py3-none-any.whl", # Non-ASCII name + "Invalid wheel filename (invalid project name '\u00e9')", + ), ( "foo-01.0-py3-none-any.whl", # Non-normalized version "Invalid wheel filename (non-normalized version '01.0')", From b3583223ac8b82ce4d94a1cba0ac2b771d28e988 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 14:17:02 -0400 Subject: [PATCH 20/53] fix(filenames): address review findings - Reject a non-normalized build tag in strict wheel parsing. - Raise when a tag set is empty or cannot be compressed into one tag string. - Add __eq__ and __hash__, and make the filename classes immutable. - Cache the parsed version so it is parsed once per filename. - Give a clear error in parse_wheel_filename for a six-part name that is read as a variant wheel. - List '.zip' in the non-strict sdist extension error. - Keep Tag, Version, parse_tag, InvalidTag, UnsortedTagsError, and InvalidVersion importable from packaging.utils. - Fix the version markers (26.4) and the docstrings. Assisted-by: ClaudeCode:claude-opus-5-5 --- docs/conf.py | 1 - docs/filenames.rst | 2 +- src/packaging/filenames.py | 297 ++++++++++++++++++++++++------------- src/packaging/utils.py | 18 ++- tests/test_filenames.py | 58 ++++++++ tests/test_utils.py | 38 +++++ 6 files changed, 301 insertions(+), 113 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index f8d0c4951..28c5e995a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -93,7 +93,6 @@ # ``from __future__ import annotations``). autodoc_type_aliases = { "Path": "pathlib.Path", - "NormalizedName": "packaging.filenames.NormalizedName", } # -- Options for extlinks ----------------------------------------------------- diff --git a/docs/filenames.rst b/docs/filenames.rst index 78b1be1ea..d86205764 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -3,7 +3,7 @@ Filenames Tools to work with filenames for SDists and wheels. -.. versionadded:: 26.1 +.. versionadded:: 26.4 Reference --------- diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index f597b7dda..6c78639f0 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -4,6 +4,7 @@ from __future__ import annotations +import math import re from typing import TYPE_CHECKING, NewType, cast @@ -59,7 +60,7 @@ class InvalidFilename(ValueError): """ An invalid filename was found, users should refer to the packaging user guide. - .. versionadded:: 26.1 + .. versionadded:: 26.4 """ @@ -131,7 +132,7 @@ def canonicalize_name( .. versionchanged:: 23.2 Added the *validate* keyword parameter. - .. versionchanged:: 26.1 + .. versionchanged:: 26.4 Added the *underscore* keyword parameter. """ if validate and not _validate_regex.fullmatch(name): @@ -224,12 +225,20 @@ def canonicalize_version( return str(_TrimmedRelease(version) if strip_trailing_zero else version) -def _join_tag_attr(tags: Iterable[Tag], field: str) -> str: - return ".".join(sorted({getattr(tag, field) for tag in tags})) - - -def _compress_tag_set(tags: Iterable[Tag]) -> str: - return "-".join(_join_tag_attr(tags, x) for x in ("interpreter", "abi", "platform")) +def _compress_tag_set(tags: frozenset[Tag]) -> str: + if not tags: + msg = "Invalid wheel filename (the tag set must have at least one tag)" + raise InvalidWheelFilename(msg) + fields = [ + sorted({getattr(tag, field) for tag in tags}) + for field in ("interpreter", "abi", "platform") + ] + # A compressed tag string always expands to every combination of its fields. + if len(tags) != math.prod(len(field) for field in fields): + inner = "the tag set cannot be compressed, it must contain every combination" + msg = f"Invalid wheel filename ({inner}): {sorted(map(str, tags))!r}" + raise InvalidWheelFilename(msg) + return "-".join(".".join(field) for field in fields) class WheelFilename: @@ -238,10 +247,20 @@ class WheelFilename: Instances preserve the original name and version strings for round-tripping, while exposing normalized and validated views through properties. + Instances are immutable and hashable. Two instances are equal if all of their + original components are equal. + .. versionadded:: 26.4 """ - __slots__ = ("build_tag", "original_name", "original_version", "tags", "variant") + __slots__ = ( + "_build_tag", + "_original_name", + "_original_version", + "_tags", + "_variant", + "_version", + ) __match_args__ = ("name", "version", "build_tag", "tags", "variant") def __init__( @@ -257,19 +276,45 @@ def __init__( :param name: The project name (in original, filename-style form). :param version: The version string (in original form). :param build_tag: Optional wheel build tag. - :param tags: The wheel tag set. + :param tags: The wheel tag set. It must not be empty to make a filename. :param variant: The variant label (see :pep:`817`), or ``None``. """ - self.original_name = name - self.original_version = version - self.build_tag = build_tag - self.tags = frozenset(tags) - self.variant = variant + self._original_name = name + self._original_version = version + self._build_tag = build_tag + self._tags = frozenset(tags) + self._variant = variant + self._version: Version | None = None + + @property + def original_name(self) -> str: + """The project name as given.""" + return self._original_name + + @property + def original_version(self) -> str: + """The version string as given.""" + return self._original_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:`817`), or ``None``.""" + return self._variant @property def name(self) -> NormalizedName: """The normalized project name.""" - return canonicalize_name(self.original_name) + return canonicalize_name(self._original_name) @property def version(self) -> Version: @@ -277,27 +322,32 @@ def version(self) -> Version: :raises InvalidWheelFilename: If the stored version is not valid. """ - try: - return Version(self.original_version) - except InvalidVersion as e: - msg = f"Invalid wheel filename (invalid version {self.original_version!r})" - raise InvalidWheelFilename(msg) from e + if self._version is None: + try: + self._version = Version(self._original_version) + except InvalidVersion as e: + inner = f"invalid version {self._original_version!r}" + raise InvalidWheelFilename(f"Invalid wheel filename ({inner})") from e + return self._version @property def compressed_tags(self) -> str: """The compressed and sorted wheel tag string (interpreter-abi-platform). + :raises InvalidWheelFilename: If the tag set is empty, or if it does not + contain every combination of its interpreters, ABIs, and platforms. + >>> from packaging.filenames import WheelFilename >>> from packaging.tags import Tag >>> wf = WheelFilename("foo", "1.0", tags={Tag("py3", "none", "any")}) >>> wf.compressed_tags 'py3-none-any' - >>> tags = {Tag("py3", "none", "any"), Tag("cp314", "cp314", "win_amd64")} + >>> tags = {Tag("py3", "none", "any"), Tag("py2", "none", "any")} >>> wf = WheelFilename("foo", "1.0", tags=tags) >>> wf.compressed_tags - 'cp314.py3-cp314.none-any.win_amd64' + 'py2.py3-none-any' """ - return _compress_tag_set(self.tags) + return _compress_tag_set(self._tags) @property def build_str(self) -> str: @@ -311,15 +361,32 @@ def build_str(self) -> str: >>> wf.build_str '1abc' """ - return "".join(map(str, self.build_tag)) + return "".join(map(str, self._build_tag)) + + def _key(self) -> tuple[str, str, BuildTag, frozenset[Tag], str | None]: + return ( + self._original_name, + self._original_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 __repr__(self) -> str: return ( - f"{self.__class__.__name__}(name={self.original_name!r}, " - f"version={self.original_version!r}, " - f"build_tag={self.build_tag!r}, " - f"tags={self.tags!r}, " - f"variant={self.variant!r})" + f"{self.__class__.__name__}(name={self._original_name!r}, " + f"version={self._original_version!r}, " + f"build_tag={self._build_tag!r}, " + f"tags={self._tags!r}, " + f"variant={self._variant!r})" ) def __str__(self) -> str: @@ -327,32 +394,30 @@ def __str__(self) -> str: def to_filename(self) -> str: """ - Combines a project name, version, build tag, and tag set + 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 an instance of - :class:`~packaging.version.Version`. The build tag can be None, - an empty tuple or a two-item tuple of an integer and a string. - The tags is set of tags that will be compressed into a wheel - tag string. + characters are replaced with ``_``. The version is normalized. The + tag set is compressed into a wheel tag string. + + :raises InvalidWheelFilename: If the version is not valid, or if the tag + set cannot be compressed (see :attr:`compressed_tags`). >>> from packaging.filenames import WheelFilename >>> from packaging.tags import Tag >>> tags = {Tag("py3", "none", "any")} - >>> WheelFilename("foo-bar", "1.0", None, tags).to_filename() + >>> WheelFilename("foo-bar", "1.0", (), tags).to_filename() 'foo_bar-1.0-py3-none-any.whl' - - .. versionadded:: 26.1 """ - name = canonicalize_name(self.original_name, underscore=True) + name = canonicalize_name(self._original_name, underscore=True) file_parts = [name, str(self.version)] - if self.build_tag: + 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) + if self._variant is not None: + file_parts.append(self._variant) filestem = "-".join(file_parts) return f"{filestem}.whl" @@ -361,25 +426,16 @@ def from_filename( cls, filename: str, /, *, strict: bool, validate_order: bool = False ) -> WheelFilename: """ - This function takes the filename of a wheel file, and parses it, - returning a tuple of name, version, build number, tags, and variant. - - The name part of the tuple is normalized and typed as - :class:`~packaging.filenames.NormalizedName`. The version portion is an - instance of :class:`~packaging.version.Version`. The build number is ``()`` if - there is no build number in the wheel filename, otherwise a - two-item tuple of an integer for the leading digits and - a string for the rest of the build number. The tags portion is a - frozen set of :class:`~packaging.tags.Tag` instances (as the tag - string format allows multiple tags to be combined into a single - string). The variant is the :pep:`817` variant label, or ``None``. + 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. + since a Python tag never does. Otherwise, the last part is a variant + label (see :pep:`817`). - If **strict** is true, the name, version, and tags must be in their - normalized form. If **validate_order** is true, compressed tag set + If **strict** is true, the name, version, build tag, and tags must be in + their normalized form. If **validate_order** is true, compressed tag set components are checked to be in sorted order as required by PEP 425. :param str filename: The name of the wheel file. @@ -390,17 +446,16 @@ def from_filename( does not follow the :ref:`wheel specification `. - >>> from packaging.utils import parse_wheel_filename + >>> from packaging.filenames import WheelFilename >>> from packaging.tags import Tag - >>> from packaging.version import Version - >>> name, ver, build, tags = parse_wheel_filename("foo-1.0-py3-none-any.whl") - >>> name + >>> wf = WheelFilename.from_filename("foo-1.0-py3-none-any.whl", strict=True) + >>> wf.name 'foo' - >>> ver == Version('1.0') - True - >>> tags == {Tag("py3", "none", "any")} - True - >>> not build + >>> wf.version + + >>> wf.build_tag + () + >>> wf.tags == {Tag("py3", "none", "any")} True """ if not filename.endswith(".whl"): @@ -413,7 +468,7 @@ def from_filename( msg = f"Invalid wheel filename (wrong number of parts): {filename!r}" raise InvalidWheelFilename(msg) - name, version, *rest = parts + name, version_part, *rest = parts # Six parts are ambiguous: a build tag or a variant label (PEP 817). # A build tag starts with a digit, and a Python tag never does. @@ -440,9 +495,9 @@ def from_filename( raise InvalidWheelFilename(msg) try: - Version(version) + version = Version(version_part) except InvalidVersion: - inner = f"invalid version: {version!r}" + inner = f"invalid version: {version_part!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) from None @@ -463,25 +518,29 @@ def from_filename( msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) - self = cls(name, version, build_tag, tags, variant) + self = cls(name, version_part, build_tag, tags, variant) + self._version = version # Reconstruct the filename and check that it matches the original if strict: try: - cname = canonicalize_name( - self.original_name, validate=True, underscore=True - ) + cname = canonicalize_name(name, validate=True, underscore=True) except InvalidName: - inner = f"invalid project name {self.original_name!r}" + inner = f"invalid project name {name!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) from None - if self.original_name != cname: - inner = f"non-normalized project name {self.original_name!r}" + if name != cname: + inner = f"non-normalized project name {name!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) + + if version_part != str(version): + inner = f"non-normalized version {version_part!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) - if self.original_version != str(self.version): - inner = f"non-normalized version {self.original_version!r}" + if build_part is not None and build_part != self.build_str: + inner = f"non-normalized build tag {build_part!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) @@ -499,10 +558,13 @@ class SourceDistributionFilename: Instances preserve the original name and version strings for round-tripping, while exposing normalized and validated views through properties. - .. versionadded:: 26.1 + Instances are immutable and hashable. Two instances are equal if their + original name and version are equal. + + .. versionadded:: 26.4 """ - __slots__ = ("original_name", "original_version") + __slots__ = ("_original_name", "_original_version", "_version") __match_args__ = ("name", "version") def __init__(self, name: str, version: str) -> None: @@ -511,14 +573,24 @@ def __init__(self, name: str, version: str) -> None: :param str name: The project name (in original, filename-style form). :param str version: The version string (in original form). """ - # Store the values that were originally passed for use externally - self.original_name = name - self.original_version = version + self._original_name = name + self._original_version = version + self._version: Version | None = None + + @property + def original_name(self) -> str: + """The project name as given.""" + return self._original_name + + @property + def original_version(self) -> str: + """The version string as given.""" + return self._original_version @property def name(self) -> NormalizedName: """The normalized project name.""" - return canonicalize_name(self.original_name) + return canonicalize_name(self._original_name) @property def version(self) -> Version: @@ -526,35 +598,48 @@ def version(self) -> Version: :raises InvalidSdistFilename: If the stored version is not valid. """ - try: - return Version(self.original_version) - except InvalidVersion as e: - raise InvalidSdistFilename( - f"Invalid filename (invalid version {self.original_version!r})" - ) from e + if self._version is None: + try: + self._version = Version(self._original_version) + except InvalidVersion as e: + raise InvalidSdistFilename( + f"Invalid filename (invalid version {self._original_version!r})" + ) from e + return self._version 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 an instance of :class:`~packaging.version.Version`. + version is normalized. + + :raises InvalidSdistFilename: If the version is not valid. >>> from packaging.filenames import SourceDistributionFilename >>> SourceDistributionFilename("foo-bar", "1.0").to_filename() 'foo_bar-1.0.tar.gz' - - .. versionadded:: 26.1 """ - name = canonicalize_name(self.original_name, underscore=True) + name = canonicalize_name(self._original_name, underscore=True) version = str(self.version) return f"{name}-{version}.tar.gz" + def __eq__(self, other: object) -> bool: + if not isinstance(other, SourceDistributionFilename): + return NotImplemented + return (self._original_name, self._original_version) == ( + other._original_name, + other._original_version, + ) + + def __hash__(self) -> int: + return hash((self._original_name, self._original_version)) + def __repr__(self) -> str: return ( - f"{self.__class__.__name__}(name={self.original_name!r}, " - f"version={self.original_version!r})" + f"{self.__class__.__name__}(name={self._original_name!r}, " + f"version={self._original_version!r})" ) def __str__(self) -> str: @@ -567,8 +652,7 @@ def from_filename( """ This function takes the filename of a sdist file (as specified in the `Source distribution format`_ documentation), and parses - it, returning a tuple of the normalized name and version as - represented by an instance of :class:`~packaging.version.Version`. + it into a :class:`SourceDistributionFilename`. :param str filename: The name of the sdist file. :param bool strict: Require a ``.tar.gz`` extension and a fully @@ -589,14 +673,15 @@ def from_filename( .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name """ # PEP 625: Source distributions must end with .tar.gz - # Non-scrict mode will allow .zip for backward compatibility + # Non-strict mode will allow .zip for backward compatibility if filename.endswith(".tar.gz"): file_stem = filename[: -len(".tar.gz")] elif filename.endswith(".zip") and not strict: file_stem = filename[: -len(".zip")] else: + extensions = "'.tar.gz'" if strict else "'.tar.gz' or '.zip'" raise InvalidSdistFilename( - f"Invalid SDist filename (extension must be '.tar.gz'): {filename!r}" + f"Invalid SDist filename (extension must be {extensions}): {filename!r}" ) name_part, sep, version_part = file_stem.rpartition("-") @@ -617,7 +702,7 @@ def from_filename( raise InvalidSdistFilename(msg) try: - Version(version_part) + version = Version(version_part) except InvalidVersion as e: inner = f"invalid version {version_part!r}" msg = f"Invalid SDist filename ({inner}): {filename!r}" @@ -634,9 +719,11 @@ def from_filename( inner = f"non-normalized project name {name_part!r}" msg = f"Invalid SDist filename ({inner}): {filename!r}" raise InvalidSdistFilename(msg) - if version_part != str(Version(version_part)): + if version_part != str(version): inner = f"non-normalized version {version_part!r}" msg = f"Invalid SDist filename ({inner}): {filename!r}" - raise InvalidSdistFilename(msg) from None + raise InvalidSdistFilename(msg) - return cls(name_part, version_part) + self = cls(name_part, version_part) + self._version = version + return self diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 8be8745d2..072b355bc 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -4,8 +4,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from .filenames import ( BuildTag, InvalidName, @@ -19,9 +17,9 @@ is_normalized_name, ) -if TYPE_CHECKING: - from .tags import Tag - from .version import Version +# These were importable from this module before, so keep them for compatibility. +from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag # noqa: F401, TC001 +from .version import InvalidVersion, Version # noqa: F401, TC001 # For historical reasons, we export the filename parsing functions from this # module, even though they are implemented in the filenames module now. @@ -104,7 +102,15 @@ def parse_wheel_filename( filename, strict=False, validate_order=validate_order ) if fname.variant is not None: - msg = f"Invalid wheel filename (variant wheels are not supported): {filename!r}" + # Without variant support, a sixth part is a build number. + if fname.build_tag: + inner = ( + "variant wheels are not supported, " + "use packaging.filenames.WheelFilename instead for support" + ) + else: + inner = "invalid build number or unsupported variant label" + msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) return (fname.name, fname.version, fname.build_tag, fname.tags) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 7f31a30b0..bfbb6756f 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -234,6 +234,10 @@ def test_wheel_from_filename_variant( "foo-1.0-200-py3-none-any-junk-more.whl", "Invalid wheel filename (wrong number of parts)", ), + ( + "foo-1.0-abc-py3-none-any-x86.whl", # Build number without a digit + "Invalid wheel filename (invalid build number: 'abc')", + ), ( "foo-1.0-py3-none-any-X86.whl", # Upper case variant label "Invalid wheel filename (invalid variant label: 'X86')", @@ -262,6 +266,10 @@ def test_wheel_from_filename_variant( "foo-01.0-py3-none-any.whl", # Non-normalized version "Invalid wheel filename (non-normalized version '01.0')", ), + ( + "foo-1.0-01-py3-none-any.whl", # Non-normalized build tag + "Invalid wheel filename (non-normalized build tag '01')", + ), ( # Unsorted interpreter tags (py3 before py2) "foo-1.0-py3.py2-none-any.whl", "Invalid wheel filename (non-normalized tags 'py3.py2-none-any')", @@ -415,3 +423,53 @@ def test_sdist_version_property_invalid() -> None: def test_sdist_repr() -> None: fn = SourceDistributionFilename("foo", "1.0") assert repr(fn) == "SourceDistributionFilename(name='foo', version='1.0')" + + +def test_sdist_from_filename_invalid_extension_not_strict() -> None: + with pytest.raises(InvalidFilename) as e: + SourceDistributionFilename.from_filename("foo-1.0.tgz", strict=False) + + assert str(e.value) == ( + "Invalid SDist filename (extension must be '.tar.gz' or '.zip'): 'foo-1.0.tgz'" + ) + + +def test_wheel_from_filename_six_parts_non_digit_is_variant() -> None: + # PEP 817: a third part that does not start with a digit is a Python tag. + fn = WheelFilename.from_filename("foo-1.0-abc-py3-none-any.whl", strict=True) + assert fn.build_tag == () + assert fn.tags == {Tag("abc", "py3", "none")} + assert fn.variant == "any" + + +def test_wheel_to_filename_no_tags() -> None: + wf = WheelFilename("foo", "1.0") + with pytest.raises(InvalidWheelFilename, match="at least one tag"): + wf.to_filename() + + +def test_wheel_to_filename_tags_not_compressible() -> None: + tags = {Tag("py3", "none", "any"), Tag("cp314", "cp314", "win_amd64")} + wf = WheelFilename("foo", "1.0", (), tags) + with pytest.raises(InvalidWheelFilename, match="cannot be compressed"): + wf.to_filename() + + +def test_wheel_eq_hash() -> None: + tags = {Tag("py3", "none", "any")} + wf = WheelFilename("foo", "1.0", (1, ""), tags) + assert wf == WheelFilename("foo", "1.0", (1, ""), tags) + assert hash(wf) == hash(WheelFilename("foo", "1.0", (1, ""), tags)) + assert wf != WheelFilename("Foo", "1.0", (1, ""), tags) + assert wf != WheelFilename("foo", "1.0", (), tags) + assert wf != WheelFilename("foo", "1.0", (1, ""), tags, "x86_64_v3") + assert wf != "foo-1.0-1-py3-none-any.whl" + assert len({wf, WheelFilename.from_filename(str(wf), strict=True)}) == 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 != "foo-1.0.tar.gz" diff --git a/tests/test_utils.py b/tests/test_utils.py index 7870cfe2a..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, @@ -255,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 + ) From f849b2f59fff738da79181251a595c467f1a6913 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 14:52:34 -0400 Subject: [PATCH 21/53] refactor(filenames): share a base class and one error formatter Move the shared state, properties, equality, and hashing of WheelFilename and SourceDistributionFilename into a private base class. Build all error messages through one helper so the wheel and sdist formats stay in sync, and restore the lowercase "sdist" prefix used on main. Share the strict name and version check. Make strict wheel parsing imply validate_order so unsorted tags get the PEP 425 message. Derive the underscore form of canonicalize_name from the hyphen form. Import canonicalize_name from filenames in metadata. Assisted-by: ClaudeCode:claude-fable-5-1 ClaudeCode:claude-fable-5-1 --- docs/filenames.rst | 1 + src/packaging/filenames.py | 372 +++++++++++++++---------------------- src/packaging/metadata.py | 14 +- src/packaging/utils.py | 7 +- tests/test_filenames.py | 104 +++++------ 5 files changed, 202 insertions(+), 296 deletions(-) diff --git a/docs/filenames.rst b/docs/filenames.rst index d86205764..070ba2eac 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -10,3 +10,4 @@ Reference .. automodule:: packaging.filenames :members: + :inherited-members: diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 6c78639f0..9a4b175c5 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -4,9 +4,9 @@ from __future__ import annotations -import math +import abc import re -from typing import TYPE_CHECKING, NewType, cast +from typing import TYPE_CHECKING, ClassVar, NewType, cast from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag from .version import InvalidVersion, Version, _TrimmedRelease @@ -137,18 +137,15 @@ def canonicalize_name( """ if validate and not _validate_regex.fullmatch(name): raise InvalidName(f"name is invalid: {name!r}") + # 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("--", "-") if underscore: - value = name.lower().replace("-", "_").replace(".", "_") - while "__" in value: - value = value.replace("__", "_") - else: - # 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("--", "-") + value = value.replace("-", "_") return cast("NormalizedName", value) @@ -229,19 +226,99 @@ def _compress_tag_set(tags: frozenset[Tag]) -> str: if not tags: msg = "Invalid wheel filename (the tag set must have at least one tag)" raise InvalidWheelFilename(msg) - fields = [ - sorted({getattr(tag, field) for tag in tags}) - for field in ("interpreter", "abi", "platform") - ] + 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) != math.prod(len(field) for field in fields): + if len(tags) != len(interpreters) * len(abis) * len(platforms): inner = "the tag set cannot be compressed, it must contain every combination" msg = f"Invalid wheel filename ({inner}): {sorted(map(str, tags))!r}" raise InvalidWheelFilename(msg) - return "-".join(".".join(field) for field in fields) + return "-".join((".".join(interpreters), ".".join(abis), ".".join(platforms))) -class WheelFilename: +class _DistributionFilename(abc.ABC): + """Shared state and behavior for wheel and sdist filenames.""" + + __slots__ = ("_original_name", "_original_version", "_version") + + _error: ClassVar[type[InvalidFilename]] + _kind: ClassVar[str] + + def __init__(self, name: str, version: str) -> None: + self._original_name = name + self._original_version = version + self._version: Version | None = None + + @classmethod + def _invalid(cls, inner: str, filename: str | None = None) -> InvalidFilename: + msg = f"Invalid {cls._kind} filename ({inner})" + if filename is not None: + msg = f"{msg}: {filename!r}" + return cls._error(msg) + + @classmethod + def _check_normalized( + cls, name: str, version_part: str, version: Version, filename: str + ) -> None: + """Check that the name and version parts are in their normalized form.""" + try: + cname = canonicalize_name(name, validate=True, underscore=True) + except InvalidName: + raise cls._invalid(f"invalid project name {name!r}", filename) from None + if name != cname: + raise cls._invalid(f"non-normalized project name {name!r}", filename) + if version_part != str(version): + raise cls._invalid(f"non-normalized version {version_part!r}", filename) + + @property + def original_name(self) -> str: + """The project name as given.""" + return self._original_name + + @property + def original_version(self) -> str: + """The version string as given.""" + return self._original_version + + @property + def name(self) -> NormalizedName: + """The normalized project name.""" + return canonicalize_name(self._original_name) + + @property + def version(self) -> Version: + """The parsed project version. + + :raises InvalidFilename: If the stored version is not valid. + """ + if self._version is None: + try: + self._version = Version(self._original_version) + except InvalidVersion as e: + inner = f"invalid version {self._original_version!r}" + raise self._invalid(inner) from e + return self._version + + def _key(self) -> tuple[object, ...]: + return (self._original_name, self._original_version) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, _DistributionFilename): + return NotImplemented + return self._key() == other._key() + + def __hash__(self) -> int: + return hash(self._key()) + + @abc.abstractmethod + def to_filename(self) -> str: ... + + def __str__(self) -> str: + return self.to_filename() + + +class WheelFilename(_DistributionFilename): """Represents a wheel filename and its parsed components. Instances preserve the original name and version strings for round-tripping, @@ -253,16 +330,12 @@ class WheelFilename: .. versionadded:: 26.4 """ - __slots__ = ( - "_build_tag", - "_original_name", - "_original_version", - "_tags", - "_variant", - "_version", - ) + __slots__ = ("_build_tag", "_tags", "_variant") __match_args__ = ("name", "version", "build_tag", "tags", "variant") + _error = InvalidWheelFilename + _kind = "wheel" + def __init__( self, name: str, @@ -279,22 +352,10 @@ def __init__( :param tags: The wheel tag set. It must not be empty to make a filename. :param variant: The variant label (see :pep:`817`), or ``None``. """ - self._original_name = name - self._original_version = version + super().__init__(name, version) self._build_tag = build_tag self._tags = frozenset(tags) self._variant = variant - self._version: Version | None = None - - @property - def original_name(self) -> str: - """The project name as given.""" - return self._original_name - - @property - def original_version(self) -> str: - """The version string as given.""" - return self._original_version @property def build_tag(self) -> BuildTag: @@ -311,25 +372,6 @@ def variant(self) -> str | None: """The variant label (see :pep:`817`), or ``None``.""" return self._variant - @property - def name(self) -> NormalizedName: - """The normalized project name.""" - return canonicalize_name(self._original_name) - - @property - def version(self) -> Version: - """The parsed project version. - - :raises InvalidWheelFilename: If the stored version is not valid. - """ - if self._version is None: - try: - self._version = Version(self._original_version) - except InvalidVersion as e: - inner = f"invalid version {self._original_version!r}" - raise InvalidWheelFilename(f"Invalid wheel filename ({inner})") from e - return self._version - @property def compressed_tags(self) -> str: """The compressed and sorted wheel tag string (interpreter-abi-platform). @@ -363,7 +405,7 @@ def build_str(self) -> str: """ return "".join(map(str, self._build_tag)) - def _key(self) -> tuple[str, str, BuildTag, frozenset[Tag], str | None]: + def _key(self) -> tuple[object, ...]: return ( self._original_name, self._original_version, @@ -372,14 +414,6 @@ def _key(self) -> tuple[str, str, BuildTag, frozenset[Tag], str | None]: 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 __repr__(self) -> str: return ( f"{self.__class__.__name__}(name={self._original_name!r}, " @@ -389,9 +423,6 @@ def __repr__(self) -> str: f"variant={self._variant!r})" ) - def __str__(self) -> str: - return self.to_filename() - def to_filename(self) -> str: """ Combines a project name, version, build tag, tag set, and variant label @@ -410,7 +441,6 @@ def to_filename(self) -> str: >>> WheelFilename("foo-bar", "1.0", (), tags).to_filename() 'foo_bar-1.0-py3-none-any.whl' """ - name = canonicalize_name(self._original_name, underscore=True) file_parts = [name, str(self.version)] if self._build_tag: @@ -418,8 +448,7 @@ def to_filename(self) -> str: file_parts.append(self.compressed_tags) if self._variant is not None: file_parts.append(self._variant) - filestem = "-".join(file_parts) - return f"{filestem}.whl" + return "-".join(file_parts) + ".whl" @classmethod def from_filename( @@ -435,8 +464,9 @@ def from_filename( label (see :pep:`817`). If **strict** is true, the name, version, build tag, and tags must be in - their normalized form. If **validate_order** is true, compressed tag set - components are checked to be in sorted order as required by PEP 425. + their normalized form, which includes sorted tag set components. If + **validate_order** is true, compressed tag set components are checked to + be in sorted order as required by PEP 425 even when **strict** is false. :param str filename: The name of the wheel file. :param bool strict: Require the filename to be fully normalized. @@ -459,100 +489,63 @@ def from_filename( True """ if not filename.endswith(".whl"): - msg = f"Invalid wheel filename (extension must be '.whl'): {filename!r}" - raise InvalidWheelFilename(msg) + raise cls._invalid("extension must be '.whl'", filename) - filestem = filename[:-4] - parts = filestem.split("-") + parts = filename[:-4].split("-") if len(parts) not in {5, 6, 7}: - msg = f"Invalid wheel filename (wrong number of parts): {filename!r}" - raise InvalidWheelFilename(msg) + raise cls._invalid("wrong number of parts", filename) name, version_part, *rest = parts # Six parts are ambiguous: a build tag or a variant label (PEP 817). # A build tag starts with a digit, and a Python tag never does. - has_build = len(rest) == 5 or (len(rest) == 4 and rest[0][:1].isdigit()) - build_part = rest.pop(0) if has_build else None + build_match = _build_tag_regex.match(rest[0]) if len(rest) > 3 else None + if len(rest) == 5 and build_match is None: + raise cls._invalid(f"invalid build number {rest[0]!r}", filename) + build_part = rest.pop(0) if build_match else None variant = rest.pop() if len(rest) == 4 else None tag_str = "-".join(rest) try: - tags = parse_tag(tag_str, validate_order=validate_order) + tags = parse_tag(tag_str, validate_order=validate_order or strict) except UnsortedTagsError: inner = "compressed tag set components must be in sorted order per PEP 425" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) from None + raise cls._invalid(inner, filename) 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 + raise cls._invalid(f"invalid tag component {tag_str!r}", filename) from None # See PEP 427 for the rules on escaping the project name. if "__" in name or _wheel_name_regex.match(name) is None: - inner = f"invalid project name: {name!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) + raise cls._invalid(f"invalid project name {name!r}", filename) try: version = Version(version_part) - except InvalidVersion: - inner = f"invalid version: {version_part!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) from None - - if build_part is not None: - build_match = _build_tag_regex.match(build_part) - if build_match is None: - inner = f"invalid build number: {build_part!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) - build_tag = cast( - "BuildTag", (int(build_match.group(1)), build_match.group(2)) - ) - else: - build_tag = () + except InvalidVersion as e: + raise cls._invalid(f"invalid version {version_part!r}", filename) from e + + build_tag: BuildTag = () + if build_match is not None: + build_tag = (int(build_match.group(1)), build_match.group(2)) 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) + raise cls._invalid(f"invalid variant label {variant!r}", filename) self = cls(name, version_part, build_tag, tags, variant) self._version = version # Reconstruct the filename and check that it matches the original if strict: - try: - cname = canonicalize_name(name, validate=True, underscore=True) - except InvalidName: - inner = f"invalid project name {name!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) from None - if name != cname: - inner = f"non-normalized project name {name!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) - - if version_part != str(version): - inner = f"non-normalized version {version_part!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) - + cls._check_normalized(name, version_part, version, filename) if build_part is not None and build_part != self.build_str: inner = f"non-normalized build tag {build_part!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) - + raise cls._invalid(inner, filename) if self.compressed_tags != tag_str: - inner = f"non-normalized tags {tag_str!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) + raise cls._invalid(f"non-normalized tags {tag_str!r}", filename) return self -class SourceDistributionFilename: +class SourceDistributionFilename(_DistributionFilename): """Represents a source distribution filename and its parsed components. Instances preserve the original name and version strings for round-tripping, @@ -564,48 +557,25 @@ class SourceDistributionFilename: .. versionadded:: 26.4 """ - __slots__ = ("_original_name", "_original_version", "_version") + __slots__ = () __match_args__ = ("name", "version") + _error = InvalidSdistFilename + _kind = "sdist" + def __init__(self, name: str, version: str) -> None: """Create a source distribution filename from name and version. :param str name: The project name (in original, filename-style form). :param str version: The version string (in original form). """ - self._original_name = name - self._original_version = version - self._version: Version | None = None - - @property - def original_name(self) -> str: - """The project name as given.""" - return self._original_name - - @property - def original_version(self) -> str: - """The version string as given.""" - return self._original_version + super().__init__(name, version) - @property - def name(self) -> NormalizedName: - """The normalized project name.""" - return canonicalize_name(self._original_name) - - @property - def version(self) -> Version: - """The parsed project version. - - :raises InvalidSdistFilename: If the stored version is not valid. - """ - if self._version is None: - try: - self._version = Version(self._original_version) - except InvalidVersion as e: - raise InvalidSdistFilename( - f"Invalid filename (invalid version {self._original_version!r})" - ) from e - return self._version + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(name={self._original_name!r}, " + f"version={self._original_version!r})" + ) def to_filename(self) -> str: """ @@ -621,29 +591,7 @@ def to_filename(self) -> str: 'foo_bar-1.0.tar.gz' """ name = canonicalize_name(self._original_name, underscore=True) - version = str(self.version) - - return f"{name}-{version}.tar.gz" - - def __eq__(self, other: object) -> bool: - if not isinstance(other, SourceDistributionFilename): - return NotImplemented - return (self._original_name, self._original_version) == ( - other._original_name, - other._original_version, - ) - - def __hash__(self) -> int: - return hash((self._original_name, self._original_version)) - - def __repr__(self) -> str: - return ( - f"{self.__class__.__name__}(name={self._original_name!r}, " - f"version={self._original_version!r})" - ) - - def __str__(self) -> str: - return self.to_filename() + return f"{name}-{self.version}.tar.gz" @classmethod def from_filename( @@ -680,49 +628,25 @@ def from_filename( file_stem = filename[: -len(".zip")] else: extensions = "'.tar.gz'" if strict else "'.tar.gz' or '.zip'" - raise InvalidSdistFilename( - f"Invalid SDist filename (extension must be {extensions}): {filename!r}" - ) + raise cls._invalid(f"extension must be {extensions}", filename) - name_part, sep, version_part = file_stem.rpartition("-") # PEP 625: Source distributions may only have one hyphen, separating - # the name and version - if strict and "-" in name_part: - inner = "name and version parts can not contain hyphens" - msg = f"Invalid SDist filename ({inner}): {filename!r}" - raise InvalidSdistFilename(msg) - + # the name and version. Strict mode rejects extra hyphens via the + # normalized name check below. + 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) + raise cls._invalid(inner, filename) if not name_part: - inner = "empty project name" - msg = f"Invalid SDist filename ({inner}): {filename!r}" - raise InvalidSdistFilename(msg) + raise cls._invalid("empty project name", filename) 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 + raise cls._invalid(f"invalid version {version_part!r}", filename) from e if strict: - try: - cname = canonicalize_name(name_part, validate=True, underscore=True) - except InvalidName: - inner = f"invalid project name {name_part!r}" - msg = f"Invalid SDist filename ({inner}): {filename!r}" - raise InvalidSdistFilename(msg) from None - if name_part != cname: - inner = f"non-normalized project name {name_part!r}" - msg = f"Invalid SDist filename ({inner}): {filename!r}" - raise InvalidSdistFilename(msg) - if version_part != str(version): - inner = f"non-normalized version {version_part!r}" - msg = f"Invalid SDist filename ({inner}): {filename!r}" - raise InvalidSdistFilename(msg) + cls._check_normalized(name_part, version_part, version, filename) self = cls(name_part, version_part) self._version = version diff --git a/src/packaging/metadata.py b/src/packaging/metadata.py index 38a3d4da7..0c5a87e06 100644 --- a/src/packaging/metadata.py +++ b/src/packaging/metadata.py @@ -16,7 +16,7 @@ cast, ) -from . import licenses, requirements, specifiers, utils +from . import filenames, licenses, requirements, specifiers from . import version as version_module from .errors import ExceptionGroup, _ErrorCollector @@ -632,8 +632,8 @@ def _process_name(self, value: str) -> str: raise self._invalid_metadata(f"{self.raw_name!r} is a required field") # Validate the name as a side-effect. try: - utils.canonicalize_name(value, validate=True) - except utils.InvalidName as exc: + filenames.canonicalize_name(value, validate=True) + except filenames.InvalidName as exc: raise self._invalid_metadata( f"{value!r} is invalid for {self.raw_name!r}", cause=exc ) from exc @@ -722,8 +722,10 @@ def _process_provides_extra( normalized_names = [] try: for name in value: - normalized_names.append(utils.canonicalize_name(name, validate=True)) - except utils.InvalidName as exc: + normalized_names.append( + filenames.canonicalize_name(name, validate=True) + ) + except filenames.InvalidName as exc: raise self._invalid_metadata( f"{name!r} is invalid for {self.raw_name!r}", cause=exc ) from exc @@ -929,7 +931,7 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata: # the original/raw name. name: _Validator[str] = _Validator() """:external:ref:`core-metadata-name` - (required; validated using :func:`~packaging.utils.canonicalize_name` and its + (required; validated using :func:`~packaging.filenames.canonicalize_name` and its *validate* parameter)""" version: _Validator[Version] = _Validator() """:external:ref:`core-metadata-version` (required)""" diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 072b355bc..dac634e32 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -4,6 +4,8 @@ from __future__ import annotations +# The name and filename helpers moved to ``packaging.filenames``. They stay +# importable from here for compatibility, as do the tags and version names. from .filenames import ( BuildTag, InvalidName, @@ -16,14 +18,9 @@ canonicalize_version, is_normalized_name, ) - -# These were importable from this module before, so keep them for compatibility. from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag # noqa: F401, TC001 from .version import InvalidVersion, Version # noqa: F401, TC001 -# For historical reasons, we export the filename parsing functions from this -# module, even though they are implemented in the filenames module now. - __all__ = [ "BuildTag", "InvalidName", diff --git a/tests/test_filenames.py b/tests/test_filenames.py index bfbb6756f..e1bcaae3c 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -18,7 +18,7 @@ from packaging.version import Version if typing.TYPE_CHECKING: - from packaging.utils import BuildTag + from packaging.filenames import BuildTag @pytest.mark.parametrize( @@ -60,39 +60,39 @@ def test_sdist_not_strict_passes( [ ( "bad.extension", # Bad extension - "Invalid SDist filename (extension must be '.tar.gz')", + "Invalid sdist filename (extension must be '.tar.gz')", ), ( "extra-hyphens-1.0-9.tar.gz", # Extra hyphens - "Invalid SDist filename (name and version parts can not contain hyphens)", + "Invalid sdist filename (non-normalized project name 'extra-hyphens-1.0')", ), ( "no_hyphen.tar.gz", # No hyphen - "Invalid SDist filename (hyphen must separate name and version parts)", + "Invalid sdist filename (hyphen must separate name and version parts)", ), ( ".invalid.name-1.0.tar.gz", # Name is not valid - "Invalid SDist filename (invalid project name '.invalid.name')", + "Invalid sdist filename (invalid project name '.invalid.name')", ), ( "invalid.name-1.0.tar.gz", # Name is not canonical (punctuation) - "Invalid SDist filename (non-normalized project name 'invalid.name')", + "Invalid sdist filename (non-normalized project name 'invalid.name')", ), ( "invalid__name-1.0.tar.gz", # Name is not canonical (punctuation) - "Invalid SDist filename (non-normalized project name 'invalid__name')", + "Invalid sdist filename (non-normalized project name 'invalid__name')", ), ( "INVALID_NAME-1.0.tar.gz", # Name is not canonical (casing) - "Invalid SDist filename (non-normalized project name 'INVALID_NAME')", + "Invalid sdist filename (non-normalized project name 'INVALID_NAME')", ), ( "valid_name-badversion.tar.gz", # Version is not valid - "Invalid SDist filename (invalid version 'badversion')", + "Invalid sdist filename (invalid version 'badversion')", ), ( "valid_name-01.0.tar.gz", # Version is not canonical - "Invalid SDist filename (non-normalized version '01.0')", + "Invalid sdist filename (non-normalized version '01.0')", ), ], ) @@ -163,6 +163,7 @@ def test_wheel_from_filename( assert fn.build_tag == build_tag assert fn.tags == tags assert fn.variant is None + assert fn.to_filename() == filename @pytest.mark.parametrize( @@ -219,15 +220,15 @@ def test_wheel_from_filename_variant( ), ( "foo__bar-1.0-py3-none-any.whl", # Invalid name (`__`) - "Invalid wheel filename (invalid project name: 'foo__bar')", + "Invalid wheel filename (invalid project name 'foo__bar')", ), ( "foo#bar-1.0-py3-none-any.whl", # Invalid name (`#`) - "Invalid wheel filename (invalid project name: 'foo#bar')", + "Invalid wheel filename (invalid project name 'foo#bar')", ), ( "foobar-1.x-py3-none-any.whl", # Invalid version (`1.x`) - "Invalid wheel filename (invalid version: '1.x')", + "Invalid wheel filename (invalid version '1.x')", ), ( # Too many dashes (`-junk-more`) @@ -236,19 +237,19 @@ def test_wheel_from_filename_variant( ), ( "foo-1.0-abc-py3-none-any-x86.whl", # Build number without a digit - "Invalid wheel filename (invalid build number: 'abc')", + "Invalid wheel filename (invalid build number 'abc')", ), ( "foo-1.0-py3-none-any-X86.whl", # Upper case variant label - "Invalid wheel filename (invalid variant label: 'X86')", + "Invalid wheel filename (invalid variant label 'X86')", ), ( "foo-1.0-1-py3-none-any-abcdefghijklmnopq.whl", # Label too long - "Invalid wheel filename (invalid variant label: 'abcdefghijklmnopq')", + "Invalid wheel filename (invalid variant label 'abcdefghijklmnopq')", ), ( "foo-1.0-1-py3-none-any-a+b.whl", # Invalid character in label - "Invalid wheel filename (invalid variant label: 'a+b')", + "Invalid wheel filename (invalid variant label 'a+b')", ), ( "fOo-1.0-py3-none-any.whl", # Non-normalized project name @@ -272,13 +273,18 @@ def test_wheel_from_filename_variant( ), ( # Unsorted interpreter tags (py3 before py2) "foo-1.0-py3.py2-none-any.whl", - "Invalid wheel filename (non-normalized tags 'py3.py2-none-any')", + "Invalid wheel filename (compressed tag set components must be in " + "sorted order per PEP 425)", ), ( # Unsorted platform tags (manylinux_ before manylinux2014) "numpy-1.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", - "Invalid wheel filename (non-normalized tags " - "'cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64')", + "Invalid wheel filename (compressed tag set components must be in " + "sorted order per PEP 425)", + ), + ( # Duplicate interpreter tags + "foo-1.0-py3.py3-none-any.whl", + "Invalid wheel filename (non-normalized tags 'py3.py3-none-any')", ), ], ) @@ -297,12 +303,11 @@ def test_wheel_from_filename_invalid(filename: str, error_message: str) -> None: "1.0", "valid_name-1.0-py3-none-any.whl", ), - # Historically, this is not allowed - # ( - # "valid__name", # Name is not canonical (punctuation) - # "1.0", - # "valid_name-1.0-py3-none-any.whl", - # ), + ( + "valid__name", # Name is not canonical (punctuation) + "1.0", + "valid_name-1.0-py3-none-any.whl", + ), ( "VALID_NAME", # Name is not canonical (casing) "1.0", @@ -327,49 +332,26 @@ def test_wheel_not_strict_passes( @pytest.mark.parametrize( ("filename", "name", "version", "build", "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"), + "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_bar-1.0-42-py2.py3-none-any.whl", "foo-bar", - Version("1.0"), + "01.0", (42, ""), {Tag("py2", "none", "any"), Tag("py3", "none", "any")}, ), ], ) def test_compose_wheel_filename( - filename: str, name: str, version: Version, build: BuildTag | None, tags: set[Tag] + filename: str, name: str, version: str, build: BuildTag, tags: set[Tag] ) -> None: - assert ( - WheelFilename(name, str(version), build or (), tags).to_filename() == filename - ) + assert WheelFilename(name, version, build, tags).to_filename() == filename def test_parse_and_create_filename() -> None: @@ -405,13 +387,12 @@ def test_wheel_version_property_invalid() -> None: def test_wheel_repr() -> None: - wf = WheelFilename("foo", "1.0", (1, "abc"), {Tag("py3", "none", "any")}) - assert "WheelFilename" in repr(wf) - assert "name='foo'" in repr(wf) - assert "version='1.0'" in repr(wf) - assert "build_tag=(1, 'abc')" in repr(wf) - assert "tags=" in repr(wf) - assert "variant=None" in repr(wf) + tags = frozenset({Tag("py3", "none", "any")}) + wf = WheelFilename("foo", "1.0", (1, "abc"), tags) + assert repr(wf) == ( + f"WheelFilename(name='foo', version='1.0', build_tag=(1, 'abc'), " + f"tags={tags!r}, variant=None)" + ) def test_sdist_version_property_invalid() -> None: @@ -430,7 +411,7 @@ def test_sdist_from_filename_invalid_extension_not_strict() -> None: SourceDistributionFilename.from_filename("foo-1.0.tgz", strict=False) assert str(e.value) == ( - "Invalid SDist filename (extension must be '.tar.gz' or '.zip'): 'foo-1.0.tgz'" + "Invalid sdist filename (extension must be '.tar.gz' or '.zip'): 'foo-1.0.tgz'" ) @@ -473,3 +454,4 @@ def test_sdist_eq_hash() -> None: assert hash(fn) == hash(SourceDistributionFilename("foo", "1.0")) assert fn != SourceDistributionFilename("foo", "1.0.0") assert fn != "foo-1.0.tar.gz" + assert fn != WheelFilename("foo", "1.0") From c89e0a20f836ded7c76bef56689e6b208d2f1434 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 18:16:15 -0400 Subject: [PATCH 22/53] docs: note on updating Assisted-by: ClaudeCode:claude-opus-5.5 Signed-off-by: Henry Schreiner --- docs/filenames.rst | 32 ++++++++++++++++++++++++++++++++ src/packaging/filenames.py | 8 ++++---- src/packaging/utils.py | 2 +- tests/test_filenames.py | 2 +- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/filenames.rst b/docs/filenames.rst index 070ba2eac..b01d924c5 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -5,6 +5,38 @@ Tools to work with filenames for SDists and wheels. .. versionadded:: 26.4 +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", strict=False + ... ) + >>> name, version, build_tag, tags = ( + ... wheel.name, wheel.version, wheel.build_tag, wheel.tags + ... ) + >>> wheel.variant is None + True + +Be sure to check and handle :attr:`~packaging.filenames.WheelFilename.variant`. +``validate_order=True`` will throw an error if the tag order is not sorted, +just like the old function. The new ``strict=True`` parameter will require +a normalized filename. + Reference --------- diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 9a4b175c5..d7984f4c2 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -350,7 +350,7 @@ def __init__( :param version: The version string (in original form). :param build_tag: Optional wheel build tag. :param tags: The wheel tag set. It must not be empty to make a filename. - :param variant: The variant label (see :pep:`817`), or ``None``. + :param variant: The variant label (see :pep:`825`), or ``None``. """ super().__init__(name, version) self._build_tag = build_tag @@ -369,7 +369,7 @@ def tags(self) -> frozenset[Tag]: @property def variant(self) -> str | None: - """The variant label (see :pep:`817`), or ``None``.""" + """The variant label (see :pep:`825`), or ``None``.""" return self._variant @property @@ -461,7 +461,7 @@ def from_filename( 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:`817`). + label (see :pep:`825`). If **strict** is true, the name, version, build tag, and tags must be in their normalized form, which includes sorted tag set components. If @@ -497,7 +497,7 @@ def from_filename( name, version_part, *rest = parts - # Six parts are ambiguous: a build tag or a variant label (PEP 817). + # 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. build_match = _build_tag_regex.match(rest[0]) if len(rest) > 3 else None if len(rest) == 5 and build_match is None: diff --git a/src/packaging/utils.py b/src/packaging/utils.py index dac634e32..81ab1fa9e 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -66,7 +66,7 @@ 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:`817` + `, or if it has a :pep:`825` variant label. >>> from packaging.utils import parse_wheel_filename diff --git a/tests/test_filenames.py b/tests/test_filenames.py index e1bcaae3c..f20293470 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -416,7 +416,7 @@ def test_sdist_from_filename_invalid_extension_not_strict() -> None: def test_wheel_from_filename_six_parts_non_digit_is_variant() -> None: - # PEP 817: a third part that does not start with a digit is a Python tag. + # PEP 825: a third part that does not start with a digit is a Python tag. fn = WheelFilename.from_filename("foo-1.0-abc-py3-none-any.whl", strict=True) assert fn.build_tag == () assert fn.tags == {Tag("abc", "py3", "none")} From 9128884f0858265bcaa226e9aa3ca130526c29e7 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 18:49:40 -0400 Subject: [PATCH 23/53] refactor(filenames): keep name helpers in packaging.utils The name helpers are not filename-specific, so they stay in utils as on main. packaging.filenames imports them, and the utils parse functions import packaging.filenames lazily to avoid a circular import. Assisted-by: ClaudeCode:claude-opus-5-5 --- docs/utils.rst | 4 +- src/packaging/filenames.py | 200 +++------------------------------- src/packaging/metadata.py | 19 ++-- src/packaging/utils.py | 212 ++++++++++++++++++++++++++++++++++--- tests/test_filenames.py | 8 ++ 5 files changed, 224 insertions(+), 219 deletions(-) diff --git a/docs/utils.rst b/docs/utils.rst index e3a25e5d3..25b53b801 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -1,9 +1,7 @@ Utilities ========= -A set of small, helper utilities for dealing with Python packages. Most of these -functions are now in the :mod:`packaging.filenames` module, and re-exported -here. +A set of small, helper utilities for dealing with Python packages. Reference --------- diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index d7984f4c2..4dc27c999 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -6,26 +6,31 @@ import abc import re -from typing import TYPE_CHECKING, ClassVar, NewType, cast +from typing import TYPE_CHECKING, ClassVar from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag -from .version import InvalidVersion, Version, _TrimmedRelease +from .utils import ( + BuildTag, + InvalidFilename, + InvalidName, + InvalidSdistFilename, + InvalidWheelFilename, + canonicalize_name, +) +from .version import InvalidVersion, Version if TYPE_CHECKING: from collections.abc import Iterable + from .utils import NormalizedName + __all__ = [ "BuildTag", "InvalidFilename", - "InvalidName", "InvalidSdistFilename", "InvalidWheelFilename", - "NormalizedName", "SourceDistributionFilename", "WheelFilename", - "canonicalize_name", - "canonicalize_version", - "is_normalized_name", ] @@ -33,58 +38,6 @@ def __dir__() -> list[str]: return __all__ -BuildTag = tuple[()] | tuple[int, str] -""" -A wheel build tag: an empty tuple, or a ``(build number, build tag suffix)`` pair. - -.. versionadded:: 20.9 -""" - -NormalizedName = NewType("NormalizedName", str) -""" -A :class:`typing.NewType` of :class:`str`, representing a normalized name. - -.. versionadded:: 20.4 -""" - - -class InvalidName(ValueError): - """ - An invalid distribution name; users should refer to the packaging user guide. - - .. versionadded:: 23.2 - """ - - -class 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. - - .. versionadded:: 20.9 - """ - - -class InvalidSdistFilename(InvalidFilename): - """ - An invalid sdist filename was found, users should refer to the packaging user guide. - - .. versionadded:: 20.9 - """ - - -# Core metadata spec for `Name` -_validate_regex = re.compile( - r"[a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9]", re.IGNORECASE | re.ASCII -) -_normalized_regex = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*", re.ASCII) # PEP 427: The build number must start with a digit. _build_tag_regex = re.compile(r"(\d+)(.*)", re.ASCII) _variant_label_regex = re.compile(r"[0-9a-z._]{1,16}", re.ASCII) @@ -93,135 +46,6 @@ class InvalidSdistFilename(InvalidFilename): _wheel_name_regex = re.compile(r"^[\w._]+\Z", re.UNICODE) -def canonicalize_name( - name: str, *, validate: bool = False, underscore: bool = False -) -> NormalizedName: - """ - This function takes a valid Python package or extra name, and returns the - normalized form of it. - - The return type is typed as :class:`~packaging.filenames.NormalizedName`. - This allows type checkers to help require that a string has passed through - this function before use. - - If **validate** is true, then the function will check if **name** is a valid - distribution name before normalizing. If **underscore** is true, then hyphens - will be replaced with underscores instead of hyphens (such as for a filename). - - :param str name: The name to normalize. - :param bool validate: Check whether the name is a valid distribution name. - :param bool underscore: Replace hyphens with underscores instead of hyphens. - :raises InvalidName: If **validate** is true and the name is not an - acceptable distribution name. - - >>> from packaging.utils import canonicalize_name - >>> canonicalize_name("Django") - 'django' - >>> canonicalize_name("oslo.concurrency") - 'oslo-concurrency' - >>> canonicalize_name("oslo.concurrency", underscore=True) - 'oslo_concurrency' - >>> canonicalize_name("requests") - 'requests' - - .. versionadded:: 16.2 - - .. versionchanged:: 20.4 - The return type was changed to :class:`~packaging.filenames.NormalizedName`. - - .. versionchanged:: 23.2 - Added the *validate* keyword parameter. - - .. versionchanged:: 26.4 - Added the *underscore* keyword parameter. - """ - if validate and not _validate_regex.fullmatch(name): - raise InvalidName(f"name is invalid: {name!r}") - # 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("--", "-") - if underscore: - value = value.replace("-", "_") - return cast("NormalizedName", value) - - -def is_normalized_name(name: str) -> bool: - """ - Check if a name is a normalized project name (i.e. a valid name that - :func:`canonicalize_name` would roundtrip to the same value). - - The roundtrip only characterizes normalized names for *valid* names. A name - must start and end with an ASCII letter or digit, which - :func:`canonicalize_name` does not enforce: it leaves a leading or trailing - hyphen in place, so such a name roundtrips without being normalized. - - :param str name: The name to check. - - >>> from packaging.utils import canonicalize_name, is_normalized_name - >>> is_normalized_name("requests") - True - >>> is_normalized_name("Django") - False - >>> canonicalize_name("_not_legal") - '-not-legal' - >>> is_normalized_name("-not-legal") # roundtrips, but not a valid name - False - - .. versionadded:: 23.2 - """ - return _normalized_regex.fullmatch(name) is not None - - -def canonicalize_version( - version: Version | str, *, strip_trailing_zero: bool = True -) -> str: - """Return a canonical form of a version as a string. - - This function takes a string representing a package version (or a - :class:`~packaging.version.Version` instance), and returns the - normalized form of it. By default, it strips trailing zeros from - the release segment. - - >>> from packaging.utils import canonicalize_version - >>> canonicalize_version('1.0.1') - '1.0.1' - - Per PEP 625, versions may have multiple canonical forms, differing - only by trailing zeros. - - >>> canonicalize_version('1.0.0') - '1' - >>> canonicalize_version('1.0.0', strip_trailing_zero=False) - '1.0.0' - - Invalid versions are returned unaltered. - - >>> canonicalize_version('foo bar baz') - 'foo bar baz' - - >>> canonicalize_version('1.4.0.0.0') - '1.4' - - .. versionadded:: 17.1 - - .. versionchanged:: 21.0 - The return type was narrowed to :class:`str`. - - .. versionchanged:: 22.0 - Added the *strip_trailing_zero* keyword parameter. - """ - if isinstance(version, str): - try: - version = Version(version) - except InvalidVersion: - return str(version) - return str(_TrimmedRelease(version) if strip_trailing_zero else version) - - def _compress_tag_set(tags: frozenset[Tag]) -> str: if not tags: msg = "Invalid wheel filename (the tag set must have at least one tag)" diff --git a/src/packaging/metadata.py b/src/packaging/metadata.py index 0c5a87e06..c13936614 100644 --- a/src/packaging/metadata.py +++ b/src/packaging/metadata.py @@ -16,14 +16,13 @@ cast, ) -from . import filenames, licenses, requirements, specifiers +from . import licenses, requirements, specifiers, utils from . import version as version_module from .errors import ExceptionGroup, _ErrorCollector if typing.TYPE_CHECKING: from collections.abc import Callable - from .filenames import NormalizedName from .licenses import NormalizedLicenseExpression from .version import Version @@ -632,8 +631,8 @@ def _process_name(self, value: str) -> str: raise self._invalid_metadata(f"{self.raw_name!r} is a required field") # Validate the name as a side-effect. try: - filenames.canonicalize_name(value, validate=True) - except filenames.InvalidName as exc: + utils.canonicalize_name(value, validate=True) + except utils.InvalidName as exc: raise self._invalid_metadata( f"{value!r} is invalid for {self.raw_name!r}", cause=exc ) from exc @@ -718,14 +717,12 @@ def _process_dynamic(self, value: list[str]) -> list[str]: def _process_provides_extra( self, value: list[str], - ) -> list[NormalizedName]: + ) -> list[utils.NormalizedName]: normalized_names = [] try: for name in value: - normalized_names.append( - filenames.canonicalize_name(name, validate=True) - ) - except filenames.InvalidName as exc: + normalized_names.append(utils.canonicalize_name(name, validate=True)) + except utils.InvalidName as exc: raise self._invalid_metadata( f"{name!r} is invalid for {self.raw_name!r}", cause=exc ) from exc @@ -931,7 +928,7 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata: # the original/raw name. name: _Validator[str] = _Validator() """:external:ref:`core-metadata-name` - (required; validated using :func:`~packaging.filenames.canonicalize_name` and its + (required; validated using :func:`~packaging.utils.canonicalize_name` and its *validate* parameter)""" version: _Validator[Version] = _Validator() """:external:ref:`core-metadata-version` (required)""" @@ -996,7 +993,7 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata: """:external:ref:`core-metadata-project-url`""" # PEP 685 lets us raise an error if an extra doesn't pass `Name` validation # regardless of metadata version. - provides_extra: _Validator[list[NormalizedName] | None] = _Validator( + provides_extra: _Validator[list[utils.NormalizedName] | None] = _Validator( added="2.1", ) """:external:ref:`core-metadata-provides-extra`""" diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 81ab1fa9e..fb555c576 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -4,25 +4,16 @@ from __future__ import annotations -# The name and filename helpers moved to ``packaging.filenames``. They stay -# importable from here for compatibility, as do the tags and version names. -from .filenames import ( - BuildTag, - InvalidName, - InvalidSdistFilename, - InvalidWheelFilename, - NormalizedName, - SourceDistributionFilename, - WheelFilename, - canonicalize_name, - canonicalize_version, - is_normalized_name, -) +import re +from typing import NewType, cast + +# The tags names are unused, but stay importable from here for compatibility. from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag # noqa: F401, TC001 -from .version import InvalidVersion, Version # noqa: F401, TC001 +from .version import InvalidVersion, Version, _TrimmedRelease __all__ = [ "BuildTag", + "InvalidFilename", "InvalidName", "InvalidSdistFilename", "InvalidWheelFilename", @@ -39,6 +30,189 @@ def __dir__() -> list[str]: return __all__ +BuildTag = tuple[()] | tuple[int, str] +""" +A wheel build tag: an empty tuple, or a ``(build number, build tag suffix)`` pair. + +.. versionadded:: 20.9 +""" + +NormalizedName = NewType("NormalizedName", str) +""" +A :class:`typing.NewType` of :class:`str`, representing a normalized name. + +.. versionadded:: 20.4 +""" + + +class InvalidName(ValueError): + """ + An invalid distribution name; users should refer to the packaging user guide. + + .. versionadded:: 23.2 + """ + + +class 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. + + .. versionadded:: 20.9 + """ + + +class InvalidSdistFilename(InvalidFilename): + """ + An invalid sdist filename was found, users should refer to the packaging user guide. + + .. versionadded:: 20.9 + """ + + +# Core metadata spec for `Name` +_validate_regex = re.compile( + r"[a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9]", re.IGNORECASE | re.ASCII +) +_normalized_regex = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*", re.ASCII) + + +def canonicalize_name( + name: str, *, validate: bool = False, underscore: bool = False +) -> NormalizedName: + """ + This function takes a valid Python package or extra name, and returns the + normalized form of it. + + The return type is typed as :class:`NormalizedName`. This allows type + checkers to help require that a string has passed through this function + before use. + + If **validate** is true, then the function will check if **name** is a valid + distribution name before normalizing. If **underscore** is true, then hyphens + will be replaced with underscores instead of hyphens (such as for a filename). + + :param str name: The name to normalize. + :param bool validate: Check whether the name is a valid distribution name. + :param bool underscore: Replace hyphens with underscores instead of hyphens. + :raises InvalidName: If **validate** is true and the name is not an + acceptable distribution name. + + >>> from packaging.utils import canonicalize_name + >>> canonicalize_name("Django") + 'django' + >>> canonicalize_name("oslo.concurrency") + 'oslo-concurrency' + >>> canonicalize_name("oslo.concurrency", underscore=True) + 'oslo_concurrency' + >>> canonicalize_name("requests") + 'requests' + + .. versionadded:: 16.2 + + .. versionchanged:: 20.4 + The return type was changed to :class:`NormalizedName`. + + .. versionchanged:: 23.2 + Added the *validate* keyword parameter. + + .. versionchanged:: 26.4 + Added the *underscore* keyword parameter. + """ + if validate and not _validate_regex.fullmatch(name): + raise InvalidName(f"name is invalid: {name!r}") + # 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("--", "-") + if underscore: + value = value.replace("-", "_") + return cast("NormalizedName", value) + + +def is_normalized_name(name: str) -> bool: + """ + Check if a name is a normalized project name (i.e. a valid name that + :func:`canonicalize_name` would roundtrip to the same value). + + The roundtrip only characterizes normalized names for *valid* names. A name + must start and end with an ASCII letter or digit, which + :func:`canonicalize_name` does not enforce: it leaves a leading or trailing + hyphen in place, so such a name roundtrips without being normalized. + + :param str name: The name to check. + + >>> from packaging.utils import canonicalize_name, is_normalized_name + >>> is_normalized_name("requests") + True + >>> is_normalized_name("Django") + False + >>> canonicalize_name("_not_legal") + '-not-legal' + >>> is_normalized_name("-not-legal") # roundtrips, but not a valid name + False + + .. versionadded:: 23.2 + """ + return _normalized_regex.fullmatch(name) is not None + + +def canonicalize_version( + version: Version | str, *, strip_trailing_zero: bool = True +) -> str: + """Return a canonical form of a version as a string. + + This function takes a string representing a package version (or a + :class:`~packaging.version.Version` instance), and returns the + normalized form of it. By default, it strips trailing zeros from + the release segment. + + >>> from packaging.utils import canonicalize_version + >>> canonicalize_version('1.0.1') + '1.0.1' + + Per PEP 625, versions may have multiple canonical forms, differing + only by trailing zeros. + + >>> canonicalize_version('1.0.0') + '1' + >>> canonicalize_version('1.0.0', strip_trailing_zero=False) + '1.0.0' + + Invalid versions are returned unaltered. + + >>> canonicalize_version('foo bar baz') + 'foo bar baz' + + >>> canonicalize_version('1.4.0.0.0') + '1.4' + + .. versionadded:: 17.1 + + .. versionchanged:: 21.0 + The return type was narrowed to :class:`str`. + + .. versionchanged:: 22.0 + Added the *strip_trailing_zero* keyword parameter. + """ + if isinstance(version, str): + try: + version = Version(version) + except InvalidVersion: + return str(version) + return str(_TrimmedRelease(version) if strip_trailing_zero else version) + + def parse_wheel_filename( filename: str, *, @@ -49,8 +223,8 @@ def parse_wheel_filename( returning a tuple of name, version, build number, and tags. The name part of the tuple is normalized and typed as - :class:`~packaging.filenames.NormalizedName`. The version portion is an - instance of :class:`~packaging.version.Version`. The build number is ``()`` if + :class:`NormalizedName`. The version portion is an instance of + :class:`~packaging.version.Version`. The build number is ``()`` if there is no build number in the wheel filename, otherwise a two-item tuple of an integer for the leading digits and a string for the rest of the build number. The tags portion is a @@ -95,6 +269,8 @@ def parse_wheel_filename( not an identifier, a tag set component is empty, or the project name is empty. """ + from .filenames import WheelFilename # noqa: PLC0415 + fname = WheelFilename.from_filename( filename, strict=False, validate_order=validate_order ) @@ -147,5 +323,7 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name """ + from .filenames import SourceDistributionFilename # noqa: PLC0415 + fname = SourceDistributionFilename.from_filename(filename, strict=False) return (fname.name, fname.version) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index f20293470..e74158642 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -4,6 +4,8 @@ from __future__ import annotations +import subprocess +import sys import typing import pytest @@ -455,3 +457,9 @@ def test_sdist_eq_hash() -> None: assert fn != SourceDistributionFilename("foo", "1.0.0") assert fn != "foo-1.0.tar.gz" assert fn != WheelFilename("foo", "1.0") + + +@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) From 8814c0bf7880753a0923651f52f84b052e89060d Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 18:54:00 -0400 Subject: [PATCH 24/53] fix(filenames): compare on normalized name and version Two filename objects that produce the same filename are now equal, and hash the same. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 20 +++++++++----------- tests/test_filenames.py | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 4dc27c999..dccc7ea65 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -125,7 +125,11 @@ def version(self) -> Version: return self._version def _key(self) -> tuple[object, ...]: - return (self._original_name, self._original_version) + try: + version = str(self.version) + except InvalidFilename: + version = self._original_version + return (self.name, version) def __eq__(self, other: object) -> bool: if not isinstance(other, _DistributionFilename): @@ -148,8 +152,8 @@ class WheelFilename(_DistributionFilename): Instances preserve the original name and version strings for round-tripping, while exposing normalized and validated views through properties. - Instances are immutable and hashable. Two instances are equal if all of their - original components are equal. + Instances are immutable and hashable. Two instances are equal if their + normalized name, normalized version, build tag, tags, and variant are equal. .. versionadded:: 26.4 """ @@ -230,13 +234,7 @@ def build_str(self) -> str: return "".join(map(str, self._build_tag)) def _key(self) -> tuple[object, ...]: - return ( - self._original_name, - self._original_version, - self._build_tag, - self._tags, - self._variant, - ) + return (*super()._key(), self._build_tag, self._tags, self._variant) def __repr__(self) -> str: return ( @@ -376,7 +374,7 @@ class SourceDistributionFilename(_DistributionFilename): while exposing normalized and validated views through properties. Instances are immutable and hashable. Two instances are equal if their - original name and version are equal. + normalized name and normalized version are equal. .. versionadded:: 26.4 """ diff --git a/tests/test_filenames.py b/tests/test_filenames.py index e74158642..45842d76a 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -443,11 +443,15 @@ def test_wheel_eq_hash() -> None: wf = WheelFilename("foo", "1.0", (1, ""), tags) assert wf == WheelFilename("foo", "1.0", (1, ""), tags) assert hash(wf) == hash(WheelFilename("foo", "1.0", (1, ""), tags)) - assert wf != WheelFilename("Foo", "1.0", (1, ""), tags) + assert wf == WheelFilename("Foo", "1.0", (1, ""), tags) + assert hash(wf) == hash(WheelFilename("Foo", "1.0", (1, ""), tags)) + assert wf != WheelFilename("foo", "1.0.0", (1, ""), tags) assert wf != WheelFilename("foo", "1.0", (), tags) assert wf != WheelFilename("foo", "1.0", (1, ""), tags, "x86_64_v3") assert wf != "foo-1.0-1-py3-none-any.whl" assert len({wf, WheelFilename.from_filename(str(wf), strict=True)}) == 1 + raw = WheelFilename.from_filename("Foo.Bar-01.0-1-py3-none-any.whl", strict=False) + assert raw == WheelFilename("foo_bar", "1.0", (1, ""), tags) def test_sdist_eq_hash() -> None: @@ -455,6 +459,16 @@ def test_sdist_eq_hash() -> None: 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", strict=False) + + +def test_eq_invalid_version() -> None: + fn = SourceDistributionFilename("foo", "bad") + assert fn == SourceDistributionFilename("Foo", "bad") + assert hash(fn) == hash(SourceDistributionFilename("Foo", "bad")) + assert fn != SourceDistributionFilename("foo", "1.0") assert fn != "foo-1.0.tar.gz" assert fn != WheelFilename("foo", "1.0") From a0f2e73ec9ba9daf0cb16f5ba1245d703d2cc3c8 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 19:02:38 -0400 Subject: [PATCH 25/53] feat(filenames): validate in the constructor and add __replace__ The constructors now check the name, version, build tag, and variant label, so to_filename() can no longer produce a filename that does not parse. Non-strict from_filename still accepts legacy names. __replace__ (used by copy.replace) checks only the replaced parts. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 175 +++++++++++++++++++++++++++++-------- tests/test_filenames.py | 90 +++++++++++++++---- 2 files changed, 214 insertions(+), 51 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index dccc7ea65..f2a4ad5d3 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -6,7 +6,7 @@ import abc import re -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, TypedDict from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag from .utils import ( @@ -20,7 +20,9 @@ from .version import InvalidVersion, Version if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Mapping + + from typing_extensions import Self, Unpack from .utils import NormalizedName @@ -40,12 +42,33 @@ def __dir__() -> list[str]: # PEP 427: The build number must start with a digit. _build_tag_regex = re.compile(r"(\d+)(.*)", re.ASCII) +# A build tag suffix must not start with a digit, so the number is unambiguous. +_build_suffix_regex = re.compile(r"(?:[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._]+\Z", re.UNICODE) +class _SdistReplace(TypedDict, total=False): + name: str + version: str + + +class _WheelReplace(TypedDict, total=False): + name: str + version: str + build_tag: BuildTag + tags: Iterable[Tag] + variant: str | None + + +def _check_replace_keys(kwargs: Mapping[str, object], allowed: frozenset[str]) -> None: + if extra := kwargs.keys() - allowed: + msg = f"__replace__() got unexpected keyword arguments: {sorted(extra)}" + raise TypeError(msg) + + def _compress_tag_set(tags: frozenset[Tag]) -> str: if not tags: msg = "Invalid wheel filename (the tag set must have at least one tag)" @@ -70,9 +93,39 @@ class _DistributionFilename(abc.ABC): _kind: ClassVar[str] def __init__(self, name: str, version: str) -> None: + self._check_name(name) + self._set_base(name, version, self._parse_version(version)) + + def _set_base(self, name: str, version_str: str, version: Version) -> None: self._original_name = name - self._original_version = version - self._version: Version | None = None + self._original_version = version_str + self._version = version + + def _replace_base(self, new: Self, kwargs: _SdistReplace | _WheelReplace) -> None: + """Set the name and version on **new**, checking only the changed values.""" + name = self._original_name + if "name" in kwargs: + name = kwargs["name"] + self._check_name(name) + if "version" in kwargs: + version_str = kwargs["version"] + new._set_base(name, version_str, self._parse_version(version_str)) + else: + new._set_base(name, self._original_version, self._version) + + @classmethod + def _check_name(cls, name: str) -> None: + try: + canonicalize_name(name, validate=True) + except InvalidName: + raise cls._invalid(f"invalid project name {name!r}") from None + + @classmethod + def _parse_version(cls, version: str, filename: str | None = None) -> Version: + try: + return Version(version) + except InvalidVersion as e: + raise cls._invalid(f"invalid version {version!r}", filename) from e @classmethod def _invalid(cls, inner: str, filename: str | None = None) -> InvalidFilename: @@ -112,24 +165,11 @@ def name(self) -> NormalizedName: @property def version(self) -> Version: - """The parsed project version. - - :raises InvalidFilename: If the stored version is not valid. - """ - if self._version is None: - try: - self._version = Version(self._original_version) - except InvalidVersion as e: - inner = f"invalid version {self._original_version!r}" - raise self._invalid(inner) from e + """The parsed project version.""" return self._version def _key(self) -> tuple[object, ...]: - try: - version = str(self.version) - except InvalidFilename: - version = self._original_version - return (self.name, version) + return (self.name, str(self._version)) def __eq__(self, other: object) -> bool: if not isinstance(other, _DistributionFilename): @@ -179,12 +219,60 @@ def __init__( :param build_tag: Optional wheel build tag. :param tags: The wheel tag set. It must not be empty to make a filename. :param variant: The variant label (see :pep:`825`), or ``None``. + :raises InvalidWheelFilename: If the name, version, build tag, or + variant label is not valid. """ super().__init__(name, version) + self._check_build_tag(build_tag) + self._check_variant(variant) + self._set_wheel(build_tag, tags, variant) + + def _set_wheel( + self, build_tag: BuildTag, tags: Iterable[Tag], variant: str | None + ) -> None: self._build_tag = build_tag self._tags = frozenset(tags) self._variant = variant + @classmethod + def _check_build_tag(cls, build_tag: BuildTag) -> None: + if build_tag and ( + build_tag[0] < 0 or _build_suffix_regex.fullmatch(build_tag[1]) is None + ): + raise cls._invalid(f"invalid build tag {build_tag!r}") + + @classmethod + def _check_variant(cls, variant: str | None, filename: str | None = None) -> None: + if variant is not None and _variant_label_regex.fullmatch(variant) is None: + raise cls._invalid(f"invalid variant label {variant!r}", filename) + + def __replace__(self, **kwargs: Unpack[_WheelReplace]) -> Self: + """ + __replace__(*, name=..., version=..., build_tag=..., tags=..., 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", tags={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. + """ + _check_replace_keys(kwargs, _WheelReplace.__optional_keys__) + build_tag = kwargs.get("build_tag", self._build_tag) + if "build_tag" in kwargs: + self._check_build_tag(build_tag) + variant = kwargs.get("variant", self._variant) + if "variant" in kwargs: + self._check_variant(variant) + new = self.__class__.__new__(self.__class__) + self._replace_base(new, kwargs) + new._set_wheel(build_tag, kwargs.get("tags", self._tags), variant) + return new + @property def build_tag(self) -> BuildTag: """The build tag, or an empty tuple when absent.""" @@ -254,8 +342,8 @@ def to_filename(self) -> str: characters are replaced with ``_``. The version is normalized. The tag set is compressed into a wheel tag string. - :raises InvalidWheelFilename: If the version is not valid, or if the tag - set cannot be compressed (see :attr:`compressed_tags`). + :raises InvalidWheelFilename: If the tag set cannot be compressed (see + :attr:`compressed_tags`). >>> from packaging.filenames import WheelFilename >>> from packaging.tags import Tag @@ -340,20 +428,18 @@ def from_filename( if "__" in name or _wheel_name_regex.match(name) is None: raise cls._invalid(f"invalid project name {name!r}", filename) - try: - version = Version(version_part) - except InvalidVersion as e: - raise cls._invalid(f"invalid version {version_part!r}", filename) from e + version = cls._parse_version(version_part, filename) build_tag: BuildTag = () if build_match is not None: build_tag = (int(build_match.group(1)), build_match.group(2)) - if variant is not None and _variant_label_regex.fullmatch(variant) is None: - raise cls._invalid(f"invalid variant label {variant!r}", filename) + cls._check_variant(variant, filename) - self = cls(name, version_part, build_tag, tags, variant) - self._version = version + # Non-strict parsing accepts legacy names, so skip the constructor checks. + self = cls.__new__(cls) + self._set_base(name, version_part, version) + self._set_wheel(build_tag, tags, variant) # Reconstruct the filename and check that it matches the original if strict: @@ -390,9 +476,29 @@ def __init__(self, name: str, version: str) -> None: :param str name: The project name (in original, filename-style form). :param str version: The version string (in original form). + :raises InvalidSdistFilename: If the name or version is not valid. """ super().__init__(name, version) + 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. + """ + _check_replace_keys(kwargs, _SdistReplace.__optional_keys__) + new = self.__class__.__new__(self.__class__) + self._replace_base(new, kwargs) + return new + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(name={self._original_name!r}, " @@ -406,7 +512,6 @@ def to_filename(self) -> str: characters are replaced with ``_`` and characters are lower cased. The version is normalized. - :raises InvalidSdistFilename: If the version is not valid. >>> from packaging.filenames import SourceDistributionFilename >>> SourceDistributionFilename("foo-bar", "1.0").to_filename() @@ -462,14 +567,12 @@ def from_filename( if not name_part: raise cls._invalid("empty project name", filename) - try: - version = Version(version_part) - except InvalidVersion as e: - raise cls._invalid(f"invalid version {version_part!r}", filename) from e + version = cls._parse_version(version_part, filename) if strict: cls._check_normalized(name_part, version_part, version, filename) - self = cls(name_part, version_part) - self._version = version + # Non-strict parsing accepts legacy names, so skip the constructor checks. + self = cls.__new__(cls) + self._set_base(name_part, version_part, version) return self diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 45842d76a..529df3e2d 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -12,6 +12,7 @@ from packaging.filenames import ( InvalidFilename, + InvalidSdistFilename, InvalidWheelFilename, SourceDistributionFilename, WheelFilename, @@ -382,10 +383,55 @@ def test_sdist_from_filename_strict_valid() -> None: assert fn.version == Version("1.0") -def test_wheel_version_property_invalid() -> None: - wf = WheelFilename("foo", "not-a-version", (), {Tag("py3", "none", "any")}) - with pytest.raises(InvalidWheelFilename, match="invalid version"): - _ = wf.version +@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')"), + ({"variant": ""}, "invalid variant label ''"), + ({"variant": "Bad-Label"}, "invalid variant label 'Bad-Label'"), + ({"variant": "x" * 17}, f"invalid variant label {'x' * 17!r}"), + ], +) +def test_wheel_init_invalid(kwargs: dict[str, typing.Any], error_message: str) -> None: + args: dict[str, typing.Any] = {"name": "foo", "version": "1.0", **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={Tag("py3", "none", "any")}) + with pytest.raises(InvalidWheelFilename) as e: + wf.__replace__(**kwargs) + assert str(e.value) == f"Invalid wheel filename ({error_message})" + + +def test_wheel_replace() -> None: + tags = {Tag("py3", "none", "any")} + wf = WheelFilename("Foo", "1.0", (1, "a"), tags, "x86_64_v3") + new = wf.__replace__(version="2.0", tags={Tag("cp314", "cp314", "win_amd64")}) + assert new == WheelFilename( + "Foo", "2.0", (1, "a"), {Tag("cp314", "cp314", "win_amd64")}, "x86_64_v3" + ) + assert new.original_name == "Foo" + 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: + # Non-strict parsing accepts names that the constructor rejects. + wf = WheelFilename.from_filename("_foo-1.0-py3-none-any.whl", strict=False) + assert wf.original_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", strict=False) + assert fn.__replace__(version="2.0").to_filename() == "_foo-2.0.tar.gz" def test_wheel_repr() -> None: @@ -397,10 +443,31 @@ def test_wheel_repr() -> None: ) -def test_sdist_version_property_invalid() -> None: - fn = SourceDistributionFilename("foo", "not-a-version") - with pytest.raises(InvalidFilename, match="invalid version"): - _ = fn.version +@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") + with pytest.raises(TypeError, match="unexpected"): + fn.__replace__(tags=()) # type: ignore[call-arg] def test_sdist_repr() -> None: @@ -462,13 +529,6 @@ def test_sdist_eq_hash() -> None: assert fn == SourceDistributionFilename("Foo", "01.0") assert hash(fn) == hash(SourceDistributionFilename("Foo", "01.0")) assert fn == SourceDistributionFilename.from_filename("foo-1.0.zip", strict=False) - - -def test_eq_invalid_version() -> None: - fn = SourceDistributionFilename("foo", "bad") - assert fn == SourceDistributionFilename("Foo", "bad") - assert hash(fn) == hash(SourceDistributionFilename("Foo", "bad")) - assert fn != SourceDistributionFilename("foo", "1.0") assert fn != "foo-1.0.tar.gz" assert fn != WheelFilename("foo", "1.0") From 107c3f856192bea6d7c6abcc36d8d377c9f9017a Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 19:46:10 -0400 Subject: [PATCH 26/53] refactor(filenames): drop the underscore parameter of canonicalize_name The result was typed as NormalizedName but was not one. The filename code now replaces the hyphens itself. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 6 +++--- src/packaging/utils.py | 15 ++------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index f2a4ad5d3..cefe83817 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -140,7 +140,7 @@ def _check_normalized( ) -> None: """Check that the name and version parts are in their normalized form.""" try: - cname = canonicalize_name(name, validate=True, underscore=True) + cname = canonicalize_name(name, validate=True).replace("-", "_") except InvalidName: raise cls._invalid(f"invalid project name {name!r}", filename) from None if name != cname: @@ -351,7 +351,7 @@ def to_filename(self) -> str: >>> WheelFilename("foo-bar", "1.0", (), tags).to_filename() 'foo_bar-1.0-py3-none-any.whl' """ - name = canonicalize_name(self._original_name, underscore=True) + name = canonicalize_name(self._original_name).replace("-", "_") file_parts = [name, str(self.version)] if self._build_tag: file_parts.append(self.build_str) @@ -517,7 +517,7 @@ def to_filename(self) -> str: >>> SourceDistributionFilename("foo-bar", "1.0").to_filename() 'foo_bar-1.0.tar.gz' """ - name = canonicalize_name(self._original_name, underscore=True) + name = canonicalize_name(self._original_name).replace("-", "_") return f"{name}-{self.version}.tar.gz" @classmethod diff --git a/src/packaging/utils.py b/src/packaging/utils.py index fb555c576..f4fdad35b 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -84,9 +84,7 @@ class InvalidSdistFilename(InvalidFilename): _normalized_regex = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*", re.ASCII) -def canonicalize_name( - name: str, *, validate: bool = False, underscore: bool = False -) -> NormalizedName: +def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName: """ This function takes a valid Python package or extra name, and returns the normalized form of it. @@ -96,12 +94,10 @@ def canonicalize_name( before use. If **validate** is true, then the function will check if **name** is a valid - distribution name before normalizing. If **underscore** is true, then hyphens - will be replaced with underscores instead of hyphens (such as for a filename). + distribution name before normalizing. :param str name: The name to normalize. :param bool validate: Check whether the name is a valid distribution name. - :param bool underscore: Replace hyphens with underscores instead of hyphens. :raises InvalidName: If **validate** is true and the name is not an acceptable distribution name. @@ -110,8 +106,6 @@ def canonicalize_name( 'django' >>> canonicalize_name("oslo.concurrency") 'oslo-concurrency' - >>> canonicalize_name("oslo.concurrency", underscore=True) - 'oslo_concurrency' >>> canonicalize_name("requests") 'requests' @@ -122,9 +116,6 @@ def canonicalize_name( .. versionchanged:: 23.2 Added the *validate* keyword parameter. - - .. versionchanged:: 26.4 - Added the *underscore* keyword parameter. """ if validate and not _validate_regex.fullmatch(name): raise InvalidName(f"name is invalid: {name!r}") @@ -135,8 +126,6 @@ def canonicalize_name( # Condense repeats (faster than regex) while "--" in value: value = value.replace("--", "-") - if underscore: - value = value.replace("-", "_") return cast("NormalizedName", value) From b4290cd56dedbc6455dc81966b8454c8bb1750d8 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 19:56:29 -0400 Subject: [PATCH 27/53] chore: metaclass Signed-off-by: Henry Schreiner --- src/packaging/filenames.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index cefe83817..90b0e3d47 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -84,7 +84,7 @@ def _compress_tag_set(tags: frozenset[Tag]) -> str: return "-".join((".".join(interpreters), ".".join(abis), ".".join(platforms))) -class _DistributionFilename(abc.ABC): +class _DistributionFilename(metaclass=abc.ABCMeta): """Shared state and behavior for wheel and sdist filenames.""" __slots__ = ("_original_name", "_original_version", "_version") From 5df48ebce8ee32fb3595cedc6853a0d0dc5f31e5 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 20:01:03 -0400 Subject: [PATCH 28/53] refactor(filenames): store only the normalized name and version The original strings were only partly kept, and equality already ignored them. Remove original_name and original_version, and accept a Version in the constructor and in __replace__. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 92 +++++++++++++++----------------------- tests/test_filenames.py | 20 +++++---- 2 files changed, 48 insertions(+), 64 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 90b0e3d47..582734dd2 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -52,12 +52,12 @@ def __dir__() -> list[str]: class _SdistReplace(TypedDict, total=False): name: str - version: str + version: Version | str class _WheelReplace(TypedDict, total=False): name: str - version: str + version: Version | str build_tag: BuildTag tags: Iterable[Tag] variant: str | None @@ -87,41 +87,37 @@ def _compress_tag_set(tags: frozenset[Tag]) -> str: class _DistributionFilename(metaclass=abc.ABCMeta): """Shared state and behavior for wheel and sdist filenames.""" - __slots__ = ("_original_name", "_original_version", "_version") + __slots__ = ("_name", "_version") _error: ClassVar[type[InvalidFilename]] _kind: ClassVar[str] - def __init__(self, name: str, version: str) -> None: - self._check_name(name) - self._set_base(name, version, self._parse_version(version)) - - def _set_base(self, name: str, version_str: str, version: Version) -> None: - self._original_name = name - self._original_version = version_str - self._version = version + def __init__(self, name: str, version: Version | str) -> None: + self._name = self._check_name(name) + self._version = self._parse_version(version) def _replace_base(self, new: Self, kwargs: _SdistReplace | _WheelReplace) -> None: """Set the name and version on **new**, checking only the changed values.""" - name = self._original_name - if "name" in kwargs: - name = kwargs["name"] - self._check_name(name) - if "version" in kwargs: - version_str = kwargs["version"] - new._set_base(name, version_str, self._parse_version(version_str)) - else: - new._set_base(name, self._original_version, self._version) + new._name = self._check_name(kwargs["name"]) if "name" in kwargs else self._name + new._version = ( + self._parse_version(kwargs["version"]) + if "version" in kwargs + else self._version + ) @classmethod - def _check_name(cls, name: str) -> None: + def _check_name(cls, name: str) -> NormalizedName: try: - canonicalize_name(name, validate=True) + return canonicalize_name(name, validate=True) except InvalidName: raise cls._invalid(f"invalid project name {name!r}") from None @classmethod - def _parse_version(cls, version: str, filename: str | None = None) -> Version: + def _parse_version( + cls, version: Version | str, filename: str | None = None + ) -> Version: + if isinstance(version, Version): + return version try: return Version(version) except InvalidVersion as e: @@ -148,20 +144,10 @@ def _check_normalized( if version_part != str(version): raise cls._invalid(f"non-normalized version {version_part!r}", filename) - @property - def original_name(self) -> str: - """The project name as given.""" - return self._original_name - - @property - def original_version(self) -> str: - """The version string as given.""" - return self._original_version - @property def name(self) -> NormalizedName: """The normalized project name.""" - return canonicalize_name(self._original_name) + return self._name @property def version(self) -> Version: @@ -169,7 +155,7 @@ def version(self) -> Version: return self._version def _key(self) -> tuple[object, ...]: - return (self.name, str(self._version)) + return (self._name, str(self._version)) def __eq__(self, other: object) -> bool: if not isinstance(other, _DistributionFilename): @@ -189,9 +175,6 @@ def __str__(self) -> str: class WheelFilename(_DistributionFilename): """Represents a wheel filename and its parsed components. - Instances preserve the original name and version strings for round-tripping, - while exposing normalized and validated views through properties. - Instances are immutable and hashable. Two instances are equal if their normalized name, normalized version, build tag, tags, and variant are equal. @@ -207,15 +190,15 @@ class WheelFilename(_DistributionFilename): def __init__( self, name: str, - version: str, + version: Version | str, build_tag: BuildTag = (), tags: Iterable[Tag] = (), variant: str | None = None, ) -> None: """Create a wheel filename from its component parts. - :param name: The project name (in original, filename-style form). - :param version: The version string (in original form). + :param name: The project name. It is stored in normalized form. + :param version: The version. :param build_tag: Optional wheel build tag. :param tags: The wheel tag set. It must not be empty to make a filename. :param variant: The variant label (see :pep:`825`), or ``None``. @@ -326,8 +309,8 @@ def _key(self) -> tuple[object, ...]: def __repr__(self) -> str: return ( - f"{self.__class__.__name__}(name={self._original_name!r}, " - f"version={self._original_version!r}, " + f"{self.__class__.__name__}(name={self._name!r}, " + f"version={str(self._version)!r}, " f"build_tag={self._build_tag!r}, " f"tags={self._tags!r}, " f"variant={self._variant!r})" @@ -351,7 +334,7 @@ def to_filename(self) -> str: >>> WheelFilename("foo-bar", "1.0", (), tags).to_filename() 'foo_bar-1.0-py3-none-any.whl' """ - name = canonicalize_name(self._original_name).replace("-", "_") + name = self._name.replace("-", "_") file_parts = [name, str(self.version)] if self._build_tag: file_parts.append(self.build_str) @@ -438,7 +421,8 @@ def from_filename( # Non-strict parsing accepts legacy names, so skip the constructor checks. self = cls.__new__(cls) - self._set_base(name, version_part, version) + self._name = canonicalize_name(name) + self._version = version self._set_wheel(build_tag, tags, variant) # Reconstruct the filename and check that it matches the original @@ -456,9 +440,6 @@ def from_filename( class SourceDistributionFilename(_DistributionFilename): """Represents a source distribution filename and its parsed components. - Instances preserve the original name and version strings for round-tripping, - while exposing normalized and validated views through properties. - Instances are immutable and hashable. Two instances are equal if their normalized name and normalized version are equal. @@ -471,11 +452,11 @@ class SourceDistributionFilename(_DistributionFilename): _error = InvalidSdistFilename _kind = "sdist" - def __init__(self, name: str, version: str) -> None: + def __init__(self, name: str, version: Version | str) -> None: """Create a source distribution filename from name and version. - :param str name: The project name (in original, filename-style form). - :param str version: The version string (in original form). + :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. """ super().__init__(name, version) @@ -501,8 +482,8 @@ def __replace__(self, **kwargs: Unpack[_SdistReplace]) -> Self: def __repr__(self) -> str: return ( - f"{self.__class__.__name__}(name={self._original_name!r}, " - f"version={self._original_version!r})" + f"{self.__class__.__name__}(name={self._name!r}, " + f"version={str(self._version)!r})" ) def to_filename(self) -> str: @@ -517,7 +498,7 @@ def to_filename(self) -> str: >>> SourceDistributionFilename("foo-bar", "1.0").to_filename() 'foo_bar-1.0.tar.gz' """ - name = canonicalize_name(self._original_name).replace("-", "_") + name = self._name.replace("-", "_") return f"{name}-{self.version}.tar.gz" @classmethod @@ -574,5 +555,6 @@ def from_filename( # Non-strict parsing accepts legacy names, so skip the constructor checks. self = cls.__new__(cls) - self._set_base(name_part, version_part, version) + self._name = canonicalize_name(name_part) + self._version = version return self diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 529df3e2d..b0496f4ec 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -18,6 +18,7 @@ WheelFilename, ) from packaging.tags import Tag +from packaging.utils import canonicalize_name from packaging.version import Version if typing.TYPE_CHECKING: @@ -54,8 +55,8 @@ def test_sdist_not_strict_passes( ) -> None: fn = SourceDistributionFilename(name, version) assert str(fn) == expected_filename - assert fn.original_name == name - assert fn.original_version == version + assert fn.name == canonicalize_name(name) + assert fn.version == Version(version) @pytest.mark.parametrize( @@ -328,8 +329,8 @@ def test_wheel_not_strict_passes( ) -> None: fn = WheelFilename(name, version, (), {Tag("py3", "none", "any")}) assert str(fn) == expected_filename - assert fn.original_name == name - assert fn.original_version == version + assert fn.name == canonicalize_name(name) + assert fn.version == Version(version) @pytest.mark.parametrize( @@ -417,7 +418,8 @@ def test_wheel_replace() -> None: assert new == WheelFilename( "Foo", "2.0", (1, "a"), {Tag("cp314", "cp314", "win_amd64")}, "x86_64_v3" ) - assert new.original_name == "Foo" + 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" ) @@ -428,7 +430,7 @@ def test_wheel_replace() -> None: def test_legacy_name_from_filename() -> None: # Non-strict parsing accepts names that the constructor rejects. wf = WheelFilename.from_filename("_foo-1.0-py3-none-any.whl", strict=False) - assert wf.original_name == "_foo" + 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", strict=False) assert fn.__replace__(version="2.0").to_filename() == "_foo-2.0.tar.gz" @@ -436,9 +438,9 @@ def test_legacy_name_from_filename() -> None: def test_wheel_repr() -> None: tags = frozenset({Tag("py3", "none", "any")}) - wf = WheelFilename("foo", "1.0", (1, "abc"), tags) + wf = WheelFilename("Foo.Bar", "01.0", (1, "abc"), tags) assert repr(wf) == ( - f"WheelFilename(name='foo', version='1.0', build_tag=(1, 'abc'), " + f"WheelFilename(name='foo-bar', version='1.0', build_tag=(1, 'abc'), " f"tags={tags!r}, variant=None)" ) @@ -471,7 +473,7 @@ def test_sdist_replace() -> None: def test_sdist_repr() -> None: - fn = SourceDistributionFilename("foo", "1.0") + fn = SourceDistributionFilename("Foo", Version("1.0")) assert repr(fn) == "SourceDistributionFilename(name='foo', version='1.0')" From f8881b0935139f77253e658ae608904cf9580406 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 20:49:06 -0400 Subject: [PATCH 29/53] refactor(filenames): drop the base class and move helpers to functions The base class was only used to share code. The classmethod helpers are now module-level functions, and small helpers are inlined. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 353 ++++++++++++++++++++----------------- tests/test_filenames.py | 1 + 2 files changed, 191 insertions(+), 163 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 582734dd2..acac59b40 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -4,9 +4,8 @@ from __future__ import annotations -import abc import re -from typing import TYPE_CHECKING, ClassVar, TypedDict +from typing import TYPE_CHECKING, TypedDict from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag from .utils import ( @@ -69,110 +68,66 @@ def _check_replace_keys(kwargs: Mapping[str, object], allowed: frozenset[str]) - raise TypeError(msg) -def _compress_tag_set(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): - inner = "the tag set cannot be compressed, it must contain every combination" - msg = f"Invalid wheel filename ({inner}): {sorted(map(str, tags))!r}" - raise InvalidWheelFilename(msg) - return "-".join((".".join(interpreters), ".".join(abis), ".".join(platforms))) - - -class _DistributionFilename(metaclass=abc.ABCMeta): - """Shared state and behavior for wheel and sdist filenames.""" - - __slots__ = ("_name", "_version") - - _error: ClassVar[type[InvalidFilename]] - _kind: ClassVar[str] - - def __init__(self, name: str, version: Version | str) -> None: - self._name = self._check_name(name) - self._version = self._parse_version(version) - - def _replace_base(self, new: Self, kwargs: _SdistReplace | _WheelReplace) -> None: - """Set the name and version on **new**, checking only the changed values.""" - new._name = self._check_name(kwargs["name"]) if "name" in kwargs else self._name - new._version = ( - self._parse_version(kwargs["version"]) - if "version" in kwargs - else self._version - ) - - @classmethod - def _check_name(cls, name: str) -> NormalizedName: - try: - return canonicalize_name(name, validate=True) - except InvalidName: - raise cls._invalid(f"invalid project name {name!r}") from None - - @classmethod - def _parse_version( - cls, version: Version | str, filename: str | None = None - ) -> Version: - if isinstance(version, Version): - return version - try: - return Version(version) - except InvalidVersion as e: - raise cls._invalid(f"invalid version {version!r}", filename) from e - - @classmethod - def _invalid(cls, inner: str, filename: str | None = None) -> InvalidFilename: - msg = f"Invalid {cls._kind} filename ({inner})" - if filename is not None: - msg = f"{msg}: {filename!r}" - return cls._error(msg) - - @classmethod - def _check_normalized( - cls, name: str, version_part: str, version: Version, filename: str - ) -> None: - """Check that the name and version parts are in their normalized form.""" - try: - cname = canonicalize_name(name, validate=True).replace("-", "_") - except InvalidName: - raise cls._invalid(f"invalid project name {name!r}", filename) from None - if name != cname: - raise cls._invalid(f"non-normalized project name {name!r}", filename) - if version_part != str(version): - raise cls._invalid(f"non-normalized version {version_part!r}", filename) - - @property - def name(self) -> NormalizedName: - """The normalized project name.""" - return self._name - - @property - def version(self) -> Version: - """The parsed project version.""" - return self._version - - def _key(self) -> tuple[object, ...]: - return (self._name, str(self._version)) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, _DistributionFilename): - return NotImplemented - return self._key() == other._key() - - def __hash__(self) -> int: - return hash(self._key()) - - @abc.abstractmethod - def to_filename(self) -> str: ... - - def __str__(self) -> str: - return self.to_filename() - - -class WheelFilename(_DistributionFilename): +def _invalid( + error: type[InvalidFilename], inner: str, filename: str | None = None +) -> InvalidFilename: + kind = "wheel" if issubclass(error, InvalidWheelFilename) else "sdist" + msg = f"Invalid {kind} filename ({inner})" + if filename is not None: + msg = f"{msg}: {filename!r}" + return error(msg) + + +def _check_name(error: type[InvalidFilename], name: str) -> NormalizedName: + try: + return canonicalize_name(name, validate=True) + except InvalidName: + raise _invalid(error, f"invalid project name {name!r}") from None + + +def _parse_version( + error: type[InvalidFilename], version: Version | str, filename: str | None = None +) -> Version: + if isinstance(version, Version): + return version + try: + return Version(version) + except InvalidVersion as e: + raise _invalid(error, f"invalid version {version!r}", filename) from e + + +def _check_normalized( + error: type[InvalidFilename], + name: str, + version_part: str, + version: Version, + filename: str, +) -> None: + """Check that the name and version parts are in their normalized form.""" + try: + cname = canonicalize_name(name, validate=True).replace("-", "_") + except InvalidName: + raise _invalid(error, f"invalid project name {name!r}", filename) from None + if name != cname: + raise _invalid(error, f"non-normalized project name {name!r}", filename) + if version_part != str(version): + raise _invalid(error, f"non-normalized version {version_part!r}", filename) + + +def _check_build_tag(build_tag: BuildTag) -> None: + if build_tag and ( + build_tag[0] < 0 or _build_suffix_regex.fullmatch(build_tag[1]) is None + ): + raise _invalid(InvalidWheelFilename, f"invalid build tag {build_tag!r}") + + +def _check_variant(variant: str | None, filename: str | None = None) -> None: + if variant is not None and _variant_label_regex.fullmatch(variant) is None: + inner = f"invalid variant label {variant!r}" + raise _invalid(InvalidWheelFilename, inner, filename) + + +class WheelFilename: """Represents a wheel filename and its parsed components. Instances are immutable and hashable. Two instances are equal if their @@ -181,12 +136,9 @@ class WheelFilename(_DistributionFilename): .. versionadded:: 26.4 """ - __slots__ = ("_build_tag", "_tags", "_variant") + __slots__ = ("_build_tag", "_name", "_tags", "_variant", "_version") __match_args__ = ("name", "version", "build_tag", "tags", "variant") - _error = InvalidWheelFilename - _kind = "wheel" - def __init__( self, name: str, @@ -205,30 +157,14 @@ def __init__( :raises InvalidWheelFilename: If the name, version, build tag, or variant label is not valid. """ - super().__init__(name, version) - self._check_build_tag(build_tag) - self._check_variant(variant) - self._set_wheel(build_tag, tags, variant) - - def _set_wheel( - self, build_tag: BuildTag, tags: Iterable[Tag], variant: str | None - ) -> None: + _check_build_tag(build_tag) + _check_variant(variant) + self._name = _check_name(InvalidWheelFilename, name) + self._version = _parse_version(InvalidWheelFilename, version) self._build_tag = build_tag self._tags = frozenset(tags) self._variant = variant - @classmethod - def _check_build_tag(cls, build_tag: BuildTag) -> None: - if build_tag and ( - build_tag[0] < 0 or _build_suffix_regex.fullmatch(build_tag[1]) is None - ): - raise cls._invalid(f"invalid build tag {build_tag!r}") - - @classmethod - def _check_variant(cls, variant: str | None, filename: str | None = None) -> None: - if variant is not None and _variant_label_regex.fullmatch(variant) is None: - raise cls._invalid(f"invalid variant label {variant!r}", filename) - def __replace__(self, **kwargs: Unpack[_WheelReplace]) -> Self: """ __replace__(*, name=..., version=..., build_tag=..., tags=..., variant=...) @@ -245,17 +181,34 @@ def __replace__(self, **kwargs: Unpack[_WheelReplace]) -> Self: :raises InvalidWheelFilename: If a replaced part is not valid. """ _check_replace_keys(kwargs, _WheelReplace.__optional_keys__) - build_tag = kwargs.get("build_tag", self._build_tag) + new = self.__class__.__new__(self.__class__) + new._name = self._name + if "name" in kwargs: + new._name = _check_name(InvalidWheelFilename, kwargs["name"]) + new._version = self._version + if "version" in kwargs: + new._version = _parse_version(InvalidWheelFilename, kwargs["version"]) + new._build_tag = self._build_tag if "build_tag" in kwargs: - self._check_build_tag(build_tag) - variant = kwargs.get("variant", self._variant) + _check_build_tag(kwargs["build_tag"]) + new._build_tag = kwargs["build_tag"] + new._tags = frozenset(kwargs["tags"]) if "tags" in kwargs else self._tags + new._variant = self._variant if "variant" in kwargs: - self._check_variant(variant) - new = self.__class__.__new__(self.__class__) - self._replace_base(new, kwargs) - new._set_wheel(build_tag, kwargs.get("tags", self._tags), variant) + _check_variant(kwargs["variant"]) + new._variant = kwargs["variant"] 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.""" @@ -288,7 +241,21 @@ def compressed_tags(self) -> str: >>> wf.compressed_tags 'py2.py3-none-any' """ - return _compress_tag_set(self._tags) + tags = self._tags + if not tags: + inner = "the tag set must have at least one tag" + raise _invalid(InvalidWheelFilename, inner) + 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): + inner = ( + "the tag set cannot be compressed, it must contain every combination" + ) + msg = f"Invalid wheel filename ({inner}): {sorted(map(str, tags))!r}" + raise InvalidWheelFilename(msg) + return "-".join((".".join(interpreters), ".".join(abis), ".".join(platforms))) @property def build_str(self) -> str: @@ -305,7 +272,24 @@ def build_str(self) -> str: return "".join(map(str, self._build_tag)) def _key(self) -> tuple[object, ...]: - return (*super()._key(), self._build_tag, self._tags, self._variant) + 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 __str__(self) -> str: + return self.to_filename() def __repr__(self) -> str: return ( @@ -382,11 +366,11 @@ def from_filename( True """ if not filename.endswith(".whl"): - raise cls._invalid("extension must be '.whl'", filename) + raise _invalid(InvalidWheelFilename, "extension must be '.whl'", filename) parts = filename[:-4].split("-") if len(parts) not in {5, 6, 7}: - raise cls._invalid("wrong number of parts", filename) + raise _invalid(InvalidWheelFilename, "wrong number of parts", filename) name, version_part, *rest = parts @@ -394,7 +378,9 @@ def from_filename( # A build tag starts with a digit, and a Python tag never does. build_match = _build_tag_regex.match(rest[0]) if len(rest) > 3 else None if len(rest) == 5 and build_match is None: - raise cls._invalid(f"invalid build number {rest[0]!r}", filename) + raise _invalid( + InvalidWheelFilename, f"invalid build number {rest[0]!r}", filename + ) build_part = rest.pop(0) if build_match else None variant = rest.pop() if len(rest) == 4 else None tag_str = "-".join(rest) @@ -403,41 +389,51 @@ def from_filename( tags = parse_tag(tag_str, validate_order=validate_order or strict) except UnsortedTagsError: inner = "compressed tag set components must be in sorted order per PEP 425" - raise cls._invalid(inner, filename) from None + raise _invalid(InvalidWheelFilename, inner, filename) from None except InvalidTag: - raise cls._invalid(f"invalid tag component {tag_str!r}", filename) from None + raise _invalid( + InvalidWheelFilename, f"invalid tag component {tag_str!r}", filename + ) from None # See PEP 427 for the rules on escaping the project name. if "__" in name or _wheel_name_regex.match(name) is None: - raise cls._invalid(f"invalid project name {name!r}", filename) + raise _invalid( + InvalidWheelFilename, f"invalid project name {name!r}", filename + ) - version = cls._parse_version(version_part, filename) + version = _parse_version(InvalidWheelFilename, version_part, filename) build_tag: BuildTag = () if build_match is not None: build_tag = (int(build_match.group(1)), build_match.group(2)) - cls._check_variant(variant, filename) + _check_variant(variant, filename) # Non-strict parsing accepts legacy names, so skip the constructor checks. self = cls.__new__(cls) self._name = canonicalize_name(name) self._version = version - self._set_wheel(build_tag, tags, variant) + self._build_tag = build_tag + self._tags = tags + self._variant = variant # Reconstruct the filename and check that it matches the original if strict: - cls._check_normalized(name, version_part, version, filename) + _check_normalized( + InvalidWheelFilename, name, version_part, version, filename + ) if build_part is not None and build_part != self.build_str: inner = f"non-normalized build tag {build_part!r}" - raise cls._invalid(inner, filename) + raise _invalid(InvalidWheelFilename, inner, filename) if self.compressed_tags != tag_str: - raise cls._invalid(f"non-normalized tags {tag_str!r}", filename) + raise _invalid( + InvalidWheelFilename, f"non-normalized tags {tag_str!r}", filename + ) return self -class SourceDistributionFilename(_DistributionFilename): +class SourceDistributionFilename: """Represents a source distribution filename and its parsed components. Instances are immutable and hashable. Two instances are equal if their @@ -446,12 +442,9 @@ class SourceDistributionFilename(_DistributionFilename): .. versionadded:: 26.4 """ - __slots__ = () + __slots__ = ("_name", "_version") __match_args__ = ("name", "version") - _error = InvalidSdistFilename - _kind = "sdist" - def __init__(self, name: str, version: Version | str) -> None: """Create a source distribution filename from name and version. @@ -459,7 +452,8 @@ def __init__(self, name: str, version: Version | str) -> None: :param version: The version. :raises InvalidSdistFilename: If the name or version is not valid. """ - super().__init__(name, version) + self._name = _check_name(InvalidSdistFilename, name) + self._version = _parse_version(InvalidSdistFilename, version) def __replace__(self, **kwargs: Unpack[_SdistReplace]) -> Self: """ @@ -477,9 +471,38 @@ def __replace__(self, **kwargs: Unpack[_SdistReplace]) -> Self: """ _check_replace_keys(kwargs, _SdistReplace.__optional_keys__) new = self.__class__.__new__(self.__class__) - self._replace_base(new, kwargs) + new._name = self._name + if "name" in kwargs: + new._name = _check_name(InvalidSdistFilename, kwargs["name"]) + new._version = self._version + if "version" in kwargs: + new._version = _parse_version(InvalidSdistFilename, kwargs["version"]) 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 + + 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 __str__(self) -> str: + return self.to_filename() + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(name={self._name!r}, " @@ -536,7 +559,9 @@ def from_filename( file_stem = filename[: -len(".zip")] else: extensions = "'.tar.gz'" if strict else "'.tar.gz' or '.zip'" - raise cls._invalid(f"extension must be {extensions}", filename) + raise _invalid( + InvalidSdistFilename, f"extension must be {extensions}", filename + ) # PEP 625: Source distributions may only have one hyphen, separating # the name and version. Strict mode rejects extra hyphens via the @@ -544,14 +569,16 @@ def from_filename( name_part, sep, version_part = file_stem.rpartition("-") if not sep: inner = "hyphen must separate name and version parts" - raise cls._invalid(inner, filename) + raise _invalid(InvalidSdistFilename, inner, filename) if not name_part: - raise cls._invalid("empty project name", filename) + raise _invalid(InvalidSdistFilename, "empty project name", filename) - version = cls._parse_version(version_part, filename) + version = _parse_version(InvalidSdistFilename, version_part, filename) if strict: - cls._check_normalized(name_part, version_part, version, filename) + _check_normalized( + InvalidSdistFilename, name_part, version_part, version, filename + ) # Non-strict parsing accepts legacy names, so skip the constructor checks. self = cls.__new__(cls) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index b0496f4ec..ac8e0fb40 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -468,6 +468,7 @@ def test_sdist_init_invalid(kwargs: dict[str, str], error_message: str) -> None: 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] From 6a41b7ddcfec4f4d10607fd719c395a431350de6 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 20:53:20 -0400 Subject: [PATCH 30/53] refactor(filenames): simplify replace and merge overlapping tests Use copy.copy in __replace__, simplify the wheel name regex, and return Self from from_filename. Merge overlapping tests into parametrized ones. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 34 +++----- tests/test_filenames.py | 170 ++++++++++++++++--------------------- 2 files changed, 85 insertions(+), 119 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index acac59b40..8146fc632 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -4,6 +4,7 @@ from __future__ import annotations +import copy import re from typing import TYPE_CHECKING, TypedDict @@ -46,7 +47,7 @@ def __dir__() -> list[str]: _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._]+\Z", re.UNICODE) +_wheel_name_regex = re.compile(r"[\w.]+") class _SdistReplace(TypedDict, total=False): @@ -181,19 +182,16 @@ def __replace__(self, **kwargs: Unpack[_WheelReplace]) -> Self: :raises InvalidWheelFilename: If a replaced part is not valid. """ _check_replace_keys(kwargs, _WheelReplace.__optional_keys__) - new = self.__class__.__new__(self.__class__) - new._name = self._name + new = copy.copy(self) if "name" in kwargs: new._name = _check_name(InvalidWheelFilename, kwargs["name"]) - new._version = self._version if "version" in kwargs: new._version = _parse_version(InvalidWheelFilename, kwargs["version"]) - new._build_tag = self._build_tag if "build_tag" in kwargs: _check_build_tag(kwargs["build_tag"]) new._build_tag = kwargs["build_tag"] - new._tags = frozenset(kwargs["tags"]) if "tags" in kwargs else self._tags - new._variant = self._variant + if "tags" in kwargs: + new._tags = frozenset(kwargs["tags"]) if "variant" in kwargs: _check_variant(kwargs["variant"]) new._variant = kwargs["variant"] @@ -319,7 +317,7 @@ def to_filename(self) -> str: 'foo_bar-1.0-py3-none-any.whl' """ name = self._name.replace("-", "_") - file_parts = [name, str(self.version)] + file_parts = [name, str(self._version)] if self._build_tag: file_parts.append(self.build_str) file_parts.append(self.compressed_tags) @@ -330,7 +328,7 @@ def to_filename(self) -> str: @classmethod def from_filename( cls, filename: str, /, *, strict: bool, validate_order: bool = False - ) -> WheelFilename: + ) -> Self: """ This function takes the filename of a wheel file, and parses it into a :class:`WheelFilename`. @@ -396,16 +394,16 @@ def from_filename( ) from None # See PEP 427 for the rules on escaping the project name. - if "__" in name or _wheel_name_regex.match(name) is None: + if "__" in name or _wheel_name_regex.fullmatch(name) is None: raise _invalid( InvalidWheelFilename, f"invalid project name {name!r}", filename ) version = _parse_version(InvalidWheelFilename, version_part, filename) - build_tag: BuildTag = () - if build_match is not None: - build_tag = (int(build_match.group(1)), build_match.group(2)) + build_tag: BuildTag = ( + (int(build_match[1]), build_match[2]) if build_match else () + ) _check_variant(variant, filename) @@ -470,11 +468,9 @@ def __replace__(self, **kwargs: Unpack[_SdistReplace]) -> Self: :raises InvalidSdistFilename: If a replaced part is not valid. """ _check_replace_keys(kwargs, _SdistReplace.__optional_keys__) - new = self.__class__.__new__(self.__class__) - new._name = self._name + new = copy.copy(self) if "name" in kwargs: new._name = _check_name(InvalidSdistFilename, kwargs["name"]) - new._version = self._version if "version" in kwargs: new._version = _parse_version(InvalidSdistFilename, kwargs["version"]) return new @@ -522,12 +518,10 @@ def to_filename(self) -> str: 'foo_bar-1.0.tar.gz' """ name = self._name.replace("-", "_") - return f"{name}-{self.version}.tar.gz" + return f"{name}-{self._version}.tar.gz" @classmethod - def from_filename( - cls, filename: str, /, *, strict: bool - ) -> SourceDistributionFilename: + def from_filename(cls, filename: str, /, *, strict: bool) -> Self: """ This function takes the filename of a sdist file (as specified in the `Source distribution format`_ documentation), and parses diff --git a/tests/test_filenames.py b/tests/test_filenames.py index ac8e0fb40..1b53ed5c4 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -48,61 +48,81 @@ "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_not_strict_passes( - name: str, version: str, expected_filename: str -) -> None: +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) + assert ( + SourceDistributionFilename.from_filename(expected_filename, strict=True) == fn + ) @pytest.mark.parametrize( - ("filename", "error_message"), + ("filename", "strict", "error_message"), [ ( "bad.extension", # Bad extension + True, "Invalid sdist filename (extension must be '.tar.gz')", ), ( "extra-hyphens-1.0-9.tar.gz", # Extra hyphens + True, "Invalid sdist filename (non-normalized project name 'extra-hyphens-1.0')", ), ( "no_hyphen.tar.gz", # No hyphen + True, "Invalid sdist filename (hyphen must separate name and version parts)", ), ( ".invalid.name-1.0.tar.gz", # Name is not valid + True, "Invalid sdist filename (invalid project name '.invalid.name')", ), ( "invalid.name-1.0.tar.gz", # Name is not canonical (punctuation) + True, "Invalid sdist filename (non-normalized project name 'invalid.name')", ), ( "invalid__name-1.0.tar.gz", # Name is not canonical (punctuation) + True, "Invalid sdist filename (non-normalized project name 'invalid__name')", ), ( "INVALID_NAME-1.0.tar.gz", # Name is not canonical (casing) + True, "Invalid sdist filename (non-normalized project name 'INVALID_NAME')", ), ( "valid_name-badversion.tar.gz", # Version is not valid + True, "Invalid sdist filename (invalid version 'badversion')", ), ( "valid_name-01.0.tar.gz", # Version is not canonical + True, "Invalid sdist filename (non-normalized version '01.0')", ), + ( + "foo-1.0.tgz", + False, + "Invalid sdist filename (extension must be '.tar.gz' or '.zip')", + ), ], ) -def test_sdist_from_filename_invalid(filename: str, error_message: str) -> None: +def test_sdist_from_filename_invalid( + filename: str, strict: bool, error_message: str +) -> None: with pytest.raises(InvalidFilename) as e: - SourceDistributionFilename.from_filename(filename, strict=True) + SourceDistributionFilename.from_filename(filename, strict=strict) assert str(e.value) == f"{error_message}: {filename!r}" @@ -171,40 +191,50 @@ def test_wheel_from_filename( @pytest.mark.parametrize( - ("filename", "build_tag", "tags", "variant"), + ("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, build_tag: BuildTag, tags: set[Tag], variant: str + filename: str, name: str, build_tag: BuildTag, tags: set[Tag], variant: str ) -> None: fn = WheelFilename.from_filename(filename, strict=True) - assert fn.name == "numpy" - assert fn.version == Version("2.3.2") + assert fn.name == name assert fn.build_tag == build_tag assert fn.tags == tags assert fn.variant == variant @@ -300,62 +330,35 @@ def test_wheel_from_filename_invalid(filename: str, error_message: str) -> None: @pytest.mark.parametrize( - ("name", "version", "expected_filename"), - [ - ( - "valid.name", # Name is not canonical (punctuation) - "1.0", - "valid_name-1.0-py3-none-any.whl", - ), - ( - "valid__name", # Name is not canonical (punctuation) - "1.0", - "valid_name-1.0-py3-none-any.whl", - ), - ( - "VALID_NAME", # Name is not canonical (casing) - "1.0", - "valid_name-1.0-py3-none-any.whl", - ), - ( - "valid_name", - "01.0", # Version is not canonical - "valid_name-1.0-py3-none-any.whl", - ), - ], -) -def test_wheel_not_strict_passes( - name: str, version: str, expected_filename: str -) -> None: - fn = WheelFilename(name, version, (), {Tag("py3", "none", "any")}) - assert str(fn) == expected_filename - assert fn.name == canonicalize_name(name) - assert fn.version == Version(version) - - -@pytest.mark.parametrize( - ("filename", "name", "version", "build", "tags"), + ("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"), ( - "some_package-1.0-py3-none-any.whl", - "some-PACKAGE", - "1.0", - (), - {Tag("py3", "none", "any")}, - ), - ( - "foo_bar-1.0-42-py2.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_compose_wheel_filename( - filename: str, name: str, version: str, build: BuildTag, tags: set[Tag] +def test_wheel_init( + name: str, + version: str, + build_tag: BuildTag, + tags: set[Tag] | None, + expected_filename: str, ) -> None: - assert WheelFilename(name, version, build, tags).to_filename() == filename + fn = WheelFilename(name, version, build_tag, tags or {Tag("py3", "none", "any")}) + assert fn.to_filename() == expected_filename + assert str(fn) == expected_filename + assert fn.name == canonicalize_name(name) + assert fn.version == Version(version) + assert WheelFilename.from_filename(expected_filename, strict=True) == fn def test_parse_and_create_filename() -> None: @@ -367,23 +370,6 @@ def test_parse_and_create_filename() -> None: assert sorted_f == composed -@pytest.mark.parametrize( - ("filename", "name", "version"), - [ - ("foo-1.0.tar.gz", "foo", Version("1.0")), - ("foo_bar-1.0.tar.gz", "foo-bar", Version("1.0")), - ], -) -def test_compose_sdist_filename(filename: str, name: str, version: Version) -> None: - assert SourceDistributionFilename(name, str(version)).to_filename() == filename - - -def test_sdist_from_filename_strict_valid() -> None: - fn = SourceDistributionFilename.from_filename("foo_bar-1.0.tar.gz", strict=True) - assert fn.name == "foo-bar" - assert fn.version == Version("1.0") - - @pytest.mark.parametrize( ("kwargs", "error_message"), [ @@ -478,33 +464,19 @@ def test_sdist_repr() -> None: assert repr(fn) == "SourceDistributionFilename(name='foo', version='1.0')" -def test_sdist_from_filename_invalid_extension_not_strict() -> None: - with pytest.raises(InvalidFilename) as e: - SourceDistributionFilename.from_filename("foo-1.0.tgz", strict=False) - - assert str(e.value) == ( - "Invalid sdist filename (extension must be '.tar.gz' or '.zip'): 'foo-1.0.tgz'" - ) - - -def test_wheel_from_filename_six_parts_non_digit_is_variant() -> None: - # PEP 825: a third part that does not start with a digit is a Python tag. - fn = WheelFilename.from_filename("foo-1.0-abc-py3-none-any.whl", strict=True) - assert fn.build_tag == () - assert fn.tags == {Tag("abc", "py3", "none")} - assert fn.variant == "any" - - -def test_wheel_to_filename_no_tags() -> None: - wf = WheelFilename("foo", "1.0") - with pytest.raises(InvalidWheelFilename, match="at least one tag"): - wf.to_filename() - - -def test_wheel_to_filename_tags_not_compressible() -> None: - tags = {Tag("py3", "none", "any"), Tag("cp314", "cp314", "win_amd64")} +@pytest.mark.parametrize( + ("tags", "match"), + [ + (set(), "at least one tag"), + ( + {Tag("py3", "none", "any"), Tag("cp314", "cp314", "win_amd64")}, + "cannot be compressed", + ), + ], +) +def test_wheel_to_filename_invalid_tags(tags: set[Tag], match: str) -> None: wf = WheelFilename("foo", "1.0", (), tags) - with pytest.raises(InvalidWheelFilename, match="cannot be compressed"): + with pytest.raises(InvalidWheelFilename, match=match): wf.to_filename() From bdb15d5979cd838b96589c09269ef071e997e6e3 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 22:17:13 -0400 Subject: [PATCH 31/53] refactor(filenames): move strict checks to validate helper functions Replace the strict and validate_order parameters of from_filename with validate_wheel_filename, validate_sdist_filename, and validate_ordered_tags. Parsing is now always lenient. Assisted-by: ClaudeCode:claude-opus-5-5 ClaudeCode:claude-opus-5-5 --- docs/filenames.rst | 31 ++++- src/packaging/filenames.py | 229 ++++++++++++++++++++++++------------- src/packaging/utils.py | 10 +- tests/test_filenames.py | 85 ++++++++------ 4 files changed, 230 insertions(+), 125 deletions(-) diff --git a/docs/filenames.rst b/docs/filenames.rst index b01d924c5..b0e6b0ff4 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -5,6 +5,19 @@ Tools to work with filenames for SDists and wheels. .. versionadded:: 26.4 +Working with filenames +---------------------- + +To construct 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") + + Converting from older utils --------------------------- @@ -23,9 +36,7 @@ with: .. doctest:: >>> from packaging.filenames import WheelFilename - >>> wheel = WheelFilename.from_filename( - ... "foo-1.0-1-py3-none-any.whl", strict=False - ... ) + >>> 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 ... ) @@ -33,9 +44,17 @@ with: True Be sure to check and handle :attr:`~packaging.filenames.WheelFilename.variant`. -``validate_order=True`` will throw an error if the tag order is not sorted, -just like the old function. The new ``strict=True`` parameter will require -a normalized filename. +To replace ``validate_order=True``, call +:func:`~packaging.filenames.validate_ordered_tags` before parsing. To require +a fully normalized filename, including sorted tags, call +:func:`~packaging.filenames.validate_wheel_filename` instead: + +.. doctest:: + + >>> from packaging.filenames import validate_ordered_tags + >>> filename = "foo-1.0-1-py3-none-any.whl" + >>> validate_ordered_tags(filename) + >>> wheel = WheelFilename.from_filename(filename) Reference --------- diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 8146fc632..8ddf9c689 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -33,6 +33,9 @@ "InvalidWheelFilename", "SourceDistributionFilename", "WheelFilename", + "validate_ordered_tags", + "validate_sdist_filename", + "validate_wheel_filename", ] @@ -326,9 +329,7 @@ def to_filename(self) -> str: return "-".join(file_parts) + ".whl" @classmethod - def from_filename( - cls, filename: str, /, *, strict: bool, validate_order: bool = False - ) -> Self: + def from_filename(cls, filename: str, /) -> Self: """ This function takes the filename of a wheel file, and parses it into a :class:`WheelFilename`. @@ -338,22 +339,17 @@ def from_filename( since a Python tag never does. Otherwise, the last part is a variant label (see :pep:`825`). - If **strict** is true, the name, version, build tag, and tags must be in - their normalized form, which includes sorted tag set components. If - **validate_order** is true, compressed tag set components are checked to - be in sorted order as required by PEP 425 even when **strict** is false. + Parsing accepts legacy names, versions, and tag orders. Use + :func:`validate_wheel_filename` first to require a normalized filename. :param str filename: The name of the wheel file. - :param bool strict: Require the filename to be fully normalized. - :param bool validate_order: Check whether compressed tag set components - are in sorted order. :raises InvalidWheelFilename: If the filename in question does not follow the :ref:`wheel specification `. >>> from packaging.filenames import WheelFilename >>> from packaging.tags import Tag - >>> wf = WheelFilename.from_filename("foo-1.0-py3-none-any.whl", strict=True) + >>> wf = WheelFilename.from_filename("foo-1.0-py3-none-any.whl") >>> wf.name 'foo' >>> wf.version @@ -363,31 +359,12 @@ def from_filename( >>> wf.tags == {Tag("py3", "none", "any")} True """ - if not filename.endswith(".whl"): - raise _invalid(InvalidWheelFilename, "extension must be '.whl'", filename) - - parts = filename[:-4].split("-") - if len(parts) not in {5, 6, 7}: - raise _invalid(InvalidWheelFilename, "wrong number of parts", filename) - - name, version_part, *rest = parts - - # 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. - build_match = _build_tag_regex.match(rest[0]) if len(rest) > 3 else None - if len(rest) == 5 and build_match is None: - raise _invalid( - InvalidWheelFilename, f"invalid build number {rest[0]!r}", filename - ) - build_part = rest.pop(0) if build_match else None - variant = rest.pop() if len(rest) == 4 else None - tag_str = "-".join(rest) + name, version_part, build_part, tag_str, variant = _split_wheel_filename( + filename + ) try: - tags = parse_tag(tag_str, validate_order=validate_order or strict) - except UnsortedTagsError: - inner = "compressed tag set components must be in sorted order per PEP 425" - raise _invalid(InvalidWheelFilename, inner, filename) from None + tags = parse_tag(tag_str) except InvalidTag: raise _invalid( InvalidWheelFilename, f"invalid tag component {tag_str!r}", filename @@ -401,34 +378,103 @@ def from_filename( version = _parse_version(InvalidWheelFilename, version_part, filename) + build_match = _build_tag_regex.match(build_part or "") build_tag: BuildTag = ( (int(build_match[1]), build_match[2]) if build_match else () ) _check_variant(variant, filename) - # Non-strict parsing accepts legacy names, so skip the constructor checks. + # Parsing accepts legacy names, so skip the constructor checks. self = cls.__new__(cls) self._name = canonicalize_name(name) self._version = version self._build_tag = build_tag self._tags = tags self._variant = variant + return self - # Reconstruct the filename and check that it matches the original - if strict: - _check_normalized( - InvalidWheelFilename, name, version_part, version, filename - ) - if build_part is not None and build_part != self.build_str: - inner = f"non-normalized build tag {build_part!r}" - raise _invalid(InvalidWheelFilename, inner, filename) - if self.compressed_tags != tag_str: - raise _invalid( - InvalidWheelFilename, f"non-normalized tags {tag_str!r}", filename - ) - return self +def _split_wheel_filename( + filename: str, +) -> tuple[str, str, str | None, str, str | None]: + """Split a wheel filename into name, version, build, tag, and variant parts.""" + if not filename.endswith(".whl"): + raise _invalid(InvalidWheelFilename, "extension must be '.whl'", filename) + + parts = filename[:-4].split("-") + if len(parts) not in {5, 6, 7}: + raise _invalid(InvalidWheelFilename, "wrong number of parts", filename) + + name, version_part, *rest = parts + + # 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. + has_build = len(rest) > 3 and _build_tag_regex.match(rest[0]) is not None + if len(rest) == 5 and not has_build: + raise _invalid( + InvalidWheelFilename, f"invalid build number {rest[0]!r}", filename + ) + build_part = rest.pop(0) if has_build else None + variant = rest.pop() if len(rest) == 4 else None + return name, version_part, build_part, "-".join(rest), variant + + +def validate_ordered_tags(filename: str, /) -> None: + """ + Check that the compressed tag set components of a wheel filename are in the + sorted order required by :pep:`425`. + + Other parts of the filename are not checked. Use + :func:`validate_wheel_filename` to check the full filename. + + :param str filename: The name of the wheel file. + :raises InvalidWheelFilename: If the tags are not valid or not sorted. + + >>> from packaging.filenames import validate_ordered_tags + >>> validate_ordered_tags("foo-1.0-py2.py3-none-any.whl") + + .. versionadded:: 26.4 + """ + tag_str = _split_wheel_filename(filename)[3] + try: + parse_tag(tag_str, validate_order=True) + except UnsortedTagsError: + inner = "compressed tag set components must be in sorted order per PEP 425" + raise _invalid(InvalidWheelFilename, inner, filename) from None + except InvalidTag: + raise _invalid( + InvalidWheelFilename, f"invalid tag component {tag_str!r}", filename + ) from None + + +def validate_wheel_filename(filename: str, /) -> None: + """ + Check that a wheel filename is valid and 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`. + + :param str filename: The name of the wheel file. + :raises InvalidWheelFilename: If the filename is not valid or not normalized. + + >>> from packaging.filenames import validate_wheel_filename + >>> validate_wheel_filename("foo-1.0-py3-none-any.whl") + + .. versionadded:: 26.4 + """ + wf = WheelFilename.from_filename(filename) + validate_ordered_tags(filename) + name, version_part, build_part, tag_str, _ = _split_wheel_filename(filename) + + _check_normalized(InvalidWheelFilename, name, version_part, wf.version, filename) + if build_part is not None and build_part != wf.build_str: + inner = f"non-normalized build tag {build_part!r}" + raise _invalid(InvalidWheelFilename, inner, filename) + if wf.compressed_tags != tag_str: + raise _invalid( + InvalidWheelFilename, f"non-normalized tags {tag_str!r}", filename + ) class SourceDistributionFilename: @@ -521,15 +567,16 @@ def to_filename(self) -> str: return f"{name}-{self._version}.tar.gz" @classmethod - def from_filename(cls, filename: str, /, *, strict: bool) -> Self: + 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. Use + :func:`validate_sdist_filename` first to require a normalized filename. + :param str filename: The name of the sdist file. - :param bool strict: Require a ``.tar.gz`` extension and a fully - normalized name and version. :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, @@ -537,7 +584,7 @@ def from_filename(cls, filename: str, /, *, strict: bool) -> Self: version. >>> from packaging.filenames import SourceDistributionFilename - >>> fn = SourceDistributionFilename.from_filename("foo-1.0.tar.gz", strict=True) + >>> fn = SourceDistributionFilename.from_filename("foo-1.0.tar.gz") >>> fn.name 'foo' >>> fn.version @@ -545,37 +592,59 @@ def from_filename(cls, filename: str, /, *, strict: bool) -> Self: .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name """ - # PEP 625: Source distributions must end with .tar.gz - # Non-strict mode will allow .zip for backward compatibility - if filename.endswith(".tar.gz"): - file_stem = filename[: -len(".tar.gz")] - elif filename.endswith(".zip") and not strict: - file_stem = filename[: -len(".zip")] - else: - extensions = "'.tar.gz'" if strict else "'.tar.gz' or '.zip'" - raise _invalid( - InvalidSdistFilename, f"extension must be {extensions}", filename - ) - - # PEP 625: Source distributions may only have one hyphen, separating - # the name and version. Strict mode rejects extra hyphens via the - # normalized name check below. - name_part, sep, version_part = file_stem.rpartition("-") - if not sep: - inner = "hyphen must separate name and version parts" - raise _invalid(InvalidSdistFilename, inner, filename) - if not name_part: - raise _invalid(InvalidSdistFilename, "empty project name", filename) - + name_part, version_part = _split_sdist_filename(filename) version = _parse_version(InvalidSdistFilename, version_part, filename) - if strict: - _check_normalized( - InvalidSdistFilename, name_part, version_part, version, filename - ) - - # Non-strict parsing accepts legacy names, so skip the constructor checks. + # Parsing accepts legacy names, so skip the constructor checks. self = cls.__new__(cls) self._name = canonicalize_name(name_part) self._version = version return self + + +def _split_sdist_filename(filename: str) -> tuple[str, str]: + """Split a sdist filename into name and version parts.""" + # 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'" + raise _invalid(InvalidSdistFilename, inner, filename) + + # 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" + raise _invalid(InvalidSdistFilename, inner, filename) + if not name_part: + raise _invalid(InvalidSdistFilename, "empty project name", filename) + return name_part, version_part + + +def validate_sdist_filename(filename: str, /) -> None: + """ + Check that a sdist filename is valid and in its normalized form. + + The extension must be ``.tar.gz``, and the name and version must be + normalized. + + :param str filename: The name of the sdist file. + :raises InvalidSdistFilename: If the filename is not valid or not normalized. + + >>> from packaging.filenames import validate_sdist_filename + >>> validate_sdist_filename("foo-1.0.tar.gz") + + .. versionadded:: 26.4 + """ + if not filename.endswith(".tar.gz"): + raise _invalid(InvalidSdistFilename, "extension must be '.tar.gz'", filename) + fn = SourceDistributionFilename.from_filename(filename) + name_part, version_part = _split_sdist_filename(filename) + _check_normalized( + InvalidSdistFilename, name_part, version_part, fn.version, filename + ) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index f4fdad35b..fe7cbd16d 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -258,11 +258,11 @@ def parse_wheel_filename( not an identifier, a tag set component is empty, or the project name is empty. """ - from .filenames import WheelFilename # noqa: PLC0415 + from .filenames import WheelFilename, validate_ordered_tags # noqa: PLC0415 - fname = WheelFilename.from_filename( - filename, strict=False, validate_order=validate_order - ) + fname = WheelFilename.from_filename(filename) + if validate_order: + validate_ordered_tags(filename) if fname.variant is not None: # Without variant support, a sixth part is a build number. if fname.build_tag: @@ -314,5 +314,5 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: """ from .filenames import SourceDistributionFilename # noqa: PLC0415 - fname = SourceDistributionFilename.from_filename(filename, strict=False) + fname = SourceDistributionFilename.from_filename(filename) return (fname.name, fname.version) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 1b53ed5c4..e16fcf540 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -16,6 +16,9 @@ InvalidWheelFilename, SourceDistributionFilename, WheelFilename, + validate_ordered_tags, + validate_sdist_filename, + validate_wheel_filename, ) from packaging.tags import Tag from packaging.utils import canonicalize_name @@ -58,75 +61,66 @@ def test_sdist_init(name: str, version: str, expected_filename: str) -> None: assert str(fn) == expected_filename assert fn.name == canonicalize_name(name) assert fn.version == Version(version) - assert ( - SourceDistributionFilename.from_filename(expected_filename, strict=True) == fn - ) + validate_sdist_filename(expected_filename) + assert SourceDistributionFilename.from_filename(expected_filename) == fn @pytest.mark.parametrize( - ("filename", "strict", "error_message"), + ("filename", "error_message"), [ ( "bad.extension", # Bad extension - True, "Invalid sdist filename (extension must be '.tar.gz')", ), ( "extra-hyphens-1.0-9.tar.gz", # Extra hyphens - True, "Invalid sdist filename (non-normalized project name 'extra-hyphens-1.0')", ), ( "no_hyphen.tar.gz", # No hyphen - True, "Invalid sdist filename (hyphen must separate name and version parts)", ), ( ".invalid.name-1.0.tar.gz", # Name is not valid - True, "Invalid sdist filename (invalid project name '.invalid.name')", ), ( "invalid.name-1.0.tar.gz", # Name is not canonical (punctuation) - True, "Invalid sdist filename (non-normalized project name 'invalid.name')", ), ( "invalid__name-1.0.tar.gz", # Name is not canonical (punctuation) - True, "Invalid sdist filename (non-normalized project name 'invalid__name')", ), ( "INVALID_NAME-1.0.tar.gz", # Name is not canonical (casing) - True, "Invalid sdist filename (non-normalized project name 'INVALID_NAME')", ), ( "valid_name-badversion.tar.gz", # Version is not valid - True, "Invalid sdist filename (invalid version 'badversion')", ), ( "valid_name-01.0.tar.gz", # Version is not canonical - True, "Invalid sdist filename (non-normalized version '01.0')", ), - ( - "foo-1.0.tgz", - False, - "Invalid sdist filename (extension must be '.tar.gz' or '.zip')", - ), ], ) -def test_sdist_from_filename_invalid( - filename: str, strict: bool, error_message: str -) -> None: +def test_validate_sdist_filename_invalid(filename: str, error_message: str) -> None: with pytest.raises(InvalidFilename) as e: - SourceDistributionFilename.from_filename(filename, strict=strict) + validate_sdist_filename(filename) assert str(e.value) == f"{error_message}: {filename!r}" +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"), @@ -181,7 +175,8 @@ def test_sdist_from_filename_invalid( def test_wheel_from_filename( filename: str, name: str, version: Version, build_tag: BuildTag, tags: set[Tag] ) -> None: - fn = WheelFilename.from_filename(filename, strict=True) + validate_wheel_filename(filename) + fn = WheelFilename.from_filename(filename) assert fn.name == name assert fn.version == version assert fn.build_tag == build_tag @@ -233,7 +228,8 @@ def test_wheel_from_filename( def test_wheel_from_filename_variant( filename: str, name: str, build_tag: BuildTag, tags: set[Tag], variant: str ) -> None: - fn = WheelFilename.from_filename(filename, strict=True) + validate_wheel_filename(filename) + fn = WheelFilename.from_filename(filename) assert fn.name == name assert fn.build_tag == build_tag assert fn.tags == tags @@ -322,9 +318,9 @@ def test_wheel_from_filename_variant( ), ], ) -def test_wheel_from_filename_invalid(filename: str, error_message: str) -> None: +def test_validate_wheel_filename_invalid(filename: str, error_message: str) -> None: with pytest.raises(InvalidWheelFilename) as e: - WheelFilename.from_filename(filename, strict=True) + validate_wheel_filename(filename) assert str(e.value) == f"{error_message}: {filename!r}" @@ -358,18 +354,39 @@ def test_wheel_init( assert str(fn) == expected_filename assert fn.name == canonicalize_name(name) assert fn.version == Version(version) - assert WheelFilename.from_filename(expected_filename, strict=True) == fn + validate_wheel_filename(expected_filename) + 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, strict=False) + wf = WheelFilename.from_filename(filename) composed = wf.to_filename() assert sorted_f == composed +@pytest.mark.parametrize( + ("filename", "error_message"), + [ + ( + "foo-1.0-py3.py2-none-any.whl", + "compressed tag set components must be in sorted order per PEP 425", + ), + ("foo-1.0-py3.-none-any.whl", "invalid tag component 'py3.-none-any'"), + ], +) +def test_validate_ordered_tags_invalid(filename: str, error_message: str) -> None: + with pytest.raises(InvalidWheelFilename) as e: + validate_ordered_tags(filename) + assert str(e.value) == f"Invalid wheel filename ({error_message}): {filename!r}" + + +def test_validate_ordered_tags_only_checks_tags() -> None: + validate_ordered_tags("Foo-01.0-py3.py3-none-any.whl") + + @pytest.mark.parametrize( ("kwargs", "error_message"), [ @@ -414,11 +431,11 @@ def test_wheel_replace() -> None: def test_legacy_name_from_filename() -> None: - # Non-strict parsing accepts names that the constructor rejects. - wf = WheelFilename.from_filename("_foo-1.0-py3-none-any.whl", strict=False) + # 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", strict=False) + fn = SourceDistributionFilename.from_filename("_foo-1.0.tar.gz") assert fn.__replace__(version="2.0").to_filename() == "_foo-2.0.tar.gz" @@ -491,8 +508,8 @@ def test_wheel_eq_hash() -> None: assert wf != WheelFilename("foo", "1.0", (), tags) assert wf != WheelFilename("foo", "1.0", (1, ""), tags, "x86_64_v3") assert wf != "foo-1.0-1-py3-none-any.whl" - assert len({wf, WheelFilename.from_filename(str(wf), strict=True)}) == 1 - raw = WheelFilename.from_filename("Foo.Bar-01.0-1-py3-none-any.whl", strict=False) + 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", (1, ""), tags) @@ -503,7 +520,7 @@ def test_sdist_eq_hash() -> None: 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", strict=False) + assert fn == SourceDistributionFilename.from_filename("foo-1.0.zip") assert fn != "foo-1.0.tar.gz" assert fn != WheelFilename("foo", "1.0") From d2199bfe039f8c11a1162aebe11b491210124a0c Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 22:58:20 -0400 Subject: [PATCH 32/53] refactor(filenames): collect normalization errors into an ExceptionGroup Replace validate_ordered_tags with a distinct exception class per normalization check. validate_wheel_filename and validate_sdist_filename raise invalid filenames directly and collect normalization errors into an ExceptionGroup, so callers can select checks with except*. Assisted-by: ClaudeCode:claude-opus-5-5 --- docs/filenames.rst | 35 ++++++--- src/packaging/filenames.py | 153 ++++++++++++++++++++++++++----------- src/packaging/utils.py | 8 +- tests/test_filenames.py | 132 +++++++++++++++++++++++++------- 4 files changed, 244 insertions(+), 84 deletions(-) diff --git a/docs/filenames.rst b/docs/filenames.rst index b0e6b0ff4..656ef9d68 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -17,6 +17,28 @@ To construct a filename, use ``.from_filename`` on >>> from packaging.filenames import WheelFilename >>> wheel_filename = WheelFilename.from_filename("foo-1.0-1-py3-none-any.whl") +Parsing accepts legacy filenames. To require a normalized filename, use +:func:`~packaging.filenames.validate_wheel_filename` or +:func:`~packaging.filenames.validate_sdist_filename`. These raise +:class:`~packaging.filenames.InvalidFilename` if the filename is not valid. +They collect all normalization problems into an +:external:exc:`ExceptionGroup`. Each problem has its own exception class, so you can use ``except*`` to handle only some of them. For +example, to accept a non-normalized name and version, but reject all other +problems: + +.. code-block:: python + + from packaging.filenames import ( + NonNormalizedName, + NonNormalizedVersion, + validate_wheel_filename, + ) + + try: + validate_wheel_filename("Foo-01.0-py3-none-any.whl") + except* (NonNormalizedName, NonNormalizedVersion): + pass + Converting from older utils --------------------------- @@ -45,16 +67,9 @@ with: Be sure to check and handle :attr:`~packaging.filenames.WheelFilename.variant`. To replace ``validate_order=True``, call -:func:`~packaging.filenames.validate_ordered_tags` before parsing. To require -a fully normalized filename, including sorted tags, call -:func:`~packaging.filenames.validate_wheel_filename` instead: - -.. doctest:: - - >>> from packaging.filenames import validate_ordered_tags - >>> filename = "foo-1.0-1-py3-none-any.whl" - >>> validate_ordered_tags(filename) - >>> wheel = WheelFilename.from_filename(filename) +:func:`~packaging.filenames.validate_wheel_filename`. It reports unsorted tags +with :class:`~packaging.filenames.UnsortedWheelTags`. It also checks that the +other parts of the filename are normalized. Reference --------- diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 8ddf9c689..ee5b20364 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -8,6 +8,7 @@ import re from typing import TYPE_CHECKING, TypedDict +from .errors import _ErrorCollector from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag from .utils import ( BuildTag, @@ -31,9 +32,13 @@ "InvalidFilename", "InvalidSdistFilename", "InvalidWheelFilename", + "NonNormalizedBuildTag", + "NonNormalizedName", + "NonNormalizedTags", + "NonNormalizedVersion", "SourceDistributionFilename", + "UnsortedWheelTags", "WheelFilename", - "validate_ordered_tags", "validate_sdist_filename", "validate_wheel_filename", ] @@ -53,6 +58,47 @@ def __dir__() -> list[str]: _wheel_name_regex = re.compile(r"[\w.]+") +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 UnsortedWheelTags(InvalidWheelFilename): + """ + The compressed tag set components in a wheel filename are not in the + sorted order required by :pep:`425`. + + .. versionadded:: 26.4 + """ + + class _SdistReplace(TypedDict, total=False): name: str version: Version | str @@ -73,9 +119,15 @@ def _check_replace_keys(kwargs: Mapping[str, object], allowed: frozenset[str]) - def _invalid( - error: type[InvalidFilename], inner: str, filename: str | None = None + error: type[InvalidFilename], + inner: str, + filename: str | None = None, + *, + wheel: bool | None = None, ) -> InvalidFilename: - kind = "wheel" if issubclass(error, InvalidWheelFilename) else "sdist" + if wheel is None: + wheel = issubclass(error, InvalidWheelFilename) + kind = "wheel" if wheel else "sdist" msg = f"Invalid {kind} filename ({inner})" if filename is not None: msg = f"{msg}: {filename!r}" @@ -101,21 +153,28 @@ def _parse_version( def _check_normalized( + collector: _ErrorCollector, error: type[InvalidFilename], name: str, version_part: str, version: Version, filename: str, ) -> None: - """Check that the name and version parts are in their normalized form.""" + """Collect errors if the name and version parts are not normalized. + + An invalid name is raised directly. + """ + wheel = issubclass(error, InvalidWheelFilename) try: cname = canonicalize_name(name, validate=True).replace("-", "_") except InvalidName: raise _invalid(error, f"invalid project name {name!r}", filename) from None if name != cname: - raise _invalid(error, f"non-normalized project name {name!r}", filename) + inner = f"non-normalized project name {name!r}" + collector.error(_invalid(NonNormalizedName, inner, filename, wheel=wheel)) if version_part != str(version): - raise _invalid(error, f"non-normalized version {version_part!r}", filename) + inner = f"non-normalized version {version_part!r}" + collector.error(_invalid(NonNormalizedVersion, inner, filename, wheel=wheel)) def _check_build_tag(build_tag: BuildTag) -> None: @@ -420,32 +479,12 @@ def _split_wheel_filename( return name, version_part, build_part, "-".join(rest), variant -def validate_ordered_tags(filename: str, /) -> None: - """ - Check that the compressed tag set components of a wheel filename are in the - sorted order required by :pep:`425`. - - Other parts of the filename are not checked. Use - :func:`validate_wheel_filename` to check the full filename. - - :param str filename: The name of the wheel file. - :raises InvalidWheelFilename: If the tags are not valid or not sorted. - - >>> from packaging.filenames import validate_ordered_tags - >>> validate_ordered_tags("foo-1.0-py2.py3-none-any.whl") - - .. versionadded:: 26.4 - """ - tag_str = _split_wheel_filename(filename)[3] +def _check_ordered_tags(filename: str, tag_str: str) -> None: try: parse_tag(tag_str, validate_order=True) except UnsortedTagsError: inner = "compressed tag set components must be in sorted order per PEP 425" - raise _invalid(InvalidWheelFilename, inner, filename) from None - except InvalidTag: - raise _invalid( - InvalidWheelFilename, f"invalid tag component {tag_str!r}", filename - ) from None + raise _invalid(UnsortedWheelTags, inner, filename) from None def validate_wheel_filename(filename: str, /) -> None: @@ -455,8 +494,14 @@ def validate_wheel_filename(filename: str, /) -> None: The name, version, build tag, and tags must be normalized. This includes compressed tag set components in the sorted order required by :pep:`425`. + 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. + :param str filename: The name of the wheel file. - :raises InvalidWheelFilename: If the filename is not valid or not normalized. + :raises InvalidWheelFilename: If the filename is not valid. + :raises ExceptionGroup: If the filename is not normalized. >>> from packaging.filenames import validate_wheel_filename >>> validate_wheel_filename("foo-1.0-py3-none-any.whl") @@ -464,17 +509,25 @@ def validate_wheel_filename(filename: str, /) -> None: .. versionadded:: 26.4 """ wf = WheelFilename.from_filename(filename) - validate_ordered_tags(filename) - name, version_part, build_part, tag_str, _ = _split_wheel_filename(filename) + with _ErrorCollector().on_exit( + f"Non-normalized wheel filename: {filename!r}" + ) as collector: + name, version_part, build_part, tag_str, _ = _split_wheel_filename(filename) - _check_normalized(InvalidWheelFilename, name, version_part, wf.version, filename) - if build_part is not None and build_part != wf.build_str: - inner = f"non-normalized build tag {build_part!r}" - raise _invalid(InvalidWheelFilename, inner, filename) - if wf.compressed_tags != tag_str: - raise _invalid( - InvalidWheelFilename, f"non-normalized tags {tag_str!r}", filename + _check_normalized( + collector, InvalidWheelFilename, name, version_part, wf.version, filename ) + if build_part is not None and build_part != wf.build_str: + inner = f"non-normalized build tag {build_part!r}" + collector.error(_invalid(NonNormalizedBuildTag, inner, filename)) + try: + _check_ordered_tags(filename, tag_str) + except UnsortedWheelTags as e: + collector.error(e) + else: + if wf.compressed_tags != tag_str: + inner = f"non-normalized tags {tag_str!r}" + collector.error(_invalid(NonNormalizedTags, inner, filename)) class SourceDistributionFilename: @@ -633,8 +686,14 @@ def validate_sdist_filename(filename: str, /) -> None: The extension must be ``.tar.gz``, and the name and version must be 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. + :param str filename: The name of the sdist file. - :raises InvalidSdistFilename: If the filename is not valid or not normalized. + :raises InvalidSdistFilename: If the filename is not valid, or if the + extension is not ``.tar.gz``. + :raises ExceptionGroup: If the filename is not normalized. >>> from packaging.filenames import validate_sdist_filename >>> validate_sdist_filename("foo-1.0.tar.gz") @@ -644,7 +703,15 @@ def validate_sdist_filename(filename: str, /) -> None: if not filename.endswith(".tar.gz"): raise _invalid(InvalidSdistFilename, "extension must be '.tar.gz'", filename) fn = SourceDistributionFilename.from_filename(filename) - name_part, version_part = _split_sdist_filename(filename) - _check_normalized( - InvalidSdistFilename, name_part, version_part, fn.version, filename - ) + with _ErrorCollector().on_exit( + f"Non-normalized sdist filename: {filename!r}" + ) as collector: + name_part, version_part = _split_sdist_filename(filename) + _check_normalized( + collector, + InvalidSdistFilename, + name_part, + version_part, + fn.version, + filename, + ) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index fe7cbd16d..1915eab03 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -258,11 +258,15 @@ def parse_wheel_filename( not an identifier, a tag set component is empty, or the project name is empty. """ - from .filenames import WheelFilename, validate_ordered_tags # noqa: PLC0415 + from .filenames import ( # noqa: PLC0415 + WheelFilename, + _check_ordered_tags, + _split_wheel_filename, + ) fname = WheelFilename.from_filename(filename) if validate_order: - validate_ordered_tags(filename) + _check_ordered_tags(filename, _split_wheel_filename(filename)[3]) if fname.variant is not None: # Without variant support, a sixth part is a build number. if fname.build_tag: diff --git a/tests/test_filenames.py b/tests/test_filenames.py index e16fcf540..a41285fb0 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -10,13 +10,18 @@ import pytest +from packaging.errors import ExceptionGroup from packaging.filenames import ( InvalidFilename, InvalidSdistFilename, InvalidWheelFilename, + NonNormalizedBuildTag, + NonNormalizedName, + NonNormalizedTags, + NonNormalizedVersion, SourceDistributionFilename, + UnsortedWheelTags, WheelFilename, - validate_ordered_tags, validate_sdist_filename, validate_wheel_filename, ) @@ -27,6 +32,9 @@ if typing.TYPE_CHECKING: from packaging.filenames import BuildTag +# Raised directly, not in a group. +_INVALID = {InvalidSdistFilename, InvalidWheelFilename} + @pytest.mark.parametrize( ("name", "version", "expected_filename"), @@ -66,51 +74,85 @@ def test_sdist_init(name: str, version: str, expected_filename: str) -> None: @pytest.mark.parametrize( - ("filename", "error_message"), + ("filename", "error_message", "error_type"), [ ( "bad.extension", # Bad extension "Invalid sdist filename (extension must be '.tar.gz')", + 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')", + InvalidSdistFilename, ), ( "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) -> None: - with pytest.raises(InvalidFilename) as e: +def test_validate_sdist_filename_invalid( + filename: str, error_message: str, error_type: type[InvalidFilename] +) -> None: + with pytest.raises( + InvalidFilename if error_type in _INVALID else ExceptionGroup + ) as e: validate_sdist_filename(filename) - assert str(e.value) == f"{error_message}: {filename!r}" + 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: + validate_sdist_filename("Foo-01.0.tar.gz") + + assert [type(error) for error in e.value.exceptions] == [ + NonNormalizedName, + NonNormalizedVersion, + ] def test_sdist_from_filename_invalid_extension() -> None: @@ -238,91 +280,143 @@ def test_wheel_from_filename_variant( @pytest.mark.parametrize( - ("filename", "error_message"), + ("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')", + InvalidWheelFilename, ), ( "\u00e9-1.0-py3-none-any.whl", # Non-ASCII name "Invalid wheel filename (invalid project name '\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, ), ], ) -def test_validate_wheel_filename_invalid(filename: str, error_message: str) -> None: - with pytest.raises(InvalidWheelFilename) as e: +def test_validate_wheel_filename_invalid( + filename: str, error_message: str, error_type: type[InvalidFilename] +) -> None: + with pytest.raises( + InvalidFilename if error_type in _INVALID else ExceptionGroup + ) as e: validate_wheel_filename(filename) - assert str(e.value) == f"{error_message}: {filename!r}" + 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]), + ], +) +def test_validate_wheel_filename_multiple_errors( + filename: str, error_types: list[type[InvalidFilename]] +) -> None: + with pytest.raises(ExceptionGroup) as e: + validate_wheel_filename(filename) + + assert [type(error) for error in e.value.exceptions] == error_types @pytest.mark.parametrize( @@ -367,26 +461,6 @@ def test_parse_and_create_filename() -> None: assert sorted_f == composed -@pytest.mark.parametrize( - ("filename", "error_message"), - [ - ( - "foo-1.0-py3.py2-none-any.whl", - "compressed tag set components must be in sorted order per PEP 425", - ), - ("foo-1.0-py3.-none-any.whl", "invalid tag component 'py3.-none-any'"), - ], -) -def test_validate_ordered_tags_invalid(filename: str, error_message: str) -> None: - with pytest.raises(InvalidWheelFilename) as e: - validate_ordered_tags(filename) - assert str(e.value) == f"Invalid wheel filename ({error_message}): {filename!r}" - - -def test_validate_ordered_tags_only_checks_tags() -> None: - validate_ordered_tags("Foo-01.0-py3.py3-none-any.whl") - - @pytest.mark.parametrize( ("kwargs", "error_message"), [ From ee5f219703787f4c62a178d96edcb4e095fb9c7e Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 22 Sep 2026 23:13:06 -0400 Subject: [PATCH 33/53] refactor(filenames): parse each filename once and share field checks Split wheel and sdist filenames once and build objects from the parts via a private classmethod. Move the no-variant policy into the splitter so parse_wheel_filename no longer reconstructs it. Share a _set helper between __init__ and __replace__, and reuse _check_name in the normalization check. Assisted-by: ClaudeCode:claude-fable-5-1 --- src/packaging/filenames.py | 197 ++++++++++++++++++++----------------- src/packaging/utils.py | 24 +---- tests/test_filenames.py | 11 +-- 3 files changed, 114 insertions(+), 118 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index ee5b20364..0ce10ebae 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -6,7 +6,7 @@ import copy import re -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, NamedTuple, TypedDict from .errors import _ErrorCollector from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag @@ -123,22 +123,23 @@ def _invalid( inner: str, filename: str | None = None, *, - wheel: bool | None = None, + kind: str | None = None, ) -> InvalidFilename: - if wheel is None: - wheel = issubclass(error, InvalidWheelFilename) - kind = "wheel" if wheel else "sdist" + if kind is None: + kind = "wheel" if issubclass(error, InvalidWheelFilename) else "sdist" msg = f"Invalid {kind} filename ({inner})" if filename is not None: msg = f"{msg}: {filename!r}" return error(msg) -def _check_name(error: type[InvalidFilename], name: str) -> NormalizedName: +def _check_name( + error: type[InvalidFilename], name: str, filename: str | None = None +) -> NormalizedName: try: return canonicalize_name(name, validate=True) except InvalidName: - raise _invalid(error, f"invalid project name {name!r}") from None + raise _invalid(error, f"invalid project name {name!r}", filename) from None def _parse_version( @@ -164,17 +165,13 @@ def _check_normalized( An invalid name is raised directly. """ - wheel = issubclass(error, InvalidWheelFilename) - try: - cname = canonicalize_name(name, validate=True).replace("-", "_") - except InvalidName: - raise _invalid(error, f"invalid project name {name!r}", filename) from None - if name != cname: + kind = "wheel" if issubclass(error, InvalidWheelFilename) else "sdist" + if name != _check_name(error, name, filename).replace("-", "_"): inner = f"non-normalized project name {name!r}" - collector.error(_invalid(NonNormalizedName, inner, filename, wheel=wheel)) + collector.error(_invalid(NonNormalizedName, inner, filename, kind=kind)) if version_part != str(version): inner = f"non-normalized version {version_part!r}" - collector.error(_invalid(NonNormalizedVersion, inner, filename, wheel=wheel)) + collector.error(_invalid(NonNormalizedVersion, inner, filename, kind=kind)) def _check_build_tag(build_tag: BuildTag) -> None: @@ -220,13 +217,23 @@ def __init__( :raises InvalidWheelFilename: If the name, version, build tag, or variant label is not valid. """ - _check_build_tag(build_tag) - _check_variant(variant) - self._name = _check_name(InvalidWheelFilename, name) - self._version = _parse_version(InvalidWheelFilename, version) - self._build_tag = build_tag - self._tags = frozenset(tags) - self._variant = variant + self._set( + name=name, version=version, build_tag=build_tag, tags=tags, variant=variant + ) + + def _set(self, **kwargs: Unpack[_WheelReplace]) -> None: + if "name" in kwargs: + self._name = _check_name(InvalidWheelFilename, kwargs["name"]) + if "version" in kwargs: + self._version = _parse_version(InvalidWheelFilename, kwargs["version"]) + if "build_tag" in kwargs: + _check_build_tag(kwargs["build_tag"]) + self._build_tag = kwargs["build_tag"] + if "tags" in kwargs: + self._tags = frozenset(kwargs["tags"]) + if "variant" in kwargs: + _check_variant(kwargs["variant"]) + self._variant = kwargs["variant"] def __replace__(self, **kwargs: Unpack[_WheelReplace]) -> Self: """ @@ -245,18 +252,7 @@ def __replace__(self, **kwargs: Unpack[_WheelReplace]) -> Self: """ _check_replace_keys(kwargs, _WheelReplace.__optional_keys__) new = copy.copy(self) - if "name" in kwargs: - new._name = _check_name(InvalidWheelFilename, kwargs["name"]) - if "version" in kwargs: - new._version = _parse_version(InvalidWheelFilename, kwargs["version"]) - if "build_tag" in kwargs: - _check_build_tag(kwargs["build_tag"]) - new._build_tag = kwargs["build_tag"] - if "tags" in kwargs: - new._tags = frozenset(kwargs["tags"]) - if "variant" in kwargs: - _check_variant(kwargs["variant"]) - new._variant = kwargs["variant"] + new._set(**kwargs) return new @property @@ -418,52 +414,65 @@ def from_filename(cls, filename: str, /) -> Self: >>> wf.tags == {Tag("py3", "none", "any")} True """ - name, version_part, build_part, tag_str, variant = _split_wheel_filename( - filename - ) + return cls._from_parts(filename, _split_wheel_filename(filename)) - try: - tags = parse_tag(tag_str) - except InvalidTag: - raise _invalid( - InvalidWheelFilename, f"invalid tag component {tag_str!r}", filename - ) from None + @classmethod + def _from_parts( + cls, filename: str, parts: _WheelParts, *, validate_order: bool = False + ) -> Self: + """Build from split parts, accepting legacy names and versions.""" + tags = _parse_tags(filename, parts.tags, validate_order=validate_order) # See PEP 427 for the rules on escaping the project name. - if "__" in name or _wheel_name_regex.fullmatch(name) is None: - raise _invalid( - InvalidWheelFilename, f"invalid project name {name!r}", filename - ) - - version = _parse_version(InvalidWheelFilename, version_part, filename) + if "__" in parts.name or _wheel_name_regex.fullmatch(parts.name) is None: + inner = f"invalid project name {parts.name!r}" + raise _invalid(InvalidWheelFilename, inner, filename) - build_match = _build_tag_regex.match(build_part or "") - build_tag: BuildTag = ( - (int(build_match[1]), build_match[2]) if build_match else () - ) + version = _parse_version(InvalidWheelFilename, parts.version, filename) + _check_variant(parts.variant, filename) - _check_variant(variant, filename) + # The split already checked that the build part starts with a digit. + build_tag: BuildTag = () + if parts.build is not None: + build_match = _build_tag_regex.match(parts.build) + assert build_match is not None + build_tag = (int(build_match[1]), build_match[2]) - # Parsing accepts legacy names, so skip the constructor checks. self = cls.__new__(cls) - self._name = canonicalize_name(name) + self._name = canonicalize_name(parts.name) self._version = version self._build_tag = build_tag self._tags = tags - self._variant = variant + self._variant = parts.variant return self -def _split_wheel_filename( - filename: str, -) -> tuple[str, str, str | None, str, str | None]: - """Split a wheel filename into name, version, build, tag, and variant parts.""" +class _WheelParts(NamedTuple): + name: str + version: str + build: str | None + tags: str + variant: 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"): raise _invalid(InvalidWheelFilename, "extension must be '.whl'", filename) parts = filename[:-4].split("-") if len(parts) not in {5, 6, 7}: raise _invalid(InvalidWheelFilename, "wrong number of parts", filename) + if len(parts) == 7 and not variants: + inner = ( + "variant wheels are not supported, " + "use packaging.filenames.WheelFilename instead for support" + ) + raise _invalid(InvalidWheelFilename, inner, filename) name, version_part, *rest = parts @@ -474,17 +483,23 @@ def _split_wheel_filename( raise _invalid( InvalidWheelFilename, f"invalid build number {rest[0]!r}", filename ) + if len(rest) == 4 and not has_build and not variants: + inner = "invalid build number or unsupported variant label" + raise _invalid(InvalidWheelFilename, inner, filename) build_part = rest.pop(0) if has_build else None variant = rest.pop() if len(rest) == 4 else None - return name, version_part, build_part, "-".join(rest), variant + return _WheelParts(name, version_part, build_part, "-".join(rest), variant) -def _check_ordered_tags(filename: str, tag_str: str) -> None: +def _parse_tags(filename: str, tag_str: str, *, validate_order: bool) -> frozenset[Tag]: try: - parse_tag(tag_str, validate_order=True) + return parse_tag(tag_str, validate_order=validate_order) except UnsortedTagsError: inner = "compressed tag set components must be in sorted order per PEP 425" raise _invalid(UnsortedWheelTags, inner, filename) from None + except InvalidTag: + inner = f"invalid tag component {tag_str!r}" + raise _invalid(InvalidWheelFilename, inner, filename) from None def validate_wheel_filename(filename: str, /) -> None: @@ -508,25 +523,26 @@ def validate_wheel_filename(filename: str, /) -> None: .. versionadded:: 26.4 """ - wf = WheelFilename.from_filename(filename) + parts = _split_wheel_filename(filename) + wf = WheelFilename._from_parts(filename, parts) with _ErrorCollector().on_exit( f"Non-normalized wheel filename: {filename!r}" ) as collector: - name, version_part, build_part, tag_str, _ = _split_wheel_filename(filename) - _check_normalized( - collector, InvalidWheelFilename, name, version_part, wf.version, filename + collector, + InvalidWheelFilename, + parts.name, + parts.version, + wf.version, + filename, ) - if build_part is not None and build_part != wf.build_str: - inner = f"non-normalized build tag {build_part!r}" + if parts.build is not None and parts.build != wf.build_str: + inner = f"non-normalized build tag {parts.build!r}" collector.error(_invalid(NonNormalizedBuildTag, inner, filename)) - try: - _check_ordered_tags(filename, tag_str) - except UnsortedWheelTags as e: - collector.error(e) - else: - if wf.compressed_tags != tag_str: - inner = f"non-normalized tags {tag_str!r}" + with collector.collect(UnsortedWheelTags): + _parse_tags(filename, parts.tags, validate_order=True) + if wf.compressed_tags != parts.tags: + inner = f"non-normalized tags {parts.tags!r}" collector.error(_invalid(NonNormalizedTags, inner, filename)) @@ -549,8 +565,13 @@ def __init__(self, name: str, version: Version | str) -> None: :param version: The version. :raises InvalidSdistFilename: If the name or version is not valid. """ - self._name = _check_name(InvalidSdistFilename, name) - self._version = _parse_version(InvalidSdistFilename, version) + self._set(name=name, version=version) + + def _set(self, **kwargs: Unpack[_SdistReplace]) -> None: + if "name" in kwargs: + self._name = _check_name(InvalidSdistFilename, kwargs["name"]) + if "version" in kwargs: + self._version = _parse_version(InvalidSdistFilename, kwargs["version"]) def __replace__(self, **kwargs: Unpack[_SdistReplace]) -> Self: """ @@ -568,10 +589,7 @@ def __replace__(self, **kwargs: Unpack[_SdistReplace]) -> Self: """ _check_replace_keys(kwargs, _SdistReplace.__optional_keys__) new = copy.copy(self) - if "name" in kwargs: - new._name = _check_name(InvalidSdistFilename, kwargs["name"]) - if "version" in kwargs: - new._version = _parse_version(InvalidSdistFilename, kwargs["version"]) + new._set(**kwargs) return new @property @@ -645,13 +663,14 @@ def from_filename(cls, filename: str, /) -> Self: .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name """ - name_part, version_part = _split_sdist_filename(filename) - version = _parse_version(InvalidSdistFilename, version_part, filename) + return cls._from_parts(filename, *_split_sdist_filename(filename)) - # Parsing accepts legacy names, so skip the constructor checks. + @classmethod + def _from_parts(cls, filename: str, name_part: str, version_part: str) -> Self: + """Build from split parts, accepting legacy names and versions.""" self = cls.__new__(cls) self._name = canonicalize_name(name_part) - self._version = version + self._version = _parse_version(InvalidSdistFilename, version_part, filename) return self @@ -702,11 +721,11 @@ def validate_sdist_filename(filename: str, /) -> None: """ if not filename.endswith(".tar.gz"): raise _invalid(InvalidSdistFilename, "extension must be '.tar.gz'", filename) - fn = SourceDistributionFilename.from_filename(filename) + name_part, version_part = _split_sdist_filename(filename) + fn = SourceDistributionFilename._from_parts(filename, name_part, version_part) with _ErrorCollector().on_exit( f"Non-normalized sdist filename: {filename!r}" ) as collector: - name_part, version_part = _split_sdist_filename(filename) _check_normalized( collector, InvalidSdistFilename, diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 1915eab03..3dba0132e 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -258,26 +258,10 @@ def parse_wheel_filename( not an identifier, a tag set component is empty, or the project name is empty. """ - from .filenames import ( # noqa: PLC0415 - WheelFilename, - _check_ordered_tags, - _split_wheel_filename, - ) - - fname = WheelFilename.from_filename(filename) - if validate_order: - _check_ordered_tags(filename, _split_wheel_filename(filename)[3]) - if fname.variant is not None: - # Without variant support, a sixth part is a build number. - if fname.build_tag: - inner = ( - "variant wheels are not supported, " - "use packaging.filenames.WheelFilename instead for support" - ) - else: - inner = "invalid build number or unsupported variant label" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) + from .filenames import WheelFilename, _split_wheel_filename # noqa: PLC0415 + + parts = _split_wheel_filename(filename, variants=False) + fname = WheelFilename._from_parts(filename, parts, validate_order=validate_order) return (fname.name, fname.version, fname.build_tag, fname.tags) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index a41285fb0..34bf3ef14 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -32,9 +32,6 @@ if typing.TYPE_CHECKING: from packaging.filenames import BuildTag -# Raised directly, not in a group. -_INVALID = {InvalidSdistFilename, InvalidWheelFilename} - @pytest.mark.parametrize( ("name", "version", "expected_filename"), @@ -131,9 +128,7 @@ def test_sdist_init(name: str, version: str, expected_filename: str) -> None: def test_validate_sdist_filename_invalid( filename: str, error_message: str, error_type: type[InvalidFilename] ) -> None: - with pytest.raises( - InvalidFilename if error_type in _INVALID else ExceptionGroup - ) as e: + with pytest.raises((InvalidFilename, ExceptionGroup)) as e: validate_sdist_filename(filename) if isinstance(e.value, ExceptionGroup): @@ -381,9 +376,7 @@ def test_wheel_from_filename_variant( def test_validate_wheel_filename_invalid( filename: str, error_message: str, error_type: type[InvalidFilename] ) -> None: - with pytest.raises( - InvalidFilename if error_type in _INVALID else ExceptionGroup - ) as e: + with pytest.raises((InvalidFilename, ExceptionGroup)) as e: validate_wheel_filename(filename) if isinstance(e.value, ExceptionGroup): From c6609ab5d855a3fe9c8cfebaabbfb0297ebab6f4 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 10:13:55 -0400 Subject: [PATCH 34/53] feat(filenames)!: require a valid tag set for WheelFilename The tags argument is now required and comes before build_tag. An empty or non-compressible tag set is rejected at construction. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 74 +++++++++++++++++++------------------- tests/test_filenames.py | 64 ++++++++++++++++----------------- 2 files changed, 68 insertions(+), 70 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 0ce10ebae..4b32b0421 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -187,6 +187,23 @@ def _check_variant(variant: str | None, filename: str | None = None) -> None: raise _invalid(InvalidWheelFilename, inner, filename) +def _compress_tags(tags: frozenset[Tag]) -> str: + if not tags: + inner = "the tag set must have at least one tag" + raise _invalid(InvalidWheelFilename, inner) + 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): + inner = ( + "the tag set cannot be compressed, it must contain every combination: " + f"{sorted(map(str, tags))!r}" + ) + raise _invalid(InvalidWheelFilename, inner) + return "-".join((".".join(interpreters), ".".join(abis), ".".join(platforms))) + + class WheelFilename: """Represents a wheel filename and its parsed components. @@ -197,28 +214,29 @@ class WheelFilename: """ __slots__ = ("_build_tag", "_name", "_tags", "_variant", "_version") - __match_args__ = ("name", "version", "build_tag", "tags", "variant") + __match_args__ = ("name", "version", "tags", "build_tag", "variant") def __init__( self, name: str, version: Version | str, + tags: Iterable[Tag], build_tag: BuildTag = (), - tags: Iterable[Tag] = (), 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. :param build_tag: Optional wheel build tag. - :param tags: The wheel tag set. It must not be empty to make a filename. :param variant: The variant label (see :pep:`825`), or ``None``. - :raises InvalidWheelFilename: If the name, version, build tag, or - variant label is not valid. + :raises InvalidWheelFilename: If the name, version, tag set, build tag, + or variant label is not valid. """ self._set( - name=name, version=version, build_tag=build_tag, tags=tags, variant=variant + name=name, version=version, tags=tags, build_tag=build_tag, variant=variant ) def _set(self, **kwargs: Unpack[_WheelReplace]) -> None: @@ -230,21 +248,23 @@ def _set(self, **kwargs: Unpack[_WheelReplace]) -> None: _check_build_tag(kwargs["build_tag"]) self._build_tag = kwargs["build_tag"] if "tags" in kwargs: - self._tags = frozenset(kwargs["tags"]) + tags = frozenset(kwargs["tags"]) + _compress_tags(tags) + self._tags = tags if "variant" in kwargs: _check_variant(kwargs["variant"]) self._variant = kwargs["variant"] def __replace__(self, **kwargs: Unpack[_WheelReplace]) -> Self: """ - __replace__(*, name=..., version=..., build_tag=..., tags=..., variant=...) + __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", tags={Tag("py3", "none", "any")}) + >>> wf = WheelFilename("foo", "1.0", {Tag("py3", "none", "any")}) >>> wf.__replace__(version="2.0").to_filename() 'foo-2.0-py3-none-any.whl' @@ -284,44 +304,29 @@ def variant(self) -> str | None: def compressed_tags(self) -> str: """The compressed and sorted wheel tag string (interpreter-abi-platform). - :raises InvalidWheelFilename: If the tag set is empty, or if it does not - contain every combination of its interpreters, ABIs, and platforms. - >>> from packaging.filenames import WheelFilename >>> from packaging.tags import Tag - >>> wf = WheelFilename("foo", "1.0", tags={Tag("py3", "none", "any")}) + >>> 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=tags) + >>> wf = WheelFilename("foo", "1.0", tags) >>> wf.compressed_tags 'py2.py3-none-any' """ - tags = self._tags - if not tags: - inner = "the tag set must have at least one tag" - raise _invalid(InvalidWheelFilename, inner) - 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): - inner = ( - "the tag set cannot be compressed, it must contain every combination" - ) - msg = f"Invalid wheel filename ({inner}): {sorted(map(str, tags))!r}" - raise InvalidWheelFilename(msg) - return "-".join((".".join(interpreters), ".".join(abis), ".".join(platforms))) + 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 - >>> wf = WheelFilename("foo", "1.0") + >>> from packaging.tags import Tag + >>> tags = {Tag("py3", "none", "any")} + >>> wf = WheelFilename("foo", "1.0", tags) >>> wf.build_str '' - >>> wf = WheelFilename("foo", "1.0", build_tag=(1, "abc")) + >>> wf = WheelFilename("foo", "1.0", tags, build_tag=(1, "abc")) >>> wf.build_str '1abc' """ @@ -351,8 +356,8 @@ def __repr__(self) -> str: return ( f"{self.__class__.__name__}(name={self._name!r}, " f"version={str(self._version)!r}, " - f"build_tag={self._build_tag!r}, " f"tags={self._tags!r}, " + f"build_tag={self._build_tag!r}, " f"variant={self._variant!r})" ) @@ -365,13 +370,10 @@ def to_filename(self) -> str: characters are replaced with ``_``. The version is normalized. The tag set is compressed into a wheel tag string. - :raises InvalidWheelFilename: If the tag set cannot be compressed (see - :attr:`compressed_tags`). - >>> from packaging.filenames import WheelFilename >>> from packaging.tags import Tag >>> tags = {Tag("py3", "none", "any")} - >>> WheelFilename("foo-bar", "1.0", (), tags).to_filename() + >>> WheelFilename("foo-bar", "1.0", tags).to_filename() 'foo_bar-1.0-py3-none-any.whl' """ name = self._name.replace("-", "_") diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 34bf3ef14..b23619a2a 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -436,7 +436,7 @@ def test_wheel_init( tags: set[Tag] | None, expected_filename: str, ) -> None: - fn = WheelFilename(name, version, build_tag, tags or {Tag("py3", "none", "any")}) + fn = WheelFilename(name, version, tags or {Tag("py3", "none", "any")}, build_tag) assert fn.to_filename() == expected_filename assert str(fn) == expected_filename assert fn.name == canonicalize_name(name) @@ -467,15 +467,27 @@ def test_parse_and_create_filename() -> None: ({"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", "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: - args: dict[str, typing.Any] = {"name": "foo", "version": "1.0", **kwargs} + 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={Tag("py3", "none", "any")}) + 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})" @@ -483,10 +495,10 @@ def test_wheel_init_invalid(kwargs: dict[str, typing.Any], error_message: str) - def test_wheel_replace() -> None: tags = {Tag("py3", "none", "any")} - wf = WheelFilename("Foo", "1.0", (1, "a"), tags, "x86_64_v3") + wf = WheelFilename("Foo", "1.0", tags, (1, "a"), "x86_64_v3") new = wf.__replace__(version="2.0", tags={Tag("cp314", "cp314", "win_amd64")}) assert new == WheelFilename( - "Foo", "2.0", (1, "a"), {Tag("cp314", "cp314", "win_amd64")}, "x86_64_v3" + "Foo", "2.0", {Tag("cp314", "cp314", "win_amd64")}, (1, "a"), "x86_64_v3" ) assert new.name == "foo" assert wf.__replace__(version=Version("2.0")) == wf.__replace__(version="2.0") @@ -508,10 +520,10 @@ def test_legacy_name_from_filename() -> None: def test_wheel_repr() -> None: tags = frozenset({Tag("py3", "none", "any")}) - wf = WheelFilename("Foo.Bar", "01.0", (1, "abc"), tags) + wf = WheelFilename("Foo.Bar", "01.0", tags, (1, "abc")) assert repr(wf) == ( - f"WheelFilename(name='foo-bar', version='1.0', build_tag=(1, 'abc'), " - f"tags={tags!r}, variant=None)" + f"WheelFilename(name='foo-bar', version='1.0', tags={tags!r}, " + "build_tag=(1, 'abc'), variant=None)" ) @@ -548,36 +560,20 @@ def test_sdist_repr() -> None: assert repr(fn) == "SourceDistributionFilename(name='foo', version='1.0')" -@pytest.mark.parametrize( - ("tags", "match"), - [ - (set(), "at least one tag"), - ( - {Tag("py3", "none", "any"), Tag("cp314", "cp314", "win_amd64")}, - "cannot be compressed", - ), - ], -) -def test_wheel_to_filename_invalid_tags(tags: set[Tag], match: str) -> None: - wf = WheelFilename("foo", "1.0", (), tags) - with pytest.raises(InvalidWheelFilename, match=match): - wf.to_filename() - - def test_wheel_eq_hash() -> None: tags = {Tag("py3", "none", "any")} - wf = WheelFilename("foo", "1.0", (1, ""), tags) - assert wf == WheelFilename("foo", "1.0", (1, ""), tags) - assert hash(wf) == hash(WheelFilename("foo", "1.0", (1, ""), tags)) - assert wf == WheelFilename("Foo", "1.0", (1, ""), tags) - assert hash(wf) == hash(WheelFilename("Foo", "1.0", (1, ""), tags)) - assert wf != WheelFilename("foo", "1.0.0", (1, ""), tags) - assert wf != WheelFilename("foo", "1.0", (), tags) - assert wf != WheelFilename("foo", "1.0", (1, ""), tags, "x86_64_v3") + wf = WheelFilename("foo", "1.0", tags, (1, "")) + assert wf == WheelFilename("foo", "1.0", tags, (1, "")) + assert hash(wf) == hash(WheelFilename("foo", "1.0", tags, (1, ""))) + assert wf == WheelFilename("Foo", "1.0", tags, (1, "")) + assert hash(wf) == hash(WheelFilename("Foo", "1.0", tags, (1, ""))) + assert wf != WheelFilename("foo", "1.0.0", tags, (1, "")) + assert wf != WheelFilename("foo", "1.0", tags) + assert wf != WheelFilename("foo", "1.0", tags, (1, ""), "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", (1, ""), tags) + assert raw == WheelFilename("foo_bar", "1.0", tags, (1, "")) def test_sdist_eq_hash() -> None: @@ -589,7 +585,7 @@ def test_sdist_eq_hash() -> None: 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") + assert fn != WheelFilename("foo", "1.0", {Tag("py3", "none", "any")}) @pytest.mark.parametrize("module", ["filenames", "utils"]) From d0bd4264032c70b8e871334f1bd5813b1e195050 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 10:16:37 -0400 Subject: [PATCH 35/53] feat(filenames): add a stable pickle format Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 66 ++++++++++++++++++++++++++++ tests/test_filenames.py | 90 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 4b32b0421..a5726bd58 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -204,12 +204,22 @@ def _compress_tags(tags: frozenset[Tag]) -> str: return "-".join((".".join(interpreters), ".".join(abis), ".".join(platforms))) +def _restore_version(cls: type, version: str, state: object) -> Version: + try: + return Version(version) + except InvalidVersion: + raise TypeError(f"Cannot restore {cls.__name__} from {state!r}") from None + + 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 """ @@ -349,6 +359,44 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(self._key()) + def __getstate__(self) -> tuple[str, str, tuple[str, ...], BuildTag, str | None]: + # Tags are sorted strings so the state does not depend on set order. + tags = tuple(sorted(map(str, self._tags))) + return (self._name, str(self._version), tags, self._build_tag, self._variant) + + def __setstate__(self, state: object) -> None: + if isinstance(state, tuple) and len(state) == 5: + name, version, tag_strs, build_tag, variant = state + if ( + isinstance(name, str) + and isinstance(version, str) + and isinstance(tag_strs, tuple) + and all(isinstance(t, str) and t.count("-") == 2 for t in tag_strs) + and isinstance(build_tag, tuple) + and ( + not build_tag + or ( + len(build_tag) == 2 + and isinstance(build_tag[0], int) + and isinstance(build_tag[1], str) + ) + ) + and (variant is None or isinstance(variant, str)) + ): + tags = frozenset(Tag(*t.split("-")) for t in tag_strs) + try: + _compress_tags(tags) + except InvalidWheelFilename: + pass + else: + self._name = canonicalize_name(name) + self._version = _restore_version(type(self), version, state) + self._tags = tags + self._build_tag = build_tag + self._variant = variant + return + raise TypeError(f"Cannot restore {type(self).__name__} from {state!r}") + def __str__(self) -> str: return self.to_filename() @@ -554,6 +602,9 @@ class SourceDistributionFilename: 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 """ @@ -615,6 +666,21 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(self._key()) + def __getstate__(self) -> tuple[str, str]: + return (self._name, str(self._version)) + + def __setstate__(self, state: object) -> None: + if ( + isinstance(state, tuple) + and len(state) == 2 + and isinstance(state[0], str) + and isinstance(state[1], str) + ): + self._name = canonicalize_name(state[0]) + self._version = _restore_version(type(self), state[1], state) + return + raise TypeError(f"Cannot restore {type(self).__name__} from {state!r}") + def __str__(self) -> str: return self.to_filename() diff --git a/tests/test_filenames.py b/tests/test_filenames.py index b23619a2a..50ec59c79 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -4,6 +4,7 @@ from __future__ import annotations +import pickle import subprocess import sys import typing @@ -588,6 +589,95 @@ def test_sdist_eq_hash() -> None: 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")}, + (1, "abc"), + "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, (1, "a"), "x86_64_v3") + state = ("foo", "1.0", ("py2-none-any", "py3-none-any"), (1, "a"), "x86_64_v3") + 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.whl", + ("foo", "1.0", (), ()), + (1, "1.0", (), (), None), + ("foo", 1, (), (), None), + ("foo", "x", (), (), None), + ("foo", "1.0", (), [], None), + ("foo", "1.0", ("py3-none-any",), (1,), None), + ("foo", "1.0", ("py3-none-any",), ("1", ""), None), + ("foo", "1.0", ["py3-none-any"], (), None), + ("foo", "1.0", ("py3-none",), (), None), + ("foo", "1.0", (("py3", "none", "any"),), (), None), + ("foo", "1.0", (), (), None), + ("foo", "1.0", ("py3-none-any", "cp314-cp314-win_amd64"), (), None), + ("foo", "1.0", ("py3-none-any",), (), 1), + ], +) +def test_wheel_setstate_invalid(state: object) -> None: + wf = WheelFilename.__new__(WheelFilename) + with pytest.raises(TypeError, match="Cannot restore WheelFilename"): + wf.__setstate__(state) + + +@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") + loaded = SourceDistributionFilename.__new__(SourceDistributionFilename) + loaded.__setstate__(("foo", "1.0")) + assert loaded == fn + + +@pytest.mark.parametrize( + "state", [None, "foo-1.0.tar.gz", ("foo",), (1, "1.0"), ("foo", 1), ("foo", "x")] +) +def test_sdist_setstate_invalid(state: object) -> None: + fn = SourceDistributionFilename.__new__(SourceDistributionFilename) + with pytest.raises(TypeError, match="Cannot restore SourceDistributionFilename"): + fn.__setstate__(state) + + @pytest.mark.parametrize("module", ["filenames", "utils"]) def test_import_order(module: str) -> None: code = f"import packaging.{module}; import packaging.filenames, packaging.utils" From 079c3b8acc942a5b2303540d01dcbf9ed2316b2c Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 10:36:00 -0400 Subject: [PATCH 36/53] docs(filenames): describe properties and supported operations Assisted-by: ClaudeCode:claude-opus-5-5 --- docs/filenames.rst | 43 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/filenames.rst b/docs/filenames.rst index 656ef9d68..3035360df 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -17,13 +17,13 @@ To construct a filename, use ``.from_filename`` on >>> from packaging.filenames import WheelFilename >>> wheel_filename = WheelFilename.from_filename("foo-1.0-1-py3-none-any.whl") -Parsing accepts legacy filenames. To require a normalized filename, use -:func:`~packaging.filenames.validate_wheel_filename` or -:func:`~packaging.filenames.validate_sdist_filename`. These raise -:class:`~packaging.filenames.InvalidFilename` if the filename is not valid. -They collect all normalization problems into an -:external:exc:`ExceptionGroup`. Each problem has its own exception class, so you can use ``except*`` to handle only some of them. For -example, to accept a non-normalized name and version, but reject all other +Parsing normalizes non-normalized filenames. To require a normalized filename, +use :func:`~packaging.filenames.validate_wheel_filename` or +:func:`~packaging.filenames.validate_sdist_filename`. Like constructing a +filename, these raise :class:`~packaging.filenames.InvalidFilename` if the +filename is not valid. They collect all normalization problems into an +:external:exc:`ExceptionGroup`. Each problem has its own exception class, so +you can use ``except*`` to handle only some of them. For example, to accept a non-normalized name and version, but reject all other problems: .. code-block:: python @@ -39,6 +39,35 @@ problems: 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``. + +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 as you'd 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. +* Pickling and unpickling are supported, and the pickle format is stable. +* Converting to a string (or ``.to_filename()``) produces a fully normalized + filename with the standard extension (``.whl`` or ``.tar.gz``). Converting from older utils --------------------------- From 734505c1b4d8580a57c7daa903176127709548b1 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 10:41:38 -0400 Subject: [PATCH 37/53] docs(filenames): show how to mimic validate_order=True Assisted-by: ClaudeCode:claude-opus-5-5 --- docs/filenames.rst | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/filenames.rst b/docs/filenames.rst index 3035360df..6ae3504ed 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -95,10 +95,53 @@ with: True Be sure to check and handle :attr:`~packaging.filenames.WheelFilename.variant`. + To replace ``validate_order=True``, call :func:`~packaging.filenames.validate_wheel_filename`. It reports unsorted tags with :class:`~packaging.filenames.UnsortedWheelTags`. It also checks that the -other parts of the filename are normalized. +other parts of the filename are normalized. To check only the tag order and +reject variant wheels, like ``parse_wheel_filename(..., validate_order=True)``: + +.. testcode:: + + import sys + + from packaging.filenames import ( + InvalidWheelFilename, + UnsortedWheelTags, + WheelFilename, + validate_wheel_filename, + ) + + if sys.version_info < (3, 11): + from packaging.errors import ExceptionGroup + + + def parse_ordered(filename: str) -> WheelFilename: + try: + validate_wheel_filename(filename) + except ExceptionGroup as group: + for error in group.exceptions: + if isinstance(error, UnsortedWheelTags): + raise error from None + wheel = WheelFilename.from_filename(filename) + 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 --------- From f26dd04c42904ef1b1bc5fbcc1e345fdb88fb758 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 10:54:38 -0400 Subject: [PATCH 38/53] refactor(filenames): inline single-purpose check helpers Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 39 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index a5726bd58..f57747c2a 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -174,13 +174,6 @@ def _check_normalized( collector.error(_invalid(NonNormalizedVersion, inner, filename, kind=kind)) -def _check_build_tag(build_tag: BuildTag) -> None: - if build_tag and ( - build_tag[0] < 0 or _build_suffix_regex.fullmatch(build_tag[1]) is None - ): - raise _invalid(InvalidWheelFilename, f"invalid build tag {build_tag!r}") - - def _check_variant(variant: str | None, filename: str | None = None) -> None: if variant is not None and _variant_label_regex.fullmatch(variant) is None: inner = f"invalid variant label {variant!r}" @@ -204,13 +197,6 @@ def _compress_tags(tags: frozenset[Tag]) -> str: return "-".join((".".join(interpreters), ".".join(abis), ".".join(platforms))) -def _restore_version(cls: type, version: str, state: object) -> Version: - try: - return Version(version) - except InvalidVersion: - raise TypeError(f"Cannot restore {cls.__name__} from {state!r}") from None - - class WheelFilename: """Represents a wheel filename and its parsed components. @@ -255,8 +241,13 @@ def _set(self, **kwargs: Unpack[_WheelReplace]) -> None: if "version" in kwargs: self._version = _parse_version(InvalidWheelFilename, kwargs["version"]) if "build_tag" in kwargs: - _check_build_tag(kwargs["build_tag"]) - self._build_tag = kwargs["build_tag"] + build_tag = kwargs["build_tag"] + if build_tag and ( + build_tag[0] < 0 or _build_suffix_regex.fullmatch(build_tag[1]) is None + ): + inner = f"invalid build tag {build_tag!r}" + raise _invalid(InvalidWheelFilename, inner) + self._build_tag = build_tag if "tags" in kwargs: tags = frozenset(kwargs["tags"]) _compress_tags(tags) @@ -386,11 +377,12 @@ def __setstate__(self, state: object) -> None: tags = frozenset(Tag(*t.split("-")) for t in tag_strs) try: _compress_tags(tags) - except InvalidWheelFilename: + parsed_version = Version(version) + except (InvalidWheelFilename, InvalidVersion): pass else: self._name = canonicalize_name(name) - self._version = _restore_version(type(self), version, state) + self._version = parsed_version self._tags = tags self._build_tag = build_tag self._variant = variant @@ -676,9 +668,14 @@ def __setstate__(self, state: object) -> None: and isinstance(state[0], str) and isinstance(state[1], str) ): - self._name = canonicalize_name(state[0]) - self._version = _restore_version(type(self), state[1], state) - return + try: + version = Version(state[1]) + except InvalidVersion: + pass + else: + self._name = canonicalize_name(state[0]) + self._version = version + return raise TypeError(f"Cannot restore {type(self).__name__} from {state!r}") def __str__(self) -> str: From 366497f344e7d56c41c406d84e18f117ed57411c Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 11:36:25 -0400 Subject: [PATCH 39/53] refactor(filenames): inline _invalid and short check helpers Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 190 +++++++++++++++++++++---------------- 1 file changed, 108 insertions(+), 82 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index f57747c2a..1e0d8d80f 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -118,41 +118,6 @@ def _check_replace_keys(kwargs: Mapping[str, object], allowed: frozenset[str]) - raise TypeError(msg) -def _invalid( - error: type[InvalidFilename], - inner: str, - filename: str | None = None, - *, - kind: str | None = None, -) -> InvalidFilename: - if kind is None: - kind = "wheel" if issubclass(error, InvalidWheelFilename) else "sdist" - msg = f"Invalid {kind} filename ({inner})" - if filename is not None: - msg = f"{msg}: {filename!r}" - return error(msg) - - -def _check_name( - error: type[InvalidFilename], name: str, filename: str | None = None -) -> NormalizedName: - try: - return canonicalize_name(name, validate=True) - except InvalidName: - raise _invalid(error, f"invalid project name {name!r}", filename) from None - - -def _parse_version( - error: type[InvalidFilename], version: Version | str, filename: str | None = None -) -> Version: - if isinstance(version, Version): - return version - try: - return Version(version) - except InvalidVersion as e: - raise _invalid(error, f"invalid version {version!r}", filename) from e - - def _check_normalized( collector: _ErrorCollector, error: type[InvalidFilename], @@ -166,34 +131,35 @@ def _check_normalized( An invalid name is raised directly. """ kind = "wheel" if issubclass(error, InvalidWheelFilename) else "sdist" - if name != _check_name(error, name, filename).replace("-", "_"): + try: + normalized = canonicalize_name(name, validate=True) + except InvalidName: + msg = f"Invalid {kind} filename (invalid project name {name!r}): {filename!r}" + raise error(msg) from None + if name != normalized.replace("-", "_"): inner = f"non-normalized project name {name!r}" - collector.error(_invalid(NonNormalizedName, inner, filename, kind=kind)) + msg = f"Invalid {kind} filename ({inner}): {filename!r}" + collector.error(NonNormalizedName(msg)) if version_part != str(version): inner = f"non-normalized version {version_part!r}" - collector.error(_invalid(NonNormalizedVersion, inner, filename, kind=kind)) - - -def _check_variant(variant: str | None, filename: str | None = None) -> None: - if variant is not None and _variant_label_regex.fullmatch(variant) is None: - inner = f"invalid variant label {variant!r}" - raise _invalid(InvalidWheelFilename, inner, filename) + msg = f"Invalid {kind} filename ({inner}): {filename!r}" + collector.error(NonNormalizedVersion(msg)) def _compress_tags(tags: frozenset[Tag]) -> str: if not tags: - inner = "the tag set must have at least one tag" - raise _invalid(InvalidWheelFilename, inner) + 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): - inner = ( - "the tag set cannot be compressed, it must contain every combination: " - f"{sorted(map(str, tags))!r}" + msg = ( + "Invalid wheel filename (the tag set cannot be compressed, it must " + f"contain every combination: {sorted(map(str, tags))!r})" ) - raise _invalid(InvalidWheelFilename, inner) + raise InvalidWheelFilename(msg) return "-".join((".".join(interpreters), ".".join(abis), ".".join(platforms))) @@ -237,24 +203,39 @@ def __init__( def _set(self, **kwargs: Unpack[_WheelReplace]) -> None: if "name" in kwargs: - self._name = _check_name(InvalidWheelFilename, kwargs["name"]) + 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: - self._version = _parse_version(InvalidWheelFilename, kwargs["version"]) + 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"] if build_tag and ( build_tag[0] < 0 or _build_suffix_regex.fullmatch(build_tag[1]) is None ): - inner = f"invalid build tag {build_tag!r}" - raise _invalid(InvalidWheelFilename, inner) + 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"]) _compress_tags(tags) self._tags = tags if "variant" in kwargs: - _check_variant(kwargs["variant"]) - self._variant = kwargs["variant"] + 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: """ @@ -468,10 +449,23 @@ def _from_parts( # See PEP 427 for the rules on escaping the project name. if "__" in parts.name or _wheel_name_regex.fullmatch(parts.name) is None: inner = f"invalid project name {parts.name!r}" - raise _invalid(InvalidWheelFilename, inner, filename) + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) + + try: + version = Version(parts.version) + except InvalidVersion as e: + inner = f"invalid version {parts.version!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) from e - version = _parse_version(InvalidWheelFilename, parts.version, filename) - _check_variant(parts.variant, filename) + if ( + parts.variant is not None + and _variant_label_regex.fullmatch(parts.variant) is None + ): + inner = f"invalid variant label {parts.variant!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) # The split already checked that the build part starts with a digit. build_tag: BuildTag = () @@ -504,17 +498,19 @@ def _split_wheel_filename(filename: str, *, variants: bool = True) -> _WheelPart build number is rejected. """ if not filename.endswith(".whl"): - raise _invalid(InvalidWheelFilename, "extension must be '.whl'", filename) + msg = f"Invalid wheel filename (extension must be '.whl'): {filename!r}" + raise InvalidWheelFilename(msg) parts = filename[:-4].split("-") if len(parts) not in {5, 6, 7}: - raise _invalid(InvalidWheelFilename, "wrong number of parts", filename) + msg = f"Invalid wheel filename (wrong number of parts): {filename!r}" + raise InvalidWheelFilename(msg) if len(parts) == 7 and not variants: - inner = ( - "variant wheels are not supported, " - "use packaging.filenames.WheelFilename instead for support" + msg = ( + "Invalid wheel filename (variant wheels are not supported, " + f"use packaging.filenames.WheelFilename instead for support): {filename!r}" ) - raise _invalid(InvalidWheelFilename, inner, filename) + raise InvalidWheelFilename(msg) name, version_part, *rest = parts @@ -522,12 +518,14 @@ def _split_wheel_filename(filename: str, *, variants: bool = True) -> _WheelPart # A build tag starts with a digit, and a Python tag never does. has_build = len(rest) > 3 and _build_tag_regex.match(rest[0]) is not None if len(rest) == 5 and not has_build: - raise _invalid( - InvalidWheelFilename, f"invalid build number {rest[0]!r}", filename - ) + msg = f"Invalid wheel filename (invalid build number {rest[0]!r}): {filename!r}" + raise InvalidWheelFilename(msg) if len(rest) == 4 and not has_build and not variants: - inner = "invalid build number or unsupported variant label" - raise _invalid(InvalidWheelFilename, inner, filename) + msg = ( + "Invalid wheel filename (invalid build number or unsupported " + f"variant label): {filename!r}" + ) + raise InvalidWheelFilename(msg) build_part = rest.pop(0) if has_build else None variant = rest.pop() if len(rest) == 4 else None return _WheelParts(name, version_part, build_part, "-".join(rest), variant) @@ -537,11 +535,15 @@ def _parse_tags(filename: str, tag_str: str, *, validate_order: bool) -> frozens try: return parse_tag(tag_str, validate_order=validate_order) except UnsortedTagsError: - inner = "compressed tag set components must be in sorted order per PEP 425" - raise _invalid(UnsortedWheelTags, inner, filename) from None + 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}" - raise _invalid(InvalidWheelFilename, inner, filename) from None + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) from None def validate_wheel_filename(filename: str, /) -> None: @@ -580,12 +582,14 @@ def validate_wheel_filename(filename: str, /) -> None: ) if parts.build is not None and parts.build != wf.build_str: inner = f"non-normalized build tag {parts.build!r}" - collector.error(_invalid(NonNormalizedBuildTag, inner, filename)) + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedBuildTag(msg)) with collector.collect(UnsortedWheelTags): _parse_tags(filename, parts.tags, validate_order=True) if wf.compressed_tags != parts.tags: inner = f"non-normalized tags {parts.tags!r}" - collector.error(_invalid(NonNormalizedTags, inner, filename)) + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedTags(msg)) class SourceDistributionFilename: @@ -614,9 +618,21 @@ def __init__(self, name: str, version: Version | str) -> None: def _set(self, **kwargs: Unpack[_SdistReplace]) -> None: if "name" in kwargs: - self._name = _check_name(InvalidSdistFilename, kwargs["name"]) + 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: - self._version = _parse_version(InvalidSdistFilename, kwargs["version"]) + 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: """ @@ -733,9 +749,15 @@ def from_filename(cls, filename: str, /) -> Self: @classmethod def _from_parts(cls, filename: str, name_part: str, version_part: str) -> Self: """Build from split parts, accepting legacy names and versions.""" + 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 self = cls.__new__(cls) self._name = canonicalize_name(name_part) - self._version = _parse_version(InvalidSdistFilename, version_part, filename) + self._version = version return self @@ -749,7 +771,8 @@ def _split_sdist_filename(filename: str) -> tuple[str, str]: file_stem = filename[: -len(".zip")] else: inner = "extension must be '.tar.gz' or '.zip'" - raise _invalid(InvalidSdistFilename, inner, filename) + 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 @@ -757,9 +780,11 @@ def _split_sdist_filename(filename: str) -> tuple[str, str]: name_part, sep, version_part = file_stem.rpartition("-") if not sep: inner = "hyphen must separate name and version parts" - raise _invalid(InvalidSdistFilename, inner, filename) + msg = f"Invalid sdist filename ({inner}): {filename!r}" + raise InvalidSdistFilename(msg) if not name_part: - raise _invalid(InvalidSdistFilename, "empty project name", filename) + msg = f"Invalid sdist filename (empty project name): {filename!r}" + raise InvalidSdistFilename(msg) return name_part, version_part @@ -785,7 +810,8 @@ def validate_sdist_filename(filename: str, /) -> None: .. versionadded:: 26.4 """ if not filename.endswith(".tar.gz"): - raise _invalid(InvalidSdistFilename, "extension must be '.tar.gz'", filename) + msg = f"Invalid sdist filename (extension must be '.tar.gz'): {filename!r}" + raise InvalidSdistFilename(msg) name_part, version_part = _split_sdist_filename(filename) fn = SourceDistributionFilename._from_parts(filename, name_part, version_part) with _ErrorCollector().on_exit( From 17ae3d4f14cd75cd3bdcea480f9ae75dcc6df796 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 11:41:09 -0400 Subject: [PATCH 40/53] refactor(filenames): inline _check_replace_keys Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 1e0d8d80f..4acacc0d7 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -21,7 +21,7 @@ from .version import InvalidVersion, Version if TYPE_CHECKING: - from collections.abc import Iterable, Mapping + from collections.abc import Iterable from typing_extensions import Self, Unpack @@ -112,12 +112,6 @@ class _WheelReplace(TypedDict, total=False): variant: str | None -def _check_replace_keys(kwargs: Mapping[str, object], allowed: frozenset[str]) -> None: - if extra := kwargs.keys() - allowed: - msg = f"__replace__() got unexpected keyword arguments: {sorted(extra)}" - raise TypeError(msg) - - def _check_normalized( collector: _ErrorCollector, error: type[InvalidFilename], @@ -252,7 +246,9 @@ def __replace__(self, **kwargs: Unpack[_WheelReplace]) -> Self: :raises InvalidWheelFilename: If a replaced part is not valid. """ - _check_replace_keys(kwargs, _WheelReplace.__optional_keys__) + if extra := kwargs.keys() - _WheelReplace.__optional_keys__: + msg = f"__replace__() got unexpected keyword arguments: {sorted(extra)}" + raise TypeError(msg) new = copy.copy(self) new._set(**kwargs) return new @@ -648,7 +644,9 @@ def __replace__(self, **kwargs: Unpack[_SdistReplace]) -> Self: :raises InvalidSdistFilename: If a replaced part is not valid. """ - _check_replace_keys(kwargs, _SdistReplace.__optional_keys__) + if extra := kwargs.keys() - _SdistReplace.__optional_keys__: + msg = f"__replace__() got unexpected keyword arguments: {sorted(extra)}" + raise TypeError(msg) new = copy.copy(self) new._set(**kwargs) return new From 7670b8130720ab7bdc5866973c75f84f80801d00 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 11:48:55 -0400 Subject: [PATCH 41/53] fix(filenames): reject wheel tags that do not round-trip Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 11 +++++++++++ tests/test_filenames.py | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 4acacc0d7..e0bedef47 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -186,6 +186,8 @@ def __init__( :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, @@ -222,6 +224,15 @@ def _set(self, **kwargs: Unpack[_WheelReplace]) -> None: 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: diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 50ec59c79..df4dbdc57 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -469,6 +469,10 @@ def test_parse_and_create_filename() -> None: ({"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: " From a38607581104f3572c2703ee1fbc226b891c0b72 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 11:48:57 -0400 Subject: [PATCH 42/53] refactor(filenames): use the filename string as pickle state Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 67 ++++++++------------------------------ tests/test_filenames.py | 43 ++++++++++-------------- 2 files changed, 31 insertions(+), 79 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index e0bedef47..380bb1559 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -338,44 +338,15 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(self._key()) - def __getstate__(self) -> tuple[str, str, tuple[str, ...], BuildTag, str | None]: - # Tags are sorted strings so the state does not depend on set order. - tags = tuple(sorted(map(str, self._tags))) - return (self._name, str(self._version), tags, self._build_tag, self._variant) + def __getstate__(self) -> str: + return self.to_filename() def __setstate__(self, state: object) -> None: - if isinstance(state, tuple) and len(state) == 5: - name, version, tag_strs, build_tag, variant = state - if ( - isinstance(name, str) - and isinstance(version, str) - and isinstance(tag_strs, tuple) - and all(isinstance(t, str) and t.count("-") == 2 for t in tag_strs) - and isinstance(build_tag, tuple) - and ( - not build_tag - or ( - len(build_tag) == 2 - and isinstance(build_tag[0], int) - and isinstance(build_tag[1], str) - ) - ) - and (variant is None or isinstance(variant, str)) - ): - tags = frozenset(Tag(*t.split("-")) for t in tag_strs) - try: - _compress_tags(tags) - parsed_version = Version(version) - except (InvalidWheelFilename, InvalidVersion): - pass - else: - self._name = canonicalize_name(name) - self._version = parsed_version - self._tags = tags - self._build_tag = build_tag - self._variant = variant - return - raise TypeError(f"Cannot restore {type(self).__name__} from {state!r}") + if not isinstance(state, str): + raise TypeError(f"Cannot restore {type(self).__name__} from {state!r}") + other = WheelFilename.from_filename(state) + for attr in WheelFilename.__slots__: + setattr(self, attr, getattr(other, attr)) def __str__(self) -> str: return self.to_filename() @@ -683,25 +654,15 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(self._key()) - def __getstate__(self) -> tuple[str, str]: - return (self._name, str(self._version)) + def __getstate__(self) -> str: + return self.to_filename() def __setstate__(self, state: object) -> None: - if ( - isinstance(state, tuple) - and len(state) == 2 - and isinstance(state[0], str) - and isinstance(state[1], str) - ): - try: - version = Version(state[1]) - except InvalidVersion: - pass - else: - self._name = canonicalize_name(state[0]) - self._version = version - return - raise TypeError(f"Cannot restore {type(self).__name__} from {state!r}") + if not isinstance(state, str): + raise TypeError(f"Cannot restore {type(self).__name__} from {state!r}") + other = SourceDistributionFilename.from_filename(state) + for attr in SourceDistributionFilename.__slots__: + setattr(self, attr, getattr(other, attr)) def __str__(self) -> str: return self.to_filename() diff --git a/tests/test_filenames.py b/tests/test_filenames.py index df4dbdc57..c1adb4541 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -617,39 +617,26 @@ def test_wheel_pickle(wf: WheelFilename, protocol: int) -> None: def test_wheel_pickle_state() -> None: tags = {Tag("py3", "none", "any"), Tag("py2", "none", "any")} wf = WheelFilename("Foo", "01.0", tags, (1, "a"), "x86_64_v3") - state = ("foo", "1.0", ("py2-none-any", "py3-none-any"), (1, "a"), "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.whl", - ("foo", "1.0", (), ()), - (1, "1.0", (), (), None), - ("foo", 1, (), (), None), - ("foo", "x", (), (), None), - ("foo", "1.0", (), [], None), - ("foo", "1.0", ("py3-none-any",), (1,), None), - ("foo", "1.0", ("py3-none-any",), ("1", ""), None), - ("foo", "1.0", ["py3-none-any"], (), None), - ("foo", "1.0", ("py3-none",), (), None), - ("foo", "1.0", (("py3", "none", "any"),), (), None), - ("foo", "1.0", (), (), None), - ("foo", "1.0", ("py3-none-any", "cp314-cp314-win_amd64"), (), None), - ("foo", "1.0", ("py3-none-any",), (), 1), - ], -) +@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", [ @@ -667,21 +654,25 @@ def test_sdist_pickle(fn: SourceDistributionFilename, protocol: int) -> None: def test_sdist_pickle_state() -> None: fn = SourceDistributionFilename("Foo", "01.0") - assert fn.__getstate__() == ("foo", "1.0") + assert fn.__getstate__() == "foo-1.0.tar.gz" loaded = SourceDistributionFilename.__new__(SourceDistributionFilename) - loaded.__setstate__(("foo", "1.0")) + loaded.__setstate__("foo-1.0.tar.gz") assert loaded == fn -@pytest.mark.parametrize( - "state", [None, "foo-1.0.tar.gz", ("foo",), (1, "1.0"), ("foo", 1), ("foo", "x")] -) +@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_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" From 73f6f84c0307a85d77d9477dba88a9b0975b2056 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 12:06:57 -0400 Subject: [PATCH 43/53] refactor(filenames): inline _check_normalized Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 72 +++++++++++++++----------------------- 1 file changed, 28 insertions(+), 44 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 380bb1559..d065c1830 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -112,34 +112,6 @@ class _WheelReplace(TypedDict, total=False): variant: str | None -def _check_normalized( - collector: _ErrorCollector, - error: type[InvalidFilename], - name: str, - version_part: str, - version: Version, - filename: str, -) -> None: - """Collect errors if the name and version parts are not normalized. - - An invalid name is raised directly. - """ - kind = "wheel" if issubclass(error, InvalidWheelFilename) else "sdist" - try: - normalized = canonicalize_name(name, validate=True) - except InvalidName: - msg = f"Invalid {kind} filename (invalid project name {name!r}): {filename!r}" - raise error(msg) from None - if name != normalized.replace("-", "_"): - inner = f"non-normalized project name {name!r}" - msg = f"Invalid {kind} filename ({inner}): {filename!r}" - collector.error(NonNormalizedName(msg)) - if version_part != str(version): - inner = f"non-normalized version {version_part!r}" - msg = f"Invalid {kind} filename ({inner}): {filename!r}" - collector.error(NonNormalizedVersion(msg)) - - def _compress_tags(tags: frozenset[Tag]) -> str: if not tags: msg = "Invalid wheel filename (the tag set must have at least one tag)" @@ -550,14 +522,20 @@ def validate_wheel_filename(filename: str, /) -> None: with _ErrorCollector().on_exit( f"Non-normalized wheel filename: {filename!r}" ) as collector: - _check_normalized( - collector, - InvalidWheelFilename, - parts.name, - parts.version, - wf.version, - filename, - ) + try: + normalized = canonicalize_name(parts.name, validate=True) + except InvalidName: + inner = f"invalid project name {parts.name!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) from None + if parts.name != normalized.replace("-", "_"): + inner = f"non-normalized project name {parts.name!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedName(msg)) + if parts.version != str(wf.version): + inner = f"non-normalized version {parts.version!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedVersion(msg)) if parts.build is not None and parts.build != wf.build_str: inner = f"non-normalized build tag {parts.build!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" @@ -787,11 +765,17 @@ def validate_sdist_filename(filename: str, /) -> None: with _ErrorCollector().on_exit( f"Non-normalized sdist filename: {filename!r}" ) as collector: - _check_normalized( - collector, - InvalidSdistFilename, - name_part, - version_part, - fn.version, - filename, - ) + try: + normalized = canonicalize_name(name_part, validate=True) + except InvalidName: + inner = f"invalid project name {name_part!r}" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + raise InvalidSdistFilename(msg) from None + if name_part != normalized.replace("-", "_"): + inner = f"non-normalized project name {name_part!r}" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + collector.error(NonNormalizedName(msg)) + if version_part != str(fn.version): + inner = f"non-normalized version {version_part!r}" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + collector.error(NonNormalizedVersion(msg)) From 3e2562185621ae6235da130e70c53733b9fd1a28 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 12:08:44 -0400 Subject: [PATCH 44/53] docs: touch up filenames Signed-off-by: Henry Schreiner --- docs/filenames.rst | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/filenames.rst b/docs/filenames.rst index 6ae3504ed..e98d2fd9f 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -17,14 +17,14 @@ To construct a filename, use ``.from_filename`` on >>> from packaging.filenames import WheelFilename >>> wheel_filename = WheelFilename.from_filename("foo-1.0-1-py3-none-any.whl") -Parsing normalizes non-normalized filenames. To require a normalized filename, +Parsing normalizes filenames. To ensure a filename is already normalized, use :func:`~packaging.filenames.validate_wheel_filename` or :func:`~packaging.filenames.validate_sdist_filename`. Like constructing a filename, these raise :class:`~packaging.filenames.InvalidFilename` if the filename is not valid. They collect all normalization problems into an :external:exc:`ExceptionGroup`. Each problem has its own exception class, so -you can use ``except*`` to handle only some of them. For example, to accept a non-normalized name and version, but reject all other -problems: +you can use ``except*`` to handle or ignore only some of them. For example, to +accept a non-normalized name and version, but reject all other problems: .. code-block:: python @@ -59,7 +59,7 @@ 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 as you'd expect: +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. @@ -94,13 +94,12 @@ with: >>> wheel.variant is None True -Be sure to check and handle :attr:`~packaging.filenames.WheelFilename.variant`. - To replace ``validate_order=True``, call :func:`~packaging.filenames.validate_wheel_filename`. 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)``: +reject variant wheels, like ``parse_wheel_filename(..., validate_order=True)``, +this time showing Python 3.10+ compatible syntax: .. testcode:: From f3ff6085522b07a2c34ad9ae2a1a614b88d5f92c Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 12:22:41 -0400 Subject: [PATCH 45/53] fix(filenames): collect invalid project names when validating Names that parsing accepts but that are not valid project names are now reported as NonNormalizedName in the ExceptionGroup, and not raised as InvalidFilename. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 18 ++++++++++-------- tests/test_filenames.py | 7 ++++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index d065c1830..3f8ec3a2d 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -527,11 +527,12 @@ def validate_wheel_filename(filename: str, /) -> None: except InvalidName: inner = f"invalid project name {parts.name!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) from None - if parts.name != normalized.replace("-", "_"): - inner = f"non-normalized project name {parts.name!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" collector.error(NonNormalizedName(msg)) + else: + if parts.name != normalized.replace("-", "_"): + inner = f"non-normalized project name {parts.name!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedName(msg)) if parts.version != str(wf.version): inner = f"non-normalized version {parts.version!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" @@ -770,11 +771,12 @@ def validate_sdist_filename(filename: str, /) -> None: except InvalidName: inner = f"invalid project name {name_part!r}" msg = f"Invalid sdist filename ({inner}): {filename!r}" - raise InvalidSdistFilename(msg) from None - if name_part != normalized.replace("-", "_"): - inner = f"non-normalized project name {name_part!r}" - msg = f"Invalid sdist filename ({inner}): {filename!r}" collector.error(NonNormalizedName(msg)) + else: + if name_part != normalized.replace("-", "_"): + inner = f"non-normalized project name {name_part!r}" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + collector.error(NonNormalizedName(msg)) if version_part != str(fn.version): inner = f"non-normalized version {version_part!r}" msg = f"Invalid sdist filename ({inner}): {filename!r}" diff --git a/tests/test_filenames.py b/tests/test_filenames.py index c1adb4541..835db8a7a 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -97,7 +97,7 @@ def test_sdist_init(name: str, version: str, expected_filename: str) -> None: ( ".invalid.name-1.0.tar.gz", # Name is not valid "Invalid sdist filename (invalid project name '.invalid.name')", - InvalidSdistFilename, + NonNormalizedName, ), ( "invalid.name-1.0.tar.gz", # Name is not canonical (punctuation) @@ -337,12 +337,12 @@ def test_wheel_from_filename_variant( ( "_foo-1.0-py3-none-any.whl", # Leading underscore "Invalid wheel filename (invalid project name '_foo')", - InvalidWheelFilename, + NonNormalizedName, ), ( "\u00e9-1.0-py3-none-any.whl", # Non-ASCII name "Invalid wheel filename (invalid project name '\u00e9')", - InvalidWheelFilename, + NonNormalizedName, ), ( "foo-01.0-py3-none-any.whl", # Non-normalized version @@ -402,6 +402,7 @@ def test_validate_wheel_filename_invalid( ], ), ("foo-01.0-py3.py3-none-any.whl", [NonNormalizedVersion, NonNormalizedTags]), + ("_foo-01.0-py3-none-any.whl", [NonNormalizedName, NonNormalizedVersion]), ], ) def test_validate_wheel_filename_multiple_errors( From 7a2547e78dfe08bafd3f87692bcb407838cc9677 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 13:19:26 -0400 Subject: [PATCH 46/53] fix(filenames): validate build tags consistently and split name errors Parse, validate, and the constructor now share one build tag regex, so suffixes with spaces, newlines, or non-ASCII characters are rejected everywhere, and a non-int build number is rejected in the constructor. Add InvalidProjectName for names that parse but are not valid project names, so NonNormalizedName only covers valid names. Parse tags once in validate_wheel_filename, use type(self) in __setstate__, and document the six-part filename ambiguity rule. Assisted-by: ClaudeCode:claude-fable-5-1 --- docs/filenames.rst | 12 ++++++- src/packaging/filenames.py | 66 ++++++++++++++++++++++++++------------ tests/test_filenames.py | 26 ++++++++++++--- 3 files changed, 79 insertions(+), 25 deletions(-) diff --git a/docs/filenames.rst b/docs/filenames.rst index e98d2fd9f..a02403528 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -17,12 +17,22 @@ To construct a filename, use ``.from_filename`` on >>> from packaging.filenames import WheelFilename >>> wheel_filename = WheelFilename.from_filename("foo-1.0-1-py3-none-any.whl") +A wheel filename with six parts is ambiguous: the extra part is either a +build tag or a variant label (:pep:`825`). A build tag must start with a +digit, and a Python tag never does, so a third part that starts with a digit +is a build tag. Otherwise, the last part is a variant label. For example, +``foo-1.0-abc-py3-none-any.whl`` has the tag ``abc-py3-none`` and the variant +label ``any``. + Parsing normalizes filenames. To ensure a filename is already normalized, use :func:`~packaging.filenames.validate_wheel_filename` or :func:`~packaging.filenames.validate_sdist_filename`. Like constructing a filename, these raise :class:`~packaging.filenames.InvalidFilename` if the filename is not valid. They collect all normalization problems into an -:external:exc:`ExceptionGroup`. Each problem has its own exception class, so +:external:exc:`ExceptionGroup`. Each problem has its own exception class, such +as :class:`~packaging.filenames.NonNormalizedName` for a valid name that is not +normalized and :class:`~packaging.filenames.InvalidProjectName` for a name that +parses but is not a valid project name, so you can use ``except*`` to handle or ignore only some of them. For example, to accept a non-normalized name and version, but reject all other problems: diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 3f8ec3a2d..aeeb9eb14 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -30,6 +30,7 @@ __all__ = [ "BuildTag", "InvalidFilename", + "InvalidProjectName", "InvalidSdistFilename", "InvalidWheelFilename", "NonNormalizedBuildTag", @@ -48,16 +49,24 @@ def __dir__() -> list[str]: return __all__ -# PEP 427: The build number must start with a digit. -_build_tag_regex = re.compile(r"(\d+)(.*)", re.ASCII) -# A build tag suffix must not start with a digit, so the number is unambiguous. -_build_suffix_regex = re.compile(r"(?:[a-z_.][a-z0-9_.]*)?", re.ASCII | re.IGNORECASE) +# 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 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. @@ -112,6 +121,14 @@ class _WheelReplace(TypedDict, total=False): variant: str | None +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 "") + + def _compress_tags(tags: frozenset[Tag]) -> str: if not tags: msg = "Invalid wheel filename (the tag set must have at least one tag)" @@ -188,8 +205,10 @@ def _set(self, **kwargs: Unpack[_WheelReplace]) -> None: 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 ( - build_tag[0] < 0 or _build_suffix_regex.fullmatch(build_tag[1]) is None + 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) @@ -316,7 +335,7 @@ def __getstate__(self) -> str: def __setstate__(self, state: object) -> None: if not isinstance(state, str): raise TypeError(f"Cannot restore {type(self).__name__} from {state!r}") - other = WheelFilename.from_filename(state) + other = type(self).from_filename(state) for attr in WheelFilename.__slots__: setattr(self, attr, getattr(other, attr)) @@ -417,12 +436,13 @@ def _from_parts( msg = f"Invalid wheel filename ({inner}): {filename!r}" raise InvalidWheelFilename(msg) - # The split already checked that the build part starts with a digit. build_tag: BuildTag = () if parts.build is not None: - build_match = _build_tag_regex.match(parts.build) - assert build_match is not None - build_tag = (int(build_match[1]), build_match[2]) + build_tag = _parse_build_tag(parts.build) + if not build_tag: + inner = f"invalid build tag {parts.build!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + raise InvalidWheelFilename(msg) self = cls.__new__(cls) self._name = canonicalize_name(parts.name) @@ -466,7 +486,7 @@ def _split_wheel_filename(filename: str, *, variants: bool = True) -> _WheelPart # 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. - has_build = len(rest) > 3 and _build_tag_regex.match(rest[0]) is not None + has_build = len(rest) > 3 and rest[0][:1].isdigit() if len(rest) == 5 and not has_build: msg = f"Invalid wheel filename (invalid build number {rest[0]!r}): {filename!r}" raise InvalidWheelFilename(msg) @@ -527,7 +547,7 @@ def validate_wheel_filename(filename: str, /) -> None: except InvalidName: inner = f"invalid project name {parts.name!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" - collector.error(NonNormalizedName(msg)) + collector.error(InvalidProjectName(msg)) else: if parts.name != normalized.replace("-", "_"): inner = f"non-normalized project name {parts.name!r}" @@ -541,12 +561,18 @@ def validate_wheel_filename(filename: str, /) -> None: inner = f"non-normalized build tag {parts.build!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" collector.error(NonNormalizedBuildTag(msg)) - with collector.collect(UnsortedWheelTags): - _parse_tags(filename, parts.tags, validate_order=True) - if wf.compressed_tags != parts.tags: - inner = f"non-normalized tags {parts.tags!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - collector.error(NonNormalizedTags(msg)) + # The tags were parsed already, so only the order needs a check here. + components = [part.split(".") for part in parts.tags.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 wf.compressed_tags != parts.tags: + inner = f"non-normalized tags {parts.tags!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedTags(msg)) class SourceDistributionFilename: @@ -639,7 +665,7 @@ def __getstate__(self) -> str: def __setstate__(self, state: object) -> None: if not isinstance(state, str): raise TypeError(f"Cannot restore {type(self).__name__} from {state!r}") - other = SourceDistributionFilename.from_filename(state) + other = type(self).from_filename(state) for attr in SourceDistributionFilename.__slots__: setattr(self, attr, getattr(other, attr)) @@ -771,7 +797,7 @@ def validate_sdist_filename(filename: str, /) -> None: except InvalidName: inner = f"invalid project name {name_part!r}" msg = f"Invalid sdist filename ({inner}): {filename!r}" - collector.error(NonNormalizedName(msg)) + collector.error(InvalidProjectName(msg)) else: if name_part != normalized.replace("-", "_"): inner = f"non-normalized project name {name_part!r}" diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 835db8a7a..634526bb9 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -14,6 +14,7 @@ from packaging.errors import ExceptionGroup from packaging.filenames import ( InvalidFilename, + InvalidProjectName, InvalidSdistFilename, InvalidWheelFilename, NonNormalizedBuildTag, @@ -97,7 +98,7 @@ def test_sdist_init(name: str, version: str, expected_filename: str) -> None: ( ".invalid.name-1.0.tar.gz", # Name is not valid "Invalid sdist filename (invalid project name '.invalid.name')", - NonNormalizedName, + InvalidProjectName, ), ( "invalid.name-1.0.tar.gz", # Name is not canonical (punctuation) @@ -337,12 +338,27 @@ def test_wheel_from_filename_variant( ( "_foo-1.0-py3-none-any.whl", # Leading underscore "Invalid wheel filename (invalid project name '_foo')", - NonNormalizedName, + InvalidProjectName, ), ( "\u00e9-1.0-py3-none-any.whl", # Non-ASCII name "Invalid wheel filename (invalid project name '\u00e9')", - NonNormalizedName, + 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 @@ -402,7 +418,7 @@ def test_validate_wheel_filename_invalid( ], ), ("foo-01.0-py3.py3-none-any.whl", [NonNormalizedVersion, NonNormalizedTags]), - ("_foo-01.0-py3-none-any.whl", [NonNormalizedName, NonNormalizedVersion]), + ("_foo-01.0-py3-none-any.whl", [InvalidProjectName, NonNormalizedVersion]), ], ) def test_validate_wheel_filename_multiple_errors( @@ -466,6 +482,8 @@ def test_parse_and_create_filename() -> None: ({"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}"), From 14c0fdf1beda39f1615f0b18976c793979020325 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 13:22:09 -0400 Subject: [PATCH 47/53] fix(filenames): do not re-parse the filename in __replace__ and copy copy.copy fell through to __getstate__ and __setstate__, so every __replace__ call serialized and re-parsed the filename. The instances are immutable, so __copy__ and __deepcopy__ return self, and __replace__ copies the slots directly. Assisted-by: ClaudeCode:claude-fable-5-1 --- src/packaging/filenames.py | 21 ++++++++++++++++++--- tests/test_filenames.py | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index aeeb9eb14..4dd7a6ef2 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -4,7 +4,6 @@ from __future__ import annotations -import copy import re from typing import TYPE_CHECKING, NamedTuple, TypedDict @@ -251,7 +250,9 @@ def __replace__(self, **kwargs: Unpack[_WheelReplace]) -> Self: if extra := kwargs.keys() - _WheelReplace.__optional_keys__: msg = f"__replace__() got unexpected keyword arguments: {sorted(extra)}" raise TypeError(msg) - new = copy.copy(self) + new = type(self).__new__(type(self)) + for attr in WheelFilename.__slots__: + setattr(new, attr, getattr(self, attr)) new._set(**kwargs) return new @@ -339,6 +340,12 @@ def __setstate__(self, state: object) -> None: 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() @@ -634,7 +641,9 @@ def __replace__(self, **kwargs: Unpack[_SdistReplace]) -> Self: if extra := kwargs.keys() - _SdistReplace.__optional_keys__: msg = f"__replace__() got unexpected keyword arguments: {sorted(extra)}" raise TypeError(msg) - new = copy.copy(self) + new = type(self).__new__(type(self)) + for attr in SourceDistributionFilename.__slots__: + setattr(new, attr, getattr(self, attr)) new._set(**kwargs) return new @@ -669,6 +678,12 @@ def __setstate__(self, state: object) -> None: 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() diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 634526bb9..68b59fdcc 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -4,6 +4,7 @@ from __future__ import annotations +import copy import pickle import subprocess import sys @@ -517,6 +518,24 @@ def test_wheel_init_invalid(kwargs: dict[str, typing.Any], error_message: str) - 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, (1, "a"), "x86_64_v3") From e9c6e1ea4cfdd20f415bcabd22f9641c95b55e50 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 17:44:22 -0400 Subject: [PATCH 48/53] perf(filenames): parse into tuples and move parsers to utils Split wheel filenames with maxsplit instead of splitting and joining all parts, and return plain tuples instead of a NamedTuple. The classes wrap tuple-returning parsers, so the legacy parse_*_filename functions no longer build and unpack an object. The parsers live in utils, which removes the function-local import (about 200 ns per call) and the circular import. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 222 ++++++------------------------------- src/packaging/utils.py | 174 +++++++++++++++++++++++++++-- 2 files changed, 196 insertions(+), 200 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 4dd7a6ef2..61eed01ce 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -4,17 +4,22 @@ from __future__ import annotations -import re -from typing import TYPE_CHECKING, NamedTuple, TypedDict +from typing import TYPE_CHECKING, TypedDict from .errors import _ErrorCollector -from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag +from .tags import InvalidTag, Tag, parse_tag from .utils import ( BuildTag, InvalidFilename, InvalidName, InvalidSdistFilename, InvalidWheelFilename, + UnsortedWheelTags, + _parse_build_tag, + _parse_sdist_filename, + _parse_wheel_parts, + _split_wheel_filename, + _variant_label_regex, canonicalize_name, ) from .version import InvalidVersion, Version @@ -48,15 +53,6 @@ def __dir__() -> list[str]: return __all__ -# 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 InvalidProjectName(InvalidFilename): """ The project name in a filename can be parsed, but is not a valid project @@ -98,15 +94,6 @@ class NonNormalizedTags(InvalidWheelFilename): """ -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 - """ - - class _SdistReplace(TypedDict, total=False): name: str version: Version | str @@ -120,14 +107,6 @@ class _WheelReplace(TypedDict, total=False): variant: str | None -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 "") - - def _compress_tags(tags: frozenset[Tag]) -> str: if not tags: msg = "Invalid wheel filename (the tag set must have at least one tag)" @@ -413,116 +392,17 @@ def from_filename(cls, filename: str, /) -> Self: >>> wf.tags == {Tag("py3", "none", "any")} True """ - return cls._from_parts(filename, _split_wheel_filename(filename)) - - @classmethod - def _from_parts( - cls, filename: str, parts: _WheelParts, *, validate_order: bool = False - ) -> Self: - """Build from split parts, accepting legacy names and versions.""" - tags = _parse_tags(filename, parts.tags, validate_order=validate_order) - - # See PEP 427 for the rules on escaping the project name. - if "__" in parts.name or _wheel_name_regex.fullmatch(parts.name) is None: - inner = f"invalid project name {parts.name!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) - - try: - version = Version(parts.version) - except InvalidVersion as e: - inner = f"invalid version {parts.version!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) from e - - if ( - parts.variant is not None - and _variant_label_regex.fullmatch(parts.variant) is None - ): - inner = f"invalid variant label {parts.variant!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) - - build_tag: BuildTag = () - if parts.build is not None: - build_tag = _parse_build_tag(parts.build) - if not build_tag: - inner = f"invalid build tag {parts.build!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - raise InvalidWheelFilename(msg) - self = cls.__new__(cls) - self._name = canonicalize_name(parts.name) - self._version = version - self._build_tag = build_tag - self._tags = tags - self._variant = parts.variant + ( + self._name, + self._version, + self._build_tag, + self._tags, + self._variant, + ) = _parse_wheel_parts(filename, _split_wheel_filename(filename)) return self -class _WheelParts(NamedTuple): - name: str - version: str - build: str | None - tags: str - variant: 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) - - parts = filename[:-4].split("-") - if len(parts) not in {5, 6, 7}: - msg = f"Invalid wheel filename (wrong number of parts): {filename!r}" - raise InvalidWheelFilename(msg) - if len(parts) == 7 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, *rest = parts - - # 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. - has_build = len(rest) > 3 and rest[0][:1].isdigit() - if len(rest) == 5 and not has_build: - msg = f"Invalid wheel filename (invalid build number {rest[0]!r}): {filename!r}" - raise InvalidWheelFilename(msg) - if len(rest) == 4 and not has_build and not variants: - msg = ( - "Invalid wheel filename (invalid build number or unsupported " - f"variant label): {filename!r}" - ) - raise InvalidWheelFilename(msg) - build_part = rest.pop(0) if has_build else None - variant = rest.pop() if len(rest) == 4 else None - return _WheelParts(name, version_part, build_part, "-".join(rest), variant) - - -def _parse_tags(filename: str, tag_str: str, *, validate_order: bool) -> frozenset[Tag]: - try: - return 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 - - def validate_wheel_filename(filename: str, /) -> None: """ Check that a wheel filename is valid and in its normalized form. @@ -545,39 +425,40 @@ def validate_wheel_filename(filename: str, /) -> None: .. versionadded:: 26.4 """ parts = _split_wheel_filename(filename) - wf = WheelFilename._from_parts(filename, parts) + _, version, build_tag, tags, _ = _parse_wheel_parts(filename, parts) + name, version_part, build, tag_str, _ = parts with _ErrorCollector().on_exit( f"Non-normalized wheel filename: {filename!r}" ) as collector: try: - normalized = canonicalize_name(parts.name, validate=True) + normalized = canonicalize_name(name, validate=True) except InvalidName: - inner = f"invalid project name {parts.name!r}" + inner = f"invalid project name {name!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" collector.error(InvalidProjectName(msg)) else: - if parts.name != normalized.replace("-", "_"): - inner = f"non-normalized project name {parts.name!r}" + if name != normalized.replace("-", "_"): + inner = f"non-normalized project name {name!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" collector.error(NonNormalizedName(msg)) - if parts.version != str(wf.version): - inner = f"non-normalized version {parts.version!r}" + if version_part != str(version): + inner = f"non-normalized version {version_part!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" collector.error(NonNormalizedVersion(msg)) - if parts.build is not None and parts.build != wf.build_str: - inner = f"non-normalized build tag {parts.build!r}" + if build is not None and build != "".join(map(str, build_tag)): + 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, so only the order needs a check here. - components = [part.split(".") for part in parts.tags.split("-")] + 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 wf.compressed_tags != parts.tags: - inner = f"non-normalized tags {parts.tags!r}" + elif _compress_tags(tags) != tag_str: + inner = f"non-normalized tags {tag_str!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" collector.error(NonNormalizedTags(msg)) @@ -734,50 +615,11 @@ def from_filename(cls, filename: str, /) -> Self: .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name """ - return cls._from_parts(filename, *_split_sdist_filename(filename)) - - @classmethod - def _from_parts(cls, filename: str, name_part: str, version_part: str) -> Self: - """Build from split parts, accepting legacy names and versions.""" - 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 self = cls.__new__(cls) - self._name = canonicalize_name(name_part) - self._version = version + self._name, self._version = _parse_sdist_filename(filename) return self -def _split_sdist_filename(filename: str) -> tuple[str, str]: - """Split a sdist filename into name and version parts.""" - # 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) - return name_part, version_part - - def validate_sdist_filename(filename: str, /) -> None: """ Check that a sdist filename is valid and in its normalized form. @@ -802,8 +644,8 @@ def validate_sdist_filename(filename: str, /) -> None: if not filename.endswith(".tar.gz"): msg = f"Invalid sdist filename (extension must be '.tar.gz'): {filename!r}" raise InvalidSdistFilename(msg) - name_part, version_part = _split_sdist_filename(filename) - fn = SourceDistributionFilename._from_parts(filename, name_part, version_part) + _, version = _parse_sdist_filename(filename) + name_part, _, version_part = filename[: -len(".tar.gz")].rpartition("-") with _ErrorCollector().on_exit( f"Non-normalized sdist filename: {filename!r}" ) as collector: @@ -818,7 +660,7 @@ def validate_sdist_filename(filename: str, /) -> None: inner = f"non-normalized project name {name_part!r}" msg = f"Invalid sdist filename ({inner}): {filename!r}" collector.error(NonNormalizedName(msg)) - if version_part != str(fn.version): + if version_part != str(version): inner = f"non-normalized version {version_part!r}" msg = f"Invalid sdist filename ({inner}): {filename!r}" collector.error(NonNormalizedVersion(msg)) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 3dba0132e..3284cc5e9 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -7,8 +7,7 @@ import re from typing import NewType, cast -# The tags names are unused, but stay importable from here for compatibility. -from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag # noqa: F401, TC001 +from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag from .version import InvalidVersion, Version, _TrimmedRelease __all__ = [ @@ -202,6 +201,164 @@ 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 "") + + +def _parse_wheel_parts( + filename: str, parts: _WheelParts, *, validate_order: bool = False +) -> tuple[NormalizedName, Version, BuildTag, frozenset[Tag], str | None]: + """Parse split parts, accepting legacy names and versions.""" + name, version_part, build, tag_str, variant = parts + 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 + + +# 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_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, *, @@ -258,11 +415,11 @@ def parse_wheel_filename( not an identifier, a tag set component is empty, or the project name is empty. """ - from .filenames import WheelFilename, _split_wheel_filename # noqa: PLC0415 - parts = _split_wheel_filename(filename, variants=False) - fname = WheelFilename._from_parts(filename, parts, validate_order=validate_order) - return (fname.name, fname.version, fname.build_tag, fname.tags) + name, version, build_tag, tags, _ = _parse_wheel_parts( + filename, parts, validate_order=validate_order + ) + return (name, version, build_tag, tags) def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: @@ -300,7 +457,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 """ - from .filenames import SourceDistributionFilename # noqa: PLC0415 - - fname = SourceDistributionFilename.from_filename(filename) - return (fname.name, fname.version) + return _parse_sdist_filename(filename) From 5885a4b67694043e502b81ab0398821a02087852 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 18:21:12 -0400 Subject: [PATCH 49/53] refactor(filenames): replace validate_*_filename with .validate() methods Parsed filenames keep the original string, so validate() checks it without parsing again. Instances that are constructed or made with __replace__ have no original filename and always pass. Pickles keep the original filename, so validation gives the same result after unpickling. Assisted-by: ClaudeCode:claude-opus-5-5 --- docs/filenames.rst | 32 +++--- src/packaging/filenames.py | 210 +++++++++++++++++++------------------ tests/test_filenames.py | 48 +++++++-- 3 files changed, 161 insertions(+), 129 deletions(-) diff --git a/docs/filenames.rst b/docs/filenames.rst index a02403528..6db7c3952 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -25,11 +25,8 @@ is a build tag. Otherwise, the last part is a variant label. For example, label ``any``. Parsing normalizes filenames. To ensure a filename is already normalized, -use :func:`~packaging.filenames.validate_wheel_filename` or -:func:`~packaging.filenames.validate_sdist_filename`. Like constructing a -filename, these raise :class:`~packaging.filenames.InvalidFilename` if the -filename is not valid. They collect all normalization problems into an -:external:exc:`ExceptionGroup`. Each problem has its own exception class, such +call ``.validate()`` on the parsed filename. It collects all normalization +problems into an :external:exc:`ExceptionGroup`. Each problem has its own exception class, such as :class:`~packaging.filenames.NonNormalizedName` for a valid name that is not normalized and :class:`~packaging.filenames.InvalidProjectName` for a name that parses but is not a valid project name, so @@ -41,11 +38,12 @@ accept a non-normalized name and version, but reject all other problems: from packaging.filenames import ( NonNormalizedName, NonNormalizedVersion, - validate_wheel_filename, + WheelFilename, ) + wheel = WheelFilename.from_filename("Foo-01.0-py3-none-any.whl") try: - validate_wheel_filename("Foo-01.0-py3-none-any.whl") + wheel.validate() except* (NonNormalizedName, NonNormalizedVersion): pass @@ -74,8 +72,14 @@ 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. + 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``). @@ -105,9 +109,10 @@ with: True To replace ``validate_order=True``, call -:func:`~packaging.filenames.validate_wheel_filename`. 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 +: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: @@ -119,7 +124,6 @@ this time showing Python 3.10+ compatible syntax: InvalidWheelFilename, UnsortedWheelTags, WheelFilename, - validate_wheel_filename, ) if sys.version_info < (3, 11): @@ -127,13 +131,13 @@ this time showing Python 3.10+ compatible syntax: def parse_ordered(filename: str) -> WheelFilename: + wheel = WheelFilename.from_filename(filename) try: - validate_wheel_filename(filename) + wheel.validate() except ExceptionGroup as group: for error in group.exceptions: if isinstance(error, UnsortedWheelTags): raise error from None - wheel = WheelFilename.from_filename(filename) if wheel.variant is not None: msg = f"Invalid wheel filename (variant wheels are not supported): {filename!r}" raise InvalidWheelFilename(msg) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 61eed01ce..4738ef7b0 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -44,8 +44,6 @@ "SourceDistributionFilename", "UnsortedWheelTags", "WheelFilename", - "validate_sdist_filename", - "validate_wheel_filename", ] @@ -136,7 +134,7 @@ class WheelFilename: .. versionadded:: 26.4 """ - __slots__ = ("_build_tag", "_name", "_tags", "_variant", "_version") + __slots__ = ("_build_tag", "_filename", "_name", "_tags", "_variant", "_version") __match_args__ = ("name", "version", "tags", "build_tag", "variant") def __init__( @@ -160,6 +158,7 @@ def __init__( :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 ) @@ -232,6 +231,7 @@ def __replace__(self, **kwargs: Unpack[_WheelReplace]) -> Self: 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 @@ -310,7 +310,7 @@ def __hash__(self) -> int: return hash(self._key()) def __getstate__(self) -> str: - return self.to_filename() + return self._filename or self.to_filename() def __setstate__(self, state: object) -> None: if not isinstance(state, str): @@ -372,8 +372,8 @@ def from_filename(cls, filename: str, /) -> Self: 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. Use - :func:`validate_wheel_filename` first to require a normalized filename. + 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 @@ -393,6 +393,7 @@ def from_filename(cls, filename: str, /) -> Self: True """ self = cls.__new__(cls) + self._filename = filename ( self._name, self._version, @@ -402,65 +403,64 @@ def from_filename(cls, filename: str, /) -> Self: ) = _parse_wheel_parts(filename, _split_wheel_filename(filename)) return self + def validate(self) -> None: + """ + Check that the filename this was parsed from is in its normalized form. -def validate_wheel_filename(filename: str, /) -> None: - """ - Check that a wheel filename is valid and 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`. - - 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. + 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. - :param str filename: The name of the wheel file. - :raises InvalidWheelFilename: If the filename is not valid. - :raises ExceptionGroup: If the filename is not 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. - >>> from packaging.filenames import validate_wheel_filename - >>> validate_wheel_filename("foo-1.0-py3-none-any.whl") + :raises ExceptionGroup: If the filename is not normalized. - .. versionadded:: 26.4 - """ - parts = _split_wheel_filename(filename) - _, version, build_tag, tags, _ = _parse_wheel_parts(filename, parts) - name, version_part, build, tag_str, _ = parts - with _ErrorCollector().on_exit( - f"Non-normalized wheel filename: {filename!r}" - ) as collector: - try: - normalized = canonicalize_name(name, validate=True) - except InvalidName: - inner = f"invalid project name {name!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - collector.error(InvalidProjectName(msg)) - else: - if name != normalized.replace("-", "_"): - inner = f"non-normalized project name {name!r}" + >>> 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) + with _ErrorCollector().on_exit( + f"Non-normalized wheel filename: {filename!r}" + ) as collector: + try: + normalized = canonicalize_name(name, validate=True) + except InvalidName: + inner = f"invalid project name {name!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" - collector.error(NonNormalizedName(msg)) - if version_part != str(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 != "".join(map(str, build_tag)): - 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, so only the order needs a check here. - 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 _compress_tags(tags) != tag_str: - inner = f"non-normalized tags {tag_str!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - collector.error(NonNormalizedTags(msg)) + collector.error(InvalidProjectName(msg)) + else: + if name != normalized.replace("-", "_"): + inner = f"non-normalized project name {name!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedName(msg)) + if version_part != str(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 != "".join(map(str, self._build_tag)): + 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, so only the order needs a check here. + 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 _compress_tags(self._tags) != tag_str: + inner = f"non-normalized tags {tag_str!r}" + msg = f"Invalid wheel filename ({inner}): {filename!r}" + collector.error(NonNormalizedTags(msg)) class SourceDistributionFilename: @@ -475,7 +475,7 @@ class SourceDistributionFilename: .. versionadded:: 26.4 """ - __slots__ = ("_name", "_version") + __slots__ = ("_filename", "_name", "_version") __match_args__ = ("name", "version") def __init__(self, name: str, version: Version | str) -> None: @@ -485,6 +485,7 @@ def __init__(self, name: str, version: Version | str) -> None: :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: @@ -525,6 +526,7 @@ def __replace__(self, **kwargs: Unpack[_SdistReplace]) -> Self: 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 @@ -550,7 +552,7 @@ def __hash__(self) -> int: return hash(self._key()) def __getstate__(self) -> str: - return self.to_filename() + return self._filename or self.to_filename() def __setstate__(self, state: object) -> None: if not isinstance(state, str): @@ -596,8 +598,8 @@ def from_filename(cls, filename: str, /) -> Self: in the `Source distribution format`_ documentation), and parses it into a :class:`SourceDistributionFilename`. - Parsing accepts legacy names, versions, and the ``.zip`` extension. Use - :func:`validate_sdist_filename` first to require a normalized filename. + 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 @@ -616,51 +618,51 @@ def from_filename(cls, filename: str, /) -> Self: .. _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. -def validate_sdist_filename(filename: str, /) -> None: - """ - Check that a sdist filename is valid and in its normalized form. - - The extension must be ``.tar.gz``, and the name and version must be - 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. + 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. - :param str filename: The name of the sdist file. - :raises InvalidSdistFilename: If the filename is not valid, or if the - extension is not ``.tar.gz``. - :raises ExceptionGroup: If the filename is not 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. - >>> from packaging.filenames import validate_sdist_filename - >>> validate_sdist_filename("foo-1.0.tar.gz") + :raises InvalidSdistFilename: If the extension is not ``.tar.gz``. + :raises ExceptionGroup: If the filename is not normalized. - .. versionadded:: 26.4 - """ - if not filename.endswith(".tar.gz"): - msg = f"Invalid sdist filename (extension must be '.tar.gz'): {filename!r}" - raise InvalidSdistFilename(msg) - _, version = _parse_sdist_filename(filename) - name_part, _, version_part = filename[: -len(".tar.gz")].rpartition("-") - with _ErrorCollector().on_exit( - f"Non-normalized sdist filename: {filename!r}" - ) as collector: - try: - normalized = canonicalize_name(name_part, validate=True) - except InvalidName: - inner = f"invalid project name {name_part!r}" - msg = f"Invalid sdist filename ({inner}): {filename!r}" - collector.error(InvalidProjectName(msg)) - else: - if name_part != normalized.replace("-", "_"): - inner = f"non-normalized project name {name_part!r}" + >>> from packaging.filenames import SourceDistributionFilename + >>> SourceDistributionFilename.from_filename("foo-1.0.tar.gz").validate() + """ + filename = self._filename + if filename is None: + return + if not filename.endswith(".tar.gz"): + msg = f"Invalid sdist filename (extension must be '.tar.gz'): {filename!r}" + raise InvalidSdistFilename(msg) + name_part, _, version_part = filename[: -len(".tar.gz")].rpartition("-") + with _ErrorCollector().on_exit( + f"Non-normalized sdist filename: {filename!r}" + ) as collector: + try: + normalized = canonicalize_name(name_part, validate=True) + except InvalidName: + inner = f"invalid project name {name_part!r}" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + collector.error(InvalidProjectName(msg)) + else: + if name_part != normalized.replace("-", "_"): + inner = f"non-normalized project name {name_part!r}" + msg = f"Invalid sdist filename ({inner}): {filename!r}" + collector.error(NonNormalizedName(msg)) + if version_part != str(self._version): + inner = f"non-normalized version {version_part!r}" msg = f"Invalid sdist filename ({inner}): {filename!r}" - collector.error(NonNormalizedName(msg)) - if version_part != str(version): - inner = f"non-normalized version {version_part!r}" - msg = f"Invalid sdist filename ({inner}): {filename!r}" - collector.error(NonNormalizedVersion(msg)) + collector.error(NonNormalizedVersion(msg)) diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 68b59fdcc..4dff08bf1 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -25,8 +25,6 @@ SourceDistributionFilename, UnsortedWheelTags, WheelFilename, - validate_sdist_filename, - validate_wheel_filename, ) from packaging.tags import Tag from packaging.utils import canonicalize_name @@ -69,7 +67,7 @@ def test_sdist_init(name: str, version: str, expected_filename: str) -> None: assert str(fn) == expected_filename assert fn.name == canonicalize_name(name) assert fn.version == Version(version) - validate_sdist_filename(expected_filename) + SourceDistributionFilename.from_filename(expected_filename).validate() assert SourceDistributionFilename.from_filename(expected_filename) == fn @@ -78,7 +76,7 @@ def test_sdist_init(name: str, version: str, expected_filename: str) -> None: [ ( "bad.extension", # Bad extension - "Invalid sdist filename (extension must be '.tar.gz')", + "Invalid sdist filename (extension must be '.tar.gz' or '.zip')", InvalidSdistFilename, ), ( @@ -132,7 +130,7 @@ def test_validate_sdist_filename_invalid( filename: str, error_message: str, error_type: type[InvalidFilename] ) -> None: with pytest.raises((InvalidFilename, ExceptionGroup)) as e: - validate_sdist_filename(filename) + SourceDistributionFilename.from_filename(filename).validate() if isinstance(e.value, ExceptionGroup): assert e.value.message == f"Non-normalized sdist filename: {filename!r}" @@ -145,7 +143,7 @@ def test_validate_sdist_filename_invalid( def test_validate_sdist_filename_multiple_errors() -> None: with pytest.raises(ExceptionGroup) as e: - validate_sdist_filename("Foo-01.0.tar.gz") + SourceDistributionFilename.from_filename("Foo-01.0.tar.gz").validate() assert [type(error) for error in e.value.exceptions] == [ NonNormalizedName, @@ -215,8 +213,8 @@ def test_sdist_from_filename_invalid_extension() -> None: def test_wheel_from_filename( filename: str, name: str, version: Version, build_tag: BuildTag, tags: set[Tag] ) -> None: - validate_wheel_filename(filename) fn = WheelFilename.from_filename(filename) + fn.validate() assert fn.name == name assert fn.version == version assert fn.build_tag == build_tag @@ -268,8 +266,8 @@ def test_wheel_from_filename( def test_wheel_from_filename_variant( filename: str, name: str, build_tag: BuildTag, tags: set[Tag], variant: str ) -> None: - validate_wheel_filename(filename) fn = WheelFilename.from_filename(filename) + fn.validate() assert fn.name == name assert fn.build_tag == build_tag assert fn.tags == tags @@ -395,7 +393,7 @@ def test_validate_wheel_filename_invalid( filename: str, error_message: str, error_type: type[InvalidFilename] ) -> None: with pytest.raises((InvalidFilename, ExceptionGroup)) as e: - validate_wheel_filename(filename) + WheelFilename.from_filename(filename).validate() if isinstance(e.value, ExceptionGroup): assert e.value.message == f"Non-normalized wheel filename: {filename!r}" @@ -426,7 +424,7 @@ def test_validate_wheel_filename_multiple_errors( filename: str, error_types: list[type[InvalidFilename]] ) -> None: with pytest.raises(ExceptionGroup) as e: - validate_wheel_filename(filename) + WheelFilename.from_filename(filename).validate() assert [type(error) for error in e.value.exceptions] == error_types @@ -460,7 +458,7 @@ def test_wheel_init( assert str(fn) == expected_filename assert fn.name == canonicalize_name(name) assert fn.version == Version(version) - validate_wheel_filename(expected_filename) + WheelFilename.from_filename(expected_filename).validate() assert WheelFilename.from_filename(expected_filename) == fn @@ -705,6 +703,34 @@ def test_sdist_setstate_invalid(state: object) -> None: fn.__setstate__(state) +def test_validate_constructed() -> None: + tags = {Tag("py3", "none", "any")} + WheelFilename("Foo", "01.0", tags, (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(InvalidSdistFilename): + fn.validate() + fn.__replace__().validate() + + +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): From 3798403075d56137667bf5a7c0c26d243e30fccb Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 23 Sep 2026 18:29:34 -0400 Subject: [PATCH 50/53] refactor(filenames): merge wheel split and parse helpers Also reuse the build_str and compressed_tags properties in validate. Assisted-by: ClaudeCode:claude-opus-5-5 --- docs/filenames.rst | 2 +- src/packaging/filenames.py | 9 ++-- src/packaging/utils.py | 101 +++++++++++++++++++------------------ 3 files changed, 56 insertions(+), 56 deletions(-) diff --git a/docs/filenames.rst b/docs/filenames.rst index 6db7c3952..1c6c5471a 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -8,7 +8,7 @@ Tools to work with filenames for SDists and wheels. Working with filenames ---------------------- -To construct a filename, use ``.from_filename`` on +To parse a filename, use ``.from_filename`` on :class:`~packaging.filenames.WheelFilename` or :class:`~packaging.filenames.SourceDistributionFilename`: diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 4738ef7b0..14fcb5820 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -17,7 +17,7 @@ UnsortedWheelTags, _parse_build_tag, _parse_sdist_filename, - _parse_wheel_parts, + _parse_wheel_filename, _split_wheel_filename, _variant_label_regex, canonicalize_name, @@ -400,7 +400,7 @@ def from_filename(cls, filename: str, /) -> Self: self._build_tag, self._tags, self._variant, - ) = _parse_wheel_parts(filename, _split_wheel_filename(filename)) + ) = _parse_wheel_filename(filename) return self def validate(self) -> None: @@ -445,7 +445,7 @@ def validate(self) -> None: 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 != "".join(map(str, self._build_tag)): + 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)) @@ -457,7 +457,7 @@ def validate(self) -> None: f"in sorted order per PEP 425): {filename!r}" ) collector.error(UnsortedWheelTags(msg)) - elif _compress_tags(self._tags) != tag_str: + elif self.compressed_tags != tag_str: inner = f"non-normalized tags {tag_str!r}" msg = f"Invalid wheel filename ({inner}): {filename!r}" collector.error(NonNormalizedTags(msg)) @@ -583,7 +583,6 @@ def to_filename(self) -> str: 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' diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 3284cc5e9..5c4750b52 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -227,53 +227,6 @@ def _parse_build_tag(build: str) -> BuildTag: return (int(build_match[1]), build_match[2] or "") -def _parse_wheel_parts( - filename: str, parts: _WheelParts, *, validate_order: bool = False -) -> tuple[NormalizedName, Version, BuildTag, frozenset[Tag], str | None]: - """Parse split parts, accepting legacy names and versions.""" - name, version_part, build, tag_str, variant = parts - 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 - - # 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"] @@ -326,6 +279,55 @@ def _split_wheel_filename(filename: str, *, variants: bool = True) -> _WheelPart 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 @@ -415,9 +417,8 @@ def parse_wheel_filename( not an identifier, a tag set component is empty, or the project name is empty. """ - parts = _split_wheel_filename(filename, variants=False) - name, version, build_tag, tags, _ = _parse_wheel_parts( - filename, parts, validate_order=validate_order + name, version, build_tag, tags, _ = _parse_wheel_filename( + filename, variants=False, validate_order=validate_order ) return (name, version, build_tag, tags) From 785a92ab5de97a11a2b1ccd23e7a206c8b1c0999 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 24 Sep 2026 10:26:32 -0400 Subject: [PATCH 51/53] feat(filenames): add original_filename and group sdist .zip error Add an original_filename property to WheelFilename and SourceDistributionFilename. SourceDistributionFilename.validate() now reports a .zip extension as InvalidSdistFilename inside the ExceptionGroup, together with the other problems. Document all validate() exception classes. Assisted-by: ClaudeCode:claude-opus-5-5 --- docs/filenames.rst | 40 ++++++++++++++++++++++++-------------- src/packaging/filenames.py | 40 +++++++++++++++++++++++++++++++++----- tests/test_filenames.py | 29 ++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 21 deletions(-) diff --git a/docs/filenames.rst b/docs/filenames.rst index 1c6c5471a..10db0b456 100644 --- a/docs/filenames.rst +++ b/docs/filenames.rst @@ -17,21 +17,29 @@ To parse a filename, use ``.from_filename`` on >>> from packaging.filenames import WheelFilename >>> wheel_filename = WheelFilename.from_filename("foo-1.0-1-py3-none-any.whl") -A wheel filename with six parts is ambiguous: the extra part is either a -build tag or a variant label (:pep:`825`). A build tag must start with a -digit, and a Python tag never does, so a third part that starts with a digit -is a build tag. Otherwise, the last part is a variant label. For example, -``foo-1.0-abc-py3-none-any.whl`` has the tag ``abc-py3-none`` and the variant -label ``any``. - -Parsing normalizes filenames. To ensure a filename is 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, such -as :class:`~packaging.filenames.NonNormalizedName` for a valid name that is not -normalized and :class:`~packaging.filenames.InvalidProjectName` for a name that -parses but is not a valid project name, so -you can use ``except*`` to handle or ignore only some of them. For example, to -accept a non-normalized name and version, but reject all other problems: +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 @@ -61,6 +69,8 @@ Filenames have the following properties: * ``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: diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 14fcb5820..b72830d76 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -292,6 +292,19 @@ def build_str(self) -> str: """ 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, @@ -540,6 +553,19 @@ 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)) @@ -634,7 +660,6 @@ def validate(self) -> None: class, such as :class:`NonNormalizedName`, so ``except*`` can select the checks to act on. - :raises InvalidSdistFilename: If the extension is not ``.tar.gz``. :raises ExceptionGroup: If the filename is not normalized. >>> from packaging.filenames import SourceDistributionFilename @@ -643,13 +668,18 @@ def validate(self) -> None: filename = self._filename if filename is None: return - if not filename.endswith(".tar.gz"): - msg = f"Invalid sdist filename (extension must be '.tar.gz'): {filename!r}" - raise InvalidSdistFilename(msg) - name_part, _, version_part = filename[: -len(".tar.gz")].rpartition("-") + ext = ".tar.gz" if filename.endswith(".tar.gz") else ".zip" + stem = filename[: -len(ext)] + name_part, _, version_part = stem.rpartition("-") with _ErrorCollector().on_exit( f"Non-normalized sdist filename: {filename!r}" ) as collector: + if ext != ".tar.gz": + msg = ( + "Invalid sdist filename (extension must be '.tar.gz'): " + f"{filename!r}" + ) + collector.error(InvalidSdistFilename(msg)) try: normalized = canonicalize_name(name_part, validate=True) except InvalidName: diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 4dff08bf1..a5c50a562 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -151,6 +151,17 @@ def test_validate_sdist_filename_multiple_errors() -> None: ] +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") @@ -715,11 +726,27 @@ def test_validate_after_replace() -> None: wf.validate() wf.__replace__(build_tag=()).validate() fn = SourceDistributionFilename.from_filename("Foo-01.0.zip") - with pytest.raises(InvalidSdistFilename): + 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" From dd7421db143a57bf005a0367a3c6e7d0eafe05c4 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 24 Sep 2026 11:03:27 -0400 Subject: [PATCH 52/53] perf(filenames): speed up validate() Avoid the on_exit context manager, and check common normalized names, versions, and tags without re-normalizing them. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 111 +++++++++++++++++++++---------------- tests/test_filenames.py | 31 +++++++++++ 2 files changed, 94 insertions(+), 48 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index b72830d76..157d1559f 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -4,6 +4,7 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING, TypedDict from .errors import _ErrorCollector @@ -19,6 +20,7 @@ _parse_sdist_filename, _parse_wheel_filename, _split_wheel_filename, + _validate_regex, _variant_label_regex, canonicalize_name, ) @@ -105,6 +107,19 @@ class _WheelReplace(TypedDict, total=False): 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)" @@ -440,40 +455,43 @@ def validate(self) -> None: if filename is None: return name, version_part, build, tag_str, _ = _split_wheel_filename(filename) - with _ErrorCollector().on_exit( - f"Non-normalized wheel filename: {filename!r}" - ) as collector: - try: - normalized = canonicalize_name(name, validate=True) - except InvalidName: + # 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: - if name != normalized.replace("-", "_"): - inner = f"non-normalized project name {name!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - collector.error(NonNormalizedName(msg)) - if version_part != str(self._version): - inner = f"non-normalized version {version_part!r}" + inner = f"non-normalized project name {name!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, so only the order needs a check here. - 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 self.compressed_tags != tag_str: - inner = f"non-normalized tags {tag_str!r}" - msg = f"Invalid wheel filename ({inner}): {filename!r}" - collector.error(NonNormalizedTags(msg)) + 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: @@ -671,27 +689,24 @@ def validate(self) -> None: ext = ".tar.gz" if filename.endswith(".tar.gz") else ".zip" stem = filename[: -len(ext)] name_part, _, version_part = stem.rpartition("-") - with _ErrorCollector().on_exit( - f"Non-normalized sdist filename: {filename!r}" - ) as collector: - if ext != ".tar.gz": - msg = ( - "Invalid sdist filename (extension must be '.tar.gz'): " - f"{filename!r}" - ) - collector.error(InvalidSdistFilename(msg)) - try: - normalized = canonicalize_name(name_part, validate=True) - except InvalidName: + # 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: - if name_part != normalized.replace("-", "_"): - inner = f"non-normalized project name {name_part!r}" - msg = f"Invalid sdist filename ({inner}): {filename!r}" - collector.error(NonNormalizedName(msg)) - if version_part != str(self._version): - inner = f"non-normalized version {version_part!r}" + inner = f"non-normalized project name {name_part!r}" msg = f"Invalid sdist filename ({inner}): {filename!r}" - collector.error(NonNormalizedVersion(msg)) + 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/tests/test_filenames.py b/tests/test_filenames.py index a5c50a562..25623d849 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -286,6 +286,27 @@ def test_wheel_from_filename_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"), [ @@ -398,6 +419,16 @@ def test_wheel_from_filename_variant( "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( From 011866650eb11ec4857510b54186d92cdb5f14b8 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 24 Sep 2026 14:44:17 -0400 Subject: [PATCH 53/53] feat(filenames)!: make WheelFilename build_tag and variant keyword-only Also drop them from __match_args__. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/packaging/filenames.py | 3 ++- tests/test_filenames.py | 40 +++++++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py index 157d1559f..769ff6d10 100644 --- a/src/packaging/filenames.py +++ b/src/packaging/filenames.py @@ -150,13 +150,14 @@ class WheelFilename: """ __slots__ = ("_build_tag", "_filename", "_name", "_tags", "_variant", "_version") - __match_args__ = ("name", "version", "tags", "build_tag", "variant") + __match_args__ = ("name", "version", "tags") def __init__( self, name: str, version: Version | str, tags: Iterable[Tag], + *, build_tag: BuildTag = (), variant: str | None = None, ) -> None: diff --git a/tests/test_filenames.py b/tests/test_filenames.py index 25623d849..47c108b6a 100644 --- a/tests/test_filenames.py +++ b/tests/test_filenames.py @@ -495,7 +495,9 @@ def test_wheel_init( tags: set[Tag] | None, expected_filename: str, ) -> None: - fn = WheelFilename(name, version, tags or {Tag("py3", "none", "any")}, build_tag) + 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) @@ -578,10 +580,14 @@ def test_replace_does_not_reparse(monkeypatch: pytest.MonkeyPatch) -> None: def test_wheel_replace() -> None: tags = {Tag("py3", "none", "any")} - wf = WheelFilename("Foo", "1.0", tags, (1, "a"), "x86_64_v3") + 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")}, (1, "a"), "x86_64_v3" + "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") @@ -603,7 +609,7 @@ def test_legacy_name_from_filename() -> None: def test_wheel_repr() -> None: tags = frozenset({Tag("py3", "none", "any")}) - wf = WheelFilename("Foo.Bar", "01.0", tags, (1, "abc")) + 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)" @@ -645,18 +651,20 @@ def test_sdist_repr() -> None: def test_wheel_eq_hash() -> None: tags = {Tag("py3", "none", "any")} - wf = WheelFilename("foo", "1.0", tags, (1, "")) - assert wf == WheelFilename("foo", "1.0", tags, (1, "")) - assert hash(wf) == hash(WheelFilename("foo", "1.0", tags, (1, ""))) - assert wf == WheelFilename("Foo", "1.0", tags, (1, "")) - assert hash(wf) == hash(WheelFilename("Foo", "1.0", tags, (1, ""))) - assert wf != WheelFilename("foo", "1.0.0", tags, (1, "")) + 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, (1, ""), "x86_64_v3") + 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, (1, "")) + assert raw == WheelFilename("foo_bar", "1.0", tags, build_tag=(1, "")) def test_sdist_eq_hash() -> None: @@ -679,8 +687,8 @@ def test_sdist_eq_hash() -> None: "foo", "1.0", {Tag("py2", "none", "any"), Tag("py3", "none", "any")}, - (1, "abc"), - "x86_64_v3", + build_tag=(1, "abc"), + variant="x86_64_v3", ), WheelFilename.from_filename("_foo.-1.0-py3-none-any.whl"), ], @@ -694,7 +702,7 @@ def test_wheel_pickle(wf: WheelFilename, protocol: int) -> None: def test_wheel_pickle_state() -> None: tags = {Tag("py3", "none", "any"), Tag("py2", "none", "any")} - wf = WheelFilename("Foo", "01.0", tags, (1, "a"), "x86_64_v3") + 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) @@ -747,7 +755,7 @@ def test_sdist_setstate_invalid(state: object) -> None: def test_validate_constructed() -> None: tags = {Tag("py3", "none", "any")} - WheelFilename("Foo", "01.0", tags, (1, "")).validate() + WheelFilename("Foo", "01.0", tags, build_tag=(1, "")).validate() SourceDistributionFilename("Foo", "01.0").validate()