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/di/resolver.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""DI Resolver — resolves handler parameters at processing time.
|
|
2
|
+
|
|
3
|
+
See Contract 4 (Parameter Resolution Precedence) for the full rules.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import typing
|
|
12
|
+
from collections.abc import AsyncGenerator, Callable, Generator
|
|
13
|
+
from typing import Annotated, Any, get_args, get_origin
|
|
14
|
+
|
|
15
|
+
from rabbitkit.core.errors import ConfigurationError, MissingDependencyError
|
|
16
|
+
from rabbitkit.core.message import RabbitMessage, is_rabbit_message_annotation
|
|
17
|
+
from rabbitkit.di.context import Context, ContextRepo, Header, Path
|
|
18
|
+
from rabbitkit.di.depends import Depends
|
|
19
|
+
|
|
20
|
+
_logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# L11: textual match on a DI marker CALL, used only once real hint resolution
|
|
23
|
+
# (get_type_hints_with_fallback's three attempts) has already failed and we
|
|
24
|
+
# are left with a raw, unresolved string annotation (PEP 563). See
|
|
25
|
+
# get_type_hints_with_fallback and DIResolver.validate_handler.
|
|
26
|
+
_DI_MARKER_CALL_RE = re.compile(r"\b(?:Depends|Header|Path|Context)\(")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_type_hints_with_fallback(handler: Callable[..., Any]) -> dict[str, Any]:
|
|
30
|
+
"""Resolve ``handler``'s type hints, tolerating ``from __future__ import
|
|
31
|
+
annotations`` forward references that plain ``typing.get_type_hints()``
|
|
32
|
+
can't reach on its own.
|
|
33
|
+
|
|
34
|
+
When annotations are strings (PEP 563), they need evaluating. For
|
|
35
|
+
locally-defined handlers (e.g. built by a factory function), the
|
|
36
|
+
annotated type may live in the enclosing closure rather than the
|
|
37
|
+
handler's module globals -- plain ``get_type_hints()`` can't see it.
|
|
38
|
+
|
|
39
|
+
Strategy, each tried in order, first success wins:
|
|
40
|
+
1. ``typing.get_type_hints()`` — the common case.
|
|
41
|
+
2. ``typing.get_type_hints()`` with the handler's closure variables
|
|
42
|
+
supplied as ``localns`` — resolves closure-scoped forward refs.
|
|
43
|
+
3. ``inspect.get_annotations(eval_str=True)`` — a different evaluation
|
|
44
|
+
path that occasionally succeeds where (1)/(2) don't.
|
|
45
|
+
4. Final fallback: the raw ``inspect.signature()`` annotations, which
|
|
46
|
+
are still PEP 563 strings if every real attempt above failed (e.g. a
|
|
47
|
+
forward ref to a name that is genuinely unreachable, such as one only
|
|
48
|
+
imported under ``if TYPE_CHECKING:``).
|
|
49
|
+
|
|
50
|
+
This single implementation backs BOTH :class:`DIResolver` (the actual
|
|
51
|
+
per-message resolver) and the pipeline's own DI-need detector
|
|
52
|
+
(``HandlerPipeline._handler_needs_di``) — the two must use identical
|
|
53
|
+
resolution strength. They used to diverge (the detector had a weaker,
|
|
54
|
+
2-attempt version without the closure-``localns`` retry), which meant a
|
|
55
|
+
closure-scoped ``Depends(...)`` annotation could resolve fine here but
|
|
56
|
+
still be mis-detected as "no DI needed" by the weaker detector, silently
|
|
57
|
+
binding the marked parameter to the message body instead (L11).
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
return typing.get_type_hints(handler, include_extras=True)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
localns: dict[str, Any] = {}
|
|
65
|
+
if hasattr(handler, "__code__") and hasattr(handler, "__closure__"):
|
|
66
|
+
code = handler.__code__
|
|
67
|
+
closure = handler.__closure__
|
|
68
|
+
if closure is not None:
|
|
69
|
+
for name, cell in zip(code.co_freevars, closure, strict=False):
|
|
70
|
+
try:
|
|
71
|
+
localns[name] = cell.cell_contents
|
|
72
|
+
except ValueError: # pragma: no cover
|
|
73
|
+
pass # pragma: no cover
|
|
74
|
+
try:
|
|
75
|
+
return typing.get_type_hints(handler, localns=localns, include_extras=True)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
return inspect.get_annotations(handler, eval_str=True)
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# Final fallback: raw (possibly still-a-string) annotations from signature.
|
|
85
|
+
sig = inspect.signature(handler)
|
|
86
|
+
return {
|
|
87
|
+
name: param.annotation
|
|
88
|
+
for name, param in sig.parameters.items()
|
|
89
|
+
if param.annotation is not inspect.Parameter.empty
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _looks_like_unresolved_di_marker(ann: Any) -> bool:
|
|
94
|
+
"""L11: best-effort detection of a DI marker in an annotation that
|
|
95
|
+
:func:`get_type_hints_with_fallback` could not resolve to a real type
|
|
96
|
+
(still a raw PEP 563 string after all three real resolution attempts).
|
|
97
|
+
|
|
98
|
+
A plain string has no ``__metadata__``, so the structural
|
|
99
|
+
``Annotated[...]`` check used everywhere else can't see a marker here —
|
|
100
|
+
this falls back to a textual match on the marker call syntax
|
|
101
|
+
(``Depends(``/``Header(``/``Path(``/``Context(``). That's imperfect (an
|
|
102
|
+
aliased import, e.g. ``from ... import Depends as D``, slips through),
|
|
103
|
+
but a silent, wrong body-binding of a DI-marked parameter is worse than
|
|
104
|
+
an occasional false positive turned into a clear registration-time error.
|
|
105
|
+
"""
|
|
106
|
+
return isinstance(ann, str) and bool(_DI_MARKER_CALL_RE.search(ann))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_rabbit_message(ann: Any) -> bool:
|
|
110
|
+
"""True if ``ann`` is/mentions :class:`RabbitMessage`.
|
|
111
|
+
|
|
112
|
+
Handles both the resolved class and the string form (``"RabbitMessage"``)
|
|
113
|
+
produced by ``from __future__ import annotations`` when the hint can't be
|
|
114
|
+
resolved by ``typing.get_type_hints``. Recognizing the string form prevents
|
|
115
|
+
valid ``(body: bytes, msg: RabbitMessage)`` handlers from being mis-classified
|
|
116
|
+
as having two body-like parameters.
|
|
117
|
+
"""
|
|
118
|
+
return is_rabbit_message_annotation(ann)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class DependencyScope:
|
|
122
|
+
"""Tracks generator dependencies for cleanup after handler completes."""
|
|
123
|
+
|
|
124
|
+
def __init__(self) -> None:
|
|
125
|
+
self._sync_generators: list[Generator[Any, None, None]] = []
|
|
126
|
+
self._async_generators: list[AsyncGenerator[Any, None]] = []
|
|
127
|
+
|
|
128
|
+
def add_sync_generator(self, gen: Generator[Any, None, None]) -> None:
|
|
129
|
+
self._sync_generators.append(gen)
|
|
130
|
+
|
|
131
|
+
def add_async_generator(self, gen: AsyncGenerator[Any, None]) -> None:
|
|
132
|
+
self._async_generators.append(gen)
|
|
133
|
+
|
|
134
|
+
def cleanup(self) -> None:
|
|
135
|
+
"""Close all sync generators (in reverse order).
|
|
136
|
+
|
|
137
|
+
Each generator's teardown is isolated: a raising teardown is logged
|
|
138
|
+
and skipped so one misbehaving generator does not leak the rest or
|
|
139
|
+
prevent ``clear()`` from running.
|
|
140
|
+
"""
|
|
141
|
+
for gen in reversed(self._sync_generators):
|
|
142
|
+
try:
|
|
143
|
+
next(gen, None)
|
|
144
|
+
except StopIteration: # pragma: no cover
|
|
145
|
+
pass # pragma: no cover
|
|
146
|
+
except Exception:
|
|
147
|
+
_logger.warning("sync generator teardown raised", exc_info=True)
|
|
148
|
+
finally:
|
|
149
|
+
try:
|
|
150
|
+
gen.close()
|
|
151
|
+
except Exception:
|
|
152
|
+
_logger.warning("sync generator close() raised", exc_info=True)
|
|
153
|
+
self._sync_generators.clear()
|
|
154
|
+
|
|
155
|
+
async def cleanup_async(self) -> None:
|
|
156
|
+
"""Close all generators (async generators + sync generators, in reverse order).
|
|
157
|
+
|
|
158
|
+
Each generator's teardown is isolated: a raising async teardown is
|
|
159
|
+
logged and skipped, and the sync-generator pass always runs even if an
|
|
160
|
+
async generator raised. ``clear()`` always runs.
|
|
161
|
+
"""
|
|
162
|
+
for gen in reversed(self._async_generators):
|
|
163
|
+
try:
|
|
164
|
+
await gen.__anext__()
|
|
165
|
+
except StopAsyncIteration:
|
|
166
|
+
pass
|
|
167
|
+
except Exception:
|
|
168
|
+
_logger.warning("async generator teardown raised", exc_info=True)
|
|
169
|
+
finally:
|
|
170
|
+
try:
|
|
171
|
+
await gen.aclose()
|
|
172
|
+
except Exception:
|
|
173
|
+
_logger.warning("async generator aclose() raised", exc_info=True)
|
|
174
|
+
self._async_generators.clear()
|
|
175
|
+
|
|
176
|
+
for sync_gen in reversed(self._sync_generators):
|
|
177
|
+
try:
|
|
178
|
+
next(sync_gen, None)
|
|
179
|
+
except StopIteration: # pragma: no cover
|
|
180
|
+
pass # pragma: no cover
|
|
181
|
+
except Exception:
|
|
182
|
+
_logger.warning("sync generator teardown raised", exc_info=True)
|
|
183
|
+
finally:
|
|
184
|
+
try:
|
|
185
|
+
sync_gen.close()
|
|
186
|
+
except Exception:
|
|
187
|
+
_logger.warning("sync generator close() raised", exc_info=True)
|
|
188
|
+
self._sync_generators.clear()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class DIResolver:
|
|
192
|
+
"""Resolves handler parameters.
|
|
193
|
+
|
|
194
|
+
Resolution rules (Contract 4):
|
|
195
|
+
1. Annotated with DI marker (Depends/Header/Path/Context) → resolve via marker
|
|
196
|
+
2. Type is RabbitMessage → inject the message object
|
|
197
|
+
3. Remaining unannotated parameters → ONE body-bound parameter allowed
|
|
198
|
+
4. Multiple body-like parameters → ConfigurationError at registration time
|
|
199
|
+
5. Parameters with defaults → use default if no other resolution applies
|
|
200
|
+
|
|
201
|
+
Constraint: At most one body-bound parameter per handler.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(self) -> None:
|
|
205
|
+
# Per-handler cache of (signature, type-hints). Reflection — especially
|
|
206
|
+
# typing.get_type_hints() — is expensive and STATIC per handler, so it is
|
|
207
|
+
# computed once and reused on the per-message hot path.
|
|
208
|
+
self._sig_hints_cache: dict[Any, tuple[inspect.Signature, dict[str, Any]]] = {}
|
|
209
|
+
|
|
210
|
+
def _sig_and_hints(self, handler: Callable[..., Any]) -> tuple[inspect.Signature, dict[str, Any]]:
|
|
211
|
+
cached = self._sig_hints_cache.get(handler)
|
|
212
|
+
if cached is None:
|
|
213
|
+
cached = (inspect.signature(handler), self._get_type_hints(handler))
|
|
214
|
+
self._sig_hints_cache[handler] = cached
|
|
215
|
+
return cached
|
|
216
|
+
|
|
217
|
+
def _get_type_hints(self, handler: Callable[..., Any]) -> dict[str, Any]:
|
|
218
|
+
"""Get resolved type hints for handler. See :func:`get_type_hints_with_fallback`."""
|
|
219
|
+
return get_type_hints_with_fallback(handler)
|
|
220
|
+
|
|
221
|
+
def validate_handler(self, handler: Callable[..., Any]) -> None:
|
|
222
|
+
"""Validate handler signature at registration time.
|
|
223
|
+
|
|
224
|
+
Raises ConfigurationError for:
|
|
225
|
+
- *args or **kwargs
|
|
226
|
+
- Multiple body-like parameters
|
|
227
|
+
- An annotation that looks like an unresolved DI marker (L11) — see
|
|
228
|
+
``_looks_like_unresolved_di_marker``
|
|
229
|
+
"""
|
|
230
|
+
sig, hints = self._sig_and_hints(handler)
|
|
231
|
+
body_params: list[str] = []
|
|
232
|
+
|
|
233
|
+
for param_name, param in sig.parameters.items():
|
|
234
|
+
# Reject *args and **kwargs
|
|
235
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
236
|
+
raise ConfigurationError(
|
|
237
|
+
f"Handler '{handler.__qualname__}': *args/**kwargs not supported. "
|
|
238
|
+
"Use explicit parameters with type annotations."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
ann = hints.get(param_name, inspect.Parameter.empty)
|
|
242
|
+
|
|
243
|
+
# L11: get_type_hints_with_fallback() could not resolve this
|
|
244
|
+
# annotation to a real type (it's still a raw PEP 563 string),
|
|
245
|
+
# and it textually looks like a DI marker call. Silently
|
|
246
|
+
# continuing would bind this parameter to the message body
|
|
247
|
+
# instead of resolving it via the marker — fail fast instead.
|
|
248
|
+
if _looks_like_unresolved_di_marker(ann):
|
|
249
|
+
raise ConfigurationError(
|
|
250
|
+
f"Handler '{handler.__qualname__}': parameter '{param_name}' has an "
|
|
251
|
+
f"annotation ({ann!r}) that looks like it uses a DI marker "
|
|
252
|
+
"(Depends/Header/Path/Context) but rabbitkit could not resolve it to "
|
|
253
|
+
"a real type. This usually means the annotated type is not reachable "
|
|
254
|
+
"from the handler's module globals or enclosing closure -- e.g. it is "
|
|
255
|
+
"only imported under `if TYPE_CHECKING:`, or defined somewhere "
|
|
256
|
+
"typing.get_type_hints() can't see. Left unresolved, this parameter "
|
|
257
|
+
"would silently bind to the message body instead of the marker. Make "
|
|
258
|
+
"the annotated type resolvable (e.g. import it unconditionally) to fix."
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Check if it's a DI-annotated parameter
|
|
262
|
+
if self._has_di_marker(ann):
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
# Check if it's RabbitMessage type (class or string form)
|
|
266
|
+
if _is_rabbit_message(ann):
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
# Check if it has a default (non-body)
|
|
270
|
+
if param.default is not inspect.Parameter.empty:
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
# Unannotated parameters are NOT body-like candidates: the fallback
|
|
274
|
+
# resolver binds the first unannotated param to the body and subsequent
|
|
275
|
+
# unannotated params to the message (a documented pattern, e.g.
|
|
276
|
+
# ``handle(body, msg)``). Only flag ANNOTATED params that look like body
|
|
277
|
+
# types (a clear intent signal that multiple body bindings are expected,
|
|
278
|
+
# which the resolver does not support) — e.g. ``handle(a: Order, b: Customer)``.
|
|
279
|
+
if ann is inspect.Parameter.empty:
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
# This is an annotated body-like parameter
|
|
283
|
+
body_params.append(param_name)
|
|
284
|
+
|
|
285
|
+
if len(body_params) > 1:
|
|
286
|
+
raise ConfigurationError(
|
|
287
|
+
f"Handler '{handler.__qualname__}': multiple body-like parameters "
|
|
288
|
+
f"({', '.join(body_params)}). At most one body parameter allowed. "
|
|
289
|
+
"Annotate extra parameters with Depends(), Header(), Path(), or Context()."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def resolve(
|
|
293
|
+
self,
|
|
294
|
+
handler: Callable[..., Any],
|
|
295
|
+
message: RabbitMessage,
|
|
296
|
+
context_repo: ContextRepo | None,
|
|
297
|
+
body: Any,
|
|
298
|
+
scope: DependencyScope | None = None,
|
|
299
|
+
) -> dict[str, Any]:
|
|
300
|
+
"""Resolve all handler parameters at message processing time."""
|
|
301
|
+
sig, hints = self._sig_and_hints(handler)
|
|
302
|
+
kwargs: dict[str, Any] = {}
|
|
303
|
+
depends_cache: dict[int, Any] = {}
|
|
304
|
+
body_injected = False
|
|
305
|
+
|
|
306
|
+
for param_name, param in sig.parameters.items():
|
|
307
|
+
ann = hints.get(param_name, inspect.Parameter.empty)
|
|
308
|
+
|
|
309
|
+
# Rule 1: DI-annotated parameters
|
|
310
|
+
marker = self._extract_di_marker(ann)
|
|
311
|
+
if marker is not None:
|
|
312
|
+
if isinstance(marker, Depends):
|
|
313
|
+
kwargs[param_name] = self._resolve_depends(marker, depends_cache, scope)
|
|
314
|
+
else:
|
|
315
|
+
kwargs[param_name] = self._resolve_marker_with_fallback(
|
|
316
|
+
marker, param, param_name, message, context_repo
|
|
317
|
+
)
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
# Rule 2: RabbitMessage type (class or string form)
|
|
321
|
+
if _is_rabbit_message(ann):
|
|
322
|
+
kwargs[param_name] = message
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
# Rule 3: Body-bound parameter (first one)
|
|
326
|
+
if not body_injected and param.default is inspect.Parameter.empty:
|
|
327
|
+
kwargs[param_name] = body
|
|
328
|
+
body_injected = True
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
# Rule 5: Parameters with defaults — omit (use default)
|
|
332
|
+
|
|
333
|
+
return kwargs
|
|
334
|
+
|
|
335
|
+
async def resolve_async(
|
|
336
|
+
self,
|
|
337
|
+
handler: Callable[..., Any],
|
|
338
|
+
message: RabbitMessage,
|
|
339
|
+
context_repo: ContextRepo | None,
|
|
340
|
+
body: Any,
|
|
341
|
+
scope: DependencyScope | None = None,
|
|
342
|
+
) -> dict[str, Any]:
|
|
343
|
+
"""Resolve all handler parameters, supporting async generator dependencies."""
|
|
344
|
+
sig, hints = self._sig_and_hints(handler)
|
|
345
|
+
kwargs: dict[str, Any] = {}
|
|
346
|
+
depends_cache: dict[int, Any] = {}
|
|
347
|
+
body_injected = False
|
|
348
|
+
|
|
349
|
+
for param_name, param in sig.parameters.items():
|
|
350
|
+
ann = hints.get(param_name, inspect.Parameter.empty)
|
|
351
|
+
|
|
352
|
+
# Rule 1: DI-annotated parameters
|
|
353
|
+
marker = self._extract_di_marker(ann)
|
|
354
|
+
if marker is not None:
|
|
355
|
+
if isinstance(marker, Depends):
|
|
356
|
+
kwargs[param_name] = await self._resolve_depends_async(marker, depends_cache, scope)
|
|
357
|
+
else:
|
|
358
|
+
kwargs[param_name] = self._resolve_marker_with_fallback(
|
|
359
|
+
marker, param, param_name, message, context_repo
|
|
360
|
+
)
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
# Rule 2: RabbitMessage type (class or string form)
|
|
364
|
+
if _is_rabbit_message(ann):
|
|
365
|
+
kwargs[param_name] = message
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
# Rule 3: Body-bound parameter (first one)
|
|
369
|
+
if not body_injected and param.default is inspect.Parameter.empty:
|
|
370
|
+
kwargs[param_name] = body
|
|
371
|
+
body_injected = True
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
# Rule 5: Parameters with defaults — omit (use default)
|
|
375
|
+
|
|
376
|
+
return kwargs
|
|
377
|
+
|
|
378
|
+
# ── Internal helpers ─────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
def _has_di_marker(self, ann: Any) -> bool:
|
|
381
|
+
"""Check if annotation has a DI marker."""
|
|
382
|
+
return self._extract_di_marker(ann) is not None
|
|
383
|
+
|
|
384
|
+
def _extract_di_marker(self, ann: Any) -> Depends | Header | Path | Context | None:
|
|
385
|
+
"""Extract DI marker from annotation (supports Annotated)."""
|
|
386
|
+
if ann is inspect.Parameter.empty:
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
# Check Annotated[Type, Marker]
|
|
390
|
+
if get_origin(ann) is Annotated:
|
|
391
|
+
args = get_args(ann)
|
|
392
|
+
for arg in args[1:]:
|
|
393
|
+
if isinstance(arg, (Depends, Header, Path, Context)):
|
|
394
|
+
return arg
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
def _resolve_marker_with_fallback(
|
|
398
|
+
self,
|
|
399
|
+
marker: Header | Path | Context,
|
|
400
|
+
param: inspect.Parameter,
|
|
401
|
+
param_name: str,
|
|
402
|
+
message: RabbitMessage,
|
|
403
|
+
context_repo: ContextRepo | None,
|
|
404
|
+
) -> Any:
|
|
405
|
+
"""Resolve a Header()/Path()/Context() marker (H10).
|
|
406
|
+
|
|
407
|
+
Fallback order when the value is absent from the message:
|
|
408
|
+
1. The marker's own ``default=`` (checked first, inside
|
|
409
|
+
``_resolve_header``/``_resolve_path``/``_resolve_context``).
|
|
410
|
+
2. The handler parameter's own Python default (checked here).
|
|
411
|
+
3. Neither present → ``MissingDependencyError`` (PERMANENT).
|
|
412
|
+
|
|
413
|
+
``Depends()`` markers never reach here — both ``resolve()`` and
|
|
414
|
+
``resolve_async()`` special-case them before calling this.
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
if isinstance(marker, Header):
|
|
418
|
+
return self._resolve_header(marker, message, param_name)
|
|
419
|
+
if isinstance(marker, Path):
|
|
420
|
+
return self._resolve_path(marker, message, param_name)
|
|
421
|
+
if isinstance(marker, Context):
|
|
422
|
+
return self._resolve_context(marker, context_repo, param_name)
|
|
423
|
+
raise ConfigurationError(f"Unknown DI marker: {marker!r}") # pragma: no cover - defensive
|
|
424
|
+
except MissingDependencyError:
|
|
425
|
+
if param.default is not inspect.Parameter.empty:
|
|
426
|
+
return param.default
|
|
427
|
+
raise
|
|
428
|
+
|
|
429
|
+
def _resolve_depends(self, marker: Depends, cache: dict[int, Any], scope: DependencyScope | None = None) -> Any:
|
|
430
|
+
"""Resolve a Depends() marker, with support for sync generator dependencies."""
|
|
431
|
+
dep_id = id(marker.dependency)
|
|
432
|
+
if marker.use_cache and dep_id in cache:
|
|
433
|
+
return cache[dep_id]
|
|
434
|
+
|
|
435
|
+
if inspect.isgeneratorfunction(marker.dependency):
|
|
436
|
+
gen = marker.dependency()
|
|
437
|
+
result = next(gen)
|
|
438
|
+
if scope is not None:
|
|
439
|
+
scope.add_sync_generator(gen)
|
|
440
|
+
elif inspect.isasyncgenfunction(marker.dependency):
|
|
441
|
+
raise ConfigurationError(
|
|
442
|
+
f"Async generator dependency '{marker.dependency.__qualname__}' "
|
|
443
|
+
"requires async pipeline. Use resolve_async() or ensure handler is async."
|
|
444
|
+
)
|
|
445
|
+
else:
|
|
446
|
+
result = marker.dependency()
|
|
447
|
+
|
|
448
|
+
if marker.use_cache:
|
|
449
|
+
cache[dep_id] = result
|
|
450
|
+
return result
|
|
451
|
+
|
|
452
|
+
async def _resolve_depends_async(
|
|
453
|
+
self, marker: Depends, cache: dict[int, Any], scope: DependencyScope | None = None
|
|
454
|
+
) -> Any:
|
|
455
|
+
"""Resolve a Depends() marker (async), with support for async/sync generator dependencies."""
|
|
456
|
+
dep_id = id(marker.dependency)
|
|
457
|
+
if marker.use_cache and dep_id in cache:
|
|
458
|
+
return cache[dep_id]
|
|
459
|
+
|
|
460
|
+
if inspect.isasyncgenfunction(marker.dependency):
|
|
461
|
+
gen = marker.dependency()
|
|
462
|
+
result = await gen.__anext__()
|
|
463
|
+
if scope is not None:
|
|
464
|
+
scope.add_async_generator(gen)
|
|
465
|
+
elif inspect.isgeneratorfunction(marker.dependency):
|
|
466
|
+
gen = marker.dependency()
|
|
467
|
+
result = next(gen)
|
|
468
|
+
if scope is not None:
|
|
469
|
+
scope.add_sync_generator(gen)
|
|
470
|
+
else:
|
|
471
|
+
result = marker.dependency()
|
|
472
|
+
if hasattr(result, "__await__"):
|
|
473
|
+
result = await result
|
|
474
|
+
|
|
475
|
+
if marker.use_cache:
|
|
476
|
+
cache[dep_id] = result
|
|
477
|
+
return result
|
|
478
|
+
|
|
479
|
+
def _resolve_header(self, marker: Header, message: RabbitMessage, param_name: str) -> Any:
|
|
480
|
+
"""Resolve a Header() marker (H10: marker default checked first)."""
|
|
481
|
+
if marker.name in message.headers:
|
|
482
|
+
return message.headers[marker.name]
|
|
483
|
+
if marker.has_default:
|
|
484
|
+
return marker.default
|
|
485
|
+
raise MissingDependencyError(repr(marker), param_name)
|
|
486
|
+
|
|
487
|
+
def _resolve_path(self, marker: Path, message: RabbitMessage, param_name: str) -> Any:
|
|
488
|
+
"""Resolve a Path() marker (H10: marker default checked first)."""
|
|
489
|
+
if marker.segment in message.path:
|
|
490
|
+
return message.path[marker.segment]
|
|
491
|
+
if marker.has_default:
|
|
492
|
+
return marker.default
|
|
493
|
+
raise MissingDependencyError(repr(marker), param_name)
|
|
494
|
+
|
|
495
|
+
def _resolve_context(self, marker: Context, context_repo: ContextRepo | None, param_name: str) -> Any:
|
|
496
|
+
"""Resolve a Context() marker (H10: marker default checked first)."""
|
|
497
|
+
if context_repo is None:
|
|
498
|
+
raise ConfigurationError("ContextRepo not available for Context() resolution")
|
|
499
|
+
if context_repo.has(marker.key):
|
|
500
|
+
return context_repo.get(marker.key)
|
|
501
|
+
if marker.has_default:
|
|
502
|
+
return marker.default
|
|
503
|
+
raise MissingDependencyError(repr(marker), param_name)
|