diff --git a/homeassistant/components/caldav/__init__.py b/homeassistant/components/caldav/__init__.py index 184545e06d86..f88b8fa65c9c 100644 --- a/homeassistant/components/caldav/__init__.py +++ b/homeassistant/components/caldav/__init__.py @@ -1,10 +1,11 @@ """The caldav component.""" +from functools import partial import logging -import caldav +from caldav.davclient import DAVClient from caldav.lib.error import AuthorizationError, DAVError -import requests +from caldav.lib.http_sync import requests as caldav_requests from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -19,7 +20,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import TIMEOUT -type CalDavConfigEntry = ConfigEntry[caldav.DAVClient] +type CalDavConfigEntry = ConfigEntry[DAVClient] _LOGGER = logging.getLogger(__name__) @@ -29,15 +30,18 @@ PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO] async def async_setup_entry(hass: HomeAssistant, entry: CalDavConfigEntry) -> bool: """Set up CalDAV from a config entry.""" - client = caldav.DAVClient( - entry.data[CONF_URL], - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - ssl_verify_cert=entry.data[CONF_VERIFY_SSL], - timeout=TIMEOUT, + client = await hass.async_add_executor_job( + partial( + DAVClient, + entry.data[CONF_URL], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ssl_verify_cert=entry.data[CONF_VERIFY_SSL], + timeout=TIMEOUT, + ) ) try: - await hass.async_add_executor_job(client.principal) + await hass.async_add_executor_job(client.get_principal) except AuthorizationError as err: if err.reason == "Unauthorized": raise ConfigEntryAuthFailed("Credentials error from CalDAV server") from err @@ -45,9 +49,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: CalDavConfigEntry) -> bo # on some other unexpected server response. _LOGGER.warning("Unexpected CalDAV server response: %s", err) return False - except requests.Timeout as err: + except caldav_requests.exceptions.Timeout as err: raise ConfigEntryNotReady("Timeout connecting to CalDAV server") from err - except requests.ConnectionError as err: + except caldav_requests.exceptions.ConnectionError as err: raise ConfigEntryNotReady("Connection error from CalDAV server") from err except DAVError as err: raise ConfigEntryNotReady("CalDAV client error") from err diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index 0f0b5a60e409..2471ae012eb4 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -1,75 +1,31 @@ """Library for working with CalDAV api.""" -import logging +from typing import cast -import caldav -from caldav.lib.error import DAVError +from caldav.calendarobjectresource import CalendarObjectResource +from caldav.collection import Calendar +from caldav.davclient import DAVClient from homeassistant.core import HomeAssistant -from .const import WARNED_CALENDARS - -_LOGGER = logging.getLogger(__name__) - -ASSUMED_COMPONENTS = frozenset({"VEVENT", "VTODO"}) - async def async_get_calendars( - hass: HomeAssistant, client: caldav.DAVClient, component: str -) -> list[caldav.Calendar]: + hass: HomeAssistant, client: DAVClient, component: str +) -> list[tuple[Calendar, str | None]]: """Get all calendars that support the specified component.""" - def _get_calendars() -> tuple[ - list[caldav.Calendar], list[tuple[str, str | None, str]] - ]: - calendars = [] - needs_warning: list[tuple[str, str | None, str]] = [] - for calendar in client.principal().calendars(): - try: - supported_components = calendar.get_supported_components() - except KeyError, DAVError: - needs_warning.append((str(calendar.url), calendar.name, component)) + def _get_calendars() -> list[tuple[Calendar, str | None]]: + calendars = cast(list[Calendar], client.get_principal().get_calendars()) + return [ + (calendar, cast(str | None, calendar.get_display_name())) + for calendar in calendars + if component in cast(list[str], calendar.get_supported_components()) + ] - if component in ASSUMED_COMPONENTS: - # If the server does not specify supported components, we assume - # the calendar is supported for the requested component. - supported_components = [component] - else: - supported_components = [] - - if component in supported_components: - calendars.append(calendar) - return calendars, needs_warning - - calendars, needs_warning = await hass.async_add_executor_job(_get_calendars) - - if needs_warning: - warned_calendars = hass.data.setdefault(WARNED_CALENDARS, set()) - for url, name, comp in needs_warning: - # This workaround and warning can be removed when we upgrade to caldav 3.0 - if (url, comp) not in warned_calendars: - warned_calendars.add((url, comp)) - if comp in ASSUMED_COMPONENTS: - _LOGGER.warning( - "CalDAV server does not report supported" - " components for calendar %s, " - "assuming it supports the requested component '%s'", - name or url, - comp, - ) - else: - _LOGGER.warning( - "CalDAV server does not report supported" - " components for calendar %s. " - "Not assuming support for requested component '%s'", - name or url, - comp, - ) - - return calendars + return await hass.async_add_executor_job(_get_calendars) -def get_attr_value(obj: caldav.CalendarObjectResource, attribute: str) -> str | None: +def get_attr_value(obj: CalendarObjectResource, attribute: str) -> str | None: """Return the value of the CalDav object attribute if defined.""" if hasattr(obj, attribute): return getattr(obj, attribute).value diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 791dbf568742..78ed4401782e 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -5,9 +5,9 @@ from functools import partial import logging from typing import Any, override -import caldav +from caldav.davclient import DAVClient from caldav.lib.error import DAVError -import requests +from caldav.lib.http_sync import requests as caldav_requests import voluptuous as vol from homeassistant.components.calendar import ( @@ -91,30 +91,33 @@ async def async_setup_platform( password = config.get(CONF_PASSWORD) days = config[CONF_DAYS] - client = caldav.DAVClient( - url, - None, - username, - password, - ssl_verify_cert=config[CONF_VERIFY_SSL], - timeout=TIMEOUT, + client = await hass.async_add_executor_job( + partial( + DAVClient, + url, + None, + username, + password, + ssl_verify_cert=config[CONF_VERIFY_SSL], + timeout=TIMEOUT, + ) ) calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT) entities = [] device_id: str | None - for calendar in list(calendars): + for calendar, calendar_name in calendars: # If a calendar name was given in the configuration, # ignore all the others - if config[CONF_CALENDARS] and calendar.name not in config[CONF_CALENDARS]: - _LOGGER.debug("Ignoring calendar '%s'", calendar.name) + if config[CONF_CALENDARS] and calendar_name not in config[CONF_CALENDARS]: + _LOGGER.debug("Ignoring calendar '%s'", calendar_name) continue # Create additional calendars based on custom filtering rules for cust_calendar in config[CONF_CUSTOM_CALENDARS]: # Check that the base calendar matches - if cust_calendar[CONF_CALENDAR] != calendar.name: + if cust_calendar[CONF_CALENDAR] != calendar_name: continue name = cust_calendar[CONF_NAME] @@ -124,6 +127,7 @@ async def async_setup_platform( hass, None, calendar=calendar, + calendar_name=calendar_name, days=days, include_all_day=True, search=cust_calendar[CONF_SEARCH], @@ -135,13 +139,14 @@ async def async_setup_platform( # Create a default calendar if there was no custom one for all calendars # that support events. if not config[CONF_CUSTOM_CALENDARS]: - name = calendar.name - device_id = calendar.name + name = calendar_name + device_id = calendar_name entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) coordinator = CalDavUpdateCoordinator( hass, None, calendar=calendar, + calendar_name=calendar_name, days=days, include_all_day=False, search=None, @@ -160,26 +165,25 @@ async def async_setup_entry( ) -> None: """Set up the CalDav calendar platform for a config entry.""" calendars = await async_get_calendars(hass, entry.runtime_data, SUPPORTED_COMPONENT) - async_add_entities( - ( - WebDavCalendarEntity( - calendar.name, - async_generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass), - CalDavUpdateCoordinator( - hass, - entry, - calendar=calendar, - days=CONFIG_ENTRY_DEFAULT_DAYS, - include_all_day=True, - search=None, - ), - unique_id=f"{entry.entry_id}-{calendar.id}", - ) - for calendar in calendars - if calendar.name - ), - True, - ) + entities = [ + WebDavCalendarEntity( + calendar_name, + async_generate_entity_id(ENTITY_ID_FORMAT, calendar_name, hass=hass), + CalDavUpdateCoordinator( + hass, + entry, + calendar=calendar, + calendar_name=calendar_name, + days=CONFIG_ENTRY_DEFAULT_DAYS, + include_all_day=True, + search=None, + ), + unique_id=f"{entry.entry_id}-{calendar.id}", + ) + for calendar, calendar_name in calendars + if calendar_name + ] + async_add_entities(entities, True) class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarEntity): @@ -240,7 +244,11 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE await self.hass.async_add_executor_job( partial(self.coordinator.calendar.add_event, **item_data), ) - except (requests.ConnectionError, requests.Timeout, DAVError) as err: + except ( + caldav_requests.exceptions.ConnectionError, + caldav_requests.exceptions.Timeout, + DAVError, + ) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @callback diff --git a/homeassistant/components/caldav/config_flow.py b/homeassistant/components/caldav/config_flow.py index de8d2d2f757b..5df6cc08e4fa 100644 --- a/homeassistant/components/caldav/config_flow.py +++ b/homeassistant/components/caldav/config_flow.py @@ -1,12 +1,13 @@ """Configuration flow for CalDav.""" from collections.abc import Mapping +from functools import partial import logging from typing import Any, override -import caldav +from caldav.davclient import DAVClient from caldav.lib.error import AuthorizationError, DAVError -import requests +from caldav.lib.http_sync import requests as caldav_requests import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -61,15 +62,18 @@ class CalDavConfigFlow(ConfigFlow, domain=DOMAIN): async def _test_connection(self, user_input: dict[str, Any]) -> str | None: """Test the connection to the CalDAV server and return an error if any.""" - client = caldav.DAVClient( - user_input[CONF_URL], - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - ssl_verify_cert=user_input[CONF_VERIFY_SSL], - timeout=TIMEOUT, + client = await self.hass.async_add_executor_job( + partial( + DAVClient, + user_input[CONF_URL], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ssl_verify_cert=user_input[CONF_VERIFY_SSL], + timeout=TIMEOUT, + ) ) try: - await self.hass.async_add_executor_job(client.principal) + await self.hass.async_add_executor_job(client.get_principal) except AuthorizationError as err: _LOGGER.warning("Authorization Error connecting to CalDAV server: %s", err) if err.reason == "Unauthorized": @@ -77,10 +81,10 @@ class CalDavConfigFlow(ConfigFlow, domain=DOMAIN): # AuthorizationError can be raised if the url is incorrect or # on some other unexpected server response. return "cannot_connect" - except requests.Timeout as err: + except caldav_requests.exceptions.Timeout as err: _LOGGER.warning("Timeout connecting to CalDAV server: %s", err) return "cannot_connect" - except requests.ConnectionError as err: + except caldav_requests.exceptions.ConnectionError as err: _LOGGER.warning("Connection Error connecting to CalDAV server: %s", err) return "cannot_connect" except DAVError as err: diff --git a/homeassistant/components/caldav/const.py b/homeassistant/components/caldav/const.py index 5c3b512be451..e133bb1b8bc8 100644 --- a/homeassistant/components/caldav/const.py +++ b/homeassistant/components/caldav/const.py @@ -2,12 +2,5 @@ from typing import Final -from homeassistant.util.hass_dict import HassKey - DOMAIN: Final = "caldav" TIMEOUT: Final = 30 - -# Calendars we have already warned about, keyed by (url, component). This is -# deliberately not stored on a config entry: the warning is per CalDAV server -# and must survive reloads, and the same server may back more than one entry. -WARNED_CALENDARS: HassKey[set[tuple[str, str]]] = HassKey(f"{DOMAIN}_warned_calendars") diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index 579b2116a4b7..e2ad8e1abad6 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -3,9 +3,10 @@ from datetime import date, datetime, time, timedelta import logging import re -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, cast, override -import caldav +from caldav.calendarobjectresource import CalendarObjectResource +from caldav.collection import Calendar from homeassistant.components.calendar import ( CalendarEvent, @@ -27,7 +28,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) OFFSET = "!!" -def _get_status(vevent: caldav.CalendarObjectResource) -> CalendarEventStatus | None: +def _get_status(vevent: CalendarObjectResource) -> CalendarEventStatus | None: """Return the rfc5545 STATUS of a VEVENT, if a calendar entity reports it. Anything outside the supported set is dropped rather than passed on, which @@ -52,7 +53,8 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): self, hass: HomeAssistant, entry: CalDavConfigEntry | None, - calendar: caldav.Calendar, + calendar: Calendar, + calendar_name: str | None, days: int, include_all_day: bool, search: str | None, @@ -62,10 +64,11 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): hass, _LOGGER, config_entry=entry, - name=f"CalDAV {calendar.name}", + name=f"CalDAV {calendar_name}", update_interval=MIN_TIME_BETWEEN_UPDATES, ) self.calendar = calendar + self.calendar_name = calendar_name self.days = days self.include_all_day = include_all_day self.search = search @@ -81,11 +84,14 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): self, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Fetch and parse events in a specific time frame.""" - vevent_list = self.calendar.search( - start=start_date, - end=end_date, - event=True, - expand=True, + vevent_list = cast( + list[CalendarObjectResource], + self.calendar.search( + start=start_date, + end=end_date, + event=True, + expand=True, + ), ) event_list = [] for event in vevent_list: @@ -132,18 +138,21 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): """Fetch and parse the next matching event.""" # We have to retrieve the results for the whole day as the server # won't return events that have already started - results = self.calendar.search( - start=start_of_today, - end=start_of_tomorrow, - event=True, - expand=True, + results = cast( + list[CalendarObjectResource], + self.calendar.search( + start=start_of_today, + end=start_of_tomorrow, + event=True, + expand=True, + ), ) # Create new events for each recurrence of an event that happens today. # For recurring events, some servers return the original # event with recurrence rules # and they would not be properly parsed using their original start/end dates. - new_events = [] + new_events: list[CalendarObjectResource] = [] for event in results: if not hasattr(event.vobject_instance, "vevent"): _LOGGER.warning("Skipped event with missing 'vevent' property") @@ -161,7 +170,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): _start_of_tomorrow = start_of_tomorrow if _start_of_today <= start_dt < _start_of_tomorrow: new_event = event.copy() - new_vevent = new_event.vobject_instance.vevent # type: ignore[attr-defined] + new_vevent = new_event.vobject_instance.vevent if hasattr(new_vevent, "dtend"): dur = new_vevent.dtend.value - new_vevent.dtstart.value new_vevent.dtend.value = start_dt + dur @@ -197,7 +206,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): _LOGGER.debug( "No matching event found in the %d results for %s", len(vevents), - self.calendar.name, + self.calendar_name, ) return None, None diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 794754e657c5..4b29dd1111b3 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==2.1.0", "icalendar==6.3.1", "vobject==0.9.9"] + "requirements": ["caldav==3.3.0a1", "icalendar==6.3.1", "vobject==0.9.9"] } diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index 6a88445e8a54..58fa1c06aa5c 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -6,9 +6,10 @@ from functools import partial import logging from typing import Any, cast, override -import caldav +from caldav.calendarobjectresource import CalendarObjectResource, Todo +from caldav.collection import Calendar from caldav.lib.error import DAVError, NotFoundError -import requests +from caldav.lib.http_sync import requests as caldav_requests from homeassistant.components.todo import ( TodoItem, @@ -52,17 +53,21 @@ async def async_setup_entry( ( WebDavTodoListEntity( calendar, + calendar_name, entry.entry_id, ) - for calendar in calendars + for calendar, calendar_name in calendars ), True, ) -def _get_todo_items(calendar: caldav.Calendar) -> list[TodoItem]: +def _get_todo_items(calendar: Calendar) -> list[TodoItem]: """Fetch and parse todo items.""" - results = calendar.search(todo=True, include_completed=True) + results = cast( + list[CalendarObjectResource], + calendar.search(todo=True, include_completed=True), + ) return [ todo_item for resource in results @@ -70,7 +75,7 @@ def _get_todo_items(calendar: caldav.Calendar) -> list[TodoItem]: ] -def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: +def _todo_item(resource: CalendarObjectResource) -> TodoItem | None: """Convert a caldav Todo into a TodoItem.""" if ( not hasattr(resource.vobject_instance, "vtodo") @@ -110,10 +115,12 @@ class WebDavTodoListEntity(TodoListEntity): | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) - def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None: + def __init__( + self, calendar: Calendar, calendar_name: str | None, config_entry_id: str + ) -> None: """Initialize WebDavTodoListEntity.""" self._calendar = calendar - self._attr_name = (calendar.name or "Unknown").capitalize() + self._attr_name = (calendar_name or "Unknown").capitalize() self._attr_unique_id = f"{config_entry_id}-{calendar.id}" async def async_update(self) -> None: @@ -136,11 +143,15 @@ class WebDavTodoListEntity(TodoListEntity): item_data["description"] = description try: await self.hass.async_add_executor_job( - partial(self._calendar.save_todo, **item_data), + partial(self._calendar.add_todo, **item_data), ) # refreshing async otherwise it would take too much time self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) - except (requests.ConnectionError, requests.Timeout, DAVError) as err: + except ( + caldav_requests.exceptions.ConnectionError, + caldav_requests.exceptions.Timeout, + DAVError, + ) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @override @@ -148,19 +159,26 @@ class WebDavTodoListEntity(TodoListEntity): """Update a To-do item.""" uid: str = cast(str, item.uid) try: - todo = await self.hass.async_add_executor_job( - self._calendar.todo_by_uid, uid + todo = cast( + Todo, + await self.hass.async_add_executor_job( + self._calendar.get_todo_by_uid, uid + ), ) except NotFoundError as err: raise HomeAssistantError(f"Could not find To-do item {uid}") from err - except (requests.ConnectionError, requests.Timeout, DAVError) as err: + except ( + caldav_requests.exceptions.ConnectionError, + caldav_requests.exceptions.Timeout, + DAVError, + ) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err - vtodo = todo.icalendar_component # type: ignore[attr-defined] + vtodo = todo.icalendar_component vtodo["SUMMARY"] = item.summary or "" if status := item.status: vtodo["STATUS"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") if due := item.due: - todo.set_due(due) # type: ignore[attr-defined] + todo.set_due(due) else: vtodo.pop("DUE", None) if description := item.description: @@ -177,29 +195,41 @@ class WebDavTodoListEntity(TodoListEntity): ) # refreshing async otherwise it would take too much time self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) - except (requests.ConnectionError, requests.Timeout, DAVError) as err: + except ( + caldav_requests.exceptions.ConnectionError, + caldav_requests.exceptions.Timeout, + DAVError, + ) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @override async def async_delete_todo_items(self, uids: list[str]) -> None: """Delete To-do items.""" tasks = ( - self.hass.async_add_executor_job(self._calendar.todo_by_uid, uid) + self.hass.async_add_executor_job(self._calendar.get_todo_by_uid, uid) for uid in uids ) try: - items = await asyncio.gather(*tasks) + items = cast(list[Todo], await asyncio.gather(*tasks)) except NotFoundError as err: raise HomeAssistantError("Could not find To-do item") from err - except (requests.ConnectionError, requests.Timeout, DAVError) as err: + except ( + caldav_requests.exceptions.ConnectionError, + caldav_requests.exceptions.Timeout, + DAVError, + ) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err # Run serially as some CalDAV servers do not support concurrent modifications for item in items: try: await self.hass.async_add_executor_job(item.delete) - except (requests.ConnectionError, requests.Timeout, DAVError) as err: + except ( + caldav_requests.exceptions.ConnectionError, + caldav_requests.exceptions.Timeout, + DAVError, + ) as err: raise HomeAssistantError(f"CalDAV delete error: {err}") from err # refreshing async otherwise it would take too much time self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) diff --git a/requirements_all.txt b/requirements_all.txt index 8d0bd592d44a..1b42a04f6282 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -755,7 +755,7 @@ buienradar==1.0.9 cached-ipaddress==1.1.2 # homeassistant.components.caldav -caldav==2.1.0 +caldav==3.3.0a1 # homeassistant.components.chef_iq chefiq-ble==1.0.1 diff --git a/requirements_test.txt b/requirements_test.txt index 9b0e873c9481..f6521495b748 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -43,7 +43,6 @@ tqdm==4.67.1 types-aiofiles==25.1.0.20260518 types-atomicwrites==1.4.5.1 types-croniter==6.2.4.20260711 -types-caldav==1.3.0.20250516 types-chardet==0.1.5 types-decorator==5.2.0.20260712 types-pexpect==4.9.0.20260518 diff --git a/tests/components/caldav/conftest.py b/tests/components/caldav/conftest.py index 686a0b75a763..ba0dbdd6ca26 100644 --- a/tests/components/caldav/conftest.py +++ b/tests/components/caldav/conftest.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch +from caldav.lib.url import URL import pytest from homeassistant.components.caldav.const import DOMAIN @@ -42,12 +43,14 @@ def mock_calendars() -> list[Mock]: @pytest.fixture(name="dav_client", autouse=True) def mock_dav_client(calendars: list[Mock]) -> Mock: """Fixture to mock the DAVClient.""" - with patch( - "homeassistant.components.caldav.calendar.caldav.DAVClient" - ) as mock_client: - mock_client.return_value.principal.return_value.calendars.return_value = ( - calendars - ) + with ( + patch("homeassistant.components.caldav.DAVClient") as mock_client, + patch("homeassistant.components.caldav.calendar.DAVClient", mock_client), + patch("homeassistant.components.caldav.config_flow.DAVClient", mock_client), + ): + mock_client.url = URL(TEST_URL) + mock_client.return_value.url = URL(TEST_URL) + mock_client.return_value.get_principal.return_value.get_calendars.return_value = calendars yield mock_client diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 2df5e5ea5310..1f6b26367842 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -1,15 +1,15 @@ """The tests for the webdav calendar component.""" +import asyncio from collections.abc import Awaitable, Callable import datetime from http import HTTPStatus -import logging from typing import Any from unittest.mock import MagicMock, Mock, patch import zoneinfo -from caldav.lib.error import NotFoundError -from caldav.objects import Event +from caldav.calendarobjectresource import Event +from caldav.lib.url import URL from freezegun.api import FrozenDateTimeFactory import pytest @@ -370,13 +370,15 @@ def _local_datetime(hours: int, minutes: int) -> datetime.datetime: def _mock_calendar(name: str, supported_components: list[str] | None = None) -> Mock: calendar = Mock() + calendar.client = None + calendar.url = URL("http://test.local/calendar/") events = [] for idx, event in enumerate(EVENTS): events.append(Event(None, f"{idx}.ics", event, calendar, str(idx))) if supported_components is None: supported_components = ["VEVENT"] calendar.search = MagicMock(return_value=events) - calendar.name = name + calendar.get_display_name = MagicMock(return_value=name) calendar.get_supported_components = MagicMock(return_value=supported_components) return calendar @@ -394,16 +396,16 @@ async def _get_api_events_for_vevent( that other tests count on. """ calendar = Mock() - calendar.name = "Example" + calendar.client = None + calendar.url = URL("http://test.local/calendar/") + calendar.get_display_name = MagicMock(return_value="Example") calendar.get_supported_components = MagicMock(return_value=["VEVENT"]) calendar.search = MagicMock( return_value=[Event(None, "0.ics", vevent, calendar, uid)] ) - with patch( - "homeassistant.components.caldav.calendar.caldav.DAVClient" - ) as mock_client: - mock_client.return_value.principal.return_value.calendars.return_value = [ + with patch("homeassistant.components.caldav.calendar.DAVClient") as mock_client: + mock_client.return_value.get_principal.return_value.get_calendars.return_value = [ calendar ] assert await async_setup_component( @@ -1223,6 +1225,25 @@ async def test_calendar_components(hass: HomeAssistant) -> None: assert not state +async def test_calendar_name_resolved_in_executor( + hass: HomeAssistant, calendars: list[Mock] +) -> None: + """Test the calendar display name is resolved outside the event loop.""" + + def _get_display_name() -> str: + with pytest.raises(RuntimeError, match="no running event loop"): + asyncio.get_running_loop() + return CALENDAR_NAME + + calendars[0].get_display_name.side_effect = _get_display_name + client = MagicMock() + client.get_principal().get_calendars.return_value = calendars + + assert await async_get_calendars(hass, client, "VEVENT") == [ + (calendars[0], CALENDAR_NAME) + ] + + @pytest.mark.parametrize("tz", [UTC]) @pytest.mark.freeze_time(_local_datetime(17, 30)) async def test_setup_config_entry( @@ -1384,85 +1405,3 @@ async def test_add_vevent( calendars[0].add_event.assert_called_once() assert calendars[0].add_event.call_args assert calendars[0].add_event.call_args[1] == expected_ics_fields - - -@pytest.mark.parametrize( - "exception", - [ - pytest.param(KeyError(), id="key_error"), - pytest.param(NotFoundError(), id="not_found_error"), - ], -) -async def test_missing_supported_components( - hass: HomeAssistant, - calendars: list[Mock], - setup_platform_cb: Callable[[], Awaitable[None]], - caplog: pytest.LogCaptureFixture, - exception: Exception, -) -> None: - """Test setup works when calendar raises on get_supported_components.""" - caplog.set_level(logging.WARNING, logger="homeassistant.components.caldav.api") - calendars[0].get_supported_components.side_effect = exception - await setup_platform_cb() - - assert hass.states.get(TEST_ENTITY) - - warning_msg = ( - "CalDAV server does not report supported components for calendar Example, " - "assuming it supports the requested component 'VEVENT'" - ) - assert warning_msg in caplog.text - - # Clear caplog and call async_get_calendars again to verify - # warning is not logged again - caplog.clear() - client = MagicMock() - client.principal().calendars.return_value = calendars - - await async_get_calendars(hass, client, "VEVENT") - assert warning_msg not in caplog.text - - # Verify that querying a *different* component for the same - # calendar DOES log the warning again because de-duplication - # is keyed by (url, component). - vjournal_warning = ( - "CalDAV server does not report supported components for calendar Example. " - "Not assuming support for requested component 'VJOURNAL'" - ) - await async_get_calendars(hass, client, "VJOURNAL") - assert vjournal_warning in caplog.text - - -@pytest.mark.parametrize( - "exception", - [ - pytest.param(KeyError(), id="key_error"), - pytest.param(NotFoundError(), id="not_found_error"), - ], -) -async def test_missing_supported_components_not_assumed( - hass: HomeAssistant, - calendars: list[Mock], - caplog: pytest.LogCaptureFixture, - exception: Exception, -) -> None: - """Test get_calendars excludes calendars when components unavailable.""" - caplog.set_level(logging.WARNING, logger="homeassistant.components.caldav.api") - calendars[0].get_supported_components.side_effect = exception - client = MagicMock() - client.principal().calendars.return_value = calendars - - returned_calendars = await async_get_calendars(hass, client, "VJOURNAL") - - assert len(returned_calendars) == 0 - warning_msg = ( - "CalDAV server does not report supported components for calendar Example. " - "Not assuming support for requested component 'VJOURNAL'" - ) - assert warning_msg in caplog.text - - # Clear caplog and call async_get_calendars again to verify - # warning is not logged again - caplog.clear() - await async_get_calendars(hass, client, "VJOURNAL") - assert warning_msg not in caplog.text diff --git a/tests/components/caldav/test_config_flow.py b/tests/components/caldav/test_config_flow.py index f3adef7b804e..d52872824eca 100644 --- a/tests/components/caldav/test_config_flow.py +++ b/tests/components/caldav/test_config_flow.py @@ -1,11 +1,12 @@ """Test the CalDAV config flow.""" from collections.abc import Generator +from functools import partial from unittest.mock import AsyncMock, Mock, patch from caldav.lib.error import AuthorizationError, DAVError +from caldav.lib.http_sync import requests as caldav_requests import pytest -import requests from homeassistant import config_entries from homeassistant.components.caldav.const import DOMAIN @@ -30,6 +31,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, + dav_client: Mock, ) -> None: """Test successful config flow setup.""" result = await hass.config_entries.flow.async_init( @@ -38,15 +40,20 @@ async def test_form( assert result.get("type") is FlowResultType.FORM assert not result.get("errors") - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_VERIFY_SSL: False, - }, - ) + with patch.object( + hass, + "async_add_executor_job", + wraps=hass.async_add_executor_job, + ) as mock_add_executor_job: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_VERIFY_SSL: False, + }, + ) await hass.async_block_till_done() assert result2.get("type") is FlowResultType.CREATE_ENTRY @@ -58,14 +65,18 @@ async def test_form( CONF_VERIFY_SSL: False, } assert len(mock_setup_entry.mock_calls) == 1 + assert any( + isinstance(call.args[0], partial) and call.args[0].func is dav_client + for call in mock_add_executor_job.call_args_list + ) @pytest.mark.parametrize( ("side_effect", "expected_error"), [ (Exception(), "unknown"), - (requests.Timeout(), "cannot_connect"), - (requests.ConnectionError(), "cannot_connect"), + (caldav_requests.exceptions.Timeout(), "cannot_connect"), + (caldav_requests.exceptions.ConnectionError(), "cannot_connect"), (DAVError(), "cannot_connect"), (AuthorizationError(reason="Unauthorized"), "invalid_auth"), (AuthorizationError(reason="Other"), "cannot_connect"), @@ -82,7 +93,7 @@ async def test_caldav_client_error( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - dav_client.return_value.principal.side_effect = side_effect + dav_client.return_value.get_principal.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -146,7 +157,7 @@ async def test_reauth_failure( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - dav_client.return_value.principal.side_effect = DAVError + dav_client.return_value.get_principal.side_effect = DAVError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -160,7 +171,7 @@ async def test_reauth_failure( assert result2.get("errors") == {"base": "cannot_connect"} # Complete the form and it succeeds this time - dav_client.return_value.principal.side_effect = None + dav_client.return_value.get_principal.side_effect = None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/caldav/test_init.py b/tests/components/caldav/test_init.py index aefe6a73ecf3..d2ad6203035a 100644 --- a/tests/components/caldav/test_init.py +++ b/tests/components/caldav/test_init.py @@ -1,14 +1,13 @@ """Unit tests for the CalDav integration.""" -import logging -from unittest.mock import MagicMock, Mock, patch +from functools import partial +from unittest.mock import patch from caldav.lib.error import AuthorizationError, DAVError +from caldav.lib.http_sync import requests as caldav_requests import pytest -import requests from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -27,10 +26,21 @@ async def test_load_unload( """Test loading and unloading of the config entry.""" assert config_entry.state is ConfigEntryState.NOT_LOADED - with patch("homeassistant.components.caldav.config_flow.caldav.DAVClient"): + with ( + patch("homeassistant.components.caldav.DAVClient") as mock_client, + patch.object( + hass, + "async_add_executor_job", + wraps=hass.async_add_executor_job, + ) as mock_add_executor_job, + ): await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state is ConfigEntryState.LOADED + assert any( + isinstance(call.args[0], partial) and call.args[0].func is mock_client + for call in mock_add_executor_job.call_args_list + ) assert await hass.config_entries.async_unload(config_entry.entry_id) assert config_entry.state is ConfigEntryState.NOT_LOADED @@ -40,8 +50,12 @@ async def test_load_unload( ("side_effect", "expected_state", "expected_flows"), [ (Exception(), ConfigEntryState.SETUP_ERROR, []), - (requests.ConnectionError(), ConfigEntryState.SETUP_RETRY, []), - (requests.Timeout(), ConfigEntryState.SETUP_RETRY, []), + ( + caldav_requests.exceptions.ConnectionError(), + ConfigEntryState.SETUP_RETRY, + [], + ), + (caldav_requests.exceptions.Timeout(), ConfigEntryState.SETUP_RETRY, []), (DAVError(), ConfigEntryState.SETUP_RETRY, []), ( AuthorizationError(reason="Unauthorized"), @@ -62,10 +76,8 @@ async def test_client_failure( assert config_entry.state is ConfigEntryState.NOT_LOADED - with patch( - "homeassistant.components.caldav.config_flow.caldav.DAVClient" - ) as mock_client: - mock_client.return_value.principal.side_effect = side_effect + with patch("homeassistant.components.caldav.DAVClient") as mock_client: + mock_client.return_value.get_principal.side_effect = side_effect await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -73,40 +85,3 @@ async def test_client_failure( flows = hass.config_entries.flow.async_progress() assert [flow.get("step_id") for flow in flows] == expected_flows - - -@pytest.fixture(name="calendars") -def mock_unsupported_calendar() -> list[Mock]: - """Fixture for a calendar that does not report its supported components.""" - calendar = Mock() - calendar.name = "Example" - calendar.search = MagicMock(return_value=[]) - calendar.get_supported_components = MagicMock(side_effect=KeyError()) - return [calendar] - - -@pytest.mark.parametrize("platforms", [[Platform.CALENDAR]]) -async def test_supported_components_warning_survives_reload( - hass: HomeAssistant, - config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the unsupported-components warning is not repeated after a reload. - - The de-duplication cache is per CalDAV server rather than per config entry, - so reloading the entry must not warn about the same calendar again. - """ - caplog.set_level(logging.WARNING, logger="homeassistant.components.caldav.api") - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - assert "does not report supported components" in caplog.text - - caplog.clear() - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - assert "does not report supported components" not in caplog.text diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 466111e9a641..ff836ea9ee4b 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -4,8 +4,9 @@ from datetime import UTC, date, datetime from typing import Any from unittest.mock import MagicMock, Mock +from caldav.calendarobjectresource import Todo from caldav.lib.error import DAVError, NotFoundError -from caldav.objects import Todo +from caldav.lib.url import URL import pytest from homeassistant.components.todo import ( @@ -121,8 +122,10 @@ def mock_supported_components() -> list[str]: def mock_calendar(supported_components: list[str]) -> Mock: """Fixture to create the primary calendar for the test.""" calendar = Mock() + calendar.client = None + calendar.url = URL("https://example.com/calendar/") calendar.search = MagicMock(return_value=[]) - calendar.name = CALENDAR_NAME + calendar.get_display_name = MagicMock(return_value=CALENDAR_NAME) calendar.get_supported_components = MagicMock(return_value=supported_components) return calendar @@ -295,8 +298,8 @@ async def test_add_item( # Wait for the fire-and-forget state refresh await hass.async_block_till_done() - assert calendar.save_todo.call_args - assert calendar.save_todo.call_args.kwargs == expcted_save_args + assert calendar.add_todo.call_args + assert calendar.add_todo.call_args.kwargs == expcted_save_args # Verify state was updated state = hass.states.get(TEST_ENTITY) @@ -312,7 +315,7 @@ async def test_add_item_failure( """Test failure when adding an item to the list.""" await hass.config_entries.async_setup(config_entry.entry_id) - calendar.save_todo.side_effect = DAVError() + calendar.add_todo.side_effect = DAVError() with pytest.raises(HomeAssistantError, match="CalDAV save error"): await hass.services.async_call( @@ -506,7 +509,7 @@ async def test_update_item( assert state assert state.state == "1" - calendar.todo_by_uid = MagicMock(return_value=item) + calendar.get_todo_by_uid = MagicMock(return_value=item) dav_client.put.return_value.status = 204 @@ -555,7 +558,7 @@ async def test_update_item_failure( await hass.config_entries.async_setup(config_entry.entry_id) - calendar.todo_by_uid = MagicMock(return_value=item) + calendar.get_todo_by_uid = MagicMock(return_value=item) dav_client.put.side_effect = DAVError() with pytest.raises(HomeAssistantError, match="CalDAV save error"): @@ -590,7 +593,7 @@ async def test_update_item_lookup_failure( await hass.config_entries.async_setup(config_entry.entry_id) - calendar.todo_by_uid.side_effect = side_effect + calendar.get_todo_by_uid.side_effect = side_effect with pytest.raises(HomeAssistantError, match=match): await hass.services.async_call( @@ -642,7 +645,7 @@ async def test_remove_item( return item1 return item2 - calendar.todo_by_uid = Mock(side_effect=lookup) + calendar.get_todo_by_uid = Mock(side_effect=lookup) item1.delete = Mock() item2.delete = Mock() @@ -676,7 +679,7 @@ async def test_remove_item_lookup_failure( await hass.config_entries.async_setup(config_entry.entry_id) - calendar.todo_by_uid.side_effect = side_effect + calendar.get_todo_by_uid.side_effect = side_effect with pytest.raises(HomeAssistantError, match=match): await hass.services.async_call( @@ -704,7 +707,7 @@ async def test_remove_item_failure( def lookup(uid: str) -> Mock: return item - calendar.todo_by_uid = Mock(side_effect=lookup) + calendar.get_todo_by_uid = Mock(side_effect=lookup) dav_client.delete.return_value.status = 500 with pytest.raises(HomeAssistantError, match="CalDAV delete error"): @@ -733,7 +736,7 @@ async def test_remove_item_not_found( def lookup(uid: str) -> Mock: return item - calendar.todo_by_uid.side_effect = NotFoundError() + calendar.get_todo_by_uid.side_effect = NotFoundError() with pytest.raises(HomeAssistantError, match="Could not find"): await hass.services.async_call( @@ -782,7 +785,7 @@ async def test_subscribe( assert items[0]["status"] == "needs_action" assert items[0]["uid"] - calendar.todo_by_uid = MagicMock(return_value=item) + calendar.get_todo_by_uid = MagicMock(return_value=item) dav_client.put.return_value.status = 204 # Reflect update for state refresh after update calendar.search.return_value = [