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
32 changes: 22 additions & 10 deletions architecture/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,33 @@ instead of raising `ContainerClosedError`.

taskiq resolves a generator `TaskiqDepends` **once per task** and shares the
yielded value across every dependent. `build_di_container` exploits this: it
builds one `Scope.REQUEST` child container per task — seeded with the
`TaskiqMessage` as context — `yield`s it, and closes it in `finally`. Every
`FromDI` parameter in a task therefore shares one child, and the child is closed
after the task completes, including when the task raises (taskiq throws the task
exception into the generator at the `yield`).
derives the child's scope and context via
`modern_di.integrations.bind(taskiq_message_provider, context.message)` —
`bind(provider, connection)` returns `ConnectionMatch(scope=provider.scope,
context={provider.context_type: connection})`, so this always produces
`scope=Scope.REQUEST, context={TaskiqMessage: context.message}`, the same
values the code used to hand-write. taskiq has a single connection provider,
so there is nothing for `classify_connection` (which dispatches across
several providers) to dispatch across here. It builds one child container per
task via `build_child_container(scope=match.scope, context=match.context)`,
opened through `Container`'s own `async with` — entering an already-open
container is a no-op; exiting closes it, run when taskiq finalizes the
generator. Every `FromDI` parameter in a task therefore shares one child, and
the child is closed after the task completes, including when the task raises
(taskiq throws the task exception into the generator at the `yield`, which
propagates out of the `async with` and triggers the close).

## Resolution

`FromDI(dependency, *, use_cache=True)` returns a taskiq `TaskiqDepends` wrapping
a frozen `Dependency`. At resolution time `Dependency.__call__` receives the
per-task child (via `TaskiqDepends(build_di_container)`) and resolves through it
with `resolve_dependency`, which dispatches on the argument kind:
a frozen `Dependency` holding a `modern_di.integrations.Marker(dependency)`. At
resolution time `Dependency.__call__` receives the per-task child (via
`TaskiqDepends(build_di_container)`) and calls `self.marker.resolve(request_container)`,
which is `container.resolve_dependency(self.dependency)` under the hood —
dispatching on the argument kind:

- an `AbstractProvider` → `resolve_provider(...)`,
- a bare `type` → `resolve(...)`.

`Dependency` is the deep part of the seam — the provider-vs-type branch and the
container lookup sit behind a single `__call__`. `FromDI` is just its constructor.
`Dependency` is the deep part of the seam — the container lookup and the `Marker`
delegation sit behind a single `__call__`. `FromDI` is just its constructor.
18 changes: 8 additions & 10 deletions modern_di_taskiq/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import typing

import taskiq
from modern_di import Container, Scope, providers
from modern_di import Container, Scope, integrations, providers
from taskiq import AsyncBroker, Context, TaskiqDepends, TaskiqEvents, TaskiqState


Expand Down Expand Up @@ -41,24 +41,22 @@ def fetch_di_container(broker: AsyncBroker) -> Container:
async def build_di_container(
context: typing.Annotated[Context, TaskiqDepends()],
) -> typing.AsyncIterator[Container]:
container = fetch_di_container(context.broker).build_child_container(
scope=Scope.REQUEST, context={taskiq.TaskiqMessage: context.message}
)
try:
match = integrations.bind(taskiq_message_provider, context.message)
async with fetch_di_container(context.broker).build_child_container(
scope=match.scope, context=match.context
) as container:
yield container
finally:
await container.close_async()


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

async def __call__(self, request_container: typing.Annotated[Container, TaskiqDepends(build_di_container)]) -> T_co:
return request_container.resolve_dependency(self.dependency)
return self.marker.resolve(request_container)


