jcode/skills/save-load-json/python.md

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.