"""Keep user UI data separate from replaceable release defaults.""" from __future__ import annotations import json import os import shutil import tempfile from contextlib import contextmanager from pathlib import Path from threading import RLock _migration_lock = RLock() def atomic_write(path: Path, content: str) -> None: """A failed save leaves the previous complete file available.""" temporary = None try: with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, prefix=path.name + ".", suffix=".tmp", delete=False) as stream: temporary = Path(stream.name) stream.write(content) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, path) finally: if temporary is not None: temporary.unlink(missing_ok=True) @contextmanager def _exclusive_migration(directory: Path): # Startup can run in several server processes, including on Windows. with _migration_lock, (directory / ".migration.lock").open("a+b") as stream: stream.seek(0, os.SEEK_END) if not stream.tell(): stream.write(b"0") stream.flush() stream.seek(0) if os.name == "nt": import msvcrt msvcrt.locking(stream.fileno(), msvcrt.LK_LOCK, 1) else: import fcntl fcntl.flock(stream.fileno(), fcntl.LOCK_EX) try: yield finally: stream.seek(0) if os.name == "nt": msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) else: fcntl.flock(stream.fileno(), fcntl.LOCK_UN) def prepare_ui_storage(settings_dir: Path, *, filenames: tuple[str, ...], legacy_filename: str) -> Path: configured = os.getenv("HOCKEY_UI_DATA_DIR", "").strip() directory = Path(configured).expanduser() if configured else settings_dir / "local" if configured and not directory.is_absolute(): raise ValueError("HOCKEY_UI_DATA_DIR должен содержать абсолютный путь") directory.mkdir(parents=True, exist_ok=True) with _exclusive_migration(directory): for filename in filenames: name = Path(filename).name destination = directory / name if destination.exists(): continue source = settings_dir / name if not source.exists(): source = settings_dir / Path(legacy_filename).name if source.exists(): content = source.read_text(encoding="utf-8-sig") try: valid = isinstance(json.loads(content), dict) except (ValueError, TypeError): valid = False if not valid: raise ValueError(f"Не удалось перенести настройки интерфейса: {source.name} повреждён") atomic_write(destination, content) # Import recovery history exactly once. Otherwise every restart resurrects # old backups that rolling retention already removed. old_backups = settings_dir / "backups" new_backups = directory / "backups" new_backups.mkdir(exist_ok=True) marker = directory / ".ui_backups_migrated_v1.json" if not marker.exists(): for filename in (*filenames, legacy_filename): for source in old_backups.glob(Path(filename).stem + "_*.json"): destination = new_backups / source.name if not destination.exists(): shutil.copy2(source, destination) atomic_write(marker, json.dumps({"completed": True, "version": 1})) return directory