You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
286 lines
9.8 KiB
286 lines
9.8 KiB
5 months ago
|
from __future__ import annotations
|
||
|
|
||
|
import json as _json
|
||
|
import logging
|
||
|
import typing
|
||
|
from contextlib import contextmanager
|
||
|
from dataclasses import dataclass
|
||
|
from http.client import HTTPException as HTTPException
|
||
|
from io import BytesIO, IOBase
|
||
|
|
||
|
from ...exceptions import InvalidHeader, TimeoutError
|
||
|
from ...response import BaseHTTPResponse
|
||
|
from ...util.retry import Retry
|
||
|
from .request import EmscriptenRequest
|
||
|
|
||
|
if typing.TYPE_CHECKING:
|
||
|
from ..._base_connection import BaseHTTPConnection, BaseHTTPSConnection
|
||
|
|
||
|
log = logging.getLogger(__name__)
|
||
|
|
||
|
|
||
|
@dataclass
|
||
|
class EmscriptenResponse:
|
||
|
status_code: int
|
||
|
headers: dict[str, str]
|
||
|
body: IOBase | bytes
|
||
|
request: EmscriptenRequest
|
||
|
|
||
|
|
||
|
class EmscriptenHttpResponseWrapper(BaseHTTPResponse):
|
||
|
def __init__(
|
||
|
self,
|
||
|
internal_response: EmscriptenResponse,
|
||
|
url: str | None = None,
|
||
|
connection: BaseHTTPConnection | BaseHTTPSConnection | None = None,
|
||
|
):
|
||
|
self._pool = None # set by pool class
|
||
|
self._body = None
|
||
|
self._response = internal_response
|
||
|
self._url = url
|
||
|
self._connection = connection
|
||
|
self._closed = False
|
||
|
super().__init__(
|
||
|
headers=internal_response.headers,
|
||
|
status=internal_response.status_code,
|
||
|
request_url=url,
|
||
|
version=0,
|
||
|
version_string="HTTP/?",
|
||
|
reason="",
|
||
|
decode_content=True,
|
||
|
)
|
||
|
self.length_remaining = self._init_length(self._response.request.method)
|
||
|
self.length_is_certain = False
|
||
|
|
||
|
@property
|
||
|
def url(self) -> str | None:
|
||
|
return self._url
|
||
|
|
||
|
@url.setter
|
||
|
def url(self, url: str | None) -> None:
|
||
|
self._url = url
|
||
|
|
||
|
@property
|
||
|
def connection(self) -> BaseHTTPConnection | BaseHTTPSConnection | None:
|
||
|
return self._connection
|
||
|
|
||
|
@property
|
||
|
def retries(self) -> Retry | None:
|
||
|
return self._retries
|
||
|
|
||
|
@retries.setter
|
||
|
def retries(self, retries: Retry | None) -> None:
|
||
|
# Override the request_url if retries has a redirect location.
|
||
|
self._retries = retries
|
||
|
|
||
|
def stream(
|
||
|
self, amt: int | None = 2**16, decode_content: bool | None = None
|
||
|
) -> typing.Generator[bytes, None, None]:
|
||
|
"""
|
||
|
A generator wrapper for the read() method. A call will block until
|
||
|
``amt`` bytes have been read from the connection or until the
|
||
|
connection is closed.
|
||
|
|
||
|
:param amt:
|
||
|
How much of the content to read. The generator will return up to
|
||
|
much data per iteration, but may return less. This is particularly
|
||
|
likely when using compressed data. However, the empty string will
|
||
|
never be returned.
|
||
|
|
||
|
:param decode_content:
|
||
|
If True, will attempt to decode the body based on the
|
||
|
'content-encoding' header.
|
||
|
"""
|
||
|
while True:
|
||
|
data = self.read(amt=amt, decode_content=decode_content)
|
||
|
|
||
|
if data:
|
||
|
yield data
|
||
|
else:
|
||
|
break
|
||
|
|
||
|
def _init_length(self, request_method: str | None) -> int | None:
|
||
|
length: int | None
|
||
|
content_length: str | None = self.headers.get("content-length")
|
||
|
|
||
|
if content_length is not None:
|
||
|
try:
|
||
|
# RFC 7230 section 3.3.2 specifies multiple content lengths can
|
||
|
# be sent in a single Content-Length header
|
||
|
# (e.g. Content-Length: 42, 42). This line ensures the values
|
||
|
# are all valid ints and that as long as the `set` length is 1,
|
||
|
# all values are the same. Otherwise, the header is invalid.
|
||
|
lengths = {int(val) for val in content_length.split(",")}
|
||
|
if len(lengths) > 1:
|
||
|
raise InvalidHeader(
|
||
|
"Content-Length contained multiple "
|
||
|
"unmatching values (%s)" % content_length
|
||
|
)
|
||
|
length = lengths.pop()
|
||
|
except ValueError:
|
||
|
length = None
|
||
|
else:
|
||
|
if length < 0:
|
||
|
length = None
|
||
|
|
||
|
else: # if content_length is None
|
||
|
length = None
|
||
|
|
||
|
# Check for responses that shouldn't include a body
|
||
|
if (
|
||
|
self.status in (204, 304)
|
||
|
or 100 <= self.status < 200
|
||
|
or request_method == "HEAD"
|
||
|
):
|
||
|
length = 0
|
||
|
|
||
|
return length
|
||
|
|
||
|
def read(
|
||
|
self,
|
||
|
amt: int | None = None,
|
||
|
decode_content: bool | None = None, # ignored because browser decodes always
|
||
|
cache_content: bool = False,
|
||
|
) -> bytes:
|
||
|
if (
|
||
|
self._closed
|
||
|
or self._response is None
|
||
|
or (isinstance(self._response.body, IOBase) and self._response.body.closed)
|
||
|
):
|
||
|
return b""
|
||
|
|
||
|
with self._error_catcher():
|
||
|
# body has been preloaded as a string by XmlHttpRequest
|
||
|
if not isinstance(self._response.body, IOBase):
|
||
|
self.length_remaining = len(self._response.body)
|
||
|
self.length_is_certain = True
|
||
|
# wrap body in IOStream
|
||
|
self._response.body = BytesIO(self._response.body)
|
||
|
if amt is not None and amt >= 0:
|
||
|
# don't cache partial content
|
||
|
cache_content = False
|
||
|
data = self._response.body.read(amt)
|
||
|
if self.length_remaining is not None:
|
||
|
self.length_remaining = max(self.length_remaining - len(data), 0)
|
||
|
if (self.length_is_certain and self.length_remaining == 0) or len(
|
||
|
data
|
||
|
) < amt:
|
||
|
# definitely finished reading, close response stream
|
||
|
self._response.body.close()
|
||
|
return typing.cast(bytes, data)
|
||
|
else: # read all we can (and cache it)
|
||
|
data = self._response.body.read()
|
||
|
if cache_content:
|
||
|
self._body = data
|
||
|
if self.length_remaining is not None:
|
||
|
self.length_remaining = max(self.length_remaining - len(data), 0)
|
||
|
if len(data) == 0 or (
|
||
|
self.length_is_certain and self.length_remaining == 0
|
||
|
):
|
||
|
# definitely finished reading, close response stream
|
||
|
self._response.body.close()
|
||
|
return typing.cast(bytes, data)
|
||
|
|
||
|
def read_chunked(
|
||
|
self,
|
||
|
amt: int | None = None,
|
||
|
decode_content: bool | None = None,
|
||
|
) -> typing.Generator[bytes, None, None]:
|
||
|
# chunked is handled by browser
|
||
|
while True:
|
||
|
bytes = self.read(amt, decode_content)
|
||
|
if not bytes:
|
||
|
break
|
||
|
yield bytes
|
||
|
|
||
|
def release_conn(self) -> None:
|
||
|
if not self._pool or not self._connection:
|
||
|
return None
|
||
|
|
||
|
self._pool._put_conn(self._connection)
|
||
|
self._connection = None
|
||
|
|
||
|
def drain_conn(self) -> None:
|
||
|
self.close()
|
||
|
|
||
|
@property
|
||
|
def data(self) -> bytes:
|
||
|
if self._body:
|
||
|
return self._body
|
||
|
else:
|
||
|
return self.read(cache_content=True)
|
||
|
|
||
|
def json(self) -> typing.Any:
|
||
|
"""
|
||
|
Deserializes the body of the HTTP response as a Python object.
|
||
|
|
||
|
The body of the HTTP response must be encoded using UTF-8, as per
|
||
|
`RFC 8529 Section 8.1 <https://www.rfc-editor.org/rfc/rfc8259#section-8.1>`_.
|
||
|
|
||
|
To use a custom JSON decoder pass the result of :attr:`HTTPResponse.data` to
|
||
|
your custom decoder instead.
|
||
|
|
||
|
If the body of the HTTP response is not decodable to UTF-8, a
|
||
|
`UnicodeDecodeError` will be raised. If the body of the HTTP response is not a
|
||
|
valid JSON document, a `json.JSONDecodeError` will be raised.
|
||
|
|
||
|
Read more :ref:`here <json_content>`.
|
||
|
|
||
|
:returns: The body of the HTTP response as a Python object.
|
||
|
"""
|
||
|
data = self.data.decode("utf-8")
|
||
|
return _json.loads(data)
|
||
|
|
||
|
def close(self) -> None:
|
||
|
if not self._closed:
|
||
|
if isinstance(self._response.body, IOBase):
|
||
|
self._response.body.close()
|
||
|
if self._connection:
|
||
|
self._connection.close()
|
||
|
self._connection = None
|
||
|
self._closed = True
|
||
|
|
||
|
@contextmanager
|
||
|
def _error_catcher(self) -> typing.Generator[None, None, None]:
|
||
|
"""
|
||
|
Catch Emscripten specific exceptions thrown by fetch.py,
|
||
|
instead re-raising urllib3 variants, so that low-level exceptions
|
||
|
are not leaked in the high-level api.
|
||
|
|
||
|
On exit, release the connection back to the pool.
|
||
|
"""
|
||
|
from .fetch import _RequestError, _TimeoutError # avoid circular import
|
||
|
|
||
|
clean_exit = False
|
||
|
|
||
|
try:
|
||
|
yield
|
||
|
# If no exception is thrown, we should avoid cleaning up
|
||
|
# unnecessarily.
|
||
|
clean_exit = True
|
||
|
except _TimeoutError as e:
|
||
|
raise TimeoutError(str(e))
|
||
|
except _RequestError as e:
|
||
|
raise HTTPException(str(e))
|
||
|
finally:
|
||
|
# If we didn't terminate cleanly, we need to throw away our
|
||
|
# connection.
|
||
|
if not clean_exit:
|
||
|
# The response may not be closed but we're not going to use it
|
||
|
# anymore so close it now
|
||
|
if (
|
||
|
isinstance(self._response.body, IOBase)
|
||
|
and not self._response.body.closed
|
||
|
):
|
||
|
self._response.body.close()
|
||
|
# release the connection back to the pool
|
||
|
self.release_conn()
|
||
|
else:
|
||
|
# If we have read everything from the response stream,
|
||
|
# return the connection back to the pool.
|
||
|
if (
|
||
|
isinstance(self._response.body, IOBase)
|
||
|
and self._response.body.closed
|
||
|
):
|
||
|
self.release_conn()
|