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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ dist/
.python-version
.venv
uv.lock
.superpowers/
37 changes: 27 additions & 10 deletions architecture/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,41 @@ container ahead of FastStream's deferred middleware construction — see
[the decision to keep the two-class split][d-factory]) runs `consume_scope` on
every message:

1. `build_child_container(scope=REQUEST, context={StreamMessage: msg})` — a
child container seeded with the current message.
2. Scopes it into `ContextRepo` under `_REQUEST_CONTAINER_KEY` for the duration
of the call.
3. Closes the child in a `finally`, so per-message resources never leak even if
the handler raises.
1. `modern_di.integrations.bind(faststream_message_provider, msg)` derives the
child's scope and context from the message — `bind(provider, connection)`
returns `ConnectionMatch(scope=provider.scope,
context={provider.context_type: connection})`, so this always produces
`scope=REQUEST, context={StreamMessage: msg}`, the same values the code
used to hand-write. FastStream is broker-agnostic through this one message
type, so there is only ever one provider to derive from —
`classify_connection` (which dispatches across several providers) has
nothing to dispatch across here.
2. `self.di_container.build_child_container(scope=match.scope,
context=match.context)` builds the child, opened via `Container`'s own
`async with` — entering an already-open container is a no-op; exiting
closes it, including on the exception path. This replaces the old manual
`try`/`finally: await request_container.close_async()`.
3. Inside that block, a plain sync `with
self.context.scope(_REQUEST_CONTAINER_KEY, request_container):` still
scopes the child into `ContextRepo` for the duration of the call —
`ContextRepo.scope` is not an async context manager, so the two blocks
nest rather than combine into one `async with A, B:` statement.

## Resolution

`FromDI(dependency, *, use_cache=True, cast=False)` returns a FastStream
`Depends` wrapping a `Dependency` instance. At resolution time `Dependency`
reads the request container out of `ContextRepo` and resolves through it:
`Depends` wrapping a `Dependency` instance holding a
`modern_di.integrations.Marker(dependency)`. At resolution time `Dependency`
reads the request container out of `ContextRepo` and calls
`self.marker.resolve(request_container)`, which is
`container.resolve_dependency(self.dependency)` under the hood — dispatching
to:

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

`Dependency` is the deep part of this seam — the provider-vs-type branch and the
container lookup sit behind a single `__call__`. `FromDI` is just its
`Dependency` is the deep part of this seam — the container lookup and the
`Marker` delegation sit behind a single `__call__`. `FromDI` is just its
constructor.

## Lifecycle note
Expand Down
22 changes: 11 additions & 11 deletions modern_di_faststream/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
from collections.abc import Awaitable, Callable

import faststream
import modern_di
from faststream.asgi import AsgiFastStream
from faststream.types import DecodedMessage
from modern_di import Container, Scope, providers
from modern_di import Container, Scope, integrations, providers


T_co = typing.TypeVar("T_co", covariant=True)
Expand Down Expand Up @@ -43,17 +42,15 @@ async def consume_scope(
call_next: Callable[[typing.Any], Awaitable[typing.Any]],
msg: faststream.StreamMessage[typing.Any],
) -> typing.AsyncIterator[DecodedMessage]:
request_container = self.di_container.build_child_container(
scope=modern_di.Scope.REQUEST, context={faststream.StreamMessage: msg}
)
try:
match = integrations.bind(faststream_message_provider, msg)
async with self.di_container.build_child_container(
scope=match.scope, context=match.context
) as request_container:
with self.context.scope(_REQUEST_CONTAINER_KEY, request_container):
return typing.cast(
typing.AsyncIterator[DecodedMessage],
await call_next(msg),
)
finally:
await request_container.close_async()


def fetch_di_container(app_: faststream.FastStream | AsgiFastStream) -> Container:
Expand Down Expand Up @@ -83,14 +80,17 @@ def setup_di(

@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, context: faststream.ContextRepo) -> T_co:
request_container: Container = context.get(_REQUEST_CONTAINER_KEY)
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, cast: bool = False
) -> T_co:
return typing.cast(T_co, faststream.Depends(dependency=Dependency(dependency), use_cache=use_cache, cast=cast))
return typing.cast(
T_co,
faststream.Depends(dependency=Dependency(integrations.Marker(dependency)), use_cache=use_cache, cast=cast),
)
123 changes: 123 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,123 @@
---
summary: modern_di_faststream/main.py now composes modern_di.integrations (bind for scope/context derivation, Container's async-with in consume_scope, 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 `_DiMiddleware.consume_scope`
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

Sixth 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).
FastStream combines traits seen separately in two prior conversions:

- Like `modern-di-flask`, it has exactly **one** connection/message type
(`faststream.StreamMessage`, broker-agnostic across NATS/Kafka/RabbitMQ/
etc.) — so this conversion uses `bind()` directly, with no
`classify_connection` dispatch anywhere.
- Unlike Flask, `consume_scope` (the DI middleware) wraps exactly **one**
call site — `await call_next(msg)` — inside a `try`/`finally`, the same
shape as the starlette/fastapi/litestar/aiohttp middleware conversions.
So, unlike Flask, this conversion **does** introduce a `Container`
async-context-manager block here.

