diff --git a/architecture/dependency-injection.md b/architecture/dependency-injection.md index 6fcb670..d8e0bae 100644 --- a/architecture/dependency-injection.md +++ b/architecture/dependency-injection.md @@ -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. diff --git a/modern_di_aiogram/dialog.py b/modern_di_aiogram/dialog.py new file mode 100644 index 0000000..976838d --- /dev/null +++ b/modern_di_aiogram/dialog.py @@ -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 diff --git a/planning/changes/2026-07-11.01-aiogram-dialog-inject.md b/planning/changes/2026-07-11.01-aiogram-dialog-inject.md new file mode 100644 index 0000000..44e78b0 --- /dev/null +++ b/planning/changes/2026-07-11.01-aiogram-dialog-inject.md @@ -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). diff --git a/planning/releases/2.1.0.md b/planning/releases/2.1.0.md new file mode 100644 index 0000000..83566a3 --- /dev/null +++ b/planning/releases/2.1.0.md @@ -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`). diff --git a/pyproject.toml b/pyproject.toml index e186bdb..777b9a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dev = [ "pytest", "pytest-cov", "pytest-asyncio", + "aiogram-dialog>=2,<3", ] lint = [ "ty", diff --git a/tests/test_dialog.py b/tests/test_dialog.py new file mode 100644 index 0000000..5fbc684 --- /dev/null +++ b/tests/test_dialog.py @@ -0,0 +1,180 @@ +import typing + +import pytest +from aiogram import Dispatcher +from aiogram.filters import CommandStart +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import Message +from aiogram_dialog import Dialog, DialogManager, StartMode, Window, setup_dialogs +from aiogram_dialog.test_tools import BotClient, MockMessageManager +from aiogram_dialog.test_tools.keyboard import InlineButtonTextLocator +from aiogram_dialog.widgets.kbd import Button +from aiogram_dialog.widgets.text import Const, Format +from modern_di import Container, Group, Scope, providers + +from modern_di_aiogram import setup_di +from modern_di_aiogram.dialog import FromDI, inject +from tests.dependencies import Dependencies, SimpleCreator + + +trace: list[typing.Any] = [] + + +class MainSG(StatesGroup): + window = State() + + +class SubSG(StatesGroup): + window = State() + + +@inject +async def on_start( + start_data: typing.Any, # noqa: ANN401, ARG001 + manager: DialogManager, # noqa: ARG001 + app: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], +) -> None: + trace.append(("on_start", isinstance(app, SimpleCreator))) + + +@inject +async def on_process_result( + start_data: typing.Any, # noqa: ANN401, ARG001 + result: typing.Any, # noqa: ANN401 + manager: DialogManager, # noqa: ARG001 + app: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], +) -> None: + trace.append(("on_process_result", isinstance(app, SimpleCreator), result)) + + +@inject +async def main_getter( + dialog_manager: DialogManager, # noqa: ARG001 + app: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], + **_kwargs: typing.Any, # noqa: ANN401 +) -> dict[str, str]: + return {"name": app.dep1} + + +async def go_sub(_callback: typing.Any, _button: typing.Any, manager: DialogManager) -> None: # noqa: ANN401 + await manager.start(SubSG.window) + + +@inject +async def sub_finish( + _callback: typing.Any, # noqa: ANN401 + _button: typing.Any, # noqa: ANN401 + manager: DialogManager, + app: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], +) -> None: + trace.append(("on_click", isinstance(app, SimpleCreator))) + await manager.done(result="RESULT") + + +async def _start_command(_message: Message, dialog_manager: DialogManager) -> None: + await dialog_manager.start(MainSG.window, mode=StartMode.RESET_STACK) + + +@pytest.fixture +def dialog_setup() -> tuple[BotClient, MockMessageManager]: + # Build the mock in the fixture so tests can read the rendered message; BotClient + # exposes the dispatcher as `.dp`. Dialog/Window are aiogram Routers, which can be + # attached to only one Dispatcher ever, so they're built fresh per test here rather + # than as module-level singletons shared across the function-scoped fixture calls. + trace.clear() + main_dialog = Dialog( + Window( + Format("Main {name}"), + Button(Const("ToSub"), id="tosub", on_click=go_sub), + state=MainSG.window, + getter=main_getter, + ), + on_start=on_start, + on_process_result=on_process_result, + ) + sub_dialog = Dialog( + Window( + Const("Sub"), + Button(Const("Finish"), id="finish", on_click=sub_finish), + state=SubSG.window, + ), + ) + dispatcher = Dispatcher() + dispatcher.message.register(_start_command, CommandStart()) + dispatcher.include_router(main_dialog) + dispatcher.include_router(sub_dialog) + setup_di(dispatcher, Container(groups=[Dependencies], validate=True)) + mock = MockMessageManager() + setup_dialogs(dispatcher, message_manager=mock) + return BotClient(dispatcher), mock + + +async def test_getter_and_on_start_resolve(dialog_setup: tuple[BotClient, MockMessageManager]) -> None: + client, mock = dialog_setup + await client.dp.emit_startup() + await client.send("/start") + assert mock.last_message().text == "Main original" # getter's FromDI shaped the text + assert ("on_start", True) in trace # 2-arg dialog-event callback resolved + await client.dp.emit_shutdown() + + +async def test_on_click_and_on_process_result_resolve( + dialog_setup: tuple[BotClient, MockMessageManager], +) -> None: + client, mock = dialog_setup + await client.dp.emit_startup() + await client.send("/start") + await client.click(mock.last_message(), InlineButtonTextLocator("ToSub")) # go_sub -> start sub + await client.click(mock.last_message(), InlineButtonTextLocator("Finish")) # sub_finish -> done + await client.dp.emit_shutdown() + assert ("on_click", True) in trace # 3-arg widget callback resolved + assert ("on_process_result", True, "RESULT") in trace # 3-arg dialog-event callback resolved + + +async def test_inject_passthrough_without_fromdi() -> None: + async def getter(dialog_manager: DialogManager, **_kwargs: typing.Any) -> dict[str, str]: # noqa: ARG001, ANN401 + return {} + + assert inject(getter) is getter + assert await getter(None) == {} + + +async def test_child_closed_per_update() -> None: + teardowns: list[str] = [] + + class Boom(Group): + resource = providers.Factory( + scope=Scope.REQUEST, + creator=SimpleCreator, + kwargs={"dep1": "x"}, + bound_type=None, + cache=providers.CacheSettings(finalizer=lambda _: teardowns.append("closed")), + ) + + class SG(StatesGroup): + window = State() + + @inject + async def getter( + dialog_manager: DialogManager, # noqa: ARG001 + _res: typing.Annotated[SimpleCreator, FromDI(Boom.resource)], + **_kwargs: typing.Any, # noqa: ANN401 + ) -> dict[str, str]: + return {} + + dialog = Dialog(Window(Const("x"), state=SG.window, getter=getter)) + + async def _start(_message: Message, dialog_manager: DialogManager) -> None: + await dialog_manager.start(SG.window, mode=StartMode.RESET_STACK) + + dispatcher = Dispatcher() + dispatcher.message.register(_start, CommandStart()) + dispatcher.include_router(dialog) + setup_di(dispatcher, Container(groups=[Boom], validate=True)) + setup_dialogs(dispatcher, message_manager=MockMessageManager()) + client = BotClient(dispatcher) + await client.dp.emit_startup() + await client.send("/start") # getter resolves Boom.resource; middleware closes the child after + await client.dp.emit_shutdown() + + assert teardowns == ["closed"]