diff --git a/architecture/dependency-injection.md b/architecture/dependency-injection.md index d8e0bae..b582938 100644 --- a/architecture/dependency-injection.md +++ b/architecture/dependency-injection.md @@ -46,17 +46,23 @@ 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 @@ -64,24 +70,25 @@ 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 @@ -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 @@ -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 diff --git a/modern_di_aiogram/dialog.py b/modern_di_aiogram/dialog.py index 976838d..c7be50c 100644 --- a/modern_di_aiogram/dialog.py +++ b/modern_di_aiogram/dialog.py @@ -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__ = [ @@ -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 diff --git a/modern_di_aiogram/main.py b/modern_di_aiogram/main.py index 630824c..9e1681a 100644 --- a/modern_di_aiogram/main.py +++ b/modern_di_aiogram/main.py @@ -1,4 +1,3 @@ -import dataclasses import functools import inspect import typing @@ -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) @@ -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: @@ -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) @@ -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__. @@ -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 @@ -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, diff --git a/planning/changes/2026-07-14.01-adopt-integration-kit.md b/planning/changes/2026-07-14.01-adopt-integration-kit.md new file mode 100644 index 0000000..a2243da --- /dev/null +++ b/planning/changes/2026-07-14.01-adopt-integration-kit.md @@ -0,0 +1,153 @@ +--- +summary: modern_di_aiogram/main.py and dialog.py now compose modern_di.integrations (Container's async-with in the middleware, Marker/from_di/parse_markers/resolve_markers for FromDI/inject, is_injected/mark_injected for the auto_inject guard) instead of hand-rolling them; the multi-provider context merge stays a literal (bind does not fit — the documented Layer-1 outlier); no public-API or test change. +--- + +# Design: Adopt the modern-di integration kit + +## Summary + +`modern-di` 2.28.0 shipped `modern_di.integrations` — the framework-agnostic +primitives that formalize what this package's `_DiMiddleware`, `FromDI`, +`inject`, and `_inject_router` (in `main.py`) and the aiogram-dialog `inject` +(in `dialog.py`) already hand-roll. This change swaps the hand-rolled +internals for kit calls. Public API and runtime behavior are unchanged; +every existing test asserts on public behavior only. + +## Motivation + +Twelfth of the 13 adapter conversions surveyed by the kit's own design — see +[modern-di's decision record](https://github.com/modern-python/modern-di/blob/main/planning/decisions/2026-07-13-integration-kit-shape.md) +and its source design doc +([`changes/2026-07-13.02-integration-kit.md`](https://github.com/modern-python/modern-di/blob/main/planning/changes/2026-07-13.02-integration-kit.md)), +which names this repo among the 8 adapters whose `_FromDI`/ +`_parse_inject_params` skeleton is the Layer-2 extraction target, and among +the 3 (flask/celery/aiogram) whose `__modern_di_injected__` double-wrap +guard becomes `is_injected`/`mark_injected`. + +aiogram is the kit's documented **Layer-1 outlier** (the decision record's +2026-07-13 addendum): its per-update child container's context is a +**multi-provider merge with a hardcoded scope** — +`build_child_container(scope=Scope.REQUEST, context={Update: event, +TelegramObject: cast(Update, event).event})`. That literal seeds **two** +aiogram types from **one** incoming `event`, and asymmetrically (`Update` → +the event itself; `TelegramObject` → the resolved inner `event.event`). No +single-provider `bind(provider, connection)` can derive that dict, and there +is no isinstance dispatch to do either — so, per the addendum, "it stays a +two-line literal." **Layer 1 (`bind`/`classify_connection`) does not apply +here**; the two `ContextProvider`s stay registered together via +`add_providers(*_CONNECTION_PROVIDERS)`, and the context dict is untouched. + +What **does** apply: `Container`'s own `async with` (the middleware wraps a +single call site — same shape as `modern-di-starlette`/`-aiohttp`/ +`-faststream`), and all of Layer 2. + +## Design + +### `main.py` + +- Import `integrations` from `modern_di`: + `from modern_di import Container, Scope, integrations, providers`. +- `_DiMiddleware.__call__`: replace the manual `child = build_child_container(...); + data[...] = child; try: return await handler(...) finally: await + child.close_async()` with `async with self.container.build_child_container( + scope=Scope.REQUEST, context={Update: event, TelegramObject: + typing.cast("Update", event).event}) as child_container:` wrapping + `data[_CHILD_CONTAINER_KEY] = child_container; return await handler(event, + data)`. **The `scope=` and `context=` arguments are unchanged** — only the + `try`/`finally` becomes `Container`'s `async with` (`__aexit__` closes on + both the normal and exception paths, identical ordering to the old + `finally`). No `bind`/`classify_connection` is introduced. +- Delete `_FromDI`; replace `FromDI`'s body with a direct assignment: + `FromDI = integrations.from_di`. +- Delete `_parse_inject_params`; `inject` calls `integrations.parse_markers(func)`. +- `inject`'s no-DI-params short-circuit: replace + `func.__modern_di_injected__ = True` with `integrations.mark_injected(func)`. +- `inject`'s wrapper body: replace the manual dict comprehension with + `integrations.resolve_markers(container, di_params)`. +- `inject`'s wrapper tail: replace `wrapper.__modern_di_injected__ = True` + with `integrations.mark_injected(wrapper)`. +- `_inject_router`: replace `getattr(handler.callback, + "__modern_di_injected__", False)` with + `integrations.is_injected(handler.callback)`. +- Drop now-unused imports/names: `dataclasses` (only `_FromDI` used it) and + the `T_co` TypeVar (only `_FromDI(typing.Generic[T_co])` used it). `T` + stays (`inject`'s signature uses it); `functools`/`inspect` stay (the + `functools.partial` in `setup_di` and the `inject` signature-rewrite + machinery are aiogram-specific and untouched); `providers`/`Scope` stay + (the module-level `ContextProvider` declarations and the middleware's + `scope=Scope.REQUEST` still use them). + +### `dialog.py` + +`dialog.py`'s aiogram-dialog `inject` shares `main`'s marker and parse. Once +`_parse_inject_params` is deleted from `main`, `dialog.py` must repoint: + +- Change `from modern_di_aiogram.main import _CHILD_CONTAINER_KEY, FromDI, + _parse_inject_params` to `from modern_di_aiogram.main import + _CHILD_CONTAINER_KEY, FromDI` and add `integrations` to the `modern_di` + import (`from modern_di import Container, integrations`). +- `di_params = _parse_inject_params(func)` → `di_params = + integrations.parse_markers(func)`. +- The wrapper's manual dict comprehension → `integrations.resolve_markers( + container, di_params)`. + +`dialog.py`'s `inject` has no double-wrap guard (dialogs have no auto-inject +sweep), so `is_injected`/`mark_injected` do not apply there; its +`functools.wraps` and the `_container_from_call` call-shape lookup are +aiogram-dialog-specific and untouched. + +Because these two files share the marker, both convert in one task so the +suite never sees a half-deleted `_parse_inject_params`. + +`__init__.py`'s re-exports (and `dialog.py`'s `__all__`) are untouched — every +public name keeps its exact signature and behavior. No test constructs +`_FromDI` directly or reads the raw `__modern_di_injected__` attribute +(verified by reading all test files — `test_auto_inject`'s +skip-already-injected test exercises the guard behaviorally, through the +public `@inject`/`auto_inject` surface only), so no test-file edit is +expected. + +## Non-goals + +- `bind`/`classify_connection` — aiogram's context is a two-type merge from + one event with a hardcoded scope; no single-provider `bind()` fits and + there is no dispatch to do (the kit's documented outlier). The context + dict and both `ContextProvider` registrations are untouched. +- The `inject` signature-rewrite machinery (the deliberate no-`functools.wraps` + + manual `__signature__` dance driven by aiogram's `__wrapped__` + unwrapping), `_inject_router`/`auto_inject`, and the entire `dialog.py` + `_container_from_call` lookup — all aiogram/aiogram-dialog-specific glue the + kit does not absorb; kept exactly as-is. +- Any change to `setup_di`, `fetch_di_container`, or the `startup`/`shutdown` + root-lifecycle registration. +- Any test rewrite. + +## Testing + +- `just test-ci` — 100% line coverage, all existing tests green with zero + test-file changes (no external service; the suite uses a fake-token `Bot` + and `aiogram_dialog.test_tools`). +- `just lint-ci` — ruff (`select=ALL`), `ty check`, `check-planning`. + +## Risk + +- **Version-floor bump breaks on an environment still resolving `<2.28`** + (low likelihood — semver-compatible; low impact — `pyproject.toml` pins + the floor explicitly, in this repo's existing `>=2.25,<3` style, kept + consistent as `>=2.28,<3`). +- **`is_injected`/`mark_injected` use a different attribute name than the + hand-written `__modern_di_injected__`, silently breaking the `auto_inject` + double-wrap guard** (low likelihood — `integrations._INJECTED_ATTR` is the + literal string `"__modern_di_injected__"`, confirmed by reading + `modern_di/integrations.py` directly before writing this spec; + `test_auto_inject`'s skip-already-injected test exercises exactly this path + and must still pass unmodified). +- **Wrapping the middleware's `try`/`finally` in `async with` changes + close-timing** (low likelihood — the same pattern already shipped in + `modern-di-starlette`/`-aiohttp`/`-faststream`; `Container.__aexit__` runs + `close_async()` on both the normal-return and exception paths, exactly as + the old `finally` did — `test_inject`'s child-closed-on-handler-error test + exercises the error path and must still pass unmodified). +- **`dialog.py` left importing the deleted `_parse_inject_params`** (mitigated + — both files convert in the same task; `just test-ci` at that task's end + imports `dialog.py` via `test_dialog.py` and must pass). diff --git a/pyproject.toml b/pyproject.toml index 941223a..f934198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Typing :: Typed", "Topic :: Software Development :: Libraries", ] -dependencies = ["aiogram>=3.2,<4", "modern-di>=2.25,<3"] +dependencies = ["aiogram>=3.2,<4", "modern-di>=2.28,<3"] version = "0" [project.urls]