Change qBittorrent lib to qbittorrentapi (#113394)

* Change qBittorrent lib to qbittorrentapi

* Fix tests

* Convert qbittorrent service to new lib

* Add missing translation key

* Catch APIConnectionError in service call

* Replace type ignore by Any typing

* Remove last type: ignore

* Use lib type for torrent_filter

* Change import format

* Fix remaining Any type

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Sébastien Clément 2024-06-10 14:27:20 +02:00 committed by GitHub
parent 960d1289ef
commit 80b2b05bd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 109 additions and 75 deletions

View File

@ -3,8 +3,7 @@
import logging
from typing import Any
from qbittorrent.client import LoginRequired
from requests.exceptions import RequestException
from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@ -118,10 +117,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
config_entry.data[CONF_PASSWORD],
config_entry.data[CONF_VERIFY_SSL],
)
except LoginRequired as err:
except LoginFailed as err:
raise ConfigEntryNotReady("Invalid credentials") from err
except RequestException as err:
raise ConfigEntryNotReady("Failed to connect") from err
except Forbidden403Error as err:
raise ConfigEntryNotReady("Fail to log in, banned user ?") from err
except APIConnectionError as exc:
raise ConfigEntryNotReady("Fail to connect to qBittorrent") from exc
coordinator = QBittorrentDataCoordinator(hass, client)
await coordinator.async_config_entry_first_refresh()

View File

@ -5,8 +5,7 @@ from __future__ import annotations
import logging
from typing import Any
from qbittorrent.client import LoginRequired
from requests.exceptions import RequestException
from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@ -46,9 +45,9 @@ class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_PASSWORD],
user_input[CONF_VERIFY_SSL],
)
except LoginRequired:
except (LoginFailed, Forbidden403Error):
errors = {"base": "invalid_auth"}
except RequestException:
except APIConnectionError:
errors = {"base": "cannot_connect"}
else:
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)

View File

