From 77f8b953b93f4e6bc26fd9f37f55cc9c2736cd3e Mon Sep 17 00:00:00 2001 From: foamyguy Date: Sat, 20 Apr 2024 11:33:03 -0500 Subject: [PATCH 01/13] starting files arg for uploading --- adafruit_requests.py | 72 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/adafruit_requests.py b/adafruit_requests.py index 9f38502..5a387bf 100644 --- a/adafruit_requests.py +++ b/adafruit_requests.py @@ -41,11 +41,13 @@ import errno import json as json_module +import random import sys from adafruit_connection_manager import get_connection_manager if not sys.implementation.name == "circuitpython": + from io import FileIO from types import TracebackType from typing import Any, Dict, Optional, Type @@ -394,6 +396,13 @@ def _send(socket: SocketType, data: bytes): def _send_as_bytes(self, socket: SocketType, data: str): return self._send(socket, bytes(data, "utf-8")) + def _generate_boundary_str(self): + hex_characters = "0123456789abcdef" + _boundary = "" + for i in range(32): + _boundary += random.choice(hex_characters) + return _boundary + def _send_header(self, socket, header, value): if value is None: return @@ -415,6 +424,7 @@ def _send_request( headers: Dict[str, str], data: Any, json: Any, + files: Optional[Dict[str, tuple]], ): # Check headers self._check_headers(headers) @@ -425,6 +435,7 @@ def _send_request( # If json is sent, set content type header and convert to string if json is not None: assert data is None + assert files is None content_type_header = "application/json" data = json_module.dumps(json) @@ -441,6 +452,9 @@ def _send_request( if data and isinstance(data, str): data = bytes(data, "utf-8") + if data is None: + data = b"" + self._send_as_bytes(socket, method) self._send(socket, b" /") self._send_as_bytes(socket, path) @@ -448,6 +462,59 @@ def _send_request( # create lower-case supplied header list supplied_headers = {header.lower() for header in headers} + boundary_str = None + + if files is not None and isinstance(files, dict): + boundary_str = self._generate_boundary_str() + content_type_header = f"multipart/form-data; boundary={boundary_str}" + + for fieldname in files.keys(): + if not fieldname.endswith("-name"): + if files[fieldname][0] is not None: + file_content = files[fieldname][1].read() + + data += b"--" + boundary_str.encode() + b"\r\n" + data += ( + b'Content-Disposition: form-data; name="' + + fieldname.encode() + + b'"; filename="' + + files[fieldname][0].encode() + + b'"\r\n' + ) + if len(files[fieldname]) >= 3: + data += ( + b"Content-Type: " + + files[fieldname][2].encode() + + b"\r\n" + ) + if len(files[fieldname]) >= 4: + for custom_header_key in files[fieldname][3].keys(): + data += ( + custom_header_key.encode() + + b": " + + files[fieldname][3][custom_header_key].encode() + + b"\r\n" + ) + data += b"\r\n" + data += file_content + b"\r\n" + else: + # filename is None + data += b"--" + boundary_str.encode() + b"\r\n" + data += ( + b'Content-Disposition: form-data; name="' + + fieldname.encode() + + b'"; \r\n' + ) + if len(files[fieldname]) >= 3: + data += ( + b"Content-Type: " + + files[fieldname][2].encode() + + b"\r\n" + ) + data += b"\r\n" + data += files[fieldname][1].encode() + b"\r\n" + + data += b"--" + boundary_str.encode() + b"--" # Send headers if not "host" in supplied_headers: @@ -478,6 +545,7 @@ def request( stream: bool = False, timeout: float = 60, allow_redirects: bool = True, + files: Optional[Dict[str, tuple]] = None, ) -> Response: """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' @@ -526,7 +594,9 @@ def request( ) ok = True try: - self._send_request(socket, host, method, path, headers, data, json) + self._send_request( + socket, host, method, path, headers, data, json, files + ) except OSError as exc: last_exc = exc ok = False From e4646a5fb3983159fc94627f9a1184ebb152f49e Mon Sep 17 00:00:00 2001 From: foamyguy Date: Sun, 21 Apr 2024 10:00:39 -0500 Subject: [PATCH 02/13] cleanup, lint, format --- adafruit_requests.py | 24 ++++++++--------- .../expanded/requests_wifi_file_upload.py | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 examples/wifi/expanded/requests_wifi_file_upload.py diff --git a/adafruit_requests.py b/adafruit_requests.py index 5a387bf..a756d5e 100644 --- a/adafruit_requests.py +++ b/adafruit_requests.py @@ -47,7 +47,6 @@ from adafruit_connection_manager import get_connection_manager if not sys.implementation.name == "circuitpython": - from io import FileIO from types import TracebackType from typing import Any, Dict, Optional, Type @@ -345,6 +344,14 @@ def iter_content(self, chunk_size: int = 1, decode_unicode: bool = False) -> byt self.close() +def _generate_boundary_str(): + hex_characters = "0123456789abcdef" + _boundary = "" + for _ in range(32): + _boundary += random.choice(hex_characters) + return _boundary + + class Session: """HTTP session that shares sockets and ssl context.""" @@ -396,13 +403,6 @@ def _send(socket: SocketType, data: bytes): def _send_as_bytes(self, socket: SocketType, data: str): return self._send(socket, bytes(data, "utf-8")) - def _generate_boundary_str(self): - hex_characters = "0123456789abcdef" - _boundary = "" - for i in range(32): - _boundary += random.choice(hex_characters) - return _boundary - def _send_header(self, socket, header, value): if value is None: return @@ -414,8 +414,7 @@ def _send_header(self, socket, header, value): self._send_as_bytes(socket, value) self._send(socket, b"\r\n") - # pylint: disable=too-many-arguments - def _send_request( + def _send_request( # pylint: disable=too-many-arguments self, socket: SocketType, host: str, @@ -425,7 +424,7 @@ def _send_request( data: Any, json: Any, files: Optional[Dict[str, tuple]], - ): + ): # pylint: disable=too-many-branches,too-many-locals,too-many-statements # Check headers self._check_headers(headers) @@ -464,8 +463,9 @@ def _send_request( supplied_headers = {header.lower() for header in headers} boundary_str = None + # pylint: disable=too-many-nested-blocks if files is not None and isinstance(files, dict): - boundary_str = self._generate_boundary_str() + boundary_str = _generate_boundary_str() content_type_header = f"multipart/form-data; boundary={boundary_str}" for fieldname in files.keys(): diff --git a/examples/wifi/expanded/requests_wifi_file_upload.py b/examples/wifi/expanded/requests_wifi_file_upload.py new file mode 100644 index 0000000..3ceaef0 --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_file_upload.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries +# SPDX-License-Identifier: MIT + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +URL = "https://httpbin.org/post" + +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +with open("raspi_snip.png", "rb") as file_handle: + files = { + "file": ( + "raspi_snip.png", + file_handle, + "image/png", + {"CustomHeader": "BlinkaRocks"}, + ), + "othervalue": (None, "HelloWorld"), + } + + with requests.post(URL, files=files) as resp: + print(resp.content) From ad7aaca81848829806b96592d1191e8fd2ab9407 Mon Sep 17 00:00:00 2001 From: foamyguy Date: Sun, 21 Apr 2024 10:09:06 -0500 Subject: [PATCH 03/13] example file for upload --- .../expanded/requests_wifi_file_upload_image.png | Bin 0 -> 7615 bytes .../requests_wifi_file_upload_image.png.license | 2 ++ 2 files changed, 2 insertions(+) create mode 100644 examples/wifi/expanded/requests_wifi_file_upload_image.png create mode 100644 examples/wifi/expanded/requests_wifi_file_upload_image.png.license diff --git a/examples/wifi/expanded/requests_wifi_file_upload_image.png b/examples/wifi/expanded/requests_wifi_file_upload_image.png new file mode 100644 index 0000000000000000000000000000000000000000..f08a15472271eba34ce6044e276bf3c86887609a GIT binary patch literal 7615 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 hM9qY8m13pf{{w4`M Date: Sun, 21 Apr 2024 15:38:34 -0700 Subject: [PATCH 04/13] Update files to chunk --- adafruit_requests.py | 160 ++++++++++-------- .../expanded/requests_wifi_file_upload.py | 4 +- 2 files changed, 96 insertions(+), 68 deletions(-) diff --git a/adafruit_requests.py b/adafruit_requests.py index a756d5e..9a69428 100644 --- a/adafruit_requests.py +++ b/adafruit_requests.py @@ -46,6 +46,8 @@ from adafruit_connection_manager import get_connection_manager +SEEK_END = 2 + if not sys.implementation.name == "circuitpython": from types import TracebackType from typing import Any, Dict, Optional, Type @@ -344,14 +346,6 @@ def iter_content(self, chunk_size: int = 1, decode_unicode: bool = False) -> byt self.close() -def _generate_boundary_str(): - hex_characters = "0123456789abcdef" - _boundary = "" - for _ in range(32): - _boundary += random.choice(hex_characters) - return _boundary - - class Session: """HTTP session that shares sockets and ssl context.""" @@ -366,6 +360,60 @@ def __init__( self._session_id = session_id self._last_response = None + def _build_boundary_data(self, files: dict): + boundary_string = self._build_boundary_string() + content_length = 0 + boundary_objects = [] + + for field_name, field_values in files.items(): + file_name = field_values[0] + file_data = field_values[1] + + boundary_data = f"--{boundary_string}\r\n" + boundary_data += f'Content-Disposition: form-data; name="{field_name}"; ' + if file_name is not None: + boundary_data += f'filename="{file_name}"' + boundary_data += "\r\n" + if len(field_values) >= 3: + file_content_type = field_values[2] + boundary_data += f"Content-Type: {file_content_type}\r\n" + if len(field_values) >= 4: + file_headers = field_values[3] + for file_header_key, file_header_value in file_headers.items(): + boundary_data += f"{file_header_key}: {file_header_value}\r\n" + boundary_data += "\r\n" + + content_length += len(boundary_data) + boundary_objects.append(boundary_data) + + if file_name is not None: + file_data.seek(0, SEEK_END) + content_length += file_data.tell() + file_data.seek(0) + boundary_objects.append(file_data) + boundary_data = "" + else: + boundary_data = file_data + + boundary_data += "\r\n" + content_length += len(boundary_data) + boundary_objects.append(boundary_data) + + boundary_data = f"--{boundary_string}--" + + content_length += len(boundary_data) + boundary_objects.append(boundary_data) + + return boundary_string, content_length, boundary_objects + + @staticmethod + def _build_boundary_string(): + hex_characters = "0123456789abcdef" + _boundary = "" + for _ in range(32): + _boundary += random.choice(hex_characters) + return _boundary + @staticmethod def _check_headers(headers: Dict[str, str]): if not isinstance(headers, dict): @@ -399,10 +447,31 @@ def _send(socket: SocketType, data: bytes): # Not EAGAIN; that was already handled. raise OSError(errno.EIO) total_sent += sent + return total_sent def _send_as_bytes(self, socket: SocketType, data: str): return self._send(socket, bytes(data, "utf-8")) + def _send_boundary_objects(self, socket: SocketType, boundary_objects: Any): + for boundary_object in boundary_objects: + if isinstance(boundary_object, str): + self._send_as_bytes(socket, boundary_object) + else: + chunk_size = 32 + if hasattr(boundary_object, "readinto"): + b = bytearray(chunk_size) + while True: + size = boundary_object.readinto(b) + if size == 0: + break + self._send(socket, b[:size]) + else: + while True: + b = boundary_object.read(chunk_size) + if len(b) == 0: + break + self._send(socket, b) + def _send_header(self, socket, header, value): if value is None: return @@ -440,6 +509,7 @@ def _send_request( # pylint: disable=too-many-arguments # If data is sent and it's a dict, set content type header and convert to string if data and isinstance(data, dict): + assert files is None content_type_header = "application/x-www-form-urlencoded" _post_data = "" for k in data: @@ -451,8 +521,18 @@ def _send_request( # pylint: disable=too-many-arguments if data and isinstance(data, str): data = bytes(data, "utf-8") - if data is None: - data = b"" + # If files are send, build data to send and calculate length + content_length = 0 + boundary_objects = None + if files and isinstance(files, dict): + boundary_string, content_length, boundary_objects = ( + self._build_boundary_data(files) + ) + content_type_header = f"multipart/form-data; boundary={boundary_string}" + else: + if data is None: + data = b"" + content_length = len(data) self._send_as_bytes(socket, method) self._send(socket, b" /") @@ -461,60 +541,6 @@ def _send_request( # pylint: disable=too-many-arguments # create lower-case supplied header list supplied_headers = {header.lower() for header in headers} - boundary_str = None - - # pylint: disable=too-many-nested-blocks - if files is not None and isinstance(files, dict): - boundary_str = _generate_boundary_str() - content_type_header = f"multipart/form-data; boundary={boundary_str}" - - for fieldname in files.keys(): - if not fieldname.endswith("-name"): - if files[fieldname][0] is not None: - file_content = files[fieldname][1].read() - - data += b"--" + boundary_str.encode() + b"\r\n" - data += ( - b'Content-Disposition: form-data; name="' - + fieldname.encode() - + b'"; filename="' - + files[fieldname][0].encode() - + b'"\r\n' - ) - if len(files[fieldname]) >= 3: - data += ( - b"Content-Type: " - + files[fieldname][2].encode() - + b"\r\n" - ) - if len(files[fieldname]) >= 4: - for custom_header_key in files[fieldname][3].keys(): - data += ( - custom_header_key.encode() - + b": " - + files[fieldname][3][custom_header_key].encode() - + b"\r\n" - ) - data += b"\r\n" - data += file_content + b"\r\n" - else: - # filename is None - data += b"--" + boundary_str.encode() + b"\r\n" - data += ( - b'Content-Disposition: form-data; name="' - + fieldname.encode() - + b'"; \r\n' - ) - if len(files[fieldname]) >= 3: - data += ( - b"Content-Type: " - + files[fieldname][2].encode() - + b"\r\n" - ) - data += b"\r\n" - data += files[fieldname][1].encode() + b"\r\n" - - data += b"--" + boundary_str.encode() + b"--" # Send headers if not "host" in supplied_headers: @@ -523,8 +549,8 @@ def _send_request( # pylint: disable=too-many-arguments self._send_header(socket, "User-Agent", "Adafruit CircuitPython") if content_type_header and not "content-type" in supplied_headers: self._send_header(socket, "Content-Type", content_type_header) - if data and not "content-length" in supplied_headers: - self._send_header(socket, "Content-Length", str(len(data))) + if (data or files) and not "content-length" in supplied_headers: + self._send_header(socket, "Content-Length", str(content_length)) # Iterate over keys to avoid tuple alloc for header in headers: self._send_header(socket, header, headers[header]) @@ -533,6 +559,8 @@ def _send_request( # pylint: disable=too-many-arguments # Send data if data: self._send(socket, bytes(data)) + elif boundary_objects: + self._send_boundary_objects(socket, boundary_objects) # pylint: disable=too-many-branches, too-many-statements, unused-argument, too-many-arguments, too-many-locals def request( diff --git a/examples/wifi/expanded/requests_wifi_file_upload.py b/examples/wifi/expanded/requests_wifi_file_upload.py index 3ceaef0..962f493 100644 --- a/examples/wifi/expanded/requests_wifi_file_upload.py +++ b/examples/wifi/expanded/requests_wifi_file_upload.py @@ -12,10 +12,10 @@ ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) requests = adafruit_requests.Session(pool, ssl_context) -with open("raspi_snip.png", "rb") as file_handle: +with open("requests_wifi_file_upload_image.png", "rb") as file_handle: files = { "file": ( - "raspi_snip.png", + "requests_wifi_file_upload_image.png", file_handle, "image/png", {"CustomHeader": "BlinkaRocks"}, From 8a14e8dd353ba8eb1b3731c62351a5a71f77a5a2 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Mon, 22 Apr 2024 10:09:55 -0700 Subject: [PATCH 05/13] Add tests --- adafruit_requests.py | 54 +++---- tests/files/green_red.png | Bin 0 -> 125 bytes tests/files/green_red.png.license | 2 + tests/files/red_green.png | Bin 0 -> 123 bytes tests/files/red_green.png.license | 2 + tests/header_test.py | 2 +- tests/method_files.py | 238 ++++++++++++++++++++++++++++++ tests/method_test.py | 10 +- 8 files changed, 279 insertions(+), 29 deletions(-) create mode 100644 tests/files/green_red.png create mode 100644 tests/files/green_red.png.license create mode 100644 tests/files/red_green.png create mode 100644 tests/files/red_green.png.license create mode 100644 tests/method_files.py diff --git a/adafruit_requests.py b/adafruit_requests.py index 9a69428..2edce6e 100644 --- a/adafruit_requests.py +++ b/adafruit_requests.py @@ -360,19 +360,19 @@ def __init__( self._session_id = session_id self._last_response = None - def _build_boundary_data(self, files: dict): + def _build_boundary_data(self, files: dict): # pylint: disable=too-many-locals boundary_string = self._build_boundary_string() content_length = 0 boundary_objects = [] for field_name, field_values in files.items(): file_name = field_values[0] - file_data = field_values[1] + file_handle = field_values[1] boundary_data = f"--{boundary_string}\r\n" - boundary_data += f'Content-Disposition: form-data; name="{field_name}"; ' + boundary_data += f'Content-Disposition: form-data; name="{field_name}"' if file_name is not None: - boundary_data += f'filename="{file_name}"' + boundary_data += f'; filename="{file_name}"' boundary_data += "\r\n" if len(field_values) >= 3: file_content_type = field_values[2] @@ -386,20 +386,30 @@ def _build_boundary_data(self, files: dict): content_length += len(boundary_data) boundary_objects.append(boundary_data) - if file_name is not None: - file_data.seek(0, SEEK_END) - content_length += file_data.tell() - file_data.seek(0) - boundary_objects.append(file_data) + if hasattr(file_handle, "read"): + is_binary = False + try: + content = file_handle.read(1) + is_binary = isinstance(content, bytes) + except UnicodeError: + is_binary = False + + if not is_binary: + raise AttributeError("Files must be opened in binary mode") + + file_handle.seek(0, SEEK_END) + content_length += file_handle.tell() + file_handle.seek(0) + boundary_objects.append(file_handle) boundary_data = "" else: - boundary_data = file_data + boundary_data = file_handle boundary_data += "\r\n" content_length += len(boundary_data) boundary_objects.append(boundary_data) - boundary_data = f"--{boundary_string}--" + boundary_data = f"--{boundary_string}--\r\n" content_length += len(boundary_data) boundary_objects.append(boundary_data) @@ -417,7 +427,7 @@ def _build_boundary_string(): @staticmethod def _check_headers(headers: Dict[str, str]): if not isinstance(headers, dict): - raise AttributeError("headers must be in dict format") + raise AttributeError("Headers must be in dict format") for key, value in headers.items(): if isinstance(value, (str, bytes)) or value is None: @@ -447,7 +457,6 @@ def _send(socket: SocketType, data: bytes): # Not EAGAIN; that was already handled. raise OSError(errno.EIO) total_sent += sent - return total_sent def _send_as_bytes(self, socket: SocketType, data: str): return self._send(socket, bytes(data, "utf-8")) @@ -458,19 +467,12 @@ def _send_boundary_objects(self, socket: SocketType, boundary_objects: Any): self._send_as_bytes(socket, boundary_object) else: chunk_size = 32 - if hasattr(boundary_object, "readinto"): - b = bytearray(chunk_size) - while True: - size = boundary_object.readinto(b) - if size == 0: - break - self._send(socket, b[:size]) - else: - while True: - b = boundary_object.read(chunk_size) - if len(b) == 0: - break - self._send(socket, b) + b = bytearray(chunk_size) + while True: + size = boundary_object.readinto(b) + if size == 0: + break + self._send(socket, b[:size]) def _send_header(self, socket, header, value): if value is None: diff --git a/tests/files/green_red.png b/tests/files/green_red.png new file mode 100644 index 0000000000000000000000000000000000000000..7d8ddb37c20bcff4cbb43154844f21966c74bc44 GIT binary patch literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^Od!kwBL7~QRScvUi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gmMZctipf@f`+X#^d=bQhmdKI;Vst0G}=&`v3p{ literal 0 HcmV?d00001 diff --git a/tests/files/red_green.png.license b/tests/files/red_green.png.license new file mode 100644 index 0000000..d41b03e --- /dev/null +++ b/tests/files/red_green.png.license @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers +# SPDX-License-Identifier: Unlicense diff --git a/tests/header_test.py b/tests/header_test.py index 8bcb354..ddfd61a 100644 --- a/tests/header_test.py +++ b/tests/header_test.py @@ -11,7 +11,7 @@ def test_check_headers_not_dict(requests): with pytest.raises(AttributeError) as context: requests._check_headers("") - assert "headers must be in dict format" in str(context) + assert "Headers must be in dict format" in str(context) def test_check_headers_not_valid(requests): diff --git a/tests/method_files.py b/tests/method_files.py new file mode 100644 index 0000000..1e28242 --- /dev/null +++ b/tests/method_files.py @@ -0,0 +1,238 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +""" Post Tests """ +# pylint: disable=line-too-long + +from unittest import mock + +import mocket +import pytest + +""" +For building tests, you can use CPython requests with logging to see what should actuall get sent. + +import logging +import http.client +import requests + +def httpclient_logging_patch(level=logging.DEBUG): + logging.basicConfig(level=level) + + httpclient_logger = logging.getLogger("http.client") + + def httpclient_log(*args): + httpclient_logger.log(level, " ".join(args)) + + http.client.print = httpclient_log + http.client.HTTPConnection.debuglevel = 1 + +httpclient_logging_patch() + +URL = "https://httpbin.org/post" + +with open("tests/files/red_green.png", "rb") as file_1: + file_data = { + "file_1": ( + "red_green.png", + file_1, + "image/png", + { + "Key_1": "Value 1", + "Key_2": "Value 2", + "Key_3": "Value 3", + }, + ), + } + + print(requests.post(URL, files=file_data).json()) +""" + + +def test_post_files_text(sock, requests): + file_data = { + "key_4": (None, "Value 5"), + } + + requests._build_boundary_string = mock.Mock( + return_value="8cd45159712eeb914c049c717d3f4d75" + ) + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Type"), + mock.call(b": "), + mock.call( + b"multipart/form-data; boundary=8cd45159712eeb914c049c717d3f4d75" + ), + mock.call(b"\r\n"), + ] + ) + sock.send.assert_has_calls( + [ + mock.call( + b'--8cd45159712eeb914c049c717d3f4d75\r\nContent-Disposition: form-data; name="key_4"\r\n\r\n' + ), + mock.call(b"Value 5\r\n"), + mock.call(b"--8cd45159712eeb914c049c717d3f4d75--\r\n"), + ] + ) + + +def test_post_files_file(sock, requests): + with open("tests/files/red_green.png", "rb") as file_1: + file_data = { + "file_1": ( + "red_green.png", + file_1, + "image/png", + { + "Key_1": "Value 1", + "Key_2": "Value 2", + "Key_3": "Value 3", + }, + ), + } + + requests._build_boundary_string = mock.Mock( + return_value="e663061c5bfcc53139c8f68d016cbef3" + ) + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Type"), + mock.call(b": "), + mock.call( + b"multipart/form-data; boundary=e663061c5bfcc53139c8f68d016cbef3" + ), + mock.call(b"\r\n"), + ] + ) + sock.send.assert_has_calls( + [ + mock.call( + b'--e663061c5bfcc53139c8f68d016cbef3\r\nContent-Disposition: form-data; name="file_1"; filename="red_green.png"\r\nContent-Type: image/png\r\nKey_1: Value 1\r\nKey_2: Value 2\r\nKey_3: Value 3\r\n\r\n' + ), + mock.call( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x08\x02\x00\x00\x00\xfd\xd4\x9a" + ), + mock.call( + b"s\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00" + ), + mock.call( + b"\x00\tpHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\xa8d\x00\x00\x00\x10IDAT\x18Wc\xf8\xcf" + ), + mock.call( + b"\xc0\x00\xc5\xff\x19\x18\x00\x1d\xf0\x03\xfd\x8fk\x13|\x00\x00\x00\x00IEND\xaeB`\x82" + ), + mock.call(b"\r\n"), + mock.call(b"--e663061c5bfcc53139c8f68d016cbef3--\r\n"), + ] + ) + + +def test_post_files_complex(sock, requests): + with open("tests/files/red_green.png", "rb") as file_1, open( + "tests/files/green_red.png", "rb" + ) as file_2: + file_data = { + "file_1": ( + "red_green.png", + file_1, + "image/png", + { + "Key_1": "Value 1", + "Key_2": "Value 2", + "Key_3": "Value 3", + }, + ), + "key_4": (None, "Value 5"), + "file_2": ( + "green_red.png", + file_2, + "image/png", + ), + "key_6": (None, "Value 6"), + } + + requests._build_boundary_string = mock.Mock( + return_value="e663061c5bfcc53139c8f68d016cbef3" + ) + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Type"), + mock.call(b": "), + mock.call( + b"multipart/form-data; boundary=e663061c5bfcc53139c8f68d016cbef3" + ), + mock.call(b"\r\n"), + ] + ) + sock.send.assert_has_calls( + [ + mock.call( + b'--e663061c5bfcc53139c8f68d016cbef3\r\nContent-Disposition: form-data; name="file_1"; filename="red_green.png"\r\nContent-Type: image/png\r\nKey_1: Value 1\r\nKey_2: Value 2\r\nKey_3: Value 3\r\n\r\n' + ), + mock.call( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x08\x02\x00\x00\x00\xfd\xd4\x9a" + ), + mock.call( + b"s\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00" + ), + mock.call( + b"\x00\tpHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\xa8d\x00\x00\x00\x10IDAT\x18Wc\xf8\xcf" + ), + mock.call( + b"\xc0\x00\xc5\xff\x19\x18\x00\x1d\xf0\x03\xfd\x8fk\x13|\x00\x00\x00\x00IEND\xaeB`\x82" + ), + mock.call(b"\r\n"), + mock.call( + b'--e663061c5bfcc53139c8f68d016cbef3\r\nContent-Disposition: form-data; name="key_4"\r\n\r\n' + ), + mock.call(b"Value 5\r\n"), + mock.call( + b'--e663061c5bfcc53139c8f68d016cbef3\r\nContent-Disposition: form-data; name="file_2"; filename="green_red.png"\r\nContent-Type: image/png\r\n\r\n' + ), + mock.call( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x08\x02\x00\x00\x00\xfd\xd4\x9a" + ), + mock.call( + b"s\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00" + ), + mock.call( + b"\x00\tpHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\xa8d\x00\x00\x00\x12IDAT\x18Wc`\xf8" + ), + mock.call( + b'\x0f\x84 \x92\x81\xe1?\x03\x00\x1d\xf0\x03\xfd\x88"uS\x00\x00\x00\x00IEND\xaeB`\x82' + ), + mock.call(b"\r\n"), + mock.call( + b'--e663061c5bfcc53139c8f68d016cbef3\r\nContent-Disposition: form-data; name="key_6"\r\n\r\n' + ), + mock.call(b"Value 6\r\n"), + mock.call(b"--e663061c5bfcc53139c8f68d016cbef3--\r\n"), + ] + ) + + +def test_post_files_not_binary(requests): + with open("tests/files/red_green.png", "r") as file_1: + file_data = { + "file_1": ( + "red_green.png", + file_1, + "image/png", + ), + } + + with pytest.raises(AttributeError) as context: + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + assert "Files must be opened in binary mode" in str(context) diff --git a/tests/method_test.py b/tests/method_test.py index d75e754..1cda6c2 100644 --- a/tests/method_test.py +++ b/tests/method_test.py @@ -52,7 +52,10 @@ def test_post_string(sock, requests): def test_post_form(sock, requests): - data = {"Date": "July 25, 2019", "Time": "12:00"} + data = { + "Date": "July 25, 2019", + "Time": "12:00", + } requests.post("http://" + mocket.MOCK_HOST_1 + "/post", data=data) sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) sock.send.assert_has_calls( @@ -67,7 +70,10 @@ def test_post_form(sock, requests): def test_post_json(sock, requests): - json_data = {"Date": "July 25, 2019", "Time": "12:00"} + json_data = { + "Date": "July 25, 2019", + "Time": "12:00", + } requests.post("http://" + mocket.MOCK_HOST_1 + "/post", json=json_data) sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) sock.send.assert_has_calls( From d5614b2d32dfdd3774aaf10e44b372a68744d026 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Mon, 22 Apr 2024 10:25:34 -0700 Subject: [PATCH 06/13] Fix test file name --- tests/{method_files.py => files_test.py} | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) rename tests/{method_files.py => files_test.py} (93%) diff --git a/tests/method_files.py b/tests/files_test.py similarity index 93% rename from tests/method_files.py rename to tests/files_test.py index 1e28242..4985cac 100644 --- a/tests/method_files.py +++ b/tests/files_test.py @@ -71,6 +71,14 @@ def test_post_files_text(sock, requests): mock.call(b"\r\n"), ] ) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Length"), + mock.call(b": "), + mock.call(b"131"), + mock.call(b"\r\n"), + ] + ) sock.send.assert_has_calls( [ mock.call( @@ -113,6 +121,14 @@ def test_post_files_file(sock, requests): mock.call(b"\r\n"), ] ) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Length"), + mock.call(b": "), + mock.call(b"347"), + mock.call(b"\r\n"), + ] + ) sock.send.assert_has_calls( [ mock.call( @@ -176,6 +192,14 @@ def test_post_files_complex(sock, requests): mock.call(b"\r\n"), ] ) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Length"), + mock.call(b": "), + mock.call(b"796"), + mock.call(b"\r\n"), + ] + ) sock.send.assert_has_calls( [ mock.call( From a2b43acbff84adb4bd3731e86ae3e5f55b7993d2 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Wed, 24 Apr 2024 21:44:25 -0700 Subject: [PATCH 07/13] Update example to match others --- examples/wifi/expanded/requests_wifi_file_upload.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/wifi/expanded/requests_wifi_file_upload.py b/examples/wifi/expanded/requests_wifi_file_upload.py index 962f493..bd9ac2a 100644 --- a/examples/wifi/expanded/requests_wifi_file_upload.py +++ b/examples/wifi/expanded/requests_wifi_file_upload.py @@ -23,5 +23,5 @@ "othervalue": (None, "HelloWorld"), } - with requests.post(URL, files=files) as resp: - print(resp.content) + with requests.post(URL, files=files) as response: + print(response.content) From 913c4c856f859d678856ec5a7bfc4b643d43f786 Mon Sep 17 00:00:00 2001 From: foamyguy Date: Fri, 26 Apr 2024 20:54:02 -0500 Subject: [PATCH 08/13] remove files test for now --- tests/files/green_red.png | Bin 125 -> 0 bytes tests/files/green_red.png.license | 2 - tests/files/red_green.png | Bin 123 -> 0 bytes tests/files/red_green.png.license | 2 - tests/files_test.py | 262 ------------------------------ 5 files changed, 266 deletions(-) delete mode 100644 tests/files/green_red.png delete mode 100644 tests/files/green_red.png.license delete mode 100644 tests/files/red_green.png delete mode 100644 tests/files/red_green.png.license delete mode 100644 tests/files_test.py diff --git a/tests/files/green_red.png b/tests/files/green_red.png deleted file mode 100644 index 7d8ddb37c20bcff4cbb43154844f21966c74bc44..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^Od!kwBL7~QRScvUi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gmMZctipf@f`+X#^d=bQhmdKI;Vst0G}=&`v3p{ diff --git a/tests/files/red_green.png.license b/tests/files/red_green.png.license deleted file mode 100644 index d41b03e..0000000 --- a/tests/files/red_green.png.license +++ /dev/null @@ -1,2 +0,0 @@ -# SPDX-FileCopyrightText: 2024 Justin Myers -# SPDX-License-Identifier: Unlicense diff --git a/tests/files_test.py b/tests/files_test.py deleted file mode 100644 index 4985cac..0000000 --- a/tests/files_test.py +++ /dev/null @@ -1,262 +0,0 @@ -# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries -# -# SPDX-License-Identifier: Unlicense - -""" Post Tests """ -# pylint: disable=line-too-long - -from unittest import mock - -import mocket -import pytest - -""" -For building tests, you can use CPython requests with logging to see what should actuall get sent. - -import logging -import http.client -import requests - -def httpclient_logging_patch(level=logging.DEBUG): - logging.basicConfig(level=level) - - httpclient_logger = logging.getLogger("http.client") - - def httpclient_log(*args): - httpclient_logger.log(level, " ".join(args)) - - http.client.print = httpclient_log - http.client.HTTPConnection.debuglevel = 1 - -httpclient_logging_patch() - -URL = "https://httpbin.org/post" - -with open("tests/files/red_green.png", "rb") as file_1: - file_data = { - "file_1": ( - "red_green.png", - file_1, - "image/png", - { - "Key_1": "Value 1", - "Key_2": "Value 2", - "Key_3": "Value 3", - }, - ), - } - - print(requests.post(URL, files=file_data).json()) -""" - - -def test_post_files_text(sock, requests): - file_data = { - "key_4": (None, "Value 5"), - } - - requests._build_boundary_string = mock.Mock( - return_value="8cd45159712eeb914c049c717d3f4d75" - ) - requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) - - sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) - sock.send.assert_has_calls( - [ - mock.call(b"Content-Type"), - mock.call(b": "), - mock.call( - b"multipart/form-data; boundary=8cd45159712eeb914c049c717d3f4d75" - ), - mock.call(b"\r\n"), - ] - ) - sock.send.assert_has_calls( - [ - mock.call(b"Content-Length"), - mock.call(b": "), - mock.call(b"131"), - mock.call(b"\r\n"), - ] - ) - sock.send.assert_has_calls( - [ - mock.call( - b'--8cd45159712eeb914c049c717d3f4d75\r\nContent-Disposition: form-data; name="key_4"\r\n\r\n' - ), - mock.call(b"Value 5\r\n"), - mock.call(b"--8cd45159712eeb914c049c717d3f4d75--\r\n"), - ] - ) - - -def test_post_files_file(sock, requests): - with open("tests/files/red_green.png", "rb") as file_1: - file_data = { - "file_1": ( - "red_green.png", - file_1, - "image/png", - { - "Key_1": "Value 1", - "Key_2": "Value 2", - "Key_3": "Value 3", - }, - ), - } - - requests._build_boundary_string = mock.Mock( - return_value="e663061c5bfcc53139c8f68d016cbef3" - ) - requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) - - sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) - sock.send.assert_has_calls( - [ - mock.call(b"Content-Type"), - mock.call(b": "), - mock.call( - b"multipart/form-data; boundary=e663061c5bfcc53139c8f68d016cbef3" - ), - mock.call(b"\r\n"), - ] - ) - sock.send.assert_has_calls( - [ - mock.call(b"Content-Length"), - mock.call(b": "), - mock.call(b"347"), - mock.call(b"\r\n"), - ] - ) - sock.send.assert_has_calls( - [ - mock.call( - b'--e663061c5bfcc53139c8f68d016cbef3\r\nContent-Disposition: form-data; name="file_1"; filename="red_green.png"\r\nContent-Type: image/png\r\nKey_1: Value 1\r\nKey_2: Value 2\r\nKey_3: Value 3\r\n\r\n' - ), - mock.call( - b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x08\x02\x00\x00\x00\xfd\xd4\x9a" - ), - mock.call( - b"s\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00" - ), - mock.call( - b"\x00\tpHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\xa8d\x00\x00\x00\x10IDAT\x18Wc\xf8\xcf" - ), - mock.call( - b"\xc0\x00\xc5\xff\x19\x18\x00\x1d\xf0\x03\xfd\x8fk\x13|\x00\x00\x00\x00IEND\xaeB`\x82" - ), - mock.call(b"\r\n"), - mock.call(b"--e663061c5bfcc53139c8f68d016cbef3--\r\n"), - ] - ) - - -def test_post_files_complex(sock, requests): - with open("tests/files/red_green.png", "rb") as file_1, open( - "tests/files/green_red.png", "rb" - ) as file_2: - file_data = { - "file_1": ( - "red_green.png", - file_1, - "image/png", - { - "Key_1": "Value 1", - "Key_2": "Value 2", - "Key_3": "Value 3", - }, - ), - "key_4": (None, "Value 5"), - "file_2": ( - "green_red.png", - file_2, - "image/png", - ), - "key_6": (None, "Value 6"), - } - - requests._build_boundary_string = mock.Mock( - return_value="e663061c5bfcc53139c8f68d016cbef3" - ) - requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) - - sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) - sock.send.assert_has_calls( - [ - mock.call(b"Content-Type"), - mock.call(b": "), - mock.call( - b"multipart/form-data; boundary=e663061c5bfcc53139c8f68d016cbef3" - ), - mock.call(b"\r\n"), - ] - ) - sock.send.assert_has_calls( - [ - mock.call(b"Content-Length"), - mock.call(b": "), - mock.call(b"796"), - mock.call(b"\r\n"), - ] - ) - sock.send.assert_has_calls( - [ - mock.call( - b'--e663061c5bfcc53139c8f68d016cbef3\r\nContent-Disposition: form-data; name="file_1"; filename="red_green.png"\r\nContent-Type: image/png\r\nKey_1: Value 1\r\nKey_2: Value 2\r\nKey_3: Value 3\r\n\r\n' - ), - mock.call( - b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x08\x02\x00\x00\x00\xfd\xd4\x9a" - ), - mock.call( - b"s\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00" - ), - mock.call( - b"\x00\tpHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\xa8d\x00\x00\x00\x10IDAT\x18Wc\xf8\xcf" - ), - mock.call( - b"\xc0\x00\xc5\xff\x19\x18\x00\x1d\xf0\x03\xfd\x8fk\x13|\x00\x00\x00\x00IEND\xaeB`\x82" - ), - mock.call(b"\r\n"), - mock.call( - b'--e663061c5bfcc53139c8f68d016cbef3\r\nContent-Disposition: form-data; name="key_4"\r\n\r\n' - ), - mock.call(b"Value 5\r\n"), - mock.call( - b'--e663061c5bfcc53139c8f68d016cbef3\r\nContent-Disposition: form-data; name="file_2"; filename="green_red.png"\r\nContent-Type: image/png\r\n\r\n' - ), - mock.call( - b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x08\x02\x00\x00\x00\xfd\xd4\x9a" - ), - mock.call( - b"s\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00" - ), - mock.call( - b"\x00\tpHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\xa8d\x00\x00\x00\x12IDAT\x18Wc`\xf8" - ), - mock.call( - b'\x0f\x84 \x92\x81\xe1?\x03\x00\x1d\xf0\x03\xfd\x88"uS\x00\x00\x00\x00IEND\xaeB`\x82' - ), - mock.call(b"\r\n"), - mock.call( - b'--e663061c5bfcc53139c8f68d016cbef3\r\nContent-Disposition: form-data; name="key_6"\r\n\r\n' - ), - mock.call(b"Value 6\r\n"), - mock.call(b"--e663061c5bfcc53139c8f68d016cbef3--\r\n"), - ] - ) - - -def test_post_files_not_binary(requests): - with open("tests/files/red_green.png", "r") as file_1: - file_data = { - "file_1": ( - "red_green.png", - file_1, - "image/png", - ), - } - - with pytest.raises(AttributeError) as context: - requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) - assert "Files must be opened in binary mode" in str(context) From 7c8d2b241e34dd15b7f9ae4065268a0d36bf17fa Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Sat, 27 Apr 2024 11:23:46 -0700 Subject: [PATCH 09/13] Add tests --- requirements.txt | 1 + tests/files/green_red.png | Bin 0 -> 125 bytes tests/files/green_red.png.license | 2 + tests/files/red_green.png | Bin 0 -> 123 bytes tests/files/red_green.png.license | 2 + tests/files_test.py | 205 ++++++++++++++++++++++++++++++ 6 files changed, 210 insertions(+) create mode 100644 tests/files/green_red.png create mode 100644 tests/files/green_red.png.license create mode 100644 tests/files/red_green.png create mode 100644 tests/files/red_green.png.license create mode 100644 tests/files_test.py diff --git a/requirements.txt b/requirements.txt index 2505288..ae35f68 100755 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ Adafruit-Blinka Adafruit-Circuitpython-ConnectionManager +requests diff --git a/tests/files/green_red.png b/tests/files/green_red.png new file mode 100644 index 0000000000000000000000000000000000000000..7d8ddb37c20bcff4cbb43154844f21966c74bc44 GIT binary patch literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^Od!kwBL7~QRScvUi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gmMZctipf@f`+X#^d=bQhmdKI;Vst0G}=&`v3p{ literal 0 HcmV?d00001 diff --git a/tests/files/red_green.png.license b/tests/files/red_green.png.license new file mode 100644 index 0000000..d41b03e --- /dev/null +++ b/tests/files/red_green.png.license @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers +# SPDX-License-Identifier: Unlicense diff --git a/tests/files_test.py b/tests/files_test.py new file mode 100644 index 0000000..32f69ae --- /dev/null +++ b/tests/files_test.py @@ -0,0 +1,205 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +""" Post Tests """ +# pylint: disable=line-too-long + +import re +from unittest import mock + +import mocket +import pytest +import requests as python_requests + + +@pytest.fixture +def log_stream(): + return [] + + +@pytest.fixture +def post_url(): + return "https://httpbin.org/post" + + +@pytest.fixture +def request_logging(log_stream): + """Reset the ConnectionManager, since it's a singlton and will hold data""" + import http.client # pylint: disable=import-outside-toplevel + + def httpclient_log(*args): + log_stream.append(args) + + http.client.print = httpclient_log + http.client.HTTPConnection.debuglevel = 1 + + +def get_actual_request_data(log_stream): + boundary_pattern = r"(?<=boundary=)(.\w*)" + + boundary = "" + actual_request_post = "" + for log in log_stream: + for log_arg in log: + boundary_search = re.findall(boundary_pattern, log_arg) + if boundary_search: + boundary = boundary_search[0] + elif "Content-Disposition" in log_arg: + # this will look like: + # b\'{content}\' + # and escapped characters look like: + # \\r + post_data = log_arg[2:-1] + post_bytes = post_data.encode("utf-8") + post_unescaped = post_bytes.decode("unicode_escape") + actual_request_post = post_unescaped.encode("latin1") + + return boundary, actual_request_post + + +def test_post_files_text( # pylint: disable=unused-argument + sock, requests, log_stream, post_url, request_logging +): + file_data = { + "key_4": (None, "Value 5"), + } + + python_requests.post(post_url, files=file_data) + boundary, actual_request_post = get_actual_request_data(log_stream) + + requests._build_boundary_string = mock.Mock(return_value=boundary) + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Type"), + mock.call(b": "), + mock.call(f"multipart/form-data; boundary={boundary}".encode()), + mock.call(b"\r\n"), + ] + ) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Length"), + mock.call(b": "), + mock.call(b"131"), + mock.call(b"\r\n"), + ] + ) + + sent = b"".join(sock.sent_data) + assert sent.endswith(actual_request_post) + + +def test_post_files_file( # pylint: disable=unused-argument + sock, requests, log_stream, post_url, request_logging +): + with open("tests/files/red_green.png", "rb") as file_1: + file_data = { + "file_1": ( + "red_green.png", + file_1, + "image/png", + { + "Key_1": "Value 1", + "Key_2": "Value 2", + "Key_3": "Value 3", + }, + ), + } + + python_requests.post(post_url, files=file_data) + boundary, actual_request_post = get_actual_request_data(log_stream) + + requests._build_boundary_string = mock.Mock(return_value=boundary) + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Type"), + mock.call(b": "), + mock.call(f"multipart/form-data; boundary={boundary}".encode()), + mock.call(b"\r\n"), + ] + ) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Length"), + mock.call(b": "), + mock.call(b"347"), + mock.call(b"\r\n"), + ] + ) + sent = b"".join(sock.sent_data) + assert sent.endswith(actual_request_post) + + +def test_post_files_complex( # pylint: disable=unused-argument + sock, requests, log_stream, post_url, request_logging +): + with open("tests/files/red_green.png", "rb") as file_1, open( + "tests/files/green_red.png", "rb" + ) as file_2: + file_data = { + "file_1": ( + "red_green.png", + file_1, + "image/png", + { + "Key_1": "Value 1", + "Key_2": "Value 2", + "Key_3": "Value 3", + }, + ), + "key_4": (None, "Value 5"), + "file_2": ( + "green_red.png", + file_2, + "image/png", + ), + "key_6": (None, "Value 6"), + } + + python_requests.post(post_url, files=file_data) + boundary, actual_request_post = get_actual_request_data(log_stream) + + requests._build_boundary_string = mock.Mock(return_value=boundary) + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Type"), + mock.call(b": "), + mock.call(f"multipart/form-data; boundary={boundary}".encode()), + mock.call(b"\r\n"), + ] + ) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Length"), + mock.call(b": "), + mock.call(b"796"), + mock.call(b"\r\n"), + ] + ) + sent = b"".join(sock.sent_data) + assert sent.endswith(actual_request_post) + + +def test_post_files_not_binary(requests): + with open("tests/files/red_green.png", "r") as file_1: + file_data = { + "file_1": ( + "red_green.png", + file_1, + "image/png", + ), + } + + with pytest.raises(AttributeError) as context: + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + assert "Files must be opened in binary mode" in str(context) From 1eaa1a816e568573578197e3f89f153c1318e1fb Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Sat, 27 Apr 2024 11:38:26 -0700 Subject: [PATCH 10/13] Use actual content length too --- tests/files_test.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/files_test.py b/tests/files_test.py index 32f69ae..fe6e77c 100644 --- a/tests/files_test.py +++ b/tests/files_test.py @@ -37,15 +37,20 @@ def httpclient_log(*args): def get_actual_request_data(log_stream): boundary_pattern = r"(?<=boundary=)(.\w*)" + content_length_pattern = r"(?<=Content-Length: )(.\d*)" boundary = "" actual_request_post = "" + content_length = "" for log in log_stream: for log_arg in log: boundary_search = re.findall(boundary_pattern, log_arg) + content_length_search = re.findall(content_length_pattern, log_arg) if boundary_search: boundary = boundary_search[0] - elif "Content-Disposition" in log_arg: + if content_length_search: + content_length = content_length_search[0] + if "Content-Disposition" in log_arg: # this will look like: # b\'{content}\' # and escapped characters look like: @@ -55,7 +60,7 @@ def get_actual_request_data(log_stream): post_unescaped = post_bytes.decode("unicode_escape") actual_request_post = post_unescaped.encode("latin1") - return boundary, actual_request_post + return boundary, content_length, actual_request_post def test_post_files_text( # pylint: disable=unused-argument @@ -66,7 +71,7 @@ def test_post_files_text( # pylint: disable=unused-argument } python_requests.post(post_url, files=file_data) - boundary, actual_request_post = get_actual_request_data(log_stream) + boundary, content_length, actual_request_post = get_actual_request_data(log_stream) requests._build_boundary_string = mock.Mock(return_value=boundary) requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) @@ -84,7 +89,7 @@ def test_post_files_text( # pylint: disable=unused-argument [ mock.call(b"Content-Length"), mock.call(b": "), - mock.call(b"131"), + mock.call(content_length.encode()), mock.call(b"\r\n"), ] ) @@ -111,7 +116,9 @@ def test_post_files_file( # pylint: disable=unused-argument } python_requests.post(post_url, files=file_data) - boundary, actual_request_post = get_actual_request_data(log_stream) + boundary, content_length, actual_request_post = get_actual_request_data( + log_stream + ) requests._build_boundary_string = mock.Mock(return_value=boundary) requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) @@ -129,7 +136,7 @@ def test_post_files_file( # pylint: disable=unused-argument [ mock.call(b"Content-Length"), mock.call(b": "), - mock.call(b"347"), + mock.call(content_length.encode()), mock.call(b"\r\n"), ] ) @@ -164,7 +171,9 @@ def test_post_files_complex( # pylint: disable=unused-argument } python_requests.post(post_url, files=file_data) - boundary, actual_request_post = get_actual_request_data(log_stream) + boundary, content_length, actual_request_post = get_actual_request_data( + log_stream + ) requests._build_boundary_string = mock.Mock(return_value=boundary) requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) @@ -182,7 +191,7 @@ def test_post_files_complex( # pylint: disable=unused-argument [ mock.call(b"Content-Length"), mock.call(b": "), - mock.call(b"796"), + mock.call(content_length.encode()), mock.call(b"\r\n"), ] ) From 9b9f7bcd6f04baec298bd3b676914f22afb08ac7 Mon Sep 17 00:00:00 2001 From: foamyguy Date: Mon, 29 Apr 2024 08:31:22 -0500 Subject: [PATCH 11/13] move requests to optional. change copyright name in new test file. add timeouts to python_requests.post. --- optional_requirements.txt | 2 ++ requirements.txt | 1 - tests/files_test.py | 12 ++++++------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/optional_requirements.txt b/optional_requirements.txt index d4e27c4..38e5c0c 100644 --- a/optional_requirements.txt +++ b/optional_requirements.txt @@ -1,3 +1,5 @@ # SPDX-FileCopyrightText: 2022 Alec Delaney, for Adafruit Industries # # SPDX-License-Identifier: Unlicense + +requests diff --git a/requirements.txt b/requirements.txt index ae35f68..2505288 100755 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,3 @@ Adafruit-Blinka Adafruit-Circuitpython-ConnectionManager -requests diff --git a/tests/files_test.py b/tests/files_test.py index fe6e77c..8299b1b 100644 --- a/tests/files_test.py +++ b/tests/files_test.py @@ -1,8 +1,8 @@ -# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-FileCopyrightText: 2024 Justin Myers # # SPDX-License-Identifier: Unlicense -""" Post Tests """ +""" Post Files Tests """ # pylint: disable=line-too-long import re @@ -53,7 +53,7 @@ def get_actual_request_data(log_stream): if "Content-Disposition" in log_arg: # this will look like: # b\'{content}\' - # and escapped characters look like: + # and escaped characters look like: # \\r post_data = log_arg[2:-1] post_bytes = post_data.encode("utf-8") @@ -70,7 +70,7 @@ def test_post_files_text( # pylint: disable=unused-argument "key_4": (None, "Value 5"), } - python_requests.post(post_url, files=file_data) + python_requests.post(post_url, files=file_data, timeout=30) boundary, content_length, actual_request_post = get_actual_request_data(log_stream) requests._build_boundary_string = mock.Mock(return_value=boundary) @@ -115,7 +115,7 @@ def test_post_files_file( # pylint: disable=unused-argument ), } - python_requests.post(post_url, files=file_data) + python_requests.post(post_url, files=file_data, timeout=30) boundary, content_length, actual_request_post = get_actual_request_data( log_stream ) @@ -170,7 +170,7 @@ def test_post_files_complex( # pylint: disable=unused-argument "key_6": (None, "Value 6"), } - python_requests.post(post_url, files=file_data) + python_requests.post(post_url, files=file_data, timeout=30) boundary, content_length, actual_request_post = get_actual_request_data( log_stream ) From 5a2934e48517bbb926d41e9fd29018323115a9e7 Mon Sep 17 00:00:00 2001 From: foamyguy Date: Wed, 1 May 2024 20:19:03 -0500 Subject: [PATCH 12/13] use os.urandom() --- adafruit_requests.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/adafruit_requests.py b/adafruit_requests.py index 2edce6e..16666cc 100644 --- a/adafruit_requests.py +++ b/adafruit_requests.py @@ -41,7 +41,7 @@ import errno import json as json_module -import random +import os import sys from adafruit_connection_manager import get_connection_manager @@ -418,11 +418,7 @@ def _build_boundary_data(self, files: dict): # pylint: disable=too-many-locals @staticmethod def _build_boundary_string(): - hex_characters = "0123456789abcdef" - _boundary = "" - for _ in range(32): - _boundary += random.choice(hex_characters) - return _boundary + return os.urandom(16).hex() @staticmethod def _check_headers(headers: Dict[str, str]): From 0f9b71fa42808af5b30a6d60068447b69a077316 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Fri, 3 May 2024 13:15:47 -0700 Subject: [PATCH 13/13] Remove str concatenation --- adafruit_requests.py | 36 ++++++++++++++++-------------------- tox.ini | 2 ++ 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/adafruit_requests.py b/adafruit_requests.py index 16666cc..7dd3462 100644 --- a/adafruit_requests.py +++ b/adafruit_requests.py @@ -369,22 +369,22 @@ def _build_boundary_data(self, files: dict): # pylint: disable=too-many-locals file_name = field_values[0] file_handle = field_values[1] - boundary_data = f"--{boundary_string}\r\n" - boundary_data += f'Content-Disposition: form-data; name="{field_name}"' + boundary_objects.append( + f'--{boundary_string}\r\nContent-Disposition: form-data; name="{field_name}"' + ) if file_name is not None: - boundary_data += f'; filename="{file_name}"' - boundary_data += "\r\n" + boundary_objects.append(f'; filename="{file_name}"') + boundary_objects.append("\r\n") if len(field_values) >= 3: file_content_type = field_values[2] - boundary_data += f"Content-Type: {file_content_type}\r\n" + boundary_objects.append(f"Content-Type: {file_content_type}\r\n") if len(field_values) >= 4: file_headers = field_values[3] for file_header_key, file_header_value in file_headers.items(): - boundary_data += f"{file_header_key}: {file_header_value}\r\n" - boundary_data += "\r\n" - - content_length += len(boundary_data) - boundary_objects.append(boundary_data) + boundary_objects.append( + f"{file_header_key}: {file_header_value}\r\n" + ) + boundary_objects.append("\r\n") if hasattr(file_handle, "read"): is_binary = False @@ -400,19 +400,15 @@ def _build_boundary_data(self, files: dict): # pylint: disable=too-many-locals file_handle.seek(0, SEEK_END) content_length += file_handle.tell() file_handle.seek(0) - boundary_objects.append(file_handle) - boundary_data = "" - else: - boundary_data = file_handle - boundary_data += "\r\n" - content_length += len(boundary_data) - boundary_objects.append(boundary_data) + boundary_objects.append(file_handle) + boundary_objects.append("\r\n") - boundary_data = f"--{boundary_string}--\r\n" + boundary_objects.append(f"--{boundary_string}--\r\n") - content_length += len(boundary_data) - boundary_objects.append(boundary_data) + for boundary_object in boundary_objects: + if isinstance(boundary_object, str): + content_length += len(boundary_object) return boundary_string, content_length, boundary_objects diff --git a/tox.ini b/tox.ini index 85530c9..099a9b7 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ envlist = py311 description = run tests deps = pytest==7.4.3 + requests commands = pytest [testenv:coverage] @@ -17,6 +18,7 @@ description = run coverage deps = pytest==7.4.3 pytest-cov==4.1.0 + requests package = editable commands = coverage run --source=. --omit=tests/* --branch {posargs} -m pytest