fix(caldav): address PR #632 review feedback

- Modernize typing: replace Dict/List/Optional with dict/list/X|None
- Add comment explaining _hacks="insist" mirrors upstream pattern
- Add comments noting caldav v3 raises PutError on HTTP failure
- Narrow except Exception to caldav_error.NotFoundError in delete methods
- Replace private _maybe_await import in tests with stdlib inspect.isawaitable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-15 22:08:42 +01:00
co-authored by Claude Opus 4.6
parent 36a664dda4
commit e24e49218e
2 changed files with 63 additions and 55 deletions
+56 -51
View File
@@ -4,7 +4,7 @@ import datetime as dt
import inspect import inspect
import logging import logging
import uuid import uuid
from typing import Any, Dict, List, Optional from typing import Any
import anyio import anyio
from caldav.aio import AsyncCalendar, AsyncDAVClient, AsyncEvent from caldav.aio import AsyncCalendar, AsyncDAVClient, AsyncEvent
@@ -78,6 +78,9 @@ class CalendarClient:
iterate the coroutine synchronously. This method properly awaits the iterate the coroutine synchronously. This method properly awaits the
search result. search result.
""" """
# _hacks="insist" mirrors upstream's Calendar.get_object_by_uid pattern:
# retries with per-component-type searches if the initial search returns
# nothing, handling CalDAV servers with incomplete search support.
items_found = await calendar.search( # type: ignore[misc] # dual-mode: returns coroutine for async clients items_found = await calendar.search( # type: ignore[misc] # dual-mode: returns coroutine for async clients
uid=uid, xml=comp_filter, post_filter=True, _hacks="insist" uid=uid, xml=comp_filter, post_filter=True, _hacks="insist"
) )
@@ -133,7 +136,7 @@ class CalendarClient:
# ============= Calendar Operations ============= # ============= Calendar Operations =============
async def list_calendars(self) -> List[Dict[str, Any]]: async def list_calendars(self) -> list[dict[str, Any]]:
"""List all available calendars for the user.""" """List all available calendars for the user."""
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color. # Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses # caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
@@ -224,7 +227,7 @@ class CalendarClient:
display_name: str = "", display_name: str = "",
description: str = "", description: str = "",
color: str = "#1976D2", color: str = "#1976D2",
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Create a new calendar with retry on 429 errors.""" """Create a new calendar with retry on 429 errors."""
# Use custom MKCALENDAR XML instead of caldav library's make_calendar() due to: # Use custom MKCALENDAR XML instead of caldav library's make_calendar() due to:
# 1. Missing CalendarServer namespace (cs:) in caldav's nsmap # 1. Missing CalendarServer namespace (cs:) in caldav's nsmap
@@ -270,7 +273,7 @@ class CalendarClient:
"status_code": 201, "status_code": 201,
} }
async def delete_calendar(self, calendar_name: str) -> Dict[str, Any]: async def delete_calendar(self, calendar_name: str) -> dict[str, Any]:
"""Delete a calendar.""" """Delete a calendar."""
# Use absolute URL for deletion # Use absolute URL for deletion
calendar_url = ( calendar_url = (
@@ -286,10 +289,10 @@ class CalendarClient:
async def get_calendar_events( async def get_calendar_events(
self, self,
calendar_name: str, calendar_name: str,
start_datetime: Optional[dt.datetime] = None, start_datetime: dt.datetime | None = None,
end_datetime: Optional[dt.datetime] = None, end_datetime: dt.datetime | None = None,
limit: int = 50, limit: int = 50,
) -> List[Dict[str, Any]]: ) -> list[dict[str, Any]]:
"""List events in a calendar within date range.""" """List events in a calendar within date range."""
calendar = self._get_calendar(calendar_name) calendar = self._get_calendar(calendar_name)
@@ -332,8 +335,8 @@ class CalendarClient:
async def _search_events_by_date( async def _search_events_by_date(
self, self,
calendar: AsyncCalendar, calendar: AsyncCalendar,
start_datetime: Optional[dt.datetime] = None, start_datetime: dt.datetime | None = None,
end_datetime: Optional[dt.datetime] = None, end_datetime: dt.datetime | None = None,
) -> list: ) -> list:
"""Execute a CalDAV REPORT with time-range filter.""" """Execute a CalDAV REPORT with time-range filter."""
# Ensure naive datetimes are treated as UTC # Ensure naive datetimes are treated as UTC
@@ -382,14 +385,15 @@ class CalendarClient:
return objects return objects
async def create_event( async def create_event(
self, calendar_name: str, event_data: Dict[str, Any] self, calendar_name: str, event_data: dict[str, Any]
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Create a new calendar event.""" """Create a new calendar event."""
calendar = self._get_calendar(calendar_name) calendar = self._get_calendar(calendar_name)
event_uid = str(uuid.uuid4()) event_uid = str(uuid.uuid4())
ical_content = self._create_ical_event(event_data, event_uid) ical_content = self._create_ical_event(event_data, event_uid)
# caldav v3's _async_put raises PutError on HTTP failure
event = await calendar.save_event(ical=ical_content) # type: ignore[misc] # dual-mode event = await calendar.save_event(ical=ical_content) # type: ignore[misc] # dual-mode
logger.debug(f"Created event {event_uid}") logger.debug(f"Created event {event_uid}")
@@ -405,9 +409,9 @@ class CalendarClient:
self, self,
calendar_name: str, calendar_name: str,
event_uid: str, event_uid: str,
event_data: Dict[str, Any], event_data: dict[str, Any],
etag: str = "", etag: str = "",
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Update an existing calendar event.""" """Update an existing calendar event."""
calendar = self._get_calendar(calendar_name) calendar = self._get_calendar(calendar_name)
@@ -431,7 +435,7 @@ class CalendarClient:
"status_code": 200, "status_code": 200,
} }
async def delete_event(self, calendar_name: str, event_uid: str) -> Dict[str, Any]: async def delete_event(self, calendar_name: str, event_uid: str) -> dict[str, Any]:
"""Delete a calendar event.""" """Delete a calendar event."""
calendar = self._get_calendar(calendar_name) calendar = self._get_calendar(calendar_name)
@@ -442,13 +446,13 @@ class CalendarClient:
await _maybe_await(event.delete()) await _maybe_await(event.delete())
logger.debug(f"Deleted event {event_uid}") logger.debug(f"Deleted event {event_uid}")
return {"status_code": 204} return {"status_code": 204}
except Exception as e: except caldav_error.NotFoundError as e:
logger.debug(f"Event {event_uid} not found: {e}") logger.debug(f"Event {event_uid} not found: {e}")
return {"status_code": 404} return {"status_code": 404}
async def get_event( async def get_event(
self, calendar_name: str, event_uid: str self, calendar_name: str, event_uid: str
) -> tuple[Dict[str, Any], str]: ) -> tuple[dict[str, Any], str]:
"""Get detailed information about a specific event.""" """Get detailed information about a specific event."""
calendar = self._get_calendar(calendar_name) calendar = self._get_calendar(calendar_name)
@@ -469,10 +473,10 @@ class CalendarClient:
async def search_events_across_calendars( async def search_events_across_calendars(
self, self,
start_datetime: Optional[dt.datetime] = None, start_datetime: dt.datetime | None = None,
end_datetime: Optional[dt.datetime] = None, end_datetime: dt.datetime | None = None,
filters: Optional[Dict[str, Any]] = None, filters: dict[str, Any] | None = None,
) -> List[Dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Search events across all calendars with advanced filtering.""" """Search events across all calendars with advanced filtering."""
try: try:
calendars = await self.list_calendars() calendars = await self.list_calendars()
@@ -511,8 +515,8 @@ class CalendarClient:
# ============= Todo/Task Operations (NEW) ============= # ============= Todo/Task Operations (NEW) =============
async def list_todos( async def list_todos(
self, calendar_name: str, filters: Optional[Dict[str, Any]] = None self, calendar_name: str, filters: dict[str, Any] | None = None
) -> List[Dict[str, Any]]: ) -> list[dict[str, Any]]:
"""List todos/tasks in a calendar.""" """List todos/tasks in a calendar."""
calendar = self._get_calendar(calendar_name) calendar = self._get_calendar(calendar_name)
@@ -540,14 +544,15 @@ class CalendarClient:
return result return result
async def create_todo( async def create_todo(
self, calendar_name: str, todo_data: Dict[str, Any] self, calendar_name: str, todo_data: dict[str, Any]
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Create a new todo/task.""" """Create a new todo/task."""
calendar = self._get_calendar(calendar_name) calendar = self._get_calendar(calendar_name)
todo_uid = str(uuid.uuid4()) todo_uid = str(uuid.uuid4())
ical_content = self._create_ical_todo(todo_data, todo_uid) ical_content = self._create_ical_todo(todo_data, todo_uid)
# caldav v3's _async_put raises PutError on HTTP failure
todo = await calendar.save_todo(ical=ical_content) # type: ignore[misc] # dual-mode todo = await calendar.save_todo(ical=ical_content) # type: ignore[misc] # dual-mode
logger.debug(f"Created todo {todo_uid}") logger.debug(f"Created todo {todo_uid}")
@@ -563,9 +568,9 @@ class CalendarClient:
self, self,
calendar_name: str, calendar_name: str,
todo_uid: str, todo_uid: str,
todo_data: Dict[str, Any], todo_data: dict[str, Any],
etag: str = "", etag: str = "",
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Update an existing todo/task.""" """Update an existing todo/task."""
calendar = self._get_calendar(calendar_name) calendar = self._get_calendar(calendar_name)
@@ -604,7 +609,7 @@ class CalendarClient:
logger.error(f"Error updating todo {todo_uid}: {e}", exc_info=True) logger.error(f"Error updating todo {todo_uid}: {e}", exc_info=True)
raise raise
async def delete_todo(self, calendar_name: str, todo_uid: str) -> Dict[str, Any]: async def delete_todo(self, calendar_name: str, todo_uid: str) -> dict[str, Any]:
"""Delete a todo/task.""" """Delete a todo/task."""
calendar = self._get_calendar(calendar_name) calendar = self._get_calendar(calendar_name)
@@ -615,13 +620,13 @@ class CalendarClient:
await _maybe_await(todo.delete()) await _maybe_await(todo.delete())
logger.debug(f"Deleted todo {todo_uid}") logger.debug(f"Deleted todo {todo_uid}")
return {"status_code": 204} return {"status_code": 204}
except Exception as e: except caldav_error.NotFoundError as e:
logger.debug(f"Todo {todo_uid} not found: {e}") logger.debug(f"Todo {todo_uid} not found: {e}")
return {"status_code": 404} return {"status_code": 404}
async def search_todos_across_calendars( async def search_todos_across_calendars(
self, filters: Optional[Dict[str, Any]] = None self, filters: dict[str, Any] | None = None
) -> List[Dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Search todos across all calendars.""" """Search todos across all calendars."""
try: try:
calendars = await self.list_calendars() calendars = await self.list_calendars()
@@ -653,7 +658,7 @@ class CalendarClient:
# ============= Helper Methods - Event iCalendar ============= # ============= Helper Methods - Event iCalendar =============
def _create_ical_event(self, event_data: Dict[str, Any], event_uid: str) -> str: def _create_ical_event(self, event_data: dict[str, Any], event_uid: str) -> str:
"""Create iCalendar content from event data.""" """Create iCalendar content from event data."""
cal = Calendar() cal = Calendar()
cal.add("prodid", "-//Nextcloud MCP Server//EN") cal.add("prodid", "-//Nextcloud MCP Server//EN")
@@ -737,12 +742,12 @@ class CalendarClient:
cal.add_component(event) cal.add_component(event)
return cal.to_ical().decode("utf-8") return cal.to_ical().decode("utf-8")
def _extract_vevent_data(self, component) -> Dict[str, Any]: def _extract_vevent_data(self, component) -> dict[str, Any]:
"""Extract event data from a single VEVENT component. """Extract event data from a single VEVENT component.
Shared helper used by both _parse_ical_event() and _parse_all_ical_events(). Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
""" """
event_data: Dict[str, Any] = { event_data: dict[str, Any] = {
"uid": str(component.get("uid", "")), "uid": str(component.get("uid", "")),
"title": str(component.get("summary", "")), "title": str(component.get("summary", "")),
"description": str(component.get("description", "")), "description": str(component.get("description", "")),
@@ -795,7 +800,7 @@ class CalendarClient:
return event_data return event_data
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]: def _parse_ical_event(self, ical_text: str) -> dict[str, Any] | None:
"""Parse iCalendar text and extract the first event.""" """Parse iCalendar text and extract the first event."""
try: try:
cal = Calendar.from_ical(ical_text) cal = Calendar.from_ical(ical_text)
@@ -807,13 +812,13 @@ class CalendarClient:
logger.error(f"Error parsing iCalendar event: {e}") logger.error(f"Error parsing iCalendar event: {e}")
return None return None
def _parse_all_ical_events(self, ical_text: str) -> list[Dict[str, Any]]: def _parse_all_ical_events(self, ical_text: str) -> list[dict[str, Any]]:
"""Parse iCalendar text and extract ALL event occurrences. """Parse iCalendar text and extract ALL event occurrences.
Used with server-side expansion where a single VCALENDAR contains Used with server-side expansion where a single VCALENDAR contains
multiple VEVENT components (one per recurrence occurrence). multiple VEVENT components (one per recurrence occurrence).
""" """
results: list[Dict[str, Any]] = [] results: list[dict[str, Any]] = []
try: try:
cal = Calendar.from_ical(ical_text) cal = Calendar.from_ical(ical_text)
for component in cal.walk(): for component in cal.walk():
@@ -824,7 +829,7 @@ class CalendarClient:
return results return results
def _merge_ical_properties( def _merge_ical_properties(
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str self, raw_ical: str, event_data: dict[str, Any], event_uid: str
) -> str: ) -> str:
"""Merge new event data into existing raw iCal while preserving all properties.""" """Merge new event data into existing raw iCal while preserving all properties."""
try: try:
@@ -960,7 +965,7 @@ class CalendarClient:
return parsed_dt return parsed_dt
def _create_ical_todo(self, todo_data: Dict[str, Any], todo_uid: str) -> str: def _create_ical_todo(self, todo_data: dict[str, Any], todo_uid: str) -> str:
"""Create iCalendar VTODO content from todo data.""" """Create iCalendar VTODO content from todo data."""
cal = Calendar() cal = Calendar()
cal.add("prodid", "-//Nextcloud MCP Server//EN") cal.add("prodid", "-//Nextcloud MCP Server//EN")
@@ -1015,7 +1020,7 @@ class CalendarClient:
cal.add_component(todo) cal.add_component(todo)
return cal.to_ical().decode("utf-8") return cal.to_ical().decode("utf-8")
def _parse_ical_todo(self, ical_text: str) -> Optional[Dict[str, Any]]: def _parse_ical_todo(self, ical_text: str) -> dict[str, Any] | None:
"""Parse iCalendar text and extract todo data.""" """Parse iCalendar text and extract todo data."""
try: try:
cal = Calendar.from_ical(ical_text) cal = Calendar.from_ical(ical_text)
@@ -1059,7 +1064,7 @@ class CalendarClient:
return None return None
def _merge_ical_todo_properties( def _merge_ical_todo_properties(
self, raw_ical: str, todo_data: Dict[str, Any], todo_uid: str self, raw_ical: str, todo_data: dict[str, Any], todo_uid: str
) -> str: ) -> str:
"""Merge new todo data into existing raw iCal while preserving all properties.""" """Merge new todo data into existing raw iCal while preserving all properties."""
try: try:
@@ -1165,15 +1170,15 @@ class CalendarClient:
return str(categories_obj) return str(categories_obj)
def _apply_event_filters( def _apply_event_filters(
self, events: List[Dict[str, Any]], filters: Dict[str, Any] self, events: list[dict[str, Any]], filters: dict[str, Any]
) -> List[Dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Apply advanced filters to event list.""" """Apply advanced filters to event list."""
return [ return [
event for event in events if self._event_matches_filters(event, filters) event for event in events if self._event_matches_filters(event, filters)
] ]
def _event_matches_filters( def _event_matches_filters(
self, event: Dict[str, Any], filters: Dict[str, Any] self, event: dict[str, Any], filters: dict[str, Any]
) -> bool: ) -> bool:
"""Check if an event matches the provided filters.""" """Check if an event matches the provided filters."""
try: try:
@@ -1216,7 +1221,7 @@ class CalendarClient:
return True return True
def _todo_matches_filters( def _todo_matches_filters(
self, todo: Dict[str, Any], filters: Dict[str, Any] self, todo: dict[str, Any], filters: dict[str, Any]
) -> bool: ) -> bool:
"""Check if a todo matches the provided filters.""" """Check if a todo matches the provided filters."""
try: try:
@@ -1253,8 +1258,8 @@ class CalendarClient:
# ============= Legacy Methods (for backward compatibility) ============= # ============= Legacy Methods (for backward compatibility) =============
async def bulk_update_events( async def bulk_update_events(
self, filter_criteria: Dict[str, Any], update_data: Dict[str, Any] self, filter_criteria: dict[str, Any], update_data: dict[str, Any]
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Bulk update events matching filter criteria.""" """Bulk update events matching filter criteria."""
try: try:
start_datetime = None start_datetime = None
@@ -1314,11 +1319,11 @@ class CalendarClient:
async def find_availability( async def find_availability(
self, self,
duration_minutes: int, duration_minutes: int,
attendees: Optional[List[str]] = None, attendees: list[str] | None = None,
start_datetime: Optional[dt.datetime] = None, start_datetime: dt.datetime | None = None,
end_datetime: Optional[dt.datetime] = None, end_datetime: dt.datetime | None = None,
constraints: Optional[Dict[str, Any]] = None, constraints: dict[str, Any] | None = None,
) -> List[Dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Find available time slots for scheduling. """Find available time slots for scheduling.
Note: This is a simplified stub that returns empty list. Note: This is a simplified stub that returns empty list.
@@ -4,14 +4,13 @@ This test module demonstrates data loss issues when non-supported fields
are present in calendar events and contacts during round-trip operations. are present in calendar events and contacts during round-trip operations.
""" """
import inspect
import logging import logging
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytest import pytest
from nextcloud_mcp_server.client.calendar import _maybe_await
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,7 +36,9 @@ async def test_calendar_event_custom_fields_preservation(nc_client):
# Get the calendar object from the caldav library # Get the calendar object from the caldav library
calendar = nc_client.calendar._get_calendar(calendar_name) calendar = nc_client.calendar._get_calendar(calendar_name)
event = await nc_client.calendar._async_object_by_uid(calendar, event_uid) event = await nc_client.calendar._async_object_by_uid(calendar, event_uid)
await _maybe_await(event.load()) result = event.load()
if inspect.isawaitable(result):
await result
# Now manually inject custom iCal properties into the raw data # Now manually inject custom iCal properties into the raw data
# This simulates what would happen if the event was created by another CalDAV client # This simulates what would happen if the event was created by another CalDAV client
@@ -309,7 +310,9 @@ async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
# Get the calendar object and event # Get the calendar object and event
calendar = nc_client.calendar._get_calendar(calendar_name) calendar = nc_client.calendar._get_calendar(calendar_name)
event = await nc_client.calendar._async_object_by_uid(calendar, event_uid) event = await nc_client.calendar._async_object_by_uid(calendar, event_uid)
await _maybe_await(event.load()) result = event.load()
if inspect.isawaitable(result):
await result
# Inject additional iCal properties that are valid but not supported by our parser # Inject additional iCal properties that are valid but not supported by our parser
extended_ical = f"""BEGIN:VCALENDAR extended_ical = f"""BEGIN:VCALENDAR