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
rabbitkit/core/app.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Application lifecycle manager.
|
|
2
|
+
|
|
3
|
+
Lifecycle: on_startup → after_startup → [run] → on_shutdown → after_shutdown
|
|
4
|
+
Signal handling: SIGINT/SIGTERM → graceful shutdown
|
|
5
|
+
Idempotent: start() twice is safe (no-op if already running)
|
|
6
|
+
Startup failure rollback: if any startup hook fails → on_shutdown still called
|
|
7
|
+
State tracking: IDLE → STARTING → RUNNING → STOPPING → STOPPED
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import signal
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from rabbitkit.core.types import AppState
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# ``AppState`` is re-exported here for backwards compatibility; its canonical home
|
|
24
|
+
# is ``rabbitkit.core.types`` (single canonical location for all enums).
|
|
25
|
+
__all__ = ["AppState", "RabbitApp"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RabbitApp:
|
|
29
|
+
"""Application lifecycle manager.
|
|
30
|
+
|
|
31
|
+
Manages startup/shutdown hooks and signal handling.
|
|
32
|
+
Idempotent: start() twice is safe (no-op if already running).
|
|
33
|
+
Startup failure rollback: if any startup hook fails → on_shutdown still called.
|
|
34
|
+
|
|
35
|
+
``startup_timeout`` bounds each startup/after-startup hook so a hung hook
|
|
36
|
+
fails fast instead of hanging until SIGKILL. The default (``120.0``) is
|
|
37
|
+
finite; pass ``None`` explicitly to disable the bound.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, title: str = "rabbitkit", *, startup_timeout: float | None = 120.0) -> None:
|
|
41
|
+
self._title = title
|
|
42
|
+
self._state = AppState.IDLE
|
|
43
|
+
self._startup_timeout = startup_timeout
|
|
44
|
+
|
|
45
|
+
# Lifecycle hooks
|
|
46
|
+
self._on_startup: list[Callable[[], Any]] = []
|
|
47
|
+
self._after_startup: list[Callable[[], Any]] = []
|
|
48
|
+
self._on_shutdown: list[Callable[[], Any]] = []
|
|
49
|
+
self._after_shutdown: list[Callable[[], Any]] = []
|
|
50
|
+
|
|
51
|
+
# Concurrency guards: prevent double-start / concurrent stop races.
|
|
52
|
+
# The async lock is created eagerly — modern asyncio binds it to the
|
|
53
|
+
# running loop on first use, so creating it without a loop is safe and
|
|
54
|
+
# avoids a lazy-create race in concurrent start_async/stop_async calls.
|
|
55
|
+
self._sync_lock = threading.Lock()
|
|
56
|
+
self._async_lock = asyncio.Lock()
|
|
57
|
+
|
|
58
|
+
# Signal handling
|
|
59
|
+
self._shutdown_event: asyncio.Event | None = None
|
|
60
|
+
self._original_handlers: dict[signal.Signals, Any] = {}
|
|
61
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def state(self) -> AppState:
|
|
65
|
+
return self._state
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def title(self) -> str:
|
|
69
|
+
return self._title
|
|
70
|
+
|
|
71
|
+
# ── Hook registration ────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def on_startup(self, func: Callable[[], Any]) -> Callable[[], Any]:
|
|
74
|
+
"""Register a startup hook (decorator or direct call)."""
|
|
75
|
+
self._on_startup.append(func)
|
|
76
|
+
return func
|
|
77
|
+
|
|
78
|
+
def after_startup(self, func: Callable[[], Any]) -> Callable[[], Any]:
|
|
79
|
+
"""Register a post-startup hook."""
|
|
80
|
+
self._after_startup.append(func)
|
|
81
|
+
return func
|
|
82
|
+
|
|
83
|
+
def on_shutdown(self, func: Callable[[], Any]) -> Callable[[], Any]:
|
|
84
|
+
"""Register a shutdown hook."""
|
|
85
|
+
self._on_shutdown.append(func)
|
|
86
|
+
return func
|
|
87
|
+
|
|
88
|
+
def after_shutdown(self, func: Callable[[], Any]) -> Callable[[], Any]:
|
|
89
|
+
"""Register a post-shutdown hook."""
|
|
90
|
+
self._after_shutdown.append(func)
|
|
91
|
+
return func
|
|
92
|
+
|
|
93
|
+
# ── Sync lifecycle ───────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def start(self) -> None:
|
|
96
|
+
"""Start the application (sync).
|
|
97
|
+
|
|
98
|
+
Idempotent and concurrency-safe — no-op if already running.
|
|
99
|
+
On startup hook failure → on_shutdown is still called.
|
|
100
|
+
"""
|
|
101
|
+
with self._sync_lock:
|
|
102
|
+
if self._state in (AppState.RUNNING, AppState.STARTING, AppState.STOPPING):
|
|
103
|
+
logger.debug("App already %s, ignoring start()", self._state.value)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
self._state = AppState.STARTING
|
|
107
|
+
logger.info("Starting %s", self._title)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
self._run_hooks_sync(self._on_startup, timeout=self._startup_timeout)
|
|
111
|
+
self._state = AppState.RUNNING
|
|
112
|
+
self._run_hooks_sync(self._after_startup, timeout=self._startup_timeout)
|
|
113
|
+
logger.info("%s started", self._title)
|
|
114
|
+
except Exception:
|
|
115
|
+
logger.exception("Startup failed, running shutdown hooks")
|
|
116
|
+
self._state = AppState.STOPPING
|
|
117
|
+
self._run_hooks_sync(self._on_shutdown)
|
|
118
|
+
self._state = AppState.STOPPED
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
def stop(self) -> None:
|
|
122
|
+
"""Stop the application (sync).
|
|
123
|
+
|
|
124
|
+
Idempotent and concurrency-safe — no-op if already stopped.
|
|
125
|
+
"""
|
|
126
|
+
with self._sync_lock:
|
|
127
|
+
if self._state in (AppState.STOPPED, AppState.STOPPING, AppState.IDLE):
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
self._state = AppState.STOPPING
|
|
131
|
+
logger.info("Stopping %s", self._title)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
self._run_hooks_sync(self._on_shutdown)
|
|
135
|
+
finally:
|
|
136
|
+
self._run_hooks_sync(self._after_shutdown)
|
|
137
|
+
self._state = AppState.STOPPED
|
|
138
|
+
logger.info("%s stopped", self._title)
|
|
139
|
+
|
|
140
|
+
# ── Async lifecycle ──────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
async def start_async(self) -> None:
|
|
143
|
+
"""Start the application (async).
|
|
144
|
+
|
|
145
|
+
Idempotent and concurrency-safe — no-op if already running.
|
|
146
|
+
On startup hook failure → on_shutdown is still called.
|
|
147
|
+
"""
|
|
148
|
+
async with self._async_lock:
|
|
149
|
+
if self._state in (AppState.RUNNING, AppState.STARTING, AppState.STOPPING):
|
|
150
|
+
logger.debug("App already %s, ignoring start_async()", self._state.value)
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
self._state = AppState.STARTING
|
|
154
|
+
logger.info("Starting %s", self._title)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
await self._run_hooks_async(self._on_startup, timeout=self._startup_timeout)
|
|
158
|
+
self._state = AppState.RUNNING
|
|
159
|
+
await self._run_hooks_async(self._after_startup, timeout=self._startup_timeout)
|
|
160
|
+
logger.info("%s started", self._title)
|
|
161
|
+
except Exception:
|
|
162
|
+
logger.exception("Startup failed, running shutdown hooks")
|
|
163
|
+
self._state = AppState.STOPPING
|
|
164
|
+
await self._run_hooks_async(self._on_shutdown)
|
|
165
|
+
self._state = AppState.STOPPED
|
|
166
|
+
raise
|
|
167
|
+
|
|
168
|
+
async def stop_async(self) -> None:
|
|
169
|
+
"""Stop the application (async).
|
|
170
|
+
|
|
171
|
+
Idempotent and concurrency-safe — no-op if already stopped.
|
|
172
|
+
"""
|
|
173
|
+
async with self._async_lock:
|
|
174
|
+
if self._state in (AppState.STOPPED, AppState.STOPPING, AppState.IDLE):
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
self._state = AppState.STOPPING
|
|
178
|
+
logger.info("Stopping %s", self._title)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
await self._run_hooks_async(self._on_shutdown)
|
|
182
|
+
finally:
|
|
183
|
+
await self._run_hooks_async(self._after_shutdown)
|
|
184
|
+
self._state = AppState.STOPPED
|
|
185
|
+
logger.info("%s stopped", self._title)
|
|
186
|
+
|
|
187
|
+
async def run_async(self) -> None:
|
|
188
|
+
"""Start, wait for shutdown signal, then stop (async).
|
|
189
|
+
|
|
190
|
+
Installs SIGINT/SIGTERM handlers for graceful shutdown. Falls back to
|
|
191
|
+
``signal.signal`` on platforms/threads where ``loop.add_signal_handler``
|
|
192
|
+
is unavailable (e.g. Windows, non-main-thread).
|
|
193
|
+
"""
|
|
194
|
+
self._shutdown_event = asyncio.Event()
|
|
195
|
+
|
|
196
|
+
loop = asyncio.get_running_loop()
|
|
197
|
+
self._loop = loop
|
|
198
|
+
installed = False
|
|
199
|
+
try:
|
|
200
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
201
|
+
loop.add_signal_handler(sig, self._signal_handler)
|
|
202
|
+
installed = True
|
|
203
|
+
except (NotImplementedError, RuntimeError, ValueError): # pragma: no cover
|
|
204
|
+
# Not supported on this platform/thread — fall back to signal.signal.
|
|
205
|
+
# The handler runs in a signal context; schedule the set via the loop.
|
|
206
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
207
|
+
try:
|
|
208
|
+
self._original_handlers[sig] = signal.signal(sig, self._signal_handler_sync)
|
|
209
|
+
except (ValueError, OSError): # pragma: no cover
|
|
210
|
+
pass # not in main thread — best effort
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
await self.start_async()
|
|
214
|
+
await self._shutdown_event.wait()
|
|
215
|
+
finally:
|
|
216
|
+
await self.stop_async()
|
|
217
|
+
# Restore signal handlers
|
|
218
|
+
if installed:
|
|
219
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
220
|
+
try:
|
|
221
|
+
loop.remove_signal_handler(sig)
|
|
222
|
+
except (NotImplementedError, RuntimeError, ValueError): # pragma: no cover
|
|
223
|
+
pass
|
|
224
|
+
else: # pragma: no cover
|
|
225
|
+
for sig, prev in self._original_handlers.items():
|
|
226
|
+
try:
|
|
227
|
+
signal.signal(sig, prev)
|
|
228
|
+
except (ValueError, OSError): # pragma: no cover
|
|
229
|
+
pass
|
|
230
|
+
self._original_handlers.clear()
|
|
231
|
+
|
|
232
|
+
def _signal_handler_sync(self, signum: int, frame: Any) -> None: # pragma: no cover
|
|
233
|
+
"""signal.signal fallback handler — schedule shutdown on the loop."""
|
|
234
|
+
logger.info("Received shutdown signal %d", signum)
|
|
235
|
+
if self._shutdown_event is not None and self._loop is not None:
|
|
236
|
+
self._loop.call_soon_threadsafe(self._shutdown_event.set)
|
|
237
|
+
|
|
238
|
+
def request_shutdown(self) -> None:
|
|
239
|
+
"""Request graceful shutdown (can be called from any context)."""
|
|
240
|
+
if self._shutdown_event is not None:
|
|
241
|
+
self._shutdown_event.set()
|
|
242
|
+
|
|
243
|
+
# ── Internal ─────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
def _signal_handler(self) -> None:
|
|
246
|
+
"""Handle SIGINT/SIGTERM."""
|
|
247
|
+
logger.info("Received shutdown signal")
|
|
248
|
+
self.request_shutdown()
|
|
249
|
+
|
|
250
|
+
def _run_hooks_sync(self, hooks: list[Callable[[], Any]], *, timeout: float | None = None) -> None:
|
|
251
|
+
"""Run hooks synchronously. If ``timeout`` is set, bound each hook.
|
|
252
|
+
|
|
253
|
+
When a timeout is set, each hook runs in a dedicated worker thread so
|
|
254
|
+
the caller is not blocked by a hung hook. NOTE: the worker thread is
|
|
255
|
+
non-daemon, so a truly-uninterruptible hook still lingers until process
|
|
256
|
+
exit; the timeout bounds the *caller* (it receives ``TimeoutError``)
|
|
257
|
+
rather than hanging forever. ``cancel_futures=True`` drops any
|
|
258
|
+
not-yet-started work.
|
|
259
|
+
|
|
260
|
+
The executor is NOT used as a context manager — its ``__exit__`` calls
|
|
261
|
+
``shutdown(wait=True)`` which would block forever on a hung hook.
|
|
262
|
+
"""
|
|
263
|
+
import concurrent.futures
|
|
264
|
+
|
|
265
|
+
for hook in hooks:
|
|
266
|
+
if timeout is None:
|
|
267
|
+
result = hook()
|
|
268
|
+
if asyncio.iscoroutine(result):
|
|
269
|
+
result.close()
|
|
270
|
+
raise TypeError(
|
|
271
|
+
f"Async hook {hook.__qualname__} called in sync context. "
|
|
272
|
+
"Use start_async() or make the hook synchronous."
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
ex = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
276
|
+
try:
|
|
277
|
+
fut = ex.submit(hook)
|
|
278
|
+
try:
|
|
279
|
+
result = fut.result(timeout=timeout)
|
|
280
|
+
except concurrent.futures.TimeoutError as e:
|
|
281
|
+
raise TimeoutError(f"Startup hook {hook.__qualname__} exceeded timeout {timeout}s") from e
|
|
282
|
+
if asyncio.iscoroutine(result):
|
|
283
|
+
raise TypeError(
|
|
284
|
+
f"Async hook {hook.__qualname__} called in sync context. "
|
|
285
|
+
"Use start_async() or make the hook synchronous."
|
|
286
|
+
)
|
|
287
|
+
finally:
|
|
288
|
+
# wait=False so a hung worker thread does not block the caller;
|
|
289
|
+
# cancel_futures=True drops any queued (not-yet-started) work.
|
|
290
|
+
ex.shutdown(wait=False, cancel_futures=True)
|
|
291
|
+
|
|
292
|
+
async def _run_hooks_async(self, hooks: list[Callable[[], Any]], *, timeout: float | None = None) -> None:
|
|
293
|
+
"""Run hooks — supports both sync and async callables. Bounds each hook.
|
|
294
|
+
|
|
295
|
+
With a timeout: coroutine-function hooks are awaited bounded; sync
|
|
296
|
+
hooks run in a worker thread via ``asyncio.to_thread`` bounded (a sync
|
|
297
|
+
hook running inline would be unbounded). If a sync hook returns a
|
|
298
|
+
future, that future is awaited bounded as well.
|
|
299
|
+
"""
|
|
300
|
+
for hook in hooks:
|
|
301
|
+
if timeout is None:
|
|
302
|
+
result = hook()
|
|
303
|
+
if asyncio.iscoroutine(result):
|
|
304
|
+
await result
|
|
305
|
+
elif asyncio.iscoroutinefunction(hook):
|
|
306
|
+
result = hook() # builds the coroutine; body not yet executed
|
|
307
|
+
try:
|
|
308
|
+
async with asyncio.timeout(timeout):
|
|
309
|
+
await result
|
|
310
|
+
except TimeoutError as e:
|
|
311
|
+
raise TimeoutError(f"Startup hook {hook.__qualname__} exceeded timeout {timeout}s") from e
|
|
312
|
+
else:
|
|
313
|
+
# Sync hook — run in a thread so it is bounded by the timeout.
|
|
314
|
+
try:
|
|
315
|
+
result = await asyncio.wait_for(asyncio.to_thread(hook), timeout=timeout)
|
|
316
|
+
except TimeoutError as e:
|
|
317
|
+
raise TimeoutError(f"Startup hook {hook.__qualname__} exceeded timeout {timeout}s") from e
|
|
318
|
+
if asyncio.isfuture(result):
|
|
319
|
+
try:
|
|
320
|
+
async with asyncio.timeout(timeout):
|
|
321
|
+
await result
|
|
322
|
+
except TimeoutError as e:
|
|
323
|
+
raise TimeoutError(f"Startup hook {hook.__qualname__} exceeded timeout {timeout}s") from e
|