diff --git a/README.md b/README.md index 624e837..d42e8c4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 | diff --git a/modern_di_flask/main.py b/modern_di_flask/main.py index 1349122..89037a6 100644 --- a/modern_di_flask/main.py +++ b/modern_di_flask/main.py @@ -1,4 +1,5 @@ import functools +import inspect import typing import flask @@ -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) diff --git a/pyproject.toml b/pyproject.toml index d56c363..6fe296e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/dependencies.py b/tests/dependencies.py index a8b4ea0..68c62a4 100644 --- a/tests/dependencies.py +++ b/tests/dependencies.py @@ -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 + ) diff --git a/tests/test_async_views.py b/tests/test_async_views.py new file mode 100644 index 0000000..5804c4c --- /dev/null +++ b/tests/test_async_views.py @@ -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/") + @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/") + 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}