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
idemkit/adapters/ai.py ADDED
@@ -0,0 +1,723 @@
1
+ """Method-level idempotency for keyless callers, per spec §8.
2
+
3
+ ``idempotent`` wraps one side-effectful function so a caller that re-invokes it
4
+ with the same arguments runs the real side effect once and replays the cached
5
+ result. It is a drop-in decorator, not a workflow engine (the §8.1 line idemkit
6
+ will not cross). The callers it is for are the ones that *don't supply an
7
+ idempotency key*:
8
+
9
+ * an LLM agent emitting the same tool call after a retry, a re-planning loop, or
10
+ in two parallel branches (the leading case; OpenAI / Anthropic / MCP) — and a
11
+ provider's per-turn ``tool_call_id`` is the wrong key because it changes every
12
+ turn;
13
+ * a background job / cron / Celery task that can run again after a failure;
14
+ * an internal call (another service, a worker, a CLI) with no idempotency
15
+ convention.
16
+
17
+ Because there is no caller-supplied key, the operation's identity is its
18
+ **arguments**, and the decorator is loud about getting that right (§8.2):
19
+
20
+ * **``key_fields=[...]`` is the default and the right choice.** Name the argument
21
+ fields that define "the same operation". Volatile fields you never list
22
+ (``request_id`` and friends) simply don't affect the key — cleaner than a
23
+ deny-list.
24
+ * **An explicit ``idempotency_key=...`` at call time overrides ``key_fields``** and
25
+ is authoritative — but reserve it for a value that is STABLE across retries (an
26
+ order id), never a per-turn provider id. idemkit warns when a volatile-looking
27
+ value lands in the key, by any path.
28
+ * **Deriving from all args is the fallback**, with a strict-mode rail: a
29
+ volatile-looking field in the key triggers a warning, because mis-keying here
30
+ silently causes a duplicate side effect or a lost call.
31
+
32
+ Deriving a key from arguments asserts an intent the caller never stated — two
33
+ genuinely-distinct calls with identical arguments collapse into one — so name
34
+ ``key_fields`` deliberately (§8.2 #1).
35
+
36
+ Side-effect classification is the caller's: wrap a side-effectful function, leave a
37
+ pure one undecorated (§8.2 #4). idemkit caches the FIRST result for a key; it does
38
+ not make a nondeterministic function deterministic (§8.2 #6).
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import asyncio
44
+ import functools
45
+ import hashlib
46
+ import inspect
47
+ import json
48
+ import logging
49
+ import re
50
+ import typing
51
+ import unicodedata
52
+ import warnings
53
+ from collections.abc import Awaitable, Callable, Mapping
54
+ from typing import Any, Literal
55
+
56
+ from idemkit.backends.base import IdempotencyBackend
57
+ from idemkit.core.codecs import (
58
+ CustomResultCodec,
59
+ DataclassResultCodec,
60
+ JsonResultCodec,
61
+ PickleResultCodec,
62
+ PydanticResultCodec,
63
+ ResultCodec,
64
+ )
65
+ from idemkit.core.events import EventEmitter, EventHandler
66
+ from idemkit.core.exception_cache import (
67
+ encode_exception,
68
+ is_cached_exception,
69
+ rebuild_exception,
70
+ )
71
+ from idemkit.core.exceptions import (
72
+ ConfigurationError,
73
+ IdempotencyConflict,
74
+ IdempotencyKeyMissing,
75
+ PayloadMismatch,
76
+ StorageUnavailable,
77
+ )
78
+ from idemkit.core.fingerprint import compose_key
79
+ from idemkit.core.policy import MethodConfig
80
+ from idemkit.core.runner import CoreOutcome, IdempotentCore
81
+ from idemkit.core.sync_bridge import register_closable, run_sync
82
+
83
+ _logger = logging.getLogger(__name__)
84
+
85
+ # A tool call's identity is its (tool, version, args/explicit-key, caller); the
86
+ # fingerprint plays no role on this surface, so a constant value means the core's
87
+ # fingerprint check never trips (mirrors the queue surface, §5.1).
88
+ AI_FINGERPRINT = "idemkit-ai-tool-v1"
89
+
90
+ # Single-tenant fallback when no scope is configured (warned once at
91
+ # decoration). A configured extractor that then yields nothing fails closed.
92
+ _ANON_IDENTITY = "_idemkit_ai_anonymous"
93
+
94
+ # Either arg-derived — ``lambda args: args["session_id"]`` — or ambient (zero-arg,
95
+ # reads a contextvar). The arity is detected at decoration; see _caller_identity.
96
+ CallerIdentity = Callable[..., "str | None"]
97
+ NormalizeArgs = Callable[[Mapping[str, Any]], Mapping[str, Any]]
98
+ # Validation fingerprint (the method-surface analogue of HTTP's body_fingerprint,
99
+ # §5.1): bound arguments -> bytes that must MATCH on a key hit. A different result
100
+ # for the same key is a PayloadMismatch, not a wrong replay. The bytes are not part
101
+ # of the key, so a mismatch errors rather than creating a new dedupe bucket.
102
+ ValidationFingerprint = Callable[[Mapping[str, Any]], bytes]
103
+
104
+
105
+ def _resolve_method_options(config: MethodConfig | None) -> dict[str, Any]:
106
+ """Read every option from the ``MethodConfig`` (or its defaults)."""
107
+ c = config or MethodConfig()
108
+ return {
109
+ "version": c.version,
110
+ "scope": c.scope,
111
+ "key_fields": c.key_fields,
112
+ "validation_fingerprint": c.validation_fingerprint,
113
+ "normalize_args": c.normalize_args,
114
+ "result_codec": c.result_codec,
115
+ "strict_keys": c.strict_keys,
116
+ "require_key": c.require_key,
117
+ "cache_exceptions": c.cache_exceptions,
118
+ "lease_ttl_seconds": c.lease_ttl_seconds if c.lease_ttl_seconds is not None else 60.0,
119
+ "wait_timeout_seconds": (
120
+ c.wait_timeout_seconds if c.wait_timeout_seconds is not None else 10.0
121
+ ),
122
+ "on_storage_error": c.on_storage_error,
123
+ "expires_after_seconds": c.expires_after_seconds,
124
+ "use_local_cache": c.use_local_cache,
125
+ "local_cache_max_items": c.local_cache_max_items,
126
+ "event_handlers": list(c.event_handlers),
127
+ }
128
+
129
+
130
+ def idempotent(
131
+ *,
132
+ backend: IdempotencyBackend,
133
+ config: MethodConfig | None = None,
134
+ ) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
135
+ """Decorate a side-effectful async function so identical calls dedupe (§8.3).
136
+
137
+ All options are plain keywords. ``version`` invalidates cached results when the
138
+ function's behavior changes. ``result_codec`` is ``"json"`` (default),
139
+ ``"dataclass"`` / ``"pydantic"``, a ``(to_dict, from_dict)`` pair, or any
140
+ ``ResultCodec``. ``lease_ttl_seconds`` is renewed while the function runs (§5.3.1).
141
+ """
142
+
143
+ opts = _resolve_method_options(config)
144
+
145
+ def decorator(
146
+ fn: Callable[..., Awaitable[Any]],
147
+ ) -> Callable[..., Awaitable[Any]]:
148
+ engine = _ToolEngine(fn, backend=backend, **opts)
149
+
150
+ @functools.wraps(fn)
151
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
152
+ return await engine.call(args, kwargs)
153
+
154
+ # Exposed for tests that set up a record by hand (spec §9.4 testability).
155
+ wrapper.idemkit_engine = engine # type: ignore[attr-defined]
156
+ return wrapper
157
+
158
+ return decorator
159
+
160
+
161
+ def idempotent_sync(
162
+ *,
163
+ backend: IdempotencyBackend,
164
+ config: MethodConfig | None = None,
165
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
166
+ """Synchronous variant of :func:`idempotent` for non-async codebases.
167
+
168
+ Decorate a **synchronous** side-effectful function (a Celery task, a
169
+ Flask/Django view helper, a plain script) and call it like a normal function —
170
+ no event loop required. The same dedup core runs on a shared background loop;
171
+ the body runs on a worker thread. Every option matches ``idempotent``.
172
+
173
+ Do not call the returned function from inside a running event loop — use the
174
+ async :func:`idempotent` there instead.
175
+ """
176
+
177
+ opts = _resolve_method_options(config)
178
+
179
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
180
+ engine = _ToolEngine(fn, backend=backend, runs_blocking=True, **opts)
181
+ register_closable(backend)
182
+
183
+ @functools.wraps(fn)
184
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
185
+ return run_sync(engine.call(args, kwargs))
186
+
187
+ wrapper.idemkit_engine = engine # type: ignore[attr-defined]
188
+ return wrapper
189
+
190
+ return decorator
191
+
192
+
193
+ class _ToolEngine:
194
+ """Per-decorated-function state and the call flow."""
195
+
196
+ def __init__(
197
+ self,
198
+ fn: Callable[..., Awaitable[Any]],
199
+ *,
200
+ backend: IdempotencyBackend,
201
+ version: str,
202
+ scope: CallerIdentity | None,
203
+ key_fields: list[str] | None,
204
+ validation_fingerprint: ValidationFingerprint | None,
205
+ normalize_args: NormalizeArgs | None,
206
+ result_codec: Any,
207
+ lease_ttl_seconds: float,
208
+ wait_timeout_seconds: float,
209
+ on_storage_error: Literal["fail_closed", "fail_open"],
210
+ strict_keys: bool,
211
+ require_key: bool,
212
+ cache_exceptions: tuple[type[BaseException], ...],
213
+ expires_after_seconds: float,
214
+ use_local_cache: bool,
215
+ local_cache_max_items: int,
216
+ event_handlers: list[EventHandler] | None,
217
+ runs_blocking: bool = False,
218
+ ) -> None:
219
+ self.fn = fn
220
+ self.tool_name = getattr(fn, "__name__", "tool")
221
+ self.version = str(version)
222
+ self._runs_blocking = runs_blocking
223
+ is_async = inspect.iscoroutinefunction(fn)
224
+ if not runs_blocking and not is_async:
225
+ # The async surface awaits the tool and the wrapper is itself an
226
+ # `async def`. A plain `def` would be wrapped in a coroutine the caller
227
+ # never awaits — the side effect would silently never run (review
228
+ # P2-1). Fail loudly; point at the sync decorator and the to_thread
229
+ # escape hatch.
230
+ raise ConfigurationError(
231
+ f"idemkit: idempotent requires an 'async def' tool, but "
232
+ f"{self.tool_name!r} is a regular (synchronous) function. For a "
233
+ "synchronous codebase use idempotent_sync; inside an async "
234
+ "tool wrap the blocking call: "
235
+ "`async def tool(...): return await asyncio.to_thread(blocking, ...)`."
236
+ )
237
+ if runs_blocking and is_async:
238
+ # The sync facade runs the tool on a worker thread, which would not
239
+ # await a coroutine. An async tool belongs on the async decorator.
240
+ raise ConfigurationError(
241
+ f"idemkit: idempotent_sync wraps a synchronous tool, but "
242
+ f"{self.tool_name!r} is 'async def'. Use idempotent for it."
243
+ )
244
+ self.signature = inspect.signature(fn)
245
+ if "idempotency_key" in self.signature.parameters:
246
+ # The call-time `idempotency_key=` kwarg is reserved for the explicit
247
+ # key (§8.2); a tool parameter of the same name would be silently
248
+ # hijacked. Fail loudly at decoration rather than at the first call.
249
+ raise ConfigurationError(
250
+ f"idemkit: tool {self.tool_name!r} declares a parameter named "
251
+ "'idempotency_key', which idempotent reserves for the "
252
+ "explicit key. Rename the tool's parameter."
253
+ )
254
+ if key_fields is not None:
255
+ # A key field that isn't an actual parameter is almost always a typo,
256
+ # and it is the WORST kind: every call hashes that field to None, so
257
+ # distinct operations silently collapse to one key and replay each
258
+ # other's result. Catch it at decoration unless the tool takes
259
+ # **kwargs (where we genuinely can't know the field set).
260
+ params = self.signature.parameters
261
+ takes_var_kw = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
262
+ unknown = [f for f in key_fields if f.split(".", 1)[0] not in params]
263
+ if unknown and not takes_var_kw:
264
+ raise ConfigurationError(
265
+ f"idemkit: key_fields {unknown} are not parameters of tool "
266
+ f"{self.tool_name!r} (its parameters are {list(params)}). This "
267
+ "is almost certainly a typo — it would silently collapse "
268
+ "distinct calls onto one key. Fix the names, or use an "
269
+ "explicit idempotency_key / normalize_args."
270
+ )
271
+ if strict_keys:
272
+ # A volatile-looking field named *in* key_fields is the same
273
+ # footgun as one slipping in via the derive-from-all-args path
274
+ # (review P2-2): retries that carry a new value won't dedupe. We
275
+ # only have field names at decoration time, so warn by name here
276
+ # (the value-based check still runs per call on the no-selector
277
+ # path). Listing it deliberately is legitimate, hence a warning,
278
+ # not an error.
279
+ volatile = [f for f in key_fields if _name_looks_volatile(f.rsplit(".", 1)[-1])]
280
+ if volatile:
281
+ warnings.warn(
282
+ f"idemkit: tool {self.tool_name!r} lists volatile-looking "
283
+ f"field(s) {volatile} in key_fields. If the value changes "
284
+ "between an honest retry and its original call, the two "
285
+ "WON'T dedupe. Use the stable fields only, or pass an "
286
+ "explicit idempotency_key when that field IS the key. (§8.2)",
287
+ stacklevel=4,
288
+ )
289
+ self.scope = scope
290
+ # scope may be either ambient (zero-arg, e.g. reads a
291
+ # contextvars.ContextVar) or arg-derived (one positional, receives the
292
+ # bound arguments). Detect which so the ambient form doesn't force a
293
+ # scope field like ``session_id`` into the tool's LLM-visible signature
294
+ # (review C-2). Default to passing the args when arity can't be read.
295
+ self._identity_wants_args = _accepts_one_positional(scope)
296
+ self.key_fields = key_fields
297
+ self.validation_fingerprint = validation_fingerprint
298
+ self.normalize_args = normalize_args
299
+ self.strict_keys = strict_keys
300
+ self.require_key = require_key
301
+ self.cache_exceptions = cache_exceptions
302
+ self._warned_volatile: set[str] = set()
303
+ self._warned_volatile_explicit = False
304
+ self.codec: ResultCodec[Any, Any] = _resolve_codec(
305
+ result_codec, _resolve_return_type(fn, self.signature)
306
+ )
307
+ self.core = IdempotentCore(
308
+ backend,
309
+ emitter=EventEmitter(event_handlers or []),
310
+ backend_name=type(backend).__name__,
311
+ surface="ai_tool",
312
+ on_storage_error=on_storage_error,
313
+ lease_ttl_seconds=lease_ttl_seconds,
314
+ wait_timeout_seconds=wait_timeout_seconds,
315
+ expires_after_seconds=expires_after_seconds,
316
+ use_local_cache=use_local_cache,
317
+ local_cache_max_items=local_cache_max_items,
318
+ )
319
+
320
+ if scope is None:
321
+ warnings.warn(
322
+ f"idemkit: tool {self.tool_name!r} has no scope, so all "
323
+ "callers share one idempotency namespace (single-tenant). For "
324
+ "per-agent / per-session scope, pass scope. See §5.1.",
325
+ stacklevel=4,
326
+ )
327
+
328
+ def effective_key(self, *args: Any, **kwargs: Any) -> str:
329
+ """Compute the effective key for a hypothetical call — for tests."""
330
+ explicit_key = kwargs.pop("idempotency_key", None)
331
+ arguments = self._bind(args, kwargs)
332
+ caller_id = self._caller_identity(arguments)
333
+ return self._effective_key(explicit_key, arguments, caller_id)
334
+
335
+ async def call(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
336
+ explicit_key = kwargs.pop("idempotency_key", None)
337
+ arguments = self._bind(args, kwargs)
338
+ caller_id = self._caller_identity(arguments)
339
+ effective_key = self._effective_key(explicit_key, arguments, caller_id)
340
+
341
+ decision = await self.core.decide(effective_key, self._fingerprint(arguments))
342
+ outcome = decision.outcome
343
+
344
+ if outcome is CoreOutcome.REPLAY:
345
+ assert decision.stored is not None
346
+ if is_cached_exception(decision.stored):
347
+ # A prior run failed with a declared-deterministic exception; the
348
+ # duplicate re-raises it instead of re-running the side effect.
349
+ raise rebuild_exception(decision.stored)
350
+ return self.codec.decode(decision.stored)
351
+ if outcome is CoreOutcome.MISMATCH:
352
+ # Only reachable if the same key was stored under a different
353
+ # fingerprint, which the constant AI fingerprint precludes — guard
354
+ # anyway rather than replay the wrong result.
355
+ raise PayloadMismatch(
356
+ f"idemkit: tool {self.tool_name!r} key collided with a different "
357
+ "stored payload; refusing to replay."
358
+ )
359
+ if outcome is CoreOutcome.CONFLICT:
360
+ raise IdempotencyConflict(
361
+ f"idemkit: tool {self.tool_name!r} is already in progress for this "
362
+ f"key ({effective_key[:12]}…) and did not finish within the wait "
363
+ "timeout. Retry with bounded backoff; do not loop forever."
364
+ )
365
+ if outcome is CoreOutcome.STORAGE_REFUSE:
366
+ raise StorageUnavailable(
367
+ f"idemkit: storage unavailable for tool {self.tool_name!r} and "
368
+ "on_storage_error=fail_closed. Retry after a short backoff."
369
+ )
370
+
371
+ token = decision.claim_token
372
+ unprotected = outcome is CoreOutcome.STORAGE_PASS
373
+
374
+ async def raw() -> Any:
375
+ if self._runs_blocking:
376
+ # Synchronous tool: run it on a worker thread so it doesn't block
377
+ # the event loop (and so the heartbeat can renew the lease while
378
+ # it runs).
379
+ return await asyncio.to_thread(self.fn, *args, **kwargs)
380
+ return await self.fn(*args, **kwargs)
381
+
382
+ if token is None or unprotected:
383
+ # fail_open: storage is down, so run this once without dedup.
384
+ return await raw()
385
+
386
+ try:
387
+ value, lease_lost = await self.core.execute_with_heartbeat(effective_key, token, raw)
388
+ except BaseException as exc:
389
+ if self.cache_exceptions and isinstance(exc, self.cache_exceptions):
390
+ # A declared-deterministic failure: cache it and replay it on a
391
+ # duplicate, instead of releasing (which would re-run the tool).
392
+ try:
393
+ await self.core.complete(effective_key, token, encode_exception(exc))
394
+ except Exception:
395
+ # Couldn't record it — fall back to release so a retry re-runs
396
+ # rather than wedging the key (the lease is the safety net).
397
+ await self.core.release(effective_key, token)
398
+ raise
399
+ # The tool itself raised (or was cancelled). Release the claim so a
400
+ # retry re-runs it once (spec §8.4 tool-crash-recovery). This is the
401
+ # crash path, distinct from the unserializable-result path below
402
+ # which deliberately keeps the claim.
403
+ await self.core.release(effective_key, token)
404
+ raise
405
+ if lease_lost:
406
+ # The lease lapsed mid-call (renewal couldn't be confirmed) and the
407
+ # handler was cancelled. Surface it; a retry re-runs the tool once.
408
+ raise IdempotencyConflict(
409
+ f"idemkit: tool {self.tool_name!r} lost its lease mid-call "
410
+ "(renewal failed) and was cancelled. Retry — it will re-run."
411
+ )
412
+
413
+ try:
414
+ stored = self.codec.encode(value)
415
+ except Exception:
416
+ # Fail closed (§5.4): the result can't be serialized. Do NOT cache,
417
+ # and do NOT release the claim — releasing would let an immediate
418
+ # retry re-run the side effect. Surface the error so the operator
419
+ # fixes the codec; the lease expires on its own as the safety net.
420
+ _logger.error(
421
+ "idemkit: tool %r produced a result the codec could not serialize. "
422
+ "NOT caching and NOT releasing the claim, so a retry won't silently "
423
+ "re-run the side effect. Fix result_codec or return a serializable "
424
+ "value (§5.4).",
425
+ self.tool_name,
426
+ )
427
+ raise
428
+
429
+ await self.core.complete(effective_key, token, stored)
430
+ return value
431
+
432
+ # ----- key derivation -----
433
+
434
+ def _bind(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
435
+ bound = self.signature.bind(*args, **kwargs)
436
+ bound.apply_defaults()
437
+ return dict(bound.arguments)
438
+
439
+ def _caller_identity(self, arguments: Mapping[str, Any]) -> str:
440
+ if self.scope is None:
441
+ return _ANON_IDENTITY
442
+ try:
443
+ # Ambient identity (zero-arg, e.g. reads a contextvar) is called with
444
+ # no arguments; the arg-derived form receives the bound arguments.
445
+ value = self.scope(arguments) if self._identity_wants_args else self.scope()
446
+ except Exception as e:
447
+ # The extractor receives the bound arguments as a dict; the most
448
+ # common failure is indexing a key that isn't a parameter (a typo).
449
+ # Turn the raw KeyError/AttributeError into an actionable message.
450
+ raise ConfigurationError(
451
+ f"idemkit: scope for tool {self.tool_name!r} raised "
452
+ f"{type(e).__name__}: {e}. It receives the call's bound arguments "
453
+ f"as a dict (available keys: {list(arguments)}); select a field by "
454
+ "name, e.g. lambda args: args['session_id']. For ambient scope, "
455
+ "use a zero-arg callable that reads a contextvars.ContextVar."
456
+ ) from e
457
+ if not value:
458
+ # A configured extractor that yields nothing is a runtime ambiguity,
459
+ # not a declared single-tenant choice — fail closed (§5.1).
460
+ raise ConfigurationError(
461
+ f"idemkit: scope for tool {self.tool_name!r} returned an "
462
+ "empty value. Return a stable, non-empty identity, or drop "
463
+ "scope to run single-tenant."
464
+ )
465
+ return str(value)
466
+
467
+ def _fingerprint(self, arguments: Mapping[str, Any]) -> str:
468
+ """The payload fingerprint compared on a key hit (spec §5.1). With no
469
+ ``validation_fingerprint`` it is a constant, so the core's check never
470
+ trips; with it, a key hit whose fingerprint bytes differ is a payload
471
+ mismatch (PayloadMismatch), not a wrong replay."""
472
+ if self.validation_fingerprint is None:
473
+ return AI_FINGERPRINT
474
+ return hashlib.sha256(self.validation_fingerprint(arguments)).hexdigest()
475
+
476
+ def _effective_key(
477
+ self,
478
+ explicit_key: Any,
479
+ arguments: Mapping[str, Any],
480
+ caller_id: str,
481
+ ) -> str:
482
+ if explicit_key is not None:
483
+ key_component = str(explicit_key)
484
+ self._warn_on_volatile_explicit(key_component)
485
+ else:
486
+ key_component = self._derive_args_hash(arguments)
487
+ return compose_key([self.tool_name, self.version, key_component, caller_id])
488
+
489
+ def _warn_on_volatile_explicit(self, key_value: str) -> None:
490
+ # The single sharpest AI-surface footgun (review C-1): a provider's
491
+ # per-turn tool_call_id (``call_…``, ``toolu_…``, ``run_…``, ``fc_…``) or
492
+ # a bare UUID looks like the right "explicit key" — but OpenAI/Anthropic
493
+ # mint a NEW one each turn, so using it makes an honest retry MISS the
494
+ # dedupe and run the side effect twice. The stable key for a tool call is
495
+ # its arguments (key_fields), not the call id. Warn once.
496
+ if not self.strict_keys or self._warned_volatile_explicit:
497
+ return
498
+ if _value_looks_like_call_id(key_value):
499
+ self._warned_volatile_explicit = True
500
+ warnings.warn(
501
+ f"idemkit: tool {self.tool_name!r} got an explicit idempotency_key "
502
+ f"({key_value[:16]}…) that looks like a per-turn provider call id or "
503
+ "a UUID. These change on every retry/re-plan, so identical calls "
504
+ "WON'T dedupe and the side effect runs again. Dedupe on the call's "
505
+ "arguments with key_fields=[...] instead; reserve idempotency_key "
506
+ "for a value that is STABLE across retries of the same operation. (§8.2)",
507
+ stacklevel=2,
508
+ )
509
+
510
+ def _derive_args_hash(self, arguments: Mapping[str, Any]) -> str:
511
+ if self.key_fields is not None:
512
+ # A field may be a dotted path into a nested dict or object
513
+ # (e.g. "order.id", "customer.address.zip"), resolved against both
514
+ # Mapping keys and object attributes.
515
+ selected: Mapping[str, Any] = {
516
+ field: _resolve_path(arguments, field) for field in self.key_fields
517
+ }
518
+ elif self.normalize_args is not None:
519
+ selected = self.normalize_args(arguments)
520
+ else:
521
+ if self.require_key:
522
+ # The caller opted into strict enforcement: refuse the
523
+ # derive-from-all-arguments fallback instead of warning.
524
+ raise IdempotencyKeyMissing(
525
+ f"idemkit: tool {self.tool_name!r} has require_key=True but no key "
526
+ "strategy for this call. Set key_fields=[...] (or normalize_args), "
527
+ "or pass an explicit idempotency_key. idemkit refuses to derive the "
528
+ "key from all arguments when require_key is on."
529
+ )
530
+ selected = dict(arguments)
531
+ self._warn_on_volatile(selected)
532
+ return _canonical_hash(selected)
533
+
534
+ def _warn_on_volatile(self, selected: Mapping[str, Any]) -> None:
535
+ if not self.strict_keys:
536
+ return
537
+ for name, value in selected.items():
538
+ if name in self._warned_volatile:
539
+ continue
540
+ if _looks_volatile(name, value):
541
+ self._warned_volatile.add(name)
542
+ warnings.warn(
543
+ f"idemkit: tool {self.tool_name!r} is deriving its key from "
544
+ f"all arguments, and {name!r} looks volatile (an id, "
545
+ "timestamp, nonce, or UUID/ISO-8601 value). A volatile field "
546
+ "in the key means retries with a new value DON'T dedupe. Name "
547
+ "the stable fields with key_fields=[...], or pass an explicit "
548
+ "idempotency_key. (§8.2)",
549
+ stacklevel=2,
550
+ )
551
+
552
+
553
+ def _resolve_codec(spec: Any, return_annotation: Any) -> ResultCodec[Any, Any]:
554
+ if spec is None or spec == "json":
555
+ return JsonResultCodec()
556
+ if spec == "dataclass":
557
+ return DataclassResultCodec(_require_annotation(return_annotation, "dataclass"))
558
+ if spec == "pydantic":
559
+ return PydanticResultCodec(_require_annotation(return_annotation, "pydantic"))
560
+ if spec == "pickle":
561
+ return PickleResultCodec()
562
+ if isinstance(spec, str):
563
+ raise ConfigurationError(
564
+ f"idemkit: unknown result_codec {spec!r}. Use 'json', 'dataclass', "
565
+ "'pydantic', 'pickle', a (to_dict, from_dict) tuple, or a ResultCodec."
566
+ )
567
+ if isinstance(spec, tuple) and len(spec) == 2:
568
+ return CustomResultCodec(spec[0], spec[1])
569
+ # Assume a ResultCodec instance (duck-typed: has encode/decode).
570
+ if hasattr(spec, "encode") and hasattr(spec, "decode"):
571
+ return spec # type: ignore[no-any-return]
572
+ raise ConfigurationError(f"idemkit: result_codec {spec!r} is not a recognized codec spec.")
573
+
574
+
575
+ def _resolve_return_type(fn: Callable[..., Awaitable[Any]], signature: inspect.Signature) -> Any:
576
+ """The decorated function's resolved return type, for the dataclass/pydantic codecs.
577
+
578
+ With ``from __future__ import annotations`` (PEP 563) every annotation is a
579
+ string, so ``get_type_hints`` is needed to turn it back into the class.
580
+ Falls back to the raw signature annotation when resolution fails (e.g. a
581
+ type defined in a local scope, where the string can't be looked up) so the
582
+ error message in ``_require_annotation`` stays useful.
583
+ """
584
+ try:
585
+ hints = typing.get_type_hints(fn)
586
+ except Exception:
587
+ return signature.return_annotation
588
+ return hints.get("return", signature.return_annotation)
589
+
590
+
591
+ def _require_annotation(return_annotation: Any, codec_name: str) -> type:
592
+ if return_annotation is inspect.Signature.empty:
593
+ raise ConfigurationError(
594
+ f"idemkit: result_codec={codec_name!r} infers the type from the tool's "
595
+ "return annotation, but the tool has none. Add a return annotation, or "
596
+ "use result_codec='json' / a custom codec."
597
+ )
598
+ if isinstance(return_annotation, str):
599
+ # get_type_hints couldn't turn the annotation string into a class —
600
+ # typically a type defined in a local scope (it can't be looked up), or
601
+ # `from __future__ import annotations` with a type that isn't importable
602
+ # at module level.
603
+ raise ConfigurationError(
604
+ f"idemkit: result_codec={codec_name!r} couldn't resolve the return "
605
+ f"type {return_annotation!r} to a class. Define the return type at "
606
+ "module level (not inside a function), or pass an explicit codec — a "
607
+ "ResultCodec instance, or a (to_dict, from_dict) pair."
608
+ )
609
+ return return_annotation # type: ignore[no-any-return]
610
+
611
+
612
+ def _resolve_path(arguments: Mapping[str, Any], path: str) -> Any:
613
+ """Resolve a (possibly dotted) key-field path against the bound arguments.
614
+
615
+ ``"order.id"`` walks ``arguments["order"]`` then its ``["id"]`` (on a Mapping)
616
+ or ``.id`` (on an object). A missing segment yields ``None`` (the same as a
617
+ missing top-level argument), so a typo collapses to a stable, visible value
618
+ rather than raising mid-call.
619
+ """
620
+ root, _, rest = path.partition(".")
621
+ value: Any = arguments.get(root)
622
+ if not rest:
623
+ return value
624
+ for segment in rest.split("."):
625
+ if value is None:
626
+ return None
627
+ value = value.get(segment) if isinstance(value, Mapping) else getattr(value, segment, None)
628
+ return value
629
+
630
+
631
+ def _nfc(value: Any) -> Any:
632
+ """Recursively NFC-normalize strings so canonically-equal text hashes equal
633
+ (``"café"`` typed as NFC vs NFD must dedupe). Non-strings pass through."""
634
+ if isinstance(value, str):
635
+ return unicodedata.normalize("NFC", value)
636
+ if isinstance(value, Mapping):
637
+ return {k: _nfc(v) for k, v in value.items()}
638
+ if isinstance(value, (list, tuple)):
639
+ return [_nfc(v) for v in value]
640
+ return value
641
+
642
+
643
+ def _canonical_hash(selected: Mapping[str, Any]) -> str:
644
+ """SHA-256 of the canonical JSON of the selected key fields (§8.2).
645
+
646
+ Strings are NFC-normalized so canonically-equal text hashes equal. Numbers keep
647
+ JSON's own repr, so an int and an equal float (``1`` vs ``1.0``) are DIFFERENT
648
+ keys: keep key-field types stable across retries. A value that isn't
649
+ JSON-serializable fails loudly rather than producing an unstable repr-based key.
650
+ """
651
+ try:
652
+ canonical = json.dumps(
653
+ _nfc(selected), sort_keys=True, separators=(",", ":"), ensure_ascii=False
654
+ )
655
+ except (TypeError, ValueError) as e:
656
+ raise ConfigurationError(
657
+ f"idemkit: key fields are not JSON-serializable ({e}). Select "
658
+ "serializable fields with key_fields=[...], pass an explicit "
659
+ "idempotency_key, or supply a normalize_args that returns scalars."
660
+ ) from e
661
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
662
+
663
+
664
+ _VOLATILE_NAMES = frozenset(
665
+ {"request_id", "requestid", "nonce", "timestamp", "ts", "idempotency_key"}
666
+ )
667
+ _UUID_RE = re.compile(
668
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
669
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
670
+ )
671
+ _ISO8601_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}")
672
+
673
+
674
+ def _accepts_one_positional(fn: Callable[..., Any] | None) -> bool:
675
+ """Whether ``fn`` takes the bound-arguments mapping (arg-derived identity) vs.
676
+ being a zero-arg ambient callable (e.g. reads a contextvar).
677
+
678
+ Defaults to ``True`` (pass the args) when the arity can't be introspected —
679
+ builtins, some ``functools.partial`` objects — preserving the existing
680
+ arg-derived behaviour.
681
+ """
682
+ if fn is None:
683
+ return True
684
+ try:
685
+ params = inspect.signature(fn).parameters.values()
686
+ except (TypeError, ValueError):
687
+ return True
688
+ return any(
689
+ p.kind
690
+ in (
691
+ inspect.Parameter.POSITIONAL_ONLY,
692
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
693
+ inspect.Parameter.VAR_POSITIONAL,
694
+ )
695
+ for p in params
696
+ )
697
+
698
+
699
+ # Provider tool-call id prefixes (OpenAI ``call_``/``fc_``, Anthropic ``toolu_``,
700
+ # agent frameworks ``run_``). These are minted per turn, so they are the wrong
701
+ # thing to use as an idempotency key — see _warn_on_volatile_explicit.
702
+ _CALL_ID_RE = re.compile(r"^(call_|toolu_|run_|fc_|msg_)", re.IGNORECASE)
703
+
704
+
705
+ def _value_looks_like_call_id(value: str) -> bool:
706
+ return bool(_CALL_ID_RE.match(value) or _UUID_RE.match(value))
707
+
708
+
709
+ def _name_looks_volatile(name: str) -> bool:
710
+ """Whether a field name alone (no value) reads as volatile.
711
+
712
+ Used at decoration time, where only the names in ``key_fields`` are known.
713
+ """
714
+ lowered = name.lower()
715
+ if lowered in _VOLATILE_NAMES or lowered.endswith("_id"):
716
+ return True
717
+ return "timestamp" in lowered or "nonce" in lowered
718
+
719
+
720
+ def _looks_volatile(name: str, value: Any) -> bool:
721
+ if _name_looks_volatile(name):
722
+ return True
723
+ return isinstance(value, str) and bool(_UUID_RE.match(value) or _ISO8601_RE.match(value))