diff --git a/architecture/dependency-injection.md b/architecture/dependency-injection.md index 0d571f9..c51084b 100644 --- a/architecture/dependency-injection.md +++ b/architecture/dependency-injection.md @@ -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 @@ -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 @@ -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 diff --git a/modern_di_flask/main.py b/modern_di_flask/main.py index 5f0c1e8..14d7069 100644 --- a/modern_di_flask/main.py +++ b/modern_di_flask/main.py @@ -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) @@ -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) @@ -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 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..c19c012 --- /dev/null +++ b/planning/changes/2026-07-13.01-adopt-integration-kit.md @@ -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). diff --git a/pyproject.toml b/pyproject.toml index f28f0d4..768d69a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]