From cd719ea813c7abefcd41645496b91b097d13a049 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Fri, 22 Dec 2023 09:26:55 -0800 Subject: [PATCH 01/24] Create ConnectionManager --- .gitignore | 7 + .pre-commit-config.yaml | 2 +- README.rst | 12 +- adafruit_connectionmanager.py | 293 ++++++++++++++++++++++++++++++++-- docs/index.rst | 6 - tests/mocket.py | 73 +++++++++ tests/protocol_test.py | 52 ++++++ tox.ini | 38 +++++ 8 files changed, 457 insertions(+), 26 deletions(-) create mode 100644 tests/mocket.py create mode 100644 tests/protocol_test.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index db3d538..a06dc67 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,10 @@ _build .idea .vscode *~ + +# tox-specific files +.tox +build + +# coverage-specific files +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70ade69..e2c8831 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,4 +39,4 @@ repos: types: [python] files: "^tests/" args: - - --disable=missing-docstring,consider-using-f-string,duplicate-code + - --disable=missing-docstring,invalid-name,consider-using-f-string,duplicate-code diff --git a/README.rst b/README.rst index 6d81dd9..0c93a61 100644 --- a/README.rst +++ b/README.rst @@ -36,11 +36,6 @@ This is easily achieved by downloading or individual libraries can be installed using `circup `_. - - -.. todo:: Describe the Adafruit product this library works with. For PCBs, you can also add the -image from the assets folder in the PCB's GitHub repo. - `Purchase one from the Adafruit shop `_ Installing from PyPI @@ -48,8 +43,6 @@ Installing from PyPI .. note:: This library is not available on PyPI yet. Install documentation is included as a standard element. Stay tuned for PyPI availability! -.. todo:: Remove the above note if PyPI version is/will be available at time of release. - On supported GNU/Linux systems like the Raspberry Pi, you can install the driver locally `from PyPI `_. To install for current user: @@ -99,8 +92,9 @@ Or the following command to update an existing version: Usage Example ============= -.. todo:: Add a quick, simple example. It and other examples should live in the -examples folder and be included in docs/examples.rst. +This library is used internally by libraries like `Adafruit_CircuitPython_Requests +`_ and `Adafruit_CircuitPython_MiniMQTT +`_ Documentation ============= diff --git a/adafruit_connectionmanager.py b/adafruit_connectionmanager.py index b7fd70e..2e34991 100644 --- a/adafruit_connectionmanager.py +++ b/adafruit_connectionmanager.py @@ -14,24 +14,297 @@ Implementation Notes -------------------- -**Hardware:** - -.. todo:: Add links to any specific hardware product page(s), or category page(s). - Use unordered list & hyperlink rST inline format: "* `Link Text `_" - **Software and Dependencies:** * Adafruit CircuitPython firmware for the supported boards: https://circuitpython.org/downloads -.. todo:: Uncomment or remove the Bus Device and/or the Register library dependencies - based on the library's use of either. - -# * Adafruit's Bus Device library: https://github.com/adafruit/Adafruit_CircuitPython_BusDevice -# * Adafruit's Register library: https://github.com/adafruit/Adafruit_CircuitPython_Register """ # imports __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_ConnectionManager.git" + +import errno +import sys + +if not sys.implementation.name == "circuitpython": + from ssl import SSLContext + from types import ModuleType + from typing import Any, Optional, Tuple, Union + + try: + from typing import Protocol + except ImportError: + from typing_extensions import Protocol + + # Based on https://github.com/python/typeshed/blob/master/stdlib/_socket.pyi + class CommonSocketType(Protocol): + """Describes the common structure every socket type must have.""" + + def send(self, data: bytes, flags: int = ...) -> None: + """Send data to the socket. The meaning of the optional flags kwarg is + implementation-specific.""" + + def settimeout(self, value: Optional[float]) -> None: + """Set a timeout on blocking socket operations.""" + + def close(self) -> None: + """Close the socket.""" + + class CommonCircuitPythonSocketType(CommonSocketType, Protocol): + """Describes the common structure every CircuitPython socket type must have.""" + + def connect( + self, + address: Tuple[str, int], + conntype: Optional[int] = ..., + ) -> None: + """Connect to a remote socket at the provided (host, port) address. The conntype + kwarg optionally may indicate SSL or not, depending on the underlying interface. + """ + + class SupportsRecvWithFlags(Protocol): + """Describes a type that posseses a socket recv() method supporting the flags kwarg.""" + + def recv(self, bufsize: int = ..., flags: int = ...) -> bytes: + """Receive data from the socket. The return value is a bytes object representing + the data received. The maximum amount of data to be received at once is specified + by bufsize. The meaning of the optional flags kwarg is implementation-specific. + """ + + class SupportsRecvInto(Protocol): + """Describes a type that possesses a socket recv_into() method.""" + + def recv_into( + self, buffer: bytearray, nbytes: int = ..., flags: int = ... + ) -> int: + """Receive up to nbytes bytes from the socket, storing the data into the provided + buffer. If nbytes is not specified (or 0), receive up to the size available in the + given buffer. The meaning of the optional flags kwarg is implementation-specific. + Returns the number of bytes received.""" + + class CircuitPythonSocketType( + CommonCircuitPythonSocketType, + SupportsRecvInto, + SupportsRecvWithFlags, + Protocol, + ): + """Describes the structure every modern CircuitPython socket type must have.""" + + class StandardPythonSocketType( + CommonSocketType, SupportsRecvInto, SupportsRecvWithFlags, Protocol + ): + """Describes the structure every standard Python socket type must have.""" + + def connect(self, address: Union[Tuple[Any, ...], str, bytes]) -> None: + """Connect to a remote socket at the provided address.""" + + SocketType = Union[ + CircuitPythonSocketType, + StandardPythonSocketType, + ] + + SocketpoolModuleType = ModuleType + + class InterfaceType(Protocol): + """Describes the structure every interface type must have.""" + + @property + def TLS_MODE(self) -> int: # pylint: disable=invalid-name + """Constant representing that a socket's connection mode is TLS.""" + + SSLContextType = Union[SSLContext, "_FakeSSLContext"] + + +class SocketGetOSError(OSError): + """ConnectionManager Exception class.""" + + +class SocketGetRuntimeError(RuntimeError): + """ConnectionManager Exception class.""" + + +class SocketConnectMemoryError(OSError): + """ConnectionManager Exception class.""" + + +class SocketConnectOSError(OSError): + """ConnectionManager Exception class.""" + + +class _FakeSSLSocket: + def __init__(self, socket: CircuitPythonSocketType, tls_mode: int) -> None: + self._socket = socket + self._mode = tls_mode + self.settimeout = socket.settimeout + self.send = socket.send + self.recv = socket.recv + self.close = socket.close + self.recv_into = socket.recv_into + + def connect(self, address: Tuple[str, int]) -> None: + """connect wrapper to add non-standard mode parameter""" + try: + return self._socket.connect(address, self._mode) + except RuntimeError as error: + raise OSError(errno.ENOMEM) from error + + +class _FakeSSLContext: + def __init__(self, iface: InterfaceType) -> None: + self._iface = iface + + def wrap_socket( + self, socket: CircuitPythonSocketType, server_hostname: Optional[str] = None + ) -> _FakeSSLSocket: + """Return the same socket""" + # pylint: disable=unused-argument + return _FakeSSLSocket(socket, self._iface.TLS_MODE) + + +def create_fake_ssl_context( + socket_pool: SocketpoolModuleType, iface: Optional[InterfaceType] = None +) -> _FakeSSLContext: + """Legacy API for creating a fake SSL context""" + if not iface: + # pylint: disable=protected-access + iface = socket_pool._the_interface + socket_pool.set_interface(iface) + return _FakeSSLContext(iface) + + +class ConnectionManager: + """Connection manager for sharing sockets.""" + + def __init__( + self, + socket_pool: SocketpoolModuleType, + ) -> None: + self._socket_pool = socket_pool + # Hang onto open sockets so that we can reuse them. + self._open_sockets = {} + self._socket_free = {} + + def _free_sockets(self) -> None: + free_sockets = [] + for socket, val in self._socket_free.items(): + if val: + free_sockets.append(socket) + + for socket in free_sockets: + self.close_socket(socket) + + def free_socket(self, socket: SocketType) -> None: + """Mark a socket as free so it can be reused if needed""" + if socket not in self._open_sockets.values(): + raise RuntimeError("Socket not from session") + self._socket_free[socket] = True + + def close_socket(self, socket: SocketType) -> None: + """Close a socket""" + socket.close() + del self._socket_free[socket] + key = None + for k, value in self._open_sockets.items(): + if value == socket: + key = k + break + if key: + del self._open_sockets[key] + + # pylint: disable=too-many-locals,too-many-statements + def get_socket( + self, + host: str, + port: int, + proto: str, + *, + timeout: float = 1, + is_ssl: bool = False, + ssl_context: Optional[SSLContextType] = None, + max_retries: int = 5, + exception_passthrough: bool = False, + ) -> CircuitPythonSocketType: + """Get socket and connect""" + # pylint: disable=too-many-branches + key = (host, port, proto) + if key in self._open_sockets: + socket = self._open_sockets[key] + if self._socket_free[socket]: + self._socket_free[socket] = False + return socket + + if proto == "https:": + is_ssl = True + if is_ssl and not ssl_context: + raise RuntimeError( + "ssl_context must be set before using adafruit_requests for https" + ) + + addr_info = self._socket_pool.getaddrinfo( + host, port, 0, self._socket_pool.SOCK_STREAM + )[0] + + retry_count = 0 + socket = None + last_exc = None + last_exc_new_type = None + while retry_count < max_retries and socket is None: + if retry_count > 0: + if any(self._socket_free.items()): + self._free_sockets() + else: + raise RuntimeError("Sending request failed") from last_exc + retry_count += 1 + + try: + socket = self._socket_pool.socket(addr_info[0], addr_info[1]) + except OSError as exc: + last_exc_new_type = SocketGetOSError + last_exc = exc + continue + except RuntimeError as exc: + last_exc_new_type = SocketGetRuntimeError + last_exc = exc + continue + + connect_host = addr_info[-1][0] + if is_ssl: + socket = ssl_context.wrap_socket(socket, server_hostname=host) + connect_host = host + socket.settimeout(timeout) # socket read timeout + + try: + socket.connect((connect_host, port)) + except MemoryError as exc: + last_exc_new_type = SocketConnectMemoryError + last_exc = exc + socket.close() + socket = None + except OSError as exc: + last_exc_new_type = SocketConnectOSError + last_exc = exc + socket.close() + socket = None + + if socket is None: + if exception_passthrough: + raise last_exc_new_type("Repeated socket failures") from last_exc + raise RuntimeError("Repeated socket failures") from last_exc + + self._open_sockets[key] = socket + self._socket_free[socket] = False + return socket + + +_global_connection_manager = None # pylint: disable=invalid-name + + +def get_connection_manager(socket_pool: SocketpoolModuleType) -> None: + """Get the ConnectionManager singleton""" + global _global_connection_manager # pylint: disable=global-statement + if _global_connection_manager is None: + _global_connection_manager = ConnectionManager(socket_pool) + return _global_connection_manager diff --git a/docs/index.rst b/docs/index.rst index 78525b6..235fd37 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,15 +24,9 @@ Table of Contents .. toctree:: :caption: Tutorials -.. todo:: Add any Learn guide links here. If there are none, then simply delete this todo and leave - the toctree above for use later. - .. toctree:: :caption: Related Products -.. todo:: Add any product links here. If there are none, then simply delete this todo and leave - the toctree above for use later. - .. toctree:: :caption: Other Links diff --git a/tests/mocket.py b/tests/mocket.py new file mode 100644 index 0000000..3603800 --- /dev/null +++ b/tests/mocket.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +""" Mock Socket """ + +from unittest import mock + + +class MocketPool: # pylint: disable=too-few-public-methods + """Mock SocketPool""" + + SOCK_STREAM = 0 + + def __init__(self): + self.getaddrinfo = mock.Mock() + self.socket = mock.Mock() + + +class Mocket: # pylint: disable=too-few-public-methods + """Mock Socket""" + + def __init__(self, response): + self.settimeout = mock.Mock() + self.close = mock.Mock() + self.connect = mock.Mock() + self.send = mock.Mock(side_effect=self._send) + self.readline = mock.Mock(side_effect=self._readline) + self.recv = mock.Mock(side_effect=self._recv) + self.recv_into = mock.Mock(side_effect=self._recv_into) + self._response = response + self._position = 0 + self.fail_next_send = False + + def _send(self, data): + if self.fail_next_send: + self.fail_next_send = False + return 0 + return len(data) + + def _readline(self): + i = self._response.find(b"\r\n", self._position) + response = self._response[self._position : i + 2] + self._position = i + 2 + return response + + def _recv(self, count): + end = self._position + count + response = self._response[self._position : end] + self._position = end + return response + + def _recv_into(self, buf, nbytes=0): + assert isinstance(nbytes, int) and nbytes >= 0 + read = nbytes if nbytes > 0 else len(buf) + remaining = len(self._response) - self._position + read = min(read, remaining) + end = self._position + read + buf[:read] = self._response[self._position : end] + self._position = end + return read + + +class SSLContext: # pylint: disable=too-few-public-methods + """Mock SSL Context""" + + def __init__(self): + self.wrap_socket = mock.Mock(side_effect=self._wrap_socket) + + def _wrap_socket( + self, sock, server_hostname=None + ): # pylint: disable=no-self-use,unused-argument + return sock diff --git a/tests/protocol_test.py b/tests/protocol_test.py new file mode 100644 index 0000000..d4123aa --- /dev/null +++ b/tests/protocol_test.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +""" Protocol Tests """ + +import mocket +import pytest +import adafruit_connectionmanager + +IP = "1.2.3.4" +HOST = "wifitest.adafruit.com" +PATH = "/testwifi/index.html" +TEXT = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" +RESPONSE = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + TEXT + + +def test_get_https_no_ssl(): + pool = mocket.MocketPool() + pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + sock = mocket.Mocket(RESPONSE) + pool.socket.return_value = sock + + connection_manager = adafruit_connectionmanager.ConnectionManager(pool) + with pytest.raises(RuntimeError): + connection_manager.get_socket(HOST, 443, "https:") + + +def test_connect_https(): + pool = mocket.MocketPool() + pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + sock = mocket.Mocket(RESPONSE) + pool.socket.return_value = sock + + ssl = mocket.SSLContext() + + connection_manager = adafruit_connectionmanager.ConnectionManager(pool) + connection_manager.get_socket(HOST, 443, "https:", ssl_context=ssl) + + sock.connect.assert_called_once_with((HOST, 443)) + + +def test_connect_http(): + pool = mocket.MocketPool() + pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + sock = mocket.Mocket(RESPONSE) + pool.socket.return_value = sock + + connection_manager = adafruit_connectionmanager.ConnectionManager(pool) + connection_manager.get_socket(HOST, 80, "http:") + + sock.connect.assert_called_once_with((IP, 80)) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..e793d6f --- /dev/null +++ b/tox.ini @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: 2023 Justin Myers +# +# SPDX-License-Identifier: MIT + +[tox] +envlist = py311 + +[testenv] +description = run tests +deps = + pytest==7.4.3 +commands = pytest + +[testenv:coverage] +description = run coverage +deps = + pytest==7.4.3 + pytest-cov==4.1.0 +package = editable +commands = + coverage run --source=. --omit=tests/* --branch {posargs} -m pytest + coverage report + coverage html + +[testenv:lint] +description = run linters +deps = + pre-commit==3.6.0 +skip_install = true +commands = pre-commit run {posargs} + +[testenv:docs] +description = build docs +deps = + -r requirements.txt + -r docs/requirements.txt +skip_install = true +commands = sphinx-build -W -b html docs/ _build/ From 9a82153407e2450a3e9cbdd9dc2de77d23b4adfe Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Fri, 22 Dec 2023 10:36:04 -0800 Subject: [PATCH 02/24] Switch from adafruit_connectionmanager.py -> adafruit_connection_manager.py --- ...connectionmanager.py => adafruit_connection_manager.py | 2 +- docs/api.rst | 2 +- pyproject.toml | 2 +- tests/protocol_test.py | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) rename adafruit_connectionmanager.py => adafruit_connection_manager.py (99%) diff --git a/adafruit_connectionmanager.py b/adafruit_connection_manager.py similarity index 99% rename from adafruit_connectionmanager.py rename to adafruit_connection_manager.py index 2e34991..4c4c2a3 100644 --- a/adafruit_connectionmanager.py +++ b/adafruit_connection_manager.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: MIT """ -`adafruit_connectionmanager` +`adafruit_connection_manager` ================================================================================ A urllib3.poolmanager/urllib3.connectionpool-like library for managing sockets and connections diff --git a/docs/api.rst b/docs/api.rst index 9916599..1112255 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4,5 +4,5 @@ .. If your library file(s) are nested in a directory (e.g. /adafruit_foo/foo.py) .. use this format as the module name: "adafruit_foo.foo" -.. automodule:: adafruit_connectionmanager +.. automodule:: adafruit_connection_manager :members: diff --git a/pyproject.toml b/pyproject.toml index 9a52e4c..f916632 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dynamic = ["dependencies", "optional-dependencies"] [tool.setuptools] # TODO: IF LIBRARY FILES ARE A PACKAGE FOLDER, # CHANGE `py_modules = ['...']` TO `packages = ['...']` -py-modules = ["adafruit_connectionmanager"] +py-modules = ["adafruit_connection_manager"] [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} diff --git a/tests/protocol_test.py b/tests/protocol_test.py index d4123aa..929b058 100644 --- a/tests/protocol_test.py +++ b/tests/protocol_test.py @@ -6,7 +6,7 @@ import mocket import pytest -import adafruit_connectionmanager +import adafruit_connection_manager IP = "1.2.3.4" HOST = "wifitest.adafruit.com" @@ -21,7 +21,7 @@ def test_get_https_no_ssl(): sock = mocket.Mocket(RESPONSE) pool.socket.return_value = sock - connection_manager = adafruit_connectionmanager.ConnectionManager(pool) + connection_manager = adafruit_connection_manager.ConnectionManager(pool) with pytest.raises(RuntimeError): connection_manager.get_socket(HOST, 443, "https:") @@ -34,7 +34,7 @@ def test_connect_https(): ssl = mocket.SSLContext() - connection_manager = adafruit_connectionmanager.ConnectionManager(pool) + connection_manager = adafruit_connection_manager.ConnectionManager(pool) connection_manager.get_socket(HOST, 443, "https:", ssl_context=ssl) sock.connect.assert_called_once_with((HOST, 443)) @@ -46,7 +46,7 @@ def test_connect_http(): sock = mocket.Mocket(RESPONSE) pool.socket.return_value = sock - connection_manager = adafruit_connectionmanager.ConnectionManager(pool) + connection_manager = adafruit_connection_manager.ConnectionManager(pool) connection_manager.get_socket(HOST, 80, "http:") sock.connect.assert_called_once_with((IP, 80)) From da22cc2ddc8af68ae6c2017e6a009c77a445f404 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Wed, 27 Dec 2023 07:54:50 -0800 Subject: [PATCH 03/24] Add logging and better docstrings --- adafruit_connection_manager.py | 106 ++++++++++++++++++++++++--------- tox.ini | 2 +- 2 files changed, 80 insertions(+), 28 deletions(-) diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index 4c4c2a3..907620d 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -29,6 +29,29 @@ import errno import sys + +# common helpers + + +try: + import adafruit_logging as logging + + logger = logging.getLogger("__name__") +except ImportError: + # pylint: disable=too-few-public-methods + class NullLogger: + """Simple class to throw away log messages.""" + + def __init__(self) -> None: + for log_level in ["debug", "info", "warning", "error", "critical"]: + setattr(NullLogger, log_level, lambda *args, **kwargs: None) + + logger = NullLogger() + + +# typing + + if not sys.implementation.name == "circuitpython": from ssl import SSLContext from types import ModuleType @@ -118,20 +141,26 @@ def TLS_MODE(self) -> int: # pylint: disable=invalid-name SSLContextType = Union[SSLContext, "_FakeSSLContext"] -class SocketGetOSError(OSError): - """ConnectionManager Exception class.""" - - -class SocketGetRuntimeError(RuntimeError): - """ConnectionManager Exception class.""" +# custon exceptions class SocketConnectMemoryError(OSError): - """ConnectionManager Exception class.""" + """ConnectionManager Exception class for an MemoryError when connecting a socket.""" class SocketConnectOSError(OSError): - """ConnectionManager Exception class.""" + """ConnectionManager Exception class for an OSError when connecting a socket.""" + + +class SocketGetOSError(OSError): + """ConnectionManager Exception class for an OSError when getting a socket.""" + + +class SocketGetRuntimeError(RuntimeError): + """ConnectionManager Exception class for an RuntimeError when getting a socket.""" + + +# classes class _FakeSSLSocket: @@ -145,7 +174,7 @@ def __init__(self, socket: CircuitPythonSocketType, tls_mode: int) -> None: self.recv_into = socket.recv_into def connect(self, address: Tuple[str, int]) -> None: - """connect wrapper to add non-standard mode parameter""" + """Connect wrapper to add non-standard mode parameter""" try: return self._socket.connect(address, self._mode) except RuntimeError as error: @@ -156,18 +185,18 @@ class _FakeSSLContext: def __init__(self, iface: InterfaceType) -> None: self._iface = iface + # pylint: disable=unused-argument def wrap_socket( self, socket: CircuitPythonSocketType, server_hostname: Optional[str] = None ) -> _FakeSSLSocket: """Return the same socket""" - # pylint: disable=unused-argument return _FakeSSLSocket(socket, self._iface.TLS_MODE) def create_fake_ssl_context( socket_pool: SocketpoolModuleType, iface: Optional[InterfaceType] = None ) -> _FakeSSLContext: - """Legacy API for creating a fake SSL context""" + """Method to return a fake SSL context for when ssl isn't available to import""" if not iface: # pylint: disable=protected-access iface = socket_pool._the_interface @@ -188,31 +217,44 @@ def __init__( self._socket_free = {} def _free_sockets(self) -> None: + logger.debug("ConnectionManager.free_sockets()") free_sockets = [] - for socket, val in self._socket_free.items(): - if val: + for socket, free in self._socket_free.items(): + if free: + key = self._get_key_for_socket(socket) + logger.debug(f" found {key}") free_sockets.append(socket) for socket in free_sockets: self.close_socket(socket) - def free_socket(self, socket: SocketType) -> None: - """Mark a socket as free so it can be reused if needed""" - if socket not in self._open_sockets.values(): - raise RuntimeError("Socket not from session") - self._socket_free[socket] = True + def _get_key_for_socket(self, socket): + try: + return next( + key for key, value in self._open_sockets.items() if value == socket + ) + except StopIteration: + return None def close_socket(self, socket: SocketType) -> None: - """Close a socket""" + """Close a previously opened socket.""" + logger.debug("ConnectionManager.close_socket()") + if socket not in self._open_sockets.values(): + raise RuntimeError("Socket not managed") + key = self._get_key_for_socket(socket) + logger.debug(f" closing {key}") socket.close() del self._socket_free[socket] - key = None - for k, value in self._open_sockets.items(): - if value == socket: - key = k - break - if key: - del self._open_sockets[key] + del self._open_sockets[key] + + def free_socket(self, socket: SocketType) -> None: + """Mark a previously opened socket as free so it can be reused if needed.""" + logger.debug("ConnectionManager.free_socket()") + if socket not in self._open_sockets.values(): + raise RuntimeError("Socket not managed") + key = self._get_key_for_socket(socket) + logger.debug(f" flagging {key} as free") + self._socket_free[socket] = True # pylint: disable=too-many-locals,too-many-statements def get_socket( @@ -227,12 +269,16 @@ def get_socket( max_retries: int = 5, exception_passthrough: bool = False, ) -> CircuitPythonSocketType: - """Get socket and connect""" + """Get a new socket and connect""" # pylint: disable=too-many-branches + logger.debug("ConnectionManager.get_socket()") + logger.debug(f" tracking {len(self._open_sockets)} sockets") key = (host, port, proto) + logger.debug(f" getting socket for {key}") if key in self._open_sockets: socket = self._open_sockets[key] if self._socket_free[socket]: + logger.debug(f" found existing {key}") self._socket_free[socket] = False return socket @@ -253,6 +299,7 @@ def get_socket( last_exc_new_type = None while retry_count < max_retries and socket is None: if retry_count > 0: + logger.debug(f" retry #{retry_count}") if any(self._socket_free.items()): self._free_sockets() else: @@ -262,10 +309,12 @@ def get_socket( try: socket = self._socket_pool.socket(addr_info[0], addr_info[1]) except OSError as exc: + logger.debug(f" OSError getting socket: {exc}") last_exc_new_type = SocketGetOSError last_exc = exc continue except RuntimeError as exc: + logger.debug(f" RuntimeError getting socket: {exc}") last_exc_new_type = SocketGetRuntimeError last_exc = exc continue @@ -279,17 +328,20 @@ def get_socket( try: socket.connect((connect_host, port)) except MemoryError as exc: + logger.debug(f" MemoryError connecting socket: {exc}") last_exc_new_type = SocketConnectMemoryError last_exc = exc socket.close() socket = None except OSError as exc: + logger.debug(f" OSError connecting socket: {exc}") last_exc_new_type = SocketConnectOSError last_exc = exc socket.close() socket = None if socket is None: + logger.debug(" Repeated socket failures") if exception_passthrough: raise last_exc_new_type("Repeated socket failures") from last_exc raise RuntimeError("Repeated socket failures") from last_exc diff --git a/tox.ini b/tox.ini index e793d6f..c3830ef 100644 --- a/tox.ini +++ b/tox.ini @@ -35,4 +35,4 @@ deps = -r requirements.txt -r docs/requirements.txt skip_install = true -commands = sphinx-build -W -b html docs/ _build/ +commands = sphinx-build -E -W -b html docs/. _build/html From 6a80954c02be1d84ef573fc781ee2bc21d217352 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Sat, 3 Feb 2024 14:13:49 -0800 Subject: [PATCH 04/24] Add get_connection_members and session_id --- adafruit_connection_manager.py | 42 +++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index 907620d..13d66fd 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -262,6 +262,7 @@ def get_socket( host: str, port: int, proto: str, + session_id: Optional[str] = None, *, timeout: float = 1, is_ssl: bool = False, @@ -273,7 +274,7 @@ def get_socket( # pylint: disable=too-many-branches logger.debug("ConnectionManager.get_socket()") logger.debug(f" tracking {len(self._open_sockets)} sockets") - key = (host, port, proto) + key = (host, port, proto, session_id) logger.debug(f" getting socket for {key}") if key in self._open_sockets: socket = self._open_sockets[key] @@ -351,6 +352,9 @@ def get_socket( return socket +# connection helpers + + _global_connection_manager = None # pylint: disable=invalid-name @@ -360,3 +364,39 @@ def get_connection_manager(socket_pool: SocketpoolModuleType) -> None: if _global_connection_manager is None: _global_connection_manager = ConnectionManager(socket_pool) return _global_connection_manager + + +def get_connection_members(radio): + """Helper to get needed connection mamers for common boards""" + logger.debug("Detecting radio...") + + if hasattr(radio, "__class__") and radio.__class__.__name__: + class_name = radio.__class__.__name__ + else: + raise AttributeError("Can not determine class of radio") + + if class_name == "Radio": + logger.debug(" - Found WiFi") + import ssl # pylint: disable=import-outside-toplevel + import socketpool # pylint: disable=import-outside-toplevel + + pool = socketpool.SocketPool(radio) + ssl_context = ssl.create_default_context() + return pool, ssl_context + + if class_name == "ESP_SPIcontrol": + logger.debug(" - Found ESP32SPI") + import adafruit_esp32spi.adafruit_esp32spi_socket as pool # pylint: disable=import-outside-toplevel + + ssl_context = create_fake_ssl_context(pool, radio) + return pool, ssl_context + + if class_name == "WIZNET5K": + logger.debug(" - Found WIZNET5K") + import adafruit_wiznet5k.adafruit_wiznet5k_socket as pool # pylint: disable=import-outside-toplevel + + # Note: SSL/TLS connections are not supported by the Wiznet5k library at this time + ssl_context = create_fake_ssl_context(pool, radio) + return pool, ssl_context + + raise AttributeError(f"Unsupported radio class: {class_name}") From e61e880df960708992a9a9dda6e59f6ed6d28c98 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Sun, 4 Feb 2024 06:44:31 -0800 Subject: [PATCH 05/24] Make sure session_id is a string --- adafruit_connection_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index 13d66fd..d7da258 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -274,7 +274,7 @@ def get_socket( # pylint: disable=too-many-branches logger.debug("ConnectionManager.get_socket()") logger.debug(f" tracking {len(self._open_sockets)} sockets") - key = (host, port, proto, session_id) + key = (host, port, proto, str(session_id)) logger.debug(f" getting socket for {key}") if key in self._open_sockets: socket = self._open_sockets[key] From a47e0ea2826301b7009700606ba361ac07abe69f Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Tue, 6 Feb 2024 13:28:54 -0800 Subject: [PATCH 06/24] Update adafruit_connection_manager.py Co-authored-by: Scott Shawcroft --- adafruit_connection_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index d7da258..8e49861 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -205,7 +205,7 @@ def create_fake_ssl_context( class ConnectionManager: - """Connection manager for sharing sockets.""" + """Connection manager for sharing open sockets (aka connections).""" def __init__( self, From 8b178c80d07496e416f1acb1b97a2df01a820087 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Tue, 6 Feb 2024 13:29:51 -0800 Subject: [PATCH 07/24] Fix typo --- adafruit_connection_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index 8e49861..5f30080 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -367,7 +367,7 @@ def get_connection_manager(socket_pool: SocketpoolModuleType) -> None: def get_connection_members(radio): - """Helper to get needed connection mamers for common boards""" + """Helper to get needed connection members for common boards""" logger.debug("Detecting radio...") if hasattr(radio, "__class__") and radio.__class__.__name__: From 9b068a88c43ec439477cd2a98bf6e761f587394d Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Fri, 9 Feb 2024 13:54:05 -0800 Subject: [PATCH 08/24] Remove unneeded typing --- adafruit_connection_manager.py | 92 +++------------------------------- 1 file changed, 7 insertions(+), 85 deletions(-) diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index 5f30080..d7acd55 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -53,92 +53,14 @@ def __init__(self) -> None: if not sys.implementation.name == "circuitpython": - from ssl import SSLContext - from types import ModuleType - from typing import Any, Optional, Tuple, Union - - try: - from typing import Protocol - except ImportError: - from typing_extensions import Protocol - - # Based on https://github.com/python/typeshed/blob/master/stdlib/_socket.pyi - class CommonSocketType(Protocol): - """Describes the common structure every socket type must have.""" - - def send(self, data: bytes, flags: int = ...) -> None: - """Send data to the socket. The meaning of the optional flags kwarg is - implementation-specific.""" - - def settimeout(self, value: Optional[float]) -> None: - """Set a timeout on blocking socket operations.""" - - def close(self) -> None: - """Close the socket.""" - - class CommonCircuitPythonSocketType(CommonSocketType, Protocol): - """Describes the common structure every CircuitPython socket type must have.""" - - def connect( - self, - address: Tuple[str, int], - conntype: Optional[int] = ..., - ) -> None: - """Connect to a remote socket at the provided (host, port) address. The conntype - kwarg optionally may indicate SSL or not, depending on the underlying interface. - """ - - class SupportsRecvWithFlags(Protocol): - """Describes a type that posseses a socket recv() method supporting the flags kwarg.""" - - def recv(self, bufsize: int = ..., flags: int = ...) -> bytes: - """Receive data from the socket. The return value is a bytes object representing - the data received. The maximum amount of data to be received at once is specified - by bufsize. The meaning of the optional flags kwarg is implementation-specific. - """ - - class SupportsRecvInto(Protocol): - """Describes a type that possesses a socket recv_into() method.""" - - def recv_into( - self, buffer: bytearray, nbytes: int = ..., flags: int = ... - ) -> int: - """Receive up to nbytes bytes from the socket, storing the data into the provided - buffer. If nbytes is not specified (or 0), receive up to the size available in the - given buffer. The meaning of the optional flags kwarg is implementation-specific. - Returns the number of bytes received.""" - - class CircuitPythonSocketType( - CommonCircuitPythonSocketType, - SupportsRecvInto, - SupportsRecvWithFlags, - Protocol, - ): - """Describes the structure every modern CircuitPython socket type must have.""" - - class StandardPythonSocketType( - CommonSocketType, SupportsRecvInto, SupportsRecvWithFlags, Protocol - ): - """Describes the structure every standard Python socket type must have.""" - - def connect(self, address: Union[Tuple[Any, ...], str, bytes]) -> None: - """Connect to a remote socket at the provided address.""" - - SocketType = Union[ + from typing import Optional, Tuple + from circuitpython_typing.socket import ( CircuitPythonSocketType, - StandardPythonSocketType, - ] - - SocketpoolModuleType = ModuleType - - class InterfaceType(Protocol): - """Describes the structure every interface type must have.""" - - @property - def TLS_MODE(self) -> int: # pylint: disable=invalid-name - """Constant representing that a socket's connection mode is TLS.""" - - SSLContextType = Union[SSLContext, "_FakeSSLContext"] + SocketType, + SocketpoolModuleType, + InterfaceType, + SSLContextType, + ) # custon exceptions From 796b9b003f136b907480725e77143992c94b8f9b Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Wed, 14 Feb 2024 08:52:30 -0800 Subject: [PATCH 09/24] Simplify retry logic --- adafruit_connection_manager.py | 222 ++++++++++++++++++--------------- tests/protocol_test.py | 2 +- 2 files changed, 121 insertions(+), 103 deletions(-) diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index d7acd55..163456b 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -30,25 +30,6 @@ import sys -# common helpers - - -try: - import adafruit_logging as logging - - logger = logging.getLogger("__name__") -except ImportError: - # pylint: disable=too-few-public-methods - class NullLogger: - """Simple class to throw away log messages.""" - - def __init__(self) -> None: - for log_level in ["debug", "info", "warning", "error", "critical"]: - setattr(NullLogger, log_level, lambda *args, **kwargs: None) - - logger = NullLogger() - - # typing @@ -63,26 +44,26 @@ def __init__(self) -> None: ) -# custon exceptions - - -class SocketConnectMemoryError(OSError): - """ConnectionManager Exception class for an MemoryError when connecting a socket.""" - +# common helpers -class SocketConnectOSError(OSError): - """ConnectionManager Exception class for an OSError when connecting a socket.""" +try: + import adafruit_logging as logging -class SocketGetOSError(OSError): - """ConnectionManager Exception class for an OSError when getting a socket.""" + logger = logging.getLogger("__name__") +except ImportError: + # pylint: disable=too-few-public-methods + class NullLogger: + """Simple class to throw away log messages.""" + def __init__(self) -> None: + for log_level in ["debug", "info", "warning", "error", "critical"]: + setattr(NullLogger, log_level, lambda *args, **kwargs: None) -class SocketGetRuntimeError(RuntimeError): - """ConnectionManager Exception class for an RuntimeError when getting a socket.""" + logger = NullLogger() -# classes +# ssl and pool helpers class _FakeSSLSocket: @@ -126,6 +107,82 @@ def create_fake_ssl_context( return _FakeSSLContext(iface) +_global_socketpool = {} +_global_ssl_contexts = {} + + +def get_radio_socketpool(radio): + """Helper to get SocketPool for common boards""" + if hasattr(radio, "__class__") and radio.__class__.__name__: + class_name = radio.__class__.__name__ + else: + raise AttributeError("Can not determine class of radio") + + if class_name not in _global_socketpool: + logger.debug("Detecting radio...") + + if class_name == "Radio": + logger.debug(" - Found onboard WiFi") + import socketpool # pylint: disable=import-outside-toplevel + + pool = socketpool.SocketPool(radio) + + elif class_name == "ESP_SPIcontrol": + logger.debug(" - Found ESP32SPI") + import adafruit_esp32spi.adafruit_esp32spi_socket as pool # pylint: disable=import-outside-toplevel + + elif class_name == "WIZNET5K": + logger.debug(" - Found WIZNET5K") + import adafruit_wiznet5k.adafruit_wiznet5k_socket as pool # pylint: disable=import-outside-toplevel + + else: + raise AttributeError(f"Unsupported radio class: {class_name}") + + _global_socketpool[class_name] = pool + + return _global_socketpool[class_name] + + +def get_radio_ssl_contexts(radio): + """Helper to get ssl_contexts for common boards""" + if hasattr(radio, "__class__") and radio.__class__.__name__: + class_name = radio.__class__.__name__ + else: + raise AttributeError("Can not determine class of radio") + + if class_name not in _global_ssl_contexts: + logger.debug("Detecting radio...") + + if class_name == "Radio": + logger.debug(" - Found onboard WiFi") + import ssl # pylint: disable=import-outside-toplevel + + ssl_context = ssl.create_default_context() + + elif class_name == "ESP_SPIcontrol": + logger.debug(" - Found ESP32SPI") + import adafruit_esp32spi.adafruit_esp32spi_socket as pool # pylint: disable=import-outside-toplevel + + ssl_context = create_fake_ssl_context(pool, radio) + + elif class_name == "WIZNET5K": + logger.debug(" - Found WIZNET5K") + import adafruit_wiznet5k.adafruit_wiznet5k_socket as pool # pylint: disable=import-outside-toplevel + + # Note: SSL/TLS connections are not supported by the Wiznet5k library at this time + ssl_context = create_fake_ssl_context(pool, radio) + + else: + raise AttributeError(f"Unsupported radio class: {class_name}") + + _global_ssl_contexts[class_name] = ssl_context + + return _global_ssl_contexts[class_name] + + +# main class + + class ConnectionManager: """Connection manager for sharing open sockets (aka connections).""" @@ -135,19 +192,19 @@ def __init__( ) -> None: self._socket_pool = socket_pool # Hang onto open sockets so that we can reuse them. + self._available_socket = {} self._open_sockets = {} - self._socket_free = {} def _free_sockets(self) -> None: logger.debug("ConnectionManager.free_sockets()") - free_sockets = [] - for socket, free in self._socket_free.items(): + available_sockets = [] + for socket, free in self._available_socket.items(): if free: key = self._get_key_for_socket(socket) logger.debug(f" found {key}") - free_sockets.append(socket) + available_sockets.append(socket) - for socket in free_sockets: + for socket in available_sockets: self.close_socket(socket) def _get_key_for_socket(self, socket): @@ -166,19 +223,19 @@ def close_socket(self, socket: SocketType) -> None: key = self._get_key_for_socket(socket) logger.debug(f" closing {key}") socket.close() - del self._socket_free[socket] + del self._available_socket[socket] del self._open_sockets[key] def free_socket(self, socket: SocketType) -> None: - """Mark a previously opened socket as free so it can be reused if needed.""" + """Mark a previously opened socket as available so it can be reused if needed.""" logger.debug("ConnectionManager.free_socket()") if socket not in self._open_sockets.values(): raise RuntimeError("Socket not managed") key = self._get_key_for_socket(socket) logger.debug(f" flagging {key} as free") - self._socket_free[socket] = True + self._available_socket[socket] = True - # pylint: disable=too-many-locals,too-many-statements + # pylint: disable=too-many-branches,too-many-locals,too-many-statements def get_socket( self, host: str, @@ -189,26 +246,25 @@ def get_socket( timeout: float = 1, is_ssl: bool = False, ssl_context: Optional[SSLContextType] = None, - max_retries: int = 5, - exception_passthrough: bool = False, ) -> CircuitPythonSocketType: """Get a new socket and connect""" - # pylint: disable=too-many-branches logger.debug("ConnectionManager.get_socket()") logger.debug(f" tracking {len(self._open_sockets)} sockets") key = (host, port, proto, str(session_id)) logger.debug(f" getting socket for {key}") if key in self._open_sockets: socket = self._open_sockets[key] - if self._socket_free[socket]: + if self._available_socket[socket]: logger.debug(f" found existing {key}") - self._socket_free[socket] = False + self._available_socket[socket] = False return socket + raise RuntimeError(f"Socket already connected to {proto}//{host}:{port}") + if proto == "https:": is_ssl = True if is_ssl and not ssl_context: - raise RuntimeError( + raise AttributeError( "ssl_context must be set before using adafruit_requests for https" ) @@ -216,65 +272,63 @@ def get_socket( host, port, 0, self._socket_pool.SOCK_STREAM )[0] - retry_count = 0 + try_count = 0 socket = None last_exc = None - last_exc_new_type = None - while retry_count < max_retries and socket is None: - if retry_count > 0: - logger.debug(f" retry #{retry_count}") - if any(self._socket_free.items()): + while try_count < 2 and socket is None: + try_count += 1 + if try_count > 1: + logger.debug(f" try #{try_count}") + if any( + socket + for socket, free in self._available_socket.items() + if free is True + ): self._free_sockets() else: - raise RuntimeError("Sending request failed") from last_exc - retry_count += 1 + break try: socket = self._socket_pool.socket(addr_info[0], addr_info[1]) except OSError as exc: logger.debug(f" OSError getting socket: {exc}") - last_exc_new_type = SocketGetOSError last_exc = exc continue except RuntimeError as exc: logger.debug(f" RuntimeError getting socket: {exc}") - last_exc_new_type = SocketGetRuntimeError last_exc = exc continue - connect_host = addr_info[-1][0] if is_ssl: socket = ssl_context.wrap_socket(socket, server_hostname=host) connect_host = host + else: + connect_host = addr_info[-1][0] socket.settimeout(timeout) # socket read timeout try: socket.connect((connect_host, port)) except MemoryError as exc: logger.debug(f" MemoryError connecting socket: {exc}") - last_exc_new_type = SocketConnectMemoryError last_exc = exc socket.close() socket = None except OSError as exc: logger.debug(f" OSError connecting socket: {exc}") - last_exc_new_type = SocketConnectOSError last_exc = exc socket.close() socket = None if socket is None: - logger.debug(" Repeated socket failures") - if exception_passthrough: - raise last_exc_new_type("Repeated socket failures") from last_exc - raise RuntimeError("Repeated socket failures") from last_exc + logger.debug(" Error connecting socket") + raise RuntimeError("Error connecting socket") from last_exc + self._available_socket[socket] = False self._open_sockets[key] = socket - self._socket_free[socket] = False return socket -# connection helpers +# global helpers _global_connection_manager = None # pylint: disable=invalid-name @@ -286,39 +340,3 @@ def get_connection_manager(socket_pool: SocketpoolModuleType) -> None: if _global_connection_manager is None: _global_connection_manager = ConnectionManager(socket_pool) return _global_connection_manager - - -def get_connection_members(radio): - """Helper to get needed connection members for common boards""" - logger.debug("Detecting radio...") - - if hasattr(radio, "__class__") and radio.__class__.__name__: - class_name = radio.__class__.__name__ - else: - raise AttributeError("Can not determine class of radio") - - if class_name == "Radio": - logger.debug(" - Found WiFi") - import ssl # pylint: disable=import-outside-toplevel - import socketpool # pylint: disable=import-outside-toplevel - - pool = socketpool.SocketPool(radio) - ssl_context = ssl.create_default_context() - return pool, ssl_context - - if class_name == "ESP_SPIcontrol": - logger.debug(" - Found ESP32SPI") - import adafruit_esp32spi.adafruit_esp32spi_socket as pool # pylint: disable=import-outside-toplevel - - ssl_context = create_fake_ssl_context(pool, radio) - return pool, ssl_context - - if class_name == "WIZNET5K": - logger.debug(" - Found WIZNET5K") - import adafruit_wiznet5k.adafruit_wiznet5k_socket as pool # pylint: disable=import-outside-toplevel - - # Note: SSL/TLS connections are not supported by the Wiznet5k library at this time - ssl_context = create_fake_ssl_context(pool, radio) - return pool, ssl_context - - raise AttributeError(f"Unsupported radio class: {class_name}") diff --git a/tests/protocol_test.py b/tests/protocol_test.py index 929b058..984add7 100644 --- a/tests/protocol_test.py +++ b/tests/protocol_test.py @@ -22,7 +22,7 @@ def test_get_https_no_ssl(): pool.socket.return_value = sock connection_manager = adafruit_connection_manager.ConnectionManager(pool) - with pytest.raises(RuntimeError): + with pytest.raises(AttributeError): connection_manager.get_socket(HOST, 443, "https:") From 392363d37e0ea4ee4c7b398132c80a6275bfd532 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Wed, 14 Feb 2024 13:33:15 -0800 Subject: [PATCH 10/24] Add tests --- .pre-commit-config.yaml | 4 +- adafruit_connection_manager.py | 10 +- examples/connectionmanager_ssltest.py | 205 ++++++++++++++++++++++++++ tests/close_socket_test.py | 48 ++++++ tests/free_socket_test.py | 109 ++++++++++++++ tests/get_socket_test.py | 197 +++++++++++++++++++++++++ tests/protocol_test.py | 50 ++++--- 7 files changed, 594 insertions(+), 29 deletions(-) create mode 100644 examples/connectionmanager_ssltest.py create mode 100644 tests/close_socket_test.py create mode 100644 tests/free_socket_test.py create mode 100644 tests/get_socket_test.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e2c8831..9c90e56 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,11 +32,11 @@ repos: types: [python] files: "^examples/" args: - - --disable=missing-docstring,invalid-name,consider-using-f-string,duplicate-code + - --disable=consider-using-f-string,duplicate-code,missing-docstring,invalid-name, - id: pylint name: pylint (test code) description: Run pylint rules on "tests/*.py" files types: [python] files: "^tests/" args: - - --disable=missing-docstring,invalid-name,consider-using-f-string,duplicate-code + - --disable=consider-using-f-string,duplicate-code,missing-docstring,invalid-name,protected-access diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index 163456b..c994b37 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -50,7 +50,7 @@ try: import adafruit_logging as logging - logger = logging.getLogger("__name__") + logger = logging.getLogger(__name__) except ImportError: # pylint: disable=too-few-public-methods class NullLogger: @@ -112,7 +112,7 @@ def create_fake_ssl_context( def get_radio_socketpool(radio): - """Helper to get SocketPool for common boards""" + """Helper to get a socket pool for common boards""" if hasattr(radio, "__class__") and radio.__class__.__name__: class_name = radio.__class__.__name__ else: @@ -250,7 +250,9 @@ def get_socket( """Get a new socket and connect""" logger.debug("ConnectionManager.get_socket()") logger.debug(f" tracking {len(self._open_sockets)} sockets") - key = (host, port, proto, str(session_id)) + if session_id: + session_id = str(session_id) + key = (host, port, proto, session_id) logger.debug(f" getting socket for {key}") if key in self._open_sockets: socket = self._open_sockets[key] @@ -321,7 +323,7 @@ def get_socket( if socket is None: logger.debug(" Error connecting socket") - raise RuntimeError("Error connecting socket") from last_exc + raise RuntimeError(f"Error connecting socket: {last_exc}") from last_exc self._available_socket[socket] = False self._open_sockets[key] = socket diff --git a/examples/connectionmanager_ssltest.py b/examples/connectionmanager_ssltest.py new file mode 100644 index 0000000..39c63e9 --- /dev/null +++ b/examples/connectionmanager_ssltest.py @@ -0,0 +1,205 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 Justin Myers +# +# SPDX-License-Identifier: Unlicense + +import time +import wifi + +import adafruit_connection_manager + + +adafruit_groups = [ + { + "heading": "Common hosts", + "description": "These are common hosts users hit.", + "success": "yes", + "fail": "no", + "subdomains": [ + {"host": "api.fitbit.com"}, + {"host": "api.github.com"}, + {"host": "api.thingspeak.com"}, + {"host": "api.twitter.com"}, + {"host": "discord.com"}, + {"host": "id.twitch.tv"}, + {"host": "oauth2.googleapis.com"}, + {"host": "opensky-network.org"}, + {"host": "www.adafruit.com"}, + {"host": "www.googleapis.com"}, + {"host": "youtube.googleapis.com"}, + ], + }, + { + "heading": "Known problem hosts", + "description": "These are hosts we have run into problems in the past.", + "success": "yes", + "fail": "no", + "subdomains": [ + {"host": "valid-isrgrootx2.letsencrypt.org"}, + ], + }, +] + +badssl_groups = [ + { + "heading": "Certificate Validation (High Risk)", + "description": ( + "If your browser connects to one of these sites, it could be very easy for an attacker " + "to see and modify everything on web sites that you visit." + ), + "success": "no", + "fail": "yes", + "subdomains": [ + {"subdomain": "expired"}, + {"subdomain": "wrong.host"}, + {"subdomain": "self-signed"}, + {"subdomain": "untrusted-root"}, + ], + }, + { + "heading": "Interception Certificates (High Risk)", + "description": ( + "If your browser connects to one of these sites, it could be very easy for an attacker " + "to see and modify everything on web sites that you visit. This may be due to " + "interception software installed on your device." + ), + "success": "no", + "fail": "yes", + "subdomains": [ + {"subdomain": "superfish"}, + {"subdomain": "edellroot"}, + {"subdomain": "dsdtestprovider"}, + {"subdomain": "preact-cli"}, + {"subdomain": "webpack-dev-server"}, + ], + }, + { + "heading": "Broken Cryptography (Medium Risk)", + "description": ( + "If your browser connects to one of these sites, an attacker with enough resources may " + "be able to see and/or modify everything on web sites that you visit. This is because " + "your browser supports connections settings that are outdated and known to have " + "significant security flaws." + ), + "success": "no", + "fail": "yes", + "subdomains": [ + {"subdomain": "rc4"}, + {"subdomain": "rc4-md5"}, + {"subdomain": "dh480"}, + {"subdomain": "dh512"}, + {"subdomain": "dh1024"}, + {"subdomain": "null"}, + ], + }, + { + "heading": "Legacy Cryptography (Moderate Risk)", + "description": ( + "If your browser connects to one of these sites, your web traffic is probably safe " + "from attackers in the near future. However, your connections to some sites might " + "not be using the strongest possible security. Your browser may use these settings in " + "order to connect to some older sites." + ), + "success": "maybe", + "fail": "yes", + "subdomains": [ + {"subdomain": "tls-v1-0", "port": 1010}, + {"subdomain": "tls-v1-1", "port": 1011}, + {"subdomain": "cbc"}, + {"subdomain": "3des"}, + {"subdomain": "dh2048"}, + ], + }, + { + "heading": "Domain Security Policies", + "description": ( + "These are special tests for some specific browsers. These tests may be able to tell " + "whether your browser uses advanced domain security policy mechanisms (HSTS, HPKP, SCT" + ") to detect illegitimate certificates." + ), + "success": "maybe", + "fail": "yes", + "subdomains": [ + {"subdomain": "revoked"}, + {"subdomain": "pinning-test"}, + {"subdomain": "no-sct"}, + ], + }, + { + "heading": "Secure (Uncommon)", + "description": ( + "These settings are secure. However, they are less common and even if your browser " + "doesn't support them you probably won't have issues with most sites." + ), + "success": "yes", + "fail": "maybe", + "subdomains": [ + {"subdomain": "1000-sans"}, + {"subdomain": "10000-sans"}, + {"subdomain": "sha384"}, + {"subdomain": "sha512"}, + {"subdomain": "rsa8192"}, + {"subdomain": "no-subject"}, + {"subdomain": "no-common-name"}, + {"subdomain": "incomplete-chain"}, + ], + }, + { + "heading": "Secure (Common)", + "description": ( + "These settings are secure and commonly used by sites. Your browser will need to " + "support most of these in order to connect to sites securely." + ), + "success": "yes", + "fail": "no", + "subdomains": [ + {"subdomain": "tls-v1-2", "port": 1012}, + {"subdomain": "sha256"}, + {"subdomain": "rsa2048"}, + {"subdomain": "ecc256"}, + {"subdomain": "ecc384"}, + {"subdomain": "extended-validation"}, + {"subdomain": "mozilla-modern"}, + ], + }, +] + +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_contexts(wifi.radio) +connection_manager = adafruit_connection_manager.get_connection_manager(pool) + + +def check_group(groups, group_name): + print(f"\nRunning {group_name}") + for group in groups: + print(f'\n - {group["heading"]}') + success = group["success"] + fail = group["fail"] + for subdomain in group["subdomains"]: + if "host" in subdomain: + host = subdomain["host"] + else: + host = f'{subdomain["subdomain"]}.badssl.com' + port = subdomain.get("port", 443) + exc = None + start_time = time.monotonic() + try: + socket = connection_manager.get_socket( + host, port, "https:", is_ssl=True, ssl_context=ssl_context + ) + connection_manager.close_socket(socket) + except RuntimeError as e: + exc = e + duration = time.monotonic() - start_time + + if fail == "yes" and exc and "Failed SSL handshake" in str(exc): + result = "passed" + elif success == "yes" and exc is None: + result = "passed" + else: + result = f"error - success:{success}, fail:{fail}, exc:{exc}" + + print(f" - {host}:{port} took {duration:.2f} seconds | {result}") + + +check_group(adafruit_groups, "Adafruit") +check_group(badssl_groups, "BadSSL") diff --git a/tests/close_socket_test.py b/tests/close_socket_test.py new file mode 100644 index 0000000..1456972 --- /dev/null +++ b/tests/close_socket_test.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +""" Protocol Tests """ + +import mocket +import pytest +import adafruit_connection_manager + +IP = "1.2.3.4" +HOST1 = "wifitest.adafruit.com" +TEXT = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" +RESPONSE = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + TEXT + + +def test_close_socket(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_pool.socket.return_value = mock_socket_1 + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # validate socket is tracked + socket = connection_manager.get_socket(HOST1, 80, "http:") + key = (HOST1, 80, "http:", None) + assert socket == mock_socket_1 + assert socket in connection_manager._available_socket + assert key in connection_manager._open_sockets + + # validate socket is no longer tracked + connection_manager.close_socket(socket) + assert socket not in connection_manager._available_socket + assert key not in connection_manager._open_sockets + + +def test_close_socket_not_managed(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # validate not managed socket errors + with pytest.raises(RuntimeError) as context: + connection_manager.close_socket(mock_socket_1) + assert "Socket not managed" in str(context) diff --git a/tests/free_socket_test.py b/tests/free_socket_test.py new file mode 100644 index 0000000..b6c009a --- /dev/null +++ b/tests/free_socket_test.py @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +""" Protocol Tests """ + +import mocket +import pytest +import adafruit_connection_manager + +IP = "1.2.3.4" +HOST1 = "wifitest.adafruit.com" +HOST2 = "wifitest2.adafruit.com" +TEXT = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" +RESPONSE = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + TEXT + + +def test_free_socket(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_pool.socket.return_value = mock_socket_1 + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # validate socket is tracked and not available + socket = connection_manager.get_socket(HOST1, 80, "http:") + key = (HOST1, 80, "http:", None) + assert socket == mock_socket_1 + assert socket in connection_manager._available_socket + assert connection_manager._available_socket[socket] is False + assert key in connection_manager._open_sockets + + # validate socket is tracked and is available + connection_manager.free_socket(socket) + assert socket in connection_manager._available_socket + assert connection_manager._available_socket[socket] is True + assert key in connection_manager._open_sockets + + +def test_free_socket_not_managed(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # validate not managed socket errors + with pytest.raises(RuntimeError) as context: + connection_manager.free_socket(mock_socket_1) + assert "Socket not managed" in str(context) + + +def test_free_sockets(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_2 = mocket.Mocket(RESPONSE) + mock_pool.socket.side_effect = [ + mock_socket_1, + mock_socket_2, + ] + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # validate socket is tracked and not available + socket_1 = connection_manager.get_socket(HOST1, 80, "http:") + assert socket_1 == mock_socket_1 + assert socket_1 in connection_manager._available_socket + assert connection_manager._available_socket[socket_1] is False + + socket_2 = connection_manager.get_socket(HOST2, 80, "http:") + assert socket_2 == mock_socket_2 + + # validate socket is tracked and is available + connection_manager.free_socket(socket_1) + assert socket_1 in connection_manager._available_socket + assert connection_manager._available_socket[socket_1] is True + + # validate socket is no longer tracked + connection_manager._free_sockets() + assert socket_1 not in connection_manager._available_socket + assert socket_2 in connection_manager._available_socket + mock_socket_1.close.assert_called_once() + + +def test_get_key_for_socket(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_pool.socket.return_value = mock_socket_1 + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # validate tracked socket has correct key + socket = connection_manager.get_socket(HOST1, 80, "http:") + key = (HOST1, 80, "http:", None) + assert connection_manager._get_key_for_socket(socket) == key + + +def test_get_key_for_socket_not_managed(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # validate untracked socket has no key + assert connection_manager._get_key_for_socket(mock_socket_1) is None diff --git a/tests/get_socket_test.py b/tests/get_socket_test.py new file mode 100644 index 0000000..b47ef59 --- /dev/null +++ b/tests/get_socket_test.py @@ -0,0 +1,197 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +""" Protocol Tests """ + +from unittest import mock +import mocket +import pytest +import adafruit_connection_manager + +IP = "1.2.3.4" +HOST1 = "wifitest.adafruit.com" +HOST2 = "wifitest2.adafruit.com" +TEXT = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" +RESPONSE = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + TEXT + + +def test_get_socket(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_2 = mocket.Mocket(RESPONSE) + mock_pool.socket.side_effect = [ + mock_socket_1, + mock_socket_2, + ] + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # get socket + socket = connection_manager.get_socket(HOST1, 80, "http:") + assert socket == mock_socket_1 + + +def test_get_socket_different_session(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_2 = mocket.Mocket(RESPONSE) + mock_pool.socket.side_effect = [ + mock_socket_1, + mock_socket_2, + ] + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # get socket + socket = connection_manager.get_socket(HOST1, 80, "http:", session_id="1") + assert socket == mock_socket_1 + + # get socket on different session + socket = connection_manager.get_socket(HOST1, 80, "http:", session_id="2") + assert socket == mock_socket_2 + + +def test_get_socket_flagged_free(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_2 = mocket.Mocket(RESPONSE) + mock_pool.socket.side_effect = [ + mock_socket_1, + mock_socket_2, + ] + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # get a socket and then mark as free + socket = connection_manager.get_socket(HOST1, 80, "http:") + assert socket == mock_socket_1 + connection_manager.free_socket(socket) + + # get a socket for the same host, should be the same one + socket = connection_manager.get_socket(HOST1, 80, "http:") + assert socket == mock_socket_1 + + +def test_get_socket_not_flagged_free(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_2 = mocket.Mocket(RESPONSE) + mock_pool.socket.side_effect = [ + mock_socket_1, + mock_socket_2, + ] + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # get a socket but don't mark as free + socket = connection_manager.get_socket(HOST1, 80, "http:") + assert socket == mock_socket_1 + + # get a socket for the same host, should be a different one + with pytest.raises(RuntimeError) as context: + socket = connection_manager.get_socket(HOST1, 80, "http:") + assert "Socket already connected" in str(context) + + +def test_get_socket_os_error(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_pool.socket.side_effect = [ + OSError("OSError"), + mock_socket_1, + ] + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # try to get a socket that returns a OSError + with pytest.raises(RuntimeError) as context: + connection_manager.get_socket(HOST1, 80, "http:") + assert "Error connecting socket: OSError" in str(context) + + +def test_get_socket_runtime_error(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_pool.socket.side_effect = [ + RuntimeError("RuntimeError"), + mock_socket_1, + ] + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # try to get a socket that returns a RuntimeError + with pytest.raises(RuntimeError) as context: + connection_manager.get_socket(HOST1, 80, "http:") + assert "Error connecting socket: RuntimeError" in str(context) + + +def test_get_socket_connect_memory_error(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_2 = mocket.Mocket(RESPONSE) + mock_pool.socket.side_effect = [ + mock_socket_1, + mock_socket_2, + ] + mock_socket_1.connect.side_effect = MemoryError("MemoryError") + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # try to connect a socket that returns a MemoryError + with pytest.raises(RuntimeError) as context: + connection_manager.get_socket(HOST1, 80, "http:") + assert "Error connecting socket: MemoryError" in str(context) + + +def test_get_socket_connect_os_error(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_2 = mocket.Mocket(RESPONSE) + mock_pool.socket.side_effect = [ + mock_socket_1, + mock_socket_2, + ] + mock_socket_1.connect.side_effect = OSError("OSError") + + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # try to connect a socket that returns a OSError + with pytest.raises(RuntimeError) as context: + connection_manager.get_socket(HOST1, 80, "http:") + assert "Error connecting socket: OSError" in str(context) + + +def test_get_socket_runtime_error_ties_again_at_least_one_free(): + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_2 = mocket.Mocket(RESPONSE) + mock_pool.socket.side_effect = [ + mock_socket_1, + RuntimeError(), + mock_socket_2, + ] + + free_sockets_mock = mock.Mock() + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + connection_manager._free_sockets = free_sockets_mock + + # get a socket and then mark as free + socket = connection_manager.get_socket(HOST1, 80, "http:") + assert socket == mock_socket_1 + connection_manager.free_socket(socket) + free_sockets_mock.assert_not_called() + + # try to get a socket that returns a RuntimeError and at least one is flagged as free + socket = connection_manager.get_socket(HOST2, 80, "http:") + assert socket == mock_socket_2 + free_sockets_mock.assert_called_once() diff --git a/tests/protocol_test.py b/tests/protocol_test.py index 984add7..20e5425 100644 --- a/tests/protocol_test.py +++ b/tests/protocol_test.py @@ -9,44 +9,48 @@ import adafruit_connection_manager IP = "1.2.3.4" -HOST = "wifitest.adafruit.com" +HOST1 = "wifitest.adafruit.com" PATH = "/testwifi/index.html" TEXT = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" RESPONSE = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + TEXT def test_get_https_no_ssl(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - sock = mocket.Mocket(RESPONSE) - pool.socket.return_value = sock + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_pool.socket.return_value = mock_socket_1 - connection_manager = adafruit_connection_manager.ConnectionManager(pool) - with pytest.raises(AttributeError): - connection_manager.get_socket(HOST, 443, "https:") + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # verify not sending in a SSL context for a HTTPS call errors + with pytest.raises(AttributeError) as context: + connection_manager.get_socket(HOST1, 443, "https:") + assert "ssl_context must be set" in str(context) def test_connect_https(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - sock = mocket.Mocket(RESPONSE) - pool.socket.return_value = sock + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_pool.socket.return_value = mock_socket_1 ssl = mocket.SSLContext() + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) - connection_manager = adafruit_connection_manager.ConnectionManager(pool) - connection_manager.get_socket(HOST, 443, "https:", ssl_context=ssl) - - sock.connect.assert_called_once_with((HOST, 443)) + # verify a HTTPS call changes the port to 443 + connection_manager.get_socket(HOST1, 443, "https:", ssl_context=ssl) + mock_socket_1.connect.assert_called_once_with((HOST1, 443)) def test_connect_http(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - sock = mocket.Mocket(RESPONSE) - pool.socket.return_value = sock + mock_pool = mocket.MocketPool() + mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + mock_socket_1 = mocket.Mocket(RESPONSE) + mock_pool.socket.return_value = mock_socket_1 - connection_manager = adafruit_connection_manager.ConnectionManager(pool) - connection_manager.get_socket(HOST, 80, "http:") + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) - sock.connect.assert_called_once_with((IP, 80)) + # verify a HTTP call does not change the port to 443 + connection_manager.get_socket(HOST1, 80, "http:") + mock_socket_1.connect.assert_called_once_with((IP, 80)) From 91e0cf718fa08244bee9325c55dc0b0d6e9701c1 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Thu, 15 Feb 2024 13:00:48 -0800 Subject: [PATCH 11/24] Add more sst test hosts --- .pre-commit-config.yaml | 7 ++- examples/connectionmanager_simpletest.py | 4 -- examples/connectionmanager_ssltest.py | 61 ++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 8 deletions(-) delete mode 100644 examples/connectionmanager_simpletest.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9c90e56..6699562 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,9 +4,14 @@ repos: - repo: https://github.com/python/black - rev: 23.3.0 + rev: 24.2.0 hooks: - id: black + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + args: ["--profile", "black", "--filter-files"] - repo: https://github.com/fsfe/reuse-tool rev: v1.1.2 hooks: diff --git a/examples/connectionmanager_simpletest.py b/examples/connectionmanager_simpletest.py deleted file mode 100644 index 8110262..0000000 --- a/examples/connectionmanager_simpletest.py +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -# SPDX-FileCopyrightText: Copyright (c) 2023 Justin Myers for Adafruit Industries -# -# SPDX-License-Identifier: Unlicense diff --git a/examples/connectionmanager_ssltest.py b/examples/connectionmanager_ssltest.py index 39c63e9..c47a69c 100644 --- a/examples/connectionmanager_ssltest.py +++ b/examples/connectionmanager_ssltest.py @@ -2,29 +2,77 @@ # # SPDX-License-Identifier: Unlicense +import os import time + import wifi import adafruit_connection_manager - adafruit_groups = [ { - "heading": "Common hosts", - "description": "These are common hosts users hit.", + "heading": "API hosts", + "description": "These are common API hosts users hit.", "success": "yes", "fail": "no", "subdomains": [ + {"host": "api.coindesk.com"}, + {"host": "api.covidtracking.com"}, + {"host": "api.developer.lifx.com"}, {"host": "api.fitbit.com"}, {"host": "api.github.com"}, + {"host": "api.hackaday.io"}, + {"host": "api.hackster.io"}, + {"host": "api.met.no"}, + {"host": "api.nasa.gov"}, + {"host": "api.nytimes.com"}, + {"host": "api.open-meteo.com"}, + {"host": "api.openai.com"}, + {"host": "api.openweathermap.org"}, + {"host": "api.purpleair.com"}, + {"host": "api.spacexdata.com"}, + {"host": "api.thecatapi.com"}, + {"host": "api.thingiverse.com"}, {"host": "api.thingspeak.com"}, + {"host": "api.tidesandcurrents.noaa.gov"}, {"host": "api.twitter.com"}, + {"host": "api.wordnik.com"}, + ], + }, + { + "heading": "Common hosts", + "description": "These are other common hosts users hit.", + "success": "yes", + "fail": "no", + "subdomains": [ + {"host": "admiraltyapi.azure-api.net"}, + {"host": "aeroapi.flightaware.com"}, + {"host": "airnowapi.org"}, + {"host": "certification.oshwa.org"}, + {"host": "certificationapi.oshwa.org"}, + {"host": "chat.openai.com"}, + {"host": "covidtracking.com"}, {"host": "discord.com"}, + {"host": "enviro.epa.gov"}, + {"host": "flightaware.com"}, + {"host": "hosted.weblate.org"}, {"host": "id.twitch.tv"}, + {"host": "io.adafruit.com"}, + {"host": "jwst.nasa.gov"}, + {"host": "management.azure.com"}, + {"host": "na1.api.riotgames.com"}, {"host": "oauth2.googleapis.com"}, {"host": "opensky-network.org"}, + {"host": "opentdb.com"}, + {"host": "raw.githubusercontent.com"}, + {"host": "site.api.espn.com"}, + {"host": "spreadsheets.google.com"}, + {"host": "twitrss.me"}, {"host": "www.adafruit.com"}, + {"host": "www.alphavantage.co"}, {"host": "www.googleapis.com"}, + {"host": "www.nhc.noaa.gov"}, + {"host": "www.reddit.com"}, {"host": "youtube.googleapis.com"}, ], }, @@ -35,6 +83,7 @@ "fail": "no", "subdomains": [ {"host": "valid-isrgrootx2.letsencrypt.org"}, + {"host": "openaccess-api.clevelandart.org"}, ], }, ] @@ -167,6 +216,12 @@ ssl_context = adafruit_connection_manager.get_radio_ssl_contexts(wifi.radio) connection_manager = adafruit_connection_manager.get_connection_manager(pool) +wifi_ssid = os.getenv("CIRCUITPY_WIFI_SSID") +wifi_password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +while not wifi.radio.connected: + wifi.radio.connect(wifi_ssid, wifi_password) + def check_group(groups, group_name): print(f"\nRunning {group_name}") From b407c02dcd29721c23cea30006fdd1c9940aebd7 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Thu, 15 Feb 2024 13:09:51 -0800 Subject: [PATCH 12/24] isort updates --- adafruit_connection_manager.py | 6 +++--- docs/conf.py | 2 +- tests/close_socket_test.py | 1 + tests/free_socket_test.py | 1 + tests/get_socket_test.py | 2 ++ tests/protocol_test.py | 1 + 6 files changed, 9 insertions(+), 4 deletions(-) diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index c994b37..a404fc2 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -29,17 +29,17 @@ import errno import sys - # typing if not sys.implementation.name == "circuitpython": from typing import Optional, Tuple + from circuitpython_typing.socket import ( CircuitPythonSocketType, - SocketType, - SocketpoolModuleType, InterfaceType, + SocketpoolModuleType, + SocketType, SSLContextType, ) diff --git a/docs/conf.py b/docs/conf.py index 56d656c..2d6ecd8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,9 +4,9 @@ # # SPDX-License-Identifier: MIT +import datetime import os import sys -import datetime sys.path.insert(0, os.path.abspath("..")) diff --git a/tests/close_socket_test.py b/tests/close_socket_test.py index 1456972..9f1afec 100644 --- a/tests/close_socket_test.py +++ b/tests/close_socket_test.py @@ -6,6 +6,7 @@ import mocket import pytest + import adafruit_connection_manager IP = "1.2.3.4" diff --git a/tests/free_socket_test.py b/tests/free_socket_test.py index b6c009a..c2bbcd6 100644 --- a/tests/free_socket_test.py +++ b/tests/free_socket_test.py @@ -6,6 +6,7 @@ import mocket import pytest + import adafruit_connection_manager IP = "1.2.3.4" diff --git a/tests/get_socket_test.py b/tests/get_socket_test.py index b47ef59..ca02cf7 100644 --- a/tests/get_socket_test.py +++ b/tests/get_socket_test.py @@ -5,8 +5,10 @@ """ Protocol Tests """ from unittest import mock + import mocket import pytest + import adafruit_connection_manager IP = "1.2.3.4" diff --git a/tests/protocol_test.py b/tests/protocol_test.py index 20e5425..5a27d79 100644 --- a/tests/protocol_test.py +++ b/tests/protocol_test.py @@ -6,6 +6,7 @@ import mocket import pytest + import adafruit_connection_manager IP = "1.2.3.4" From 4bb1be30d50b98a5f57d15b21958450d16551e1b Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Thu, 15 Feb 2024 13:27:30 -0800 Subject: [PATCH 13/24] Fix docs --- docs/examples.rst | 10 ++++++---- examples/connectionmanager_ssltest.py | 4 ++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index 8e31fae..640f53a 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,8 +1,10 @@ -Simple test +SSL Test ------------ -Ensure your device works with this simple test. +This test runs across the common hosts found in the +`Adafruit Learning System Guides `_ +as well as tests created by `badssl.com `_ -.. literalinclude:: ../examples/connectionmanager_simpletest.py - :caption: examples/connectionmanager_simpletest.py +.. literalinclude:: ../examples/connectionmanager_ssltest.py + :caption: examples/connectionmanager_ssltest.py :linenos: diff --git a/examples/connectionmanager_ssltest.py b/examples/connectionmanager_ssltest.py index c47a69c..7a24456 100644 --- a/examples/connectionmanager_ssltest.py +++ b/examples/connectionmanager_ssltest.py @@ -9,6 +9,8 @@ import adafruit_connection_manager +# built from: +# https://github.com/adafruit/Adafruit_Learning_System_Guides adafruit_groups = [ { "heading": "API hosts", @@ -88,6 +90,8 @@ }, ] +# pulled from: +# https://github.com/chromium/badssl.com/blob/master/domains/misc/badssl.com/dashboard/sets.js badssl_groups = [ { "heading": "Certificate Validation (High Risk)", From f278eabf061669781dc899991e51397501d6b07c Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Thu, 15 Feb 2024 14:14:05 -0800 Subject: [PATCH 14/24] Update example for Pico W error codes and timeout --- examples/connectionmanager_ssltest.py | 34 ++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/examples/connectionmanager_ssltest.py b/examples/connectionmanager_ssltest.py index 7a24456..a7272ad 100644 --- a/examples/connectionmanager_ssltest.py +++ b/examples/connectionmanager_ssltest.py @@ -11,7 +11,7 @@ # built from: # https://github.com/adafruit/Adafruit_Learning_System_Guides -adafruit_groups = [ +ADAFRUIT_GROUPS = [ { "heading": "API hosts", "description": "These are common API hosts users hit.", @@ -92,7 +92,7 @@ # pulled from: # https://github.com/chromium/badssl.com/blob/master/domains/misc/badssl.com/dashboard/sets.js -badssl_groups = [ +BADSSL_GROUPS = [ { "heading": "Certificate Validation (High Risk)", "description": ( @@ -216,6 +216,15 @@ }, ] +COMMON_FAILURE_CODES = [ + "Failed SSL handshake", + "MBEDTLS_ERR_SSL_BAD_HS_SERVER_KEY_EXCHANG", + "MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE", + "MBEDTLS_ERR_X509_CERT_VERIFY_FAILED", + "MBEDTLS_ERR_X509_FATAL_ERROR", +] + + pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) ssl_context = adafruit_connection_manager.get_radio_ssl_contexts(wifi.radio) connection_manager = adafruit_connection_manager.get_connection_manager(pool) @@ -227,6 +236,14 @@ wifi.radio.connect(wifi_ssid, wifi_password) +def common_failuer(exc): + text_value = str(exc) + for common_failures_code in COMMON_FAILURE_CODES: + if common_failures_code in text_value: + return True + return False + + def check_group(groups, group_name): print(f"\nRunning {group_name}") for group in groups: @@ -243,14 +260,19 @@ def check_group(groups, group_name): start_time = time.monotonic() try: socket = connection_manager.get_socket( - host, port, "https:", is_ssl=True, ssl_context=ssl_context + host, + port, + "https:", + is_ssl=True, + ssl_context=ssl_context, + timeout=10, ) connection_manager.close_socket(socket) except RuntimeError as e: exc = e duration = time.monotonic() - start_time - if fail == "yes" and exc and "Failed SSL handshake" in str(exc): + if fail == "yes" and exc and common_failuer(exc): result = "passed" elif success == "yes" and exc is None: result = "passed" @@ -260,5 +282,5 @@ def check_group(groups, group_name): print(f" - {host}:{port} took {duration:.2f} seconds | {result}") -check_group(adafruit_groups, "Adafruit") -check_group(badssl_groups, "BadSSL") +check_group(ADAFRUIT_GROUPS, "Adafruit") +check_group(BADSSL_GROUPS, "BadSSL") From 8cc513bf2c9e288abf0d18fbeefe5d2db7705083 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Fri, 16 Feb 2024 10:13:55 -0800 Subject: [PATCH 15/24] Remove logging --- adafruit_connection_manager.py | 47 ---------------------------------- 1 file changed, 47 deletions(-) diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index a404fc2..4d5e02d 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -44,25 +44,6 @@ ) -# common helpers - - -try: - import adafruit_logging as logging - - logger = logging.getLogger(__name__) -except ImportError: - # pylint: disable=too-few-public-methods - class NullLogger: - """Simple class to throw away log messages.""" - - def __init__(self) -> None: - for log_level in ["debug", "info", "warning", "error", "critical"]: - setattr(NullLogger, log_level, lambda *args, **kwargs: None) - - logger = NullLogger() - - # ssl and pool helpers @@ -119,20 +100,15 @@ def get_radio_socketpool(radio): raise AttributeError("Can not determine class of radio") if class_name not in _global_socketpool: - logger.debug("Detecting radio...") - if class_name == "Radio": - logger.debug(" - Found onboard WiFi") import socketpool # pylint: disable=import-outside-toplevel pool = socketpool.SocketPool(radio) elif class_name == "ESP_SPIcontrol": - logger.debug(" - Found ESP32SPI") import adafruit_esp32spi.adafruit_esp32spi_socket as pool # pylint: disable=import-outside-toplevel elif class_name == "WIZNET5K": - logger.debug(" - Found WIZNET5K") import adafruit_wiznet5k.adafruit_wiznet5k_socket as pool # pylint: disable=import-outside-toplevel else: @@ -151,22 +127,17 @@ def get_radio_ssl_contexts(radio): raise AttributeError("Can not determine class of radio") if class_name not in _global_ssl_contexts: - logger.debug("Detecting radio...") - if class_name == "Radio": - logger.debug(" - Found onboard WiFi") import ssl # pylint: disable=import-outside-toplevel ssl_context = ssl.create_default_context() elif class_name == "ESP_SPIcontrol": - logger.debug(" - Found ESP32SPI") import adafruit_esp32spi.adafruit_esp32spi_socket as pool # pylint: disable=import-outside-toplevel ssl_context = create_fake_ssl_context(pool, radio) elif class_name == "WIZNET5K": - logger.debug(" - Found WIZNET5K") import adafruit_wiznet5k.adafruit_wiznet5k_socket as pool # pylint: disable=import-outside-toplevel # Note: SSL/TLS connections are not supported by the Wiznet5k library at this time @@ -196,12 +167,9 @@ def __init__( self._open_sockets = {} def _free_sockets(self) -> None: - logger.debug("ConnectionManager.free_sockets()") available_sockets = [] for socket, free in self._available_socket.items(): if free: - key = self._get_key_for_socket(socket) - logger.debug(f" found {key}") available_sockets.append(socket) for socket in available_sockets: @@ -217,22 +185,17 @@ def _get_key_for_socket(self, socket): def close_socket(self, socket: SocketType) -> None: """Close a previously opened socket.""" - logger.debug("ConnectionManager.close_socket()") if socket not in self._open_sockets.values(): raise RuntimeError("Socket not managed") key = self._get_key_for_socket(socket) - logger.debug(f" closing {key}") socket.close() del self._available_socket[socket] del self._open_sockets[key] def free_socket(self, socket: SocketType) -> None: """Mark a previously opened socket as available so it can be reused if needed.""" - logger.debug("ConnectionManager.free_socket()") if socket not in self._open_sockets.values(): raise RuntimeError("Socket not managed") - key = self._get_key_for_socket(socket) - logger.debug(f" flagging {key} as free") self._available_socket[socket] = True # pylint: disable=too-many-branches,too-many-locals,too-many-statements @@ -248,16 +211,12 @@ def get_socket( ssl_context: Optional[SSLContextType] = None, ) -> CircuitPythonSocketType: """Get a new socket and connect""" - logger.debug("ConnectionManager.get_socket()") - logger.debug(f" tracking {len(self._open_sockets)} sockets") if session_id: session_id = str(session_id) key = (host, port, proto, session_id) - logger.debug(f" getting socket for {key}") if key in self._open_sockets: socket = self._open_sockets[key] if self._available_socket[socket]: - logger.debug(f" found existing {key}") self._available_socket[socket] = False return socket @@ -280,7 +239,6 @@ def get_socket( while try_count < 2 and socket is None: try_count += 1 if try_count > 1: - logger.debug(f" try #{try_count}") if any( socket for socket, free in self._available_socket.items() @@ -293,11 +251,9 @@ def get_socket( try: socket = self._socket_pool.socket(addr_info[0], addr_info[1]) except OSError as exc: - logger.debug(f" OSError getting socket: {exc}") last_exc = exc continue except RuntimeError as exc: - logger.debug(f" RuntimeError getting socket: {exc}") last_exc = exc continue @@ -311,18 +267,15 @@ def get_socket( try: socket.connect((connect_host, port)) except MemoryError as exc: - logger.debug(f" MemoryError connecting socket: {exc}") last_exc = exc socket.close() socket = None except OSError as exc: - logger.debug(f" OSError connecting socket: {exc}") last_exc = exc socket.close() socket = None if socket is None: - logger.debug(" Error connecting socket") raise RuntimeError(f"Error connecting socket: {last_exc}") from last_exc self._available_socket[socket] = False From 905354917effbb16c3aa9442312f11430a33ae24 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Fri, 16 Feb 2024 14:43:37 -0800 Subject: [PATCH 16/24] Raise proper exception if TLS isn't supported --- adafruit_connection_manager.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index 4d5e02d..77dbe1c 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -74,7 +74,10 @@ def wrap_socket( self, socket: CircuitPythonSocketType, server_hostname: Optional[str] = None ) -> _FakeSSLSocket: """Return the same socket""" - return _FakeSSLSocket(socket, self._iface.TLS_MODE) + if hasattr(self._iface, "TLS_MODE"): + return _FakeSSLSocket(socket, self._iface.TLS_MODE) + + raise AttributeError("This radio does not support TLS/HTTPS") def create_fake_ssl_context( From 398d1a1cbd3971c38a5f4e406bcef98c2396b083 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Fri, 16 Feb 2024 14:43:55 -0800 Subject: [PATCH 17/24] Update connectionmanager_ssltest --- examples/connectionmanager_ssltest.py | 54 ++++++++++++++++++++------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/examples/connectionmanager_ssltest.py b/examples/connectionmanager_ssltest.py index a7272ad..f0ca15c 100644 --- a/examples/connectionmanager_ssltest.py +++ b/examples/connectionmanager_ssltest.py @@ -5,10 +5,29 @@ import os import time -import wifi - import adafruit_connection_manager +try: + import wifi + + radio = wifi.radio + onboard_wifi = True +except ImportError: + import board + import busio + from adafruit_esp32spi import adafruit_esp32spi + from digitalio import DigitalInOut + + # esp32spi pins set based on Adafruit AirLift FeatherWing + # if using a different setup, please change appropriately + spi = busio.SPI(board.SCK, board.MOSI, board.MISO) + esp32_cs = DigitalInOut(board.D13) + esp32_ready = DigitalInOut(board.D11) + esp32_reset = DigitalInOut(board.D12) + radio = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + onboard_wifi = False + + # built from: # https://github.com/adafruit/Adafruit_Learning_System_Guides ADAFRUIT_GROUPS = [ @@ -217,26 +236,35 @@ ] COMMON_FAILURE_CODES = [ - "Failed SSL handshake", - "MBEDTLS_ERR_SSL_BAD_HS_SERVER_KEY_EXCHANG", - "MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE", - "MBEDTLS_ERR_X509_CERT_VERIFY_FAILED", - "MBEDTLS_ERR_X509_FATAL_ERROR", + "Expected 01 but got 00", # AirLift + "Failed SSL handshake", # Espressif + "MBEDTLS_ERR_SSL_BAD_HS_SERVER_KEY_EXCHANG", # mbedtls + "MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE", # mbedtls + "MBEDTLS_ERR_X509_CERT_VERIFY_FAILED", # mbedtls + "MBEDTLS_ERR_X509_FATAL_ERROR", # mbedtls ] -pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) -ssl_context = adafruit_connection_manager.get_radio_ssl_contexts(wifi.radio) +pool = adafruit_connection_manager.get_radio_socketpool(radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_contexts(radio) connection_manager = adafruit_connection_manager.get_connection_manager(pool) wifi_ssid = os.getenv("CIRCUITPY_WIFI_SSID") wifi_password = os.getenv("CIRCUITPY_WIFI_PASSWORD") -while not wifi.radio.connected: - wifi.radio.connect(wifi_ssid, wifi_password) +if onboard_wifi: + while not radio.connected: + radio.connect(wifi_ssid, wifi_password) +else: + while not radio.is_connected: + try: + radio.connect_AP(wifi_ssid, wifi_password) + except OSError as os_exc: + print(f"could not connect to AP, retrying: {os_exc}") + continue -def common_failuer(exc): +def common_failure(exc): text_value = str(exc) for common_failures_code in COMMON_FAILURE_CODES: if common_failures_code in text_value: @@ -272,7 +300,7 @@ def check_group(groups, group_name): exc = e duration = time.monotonic() - start_time - if fail == "yes" and exc and common_failuer(exc): + if fail == "yes" and exc and common_failure(exc): result = "passed" elif success == "yes" and exc is None: result = "passed" From 39b39df0a13afa53d5191dd6277d4cbfeb5218b7 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Sat, 17 Feb 2024 10:53:32 -0800 Subject: [PATCH 18/24] Get test coverage to 100% --- .coveragerc | 8 ++ adafruit_connection_manager.py | 18 +--- examples/connectionmanager_ssltest.py | 2 +- tests/close_socket_test.py | 15 +-- tests/conftest.py | 31 ++++++ tests/fake_ssl_context_test.py | 45 ++++++++ tests/free_socket_test.py | 37 +++---- tests/get_connection_manager_test.py | 18 ++++ tests/get_radio_test.py | 76 ++++++++++++++ tests/get_socket_test.py | 142 ++++++++++++++++++-------- tests/mocket.py | 25 ++++- tests/protocol_test.py | 30 +++--- 12 files changed, 332 insertions(+), 115 deletions(-) create mode 100644 .coveragerc create mode 100644 tests/conftest.py create mode 100644 tests/fake_ssl_context_test.py create mode 100644 tests/get_connection_manager_test.py create mode 100644 tests/get_radio_test.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..9dbafb5 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers +# +# SPDX-License-Identifier: Unlicense + +[report] +exclude_lines = + # pragma: no cover + if not sys.implementation.name == "circuitpython": diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index 77dbe1c..4fd2375 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -81,12 +81,9 @@ def wrap_socket( def create_fake_ssl_context( - socket_pool: SocketpoolModuleType, iface: Optional[InterfaceType] = None + socket_pool: SocketpoolModuleType, iface: InterfaceType ) -> _FakeSSLContext: """Method to return a fake SSL context for when ssl isn't available to import""" - if not iface: - # pylint: disable=protected-access - iface = socket_pool._the_interface socket_pool.set_interface(iface) return _FakeSSLContext(iface) @@ -97,11 +94,7 @@ def create_fake_ssl_context( def get_radio_socketpool(radio): """Helper to get a socket pool for common boards""" - if hasattr(radio, "__class__") and radio.__class__.__name__: - class_name = radio.__class__.__name__ - else: - raise AttributeError("Can not determine class of radio") - + class_name = radio.__class__.__name__ if class_name not in _global_socketpool: if class_name == "Radio": import socketpool # pylint: disable=import-outside-toplevel @@ -122,12 +115,9 @@ def get_radio_socketpool(radio): return _global_socketpool[class_name] -def get_radio_ssl_contexts(radio): +def get_radio_ssl_context(radio): """Helper to get ssl_contexts for common boards""" - if hasattr(radio, "__class__") and radio.__class__.__name__: - class_name = radio.__class__.__name__ - else: - raise AttributeError("Can not determine class of radio") + class_name = radio.__class__.__name__ if class_name not in _global_ssl_contexts: if class_name == "Radio": diff --git a/examples/connectionmanager_ssltest.py b/examples/connectionmanager_ssltest.py index f0ca15c..f55375a 100644 --- a/examples/connectionmanager_ssltest.py +++ b/examples/connectionmanager_ssltest.py @@ -246,7 +246,7 @@ pool = adafruit_connection_manager.get_radio_socketpool(radio) -ssl_context = adafruit_connection_manager.get_radio_ssl_contexts(radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) connection_manager = adafruit_connection_manager.get_connection_manager(pool) wifi_ssid = os.getenv("CIRCUITPY_WIFI_SSID") diff --git a/tests/close_socket_test.py b/tests/close_socket_test.py index 9f1afec..142961d 100644 --- a/tests/close_socket_test.py +++ b/tests/close_socket_test.py @@ -9,23 +9,17 @@ import adafruit_connection_manager -IP = "1.2.3.4" -HOST1 = "wifitest.adafruit.com" -TEXT = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" -RESPONSE = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + TEXT - def test_close_socket(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() mock_pool.socket.return_value = mock_socket_1 connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) # validate socket is tracked - socket = connection_manager.get_socket(HOST1, 80, "http:") - key = (HOST1, 80, "http:", None) + socket = connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") + key = (mocket.MOCK_HOST_1, 80, "http:", None) assert socket == mock_socket_1 assert socket in connection_manager._available_socket assert key in connection_manager._open_sockets @@ -38,8 +32,7 @@ def test_close_socket(): def test_close_socket_not_managed(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2d9bb0a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +""" Setup Tests """ + +import sys + +import mocket + + +# pylint: disable=unused-argument +def set_interface(iface): + """Helper to set the global internet interface""" + + +socketpool_module = type(sys)("socketpool") +socketpool_module.SocketPool = mocket.MocketPool +sys.modules["socketpool"] = socketpool_module + +esp32spi_module = type(sys)("adafruit_esp32spi") +esp32spi_socket_module = type(sys)("adafruit_esp32spi_socket") +esp32spi_socket_module.set_interface = set_interface +sys.modules["adafruit_esp32spi"] = esp32spi_module +sys.modules["adafruit_esp32spi.adafruit_esp32spi_socket"] = esp32spi_socket_module + +wiznet5k_module = type(sys)("adafruit_wiznet5k") +wiznet5k_socket_module = type(sys)("adafruit_wiznet5k_socket") +wiznet5k_socket_module.set_interface = set_interface +sys.modules["adafruit_wiznet5k"] = wiznet5k_module +sys.modules["adafruit_wiznet5k.adafruit_wiznet5k_socket"] = wiznet5k_socket_module diff --git a/tests/fake_ssl_context_test.py b/tests/fake_ssl_context_test.py new file mode 100644 index 0000000..1db0038 --- /dev/null +++ b/tests/fake_ssl_context_test.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +""" Protocol Tests """ + +import mocket +import pytest + +import adafruit_connection_manager + + +def test_connect_https(): + mock_pool = mocket.MocketPool() + mock_socket_1 = mocket.Mocket() + mock_pool.socket.return_value = mock_socket_1 + + radio = mocket.MockRadio.ESP_SPIcontrol() + ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # verify a HTTPS call gets a _FakeSSLSocket + socket = connection_manager.get_socket( + mocket.MOCK_HOST_1, 443, "https:", ssl_context=ssl_context + ) + assert socket != mock_socket_1 + assert socket._socket == mock_socket_1 + assert isinstance(socket, adafruit_connection_manager._FakeSSLSocket) + + +def test_connect_https_not_supported(): + mock_pool = mocket.MocketPool() + mock_socket_1 = mocket.Mocket() + mock_pool.socket.return_value = mock_socket_1 + + radio = mocket.MockRadio.WIZNET5K() + ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # verify a HTTPS call gets a _FakeSSLSocket + with pytest.raises(AttributeError) as context: + connection_manager.get_socket( + mocket.MOCK_HOST_1, 443, "https:", ssl_context=ssl_context + ) + assert "This radio does not support TLS/HTTPS" in str(context) diff --git a/tests/free_socket_test.py b/tests/free_socket_test.py index c2bbcd6..21cbb01 100644 --- a/tests/free_socket_test.py +++ b/tests/free_socket_test.py @@ -2,31 +2,24 @@ # # SPDX-License-Identifier: Unlicense -""" Protocol Tests """ +""" Free Socket Tests """ import mocket import pytest import adafruit_connection_manager -IP = "1.2.3.4" -HOST1 = "wifitest.adafruit.com" -HOST2 = "wifitest2.adafruit.com" -TEXT = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" -RESPONSE = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + TEXT - def test_free_socket(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() mock_pool.socket.return_value = mock_socket_1 connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) # validate socket is tracked and not available - socket = connection_manager.get_socket(HOST1, 80, "http:") - key = (HOST1, 80, "http:", None) + socket = connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") + key = (mocket.MOCK_HOST_1, 80, "http:", None) assert socket == mock_socket_1 assert socket in connection_manager._available_socket assert connection_manager._available_socket[socket] is False @@ -41,8 +34,7 @@ def test_free_socket(): def test_free_socket_not_managed(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) @@ -54,9 +46,8 @@ def test_free_socket_not_managed(): def test_free_sockets(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) - mock_socket_2 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() + mock_socket_2 = mocket.Mocket() mock_pool.socket.side_effect = [ mock_socket_1, mock_socket_2, @@ -65,12 +56,12 @@ def test_free_sockets(): connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) # validate socket is tracked and not available - socket_1 = connection_manager.get_socket(HOST1, 80, "http:") + socket_1 = connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") assert socket_1 == mock_socket_1 assert socket_1 in connection_manager._available_socket assert connection_manager._available_socket[socket_1] is False - socket_2 = connection_manager.get_socket(HOST2, 80, "http:") + socket_2 = connection_manager.get_socket(mocket.MOCK_HOST_2, 80, "http:") assert socket_2 == mock_socket_2 # validate socket is tracked and is available @@ -87,22 +78,20 @@ def test_free_sockets(): def test_get_key_for_socket(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() mock_pool.socket.return_value = mock_socket_1 connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) # validate tracked socket has correct key - socket = connection_manager.get_socket(HOST1, 80, "http:") - key = (HOST1, 80, "http:", None) + socket = connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") + key = (mocket.MOCK_HOST_1, 80, "http:", None) assert connection_manager._get_key_for_socket(socket) == key def test_get_key_for_socket_not_managed(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) diff --git a/tests/get_connection_manager_test.py b/tests/get_connection_manager_test.py new file mode 100644 index 0000000..5714cb8 --- /dev/null +++ b/tests/get_connection_manager_test.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +""" Protocol Tests """ + +import mocket + +import adafruit_connection_manager + + +def test_get_connection_manager(): + mock_pool = mocket.MocketPool() + + connection_manager_1 = adafruit_connection_manager.get_connection_manager(mock_pool) + connection_manager_2 = adafruit_connection_manager.get_connection_manager(mock_pool) + + assert connection_manager_1 == connection_manager_2 diff --git a/tests/get_radio_test.py b/tests/get_radio_test.py new file mode 100644 index 0000000..ea80f7e --- /dev/null +++ b/tests/get_radio_test.py @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +""" Get socketpool and ssl_context Tests """ + +import ssl + +import mocket +import pytest + +import adafruit_connection_manager + + +def test_get_radio_socketpool_wifi(): + radio = mocket.MockRadio.Radio() + socket_pool = adafruit_connection_manager.get_radio_socketpool(radio) + assert isinstance(socket_pool, mocket.MocketPool) + + +def test_get_radio_socketpool_esp32spi(): + radio = mocket.MockRadio.ESP_SPIcontrol() + socket_pool = adafruit_connection_manager.get_radio_socketpool(radio) + assert socket_pool.__name__ == "adafruit_esp32spi_socket" + + +def test_get_radio_socketpool_wiznet5k(): + radio = mocket.MockRadio.WIZNET5K() + socket_pool = adafruit_connection_manager.get_radio_socketpool(radio) + assert socket_pool.__name__ == "adafruit_wiznet5k_socket" + + +def test_get_radio_socketpool_unsupported(): + radio = mocket.MockRadio.Unsupported() + with pytest.raises(AttributeError) as context: + adafruit_connection_manager.get_radio_socketpool(radio) + assert "Unsupported radio class" in str(context) + + +def test_get_radio_socketpool_returns_same_one(): + radio = mocket.MockRadio.Radio() + socket_pool_1 = adafruit_connection_manager.get_radio_socketpool(radio) + socket_pool_2 = adafruit_connection_manager.get_radio_socketpool(radio) + assert socket_pool_1 == socket_pool_2 + + +def test_get_radio_ssl_context_wifi(): + radio = mocket.MockRadio.Radio() + ssl_contexts = adafruit_connection_manager.get_radio_ssl_context(radio) + assert isinstance(ssl_contexts, ssl.SSLContext) + + +def test_get_radio_ssl_context_esp32spi(): + radio = mocket.MockRadio.ESP_SPIcontrol() + ssl_contexts = adafruit_connection_manager.get_radio_ssl_context(radio) + assert isinstance(ssl_contexts, adafruit_connection_manager._FakeSSLContext) + + +def test_get_radio_ssl_context_wiznet5k(): + radio = mocket.MockRadio.WIZNET5K() + ssl_contexts = adafruit_connection_manager.get_radio_ssl_context(radio) + assert isinstance(ssl_contexts, adafruit_connection_manager._FakeSSLContext) + + +def test_get_radio_ssl_context_unsupported(): + radio = mocket.MockRadio.Unsupported() + with pytest.raises(AttributeError) as context: + adafruit_connection_manager.get_radio_ssl_context(radio) + assert "Unsupported radio class" in str(context) + + +def test_get_radio_ssl_context_returns_same_one(): + radio = mocket.MockRadio.Radio() + ssl_contexts_1 = adafruit_connection_manager.get_radio_ssl_context(radio) + ssl_contexts_2 = adafruit_connection_manager.get_radio_ssl_context(radio) + assert ssl_contexts_1 == ssl_contexts_2 diff --git a/tests/get_socket_test.py b/tests/get_socket_test.py index ca02cf7..cee223d 100644 --- a/tests/get_socket_test.py +++ b/tests/get_socket_test.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: Unlicense -""" Protocol Tests """ +""" Get Socket Tests """ from unittest import mock @@ -11,18 +11,11 @@ import adafruit_connection_manager -IP = "1.2.3.4" -HOST1 = "wifitest.adafruit.com" -HOST2 = "wifitest2.adafruit.com" -TEXT = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" -RESPONSE = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + TEXT - def test_get_socket(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) - mock_socket_2 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() + mock_socket_2 = mocket.Mocket() mock_pool.socket.side_effect = [ mock_socket_1, mock_socket_2, @@ -31,15 +24,14 @@ def test_get_socket(): connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) # get socket - socket = connection_manager.get_socket(HOST1, 80, "http:") + socket = connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") assert socket == mock_socket_1 def test_get_socket_different_session(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) - mock_socket_2 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() + mock_socket_2 = mocket.Mocket() mock_pool.socket.side_effect = [ mock_socket_1, mock_socket_2, @@ -48,19 +40,22 @@ def test_get_socket_different_session(): connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) # get socket - socket = connection_manager.get_socket(HOST1, 80, "http:", session_id="1") + socket = connection_manager.get_socket( + mocket.MOCK_HOST_1, 80, "http:", session_id="1" + ) assert socket == mock_socket_1 # get socket on different session - socket = connection_manager.get_socket(HOST1, 80, "http:", session_id="2") + socket = connection_manager.get_socket( + mocket.MOCK_HOST_1, 80, "http:", session_id="2" + ) assert socket == mock_socket_2 def test_get_socket_flagged_free(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) - mock_socket_2 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() + mock_socket_2 = mocket.Mocket() mock_pool.socket.side_effect = [ mock_socket_1, mock_socket_2, @@ -69,20 +64,19 @@ def test_get_socket_flagged_free(): connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) # get a socket and then mark as free - socket = connection_manager.get_socket(HOST1, 80, "http:") + socket = connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") assert socket == mock_socket_1 connection_manager.free_socket(socket) # get a socket for the same host, should be the same one - socket = connection_manager.get_socket(HOST1, 80, "http:") + socket = connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") assert socket == mock_socket_1 def test_get_socket_not_flagged_free(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) - mock_socket_2 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() + mock_socket_2 = mocket.Mocket() mock_pool.socket.side_effect = [ mock_socket_1, mock_socket_2, @@ -91,19 +85,18 @@ def test_get_socket_not_flagged_free(): connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) # get a socket but don't mark as free - socket = connection_manager.get_socket(HOST1, 80, "http:") + socket = connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") assert socket == mock_socket_1 # get a socket for the same host, should be a different one with pytest.raises(RuntimeError) as context: - socket = connection_manager.get_socket(HOST1, 80, "http:") + socket = connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") assert "Socket already connected" in str(context) def test_get_socket_os_error(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() mock_pool.socket.side_effect = [ OSError("OSError"), mock_socket_1, @@ -113,14 +106,13 @@ def test_get_socket_os_error(): # try to get a socket that returns a OSError with pytest.raises(RuntimeError) as context: - connection_manager.get_socket(HOST1, 80, "http:") + connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") assert "Error connecting socket: OSError" in str(context) def test_get_socket_runtime_error(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() mock_pool.socket.side_effect = [ RuntimeError("RuntimeError"), mock_socket_1, @@ -130,15 +122,14 @@ def test_get_socket_runtime_error(): # try to get a socket that returns a RuntimeError with pytest.raises(RuntimeError) as context: - connection_manager.get_socket(HOST1, 80, "http:") + connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") assert "Error connecting socket: RuntimeError" in str(context) def test_get_socket_connect_memory_error(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) - mock_socket_2 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() + mock_socket_2 = mocket.Mocket() mock_pool.socket.side_effect = [ mock_socket_1, mock_socket_2, @@ -149,15 +140,14 @@ def test_get_socket_connect_memory_error(): # try to connect a socket that returns a MemoryError with pytest.raises(RuntimeError) as context: - connection_manager.get_socket(HOST1, 80, "http:") + connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") assert "Error connecting socket: MemoryError" in str(context) def test_get_socket_connect_os_error(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) - mock_socket_2 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() + mock_socket_2 = mocket.Mocket() mock_pool.socket.side_effect = [ mock_socket_1, mock_socket_2, @@ -168,15 +158,14 @@ def test_get_socket_connect_os_error(): # try to connect a socket that returns a OSError with pytest.raises(RuntimeError) as context: - connection_manager.get_socket(HOST1, 80, "http:") + connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") assert "Error connecting socket: OSError" in str(context) def test_get_socket_runtime_error_ties_again_at_least_one_free(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) - mock_socket_2 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() + mock_socket_2 = mocket.Mocket() mock_pool.socket.side_effect = [ mock_socket_1, RuntimeError(), @@ -188,12 +177,75 @@ def test_get_socket_runtime_error_ties_again_at_least_one_free(): connection_manager._free_sockets = free_sockets_mock # get a socket and then mark as free - socket = connection_manager.get_socket(HOST1, 80, "http:") + socket = connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") assert socket == mock_socket_1 connection_manager.free_socket(socket) free_sockets_mock.assert_not_called() # try to get a socket that returns a RuntimeError and at least one is flagged as free - socket = connection_manager.get_socket(HOST2, 80, "http:") + socket = connection_manager.get_socket(mocket.MOCK_HOST_2, 80, "http:") assert socket == mock_socket_2 free_sockets_mock.assert_called_once() + + +def test_get_socket_runtime_error_ties_again_only_once(): + mock_pool = mocket.MocketPool() + mock_socket_1 = mocket.Mocket() + mock_socket_2 = mocket.Mocket() + mock_pool.socket.side_effect = [ + mock_socket_1, + RuntimeError("error 1"), + RuntimeError("error 2"), + RuntimeError("error 3"), + mock_socket_2, + ] + + free_sockets_mock = mock.Mock() + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + connection_manager._free_sockets = free_sockets_mock + + # get a socket and then mark as free + socket = connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") + assert socket == mock_socket_1 + connection_manager.free_socket(socket) + free_sockets_mock.assert_not_called() + + # try to get a socket that returns a RuntimeError twice + with pytest.raises(RuntimeError) as context: + connection_manager.get_socket(mocket.MOCK_HOST_2, 80, "http:") + assert "Error connecting socket: error 2" in str(context) + free_sockets_mock.assert_called_once() + + +def test_fake_ssl_context_connect(): + mock_pool = mocket.MocketPool() + mock_socket_1 = mocket.Mocket() + mock_pool.socket.return_value = mock_socket_1 + + radio = mocket.MockRadio.ESP_SPIcontrol() + ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + # verify a HTTPS call gets a _FakeSSLSocket + socket = connection_manager.get_socket( + mocket.MOCK_HOST_1, 443, "https:", ssl_context=ssl_context + ) + assert socket != mock_socket_1 + socket._socket.connect.assert_called_once() + + +def test_fake_ssl_context_connect_error(): + mock_pool = mocket.MocketPool() + mock_socket_1 = mocket.Mocket() + mock_pool.socket.return_value = mock_socket_1 + mock_socket_1.connect.side_effect = RuntimeError("RuntimeError ") + + radio = mocket.MockRadio.ESP_SPIcontrol() + ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) + connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) + + with pytest.raises(RuntimeError) as context: + connection_manager.get_socket( + mocket.MOCK_HOST_1, 443, "https:", ssl_context=ssl_context + ) + assert "Error connecting socket: 12" in str(context) diff --git a/tests/mocket.py b/tests/mocket.py index 3603800..0045f75 100644 --- a/tests/mocket.py +++ b/tests/mocket.py @@ -6,21 +6,27 @@ from unittest import mock +MOCK_POOL_IP = "10.10.10.10" +MOCK_HOST_1 = "wifitest.adafruit.com" +MOCK_HOST_2 = "wifitest2.adafruit.com" + class MocketPool: # pylint: disable=too-few-public-methods """Mock SocketPool""" SOCK_STREAM = 0 - def __init__(self): + # pylint: disable=unused-argument + def __init__(self, radio=None): self.getaddrinfo = mock.Mock() + self.getaddrinfo.return_value = ((None, None, None, None, (MOCK_POOL_IP, 80)),) self.socket = mock.Mock() class Mocket: # pylint: disable=too-few-public-methods """Mock Socket""" - def __init__(self, response): + def __init__(self, response=None): self.settimeout = mock.Mock() self.close = mock.Mock() self.connect = mock.Mock() @@ -71,3 +77,18 @@ def _wrap_socket( self, sock, server_hostname=None ): # pylint: disable=no-self-use,unused-argument return sock + + +# pylint: disable=too-few-public-methods +class MockRadio: + class Radio: + pass + + class ESP_SPIcontrol: + TLS_MODE = 2 + + class WIZNET5K: + pass + + class Unsupported: + pass diff --git a/tests/protocol_test.py b/tests/protocol_test.py index 5a27d79..98b5296 100644 --- a/tests/protocol_test.py +++ b/tests/protocol_test.py @@ -9,49 +9,43 @@ import adafruit_connection_manager -IP = "1.2.3.4" -HOST1 = "wifitest.adafruit.com" -PATH = "/testwifi/index.html" -TEXT = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" -RESPONSE = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + TEXT - def test_get_https_no_ssl(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() mock_pool.socket.return_value = mock_socket_1 connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) # verify not sending in a SSL context for a HTTPS call errors with pytest.raises(AttributeError) as context: - connection_manager.get_socket(HOST1, 443, "https:") + connection_manager.get_socket(mocket.MOCK_HOST_1, 443, "https:") assert "ssl_context must be set" in str(context) def test_connect_https(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() mock_pool.socket.return_value = mock_socket_1 - ssl = mocket.SSLContext() + mock_ssl_context = mocket.SSLContext() connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) # verify a HTTPS call changes the port to 443 - connection_manager.get_socket(HOST1, 443, "https:", ssl_context=ssl) - mock_socket_1.connect.assert_called_once_with((HOST1, 443)) + connection_manager.get_socket( + mocket.MOCK_HOST_1, 443, "https:", ssl_context=mock_ssl_context + ) + mock_socket_1.connect.assert_called_once_with((mocket.MOCK_HOST_1, 443)) + mock_ssl_context.wrap_socket.assert_called_once() def test_connect_http(): mock_pool = mocket.MocketPool() - mock_pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) - mock_socket_1 = mocket.Mocket(RESPONSE) + mock_socket_1 = mocket.Mocket() mock_pool.socket.return_value = mock_socket_1 connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) # verify a HTTP call does not change the port to 443 - connection_manager.get_socket(HOST1, 80, "http:") - mock_socket_1.connect.assert_called_once_with((IP, 80)) + connection_manager.get_socket(mocket.MOCK_HOST_1, 80, "http:") + mock_socket_1.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) From 449194cf8939560a79b7bc4b4226f4c0ad386c51 Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Sat, 17 Feb 2024 11:32:54 -0800 Subject: [PATCH 19/24] Add more info into docs --- README.rst | 2 ++ adafruit_connection_manager.py | 29 ++++++++++++++++++--- docs/examples.rst | 12 ++++++++- examples/connectionmanager_helpers.py | 37 +++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 examples/connectionmanager_helpers.py diff --git a/README.rst b/README.rst index 0c93a61..25a7836 100644 --- a/README.rst +++ b/README.rst @@ -96,6 +96,8 @@ This library is used internally by libraries like `Adafruit_CircuitPython_Reques `_ and `Adafruit_CircuitPython_MiniMQTT `_ +Usage examples are within the `examples` subfolder of this library. + Documentation ============= API documentation for this library can be found on `Read the Docs `_. diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index 4fd2375..2057477 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -83,7 +83,16 @@ def wrap_socket( def create_fake_ssl_context( socket_pool: SocketpoolModuleType, iface: InterfaceType ) -> _FakeSSLContext: - """Method to return a fake SSL context for when ssl isn't available to import""" + """Method to return a fake SSL context for when ssl isn't available to import + + For example when using a: + + * `Adafruit Ethernet FeatherWing `_ + * `Adafruit AirLift – ESP32 WiFi Co-Processor Breakout Board + `_ + * `Adafruit AirLift FeatherWing – ESP32 WiFi Co-Processor + `_ + """ socket_pool.set_interface(iface) return _FakeSSLContext(iface) @@ -93,7 +102,14 @@ def create_fake_ssl_context( def get_radio_socketpool(radio): - """Helper to get a socket pool for common boards""" + """Helper to get a socket pool for common boards + + Currently supported: + + * Boards with onboard WiFi (ESP32S2, ESP32S3, Pico W, etc) + * Using the ESP32 WiFi Co-Processor (like the Adafruit AirLift) + * Using a WIZ5500 (Like the Adafruit Ethernet FeatherWing) + """ class_name = radio.__class__.__name__ if class_name not in _global_socketpool: if class_name == "Radio": @@ -116,7 +132,14 @@ def get_radio_socketpool(radio): def get_radio_ssl_context(radio): - """Helper to get ssl_contexts for common boards""" + """Helper to get ssl_contexts for common boards + + Currently supported: + + * Boards with onboard WiFi (ESP32S2, ESP32S3, Pico W, etc) + * Using the ESP32 WiFi Co-Processor (like the Adafruit AirLift) + * Using a WIZ5500 (Like the Adafruit Ethernet FeatherWing) + """ class_name = radio.__class__.__name__ if class_name not in _global_ssl_contexts: diff --git a/docs/examples.rst b/docs/examples.rst index 640f53a..3225d55 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,5 +1,15 @@ +Helper example +-------------- + +This shows you how to use some helpers to help simplify code +when writing it for multiple different boards + +.. literalinclude:: ../examples/connectionmanager_helpers.py + :caption: examples/connectionmanager_helpers.py + :linenos: + SSL Test ------------- +-------- This test runs across the common hosts found in the `Adafruit Learning System Guides `_ diff --git a/examples/connectionmanager_helpers.py b/examples/connectionmanager_helpers.py new file mode 100644 index 0000000..62921c0 --- /dev/null +++ b/examples/connectionmanager_helpers.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 Justin Myers +# +# SPDX-License-Identifier: Unlicense + +import os + +import adafruit_requests +import wifi + +import adafruit_connection_manager + +TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html" + +wifi_ssid = os.getenv("CIRCUITPY_WIFI_SSID") +wifi_password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +radio = wifi.radio +while not radio.connected: + radio.connect(wifi_ssid, wifi_password) + +# get the pool and ssl_context from the helpers: +pool = adafruit_connection_manager.get_radio_socketpool(radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) + +# get request session +requests = adafruit_requests.Session(pool, ssl_context) + +# make request +print("-" * 40) +print(f"Fetching from {TEXT_URL}") + +response = requests.get(TEXT_URL) +response_text = response.text +response.close() + +print(f"Text Response {response_text}") +print("-" * 40) From 21549cd50e3de5e157f85c1580fc1954b45451df Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Sat, 17 Feb 2024 13:35:05 -0800 Subject: [PATCH 20/24] Simplify helpers --- adafruit_connection_manager.py | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index 2057477..47c9074 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -113,20 +113,29 @@ def get_radio_socketpool(radio): class_name = radio.__class__.__name__ if class_name not in _global_socketpool: if class_name == "Radio": + import ssl # pylint: disable=import-outside-toplevel + import socketpool # pylint: disable=import-outside-toplevel pool = socketpool.SocketPool(radio) + ssl_context = ssl.create_default_context() elif class_name == "ESP_SPIcontrol": import adafruit_esp32spi.adafruit_esp32spi_socket as pool # pylint: disable=import-outside-toplevel + ssl_context = create_fake_ssl_context(pool, radio) + elif class_name == "WIZNET5K": import adafruit_wiznet5k.adafruit_wiznet5k_socket as pool # pylint: disable=import-outside-toplevel + # Note: SSL/TLS connections are not supported by the Wiznet5k library at this time + ssl_context = create_fake_ssl_context(pool, radio) + else: raise AttributeError(f"Unsupported radio class: {class_name}") _global_socketpool[class_name] = pool + _global_ssl_contexts[class_name] = ssl_context return _global_socketpool[class_name] @@ -141,29 +150,7 @@ def get_radio_ssl_context(radio): * Using a WIZ5500 (Like the Adafruit Ethernet FeatherWing) """ class_name = radio.__class__.__name__ - - if class_name not in _global_ssl_contexts: - if class_name == "Radio": - import ssl # pylint: disable=import-outside-toplevel - - ssl_context = ssl.create_default_context() - - elif class_name == "ESP_SPIcontrol": - import adafruit_esp32spi.adafruit_esp32spi_socket as pool # pylint: disable=import-outside-toplevel - - ssl_context = create_fake_ssl_context(pool, radio) - - elif class_name == "WIZNET5K": - import adafruit_wiznet5k.adafruit_wiznet5k_socket as pool # pylint: disable=import-outside-toplevel - - # Note: SSL/TLS connections are not supported by the Wiznet5k library at this time - ssl_context = create_fake_ssl_context(pool, radio) - - else: - raise AttributeError(f"Unsupported radio class: {class_name}") - - _global_ssl_contexts[class_name] = ssl_context - + get_radio_socketpool(radio) return _global_ssl_contexts[class_name] From e04e887d1d851fdd3be7a403e1e59511b467494f Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Sat, 17 Feb 2024 15:17:59 -0800 Subject: [PATCH 21/24] Fix test descriptions --- tests/close_socket_test.py | 2 +- tests/fake_ssl_context_test.py | 2 +- tests/get_connection_manager_test.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/close_socket_test.py b/tests/close_socket_test.py index 142961d..957cb94 100644 --- a/tests/close_socket_test.py +++ b/tests/close_socket_test.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: Unlicense -""" Protocol Tests """ +""" Close Socket Tests """ import mocket import pytest diff --git a/tests/fake_ssl_context_test.py b/tests/fake_ssl_context_test.py index 1db0038..d5e320a 100644 --- a/tests/fake_ssl_context_test.py +++ b/tests/fake_ssl_context_test.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: Unlicense -""" Protocol Tests """ +""" FakeSLLSocket Tests """ import mocket import pytest diff --git a/tests/get_connection_manager_test.py b/tests/get_connection_manager_test.py index 5714cb8..7f8b609 100644 --- a/tests/get_connection_manager_test.py +++ b/tests/get_connection_manager_test.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: Unlicense -""" Protocol Tests """ +""" Get Connection Manager Tests """ import mocket From e1982103f23598bb6fd93ecece5cfff6a1cb98ad Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Sat, 17 Feb 2024 15:20:32 -0800 Subject: [PATCH 22/24] Fix test descriptions --- tests/fake_ssl_context_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fake_ssl_context_test.py b/tests/fake_ssl_context_test.py index d5e320a..91a3d31 100644 --- a/tests/fake_ssl_context_test.py +++ b/tests/fake_ssl_context_test.py @@ -19,7 +19,7 @@ def test_connect_https(): ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) - # verify a HTTPS call gets a _FakeSSLSocket + # verify a HTTPS call for a board without built in WiFi gets a _FakeSSLSocket socket = connection_manager.get_socket( mocket.MOCK_HOST_1, 443, "https:", ssl_context=ssl_context ) @@ -37,7 +37,7 @@ def test_connect_https_not_supported(): ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) connection_manager = adafruit_connection_manager.ConnectionManager(mock_pool) - # verify a HTTPS call gets a _FakeSSLSocket + # verify a HTTPS call for a board without built in WiFi and SSL support errors with pytest.raises(AttributeError) as context: connection_manager.get_socket( mocket.MOCK_HOST_1, 443, "https:", ssl_context=ssl_context From e7cfca35727afc81a4326573842211248bb2037a Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Sat, 17 Feb 2024 15:32:58 -0800 Subject: [PATCH 23/24] Doc updates --- docs/examples.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index 3225d55..0e1e53b 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,8 +1,13 @@ +Examples +======== + +Below are a few examples, there may be more in the examples folder of the library + Helper example -------------- -This shows you how to use some helpers to help simplify code -when writing it for multiple different boards +This example shows you how to use the ``adafruit_connection_manager`` helpers to help +simplify code when writing it for multiple different boards .. literalinclude:: ../examples/connectionmanager_helpers.py :caption: examples/connectionmanager_helpers.py From 9c6adc70c162f05eef3b3048a4722c79c75e6bcf Mon Sep 17 00:00:00 2001 From: Justin Myers Date: Sun, 18 Feb 2024 09:00:59 -0800 Subject: [PATCH 24/24] Standardise license text --- .coveragerc | 2 +- LICENSE | 2 +- README.rst.license | 2 +- adafruit_connection_manager.py | 2 +- docs/api.rst.license | 2 +- docs/conf.py | 2 +- docs/examples.rst.license | 2 +- docs/index.rst.license | 2 +- examples/connectionmanager_helpers.py | 2 +- examples/connectionmanager_ssltest.py | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/fake_ssl_context_test.py | 2 +- tests/free_socket_test.py | 2 +- tests/get_connection_manager_test.py | 2 +- tests/mocket.py | 1 + tox.ini | 2 +- 17 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.coveragerc b/.coveragerc index 9dbafb5..23ee7ef 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2024 Justin Myers +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: Unlicense diff --git a/LICENSE b/LICENSE index fa6ee38..87fc65e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2023 Justin Myers for Adafruit Industries +Copyright (c) 2024 Justin Myers for Adafruit Industries Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.rst.license b/README.rst.license index 5963138..f69990d 100644 --- a/README.rst.license +++ b/README.rst.license @@ -1,3 +1,3 @@ SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -SPDX-FileCopyrightText: Copyright (c) 2023 Justin Myers for Adafruit Industries +SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries SPDX-License-Identifier: MIT diff --git a/adafruit_connection_manager.py b/adafruit_connection_manager.py index 47c9074..cc70f3f 100644 --- a/adafruit_connection_manager.py +++ b/adafruit_connection_manager.py @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -# SPDX-FileCopyrightText: Copyright (c) 2023 Justin Myers for Adafruit Industries +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: MIT """ diff --git a/docs/api.rst.license b/docs/api.rst.license index ddc59df..95c6363 100644 --- a/docs/api.rst.license +++ b/docs/api.rst.license @@ -1,4 +1,4 @@ SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -SPDX-FileCopyrightText: Copyright (c) 2023 Justin Myers for Adafruit Industries +SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries SPDX-License-Identifier: MIT diff --git a/docs/conf.py b/docs/conf.py index 2d6ecd8..b184b10 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,7 +50,7 @@ # General information about the project. project = "Adafruit CircuitPython ConnectionManager Library" -creation_year = "2023" +creation_year = "2024" current_year = str(datetime.datetime.now().year) year_duration = ( current_year diff --git a/docs/examples.rst.license b/docs/examples.rst.license index ddc59df..95c6363 100644 --- a/docs/examples.rst.license +++ b/docs/examples.rst.license @@ -1,4 +1,4 @@ SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -SPDX-FileCopyrightText: Copyright (c) 2023 Justin Myers for Adafruit Industries +SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries SPDX-License-Identifier: MIT diff --git a/docs/index.rst.license b/docs/index.rst.license index ddc59df..95c6363 100644 --- a/docs/index.rst.license +++ b/docs/index.rst.license @@ -1,4 +1,4 @@ SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -SPDX-FileCopyrightText: Copyright (c) 2023 Justin Myers for Adafruit Industries +SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries SPDX-License-Identifier: MIT diff --git a/examples/connectionmanager_helpers.py b/examples/connectionmanager_helpers.py index 62921c0..36f4af6 100644 --- a/examples/connectionmanager_helpers.py +++ b/examples/connectionmanager_helpers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024 Justin Myers +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: Unlicense diff --git a/examples/connectionmanager_ssltest.py b/examples/connectionmanager_ssltest.py index f55375a..6fa707d 100644 --- a/examples/connectionmanager_ssltest.py +++ b/examples/connectionmanager_ssltest.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024 Justin Myers +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: Unlicense diff --git a/pyproject.toml b/pyproject.toml index f916632..cb04b0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2022 Alec Delaney, written for Adafruit Industries -# SPDX-FileCopyrightText: Copyright (c) 2023 Justin Myers for Adafruit Industries +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: MIT diff --git a/requirements.txt b/requirements.txt index c3539cf..e64002a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -# SPDX-FileCopyrightText: Copyright (c) 2023 Justin Myers for Adafruit Industries +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: MIT diff --git a/tests/fake_ssl_context_test.py b/tests/fake_ssl_context_test.py index 91a3d31..fc566ea 100644 --- a/tests/fake_ssl_context_test.py +++ b/tests/fake_ssl_context_test.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: Unlicense diff --git a/tests/free_socket_test.py b/tests/free_socket_test.py index 21cbb01..93f34eb 100644 --- a/tests/free_socket_test.py +++ b/tests/free_socket_test.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: Unlicense diff --git a/tests/get_connection_manager_test.py b/tests/get_connection_manager_test.py index 7f8b609..0efdbfd 100644 --- a/tests/get_connection_manager_test.py +++ b/tests/get_connection_manager_test.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: Unlicense diff --git a/tests/mocket.py b/tests/mocket.py index 0045f75..6740a1a 100644 --- a/tests/mocket.py +++ b/tests/mocket.py @@ -1,4 +1,5 @@ # SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: Unlicense diff --git a/tox.ini b/tox.ini index c3830ef..74ae4fe 100644 --- a/tox.ini +++ b/tox.ini @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2023 Justin Myers +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: MIT