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
68 changes: 68 additions & 0 deletions architecture/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,71 @@ wrapped. Explicitly decorating a handler with `@inject` still works alongside
`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
callback — this is why the package requires `aiogram>=3.2,<4`.

## aiogram-dialog

`modern_di_aiogram.dialog` (public surface: `inject`, `FromDI`) adds DI for
[aiogram-dialog](https://github.com/Tishka17/aiogram_dialog) getters and
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.

### Call-shape container lookup

aiogram-dialog calls getters and callbacks with different, fixed shapes, and
`_container_from_call(args, kwargs)` dispatches on them instead of on any
declared parameter name:

- **Getter** — aiogram-dialog calls `getter(**manager.middleware_data)`, so
there are no positional args and the container is a keyword,
`kwargs[_CHILD_CONTAINER_KEY]`.
- **Callbacks** carry a `DialogManager` positionally instead: `on_start` and
`on_close` are called as `(data, manager)` (2 args, `_ON_DIALOG_EVENT_ARGS`),
so the manager is `args[-1]`; `on_click` (`(event, widget, manager, item?)`)
and `on_process_result` (`(start_data, result, manager)`) are 3–4 args, so
the manager is always `args[2]`. Either way the container is
`manager.middleware_data[_CHILD_CONTAINER_KEY]` — the same dict the getter
received as kwargs.

### functools.wraps is safe here

The handler `inject` in `main` deliberately avoids `functools.wraps` because
aiogram unwraps `__wrapped__` and would defeat the rewritten `__signature__`.
The dialog `inject` has no such hazard: aiogram-dialog never inspects a
getter's or callback's signature — getters are always called with
`**middleware_data` and callbacks always positionally — so there is no
signature to defeat, and `wrapper` uses `functools.wraps(func)` plainly. This
also means the dialog `inject` needs no signature rewrite at all: it just
appends the resolved `FromDI` values as extra keywords via
`func(*args, **kwargs, **resolved)`.

### Requirements and constraints

- **No runtime `aiogram_dialog` dependency.** The lookup is structural
(duck-typed on `.middleware_data`); `dialog.py` imports nothing from
`aiogram_dialog`. It is a test dependency only (`aiogram-dialog>=2,<3` in
the `dev` group).
- **Requires `setup_di`.** Dialog DI has no setup of its own — it depends on
`setup_di`'s middleware to have already put the child container in
`middleware_data`. Without it, the lookup raises `KeyError`.
- **A getter decorated with `@inject` must still declare `**kwargs`.**
aiogram-dialog always calls getters as `getter(**manager.middleware_data)`,
which carries more entries than just the DI container (dialog manager,
event, etc.) — a bare getter that doesn't accept `**kwargs` raises
`TypeError` on that call regardless of `@inject`. This is inherent to
aiogram-dialog's own calling convention, not something `inject` imposes.
- **Don't name a `FromDI` getter param after a `middleware_data` key.** A getter
is called as `getter(**middleware_data)`, and `inject` forwards those kwargs
plus the resolved dependencies (`func(*args, **kwargs, **resolved)`). If a
`FromDI` parameter shares a name with a `middleware_data` entry (e.g. `bot`,
`event`, `dialog_manager`), Python raises `TypeError: multiple values for
keyword`. Give injected getter params distinct names. (Callbacks are called
positionally, so they are unaffected.)
- **No auto-inject for dialogs.** Getters/callbacks live on `Window`/widget
objects with no router-like registry to walk, so only explicit `@inject`
is supported.
56 changes: 56 additions & 0 deletions modern_di_aiogram/dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""modern-di dialog-aware injection for aiogram-dialog.

aiogram-dialog runs inside aiogram's dispatch, so ``modern_di_aiogram.setup_di``'s
middleware already builds the per-update ``Scope.REQUEST`` child. This module adds
an ``inject`` for aiogram-dialog getters and callbacks that finds that child by the
call shape aiogram-dialog uses and resolves ``FromDI`` params. It imports nothing
from ``aiogram_dialog`` (the lookup is structural), so aiogram-dialog is a test
dependency only.
"""

import functools
import typing

from modern_di import Container

from modern_di_aiogram.main import _CHILD_CONTAINER_KEY, FromDI, _parse_inject_params


__all__ = [
"FromDI",
"inject",
]

_ON_DIALOG_EVENT_ARGS = 2


def _container_from_call(args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]) -> Container:
if not args:
# getter: aiogram-dialog calls it as getter(**manager.middleware_data)
return typing.cast(Container, kwargs[_CHILD_CONTAINER_KEY])
# callbacks carry a DialogManager positionally: (data, manager) or (event, widget, manager[, item])
manager = args[-1] if len(args) == _ON_DIALOG_EVENT_ARGS else args[2]
return typing.cast(Container, manager.middleware_data[_CHILD_CONTAINER_KEY])


def inject(
func: typing.Callable[..., typing.Awaitable[typing.Any]],
) -> typing.Callable[..., typing.Awaitable[typing.Any]]:
"""Resolve ``FromDI`` params of an aiogram-dialog getter or callback.

Finds the per-update child container by call shape (getter kwargs, or the
``DialogManager``'s ``middleware_data`` for callbacks) and appends the resolved
dependencies as keywords. A getter/callback with no ``FromDI`` is returned
unchanged.
"""
di_params = _parse_inject_params(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()}
return await func(*args, **kwargs, **resolved)

return wrapper
179 changes: 179 additions & 0 deletions planning/changes/2026-07-11.01-aiogram-dialog-inject.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
---
summary: Add a modern_di_aiogram.dialog submodule with a dialog-aware @inject for aiogram-dialog getters and callbacks (on_click, on_start/on_close, on_process_result). It reuses the existing modern-di-aiogram middleware, FromDI marker, and per-update child-container key — finding the container by call shape (getter kwargs vs the DialogManager's middleware_data) — so aiogram-dialog needs no runtime dependency, only a test dependency. Ships in modern-di-aiogram 2.1.0.
---

# Design: aiogram-dialog support in `modern-di-aiogram`

**Status:** design, pending maintainer approval
**Target:** existing repo `modern-python/modern-di-aiogram`, new module
`modern_di_aiogram/dialog.py`; released as `modern-di-aiogram` **2.1.0**.

## Summary

Add first-class DI for [aiogram-dialog](https://github.com/Tishka17/aiogram_dialog)
getters and callbacks as a small submodule of the existing `modern-di-aiogram`
package. aiogram-dialog runs **inside** aiogram's dispatch, so the middleware
`setup_di` already installs builds a per-update `Scope.REQUEST` child container in
`data["modern_di_container"]`. The only new code is a **dialog-aware `inject`**
that finds that container by the call shape aiogram-dialog uses and resolves
`FromDI` params. Everything else — the middleware, `setup_di`, the `FromDI`
marker, the child-container key — is reused from `modern_di_aiogram.main`.

Users install `aiogram-dialog` themselves, call the existing
`setup_di(dispatcher, container)`, and decorate dialog getters/callbacks with
`from modern_di_aiogram.dialog import inject`.

## Motivation

aiogram-dialog is the most-requested aiogram addon and the roadmap's next
integration (its "only after aiogram lands" gate is satisfied — aiogram shipped).
Dishka has an `aiogram_dialog` integration; modern-di has none. Because the
container lifecycle is already owned by `modern-di-aiogram`'s middleware, this is
a **~25-line addition**, not a new package.

## Verified facts (aiogram 3.29 + aiogram-dialog 2.6 + modern-di 2.27, spiked end-to-end via `aiogram_dialog.test_tools`)

Every non-obvious decision below was proven against installed aiogram-dialog:

- **The per-update child reaches dialog code two ways.** A **getter** is called
as `await self.getter(**manager.middleware_data)` (`window.py:118`) — the
middleware data (which includes `data["modern_di_container"]`) is spread as
kwargs, so the container is in the getter's `**kwargs`. **Callbacks**
(`on_click`, `on_start`/`on_close`, `on_process_result`) are called
positionally with a `DialogManager`, whose `.middleware_data` is the same dict —
so the container is at `manager.middleware_data["modern_di_container"]`.
Verified: a getter and an on_click both resolved a `FromDI` service, and the
REQUEST child closed after each update.
- **aiogram-dialog never inspects the getter/callback signature** — getters are
called with `**middleware_data`, callbacks positionally — so **`functools.wraps`
is safe** here (unlike the handler `inject`, which avoids it because aiogram's
`HandlerObject` unwraps `__wrapped__`). No signature rewrite is needed.
- **Call-shape dispatch locates the `DialogManager`** (mirrors Dishka's
`_container_getter`): no positional args → getter (container in kwargs); 2
positional args → `(data, manager)` → `args[-1]`; 3–4 positional args →
`(event, widget, manager[, item])` or `(start_data, result, manager)` →
`args[2]`.
- **No runtime dependency on aiogram-dialog.** The dispatch is structural
(duck-typed `.middleware_data`); the module imports nothing from
`aiogram_dialog`. It is a **test dependency only**.
- **`test_tools` drives dialogs bot-less.** `BotClient` + `MockMessageManager`
run a real dialog (start, render, click) without Telegram — the test vehicle.

## Design

### `modern_di_aiogram/dialog.py`

```python
import functools
import typing

