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,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"]
|
rabbitkit/testing/app.py
ADDED
|
@@ -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
|