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.
- idemkit/__init__.py +101 -0
- idemkit/_version.py +1 -0
- idemkit/adapters/__init__.py +10 -0
- idemkit/adapters/ai.py +723 -0
- idemkit/adapters/asgi.py +533 -0
- idemkit/adapters/queue.py +413 -0
- idemkit/adapters/route.py +273 -0
- idemkit/adapters/wsgi.py +393 -0
- idemkit/backends/__init__.py +30 -0
- idemkit/backends/base.py +116 -0
- idemkit/backends/dynamodb.py +489 -0
- idemkit/backends/memory.py +325 -0
- idemkit/backends/mongo.py +395 -0
- idemkit/backends/postgres.py +682 -0
- idemkit/backends/redis.py +547 -0
- idemkit/cli.py +165 -0
- idemkit/conformance/__init__.py +25 -0
- idemkit/conformance/backend.py +257 -0
- idemkit/conformance/report.py +54 -0
- idemkit/contrib/__init__.py +30 -0
- idemkit/contrib/drf.py +181 -0
- idemkit/contrib/fastapi.py +141 -0
- idemkit/contrib/kafka.py +96 -0
- idemkit/contrib/logging.py +61 -0
- idemkit/contrib/mcp.py +113 -0
- idemkit/contrib/prometheus.py +79 -0
- idemkit/contrib/pubsub.py +98 -0
- idemkit/contrib/rabbitmq.py +138 -0
- idemkit/contrib/reconciliation.py +86 -0
- idemkit/contrib/sqs.py +117 -0
- idemkit/core/__init__.py +36 -0
- idemkit/core/codecs.py +229 -0
- idemkit/core/config.py +255 -0
- idemkit/core/engine.py +331 -0
- idemkit/core/events.py +63 -0
- idemkit/core/exception_cache.py +88 -0
- idemkit/core/exceptions.py +79 -0
- idemkit/core/fingerprint.py +153 -0
- idemkit/core/policy.py +143 -0
- idemkit/core/runner.py +664 -0
- idemkit/core/state.py +95 -0
- idemkit/core/sync_bridge.py +115 -0
- idemkit/problem_details.py +119 -0
- idemkit/py.typed +0 -0
- idemkit-0.1.0.dist-info/METADATA +446 -0
- idemkit-0.1.0.dist-info/RECORD +48 -0
- idemkit-0.1.0.dist-info/WHEEL +4 -0
- 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
|