Add config flow to remember_the_milk

This commit is contained in:
Martin Hjelmare 2023-11-05 17:43:06 +01:00
parent d2a660714f
commit f218c37364
16 changed files with 864 additions and 295 deletions

View File

@ -1,26 +1,29 @@
"""Support to interact with Remember The Milk."""
"""The Remember The Milk integration."""
from rtmapi import Rtm
from __future__ import annotations
from aiortm import AioRTMClient, Auth, AuthError
import voluptuous as vol
from homeassistant.components import configurator
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
CONF_ID,
CONF_NAME,
CONF_TOKEN,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from .const import LOGGER
from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER
from .entity import RememberTheMilkEntity
from .storage import RememberTheMilkConfiguration
# httplib2 is a transitive dependency from RtmAPI. If this dependency is not
# set explicitly, the library does not work.
DOMAIN = "remember_the_milk"
CONF_SHARED_SECRET = "shared_secret"
RTM_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
@ -42,104 +45,106 @@ SERVICE_SCHEMA_CREATE_TASK = vol.Schema(
SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string})
DATA_COMPONENT = "component"
DATA_ENTITY_ID = "entity_id"
DATA_STORAGE = "storage"
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Remember the milk component."""
component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass)
stored_rtm_config = RememberTheMilkConfiguration(hass)
hass.data[DOMAIN] = {}
hass.data[DOMAIN][DATA_COMPONENT] = EntityComponent[RememberTheMilkEntity](
LOGGER, DOMAIN, hass
)
storage = hass.data[DOMAIN][DATA_STORAGE] = RememberTheMilkConfiguration(hass)
await hass.async_add_executor_job(storage.setup)
if DOMAIN not in config:
return True
for rtm_config in config[DOMAIN]:
account_name = rtm_config[CONF_NAME]
LOGGER.debug("Adding Remember the milk account %s", account_name)
api_key = rtm_config[CONF_API_KEY]
shared_secret = rtm_config[CONF_SHARED_SECRET]
token = stored_rtm_config.get_token(account_name)
if token:
LOGGER.debug("found token for account %s", account_name)
_create_instance(
hass,
account_name,
api_key,
shared_secret,
token,
stored_rtm_config,
component,
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=rtm_config,
)
else:
_register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component
)
LOGGER.debug("Finished adding all Remember the milk accounts")
return True
def _create_instance(
hass, account_name, api_key, shared_secret, token, stored_rtm_config, component
):
entity = RememberTheMilkEntity(
account_name, api_key, shared_secret, token, stored_rtm_config
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Remember The Milk from a config entry."""
component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][
DATA_COMPONENT
]
storage: RememberTheMilkConfiguration = hass.data[DOMAIN][DATA_STORAGE]
rtm_config = entry.data
account_name: str = rtm_config[CONF_USERNAME]
LOGGER.debug("Adding Remember the milk account %s", account_name)
api_key: str = rtm_config[CONF_API_KEY]
shared_secret: str = rtm_config[CONF_SHARED_SECRET]
token: str | None = rtm_config[CONF_TOKEN] # None if imported from YAML
client = AioRTMClient(
Auth(
client_session=async_get_clientsession(hass),
api_key=api_key,
shared_secret=shared_secret,
auth_token=token,
permission="delete",
)
component.add_entities([entity])
hass.services.register(
)
token_valid = True
try:
await client.rtm.api.check_token()
except AuthError as err:
token_valid = False
if entry.source == SOURCE_IMPORT:
raise ConfigEntryAuthFailed("Missing token") from err
if (known_entity_ids := hass.data[DOMAIN].get(DATA_ENTITY_ID)) and (
entity_id := known_entity_ids.get(account_name)
):
await component.async_remove_entity(entity_id)
# The entity will be deprecated when a todo platform is added.
entity = RememberTheMilkEntity(
name=account_name,
client=client,
config_entry_id=entry.entry_id,
storage=storage,
token_valid=token_valid,
)
await component.async_add_entities([entity])
known_entity_ids = hass.data[DOMAIN].setdefault(DATA_ENTITY_ID, {})
known_entity_ids[account_name] = entity.entity_id
# The services are registered here for now because they need the account name.
# The services will be deprecated when a todo platform is added.
hass.services.async_register(
DOMAIN,
f"{account_name}_create_task",
entity.create_task,
schema=SERVICE_SCHEMA_CREATE_TASK,
)
hass.services.register(
hass.services.async_register(
DOMAIN,
f"{account_name}_complete_task",
entity.complete_task,
schema=SERVICE_SCHEMA_COMPLETE_TASK,
)
if not token_valid:
raise ConfigEntryAuthFailed("Invalid token")
def _register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component
):
request_id = None
api = Rtm(api_key, shared_secret, "write", None)
url, frob = api.authenticate_desktop()
LOGGER.debug("Sent authentication request to server")
return True
def register_account_callback(fields: list[dict[str, str]]) -> None:
"""Call for register the configurator."""
api.retrieve_token(frob)
token = api.token
if api.token is None:
LOGGER.error("Failed to register, please try again")
configurator.notify_errors(
hass, request_id, "Failed to register, please try again."
)
return
stored_rtm_config.set_token(account_name, token)
LOGGER.debug("Retrieved new token from server")
_create_instance(
hass,
account_name,
api_key,
shared_secret,
token,
stored_rtm_config,
component,
)
configurator.request_done(hass, request_id)
request_id = configurator.request_config(
hass,
f"{DOMAIN} - {account_name}",
callback=register_account_callback,
description=(
"You need to log in to Remember The Milk to"
"connect your account. \n\n"
"Step 1: Click on the link 'Remember The Milk login'\n\n"
"Step 2: Click on 'login completed'"
),
link_name="Remember The Milk login",
link_url=url,
submit_caption="login completed",
)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][
DATA_COMPONENT
]
entity_id = hass.data[DOMAIN][DATA_ENTITY_ID].pop(entry.data[CONF_USERNAME])
await component.async_remove_entity(entity_id)
return True

