diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ff19dde..f27b786 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,5 @@ -# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# SPDX-FileCopyrightText: 2020 Diego Elio Pettenò +# SPDX-FileCopyrightText: 2024 Justin Myers # # SPDX-License-Identifier: Unlicense diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 33c2a61..255dafd 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,8 +8,11 @@ # Required version: 2 +sphinx: + configuration: docs/conf.py + build: - os: ubuntu-20.04 + os: ubuntu-lts-latest tools: python: "3" diff --git a/adafruit_requests.py b/adafruit_requests.py index f0c9e65..d15db69 100644 --- a/adafruit_requests.py +++ b/adafruit_requests.py @@ -102,11 +102,12 @@ class Response: It is still necessary to ``close`` the response object for correct management of sockets, including doing so implicitly via ``with requests.get(...) as response``.""" - def __init__(self, sock: SocketType, session: "Session") -> None: + def __init__(self, sock: SocketType, session: "Session", method: str) -> None: self.socket = sock self.encoding = "utf-8" self._cached = None self._headers = {} + self._method = method # _start_index and _receive_buffer are used when parsing headers. # _receive_buffer will grow by 32 bytes everytime it is too small. @@ -276,6 +277,15 @@ def _parse_headers(self) -> None: else: self._headers[title] = content + # does the body have a fixed length? (of zero) + if ( + self.status_code == 204 + or self.status_code == 304 + or 100 <= self.status_code < 200 # 1xx codes + or self._method == "HEAD" + ): + self._remaining = 0 + def _validate_not_gzip(self) -> None: """gzip encoding is not supported. Raise an exception if found.""" if "content-encoding" in self.headers and self.headers["content-encoding"] == "gzip": @@ -622,6 +632,10 @@ def request( # noqa: PLR0912,PLR0913,PLR0915 Too many branches,Too many argumen # We may fail to send the request if the socket we got is closed already. So, try a second # time in that case. + # Note that the loop below actually tries a second time in other failure cases too, + # namely timeout and no data from socket. This was not covered in the stated intent of the + # commit that introduced the loop, but removing the retry from those cases could prove + # problematic to callers that now depend on that resiliency. retry_count = 0 last_exc = None while retry_count < 2: @@ -643,24 +657,30 @@ def request( # noqa: PLR0912,PLR0913,PLR0915 Too many branches,Too many argumen 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: + # Both recv/recv_into can raise OSError; when that happens, we need to call + # _connection_manager.close_socket(socket) or future calls to + # _connection_manager.get_socket() for the same parameter set will fail + try: + if hasattr(socket, "recv"): + result = socket.recv(1) + else: + result = bytearray(1) socket.recv_into(result) - except OSError: - pass - if result == b"H": - # Things seem to be ok so break with socket set. - break + if result == b"H": + # Things seem to be ok so break with socket set. + break + else: + raise RuntimeError("no data from socket") + except (OSError, RuntimeError) as exc: + last_exc = exc + pass self._connection_manager.close_socket(socket) socket = None if not socket: raise OutOfRetries("Repeated socket failures") from last_exc - resp = Response(socket, self) # our response + resp = Response(socket, self, method) # our response if allow_redirects: if "location" in resp.headers and 300 <= resp.status_code <= 399: # a naive handler for redirects diff --git a/docs/conf.py b/docs/conf.py index f3a8700..689c1af 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -100,7 +100,6 @@ import sphinx_rtd_theme html_theme = "sphinx_rtd_theme" -html_theme_path = [sphinx_rtd_theme.get_html_theme_path(), "."] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/examples/esp32spi/requests_esp32spi_advanced.py b/examples/esp32spi/requests_esp32spi_advanced.py index 6754dbf..170589f 100644 --- a/examples/esp32spi/requests_esp32spi_advanced.py +++ b/examples/esp32spi/requests_esp32spi_advanced.py @@ -40,7 +40,7 @@ except RuntimeError as e: print("could not connect to AP, retrying: ", e) continue -print("Connected to", str(radio.ssid, "utf-8"), "\tRSSI:", radio.rssi) +print("Connected to", str(radio.ap_info.ssid, "utf-8"), "\tRSSI:", radio.ap_info.rssi) # Initialize a requests session pool = adafruit_connection_manager.get_radio_socketpool(radio) diff --git a/examples/esp32spi/requests_esp32spi_simpletest.py b/examples/esp32spi/requests_esp32spi_simpletest.py index 8a61209..3e856c0 100644 --- a/examples/esp32spi/requests_esp32spi_simpletest.py +++ b/examples/esp32spi/requests_esp32spi_simpletest.py @@ -40,7 +40,7 @@ except RuntimeError as e: print("could not connect to AP, retrying: ", e) continue -print("Connected to", str(radio.ssid, "utf-8"), "\tRSSI:", radio.rssi) +print("Connected to", str(radio.ap_info.ssid, "utf-8"), "\tRSSI:", radio.ap_info.rssi) # Initialize a requests session pool = adafruit_connection_manager.get_radio_socketpool(radio) diff --git a/tests/local_test_server.py b/tests/local_test_server.py new file mode 100644 index 0000000..73b52b3 --- /dev/null +++ b/tests/local_test_server.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: 2025 Tim Cocks +# +# SPDX-License-Identifier: MIT +import json +from http.server import SimpleHTTPRequestHandler + + +class LocalTestServerHandler(SimpleHTTPRequestHandler): + def do_GET(self): + if self.path == "/get": + resp_body = json.dumps({"url": "http://localhost:5000/get"}).encode("utf-8") + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Content-Length", str(len(resp_body))) + self.end_headers() + self.wfile.write(resp_body) + if self.path.startswith("/status"): + try: + requested_status = int(self.path.split("/")[2]) + except ValueError: + resp_body = json.dumps({"error": "requested status code must be int"}).encode( + "utf-8" + ) + self.send_response(400) + self.send_header("Content-type", "application/json") + self.send_header("Content-Length", str(len(resp_body))) + self.end_headers() + self.wfile.write(resp_body) + return + + if requested_status != 204: + self.send_response(requested_status) + self.send_header("Content-type", "text/html") + self.send_header("Content-Length", "0") + else: + self.send_response(requested_status) + self.send_header("Content-type", "text/html") + self.end_headers() diff --git a/tests/real_call_test.py b/tests/real_call_test.py new file mode 100644 index 0000000..b982f00 --- /dev/null +++ b/tests/real_call_test.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers +# +# SPDX-License-Identifier: Unlicense + +"""Real call Tests""" + +import socket +import socketserver +import ssl +import threading +import time + +import adafruit_connection_manager +import pytest +from local_test_server import LocalTestServerHandler + +import adafruit_requests + + +def test_gets(): + path_index = 0 + status_code_index = 1 + text_result_index = 2 + json_keys_index = 3 + cases = [ + ("get", 200, None, {"url": "http://localhost:5000/get"}), + ("status/200", 200, "", None), + ("status/204", 204, "", None), + ] + + with socketserver.TCPServer(("127.0.0.1", 5000), LocalTestServerHandler) as server: + server_thread = threading.Thread(target=server.serve_forever) + server_thread.daemon = True + server_thread.start() + + time.sleep(2) # Give the server some time to start + + for case in cases: + requests = adafruit_requests.Session(socket, ssl.create_default_context()) + with requests.get(f"http://127.0.0.1:5000/{case[path_index]}") as response: + assert response.status_code == case[status_code_index] + if case[text_result_index] is not None: + assert response.text == case[text_result_index] + if case[json_keys_index] is not None: + for key, value in case[json_keys_index].items(): + assert response.json()[key] == value + + adafruit_connection_manager.connection_manager_close_all(release_references=True) + + server.shutdown() + server.server_close() + time.sleep(2)