Use builtin store for remember the milk config

This commit is contained in:
Martin Hjelmare 2025-02-22 16:48:39 +01:00
parent d2a660714f
commit cbc1899990
7 changed files with 294 additions and 155 deletions

View File

@ -5,20 +5,18 @@ import voluptuous as vol
from homeassistant.components import configurator
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from .const import LOGGER
from .const import 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(
@ -43,11 +41,12 @@ SERVICE_SCHEMA_CREATE_TASK = vol.Schema(
SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string})
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)
await stored_rtm_config.setup()
for rtm_config in config[DOMAIN]:
account_name = rtm_config[CONF_NAME]
LOGGER.debug("Adding Remember the milk account %s", account_name)
@ -56,7 +55,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
token = stored_rtm_config.get_token(account_name)
if token:
LOGGER.debug("found token for account %s", account_name)
_create_instance(
await _create_instance(
hass,
account_name,
api_key,
@ -66,7 +65,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
component,
)
else:
_register_new_account(
await _register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component
)
@ -74,20 +73,28 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
def _create_instance(
hass, account_name, api_key, shared_secret, token, stored_rtm_config, component
):
async def _create_instance(
hass: HomeAssistant,
account_name: str,
api_key: str,
shared_secret: str,
token: str,
stored_rtm_config: RememberTheMilkConfiguration,
component: EntityComponent[RememberTheMilkEntity],
) -> None:
entity = RememberTheMilkEntity(
account_name, api_key, shared_secret, token, stored_rtm_config
)
component.add_entities([entity])
hass.services.register(
LOGGER.debug("Instance created for account %s", entity.name)
await entity.check_token(hass)
await component.async_add_entities([entity])
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,
@ -95,21 +102,32 @@ def _create_instance(
)
def _register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component
):
request_id = None
async def _register_new_account(
hass: HomeAssistant,
account_name: str,
api_key: str,
shared_secret: str,
stored_rtm_config: RememberTheMilkConfiguration,
component: EntityComponent[RememberTheMilkEntity],
) -> None:
"""Register a new account."""
api = Rtm(api_key, shared_secret, "write", None)
url, frob = api.authenticate_desktop()
url, frob = await hass.async_add_executor_job(api.authenticate_desktop)
LOGGER.debug("Sent authentication request to server")
@callback
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:
hass.async_create_task(handle_token(api, frob))
async def handle_token(api: Rtm, frob: str) -> None:
"""Handle token."""
await hass.async_add_executor_job(api.retrieve_token, frob)
token: str | None = api.token
if token is None:
LOGGER.error("Failed to register, please try again")
configurator.notify_errors(
configurator.async_notify_errors(
hass, request_id, "Failed to register, please try again."
)
return
@ -117,7 +135,7 @@ def _register_new_account(
stored_rtm_config.set_token(account_name, token)
LOGGER.debug("Retrieved new token from server")
_create_instance(
await _create_instance(
hass,
account_name,
api_key,
@ -127,9 +145,9 @@ def _register_new_account(
component,
)
configurator.request_done(hass, request_id)
configurator.async_request_done(hass, request_id)
request_id = configurator.request_config(
request_id = configurator.async_request_config(
hass,
f"{DOMAIN} - {account_name}",
callback=register_account_callback,

View File

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

View File

@ -1,18 +1,30 @@
"""Support to interact with Remember The Milk."""
from __future__ import annotations
from typing import Any
from rtmapi import Rtm, RtmRequestFailedException
from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK
from homeassistant.core import ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall
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,
api_key: str,
shared_secret: str,
token: str,
rtm_config: RememberTheMilkConfiguration,
) -> None:
"""Create new instance of Remember The Milk component."""
self._name = name
self._api_key = api_key
@ -20,29 +32,27 @@ class RememberTheMilkEntity(Entity):
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._token_valid = False
def _check_token(self):
async def check_token(self, hass: HomeAssistant) -> None:
"""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:
valid = await hass.async_add_executor_job(self._rtm_api.token_valid)
if valid:
self._token_valid = True
return self._token_valid
return
def create_task(self, call: ServiceCall) -> None:
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
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,
@ -55,12 +65,11 @@ class RememberTheMilkEntity(Entity):
rtm_id = 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
if rtm_id is None:
result = self._rtm_api.rtm.tasks.add(
timeline=timeline, name=task_name, parse="1"
result = await self.hass.async_add_executor_job(
self._add_task,
task_name,
)
LOGGER.debug(
"Created new task '%s' in account %s", task_name, self.name
@ -74,12 +83,10 @@ class RememberTheMilkEntity(Entity):
result.list.taskseries.task.id,
)
else:
self._rtm_api.rtm.tasks.setName(
name=task_name,
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
task_id=rtm_id[2],
timeline=timeline,
await self.hass.async_add_executor_job(
self._rename_task,
rtm_id,
task_name,
)
LOGGER.debug(
"Updated task with id '%s' in account %s to name %s",
@ -94,7 +101,29 @@ class RememberTheMilkEntity(Entity):
rtm_exception,
)
def complete_task(self, call: ServiceCall) -> None:
def _add_task(self, task_name: str) -> Any:
"""Add a task."""
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
return self._rtm_api.rtm.tasks.add(
timeline=timeline,
name=task_name,
parse="1",
)
def _rename_task(self, rtm_id: tuple[str, str, str], task_name: str) -> None:
"""Rename a task."""
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
self._rtm_api.rtm.tasks.setName(
name=task_name,
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
task_id=rtm_id[2],
timeline=timeline,
)
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)
@ -109,30 +138,36 @@ class RememberTheMilkEntity(Entity):
)
return
try:
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
self._rtm_api.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)
LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name)
await self.hass.async_add_executor_job(self._complete_task, rtm_id)
except RtmRequestFailedException as rtm_exception:
LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
rtm_exception,
)
return
self._rtm_config.delete_rtm_id(self._name, hass_id)
LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name)
def _complete_task(self, rtm_id: tuple[str, str, str]) -> None:
"""Complete a task."""
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
self._rtm_api.rtm.tasks.complete(
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
task_id=rtm_id[2],
timeline=timeline,
)
@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"

View File

@ -4,17 +4,31 @@ from __future__ import annotations
import json
from pathlib import Path
from typing import TypedDict
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
from .const import LOGGER
from .const import DOMAIN, LOGGER
CONFIG_FILE_NAME = ".remember_the_milk.conf"
CONF_ID_MAP = "id_map"
CONF_LIST_ID = "list_id"
CONF_TASK_ID = "task_id"
CONF_TIMESERIES_ID = "timeseries_id"
LEGACY_CONFIG_FILE_NAME = ".remember_the_milk.conf"
STORE_DELAY_SAVE = 30
class StoredUserConfig(TypedDict, total=False):
"""Represent the stored config for a username."""
id_map: dict[str, TaskIds]
token: str
class TaskIds(TypedDict):
"""Represent the stored ids of a task."""
list_id: str
timeseries_id: str
task_id: str
class RememberTheMilkConfiguration:
@ -22,31 +36,63 @@ 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 = {}
LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
self._legacy_config_path = hass.config.path(LEGACY_CONFIG_FILE_NAME)
self._config: dict[str, StoredUserConfig] = {}
self._hass = hass
self._store = Store[dict[str, StoredUserConfig]](hass, 1, DOMAIN)
async def setup(self) -> None:
"""Set up the configuration."""
if not (config := await self._hass.async_add_executor_job(self._load_legacy)):
config = await self._load()
self._config = config or {}
def _load_legacy(self) -> dict[str, StoredUserConfig] | None:
"""Load configuration from legacy storage."""
# Do not load from legacy if the new store exists.
if Path(self._store.path).exists():
return None
LOGGER.debug(
"Loading legacy configuration from file: %s", self._legacy_config_path
)
config: dict[str, StoredUserConfig] | None = None
try:
self._config = json.loads(
Path(self._config_file_path).read_text(encoding="utf8")
config = json.loads(
Path(self._legacy_config_path).read_text(encoding="utf8")
)
except FileNotFoundError:
LOGGER.debug("Missing configuration file: %s", self._config_file_path)
LOGGER.debug(
"Missing legacy configuration file: %s", self._legacy_config_path
)
except OSError:
LOGGER.debug(
"Failed to read from configuration file, %s, using empty configuration",
self._config_file_path,
"Failed to read from legacy configuration file, %s, using empty configuration",
self._legacy_config_path,
)
except ValueError:
LOGGER.error(
"Failed to parse configuration file, %s, using empty configuration",
self._config_file_path,
"Failed to parse legacy configuration file, %s, using empty configuration",
self._legacy_config_path,
)
return config
async def _load(self) -> dict[str, StoredUserConfig] | None:
"""Load the store."""
return await self._store.async_load()
@callback
def _save_config(self) -> None:
"""Write the configuration to a file."""
Path(self._config_file_path).write_text(
json.dumps(self._config), encoding="utf8"
)
"""Save config."""
self._store.async_delay_save(self._data_to_save, STORE_DELAY_SAVE)
@callback
def _data_to_save(self) -> dict[str, StoredUserConfig]:
"""Return data to save."""
return self._config
def get_token(self, profile_name: str) -> str | None:
"""Get the server token for a profile."""
@ -54,12 +100,14 @@ class RememberTheMilkConfiguration:
return self._config[profile_name][CONF_TOKEN]
return None
@callback
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()
@callback
def delete_token(self, profile_name: str) -> None:
"""Delete a token for a profile.
@ -72,8 +120,8 @@ class RememberTheMilkConfiguration:
"""Initialize the data structures for a profile."""
if profile_name not in self._config:
self._config[profile_name] = {}
if CONF_ID_MAP not in self._config[profile_name]:
self._config[profile_name][CONF_ID_MAP] = {}
if "id_map" not in self._config[profile_name]:
self._config[profile_name]["id_map"] = {}
def get_rtm_id(
self, profile_name: str, hass_id: str
@ -84,11 +132,12 @@ class RememberTheMilkConfiguration:
list id, timeseries id and the task id.
"""
self._initialize_profile(profile_name)
ids = self._config[profile_name][CONF_ID_MAP].get(hass_id)
if ids is None:
task_ids = self._config[profile_name]["id_map"].get(hass_id)
if task_ids is None:
return None
return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID]
return task_ids["list_id"], task_ids["timeseries_id"], task_ids["task_id"]
@callback
def set_rtm_id(
self,
profile_name: str,
@ -97,19 +146,20 @@ class RememberTheMilkConfiguration:
time_series_id: str,
rtm_task_id: str,
) -> 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,
CONF_TIMESERIES_ID: time_series_id,
CONF_TASK_ID: rtm_task_id,
}
self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple
ids = TaskIds(
list_id=list_id,
timeseries_id=time_series_id,
task_id=rtm_task_id,
)
self._config[profile_name]["id_map"][hass_id] = ids
self._save_config()
@callback
def delete_rtm_id(self, profile_name: str, hass_id: str) -> None:
"""Delete a key mapping."""
self._initialize_profile(profile_name)
if hass_id in self._config[profile_name][CONF_ID_MAP]:
del self._config[profile_name][CONF_ID_MAP][hass_id]
if hass_id in self._config[profile_name]["id_map"]:
del self._config[profile_name]["id_map"][hass_id]
self._save_config()

View File

@ -32,7 +32,8 @@ def client_fixture() -> Generator[MagicMock]:
async def storage(hass: HomeAssistant, client) -> AsyncGenerator[MagicMock]:
"""Mock the config storage."""
with patch(
"homeassistant.components.remember_the_milk.RememberTheMilkConfiguration"
"homeassistant.components.remember_the_milk.RememberTheMilkConfiguration",
autospec=True,
) as storage_class:
storage = storage_class.return_value
storage.get_token.return_value = TOKEN

View File

@ -4,11 +4,10 @@ import json
PROFILE = "myprofile"
TOKEN = "mytoken"
JSON_STRING = json.dumps(
{
"myprofile": {
"token": "mytoken",
"id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}},
}
STORED_DATA = {
"myprofile": {
"token": "mytoken",
"id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}},
}
)
}
JSON_STRING = json.dumps(STORED_DATA)

View File

@ -1,54 +1,88 @@
"""Tests for the Remember The Milk integration."""
import json
from collections.abc import Generator
from typing import Any
from unittest.mock import mock_open, patch
import pytest
from homeassistant.components import remember_the_milk as rtm
from homeassistant.components.remember_the_milk import (
DOMAIN,
RememberTheMilkConfiguration,
)
from homeassistant.core import HomeAssistant
from .const import JSON_STRING, PROFILE, TOKEN
from .const import JSON_STRING, PROFILE, STORED_DATA, TOKEN
from tests.common import async_fire_time_changed
pytestmark = pytest.mark.parametrize("new_storage_exists", [True, False])
def test_set_get_delete_token(hass: HomeAssistant) -> None:
@pytest.fixture(autouse=True)
def mock_path_exists(new_storage_exists: bool) -> Generator[None]:
"""Mock path exists."""
with patch(
"homeassistant.components.remember_the_milk.storage.Path.exists",
return_value=new_storage_exists,
):
yield
@pytest.fixture(autouse=True)
def mock_delay_save() -> Generator[None]:
"""Mock delay save."""
with patch(
"homeassistant.components.remember_the_milk.storage.STORE_DELAY_SAVE", 0
):
yield
async def test_set_get_delete_token(
hass: HomeAssistant,
hass_storage: dict[str, Any],
) -> None:
"""Test set, get and delete token."""
open_mock = mock_open()
config = RememberTheMilkConfiguration(hass)
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
await config.setup()
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",
}
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass_storage[DOMAIN]["data"] == {
"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({})
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass_storage[DOMAIN]["data"] == {}
assert config.get_token(PROFILE) is None
assert open_mock.return_value.write.call_count == 2
def test_config_load(hass: HomeAssistant) -> None:
async def test_config_load(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None:
"""Test loading from the file."""
hass_storage[DOMAIN] = {
"data": STORED_DATA,
"key": DOMAIN,
"version": 1,
"minor_version": 1,
}
config = RememberTheMilkConfiguration(hass)
with (
patch(
"homeassistant.components.remember_the_milk.storage.Path.open",
mock_open(read_data=JSON_STRING),
),
):
config = rtm.RememberTheMilkConfiguration(hass)
await config.setup()
rtm_id = config.get_rtm_id(PROFILE, "123")
assert rtm_id is not None
@ -58,16 +92,18 @@ def test_config_load(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
"side_effect", [FileNotFoundError("Missing file"), OSError("IO error")]
)
def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) -> None:
async def test_config_load_file_error(
hass: HomeAssistant, side_effect: Exception
) -> None:
"""Test loading with file error."""
config = rtm.RememberTheMilkConfiguration(hass)
config = RememberTheMilkConfiguration(hass)
with (
patch(
"homeassistant.components.remember_the_milk.storage.Path.open",
side_effect=side_effect,
),
):
config = rtm.RememberTheMilkConfiguration(hass)
await config.setup()
# The config should be empty and we should not have any errors
# when trying to access it.
@ -75,16 +111,16 @@ def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) ->
assert rtm_id is None
def test_config_load_invalid_data(hass: HomeAssistant) -> None:
async def test_config_load_invalid_data(hass: HomeAssistant) -> None:
"""Test loading invalid data."""
config = rtm.RememberTheMilkConfiguration(hass)
config = RememberTheMilkConfiguration(hass)
with (
patch(
"homeassistant.components.remember_the_milk.storage.Path.open",
mock_open(read_data="random characters"),
),
):
config = rtm.RememberTheMilkConfiguration(hass)
await config.setup()
# The config should be empty and we should not have any errors
# when trying to access it.
@ -92,40 +128,39 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None:
assert rtm_id is None
def test_config_set_delete_id(hass: HomeAssistant) -> None:
async def test_config_set_delete_id(
hass: HomeAssistant,
hass_storage: dict[str, Any],
) -> None:
"""Test setting and deleting an id from the config."""
hass_id = "123"
list_id = "1"
timeseries_id = "2"
rtm_id = "3"
open_mock = mock_open()
config = rtm.RememberTheMilkConfiguration(hass)
config = RememberTheMilkConfiguration(hass)
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
await config.setup()
assert config.get_rtm_id(PROFILE, hass_id) is None
assert open_mock.return_value.write.call_count == 0
config.set_rtm_id(PROFILE, hass_id, list_id, timeseries_id, rtm_id)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (list_id, timeseries_id, rtm_id) == config.get_rtm_id(PROFILE, hass_id)
assert open_mock.return_value.write.call_count == 1
assert open_mock.return_value.write.call_args[0][0] == json.dumps(
{
"myprofile": {
"id_map": {
"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}
}
assert hass_storage[DOMAIN]["data"] == {
"myprofile": {
"id_map": {
"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}
}
}
)
}
config.delete_rtm_id(PROFILE, hass_id)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert config.get_rtm_id(PROFILE, hass_id) is None
assert open_mock.return_value.write.call_count == 2
assert open_mock.return_value.write.call_args[0][0] == json.dumps(
{
"myprofile": {
"id_map": {},
}
assert hass_storage[DOMAIN]["data"] == {
"myprofile": {
"id_map": {},
}
)
}