From 5dbf503b8207ac9f97afbcf92031a121c6f55a83 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 13:05:14 +0300 Subject: [PATCH] fix: keep sync handlers sync under @inject --- README.md | 4 ++- modern_di_aiohttp/main.py | 20 ++++++++--- tests/test_sync_handlers.py | 66 +++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 tests/test_sync_handlers.py diff --git a/README.md b/README.md index 62d25d1..e56bd56 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ setup_di(app, container) container.validate() # optional fail-fast; must come after setup_di registers its providers ``` +`@inject` also works on a plain `def` handler. It returns a sync wrapper for a sync function, so aiohttp handles the decorated handler the same way it handles the undecorated one, including its deprecation warning for sync handlers. + `@inject` works the same on the methods of a class-based `web.View`. Decorate the handler method, not the class; aiohttp calls the method with no arguments, so the request is taken from `self.request`: ```python @@ -96,7 +98,7 @@ Sub-apps added with `app.add_subapp` work with or without their own `setup_di`. | Symbol | Description | |---|---| | `setup_di(app, container)` | Stores the container on the app, wires `on_startup`/`on_cleanup` (reopen on startup, close on cleanup), registers the connection providers, and installs the per-connection middleware | -| `inject(handler)` | Decorator that resolves every `FromDI`-marked parameter from the request's child container and passes them to the handler; works on a function handler and on a method of a `web.View` subclass | +| `inject(handler)` | Decorator that resolves every `FromDI`-marked parameter from the request's child container and passes them to the handler; works on a sync or `async def` function handler and on a method of a `web.View` subclass | | `FromDI(dependency)` | Inert `Annotated` marker resolved by `@inject`; accepts a provider or a type | | `fetch_di_container(app)` | Returns the app-scoped root container | | `fetch_request_container(request)` | Returns the per-connection child container the middleware built for this request | diff --git a/modern_di_aiohttp/main.py b/modern_di_aiohttp/main.py index 8a06592..df9785a 100644 --- a/modern_di_aiohttp/main.py +++ b/modern_di_aiohttp/main.py @@ -1,6 +1,7 @@ """modern-di integration for aiohttp.""" import functools +import inspect import typing import aiohttp.abc @@ -105,13 +106,24 @@ def _find_request(handler_name: str, args: tuple[typing.Any, ...]) -> web.Reques raise TypeError(msg) -def inject(func: typing.Callable[..., typing.Awaitable[T]]) -> typing.Callable[..., typing.Awaitable[T]]: +def inject(func: typing.Callable[..., T]) -> typing.Callable[..., T]: markers = integrations.parse_markers(func) handler_name = getattr(func, "__qualname__", repr(func)) - @functools.wraps(func) - async def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401 + def resolve(args: tuple[typing.Any, ...]) -> dict[str, typing.Any]: child_container = fetch_request_container(_find_request(handler_name, args)) - return await func(*args, **kwargs, **integrations.resolve_markers(child_container, markers)) + return integrations.resolve_markers(child_container, markers) + + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: # noqa: ANN401 + return await func(*args, **kwargs, **resolve(args)) + + return typing.cast(typing.Callable[..., T], async_wrapper) + + @functools.wraps(func) + def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401 + return func(*args, **kwargs, **resolve(args)) return wrapper diff --git a/tests/test_sync_handlers.py b/tests/test_sync_handlers.py new file mode 100644 index 0000000..0685d9e --- /dev/null +++ b/tests/test_sync_handlers.py @@ -0,0 +1,66 @@ +import collections.abc +import http +import inspect +import typing + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, make_mocked_request + +from modern_di_aiohttp import FromDI, inject +from tests.dependencies import Dependencies, DependentCreator, SimpleCreator + + +AiohttpClient = collections.abc.Callable[[web.Application], collections.abc.Awaitable[TestClient]] + + +async def test_sync_handler_resolves_markers(aiohttp_client: AiohttpClient, app: web.Application) -> None: + @inject + def read_root( + request: web.Request, # noqa: ARG001 + app_factory_instance: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], + request_factory_instance: typing.Annotated[DependentCreator, FromDI(Dependencies.request_factory)], + method: typing.Annotated[str, FromDI(Dependencies.request_method)], + ) -> web.Response: + assert isinstance(request_factory_instance, DependentCreator) + return web.Response(text=f"{method}:{app_factory_instance.dep1}") + + with pytest.warns(DeprecationWarning, match="Bare functions"): + app.router.add_get("/", read_root) # ty: ignore[invalid-argument-type] + client = await aiohttp_client(app) + response = await client.get("/") + assert response.status == http.HTTPStatus.OK + assert await response.text() == "GET:original" + + +def test_inject_keeps_sync_and_async_kinds() -> None: + def sync_handler(request: web.Request) -> None: ... + + async def async_handler(request: web.Request) -> None: ... + + assert not inspect.iscoroutinefunction(inject(sync_handler)) + assert inspect.iscoroutinefunction(inject(async_handler)) + + +def test_sync_inject_without_request_argument_raises_clear_error() -> None: + @inject + def handler( + value: str, + app_factory_instance: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], + ) -> str: + return f"{value}:{app_factory_instance.dep1}" # pragma: no cover -- TypeError precedes this call + + with pytest.raises(TypeError, match=r"web\.Request or a web\.View"): + handler("value") + + +def test_sync_inject_without_setup_di_raises_clear_error() -> None: + @inject + def read_root( + request: web.Request, # noqa: ARG001 + app_factory_instance: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], + ) -> web.Response: + return web.Response(text=app_factory_instance.dep1) # pragma: no cover -- RuntimeError precedes this call + + with pytest.raises(RuntimeError, match="setup_di"): + read_root(make_mocked_request("GET", "/"))