| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480 |
- 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)
|