- F11 PopulateRule field_name/collection_name corrections - F12 service adaptor update handles PopulatingRepository - F13 nested-references section; F14 authless /users/me guidance - F15 template ruff-clean; F16 stop vendoring .ruff_cache, ship .gitignore - document --template-style in build_recipe/spec_first_routing; refresh collected openapi-first + standards bundle (12 modules, 43 components) - add trial.md and trial_findings.md (F1-F16, re-trial A/B evidence)
147 lines
4.6 KiB
Python
147 lines
4.6 KiB
Python
"""# Summary
|
|
|
|
Key-value store port with in-memory and Redis adaptors.
|
|
|
|
---
|
|
|
|
Reserve infrastructure for caching small values by key (sessions, locks,
|
|
result blobs, rate counters). The in-memory adaptor needs no services and is a
|
|
safe default; the Redis adaptor is opt-in and imports ``redis`` lazily so it
|
|
never becomes a hard dependency.
|
|
|
|
Example:
|
|
```python
|
|
from infra.adapters.kv import InMemoryKV
|
|
|
|
kv = InMemoryKV()
|
|
await kv.set("greeting", "hello", ttl=30)
|
|
assert await kv.get("greeting") == "hello"
|
|
```
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
|
|
|
|
class KVStore(ABC):
|
|
"""Abstract key-value store port."""
|
|
|
|
@abstractmethod
|
|
async def get(self, key: str) -> str | None:
|
|
"""Return the value at ``key``, or ``None``."""
|
|
|
|
@abstractmethod
|
|
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
|
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
|
|
|
@abstractmethod
|
|
async def delete(self, key: str) -> None:
|
|
"""Delete ``key`` if present."""
|
|
|
|
@abstractmethod
|
|
async def scan(self, pattern: str) -> list[str]:
|
|
"""Return keys matching a glob ``pattern``."""
|
|
|
|
async def clear(self, pattern: str = "*") -> None:
|
|
"""Delete all keys matching ``pattern``.
|
|
|
|
Args:
|
|
pattern (str): Glob-style key pattern.
|
|
"""
|
|
for key in await self.scan(pattern):
|
|
await self.delete(key)
|
|
|
|
async def close(self) -> None:
|
|
"""Release any underlying resources. Default is a no-op."""
|
|
|
|
|
|
class InMemoryKV(KVStore):
|
|
"""Process-local key-value store used as a dependency-free fallback.
|
|
|
|
Attributes:
|
|
_data (Dict[str, str]): In-memory value map.
|
|
_expiry (Dict[str, float]): Absolute expiry timestamps by key; ``0.0``
|
|
means "no expiry".
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize an empty store with no imports or connections."""
|
|
self._data: dict[str, str] = {}
|
|
self._expiry: dict[str, float] = {}
|
|
|
|
def _expired(self, key: str) -> bool:
|
|
import time
|
|
|
|
deadline = self._expiry.get(key, 0.0)
|
|
if deadline and deadline < time.time():
|
|
self._data.pop(key, None)
|
|
self._expiry.pop(key, None)
|
|
return True
|
|
return False
|
|
|
|
async def get(self, key: str) -> str | None:
|
|
"""Return the value at ``key``, or ``None`` when absent/expired."""
|
|
if self._expired(key):
|
|
return None
|
|
return self._data.get(key)
|
|
|
|
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
|
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
|
import time
|
|
|
|
self._data[key] = value
|
|
self._expiry[key] = (time.time() + ttl) if ttl else 0.0
|
|
|
|
async def delete(self, key: str) -> None:
|
|
"""Delete ``key`` if present."""
|
|
self._data.pop(key, None)
|
|
self._expiry.pop(key, None)
|
|
|
|
async def scan(self, pattern: str) -> list[str]:
|
|
"""Return live keys matching a glob ``pattern``."""
|
|
import fnmatch
|
|
|
|
return [k for k in list(self._data) if fnmatch.fnmatch(k, pattern) and not self._expired(k)]
|
|
|
|
|
|
class RedisKV(KVStore):
|
|
"""Redis-backed key-value store.
|
|
|
|
Attributes:
|
|
_redis: Asynchronous Redis client (imported lazily).
|
|
"""
|
|
|
|
def __init__(self, url: str = "redis://localhost:6379/0", decode_responses: bool = True):
|
|
"""Initialize the store with a Redis connection.
|
|
|
|
Args:
|
|
url (str): Redis connection URL.
|
|
decode_responses (bool): Whether Redis returns decoded strings.
|
|
|
|
Raises:
|
|
ImportError: When the optional ``redis`` package is not installed.
|
|
"""
|
|
from redis.asyncio import Redis
|
|
|
|
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
|
|
|
async def get(self, key: str) -> str | None:
|
|
"""Return the value at ``key``, or ``None``."""
|
|
return await self._redis.get(key)
|
|
|
|
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
|
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
|
await self._redis.set(key, value, ex=ttl)
|
|
|
|
async def delete(self, key: str) -> None:
|
|
"""Delete ``key`` if present."""
|
|
await self._redis.delete(key)
|
|
|
|
async def scan(self, pattern: str) -> list[str]:
|
|
"""Return keys matching a glob ``pattern``."""
|
|
return [key async for key in self._redis.scan_iter(match=pattern)]
|
|
|
|
async def close(self) -> None:
|
|
"""Close the underlying Redis connection."""
|
|
await self._redis.aclose()
|