diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index 5f17f8df..96aa35c6 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -13,10 +13,16 @@ Protocol, TypeVar, ) +from urllib.parse import urlparse from .markers import Marker from .specifiers import SpecifierSet -from .utils import NormalizedName, is_normalized_name +from .utils import ( + NormalizedName, + is_normalized_name, + parse_sdist_filename, + parse_wheel_filename, +) from .version import Version if TYPE_CHECKING: # pragma: no cover @@ -227,6 +233,26 @@ def _validate_path_url(path: str | None, url: str | None) -> None: raise PylockValidationError("path or url must be provided") +def _path_name(path: str | None) -> str | None: + if not path: + return None + # If the path is relative it MAY use POSIX-style path separators explicitly + # for portability + if "/" in path: + return path.rsplit("/", 1)[-1] + elif "\\" in path: + return path.rsplit("\\", 1)[-1] + else: + return path + + +def _url_name(url: str | None) -> str | None: + if not url: + return None + url_path = urlparse(url).path + return url_path.rsplit("/", 1)[-1] + + def _validate_hashes(hashes: Mapping[str, Any]) -> Mapping[str, Any]: if not hashes: raise PylockValidationError("At least one hash must be provided") @@ -421,8 +447,22 @@ def _from_dict(cls, d: Mapping[str, Any]) -> Self: hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract] ) _validate_path_url(package_sdist.path, package_sdist.url) + try: + parse_sdist_filename(package_sdist.filename) + except Exception as e: + raise PylockValidationError( + f"Invalid sdist filename {package_sdist.filename!r}" + ) from e return package_sdist + @property + def filename(self) -> str: + """Get the filename of the sdist.""" + filename = self.name or _url_name(self.url) or _path_name(self.path) + if not filename: + raise PylockValidationError("Cannot determine sdist filename") + return filename + @dataclass(frozen=True, init=False) class PackageWheel: @@ -462,8 +502,22 @@ def _from_dict(cls, d: Mapping[str, Any]) -> Self: hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract] ) _validate_path_url(package_wheel.path, package_wheel.url) + try: + parse_wheel_filename(package_wheel.filename) + except Exception as e: + raise PylockValidationError( + f"Invalid wheel filename {package_wheel.filename!r}" + ) from e return package_wheel + @property + def filename(self) -> str: + """Get the filename of the wheel.""" + filename = self.name or _url_name(self.url) or _path_name(self.path) + if not filename: + raise PylockValidationError("Cannot determine wheel filename") + return filename + @dataclass(frozen=True, init=False) class Package: diff --git a/tests/test_pylock.py b/tests/test_pylock.py index 1e3d9575..7c0090ec 100644 --- a/tests/test_pylock.py +++ b/tests/test_pylock.py @@ -12,6 +12,7 @@ from packaging.pylock import ( Package, PackageDirectory, + PackageSdist, PackageVcs, PackageWheel, Pylock, @@ -156,7 +157,7 @@ def test_pylock_packages_with_dist_and_archive() -> None: "hashes": {"sha256": "f" * 40}, }, "sdist": { - "path": "example.tar.gz", + "path": "example-1.0.tar.gz", "hashes": {"sha256": "f" * 40}, }, } @@ -280,6 +281,185 @@ def test_pylock_invalid_vcs() -> None: assert str(exc_info.value) == "path or url must be provided" +@pytest.mark.parametrize( + ("dist", "expected_filename"), + [ + # sdists + ( + PackageSdist( + name="example-1.0.tar.gz", + hashes={}, + ), + "example-1.0.tar.gz", + ), + ( + PackageSdist( + path="./example-1.0.tar.gz", + hashes={}, + ), + "example-1.0.tar.gz", + ), + ( + PackageSdist( + path=".\\example-1.0.tar.gz", + hashes={}, + ), + "example-1.0.tar.gz", + ), + ( + PackageSdist( + url="https://example.com/example-1.0.tar.gz", + hashes={}, + ), + "example-1.0.tar.gz", + ), + ( + # name preferred over path + PackageSdist( + name="example-2.0.tar.gz", + path=".\\example-1.0.tar.gz", + hashes={}, + ), + "example-2.0.tar.gz", + ), + ( + # name preferred over url + PackageSdist( + name="example-2.0.tar.gz", + url="https://example.com/example-1.0.tar.gz", + hashes={}, + ), + "example-2.0.tar.gz", + ), + ( + # url preferred over path + PackageSdist( + url="https://example.com/example-2.0.tar.gz", + path="./example-1.0.tar.gz", + hashes={}, + ), + "example-2.0.tar.gz", + ), + # wheels + ( + PackageWheel( + name="example-1.0-py3-none-any.whl", + hashes={}, + ), + "example-1.0-py3-none-any.whl", + ), + ( + PackageWheel( + path="./example-1.0-py3-none-any.whl", + hashes={}, + ), + "example-1.0-py3-none-any.whl", + ), + ( + PackageWheel( + path=".\\example-1.0-py3-none-any.whl", + hashes={}, + ), + "example-1.0-py3-none-any.whl", + ), + ( + PackageWheel( + url="https://example.com/example-1.0-py3-none-any.whl", + hashes={}, + ), + "example-1.0-py3-none-any.whl", + ), + ( + # name preferred over path + PackageWheel( + name="example-2.0-py3-none-any.whl", + path=".\\example-1.0-py3-none-any.whl", + hashes={}, + ), + "example-2.0-py3-none-any.whl", + ), + ( + # name preferred over url + PackageWheel( + name="example-2.0-py3-none-any.whl", + url="https://example.com/example-1.0-py3-none-any.whl", + hashes={}, + ), + "example-2.0-py3-none-any.whl", + ), + ( + # url preferred over path + PackageWheel( + url="https://example.com/example-2.0-py3-none-any.whl", + path="./example-1.0-py3-none-any.whl", + hashes={}, + ), + "example-2.0-py3-none-any.whl", + ), + ], +) +def test_dist_filename( + dist: PackageSdist | PackageWheel, expected_filename: str +) -> None: + assert dist.filename == expected_filename + + +def test_missing_sdist_filename() -> None: + with pytest.raises(PylockValidationError) as exc_info: + _ = PackageSdist(hashes={}).filename + assert str(exc_info.value) == "Cannot determine sdist filename" + + +def test_missing_wheel_filename() -> None: + with pytest.raises(PylockValidationError) as exc_info: + _ = PackageWheel(hashes={}).filename + assert str(exc_info.value) == "Cannot determine wheel filename" + + +def test_pylock_invalid_wheel_filename() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [ + { + "name": "example", + "wheels": [ + { + "url": "http://example.com/example-1.0.whl", + "hashes": {"sha256": "f" * 40}, + } + ], + } + ], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Invalid wheel filename 'example-1.0.whl' in 'packages[0].wheels[0]'" + ) + + +def test_pylock_invalid_sdist_filename() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [ + { + "name": "example", + "sdist": { + "path": "./example.tar.gz", + "hashes": {"sha256": "f" * 40}, + }, + }, + ], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Invalid sdist filename 'example.tar.gz' in 'packages[0].sdist'" + ) + + def test_pylock_invalid_wheel() -> None: data = { "lock-version": "1.0",