terp-cap-redis 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ .venv-*/
10
+ venv/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # uv
18
+ uv.lock
19
+
20
+ # Node
21
+ node_modules/
22
+ .pnpm-store/
23
+ *.tsbuildinfo
24
+
25
+ # Playwright (conformance e2e) artifacts
26
+ test-results/
27
+ playwright-report/
28
+ blob-report/
29
+ playwright/.cache/
30
+ .last-run.json
31
+
32
+ # Local frontend template render checks
33
+ apps/example/_frontend_tpl_check/
34
+
35
+ # Editor / OS
36
+ .DS_Store
37
+ .idea/
38
+ *.local
39
+
40
+ # Local environment overrides — never commit (a real .env may hold SECRET_KEY).
41
+ # The tracked template is `.env.example`.
42
+ .env
43
+ .env.*
44
+ !.env.example
45
+ !.env.example.jinja
46
+ # Rendered app-declared variables (environment.schema.json) — may hold secrets.
47
+ .app.env
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-cap-redis
3
+ Version: 0.1.0
4
+ Summary: Terp Redis store adapters — shared idempotency, throttling, and cache state for multi-replica deployments.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: redis>=5
8
+ Requires-Dist: terp-core==0.1.0
9
+ Provides-Extra: all
10
+ Requires-Dist: terp-cap-oidc==0.1.0; extra == 'all'
11
+ Requires-Dist: terp-cap-realtime==0.1.0; extra == 'all'
12
+ Provides-Extra: oidc
13
+ Requires-Dist: terp-cap-oidc==0.1.0; extra == 'oidc'
14
+ Provides-Extra: realtime
15
+ Requires-Dist: terp-cap-realtime==0.1.0; extra == 'realtime'
@@ -0,0 +1,3 @@
1
+ {
2
+ "arch-allow-no-adhoc-background-runtime": 1
3
+ }
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "terp-cap-redis"
7
+ version = "0.1.0"
8
+ description = "Terp Redis store adapters — shared idempotency, throttling, and cache state for multi-replica deployments."
9
+ requires-python = ">=3.13"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "terp-core==0.1.0",
13
+ "redis>=5",
14
+ ]
15
+
16
+ # Capability-facing adapters are OPT-IN extras, so the base distribution never installs
17
+ # another capability: shared throttling/idempotency/cache must not drag in a
18
+ # self-registering transport (realtime) or an SSO stack (oidc).
19
+ [project.optional-dependencies]
20
+ realtime = ["terp-cap-realtime==0.1.0"]
21
+ oidc = ["terp-cap-oidc==0.1.0"]
22
+ all = [
23
+ "terp-cap-realtime==0.1.0",
24
+ "terp-cap-oidc==0.1.0",
25
+ ]
26
+
27
+ # A LIBRARY (store-adapter) capability: it provides Redis-backed implementations of
28
+ # Terp's per-process store seams — IdempotencyStore, ThrottleStore, and CacheStore —
29
+ # and declares NO `terp.capabilities` ModuleSpec entry point (nothing is auto-mounted)
30
+ # and NO `terp.migrations` entry point (Redis owns no SQL tables). A composition root
31
+ # opts in by wiring create_app(..., idempotency_store=..., throttle_store=...,
32
+ # cache_store=..., require_shared_*=settings.is_production).
33
+
34
+ # PEP 420 namespace package: this distribution owns only `terp.capabilities.redis`.
35
+ [tool.hatch.build.targets.wheel]
36
+ sources = ["src"]
37
+ only-include = ["src/terp/capabilities/redis"]
@@ -0,0 +1,94 @@
1
+ """terp.capabilities.redis — shared Redis-backed stores for horizontal deployments.
2
+
3
+ Terp's kernel deliberately keeps the idempotency, throttling, and cache ports engine-free:
4
+ ``terp.core`` ships safe per-process defaults and boot guards, while a deployment that runs
5
+ more than one replica wires a shared backend explicitly. This capability is that opt-in
6
+ backend for Redis. It has no routes, models, or migrations; it is a library adapter a
7
+ composition root constructs from either an existing redis-py client or a Redis URL and then
8
+ passes to ``create_app``::
9
+
10
+ stores = RedisStoreBundle.from_url(settings.REDIS_URL)
11
+ create_app(
12
+ ...,
13
+ idempotency_store=stores.idempotency,
14
+ throttle_store=stores.throttle,
15
+ cache_store=stores.cache,
16
+ require_shared_idempotency_store=settings.is_production,
17
+ require_shared_throttle_store=settings.is_production,
18
+ require_shared_cache_store=settings.is_production,
19
+ )
20
+
21
+ The adapters stamp themselves with the public ``mark_shared_*`` markers so the kernel's
22
+ multi-instance boot promises are checked fail-closed. Redis operations are intentionally
23
+ small and TTL-bound: idempotency claims and throttle counters use Lua scripts for atomicity;
24
+ cache values use Redis' native string TTLs.
25
+
26
+ Two capability-facing adapters live behind optional extras, so the base distribution
27
+ depends only on ``terp-core`` (shared throttling/idempotency never installs another
28
+ capability):
29
+
30
+ * ``terp-cap-redis[realtime]`` — :class:`RedisConnectionTicketStore`
31
+ (:mod:`terp.capabilities.redis.realtime`), shared one-use realtime connection tickets.
32
+ * ``terp-cap-redis[oidc]`` — :class:`RedisOIDCStateStore`
33
+ (:mod:`terp.capabilities.redis.oidc`), shared single-use OIDC authorization state for
34
+ multi-replica SSO.
35
+ * ``terp-cap-redis[all]`` — both capability-facing adapters.
36
+
37
+ Both re-export lazily from the package root: importing them without the matching extra
38
+ raises a directive ``ModuleNotFoundError`` naming the extra to install. They are omitted
39
+ from ``__all__`` so a wildcard import remains valid for a base-only installation.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from typing import Any
45
+
46
+ from terp.capabilities.redis.stores import (
47
+ RedisCacheStore,
48
+ RedisIdempotencyStore,
49
+ RedisStoreBundle,
50
+ RedisThrottleStore,
51
+ )
52
+
53
+ # The extras' adapters import their capability at module import time, so they resolve
54
+ # lazily here: the base install (no extras) can `import terp.capabilities.redis` freely.
55
+ _EXTRA_EXPORTS = {
56
+ "RedisConnectionTicketStore": (
57
+ "terp.capabilities.redis.realtime",
58
+ "terp.capabilities.realtime",
59
+ "realtime",
60
+ ),
61
+ "RedisOIDCStateStore": (
62
+ "terp.capabilities.redis.oidc",
63
+ "terp.capabilities.oidc",
64
+ "oidc",
65
+ ),
66
+ }
67
+
68
+
69
+ def __getattr__(name: str) -> Any:
70
+ extra = _EXTRA_EXPORTS.get(name)
71
+ if extra is None:
72
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
73
+ import importlib
74
+
75
+ module_name, required_module, extra_name = extra
76
+ try:
77
+ module = importlib.import_module(module_name)
78
+ except ModuleNotFoundError as exc:
79
+ if exc.name != required_module:
80
+ raise
81
+ raise ModuleNotFoundError(
82
+ f"{name} requires the optional `{extra_name}` adapter; "
83
+ f"install `terp-cap-redis[{extra_name}]` (or `terp-cap-redis[all]`).",
84
+ name=required_module,
85
+ ) from exc
86
+ return getattr(module, name)
87
+
88
+
89
+ __all__ = [
90
+ "RedisCacheStore",
91
+ "RedisIdempotencyStore",
92
+ "RedisStoreBundle",
93
+ "RedisThrottleStore",
94
+ ]
@@ -0,0 +1,113 @@
1
+ """Redis-backed OIDC authorization state (the ``terp-cap-redis[oidc]`` extra).
2
+
3
+ The OIDC capability's default :class:`~terp.capabilities.oidc.InMemoryStateStore` is
4
+ per-process, so out of the box SSO needs one API replica or sticky routing. This store
5
+ implements the same :class:`~terp.capabilities.oidc.OIDCStateStore` port over Redis:
6
+ the ``/authorize`` and ``/callback`` halves of a flow may then land on different
7
+ replicas. ``consume`` keeps the port's guarantees — strictly single-use (an atomic
8
+ GET+DEL), expiring (a native Redis TTL bounds every pending flow), and
9
+ provider-matched (a state issued for one provider cannot finish another's callback).
10
+
11
+ Importing this submodule requires the ``oidc`` extra (``terp-cap-redis[oidc]``); the
12
+ shared kernel stores in :mod:`terp.capabilities.redis.stores` stay decoupled from it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import json
19
+ import secrets
20
+ from typing import Any
21
+
22
+ from terp.capabilities.oidc import (
23
+ DEFAULT_STATE_TTL,
24
+ PendingAuthorization,
25
+ generate_code_verifier,
26
+ )
27
+
28
+ from terp.capabilities.redis.stores import _client_from_url, _text
29
+
30
+ _CONSUME_STATE_SCRIPT = """
31
+ local value = redis.call('GET', KEYS[1])
32
+ if not value then
33
+ return nil
34
+ end
35
+ redis.call('DEL', KEYS[1])
36
+ return value
37
+ """
38
+
39
+
40
+ class RedisOIDCStateStore:
41
+ """A shared, single-use OIDC state store for multi-replica deployments."""
42
+
43
+ def __init__(
44
+ self,
45
+ client: Any,
46
+ *,
47
+ namespace: str = "terp",
48
+ ttl: datetime.timedelta = DEFAULT_STATE_TTL,
49
+ ) -> None:
50
+ ttl_seconds = int(ttl.total_seconds())
51
+ if ttl_seconds <= 0:
52
+ raise ValueError("RedisOIDCStateStore requires a positive ttl")
53
+ self._client = client
54
+ self._prefix = f"{namespace}:oidc-state:"
55
+ self._ttl = datetime.timedelta(seconds=ttl_seconds)
56
+
57
+ @classmethod
58
+ def from_url(
59
+ cls,
60
+ url: str,
61
+ *,
62
+ namespace: str = "terp",
63
+ ttl: datetime.timedelta = DEFAULT_STATE_TTL,
64
+ ) -> RedisOIDCStateStore:
65
+ """Create the store from a Redis URL using redis-py's ``Redis.from_url``."""
66
+ return cls(_client_from_url(url), namespace=namespace, ttl=ttl)
67
+
68
+ def issue(self, provider: str) -> tuple[str, PendingAuthorization]:
69
+ """Open a new flow for *provider*: returns ``(state, pending)``."""
70
+ state = secrets.token_urlsafe(32)
71
+ pending = PendingAuthorization(
72
+ provider=provider,
73
+ nonce=secrets.token_urlsafe(32),
74
+ code_verifier=generate_code_verifier(),
75
+ expires_at=datetime.datetime.now(datetime.UTC) + self._ttl,
76
+ )
77
+ value = json.dumps(
78
+ {
79
+ "provider": pending.provider,
80
+ "nonce": pending.nonce,
81
+ "code_verifier": pending.code_verifier,
82
+ "expires_at": pending.expires_at.isoformat(),
83
+ },
84
+ separators=(",", ":"),
85
+ )
86
+ # The Redis TTL is the expiry: an abandoned flow ages out server-side, so the
87
+ # store cannot grow without bound and a replica never sees a stale state.
88
+ self._client.set(self._key(state), value, ex=int(self._ttl.total_seconds()))
89
+ return state, pending
90
+
91
+ def consume(self, state: str, provider: str) -> PendingAuthorization | None:
92
+ """Redeem *state* exactly once, or ``None`` (unknown / expired / wrong provider)."""
93
+ raw = self._client.eval(_CONSUME_STATE_SCRIPT, 1, self._key(state))
94
+ if raw is None:
95
+ return None
96
+ payload = json.loads(_text(raw))
97
+ pending = PendingAuthorization(
98
+ provider=payload["provider"],
99
+ nonce=payload["nonce"],
100
+ code_verifier=payload["code_verifier"],
101
+ expires_at=datetime.datetime.fromisoformat(payload["expires_at"]),
102
+ )
103
+ if pending.provider != provider:
104
+ return None
105
+ if pending.expires_at <= datetime.datetime.now(datetime.UTC):
106
+ return None
107
+ return pending
108
+
109
+ def _key(self, state: str) -> str:
110
+ return f"{self._prefix}{state}"
111
+
112
+
113
+ __all__ = ["RedisOIDCStateStore"]
@@ -0,0 +1,91 @@
1
+ """Redis-backed realtime connection tickets (the ``terp-cap-redis[realtime]`` extra).
2
+
3
+ The realtime capability's one-use connection tickets are per-process by default; a
4
+ multi-replica deployment shares them here so the replica that serves the WebSocket
5
+ upgrade can consume a ticket another replica issued. This submodule is the only place
6
+ terp-cap-redis touches terp-cap-realtime: importing it requires the ``realtime`` extra
7
+ (``terp-cap-redis[realtime]``), so the shared kernel stores in
8
+ :mod:`terp.capabilities.redis.stores` never drag a self-registering transport
9
+ capability onto the path.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import uuid
16
+ from typing import Any
17
+
18
+ from terp.capabilities.realtime import ConnectionTicket, ConnectionTicketStore
19
+ from terp.core import Principal, Role
20
+
21
+ from terp.capabilities.redis.stores import _client_from_url, _text
22
+
23
+ _CONSUME_TICKET_SCRIPT = """
24
+ local value = redis.call('GET', KEYS[1])
25
+ if not value then
26
+ return nil
27
+ end
28
+ redis.call('DEL', KEYS[1])
29
+ return value
30
+ """
31
+
32
+
33
+ class RedisConnectionTicketStore(ConnectionTicketStore):
34
+ """Shared one-use realtime tickets with atomic GET+DEL consumption."""
35
+
36
+ def __init__(self, client: Any, *, namespace: str = "terp") -> None:
37
+ self._client = client
38
+ self._prefix = f"{namespace}:realtime-ticket:"
39
+
40
+ @classmethod
41
+ def from_url(
42
+ cls, url: str, *, namespace: str = "terp"
43
+ ) -> RedisConnectionTicketStore:
44
+ return cls(_client_from_url(url), namespace=namespace)
45
+
46
+ def issue(self, ticket: ConnectionTicket, *, ttl_seconds: int) -> str:
47
+ if ttl_seconds <= 0:
48
+ raise ValueError(
49
+ "RedisConnectionTicketStore.issue requires a positive ttl_seconds"
50
+ )
51
+ token = uuid.uuid4().hex + uuid.uuid4().hex
52
+ value = json.dumps(
53
+ {
54
+ "principal_id": str(ticket.principal.id),
55
+ "role_name": ticket.principal.role.name,
56
+ "role_rank": ticket.principal.role.rank,
57
+ "channel": ticket.channel,
58
+ "transport": ticket.transport,
59
+ "credential": ticket.credential,
60
+ "audience": ticket.audience,
61
+ },
62
+ separators=(",", ":"),
63
+ )
64
+ self._client.set(self._key(token), value, ex=int(ttl_seconds))
65
+ return token
66
+
67
+ def consume(
68
+ self, token: str, *, channel: str, transport: str
69
+ ) -> ConnectionTicket | None:
70
+ raw = self._client.eval(_CONSUME_TICKET_SCRIPT, 1, self._key(token))
71
+ if raw is None:
72
+ return None
73
+ payload = json.loads(_text(raw))
74
+ if payload["channel"] != channel or payload["transport"] != transport:
75
+ return None
76
+ return ConnectionTicket(
77
+ principal=Principal(
78
+ id=uuid.UUID(payload["principal_id"]),
79
+ role=Role(payload["role_name"], int(payload["role_rank"])),
80
+ ),
81
+ channel=payload["channel"],
82
+ transport=payload["transport"],
83
+ credential=payload.get("credential", ""),
84
+ audience=payload.get("audience", ""),
85
+ )
86
+
87
+ def _key(self, token: str) -> str:
88
+ return f"{self._prefix}{token}"
89
+
90
+
91
+ __all__ = ["RedisConnectionTicketStore"]
@@ -0,0 +1,321 @@
1
+ """Redis implementations of Terp's kernel store ports (idempotency, throttle, cache).
2
+
3
+ The three ports have deliberately narrow contracts, so the adapter keeps Redis usage narrow
4
+ as well: one namespaced key per logical entry, explicit TTLs on every value, and Lua only
5
+ where a multi-command transition must be indivisible. That gives horizontally scaled Terp
6
+ apps honest global behaviour without teaching the kernel about Redis or weakening the
7
+ single-process defaults.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import importlib
14
+ import json
15
+ import uuid
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from terp.core import (
20
+ BeginOutcome,
21
+ CacheStore,
22
+ IdempotencyStore,
23
+ StoredResponse,
24
+ ThrottleStore,
25
+ mark_shared_cache_store,
26
+ mark_shared_idempotency_store,
27
+ mark_shared_throttle_store,
28
+ )
29
+
30
+ _BEGIN_SCRIPT = """
31
+ local fingerprint = redis.call('HGET', KEYS[1], 'fingerprint')
32
+ if not fingerprint then
33
+ redis.call('HSET', KEYS[1], 'fingerprint', ARGV[1], 'lease', ARGV[2], 'done', '0')
34
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
35
+ return {'started', ARGV[2]}
36
+ end
37
+ if fingerprint ~= ARGV[1] then
38
+ return {'mismatch'}
39
+ end
40
+ if redis.call('HGET', KEYS[1], 'done') ~= '1' then
41
+ return {'in_flight'}
42
+ end
43
+ return {
44
+ 'replay',
45
+ redis.call('HGET', KEYS[1], 'status'),
46
+ redis.call('HGET', KEYS[1], 'headers'),
47
+ redis.call('HGET', KEYS[1], 'body')
48
+ }
49
+ """
50
+
51
+ _COMPLETE_SCRIPT = """
52
+ if redis.call('HGET', KEYS[1], 'lease') ~= ARGV[1] then
53
+ return 0
54
+ end
55
+ redis.call(
56
+ 'HSET', KEYS[1],
57
+ 'done', '1',
58
+ 'status', ARGV[2],
59
+ 'headers', ARGV[3],
60
+ 'body', ARGV[4]
61
+ )
62
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[5]))
63
+ return 1
64
+ """
65
+
66
+ _RELEASE_SCRIPT = """
67
+ if redis.call('HGET', KEYS[1], 'lease') == ARGV[1] then
68
+ return redis.call('DEL', KEYS[1])
69
+ end
70
+ return 0
71
+ """
72
+
73
+ _HIT_SCRIPT = """
74
+ local count = redis.call('INCR', KEYS[1])
75
+ if count == 1 then
76
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
77
+ end
78
+ local ttl = redis.call('TTL', KEYS[1])
79
+ if ttl < 0 then
80
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
81
+ ttl = tonumber(ARGV[1])
82
+ end
83
+ return {count, ttl}
84
+ """
85
+
86
+ def _client_from_url(url: str) -> Any:
87
+ """Construct a redis-py client lazily so importing the adapter stays lightweight."""
88
+ from redis import Redis # arch-allow-no-adhoc-background-runtime: this capability IS the Redis adapter for shared store seams — the one governed place the engine is imported
89
+
90
+ return Redis.from_url(url)
91
+
92
+
93
+ def _text(value: object) -> str:
94
+ if isinstance(value, bytes):
95
+ return value.decode("utf-8")
96
+ return str(value)
97
+
98
+
99
+ def _bytes(value: object) -> bytes:
100
+ if isinstance(value, bytes):
101
+ return value
102
+ return str(value).encode("utf-8")
103
+
104
+
105
+ class RedisIdempotencyStore(IdempotencyStore):
106
+ """A Redis-backed :class:`~terp.core.IdempotencyStore` with atomic claims.
107
+
108
+ ``begin`` is a single Lua transition: the first caller creates an in-flight hash with a
109
+ random lease and TTL; concurrent callers see ``in_flight``; completed hashes replay only
110
+ when the fingerprint matches; reuse under a different fingerprint is always a
111
+ ``mismatch``. ``complete`` and ``release`` are also Lua lease checks, so a slow request
112
+ whose claim expired cannot overwrite or delete a newer claim.
113
+ """
114
+
115
+ def __init__(self, client: Any, *, namespace: str = "terp") -> None:
116
+ self._client = client
117
+ self._prefix = f"{namespace}:idempotency:"
118
+ mark_shared_idempotency_store(self)
119
+
120
+ @classmethod
121
+ def from_url(cls, url: str, *, namespace: str = "terp") -> RedisIdempotencyStore:
122
+ """Create the store from a Redis URL using redis-py's ``Redis.from_url``."""
123
+ return cls(_client_from_url(url), namespace=namespace)
124
+
125
+ def begin(self, key: str, fingerprint: str, *, ttl_seconds: int) -> BeginOutcome:
126
+ if ttl_seconds <= 0:
127
+ raise ValueError(
128
+ f"RedisIdempotencyStore.begin requires a positive ttl_seconds, got {ttl_seconds!r}"
129
+ )
130
+ lease = uuid.uuid4().hex
131
+ result = self._client.eval(
132
+ _BEGIN_SCRIPT,
133
+ 1,
134
+ self._key(key),
135
+ fingerprint,
136
+ lease,
137
+ int(ttl_seconds),
138
+ )
139
+ parts = list(result)
140
+ state = _text(parts[0])
141
+ if state == "started":
142
+ return BeginOutcome(state="started", lease=_text(parts[1]))
143
+ if state == "in_flight":
144
+ return BeginOutcome(state="in_flight")
145
+ if state == "mismatch":
146
+ return BeginOutcome(state="mismatch")
147
+ headers = tuple((str(name), str(value)) for name, value in json.loads(_text(parts[2])))
148
+ body = base64.b64decode(_bytes(parts[3]))
149
+ response = StoredResponse(status_code=int(_text(parts[1])), headers=headers, body=body)
150
+ return BeginOutcome(state="replay", response=response)
151
+
152
+ def complete(
153
+ self, key: str, lease: str, response: StoredResponse, *, ttl_seconds: int
154
+ ) -> None:
155
+ if ttl_seconds <= 0:
156
+ raise ValueError(
157
+ f"RedisIdempotencyStore.complete requires a positive ttl_seconds, got {ttl_seconds!r}"
158
+ )
159
+ headers = json.dumps(list(response.headers), separators=(",", ":"))
160
+ self._client.eval(
161
+ _COMPLETE_SCRIPT,
162
+ 1,
163
+ self._key(key),
164
+ lease,
165
+ str(response.status_code),
166
+ headers,
167
+ base64.b64encode(response.body).decode("ascii"),
168
+ int(ttl_seconds),
169
+ )
170
+
171
+ def release(self, key: str, lease: str) -> None:
172
+ self._client.eval(_RELEASE_SCRIPT, 1, self._key(key), lease)
173
+
174
+ def _key(self, key: str) -> str:
175
+ return f"{self._prefix}{key}"
176
+
177
+
178
+ class RedisThrottleStore(ThrottleStore):
179
+ """A Redis fixed-window counter and lockout store.
180
+
181
+ Hit counters are namespaced strings whose first increment sets the window TTL in the
182
+ same Lua script. Locks are separate TTL strings, so clearing a key removes both the
183
+ counter and the lock while callers retain the port's simple ``hit`` / ``lock`` /
184
+ ``locked`` contract.
185
+ """
186
+
187
+ def __init__(self, client: Any, *, namespace: str = "terp") -> None:
188
+ self._client = client
189
+ self._hits_prefix = f"{namespace}:throttle:h:"
190
+ self._locks_prefix = f"{namespace}:throttle:l:"
191
+ mark_shared_throttle_store(self)
192
+
193
+ @classmethod
194
+ def from_url(cls, url: str, *, namespace: str = "terp") -> RedisThrottleStore:
195
+ """Create the store from a Redis URL using redis-py's ``Redis.from_url``."""
196
+ return cls(_client_from_url(url), namespace=namespace)
197
+
198
+ def hit(self, key: str, window_seconds: int) -> tuple[int, int]:
199
+ result = self._client.eval(_HIT_SCRIPT, 1, self._hit_key(key), int(window_seconds))
200
+ count, reset = list(result)
201
+ return int(count), max(1, int(reset))
202
+
203
+ def lock(self, key: str, seconds: int) -> None:
204
+ self._client.set(self._lock_key(key), "1", ex=int(seconds))
205
+
206
+ def locked(self, key: str) -> int:
207
+ ttl = int(self._client.ttl(self._lock_key(key)))
208
+ if ttl <= 0:
209
+ return 0
210
+ return ttl
211
+
212
+ def clear(self, key: str) -> None:
213
+ self._client.delete(self._hit_key(key), self._lock_key(key))
214
+
215
+ def _hit_key(self, key: str) -> str:
216
+ return f"{self._hits_prefix}{key}"
217
+
218
+ def _lock_key(self, key: str) -> str:
219
+ return f"{self._locks_prefix}{key}"
220
+
221
+
222
+ class RedisCacheStore(CacheStore):
223
+ """A Redis string cache with one TTL-bound key per Terp cache entry."""
224
+
225
+ def __init__(self, client: Any, *, namespace: str = "terp") -> None:
226
+ self._client = client
227
+ self._prefix = f"{namespace}:cache:"
228
+ mark_shared_cache_store(self)
229
+
230
+ @classmethod
231
+ def from_url(cls, url: str, *, namespace: str = "terp") -> RedisCacheStore:
232
+ """Create the store from a Redis URL using redis-py's ``Redis.from_url``."""
233
+ return cls(_client_from_url(url), namespace=namespace)
234
+
235
+ def get(self, key: str) -> str | None:
236
+ value = self._client.get(self._key(key))
237
+ if value is None:
238
+ return None
239
+ return _text(value)
240
+
241
+ def set(self, key: str, value: str, *, ttl_seconds: int) -> None:
242
+ if ttl_seconds <= 0:
243
+ raise ValueError(
244
+ f"RedisCacheStore.set requires a positive ttl_seconds, got {ttl_seconds!r}"
245
+ )
246
+ self._client.set(self._key(key), value, ex=int(ttl_seconds))
247
+
248
+ def delete(self, key: str) -> None:
249
+ self._client.delete(self._key(key))
250
+
251
+ def _key(self, key: str) -> str:
252
+ return f"{self._prefix}{key}"
253
+
254
+
255
+ @dataclass(frozen=True)
256
+ class RedisStoreBundle:
257
+ """Convenience holder for Redis-backed platform store adapters.
258
+
259
+ Use this when one Redis deployment backs all three Terp store seams. Separate classes
260
+ remain available for deployments that split cache and control-state Redis clusters.
261
+ Capability-facing adapters (realtime connection tickets, OIDC authorization state)
262
+ live in their own submodules behind optional extras. For compatibility, the bundle
263
+ includes ``realtime_tickets`` when the ``realtime`` extra is installed; it is ``None``
264
+ in a base-only installation.
265
+ """
266
+
267
+ idempotency: RedisIdempotencyStore
268
+ throttle: RedisThrottleStore
269
+ cache: RedisCacheStore
270
+ realtime_tickets: Any | None = None
271
+
272
+ @classmethod
273
+ def from_client(cls, client: Any, *, namespace: str = "terp") -> RedisStoreBundle:
274
+ """Build all three adapters over an already-configured redis-py compatible client."""
275
+ return cls(
276
+ idempotency=RedisIdempotencyStore(client, namespace=namespace),
277
+ throttle=RedisThrottleStore(client, namespace=namespace),
278
+ cache=RedisCacheStore(client, namespace=namespace),
279
+ realtime_tickets=_optional_realtime_store(client, namespace=namespace),
280
+ )
281
+
282
+ @classmethod
283
+ def from_url(cls, url: str, *, namespace: str = "terp") -> RedisStoreBundle:
284
+ """Build all three adapters over one Redis client constructed from *url*."""
285
+ return cls.from_client(_client_from_url(url), namespace=namespace)
286
+
287
+
288
+ def _optional_realtime_store(client: Any, *, namespace: str) -> Any | None:
289
+ """Build the compatibility bundle member only when the realtime extra is installed."""
290
+ try:
291
+ module = importlib.import_module("terp.capabilities.redis.realtime")
292
+ except ModuleNotFoundError as exc:
293
+ if exc.name == "terp.capabilities.realtime":
294
+ return None
295
+ raise
296
+ return module.RedisConnectionTicketStore(client, namespace=namespace)
297
+
298
+
299
+ def __getattr__(name: str) -> Any:
300
+ """Preserve the former direct import path when the realtime extra is installed."""
301
+ if name != "RedisConnectionTicketStore":
302
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
303
+ try:
304
+ module = importlib.import_module("terp.capabilities.redis.realtime")
305
+ except ModuleNotFoundError as exc:
306
+ if exc.name != "terp.capabilities.realtime":
307
+ raise
308
+ raise ModuleNotFoundError(
309
+ "RedisConnectionTicketStore requires the optional `realtime` adapter; "
310
+ "install `terp-cap-redis[realtime]` (or `terp-cap-redis[all]`).",
311
+ name="terp.capabilities.realtime",
312
+ ) from exc
313
+ return module.RedisConnectionTicketStore
314
+
315
+
316
+ __all__ = [
317
+ "RedisCacheStore",
318
+ "RedisIdempotencyStore",
319
+ "RedisStoreBundle",
320
+ "RedisThrottleStore",
321
+ ]