Skip to content

Commit c66f02f

Browse files
committed
flesh out schema syntax
1 parent 92e9511 commit c66f02f

File tree

6 files changed

+226
-29
lines changed

6 files changed

+226
-29
lines changed

pyodide_lock/spec.py

+115-17
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,124 @@
22
from pathlib import Path
33
from typing import Literal
44

5-
from pydantic import BaseModel, Extra
5+
from pydantic import BaseModel, Extra, Field
66

77
from .utils import (
8+
_add_required,
89
_generate_package_hash,
910
_wheel_depends,
1011
parse_top_level_import_name,
1112
)
1213

1314

1415
class InfoSpec(BaseModel):
15-
arch: Literal["wasm32", "wasm64"] = "wasm32"
16-
platform: str
17-
version: str
18-
python: str
16+
arch: Literal["wasm32", "wasm64"] = Field(
17+
default="wasm32",
18+
description=(
19+
"the short name for the compiled architecture, available in "
20+
"dependency markers as `platform_machine`"
21+
),
22+
)
23+
platform: str = Field(
24+
description=(
25+
"the emscripten virtual machine for which this distribution is "
26+
" compiled, not available directly in a dependency marker: use e.g. "
27+
"""`plaform_system == "Emscripten" and platform_release == "3.1.45"`"""
28+
),
29+
examples=["emscripten_3_1_32", "emscripten_3_1_45"],
30+
)
31+
version: str = Field(
32+
description="the PEP 440 version of pyodide",
33+
examples=["0.24.1", "0.23.3"],
34+
)
35+
python: str = Field(
36+
description=(
37+
"the version of python for which this lockfile is valid, available in "
38+
"version markers as `platform_machine`"
39+
),
40+
examples=["3.11.2", "3.11.3"],
41+
)
1942

2043
class Config:
2144
extra = Extra.forbid
2245

46+
schema_extra = _add_required(
47+
"arch",
48+
description=(
49+
"the execution environment in which the packages in this lockfile "
50+
"can be installed"
51+
),
52+
)
53+
2354

2455
class PackageSpec(BaseModel):
25-
name: str
26-
version: str
27-
file_name: str
28-
install_dir: str
29-
sha256: str = ""
56+
name: str = Field(
57+
description="the verbatim name as found in the package's metadata",
58+
examples=["pyodide-lock", "PyYAML", "ruamel.yaml"],
59+
)
60+
version: str = Field(
61+
description="the reported version of the package",
62+
examples=["0.1.0", "1.0.0a0", "1.0.0a0.post1"],
63+
)
64+
file_name: str = Field(
65+
format="uri-reference",
66+
description="the URL of the file",
67+
examples=[
68+
"pyodide_lock-0.1.0-py3-none-any.whl",
69+
"https://files.pythonhosted.org/packages/py3/m/micropip/micropip-0.5.0-py3-none-any.whl",
70+
],
71+
)
72+
install_dir: str = Field(
73+
default="site",
74+
description="the file system destination for a package's data",
75+
examples=["dynlib", "stdlib"],
76+
)
77+
sha256: str = Field(description="the SHA256 cryptographic hash of the file")
3078
package_type: Literal[
3179
"package", "cpython_module", "shared_library", "static_library"
32-
] = "package"
33-
imports: list[str] = []
34-
depends: list[str] = []
35-
unvendored_tests: bool = False
80+
] = Field(
81+
default="package",
82+
description="the top-level kind of content provided by this package",
83+
)
84+
imports: list[str] = Field(
85+
default=[],
86+
description=(
87+
"the importable names provided by this package."
88+
"note that PEP 420 namespace packages will likely not be correctly found."
89+
),
90+
)
91+
depends: list[str] = Field(
92+
default=[],
93+
unique_items=True,
94+
description=(
95+
"package names that must be installed when this package in installed"
96+
),
97+
)
98+
unvendored_tests: bool = Field(
99+
default=False,
100+
description=(
101+
"whether the package's tests folder have been repackaged "
102+
"as a separate archive"
103+
),
104+
)
36105
# This field is deprecated
37-
shared_library: bool = False
106+
shared_library: bool = Field(
107+
default=False,
108+
deprecated=True,
109+
description=(
110+
"(deprecated) whether this package is a shared library. "
111+
"replaced with `package_type: shared_library`"
112+
),
113+
)
38114

