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,349 @@
1
+ """Transport ABCs and capability sub-protocols.
2
+
3
+ Defines the minimal interfaces that sync and async transports must implement,
4
+ plus opt-in capability protocols for publisher confirms, backpressure, RPC,
5
+ circuit breakers, and metrics.
6
+
7
+ ZERO pika or aio-pika imports — truly transport-agnostic.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Awaitable, Callable
13
+ from typing import Any, Protocol, runtime_checkable
14
+
15
+ from rabbitkit.core.message import RabbitMessage
16
+ from rabbitkit.core.topology import RabbitExchange, RabbitQueue
17
+ from rabbitkit.core.types import MessageEnvelope, PublishOutcome
18
+
19
+ # ── Core transport protocols ──────────────────────────────────────────────
20
+
21
+
22
+ @runtime_checkable
23
+ class Transport(Protocol):
24
+ """Sync transport — implemented by sync/transport.py.
25
+
26
+ Minimal interface for sync message broker I/O.
27
+ """
28
+
29
+ def connect(self) -> None:
30
+ """Establish connection to broker."""
31
+ ...
32
+
33
+ def disconnect(self) -> None:
34
+ """Close connection to broker."""
35
+ ...
36
+
37
+ def is_connected(self) -> bool:
38
+ """Check if transport is connected."""
39
+ ...
40
+
41
+ def publish(self, envelope: MessageEnvelope) -> PublishOutcome:
42
+ """Publish a message. Returns outcome with confirm status."""
43
+ ...
44
+
45
+ def consume(
46
+ self,
47
+ queue: str,
48
+ callback: Callable[[RabbitMessage], None],
49
+ prefetch: int = 10,
50
+ *,
51
+ no_ack: bool = False,
52
+ declare: bool = True,
53
+ ) -> str:
54
+ """Start consuming from a queue. Returns consumer_tag.
55
+
56
+ ``no_ack=True`` starts a no-ack consumer (the broker auto-acks on
57
+ delivery; the built ``RabbitMessage`` gets no settlement functions).
58
+ ``declare=False`` skips declaring/checking the queue first — required
59
+ for AMQP pseudo-queues such as ``amq.rabbitmq.reply-to``, which the
60
+ broker rejects any Queue.Declare for (active or passive).
61
+ """
62
+ ...
63
+
64
+ def declare_exchange(self, exchange: RabbitExchange) -> None:
65
+ """Declare an exchange on the broker."""
66
+ ...
67
+
68
+ def declare_queue(self, queue: RabbitQueue) -> None:
69
+ """Declare a queue on the broker."""
70
+ ...
71
+
72
+ def bind_queue(
73
+ self,
74
+ queue: str,
75
+ exchange: str,
76
+ routing_key: str,
77
+ arguments: dict[str, Any] | None = None,
78
+ ) -> None:
79
+ """Bind a queue to an exchange with a routing key.
80
+
81
+ ``arguments`` carries header-match criteria for HEADERS exchanges.
82
+ """
83
+ ...
84
+
85
+ def bind_exchange(
86
+ self,
87
+ destination: str,
88
+ source: str,
89
+ routing_key: str = "",
90
+ arguments: dict[str, Any] | None = None,
91
+ ) -> None:
92
+ """Bind an exchange to another exchange (exchange-to-exchange binding)."""
93
+ ...
94
+
95
+ def cancel_consumer(self, consumer_tag: str) -> None:
96
+ """Cancel a consumer by its tag."""
97
+ ...
98
+
99
+
100
+ @runtime_checkable
101
+ class AsyncTransport(Protocol):
102
+ """Async transport — implemented by async_/transport.py.
103
+
104
+ Minimal interface for async message broker I/O.
105
+ """
106
+
107
+ async def connect(self) -> None:
108
+ """Establish connection to broker."""
109
+ ...
110
+
111
+ async def disconnect(self) -> None:
112
+ """Close connection to broker."""
113
+ ...
114
+
115
+ def is_connected(self) -> bool:
116
+ """Check if transport is connected."""
117
+ ...
118
+
119
+ async def publish(self, envelope: MessageEnvelope) -> PublishOutcome:
120
+ """Publish a message. Returns outcome with confirm status."""
121
+ ...
122
+
123
+ async def consume(
124
+ self,
125
+ queue: str,
126
+ callback: Callable[[RabbitMessage], Awaitable[None]],
127
+ prefetch: int = 10,
128
+ *,
129
+ no_ack: bool = False,
130
+ declare: bool = True,
131
+ ) -> str:
132
+ """Start consuming from a queue. Returns consumer_tag.
133
+
134
+ ``no_ack=True`` starts a no-ack consumer (the broker auto-acks on
135
+ delivery; the built ``RabbitMessage`` gets no settlement functions).
136
+ ``declare=False`` skips declaring/checking the queue first — required
137
+ for AMQP pseudo-queues such as ``amq.rabbitmq.reply-to``, which the
138
+ broker rejects any Queue.Declare for (active or passive).
139
+ """
140
+ ...
141
+
142
+ async def declare_exchange(self, exchange: RabbitExchange) -> None:
143
+ """Declare an exchange on the broker."""
144
+ ...
145
+
146
+ async def declare_queue(self, queue: RabbitQueue) -> None:
147
+ """Declare a queue on the broker."""
148
+ ...
149
+
150
+ async def bind_queue(
151
+ self,
152
+ queue: str,
153
+ exchange: str,
154
+ routing_key: str,
155
+ arguments: dict[str, Any] | None = None,
156
+ ) -> None:
157
+ """Bind a queue to an exchange with a routing key.
158
+
159
+ ``arguments`` carries header-match criteria for HEADERS exchanges.
160
+ """
161
+ ...
162
+
163
+ async def bind_exchange(
164
+ self,
165
+ destination: str,
166
+ source: str,
167
+ routing_key: str = "",
168
+ arguments: dict[str, Any] | None = None,
169
+ ) -> None:
170
+ """Bind an exchange to another exchange (exchange-to-exchange binding)."""
171
+ ...
172
+
173
+ async def cancel_consumer(self, consumer_tag: str) -> None:
174
+ """Cancel a consumer by its tag."""
175
+ ...
176
+
177
+
178
+ # ── DLQ / inspection sub-protocols ────────────────────────────────────────
179
+
180
+
181
+ @runtime_checkable
182
+ class SupportsBasicGet(Protocol):
183
+ """Transport supports basic.get (single-message fetch)."""
184
+
185
+ def basic_get(self, queue: str) -> RabbitMessage | None:
186
+ """Fetch a single message from a queue. Returns None if empty."""
187
+ ...
188
+
189
+
190
+ @runtime_checkable
191
+ class AsyncSupportsBasicGet(Protocol):
192
+ """Async transport supports basic.get."""
193
+
194
+ async def basic_get(self, queue: str) -> RabbitMessage | None:
195
+ """Fetch a single message from a queue. Returns None if empty."""
196
+ ...
197
+
198
+
199
+ @runtime_checkable
200
+ class SupportsPurge(Protocol):
201
+ """Transport supports queue purge."""
202
+
203
+ def purge_queue(self, queue: str) -> int:
204
+ """Purge all messages from a queue. Returns purged count."""
205
+ ...
206
+
207
+
208
+ @runtime_checkable
209
+ class AsyncSupportsPurge(Protocol):
210
+ """Async transport supports queue purge."""
211
+
212
+ async def purge_queue(self, queue: str) -> int:
213
+ """Purge all messages from a queue. Returns purged count."""
214
+ ...
215
+
216
+
217
+ # ── Capability sub-protocols (opt-in) ─────────────────────────────────────
218
+
219
+
220
+ @runtime_checkable
221
+ class SupportsPublisherConfirms(Protocol):
222
+ """Transport supports confirm_delivery mode."""
223
+
224
+ def enable_confirms(self) -> None:
225
+ """Enable publisher confirms on the channel."""
226
+ ...
227
+
228
+
229
+ @runtime_checkable
230
+ class SupportsBackpressure(Protocol):
231
+ """Transport supports connection.blocked/unblocked callbacks."""
232
+
233
+ def on_blocked(self, callback: Callable[[], None]) -> None:
234
+ """Register callback for connection.blocked."""
235
+ ...
236
+
237
+ def on_unblocked(self, callback: Callable[[], None]) -> None:
238
+ """Register callback for connection.unblocked."""
239
+ ...
240
+
241
+
242
+ @runtime_checkable
243
+ class SupportsRPC(Protocol):
244
+ """Transport supports exclusive reply queues for RPC."""
245
+
246
+ def create_reply_queue(self) -> str:
247
+ """Create an exclusive reply queue. Returns queue name."""
248
+ ...
249
+
250
+
251
+ # ── Generic extension protocols ──────────────────────────────────────────
252
+
253
+
254
+ @runtime_checkable
255
+ class CircuitBreakerProtocol(Protocol):
256
+ """Generic circuit breaker protocol.
257
+
258
+ pybreaker (among others) satisfies this interface.
259
+ Used optionally by transports — core works without any CB.
260
+ """
261
+
262
+ def call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
263
+ """Execute func through circuit breaker."""
264
+ ...
265
+
266
+
267
+ @runtime_checkable
268
+ class AsyncCircuitBreakerProtocol(Protocol):
269
+ """Async circuit breaker protocol.
270
+
271
+ For async transports that need async circuit breaker support.
272
+ """
273
+
274
+ async def call_async(self, func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> Any:
275
+ """Execute async func through circuit breaker."""
276
+ ...
277
+
278
+
279
+ @runtime_checkable
280
+ class MetricsCollector(Protocol):
281
+ """Optional metrics collector protocol.
282
+
283
+ Used for observability integration; rabbitkit ships PrometheusCollector.
284
+ """
285
+
286
+ def increment(self, name: str, tags: dict[str, str] | None = None) -> None:
287
+ """Increment a counter metric."""
288
+ ...
289
+
290
+ def histogram(self, name: str, value: float, tags: dict[str, str] | None = None) -> None:
291
+ """Record a histogram metric value."""
292
+ ...
293
+
294
+
295
+ @runtime_checkable
296
+ class AsyncMetricsCollector(Protocol):
297
+ """Async metrics collector for async transports."""
298
+
299
+ async def increment(self, name: str, tags: dict[str, str] | None = None) -> None:
300
+ """Increment a counter metric."""
301
+ ...
302
+
303
+ async def histogram(self, name: str, value: float, tags: dict[str, str] | None = None) -> None:
304
+ """Record a histogram metric value."""
305
+ ...
306
+
307
+
308
+ # ── Health sub-protocol ─────────────────────────────────────────────────
309
+
310
+
311
+ @runtime_checkable
312
+ class HealthProvider(Protocol):
313
+ """Broker health surface — typed alternative to private-attribute probing.
314
+
315
+ Brokers that expose these read-only properties satisfy the protocol and
316
+ :mod:`rabbitkit.health` will use them directly. Brokers that still use
317
+ private attributes (``_started``, ``_transport``, ...) are supported via
318
+ the deprecation fallback in :func:`rabbitkit.health._get`.
319
+ """
320
+
321
+ @property
322
+ def started(self) -> bool:
323
+ """Whether the broker has been started."""
324
+ ...
325
+
326
+ @property
327
+ def connected(self) -> bool:
328
+ """Whether the underlying transport is connected."""
329
+ ...
330
+
331
+ @property
332
+ def consumer_count(self) -> int:
333
+ """Number of routes with an active (live) consumer."""
334
+ ...
335
+
336
+ @property
337
+ def route_count(self) -> int:
338
+ """Total number of registered routes."""
339
+ ...
340
+
341
+ @property
342
+ def worker_pool_pending(self) -> int:
343
+ """Current worker-pool backlog (pending tasks)."""
344
+ ...
345
+
346
+ @property
347
+ def last_heartbeat(self) -> float | None:
348
+ """Last liveness heartbeat (monotonic seconds), or None."""
349
+ ...
@@ -0,0 +1,284 @@
1
+ """Subscriber/Publisher registry — stores all @subscriber/@publisher registrations.
2
+
3
+ Semantic rules:
4
+ - One handler per queue (duplicate → DuplicateRouteError at registration time)
5
+ - str queue → auto-creates RabbitQueue(name=str, durable=True)
6
+ - str exchange → auto-creates RabbitExchange(name=str, type=DIRECT)
7
+ - @publisher without @subscriber → raises ConfigurationError
8
+ - @publisher BEFORE @subscriber on same handler → sets result_publisher
9
+
10
+ Registration-time retry conflict checks (fail fast):
11
+ When a route is registered, the registry resolves the effective retry policy
12
+ and validates retry + ack policy + DLX config compatibility.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from rabbitkit.core.config import RetryConfig, RetryDisabled
21
+ from rabbitkit.core.message import RabbitMessage
22
+ from rabbitkit.core.route import ResultPublisher, RouteDefinition
23
+ from rabbitkit.core.topology import RabbitExchange, RabbitQueue
24
+ from rabbitkit.core.types import AckPolicy
25
+ from rabbitkit.serialization.base import Serializer
26
+
27
+ if TYPE_CHECKING:
28
+ from rabbitkit.core.router import RabbitRouter
29
+ from rabbitkit.middleware.base import BaseMiddleware
30
+
31
+
32
+ class DuplicateRouteError(Exception):
33
+ """Raised when two routes target the same queue."""
34
+
35
+
36
+ class SubscriberRegistry:
37
+ """Stores all @subscriber/@publisher registrations.
38
+
39
+ Used by RabbitApp and RabbitRouter to collect route definitions
40
+ which are later wired by the broker.
41
+ """
42
+
43
+ def __init__(self, broker_retry: RetryConfig | None = None) -> None:
44
+ self._routes: list[RouteDefinition] = []
45
+ self._queue_names: set[str] = set()
46
+ self._pending_publishers: dict[int, ResultPublisher] = {} # handler id → ResultPublisher
47
+ self._broker_retry = broker_retry
48
+
49
+ @property
50
+ def routes(self) -> list[RouteDefinition]:
51
+ """Return all registered routes."""
52
+ return list(self._routes)
53
+
54
+ def subscriber(
55
+ self,
56
+ queue: RabbitQueue | str,
57
+ exchange: RabbitExchange | str | None = None,
58
+ routing_key: str = "",
59
+ ack_policy: AckPolicy = AckPolicy.AUTO,
60
+ middlewares: list[BaseMiddleware] | None = None,
61
+ serializer: Serializer[Any] | None = None,
62
+ retry: RetryConfig | RetryDisabled | None = None,
63
+ tags: frozenset[str] | set[str] | None = None,
64
+ description: str = "",
65
+ name: str | None = None,
66
+ prefetch_count: int | None = None,
67
+ filter_fn: Callable[[RabbitMessage], bool] | None = None,
68
+ reject_without_dlx: str | None = None,
69
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
70
+ """Decorator to register a message handler for a queue.
71
+
72
+ Args:
73
+ queue: Queue to consume from (str auto-creates RabbitQueue).
74
+ exchange: Exchange to bind to (str auto-creates RabbitExchange).
75
+ routing_key: Routing key for binding.
76
+ ack_policy: Acknowledgment policy for this route.
77
+ middlewares: Route-specific middleware list.
78
+ serializer: Route-specific serializer override.
79
+ retry: Per-route retry config (None=inherit, RETRY_DISABLED=opt-out).
80
+ tags: Route tags for filtering/grouping.
81
+ description: Human-readable route description.
82
+ name: Explicit route name (auto-generated if None).
83
+ prefetch_count: Per-route prefetch override (None=use global).
84
+ reject_without_dlx: Per-route override of
85
+ ``SafetyConfig.reject_without_dlx`` — 'auto_provision',
86
+ 'error', or 'discard' (None=inherit broker default).
87
+ """
88
+ # Normalize queue
89
+ if isinstance(queue, str):
90
+ queue = RabbitQueue(name=queue)
91
+
92
+ # Apply routing_key to the queue if not already set. The queue is frozen,
93
+ # so build a copy rather than mutating the caller's object (which may be
94
+ # shared across brokers/routers — see topology freeze + include-router).
95
+ if routing_key and not queue.routing_key:
96
+ from dataclasses import replace as _replace
97
+
98
+ queue = _replace(queue, routing_key=routing_key)
99
+
100
+ # Normalize exchange
101
+ if isinstance(exchange, str):
102
+ exchange = RabbitExchange(name=exchange)
103
+
104
+ # Normalize tags
105
+ if tags is not None and not isinstance(tags, frozenset):
106
+ tags = frozenset(tags)
107
+ elif tags is None:
108
+ tags = frozenset()
109
+
110
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
111
+ # Validate handler signature at registration time (fail fast).
112
+ # Covers *args/**kwargs and multiple body-like parameters (Contract 4).
113
+ from rabbitkit.di.resolver import DIResolver
114
+
115
+ DIResolver().validate_handler(func)
116
+
117
+ # Check duplicate queue
118
+ if queue.name in self._queue_names:
119
+ raise DuplicateRouteError(
120
+ f"Queue '{queue.name}' already has a registered handler. "
121
+ "rabbitkit enforces one handler per queue. "
122
+ "Use multiple routing keys on the same queue for fan-in."
123
+ )
124
+
125
+ # Auto-generate name
126
+ route_name = name or f"{queue.name}:{func.__qualname__}"
127
+
128
+ # Check for pending @publisher
129
+ result_publisher = self._pending_publishers.pop(id(func), None)
130
+
131
+ # Create route
132
+ route = RouteDefinition(
133
+ name=route_name,
134
+ queue=queue,
135
+ exchange=exchange,
136
+ handler=func,
137
+ ack_policy=ack_policy,
138
+ route_middlewares=middlewares or [],
139
+ result_publisher=result_publisher,
140
+ serializer_override=serializer,
141
+ retry_override=retry,
142
+ prefetch_count=prefetch_count,
143
+ tags=tags,
144
+ description=description,
145
+ filter_fn=filter_fn,
146
+ reject_without_dlx=reject_without_dlx,
147
+ )
148
+
149
+ # Validate at registration time (fail fast)
150
+ route.validate(self._broker_retry)
151
+
152
+ self._routes.append(route)
153
+ self._queue_names.add(queue.name)
154
+
155
+ # M-C6: detect dead-letter-exchange cycles across the growing route graph.
156
+ self.validate_dlx_graph()
157
+
158
+ return func
159
+
160
+ return decorator
161
+
162
+ def publisher(
163
+ self,
164
+ exchange: RabbitExchange | str | None = None,
165
+ routing_key: str = "",
166
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
167
+ """Decorator to configure result publishing for a handler.
168
+
169
+ Must be applied BEFORE @subscriber on the same handler.
170
+ If applied without @subscriber, the publisher info is stored
171
+ and applied when @subscriber is later applied.
172
+
173
+ Args:
174
+ exchange: Target exchange for result publishing.
175
+ routing_key: Routing key for result publishing.
176
+ """
177
+ # Normalize exchange
178
+ if isinstance(exchange, str):
179
+ exchange = RabbitExchange(name=exchange)
180
+
181
+ result_pub = ResultPublisher(exchange=exchange, routing_key=routing_key)
182
+
183
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
184
+ # Store pending publisher for this handler
185
+ self._pending_publishers[id(func)] = result_pub
186
+ return func
187
+
188
+ return decorator
189
+
190
+ def include_router(self, router: RabbitRouter, prefix: str = "") -> None:
191
+ """Include routes from a RabbitRouter.
192
+
193
+ Applies the router's prefix to routing keys and merges
194
+ router-level defaults (exchange, middlewares, serializer, tags).
195
+ """
196
+ # Duck-typed check retained despite the static RabbitRouter annotation:
197
+ # this is a public entry point Python won't enforce the type on at
198
+ # runtime, and a clear TypeError beats an opaque AttributeError below.
199
+ if not hasattr(router, "_registry"):
200
+ raise TypeError(f"Expected a RabbitRouter, got {type(router).__name__}")
201
+
202
+ for route in router._registry.routes:
203
+ # Apply prefix to routing key WITHOUT mutating the included router's
204
+ # RabbitQueue/RouteDefinition (frozen + shared-object safety). Build
205
+ # fresh copies so re-including the same router under a different
206
+ # prefix doesn't double-prefix or cross-contaminate.
207
+ from dataclasses import replace as _replace
208
+
209
+ if prefix:
210
+ effective_rk = f"{prefix}.{route.queue.routing_key}" if route.queue.routing_key else prefix
211
+ new_queue = _replace(route.queue, routing_key=effective_rk)
212
+ route = _replace(route, queue=new_queue)
213
+
214
+ # Check duplicate
215
+ if route.queue.name in self._queue_names:
216
+ raise DuplicateRouteError(
217
+ f"Queue '{route.queue.name}' already has a registered handler. Duplicate from included router."
218
+ )
219
+
220
+ # Validate with broker retry context
221
+ route.validate(self._broker_retry)
222
+
223
+ self._routes.append(route)
224
+ self._queue_names.add(route.queue.name)
225
+
226
+ # M-C6: detect DLX cycles after including router routes.
227
+ self.validate_dlx_graph()
228
+
229
+ def set_broker_retry(self, retry: RetryConfig | None) -> None:
230
+ """Update broker retry default. Re-validates all existing routes."""
231
+ self._broker_retry = retry
232
+ for route in self._routes:
233
+ route.validate(self._broker_retry)
234
+
235
+ def validate_dlx_graph(self) -> None:
236
+ """Detect dead-letter-exchange cycles across registered routes.
237
+
238
+ A queue's ``dead_letter_exchange`` points at an exchange; if that exchange
239
+ is the bind target of another route whose queue dead-letters back to an
240
+ exchange reachable from the first, messages loop forever (until TTL/GC).
241
+ This builds a queue→queue graph via DLX→exchange→bound-queue and rejects
242
+ any cycle with :class:`ConfigurationError`. Best-effort: only exchanges
243
+ named as a route's ``exchange`` are resolvable to a queue; unknown DLX
244
+ targets (external exchanges) are treated as sinks.
245
+ """
246
+ from rabbitkit.core.errors import ConfigurationError as _CfgErr
247
+
248
+ # exchange name → list of queue names bound to it via a registered route
249
+ exchange_to_queues: dict[str, list[str]] = {}
250
+ for r in self._routes:
251
+ if r.exchange is not None:
252
+ exchange_to_queues.setdefault(r.exchange.name, []).append(r.queue.name)
253
+
254
+ # queue → next queues reachable via its DLX (through the exchange graph)
255
+ adj: dict[str, list[str]] = {}
256
+ for r in self._routes:
257
+ dlx = r.queue.dead_letter_exchange
258
+ if not dlx:
259
+ continue
260
+ nxts = exchange_to_queues.get(dlx, []) # unknown DLX → sink (no edges)
261
+ adj.setdefault(r.queue.name, []).extend(nxts)
262
+
263
+ # DFS cycle detection
264
+ white, gray, black = 0, 1, 2
265
+ color: dict[str, int] = {}
266
+
267
+ def dfs(node: str, path: list[str]) -> None:
268
+ color[node] = gray
269
+ for nxt in adj.get(node, []):
270
+ if color.get(nxt, white) == gray:
271
+ cycle = " -> ".join([*path, node, nxt])
272
+ raise _CfgErr(
273
+ f"Dead-letter-exchange cycle detected: {cycle}. "
274
+ "Messages would loop forever. Break the cycle (e.g. let "
275
+ "retry own DLQ topology, or point the final DLX at a sink "
276
+ "exchange with no bound queue)."
277
+ )
278
+ if color.get(nxt, white) == white:
279
+ dfs(nxt, [*path, node])
280
+ color[node] = black
281
+
282
+ for q in adj:
283
+ if color.get(q, white) == white:
284
+ dfs(q, [])