Skip to content

Commit cd719ea

Browse files
committed
Create ConnectionManager
1 parent 8cc811e commit cd719ea

File tree

8 files changed

+457
-26
lines changed

8 files changed

+457
-26
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,10 @@ _build
4646
.idea
4747
.vscode
4848
*~
49+
50+
# tox-specific files
51+
.tox
52+
build
53+
54+
# coverage-specific files
55+
.coverage

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@ repos:
3939
types: [python]
4040
files: "^tests/"
4141
args:
42-
- --disable=missing-docstring,consider-using-f-string,duplicate-code
42+
- --disable=missing-docstring,invalid-name,consider-using-f-string,duplicate-code

README.rst

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,13 @@ This is easily achieved by downloading
3636
or individual libraries can be installed using
3737
`circup <https://github.com/adafruit/circup>`_.
3838

39-
40-
41-
.. todo:: Describe the Adafruit product this library works with. For PCBs, you can also add the
42-
image from the assets folder in the PCB's GitHub repo.
43-
4439
`Purchase one from the Adafruit shop <http://www.adafruit.com/products/>`_
4540

4641
Installing from PyPI
4742
=====================
4843
.. note:: This library is not available on PyPI yet. Install documentation is included
4944
as a standard element. Stay tuned for PyPI availability!
5045

51-
.. todo:: Remove the above note if PyPI version is/will be available at time of release.
52-
5346
On supported GNU/Linux systems like the Raspberry Pi, you can install the driver locally `from
5447
PyPI <https://pypi.org/project/adafruit-circuitpython-connectionmanager/>`_.
5548
To install for current user:
@@ -99,8 +92,9 @@ Or the following command to update an existing version:
9992
Usage Example
10093
=============
10194

102-
.. todo:: Add a quick, simple example. It and other examples should live in the
103-
examples folder and be included in docs/examples.rst.
95+
This library is used internally by libraries like `Adafruit_CircuitPython_Requests
96+
<https://github.com/adafruit/Adafruit_CircuitPython_Requests>`_ and `Adafruit_CircuitPython_MiniMQTT
97+
<https://github.com/adafruit/Adafruit_CircuitPython_MiniMQTT>`_
10498

10599
Documentation
106100
=============

adafruit_connectionmanager.py

