From 5f7413a7e8ca81a8cfc3a21c47881694435d652a Mon Sep 17 00:00:00 2001 From: Eldon Allred Date: Fri, 5 Mar 2021 23:30:01 -0500 Subject: [PATCH 1/5] 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 9d66be888..78531e963 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, Tuple, Union, cast +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 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", @@ -61,6 +66,7 @@ class InvalidSdistFilename(ValueError): _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) +_distribution_regex = re.compile(r"[^\w\d.]+", re.ASCII) def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName: @@ -154,6 +160,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, ) -> tuple[NormalizedName, Version, BuildTag, frozenset[Tag]]: @@ -229,6 +285,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 2f269edc1..b20b233b7 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, @@ -106,6 +109,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"), [ @@ -177,6 +226,17 @@ def test_parse_wheel_invalid_filename(filename: str) -> None: parse_wheel_filename(filename) +@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 bf03cfc9a409f1ddfe750fe4a1cca86a762ab104 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 11:49:53 -0500 Subject: [PATCH 2/5] 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 78531e963..719731ea8 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -197,7 +197,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, ...] @@ -287,7 +287,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 @@ -299,7 +302,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 b20b233b7..56a70ae23 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -120,8 +120,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 9fd76992f7ae27f81b7e6a8a1c06e72ac24fd271 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 11:56:57 -0500 Subject: [PATCH 3/5] 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 719731ea8..def4464a2 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", @@ -168,7 +168,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: """ @@ -187,12 +187,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 @@ -285,7 +285,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 ``-._`` @@ -295,9 +295,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 56a70ae23..6be180a3e 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, @@ -149,10 +149,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( @@ -233,8 +233,8 @@ def test_parse_wheel_invalid_filename(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 d3dd41c75985ec9fd1211d326a50ca38a2a270af Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 12:04:51 -0500 Subject: [PATCH 4/5] 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 6be180a3e..bf123224d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -226,6 +226,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", "name", "version"), [ From 14f738e3b7223e78f1189c0ee99a82310fd0ccba Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 12:11:32 -0500 Subject: [PATCH 5/5] 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 def4464a2..e101d67ef 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", @@ -160,16 +160,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