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.
Files changed (95) hide show
  1. rabbitkit/__init__.py +201 -0
  2. rabbitkit/_version.py +3 -0
  3. rabbitkit/aio/__init__.py +31 -0
  4. rabbitkit/async_/__init__.py +9 -0
  5. rabbitkit/async_/batch.py +213 -0
  6. rabbitkit/async_/broker.py +1123 -0
  7. rabbitkit/async_/connection.py +274 -0
  8. rabbitkit/async_/pool.py +363 -0
  9. rabbitkit/async_/transport.py +877 -0
  10. rabbitkit/asyncapi/__init__.py +5 -0
  11. rabbitkit/asyncapi/generator.py +219 -0
  12. rabbitkit/asyncapi/schema.py +98 -0
  13. rabbitkit/cli/__init__.py +77 -0
  14. rabbitkit/cli/_utils.py +38 -0
  15. rabbitkit/cli/commands/__init__.py +0 -0
  16. rabbitkit/cli/commands/dlq.py +190 -0
  17. rabbitkit/cli/commands/health.py +34 -0
  18. rabbitkit/cli/commands/migrate.py +570 -0
  19. rabbitkit/cli/commands/routes.py +88 -0
  20. rabbitkit/cli/commands/run.py +144 -0
  21. rabbitkit/cli/commands/shell.py +72 -0
  22. rabbitkit/cli/commands/topology.py +346 -0
  23. rabbitkit/concurrency.py +451 -0
  24. rabbitkit/core/__init__.py +5 -0
  25. rabbitkit/core/app.py +323 -0
  26. rabbitkit/core/config.py +849 -0
  27. rabbitkit/core/env_config.py +251 -0
  28. rabbitkit/core/errors.py +199 -0
  29. rabbitkit/core/logging.py +261 -0
  30. rabbitkit/core/message.py +235 -0
  31. rabbitkit/core/path.py +53 -0
  32. rabbitkit/core/pipeline.py +1289 -0
  33. rabbitkit/core/protocols.py +349 -0
  34. rabbitkit/core/registry.py +284 -0
  35. rabbitkit/core/route.py +329 -0
  36. rabbitkit/core/router.py +142 -0
  37. rabbitkit/core/topology.py +261 -0
  38. rabbitkit/core/topology_dispatch.py +74 -0
  39. rabbitkit/core/types.py +324 -0
  40. rabbitkit/dashboard/__init__.py +5 -0
  41. rabbitkit/dashboard/app.py +212 -0
  42. rabbitkit/di/__init__.py +19 -0
  43. rabbitkit/di/context.py +193 -0
  44. rabbitkit/di/depends.py +42 -0
  45. rabbitkit/di/resolver.py +503 -0
  46. rabbitkit/dlq.py +320 -0
  47. rabbitkit/experimental/__init__.py +50 -0
  48. rabbitkit/fastapi.py +91 -0
  49. rabbitkit/health.py +654 -0
  50. rabbitkit/highload/__init__.py +10 -0
  51. rabbitkit/highload/backpressure.py +514 -0
  52. rabbitkit/highload/batch.py +448 -0
  53. rabbitkit/locking.py +277 -0
  54. rabbitkit/management.py +470 -0
  55. rabbitkit/middleware/__init__.py +27 -0
  56. rabbitkit/middleware/base.py +125 -0
  57. rabbitkit/middleware/circuit_breaker.py +131 -0
  58. rabbitkit/middleware/compression.py +267 -0
  59. rabbitkit/middleware/deduplication.py +651 -0
  60. rabbitkit/middleware/error_classifier.py +43 -0
  61. rabbitkit/middleware/exception.py +105 -0
  62. rabbitkit/middleware/metrics.py +440 -0
  63. rabbitkit/middleware/otel.py +203 -0
  64. rabbitkit/middleware/rate_limit.py +247 -0
  65. rabbitkit/middleware/retry.py +540 -0
  66. rabbitkit/middleware/signing.py +682 -0
  67. rabbitkit/middleware/timeout.py +291 -0
  68. rabbitkit/py.typed +0 -0
  69. rabbitkit/queue_metrics.py +174 -0
  70. rabbitkit/results/__init__.py +6 -0
  71. rabbitkit/results/backend.py +102 -0
  72. rabbitkit/results/middleware.py +123 -0
  73. rabbitkit/rpc.py +632 -0
  74. rabbitkit/serialization/__init__.py +25 -0
  75. rabbitkit/serialization/base.py +35 -0
  76. rabbitkit/serialization/json.py +122 -0
  77. rabbitkit/serialization/msgspec.py +136 -0
  78. rabbitkit/serialization/pipeline.py +255 -0
  79. rabbitkit/streams.py +139 -0
  80. rabbitkit/sync/__init__.py +11 -0
  81. rabbitkit/sync/batch.py +595 -0
  82. rabbitkit/sync/broker.py +996 -0
  83. rabbitkit/sync/connection.py +209 -0
  84. rabbitkit/sync/pool.py +262 -0
  85. rabbitkit/sync/transport.py +1085 -0
  86. rabbitkit/testing/__init__.py +20 -0
  87. rabbitkit/testing/app.py +99 -0
  88. rabbitkit/testing/broker.py +540 -0
  89. rabbitkit/testing/fixtures.py +56 -0
  90. rabbitkit-0.9.0.dist-info/METADATA +575 -0
  91. rabbitkit-0.9.0.dist-info/RECORD +95 -0
  92. rabbitkit-0.9.0.dist-info/WHEEL +5 -0
  93. rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
  94. rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
  95. 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