Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion src/packaging/pylock.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,45 @@ def _from_dict(cls, d: Mapping[str, Any]) -> Self: ...

_PYLOCK_FILE_NAME_RE = re.compile(r"^pylock\.([^.]+)\.toml$")

_PYLOCK_1_0_KEYS = frozenset(
{
"lock-version",
"environments",
"requires-python",
"extras",
"dependency-groups",
"default-groups",
"created-by",
"packages",
"tool",
}
)
_PACKAGE_KEYS = frozenset(
{
"name",
"version",
"marker",
"requires-python",
"dependencies",
"vcs",
"directory",
"archive",
"index",
"sdist",
"wheels",
"attestation-identities",
"tool",
}
)
_VCS_KEYS = frozenset(
{"type", "url", "path", "requested-revision", "commit-id", "subdirectory"}
)
_DIRECTORY_KEYS = frozenset({"path", "editable", "subdirectory"})
_ARCHIVE_KEYS = frozenset(
{"url", "path", "size", "upload-time", "hashes", "subdirectory"}
)
_DISTRIBUTION_KEYS = frozenset({"name", "upload-time", "url", "path", "size", "hashes"})


def is_valid_pylock_path(path: Path) -> bool:
"""Check if the given path is a valid pylock file path."""
Expand Down Expand Up @@ -235,6 +274,65 @@ def _get_required_sequence_of_objects(
return result


def _validate_known_keys(d: Mapping[str, Any], expected_keys: frozenset[str]) -> None:
"""Reject keys outside a schema-defined closed object."""
for key in d:
if key not in expected_keys:
raise PylockValidationError("Unexpected key", context=str(key))


def _validate_nested_object_keys(
d: Mapping[str, Any], key: str, expected_keys: frozenset[str]
) -> None:
value = d.get(key)
if not isinstance(value, Mapping):
return
try:
_validate_known_keys(value, expected_keys)
except Exception as e:
raise PylockValidationError(e, context=key) from e


def _validate_nested_sequence_keys(
d: Mapping[str, Any], key: str, expected_keys: frozenset[str]
) -> None:
value = d.get(key)
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
return
for i, item in enumerate(value):
if not isinstance(item, Mapping):
continue
try:
_validate_known_keys(item, expected_keys)
except Exception as e:
raise PylockValidationError(e, context=f"{key}[{i}]") from e


def _validate_pylock_1_0_keys(d: Mapping[str, Any]) -> None:
"""Apply the closed-object rules from the pylock 1.0 schema.

Dependency references, attestation identities, hashes, and tool tables
intentionally preserve the parser's existing open-map behavior, so they are
not traversed here.
"""
_validate_known_keys(d, _PYLOCK_1_0_KEYS)
packages = d.get("packages")
if not isinstance(packages, Sequence) or isinstance(packages, (str, bytes)):
return
for i, package in enumerate(packages):
if not isinstance(package, Mapping):
continue
try:
_validate_known_keys(package, _PACKAGE_KEYS)
_validate_nested_object_keys(package, "vcs", _VCS_KEYS)
_validate_nested_object_keys(package, "directory", _DIRECTORY_KEYS)
_validate_nested_object_keys(package, "archive", _ARCHIVE_KEYS)
_validate_nested_object_keys(package, "sdist", _DISTRIBUTION_KEYS)
_validate_nested_sequence_keys(package, "wheels", _DISTRIBUTION_KEYS)
except Exception as e:
raise PylockValidationError(e, context=f"packages[{i}]") from e


def _validate_normalized_name(name: str) -> NormalizedName:
"""Validate that a string is a NormalizedName."""
if not is_normalized_name(name):
Expand Down Expand Up @@ -703,8 +801,11 @@ def __init__(

@classmethod
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
lock_version = _get_required_as(d, str, Version, "lock-version")
if lock_version == Version("1.0"):
_validate_pylock_1_0_keys(d)
pylock = cls(
lock_version=_get_required_as(d, str, Version, "lock-version"),
lock_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"),
Expand Down
115 changes: 115 additions & 0 deletions tests/test_pylock.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,121 @@ def test_pylock_version(version: str) -> None:
Pylock.from_dict(data)


@pytest.mark.parametrize(
("data_update", "error_context"),
[
({"requires_python": ">=99"}, "requires_python"),
(
{
"packages": [
{
"name": "example",
"directory": {"path": "."},
"markr": 'python_version >= "99"',
}
]
},
"packages[0].markr",
),
(
{
"packages": [
{
"name": "example",
"directory": {"path": ".", "unexpected": True},
}
]
},
"packages[0].directory.unexpected",
),
(
{
"packages": [
{
"name": "example",
"wheels": [
{
"name": "example-1.0-py3-none-any.whl",
"unexpected": True,
}
],
}
]
},
"packages[0].wheels[0].unexpected",
),
],
)
def test_pylock_1_0_rejects_unknown_keys(
data_update: dict[str, Any], error_context: str
) -> None:
data = {
"lock-version": "1.0",
"created-by": "pip",
"packages": [],
**data_update,
}
with pytest.raises(PylockValidationError) as exc_info:
Pylock.from_dict(data)
assert str(exc_info.value) == f"Unexpected key in {error_context!r}"


def test_pylock_1_0_leaves_invalid_wheel_types_to_value_validation() -> None:
data = {
"lock-version": "1.0",
"created-by": "pip",
"packages": [{"name": "example", "wheels": [42]}],
}
with pytest.raises(PylockValidationError) as exc_info:
Pylock.from_dict(data)
assert str(exc_info.value) == (
"Unexpected type int (expected Mapping) in 'packages[0].wheels[0]'"
)


def test_pylock_1_0_preserves_open_extension_maps() -> None:
data = {
"lock-version": "1.0",
"created-by": "pip",
"tool": {"vendor": {"future": True}},
"packages": [
{
"name": "example",
"archive": {
"path": "example.tar.gz",
"hashes": {"sha256": "f" * 40, "future": "value"},
},
"dependencies": [{"name": "dependency", "future": "value"}],
"attestation-identities": [
{
"kind": "GitHub",
"repository": "example/project",
"workflow": "release.yml",
}
],
"tool": {"vendor": {"future": True}},
}
],
}
Pylock.from_dict(data)


def test_future_pylock_minor_preserves_unknown_keys() -> None:
data = {
"lock-version": "1.1",
"created-by": "pip",
"future": True,
"packages": [
{
"name": "example",
"directory": {"path": ".", "future": True},
"future": True,
}
],
}
Pylock.from_dict(data)


@pytest.mark.parametrize("version", ["0.9", "2", "2.0", "2.1"])
def test_pylock_unsupported_version(version: str) -> None:
data = {
Expand Down
Loading