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/
27 changes: 15 additions & 12 deletions architecture/container-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,21 @@ container instance.
`build_di_container` is an async FastAPI dependency that yields a *child
container* scoped to the current *connection*, then closes it:

- It applies the *scope mapping* by walking the registered *context providers*
(`_CONNECTION_PROVIDERS`): the first whose `context_type` the connection is an
instance of supplies both the scope and the context key. So a `fastapi.Request`
→ `Scope.REQUEST` with the request placed in `context[fastapi.Request]`; a
`fastapi.WebSocket` → `Scope.SESSION` with the socket in
`context[fastapi.WebSocket]`. Any other `HTTPConnection` matches no provider and
yields a child with `scope=None`. The providers are the single source — adding a
connection kind is adding a provider, with no change to this dispatch.
- The child is built from the root container via
`build_child_container(context=..., scope=...)`.
- After the endpoint returns, the `finally` block calls
`container.close_async()`, tearing down anything opened in that scope.
- It applies the *scope mapping* via `modern_di.integrations.classify_connection`,
which walks the registered *context providers* (`_CONNECTION_PROVIDERS`): the
first whose `context_type` the connection is an instance of supplies both the
scope and the context key. So a `fastapi.Request` → `Scope.REQUEST` with the
request placed in `context[fastapi.Request]`; a `fastapi.WebSocket` →
`Scope.SESSION` with the socket in `context[fastapi.WebSocket]`. Any other
`HTTPConnection` matches no provider and yields a child with `scope=None`. The
providers are the single source — adding a connection kind is adding a
provider, with no change to this dispatch. The isinstance-over-tuple dispatch
itself lives in modern-di's integration kit, not here.
- The child is opened as an `async with` block —
`Container.build_child_container(scope=..., context=...)` returns a container
that is already open, so entering it is a no-op.
- Exiting the block closes the child container (`close_async`), including on
the exception path, tearing down anything opened in that scope.

Finer scopes are reached by building further children from this one: an HTTP
endpoint can `build_child_container()` again for `ACTION` scope, and a WebSocket
Expand Down
16 changes: 9 additions & 7 deletions architecture/dependency-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ type, so the annotated parameter type stays accurate.

## The callable — `Dependency`

`Dependency` is a frozen, slotted, generic dataclass holding the requested
`dependency`. Its `__call__` is itself a FastAPI dependency: it depends on
`build_di_container`, so it receives the *child container* for the current
connection, then resolves against it via
`request_container.resolve_dependency(self.dependency)` — a provider argument
resolves by reference, a plain type resolves by type; overrides, caching, and
suggestions are inherited from whichever it dispatches to.
`Dependency` is a frozen, slotted, generic dataclass holding a
`modern_di.integrations.Marker` wrapping the requested dependency. Its
`__call__` is itself a FastAPI dependency: it depends on `build_di_container`,
so it receives the *child container* for the current connection, then resolves
against it via `self.marker.resolve(request_container)` — which calls
`request_container.resolve_dependency(marker.dependency)` internally. A
provider argument resolves by reference, a plain type resolves by type;
overrides, caching, and suggestions are inherited from whichever it dispatches
to.

Because resolution flows through `build_di_container`, every `FromDI`
dependency in a request shares that request's scoped container and its
Expand Down
26 changes: 11 additions & 15 deletions modern_di_fastapi/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import typing

import fastapi
from modern_di import Container, Scope, providers
from modern_di import Container, Scope, integrations, providers
from starlette.requests import HTTPConnection
from starlette.types import Lifespan

Expand Down Expand Up @@ -52,29 +52,25 @@ def setup_di(app: fastapi.FastAPI, container: Container) -> Container:


async def build_di_container(connection: HTTPConnection) -> typing.AsyncIterator[Container]:
context: dict[type[typing.Any], typing.Any] = {}
scope = None
for provider in _CONNECTION_PROVIDERS:
if isinstance(connection, provider.context_type):
context[provider.context_type] = connection
scope = provider.scope
break
container = fetch_di_container(connection.app).build_child_container(context=context, scope=scope)
try:
match = integrations.classify_connection(connection, _CONNECTION_PROVIDERS)
async with fetch_di_container(connection.app).build_child_container(
scope=match.scope if match else None,
context=match.context if match else None,
) 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, fastapi.Depends(build_di_container)]
) -> T_co:
return request_container.resolve_dependency(self.dependency)
return self.marker.resolve(request_container)


