mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02: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:
@ -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
|
||||
|
@ -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
|
||||
|
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",
|
||||
"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"]
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -4775,7 +4775,7 @@
|
||||
},
|
||||
"pyload": {
|
||||
"name": "pyLoad",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "service",
|
||||
"config_flow": false,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
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
|
Reference in New Issue
Block a user