from modern_di import Container

from modern_di_aiogram.main import _CHILD_CONTAINER_KEY, _FromDI, _parse_inject_params

FromDI = ... # re-exported from main for convenience


def _container_from_call(args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]) -> Container:
if not args: # getter(**middleware_data)
return typing.cast(Container, kwargs[_CHILD_CONTAINER_KEY])
manager = args[-1] if len(args) == 2 else args[2] # DialogManager position by call shape
return typing.cast(Container, manager.middleware_data[_CHILD_CONTAINER_KEY])


def inject(func):
di_params = _parse_inject_params(func)
if not di_params:
return func

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

return wrapper
```

- Reuses `_CHILD_CONTAINER_KEY` (`"modern_di_container"`), `_FromDI`, and
`_parse_inject_params` from `main` — the marker is the **same** `FromDI`, so a
`FromDI(...)` works in both handlers and dialog code.
- `FromDI` is re-exported so users can `from modern_di_aiogram.dialog import
inject, FromDI`.
- A getter/callback with no `FromDI` param is returned unchanged (passthrough).
- gRPC-style positional forwarding: `func(*args, **kwargs, **resolved)` — no
bind-by-name/no signature rewrite (aiogram-dialog's call convention is fixed).

### Public surface (`dialog.__all__`)

`FromDI`, `inject`. (The top-level `modern_di_aiogram` package is unchanged.)

## Non-goals

- **Auto-inject for dialogs** — the handler `auto_inject` walks the router's
observers; dialog getters/callbacks live on `Window`/widget objects with no
comparable registry, so v1 is explicit `@inject` only (Dishka is too).
- **A new `setup`** — dialog DI rides the existing `setup_di` middleware; nothing
new to wire beyond the user's own `setup_dialogs(...)`.
- **A runtime dependency on aiogram-dialog** — the module is import-free of it.
- **Re-deriving the middleware/child lifecycle** — reused wholesale from `main`.
- **A separate package** — this is a submodule of `modern-di-aiogram` (maintainer
decision: a standalone package is disproportionate for ~25 lines).

## Testing

`tests/test_dialog.py`, driving real dialogs with `aiogram_dialog.test_tools`
(`BotClient` + `MockMessageManager`); 100%-coverage gate (`just test-ci`).
`aiogram-dialog>=2,<3` is added to the **dev** dependency group only.

Two dialogs cover all four DI-injection shapes in one flow:

- **Dialog A** (main): `on_start` (2-arg callback, `FromDI`), a `Window` with a
`getter` (`FromDI`) and a button to launch Dialog B, and `on_process_result`
(3-arg callback, `FromDI`) handling B's result.
- **Dialog B**: a `Window` with a button whose `on_click` (3-arg callback,
`FromDI`) calls `manager.done(result)`.

Test cases:
- `test_getter_resolves` — `/start` renders Dialog A; the getter's `FromDI`
service shaped the rendered text (`MockMessageManager.last_message()`).
- `test_on_start_resolves` and `test_on_process_result_resolves` — the 2-arg and
3-arg dialog-event callbacks resolve their `FromDI` params (assert via a
recorded side effect).
- `test_on_click_resolves` — clicking Dialog B's button runs its `on_click` with
a resolved `FromDI` service.
- `test_passthrough_without_fromdi` — `inject` returns a getter/callback with no
`FromDI` unchanged (`inject(f) is f`).
- `test_child_closed_per_update` — the REQUEST child's finalizer runs after each
update (reusing the middleware lifecycle), including the click update.

## Risk

- **R1 — aiogram-dialog call-shape drift.** The getter-kwargs and
positional-`DialogManager` conventions are relied on. *Mitigation:* they are
stable across aiogram-dialog 2.x and match Dishka's shipped integration; the
test dep pins `>=2,<3`. Since the runtime code has **no** aiogram-dialog import,
a future 3.x only affects the test suite, not users' installs.
- **R2 — container missing from `middleware_data`.** If a user decorates dialog
code but never calls `setup_di` (no middleware), the lookup raises `KeyError`.
Documented as a prerequisite (same failure mode as Dishka). A clearer custom
error is a possible later enhancement; v1 keeps the direct lookup.
- **R3 — `functools.wraps` exposing `FromDI` params via `__wrapped__`.** aiogram-
dialog never reads the signature (getters get `**middleware_data`, callbacks are
positional), so this is inert — verified against the source (`window.py:118`)
and the end-to-end spike.

## Scaffolding (implementation phase)

- New `modern_di_aiogram/dialog.py` (above).
- `pyproject.toml`: add `aiogram-dialog>=2,<3` to the `dev` dependency group.
- `tests/test_dialog.py` (above); the sample dialogs/providers may extend the
existing `tests/dependencies.py`/`factories.py`.
- `architecture/dependency-injection.md`: add an **"aiogram-dialog"** section
documenting the dialog `inject`, the call-shape container lookup, and the reuse
of the middleware/marker.
- `planning/releases/2.1.0.md`: release notes (minor bump — a backward-compatible
feature).
- **In the core `modern-di` repo** (finishing phase): extend
`docs/integrations/aiogram.md` with an aiogram-dialog usage section (no new nav
entry — it is the same package).
45 changes: 45 additions & 0 deletions planning/releases/2.1.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# modern-di-aiogram 2.1.0 — aiogram-dialog support

A backward-compatible minor release. Adds DI for
[aiogram-dialog](https://github.com/Tishka17/aiogram_dialog) getters and
callbacks as a new submodule of the existing package — not a new package,
since the container lifecycle is already owned by `modern-di-aiogram`'s
middleware.

## Feature

- **`modern_di_aiogram.dialog.inject`.** A dialog-aware `inject` for
aiogram-dialog getters and callbacks — window `getter`s, `on_start`,
`on_close`, `on_click`, and `on_process_result`. It finds the per-update
`Scope.REQUEST` child container by the call shape aiogram-dialog uses
(getter: container in `**kwargs`; callbacks: container at the positional
`DialogManager`'s `middleware_data`) and resolves `FromDI` params the same
way the handler `inject` does.
- **Reuses existing infrastructure.** The `setup_di` middleware, the `FromDI`
marker, and the per-update child-container key are all imported unchanged
from `modern_di_aiogram.main` — no second container, no second lifecycle.
Import via `from modern_di_aiogram.dialog import inject, FromDI`.
- **No auto-inject for dialogs.** Getters/callbacks live on `Window`/widget
objects with no router-like registry to walk, so only explicit `@inject` is
supported (matches Dishka's aiogram-dialog integration).

## Packaging

- **No new runtime dependency.** `dialog.py` imports nothing from
`aiogram_dialog` — the container lookup is structural. `aiogram-dialog` is
added to the `dev` dependency group only, for the test suite.
- `aiogram>=3.2,<4` and `modern-di>=2.25,<3` are unchanged.

## Downstream

No action needed — purely additive; existing handler `inject`/`auto_inject`
behavior is untouched.

## Internals

- 100% line coverage via `tests/test_dialog.py`, driving real dialogs
bot-less with `aiogram_dialog.test_tools` (`BotClient` +
`MockMessageManager`) — no live bot or Telegram API involved.
- `architecture/dependency-injection.md` gained an "aiogram-dialog" section
covering the call-shape lookup and why `functools.wraps` is safe there
(unlike the handler `inject`).
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dev = [
"pytest",
"pytest-cov",
"pytest-asyncio",
"aiogram-dialog>=2,<3",
]
lint = [
"ty",
Expand Down
Loading
Loading