Skip to content

Commit fa9cef1

Browse files
feat(jmap): return JMAPCalendarObject from search/get; deduplicate set-response parsing
1 parent 6a7ee7c commit fa9cef1

9 files changed

Lines changed: 369 additions & 179 deletions

File tree

caldav/jmap/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
JMAPError,
3636
JMAPMethodError,
3737
)
38+
from caldav.jmap.objects.calendar import JMAPCalendar
39+
from caldav.jmap.objects.calendar_object import JMAPCalendarObject
3840

3941
_JMAP_KEYS = {"url", "username", "password", "auth", "auth_type", "timeout"}
4042

@@ -95,4 +97,6 @@ def get_async_jmap_client(**kwargs) -> AsyncJMAPClient | None:
9597
"JMAPCapabilityError",
9698
"JMAPAuthError",
9799
"JMAPMethodError",
100+
"JMAPCalendar",
101+
"JMAPCalendarObject",
98102
]

caldav/jmap/_methods/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
def parse_set_response(response_args: dict) -> tuple[dict, dict, list[str], dict, dict, dict]:
2+
"""Parse the arguments dict from any JMAP ``*/set`` method response.
3+
4+
Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``.
5+
"""
6+
created: dict = response_args.get("created") or {}
7+
updated: dict = response_args.get("updated") or {}
8+
destroyed: list[str] = response_args.get("destroyed") or []
9+
not_created: dict = response_args.get("notCreated") or {}
10+
not_updated: dict = response_args.get("notUpdated") or {}
11+
not_destroyed: dict = response_args.get("notDestroyed") or {}
12+
return created, updated, destroyed, not_created, not_updated, not_destroyed

caldav/jmap/_methods/event.py

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
from __future__ import annotations
1414

15+
from caldav.jmap._methods import parse_set_response
16+
1517

