From 47d86649f706b04b1d767f28ec4b9b2a267d5efe Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 23:43:22 +0300 Subject: [PATCH 1/6] docs(planning): add adopt-integration-kit design --- .../2026-07-13.01-adopt-integration-kit.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 planning/changes/2026-07-13.01-adopt-integration-kit.md diff --git a/planning/changes/2026-07-13.01-adopt-integration-kit.md b/planning/changes/2026-07-13.01-adopt-integration-kit.md new file mode 100644 index 0000000..b0938d7 --- /dev/null +++ b/planning/changes/2026-07-13.01-adopt-integration-kit.md @@ -0,0 +1,125 @@ +--- +summary: Compose modern_di.integrations (bind for the TaskiqMessage scope/context derivation, Container's async-with, Marker for the Dependency/FromDI resolution seam) instead of hand-rolling them; no public-API 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). From 41dd8d9c9992e9921b17baf4887289bee8ff4884 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 23:46:24 +0300 Subject: [PATCH 2/6] chore: bump modern-di floor to 2.28.0 for the integration kit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5394d94..ec59a31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] From 74ce70fbe1a2b9911b68fde5cc50323ee7a3df20 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 23:48:58 +0300 Subject: [PATCH 3/6] refactor: compose integrations.bind and Container's async-with in build_di_container --- modern_di_taskiq/main.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/modern_di_taskiq/main.py b/modern_di_taskiq/main.py index 8376abd..d4701c6 100644 --- a/modern_di_taskiq/main.py +++ b/modern_di_taskiq/main.py @@ -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 @@ -41,13 +41,11 @@ 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) From e3f6eebfcc4cbe825351ad32331f8ab621e68275 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 23:51:51 +0300 Subject: [PATCH 4/6] refactor: compose integrations.Marker in Dependency and FromDI --- modern_di_taskiq/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modern_di_taskiq/main.py b/modern_di_taskiq/main.py index d4701c6..64d37c0 100644 --- a/modern_di_taskiq/main.py +++ b/modern_di_taskiq/main.py @@ -50,13 +50,13 @@ async def build_di_container( @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)) From b814c1c375a5865b8ffab74497fc11d9e5e51e28 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 23:54:00 +0300 Subject: [PATCH 5/6] docs: promote the integration-kit internals into architecture/ --- architecture/dependency-injection.md | 32 +++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/architecture/dependency-injection.md b/architecture/dependency-injection.md index ef2ac62..6f4cce0 100644 --- a/architecture/dependency-injection.md +++ b/architecture/dependency-injection.md @@ -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. From 23862ea46ba79a979ec33fdcc14e4e3d695efb61 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 23:56:13 +0300 Subject: [PATCH 6/6] docs: finalize adopt-integration-kit change summary --- planning/changes/2026-07-13.01-adopt-integration-kit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/planning/changes/2026-07-13.01-adopt-integration-kit.md b/planning/changes/2026-07-13.01-adopt-integration-kit.md index b0938d7..9308fd5 100644 --- a/planning/changes/2026-07-13.01-adopt-integration-kit.md +++ b/planning/changes/2026-07-13.01-adopt-integration-kit.md @@ -1,5 +1,5 @@ --- -summary: Compose modern_di.integrations (bind for the TaskiqMessage scope/context derivation, Container's async-with, Marker for the Dependency/FromDI resolution seam) instead of hand-rolling them; no public-API change. +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