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

View File

@ -2,12 +2,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from aiortm import AioRTMClient, AioRTMError, AuthError
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 HomeAssistant, ServiceCall from homeassistant.core import ServiceCall
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from .const import LOGGER from .const import LOGGER
@ -20,35 +18,43 @@ class RememberTheMilkEntity(Entity):
def __init__( def __init__(
self, self,
name: str, name: str,
api_key: str, client: AioRTMClient,
shared_secret: str,
token: str,
rtm_config: RememberTheMilkConfiguration, rtm_config: RememberTheMilkConfiguration,
) -> None: ) -> None:
"""Create new instance of Remember The Milk component.""" """Create new instance of Remember The Milk component."""
self._name = name self._name = name
self._api_key = api_key
self._shared_secret = shared_secret
self._token = token
self._rtm_config = rtm_config self._rtm_config = rtm_config
self._rtm_api = Rtm(api_key, shared_secret, "delete", token) self._client = client
self._token_valid = False 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. """Check if the API token is still valid.
If it is not valid any more, delete it from the configuration. This If it is not valid any more, delete it from the configuration. This
will trigger a new authentication process. will trigger a new authentication process.
""" """
valid = await hass.async_add_executor_job(self._rtm_api.token_valid) try:
if valid: 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 self._token_valid = True
return return
LOGGER.error(
"Token for account %s is invalid. You need to register again!",
self.name,
)
self._rtm_config.delete_token(self._name) self._rtm_config.delete_token(self._name)
self._token_valid = False self._token_valid = False
@ -67,10 +73,7 @@ class RememberTheMilkEntity(Entity):
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:
result = await self.hass.async_add_executor_job( rtm_id = await self._add_task(task_name)
self._add_task,
task_name,
)
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
) )
@ -78,48 +81,51 @@ class RememberTheMilkEntity(Entity):
self._rtm_config.set_rtm_id( self._rtm_config.set_rtm_id(
self._name, self._name,
hass_id, hass_id,
result.list.id, rtm_id[0],
result.list.taskseries.id, rtm_id[1],
result.list.taskseries.task.id, rtm_id[2],
) )
else: else:
await self.hass.async_add_executor_job( await self._rename_task(rtm_id, task_name)
self._rename_task,
rtm_id,
task_name,
)
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 AioRTMError as err:
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, err,
) )
def _add_task(self, task_name: str) -> Any: async def _add_task(self, task_name: str) -> tuple[str, str, str]:
"""Add a task.""" """Add a task."""
result = self._rtm_api.rtm.timelines.create() timeline_response = await self._client.rtm.timelines.create()
timeline = result.timeline.value timeline = timeline_response.timeline
return self._rtm_api.rtm.tasks.add( task_response = await self._client.rtm.tasks.add(
timeline=timeline, timeline=timeline,
name=task_name, 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.""" """Rename a task."""
result = self._rtm_api.rtm.timelines.create() result = await self._client.rtm.timelines.create()
timeline = result.timeline.value timeline = result.timeline
self._rtm_api.rtm.tasks.setName( await self._client.rtm.tasks.set_name(
name=task_name, name=task_name,
list_id=rtm_id[0], list_id=int(rtm_id[0]),
taskseries_id=rtm_id[1], taskseries_id=int(rtm_id[1]),
task_id=rtm_id[2], task_id=int(rtm_id[2]),
timeline=timeline, timeline=timeline,
) )
@ -138,26 +144,26 @@ class RememberTheMilkEntity(Entity):
) )
return return
try: try:
await self.hass.async_add_executor_job(self._complete_task, rtm_id) await self._complete_task(rtm_id)
except RtmRequestFailedException as rtm_exception: except AioRTMError as err:
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, err,
) )
return return
self._rtm_config.delete_rtm_id(self._name, hass_id) self._rtm_config.delete_rtm_id(self._name, hass_id)
LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name) 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.""" """Complete a task."""
result = self._rtm_api.rtm.timelines.create() result = await self._client.rtm.timelines.create()
timeline = result.timeline.value timeline = result.timeline
self._rtm_api.rtm.tasks.complete( await self._client.rtm.tasks.complete(
list_id=rtm_id[0], list_id=int(rtm_id[0]),
taskseries_id=rtm_id[1], taskseries_id=int(rtm_id[1]),
task_id=rtm_id[2], task_id=int(rtm_id[2]),
timeline=timeline, timeline=timeline,
) )

View File

@ -4,8 +4,8 @@
"codeowners": [], "codeowners": [],
"dependencies": ["configurator"], "dependencies": ["configurator"],
"documentation": "https://www.home-assistant.io/integrations/remember_the_milk", "documentation": "https://www.home-assistant.io/integrations/remember_the_milk",
"iot_class": "cloud_push", "iot_class": "cloud_polling",
"loggers": ["rtmapi"], "loggers": ["aiortm"],
"quality_scale": "legacy", "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", "name": "Remember The Milk",
"integration_type": "hub", "integration_type": "hub",
"config_flow": false, "config_flow": false,
"iot_class": "cloud_push" "iot_class": "cloud_polling"
}, },
"renault": { "renault": {
"name": "Renault", "name": "Renault",

9
requirements_all.txt generated
View File

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

View File

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

View File

@ -1,7 +1,7 @@
"""Provide common pytest fixtures.""" """Provide common pytest fixtures."""
from collections.abc import AsyncGenerator, Generator from collections.abc import AsyncGenerator, Generator
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -10,20 +10,48 @@ from homeassistant.core import HomeAssistant
from .const import TOKEN 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") @pytest.fixture(name="client")
def client_fixture() -> Generator[MagicMock]: def client_fixture() -> Generator[MagicMock]:
"""Create a mock client.""" """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 = 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 = MagicMock()
timelines.timeline.value = "1234" timelines.timeline = 1234
client.rtm.timelines.create.return_value = timelines client.rtm.timelines.create = AsyncMock(return_value=timelines)
add_response = MagicMock() response = MagicMock()
add_response.list.id = "1" response.task_list.id = 1
add_response.list.taskseries.id = "2" task_series = MagicMock()
add_response.list.taskseries.task.id = "3" task_series.id = 2
client.rtm.tasks.add.return_value = add_response 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 yield client

