diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 58fca7d..76b5b4f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,27 +1,27 @@ repos: -- repo: https://github.com/psf/black-pre-commit-mirror - rev: '23.9.1' - hooks: - - id: black -- repo: https://github.com/pycqa/isort - rev: '5.12.0' - hooks: - - id: isort -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: 'v4.4.0' - hooks: - - id: check-json - - id: end-of-file-fixer - - id: trailing-whitespace -- repo: https://github.com/abravalheri/validate-pyproject - rev: 'v0.14' - hooks: - - id: validate-pyproject -- repo: local - hooks: - - id: stubgen - name: check API stub files - entry: scripts/stubgen.py - description: check if stub files of the APIs are up-to-date - language: script - types: [python] + - repo: https://github.com/psf/black-pre-commit-mirror + rev: "25.1.0" + hooks: + - id: black + - repo: https://github.com/pycqa/isort + rev: "6.0.1" + hooks: + - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: "v5.0.0" + hooks: + - id: check-json + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/abravalheri/validate-pyproject + rev: "v0.24.1" + hooks: + - id: validate-pyproject + - repo: local + hooks: + - id: stubgen + name: check API stub files + entry: scripts/stubgen.py + description: check if stub files of the APIs are up-to-date + language: script + types: [python] diff --git a/README.md b/README.md index f994ef7..4ecbc63 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Defining the system requirements with exact versions typically is difficult. But * httpx 0.28.1 * protobuf 5.28.3 * segno 1.6.1 +* tenacity 9.0.0 * zeroconf 0.146.1 Other versions and even other operating systems might work. Feel free to tell us about your experience. If you want to run our unit tests, you also need: diff --git a/devolo_plc_api/clients/protobuf.py b/devolo_plc_api/clients/protobuf.py index f65b493..11088e2 100644 --- a/devolo_plc_api/clients/protobuf.py +++ b/devolo_plc_api/clients/protobuf.py @@ -19,6 +19,7 @@ RemoteProtocolError, Response, ) +from tenacity import before_sleep_log, retry, retry_if_exception_type, stop_after_attempt, wait_exponential from devolo_plc_api.exceptions import DevicePasswordProtected, DeviceUnavailable @@ -70,6 +71,13 @@ async def _async_post(self, sub_url: str, content: bytes, timeout: float = TIMEO self._logger.debug("Posting to %s", url) return await self._async_request("POST", url, content, timeout) + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=5), + retry=retry_if_exception_type(DeviceUnavailable), + reraise=True, + before_sleep=before_sleep_log(logging.getLogger("devolo_plc_api.clients.protobuf.Protobuf"), logging.DEBUG), + ) async def _async_request(self, method: str, url: str, content: bytes | None, timeout: float = TIMEOUT) -> Response: """Request data asynchronously.""" try: diff --git a/devolo_plc_api/device_api/deviceapi.py b/devolo_plc_api/device_api/deviceapi.py index 6715a34..ef8a00e 100644 --- a/devolo_plc_api/device_api/deviceapi.py +++ b/devolo_plc_api/device_api/deviceapi.py @@ -36,6 +36,9 @@ _P = ParamSpec("_P") +LONG_RUNNING = 30.0 + + def _feature( feature: str, ) -> Callable[[Callable[Concatenate[DeviceApi, _P], _ReturnT]], Callable[Concatenate[DeviceApi, _P], _ReturnT]]: @@ -196,7 +199,7 @@ async def async_get_support_info(self) -> SupportInfoDump: """ self._logger.debug("Get uptime.") support_info = SupportInfoDumpResponse() - response = await self._async_get("SupportInfoDump") + response = await self._async_get("SupportInfoDump", timeout=LONG_RUNNING) support_info.ParseFromString(await response.aread()) return support_info.info @@ -209,7 +212,7 @@ async def async_check_firmware_available(self) -> UpdateFirmwareCheck: """ self._logger.debug("Checking for new firmware.") update_firmware_check = UpdateFirmwareCheck() - response = await self._async_get("UpdateFirmwareCheck", timeout=30.0) + response = await self._async_get("UpdateFirmwareCheck", timeout=LONG_RUNNING) update_firmware_check.ParseFromString(await response.aread()) return update_firmware_check @@ -283,7 +286,7 @@ async def async_get_wifi_neighbor_access_points(self) -> list[WifiNeighborAPsGet """ self._logger.debug("Getting neighbored access points.") wifi_neighbor_aps = WifiNeighborAPsGet() - response = await self._async_get("WifiNeighborAPsGet", timeout=30.0) + response = await self._async_get("WifiNeighborAPsGet", timeout=LONG_RUNNING) wifi_neighbor_aps.ParseFromString(await response.aread()) return list(wifi_neighbor_aps.neighbor_aps) diff --git a/devolo_plc_api/device_api/deviceapi.pyi b/devolo_plc_api/device_api/deviceapi.pyi index e1d6d81..4381a91 100644 --- a/devolo_plc_api/device_api/deviceapi.pyi +++ b/devolo_plc_api/device_api/deviceapi.pyi @@ -11,6 +11,8 @@ from devolo_plc_api.clients import Protobuf from devolo_plc_api.zeroconf import ZeroconfServiceInfo as ZeroconfServiceInfo from httpx import AsyncClient as AsyncClient +LONG_RUNNING: float + class DeviceApi(Protobuf): features: list[str] password: str diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8e10498..d0f6fee 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,11 +4,17 @@ 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). -## Unreleased +## [v1.5.0] - 2025/04/13 -- Drop support for Python 3.8 +### Added + +- Retry mechanism for timed-out connections - Use SPDX license identifier for project metadata +### Changed + +- Drop support for Python 3.8 + ## [v1.4.1] - 2023/09/14 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 5e82b78..a999f29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "httpx>=0.21.0", "protobuf>=4.22.0", "segno>=1.5.2", + "tenacity>=9.0.0", "zeroconf>=0.70.0", ] dynamic = [ diff --git a/tests/test_deviceapi.py b/tests/test_deviceapi.py index e57ff7a..082ebab 100644 --- a/tests/test_deviceapi.py +++ b/tests/test_deviceapi.py @@ -5,6 +5,7 @@ import sys from http import HTTPStatus from typing import TYPE_CHECKING +from unittest.mock import patch import pytest from httpx import ConnectTimeout @@ -341,10 +342,11 @@ async def test_device_unavailable(self, httpx_mock: HTTPXMock, mock_device: Devi """Test device being unavailable.""" await mock_device.async_connect() assert mock_device.device - httpx_mock.add_exception(ConnectTimeout("")) - with pytest.raises(DeviceUnavailable): + httpx_mock.add_exception(ConnectTimeout(""), is_reusable=True) + with pytest.raises(DeviceUnavailable), patch("asyncio.sleep"): await mock_device.device.async_get_wifi_connected_station() await mock_device.async_disconnect() + assert len(httpx_mock.get_requests()) == 3 @pytest.mark.asyncio @pytest.mark.parametrize("device_type", [DeviceType.PLC]) diff --git a/tests/test_plcnetapi.py b/tests/test_plcnetapi.py index 7993004..fe8189b 100644 --- a/tests/test_plcnetapi.py +++ b/tests/test_plcnetapi.py @@ -2,6 +2,7 @@ import sys from http import HTTPStatus +from unittest.mock import patch import pytest from httpx import ConnectTimeout @@ -118,10 +119,11 @@ async def test_device_unavailable(self, httpx_mock: HTTPXMock, mock_device: Devi """Test device being unavailable.""" await mock_device.async_connect() assert mock_device.plcnet - httpx_mock.add_exception(ConnectTimeout("")) - with pytest.raises(DeviceUnavailable): + httpx_mock.add_exception(ConnectTimeout(""), is_reusable=True) + with pytest.raises(DeviceUnavailable), patch("asyncio.sleep"): await mock_device.plcnet.async_get_network_overview() await mock_device.async_disconnect() + assert len(httpx_mock.get_requests()) == 3 @pytest.mark.asyncio @pytest.mark.parametrize("device_type", [DeviceType.PLC])