Move remember the milk config storage to own module (#138999)

This commit is contained in:
Martin Hjelmare 2025-02-23 16:32:55 +01:00 committed by GitHub
parent 1cd82ab8ee
commit 0b961d98f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 148 additions and 138 deletions

View File

@ -1,33 +1,25 @@
"""Support to interact with Remember The Milk.""" """Support to interact with Remember The Milk."""
import json
import logging
from pathlib import Path
from rtmapi import Rtm from rtmapi import Rtm
import voluptuous as vol import voluptuous as vol
from homeassistant.components import configurator from homeassistant.components import configurator
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import LOGGER
from .entity import RememberTheMilkEntity from .entity import RememberTheMilkEntity
from .storage import RememberTheMilkConfiguration
# httplib2 is a transitive dependency from RtmAPI. If this dependency is not # httplib2 is a transitive dependency from RtmAPI. If this dependency is not
# set explicitly, the library does not work. # set explicitly, the library does not work.
_LOGGER = logging.getLogger(__name__)
DOMAIN = "remember_the_milk" DOMAIN = "remember_the_milk"
DEFAULT_NAME = DOMAIN
CONF_SHARED_SECRET = "shared_secret" CONF_SHARED_SECRET = "shared_secret"
CONF_ID_MAP = "id_map"
CONF_LIST_ID = "list_id"
CONF_TIMESERIES_ID = "timeseries_id"
CONF_TASK_ID = "task_id"
RTM_SCHEMA = vol.Schema( RTM_SCHEMA = vol.Schema(
{ {
@ -41,7 +33,6 @@ CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])}, extra=vol.ALLOW_EXTRA {DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])}, extra=vol.ALLOW_EXTRA
) )
CONFIG_FILE_NAME = ".remember_the_milk.conf"
SERVICE_CREATE_TASK = "create_task" SERVICE_CREATE_TASK = "create_task"
SERVICE_COMPLETE_TASK = "complete_task" SERVICE_COMPLETE_TASK = "complete_task"
@ -54,17 +45,17 @@ SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string})
def setup(hass: HomeAssistant, config: ConfigType) -> bool: def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Remember the milk component.""" """Set up the Remember the milk component."""
component = EntityComponent[RememberTheMilkEntity](_LOGGER, DOMAIN, hass) component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass)
stored_rtm_config = RememberTheMilkConfiguration(hass) stored_rtm_config = RememberTheMilkConfiguration(hass)
for rtm_config in config[DOMAIN]: for rtm_config in config[DOMAIN]:
account_name = rtm_config[CONF_NAME] account_name = rtm_config[CONF_NAME]
_LOGGER.debug("Adding Remember the milk account %s", account_name) LOGGER.debug("Adding Remember the milk account %s", account_name)
api_key = rtm_config[CONF_API_KEY] api_key = rtm_config[CONF_API_KEY]
shared_secret = rtm_config[CONF_SHARED_SECRET] shared_secret = rtm_config[CONF_SHARED_SECRET]
token = stored_rtm_config.get_token(account_name) token = stored_rtm_config.get_token(account_name)
if token: if token:
_LOGGER.debug("found token for account %s", account_name) LOGGER.debug("found token for account %s", account_name)
_create_instance( _create_instance(
hass, hass,
account_name, account_name,
@ -79,7 +70,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass, account_name, api_key, shared_secret, stored_rtm_config, component hass, account_name, api_key, shared_secret, stored_rtm_config, component
) )
_LOGGER.debug("Finished adding all Remember the milk accounts") LOGGER.debug("Finished adding all Remember the milk accounts")
return True return True
@ -110,21 +101,21 @@ def _register_new_account(
request_id = None request_id = None
api = Rtm(api_key, shared_secret, "write", None) api = Rtm(api_key, shared_secret, "write", None)
url, frob = api.authenticate_desktop() url, frob = api.authenticate_desktop()
_LOGGER.debug("Sent authentication request to server") LOGGER.debug("Sent authentication request to server")
def register_account_callback(fields: list[dict[str, str]]) -> None: def register_account_callback(fields: list[dict[str, str]]) -> None:
"""Call for register the configurator.""" """Call for register the configurator."""
api.retrieve_token(frob) api.retrieve_token(frob)
token = api.token token = api.token
if api.token is None: if api.token is None:
_LOGGER.error("Failed to register, please try again") LOGGER.error("Failed to register, please try again")
configurator.notify_errors( configurator.notify_errors(
hass, request_id, "Failed to register, please try again." hass, request_id, "Failed to register, please try again."
) )
return return
stored_rtm_config.set_token(account_name, token) stored_rtm_config.set_token(account_name, token)
_LOGGER.debug("Retrieved new token from server") LOGGER.debug("Retrieved new token from server")
_create_instance( _create_instance(
hass, hass,
@ -152,104 +143,3 @@ def _register_new_account(
link_url=url, link_url=url,
submit_caption="login completed", submit_caption="login completed",
) )
class RememberTheMilkConfiguration:
"""Internal configuration data for RememberTheMilk class.
This class stores the authentication token it get from the backend.
"""
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)
try:
self._config = json.loads(
Path(self._config_file_path).read_text(encoding="utf8")
)
except FileNotFoundError:
_LOGGER.debug("Missing configuration file: %s", self._config_file_path)
except OSError:
_LOGGER.debug(
"Failed to read from configuration file, %s, using empty configuration",
self._config_file_path,
)
except ValueError:
_LOGGER.error(
"Failed to parse configuration file, %s, using empty configuration",
self._config_file_path,
)
def _save_config(self) -> None:
"""Write the configuration to a file."""
Path(self._config_file_path).write_text(
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:
self._config[profile_name] = {}
if CONF_ID_MAP not in self._config[profile_name]:
self._config[profile_name][CONF_ID_MAP] = {}
def get_rtm_id(
self, profile_name: str, hass_id: str
) -> tuple[str, str, str] | None:
"""Get the RTM ids for a Home Assistant task ID.
The id of a RTM tasks consists of the tuple:
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:
return None
return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID]
def set_rtm_id(
self,
profile_name: str,
hass_id: str,
list_id: str,
time_series_id: str,
rtm_task_id: str,
) -> None:
"""Add/Update the RTM task ID for a Home Assistant task IS."""
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
self._save_config()
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]
self._save_config()

