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
47 changes: 27 additions & 20 deletions architecture/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,17 @@ themselves). `modern-di-flask` does not do this automatically.
## Per-request scope

`_enter_request`, connected to `before_request`, runs once per incoming
request. It builds one `Scope.REQUEST` child container via
`container.build_child_container(scope=Scope.REQUEST, context={Request:
flask.request._get_current_object()})` — unwrapping Flask's request-local
proxy to the real `Request` object before seeding it as context — and stashes
the child on `flask.g` under `_CHILD_CONTAINER_ATTR`
request. It unwraps Flask's request-local proxy to the real `Request` object,
then derives the child's scope and context via
`modern_di.integrations.bind(flask_request_provider, connection)` —
`bind(provider, connection)` returns a `ConnectionMatch(scope=provider.scope,
context={provider.context_type: connection})`, so this always produces
`scope=Scope.REQUEST, context={Request: connection}`, the same values the
code used to hand-write. Flask has no WebSocket counterpart, so there is only
ever one provider to derive from — `classify_connection` (which dispatches
across several providers) has nothing to dispatch across here.
`container.build_child_container(scope=match.scope, context=match.context)`
builds the child, which is stashed on `flask.g` under `_CHILD_CONTAINER_ATTR`
(`"modern_di_request_container"`).

`_close_request`, connected to `teardown_appcontext`, reads the child back off
Expand All @@ -58,26 +64,27 @@ no-op instead of raising.

## Resolution

`FromDI(dependency)` returns an inert marker (`_FromDI`, a frozen dataclass
wrapping a provider or a bare type) — it does nothing on its own. Parameters
opt into injection by annotating them
`typing.Annotated[SomeType, FromDI(dependency)]`.
`FromDI` is `modern_di.integrations.from_di` — its marker factory. Calling
`FromDI(dependency)` returns an inert `Marker(dependency)` wrapping a
provider or a bare type; it does nothing on its own. Parameters opt into
injection by annotating them `typing.Annotated[SomeType, FromDI(dependency)]`.

`inject`:

1. `_parse_inject_params` scans the resolved type hints
1. `integrations.parse_markers(func)` scans the resolved type hints
(`typing.get_type_hints(func, include_extras=True)`) for `Annotated`
parameters carrying a `_FromDI` marker.
2. If none are found, the function is returned unchanged — only marked
`func.__modern_di_injected__ = True` — and `inject` short-circuits without
parameters carrying a `Marker`.
2. If none are found, the function is returned unchanged — only marked via
`integrations.mark_injected(func)` — and `inject` short-circuits without
building a wrapper at all.
3. Otherwise `inject` builds a `wrapper`, decorated with `functools.wraps`,
that reads the current request's child container off `flask.g`
(`getattr(g, _CHILD_CONTAINER_ATTR)`), resolves each DI parameter via
`child.resolve_dependency(marker.dependency)` — which dispatches to
`resolve_provider` when `dependency` is a provider instance and to
`resolve` (by type) otherwise — and calls the original function with the
resolved dependencies merged into the caller's `args`/`kwargs`.
(`getattr(g, _CHILD_CONTAINER_ATTR)`), resolves every marker via
`integrations.resolve_markers(child, markers)` — which calls each
`Marker.resolve(container)`, itself `container.resolve_dependency(...)`,
dispatching to `resolve_provider` when `dependency` is a provider instance
and to `resolve` (by type) otherwise — and calls the original function
with the resolved dependencies merged into the caller's `args`/`kwargs`.

Unlike the aiogram and Celery integrations, `inject` performs **no signature
rewrite**. Flask's URL dispatcher calls a view as `view(**url_args)` — it
Expand All @@ -89,8 +96,8 @@ introspection and Flask's endpoint-naming.
## auto_inject

With `auto_inject=True`, `setup_di` calls `_inject_views(app)`, which iterates
`app.view_functions` and wraps every entry not already marked
`__modern_di_injected__` with `inject`. Flask registers **every** dispatched
`app.view_functions` and wraps every entry `integrations.is_injected` doesn't
already report as marked with `inject`. Flask registers **every** dispatched
view — both app-level routes and blueprint routes (the latter under dotted
endpoints like `"bp.view"`) — in that single `app.view_functions` mapping, and
always dispatches through it, so wrapping that one registry covers app and
Expand Down
45 changes: 11 additions & 34 deletions modern_di_flask/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import dataclasses
import functools
import typing

import flask
from flask import Flask, Request, g
from modern_di import Container, Scope, providers
from modern_di import Container, Scope, integrations, providers


flask_request_provider = providers.ContextProvider(Request, scope=Scope.REQUEST)
Expand All @@ -31,10 +30,9 @@ def setup_di(app: Flask, container: Container, *, auto_inject: bool = False) ->
container.add_providers(*_CONNECTION_PROVIDERS)

def _enter_request() -> None:
child = container.build_child_container(
scope=Scope.REQUEST,
context={Request: flask.request._get_current_object()}, # noqa: SLF001 # ty: ignore[unresolved-attribute]
)
connection = flask.request._get_current_object() # noqa: SLF001 # ty: ignore[unresolved-attribute]
match = integrations.bind(flask_request_provider, connection)
child = container.build_child_container(scope=match.scope, context=match.context)
setattr(g, _CHILD_CONTAINER_ATTR, child)

app.before_request(_enter_request)
Expand All @@ -49,48 +47,27 @@ def _inject_views(app: Flask) -> None:
# the latter under dotted endpoints like "bp.view") in ``app.view_functions``
# and always dispatches through it, so wrapping that one registry covers all.
for endpoint, view in list(app.view_functions.items()):
if not getattr(view, "__modern_di_injected__", False):
if not integrations.is_injected(view):
app.view_functions[endpoint] = inject(view) # ty: ignore[invalid-assignment]


