2 Commitit b3930dbcdf ... d8bf41dd7b

Tekijä SHA1 Viesti Päivämäärä
  J. Nick Koston d8bf41dd7b chore: add additional ruff config (#302) 4 viikkoa sitten
  J. Nick Koston df11f7624a fix: background tasks getting garbage collected before they finish (#301) 4 viikkoa sitten

+ 59 - 0
pyproject.toml

@@ -0,0 +1,59 @@
+[tool.ruff]
+target-version = "py311"
+line-length = 88
+
+[tool.ruff.lint]
+ignore = [
+    "S101", # use of assert
+    "D203", # 1 blank line required before class docstring
+    "D212", # Multi-line docstring summary should start at the first line
+    "D100", # Missing docstring in public module
+    "D101", # Missing docstring in public module
+    "D102", # Missing docstring in public method
+    "D103", # Missing docstring in public module
+    "D104", # Missing docstring in public package
+    "D105", # Missing docstring in magic method
+    "D107", # Missing docstring in `__init__`
+    "D400", # First line should end with a period
+    "D401", # First line of docstring should be in imperative mood
+    "D205", # 1 blank line required between summary line and description
+    "D415", # First line should end with a period, question mark, or exclamation point
+    "D417", # Missing argument descriptions in the docstring
+    "E501", # Line too long
+    "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
+    "B008", # Do not perform function call
+    "S110", # `try`-`except`-`pass` detected, consider logging the exception
+    "D106", # Missing docstring in public nested class
+    "UP007", # typer needs Optional syntax
+    "UP038", # Use `X | Y` in `isinstance` is slower
+    "S603", #  check for execution of untrusted input
+    "S105", # possible hard coded creds
+]
+select = [
+    "B",   # flake8-bugbear
+    "D",   # flake8-docstrings
+    "C4",  # flake8-comprehensions
+    "S",   # flake8-bandit
+    "F",   # pyflake
+    "E",   # pycodestyle
+    "W",   # pycodestyle
+    "UP",  # pyupgrade
+    "I",   # isort
+    "RUF", # ruff specific
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**/*" = [
+    "D100",
+    "D101",
+    "D102",
+    "D103",
+    "D104",
+    "S101",
+]
+"setup.py" = ["D100"]
+"conftest.py" = ["D100"]
+"docs/conf.py" = ["D100"]
+
+[tool.ruff.lint.isort]
+known-first-party = ["pySwitchbot", "tests"]

+ 15 - 16
switchbot/__init__.py

@@ -16,14 +16,13 @@ from .const import (
     SwitchbotAuthenticationError,
     SwitchbotModel,
 )
-from .devices.device import SwitchbotEncryptedDevice
 from .devices.base_light import SwitchbotBaseLight
 from .devices.blind_tilt import SwitchbotBlindTilt
 from .devices.bot import Switchbot
 from .devices.bulb import SwitchbotBulb
 from .devices.ceiling_light import SwitchbotCeilingLight
 from .devices.curtain import SwitchbotCurtain
-from .devices.device import ColorMode, SwitchbotDevice
+from .devices.device import ColorMode, SwitchbotDevice, SwitchbotEncryptedDevice
 from .devices.humidifier import SwitchbotHumidifier
 from .devices.light_strip import SwitchbotLightStrip
 from .devices.lock import SwitchbotLock
@@ -33,30 +32,30 @@ from .discovery import GetSwitchbotDevices
 from .models import SwitchBotAdvertisement
 
 __all__ = [
-    "get_device",
-    "close_stale_connections",
-    "close_stale_connections_by_address",
-    "parse_advertisement_data",
+    "ColorMode",
     "GetSwitchbotDevices",
+    "LockStatus",
     "SwitchBotAdvertisement",
+    "Switchbot",
     "SwitchbotAccountConnectionError",
     "SwitchbotApiError",
     "SwitchbotAuthenticationError",
-    "SwitchbotEncryptedDevice",
-    "ColorMode",
-    "LockStatus",
     "SwitchbotBaseLight",
+    "SwitchbotBlindTilt",
     "SwitchbotBulb",
     "SwitchbotCeilingLight",
-    "SwitchbotDevice",
     "SwitchbotCurtain",
-    "SwitchbotLightStrip",
+    "SwitchbotDevice",
+    "SwitchbotEncryptedDevice",
     "SwitchbotHumidifier",
-    "Switchbot",
-    "SwitchbotPlugMini",
-    "SwitchbotSupportedType",
-    "SwitchbotModel",
+    "SwitchbotLightStrip",
     "SwitchbotLock",
-    "SwitchbotBlindTilt",
+    "SwitchbotModel",
+    "SwitchbotPlugMini",
     "SwitchbotRelaySwitch",
+    "SwitchbotSupportedType",
+    "close_stale_connections",
+    "close_stale_connections_by_address",
+    "get_device",
+    "parse_advertisement_data",
 ]

+ 0 - 1
switchbot/adv_parsers/blind_tilt.py

@@ -7,7 +7,6 @@ def process_woblindtilt(
     data: bytes | None, mfr_data: bytes | None, reverse: bool = False
 ) -> dict[str, bool | int]:
     """Process woBlindTilt services data."""
-
     if mfr_data is None:
         return {}
 

+ 0 - 1
switchbot/adv_parsers/remote.py

@@ -11,7 +11,6 @@ def process_woremote(
     data: bytes | None, mfr_data: bytes | None
 ) -> dict[str, int | None]:
     """Process WoRemote adv data."""
-
     if data is None:
         return {
             "battery": None,

+ 6 - 3
switchbot/const.py

@@ -12,7 +12,8 @@ DEFAULT_SCAN_TIMEOUT = 5
 
 
 class SwitchbotApiError(RuntimeError):
-    """Raised when API call fails.
+    """
+    Raised when API call fails.
 
     This exception inherits from RuntimeError to avoid breaking existing code
     but will be changed to Exception in a future release.
@@ -20,7 +21,8 @@ class SwitchbotApiError(RuntimeError):
 
 
 class SwitchbotAuthenticationError(RuntimeError):
-    """Raised when authentication fails.
+    """
+    Raised when authentication fails.
 
     This exception inherits from RuntimeError to avoid breaking existing code
     but will be changed to Exception in a future release.
@@ -28,7 +30,8 @@ class SwitchbotAuthenticationError(RuntimeError):
 
 
 class SwitchbotAccountConnectionError(RuntimeError):
-    """Raised when connection to Switchbot account fails.
+    """
+    Raised when connection to Switchbot account fails.
 
     This exception inherits from RuntimeError to avoid breaking existing code
     but will be changed to Exception in a future release.

+ 2 - 3
switchbot/devices/base_cover.py

@@ -33,7 +33,6 @@ class SwitchbotBaseCover(SwitchbotDevice):
 
     def __init__(self, reverse: bool, *args: Any, **kwargs: Any) -> None:
         """Switchbot Cover device constructor."""
-
         super().__init__(*args, **kwargs)
         self._reverse = reverse
         self._settings: dict[str, Any] = {}
@@ -43,7 +42,8 @@ class SwitchbotBaseCover(SwitchbotDevice):
         self._is_closing: bool = False
 
     async def _send_multiple_commands(self, keys: list[str]) -> bool:
-        """Send multiple commands to device.
+        """
+        Send multiple commands to device.
 
         Since we current have no way to tell which command the device
         needs we send both.
@@ -84,7 +84,6 @@ class SwitchbotBaseCover(SwitchbotDevice):
 
     async def get_extended_info_adv(self) -> dict[str, Any] | None:
         """Get advance page info for device chain."""
-
         _data = await self._send_command(key=COVER_EXT_ADV_KEY)
         if not _data:
             _LOGGER.error("%s: Unsuccessful, no result from device", self.name)

+ 2 - 2
switchbot/devices/base_light.py

@@ -1,11 +1,11 @@
 from __future__ import annotations
 
-import asyncio
 import logging
 import time
 from abc import abstractmethod
 from typing import Any
 
+from ..helpers import create_background_task
 from ..models import SwitchBotAdvertisement
 from .device import ColorMode, SwitchbotDevice
 
@@ -106,4 +106,4 @@ class SwitchbotSequenceBaseLight(SwitchbotBaseLight):
             new_state,
         )
         if current_state != new_state:
-            asyncio.ensure_future(self.update())
+            create_background_task(self.update())

+ 0 - 1
switchbot/devices/curtain.py

@@ -38,7 +38,6 @@ class SwitchbotCurtain(SwitchbotBaseCover):
 
     def __init__(self, *args: Any, **kwargs: Any) -> None:
         """Switchbot Curtain/WoCurtain constructor."""
-
         # The position of the curtain is saved returned with 0 = open and 100 = closed.
         # This is independent of the calibration of the curtain bot (Open left to right/
         # Open right to left/Open from the middle).

+ 14 - 10
switchbot/devices/device.py

@@ -6,11 +6,10 @@ import asyncio
 import binascii
 import logging
 import time
+from collections.abc import Callable
 from dataclasses import replace
 from enum import Enum
 from typing import Any, TypeVar, cast
-from collections.abc import Callable
-from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 from uuid import UUID
 
 import aiohttp
@@ -24,6 +23,7 @@ from bleak_retry_connector import (
     ble_device_has_changed,
     establish_connection,
 )
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 
 from ..api_config import SWITCHBOT_APP_API_BASE_URL, SWITCHBOT_APP_CLIENT_ID
 from ..const import (
@@ -35,6 +35,7 @@ from ..const import (
     SwitchbotModel,
 )
 from ..discovery import GetSwitchbotDevices
+from ..helpers import create_background_task
 from ..models import SwitchBotAdvertisement
 
 _LOGGER = logging.getLogger(__name__)
@@ -83,7 +84,6 @@ class SwitchbotOperationError(Exception):
 
 def _sb_uuid(comms_type: str = "service") -> UUID | str:
     """Return Switchbot UUID."""
-
     _uuid = {"tx": "002", "rx": "003", "service": "d00"}
 
     if comms_type in _uuid:
@@ -169,8 +169,8 @@ class SwitchbotBaseDevice:
         session: aiohttp.ClientSession,
         subdomain: str,
         path: str,
-        data: dict = None,
-        headers: dict = None,
+        data: dict | None = None,
+        headers: dict | None = None,
     ) -> dict:
         url = f"https://{subdomain}.{SWITCHBOT_APP_API_BASE_URL}/{path}"
         async with session.post(
@@ -639,7 +639,8 @@ class SwitchbotBaseDevice:
         return result[index] in values
 
     def _update_parsed_data(self, new_data: dict[str, Any]) -> bool:
-        """Update data.
+        """
+        Update data.
 
         Returns true if data has changed and False if not.
         """
@@ -693,7 +694,8 @@ class SwitchbotBaseDevice:
 
 
 class SwitchbotDevice(SwitchbotBaseDevice):
-    """Base Representation of a Switchbot Device.
+    """
+    Base Representation of a Switchbot Device.
 
     This base class consumes the advertisement data during connection. If the device
     sends stale advertisement data while connected, use
@@ -885,7 +887,8 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
 
 
 class SwitchbotDeviceOverrideStateDuringConnection(SwitchbotBaseDevice):
-    """Base Representation of a Switchbot Device.
+    """
+    Base Representation of a Switchbot Device.
 
     This base class ignores the advertisement data during connection and uses the
     data from the device instead.
@@ -903,7 +906,8 @@ class SwitchbotDeviceOverrideStateDuringConnection(SwitchbotBaseDevice):
 
 
 class SwitchbotSequenceDevice(SwitchbotDevice):
-    """A Switchbot sequence device.
+    """
+    A Switchbot sequence device.
 
     This class must not use SwitchbotDeviceOverrideStateDuringConnection because
     it needs to know when the sequence_number has changed.
@@ -922,4 +926,4 @@ class SwitchbotSequenceDevice(SwitchbotDevice):
             new_state,
         )
         if current_state != new_state:
-            asyncio.ensure_future(self.update())
+            create_background_task(self.update())

+ 0 - 1
switchbot/discovery.py

@@ -39,7 +39,6 @@ class GetSwitchbotDevices:
         self, retry: int = DEFAULT_RETRY_COUNT, scan_timeout: int = DEFAULT_SCAN_TIMEOUT
     ) -> dict:
         """Find switchbot devices and their advertisement data."""
-
         devices = None
         devices = bleak.BleakScanner(
             detection_callback=self.detection_callback,

+ 17 - 0
switchbot/helpers.py

@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Coroutine
+from typing import Any, TypeVar
+
+_R = TypeVar("_R")
+
+_BACKGROUND_TASKS: set[asyncio.Task[Any]] = set()
+
+
+def create_background_task(target: Coroutine[Any, Any, _R]) -> asyncio.Task[_R]:
+    """Create a background task."""
+    task = asyncio.create_task(target)
+    _BACKGROUND_TASKS.add(task)
+    task.add_done_callback(_BACKGROUND_TASKS.remove)
+    return task

+ 0 - 1
tests/test_base_cover.py

@@ -24,7 +24,6 @@ def make_advertisement_data(
     ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True
 ):
     """Set advertisement data with defaults."""
-
     return SwitchBotAdvertisement(
         address="aa:bb:cc:dd:ee:ff",
         data={

+ 0 - 1
tests/test_blind_tilt.py

@@ -29,7 +29,6 @@ def make_advertisement_data(
     ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True
 ):
     """Set advertisement data with defaults."""
-
     return SwitchBotAdvertisement(
         address="aa:bb:cc:dd:ee:ff",
         data={

+ 0 - 1
tests/test_curtain.py

@@ -25,7 +25,6 @@ def make_advertisement_data(
     ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True
 ):
     """Set advertisement data with defaults."""
-
     return SwitchBotAdvertisement(
         address="aa:bb:cc:dd:ee:ff",
         data={

+ 0 - 1
tests/test_relay_switch.py

@@ -20,7 +20,6 @@ def create_device_for_command_testing(calibration=True, reverse_mode=False):
 
 def make_advertisement_data(ble_device: BLEDevice):
     """Set advertisement data with defaults."""
-
     return SwitchBotAdvertisement(
         address="aa:bb:cc:dd:ee:ff",
         data={