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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ uv add modern-di-flask # or: pip install modern-di-flask

## Usage

Flask has no dependency-injection system of its own, so `modern-di-flask` pairs an `@inject` decorator with inert `FromDI` markers (there is no `Depends`). `setup_di` installs a `before_request`/`teardown_appcontext` pair that builds a per-request `Scope.REQUEST` child container and closes it once the request finishes. Resolution is sync-only, and the child container is closed with `close_sync()`.
Flask has no dependency-injection system of its own, so `modern-di-flask` pairs an `@inject` decorator with inert `FromDI` markers (there is no `Depends`). `setup_di` installs a `before_request`/`teardown_appcontext` pair that builds a per-request `Scope.REQUEST` child container and closes it once the request finishes. Resolution is sync, also for `async def` views, and the child container is closed with `close_sync()`.

```python
import typing
Expand Down Expand Up @@ -66,6 +66,8 @@ container.validate() # optional fail-fast; must come after setup_di registers i

Pass `auto_inject=True` to `setup_di` to wire every registered view (app and blueprint routes alike) without a per-view `@inject`. It walks `app.view_functions` when `setup_di` runs, so call `setup_di` after all routes are registered. `flask.Request` is resolvable within DI via the pre-built `flask_request_provider` context provider.

`async def` views work with `@inject` and with `auto_inject=True`. Flask runs them through asgiref, so install Flask's async extra (`pip install "flask[async]"`). The markers are resolved from the request's child container before the view is awaited. Teardown is sync: the child container is closed with `close_sync()`, so a `Scope.REQUEST` provider with an async finalizer is not supported and fails when the request ends. The same applies to the root container at shutdown: if an APP-scoped provider has an async finalizer, close the root with `asyncio.run(fetch_di_container(app).close_async())` instead of `close_sync()`.

A container is open from construction. To fail fast at boot, call `.validate()` after `setup_di`, which registers `flask_request_provider`. Flask has no shutdown hook, so call `fetch_di_container(app).close_sync()` at process shutdown.

## API
Expand All @@ -74,7 +76,7 @@ A container is open from construction. To fail fast at boot, call `.validate()`
|---|---|
| `setup_di(app, container, *, auto_inject=False)` | Registers the container on `app.extensions`, installs the `before_request`/`teardown_appcontext` pair that builds and closes a per-request `Scope.REQUEST` child, and, if `auto_inject=True`, wraps every currently registered view with `inject`; returns the container |
| `FromDI(dependency)` | Inert marker (used with `@inject`) that resolves a provider or type from the per-request child container |
| `inject(view)` | Decorator for a view function; resolves its `FromDI`-annotated parameters without rewriting the function's signature. Raises `RuntimeError` naming `setup_di` when a request reaches it without `setup_di` called |
| `inject(view)` | Decorator for a sync or `async def` view; returns a wrapper of the same kind and resolves its `FromDI`-annotated parameters without rewriting the function's signature. Raises `RuntimeError` naming `setup_di` when a request reaches it without `setup_di` called |
| `fetch_di_container(app)` | Returns the root `Container` stored on `app.extensions` |
| `flask_request_provider` | `ContextProvider` for `flask.Request` (`REQUEST` scope), auto-registered by type |

Expand Down
11 changes: 11 additions & 0 deletions modern_di_flask/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import functools
import inspect
import typing

import flask
Expand Down Expand Up @@ -74,6 +75,16 @@ def inject(func: typing.Callable[..., T]) -> typing.Callable[..., T]:
integrations.mark_injected(func)
return func

if inspect.iscoroutinefunction(func):

@functools.wraps(func)
async def async_wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: # noqa: ANN401
resolved = integrations.resolve_markers(_fetch_child_container(), markers)
return await func(*args, **kwargs, **resolved)

integrations.mark_injected(async_wrapper)
return typing.cast(typing.Callable[..., T], async_wrapper)

@functools.wraps(func)
def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401
resolved = integrations.resolve_markers(_fetch_child_container(), markers)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Changelog = "https://github.com/modern-python/modern-di-flask/releases"
dev = [
"pytest>=8",
"pytest-cov>=5",
"asgiref>=3.2",
]
lint = [
"ty",
Expand Down
3 changes: 3 additions & 0 deletions tests/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ class Dependencies(Group):
app_factory = providers.Factory(creator=SimpleCreator, kwargs={"dep1": "original"})
request_factory = providers.Factory(scope=Scope.REQUEST, creator=DependentCreator, bound_type=None)
request_path = providers.Factory(scope=Scope.REQUEST, creator=fetch_request_path)
request_cached = providers.Factory(
scope=Scope.REQUEST, creator=SimpleCreator, kwargs={"dep1": "cached"}, cache=True, bound_type=None
)
53 changes: 53 additions & 0 deletions tests/test_async_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import inspect
import typing
from http import HTTPStatus

from flask import Flask
from modern_di import Container

from modern_di_flask import FromDI, inject, setup_di
from tests.dependencies import Dependencies, SimpleCreator


def test_inject_async_view_resolves_and_awaits(app: Flask, container: Container) -> None:
@app.route("/async/<name>")
@inject
async def hello(name: str, app_instance: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)]) -> dict[str, str]:
return {"name": name, "dep1": app_instance.dep1}

assert inspect.iscoroutinefunction(hello)
setup_di(app, container)
with app.test_client() as client:
response = client.get("/async/world")
assert response.status_code == HTTPStatus.OK
assert response.json == {"name": "world", "dep1": "original"}


def test_auto_inject_async_view(app: Flask, container: Container) -> None:
@app.route("/async/<name>")
async def hello(name: str, app_instance: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)]) -> dict[str, str]:
return {"name": name, "dep1": app_instance.dep1}

setup_di(app, container, auto_inject=True)
assert inspect.iscoroutinefunction(app.view_functions["hello"])
with app.test_client() as client:
response = client.get("/async/world")
assert response.status_code == HTTPStatus.OK
assert response.json == {"name": "world", "dep1": "original"}


def test_async_view_resolves_from_that_requests_child_container(app: Flask, container: Container) -> None:
@inject
def sync_resolve(cached: typing.Annotated[SimpleCreator, FromDI(Dependencies.request_cached)]) -> SimpleCreator:
return cached

@app.route("/cached")
@inject
async def cached_view(
cached: typing.Annotated[SimpleCreator, FromDI(Dependencies.request_cached)],
) -> dict[str, bool]:
return {"same": cached is sync_resolve()}

setup_di(app, container)
with app.test_client() as client:
assert client.get("/cached").json == {"same": True}
Loading