feat: preferences LocalFileStore — YAML-backed single-user preference store
This commit is contained in:
parent
9ee31a09c1
commit
0d9d030320
2 changed files with 119 additions and 0 deletions
75
circuitforge_core/preferences/store.py
Normal file
75
circuitforge_core/preferences/store.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Preference store backends.
|
||||||
|
|
||||||
|
``LocalFileStore`` reads and writes a single YAML file at a configurable
|
||||||
|
path (default: ``~/.config/circuitforge/preferences.yaml``).
|
||||||
|
|
||||||
|
The ``PreferenceStore`` protocol describes the interface any backend must
|
||||||
|
satisfy. The Heimdall cloud backend will implement the same protocol once
|
||||||
|
Heimdall#5 (user_preferences column) lands — products swap backends by
|
||||||
|
passing a different store instance.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from .paths import get_path, set_path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_PREFS_PATH = Path.home() / ".config" / "circuitforge" / "preferences.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PreferenceStore(Protocol):
|
||||||
|
"""Read/write interface for user preferences.
|
||||||
|
|
||||||
|
``user_id`` is passed through for cloud backends that store per-user
|
||||||
|
data. Local single-user backends accept it but ignore it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, user_id: str | None, path: str, default: Any = None) -> Any:
|
||||||
|
"""Return the value at *path*, or *default* if missing."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set(self, user_id: str | None, path: str, value: Any) -> None:
|
||||||
|
"""Persist *value* at *path*."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFileStore:
|
||||||
|
"""Single-user preference store backed by a YAML file.
|
||||||
|
|
||||||
|
Thread-safe for typical single-process use (reads the file on every
|
||||||
|
``get`` call, writes atomically via a temp-file rename on ``set``).
|
||||||
|
Not suitable for concurrent multi-process writes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, prefs_path: Path = _DEFAULT_PREFS_PATH) -> None:
|
||||||
|
self._path = Path(prefs_path)
|
||||||
|
|
||||||
|
def _load(self) -> dict:
|
||||||
|
if not self._path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore[import]
|
||||||
|
text = self._path.read_text(encoding="utf-8")
|
||||||
|
data = yaml.safe_load(text)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("preferences: could not read %s: %s", self._path, exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save(self, data: dict) -> None:
|
||||||
|
import yaml # type: ignore[import]
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self._path.with_suffix(".yaml.tmp")
|
||||||
|
tmp.write_text(yaml.safe_dump(data, default_flow_style=False), encoding="utf-8")
|
||||||
|
tmp.replace(self._path)
|
||||||
|
|
||||||
|
def get(self, user_id: str | None, path: str, default: Any = None) -> Any: # noqa: ARG002
|
||||||
|
return get_path(self._load(), path, default=default)
|
||||||
|
|
||||||
|
def set(self, user_id: str | None, path: str, value: Any) -> None: # noqa: ARG002
|
||||||
|
self._save(set_path(self._load(), path, value))
|
||||||
|
|
@ -73,3 +73,47 @@ class TestSetPath:
|
||||||
prefs = set_path(prefs, "affiliate.byok_ids.ebay", "tag-123")
|
prefs = set_path(prefs, "affiliate.byok_ids.ebay", "tag-123")
|
||||||
assert get_path(prefs, "affiliate.opt_out") is True
|
assert get_path(prefs, "affiliate.opt_out") is True
|
||||||
assert get_path(prefs, "affiliate.byok_ids.ebay") == "tag-123"
|
assert get_path(prefs, "affiliate.byok_ids.ebay") == "tag-123"
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from circuitforge_core.preferences.store import LocalFileStore
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocalFileStore:
|
||||||
|
def _store(self, tmp_path) -> LocalFileStore:
|
||||||
|
return LocalFileStore(prefs_path=tmp_path / "preferences.yaml")
|
||||||
|
|
||||||
|
def test_get_returns_default_when_file_missing(self, tmp_path):
|
||||||
|
store = self._store(tmp_path)
|
||||||
|
assert store.get(user_id=None, path="affiliate.opt_out", default=False) is False
|
||||||
|
|
||||||
|
def test_set_then_get_roundtrip(self, tmp_path):
|
||||||
|
store = self._store(tmp_path)
|
||||||
|
store.set(user_id=None, path="affiliate.opt_out", value=True)
|
||||||
|
assert store.get(user_id=None, path="affiliate.opt_out", default=False) is True
|
||||||
|
|
||||||
|
def test_set_nested_path(self, tmp_path):
|
||||||
|
store = self._store(tmp_path)
|
||||||
|
store.set(user_id=None, path="affiliate.byok_ids.ebay", value="my-tag")
|
||||||
|
assert store.get(user_id=None, path="affiliate.byok_ids.ebay") == "my-tag"
|
||||||
|
|
||||||
|
def test_set_preserves_sibling_keys(self, tmp_path):
|
||||||
|
store = self._store(tmp_path)
|
||||||
|
store.set(user_id=None, path="affiliate.opt_out", value=False)
|
||||||
|
store.set(user_id=None, path="affiliate.byok_ids.ebay", value="tag")
|
||||||
|
assert store.get(user_id=None, path="affiliate.opt_out") is False
|
||||||
|
assert store.get(user_id=None, path="affiliate.byok_ids.ebay") == "tag"
|
||||||
|
|
||||||
|
def test_creates_parent_dirs(self, tmp_path):
|
||||||
|
deep_path = tmp_path / "deep" / "nested" / "preferences.yaml"
|
||||||
|
store = LocalFileStore(prefs_path=deep_path)
|
||||||
|
store.set(user_id=None, path="x", value=1)
|
||||||
|
assert deep_path.exists()
|
||||||
|
|
||||||
|
def test_user_id_ignored_for_local_store(self, tmp_path):
|
||||||
|
"""LocalFileStore is single-user; user_id is accepted but ignored."""
|
||||||
|
store = self._store(tmp_path)
|
||||||
|
store.set(user_id="u123", path="affiliate.opt_out", value=True)
|
||||||
|
assert store.get(user_id="u456", path="affiliate.opt_out", default=False) is True
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue