2 Commits b3930dbcdf ... d8bf41dd7b

Author SHA1 Message Date
  J. Nick Koston d8bf41dd7b chore: add additional ruff config (#302) 4 weeks ago
  J. Nick Koston df11f7624a fix: background tasks getting garbage collected before they finish (#301) 4 weeks ago

+ 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,
     SwitchbotAuthenticationError,
     SwitchbotModel,
     SwitchbotModel,
 )
 )
-from .devices.device import SwitchbotEncryptedDevice
 from .devices.base_light import SwitchbotBaseLight
 from .devices.base_light import SwitchbotBaseLight
 from .devices.blind_tilt import SwitchbotBlindTilt
 from .devices.blind_tilt import SwitchbotBlindTilt
 from .devices.bot import Switchbot
 from .devices.bot import Switchbot
 from .devices.bulb import SwitchbotBulb
 from .devices.bulb import SwitchbotBulb
 from .devices.ceiling_light import SwitchbotCeilingLight
 from .devices.ceiling_light import SwitchbotCeilingLight
 from .devices.curtain import SwitchbotCurtain
 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.humidifier import SwitchbotHumidifier
 from .devices.light_strip import SwitchbotLightStrip
 from .devices.light_strip import SwitchbotLightStrip
 from .devices.lock import SwitchbotLock
 from .devices.lock import SwitchbotLock
@@ -33,30 +32,30 @@ from .discovery import GetSwitchbotDevices
 from .models import SwitchBotAdvertisement
 from .models import SwitchBotAdvertisement
 
 
 __all__ = [
 __all__ = [
-    "get_device",
-    "close_stale_connections",
-    "close_stale_connections_by_address",
-    "parse_advertisement_data",
+    "ColorMode",
     "GetSwitchbotDevices",
     "GetSwitchbotDevices",
+    "LockStatus",
     "SwitchBotAdvertisement",
     "SwitchBotAdvertisement",
+    "Switchbot",
     "SwitchbotAccountConnectionError",
     "SwitchbotAccountConnectionError",
     "SwitchbotApiError",
     "SwitchbotApiError",
     "SwitchbotAuthenticationError",
     "SwitchbotAuthenticationError",
-    "SwitchbotEncryptedDevice",
-    "ColorMode",
-    "LockStatus",
     "SwitchbotBaseLight",
     "SwitchbotBaseLight",
+    "SwitchbotBlindTilt",
     "SwitchbotBulb",
     "SwitchbotBulb",
     "SwitchbotCeilingLight",
     "SwitchbotCeilingLight",
-    "SwitchbotDevice",
     "SwitchbotCurtain",
     "SwitchbotCurtain",
-    "SwitchbotLightStrip",
+    "SwitchbotDevice",
+    "SwitchbotEncryptedDevice",
     "SwitchbotHumidifier",
     "SwitchbotHumidifier",
-    "Switchbot",
-    "SwitchbotPlugMini",
-    "SwitchbotSupportedType",
-    "SwitchbotModel",
+    "SwitchbotLightStrip",
     "SwitchbotLock",
     "SwitchbotLock",
-    "SwitchbotBlindTilt",
+    "SwitchbotModel",
+    "SwitchbotPlugMini",
     "SwitchbotRelaySwitch",
     "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
     data: bytes | None, mfr_data: bytes | None, reverse: bool = False
 ) -> dict[str, bool | int]:
 ) -> dict[str, bool | int]:
     """Process woBlindTilt services data."""
     """Process woBlindTilt services data."""
-
     if mfr_data is None:
     if mfr_data is None:
         return {}
         return {}
 
 

+ 0 - 1
switchbot/adv_parsers/remote.py

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

+ 6 - 3
switchbot/const.py

@@ -12,7 +12,8 @@ DEFAULT_SCAN_TIMEOUT = 5
 
 
 
 
 class SwitchbotApiError(RuntimeError):
 class SwitchbotApiError(RuntimeError):
-    """Raised when API call fails.
+    """
+    Raised when API call fails.
 
 
     This exception inherits from RuntimeError to avoid breaking existing code
     This exception inherits from RuntimeError to avoid breaking existing code
     but will be changed to Exception in a future release.
     but will be changed to Exception in a future release.
@@ -20,7 +21,8 @@ class SwitchbotApiError(RuntimeError):
 
 
 
 
 class SwitchbotAuthenticationError(RuntimeError):
 class SwitchbotAuthenticationError(RuntimeError):
-    """Raised when authentication fails.
+    """
+    Raised when authentication fails.
 
 
     This exception inherits from RuntimeError to avoid breaking existing code
     This exception inherits from RuntimeError to avoid breaking existing code
     but will be changed to Exception in a future release.
     but will be changed to Exception in a future release.
@@ -28,7 +30,8 @@ class SwitchbotAuthenticationError(RuntimeError):
 
 
 
 
 class SwitchbotAccountConnectionError(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
     This exception inherits from RuntimeError to avoid breaking existing code
     but will be changed to Exception in a future release.
     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:
     def __init__(self, reverse: bool, *args: Any, **kwargs: Any) -> None:
         """Switchbot Cover device constructor."""
         """Switchbot Cover device constructor."""
-
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
         self._reverse = reverse
         self._reverse = reverse
         self._settings: dict[str, Any] = {}
         self._settings: dict[str, Any] = {}
@@ -43,7 +42,8 @@ class SwitchbotBaseCover(SwitchbotDevice):
         self._is_closing: bool = False
         self._is_closing: bool = False
 
 
     async def _send_multiple_commands(self, keys: list[str]) -> bool:
     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
         Since we current have no way to tell which command the device
         needs we send both.
         needs we send both.
@@ -84,7 +84,6 @@ class SwitchbotBaseCover(SwitchbotDevice):
 
 
     async def get_extended_info_adv(self) -> dict[str, Any] | None:
     async def get_extended_info_adv(self) -> dict[str, Any] | None:
         """Get advance page info for device chain."""
         """Get advance page info for device chain."""
-
         _data = await self._send_command(key=COVER_EXT_ADV_KEY)
         _data = await self._send_command(key=COVER_EXT_ADV_KEY)
         if not _data:
         if not _data:
             _LOGGER.error("%s: Unsuccessful, no result from device", self.name)
             _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
 from __future__ import annotations
 
 
-import asyncio
 import logging
 import logging
 import time
 import time
 from abc import abstractmethod
 from abc import abstractmethod
 from typing import Any
 from typing import Any
 
 
+from ..helpers import create_background_task
 from ..models import SwitchBotAdvertisement
 from ..models import SwitchBotAdvertisement
 from .device import ColorMode, SwitchbotDevice
 from .device import ColorMode, SwitchbotDevice
 
 
@@ -106,4 +106,4 @@ class SwitchbotSequenceBaseLight(SwitchbotBaseLight):
             new_state,
             new_state,
         )
         )
         if current_state != 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:
     def __init__(self, *args: Any, **kwargs: Any) -> None:
         """Switchbot Curtain/WoCurtain constructor."""
         """Switchbot Curtain/WoCurtain constructor."""
-
         # The position of the curtain is saved returned with 0 = open and 100 = closed.
         # 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/
         # This is independent of the calibration of the curtain bot (Open left to right/
         # Open right to left/Open from the middle).
         # Open right to left/Open from the middle).

+ 14 - 10
switchbot/devices/device.py

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