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,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
|
rabbitkit/di/__init__.py
ADDED
|
@@ -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
|
+
]
|
rabbitkit/di/context.py
ADDED
|
@@ -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
|
rabbitkit/di/depends.py
ADDED
|
@@ -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))
|