Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 37 additions & 27 deletions architecture/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,42 +46,49 @@ was closed on a previous `shutdown` instead of raising `ContainerClosedError`.
runs once per incoming `Update` and wraps every handler that processes it. On
each call it:

1. Builds one `Scope.REQUEST` child container via
`container.build_child_container(scope=Scope.REQUEST, context={...})`,
seeded with `{Update: event, TelegramObject: event.event}` — `event` is the
`Update` itself, and `event.event` is aiogram's resolved inner object for
that update (`Message`, `CallbackQuery`, etc.).
1. Opens one `Scope.REQUEST` child container through `Container`'s own
`async with` — `container.build_child_container(scope=Scope.REQUEST,
context={...})`, seeded with `{Update: event, TelegramObject: event.event}` —
`event` is the `Update` itself, and `event.event` is aiogram's resolved inner
object for that update (`Message`, `CallbackQuery`, etc.). Entering an
already-open container is a no-op, so the `async with` simply pairs the child
with a guaranteed close.
2. Stashes the child under `_CHILD_CONTAINER_KEY` (`"modern_di_container"`) in
the per-update `data` dict — the same key name `inject`'s rewritten
handlers pull their container argument from.
3. Calls the wrapped handler, then closes the child container in a `finally`
block via `close_async`, so it is closed whether the handler returns
normally or raises.
3. Calls the wrapped handler inside the block; exiting the `async with` closes
the child container, whether the handler returns normally or raises.

The `context` dict is a hand-written literal, not a `bind()` result: it is a
multi-provider merge (`Update` **and** `TelegramObject`) at a hardcoded
`Scope.REQUEST`, which is the kit's documented Layer-1 outlier — `bind()`
classifies a single provider and does not fit this shape, so the literal stays.

Every `FromDI` parameter resolved during that update therefore shares one
child container, and the child is always closed before the middleware
returns, including on the error path.

## Resolution

`FromDI(dependency)` returns an inert marker (`_FromDI`, a frozen dataclass
wrapping a provider or a bare type) — it does nothing on its own. Parameters
opt into injection by annotating them
`FromDI` is `modern_di.integrations.from_di`: `FromDI(dependency)` returns an
inert `Marker` wrapping a provider or a bare type — it does nothing on its own.
Parameters opt into injection by annotating them
`typing.Annotated[SomeType, FromDI(dependency)]`.

`inject` rewrites a handler's signature at decoration time:

1. `_parse_inject_params` scans the resolved type hints for `Annotated`
parameters carrying a `_FromDI` marker.
2. If none are found, the handler is returned unchanged (only marked
`__modern_di_injected__ = True`, so `auto_inject` skips it later).
1. `integrations.parse_markers` scans the resolved type hints for `Annotated`
parameters carrying a `Marker`.
2. If none are found, the handler is returned unchanged (only marked injected
via `integrations.mark_injected`, so `auto_inject` skips it later).
3. Otherwise, `inject` builds a `wrapper` whose visible signature drops every
DI parameter and adds one keyword-only parameter named
`_CHILD_CONTAINER_KEY`, typed `Container`. At call time the wrapper pops
that keyword argument, resolves each DI parameter via
`container.resolve_dependency(marker.dependency)` (which dispatches on
whether `dependency` is a provider or a bare type), and calls the original
function with the DI arguments filled in.
that keyword argument and resolves the DI parameters via
`integrations.resolve_markers(container, di_params)` — which calls each
`Marker.resolve(container)`, itself `container.resolve_dependency(...)`
(dispatching on whether the wrapped dependency is a provider or a bare type)
— then calls the original function with the DI arguments filled in.
4. The wrapper deliberately does **not** use `functools.wraps`: aiogram calls
`inspect.unwrap` on handler callbacks, which follows `__wrapped__` back to
the original function and would defeat the rewritten `__signature__` that
Expand All @@ -101,15 +108,17 @@ When `setup_di(..., auto_inject=True)`, a `dispatcher.startup` callback
(`_inject_router`) walks `dispatcher.chain_tail` — every router reachable from
the dispatcher's router tree — and, for every observer except `update`
(update-level dispatch is handled by `_DiMiddleware`, not per-handler
injection), wraps each handler whose callback is not already marked
`__modern_di_injected__` with `inject`. The rewritten callback and its
injection), wraps each handler whose callback `integrations.is_injected`
reports as not yet injected with `inject`. The rewritten callback and its
recomputed `params` replace the originals on the `HandlerObject` in place.

Because this runs on `startup`, handlers must already be registered on their
routers before startup fires — a handler registered afterward is never
wrapped. Explicitly decorating a handler with `@inject` still works alongside
`auto_inject`: `inject` marks the handler as injected up front, so
`_inject_router` skips it rather than double-wrapping.
`auto_inject`: `inject` calls `integrations.mark_injected` up front, so
`_inject_router` skips it rather than double-wrapping. `mark_injected` and
`is_injected` share the same `"__modern_di_injected__"` attribute under the
hood, so the guard behaves identically to a hand-written get/set.

