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

110 lines
3.9 KiB
Markdown

# 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 <skill-dir>/check_roundtrip.py a.json state_fields.json`.