diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03522fc..42ec170 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,10 @@ jobs: python -m pip install --upgrade pip pip install poetry poetry install + echo "$(poetry env info --path)/bin" >> $GITHUB_PATH - name: Build the wheel run: poetry build -f wheel + - name: Typecheck with pyright + uses: jakebailey/pyright-action@v1 - name: Run tests run: poetry run pytest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c5cbe9..518cd69 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -56,3 +56,12 @@ repos: # https://pre-commit.com/#top_level-default_language_version language_version: python3.11 args: [--experimental-string-processing] + + - repo: local + hooks: + - id: pyright + name: pyright + language: system + entry: poetry run pyright --verbose + files: .*\.py$ + pass_filenames: false diff --git a/dump_packets.py b/dump_packets.py index 4fbfd9b..0b73f09 100644 --- a/dump_packets.py +++ b/dump_packets.py @@ -115,7 +115,7 @@ async def spy(host: str) -> None: async def redirect( - gem: str, original=Tuple[str, int], redirect=Tuple[str, int] + gem: str, original: Tuple[str, int], redirect: Tuple[str, int] ) -> None: async with aiohttp.ClientSession() as session: async with Monitors() as monitors: diff --git a/greeneye/__init__.py b/greeneye/__init__.py index b83c00c..13012bf 100644 --- a/greeneye/__init__.py +++ b/greeneye/__init__.py @@ -1 +1 @@ -from .monitor import Monitors # noqa: F401 +from .monitor import Monitors # noqa: F401 # type: ignore diff --git a/greeneye/api.py b/greeneye/api.py index edfcdd4..6ade8d9 100644 --- a/greeneye/api.py +++ b/greeneye/api.py @@ -266,20 +266,20 @@ def unpack(format: str | bytes) -> Tuple[Any, ...]: ecm_parser=None, ) -_SET_CT_TYPE = ApiCall[(int, int), None]( +_SET_CT_TYPE = ApiCall[Tuple[int, int], None]( gem_formatter=lambda args: f"^^^C{args[0]:02}TYP{args[1]}", gem_parser=None, ecm_formatter=None, ecm_parser=None, ) -_SET_CT_RANGE = ApiCall[(int, int), None]( +_SET_CT_RANGE = ApiCall[Tuple[int, int], None]( gem_formatter=lambda args: f"^^^C{args[0]:02}RNG{args[1]}", gem_parser=None, ecm_formatter=None, ecm_parser=None, ) -_SET_CT_TYPE_AND_RANGE = ApiCall[(int, int, int), None]( - gem_formatter=None, +_SET_CT_TYPE_AND_RANGE = ApiCall[Tuple[int, int, int], None]( + gem_formatter=None, # type: ignore gem_parser=None, ecm_formatter=lambda args: [ b"\xfc", diff --git a/greeneye/monitor.py b/greeneye/monitor.py index dbc2dab..7644a98 100644 --- a/greeneye/monitor.py +++ b/greeneye/monitor.py @@ -6,7 +6,18 @@ from datetime import datetime, timedelta from enum import Enum from types import TracebackType -from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union +from typing import ( + Awaitable, + Callable, + Dict, + List, + Optional, + ParamSpec, + Tuple, + Type, + TypeVar, + Union, +) import aiohttp from siobrultech_protocols.gem import api @@ -551,7 +562,7 @@ async def _configure_from_settings( # Voltage sensor was created up front # Now update settings if needed and trigger listeners - coroutines = [] + coroutines: list[Awaitable[None]] = [] for temperature_sensor in self.temperature_sensors: coroutines.append(temperature_sensor.handle_settings(settings)) for channel in self.channels: @@ -587,12 +598,10 @@ async def _configure_from_packet(self, packet: Packet) -> None: self._configured = True LOG.info(f"Configured {self.serial_number} from first packet.") - coroutines = [] - for listener in self._listeners: - coroutines.append(_ensure_coroutine(listener)()) - await asyncio.gather(*coroutines) + await _invoke_listeners(self._listeners) async def set_packet_send_interval(self, seconds: int) -> None: + assert self._control await self._control.set_packet_send_interval(seconds) def set_packet_interval(self, seconds: int) -> None: @@ -626,9 +635,11 @@ async def handle_packet(self, packet: Packet) -> None: async def _invoke_listeners(listeners: List[Listener]) -> None: - coroutines = [_ensure_coroutine(listener)() for listener in listeners] + coroutines: list[Awaitable[None]] = [ + _ensure_coroutine(listener)() for listener in listeners + ] if len(coroutines) > 0: - await asyncio.gather(*coroutines) # type: ignore + await asyncio.gather(*coroutines) ServerListener = Callable[[PacketProtocolMessage], Awaitable[None]] @@ -640,7 +651,9 @@ class MonitorProtocolProcessor: packet.""" def __init__(self, listener: ServerListener, send_packet_delay: bool) -> None: - self._consumer_task = asyncio.ensure_future(self._consumer()) + self._consumer_task: asyncio.Task[None] | None = asyncio.ensure_future( + self._consumer() + ) LOG.debug("Packet processor started") self._listener = listener self._queue: asyncio.Queue[PacketProtocolMessage] = asyncio.Queue() @@ -783,7 +796,7 @@ async def _handle_message(self, message: PacketProtocolMessage) -> None: else: if isinstance(message, ConnectionLostMessage): for monitor in self._protocol_to_monitors.pop(protocol_id): - await monitor._set_protocol(None) + await monitor._set_protocol(None) # type: ignore elif isinstance(message, ConnectionMadeMessage): self._protocol_to_monitors[protocol_id] = set() @@ -804,7 +817,9 @@ async def _set_monitor_protocol( ) -> None: protocol_id = id(protocol) self._protocol_to_monitors[protocol_id].add(monitor) - await monitor._set_protocol(protocol, api_timeout=self._api_timeout) + await monitor._set_protocol( # type: ignore + protocol, api_timeout=self._api_timeout + ) async def _notify_new_monitor(self, monitor: Monitor) -> None: listeners = [ @@ -814,12 +829,18 @@ async def _notify_new_monitor(self, monitor: Monitor) -> None: await asyncio.gather(*listeners) # type: ignore -def _ensure_coroutine(listener): +P = ParamSpec("P") +R = TypeVar("R") + + +def _ensure_coroutine( + listener: Union[Callable[P, Awaitable[R]], Callable[P, R]] +) -> Callable[P, Awaitable[R]]: if inspect.iscoroutinefunction(listener): return listener else: - async def async_listener(*args): - listener(*args) + async def async_listener(*args: P.args, **kwargs: P.kwargs) -> R: + return listener(*args, **kwargs) # type: ignore return async_listener diff --git a/poetry.lock b/poetry.lock index 66f3b41..5ed1ad3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -435,6 +435,20 @@ files = [ {file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"}, ] +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + [[package]] name = "packaging" version = "23.1" @@ -461,6 +475,24 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pyright" +version = "1.1.325" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyright-1.1.325-py3-none-any.whl", hash = "sha256:8f3ab88ba4843f053ab5b5c886d676161aba6f446776bfb57cc0434ed4d88672"}, + {file = "pyright-1.1.325.tar.gz", hash = "sha256:879a3f66944ffd59d3facd54872fed814830fed64daf3e8eb71b146ddd83bb67"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" + +[package.extras] +all = ["twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] + [[package]] name = "pytest" version = "7.4.1" @@ -523,6 +555,22 @@ files = [ {file = "ruff-0.0.287.tar.gz", hash = "sha256:02dc4f5bf53ef136e459d467f3ce3e04844d509bc46c025a05b018feb37bbc39"}, ] +[[package]] +name = "setuptools" +version = "68.1.2" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-68.1.2-py3-none-any.whl", hash = "sha256:3d8083eed2d13afc9426f227b24fd1659489ec107c0e86cec2ffdde5c92e790b"}, + {file = "setuptools-68.1.2.tar.gz", hash = "sha256:3d4dfa6d95f1b101d695a6160a7626e15583af71a5f52176efa5d39a054d475d"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5,<=7.1.2)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "siobrultech-protocols" version = "0.12.0" @@ -635,4 +683,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "459feb300e05916de2f2c1e066897aecd2f6c3cfb5591308a6e7f2161c2f807f" +content-hash = "3ee402948be74b384732d196f820f712bd074f21257aeb7b5784c22dcc1cddf4" diff --git a/pyproject.toml b/pyproject.toml index b41b8f2..760c531 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,5 +28,11 @@ siobrultech-protocols = "0.12" [tool.poetry.group.test.dependencies] ruff = "*" +pyright = "*" pytest = "*" pytest-socket = "*" + +[tool.pyright] +include = ["greeneye", "tests", "dump_packets.py"] +pythonVersion = "3.10" +typeCheckingMode = "strict" diff --git a/tests/packet_test_data.py b/tests/packet_test_data.py index 9e8a8fc..6f20eeb 100644 --- a/tests/packet_test_data.py +++ b/tests/packet_test_data.py @@ -2,6 +2,8 @@ import os from io import StringIO +from siobrultech_protocols.gem.packets import Packet + greeneye_dir = os.path.dirname(os.path.abspath(__file__)) greeneye_data_dir = os.path.join(greeneye_dir, "data") @@ -779,7 +781,7 @@ } -def read_packets(packet_file_names): +def read_packets(packet_file_names: list[str]) -> bytes: result = bytearray() for packet_file_name in packet_file_names: result.extend(read_packet(packet_file_name)) @@ -787,12 +789,12 @@ def read_packets(packet_file_names): return bytes(result) -def read_packet(packet_file_name): +def read_packet(packet_file_name: str) -> bytes: with open(os.path.join(greeneye_data_dir, packet_file_name), "rb") as data_file: return data_file.read() -def assert_packet(packet_file_name, parsed_packet): +def assert_packet(packet_file_name: str, parsed_packet: Packet): expected_packet = PACKETS[packet_file_name] expected = StringIO() diff --git a/tests/test_api.py b/tests/test_api.py index 3e9c99d..fae5492 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,14 +3,17 @@ from siobrultech_protocols.gem.packets import PacketFormatType -from greeneye.api import TemperatureUnit, _parse_all_settings +from greeneye.api import _parse_all_settings # type: ignore +from greeneye.api import GemSettings, TemperatureUnit class TestAllSettings(unittest.TestCase): def setUp(self): - self.settings = _parse_all_settings( + settings = _parse_all_settings( "ALL\r\n00,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,00,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,D3,D3,D3,D2,D3,D3,D4,D3,D3,D3,D3,D3,D3,D3,D3,D2,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D2,D3,D3,D2,90,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,44,44,44,43,44,44,44,44,44,44,44,44,44,44,44,24,44,44,44,44,44,44,44,44,89,03,08,05,00,00,1E,00,87,00,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,18,26,A6,80,C7,18,25,81,87,C6,18,25,A4,40,27,F9,86,81,BF,78,6E,25,87,4E,84,88,4E,85,89,A6,8A,B0,83,C7,04,EB,C7,05,1B,A6,00,B2,82,C7,04,EA,C7,05,1A,55,82,D6,00,DC,B7,86,CD,04,B0,AF,01,8B,89,55,00,00,A6,20,00,02,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,AF,FF,35,84,65,DD,00,00,00,00,00,00,00,0F,00,00,00,00,00,01,01,00,00,01,07,FF,01,50,17,70,20,7F,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00\r\n" ) + assert settings + self.settings: GemSettings = settings def testPacketFormat(self): self.assertEqual(PacketFormatType.BIN32_NET, self.settings.packet_format) @@ -31,7 +34,9 @@ def testNotNetMetering(self): self.assertFalse(self.settings.channel_net_metering[0]) def testBadPacketFormat(self): - self.settings = _parse_all_settings( + settings = _parse_all_settings( "ALL\r\n00,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,00,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,40,D3,D3,D3,D2,D3,D3,D4,D3,D3,D3,D3,D3,D3,D3,D3,D2,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D2,D3,D3,D2,90,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,D3,44,44,44,43,44,44,44,44,44,44,44,44,44,44,44,24,44,44,44,44,44,44,44,44,89,03,0A,05,00,00,1E,00,87,00,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,01,18,26,A6,80,C7,18,25,81,87,C6,18,25,A4,40,27,F9,86,81,BF,78,6E,25,87,4E,84,88,4E,85,89,A6,8A,B0,83,C7,04,EB,C7,05,1B,A6,00,B2,82,C7,04,EA,C7,05,1A,55,82,D6,00,DC,B7,86,CD,04,B0,AF,01,8B,89,55,00,00,A6,20,00,02,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,AF,FF,35,84,65,DD,00,00,00,00,00,00,00,0F,00,00,00,00,00,01,01,00,00,01,07,FF,01,50,17,70,20,7F,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00\r\n" ) + assert settings + self.settings = settings self.assertIsNone(self.settings.packet_format) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index a11cb84..8d7b109 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -3,7 +3,7 @@ import socket import unittest from datetime import timedelta -from typing import Optional, cast +from typing import Callable, Optional, cast import pytest from siobrultech_protocols.gem.packets import PacketFormatType @@ -85,7 +85,9 @@ async def testMonitorConfiguredProperlyWhenClientIgnoresAPI(self): functools.partial(ApiUnawareClient, packet="BIN32-NET.bin") ) - async def assertMonitorConfiguredProperlyWithClient(self, client): + async def assertMonitorConfiguredProperlyWithClient( + self, client: Callable[[], asyncio.Protocol] + ): loop = asyncio.get_event_loop() async with Monitors( send_packet_delay=False, api_timeout=timedelta(seconds=0) @@ -129,7 +131,9 @@ async def testMonitorConfiguredProperlyWhenClientIgnoresAPI(self) -> None: functools.partial(ApiUnawareClient, packet="ECM-1240.bin") ) - async def assertMonitorConfiguredProperlyWithClient(self, client): + async def assertMonitorConfiguredProperlyWithClient( + self, client: Callable[[], asyncio.Protocol] + ): loop = asyncio.get_event_loop() async with Monitors( send_packet_delay=False, api_timeout=timedelta(seconds=0)