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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
20 changes: 16 additions & 4 deletions modern_di_aiohttp/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""modern-di integration for aiohttp."""

import functools
import inspect
import typing

import aiohttp.abc
Expand Down Expand Up @@ -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
66 changes: 66 additions & 0 deletions tests/test_sync_handlers.py
Original file line number Diff line number Diff line change
@@ -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", "/"))
Loading