"""Chargement/sauvegarde de documents JSON versionnes (sessions, cles, ...). Chaque document porte un `schema_version`. `migrations` associe une version de depart a une fonction qui transforme le document vers la version suivante ; le chargement applique la chaine jusqu'a `current_version`, ce qui permet de faire evoluer le format plus tard sans invalider les fichiers existants. La sauvegarde est atomique (voir atomic_file) et conserve la version precedente dans un `.bak` : si l'ecriture suivante produit un fichier corrompu (crash, disque plein), le chargement suivant retombe automatiquement sur cette sauvegarde plutot que de perdre les donnees de l'utilisateur. """ from __future__ import annotations import json from collections.abc import Callable from pathlib import Path from app.storage.atomic_file import atomic_write_bytes MigrationMap = dict[int, Callable[[dict], dict]] def _bak_path(path: Path) -> Path: return path.with_name(path.name + ".bak") def _migrate(document: dict, current_version: int, migrations: MigrationMap) -> dict: version = int(document.get("schema_version", 0) or 0) while version < current_version: migrate = migrations.get(version) if migrate is None: break # pas de chemin de migration connu : le document est garde tel quel document = migrate(document) version = int(document.get("schema_version", version + 1)) document["schema_version"] = current_version return document def load_json_document( path: Path, *, default: dict, current_version: int, migrations: MigrationMap | None = None, ) -> tuple[dict, str | None]: """Renvoie (document, avertissement). `avertissement` est None si le fichier principal a ete lu normalement ; sinon il decrit la recuperation effectuee (restauration depuis .bak, ou repli sur les valeurs par defaut).""" migrations = migrations or {} warning: str | None = None for candidate, is_backup in ((Path(path), False), (_bak_path(Path(path)), True)): if not candidate.exists(): continue try: raw = json.loads(candidate.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc: warning = f"{candidate.name} illisible ({exc})" continue if not isinstance(raw, dict): warning = f"{candidate.name} invalide (contenu inattendu)" continue if is_backup: warning = f"{Path(path).name} illisible, restaure depuis {candidate.name}" return _migrate(raw, current_version, migrations), warning if warning is None and not Path(path).exists(): pass # premier lancement : pas d'avertissement, juste les valeurs par defaut return _migrate(dict(default), current_version, migrations), warning def save_json_document(path: Path, document: dict) -> None: path = Path(path) if path.exists(): try: atomic_write_bytes(_bak_path(path), path.read_bytes()) except OSError: pass payload = json.dumps(document, indent=2, ensure_ascii=False) atomic_write_bytes(path, payload.encode("utf-8"))