idemkit 0.1.0__py3-none-any.whl

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.
Files changed (48) hide show
  1. idemkit/__init__.py +101 -0
  2. idemkit/_version.py +1 -0
  3. idemkit/adapters/__init__.py +10 -0
  4. idemkit/adapters/ai.py +723 -0
  5. idemkit/adapters/asgi.py +533 -0
  6. idemkit/adapters/queue.py +413 -0
  7. idemkit/adapters/route.py +273 -0
  8. idemkit/adapters/wsgi.py +393 -0
  9. idemkit/backends/__init__.py +30 -0
  10. idemkit/backends/base.py +116 -0
  11. idemkit/backends/dynamodb.py +489 -0
  12. idemkit/backends/memory.py +325 -0
  13. idemkit/backends/mongo.py +395 -0
  14. idemkit/backends/postgres.py +682 -0
  15. idemkit/backends/redis.py +547 -0
  16. idemkit/cli.py +165 -0
  17. idemkit/conformance/__init__.py +25 -0
  18. idemkit/conformance/backend.py +257 -0
  19. idemkit/conformance/report.py +54 -0
  20. idemkit/contrib/__init__.py +30 -0
  21. idemkit/contrib/drf.py +181 -0
  22. idemkit/contrib/fastapi.py +141 -0
  23. idemkit/contrib/kafka.py +96 -0
  24. idemkit/contrib/logging.py +61 -0
  25. idemkit/contrib/mcp.py +113 -0
  26. idemkit/contrib/prometheus.py +79 -0
  27. idemkit/contrib/pubsub.py +98 -0
  28. idemkit/contrib/rabbitmq.py +138 -0
  29. idemkit/contrib/reconciliation.py +86 -0
  30. idemkit/contrib/sqs.py +117 -0
  31. idemkit/core/__init__.py +36 -0
  32. idemkit/core/codecs.py +229 -0
  33. idemkit/core/config.py +255 -0
  34. idemkit/core/engine.py +331 -0
  35. idemkit/core/events.py +63 -0
  36. idemkit/core/exception_cache.py +88 -0
  37. idemkit/core/exceptions.py +79 -0
  38. idemkit/core/fingerprint.py +153 -0
  39. idemkit/core/policy.py +143 -0
  40. idemkit/core/runner.py +664 -0
  41. idemkit/core/state.py +95 -0
  42. idemkit/core/sync_bridge.py +115 -0
  43. idemkit/problem_details.py +119 -0
  44. idemkit/py.typed +0 -0
  45. idemkit-0.1.0.dist-info/METADATA +446 -0
  46. idemkit-0.1.0.dist-info/RECORD +48 -0
  47. idemkit-0.1.0.dist-info/WHEEL +4 -0
  48. idemkit-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,325 @@