View File

@ -0,0 +1,5 @@
"""Constants for the Remember The Milk integration."""
import logging
LOGGER = logging.getLogger(__package__)

View File

@ -1,14 +1,12 @@
"""Support to interact with Remember The Milk.""" """Support to interact with Remember The Milk."""
import logging
from rtmapi import Rtm, RtmRequestFailedException from rtmapi import Rtm, RtmRequestFailedException
from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK
from homeassistant.core import ServiceCall from homeassistant.core import ServiceCall
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
_LOGGER = logging.getLogger(__name__) from .const import LOGGER
class RememberTheMilkEntity(Entity): class RememberTheMilkEntity(Entity):
@ -24,7 +22,7 @@ class RememberTheMilkEntity(Entity):
self._rtm_api = Rtm(api_key, shared_secret, "delete", token) self._rtm_api = Rtm(api_key, shared_secret, "delete", token)
self._token_valid = None self._token_valid = None
self._check_token() self._check_token()
_LOGGER.debug("Instance created for account %s", self._name) LOGGER.debug("Instance created for account %s", self._name)
def _check_token(self): def _check_token(self):
"""Check if the API token is still valid. """Check if the API token is still valid.
@ -34,7 +32,7 @@ class RememberTheMilkEntity(Entity):
""" """
valid = self._rtm_api.token_valid() valid = self._rtm_api.token_valid()
if not valid: if not valid:
_LOGGER.error( LOGGER.error(
"Token for account %s is invalid. You need to register again!", "Token for account %s is invalid. You need to register again!",
self.name, self.name,
) )
@ -64,7 +62,7 @@ class RememberTheMilkEntity(Entity):
result = self._rtm_api.rtm.tasks.add( result = self._rtm_api.rtm.tasks.add(
timeline=timeline, name=task_name, parse="1" timeline=timeline, name=task_name, parse="1"
) )
_LOGGER.debug( LOGGER.debug(
"Created new task '%s' in account %s", task_name, self.name "Created new task '%s' in account %s", task_name, self.name
) )
if hass_id is not None: if hass_id is not None:
@ -83,14 +81,14 @@ class RememberTheMilkEntity(Entity):
task_id=rtm_id[2], task_id=rtm_id[2],
timeline=timeline, timeline=timeline,
) )
_LOGGER.debug( LOGGER.debug(
"Updated task with id '%s' in account %s to name %s", "Updated task with id '%s' in account %s to name %s",
hass_id, hass_id,
self.name, self.name,
task_name, task_name,
) )
except RtmRequestFailedException as rtm_exception: except RtmRequestFailedException as rtm_exception:
_LOGGER.error( LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s", "Error creating new Remember The Milk task for account %s: %s",
self._name, self._name,
rtm_exception, rtm_exception,
@ -101,7 +99,7 @@ class RememberTheMilkEntity(Entity):
hass_id = call.data[CONF_ID] hass_id = call.data[CONF_ID]
rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
if rtm_id is None: if rtm_id is None:
_LOGGER.error( LOGGER.error(
( (
"Could not find task with ID %s in account %s. " "Could not find task with ID %s in account %s. "
"So task could not be closed" "So task could not be closed"
@ -120,11 +118,9 @@ class RememberTheMilkEntity(Entity):
timeline=timeline, timeline=timeline,
) )
self._rtm_config.delete_rtm_id(self._name, hass_id) self._rtm_config.delete_rtm_id(self._name, hass_id)
_LOGGER.debug( LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name)
"Completed task with id %s in account %s", hass_id, self._name
)
except RtmRequestFailedException as rtm_exception: except RtmRequestFailedException as rtm_exception:
_LOGGER.error( LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s", "Error creating new Remember The Milk task for account %s: %s",
self._name, self._name,
rtm_exception, rtm_exception,

View File

@ -0,0 +1,115 @@
"""Store RTM configuration in Home Assistant storage."""
from __future__ import annotations
import json
from pathlib import Path
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
from .const import 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"
class RememberTheMilkConfiguration:
"""Internal configuration data for Remember The Milk."""
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)
try:
self._config = json.loads(
Path(self._config_file_path).read_text(encoding="utf8")
)
except FileNotFoundError:
LOGGER.debug("Missing configuration file: %s", self._config_file_path)
except OSError:
LOGGER.debug(
"Failed to read from configuration file, %s, using empty configuration",
self._config_file_path,
)
except ValueError:
LOGGER.error(
"Failed to parse configuration file, %s, using empty configuration",
self._config_file_path,
)
def _save_config(self) -> None:
"""Write the configuration to a file."""
Path(self._config_file_path).write_text(
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:
self._config[profile_name] = {}
if CONF_ID_MAP not in self._config[profile_name]:
self._config[profile_name][CONF_ID_MAP] = {}
def get_rtm_id(
self, profile_name: str, hass_id: str
) -> tuple[str, str, str] | None:
"""Get the RTM ids for a Home Assistant task ID.
The id of a RTM tasks consists of the tuple:
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:
return None
return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID]
def set_rtm_id(
self,
profile_name: str,
hass_id: str,
list_id: str,
time_series_id: str,
rtm_task_id: str,
) -> None:
"""Add/Update the RTM task ID for a Home Assistant task IS."""
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
self._save_config()
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]
self._save_config()

View File

@ -14,7 +14,9 @@ from .const import JSON_STRING, PROFILE, TOKEN
def test_set_get_delete_token(hass: HomeAssistant) -> None: def test_set_get_delete_token(hass: HomeAssistant) -> None:
"""Test set, get and delete token.""" """Test set, get and delete token."""
open_mock = mock_open() open_mock = mock_open()
with patch("homeassistant.components.remember_the_milk.Path.open", open_mock): with patch(
"homeassistant.components.remember_the_milk.storage.Path.open", open_mock
):
config = rtm.RememberTheMilkConfiguration(hass) config = rtm.RememberTheMilkConfiguration(hass)
assert open_mock.return_value.write.call_count == 0 assert open_mock.return_value.write.call_count == 0
assert config.get_token(PROFILE) is None assert config.get_token(PROFILE) is None
@ -42,7 +44,7 @@ def test_config_load(hass: HomeAssistant) -> None:
"""Test loading from the file.""" """Test loading from the file."""
with ( with (
patch( patch(
"homeassistant.components.remember_the_milk.Path.open", "homeassistant.components.remember_the_milk.storage.Path.open",
mock_open(read_data=JSON_STRING), mock_open(read_data=JSON_STRING),
), ),
): ):
@ -61,7 +63,7 @@ def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) ->
config = rtm.RememberTheMilkConfiguration(hass) config = rtm.RememberTheMilkConfiguration(hass)
with ( with (
patch( patch(
"homeassistant.components.remember_the_milk.Path.open", "homeassistant.components.remember_the_milk.storage.Path.open",
side_effect=side_effect, side_effect=side_effect,
), ),
): ):
@ -78,7 +80,7 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None:
config = rtm.RememberTheMilkConfiguration(hass) config = rtm.RememberTheMilkConfiguration(hass)
with ( with (
patch( patch(
"homeassistant.components.remember_the_milk.Path.open", "homeassistant.components.remember_the_milk.storage.Path.open",
mock_open(read_data="random characters"), mock_open(read_data="random characters"),
), ),
): ):
@ -98,7 +100,9 @@ def test_config_set_delete_id(hass: HomeAssistant) -> None:
rtm_id = "3" rtm_id = "3"
open_mock = mock_open() open_mock = mock_open()
config = rtm.RememberTheMilkConfiguration(hass) config = rtm.RememberTheMilkConfiguration(hass)
with patch("homeassistant.components.remember_the_milk.Path.open", open_mock): with patch(
"homeassistant.components.remember_the_milk.storage.Path.open", open_mock
):
config = rtm.RememberTheMilkConfiguration(hass) config = rtm.RememberTheMilkConfiguration(hass)
assert open_mock.return_value.write.call_count == 0 assert open_mock.return_value.write.call_count == 0
assert config.get_rtm_id(PROFILE, hass_id) is None assert config.get_rtm_id(PROFILE, hass_id) is None