94 lines
3.7 KiB
Python
94 lines
3.7 KiB
Python
"""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
|