Lines changed: 283 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,297 @@
1414
Implementation Notes
1515
--------------------
1616
17-
**Hardware:**
18-
19-
.. todo:: Add links to any specific hardware product page(s), or category page(s).
20-
Use unordered list & hyperlink rST inline format: "* `Link Text <url>`_"
21-
2217
**Software and Dependencies:**
2318
2419
* Adafruit CircuitPython firmware for the supported boards:
2520
https://circuitpython.org/downloads
2621
27-
.. todo:: Uncomment or remove the Bus Device and/or the Register library dependencies
28-
based on the library's use of either.
29-
30-
# * Adafruit's Bus Device library: https://github.com/adafruit/Adafruit_CircuitPython_BusDevice
31-
# * Adafruit's Register library: https://github.com/adafruit/Adafruit_CircuitPython_Register
3222
"""
3323

3424
# imports
3525

3626
__version__ = "0.0.0+auto.0"
3727
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_ConnectionManager.git"
28+
29+
import errno
30+
import sys
31+
32+
if not sys.implementation.name == "circuitpython":
33+
from ssl import SSLContext
34+
from types import ModuleType
35+
from typing import Any, Optional, Tuple, Union
36+
37+
try:
38+
from typing import Protocol
39+
except ImportError:
40+
from typing_extensions import Protocol
41+
42+
# Based on https://github.com/python/typeshed/blob/master/stdlib/_socket.pyi
43+
class CommonSocketType(Protocol):
44+
"""Describes the common structure every socket type must have."""
45+
46+
def send(self, data: bytes, flags: int = ...) -> None:
47+
"""Send data to the socket. The meaning of the optional flags kwarg is
48+
implementation-specific."""
49+
50+
def settimeout(self, value: Optional[float]) -> None:
51+
"""Set a timeout on blocking socket operations."""
52+
53+
def close(self) -> None:
54+
"""Close the socket."""
55+
56+
class CommonCircuitPythonSocketType(CommonSocketType, Protocol):
57+
"""Describes the common structure every CircuitPython socket type must have."""
58+
59+
def connect(
60+
self,
61+
address: Tuple[str, int],
62+
conntype: Optional[int] = ...,
63+
) -> None:
64+
"""Connect to a remote socket at the provided (host, port) address. The conntype
65+
kwarg optionally may indicate SSL or not, depending on the underlying interface.
66+
"""
67+
68+
class SupportsRecvWithFlags(Protocol):
69+
"""Describes a type that posseses a socket recv() method supporting the flags kwarg."""
70+
71+
def recv(self, bufsize: int = ..., flags: int = ...) -> bytes:
72+
"""Receive data from the socket. The return value is a bytes object representing
73+
the data received. The maximum amount of data to be received at once is specified
74+
by bufsize. The meaning of the optional flags kwarg is implementation-specific.
75+
"""
76+
77+
class SupportsRecvInto(Protocol):
78+
"""Describes a type that possesses a socket recv_into() method."""
79+
80+
def recv_into(
81+
self, buffer: bytearray, nbytes: int = ..., flags: int = ...
82+
) -> int:
83+
"""Receive up to nbytes bytes from the socket, storing the data into the provided
84+
buffer. If nbytes is not specified (or 0), receive up to the size available in the
85+
given buffer. The meaning of the optional flags kwarg is implementation-specific.
86+
Returns the number of bytes received."""
87+
88+
class CircuitPythonSocketType(
89+
CommonCircuitPythonSocketType,
90+
SupportsRecvInto,
91+
SupportsRecvWithFlags,
92+
Protocol,
93+
):
94+
"""Describes the structure every modern CircuitPython socket type must have."""
95+
96+
class StandardPythonSocketType(
97+
CommonSocketType, SupportsRecvInto, SupportsRecvWithFlags, Protocol
98+
):
99+
"""Describes the structure every standard Python socket type must have."""
100+
101+
def connect(self, address: Union[Tuple[Any, ...], str, bytes]) -> None:
102+
"""Connect to a remote socket at the provided address."""
103+
104+
SocketType = Union[
105+
CircuitPythonSocketType,
106+
StandardPythonSocketType,
107+
]
108+
109+
SocketpoolModuleType = ModuleType
110+
111+
class InterfaceType(Protocol):
112+
"""Describes the structure every interface type must have."""
113+
114+
@property
115+
def TLS_MODE(self) -> int: # pylint: disable=invalid-name
116+
"""Constant representing that a socket's connection mode is TLS."""
117+
118+
SSLContextType = Union[SSLContext, "_FakeSSLContext"]
119+
120+
121+
class SocketGetOSError(OSError):
122+
"""ConnectionManager Exception class."""
123+
124+
125+
class SocketGetRuntimeError(RuntimeError):
126+
"""ConnectionManager Exception class."""
127+
128+
129+
class SocketConnectMemoryError(OSError):
130+
"""ConnectionManager Exception class."""
131+
132+
133+
class SocketConnectOSError(OSError):
134+
"""ConnectionManager Exception class."""
135+
136+
137+
class _FakeSSLSocket:
138+
def __init__(self, socket: CircuitPythonSocketType, tls_mode: int) -> None:
139+
self._socket = socket
140+
self._mode = tls_mode
141+
self.settimeout = socket.settimeout
142+
self.send = socket.send
143+
self.recv = socket.recv
144+
self.close = socket.close
145+
self.recv_into = socket.recv_into
146+
147+
def connect(self, address: Tuple[str, int]) -> None:
148+
"""connect wrapper to add non-standard mode parameter"""
149+
try:
150+
return self._socket.connect(address, self._mode)
151+
except RuntimeError as error:
152+
raise OSError(errno.ENOMEM) from error
153+
154+
155+
class _FakeSSLContext:
156+
def __init__(self, iface: InterfaceType) -> None:
157+
self._iface = iface
158+
159+
def wrap_socket(
160+
self, socket: CircuitPythonSocketType, server_hostname: Optional[str] = None
161+
) -> _FakeSSLSocket:
162+
"""Return the same socket"""
163+
# pylint: disable=unused-argument
164+
return _FakeSSLSocket(socket, self._iface.TLS_MODE)
165+
166+
167+
def create_fake_ssl_context(
168+
socket_pool: SocketpoolModuleType, iface: Optional[InterfaceType] = None
169+
) -> _FakeSSLContext:
170+
"""Legacy API for creating a fake SSL context"""
171+
if not iface:
172+
# pylint: disable=protected-access
173+
iface = socket_pool._the_interface
174+
socket_pool.set_interface(iface)
175+
return _FakeSSLContext(iface)
176+
177+
178+
class ConnectionManager:
179+
"""Connection manager for sharing sockets."""
180+
181+
def __init__(
182+
self,
183+
socket_pool: SocketpoolModuleType,
184+
) -> None:
185+
self._socket_pool = socket_pool
186+
# Hang onto open sockets so that we can reuse them.
187+
self._open_sockets = {}
188+
self._socket_free = {}
189+
190+
def _free_sockets(self) -> None:
191+
free_sockets = []
192+
for socket, val in self._socket_free.items():
193+
if val:
194+
free_sockets.append(socket)
195+
196+
for socket in free_sockets:
197+
self.close_socket(socket)
198+
199+
def free_socket(self, socket: SocketType) -> None:
200+
"""Mark a socket as free so it can be reused if needed"""
201+
if socket not in self._open_sockets.values():
202+
raise RuntimeError("Socket not from session")
203+
self._socket_free[socket] = True
204+
205+
def close_socket(self, socket: SocketType) -> None:
206+
"""Close a socket"""
207+
socket.close()
208+
del self._socket_free[socket]
209+
key = None
210+
for k, value in self._open_sockets.items():
211+
if value == socket:
212+
key = k
213+
break
214+
if key:
215+
del self._open_sockets[key]
216+
217+
# pylint: disable=too-many-locals,too-many-statements
218+
def get_socket(
219+
self,
220+
host: str,
221+
port: int,
222+
proto: str,
223+
*,
224+
timeout: float = 1,
225+
is_ssl: bool = False,
226+
ssl_context: Optional[SSLContextType] = None,
227+
max_retries: int = 5,
228+
exception_passthrough: bool = False,
229+
) -> CircuitPythonSocketType:
230+
"""Get socket and connect"""
231+
# pylint: disable=too-many-branches
232+
key = (host, port, proto)
233+
if key in self._open_sockets:
234+
socket = self._open_sockets[key]
235+
if self._socket_free[socket]:
236+
self._socket_free[socket] = False
237+
return socket
238+
239+
if proto == "https:":
240+
is_ssl = True
241+
if is_ssl and not ssl_context:
242+
raise RuntimeError(
243+
"ssl_context must be set before using adafruit_requests for https"
244+
)
245+
246+
addr_info = self._socket_pool.getaddrinfo(
247+
host, port, 0, self._socket_pool.SOCK_STREAM
248+
)[0]
249+
250+
retry_count = 0
251+
socket = None
252+
last_exc = None
253+
last_exc_new_type = None
254+
while retry_count < max_retries and socket is None:
255+
if retry_count > 0:
256+
if any(self._socket_free.items()):
257+
self._free_sockets()
258+
else:
259+
raise RuntimeError("Sending request failed") from last_exc
260+
retry_count += 1
261+
262+
try:
263+
socket = self._socket_pool.socket(addr_info[0], addr_info[1])
264+
except OSError as exc:
265+
last_exc_new_type = SocketGetOSError
266+
last_exc = exc
267+
continue
268+
except RuntimeError as exc:
269+
last_exc_new_type = SocketGetRuntimeError
270+
last_exc = exc
271+
continue
272+
273+
connect_host = addr_info[-1][0]
274+
if is_ssl:
275+
socket = ssl_context.wrap_socket(socket, server_hostname=host)
276+
connect_host = host
277+
socket.settimeout(timeout) # socket read timeout
278+
279+
try:
280+
socket.connect((connect_host, port))
281+
except MemoryError as exc:
282+
last_exc_new_type = SocketConnectMemoryError
283+
last_exc = exc
284+
socket.close()
285+
socket = None
286+
except OSError as exc:
287+
last_exc_new_type = SocketConnectOSError
288+
last_exc = exc
289+
socket.close()
290+
socket = None
291+
292+
if socket is None:
293+
if exception_passthrough:
294+
raise last_exc_new_type("Repeated socket failures") from last_exc
295+
raise RuntimeError("Repeated socket failures") from last_exc
296+
297+
self._open_sockets[key] = socket
298+
self._socket_free[socket] = False
299+
return socket
300+
301+
302+
_global_connection_manager = None # pylint: disable=invalid-name
303+
304+
305+
def get_connection_manager(socket_pool: SocketpoolModuleType) -> None:
306+
"""Get the ConnectionManager singleton"""
307+
global _global_connection_manager # pylint: disable=global-statement
308+
if _global_connection_manager is None:
309+
_global_connection_manager = ConnectionManager(socket_pool)
310+
return _global_connection_manager

docs/index.rst

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,9 @@ Table of Contents
2424
.. toctree::
2525
:caption: Tutorials
2626

27-
.. todo:: Add any Learn guide links here. If there are none, then simply delete this todo and leave
28-
the toctree above for use later.
29-
3027
.. toctree::
3128
:caption: Related Products
3229

33-
.. todo:: Add any product links here. If there are none, then simply delete this todo and leave
34-
the toctree above for use later.
35-
3630
.. toctree::
3731
:caption: Other Links
3832

0 commit comments

Comments
 (0)