rabbitkit 0.9.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.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Structured logging configuration for rabbitkit.
|
|
2
|
+
|
|
3
|
+
structlog is a declared dependency but must be explicitly configured.
|
|
4
|
+
``LoggingConfig`` controls rendering (JSON for prod, console for dev).
|
|
5
|
+
Once configured, all rabbitkit internals (pipeline, broker, transport) emit
|
|
6
|
+
structured log events with per-message context automatically bound via
|
|
7
|
+
``structlog.contextvars``.
|
|
8
|
+
|
|
9
|
+
Usage
|
|
10
|
+
-----
|
|
11
|
+
Pass ``logging=LoggingConfig(...)`` to ``RabbitConfig`` and the broker will
|
|
12
|
+
call ``configure_structlog()`` on ``start()``:
|
|
13
|
+
|
|
14
|
+
from rabbitkit import RabbitConfig
|
|
15
|
+
from rabbitkit.core.logging import LoggingConfig
|
|
16
|
+
from rabbitkit.async_ import AsyncBroker
|
|
17
|
+
|
|
18
|
+
# Development — coloured console output
|
|
19
|
+
broker = AsyncBroker(
|
|
20
|
+
RabbitConfig(
|
|
21
|
+
logging=LoggingConfig(render_json=False, include_caller_info=True)
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Production — JSON lines to stdout (pipe to fluentd / Loki / etc.)
|
|
26
|
+
broker = AsyncBroker(
|
|
27
|
+
RabbitConfig(
|
|
28
|
+
logging=LoggingConfig(render_json=True, timestamper_fmt="iso")
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
Per-message context
|
|
33
|
+
-------------------
|
|
34
|
+
The pipeline automatically binds these keys for every message:
|
|
35
|
+
|
|
36
|
+
message_id, routing_key, queue, handler
|
|
37
|
+
|
|
38
|
+
They appear in every log line emitted while the handler runs and are cleared
|
|
39
|
+
in a ``finally`` block so they never bleed into unrelated events.
|
|
40
|
+
|
|
41
|
+
Manual configuration
|
|
42
|
+
--------------------
|
|
43
|
+
Call ``configure_structlog()`` directly if you manage the broker lifecycle
|
|
44
|
+
yourself and do not use ``RabbitConfig.logging``:
|
|
45
|
+
|
|
46
|
+
from rabbitkit.core.logging import configure_structlog, LoggingConfig
|
|
47
|
+
|
|
48
|
+
configure_structlog(LoggingConfig(render_json=True))
|
|
49
|
+
|
|
50
|
+
Safe to call multiple times — last call wins.
|
|
51
|
+
|
|
52
|
+
Secrets and message content (L16)
|
|
53
|
+
----------------------------------
|
|
54
|
+
rabbitkit's own structured log events never include the message body or
|
|
55
|
+
the raw ``headers`` dict — only ``message_id``, ``routing_key``, ``queue``,
|
|
56
|
+
and ``handler`` are bound per message. Bodies/headers may legitimately
|
|
57
|
+
carry credentials or PII, so this is deliberate: none of rabbitkit's
|
|
58
|
+
internal logging can leak them.
|
|
59
|
+
|
|
60
|
+
That guarantee does not extend to log calls YOU write. If your own
|
|
61
|
+
handler code does e.g. ``logger.info("processing", headers=msg.headers)``,
|
|
62
|
+
whatever is in that dict goes out verbatim. Because ``configure_structlog()``
|
|
63
|
+
sets structlog's *global* processor chain, ``LoggingConfig.redact_keys``
|
|
64
|
+
(enabled by default) applies to those calls too: any top-level event field,
|
|
65
|
+
or field one level deep inside a nested dict (e.g. ``headers={...}``),
|
|
66
|
+
whose key case-insensitively matches -- or contains all the underscore-
|
|
67
|
+
separated words of -- an entry in ``redact_keys`` is replaced with a fixed
|
|
68
|
+
redacted marker before rendering. The word-based matching is what catches
|
|
69
|
+
compound key names like ``x-auth-token`` or ``session-token`` against the
|
|
70
|
+
standalone ``token``/``auth`` defaults, without treating a partial word
|
|
71
|
+
from a compound default (e.g. ``key`` from ``api_key``) as a standalone
|
|
72
|
+
matcher -- that would misfire on unrelated fields like ``primary_key``.
|
|
73
|
+
This is a best-effort, key-name-based scrubber — not a PII/content
|
|
74
|
+
scanner, and not a substitute for simply not logging bodies/headers
|
|
75
|
+
containing secrets in the first place. Pass ``redact_keys=None`` to
|
|
76
|
+
disable it, or a custom ``frozenset`` to redact your own key names instead
|
|
77
|
+
of (or in addition to) the defaults.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
from __future__ import annotations
|
|
81
|
+
|
|
82
|
+
from dataclasses import dataclass
|
|
83
|
+
from typing import TYPE_CHECKING, Any
|
|
84
|
+
|
|
85
|
+
if TYPE_CHECKING:
|
|
86
|
+
import structlog
|
|
87
|
+
|
|
88
|
+
# L16: common credential/secret-bearing key names, matched case-insensitively.
|
|
89
|
+
# Deliberately name-based (not content-based) -- see the module docstring.
|
|
90
|
+
DEFAULT_REDACT_KEYS: frozenset[str] = frozenset(
|
|
91
|
+
{
|
|
92
|
+
"password",
|
|
93
|
+
"passwd",
|
|
94
|
+
"secret",
|
|
95
|
+
"token",
|
|
96
|
+
"api_key",
|
|
97
|
+
"apikey",
|
|
98
|
+
"authorization",
|
|
99
|
+
"auth",
|
|
100
|
+
"access_token",
|
|
101
|
+
"refresh_token",
|
|
102
|
+
"private_key",
|
|
103
|
+
"client_secret",
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
_REDACTED = "***REDACTED***"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class LoggingConfig:
|
|
112
|
+
"""Structured logging configuration.
|
|
113
|
+
|
|
114
|
+
Attributes:
|
|
115
|
+
render_json: True for JSON output (prod), False for console (dev).
|
|
116
|
+
add_log_level: Include log level in output.
|
|
117
|
+
timestamper_fmt: Timestamp format ("iso", "unix", or None to disable).
|
|
118
|
+
include_caller_info: Add filename/line number to log events.
|
|
119
|
+
redact_keys: Key names to redact from log events -- checked at the
|
|
120
|
+
top level and one level deep inside nested dict values (e.g. a
|
|
121
|
+
``headers={...}`` field). Matching is case-insensitive and
|
|
122
|
+
normalizes AMQP-style ``x-`` prefixes/hyphens, so ``api_key``
|
|
123
|
+
also matches ``X-Api-Key``. Defaults to
|
|
124
|
+
:data:`DEFAULT_REDACT_KEYS`. Pass ``None`` to disable redaction
|
|
125
|
+
entirely, or your own ``frozenset`` to customize it. See the
|
|
126
|
+
module docstring ("Secrets and message content") for scope and
|
|
127
|
+
limitations.
|
|
128
|
+
capture_warnings: Route Python's ``warnings`` module (used for every
|
|
129
|
+
rabbitkit safety warning -- topology drift, retry-without-
|
|
130
|
+
confirms, unsafe TLS, dashboard auth, ...) through the standard
|
|
131
|
+
``logging`` module via ``logging.captureWarnings()``. Without
|
|
132
|
+
this, ``warnings.warn()`` writes directly to ``sys.stderr`` in
|
|
133
|
+
its own format, completely bypassing whatever log pipeline
|
|
134
|
+
``render_json``/handlers were set up for -- a "loud warning" is
|
|
135
|
+
only actually loud if something is watching raw stderr in dev;
|
|
136
|
+
in a production JSON-logging deployment it's invisible unless
|
|
137
|
+
this is enabled. Default ``True``. Set ``False`` if your
|
|
138
|
+
application already manages ``captureWarnings`` itself.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
render_json: bool = False
|
|
142
|
+
add_log_level: bool = True
|
|
143
|
+
timestamper_fmt: str = "iso"
|
|
144
|
+
include_caller_info: bool = False
|
|
145
|
+
redact_keys: frozenset[str] | None = DEFAULT_REDACT_KEYS
|
|
146
|
+
capture_warnings: bool = True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _normalize_key(key: str) -> str:
|
|
150
|
+
"""Normalize a key for comparison (L16).
|
|
151
|
+
|
|
152
|
+
AMQP headers conventionally use a ``x-`` prefix and hyphens (e.g.
|
|
153
|
+
``x-api-key``), not the Python-style snake_case of
|
|
154
|
+
:data:`DEFAULT_REDACT_KEYS` (``api_key``). Stripping the ``x-`` prefix
|
|
155
|
+
and folding hyphens to underscores lets both spellings match the same
|
|
156
|
+
default entry.
|
|
157
|
+
"""
|
|
158
|
+
lowered = key.lower()
|
|
159
|
+
if lowered.startswith("x-"):
|
|
160
|
+
lowered = lowered[2:]
|
|
161
|
+
return lowered.replace("-", "_")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _redact_processor(keys: frozenset[str]) -> Any:
|
|
165
|
+
"""Build a structlog processor that redacts *keys* (L16).
|
|
166
|
+
|
|
167
|
+
Checks event-dict keys at the top level and one level deep inside any
|
|
168
|
+
nested ``dict`` value (covers the common ``headers={...}`` shape),
|
|
169
|
+
normalized via :func:`_normalize_key`. Not a recursive/deep scan -- see
|
|
170
|
+
the module docstring for why a shallow, name-based approach is the
|
|
171
|
+
deliberate scope here.
|
|
172
|
+
|
|
173
|
+
Matching a normalized key against *keys* is a word-set match, not an
|
|
174
|
+
exact-string match: a configured key may itself be a compound
|
|
175
|
+
(``api_key``, ``access_token``), so a target key matches when it
|
|
176
|
+
contains ALL of some configured key's underscore-separated words (in
|
|
177
|
+
any order) -- e.g. ``x-auth-token`` normalizes to ``auth_token``,
|
|
178
|
+
which contains the words of the standalone ``token`` entry, so it
|
|
179
|
+
matches even though ``auth_token`` itself isn't literally one of the
|
|
180
|
+
configured names. Exact-string matching alone let common compound
|
|
181
|
+
secret-bearing names (``x-auth-token``, ``session-token``,
|
|
182
|
+
``x-secret-key``, ``bearer-token``, ...) slip through untouched.
|
|
183
|
+
Splitting each configured key into its OWN word-set (rather than
|
|
184
|
+
pooling every word from every configured key into one flat set) is
|
|
185
|
+
what keeps this from over-matching: naively treating ``api_key``'s
|
|
186
|
+
``key`` as a standalone matcher would redact totally benign fields
|
|
187
|
+
like ``primary_key``/``cache_key``, since neither contains ``api``.
|
|
188
|
+
"""
|
|
189
|
+
redact_word_sets = [frozenset(_normalize_key(k).split("_")) for k in keys]
|
|
190
|
+
|
|
191
|
+
def matches(normalized_key: str) -> bool:
|
|
192
|
+
key_words = frozenset(normalized_key.split("_"))
|
|
193
|
+
return any(words <= key_words for words in redact_word_sets)
|
|
194
|
+
|
|
195
|
+
def processor(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
|
|
196
|
+
for key, value in event_dict.items():
|
|
197
|
+
if matches(_normalize_key(key)):
|
|
198
|
+
event_dict[key] = _REDACTED
|
|
199
|
+
elif isinstance(value, dict):
|
|
200
|
+
event_dict[key] = {
|
|
201
|
+
nested_key: (_REDACTED if matches(_normalize_key(nested_key)) else nested_value)
|
|
202
|
+
for nested_key, nested_value in value.items()
|
|
203
|
+
}
|
|
204
|
+
return event_dict
|
|
205
|
+
|
|
206
|
+
return processor
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def configure_structlog(config: LoggingConfig | None = None) -> None:
|
|
210
|
+
"""One-time structlog configuration.
|
|
211
|
+
|
|
212
|
+
Safe to call multiple times — last call wins.
|
|
213
|
+
If config is None, uses defaults (console renderer, ISO timestamps).
|
|
214
|
+
"""
|
|
215
|
+
import logging
|
|
216
|
+
|
|
217
|
+
import structlog
|
|
218
|
+
|
|
219
|
+
if config is None:
|
|
220
|
+
config = LoggingConfig()
|
|
221
|
+
|
|
222
|
+
# L16 follow-up: bridge warnings.warn() (every rabbitkit safety warning)
|
|
223
|
+
# into the standard logging module -- otherwise it bypasses this whole
|
|
224
|
+
# pipeline entirely, writing straight to sys.stderr in its own format.
|
|
225
|
+
logging.captureWarnings(config.capture_warnings)
|
|
226
|
+
|
|
227
|
+
processors: list[structlog.types.Processor] = [
|
|
228
|
+
structlog.contextvars.merge_contextvars,
|
|
229
|
+
structlog.stdlib.filter_by_level,
|
|
230
|
+
structlog.stdlib.add_logger_name,
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
if config.redact_keys:
|
|
234
|
+
processors.append(_redact_processor(config.redact_keys))
|
|
235
|
+
|
|
236
|
+
if config.add_log_level:
|
|
237
|
+
processors.append(structlog.stdlib.add_log_level)
|
|
238
|
+
|
|
239
|
+
if config.timestamper_fmt:
|
|
240
|
+
fmt = config.timestamper_fmt if config.timestamper_fmt != "iso" else "iso"
|
|
241
|
+
processors.append(structlog.processors.TimeStamper(fmt=fmt))
|
|
242
|
+
|
|
243
|
+
if config.include_caller_info:
|
|
244
|
+
processors.append(structlog.processors.CallsiteParameterAdder())
|
|
245
|
+
|
|
246
|
+
processors.append(structlog.stdlib.PositionalArgumentsFormatter())
|
|
247
|
+
processors.append(structlog.processors.StackInfoRenderer())
|
|
248
|
+
processors.append(structlog.processors.UnicodeDecoder())
|
|
249
|
+
|
|
250
|
+
if config.render_json:
|
|
251
|
+
processors.append(structlog.processors.JSONRenderer())
|
|
252
|
+
else:
|
|
253
|
+
processors.append(structlog.dev.ConsoleRenderer())
|
|
254
|
+
|
|
255
|
+
structlog.configure(
|
|
256
|
+
processors=processors,
|
|
257
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
258
|
+
context_class=dict,
|
|
259
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
260
|
+
cache_logger_on_first_use=True,
|
|
261
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Rich incoming message with runtime-aware ack/nack/reject.
|
|
2
|
+
|
|
3
|
+
See Contract 2 in the plan for sync/async ack design.
|
|
4
|
+
|
|
5
|
+
Sync transport sets _ack_fn. Async transport sets _ack_async_fn.
|
|
6
|
+
Pipeline calls the appropriate variant internally.
|
|
7
|
+
MANUAL mode handlers choose ack() or ack_async() based on their runtime.
|
|
8
|
+
Idempotent: double-ack is a no-op (guarded by _disposition state).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_rabbit_message_annotation(ann: Any) -> bool:
|
|
19
|
+
"""True if ``ann`` is/mentions :class:`RabbitMessage`.
|
|
20
|
+
|
|
21
|
+
Handles both the resolved class and the string form (``"RabbitMessage"``)
|
|
22
|
+
produced by ``from __future__ import annotations`` when the hint can't be
|
|
23
|
+
resolved by ``typing.get_type_hints`` (e.g. a handler in a module that didn't
|
|
24
|
+
import the name). Recognizing the string form prevents valid
|
|
25
|
+
``(body: bytes, msg: RabbitMessage)`` handlers from being mis-classified as
|
|
26
|
+
having two body-like parameters / wrong body-type detection.
|
|
27
|
+
"""
|
|
28
|
+
if ann is RabbitMessage:
|
|
29
|
+
return True
|
|
30
|
+
if isinstance(ann, str):
|
|
31
|
+
return ann == "RabbitMessage" or ann.endswith(".RabbitMessage")
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RabbitMessage:
|
|
36
|
+
"""Rich incoming message with transport-aware settlement.
|
|
37
|
+
|
|
38
|
+
The message object wraps raw AMQP delivery data and provides:
|
|
39
|
+
- Typed access to headers, properties, routing info
|
|
40
|
+
- Sync and async ack/nack/reject methods
|
|
41
|
+
- Idempotent settlement (double-ack is a no-op)
|
|
42
|
+
- Topic wildcard path extraction
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
__slots__ = (
|
|
46
|
+
"_ack_async_fn",
|
|
47
|
+
"_ack_fn",
|
|
48
|
+
"_disposition",
|
|
49
|
+
"_nack_async_fn",
|
|
50
|
+
"_nack_fn",
|
|
51
|
+
"_reject_async_fn",
|
|
52
|
+
"_reject_fn",
|
|
53
|
+
"app_id",
|
|
54
|
+
"body",
|
|
55
|
+
"consumer_tag",
|
|
56
|
+
"content_encoding",
|
|
57
|
+
"content_type",
|
|
58
|
+
"correlation_id",
|
|
59
|
+
"delivery_tag",
|
|
60
|
+
"exchange",
|
|
61
|
+
"expiration",
|
|
62
|
+
"headers",
|
|
63
|
+
"message_id",
|
|
64
|
+
"path",
|
|
65
|
+
"priority",
|
|
66
|
+
"raw_message",
|
|
67
|
+
"redelivered",
|
|
68
|
+
"reply_to",
|
|
69
|
+
"routing_key",
|
|
70
|
+
"timestamp",
|
|
71
|
+
"type",
|
|
72
|
+
"user_id",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
body: bytes,
|
|
79
|
+
headers: dict[str, Any] | None = None,
|
|
80
|
+
message_id: str | None = None,
|
|
81
|
+
correlation_id: str | None = None,
|
|
82
|
+
reply_to: str | None = None,
|
|
83
|
+
content_type: str | None = None,
|
|
84
|
+
content_encoding: str | None = None,
|
|
85
|
+
timestamp: datetime | None = None,
|
|
86
|
+
type: str | None = None, # noqa: A002 — AMQP property name
|
|
87
|
+
app_id: str | None = None,
|
|
88
|
+
priority: int | None = None,
|
|
89
|
+
expiration: str | None = None,
|
|
90
|
+
user_id: str | None = None,
|
|
91
|
+
routing_key: str = "",
|
|
92
|
+
exchange: str = "",
|
|
93
|
+
delivery_tag: int | None = None,
|
|
94
|
+
redelivered: bool = False,
|
|
95
|
+
consumer_tag: str | None = None,
|
|
96
|
+
path: dict[str, str] | None = None,
|
|
97
|
+
raw_message: Any = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
self.body = body
|
|
100
|
+
self.headers: dict[str, Any] = headers or {}
|
|
101
|
+
self.message_id = message_id
|
|
102
|
+
self.correlation_id = correlation_id
|
|
103
|
+
self.reply_to = reply_to
|
|
104
|
+
self.content_type = content_type
|
|
105
|
+
self.content_encoding = content_encoding
|
|
106
|
+
self.timestamp = timestamp
|
|
107
|
+
self.type = type
|
|
108
|
+
self.app_id = app_id
|
|
109
|
+
self.priority = priority
|
|
110
|
+
self.expiration = expiration
|
|
111
|
+
self.user_id = user_id
|
|
112
|
+
self.routing_key = routing_key
|
|
113
|
+
self.exchange = exchange
|
|
114
|
+
self.delivery_tag = delivery_tag
|
|
115
|
+
self.redelivered = redelivered
|
|
116
|
+
self.consumer_tag = consumer_tag
|
|
117
|
+
self.path: dict[str, str] = path or {}
|
|
118
|
+
self.raw_message = raw_message
|
|
119
|
+
|
|
120
|
+
# Transport-injected settlement functions (internal)
|
|
121
|
+
self._ack_fn: Callable[[], None] | None = None
|
|
122
|
+
self._ack_async_fn: Callable[[], Awaitable[None]] | None = None
|
|
123
|
+
self._nack_fn: Callable[[bool], None] | None = None
|
|
124
|
+
self._nack_async_fn: Callable[[bool], Awaitable[None]] | None = None
|
|
125
|
+
self._reject_fn: Callable[[bool], None] | None = None
|
|
126
|
+
self._reject_async_fn: Callable[[bool], Awaitable[None]] | None = None
|
|
127
|
+
self._disposition: str = "pending"
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def is_settled(self) -> bool:
|
|
131
|
+
"""True if the message has been acked, nacked, or rejected."""
|
|
132
|
+
return self._disposition != "pending"
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def disposition(self) -> str:
|
|
136
|
+
"""Final settlement state: "pending", "acked", "nacked", or "rejected" (M2)."""
|
|
137
|
+
return self._disposition
|
|
138
|
+
|
|
139
|
+
# ── Sync settlement ───────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def ack(self) -> None:
|
|
142
|
+
"""Synchronous ack. Raises RuntimeError on async-only transport.
|
|
143
|
+
|
|
144
|
+
Sets disposition only after the transport call succeeds, so a failed
|
|
145
|
+
ack (channel closed, frame error) leaves the message unsettled and the
|
|
146
|
+
exception propagates to the recovery loop instead of being swallowed.
|
|
147
|
+
"""
|
|
148
|
+
if self._disposition != "pending":
|
|
149
|
+
return # idempotent guard
|
|
150
|
+
if self._ack_fn is None:
|
|
151
|
+
msg = "Cannot sync-ack an async transport message. Use await msg.ack_async()."
|
|
152
|
+
raise RuntimeError(msg)
|
|
153
|
+
self._ack_fn() # may raise — disposition stays "pending" on failure
|
|
154
|
+
self._disposition = "acked"
|
|
155
|
+
|
|
156
|
+
def nack(self, requeue: bool = True) -> None:
|
|
157
|
+
"""Synchronous nack. Raises RuntimeError on async-only transport."""
|
|
158
|
+
if self._disposition != "pending":
|
|
159
|
+
return
|
|
160
|
+
if self._nack_fn is None:
|
|
161
|
+
msg = "Cannot sync-nack an async transport message. Use await msg.nack_async()."
|
|
162
|
+
raise RuntimeError(msg)
|
|
163
|
+
self._nack_fn(requeue)
|
|
164
|
+
self._disposition = "nacked"
|
|
165
|
+
|
|
166
|
+
def reject(self, requeue: bool = False) -> None:
|
|
167
|
+
"""Synchronous reject. Raises RuntimeError on async-only transport."""
|
|
168
|
+
if self._disposition != "pending":
|
|
169
|
+
return
|
|
170
|
+
if self._reject_fn is None:
|
|
171
|
+
msg = "Cannot sync-reject an async transport message. Use await msg.reject_async()."
|
|
172
|
+
raise RuntimeError(msg)
|
|
173
|
+
self._reject_fn(requeue)
|
|
174
|
+
self._disposition = "rejected"
|
|
175
|
+
|
|
176
|
+
# ── Async settlement ──────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
async def ack_async(self) -> None:
|
|
179
|
+
"""Async ack. Falls back to sync if async fn not set."""
|
|
180
|
+
if self._disposition != "pending":
|
|
181
|
+
return
|
|
182
|
+
if self._ack_async_fn:
|
|
183
|
+
await self._ack_async_fn()
|
|
184
|
+
elif self._ack_fn:
|
|
185
|
+
self._ack_fn()
|
|
186
|
+
else:
|
|
187
|
+
raise RuntimeError("Cannot async-ack: no settlement fn set. Use msg.ack() on a sync transport.")
|
|
188
|
+
self._disposition = "acked"
|
|
189
|
+
|
|
190
|
+
async def nack_async(self, requeue: bool = True) -> None:
|
|
191
|
+
"""Async nack. Falls back to sync if async fn not set."""
|
|
192
|
+
if self._disposition != "pending":
|
|
193
|
+
return
|
|
194
|
+
if self._nack_async_fn:
|
|
195
|
+
await self._nack_async_fn(requeue)
|
|
196
|
+
elif self._nack_fn:
|
|
197
|
+
self._nack_fn(requeue)
|
|
198
|
+
else:
|
|
199
|
+
raise RuntimeError("Cannot async-nack: no settlement fn set. Use msg.nack() on a sync transport.")
|
|
200
|
+
self._disposition = "nacked"
|
|
201
|
+
|
|
202
|
+
async def reject_async(self, requeue: bool = False) -> None:
|
|
203
|
+
"""Async reject. Falls back to sync if async fn not set."""
|
|
204
|
+
if self._disposition != "pending":
|
|
205
|
+
return
|
|
206
|
+
if self._reject_async_fn:
|
|
207
|
+
await self._reject_async_fn(requeue)
|
|
208
|
+
elif self._reject_fn:
|
|
209
|
+
self._reject_fn(requeue)
|
|
210
|
+
else:
|
|
211
|
+
raise RuntimeError("Cannot async-reject: no settlement fn set. Use msg.reject() on a sync transport.")
|
|
212
|
+
self._disposition = "rejected"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── Exception-based ack control ──────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class AckMessage(Exception):
|
|
219
|
+
"""Raise from handler to ack the message."""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class NackMessage(Exception):
|
|
223
|
+
"""Raise from handler to nack the message."""
|
|
224
|
+
|
|
225
|
+
def __init__(self, requeue: bool = True) -> None:
|
|
226
|
+
super().__init__()
|
|
227
|
+
self.requeue = requeue
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class RejectMessage(Exception):
|
|
231
|
+
"""Raise from handler to reject the message."""
|
|
232
|
+
|
|
233
|
+
def __init__(self, requeue: bool = False) -> None:
|
|
234
|
+
super().__init__()
|
|
235
|
+
self.requeue = requeue
|
rabbitkit/core/path.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Named routing-key segments for ``Path()`` dependency injection.
|
|
2
|
+
|
|
3
|
+
AMQP topic wildcards (``*`` one word, ``#`` zero+ words) are anonymous, so there
|
|
4
|
+
is no way to bind ``Path("level")`` to a position. rabbitkit lets a route name a
|
|
5
|
+
single-word segment with ``{name}`` in its routing key, e.g.::
|
|
6
|
+
|
|
7
|
+
@broker.subscriber(queue="events", routing_key="events.{level}.#")
|
|
8
|
+
def handle(body: bytes, level: Annotated[str, Path("level")]) -> None: ...
|
|
9
|
+
|
|
10
|
+
``{name}`` binds to AMQP as ``*`` (one word). On each delivery the named segments
|
|
11
|
+
are extracted from the message's actual routing key into ``message.path``, which
|
|
12
|
+
is what the ``Path()`` resolver reads.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_named(segment: str) -> bool:
|
|
19
|
+
return len(segment) > 2 and segment[0] == "{" and segment[-1] == "}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def to_binding_key(routing_key: str) -> str:
|
|
23
|
+
"""Translate ``{name}`` segments to the AMQP single-word wildcard ``*``.
|
|
24
|
+
|
|
25
|
+
Routing keys without named segments are returned unchanged (fast path), so
|
|
26
|
+
existing topic/direct routes are completely unaffected.
|
|
27
|
+
"""
|
|
28
|
+
if "{" not in routing_key:
|
|
29
|
+
return routing_key
|
|
30
|
+
return ".".join("*" if _is_named(s) else s for s in routing_key.split("."))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def extract_path(actual_routing_key: str, pattern: str) -> dict[str, str]:
|
|
34
|
+
"""Extract named segments from a delivered routing key given the route pattern.
|
|
35
|
+
|
|
36
|
+
Positional: each ``{name}`` in the pattern maps to the same-index segment of
|
|
37
|
+
the actual key. Stops at a ``#`` (which spans a variable number of words, so
|
|
38
|
+
nothing after it has a fixed position). Returns ``{}`` when the pattern has no
|
|
39
|
+
named segments — the broker already matched the binding, so no validation is
|
|
40
|
+
needed here.
|
|
41
|
+
"""
|
|
42
|
+
if "{" not in pattern:
|
|
43
|
+
return {}
|
|
44
|
+
actual = actual_routing_key.split(".")
|
|
45
|
+
out: dict[str, str] = {}
|
|
46
|
+
for i, seg in enumerate(pattern.split(".")):
|
|
47
|
+
if seg == "#":
|
|
48
|
+
break
|
|
49
|
+
if i >= len(actual):
|
|
50
|
+
break
|
|
51
|
+
if _is_named(seg):
|
|
52
|
+
out[seg[1:-1]] = actual[i]
|
|
53
|
+
return out
|