@ -4,10 +4,16 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from qbittorrent import Client
from qbittorrent.client import LoginRequired
from qbittorrentapi import (
APIConnectionError,
Client,
Forbidden403Error,
LoginFailed,
SyncMainDataDictionary,
TorrentInfoList,
)
from qbittorrentapi.torrents import TorrentStatusesT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@ -18,8 +24,8 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for updating qBittorrent data."""
class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]):
"""Coordinator for updating QBittorrent data."""
def __init__(self, hass: HomeAssistant, client: Client) -> None:
"""Initialize coordinator."""
@ -39,22 +45,31 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
update_interval=timedelta(seconds=30),
)
async def _async_update_data(self) -> dict[str, Any]:
"""Async method to update QBittorrent data."""
async def _async_update_data(self) -> SyncMainDataDictionary:
try:
return await self.hass.async_add_executor_job(self.client.sync_main_data)
except LoginRequired as exc:
raise HomeAssistantError(str(exc)) from exc
async def get_torrents(self, torrent_filter: str) -> list[dict[str, Any]]:
"""Async method to get QBittorrent torrents."""
try:
torrents = await self.hass.async_add_executor_job(
lambda: self.client.torrents(filter=torrent_filter)
)
except LoginRequired as exc:
return await self.hass.async_add_executor_job(self.client.sync_maindata)
except (LoginFailed, Forbidden403Error) as exc:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="login_error"
) from exc
except APIConnectionError as exc:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="cannot_connect"
) from exc
async def get_torrents(self, torrent_filter: TorrentStatusesT) -> TorrentInfoList:
"""Async method to get QBittorrent torrents."""
try:
torrents = await self.hass.async_add_executor_job(
lambda: self.client.torrents_info(torrent_filter)
)
except (LoginFailed, Forbidden403Error) as exc:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="login_error"
) from exc
except APIConnectionError as exc:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="cannot_connect"
) from exc
return torrents

View File

@ -1,17 +1,18 @@
"""Helper functions for qBittorrent."""
from datetime import UTC, datetime
from typing import Any
from typing import Any, cast
from qbittorrent.client import Client
from qbittorrentapi import Client, TorrentDictionary, TorrentInfoList
def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client:
"""Create a qBittorrent client."""
client = Client(url, verify=verify_ssl)
client.login(username, password)
# Get an arbitrary attribute to test if connection succeeds
client.get_alternative_speed_status()
client = Client(
url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=verify_ssl
)
client.auth_log_in(username, password)
return client
@ -31,23 +32,24 @@ def format_unix_timestamp(timestamp) -> str:
return dt_object.isoformat()
def format_progress(torrent) -> str:
def format_progress(torrent: TorrentDictionary) -> str:
"""Format the progress of a torrent."""
progress = torrent["progress"]
progress = float(progress) * 100
progress = cast(float, torrent["progress"]) * 100
return f"{progress:.2f}"
def format_torrents(torrents: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
def format_torrents(
torrents: TorrentInfoList,
) -> dict[str, dict[str, Any]]:
"""Format a list of torrents."""
value = {}
for torrent in torrents:
value[torrent["name"]] = format_torrent(torrent)
value[str(torrent["name"])] = format_torrent(torrent)
return value
def format_torrent(torrent) -> dict[str, Any]:
def format_torrent(torrent: TorrentDictionary) -> dict[str, Any]:
"""Format a single torrent."""
value = {}
value["id"] = torrent["hash"]
@ -55,6 +57,7 @@ def format_torrent(torrent) -> dict[str, Any]:
value["percent_done"] = format_progress(torrent)
value["status"] = torrent["state"]
value["eta"] = seconds_to_hhmmss(torrent["eta"])
value["ratio"] = "{:.2f}".format(float(torrent["ratio"]))
ratio = cast(float, torrent["ratio"])
value["ratio"] = f"{ratio:.2f}"
return value

View File

@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["qbittorrent"],
"requirements": ["python-qbittorrent==0.4.3"]
"requirements": ["qbittorrent-api==2024.2.59"]
}

View File

@ -2,9 +2,10 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Mapping
from dataclasses import dataclass
import logging
from typing import Any, cast
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -35,8 +36,9 @@ SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents"
def get_state(coordinator: QBittorrentDataCoordinator) -> str:
"""Get current download/upload state."""
upload = coordinator.data["server_state"]["up_info_speed"]
download = coordinator.data["server_state"]["dl_info_speed"]
server_state = cast(Mapping, coordinator.data.get("server_state"))
upload = cast(int, server_state.get("up_info_speed"))
download = cast(int, server_state.get("dl_info_speed"))
if upload > 0 and download > 0:
return STATE_UP_DOWN
@ -47,6 +49,18 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str:
return STATE_IDLE
def get_dl(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current download speed."""
server_state = cast(Mapping, coordinator.data.get("server_state"))
return cast(int, server_state.get("dl_info_speed"))
def get_up(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current upload speed."""
server_state = cast(Mapping[str, Any], coordinator.data.get("server_state"))
return cast(int, server_state.get("up_info_speed"))
@dataclass(frozen=True, kw_only=True)
class QBittorrentSensorEntityDescription(SensorEntityDescription):
"""Entity description class for qBittorent sensors."""
@ -69,9 +83,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
value_fn=lambda coordinator: float(
coordinator.data["server_state"]["dl_info_speed"]
),
value_fn=get_dl,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_UPLOAD_SPEED,
@ -80,9 +92,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
value_fn=lambda coordinator: float(
coordinator.data["server_state"]["up_info_speed"]
),
value_fn=get_up,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_ALL_TORRENTS,
@ -165,16 +175,12 @@ def count_torrents_in_states(
) -> int:
"""Count the number of torrents in specified states."""
# When torrents are not in the returned data, there are none, return 0.
if "torrents" not in coordinator.data:
try:
torrents = cast(Mapping[str, Mapping], coordinator.data.get("torrents"))
if not states:
return len(torrents)
return len(
[torrent for torrent in torrents.values() if torrent.get("state") in states]
)
except AttributeError:
return 0
if not states:
return len(coordinator.data["torrents"])
return len(
[
torrent
for torrent in coordinator.data["torrents"].values()
if torrent["state"] in states
]
)

View File

@ -84,6 +84,9 @@
},
"login_error": {
"message": "A login error occured. Please check you username and password."
},
"cannot_connect": {
"message": "Can't connect to QBittorrent, please check your configuration."
}
}
}

View File

@ -2302,9 +2302,6 @@ python-otbr-api==2.6.0
# homeassistant.components.picnic
python-picnic-api==1.1.0
# homeassistant.components.qbittorrent
python-qbittorrent==0.4.3
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@ -2420,6 +2417,9 @@ pyzbar==0.1.7
# homeassistant.components.zerproc
pyzerproc==0.4.8
# homeassistant.components.qbittorrent
qbittorrent-api==2024.2.59
# homeassistant.components.qingping
qingping-ble==0.10.0

View File

@ -1799,9 +1799,6 @@ python-otbr-api==2.6.0
# homeassistant.components.picnic
python-picnic-api==1.1.0
# homeassistant.components.qbittorrent
python-qbittorrent==0.4.3
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@ -1893,6 +1890,9 @@ pyyardian==1.1.1
# homeassistant.components.zerproc
pyzerproc==0.4.8
# homeassistant.components.qbittorrent
qbittorrent-api==2024.2.59
# homeassistant.components.qingping
qingping-ble==0.10.0

View File

@ -59,8 +59,7 @@ async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) ->
# Test flow with wrong creds, fail with invalid_auth
with requests_mock.Mocker() as mock:
mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/transfer/speedLimitsMode")
mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", status_code=403)
mock.head(USER_INPUT[CONF_URL])
mock.post(
f"{USER_INPUT[CONF_URL]}/api/v2/auth/login",
text="Wrong username/password",
@ -74,11 +73,18 @@ async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) ->
assert result["errors"] == {"base": "invalid_auth"}
# Test flow with proper input, succeed
result = await hass.config_entries.flow.async_configure(
result["flow_id"], USER_INPUT
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
with requests_mock.Mocker() as mock:
mock.head(USER_INPUT[CONF_URL])
mock.post(
f"{USER_INPUT[CONF_URL]}/api/v2/auth/login",
text="Ok.",
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], USER_INPUT
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_URL: "http://localhost:8080",
CONF_USERNAME: "user",