Add config flow to qBittorrent (#82560)

* qbittorrent: implement config_flow

Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com>

* qbittorrent: add English translations

Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com>

* qbittorrent: create sensors with config_flow

Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com>

* qbittorrent: set unique_id and icon

Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com>

* qbittorrent: add tests for config_flow

Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com>

* qbittorrent: detect duplicate config entries

Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com>

* qbittorrent: import YAML config

Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com>

* qbittorrent: update coveragerc

Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com>

* qbittorrent: delete translations file

* create `deprecated_yaml` issue in `setup_platform`

* move qbittorrent test fixtures to conftest.py

* improve code quality & remove wrong unique_id

* keep PLATFORM_SCHEMA until YAML support is removed

* remove CONF_NAME in config entry, fix setup_entry

* improve test suite

* clean up QBittorrentSensor class

* improve user flow tests

* explicit result assertion & minor tweaks in tests

Co-authored-by: epenet <epenet@users.noreply.github.com>

* implement entry unloading

Co-authored-by: epenet <epenet@users.noreply.github.com>

* add type hints

* tweak config_flow data handling

---------

Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com>
Co-authored-by: epenet <epenet@users.noreply.github.com>
pull/90481/head
Chris Xiao 2023-03-29 16:13:41 -04:00 committed by GitHub
parent 5bc9545b81
commit 7c778847e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 375 additions and 23 deletions

View File

@ -938,6 +938,7 @@ omit =
homeassistant/components/pushover/notify.py
homeassistant/components/pushsafer/notify.py
homeassistant/components/pyload/sensor.py
homeassistant/components/qbittorrent/__init__.py
homeassistant/components/qbittorrent/sensor.py
homeassistant/components/qnap/sensor.py
homeassistant/components/qrcode/image_processing.py

View File

@ -933,6 +933,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/pvpc_hourly_pricing/ @azogue
/tests/components/pvpc_hourly_pricing/ @azogue
/homeassistant/components/qbittorrent/ @geoffreylagaisse
/tests/components/qbittorrent/ @geoffreylagaisse
/homeassistant/components/qingping/ @bdraco @skgsergio
/tests/components/qingping/ @bdraco @skgsergio
/homeassistant/components/qld_bushfire/ @exxamalte

View File

@ -1 +1,54 @@
"""The qbittorrent component."""
import logging
from qbittorrent.client import LoginRequired
from requests.exceptions import RequestException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .helpers import setup_client
PLATFORMS = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up qBittorrent from a config entry."""
hass.data.setdefault(DOMAIN, {})
try:
hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job(
setup_client,
entry.data[CONF_URL],
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
entry.data[CONF_VERIFY_SSL],
)
except LoginRequired as err:
_LOGGER.error("Invalid credentials")
raise ConfigEntryNotReady from err
except RequestException as err:
_LOGGER.error("Failed to connect")
raise ConfigEntryNotReady from err
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload qBittorrent config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
del hass.data[DOMAIN][entry.entry_id]
if not hass.data[DOMAIN]:
del hass.data[DOMAIN]
return unload_ok

View File

@ -0,0 +1,76 @@
"""Config flow for qBittorrent."""
from __future__ import annotations
import logging
from typing import Any
from qbittorrent.client import LoginRequired
from requests.exceptions import RequestException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.data_entry_flow import FlowResult
from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN
from .helpers import setup_client
_LOGGER = logging.getLogger(__name__)
USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL, default=DEFAULT_URL): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
}
)
class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for the qBittorrent integration."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a user-initiated config flow."""
errors = {}
if user_input is not None:
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
try:
await self.hass.async_add_executor_job(
setup_client,
user_input[CONF_URL],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
user_input[CONF_VERIFY_SSL],
)
except LoginRequired:
errors = {"base": "invalid_auth"}
except RequestException:
errors = {"base": "cannot_connect"}
else:
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
schema = self.add_suggested_values_to_schema(USER_DATA_SCHEMA, user_input)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_import(self, config: dict[str, Any]) -> FlowResult:
"""Import a config entry from configuration.yaml."""
self._async_abort_entries_match({CONF_URL: config[CONF_URL]})
return self.async_create_entry(
title=config.get(CONF_NAME, DEFAULT_NAME),
data={
CONF_URL: config[CONF_URL],
CONF_USERNAME: config[CONF_USERNAME],
CONF_PASSWORD: config[CONF_PASSWORD],
CONF_VERIFY_SSL: True,
},
)

View File

@ -1,3 +1,7 @@
"""Constants for qBittorrent."""
from typing import Final
DOMAIN: Final = "qbittorrent"
DEFAULT_NAME = "qBittorrent"
DEFAULT_URL = "http://127.0.0.1:8080"

View File

@ -0,0 +1,11 @@
"""Helper functions for qBittorrent."""
from qbittorrent.client import Client
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()
return client

View File

@ -2,6 +2,7 @@
"domain": "qbittorrent",
"name": "qBittorrent",
"codeowners": ["@geoffreylagaisse"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/qbittorrent",
"integration_type": "service",
"iot_class": "local_polling",

View File

@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
@ -23,12 +24,12 @@ from homeassistant.const import (
UnitOfDataRate,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import issue_registry as ir
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DEFAULT_NAME
from .const import DEFAULT_NAME, DOMAIN
_LOGGER = logging.getLogger(__name__)
@ -69,31 +70,41 @@ 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 qBittorrent sensors."""
"""Set up the qBittorrent platform."""
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
)
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_yaml",
breaks_in_ha_version="2023.6.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
)
try:
client = Client(config[CONF_URL])
client.login(config[CONF_USERNAME], config[CONF_PASSWORD])
except LoginRequired:
_LOGGER.error("Invalid authentication")
return
except RequestException as err:
_LOGGER.error("Connection failed")
raise PlatformNotReady from err
name = config.get(CONF_NAME)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entites: AddEntitiesCallback,
) -> None:
"""Set up qBittorrent sensor entries."""
client: Client = hass.data[DOMAIN][config_entry.entry_id]
entities = [
QBittorrentSensor(description, client, name) for description in SENSOR_TYPES
QBittorrentSensor(description, client, config_entry)
for description in SENSOR_TYPES
]
add_entities(entities, True)
async_add_entites(entities, True)
def format_speed(speed):
@ -108,14 +119,15 @@ class QBittorrentSensor(SensorEntity):
def __init__(
self,
description: SensorEntityDescription,
qbittorrent_client,
client_name,
qbittorrent_client: Client,
config_entry: ConfigEntry,
) -> None:
"""Initialize the qBittorrent sensor."""
self.entity_description = description
self.client = qbittorrent_client
self._attr_name = f"{client_name} {description.name}"
self._attr_unique_id = f"{config_entry.entry_id}-{description.key}"
self._attr_name = f"{config_entry.title} {description.name}"
self._attr_available = False
def update(self) -> None:

View File

@ -0,0 +1,27 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"issues": {
"deprecated_yaml": {
"title": "The qBittorrent YAML configuration is being removed",
"description": "Configuring qBittorrent using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the qBittorrent YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
}
}
}

View File

@ -339,6 +339,7 @@ FLOWS = {
"pushover",
"pvoutput",
"pvpc_hourly_pricing",
"qbittorrent",
"qingping",
"qnap_qsw",
"rachio",

View File

@ -4316,7 +4316,7 @@
"qbittorrent": {
"name": "qBittorrent",
"integration_type": "service",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling"
},
"qingping": {

View File

@ -1507,6 +1507,9 @@ python-otbr-api==1.0.9
# homeassistant.components.picnic
python-picnic-api==1.1.0
# homeassistant.components.qbittorrent
python-qbittorrent==0.4.2
# homeassistant.components.smarttub
python-smarttub==0.0.33

View File

@ -0,0 +1 @@
"""Tests for the qBittorrent integration."""

View File

@ -0,0 +1,25 @@
"""Fixtures for testing qBittorrent component."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
import requests_mock
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Mock qbittorrent entry setup."""
with patch(
"homeassistant.components.qbittorrent.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_api() -> Generator[requests_mock.Mocker, None, None]:
"""Mock the qbittorrent API."""
with requests_mock.Mocker() as mocker:
mocker.get("http://localhost:8080/api/v2/app/preferences", status_code=403)
mocker.get("http://localhost:8080/api/v2/transfer/speedLimitsMode")
mocker.post("http://localhost:8080/api/v2/auth/login", text="Ok.")
yield mocker

View File

@ -0,0 +1,136 @@
"""Test the qBittorrent config flow."""
import pytest
from requests.exceptions import RequestException
import requests_mock
from homeassistant.components.qbittorrent.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import (
CONF_PASSWORD,
CONF_SOURCE,
CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
USER_INPUT = {
CONF_URL: "http://localhost:8080",
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
CONF_VERIFY_SSL: True,
}
YAML_IMPORT = {
CONF_URL: "http://localhost:8080",
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
}
async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> None:
"""Test the user flow."""
# Open flow as USER with no input
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
# Test flow with connection failure, fail with cannot_connect
with requests_mock.Mocker() as mock:
mock.get(
f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences",
exc=RequestException,
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], USER_INPUT
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
# 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.post(
f"{USER_INPUT[CONF_URL]}/api/v2/auth/login",
text="Wrong username/password",
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], USER_INPUT
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
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"] == FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_URL: "http://localhost:8080",
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
CONF_VERIFY_SSL: True,
}
async def test_flow_user_already_configured(hass: HomeAssistant) -> None:
"""Test user initialized flow with duplicate server."""
entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT)
entry.add_to_hass(hass)
# Open flow as USER with no input
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
# Test flow with duplicate config
result = await hass.config_entries.flow.async_configure(
result["flow_id"], USER_INPUT
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_flow_import(hass: HomeAssistant) -> None:
"""Test import step."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_IMPORT},
data=YAML_IMPORT,
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_URL: "http://localhost:8080",
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
CONF_VERIFY_SSL: True,
}
async def test_flow_import_already_configured(hass: HomeAssistant) -> None:
"""Test import step already configured."""
entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_IMPORT},
data=YAML_IMPORT,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"