_wsgi.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. from __future__ import annotations
  2. import contextlib
  3. import contextvars
  4. import threading
  5. import time
  6. from collections.abc import Callable, Iterator
  7. from concurrent.futures import Future, ThreadPoolExecutor
  8. from io import StringIO
  9. from queue import Empty, Queue
  10. from typing import TYPE_CHECKING
  11. from urllib.parse import unquote, urlparse
  12. from pyqwest import (
  13. Headers,
  14. HTTPVersion,
  15. ReadError,
  16. SyncRequest,
  17. SyncResponse,
  18. SyncTransport,
  19. WriteError,
  20. )
  21. from pyqwest._pyqwest import get_sync_timeout
  22. from ._decompress import Decompressor, get_decompressor
  23. if TYPE_CHECKING:
  24. import sys
  25. if sys.version_info >= (3, 11):
  26. from wsgiref.types import WSGIApplication, WSGIEnvironment
  27. else:
  28. from _typeshed.wsgi import WSGIApplication, WSGIEnvironment
  29. _UNSET_STATUS = "unset"
  30. _DEFAULT_EXECUTOR: ThreadPoolExecutor | None = None
  31. class ContextCopyingExecutor(ThreadPoolExecutor):
  32. """ThreadPoolExecutor that copies context variables from the submitting thread to the worker thread."""
  33. def submit(
  34. self, fn: Callable[..., object], *args: object, **kwargs: object
  35. ) -> Future:
  36. ctx = contextvars.copy_context()
  37. return super().submit(lambda: ctx.run(fn, *args, **kwargs))
  38. def get_default_executor() -> ThreadPoolExecutor:
  39. global _DEFAULT_EXECUTOR # noqa: PLW0603
  40. if _DEFAULT_EXECUTOR is None:
  41. _DEFAULT_EXECUTOR = ContextCopyingExecutor()
  42. return _DEFAULT_EXECUTOR
  43. class WSGITransport(SyncTransport):
  44. """Transport implementation that directly invokes a WSGI application. Useful for testing."""
  45. _app: WSGIApplication
  46. _http_version: HTTPVersion
  47. _client: tuple[str, int]
  48. _closed: bool
  49. _app_exception: Exception | None
  50. _error_stream: StringIO
  51. def __init__(
  52. self,
  53. app: WSGIApplication,
  54. http_version: HTTPVersion = HTTPVersion.HTTP2,
  55. client: tuple[str, int] = ("127.0.0.1", 111),
  56. executor: ThreadPoolExecutor | None = None,
  57. ) -> None:
  58. """Creates a new WSGI transport.
  59. Args:
  60. app: The WSGI application to invoke for requests.
  61. http_version: The HTTP version to simulate for requests.
  62. executor: An optional ThreadPoolExecutor to use for running the WSGI app.
  63. If not provided, a default executor will be used.
  64. """
  65. self._app = app
  66. self._http_version = http_version
  67. self._client = client
  68. self._executor = executor or get_default_executor()
  69. self._closed = False
  70. self._app_exception = None
  71. self._error_stream = StringIO()
  72. def execute_sync(self, request: SyncRequest) -> SyncResponse:
  73. timeout = get_sync_timeout()
  74. deadline = None
  75. if timeout is not None:
  76. deadline = time.monotonic() + timeout.total_seconds()
  77. parsed_url = urlparse(request.url)
  78. raw_path = parsed_url.path or "/"
  79. path = unquote(raw_path).encode().decode("latin-1")
  80. query = parsed_url.query.encode().decode("latin-1")
  81. match self._http_version:
  82. case HTTPVersion.HTTP1:
  83. server_protocol = "HTTP/1.1"
  84. case HTTPVersion.HTTP2:
  85. server_protocol = "HTTP/2"
  86. case HTTPVersion.HTTP3:
  87. server_protocol = "HTTP/3"
  88. case _:
  89. server_protocol = "HTTP/1.1"
  90. trailers = Headers()
  91. trailers_supported = (
  92. self._http_version == HTTPVersion.HTTP2
  93. and request.headers.get("te", "") == "trailers"
  94. )
  95. def send_trailers(headers: list[tuple[str, str]]) -> None:
  96. if not trailers_supported:
  97. return
  98. for k, v in headers:
  99. trailers.add(k, v)
  100. request_input = RequestInput(request.content, self._http_version)
  101. environ: WSGIEnvironment = {
  102. "REQUEST_METHOD": request.method,
  103. "SCRIPT_NAME": "",
  104. "PATH_INFO": path,
  105. "QUERY_STRING": query,
  106. "SERVER_NAME": parsed_url.hostname or "",
  107. "SERVER_PORT": str(
  108. parsed_url.port or (443 if parsed_url.scheme == "https" else 80)
  109. ),
  110. "SERVER_PROTOCOL": server_protocol,
  111. "wsgi.url_scheme": parsed_url.scheme,
  112. "wsgi.version": (1, 0),
  113. "wsgi.multithread": True,
  114. "wsgi.multiprocess": False,
  115. "wsgi.run_once": False,
  116. "wsgi.input": request_input,
  117. "wsgi.errors": self._error_stream,
  118. "wsgi.ext.http.send_trailers": send_trailers,
  119. # CGI, not WSGI
  120. "REMOTE_ADDR": self._client[0],
  121. "REMOTE_PORT": str(self._client[1]),
  122. }
  123. for k, v in request.headers.items():
  124. match k:
  125. case "content-type":
  126. environ["CONTENT_TYPE"] = v
  127. case "content-length":
  128. environ["CONTENT_LENGTH"] = v
  129. case _:
  130. name = f"HTTP_{k.upper().replace('-', '_')}"
  131. value = f"{existing},{v}" if (existing := environ.get(name)) else v
  132. environ[name] = value
  133. if "host" not in request.headers:
  134. environ["HTTP_HOST"] = parsed_url.netloc
  135. if request._json and "content-type" not in request.headers: # noqa: SLF001
  136. environ["CONTENT_TYPE"] = "application/json"
  137. response_queue: Queue[bytes | None | Exception] = Queue()
  138. status_str: str = _UNSET_STATUS
  139. headers: list[tuple[str, str]] = []
  140. exc: (
  141. tuple[type[BaseException], BaseException, object]
  142. | tuple[None, None, None]
  143. | None
  144. ) = None
  145. response_started = threading.Event()
  146. def start_response(
  147. status: str,
  148. response_headers: list[tuple[str, str]],
  149. exc_info: tuple[type[BaseException], BaseException, object]
  150. | tuple[None, None, None]
  151. | None = None,
  152. ) -> Callable[[bytes], object]:
  153. nonlocal status_str, headers, exc
  154. status_str = status
  155. headers = response_headers
  156. exc = exc_info
  157. def write(body: bytes) -> None:
  158. if not response_started.is_set():
  159. response_started.set()
  160. if body:
  161. response_queue.put(body)
  162. return write
  163. def run_app() -> None:
  164. try:
  165. response_iter = self._app(environ, start_response)
  166. for chunk in response_iter:
  167. if chunk:
  168. if not response_started.is_set():
  169. response_started.set()
  170. response_queue.put(chunk)
  171. except Exception as e:
  172. self._app_exception = e
  173. response_queue.put(e)
  174. else:
  175. response_queue.put(None)
  176. finally:
  177. if not response_started.is_set():
  178. request_input.close()
  179. response_started.set()
  180. with contextlib.suppress(Exception):
  181. response_iter.close() # ty: ignore[unresolved-attribute]
  182. app_future = self._executor.submit(run_app)
  183. if not response_started.wait(
  184. timeout=timeout.total_seconds() if timeout is not None else None
  185. ):
  186. request_input.close()
  187. msg = "Application did not start response before timeout"
  188. raise WSGITimeoutError(msg, app_future)
  189. if status_str is _UNSET_STATUS:
  190. return SyncResponse(
  191. status=500,
  192. http_version=self._http_version,
  193. headers=Headers((("content-type", "text/plain"),)),
  194. content=b"WSGI application did not call start_response",
  195. )
  196. if exc and exc[0]:
  197. if isinstance(exc[1], TimeoutError):
  198. # Allow an app to evaluate a TimeoutError itself.
  199. raise WSGITimeoutError(str(exc[1]), app_future) from exc[1]
  200. if isinstance(exc[1], ConnectionError):
  201. # Allow an app to evaluate a ConnectionError itself.
  202. raise WSGIConnectionError(str(exc[1]), app_future) from exc[1]
  203. return SyncResponse(
  204. status=500,
  205. http_version=self._http_version,
  206. headers=Headers((("content-type", "text/plain"),)),
  207. content=str(exc[1]).encode(),
  208. )
  209. response_headers = Headers(headers)
  210. decompressor = get_decompressor(response_headers.get("content-encoding"))
  211. response_content = ResponseContent(
  212. response_queue, request_input, app_future, deadline, decompressor
  213. )
  214. status = int(status_str.split(" ", 1)[0])
  215. return SyncResponse(
  216. status=status,
  217. headers=response_headers,
  218. http_version=self._http_version,
  219. content=response_content,
  220. trailers=trailers,
  221. )
  222. @property
  223. def app_exception(self) -> Exception | None:
  224. """The exception raised by the ASGI application, if any.
  225. This will be overwritten for any request which raises an exception, so it is generally
  226. expected to be used with a transport that is used only once, or in a precise order.
  227. """
  228. return self._app_exception
  229. @property
  230. def error_stream(self) -> StringIO:
  231. """The error stream to which the WSGI application writes.
  232. This is not reset per request or threadsafe, so it is generally expected to be used
  233. with a transport that is used only once, or in a precise order.
  234. """
  235. return self._error_stream
  236. class RequestInput:
  237. def __init__(
  238. self, content: bytes | Iterator[bytes], http_version: HTTPVersion
  239. ) -> None:
  240. if isinstance(content, bytes):
  241. content = iter([content])
  242. self._content = content
  243. self._http_version = http_version
  244. self._closed = False
  245. self._buffer = bytearray()
  246. def read(self, size: int = -1) -> bytes:
  247. return self._do_read(size)
  248. def readline(self, size: int = -1) -> bytes:
  249. if self._closed or size == 0:
  250. return b""
  251. line = bytearray()
  252. while True:
  253. sz = size - len(line) if size >= 0 else -1
  254. read_bytes = self._do_read(sz)
  255. if not read_bytes:
  256. return bytes(line)
  257. if len(line) + len(read_bytes) == size:
  258. return bytes(line + read_bytes)
  259. newline_index = read_bytes.find(b"\n")
  260. if newline_index == -1:
  261. line.extend(read_bytes)
  262. continue
  263. res = line + read_bytes[: newline_index + 1]
  264. self._buffer.extend(read_bytes[newline_index + 1 :])
  265. return bytes(res)
  266. def __iter__(self) -> Iterator[bytes]:
  267. return self
  268. def __next__(self) -> bytes:
  269. line = self.readline()
  270. if not line:
  271. raise StopIteration
  272. return line
  273. def readlines(self, hint: int = -1) -> list[bytes]:
  274. return list(self)
  275. def _do_read(self, size: int) -> bytes:
  276. if self._closed or size == 0:
  277. return b""
  278. try:
  279. while True:
  280. chunk = next(self._content)
  281. if size < 0:
  282. self._buffer.extend(chunk)
  283. continue
  284. if len(self._buffer) + len(chunk) >= size:
  285. to_read = size - len(self._buffer)
  286. res = self._buffer + chunk[:to_read]
  287. self._buffer.clear()
  288. self._buffer.extend(chunk[to_read:])
  289. return bytes(res)
  290. if len(self._buffer) == 0:
  291. return chunk
  292. res = self._buffer + chunk
  293. self._buffer.clear()
  294. return bytes(res)
  295. except StopIteration:
  296. self.close()
  297. res = bytes(self._buffer)
  298. self._buffer = bytearray()
  299. return res
  300. except Exception as e:
  301. self.close()
  302. if self._http_version != HTTPVersion.HTTP2:
  303. msg = f"Request failed: {e}"
  304. else:
  305. # With HTTP/2, reqwest seems to squash the original error message.
  306. msg = "Request failed: stream error sent by user"
  307. raise WriteError(msg) from e
  308. def close(self) -> None:
  309. if self._closed:
  310. return
  311. self._closed = True
  312. with contextlib.suppress(Exception):
  313. self._content.close() # ty: ignore[unresolved-attribute]
  314. class ResponseContent(Iterator[bytes]):
  315. def __init__(
  316. self,
  317. response_queue: Queue[bytes | None | Exception],
  318. request_input: RequestInput,
  319. app_future: Future,
  320. deadline: float | None,
  321. decompressor: Decompressor,
  322. ) -> None:
  323. self._response_queue = response_queue
  324. self._request_input = request_input
  325. self._app_future = app_future
  326. self._closed = False
  327. self._read_pending = False
  328. self._deadline = deadline
  329. self._decompressor = decompressor
  330. def __iter__(self) -> Iterator[bytes]:
  331. return self
  332. def __next__(self) -> bytes:
  333. if self._closed:
  334. raise StopIteration
  335. err: Exception | None = None
  336. self._read_pending = True
  337. chunk = b""
  338. try:
  339. if self._deadline:
  340. while True:
  341. time_left = self._deadline - time.monotonic()
  342. if time_left <= 0:
  343. msg = "Response read timed out"
  344. message = TimeoutError(msg)
  345. break
  346. try:
  347. message = self._response_queue.get(timeout=time_left)
  348. break
  349. except Empty:
  350. continue
  351. else:
  352. message = self._response_queue.get()
  353. finally:
  354. self._read_pending = False
  355. if isinstance(message, Exception):
  356. match message:
  357. case WriteError() | TimeoutError():
  358. err = message
  359. case _:
  360. msg = "Request Failed: Error reading response body"
  361. err = ReadError(msg)
  362. elif message is None:
  363. remaining = self._decompressor.feed(b"", end=True)
  364. if remaining:
  365. self._closed = True
  366. self._request_input.close()
  367. with contextlib.suppress(Exception):
  368. self._app_future.result()
  369. return remaining
  370. err = StopIteration()
  371. else:
  372. chunk = message
  373. if err:
  374. self._closed = True
  375. self._request_input.close()
  376. with contextlib.suppress(Exception):
  377. self._app_future.result()
  378. raise err
  379. return self._decompressor.feed(chunk, end=False)
  380. def __del__(self) -> None:
  381. self.close()
  382. def close(self) -> None:
  383. if self._closed:
  384. return
  385. self._closed = True
  386. self._request_input.close()
  387. self._response_queue.put(ReadError("Response body read cancelled"))
  388. with contextlib.suppress(Exception):
  389. self._app_future.result()
  390. class WSGITimeoutError(TimeoutError):
  391. """Timeout error raised by WSGI transport.
  392. Contains a handle to the app future to allow joining on its thread.
  393. """
  394. def __init__(self, msg: str, app_future: Future) -> None:
  395. super().__init__(msg)
  396. self._app_future = app_future
  397. def wait(self, timeout: float | None = None) -> None:
  398. """Waits for the WSGI application to finish."""
  399. with contextlib.suppress(Exception):
  400. self._app_future.result(timeout)
  401. class WSGIConnectionError(ConnectionError):
  402. """Connection error raised by WSGI transport.
  403. Contains a handle to the app future to allow joining on its thread.
  404. """
  405. def __init__(self, msg: str, app_future: Future) -> None:
  406. super().__init__(msg)
  407. self._app_future = app_future
  408. def wait(self, timeout: float | None = None) -> None:
  409. """Waits for the WSGI application to finish."""
  410. with contextlib.suppress(Exception):
  411. self._app_future.result(timeout)