Skip to content

Commit bcdc014

Browse files
feat(jmap): align API with CalDAV v3 — calendar-scoped search/get/add_event
1 parent 8e17711 commit bcdc014

7 files changed

Lines changed: 397 additions & 49 deletions

File tree

caldav/base_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,9 @@ def get_davclient(
347347
setup_func = conn_params.pop("_setup", None)
348348
teardown_func = conn_params.pop("_teardown", None)
349349
server_name = conn_params.pop("_server_name", None)
350+
# Remove protocol field — present when config file has both CalDAV and JMAP sections,
351+
# or when the caller passes protocol="jmap"/"caldav". DAVClient doesn't accept it.
352+
conn_params.pop("protocol", None)
350353

351354
# Create client
352355
client = client_class(**conn_params)

caldav/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ def replacer(match: re.Match) -> str:
179179
"features",
180180
"enable_rfc6764",
181181
"require_tls",
182+
"protocol",
182183
]
183184
)
184185

caldav/jmap/async_client.py

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import logging
1111
import uuid
1212

13+
import icalendar
1314
from niquests import AsyncSession
1415

1516
from caldav.jmap.client import _DEFAULT_USING, _TASK_USING, _JMAPClientBase
@@ -144,7 +145,11 @@ async def get_calendars(self) -> list[JMAPCalendar]:
144145

145146
for method_name, resp_args, _ in responses:
146147
if method_name == "Calendar/get":
147-
return parse_calendar_get(resp_args)
148+
calendars = parse_calendar_get(resp_args)
149+
for cal in calendars:
150+
cal._client = self
151+
cal._is_async = True
152+
return calendars
148153

149154
return []
150155

@@ -238,28 +243,13 @@ async def update_event(self, event_id: str, ical_str: str) -> None:
238243

239244
raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response")
240245