View File

@ -0,0 +1,157 @@
"""Config flow for Remember The Milk integration."""
from __future__ import annotations
import asyncio
from collections.abc import Mapping
from typing import Any
from aiortm import Auth, AuthError, ResponseError
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_IMPORT,
SOURCE_REAUTH,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_TOKEN, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER
TOKEN_TIMEOUT_SEC = 30
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_SHARED_SECRET): str,
}
)
class RTMConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Remember The Milk."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._auth: Auth | None = None
self._url: str | None = None
self._frob: str | None = None
self._auth_data: dict[str, str] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._auth_data = user_input
auth = self._auth = Auth(
client_session=async_get_clientsession(self.hass),
api_key=user_input[CONF_API_KEY],
shared_secret=user_input[CONF_SHARED_SECRET],
permission="delete",
)
try:
self._url, self._frob = await auth.authenticate_desktop()
except AuthError:
errors["base"] = "invalid_auth"
except ResponseError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001 pylint: disable=broad-except
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return await self.async_step_auth()
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Authorize the application."""
assert self._url is not None
if user_input is not None:
return await self._get_token()
return self.async_show_form(
step_id="auth", description_placeholders={"url": self._url}
)
async def _get_token(self) -> ConfigFlowResult:
"""Get token and create config entry."""
assert self._auth is not None
assert self._frob is not None
assert self._auth_data is not None
try:
async with asyncio.timeout(TOKEN_TIMEOUT_SEC):
token = await self._auth.get_token(self._frob)
except TimeoutError:
return self.async_abort(reason="timeout_token")
except AuthError:
return self.async_abort(reason="invalid_auth")
except ResponseError:
return self.async_abort(reason="cannot_connect")
except Exception: # noqa: BLE001 pylint: disable=broad-except
LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
await self.async_set_unique_id(token["user"]["id"])
data = {
**self._auth_data,
CONF_TOKEN: token["token"],
CONF_USERNAME: token["user"]["username"],
}
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
if reauth_entry.source == SOURCE_IMPORT and reauth_entry.unique_id is None:
# Imported entries do not have a token nor unique id.
# Update unique id to match the new token.
# This case can be removed when the import step is removed.
self.hass.config_entries.async_update_entry(
reauth_entry, data=data, unique_id=token["user"]["id"]
)
else:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates=data,
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=token["user"]["fullname"],
data=data,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
return await self.async_step_user()
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import a config entry.
The token will be retrieved after config entry setup in a reauth flow.
"""
name = import_info.pop(CONF_NAME)
return self.async_create_entry(
title=name,
data=import_info | {CONF_USERNAME: name, CONF_TOKEN: None},
)