1618
def build_event_get(
1719
account_id: str,
@@ -260,24 +262,7 @@ def parse_event_set(
260262
) -> tuple[dict, dict, list[str], dict, dict, dict]:
261263
"""Parse the arguments dict from a ``CalendarEvent/set`` method response.
262264
263-
Args:
264-
response_args: The second element of a ``methodResponses`` entry
265-
whose method name is ``"CalendarEvent/set"``.
266-
267-
Returns:
268-
A 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``:
269-
270-
- ``created``: Map of creation ID → server-assigned event dict.
271-
- ``updated``: Map of event ID → null or partial server-updated object.
272-
- ``destroyed``: List of successfully destroyed event IDs.
273-
- ``not_created``: Map of creation ID → SetError dict for failed creates.
274-
- ``not_updated``: Map of event ID → SetError dict for failed updates.
275-
- ``not_destroyed``: Map of event ID → SetError dict for failed destroys.
265+
Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``.
266+
See :func:`caldav.jmap._methods.parse_set_response` for field semantics.
276267
"""
277-
created: dict = response_args.get("created") or {}
278-
updated: dict = response_args.get("updated") or {}
279-
destroyed: list[str] = response_args.get("destroyed") or []
280-
not_created: dict = response_args.get("notCreated") or {}
281-
not_updated: dict = response_args.get("notUpdated") or {}
282-
not_destroyed: dict = response_args.get("notDestroyed") or {}
283-
return created, updated, destroyed, not_created, not_updated, not_destroyed
268+
return parse_set_response(response_args)

caldav/jmap/_methods/task.py

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from __future__ import annotations
1313

14+
from caldav.jmap._methods import parse_set_response
15+
1416

1517
def build_task_list_get(
1618
account_id: str,
@@ -151,24 +153,7 @@ def parse_task_set(
151153
) -> tuple[dict, dict, list[str], dict, dict, dict]:
152154
"""Parse the arguments dict from a ``Task/set`` method response.
153155
154-
Args:
155-
response_args: The second element of a ``methodResponses`` entry
156-
whose method name is ``"Task/set"``.
157-
158-
Returns:
159-
A 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``:
160-
161-
- ``created``: Map of creation ID → server-assigned task dict.
162-
- ``updated``: Map of task ID → null or partial server-updated object.
163-
- ``destroyed``: List of successfully destroyed task IDs.
164-
- ``not_created``: Map of creation ID → SetError dict for failed creates.
165-
- ``not_updated``: Map of task ID → SetError dict for failed updates.
166-
- ``not_destroyed``: Map of task ID → SetError dict for failed destroys.
156+
Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``.
157+
See :func:`caldav.jmap._methods.parse_set_response` for field semantics.
167158
"""
168-
created: dict = response_args.get("created") or {}
169-
updated: dict = response_args.get("updated") or {}
170-
destroyed: list[str] = response_args.get("destroyed") or []
171-
not_created: dict = response_args.get("notCreated") or {}
172-
not_updated: dict = response_args.get("notUpdated") or {}
173-
not_destroyed: dict = response_args.get("notDestroyed") or {}
174-
return created, updated, destroyed, not_created, not_updated, not_destroyed
159+
return parse_set_response(response_args)

caldav/jmap/async_client.py

Lines changed: 35 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,12 @@
1010
import logging
1111
import uuid
1212

13-
import icalendar
1413
from niquests import AsyncSession
1514

1615
from caldav.jmap._methods.calendar import build_calendar_get, parse_calendar_get
1716
from caldav.jmap._methods.event import (
1817
build_event_changes,
1918
build_event_get,
20-
build_event_query,
2119
build_event_set_destroy,
2220
build_event_set_update,
2321
parse_event_changes,
@@ -33,9 +31,10 @@
3331
parse_task_set,
3432
)
3533
from caldav.jmap.client import _DEFAULT_USING, _TASK_USING, _JMAPClientBase
36-
from caldav.jmap.convert import ical_to_jscal, jscal_to_ical
34+
from caldav.jmap.convert import ical_to_jscal
3735
from caldav.jmap.error import JMAPAuthError, JMAPMethodError
3836
from caldav.jmap.objects.calendar import JMAPCalendar
37+
from caldav.jmap.objects.calendar_object import JMAPCalendarObject
3938
from caldav.jmap.session import Session, async_fetch_session
4039

4140
log = logging.getLogger("caldav.jmap")
@@ -188,14 +187,17 @@ async def create_event(self, calendar_id: str, ical_str: str) -> str:
188187

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

191-
async def get_event(self, event_id: str) -> str:
190+
async def get_event(self, event_id: str) -> JMAPCalendarObject:
192191
"""Fetch a calendar event as an iCalendar string.
193192
194193
Args:
195194
event_id: The JMAP event ID to retrieve.
196195
197196
Returns:
198-
A VCALENDAR string for the event.
197+
A :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject`
198+
wrapping the raw JSCalendar dict. ``parent`` is ``None`` since
199+
no :class:`~caldav.jmap.objects.calendar.JMAPCalendar` is available
200+
at the client level.
199201
200202
Raises:
201203
JMAPMethodError: If the event is not found.
@@ -213,7 +215,7 @@ async def get_event(self, event_id: str) -> str:
213215
reason=f"Event not found: {event_id}",
214216
error_type="notFound",
215217
)
216-
return jscal_to_ical(items[0])
218+
return JMAPCalendarObject(data=items[0], parent=None)
217219

218220
raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/get response")
219221

@@ -248,36 +250,18 @@ async def _search(
248250
start: str | None = None,
249251
end: str | None = None,
250252
text: str | None = None,
251-
) -> list[str]:
253+
parent: JMAPCalendar | None = None,
254+
) -> list[JMAPCalendarObject]:
252255
session = await self._get_session()
253-
filter_dict: dict = {}
254-
if calendar_id is not None:
255-
filter_dict["inCalendars"] = [calendar_id]
256-
if start is not None:
257-
filter_dict["after"] = start
258-
if end is not None:
259-
filter_dict["before"] = end
260-
if text is not None:
261-
filter_dict["text"] = text
262-
263-
query_call = build_event_query(session.account_id, filter=filter_dict or None)
264-
get_call = (
265-
"CalendarEvent/get",
266-
{
267-
"accountId": session.account_id,
268-
"#ids": {
269-
"resultOf": "ev-query-0",
270-
"name": "CalendarEvent/query",
271-
"path": "/ids",
272-
},
273-
},
274-
"ev-get-1",
275-
)
276-
responses = await self._request([query_call, get_call])
256+
calls = self._build_event_search_calls(session.account_id, calendar_id, start, end, text)
257+
responses = await self._request(calls)
277258

278259
for method_name, resp_args, _ in responses:
279260
if method_name == "CalendarEvent/get":
280-
return [jscal_to_ical(item) for item in resp_args.get("list", [])]
261+
return [
262+
JMAPCalendarObject(data=item, parent=parent)
263+
for item in resp_args.get("list", [])
264+
]
281265

282266
return []
283267

@@ -287,8 +271,8 @@ async def search_events(
287271
start: str | None = None,
288272
end: str | None = None,
289273
text: str | None = None,
290-
) -> list[str]:
291-
"""Search for calendar events and return them as iCalendar strings.
274+
) -> list[JMAPCalendarObject]:
275+
"""Search for calendar events.
292276
293277
All parameters are optional; omitting all returns every event in the account.
294278
Results are fetched in a single batched JMAP request using a result reference
@@ -301,7 +285,9 @@ async def search_events(
301285
text: Free-text search across title, description, locations, and participants.
302286
303287
Returns:
304-
List of VCALENDAR strings for all matching events.
288+
List of :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject`
289+
instances. ``parent`` is ``None`` on these objects; use
290+
:meth:`JMAPCalendar.search` if you need ``parent`` set.
305291
"""
306292
return await self._search(calendar_id=calendar_id, start=start, end=end, text=text)
307293

@@ -325,13 +311,14 @@ async def get_sync_token(self) -> str:
325311

326312
async def get_objects_by_sync_token(
327313
self, sync_token: str
328-
) -> tuple[list[str], list[str], list[str]]:
314+
) -> tuple[list[JMAPCalendarObject], list[JMAPCalendarObject], list[str]]:
329315
"""Fetch events changed since a previous sync token.
330316
331317
Calls ``CalendarEvent/changes`` to discover which events were created,
332318
modified, or destroyed since ``sync_token`` was issued. Created and
333-
modified events are returned as iCalendar strings; destroyed events are
334-
returned as IDs (the objects no longer exist on the server).
319+
modified events are returned as
320+
:class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject` instances;
321+
destroyed events are returned as IDs (the objects no longer exist on the server).
335322
336323
Args:
337324
sync_token: A state string previously returned by :meth:`get_sync_token`
@@ -340,8 +327,8 @@ async def get_objects_by_sync_token(
340327
Returns:
341328
A 3-tuple ``(added, modified, deleted)``:
342329
343-
- ``added``: iCalendar strings for newly created events.
344-
- ``modified``: iCalendar strings for updated events.
330+
- ``added``: objects for newly created events (``parent`` is ``None``).
331+
- ``modified``: objects for updated events (``parent`` is ``None``).
345332
- ``deleted``: Event IDs that were destroyed.
346333
347334
Raises:
@@ -376,11 +363,11 @@ async def get_objects_by_sync_token(
376363
get_call = build_event_get(session.account_id, ids=fetch_ids)
377364
get_responses = await self._request([get_call])
378365

379-
events_by_id: dict[str, str] = {}
366+
events_by_id: dict[str, JMAPCalendarObject] = {}
380367
for method_name, resp_args, _ in get_responses:
381368
if method_name == "CalendarEvent/get":
382369
for item in resp_args.get("list", []):
383-
events_by_id[item["id"]] = jscal_to_ical(item)
370+
events_by_id[item["id"]] = JMAPCalendarObject(data=item, parent=None)
384371

385372
added = [events_by_id[i] for i in created_ids if i in events_by_id]
386373
modified = [events_by_id[i] for i in updated_ids if i in events_by_id]
@@ -408,19 +395,12 @@ async def delete_event(self, event_id: str) -> None:
408395

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

411-
async def _get_object_by_uid(self, uid: str, calendar_id: str | None = None) -> str:
412-
for event_ical in await self._search(calendar_id=calendar_id):
413-
try:
414-
cal = icalendar.Calendar.from_ical(event_ical)
415-
for component in cal.walk():
416-
if component.name in ("VEVENT", "VTODO", "VJOURNAL"):
417-
component_uid = component.get("UID")
418-
if component_uid is not None and str(component_uid) == uid:
419-
return event_ical
420-
except ValueError:
421-
log.debug("Skipping unparseable iCalendar string during UID lookup")
422-
continue
423-
398+
async def _get_object_by_uid(
399+
self, uid: str, calendar_id: str | None = None, parent: JMAPCalendar | None = None
400+
) -> JMAPCalendarObject:
401+
for obj in await self._search(calendar_id=calendar_id, parent=parent):
402+
if obj.data.get("uid") == uid:
403+
return obj
424404
session = await self._get_session()
425405
raise JMAPMethodError(
426406
url=session.api_url, reason=f"No calendar object found with UID: {uid}"

0 commit comments

Comments
 (0)