mirror of
https://github.com/home-assistant/core.git
synced 2026-09-14 04:30:02 +01:00
Bump caldav to 3.3.0a1 (#181600)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user