def FromDI(dependency: providers.AbstractProvider[T_co] | type[T_co], *, use_cache: bool = True) -> T_co: # noqa: N802
return typing.cast(T_co, fastapi.Depends(dependency=Dependency(dependency), use_cache=use_cache))
return typing.cast(
T_co, fastapi.Depends(dependency=Dependency(integrations.Marker(dependency)), use_cache=use_cache)
)
94 changes: 94 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,94 @@
---
summary: modern_di_fastapi/main.py now composes modern_di.integrations (classify_connection, Marker, Container's async-with) 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` already hand-roll. This change swaps the hand-rolled internals
for kit calls. Public API, runtime behavior, and every existing test are
unchanged; only `modern_di_fastapi/main.py`'s internals shrink.

## Motivation

This is the second 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
[`modern-di-starlette`'s own conversion](https://github.com/modern-python/modern-di-starlette/pull/8)
(first of the 13, merged as `modern-di-starlette` 2.2.0), which validated the
same primitives for a non-native-DI adapter. `modern-di-fastapi` is a
**native-DI** adapter (FastAPI's own `Depends` drives resolution), so this
conversion exercises a different slice of the kit: `Marker.resolve()` and
Layer 1's connection-dispatch, but none of `parse_markers`/`resolve_markers`
(fastapi has no `@inject` decorator to replace — `Depends` already does that
job).

Concretely, in `main.py` today:

- `build_di_container`'s isinstance-over-`_CONNECTION_PROVIDERS` loop is
`modern_di.integrations.classify_connection`, byte-for-byte — the exact
target shape is already documented in modern-di's own
[writing-integrations.md](https://github.com/modern-python/modern-di/blob/main/docs/integrations/writing-integrations.md#deriving-scope-and-context-with-the-integration-kit),
which uses this function's real signature as its worked example.
- `Dependency`'s `__call__` body
(`return request_container.resolve_dependency(self.dependency)`) is
`modern_di.integrations.Marker.resolve`, byte-for-byte — same doc's
[FromDI section](https://github.com/modern-python/modern-di/blob/main/docs/integrations/writing-integrations.md#5-fromdi-marker--dependency-resolver)
uses fastapi's own `Dependency` shape as its worked example for holding a
`Marker` instead of a raw `dependency` field.

## Design

In `modern_di_fastapi/main.py`:

- Import `integrations` from `modern_di`.
- `build_di_container`: replace the isinstance loop with
`match = integrations.classify_connection(connection, _CONNECTION_PROVIDERS)`,
then open the child via
`async with fetch_di_container(connection.app).build_child_container(scope=match.scope if match else None, context=match.context if match else None) as container:` —
replacing the manual `try`/`finally: await container.close_async()` with
`Container`'s own context-manager protocol.
- `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)`.
- `FromDI` constructs `Dependency(integrations.Marker(dependency))` instead of
`Dependency(dependency)` — its own signature (`dependency`, `use_cache`) is
unchanged.

`build_di_container`'s public signature
(`async def build_di_container(connection: HTTPConnection) ->
typing.AsyncIterator[Container]`) is unchanged — it's re-exported in
`__init__.py` and used directly in this package's own tests
(`fastapi.Depends(build_di_container)` for `ACTION`/`REQUEST`-scope access),
so this is verified, not assumed, to be a pure internals swap.

## Non-goals

- Any change to `setup_di`, `_compose_lifespan`, or connection-provider
registration — none of that is part of the kit.
- `integrations.parse_markers`/`resolve_markers` — fastapi has no decorator
path; `Depends` already scans/resolves per parameter.
- Any test rewrite. The existing suite asserts on public behavior only
(`FromDI`, `build_di_container`, `setup_di`, `fetch_di_container`, the two
connection providers) — none of it references `Dependency`'s internal field
name, so it should pass unmodified.

## 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, purely additive on modern-di's side;
low impact — `pyproject.toml` pins the floor explicitly).
- **`async with` on the child container behaves differently from the manual
try/finally** (low likelihood — already validated identically in
`modern-di-starlette`'s conversion; `Container.__aenter__` is a no-op on an
already-open container per modern-di's own architecture docs).
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 = ["fastapi>=0.100,<1", "modern-di>=2.25,<3"]
dependencies = ["fastapi>=0.100,<1", "modern-di>=2.28.0,<3"]
version = "0"

[project.urls]
Expand Down
Loading