From 1c38bc8ca659ea9f8d2e5d46d104a7afc70a5ca6 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 18 Feb 2024 13:48:08 +0000 Subject: [PATCH 1/6] Added explicit typing for socketpool and socket with shared interface --- adafruit_httpserver/interfaces.py | 61 ++++++++++++++++++++++++++++++- adafruit_httpserver/request.py | 8 ++-- adafruit_httpserver/response.py | 7 ++-- adafruit_httpserver/server.py | 15 +++----- 4 files changed, 72 insertions(+), 19 deletions(-) diff --git a/adafruit_httpserver/interfaces.py b/adafruit_httpserver/interfaces.py index 48b4e46..0a17281 100644 --- a/adafruit_httpserver/interfaces.py +++ b/adafruit_httpserver/interfaces.py @@ -8,11 +8,70 @@ """ try: - from typing import List, Dict, Union, Any + from typing import List, Tuple, Dict, Union, Any except ImportError: pass +class _ISocket: # pylint: disable=missing-function-docstring,no-self-use,unused-argument + """A class for typing necessary methods for a socket object.""" + + def accept(self) -> Tuple["_ISocket", Tuple[str, int]]: + ... + + def bind(self, address: Tuple[str, int]) -> None: + ... + + def setblocking(self, flag: bool) -> None: + ... + + def settimeout(self, value: "Union[float, None]") -> None: + ... + + def setsockopt(self, level: int, optname: int, value: int) -> None: + ... + + def listen(self, backlog: int) -> None: + ... + + def send(self, data: bytes) -> int: + ... + + def recv_into(self, buffer: memoryview, nbytes: int) -> int: + ... + + def close(self) -> None: + ... + + +class _ISocketPool: # pylint: disable=missing-function-docstring,no-self-use,unused-argument + """A class to typing necessary methods and properties for a socket pool object.""" + + AF_INET: int + SO_REUSEADDR: int + SOCK_STREAM: int + SOL_SOCKET: int + + def socket( # pylint: disable=redefined-builtin + self, + family: int = ..., + type: int = ..., + proto: int = ..., + ) -> _ISocket: + ... + + def getaddrinfo( # pylint: disable=redefined-builtin,too-many-arguments + self, + host: str, + port: int, + family: int = ..., + type: int = ..., + proto: int = ..., + flags: int = ..., + ) -> Tuple[int, int, int, str, Tuple[str, int]]: + ... + + class _IFieldStorage: """Interface with shared methods for QueryParams, FormData and Headers.""" diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index d8b0c26..63249df 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -9,8 +9,6 @@ try: from typing import List, Dict, Tuple, Union, Any, TYPE_CHECKING - from socket import socket - from socketpool import SocketPool if TYPE_CHECKING: from .server import Server @@ -20,7 +18,7 @@ import json from .headers import Headers -from .interfaces import _IFieldStorage, _IXSSSafeFieldStorage +from .interfaces import _ISocket, _IFieldStorage, _IXSSSafeFieldStorage from .methods import POST, PUT, PATCH, DELETE @@ -274,7 +272,7 @@ class Request: # pylint: disable=too-many-instance-attributes Server object that received the request. """ - connection: Union["SocketPool.Socket", "socket.socket"] + connection: _ISocket """ Socket object used to send and receive data on the connection. """ @@ -325,7 +323,7 @@ class Request: # pylint: disable=too-many-instance-attributes def __init__( self, server: "Server", - connection: Union["SocketPool.Socket", "socket.socket"], + connection: _ISocket, client_address: Tuple[str, int], raw_request: bytes = None, ) -> None: diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 8c6d240..82f9546 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -9,8 +9,6 @@ try: from typing import Optional, Dict, Union, Tuple, Generator, Any - from socket import socket - from socketpool import SocketPool except ImportError: pass @@ -47,6 +45,7 @@ PERMANENT_REDIRECT_308, ) from .headers import Headers +from .interfaces import _ISocket class Response: # pylint: disable=too-few-public-methods @@ -132,7 +131,7 @@ def _send(self) -> None: def _send_bytes( self, - conn: Union["SocketPool.Socket", "socket.socket"], + conn: _ISocket, buffer: Union[bytes, bytearray, memoryview], ): bytes_sent: int = 0 @@ -708,7 +707,7 @@ def _read_frame(self): length -= min(payload_length, length) if has_mask: - payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) + payload = bytes(byte ^ mask[idx % 4] for idx, byte in enumerate(payload)) return opcode, payload diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 2801a31..943480d 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -8,9 +8,7 @@ """ try: - from typing import Callable, Protocol, Union, List, Tuple, Dict, Iterable - from socket import socket - from socketpool import SocketPool + from typing import Callable, Union, List, Tuple, Dict, Iterable except ImportError: pass @@ -28,6 +26,7 @@ ServingFilesDisabledError, ) from .headers import Headers +from .interfaces import _ISocketPool, _ISocket from .methods import GET, HEAD from .request import Request from .response import Response, FileResponse @@ -54,7 +53,7 @@ class Server: # pylint: disable=too-many-instance-attributes """Root directory to serve files from. ``None`` if serving files is disabled.""" def __init__( - self, socket_source: Protocol, root_path: str = None, *, debug: bool = False + self, socket_source: _ISocketPool, root_path: str = None, *, debug: bool = False ) -> None: """Create a server, and get it ready to run. @@ -244,9 +243,7 @@ def stop(self) -> None: if self.debug: _debug_stopped_server(self) - def _receive_header_bytes( - self, sock: Union["SocketPool.Socket", "socket.socket"] - ) -> bytes: + def _receive_header_bytes(self, sock: _ISocket) -> bytes: """Receive bytes until a empty line is received.""" received_bytes = bytes() while b"\r\n\r\n" not in received_bytes: @@ -263,7 +260,7 @@ def _receive_header_bytes( def _receive_body_bytes( self, - sock: Union["SocketPool.Socket", "socket.socket"], + sock: _ISocket, received_body_bytes: bytes, content_length: int, ) -> bytes: @@ -282,7 +279,7 @@ def _receive_body_bytes( def _receive_request( self, - sock: Union["SocketPool.Socket", "socket.socket"], + sock: _ISocket, client_address: Tuple[str, int], ) -> Request: """Receive bytes from socket until the whole request is received.""" From 2cc63fe366781ef880a4dce72b27afa9fc7212e1 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Mon, 19 Feb 2024 23:50:08 +0000 Subject: [PATCH 2/6] Extracted creating server socket to separate method --- adafruit_httpserver/server.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 943480d..592971e 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -194,13 +194,23 @@ def serve_forever( except Exception: # pylint: disable=broad-except pass # Ignore exceptions in handler function - def _set_socket_level_to_reuse_address(self) -> None: - """ - Only for CPython, prevents "Address already in use" error when restarting the server. - """ - self._sock.setsockopt( - self._socket_source.SOL_SOCKET, self._socket_source.SO_REUSEADDR, 1 - ) + @staticmethod + def _create_server_socket( + socket_source: _ISocketPool, + host: str, + port: int, + ) -> _ISocket: + sock = socket_source.socket(socket_source.AF_INET, socket_source.SOCK_STREAM) + + # TODO: Temporary backwards compatibility, remove after CircuitPython 9.0.0 release + if implementation.version >= (9,) or implementation.name != "circuitpython": + sock.setsockopt(socket_source.SOL_SOCKET, socket_source.SO_REUSEADDR, 1) + + sock.bind((host, port)) + sock.listen(10) + sock.setblocking(False) # Non-blocking socket + + return sock def start(self, host: str, port: int = 80) -> None: """ @@ -215,16 +225,7 @@ def start(self, host: str, port: int = 80) -> None: self.host, self.port = host, port self.stopped = False - self._sock = self._socket_source.socket( - self._socket_source.AF_INET, self._socket_source.SOCK_STREAM - ) - - if implementation.name != "circuitpython": - self._set_socket_level_to_reuse_address() - - self._sock.bind((host, port)) - self._sock.listen(10) - self._sock.setblocking(False) # Non-blocking socket + self._sock = self._create_server_socket(self._socket_source, host, port) if self.debug: _debug_started_server(self) From e49a5ed0b340572764097d45772cd0065caaf7bc Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 20 Feb 2024 00:31:34 +0000 Subject: [PATCH 3/6] Changed default host to 0.0.0.0 and port to 5000 --- adafruit_httpserver/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 592971e..34a7745 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -171,7 +171,7 @@ def _verify_can_start(self, host: str, port: int) -> None: raise RuntimeError(f"Cannot start server on {host}:{port}") from error def serve_forever( - self, host: str, port: int = 80, *, poll_interval: float = 0.1 + self, host: str = "0.0.0.0", port: int = 5000, *, poll_interval: float = 0.1 ) -> None: """ Wait for HTTP requests at the given host and port. Does not return. @@ -212,7 +212,7 @@ def _create_server_socket( return sock - def start(self, host: str, port: int = 80) -> None: + def start(self, host: str = "0.0.0.0", port: int = 5000) -> None: """ Start the HTTP server at the given host and port. Requires calling ``.poll()`` in a while loop to handle incoming requests. From f7d20b5fe68ed483e2b790ccbda7b32ac2adac69 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 20 Feb 2024 05:38:18 +0000 Subject: [PATCH 4/6] Updated docs to use port 5000 --- docs/examples.rst | 9 ++++++--- examples/httpserver_mdns.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index e4cfcdb..93eb107 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -27,6 +27,9 @@ Although there is nothing wrong with this approach, from the version 8.0.0 of Ci `it is possible to use the environment variables `_ defined in ``settings.toml`` file to store secrets and configure the WiFi network. +By default the library uses ``0.0.0.0`` and port ``5000`` for the server, as port ``80`` is reserved for the CircuitPython Web Workflow. +If you want to use port ``80`` , you need to set ``CIRCUITPY_WEB_API_PORT`` to any other port, and then set ``port`` parameter in ``Server`` constructor to ``80`` . + This is the same example as above, but it uses the ``settings.toml`` file to configure the WiFi network. **From now on, all the examples will use the** ``settings.toml`` **file to configure the WiFi network.** @@ -122,8 +125,8 @@ It is possible to use the MDNS protocol to make the server accessible via a host to an IP address. It is worth noting that it takes a bit longer to get the response from the server when accessing it via the hostname. -In this example, the server is accessible via the IP and ``http://custom-mdns-hostname.local/``. -On some routers it is also possible to use ``http://custom-mdns-hostname/``, but **this is not guaranteed to work**. +In this example, the server is accessible via the IP and ``http://custom-mdns-hostname.local:5000/``. +On some routers it is also possible to use ``http://custom-mdns-hostname:5000/``, but **this is not guaranteed to work**. .. literalinclude:: ../examples/httpserver_mdns.py :caption: examples/httpserver_mdns.py @@ -412,7 +415,7 @@ occurs during handling of the request in ``.serve_forever()``. This is how the logs might look like when debug mode is enabled:: - Started development server on http://192.168.0.100:80 + Started development server on http://192.168.0.100:5000 192.168.0.101 -- "GET /" 194 -- "200 OK" 154 -- 96ms 192.168.0.101 -- "GET /example" 134 -- "404 Not Found" 172 -- 123ms 192.168.0.102 -- "POST /api" 1241 -- "401 Unauthorized" 95 -- 64ms diff --git a/examples/httpserver_mdns.py b/examples/httpserver_mdns.py index 27f32bc..5ad25cb 100644 --- a/examples/httpserver_mdns.py +++ b/examples/httpserver_mdns.py @@ -11,7 +11,7 @@ mdns_server = mdns.Server(wifi.radio) mdns_server.hostname = "custom-mdns-hostname" -mdns_server.advertise_service(service_type="_http", protocol="_tcp", port=80) +mdns_server.advertise_service(service_type="_http", protocol="_tcp", port=5000) pool = socketpool.SocketPool(wifi.radio) server = Server(pool, "/static", debug=True) From b88bfa53eff8c783dfe85749c77ab6c202d5e1da Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 21 Feb 2024 04:43:57 +0000 Subject: [PATCH 5/6] Changes in repr of multiple classes --- adafruit_httpserver/interfaces.py | 2 +- adafruit_httpserver/request.py | 16 +++++++++++----- adafruit_httpserver/route.py | 8 ++++---- adafruit_httpserver/server.py | 7 +++++++ adafruit_httpserver/status.py | 11 +++++++---- 5 files changed, 30 insertions(+), 14 deletions(-) diff --git a/adafruit_httpserver/interfaces.py b/adafruit_httpserver/interfaces.py index 0a17281..37b7b70 100644 --- a/adafruit_httpserver/interfaces.py +++ b/adafruit_httpserver/interfaces.py @@ -121,7 +121,7 @@ def __contains__(self, key: str) -> bool: return key in self._storage def __repr__(self) -> str: - return f"{self.__class__.__name__}({repr(self._storage)})" + return f"<{self.__class__.__name__} {repr(self._storage)}>" def _encode_html_entities(value: Union[str, None]) -> Union[str, None]: diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 63249df..69e7fc2 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -125,11 +125,11 @@ def size(self) -> int: def __repr__(self) -> str: filename, content_type, size = ( - repr(self.filename), - repr(self.content_type), - repr(self.size), + self.filename, + self.content_type, + self.size, ) - return f"{self.__class__.__name__}({filename=}, {content_type=}, {size=})" + return f"<{self.__class__.__name__} {filename=}, {content_type=}, {size=}>" class Files(_IFieldStorage): @@ -258,7 +258,9 @@ def get_list(self, field_name: str, *, safe=True) -> List[Union[str, bytes]]: def __repr__(self) -> str: class_name = self.__class__.__name__ - return f"{class_name}({repr(self._storage)}, files={repr(self.files._storage)})" + return ( + f"<{class_name} {repr(self._storage)}, files={repr(self.files._storage)}>" + ) class Request: # pylint: disable=too-many-instance-attributes @@ -479,6 +481,10 @@ def _parse_request_header( return method, path, query_params, http_version, headers + def __repr__(self) -> str: + path = self.path + (f"?{self.query_params}" if self.query_params else "") + return f'<{self.__class__.__name__} "{self.method} {path}">' + def _debug_unsupported_form_content_type(content_type: str) -> None: """Warns when an unsupported form content type is used.""" diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index fe000bd..0659555 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -136,11 +136,11 @@ def matches( return True, dict(zip(self.parameters_names, url_parameters_values)) def __repr__(self) -> str: - path = repr(self.path) - methods = repr(self.methods) - handler = repr(self.handler) + path = self.path + methods = self.methods + handler = self.handler - return f"Route({path=}, {methods=}, {handler=})" + return f"" def as_route( diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 34a7745..ea9ba46 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -528,6 +528,13 @@ def socket_timeout(self, value: int) -> None: else: raise ValueError("Server.socket_timeout must be a positive numeric value.") + def __repr__(self) -> str: + host = self.host + port = self.port + root_path = self.root_path + + return f"" + def _debug_warning_exposed_files(root_path: str): """Warns about exposing all files on the device.""" diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index ea72284..a27f15d 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -21,14 +21,17 @@ def __init__(self, code: int, text: str): self.code = code self.text = text - def __repr__(self): - return f'Status({self.code}, "{self.text}")' + def __eq__(self, other: "Status"): + return self.code == other.code and self.text == other.text def __str__(self): return f"{self.code} {self.text}" - def __eq__(self, other: "Status"): - return self.code == other.code and self.text == other.text + def __repr__(self): + code = self.code + text = self.text + + return f'' SWITCHING_PROTOCOLS_101 = Status(101, "Switching Protocols") From b00f70f1c4eaccc29ab5bbd7a4bc3522ab38982c Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 21 Feb 2024 04:44:46 +0000 Subject: [PATCH 6/6] Added validation for root_path in FileResponse --- adafruit_httpserver/response.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index 82f9546..9f19294 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -216,6 +216,10 @@ def __init__( # pylint: disable=too-many-arguments ) self._filename = filename + "index.html" if filename.endswith("/") else filename self._root_path = root_path or self._request.server.root_path + + if self._root_path is None: + raise ValueError("root_path must be provided in Server or in FileResponse") + self._full_file_path = self._combine_path(self._root_path, self._filename) self._content_type = content_type or MIMETypes.get_for_filename(self._filename) self._file_length = self._get_file_length(self._full_file_path)