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
14 changes: 9 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Three cooperating roles, each configurable independently and often on different

### Configuration is the program

`pyrolab/configure.py` is the center of gravity. A single YAML file (pydantic v1 models) declares
`pyrolab/configure.py` is the center of gravity. A single YAML file (pydantic 2 models) declares
`nameservers`, `daemons`, `services`, and `autolaunch` (see the `sample_config_yaml` fixture in
`tests/conftest.py` and `examples/library-catalog/config.yaml`; `tests/data/*.yaml` are stale and reference
a `pyrolab.daemon` module that no longer exists). A `ServiceConfiguration` names a `module` + `classname` that is
Expand All @@ -88,12 +88,15 @@ refers to one called `default`). `describe_config_error()` turns a load error in
Anything that writes a config must re-validate before saving; `rename_entity()` renames an entity together with
every reference to it.

The models derive from `_ConfigModel` (plain pydantic `BaseModel`, `extra = "forbid"`), **not** `BaseSettings`:
The models derive from `_ConfigModel` (plain pydantic `BaseModel`, `extra="forbid"`), **not** `BaseSettings`:
settings models fill unset fields from same-named environment variables (`PORT`, `HOST`, ...), which silently
changed configurations. Unknown keys are errors. `update_pyro_config()` warns about any field it can't apply
unless the model lists it in `NOT_PYRO_OPTIONS`. Daemon placement (`host`, `port`, `unixsocket`, `nathost`,
`natport`) goes to the daemon's constructor, since Pyro5 has no global `PORT`/`UNIXSOCKET` options.
Nameservers get their names (used for storage paths) whenever a `PyroLabConfiguration` is constructed.
Nameservers get their names (used for storage paths) in `PyroLabConfiguration.model_post_init`, so on every
construction or validation path. `_ConfigModel` sets `coerce_numbers_to_str` so pydantic-1-era files with
e.g. an unquoted `version: 1.0` still load. In pydantic 2, model equality includes private attributes, so two
nameserver configs with different names are unequal; compare `model_dump()` for settings only.

Every config/lockfile write goes through `utils.atomic_write_text()` (temp file + fsync + `os.replace`).

Expand Down Expand Up @@ -198,8 +201,9 @@ excluded from import sorting (a ruff per-file ignore); import order there is loa
`cameras`), not in the base `dependencies`. PyPI rejects direct git URLs, so git-sourced extras stay
commented out. `[tool.uv.sources]` can point development installs at git, but PyPI users never see it, so
don't rely on it to fix a broken release — the Arduino driver patches PyPI's pyfirmata 1.1.0 instead.
- Pinned to **pydantic 1.x** (`BaseSettings`, `validator`, `PrivateAttr` from `pydantic.fields`). Do not
migrate to pydantic 2 piecemeal.
- **pydantic 2** (`>=2.8`): `model_config = ConfigDict(...)`, `field_validator`/`model_validator`,
`model_dump`/`model_validate`/`model_validate_json`. No v1 API (`.dict()`, `parse_file`, `@validator`, ...);
the suite passes with pydantic's deprecation warnings turned into errors.

## Releasing

