113 lines
3.9 KiB
Python
113 lines
3.9 KiB
Python
"""Sidecar config for DB location preference + pending move state.
|
|
|
|
Lives at ``<plugin>/db-config.json`` so the resolver can read it BEFORE
|
|
opening any DB connection (chicken-and-egg: the preference about where
|
|
the DB lives can't live inside the DB itself).
|
|
|
|
Atomic writes: write-to-temp-then-rename so a crash mid-write can't
|
|
corrupt the sidecar.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, Literal, TypedDict
|
|
|
|
Preference = Literal["user", "global", "root"]
|
|
MoveMode = Literal["copy", "move"]
|
|
|
|
# Plugin root: this file is at <plugin>/engine/db/config.py — go up three.
|
|
_PLUGIN_ROOT = Path(__file__).resolve().parents[2]
|
|
SIDECAR_PATH = _PLUGIN_ROOT / "db-config.json"
|
|
|
|
_VALID_PREFERENCES = {"user", "global", "root"}
|
|
_VALID_MODES = {"copy", "move"}
|
|
|
|
|
|
class PendingMove(TypedDict, total=False):
|
|
from_: str # NB: "from" is a Python keyword; we map this on read/write
|
|
to: str
|
|
mode: MoveMode
|
|
|
|
|
|
class DbConfig(TypedDict, total=False):
|
|
preference: Preference
|
|
pending_move: dict[str, str] # {"from": "...", "to": "...", "mode": "..."}
|
|
|
|
|
|
def _read_raw(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
|
|
|
|
def _resolve_sidecar(path: Path | None) -> Path:
|
|
"""Resolve the effective sidecar path.
|
|
|
|
Reads ``SIDECAR_PATH`` from this module dynamically when ``path`` is
|
|
``None`` so tests can ``monkeypatch.setattr("engine.db.config.SIDECAR_PATH", ...)``
|
|
and have all three of ``load``/``save``/``clear_pending_move`` honor
|
|
the override. Using a default argument like ``path = SIDECAR_PATH``
|
|
captures the value at function-definition time, which breaks
|
|
monkeypatching the module attribute."""
|
|
return path if path is not None else SIDECAR_PATH
|
|
|
|
|
|
def load(path: Path | None = None) -> DbConfig:
|
|
"""Return the sidecar config or {} if absent / unreadable / invalid."""
|
|
raw = _read_raw(_resolve_sidecar(path))
|
|
out: DbConfig = {}
|
|
pref = raw.get("preference")
|
|
if isinstance(pref, str) and pref in _VALID_PREFERENCES:
|
|
out["preference"] = pref # type: ignore[typeddict-item]
|
|
pm = raw.get("pending_move")
|
|
if isinstance(pm, dict):
|
|
mode = pm.get("mode")
|
|
src = pm.get("from")
|
|
dst = pm.get("to")
|
|
if (isinstance(mode, str) and mode in _VALID_MODES
|
|
and isinstance(src, str) and isinstance(dst, str)):
|
|
out["pending_move"] = {"from": src, "to": dst, "mode": mode}
|
|
return out
|
|
|
|
|
|
def save(config: DbConfig, path: Path | None = None) -> None:
|
|
"""Atomically write the sidecar (write-temp + rename)."""
|
|
path = _resolve_sidecar(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
# Strip any unknown keys; only persist the validated shape.
|
|
out: dict[str, Any] = {}
|
|
if "preference" in config:
|
|
out["preference"] = config["preference"]
|
|
pm = config.get("pending_move")
|
|
if isinstance(pm, dict) and {"from", "to", "mode"} <= pm.keys():
|
|
out["pending_move"] = {"from": pm["from"], "to": pm["to"], "mode": pm["mode"]}
|
|
fd, tmp_str = tempfile.mkstemp(prefix=".db-config-", dir=str(path.parent))
|
|
tmp_path = Path(tmp_str)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump(out, f, indent=2)
|
|
os.replace(tmp_path, path)
|
|
except OSError:
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def clear_pending_move(path: Path | None = None) -> None:
|
|
"""Remove just the pending_move field; preserve preference."""
|
|
path = _resolve_sidecar(path)
|
|
cfg = load(path)
|
|
if "pending_move" in cfg:
|
|
cfg.pop("pending_move", None)
|
|
save(cfg, path)
|
|
|
|
|
|
def plugin_root() -> Path:
|
|
"""Expose the plugin root so the resolver can compute the `root` location."""
|
|
return _PLUGIN_ROOT
|