Files

162 lines
5.4 KiB
Python

"""Shared, crash-safe Desktop settings storage for bridge and package tools."""
from __future__ import annotations
import contextlib
import json
import os
import shutil
import stat
import time
import uuid
from pathlib import Path
from typing import Callable
LOCK_TIMEOUT_SECONDS = 5.0
LOCK_STALE_SECONDS = 30.0
PACKAGE_PATH_KEYS = ("python_path", "project_dir", "bridge_script")
class DesktopSettingsError(RuntimeError):
pass
def _lock_path(settings_path: Path) -> Path:
return settings_path.with_name(f"{settings_path.name}.lock")
@contextlib.contextmanager
def settings_lock(
settings_path: Path,
*,
timeout: float = LOCK_TIMEOUT_SECONDS,
stale_after: float = LOCK_STALE_SECONDS,
):
lock_path = _lock_path(settings_path)
token = f"{os.getpid()}:{uuid.uuid4().hex}"
deadline = time.monotonic() + timeout
settings_path.parent.mkdir(parents=True, exist_ok=True)
while True:
try:
os.mkdir(lock_path, 0o700)
(lock_path / "owner").write_text(token, encoding="ascii")
break
except FileExistsError:
try:
lock_stat = lock_path.lstat()
if not stat.S_ISDIR(lock_stat.st_mode) or stat.S_ISLNK(lock_stat.st_mode):
raise DesktopSettingsError(f"settings lock is not a directory: {lock_path}")
stale = time.time() - lock_stat.st_mtime > stale_after
except FileNotFoundError:
continue
if stale:
tombstone = lock_path.with_name(f"{lock_path.name}.stale.{uuid.uuid4().hex}")
try:
os.rename(lock_path, tombstone)
except FileNotFoundError:
continue
except OSError:
pass
else:
shutil.rmtree(tombstone, ignore_errors=True)
continue
if time.monotonic() >= deadline:
raise DesktopSettingsError(f"timed out waiting for settings lock: {lock_path}")
time.sleep(0.05)
try:
yield
finally:
try:
owner = lock_path / "owner"
if owner.read_text(encoding="ascii") == token:
owner.unlink()
lock_path.rmdir()
except (FileNotFoundError, OSError):
pass
def read_settings(settings_path: Path, *, strict: bool = True) -> dict:
if not settings_path.exists():
return {}
try:
document = json.loads(settings_path.read_text(encoding="utf-8-sig"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
if strict:
raise DesktopSettingsError(f"cannot read Desktop settings: {error}") from error
return {}
if not isinstance(document, dict):
if strict:
raise DesktopSettingsError(f"Desktop settings must be a JSON object: {settings_path}")
return {}
return document
def write_settings_atomically(settings_path: Path, document: dict) -> None:
if not isinstance(document, dict):
raise DesktopSettingsError("Desktop settings must be a JSON object")
mode = stat.S_IMODE(settings_path.stat().st_mode) if settings_path.exists() else 0o600
payload = (json.dumps(document, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
temporary = settings_path.with_name(
f".{settings_path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
)
try:
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, settings_path)
if os.name != "nt":
directory = os.open(settings_path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
except OSError as error:
raise DesktopSettingsError(f"cannot write Desktop settings: {error}") from error
finally:
temporary.unlink(missing_ok=True)
def update_settings(settings_path: Path, mutate: Callable[[dict], None]) -> dict:
with settings_lock(settings_path):
document = read_settings(settings_path, strict=True)
mutate(document)
write_settings_atomically(settings_path, document)
return document
def merge_package_paths(
settings_path: Path,
*,
python_path: str,
project_dir: str,
bridge_script: str,
) -> dict:
updates = {
"python_path": python_path,
"project_dir": project_dir,
"bridge_script": bridge_script,
}
return update_settings(settings_path, lambda document: document.update(updates))
def _path_is_within(value: str, bundle_root: str) -> bool:
try:
candidate = os.path.normcase(os.path.abspath(os.path.expanduser(value)))
bundle = os.path.normcase(os.path.abspath(os.path.expanduser(bundle_root)))
return os.path.commonpath((candidate, bundle)) == bundle
except (OSError, ValueError):
return False
def remove_bundle_paths(settings_path: Path, bundle_root: str) -> dict:
def remove_owned(document: dict) -> None:
for key in PACKAGE_PATH_KEYS:
value = document.get(key)
if isinstance(value, str) and _path_is_within(value, bundle_root):
document.pop(key, None)
return update_settings(settings_path, remove_owned)