From f04f22407d383c014852569e0725b4f9804cef4c Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Mon, 25 Nov 2024 20:59:31 +0000 Subject: [PATCH 1/2] 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 9d66be888..19f62e98d 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -42,13 +42,19 @@ class InvalidName(ValueError): """ -class InvalidWheelFilename(ValueError): +class InvalidFilename(ValueError): + """ + . + """ + + +class InvalidWheelFilename(InvalidFilename): """ An invalid wheel filename was found, users should refer to PEP 427. """ -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 5dff9d3bda86a136fd80d312428aa43657d991b8 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 9 Mar 2026 18:31:01 -0400 Subject: [PATCH 2/2] 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