Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ repos:
- id: mypy
args: []
additional_dependencies:
- pydantic
- attrs
- cattrs

ci:
autoupdate_schedule: "quarterly"
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 85 additions & 19 deletions pyodide_lock/spec.py
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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)),
)
8 changes: 4 additions & 4 deletions pyodide_lock/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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=[],
)

Expand Down
42 changes: 30 additions & 12 deletions pyodide_lock/uv_pip_compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 #############################################################
Expand All @@ -109,34 +112,39 @@ 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
work_dir: Path | None = None
#: 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading