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
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