diff --git a/docs/index.rst b/docs/index.rst index 04579c4f2..cc9dca61c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,6 +30,7 @@ The ``packaging`` library uses calendar-based versioning (``YY.N``). requirements metadata tags + pylock utils .. toctree:: diff --git a/docs/pylock.rst b/docs/pylock.rst new file mode 100644 index 000000000..3f2e0d359 --- /dev/null +++ b/docs/pylock.rst @@ -0,0 +1,83 @@ +Lock Files +========== + +.. currentmodule:: packaging.pylock + +Parse and validate `pylock.toml files `_. + +Usage +----- + +.. code-block:: python + + import tomllib + from pathlib import Path + + from packaging.pylock import Package, PackageWheel, Pylock + from packaging.utils import NormalizedName + from packaging.version import Version + + # validate a pylock file name + assert is_valid_pylock_path(Path("pylock.example.toml")) + + # parse and validate pylock file + toml_dict = tomllib.loads(Path("pylock.toml").read_text(encoding="utf-8")) + pylock = PyLock.from_dict(toml_dict) + # the resulting pylock object is validated against the specification, + # else a PylockValidationError is raised + + # generate a pylock file + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + packages=[ + Package( + name=NormalizedName("example-package"), + version=Version("1.0.0"), + wheels=[ + PackageWheel( + url="https://example.com/example_package-1.0.0-py3-none-any.whl", + hashes={"sha256": "0fd.."}, + ) + ], + ) + ], + ) + toml_dict = pylock.to_dict() + # use a third-party library to serialize to TOML + + # you can validate a manually constructed Pylock class + pylock.validate() + +Reference +--------- + +.. autofunction:: is_valid_pylock_path + +The following frozen keyword-only dataclasses are used to represent the +structure of a pylock file. The attributes correspond to the fields in the +pylock file specification. + +.. autoclass:: Pylock + :members: from_dict, to_dict, validate + :exclude-members: __init__, __new__ + +.. class:: Package + +.. class:: PackageWheel + +.. class:: PackageSdist + +.. class:: PackageArchive + +.. class:: PackageVcs + +.. class:: PackageDirectory + +The following exception may be raised by this module: + +.. autoexception:: PylockValidationError + :exclude-members: __init__, __new__ + +.. autoexception:: PylockUnsupportedVersionError + :exclude-members: __init__, __new__ diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py new file mode 100644 index 000000000..b7ec8ec46 --- /dev/null +++ b/src/packaging/pylock.py @@ -0,0 +1,636 @@ +from __future__ import annotations + +import dataclasses +import logging +import re +import sys +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Protocol, + TypeVar, +) + +from .markers import Marker +from .specifiers import SpecifierSet +from .utils import NormalizedName, is_normalized_name +from .version import Version + +if TYPE_CHECKING: # pragma: no cover + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + +__all__ = [ + "Package", + "PackageArchive", + "PackageDirectory", + "PackageSdist", + "PackageVcs", + "PackageWheel", + "Pylock", + "PylockUnsupportedVersionError", + "PylockValidationError", + "is_valid_pylock_path", +] + +_T = TypeVar("_T") +_T2 = TypeVar("_T2") + + +class _FromMappingProtocol(Protocol): # pragma: no cover + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: ... + + +_FromMappingProtocolT = TypeVar("_FromMappingProtocolT", bound=_FromMappingProtocol) + + +_PYLOCK_FILE_NAME_RE = re.compile(r"^pylock\.([^.]+)\.toml$") + + +def is_valid_pylock_path(path: Path) -> bool: + """Check if the given path is a valid pylock file path.""" + return path.name == "pylock.toml" or bool(_PYLOCK_FILE_NAME_RE.match(path.name)) + + +def _toml_key(key: str) -> str: + return key.replace("_", "-") + + +def _toml_value(key: str, value: Any) -> Any: + if isinstance(value, (Version, Marker, SpecifierSet)): + return str(value) + if isinstance(value, Sequence) and key == "environments": + return [str(v) for v in value] + return value + + +def _toml_dict_factory(data: list[tuple[str, Any]]) -> dict[str, Any]: + return { + _toml_key(key): _toml_value(key, value) + for key, value in data + if value is not None + } + + +def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None: + """Get a value from the dictionary and verify it's the expected type.""" + if (value := d.get(key)) is None: + return None + if not isinstance(value, expected_type): + raise PylockValidationError( + f"Unexpected type {type(value).__name__} " + f"(expected {expected_type.__name__})", + context=key, + ) + return value + + +def _get_required(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T: + """Get a required value from the dictionary and verify it's the expected type.""" + if (value := _get(d, expected_type, key)) is None: + raise _PylockRequiredKeyError(key) + return value + + +def _get_sequence( + d: Mapping[str, Any], expected_item_type: type[_T], key: str +) -> Sequence[_T] | None: + """Get a list value from the dictionary and verify it's the expected items type.""" + if (value := _get(d, Sequence, key)) is None: # type: ignore[type-abstract] + return None + if isinstance(value, (str, bytes)): + # special case: str and bytes are Sequences, but we want to reject it + raise PylockValidationError( + f"Unexpected type {type(value).__name__} (expected Sequence)", + context=key, + ) + for i, item in enumerate(value): + if not isinstance(item, expected_item_type): + raise PylockValidationError( + f"Unexpected type {type(item).__name__} " + f"(expected {expected_item_type.__name__})", + context=f"{key}[{i}]", + ) + return value + + +def _get_as( + d: Mapping[str, Any], + expected_type: type[_T], + target_type: Callable[[_T], _T2], + key: str, +) -> _T2 | None: + """Get a value from the dictionary, verify it's the expected type, + and convert to the target type. + + This assumes the target_type constructor accepts the value. + """ + if (value := _get(d, expected_type, key)) is None: + return None + try: + return target_type(value) + except Exception as e: + raise PylockValidationError(e, context=key) from e + + +def _get_required_as( + d: Mapping[str, Any], + expected_type: type[_T], + target_type: Callable[[_T], _T2], + key: str, +) -> _T2: + """Get a required value from the dict, verify it's the expected type, + and convert to the target type.""" + if (value := _get_as(d, expected_type, target_type, key)) is None: + raise _PylockRequiredKeyError(key) + return value + + +def _get_sequence_as( + d: Mapping[str, Any], + expected_item_type: type[_T], + target_item_type: Callable[[_T], _T2], + key: str, +) -> Sequence[_T2] | None: + """Get list value from dictionary and verify expected items type.""" + if (value := _get_sequence(d, expected_item_type, key)) is None: + return None + result = [] + for i, item in enumerate(value): + try: + typed_item = target_item_type(item) + except Exception as e: + raise PylockValidationError(e, context=f"{key}[{i}]") from e + else: + result.append(typed_item) + return result + + +def _get_object( + d: Mapping[str, Any], target_type: type[_FromMappingProtocolT], key: str +) -> _FromMappingProtocolT | None: + """Get a dictionary value from the dictionary and convert it to a dataclass.""" + if (value := _get(d, Mapping, key)) is None: # type: ignore[type-abstract] + return None + try: + return target_type._from_dict(value) + except Exception as e: + raise PylockValidationError(e, context=key) from e + + +def _get_sequence_of_objects( + d: Mapping[str, Any], target_item_type: type[_FromMappingProtocolT], key: str +) -> Sequence[_FromMappingProtocolT] | None: + """Get a list value from the dictionary and convert its items to a dataclass.""" + if (value := _get_sequence(d, Mapping, key)) is None: # type: ignore[type-abstract] + return None + result = [] + for i, item in enumerate(value): + try: + typed_item = target_item_type._from_dict(item) + except Exception as e: + raise PylockValidationError(e, context=f"{key}[{i}]") from e + else: + result.append(typed_item) + return result + + +def _get_required_sequence_of_objects( + d: Mapping[str, Any], target_item_type: type[_FromMappingProtocolT], key: str +) -> Sequence[_FromMappingProtocolT]: + """Get a required list value from the dictionary and convert its items to a + dataclass.""" + if (result := _get_sequence_of_objects(d, target_item_type, key)) is None: + raise _PylockRequiredKeyError(key) + return result + + +def _validate_normalized_name(name: str) -> NormalizedName: + """Validate that a string is a NormalizedName.""" + if not is_normalized_name(name): + raise PylockValidationError(f"Name {name!r} is not normalized") + return NormalizedName(name) + + +def _validate_path_url(path: str | None, url: str | None) -> None: + if not path and not url: + raise PylockValidationError("path or url must be provided") + + +def _validate_hashes(hashes: Mapping[str, Any]) -> Mapping[str, Any]: + if not hashes: + raise PylockValidationError("At least one hash must be provided") + if not all(isinstance(hash, str) for hash in hashes.values()): + raise PylockValidationError("Hash values must be strings") + return hashes + + +class PylockValidationError(Exception): + """Raised when when input data is not spec-compliant.""" + + context: str | None = None + message: str + + def __init__( + self, + cause: str | Exception, + *, + context: str | None = None, + ) -> None: + if isinstance(cause, PylockValidationError): + if cause.context: + self.context = ( + f"{context}.{cause.context}" if context else cause.context + ) + else: + self.context = context + self.message = cause.message + else: + self.context = context + self.message = str(cause) + + def __str__(self) -> str: + if self.context: + return f"{self.message} in {self.context!r}" + return self.message + + +class _PylockRequiredKeyError(PylockValidationError): + def __init__(self, key: str) -> None: + super().__init__("Missing required value", context=key) + + +class PylockUnsupportedVersionError(PylockValidationError): + """Raised when encountering an unsupported `lock_version`.""" + + +@dataclass(frozen=True, init=False) +class PackageVcs: + type: str + url: str | None = None + path: str | None = None + requested_revision: str | None = None + commit_id: str # type: ignore[misc] + subdirectory: str | None = None + + def __init__( + self, + *, + type: str, + url: str | None = None, + path: str | None = None, + requested_revision: str | None = None, + commit_id: str, + subdirectory: str | None = None, + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "type", type) + object.__setattr__(self, "url", url) + object.__setattr__(self, "path", path) + object.__setattr__(self, "requested_revision", requested_revision) + object.__setattr__(self, "commit_id", commit_id) + object.__setattr__(self, "subdirectory", subdirectory) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + package_vcs = cls( + type=_get_required(d, str, "type"), + url=_get(d, str, "url"), + path=_get(d, str, "path"), + requested_revision=_get(d, str, "requested-revision"), + commit_id=_get_required(d, str, "commit-id"), + subdirectory=_get(d, str, "subdirectory"), + ) + _validate_path_url(package_vcs.path, package_vcs.url) + return package_vcs + + +@dataclass(frozen=True, init=False) +class PackageDirectory: + path: str + editable: bool | None = None + subdirectory: str | None = None + + def __init__( + self, + *, + path: str, + editable: bool | None = None, + subdirectory: str | None = None, + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "path", path) + object.__setattr__(self, "editable", editable) + object.__setattr__(self, "subdirectory", subdirectory) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + return cls( + path=_get_required(d, str, "path"), + editable=_get(d, bool, "editable"), + subdirectory=_get(d, str, "subdirectory"), + ) + + +@dataclass(frozen=True, init=False) +class PackageArchive: + url: str | None = None + path: str | None = None + size: int | None = None + upload_time: datetime | None = None + hashes: Mapping[str, str] # type: ignore[misc] + subdirectory: str | None = None + + def __init__( + self, + *, + url: str | None = None, + path: str | None = None, + size: int | None = None, + upload_time: datetime | None = None, + hashes: Mapping[str, str], + subdirectory: str | None = None, + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "url", url) + object.__setattr__(self, "path", path) + object.__setattr__(self, "size", size) + object.__setattr__(self, "upload_time", upload_time) + object.__setattr__(self, "hashes", hashes) + object.__setattr__(self, "subdirectory", subdirectory) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + package_archive = cls( + url=_get(d, str, "url"), + path=_get(d, str, "path"), + size=_get(d, int, "size"), + upload_time=_get(d, datetime, "upload-time"), + hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract] + subdirectory=_get(d, str, "subdirectory"), + ) + _validate_path_url(package_archive.path, package_archive.url) + return package_archive + + +@dataclass(frozen=True, init=False) +class PackageSdist: + name: str | None = None + upload_time: datetime | None = None + url: str | None = None + path: str | None = None + size: int | None = None + hashes: Mapping[str, str] # type: ignore[misc] + + def __init__( + self, + *, + name: str | None = None, + upload_time: datetime | None = None, + url: str | None = None, + path: str | None = None, + size: int | None = None, + hashes: Mapping[str, str], + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "name", name) + object.__setattr__(self, "upload_time", upload_time) + object.__setattr__(self, "url", url) + object.__setattr__(self, "path", path) + object.__setattr__(self, "size", size) + object.__setattr__(self, "hashes", hashes) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + package_sdist = cls( + name=_get(d, str, "name"), + upload_time=_get(d, datetime, "upload-time"), + url=_get(d, str, "url"), + path=_get(d, str, "path"), + size=_get(d, int, "size"), + hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract] + ) + _validate_path_url(package_sdist.path, package_sdist.url) + return package_sdist + + +@dataclass(frozen=True, init=False) +class PackageWheel: + name: str | None = None + upload_time: datetime | None = None + url: str | None = None + path: str | None = None + size: int | None = None + hashes: Mapping[str, str] # type: ignore[misc] + + def __init__( + self, + *, + name: str | None = None, + upload_time: datetime | None = None, + url: str | None = None, + path: str | None = None, + size: int | None = None, + hashes: Mapping[str, str], + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "name", name) + object.__setattr__(self, "upload_time", upload_time) + object.__setattr__(self, "url", url) + object.__setattr__(self, "path", path) + object.__setattr__(self, "size", size) + object.__setattr__(self, "hashes", hashes) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + package_wheel = cls( + name=_get(d, str, "name"), + upload_time=_get(d, datetime, "upload-time"), + url=_get(d, str, "url"), + path=_get(d, str, "path"), + size=_get(d, int, "size"), + hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract] + ) + _validate_path_url(package_wheel.path, package_wheel.url) + return package_wheel + + +@dataclass(frozen=True, init=False) +class Package: + name: NormalizedName + version: Version | None = None + marker: Marker | None = None + requires_python: SpecifierSet | None = None + dependencies: Sequence[Mapping[str, Any]] | None = None + vcs: PackageVcs | None = None + directory: PackageDirectory | None = None + archive: PackageArchive | None = None + index: str | None = None + sdist: PackageSdist | None = None + wheels: Sequence[PackageWheel] | None = None + attestation_identities: Sequence[Mapping[str, Any]] | None = None + tool: Mapping[str, Any] | None = None + + def __init__( + self, + *, + name: NormalizedName, + version: Version | None = None, + marker: Marker | None = None, + requires_python: SpecifierSet | None = None, + dependencies: Sequence[Mapping[str, Any]] | None = None, + vcs: PackageVcs | None = None, + directory: PackageDirectory | None = None, + archive: PackageArchive | None = None, + index: str | None = None, + sdist: PackageSdist | None = None, + wheels: Sequence[PackageWheel] | None = None, + attestation_identities: Sequence[Mapping[str, Any]] | None = None, + tool: Mapping[str, Any] | None = None, + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "name", name) + object.__setattr__(self, "version", version) + object.__setattr__(self, "marker", marker) + object.__setattr__(self, "requires_python", requires_python) + object.__setattr__(self, "dependencies", dependencies) + object.__setattr__(self, "vcs", vcs) + object.__setattr__(self, "directory", directory) + object.__setattr__(self, "archive", archive) + object.__setattr__(self, "index", index) + object.__setattr__(self, "sdist", sdist) + object.__setattr__(self, "wheels", wheels) + object.__setattr__(self, "attestation_identities", attestation_identities) + object.__setattr__(self, "tool", tool) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + package = cls( + name=_get_required_as(d, str, _validate_normalized_name, "name"), + version=_get_as(d, str, Version, "version"), + requires_python=_get_as(d, str, SpecifierSet, "requires-python"), + dependencies=_get_sequence(d, Mapping, "dependencies"), # type: ignore[type-abstract] + marker=_get_as(d, str, Marker, "marker"), + vcs=_get_object(d, PackageVcs, "vcs"), + directory=_get_object(d, PackageDirectory, "directory"), + archive=_get_object(d, PackageArchive, "archive"), + index=_get(d, str, "index"), + sdist=_get_object(d, PackageSdist, "sdist"), + wheels=_get_sequence_of_objects(d, PackageWheel, "wheels"), + attestation_identities=_get_sequence(d, Mapping, "attestation-identities"), # type: ignore[type-abstract] + tool=_get(d, Mapping, "tool"), # type: ignore[type-abstract] + ) + distributions = bool(package.sdist) + len(package.wheels or []) + direct_urls = ( + bool(package.vcs) + bool(package.directory) + bool(package.archive) + ) + if distributions > 0 and direct_urls > 0: + raise PylockValidationError( + "None of vcs, directory, archive must be set if sdist or wheels are set" + ) + if distributions == 0 and direct_urls != 1: + raise PylockValidationError( + "Exactly one of vcs, directory, archive must be set " + "if sdist and wheels are not set" + ) + for i, attestation_identity in enumerate(package.attestation_identities or []): + try: + _get_required(attestation_identity, str, "kind") + except Exception as e: + raise PylockValidationError( + e, context=f"attestation-identities[{i}]" + ) from e + return package + + @property + def is_direct(self) -> bool: + return not (self.sdist or self.wheels) + + +@dataclass(frozen=True, init=False) +class Pylock: + """A class representing a pylock file.""" + + lock_version: Version + environments: Sequence[Marker] | None = None + requires_python: SpecifierSet | None = None + extras: Sequence[NormalizedName] | None = None + dependency_groups: Sequence[str] | None = None + default_groups: Sequence[str] | None = None + created_by: str # type: ignore[misc] + packages: Sequence[Package] # type: ignore[misc] + tool: Mapping[str, Any] | None = None + + def __init__( + self, + *, + lock_version: Version, + environments: Sequence[Marker] | None = None, + requires_python: SpecifierSet | None = None, + extras: Sequence[NormalizedName] | None = None, + dependency_groups: Sequence[str] | None = None, + default_groups: Sequence[str] | None = None, + created_by: str, + packages: Sequence[Package], + tool: Mapping[str, Any] | None = None, + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "lock_version", lock_version) + object.__setattr__(self, "environments", environments) + object.__setattr__(self, "requires_python", requires_python) + object.__setattr__(self, "extras", extras) + object.__setattr__(self, "dependency_groups", dependency_groups) + object.__setattr__(self, "default_groups", default_groups) + object.__setattr__(self, "created_by", created_by) + object.__setattr__(self, "packages", packages) + object.__setattr__(self, "tool", tool) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + pylock = cls( + lock_version=_get_required_as(d, str, Version, "lock-version"), + environments=_get_sequence_as(d, str, Marker, "environments"), + extras=_get_sequence_as(d, str, _validate_normalized_name, "extras"), + dependency_groups=_get_sequence(d, str, "dependency-groups"), + default_groups=_get_sequence(d, str, "default-groups"), + created_by=_get_required(d, str, "created-by"), + requires_python=_get_as(d, str, SpecifierSet, "requires-python"), + packages=_get_required_sequence_of_objects(d, Package, "packages"), + tool=_get(d, Mapping, "tool"), # type: ignore[type-abstract] + ) + if not Version("1") <= pylock.lock_version < Version("2"): + raise PylockUnsupportedVersionError( + f"pylock version {pylock.lock_version} is not supported" + ) + if pylock.lock_version > Version("1.0"): + logging.warning( + "pylock minor version %s is not supported", pylock.lock_version + ) + return pylock + + @classmethod + def from_dict(cls, d: Mapping[str, Any], /) -> Self: + """Create and validate a Pylock instance from a TOML dictionary. + + Raises :class:`PylockValidationError` if the input data is not + spec-compliant. + """ + return cls._from_dict(d) + + def to_dict(self) -> Mapping[str, Any]: + """Convert the Pylock instance to a TOML dictionary.""" + return dataclasses.asdict(self, dict_factory=_toml_dict_factory) + + def validate(self) -> None: + """Validate the Pylock instance against the specification. + + Raises :class:`PylockValidationError` otherwise.""" + self.from_dict(self.to_dict()) diff --git a/tests/pylock/pylock.spec-example.toml b/tests/pylock/pylock.spec-example.toml new file mode 100644 index 000000000..80d3f14ee --- /dev/null +++ b/tests/pylock/pylock.spec-example.toml @@ -0,0 +1,68 @@ +# This is the example from PEP 751, with the following differences: +# - a minor modification to the 'environments' field to use double quotes +# instead of single quotes, since that is what 'packaging' does when +# serializing markers; +# - added an index field, which was not demonstrated in the PEP 751 example. +# - removed spaces in require-python specifiers + +lock-version = '1.0' +environments = ['sys_platform == "win32"', 'sys_platform == "linux"'] +requires-python = '==3.12' +created-by = 'mousebender' + +[[packages]] +name = 'attrs' +version = '25.1.0' +requires-python = '>=3.8' + + [[packages.wheels]] + name = 'attrs-25.1.0-py3-none-any.whl' + upload-time = 2025-01-25T11:30:10.164985+00:00 + url = 'https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl' + size = 63152 + hashes = {sha256 = 'c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a'} + + [[packages.attestation-identities]] + environment = 'release-pypi' + kind = 'GitHub' + repository = 'python-attrs/attrs' + workflow = 'pypi-package.yml' + +[[packages]] +name = 'cattrs' +version = '24.1.2' +requires-python = '>=3.8' +dependencies = [ + {name = 'attrs'}, +] +index = 'https://pypi.org/simple' + + [[packages.wheels]] + name = 'cattrs-24.1.2-py3-none-any.whl' + upload-time = 2024-09-22T14:58:34.812643+00:00 + url = 'https://files.pythonhosted.org/packages/c8/d5/867e75361fc45f6de75fe277dd085627a9db5ebb511a87f27dc1396b5351/cattrs-24.1.2-py3-none-any.whl' + size = 66446 + hashes = {sha256 = '67c7495b760168d931a10233f979b28dc04daf853b30752246f4f8471c6d68d0'} + +[[packages]] +name = 'numpy' +version = '2.2.3' +requires-python = '>=3.10' + + [[packages.wheels]] + name = 'numpy-2.2.3-cp312-cp312-win_amd64.whl' + upload-time = 2025-02-13T16:51:21.821880+00:00 + url = 'https://files.pythonhosted.org/packages/42/6e/55580a538116d16ae7c9aa17d4edd56e83f42126cb1dfe7a684da7925d2c/numpy-2.2.3-cp312-cp312-win_amd64.whl' + size = 12626357 + hashes = {sha256 = '83807d445817326b4bcdaaaf8e8e9f1753da04341eceec705c001ff342002e5d'} + + [[packages.wheels]] + name = 'numpy-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl' + upload-time = 2025-02-13T16:50:00.079662+00:00 + url = 'https://files.pythonhosted.org/packages/39/04/78d2e7402fb479d893953fb78fa7045f7deb635ec095b6b4f0260223091a/numpy-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl' + size = 16116679 + hashes = {sha256 = '3b787adbf04b0db1967798dba8da1af07e387908ed1553a0d6e74c084d1ceafe'} + +[tool.mousebender] +command = ['.', 'lock', '--platform', 'cpython3.12-windows-x64', '--platform', 'cpython3.12-manylinux2014-x64', 'cattrs', 'numpy'] +run-on = 2025-03-06T12:28:57.760769 diff --git a/tests/requirements.txt b/tests/requirements.txt index 6dacfd1b1..efe3f4f76 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -2,3 +2,5 @@ coverage[toml]>=5.0.0 pip>=21.1 pretend pytest>=6.2.0 +tomli; python_version<'3.11' +tomli_w diff --git a/tests/test_pylock.py b/tests/test_pylock.py new file mode 100644 index 000000000..c4246ca8d --- /dev/null +++ b/tests/test_pylock.py @@ -0,0 +1,578 @@ +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +import pytest +import tomli_w + +from packaging.markers import Marker +from packaging.pylock import ( + Package, + PackageDirectory, + PackageVcs, + PackageWheel, + Pylock, + PylockUnsupportedVersionError, + PylockValidationError, + is_valid_pylock_path, +) +from packaging.specifiers import SpecifierSet +from packaging.utils import NormalizedName +from packaging.version import Version + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +@pytest.mark.parametrize( + "file_name,valid", + [ + ("pylock.toml", True), + ("pylock.spam.toml", True), + ("pylock.json", False), + ("pylock..toml", False), + ], +) +def test_pylock_file_name(file_name: str, valid: bool) -> None: + assert is_valid_pylock_path(Path(file_name)) is valid + + +def test_toml_roundtrip() -> None: + pep751_example = ( + Path(__file__).parent / "pylock" / "pylock.spec-example.toml" + ).read_text() + pylock_dict = tomllib.loads(pep751_example) + pylock = Pylock.from_dict(pylock_dict) + # Check that the roundrip via Pylock dataclasses produces the same TOML + # output, modulo TOML serialization differences. + assert tomli_w.dumps(pylock.to_dict()) == tomli_w.dumps(pylock_dict) + + +@pytest.mark.parametrize("version", ["1.0", "1.1"]) +def test_pylock_version(version: str) -> None: + data = { + "lock-version": version, + "created-by": "pip", + "packages": [], + } + Pylock.from_dict(data) + + +@pytest.mark.parametrize("version", ["0.9", "2", "2.0", "2.1"]) +def test_pylock_unsupported_version(version: str) -> None: + data = { + "lock-version": version, + "created-by": "pip", + "packages": [], + } + with pytest.raises(PylockUnsupportedVersionError): + Pylock.from_dict(data) + + +def test_pylock_invalid_version() -> None: + data = { + "lock-version": "2.x", + "created-by": "pip", + "packages": [], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == "Invalid version: '2.x' in 'lock-version'" + + +def test_pylock_unexpected_type() -> None: + data = { + "lock-version": 1.0, + "created-by": "pip", + "packages": [], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Unexpected type float (expected str) in 'lock-version'" + ) + + +def test_pylock_missing_version() -> None: + data = { + "created-by": "pip", + "packages": [], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == "Missing required value in 'lock-version'" + + +def test_pylock_missing_created_by() -> None: + data = { + "lock-version": "1.0", + "packages": [], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == "Missing required value in 'created-by'" + + +def test_pylock_missing_packages() -> None: + data = { + "lock-version": "1.0", + "created-by": "uv", + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == "Missing required value in 'packages'" + + +def test_pylock_packages_without_dist() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [{"name": "example", "version": "1.0"}], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Exactly one of vcs, directory, archive must be set " + "if sdist and wheels are not set " + "in 'packages[0]'" + ) + + +def test_pylock_packages_with_dist_and_archive() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [ + { + "name": "example", + "version": "1.0", + "archive": { + "path": "example.tar.gz", + "hashes": {"sha256": "f" * 40}, + }, + "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) == ( + "None of vcs, directory, archive must be set " + "if sdist or wheels are set " + "in 'packages[0]'" + ) + + +def test_pylock_packages_with_archive_directory_and_vcs() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [ + { + "name": "example", + "version": "1.0", + "archive": { + "path": "example.tar.gz", + "hashes": {"sha256": "f" * 40}, + }, + "vcs": { + "type": "git", + "url": "https://githhub/pypa/packaging", + "commit-id": "...", + }, + "directory": { + "path": ".", + "editable": False, + }, + } + ], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Exactly one of vcs, directory, archive must be set " + "if sdist and wheels are not set " + "in 'packages[0]'" + ) + + +def test_pylock_basic_package() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "requires-python": ">=3.10", + "environments": ['os_name == "posix"'], + "packages": [ + { + "name": "example", + "version": "1.0", + "marker": 'os_name == "posix"', + "requires-python": "!=3.10.1,>=3.10", + "directory": { + "path": ".", + "editable": False, + }, + } + ], + } + pylock = Pylock.from_dict(data) + assert pylock.environments == [Marker('os_name == "posix"')] + package = pylock.packages[0] + assert package.version == Version("1.0") + assert package.marker == Marker('os_name == "posix"') + assert package.requires_python == SpecifierSet(">=3.10, !=3.10.1") + assert pylock.to_dict() == data + + +def test_pylock_vcs_package() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [ + { + "name": "packaging", + "vcs": { + "type": "git", + "url": "https://githhub/pypa/packaging", + "commit-id": "...", + }, + } + ], + } + pylock = Pylock.from_dict(data) + assert pylock.to_dict() == data + + +def test_pylock_invalid_archive() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "requires-python": ">=3.10", + "environments": ['os_name == "posix"'], + "packages": [ + { + "name": "example", + "archive": { + # "path": "example.tar.gz", + "hashes": {"sha256": "f" * 40}, + }, + } + ], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "path or url must be provided in 'packages[0].archive'" + ) + + +def test_pylock_invalid_vcs() -> None: + with pytest.raises(PylockValidationError) as exc_info: + PackageVcs._from_dict({"type": "git", "commit-id": "f" * 40}) + assert str(exc_info.value) == "path or url must be provided" + + +def test_pylock_invalid_wheel() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "requires-python": ">=3.10", + "environments": ['os_name == "posix"'], + "packages": [ + { + "name": "example", + "wheels": [ + { + "name": "example-1.0-py3-none-any.whl", + "path": "./example-1.0-py3-none-any.whl", + # Purposefully no "hashes" key. + } + ], + } + ], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Missing required value in 'packages[0].wheels[0].hashes'" + ) + + +def test_pylock_invalid_environments() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "environments": [ + 'os_name == "posix"', + 'invalid_marker == "..."', + ], + "packages": [], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Expected a marker variable or quoted string\n" + ' invalid_marker == "..."\n' + " ^ " + "in 'environments[1]'" + ) + + +def test_pylock_invalid_environments_type() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "environments": [ + 'os_name == "posix"', + 1, + ], + "packages": [], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Unexpected type int (expected str) in 'environments[1]'" + ) + + +def test_pylock_extras_and_groups() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "extras": ["feat1", "feat2"], + "dependency-groups": ["dev", "docs"], + "default-groups": ["dev"], + "packages": [], + } + pylock = Pylock.from_dict(data) + assert pylock.extras == ["feat1", "feat2"] + assert pylock.dependency_groups == ["dev", "docs"] + assert pylock.default_groups == ["dev"] + + +def test_pylock_tool() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [ + { + "name": "example", + "sdist": { + "name": "example-1.0.tar.gz", + "path": "./example-1.0.tar.gz", + "upload-time": datetime(2023, 10, 1, 0, 0), + "hashes": {"sha256": "f" * 40}, + }, + "tool": {"pip": {"foo": "bar"}}, + } + ], + "tool": {"pip": {"version": "25.2"}}, + } + pylock = Pylock.from_dict(data) + assert pylock.tool == {"pip": {"version": "25.2"}} + package = pylock.packages[0] + assert package.tool == {"pip": {"foo": "bar"}} + + +def test_pylock_package_not_a_table() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": ["example"], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Unexpected type str (expected Mapping) in 'packages[0]'" + ) + + +@pytest.mark.parametrize( + "hashes,expected_error", + [ + ( + { + "sha256": "f" * 40, + "md5": 1, + }, + "Hash values must be strings in 'hashes'", + ), + ( + {}, + "At least one hash must be provided in 'hashes'", + ), + ( + "sha256:...", + "Unexpected type str (expected Mapping) in 'hashes'", + ), + ], +) +def test_hash_validation(hashes: dict[str, Any], expected_error: str) -> None: + with pytest.raises(PylockValidationError) as exc_info: + PackageWheel._from_dict( + dict( + name="example-1.0-py3-none-any.whl", + upload_time=None, + url="https://example.com/example-1.0-py3-none-any.whl", + path=None, + size=None, + hashes=hashes, + ) + ) + assert str(exc_info.value) == expected_error + + +def test_package_name_validation() -> None: + with pytest.raises(PylockValidationError) as exc_info: + Package._from_dict({"name": "Example"}) + assert str(exc_info.value) == "Name 'Example' is not normalized in 'name'" + + +def test_extras_name_validation() -> None: + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict( + { + "lock-version": "1.0", + "created-by": "pip", + "extras": ["extra", "Feature"], + "packages": [], + } + ) + assert str(exc_info.value) == "Name 'Feature' is not normalized in 'extras[1]'" + + +def test_is_direct() -> None: + direct_package = Package( + name=NormalizedName("example"), + directory=PackageDirectory(path="."), + ) + assert direct_package.is_direct + wheel_package = Package( + name=NormalizedName("example"), + wheels=[ + PackageWheel( + url="https://example.com/example-1.0-py3-none-any.whl", + hashes={"sha256": "f" * 40}, + ) + ], + ) + assert not wheel_package.is_direct + + +def test_validate() -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + packages=[ + Package( + name=NormalizedName("example-package"), + version=Version("1.0.0"), + wheels=[ + PackageWheel( + url="https://example.com/example_package-1.0.0-py3-none-any.whl", + hashes={"sha256": "0fd.."}, + ) + ], + ) + ], + ) + pylock.validate() # Should not raise any exceptions + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + packages=[ + Package( + name=NormalizedName("example_package"), # not normalized + version=Version("1.0.0"), + wheels=[ + PackageWheel( + url="https://example.com/example_package-1.0.0-py3-none-any.whl", + hashes={"sha256": "0fd.."}, + ) + ], + ) + ], + ) + with pytest.raises(PylockValidationError) as exc_info: + pylock.validate() + assert ( + str(exc_info.value) + == "Name 'example_package' is not normalized in 'packages[0].name'" + ) + + +def test_validate_sequence_of_str() -> None: + pylock = Pylock( + lock_version=Version("1.0"), + created_by="some_tool", + packages=[], + dependency_groups="abc", # should be a sequence of str + ) + with pytest.raises(PylockValidationError) as exc_info: + pylock.validate() + assert str(exc_info.value) == ( + "Unexpected type str (expected Sequence) in 'dependency-groups'" + ) + + +def test_validate_attestation_identity_missing_kind() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [ + { + "name": "example", + "version": "1.0", + "directory": { + "path": ".", + }, + "attestation-identities": [ + { + # missing "kind" field + "value": "some-value", + } + ], + } + ], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Missing required value in 'packages[0].attestation-identities[0].kind'" + ) + + +def test_validate_attestation_identity_invalid_kind() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [ + { + "name": "example", + "version": "1.0", + "directory": { + "path": ".", + }, + "attestation-identities": [ + { + "kind": 123, + "value": "some-value", + } + ], + } + ], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Unexpected type int (expected str) " + "in 'packages[0].attestation-identities[0].kind'" + )