From a1f4cb8b344a9d3cbc267900efe94103913d3ad3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 14:45:11 +0300 Subject: [PATCH 1/6] docs: add adopt-integration-kit change spec Carries the design spec authored during the earlier planning session onto this branch so Task 5 can finalize it. --- .../2026-07-13.01-adopt-integration-kit.md | 94 +++++++++++++++++++ 1 file changed, 94 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..3d46d85 --- /dev/null +++ b/planning/changes/2026-07-13.01-adopt-integration-kit.md @@ -0,0 +1,94 @@ +--- +summary: Compose modern_di.integrations instead of hand-rolling the connection-dispatch loop and the Dependency/resolve pair; 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` 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). From 57f37adbe70943efdb7932f9edf762d21ac7f78c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 14:46:50 +0300 Subject: [PATCH 2/6] chore: bump modern-di floor to 2.28.0 for the integration kit --- .gitignore | 1 + pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8cf31db..3db7a1f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ dist/ .python-version .venv uv.lock +.superpowers/ diff --git a/pyproject.toml b/pyproject.toml index 361c7af..fa323dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] From 391fab642ab7a8a40a69f129829c6270ea63541c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 14:49:17 +0300 Subject: [PATCH 3/6] refactor: compose integrations.classify_connection and Container's async-with in build_di_container --- modern_di_fastapi/main.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/modern_di_fastapi/main.py b/modern_di_fastapi/main.py index 7708c3f..d9b43e5 100644 --- a/modern_di_fastapi/main.py +++ b/modern_di_fastapi/main.py @@ -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 @@ -52,18 +52,12 @@ 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) From e246298324dc94979dc1dbf785e7a2aea7722391 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 14:53:14 +0300 Subject: [PATCH 4/6] refactor: compose integrations.Marker for Dependency and FromDI --- modern_di_fastapi/main.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/modern_di_fastapi/main.py b/modern_di_fastapi/main.py index d9b43e5..bb4aa66 100644 --- a/modern_di_fastapi/main.py +++ b/modern_di_fastapi/main.py @@ -62,13 +62,15 @@ async def build_di_container(connection: HTTPConnection) -> typing.AsyncIterator @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) + ) From e79d2c6dc3083fa5a5a70d3d48716c4971fa253e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 14:56:58 +0300 Subject: [PATCH 5/6] docs: promote the integration-kit internals into architecture/ --- architecture/container-lifecycle.md | 27 +++++++++++++++------------ architecture/dependency-resolution.md | 16 +++++++++------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/architecture/container-lifecycle.md b/architecture/container-lifecycle.md index e184b83..05bcbc6 100644 --- a/architecture/container-lifecycle.md +++ b/architecture/container-lifecycle.md @@ -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 diff --git a/architecture/dependency-resolution.md b/architecture/dependency-resolution.md index d75d544..0b9fc3d 100644 --- a/architecture/dependency-resolution.md +++ b/architecture/dependency-resolution.md @@ -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 From dd06ed98d98e84d218a185a3cc5681e86959e966 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 15:01:08 +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 3d46d85..136e185 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 instead of hand-rolling the connection-dispatch loop and the Dependency/resolve pair; no public-API change. +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