`auto_inject` relies on `HandlerObject.params`, added in aiogram 3.2.0, to
recompute the observer's cached parameter set after replacing a handler's
Expand All @@ -123,10 +132,11 @@ callbacks. aiogram-dialog runs inside aiogram's own dispatch, so it needs no
container of its own: `setup_di`'s middleware already builds the per-update
`Scope.REQUEST` child and stashes it under `_CHILD_CONTAINER_KEY` in the
per-update `data`, which is also `DialogManager.middleware_data`. The
`FromDI` marker and `_parse_inject_params` are imported unchanged from
`main` — `FromDI` re-exported from `dialog` is the same marker, so it works
in handlers and dialog code interchangeably. The only new code is the
dialog-aware `inject` and its container lookup.
`FromDI` marker is re-exported unchanged from `main`, and marker parsing and
resolution reuse `integrations.parse_markers` / `integrations.resolve_markers` —
`FromDI` re-exported from `dialog` is the same marker, so it works in handlers
and dialog code interchangeably. The only new code is the dialog-aware `inject`
and its container lookup.

### Call-shape container lookup

Expand Down
8 changes: 4 additions & 4 deletions modern_di_aiogram/dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
import functools
import typing

from modern_di import Container
from modern_di import Container, integrations

from modern_di_aiogram.main import _CHILD_CONTAINER_KEY, FromDI, _parse_inject_params
from modern_di_aiogram.main import _CHILD_CONTAINER_KEY, FromDI


__all__ = [
Expand Down Expand Up @@ -43,14 +43,14 @@ def inject(
dependencies as keywords. A getter/callback with no ``FromDI`` is returned
unchanged.
"""
di_params = _parse_inject_params(func)
di_params = integrations.parse_markers(func)
if not di_params:
return func

@functools.wraps(func)
async def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: # noqa: ANN401
container = _container_from_call(args, kwargs)
resolved = {name: container.resolve_dependency(marker.dependency) for name, marker in di_params.items()}
resolved = integrations.resolve_markers(container, di_params)
return await func(*args, **kwargs, **resolved)

return wrapper
45 changes: 10 additions & 35 deletions modern_di_aiogram/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import dataclasses
import functools
import inspect
import typing
Expand All @@ -7,7 +6,7 @@
from aiogram import BaseMiddleware, Dispatcher
from aiogram.dispatcher.event.handler import HandlerObject
from aiogram.types import TelegramObject, Update
from modern_di import Container, Scope, providers
from modern_di import Container, Scope, integrations, providers


aiogram_update_provider = providers.ContextProvider(Update, scope=Scope.REQUEST)
Expand All @@ -31,15 +30,12 @@ async def __call__(
event: TelegramObject,
data: dict[str, typing.Any],
) -> typing.Any: # noqa: ANN401
child_container = self.container.build_child_container(
async with self.container.build_child_container(
scope=Scope.REQUEST,
context={Update: event, TelegramObject: typing.cast("Update", event).event},
)
data[_CHILD_CONTAINER_KEY] = child_container
try:
) as child_container:
data[_CHILD_CONTAINER_KEY] = child_container
return await handler(event, data)
finally:
await child_container.close_async()


def setup_di(dispatcher: Dispatcher, container: Container, *, auto_inject: bool = False) -> Container:
Expand All @@ -58,36 +54,15 @@ def fetch_di_container(dispatcher: Dispatcher) -> Container:


T = typing.TypeVar("T")
T_co = typing.TypeVar("T_co", covariant=True)


@dataclasses.dataclass(slots=True, frozen=True)
class _FromDI(typing.Generic[T_co]):
dependency: providers.AbstractProvider[T_co] | type[T_co]


def FromDI(dependency: providers.AbstractProvider[T] | type[T]) -> T: # noqa: N802
return typing.cast(T, _FromDI(dependency))


def _parse_inject_params(func: typing.Callable[..., typing.Any]) -> dict[str, _FromDI[typing.Any]]:
hints = typing.get_type_hints(func, include_extras=True)
di_params: dict[str, _FromDI[typing.Any]] = {}
for name, hint in hints.items():
if name == "return":
continue
if typing.get_origin(hint) is typing.Annotated:
for meta in typing.get_args(hint)[1:]:
if isinstance(meta, _FromDI):
di_params[name] = meta
break
return di_params
FromDI = integrations.from_di


def inject(func: typing.Callable[..., typing.Awaitable[T]]) -> typing.Callable[..., typing.Awaitable[T]]:
di_params = _parse_inject_params(func)
di_params = integrations.parse_markers(func)
if not di_params:
func.__modern_di_injected__ = True # ty: ignore[unresolved-attribute]
integrations.mark_injected(func)
return func

original_signature = inspect.signature(func)
Expand All @@ -102,7 +77,7 @@ async def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401
container: Container = (
kwargs.pop(_CHILD_CONTAINER_KEY) if container_param_injected else kwargs[_CHILD_CONTAINER_KEY]
)
resolved = {name: container.resolve_dependency(marker.dependency) for name, marker in di_params.items()}
resolved = integrations.resolve_markers(container, di_params)
return await func(*args, **kwargs, **resolved)

# NOT functools.wraps: aiogram unwraps __wrapped__ and would defeat __signature__.
Expand All @@ -111,7 +86,7 @@ async def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401
wrapper.__doc__ = func.__doc__
wrapper.__module__ = func.__module__
wrapper.__signature__ = original_signature.replace(parameters=visible_params) # ty: ignore[unresolved-attribute]
wrapper.__modern_di_injected__ = True # ty: ignore[unresolved-attribute]
integrations.mark_injected(wrapper)
return wrapper


Expand All @@ -121,7 +96,7 @@ def _inject_router(router: aiogram.Router) -> None:
if observer.event_name == "update":
continue
for handler in observer.handlers:
if not getattr(handler.callback, "__modern_di_injected__", False):
if not integrations.is_injected(handler.callback):
wrapped = HandlerObject(
callback=inject(handler.callback),
filters=handler.filters,
Expand Down
Loading
Loading