39115
class Config:
40116
extra = Extra.forbid
117+
schema_extra = _add_required(
118+
"depends",
119+
"imports",
120+
"install_dir",
121+
description="a single pyodide-compatible file",
122+
)
41123

42124
@classmethod
43125
def from_wheel(
@@ -78,11 +160,27 @@ def update_sha256(self, path: Path) -> "PackageSpec":
78160
class PyodideLockSpec(BaseModel):
79161
"""A specification for the pyodide-lock.json file."""
80162

81-
info: InfoSpec
82-
packages: dict[str, PackageSpec]
163+
info: InfoSpec = Field(
164+
description=(
165+
"the execution environment in which the packages in this lockfile "
166+
"can be installable"
167+
)
168+
)
169+
packages: dict[str, PackageSpec] = Field(
170+
default={},
171+
description="a set of packages keyed by name",
172+
)
83173

84174
class Config:
85175
extra = Extra.forbid
176+
schema_extra = {
177+
"$schema": "https://json-schema.org/draft/2019-09/schema#",
178+
"$id": ("https://pyodide.org/schema/pyodide-lock/v0-lockfile.schema.json"),
179+
"description": (
180+
"a description of a viable pyodide runtime environment, "
181+
"as defined by pyodide-lock"
182+
),
183+
}
86184

87185
@classmethod
88186
def from_json(cls, path: Path) -> "PyodideLockSpec":

pyodide_lock/utils.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import sys
55
import zipfile
66
from collections import deque
7+
from collections.abc import Callable
78
from pathlib import Path
8-
from typing import TYPE_CHECKING
9+
from typing import TYPE_CHECKING, Any
910

1011
if TYPE_CHECKING:
1112
from pkginfo import Distribution
@@ -130,3 +131,13 @@ def _wheel_depends(
130131
depends += [canonicalize_name(req.name)]
131132

132133
return sorted(set(depends))
134+
135+
136+
def _add_required(
137+
*field_names: str, **extra: Any
138+
) -> Callable[[dict[str, Any], Any], None]:
139+
def add_required(schema: dict[str, Any], *args: Any) -> None:
140+
schema["required"] = sorted([*field_names, *schema.get("required", [])])
141+
schema.update(extra)
142+
143+
return add_required

pyproject.toml

+9
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,22 @@ wheel = [
2626
"pkginfo",
2727
"packaging",
2828
]
29+
schema = [
30+
"jsonschema >=4",
31+
"rfc3986-validator",
32+
]
2933
dev = [
3034
"pytest",
3135
"pytest-cov",
3236
"build",
3337
# from wheel
3438
"pkginfo",
3539
"packaging",
40+
# from schema
41+
"jsonschema >=4",
42+
"rfc3986-validator",
43+
# stubs
44+
"types-jsonschema",
3645
]
3746

3847
[project.urls]

tests/conftest.py

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import gzip
2+
import shutil
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
HERE = Path(__file__).parent
8+
DATA_DIR = Path(__file__).parent / "data"
9+
SPEC_JSON_GZ = sorted(DATA_DIR.glob("*.json.gz"))
10+
11+
12+
@pytest.fixture(params=SPEC_JSON_GZ)
13+
def an_historic_spec_gz(request) -> Path:
14+
return request.param
15+
16+
17+
@pytest.fixture
18+
def an_historic_spec_json(tmp_path: Path, an_historic_spec_gz: Path) -> Path:
19+
target_path = tmp_path / "pyodide-lock.json"
20+
21+
with gzip.open(an_historic_spec_gz) as fh_in:
22+
with target_path.open("wb") as fh_out:
23+
shutil.copyfileobj(fh_in, fh_out)
24+
25+
return target_path

tests/test_schema.py

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import json
2+
from pathlib import Path
3+
from typing import Any
4+
5+
import pytest
6+
from jsonschema import ValidationError
7+
from jsonschema.validators import Draft201909Validator
8+
9+
from pyodide_lock import PyodideLockSpec
10+
11+
#: a schema that constrains the schema itself for schema syntax
12+
META_SCHEMA = {
13+
"type": "object",
14+
"required": ["description", "$id", "$schema"],
15+
"properties": {
16+
"description": {"type": "string"},
17+
"$id": {"type": "string", "format": "uri"},
18+
"$schema": {"type": "string", "format": "uri"},
19+
"definitions": {"patternProperties": {".*": {"required": ["description"]}}},
20+
},
21+
}
22+
23+
Validator = Draft201909Validator
24+
FORMAT_CHECKER = Draft201909Validator.FORMAT_CHECKER
25+
26+
27+
@pytest.fixture
28+
def schema() -> dict[str, Any]:
29+
return PyodideLockSpec.schema()
30+
31+
32+
@pytest.fixture
33+
def spec_validator(schema: dict[str, Any]) -> Validator:
34+
return Validator(schema, format_checker=FORMAT_CHECKER)
35+
36+
37+
def test_documentation(schema: dict[str, Any]) -> None:
38+
meta_validator = Validator(META_SCHEMA, format_checker=FORMAT_CHECKER)
39+
_assert_validation_errors(meta_validator, schema)
40+
41+
42+
def test_validate(an_historic_spec_json: Path, spec_validator: Validator) -> None:
43+
spec_json = json.loads(an_historic_spec_json.read_text(encoding="utf-8"))
44+
_assert_validation_errors(spec_validator, spec_json)
45+
46+
47+
def _assert_validation_errors(
48+
validator: Draft201909Validator,
49+
instance: dict[str, Any],
50+
expect_errors: list[str] | None = None,
51+
) -> None:
52+
expect_errors = expect_errors or []
53+
expect_error_count = len(expect_errors)
54+
55+
errors: list[ValidationError] = list(validator.iter_errors(instance))
56+
error_count = len(errors)
57+
58+
print("\n".join([f"""{err.json_path}: {err.message}""" for err in errors]))
59+
60+
assert error_count == expect_error_count

tests/test_spec.py

+5-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import gzip
2-
import shutil
31
from copy import deepcopy
42
from pathlib import Path
53

@@ -33,17 +31,10 @@
3331
}
3432

3533

36-
@pytest.mark.parametrize("pyodide_version", ["0.22.1", "0.23.3"])
37-
def test_lock_spec_parsing(pyodide_version, tmp_path):
38-
source_path = DATA_DIR / f"pyodide-lock-{pyodide_version}.json.gz"
39-
target_path = tmp_path / "pyodide-lock.json"
34+
def test_lock_spec_parsing(an_historic_spec_json: Path, tmp_path):
4035
target2_path = tmp_path / "pyodide-lock2.json"
4136

42-
with gzip.open(source_path) as fh_in:
43-
with target_path.open("wb") as fh_out:
44-
shutil.copyfileobj(fh_in, fh_out)
45-
46-
spec = PyodideLockSpec.from_json(target_path)
37+
spec = PyodideLockSpec.from_json(an_historic_spec_json)
4738
spec.to_json(target2_path, indent=2)
4839

4940
spec2 = PyodideLockSpec.from_json(target2_path)
@@ -53,6 +44,9 @@ def test_lock_spec_parsing(pyodide_version, tmp_path):
5344
for key in spec.packages:
5445
assert spec.packages[key] == spec2.packages[key]
5546

47+
with pytest.raises(ValueError, match="does not match package version"):
48+
spec.check_wheel_filenames()
49+
5650

5751
def test_check_wheel_filenames():
5852
lock_data = deepcopy(LOCK_EXAMPLE)

0 commit comments

Comments
 (0)