from __future__ import annotations import contextlib import contextvars import threading import time from collections.abc import Callable, Iterator from concurrent.futures import Future, ThreadPoolExecutor from io import StringIO from queue import Empty, Queue from typing import TYPE_CHECKING from urllib.parse import unquote, urlparse from pyqwest import ( Headers, HTTPVersion, ReadError, SyncRequest, SyncResponse, SyncTransport, WriteError, ) from pyqwest._pyqwest import get_sync_timeout from ._decompress import Decompressor, get_decompressor if TYPE_CHECKING: import sys if sys.version_info >= (3, 11): from wsgiref.types import WSGIApplication, WSGIEnvironment else: from _typeshed.wsgi import WSGIApplication, WSGIEnvironment _UNSET_STATUS = "unset" _DEFAULT_EXECUTOR: ThreadPoolExecutor | None = None class ContextCopyingExecutor(ThreadPoolExecutor): """ThreadPoolExecutor that copies context variables from the submitting thread to the worker thread.""" def submit( self, fn: Callable[..., object], *args: object, **kwargs: object ) -> Future: ctx = contextvars.copy_context() return super().submit(lambda: ctx.run(fn, *args, **kwargs)) def get_default_executor() -> ThreadPoolExecutor: global _DEFAULT_EXECUTOR # noqa: PLW0603 if _DEFAULT_EXECUTOR is None: _DEFAULT_EXECUTOR = ContextCopyingExecutor() return _DEFAULT_EXECUTOR class WSGITransport(SyncTransport): """Transport implementation that directly invokes a WSGI application. Useful for testing.""" _app: WSGIApplication _http_version: HTTPVersion _client: tuple[str, int] _closed: bool _app_exception: Exception | None _error_stream: StringIO def __init__( self, app: WSGIApplication, http_version: HTTPVersion = HTTPVersion.HTTP2, client: tuple[str, int] = ("127.0.0.1", 111), executor: ThreadPoolExecutor | None = None, ) -> None: """Creates a new WSGI transport. Args: app: The WSGI application to invoke for requests. http_version: The HTTP version to simulate for requests. executor: An optional ThreadPoolExecutor to use for running the WSGI app. If not provided, a default executor will be used. """ self._app = app self._http_version = http_version self._client = client self._executor = executor or get_default_executor() self._closed = False self._app_exception = None self._error_stream = StringIO() def execute_sync(self, request: SyncRequest) -> SyncResponse: timeout = get_sync_timeout() deadline = None if timeout is not None: deadline = time.monotonic() + timeout.total_seconds() parsed_url = urlparse(request.url) raw_path = parsed_url.path or "/" path = unquote(raw_path).encode().decode("latin-1") query = parsed_url.query.encode().decode("latin-1") match self._http_version: case HTTPVersion.HTTP1: server_protocol = "HTTP/1.1" case HTTPVersion.HTTP2: server_protocol = "HTTP/2" case HTTPVersion.HTTP3: server_protocol = "HTTP/3" case _: server_protocol = "HTTP/1.1" trailers = Headers() trailers_supported = ( self._http_version == HTTPVersion.HTTP2 and request.headers.get("te", "") == "trailers" ) def send_trailers(headers: list[tuple[str, str]]) -> None: if not trailers_supported: return for k, v in headers: trailers.add(k, v) request_input = RequestInput(request.content, self._http_version) environ: WSGIEnvironment = { "REQUEST_METHOD": request.method, "SCRIPT_NAME": "", "PATH_INFO": path, "QUERY_STRING": query, "SERVER_NAME": parsed_url.hostname or "", "SERVER_PORT": str( parsed_url.port or (443 if parsed_url.scheme == "https" else 80) ), "SERVER_PROTOCOL": server_protocol, "wsgi.url_scheme": parsed_url.scheme, "wsgi.version": (1, 0), "wsgi.multithread": True, "wsgi.multiprocess": False, "wsgi.run_once": False, "wsgi.input": request_input, "wsgi.errors": self._error_stream, "wsgi.ext.http.send_trailers": send_trailers, # CGI, not WSGI "REMOTE_ADDR": self._client[0], "REMOTE_PORT": str(self._client[1]), } for k, v in request.headers.items(): match k: case "content-type": environ["CONTENT_TYPE"] = v case "content-length": environ["CONTENT_LENGTH"] = v case _: name = f"HTTP_{k.upper().replace('-', '_')}" value = f"{existing},{v}" if (existing := environ.get(name)) else v environ[name] = value if "host" not in request.headers: environ["HTTP_HOST"] = parsed_url.netloc if request._json and "content-type" not in request.headers: # noqa: SLF001 environ["CONTENT_TYPE"] = "application/json" response_queue: Queue[bytes | None | Exception] = Queue() status_str: str = _UNSET_STATUS headers: list[tuple[str, str]] = [] exc: ( tuple[type[BaseException], BaseException, object] | tuple[None, None, None] | None ) = None response_started = threading.Event() def start_response( status: str, response_headers: list[tuple[str, str]], exc_info: tuple[type[BaseException], BaseException, object] | tuple[None, None, None] | None = None, ) -> Callable[[bytes], object]: nonlocal status_str, headers, exc status_str = status headers = response_headers exc = exc_info def write(body: bytes) -> None: if not response_started.is_set(): response_started.set() if body: response_queue.put(body) return write def run_app() -> None: try: response_iter = self._app(environ, start_response) for chunk in response_iter: if chunk: if not response_started.is_set(): response_started.set() response_queue.put(chunk) except Exception as e: self._app_exception = e response_queue.put(e) else: response_queue.put(None) finally: if not response_started.is_set(): request_input.close() response_started.set() with contextlib.suppress(Exception): response_iter.close() # ty: ignore[unresolved-attribute] app_future = self._executor.submit(run_app) if not response_started.wait( timeout=timeout.total_seconds() if timeout is not None else None ): request_input.close() msg = "Application did not start response before timeout" raise WSGITimeoutError(msg, app_future) if status_str is _UNSET_STATUS: return SyncResponse( status=500, http_version=self._http_version, headers=Headers((("content-type", "text/plain"),)), content=b"WSGI application did not call start_response", ) if exc and exc[0]: if isinstance(exc[1], TimeoutError): # Allow an app to evaluate a TimeoutError itself. raise WSGITimeoutError(str(exc[1]), app_future) from exc[1] if isinstance(exc[1], ConnectionError): # Allow an app to evaluate a ConnectionError itself. raise WSGIConnectionError(str(exc[1]), app_future) from exc[1] return SyncResponse( status=500, http_version=self._http_version, headers=Headers((("content-type", "text/plain"),)), content=str(exc[1]).encode(), ) response_headers = Headers(headers) decompressor = get_decompressor(response_headers.get("content-encoding")) response_content = ResponseContent( response_queue, request_input, app_future, deadline, decompressor ) status = int(status_str.split(" ", 1)[0]) return SyncResponse( status=status, headers=response_headers, http_version=self._http_version, content=response_content, trailers=trailers, ) @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 @property def error_stream(self) -> StringIO: """The error stream to which the WSGI application writes. This is not reset per request or threadsafe, so it is generally expected to be used with a transport that is used only once, or in a precise order. """ return self._error_stream class RequestInput: def __init__( self, content: bytes | Iterator[bytes], http_version: HTTPVersion ) -> None: if isinstance(content, bytes): content = iter([content]) self._content = content self._http_version = http_version self._closed = False self._buffer = bytearray() def read(self, size: int = -1) -> bytes: return self._do_read(size) def readline(self, size: int = -1) -> bytes: if self._closed or size == 0: return b"" line = bytearray() while True: sz = size - len(line) if size >= 0 else -1 read_bytes = self._do_read(sz) if not read_bytes: return bytes(line) if len(line) + len(read_bytes) == size: return bytes(line + read_bytes) newline_index = read_bytes.find(b"\n") if newline_index == -1: line.extend(read_bytes) continue res = line + read_bytes[: newline_index + 1] self._buffer.extend(read_bytes[newline_index + 1 :]) return bytes(res) def __iter__(self) -> Iterator[bytes]: return self def __next__(self) -> bytes: line = self.readline() if not line: raise StopIteration return line def readlines(self, hint: int = -1) -> list[bytes]: return list(self) def _do_read(self, size: int) -> bytes: if self._closed or size == 0: return b"" try: while True: chunk = next(self._content) if size < 0: self._buffer.extend(chunk) continue if len(self._buffer) + len(chunk) >= size: to_read = size - len(self._buffer) res = self._buffer + chunk[:to_read] self._buffer.clear() self._buffer.extend(chunk[to_read:]) return bytes(res) if len(self._buffer) == 0: return chunk res = self._buffer + chunk self._buffer.clear() return bytes(res) except StopIteration: self.close() res = bytes(self._buffer) self._buffer = bytearray() return res except Exception as e: self.close() if self._http_version != HTTPVersion.HTTP2: msg = f"Request failed: {e}" else: # With HTTP/2, reqwest seems to squash the original error message. msg = "Request failed: stream error sent by user" raise WriteError(msg) from e def close(self) -> None: if self._closed: return self._closed = True with contextlib.suppress(Exception): self._content.close() # ty: ignore[unresolved-attribute] class ResponseContent(Iterator[bytes]): def __init__( self, response_queue: Queue[bytes | None | Exception], request_input: RequestInput, app_future: Future, deadline: float | None, decompressor: Decompressor, ) -> None: self._response_queue = response_queue self._request_input = request_input self._app_future = app_future self._closed = False self._read_pending = False self._deadline = deadline self._decompressor = decompressor def __iter__(self) -> Iterator[bytes]: return self def __next__(self) -> bytes: if self._closed: raise StopIteration err: Exception | None = None self._read_pending = True chunk = b"" try: if self._deadline: while True: time_left = self._deadline - time.monotonic() if time_left <= 0: msg = "Response read timed out" message = TimeoutError(msg) break try: message = self._response_queue.get(timeout=time_left) break except Empty: continue else: message = self._response_queue.get() finally: self._read_pending = False if isinstance(message, Exception): match message: case WriteError() | TimeoutError(): err = message case _: msg = "Request Failed: Error reading response body" err = ReadError(msg) elif message is None: remaining = self._decompressor.feed(b"", end=True) if remaining: self._closed = True self._request_input.close() with contextlib.suppress(Exception): self._app_future.result() return remaining err = StopIteration() else: chunk = message if err: self._closed = True self._request_input.close() with contextlib.suppress(Exception): self._app_future.result() raise err return self._decompressor.feed(chunk, end=False) def __del__(self) -> None: self.close() def close(self) -> None: if self._closed: return self._closed = True self._request_input.close() self._response_queue.put(ReadError("Response body read cancelled")) with contextlib.suppress(Exception): self._app_future.result() class WSGITimeoutError(TimeoutError): """Timeout error raised by WSGI transport. Contains a handle to the app future to allow joining on its thread. """ def __init__(self, msg: str, app_future: Future) -> None: super().__init__(msg) self._app_future = app_future def wait(self, timeout: float | None = None) -> None: """Waits for the WSGI application to finish.""" with contextlib.suppress(Exception): self._app_future.result(timeout) class WSGIConnectionError(ConnectionError): """Connection error raised by WSGI transport. Contains a handle to the app future to allow joining on its thread. """ def __init__(self, msg: str, app_future: Future) -> None: super().__init__(msg) self._app_future = app_future def wait(self, timeout: float | None = None) -> None: """Waits for the WSGI application to finish.""" with contextlib.suppress(Exception): self._app_future.result(timeout)