Skip to content

Commit 283955d

Browse files
committed
Improved type hinting.
Now running mypy with --strict. Fixed issue in PlugListenerUdp's connection retry backoff; it wasn't backing off in the error path correctly. Also fixed a few more potential races of create_task/gc interactions.
1 parent 0813d94 commit 283955d

18 files changed

Lines changed: 251 additions & 183 deletions

‎scripts/run-tests.sh‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ extra=
1111
[ -z "$@" ] && extra="--cov-fail-under=100"
1212

1313
echo "Linting src..."
14-
python3 -m mypy --show-error-codes --show-column-numbers "${rootdir}/src"
14+
python3 -m mypy --strict --show-error-codes --show-column-numbers "${rootdir}/src"
1515

1616
echo "Linting tests..."
17-
mypy "${testsdir}"
17+
python3 -m mypy --strict "${testsdir}"
1818

1919
pytest \
2020
--cov=powersensor_local.xlatemsg \
21+
--cov=powersensor_local.async_event_emitter \
2122
--cov-report term-missing \
2223
--cov-config="${testsdir}/.coveragerc" \
2324
--cache-clear \

‎src/powersensor_local/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
debug aids, which get installed under the names ps-plugevents and ps-rawplug
3434
respectively. There is also the legacy 'events' debug aid which get installed
3535
nder the names ps-events, and offers up the events from PowersensorDevices.
36+
Similarly, 'zc_events' offers up events from PowersensorZeroconfDevices,
37+
installed as 'ps-zcevents', again, for debug use only.
38+
39+
Type hints for emitted data are available in Event/Events/Message.
3640
"""
3741
__all__ = [
3842
'VirtualHousehold',
@@ -43,6 +47,9 @@
4347
'PowersensorDevices',
4448
'PowersensorLegacyDevices',
4549
'PowersensorZeroconfDevices',
50+
'Event',
51+
'Events',
52+
'Message',
4653
]
4754
__version__ = "2.2.1"
4855
from .devices import PowersensorDevices, PowersensorLegacyDevices
@@ -52,3 +59,4 @@
5259
from .plug_listener_udp import PlugListenerUdp
5360
from .virtual_household import VirtualHousehold
5461
from .zeroconf_devices import PowersensorZeroconfDevices
62+
from .xlatemsg import Event, Events, Message

‎src/powersensor_local/abstract_event_handler.py‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import asyncio
33
import signal
44
from abc import ABC, abstractmethod
5+
from types import FrameType
56

67
class AbstractEventHandler(ABC):
78
"""Base class to handle signals and the asyncio loop.
@@ -13,15 +14,15 @@ class AbstractEventHandler(ABC):
1314
"""
1415
exiting: bool = False
1516
@abstractmethod
16-
async def on_exit(self):
17+
async def on_exit(self) -> None:
1718
"""Called when a SIGINT is received.
1819
1920
Subclasses should override this method to perform any cleanup
2021
(e.g. closing connections, flushing buffers). It is awaited before
2122
the handler sets :pyattr:`exiting` to ``True``.
2223
"""
2324

24-
async def _do_exit(self):
25+
async def _do_exit(self) -> None:
2526
"""Internal helper that runs ``on_exit`` and marks the handler as
2627
exiting. This coroutine is scheduled by :py:meth:`__handle_sigint`
2728
when a SIGINT signal arrives.
@@ -30,7 +31,7 @@ async def _do_exit(self):
3031
self.exiting = True
3132

3233
@abstractmethod
33-
async def main(self):
34+
async def main(self) -> None:
3435
"""Main coroutine to be executed by the event loop.
3536
3637
Subclasses must implement this method. It should contain the
@@ -39,7 +40,7 @@ async def main(self):
3940
"""
4041