View File

@ -3,8 +3,8 @@
from typing import Any from typing import Any
from unittest.mock import MagicMock, call from unittest.mock import MagicMock, call
from aiortm import AioRTMError, AuthError
import pytest import pytest
from rtmapi import RtmRequestFailedException
from homeassistant.components.remember_the_milk import DOMAIN from homeassistant.components.remember_the_milk import DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -19,18 +19,23 @@ CONFIG = {
} }
@pytest.mark.usefixtures("storage")
@pytest.mark.parametrize( @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( async def test_entity_state(
hass: HomeAssistant, hass: HomeAssistant,
client: MagicMock, client: MagicMock,
storage: MagicMock, check_token_side_effect: Exception | None,
valid_token: bool,
entity_state: str, entity_state: str,
) -> None: ) -> None:
"""Test the entity state.""" """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}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG})
entity_id = f"{DOMAIN}.{PROFILE}" entity_id = f"{DOMAIN}.{PROFILE}"
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
@ -65,9 +70,9 @@ async def test_entity_state(
"rtm.tasks.add", "rtm.tasks.add",
1, 1,
call( call(
timeline="1234", timeline=1234,
name="Test 1", name="Test 1",
parse="1", parse=True,
), ),
"set_rtm_id", "set_rtm_id",
0, 0,
@ -83,9 +88,9 @@ async def test_entity_state(
"rtm.tasks.add", "rtm.tasks.add",
1, 1,
call( call(
timeline="1234", timeline=1234,
name="Test 1", name="Test 1",
parse="1", parse=True,
), ),
"set_rtm_id", "set_rtm_id",
1, 1,
@ -98,14 +103,14 @@ async def test_entity_state(
1, 1,
call(PROFILE, "test_1"), call(PROFILE, "test_1"),
1, 1,
"rtm.tasks.setName", "rtm.tasks.set_name",
1, 1,
call( call(
name="Test 1", name="Test 1",
list_id="1", list_id=1,
taskseries_id="2", taskseries_id=2,
task_id="3", task_id=3,
timeline="1234", timeline=1234,
), ),
"set_rtm_id", "set_rtm_id",
0, 0,
@ -121,10 +126,10 @@ async def test_entity_state(
"rtm.tasks.complete", "rtm.tasks.complete",
1, 1,
call( call(
list_id="1", list_id=1,
taskseries_id="2", taskseries_id=2,
task_id="3", task_id=3,
timeline="1234", timeline=1234,
), ),
"delete_rtm_id", "delete_rtm_id",
1, 1,
@ -183,48 +188,48 @@ async def test_services(
f"{PROFILE}_create_task", f"{PROFILE}_create_task",
{"name": "Test 1"}, {"name": "Test 1"},
"rtm.timelines.create", "rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), AioRTMError("Boom!"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.", "Boom!",
), ),
( (
("1", "2", "3"), ("1", "2", "3"),
f"{PROFILE}_create_task", f"{PROFILE}_create_task",
{"name": "Test 1"}, {"name": "Test 1"},
"rtm.tasks.add", "rtm.tasks.add",
RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"), AioRTMError("Boom!"),
"Request rtm.tasks.add failed. Status: 400, reason: Bad request.", "Boom!",
), ),
( (
None, None,
f"{PROFILE}_create_task", f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"}, {"name": "Test 1", "id": "test_1"},
"rtm.timelines.create", "rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), AioRTMError("Boom!"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.", "Boom!",
), ),
( (
None, None,
f"{PROFILE}_create_task", f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"}, {"name": "Test 1", "id": "test_1"},
"rtm.tasks.add", "rtm.tasks.add",
RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"), AioRTMError("Boom!"),
"Request rtm.tasks.add failed. Status: 400, reason: Bad request.", "Boom!",
), ),
( (
("1", "2", "3"), ("1", "2", "3"),
f"{PROFILE}_create_task", f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"}, {"name": "Test 1", "id": "test_1"},
"rtm.timelines.create", "rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), AioRTMError("Boom!"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.", "Boom!",
), ),
( (
("1", "2", "3"), ("1", "2", "3"),
f"{PROFILE}_create_task", f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"}, {"name": "Test 1", "id": "test_1"},
"rtm.tasks.setName", "rtm.tasks.set_name",
RtmRequestFailedException("rtm.tasks.setName", "400", "Bad request"), AioRTMError("Boom!"),
"Request rtm.tasks.setName failed. Status: 400, reason: Bad request.", "Boom!",
), ),
( (
None, None,
@ -242,16 +247,16 @@ async def test_services(
f"{PROFILE}_complete_task", f"{PROFILE}_complete_task",
{"id": "test_1"}, {"id": "test_1"},
"rtm.timelines.create", "rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), AioRTMError("Boom!"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.", "Boom!",
), ),
( (
("1", "2", "3"), ("1", "2", "3"),
f"{PROFILE}_complete_task", f"{PROFILE}_complete_task",
{"id": "test_1"}, {"id": "test_1"},
"rtm.tasks.complete", "rtm.tasks.complete",
RtmRequestFailedException("rtm.tasks.complete", "400", "Bad request"), AioRTMError("Boom!"),
"Request rtm.tasks.complete failed. Status: 400, reason: Bad request.", "Boom!",
), ),
], ],
) )