2 changed files with 669 additions and 0 deletions
Binary file not shown.
@ -0,0 +1,669 @@ |
|||
__version__ = "0.0.0-auto.0" |
|||
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_Requests.git" |
|||
|
|||
import errno |
|||
|
|||
# CircuitPython 6.0 does not have the bytearray.split method. |
|||
# This function emulates buf.split(needle)[0], which is the functionality |
|||
# required. |
|||
def _buffer_split0(buf, needle): |
|||
index = buf.find(needle) |
|||
if index == -1: |
|||
return buf |
|||
return buf[:index] |
|||
|
|||
|
|||
class _RawResponse: |
|||
def __init__(self, response): |
|||
self._response = response |
|||
|
|||
def read(self, size=-1): |
|||
"""Read as much as available or up to size and return it in a byte string. |
|||
|
|||
Do NOT use this unless you really need to. Reusing memory with `readinto` is much better. |
|||
""" |
|||
if size == -1: |
|||
return self._response.content |
|||
return self._response.socket.recv(size) |
|||
|
|||
def readinto(self, buf): |
|||
"""Read as much as available into buf or until it is full. Returns the number of bytes read |
|||
into buf.""" |
|||
return self._response._readinto(buf) # pylint: disable=protected-access |
|||
|
|||
|
|||
class _SendFailed(Exception): |
|||
"""Custom exception to abort sending a request.""" |
|||
|
|||
|
|||
class OutOfRetries(Exception): |
|||
"""Raised when requests has retried to make a request unsuccessfully.""" |
|||
|
|||
|
|||
class Response: |
|||
"""The response from a request, contains all the headers/content""" |
|||
|
|||
# pylint: disable=too-many-instance-attributes |
|||
|
|||
encoding = None |
|||
|
|||
def __init__(self, sock, session=None): |
|||
self.socket = sock |
|||
self.encoding = "utf-8" |
|||
self._cached = None |
|||
self._headers = {} |
|||
|
|||
# _start_index and _receive_buffer are used when parsing headers. |
|||
# _receive_buffer will grow by 32 bytes everytime it is too small. |
|||
self._received_length = 0 |
|||
self._receive_buffer = bytearray(32) |
|||
self._remaining = None |
|||
self._chunked = False |
|||
|
|||
self._backwards_compatible = not hasattr(sock, "recv_into") |
|||
|
|||
http = self._readto(b" ") |
|||
if not http: |
|||
if session: |
|||
session._close_socket(self.socket) |
|||
else: |
|||
self.socket.close() |
|||
raise RuntimeError("Unable to read HTTP response.") |
|||
self.status_code = int(bytes(self._readto(b" "))) |
|||
self.reason = self._readto(b"\r\n") |
|||
self._parse_headers() |
|||
self._raw = None |
|||
self._session = session |
|||
|
|||
def __enter__(self): |
|||
return self |
|||
|
|||
def __exit__(self, exc_type, exc_value, traceback): |
|||
self.close() |
|||
|
|||
def _recv_into(self, buf, size=0): |
|||
if self._backwards_compatible: |
|||
size = len(buf) if size == 0 else size |
|||
b = self.socket.recv(size) |
|||
read_size = len(b) |
|||
buf[:read_size] = b |
|||
return read_size |
|||
return self.socket.recv_into(buf, size) |
|||
|
|||
@staticmethod |
|||
def _find(buf, needle, start, end): |
|||
if hasattr(buf, "find"): |
|||
return buf.find(needle, start, end) |
|||
result = -1 |
|||
i = start |
|||
while i < end: |
|||
j = 0 |
|||
while j < len(needle) and i + j < end and buf[i + j] == needle[j]: |
|||
j += 1 |
|||
if j == len(needle): |
|||
result = i |
|||
break |
|||
i += 1 |
|||
|
|||
return result |
|||
|
|||
def _readto(self, first, second=b""): |
|||
buf = self._receive_buffer |
|||
end = self._received_length |
|||
while True: |
|||
firsti = self._find(buf, first, 0, end) |
|||
secondi = -1 |
|||
if second: |
|||
secondi = self._find(buf, second, 0, end) |
|||
|
|||
i = -1 |
|||
needle_len = 0 |
|||
if firsti >= 0: |
|||
i = firsti |
|||
needle_len = len(first) |
|||
if secondi >= 0 and (firsti < 0 or secondi < firsti): |
|||
i = secondi |
|||
needle_len = len(second) |
|||
if i >= 0: |
|||
result = buf[:i] |
|||
new_start = i + needle_len |
|||
|
|||
if i + needle_len <= end: |
|||
new_end = end - new_start |
|||
buf[:new_end] = buf[new_start:end] |
|||
self._received_length = new_end |
|||
return result |
|||
|
|||
# Not found so load more. |
|||
|
|||
# If our buffer is full, then make it bigger to load more. |
|||
if end == len(buf): |
|||
new_size = len(buf) + 32 |
|||
new_buf = bytearray(new_size) |
|||
new_buf[: len(buf)] = buf |
|||
buf = new_buf |
|||
self._receive_buffer = buf |
|||
|
|||
read = self._recv_into(memoryview(buf)[end:]) |
|||
if read == 0: |
|||
self._received_length = 0 |
|||
return buf[:end] |
|||
end += read |
|||
|
|||
return b"" |
|||
|
|||
def _read_from_buffer(self, buf=None, nbytes=None): |
|||
if self._received_length == 0: |
|||
return 0 |
|||
read = self._received_length |
|||
if nbytes < read: |
|||
read = nbytes |
|||
membuf = memoryview(self._receive_buffer) |
|||
if buf: |
|||
buf[:read] = membuf[:read] |
|||
if read < self._received_length: |
|||
new_end = self._received_length - read |
|||
self._receive_buffer[:new_end] = membuf[read : self._received_length] |
|||
self._received_length = new_end |
|||
else: |
|||
self._received_length = 0 |
|||
return read |
|||
|
|||
def _readinto(self, buf): |
|||
if not self.socket: |
|||
raise RuntimeError( |
|||
"Newer Response closed this one. Use Responses immediately." |
|||
) |
|||
|
|||
if not self._remaining: |
|||
# Consume the chunk header if need be. |
|||
if self._chunked: |
|||
# Consume trailing \r\n for chunks 2+ |
|||
if self._remaining == 0: |
|||
self._throw_away(2) |
|||
chunk_header = _buffer_split0(self._readto(b"\r\n"), b";") |
|||
http_chunk_size = int(bytes(chunk_header), 16) |
|||
if http_chunk_size == 0: |
|||
self._chunked = False |
|||
self._parse_headers() |
|||
return 0 |
|||
self._remaining = http_chunk_size |
|||
else: |
|||
return 0 |
|||
|
|||
nbytes = len(buf) |
|||
if nbytes > self._remaining: |
|||
nbytes = self._remaining |
|||
|
|||
read = self._read_from_buffer(buf, nbytes) |
|||
if read == 0: |
|||
read = self._recv_into(buf, nbytes) |
|||
self._remaining -= read |
|||
|
|||
return read |
|||
|
|||
def _throw_away(self, nbytes): |
|||
nbytes -= self._read_from_buffer(nbytes=nbytes) |
|||
|
|||
buf = self._receive_buffer |
|||
for _ in range(nbytes // len(buf)): |
|||
self._recv_into(buf) |
|||
remaining = nbytes % len(buf) |
|||
if remaining: |
|||
self._recv_into(buf, remaining) |
|||
|
|||
def close(self): |
|||
"""Drain the remaining ESP socket buffers. We assume we already got what we wanted.""" |
|||
if not self.socket: |
|||
return |
|||
# Make sure we've read all of our response. |
|||
if self._cached is None: |
|||
if self._remaining and self._remaining > 0: |
|||
self._throw_away(self._remaining) |
|||
elif self._chunked: |
|||
while True: |
|||
chunk_header = _buffer_split0(self._readto(b"\r\n"), b";") |
|||
chunk_size = int(bytes(chunk_header), 16) |
|||
if chunk_size == 0: |
|||
break |
|||
self._throw_away(chunk_size + 2) |
|||
self._parse_headers() |
|||
if self._session: |
|||
self._session._free_socket(self.socket) # pylint: disable=protected-access |
|||
else: |
|||
self.socket.close() |
|||
self.socket = None |
|||
|
|||
def _parse_headers(self): |
|||
""" |
|||
Parses the header portion of an HTTP request/response from the socket. |
|||
Expects first line of HTTP request/response to have been read already. |
|||
""" |
|||
while True: |
|||
title = self._readto(b": ", b"\r\n") |
|||
if not title: |
|||
break |
|||
|
|||
content = self._readto(b"\r\n") |
|||
if title and content: |
|||
title = str(title, "utf-8") |
|||
content = str(content, "utf-8") |
|||
# Check len first so we can skip the .lower allocation most of the time. |
|||
if ( |
|||
len(title) == len("content-length") |
|||
and title.lower() == "content-length" |
|||
): |
|||
self._remaining = int(content) |
|||
if ( |
|||
len(title) == len("transfer-encoding") |
|||
and title.lower() == "transfer-encoding" |
|||
): |
|||
self._chunked = content.strip().lower() == "chunked" |
|||
self._headers[title] = content |
|||
|
|||
@property |
|||
def headers(self): |
|||
""" |
|||
The response headers. Does not include headers from the trailer until |
|||
the content has been read. |
|||
""" |
|||
return self._headers |
|||
|
|||
@property |
|||
def content(self): |
|||
"""The HTTP content direct from the socket, as bytes""" |
|||
if self._cached is not None: |
|||
if isinstance(self._cached, bytes): |
|||
return self._cached |
|||
raise RuntimeError("Cannot access content after getting text or json") |
|||
|
|||
self._cached = b"".join(self.iter_content(chunk_size=32)) |
|||
return self._cached |
|||
|
|||
@property |
|||
def text(self): |
|||
"""The HTTP content, encoded into a string according to the HTTP |
|||
header encoding""" |
|||
if self._cached is not None: |
|||
if isinstance(self._cached, str): |
|||
return self._cached |
|||
raise RuntimeError("Cannot access text after getting content or json") |
|||
self._cached = str(self.content, self.encoding) |
|||
return self._cached |
|||
|
|||
def json(self): |
|||
"""The HTTP content, parsed into a json dictionary""" |
|||
# pylint: disable=import-outside-toplevel |
|||
import json |
|||
|
|||
# The cached JSON will be a list or dictionary. |
|||
if self._cached: |
|||
if isinstance(self._cached, (list, dict)): |
|||
return self._cached |
|||
raise RuntimeError("Cannot access json after getting text or content") |
|||
if not self._raw: |
|||
self._raw = _RawResponse(self) |
|||
|
|||
try: |
|||
obj = json.load(self._raw) |
|||
except OSError: |
|||
# <5.3.1 doesn't piecemeal load json from any object with readinto so load the whole |
|||
# string. |
|||
obj = json.loads(self._raw.read()) |
|||
if not self._cached: |
|||
self._cached = obj |
|||
self.close() |
|||
return obj |
|||
|
|||
def iter_content(self, chunk_size=1, decode_unicode=False): |
|||
"""An iterator that will stream data by only reading 'chunk_size' |
|||
bytes and yielding them, when we can't buffer the whole datastream""" |
|||
if decode_unicode: |
|||
raise NotImplementedError("Unicode not supported") |
|||
|
|||
b = bytearray(chunk_size) |
|||
while True: |
|||
size = self._readinto(b) |
|||
if size == 0: |
|||
break |
|||
if size < chunk_size: |
|||
chunk = bytes(memoryview(b)[:size]) |
|||
else: |
|||
chunk = bytes(b) |
|||
yield chunk |
|||
self.close() |
|||
|
|||
|
|||
class Session: |
|||
"""HTTP session that shares sockets and ssl context.""" |
|||
|
|||
def __init__(self, socket_pool, ssl_context=None): |
|||
self._socket_pool = socket_pool |
|||
self._ssl_context = ssl_context |
|||
# Hang onto open sockets so that we can reuse them. |
|||
self._open_sockets = {} |
|||
self._socket_free = {} |
|||
self._last_response = None |
|||
|
|||
def _free_socket(self, socket): |
|||
if socket not in self._open_sockets.values(): |
|||
raise RuntimeError("Socket not from session") |
|||
self._socket_free[socket] = True |
|||
|
|||
def _close_socket(self, sock): |
|||
sock.close() |
|||
del self._socket_free[sock] |
|||
key = None |
|||
for k in self._open_sockets: |
|||
if self._open_sockets[k] == sock: |
|||
key = k |
|||
break |
|||
if key: |
|||
del self._open_sockets[key] |
|||
|
|||
def _free_sockets(self): |
|||
free_sockets = [] |
|||
for sock in self._socket_free: |
|||
if self._socket_free[sock]: |
|||
free_sockets.append(sock) |
|||
for sock in free_sockets: |
|||
self._close_socket(sock) |
|||
|
|||
def _get_socket(self, host, port, proto, *, timeout=1): |
|||
# pylint: disable=too-many-branches |
|||
key = (host, port, proto) |
|||
if key in self._open_sockets: |
|||
sock = self._open_sockets[key] |
|||
if self._socket_free[sock]: |
|||
self._socket_free[sock] = False |
|||
return sock |
|||
if proto == "https:" and not self._ssl_context: |
|||
raise RuntimeError( |
|||
"ssl_context must be set before using adafruit_requests for https" |
|||
) |
|||
addr_info = self._socket_pool.getaddrinfo( |
|||
host, port, 0, self._socket_pool.SOCK_STREAM |
|||
)[0] |
|||
retry_count = 0 |
|||
sock = None |
|||
while retry_count < 5 and sock is None: |
|||
if retry_count > 0: |
|||
if any(self._socket_free.items()): |
|||
self._free_sockets() |
|||
else: |
|||
raise RuntimeError("Sending request failed") |
|||
retry_count += 1 |
|||
|
|||
try: |
|||
sock = self._socket_pool.socket( |
|||
addr_info[0], addr_info[1], addr_info[2] |
|||
) |
|||
except OSError: |
|||
continue |
|||
except RuntimeError: |
|||
continue |
|||
|
|||
connect_host = addr_info[-1][0] |
|||
if proto == "https:": |
|||
sock = self._ssl_context.wrap_socket(sock, server_hostname=host) |
|||
connect_host = host |
|||
sock.settimeout(timeout) # socket read timeout |
|||
|
|||
try: |
|||
sock.connect((connect_host, port)) |
|||
except MemoryError: |
|||
sock.close() |
|||
sock = None |
|||
except OSError: |
|||
sock.close() |
|||
sock = None |
|||
|
|||
if sock is None: |
|||
raise RuntimeError("Repeated socket failures") |
|||
|
|||
self._open_sockets[key] = sock |
|||
self._socket_free[sock] = False |
|||
return sock |
|||
|
|||
@staticmethod |
|||
def _send(socket, data): |
|||
total_sent = 0 |
|||
while total_sent < len(data): |
|||
# ESP32SPI sockets raise a RuntimeError when unable to send. |
|||
try: |
|||
sent = socket.send(data[total_sent:]) |
|||
except RuntimeError: |
|||
sent = 0 |
|||
if sent is None: |
|||
sent = len(data) |
|||
if sent == 0: |
|||
raise _SendFailed() |
|||
total_sent += sent |
|||
|
|||
def _send_request(self, socket, host, method, path, headers, data, json): |
|||
# pylint: disable=too-many-arguments |
|||
self._send(socket, bytes(method, "utf-8")) |
|||
self._send(socket, b" /") |
|||
self._send(socket, bytes(path, "utf-8")) |
|||
self._send(socket, b" HTTP/1.1\r\n") |
|||
if "Host" not in headers: |
|||
self._send(socket, b"Host: ") |
|||
self._send(socket, bytes(host, "utf-8")) |
|||
self._send(socket, b"\r\n") |
|||
if "User-Agent" not in headers: |
|||
self._send(socket, b"User-Agent: Adafruit CircuitPython\r\n") |
|||
# Iterate over keys to avoid tuple alloc |
|||
for k in headers: |
|||
self._send(socket, k.encode()) |
|||
self._send(socket, b": ") |
|||
self._send(socket, headers[k].encode()) |
|||
self._send(socket, b"\r\n") |
|||
if json is not None: |
|||
assert data is None |
|||
# pylint: disable=import-outside-toplevel |
|||
try: |
|||
import json as json_module |
|||
except ImportError: |
|||
import ujson as json_module |
|||
data = json_module.dumps(json) |
|||
self._send(socket, b"Content-Type: application/json\r\n") |
|||
if data: |
|||
if isinstance(data, dict): |
|||
self._send( |
|||
socket, b"Content-Type: application/x-www-form-urlencoded\r\n" |
|||
) |
|||
_post_data = "" |
|||
for k in data: |
|||
_post_data = "{}&{}={}".format(_post_data, k, data[k]) |
|||
data = _post_data[1:] |
|||
self._send(socket, b"Content-Length: %d\r\n" % len(data)) |
|||
self._send(socket, b"\r\n") |
|||
if data: |
|||
if isinstance(data, bytearray): |
|||
self._send(socket, bytes(data)) |
|||
else: |
|||
self._send(socket, bytes(data, "utf-8")) |
|||
|
|||
# pylint: disable=too-many-branches, too-many-statements, unused-argument, too-many-arguments, too-many-locals |
|||
def request( |
|||
self, method, url, data=None, json=None, headers=None, stream=False, timeout=60 |
|||
): |
|||
"""Perform an HTTP request to the given url which we will parse to determine |
|||
whether to use SSL ('https://') or not. We can also send some provided 'data' |
|||
or a json dictionary which we will stringify. 'headers' is optional HTTP headers |
|||
sent along. 'stream' will determine if we buffer everything, or whether to only |
|||
read only when requested |
|||
""" |
|||
if not headers: |
|||
headers = {} |
|||
|
|||
try: |
|||
proto, dummy, host, path = url.split("/", 3) |
|||
# replace spaces in path |
|||
path = path.replace(" ", "%20") |
|||
except ValueError: |
|||
proto, dummy, host = url.split("/", 2) |
|||
path = "" |
|||
if proto == "http:": |
|||
port = 80 |
|||
elif proto == "https:": |
|||
port = 443 |
|||
else: |
|||
raise ValueError("Unsupported protocol: " + proto) |
|||
|
|||
if ":" in host: |
|||
host, port = host.split(":", 1) |
|||
port = int(port) |
|||
|
|||
if self._last_response: |
|||
self._last_response.close() |
|||
self._last_response = None |
|||
|
|||
# We may fail to send the request if the socket we got is closed already. So, try a second |
|||
# time in that case. |
|||
retry_count = 0 |
|||
while retry_count < 2: |
|||
retry_count += 1 |
|||
socket = self._get_socket(host, port, proto, timeout=timeout) |
|||
ok = True |
|||
try: |
|||
self._send_request(socket, host, method, path, headers, data, json) |
|||
except (_SendFailed, OSError): |
|||
ok = False |
|||
if ok: |
|||
# Read the H of "HTTP/1.1" to make sure the socket is alive. send can appear to work |
|||
# even when the socket is closed. |
|||
if hasattr(socket, "recv"): |
|||
result = socket.recv(1) |
|||
else: |
|||
result = bytearray(1) |
|||
try: |
|||
socket.recv_into(result) |
|||
except OSError: |
|||
pass |
|||
if result == b"H": |
|||
# Things seem to be ok so break with socket set. |
|||
break |
|||
self._close_socket(socket) |
|||
socket = None |
|||
|
|||
if not socket: |
|||
raise OutOfRetries("Repeated socket failures") |
|||
|
|||
resp = Response(socket, self) # our response |
|||
if "location" in resp.headers and 300 <= resp.status_code <= 399: |
|||
raise NotImplementedError("Redirects not yet supported") |
|||
|
|||
self._last_response = resp |
|||
return resp |
|||
|
|||
def head(self, url, **kw): |
|||
"""Send HTTP HEAD request""" |
|||
return self.request("HEAD", url, **kw) |
|||
|
|||
def get(self, url, **kw): |
|||
"""Send HTTP GET request""" |
|||
return self.request("GET", url, **kw) |
|||
|
|||
def post(self, url, **kw): |
|||
"""Send HTTP POST request""" |
|||
return self.request("POST", url, **kw) |
|||
|
|||
def put(self, url, **kw): |
|||
"""Send HTTP PUT request""" |
|||
return self.request("PUT", url, **kw) |
|||
|
|||
def patch(self, url, **kw): |
|||
"""Send HTTP PATCH request""" |
|||
return self.request("PATCH", url, **kw) |
|||
|
|||
def delete(self, url, **kw): |
|||
"""Send HTTP DELETE request""" |
|||
return self.request("DELETE", url, **kw) |
|||
|
|||
|
|||
# Backwards compatible API: |
|||
|
|||
_default_session = None # pylint: disable=invalid-name |
|||
|
|||
|
|||
class _FakeSSLSocket: |
|||
def __init__(self, socket, tls_mode): |
|||
self._socket = socket |
|||
self._mode = tls_mode |
|||
self.settimeout = socket.settimeout |
|||
self.send = socket.send |
|||
self.recv = socket.recv |
|||
self.close = socket.close |
|||
|
|||
def connect(self, address): |
|||
"""connect wrapper to add non-standard mode parameter""" |
|||
try: |
|||
return self._socket.connect(address, self._mode) |
|||
except RuntimeError as error: |
|||
raise OSError(errno.ENOMEM) from error |
|||
|
|||
|
|||
class _FakeSSLContext: |
|||
def __init__(self, iface): |
|||
self._iface = iface |
|||
|
|||
def wrap_socket(self, socket, server_hostname=None): |
|||
"""Return the same socket""" |
|||
# pylint: disable=unused-argument |
|||
return _FakeSSLSocket(socket, self._iface.TLS_MODE) |
|||
|
|||
|
|||
def set_socket(sock, iface=None): |
|||
"""Legacy API for setting the socket and network interface. Use a `Session` instead.""" |
|||
global _default_session # pylint: disable=global-statement,invalid-name |
|||
if not iface: |
|||
# pylint: disable=protected-access |
|||
_default_session = Session(sock, _FakeSSLContext(sock._the_interface)) |
|||
else: |
|||
_default_session = Session(sock, _FakeSSLContext(iface)) |
|||
sock.set_interface(iface) |
|||
|
|||
|
|||
def request(method, url, data=None, json=None, headers=None, stream=False, timeout=0.1): |
|||
"""Send HTTP request""" |
|||
# pylint: disable=too-many-arguments |
|||
_default_session.request( |
|||
method, |
|||
url, |
|||
data=data, |
|||
json=json, |
|||
headers=headers, |
|||
stream=stream, |
|||
timeout=timeout, |
|||
) |
|||
|
|||
|
|||
def head(url, **kw): |
|||
"""Send HTTP HEAD request""" |
|||
return _default_session.request("HEAD", url, **kw) |
|||
|
|||
|
|||
def get(url, **kw): |
|||
"""Send HTTP GET request""" |
|||
return _default_session.request("GET", url, **kw) |
|||
|
|||
|
|||
def post(url, **kw): |
|||
"""Send HTTP POST request""" |
|||
return _default_session.request("POST", url, **kw) |
|||
|
|||
|
|||
def put(url, **kw): |
|||
"""Send HTTP PUT request""" |
|||
return _default_session.request("PUT", url, **kw) |
|||
|
|||
|
|||
def patch(url, **kw): |
|||
"""Send HTTP PATCH request""" |
|||
return _default_session.request("PATCH", url, **kw) |
|||
|
|||
|
|||
def delete(url, **kw): |
|||
"""Send HTTP DELETE request""" |
|||
return _default_session.request("DELETE", url, **kw) |
Loading…
Reference in new issue