from __future__ import annotations import asyncio import contextlib from collections.abc import AsyncIterator from dataclasses import dataclass from typing import TYPE_CHECKING, Any from urllib.parse import unquote, urlparse from pyqwest import ( Headers, HTTPVersion, ReadError, Request, Response, Transport, WriteError, ) from ._asgi_compatibility import guarantee_single_callable from ._decompress import Decompressor, get_decompressor if TYPE_CHECKING: from types import TracebackType from asgiref.typing import ( ASGI3Application, ASGIApplication, ASGIReceiveEvent, ASGISendEvent, ASGIVersions, HTTPScope, LifespanScope, LifespanShutdownEvent, LifespanStartupEvent, ) _asgi: ASGIVersions = {"version": "3.0", "spec_version": "2.5"} _extensions = {"http.response.trailers": {}} @dataclass(frozen=True) class Lifespan: task: asyncio.Task[None] receive_queue: asyncio.Queue[LifespanStartupEvent | LifespanShutdownEvent] send_queue: asyncio.Queue[ASGISendEvent | Exception] class ASGITransport(Transport): """Transport implementation that directly invokes an ASGI application. Useful for testing. The ASGI transport supports lifespan - to use it, make sure to use the transport as an asynchronous context manager. Lifespan startup will be run on entering and shutdown when exiting. """ _app: ASGI3Application _http_version: HTTPVersion _client: tuple[str, int] _state: dict[str, Any] _lifespan: Lifespan | None _app_exception: Exception | None def __init__( self, app: ASGIApplication, http_version: HTTPVersion = HTTPVersion.HTTP2, client: tuple[str, int] = ("127.0.0.1", 111), ) -> None: """Creates a new ASGI transport. Args: app: The ASGI application to invoke. http_version: The HTTP version to mimic for requests. Note, semantics such as lack of bidirectional streaming for HTTP/1 are not enforced. client: The (host, port) tuple to use for the client address in the ASGI scope. """ self._app = guarantee_single_callable(app) self._http_version = http_version self._client = client self._state = {} self._lifespan = None self._app_exception = None async def execute(self, request: Request) -> Response: parsed_url = urlparse(request.url) raw_path = parsed_url.path or "/" path = unquote(raw_path) match self._http_version: case HTTPVersion.HTTP1: http_version = "1.1" case HTTPVersion.HTTP2: http_version = "2" case HTTPVersion.HTTP3: http_version = "3" case _: http_version = "1.1" request_headers = Headers(request.headers.items()) if "host" not in request_headers: request_headers["host"] = parsed_url.netloc if request._json and "content-type" not in request_headers: # noqa: SLF001 request_headers["content-type"] = "application/json" scope: HTTPScope = { "type": "http", "asgi": _asgi, "http_version": http_version, "method": request.method, "scheme": parsed_url.scheme, "path": path, "raw_path": raw_path.encode(), "query_string": parsed_url.query.encode(), "headers": [ (k.lower().encode("utf-8"), v.encode("utf-8")) for k, v in request_headers.items() ], "server": ( parsed_url.hostname or "", parsed_url.port or (443 if parsed_url.scheme == "https" else 80), ), "client": self._client, "extensions": _extensions, "state": self._state, "root_path": "", } receive_queue: asyncio.Queue[bytes | Exception | None] = asyncio.Queue(1) receive_started = asyncio.Event() async def read_request_content() -> None: try: await receive_started.wait() if isinstance(request.content, bytes): await receive_queue.put(request.content) await receive_queue.put(None) return async for chunk in request.content: if not isinstance(chunk, bytes): msg = "Request not bytes object" raise WriteError(msg) # noqa: TRY301 await receive_queue.put(chunk) await receive_queue.put(None) except Exception as e: await receive_queue.put(e) finally: try: aclose = request.content.aclose # ty: ignore[unresolved-attribute] except AttributeError: pass else: await aclose() # Need a separate task to read the request body to allow # cancelling when response closes. request_task = asyncio.create_task(read_request_content()) async def receive() -> ASGIReceiveEvent: receive_started.set() chunk = await receive_queue.get() if chunk is None: return {"type": "http.request", "body": b"", "more_body": False} if isinstance(chunk, Exception): if self._http_version != HTTPVersion.HTTP2: msg = f"Request failed: {chunk}" else: # With HTTP/2, reqwest seems to squash the original error message. msg = "Request failed: stream error sent by user" raise WriteError(msg) from chunk if isinstance(chunk, BaseException): raise chunk return {"type": "http.request", "body": chunk, "more_body": True} send_queue: asyncio.Queue[ASGISendEvent | Exception] = asyncio.Queue() async def send(message: ASGISendEvent) -> None: await send_queue.put(message) async def run_app() -> None: try: await self._app(scope, receive, send) except asyncio.TimeoutError as e: send_queue.put_nowait(TimeoutError(str(e))) except Exception as e: self._app_exception = e send_queue.put_nowait(e) app_task = asyncio.create_task(run_app()) message = await send_queue.get() if isinstance(message, Exception): request_task.cancel() with contextlib.suppress(BaseException): await app_task await request_task if isinstance(message, (ConnectionError, TimeoutError)): raise message return Response( status=500, http_version=self._http_version, headers=Headers((("content-type", "text/plain"),)), content=str(message).encode(), ) assert message["type"] == "http.response.start" # noqa: S101 status = message["status"] headers = Headers( ( (k.decode("utf-8"), v.decode("utf-8")) for k, v in message.get("headers", []) ) ) trailers = ( Headers() if self._http_version == HTTPVersion.HTTP2 and request.headers.get("te") == "trailers" else None ) decompressor = get_decompressor(headers.get("content-encoding")) response_content = ResponseContent( send_queue, request_task, trailers, app_task, decompressor, read_trailers=message.get("trailers", False), ) return Response( status=status, http_version=self._http_version, headers=headers, content=response_content, trailers=trailers, ) async def __aenter__(self) -> ASGITransport: await self.run_lifespan() return self async def __aexit__( self, _exc_type: type[BaseException] | None, _exc_value: BaseException | None, _traceback: TracebackType | None, ) -> None: await self.close() async def run_lifespan(self) -> None: scope: LifespanScope = {"type": "lifespan", "asgi": _asgi, "state": self._state} receive_queue: asyncio.Queue[LifespanStartupEvent | LifespanShutdownEvent] = ( asyncio.Queue() ) async def receive() -> LifespanStartupEvent | LifespanShutdownEvent: return await receive_queue.get() send_queue: asyncio.Queue[ASGISendEvent | Exception] = asyncio.Queue() async def send(message: ASGISendEvent) -> None: await send_queue.put(message) async def run_app() -> None: try: await self._app(scope, receive, send) except Exception as e: send_queue.put_nowait(e) task = asyncio.create_task(run_app()) receive_queue.put_nowait({"type": "lifespan.startup"}) message = await send_queue.get() if isinstance(message, Exception): # Lifespan not supported await task return self._lifespan = Lifespan( task=task, receive_queue=receive_queue, send_queue=send_queue ) match message["type"]: case "lifespan.startup.complete": return case "lifespan.startup.failed": msg = ( f"ASGI application failed to start up: {message.get('message', '')}" ) raise RuntimeError(msg) async def close(self) -> None: if self._lifespan is None: return await self._lifespan.receive_queue.put({"type": "lifespan.shutdown"}) message = await self._lifespan.send_queue.get() await self._lifespan.task if isinstance(message, Exception): raise message match message["type"]: case "lifespan.shutdown.complete": return case "lifespan.shutdown.failed": msg = f"ASGI application failed to shut down cleanly: {message.get('message', '')}" raise RuntimeError(msg) @property def app_exception(self) -> Exception | None: """The exception raised by the ASGI application, if any. This will be overwritten for any request which raises an exception, so it is generally expected to be used with a transport that is used only once, or in a precise order. """ return self._app_exception class CancelResponse(Exception): pass class ResponseContent(AsyncIterator[bytes]): def __init__( self, send_queue: asyncio.Queue[ASGISendEvent | Exception], request_task: asyncio.Task[None], trailers: Headers | None, task: asyncio.Task[None], decompressor: Decompressor, *, read_trailers: bool, ) -> None: self._send_queue = send_queue self._request_task = request_task self._trailers = trailers self._task = task self._decompressor = decompressor self._read_trailers = read_trailers self._read_pending = False self._closed = False def __aiter__(self) -> AsyncIterator[bytes]: return self async def __anext__(self) -> bytes: if self._closed: raise StopAsyncIteration err: Exception | None = None body: bytes | None = None while True: self._read_pending = True try: message = await self._send_queue.get() finally: self._read_pending = False if isinstance(message, Exception): match message: case CancelResponse(): err = StopAsyncIteration() break case WriteError() | TimeoutError(): err = message break case ReadError(): raise message case Exception(): msg = "Error reading response body" raise ReadError(msg) from message match message["type"]: case "http.response.body": more_body = message.get("more_body", False) if not more_body and not self._read_trailers: await self._cleanup() if (body := message.get("body", b"")) or self._closed: return self._decompressor.feed(body, end=not more_body) case "http.response.trailers": if self._trailers is not None: for k, v in message.get("headers", []): self._trailers.add(k.decode("utf-8"), v.decode("utf-8")) if not message.get("more_trailers", False): break await self._cleanup() if err: raise err raise StopAsyncIteration async def aclose(self) -> None: if self._closed: return self._closed = True self._send_queue.put_nowait(ReadError("Response body read cancelled")) await self._cleanup() async def _cleanup(self) -> None: self._closed = True self._request_task.cancel() with contextlib.suppress(BaseException): await self._request_task self._task.cancel() with contextlib.suppress(BaseException): await self._task