FastStream also uses its own native dependency-injection seam
(`faststream.Depends`, analogous to FastAPI's `Depends`) — like
`modern-di-fastapi`/`modern-di-litestar`, `FromDI` constructs a FastStream
object (`Depends`) directly; there is no hand-rolled `@inject` decorator, so
`parse_markers`/`resolve_markers`/`is_injected`/`mark_injected` do not apply
here.

The **root** container's lifecycle (paired `app.on_startup(container.open)` /
`app.after_shutdown(container.close_async)`) is unaffected — FastStream's
app lifecycle is callback-based (documented in
[`architecture/dependency-injection.md`](../../architecture/dependency-injection.md)'s
"Lifecycle note"), so it cannot be wrapped in a single `async with` the way
the per-message scope can; this conversion does not touch it.

## Design

In `modern_di_faststream/main.py`:

- Import `integrations` from `modern_di`:
`from modern_di import Container, Scope, integrations, providers`.
- `_DiMiddleware.consume_scope`: replace the hand-written
`scope=modern_di.Scope.REQUEST, context={faststream.StreamMessage: msg}`
with `match = integrations.bind(faststream_message_provider, msg)` then
`scope=match.scope, context=match.context`. Replace the manual
`try`/`finally: await request_container.close_async()` with
`async with self.di_container.build_child_container(scope=match.scope,
context=match.context) as request_container:` wrapping the existing
`with self.context.scope(_REQUEST_CONTAINER_KEY, request_container):`
block (which stays a plain sync `with` — `ContextRepo.scope` is not an
async context manager, so the two blocks nest, they do not combine into
one `async with A, B:` statement).
- Drop the now-unused `import modern_di` (bare-module import) — it was only
used for `modern_di.Scope.REQUEST` inside `consume_scope`, which is gone
once `bind()` derives the scope. The direct `from modern_di import ...
Scope` import stays — the module-level
`faststream_message_provider = providers.ContextProvider(scope=Scope.REQUEST,
context_type=faststream.StreamMessage)` declaration still uses it.
- `Dependency`'s field changes from `dependency: AbstractProvider[T_co] |
type[T_co]` to `marker: integrations.Marker[T_co]`; its `__call__` body
becomes `return self.marker.resolve(request_container)` (stays `async
def` — FastStream's `Depends` dispatch calls it as a coroutine regardless
of what the body awaits; `Marker.resolve` itself is a plain sync call).
- `FromDI` constructs `Dependency(integrations.Marker(dependency))` instead
of `Dependency(dependency)` — its own signature (`dependency, *,
use_cache, cast`) is unchanged.
- `dataclasses` import stays — `Dependency` is still a `@dataclasses.dataclass`,
only its one field's name/type changes.

`__init__.py`'s re-exports are untouched — every public name keeps its exact
signature and behavior. No test constructs `Dependency` directly (verified
by reading both test files), so no test-file edit is expected.

## Non-goals

- Any change to `setup_di`, `fetch_di_container`, the root container's
`on_startup`/`after_shutdown` pairing, or the `_DIMiddlewareFactory`/
`_DiMiddleware` two-class split — the latter is a deliberate, documented
decision ([`planning/decisions/2026-06-25-keep-dimiddlewarefactory.md`](../decisions/2026-06-25-keep-dimiddlewarefactory.md))
unrelated to this refactor.
- `classify_connection` — FastStream has exactly one connection provider; a
dispatch-across-providers function has nothing to dispatch across here.
- `parse_markers`/`resolve_markers`/`is_injected`/`mark_injected` —
FastStream's own `Depends` already scans/resolves per parameter; there is
no hand-rolled decorator to replace.
- Any test rewrite. The existing suite asserts on public behavior only
(`FromDI`, `setup_di`, `fetch_di_container`, `faststream_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.
- `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
`{StreamMessage: msg}`** (low likelihood — `bind(provider, connection)`
returns `context={provider.context_type: connection}`, and
`faststream_message_provider.context_type` is `faststream.StreamMessage` —
the exact same key the hand-written dict already used — verified against
the provider declaration before writing this spec).
- **Nesting `async with ... as request_container:` around the existing
`with self.context.scope(...):` changes exception-propagation timing**
(low likelihood — the container close still happens in the `async with`'s
`__aexit__`, which runs after the inner `with`'s `__exit__` regardless of
whether `call_next` raises, matching the original `try`/`finally`'s
ordering: `ContextRepo.scope` unwound first, then the container closed).
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 = ["faststream>=0.7,<0.8", "modern-di>=2.25,<3"]
dependencies = ["faststream>=0.7,<0.8", "modern-di>=2.28,<3"]
version = "0"

[project.urls]
Expand Down
Loading