import fcntl import json import logging import os import tempfile import time from contextlib import contextmanager logger = logging.getLogger(__name__) class StorageError(Exception): """Файл данных повреждён или недоступен.""" def load_json(path, default): """Читает JSON. Битый файл переименовывается в *.corrupt-, чтобы его не затёрли.""" if not os.path.exists(path): return default try: with open(path, 'r') as f: return json.load(f) except json.JSONDecodeError as e: backup = f"{path}.corrupt-{int(time.time())}" os.replace(path, backup) logger.error("Corrupted JSON in %s (%s); moved to %s", path, e, backup) raise StorageError(f"{path} is corrupted") from e except OSError as e: raise StorageError(f"Cannot read {path}: {e}") from e def save_json_atomic(path, data): """Пишет во временный файл в том же каталоге и подменяет оригинал через os.replace.""" directory = os.path.dirname(os.path.abspath(path)) fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=os.path.basename(path) + ".", suffix=".tmp") try: with os.fdopen(fd, 'w') as f: json.dump(data, f, indent=4) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, path) except BaseException: if os.path.exists(tmp_path): os.unlink(tmp_path) raise @contextmanager def file_lock(path): """Межпроцессная блокировка (сборщик, CLI и API) через .lock.""" with open(path + ".lock", 'w') as lock_file: fcntl.flock(lock_file, fcntl.LOCK_EX) try: yield finally: fcntl.flock(lock_file, fcntl.LOCK_UN) def try_lock(path): """Неблокирующая эксклюзивная блокировка (singleton-процесс). Возвращает открытый файл (блокировка держится, пока он жив) или None, если занято. """ lock_file = open(path, 'w') try: fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: lock_file.close() return None return lock_file