mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
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:
parent
40b98b70b0
commit
7bbd28d385
@ -1061,7 +1061,6 @@ omit =
|
|||||||
homeassistant/components/pushbullet/sensor.py
|
homeassistant/components/pushbullet/sensor.py
|
||||||
homeassistant/components/pushover/notify.py
|
homeassistant/components/pushover/notify.py
|
||||||
homeassistant/components/pushsafer/notify.py
|
homeassistant/components/pushsafer/notify.py
|
||||||
homeassistant/components/pyload/sensor.py
|
|
||||||
homeassistant/components/qbittorrent/__init__.py
|
homeassistant/components/qbittorrent/__init__.py
|
||||||
homeassistant/components/qbittorrent/coordinator.py
|
homeassistant/components/qbittorrent/coordinator.py
|
||||||
homeassistant/components/qbittorrent/sensor.py
|
homeassistant/components/qbittorrent/sensor.py
|
||||||
|
@ -1100,6 +1100,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/pvoutput/ @frenck
|
/tests/components/pvoutput/ @frenck
|
||||||
/homeassistant/components/pvpc_hourly_pricing/ @azogue
|
/homeassistant/components/pvpc_hourly_pricing/ @azogue
|
||||||
/tests/components/pvpc_hourly_pricing/ @azogue
|
/tests/components/pvpc_hourly_pricing/ @azogue
|
||||||
|
/homeassistant/components/pyload/ @tr4nt0r
|
||||||
|
/tests/components/pyload/ @tr4nt0r
|
||||||
/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
|
/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
|
||||||
/tests/components/qbittorrent/ @geoffreylagaisse @finder39
|
/tests/components/qbittorrent/ @geoffreylagaisse @finder39
|
||||||
/homeassistant/components/qingping/ @bdraco
|
/homeassistant/components/qingping/ @bdraco
|
||||||
|
7
homeassistant/components/pyload/const.py
Normal file
7
homeassistant/components/pyload/const.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"""Constants for the pyLoad integration."""
|
||||||
|
|
||||||
|
DOMAIN = "pyload"
|
||||||
|
|
||||||
|
DEFAULT_HOST = "localhost"
|
||||||
|
DEFAULT_NAME = "pyLoad"
|
||||||
|
DEFAULT_PORT = 8000
|
@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"domain": "pyload",
|
"domain": "pyload",
|
||||||
"name": "pyLoad",
|
"name": "pyLoad",
|
||||||
"codeowners": [],
|
"codeowners": ["@tr4nt0r"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/pyload",
|
"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"]
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,10 @@ from __future__ import annotations
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
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
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
@ -22,22 +25,22 @@ from homeassistant.const import (
|
|||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
CONF_SSL,
|
CONF_SSL,
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
CONTENT_TYPE_JSON,
|
|
||||||
UnitOfDataRate,
|
UnitOfDataRate,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
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
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
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__)
|
_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 = {
|
SENSOR_TYPES = {
|
||||||
"speed": SensorEntityDescription(
|
"speed": SensorEntityDescription(
|
||||||
@ -63,10 +66,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(
|
async def async_setup_platform(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config: ConfigType,
|
config: ConfigType,
|
||||||
add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
discovery_info: DiscoveryInfoType | None = None,
|
discovery_info: DiscoveryInfoType | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the pyLoad sensors."""
|
"""Set up the pyLoad sensors."""
|
||||||
@ -77,16 +80,26 @@ def setup_platform(
|
|||||||
username = config.get(CONF_USERNAME)
|
username = config.get(CONF_USERNAME)
|
||||||
password = config.get(CONF_PASSWORD)
|
password = config.get(CONF_PASSWORD)
|
||||||
monitored_types = config[CONF_MONITORED_VARIABLES]
|
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:
|
try:
|
||||||
pyloadapi = PyLoadAPI(api_url=url, username=username, password=password)
|
await pyloadapi.login()
|
||||||
except (
|
except CannotConnect as conn_err:
|
||||||
requests.exceptions.ConnectionError,
|
raise PlatformNotReady(
|
||||||
requests.exceptions.HTTPError,
|
"Unable to connect and retrieve data from pyLoad API"
|
||||||
) as conn_err:
|
) from conn_err
|
||||||
_LOGGER.error("Error setting up pyLoad API: %s", conn_err)
|
except ParserError as e:
|
||||||
return
|
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 = []
|
devices = []
|
||||||
for ng_type in monitored_types:
|
for ng_type in monitored_types:
|
||||||
@ -95,7 +108,7 @@ def setup_platform(
|
|||||||
)
|
)
|
||||||
devices.append(new_sensor)
|
devices.append(new_sensor)
|
||||||
|
|
||||||
add_entities(devices, True)
|
async_add_entities(devices, True)
|
||||||
|
|
||||||
|
|
||||||
class PyLoadSensor(SensorEntity):
|
class PyLoadSensor(SensorEntity):
|
||||||
@ -109,64 +122,33 @@ class PyLoadSensor(SensorEntity):
|
|||||||
self.type = sensor_type.key
|
self.type = sensor_type.key
|
||||||
self.api = api
|
self.api = api
|
||||||
self.entity_description = sensor_type
|
self.entity_description = sensor_type
|
||||||
|
self.data: StatusServerResponse
|
||||||
|
|
||||||
def update(self) -> None:
|
async def async_update(self) -> None:
|
||||||
"""Update state of sensor."""
|
"""Update state of sensor."""
|
||||||
try:
|
try:
|
||||||
self.api.update()
|
self.data = await self.api.get_status()
|
||||||
except requests.exceptions.ConnectionError:
|
except InvalidAuth:
|
||||||
# Error calling the API, already logged in api.update()
|
_LOGGER.info("Authentication failed, trying to reauthenticate")
|
||||||
return
|
try:
|
||||||
|
await self.api.login()
|
||||||
if self.api.status is None:
|
except InvalidAuth as e:
|
||||||
_LOGGER.debug(
|
raise PlatformNotReady(
|
||||||
"Update of %s requested, but no status is available", self.name
|
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."
|
||||||
)
|
)
|
||||||
return
|
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 (value := self.api.status.get(self.type)) is None:
|
value = getattr(self.data, self.type)
|
||||||
_LOGGER.warning("Unable to locate value for %s", self.type)
|
|
||||||
return
|
|
||||||
|
|
||||||
if "speed" in self.type and value > 0:
|
if "speed" in self.type and value > 0:
|
||||||
# Convert download rate from Bytes/s to MBytes/s
|
# Convert download rate from Bytes/s to MBytes/s
|
||||||
self._attr_native_value = round(value / 2**20, 2)
|
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()
|
|
||||||
|
@ -4775,7 +4775,7 @@
|
|||||||
},
|
},
|
||||||
"pyload": {
|
"pyload": {
|
||||||
"name": "pyLoad",
|
"name": "pyLoad",
|
||||||
"integration_type": "hub",
|
"integration_type": "service",
|
||||||
"config_flow": false,
|
"config_flow": false,
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
|
@ -59,6 +59,9 @@ PyFlume==0.6.5
|
|||||||
# homeassistant.components.fronius
|
# homeassistant.components.fronius
|
||||||
PyFronius==0.7.3
|
PyFronius==0.7.3
|
||||||
|
|
||||||
|
# homeassistant.components.pyload
|
||||||
|
PyLoadAPI==1.1.0
|
||||||
|
|
||||||
# homeassistant.components.mvglive
|
# homeassistant.components.mvglive
|
||||||
PyMVGLive==1.1.4
|
PyMVGLive==1.1.4
|
||||||
|
|
||||||
|
@ -50,6 +50,9 @@ PyFlume==0.6.5
|
|||||||
# homeassistant.components.fronius
|
# homeassistant.components.fronius
|
||||||
PyFronius==0.7.3
|
PyFronius==0.7.3
|
||||||
|
|
||||||
|
# homeassistant.components.pyload
|
||||||
|
PyLoadAPI==1.1.0
|
||||||
|
|
||||||
# homeassistant.components.met_eireann
|
# homeassistant.components.met_eireann
|
||||||
PyMetEireann==2021.8.0
|
PyMetEireann==2021.8.0
|
||||||
|
|
||||||
|
1
tests/components/pyload/__init__.py
Normal file
1
tests/components/pyload/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the pyLoad component."""
|
74
tests/components/pyload/conftest.py
Normal file
74
tests/components/pyload/conftest.py
Normal 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
|
16
tests/components/pyload/snapshots/test_sensor.ambr
Normal file
16
tests/components/pyload/snapshots/test_sensor.ambr
Normal 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',
|
||||||
|
})
|
||||||
|
# ---
|
84
tests/components/pyload/test_sensor.py
Normal file
84
tests/components/pyload/test_sensor.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user