3.9 KiB
3.9 KiB
Save / load in Python
Data classes, one RUNTIME list
from dataclasses import dataclass, field, asdict, fields
import json, os, tempfile
from pathlib import Path
VERSION = 1
@dataclass
class Item:
id: int
type: str
params: dict = field(default_factory=dict)
position: list = field(default_factory=lambda: [0.0, 0.0, 0.0])
children: list = field(default_factory=list) # nested Items
@dataclass
class State:
items: list = field(default_factory=list)
settings: dict = field(default_factory=lambda: {"grid": 1.0})
next_id: int = 1
selection: set = field(default_factory=set) # runtime only
_cache: dict = field(default_factory=dict) # runtime only
RUNTIME = ("selection", "_cache") # the ONLY fields not saved
to_data / load
def to_data(state: State) -> dict:
data = asdict(state) # everything, recursively
for k in RUNTIME:
data.pop(k, None)
return {"version": VERSION, "app": "myapp", "state": data}
def from_record(rec: dict) -> Item: # the ONE constructor path
rec = {**rec, "children": [from_record(c) for c in rec.get("children", [])]}
return Item(**rec)
def load(data: dict) -> State:
data = migrate(data)
st = data["state"]
state = State(items=[from_record(r) for r in st["items"]],
settings=st.get("settings", {}), next_id=st.get("next_id", 1))
state.next_id = max(state.next_id, max(_ids(state.items), default=0) + 1) # never reuse an id
return state
def _ids(items):
for it in items:
yield it.id
yield from _ids(it.children)
def migrate(data: dict) -> dict:
if "version" not in data:
data = {"version": 0, "state": data}
if data["version"] < 1:
for r in data["state"]["items"]:
r.setdefault("position", [0.0, 0.0, 0.0])
data["version"] = 1
return data
Files: atomic write, explicit encoding
def save_file(state: State, path: Path) -> None:
tmp = tempfile.NamedTemporaryFile("w", dir=path.parent, suffix=".tmp", delete=False, encoding="utf-8")
with tmp:
json.dump(to_data(state), tmp, indent=2, ensure_ascii=False, allow_nan=False)
os.replace(tmp.name, path) # never a half-written file
def load_file(path: Path) -> State:
return load(json.loads(path.read_text(encoding="utf-8")))
allow_nan=False makes NaN/inf an error at save time instead of a file no
other parser can read. For Path, datetime, set, Decimal use
json.dump(..., default=_enc) with _enc returning str(o), o.isoformat(),
sorted(o), float(o), and convert back in from_record.
Round-trip test (pytest)
def test_roundtrip(tmp_path):
s = State(items=[Item(1, "box", {"size": 2}, [1, 2, 3], [Item(2, "hole", {"r": 0.5})])], next_id=3)
s.selection.add(1)
save_file(s, tmp_path / "a.json")
s2 = load_file(tmp_path / "a.json")
assert to_data(s2) == to_data(s) # data equal
assert s2.selection == set() # runtime rebuilt, not loaded
assert s2.next_id == 3
Completeness
def field_paths(obj, prefix=""):
"""All leaf paths of a live state, dataclasses included."""
if hasattr(obj, "__dataclass_fields__"):
obj = {f.name: getattr(obj, f.name) for f in fields(obj)}
if isinstance(obj, dict):
for k, v in obj.items():
yield from field_paths(v, f"{prefix}.{k}" if prefix else k)
elif isinstance(obj, (list, tuple, set)):
for v in obj:
yield from field_paths(v, prefix + "[]")
else:
yield prefix
json.dump(sorted(set(field_paths(state))), open("state_fields.json", "w"), indent=2)
then python <skill-dir>/check_roundtrip.py a.json state_fields.json.