diff --git a/README.md b/README.md index ad6f8f5..43ce5fa 100644 --- a/README.md +++ b/README.md @@ -213,16 +213,42 @@ HelpEngine( ### `SessionStorage` Protocol -Implement custom storage backends: +Session depth state defaults to `LocalFileStorage` (per-user JSON files +under `~/.attune-help/sessions/`, 4-hour TTL). Implement the protocol to +plug in any backend: ```python from attune_help import SessionStorage -class RedisStorage(SessionStorage): - def load(self, user_id: str) -> dict: ... - def save(self, user_id: str, state: dict) -> None: ... +class MyStorage(SessionStorage): + def get_session(self, user_id: str) -> dict: ... + def set_session(self, user_id: str, state: dict) -> None: ... ``` +#### `BackendSessionStorage` — bring your own key/value store + +For cross-host continuity without writing the protocol yourself, inject +any key/value backend (an `attune_redis` backend, attune's +`MemoryBackend`, or a custom object exposing `stash`/`retrieve`). +attune-help imports none of these, so this adds **no required +dependency** (ADR-002 stays intact): + +```python +from attune_help import BackendSessionStorage, HelpEngine + +class KVBackend: # your store — e.g. wrap Redis + def stash(self, key: str, value: str) -> bool: ... + def retrieve(self, key: str) -> str | None: ... + +storage = BackendSessionStorage(my_backend) # same schema + 4h TTL +engine = HelpEngine(storage=storage) +``` + +Schema, TTL, and legacy migration match `LocalFileStorage` exactly — +only the transport (a backend key instead of a file) differs. Backend +errors never propagate into the runtime: reads fall back to defaults, +writes log-and-continue. + ## Staleness Detection `attune-help` tracks whether your help templates are up to date with diff --git a/src/attune_help/__init__.py b/src/attune_help/__init__.py index e836099..7c2a91b 100644 --- a/src/attune_help/__init__.py +++ b/src/attune_help/__init__.py @@ -15,12 +15,19 @@ TemplateContext, ) from attune_help.preamble import get_preamble # noqa: F401 -from attune_help.storage import LocalFileStorage, SessionStorage +from attune_help.storage import ( + BackendSessionStorage, + KVBackend, + LocalFileStorage, + SessionStorage, +) __all__ = [ # Engine "AudienceProfile", + "BackendSessionStorage", "HelpEngine", + "KVBackend", "LocalFileStorage", "PopulatedTemplate", "SessionStorage", diff --git a/src/attune_help/storage.py b/src/attune_help/storage.py index f78ca1f..a8c210e 100644 --- a/src/attune_help/storage.py +++ b/src/attune_help/storage.py @@ -46,6 +46,28 @@ def set_session(self, user_id: str, state: dict[str, Any]) -> None: ... +class KVBackend(Protocol): + """Minimal key/value backend for :class:`BackendSessionStorage`. + + Any object with these two methods works — an ``attune_redis`` + backend, attune's ``MemoryBackend``, or a hand-rolled dict wrapper. + attune-help imports none of those; the integrator injects an + instance, keeping the runtime dependency-free (tech.md ADR-002). + + ``stash`` returns ``True`` on a successful write, ``False`` (or + raises) on failure. ``retrieve`` returns the stored string, or + ``None`` when the key is absent. + """ + + def stash(self, key: str, value: str) -> bool: + """Persist ``value`` under ``key``. Return True on success.""" + ... + + def retrieve(self, key: str) -> str | None: + """Return the value for ``key``, or None if absent.""" + ... + + def _defaults() -> dict[str, Any]: """Fresh session defaults.""" return { @@ -93,6 +115,43 @@ def _migrate_legacy(data: dict[str, Any]) -> dict[str, Any]: } +def _serialize(state: dict[str, Any]) -> str: + """Render a session dict as a timestamped JSON line. + + Shared by the file and backend storages so both persist the exact + same schema (``last_topic``, ``depth_level``, ``topics``, ``order``, + ``timestamp``). + """ + payload = { + "last_topic": state.get("last_topic"), + "depth_level": state.get("depth_level", 0), + "topics": state.get("topics", {}), + "order": state.get("order", []), + "timestamp": time.time(), + } + return json.dumps(payload) + "\n" + + +def _deserialize(raw: str | None, ttl_seconds: float) -> dict[str, Any]: + """Parse a stored payload, applying TTL expiry and legacy migration. + + Returns fresh defaults when ``raw`` is missing, malformed, or older + than ``ttl_seconds``. Shared by the file and backend storages. + """ + if raw is None: + return _defaults() + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return _defaults() + if not isinstance(data, dict): + return _defaults() + ts = data.get("timestamp", 0) + if time.time() - ts > ttl_seconds: + return _defaults() + return _migrate_legacy(data) + + class LocalFileStorage: """File-based session storage (default implementation). @@ -182,11 +241,7 @@ def get_session(self, user_id: str) -> dict[str, Any]: try: if not path.exists(): return defaults - data = json.loads(path.read_text(encoding="utf-8")) - ts = data.get("timestamp", 0) - if time.time() - ts > self._ttl: - return defaults - return _migrate_legacy(data) + return _deserialize(path.read_text(encoding="utf-8"), self._ttl) except (json.JSONDecodeError, OSError, KeyError): return defaults @@ -205,17 +260,86 @@ def set_session(self, user_id: str, state: dict[str, Any]) -> None: try: self._dir.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".json.tmp") - payload = { - "last_topic": state.get("last_topic"), - "depth_level": state.get("depth_level", 0), - "topics": state.get("topics", {}), - "order": state.get("order", []), - "timestamp": time.time(), - } - tmp.write_text( - json.dumps(payload) + "\n", - encoding="utf-8", - ) + tmp.write_text(_serialize(state), encoding="utf-8") tmp.replace(path) # replace() is cross-platform except OSError as e: logger.warning("Session write failed: %s", e) + + +class BackendSessionStorage: + """Session storage backed by an injected key/value backend. + + A drop-in :class:`SessionStorage` that delegates persistence to any + :class:`KVBackend` (an ``attune_redis`` backend, attune's + ``MemoryBackend``, or a custom wrapper). Useful when session state + must survive across hosts/processes rather than a single machine's + ``~/.attune-help/sessions/`` directory. + + Schema, TTL, and legacy migration are identical to + :class:`LocalFileStorage` — only the transport differs (a backend + key instead of a file). attune-help imports no backend itself, so + this adds no required dependency (tech.md ADR-002). + + Args: + backend: The key/value backend to delegate to. + key_prefix: Namespace prepended to every key. Defaults to + ``"helpsess"``. + ttl_seconds: Session time-to-live. Defaults to 4 hours, matching + :class:`LocalFileStorage`. + + Example:: + + from attune_help import BackendSessionStorage, HelpEngine + storage = BackendSessionStorage(my_redis_backend) + engine = HelpEngine(storage=storage) + """ + + def __init__( + self, + backend: KVBackend, + *, + key_prefix: str = "helpsess", + ttl_seconds: int = _SESSION_TTL_SECONDS, + ) -> None: + self._backend = backend + self._prefix = key_prefix + self._ttl = ttl_seconds + + def _key(self, user_id: str) -> str: + """Backend key for a user's session.""" + return f"{self._prefix}:{user_id}" + + def get_session(self, user_id: str) -> dict[str, Any]: + """Load session state from the backend. + + Returns fresh defaults on a miss, a malformed/expired payload, or + any backend error — never raises into the runtime (matches + :class:`LocalFileStorage`). + + Args: + user_id: User identifier. + + Returns: + Session state dict, or fresh defaults. + """ + try: + raw = self._backend.retrieve(self._key(user_id)) + except Exception as e: # noqa: BLE001 - backend errors must not propagate + logger.warning("Session backend read failed: %s", e) + return _defaults() + return _deserialize(raw, self._ttl) + + def set_session(self, user_id: str, state: dict[str, Any]) -> None: + """Persist session state to the backend. + + Logs and no-ops on any backend failure (matches + :class:`LocalFileStorage`); never raises into the runtime. + + Args: + user_id: User identifier. + state: Session state dict to persist. + """ + try: + self._backend.stash(self._key(user_id), _serialize(state)) + except Exception as e: # noqa: BLE001 - backend errors must not propagate + logger.warning("Session backend write failed: %s", e) diff --git a/tests/test_backend_session_storage.py b/tests/test_backend_session_storage.py new file mode 100644 index 0000000..93a02af --- /dev/null +++ b/tests/test_backend_session_storage.py @@ -0,0 +1,168 @@ +"""Tests for BackendSessionStorage — protocol parity with the file +backend, plus error-safety of the injected key/value backend. + +No external service and no API: a dict-backed fake KVBackend drives +every case. +""" + +from __future__ import annotations + +import json + +from attune_help.storage import BackendSessionStorage + + +class FakeKV: + """In-memory KVBackend for tests (stash/retrieve over a dict).""" + + def __init__(self) -> None: + self.store: dict[str, str] = {} + + def stash(self, key: str, value: str) -> bool: + self.store[key] = value + return True + + def retrieve(self, key: str) -> str | None: + return self.store.get(key) + + +class BoomKV: + """KVBackend whose every operation raises — exercises error-safety.""" + + def stash(self, key: str, value: str) -> bool: + raise RuntimeError("backend down") + + def retrieve(self, key: str) -> str | None: + raise RuntimeError("backend down") + + +# --------------------------------------------------------------------------- +# Protocol parity with LocalFileStorage +# --------------------------------------------------------------------------- + + +def test_round_trip_new_schema() -> None: + s = BackendSessionStorage(FakeKV()) + s.set_session( + "alice", + { + "last_topic": "auth", + "depth_level": 2, + "topics": {"auth": 2, "security": 1}, + "order": ["security", "auth"], + }, + ) + got = s.get_session("alice") + assert got["topics"] == {"auth": 2, "security": 1} + assert got["order"] == ["security", "auth"] + assert got["last_topic"] == "auth" + assert got["depth_level"] == 2 + + +def test_missing_key_returns_defaults() -> None: + s = BackendSessionStorage(FakeKV()) + got = s.get_session("nobody") + assert got["topics"] == {} + assert got["order"] == [] + assert got["last_topic"] is None + assert got["depth_level"] == 0 + + +def test_legacy_payload_migrates_on_read() -> None: + kv = FakeKV() + kv.store["helpsess:alice"] = json.dumps( + {"last_topic": "security", "depth_level": 1, "timestamp": 9_999_999_999} + ) + got = BackendSessionStorage(kv).get_session("alice") + assert got["topics"] == {"security": 1} + assert got["order"] == ["security"] + + +def test_expired_session_returns_defaults() -> None: + kv = FakeKV() + kv.store["helpsess:alice"] = json.dumps( + { + "last_topic": "auth", + "depth_level": 2, + "topics": {"auth": 2}, + "order": ["auth"], + "timestamp": 0, + } + ) + got = BackendSessionStorage(kv, ttl_seconds=1).get_session("alice") + assert got["topics"] == {} + assert got["last_topic"] is None + + +def test_malformed_payload_returns_defaults() -> None: + kv = FakeKV() + kv.store["helpsess:alice"] = "{not json" + got = BackendSessionStorage(kv).get_session("alice") + assert got["topics"] == {} + assert got["last_topic"] is None + + +# --------------------------------------------------------------------------- +# Keying + configuration +# --------------------------------------------------------------------------- + + +def test_key_prefix_namespaces_writes() -> None: + kv = FakeKV() + BackendSessionStorage(kv, key_prefix="sess").set_session( + "bob", {"last_topic": "x", "depth_level": 0, "topics": {"x": 0}, "order": ["x"]} + ) + assert "sess:bob" in kv.store + assert "helpsess:bob" not in kv.store + + +def test_distinct_users_are_isolated() -> None: + s = BackendSessionStorage(FakeKV()) + s.set_session("a", {"last_topic": "ta", "depth_level": 1, "topics": {"ta": 1}, "order": ["ta"]}) + s.set_session("b", {"last_topic": "tb", "depth_level": 2, "topics": {"tb": 2}, "order": ["tb"]}) + assert s.get_session("a")["last_topic"] == "ta" + assert s.get_session("b")["last_topic"] == "tb" + + +# --------------------------------------------------------------------------- +# Error-safety — backend failures never propagate into the runtime +# --------------------------------------------------------------------------- + + +def test_read_error_returns_defaults() -> None: + got = BackendSessionStorage(BoomKV()).get_session("alice") + assert got["topics"] == {} + assert got["last_topic"] is None + + +def test_write_error_does_not_raise() -> None: + # Must not propagate — mirrors LocalFileStorage's log-and-continue. + BackendSessionStorage(BoomKV()).set_session( + "alice", {"last_topic": "x", "depth_level": 0, "topics": {}, "order": []} + ) + + +def test_stash_returning_false_is_swallowed() -> None: + class FalseKV: + def stash(self, key: str, value: str) -> bool: + return False + + def retrieve(self, key: str) -> str | None: + return None + + # No exception; the write is simply best-effort. + BackendSessionStorage(FalseKV()).set_session( + "alice", {"last_topic": "x", "depth_level": 0, "topics": {}, "order": []} + ) + + +# --------------------------------------------------------------------------- +# Exports +# --------------------------------------------------------------------------- + + +def test_exported_from_package_root() -> None: + import attune_help + + assert hasattr(attune_help, "BackendSessionStorage") + assert hasattr(attune_help, "KVBackend")