wsgi.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. import io
  2. import itertools
  3. import sys
  4. import typing
  5. from .._models import Request, Response
  6. from .._types import SyncByteStream
  7. from .base import BaseTransport
  8. if typing.TYPE_CHECKING:
  9. from _typeshed import OptExcInfo # pragma: no cover
  10. from _typeshed.wsgi import WSGIApplication # pragma: no cover
  11. _T = typing.TypeVar("_T")
  12. def _skip_leading_empty_chunks(body: typing.Iterable[_T]) -> typing.Iterable[_T]:
  13. body = iter(body)
  14. for chunk in body:
  15. if chunk:
  16. return itertools.chain([chunk], body)
  17. return []
  18. class WSGIByteStream(SyncByteStream):
  19. def __init__(self, result: typing.Iterable[bytes]) -> None:
  20. self._close = getattr(result, "close", None)
  21. self._result = _skip_leading_empty_chunks(result)
  22. def __iter__(self) -> typing.Iterator[bytes]:
  23. for part in self._result:
  24. yield part
  25. def close(self) -> None:
  26. if self._close is not None:
  27. self._close()
  28. class WSGITransport(BaseTransport):
  29. """
  30. A custom transport that handles sending requests directly to an WSGI app.
  31. The simplest way to use this functionality is to use the `app` argument.
  32. ```
  33. client = httpx.Client(app=app)
  34. ```
  35. Alternatively, you can setup the transport instance explicitly.
  36. This allows you to include any additional configuration arguments specific
  37. to the WSGITransport class:
  38. ```
  39. transport = httpx.WSGITransport(
  40. app=app,
  41. script_name="/submount",
  42. remote_addr="1.2.3.4"
  43. )
  44. client = httpx.Client(transport=transport)
  45. ```
  46. Arguments:
  47. * `app` - The WSGI application.
  48. * `raise_app_exceptions` - Boolean indicating if exceptions in the application
  49. should be raised. Default to `True`. Can be set to `False` for use cases
  50. such as testing the content of a client 500 response.
  51. * `script_name` - The root path on which the WSGI application should be mounted.
  52. * `remote_addr` - A string indicating the client IP of incoming requests.
  53. ```
  54. """
  55. def __init__(
  56. self,
  57. app: "WSGIApplication",
  58. raise_app_exceptions: bool = True,
  59. script_name: str = "",
  60. remote_addr: str = "127.0.0.1",
  61. wsgi_errors: typing.Optional[typing.TextIO] = None,
  62. ) -> None:
  63. self.app = app
  64. self.raise_app_exceptions = raise_app_exceptions
  65. self.script_name = script_name
  66. self.remote_addr = remote_addr
  67. self.wsgi_errors = wsgi_errors
  68. def handle_request(self, request: Request) -> Response:
  69. request.read()
  70. wsgi_input = io.BytesIO(request.content)
  71. port = request.url.port or {"http": 80, "https": 443}[request.url.scheme]
  72. environ = {
  73. "wsgi.version": (1, 0),
  74. "wsgi.url_scheme": request.url.scheme,
  75. "wsgi.input": wsgi_input,
  76. "wsgi.errors": self.wsgi_errors or sys.stderr,
  77. "wsgi.multithread": True,
  78. "wsgi.multiprocess": False,
  79. "wsgi.run_once": False,
  80. "REQUEST_METHOD": request.method,
  81. "SCRIPT_NAME": self.script_name,
  82. "PATH_INFO": request.url.path,
  83. "QUERY_STRING": request.url.query.decode("ascii"),
  84. "SERVER_NAME": request.url.host,
  85. "SERVER_PORT": str(port),
  86. "SERVER_PROTOCOL": "HTTP/1.1",
  87. "REMOTE_ADDR": self.remote_addr,
  88. }
  89. for header_key, header_value in request.headers.raw:
  90. key = header_key.decode("ascii").upper().replace("-", "_")
  91. if key not in ("CONTENT_TYPE", "CONTENT_LENGTH"):
  92. key = "HTTP_" + key
  93. environ[key] = header_value.decode("ascii")
  94. seen_status = None
  95. seen_response_headers = None
  96. seen_exc_info = None
  97. def start_response(
  98. status: str,
  99. response_headers: typing.List[typing.Tuple[str, str]],
  100. exc_info: typing.Optional["OptExcInfo"] = None,
  101. ) -> typing.Callable[[bytes], typing.Any]:
  102. nonlocal seen_status, seen_response_headers, seen_exc_info
  103. seen_status = status
  104. seen_response_headers = response_headers
  105. seen_exc_info = exc_info
  106. return lambda _: None
  107. result = self.app(environ, start_response)
  108. stream = WSGIByteStream(result)
  109. assert seen_status is not None
  110. assert seen_response_headers is not None
  111. if seen_exc_info and seen_exc_info[0] and self.raise_app_exceptions:
  112. raise seen_exc_info[1]
  113. status_code = int(seen_status.split()[0])
  114. headers = [
  115. (key.encode("ascii"), value.encode("ascii"))
  116. for key, value in seen_response_headers
  117. ]
  118. return Response(status_code, headers=headers, stream=stream)