diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index d64e73d..94c7c5d 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9] + python-version: ["3.7", "3.8", "3.9", "3.10"] steps: - name: Checkout sources uses: actions/checkout@v2 diff --git a/devolo_plc_api/__init__.py b/devolo_plc_api/__init__.py index eddea92..c509377 100644 --- a/devolo_plc_api/__init__.py +++ b/devolo_plc_api/__init__.py @@ -1,7 +1,7 @@ try: from importlib.metadata import PackageNotFoundError, version except ImportError: - from importlib_metadata import PackageNotFoundError, version # type: ignore[no-redef] + from importlib_metadata import PackageNotFoundError, version # type: ignore try: __version__ = version("devolo_plc_api") diff --git a/devolo_plc_api/clients/protobuf.py b/devolo_plc_api/clients/protobuf.py index c37d849..db3a4f2 100644 --- a/devolo_plc_api/clients/protobuf.py +++ b/devolo_plc_api/clients/protobuf.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import asyncio import logging from abc import ABC, abstractclassmethod -from typing import Callable +from typing import Any, Callable from google.protobuf.json_format import MessageToDict from httpx import AsyncClient, ConnectError, ConnectTimeout, DigestAuth, ReadTimeout, Response @@ -69,6 +71,6 @@ async def _async_post(self, sub_url: str, content: bytes, timeout: float = TIMEO raise DeviceUnavailable("The device is currenctly not available. Maybe on standby?") from None @staticmethod - def _message_to_dict(message) -> dict: + def _message_to_dict(message) -> dict[str, Any]: """ Convert message to dict with certain settings. """ return MessageToDict(message=message, including_default_value_fields=True, preserving_proto_field_name=True) diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index 48995f1..037f90d 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -1,9 +1,11 @@ +from __future__ import annotations + import asyncio import ipaddress import logging import struct from datetime import date -from typing import Dict, Optional +from typing import Any import httpx from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf @@ -12,9 +14,10 @@ from .exceptions.device import DeviceNotFound from .plcnet_api.plcnetapi import PlcNetApi -EMPTY_INFO: Dict = { - "properties": {} -} +EMPTY_INFO: dict[str, + Any] = { + "properties": {} + } class Device: @@ -29,9 +32,11 @@ class Device: def __init__(self, ip: str, - plcnetapi: Optional[Dict] = None, - deviceapi: Optional[Dict] = None, - zeroconf_instance: Optional[Zeroconf] = None): + plcnetapi: dict[str, + Any] | None = None, + deviceapi: dict[str, + Any] | None = None, + zeroconf_instance: Zeroconf | None = None): self.firmware_date = date.fromtimestamp(0) self.firmware_version = "" self.hostname = "" @@ -46,13 +51,15 @@ def __init__(self, self.plcnet = None self._connected = False - self._info: Dict = { - "_dvl-plcnetapi._tcp.local.": plcnetapi or EMPTY_INFO, - "_dvl-deviceapi._tcp.local.": deviceapi or EMPTY_INFO, - } + self._info: dict[str, + dict[str, + Any]] = { + "_dvl-plcnetapi._tcp.local.": plcnetapi or EMPTY_INFO, + "_dvl-deviceapi._tcp.local.": deviceapi or EMPTY_INFO, + } self._logger = logging.getLogger(f"{self.__class__.__module__}.{self.__class__.__name__}") self._password = "" - self._session_instance: Optional[httpx.AsyncClient] = None + self._session_instance: httpx.AsyncClient | None = None self._zeroconf_instance = zeroconf_instance logging.captureWarnings(True) @@ -90,7 +97,7 @@ def password(self, password: str): if self.device: self.device.password = password - async def async_connect(self, session_instance: Optional[httpx.AsyncClient] = None): + async def async_connect(self, session_instance: httpx.AsyncClient | None = None): """ Connect to a device asynchronous. @@ -176,11 +183,11 @@ def _state_change(self, zeroconf: Zeroconf, service_type: str, name: str, state_ self._info[service_type] = self.info_from_service(service_info) @staticmethod - def info_from_service(service_info: ServiceInfo) -> Optional[Dict]: + def info_from_service(service_info: ServiceInfo) -> dict[str, Any]: """ Return prepared info from mDNS entries. """ properties = {} if not service_info.addresses: - return None # No need to continue, if there is no IP address to contact the device + return {} # No need to continue, if there is no IP address to contact the device total_length = len(service_info.text) offset = 0 diff --git a/devolo_plc_api/device_api/deviceapi.py b/devolo_plc_api/device_api/deviceapi.py index 9008242..4cd8910 100644 --- a/devolo_plc_api/device_api/deviceapi.py +++ b/devolo_plc_api/device_api/deviceapi.py @@ -1,4 +1,6 @@ -from typing import Callable, Dict +from __future__ import annotations + +from typing import Any, Callable from httpx import AsyncClient @@ -18,7 +20,7 @@ class DeviceApi(Protobuf): :param info: Information collected from the mDNS query """ - def __init__(self, ip: str, session: AsyncClient, info: Dict): + def __init__(self, ip: str, session: AsyncClient, info: dict[str, Any]): super().__init__() self._ip = ip @@ -30,7 +32,7 @@ def __init__(self, ip: str, session: AsyncClient, info: Dict): self._version = info["properties"]["Version"] features = info["properties"].get("Features", "") - self.features = features.split(",") if features else ["reset", "update", "led", "intmtg"] + self.features: list[str] = features.split(",") if features else ["reset", "update", "led", "intmtg"] self.password = "" def _feature(feature: str): # type: ignore # pylint: disable=no-self-argument @@ -49,7 +51,7 @@ def wrapper(self, *args, **kwargs): return feature_decorator @_feature("led") - async def async_get_led_setting(self) -> dict: + async def async_get_led_setting(self) -> dict[str, Any]: """ Get LED setting asynchronously. This feature only works on devices, that announce the led feature. @@ -78,7 +80,7 @@ async def async_set_led_setting(self, enable: bool) -> bool: return bool(not response.result) # pylint: disable=no-member @_feature("update") - async def async_check_firmware_available(self) -> dict: + async def async_check_firmware_available(self) -> dict[str, Any]: """ Check asynchronously, if a firmware update is available for the device. @@ -105,7 +107,7 @@ async def async_start_firmware_update(self) -> bool: return bool(not update_firmware.result) # pylint: disable=no-member @_feature("wifi1") - async def async_get_wifi_connected_station(self) -> dict: + async def async_get_wifi_connected_station(self) -> dict[str, Any]: """ Get wifi stations connected to the device asynchronously. This feature only works on devices, that announce the wifi1 feature. @@ -119,7 +121,7 @@ async def async_get_wifi_connected_station(self) -> dict: return self._message_to_dict(wifi_connected_proto) @_feature("wifi1") - async def async_get_wifi_guest_access(self) -> dict: + async def async_get_wifi_guest_access(self) -> dict[str, Any]: """ Get details about wifi guest access asynchronously. This feature only works on devices, that announce the wifi1 feature. @@ -149,7 +151,7 @@ async def async_set_wifi_guest_access(self, enable: bool) -> bool: return bool(not response.result) # pylint: disable=no-member @_feature("wifi1") - async def async_get_wifi_neighbor_access_points(self) -> dict: + async def async_get_wifi_neighbor_access_points(self) -> dict[str, Any]: """ Get wifi access point in the neighborhood asynchronously. This feature only works on devices, that announce the wifi1 feature. @@ -163,7 +165,7 @@ async def async_get_wifi_neighbor_access_points(self) -> dict: return self._message_to_dict(wifi_neighbor_aps) @_feature("wifi1") - async def async_get_wifi_repeated_access_points(self): + async def async_get_wifi_repeated_access_points(self) -> dict[str, Any]: """ Get repeated wifi access point asynchronously. This feature only works on repeater devices, that announce the wifi1 feature. @@ -177,7 +179,7 @@ async def async_get_wifi_repeated_access_points(self): return self._message_to_dict(wifi_connected_proto) @_feature("wifi1") - async def async_start_wps(self): + async def async_start_wps(self) -> bool: """ Start WPS push button configuration. diff --git a/devolo_plc_api/network/__init__.py b/devolo_plc_api/network/__init__.py index f080273..92b58d2 100644 --- a/devolo_plc_api/network/__init__.py +++ b/devolo_plc_api/network/__init__.py @@ -1,16 +1,17 @@ +from __future__ import annotations + import asyncio import time -from typing import Dict from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf from ..device import Device -_devices: Dict[str, +_devices: dict[str, Device] = {} -async def async_discover_network() -> Dict[str, Device]: +async def async_discover_network() -> dict[str, Device]: """ Discover all devices that expose the devolo device API via mDNS asynchronous. @@ -22,7 +23,7 @@ async def async_discover_network() -> Dict[str, Device]: return _devices -def discover_network() -> Dict[str, Device]: +def discover_network() -> dict[str, Device]: """ Discover devices that expose the devolo device API via mDNS synchronous. diff --git a/devolo_plc_api/plcnet_api/plcnetapi.py b/devolo_plc_api/plcnet_api/plcnetapi.py index c3fe0bb..bd9402a 100644 --- a/devolo_plc_api/plcnet_api/plcnetapi.py +++ b/devolo_plc_api/plcnet_api/plcnetapi.py @@ -1,4 +1,5 @@ -from typing import Dict +from __future__ import annotations +from typing import Any from httpx import AsyncClient @@ -17,7 +18,7 @@ class PlcNetApi(Protobuf): :param info: Information collected from the mDNS query """ - def __init__(self, ip: str, session: AsyncClient, info: Dict): + def __init__(self, ip: str, session: AsyncClient, info: dict[str, Any]): super().__init__() self._ip = ip @@ -30,7 +31,7 @@ def __init__(self, ip: str, session: AsyncClient, info: Dict): self.password = "" # PLC API is not password protected. - async def async_get_network_overview(self) -> dict: + async def async_get_network_overview(self) -> dict[str, Any]: """ Get a PLC network overview. diff --git a/devolo_plc_api/py.typed b/devolo_plc_api/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d6a4865..fd7d520 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.5.3] - 2021/10/18 + +### Changed + +- Rework typing +- Mark package as typed +- Add Python 3.10 to CI + ## [v0.5.2] - 2021/09/01 ### Changed diff --git a/setup.py b/setup.py index 11c76d3..d3e54f8 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ import shlex from subprocess import check_call -from setuptools import find_packages, setup +from setuptools import setup from setuptools.command.develop import develop with open("README.md", "r") as fh: @@ -29,15 +29,17 @@ def run(self): long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/2Fake/devolo_plc_api", - packages=find_packages(exclude=("tests*", - )), + packages=["devolo_plc_api"], + package_data={ + "devolo_plc_api": ["py.typed"], + }, classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", ], install_requires=[ - "httpx>=0.14,<0.20", + "httpx>=0.14,<0.21", "importlib-metadata;python_version<'3.8'", "protobuf", "zeroconf>=0.27.0", @@ -56,5 +58,5 @@ def run(self): ], }, setup_requires=["setuptools_scm"], - python_requires='>=3.7', + python_requires=">=3.7", ) diff --git a/tests/fixtures/device.py b/tests/fixtures/device.py index af8a848..7359ef3 100644 --- a/tests/fixtures/device.py +++ b/tests/fixtures/device.py @@ -22,7 +22,7 @@ def mock_device(request): device._session = Mock() device._session.aclose = AsyncMock() device._zeroconf = Mock() - device._zeroconf.close = lambda: None + device._zeroconf.close = Mock() yield device diff --git a/tests/test_device.py b/tests/test_device.py index 1afa1f9..f4ed240 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -54,12 +54,10 @@ def test_connect(self, mocker, mock_device): assert spy_connect.call_count == 1 @pytest.mark.asyncio - async def test_async_disconnect(self, mocker, mock_device): - spy_zeroconf = mocker.spy(mock_device._zeroconf, "close") - spy_session = mocker.spy(mock_device._session, "aclose") + async def test_async_disconnect(self, mock_device): await mock_device.async_disconnect() - assert spy_zeroconf.call_count == 1 - assert spy_session.call_count == 1 + assert mock_device._zeroconf.close.call_count == 1 + assert mock_device._session.aclose.call_count == 1 assert not mock_device._connected def test_disconnect(self, mocker, mock_device): @@ -143,4 +141,4 @@ def test__state_change_removed(self, mock_device, mock_zeroconf): def test_info_from_service_no_address(self, mock_device): service_info = Mock() service_info.addresses = None - assert mock_device.info_from_service(service_info) is None + assert mock_device.info_from_service(service_info) == {}