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,153 @@
1
+ """Length-prefixed SHA-256 hashing for fingerprint (§4.5) and effective key (§4.6).
2
+
3
+ The construction ``SHA-256(len_be32(c0) ‖ c0 ‖ len_be32(c1) ‖ c1 ‖ …)`` is
4
+ collision-resistant for arbitrary inputs, including those containing 0x00
5
+ bytes. This matters because both request bodies and idempotency keys are
6
+ user-controlled.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import re
14
+ import struct
15
+ import urllib.parse
16
+
17
+ FINGERPRINT_VERSION = 1
18
+
19
+
20
+ def _len_be32(b: bytes) -> bytes:
21
+ """4-byte big-endian unsigned length prefix."""
22
+ return struct.pack(">I", len(b))
23
+
24
+
25
+ def _length_prefixed_sha256(components: list[bytes]) -> str:
26
+ """Lowercase hex SHA-256 of length-prefixed concatenation."""
27
+ h = hashlib.sha256()
28
+ for component in components:
29
+ h.update(_len_be32(component))
30
+ h.update(component)
31
+ return h.hexdigest()
32
+
33
+
34
+ _PCT_RE = re.compile(rb"%([0-9a-fA-F]{2})")
35
+
36
+
37
+ def _uppercase_percent_bytes(s: bytes) -> bytes:
38
+ """Normalize ``%xx`` escapes to uppercase hex per RFC 3986 §6.2.2.1."""
39
+ return _PCT_RE.sub(lambda m: b"%" + m.group(1).upper(), s)
40
+
41
+
42
+ def canonicalize_path(path: str) -> str:
43
+ """Canonicalize a URL path per spec §4.5.
44
+
45
+ - Collapse repeated slashes.
46
+ - Strip trailing slash except for root ``/``.
47
+ - Normalize percent-encoding to uppercase hex.
48
+ """
49
+ while "//" in path:
50
+ path = path.replace("//", "/")
51
+ if len(path) > 1 and path.endswith("/"):
52
+ path = path[:-1]
53
+ return _uppercase_percent_bytes(path.encode("utf-8")).decode("utf-8")
54
+
55
+
56
+ def canonicalize_query(query: str) -> str:
57
+ """Canonicalize a query string per spec §4.5.
58
+
59
+ Percent-decode once, sort params lexicographically by (name, value),
60
+ re-encode with uppercase percent escapes.
61
+ """
62
+ if not query:
63
+ return ""
64
+ parsed = urllib.parse.parse_qsl(query, keep_blank_values=True)
65
+ parsed.sort()
66
+ encoded = urllib.parse.urlencode(parsed, doseq=False)
67
+ return _uppercase_percent_bytes(encoded.encode("utf-8")).decode("utf-8")
68
+
69
+
70
+ def canonicalize_body(body: bytes, content_type: str | None) -> bytes:
71
+ """Canonicalize a request body per spec §4.5.
72
+
73
+ Returns canonicalized bytes. On parse failure, falls back to raw bytes
74
+ (normative per spec — implementations MUST NOT raise on parse failure).
75
+ """
76
+ if not body or not content_type:
77
+ return body
78
+
79
+ ct_main = content_type.lower().split(";", 1)[0].strip()
80
+
81
+ if ct_main == "application/json":
82
+ try:
83
+ parsed = json.loads(body)
84
+ except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
85
+ return body
86
+ return json.dumps(parsed, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
87
+ "utf-8"
88
+ )
89
+
90
+ if ct_main == "application/x-www-form-urlencoded":
91
+ try:
92
+ decoded = body.decode("utf-8")
93
+ except UnicodeDecodeError:
94
+ return body
95
+ parsed_form = urllib.parse.parse_qsl(decoded, keep_blank_values=True)
96
+ parsed_form.sort()
97
+ return urllib.parse.urlencode(parsed_form).encode("utf-8")
98
+
99
+ # Other content types fall back to the raw decoded bytes. NOTE: spec §4.5
100
+ # describes a per-part canonicalization for multipart/form-data; v0.1 does
101
+ # NOT implement it and treats multipart bodies as raw bytes, so a re-encoded
102
+ # multipart body with a different boundary fingerprints differently. This is
103
+ # a documented v0.1 limitation; full multipart canonicalization is deferred.
104
+ return body
105
+
106
+
107
+ def compute_fingerprint(
108
+ method: str,
109
+ path: str,
110
+ query: str,
111
+ body: bytes,
112
+ content_type: str | None,
113
+ ) -> str:
114
+ """Compute fingerprint v1 per spec §4.5."""
115
+ return _length_prefixed_sha256(
116
+ [
117
+ method.upper().encode("ascii"),
118
+ canonicalize_path(path).encode("utf-8"),
119
+ canonicalize_query(query).encode("utf-8"),
120
+ canonicalize_body(body, content_type),
121
+ ]
122
+ )
123
+
124
+
125
+ def compute_effective_key(
126
+ idempotency_key: str,
127
+ scope: str,
128
+ method: str,
129
+ path: str,
130
+ ) -> str:
131
+ """Compute effective key per spec §4.6 with length-prefixed composition."""
132
+ return _length_prefixed_sha256(
133
+ [
134
+ idempotency_key.encode("utf-8"),
135
+ scope.encode("utf-8"),
136
+ method.upper().encode("ascii"),
137
+ canonicalize_path(path).encode("utf-8"),
138
+ ]
139
+ )
140
+
141
+
142
+ def compose_key(components: list[str]) -> str:
143
+ """Length-prefixed SHA-256 over arbitrary string scope components (spec §5.1).
144
+
145
+ The surface-neutral effective-key builder for the queue and AI surfaces,
146
+ where the scope is a tuple of plain strings rather than HTTP's
147
+ (key, caller, method, path). Length-prefixing keeps it collision-resistant
148
+ even when a component contains a 0x00 byte, same as the HTTP construction.
149
+
150
+ Queue: ``compose_key([dedup_id, queue_or_topic, consumer_group])``.
151
+ AI tool: ``compose_key([tool_name, version, args_hash, scope])``.
152
+ """
153
+ return _length_prefixed_sha256([c.encode("utf-8") for c in components])
idemkit/core/policy.py ADDED
@@ -0,0 +1,143 @@
1
+ """One flat config object per surface: ``HttpConfig`` / ``QueueConfig`` / ``MethodConfig``.
2
+
3
+ Each surface is configured with exactly one of these objects, passed as ``config=``.
4
+ There are no loose keyword overrides: the config is the one way in. To reuse settings
5
+ across surfaces or call sites, write a small factory that returns the config you want
6
+ (there is no separate shared object to learn).
7
+
8
+ The datastore is configured separately, on the backend (``PostgresBackend(table=...)``,
9
+ ``RedisBackend.from_url(url, namespace=...)``): the backend is WHERE dedup state
10
+ lives; the config is HOW a duplicate is judged and replayed.
11
+
12
+ Surface-option fields are typed loosely here (this module cannot import the HTTP
13
+ config's aliases without a cycle); the adapters validate them.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from typing import Any, Literal
21
+
22
+ from idemkit.core.events import EventHandler
23
+
24
+
25
+ class _Unset:
26
+ """Sentinel for a keyword that was not passed, so a config can supply it."""
27
+
28
+ __slots__ = ()
29
+
30
+ def __repr__(self) -> str:
31
+ return "<use default>"
32
+
33
+
34
+ # Typed as Any so a signature can default a real-typed keyword to it without
35
+ # upsetting type checkers, while the runtime can still detect "not passed".
36
+ UNSET: Any = _Unset()
37
+
38
+
39
+ # The options shared by every surface (lease/wait default to None = "use the
40
+ # surface's own default"). They are inlined flat into each surface config below.
41
+ @dataclass(frozen=True)
42
+ class MethodConfig:
43
+ """Config for ``@idempotent`` / ``idempotent_sync`` (flat: all options on one
44
+ object). Pass ``config=MethodConfig(...)``."""
45
+
46
+ # method-specific
47
+ key_fields: list[str] | None = None
48
+ scope: Callable[[Any], Any] | None = None
49
+ validation_fingerprint: Callable[[Any], bytes] | None = None
50
+ normalize_args: Callable[[Any], Any] | None = None
51
+ result_codec: Any = "json"
52
+ strict_keys: bool = True
53
+ # When True, refuse to derive the key from all arguments: the call must name its
54
+ # key via key_fields, normalize_args, or a per-call idempotency_key (else raises
55
+ # IdempotencyKeyMissing). The enforcing analogue of Powertools raise_on_no_idempotency_key.
56
+ require_key: bool = False
57
+ # Exception types that are DETERMINISTIC: instead of releasing the claim (retry
58
+ # re-runs), cache the exception and re-raise it on a duplicate. Default: cache
59
+ # nothing (every raise is treated as a transient crash and re-run).
60
+ cache_exceptions: tuple[type[BaseException], ...] = ()
61
+ version: str = "1"
62
+ # shared
63
+ lease_ttl_seconds: float | None = None
64
+ wait_timeout_seconds: float | None = None
65
+ expires_after_seconds: float = 86_400.0
66
+ on_storage_error: Literal["fail_closed", "fail_open"] = "fail_closed"
67
+ use_local_cache: bool = False
68
+ local_cache_max_items: int = 1024
69
+ event_handlers: tuple[EventHandler, ...] = ()
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class QueueConfig:
74
+ """Config for ``IdempotentConsumer`` (flat). ``dedup_id`` (how to read the broker's
75
+ dedup id from a message) and ``visibility_timeout_seconds`` (the broker's window)
76
+ are required for a direct consumer; the ``contrib`` helpers (sqs/kafka) preset
77
+ ``dedup_id`` for you."""
78
+
79
+ # required wiring
80
+ dedup_id: Callable[[Any], str] | None = None
81
+ visibility_timeout_seconds: float | None = None
82
+ # queue-specific
83
+ scope: Callable[[Any], Any] | None = None
84
+ max_attempts: int = 5
85
+ on_exhausted: Callable[[Any, Any], Any] | None = None
86
+ receive_count: Callable[[Any], Any] | None = None
87
+ attempt_store: Any | None = None
88
+ cache_result: bool = False
89
+ result_codec: Any | None = None
90
+ # A field that must match on a dedup-id hit but isn't part of the key: catches a
91
+ # producer that reused a message id with a different body (raises PayloadMismatch).
92
+ validation_fingerprint: Callable[[Any], bytes] | None = None
93
+ # shared
94
+ lease_ttl_seconds: float | None = None
95
+ wait_timeout_seconds: float | None = None
96
+ expires_after_seconds: float = 86_400.0
97
+ on_storage_error: Literal["fail_closed", "fail_open"] = "fail_closed"
98
+ use_local_cache: bool = False
99
+ local_cache_max_items: int = 1024
100
+ event_handlers: tuple[EventHandler, ...] = ()
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class HttpConfig:
105
+ """Config for the HTTP middleware / route decorator (flat). ``None`` fields fall
106
+ back to the HTTP defaults."""
107
+
108
+ # http-specific
109
+ scope: Callable[[Any], str] | None = None
110
+ scope_mode: Literal["warn", "single_tenant", "strict"] = "warn"
111
+ key: Callable[[Any], str | None] | None = None
112
+ require_key_for_mutations: bool = False
113
+ applicable_methods: set[str] | None = None
114
+ body_fingerprint: Callable[[bytes, Any], bytes] | None = None
115
+ response_redactor: Any | None = None
116
+ response_hook: Any | None = None
117
+ header_allow: set[str] | None = None
118
+ header_deny: set[str] | None = None
119
+ cacheable_status: set[int] | None = None
120
+ max_body_bytes: int | None = None
121
+ max_request_body_bytes: int | None = None
122
+ compat_mode: Literal["default", "stripe"] = "default"
123
+ # shared
124
+ lease_ttl_seconds: float | None = None
125
+ wait_timeout_seconds: float | None = None
126
+ expires_after_seconds: float = 86_400.0
127
+ on_storage_error: Literal["fail_closed", "fail_open"] = "fail_closed"
128
+ use_local_cache: bool = False
129
+ local_cache_max_items: int = 1024
130
+ event_handlers: tuple[EventHandler, ...] = ()
131
+
132
+
133
+ def pick(explicit: Any, config: Any, field_name: str, default: Any) -> Any:
134
+ """Resolve one value: an explicit keyword wins, then the config field, then the
135
+ default. A config field of ``None`` means "inherit the default". ``config`` is a
136
+ flat surface config or ``None``."""
137
+ if explicit is not UNSET:
138
+ return explicit
139
+ if config is not None:
140
+ value = getattr(config, field_name)
141
+ if value is not None:
142
+ return value
143
+ return default