diff --git a/docs/utils.rst b/docs/utils.rst index 9bb63aa65..bef4ab483 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -71,6 +71,35 @@ Reference >>> canonicalize_version('1.4.0.0.0') '1.4' +.. 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. + + 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 Iterable[~packaging.tags.Tag] tags: The set of tags that apply to the wheel + + .. doctest:: + + >>> 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" == 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, @@ -106,6 +135,25 @@ Reference >>> not build True +.. function:: compose_sdist_filename(name, version) + + 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 + + .. doctest:: + + >>> 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 + .. 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..758791313 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 + BuildTag = Union[Tuple[()], Tuple[int, str]] NormalizedName = NewType("NormalizedName", str) @@ -21,6 +24,8 @@ "NormalizedName", "canonicalize_name", "canonicalize_version", + "compose_sdist_filename", + "compose_wheel_filename", "is_normalized_name", "parse_sdist_filename", "parse_wheel_filename", @@ -103,6 +108,30 @@ 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: + 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]]: @@ -146,6 +175,11 @@ def parse_wheel_filename( return (name, version, build, tags) +def compose_sdist_filename(name: str, version: Version) -> str: + norm_name = canonicalize_name(name).replace("-", "_") + return f"{norm_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..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"))],