View File

@ -2,4 +2,6 @@
import logging
CONF_SHARED_SECRET = "shared_secret"
DOMAIN = "remember_the_milk"
LOGGER = logging.getLogger(__package__)

View File

@ -1,48 +1,35 @@
"""Support to interact with Remember The Milk."""
from rtmapi import Rtm, RtmRequestFailedException
from aiortm import AioRTMClient, AioRTMError, AuthError
from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK
from homeassistant.core import ServiceCall
from homeassistant.core import ServiceCall, callback
from homeassistant.helpers.entity import Entity
from .const import LOGGER
from .storage import RememberTheMilkConfiguration
class RememberTheMilkEntity(Entity):
"""Representation of an interface to Remember The Milk."""
def __init__(self, name, api_key, shared_secret, token, rtm_config):
def __init__(
self,
*,
name: str,
client: AioRTMClient,
config_entry_id: str,
storage: RememberTheMilkConfiguration,
token_valid: bool,
) -> None:
"""Create new instance of Remember The Milk component."""
self._name = name
self._api_key = api_key
self._shared_secret = shared_secret
self._token = token
self._rtm_config = rtm_config
self._rtm_api = Rtm(api_key, shared_secret, "delete", token)
self._token_valid = None
self._check_token()
LOGGER.debug("Instance created for account %s", self._name)
self._rtm_config = storage
self._client = client
self._config_entry_id = config_entry_id
self._token_valid = token_valid
def _check_token(self):
"""Check if the API token is still valid.
If it is not valid any more, delete it from the configuration. This
will trigger a new authentication process.
"""
valid = self._rtm_api.token_valid()
if not valid:
LOGGER.error(
"Token for account %s is invalid. You need to register again!",
self.name,
)
self._rtm_config.delete_token(self._name)
self._token_valid = False
else:
self._token_valid = True
return self._token_valid
def create_task(self, call: ServiceCall) -> None:
async def create_task(self, call: ServiceCall) -> None:
"""Create a new task on Remember The Milk.
You can use the smart syntax to define the attributes of a new task,
@ -50,31 +37,37 @@ class RememberTheMilkEntity(Entity):
due date to today.
"""
try:
task_name = call.data[CONF_NAME]
hass_id = call.data.get(CONF_ID)
rtm_id = None
task_name: str = call.data[CONF_NAME]
hass_id: str | None = call.data.get(CONF_ID)
rtm_id: tuple[int, int, int] | None = None
if hass_id is not None:
rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
rtm_id = await self.hass.async_add_executor_job(
self._rtm_config.get_rtm_id, self._name, hass_id
)
timeline_response = await self._client.rtm.timelines.create()
timeline = timeline_response.timeline
if rtm_id is None:
result = self._rtm_api.rtm.tasks.add(
timeline=timeline, name=task_name, parse="1"
add_response = await self._client.rtm.tasks.add(
timeline=timeline, name=task_name, parse=True
)
LOGGER.debug(
"Created new task '%s' in account %s", task_name, self.name
)
if hass_id is not None:
self._rtm_config.set_rtm_id(
if hass_id is None:
return
task_list = add_response.task_list
taskseries = task_list.taskseries[0]
await self.hass.async_add_executor_job(
self._rtm_config.set_rtm_id,
self._name,
hass_id,
result.list.id,
result.list.taskseries.id,
result.list.taskseries.task.id,
task_list.id,
taskseries.id,
taskseries.task[0].id,
)
else:
self._rtm_api.rtm.tasks.setName(
await self._client.rtm.tasks.set_name(
name=task_name,
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
@ -87,17 +80,26 @@ class RememberTheMilkEntity(Entity):
self.name,
task_name,
)
except RtmRequestFailedException as rtm_exception:
except AuthError as err:
LOGGER.error(
"Invalid authentication when creating task for account %s: %s",
self._name,
err,
)
self._handle_token(False)
except AioRTMError as err:
LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
rtm_exception,
err,
)
def complete_task(self, call: ServiceCall) -> None:
async def complete_task(self, call: ServiceCall) -> None:
"""Complete a task that was previously created by this component."""
hass_id = call.data[CONF_ID]
rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
rtm_id = await self.hass.async_add_executor_job(
self._rtm_config.get_rtm_id, self._name, hass_id
)
if rtm_id is None:
LOGGER.error(
(
@ -109,31 +111,50 @@ class RememberTheMilkEntity(Entity):
)
return
try:
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
self._rtm_api.rtm.tasks.complete(
result = await self._client.rtm.timelines.create()
timeline = result.timeline
await self._client.rtm.tasks.complete(
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
task_id=rtm_id[2],
timeline=timeline,
)
self._rtm_config.delete_rtm_id(self._name, hass_id)
await self.hass.async_add_executor_job(
self._rtm_config.delete_rtm_id, self._name, hass_id
)
LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name)
except RtmRequestFailedException as rtm_exception:
except AuthError as err:
LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
"Invalid authentication when completing task with id %s for account %s: %s",
hass_id,
self._name,
rtm_exception,
err,
)
self._handle_token(False)
except AioRTMError as err:
LOGGER.error(
"Error completing task with id %s for account %s: %s",
hass_id,
self._name,
err,
)
@property
def name(self):
def name(self) -> str:
"""Return the name of the device."""
return self._name
@property
def state(self):
def state(self) -> str:
"""Return the state of the device."""
if not self._token_valid:
return "API token invalid"
return STATE_OK
@callback
def _handle_token(self, token_valid: bool) -> None:
self._token_valid = token_valid
self.async_write_ha_state()
self.hass.async_create_task(
self.hass.config_entries.async_reload(self._config_entry_id)
)

View File

@ -2,10 +2,10 @@
"domain": "remember_the_milk",
"name": "Remember The Milk",
"codeowners": [],
"dependencies": ["configurator"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/remember_the_milk",
"iot_class": "cloud_push",
"loggers": ["rtmapi"],
"loggers": ["aiortm"],
"quality_scale": "legacy",
"requirements": ["RtmAPI==0.7.2", "httplib2==0.20.4"]
"requirements": ["aiortm==0.9.45"]
}

View File

@ -1,11 +1,11 @@
"""Store RTM configuration in Home Assistant storage."""
"""Provide storage for Remember The Milk integration."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
from .const import LOGGER
@ -23,7 +23,10 @@ class RememberTheMilkConfiguration:
def __init__(self, hass: HomeAssistant) -> None:
"""Create new instance of configuration."""
self._config_file_path = hass.config.path(CONFIG_FILE_NAME)
self._config = {}
self._config: dict[str, Any] = {}
def setup(self) -> None:
"""Set up the configuration."""
LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
try:
self._config = json.loads(
@ -48,26 +51,6 @@ class RememberTheMilkConfiguration:
json.dumps(self._config), encoding="utf8"
)
def get_token(self, profile_name: str) -> str | None:
"""Get the server token for a profile."""
if profile_name in self._config:
return self._config[profile_name][CONF_TOKEN]
return None
def set_token(self, profile_name: str, token: str) -> None:
"""Store a new server token for a profile."""
self._initialize_profile(profile_name)
self._config[profile_name][CONF_TOKEN] = token
self._save_config()
def delete_token(self, profile_name: str) -> None:
"""Delete a token for a profile.
Usually called when the token has expired.
"""
self._config.pop(profile_name, None)
self._save_config()
def _initialize_profile(self, profile_name: str) -> None:
"""Initialize the data structures for a profile."""
if profile_name not in self._config:
@ -77,7 +60,7 @@ class RememberTheMilkConfiguration:
def get_rtm_id(
self, profile_name: str, hass_id: str
) -> tuple[str, str, str] | None:
) -> tuple[int, int, int] | None:
"""Get the RTM ids for a Home Assistant task ID.
The id of a RTM tasks consists of the tuple:
@ -93,11 +76,11 @@ class RememberTheMilkConfiguration:
self,
profile_name: str,
hass_id: str,
list_id: str,
time_series_id: str,
rtm_task_id: str,
list_id: int,
time_series_id: int,
rtm_task_id: int,
) -> None:
"""Add/Update the RTM task ID for a Home Assistant task IS."""
"""Add/Update the RTM task ID for a Home Assistant task ID."""
self._initialize_profile(profile_name)
id_tuple = {
CONF_LIST_ID: list_id,

View File

@ -24,5 +24,36 @@
}
}
}
},
"config": {
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"shared_secret": "Shared secret"
}
},
"auth": {
"description": "Follow the link to authorize Home Assistant to access your Remember The Milk account. When done, click on the button below to continue.\n\n[Authorize]({url})"
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Remember The Milk integration needs to re-authenticate your account"
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"timeout_token": "Timeout getting access token",
"unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account.",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@ -512,6 +512,7 @@ FLOWS = {
"rdw",
"recollect_waste",
"refoss",
"remember_the_milk",
"renault",
"renson",
"reolink",

View File

@ -5231,7 +5231,7 @@
"remember_the_milk": {
"name": "Remember The Milk",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "cloud_push"
},
"renault": {

9
requirements_all.txt generated
View File

@ -111,9 +111,6 @@ RachioPy==1.1.0
# homeassistant.components.python_script
RestrictedPython==8.0
# homeassistant.components.remember_the_milk
RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
SQLAlchemy==2.0.38
@ -358,6 +355,9 @@ aiorecollect==2023.09.0
# homeassistant.components.ridwell
aioridwell==2024.01.0
# homeassistant.components.remember_the_milk
aiortm==0.9.45
# homeassistant.components.ruckus_unleashed
aioruckus==0.42
@ -1157,9 +1157,6 @@ homematicip==1.1.7
# homeassistant.components.horizon
horimote==0.4.1
# homeassistant.components.remember_the_milk
httplib2==0.20.4
# homeassistant.components.huawei_lte
huawei-lte-api==1.10.0

View File

@ -105,9 +105,6 @@ RachioPy==1.1.0
# homeassistant.components.python_script
RestrictedPython==8.0
# homeassistant.components.remember_the_milk
RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
SQLAlchemy==2.0.38
@ -340,6 +337,9 @@ aiorecollect==2023.09.0
# homeassistant.components.ridwell
aioridwell==2024.01.0
# homeassistant.components.remember_the_milk
aiortm==0.9.45
# homeassistant.components.ruckus_unleashed
aioruckus==0.42
@ -983,9 +983,6 @@ home-assistant-intents==2025.2.5
# homeassistant.components.homematicip_cloud
homematicip==1.1.7
# homeassistant.components.remember_the_milk
httplib2==0.20.4
# homeassistant.components.huawei_lte
huawei-lte-api==1.10.0

View File

@ -1,29 +1,42 @@
"""Provide common pytest fixtures."""
from collections.abc import AsyncGenerator, Generator
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.remember_the_milk.const import DOMAIN
from homeassistant.core import HomeAssistant
from .const import TOKEN
from .const import CREATE_ENTRY_DATA, TOKEN_RESPONSE
from tests.common import MockConfigEntry
@pytest.fixture(name="client")
def client_fixture() -> Generator[MagicMock]:
"""Create a mock client."""
with patch("homeassistant.components.remember_the_milk.entity.Rtm") as client_class:
with patch(
"homeassistant.components.remember_the_milk.AioRTMClient",
) as client_class:
client = client_class.return_value
client.token_valid.return_value = True
client.rtm.api.check_token = AsyncMock(return_value=TOKEN_RESPONSE)
timelines = MagicMock()
timelines.timeline.value = "1234"
client.rtm.timelines.create.return_value = timelines
add_response = MagicMock()
add_response.list.id = "1"
add_response.list.taskseries.id = "2"
add_response.list.taskseries.task.id = "3"
client.rtm.tasks.add.return_value = add_response
timelines.timeline = 1234
client.rtm.timelines.create = AsyncMock(return_value=timelines)
response = MagicMock()
response.task_list.id = 1
response.task_list.taskseries = []
task_series = MagicMock()
task_series.id = 2
task_series.task = []
task = MagicMock()
task.id = 3
task_series.task.append(task)
response.task_list.taskseries.append(task_series)
client.rtm.tasks.add = AsyncMock(return_value=response)
client.rtm.tasks.complete = AsyncMock(return_value=response)
client.rtm.tasks.set_name = AsyncMock(return_value=response)
yield client
@ -35,6 +48,26 @@ async def storage(hass: HomeAssistant, client) -> AsyncGenerator[MagicMock]:
"homeassistant.components.remember_the_milk.RememberTheMilkConfiguration"
) as storage_class:
storage = storage_class.return_value
storage.get_token.return_value = TOKEN
storage.get_rtm_id.return_value = None
yield storage
@pytest.fixture
def config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Return a mock config entry."""
entry = MockConfigEntry(
data=CREATE_ENTRY_DATA,
domain=DOMAIN,
)
entry.add_to_hass(hass)
return entry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.remember_the_milk.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry

View File

@ -3,12 +3,32 @@
import json
PROFILE = "myprofile"
TOKEN = "mytoken"
CREATE_ENTRY_DATA = {
"api_key": "test-api-key",
"shared_secret": "test-secret",
"token": "test-token",
"username": PROFILE,
}
TOKEN_RESPONSE = {
"token": "test-token",
"perms": "delete",
"user": {"id": "1234567", "username": "johnsmith", "fullname": "John Smith"},
}
# The legacy configuration file format:
# {
# "myprofile": {
# "token": "mytoken",
# "id_map": {"123": {"list_id": 1, "timeseries_id": 2, "task_id": 3}},
# }
# }
# The new configuration file format:
JSON_STRING = json.dumps(
{
"myprofile": {
"token": "mytoken",
"id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}},
"id_map": {"123": {"list_id": 1, "timeseries_id": 2, "task_id": 3}},
}
}
)

View File

@ -0,0 +1,346 @@
"""Test the Remember The Milk config flow."""
import asyncio
from collections.abc import Awaitable
from copy import deepcopy
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant import config_entries
from homeassistant.components.remember_the_milk.config_flow import (
TOKEN_TIMEOUT_SEC,
AuthError,
ResponseError,
)
from homeassistant.components.remember_the_milk.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import CREATE_ENTRY_DATA, PROFILE
from tests.common import MockConfigEntry
TOKEN_DATA = {
"token": "test-token",
"user": {
"fullname": PROFILE,
"id": "test-user-id",
"username": PROFILE,
},
}
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
async def test_successful_flow(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test successful flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert not result["errors"]
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop",
return_value=("https://test-url.com", "test-frob"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.get_token",
return_value=TOKEN_DATA,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == PROFILE
assert result["data"] == CREATE_ENTRY_DATA
assert result["result"].unique_id == "test-user-id"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(AuthError, "invalid_auth"),
(ResponseError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_form_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test form errors when getting the authentication URL."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop",
side_effect=exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": error}
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop",
return_value=("https://test-url.com", "test-frob"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.get_token",
return_value=TOKEN_DATA,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == PROFILE
assert result["data"] == CREATE_ENTRY_DATA
assert result["result"].unique_id == "test-user-id"
assert len(mock_setup_entry.mock_calls) == 1
async def mock_get_token(*args: Any) -> None:
"""Handle get token."""
await asyncio.Future()
@pytest.mark.parametrize(
("side_effect", "reason", "timeout"),
[
(AuthError, "invalid_auth", TOKEN_TIMEOUT_SEC),
(ResponseError, "cannot_connect", TOKEN_TIMEOUT_SEC),
(Exception, "unknown", TOKEN_TIMEOUT_SEC),
(mock_get_token, "timeout_token", 0),
],
)
async def test_token_abort_reasons(
hass: HomeAssistant,
side_effect: Exception | Awaitable[None],
reason: str,
timeout: int,
) -> None:
"""Test abort result when getting token."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop",
return_value=("https://test-url.com", "test-frob"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
with (
patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.get_token",
side_effect=side_effect,
),
patch(
"homeassistant.components.remember_the_milk.config_flow.TOKEN_TIMEOUT_SEC",
timeout,
),
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == reason
async def test_abort_if_already_configured(hass: HomeAssistant) -> None:
"""Test abort if the same username is already configured."""
mock_entry = MockConfigEntry(domain=DOMAIN, unique_id="test-user-id")
mock_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert not result["errors"]
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop",
return_value=("https://test-url.com", "test-frob"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.get_token",
return_value=TOKEN_DATA,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
"source", [config_entries.SOURCE_IMPORT, config_entries.SOURCE_USER]
)
@pytest.mark.parametrize(
("reauth_unique_id", "abort_reason", "abort_entry_data"),
[
(
"test-user-id",
"reauth_successful",
CREATE_ENTRY_DATA | {"token": "new-test-token"},
),
("other-user-id", "unique_id_mismatch", CREATE_ENTRY_DATA),
],
)
async def test_reauth(
hass: HomeAssistant,
source: str,
reauth_unique_id: str,
abort_reason: str,
abort_entry_data: dict[str, str],
) -> None:
"""Test reauth flow."""
mock_entry = MockConfigEntry(
domain=DOMAIN, unique_id="test-user-id", data=CREATE_ENTRY_DATA, source=source
)
mock_entry.add_to_hass(hass)
result = await mock_entry.start_reauth_flow(hass)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop",
return_value=("https://test-url.com", "test-frob"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
reauth_data: dict[str, Any] = deepcopy(TOKEN_DATA) | {"token": "new-test-token"}
reauth_data["user"]["id"] = reauth_unique_id
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.get_token",
return_value=reauth_data,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == abort_reason
assert mock_entry.data == abort_entry_data
assert mock_entry.unique_id == "test-user-id"
async def test_import_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test import flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"api_key": "test-api-key",
"shared_secret": "test-secret",
"name": "test-name",
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-name"
assert result["data"] == {
"api_key": "test-api-key",
"shared_secret": "test-secret",
"token": None,
"username": "test-name",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_reauth_imported_entry(hass: HomeAssistant) -> None:
"""Test reauth flow for an imported entry."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
data={
"api_key": "test-api-key",
"shared_secret": "test-secret",
"token": None,
"username": "test-name",
},
source=config_entries.SOURCE_IMPORT,
)
mock_entry.add_to_hass(hass)
result = await mock_entry.start_reauth_flow(hass)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop",
return_value=("https://test-url.com", "test-frob"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.get_token",
return_value=TOKEN_DATA,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_entry.data == CREATE_ENTRY_DATA
assert mock_entry.unique_id == "test-user-id"

View File

@ -3,8 +3,8 @@
from typing import Any
from unittest.mock import MagicMock, call
from aiortm import AioRTMError, AuthError
import pytest
from rtmapi import RtmRequestFailedException
from homeassistant.components.remember_the_milk import DOMAIN
from homeassistant.core import HomeAssistant
@ -12,6 +12,8 @@ from homeassistant.setup import async_setup_component
from .const import PROFILE
from tests.common import MockConfigEntry
CONFIG = {
"name": f"{PROFILE}",
"api_key": "test-api-key",
@ -19,19 +21,21 @@ CONFIG = {
}
@pytest.mark.usefixtures("storage")
@pytest.mark.parametrize(
("valid_token", "entity_state"), [(True, "ok"), (False, "API token invalid")]
("check_token_side_effect", "entity_state"),
[(None, "ok"), (AuthError("Invalid token!"), "API token invalid")],
)
async def test_entity_state(
hass: HomeAssistant,
client: MagicMock,
storage: MagicMock,
valid_token: bool,
config_entry: MockConfigEntry,
check_token_side_effect: Exception | None,
entity_state: str,
) -> None:
"""Test the entity state."""
client.token_valid.return_value = valid_token
assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG})
client.rtm.api.check_token.side_effect = check_token_side_effect
await hass.config_entries.async_setup(config_entry.entry_id)
entity_id = f"{DOMAIN}.{PROFILE}"
state = hass.states.get(entity_id)
@ -56,7 +60,7 @@ async def test_entity_state(
),
[
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1"},
0,
@ -65,9 +69,9 @@ async def test_entity_state(
"rtm.tasks.add",
1,
call(
timeline="1234",
timeline=1234,
name="Test 1",
parse="1",
parse=True,
),
"set_rtm_id",
0,
@ -83,36 +87,36 @@ async def test_entity_state(
"rtm.tasks.add",
1,
call(
timeline="1234",
timeline=1234,
name="Test 1",
parse="1",
parse=True,
),
"set_rtm_id",
1,
call(PROFILE, "test_1", "1", "2", "3"),
call(PROFILE, "test_1", 1, 2, 3),
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"},
1,
call(PROFILE, "test_1"),
1,
"rtm.tasks.setName",
"rtm.tasks.set_name",
1,
call(
name="Test 1",
list_id="1",
taskseries_id="2",
task_id="3",
timeline="1234",
list_id=1,
taskseries_id=2,
task_id=3,
timeline=1234,
),
"set_rtm_id",
0,
None,
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_complete_task",
{"id": "test_1"},
1,
@ -121,10 +125,10 @@ async def test_entity_state(
"rtm.tasks.complete",
1,
call(
list_id="1",
taskseries_id="2",
task_id="3",
timeline="1234",
list_id=1,
taskseries_id=2,
task_id=3,
timeline=1234,
),
"delete_rtm_id",
1,
@ -179,52 +183,52 @@ async def test_services(
),
[
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1"},
"rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1"},
"rtm.tasks.add",
RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"),
"Request rtm.tasks.add failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
None,
f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"},
"rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
None,
f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"},
"rtm.tasks.add",
RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"),
"Request rtm.tasks.add failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"},
"rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"},
"rtm.tasks.setName",
RtmRequestFailedException("rtm.tasks.setName", "400", "Bad request"),
"Request rtm.tasks.setName failed. Status: 400, reason: Bad request.",
"rtm.tasks.set_name",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
None,
@ -238,20 +242,20 @@ async def test_services(
),
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_complete_task",
{"id": "test_1"},
"rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error completing task with id test_1 for account myprofile: Boom!",
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_complete_task",
{"id": "test_1"},
"rtm.tasks.complete",
RtmRequestFailedException("rtm.tasks.complete", "400", "Bad request"),
"Request rtm.tasks.complete failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error completing task with id test_1 for account myprofile: Boom!",
),
],
)

View File

@ -8,51 +8,23 @@ import pytest
from homeassistant.components import remember_the_milk as rtm
from homeassistant.core import HomeAssistant
from .const import JSON_STRING, PROFILE, TOKEN
def test_set_get_delete_token(hass: HomeAssistant) -> None:
"""Test set, get and delete token."""
open_mock = mock_open()
with patch(
"homeassistant.components.remember_the_milk.storage.Path.open", open_mock
):
config = rtm.RememberTheMilkConfiguration(hass)
assert open_mock.return_value.write.call_count == 0
assert config.get_token(PROFILE) is None
assert open_mock.return_value.write.call_count == 0
config.set_token(PROFILE, TOKEN)
assert open_mock.return_value.write.call_count == 1
assert open_mock.return_value.write.call_args[0][0] == json.dumps(
{
"myprofile": {
"id_map": {},
"token": "mytoken",
}
}
)
assert config.get_token(PROFILE) == TOKEN
assert open_mock.return_value.write.call_count == 1
config.delete_token(PROFILE)
assert open_mock.return_value.write.call_count == 2
assert open_mock.return_value.write.call_args[0][0] == json.dumps({})
assert config.get_token(PROFILE) is None
assert open_mock.return_value.write.call_count == 2
from .const import JSON_STRING, PROFILE
def test_config_load(hass: HomeAssistant) -> None:
"""Test loading from the file."""
config = rtm.RememberTheMilkConfiguration(hass)
with (
patch(
"homeassistant.components.remember_the_milk.storage.Path.open",
mock_open(read_data=JSON_STRING),
),
):
config = rtm.RememberTheMilkConfiguration(hass)
config.setup()
rtm_id = config.get_rtm_id(PROFILE, "123")
assert rtm_id is not None
assert rtm_id == ("1", "2", "3")
assert rtm_id == (1, 2, 3)
@pytest.mark.parametrize(
@ -67,7 +39,7 @@ def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) ->
side_effect=side_effect,
),
):
config = rtm.RememberTheMilkConfiguration(hass)
config.setup()
# The config should be empty and we should not have any errors
# when trying to access it.
@ -84,7 +56,7 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None:
mock_open(read_data="random characters"),
),
):
config = rtm.RememberTheMilkConfiguration(hass)
config.setup()
# The config should be empty and we should not have any errors
# when trying to access it.
@ -95,15 +67,15 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None:
def test_config_set_delete_id(hass: HomeAssistant) -> None:
"""Test setting and deleting an id from the config."""
hass_id = "123"
list_id = "1"
timeseries_id = "2"
rtm_id = "3"
list_id = 1
timeseries_id = 2
rtm_id = 3
open_mock = mock_open()
config = rtm.RememberTheMilkConfiguration(hass)
with patch(
"homeassistant.components.remember_the_milk.storage.Path.open", open_mock
):
config = rtm.RememberTheMilkConfiguration(hass)
config.setup()
assert open_mock.return_value.write.call_count == 0
assert config.get_rtm_id(PROFILE, hass_id) is None
assert open_mock.return_value.write.call_count == 0
@ -114,7 +86,7 @@ def test_config_set_delete_id(hass: HomeAssistant) -> None:
{
"myprofile": {
"id_map": {
"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}
"123": {"list_id": 1, "timeseries_id": 2, "task_id": 3}
}
}
}