Use aiortm library in remember the milk

This commit is contained in:
Martin Hjelmare 2025-02-23 20:43:33 +01:00
parent cbc1899990
commit f7511457c3
8 changed files with 170 additions and 127 deletions

View File

@ -1,12 +1,13 @@
"""Support to interact with Remember The Milk."""
from rtmapi import Rtm
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.core import HomeAssistant, callback
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
@ -82,11 +83,18 @@ async def _create_instance(
stored_rtm_config: RememberTheMilkConfiguration,
component: EntityComponent[RememberTheMilkEntity],
) -> None:
entity = RememberTheMilkEntity(
account_name, api_key, shared_secret, token, stored_rtm_config
client = AioRTMClient(
Auth(
async_get_clientsession(hass),
api_key,
shared_secret,
token,
permission="delete",
)
)
entity = RememberTheMilkEntity(account_name, client, stored_rtm_config)
LOGGER.debug("Instance created for account %s", entity.name)
await entity.check_token(hass)
await entity.check_token()
await component.async_add_entities([entity])
hass.services.async_register(
DOMAIN,
@ -111,27 +119,29 @@ async def _register_new_account(
component: EntityComponent[RememberTheMilkEntity],
) -> None:
"""Register a new account."""
api = Rtm(api_key, shared_secret, "write", None)
url, frob = await hass.async_add_executor_job(api.authenticate_desktop)
auth = Auth(
async_get_clientsession(hass), api_key, shared_secret, permission="write"
)
url, frob = await auth.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."""
hass.async_create_task(handle_token(api, frob))
hass.async_create_task(handle_token(auth, frob))
async def handle_token(api: Rtm, frob: str) -> None:
async def handle_token(auth: Auth, frob: str) -> None:
"""Handle token."""
await hass.async_add_executor_job(api.retrieve_token, frob)
token: str | None = api.token
if token is None:
try:
auth_data = await auth.get_token(frob)
except AuthError:
LOGGER.error("Failed to register, please try again")
configurator.async_notify_errors(
hass, request_id, "Failed to register, please try again."
)
return
token: str = auth_data["token"]
stored_rtm_config.set_token(account_name, token)
LOGGER.debug("Retrieved new token from server")

View File

@ -2,12 +2,10 @@
from __future__ import annotations
from typing import Any
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 HomeAssistant, ServiceCall
from homeassistant.core import ServiceCall
from homeassistant.helpers.entity import Entity
from .const import LOGGER
@ -20,35 +18,43 @@ class RememberTheMilkEntity(Entity):
def __init__(
self,
name: str,
api_key: str,
shared_secret: str,
token: str,
client: AioRTMClient,
rtm_config: RememberTheMilkConfiguration,
) -> 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._client = client
self._token_valid = False
async def check_token(self, hass: HomeAssistant) -> None:
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await self.check_token()
async def check_token(self) -> 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 = await hass.async_add_executor_job(self._rtm_api.token_valid)
if valid:
try:
await self._client.rtm.api.check_token()
except AuthError as err:
LOGGER.error(
"Token for account %s is invalid. You need to register again: %s",
self.name,
err,
)
except AioRTMError as err:
LOGGER.error(
"Error checking token for account %s. You need to register again: %s",
self.name,
err,
)
else:
self._token_valid = True
return
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
@ -67,10 +73,7 @@ class RememberTheMilkEntity(Entity):
rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
if rtm_id is None:
result = await self.hass.async_add_executor_job(
self._add_task,
task_name,
)
rtm_id = await self._add_task(task_name)
LOGGER.debug(
"Created new task '%s' in account %s", task_name, self.name
)
@ -78,48 +81,51 @@ class RememberTheMilkEntity(Entity):
self._rtm_config.set_rtm_id(
self._name,
hass_id,
result.list.id,
result.list.taskseries.id,
result.list.taskseries.task.id,
rtm_id[0],
rtm_id[1],
rtm_id[2],
)
else:
await self.hass.async_add_executor_job(
self._rename_task,
rtm_id,
task_name,
)
await self._rename_task(rtm_id, task_name)
LOGGER.debug(
"Updated task with id '%s' in account %s to name %s",
hass_id,
self.name,
task_name,
)
except RtmRequestFailedException as rtm_exception:
except AioRTMError as err:
LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
rtm_exception,
err,
)
def _add_task(self, task_name: str) -> Any:
async def _add_task(self, task_name: str) -> tuple[str, str, str]:
"""Add a task."""
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
return self._rtm_api.rtm.tasks.add(
timeline_response = await self._client.rtm.timelines.create()
timeline = timeline_response.timeline
task_response = await self._client.rtm.tasks.add(
timeline=timeline,
name=task_name,
parse="1",
parse=True,
)
task_list = task_response.task_list
task_list_id = task_list.id
task_series = task_list.taskseries[0]
task_series_id = task_series.id
task = task_series.task[0]
task_id = task.id
return (str(task_list_id), str(task_series_id), str(task_id))
def _rename_task(self, rtm_id: tuple[str, str, str], task_name: str) -> None:
async 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(
result = await self._client.rtm.timelines.create()
timeline = result.timeline
await self._client.rtm.tasks.set_name(
name=task_name,
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
task_id=rtm_id[2],
list_id=int(rtm_id[0]),
taskseries_id=int(rtm_id[1]),
task_id=int(rtm_id[2]),
timeline=timeline,
)
@ -138,26 +144,26 @@ class RememberTheMilkEntity(Entity):
)
return
try:
await self.hass.async_add_executor_job(self._complete_task, rtm_id)
except RtmRequestFailedException as rtm_exception:
await self._complete_task(rtm_id)
except AioRTMError as err:
LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
rtm_exception,
err,
)
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:
async 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],
result = await self._client.rtm.timelines.create()
timeline = result.timeline
await self._client.rtm.tasks.complete(
list_id=int(rtm_id[0]),
taskseries_id=int(rtm_id[1]),
task_id=int(rtm_id[2]),
timeline=timeline,
)

View File

@ -4,8 +4,8 @@
"codeowners": [],
"dependencies": ["configurator"],
"documentation": "https://www.home-assistant.io/integrations/remember_the_milk",
"iot_class": "cloud_push",
"loggers": ["rtmapi"],
"iot_class": "cloud_polling",
"loggers": ["aiortm"],
"quality_scale": "legacy",
"requirements": ["RtmAPI==0.7.2", "httplib2==0.20.4"]
"requirements": ["aiortm==0.10.0"]
}

View File

@ -5232,7 +5232,7 @@
"name": "Remember The Milk",
"integration_type": "hub",
"config_flow": false,
"iot_class": "cloud_push"
"iot_class": "cloud_polling"
},
"renault": {
"name": "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.10.0
# 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.10.0
# 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,7 +1,7 @@
"""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
@ -10,20 +10,48 @@ from homeassistant.core import HomeAssistant
from .const import TOKEN
@pytest.fixture(name="auth", autouse=True)
def auth_fixture() -> Generator[MagicMock]:
"""Create a mock auth."""
with patch(
"homeassistant.components.remember_the_milk.Auth", autospec=True
) as auth_class:
auth = auth_class.return_value
yield auth
@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": "test-token",
"perms": "delete",
"user": {
"id": "1234567",
"username": "johnsmith",
"fullname": "John Smith",
},
}
)
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
task_series = MagicMock()
task_series.id = 2
task = MagicMock()
task.id = 3
task_series.task = [task]
response.task_list.taskseries = [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

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
@ -19,18 +19,23 @@ 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("Boom!"), "API token invalid"),
(AioRTMError("Boom!"), "API token invalid"),
],
)
async def test_entity_state(
hass: HomeAssistant,
client: MagicMock,
storage: MagicMock,
valid_token: bool,
check_token_side_effect: Exception | None,
entity_state: str,
) -> None:
"""Test the entity state."""
client.token_valid.return_value = valid_token
client.rtm.api.check_token.side_effect = check_token_side_effect
assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG})
entity_id = f"{DOMAIN}.{PROFILE}"
state = hass.states.get(entity_id)
@ -65,9 +70,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,9 +88,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",
1,
@ -98,14 +103,14 @@ async def test_entity_state(
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,
@ -121,10 +126,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,
@ -183,48 +188,48 @@ async def test_services(
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!"),
"Boom!",
),
(
("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!"),
"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!"),
"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!"),
"Boom!",
),
(
("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!"),
"Boom!",
),
(
("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!"),
"Boom!",
),
(
None,
@ -242,16 +247,16 @@ async def test_services(
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!"),
"Boom!",
),
(
("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!"),
"Boom!",
),
],
)