From f7511457c3b40d57290f49d2520ca816d898d46c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Feb 2025 20:43:33 +0100 Subject: [PATCH] Use aiortm library in remember the milk --- .../components/remember_the_milk/__init__.py | 34 ++++-- .../components/remember_the_milk/entity.py | 114 +++++++++--------- .../remember_the_milk/manifest.json | 6 +- homeassistant/generated/integrations.json | 2 +- requirements_all.txt | 9 +- requirements_test_all.txt | 9 +- .../components/remember_the_milk/conftest.py | 48 ++++++-- .../remember_the_milk/test_entity.py | 75 ++++++------ 8 files changed, 170 insertions(+), 127 deletions(-) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 63c411c51ba..ed7c7b90bd5 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -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") diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index 08acc7a4075..2d216e682c7 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -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, ) diff --git a/homeassistant/components/remember_the_milk/manifest.json b/homeassistant/components/remember_the_milk/manifest.json index 13c37d56dba..4c47a504764 100644 --- a/homeassistant/components/remember_the_milk/manifest.json +++ b/homeassistant/components/remember_the_milk/manifest.json @@ -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"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2d28d4f46d7..d2aeb47d261 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index 88eeaafd223..a5b5cbe0649 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b37274817c5..35ca1b9555f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/remember_the_milk/conftest.py b/tests/components/remember_the_milk/conftest.py index 44a3a58e030..069264a4f48 100644 --- a/tests/components/remember_the_milk/conftest.py +++ b/tests/components/remember_the_milk/conftest.py @@ -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 diff --git a/tests/components/remember_the_milk/test_entity.py b/tests/components/remember_the_milk/test_entity.py index e9d7a16d7ab..ddcbc90878c 100644 --- a/tests/components/remember_the_milk/test_entity.py +++ b/tests/components/remember_the_milk/test_entity.py @@ -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!", ), ], )