110 lines
3.9 KiB
Markdown
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`.
|