From fc0673f3dc234398280441ebcc918e434413d246 Mon Sep 17 00:00:00 2001 From: szeka9 Date: Wed, 25 Mar 2026 23:33:43 +0100 Subject: [PATCH 1/8] Use lookups for endpoint callbacks Replace callback dictionary with tuples to improve RAM usage and readability. --- src/pyrobusta/protocol/http.py | 65 ++++++++++++++++-------- src/pyrobusta/protocol/http_multipart.py | 2 +- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/src/pyrobusta/protocol/http.py b/src/pyrobusta/protocol/http.py index ddd9127..4e23e7f 100644 --- a/src/pyrobusta/protocol/http.py +++ b/src/pyrobusta/protocol/http.py @@ -45,7 +45,7 @@ class HttpEngine: "mp_closing_delimiter", ) - ENDPOINTS = {} + ENDPOINTS = [] # (endpoint, callback, method) RESP_HEADERS = ( 200, b"200 OK", @@ -144,11 +144,13 @@ def register( """ endpoint = endpoint.encode(cls.ASCII) method = method.encode(cls.ASCII) - if not endpoint in cls.ENDPOINTS: - cls.ENDPOINTS[endpoint] = {} + endpoint_exists = cls._get_callback(endpoint, method) is not None + if method not in cls.METHODS: raise ValueError(f"method must be one of {cls.METHODS}") - cls.ENDPOINTS[endpoint][method] = callback + if endpoint_exists: + raise ValueError("endpoint exists") + cls.ENDPOINTS.append((endpoint, callback, method)) @staticmethod def route(endpoint, method): @@ -166,15 +168,31 @@ def decorator(func): # Static helpers for parsing # ========================================= + @staticmethod + def _lookup(tuple_, key): + idx = tuple_.index(key) + return tuple_[idx + 1] + + @classmethod + def _get_callback(cls, endpoint, method): + for e in cls.ENDPOINTS: + if endpoint == e[0] and method == e[2]: + return e[1] + @classmethod - def _get_status(cls, status_code): - idx = cls.RESP_HEADERS.index(status_code) - return cls.RESP_HEADERS[idx + 1] + def _has_endpoint(cls, endpoint): + for e in cls.ENDPOINTS: + if endpoint == e[0]: + return True + return False @classmethod - def _get_content_type(cls, extension): - idx = cls.CONTENT_TYPES.index(extension) - return cls.CONTENT_TYPES[idx + 1] + def _supported_methods(cls, endpoint): + supported_methods = [] + for method in cls.METHODS: + if cls._get_callback(endpoint, method) is not None: + supported_methods.append(method) + return supported_methods @classmethod def _parse_headers(cls, raw_headers: memoryview) -> dict[str, str | int]: @@ -263,7 +281,7 @@ def _write_response_head(self, tx, content_length: int = 0): tx.consume() tx.write(self.version) tx.write(b" ") - tx.write(self._get_status(self.status_code)) + tx.write(self._lookup(self.RESP_HEADERS, self.status_code)) if content_length is not None: tx.write(b"\r\n") tx.write(b"content-length: %s" % str(content_length).encode(self.ASCII)) @@ -398,13 +416,16 @@ def _route_request_st(self, _, tx): State for routing requests - supported ways: static resources, endpoint callback functions """ - if self.url in self.ENDPOINTS and ( - self.method in self.ENDPOINTS[self.url] + if self._has_endpoint(self.url) and ( + self._get_callback(self.url, self.method) is not None or self.method == self.OPTIONS - or (self.method == self.HEAD and self.GET in self.ENDPOINTS[self.url]) + or ( + self.method == self.HEAD + and self._get_callback(self.url, self.GET) is not None + ) ): if self.method == self.OPTIONS: - supported_methods = list(self.ENDPOINTS[self.url].keys()) + supported_methods = self._supported_methods(self.url) self._set_response_header(b"allow", b", ".join(supported_methods)) self.terminate(204, None) self._write_response_head(tx, None) @@ -421,8 +442,12 @@ def _route_request_st(self, _, tx): else: self.state = self._app_endpoint_st return - if self.url in self.ENDPOINTS and self.method not in self.ENDPOINTS[self.url]: - supported_methods = list(self.ENDPOINTS[self.url].keys()) + + if ( + self._has_endpoint(self.url) + and self._get_callback(self.method, self.url) is None + ): + supported_methods = self._supported_methods(self.url) self._set_response_header(b"allow", b", ".join(supported_methods)) self.on_method_not_allowed(tx) return @@ -443,7 +468,7 @@ def _recv_payload(self, rx, tx): def _app_endpoint_st(self, rx, tx): """Process a request by registered callback functions""" method = self.GET if self.method == self.HEAD else self.method - callback = self.ENDPOINTS[self.url][method] + callback = self._get_callback(self.url, method) if self._has_payload(): self.state = None dtype, data = callback(self.headers, bytes(rx.peek())) @@ -500,9 +525,9 @@ def _send_file_st(self, _, tx, web_resource: bytes): norm_path = b"/".join(parts) try: - content_type = self._get_content_type(extension) + content_type = self._lookup(self.CONTENT_TYPES, extension) except ValueError: - content_type = self._get_content_type(b"raw") + content_type = self._lookup(self.CONTENT_TYPES, b"raw") try: self._set_response_header( b"content-length", str(stat(norm_path)[6]).encode(HttpEngine.ASCII) diff --git a/src/pyrobusta/protocol/http_multipart.py b/src/pyrobusta/protocol/http_multipart.py index 75cde2f..5296698 100644 --- a/src/pyrobusta/protocol/http_multipart.py +++ b/src/pyrobusta/protocol/http_multipart.py @@ -107,7 +107,7 @@ def _parse_complete_part_st(self, rx, tx): except http.HeaderParsingError: self.on_client_error(tx, http.HttpEngine.HEADER_ERROR) return - callback = http.HttpEngine.ENDPOINTS[self.url][self.method] + callback = http.HttpEngine._get_callback(self.url, self.method) # Process complete part if not is_final: callback(part_headers, part_body, first=self.mp_first_part, last=False) From dff5c92b70d0cf7dc4b8256a5f8b1517efdfffec Mon Sep 17 00:00:00 2001 From: szeka9 Date: Wed, 25 Mar 2026 23:40:20 +0100 Subject: [PATCH 2/8] Utilize state machine object by endpoints Instead of passing state machine variables selectively, supply the statemachine object as a context as a uniform contract in all callback functions. --- example/mip_repo/app.py | 4 +-- src/pyrobusta/protocol/http.py | 5 ++-- src/pyrobusta/protocol/http_multipart.py | 6 ++-- tests/functional/test_http.py | 18 ++++++------ tests/unit/test_http.py | 35 ++++++++++++++---------- 5 files changed, 39 insertions(+), 29 deletions(-) diff --git a/example/mip_repo/app.py b/example/mip_repo/app.py index e637e59..5955b9a 100644 --- a/example/mip_repo/app.py +++ b/example/mip_repo/app.py @@ -28,10 +28,10 @@ def append_package_files(dir, package_files, host_name, protocol): @HttpEngine.route("/pyrobusta/package.json", "GET") -def self_serve_mip_package(headers, _): +def self_serve_mip_package(http_ctx, _): package_files = {"version": config.PYROBUSTA_VERSION, "deps": [], "urls": []} tls_enabled = config.get_config("tls").lower() == "true" - server_addr = headers["host"] + server_addr = http_ctx.headers["host"] if ":" not in server_addr: port = ( http_server.HttpServer.LISTEN_PORT_HTTPS diff --git a/src/pyrobusta/protocol/http.py b/src/pyrobusta/protocol/http.py index 4e23e7f..7384e89 100644 --- a/src/pyrobusta/protocol/http.py +++ b/src/pyrobusta/protocol/http.py @@ -41,6 +41,7 @@ class HttpEngine: "content_length_cnt", "mp_boundary", "mp_first_part", + "mp_last_part", "mp_delimiter", "mp_closing_delimiter", ) @@ -471,7 +472,7 @@ def _app_endpoint_st(self, rx, tx): callback = self._get_callback(self.url, method) if self._has_payload(): self.state = None - dtype, data = callback(self.headers, bytes(rx.peek())) + dtype, data = callback(self, bytes(rx.peek())) dtype = dtype.encode(self.ASCII) else: if not callable(callback): @@ -480,7 +481,7 @@ def _app_endpoint_st(self, rx, tx): _rx, _tx, callback.encode(HttpEngine.ASCII) ) return - dtype, data = callback(self.headers, b"") + dtype, data = callback(self, b"") dtype = dtype.encode(self.ASCII) self._set_response_header(b"content-type", dtype) if dtype == b"image/jpeg": diff --git a/src/pyrobusta/protocol/http_multipart.py b/src/pyrobusta/protocol/http_multipart.py index 5296698..a8e6e96 100644 --- a/src/pyrobusta/protocol/http_multipart.py +++ b/src/pyrobusta/protocol/http_multipart.py @@ -110,7 +110,7 @@ def _parse_complete_part_st(self, rx, tx): callback = http.HttpEngine._get_callback(self.url, self.method) # Process complete part if not is_final: - callback(part_headers, part_body, first=self.mp_first_part, last=False) + callback(self, (part_headers, part_body)) if rx.peek(len(self.mp_delimiter)) != self.mp_delimiter: self.on_client_error(tx, http.HttpEngine.MULTIPART_BOUNDARY_ERROR) return @@ -129,7 +129,8 @@ def _parse_complete_part_st(self, rx, tx): ): self.on_client_error(tx, http.HttpEngine.CONTENT_LENGTH_ERROR) return - dtype, data = callback(part_headers, part_body, first=self.mp_first_part, last=True) + self.mp_last_part = True + dtype, data = callback(self, (part_headers, part_body)) self.terminate(200, dtype.encode(http.HttpEngine.ASCII)) return self._generate_response(tx, data) @@ -145,6 +146,7 @@ def apply_patches(): def new_init(self, *args, **kwargs): orig_init(self, *args, **kwargs) self.mp_first_part = True + self.mp_last_part = False self.mp_delimiter = None self.mp_closing_delimiter = None diff --git a/tests/functional/test_http.py b/tests/functional/test_http.py index 76b084a..611a0d2 100644 --- a/tests/functional/test_http.py +++ b/tests/functional/test_http.py @@ -70,22 +70,22 @@ def response_generator(): @HttpEngine.route("/test/simple", "GET") -def simple_callback(headers, body): - if headers["accept"] == "text/plain": +def simple_callback(http_ctx, _): + if http_ctx.headers["accept"] == "text/plain": return "text/plain", "Test response\n" - elif headers["accept"] == "application/json": + elif http_ctx.headers["accept"] == "application/json": return "application/json", '{"response": "Test response"}' raise ValueError("Unhandled content-type") @HttpEngine.route("/test/multipart", "GET") -def multipart_callback(headers, body): - part_count = int(headers["x-part-count"]) +def multipart_callback(http_ctx, _): + part_count = int(http_ctx.headers["x-part-count"]) return "multipart/form-data", ("text/plain", multipart_response(part_count)) @HttpEngine.route("/test/busy", "POST") -def busy_callback(headers, body): +def busy_callback(*_): raise ServerBusyError() @@ -211,19 +211,19 @@ def test_registration(): test_assert( "simple endpoint registration", simple_callback, - HttpEngine.ENDPOINTS[b"/test/simple"][b"GET"], + HttpEngine._get_callback(b"/test/simple", b"GET"), ) test_assert( "multipart endpoint registration", multipart_callback, - HttpEngine.ENDPOINTS[b"/test/multipart"][b"GET"], + HttpEngine._get_callback(b"/test/multipart", b"GET"), ) test_assert( "busy endpoint registration", busy_callback, - HttpEngine.ENDPOINTS[b"/test/busy"][b"POST"], + HttpEngine._get_callback(b"/test/busy", b"POST"), ) diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index b4774cd..12ed889 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -307,19 +307,23 @@ def test_multipart_receiver_complete_part(self): self.assertEqual(self.engine.state, self.engine._parse_complete_part_st) self.assertEqual(self.rx.peek(), body_part) + self.assertEqual(self.engine.mp_first_part, True) self.engine.state(self.rx, self.tx) self.assertEqual(self.engine.state, self.engine._parse_boundary_st) test_callback.assert_called_once_with( - { - "content-disposition": 'form-data;name="file-chunk";filename="upload.txt"', - "content-type": "text/plain", - }, - b"Upload content", - first=True, - last=False, + self.engine, + ( + { + "content-disposition": 'form-data;name="file-chunk";filename="upload.txt"', + "content-type": "text/plain", + }, + b"Upload content", + ), ) + self.assertEqual(self.engine.mp_first_part, False) + self.assertEqual(self.engine.mp_last_part, False) def test_multipart_receiver_last_part(self): self.engine.state = self.engine._parse_boundary_st @@ -354,14 +358,17 @@ def test_multipart_receiver_last_part(self): self.assertEqual(self.engine.state, None) self.assertEqual(self.engine.status_code, 200) test_callback.assert_called_once_with( - { - "content-disposition": 'form-data;name="file-chunk";filename="upload.txt"', - "content-type": "text/plain", - }, - b"Upload content", - first=True, - last=True, + self.engine, + ( + { + "content-disposition": 'form-data;name="file-chunk";filename="upload.txt"', + "content-type": "text/plain", + }, + b"Upload content", + ), ) + self.assertEqual(self.engine.mp_first_part, True) + self.assertEqual(self.engine.mp_last_part, True) if __name__ == "__main__": From 86517032819fd62768c7181d3bf13673766a465b Mon Sep 17 00:00:00 2001 From: szeka9 Date: Wed, 25 Mar 2026 23:52:51 +0100 Subject: [PATCH 3/8] Allow empty lines in runtime configuration --- src/pyrobusta/utils/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyrobusta/utils/config.py b/src/pyrobusta/utils/config.py index 331dd0b..5d74cd0 100644 --- a/src/pyrobusta/utils/config.py +++ b/src/pyrobusta/utils/config.py @@ -36,7 +36,7 @@ def read_config(config=CONFIG_LOCATION): with open(config, encoding="utf-8") as conf: for line in conf.read().splitlines("\n"): key = line.split("=")[0].strip() - if key.startswith("#"): + if key.startswith("#") or not line.strip(): continue value = line.split("=")[1].strip().strip("'").strip('"') if key and value: From 8798654376402343524b0e7d15f04fa88761d01a Mon Sep 17 00:00:00 2001 From: szeka9 Date: Thu, 26 Mar 2026 23:51:43 +0100 Subject: [PATCH 4/8] Support HTTP HEAD for static resources --- src/pyrobusta/protocol/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyrobusta/protocol/http.py b/src/pyrobusta/protocol/http.py index 7384e89..4abcd76 100644 --- a/src/pyrobusta/protocol/http.py +++ b/src/pyrobusta/protocol/http.py @@ -452,7 +452,7 @@ def _route_request_st(self, _, tx): self._set_response_header(b"allow", b", ".join(supported_methods)) self.on_method_not_allowed(tx) return - if self.method == self.GET: + if self.method in (self.GET, self.HEAD): resource = b"index.html" if not self.url else self.url self.state = lambda _rx, _tx: self._send_file_st(_rx, _tx, resource) return From 6df6967d34fa057dcb31482e9de86b91308e33f4 Mon Sep 17 00:00:00 2001 From: szeka9 Date: Fri, 27 Mar 2026 18:58:32 +0100 Subject: [PATCH 5/8] Query parameter support with percent encoding Parse query part in HTTP request URL. Add public methods for parsing percent-encoded format and key-value extraction. Create common base class for HTTP unit tests for cleaner separation of config-based setup. --- README.md | 4 +- src/pyrobusta/protocol/http.py | 46 ++++++++++++++++++- tests/unit/test_http.py | 81 ++++++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 19338f9..1156afc 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,9 @@ A lightweight HTTP server library for MicroPython designed for constrained embed - Bounded-copy memory footprint - Finite-state-machine parser with linear sliding buffer - Robust byte-stream handling +- Query parameter parsing with percent encoding support - TLS support -## Current limitation -- Query parameter parsing is not yet implemented - # Prerequisites ## Setup virtual environment diff --git a/src/pyrobusta/protocol/http.py b/src/pyrobusta/protocol/http.py index 4abcd76..cc6fca9 100644 --- a/src/pyrobusta/protocol/http.py +++ b/src/pyrobusta/protocol/http.py @@ -38,6 +38,7 @@ class HttpEngine: "headers", "method", "url", + "query", "content_length_cnt", "mp_boundary", "mp_first_part", @@ -124,6 +125,7 @@ def __init__(self): self.headers = {} self.method = None self.url = None + self.query = None self.content_length_cnt = 0 # [Multipart state] @@ -154,7 +156,7 @@ def register( cls.ENDPOINTS.append((endpoint, callback, method)) @staticmethod - def route(endpoint, method): + def route(endpoint: str, method: str): """ Decorator for registering endpoint callback functions. """ @@ -169,6 +171,40 @@ def decorator(func): # Static helpers for parsing # ========================================= + @staticmethod + def percent_decode(s: str): + """Decode percent-encoded input""" + out = [] + i = 0 + while i < len(s): + if s[i] == "%" and i + 2 < len(s): + out.append(chr(int(s[i + 1 : i + 3], 16))) + i += 3 + else: + out.append(s[i]) + i += 1 + return "".join(out) + + @staticmethod + def get_url_encoded_query_param(query: str, key: str, default: str = None): + """ + Parse query and return the value belonging to a key + according to x-www-form-urlencoded + :param query: query part + :param key: key to parse from the query + :param default: default value to return when key is not present + """ + idx_start = query.index(key + "=") + idx_end = -1 + idx_end = query.find("&", idx_start) + if idx_start > -1: + if idx_end > -1: + return query[idx_start + len(key) + 1 : idx_end] + return query[idx_start + len(key) + 1 :] + if not default: + raise KeyError() + return default + @staticmethod def _lookup(tuple_, key): idx = tuple_.index(key) @@ -383,7 +419,13 @@ def _parse_request_line_st(self, rx, tx): self.on_client_error(tx, self.BAD_REQUEST_ERROR) return self.method = status_parts[0] - self.url = status_parts[1] + url_parts = status_parts[1].split(b"?", 1) + self.url = url_parts[0] + self.query = ( + "" + if len(url_parts) == 1 + else self.percent_decode(url_parts[1].decode(self.ASCII)) + ) self.version = status_parts[2] if self.method not in self.METHODS: self.on_method_not_allowed(tx) diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 12ed889..560e577 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -6,14 +6,14 @@ from .utils import load_module -class TestWebStateMachine(unittest.TestCase): +class TestWebStateMachineBase(unittest.TestCase): """ - Tests for the core functionality of the state machine. + Base class for stat machine tests. """ @classmethod def setUpClass(cls): - cls.config = {"http_multipart": "False"} + cls.config = {} def setUp(self): # Create mock modules @@ -54,6 +54,16 @@ def side_effect(input_arg, *_, **__): self.mock_utils_config.get_config.side_effect = side_effect + +class TestWebStateMachine(TestWebStateMachineBase): + """ + Tests for the core functionality of the state machine. + """ + + @classmethod + def setUpClass(cls): + cls.config = {"http_multipart": "False"} + def test_status_parsing_valid(self): request = b"GET /index.html HTTP/1.1\r\nContent-Length:10" @@ -216,8 +226,71 @@ def test_routing_head_method(self): ) self.assertEqual(self.tx.find(test_response), -1) + def test_simple_query_parameter(self): + request = b"GET /api/test?param HTTP/1.1\r\n" + + for i in range(len(request)): + self.rx.write(request[i : i + 1]) + self.engine.state(self.rx, self.tx) + + self.assertEqual(self.engine.query, "param") + + def test_pct_encoded_query_parameter(self): + def pct_encode(b): + out = [] + for c in b: + out.append(f"%{ord(c):02X}") + return "".join(out) + + unsafe_chars = ":/?#[]@!$&'()*+,;=% " + request = b"GET /api/test?safe_chars.%s HTTP/1.1\r\n" % pct_encode( + unsafe_chars + ).encode("ascii") + + for i in range(len(request)): + self.rx.write(request[i : i + 1]) + self.engine.state(self.rx, self.tx) -class TestMultipartStateMachine(TestWebStateMachine): + self.assertEqual(self.engine.query, f"safe_chars.{unsafe_chars}") + + def test_single_url_encoded_query_parameter(self): + request = b"GET /api/test?param=value HTTP/1.1\r\n" + + for i in range(len(request)): + self.rx.write(request[i : i + 1]) + self.engine.state(self.rx, self.tx) + + self.assertEqual( + self.engine.get_url_encoded_query_param(self.engine.query, "param"), "value" + ) + + def test_multiple_url_encoded_query_parameter(self): + request = ( + b"GET /api/test?param1=value1¶m2=value2¶m3=value3 HTTP/1.1\r\n" + ) + + for i in range(len(request)): + self.rx.write(request[i : i + 1]) + self.engine.state(self.rx, self.tx) + + self.assertEqual( + self.engine.get_url_encoded_query_param(self.engine.query, "param1"), + "value1", + ) + self.assertEqual( + self.engine.get_url_encoded_query_param(self.engine.query, "param2"), + "value2", + ) + self.assertEqual( + self.engine.get_url_encoded_query_param(self.engine.query, "param3"), + "value3", + ) + + +class TestMultipartStateMachine(TestWebStateMachineBase): + """ + Tests for multipart handling + """ @classmethod def setUpClass(cls): From 13f836eb63363d4efb7da2787ae169620e757c96 Mon Sep 17 00:00:00 2001 From: szeka9 Date: Fri, 27 Mar 2026 19:56:17 +0100 Subject: [PATCH 6/8] HTTP query example and fixes in handling corner cases - extend mem_usage example with query parameter option - fix query-parameter parsing to respect default values when keys are missing - add new unit tests for testing missing/empty url-encoded query parameters --- example/mem_usage/app.py | 25 ++++++++++++++++++++++++- src/pyrobusta/protocol/http.py | 17 +++++++++-------- tests/unit/test_http.py | 25 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/example/mem_usage/app.py b/example/mem_usage/app.py index 4b1b6d6..bccf0ae 100644 --- a/example/mem_usage/app.py +++ b/example/mem_usage/app.py @@ -7,11 +7,34 @@ @HttpEngine.route("/mem-usage", "GET") -def mem_usage(*_): +def mem_usage(http_ctx, _): collect() free = mem_free() used = mem_alloc() usage_percentage = 100 * used / (free + used) + + if http_ctx.query: + value_format = http_ctx.get_url_encoded_query_param( + http_ctx.query, "format", "bytes" + ) + if value_format not in ("%", "bytes"): + raise ValueError("invalid format") + + selector = http_ctx.get_url_encoded_query_param(http_ctx.query, "key", "") + if selector == "free": + if value_format == "%": + free = 100 * free / (used + free) + return "text/plain", f"Free [{value_format}]: {free}\n" + if selector == "used": + if value_format == "%": + used = 100 * used / (used + free) + return "text/plain", f"Used [{value_format}]: {used}\n" + if selector == "total": + return "text/plain", f"Total [bytes]: {used + free}\n" + + if selector: + raise ValueError("invalid key") + return "text/plain", ( f"Currently used: {usage_percentage:.2f}%\n" f"Free [bytes]: {free}\n" diff --git a/src/pyrobusta/protocol/http.py b/src/pyrobusta/protocol/http.py index cc6fca9..f72cb1a 100644 --- a/src/pyrobusta/protocol/http.py +++ b/src/pyrobusta/protocol/http.py @@ -194,14 +194,15 @@ def get_url_encoded_query_param(query: str, key: str, default: str = None): :param key: key to parse from the query :param default: default value to return when key is not present """ - idx_start = query.index(key + "=") - idx_end = -1 - idx_end = query.find("&", idx_start) - if idx_start > -1: - if idx_end > -1: - return query[idx_start + len(key) + 1 : idx_end] - return query[idx_start + len(key) + 1 :] - if not default: + idx_start = query.find(key + "=") + if idx_start != -1: + idx_end = -1 + idx_end = query.find("&", idx_start) + if idx_start > -1: + if idx_end > -1: + return query[idx_start + len(key) + 1 : idx_end] + return query[idx_start + len(key) + 1 :] + if default is None: raise KeyError() return default diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 560e577..8f42f07 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -286,6 +286,31 @@ def test_multiple_url_encoded_query_parameter(self): "value3", ) + def test_empty_or_missing_url_encoded_query_parameter(self): + request = ( + b"GET /api/test?param1=¶m2= HTTP/1.1\r\n" + ) + + for i in range(len(request)): + self.rx.write(request[i : i + 1]) + self.engine.state(self.rx, self.tx) + + self.assertEqual( + self.engine.get_url_encoded_query_param(self.engine.query, "param1"), + "", + ) + self.assertEqual( + self.engine.get_url_encoded_query_param(self.engine.query, "param2"), + "", + ) + self.assertEqual( + self.engine.get_url_encoded_query_param(self.engine.query, "param3", "default"), + "default", + ) + + with self.assertRaises(KeyError): + self.engine.get_url_encoded_query_param(self.engine.query, "param3") + class TestMultipartStateMachine(TestWebStateMachineBase): """ From c83d3e0e449510be3d9fcae3e07996846bcd5c17 Mon Sep 17 00:00:00 2001 From: szeka9 Date: Fri, 27 Mar 2026 19:59:54 +0100 Subject: [PATCH 7/8] Fixes in formatting --- tests/unit/test_http.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 8f42f07..b0c8655 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -287,9 +287,7 @@ def test_multiple_url_encoded_query_parameter(self): ) def test_empty_or_missing_url_encoded_query_parameter(self): - request = ( - b"GET /api/test?param1=¶m2= HTTP/1.1\r\n" - ) + request = b"GET /api/test?param1=¶m2= HTTP/1.1\r\n" for i in range(len(request)): self.rx.write(request[i : i + 1]) @@ -304,7 +302,9 @@ def test_empty_or_missing_url_encoded_query_parameter(self): "", ) self.assertEqual( - self.engine.get_url_encoded_query_param(self.engine.query, "param3", "default"), + self.engine.get_url_encoded_query_param( + self.engine.query, "param3", "default" + ), "default", ) From 63f8661241043d11aa49778142ce7848520ec163 Mon Sep 17 00:00:00 2001 From: szeka9 Date: Fri, 27 Mar 2026 20:09:42 +0100 Subject: [PATCH 8/8] Bump version to 0.2.0 --- Makefile | 2 +- dist/pyrobusta/bindings/socket_http.mpy | Bin 3074 -> 3138 bytes dist/pyrobusta/protocol/http.mpy | Bin 6047 -> 6685 bytes dist/pyrobusta/protocol/http_multipart.mpy | Bin 1824 -> 1827 bytes dist/pyrobusta/utils/config.mpy | Bin 726 -> 734 bytes package.json | 2 +- src/pyrobusta/utils/config.py | 2 +- 7 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index c609406..4d1b045 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PYROBUSTA_VERSION := 0.1.0 +PYROBUSTA_VERSION := 0.2.0 DEVICE ?= u0 SRC_DIR := src diff --git a/dist/pyrobusta/bindings/socket_http.mpy b/dist/pyrobusta/bindings/socket_http.mpy index 40f665956ddd73201a06d10567f24e1d6457bab0..95d8afa31be09689dde2887c4f35eae0b8555624 100644 GIT binary patch delta 1545 zcmaJ=&r=&!9N$fV(DG~Z@_1bep~)@@*$@IO>q-?BH(MYGfrJVj5jt=LfPES_e(<7AS;wj-KL)wIOg-B~;V2vbilLRFJN`l^L8RAH*$P#J>Se6K~i*(Cw5|?X8 zpIl4&G25pnJ-PnRY9`|`-t^aVfV_Vzd$|(n?Hb0+rh*8 zTY3^9d|Gs=Y-URz1#7#e+lL+;Y8{Tof4_bAFye>u&O<$m=0rF4ThQ?{I7@`Wm8Uzl zqPi7!30)}fJS!=#d<8OhiZ$3@QdvlkJliGi460YwTnO_4OvPojMXZX%Bm}aniR(3MQZU~utTuJ)F`e#8R5Fqg@|I198sBF^iBaS zSPF~qS)Zi1a}&Xe>5JkXt5kwkrGvO@4tlXoF_pPQ-_K}%JG572GSpaSe$=Az^yXrH zYrgN$O-;TlIXW(Cb0+RTG$A)+8c?T_rL~8*cXKqS& zBWyOsW+@cXXRFm9M-UH!=Cl*C9U-R;a0KvR9#q?dm7}7y3X{oFK$u?|SkK7{zhgp=zfl`;7cRC4qncJ%OzQ-t&uyStOr*!cK~ zHq@Wa7jDavD8P!uB|*ydO35B+BCbl&KGWCbjQuDv1|k%lGO_A(xoagfYK)K4inih$ z0r23DWoX8p)CF9G@yjZc(6pS1yZ;BzJd^r-9-RO{ZH(~{1d1NG3D2XbF^`%b>+_EL ztexFP>nNq|1TwDgCHovQ>aoAu9aK&no8{p6Fa@JJjNMQV zw4VVmM#YoQU*tkbVU0UOzmCYm>BT}N7L~;CK&&ee=@#>XB+Z1xl>j$}-i7z%8-JvK zWI;O$>%6Hd(j|Q~152B7?o;ycRcL6Q+*izXC+i(XK hZQEPh$C<09QJlBgNEfo&dsWiJM(jVzs{SW=e*?1VmQ4Tv delta 1481 zcmaJ=-%s0C6t)u}w7}xnmx~Kw1UrE^gaEGbDC;)rcnJxE^|laj*EC38wMqM-?WJN5 zQ@kjvSlel;_y_hd?b%MC^l1{IKc2Qd?4jzzsQNN#ZxgG!*BzUbfi~?**FNXo`<-*X z@7&wqD}3vG8#S=4*&ID|vSqD(Pds6+8%j+pdUoqU>GXI58*e9RqJMbQ+~>q-53pc<&2s&-U+ ztG!exgXb6yzZIGf1w~4s6&iO7RSNeB-%_~8wR&9(3+s6b^TJI9$18Zeg0na9If4fW zj)WFuz+oiP_-#8v(|Az!;UU?NN97irl3VeZ+=eIR0G_tZ_OBzKo<-+3h66&Gd|@lT z277_!xNhf}LhsgVe}Ro*_Uyxl-+y#(Q_ry>bV~H8w6UpYV6|7%-9z^eygtwCd9Zco z0P(QC{YWpe7ezk`JHk5k9L{zN*1ONPT}SmR^i_7TzWuzW_{t54+%LAEa80ElJR8`m zF)y?2O2x-=`~VxcbHt4hv$A%#1rIJ2mL+Q?IUJ3$D>2IH9<0?>pTTx1l*0?e;YFBH zm@#5j*PhNWLj|i8mrsw{Tnr7X)DCM{>?KE`S?&xMSNx^9!G_IADB;vn@B#Aso|YVR(m4z`KSi@D6Ks4dxO>|lxYL!@zqcF{H` za1&(jm-Jvhvdq5oL^pPFK~K?E5Xj?A$WELt=IJ7VMEc^3Y!D-YWbnLlqV7i2D+4wL zkX#1W-9e|DNm~`lC=&C_wc9f|Rj|tASs^wO8*#&sz4WY$B71t4y)3pt6SNNjpPut# zDJo_2QOM-^5#;%?<1C_j(5>zyF~9lbsV0vV%GM285(OCXlq5){QE4uvN{KO>6Ftt4 z(^)Vfz>#zj46Gi*k=?l8ZH8ANlz+Yk z-P7|nnuc5Ic9fX6R({u#<;jBeT}m901mS~)_=q$YTa*$~3b=5u!pG(DConF!3D3Ip z++H;~S>`Nnqt}?%9p(+f%s>;)sn^}h*2pg|vp!CHqAz>I(*FW_v_9U=MWO#q;wO%9 z**{9gGXEhHMG2_i05Np&(~CUnmDoJSN(G*tL_3A&8pPB oI?8RT^!+Vp^Qya+-#V(zu`r$Fa#4HlkZC6hH-oA)`mg5w4V)l>R{#J2 diff --git a/dist/pyrobusta/protocol/http.mpy b/dist/pyrobusta/protocol/http.mpy index e7f78f5f3b4c87f1ff636804c2e6f99e18752c47..49e817647714b7440458306a0c46f08ffa629170 100644 GIT binary patch delta 4081 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 QWWB9{{s3e$;E0I)U#i%YoB#j- delta 3480 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 b!oY4`ZZ$?5NT0Hqp`y+3n$3_-QJV1=tj4-f delta 634 zcmYk1Ur*Cu6vp4T+sc@RG<_Q>#6WGktz+v}XW5t=hU`z&=wKb7sm3re=fprHFmJq} z61m`tm?kFNnfL|Xc5MEO2`0`cUqHFi8@_;k0b3!#i<9#_zw_it&g+3!1HLi|Mz2`D zk1#`*ri+!C>(g_yfPAvH49>1JS`Q!J2Z0AmYwImAyt2CRPc5u2uC;)FdC`IX^`)l` zo9KryZU#J3GosbWt*H<2yf2|#Gy*j)6bmV#icNVBi#O>cwh(El9zDNqQ+#I%Ol^#dB5yII`xuRd37Z4(lZDwavqvVw%7L*zMa6Sz<3&^ygTS(9+neK?gkH5+Ub9%*8Kl+! E0u6?-fB*mh diff --git a/dist/pyrobusta/utils/config.mpy b/dist/pyrobusta/utils/config.mpy index 3b743669710cb93e5e3fe1ad50caceab464e56d9..9f08f564bd2591a93e5eb48accf8a98d7e7af330 100644 GIT binary patch delta 94 zcmV-k0HOcZ1>OaaDFHIEDxU#2NcjUO2poVOU?3wdFD@`5Iwl|{Pe~|ONRt5)9k37y z5DGMc0Y`!X!LbwzHUJb15Dhi~UrTb6{{a>YLr1a{5Hb%7 delta 85 zcmcb|dX060CZpj-t@(^*96y;gIAj{+5)_nm_4PEBtke|L{5>_oJ-Qi%WH$(K3UHY; pF}hbD+$h3r#vsBYz-z`B@0~q~NsPtC$w_6hB9lMk