feat(jmap): add caldav/jmap — JMAP calendar and task client#625
feat(jmap): add caldav/jmap — JMAP calendar and task client#625SashankBhamidi wants to merge 37 commits intomasterfrom
Conversation
Introduces caldav/jmap/ as a purely additive package providing JMAP calendar support alongside the existing CalDAV client. No existing files are modified.
…nd parse_event_set to 6-tuple
docs(jmap): JMAP usage documentation and autodoc stubs
…tion label; fix async docs wording
Code Review:
|
|
Thanks for the review! Responses inline:
Valid, will fix.
False positive. The UUID is a local ephemeral key within a single
Noted, out of scope for this PR.
Intentional.
Valid, will fix.
Noted, low priority.
|
…erve RELATED=END in alarm round-trip
|
Sorry for not getting back with human-generated comments yet. My 15yo son was dragging me for a monster skiing trip yesterday, we managed to get home only after midnight, quite exhausted. Will have a calmer shorter skiing trip with my daughter today before getting back to this. |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ed Fastmail claim by @tobixen
… attribution for tasks
|
Sorry for the slow response on this. Crazy days, as always. One of my ideas here was to keep the API as similar as possible. There is no Is this possible? |
|
I did think about this when designing the API. Since we talked about potentially having JMAP in a different repo, I thought it would be better to have its own client so it's easier to maintain as a separate project. Having calendar.get_object_by_uid() and calendar.search() work across both protocols would be much cleaner for users. It's definitely possible but requires significant refactoring. Here's what we'd need to do:
The protocol and conversion layers could mostly stay put. It's really about how we expose things at the API level. Here's the thing though: should we prioritize this integration work, or does the separate repo idea still make more sense? If we're splitting it out anyway, the current approach might actually be better since it keeps things decoupled. Plus, if anyone contributes to that repo, they wouldn't need to understand CalDAV internals. Same for future maintainers - they could focus purely on JMAP without getting tangled up in CalDAV complexities. What are you thinking? I'm happy to go either direction. Just want to avoid doing a bunch of integration work if we're going to separate them later. |
|
Sorry for the late reply. While this is a part of the caldav library, I think it should be a bit integrated. At the very minimum, it should be possible to have JMAP and caldav configuration side-by-side in the same configuration file. If we release 3.0 with a Anyway, the API should be as similar as possible (deviations acceptable only when the standard itself deviates too much for a common API to make sense), so I think this should be redone a bit. |
|
No problem. Having unified config support and API alignment without breaking backward compatibility is a good middle ground. So instead of trying to make For unified config, we could extend the existing config file format to handle both protocol settings in the same file. That would make it much easier for users who want to work with both. I'll rework the API to align better with the CalDAV structure. The protocol and conversion layers can stay mostly as-is, it's really about reshaping the public interface. Should I start with the API restructuring, or do you want to discuss the config approach first? |
|
Just go ahead |
|
Sorry for the delay. Reworked per your feedback, please review bcdc014 |
Adds
caldav/jmap/, a new module providing JMAP calendar and task support alongside the existing CalDAV client. Zero modifications to any existing file.The module follows the same layered sans-I/O design as the CalDAV side: pure method builders/parsers in
methods/, dataclasses inobjects/, bidirectional iCalendar ↔ JSCalendar conversion inconvert/, HTTP + session logic inclient.pyandasync_client.py.Usage documentation in
docs/source/jmap.rstcovers auth, event CRUD, search, incremental sync, tasks, async API, and error handling.Session bootstrap (
session.py): GET/.well-known/jmap, resolve relativeapiUrlviaurljoin(Cyrus returns a relative path), select account viaprimaryAccounts[CALENDAR_CAPABILITY]with a fallback scan of all accounts. RaisesJMAPCapabilityErrorif no calendar-capable account is found.Auth (
client.py): Basic whenusernameis supplied, Bearer when onlypasswordis given, or a pre-built auth object via theauthkwarg. No 401-challenge-retry — a 401/403 from session or API endpoint raisesJMAPAuthErrorimmediately.JMAPErrorextendsDAVErrorso existing CalDAV exception handlers catch JMAP errors too.Calendar-scoped API (
objects/calendar.py):JMAPCalendarobjects returned byget_calendars()carry three methods that mirrorcaldav.collection.Calendarexactly:cal.add_event(ical_str),cal.get_object_by_uid(uid), andcal.search(event=True, start=, end=, text=). Both sync and async clients inject themselves into each calendar object so the same method works regardless of which client was used.Client-level operations on both
JMAPClientandAsyncJMAPClient:get_calendars,create_event,get_event,update_event,delete_event,search_events,get_sync_token,get_objects_by_sync_token.search_eventsuses a single batched request —CalendarEvent/query+ a result reference intoCalendarEvent/get— one HTTP round-trip regardless of result size.get_objects_by_sync_tokenraisesJMAPMethodError(error_type="serverPartialFail")when the server truncates the change list (hasMoreChanges: true).Task operations (
urn:ietf:params:jmap:tasks):get_task_lists,create_task,get_task,update_task,delete_task. Task methods send_TASK_USING = [CORE_CAPABILITY, TASK_CAPABILITY]; servers withouturn:ietf:params:jmap:tasksreturn an error methodResponse which_requestconverts toJMAPMethodError. The tasks specification is an expired IETF draft (draft-ietf-jmap-tasks, expired Sep 2023) with no RFC number — this is the most spec-unstable part of the implementation. Cyrus does not implement it, so task integration tests are deferred until a Stalwart Docker setup is added.AsyncJMAPClient(async_client.py) mirrors every method as a coroutine. Each request opens its ownniquests.AsyncSession— no long-lived connection is held.iCalendar ↔ JSCalendar conversion (
convert/): full bidirectional mapping covering DTSTART (all-day, floating, UTC, IANA-tz, non-IANA TZID passthrough), DTEND/DURATION, RRULE, EXRULE, EXDATE, RECURRENCE-ID overrides, ORGANIZER/ATTENDEE with roles and participation status, VALARM (relative and absolute triggers), CATEGORIES, LOCATION, CLASS, TRANSP, SEQUENCE, PRIORITY, COLOR. Shared duration/datetime primitives (_timedelta_to_duration,_duration_to_timedelta,_format_local_dt) live only inconvert/_utils.py.Entry points (
__init__.py):get_jmap_client(**kwargs)andget_async_jmap_client(**kwargs)read from the same sources asget_davclient— explicit kwargs, env vars, config file — and returnNonewhen no configuration is found.264 unit tests (zero network, all mocked). 17 integration tests against live Cyrus Docker (5 session/calendar checks, 6 sync event CRUD/search/sync, 6 async equivalents); auto-skipped if server unreachable.