Expand Down
5 changes: 0 additions & 5 deletions docs/_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,11 @@ sphinx:
autodoc_typehints: 'signature'
autodoc_mock_imports: [
"appnope",
"deprecation",
"typer",
"colorama",
"tabulate",
"serial",
"thorlabs_kinesis",
"pyfirmata",
"sacher_tec",
"pyvisa",
"pyfirmata",
"cv2",
"WF_SDK",
]
Expand Down
15 changes: 4 additions & 11 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
# Read the Docs installs these, then PyroLab itself (pip install .), which
# brings PyroLab's own dependencies. Don't repeat those here: a pin that
# disagrees with pyproject.toml makes the docs build unresolvable.
# Keep in step with the "docs" dependency group in pyproject.toml.
jupyter-book==1.0.3
# sphinx==7.4.7
# pydata-sphinx-theme==0.15.4
numpydoc==1.5.0
numpy==2.2.3
scipy==1.15.2
# sphinx_design==0.6.1
# sphinx-copybutton==0.5.2
# sphinx-togglebutton==0.3.2
linkify-it-py==2.0.3
pydantic==1.10.21
pyyaml==6.0.2
Pyro5==5.15
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ dependencies = [
"numpy",
"scipy",
"Pyro5",
"pydantic >= 1.9.1, < 2.0.0",
"pydantic>=2.8,<3",
"pyyaml",
"deprecation",
"typer",
Expand Down
6 changes: 3 additions & 3 deletions pyrolab/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ def info():

ns_data = []
for name in config.nameservers:
ns_data.append({"name": name, **config.nameservers[name].dict()})
ns_data.append({"name": name, **config.nameservers[name].model_dump()})
for item in ns_data:
item["ns_autoclean"] = (
f"{item['ns_autoclean']} sec" if item["ns_autoclean"] else "Off"
Expand All @@ -573,7 +573,7 @@ def info():

daemon_data = []
for name in config.daemons:
daemon_data.append({"name": name, **config.daemons[name].dict()})
daemon_data.append({"name": name, **config.daemons[name].model_dump()})
for item in daemon_data:
item["nameservers"] = "".join([f"- {name}\n" for name in item["nameservers"]])
if daemon_data:
Expand All @@ -582,7 +582,7 @@ def info():

service_data = []
for name in config.services:
service_data.append({"name": name, **config.services[name].dict()})
service_data.append({"name": name, **config.services[name].model_dump()})
for item in service_data:
item["parameters"] = "".join(
[f"{k}: {v}\n" for k, v in item["parameters"].items()]
Expand Down
68 changes: 41 additions & 27 deletions pyrolab/configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,14 @@
from typing import IO, Any, ClassVar, Dict, List, Optional, Tuple, Type, Union

import Pyro5
from pydantic import BaseModel, root_validator, validator
from pydantic.fields import PrivateAttr
from pydantic import (
BaseModel,
ConfigDict,
PrivateAttr,
ValidationError,
field_validator,
model_validator,
)
from yaml import dump, load
from yaml.constructor import ConstructorError
from yaml.nodes import MappingNode
Expand Down Expand Up @@ -202,7 +208,7 @@ def update_pyro_config(self, values: dict = None) -> Dict[str, Any]:
debugging or informational purposes.
"""
if values is None:
values = self.dict()
values = self.model_dump()

for key in ["host", "ns_host", "ns_bchost"]:
if key in values:
Expand Down Expand Up @@ -238,9 +244,13 @@ class _ConfigModel(BaseModel):
in the environment would silently change the configuration.
"""

class Config:
extra = "forbid" # a misspelled key is an error, not silently ignored
validate_all = True
model_config = ConfigDict(
extra="forbid", # a misspelled key is an error, not silently ignored
validate_default=True,
# pydantic 1 accepted e.g. an unquoted `version: 1.0` for a string
# field; keep accepting numbers where text is expected.
coerce_numbers_to_str=True,
)


class YAMLMixin:
Expand All @@ -266,7 +276,7 @@ def yaml(
includes them (default False).
"""
return dump(
self.dict(exclude_defaults=exclude_defaults),
self.model_dump(exclude_defaults=exclude_defaults),
sort_keys=sort_keys,
default_flow_style=default_flow_style,
)
Expand All @@ -289,7 +299,7 @@ def from_yaml(
The YAML to load.
"""
loaded = load(yaml, Loader=UniqueOrAutoKeyLoader)
cfg = cls.parse_obj(loaded)
cfg = cls.model_validate(loaded)
return cfg

@classmethod
Expand Down Expand Up @@ -398,7 +408,8 @@ class NameServerConfiguration(_ConfigModel, PyroConfigMixin, YAMLMixin):
# Passed to the nameserver directly rather than through Pyro5.config.
NOT_PYRO_OPTIONS: ClassVar[frozenset] = frozenset({"broadcast", "storage"})

@validator("storage")
@field_validator("storage")
@classmethod
def valid_memory_format(cls, v: str):
if v == "memory":
return v
Expand Down Expand Up @@ -453,7 +464,7 @@ def update_pyro_config(self) -> Dict[str, Any]:
A dictionary of Pyro5 key-value pairs that were updated, for
debugging or informational purposes.
"""
values = self.dict()
values = self.model_dump()
values["ns_host"] = values["host"]
return super().update_pyro_config(values=values)

Expand Down Expand Up @@ -694,10 +705,10 @@ class PyroLabConfiguration(_ConfigModel, YAMLMixin):
services: Dict[str, ServiceConfiguration] = {}
autolaunch: AutolaunchSettings = AutolaunchSettings()

@root_validator(skip_on_failure=True)
def references_are_defined(cls, values):
nameservers = values["nameservers"]
daemons = values["daemons"]
@model_validator(mode="after")
def references_are_defined(self) -> "PyroLabConfiguration":
nameservers = self.nameservers
daemons = self.daemons
problems = []

def check(kind, names, defined, where):
Expand All @@ -707,25 +718,26 @@ def check(kind, names, defined, where):
f"{where} refers to {kind} '{name}', which is not defined"
)

for sname, svc in values["services"].items():
for sname, svc in self.services.items():
where = f"service '{sname}'"
if svc.daemon not in daemons:
hint = "" if "daemon" in svc.__fields_set__ else " (the default)"
hint = "" if "daemon" in svc.model_fields_set else " (the default)"
problems.append(
f"{where} refers to daemon '{svc.daemon}'{hint}, which is not defined"
)
check("nameserver", svc.nameservers, nameservers, where)
for dname, dcfg in daemons.items():
check("nameserver", dcfg.nameservers, nameservers, f"daemon '{dname}'")
check("nameserver", values["autolaunch"].nameservers, nameservers, "autolaunch")
check("daemon", values["autolaunch"].daemons, daemons, "autolaunch")
check("nameserver", self.autolaunch.nameservers, nameservers, "autolaunch")
check("daemon", self.autolaunch.daemons, daemons, "autolaunch")

if problems:
raise ValueError("; ".join(problems))
return values
return self

def __init__(self, **data: Any) -> None:
super().__init__(**data)
def model_post_init(self, context: Any) -> None:
# Runs however the configuration was created (constructed, validated
# from YAML, ...), so nameservers always know their names.
self.initialize_nameservers()

def initialize_nameservers(self):
Expand Down Expand Up @@ -755,14 +767,16 @@ def describe_config_error(exc: Exception) -> List[str]:
Human-readable problem descriptions, e.g.
``"daemons.lab.port: value is not a valid integer"``.
"""
from pydantic import ValidationError

if not isinstance(exc, ValidationError):
return [str(exc)]
lines = []
for err in exc.errors():
loc = ".".join(str(part) for part in err["loc"] if part != "__root__")
for msg in err["msg"].split("; "):
loc = ".".join(str(part) for part in err["loc"])
# A ValueError raised by one of our validators: use its own message
# rather than pydantic's "Value error, ..." wrapping of it.
original = (err.get("ctx") or {}).get("error")
message = str(original) if isinstance(original, ValueError) else err["msg"]
for msg in message.split("; "):
lines.append(f"{loc}: {msg}" if loc else msg)
return lines

Expand Down Expand Up @@ -1037,7 +1051,7 @@ def rename_entity(
KeyError
If there is no ``kind`` named ``old``.
"""
data = config.dict()
data = config.model_dump()
section = data[SECTIONS[kind]]
if old not in section:
raise KeyError(old)
Expand Down Expand Up @@ -1071,4 +1085,4 @@ def swap(names: List[str], where: str) -> List[str]:
auto = data["autolaunch"]
auto["daemons"] = swap(auto["daemons"], "autolaunch")

return PyroLabConfiguration.parse_obj(data), changes
return PyroLabConfiguration.model_validate(data), changes
4 changes: 2 additions & 2 deletions pyrolab/pyrolabd.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def write_lockfile(info: InstanceInfo) -> None:
replaces the lockfile in one step, so readers see either no lockfile or a
complete one, never a partial write.
"""
atomic_write_text(LOCKFILE, info.json())
atomic_write_text(LOCKFILE, info.model_dump_json())


def read_lockfile() -> Optional[InstanceInfo]:
Expand All @@ -68,7 +68,7 @@ def read_lockfile() -> Optional[InstanceInfo]:
:py:func:`pyrolab.utils.pid_is_running`.
"""
try:
return InstanceInfo.parse_file(LOCKFILE)
return InstanceInfo.model_validate_json(LOCKFILE.read_text())
except (OSError, ValueError): # missing, unreadable, or not valid JSON
return None

Expand Down
17 changes: 10 additions & 7 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def running_daemon(data_dir, monkeypatch):
FakeDaemonProxy.returns = {}
monkeypatch.setattr(cli, "Proxy", FakeDaemonProxy)
data_dir.LOCKFILE.write_text(
InstanceInfo(pid=os.getpid(), uri="PYRO:pyrolabd@localhost:1").json()
InstanceInfo(pid=os.getpid(), uri="PYRO:pyrolabd@localhost:1").model_dump_json()
)
return FakeDaemonProxy

Expand Down Expand Up @@ -99,7 +99,7 @@ def dead_pid():

def test_stale_lockfile_means_not_running(data_dir, dead_pid):
data_dir.LOCKFILE.write_text(
InstanceInfo(pid=dead_pid, uri="PYRO:pyrolabd@localhost:1").json()
InstanceInfo(pid=dead_pid, uri="PYRO:pyrolabd@localhost:1").model_dump_json()
)
result = runner.invoke(cli.app, ["ps"])
assert result.exit_code == 1
Expand Down Expand Up @@ -394,7 +394,8 @@ def test_rename(config_with_spare_daemon, kind, section, old):

entities = getattr(PyroLabConfiguration.from_file(user_config), section)
assert old not in entities
assert entities["renamed"] == before
# Compare settings: in pydantic 2, equality also compares the private name.
assert entities["renamed"].model_dump() == before.model_dump()


def _load(path):
Expand Down Expand Up @@ -720,7 +721,9 @@ def poll(self):
return None
if self.exit_code is None:
self.data_dir.LOCKFILE.write_text(
InstanceInfo(pid=os.getpid(), uri="PYRO:pyrolabd@localhost:1").json()
InstanceInfo(
pid=os.getpid(), uri="PYRO:pyrolabd@localhost:1"
).model_dump_json()
)
return None
if self.error:
Expand Down Expand Up @@ -759,7 +762,7 @@ def test_up_waits_until_daemon_responds(spawn):

def test_up_replaces_stale_lockfile(spawn, data_dir, dead_pid):
data_dir.LOCKFILE.write_text(
InstanceInfo(pid=dead_pid, uri="PYRO:pyrolabd@localhost:1").json()
InstanceInfo(pid=dead_pid, uri="PYRO:pyrolabd@localhost:1").model_dump_json()
)
result = runner.invoke(cli.app, ["up"])
assert result.exit_code == 0, result.output
Expand All @@ -768,7 +771,7 @@ def test_up_replaces_stale_lockfile(spawn, data_dir, dead_pid):

def test_up_when_already_running(spawn, data_dir):
data_dir.LOCKFILE.write_text(
InstanceInfo(pid=os.getpid(), uri="PYRO:pyrolabd@localhost:1").json()
InstanceInfo(pid=os.getpid(), uri="PYRO:pyrolabd@localhost:1").model_dump_json()
)
result = runner.invoke(cli.app, ["up"])
assert result.exit_code == 1
Expand All @@ -778,7 +781,7 @@ def test_up_when_already_running(spawn, data_dir):

def test_up_when_running_but_not_responding(spawn, data_dir, monkeypatch):
data_dir.LOCKFILE.write_text(
InstanceInfo(pid=os.getpid(), uri="PYRO:pyrolabd@localhost:1").json()
InstanceInfo(pid=os.getpid(), uri="PYRO:pyrolabd@localhost:1").model_dump_json()
)
monkeypatch.setattr(cli, "_daemon_responds", lambda uri: False)
result = runner.invoke(cli.app, ["up"])
Expand Down
Loading
Loading