Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
8fa05b5
feat(jmap): add JMAP calendar client foundation
SashankBhamidi Feb 19, 2026
762c507
style: apply ruff formatting fixes
SashankBhamidi Feb 19, 2026
1d2cdd1
feat(jmap): add context manager support to JMAPClient
SashankBhamidi Feb 19, 2026
8bd2c5c
fix(jmap): correct RFC attributions in constants.py; add get_jmap_cli…
SashankBhamidi Feb 19, 2026
91cacd4
fix(jmap): exclude server-set fields from JMAPCalendar.to_jmap()
SashankBhamidi Feb 20, 2026
5e16ceb
feat(jmap): add JMAPEvent dataclass and CalendarEvent method builders
SashankBhamidi Feb 20, 2026
4ad24be
fix(jmap): resolve relative apiUrl in fetch_session against base URL
SashankBhamidi Feb 20, 2026
858cdd9
style(jmap): remove redundant section-label comment from JMAPClient
SashankBhamidi Feb 20, 2026
383030d
fix(jmap): handle null collection fields in JMAPEvent.from_jmap; expa…
SashankBhamidi Feb 20, 2026
4fb7105
feat(jmap): add iCalendar ↔ JSCalendar conversion layer
SashankBhamidi Feb 20, 2026
9e24561
fix(deps): ignore pytz in deptry DEP003 check
SashankBhamidi Feb 20, 2026
d3c609d
fix(jmap): correct parse_event_set return type; handle int byMonth va…
SashankBhamidi Feb 20, 2026
de8f733
style: apply ruff-format to jscal_to_ical byMonth fix
SashankBhamidi Feb 20, 2026
b88adbd
refactor(jmap): deduplicate participant imip extraction
SashankBhamidi Feb 20, 2026
25f062f
feat(jmap): add event CRUD methods to JMAPClient
SashankBhamidi Feb 20, 2026
1f6d66f
fix(jmap): add timeout to fetch_session; drop unused variable in test
SashankBhamidi Feb 20, 2026
4ef9b13
feat(jmap): add JMAPClient.search_events with batched query+get
SashankBhamidi Feb 20, 2026
37dbb01
fix(jmap): narrow broad exception catch in pytz timezone handling
SashankBhamidi Feb 20, 2026
b42efc4
feat(jmap): add get_sync_token and get_objects_by_sync_token
SashankBhamidi Feb 20, 2026
d1e8462
feat(jmap): add Task and TaskList support via RFC 9553
SashankBhamidi Feb 20, 2026
c53022e
style: remove section-label comments from JMAP test files
SashankBhamidi Feb 20, 2026
064d90f
feat(jmap): add AsyncJMAPClient mirroring public methods as coroutines
SashankBhamidi Feb 20, 2026
04a4549
fix(jmap): replace deprecated pytz with stdlib zoneinfo in jscal_to_ical
SashankBhamidi Feb 20, 2026
f1c9145
fix(jmap): replace deprecated pytz with stdlib zoneinfo in jscal_to_ical
SashankBhamidi Feb 20, 2026
591554d
fix(jmap): remove duplicate pytz block left by partial suggestion apply
SashankBhamidi Feb 20, 2026
364b250
feat(jmap): add event integration tests; fix UTC start encoding in ic…
SashankBhamidi Feb 21, 2026
c1be8aa
fix(jmap): remove unused _format_local_dt import from jscal_to_ical
SashankBhamidi Feb 21, 2026
432299a
fix(jmap): collapse double participants loop; fix _start_to_dtstart d…
SashankBhamidi Feb 21, 2026
3ab9078
docs(jmap): add JMAP usage documentation and autodoc stubs
SashankBhamidi Feb 21, 2026
eedaac0
fix(jmap): prefer primaryAccounts for account selection; fix test sec…
SashankBhamidi Feb 21, 2026
49b9c8d
style(jmap): fix ruff-format blank line after fallback import in sess…
SashankBhamidi Feb 21, 2026
39ce181
fix(jmap): guard create_event against malformed server response; pres…
SashankBhamidi Feb 22, 2026
fd71e1d
docs(jmap): add JMAP client entry to CHANGELOG
tobixen Feb 22, 2026
2eba687
fix(jmap): correct RFC attributions for task support; remove unverifi…
SashankBhamidi Feb 22, 2026
3a3225d
docs(jmap): remove unverified Fastmail claim; drop incorrect RFC 9553…
SashankBhamidi Feb 22, 2026
4a15d0c
feat(jmap): align API with CalDAV v3 — calendar-scoped search/get/add…
SashankBhamidi Feb 26, 2026
d96f1a0
docs(jmap): clarify JMAPTask.from_jmap docstring — JSCalendar not jCal
SashankBhamidi Mar 2, 2026
ce0b14b
refactor(jmap): replace JMAPEvent/JMAPTask/JMAPTaskList dataclasses w…
SashankBhamidi Mar 2, 2026
6a7ee7c
refactor(jmap): rename methods/ to _methods/ to signal internal API
SashankBhamidi Mar 2, 2026
fa9cef1
feat(jmap): return JMAPCalendarObject from search/get; deduplicate se…
SashankBhamidi Mar 2, 2026
2b61f2b
fix(jmap): update integration tests for JMAPCalendarObject return type
SashankBhamidi Mar 2, 2026
0b48bc6
refactor(jmap): eliminate duplication and shadow-builtin parameter names
SashankBhamidi Mar 2, 2026
8bc2adf
feat(jmap): add @type Event, Stalwart Docker test server, session URL…
SashankBhamidi Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,19 @@ Additionally, direct `DAVClient()` instantiation should migrate to `get_davclien

### Added

* **JMAP calendar client** — new `caldav.jmap` package providing a JMAP client
for servers implementing RFC 8620 (JMAP Core) and RFC 8984 (JMAP Calendars).
Features:
- Synchronous `JMAPClient` and asynchronous `AsyncJMAPClient` with mirrored APIs
- Full calendar + event CRUD (`create_event`, `get_event`, `update_event`,
`delete_event`, `search_events`)
- Incremental sync via `get_sync_token` / `get_objects_by_sync_token`
- Task CRUD (draft-ietf-jmap-tasks) via `create_task`, `get_task`, `update_task`, `delete_task`
- Bidirectional iCalendar ↔ JSCalendar conversion layer
- `get_jmap_client()` factory reads from the same config sources as
`get_davclient()` (env vars, config file)
- Tested against Cyrus IMAP

* **Full async API** - New `AsyncDAVClient` and async-compatible domain objects:
```python
from caldav.async_davclient import get_davclient
Expand Down
3 changes: 3 additions & 0 deletions caldav/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,9 @@ def get_davclient(
setup_func = conn_params.pop("_setup", None)
teardown_func = conn_params.pop("_teardown", None)
server_name = conn_params.pop("_server_name", None)
# Remove protocol field — present when config file has both CalDAV and JMAP sections,
# or when the caller passes protocol="jmap"/"caldav". DAVClient doesn't accept it.
conn_params.pop("protocol", None)

# Create client
client = client_class(**conn_params)
Expand Down
1 change: 1 addition & 0 deletions caldav/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ def resolve_features(features):
"features",
"enable_rfc6764",
"require_tls",
"protocol",
]
)

