From fdd674584acb48c5b17cbc7d03ce9e206aecbf6f Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Tue, 10 Jun 2025 14:06:28 +0200 Subject: [PATCH 1/7] Add expect handler and simplify apps --- aiohttp_asgi/resource.py | 80 ++++++++++++++++++++----------- tests/test_fastapi_integration.py | 32 +++++++++++++ 2 files changed, 83 insertions(+), 29 deletions(-) diff --git a/aiohttp_asgi/resource.py b/aiohttp_asgi/resource.py index 679cc8a..8ffa400 100644 --- a/aiohttp_asgi/resource.py +++ b/aiohttp_asgi/resource.py @@ -1,6 +1,9 @@ import asyncio import logging from contextlib import contextmanager +from contextvars import ContextVar +from pathlib import Path +from re import Pattern from types import MappingProxyType from typing import ( Any, Awaitable, Callable, Coroutine, Dict, Generator, List, Mapping, @@ -13,8 +16,12 @@ from aiohttp.abc import AbstractMatchInfo, AbstractStreamWriter from aiohttp.helpers import DEBUG from aiohttp.web import ( - AbstractResource, Application, HTTPException, Request, StreamResponse, - WebSocketResponse, + AbstractResource, AbstractRoute, Application, HTTPException, Request, + StreamResponse, WebSocketResponse, +) +from aiohttp.web_urldispatcher import AbstractRuleMatching +from aiohttp.web_urldispatcher import ( + _default_expect_handler as default_expect_handler, ) from yarl import URL @@ -29,15 +36,26 @@ ] -try: - from aiohttp.web_urldispatcher import ( - _InfoDict as ResourceInfoDict, # type: ignore - ) -except ImportError: - ResourceInfoDict = Dict[str, Any] # type: ignore +log = logging.getLogger(__name__) -log = logging.getLogger(__name__) +class ResourceInfoDict(TypedDict, total=False): + """ + Redefining `aiohttp.web_urldispatcher._InfoDict`. + It is not total and just using for better typing. + Do not afraid this. + """ + + path: str + formatter: str + pattern: Pattern[str] + directory: Path + prefix: str + routes: Mapping[str, AbstractRoute] + app: Application + domain: str + rule: AbstractRuleMatching + http_exception: HTTPException class ScopeDict(TypedDict): @@ -65,20 +83,25 @@ class LifespanDict(TypedDict): asgi: ASGIDict + class ASGIMatchInfo(AbstractMatchInfo): + CURRENT_APP: ContextVar[Application] = ContextVar("CURRENT_APP") + def __init__(self, handler: Callable[..., Any]): self._handler = handler - self._apps: List[Application] = list() - self._current_app: Optional[Application] = None - self._frozen = False + self._apps: Union[List[Application], Tuple[Application, ...]] = list() + + @property + def frozen(self) -> bool: + return isinstance(self._apps, tuple) @property def handler(self) -> Callable[[Request], Awaitable[StreamResponse]]: return self._handler @property - def expect_handler(self) -> Callable[[Request], Awaitable[None]]: - raise NotImplementedError + def expect_handler(self) -> Optional[Callable[[Request], Awaitable[None]]]: + return default_expect_handler @property def http_exception(self) -> Optional[HTTPException]: @@ -93,15 +116,15 @@ def get_info(self) -> Dict[str, Any]: @property def apps(self) -> Tuple[Application, ...]: - if not isinstance(self._apps, tuple): - return tuple(self._apps) - return self._apps + if self.frozen: + return self._apps + return tuple(self._apps) def add_app(self, app: Application) -> None: - if self._frozen: + if isinstance(self._apps, tuple): raise RuntimeError("Cannot change apps stack after .freeze() call") - if self._current_app is None: - self._current_app = app + + self.CURRENT_APP.set(app) self._apps.insert(0, app) @contextmanager @@ -114,19 +137,18 @@ def set_current_app( " instead (https://github.com/mosquito/aiohttp-asgi/pull/11)!", DeprecationWarning, ) - prev = self._current_app - self.add_app(app) - self._current_app = app + prev_app = self.CURRENT_APP.get() + self.CURRENT_APP.set(app) try: yield finally: - self._current_app = prev - self._apps.pop(0) + self.CURRENT_APP.set(prev_app) @property def current_app(self) -> Application: - app = self._current_app - assert app is not None + app = self.CURRENT_APP.get() + if app is None: + raise RuntimeError("No current app set, use add_app() method first") return app @current_app.setter @@ -138,10 +160,10 @@ def current_app(self, app: Application) -> None: self._apps, app, ), ) - self._current_app = app + self.CURRENT_APP.set(app) def freeze(self) -> None: - self._frozen = True + self._apps = tuple(self._apps) _ResponseType = Optional[Union[StreamResponse, WebSocketResponse]] diff --git a/tests/test_fastapi_integration.py b/tests/test_fastapi_integration.py index e68367d..4aa43d2 100644 --- a/tests/test_fastapi_integration.py +++ b/tests/test_fastapi_integration.py @@ -1,3 +1,7 @@ +import asyncio +from asyncio import current_task +from ctypes import pythonapi + import aiohttp import pytest from aiohttp import test_utils, web @@ -35,6 +39,19 @@ async def websocket_endpoint(websocket: ASGIWebSocket): except WebSocketDisconnect: return + @asgi_app.post("/upload") + async def upload_endpoint(request: ASGIRequest): + headers = dict(request.scope["headers"]) + expect_header = headers.get("expect", b"").decode().lower() + + body = await request.body() + + return { + "received_expect": expect_header, + "body_size": len(body), + "message": "success", + } + @pytest.fixture def asgi_resource(routes, asgi_app): @@ -146,3 +163,18 @@ def test_get_routes_from_resource(asgi_resource): for _ in asgi_resource: # Should be unreachable pytest.fail("ASGIResource should not return routes during iteration") + + +async def test_expect_handler_basic(client: test_utils.TestClient): + test_data = b"This is test data for upload" + + async with client.post( + "/upload", + data=test_data, + headers={"Expect": "100-continue", "Content-Type": "application/octet-stream"}, + ) as resp: + assert resp.status == 200 + data = await resp.json() + + assert data["body_size"] == len(test_data) + assert data["message"] == "success" From 5cb42d52c0b3f7453904d16c9ca3bba91274e247 Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Tue, 10 Jun 2025 14:09:31 +0200 Subject: [PATCH 2/7] linter fixes --- aiohttp_asgi/resource.py | 1 - 1 file changed, 1 deletion(-) diff --git a/aiohttp_asgi/resource.py b/aiohttp_asgi/resource.py index 8ffa400..5d9064c 100644 --- a/aiohttp_asgi/resource.py +++ b/aiohttp_asgi/resource.py @@ -83,7 +83,6 @@ class LifespanDict(TypedDict): asgi: ASGIDict - class ASGIMatchInfo(AbstractMatchInfo): CURRENT_APP: ContextVar[Application] = ContextVar("CURRENT_APP") From 55d95469fccc837e3c789d9deb6bc5334a15aa35 Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Tue, 10 Jun 2025 14:10:05 +0200 Subject: [PATCH 3/7] mypy fixes --- aiohttp_asgi/resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp_asgi/resource.py b/aiohttp_asgi/resource.py index 5d9064c..4c81a80 100644 --- a/aiohttp_asgi/resource.py +++ b/aiohttp_asgi/resource.py @@ -115,7 +115,7 @@ def get_info(self) -> Dict[str, Any]: @property def apps(self) -> Tuple[Application, ...]: - if self.frozen: + if isinstance(self._apps, tuple): return self._apps return tuple(self._apps) From 72e596bc8acd00abbae26e7128290b15c909271f Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Tue, 10 Jun 2025 14:10:56 +0200 Subject: [PATCH 4/7] useless imports in tests --- tests/test_fastapi_integration.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_fastapi_integration.py b/tests/test_fastapi_integration.py index 4aa43d2..65488fa 100644 --- a/tests/test_fastapi_integration.py +++ b/tests/test_fastapi_integration.py @@ -1,7 +1,3 @@ -import asyncio -from asyncio import current_task -from ctypes import pythonapi - import aiohttp import pytest from aiohttp import test_utils, web From b2fc71c3c84ac7674acd4e1a959023575eb3045c Mon Sep 17 00:00:00 2001 From: Mosquito Date: Tue, 10 Jun 2025 14:12:26 +0200 Subject: [PATCH 5/7] Update aiohttp_asgi/resource.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- aiohttp_asgi/resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp_asgi/resource.py b/aiohttp_asgi/resource.py index 4c81a80..adda8a5 100644 --- a/aiohttp_asgi/resource.py +++ b/aiohttp_asgi/resource.py @@ -43,7 +43,7 @@ class ResourceInfoDict(TypedDict, total=False): """ Redefining `aiohttp.web_urldispatcher._InfoDict`. It is not total and just using for better typing. - Do not afraid this. + Do not be afraid of this. """ path: str From 859b66d873d51b34f2e9961e76a1c9d7ccd7985f Mon Sep 17 00:00:00 2001 From: Mosquito Date: Tue, 10 Jun 2025 14:12:37 +0200 Subject: [PATCH 6/7] Update tests/test_fastapi_integration.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_fastapi_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_fastapi_integration.py b/tests/test_fastapi_integration.py index 65488fa..3d0ea69 100644 --- a/tests/test_fastapi_integration.py +++ b/tests/test_fastapi_integration.py @@ -38,7 +38,7 @@ async def websocket_endpoint(websocket: ASGIWebSocket): @asgi_app.post("/upload") async def upload_endpoint(request: ASGIRequest): headers = dict(request.scope["headers"]) - expect_header = headers.get("expect", b"").decode().lower() + expect_header = headers.get(b"expect", b"").decode().lower() body = await request.body() From ec290fb5fc8b38d5af9dd075a1fe0d6e08fec84b Mon Sep 17 00:00:00 2001 From: Mosquito Date: Tue, 10 Jun 2025 14:12:46 +0200 Subject: [PATCH 7/7] Update aiohttp_asgi/resource.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- aiohttp_asgi/resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp_asgi/resource.py b/aiohttp_asgi/resource.py index adda8a5..b99c432 100644 --- a/aiohttp_asgi/resource.py +++ b/aiohttp_asgi/resource.py @@ -145,7 +145,7 @@ def set_current_app( @property def current_app(self) -> Application: - app = self.CURRENT_APP.get() + app = self.CURRENT_APP.get(None) if app is None: raise RuntimeError("No current app set, use add_app() method first") return app