1
+ """In-memory backend per spec §4.1 / §4.13.
2
+
3
+ Single-process only — NOT distributed. Intended for development and tests.
4
+ ``IdempotencyMiddleware`` logs a warning if this backend is used outside a
5
+ test environment.
6
+
7
+ Storage model: per-effective-key ``asyncio.Lock`` (here approximated by a
8
+ single coarse lock since asyncio is single-threaded; the coarse lock is
9
+ correct and sufficient for in-process use) plus a dict keyed by effective
10
+ key. Per-key ``asyncio.Event`` instances signal completion to waiters
11
+ (spec §4.3 subscribe-before-read).
12
+
13
+ Per spec §4.13, eviction is per-key TTL only — no LRU. When ``max_size`` is
14
+ reached, new claims are rejected with ``MemoryBackendFull``, which the
15
+ middleware maps to a ``storage_error`` event (spec §4.16). This is the
16
+ explicit design choice: any LRU that can evict ``CLAIMED`` records risks
17
+ duplicate execution under load.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import logging
24
+ import secrets
25
+ import time
26
+ from collections.abc import Callable
27
+
28
+ from idemkit.core.state import (
29
+ ClaimResult,
30
+ ClaimResultType,
31
+ State,
32
+ StoredRecord,
33
+ )
34
+
35
+ _logger = logging.getLogger(__name__)
36
+
37
+ # In-flight wait poll schedule (spec §5.5): re-read at a backed-off cadence so a
38
+ # lost completion notification can't hang a waiter for the full timeout.
39
+ _POLL_INITIAL_SECONDS = 0.05
40
+ _POLL_CAP_SECONDS = 1.0
41
+
42
+
43
+ class MemoryBackendFull(RuntimeError):
44
+ """Raised when ``InMemoryBackend`` is at ``max_size`` and rejects a new claim."""
45
+
46
+
47
+ class ManualClock:
48
+ """An advanceable clock for deterministic lease/TTL tests (spec §9.4).
49
+
50
+ Pass to ``InMemoryBackend(clock=ManualClock())`` and call ``advance(seconds)``
51
+ to expire leases and completed-TTLs without real sleeps::
52
+
53
+ clock = ManualClock()
54
+ backend = InMemoryBackend(clock=clock)
55
+ ... # claim + complete
56
+ clock.advance(86_401) # past expires_after_seconds
57
+ ... # next claim now sees the record as expired
58
+ """
59
+
60
+ def __init__(self, start: float = 0.0) -> None:
61
+ self._t = start
62
+
63
+ def __call__(self) -> float:
64
+ return self._t
65
+
66
+ def advance(self, seconds: float) -> None:
67
+ self._t += seconds
68
+
69
+
70
+ class InMemoryBackend:
71
+ """Reference in-memory implementation of the backend Protocol."""
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ max_size: int = 10_000,
77
+ default_expires_after_seconds: float = 86_400.0,
78
+ clock: Callable[[], float] | None = None,
79
+ ) -> None:
80
+ self._max_size = max_size
81
+ self._default_completed_ttl = default_expires_after_seconds
82
+ self._records: dict[str, StoredRecord] = {}
83
+ self._waiters: dict[str, asyncio.Event] = {}
84
+ self._lock = asyncio.Lock()
85
+ # The storage clock that drives lease expiry and TTLs. Inject a ManualClock
86
+ # in tests to advance time deterministically instead of sleeping (spec §9.4).
87
+ # (The in-flight wait timeout still uses real time, so it always elapses.)
88
+ self._clock: Callable[[], float] = clock or time.monotonic
89
+
90
+ async def aclose(self) -> None:
91
+ """No-op: the in-memory backend holds no external resources. Provided so
92
+ it is a drop-in for the Redis/Postgres backends in ``async with`` and
93
+ ``aclose()`` teardown code."""
94
+ return
95
+
96
+ async def __aenter__(self) -> InMemoryBackend:
97
+ return self
98
+
99
+ async def __aexit__(self, *exc: object) -> None:
100
+ await self.aclose()
101
+
102
+ async def claim(
103
+ self,
104
+ effective_key: str,
105
+ fingerprint: str,
106
+ fingerprint_version: int,
107
+ lease_ttl_seconds: float,
108
+ ) -> ClaimResult:
109
+ async with self._lock:
110
+ self._gc_expired_unlocked()
111
+ existing = self._records.get(effective_key)
112
+ now = self._clock()
113
+
114
+ if existing is None:
115
+ if len(self._records) >= self._max_size:
116
+ _logger.warning(
117
+ "idemkit.InMemoryBackend at max_size=%d; rejecting claim. "
118
+ "Increase max_size or switch to a distributed backend.",
119
+ self._max_size,
120
+ )
121
+ raise MemoryBackendFull(f"InMemoryBackend at max_size={self._max_size}")
122
+ return self._fresh_claim(
123
+ effective_key, fingerprint, fingerprint_version, lease_ttl_seconds, now
124
+ )
125
+
126
+ if existing.state == State.COMPLETED:
127
+ # Expired completed records are dropped by _gc_expired_unlocked above
128
+ # so reaching here means a valid completed record exists.
129
+ return ClaimResult(
130
+ result=ClaimResultType.ALREADY_COMPLETED,
131
+ record=existing,
132
+ )
133
+
134
+ # state == CLAIMED
135
+ if existing.lease_until < now:
136
+ # Lease expired; reclaim atomically. Wake any pre-existing waiter
137
+ # that was watching the old claim — they will re-read state and
138
+ # see the new record (different claim_token) and behave correctly.
139
+ old_event = self._waiters.pop(effective_key, None)
140
+ if old_event is not None:
141
+ old_event.set()
142
+ result = self._fresh_claim(
143
+ effective_key,
144
+ fingerprint,
145
+ fingerprint_version,
146
+ lease_ttl_seconds,
147
+ now,
148
+ )
149
+ return ClaimResult(
150
+ result=ClaimResultType.LEASE_RECLAIMED,
151
+ record=result.record,
152
+ our_claim_token=result.our_claim_token,
153
+ )
154
+
155
+ return ClaimResult(
156
+ result=ClaimResultType.ALREADY_CLAIMED,
157
+ record=existing,
158
+ )
159
+
160
+ def _fresh_claim(
161
+ self,
162
+ effective_key: str,
163
+ fingerprint: str,
164
+ fingerprint_version: int,
165
+ lease_ttl_seconds: float,
166
+ now: float,
167
+ ) -> ClaimResult:
168
+ """Write a brand-new CLAIMED record. Caller holds self._lock."""
169
+ claim_token = secrets.token_hex(16) # 128 bits, spec §4.1
170
+ record = StoredRecord(
171
+ effective_key=effective_key,
172
+ state=State.CLAIMED,
173
+ fingerprint=fingerprint,
174
+ fingerprint_version=fingerprint_version,
175
+ claim_token=claim_token,
176
+ claimed_at=now,
177
+ lease_until=now + lease_ttl_seconds,
178
+ )
179
+ self._records[effective_key] = record
180
+ return ClaimResult(
181
+ result=ClaimResultType.NEW_CLAIMED,
182
+ record=record,
183
+ our_claim_token=claim_token,
184
+ )
185
+
186
+ async def complete(
187
+ self,
188
+ effective_key: str,
189
+ claim_token: str,
190
+ response_status: int,
191
+ response_headers: dict[str, str],
192
+ response_body: bytes,
193
+ expires_after_seconds: float,
194
+ ) -> bool:
195
+ async with self._lock:
196
+ existing = self._records.get(effective_key)
197
+ if (
198
+ existing is None
199
+ or existing.state != State.CLAIMED
200
+ or existing.claim_token != claim_token
201
+ ):
202
+ return False
203
+
204
+ now = self._clock()
205
+ self._records[effective_key] = StoredRecord(
206
+ effective_key=existing.effective_key,
207
+ state=State.COMPLETED,
208
+ fingerprint=existing.fingerprint,
209
+ fingerprint_version=existing.fingerprint_version,
210
+ claim_token=existing.claim_token,
211
+ claimed_at=existing.claimed_at,
212
+ lease_until=now + expires_after_seconds, # spec §4.8 TTL from completed_at
213
+ completed_at=now,
214
+ response_status=response_status,
215
+ response_headers=dict(response_headers),
216
+ response_body=response_body,
217
+ )
218
+ self._signal_waiters_unlocked(effective_key)
219
+ return True
220
+
221
+ async def release(self, effective_key: str, claim_token: str) -> bool:
222
+ async with self._lock:
223
+ existing = self._records.get(effective_key)
224
+ if (
225
+ existing is None
226
+ or existing.state != State.CLAIMED
227
+ or existing.claim_token != claim_token
228
+ ):
229
+ return False
230
+ del self._records[effective_key]
231
+ self._signal_waiters_unlocked(effective_key)
232
+ return True
233
+
234
+ async def renew(
235
+ self,
236
+ effective_key: str,
237
+ claim_token: str,
238
+ lease_ttl_seconds: float,
239
+ ) -> bool:
240
+ """Push out the lease of a still-held claim (spec §5.3.1)."""
241
+ async with self._lock:
242
+ existing = self._records.get(effective_key)
243
+ if (
244
+ existing is None
245
+ or existing.state != State.CLAIMED
246
+ or existing.claim_token != claim_token
247
+ ):
248
+ return False
249
+ now = self._clock()
250
+ # Replace with a copy carrying the new lease deadline. We do not
251
+ # require the old lease to still be live: this op and claim() both
252
+ # hold self._lock, so they serialize — if a reclaim had already
253
+ # happened, claim_token would differ and we'd have returned False.
254
+ self._records[effective_key] = StoredRecord(
255
+ effective_key=existing.effective_key,
256
+ state=existing.state,
257
+ fingerprint=existing.fingerprint,
258
+ fingerprint_version=existing.fingerprint_version,
259
+ claim_token=existing.claim_token,
260
+ claimed_at=existing.claimed_at,
261
+ lease_until=now + lease_ttl_seconds,
262
+ )
263
+ return True
264
+
265
+ async def wait_for_completion(
266
+ self,
267
+ effective_key: str,
268
+ timeout_seconds: float,
269
+ ) -> StoredRecord | None:
270
+ # Subscribe BEFORE re-reading state, per spec §4.3, then wait on a
271
+ # NOTIFICATION-OR-POLL schedule (§5.5): the event is the latency
272
+ # optimization, the bounded backed-off re-read is the correctness floor.
273
+ # An in-process Event can't really be "lost", but keeping the same loop
274
+ # shape as the distributed backends is what makes the contract uniform.
275
+ async with self._lock:
276
+ event = self._waiters.setdefault(effective_key, asyncio.Event())
277
+
278
+ deadline = time.monotonic() + timeout_seconds
279
+ poll = _POLL_INITIAL_SECONDS
280
+ while True:
281
+ async with self._lock:
282
+ existing = self._records.get(effective_key)
283
+ if existing is not None and existing.state == State.COMPLETED:
284
+ return existing
285
+ if existing is None:
286
+ # Released/reclaimed; the caller should retry the claim.
287
+ return None
288
+ remaining = deadline - time.monotonic()
289
+ if remaining <= 0:
290
+ return None
291
+ event.clear()
292
+ try:
293
+ await asyncio.wait_for(event.wait(), timeout=min(poll, remaining))
294
+ except asyncio.TimeoutError:
295
+ pass
296
+ poll = min(poll * 2, _POLL_CAP_SECONDS)
297
+
298
+ def _signal_waiters_unlocked(self, effective_key: str) -> None:
299
+ """Wake everyone waiting on this key. Caller holds self._lock."""
300
+ event = self._waiters.pop(effective_key, None)
301
+ if event is not None:
302
+ event.set()
303
+
304
+ def _gc_expired_unlocked(self) -> None:
305
+ """Lazy GC: drop expired CLAIMED and COMPLETED records.
306
+
307
+ Caller holds self._lock. Runs on every claim attempt; cheap because
308
+ it's a single dict scan keyed on the small live set.
309
+ """
310
+ now = self._clock()
311
+ expired: list[str] = []
312
+ for key, record in self._records.items():
313
+ if record.state == State.CLAIMED and record.lease_until < now:
314
+ # CLAIMED lease expired — eligible for reclaim, but until a
315
+ # new claim comes in we keep the record (claim() handles it).
316
+ # We don't proactively delete here because doing so would lose
317
+ # the claim_token used to reject a delayed completion from
318
+ # the original owner. The reclaim path in claim() handles
319
+ # the swap.
320
+ continue
321
+ if record.state == State.COMPLETED and record.lease_until < now:
322
+ expired.append(key)
323
+ for key in expired:
324
+ del self._records[key]
325
+ self._waiters.pop(key, None)