241-
async def search_events(
246+
async def _search(
242247
self,
243248
calendar_id: str | None = None,
244249
start: str | None = None,
245250
end: str | None = None,
246251
text: str | None = None,
247252
) -> list[str]:
248-
"""Search for calendar events and return them as iCalendar strings.
249-
250-
All parameters are optional; omitting all returns every event in the account.
251-
Results are fetched in a single batched JMAP request using a result reference
252-
from ``CalendarEvent/query`` into ``CalendarEvent/get``.
253-
254-
Args:
255-
calendar_id: Limit results to this calendar.
256-
start: Only events ending after this datetime (``YYYY-MM-DDTHH:MM:SS``).
257-
end: Only events starting before this datetime (``YYYY-MM-DDTHH:MM:SS``).
258-
text: Free-text search across title, description, locations, and participants.
259-
260-
Returns:
261-
List of VCALENDAR strings for all matching events.
262-
"""
263253
session = await self._get_session()
264254
filter_dict: dict = {}
265255
if calendar_id is not None:
@@ -292,6 +282,30 @@ async def search_events(
292282

293283
return []
294284

285+
async def search_events(
286+
self,
287+
calendar_id: str | None = None,
288+
start: str | None = None,
289+
end: str | None = None,
290+
text: str | None = None,
291+
) -> list[str]:
292+
"""Search for calendar events and return them as iCalendar strings.
293+
294+
All parameters are optional; omitting all returns every event in the account.
295+
Results are fetched in a single batched JMAP request using a result reference
296+
from ``CalendarEvent/query`` into ``CalendarEvent/get``.
297+
298+
Args:
299+
calendar_id: Limit results to this calendar.
300+
start: Only events ending after this datetime (``YYYY-MM-DDTHH:MM:SS``).
301+
end: Only events starting before this datetime (``YYYY-MM-DDTHH:MM:SS``).
302+
text: Free-text search across title, description, locations, and participants.
303+
304+
Returns:
305+
List of VCALENDAR strings for all matching events.
306+
"""
307+
return await self._search(calendar_id=calendar_id, start=start, end=end, text=text)
308+
295309
async def get_sync_token(self) -> str:
296310
"""Return the current CalendarEvent state string for use as a sync token.
297311
@@ -395,6 +409,24 @@ async def delete_event(self, event_id: str) -> None:
395409

396410
raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response")
397411

412+
async def _get_object_by_uid(self, uid: str, calendar_id: str | None = None) -> str:
413+
for event_ical in await self._search(calendar_id=calendar_id):
414+
try:
415+
cal = icalendar.Calendar.from_ical(event_ical)
416+
for component in cal.walk():
417+
if component.name in ("VEVENT", "VTODO", "VJOURNAL"):
418+
component_uid = component.get("UID")
419+
if component_uid is not None and str(component_uid) == uid:
420+
return event_ical
421+
except ValueError:
422+
log.debug("Skipping unparseable iCalendar string during UID lookup")
423+
continue
424+
425+
session = await self._get_session()
426+
raise JMAPMethodError(
427+
url=session.api_url, reason=f"No calendar object found with UID: {uid}"
428+
)
429+
398430
async def get_task_lists(self) -> list[JMAPTaskList]:
399431
"""Fetch all task lists for the authenticated account.
400432

caldav/jmap/client.py

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import requests # type: ignore[no-redef]
2121
from requests.auth import HTTPBasicAuth # type: ignore[no-redef]
2222

23+
import icalendar
24+
2325
from caldav.jmap.constants import CALENDAR_CAPABILITY, CORE_CAPABILITY, TASK_CAPABILITY
2426
from caldav.jmap.convert import ical_to_jscal, jscal_to_ical
2527
from caldav.jmap.error import JMAPAuthError, JMAPMethodError
@@ -223,7 +225,11 @@ def get_calendars(self) -> list[JMAPCalendar]:
223225

224226
for method_name, resp_args, _ in responses:
225227
if method_name == "Calendar/get":
226-
return parse_calendar_get(resp_args)
228+
calendars = parse_calendar_get(resp_args)
229+
for cal in calendars:
230+
cal._client = self
231+
cal._is_async = False
232+
return calendars
227233

228234
return []
229235

@@ -317,28 +323,13 @@ def update_event(self, event_id: str, ical_str: str) -> None:
317323

318324
raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response")
319325

320-
def search_events(
326+
def _search(
321327
self,
322328
calendar_id: str | None = None,
323329
start: str | None = None,
324330
end: str | None = None,
325331
text: str | None = None,
326332
) -> list[str]:
327-
"""Search for calendar events and return them as iCalendar strings.
328-
329-
All parameters are optional; omitting all returns every event in the account.
330-
Results are fetched in a single batched JMAP request using a result reference
331-
from ``CalendarEvent/query`` into ``CalendarEvent/get``.
332-
333-
Args:
334-
calendar_id: Limit results to this calendar.
335-
start: Only events ending after this datetime (``YYYY-MM-DDTHH:MM:SS``).
336-
end: Only events starting before this datetime (``YYYY-MM-DDTHH:MM:SS``).
337-
text: Free-text search across title, description, locations, and participants.
338-
339-
Returns:
340-
List of VCALENDAR strings for all matching events.
341-
"""
342333
session = self._get_session()
343334
filter_dict: dict = {}
344335
if calendar_id is not None:
@@ -371,6 +362,30 @@ def search_events(
371362

372363
return []
373364

365+
def search_events(
366+
self,
367+
calendar_id: str | None = None,
368+
start: str | None = None,
369+
end: str | None = None,
370+
text: str | None = None,
371+
) -> list[str]:
372+
"""Search for calendar events and return them as iCalendar strings.
373+
374+
All parameters are optional; omitting all returns every event in the account.
375+
Results are fetched in a single batched JMAP request using a result reference
376+
from ``CalendarEvent/query`` into ``CalendarEvent/get``.
377+
378+
Args:
379+
calendar_id: Limit results to this calendar.
380+
start: Only events ending after this datetime (``YYYY-MM-DDTHH:MM:SS``).
381+
end: Only events starting before this datetime (``YYYY-MM-DDTHH:MM:SS``).
382+
text: Free-text search across title, description, locations, and participants.
383+
384+
Returns:
385+
List of VCALENDAR strings for all matching events.
386+
"""
387+
return self._search(calendar_id=calendar_id, start=start, end=end, text=text)
388+
374389
def get_sync_token(self) -> str:
375390
"""Return the current CalendarEvent state string for use as a sync token.
376391
@@ -472,6 +487,23 @@ def delete_event(self, event_id: str) -> None:
472487

473488
raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response")
474489

490+
def _get_object_by_uid(self, uid: str, calendar_id: str | None = None) -> str:
491+
for event_ical in self._search(calendar_id=calendar_id):
492+
try:
493+
cal = icalendar.Calendar.from_ical(event_ical)
494+
for component in cal.walk():
495+
if component.name in ("VEVENT", "VTODO", "VJOURNAL"):
496+
component_uid = component.get("UID")
497+
if component_uid is not None and str(component_uid) == uid:
498+
return event_ical
499+
except ValueError:
500+
log.debug("Skipping unparseable iCalendar string during UID lookup")
501+
continue
502+
503+
raise JMAPMethodError(
504+
url=self._get_session().api_url, reason=f"No calendar object found with UID: {uid}"
505+
)
506+
475507
def get_task_lists(self) -> list[JMAPTaskList]:
476508
"""Fetch all task lists for the authenticated account.
477509

caldav/jmap/objects/calendar.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
from __future__ import annotations
99

1010
from dataclasses import dataclass, field
11+
from datetime import datetime
12+
from typing import TYPE_CHECKING
13+
14+
if TYPE_CHECKING:
15+
from caldav.jmap.async_client import AsyncJMAPClient
16+
from caldav.jmap.client import JMAPClient
1117

1218

1319
@dataclass
@@ -34,6 +40,12 @@ class JMAPCalendar:
3440
sort_order: int = 0
3541
is_visible: bool = True
3642

43+
# Injected by JMAPClient.get_calendars() / AsyncJMAPClient.get_calendars()
44+
_client: JMAPClient | AsyncJMAPClient | None = field(
45+
default=None, init=False, repr=False, compare=False
46+
)
47+
_is_async: bool = field(default=False, init=False, repr=False, compare=False)
48+
3749
@classmethod
3850
def from_jmap(cls, data: dict) -> JMAPCalendar:
3951
"""Construct a JMAPCalendar from a raw JMAP Calendar JSON dict.
@@ -70,3 +82,103 @@ def to_jmap(self) -> dict:
7082
if self.color is not None:
7183
d["color"] = self.color
7284
return d
85+
86+
def search(self, **searchargs) -> list[str]:
87+
"""Search for calendar objects and return them as iCalendar strings.
88+
89+
Mirrors :meth:`caldav.collection.Calendar.search`. When called on an
90+
async-backed calendar, returns a coroutine that must be awaited.
91+
92+
Accepted keyword arguments (all optional):
93+
94+
- ``event`` (bool): search for events (VEVENT components).
95+
- ``todo`` (bool): search for tasks (VTODO). Note: requires server
96+
JMAP Task capability; raises :class:`NotImplementedError` if absent.
97+
- ``start`` (datetime or str): only events ending after this time
98+
(maps to JMAP ``after`` filter).
99+
- ``end`` (datetime or str): only events starting before this time
100+
(maps to JMAP ``before`` filter).
101+
- ``text`` (str): free-text search across title, description,
102+
locations, and participants.
103+
104+
Unknown searchargs keys are silently ignored for forward compatibility.
105+
106+
Returns:
107+
List of VCALENDAR strings for all matching objects.
108+
"""
109+
if self._is_async:
110+
return self._async_search(**searchargs)
111+
start = searchargs.get("start")
112+
end = searchargs.get("end")
113+
if isinstance(start, datetime):
114+
start = start.isoformat()
115+
if isinstance(end, datetime):
116+
end = end.isoformat()
117+
return self._client._search(
118+
calendar_id=self.id,
119+
start=start,
120+
end=end,
121+
text=searchargs.get("text"),
122+
)
123+
124+
async def _async_search(self, **searchargs) -> list[str]:
125+
start = searchargs.get("start")
126+
end = searchargs.get("end")
127+
if isinstance(start, datetime):
128+
start = start.isoformat()
129+
if isinstance(end, datetime):
130+
end = end.isoformat()
131+
return await self._client._search(
132+
calendar_id=self.id,
133+
start=start,
134+
end=end,
135+
text=searchargs.get("text"),
136+
)
137+
138+
def get_object_by_uid(self, uid: str, comp_class=None) -> str:
139+
"""Get a calendar object by its iCalendar UID.
140+
141+
Mirrors :meth:`caldav.collection.Calendar.get_object_by_uid`. When
142+
called on an async-backed calendar, returns a coroutine that must be
143+
awaited.
144+
145+
Args:
146+
uid: The iCalendar UID to search for.
147+
comp_class: Accepted for API compatibility with the CalDAV interface;
148+
JMAP ``CalendarEvent/query`` has no native component-type filter,
149+
so this argument is currently ignored.
150+
151+
Returns:
152+
A VCALENDAR string for the matching object.
153+
154+
Raises:
155+
JMAPMethodError: If no object with this UID is found.
156+
"""
157+
if self._is_async:
158+
return self._async_get_object_by_uid(uid)
159+
return self._client._get_object_by_uid(uid, calendar_id=self.id)
160+
161+
async def _async_get_object_by_uid(self, uid: str) -> str:
162+
return await self._client._get_object_by_uid(uid, calendar_id=self.id)
163+
164+
def add_event(self, ical_str: str) -> str:
165+
"""Add an event to this calendar from an iCalendar string.
166+
167+
Mirrors :meth:`caldav.collection.Calendar.add_event`. When called on
168+
an async-backed calendar, returns a coroutine that must be awaited.
169+
170+
Args:
171+
ical_str: A VCALENDAR string representing the event.
172+
173+
Returns:
174+
The server-assigned JMAP event ID.
175+
176+
Raises:
177+
JMAPMethodError: If the server rejects the create request.
178+
"""
179+
if self._is_async:
180+
return self._async_add_event(ical_str)
181+
return self._client.create_event(self.id, ical_str)
182+
183+
async def _async_add_event(self, ical_str: str) -> str:
184+
return await self._client.create_event(self.id, ical_str)

0 commit comments

Comments
 (0)