T = typing.TypeVar("T")
T_co = typing.TypeVar("T_co", covariant=True)


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


def FromDI(dependency: providers.AbstractProvider[T] | type[T], /) -> T: # noqa: N802
return typing.cast(T, _FromDI(dependency))


def _parse_inject_params(func: typing.Callable[..., typing.Any]) -> dict[str, _FromDI[typing.Any]]:
hints = typing.get_type_hints(func, include_extras=True)
di_params: dict[str, _FromDI[typing.Any]] = {}
for name, hint in hints.items():
if name == "return":
continue
if typing.get_origin(hint) is typing.Annotated:
for meta in typing.get_args(hint)[1:]:
if isinstance(meta, _FromDI):
di_params[name] = meta
break
return di_params
FromDI = integrations.from_di


def inject(func: typing.Callable[..., T]) -> typing.Callable[..., T]:
di_params = _parse_inject_params(func)
if not di_params:
func.__modern_di_injected__ = True # ty: ignore[unresolved-attribute]
markers = integrations.parse_markers(func)
if not markers:
integrations.mark_injected(func)
return func

@functools.wraps(func)
def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401
child: Container = getattr(g, _CHILD_CONTAINER_ATTR)
resolved = {name: child.resolve_dependency(marker.dependency) for name, marker in di_params.items()}
resolved = integrations.resolve_markers(child, markers)
return func(*args, **kwargs, **resolved)

wrapper.__modern_di_injected__ = True # ty: ignore[unresolved-attribute]
integrations.mark_injected(wrapper)
return wrapper
99 changes: 99 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,99 @@
---
summary: modern_di_flask/main.py now composes modern_di.integrations (bind for scope/context derivation, Marker/from_di/parse_markers/resolve_markers for FromDI/inject, is_injected/mark_injected for the auto_inject double-wrap guard) 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 `_enter_request` and
`@inject`/`FromDI`/`auto_inject` 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

Fifth 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).
Unlike every conversion so far (starlette, fastapi, litestar, aiohttp),
Flask has exactly **one** connection type — `flask.Request` — with no
WebSocket counterpart, so this conversion needs neither `classify_connection`
(a dispatch across multiple providers) nor aiohttp's `can_prepare()`-style
probe: `flask_request_provider` is always the right provider, so `bind()`
alone derives its scope/context. It's also the first fully **synchronous**
conversion — `Container` supports a sync context-manager pair
(`__enter__`/`__exit__` → `open()`/`close_sync()`) alongside the async one,
but Flask's own lifecycle hooks (`before_request` / `teardown_appcontext`)
are two separate callbacks, not one block a `with` statement could wrap — so
this conversion does not introduce a context-manager block the way the
starlette/fastapi/litestar/aiohttp middleware conversions did; it stays two
hooks, each unchanged in shape.

## Design

In `modern_di_flask/main.py`:

- Import `integrations` from `modern_di`:
`from modern_di import Container, Scope, integrations, providers`.
- `_enter_request`: replace the inline `scope=Scope.REQUEST,
context={Request: flask.request._get_current_object()}` with
`match = integrations.bind(flask_request_provider,
flask.request._get_current_object())` then
`container.build_child_container(scope=match.scope,
context=match.context)`. `Scope` stays imported — the module-level
`flask_request_provider = providers.ContextProvider(Request,
scope=Scope.REQUEST)` declaration is unaffected.
- Delete `_FromDI`; replace `FromDI`'s body with a direct assignment:
`FromDI = integrations.from_di`.
- Delete `_parse_inject_params`; `inject` calls `integrations.parse_markers(func)`
at decoration time and `integrations.resolve_markers(child, markers)` at
call time.
- Replace the inline `getattr(view, "__modern_di_injected__", False)` /
`func.__modern_di_injected__ = True` / `wrapper.__modern_di_injected__ =
True` string-attribute checks with `integrations.is_injected(view)` /
`integrations.mark_injected(func)` / `integrations.mark_injected(wrapper)`
— `integrations._INJECTED_ATTR` is the same `"__modern_di_injected__"`
string literal, so this is a behavioral no-op.
- Drop now-unused imports: `dataclasses` (only used by `_FromDI`) and the
`T_co` TypeVar (only used by `_FromDI(typing.Generic[T_co])`). `T` stays —
`inject`'s own signature (`Callable[..., T]`) still uses it, independent of
`_FromDI`.

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

## Non-goals

- Any change to `setup_di`, `fetch_di_container`, `_close_request`, or
`_inject_views`'s iteration logic — none of that is part of the kit.
- Introducing a `with container:` block anywhere — Flask's split
before_request/teardown_appcontext hooks have no single call site to wrap
one around (see Motivation).
- `classify_connection` — Flask has exactly one connection provider; a
dispatch-across-providers function has nothing to dispatch across here.
- Any test rewrite. The existing suite asserts on public behavior only
(`FromDI`, `inject`, `setup_di`, `fetch_di_container`,
`flask_request_provider`, and the `g.modern_di_request_container` raw
attribute some tests read directly — none of it references `_FromDI` or
the scope/context-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
`{Request: ...}`** (low likelihood — `bind(provider, connection)` returns
`context={provider.context_type: connection}`, and
`flask_request_provider.context_type` is `Request` — the exact same key
the hand-written dict already used — verified against the provider
declaration before writing this spec).
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 = ["flask>=3,<4", "modern-di>=2.25,<3"]
dependencies = ["flask>=3,<4", "modern-di>=2.28,<3"]
version = "0"

[project.urls]
Expand Down
Loading