def FromDI( # noqa: N802
dependency: providers.AbstractProvider[T_co] | type[T_co], *, use_cache: bool = True
) -> T_co:
return typing.cast(T_co, TaskiqDepends(Dependency(dependency), use_cache=use_cache))
return typing.cast(T_co, TaskiqDepends(Dependency(integrations.Marker(dependency)), use_cache=use_cache))
125 changes: 125 additions & 0 deletions planning/changes/2026-07-13.01-adopt-integration-kit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
---
summary: modern_di_taskiq/main.py now composes modern_di.integrations (bind for the TaskiqMessage scope/context derivation in build_di_container, Container's async-with, Marker for the Dependency/FromDI resolution seam) instead of hand-rolling them; 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 `build_di_container` and
`Dependency`/`FromDI` 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

Eleventh 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 3 adapters (fastapi, litestar, taskiq)
whose `build_di_container` child-generator is copied "line-for-line but for
the connection type," and among the 4 **native-DI** adapters whose
`Dependency.__call__` seam is the extraction target for Layer 2's `Marker`.

taskiq is a **native-DI adapter**: it uses taskiq's own `TaskiqDepends`, so
`FromDI` builds a framework object (`TaskiqDepends`) directly — there is no
hand-rolled `@inject` decorator, no annotation-scanning, so
`parse_markers`/`resolve_markers`/`is_injected`/`mark_injected` do **not**
apply here. It keeps its own `FromDI` (it must wrap `TaskiqDepends`), same as
`modern-di-fastapi`/`modern-di-litestar`/`modern-di-faststream`.

Unlike fastapi/litestar (which have two connection providers — request +
websocket — and dispatch with `classify_connection`), taskiq has exactly
**one** connection provider (`taskiq_message_provider`, binding
`taskiq.TaskiqMessage`). So — like grpc — this conversion uses `bind()`
directly for scope/context derivation, with **no `classify_connection`**
call (there being only one provider, nothing to dispatch across) and no
`if match else None` fallback (`bind()` never returns `None`).

## Design

In `modern_di_taskiq/main.py`:

- Import `integrations` from `modern_di`:
`from modern_di import Container, Scope, integrations, providers`.
- `build_di_container`: replace the hand-written
`build_child_container(scope=Scope.REQUEST, context={taskiq.TaskiqMessage:
context.message})` + manual `try`/`finally: await container.close_async()`
with `match = integrations.bind(taskiq_message_provider, context.message)`
then `async with fetch_di_container(context.broker).build_child_container(
scope=match.scope, context=match.context) as container: yield container`.
`Container`'s own `async with` closes the child when taskiq finalizes the
generator — normal completion or the task-error path (taskiq throws the
exception into the generator at the `yield`), matching the old `finally`'s
ordering exactly.
- `Dependency`'s field changes from `dependency:
providers.AbstractProvider[T_co] | type[T_co]` to `marker:
integrations.Marker[T_co]`; its `__call__` body becomes `return
self.marker.resolve(request_container)` (it stays `async def`, still
receiving the per-task child via `TaskiqDepends(build_di_container)`).
- `FromDI` constructs `Dependency(integrations.Marker(dependency))` instead
of `Dependency(dependency)` — its own signature (`dependency, *,
use_cache`) is unchanged; it still wraps the result in
`TaskiqDepends(..., use_cache=use_cache)`.

No dead imports result — this is a native-DI adapter (like fastapi/litestar):
`dataclasses` still decorates `Dependency`, `providers` still types `FromDI`'s
signature and the module-level `taskiq_message_provider` declaration, `Scope`
still declares that provider's scope, and `T_co` still parametrizes
`Dependency`/`FromDI`.

`__init__.py`'s re-exports are untouched — every public name keeps its exact
signature and behavior. No test constructs `Dependency` directly (verified
by reading all test files), so no test-file edit is expected — including
`test_resolves_app_request_and_context`, which asserts that a task resolving
`FromDI(Dependencies.task_name)` reads `message.task_name` off the bound
`TaskiqMessage`, exercising exactly the `bind()`-derived context this change
produces.

## Non-goals

- `classify_connection` — taskiq has exactly one connection provider; a
dispatch-across-providers function has nothing to dispatch across here
(confirmed by the single-element `_CONNECTION_PROVIDERS` tuple).
- `parse_markers`/`resolve_markers`/`is_injected`/`mark_injected` — taskiq's
own `TaskiqDepends` already scans/resolves per parameter; there is no
hand-rolled decorator or double-wrap guard to replace.
- Any change to `setup_di`, `fetch_di_container`, or the
`WORKER_STARTUP`/`WORKER_SHUTDOWN` root-lifecycle wiring — none of that is
part of the kit.
- Any test rewrite. The existing suite asserts on public behavior only
(`FromDI`, `setup_di`, `fetch_di_container`, `taskiq_message_provider` —
none of it references `Dependency` or the scope-derivation internals being
replaced).

## Testing

- `just test-ci` — 100% line coverage, all existing tests green with zero
test-file changes (the suite uses `taskiq.InMemoryBroker`; no external
broker/Redis).
- `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 without a
patch component, kept consistent as `>=2.28,<3`).
- **`bind()` derives a different context dict than the hand-written
`{TaskiqMessage: context.message}`** (low likelihood — `bind(provider,
connection)` returns `context={provider.context_type: connection}`, and
`taskiq_message_provider.context_type` is `taskiq.TaskiqMessage` — the
exact same key the hand-written dict already used — verified against the
provider declaration before writing this spec; `test_resolves_app_request_
and_context` exercises the `TaskiqMessage`-derived `task_name` and must
still pass unmodified).
- **Wrapping the async generator's `yield` in `async with` changes
close-timing versus the old `try`/`finally`** (low likelihood — the same
pattern already shipped in `modern-di-fastapi`/`modern-di-litestar`'s
`build_di_container`; `Container.__aexit__` runs `close_async()` on both
the normal and thrown-in paths, exactly as the `finally` did —
`test_request_child_closed_on_task_error` exercises the error path and
must still pass unmodified).
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ classifiers = [
"Typing :: Typed",
"Topic :: Software Development :: Libraries",
]
dependencies = ["taskiq>=0.11,<0.13", "modern-di>=2.25,<3"]
dependencies = ["taskiq>=0.11,<0.13", "modern-di>=2.28,<3"]
version = "0"

[project.urls]
Expand Down
Loading