From 096d76d0c67c9c1a637fb0ddf24b5d9a73395fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Mon, 28 Sep 2026 15:49:02 +0200 Subject: [PATCH] Bump ruff and adjust code for new rules --- letpot/converters.py | 31 +++++++++++---------------- letpot/deviceclient.py | 45 +++++++++++++++++++++------------------ poetry.lock | 42 ++++++++++++++++++------------------ pyproject.toml | 2 +- tests/__init__.py | 12 +++++------ tests/test_converter.py | 2 +- tests/test_models_auth.py | 18 +++++++++++----- 7 files changed, 79 insertions(+), 73 deletions(-) diff --git a/letpot/converters.py b/letpot/converters.py index 35e1227..961dc38 100644 --- a/letpot/converters.py +++ b/letpot/converters.py @@ -3,8 +3,8 @@ import logging import math from abc import ABC, abstractmethod -from datetime import datetime, time, timedelta -from typing import Sequence +from collections.abc import Sequence +from datetime import UTC, datetime, time, timedelta from aiomqtt.types import PayloadType @@ -47,7 +47,6 @@ def __init__(self, device_type: str) -> None: @abstractmethod def supports_type(device_type: str) -> bool: """Returns if the converter supports the supplied type.""" - pass @abstractmethod def get_device_model(self) -> tuple[str, str] | None: @@ -60,22 +59,18 @@ def supported_features(self) -> DeviceFeature: @abstractmethod def get_current_status_message(self) -> list[int]: """Returns the message content for getting the current device status.""" - pass @abstractmethod def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | None: """Converts a hexadecimal bytes status message to a status dataclass.""" - pass @abstractmethod def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: """Returns the message content for updating the device status.""" - pass @abstractmethod def get_light_brightness_levels(self) -> list[int]: """Returns the brightness steps supported by the device for this converter.""" - pass def _hex_bytes_to_int_array(self, hex_message: PayloadType) -> list[int] | None: """Converts a hexadecimal bytes message to a list of integers.""" @@ -148,7 +143,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No if self._device_type in ["LPH21", "LPH22"]: error_pump_malfunction = None else: - error_pump_malfunction = True if data[7] & 2 else False + error_pump_malfunction = bool(data[7] & 2) return LetPotGardenStatus( raw=data, @@ -164,7 +159,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No system_on=data[8] == 1, system_sound=data[20] == 1 if data[20] is not None else None, errors=LetPotDeviceErrors( - low_water=True if data[7] & 1 else False, + low_water=bool(data[7] & 1), pump_malfunction=error_pump_malfunction, ), ) @@ -227,7 +222,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No if self._device_type == "IGS01": error_low_water = None else: - error_low_water = True if data[7] & 1 else False + error_low_water = bool(data[7] & 1) return LetPotGardenStatus( raw=data, @@ -320,9 +315,9 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No system_on=data[8] == 1, system_sound=data[25] == 1 if data[25] is not None else None, errors=LetPotDeviceErrors( - low_water=True if data[7] & 2 else False, - low_nutrients=True if data[7] & 1 else False, - refill_error=True if data[7] & 4 else False, + low_water=bool(data[7] & 2), + low_nutrients=bool(data[7] & 1), + refill_error=bool(data[7] & 4), ), temperature_unit=TemperatureUnit(data[24]), temperature_value=256 * data[22] + data[23], @@ -400,9 +395,9 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No system_on=data[8] == 1, system_sound=None, errors=LetPotDeviceErrors( - low_water=True if data[7] & 2 else False, - low_nutrients=True if data[7] & 1 else False, - refill_error=True if data[7] & 4 else False, + low_water=bool(data[7] & 2), + low_nutrients=bool(data[7] & 1), + refill_error=bool(data[7] & 4), ), temperature_unit=TemperatureUnit(data[24]), temperature_value=256 * data[22] + data[23], @@ -467,7 +462,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No else: pump_cycle_skipwater = math.floor((256 * data[35] + data[36]) / 60) - now = datetime.now() + now = datetime.now(tz=UTC) if (seconds := int.from_bytes(data[12:16], byteorder="big")) == 0: pump_works_end = None else: @@ -487,7 +482,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No raw=data, pump_mode=data[9], errors=LetPotDeviceErrors( - low_water=True if data[7] & 1 else False, + low_water=bool(data[7] & 1), ), wifi_state=data[6], pump_on=data[8] == 1, diff --git a/letpot/deviceclient.py b/letpot/deviceclient.py index 3129326..8a0f896 100644 --- a/letpot/deviceclient.py +++ b/letpot/deviceclient.py @@ -6,11 +6,11 @@ import os import ssl import time as systime -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine from datetime import time from functools import wraps from hashlib import md5, sha256 -from typing import Any, Callable, ParamSpec, TypeVar, cast +from typing import Any, ParamSpec, TypeVar, cast import aiomqtt @@ -83,29 +83,30 @@ def _create_ssl_context() -> ssl.SSLContext: class LetPotDeviceClient: """Client for connecting to LetPot device.""" - AUTH_ERROR_RC = [4, 5, 134, 135] + AUTH_ERROR_RC = (4, 5, 134, 135) BROKER_HOST = "broker.letpot.net" MTU = 128 _client: aiomqtt.Client | None = None _client_task: asyncio.Task | None = None _connected: asyncio.Future[bool] | None = None - _topics: list[str] = [] _message_id: int = 0 _user_id: str _email: str - _device_callbacks: dict[str, Callable[[LetPotDeviceStatus], None]] = {} - _device_status_last: dict[str, LetPotDeviceStatus | None] = {} - _device_status_pending: dict[str, LetPotDeviceStatus | None] = {} - _device_status_timeout: dict[str, asyncio.Task | None] = {} - _device_status_event: dict[str, asyncio.Event | None] = {} - def __init__(self, info: AuthenticationInfo) -> None: self._user_id = info.user_id self._email = info.email + self._topics: list[str] = [] + + self._device_callbacks: dict[str, Callable[[LetPotDeviceStatus], None]] = {} + self._device_status_last: dict[str, LetPotDeviceStatus | None] = {} + self._device_status_pending: dict[str, LetPotDeviceStatus | None] = {} + self._device_status_timeout: dict[str, asyncio.Task | None] = {} + self._device_status_event: dict[str, asyncio.Event | None] = {} + def _converter(self, serial: str) -> LetPotDeviceConverter: """Get the device converter for the current serial number.""" device_type = serial[:5] @@ -172,7 +173,7 @@ def _handle_message(self, message: aiomqtt.Message) -> None: event = self._device_status_event.get(serial) if event is not None and not event.is_set(): event.set() - except Exception: # noqa: BLE001 + except Exception: _LOGGER.warning( f"Exception while handling message for {message.topic.value}, ignoring", exc_info=True, @@ -274,14 +275,16 @@ async def _connect(self) -> None: except aiomqtt.MqttError as err: self._client = None - if isinstance(err, aiomqtt.MqttCodeError): - if err.rc in self.AUTH_ERROR_RC: - msg = "MQTT auth error" - _LOGGER.error("%s: %s", msg, err) - auth_exception = LetPotAuthenticationException(msg) - if self._connected is not None and not self._connected.done(): - self._connected.set_exception(auth_exception) - raise auth_exception from err + if ( + isinstance(err, aiomqtt.MqttCodeError) + and err.rc in self.AUTH_ERROR_RC + ): + msg = "MQTT auth error" + _LOGGER.error("%s: %s", msg, err) + auth_exception = LetPotAuthenticationException(msg) + if self._connected is not None and not self._connected.done(): + self._connected.set_exception(auth_exception) + raise auth_exception from err connection_attempts += 1 if connection_attempts == 1: @@ -346,10 +349,10 @@ async def subscribe( await self._client.subscribe(topic) self._topics.append(topic) self._device_callbacks[serial] = callback - except aiomqtt.MqttError as err: + except aiomqtt.MqttError: if len(self._topics) == 0: await self._disconnect() - raise err + raise async def unsubscribe(self, serial: str) -> None: """Unsubscribes from device updates, and cancels the active device client connection if required.""" diff --git a/poetry.lock b/poetry.lock index 93e9eef..e584a24 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1138,30 +1138,30 @@ six = ">=1.5" [[package]] name = "ruff" -version = "0.15.12" +version = "0.16.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c"}, - {file = "ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c"}, - {file = "ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5"}, - {file = "ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002"}, - {file = "ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5"}, - {file = "ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6"}, - {file = "ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33"}, - {file = "ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847"}, - {file = "ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0"}, - {file = "ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339"}, - {file = "ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5"}, - {file = "ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd"}, - {file = "ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b"}, - {file = "ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e"}, - {file = "ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20"}, - {file = "ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d"}, - {file = "ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f"}, - {file = "ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6"}, + {file = "ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96"}, + {file = "ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67"}, + {file = "ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588"}, + {file = "ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385"}, + {file = "ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b"}, + {file = "ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284"}, + {file = "ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161"}, ] [[package]] @@ -1335,4 +1335,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "1f0c590502066e83eaff9b7abe921d3ce13cc0457de42abab889c6c19c4b53f5" +content-hash = "107c8efae76294d52e1a7bbe108c7b0f53e8a823bfe6befba51f7977450a0373" diff --git a/pyproject.toml b/pyproject.toml index 74468da..103b1ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ aiohttp = "^3.11" aiomqtt = "^2.0" [tool.poetry.group.dev.dependencies] -ruff = "0.15.12" +ruff = "0.16.9" pytest = "9.0.3" pytest-asyncio = "1.3.0" pytest-cov = "7.1.0" diff --git a/tests/__init__.py b/tests/__init__.py index 3dd7212..5794064 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,6 @@ """Tests for Python client for LetPot hydroponic gardens.""" -from datetime import datetime, time +from datetime import UTC, datetime, time from letpot.models import ( AuthenticationInfo, @@ -152,9 +152,9 @@ pump_cycle_workinginterval=0, pump_cycle_restinterval=0, pump_cycle_skip_water=0, - pump_works_end=datetime(2026, 3, 1, 0, 2, 12), + pump_works_end=datetime(2026, 3, 1, 0, 2, 12, tzinfo=UTC), pump_works_latest_reason=WateringReason.MANUAL, - pump_works_latest_time=datetime(2026, 2, 28, 23, 59, 11), + pump_works_latest_time=datetime(2026, 2, 28, 23, 59, 11, tzinfo=UTC), pump_works_next_time=None, ) @@ -210,8 +210,8 @@ pump_cycle_workinginterval=30, pump_cycle_restinterval=15, pump_cycle_skip_water=0, - pump_works_end=datetime(2026, 3, 1, 0, 2, 3), + pump_works_end=datetime(2026, 3, 1, 0, 2, 3, tzinfo=UTC), pump_works_latest_reason=WateringReason.CYCLE, - pump_works_latest_time=datetime(2026, 2, 28, 23, 59, 3), - pump_works_next_time=datetime(2026, 3, 1, 11, 59), + pump_works_latest_time=datetime(2026, 2, 28, 23, 59, 3, tzinfo=UTC), + pump_works_next_time=datetime(2026, 3, 1, 11, 59, tzinfo=UTC), ) diff --git a/tests/test_converter.py b/tests/test_converter.py index afc0908..17c8d54 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,7 +1,7 @@ """Tests for the converters.""" -from freezegun import freeze_time import pytest +from freezegun import freeze_time from letpot.converters import ( CONVERTERS, diff --git a/tests/test_models_auth.py b/tests/test_models_auth.py index fe84093..ed3997c 100644 --- a/tests/test_models_auth.py +++ b/tests/test_models_auth.py @@ -1,7 +1,7 @@ """Tests for the AuthenticationInfo model.""" import dataclasses -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from . import AUTHENTICATION @@ -10,8 +10,12 @@ def test_valid_info() -> None: """Test auth with access token expiring in the future is valid.""" auth_info = dataclasses.replace( AUTHENTICATION, - access_token_expires=int((datetime.now() + timedelta(days=7)).timestamp()), - refresh_token_expires=int((datetime.now() + timedelta(days=30)).timestamp()), + access_token_expires=int( + (datetime.now(tz=UTC) + timedelta(days=7)).timestamp() + ), + refresh_token_expires=int( + (datetime.now(tz=UTC) + timedelta(days=30)).timestamp() + ), ) assert auth_info.is_valid is True @@ -20,7 +24,11 @@ def test_expired_info() -> None: """Test auth with expired access token is considered invalid.""" auth_info = dataclasses.replace( AUTHENTICATION, - access_token_expires=int((datetime.now() - timedelta(days=7)).timestamp()), - refresh_token_expires=int((datetime.now() + timedelta(days=14)).timestamp()), + access_token_expires=int( + (datetime.now(tz=UTC) - timedelta(days=7)).timestamp() + ), + refresh_token_expires=int( + (datetime.now(tz=UTC) + timedelta(days=14)).timestamp() + ), ) assert auth_info.is_valid is False