4142
# Signal handler for Ctrl+C
42-
def register_sigint_handler(self):
43+
def register_sigint_handler(self) -> None:
4344
"""Register the SIGINT (Ctrl‑C) handler.
4445
4546
This method sets :py:meth:`__handle_sigint` as the callback for
@@ -48,21 +49,22 @@ def register_sigint_handler(self):
4849
"""
4950
signal.signal(signal.SIGINT, self.__handle_sigint)
5051

51-
def __handle_sigint(self, signum, frame):
52+
def __handle_sigint(self, signum: int, frame: FrameType | None) -> None:
5253
"""Internal SIGINT callback.
5354
5455
Prints diagnostic information and schedules :py:meth:`_do_exit`
5556
as a task in the running event loop. After the first SIGINT
5657
the default handler is restored to allow a second Ctrl‑C to
5758
terminate immediately.
5859
"""
59-
print(f"\nReceived signal: {signum}")
60-
print(f"Signal name: {signal.Signals(signum).name}")
61-
print(f"Interrupted at: {frame.f_code.co_filename}:{frame.f_lineno}")
60+
if frame is not None:
61+
print(f"\nReceived signal: {signum}")
62+
print(f"Signal name: {signal.Signals(signum).name}")
63+
print(f"Interrupted at: {frame.f_code.co_filename}:{frame.f_lineno}")
6264
signal.signal(signal.SIGINT, signal.SIG_DFL)
63-
asyncio.create_task(self._do_exit())
65+
self._exit_task = asyncio.create_task(self._do_exit())
6466

65-
def run(self):
67+
def run(self) -> None:
6668
"""Start the event loop and execute :py:meth:`main`.
6769
6870
A new event loop is created, the SIGINT handler is registered,
@@ -73,7 +75,7 @@ def run(self):
7375
asyncio.run(self.main())
7476
loop.stop()
7577

76-
async def wait(self, seconds=1):
78+
async def wait(self, seconds: int = 1) -> None:
7779
"""Keep the event loop alive until a SIGINT is received.
7880
7981
Parameters

‎src/powersensor_local/async_event_emitter.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,33 @@
11
"""Small helper class for pub/sub functionality with async handlers."""
22
import logging
3-
from typing import Callable
3+
from typing import Any, Awaitable, Callable
4+
5+
_OpenCallback = Callable[..., Awaitable[None]]
46

57
class AsyncEventEmitter:
68
"""Small helper class for pub/sub functionality with async handlers.
79
An optional Logger can be provided, which will be used to log any
810
unhandled exceptions."""
911
def __init__(self, logger: logging.Logger | None = None):
10-
self._listeners: dict[str,list[Callable]] = {}
12+
self._listeners: dict[str,list[_OpenCallback]] = {}
1113
self._logger = logger
1214

13-
def subscribe(self, event_name: str, callback: Callable):
15+
def subscribe(self, event_name: str, callback: _OpenCallback) -> None:
1416
"""Registers an event handler for the given event key. The handler must
1517
be async. Duplicate registrations are ignored."""
1618
if self._listeners.get(event_name) is None:
1719
self._listeners[event_name] = []
1820
if not callback in self._listeners[event_name]:
1921
self._listeners[event_name].append(callback)
2022

21-
def unsubscribe(self, event_name: str, callback: Callable):
23+
def unsubscribe(self, event_name: str, callback: _OpenCallback) -> None:
2224
"""Unregisters the given event handler from the given event type."""
2325
if self._listeners.get(event_name) is None:
2426
return
2527
if callback in self._listeners[event_name]:
2628
self._listeners[event_name].remove(callback)
2729