Expand Down
102 changes: 102 additions & 0 deletions caldav/jmap/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""
JMAP calendar support for python-caldav.

Provides synchronous and asynchronous JMAP clients with the same public API as
the CalDAV client, so user code works regardless of server protocol.

Basic usage::

from caldav.jmap import get_jmap_client

client = get_jmap_client(
url="https://jmap.example.com/.well-known/jmap",
username="alice",
password="secret",
)
calendars = client.get_calendars()

Async usage::

from caldav.jmap import get_async_jmap_client

async with get_async_jmap_client(
url="https://jmap.example.com/.well-known/jmap",
username="alice",
password="secret",
) as client:
calendars = await client.get_calendars()
"""

from caldav.jmap.async_client import AsyncJMAPClient
from caldav.jmap.client import JMAPClient
from caldav.jmap.error import (
JMAPAuthError,
JMAPCapabilityError,
JMAPError,
JMAPMethodError,
)
from caldav.jmap.objects.calendar import JMAPCalendar
from caldav.jmap.objects.calendar_object import JMAPCalendarObject

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


def get_jmap_client(**kwargs) -> JMAPClient | None:
"""Create a :class:`JMAPClient` from configuration.

Configuration is read from the same sources as :func:`caldav.get_davclient`:

1. Explicit keyword arguments (``url``, ``username``, ``password``, …)
2. Environment variables (``CALDAV_URL``, ``CALDAV_USERNAME``, …)
3. Config file (``~/.config/caldav/calendar.conf`` or equivalent)

