Migrate library to PyLoadAPI 1.1.0 in pyLoad integration (#116053)

* Migrate pyLoad integration to externa API library

* Add const to .coveragerc

* raise update failed when cookie expired

* fix exceptions

* Add tests

* bump to PyLoadAPI 1.1.0

* remove unreachable code

* fix tests

* Improve logging and exception handling

- Modify manifest.json to update logger configuration.
- Improve error messages for authentication failures in sensor.py.
- Simplify and rename pytest fixtures in conftest.py.
- Update test cases in test_sensor.py to check for log entries and remove unnecessary code.

* remove exception translations
This commit is contained in:
Mr. Bubbles
2024-06-13 22:52:19 +02:00
committed by GitHub
parent 40b98b70b0
commit 7bbd28d385
12 changed files with 249 additions and 75 deletions

View File

@ -1061,7 +1061,6 @@ omit =
homeassistant/components/pushbullet/sensor.py
homeassistant/components/pushover/notify.py
homeassistant/components/pushsafer/notify.py
homeassistant/components/pyload/sensor.py
homeassistant/components/qbittorrent/__init__.py
homeassistant/components/qbittorrent/coordinator.py
homeassistant/components/qbittorrent/sensor.py

View File

@ -1100,6 +1100,8 @@ build.json @home-assistant/supervisor
/tests/components/pvoutput/ @frenck
/homeassistant/components/pvpc_hourly_pricing/ @azogue
/tests/components/pvpc_hourly_pricing/ @azogue
/homeassistant/components/pyload/ @tr4nt0r
/tests/components/pyload/ @tr4nt0r
/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
/tests/components/qbittorrent/ @geoffreylagaisse @finder39
/homeassistant/components/qingping/ @bdraco

View File

@ -0,0 +1,7 @@
"""Constants for the pyLoad integration."""
DOMAIN = "pyload"
DEFAULT_HOST = "localhost"
DEFAULT_NAME = "pyLoad"
DEFAULT_PORT = 8000

View File

@ -1,7 +1,10 @@
{
"domain": "pyload",
"name": "pyLoad",
"codeowners": [],
"codeowners": ["@tr4nt0r"],
"documentation": "https://www.home-assistant.io/integrations/pyload",
"iot_class": "local_polling"
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["pyloadapi"],
"requirements": ["PyLoadAPI==1.1.0"]
}

View File

@ -5,7 +5,10 @@ from __future__ import annotations
from datetime import timedelta
import logging
import requests
from aiohttp import CookieJar
from pyloadapi.api import PyLoadAPI
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
from pyloadapi.types import StatusServerResponse
import voluptuous as vol
from homeassistant.components.sensor import (
@ -22,22 +25,22 @@ from homeassistant.const import (
CONF_PORT,
CONF_SSL,
CONF_USERNAME,
CONTENT_TYPE_JSON,
UnitOfDataRate,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
from homeassistant.helpers.update_coordinator import UpdateFailed
from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT
_LOGGER = logging.getLogger(__name__)
DEFAULT_HOST = "localhost"
DEFAULT_NAME = "pyLoad"
DEFAULT_PORT = 8000
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15)
SCAN_INTERVAL = timedelta(seconds=15)
SENSOR_TYPES = {
"speed": SensorEntityDescription(
@ -63,10 +66,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
def setup_platform(
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the pyLoad sensors."""
@ -77,16 +80,26 @@ def setup_platform(
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
monitored_types = config[CONF_MONITORED_VARIABLES]
url = f"{protocol}://{host}:{port}/api/"
url = f"{protocol}://{host}:{port}/"
session = async_create_clientsession(
hass,
verify_ssl=False,
cookie_jar=CookieJar(unsafe=True),
)
pyloadapi = PyLoadAPI(session, api_url=url, username=username, password=password)
try:
pyloadapi = PyLoadAPI(api_url=url, username=username, password=password)
except (
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,
) as conn_err:
_LOGGER.error("Error setting up pyLoad API: %s", conn_err)
return
await pyloadapi.login()
except CannotConnect as conn_err:
raise PlatformNotReady(
"Unable to connect and retrieve data from pyLoad API"
) from conn_err
except ParserError as e:
raise PlatformNotReady("Unable to parse data from pyLoad API") from e
except InvalidAuth as e:
raise PlatformNotReady(
f"Authentication failed for {config[CONF_USERNAME]}, check your login credentials"
) from e
devices = []
for ng_type in monitored_types:
@ -95,7 +108,7 @@ def setup_platform(
)
devices.append(new_sensor)
add_entities(devices, True)
async_add_entities(devices, True)
class PyLoadSensor(SensorEntity):
@ -109,64 +122,33 @@ class PyLoadSensor(SensorEntity):
self.type = sensor_type.key
self.api = api
self.entity_description = sensor_type
self.data: StatusServerResponse
def update(self) -> None:
async def async_update(self) -> None:
"""Update state of sensor."""
try:
self.api.update()
except requests.exceptions.ConnectionError:
# Error calling the API, already logged in api.update()
return
self.data = await self.api.get_status()
except InvalidAuth:
_LOGGER.info("Authentication failed, trying to reauthenticate")
try:
await self.api.login()
except InvalidAuth as e:
raise PlatformNotReady(
f"Authentication failed for {self.api.username}, check your login credentials"
) from e
else:
raise UpdateFailed(
"Unable to retrieve data due to cookie expiration but re-authentication was successful."
)
except CannotConnect as e:
raise UpdateFailed(
"Unable to connect and retrieve data from pyLoad API"
) from e
except ParserError as e:
raise UpdateFailed("Unable to parse data from pyLoad API") from e
if self.api.status is None:
_LOGGER.debug(
"Update of %s requested, but no status is available", self.name
)
return
if (value := self.api.status.get(self.type)) is None:
_LOGGER.warning("Unable to locate value for %s", self.type)
return
value = getattr(self.data, self.type)
if "speed" in self.type and value > 0:
# Convert download rate from Bytes/s to MBytes/s
self._attr_native_value = round(value / 2**20, 2)
else:
self._attr_native_value = value
class PyLoadAPI:
"""Simple wrapper for pyLoad's API."""
def __init__(self, api_url, username=None, password=None):
"""Initialize pyLoad API and set headers needed later."""
self.api_url = api_url
self.status = None
self.headers = {"Content-Type": CONTENT_TYPE_JSON}
if username is not None and password is not None:
self.payload = {"username": username, "password": password}
self.login = requests.post(f"{api_url}login", data=self.payload, timeout=5)
self.update()
def post(self):
"""Send a POST request and return the response as a dict."""
try:
response = requests.post(
f"{self.api_url}statusServer",
cookies=self.login.cookies,
headers=self.headers,
timeout=5,
)
response.raise_for_status()
_LOGGER.debug("JSON Response: %s", response.json())
return response.json()
except requests.exceptions.ConnectionError as conn_exc:
_LOGGER.error("Failed to update pyLoad status. Error: %s", conn_exc)
raise
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Update cached response."""
self.status = self.post()

View File

@ -4775,7 +4775,7 @@
},
"pyload": {
"name": "pyLoad",
"integration_type": "hub",
"integration_type": "service",
"config_flow": false,
"iot_class": "local_polling"
},

View File

@ -59,6 +59,9 @@ PyFlume==0.6.5
# homeassistant.components.fronius
PyFronius==0.7.3
# homeassistant.components.pyload
PyLoadAPI==1.1.0
# homeassistant.components.mvglive
PyMVGLive==1.1.4

View File

@ -50,6 +50,9 @@ PyFlume==0.6.5
# homeassistant.components.fronius
PyFronius==0.7.3
# homeassistant.components.pyload
PyLoadAPI==1.1.0
# homeassistant.components.met_eireann
PyMetEireann==2021.8.0

View File

@ -0,0 +1 @@
"""Tests for the pyLoad component."""

View File

@ -0,0 +1,74 @@
"""Fixtures for pyLoad integration tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from pyloadapi.types import LoginResponse, StatusServerResponse
import pytest
from homeassistant.const import (
CONF_HOST,
CONF_MONITORED_VARIABLES,
CONF_NAME,
CONF_PASSWORD,
CONF_PLATFORM,
CONF_PORT,
CONF_SSL,
CONF_USERNAME,
)
from homeassistant.helpers.typing import ConfigType
@pytest.fixture
def pyload_config() -> ConfigType:
"""Mock pyload configuration entry."""
return {
"sensor": {
CONF_PLATFORM: "pyload",
CONF_HOST: "localhost",
CONF_PORT: 8000,
CONF_USERNAME: "username",
CONF_PASSWORD: "password",
CONF_SSL: True,
CONF_MONITORED_VARIABLES: ["speed"],
CONF_NAME: "pyload",
}
}
@pytest.fixture
def mock_pyloadapi() -> Generator[AsyncMock, None, None]:
"""Mock PyLoadAPI."""
with (
patch(
"homeassistant.components.pyload.sensor.PyLoadAPI",
autospec=True,
) as mock_client,
):
client = mock_client.return_value
client.username = "username"
client.login.return_value = LoginResponse.from_dict(
{
"_permanent": True,
"authenticated": True,
"id": 2,
"name": "username",
"role": 0,
"perms": 0,
"template": "default",
"_flashes": [["message", "Logged in successfully"]],
}
)
client.get_status.return_value = StatusServerResponse.from_dict(
{
"pause": False,
"active": 1,
"queue": 6,
"total": 37,
"speed": 5405963.0,
"download": True,
"reconnect": False,
"captcha": False,
}
)
yield client

View File

@ -0,0 +1,16 @@
# serializer version: 1
# name: test_setup
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'pyload Speed',
'unit_of_measurement': <UnitOfDataRate.MEGABYTES_PER_SECOND: 'MB/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.16',
})
# ---

View File

@ -0,0 +1,84 @@
"""Tests for the pyLoad Sensors."""
from unittest.mock import AsyncMock
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.sensor import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
@pytest.mark.usefixtures("mock_pyloadapi")
async def test_setup(
hass: HomeAssistant,
pyload_config: ConfigType,
snapshot: SnapshotAssertion,
) -> None:
"""Test setup of the pyload sensor platform."""
assert await async_setup_component(hass, DOMAIN, pyload_config)
await hass.async_block_till_done()
result = hass.states.get("sensor.pyload_speed")
assert result == snapshot
@pytest.mark.parametrize(
("exception", "expected_exception"),
[
(CannotConnect, "Unable to connect and retrieve data from pyLoad API"),
(ParserError, "Unable to parse data from pyLoad API"),
(
InvalidAuth,
"Authentication failed for username, check your login credentials",
),
],
)
async def test_setup_exceptions(
hass: HomeAssistant,
pyload_config: ConfigType,
mock_pyloadapi: AsyncMock,
exception: Exception,
expected_exception: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test exceptions during setup up pyLoad platform."""
mock_pyloadapi.login.side_effect = exception
assert await async_setup_component(hass, DOMAIN, pyload_config)
await hass.async_block_till_done()
assert len(hass.states.async_all(DOMAIN)) == 0
assert expected_exception in caplog.text
@pytest.mark.parametrize(
("exception", "expected_exception"),
[
(CannotConnect, "UpdateFailed"),
(ParserError, "UpdateFailed"),
(InvalidAuth, "UpdateFailed"),
],
)
async def test_sensor_update_exceptions(
hass: HomeAssistant,
pyload_config: ConfigType,
mock_pyloadapi: AsyncMock,
exception: Exception,
expected_exception: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test exceptions during update of pyLoad sensor."""
mock_pyloadapi.get_status.side_effect = exception
assert await async_setup_component(hass, DOMAIN, pyload_config)
await hass.async_block_till_done()
assert len(hass.states.async_all(DOMAIN)) == 0
assert expected_exception in caplog.text