#!/usr/bin/env python3 """check_roundtrip.py - is every field of the live state in the saved file? python check_roundtrip.py saved.json state_fields.json [--runtime selection,_cache] saved.json the file the app wrote (any JSON; a top-level "state" key is unwrapped) state_fields.json JSON array of field paths of the LIVE state, e.g. ["objects[].id", "objects[].position[]", "settings.grid", "selection"] (how to produce it: js.md / python.md, section "Completeness") --runtime comma-separated paths that are intentionally not saved Exit 1 if a state field is missing from the file (and not in --runtime), with the list. Also lists file fields that the state does not have (stale keys from old versions). """ import json, sys def paths(obj, prefix=""): if isinstance(obj, dict): if not obj: yield prefix for k, v in obj.items(): yield from paths(v, f"{prefix}.{k}" if prefix else k) elif isinstance(obj, list): if not obj: yield prefix # empty array: can't know the element shape for v in obj: yield from paths(v, prefix + "[]") else: yield prefix def normalize(p): """'objects[].children[].id' and 'objects[].id' describe the same record type when children hold the same records; compare on the path with repeated '[]' segments kept.""" return p def main(argv): runtime = set() if "--runtime" in argv: i = argv.index("--runtime"); runtime = set(argv[i + 1].split(",")); del argv[i:i + 2] if len(argv) != 2: print(__doc__); return 2 saved = json.load(open(argv[0], encoding="utf-8")) if isinstance(saved, dict) and "state" in saved and isinstance(saved["state"], dict): saved = saved["state"] want = set(json.load(open(argv[1], encoding="utf-8"))) have = set(paths(saved)) # a field counts as present if the file has it at that path or any ancestor is an empty container def present(p): if p in have: return True parts = p.replace("[]", ".[]").split(".") for n in range(len(parts) - 1, 0, -1): anc = ".".join(parts[:n]).replace(".[]", "[]") if anc in have and any(h == anc for h in have): # ancestor exists as a leaf = empty list/dict return True return False missing = sorted(p for p in want if not present(p) and p not in runtime and not any(p == r or p.startswith(r + ".") or p.startswith(r + "[]") for r in runtime)) extra = sorted(h for h in have if h not in want) if missing: print(f"MISSING in the file ({len(missing)}): fields the live state has but the save does not") for p in missing: print(" -", p) if extra: print(f"only in the file ({len(extra)}): not in the live state (old keys? wrong source?)") for p in extra: print(" -", p) print("ok - every state field is in the file" if not missing else f"{len(missing)} field(s) lost on save") return 1 if missing else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))