diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 61db915..4c2d29e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,8 @@ repos: - id: mypy args: [] additional_dependencies: - - pydantic + - attrs + - cattrs ci: autoupdate_schedule: "quarterly" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4737603..c061440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Replaced the `pydantic` dependency with `attrs` + `cattrs`. The public spec + classes (`InfoSpec`, `PackageSpec`, `PyodideLockSpec`) are now `attrs` classes. + Construct them from dictionaries with `PyodideLockSpec.from_dict(...)` (and the + equivalent `from_dict` on the other specs) instead of `PyodideLockSpec(**data)`, + and serialize with `.to_dict()` instead of `.model_dump()`. + ## [0.1.3] - 2026-04-21 ### Changed diff --git a/pyodide_lock/spec.py b/pyodide_lock/spec.py index a4f6a0e..0801cd2 100644 --- a/pyodide_lock/spec.py +++ b/pyodide_lock/spec.py @@ -1,57 +1,90 @@ +import copy import json from pathlib import Path -from typing import Literal +from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +import attrs +import cattrs +from attrs import define, field +from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override -class InfoSpec(BaseModel): - arch: Literal["wasm32", "wasm64"] = "wasm32" +class SpecValidationError(ValueError): + """Raised when a lock spec fails validation.""" + + +@define +class InfoSpec: platform: str - # This field is deprecated and will not be included in the output - version: str = Field(default="0.0.0", exclude=True) python: str + arch: Literal["wasm32", "wasm64"] = "wasm32" + # This field is deprecated and will not be included in the output + version: str = field(default="0.0.0", metadata={"exclude": True}) abi_version: str | None = None - model_config = ConfigDict(extra="forbid") + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InfoSpec": + return _converter.structure(data, cls) + + def to_dict(self) -> dict[str, Any]: + return _converter.unstructure(self) -class PackageSpec(BaseModel): +@define +class PackageSpec: name: str version: str - file_name: str = Field( - description="Path (or URL) to wheel.", - ) + #: Path (or URL) to wheel. + file_name: str install_dir: str sha256: str = "" package_type: Literal[ "package", "cpython_module", "shared_library", "static_library" ] = "package" - imports: list[str] = [] - depends: list[str] = [] + imports: list[str] = field(factory=list) + depends: list[str] = field(factory=list) unvendored_tests: bool = False # This field is deprecated and will not be included in the output - shared_library: bool = Field(default=False, exclude=True) - model_config = ConfigDict(extra="forbid") + shared_library: bool = field(default=False, metadata={"exclude": True}) + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PackageSpec": + return _converter.structure(data, cls) -class PyodideLockSpec(BaseModel): + def to_dict(self) -> dict[str, Any]: + return _converter.unstructure(self) + + +@define +class PyodideLockSpec: """A specification for the pyodide-lock.json file.""" info: InfoSpec packages: dict[str, PackageSpec] - model_config = ConfigDict(extra="forbid") @classmethod def from_json(cls, path: Path) -> "PyodideLockSpec": """Read the lock spec from a json file.""" with path.open("r", encoding="utf-8") as fh: data = json.load(fh) - return cls(**data) + return cls.from_dict(data) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PyodideLockSpec": + """Build a lock spec from a dictionary.""" + return _converter.structure(data, cls) + + def to_dict(self) -> dict[str, Any]: + return _converter.unstructure(self) + + def clone(self) -> "PyodideLockSpec": + """Return a deep copy of this lock spec.""" + return copy.deepcopy(self) def to_json(self, path: Path, indent: int | None = None) -> None: """Write the lock spec to a json file.""" with path.open("w", encoding="utf-8") as fh: - model_dict = self.model_dump() + model_dict = self.to_dict() json_str = json.dumps(model_dict, indent=indent, sort_keys=True) fh.write(json_str) @@ -87,3 +120,36 @@ def check_wheel_filenames(self) -> None: for name, errs in errors.items() ) raise ValueError(error_msg) + + +# --------------------------------------------------------------------------- +# (de)serialization (attrs + cattrs) +# --------------------------------------------------------------------------- + + +def _exclude_overrides(cls: type) -> dict[str, Any]: + return { + f.name: override(omit=True) + for f in attrs.fields(cls) + if f.metadata.get("exclude") + } + + +_converter = cattrs.Converter(detailed_validation=False) + +for _cls in (InfoSpec, PackageSpec, PyodideLockSpec): + _converter.register_structure_hook( + _cls, + make_dict_structure_fn( + _cls, + _converter, + _cattrs_forbid_extra_keys=True, + ), + ) + +# Some fields are deprecated and excluded from the serialized output. +for _cls in (InfoSpec, PackageSpec): + _converter.register_unstructure_hook( + _cls, + make_dict_unstructure_fn(_cls, _converter, **_exclude_overrides(_cls)), + ) diff --git a/pyodide_lock/utils.py b/pyodide_lock/utils.py index d473ea2..cc0d2c1 100644 --- a/pyodide_lock/utils.py +++ b/pyodide_lock/utils.py @@ -182,7 +182,7 @@ def add_wheels_to_spec( not 100% reliable, because it ignores any extras and does not do any sub-dependency or version resolution. """ - new_spec = lock_spec.model_copy(deep=True) + new_spec = lock_spec.clone() if not wheel_files: return new_spec wheel_files = [f.resolve() for f in wheel_files] @@ -214,7 +214,7 @@ def _fix_new_package_deps( from packaging.utils import canonicalize_name requirements_with_extras = [] - marker_environment = _get_marker_environment(**lock_spec.info.model_dump()) + marker_environment = _get_marker_environment(**lock_spec.info.to_dict()) for package in new_packages.values(): # add any requirements to the list of packages our_depends = [] @@ -264,7 +264,7 @@ def _fix_extra_dep( requirements_with_extras = [] - marker_environment = _get_marker_environment(**lock_spec.info.model_dump()) + marker_environment = _get_marker_environment(**lock_spec.info.to_dict()) extra_package_name = canonicalize_name(extra_req.name) if extra_package_name not in new_packages: return [] @@ -386,7 +386,7 @@ def package_spec_from_wheel(path: Path, info: InfoSpec) -> PackageSpec: sha256=_generate_package_hash(path), package_type="package", install_dir="site", - imports=parse_top_level_import_name(path), + imports=parse_top_level_import_name(path) or [], depends=[], ) diff --git a/pyodide_lock/uv_pip_compile.py b/pyodide_lock/uv_pip_compile.py index 749a44d..b5a122c 100644 --- a/pyodide_lock/uv_pip_compile.py +++ b/pyodide_lock/uv_pip_compile.py @@ -15,10 +15,12 @@ from typing import TYPE_CHECKING, Any from urllib import parse, request +import attrs +import cattrs import pkginfo +from attrs import define, field from packaging.requirements import Requirement from packaging.utils import NormalizedName, canonicalize_name -from pydantic import BaseModel, Field from .spec import PackageSpec, PyodideLockSpec from .utils import add_wheels_to_spec, logger @@ -94,7 +96,8 @@ def _find_uv_path() -> Path | None: # pragma: no cover return None -class UvPipCompile(BaseModel): +@define +class UvPipCompile: """Update a partial Pyodide distribution with ``uv pip compile``.""" # input/output ############################################################# @@ -109,27 +112,27 @@ class UvPipCompile(BaseModel): #: indent level for output lock indent: int | None = None #: if given, preserve remote URLs starting with these prefixes - preserve_url_prefixes: list[str] = Field(default_factory=list) + preserve_url_prefixes: list[str] = field(factory=list) #: if given, rewrite all missing local wheels with this URL prefix base_url_for_missing: str | None = None # packages ################################################################# #: list of PEP-508 specs to include when solving - specs: list[str] = Field(default_factory=list) + specs: list[str] = field(factory=list) #: list of local wheels to include when solving - wheels: list[Path] = Field(default_factory=list) + wheels: list[Path] = field(factory=list) #: list of PEP-508 specs to constrain when solving - constraints: list[str] = Field(default_factory=list) + constraints: list[str] = field(factory=list) #: list of PEP-508 specs to exclude from solving - excludes: list[str] = Field(default_factory=list) + excludes: list[str] = field(factory=list) # solver ################################################################### #: the ``uv`` python platform for pyodide python_platform: str = DEFAULT_UV_PYODIDE_PLATFORM #: the ``uv`` binary - uv_path: Path | None = Field(default_factory=_find_uv_path) + uv_path: Path | None = field(factory=_find_uv_path) #: extra arguments to ``uv pip compile`` - extra_uv_args: list[str] = Field(default_factory=list) + extra_uv_args: list[str] = field(factory=list) # misc ##################################################################### #: a working directory; if unset, uses a temp folder, cleaned on success @@ -137,6 +140,11 @@ class UvPipCompile(BaseModel): #: increase logging level while updating debug: bool | None = None + @classmethod + def from_dict(cls, data: dict[str, Any]) -> UvPipCompile: + """Build from a dictionary, ignoring any unknown keys.""" + return _converter.structure(data, cls) + def update(self) -> PyodideLockSpec: """Update a lock with ``uv pip compile``, managing logging and work folder.""" old_log_level = logger.level @@ -425,7 +433,8 @@ def validate_depends(self, lock_spec: PyodideLockSpec) -> None: raise InvalidPyodideLockError(msg) -class Pep751Toml(BaseModel): +@define +class Pep751Toml: """A PEP-751 ``pylock.toml``.""" #: the path on disk @@ -463,7 +472,8 @@ def from_uv_pip_compile( return cls(path=path) -class Pep508Text(BaseModel): +@define +class Pep508Text: """A ``requirements.txt``-style file for requirements, constraints, excludes, etc.""" #: the path on disk @@ -475,7 +485,7 @@ class Pep508Text(BaseModel): def text(self) -> str: return "\n".join(sorted(self.specs.values())) - def model_post_init(self, _context: Any) -> None: + def __attrs_post_init__(self) -> None: """Write the validated specs out to disk.""" self.path.parent.mkdir(parents=True, exist_ok=True) text = self.text @@ -499,3 +509,11 @@ def from_raw_specs( specs[name] = spec return specs + + +# The module uses ``from __future__ import annotations``; resolve string +# annotations so cattrs can build a structure hook for ``UvPipCompile``. +attrs.resolve_types(UvPipCompile, globalns=globals()) + +#: extra keys are ignored when structuring (mirrors the previous pydantic default) +_converter = cattrs.Converter() diff --git a/pyproject.toml b/pyproject.toml index bb25c13..fd8349d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,8 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ # compatible with pyodide-build and the as-shipped wheel in the pyodide distribution - "pydantic>=2", + "attrs", + "cattrs", ] classifiers = [ "Programming Language :: Python :: 3", @@ -69,7 +70,6 @@ python_version = "3.10" mypy_path = ["pyodide_lock", "tests"] show_error_codes = true warn_unreachable = true -plugins = ["pydantic.mypy"] check_untyped_defs = true ignore_missing_imports = true diff --git a/tests/conftest.py b/tests/conftest.py index 1fd7840..a64a0cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,7 +76,7 @@ def example_lock_data(): @pytest.fixture def example_lock_spec(): - return PyodideLockSpec(**deepcopy(LOCK_EXAMPLE)) + return PyodideLockSpec.from_dict(deepcopy(LOCK_EXAMPLE)) # build a wheel diff --git a/tests/test_spec.py b/tests/test_spec.py index d89a7eb..703ccaa 100644 --- a/tests/test_spec.py +++ b/tests/test_spec.py @@ -27,20 +27,20 @@ def test_lock_spec_parsing(pyodide_version, tmp_path): spec2 = PyodideLockSpec.from_json(target2_path) - assert spec.info.model_dump() == spec2.info.model_dump() + assert spec.info.to_dict() == spec2.info.to_dict() assert set(spec.packages.keys()) == set(spec2.packages.keys()) for key in spec.packages: pkg1 = spec.packages[key] pkg2 = spec2.packages[key] - assert pkg1.model_dump() == pkg2.model_dump() + assert pkg1.to_dict() == pkg2.to_dict() def test_check_wheel_filenames(example_lock_data): - spec = PyodideLockSpec(**example_lock_data) + spec = PyodideLockSpec.from_dict(example_lock_data) spec.check_wheel_filenames() example_lock_data["packages"]["numpy"]["name"] = "numpy2" # type: ignore[index] - spec = PyodideLockSpec(**example_lock_data) + spec = PyodideLockSpec.from_dict(example_lock_data) msg = ( ".*check_wheel_filenames failed.*\n.*numpy:\n.*" "Package name in wheel filename 'numpy' does not match 'numpy2'" @@ -49,7 +49,7 @@ def test_check_wheel_filenames(example_lock_data): spec.check_wheel_filenames() example_lock_data["packages"]["numpy"]["version"] = "0.2.3" # type: ignore[index] - spec = PyodideLockSpec(**example_lock_data) + spec = PyodideLockSpec.from_dict(example_lock_data) msg = ( ".*check_wheel_filenames failed.*\n.*numpy:\n.*" "Package name in wheel filename 'numpy' does not match 'numpy2'\n.*" @@ -63,7 +63,7 @@ def test_check_wheel_filenames(example_lock_data): def test_to_json_indent(tmp_path, example_lock_data): target_path = tmp_path / "pyodide-lock.json" - spec = PyodideLockSpec(**example_lock_data) + spec = PyodideLockSpec.from_dict(example_lock_data) spec.to_json(target_path) assert "\n" not in target_path.read_text() @@ -79,14 +79,14 @@ def test_update_sha256(monkeypatch, example_lock_data): monkeypatch.setattr("pyodide_lock.utils._generate_package_hash", lambda x: "abcd") example_lock_data["packages"]["numpy"]["sha256"] = "0" # type: ignore[index] - spec = PyodideLockSpec(**example_lock_data) + spec = PyodideLockSpec.from_dict(example_lock_data) assert spec.packages["numpy"].sha256 == "0" update_package_sha256(spec.packages["numpy"], Path("/some/path")) assert spec.packages["numpy"].sha256 == "abcd" def test_extra_config_forbidden(example_lock_data): - from pydantic import ValidationError + from cattrs.errors import ForbiddenExtraKeysError info_data = deepcopy(example_lock_data["info"]) package_data = deepcopy( @@ -97,19 +97,19 @@ def test_extra_config_forbidden(example_lock_data): info_data["extra"] = "extra" # type: ignore[index] package_data["extra"] = "extra" - with pytest.raises(ValidationError, match="Extra inputs are not permitted"): - PyodideLockSpec(**example_lock_data) + with pytest.raises(ForbiddenExtraKeysError, match="extra"): + PyodideLockSpec.from_dict(example_lock_data) - with pytest.raises(ValidationError, match="Extra inputs are not permitted"): - InfoSpec(**info_data) # type: ignore[arg-type] + with pytest.raises(ForbiddenExtraKeysError, match="extra"): + InfoSpec.from_dict(info_data) # type: ignore[arg-type] - with pytest.raises(ValidationError, match="Extra inputs are not permitted"): - PackageSpec(**package_data) + with pytest.raises(ForbiddenExtraKeysError, match="extra"): + PackageSpec.from_dict(package_data) def test_exclude_key(example_lock_data): - spec = PyodideLockSpec(**example_lock_data) - dump = spec.model_dump() + spec = PyodideLockSpec.from_dict(example_lock_data) + dump = spec.to_dict() assert "packages" in dump for pkg in dump["packages"].values(): assert "shared_library" not in pkg diff --git a/tests/test_uv_pip_compile.py b/tests/test_uv_pip_compile.py index c98c964..7551b8c 100644 --- a/tests/test_uv_pip_compile.py +++ b/tests/test_uv_pip_compile.py @@ -97,7 +97,7 @@ if WHEEL and WHEEL.is_file(): TEST_CASES["0.29.0-add-pkg-by-whl"] = ( {"wheels": [WHEEL], **COMMON_0290}, - ["pyodide-lock"], + ["pyodide-lock", "cattrs"], ) TEST_CASES["0.29.0-add-whl-by-constraint"] = ( { @@ -127,7 +127,7 @@ def test_uv_pip_compile(test_case: str, tmp_path: Path) -> None: } # run the build - upc = UvPipCompile(**base_kwargs, **kwargs) + upc = UvPipCompile.from_dict({**base_kwargs, **kwargs}) upc.update() raw_lock = json.loads(output_path.read_text(encoding="utf-8")) diff = len(diff_json(input_path, output_path)) @@ -152,7 +152,7 @@ def test_uv_pip_compile(test_case: str, tmp_path: Path) -> None: ] # run the build again, in-place - upc = UvPipCompile(input_path=output_path, **relock_kwargs) + upc = UvPipCompile.from_dict({"input_path": output_path, **relock_kwargs}) upc.update() # verify no changes diff --git a/tests/test_wheel.py b/tests/test_wheel.py index bd95e06..61df102 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -115,7 +115,7 @@ def test_self_wheel(example_lock_spec): sha256=_generate_package_hash(WHEEL), package_type="package", imports=["pyodide_lock"], - depends=["pydantic"], + depends=["attrs", "cattrs"], unvendored_tests=False, )