Bump caldav to 3.3.0a1 (#181600)

This commit is contained in:
Jamie Magee
2026-09-09 10:35:21 +02:00
committed by GitHub
parent e6be327f2b
commit 7be849bcfc
15 changed files with 273 additions and 339 deletions
+16 -12
View File
@@ -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
+15 -59
View File
@@ -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
+44 -36
View File
@@ -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
+15 -11
View File
@@ -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:
-7
View File
@@ -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")
+27 -18
View File
@@ -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
@@ -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"]
}
+50 -20
View File
@@ -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))
+1 -1
View File
@@ -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
-1
View File
@@ -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
+9 -6
View File
@@ -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
+30 -91
View File
@@ -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
+26 -15
View File
@@ -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"],
{
+23 -48
View File
@@ -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
+16 -13
View File
@@ -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 = [