replyonce 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.
replyonce/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """replyonce: stop your agent from replying, refunding or ticketing twice."""
2
+
3
+ from ._core import (
4
+ CheckResult,
5
+ EffectContext,
6
+ Key,
7
+ NotApplied,
8
+ OnceResult,
9
+ ReplyOnce,
10
+ ReplyOnceError,
11
+ Run,
12
+ Store,
13
+ default_classify_error,
14
+ unwrap,
15
+ )
16
+ from ._key import MISSING, canonical_json, effect_key, hash_payload, provider_idempotency_key
17
+ from .stores.memory import MemoryStore
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "CheckResult",
23
+ "EffectContext",
24
+ "Key",
25
+ "MISSING",
26
+ "MemoryStore",
27
+ "NotApplied",
28
+ "OnceResult",
29
+ "ReplyOnce",
30
+ "ReplyOnceError",
31
+ "Run",
32
+ "Store",
33
+ "canonical_json",
34
+ "default_classify_error",
35
+ "effect_key",
36
+ "hash_payload",
37
+ "provider_idempotency_key",
38
+ "unwrap",
39
+ ]
replyonce/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
replyonce/_core.py ADDED
@@ -0,0 +1,462 @@
1
+ """The replyonce API (asyncio). Mirrors the TypeScript package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import json
8
+ import time
9
+ import uuid
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Awaitable, Callable, Generic, Optional, Protocol, TypeVar, Union
12
+
13
+ from . import _logic as logic
14
+ from ._key import MISSING, effect_key, provider_idempotency_key
15
+
16
+ T = TypeVar("T")
17
+
18
+ MAX_ROUNDS = 5
19
+
20
+
21
+ class NotApplied(Exception):
22
+ """Raise this (or any exception with ``replyonce = "not_applied"``) when the effect definitely did not happen."""
23
+
24
+ replyonce = "not_applied"
25
+
26
+
27
+ def default_classify_error(err: BaseException) -> str:
28
+ if isinstance(err, NotApplied) or getattr(err, "replyonce", None) == "not_applied":
29
+ return "not_applied"
30
+ return "ambiguous"
31
+
32
+
33
+ class Store(Protocol):
34
+ async def transact(self, scope: str, fn: Callable[[Optional[dict]], tuple[Optional[dict], Any]]) -> Any: ...
35
+
36
+ async def read(self, scope: str) -> Optional[dict]: ...
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Key:
41
+ """A scope (usually the conversation), an intent, and the business identity of the effect."""
42
+
43
+ scope: str
44
+ intent: str
45
+ payload: Any = MISSING
46
+
47
+
48
+ KeyLike = Union[str, Key, dict]
49
+
50
+
51
+ @dataclass
52
+ class EffectContext:
53
+ key: str
54
+ scope: str
55
+ intent: str
56
+ attempt: int
57
+ idempotency_key: str
58
+ """Stable across attempts and workers. Pass it to providers that accept an idempotency key."""
59
+ fencing_token: Optional[int]
60
+ deadline_ms: int
61
+ """Epoch ms after which the claim is treated as abandoned. Finish (or give up) before it."""
62
+
63
+
64
+ @dataclass
65
+ class OnceResult(Generic[T]):
66
+ status: str
67
+ """executed, replayed, in_flight, ambiguous, failed or refused."""
68
+ ok: bool
69
+ key: str
70
+ value: Any = None
71
+ attempt: Optional[int] = None
72
+ entry: Optional[dict] = None
73
+ error: Optional[BaseException] = None
74
+ retryable: bool = False
75
+ reason: Optional[str] = None
76
+ conflicts: Optional[list] = None
77
+ late_settle: bool = False
78
+
79
+
80
+ @dataclass
81
+ class CheckResult:
82
+ done: bool
83
+ status: str
84
+ entry: Optional[dict]
85
+
86
+
87
+ class ReplyOnceError(Exception):
88
+ def __init__(self, result: OnceResult):
89
+ detail = f"{result.status}: {result.reason}" if result.status == "refused" else result.status
90
+ super().__init__(f"replyonce: effect {result.key} not executed ({detail})")
91
+ self.result = result
92
+
93
+
94
+ def unwrap(result: OnceResult[T]) -> T:
95
+ """Return the value of a successful result, or raise ``ReplyOnceError``."""
96
+ if result.ok:
97
+ return result.value
98
+ raise ReplyOnceError(result)
99
+
100
+
101
+ Effect = Callable[[EffectContext], Union[Awaitable[T], T]]
102
+ Reconciler = Callable[[dict], Awaitable[dict]]
103
+
104
+
105
+ def _now_ms() -> int:
106
+ return int(time.time() * 1000)
107
+
108
+
109
+ def _assert_scope(scope: Any) -> None:
110
+ if not isinstance(scope, str) or not scope or len(scope) > 512:
111
+ raise TypeError("replyonce: scope must be a non-empty string of at most 512 characters")
112
+
113
+
114
+ def _describe(err: BaseException) -> str:
115
+ return f"{type(err).__name__}: {err}"[:500]
116
+
117
+
118
+ class ReplyOnce:
119
+ def __init__(
120
+ self,
121
+ store: Store,
122
+ *,
123
+ pending_ttl_ms: int = 15 * 60_000,
124
+ max_attempts: int = 3,
125
+ retention_ms: int = 7 * 24 * 3_600_000,
126
+ lease_ttl_ms: int = 60_000,
127
+ classify_error: Optional[Callable[[BaseException], str]] = None,
128
+ reconcile: Optional[Reconciler] = None,
129
+ on_event: Optional[Callable[[dict], None]] = None,
130
+ clock: Optional[Callable[[], int]] = None,
131
+ ) -> None:
132
+ if store is None:
133
+ raise TypeError("replyonce: a store is required")
134
+ if pending_ttl_ms <= 0 or max_attempts < 1 or retention_ms <= 0:
135
+ raise ValueError("replyonce: pending_ttl_ms, max_attempts and retention_ms must be positive")
136
+ self.store = store
137
+ self.policy = logic.Policy(pending_ttl_ms, max_attempts, retention_ms)
138
+ self.lease_ttl_ms = lease_ttl_ms
139
+ self._classify = classify_error or default_classify_error
140
+ self._reconcile = reconcile
141
+ self._on_event = on_event
142
+ self._now = clock or _now_ms
143
+
144
+ # -------------------------------------------------------------- helpers
145
+
146
+ def _emit(self, event: dict) -> None:
147
+ if self._on_event is None:
148
+ return
149
+ try:
150
+ self._on_event(event)
151
+ except Exception:
152
+ pass # an observer must never change the outcome
153
+
154
+ def resolve_key(self, key: KeyLike) -> tuple[str, str, str]:
155
+ if isinstance(key, str):
156
+ _assert_scope(key)
157
+ return key, key, "once"
158
+ if isinstance(key, dict):
159
+ key = Key(key["scope"], key["intent"], key.get("payload", MISSING))
160
+ _assert_scope(key.scope)
161
+ return key.scope, effect_key(key.intent, key.payload), key.intent
162
+
163
+ def key(self, intent: str, payload: Any = MISSING) -> str:
164
+ return effect_key(intent, payload)
165
+
166
+ # -------------------------------------------------------------- public API
167
+
168
+ async def once(
169
+ self,
170
+ key: KeyLike,
171
+ effect: Effect,
172
+ *,
173
+ summary: Optional[str] = None,
174
+ classify_error: Optional[Callable[[BaseException], str]] = None,
175
+ reconcile: Optional[Reconciler] = None,
176
+ wait_ms: Optional[int] = None,
177
+ wait_interval_ms: int = 100,
178
+ ) -> OnceResult:
179
+ """Run ``effect`` at most once for this key."""
180
+ scope, ledger_key, intent = self.resolve_key(key)
181
+ return await self._execute(
182
+ scope, ledger_key, intent, None, effect,
183
+ summary=summary, classify_error=classify_error, reconcile=reconcile,
184
+ wait_ms=wait_ms, wait_interval_ms=wait_interval_ms, conflict="none", ack=False,
185
+ )
186
+
187
+ async def check(self, key: KeyLike) -> CheckResult:
188
+ """Was this already done? Read-only; safe to expose to a model."""
189
+ scope, ledger_key, _ = self.resolve_key(key)
190
+ state = await self.store.read(scope)
191
+ entry = (state or {}).get("effects", {}).get(ledger_key)
192
+ if entry is None:
193
+ return CheckResult(False, "none", None)
194
+ status = logic.effective_status(entry, self._now(), self.policy)
195
+ return CheckResult(status == "committed", status, entry)
196
+
197
+ async def history(self, scope: str) -> list[dict]:
198
+ state = await self.store.read(scope)
199
+ if not state:
200
+ return []
201
+ now = self._now()
202
+ out = [{**e, "status": logic.effective_status(e, now, self.policy)} for e in state["effects"].values()]
203
+ return sorted(out, key=lambda e: e["createdAt"])
204
+
205
+ async def resolve(self, key: KeyLike, outcome: str, result: Any = logic._UNSET) -> Optional[dict]:
206
+ """Record what an operator or reconciler found out about an ambiguous effect."""
207
+ if outcome not in ("committed", "not_applied"):
208
+ raise ValueError("outcome must be 'committed' or 'not_applied'")
209
+ scope, ledger_key, _ = self.resolve_key(key)
210
+ now = self._now()
211
+ entry = await self.store.transact(
212
+ scope, lambda s: logic.resolve(s, key=ledger_key, outcome=outcome, now=now, policy=self.policy, result=result)
213
+ )
214
+ if entry is not None:
215
+ self._emit({"type": "reconciled", "scope": scope, "key": ledger_key, "outcome": outcome})
216
+ return entry
217
+
218
+ async def begin(
219
+ self,
220
+ scope: str,
221
+ *,
222
+ run_id: Optional[str] = None,
223
+ mode: str = "supersede",
224
+ lease_ttl_ms: Optional[int] = None,
225
+ context_at: Optional[int] = None,
226
+ ) -> "Run":
227
+ """Start (or, with the same ``run_id``, resume) a unit of work on a scope."""
228
+ _assert_scope(scope)
229
+ if mode not in ("supersede", "exclusive"):
230
+ raise ValueError("mode must be 'supersede' or 'exclusive'")
231
+ rid = run_id or str(uuid.uuid4())
232
+ ttl = lease_ttl_ms if lease_ttl_ms is not None else self.lease_ttl_ms
233
+ now = self._now()
234
+ outcome = await self.store.transact(
235
+ scope,
236
+ lambda s: logic.begin(s, run_id=rid, mode=mode, lease_ttl_ms=ttl, now=now, context_at=context_at, policy=self.policy),
237
+ )
238
+ return Run(self, scope, rid, ttl, outcome)
239
+
240
+ async def cancel(self, scope: str, run_id: str) -> bool:
241
+ now = self._now()
242
+ r = await self.store.transact(scope, lambda s: logic.end_run(s, run_id=run_id, reason="cancelled", now=now, policy=self.policy))
243
+ return r is not None
244
+
245
+ # -------------------------------------------------------------- internals
246
+
247
+ async def _renew(self, scope: str, run_id: str, token: int, ttl: int) -> bool:
248
+ now = self._now()
249
+ r = await self.store.transact(
250
+ scope, lambda s: logic.renew(s, run_id=run_id, token=token, lease_ttl_ms=ttl, now=now, policy=self.policy)
251
+ )
252
+ return r["ok"]
253
+
254
+ async def _end(self, scope: str, run_id: str, token: int, reason: str) -> None:
255
+ now = self._now()
256
+ await self.store.transact(
257
+ scope, lambda s: logic.end_run(s, run_id=run_id, reason=reason, now=now, policy=self.policy, token=token)
258
+ )
259
+
260
+ async def _execute(
261
+ self,
262
+ scope: str,
263
+ key: str,
264
+ intent: str,
265
+ run: Optional[tuple[str, int, int]],
266
+ effect: Effect,
267
+ *,
268
+ summary: Optional[str],
269
+ classify_error,
270
+ reconcile,
271
+ wait_ms: Optional[int],
272
+ wait_interval_ms: int,
273
+ conflict: str,
274
+ ack: bool,
275
+ ) -> OnceResult:
276
+ run_id, token, ttl = run if run else (None, None, self.lease_ttl_ms)
277
+ classify = classify_error or self._classify
278
+ reconciler = reconcile or self._reconcile
279
+ wait_until = self._now() + wait_ms if wait_ms else 0
280
+ reconciled = False
281
+
282
+ for _ in range(MAX_ROUNDS):
283
+ attempt_id = str(uuid.uuid4())
284
+ now = self._now()
285
+ claimed = await self.store.transact(
286
+ scope,
287
+ lambda s: logic.claim(
288
+ s, scope, key=key, intent=intent, summary=summary, run_id=run_id, token=token,
289
+ attempt_id=attempt_id, conflict=conflict, ack=ack, lease_ttl_ms=ttl, now=now, policy=self.policy,
290
+ ),
291
+ )
292
+ kind = claimed["kind"]
293
+ if kind == "replay":
294
+ self._emit({"type": "replayed", "scope": scope, "key": key})
295
+ return OnceResult("replayed", True, key, value=claimed["entry"].get("result"), entry=claimed["entry"])
296
+ if kind == "refused":
297
+ self._emit({"type": "refused", "scope": scope, "key": key, "reason": claimed["reason"], "runId": run_id})
298
+ return OnceResult(
299
+ "refused", False, key, reason=claimed["reason"],
300
+ conflicts=claimed.get("conflicts"), entry=claimed.get("entry"),
301
+ )
302
+ if kind == "in_flight":
303
+ if wait_ms and self._now() < wait_until:
304
+ await self._wait_for_settle(scope, key, wait_until, wait_interval_ms)
305
+ continue
306
+ self._emit({"type": "in_flight", "scope": scope, "key": key})
307
+ return OnceResult("in_flight", False, key, entry=claimed["entry"])
308
+ if kind == "ambiguous":
309
+ if reconciler is not None and not reconciled:
310
+ reconciled = True
311
+ try:
312
+ verdict = await reconciler(claimed["entry"])
313
+ except Exception:
314
+ verdict = {"outcome": "unknown"}
315
+ outcome = (verdict or {}).get("outcome", "unknown")
316
+ if outcome in ("committed", "not_applied"):
317
+ at = self._now()
318
+ res = verdict.get("result", logic._UNSET) if outcome == "committed" else logic._UNSET
319
+ resolved = await self.store.transact(
320
+ scope,
321
+ lambda s: logic.resolve(
322
+ s, key=key, outcome=outcome, now=at, policy=self.policy, result=res,
323
+ attempt_id=claimed["entry"]["attemptId"],
324
+ ),
325
+ )
326
+ if resolved is not None:
327
+ self._emit({"type": "reconciled", "scope": scope, "key": key, "outcome": outcome})
328
+ continue
329
+ self._emit({"type": "ambiguous", "scope": scope, "key": key})
330
+ return OnceResult("ambiguous", False, key, entry=claimed["entry"])
331
+ return await self._run_acquired(scope, key, claimed["entry"], effect, classify)
332
+
333
+ state = await self.store.read(scope)
334
+ entry = (state or {}).get("effects", {}).get(key)
335
+ if entry and entry["status"] == "committed":
336
+ return OnceResult("replayed", True, key, value=entry.get("result"), entry=entry)
337
+ if entry:
338
+ return OnceResult("in_flight", False, key, entry=entry)
339
+ raise RuntimeError("replyonce: could not claim key after several rounds")
340
+
341
+ async def _run_acquired(self, scope, key, entry, effect, classify) -> OnceResult:
342
+ attempt, attempt_id, run_id = entry["attempt"], entry["attemptId"], entry["runId"]
343
+ self._emit({"type": "claimed", "scope": scope, "key": key, "attempt": attempt, "runId": run_id})
344
+ ctx = EffectContext(
345
+ key=key, scope=scope, intent=entry["intent"], attempt=attempt,
346
+ idempotency_key=provider_idempotency_key(scope, key), fencing_token=entry["token"],
347
+ deadline_ms=entry["claimedAt"] + self.policy.pending_ttl_ms,
348
+ )
349
+ try:
350
+ value = effect(ctx)
351
+ if inspect.isawaitable(value):
352
+ value = await value
353
+ except Exception as err: # noqa: BLE001 - classified below
354
+ try:
355
+ cls = "not_applied" if classify(err) == "not_applied" else "ambiguous"
356
+ except Exception:
357
+ cls = "ambiguous"
358
+ outcome = "failed" if cls == "not_applied" else "ambiguous"
359
+ try:
360
+ await self._settle(scope, key=key, attempt_id=attempt_id, outcome=outcome, error=_describe(err))
361
+ except Exception:
362
+ pass
363
+ if outcome == "failed":
364
+ self._emit({"type": "failed", "scope": scope, "key": key, "attempt": attempt, "error": err})
365
+ return OnceResult("failed", False, key, attempt=attempt, error=err, retryable=attempt < self.policy.max_attempts)
366
+ self._emit({"type": "ambiguous", "scope": scope, "key": key, "error": err})
367
+ try:
368
+ current = ((await self.store.read(scope)) or {}).get("effects", {}).get(key) or entry
369
+ except Exception:
370
+ current = entry
371
+ return OnceResult("ambiguous", False, key, entry=current, error=err)
372
+
373
+ try:
374
+ stored = None if value is None else json.loads(json.dumps(value))
375
+ except (TypeError, ValueError):
376
+ stored = None
377
+ self._emit({"type": "unserializable_result", "scope": scope, "key": key})
378
+ try:
379
+ settled = await self._settle(scope, key=key, attempt_id=attempt_id, outcome="committed", result=stored)
380
+ except Exception:
381
+ self._emit({"type": "late_settle", "scope": scope, "key": key, "attempt": attempt})
382
+ return OnceResult("executed", True, key, value=value, attempt=attempt, late_settle=True)
383
+ self._emit({"type": "committed", "scope": scope, "key": key, "attempt": attempt, "runId": run_id})
384
+ if settled["kind"] == "stale" or settled.get("wasAbandoned"):
385
+ self._emit({"type": "late_settle", "scope": scope, "key": key, "attempt": attempt})
386
+ return OnceResult("executed", True, key, value=value, attempt=attempt, late_settle=True)
387
+ return OnceResult("executed", True, key, value=value, attempt=attempt)
388
+
389
+ async def _settle(self, scope: str, **kw) -> dict:
390
+ last: Optional[BaseException] = None
391
+ for i in range(4):
392
+ try:
393
+ now = self._now()
394
+ return await self.store.transact(scope, lambda s: logic.settle(s, now=now, policy=self.policy, **kw))
395
+ except Exception as err: # noqa: BLE001
396
+ last = err
397
+ await asyncio.sleep(0.025 * 4**i)
398
+ assert last is not None
399
+ raise last
400
+
401
+ async def _wait_for_settle(self, scope: str, key: str, until: int, interval_ms: int) -> None:
402
+ while self._now() < until:
403
+ await asyncio.sleep(min(interval_ms, max(1, until - self._now())) / 1000)
404
+ e = ((await self.store.read(scope)) or {}).get("effects", {}).get(key)
405
+ if e is None or logic.effective_status(e, self._now(), self.policy) != "pending":
406
+ return
407
+
408
+
409
+ class Run:
410
+ """A unit of agent work on one scope, holding a fencing token."""
411
+
412
+ def __init__(self, ro: ReplyOnce, scope: str, run_id: str, lease_ttl_ms: int, outcome: dict) -> None:
413
+ self._ro = ro
414
+ self.scope = scope
415
+ self.run_id = run_id
416
+ self._ttl = lease_ttl_ms
417
+ self.state: str = outcome["state"]
418
+ if self.state == "busy":
419
+ self.fencing_token: Optional[int] = None
420
+ self.prior_effects: list[dict] = []
421
+ self.busy_holder: Optional[dict] = outcome["holder"]
422
+ else:
423
+ self.fencing_token = outcome["run"]["token"]
424
+ self.prior_effects = outcome["priorEffects"]
425
+ self.busy_holder = None
426
+
427
+ @property
428
+ def can_write(self) -> bool:
429
+ return self.state in ("new", "resumed")
430
+
431
+ @property
432
+ def already_wrote(self) -> bool:
433
+ return any(e["status"] != "failed" for e in self.prior_effects)
434
+
435
+ async def once(self, intent: str, payload: Any, effect: Effect, *, summary: Optional[str] = None,
436
+ conflict: str = "any", ack: bool = True, classify_error=None, reconcile=None,
437
+ wait_ms: Optional[int] = None, wait_interval_ms: int = 100) -> OnceResult:
438
+ """Run ``effect`` at most once per (scope, intent, payload), fenced to this run."""
439
+ key = effect_key(intent, payload)
440
+ if self.fencing_token is None:
441
+ return OnceResult("refused", False, key, reason="busy")
442
+ return await self._ro._execute(
443
+ self.scope, key, intent, (self.run_id, self.fencing_token, self._ttl), effect,
444
+ summary=summary, classify_error=classify_error, reconcile=reconcile,
445
+ wait_ms=wait_ms, wait_interval_ms=wait_interval_ms, conflict=conflict, ack=ack,
446
+ )
447
+
448
+ async def check(self, intent: str, payload: Any = MISSING) -> CheckResult:
449
+ return await self._ro.check(Key(self.scope, intent, payload))
450
+
451
+ async def history(self) -> list[dict]:
452
+ return await self._ro.history(self.scope)
453
+
454
+ async def heartbeat(self) -> bool:
455
+ if self.fencing_token is None:
456
+ return False
457
+ return await self._ro._renew(self.scope, self.run_id, self.fencing_token, self._ttl)
458
+
459
+ async def end(self, reason: str = "completed") -> None:
460
+ if self.fencing_token is None:
461
+ return
462
+ await self._ro._end(self.scope, self.run_id, self.fencing_token, reason)
replyonce/_key.py ADDED
@@ -0,0 +1,89 @@
1
+ """Keys and canonical hashing, byte-compatible with the TypeScript package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import datetime as _dt
7
+ import hashlib
8
+ import json
9
+ import math
10
+ import re
11
+ from typing import Any
12
+
13
+ MISSING: Any = object()
14
+ """Sentinel for "no payload" (distinct from a ``None`` payload, which hashes as JSON null)."""
15
+
16
+
17
+ def _sort_key(k: str) -> bytes:
18
+ # JavaScript sorts object keys by UTF-16 code units.
19
+ return k.encode("utf-16-be")
20
+
21
+
22
+ def _iso(value: _dt.datetime) -> str:
23
+ if value.tzinfo is None:
24
+ value = value.replace(tzinfo=_dt.timezone.utc)
25
+ value = value.astimezone(_dt.timezone.utc)
26
+ return value.strftime("%Y-%m-%dT%H:%M:%S.") + f"{value.microsecond // 1000:03d}Z"
27
+
28
+
29
+ def _normalize(value: Any, seen: set[int]) -> Any:
30
+ if value is None or isinstance(value, (bool, str)):
31
+ return value
32
+ if isinstance(value, int):
33
+ return value
34
+ if isinstance(value, float):
35
+ if math.isnan(value):
36
+ return "NaN"
37
+ if math.isinf(value):
38
+ return "Infinity" if value > 0 else "-Infinity"
39
+ return int(value) if value.is_integer() else value
40
+ if isinstance(value, _dt.datetime):
41
+ return _iso(value)
42
+ if isinstance(value, _dt.date):
43
+ return _iso(_dt.datetime(value.year, value.month, value.day, tzinfo=_dt.timezone.utc))
44
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
45
+ value = dataclasses.asdict(value)
46
+ elif hasattr(value, "model_dump") and callable(value.model_dump):
47
+ value = value.model_dump()
48
+ if id(value) in seen:
49
+ raise TypeError("replyonce: cannot hash a circular payload")
50
+ if isinstance(value, (list, tuple)):
51
+ seen.add(id(value))
52
+ out = [_normalize(v, seen) for v in value]
53
+ seen.discard(id(value))
54
+ return out
55
+ if isinstance(value, dict):
56
+ seen.add(id(value))
57
+ items = sorted(((str(k), v) for k, v in value.items()), key=lambda kv: _sort_key(kv[0]))
58
+ obj = {k: _normalize(v, seen) for k, v in items}
59
+ seen.discard(id(value))
60
+ return obj
61
+ raise TypeError(f"replyonce: cannot hash a payload of type {type(value).__name__}")
62
+
63
+
64
+ def canonical_json(value: Any) -> str:
65
+ """Deterministic JSON: keys sorted, no whitespace. Matches the TypeScript ``canonicalJson``."""
66
+ return json.dumps(_normalize(value, set()), separators=(",", ":"), ensure_ascii=False, sort_keys=False)
67
+
68
+
69
+ def sha256_hex(text: str) -> str:
70
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
71
+
72
+
73
+ def hash_payload(payload: Any) -> str:
74
+ return sha256_hex(canonical_json(payload))[:32]
75
+
76
+
77
+ _SAFE_INTENT = re.compile(r"^[A-Za-z0-9_.:-]{1,100}$")
78
+
79
+
80
+ def effect_key(intent: str, payload: Any = MISSING) -> str:
81
+ """Ledger key for an intent plus the business identity of the payload."""
82
+ if not isinstance(intent, str) or not _SAFE_INTENT.match(intent):
83
+ raise TypeError(f"replyonce: intent must match {_SAFE_INTENT.pattern} (got {intent!r})")
84
+ return intent if payload is MISSING else f"{intent}:{hash_payload(payload)}"
85
+
86
+
87
+ def provider_idempotency_key(scope: str, key: str) -> str:
88
+ """Stable idempotency key to hand to a provider (Stripe-style ``Idempotency-Key``)."""
89
+ return "ro_" + sha256_hex(f"{scope}\u0000{key}")[:40]