# Save / load in Python ## Data classes, one `RUNTIME` list ```python 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 ```python 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 ```python 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) ```python 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 ```python 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 /check_roundtrip.py a.json state_fields.json`.