diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 9d66be888..e101d67ef 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 Iterable + __all__ = [ "BuildTag", "InvalidName", @@ -18,6 +21,8 @@ "NormalizedName", "canonicalize_name", "canonicalize_version", + "compose_sdist_filename", + "compose_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: 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: + """ + 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 + """ + 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" + + def parse_wheel_filename( filename: str, ) -> tuple[NormalizedName, Version, BuildTag, frozenset[Tag]]: @@ -229,6 +285,27 @@ def parse_wheel_filename( return (name, version, build, 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 + """ + norm_name = canonicalize_name(name).replace("-", "_") + return f"{norm_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..bf123224d 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, + compose_sdist_filename, + compose_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_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"), [ @@ -177,6 +226,26 @@ 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"), + [ + ("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"))],