diff --git a/src/packaging/filenames.py b/src/packaging/filenames.py new file mode 100644 index 000000000..25aae0af6 --- /dev/null +++ b/src/packaging/filenames.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import re +from typing import 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 + +__all__ = [ + "Filename", + "InvalidFilename", + "InvalidSdistFilename", + "InvalidWheelFilename", + "SourceFilename", + "WheelFilename", +] + + +def __dir__() -> list[str]: + return __all__ + + +class Filename: + def __init__(self, *a: object, **kw: object) -> 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(f"{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..6fb963029 --- /dev/null +++ b/tests/test_filenames.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import typing + +import pytest + +from packaging.filenames import ( + Filename, + InvalidFilename, + InvalidWheelFilename, + SourceFilename, + WheelFilename, +) +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() -> None: + 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: str) -> None: + fn = Filename.from_filename(filename) + 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() -> None: + 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: str, version: str, expected_filename: str +) -> None: + 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: str, error_message: str) -> None: + 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: 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 + 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: str, error_message: str) -> None: + 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: 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 + assert fn.original_version == version