From 58e5b5b791ed32de0936c74ce9c356f310f2c280 Mon Sep 17 00:00:00 2001 From: Eldon Allred Date: Fri, 5 Mar 2021 23:30:01 -0500 Subject: [PATCH 1/6] Added packaging.utils.create_wheel_filename and create_sdist_filename functions --- docs/utils.rst | 41 +++++++++++++++++++++++++++++ src/packaging/utils.py | 36 ++++++++++++++++++++++++- tests/test_utils.py | 60 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/docs/utils.rst b/docs/utils.rst index 9bb63aa65..fefe0b2de 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -71,6 +71,33 @@ Reference >>> canonicalize_version('1.4.0.0.0') '1.4' +.. function:: create_wheel_filename(name, version, build, tags) + + 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 str name: The project name + :param ~packaging.version.Version version: The project version + :param Optional[(),(int,str)] build: An optional two-item tuple of an integer and string + :param set[~packaging.tags.Tag] tags: The set of tags that apply to the wheel + + .. doctest:: + + >>> 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")} + >>> "foo_bar-1.0-py3-none-any.whl" == create_wheel_filename("foo-bar", version, None, tags) + True + .. function:: parse_wheel_filename(filename) This function takes the filename of a wheel file, and parses it, @@ -106,6 +133,20 @@ Reference >>> not build True +.. function:: create_sdist_filename(name, version) + + Combines the project name and a version to make a valid sdist filename. + + :param str name: The project name + :param ~packaging.version.Version version: The project version + + .. doctest:: + + >>> 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 + .. function:: parse_sdist_filename(filename) This function takes the filename of a sdist file (as specified diff --git a/src/packaging/utils.py b/src/packaging/utils.py index c14747f6f..b589d56cb 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 + BuildTag = Union[Tuple[()], Tuple[int, str]] NormalizedName = NewType("NormalizedName", str) @@ -21,6 +24,8 @@ "NormalizedName", "canonicalize_name", "canonicalize_version", + "create_sdist_filename", + "create_wheel_filename", "is_normalized_name", "parse_sdist_filename", "parse_wheel_filename", @@ -54,6 +59,7 @@ class InvalidSdistFilename(ValueError): _normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]") # PEP 427: The build number must start with a digit. _build_tag_regex = re.compile(r"(\d+)(.*)") +_distribution_regex = re.compile(r"[^\w\d.]+") def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName: @@ -103,6 +109,30 @@ 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: + 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]]: @@ -146,6 +176,10 @@ def parse_wheel_filename( return (name, version, build, tags) +def create_sdist_filename(name: str, version: Version) -> str: + return f"{_distribution_regex.sub('_', name)}-{version}.tar.gz" + + def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: if filename.endswith(".tar.gz"): file_stem = filename[: -len(".tar.gz")] 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 7ba97c563193b9726e2fd93951734e81a32d3d3a Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 11:49:53 -0500 Subject: [PATCH 2/6] fix: always normalize names Signed-off-by: Henry Schreiner --- src/packaging/utils.py | 6 +++--- tests/test_utils.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index b589d56cb..2cea295a8 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -59,7 +59,6 @@ class InvalidSdistFilename(ValueError): _normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]") # PEP 427: The build number must start with a digit. _build_tag_regex = re.compile(r"(\d+)(.*)") -_distribution_regex = re.compile(r"[^\w\d.]+") def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName: @@ -120,7 +119,7 @@ def _compress_tag_set(tags: AbstractSet[Tag]) -> str: def create_wheel_filename( name: str, version: Version, build: BuildTag | None, tags: AbstractSet[Tag] ) -> str: - norm_name = _distribution_regex.sub("_", name) + norm_name = canonicalize_name(name).replace("-", "_") compressed_tag = _compress_tag_set(tags) parts: tuple[str, ...] @@ -177,7 +176,8 @@ def parse_wheel_filename( def create_sdist_filename(name: str, version: Version) -> str: - 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 defb3b608447b67bc31a33e3e4b2995d4f791df9 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 11:56:57 -0500 Subject: [PATCH 3/6] refactor: create -> compose Signed-off-by: Henry Schreiner --- docs/utils.rst | 12 ++++++------ src/packaging/utils.py | 8 ++++---- tests/test_utils.py | 12 ++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/utils.rst b/docs/utils.rst index fefe0b2de..78e209a3f 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -71,7 +71,7 @@ Reference >>> canonicalize_version('1.4.0.0.0') '1.4' -.. function:: create_wheel_filename(name, version, build, tags) +.. function:: compose_wheel_filename(name, version, build, tags) Combines a project name, version, build tag, and tag set to make a properly formatted wheel filename. @@ -90,12 +90,12 @@ Reference .. doctest:: - >>> 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")} - >>> "foo_bar-1.0-py3-none-any.whl" == create_wheel_filename("foo-bar", version, None, tags) + >>> "foo_bar-1.0-py3-none-any.whl" == compose_wheel_filename("foo-bar", version, None, tags) True .. function:: parse_wheel_filename(filename) @@ -133,7 +133,7 @@ Reference >>> not build True -.. function:: create_sdist_filename(name, version) +.. function:: compose_sdist_filename(name, version) Combines the project name and a version to make a valid sdist filename. @@ -142,9 +142,9 @@ Reference .. doctest:: - >>> 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 .. function:: parse_sdist_filename(filename) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 2cea295a8..aa4aab6c7 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -24,8 +24,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", @@ -116,7 +116,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: norm_name = canonicalize_name(name).replace("-", "_") @@ -175,7 +175,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: norm_name = canonicalize_name(name).replace("-", "_") return f"{norm_name}-{version}.tar.gz" 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 abb404cf71c9cc12fa1e59044ad742898a228585 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 12:04:51 -0500 Subject: [PATCH 4/6] 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 050fedbbc02dfcb8ae266999a84db7f50e891d5e Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 12:11:32 -0500 Subject: [PATCH 5/6] fix(types): support any iterable for the tag set Signed-off-by: Henry Schreiner --- docs/utils.rst | 19 +++++++++++-------- src/packaging/utils.py | 8 ++++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/utils.rst b/docs/utils.rst index 78e209a3f..e7bb95f4e 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -76,17 +76,17 @@ Reference 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. + 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`. 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 sorted + wheel tag string. :param str name: The project name :param ~packaging.version.Version version: The project version :param Optional[(),(int,str)] build: An optional two-item tuple of an integer and string - :param set[~packaging.tags.Tag] tags: The set of tags that apply to the wheel + :param Iterable[~packaging.tags.Tag] tags: The set of tags that apply to the wheel .. doctest:: @@ -135,7 +135,10 @@ Reference .. function:: compose_sdist_filename(name, version) - 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 str name: The project name :param ~packaging.version.Version version: The project version diff --git a/src/packaging/utils.py b/src/packaging/utils.py index aa4aab6c7..758791313 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 BuildTag = Union[Tuple[()], Tuple[int, str]] NormalizedName = NewType("NormalizedName", str) @@ -108,16 +108,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: norm_name = canonicalize_name(name).replace("-", "_") compressed_tag = _compress_tag_set(tags) From aa08b62a1fed47732211f69edf7746bfedd86684 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 6 Mar 2026 15:36:55 -0500 Subject: [PATCH 6/6] docs: add versionadded Signed-off-by: Henry Schreiner --- docs/utils.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/utils.rst b/docs/utils.rst index e7bb95f4e..bef4ab483 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -98,6 +98,8 @@ Reference >>> "foo_bar-1.0-py3-none-any.whl" == compose_wheel_filename("foo-bar", version, None, tags) True + .. versionadded:: 26.1 + .. function:: parse_wheel_filename(filename) This function takes the filename of a wheel file, and parses it, @@ -150,6 +152,8 @@ Reference >>> "foo_bar-1.0.tar.gz" == compose_sdist_filename("foo-bar", Version("1.0")) True + .. versionadded:: 26.1 + .. function:: parse_sdist_filename(filename) This function takes the filename of a sdist file (as specified