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,20 @@
1
+ """Testing utilities — TestBroker, TestApp, fixtures.
2
+
3
+ Usage:
4
+ from rabbitkit.testing import TestBroker, TestApp
5
+
6
+ broker = TestBroker()
7
+
8
+ @broker.subscriber(queue="orders")
9
+ def handle_order(body: bytes) -> None:
10
+ ...
11
+
12
+ broker.start()
13
+ broker.publish("orders", b'{"id": 1}')
14
+ handle_order.mock.assert_called_once()
15
+ """
16
+
17
+ from rabbitkit.testing.app import TestApp
18
+ from rabbitkit.testing.broker import SettlementRecord, TestAsyncBroker, TestBroker
19
+
20
+ __all__ = ["SettlementRecord", "TestApp", "TestAsyncBroker", "TestBroker"]
@@ -0,0 +1,99 @@
1
+ """TestApp — lifecycle testing wrapper.
2
+
3
+ Wraps TestBroker with RabbitApp lifecycle hooks.
4
+ Triggers startup/shutdown hooks in test context.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from rabbitkit.core.app import AppState, RabbitApp
12
+ from rabbitkit.testing.broker import TestBroker
13
+
14
+
15
+ class TestApp:
16
+ """Full lifecycle wrapper — triggers startup/shutdown hooks in test context.
17
+
18
+ Usage::
19
+
20
+ broker = TestBroker()
21
+ app = RabbitApp(title="my-app")
22
+ test_app = TestApp(app, broker)
23
+
24
+ @broker.subscriber(queue="orders")
25
+ def handle_order(body: bytes) -> None:
26
+ ...
27
+
28
+ test_app.start()
29
+ broker.publish("orders", b'{"id": 1}')
30
+ handle_order.mock.assert_called_once()
31
+ test_app.stop()
32
+
33
+ Or as context manager::
34
+
35
+ with TestApp(app, broker) as ta:
36
+ broker.publish("orders", b'{"id": 1}')
37
+ """
38
+
39
+ __test__ = False # Prevent pytest from collecting as test class
40
+
41
+ def __init__(
42
+ self,
43
+ app: RabbitApp,
44
+ broker: TestBroker,
45
+ ) -> None:
46
+ self._app = app
47
+ self._broker = broker
48
+
49
+ @property
50
+ def app(self) -> RabbitApp:
51
+ return self._app
52
+
53
+ @property
54
+ def broker(self) -> TestBroker:
55
+ return self._broker
56
+
57
+ @property
58
+ def state(self) -> AppState:
59
+ return self._app.state
60
+
61
+ def start(self) -> None:
62
+ """Start the test app — runs startup hooks and starts broker."""
63
+ self._app.start()
64
+ self._broker.start()
65
+
66
+ def stop(self) -> None:
67
+ """Stop the test app — stops broker and runs shutdown hooks."""
68
+ self._broker.stop()
69
+ self._app.stop()
70
+
71
+ async def start_async(self) -> None:
72
+ """Async start — runs startup hooks and starts broker."""
73
+ await self._app.start_async()
74
+ self._broker.start()
75
+
76
+ async def stop_async(self) -> None:
77
+ """Async stop — stops broker and runs shutdown hooks."""
78
+ self._broker.stop()
79
+ await self._app.stop_async()
80
+
81
+ def reset(self) -> None:
82
+ """Reset broker state (published messages, mocks)."""
83
+ self._broker.reset()
84
+
85
+ # ── Context manager ───────────────────────────────────────────────────
86
+
87
+ def __enter__(self) -> TestApp:
88
+ self.start()
89
+ return self
90
+
91
+ def __exit__(self, *args: Any) -> None:
92
+ self.stop()
93
+
94
+ async def __aenter__(self) -> TestApp:
95
+ await self.start_async()
96
+ return self
97
+
98
+ async def __aexit__(self, *args: Any) -> None:
99
+ await self.stop_async()
@@ -0,0 +1,540 @@
1
+ """TestBroker — in-memory broker for unit testing.
2
+
3
+ No RabbitMQ required. Routes messages between subscribers using
4
+ exchange type matching. Captures published messages for assertions.
5
+
6
+ Implements the Transport protocol so it can be used anywhere a
7
+ transport is expected.
8
+
9
+ Settlement is *real*: ``ack``/``nack``/``reject`` go through the actual
10
+ ``RabbitMessage`` methods, so ``msg.is_settled`` and ``msg._disposition``
11
+ reflect what happened (no no-op mocks). The transport-level settlement
12
+ functions record their invocations (including the ``requeue`` argument)
13
+ so :meth:`TestBroker.assert_acked` / :meth:`assert_nacked` /
14
+ :meth:`assert_rejected` can assert on both disposition and requeue.
15
+
16
+ Publish outcomes are injectable via :attr:`publish_outcome` (persistent
17
+ override) or :meth:`fail_next_publish` (one-shot), so the pipeline's
18
+ failed-publish → ``nack(requeue=True)`` branch is reachable in tests.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass
26
+ from typing import TYPE_CHECKING, Any
27
+ from unittest.mock import MagicMock
28
+
29
+ from rabbitkit.core.config import RetryConfig, RetryDisabled
30
+ from rabbitkit.core.message import RabbitMessage
31
+ from rabbitkit.core.path import extract_path
32
+ from rabbitkit.core.pipeline import HandlerPipeline
33
+ from rabbitkit.core.registry import SubscriberRegistry
34
+ from rabbitkit.core.route import RouteDefinition
35
+ from rabbitkit.core.topology import RabbitExchange, RabbitQueue
36
+ from rabbitkit.middleware.base import BaseMiddleware
37
+ from rabbitkit.serialization.base import Serializer
38
+
39
+ if TYPE_CHECKING:
40
+ from rabbitkit.core.router import RabbitRouter
41
+ from rabbitkit.core.types import (
42
+ AckPolicy,
43
+ MessageEnvelope,
44
+ PublishOutcome,
45
+ PublishStatus,
46
+ )
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class SettlementRecord:
53
+ """A single transport-level settlement call recorded by TestBroker.
54
+
55
+ ``requeue`` is ``None`` for ``ack`` (which has no requeue argument).
56
+ """
57
+
58
+ kind: str # "ack" | "nack" | "reject"
59
+ requeue: bool | None
60
+
61
+
62
+ class TestBroker:
63
+ """In-memory broker — no RabbitMQ needed.
64
+
65
+ Features:
66
+ - Routes messages between subscribers using exchange type matching
67
+ - .mock attribute on every handler for assertions
68
+ - Captures published messages for assertion
69
+ - *Real* settlement: ack/nack/reject update ``msg._disposition`` and are
70
+ recorded (including ``requeue``) for :meth:`assert_acked` /
71
+ :meth:`assert_nacked` / :meth:`assert_rejected`
72
+ - Injectable publish outcome (``publish_outcome`` / ``fail_next_publish()``)
73
+ so the failed-publish → nack(requeue=True) branch is reachable
74
+ - Implements basic Transport-like interface
75
+
76
+ Usage:
77
+ broker = TestBroker()
78
+
79
+ @broker.subscriber(queue="orders")
80
+ def handle_order(body: bytes) -> None:
81
+ ...
82
+
83
+ broker.start()
84
+ broker.publish("orders", b'{"id": 1}')
85
+
86
+ handle_order.mock.assert_called_once()
87
+ broker.assert_acked(broker.consumed_messages[0])
88
+ """
89
+
90
+ __test__ = False # Prevent pytest from collecting as test class
91
+
92
+ def __init__(
93
+ self,
94
+ *,
95
+ serializer: Serializer[Any] | None = None,
96
+ di_resolver: Any | None = None,
97
+ context_repo: Any | None = None,
98
+ publish_outcome: PublishOutcome | None = None,
99
+ ) -> None:
100
+ self._registry = SubscriberRegistry()
101
+ self._pipeline = HandlerPipeline(
102
+ serializer=serializer,
103
+ di_resolver=di_resolver,
104
+ context_repo=context_repo,
105
+ )
106
+ self._published: list[MessageEnvelope] = []
107
+ self._consumed: list[RabbitMessage] = []
108
+ self._exchanges: dict[str, RabbitExchange] = {}
109
+ self._queues: dict[str, RabbitQueue] = {}
110
+ self._bindings: list[tuple[str, str, str]] = [] # (queue, exchange, routing_key)
111
+ self._started = False
112
+ # Per-message settlement log, keyed by id(message). Messages are retained
113
+ # in ``_consumed`` for the test's lifetime, so ids stay stable.
114
+ self._settlements: dict[int, list[SettlementRecord]] = {}
115
+ # Injectable publish outcome. When set, every publish returns it. When
116
+ # None, publishes return CONFIRMED (unless ``_fail_next`` is set).
117
+ self._publish_outcome: PublishOutcome | None = publish_outcome
118
+ # One-shot: the next publish returns a NACKED outcome, then clears.
119
+ self._fail_next: bool = False
120
+
121
+ # ── Registration (mirrors real broker API) ────────────────────────────
122
+
123
+ def subscriber(
124
+ self,
125
+ queue: RabbitQueue | str,
126
+ exchange: RabbitExchange | str | None = None,
127
+ routing_key: str = "",
128
+ ack_policy: AckPolicy = AckPolicy.AUTO,
129
+ middlewares: list[BaseMiddleware] | None = None,
130
+ serializer: Serializer[Any] | None = None,
131
+ retry: RetryConfig | RetryDisabled | None = None,
132
+ tags: frozenset[str] | set[str] | None = None,
133
+ description: str = "",
134
+ name: str | None = None,
135
+ prefetch_count: int | None = None,
136
+ filter_fn: Callable[[RabbitMessage], bool] | None = None,
137
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
138
+ """Register a subscriber — same API as real broker."""
139
+ decorator = self._registry.subscriber(
140
+ queue=queue,
141
+ exchange=exchange,
142
+ routing_key=routing_key,
143
+ ack_policy=ack_policy,
144
+ middlewares=middlewares,
145
+ serializer=serializer,
146
+ retry=retry,
147
+ tags=tags,
148
+ description=description,
149
+ name=name,
150
+ prefetch_count=prefetch_count,
151
+ filter_fn=filter_fn,
152
+ )
153
+
154
+ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
155
+ # Apply the subscriber decorator
156
+ result = decorator(func)
157
+ # Attach a mock for assertions
158
+ if not hasattr(result, "mock"):
159
+ result.mock = MagicMock() # type: ignore[attr-defined]
160
+ return result
161
+
162
+ return wrapper
163
+
164
+ def publisher(
165
+ self,
166
+ exchange: RabbitExchange | str | None = None,
167
+ routing_key: str = "",
168
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
169
+ """Register a result publisher."""
170
+ return self._registry.publisher(exchange=exchange, routing_key=routing_key)
171
+
172
+ def include_router(self, router: RabbitRouter, prefix: str = "") -> None:
173
+ """Include routes from a RabbitRouter."""
174
+ self._registry.include_router(router, prefix=prefix)
175
+
176
+ # ── Lifecycle ─────────────────────────────────────────────────────────
177
+
178
+ def start(self) -> None:
179
+ """Start the test broker. Records topology declarations."""
180
+ for route in self._registry.routes:
181
+ # Record exchange
182
+ if route.exchange is not None:
183
+ self._exchanges[route.exchange.name] = route.exchange
184
+
185
+ # Record queue
186
+ self._queues[route.queue.name] = route.queue
187
+
188
+ # Record binding
189
+ exchange_name = route.exchange.name if route.exchange else ""
190
+ self._bindings.append((route.queue.name, exchange_name, route.queue.routing_key))
191
+
192
+ # Attach mock to handler
193
+ if not hasattr(route.handler, "mock"):
194
+ route.handler.mock = MagicMock() # type: ignore[attr-defined]
195
+
196
+ # Mirror the real broker: install RetryMiddleware on retry-enabled routes
197
+ # so retry=RetryConfig(...) actually routes failures to the delay queues.
198
+ # Retry publishes are captured in ``_published`` (routing_key
199
+ # ``<queue>.retry.<n>``) so tests can assert on them.
200
+ self._wire_retry_middleware()
201
+
202
+ self._started = True
203
+
204
+ def _wire_retry_middleware(self) -> None:
205
+ """Install ``RetryMiddleware`` on retry-enabled routes (mirrors the real broker).
206
+
207
+ Idempotent — see ``SyncBroker._wire_retry_middleware`` for the insertion
208
+ position rationale (outer of ordinary middlewares, inner of any
209
+ ``ExceptionMiddleware``).
210
+ """
211
+ from rabbitkit.middleware.metrics import MetricsMiddleware
212
+ from rabbitkit.middleware.retry import (
213
+ RetryMiddleware,
214
+ retry_middleware_insertion_index,
215
+ warn_retry_middleware_without_topology,
216
+ )
217
+
218
+ for route in self._registry.routes:
219
+ retry_config = route.effective_retry_config()
220
+ has_retry_mw = any(isinstance(mw, RetryMiddleware) for mw in route.route_middlewares)
221
+ if retry_config is None:
222
+ if has_retry_mw:
223
+ warn_retry_middleware_without_topology(route.name)
224
+ continue
225
+ if has_retry_mw:
226
+ continue
227
+ index = retry_middleware_insertion_index(route.route_middlewares)
228
+ # M2: mirror the real brokers -- wire in a route MetricsMiddleware
229
+ # (if any) so messages_retried_total/dead_lettered_total are
230
+ # observable through TestBroker too.
231
+ metrics_mw = next(
232
+ (mw for mw in route.route_middlewares if isinstance(mw, MetricsMiddleware)), None
233
+ )
234
+ route.route_middlewares.insert(
235
+ index,
236
+ RetryMiddleware(
237
+ retry_config,
238
+ publish_fn=self._retry_publish_sync,
239
+ publish_async_fn=self._retry_publish_async,
240
+ metrics_collector=metrics_mw.collector if metrics_mw else None,
241
+ metrics_config=metrics_mw.config if metrics_mw else None,
242
+ ),
243
+ )
244
+ self._pipeline.clear_caches()
245
+
246
+ def _retry_publish_sync(self, envelope: MessageEnvelope) -> PublishOutcome:
247
+ """Capture a retry (delay-queue) publish; honors injected outcomes."""
248
+ self._published.append(envelope)
249
+ return self._next_publish_outcome(envelope)
250
+
251
+ async def _retry_publish_async(self, envelope: MessageEnvelope) -> PublishOutcome:
252
+ """Async variant of :meth:`_retry_publish_sync`."""
253
+ self._published.append(envelope)
254
+ return self._next_publish_outcome(envelope)
255
+
256
+ def stop(self) -> None:
257
+ """Stop the test broker."""
258
+ self._started = False
259
+
260
+ def reset(self) -> None:
261
+ """Reset all captured state (published messages, settlements, mocks)."""
262
+ self._published.clear()
263
+ self._consumed.clear()
264
+ self._settlements.clear()
265
+ # One-shot failure state is reset too, so a fresh publish succeeds.
266
+ self._fail_next = False
267
+
268
+ for route in self._registry.routes:
269
+ if hasattr(route.handler, "mock"):
270
+ route.handler.mock.reset_mock()
271
+
272
+ # ── Publish outcome injection ─────────────────────────────────────────
273
+
274
+ @property
275
+ def publish_outcome(self) -> PublishOutcome | None:
276
+ """The persistent publish outcome override (None = CONFIRMED)."""
277
+ return self._publish_outcome
278
+
279
+ @publish_outcome.setter
280
+ def publish_outcome(self, outcome: PublishOutcome | None) -> None:
281
+ self._publish_outcome = outcome
282
+
283
+ def fail_next_publish(self) -> None:
284
+ """Make the next handler-result publish return a NACKED outcome.
285
+
286
+ One-shot: only the next publish is affected; subsequent publishes
287
+ return to CONFIRMED (or the persistent ``publish_outcome``).
288
+ """
289
+ self._fail_next = True
290
+
291
+ def _next_publish_outcome(self, envelope: MessageEnvelope) -> PublishOutcome:
292
+ """Compute the outcome for a publish, honoring injection state."""
293
+ if self._fail_next:
294
+ self._fail_next = False
295
+ return PublishOutcome(
296
+ status=PublishStatus.NACKED,
297
+ exchange=envelope.exchange,
298
+ routing_key=envelope.routing_key,
299
+ )
300
+ if self._publish_outcome is not None:
301
+ return self._publish_outcome
302
+ return PublishOutcome(status=PublishStatus.CONFIRMED)
303
+
304
+ # ── Settlement wiring (real — records transport-level calls) ──────────
305
+
306
+ def _wire_settlement(self, message: RabbitMessage) -> None:
307
+ """Attach *real* settlement functions to ``message``.
308
+
309
+ These are the transport-level stubs that ``RabbitMessage.ack()`` /
310
+ ``nack()`` / ``reject()`` (and the async variants) call internally.
311
+ They record the call (including ``requeue``) so the assert helpers
312
+ can verify both disposition and requeue. They succeed by default —
313
+ for ack-failure propagation tests, build a ``RabbitMessage`` with a
314
+ raising ``_ack_fn`` directly (see ``tests/unit/core/test_message.py``).
315
+ """
316
+ records: list[SettlementRecord] = []
317
+ self._settlements[id(message)] = records
318
+
319
+ def ack_fn() -> None:
320
+ records.append(SettlementRecord(kind="ack", requeue=None))
321
+
322
+ def nack_fn(requeue: bool = True) -> None:
323
+ records.append(SettlementRecord(kind="nack", requeue=requeue))
324
+
325
+ def reject_fn(requeue: bool = False) -> None:
326
+ records.append(SettlementRecord(kind="reject", requeue=requeue))
327
+
328
+ async def async_ack() -> None:
329
+ records.append(SettlementRecord(kind="ack", requeue=None))
330
+
331
+ async def async_nack(requeue: bool = True) -> None:
332
+ records.append(SettlementRecord(kind="nack", requeue=requeue))
333
+
334
+ async def async_reject(requeue: bool = False) -> None:
335
+ records.append(SettlementRecord(kind="reject", requeue=requeue))
336
+
337
+ message._ack_fn = ack_fn
338
+ message._nack_fn = nack_fn
339
+ message._reject_fn = reject_fn
340
+ message._ack_async_fn = async_ack
341
+ message._nack_async_fn = async_nack
342
+ message._reject_async_fn = async_reject
343
+
344
+ # ── Publish (test helper) ─────────────────────────────────────────────
345
+
346
+ def publish(
347
+ self,
348
+ queue: str,
349
+ body: bytes,
350
+ *,
351
+ headers: dict[str, Any] | None = None,
352
+ routing_key: str = "",
353
+ exchange: str = "",
354
+ message_id: str | None = None,
355
+ correlation_id: str | None = None,
356
+ reply_to: str | None = None,
357
+ content_type: str | None = None,
358
+ content_encoding: str | None = None,
359
+ ) -> None:
360
+ """Publish a message to a queue for processing.
361
+
362
+ Finds the matching route and processes the message through the pipeline.
363
+ This is the primary test helper — call this to trigger handler execution.
364
+
365
+ Args:
366
+ queue: Target queue name (must match a registered subscriber).
367
+ body: Raw message body.
368
+ headers: Message headers.
369
+ routing_key: Routing key (defaults to "").
370
+ exchange: Exchange name (defaults to "").
371
+ message_id: Message ID.
372
+ correlation_id: Correlation ID.
373
+ reply_to: Reply-to queue.
374
+ content_type: Content type.
375
+ content_encoding: Content encoding.
376
+ """
377
+ route = self._find_route_by_queue(queue)
378
+ if route is None:
379
+ raise ValueError(f"No subscriber registered for queue '{queue}'")
380
+
381
+ # Build RabbitMessage
382
+ message = RabbitMessage(
383
+ body=body,
384
+ headers=headers or {},
385
+ routing_key=routing_key or route.queue.routing_key,
386
+ exchange=exchange or (route.exchange.name if route.exchange else ""),
387
+ message_id=message_id,
388
+ correlation_id=correlation_id,
389
+ reply_to=reply_to,
390
+ content_type=content_type,
391
+ content_encoding=content_encoding,
392
+ )
393
+
394
+ # Wire real settlement (records ack/nack/reject, updates disposition)
395
+ self._wire_settlement(message)
396
+
397
+ # Mirror the real broker: stamp the source queue for retry routing.
398
+ if "x-rabbitkit-original-queue" not in message.headers:
399
+ message.headers["x-rabbitkit-original-queue"] = route.queue.name
400
+
401
+ message.path = extract_path(message.routing_key, route.queue.routing_key)
402
+ self._consumed.append(message)
403
+
404
+ # Process through pipeline — publish outcome is injectable so the
405
+ # failed-publish → nack(requeue=True) branch is reachable.
406
+ def test_publish_fn(envelope: MessageEnvelope) -> PublishOutcome:
407
+ self._published.append(envelope)
408
+ return self._next_publish_outcome(envelope)
409
+
410
+ self._pipeline.process_sync(route, message, publish_fn=test_publish_fn)
411
+
412
+ # Record mock call
413
+ if hasattr(route.handler, "mock"):
414
+ route.handler.mock(body)
415
+
416
+ async def publish_async(
417
+ self,
418
+ queue: str,
419
+ body: bytes,
420
+ *,
421
+ headers: dict[str, Any] | None = None,
422
+ routing_key: str = "",
423
+ exchange: str = "",
424
+ message_id: str | None = None,
425
+ correlation_id: str | None = None,
426
+ reply_to: str | None = None,
427
+ content_type: str | None = None,
428
+ content_encoding: str | None = None,
429
+ ) -> None:
430
+ """Async variant of publish."""
431
+ route = self._find_route_by_queue(queue)
432
+ if route is None:
433
+ raise ValueError(f"No subscriber registered for queue '{queue}'")
434
+
435
+ message = RabbitMessage(
436
+ body=body,
437
+ headers=headers or {},
438
+ routing_key=routing_key or route.queue.routing_key,
439
+ exchange=exchange or (route.exchange.name if route.exchange else ""),
440
+ message_id=message_id,
441
+ correlation_id=correlation_id,
442
+ reply_to=reply_to,
443
+ content_type=content_type,
444
+ content_encoding=content_encoding,
445
+ )
446
+
447
+ # Wire real settlement (async + sync variants)
448
+ self._wire_settlement(message)
449
+
450
+ # Mirror the real broker: stamp the source queue for retry routing.
451
+ if "x-rabbitkit-original-queue" not in message.headers:
452
+ message.headers["x-rabbitkit-original-queue"] = route.queue.name
453
+
454
+ message.path = extract_path(message.routing_key, route.queue.routing_key)
455
+ self._consumed.append(message)
456
+
457
+ async def test_publish_fn(envelope: MessageEnvelope) -> PublishOutcome:
458
+ self._published.append(envelope)
459
+ return self._next_publish_outcome(envelope)
460
+
461
+ await self._pipeline.process_async(route, message, publish_fn=test_publish_fn)
462
+
463
+ if hasattr(route.handler, "mock"):
464
+ route.handler.mock(body)
465
+
466
+ # ── Assertions ────────────────────────────────────────────────────────
467
+
468
+ @property
469
+ def published_messages(self) -> list[MessageEnvelope]:
470
+ """Return all messages published by handlers (result publishing)."""
471
+ return list(self._published)
472
+
473
+ @property
474
+ def consumed_messages(self) -> list[RabbitMessage]:
475
+ """Return all messages consumed during tests."""
476
+ return list(self._consumed)
477
+
478
+ @property
479
+ def routes(self) -> list[RouteDefinition]:
480
+ """Return all registered routes."""
481
+ return self._registry.routes
482
+
483
+ @property
484
+ def declared_exchanges(self) -> dict[str, RabbitExchange]:
485
+ """Return all declared exchanges."""
486
+ return dict(self._exchanges)
487
+
488
+ @property
489
+ def declared_queues(self) -> dict[str, RabbitQueue]:
490
+ """Return all declared queues."""
491
+ return dict(self._queues)
492
+
493
+ def settlements_for(self, message: RabbitMessage) -> list[SettlementRecord]:
494
+ """Return the recorded transport-level settlements for ``message``."""
495
+ return list(self._settlements.get(id(message), []))
496
+
497
+ def assert_acked(self, message: RabbitMessage) -> None:
498
+ """Assert ``message`` was acked (disposition + transport record)."""
499
+ assert message._disposition == "acked", f"expected message acked, got disposition={message._disposition!r}"
500
+ records = self._settlements.get(id(message), [])
501
+ assert any(r.kind == "ack" for r in records), "no transport ack recorded"
502
+
503
+ def assert_nacked(self, message: RabbitMessage, *, requeue: bool = True) -> None:
504
+ """Assert ``message`` was nacked with the given ``requeue`` flag."""
505
+ assert message._disposition == "nacked", f"expected message nacked, got disposition={message._disposition!r}"
506
+ nack_records = [r for r in self._settlements.get(id(message), []) if r.kind == "nack"]
507
+ assert nack_records, "no transport nack recorded"
508
+ last = nack_records[-1]
509
+ assert last.requeue == requeue, f"expected nack requeue={requeue}, got {last.requeue}"
510
+
511
+ def assert_rejected(self, message: RabbitMessage, *, requeue: bool = False) -> None:
512
+ """Assert ``message`` was rejected with the given ``requeue`` flag."""
513
+ assert message._disposition == "rejected", (
514
+ f"expected message rejected, got disposition={message._disposition!r}"
515
+ )
516
+ reject_records = [r for r in self._settlements.get(id(message), []) if r.kind == "reject"]
517
+ assert reject_records, "no transport reject recorded"
518
+ last = reject_records[-1]
519
+ assert last.requeue == requeue, f"expected reject requeue={requeue}, got {last.requeue}"
520
+
521
+ # ── Internal ──────────────────────────────────────────────────────────
522
+
523
+ def _find_route_by_queue(self, queue_name: str) -> RouteDefinition | None:
524
+ """Find the route registered for a given queue name."""
525
+ for route in self._registry.routes:
526
+ if route.queue.name == queue_name:
527
+ return route
528
+ return None
529
+
530
+
531
+ class TestAsyncBroker(TestBroker):
532
+ """Async-flavored alias for :class:`TestBroker`.
533
+
534
+ ``TestBroker`` already supports both sync (``publish``) and async
535
+ (``publish_async``) flows; this subclass exists so tests that want an
536
+ explicitly async-named fixture read naturally and share the same
537
+ injectable-publish-outcome / real-settlement behavior.
538
+ """
539
+
540
+ __test__ = False