- 8 DB models (services, incidents, monitors, subscribers, etc.) - Full CRUD API for services, incidents, monitors - Public status page with live data - Incident detail page with timeline - API key authentication - Uptime monitoring scheduler - 13 tests passing - TECHNICAL_DESIGN.md with full spec
905 lines
33 KiB
Python
905 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import logging
|
|
import re
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
Callable,
|
|
Protocol,
|
|
TypeVar,
|
|
cast,
|
|
)
|
|
from urllib.parse import urlparse
|
|
|
|
from .markers import Environment, Marker, default_environment
|
|
from .specifiers import SpecifierSet
|
|
from .tags import create_compatible_tags_selector, sys_tags
|
|
from .utils import (
|
|
NormalizedName,
|
|
is_normalized_name,
|
|
parse_sdist_filename,
|
|
parse_wheel_filename,
|
|
)
|
|
from .version import Version
|
|
|
|
if TYPE_CHECKING: # pragma: no cover
|
|
from collections.abc import Collection, Iterator
|
|
from pathlib import Path
|
|
|
|
from typing_extensions import Self
|
|
|
|
from .tags import Tag
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
__all__ = [
|
|
"Package",
|
|
"PackageArchive",
|
|
"PackageDirectory",
|
|
"PackageSdist",
|
|
"PackageVcs",
|
|
"PackageWheel",
|
|
"Pylock",
|
|
"PylockUnsupportedVersionError",
|
|
"PylockValidationError",
|
|
"is_valid_pylock_path",
|
|
]
|
|
|
|
|
|
def __dir__() -> list[str]:
|
|
return __all__
|
|
|
|
|
|
_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: # noqa: ANN401
|
|
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,
|
|
) -> list[_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 = []
|
|
try:
|
|
for item in value:
|
|
typed_item = target_item_type(item)
|
|
result.append(typed_item)
|
|
except Exception as e:
|
|
raise PylockValidationError(e, context=f"{key}[{len(result)}]") from e
|
|
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
|
|
) -> list[_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: list[_FromMappingProtocolT] = []
|
|
try:
|
|
for item in value:
|
|
typed_item = target_item_type._from_dict(item)
|
|
result.append(typed_item)
|
|
except Exception as e:
|
|
raise PylockValidationError(e, context=f"{key}[{len(result)}]") from e
|
|
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 _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")
|
|
if not all(isinstance(hash_val, str) for hash_val 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`."""
|
|
|
|
|
|
class PylockSelectError(Exception):
|
|
"""Base exception for errors raised by :meth:`Pylock.select`."""
|
|
|
|
|
|
@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
|
|
|
|
@property
|
|
def filename(self) -> str:
|
|
"""Get the filename of the sdist."""
|
|
filename = self.name or _path_name(self.path) or _url_name(self.url)
|
|
if not filename:
|
|
raise PylockValidationError("Cannot determine sdist filename")
|
|
return filename
|
|
|
|
|
|
@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
|
|
|
|
@property
|
|
def filename(self) -> str:
|
|
"""Get the filename of the wheel."""
|
|
filename = self.name or _path_name(self.path) or _url_name(self.url)
|
|
if not filename:
|
|
raise PylockValidationError("Cannot determine wheel filename")
|
|
return filename
|
|
|
|
|
|
@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, wheel in enumerate(package.wheels or []):
|
|
try:
|
|
(name, version, _, _) = parse_wheel_filename(wheel.filename)
|
|
except Exception as e:
|
|
raise PylockValidationError(
|
|
f"Invalid wheel filename {wheel.filename!r}",
|
|
context=f"wheels[{i}]",
|
|
) from e
|
|
if name != package.name:
|
|
raise PylockValidationError(
|
|
f"Name in {wheel.filename!r} is not consistent with "
|
|
f"package name {package.name!r}",
|
|
context=f"wheels[{i}]",
|
|
)
|
|
if package.version and version != package.version:
|
|
raise PylockValidationError(
|
|
f"Version in {wheel.filename!r} is not consistent with "
|
|
f"package version {str(package.version)!r}",
|
|
context=f"wheels[{i}]",
|
|
)
|
|
if package.sdist:
|
|
try:
|
|
name, version = parse_sdist_filename(package.sdist.filename)
|
|
except Exception as e:
|
|
raise PylockValidationError(
|
|
f"Invalid sdist filename {package.sdist.filename!r}",
|
|
context="sdist",
|
|
) from e
|
|
if name != package.name:
|
|
raise PylockValidationError(
|
|
f"Name in {package.sdist.filename!r} is not consistent with "
|
|
f"package name {package.name!r}",
|
|
context="sdist",
|
|
)
|
|
if package.version and version != package.version:
|
|
raise PylockValidationError(
|
|
f"Version in {package.sdist.filename!r} is not consistent with "
|
|
f"package version {str(package.version)!r}",
|
|
context="sdist",
|
|
)
|
|
try:
|
|
for i, attestation_identity in enumerate( # noqa: B007
|
|
package.attestation_identities or []
|
|
):
|
|
_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"):
|
|
_logger.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())
|
|
|
|
def select(
|
|
self,
|
|
*,
|
|
environment: Environment | None = None,
|
|
tags: Sequence[Tag] | None = None,
|
|
extras: Collection[str] | None = None,
|
|
dependency_groups: Collection[str] | None = None,
|
|
) -> Iterator[
|
|
tuple[
|
|
Package,
|
|
PackageVcs
|
|
| PackageDirectory
|
|
| PackageArchive
|
|
| PackageWheel
|
|
| PackageSdist,
|
|
]
|
|
]:
|
|
"""Select what to install from the lock file.
|
|
|
|
The *environment* and *tags* parameters represent the environment being
|
|
selected for. If unspecified, ``packaging.markers.default_environment()`` and
|
|
``packaging.tags.sys_tags()`` are used.
|
|
|
|
The *extras* parameter represents the extras to install.
|
|
|
|
The *dependency_groups* parameter represents the groups to install. If
|
|
unspecified, the default groups are used.
|
|
|
|
This method must be used on valid Pylock instances (i.e. one obtained
|
|
from :meth:`Pylock.from_dict` or if constructed manually, after calling
|
|
:meth:`Pylock.validate`).
|
|
"""
|
|
compatible_tags_selector = create_compatible_tags_selector(tags or sys_tags())
|
|
|
|
# #. Gather the extras and dependency groups to install and set ``extras`` and
|
|
# ``dependency_groups`` for marker evaluation, respectively.
|
|
#
|
|
# #. ``extras`` SHOULD be set to the empty set by default.
|
|
# #. ``dependency_groups`` SHOULD be the set created from
|
|
# :ref:`pylock-default-groups` by default.
|
|
env = cast(
|
|
"dict[str, str | frozenset[str]]",
|
|
dict(
|
|
environment or {}, # Marker.evaluate will fill-up
|
|
extras=frozenset(extras or []),
|
|
dependency_groups=frozenset(
|
|
(self.default_groups or [])
|
|
if dependency_groups is None # to allow selecting no group
|
|
else dependency_groups
|
|
),
|
|
),
|
|
)
|
|
env_python_full_version = (
|
|
environment["python_full_version"]
|
|
if environment
|
|
else default_environment()["python_full_version"]
|
|
)
|
|
|
|
# #. Check if the metadata version specified by :ref:`pylock-lock-version` is
|
|
# supported; an error or warning MUST be raised as appropriate.
|
|
# Covered by lock.validate() which is a precondition for this method.
|
|
|
|
# #. If :ref:`pylock-requires-python` is specified, check that the environment
|
|
# being installed for meets the requirement; an error MUST be raised if it is
|
|
# not met.
|
|
if self.requires_python and not self.requires_python.contains(
|
|
env_python_full_version,
|
|
):
|
|
raise PylockSelectError(
|
|
f"python_full_version {env_python_full_version!r} "
|
|
f"in provided environment does not satisfy the Python version "
|
|
f"requirement {str(self.requires_python)!r}"
|
|
)
|
|
|
|
# #. If :ref:`pylock-environments` is specified, check that at least one of the
|
|
# environment marker expressions is satisfied; an error MUST be raised if no
|
|
# expression is satisfied.
|
|
if self.environments:
|
|
for env_marker in self.environments:
|
|
if env_marker.evaluate(
|
|
cast("dict[str, str]", environment or {}), context="requirement"
|
|
):
|
|
break
|
|
else:
|
|
raise PylockSelectError(
|
|
"Provided environment does not satisfy any of the "
|
|
"environments specified in the lock file"
|
|
)
|
|
|
|
# #. For each package listed in :ref:`pylock-packages`:
|
|
selected_packages_by_name: dict[str, tuple[int, Package]] = {}
|
|
for package_index, package in enumerate(self.packages):
|
|
# #. If :ref:`pylock-packages-marker` is specified, check if it is
|
|
# satisfied;if it isn't, skip to the next package.
|
|
if package.marker and not package.marker.evaluate(env, context="lock_file"):
|
|
continue
|
|
|
|
# #. If :ref:`pylock-packages-requires-python` is specified, check if it is
|
|
# satisfied; an error MUST be raised if it isn't.
|
|
if package.requires_python and not package.requires_python.contains(
|
|
env_python_full_version,
|
|
):
|
|
raise PylockSelectError(
|
|
f"python_full_version {env_python_full_version!r} "
|
|
f"in provided environment does not satisfy the Python version "
|
|
f"requirement {str(package.requires_python)!r} for package "
|
|
f"{package.name!r} at packages[{package_index}]"
|
|
)
|
|
|
|
# #. Check that no other conflicting instance of the package has been slated
|
|
# to be installed; an error about the ambiguity MUST be raised otherwise.
|
|
if package.name in selected_packages_by_name:
|
|
raise PylockSelectError(
|
|
f"Multiple packages with the name {package.name!r} are "
|
|
f"selected at packages[{package_index}] and "
|
|
f"packages[{selected_packages_by_name[package.name][0]}]"
|
|
)
|
|
|
|
# #. Check that the source of the package is specified appropriately (i.e.
|
|
# there are no conflicting sources in the package entry);
|
|
# an error MUST be raised if any issues are found.
|
|
# Covered by lock.validate() which is a precondition for this method.
|
|
|
|
# #. Add the package to the set of packages to install.
|
|
selected_packages_by_name[package.name] = (package_index, package)
|
|
|
|
# #. For each package to be installed:
|
|
for package_index, package in selected_packages_by_name.values():
|
|
# - If :ref:`pylock-packages-vcs` is set:
|
|
if package.vcs is not None:
|
|
yield package, package.vcs
|
|
|
|
# - Else if :ref:`pylock-packages-directory` is set:
|
|
elif package.directory is not None:
|
|
yield package, package.directory
|
|
|
|
# - Else if :ref:`pylock-packages-archive` is set:
|
|
elif package.archive is not None:
|
|
yield package, package.archive
|
|
|
|
# - Else if there are entries for :ref:`pylock-packages-wheels`:
|
|
elif package.wheels:
|
|
# #. Look for the appropriate wheel file based on
|
|
# :ref:`pylock-packages-wheels-name`; if one is not found then move
|
|
# on to :ref:`pylock-packages-sdist` or an error MUST be raised about
|
|
# a lack of source for the project.
|
|
best_wheel = next(
|
|
compatible_tags_selector(
|
|
(wheel, parse_wheel_filename(wheel.filename)[-1])
|
|
for wheel in package.wheels
|
|
),
|
|
None,
|
|
)
|
|
if best_wheel:
|
|
yield package, best_wheel
|
|
elif package.sdist is not None:
|
|
yield package, package.sdist
|
|
else:
|
|
raise PylockSelectError(
|
|
f"No wheel found matching the provided tags "
|
|
f"for package {package.name!r} "
|
|
f"at packages[{package_index}], "
|
|
f"and no sdist available as a fallback"
|
|
)
|
|
|
|
# - Else if no :ref:`pylock-packages-wheels` file is found or
|
|
# :ref:`pylock-packages-sdist` is solely set:
|
|
elif package.sdist is not None:
|
|
yield package, package.sdist
|
|
|
|
else:
|
|
# Covered by lock.validate() which is a precondition for this method.
|
|
raise NotImplementedError # pragma: no cover
|