aiohttp-asgi-connector 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024, Elias Gabriel
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.1
2
+ Name: aiohttp-asgi-connector
3
+ Version: 0.1.0
4
+ Summary: AIOHTTP Connector for running ASGI applications
5
+ Author-Email: thearchitector <me@eliasfgabriel.com>
6
+ License: BSD-3-Clause
7
+ Requires-Python: >=3.8
8
+ Requires-Dist: aiohttp>=3
9
+ Description-Content-Type: text/markdown
10
+
11
+ # aiohttp-asgi
12
+
13
+ ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/thearchitector/aiohttp-asgi-connector/CI.yaml?label=tests&style=flat-square)
14
+ ![PyPI - Downloads](https://img.shields.io/pypi/dw/aiohttp-asgi-connector?style=flat-square)
15
+ ![GitHub](https://img.shields.io/github/license/thearchitector/aiohttp-asgi-connector?style=flat-square)
16
+
17
+ AIOHTTP Connector for running ASGI applications.
18
+
19
+ This library intends to increase the parity between AIOHTTP and HTTPX, specifically with HTTPX's `AsyncClient`. It is primarily intended to be used in test suite scenarios, or other situations where one would want to interface with an ASGI application directly instead of through a web server.
20
+
21
+ Supports Python 3.8+ and AIOHTTP 3+.
22
+
23
+ ## Installation
24
+
25
+ ```sh
26
+ $ pdm add aiohttp-asgi-connector
27
+ # or
28
+ $ python -m pip install --user aiohttp-asgi-connector
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ This library replaces the entire connection stack and pool underlying HTTP transport. AIOHTTP exposes custom connectors via the `connector` argument supplied when creating a `ClientSession` instance.
34
+
35
+ To use the `ASGIApplicationConnector`:
36
+
37
+ ```py
38
+ import asyncio
39
+ from typing import Annotated # or from typing_extensions
40
+
41
+ from aiohttp_asgi_connector import ASGIApplicationConnector
42
+ from aiohttp import ClientSession
43
+ from fastapi import FastAPI, Body
44
+
45
+ app = FastAPI()
46
+
47
+ @app.post("/ping")
48
+ async def pong(message: Annotated[str, Body(embed=True)]):
49
+ return {"broadcast": f"Application says '{message}'!"}
50
+
51
+ async def main():
52
+ connector = ASGIApplicationConnector(app)
53
+ async with ClientSession(base_url="http://localhost", connector=connector) as session:
54
+ async with session.post("/ping", json={"message": "hello"}) as resp:
55
+ print(await resp.json())
56
+ # ==> {'broadcast': "Application says 'hello'!"}
57
+
58
+ asyncio.run(main())
59
+ ```
60
+
61
+ This library does not handle ASGI lifespan events. If you want to run those events, use this library in conjunction with something like [asgi-lifespan](https://pypi.org/project/asgi-lifespan/).
62
+
63
+ ## License
64
+
65
+ This software is licensed under the [BSD 3-Clause License](LICENSE).
@@ -0,0 +1,55 @@
1
+ # aiohttp-asgi
2
+
3
+ ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/thearchitector/aiohttp-asgi-connector/CI.yaml?label=tests&style=flat-square)
4
+ ![PyPI - Downloads](https://img.shields.io/pypi/dw/aiohttp-asgi-connector?style=flat-square)
5
+ ![GitHub](https://img.shields.io/github/license/thearchitector/aiohttp-asgi-connector?style=flat-square)
6
+
7
+ AIOHTTP Connector for running ASGI applications.
8
+
9
+ This library intends to increase the parity between AIOHTTP and HTTPX, specifically with HTTPX's `AsyncClient`. It is primarily intended to be used in test suite scenarios, or other situations where one would want to interface with an ASGI application directly instead of through a web server.
10
+
11
+ Supports Python 3.8+ and AIOHTTP 3+.
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ $ pdm add aiohttp-asgi-connector
17
+ # or
18
+ $ python -m pip install --user aiohttp-asgi-connector
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ This library replaces the entire connection stack and pool underlying HTTP transport. AIOHTTP exposes custom connectors via the `connector` argument supplied when creating a `ClientSession` instance.
24
+
25
+ To use the `ASGIApplicationConnector`:
26
+
27
+ ```py
28
+ import asyncio
29
+ from typing import Annotated # or from typing_extensions
30
+
31
+ from aiohttp_asgi_connector import ASGIApplicationConnector
32
+ from aiohttp import ClientSession
33
+ from fastapi import FastAPI, Body
34
+
35
+ app = FastAPI()
36
+
37
+ @app.post("/ping")
38
+ async def pong(message: Annotated[str, Body(embed=True)]):
39
+ return {"broadcast": f"Application says '{message}'!"}
40
+
41
+ async def main():
42
+ connector = ASGIApplicationConnector(app)
43
+ async with ClientSession(base_url="http://localhost", connector=connector) as session:
44
+ async with session.post("/ping", json={"message": "hello"}) as resp:
45
+ print(await resp.json())
46
+ # ==> {'broadcast': "Application says 'hello'!"}
47
+
48
+ asyncio.run(main())
49
+ ```
50
+
51
+ This library does not handle ASGI lifespan events. If you want to run those events, use this library in conjunction with something like [asgi-lifespan](https://pypi.org/project/asgi-lifespan/).
52
+
53
+ ## License
54
+
55
+ This software is licensed under the [BSD 3-Clause License](LICENSE).
@@ -0,0 +1,3 @@
1
+ from .connector import ASGIApplicationConnector
2
+
3
+ __all__ = ["ASGIApplicationConnector"]
@@ -0,0 +1,59 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from aiohttp import BaseConnector
4
+
5
+ from .transport import ASGITransport
6
+
7
+ if TYPE_CHECKING: # pragma: no cover
8
+ from asyncio import AbstractEventLoop
9
+ from typing import List, Optional
10
+
11
+ from aiohttp import ClientRequest, ClientTimeout
12
+ from aiohttp.client_proto import ResponseHandler
13
+ from aiohttp.client_reqrep import ConnectionKey
14
+ from aiohttp.tracing import Trace
15
+
16
+ from .transport import Application
17
+
18
+
19
+ class ASGIApplicationConnector(BaseConnector):
20
+ """
21
+ A Connector that replaces the underlying connection transport with one that
22
+ intercepts and runs the provided ASGI application.
23
+
24
+ Since requests are handled by the ASGI application directly, there is no concept of
25
+ connection pooling with this connector; every request is processed immediately.
26
+
27
+ @param 'root_path' [""]: alters the root path of the constructed ASGI request
28
+ scope.
29
+ @param 'raise_app_exceptions' [True]: will cause the transport to propagate any
30
+ exceptions produced and not handled by the ASGI application.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ application: "Application",
36
+ root_path: str = "",
37
+ raise_app_exceptions: bool = True,
38
+ loop: "Optional[AbstractEventLoop]" = None,
39
+ ) -> None:
40
+ super().__init__(loop=loop)
41
+ self.app = application
42
+ self.root_path = root_path
43
+ self.raise_app_exceptions = raise_app_exceptions
44
+
45
+ async def _create_connection(
46
+ self, req: "ClientRequest", traces: "List[Trace]", timeout: "ClientTimeout"
47
+ ) -> "ResponseHandler":
48
+ protocol = self._factory()
49
+ transport = ASGITransport(
50
+ protocol, self.app, req, self.root_path, self.raise_app_exceptions
51
+ )
52
+ protocol.connection_made(transport)
53
+ return protocol
54
+
55
+ def _available_connections(self, key: "ConnectionKey") -> int:
56
+ return 1
57
+
58
+ def _get(self, key: "ConnectionKey") -> None:
59
+ return None
@@ -0,0 +1,175 @@
1
+ from asyncio import Transport
2
+ from io import BytesIO
3
+ from typing import TYPE_CHECKING
4
+
5
+ from aiohttp import Payload
6
+
7
+ if TYPE_CHECKING: # pragma: no cover
8
+ from asyncio import Task
9
+ from typing import (
10
+ Any,
11
+ Awaitable,
12
+ Callable,
13
+ Dict,
14
+ Iterator,
15
+ List,
16
+ MutableMapping,
17
+ Optional,
18
+ Tuple,
19
+ )
20
+
21
+ from aiohttp import ClientRequest
22
+ from aiohttp.client_proto import ResponseHandler
23
+
24
+ Application = Callable[
25
+ [
26
+ Dict[str, Any],
27
+ Callable[[], Awaitable[Dict[str, Any]]],
28
+ Callable[[MutableMapping[str, Any]], Awaitable[None]],
29
+ ],
30
+ Awaitable[None],
31
+ ]
32
+
33
+ STATUS_CODE_TO_REASON: "Dict[int, str]" = {
34
+ 200: "OK",
35
+ 201: "Created",
36
+ 202: "Accepted",
37
+ 204: "No Content",
38
+ 301: "Moved Permanently",
39
+ 302: "Found",
40
+ 400: "Bad Request",
41
+ 401: "Unauthorized",
42
+ 403: "Forbidden",
43
+ 404: "Not Found",
44
+ 500: "Internal Server Error",
45
+ 502: "Bad Gateway",
46
+ 503: "Service Unavailable",
47
+ }
48
+
49
+
50
+ class ASGITransport(Transport):
51
+ def __init__(
52
+ self,
53
+ protocol: "ResponseHandler",
54
+ app: "Application",
55
+ request: "ClientRequest",
56
+ root_path: str,
57
+ raise_app_exceptions: bool,
58
+ ):
59
+ super().__init__()
60
+ self.protocol = protocol
61
+ self.app = app
62
+ self.root_path = root_path
63
+ self.raise_app_exceptions = raise_app_exceptions
64
+
65
+ self.request = request
66
+ self.request_size = -1
67
+ self.request_handler: "Optional[Task[None]]" = None
68
+
69
+ self._closing = False
70
+
71
+ async def _handle_request(self) -> None:
72
+ scope: "Dict[str, Any]" = {
73
+ "type": "http",
74
+ "asgi": {"version": "3.0"},
75
+ "http_version": "1.1",
76
+ "method": self.request.method,
77
+ "headers": [(k.lower(), v) for (k, v) in self.request.headers.items()],
78
+ "scheme": self.request.url.scheme,
79
+ "path": self.request.url.path,
80
+ "raw_path": self.request.url.raw_path.split("?")[0],
81
+ "query_string": self.request.url.query,
82
+ "server": (self.request.url.host, self.request.url.port),
83
+ "client": ("127.0.0.1", 123),
84
+ "root_path": self.root_path,
85
+ }
86
+
87
+ payload = BytesIO()
88
+
89
+ if isinstance(self.request.body, Payload):
90
+
91
+ class FalseWriter:
92
+ async def write(self, chunk: bytes) -> None:
93
+ payload.write(chunk)
94
+
95
+ await self.request.body.write(FalseWriter()) # type: ignore
96
+ elif isinstance(self.request.body, tuple):
97
+ for chunk in self.request.body:
98
+ payload.write(chunk)
99
+ else:
100
+ payload.write(self.request.body)
101
+
102
+ request_body_chunks: "Iterator[bytes]" = iter([payload.getvalue()])
103
+ status_code: "Optional[int]" = None
104
+ response_headers: "Optional[List[Tuple[bytes, bytes]]]" = None
105
+ response_body: bytearray = bytearray()
106
+
107
+ async def receive() -> "Dict[str, Any]":
108
+ try:
109
+ body = next(request_body_chunks)
110
+ except StopIteration:
111
+ return {"type": "http.request", "body": b"", "more_body": False}
112
+ return {"type": "http.request", "body": body, "more_body": True}
113
+
114
+ async def send(message: "MutableMapping[str, Any]") -> None:
115
+ nonlocal status_code, response_headers
116
+
117
+ if message["type"] == "http.response.start":
118
+ status_code = message["status"]
119
+ response_headers = message.get("headers", [])
120
+ elif message["type"] == "http.response.body":
121
+ body = message.get("body", b"")
122
+ if body and self.request.method != "HEAD":
123
+ response_body.extend(body)
124
+
125
+ try:
126
+ await self.app(scope, receive, send)
127
+ except Exception:
128
+ if self.raise_app_exceptions:
129
+ raise
130
+
131
+ if status_code is None:
132
+ status_code = 500
133
+ if response_headers is None:
134
+ response_headers = []
135
+
136
+ response_payload = self._encode_response(
137
+ status_code, response_headers, response_body
138
+ )
139
+ self.protocol.data_received(response_payload)
140
+
141
+ def _encode_response(
142
+ self, status: int, headers: "List[Tuple[bytes, bytes]]", body: bytearray
143
+ ) -> bytes:
144
+ status_line = (
145
+ f"HTTP/1.1 {status} {STATUS_CODE_TO_REASON.get(status, 'Unknown')}"
146
+ )
147
+ header_line = "\r\n".join(
148
+ f"{name.decode()}: {value.decode()}" for name, value in headers
149
+ )
150
+ response = f"{status_line}\r\n{header_line}\r\n\r\n{body.decode()}"
151
+ return response.encode()
152
+
153
+ def write(self, data: bytes) -> None:
154
+ if self.request_size == -1:
155
+ self.request_size = 0
156
+ if size := getattr(self.request.body, "size", None):
157
+ self.max_request_size = size
158
+ else:
159
+ self.max_request_size = len(self.request.body)
160
+ else:
161
+ self.request_size += len(data)
162
+
163
+ if self.request_size == self.max_request_size:
164
+ # we've hit EOF. schedule the request for processing. we have to save this
165
+ # to a task since the event loop only holds weak refs and we don't want to
166
+ # GC in the middle of an execution
167
+ task = self.protocol._loop.create_task(self._handle_request())
168
+ self.request_handler = task
169
+
170
+ def close(self) -> None:
171
+ self._closing = True
172
+ del self.request_handler
173
+
174
+ def is_closing(self) -> bool:
175
+ return self._closing
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "aiohttp-asgi-connector"
3
+ version = "0.1.0"
4
+ description = "AIOHTTP Connector for running ASGI applications"
5
+ authors = [
6
+ { name = "thearchitector", email = "me@eliasfgabriel.com" },
7
+ ]
8
+ dependencies = [
9
+ "aiohttp>=3",
10
+ ]
11
+ requires-python = ">=3.8"
12
+ readme = "README.md"
13
+
14
+ [project.license]
15
+ text = "BSD-3-Clause"
16
+
17
+ [tool.pdm.dev-dependencies]
18
+ dev = [
19
+ "fastapi-slim>=0.111.0",
20
+ "pytest>=8.2.2",
21
+ "pytest-asyncio>=0.23.7",
22
+ "mypy>=1.10.1",
23
+ "typing-extensions>=4.12.2",
24
+ ]
25
+
26
+ [tool.pytest.ini_options]
27
+ addopts = "-ra -vv"
28
+ testpaths = [
29
+ "tests",
30
+ ]
31
+ asyncio_mode = "auto"
32
+
33
+ [tool.mypy]
34
+ strict = true
35
+
36
+ [tool.pyright]
37
+ ignore = [
38
+ "tests",
39
+ ]
40
+
41
+ [tool.ruff]
42
+ target-version = "py310"
43
+
44
+ [tool.ruff.lint]
45
+ extend-select = [
46
+ "B",
47
+ "I",
48
+ "ASYNC",
49
+ ]
50
+
51
+ [build-system]
52
+ requires = [
53
+ "pdm-backend",
54
+ ]
55
+ build-backend = "pdm.backend"
@@ -0,0 +1,41 @@
1
+ import pytest
2
+ from aiohttp import ClientSession
3
+ from fastapi import Body, FastAPI
4
+ from fastapi.responses import JSONResponse
5
+ from pytest_asyncio import is_async_test
6
+ from typing_extensions import Annotated
7
+
8
+ from aiohttp_asgi_connector import ASGIApplicationConnector
9
+
10
+
11
+ def pytest_collection_modifyitems(items):
12
+ pytest_asyncio_tests = (item for item in items if is_async_test(item))
13
+ session_scope_marker = pytest.mark.asyncio(scope="session")
14
+ for async_test in pytest_asyncio_tests:
15
+ async_test.add_marker(session_scope_marker, append=False)
16
+
17
+
18
+ app = FastAPI(default_response_class=JSONResponse)
19
+
20
+
21
+ @app.get("/ping")
22
+ async def pong():
23
+ return True
24
+
25
+
26
+ @app.post("/post_ping")
27
+ async def post_ping(message: Annotated[str, Body(embed=True)]):
28
+ return {"broadcast": message}
29
+
30
+
31
+ @pytest.fixture(scope="session")
32
+ async def asgi_connector():
33
+ return ASGIApplicationConnector(app)
34
+
35
+
36
+ @pytest.fixture(scope="session")
37
+ async def session(asgi_connector):
38
+ async with ClientSession(
39
+ base_url="http://localhost", connector=asgi_connector
40
+ ) as session:
41
+ yield session
@@ -0,0 +1,13 @@
1
+ async def test_bad_method(session):
2
+ async with session.post("/ping") as resp:
3
+ assert (await resp.json()) == {"detail": "Method Not Allowed"}
4
+
5
+
6
+ async def test_simple_get(session):
7
+ async with session.get("/ping") as resp:
8
+ assert (await resp.json()) is True
9
+
10
+
11
+ async def test_simple_post(session):
12
+ async with session.post("/post_ping", json={"message": "hello world"}) as resp:
13
+ assert (await resp.json()) == {"broadcast": "hello world"}