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,212 @@
1
+ """Monitoring dashboard ASGI application.
2
+
3
+ Provides a lightweight, dependency-free HTTP dashboard for monitoring
4
+ a running rabbitkit broker. Built on **Starlette** (optional dependency).
5
+
6
+ Requires: ``pip install rabbitkit[dashboard]``
7
+
8
+ Endpoints
9
+ ---------
10
+ GET / — HTML dashboard with route table and health status
11
+ GET /api/health — JSON health snapshot (status, connected, consumer_count, …)
12
+ GET /api/routes — JSON array of all registered routes
13
+
14
+ Quick start
15
+ -----------
16
+ Mount as a standalone ASGI app with Uvicorn::
17
+
18
+ # myapp/dashboard.py
19
+ from rabbitkit.dashboard import create_dashboard_app
20
+ from myapp.main import broker # your AsyncBroker or SyncBroker
21
+
22
+ app = create_dashboard_app(broker, auth_token="...") # see SECURITY below
23
+
24
+ # Run separately, bound to loopback only by default (M8):
25
+ # uvicorn myapp.dashboard:app --host 127.0.0.1 --port 8080
26
+ #
27
+ # Only bind 0.0.0.0 deliberately, behind a NetworkPolicy/firewall/reverse
28
+ # proxy that terminates auth -- the dashboard exposes full broker
29
+ # topology (queue/exchange/routing-key names, consumer counts).
30
+
31
+ CLI shortcut::
32
+
33
+ rabbitkit dashboard myapp.main:broker --port 8080
34
+
35
+ Mount inside an existing FastAPI / Starlette app::
36
+
37
+ from fastapi import FastAPI
38
+ from starlette.routing import Mount
39
+ from rabbitkit.dashboard import create_dashboard_app
40
+
41
+ api = FastAPI()
42
+ dashboard = create_dashboard_app(broker)
43
+ api.mount("/rabbit", dashboard)
44
+
45
+ With Management API integration (adds live queue stats)::
46
+
47
+ from rabbitkit.management import RabbitManagementClient, ManagementConfig
48
+ from rabbitkit.dashboard import create_dashboard_app
49
+
50
+ mgmt = RabbitManagementClient(
51
+ ManagementConfig(url="http://rabbitmq:15672", username="admin", password="secret")
52
+ )
53
+
54
+ With MetricsCollector (adds throughput / latency metrics from MetricsMiddleware)::
55
+
56
+ from rabbitkit.middleware.metrics import MetricsCollector
57
+ from rabbitkit.dashboard import create_dashboard_app
58
+
59
+ collector = MetricsCollector()
60
+
61
+ Health status values
62
+ --------------------
63
+ ``GET /api/health`` returns:
64
+
65
+ {
66
+ "status": "healthy" | "degraded" | "unhealthy",
67
+ "started": true,
68
+ "connected": true,
69
+ "consumer_count": 3,
70
+ "route_count": 3
71
+ }
72
+ """
73
+
74
+ from __future__ import annotations
75
+
76
+ import logging
77
+ from html import escape
78
+ from typing import Any
79
+
80
+ logger = logging.getLogger(__name__)
81
+
82
+
83
+ def create_dashboard_app(
84
+ broker: Any,
85
+ *,
86
+ auth_token: str | None = None,
87
+ ) -> Any:
88
+ """Create an ASGI dashboard application.
89
+
90
+ SECURITY (M8): by default this app has NO authentication and exposes
91
+ broker topology (queue/exchange/routing-key names, consumer counts).
92
+ Bind the server to loopback (``127.0.0.1``, not ``0.0.0.0``) by default,
93
+ and mount it behind authn (OIDC/reverse proxy) plus network restriction
94
+ for anything beyond local access — never expose it publicly.
95
+
96
+ For a lightweight built-in guard, pass ``auth_token``: when set, every
97
+ route requires an ``Authorization: Bearer <auth_token>`` header
98
+ (compared in constant time via ``hmac.compare_digest``, not a plain
99
+ string ``!=``, which would leak timing information about the token) and
100
+ returns ``401`` otherwise. When unset (default), all requests pass
101
+ through and a startup warning is logged reminding you not to expose the
102
+ dashboard publicly.
103
+
104
+ Args:
105
+ broker: A rabbitkit broker instance (SyncBroker or AsyncBroker).
106
+ auth_token: Optional bearer token. When set, all routes require
107
+ ``Authorization: Bearer <auth_token>``. When None, the dashboard
108
+ runs unauthenticated (a startup warning is emitted).
109
+
110
+ Returns:
111
+ A Starlette application.
112
+
113
+ Raises:
114
+ ImportError: If starlette is not installed.
115
+ """
116
+ import hmac
117
+
118
+ try:
119
+ from starlette.applications import Starlette
120
+ from starlette.middleware.base import BaseHTTPMiddleware
121
+ from starlette.requests import Request # noqa: TC002 # lazy optional import
122
+ from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
123
+ from starlette.routing import Route
124
+ except ImportError: # pragma: no cover
125
+ raise ImportError( # pragma: no cover
126
+ "Dashboard requires starlette. Install with: pip install rabbitkit[dashboard]"
127
+ ) from None
128
+
129
+ from rabbitkit.health import broker_health_check
130
+
131
+ if auth_token is None:
132
+ logger.warning("Dashboard running WITHOUT authentication — do not expose publicly")
133
+
134
+ class _BearerAuthMiddleware(BaseHTTPMiddleware):
135
+ async def dispatch(self, request: Request, call_next: Any) -> Response:
136
+ auth = request.headers.get("Authorization", "")
137
+ expected = f"Bearer {auth_token}"
138
+ # M8: hmac.compare_digest instead of != -- a plain string
139
+ # comparison short-circuits on the first mismatched byte, leaking
140
+ # the token's length (and, over many requests, its prefix) via a
141
+ # timing side channel. compare_digest runs in constant time
142
+ # regardless of where the mismatch is.
143
+ if not hmac.compare_digest(auth, expected):
144
+ return PlainTextResponse("Unauthorized", status_code=401)
145
+ return await call_next(request) # type: ignore[no-any-return]
146
+
147
+ async def index(request: Any) -> Any:
148
+ routes_count = len(broker.routes)
149
+ health = broker_health_check(broker)
150
+ html = f"""<!DOCTYPE html>
151
+ <html><head><title>rabbitkit Dashboard</title>
152
+ <style>
153
+ body {{ font-family: system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }}
154
+ h1 {{ color: #333; }}
155
+ table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
156
+ th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
157
+ th {{ background: #f5f5f5; }}
158
+ .healthy {{ color: green; }} .degraded {{ color: orange; }} .unhealthy {{ color: red; }}
159
+ </style></head>
160
+ <body>
161
+ <h1>rabbitkit Dashboard</h1>
162
+ <h2>Health: <span class="{health.status.value}">{health.status.value.upper()}</span></h2>
163
+ <p>Routes: {routes_count} | Connected: {health.connected} | Consumers: {health.consumer_count}</p>
164
+ <h2>Routes</h2>
165
+ <table><tr><th>Name</th><th>Queue</th><th>Exchange</th><th>Ack Policy</th></tr>"""
166
+ for r in broker.routes:
167
+ exchange = r.exchange.name if r.exchange else ""
168
+ # escape() — queue/exchange/route names render into HTML; never trust them raw
169
+ html += (
170
+ f"<tr><td>{escape(r.name)}</td><td>{escape(r.queue.name)}</td>"
171
+ f"<td>{escape(exchange)}</td><td>{escape(r.ack_policy.value)}</td></tr>"
172
+ )
173
+ html += "</table></body></html>"
174
+ return HTMLResponse(html)
175
+
176
+ async def api_health(request: Any) -> Any:
177
+ health = broker_health_check(broker)
178
+ return JSONResponse(
179
+ {
180
+ "status": health.status.value,
181
+ "started": health.started,
182
+ "connected": health.connected,
183
+ "consumer_count": health.consumer_count,
184
+ "route_count": health.route_count,
185
+ }
186
+ )
187
+
188
+ async def api_routes(request: Any) -> Any:
189
+ routes = []
190
+ for r in broker.routes:
191
+ routes.append(
192
+ {
193
+ "name": r.name,
194
+ "queue": r.queue.name,
195
+ "exchange": r.exchange.name if r.exchange else "",
196
+ "ack_policy": r.ack_policy.value,
197
+ "tags": sorted(r.tags) if r.tags else [],
198
+ "description": r.description,
199
+ }
200
+ )
201
+ return JSONResponse(routes)
202
+
203
+ app = Starlette(
204
+ routes=[
205
+ Route("/", index),
206
+ Route("/api/health", api_health),
207
+ Route("/api/routes", api_routes),
208
+ ],
209
+ )
210
+ if auth_token is not None:
211
+ app.add_middleware(_BearerAuthMiddleware)
212
+ return app
@@ -0,0 +1,19 @@
1
+ """Dependency injection module — Depends, Context, Header, Path, DIResolver.
2
+
3
+ Public API re-exported here per the project convention that each package's
4
+ ``__init__.py`` re-exports its public symbols.
5
+ """
6
+
7
+ from rabbitkit.di.context import Context, ContextRepo, Header, Path
8
+ from rabbitkit.di.depends import Depends
9
+ from rabbitkit.di.resolver import DependencyScope, DIResolver
10
+
11
+ __all__ = [
12
+ "Context",
13
+ "ContextRepo",
14
+ "DIResolver",
15
+ "DependencyScope",
16
+ "Depends",
17
+ "Header",
18
+ "Path",
19
+ ]
@@ -0,0 +1,193 @@
1
+ """Context, Header, and Path markers + ContextRepo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ from typing import Any
7
+
8
+
9
+ class _MissingSentinel:
10
+ """Distinguishes "no default given" from an explicit ``default=None``
11
+ (H10) — a plain ``None`` default would be indistinguishable from "not
12
+ provided" otherwise."""
13
+
14
+ __slots__ = ()
15
+
16
+ def __repr__(self) -> str:
17
+ return "<no default>"
18
+
19
+
20
+ _MISSING = _MissingSentinel()
21
+
22
+
23
+ class Context:
24
+ """Marker for context value injection.
25
+
26
+ Usage:
27
+ @broker.subscriber(queue="orders")
28
+ async def handle(
29
+ order: Order,
30
+ app_name: Annotated[str, Context("app")],
31
+ ) -> None:
32
+ ...
33
+
34
+ H10 — optional values: pass ``default=`` to make a missing context key
35
+ resolve to that value instead of raising, or simply give the parameter
36
+ itself a Python default (``app_name: Annotated[str | None, Context("app")]
37
+ = None``) — the resolver falls back to the parameter default when the
38
+ marker has none. With neither, a missing key raises
39
+ ``MissingDependencyError`` (PERMANENT — settles straight to the DLQ,
40
+ matching the ``KeyError`` this replaces).
41
+ """
42
+
43
+ def __init__(self, key: str, *, default: Any = _MISSING) -> None:
44
+ self.key = key
45
+ self.default = default
46
+
47
+ @property
48
+ def has_default(self) -> bool:
49
+ return self.default is not _MISSING
50
+
51
+ def __repr__(self) -> str:
52
+ return f"Context({self.key!r})"
53
+
54
+ def __eq__(self, other: object) -> bool:
55
+ if not isinstance(other, Context):
56
+ return NotImplemented
57
+ return self.key == other.key and self.default == other.default
58
+
59
+ def __hash__(self) -> int:
60
+ return hash(("Context", self.key))
61
+
62
+
63
+ class Header:
64
+ """Marker for AMQP header extraction.
65
+
66
+ Usage:
67
+ @broker.subscriber(queue="orders")
68
+ async def handle(
69
+ order: Order,
70
+ tenant: Annotated[str, Header("x-tenant")],
71
+ ) -> None:
72
+ ...
73
+
74
+ H10 — optional values: pass ``default=`` to make a missing header
75
+ resolve to that value instead of raising, or simply give the parameter
76
+ itself a Python default (``tenant: Annotated[str | None,
77
+ Header("x-tenant")] = None``) — the resolver falls back to the parameter
78
+ default when the marker has none. With neither, a missing header raises
79
+ ``MissingDependencyError`` (PERMANENT — settles straight to the DLQ,
80
+ matching the ``KeyError`` this replaces).
81
+ """
82
+
83
+ def __init__(self, name: str, *, default: Any = _MISSING) -> None:
84
+ self.name = name
85
+ self.default = default
86
+
87
+ @property
88
+ def has_default(self) -> bool:
89
+ return self.default is not _MISSING
90
+
91
+ def __repr__(self) -> str:
92
+ return f"Header({self.name!r})"
93
+
94
+ def __eq__(self, other: object) -> bool:
95
+ if not isinstance(other, Header):
96
+ return NotImplemented
97
+ return self.name == other.name and self.default == other.default
98
+
99
+ def __hash__(self) -> int:
100
+ return hash(("Header", self.name))
101
+
102
+
103
+ class Path:
104
+ """Marker for topic wildcard segment extraction.
105
+
106
+ Usage:
107
+ @broker.subscriber(queue="events", routing_key="events.*.#")
108
+ async def handle(
109
+ event: Event,
110
+ level: Annotated[str, Path("level")],
111
+ ) -> None:
112
+ ...
113
+
114
+ H10 — optional values: pass ``default=`` to make a missing path segment
115
+ resolve to that value instead of raising, or simply give the parameter
116
+ itself a Python default (``level: Annotated[str | None, Path("level")]
117
+ = None``) — the resolver falls back to the parameter default when the
118
+ marker has none. With neither, a missing segment raises
119
+ ``MissingDependencyError`` (PERMANENT — settles straight to the DLQ,
120
+ matching the ``KeyError`` this replaces).
121
+ """
122
+
123
+ def __init__(self, segment: str, *, default: Any = _MISSING) -> None:
124
+ self.segment = segment
125
+ self.default = default
126
+
127
+ @property
128
+ def has_default(self) -> bool:
129
+ return self.default is not _MISSING
130
+
131
+ def __repr__(self) -> str:
132
+ return f"Path({self.segment!r})"
133
+
134
+ def __eq__(self, other: object) -> bool:
135
+ if not isinstance(other, Path):
136
+ return NotImplemented
137
+ return self.segment == other.segment and self.default == other.default
138
+
139
+ def __hash__(self) -> int:
140
+ return hash(("Path", self.segment))
141
+
142
+
143
+ class ContextRepo:
144
+ """Context repository for global and per-request values.
145
+
146
+ Global values are shared across all messages (thread-safe via a lock).
147
+ Local values use ``contextvars.ContextVar`` for correct isolation across
148
+ both sync threads AND async coroutines on the same event loop —
149
+ ``threading.local()`` would bleed context between concurrent coroutines
150
+ sharing one OS thread in an async transport.
151
+ """
152
+
153
+ def __init__(self) -> None:
154
+ self._global: dict[str, Any] = {}
155
+ self._local: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar("rabbitkit_local_ctx")
156
+
157
+ def set_global(self, key: str, value: Any) -> None:
158
+ """Set a global context value (shared across all messages)."""
159
+ self._global[key] = value
160
+
161
+ def set_local(self, key: str, value: Any) -> None:
162
+ """Set a per-request context value.
163
+
164
+ Uses ``ContextVar.set`` with an immutable copy so that each
165
+ coroutine/task gets its own isolated snapshot (contextvars
166
+ copy-on-write semantics).
167
+ """
168
+ try:
169
+ current = self._local.get()
170
+ except LookupError:
171
+ current = {}
172
+ self._local.set({**current, key: value})
173
+
174
+ def get(self, key: str, default: Any = None) -> Any:
175
+ """Get a context value. Local overrides global."""
176
+ try:
177
+ local = self._local.get()
178
+ except LookupError:
179
+ local = {}
180
+ if key in local:
181
+ return local[key]
182
+ return self._global.get(key, default)
183
+
184
+ def clear_local(self) -> None:
185
+ """Clear per-request context (called after each message)."""
186
+ self._local.set({})
187
+
188
+ def has(self, key: str) -> bool:
189
+ """Check if a key exists in either local or global context."""
190
+ try:
191
+ return key in self._local.get() or key in self._global
192
+ except LookupError:
193
+ return key in self._global
@@ -0,0 +1,42 @@
1
+ """Depends marker — dependency injection for handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+
9
+ class Depends:
10
+ """Marker for dependency injection.
11
+
12
+ Usage:
13
+ async def get_db() -> Session:
14
+ return Session()
15
+
16
+ @broker.subscriber(queue="orders")
17
+ async def handle(
18
+ order: Order,
19
+ db: Annotated[Session, Depends(get_db)],
20
+ ) -> None:
21
+ ...
22
+
23
+ Per-message lifetime: a fresh dependency graph is resolved for each
24
+ message. A generator (or async generator) dependency is supported too —
25
+ yield the value; the code after ``yield`` runs as teardown once the
26
+ handler completes (see :class:`~rabbitkit.di.resolver.DependencyScope`).
27
+ """
28
+
29
+ def __init__(self, dependency: Callable[..., Any], *, use_cache: bool = True) -> None:
30
+ self.dependency = dependency
31
+ self.use_cache = use_cache
32
+
33
+ def __repr__(self) -> str:
34
+ return f"Depends({self.dependency.__qualname__}, use_cache={self.use_cache})"
35
+
36
+ def __eq__(self, other: object) -> bool:
37
+ if not isinstance(other, Depends):
38
+ return NotImplemented
39
+ return self.dependency is other.dependency and self.use_cache == other.use_cache
40
+
41
+ def __hash__(self) -> int:
42
+ return hash((id(self.dependency), self.use_cache))