28-
async def emit(self, event_name: str, *args):
30+
async def emit(self, event_name: str, *args: Any) -> None:
2931
"""Emits an event to all registered listeners for that event type.
3032
Additional arguments may be supplied with event as appropriate. Each
3133
event handler is awaited before delivering the event to the next.

‎src/powersensor_local/devices.py‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55

66
from datetime import datetime, timezone
77
from enum import Enum
8+
from typing import Any, Callable, Coroutine
89

910
from .legacy_discovery import LegacyDiscovery
1011
from .plug_api import PlugApi
12+
from .xlatemsg import Event
13+
14+
_AsyncCallback = Callable[[Event], Coroutine[None, None, None]]
1115

1216
EXPIRY_CHECK_INTERVAL_S = 30
1317
EXPIRY_TIMEOUT_S = 5 * 60
@@ -46,9 +50,17 @@ class _PowersensorDevicesBase:
4650
4751
**device_found**
4852
A device has been discovered or re-discovered.
53+
4954
Note that due to device hardware limitations, role information is NOT
5055
reliably available at this time, and therefore not included in this
51-
message.
56+
message. There are situations where role information will never become
57+
available, and therefore the API can make no promises otherwise. If a
58+
user requires role information, they must manage that themselves and
59+
also provide a mechanism for handling the situation of a device not
60+
being able to provide role information in the first place. If a device
61+
does supply a role at any point, it should be considered authoritative
62+
and override any user provided value.
63+
5264
``{ event: "device_found", device_type: "plug"|"sensor", mac: "..." }``
5365
5466
**device_lost**
@@ -96,7 +108,7 @@ def __init__(
96108
library emits debug/warning/error messages via this logger. When
97109
None (default) the library is completely silent.
98110
"""
99-
self._event_cb = None
111+
self._event_cb: _AsyncCallback | None = None
100112
self._devices: dict[str, '_PowersensorDevicesBase._Device'] = {}
101113
self._plug_apis: dict[str, PlugApi] = {}
102114
self._timer: '_PowersensorDevicesBase._Timer | None' = None
@@ -107,7 +119,7 @@ def __init__(
107119
# Internal logging helper
108120
# ------------------------------------------------------------------
109121

110-
def _maybe_log(self, level: _LogLevel, msg: str, *args) -> None:
122+
def _maybe_log(self, level: _LogLevel, msg: str, *args: Any) -> None:
111123
"""Emit a log message if a logger was provided at construction."""
112124
if self._logger is None:
113125
return
@@ -191,19 +203,20 @@ async def _plug_lost(self, mac: str) -> None:
191203
# Internal event routing
192204
# ------------------------------------------------------------------
193205

194-
async def _emit_if_subscribed(self, ev: str, mac: str, obj: dict) -> None:
206+
async def _emit_if_subscribed(self, ev: str, mac: str, obj: Event) -> None:
195207
if self._event_cb is None:
196208
return
197209
device = self._devices.get(mac)
198210
if device is not None and device.subscribed:
199211
obj['event'] = ev
200212
await self._event_cb(obj)
201213

202-
async def _reemit(self, ev: str, obj: dict[str, str]) -> None:
203-
mac: str|None = obj.get('mac')
214+
async def _reemit(self, ev: str, obj: Event) -> None:
215+
mac = obj.get('mac')
204216
if mac is None:
205217
self._maybe_log(_LogLevel.WARNING, "Received event '%s' with no MAC address — ignoring", ev)
206218
return
219+
mac = str(mac)
207220
device = self._devices.get(mac)
208221
if device is not None:
209222
device.mark_active()
@@ -264,7 +277,7 @@ def has_expired(self) -> bool:
264277
return delta.total_seconds() > EXPIRY_TIMEOUT_S
265278

266279
class _Timer:
267-
def __init__(self, interval_s: float, callback) -> None:
280+
def __init__(self, interval_s: float, callback: Callable[[], Coroutine[Any, Any, None]]):
268281
self._terminate = False
269282
self._interval = interval_s
270283
self._callback = callback
@@ -300,7 +313,7 @@ def __init__(
300313
super().__init__(relay_now_relaying_for=relay_now_relaying_for, logger=logger)
301314
self._discovery = LegacyDiscovery(bcast_addr)
302315

303-
async def start(self, async_event_cb) -> int:
316+
async def start(self, async_event_cb: _AsyncCallback) -> int:
304317
"""Register the async event callback and scan the local network.
305318
306319
The callback has the form::
@@ -329,7 +342,7 @@ async def rescan(self) -> None:
329342
"""Perform a fresh scan to discover added or moved devices."""
330343
await self._on_scanned(await self._discovery.scan())
331344

332-
async def _on_scanned(self, found: list) -> None:
345+
async def _on_scanned(self, found: list[dict[str,str]]) -> None:
333346
for device in found:
334347
mac = device['id']
335348
ip = device['ip']

‎src/powersensor_local/event_buffer.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ class EventBuffer:
1414
"""
1515
def __init__(self, keep: int):
1616
self._keep = keep
17-
self._evs: list = []
17+
self._evs: list[Any] = []
1818

19-
def find_by_key(self, key: str, value: Any):
19+
def find_by_key(self, key: str, value: Any) -> Any | None:
2020
"""Return the first event that contains ``key`` with the given ``value``.
2121
2222
Parameters
@@ -36,7 +36,7 @@ def find_by_key(self, key: str, value: Any):
3636
return ev
3737
return None
3838

39-
def append(self, ev: dict):
39+
def append(self, ev: dict[str, Any]) -> None:
4040
"""Add an event to the buffer.
4141
4242
If adding the new event would exceed ``self._keep``, the oldest event
@@ -51,7 +51,7 @@ def append(self, ev: dict):
5151
if len(self._evs) > self._keep:
5252
del self._evs[0]
5353

54-
def evict_older(self, key: str, value: float):
54+
def evict_older(self, key: str, value: float) -> None:
5555
"""Remove events that are older than a given timestamp.
5656
5757
Events are considered *older* if they contain ``key`` and its value is

‎src/powersensor_local/events.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from powersensor_local.devices import PowersensorDevices
1010
from powersensor_local.abstract_event_handler import AbstractEventHandler
11+
from powersensor_local.xlatemsg import Event
1112

1213
class EventLoopRunner(AbstractEventHandler):
1314
"""Main logic wrapper."""
@@ -18,11 +19,11 @@ async def on_exit(self) -> None:
1819
if self.devices is not None:
1920
await self.devices.stop()
2021

21-
async def on_message(self, obj) -> None:
22+
async def on_message(self, obj: Event) -> None:
2223
"""Callback for printing received events."""
2324
print(obj)
2425
if obj['event'] == 'device_found':
25-
self.devices.subscribe(obj['mac'])
26+
self.devices.subscribe(str(obj['mac']))
2627

2728
async def main(self) -> None:
2829
if self.devices is None:

‎src/powersensor_local/legacy_discovery.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88
class LegacyDiscovery(asyncio.DatagramProtocol):
99
"""The legacy alternative to using mDNS discovery."""
1010

11-
def __init__(self, broadcast_addr = '<broadcast>'):
11+
def __init__(self, broadcast_addr: str = '<broadcast>'):
1212
"""Initialises a new discovery object.
1313
Optionally takes a specific broadcast address to use.
1414
"""
1515
super().__init__()
16-
self._dst_addr = broadcast_addr
17-
self._found = {}
16+
self._dst_addr: str = broadcast_addr
17+
self._found: dict[str,dict[str,str]] = {}
1818

19-
async def scan(self, timeout_sec = 2.0):
19+
async def scan(self, timeout_sec: float = 2.0) -> list[dict[str,str]]:
2020
"""Scans the local network for discoverable devices.
2121
Returns the list of devices found, with each device represented
2222
in the format:
@@ -45,11 +45,11 @@ async def scan(self, timeout_sec = 2.0):
4545
transport.close()
4646
return list(self._found.values())
4747

48-
def protocol_factory(self):
48+
def protocol_factory(self) -> asyncio.DatagramProtocol:
4949
"""UDP protocol factory."""
5050
return self
5151

52-
def datagram_received(self, data, addr):
52+
def datagram_received(self, data, addr) -> None: # type: ignore
5353
try:
5454
response = json.loads(data.decode('utf-8'))
5555
ip = response['ip']

0 commit comments

Comments
 (0)