Add to-do list platform to Apple iCloud (#181058)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Federico Zivolo
2026-09-08 19:18:15 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent d4842c8be3
commit 5006c36d10
8 changed files with 1077 additions and 19 deletions
+11 -7
View File
@@ -18,7 +18,7 @@ from .const import (
STORAGE_KEY,
STORAGE_VERSION,
)
from .coordinator import IcloudCalendarCoordinator
from .coordinator import IcloudCalendarCoordinator, IcloudRemindersCoordinator
from .media_source import async_setup_mediasource, async_setup_photo_cache
from .services import async_setup_services
@@ -63,15 +63,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
await hass.async_add_executor_job(account.setup)
# Refreshed before the platforms are forwarded so the calendars are known
# by the time the calendar platform sets up. This deliberately does not use
# async_config_entry_first_refresh: an account that fails to authenticate
# still loads and starts a reauth flow, and a calendar outage should not
# take device tracking down with it. Calendars that are missing from the
# first refresh appear on a later one through the coordinator listener.
# Refreshed before the platforms are forwarded so the calendars and lists
# are known by the time their platforms set up. This deliberately does not
# use async_config_entry_first_refresh: an account that fails to
# authenticate still loads and starts a reauth flow, and a calendar or
# Reminders outage should not take device tracking down with it. Anything
# missing from the first refresh appears on a later one through the
# coordinator listener.
account.calendar_coordinator = IcloudCalendarCoordinator(hass, entry)
await account.calendar_coordinator.async_refresh()
account.reminders_coordinator = IcloudRemindersCoordinator(hass, entry)
await account.reminders_coordinator.async_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await async_setup_photo_cache(hass, account)
+2 -1
View File
@@ -56,7 +56,7 @@ from .const import (
)
if TYPE_CHECKING:
from .coordinator import IcloudCalendarCoordinator
from .coordinator import IcloudCalendarCoordinator, IcloudRemindersCoordinator
from .media_source import PhotoCache
_LOGGER = logging.getLogger(__name__)
@@ -101,6 +101,7 @@ class IcloudAccount:
# Built in async_setup_entry, before the platforms are forwarded.
self.calendar_coordinator: IcloudCalendarCoordinator | None = None
self.reminders_coordinator: IcloudRemindersCoordinator | None = None
self.photo_cache: PhotoCache | None = None
+1 -1
View File
@@ -18,7 +18,7 @@ DEFAULT_GPS_ACCURACY_THRESHOLD = 500 # meters
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 2
PLATFORMS = [Platform.CALENDAR, Platform.DEVICE_TRACKER, Platform.SENSOR]
PLATFORMS = [Platform.CALENDAR, Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.TODO]
# pyicloud.AppleDevice status
DEVICE_BATTERY_LEVEL = "batteryLevel"
+172 -4
View File
@@ -1,12 +1,14 @@
"""Coordinator for iCloud Calendars."""
"""Coordinators for the iCloud integration."""
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, tzinfo
import logging
from typing import override
from pyicloud.exceptions import PyiCloudException
from pyicloud.services.calendar import CalendarService, EventObject
from pyicloud.services.reminders.client import RemindersApiError, RemindersAuthError
from pyicloud.services.reminders.service import RemindersService
from homeassistant.components.calendar import CalendarEvent
from homeassistant.core import HomeAssistant
@@ -19,7 +21,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=15)
CALENDAR_SCAN_INTERVAL = timedelta(minutes=15)
# How much of the calendar to keep cached for the entity's current/next event.
# `async_get_events` queries iCloud directly for anything outside this window.
@@ -52,7 +54,7 @@ class IcloudCalendarCoordinator(DataUpdateCoordinator[dict[str, IcloudCalendarDa
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
update_interval=CALENDAR_SCAN_INTERVAL,
)
self.account = entry.runtime_data
@@ -196,3 +198,169 @@ def _as_calendar_event(event: EventObject) -> CalendarEvent | None:
end=end_value,
location=event.location or None,
)
REMINDERS_SCAN_INTERVAL = timedelta(minutes=5)
# pyicloud raises these for CloudKit failures; neither subclasses
# PyiCloudException, so both have to be caught explicitly.
# `get()` raises a bare LookupError when a reminder was removed elsewhere.
REMINDERS_ERRORS = (
PyiCloudException,
RemindersApiError,
RemindersAuthError,
LookupError,
)
# pyicloud substitutes this when a reminder's title cannot be decrypted, which
# happens on Advanced Data Protection accounts whose session has no Protected
# Cloud Storage key. Such a reminder has no readable title and no notes, and
# `update()` rewrites TitleDocument and NotesDocument unconditionally, so
# writing one back would replace the real title with this placeholder and blank
# the notes. They are left out of the to-do lists entirely until the library
# requests Protected Cloud Storage access.
UNDECODED_TITLE = "Error Decoding Title"
# Reminders are fetched one list at a time, so keep the per-list page bounded.
RESULTS_LIMIT = 200
@dataclass(slots=True)
class IcloudReminder:
"""A single reminder."""
uid: str
summary: str
description: str | None
due: date | datetime | None
completed: bool
completed_at: datetime | None
# Only used to order subtasks after their parent; never exposed on the
# to-do item, which has no notion of a parent.
parent_uid: str | None
@dataclass(slots=True)
class IcloudReminderList:
"""A reminder list and the reminders it holds."""
list_id: str
name: str
reminders: list[IcloudReminder] = field(default_factory=list)
class IcloudRemindersCoordinator(DataUpdateCoordinator[dict[str, IcloudReminderList]]):
"""Fetch reminder lists and their contents."""
def __init__(self, hass: HomeAssistant, entry: IcloudConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=REMINDERS_SCAN_INTERVAL,
)
self.account = entry.runtime_data
self._warned_undecoded = False
self._skipped_undecoded = False
@property
def reminders(self) -> RemindersService:
"""Return the reminders service of the authenticated account."""
if (api := self.account.api) is None:
raise ConfigEntryAuthFailed("iCloud account is not authenticated")
return api.reminders
def _fetch(self) -> dict[str, IcloudReminderList]:
"""Fetch every list and its reminders. Runs in the executor."""
service = self.reminders
result: dict[str, IcloudReminderList] = {}
self._skipped_undecoded = False
for list_id, name in _live_lists(service):
batch = service.list_reminders(
list_id=list_id,
include_completed=True,
results_limit=RESULTS_LIMIT,
)
reminders = []
for reminder in batch.reminders:
if reminder.deleted:
continue
if reminder.title == UNDECODED_TITLE:
self._skipped_undecoded = True
continue
reminders.append(_as_reminder(reminder))
result[list_id] = IcloudReminderList(
list_id=list_id, name=name, reminders=reminders
)
return result
@override
async def _async_update_data(self) -> dict[str, IcloudReminderList]:
"""Fetch reminders from iCloud."""
try:
data = await self.hass.async_add_executor_job(self._fetch)
except REMINDERS_ERRORS as err:
raise UpdateFailed(f"Error fetching reminders: {err}") from err
self._warn_if_undecoded()
return data
def _warn_if_undecoded(self) -> None:
"""Warn once if reminders had to be left out because of encryption.
With Advanced Data Protection enabled, CloudKit only returns readable
content to a session holding a Protected Cloud Storage key, which
pyicloud does not yet request. Those reminders are skipped rather than
shown as placeholders, so say once why a list looks short.
"""
if self._warned_undecoded or not self._skipped_undecoded:
return
self._warned_undecoded = True
_LOGGER.warning(
"Some reminders could not be decrypted and have been left out of "
"the to-do lists. This account uses Advanced Data Protection, "
"which Home Assistant cannot read Reminders from yet"
)
def _live_lists(service: RemindersService) -> list[tuple[str, str]]:
"""Return the ``(list_id, name)`` pairs that should become todo entities.
``lists()`` yields every List record in the CloudKit zone, which includes
groups: a folder holding other lists rather than reminders. A group and a
list inside it can share a name, so leaving groups in shows that name
twice, once as a permanently empty list.
Deleted lists leave tombstones that cannot be recognised yet. pyicloud
exposes ``deleted`` on a reminder but not on a list, and the model forbids
extra fields, so a tombstone is indistinguishable from an empty list until
timlaing/pyicloud#319 is released.
"""
return [
(reminder_list.id, reminder_list.title)
for reminder_list in service.lists()
if not reminder_list.is_group
]
def _as_reminder(reminder) -> IcloudReminder:
"""Convert a pyicloud reminder into the coordinator's model."""
due = reminder.due_date
if due is not None and reminder.all_day and isinstance(due, datetime):
due = due.date()
return IcloudReminder(
uid=reminder.id,
summary=reminder.title,
description=reminder.desc or None,
due=due,
completed=reminder.completed,
completed_at=reminder.completed_date,
parent_uid=reminder.parent_reminder_id,
)
+260
View File
@@ -0,0 +1,260 @@
"""Support for iCloud Reminders."""
from datetime import date, datetime
from typing import override
from pyicloud.services.reminders.service import RemindersService
from homeassistant.components.todo import (
TodoItem,
TodoItemStatus,
TodoListEntity,
TodoListEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .account import IcloudConfigEntry
from .const import DOMAIN
from .coordinator import (
REMINDERS_ERRORS,
UNDECODED_TITLE,
IcloudReminder,
IcloudRemindersCoordinator,
)
# Every action is a blocking read-modify-write against one shared reminders
# service, so two concurrent calls could read the same reminder and then
# overwrite each other.
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: IcloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the iCloud reminder lists."""
coordinator = entry.runtime_data.reminders_coordinator
assert coordinator is not None
known: set[str] = set()
@callback
def _add_new_lists() -> None:
"""Add entities for lists that appeared since the last poll."""
if not (new := set(coordinator.data or {}) - known):
return
known.update(new)
async_add_entities(
IcloudTodoListEntity(coordinator, entry, list_id) for list_id in new
)
_add_new_lists()
entry.async_on_unload(coordinator.async_add_listener(_add_new_lists))
class IcloudTodoListEntity(
CoordinatorEntity[IcloudRemindersCoordinator], TodoListEntity
):
"""A reminder list from iCloud."""
_attr_has_entity_name = True
_attr_supported_features = (
TodoListEntityFeature.CREATE_TODO_ITEM
| TodoListEntityFeature.UPDATE_TODO_ITEM
| TodoListEntityFeature.DELETE_TODO_ITEM
| TodoListEntityFeature.SET_DUE_DATE_ON_ITEM
| TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
)
def __init__(
self,
coordinator: IcloudRemindersCoordinator,
entry: IcloudConfigEntry,
list_id: str,
) -> None:
"""Initialize the reminder list."""
super().__init__(coordinator)
self._list_id = list_id
self._attr_unique_id = f"{entry.unique_id}_{list_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{entry.unique_id}_account")},
manufacturer="Apple",
name=entry.title,
entry_type=DeviceEntryType.SERVICE,
)
@property
@override
def available(self) -> bool:
"""Return True if the list still exists in iCloud."""
return super().available and self._list_id in (self.coordinator.data or {})
@property
@override
def name(self) -> str | None:
"""Return the name of the list."""
if (
reminder_list := (self.coordinator.data or {}).get(self._list_id)
) is not None:
return reminder_list.name
return None
@property
@override
def todo_items(self) -> list[TodoItem] | None:
"""Return the reminders in this list.
A subtask is an ordinary to-do item here, not a child of anything: the
hierarchy only decides the order, so no entity model change is needed.
"""
if (reminder_list := (self.coordinator.data or {}).get(self._list_id)) is None:
return None
return [_as_todo_item(item) for item in _ordered(reminder_list.reminders)]
@override
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Add a reminder to the list."""
await self._async_call(self._create, item)
@override
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update a reminder."""
if item.uid is None:
raise HomeAssistantError("Cannot update a reminder without an identifier")
await self._async_call(self._update, item)
@override
async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete reminders from the list."""
await self._async_call(self._delete, uids)
def _service(self) -> RemindersService:
"""Return the reminders service. Runs in the executor."""
return self.coordinator.reminders
def _create(self, item: TodoItem) -> None:
"""Create a reminder. Runs in the executor."""
due, all_day = _as_due(item.due)
self._service().create(
list_id=self._list_id,
title=item.summary or "",
desc=item.description or "",
due_date=due,
all_day=all_day,
)
def _update(self, item: TodoItem) -> None:
"""Apply an update. Runs in the executor."""
service = self._service()
reminder = service.get(item.uid)
if reminder.title == UNDECODED_TITLE:
# Writing this back would destroy the reminder's real title and
# notes, so refuse rather than silently corrupting the reminder.
raise HomeAssistantError(
"This reminder's title could not be decrypted, so it cannot be "
"updated. Approve access on one of your Apple devices."
)
if item.summary is not None:
reminder.title = item.summary
reminder.desc = item.description or ""
reminder.due_date, reminder.all_day = _as_due(item.due)
if item.status is not None:
reminder.completed = item.status == TodoItemStatus.COMPLETED
service.update(reminder)
def _delete(self, uids: list[str]) -> None:
"""Delete reminders. Runs in the executor."""
service = self._service()
for uid in uids:
service.delete(service.get(uid))
async def _async_call(self, func, *args, **kwargs) -> None:
"""Run a blocking call and refresh the list afterwards."""
try:
await self.hass.async_add_executor_job(lambda: func(*args, **kwargs))
except REMINDERS_ERRORS as err:
raise HomeAssistantError(f"Error updating reminders: {err}") from err
finally:
# A batch that failed part of the way through still changed things
# in iCloud, so refresh either way rather than showing reminders
# that are already gone until the next poll.
await self.coordinator.async_request_refresh()
def _as_due(due: date | datetime | None) -> tuple[datetime | None, bool]:
"""Return the due value as ``(datetime, all_day)``.
``TodoItem.due`` is a plain ``date`` for an all-day item, but pyicloud
reads ``tzinfo`` and ``timestamp()`` off whatever it is given, so a date
has to be widened before it reaches the library.
"""
if due is None:
return None, False
if isinstance(due, datetime):
return due, False
return datetime(due.year, due.month, due.day), True
def _ordered(reminders: list[IcloudReminder]) -> list[IcloudReminder]:
"""Return the reminders with each subtask placed after its parent.
Home Assistant renders a flat list in the order given, and iCloud does not
guarantee that a subtask follows the reminder it belongs to, so putting it
there keeps the list reading the way Reminders shows it on iOS. Nesting can
be deeper than one level, so descendants are emitted recursively.
"""
uids = {reminder.uid for reminder in reminders}
children: dict[str, list[IcloudReminder]] = {}
roots: list[IcloudReminder] = []
for reminder in reminders:
# A parent outside this list is no anchor to sort against, so treat the
# subtask as a root here rather than dropping it. Its parent may well
# be a reminder in another list.
if reminder.parent_uid is not None and reminder.parent_uid in uids:
children.setdefault(reminder.parent_uid, []).append(reminder)
else:
roots.append(reminder)
ordered: list[IcloudReminder] = []
def _emit(reminder: IcloudReminder) -> None:
ordered.append(reminder)
for child in children.pop(reminder.uid, ()):
_emit(child)
for reminder in roots:
_emit(reminder)
# Anything still here is part of a parent cycle, which iCloud should never
# return. Pop the whole group before emitting, so the loop terminates.
while children:
for orphan in children.pop(next(iter(children))):
_emit(orphan)
return ordered
def _as_todo_item(reminder: IcloudReminder) -> TodoItem:
"""Convert a reminder into a to-do item."""
return TodoItem(
uid=reminder.uid,
summary=reminder.summary,
description=reminder.description,
due=reminder.due,
completed=reminder.completed_at if reminder.completed else None,
status=(
TodoItemStatus.COMPLETED
if reminder.completed
else TodoItemStatus.NEEDS_ACTION
),
)
@@ -0,0 +1,52 @@
# serializer version: 1
# name: test_entities[todo.test_icloud_account_groceries-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'todo',
'entity_category': None,
'entity_id': 'todo.test_icloud_account_groceries',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Groceries',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Groceries',
'platform': 'icloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <TodoListEntityFeature: 119>,
'translation_key': None,
'unique_id': 'test_account_id_list1',
'unit_of_measurement': None,
})
# ---
# name: test_entities[todo.test_icloud_account_groceries-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test iCloud Account Groceries',
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <TodoListEntityFeature: 119>,
}),
'context': <ANY>,
'entity_id': 'todo.test_icloud_account_groceries',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
+6 -6
View File
@@ -10,7 +10,7 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.calendar import DOMAIN as CALENDAR_DOMAIN
from homeassistant.components.icloud.coordinator import SCAN_INTERVAL
from homeassistant.components.icloud.coordinator import CALENDAR_SCAN_INTERVAL
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -123,14 +123,14 @@ async def test_event_in_progress_wins(
_event(
"ev1",
"Now",
now.replace(tzinfo=None) - SCAN_INTERVAL,
now.replace(tzinfo=None) + SCAN_INTERVAL,
now.replace(tzinfo=None) - CALENDAR_SCAN_INTERVAL,
now.replace(tzinfo=None) + CALENDAR_SCAN_INTERVAL,
),
_event(
"ev2",
"Later",
now.replace(tzinfo=None) + SCAN_INTERVAL * 2,
now.replace(tzinfo=None) + SCAN_INTERVAL * 3,
now.replace(tzinfo=None) + CALENDAR_SCAN_INTERVAL * 2,
now.replace(tzinfo=None) + CALENDAR_SCAN_INTERVAL * 3,
),
]
@@ -246,7 +246,7 @@ async def test_new_calendar_added_on_later_poll(
_calendar("cal1", "Personal"),
_calendar("cal2", "Work"),
]
freezer.tick(SCAN_INTERVAL + timedelta(seconds=1))
freezer.tick(CALENDAR_SCAN_INTERVAL + timedelta(seconds=1))
async_fire_time_changed(hass)
# The scheduled refresh runs as a background task of the config entry.
await hass.async_block_till_done(wait_background_tasks=True)
+573
View File
@@ -0,0 +1,573 @@
"""Tests for the iCloud to-do platform."""
from datetime import date, datetime
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from pyicloud.services.reminders.client import RemindersApiError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.icloud.coordinator import (
REMINDERS_SCAN_INTERVAL,
UNDECODED_TITLE,
)
from homeassistant.components.todo import (
ATTR_DESCRIPTION,
ATTR_DUE_DATE,
ATTR_DUE_DATETIME,
ATTR_ITEM,
ATTR_RENAME,
ATTR_STATUS,
DOMAIN as TODO_DOMAIN,
TodoServices,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from tests.common import (
AsyncMock,
MockConfigEntry,
async_fire_time_changed,
snapshot_platform,
)
ENTITY_ID = "todo.test_icloud_account_groceries"
def _reminder(
uid: str,
title: str,
*,
completed: bool = False,
parent: str | None = None,
due: datetime | None = None,
desc: str = "",
) -> MagicMock:
"""Build a mock pyicloud reminder."""
reminder = MagicMock()
reminder.id = uid
reminder.title = title
reminder.desc = desc
reminder.due_date = due
reminder.all_day = False
reminder.completed = completed
reminder.completed_date = datetime(2024, 1, 2, 3, 4) if completed else None
reminder.parent_reminder_id = parent
reminder.deleted = False
return reminder
def _list(list_id: str, title: str, *, is_group: bool = False) -> MagicMock:
"""Build a mock pyicloud reminder list."""
reminder_list = MagicMock()
reminder_list.id = list_id
reminder_list.title = title
reminder_list.is_group = is_group
reminder_list.deleted = False
return reminder_list
@pytest.fixture(name="reminders")
def mock_reminders(icloud_client: AsyncMock) -> MagicMock:
"""Mock the reminders service with one list."""
service = icloud_client.api.reminders
service.lists.return_value = [_list("list1", "Groceries")]
service.list_reminders.return_value = MagicMock(
reminders=[
_reminder("r1", "Milk"),
_reminder("r2", "Bread", completed=True),
]
)
return service
async def _setup(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Set up the config entry with only the to-do platform loaded."""
config_entry.add_to_hass(hass)
with patch("homeassistant.components.icloud.PLATFORMS", [Platform.TODO]):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
async def test_entities(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test that a reminder list becomes a to-do entity with its items."""
await _setup(hass, config_entry)
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
async def test_groups_skipped(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test that reminder groups do not become entities.
A group and a list inside it can share a name, which would otherwise
create two identically named entities, one of them always empty.
"""
reminders.lists.return_value = [
_list("list1", "Groceries"),
_list("list2", "Family", is_group=True),
]
await _setup(hass, config_entry)
assert hass.states.get(ENTITY_ID) is not None
assert hass.states.get("todo.test_icloud_account_family") is None
async def test_subtasks_follow_their_parent(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test that a subtask is ordered directly after its parent."""
reminders.list_reminders.return_value = MagicMock(
reminders=[
_reminder("r1", "Child", parent="r2"),
_reminder("r2", "Parent"),
_reminder("r3", "Other"),
]
)
await _setup(hass, config_entry)
result = await hass.services.async_call(
TODO_DOMAIN,
TodoServices.GET_ITEMS,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
return_response=True,
)
summaries = [item["summary"] for item in result[ENTITY_ID]["items"]]
assert summaries == ["Parent", "Child", "Other"]
async def test_create_item(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test creating a reminder."""
await _setup(hass, config_entry)
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.ADD_ITEM,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_ITEM: "Eggs",
ATTR_DUE_DATE: date(2024, 5, 1),
ATTR_DESCRIPTION: "a dozen",
},
blocking=True,
)
reminders.create.assert_called_once_with(
list_id="list1",
title="Eggs",
desc="a dozen",
due_date=datetime(2024, 5, 1),
all_day=True,
)
async def test_update_item(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test renaming and completing a reminder."""
existing = _reminder("r1", "Milk")
reminders.get.return_value = existing
await _setup(hass, config_entry)
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_ITEM: "Milk",
ATTR_RENAME: "Oat milk",
ATTR_STATUS: "completed",
},
blocking=True,
)
assert existing.title == "Oat milk"
assert existing.completed is True
reminders.update.assert_called_once_with(existing)
async def test_delete_item(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test deleting a reminder."""
existing = _reminder("r1", "Milk")
reminders.get.return_value = existing
await _setup(hass, config_entry)
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.REMOVE_ITEM,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: "Milk"},
blocking=True,
)
reminders.delete.assert_called_once_with(existing)
async def test_partial_delete_still_refreshes(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test that a batch failing halfway still refreshes the list.
The reminders deleted before the failure are already gone in iCloud, so
leaving them on screen until the next poll would be wrong.
"""
reminders.list_reminders.return_value = MagicMock(
reminders=[_reminder("r1", "Milk"), _reminder("r2", "Eggs")]
)
reminders.delete.side_effect = [None, RemindersApiError("boom")]
await _setup(hass, config_entry)
before = reminders.list_reminders.call_count
with pytest.raises(HomeAssistantError, match="Error updating reminders"):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.REMOVE_ITEM,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: ["Milk", "Eggs"]},
blocking=True,
)
await hass.async_block_till_done()
assert reminders.delete.call_count == 2
assert reminders.list_reminders.call_count > before
async def test_api_error_raises(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test that an iCloud error surfaces as a Home Assistant error."""
reminders.get.side_effect = RemindersApiError("boom")
await _setup(hass, config_entry)
with pytest.raises(HomeAssistantError, match="Error updating reminders"):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.REMOVE_ITEM,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: "Milk"},
blocking=True,
)
async def test_new_list_added_on_later_poll(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that a list created after setup appears on a later refresh."""
await _setup(hass, config_entry)
assert hass.states.get("todo.test_icloud_account_travel") is None
reminders.lists.return_value = [
_list("list1", "Groceries"),
_list("list2", "Travel"),
]
freezer.tick(REMINDERS_SCAN_INTERVAL)
async_fire_time_changed(hass)
# The scheduled refresh runs as a background task of the config entry.
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get("todo.test_icloud_account_travel") is not None
async def test_create_item_with_date_due(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test that a date-only due value is widened to a datetime.
pyicloud reads tzinfo and timestamp() off the value it is given, so a
plain date would raise AttributeError before reaching iCloud.
"""
await _setup(hass, config_entry)
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.ADD_ITEM,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_ITEM: "Eggs",
ATTR_DUE_DATE: date(2024, 5, 1),
},
blocking=True,
)
assert reminders.create.call_args.kwargs["due_date"] == datetime(2024, 5, 1)
assert reminders.create.call_args.kwargs["all_day"] is True
async def test_update_item_with_datetime_due(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test that a timed due value clears the all-day flag."""
existing = _reminder("r1", "Milk")
existing.all_day = True
reminders.get.return_value = existing
await _setup(hass, config_entry)
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_ITEM: "Milk",
ATTR_DUE_DATETIME: datetime(2024, 5, 1, 9, 30),
},
blocking=True,
)
assert existing.all_day is False
# Home Assistant hands the platform a timezone-aware value.
assert existing.due_date.tzinfo is not None
assert existing.due_date.replace(tzinfo=None) == datetime(2024, 5, 1, 9, 30)
async def test_nested_subtasks_follow_their_parent(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test that a grandchild stays with its parent rather than sorting last."""
reminders.list_reminders.return_value = MagicMock(
reminders=[
_reminder("r1", "Grandchild", parent="r2"),
_reminder("r2", "Child", parent="r3"),
_reminder("r3", "Parent"),
_reminder("r4", "Other"),
]
)
await _setup(hass, config_entry)
result = await hass.services.async_call(
TODO_DOMAIN,
TodoServices.GET_ITEMS,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
return_response=True,
)
summaries = [item["summary"] for item in result[ENTITY_ID]["items"]]
assert summaries == ["Parent", "Child", "Grandchild", "Other"]
async def test_orphaned_subtask_is_kept(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test that a subtask whose parent is absent is still listed.
The parent may live in another list, so the subtask has no anchor here.
It must still appear, and must not spin the ordering loop.
"""
reminders.list_reminders.return_value = MagicMock(
reminders=[
_reminder("r1", "Top level"),
_reminder("r2", "Orphan", parent="missing"),
_reminder("r3", "Orphan child", parent="r2"),
]
)
await _setup(hass, config_entry)
result = await hass.services.async_call(
TODO_DOMAIN,
TodoServices.GET_ITEMS,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
return_response=True,
)
summaries = [item["summary"] for item in result[ENTITY_ID]["items"]]
assert summaries == ["Top level", "Orphan", "Orphan child"]
async def test_orphan_parent_returned_after_its_child(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reminders: MagicMock,
) -> None:
"""Test that an orphan still comes before its own child.
iCloud gives no ordering guarantee, so the child of a subtask whose parent
is missing can arrive first. The subtask is the root of what is present.
"""
reminders.list_reminders.return_value = MagicMock(
reminders=[
_reminder("r1", "Grandchild", parent="r2"),
_reminder("r2", "Child", parent="missing"),
]
)
await _setup(hass, config_entry)
result = await hass.services.async_call(
TODO_DOMAIN,
TodoServices.GET_ITEMS,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
return_response=True,
)
summaries = [item["summary"] for item in result[ENTITY_ID]["items"]]
assert summaries == ["Child", "Grandchild"]
async def test_update_refused_when_title_undecrypted(
hass: HomeAssistant,
config_entry: MagicMock,
reminders: MagicMock,
) -> None:
"""Test that a reminder with an undecryptable title is not written back.
pyicloud's update() rewrites TitleDocument and NotesDocument
unconditionally, so persisting the placeholder would destroy the real
title and blank the notes.
"""
existing = _reminder("r1", UNDECODED_TITLE)
reminders.get.return_value = existing
await _setup(hass, config_entry)
with pytest.raises(HomeAssistantError, match="could not be decrypted"):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_ITEM: "Milk",
ATTR_STATUS: "completed",
},
blocking=True,
)
reminders.update.assert_not_called()
async def test_missing_reminder_raises_service_error(
hass: HomeAssistant,
config_entry: MagicMock,
reminders: MagicMock,
) -> None:
"""Test that a reminder deleted elsewhere surfaces as a service error."""
reminders.get.side_effect = LookupError("Reminder not found: r1")
await _setup(hass, config_entry)
with pytest.raises(HomeAssistantError, match="Error updating reminders"):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.REMOVE_ITEM,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: "Milk"},
blocking=True,
)
async def test_undecryptable_reminders_are_left_out(
hass: HomeAssistant,
config_entry: MagicMock,
reminders: MagicMock,
) -> None:
"""Test that reminders which cannot be decrypted are skipped.
On an Advanced Data Protection account pyicloud returns a placeholder
title and no notes, which is worse than useless in a to-do list, and
writing such a reminder back would destroy its real content.
"""
reminders.list_reminders.return_value = MagicMock(
reminders=[
_reminder("r1", "Milk"),
_reminder("r2", UNDECODED_TITLE),
]
)
await _setup(hass, config_entry)
items = await hass.services.async_call(
TODO_DOMAIN,
TodoServices.GET_ITEMS,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
return_response=True,
)
summaries = [item["summary"] for item in items[ENTITY_ID]["items"]]
assert summaries == ["Milk"]
async def test_warns_once_when_titles_undecrypted(
hass: HomeAssistant,
config_entry: MagicMock,
reminders: MagicMock,
caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that encrypted reminders are explained once, not on every poll."""
reminders.list_reminders.return_value = MagicMock(
reminders=[_reminder("r1", UNDECODED_TITLE)]
)
await _setup(hass, config_entry)
assert "could not be decrypted" in caplog.text
caplog.clear()
freezer.tick(REMINDERS_SCAN_INTERVAL)
async_fire_time_changed(hass)
# The scheduled refresh runs as a background task of the config entry.
await hass.async_block_till_done(wait_background_tasks=True)
assert "could not be decrypted" not in caplog.text
async def test_entry_loads_when_reminders_fail(
hass: HomeAssistant,
config_entry: MagicMock,
reminders: MagicMock,
) -> None:
"""Test that a Reminders outage does not stop the entry loading.
The coordinator is refreshed before the platforms are forwarded, but
without raising: the rest of the integration, and the reauth flow for a
failed login, must keep working regardless of Reminders.
"""
reminders.lists.side_effect = RemindersApiError("boom")
await _setup(hass, config_entry)
assert config_entry.state is ConfigEntryState.LOADED
assert hass.states.get(ENTITY_ID) is None