"""File locking and atomic JSON helpers.""" import fcntl import json import os __all__ = [ "locked_file", "atomic_write_json", "load_json_locked", "save_json_locked", "read_modify_write_json", ] from contextlib import contextmanager from pathlib import Path @contextmanager def locked_file(path: Path): path.parent.mkdir(parents=True, exist_ok=True) fd = None try: fd = os.open(path, os.O_RDWR | os.O_CREAT) fcntl.flock(fd, fcntl.LOCK_EX) yield fd finally: if fd is not None: try: os.fsync(fd) except Exception: pass try: fcntl.flock(fd, fcntl.LOCK_UN) except Exception: pass os.close(fd) def atomic_write_json(path: Path, data: dict): path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") os.replace(tmp, path) def load_json_locked(path: Path, lock_path: Path, default): with locked_file(lock_path): if not path.exists(): return default try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return default def save_json_locked(path: Path, lock_path: Path, data: dict): with locked_file(lock_path): atomic_write_json(path, data) def read_modify_write_json(path: Path, lock_path: Path, default, modifier): """Atomic read-modify-write under a single file lock. Loads JSON from *path* (or uses *default* if missing/invalid), calls ``modifier(data)``, then atomically writes the result back. If *modifier* returns None, the mutated *data* is written. """ with locked_file(lock_path): if path.exists(): try: data = json.loads(path.read_text(encoding="utf-8")) except Exception: data = default else: data = default result = modifier(data) atomic_write_json(path, result if result is not None else data)