1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Update todoist-api-python to 3.1.0 (#161811)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Aaron Godfrey
2026-01-28 15:00:53 -08:00
committed by GitHub
parent c875b75272
commit 7e32b50fee
10 changed files with 324 additions and 215 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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:

View File

@@ -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

2
requirements_all.txt generated
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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,