Returns ``None`` if no configuration is found, matching the behaviour
of :func:`caldav.get_davclient`.

Example::

client = get_jmap_client(url="https://jmap.example.com/.well-known/jmap",
username="alice", password="secret")
"""
from caldav.config import get_connection_params

conn_params = get_connection_params(**kwargs)
if conn_params is None:
return None
return JMAPClient(**{k: v for k, v in conn_params.items() if k in _JMAP_KEYS})


def get_async_jmap_client(**kwargs) -> AsyncJMAPClient | None:
"""Create an :class:`AsyncJMAPClient` from configuration.

Accepts the same arguments and reads configuration from the same sources
as :func:`get_jmap_client`. Returns ``None`` if no configuration is found.

Example::

async with get_async_jmap_client(
url="https://jmap.example.com/.well-known/jmap",
username="alice", password="secret"
) as client:
calendars = await client.get_calendars()
"""
from caldav.config import get_connection_params

conn_params = get_connection_params(**kwargs)
if conn_params is None:
return None
return AsyncJMAPClient(**{k: v for k, v in conn_params.items() if k in _JMAP_KEYS})


__all__ = [
"JMAPClient",
"AsyncJMAPClient",
"get_jmap_client",
"get_async_jmap_client",
"JMAPError",
"JMAPCapabilityError",
"JMAPAuthError",
"JMAPMethodError",
"JMAPCalendar",
"JMAPCalendarObject",
]
12 changes: 12 additions & 0 deletions caldav/jmap/_methods/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
def parse_set_response(response_args: dict) -> tuple[dict, dict, list[str], dict, dict, dict]:
"""Parse the arguments dict from any JMAP ``*/set`` method response.

Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``.
"""
created: dict = response_args.get("created") or {}
updated: dict = response_args.get("updated") or {}
destroyed: list[str] = response_args.get("destroyed") or []
not_created: dict = response_args.get("notCreated") or {}
not_updated: dict = response_args.get("notUpdated") or {}
not_destroyed: dict = response_args.get("notDestroyed") or {}
return created, updated, destroyed, not_created, not_updated, not_destroyed
68 changes: 68 additions & 0 deletions caldav/jmap/_methods/calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""
JMAP Calendar method builders and response parsers.

These are pure functions — no HTTP, no state. They build the request
tuples that go into a ``methodCalls`` list, and parse the corresponding
``methodResponses`` entries.

Method shapes follow RFC 8620 §3.3 (get), §3.4 (changes), §3.5 (set); Calendar-specific
properties are defined in the JMAP Calendars specification.
"""

from __future__ import annotations

from caldav.jmap.objects.calendar import JMAPCalendar


def build_calendar_get(
account_id: str,
ids: list[str] | None = None,
properties: list[str] | None = None,
) -> tuple:
"""Build a ``Calendar/get`` method call tuple.

Args:
account_id: The JMAP accountId to query.
ids: List of calendar IDs to fetch, or ``None`` to fetch all.
properties: List of property names to return, or ``None`` for all.

Returns:
A 3-tuple ``("Calendar/get", arguments_dict, call_id)`` suitable
for inclusion in a ``methodCalls`` list.
"""
args: dict = {"accountId": account_id, "ids": ids}
if properties is not None:
args["properties"] = properties
return ("Calendar/get", args, "cal-get-0")


def parse_calendar_get(response_args: dict) -> list[JMAPCalendar]:
"""Parse the arguments dict from a ``Calendar/get`` method response.

Args:
response_args: The second element of a ``methodResponses`` entry
whose method name is ``"Calendar/get"``.

Returns:
List of :class:`~caldav.jmap.objects.calendar.JMAPCalendar` objects.
Returns an empty list if ``"list"`` is absent or empty.
"""
return [JMAPCalendar.from_jmap(item) for item in response_args.get("list", [])]


def build_calendar_changes(account_id: str, since_state: str) -> tuple:
"""Build a ``Calendar/changes`` method call tuple.

Args:
account_id: The JMAP accountId to query.
since_state: The ``state`` string from a previous ``Calendar/get``
or ``Calendar/changes`` response.

Returns:
A 3-tuple ``("Calendar/changes", arguments_dict, call_id)``.
"""
return (
"Calendar/changes",
{"accountId": account_id, "sinceState": since_state},
"cal-changes-0",
)
Loading
Loading