From 7e32b50feef0608af35f5aefbe2c9383a980a223 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Wed, 28 Jan 2026 15:00:53 -0800 Subject: [PATCH] Update todoist-api-python to 3.1.0 (#161811) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/todoist/calendar.py | 130 ++++++++-------- .../components/todoist/coordinator.py | 26 +++- .../components/todoist/manifest.json | 2 +- homeassistant/components/todoist/todo.py | 18 +-- homeassistant/components/todoist/util.py | 35 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/todoist/conftest.py | 140 ++++++++++++------ tests/components/todoist/test_calendar.py | 47 +++--- tests/components/todoist/test_todo.py | 137 +++++++++-------- 10 files changed, 324 insertions(+), 215 deletions(-) create mode 100644 homeassistant/components/todoist/util.py diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 2e2873353c6..509ce593699 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -8,9 +8,7 @@ from typing import Any import uuid from todoist_api_python.api_async import TodoistAPIAsync -from todoist_api_python.endpoints import get_sync_url -from todoist_api_python.headers import create_headers -from todoist_api_python.models import Due, Label, Task +from todoist_api_python.models import Label, Project, Task import voluptuous as vol from homeassistant.components.calendar import ( @@ -62,8 +60,9 @@ from .const import ( START, SUMMARY, ) -from .coordinator import TodoistCoordinator +from .coordinator import TodoistCoordinator, flatten_async_pages from .types import CalData, CustomProject, ProjectData, TodoistEvent +from .util import parse_due_date _LOGGER = logging.getLogger(__name__) @@ -157,18 +156,22 @@ async def async_setup_platform( # Setup devices: # Grab all projects. - projects = await api.get_projects() + projects_result = await api.get_projects() + all_projects: list[Project] = await flatten_async_pages(projects_result) # Grab all labels - labels = await api.get_labels() + labels_result = await api.get_labels() + all_labels: list[Label] = await flatten_async_pages(labels_result) # Add all Todoist-defined projects. project_devices = [] - for project in projects: + for project in all_projects: # Project is an object, not a dict! # Because of that, we convert what we need to a dict. project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id} - project_devices.append(TodoistProjectEntity(coordinator, project_data, labels)) + project_devices.append( + TodoistProjectEntity(coordinator, project_data, all_labels) + ) # Cache the names so we can easily look up name->ID. project_id_lookup[project.name.lower()] = project.id @@ -196,7 +199,7 @@ async def async_setup_platform( TodoistProjectEntity( coordinator, {"id": None, "name": extra_project["name"]}, - labels, + all_labels, due_date_days=project_due_date, whitelisted_labels=project_label_filter, whitelisted_projects=project_id_filter, @@ -218,7 +221,7 @@ def async_register_services( # noqa: C901 session = async_get_clientsession(hass) - async def handle_new_task(call: ServiceCall) -> None: # noqa: C901 + async def handle_new_task(call: ServiceCall) -> None: """Call when a user creates a new Todoist Task from Home Assistant.""" project_name = call.data[PROJECT_NAME] projects = await coordinator.async_get_projects() @@ -269,9 +272,10 @@ def async_register_services( # noqa: C901 data["labels"] = task_labels if ASSIGNEE in call.data: - collaborators = await coordinator.api.get_collaborators(project_id) + collaborators_result = await coordinator.api.get_collaborators(project_id) + all_collaborators = await flatten_async_pages(collaborators_result) collaborator_id_lookup = { - collab.name.lower(): collab.id for collab in collaborators + collab.name.lower(): collab.id for collab in all_collaborators } task_assignee = call.data[ASSIGNEE].lower() if task_assignee in collaborator_id_lookup: @@ -297,17 +301,14 @@ def async_register_services( # noqa: C901 if due is None: raise ValueError(f"Invalid due_date: {call.data[DUE_DATE]}") due_date = datetime(due.year, due.month, due.day) - # Format it in the manner Todoist expects - due_date = dt_util.as_utc(due_date) - date_format = "%Y-%m-%dT%H:%M:%S" - data["due_datetime"] = datetime.strftime(due_date, date_format) + # Pass the datetime object directly - the library handles formatting + data["due_datetime"] = dt_util.as_utc(due_date) api_task = await coordinator.api.add_task(content, **data) - # @NOTE: The rest-api doesn't support reminders, this works manually using - # the sync api, in order to keep functional parity with the component. - # https://developer.todoist.com/sync/v9/#reminders - sync_url = get_sync_url("sync") + # The REST API doesn't support reminders, so we use the Sync API directly + # to maintain functional parity with the component. + # https://developer.todoist.com/api/v1/#tag/Sync/Reminders/Add-a-reminder _reminder_due: dict = {} if REMINDER_DATE_STRING in call.data: _reminder_due["string"] = call.data[REMINDER_DATE_STRING] @@ -316,20 +317,21 @@ def async_register_services( # noqa: C901 _reminder_due["lang"] = call.data[REMINDER_DATE_LANG] if REMINDER_DATE in call.data: - due_date = dt_util.parse_datetime(call.data[REMINDER_DATE]) - if due_date is None: - due = dt_util.parse_date(call.data[REMINDER_DATE]) - if due is None: + reminder_date = dt_util.parse_datetime(call.data[REMINDER_DATE]) + if reminder_date is None: + reminder = dt_util.parse_date(call.data[REMINDER_DATE]) + if reminder is None: raise ValueError( f"Invalid reminder_date: {call.data[REMINDER_DATE]}" ) - due_date = datetime(due.year, due.month, due.day) - # Format it in the manner Todoist expects - due_date = dt_util.as_utc(due_date) - date_format = "%Y-%m-%dT%H:%M:%S" - _reminder_due["date"] = datetime.strftime(due_date, date_format) + reminder_date = datetime(reminder.year, reminder.month, reminder.day) + # Format it in the manner Todoist expects (UTC with Z suffix) + reminder_date = dt_util.as_utc(reminder_date) + date_format = "%Y-%m-%dT%H:%M:%S.000000Z" + _reminder_due["date"] = datetime.strftime(reminder_date, date_format) - async def add_reminder(reminder_due: dict): + if _reminder_due: + sync_url = "https://api.todoist.com/api/v1/sync" reminder_data = { "commands": [ { @@ -339,16 +341,16 @@ def async_register_services( # noqa: C901 "args": { "item_id": api_task.id, "type": "absolute", - "due": reminder_due, + "due": _reminder_due, }, } ] } - headers = create_headers(token=coordinator.token, with_content=True) - return await session.post(sync_url, headers=headers, json=reminder_data) - - if _reminder_due: - await add_reminder(_reminder_due) + headers = { + "Authorization": f"Bearer {coordinator.token}", + "Content-Type": "application/json", + } + await session.post(sync_url, headers=headers, json=reminder_data) _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) @@ -527,7 +529,7 @@ class TodoistProjectData: """ task: TodoistEvent = { ALL_DAY: False, - COMPLETED: data.is_completed, + COMPLETED: data.completed_at is not None, DESCRIPTION: f"https://todoist.com/showTask?id={data.id}", DUE_TODAY: False, END: None, @@ -561,22 +563,26 @@ class TodoistProjectData: # complete the task. # Generally speaking, that means right now. if data.due is not None: - end = dt_util.parse_datetime( - data.due.datetime if data.due.datetime else data.due.date - ) - task[END] = dt_util.as_local(end) if end is not None else end - if task[END] is not None: - if self._due_date_days is not None and ( - task[END] > dt_util.now() + self._due_date_days - ): - # This task is out of range of our due date; - # it shouldn't be counted. - return None + due_date = data.due.date + # The API returns date or datetime objects when deserialized via from_dict() + if isinstance(due_date, datetime): + task[END] = dt_util.as_local(due_date) + elif isinstance(due_date, date): + task[END] = dt_util.start_of_local_day(due_date) - task[DUE_TODAY] = task[END].date() == dt_util.now().date() + if (end_dt := task[END]) is not None: + if self._due_date_days is not None: + # For comparison with now, use datetime + + if end_dt > dt_util.now() + self._due_date_days: + # This task is out of range of our due date; + # it shouldn't be counted. + return None + + task[DUE_TODAY] = end_dt.date() == dt_util.now().date() # Special case: Task is overdue. - if task[END] <= task[START]: + if end_dt <= task[START]: task[OVERDUE] = True # Set end time to the current time plus 1 hour. # We're pretty much guaranteed to update within that 1 hour, @@ -681,7 +687,7 @@ class TodoistProjectData: for task in project_task_data: if task.due is None: continue - start = get_start(task.due) + start = parse_due_date(task.due) if start is None: continue event = CalendarEvent( @@ -689,9 +695,15 @@ class TodoistProjectData: start=start, end=start + timedelta(days=1), ) - if event.start_datetime_local >= end_date: + if ( + event.start_datetime_local is not None + and event.start_datetime_local >= end_date + ): continue - if event.end_datetime_local < start_date: + if ( + event.end_datetime_local is not None + and event.end_datetime_local < start_date + ): continue events.append(event) return events @@ -748,15 +760,3 @@ class TodoistProjectData: return self.event = event _LOGGER.debug("Updated %s", self._name) - - -def get_start(due: Due) -> datetime | date | None: - """Return the task due date as a start date or date time.""" - if due.datetime: - start = dt_util.parse_datetime(due.datetime) - if not start: - return None - return dt_util.as_local(start) - if due.date: - return dt_util.parse_date(due.date) - return None diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py index 2f35741c5ab..8bdf35ceaf5 100644 --- a/homeassistant/components/todoist/coordinator.py +++ b/homeassistant/components/todoist/coordinator.py @@ -1,7 +1,9 @@ """DataUpdateCoordinator for the Todoist component.""" +from collections.abc import AsyncGenerator from datetime import timedelta import logging +from typing import TypeVar from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.models import Label, Project, Section, Task @@ -10,6 +12,18 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +T = TypeVar("T") + + +async def flatten_async_pages( + pages: AsyncGenerator[list[T]], +) -> list[T]: + """Flatten paginated results from an async generator.""" + all_items: list[T] = [] + async for page in pages: + all_items.extend(page) + return all_items + class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): """Coordinator for updating task data from Todoist.""" @@ -39,22 +53,26 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): async def _async_update_data(self) -> list[Task]: """Fetch tasks from the Todoist API.""" try: - return await self.api.get_tasks() + tasks_async = await self.api.get_tasks() except Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + return await flatten_async_pages(tasks_async) async def async_get_projects(self) -> list[Project]: """Return todoist projects fetched at most once.""" if self._projects is None: - self._projects = await self.api.get_projects() + projects_async = await self.api.get_projects() + self._projects = await flatten_async_pages(projects_async) return self._projects async def async_get_sections(self, project_id: str) -> list[Section]: """Return todoist sections for a given project ID.""" - return await self.api.get_sections(project_id=project_id) + sections_async = await self.api.get_sections(project_id=project_id) + return await flatten_async_pages(sections_async) async def async_get_labels(self) -> list[Label]: """Return todoist labels fetched at most once.""" if self._labels is None: - self._labels = await self.api.get_labels() + labels_async = await self.api.get_labels() + self._labels = await flatten_async_pages(labels_async) return self._labels diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index 791f5642aad..67526a85b65 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/todoist", "iot_class": "cloud_polling", "loggers": ["todoist"], - "requirements": ["todoist-api-python==2.1.7"] + "requirements": ["todoist-api-python==3.1.0"] } diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 202c51fb4c0..fb56e30fb68 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -16,10 +16,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import TodoistCoordinator +from .util import parse_due_date async def async_setup_entry( @@ -99,24 +99,16 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit if task.parent_id is not None: # Filter out sub-tasks until they are supported by the UI. continue - if task.is_completed: + if task.completed_at is not None: status = TodoItemStatus.COMPLETED else: status = TodoItemStatus.NEEDS_ACTION - due: datetime.date | datetime.datetime | None = None - if task_due := task.due: - if task_due.datetime: - due = dt_util.as_local( - datetime.datetime.fromisoformat(task_due.datetime) - ) - elif task_due.date: - due = datetime.date.fromisoformat(task_due.date) items.append( TodoItem( summary=task.content, uid=task.id, status=status, - due=due, + due=parse_due_date(task.due), description=task.description or None, # Don't use empty string ) ) @@ -147,9 +139,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit if item.status != existing_item.status: if item.status == TodoItemStatus.COMPLETED: - await self.coordinator.api.close_task(task_id=uid) + await self.coordinator.api.complete_task(task_id=uid) else: - await self.coordinator.api.reopen_task(task_id=uid) + await self.coordinator.api.uncomplete_task(task_id=uid) await self.coordinator.async_refresh() async def async_delete_todo_items(self, uids: list[str]) -> None: diff --git a/homeassistant/components/todoist/util.py b/homeassistant/components/todoist/util.py new file mode 100644 index 00000000000..430db133ba8 --- /dev/null +++ b/homeassistant/components/todoist/util.py @@ -0,0 +1,35 @@ +"""Utility functions for the Todoist integration.""" + +from __future__ import annotations + +from datetime import date, datetime + +from todoist_api_python.models import Due + +from homeassistant.util import dt as dt_util + + +def parse_due_date(task_due: Due | None) -> date | datetime | None: + """Parse due date from Todoist task due object. + + The due.date field contains either a date object (for date-only tasks) + or a datetime object (for tasks with a specific time). When deserialized + from the API via from_dict(), these are already proper Python date/datetime + objects. + + Args: + task_due: The Due object from a Todoist task, or None. + + Returns: + A date object for date-only due dates, a localized datetime for + datetime due dates, or None if no due date is set. + + """ + if task_due is None or not (due_date := task_due.date): + return None + + if isinstance(due_date, datetime): + return dt_util.as_local(due_date) + if isinstance(due_date, date): + return due_date + return None diff --git a/requirements_all.txt b/requirements_all.txt index 429780eeb64..7e813c65915 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3040,7 +3040,7 @@ tilt-pi==0.2.1 tmb==0.0.4 # homeassistant.components.todoist -todoist-api-python==2.1.7 +todoist-api-python==3.1.0 # homeassistant.components.togrill togrill-bluetooth==0.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6732f867f5..db80930b057 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2540,7 +2540,7 @@ tilt-ble==1.0.1 tilt-pi==0.2.1 # homeassistant.components.todoist -todoist-api-python==2.1.7 +todoist-api-python==3.1.0 # homeassistant.components.togrill togrill-bluetooth==0.8.1 diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 84f0fa740e9..2b8bf169142 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -1,7 +1,8 @@ """Common fixtures for the todoist tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Callable, Generator from http import HTTPStatus +from typing import TypeVar from unittest.mock import AsyncMock, patch import pytest @@ -13,15 +14,55 @@ from homeassistant.components.todoist import DOMAIN from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry +T = TypeVar("T") + PROJECT_ID = "project-id-1" SECTION_ID = "section-id-1" SUMMARY = "A task" TOKEN = "some-token" -TODAY = dt_util.now().strftime("%Y-%m-%d") + + +async def _async_generator(items: list[T]) -> AsyncGenerator[list[T]]: + """Create an async generator that yields items as a single page.""" + yield items + + +def make_api_response(items: list[T]) -> Callable[[], AsyncGenerator[list[T]]]: + """Create a callable that returns a fresh async generator each time. + + This is needed because async generators can only be iterated once, + but mocks may be called multiple times. + """ + + async def _generator(*args, **kwargs) -> AsyncGenerator[list[T]]: + async for page in _async_generator(items): + yield page + + return _generator + + +def make_api_due( + date: str, + is_recurring: bool = False, + string: str = "", + timezone: str | None = None, +) -> Due: + """Create a Due object using from_dict to match API deserialization behavior. + + This ensures the date field is properly converted to date/datetime objects + just like the real API response deserialization does. + """ + data: dict = { + "date": date, + "is_recurring": is_recurring, + "string": string, + } + if timezone is not None: + data["timezone"] = timezone + return Due.from_dict(data) @pytest.fixture @@ -35,16 +76,18 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="due") def mock_due() -> Due: - """Mock a todoist Task Due date/time.""" - return Due( - is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today" - ) + """Mock a todoist Task Due date/time. + + Uses a fixed date matching the frozen test time in test_calendar.py + and test_todo.py (2024-05-24 12:00:00). + """ + return make_api_due(date="2024-05-24", string="today") def make_api_task( id: str | None = None, content: str | None = None, - is_completed: bool = False, + completed_at: str | None = None, due: Due | None = None, project_id: str | None = None, description: str | None = None, @@ -54,12 +97,11 @@ def make_api_task( return Task( assignee_id="1", assigner_id="1", - comment_count=0, - is_completed=is_completed, + completed_at=completed_at, content=content or SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", - description=description, + description=description or "", due=due, id=id or "1", labels=["Label1"], @@ -68,9 +110,10 @@ def make_api_task( priority=1, project_id=project_id or PROJECT_ID, section_id=None, - url="https://todoist.com", - sync_id=None, duration=None, + deadline=None, + is_collapsed=False, + updated_at="2021-10-01T00:00:00", ) @@ -84,38 +127,45 @@ def mock_tasks(due: Due) -> list[Task]: def mock_api(tasks: list[Task]) -> AsyncMock: """Mock the api state.""" api = AsyncMock() - api.get_projects.return_value = [ - Project( - id=PROJECT_ID, - color="blue", - comment_count=0, - is_favorite=False, - name="Name", - is_shared=False, - url="", - is_inbox_project=False, - is_team_inbox=False, - can_assign_tasks=False, - order=1, - parent_id=None, - view_style="list", - ) - ] - api.get_sections.return_value = [ - Section( - id=SECTION_ID, - project_id=PROJECT_ID, - name="Section Name", - order=1, - ) - ] - api.get_labels.return_value = [ - Label(id="1", name="Label1", color="1", order=1, is_favorite=False) - ] - api.get_collaborators.return_value = [ - Collaborator(email="user@gmail.com", id="1", name="user") - ] - api.get_tasks.return_value = tasks + api.get_projects.side_effect = make_api_response( + [ + Project( + id=PROJECT_ID, + color="blue", + is_favorite=False, + name="Name", + is_shared=False, + is_archived=False, + is_collapsed=False, + is_inbox_project=False, + can_assign_tasks=False, + order=1, + parent_id=None, + view_style="list", + description="", + created_at="2021-01-01", + updated_at="2021-01-01", + ) + ] + ) + api.get_sections.side_effect = make_api_response( + [ + Section( + id=SECTION_ID, + project_id=PROJECT_ID, + name="Section Name", + order=1, + is_collapsed=False, + ) + ] + ) + api.get_labels.side_effect = make_api_response( + [Label(id="1", name="Label1", color="1", order=1, is_favorite=False)] + ) + api.get_collaborators.side_effect = make_api_response( + [Collaborator(email="user@gmail.com", id="1", name="user")] + ) + api.get_tasks.side_effect = make_api_response(tasks) return api diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index cfd8f55df2d..bb03286cf53 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -29,7 +29,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import dt as dt_util -from .conftest import PROJECT_ID, SECTION_ID, SUMMARY +from .conftest import PROJECT_ID, SECTION_ID, SUMMARY, make_api_due from tests.typing import ClientSessionGenerator @@ -147,7 +147,7 @@ async def test_update_entity_for_custom_project_no_due_date_on( @pytest.mark.parametrize( "due", [ - Due( + make_api_due( # Note: This runs before the test fixture that sets the timezone date=( datetime( @@ -164,6 +164,7 @@ async def test_update_entity_for_calendar_with_due_date_in_the_future( hass: HomeAssistant, freezer: FrozenDateTimeFactory, api: AsyncMock, + due: Due, ) -> None: """Test that a task with a due date in the future has on state and correct end_time.""" await async_update_entity(hass, "calendar.name") @@ -216,37 +217,37 @@ async def test_calendar_custom_project_unique_id( ("due", "start", "end", "expected_response"), [ ( - Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), "2023-03-28T00:00:00.000Z", "2023-04-01T00:00:00.000Z", [get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})], ), ( - Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), "2023-03-30T06:00:00.000Z", "2023-03-31T06:00:00.000Z", [get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})], ), ( - Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), "2023-03-29T08:00:00.000Z", "2023-03-30T08:00:00.000Z", [get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})], ), ( - Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), "2023-03-30T08:00:00.000Z", "2023-03-31T08:00:00.000Z", [get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})], ), ( - Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), "2023-03-31T08:00:00.000Z", "2023-04-01T08:00:00.000Z", [], ), ( - Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), "2023-03-29T06:00:00.000Z", "2023-03-30T06:00:00.000Z", [], @@ -334,25 +335,22 @@ async def test_create_task_service_call_with_section( [ # These are all equivalent due dates for the same time in different # timezone formats. - Due( - date="2023-03-30", + make_api_due( + date="2023-03-31T00:00:00Z", is_recurring=False, string="Mar 30 6:00 PM", - datetime="2023-03-31T00:00:00Z", timezone="America/Regina", ), - Due( - date="2023-03-30", + make_api_due( + date="2023-03-31T00:00:00Z", is_recurring=False, string="Mar 30 7:00 PM", - datetime="2023-03-31T00:00:00Z", timezone="America/Los_Angeles", ), - Due( - date="2023-03-30", + make_api_due( + date="2023-03-30T18:00:00", is_recurring=False, string="Mar 30 6:00 PM", - datetime="2023-03-30T18:00:00", ), ], ids=("in_local_timezone", "in_other_timezone", "floating"), @@ -431,35 +429,35 @@ async def test_task_due_datetime( [ ( {"custom_projects": [{"name": "Test", "labels": ["Label1"]}]}, - Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), "2023-03-28T00:00:00.000Z", "2023-04-01T00:00:00.000Z", [get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})], ), ( {"custom_projects": [{"name": "Test", "labels": ["custom"]}]}, - Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), "2023-03-28T00:00:00.000Z", "2023-04-01T00:00:00.000Z", [], ), ( {"custom_projects": [{"name": "Test", "include_projects": ["Name"]}]}, - Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), "2023-03-28T00:00:00.000Z", "2023-04-01T00:00:00.000Z", [get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})], ), ( {"custom_projects": [{"name": "Test", "due_date_days": 1}]}, - Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), "2023-03-28T00:00:00.000Z", "2023-04-01T00:00:00.000Z", [get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})], ), ( {"custom_projects": [{"name": "Test", "due_date_days": 1}]}, - Due( + make_api_due( date=(dt_util.now() + timedelta(days=2)).strftime("%Y-%m-%d"), is_recurring=False, string="Mar 30", @@ -497,11 +495,10 @@ async def test_events_filtered_for_custom_projects( ("due", "setup_platform"), [ ( - Due( - date="2023-03-30", + make_api_due( + date="2023-03-31T00:00:00Z", is_recurring=False, string="Mar 30 6:00 PM", - datetime="2023-03-31T00:00:00Z", timezone="America/Regina", ), None, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index 1c2da67fb02..d15d857b47c 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock import pytest -from todoist_api_python.models import Due, Task +from todoist_api_python.models import Task from homeassistant.components.todo import ( ATTR_DESCRIPTION, @@ -20,7 +20,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity -from .conftest import PROJECT_ID, make_api_task +from .conftest import PROJECT_ID, make_api_due, make_api_response, make_api_task from tests.typing import WebSocketGenerator @@ -41,12 +41,19 @@ async def set_time_zone(hass: HomeAssistant) -> None: ("tasks", "expected_state"), [ ([], "0"), - ([make_api_task(id="12345", content="Soda", is_completed=False)], "1"), - ([make_api_task(id="12345", content="Soda", is_completed=True)], "0"), + ([make_api_task(id="12345", content="Soda", completed_at=None)], "1"), ( [ - make_api_task(id="12345", content="Milk", is_completed=False), - make_api_task(id="54321", content="Soda", is_completed=False), + make_api_task( + id="12345", content="Soda", completed_at="2021-10-01T00:00:00" + ) + ], + "0", + ), + ( + [ + make_api_task(id="12345", content="Milk", completed_at=None), + make_api_task(id="54321", content="Soda", completed_at=None), ], "2", ), @@ -55,7 +62,7 @@ async def set_time_zone(hass: HomeAssistant) -> None: make_api_task( id="12345", content="Soda", - is_completed=False, + completed_at=None, project_id="other-project-id", ) ], @@ -64,7 +71,7 @@ async def set_time_zone(hass: HomeAssistant) -> None: ( [ make_api_task( - id="12345", content="sub-task", is_completed=False, parent_id="1" + id="12345", content="sub-task", completed_at=None, parent_id="1" ) ], "0", @@ -89,7 +96,7 @@ async def test_todo_item_state( ( [], {}, - [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + [make_api_task(id="task-id-1", content="Soda", completed_at=None)], {"content": "Soda", "due_string": "no date", "description": ""}, {"uid": "task-id-1", "summary": "Soda", "status": "needs_action"}, ), @@ -100,8 +107,10 @@ async def test_todo_item_state( make_api_task( id="task-id-1", content="Soda", - is_completed=False, - due=Due(is_recurring=False, date="2023-11-18", string="today"), + completed_at=None, + due=make_api_due( + date="2023-11-18", is_recurring=False, string="today" + ), ) ], {"description": "", "due_date": "2023-11-18"}, @@ -119,11 +128,10 @@ async def test_todo_item_state( make_api_task( id="task-id-1", content="Soda", - is_completed=False, - due=Due( - date="2023-11-18", + completed_at=None, + due=make_api_due( + date="2023-11-18T12:30:00.000000Z", is_recurring=False, - datetime="2023-11-18T12:30:00.000000Z", string="today", ), ) @@ -147,7 +155,7 @@ async def test_todo_item_state( id="task-id-1", content="Soda", description="6-pack", - is_completed=False, + completed_at=None, ) ], {"description": "6-pack", "due_string": "no date"}, @@ -178,7 +186,7 @@ async def test_add_todo_list_item( api.add_task = AsyncMock() # Fake API response when state is refreshed after create - api.get_tasks.return_value = tasks_after_update + api.get_tasks.side_effect = make_api_response(tasks_after_update) await hass.services.async_call( TODO_DOMAIN, @@ -209,7 +217,7 @@ async def test_add_todo_list_item( @pytest.mark.parametrize( - ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] + ("tasks"), [[make_api_task(id="task-id-1", content="Soda", completed_at=None)]] ) async def test_update_todo_item_status( hass: HomeAssistant, @@ -222,13 +230,17 @@ async def test_update_todo_item_status( assert state assert state.state == "1" - api.close_task = AsyncMock() - api.reopen_task = AsyncMock() + api.complete_task = AsyncMock() + api.uncomplete_task = AsyncMock() - # Fake API response when state is refreshed after close - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=True) - ] + # Fake API response when state is refreshed after complete + api.get_tasks.side_effect = make_api_response( + [ + make_api_task( + id="task-id-1", content="Soda", completed_at="2021-10-01T00:00:00" + ) + ] + ) await hass.services.async_call( TODO_DOMAIN, @@ -237,21 +249,21 @@ async def test_update_todo_item_status( target={ATTR_ENTITY_ID: "todo.name"}, blocking=True, ) - assert api.close_task.called - args = api.close_task.call_args + assert api.complete_task.called + args = api.complete_task.call_args assert args assert args.kwargs.get("task_id") == "task-id-1" - assert not api.reopen_task.called + assert not api.uncomplete_task.called # Verify state is refreshed state = hass.states.get("todo.name") assert state assert state.state == "0" - # Fake API response when state is refreshed after reopen - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=False) - ] + # Fake API response when state is refreshed after reopening task + api.get_tasks.side_effect = make_api_response( + [make_api_task(id="task-id-1", content="Soda", completed_at=None)] + ) await hass.services.async_call( TODO_DOMAIN, @@ -260,8 +272,8 @@ async def test_update_todo_item_status( target={ATTR_ENTITY_ID: "todo.name"}, blocking=True, ) - assert api.reopen_task.called - args = api.reopen_task.call_args + assert api.uncomplete_task.called + args = api.uncomplete_task.call_args assert args assert args.kwargs.get("task_id") == "task-id-1" @@ -279,7 +291,7 @@ async def test_update_todo_item_status( make_api_task( id="task-id-1", content="Soda", - is_completed=False, + completed_at=None, description="desc", ) ], @@ -288,7 +300,7 @@ async def test_update_todo_item_status( make_api_task( id="task-id-1", content="Milk", - is_completed=False, + completed_at=None, description="desc", ) ], @@ -306,14 +318,16 @@ async def test_update_todo_item_status( }, ), ( - [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + [make_api_task(id="task-id-1", content="Soda", completed_at=None)], {ATTR_DUE_DATE: "2023-11-18"}, [ make_api_task( id="task-id-1", content="Soda", - is_completed=False, - due=Due(is_recurring=False, date="2023-11-18", string="today"), + completed_at=None, + due=make_api_due( + date="2023-11-18", is_recurring=False, string="today" + ), ) ], { @@ -330,17 +344,16 @@ async def test_update_todo_item_status( }, ), ( - [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + [make_api_task(id="task-id-1", content="Soda", completed_at=None)], {ATTR_DUE_DATETIME: "2023-11-18T06:30:00"}, [ make_api_task( id="task-id-1", content="Soda", - is_completed=False, - due=Due( - date="2023-11-18", + completed_at=None, + due=make_api_due( + date="2023-11-18T12:30:00.000000Z", is_recurring=False, - datetime="2023-11-18T12:30:00.000000Z", string="today", ), ) @@ -359,14 +372,14 @@ async def test_update_todo_item_status( }, ), ( - [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + [make_api_task(id="task-id-1", content="Soda", completed_at=None)], {ATTR_DESCRIPTION: "6-pack"}, [ make_api_task( id="task-id-1", content="Soda", description="6-pack", - is_completed=False, + completed_at=None, ) ], { @@ -388,7 +401,7 @@ async def test_update_todo_item_status( id="task-id-1", content="Soda", description="6-pack", - is_completed=False, + completed_at=None, ) ], {ATTR_DESCRIPTION: None}, @@ -396,7 +409,7 @@ async def test_update_todo_item_status( make_api_task( id="task-id-1", content="Soda", - is_completed=False, + completed_at=None, description="", ) ], @@ -418,10 +431,12 @@ async def test_update_todo_item_status( id="task-id-1", content="Soda", description="6-pack", - is_completed=False, - # Create a mock task with a string value in the Due object and verify it + completed_at=None, + # Create a mock task with a Due object and verify the due string # gets preserved when verifying the kwargs to update below - due=Due(date="2024-01-01", is_recurring=True, string="every day"), + due=make_api_due( + date="2024-01-01", is_recurring=True, string="every day" + ), ) ], {ATTR_DUE_DATE: "2024-02-01"}, @@ -430,8 +445,10 @@ async def test_update_todo_item_status( id="task-id-1", content="Soda", description="6-pack", - is_completed=False, - due=Due(date="2024-02-01", is_recurring=True, string="every day"), + completed_at=None, + due=make_api_due( + date="2024-02-01", is_recurring=True, string="every day" + ), ) ], { @@ -477,7 +494,7 @@ async def test_update_todo_items( api.update_task = AsyncMock() # Fake API response when state is refreshed after close - api.get_tasks.return_value = tasks_after_update + api.get_tasks.side_effect = make_api_response(tasks_after_update) await hass.services.async_call( TODO_DOMAIN, @@ -506,8 +523,8 @@ async def test_update_todo_items( ("tasks"), [ [ - make_api_task(id="task-id-1", content="Soda", is_completed=False), - make_api_task(id="task-id-2", content="Milk", is_completed=False), + make_api_task(id="task-id-1", content="Soda", completed_at=None), + make_api_task(id="task-id-2", content="Milk", completed_at=None), ] ], ) @@ -524,7 +541,7 @@ async def test_remove_todo_item( api.delete_task = AsyncMock() # Fake API response when state is refreshed after close - api.get_tasks.return_value = [] + api.get_tasks.side_effect = make_api_response([]) await hass.services.async_call( TODO_DOMAIN, @@ -545,7 +562,7 @@ async def test_remove_todo_item( @pytest.mark.parametrize( - ("tasks"), [[make_api_task(id="task-id-1", content="Cheese", is_completed=False)]] + ("tasks"), [[make_api_task(id="task-id-1", content="Cheese", completed_at=None)]] ) async def test_subscribe( hass: HomeAssistant, @@ -579,9 +596,9 @@ async def test_subscribe( assert items[0]["uid"] # Fake API response when state is refreshed - api.get_tasks.return_value = [ - make_api_task(id="test-id-1", content="Wine", is_completed=False) - ] + api.get_tasks.side_effect = make_api_response( + [make_api_task(id="test-id-1", content="Wine", completed_at=None)] + ) await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM,