From 28f97e2ff300b4ca54c89b04f5e89e0b34680444 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Thu, 19 Feb 2026 23:12:29 +0530 Subject: [PATCH 01/31] feat(jmap): add JMAP calendar client foundation Introduces caldav/jmap/ as a purely additive package providing JMAP calendar support alongside the existing CalDAV client. No existing files are modified. --- caldav/jmap/__init__.py | 65 +++++ caldav/jmap/client.py | 194 +++++++++++++ caldav/jmap/constants.py | 15 ++ caldav/jmap/convert/__init__.py | 0 caldav/jmap/error.py | 83 ++++++ caldav/jmap/methods/__init__.py | 0 caldav/jmap/methods/calendar.py | 68 +++++ caldav/jmap/objects/__init__.py | 0 caldav/jmap/objects/calendar.py | 73 +++++ caldav/jmap/session.py | 111 ++++++++ tests/test_jmap_integration.py | 100 +++++++ tests/test_jmap_unit.py | 465 ++++++++++++++++++++++++++++++++ 12 files changed, 1174 insertions(+) create mode 100644 caldav/jmap/__init__.py create mode 100644 caldav/jmap/client.py create mode 100644 caldav/jmap/constants.py create mode 100644 caldav/jmap/convert/__init__.py create mode 100644 caldav/jmap/error.py create mode 100644 caldav/jmap/methods/__init__.py create mode 100644 caldav/jmap/methods/calendar.py create mode 100644 caldav/jmap/objects/__init__.py create mode 100644 caldav/jmap/objects/calendar.py create mode 100644 caldav/jmap/session.py create mode 100644 tests/test_jmap_integration.py create mode 100644 tests/test_jmap_unit.py diff --git a/caldav/jmap/__init__.py b/caldav/jmap/__init__.py new file mode 100644 index 00000000..c05425a1 --- /dev/null +++ b/caldav/jmap/__init__.py @@ -0,0 +1,65 @@ +""" +JMAP calendar support for python-caldav. + +Provides a synchronous JMAP client 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() +""" + +from caldav.jmap.client import JMAPClient +from caldav.jmap.error import ( + JMAPAuthError, + JMAPCapabilityError, + JMAPError, + JMAPMethodError, +) + + +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 + + # Strip CalDAV-only keys that JMAPClient does not accept. + _JMAP_KEYS = {"url", "username", "password", "auth", "auth_type", "timeout"} + jmap_params = {k: v for k, v in conn_params.items() if k in _JMAP_KEYS} + + return JMAPClient(**jmap_params) + + +__all__ = [ + "JMAPClient", + "get_jmap_client", + "JMAPError", + "JMAPCapabilityError", + "JMAPAuthError", + "JMAPMethodError", +] diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py new file mode 100644 index 00000000..39981e7d --- /dev/null +++ b/caldav/jmap/client.py @@ -0,0 +1,194 @@ +""" +Synchronous JMAP client. + +Wraps session establishment, HTTP communication, and method dispatching +into a single object with a clean public API. + +Auth note: JMAP has no 401-challenge-retry dance (unlike CalDAV). +Credentials are sent upfront on every request. A 401/403 is a hard failure. +""" + +from __future__ import annotations + +import logging + +try: + import niquests as requests + from niquests.auth import HTTPBasicAuth +except ImportError: + import requests # type: ignore[no-redef] + from requests.auth import HTTPBasicAuth # type: ignore[no-redef] + +from caldav.jmap.constants import CALENDAR_CAPABILITY, CORE_CAPABILITY +from caldav.jmap.error import JMAPAuthError, JMAPMethodError +from caldav.jmap.methods.calendar import build_calendar_get, parse_calendar_get +from caldav.jmap.objects.calendar import JMAPCalendar +from caldav.jmap.session import Session, fetch_session +from caldav.requests import HTTPBearerAuth + +log = logging.getLogger("caldav.jmap") + +# Default capabilities declared in every API request +_DEFAULT_USING = [CORE_CAPABILITY, CALENDAR_CAPABILITY] + + +class JMAPClient: + """Synchronous JMAP client for calendar operations. + + 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() + + Args: + url: URL of the JMAP session endpoint (``/.well-known/jmap``). + username: Username for Basic auth. + password: Password for Basic auth, or bearer token if no username. + auth: A pre-built requests-compatible auth object. Takes precedence + over username/password if provided. + auth_type: Force a specific auth type: ``"basic"`` or ``"bearer"``. + timeout: HTTP request timeout in seconds. + """ + + def __init__( + self, + url: str, + username: str | None = None, + password: str | None = None, + auth=None, + auth_type: str | None = None, + timeout: int = 30, + ) -> None: + self.url = url + self.username = username + self.password = password + self.timeout = timeout + self._session_cache: Session | None = None + + if auth is not None: + self._auth = auth + else: + self._auth = self._build_auth(auth_type) + + def _build_auth(self, auth_type: str | None): + """Select and construct the auth object. + + JMAP supports Basic and Bearer auth; Digest is not supported. + When ``auth_type`` is ``None`` the type is inferred from the + credentials supplied: a username triggers Basic, a password + alone triggers Bearer, and neither raises :class:`JMAPAuthError`. + """ + effective_type = auth_type + if effective_type is None: + if self.username: + effective_type = "basic" + elif self.password: + effective_type = "bearer" + else: + raise JMAPAuthError( + url=self.url, + reason="No credentials provided. Supply username+password or a bearer token.", + ) + + if effective_type == "basic": + if not self.username or not self.password: + raise JMAPAuthError( + url=self.url, + reason="Basic auth requires both username and password.", + ) + return HTTPBasicAuth(self.username, self.password) + elif effective_type == "bearer": + if not self.password: + raise JMAPAuthError( + url=self.url, + reason="Bearer auth requires a token supplied as the password argument.", + ) + return HTTPBearerAuth(self.password) + else: + raise JMAPAuthError( + url=self.url, + reason=f"Unsupported auth_type {effective_type!r}. Use 'basic' or 'bearer'.", + ) + + def _get_session(self) -> Session: + """Return the cached Session, fetching it on first call.""" + if self._session_cache is None: + self._session_cache = fetch_session(self.url, auth=self._auth) + return self._session_cache + + def _request(self, method_calls: list[tuple]) -> list: + """POST a batch of JMAP method calls and return the methodResponses. + + Args: + method_calls: List of 3-tuples ``(method_name, args_dict, call_id)``. + + Returns: + List of 3-tuples ``(method_name, response_args, call_id)`` from + the server's ``methodResponses`` array. + + Raises: + JMAPAuthError: On HTTP 401 or 403. + JMAPMethodError: If any methodResponse is an ``error`` response. + requests.HTTPError: On other non-2xx HTTP responses. + """ + session = self._get_session() + + payload = { + "using": _DEFAULT_USING, + "methodCalls": list(method_calls), + } + + log.debug("JMAP POST to %s: %d method call(s)", session.api_url, len(method_calls)) + + response = requests.post( + session.api_url, + json=payload, + auth=self._auth, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + timeout=self.timeout, + ) + + if response.status_code in (401, 403): + raise JMAPAuthError( + url=session.api_url, + reason=f"HTTP {response.status_code} from API endpoint", + ) + + response.raise_for_status() + + data = response.json() + method_responses = data.get("methodResponses", []) + + for resp in method_responses: + method_name, resp_args, call_id = resp + if method_name == "error": + error_type = resp_args.get("type", "serverError") + raise JMAPMethodError( + url=session.api_url, + reason=f"Method call failed: {resp_args}", + error_type=error_type, + ) + + return method_responses + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def get_calendars(self) -> list[JMAPCalendar]: + """Fetch all calendars for the authenticated account. + + Returns: + List of :class:`~caldav.jmap.objects.calendar.JMAPCalendar` objects. + """ + session = self._get_session() + call = build_calendar_get(session.account_id) + responses = self._request([call]) + + for method_name, resp_args, _ in responses: + if method_name == "Calendar/get": + return parse_calendar_get(resp_args) + + return [] diff --git a/caldav/jmap/constants.py b/caldav/jmap/constants.py new file mode 100644 index 00000000..2051b929 --- /dev/null +++ b/caldav/jmap/constants.py @@ -0,0 +1,15 @@ +""" +JMAP capability URN constants (RFC 8620, JMAP Calendars spec, RFC 9553). + +All JMAP capability strings are defined here so they are never duplicated +across the package. Every other module should import from this file. +""" + +#: Core JMAP capability — required in every ``using`` declaration. +CORE_CAPABILITY = "urn:ietf:params:jmap:core" + +#: RFC 8620 JMAP Calendars capability. +CALENDAR_CAPABILITY = "urn:ietf:params:jmap:calendars" + +#: RFC 9553 JMAP Tasks capability. +TASK_CAPABILITY = "urn:ietf:params:jmap:tasks" diff --git a/caldav/jmap/convert/__init__.py b/caldav/jmap/convert/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caldav/jmap/error.py b/caldav/jmap/error.py new file mode 100644 index 00000000..8b7f704d --- /dev/null +++ b/caldav/jmap/error.py @@ -0,0 +1,83 @@ +""" +JMAP error hierarchy. + +Extends the existing caldav.lib.error.DAVError base so that JMAP errors +integrate naturally with existing exception handling in user code. + +RFC 8620 §3.6.2 defines the standard method-level error types. +""" + +from caldav.lib.error import AuthorizationError, DAVError + + +class JMAPError(DAVError): + """Base class for all JMAP errors. + + Adds ``error_type`` to carry the RFC 8620 error type string + (e.g. ``"unknownMethod"``, ``"invalidArguments"``). + """ + + error_type: str = "serverError" + + def __init__( + self, + url: str | None = None, + reason: str | None = None, + error_type: str | None = None, + ) -> None: + super().__init__(url=url, reason=reason) + if error_type is not None: + self.error_type = error_type + + def __str__(self) -> str: + return "%s (type=%s) at '%s', reason: %s" % ( + self.__class__.__name__, + self.error_type, + self.url, + self.reason, + ) + + +class JMAPCapabilityError(JMAPError): + """Server does not advertise the required JMAP capability. + + Raised when the Session object returned by the server does not include + ``urn:ietf:params:jmap:calendars`` in the account capabilities. + """ + + error_type = "capabilityNotSupported" + reason = "Server does not support urn:ietf:params:jmap:calendars" + + +class JMAPAuthError(AuthorizationError, JMAPError): + """HTTP 401 or 403 received from JMAP server. + + Unlike CalDAV, JMAP does not use a 401-challenge-retry dance. + A 401/403 on the session GET or any API call is a hard failure. + """ + + error_type = "forbidden" + reason = "Authentication failed" + + +class JMAPMethodError(JMAPError): + """A JMAP method call returned an error response. + + RFC 8620 §3.6.2 error types that may be set as ``error_type``: + + - ``serverError`` — unexpected server-side error + - ``unknownMethod`` — method name not recognised + - ``invalidArguments`` — bad argument types or values + - ``invalidResultReference`` — bad ``#result`` reference + - ``forbidden`` — not allowed to perform this call + - ``accountNotFound`` — ``accountId`` does not exist + - ``accountNotSupportedByMethod`` — account lacks needed capability + - ``accountReadOnly`` — account is read-only + - ``requestTooLarge`` — request exceeds server limits + - ``stateMismatch`` — ``ifInState`` check failed + - ``serverPartialFail`` — partial failure; some calls succeeded + - ``notFound`` — requested object does not exist + - ``notDraft`` — object is not in draft state + """ + + error_type = "serverError" diff --git a/caldav/jmap/methods/__init__.py b/caldav/jmap/methods/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caldav/jmap/methods/calendar.py b/caldav/jmap/methods/calendar.py new file mode 100644 index 00000000..3dcbe49a --- /dev/null +++ b/caldav/jmap/methods/calendar.py @@ -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", + ) diff --git a/caldav/jmap/objects/__init__.py b/caldav/jmap/objects/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caldav/jmap/objects/calendar.py b/caldav/jmap/objects/calendar.py new file mode 100644 index 00000000..61c9a47e --- /dev/null +++ b/caldav/jmap/objects/calendar.py @@ -0,0 +1,73 @@ +""" +JMAP Calendar object. + +Represents a JMAP Calendar resource as returned by ``Calendar/get``. +Properties are defined in the JMAP Calendars specification. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class JMAPCalendar: + """A JMAP Calendar object. + + Attributes: + id: Server-assigned calendar identifier. + name: Display name of the calendar. + description: Optional longer description. + color: Optional CSS color string (e.g. ``"#ff0000"``). + is_subscribed: Whether the user is subscribed to this calendar. + my_rights: Dict of right names → bool for the current user. + sort_order: Hint for display ordering (lower = first). + is_visible: Whether the calendar should be displayed. + """ + + id: str + name: str + description: str | None = None + color: str | None = None + is_subscribed: bool = True + my_rights: dict = field(default_factory=dict) + sort_order: int = 0 + is_visible: bool = True + + @classmethod + def from_jmap(cls, data: dict) -> "JMAPCalendar": + """Construct a JMAPCalendar from a raw JMAP Calendar JSON dict. + + Unknown keys in ``data`` are silently ignored so that forward + compatibility is maintained as the spec evolves. + """ + return cls( + id=data["id"], + name=data["name"], + description=data.get("description"), + color=data.get("color"), + is_subscribed=data.get("isSubscribed", True), + my_rights=data.get("myRights", {}), + sort_order=data.get("sortOrder", 0), + is_visible=data.get("isVisible", True), + ) + + def to_jmap(self) -> dict: + """Serialise to a JMAP Calendar JSON dict. + + Only includes fields that are non-None so that the output can be + used directly in ``Calendar/set`` create/update patches. + """ + d: dict = { + "id": self.id, + "name": self.name, + "isSubscribed": self.is_subscribed, + "myRights": self.my_rights, + "sortOrder": self.sort_order, + "isVisible": self.is_visible, + } + if self.description is not None: + d["description"] = self.description + if self.color is not None: + d["color"] = self.color + return d diff --git a/caldav/jmap/session.py b/caldav/jmap/session.py new file mode 100644 index 00000000..6db7e1b2 --- /dev/null +++ b/caldav/jmap/session.py @@ -0,0 +1,111 @@ +""" +JMAP session establishment (RFC 8620 §2). + +Fetches the Session object from /.well-known/jmap and extracts the +information needed to make subsequent API calls. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +try: + import niquests as requests +except ImportError: + import requests # type: ignore[no-redef] + +from caldav.jmap.constants import CALENDAR_CAPABILITY +from caldav.jmap.error import JMAPAuthError, JMAPCapabilityError + + +@dataclass +class Session: + """Parsed JMAP Session object (RFC 8620 §2). + + Attributes: + api_url: URL to POST method calls to. + account_id: The accountId to use for calendar method calls. + Chosen as the first account advertising the calendars capability. + state: Current session state string. + account_capabilities: Capabilities dict for the chosen account. + server_capabilities: Server-level capabilities dict. + raw: The full parsed Session JSON for anything not captured above. + """ + + api_url: str + account_id: str + state: str + account_capabilities: dict = field(default_factory=dict) + server_capabilities: dict = field(default_factory=dict) + raw: dict = field(default_factory=dict) + + +def fetch_session(url: str, auth) -> Session: + """Fetch and parse the JMAP Session object. + + Performs a GET request to ``url`` (expected to be ``/.well-known/jmap`` + or equivalent), authenticates with ``auth``, and returns a parsed + :class:`Session`. + + Args: + url: Full URL to the JMAP session endpoint. + auth: A requests-compatible auth object (e.g. HTTPBasicAuth, + HTTPBearerAuth). + + Returns: + Parsed :class:`Session` with ``api_url`` and ``account_id`` set. + + Raises: + JMAPAuthError: If the server returns HTTP 401 or 403. + JMAPCapabilityError: If no account advertises the calendars capability. + requests.HTTPError: For other non-2xx responses. + """ + response = requests.get(url, auth=auth, headers={"Accept": "application/json"}) + + if response.status_code in (401, 403): + raise JMAPAuthError( + url=url, + reason=f"HTTP {response.status_code} from session endpoint", + ) + + response.raise_for_status() + + data = response.json() + + api_url = data.get("apiUrl") + if not api_url: + raise JMAPCapabilityError( + url=url, + reason="Session response missing 'apiUrl'", + ) + + state = data.get("state", "") + server_capabilities = data.get("capabilities", {}) + accounts = data.get("accounts", {}) + + account_id = None + account_capabilities: dict = {} + for acct_id, acct_data in accounts.items(): + caps = acct_data.get("accountCapabilities", {}) + if CALENDAR_CAPABILITY in caps: + account_id = acct_id + account_capabilities = caps + break + + if account_id is None: + raise JMAPCapabilityError( + url=url, + reason=( + f"No account found with capability {CALENDAR_CAPABILITY!r}. " + f"Available accounts: {list(accounts.keys())}" + ), + ) + + return Session( + api_url=api_url, + account_id=account_id, + state=state, + account_capabilities=account_capabilities, + server_capabilities=server_capabilities, + raw=data, + ) diff --git a/tests/test_jmap_integration.py b/tests/test_jmap_integration.py new file mode 100644 index 00000000..747fc7cc --- /dev/null +++ b/tests/test_jmap_integration.py @@ -0,0 +1,100 @@ +""" +Integration tests for the caldav.jmap package against a live Cyrus IMAP server. + +These tests require the Cyrus Docker container to be running: + + docker-compose -f tests/docker-test-servers/cyrus/docker-compose.yml up -d + +If the server is not reachable on port 8802 the entire module is skipped +automatically — no failure, no noise. + +Cyrus JMAP endpoint: http://localhost:8802/.well-known/jmap +Test credentials: user1 / x +""" + +import pytest + +try: + from niquests.auth import HTTPBasicAuth +except ImportError: + from requests.auth import HTTPBasicAuth # type: ignore[no-redef] + +from caldav.jmap import JMAPClient +from caldav.jmap.constants import CALENDAR_CAPABILITY +from caldav.jmap.session import fetch_session + +# --------------------------------------------------------------------------- +# Skip the whole module if Cyrus is not reachable +# --------------------------------------------------------------------------- + +CYRUS_HOST = "localhost" +CYRUS_PORT = 8802 +JMAP_URL = f"http://{CYRUS_HOST}:{CYRUS_PORT}/.well-known/jmap" +CYRUS_USERNAME = "user1" +CYRUS_PASSWORD = "x" + + +def _cyrus_reachable() -> bool: + import socket + + try: + with socket.create_connection((CYRUS_HOST, CYRUS_PORT), timeout=2): + return True + except OSError: + return False + + +pytestmark = pytest.mark.skipif( + not _cyrus_reachable(), + reason=f"Cyrus Docker not reachable on {CYRUS_HOST}:{CYRUS_PORT} — " + "start it with: docker-compose -f tests/docker-test-servers/cyrus/docker-compose.yml up -d", +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def client(): + return JMAPClient(url=JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD) + + +@pytest.fixture(scope="module") +def session(): + return fetch_session(JMAP_URL, auth=HTTPBasicAuth(CYRUS_USERNAME, CYRUS_PASSWORD)) + + +# --------------------------------------------------------------------------- +# Session +# --------------------------------------------------------------------------- + + +class TestJMAPSessionIntegration: + def test_session_fetch_returns_api_url(self, session): + assert session.api_url + assert session.api_url.startswith("http") + + def test_session_has_account_id(self, session): + assert session.account_id + + def test_session_has_calendar_capability(self, session): + assert CALENDAR_CAPABILITY in session.account_capabilities + + +# --------------------------------------------------------------------------- +# Calendar listing +# --------------------------------------------------------------------------- + + +class TestJMAPCalendarListIntegration: + def test_list_calendars_returns_list(self, client): + calendars = client.get_calendars() + assert isinstance(calendars, list) + + def test_calendars_have_id_and_name(self, client): + calendars = client.get_calendars() + assert len(calendars) >= 1, "Expected at least one calendar on Cyrus for user1" + for cal in calendars: + assert cal.id, f"Calendar missing id: {cal}" + assert cal.name, f"Calendar has empty name: {cal}" diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py new file mode 100644 index 00000000..5811eabe --- /dev/null +++ b/tests/test_jmap_unit.py @@ -0,0 +1,465 @@ +""" +Unit tests for the caldav.jmap package. + +Rule: zero network calls, zero Docker dependency, all tests are fast. +External HTTP is mocked via unittest.mock wherever needed. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +try: + from niquests.auth import HTTPBasicAuth +except ImportError: + from requests.auth import HTTPBasicAuth # type: ignore[no-redef] + +# --------------------------------------------------------------------------- +# Shared test fixtures (module-level constants — not hardcoded inline) +# --------------------------------------------------------------------------- + +_JMAP_URL = "http://localhost:8802/.well-known/jmap" +_API_URL = "http://localhost:8802/jmap/api" +_USERNAME = "user1" +_PASSWORD = "x" + +# --------------------------------------------------------------------------- +# Error hierarchy +# --------------------------------------------------------------------------- + +from caldav.jmap.error import ( + JMAPAuthError, + JMAPCapabilityError, + JMAPError, + JMAPMethodError, +) +from caldav.lib.error import AuthorizationError, DAVError + + +class TestJMAPErrorHierarchy: + def test_jmap_error_is_dav_error(self): + assert issubclass(JMAPError, DAVError) + + def test_jmap_capability_error_is_jmap_error(self): + assert issubclass(JMAPCapabilityError, JMAPError) + + def test_jmap_auth_error_is_authorization_error(self): + assert issubclass(JMAPAuthError, AuthorizationError) + + def test_jmap_auth_error_is_jmap_error(self): + assert issubclass(JMAPAuthError, JMAPError) + + def test_jmap_method_error_is_jmap_error(self): + assert issubclass(JMAPMethodError, JMAPError) + + def test_jmap_error_default_error_type(self): + e = JMAPError() + assert e.error_type == "serverError" + + def test_jmap_error_custom_error_type(self): + e = JMAPError(error_type="unknownMethod") + assert e.error_type == "unknownMethod" + + def test_jmap_error_str_contains_type(self): + e = JMAPError(url="http://example.com", reason="boom", error_type="invalidArguments") + s = str(e) + assert "invalidArguments" in s + assert "boom" in s + assert "http://example.com" in s + + def test_jmap_capability_error_default_type(self): + e = JMAPCapabilityError() + assert e.error_type == "capabilityNotSupported" + + def test_jmap_auth_error_default_type(self): + e = JMAPAuthError() + assert e.error_type == "forbidden" + + def test_jmap_method_error_custom_type(self): + e = JMAPMethodError(error_type="stateMismatch", reason="state changed") + assert e.error_type == "stateMismatch" + assert e.reason == "state changed" + + def test_jmap_error_catchable_as_dav_error(self): + with pytest.raises(DAVError): + raise JMAPMethodError(error_type="notFound") + + def test_jmap_auth_error_catchable_as_authorization_error(self): + with pytest.raises(AuthorizationError): + raise JMAPAuthError() + + +# --------------------------------------------------------------------------- +# Session establishment +# --------------------------------------------------------------------------- + +from caldav.jmap.constants import CALENDAR_CAPABILITY +from caldav.jmap.session import Session, fetch_session + +# Minimal valid Session JSON fixture +_SESSION_JSON = { + "apiUrl": _API_URL, + "state": "state-abc", + "capabilities": { + "urn:ietf:params:jmap:core": {"maxCallsInRequest": 32}, + CALENDAR_CAPABILITY: {}, + }, + "accounts": { + _USERNAME: { + "name": f"{_USERNAME}@example.com", + "isPersonalAccount": True, + "accountCapabilities": { + CALENDAR_CAPABILITY: {}, + }, + } + }, +} + + +def _make_mock_response(json_data, status_code=200): + mock_resp = MagicMock() + mock_resp.status_code = status_code + mock_resp.json.return_value = json_data + mock_resp.raise_for_status = MagicMock() + return mock_resp + + +class TestFetchSession: + def test_parses_api_url(self): + with patch("caldav.jmap.session.requests.get") as mock_get: + mock_get.return_value = _make_mock_response(_SESSION_JSON) + session = fetch_session(_JMAP_URL, auth=None) + assert session.api_url == _API_URL + + def test_parses_account_id(self): + with patch("caldav.jmap.session.requests.get") as mock_get: + mock_get.return_value = _make_mock_response(_SESSION_JSON) + session = fetch_session(_JMAP_URL, auth=None) + assert session.account_id == _USERNAME + + def test_parses_state(self): + with patch("caldav.jmap.session.requests.get") as mock_get: + mock_get.return_value = _make_mock_response(_SESSION_JSON) + session = fetch_session(_JMAP_URL, auth=None) + assert session.state == "state-abc" + + def test_parses_account_capabilities(self): + with patch("caldav.jmap.session.requests.get") as mock_get: + mock_get.return_value = _make_mock_response(_SESSION_JSON) + session = fetch_session(_JMAP_URL, auth=None) + assert CALENDAR_CAPABILITY in session.account_capabilities + + def test_raw_is_full_response(self): + with patch("caldav.jmap.session.requests.get") as mock_get: + mock_get.return_value = _make_mock_response(_SESSION_JSON) + session = fetch_session(_JMAP_URL, auth=None) + assert session.raw == _SESSION_JSON + + def test_raises_auth_error_on_401(self): + with patch("caldav.jmap.session.requests.get") as mock_get: + mock_get.return_value = _make_mock_response({}, status_code=401) + with pytest.raises(JMAPAuthError): + fetch_session(_JMAP_URL, auth=None) + + def test_raises_auth_error_on_403(self): + with patch("caldav.jmap.session.requests.get") as mock_get: + mock_get.return_value = _make_mock_response({}, status_code=403) + with pytest.raises(JMAPAuthError): + fetch_session(_JMAP_URL, auth=None) + + def test_raises_capability_error_when_no_calendar_account(self): + data = dict(_SESSION_JSON) + data["accounts"] = { + _USERNAME: { + "name": f"{_USERNAME}@example.com", + "isPersonalAccount": True, + "accountCapabilities": { + "urn:ietf:params:jmap:mail": {}, # no calendars + }, + } + } + with patch("caldav.jmap.session.requests.get") as mock_get: + mock_get.return_value = _make_mock_response(data) + with pytest.raises(JMAPCapabilityError): + fetch_session(_JMAP_URL, auth=None) + + def test_raises_capability_error_when_no_accounts(self): + data = dict(_SESSION_JSON) + data["accounts"] = {} + with patch("caldav.jmap.session.requests.get") as mock_get: + mock_get.return_value = _make_mock_response(data) + with pytest.raises(JMAPCapabilityError): + fetch_session(_JMAP_URL, auth=None) + + def test_raises_capability_error_when_missing_api_url(self): + data = dict(_SESSION_JSON) + del data["apiUrl"] + with patch("caldav.jmap.session.requests.get") as mock_get: + mock_get.return_value = _make_mock_response(data) + with pytest.raises(JMAPCapabilityError): + fetch_session(_JMAP_URL, auth=None) + + def test_picks_first_calendar_capable_account(self): + data = dict(_SESSION_JSON) + data["accounts"] = { + "user_mail_only": { + "name": "mailonly@example.com", + "isPersonalAccount": True, + "accountCapabilities": {"urn:ietf:params:jmap:mail": {}}, + }, + "user_calendar": { + "name": "calendar@example.com", + "isPersonalAccount": True, + "accountCapabilities": {CALENDAR_CAPABILITY: {}}, + }, + } + with patch("caldav.jmap.session.requests.get") as mock_get: + mock_get.return_value = _make_mock_response(data) + session = fetch_session(_JMAP_URL, auth=None) + assert session.account_id == "user_calendar" + + +# --------------------------------------------------------------------------- +# Calendar domain object +# --------------------------------------------------------------------------- + +from caldav.jmap.objects.calendar import JMAPCalendar + +_CALENDAR_JSON_FULL = { + "id": "cal1", + "name": "Personal", + "description": "My personal calendar", + "color": "#3a86ff", + "isSubscribed": True, + "myRights": {"mayReadItems": True, "mayAddItems": True}, + "sortOrder": 1, + "isVisible": True, +} + +_CALENDAR_JSON_MINIMAL = { + "id": "cal2", + "name": "Work", +} + + +class TestJMAPCalendar: + def test_from_jmap_full(self): + cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_FULL) + assert cal.id == "cal1" + assert cal.name == "Personal" + assert cal.description == "My personal calendar" + assert cal.color == "#3a86ff" + assert cal.is_subscribed is True + assert cal.my_rights == {"mayReadItems": True, "mayAddItems": True} + assert cal.sort_order == 1 + assert cal.is_visible is True + + def test_from_jmap_minimal_uses_defaults(self): + cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_MINIMAL) + assert cal.id == "cal2" + assert cal.name == "Work" + assert cal.description is None + assert cal.color is None + assert cal.is_subscribed is True + assert cal.my_rights == {} + assert cal.sort_order == 0 + assert cal.is_visible is True + + def test_to_jmap_includes_required_fields(self): + cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_MINIMAL) + d = cal.to_jmap() + assert d["id"] == "cal2" + assert d["name"] == "Work" + assert "isSubscribed" in d + + def test_to_jmap_omits_none_optional_fields(self): + cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_MINIMAL) + d = cal.to_jmap() + assert "description" not in d + assert "color" not in d + + def test_to_jmap_includes_optional_when_set(self): + cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_FULL) + d = cal.to_jmap() + assert d["description"] == "My personal calendar" + assert d["color"] == "#3a86ff" + + def test_from_jmap_ignores_unknown_keys(self): + data = dict(_CALENDAR_JSON_FULL) + data["unknownFutureField"] = "something" + cal = JMAPCalendar.from_jmap(data) + assert cal.id == "cal1" + + def test_from_jmap_raises_when_name_missing(self): + with pytest.raises(KeyError): + JMAPCalendar.from_jmap({"id": "cal3"}) + + +# --------------------------------------------------------------------------- +# Calendar method builders and parsers +# --------------------------------------------------------------------------- + +from caldav.jmap.methods.calendar import ( + build_calendar_changes, + build_calendar_get, + parse_calendar_get, +) + + +class TestCalendarMethodBuilders: + def test_build_calendar_get_structure(self): + method, args, call_id = build_calendar_get("u1") + assert method == "Calendar/get" + assert args["accountId"] == "u1" + assert args["ids"] is None + assert isinstance(call_id, str) + + def test_build_calendar_get_with_ids(self): + _, args, _ = build_calendar_get("u1", ids=["cal1", "cal2"]) + assert args["ids"] == ["cal1", "cal2"] + + def test_build_calendar_get_with_properties(self): + _, args, _ = build_calendar_get("u1", properties=["id", "name"]) + assert args["properties"] == ["id", "name"] + + def test_build_calendar_get_no_properties_key_when_not_set(self): + _, args, _ = build_calendar_get("u1") + assert "properties" not in args + + def test_parse_calendar_get_returns_calendars(self): + response_args = {"list": [_CALENDAR_JSON_FULL, _CALENDAR_JSON_MINIMAL]} + cals = parse_calendar_get(response_args) + assert len(cals) == 2 + assert isinstance(cals[0], JMAPCalendar) + assert cals[0].id == "cal1" + assert cals[1].id == "cal2" + + def test_parse_calendar_get_empty_list(self): + cals = parse_calendar_get({"list": []}) + assert cals == [] + + def test_parse_calendar_get_missing_list_key(self): + cals = parse_calendar_get({}) + assert cals == [] + + def test_build_calendar_changes_structure(self): + method, args, call_id = build_calendar_changes("u1", "state-abc") + assert method == "Calendar/changes" + assert args["accountId"] == "u1" + assert args["sinceState"] == "state-abc" + assert isinstance(call_id, str) + + +# --------------------------------------------------------------------------- +# JMAPClient +# --------------------------------------------------------------------------- + +from caldav.jmap.client import JMAPClient + +_CALENDAR_GET_RESPONSE = { + "methodResponses": [ + [ + "Calendar/get", + { + "accountId": _USERNAME, + "state": "cal-state-1", + "list": [_CALENDAR_JSON_FULL, _CALENDAR_JSON_MINIMAL], + "notFound": [], + }, + "cal-get-0", + ] + ] +} + + +def _make_client_with_mocked_session(monkeypatch, api_response_json): + """Return a JMAPClient whose HTTP calls are fully mocked.""" + client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) + client._session_cache = Session( + api_url=_API_URL, + account_id=_USERNAME, + state="state-abc", + ) + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = api_response_json + mock_resp.raise_for_status = MagicMock() + monkeypatch.setattr("caldav.jmap.client.requests.post", lambda *a, **kw: mock_resp) + return client + + +class TestJMAPClient: + def test_build_auth_basic_when_username_given(self): + client = JMAPClient(url="http://x", username="u", password="p") + assert isinstance(client._auth, HTTPBasicAuth) + + def test_build_auth_bearer_when_no_username(self): + from caldav.requests import HTTPBearerAuth + client = JMAPClient(url="http://x", password="token") + assert isinstance(client._auth, HTTPBearerAuth) + + def test_build_auth_raises_when_no_credentials(self): + with pytest.raises(JMAPAuthError): + JMAPClient(url="http://x") + + def test_build_auth_explicit_bearer_type(self): + from caldav.requests import HTTPBearerAuth + client = JMAPClient(url="http://x", username="u", password="token", + auth_type="bearer") + assert isinstance(client._auth, HTTPBearerAuth) + + def test_build_auth_unsupported_type_raises(self): + with pytest.raises(JMAPAuthError): + JMAPClient(url="http://x", username="u", password="p", auth_type="digest") + + def test_build_auth_basic_without_username_raises(self): + with pytest.raises(JMAPAuthError): + JMAPClient(url="http://x", password="p", auth_type="basic") + + def test_build_auth_basic_without_password_raises(self): + with pytest.raises(JMAPAuthError): + JMAPClient(url="http://x", username="u", auth_type="basic") + + def test_build_auth_bearer_without_token_raises(self): + with pytest.raises(JMAPAuthError): + JMAPClient(url="http://x", username="u", auth_type="bearer") + + def test_get_calendars_returns_list(self, monkeypatch): + client = _make_client_with_mocked_session(monkeypatch, _CALENDAR_GET_RESPONSE) + cals = client.get_calendars() + assert len(cals) == 2 + assert isinstance(cals[0], JMAPCalendar) + assert cals[0].id == "cal1" + assert cals[1].id == "cal2" + + def test_get_calendars_empty_response(self, monkeypatch): + empty_response = { + "methodResponses": [ + ["Calendar/get", {"accountId": _USERNAME, "state": "s1", "list": []}, "c0"] + ] + } + client = _make_client_with_mocked_session(monkeypatch, empty_response) + assert client.get_calendars() == [] + + def test_request_raises_auth_error_on_401(self, monkeypatch): + client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) + client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="s") + + mock_resp = MagicMock() + mock_resp.status_code = 401 + mock_resp.raise_for_status = MagicMock() + monkeypatch.setattr("caldav.jmap.client.requests.post", lambda *a, **kw: mock_resp) + + with pytest.raises(JMAPAuthError): + client._request([("Calendar/get", {"accountId": _USERNAME, "ids": None}, "c0")]) + + def test_request_raises_method_error_on_error_response(self, monkeypatch): + error_response = { + "methodResponses": [ + ["error", {"type": "unknownMethod"}, "c0"] + ] + } + client = _make_client_with_mocked_session(monkeypatch, error_response) + with pytest.raises(JMAPMethodError) as exc_info: + client._request([("Calendar/get", {"accountId": _USERNAME}, "c0")]) + assert exc_info.value.error_type == "unknownMethod" From 7d14f94155ea37ec1f466fe4de82add685a89f18 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Thu, 19 Feb 2026 23:41:01 +0530 Subject: [PATCH 02/31] style: apply ruff formatting fixes --- caldav/jmap/objects/calendar.py | 2 +- tests/test_jmap_unit.py | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/caldav/jmap/objects/calendar.py b/caldav/jmap/objects/calendar.py index 61c9a47e..fe1aa7a3 100644 --- a/caldav/jmap/objects/calendar.py +++ b/caldav/jmap/objects/calendar.py @@ -35,7 +35,7 @@ class JMAPCalendar: is_visible: bool = True @classmethod - def from_jmap(cls, data: dict) -> "JMAPCalendar": + def from_jmap(cls, data: dict) -> JMAPCalendar: """Construct a JMAPCalendar from a raw JMAP Calendar JSON dict. Unknown keys in ``data`` are silently ignored so that forward diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index 5811eabe..1d99a846 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -395,6 +395,7 @@ def test_build_auth_basic_when_username_given(self): def test_build_auth_bearer_when_no_username(self): from caldav.requests import HTTPBearerAuth + client = JMAPClient(url="http://x", password="token") assert isinstance(client._auth, HTTPBearerAuth) @@ -404,8 +405,8 @@ def test_build_auth_raises_when_no_credentials(self): def test_build_auth_explicit_bearer_type(self): from caldav.requests import HTTPBearerAuth - client = JMAPClient(url="http://x", username="u", password="token", - auth_type="bearer") + + client = JMAPClient(url="http://x", username="u", password="token", auth_type="bearer") assert isinstance(client._auth, HTTPBearerAuth) def test_build_auth_unsupported_type_raises(self): @@ -454,11 +455,7 @@ def test_request_raises_auth_error_on_401(self, monkeypatch): client._request([("Calendar/get", {"accountId": _USERNAME, "ids": None}, "c0")]) def test_request_raises_method_error_on_error_response(self, monkeypatch): - error_response = { - "methodResponses": [ - ["error", {"type": "unknownMethod"}, "c0"] - ] - } + error_response = {"methodResponses": [["error", {"type": "unknownMethod"}, "c0"]]} client = _make_client_with_mocked_session(monkeypatch, error_response) with pytest.raises(JMAPMethodError) as exc_info: client._request([("Calendar/get", {"accountId": _USERNAME}, "c0")]) From edb000920a7f50e29cbfc15773e8f5302ce15521 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Thu, 19 Feb 2026 23:44:57 +0530 Subject: [PATCH 03/31] feat(jmap): add context manager support to JMAPClient --- caldav/jmap/client.py | 6 ++++++ tests/test_jmap_unit.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py index 39981e7d..535f422b 100644 --- a/caldav/jmap/client.py +++ b/caldav/jmap/client.py @@ -72,6 +72,12 @@ def __init__( else: self._auth = self._build_auth(auth_type) + def __enter__(self) -> JMAPClient: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + return None + def _build_auth(self, auth_type: str | None): """Select and construct the auth object. diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index 1d99a846..effb11bd 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -389,6 +389,10 @@ def _make_client_with_mocked_session(monkeypatch, api_response_json): class TestJMAPClient: + def test_context_manager(self): + with JMAPClient(url="http://x", username="u", password="p") as client: + assert isinstance(client, JMAPClient) + def test_build_auth_basic_when_username_given(self): client = JMAPClient(url="http://x", username="u", password="p") assert isinstance(client._auth, HTTPBasicAuth) From d32d8da9644a6a16d0852bcbd018f03f553de2ab Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Thu, 19 Feb 2026 23:47:05 +0530 Subject: [PATCH 04/31] fix(jmap): correct RFC attributions in constants.py; add get_jmap_client tests --- caldav/jmap/constants.py | 8 ++++---- tests/test_jmap_unit.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/caldav/jmap/constants.py b/caldav/jmap/constants.py index 2051b929..6b2554a9 100644 --- a/caldav/jmap/constants.py +++ b/caldav/jmap/constants.py @@ -1,15 +1,15 @@ """ -JMAP capability URN constants (RFC 8620, JMAP Calendars spec, RFC 9553). +JMAP capability URN constants. All JMAP capability strings are defined here so they are never duplicated across the package. Every other module should import from this file. """ -#: Core JMAP capability — required in every ``using`` declaration. +#: Core JMAP capability (RFC 8620) — required in every ``using`` declaration. CORE_CAPABILITY = "urn:ietf:params:jmap:core" -#: RFC 8620 JMAP Calendars capability. +#: JMAP Calendars capability (JMAP Calendars specification). CALENDAR_CAPABILITY = "urn:ietf:params:jmap:calendars" -#: RFC 9553 JMAP Tasks capability. +#: JMAP Tasks capability (JMAP Tasks specification). TASK_CAPABILITY = "urn:ietf:params:jmap:tasks" diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index effb11bd..d255cf6f 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -464,3 +464,32 @@ def test_request_raises_method_error_on_error_response(self, monkeypatch): with pytest.raises(JMAPMethodError) as exc_info: client._request([("Calendar/get", {"accountId": _USERNAME}, "c0")]) assert exc_info.value.error_type == "unknownMethod" + + +# --------------------------------------------------------------------------- +# get_jmap_client factory +# --------------------------------------------------------------------------- + +from caldav.jmap import get_jmap_client + + +class TestGetJMAPClient: + def test_returns_client_with_explicit_params(self): + client = get_jmap_client(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) + assert isinstance(client, JMAPClient) + assert client.url == _JMAP_URL + + def test_returns_none_when_no_config(self, monkeypatch): + monkeypatch.delenv("CALDAV_URL", raising=False) + client = get_jmap_client(check_config_file=False, environment=False) + assert client is None + + def test_strips_caldav_only_keys(self, monkeypatch): + client = get_jmap_client( + url=_JMAP_URL, + username=_USERNAME, + password=_PASSWORD, + ssl_verify_cert=True, + ) + assert isinstance(client, JMAPClient) + assert not hasattr(client, "ssl_verify_cert") From 06c6221a1f53b844b69965caa9322de56df907ac Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 07:24:57 +0530 Subject: [PATCH 05/31] fix(jmap): exclude server-set fields from JMAPCalendar.to_jmap() --- caldav/jmap/objects/calendar.py | 9 +- tests/test_jmap_unit.py | 376 +++++++++++++++++++++++++++++++- 2 files changed, 379 insertions(+), 6 deletions(-) diff --git a/caldav/jmap/objects/calendar.py b/caldav/jmap/objects/calendar.py index fe1aa7a3..817b347a 100644 --- a/caldav/jmap/objects/calendar.py +++ b/caldav/jmap/objects/calendar.py @@ -53,16 +53,15 @@ def from_jmap(cls, data: dict) -> JMAPCalendar: ) def to_jmap(self) -> dict: - """Serialise to a JMAP Calendar JSON dict. + """Serialise to a JMAP Calendar JSON dict for ``Calendar/set``. - Only includes fields that are non-None so that the output can be - used directly in ``Calendar/set`` create/update patches. + ``id`` and ``myRights`` are intentionally excluded — both are + server-set and must not appear in create or update payloads. + Optional fields are included only when they hold a non-default value. """ d: dict = { - "id": self.id, "name": self.name, "isSubscribed": self.is_subscribed, - "myRights": self.my_rights, "sortOrder": self.sort_order, "isVisible": self.is_visible, } diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index d255cf6f..327cb072 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -268,10 +268,15 @@ def test_from_jmap_minimal_uses_defaults(self): def test_to_jmap_includes_required_fields(self): cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_MINIMAL) d = cal.to_jmap() - assert d["id"] == "cal2" assert d["name"] == "Work" assert "isSubscribed" in d + def test_to_jmap_excludes_server_set_fields(self): + cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_FULL) + d = cal.to_jmap() + assert "id" not in d + assert "myRights" not in d + def test_to_jmap_omits_none_optional_fields(self): cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_MINIMAL) d = cal.to_jmap() @@ -493,3 +498,372 @@ def test_strips_caldav_only_keys(self, monkeypatch): ) assert isinstance(client, JMAPClient) assert not hasattr(client, "ssl_verify_cert") + + +# --------------------------------------------------------------------------- +# CalendarEvent domain object +# --------------------------------------------------------------------------- + +from caldav.jmap.objects.event import JMAPEvent + +_EVENT_JSON_FULL = { + "id": "ev1", + "uid": "abc123@example.com", + "calendarIds": {"cal1": True}, + "title": "Team standup", + "start": "2024-06-15T09:00:00", + "timeZone": "Europe/Berlin", + "duration": "PT30M", + "showWithoutTime": False, + "description": "Daily sync", + "locations": {"loc1": {"name": "Room A"}}, + "virtualLocations": {"vl1": {"name": "Zoom", "uri": "https://zoom.us/j/123"}}, + "links": {"lnk1": {"href": "https://example.com/doc.pdf", "rel": "enclosure"}}, + "keywords": {"standup": True, "work": True}, + "participants": { + "p1": {"name": "Alice", "email": "alice@example.com", "roles": {"owner": True}}, + "p2": {"name": "Bob", "email": "bob@example.com", "roles": {"attendee": True}}, + }, + "recurrenceRules": [{"frequency": "weekly", "byDay": [{"day": "mo"}]}], + "excludedRecurrenceRules": [], + "recurrenceOverrides": {"2024-06-22T09:00:00": None}, + "alerts": {"al1": {"trigger": "-PT15M", "action": "display"}}, + "useDefaultAlerts": False, + "sequence": 3, + "freeBusyStatus": "busy", + "privacy": "private", + "color": "#ff6b6b", + "isDraft": False, + "priority": 5, +} + +_EVENT_JSON_MINIMAL = { + "id": "ev2", + "uid": "def456@example.com", + "calendarIds": {"cal1": True}, + "title": "Dentist", + "start": "2024-07-01T14:00:00", +} + + +class TestJMAPEvent: + def test_from_jmap_full(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_FULL) + assert ev.id == "ev1" + assert ev.uid == "abc123@example.com" + assert ev.calendar_ids == {"cal1": True} + assert ev.title == "Team standup" + assert ev.start == "2024-06-15T09:00:00" + assert ev.time_zone == "Europe/Berlin" + assert ev.duration == "PT30M" + assert ev.show_without_time is False + assert ev.description == "Daily sync" + assert ev.locations == {"loc1": {"name": "Room A"}} + assert ev.virtual_locations == {"vl1": {"name": "Zoom", "uri": "https://zoom.us/j/123"}} + assert ev.links == {"lnk1": {"href": "https://example.com/doc.pdf", "rel": "enclosure"}} + assert ev.keywords == {"standup": True, "work": True} + assert ev.participants["p1"]["roles"] == {"owner": True} + assert ev.recurrence_rules == [{"frequency": "weekly", "byDay": [{"day": "mo"}]}] + assert ev.recurrence_overrides == {"2024-06-22T09:00:00": None} + assert ev.alerts == {"al1": {"trigger": "-PT15M", "action": "display"}} + assert ev.use_default_alerts is False + assert ev.sequence == 3 + assert ev.free_busy_status == "busy" + assert ev.privacy == "private" + assert ev.color == "#ff6b6b" + assert ev.is_draft is False + assert ev.priority == 5 + + def test_from_jmap_minimal_uses_defaults(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_MINIMAL) + assert ev.id == "ev2" + assert ev.uid == "def456@example.com" + assert ev.calendar_ids == {"cal1": True} + assert ev.title == "Dentist" + assert ev.start == "2024-07-01T14:00:00" + assert ev.time_zone is None + assert ev.duration == "P0D" + assert ev.show_without_time is False + assert ev.description is None + assert ev.locations == {} + assert ev.virtual_locations == {} + assert ev.links == {} + assert ev.keywords == {} + assert ev.participants == {} + assert ev.recurrence_rules == [] + assert ev.excluded_recurrence_rules == [] + assert ev.recurrence_overrides == {} + assert ev.alerts == {} + assert ev.use_default_alerts is False + assert ev.sequence == 0 + assert ev.free_busy_status == "busy" + assert ev.privacy is None + assert ev.color is None + assert ev.is_draft is False + assert ev.priority == 0 + + def test_from_jmap_raises_when_required_field_missing(self): + for missing_key in ("id", "uid", "calendarIds", "title", "start"): + data = dict(_EVENT_JSON_MINIMAL) + del data[missing_key] + with pytest.raises(KeyError): + JMAPEvent.from_jmap(data) + + def test_from_jmap_ignores_unknown_keys(self): + data = dict(_EVENT_JSON_MINIMAL) + data["unknownFutureField"] = "ignored" + ev = JMAPEvent.from_jmap(data) + assert ev.id == "ev2" + + def test_to_jmap_excludes_id(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_MINIMAL) + d = ev.to_jmap() + assert "id" not in d + + def test_to_jmap_includes_required_fields(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_MINIMAL) + d = ev.to_jmap() + assert d["uid"] == "def456@example.com" + assert d["calendarIds"] == {"cal1": True} + assert d["title"] == "Dentist" + assert d["start"] == "2024-07-01T14:00:00" + assert d["duration"] == "P0D" + assert "showWithoutTime" in d + assert "sequence" in d + assert "freeBusyStatus" in d + assert "useDefaultAlerts" in d + + def test_to_jmap_omits_none_optional_fields(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_MINIMAL) + d = ev.to_jmap() + assert "timeZone" not in d + assert "description" not in d + assert "privacy" not in d + assert "color" not in d + assert "isDraft" not in d + + def test_to_jmap_omits_empty_collections(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_MINIMAL) + d = ev.to_jmap() + assert "locations" not in d + assert "virtualLocations" not in d + assert "links" not in d + assert "keywords" not in d + assert "participants" not in d + assert "recurrenceRules" not in d + assert "excludedRecurrenceRules" not in d + assert "recurrenceOverrides" not in d + assert "alerts" not in d + + def test_to_jmap_includes_optional_when_set(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_FULL) + d = ev.to_jmap() + assert d["timeZone"] == "Europe/Berlin" + assert d["description"] == "Daily sync" + assert d["locations"] == {"loc1": {"name": "Room A"}} + assert d["participants"]["p1"]["roles"] == {"owner": True} + assert d["recurrenceRules"] == [{"frequency": "weekly", "byDay": [{"day": "mo"}]}] + assert d["alerts"] == {"al1": {"trigger": "-PT15M", "action": "display"}} + assert d["privacy"] == "private" + assert d["color"] == "#ff6b6b" + + def test_to_jmap_isDraft_included_when_true(self): + data = dict(_EVENT_JSON_MINIMAL) + data["isDraft"] = True + ev = JMAPEvent.from_jmap(data) + d = ev.to_jmap() + assert d["isDraft"] is True + + def test_to_jmap_isDraft_omitted_when_false(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_MINIMAL) + d = ev.to_jmap() + assert "isDraft" not in d + + def test_participant_roles_is_map_not_list(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_FULL) + roles = ev.participants["p1"]["roles"] + assert isinstance(roles, dict) + assert roles.get("owner") is True + + def test_recurrence_overrides_null_value_preserved(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_FULL) + assert ev.recurrence_overrides["2024-06-22T09:00:00"] is None + + def test_alert_trigger_is_string(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_FULL) + trigger = ev.alerts["al1"]["trigger"] + assert isinstance(trigger, str) + assert trigger == "-PT15M" + + +# --------------------------------------------------------------------------- +# CalendarEvent method builders and parsers +# --------------------------------------------------------------------------- + +from caldav.jmap.methods.event import ( + build_event_changes, + build_event_get, + build_event_query, + build_event_query_changes, + build_event_set_create, + build_event_set_destroy, + build_event_set_update, + parse_event_get, + parse_event_query, + parse_event_set, +) + + +class TestEventMethodBuilders: + def test_build_event_get_structure(self): + method, args, call_id = build_event_get("u1") + assert method == "CalendarEvent/get" + assert args["accountId"] == "u1" + assert args["ids"] is None + assert isinstance(call_id, str) + + def test_build_event_get_with_ids(self): + _, args, _ = build_event_get("u1", ids=["ev1", "ev2"]) + assert args["ids"] == ["ev1", "ev2"] + + def test_build_event_get_with_properties(self): + _, args, _ = build_event_get("u1", properties=["id", "title", "start"]) + assert args["properties"] == ["id", "title", "start"] + + def test_build_event_get_no_properties_key_when_not_set(self): + _, args, _ = build_event_get("u1") + assert "properties" not in args + + def test_parse_event_get_returns_events(self): + response_args = {"list": [_EVENT_JSON_FULL, _EVENT_JSON_MINIMAL]} + events = parse_event_get(response_args) + assert len(events) == 2 + assert isinstance(events[0], JMAPEvent) + assert events[0].id == "ev1" + assert events[1].id == "ev2" + + def test_parse_event_get_empty_list(self): + assert parse_event_get({"list": []}) == [] + + def test_parse_event_get_missing_list_key(self): + assert parse_event_get({}) == [] + + def test_build_event_changes_structure(self): + method, args, call_id = build_event_changes("u1", "state-abc") + assert method == "CalendarEvent/changes" + assert args["accountId"] == "u1" + assert args["sinceState"] == "state-abc" + assert isinstance(call_id, str) + + def test_build_event_changes_with_max_changes(self): + _, args, _ = build_event_changes("u1", "state-abc", max_changes=50) + assert args["maxChanges"] == 50 + + def test_build_event_changes_no_max_changes_key_when_not_set(self): + _, args, _ = build_event_changes("u1", "state-abc") + assert "maxChanges" not in args + + def test_build_event_query_structure(self): + method, args, call_id = build_event_query("u1") + assert method == "CalendarEvent/query" + assert args["accountId"] == "u1" + assert args["position"] == 0 + assert isinstance(call_id, str) + + def test_build_event_query_with_filter(self): + f = {"after": "2024-01-01T00:00:00Z", "before": "2024-12-31T23:59:59Z"} + _, args, _ = build_event_query("u1", filter=f) + assert args["filter"] == f + + def test_build_event_query_with_sort(self): + s = [{"property": "start", "isAscending": True}] + _, args, _ = build_event_query("u1", sort=s) + assert args["sort"] == s + + def test_build_event_query_with_limit(self): + _, args, _ = build_event_query("u1", limit=100) + assert args["limit"] == 100 + + def test_build_event_query_no_optional_keys_when_not_set(self): + _, args, _ = build_event_query("u1") + assert "filter" not in args + assert "sort" not in args + assert "limit" not in args + + def test_parse_event_query_returns_ids_state_total(self): + response_args = { + "ids": ["ev1", "ev2", "ev3"], + "queryState": "qstate-1", + "total": 10, + } + ids, query_state, total = parse_event_query(response_args) + assert ids == ["ev1", "ev2", "ev3"] + assert query_state == "qstate-1" + assert total == 10 + + def test_parse_event_query_total_defaults_to_ids_length(self): + response_args = {"ids": ["ev1", "ev2"], "queryState": "q1"} + ids, _, total = parse_event_query(response_args) + assert total == 2 + + def test_parse_event_query_empty_response(self): + ids, query_state, total = parse_event_query({}) + assert ids == [] + assert query_state == "" + assert total == 0 + + def test_build_event_query_changes_structure(self): + method, args, call_id = build_event_query_changes("u1", "qstate-1") + assert method == "CalendarEvent/queryChanges" + assert args["accountId"] == "u1" + assert args["sinceQueryState"] == "qstate-1" + assert isinstance(call_id, str) + + def test_build_event_query_changes_with_filter_and_sort(self): + f = {"calendarIds": {"cal1": True}} + s = [{"property": "start", "isAscending": True}] + _, args, _ = build_event_query_changes("u1", "qstate-1", filter=f, sort=s) + assert args["filter"] == f + assert args["sort"] == s + + def test_build_event_set_create_structure(self): + ev = JMAPEvent.from_jmap(_EVENT_JSON_MINIMAL) + method, args, call_id = build_event_set_create("u1", {"new-1": ev}) + assert method == "CalendarEvent/set" + assert "create" in args + assert "new-1" in args["create"] + assert "id" not in args["create"]["new-1"] + + def test_build_event_set_update_structure(self): + method, args, call_id = build_event_set_update("u1", {"ev1": {"title": "Updated title"}}) + assert method == "CalendarEvent/set" + assert args["update"] == {"ev1": {"title": "Updated title"}} + + def test_build_event_set_destroy_structure(self): + method, args, call_id = build_event_set_destroy("u1", ["ev1", "ev2"]) + assert method == "CalendarEvent/set" + assert args["destroy"] == ["ev1", "ev2"] + + def test_parse_event_set_created(self): + response_args = { + "created": {"new-1": {"id": "server-ev-99", "uid": "def456@example.com"}}, + "updated": None, + "destroyed": None, + } + created, updated, destroyed = parse_event_set(response_args) + assert created["new-1"]["id"] == "server-ev-99" + assert updated == {} + assert destroyed == [] + + def test_parse_event_set_destroyed(self): + response_args = {"created": None, "updated": None, "destroyed": ["ev1", "ev2"]} + created, updated, destroyed = parse_event_set(response_args) + assert created == {} + assert updated == {} + assert destroyed == ["ev1", "ev2"] + + def test_parse_event_set_empty_response(self): + created, updated, destroyed = parse_event_set({}) + assert created == {} + assert updated == {} + assert destroyed == [] From 9add0b7946469412ebefc358188cc57bdf6e9262 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 08:23:57 +0530 Subject: [PATCH 06/31] feat(jmap): add JMAPEvent dataclass and CalendarEvent method builders --- caldav/jmap/methods/event.py | 252 +++++++++++++++++++++++++++++++++++ caldav/jmap/objects/event.py | 186 ++++++++++++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 caldav/jmap/methods/event.py create mode 100644 caldav/jmap/objects/event.py diff --git a/caldav/jmap/methods/event.py b/caldav/jmap/methods/event.py new file mode 100644 index 00000000..90154f7e --- /dev/null +++ b/caldav/jmap/methods/event.py @@ -0,0 +1,252 @@ +""" +JMAP CalendarEvent 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), +§3.6 (query), §3.7 (queryChanges); CalendarEvent-specific properties are +defined in the JMAP Calendars specification. +""" + +from __future__ import annotations + +from caldav.jmap.objects.event import JMAPEvent + + +def build_event_get( + account_id: str, + ids: list[str] | None = None, + properties: list[str] | None = None, +) -> tuple: + """Build a ``CalendarEvent/get`` method call tuple. + + Args: + account_id: The JMAP accountId to query. + ids: List of event IDs to fetch, or ``None`` to fetch all. + properties: List of property names to return, or ``None`` for all. + + Returns: + A 3-tuple ``("CalendarEvent/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 ("CalendarEvent/get", args, "ev-get-0") + + +def parse_event_get(response_args: dict) -> list[JMAPEvent]: + """Parse the arguments dict from a ``CalendarEvent/get`` method response. + + Args: + response_args: The second element of a ``methodResponses`` entry + whose method name is ``"CalendarEvent/get"``. + + Returns: + List of :class:`~caldav.jmap.objects.event.JMAPEvent` objects. + Returns an empty list if ``"list"`` is absent or empty. + """ + return [JMAPEvent.from_jmap(item) for item in response_args.get("list", [])] + + +def build_event_changes( + account_id: str, + since_state: str, + max_changes: int | None = None, +) -> tuple: + """Build a ``CalendarEvent/changes`` method call tuple. + + Args: + account_id: The JMAP accountId to query. + since_state: The ``state`` string from a previous ``CalendarEvent/get`` + or ``CalendarEvent/changes`` response. + max_changes: Optional upper bound on the number of changes returned. + The server may return fewer. + + Returns: + A 3-tuple ``("CalendarEvent/changes", arguments_dict, call_id)``. + """ + args: dict = {"accountId": account_id, "sinceState": since_state} + if max_changes is not None: + args["maxChanges"] = max_changes + return ("CalendarEvent/changes", args, "ev-changes-0") + + +def build_event_query( + account_id: str, + filter: dict | None = None, + sort: list[dict] | None = None, + position: int = 0, + limit: int | None = None, +) -> tuple: + """Build a ``CalendarEvent/query`` method call tuple. + + Args: + account_id: The JMAP accountId to query. + filter: A ``FilterCondition`` or ``FilterOperator`` dict, e.g. + ``{"after": "2024-01-01T00:00:00Z", "before": "2024-12-31T23:59:59Z"}``. + ``None`` means no filter (return all events). + sort: List of ``Comparator`` dicts, e.g. + ``[{"property": "start", "isAscending": True}]``. + ``None`` means server default ordering. + position: Zero-based index of the first result to return. + limit: Maximum number of IDs to return. ``None`` means no limit. + + Returns: + A 3-tuple ``("CalendarEvent/query", arguments_dict, call_id)``. + """ + args: dict = {"accountId": account_id, "position": position} + if filter is not None: + args["filter"] = filter + if sort is not None: + args["sort"] = sort + if limit is not None: + args["limit"] = limit + return ("CalendarEvent/query", args, "ev-query-0") + + +def parse_event_query(response_args: dict) -> tuple[list[str], str, int]: + """Parse the arguments dict from a ``CalendarEvent/query`` response. + + Args: + response_args: The second element of a ``methodResponses`` entry + whose method name is ``"CalendarEvent/query"``. + + Returns: + A 3-tuple ``(ids, query_state, total)``: + + - ``ids``: Ordered list of matching event IDs. + - ``query_state``: Opaque state string for use with + ``CalendarEvent/queryChanges``. + - ``total``: Total number of matching events (may exceed ``len(ids)`` + when a limit was applied). + """ + ids: list[str] = response_args.get("ids", []) + query_state: str = response_args.get("queryState", "") + total: int = response_args.get("total", len(ids)) + return ids, query_state, total + + +def build_event_query_changes( + account_id: str, + since_query_state: str, + filter: dict | None = None, + sort: list[dict] | None = None, + max_changes: int | None = None, +) -> tuple: + """Build a ``CalendarEvent/queryChanges`` method call tuple. + + Args: + account_id: The JMAP accountId to query. + since_query_state: The ``queryState`` string from a previous + ``CalendarEvent/query`` or ``CalendarEvent/queryChanges`` response. + filter: Same filter as the original ``CalendarEvent/query`` call. + sort: Same sort as the original ``CalendarEvent/query`` call. + max_changes: Optional upper bound on the number of changes returned. + + Returns: + A 3-tuple ``("CalendarEvent/queryChanges", arguments_dict, call_id)``. + """ + args: dict = {"accountId": account_id, "sinceQueryState": since_query_state} + if filter is not None: + args["filter"] = filter + if sort is not None: + args["sort"] = sort + if max_changes is not None: + args["maxChanges"] = max_changes + return ("CalendarEvent/queryChanges", args, "ev-qchanges-0") + + +def build_event_set_create( + account_id: str, + events: dict[str, JMAPEvent], +) -> tuple: + """Build a ``CalendarEvent/set`` method call for creating events. + + Args: + account_id: The JMAP accountId. + events: Map of client-assigned creation ID → :class:`JMAPEvent`. + The creation IDs are ephemeral — they are used to correlate + server responses with individual creation requests within the + same batch call. + + Returns: + A 3-tuple ``("CalendarEvent/set", arguments_dict, call_id)``. + """ + return ( + "CalendarEvent/set", + { + "accountId": account_id, + "create": {cid: ev.to_jmap() for cid, ev in events.items()}, + }, + "ev-set-create-0", + ) + + +def build_event_set_update( + account_id: str, + updates: dict[str, dict], +) -> tuple: + """Build a ``CalendarEvent/set`` method call for updating events. + + Args: + account_id: The JMAP accountId. + updates: Map of event ID → partial patch dict. Keys are property + names (or JSON Pointer paths for nested properties); values are + the new values. Use ``None`` as a value to reset a property to + its server default. + + Returns: + A 3-tuple ``("CalendarEvent/set", arguments_dict, call_id)``. + """ + return ( + "CalendarEvent/set", + {"accountId": account_id, "update": updates}, + "ev-set-update-0", + ) + + +def build_event_set_destroy( + account_id: str, + ids: list[str], +) -> tuple: + """Build a ``CalendarEvent/set`` method call for destroying events. + + Args: + account_id: The JMAP accountId. + ids: List of event IDs to destroy. + + Returns: + A 3-tuple ``("CalendarEvent/set", arguments_dict, call_id)``. + """ + return ( + "CalendarEvent/set", + {"accountId": account_id, "destroy": ids}, + "ev-set-destroy-0", + ) + + +def parse_event_set(response_args: dict) -> tuple[dict, dict, list[str]]: + """Parse the arguments dict from a ``CalendarEvent/set`` method response. + + Args: + response_args: The second element of a ``methodResponses`` entry + whose method name is ``"CalendarEvent/set"``. + + Returns: + A 3-tuple ``(created, updated, destroyed)``: + + - ``created``: Map of creation ID → server-assigned event dict + (includes the new ``id`` and any server-set properties). + Empty dict if no creates were requested or all failed. + - ``updated``: Map of event ID → ``null`` (per RFC 8620) or a + partial object with server-updated properties. + Empty dict if no updates were requested or all failed. + - ``destroyed``: List of successfully destroyed event IDs. + """ + created: dict = response_args.get("created") or {} + updated: dict = response_args.get("updated") or {} + destroyed: list[str] = response_args.get("destroyed") or [] + return created, updated, destroyed diff --git a/caldav/jmap/objects/event.py b/caldav/jmap/objects/event.py new file mode 100644 index 00000000..d4e47024 --- /dev/null +++ b/caldav/jmap/objects/event.py @@ -0,0 +1,186 @@ +""" +JMAP CalendarEvent object. + +Represents a JMAP CalendarEvent resource as returned by ``CalendarEvent/get``. +Properties follow RFC 8984 (JSCalendar) extended with JMAP-specific additions +defined in the JMAP Calendars specification. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class JMAPEvent: + """A JMAP CalendarEvent object. + + Fields map directly to the JMAP Calendars specification. Only fields + that are stored on the server are represented here; computed properties + (``utcStart``, ``utcEnd``, ``baseEventId``) and server-assigned metadata + (``created``, ``updated``, ``isOrigin``) are not stored as attributes + because they are either read-only or fetched on demand. + + Attributes: + id: Server-assigned event identifier (immutable, scoped to account). + uid: iCalendar UID — stable across copies and scheduling messages. + calendar_ids: Map of calendarId → ``True`` for each calendar + containing this event. An event may belong to multiple calendars. + title: Short summary. Maps to iCalendar ``SUMMARY``. + start: Local start date-time, e.g. ``"2024-06-15T14:30:00"``. + No timezone offset in the string — context comes from + ``time_zone``. + time_zone: IANA timezone name for interpreting ``start`` + (e.g. ``"Europe/Berlin"``). ``None`` means the event is + "floating" — it occurs at the same wall-clock time in every zone. + duration: ISO 8601 duration, e.g. ``"PT1H30M"``. Default ``"P0D"`` + (zero duration / instantaneous event). + show_without_time: Presentation hint — when ``True`` the time + component is not important to display. Equivalent to the + all-day concept in iCalendar. Does not affect scheduling + semantics. + description: Full plain-text description. Maps to iCalendar + ``DESCRIPTION``. + locations: Map of location id → location dict. + virtual_locations: Map of virtual location id → virtual-location dict + (video-conference links, etc.). + links: Map of link id → link dict (attachments, related URLs). + keywords: Map of free-form tag string → ``True``. Maps to iCalendar + ``CATEGORIES``. + participants: Map of participant id → participant dict. The + participant with ``"roles": {"owner": True}`` or + ``"roles": {"organizer": True}`` is the organizer. + recurrence_rules: List of recurrence-rule dicts. Each dict uses + JSCalendar ``RecurrenceRule`` structure (JSON, not RFC 5545 RRULE + text syntax). + excluded_recurrence_rules: List of exclusion recurrence-rule dicts. + recurrence_overrides: Map of LocalDateTime string → patch dict. + An empty patch ``{}`` adds the occurrence without changes; a + partial patch modifies individual properties via JSON Pointer + paths; a ``null`` value cancels that occurrence. + alerts: Map of alert id → alert dict. ``trigger`` is a + ``SignedDuration`` (e.g. ``"-PT15M"``) or a ``UTCDateTime`` + string. ``action`` is ``"display"`` or ``"email"``. + use_default_alerts: When ``True``, the server's default alerts + are applied instead of ``alerts``. + sequence: Scheduling sequence number. Maps to iCalendar + ``SEQUENCE``. + free_busy_status: ``"busy"`` (default) or ``"free"``. + privacy: Optional visibility/privacy level string. + color: Optional CSS color hint for calendar clients. + is_draft: When ``True``, the server suppresses scheduling messages. + """ + + id: str + uid: str + calendar_ids: dict + title: str + start: str + time_zone: str | None = None + duration: str = "P0D" + show_without_time: bool = False + description: str | None = None + locations: dict = field(default_factory=dict) + virtual_locations: dict = field(default_factory=dict) + links: dict = field(default_factory=dict) + keywords: dict = field(default_factory=dict) + participants: dict = field(default_factory=dict) + recurrence_rules: list = field(default_factory=list) + excluded_recurrence_rules: list = field(default_factory=list) + recurrence_overrides: dict = field(default_factory=dict) + alerts: dict = field(default_factory=dict) + use_default_alerts: bool = False + sequence: int = 0 + free_busy_status: str = "busy" + privacy: str | None = None + color: str | None = None + is_draft: bool = False + priority: int = 0 + + @classmethod + def from_jmap(cls, data: dict) -> JMAPEvent: + """Construct a JMAPEvent from a raw JMAP CalendarEvent JSON dict. + + ``id``, ``uid``, ``calendarIds``, ``title``, and ``start`` are + required in server responses; a missing key raises ``KeyError``. + Unknown keys are silently ignored for forward compatibility. + """ + return cls( + id=data["id"], + uid=data["uid"], + calendar_ids=data["calendarIds"], + title=data["title"], + start=data["start"], + time_zone=data.get("timeZone"), + duration=data.get("duration", "P0D"), + show_without_time=data.get("showWithoutTime", False), + description=data.get("description"), + locations=data.get("locations", {}), + virtual_locations=data.get("virtualLocations", {}), + links=data.get("links", {}), + keywords=data.get("keywords", {}), + participants=data.get("participants", {}), + recurrence_rules=data.get("recurrenceRules", []), + excluded_recurrence_rules=data.get("excludedRecurrenceRules", []), + recurrence_overrides=data.get("recurrenceOverrides", {}), + alerts=data.get("alerts", {}), + use_default_alerts=data.get("useDefaultAlerts", False), + sequence=data.get("sequence", 0), + free_busy_status=data.get("freeBusyStatus", "busy"), + privacy=data.get("privacy"), + color=data.get("color"), + is_draft=data.get("isDraft", False), + priority=data.get("priority", 0), + ) + + def to_jmap(self) -> dict: + """Serialise to a JMAP CalendarEvent JSON dict for ``CalendarEvent/set``. + + Always includes the fields required for a valid create payload. + Optional fields are included only when they hold a non-default value, + keeping the payload minimal. + + Note: ``id`` is intentionally excluded — it is server-assigned on + create and not sent in the request body. + """ + d: dict = { + "uid": self.uid, + "calendarIds": self.calendar_ids, + "title": self.title, + "start": self.start, + "duration": self.duration, + "showWithoutTime": self.show_without_time, + "sequence": self.sequence, + "freeBusyStatus": self.free_busy_status, + "useDefaultAlerts": self.use_default_alerts, + "priority": self.priority, + } + if self.time_zone is not None: + d["timeZone"] = self.time_zone + if self.description is not None: + d["description"] = self.description + if self.locations: + d["locations"] = self.locations + if self.virtual_locations: + d["virtualLocations"] = self.virtual_locations + if self.links: + d["links"] = self.links + if self.keywords: + d["keywords"] = self.keywords + if self.participants: + d["participants"] = self.participants + if self.recurrence_rules: + d["recurrenceRules"] = self.recurrence_rules + if self.excluded_recurrence_rules: + d["excludedRecurrenceRules"] = self.excluded_recurrence_rules + if self.recurrence_overrides: + d["recurrenceOverrides"] = self.recurrence_overrides + if self.alerts: + d["alerts"] = self.alerts + if self.privacy is not None: + d["privacy"] = self.privacy + if self.color is not None: + d["color"] = self.color + if self.is_draft: + d["isDraft"] = self.is_draft + return d From d8a2c3062627eea4902c5679c0dee1b6ebd24f72 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 09:48:59 +0530 Subject: [PATCH 07/31] fix(jmap): resolve relative apiUrl in fetch_session against base URL --- caldav/jmap/session.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/caldav/jmap/session.py b/caldav/jmap/session.py index 6db7e1b2..0712fc20 100644 --- a/caldav/jmap/session.py +++ b/caldav/jmap/session.py @@ -8,6 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from urllib.parse import urljoin try: import niquests as requests @@ -79,6 +80,10 @@ def fetch_session(url: str, auth) -> Session: reason="Session response missing 'apiUrl'", ) + # RFC 8620 §2 says apiUrl SHOULD be absolute, but some servers (e.g. Cyrus) + # return a relative path. Resolve it against the session endpoint URL. + api_url = urljoin(url, api_url) + state = data.get("state", "") server_capabilities = data.get("capabilities", {}) accounts = data.get("accounts", {}) From 4295d244d1ace96fa4a8bb117e3e0d610e25aaea Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 10:59:22 +0530 Subject: [PATCH 08/31] style(jmap): remove redundant section-label comment from JMAPClient --- caldav/jmap/client.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py index 535f422b..b59f531a 100644 --- a/caldav/jmap/client.py +++ b/caldav/jmap/client.py @@ -179,10 +179,6 @@ def _request(self, method_calls: list[tuple]) -> list: return method_responses - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - def get_calendars(self) -> list[JMAPCalendar]: """Fetch all calendars for the authenticated account. From cbad191a34ca7848d81a34dec43705aa13e0b0b1 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 10:59:41 +0530 Subject: [PATCH 09/31] fix(jmap): handle null collection fields in JMAPEvent.from_jmap; expand parse_event_set to 6-tuple --- caldav/jmap/methods/event.py | 24 ++++++++++++++---------- caldav/jmap/objects/event.py | 18 +++++++++--------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/caldav/jmap/methods/event.py b/caldav/jmap/methods/event.py index 90154f7e..d8125aec 100644 --- a/caldav/jmap/methods/event.py +++ b/caldav/jmap/methods/event.py @@ -228,7 +228,9 @@ def build_event_set_destroy( ) -def parse_event_set(response_args: dict) -> tuple[dict, dict, list[str]]: +def parse_event_set( + response_args: dict, +) -> tuple[dict, dict, list[str], dict, dict, list[str]]: """Parse the arguments dict from a ``CalendarEvent/set`` method response. Args: @@ -236,17 +238,19 @@ def parse_event_set(response_args: dict) -> tuple[dict, dict, list[str]]: whose method name is ``"CalendarEvent/set"``. Returns: - A 3-tuple ``(created, updated, destroyed)``: - - - ``created``: Map of creation ID → server-assigned event dict - (includes the new ``id`` and any server-set properties). - Empty dict if no creates were requested or all failed. - - ``updated``: Map of event ID → ``null`` (per RFC 8620) or a - partial object with server-updated properties. - Empty dict if no updates were requested or all failed. + A 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``: + + - ``created``: Map of creation ID → server-assigned event dict. + - ``updated``: Map of event ID → null or partial server-updated object. - ``destroyed``: List of successfully destroyed event IDs. + - ``not_created``: Map of creation ID → SetError dict for failed creates. + - ``not_updated``: Map of event ID → SetError dict for failed updates. + - ``not_destroyed``: Map of event ID → SetError dict for failed destroys. """ created: dict = response_args.get("created") or {} updated: dict = response_args.get("updated") or {} destroyed: list[str] = response_args.get("destroyed") or [] - return created, updated, destroyed + 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 diff --git a/caldav/jmap/objects/event.py b/caldav/jmap/objects/event.py index d4e47024..5da11e36 100644 --- a/caldav/jmap/objects/event.py +++ b/caldav/jmap/objects/event.py @@ -115,15 +115,15 @@ def from_jmap(cls, data: dict) -> JMAPEvent: duration=data.get("duration", "P0D"), show_without_time=data.get("showWithoutTime", False), description=data.get("description"), - locations=data.get("locations", {}), - virtual_locations=data.get("virtualLocations", {}), - links=data.get("links", {}), - keywords=data.get("keywords", {}), - participants=data.get("participants", {}), - recurrence_rules=data.get("recurrenceRules", []), - excluded_recurrence_rules=data.get("excludedRecurrenceRules", []), - recurrence_overrides=data.get("recurrenceOverrides", {}), - alerts=data.get("alerts", {}), + locations=data.get("locations") or {}, + virtual_locations=data.get("virtualLocations") or {}, + links=data.get("links") or {}, + keywords=data.get("keywords") or {}, + participants=data.get("participants") or {}, + recurrence_rules=data.get("recurrenceRules") or [], + excluded_recurrence_rules=data.get("excludedRecurrenceRules") or [], + recurrence_overrides=data.get("recurrenceOverrides") or {}, + alerts=data.get("alerts") or {}, use_default_alerts=data.get("useDefaultAlerts", False), sequence=data.get("sequence", 0), free_busy_status=data.get("freeBusyStatus", "busy"), From b7605ddd9e5ebade8874374d713094e78f73a704 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 11:00:07 +0530 Subject: [PATCH 10/31] =?UTF-8?q?feat(jmap):=20add=20iCalendar=20=E2=86=94?= =?UTF-8?q?=20JSCalendar=20conversion=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- caldav/jmap/convert/__init__.py | 12 + caldav/jmap/convert/_utils.py | 132 ++++++ caldav/jmap/convert/ical_to_jscal.py | 458 ++++++++++++++++++ caldav/jmap/convert/jscal_to_ical.py | 424 +++++++++++++++++ tests/test_jmap_unit.py | 680 ++++++++++++++++++++++++++- 5 files changed, 1703 insertions(+), 3 deletions(-) create mode 100644 caldav/jmap/convert/_utils.py create mode 100644 caldav/jmap/convert/ical_to_jscal.py create mode 100644 caldav/jmap/convert/jscal_to_ical.py diff --git a/caldav/jmap/convert/__init__.py b/caldav/jmap/convert/__init__.py index e69de29b..76c231d6 100644 --- a/caldav/jmap/convert/__init__.py +++ b/caldav/jmap/convert/__init__.py @@ -0,0 +1,12 @@ +""" +JSCalendar ↔ iCalendar conversion utilities. + +Public API: + ical_to_jscal(ical_str, calendar_id=None) -> dict + jscal_to_ical(jscal) -> str +""" + +from caldav.jmap.convert.ical_to_jscal import ical_to_jscal +from caldav.jmap.convert.jscal_to_ical import jscal_to_ical + +__all__ = ["ical_to_jscal", "jscal_to_ical"] diff --git a/caldav/jmap/convert/_utils.py b/caldav/jmap/convert/_utils.py new file mode 100644 index 00000000..4fa0e4ea --- /dev/null +++ b/caldav/jmap/convert/_utils.py @@ -0,0 +1,132 @@ +""" +Shared datetime and duration utilities for JSCalendar ↔ iCalendar conversion. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + + +def _timedelta_to_duration(td: timedelta) -> str: + """Convert a timedelta to an ISO 8601 duration string. + + Examples: + timedelta(hours=1, minutes=30) → "PT1H30M" + timedelta(days=1, hours=2) → "P1DT2H" + timedelta(0) → "P0D" + timedelta(seconds=-900) → "-PT15M" + + Args: + td: The duration to convert. + + Returns: + ISO 8601 duration string, always positive or negative prefix, + never fractional components. + """ + total_seconds = int(td.total_seconds()) + sign = "-" if total_seconds < 0 else "" + total_seconds = abs(total_seconds) + + days, rem = divmod(total_seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes, seconds = divmod(rem, 60) + + day_part = f"{days}D" if days else "" + time_parts = [] + if hours: + time_parts.append(f"{hours}H") + if minutes: + time_parts.append(f"{minutes}M") + if seconds: + time_parts.append(f"{seconds}S") + + time_part = ("T" + "".join(time_parts)) if time_parts else "" + + body = day_part + time_part or "0D" + return f"{sign}P{body}" + + +def _duration_to_timedelta(duration_str: str) -> timedelta: + """Parse an ISO 8601 duration string into a timedelta. + + Handles the subset used in JSCalendar: P[nW][nD][T[nH][nM][nS]]. + Does not handle months or years (JSCalendar uses recurrenceRules for those). + + Examples: + "PT1H30M" → timedelta(hours=1, minutes=30) + "P1DT2H" → timedelta(days=1, hours=2) + "P0D" → timedelta(0) + "-PT15M" → timedelta(seconds=-900) + + Args: + duration_str: ISO 8601 duration string. + + Returns: + Equivalent timedelta. + + Raises: + ValueError: If the string cannot be parsed. + """ + s = duration_str.strip() + sign = 1 + if s.startswith("-"): + sign = -1 + s = s[1:] + elif s.startswith("+"): + s = s[1:] + + if not s.startswith("P"): + raise ValueError(f"Invalid duration string: {duration_str!r}") + s = s[1:] + + weeks = days = hours = minutes = seconds = 0 + + if "T" in s: + date_part, time_part = s.split("T", 1) + else: + date_part, time_part = s, "" + + if date_part: + if "W" in date_part: + w, date_part = date_part.split("W", 1) + weeks = int(w) + if "D" in date_part: + d, _ = date_part.split("D", 1) + days = int(d) + + if time_part: + remaining = time_part + if "H" in remaining: + h, remaining = remaining.split("H", 1) + hours = int(h) + if "M" in remaining: + m, remaining = remaining.split("M", 1) + minutes = int(m) + if "S" in remaining: + sec_str, _ = remaining.split("S", 1) + seconds = int(float(sec_str)) # truncate fractional seconds to whole seconds + + td = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds) + return sign * td + + +def _format_local_dt(dt: datetime | date) -> str: + """Format a datetime or date as a JSCalendar LocalDateTime or UTCDateTime string. + + JSCalendar uses: + - LocalDateTime: "2024-03-15T09:00:00" (no TZ suffix) + - UTCDateTime: "2024-03-15T09:00:00Z" (uppercase Z) + + For date objects (all-day), uses T00:00:00 suffix. + + Args: + dt: A datetime (with or without tzinfo) or a date. + + Returns: + Formatted string suitable for use as a JSCalendar override key or datetime value. + """ + if isinstance(dt, datetime): + if dt.tzinfo is not None and dt.utcoffset() == timedelta(0): + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + return dt.strftime("%Y-%m-%dT%H:%M:%S") + return f"{dt.isoformat()}T00:00:00" diff --git a/caldav/jmap/convert/ical_to_jscal.py b/caldav/jmap/convert/ical_to_jscal.py new file mode 100644 index 00000000..9ef998d5 --- /dev/null +++ b/caldav/jmap/convert/ical_to_jscal.py @@ -0,0 +1,458 @@ +""" +iCalendar → JSCalendar conversion (RFC 5545 → RFC 8984). + +Public API: + ical_to_jscal(ical_str, calendar_id=None) -> dict + +The output dict is a raw JSCalendar CalendarEvent object suitable for passing +directly to CalendarEvent/set, or to JMAPEvent.from_jmap() to get a dataclass. +""" + +from __future__ import annotations + +import uuid +from datetime import date, datetime, timedelta + +import icalendar + +from caldav.jmap.convert._utils import _format_local_dt, _timedelta_to_duration +from caldav.lib import vcal + +_CLASS_MAP = { + "PRIVATE": "private", + "CONFIDENTIAL": "secret", +} + +_PARTSTAT_MAP = { + "NEEDS-ACTION": "needs-action", + "ACCEPTED": "accepted", + "DECLINED": "declined", + "TENTATIVE": "tentative", + "DELEGATED": "delegated", +} + +_CUTYPE_MAP = { + "INDIVIDUAL": "individual", + "GROUP": "group", + "RESOURCE": "resource", + "ROOM": "room", +} + +_BYDAY_ABBR = {"SU", "MO", "TU", "WE", "TH", "FR", "SA"} + + +def _dtstart_to_jscal(dtstart_prop) -> tuple[str, str | None, bool]: + """Extract JSCalendar start, timeZone, showWithoutTime from a DTSTART property. + + Returns: + (start_str, time_zone, show_without_time) + """ + dt = dtstart_prop.dt + + if isinstance(dt, date) and not isinstance(dt, datetime): + # VALUE=DATE — all-day event + return f"{dt.isoformat()}T00:00:00", None, True + + if dt.tzinfo is not None and dt.utcoffset() == timedelta(0): + # UTC (Z suffix) + return dt.strftime("%Y-%m-%dT%H:%M:%SZ"), None, False + + if dt.tzinfo is not None: + # Timezone-aware — prefer the TZID parameter (IANA name) over tzinfo repr + # NOTE: non-IANA TZIDs (e.g. "Eastern Standard Time" from Outlook) + # are passed through unchanged; mapping to IANA is out of scope. + tz_str = dtstart_prop.params.get("TZID") + return dt.strftime("%Y-%m-%dT%H:%M:%S"), tz_str, False + + # Floating (no timezone) + return dt.strftime("%Y-%m-%dT%H:%M:%S"), None, False + + +def _rrule_to_jscal(rrule_prop) -> dict: + """Convert an iCalendar RRULE property to a JSCalendar RecurrenceRule dict. + + Always emits @type, interval, rscale, skip, firstDayOfWeek to match the + fields Cyrus returns — makes round-trip comparison predictable. + """ + rule: dict = { + "@type": "RecurrenceRule", + "rscale": "gregorian", + "skip": "omit", + } + + freq_list = rrule_prop.get("FREQ", []) + if not freq_list: + raise ValueError(f"RRULE is missing required FREQ component: {rrule_prop!r}") + rule["frequency"] = freq_list[0].lower() + + interval_list = rrule_prop.get("INTERVAL", []) + rule["interval"] = int(interval_list[0]) if interval_list else 1 + + wkst_list = rrule_prop.get("WKST", []) + rule["firstDayOfWeek"] = wkst_list[0].lower() if wkst_list else "mo" + + count_list = rrule_prop.get("COUNT", []) + if count_list: + rule["count"] = int(count_list[0]) + + until_list = rrule_prop.get("UNTIL", []) + if until_list: + rule["until"] = _format_local_dt(until_list[0]) + + byday_list = rrule_prop.get("BYDAY", []) + if byday_list: + by_day = [] + for item in byday_list: + s = str(item) + day_abbr = s.lstrip("+-0123456789") + nth_str = s[: len(s) - len(day_abbr)] + nday: dict = {"@type": "NDay", "day": day_abbr.lower()} + if nth_str: + nday["nthOfPeriod"] = int(nth_str) + by_day.append(nday) + rule["byDay"] = by_day + + bymonth_list = rrule_prop.get("BYMONTH", []) + if bymonth_list: + rule["byMonth"] = [str(m) for m in bymonth_list] + + bymonthday = rrule_prop.get("BYMONTHDAY", []) + if bymonthday: + rule["byMonthDay"] = [int(d) for d in bymonthday] + + byyearday = rrule_prop.get("BYYEARDAY", []) + if byyearday: + rule["byYearDay"] = [int(d) for d in byyearday] + + byweekno = rrule_prop.get("BYWEEKNO", []) + if byweekno: + rule["byWeekNo"] = [int(n) for n in byweekno] + + byhour = rrule_prop.get("BYHOUR", []) + if byhour: + rule["byHour"] = [int(h) for h in byhour] + byminute = rrule_prop.get("BYMINUTE", []) + if byminute: + rule["byMinute"] = [int(m) for m in byminute] + bysecond = rrule_prop.get("BYSECOND", []) + if bysecond: + rule["bySecond"] = [int(s) for s in bysecond] + + bysetpos = rrule_prop.get("BYSETPOS", []) + if bysetpos: + rule["bySetPosition"] = [int(p) for p in bysetpos] + + return rule + + +def _exdate_to_overrides(exdate_prop) -> dict: + """Convert an EXDATE property (single or list) to recurrenceOverrides entries. + + Returns: + Dict mapping LocalDateTime/UTCDateTime string → {"excluded": True} + """ + # EXDATE may be a single vDDDLists or a list of them + if not isinstance(exdate_prop, list): + exdate_prop = [exdate_prop] + + overrides: dict = {} + for ex in exdate_prop: + dts = getattr(ex, "dts", [ex]) + for dt_prop in dts: + dt = getattr(dt_prop, "dt", dt_prop) + overrides[_format_local_dt(dt)] = {"excluded": True} + return overrides + + +def _organizer_to_participant(organizer) -> tuple[str, dict]: + """Convert an ORGANIZER property to a (participant_id, Participant dict) tuple.""" + email = str(organizer).removeprefix("mailto:") + pid = str(uuid.uuid4()) + p: dict = { + "roles": {"owner": True, "organizer": True}, + "sendTo": { + "imip": str(organizer) if str(organizer).startswith("mailto:") else f"mailto:{email}" + }, + } + cn = organizer.params.get("CN") + if cn: + p["name"] = str(cn) + p["email"] = email + return pid, p + + +def _attendee_to_participant(attendee) -> tuple[str, dict]: + """Convert an ATTENDEE property to a (participant_id, Participant dict) tuple.""" + addr = str(attendee) + email = addr.removeprefix("mailto:") + pid = str(uuid.uuid4()) + p: dict = { + "roles": {"attendee": True}, + "sendTo": {"imip": addr if addr.startswith("mailto:") else f"mailto:{email}"}, + "email": email, + } + cn = attendee.params.get("CN") + if cn: + p["name"] = str(cn) + + partstat = attendee.params.get("PARTSTAT") + if partstat: + p["participationStatus"] = _PARTSTAT_MAP.get(partstat.upper(), partstat.lower()) + + rsvp = attendee.params.get("RSVP", "") + if str(rsvp).upper() == "TRUE": + p["expectReply"] = True + + cutype = attendee.params.get("CUTYPE") + if cutype: + p["kind"] = _CUTYPE_MAP.get(cutype.upper(), cutype.lower()) + + role = attendee.params.get("ROLE") + if role and role.upper() == "CHAIR": + p["roles"]["chair"] = True + + return pid, p + + +def _valarm_to_alert(alarm) -> tuple[str, dict]: + """Convert a VALARM component to a (alert_id, Alert dict) tuple. + + Trigger is emitted as a plain SignedDuration string (e.g. "-PT15M") or + UTCDateTime string — matching the JMAPEvent.alerts docstring convention. + """ + alert_id = str(uuid.uuid4()) + action = str(alarm.get("ACTION", "display")).lower() + alert: dict = {"action": action} + + trigger_prop = alarm.get("TRIGGER") + if trigger_prop is not None: + trigger_val = trigger_prop.dt + if isinstance(trigger_val, timedelta): + # Relative trigger — convert to SignedDuration string + alert["trigger"] = _timedelta_to_duration(trigger_val) + elif isinstance(trigger_val, datetime): + # Absolute trigger — UTCDateTime string + alert["trigger"] = trigger_val.strftime("%Y-%m-%dT%H:%M:%SZ") + + description = alarm.get("DESCRIPTION") + if description: + alert["description"] = str(description) + + return alert_id, alert + + +def _location_str_to_jscal(location_str: str) -> dict: + """Convert a LOCATION string to a JSCalendar locations map entry. + + Returns: + {"": {"name": location_str}} + """ + return {str(uuid.uuid4()): {"name": location_str}} + + +def _categories_to_keywords(categories_prop) -> dict: + """Convert a CATEGORIES property to a JSCalendar keywords map. + + icalendar returns one of three types depending on how CATEGORIES appears: + - vCategory (single CATEGORIES line, possibly multi-value): access .cats + - list of vCategory (multiple CATEGORIES lines): flatten .cats from each + - vText (rare, single bare string value): str() and comma-split + """ + if hasattr(categories_prop, "cats"): + values = [str(c) for c in categories_prop.cats] + elif isinstance(categories_prop, list): + values = [] + for item in categories_prop: + if hasattr(item, "cats"): + values.extend(str(c) for c in item.cats) + else: + values.append(str(item)) + else: + raw = str(categories_prop) + values = [v.strip() for v in raw.split(",") if v.strip()] + + return {v: True for v in values} + + +def ical_to_jscal(ical_str: str, calendar_id: str | None = None) -> dict: + """Convert an iCalendar string to a JSCalendar CalendarEvent dict (RFC 8984). + + Processes the first VEVENT found in the string. Any sibling VEVENTs with a + RECURRENCE-ID are folded into the ``recurrenceOverrides`` map of the master + event. EXDATE entries are also added to ``recurrenceOverrides``. + + Args: + ical_str: A VCALENDAR string (or bare VEVENT — vcal.fix normalises it). + calendar_id: If provided, sets ``calendarIds: {calendar_id: true}`` + on the output. Required when the result will be used in + ``CalendarEvent/set`` (the server needs to know which calendar). + + Returns: + Raw JSCalendar dict suitable for passing to ``JMAPEvent.from_jmap()`` + or directly to ``CalendarEvent/set``. + + Raises: + ValueError: If no VEVENT component is found. + """ + # Normalize iCal string (fixes common server-generated violations) + fixed = vcal.fix(ical_str) + + cal = icalendar.Calendar.from_ical(fixed) + + # Split subcomponents into master VEVENTs and override VEVENTs + master: icalendar.Event | None = None + overrides_by_recurrence_id: dict[str, icalendar.Event] = {} + + for component in cal.subcomponents: + if not isinstance(component, icalendar.Event): + continue + if component.get("RECURRENCE-ID") is not None: + # Override instance — key by its recurrence-id datetime + rid = _format_local_dt(component["RECURRENCE-ID"].dt) + overrides_by_recurrence_id[rid] = component + elif master is None: + master = component + + if master is None: + raise ValueError("No VEVENT component found in iCalendar string") + + uid = str(master["UID"]) + summary = master.get("SUMMARY") + title = str(summary) if summary else "" + dtstart_prop = master["DTSTART"] + start, time_zone, show_without_time = _dtstart_to_jscal(dtstart_prop) + + if master.get("DURATION"): + duration = _timedelta_to_duration(master["DURATION"].dt) + elif master.get("DTEND"): + delta = master["DTEND"].dt - dtstart_prop.dt + duration = _timedelta_to_duration(delta) + else: + duration = "P0D" + + jscal: dict = { + "uid": uid, + "title": title, + "start": start, + "duration": duration, + } + + if calendar_id is not None: + jscal["calendarIds"] = {calendar_id: True} + + if time_zone is not None: + jscal["timeZone"] = time_zone + + if show_without_time: + jscal["showWithoutTime"] = True + + description = master.get("DESCRIPTION") + if description: + jscal["description"] = str(description) + + sequence = master.get("SEQUENCE") + if sequence is not None: + jscal["sequence"] = int(sequence) + + priority = master.get("PRIORITY") + if priority is not None: + p_int = int(priority) + if p_int != 0: + jscal["priority"] = p_int + + cls = master.get("CLASS") + if cls: + privacy = _CLASS_MAP.get(str(cls).upper()) + if privacy: + jscal["privacy"] = privacy + + transp = master.get("TRANSP") + if transp and str(transp).upper() == "TRANSPARENT": + jscal["freeBusyStatus"] = "free" + + color = master.get("COLOR") + if color: + jscal["color"] = str(color) + + categories = master.get("CATEGORIES") + if categories is not None: + kw = _categories_to_keywords(categories) + if kw: + jscal["keywords"] = kw + + location = master.get("LOCATION") + if location: + jscal["locations"] = _location_str_to_jscal(str(location)) + + participants: dict = {} + organizer = master.get("ORGANIZER") + if organizer is not None: + pid, p = _organizer_to_participant(organizer) + participants[pid] = p + + # .get() returns a single vCalAddress or a list; normalise to list + raw_attendees = master.get("ATTENDEE") + if raw_attendees is None: + attendees = [] + elif isinstance(raw_attendees, list): + attendees = raw_attendees + else: + attendees = [raw_attendees] + for attendee in attendees: + pid, p = _attendee_to_participant(attendee) + participants[pid] = p + + if participants: + jscal["participants"] = participants + + rrules = master.get("RRULE") + if rrules is not None: + if not isinstance(rrules, list): + rrules = [rrules] + jscal["recurrenceRules"] = [_rrule_to_jscal(r) for r in rrules] + + exrules = master.get("EXRULE") + if exrules is not None: + if not isinstance(exrules, list): + exrules = [exrules] + jscal["excludedRecurrenceRules"] = [_rrule_to_jscal(r) for r in exrules] + + recurrence_overrides: dict = {} + + exdate = master.get("EXDATE") + if exdate is not None: + recurrence_overrides.update(_exdate_to_overrides(exdate)) + + for rid_key, child in overrides_by_recurrence_id.items(): + # Build a patch: only fields that differ from the master + patch: dict = {} + child_summary = child.get("SUMMARY") + if child_summary and str(child_summary) != title: + patch["title"] = str(child_summary) + child_start_prop = child.get("DTSTART") + if child_start_prop: + child_start, _, _ = _dtstart_to_jscal(child_start_prop) + if child_start != start: + patch["start"] = child_start + child_duration_prop = child.get("DURATION") + if child_duration_prop: + child_dur = _timedelta_to_duration(child_duration_prop.dt) + if child_dur != duration: + patch["duration"] = child_dur + child_description = child.get("DESCRIPTION") + if child_description and str(child_description) != jscal.get("description"): + patch["description"] = str(child_description) + recurrence_overrides[rid_key] = patch or {} + + if recurrence_overrides: + jscal["recurrenceOverrides"] = recurrence_overrides + + alarms = [c for c in master.subcomponents if getattr(c, "name", None) == "VALARM"] + if alarms: + alerts: dict = {} + for alarm in alarms: + alert_id, alert = _valarm_to_alert(alarm) + alerts[alert_id] = alert + jscal["alerts"] = alerts + + return jscal diff --git a/caldav/jmap/convert/jscal_to_ical.py b/caldav/jmap/convert/jscal_to_ical.py new file mode 100644 index 00000000..cdffeaeb --- /dev/null +++ b/caldav/jmap/convert/jscal_to_ical.py @@ -0,0 +1,424 @@ +""" +JSCalendar → iCalendar conversion (RFC 8984 → RFC 5545). + +Public API: + jscal_to_ical(jscal: dict) -> str + +Accepts a raw JSCalendar CalendarEvent dict (as returned by CalendarEvent/get +or produced by ical_to_jscal). Returns a VCALENDAR string. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +import icalendar +from icalendar import vCalAddress, vText + +from caldav.jmap.convert._utils import _duration_to_timedelta, _format_local_dt +from caldav.lib import vcal + +_PRIVACY_TO_CLASS = { + "private": "PRIVATE", + "secret": "CONFIDENTIAL", +} + +_FREE_BUSY_TO_TRANSP = { + "free": "TRANSPARENT", + "busy": "OPAQUE", +} + +_PARTSTAT_MAP = { + "needs-action": "NEEDS-ACTION", + "accepted": "ACCEPTED", + "declined": "DECLINED", + "tentative": "TENTATIVE", + "delegated": "DELEGATED", +} + +_KIND_TO_CUTYPE = { + "individual": "INDIVIDUAL", + "group": "GROUP", + "resource": "RESOURCE", + "room": "ROOM", +} + + +def _start_to_dtstart( + component: icalendar.Event, + start_str: str, + time_zone: str | None, + show_without_time: bool, +) -> None: + """Add a DTSTART property to component from JSCalendar start fields. + + Handles three cases: + - All-day (showWithoutTime): VALUE=DATE + - UTC (start ends with Z): UTC DATETIME + - Timezone-aware: DATETIME;TZID=... + - Floating (no timeZone, no Z): plain DATETIME + """ + if show_without_time: + dt = date.fromisoformat(start_str[:10]) + component.add("dtstart", dt) + return + + if start_str.endswith("Z"): + dt = datetime.strptime(start_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + component.add("dtstart", dt) + return + + dt_naive = datetime.strptime(start_str[:19], "%Y-%m-%dT%H:%M:%S") + + if time_zone: + try: + import pytz # type: ignore[import] + + tz = pytz.timezone(time_zone) + dt = tz.localize(dt_naive) + component.add("dtstart", dt) + except (ImportError, Exception): + dtstart = icalendar.vDatetime(dt_naive) + dtstart.params["TZID"] = time_zone + component.add("dtstart", dtstart) + else: + component.add("dtstart", dt_naive) + + +def _jscal_rrule_to_rrule(rule: dict) -> dict: + """Convert a JSCalendar RecurrenceRule dict to an iCalendar vRecur-compatible dict. + + Strips @type and NDay @type fields — icalendar library rejects them. + Returns a plain dict suitable for icalendar.vRecur. + """ + freq = rule.get("frequency", "").upper() + if not freq: + return {} + + ical_rule: dict = {"FREQ": freq} + + interval = rule.get("interval") + if interval and interval != 1: + ical_rule["INTERVAL"] = interval + + count = rule.get("count") + if count is not None: + ical_rule["COUNT"] = count + + until = rule.get("until") + if until: + if until.endswith("Z"): + ical_rule["UNTIL"] = datetime.strptime(until, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + else: + ical_rule["UNTIL"] = datetime.strptime(until[:19], "%Y-%m-%dT%H:%M:%S") + + by_day = rule.get("byDay", []) + if by_day: + byday_strs = [] + for nday in by_day: + day = nday.get("day", "").upper() + nth = nday.get("nthOfPeriod") + if nth: + byday_strs.append(f"{nth}{day}") + else: + byday_strs.append(day) + ical_rule["BYDAY"] = byday_strs + + by_month = rule.get("byMonth", []) + if by_month: + ical_rule["BYMONTH"] = [int(m.rstrip("L")) for m in by_month] + + by_month_day = rule.get("byMonthDay", []) + if by_month_day: + ical_rule["BYMONTHDAY"] = by_month_day + + by_year_day = rule.get("byYearDay", []) + if by_year_day: + ical_rule["BYYEARDAY"] = by_year_day + + by_week_no = rule.get("byWeekNo", []) + if by_week_no: + ical_rule["BYWEEKNO"] = by_week_no + + by_hour = rule.get("byHour", []) + if by_hour: + ical_rule["BYHOUR"] = by_hour + + by_minute = rule.get("byMinute", []) + if by_minute: + ical_rule["BYMINUTE"] = by_minute + + by_second = rule.get("bySecond", []) + if by_second: + ical_rule["BYSECOND"] = by_second + + by_set_pos = rule.get("bySetPosition", []) + if by_set_pos: + ical_rule["BYSETPOS"] = by_set_pos + + first_day = rule.get("firstDayOfWeek") + if first_day and first_day != "mo": + ical_rule["WKST"] = first_day.upper() + + return ical_rule + + +def _participant_to_organizer(p: dict) -> vCalAddress | None: + """Build a vCalAddress for ORGANIZER, or None if this participant is not an organizer.""" + roles = p.get("roles", {}) + if not (roles.get("owner") or roles.get("organizer")): + return None + + send_to = p.get("sendTo", {}) + imip = send_to.get("imip") or send_to.get("other") or p.get("email", "") + if imip and not imip.startswith("mailto:"): + imip = f"mailto:{imip}" + + addr = vCalAddress(imip) + name = p.get("name") + if name: + addr.params["CN"] = vText(name) + return addr + + +def _participant_to_attendee(p: dict) -> vCalAddress | None: + """Build a vCalAddress for ATTENDEE, or None if participant is purely an organizer.""" + roles = p.get("roles", {}) + # If only owner/organizer with no attendee/chair role, don't emit an ATTENDEE line + has_attendee_role = any( + roles.get(r) for r in ("attendee", "chair", "informational", "optional") + ) + # If owner-only (pure organizer), skip + if not has_attendee_role and (roles.get("owner") or roles.get("organizer")): + return None + + send_to = p.get("sendTo", {}) + imip = send_to.get("imip") or send_to.get("other") or p.get("email", "") + if imip and not imip.startswith("mailto:"): + imip = f"mailto:{imip}" + + addr = vCalAddress(imip) + name = p.get("name") + if name: + addr.params["CN"] = vText(name) + + partstat = p.get("participationStatus") + if partstat: + addr.params["PARTSTAT"] = _PARTSTAT_MAP.get(partstat, partstat.upper()) + else: + addr.params["PARTSTAT"] = "NEEDS-ACTION" + + if p.get("expectReply"): + addr.params["RSVP"] = "TRUE" + + kind = p.get("kind") + if kind: + addr.params["CUTYPE"] = _KIND_TO_CUTYPE.get(kind, kind.upper()) + + if roles.get("chair"): + addr.params["ROLE"] = "CHAIR" + elif roles.get("attendee") or has_attendee_role: + addr.params["ROLE"] = "REQ-PARTICIPANT" + + return addr + + +def _alert_to_valarm(alert: dict) -> icalendar.Alarm: + """Convert a JSCalendar Alert dict to an icalendar.Alarm component.""" + alarm = icalendar.Alarm() + action = alert.get("action", "display").upper() + alarm.add("action", action) + + trigger_str = alert.get("trigger", "") + if trigger_str: + if trigger_str.endswith("Z"): + try: + dt = datetime.strptime(trigger_str, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + alarm.add("trigger", dt) + except ValueError: + alarm.add("trigger", timedelta(0)) + else: + try: + td = _duration_to_timedelta(trigger_str) + alarm.add("trigger", td) + except ValueError: + alarm.add("trigger", timedelta(0)) + else: + alarm.add("trigger", timedelta(0)) + + description = alert.get("description") + if description: + alarm.add("description", description) + elif action == "DISPLAY": + alarm.add("description", "Reminder") + + return alarm + + +def _keywords_to_categories(keywords: dict) -> list[str]: + """Convert JSCalendar keywords map to a list of CATEGORIES strings.""" + return [k for k, v in keywords.items() if v] + + +def _locations_to_location(locations: dict) -> str | None: + """Extract the first location name from a JSCalendar locations map.""" + for loc in locations.values(): + name = loc.get("name") + if name: + return str(name) + return None + + +def jscal_to_ical(jscal: dict) -> str: + """Convert a JSCalendar CalendarEvent dict to an iCalendar VCALENDAR string. + + Handles the full set of fields supported by ``ical_to_jscal`` for round-trip + fidelity. ``recurrenceOverrides`` entries with ``excluded: true`` become + EXDATE properties; patch dicts become child VEVENTs with RECURRENCE-ID. + + Args: + jscal: A JSCalendar CalendarEvent dict (raw, not a JMAPEvent dataclass). + + Returns: + An iCalendar VCALENDAR string, normalised by ``vcal.fix()``. + """ + cal = icalendar.Calendar() + cal.add("prodid", "-//python-caldav//JMAP//EN") + cal.add("version", "2.0") + + event = icalendar.Event() + + uid = jscal.get("uid", "") + if uid: + event.add("uid", uid) + event.add("dtstamp", datetime.now(tz=timezone.utc)) + + sequence = jscal.get("sequence", 0) + if sequence: + event.add("sequence", sequence) + + start_str = jscal.get("start", "") + time_zone = jscal.get("timeZone") + show_without_time = jscal.get("showWithoutTime", False) + if start_str: + _start_to_dtstart(event, start_str, time_zone, show_without_time) + + duration_str = jscal.get("duration", "P0D") + if duration_str and duration_str != "P0D": + td = _duration_to_timedelta(duration_str) + event.add("duration", td) + + title = jscal.get("title", "") + if title: + event.add("summary", title) + + description = jscal.get("description") + if description: + event.add("description", description) + + priority = jscal.get("priority", 0) + if priority: + event.add("priority", priority) + + privacy = jscal.get("privacy") + if privacy: + cls = _PRIVACY_TO_CLASS.get(privacy) + if cls: + event.add("class", cls) + + free_busy = jscal.get("freeBusyStatus", "busy") + transp = _FREE_BUSY_TO_TRANSP.get(free_busy, "OPAQUE") + if transp != "OPAQUE": + event.add("transp", transp) + + color = jscal.get("color") + if color: + event.add("color", color) + + keywords = jscal.get("keywords") or {} + if keywords: + cats = _keywords_to_categories(keywords) + if cats: + event.add("categories", cats) + + locations = jscal.get("locations") or {} + if locations: + loc_name = _locations_to_location(locations) + if loc_name: + event.add("location", loc_name) + + for rule in jscal.get("recurrenceRules") or []: + ical_rule = _jscal_rrule_to_rrule(rule) + if ical_rule: + event.add("rrule", ical_rule) + + for rule in jscal.get("excludedRecurrenceRules") or []: + ical_rule = _jscal_rrule_to_rrule(rule) + if ical_rule: + event.add("exrule", ical_rule) + + exdates: list[datetime | date] = [] + child_events: list[icalendar.Event] = [] + + for override_key, patch in (jscal.get("recurrenceOverrides") or {}).items(): + if override_key.endswith("Z"): + rid_dt: datetime | date = datetime.strptime(override_key, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + else: + rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S") + + if patch is None or (isinstance(patch, dict) and patch.get("excluded")): + exdates.append(rid_dt) + else: + child = icalendar.Event() + child.add("uid", uid) + child.add("dtstamp", datetime.now(tz=timezone.utc)) + child.add("recurrence-id", rid_dt) + child_start = patch.get("start", start_str) + child_tz = patch.get("timeZone", time_zone) + child_swt = patch.get("showWithoutTime", show_without_time) + if child_start: + _start_to_dtstart(child, child_start, child_tz, child_swt) + child_dur = patch.get("duration", duration_str) + if child_dur and child_dur != "P0D": + child.add("duration", _duration_to_timedelta(child_dur)) + child_title = patch.get("title", title) + if child_title: + child.add("summary", child_title) + child_desc = patch.get("description", description) + if child_desc: + child.add("description", child_desc) + child_events.append(child) + + if exdates: + for exdate_dt in exdates: + event.add("exdate", exdate_dt) + + organizer_added = False + for p in (jscal.get("participants") or {}).values(): + org = _participant_to_organizer(p) + if org and not organizer_added: + event.add("organizer", org) + organizer_added = True + + for p in (jscal.get("participants") or {}).values(): + att = _participant_to_attendee(p) + if att is not None: + event.add("attendee", att) + + for alert in (jscal.get("alerts") or {}).values(): + alarm = _alert_to_valarm(alert) + event.add_component(alarm) + + cal.add_component(event) + + for child in child_events: + cal.add_component(child) + + raw = cal.to_ical().decode("utf-8") + return vcal.fix(raw) diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index 327cb072..ded59888 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -695,6 +695,56 @@ def test_alert_trigger_is_string(self): assert isinstance(trigger, str) assert trigger == "-PT15M" + def test_from_jmap_null_fields_coerced_to_defaults(self): + # Cyrus returns null for optional collection fields instead of omitting them + data = { + "id": "ev1", + "uid": "uid1", + "calendarIds": {"Default": True}, + "title": "Test", + "start": "2024-06-15T10:00:00", + "keywords": None, + "locations": None, + "participants": None, + "alerts": None, + "recurrenceRules": None, + "excludedRecurrenceRules": None, + "recurrenceOverrides": None, + } + ev = JMAPEvent.from_jmap(data) + assert ev.keywords == {} + assert ev.locations == {} + assert ev.participants == {} + assert ev.alerts == {} + assert ev.recurrence_rules == [] + assert ev.excluded_recurrence_rules == [] + assert ev.recurrence_overrides == {} + + def test_from_jmap_explicit_empty_collections_are_empty(self): + # Server sending empty {} / [] is semantically identical to null for optional fields + data = { + "id": "ev1", + "uid": "uid1", + "calendarIds": {"Default": True}, + "title": "Test", + "start": "2024-06-15T10:00:00", + "keywords": {}, + "locations": {}, + "participants": {}, + "alerts": {}, + "recurrenceRules": [], + "excludedRecurrenceRules": [], + "recurrenceOverrides": {}, + } + ev = JMAPEvent.from_jmap(data) + assert ev.keywords == {} + assert ev.locations == {} + assert ev.participants == {} + assert ev.alerts == {} + assert ev.recurrence_rules == [] + assert ev.excluded_recurrence_rules == [] + assert ev.recurrence_overrides == {} + # --------------------------------------------------------------------------- # CalendarEvent method builders and parsers @@ -850,20 +900,644 @@ def test_parse_event_set_created(self): "updated": None, "destroyed": None, } - created, updated, destroyed = parse_event_set(response_args) + created, updated, destroyed, not_created, not_updated, not_destroyed = parse_event_set( + response_args + ) assert created["new-1"]["id"] == "server-ev-99" assert updated == {} assert destroyed == [] + assert not_created == {} + assert not_updated == {} + assert not_destroyed == {} def test_parse_event_set_destroyed(self): response_args = {"created": None, "updated": None, "destroyed": ["ev1", "ev2"]} - created, updated, destroyed = parse_event_set(response_args) + created, updated, destroyed, not_created, not_updated, not_destroyed = parse_event_set( + response_args + ) assert created == {} assert updated == {} assert destroyed == ["ev1", "ev2"] + assert not_created == {} def test_parse_event_set_empty_response(self): - created, updated, destroyed = parse_event_set({}) + created, updated, destroyed, not_created, not_updated, not_destroyed = parse_event_set({}) assert created == {} assert updated == {} assert destroyed == [] + assert not_created == {} + assert not_updated == {} + assert not_destroyed == {} + + def test_parse_event_set_partial_failure(self): + # notCreated/notUpdated/notDestroyed carry SetError objects for failed operations + response_args = { + "created": {"new-1": {"id": "server-ev-99"}}, + "notCreated": {"new-2": {"type": "invalidArguments", "description": "bad uid"}}, + "notDestroyed": {"ev-old": {"type": "notFound"}}, + } + created, updated, destroyed, not_created, not_updated, not_destroyed = parse_event_set( + response_args + ) + assert "new-1" in created + assert not_created["new-2"]["type"] == "invalidArguments" + assert not_destroyed["ev-old"]["type"] == "notFound" + + +# =========================================================================== +# iCalendar ↔ JSCalendar conversion layer +# =========================================================================== + +from datetime import date, datetime, timedelta, timezone + +import icalendar as _icalendar + +from caldav.jmap.convert import ical_to_jscal, jscal_to_ical +from caldav.jmap.convert._utils import ( + _duration_to_timedelta, + _format_local_dt, + _timedelta_to_duration, +) + +# --------------------------------------------------------------------------- +# Shared iCal fixtures +# --------------------------------------------------------------------------- + + +def _make_ical(extra_lines: str = "", uid: str = "test-uid@example.com") -> str: + return ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Test//Test//EN\r\n" + "BEGIN:VEVENT\r\n" + f"UID:{uid}\r\n" + "DTSTAMP:20240101T000000Z\r\n" + extra_lines + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + + +def _minimal_jscal(**kwargs) -> dict: + base = { + "uid": "test-uid@example.com", + "title": "Test Event", + "start": "2024-06-15T10:00:00", + "timeZone": "Europe/Berlin", + "duration": "PT1H", + } + base.update(kwargs) + return base + + +# --------------------------------------------------------------------------- +# TestUtils — shared utility functions +# --------------------------------------------------------------------------- + + +class TestUtils: + def test_timedelta_to_duration_hours(self): + assert _timedelta_to_duration(timedelta(hours=1, minutes=30)) == "PT1H30M" + + def test_timedelta_to_duration_days(self): + assert _timedelta_to_duration(timedelta(days=1)) == "P1D" + + def test_timedelta_to_duration_mixed(self): + assert _timedelta_to_duration(timedelta(days=1, hours=2)) == "P1DT2H" + + def test_timedelta_to_duration_zero(self): + assert _timedelta_to_duration(timedelta(0)) == "P0D" + + def test_timedelta_to_duration_negative(self): + assert _timedelta_to_duration(timedelta(seconds=-900)) == "-PT15M" + + def test_duration_to_timedelta_hours(self): + assert _duration_to_timedelta("PT1H30M") == timedelta(hours=1, minutes=30) + + def test_duration_to_timedelta_days(self): + assert _duration_to_timedelta("P1D") == timedelta(days=1) + + def test_duration_to_timedelta_zero(self): + assert _duration_to_timedelta("P0D") == timedelta(0) + + def test_duration_to_timedelta_negative(self): + assert _duration_to_timedelta("-PT15M") == timedelta(seconds=-900) + + def test_duration_round_trip(self): + td = timedelta(days=2, hours=3, minutes=45, seconds=30) + assert _duration_to_timedelta(_timedelta_to_duration(td)) == td + + def test_format_local_dt_utc(self): + dt = datetime(2024, 6, 15, 9, 0, 0, tzinfo=timezone.utc) + assert _format_local_dt(dt) == "2024-06-15T09:00:00Z" + + def test_format_local_dt_naive(self): + dt = datetime(2024, 6, 15, 9, 0, 0) + assert _format_local_dt(dt) == "2024-06-15T09:00:00" + + def test_format_local_dt_date(self): + d = date(2024, 6, 15) + assert _format_local_dt(d) == "2024-06-15T00:00:00" + + +# --------------------------------------------------------------------------- +# TestIcalToJscal +# --------------------------------------------------------------------------- + + +class TestIcalToJscal: + def test_minimal_event(self): + ical = _make_ical("DTSTART:20240615T100000Z\r\nDURATION:PT1H\r\nSUMMARY:Test Event\r\n") + result = ical_to_jscal(ical) + assert result["uid"] == "test-uid@example.com" + assert result["title"] == "Test Event" + assert result["start"] == "2024-06-15T10:00:00Z" + assert result["duration"] == "PT1H" + + def test_all_day_event(self): + ical = _make_ical( + "DTSTART;VALUE=DATE:20240615\r\nDTEND;VALUE=DATE:20240616\r\nSUMMARY:All Day\r\n" + ) + result = ical_to_jscal(ical) + assert result["start"] == "2024-06-15T00:00:00" + assert result["showWithoutTime"] is True + assert "timeZone" not in result + assert result["duration"] == "P1D" + + def test_timezone_aware_event(self): + ical = _make_ical( + "DTSTART;TZID=America/New_York:20240615T100000\r\nDURATION:PT1H\r\nSUMMARY:TZ Event\r\n" + ) + result = ical_to_jscal(ical) + assert result["start"] == "2024-06-15T10:00:00" + assert result["timeZone"] == "America/New_York" + assert "showWithoutTime" not in result + + def test_utc_event(self): + ical = _make_ical("DTSTART:20240615T100000Z\r\nDURATION:PT30M\r\nSUMMARY:UTC Event\r\n") + result = ical_to_jscal(ical) + assert result["start"].endswith("Z") + assert "timeZone" not in result + + def test_duration_from_dtend(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\nDTEND:20240615T113000Z\r\nSUMMARY:DTEND Event\r\n" + ) + result = ical_to_jscal(ical) + assert result["duration"] == "PT1H30M" + + def test_duration_explicit(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\nDURATION:P1DT2H\r\nSUMMARY:Duration Event\r\n" + ) + result = ical_to_jscal(ical) + assert result["duration"] == "P1DT2H" + + def test_duration_zero_when_missing(self): + ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:No Duration\r\n") + result = ical_to_jscal(ical) + assert result["duration"] == "P0D" + + def test_categories_to_keywords(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\nSUMMARY:Cat Event\r\nCATEGORIES:work,standup\r\n" + ) + result = ical_to_jscal(ical) + assert "keywords" in result + assert result["keywords"].get("work") is True + assert result["keywords"].get("standup") is True + + def test_categories_multiple_lines(self): + # Two separate CATEGORIES lines — icalendar returns a list of vCategory objects + ical = _make_ical( + "DTSTART:20240615T100000Z\r\nSUMMARY:Cat Event\r\n" + "CATEGORIES:Work\r\nCATEGORIES:Standup\r\n" + ) + result = ical_to_jscal(ical) + assert "keywords" in result + assert result["keywords"].get("Work") is True + assert result["keywords"].get("Standup") is True + + def test_location_string(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\nSUMMARY:Located Event\r\nLOCATION:Conference Room A\r\n" + ) + result = ical_to_jscal(ical) + assert "locations" in result + locs = result["locations"] + assert len(locs) == 1 + first_loc = next(iter(locs.values())) + assert first_loc["name"] == "Conference Room A" + + def test_priority(self): + ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:Priority Event\r\nPRIORITY:5\r\n") + result = ical_to_jscal(ical) + assert result["priority"] == 5 + + def test_class_private(self): + ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:Private Event\r\nCLASS:PRIVATE\r\n") + result = ical_to_jscal(ical) + assert result["privacy"] == "private" + + def test_class_confidential(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\nSUMMARY:Confidential Event\r\nCLASS:CONFIDENTIAL\r\n" + ) + result = ical_to_jscal(ical) + assert result["privacy"] == "secret" + + def test_transp_transparent(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\nSUMMARY:Free Event\r\nTRANSP:TRANSPARENT\r\n" + ) + result = ical_to_jscal(ical) + assert result["freeBusyStatus"] == "free" + + def test_rrule_weekly(self): + ical = _make_ical( + "DTSTART;TZID=Europe/Berlin:20240617T140000\r\n" + "DURATION:PT1H\r\n" + "SUMMARY:Team Meeting\r\n" + "RRULE:FREQ=WEEKLY;BYDAY=MO,WE\r\n" + ) + result = ical_to_jscal(ical) + assert "recurrenceRules" in result + rule = result["recurrenceRules"][0] + assert rule["@type"] == "RecurrenceRule" + assert rule["frequency"] == "weekly" + assert rule["interval"] == 1 + assert rule["rscale"] == "gregorian" + days = [d["day"] for d in rule["byDay"]] + assert "mo" in days + assert "we" in days + + def test_exdate(self): + ical = _make_ical( + "DTSTART;TZID=Europe/Berlin:20240617T140000\r\n" + "DURATION:PT1H\r\n" + "SUMMARY:Recurring\r\n" + "RRULE:FREQ=WEEKLY\r\n" + "EXDATE;TZID=Europe/Berlin:20240624T140000\r\n" + ) + result = ical_to_jscal(ical) + assert "recurrenceOverrides" in result + overrides = result["recurrenceOverrides"] + assert any(v == {"excluded": True} for v in overrides.values()) + + def test_valarm_relative(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\n" + "SUMMARY:Alarm Event\r\n" + "BEGIN:VALARM\r\n" + "ACTION:DISPLAY\r\n" + "TRIGGER:-PT15M\r\n" + "DESCRIPTION:Reminder\r\n" + "END:VALARM\r\n" + ) + result = ical_to_jscal(ical) + assert "alerts" in result + alert = next(iter(result["alerts"].values())) + assert alert["trigger"] == "-PT15M" + assert alert["action"] == "display" + + def test_valarm_absolute(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\n" + "SUMMARY:Abs Alarm Event\r\n" + "BEGIN:VALARM\r\n" + "ACTION:DISPLAY\r\n" + "TRIGGER;VALUE=DATE-TIME:20240615T093000Z\r\n" + "DESCRIPTION:Reminder\r\n" + "END:VALARM\r\n" + ) + result = ical_to_jscal(ical) + assert "alerts" in result + alert = next(iter(result["alerts"].values())) + assert alert["trigger"].endswith("Z") + + def test_organizer_attendee(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\n" + "SUMMARY:Meeting\r\n" + "ORGANIZER;CN=Alice:mailto:alice@example.com\r\n" + "ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED:mailto:bob@example.com\r\n" + ) + result = ical_to_jscal(ical) + assert "participants" in result + participants = result["participants"] + # Find organizer + organizer = next( + (p for p in participants.values() if p.get("roles", {}).get("owner")), None + ) + assert organizer is not None + assert organizer["roles"].get("organizer") is True + # Find attendee + attendee = next( + (p for p in participants.values() if p.get("roles", {}).get("attendee")), None + ) + assert attendee is not None + + def test_attendee_partstat(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\n" + "SUMMARY:Meeting\r\n" + "ATTENDEE;PARTSTAT=DECLINED:mailto:bob@example.com\r\n" + ) + result = ical_to_jscal(ical) + attendee = next(iter(result["participants"].values())) + assert attendee["participationStatus"] == "declined" + + def test_calendar_id_set(self): + ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:Cal Event\r\n") + result = ical_to_jscal(ical, calendar_id="Default") + assert result["calendarIds"] == {"Default": True} + + def test_no_calendar_id_omits_key(self): + ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:No Cal\r\n") + result = ical_to_jscal(ical) + assert "calendarIds" not in result + + def test_floating_datetime(self): + ical = _make_ical("DTSTART:20240615T100000\r\nDURATION:PT1H\r\nSUMMARY:Floating\r\n") + result = ical_to_jscal(ical) + assert result["start"] == "2024-06-15T10:00:00" + assert "timeZone" not in result + assert result.get("showWithoutTime") is not True + + def test_recurrence_id_child_vevent(self): + ical = ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Test//Test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:recur-uid@example.com\r\n" + "DTSTAMP:20240101T000000Z\r\n" + "DTSTART:20240617T140000Z\r\n" + "DURATION:PT1H\r\n" + "SUMMARY:Weekly Meeting\r\n" + "RRULE:FREQ=WEEKLY\r\n" + "END:VEVENT\r\n" + "BEGIN:VEVENT\r\n" + "UID:recur-uid@example.com\r\n" + "DTSTAMP:20240101T000000Z\r\n" + "RECURRENCE-ID:20240624T140000Z\r\n" + "DTSTART:20240624T160000Z\r\n" + "DURATION:PT2H\r\n" + "SUMMARY:Rescheduled Meeting\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + result = ical_to_jscal(ical) + assert "recurrenceOverrides" in result + overrides = result["recurrenceOverrides"] + assert len(overrides) == 1 + key = next(iter(overrides)) + patch = overrides[key] + assert isinstance(patch, dict) + assert patch.get("excluded") is not True + assert patch.get("title") == "Rescheduled Meeting" + + def test_color_and_sequence(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\nSUMMARY:Colored\r\nCOLOR:red\r\nSEQUENCE:3\r\n" + ) + result = ical_to_jscal(ical) + assert result.get("color") == "red" + assert result.get("sequence") == 3 + + def test_rrule_missing_freq_raises(self): + ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:Bad RRULE\r\nRRULE:INTERVAL=2\r\n") + with pytest.raises((ValueError, Exception)): + ical_to_jscal(ical) + + +# --------------------------------------------------------------------------- +# TestJscalToIcal +# --------------------------------------------------------------------------- + + +class TestJscalToIcal: + def test_minimal_event(self): + jscal = _minimal_jscal() + result = jscal_to_ical(jscal) + assert "BEGIN:VCALENDAR" in result + assert "BEGIN:VEVENT" in result + assert "SUMMARY:Test Event" in result + assert "UID:test-uid@example.com" in result + + def test_all_day_event(self): + jscal = _minimal_jscal( + start="2024-06-15T00:00:00", + showWithoutTime=True, + duration="P1D", + ) + del jscal["timeZone"] + result = jscal_to_ical(jscal) + assert "DTSTART;VALUE=DATE:20240615" in result + + def test_timezone_aware_event(self): + jscal = _minimal_jscal(start="2024-06-15T10:00:00", timeZone="Europe/Berlin") + result = jscal_to_ical(jscal) + assert "DTSTART;TZID=Europe/Berlin:" in result + + def test_utc_event(self): + jscal = _minimal_jscal(start="2024-06-15T10:00:00Z") + del jscal["timeZone"] + result = jscal_to_ical(jscal) + assert "20240615T100000Z" in result + + def test_duration(self): + jscal = _minimal_jscal(duration="PT2H30M") + result = jscal_to_ical(jscal) + assert "DURATION:PT2H30M" in result + + def test_keywords_to_categories(self): + jscal = _minimal_jscal(keywords={"work": True, "standup": True}) + result = jscal_to_ical(jscal) + assert "CATEGORIES" in result + assert "work" in result or "standup" in result + + def test_location(self): + jscal = _minimal_jscal(locations={"loc1": {"name": "Room A"}}) + result = jscal_to_ical(jscal) + assert "LOCATION:Room A" in result + + def test_priority(self): + jscal = _minimal_jscal(priority=5) + result = jscal_to_ical(jscal) + assert "PRIORITY:5" in result + + def test_privacy_private(self): + jscal = _minimal_jscal(privacy="private") + result = jscal_to_ical(jscal) + assert "CLASS:PRIVATE" in result + + def test_privacy_secret(self): + jscal = _minimal_jscal(privacy="secret") + result = jscal_to_ical(jscal) + assert "CLASS:CONFIDENTIAL" in result + + def test_free_busy_free(self): + jscal = _minimal_jscal(freeBusyStatus="free") + result = jscal_to_ical(jscal) + assert "TRANSP:TRANSPARENT" in result + + def test_rrule(self): + jscal = _minimal_jscal( + recurrenceRules=[ + { + "@type": "RecurrenceRule", + "frequency": "weekly", + "interval": 1, + "byDay": [{"@type": "NDay", "day": "mo"}], + "rscale": "gregorian", + "skip": "omit", + "firstDayOfWeek": "mo", + } + ] + ) + result = jscal_to_ical(jscal) + assert "RRULE" in result + assert "FREQ=WEEKLY" in result + assert "BYDAY=MO" in result + + def test_exdate_from_overrides(self): + jscal = _minimal_jscal( + recurrenceRules=[{"frequency": "weekly", "@type": "RecurrenceRule"}], + recurrenceOverrides={"2024-06-22T10:00:00": {"excluded": True}}, + ) + result = jscal_to_ical(jscal) + assert "EXDATE" in result + + def test_alert_relative(self): + jscal = _minimal_jscal(alerts={"al1": {"trigger": "-PT15M", "action": "display"}}) + result = jscal_to_ical(jscal) + assert "BEGIN:VALARM" in result + assert "TRIGGER:-PT15M" in result + + def test_participants_organizer(self): + jscal = _minimal_jscal( + participants={ + "p1": { + "roles": {"owner": True, "organizer": True}, + "name": "Alice", + "email": "alice@example.com", + "sendTo": {"imip": "mailto:alice@example.com"}, + } + } + ) + result = jscal_to_ical(jscal) + assert "ORGANIZER" in result + assert "alice@example.com" in result + + def test_sequence_emitted(self): + result = jscal_to_ical(_minimal_jscal(sequence=5)) + assert "SEQUENCE:5" in result + + def test_color_emitted(self): + result = jscal_to_ical(_minimal_jscal(color="blue")) + assert "COLOR:blue" in result + + def test_exrule_from_excluded_recurrence_rules(self): + jscal = _minimal_jscal( + recurrenceRules=[{"@type": "RecurrenceRule", "frequency": "weekly"}], + excludedRecurrenceRules=[ + {"@type": "RecurrenceRule", "frequency": "weekly", "byDay": [{"day": "mo"}]} + ], + ) + assert "EXRULE" in jscal_to_ical(jscal) + + def test_recurrence_override_patch_becomes_child_vevent(self): + jscal = _minimal_jscal( + start="2024-06-17T14:00:00Z", + recurrenceRules=[{"@type": "RecurrenceRule", "frequency": "weekly"}], + recurrenceOverrides={ + "2024-06-24T14:00:00Z": {"title": "Rescheduled", "start": "2024-06-24T16:00:00Z"} + }, + ) + del jscal["timeZone"] + result = jscal_to_ical(jscal) + assert result.count("BEGIN:VEVENT") == 2 + assert "RECURRENCE-ID" in result + assert "Rescheduled" in result + + def test_floating_datetime_emitted(self): + jscal = { + "uid": "float-uid@example.com", + "title": "Floating", + "start": "2024-06-15T10:00:00", + "duration": "PT1H", + } + result = jscal_to_ical(jscal) + assert "DTSTART:20240615T100000" in result + assert "TZID" not in result + + +# --------------------------------------------------------------------------- +# TestRoundTrip +# --------------------------------------------------------------------------- + + +class TestRoundTrip: + def _key_fields_survive(self, original_ical: str) -> dict: + """ical → jscal → ical → parse back and check.""" + jscal = ical_to_jscal(original_ical) + round_tripped = jscal_to_ical(jscal) + cal = _icalendar.Calendar.from_ical(round_tripped) + event = next(c for c in cal.subcomponents if isinstance(c, _icalendar.Event)) + return {"jscal": jscal, "ical": round_tripped, "event": event} + + def test_basic_event_round_trip(self): + ical = _make_ical("DTSTART:20240615T100000Z\r\nDURATION:PT1H\r\nSUMMARY:Basic Event\r\n") + ctx = self._key_fields_survive(ical) + assert str(ctx["event"]["SUMMARY"]) == "Basic Event" + assert ctx["jscal"]["title"] == "Basic Event" + assert ctx["jscal"]["duration"] == "PT1H" + + def test_all_day_round_trip(self): + ical = _make_ical( + "DTSTART;VALUE=DATE:20240615\r\nDTEND;VALUE=DATE:20240616\r\nSUMMARY:All Day Event\r\n" + ) + ctx = self._key_fields_survive(ical) + assert ctx["jscal"]["showWithoutTime"] is True + assert ctx["jscal"]["duration"] == "P1D" + + def test_recurring_event_round_trip(self): + ical = _make_ical( + "DTSTART;TZID=Europe/Berlin:20240617T140000\r\n" + "DURATION:PT1H\r\n" + "SUMMARY:Weekly\r\n" + "RRULE:FREQ=WEEKLY;COUNT=4\r\n" + ) + ctx = self._key_fields_survive(ical) + assert "recurrenceRules" in ctx["jscal"] + assert ctx["jscal"]["recurrenceRules"][0]["frequency"] == "weekly" + assert "RRULE" in ctx["ical"] + + def test_with_alert_round_trip(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\n" + "DURATION:PT1H\r\n" + "SUMMARY:Alert Event\r\n" + "BEGIN:VALARM\r\n" + "ACTION:DISPLAY\r\n" + "TRIGGER:-PT15M\r\n" + "DESCRIPTION:Reminder\r\n" + "END:VALARM\r\n" + ) + ctx = self._key_fields_survive(ical) + assert "alerts" in ctx["jscal"] + alert = next(iter(ctx["jscal"]["alerts"].values())) + assert alert["trigger"] == "-PT15M" + assert "BEGIN:VALARM" in ctx["ical"] + + def test_with_attendees_round_trip(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\n" + "DURATION:PT1H\r\n" + "SUMMARY:Meeting\r\n" + "ORGANIZER;CN=Alice:mailto:alice@example.com\r\n" + "ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED:mailto:bob@example.com\r\n" + ) + ctx = self._key_fields_survive(ical) + assert "participants" in ctx["jscal"] + assert len(ctx["jscal"]["participants"]) >= 1 + assert "alice@example.com" in ctx["ical"] or "ORGANIZER" in ctx["ical"] From ea687675ae34b578037093dcc2fc1ff88563fdd3 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 11:06:43 +0530 Subject: [PATCH 11/31] fix(deps): ignore pytz in deptry DEP003 check --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 8c27bbd5..f1339a85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,7 @@ ignore = ["DEP002"] # Test dependencies (pytest, coverage, etc.) are not import [tool.deptry.per_rule_ignores] DEP001 = ["conf", "h2"] # conf: Local test config, h2: Optional HTTP/2 support +DEP003 = ["pytz"] # Optional timezone library; imported inside try/except with graceful fallback [tool.ruff] line-length = 100 From cd04ae9ec204a2be8f64f75fc05167377177e62f Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 11:25:53 +0530 Subject: [PATCH 12/31] fix(jmap): correct parse_event_set return type; handle int byMonth values --- caldav/jmap/convert/jscal_to_ical.py | 2 +- caldav/jmap/methods/event.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/caldav/jmap/convert/jscal_to_ical.py b/caldav/jmap/convert/jscal_to_ical.py index cdffeaeb..311f038f 100644 --- a/caldav/jmap/convert/jscal_to_ical.py +++ b/caldav/jmap/convert/jscal_to_ical.py @@ -128,7 +128,7 @@ def _jscal_rrule_to_rrule(rule: dict) -> dict: by_month = rule.get("byMonth", []) if by_month: - ical_rule["BYMONTH"] = [int(m.rstrip("L")) for m in by_month] + ical_rule["BYMONTH"] = [m if isinstance(m, int) else int(str(m).rstrip("L")) for m in by_month] by_month_day = rule.get("byMonthDay", []) if by_month_day: diff --git a/caldav/jmap/methods/event.py b/caldav/jmap/methods/event.py index d8125aec..d88e2196 100644 --- a/caldav/jmap/methods/event.py +++ b/caldav/jmap/methods/event.py @@ -230,7 +230,7 @@ def build_event_set_destroy( def parse_event_set( response_args: dict, -) -> tuple[dict, dict, list[str], dict, dict, list[str]]: +) -> tuple[dict, dict, list[str], dict, dict, dict]: """Parse the arguments dict from a ``CalendarEvent/set`` method response. Args: From 0a451c8e71eb9c4d4082ec40753815f301aaaf4e Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 11:29:05 +0530 Subject: [PATCH 13/31] style: apply ruff-format to jscal_to_ical byMonth fix --- caldav/jmap/convert/jscal_to_ical.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/caldav/jmap/convert/jscal_to_ical.py b/caldav/jmap/convert/jscal_to_ical.py index 311f038f..aa7c1a9e 100644 --- a/caldav/jmap/convert/jscal_to_ical.py +++ b/caldav/jmap/convert/jscal_to_ical.py @@ -128,7 +128,9 @@ def _jscal_rrule_to_rrule(rule: dict) -> dict: by_month = rule.get("byMonth", []) if by_month: - ical_rule["BYMONTH"] = [m if isinstance(m, int) else int(str(m).rstrip("L")) for m in by_month] + ical_rule["BYMONTH"] = [ + m if isinstance(m, int) else int(str(m).rstrip("L")) for m in by_month + ] by_month_day = rule.get("byMonthDay", []) if by_month_day: From d6c5525ad5b679eadbb13f9fe6be16b954308822 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 11:42:23 +0530 Subject: [PATCH 14/31] refactor(jmap): deduplicate participant imip extraction --- caldav/jmap/convert/jscal_to_ical.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/caldav/jmap/convert/jscal_to_ical.py b/caldav/jmap/convert/jscal_to_ical.py index aa7c1a9e..58a17c21 100644 --- a/caldav/jmap/convert/jscal_to_ical.py +++ b/caldav/jmap/convert/jscal_to_ical.py @@ -167,18 +167,21 @@ def _jscal_rrule_to_rrule(rule: dict) -> dict: return ical_rule +def _participant_imip(p: dict) -> str: + send_to = p.get("sendTo", {}) + imip = send_to.get("imip") or send_to.get("other") or p.get("email", "") + if imip and not imip.startswith("mailto:"): + imip = f"mailto:{imip}" + return imip + + def _participant_to_organizer(p: dict) -> vCalAddress | None: """Build a vCalAddress for ORGANIZER, or None if this participant is not an organizer.""" roles = p.get("roles", {}) if not (roles.get("owner") or roles.get("organizer")): return None - send_to = p.get("sendTo", {}) - imip = send_to.get("imip") or send_to.get("other") or p.get("email", "") - if imip and not imip.startswith("mailto:"): - imip = f"mailto:{imip}" - - addr = vCalAddress(imip) + addr = vCalAddress(_participant_imip(p)) name = p.get("name") if name: addr.params["CN"] = vText(name) @@ -188,20 +191,13 @@ def _participant_to_organizer(p: dict) -> vCalAddress | None: def _participant_to_attendee(p: dict) -> vCalAddress | None: """Build a vCalAddress for ATTENDEE, or None if participant is purely an organizer.""" roles = p.get("roles", {}) - # If only owner/organizer with no attendee/chair role, don't emit an ATTENDEE line has_attendee_role = any( roles.get(r) for r in ("attendee", "chair", "informational", "optional") ) - # If owner-only (pure organizer), skip if not has_attendee_role and (roles.get("owner") or roles.get("organizer")): return None - send_to = p.get("sendTo", {}) - imip = send_to.get("imip") or send_to.get("other") or p.get("email", "") - if imip and not imip.startswith("mailto:"): - imip = f"mailto:{imip}" - - addr = vCalAddress(imip) + addr = vCalAddress(_participant_imip(p)) name = p.get("name") if name: addr.params["CN"] = vText(name) From be8cd5d8a2cb59bc9926850c4005ca978a9e0fa3 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 12:07:53 +0530 Subject: [PATCH 15/31] feat(jmap): add event CRUD methods to JMAPClient --- caldav/jmap/client.py | 122 ++++++++++++++++++++++++++++++++++++- tests/test_jmap_unit.py | 130 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 1 deletion(-) diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py index b59f531a..1268b212 100644 --- a/caldav/jmap/client.py +++ b/caldav/jmap/client.py @@ -20,15 +20,21 @@ from requests.auth import HTTPBasicAuth # type: ignore[no-redef] from caldav.jmap.constants import CALENDAR_CAPABILITY, CORE_CAPABILITY +from caldav.jmap.convert import ical_to_jscal, jscal_to_ical from caldav.jmap.error import JMAPAuthError, JMAPMethodError from caldav.jmap.methods.calendar import build_calendar_get, parse_calendar_get +from caldav.jmap.methods.event import ( + build_event_get, + build_event_set_destroy, + build_event_set_update, + parse_event_set, +) from caldav.jmap.objects.calendar import JMAPCalendar from caldav.jmap.session import Session, fetch_session from caldav.requests import HTTPBearerAuth log = logging.getLogger("caldav.jmap") -# Default capabilities declared in every API request _DEFAULT_USING = [CORE_CAPABILITY, CALENDAR_CAPABILITY] @@ -118,6 +124,13 @@ def _build_auth(self, auth_type: str | None): reason=f"Unsupported auth_type {effective_type!r}. Use 'basic' or 'bearer'.", ) + def _raise_set_error(self, session: Session, err: dict) -> None: + raise JMAPMethodError( + url=session.api_url, + reason=f"CalendarEvent/set failed: {err}", + error_type=err.get("type", "serverError"), + ) + def _get_session(self) -> Session: """Return the cached Session, fetching it on first call.""" if self._session_cache is None: @@ -194,3 +207,110 @@ def get_calendars(self) -> list[JMAPCalendar]: return parse_calendar_get(resp_args) return [] + + def create_event(self, calendar_id: str, ical_str: str) -> str: + """Create a calendar event from an iCalendar string. + + Args: + calendar_id: The JMAP calendar ID to create the event in. + ical_str: A VCALENDAR string representing the event. + + Returns: + The server-assigned JMAP event ID. + + Raises: + JMAPMethodError: If the server rejects the create request. + """ + session = self._get_session() + jscal = ical_to_jscal(ical_str, calendar_id=calendar_id) + call = ( + "CalendarEvent/set", + {"accountId": session.account_id, "create": {"new-0": jscal}}, + "ev-set-create-0", + ) + responses = self._request([call]) + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/set": + created, _, _, not_created, _, _ = parse_event_set(resp_args) + if "new-0" in not_created: + self._raise_set_error(session, not_created["new-0"]) + return created["new-0"]["id"] + + raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response") + + def get_event(self, event_id: str) -> str: + """Fetch a calendar event as an iCalendar string. + + Args: + event_id: The JMAP event ID to retrieve. + + Returns: + A VCALENDAR string for the event. + + Raises: + JMAPMethodError: If the event is not found. + """ + session = self._get_session() + call = build_event_get(session.account_id, ids=[event_id]) + responses = self._request([call]) + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/get": + items = resp_args.get("list", []) + if not items: + raise JMAPMethodError( + url=session.api_url, + reason=f"Event not found: {event_id}", + error_type="notFound", + ) + return jscal_to_ical(items[0]) + + raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/get response") + + def update_event(self, event_id: str, ical_str: str) -> None: + """Update a calendar event from an iCalendar string. + + Args: + event_id: The JMAP event ID to update. + ical_str: A VCALENDAR string with the updated event data. + + Raises: + JMAPMethodError: If the server rejects the update. + """ + session = self._get_session() + patch = ical_to_jscal(ical_str) + patch.pop("uid", None) # uid is server-immutable after creation; patch must omit it + call = build_event_set_update(session.account_id, {event_id: patch}) + responses = self._request([call]) + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/set": + _, _, _, _, not_updated, _ = parse_event_set(resp_args) + if event_id in not_updated: + self._raise_set_error(session, not_updated[event_id]) + return + + raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response") + + def delete_event(self, event_id: str) -> None: + """Delete a calendar event. + + Args: + event_id: The JMAP event ID to delete. + + Raises: + JMAPMethodError: If the server rejects the delete. + """ + session = self._get_session() + call = build_event_set_destroy(session.account_id, [event_id]) + responses = self._request([call]) + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/set": + _, _, _, _, _, not_destroyed = parse_event_set(resp_args) + if event_id in not_destroyed: + self._raise_set_error(session, not_destroyed[event_id]) + return + + raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response") diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index ded59888..17712d3a 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -1541,3 +1541,133 @@ def test_with_attendees_round_trip(self): assert "participants" in ctx["jscal"] assert len(ctx["jscal"]["participants"]) >= 1 assert "alice@example.com" in ctx["ical"] or "ORGANIZER" in ctx["ical"] + + +class TestJMAPClientEvents: + _MINIMAL_ICAL = ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "BEGIN:VEVENT\r\n" + "UID:test-uid-123@example.com\r\n" + "DTSTART:20240615T090000Z\r\n" + "SUMMARY:Test Event\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + + def _set_response(self, **kwargs): + return {"methodResponses": [["CalendarEvent/set", kwargs, "ev-set-create-0"]]} + + def _get_response(self, items): + return { + "methodResponses": [ + [ + "CalendarEvent/get", + {"accountId": _USERNAME, "list": items, "notFound": []}, + "ev-get-0", + ] + ] + } + + def test_create_event_returns_server_id(self, monkeypatch): + resp = self._set_response(created={"new-0": {"id": "sv-1"}}) + client = _make_client_with_mocked_session(monkeypatch, resp) + event_id = client.create_event("cal1", self._MINIMAL_ICAL) + assert event_id == "sv-1" + + def test_create_event_raises_on_failure(self, monkeypatch): + resp = self._set_response( + notCreated={"new-0": {"type": "invalidArguments", "description": "bad"}} + ) + client = _make_client_with_mocked_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + client.create_event("cal1", self._MINIMAL_ICAL) + assert exc_info.value.error_type == "invalidArguments" + + def test_create_event_passes_calendar_id(self, monkeypatch): + captured = {} + resp = self._set_response(created={"new-0": {"id": "sv-2"}}) + client = _make_client_with_mocked_session(monkeypatch, resp) + + original_post = __import__("caldav.jmap.client", fromlist=["requests"]).requests.post + + def capturing_post(*args, **kwargs): + captured["json"] = kwargs.get("json", {}) + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = resp + mock_resp.raise_for_status = MagicMock() + return mock_resp + + monkeypatch.setattr("caldav.jmap.client.requests.post", capturing_post) + client.create_event("my-calendar", self._MINIMAL_ICAL) + + method_calls = captured["json"]["methodCalls"] + create_args = method_calls[0][1] + event_payload = create_args["create"]["new-0"] + assert event_payload.get("calendarIds") == {"my-calendar": True} + + def test_get_event_returns_ical(self, monkeypatch): + raw_event = { + "id": "ev1", + "uid": "test-uid@example.com", + "calendarIds": {"cal1": True}, + "title": "Staff Meeting", + "start": "2024-06-15T09:00:00Z", + "duration": "PT1H", + } + client = _make_client_with_mocked_session(monkeypatch, self._get_response([raw_event])) + result = client.get_event("ev1") + assert "VCALENDAR" in result + assert "Staff Meeting" in result + + def test_get_event_raises_on_not_found(self, monkeypatch): + client = _make_client_with_mocked_session(monkeypatch, self._get_response([])) + with pytest.raises(JMAPMethodError) as exc_info: + client.get_event("missing-id") + assert exc_info.value.error_type == "notFound" + + def test_update_event_success(self, monkeypatch): + resp = self._set_response(updated={"ev1": None}) + client = _make_client_with_mocked_session(monkeypatch, resp) + client.update_event("ev1", self._MINIMAL_ICAL) + + def test_update_event_raises_on_failure(self, monkeypatch): + resp = self._set_response(notUpdated={"ev1": {"type": "notFound"}}) + client = _make_client_with_mocked_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + client.update_event("ev1", self._MINIMAL_ICAL) + assert exc_info.value.error_type == "notFound" + + def test_update_event_drops_uid_from_patch(self, monkeypatch): + captured = {} + resp = self._set_response(updated={"ev1": None}) + client = _make_client_with_mocked_session(monkeypatch, resp) + + def capturing_post(*args, **kwargs): + captured["json"] = kwargs.get("json", {}) + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = resp + mock_resp.raise_for_status = MagicMock() + return mock_resp + + monkeypatch.setattr("caldav.jmap.client.requests.post", capturing_post) + client.update_event("ev1", self._MINIMAL_ICAL) + + method_calls = captured["json"]["methodCalls"] + update_args = method_calls[0][1] + patch = update_args["update"]["ev1"] + assert "uid" not in patch + + def test_delete_event_success(self, monkeypatch): + resp = self._set_response(destroyed=["ev1"]) + client = _make_client_with_mocked_session(monkeypatch, resp) + client.delete_event("ev1") + + def test_delete_event_raises_on_failure(self, monkeypatch): + resp = self._set_response(notDestroyed={"ev1": {"type": "notFound"}}) + client = _make_client_with_mocked_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + client.delete_event("ev1") + assert exc_info.value.error_type == "notFound" From 326c0637cce98883f6ba9cf997c24062085a06e6 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 12:14:46 +0530 Subject: [PATCH 16/31] fix(jmap): add timeout to fetch_session; drop unused variable in test --- caldav/jmap/client.py | 2 +- caldav/jmap/session.py | 4 ++-- tests/test_jmap_unit.py | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py index 1268b212..ecba7649 100644 --- a/caldav/jmap/client.py +++ b/caldav/jmap/client.py @@ -134,7 +134,7 @@ def _raise_set_error(self, session: Session, err: dict) -> None: def _get_session(self) -> Session: """Return the cached Session, fetching it on first call.""" if self._session_cache is None: - self._session_cache = fetch_session(self.url, auth=self._auth) + self._session_cache = fetch_session(self.url, auth=self._auth, timeout=self.timeout) return self._session_cache def _request(self, method_calls: list[tuple]) -> list: diff --git a/caldav/jmap/session.py b/caldav/jmap/session.py index 0712fc20..9e04056a 100644 --- a/caldav/jmap/session.py +++ b/caldav/jmap/session.py @@ -41,7 +41,7 @@ class Session: raw: dict = field(default_factory=dict) -def fetch_session(url: str, auth) -> Session: +def fetch_session(url: str, auth, timeout: int = 30) -> Session: """Fetch and parse the JMAP Session object. Performs a GET request to ``url`` (expected to be ``/.well-known/jmap`` @@ -61,7 +61,7 @@ def fetch_session(url: str, auth) -> Session: JMAPCapabilityError: If no account advertises the calendars capability. requests.HTTPError: For other non-2xx responses. """ - response = requests.get(url, auth=auth, headers={"Accept": "application/json"}) + response = requests.get(url, auth=auth, headers={"Accept": "application/json"}, timeout=timeout) if response.status_code in (401, 403): raise JMAPAuthError( diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index 17712d3a..517bed91 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -1589,8 +1589,6 @@ def test_create_event_passes_calendar_id(self, monkeypatch): resp = self._set_response(created={"new-0": {"id": "sv-2"}}) client = _make_client_with_mocked_session(monkeypatch, resp) - original_post = __import__("caldav.jmap.client", fromlist=["requests"]).requests.post - def capturing_post(*args, **kwargs): captured["json"] = kwargs.get("json", {}) mock_resp = MagicMock() From 5f0dc59ce839bf55251bcd59a2c827fe1ad602b5 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 13:34:00 +0530 Subject: [PATCH 17/31] feat(jmap): add JMAPClient.search_events with batched query+get --- caldav/jmap/client.py | 55 ++++++++++++++++++++ tests/test_jmap_unit.py | 110 +++++++++++++++++++++++++++++++--------- 2 files changed, 141 insertions(+), 24 deletions(-) diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py index ecba7649..094a0025 100644 --- a/caldav/jmap/client.py +++ b/caldav/jmap/client.py @@ -25,6 +25,7 @@ from caldav.jmap.methods.calendar import build_calendar_get, parse_calendar_get from caldav.jmap.methods.event import ( build_event_get, + build_event_query, build_event_set_destroy, build_event_set_update, parse_event_set, @@ -293,6 +294,60 @@ def update_event(self, event_id: str, ical_str: str) -> None: raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response") + def search_events( + self, + calendar_id: str | None = None, + start: str | None = None, + end: str | None = None, + text: str | None = None, + ) -> list[str]: + """Search for calendar events and return them as iCalendar strings. + + All parameters are optional; omitting all returns every event in the account. + Results are fetched in a single batched JMAP request using a result reference + from ``CalendarEvent/query`` into ``CalendarEvent/get``. + + Args: + calendar_id: Limit results to this calendar. + start: Only events ending after this datetime (``YYYY-MM-DDTHH:MM:SS``). + end: Only events starting before this datetime (``YYYY-MM-DDTHH:MM:SS``). + text: Free-text search across title, description, locations, and participants. + + Returns: + List of VCALENDAR strings for all matching events. + """ + session = self._get_session() + filter_dict: dict = {} + if calendar_id is not None: + filter_dict["inCalendars"] = [calendar_id] + if start is not None: + filter_dict["after"] = start + if end is not None: + filter_dict["before"] = end + if text is not None: + filter_dict["text"] = text + + query_call = build_event_query(session.account_id, filter=filter_dict or None) + get_call = ( + "CalendarEvent/get", + { + "accountId": session.account_id, + "#ids": { + "resultOf": "ev-query-0", + "name": "CalendarEvent/query", + "path": "/ids", + }, + }, + "ev-get-1", + ) + responses = self._request([query_call, get_call]) + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/get": + return [jscal_to_ical(item) for item in resp_args.get("list", [])] + + return [] + def delete_event(self, event_id: str) -> None: """Delete a calendar event. diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index 517bed91..68e9332b 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -1555,6 +1555,15 @@ class TestJMAPClientEvents: "END:VCALENDAR\r\n" ) + _RAW_EVENT = { + "id": "ev1", + "uid": "test-uid@example.com", + "calendarIds": {"cal1": True}, + "title": "Staff Meeting", + "start": "2024-06-15T09:00:00", + "duration": "PT1H", + } + def _set_response(self, **kwargs): return {"methodResponses": [["CalendarEvent/set", kwargs, "ev-set-create-0"]]} @@ -1585,19 +1594,8 @@ def test_create_event_raises_on_failure(self, monkeypatch): assert exc_info.value.error_type == "invalidArguments" def test_create_event_passes_calendar_id(self, monkeypatch): - captured = {} resp = self._set_response(created={"new-0": {"id": "sv-2"}}) - client = _make_client_with_mocked_session(monkeypatch, resp) - - def capturing_post(*args, **kwargs): - captured["json"] = kwargs.get("json", {}) - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = resp - mock_resp.raise_for_status = MagicMock() - return mock_resp - - monkeypatch.setattr("caldav.jmap.client.requests.post", capturing_post) + client, captured = self._capturing_client(monkeypatch, resp) client.create_event("my-calendar", self._MINIMAL_ICAL) method_calls = captured["json"]["methodCalls"] @@ -1638,19 +1636,8 @@ def test_update_event_raises_on_failure(self, monkeypatch): assert exc_info.value.error_type == "notFound" def test_update_event_drops_uid_from_patch(self, monkeypatch): - captured = {} resp = self._set_response(updated={"ev1": None}) - client = _make_client_with_mocked_session(monkeypatch, resp) - - def capturing_post(*args, **kwargs): - captured["json"] = kwargs.get("json", {}) - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = resp - mock_resp.raise_for_status = MagicMock() - return mock_resp - - monkeypatch.setattr("caldav.jmap.client.requests.post", capturing_post) + client, captured = self._capturing_client(monkeypatch, resp) client.update_event("ev1", self._MINIMAL_ICAL) method_calls = captured["json"]["methodCalls"] @@ -1669,3 +1656,78 @@ def test_delete_event_raises_on_failure(self, monkeypatch): with pytest.raises(JMAPMethodError) as exc_info: client.delete_event("ev1") assert exc_info.value.error_type == "notFound" + + def _capturing_client(self, monkeypatch, resp): + """Return (client, captured) where captured["json"] is set on each POST.""" + captured = {} + client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) + client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-abc") + + def capturing_post(*args, **kwargs): + captured["json"] = kwargs.get("json", {}) + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = resp + mock_resp.raise_for_status = MagicMock() + return mock_resp + + monkeypatch.setattr("caldav.jmap.client.requests.post", capturing_post) + return client, captured + + def _query_get_response(self, items): + return { + "methodResponses": [ + [ + "CalendarEvent/query", + {"ids": [i["id"] for i in items], "queryState": "qs-1", "total": len(items)}, + "ev-query-0", + ], + [ + "CalendarEvent/get", + {"accountId": _USERNAME, "list": items, "notFound": []}, + "ev-get-1", + ], + ] + } + + def test_search_events_returns_ical_list(self, monkeypatch): + event2 = {**self._RAW_EVENT, "id": "ev2", "title": "Standup"} + resp = self._query_get_response([self._RAW_EVENT, event2]) + client = _make_client_with_mocked_session(monkeypatch, resp) + results = client.search_events() + assert len(results) == 2 + assert all("VCALENDAR" in r for r in results) + + def test_search_events_empty_result(self, monkeypatch): + resp = self._query_get_response([]) + client = _make_client_with_mocked_session(monkeypatch, resp) + assert client.search_events() == [] + + def test_search_events_passes_calendar_id_filter(self, monkeypatch): + resp = self._query_get_response([self._RAW_EVENT]) + client, captured = self._capturing_client(monkeypatch, resp) + client.search_events(calendar_id="my-cal") + query_args = captured["json"]["methodCalls"][0][1] + assert query_args["filter"]["inCalendars"] == ["my-cal"] + + def test_search_events_passes_date_range_filter(self, monkeypatch): + resp = self._query_get_response([self._RAW_EVENT]) + client, captured = self._capturing_client(monkeypatch, resp) + client.search_events(start="2024-01-01T00:00:00", end="2024-12-31T23:59:59") + query_args = captured["json"]["methodCalls"][0][1] + assert query_args["filter"]["after"] == "2024-01-01T00:00:00" + assert query_args["filter"]["before"] == "2024-12-31T23:59:59" + + def test_search_events_passes_text_filter(self, monkeypatch): + resp = self._query_get_response([self._RAW_EVENT]) + client, captured = self._capturing_client(monkeypatch, resp) + client.search_events(text="standup") + query_args = captured["json"]["methodCalls"][0][1] + assert query_args["filter"]["text"] == "standup" + + def test_search_events_no_filter_when_no_args(self, monkeypatch): + resp = self._query_get_response([self._RAW_EVENT]) + client, captured = self._capturing_client(monkeypatch, resp) + client.search_events() + query_args = captured["json"]["methodCalls"][0][1] + assert "filter" not in query_args From ff55e6580ce51003b489a23059786f53f81828d1 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 14:25:55 +0530 Subject: [PATCH 18/31] fix(jmap): narrow broad exception catch in pytz timezone handling --- caldav/jmap/convert/jscal_to_ical.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/caldav/jmap/convert/jscal_to_ical.py b/caldav/jmap/convert/jscal_to_ical.py index 58a17c21..21fb7c5a 100644 --- a/caldav/jmap/convert/jscal_to_ical.py +++ b/caldav/jmap/convert/jscal_to_ical.py @@ -71,16 +71,23 @@ def _start_to_dtstart( dt_naive = datetime.strptime(start_str[:19], "%Y-%m-%dT%H:%M:%S") if time_zone: - try: - import pytz # type: ignore[import] - tz = pytz.timezone(time_zone) - dt = tz.localize(dt_naive) - component.add("dtstart", dt) - except (ImportError, Exception): + def _add_dtstart_tzid_passthrough(): dtstart = icalendar.vDatetime(dt_naive) dtstart.params["TZID"] = time_zone component.add("dtstart", dtstart) + + try: + import pytz # type: ignore[import] + + try: + tz = pytz.timezone(time_zone) + dt = tz.localize(dt_naive) + component.add("dtstart", dt) + except pytz.exceptions.UnknownTimeZoneError: + _add_dtstart_tzid_passthrough() + except ImportError: + _add_dtstart_tzid_passthrough() else: component.add("dtstart", dt_naive) From 23d578b7b3329ad19793ae0574cb82eead55eb36 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 15:40:08 +0530 Subject: [PATCH 19/31] feat(jmap): add get_sync_token and get_objects_by_sync_token --- caldav/jmap/client.py | 81 +++++++++++++++++++ caldav/jmap/methods/event.py | 29 +++++++ tests/test_jmap_unit.py | 147 +++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py index 094a0025..214f0250 100644 --- a/caldav/jmap/client.py +++ b/caldav/jmap/client.py @@ -24,10 +24,12 @@ from caldav.jmap.error import JMAPAuthError, JMAPMethodError from caldav.jmap.methods.calendar import build_calendar_get, parse_calendar_get from caldav.jmap.methods.event import ( + build_event_changes, build_event_get, build_event_query, build_event_set_destroy, build_event_set_update, + parse_event_changes, parse_event_set, ) from caldav.jmap.objects.calendar import JMAPCalendar @@ -348,6 +350,85 @@ def search_events( return [] + def get_sync_token(self) -> str: + """Return the current CalendarEvent state string for use as a sync token. + + Calls ``CalendarEvent/get`` with an empty ID list — no event data is + transferred, only the ``state`` field from the response. + + Returns: + Opaque state string. Pass to :meth:`get_objects_by_sync_token` to + retrieve only what changed since this point. + """ + session = self._get_session() + call = build_event_get(session.account_id, ids=[]) + responses = self._request([call]) + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/get": + return resp_args.get("state", "") + raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/get response") + + def get_objects_by_sync_token(self, sync_token: str) -> tuple[list[str], list[str], list[str]]: + """Fetch events changed since a previous sync token. + + Calls ``CalendarEvent/changes`` to discover which events were created, + modified, or destroyed since ``sync_token`` was issued. Created and + modified events are returned as iCalendar strings; destroyed events are + returned as IDs (the objects no longer exist on the server). + + Args: + sync_token: A state string previously returned by :meth:`get_sync_token` + or by a prior call to this method. + + Returns: + A 3-tuple ``(added, modified, deleted)``: + + - ``added``: iCalendar strings for newly created events. + - ``modified``: iCalendar strings for updated events. + - ``deleted``: Event IDs that were destroyed. + + Raises: + JMAPMethodError: If the server reports ``hasMoreChanges: true``. + """ + session = self._get_session() + changes_call = build_event_changes(session.account_id, sync_token) + responses = self._request([changes_call]) + + created_ids: list[str] = [] + updated_ids: list[str] = [] + destroyed: list[str] = [] + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/changes": + _, _, has_more, created_ids, updated_ids, destroyed = parse_event_changes(resp_args) + if has_more: + raise JMAPMethodError( + url=session.api_url, + reason=( + "CalendarEvent/changes response was truncated by the server " + "(hasMoreChanges=true). Call get_sync_token() to obtain a " + "fresh baseline and re-sync." + ), + error_type="serverPartialFail", + ) + + fetch_ids = created_ids + updated_ids + if not fetch_ids: + return [], [], destroyed + + get_call = build_event_get(session.account_id, ids=fetch_ids) + get_responses = self._request([get_call]) + + events_by_id: dict[str, str] = {} + for method_name, resp_args, _ in get_responses: + if method_name == "CalendarEvent/get": + for item in resp_args.get("list", []): + events_by_id[item["id"]] = jscal_to_ical(item) + + added = [events_by_id[i] for i in created_ids if i in events_by_id] + modified = [events_by_id[i] for i in updated_ids if i in events_by_id] + return added, modified, destroyed + def delete_event(self, event_id: str) -> None: """Delete a calendar event. diff --git a/caldav/jmap/methods/event.py b/caldav/jmap/methods/event.py index d88e2196..204ac547 100644 --- a/caldav/jmap/methods/event.py +++ b/caldav/jmap/methods/event.py @@ -74,6 +74,35 @@ def build_event_changes( return ("CalendarEvent/changes", args, "ev-changes-0") +def parse_event_changes( + response_args: dict, +) -> tuple[str, str, bool, list[str], list[str], list[str]]: + """Parse the arguments dict from a ``CalendarEvent/changes`` response. + + Args: + response_args: The second element of a ``methodResponses`` entry + whose method name is ``"CalendarEvent/changes"``. + + Returns: + A 6-tuple ``(old_state, new_state, has_more_changes, created, updated, destroyed)``: + + - ``old_state``: Echo of the ``sinceState`` argument. + - ``new_state``: State string to store as the next sync token. + - ``has_more_changes``: True if the server capped the response. + - ``created``: IDs of newly created events. + - ``updated``: IDs of modified events. + - ``destroyed``: IDs of deleted events. + """ + return ( + response_args.get("oldState", ""), + response_args.get("newState", ""), + response_args.get("hasMoreChanges", False), + response_args.get("created") or [], + response_args.get("updated") or [], + response_args.get("destroyed") or [], + ) + + def build_event_query( account_id: str, filter: dict | None = None, diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index 68e9332b..29d7e4b9 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -758,6 +758,7 @@ def test_from_jmap_explicit_empty_collections_are_empty(self): build_event_set_create, build_event_set_destroy, build_event_set_update, + parse_event_changes, parse_event_get, parse_event_query, parse_event_set, @@ -1731,3 +1732,149 @@ def test_search_events_no_filter_when_no_args(self, monkeypatch): client.search_events() query_args = captured["json"]["methodCalls"][0][1] assert "filter" not in query_args + + +class TestJMAPClientSync: + _RAW_EVENT = { + "id": "ev1", + "uid": "test-uid@example.com", + "calendarIds": {"cal1": True}, + "title": "Staff Meeting", + "start": "2026-01-15T09:00:00", + "duration": "PT1H", + } + + def _changes_resp( + self, + created=None, + updated=None, + destroyed=None, + old_state="state-1", + new_state="state-2", + has_more=False, + ): + return { + "methodResponses": [ + [ + "CalendarEvent/changes", + { + "accountId": _USERNAME, + "oldState": old_state, + "newState": new_state, + "hasMoreChanges": has_more, + "created": created or [], + "updated": updated or [], + "destroyed": destroyed or [], + }, + "ev-changes-0", + ] + ] + } + + def _get_resp_with_state(self, items, state="state-2"): + return { + "methodResponses": [ + [ + "CalendarEvent/get", + {"accountId": _USERNAME, "state": state, "list": items, "notFound": []}, + "ev-get-0", + ] + ] + } + + def _make_mock(self, resp_json): + m = MagicMock() + m.status_code = 200 + m.json.return_value = resp_json + m.raise_for_status = MagicMock() + return m + + def _make_client(self): + client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) + client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-abc") + return client + + def test_get_sync_token_returns_state(self, monkeypatch): + resp = self._get_resp_with_state([], state="tok-1") + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + assert self._make_client().get_sync_token() == "tok-1" + + def test_get_sync_token_sends_empty_ids(self, monkeypatch): + captured = {} + resp = self._get_resp_with_state([]) + + def capturing_post(*args, **kwargs): + captured["json"] = kwargs.get("json", {}) + return self._make_mock(resp) + + monkeypatch.setattr("caldav.jmap.client.requests.post", capturing_post) + self._make_client().get_sync_token() + assert captured["json"]["methodCalls"][0][1]["ids"] == [] + + def test_get_objects_no_changes(self, monkeypatch): + resp = self._changes_resp() + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + added, modified, deleted = self._make_client().get_objects_by_sync_token("state-1") + assert added == [] and modified == [] and deleted == [] + + def test_get_objects_deleted_returns_ids(self, monkeypatch): + resp = self._changes_resp(destroyed=["ev1"]) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + added, modified, deleted = self._make_client().get_objects_by_sync_token("state-1") + assert deleted == ["ev1"] and added == [] and modified == [] + + def test_get_objects_added_returns_ical(self, monkeypatch): + changes_resp = self._changes_resp(created=["ev1"]) + get_resp = self._get_resp_with_state([self._RAW_EVENT]) + mock_post = MagicMock( + side_effect=[self._make_mock(changes_resp), self._make_mock(get_resp)] + ) + monkeypatch.setattr("caldav.jmap.client.requests.post", mock_post) + added, modified, deleted = self._make_client().get_objects_by_sync_token("state-1") + assert len(added) == 1 + assert "VCALENDAR" in added[0] + assert modified == [] and deleted == [] + + def test_get_objects_modified_returns_ical(self, monkeypatch): + changes_resp = self._changes_resp(updated=["ev1"]) + get_resp = self._get_resp_with_state([self._RAW_EVENT]) + mock_post = MagicMock( + side_effect=[self._make_mock(changes_resp), self._make_mock(get_resp)] + ) + monkeypatch.setattr("caldav.jmap.client.requests.post", mock_post) + added, modified, deleted = self._make_client().get_objects_by_sync_token("state-1") + assert len(modified) == 1 + assert "VCALENDAR" in modified[0] + assert added == [] and deleted == [] + + def test_get_objects_has_more_raises(self, monkeypatch): + resp = self._changes_resp(created=["ev1"], has_more=True) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + with pytest.raises(JMAPMethodError) as exc_info: + self._make_client().get_objects_by_sync_token("state-1") + assert exc_info.value.error_type == "serverPartialFail" + + def test_parse_event_changes_all_fields(self): + resp_args = { + "oldState": "s1", + "newState": "s2", + "hasMoreChanges": True, + "created": ["ev1"], + "updated": ["ev2"], + "destroyed": ["ev3"], + } + old, new, has_more, created, updated, destroyed = parse_event_changes(resp_args) + assert old == "s1" + assert new == "s2" + assert has_more is True + assert created == ["ev1"] + assert updated == ["ev2"] + assert destroyed == ["ev3"] From b1f4b555c5524353ea181a976799b34b96eb63d1 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 20:02:39 +0530 Subject: [PATCH 20/31] feat(jmap): add Task and TaskList support via RFC 9553 --- caldav/jmap/client.py | 148 +++++++++++++- caldav/jmap/methods/task.py | 176 +++++++++++++++++ caldav/jmap/objects/task.py | 216 ++++++++++++++++++++ tests/test_jmap_unit.py | 383 +++++++++++++++++++++++++++++++++++- 4 files changed, 918 insertions(+), 5 deletions(-) create mode 100644 caldav/jmap/methods/task.py create mode 100644 caldav/jmap/objects/task.py diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py index 214f0250..d16d5787 100644 --- a/caldav/jmap/client.py +++ b/caldav/jmap/client.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging +import uuid try: import niquests as requests @@ -19,7 +20,7 @@ import requests # type: ignore[no-redef] from requests.auth import HTTPBasicAuth # type: ignore[no-redef] -from caldav.jmap.constants import CALENDAR_CAPABILITY, CORE_CAPABILITY +from caldav.jmap.constants import CALENDAR_CAPABILITY, CORE_CAPABILITY, TASK_CAPABILITY from caldav.jmap.convert import ical_to_jscal, jscal_to_ical from caldav.jmap.error import JMAPAuthError, JMAPMethodError from caldav.jmap.methods.calendar import build_calendar_get, parse_calendar_get @@ -32,13 +33,24 @@ parse_event_changes, parse_event_set, ) +from caldav.jmap.methods.task import ( + build_task_get, + build_task_list_get, + build_task_set_create, + build_task_set_destroy, + build_task_set_update, + parse_task_list_get, + parse_task_set, +) from caldav.jmap.objects.calendar import JMAPCalendar +from caldav.jmap.objects.task import JMAPTask, JMAPTaskList from caldav.jmap.session import Session, fetch_session from caldav.requests import HTTPBearerAuth log = logging.getLogger("caldav.jmap") _DEFAULT_USING = [CORE_CAPABILITY, CALENDAR_CAPABILITY] +_TASK_USING = [CORE_CAPABILITY, TASK_CAPABILITY] class JMAPClient: @@ -130,7 +142,7 @@ def _build_auth(self, auth_type: str | None): def _raise_set_error(self, session: Session, err: dict) -> None: raise JMAPMethodError( url=session.api_url, - reason=f"CalendarEvent/set failed: {err}", + reason=f"set failed: {err}", error_type=err.get("type", "serverError"), ) @@ -140,11 +152,13 @@ def _get_session(self) -> Session: self._session_cache = fetch_session(self.url, auth=self._auth, timeout=self.timeout) return self._session_cache - def _request(self, method_calls: list[tuple]) -> list: + def _request(self, method_calls: list[tuple], using: list[str] | None = None) -> list: """POST a batch of JMAP method calls and return the methodResponses. Args: method_calls: List of 3-tuples ``(method_name, args_dict, call_id)``. + using: Capability URN list for the ``using`` field. Defaults to + ``_DEFAULT_USING`` (core + calendars). Returns: List of 3-tuples ``(method_name, response_args, call_id)`` from @@ -158,7 +172,7 @@ def _request(self, method_calls: list[tuple]) -> list: session = self._get_session() payload = { - "using": _DEFAULT_USING, + "using": using if using is not None else _DEFAULT_USING, "methodCalls": list(method_calls), } @@ -450,3 +464,129 @@ def delete_event(self, event_id: str) -> None: return raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response") + + def get_task_lists(self) -> list[JMAPTaskList]: + """Fetch all task lists for the authenticated account. + + Returns: + List of :class:`~caldav.jmap.objects.task.JMAPTaskList` objects. + """ + session = self._get_session() + call = build_task_list_get(session.account_id) + responses = self._request([call], using=_TASK_USING) + + for method_name, resp_args, _ in responses: + if method_name == "TaskList/get": + return parse_task_list_get(resp_args) + + return [] + + def create_task(self, task_list_id: str, title: str, **kwargs) -> str: + """Create a task in a task list. + + Args: + task_list_id: The JMAP task list ID to create the task in. + title: Task title (maps to VTODO ``SUMMARY``). + **kwargs: Optional task fields: ``description``, ``due``, ``start``, + ``time_zone``, ``estimated_duration``, ``percent_complete``, + ``progress``, ``priority``. + + Returns: + The server-assigned JMAP task ID. + + Raises: + JMAPMethodError: If the server rejects the create request. + """ + session = self._get_session() + task = JMAPTask( + id="", + uid=str(uuid.uuid4()), + task_list_id=task_list_id, + title=title, + **kwargs, + ) + call = build_task_set_create(session.account_id, {"new-0": task}) + responses = self._request([call], using=_TASK_USING) + + for method_name, resp_args, _ in responses: + if method_name == "Task/set": + created, _, _, not_created, _, _ = parse_task_set(resp_args) + if "new-0" in not_created: + self._raise_set_error(session, not_created["new-0"]) + return created["new-0"]["id"] + + raise JMAPMethodError(url=session.api_url, reason="No Task/set response") + + def get_task(self, task_id: str) -> JMAPTask: + """Fetch a task by ID. + + Args: + task_id: The JMAP task ID to retrieve. + + Returns: + A :class:`~caldav.jmap.objects.task.JMAPTask` object. + + Raises: + JMAPMethodError: If the task is not found. + """ + session = self._get_session() + call = build_task_get(session.account_id, ids=[task_id]) + responses = self._request([call], using=_TASK_USING) + + for method_name, resp_args, _ in responses: + if method_name == "Task/get": + items = resp_args.get("list", []) + if not items: + raise JMAPMethodError( + url=session.api_url, + reason=f"Task not found: {task_id}", + error_type="notFound", + ) + return JMAPTask.from_jmap(items[0]) + + raise JMAPMethodError(url=session.api_url, reason="No Task/get response") + + def update_task(self, task_id: str, patch: dict) -> None: + """Update a task with a partial patch. + + Args: + task_id: The JMAP task ID to update. + patch: Partial patch dict mapping property names to new values. + + Raises: + JMAPMethodError: If the server rejects the update. + """ + session = self._get_session() + call = build_task_set_update(session.account_id, {task_id: patch}) + responses = self._request([call], using=_TASK_USING) + + for method_name, resp_args, _ in responses: + if method_name == "Task/set": + _, _, _, _, not_updated, _ = parse_task_set(resp_args) + if task_id in not_updated: + self._raise_set_error(session, not_updated[task_id]) + return + + raise JMAPMethodError(url=session.api_url, reason="No Task/set response") + + def delete_task(self, task_id: str) -> None: + """Delete a task. + + Args: + task_id: The JMAP task ID to delete. + + Raises: + JMAPMethodError: If the server rejects the delete. + """ + session = self._get_session() + call = build_task_set_destroy(session.account_id, [task_id]) + responses = self._request([call], using=_TASK_USING) + + for method_name, resp_args, _ in responses: + if method_name == "Task/set": + _, _, _, _, _, not_destroyed = parse_task_set(resp_args) + if task_id in not_destroyed: + self._raise_set_error(session, not_destroyed[task_id]) + return + + raise JMAPMethodError(url=session.api_url, reason="No Task/set response") diff --git a/caldav/jmap/methods/task.py b/caldav/jmap/methods/task.py new file mode 100644 index 00000000..fa8def68 --- /dev/null +++ b/caldav/jmap/methods/task.py @@ -0,0 +1,176 @@ +""" +JMAP Task and TaskList 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.5 (set); Task-specific +properties are defined in RFC 9553 (JMAP for Tasks). +""" + +from __future__ import annotations + +from caldav.jmap.objects.task import JMAPTask, JMAPTaskList + + +def build_task_list_get( + account_id: str, + ids: list[str] | None = None, + properties: list[str] | None = None, +) -> tuple: + """Build a ``TaskList/get`` method call tuple. + + Args: + account_id: The JMAP accountId to query. + ids: List of task list IDs to fetch, or ``None`` to fetch all. + properties: List of property names to return, or ``None`` for all. + + Returns: + A 3-tuple ``("TaskList/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 ("TaskList/get", args, "tasklist-get-0") + + +def parse_task_list_get(response_args: dict) -> list[JMAPTaskList]: + """Parse the arguments dict from a ``TaskList/get`` method response. + + Args: + response_args: The second element of a ``methodResponses`` entry + whose method name is ``"TaskList/get"``. + + Returns: + List of :class:`~caldav.jmap.objects.task.JMAPTaskList` objects. + Returns an empty list if ``"list"`` is absent or empty. + """ + return [JMAPTaskList.from_jmap(item) for item in response_args.get("list", [])] + + +def build_task_get( + account_id: str, + ids: list[str] | None = None, + properties: list[str] | None = None, +) -> tuple: + """Build a ``Task/get`` method call tuple. + + Args: + account_id: The JMAP accountId to query. + ids: List of task IDs to fetch, or ``None`` to fetch all. + properties: List of property names to return, or ``None`` for all. + + Returns: + A 3-tuple ``("Task/get", arguments_dict, call_id)``. + """ + args: dict = {"accountId": account_id, "ids": ids} + if properties is not None: + args["properties"] = properties + return ("Task/get", args, "task-get-0") + + +def parse_task_get(response_args: dict) -> list[JMAPTask]: + """Parse the arguments dict from a ``Task/get`` method response. + + Args: + response_args: The second element of a ``methodResponses`` entry + whose method name is ``"Task/get"``. + + Returns: + List of :class:`~caldav.jmap.objects.task.JMAPTask` objects. + Returns an empty list if ``"list"`` is absent or empty. + """ + return [JMAPTask.from_jmap(item) for item in response_args.get("list", [])] + + +def build_task_set_create( + account_id: str, + tasks: dict[str, JMAPTask], +) -> tuple: + """Build a ``Task/set`` method call for creating tasks. + + Args: + account_id: The JMAP accountId. + tasks: Map of client-assigned creation ID → :class:`JMAPTask`. + + Returns: + A 3-tuple ``("Task/set", arguments_dict, call_id)``. + """ + return ( + "Task/set", + { + "accountId": account_id, + "create": {cid: task.to_jmap() for cid, task in tasks.items()}, + }, + "task-set-create-0", + ) + + +def build_task_set_update( + account_id: str, + updates: dict[str, dict], +) -> tuple: + """Build a ``Task/set`` method call for updating tasks. + + Args: + account_id: The JMAP accountId. + updates: Map of task ID → partial patch dict. + + Returns: + A 3-tuple ``("Task/set", arguments_dict, call_id)``. + """ + return ( + "Task/set", + {"accountId": account_id, "update": updates}, + "task-set-update-0", + ) + + +def build_task_set_destroy( + account_id: str, + ids: list[str], +) -> tuple: + """Build a ``Task/set`` method call for destroying tasks. + + Args: + account_id: The JMAP accountId. + ids: List of task IDs to destroy. + + Returns: + A 3-tuple ``("Task/set", arguments_dict, call_id)``. + """ + return ( + "Task/set", + {"accountId": account_id, "destroy": ids}, + "task-set-destroy-0", + ) + + +def parse_task_set( + response_args: dict, +) -> tuple[dict, dict, list[str], dict, dict, dict]: + """Parse the arguments dict from a ``Task/set`` method response. + + Args: + response_args: The second element of a ``methodResponses`` entry + whose method name is ``"Task/set"``. + + Returns: + A 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``: + + - ``created``: Map of creation ID → server-assigned task dict. + - ``updated``: Map of task ID → null or partial server-updated object. + - ``destroyed``: List of successfully destroyed task IDs. + - ``not_created``: Map of creation ID → SetError dict for failed creates. + - ``not_updated``: Map of task ID → SetError dict for failed updates. + - ``not_destroyed``: Map of task ID → SetError dict for failed destroys. + """ + 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 diff --git a/caldav/jmap/objects/task.py b/caldav/jmap/objects/task.py new file mode 100644 index 00000000..d783e426 --- /dev/null +++ b/caldav/jmap/objects/task.py @@ -0,0 +1,216 @@ +""" +JMAP Task and TaskList objects. + +Represents JMAP Task and TaskList resources as returned by ``Task/get`` and +``TaskList/get``. Properties follow RFC 9553 (JMAP for Tasks) and RFC 8984 +(JSCalendar), extended with JMAP-specific additions. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class JMAPTaskList: + """A JMAP TaskList object. + + Attributes: + id: Server-assigned identifier (immutable, scoped to account). + name: Display name of the task list. + description: Optional longer description. + color: Optional CSS color string (e.g. ``"#ff0000"``). + is_subscribed: Whether the user is subscribed to this list. + my_rights: Dict of right names → bool for the current user. + sort_order: Hint for display ordering (lower = first). + time_zone: IANA timezone name for tasks in this list. + role: ``"inbox"``, ``"trash"``, or ``None`` for a regular list. + """ + + id: str + name: str + description: str | None = None + color: str | None = None + is_subscribed: bool = True + my_rights: dict = field(default_factory=dict) + sort_order: int = 0 + time_zone: str | None = None + role: str | None = None + + @classmethod + def from_jmap(cls, data: dict) -> JMAPTaskList: + """Construct a JMAPTaskList from a raw JMAP TaskList JSON dict. + + Unknown keys are silently ignored for forward compatibility. + """ + return cls( + id=data["id"], + name=data["name"], + description=data.get("description"), + color=data.get("color"), + is_subscribed=data.get("isSubscribed", True), + my_rights=data.get("myRights", {}), + sort_order=data.get("sortOrder", 0), + time_zone=data.get("timeZone"), + role=data.get("role"), + ) + + def to_jmap(self) -> dict: + """Serialise to a JMAP TaskList JSON dict for ``TaskList/set``. + + ``id`` and ``myRights`` are intentionally excluded — both are + server-set and must not appear in create or update payloads. + Optional fields are included only when they hold a non-default value. + """ + d: dict = { + "name": self.name, + "isSubscribed": self.is_subscribed, + "sortOrder": self.sort_order, + } + if self.description is not None: + d["description"] = self.description + if self.color is not None: + d["color"] = self.color + if self.time_zone is not None: + d["timeZone"] = self.time_zone + if self.role is not None: + d["role"] = self.role + return d + + +@dataclass +class JMAPTask: + """A JMAP Task object. + + Fields map to RFC 9553 (JMAP for Tasks) and RFC 8984 (JSCalendar Task). + Only user-settable fields are stored; server-computed properties + (``utcStart``, ``utcDue``) are not included. + + Note: ``estimatedDuration`` is the Task equivalent of an Event's + ``duration`` — they are distinct fields with different wire names. + ``due`` (LocalDateTime) replaces the Event's ``start + duration`` pattern. + + Attributes: + id: Server-assigned identifier (immutable, scoped to account). + uid: iCalendar UID — stable across copies. + task_list_id: ID of the parent TaskList. + title: Short summary. Maps to VTODO ``SUMMARY``. + description: Full description. Maps to VTODO ``DESCRIPTION``. + start: Local start date-time (no TZ suffix). Maps to VTODO ``DTSTART``. + due: Local due date-time (no TZ suffix). Maps to VTODO ``DUE``. + time_zone: IANA timezone name for ``start`` and ``due``. + estimated_duration: ISO 8601 duration. Maps to VTODO ``DURATION``. + percent_complete: Progress percentage 0–100. Maps to VTODO ``PERCENT-COMPLETE``. + progress: Lifecycle status. Maps to VTODO ``STATUS``. + progress_updated: UTC timestamp of last progress change. + priority: Priority –9 to 9. Maps to VTODO ``PRIORITY``. + is_draft: When ``True``, server suppresses alerts and scheduling messages. + keywords: Tag set (map of string → ``True``). Maps to VTODO ``CATEGORIES``. + recurrence_rules: List of RecurrenceRule dicts. + recurrence_overrides: Map of LocalDateTime → patch dict. + alerts: Map of alert id → alert dict. + participants: Map of participant id → participant dict. + color: Optional CSS color hint. + privacy: Optional visibility level (``"public"``, ``"private"``, ``"secret"``). + """ + + id: str + uid: str + task_list_id: str + title: str = "" + description: str | None = None + start: str | None = None + due: str | None = None + time_zone: str | None = None + estimated_duration: str | None = None + percent_complete: int = 0 + progress: str = "needs-action" + progress_updated: str | None = None + priority: int = 0 + is_draft: bool = False + keywords: dict = field(default_factory=dict) + recurrence_rules: list = field(default_factory=list) + recurrence_overrides: dict = field(default_factory=dict) + alerts: dict = field(default_factory=dict) + participants: dict = field(default_factory=dict) + color: str | None = None + privacy: str | None = None + + @classmethod + def from_jmap(cls, data: dict) -> JMAPTask: + """Construct a JMAPTask from a raw JMAP Task JSON dict. + + ``id``, ``uid``, and ``taskListId`` are required; a missing key raises + ``KeyError``. Unknown keys are silently ignored for forward compatibility. + """ + return cls( + id=data["id"], + uid=data["uid"], + task_list_id=data["taskListId"], + title=data.get("title", ""), + description=data.get("description"), + start=data.get("start"), + due=data.get("due"), + time_zone=data.get("timeZone"), + estimated_duration=data.get("estimatedDuration"), + percent_complete=data.get("percentComplete", 0), + progress=data.get("progress", "needs-action"), + progress_updated=data.get("progressUpdated"), + priority=data.get("priority", 0), + is_draft=data.get("isDraft", False), + keywords=data.get("keywords") or {}, + recurrence_rules=data.get("recurrenceRules") or [], + recurrence_overrides=data.get("recurrenceOverrides") or {}, + alerts=data.get("alerts") or {}, + participants=data.get("participants") or {}, + color=data.get("color"), + privacy=data.get("privacy"), + ) + + def to_jmap(self) -> dict: + """Serialise to a JMAP Task JSON dict for ``Task/set``. + + Includes ``@type: "Task"`` — the server requires this discriminator to + distinguish Task from CalendarEvent in mixed-type contexts. + + ``id`` is intentionally excluded — it is server-assigned on create. + Optional fields are included only when they hold a non-default value. + """ + d: dict = { + "@type": "Task", + "uid": self.uid, + "taskListId": self.task_list_id, + "title": self.title, + "percentComplete": self.percent_complete, + "progress": self.progress, + "priority": self.priority, + } + if self.description is not None: + d["description"] = self.description + if self.start is not None: + d["start"] = self.start + if self.due is not None: + d["due"] = self.due + if self.time_zone is not None: + d["timeZone"] = self.time_zone + if self.estimated_duration is not None: + d["estimatedDuration"] = self.estimated_duration + if self.progress_updated is not None: + d["progressUpdated"] = self.progress_updated + if self.is_draft: + d["isDraft"] = self.is_draft + if self.keywords: + d["keywords"] = self.keywords + if self.recurrence_rules: + d["recurrenceRules"] = self.recurrence_rules + if self.recurrence_overrides: + d["recurrenceOverrides"] = self.recurrence_overrides + if self.alerts: + d["alerts"] = self.alerts + if self.participants: + d["participants"] = self.participants + if self.color is not None: + d["color"] = self.color + if self.privacy is not None: + d["privacy"] = self.privacy + return d diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index 29d7e4b9..9c20dc53 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -93,7 +93,7 @@ def test_jmap_auth_error_catchable_as_authorization_error(self): # Session establishment # --------------------------------------------------------------------------- -from caldav.jmap.constants import CALENDAR_CAPABILITY +from caldav.jmap.constants import CALENDAR_CAPABILITY, TASK_CAPABILITY from caldav.jmap.session import Session, fetch_session # Minimal valid Session JSON fixture @@ -763,6 +763,17 @@ def test_from_jmap_explicit_empty_collections_are_empty(self): parse_event_query, parse_event_set, ) +from caldav.jmap.methods.task import ( + build_task_get, + build_task_list_get, + build_task_set_create, + build_task_set_destroy, + build_task_set_update, + parse_task_get, + parse_task_list_get, + parse_task_set, +) +from caldav.jmap.objects.task import JMAPTask, JMAPTaskList class TestEventMethodBuilders: @@ -1878,3 +1889,373 @@ def test_parse_event_changes_all_fields(self): assert created == ["ev1"] assert updated == ["ev2"] assert destroyed == ["ev3"] + + +class TestJMAPTaskList: + _FULL = { + "id": "tl1", + "name": "Work Tasks", + "description": "All work-related tasks", + "color": "#0000ff", + "isSubscribed": True, + "myRights": {"mayReadItems": True, "mayWriteAll": True}, + "sortOrder": 1, + "timeZone": "Europe/Berlin", + "role": "inbox", + } + + def test_from_jmap_required_fields(self): + tl = JMAPTaskList.from_jmap({"id": "tl1", "name": "My Tasks"}) + assert tl.id == "tl1" + assert tl.name == "My Tasks" + + def test_from_jmap_optional_defaults(self): + tl = JMAPTaskList.from_jmap({"id": "tl1", "name": "My Tasks"}) + assert tl.description is None + assert tl.color is None + assert tl.is_subscribed is True + assert tl.my_rights == {} + assert tl.sort_order == 0 + assert tl.time_zone is None + assert tl.role is None + + def test_from_jmap_full(self): + tl = JMAPTaskList.from_jmap(self._FULL) + assert tl.description == "All work-related tasks" + assert tl.color == "#0000ff" + assert tl.sort_order == 1 + assert tl.time_zone == "Europe/Berlin" + assert tl.role == "inbox" + + def test_to_jmap_excludes_server_set_fields(self): + tl = JMAPTaskList.from_jmap(self._FULL) + d = tl.to_jmap() + assert "id" not in d + assert "myRights" not in d + + def test_to_jmap_includes_required_fields(self): + tl = JMAPTaskList.from_jmap({"id": "tl1", "name": "My Tasks"}) + d = tl.to_jmap() + assert d["name"] == "My Tasks" + assert "isSubscribed" in d + assert "sortOrder" in d + + def test_to_jmap_omits_none_optionals(self): + tl = JMAPTaskList.from_jmap({"id": "tl1", "name": "My Tasks"}) + d = tl.to_jmap() + assert "description" not in d + assert "color" not in d + assert "timeZone" not in d + assert "role" not in d + + +class TestJMAPTask: + _FULL = { + "id": "task1", + "uid": "uid-123@example.com", + "taskListId": "tl1", + "title": "Buy groceries", + "description": "Milk and eggs", + "start": "2026-02-20T09:00:00", + "due": "2026-02-20T18:00:00", + "timeZone": "Europe/Berlin", + "estimatedDuration": "PT1H", + "percentComplete": 50, + "progress": "in-process", + "progressUpdated": "2026-02-20T10:00:00Z", + "priority": 1, + "isDraft": False, + "keywords": {"urgent": True}, + "color": "red", + "privacy": "private", + } + + def test_from_jmap_required_fields(self): + task = JMAPTask.from_jmap({"id": "t1", "uid": "u1", "taskListId": "tl1"}) + assert task.id == "t1" + assert task.uid == "u1" + assert task.task_list_id == "tl1" + + def test_from_jmap_optional_defaults(self): + task = JMAPTask.from_jmap({"id": "t1", "uid": "u1", "taskListId": "tl1"}) + assert task.title == "" + assert task.description is None + assert task.start is None + assert task.due is None + assert task.time_zone is None + assert task.estimated_duration is None + assert task.percent_complete == 0 + assert task.progress == "needs-action" + assert task.priority == 0 + assert task.is_draft is False + assert task.keywords == {} + assert task.recurrence_rules == [] + assert task.recurrence_overrides == {} + assert task.alerts == {} + assert task.participants == {} + assert task.color is None + assert task.privacy is None + + def test_from_jmap_full(self): + task = JMAPTask.from_jmap(self._FULL) + assert task.title == "Buy groceries" + assert task.percent_complete == 50 + assert task.progress == "in-process" + assert task.estimated_duration == "PT1H" + assert task.time_zone == "Europe/Berlin" + assert task.keywords == {"urgent": True} + + def test_to_jmap_includes_type_discriminator(self): + task = JMAPTask.from_jmap({"id": "t1", "uid": "u1", "taskListId": "tl1"}) + assert task.to_jmap()["@type"] == "Task" + + def test_to_jmap_excludes_id(self): + task = JMAPTask.from_jmap(self._FULL) + assert "id" not in task.to_jmap() + + def test_to_jmap_omits_none_optionals(self): + task = JMAPTask.from_jmap({"id": "t1", "uid": "u1", "taskListId": "tl1"}) + d = task.to_jmap() + assert "description" not in d + assert "start" not in d + assert "due" not in d + assert "timeZone" not in d + assert "estimatedDuration" not in d + assert "color" not in d + assert "privacy" not in d + + def test_to_jmap_includes_task_list_id(self): + task = JMAPTask.from_jmap({"id": "t1", "uid": "u1", "taskListId": "tl1"}) + assert task.to_jmap()["taskListId"] == "tl1" + + +class TestTaskMethodBuilders: + def test_build_task_list_get_structure(self): + method, args, call_id = build_task_list_get("u1") + assert method == "TaskList/get" + assert args["accountId"] == "u1" + assert args["ids"] is None + assert call_id == "tasklist-get-0" + + def test_build_task_get_structure(self): + method, args, call_id = build_task_get("u1") + assert method == "Task/get" + assert args["accountId"] == "u1" + assert args["ids"] is None + assert call_id == "task-get-0" + + def test_build_task_get_with_ids(self): + _, args, _ = build_task_get("u1", ids=["t1", "t2"]) + assert args["ids"] == ["t1", "t2"] + + def test_build_task_set_create_structure(self): + task = JMAPTask(id="", uid="u1", task_list_id="tl1", title="Test") + method, args, call_id = build_task_set_create("acct1", {"new-0": task}) + assert method == "Task/set" + assert "create" in args + assert "@type" in args["create"]["new-0"] + assert call_id == "task-set-create-0" + + def test_build_task_set_update_structure(self): + method, args, call_id = build_task_set_update("acct1", {"t1": {"title": "New"}}) + assert method == "Task/set" + assert args["update"] == {"t1": {"title": "New"}} + assert call_id == "task-set-update-0" + + def test_build_task_set_destroy_structure(self): + method, args, call_id = build_task_set_destroy("acct1", ["t1"]) + assert method == "Task/set" + assert args["destroy"] == ["t1"] + assert call_id == "task-set-destroy-0" + + def test_parse_task_list_get_returns_tasklists(self): + resp_args = {"list": [{"id": "tl1", "name": "Work"}, {"id": "tl2", "name": "Home"}]} + results = parse_task_list_get(resp_args) + assert len(results) == 2 + assert all(isinstance(r, JMAPTaskList) for r in results) + assert results[0].name == "Work" + + def test_parse_task_get_returns_tasks(self): + resp_args = { + "list": [ + {"id": "t1", "uid": "uid-1", "taskListId": "tl1", "title": "Buy milk"}, + {"id": "t2", "uid": "uid-2", "taskListId": "tl1", "title": "Call dentist"}, + ] + } + results = parse_task_get(resp_args) + assert len(results) == 2 + assert all(isinstance(r, JMAPTask) for r in results) + assert results[0].title == "Buy milk" + + def test_parse_task_set_all_fields(self): + resp_args = { + "created": {"new-0": {"id": "t1"}}, + "updated": {"t2": None}, + "destroyed": ["t3"], + "notCreated": {"new-1": {"type": "invalidArguments"}}, + "notUpdated": {}, + "notDestroyed": {}, + } + created, updated, destroyed, not_created, not_updated, not_destroyed = parse_task_set( + resp_args + ) + assert created == {"new-0": {"id": "t1"}} + assert destroyed == ["t3"] + assert not_created == {"new-1": {"type": "invalidArguments"}} + + +class TestJMAPClientTasks: + _MINIMAL_TASK = { + "id": "task1", + "uid": "uid-task-1@example.com", + "taskListId": "tl1", + "title": "Buy groceries", + "percentComplete": 0, + "progress": "needs-action", + "priority": 0, + } + + _MINIMAL_TASKLIST = { + "id": "tl1", + "name": "My Tasks", + } + + def _set_response(self, **kwargs): + return {"methodResponses": [["Task/set", kwargs, "task-set-create-0"]]} + + def _get_response(self, items): + return { + "methodResponses": [ + [ + "Task/get", + {"accountId": _USERNAME, "list": items, "notFound": []}, + "task-get-0", + ] + ] + } + + def _tasklist_response(self, items): + return { + "methodResponses": [ + [ + "TaskList/get", + {"accountId": _USERNAME, "list": items, "notFound": []}, + "tasklist-get-0", + ] + ] + } + + def _make_mock(self, resp_json): + m = MagicMock() + m.status_code = 200 + m.json.return_value = resp_json + m.raise_for_status = MagicMock() + return m + + def _make_client(self): + client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) + client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-abc") + return client + + def test_get_task_lists_returns_list(self, monkeypatch): + resp = self._tasklist_response([self._MINIMAL_TASKLIST]) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + result = self._make_client().get_task_lists() + assert len(result) == 1 + assert isinstance(result[0], JMAPTaskList) + assert result[0].name == "My Tasks" + + def test_create_task_returns_server_id(self, monkeypatch): + resp = self._set_response(created={"new-0": {"id": "sv-task-1"}}) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + task_id = self._make_client().create_task("tl1", "Buy groceries") + assert task_id == "sv-task-1" + + def test_create_task_passes_task_list_id(self, monkeypatch): + captured = {} + resp = self._set_response(created={"new-0": {"id": "sv-task-1"}}) + + def capturing_post(*args, **kwargs): + captured["json"] = kwargs.get("json", {}) + return self._make_mock(resp) + + monkeypatch.setattr("caldav.jmap.client.requests.post", capturing_post) + self._make_client().create_task("my-list", "Test Task") + create_args = captured["json"]["methodCalls"][0][1] + assert create_args["create"]["new-0"]["taskListId"] == "my-list" + + def test_create_task_raises_on_failure(self, monkeypatch): + resp = self._set_response(notCreated={"new-0": {"type": "invalidArguments"}}) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + with pytest.raises(JMAPMethodError) as exc_info: + self._make_client().create_task("tl1", "Test") + assert exc_info.value.error_type == "invalidArguments" + + def test_get_task_returns_task_object(self, monkeypatch): + resp = self._get_response([self._MINIMAL_TASK]) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + task = self._make_client().get_task("task1") + assert isinstance(task, JMAPTask) + assert task.title == "Buy groceries" + + def test_get_task_raises_on_not_found(self, monkeypatch): + resp = self._get_response([]) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + with pytest.raises(JMAPMethodError) as exc_info: + self._make_client().get_task("missing") + assert exc_info.value.error_type == "notFound" + + def test_update_task_success(self, monkeypatch): + resp = self._set_response(updated={"task1": None}) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + self._make_client().update_task("task1", {"title": "Updated"}) + + def test_update_task_raises_on_failure(self, monkeypatch): + resp = self._set_response(notUpdated={"task1": {"type": "notFound"}}) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + with pytest.raises(JMAPMethodError) as exc_info: + self._make_client().update_task("task1", {"title": "X"}) + assert exc_info.value.error_type == "notFound" + + def test_delete_task_success(self, monkeypatch): + resp = self._set_response(destroyed=["task1"]) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + self._make_client().delete_task("task1") + + def test_delete_task_raises_on_failure(self, monkeypatch): + resp = self._set_response(notDestroyed={"task1": {"type": "notFound"}}) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + with pytest.raises(JMAPMethodError) as exc_info: + self._make_client().delete_task("task1") + assert exc_info.value.error_type == "notFound" + + def test_task_requests_use_task_capability(self, monkeypatch): + captured = {} + resp = self._tasklist_response([self._MINIMAL_TASKLIST]) + + def capturing_post(*args, **kwargs): + captured["json"] = kwargs.get("json", {}) + return self._make_mock(resp) + + monkeypatch.setattr("caldav.jmap.client.requests.post", capturing_post) + self._make_client().get_task_lists() + assert TASK_CAPABILITY in captured["json"]["using"] + assert CALENDAR_CAPABILITY not in captured["json"]["using"] From 02a88f165977bc48790eafc000ed4a8ede35cec4 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 20:17:50 +0530 Subject: [PATCH 21/31] style: remove section-label comments from JMAP test files --- tests/test_jmap_integration.py | 18 ---------- tests/test_jmap_unit.py | 60 ---------------------------------- 2 files changed, 78 deletions(-) diff --git a/tests/test_jmap_integration.py b/tests/test_jmap_integration.py index 747fc7cc..4b2f42b7 100644 --- a/tests/test_jmap_integration.py +++ b/tests/test_jmap_integration.py @@ -23,10 +23,6 @@ from caldav.jmap.constants import CALENDAR_CAPABILITY from caldav.jmap.session import fetch_session -# --------------------------------------------------------------------------- -# Skip the whole module if Cyrus is not reachable -# --------------------------------------------------------------------------- - CYRUS_HOST = "localhost" CYRUS_PORT = 8802 JMAP_URL = f"http://{CYRUS_HOST}:{CYRUS_PORT}/.well-known/jmap" @@ -50,10 +46,6 @@ def _cyrus_reachable() -> bool: "start it with: docker-compose -f tests/docker-test-servers/cyrus/docker-compose.yml up -d", ) -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - @pytest.fixture(scope="module") def client(): @@ -65,11 +57,6 @@ def session(): return fetch_session(JMAP_URL, auth=HTTPBasicAuth(CYRUS_USERNAME, CYRUS_PASSWORD)) -# --------------------------------------------------------------------------- -# Session -# --------------------------------------------------------------------------- - - class TestJMAPSessionIntegration: def test_session_fetch_returns_api_url(self, session): assert session.api_url @@ -82,11 +69,6 @@ def test_session_has_calendar_capability(self, session): assert CALENDAR_CAPABILITY in session.account_capabilities -# --------------------------------------------------------------------------- -# Calendar listing -# --------------------------------------------------------------------------- - - class TestJMAPCalendarListIntegration: def test_list_calendars_returns_list(self, client): calendars = client.get_calendars() diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index 9c20dc53..f2019e6e 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -14,19 +14,11 @@ except ImportError: from requests.auth import HTTPBasicAuth # type: ignore[no-redef] -# --------------------------------------------------------------------------- -# Shared test fixtures (module-level constants — not hardcoded inline) -# --------------------------------------------------------------------------- - _JMAP_URL = "http://localhost:8802/.well-known/jmap" _API_URL = "http://localhost:8802/jmap/api" _USERNAME = "user1" _PASSWORD = "x" -# --------------------------------------------------------------------------- -# Error hierarchy -# --------------------------------------------------------------------------- - from caldav.jmap.error import ( JMAPAuthError, JMAPCapabilityError, @@ -89,10 +81,6 @@ def test_jmap_auth_error_catchable_as_authorization_error(self): raise JMAPAuthError() -# --------------------------------------------------------------------------- -# Session establishment -# --------------------------------------------------------------------------- - from caldav.jmap.constants import CALENDAR_CAPABILITY, TASK_CAPABILITY from caldav.jmap.session import Session, fetch_session @@ -219,10 +207,6 @@ def test_picks_first_calendar_capable_account(self): assert session.account_id == "user_calendar" -# --------------------------------------------------------------------------- -# Calendar domain object -# --------------------------------------------------------------------------- - from caldav.jmap.objects.calendar import JMAPCalendar _CALENDAR_JSON_FULL = { @@ -300,10 +284,6 @@ def test_from_jmap_raises_when_name_missing(self): JMAPCalendar.from_jmap({"id": "cal3"}) -# --------------------------------------------------------------------------- -# Calendar method builders and parsers -# --------------------------------------------------------------------------- - from caldav.jmap.methods.calendar import ( build_calendar_changes, build_calendar_get, @@ -355,10 +335,6 @@ def test_build_calendar_changes_structure(self): assert isinstance(call_id, str) -# --------------------------------------------------------------------------- -# JMAPClient -# --------------------------------------------------------------------------- - from caldav.jmap.client import JMAPClient _CALENDAR_GET_RESPONSE = { @@ -471,10 +447,6 @@ def test_request_raises_method_error_on_error_response(self, monkeypatch): assert exc_info.value.error_type == "unknownMethod" -# --------------------------------------------------------------------------- -# get_jmap_client factory -# --------------------------------------------------------------------------- - from caldav.jmap import get_jmap_client @@ -500,10 +472,6 @@ def test_strips_caldav_only_keys(self, monkeypatch): assert not hasattr(client, "ssl_verify_cert") -# --------------------------------------------------------------------------- -# CalendarEvent domain object -# --------------------------------------------------------------------------- - from caldav.jmap.objects.event import JMAPEvent _EVENT_JSON_FULL = { @@ -746,10 +714,6 @@ def test_from_jmap_explicit_empty_collections_are_empty(self): assert ev.recurrence_overrides == {} -# --------------------------------------------------------------------------- -# CalendarEvent method builders and parsers -# --------------------------------------------------------------------------- - from caldav.jmap.methods.event import ( build_event_changes, build_event_get, @@ -971,10 +935,6 @@ def test_parse_event_set_partial_failure(self): _timedelta_to_duration, ) -# --------------------------------------------------------------------------- -# Shared iCal fixtures -# --------------------------------------------------------------------------- - def _make_ical(extra_lines: str = "", uid: str = "test-uid@example.com") -> str: return ( @@ -1000,11 +960,6 @@ def _minimal_jscal(**kwargs) -> dict: return base -# --------------------------------------------------------------------------- -# TestUtils — shared utility functions -# --------------------------------------------------------------------------- - - class TestUtils: def test_timedelta_to_duration_hours(self): assert _timedelta_to_duration(timedelta(hours=1, minutes=30)) == "PT1H30M" @@ -1050,11 +1005,6 @@ def test_format_local_dt_date(self): assert _format_local_dt(d) == "2024-06-15T00:00:00" -# --------------------------------------------------------------------------- -# TestIcalToJscal -# --------------------------------------------------------------------------- - - class TestIcalToJscal: def test_minimal_event(self): ical = _make_ical("DTSTART:20240615T100000Z\r\nDURATION:PT1H\r\nSUMMARY:Test Event\r\n") @@ -1321,11 +1271,6 @@ def test_rrule_missing_freq_raises(self): ical_to_jscal(ical) -# --------------------------------------------------------------------------- -# TestJscalToIcal -# --------------------------------------------------------------------------- - - class TestJscalToIcal: def test_minimal_event(self): jscal = _minimal_jscal() @@ -1483,11 +1428,6 @@ def test_floating_datetime_emitted(self): assert "TZID" not in result -# --------------------------------------------------------------------------- -# TestRoundTrip -# --------------------------------------------------------------------------- - - class TestRoundTrip: def _key_fields_survive(self, original_ical: str) -> dict: """ical → jscal → ical → parse back and check.""" From 703ad6b5f5f878a302e572c50e06382d7b98b67c Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 20:49:55 +0530 Subject: [PATCH 22/31] feat(jmap): add AsyncJMAPClient mirroring public methods as coroutines --- caldav/jmap/__init__.py | 45 +++- caldav/jmap/async_client.py | 517 ++++++++++++++++++++++++++++++++++++ caldav/jmap/client.py | 54 ++-- caldav/jmap/session.py | 87 +++--- tests/test_jmap_unit.py | 474 ++++++++++++++++++++++++++++++++- 5 files changed, 1112 insertions(+), 65 deletions(-) create mode 100644 caldav/jmap/async_client.py diff --git a/caldav/jmap/__init__.py b/caldav/jmap/__init__.py index c05425a1..4cb556a2 100644 --- a/caldav/jmap/__init__.py +++ b/caldav/jmap/__init__.py @@ -1,8 +1,8 @@ """ JMAP calendar support for python-caldav. -Provides a synchronous JMAP client with the same public API as the -CalDAV client, so user code works regardless of server protocol. +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:: @@ -14,8 +14,20 @@ 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, @@ -24,6 +36,8 @@ JMAPMethodError, ) +_JMAP_KEYS = {"url", "username", "password", "auth", "auth_type", "timeout"} + def get_jmap_client(**kwargs) -> JMAPClient | None: """Create a :class:`JMAPClient` from configuration. @@ -47,17 +61,36 @@ def get_jmap_client(**kwargs) -> JMAPClient | None: 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. - # Strip CalDAV-only keys that JMAPClient does not accept. - _JMAP_KEYS = {"url", "username", "password", "auth", "auth_type", "timeout"} - jmap_params = {k: v for k, v in conn_params.items() if k in _JMAP_KEYS} + Accepts the same arguments and reads configuration from the same sources + as :func:`get_jmap_client`. Returns ``None`` if no configuration is found. - return JMAPClient(**jmap_params) + 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", diff --git a/caldav/jmap/async_client.py b/caldav/jmap/async_client.py new file mode 100644 index 00000000..dc3bdfa3 --- /dev/null +++ b/caldav/jmap/async_client.py @@ -0,0 +1,517 @@ +""" +Asynchronous JMAP client. + +Mirrors JMAPClient with all public methods as coroutines. +Uses niquests.AsyncSession for HTTP — niquests is a core dependency. +""" + +from __future__ import annotations + +import logging +import uuid + +from niquests import AsyncSession + +from caldav.jmap.client import _DEFAULT_USING, _TASK_USING, _JMAPClientBase +from caldav.jmap.convert import ical_to_jscal, jscal_to_ical +from caldav.jmap.error import JMAPAuthError, JMAPMethodError +from caldav.jmap.methods.calendar import build_calendar_get, parse_calendar_get +from caldav.jmap.methods.event import ( + build_event_changes, + build_event_get, + build_event_query, + build_event_set_destroy, + build_event_set_update, + parse_event_changes, + parse_event_set, +) +from caldav.jmap.methods.task import ( + build_task_get, + build_task_list_get, + build_task_set_create, + build_task_set_destroy, + build_task_set_update, + parse_task_list_get, + parse_task_set, +) +from caldav.jmap.objects.calendar import JMAPCalendar +from caldav.jmap.objects.task import JMAPTask, JMAPTaskList +from caldav.jmap.session import Session, async_fetch_session + +log = logging.getLogger("caldav.jmap") + + +class AsyncJMAPClient(_JMAPClientBase): + """Asynchronous JMAP client for calendar operations. + + 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() + + Args: + url: URL of the JMAP session endpoint (``/.well-known/jmap``). + username: Username for Basic auth. + password: Password for Basic auth, or bearer token if no username. + auth: A pre-built niquests-compatible auth object. Takes precedence + over username/password if provided. + auth_type: Force a specific auth type: ``"basic"`` or ``"bearer"``. + timeout: HTTP request timeout in seconds. + """ + + async def __aenter__(self) -> AsyncJMAPClient: + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + return None + + async def _get_session(self) -> Session: + """Return the cached Session, fetching it on first call.""" + if self._session_cache is None: + self._session_cache = await async_fetch_session( + self.url, auth=self._auth, timeout=self.timeout + ) + return self._session_cache + + async def _request(self, method_calls: list[tuple], using: list[str] | None = None) -> list: + """POST a batch of JMAP method calls and return the methodResponses. + + Args: + method_calls: List of 3-tuples ``(method_name, args_dict, call_id)``. + using: Capability URN list for the ``using`` field. Defaults to + ``_DEFAULT_USING`` (core + calendars). + + Returns: + List of 3-tuples ``(method_name, response_args, call_id)`` from + the server's ``methodResponses`` array. + + Raises: + JMAPAuthError: On HTTP 401 or 403. + JMAPMethodError: If any methodResponse is an ``error`` response. + """ + session = await self._get_session() + + payload = { + "using": using if using is not None else _DEFAULT_USING, + "methodCalls": list(method_calls), + } + + log.debug("JMAP POST to %s: %d method call(s)", session.api_url, len(method_calls)) + + async with AsyncSession() as http: + response = await http.post( + session.api_url, + json=payload, + auth=self._auth, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + timeout=self.timeout, + ) + + if response.status_code in (401, 403): + raise JMAPAuthError( + url=session.api_url, + reason=f"HTTP {response.status_code} from API endpoint", + ) + + response.raise_for_status() + + data = response.json() + method_responses = data.get("methodResponses", []) + + for resp in method_responses: + method_name, resp_args, call_id = resp + if method_name == "error": + error_type = resp_args.get("type", "serverError") + raise JMAPMethodError( + url=session.api_url, + reason=f"Method call failed: {resp_args}", + error_type=error_type, + ) + + return method_responses + + async def get_calendars(self) -> list[JMAPCalendar]: + """Fetch all calendars for the authenticated account. + + Returns: + List of :class:`~caldav.jmap.objects.calendar.JMAPCalendar` objects. + """ + session = await self._get_session() + call = build_calendar_get(session.account_id) + responses = await self._request([call]) + + for method_name, resp_args, _ in responses: + if method_name == "Calendar/get": + return parse_calendar_get(resp_args) + + return [] + + async def create_event(self, calendar_id: str, ical_str: str) -> str: + """Create a calendar event from an iCalendar string. + + Args: + calendar_id: The JMAP calendar ID to create the event in. + ical_str: A VCALENDAR string representing the event. + + Returns: + The server-assigned JMAP event ID. + + Raises: + JMAPMethodError: If the server rejects the create request. + """ + session = await self._get_session() + jscal = ical_to_jscal(ical_str, calendar_id=calendar_id) + call = ( + "CalendarEvent/set", + {"accountId": session.account_id, "create": {"new-0": jscal}}, + "ev-set-create-0", + ) + responses = await self._request([call]) + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/set": + created, _, _, not_created, _, _ = parse_event_set(resp_args) + if "new-0" in not_created: + self._raise_set_error(session, not_created["new-0"]) + return created["new-0"]["id"] + + raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response") + + async def get_event(self, event_id: str) -> str: + """Fetch a calendar event as an iCalendar string. + + Args: + event_id: The JMAP event ID to retrieve. + + Returns: + A VCALENDAR string for the event. + + Raises: + JMAPMethodError: If the event is not found. + """ + session = await self._get_session() + call = build_event_get(session.account_id, ids=[event_id]) + responses = await self._request([call]) + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/get": + items = resp_args.get("list", []) + if not items: + raise JMAPMethodError( + url=session.api_url, + reason=f"Event not found: {event_id}", + error_type="notFound", + ) + return jscal_to_ical(items[0]) + + raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/get response") + + async def update_event(self, event_id: str, ical_str: str) -> None: + """Update a calendar event from an iCalendar string. + + Args: + event_id: The JMAP event ID to update. + ical_str: A VCALENDAR string with the updated event data. + + Raises: + JMAPMethodError: If the server rejects the update. + """ + session = await self._get_session() + patch = ical_to_jscal(ical_str) + patch.pop("uid", None) # uid is server-immutable after creation; patch must omit it + call = build_event_set_update(session.account_id, {event_id: patch}) + responses = await self._request([call]) + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/set": + _, _, _, _, not_updated, _ = parse_event_set(resp_args) + if event_id in not_updated: + self._raise_set_error(session, not_updated[event_id]) + return + + raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response") + + async def search_events( + self, + calendar_id: str | None = None, + start: str | None = None, + end: str | None = None, + text: str | None = None, + ) -> list[str]: + """Search for calendar events and return them as iCalendar strings. + + All parameters are optional; omitting all returns every event in the account. + Results are fetched in a single batched JMAP request using a result reference + from ``CalendarEvent/query`` into ``CalendarEvent/get``. + + Args: + calendar_id: Limit results to this calendar. + start: Only events ending after this datetime (``YYYY-MM-DDTHH:MM:SS``). + end: Only events starting before this datetime (``YYYY-MM-DDTHH:MM:SS``). + text: Free-text search across title, description, locations, and participants. + + Returns: + List of VCALENDAR strings for all matching events. + """ + session = await self._get_session() + filter_dict: dict = {} + if calendar_id is not None: + filter_dict["inCalendars"] = [calendar_id] + if start is not None: + filter_dict["after"] = start + if end is not None: + filter_dict["before"] = end + if text is not None: + filter_dict["text"] = text + + query_call = build_event_query(session.account_id, filter=filter_dict or None) + get_call = ( + "CalendarEvent/get", + { + "accountId": session.account_id, + "#ids": { + "resultOf": "ev-query-0", + "name": "CalendarEvent/query", + "path": "/ids", + }, + }, + "ev-get-1", + ) + responses = await self._request([query_call, get_call]) + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/get": + return [jscal_to_ical(item) for item in resp_args.get("list", [])] + + return [] + + async def get_sync_token(self) -> str: + """Return the current CalendarEvent state string for use as a sync token. + + Calls ``CalendarEvent/get`` with an empty ID list — no event data is + transferred, only the ``state`` field from the response. + + Returns: + Opaque state string. Pass to :meth:`get_objects_by_sync_token` to + retrieve only what changed since this point. + """ + session = await self._get_session() + call = build_event_get(session.account_id, ids=[]) + responses = await self._request([call]) + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/get": + return resp_args.get("state", "") + raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/get response") + + async def get_objects_by_sync_token( + self, sync_token: str + ) -> tuple[list[str], list[str], list[str]]: + """Fetch events changed since a previous sync token. + + Calls ``CalendarEvent/changes`` to discover which events were created, + modified, or destroyed since ``sync_token`` was issued. Created and + modified events are returned as iCalendar strings; destroyed events are + returned as IDs (the objects no longer exist on the server). + + Args: + sync_token: A state string previously returned by :meth:`get_sync_token` + or by a prior call to this method. + + Returns: + A 3-tuple ``(added, modified, deleted)``: + + - ``added``: iCalendar strings for newly created events. + - ``modified``: iCalendar strings for updated events. + - ``deleted``: Event IDs that were destroyed. + + Raises: + JMAPMethodError: If the server reports ``hasMoreChanges: true``. + """ + session = await self._get_session() + changes_call = build_event_changes(session.account_id, sync_token) + responses = await self._request([changes_call]) + + created_ids: list[str] = [] + updated_ids: list[str] = [] + destroyed: list[str] = [] + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/changes": + _, _, has_more, created_ids, updated_ids, destroyed = parse_event_changes(resp_args) + if has_more: + raise JMAPMethodError( + url=session.api_url, + reason=( + "CalendarEvent/changes response was truncated by the server " + "(hasMoreChanges=true). Call get_sync_token() to obtain a " + "fresh baseline and re-sync." + ), + error_type="serverPartialFail", + ) + + fetch_ids = created_ids + updated_ids + if not fetch_ids: + return [], [], destroyed + + get_call = build_event_get(session.account_id, ids=fetch_ids) + get_responses = await self._request([get_call]) + + events_by_id: dict[str, str] = {} + for method_name, resp_args, _ in get_responses: + if method_name == "CalendarEvent/get": + for item in resp_args.get("list", []): + events_by_id[item["id"]] = jscal_to_ical(item) + + added = [events_by_id[i] for i in created_ids if i in events_by_id] + modified = [events_by_id[i] for i in updated_ids if i in events_by_id] + return added, modified, destroyed + + async def delete_event(self, event_id: str) -> None: + """Delete a calendar event. + + Args: + event_id: The JMAP event ID to delete. + + Raises: + JMAPMethodError: If the server rejects the delete. + """ + session = await self._get_session() + call = build_event_set_destroy(session.account_id, [event_id]) + responses = await self._request([call]) + + for method_name, resp_args, _ in responses: + if method_name == "CalendarEvent/set": + _, _, _, _, _, not_destroyed = parse_event_set(resp_args) + if event_id in not_destroyed: + self._raise_set_error(session, not_destroyed[event_id]) + return + + raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response") + + async def get_task_lists(self) -> list[JMAPTaskList]: + """Fetch all task lists for the authenticated account. + + Returns: + List of :class:`~caldav.jmap.objects.task.JMAPTaskList` objects. + """ + session = await self._get_session() + call = build_task_list_get(session.account_id) + responses = await self._request([call], using=_TASK_USING) + + for method_name, resp_args, _ in responses: + if method_name == "TaskList/get": + return parse_task_list_get(resp_args) + + return [] + + async def create_task(self, task_list_id: str, title: str, **kwargs) -> str: + """Create a task in a task list. + + Args: + task_list_id: The JMAP task list ID to create the task in. + title: Task title (maps to VTODO ``SUMMARY``). + **kwargs: Optional task fields: ``description``, ``due``, ``start``, + ``time_zone``, ``estimated_duration``, ``percent_complete``, + ``progress``, ``priority``. + + Returns: + The server-assigned JMAP task ID. + + Raises: + JMAPMethodError: If the server rejects the create request. + """ + session = await self._get_session() + task = JMAPTask( + id="", + uid=str(uuid.uuid4()), + task_list_id=task_list_id, + title=title, + **kwargs, + ) + call = build_task_set_create(session.account_id, {"new-0": task}) + responses = await self._request([call], using=_TASK_USING) + + for method_name, resp_args, _ in responses: + if method_name == "Task/set": + created, _, _, not_created, _, _ = parse_task_set(resp_args) + if "new-0" in not_created: + self._raise_set_error(session, not_created["new-0"]) + return created["new-0"]["id"] + + raise JMAPMethodError(url=session.api_url, reason="No Task/set response") + + async def get_task(self, task_id: str) -> JMAPTask: + """Fetch a task by ID. + + Args: + task_id: The JMAP task ID to retrieve. + + Returns: + A :class:`~caldav.jmap.objects.task.JMAPTask` object. + + Raises: + JMAPMethodError: If the task is not found. + """ + session = await self._get_session() + call = build_task_get(session.account_id, ids=[task_id]) + responses = await self._request([call], using=_TASK_USING) + + for method_name, resp_args, _ in responses: + if method_name == "Task/get": + items = resp_args.get("list", []) + if not items: + raise JMAPMethodError( + url=session.api_url, + reason=f"Task not found: {task_id}", + error_type="notFound", + ) + return JMAPTask.from_jmap(items[0]) + + raise JMAPMethodError(url=session.api_url, reason="No Task/get response") + + async def update_task(self, task_id: str, patch: dict) -> None: + """Update a task with a partial patch. + + Args: + task_id: The JMAP task ID to update. + patch: Partial patch dict mapping property names to new values. + + Raises: + JMAPMethodError: If the server rejects the update. + """ + session = await self._get_session() + call = build_task_set_update(session.account_id, {task_id: patch}) + responses = await self._request([call], using=_TASK_USING) + + for method_name, resp_args, _ in responses: + if method_name == "Task/set": + _, _, _, _, not_updated, _ = parse_task_set(resp_args) + if task_id in not_updated: + self._raise_set_error(session, not_updated[task_id]) + return + + raise JMAPMethodError(url=session.api_url, reason="No Task/set response") + + async def delete_task(self, task_id: str) -> None: + """Delete a task. + + Args: + task_id: The JMAP task ID to delete. + + Raises: + JMAPMethodError: If the server rejects the delete. + """ + session = await self._get_session() + call = build_task_set_destroy(session.account_id, [task_id]) + responses = await self._request([call], using=_TASK_USING) + + for method_name, resp_args, _ in responses: + if method_name == "Task/set": + _, _, _, _, _, not_destroyed = parse_task_set(resp_args) + if task_id in not_destroyed: + self._raise_set_error(session, not_destroyed[task_id]) + return + + raise JMAPMethodError(url=session.api_url, reason="No Task/set response") diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py index d16d5787..c5f08304 100644 --- a/caldav/jmap/client.py +++ b/caldav/jmap/client.py @@ -53,26 +53,7 @@ _TASK_USING = [CORE_CAPABILITY, TASK_CAPABILITY] -class JMAPClient: - """Synchronous JMAP client for calendar operations. - - 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() - - Args: - url: URL of the JMAP session endpoint (``/.well-known/jmap``). - username: Username for Basic auth. - password: Password for Basic auth, or bearer token if no username. - auth: A pre-built requests-compatible auth object. Takes precedence - over username/password if provided. - auth_type: Force a specific auth type: ``"basic"`` or ``"bearer"``. - timeout: HTTP request timeout in seconds. - """ - +class _JMAPClientBase: def __init__( self, url: str, @@ -93,12 +74,6 @@ def __init__( else: self._auth = self._build_auth(auth_type) - def __enter__(self) -> JMAPClient: - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - return None - def _build_auth(self, auth_type: str | None): """Select and construct the auth object. @@ -146,6 +121,33 @@ def _raise_set_error(self, session: Session, err: dict) -> None: error_type=err.get("type", "serverError"), ) + +class JMAPClient(_JMAPClientBase): + """Synchronous JMAP client for calendar operations. + + 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() + + Args: + url: URL of the JMAP session endpoint (``/.well-known/jmap``). + username: Username for Basic auth. + password: Password for Basic auth, or bearer token if no username. + auth: A pre-built requests-compatible auth object. Takes precedence + over username/password if provided. + auth_type: Force a specific auth type: ``"basic"`` or ``"bearer"``. + timeout: HTTP request timeout in seconds. + """ + + def __enter__(self) -> JMAPClient: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + return None + def _get_session(self) -> Session: """Return the cached Session, fetching it on first call.""" if self._session_cache is None: diff --git a/caldav/jmap/session.py b/caldav/jmap/session.py index 9e04056a..81309998 100644 --- a/caldav/jmap/session.py +++ b/caldav/jmap/session.py @@ -12,9 +12,12 @@ try: import niquests as requests + from niquests import AsyncSession except ImportError: import requests # type: ignore[no-redef] + AsyncSession = None # type: ignore[assignment,misc] + from caldav.jmap.constants import CALENDAR_CAPABILITY from caldav.jmap.error import JMAPAuthError, JMAPCapabilityError @@ -41,38 +44,7 @@ class Session: raw: dict = field(default_factory=dict) -def fetch_session(url: str, auth, timeout: int = 30) -> Session: - """Fetch and parse the JMAP Session object. - - Performs a GET request to ``url`` (expected to be ``/.well-known/jmap`` - or equivalent), authenticates with ``auth``, and returns a parsed - :class:`Session`. - - Args: - url: Full URL to the JMAP session endpoint. - auth: A requests-compatible auth object (e.g. HTTPBasicAuth, - HTTPBearerAuth). - - Returns: - Parsed :class:`Session` with ``api_url`` and ``account_id`` set. - - Raises: - JMAPAuthError: If the server returns HTTP 401 or 403. - JMAPCapabilityError: If no account advertises the calendars capability. - requests.HTTPError: For other non-2xx responses. - """ - response = requests.get(url, auth=auth, headers={"Accept": "application/json"}, timeout=timeout) - - if response.status_code in (401, 403): - raise JMAPAuthError( - url=url, - reason=f"HTTP {response.status_code} from session endpoint", - ) - - response.raise_for_status() - - data = response.json() - +def _parse_session_data(url: str, data: dict) -> Session: api_url = data.get("apiUrl") if not api_url: raise JMAPCapabilityError( @@ -114,3 +86,54 @@ def fetch_session(url: str, auth, timeout: int = 30) -> Session: server_capabilities=server_capabilities, raw=data, ) + + +def fetch_session(url: str, auth, timeout: int = 30) -> Session: + """Fetch and parse the JMAP Session object. + + Performs a GET request to ``url`` (expected to be ``/.well-known/jmap`` + or equivalent), authenticates with ``auth``, and returns a parsed + :class:`Session`. + + Args: + url: Full URL to the JMAP session endpoint. + auth: A requests-compatible auth object (e.g. HTTPBasicAuth, + HTTPBearerAuth). + + Returns: + Parsed :class:`Session` with ``api_url`` and ``account_id`` set. + + Raises: + JMAPAuthError: If the server returns HTTP 401 or 403. + JMAPCapabilityError: If no account advertises the calendars capability. + requests.HTTPError: For other non-2xx responses. + """ + response = requests.get(url, auth=auth, headers={"Accept": "application/json"}, timeout=timeout) + if response.status_code in (401, 403): + raise JMAPAuthError(url=url, reason=f"HTTP {response.status_code} from session endpoint") + response.raise_for_status() + return _parse_session_data(url, response.json()) + + +async def async_fetch_session(url: str, auth, timeout: int = 30) -> Session: + """Async variant of :func:`fetch_session` using niquests.AsyncSession. + + Args: + url: Full URL to the JMAP session endpoint. + auth: A niquests-compatible auth object. + + Returns: + Parsed :class:`Session` with ``api_url`` and ``account_id`` set. + + Raises: + JMAPAuthError: If the server returns HTTP 401 or 403. + JMAPCapabilityError: If no account advertises the calendars capability. + """ + async with AsyncSession() as session: + response = await session.get( + url, auth=auth, headers={"Accept": "application/json"}, timeout=timeout + ) + if response.status_code in (401, 403): + raise JMAPAuthError(url=url, reason=f"HTTP {response.status_code} from session endpoint") + response.raise_for_status() + return _parse_session_data(url, response.json()) diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index f2019e6e..5c1e3a16 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -5,7 +5,7 @@ External HTTP is mocked via unittest.mock wherever needed. """ -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -2199,3 +2199,475 @@ def capturing_post(*args, **kwargs): self._make_client().get_task_lists() assert TASK_CAPABILITY in captured["json"]["using"] assert CALENDAR_CAPABILITY not in captured["json"]["using"] + + +from caldav.jmap.async_client import AsyncJMAPClient + + +class TestAsyncJMAPClient: + _MINIMAL_ICAL = "\r\n".join( + [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "BEGIN:VEVENT", + "UID:async-test-uid@example.com", + "SUMMARY:Async Test Event", + "DTSTART:20260101T100000Z", + "DTEND:20260101T110000Z", + "END:VEVENT", + "END:VCALENDAR", + ] + ) + + _RAW_EVENT = { + "id": "ev-async-1", + "uid": "async-test-uid@example.com", + "calendarIds": {"cal1": True}, + "title": "Async Test Event", + "start": "2026-01-01T10:00:00", + "duration": "PT1H", + } + + _MINIMAL_TASK = { + "id": "task-async-1", + "uid": "uid-async-task@example.com", + "taskListId": "tl1", + "title": "Async Task", + "percentComplete": 0, + "progress": "needs-action", + "priority": 0, + } + + _MINIMAL_TASKLIST = {"id": "tl1", "name": "Async Tasks"} + + def _make_client(self): + client = AsyncJMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) + client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-async") + return client + + def _make_mock_response(self, resp_json): + m = MagicMock() + m.status_code = 200 + m.json.return_value = resp_json + m.raise_for_status = MagicMock() + return m + + def _patch_async_session(self, monkeypatch, resp_json): + mock_resp = self._make_mock_response(resp_json) + mock_http = MagicMock() + mock_http.__aenter__ = AsyncMock(return_value=mock_http) + mock_http.__aexit__ = AsyncMock(return_value=None) + mock_http.post = AsyncMock(return_value=mock_resp) + monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) + return mock_http + + def _calendar_get_resp(self, items): + return { + "methodResponses": [ + [ + "Calendar/get", + {"accountId": _USERNAME, "list": items, "notFound": []}, + "cal-get-0", + ] + ] + } + + def _event_set_resp(self, **kwargs): + return {"methodResponses": [["CalendarEvent/set", kwargs, "ev-set-0"]]} + + def _event_get_resp(self, items): + return { + "methodResponses": [ + [ + "CalendarEvent/get", + {"accountId": _USERNAME, "list": items, "notFound": []}, + "ev-get-0", + ] + ] + } + + def _query_get_resp(self, items): + return { + "methodResponses": [ + [ + "CalendarEvent/query", + {"ids": [i["id"] for i in items], "queryState": "qs-1", "total": len(items)}, + "ev-query-0", + ], + [ + "CalendarEvent/get", + {"accountId": _USERNAME, "list": items, "notFound": []}, + "ev-get-1", + ], + ] + } + + def _changes_resp(self, created=None, updated=None, destroyed=None, has_more=False): + return { + "methodResponses": [ + [ + "CalendarEvent/changes", + { + "accountId": _USERNAME, + "oldState": "state-1", + "newState": "state-2", + "hasMoreChanges": has_more, + "created": created or [], + "updated": updated or [], + "destroyed": destroyed or [], + }, + "ev-changes-0", + ] + ] + } + + def _task_set_resp(self, **kwargs): + return {"methodResponses": [["Task/set", kwargs, "task-set-0"]]} + + def _task_get_resp(self, items): + return { + "methodResponses": [ + [ + "Task/get", + {"accountId": _USERNAME, "list": items, "notFound": []}, + "task-get-0", + ] + ] + } + + def _tasklist_resp(self, items): + return { + "methodResponses": [ + [ + "TaskList/get", + {"accountId": _USERNAME, "list": items, "notFound": []}, + "tasklist-get-0", + ] + ] + } + + @pytest.mark.asyncio + async def test_context_manager(self): + async with AsyncJMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) as client: + assert isinstance(client, AsyncJMAPClient) + + @pytest.mark.asyncio + async def test_get_calendars_returns_list(self, monkeypatch): + cal = {"id": "cal1", "name": "Personal", "isSubscribed": True, "myRights": {}} + self._patch_async_session(monkeypatch, self._calendar_get_resp([cal])) + result = await self._make_client().get_calendars() + assert len(result) == 1 + assert isinstance(result[0], JMAPCalendar) + assert result[0].name == "Personal" + + @pytest.mark.asyncio + async def test_create_event_returns_id(self, monkeypatch): + resp = self._event_set_resp(created={"new-0": {"id": "ev-new-1"}}, notCreated={}) + self._patch_async_session(monkeypatch, resp) + event_id = await self._make_client().create_event("cal1", self._MINIMAL_ICAL) + assert event_id == "ev-new-1" + + @pytest.mark.asyncio + async def test_create_event_raises_on_failure(self, monkeypatch): + resp = self._event_set_resp(created={}, notCreated={"new-0": {"type": "invalidArguments"}}) + self._patch_async_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + await self._make_client().create_event("cal1", self._MINIMAL_ICAL) + assert exc_info.value.error_type == "invalidArguments" + + @pytest.mark.asyncio + async def test_get_event_returns_ical(self, monkeypatch): + self._patch_async_session(monkeypatch, self._event_get_resp([self._RAW_EVENT])) + result = await self._make_client().get_event("ev-async-1") + assert "VCALENDAR" in result + assert "Async Test Event" in result + + @pytest.mark.asyncio + async def test_get_event_raises_on_not_found(self, monkeypatch): + self._patch_async_session(monkeypatch, self._event_get_resp([])) + with pytest.raises(JMAPMethodError) as exc_info: + await self._make_client().get_event("missing") + assert exc_info.value.error_type == "notFound" + + @pytest.mark.asyncio + async def test_update_event_success(self, monkeypatch): + resp = self._event_set_resp(updated={"ev-async-1": None}, notUpdated={}) + self._patch_async_session(monkeypatch, resp) + await self._make_client().update_event("ev-async-1", self._MINIMAL_ICAL) + + @pytest.mark.asyncio + async def test_update_event_raises_on_failure(self, monkeypatch): + resp = self._event_set_resp(updated={}, notUpdated={"ev-async-1": {"type": "notFound"}}) + self._patch_async_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + await self._make_client().update_event("ev-async-1", self._MINIMAL_ICAL) + assert exc_info.value.error_type == "notFound" + + @pytest.mark.asyncio + async def test_delete_event_success(self, monkeypatch): + resp = self._event_set_resp(destroyed=["ev-async-1"], notDestroyed={}) + self._patch_async_session(monkeypatch, resp) + await self._make_client().delete_event("ev-async-1") + + @pytest.mark.asyncio + async def test_delete_event_raises_on_failure(self, monkeypatch): + resp = self._event_set_resp(destroyed=[], notDestroyed={"ev-async-1": {"type": "notFound"}}) + self._patch_async_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + await self._make_client().delete_event("ev-async-1") + assert exc_info.value.error_type == "notFound" + + @pytest.mark.asyncio + async def test_search_events_returns_ical_list(self, monkeypatch): + event2 = {**self._RAW_EVENT, "id": "ev-async-2", "title": "Another"} + self._patch_async_session(monkeypatch, self._query_get_resp([self._RAW_EVENT, event2])) + results = await self._make_client().search_events() + assert len(results) == 2 + assert all("VCALENDAR" in r for r in results) + + @pytest.mark.asyncio + async def test_search_events_empty_result(self, monkeypatch): + self._patch_async_session(monkeypatch, self._query_get_resp([])) + assert await self._make_client().search_events() == [] + + @pytest.mark.asyncio + async def test_get_sync_token_returns_state(self, monkeypatch): + resp = { + "methodResponses": [ + [ + "CalendarEvent/get", + {"accountId": _USERNAME, "state": "tok-async-1", "list": [], "notFound": []}, + "ev-get-0", + ] + ] + } + self._patch_async_session(monkeypatch, resp) + token = await self._make_client().get_sync_token() + assert token == "tok-async-1" + + @pytest.mark.asyncio + async def test_get_objects_no_changes(self, monkeypatch): + mock_http = MagicMock() + mock_http.__aenter__ = AsyncMock(return_value=mock_http) + mock_http.__aexit__ = AsyncMock(return_value=None) + mock_http.post = AsyncMock(return_value=self._make_mock_response(self._changes_resp())) + monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) + added, modified, deleted = await self._make_client().get_objects_by_sync_token("state-1") + assert added == [] and modified == [] and deleted == [] + + @pytest.mark.asyncio + async def test_get_objects_deleted_returns_ids(self, monkeypatch): + mock_http = MagicMock() + mock_http.__aenter__ = AsyncMock(return_value=mock_http) + mock_http.__aexit__ = AsyncMock(return_value=None) + mock_http.post = AsyncMock( + return_value=self._make_mock_response(self._changes_resp(destroyed=["ev1"])) + ) + monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) + added, modified, deleted = await self._make_client().get_objects_by_sync_token("state-1") + assert deleted == ["ev1"] and added == [] and modified == [] + + @pytest.mark.asyncio + async def test_get_objects_added_returns_ical(self, monkeypatch): + mock_http = MagicMock() + mock_http.__aenter__ = AsyncMock(return_value=mock_http) + mock_http.__aexit__ = AsyncMock(return_value=None) + mock_http.post = AsyncMock( + side_effect=[ + self._make_mock_response(self._changes_resp(created=["ev-async-1"])), + self._make_mock_response(self._event_get_resp([self._RAW_EVENT])), + ] + ) + monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) + added, modified, deleted = await self._make_client().get_objects_by_sync_token("state-1") + assert len(added) == 1 + assert "VCALENDAR" in added[0] + assert modified == [] and deleted == [] + + @pytest.mark.asyncio + async def test_get_task_lists_returns_list(self, monkeypatch): + self._patch_async_session(monkeypatch, self._tasklist_resp([self._MINIMAL_TASKLIST])) + result = await self._make_client().get_task_lists() + assert len(result) == 1 + assert isinstance(result[0], JMAPTaskList) + assert result[0].name == "Async Tasks" + + @pytest.mark.asyncio + async def test_create_task_returns_id(self, monkeypatch): + resp = self._task_set_resp(created={"new-0": {"id": "task-new-1"}}, notCreated={}) + self._patch_async_session(monkeypatch, resp) + task_id = await self._make_client().create_task("tl1", "Async Task") + assert task_id == "task-new-1" + + @pytest.mark.asyncio + async def test_get_task_returns_task(self, monkeypatch): + self._patch_async_session(monkeypatch, self._task_get_resp([self._MINIMAL_TASK])) + result = await self._make_client().get_task("task-async-1") + assert isinstance(result, JMAPTask) + assert result.title == "Async Task" + + @pytest.mark.asyncio + async def test_get_task_raises_on_not_found(self, monkeypatch): + self._patch_async_session(monkeypatch, self._task_get_resp([])) + with pytest.raises(JMAPMethodError) as exc_info: + await self._make_client().get_task("missing") + assert exc_info.value.error_type == "notFound" + + @pytest.mark.asyncio + async def test_update_task_success(self, monkeypatch): + resp = self._task_set_resp(updated={"task-async-1": None}, notUpdated={}) + self._patch_async_session(monkeypatch, resp) + await self._make_client().update_task("task-async-1", {"title": "Updated"}) + + @pytest.mark.asyncio + async def test_update_task_raises_on_failure(self, monkeypatch): + resp = self._task_set_resp(updated={}, notUpdated={"task-async-1": {"type": "notFound"}}) + self._patch_async_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + await self._make_client().update_task("task-async-1", {"title": "X"}) + assert exc_info.value.error_type == "notFound" + + @pytest.mark.asyncio + async def test_delete_task_success(self, monkeypatch): + resp = self._task_set_resp(destroyed=["task-async-1"], notDestroyed={}) + self._patch_async_session(monkeypatch, resp) + await self._make_client().delete_task("task-async-1") + + @pytest.mark.asyncio + async def test_delete_task_raises_on_failure(self, monkeypatch): + resp = self._task_set_resp( + destroyed=[], notDestroyed={"task-async-1": {"type": "notFound"}} + ) + self._patch_async_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + await self._make_client().delete_task("task-async-1") + assert exc_info.value.error_type == "notFound" + + @pytest.mark.asyncio + async def test_task_requests_use_task_capability(self, monkeypatch): + captured = {} + mock_resp = self._make_mock_response(self._tasklist_resp([self._MINIMAL_TASKLIST])) + mock_http = MagicMock() + mock_http.__aenter__ = AsyncMock(return_value=mock_http) + mock_http.__aexit__ = AsyncMock(return_value=None) + + async def capturing_post(*args, **kwargs): + captured["json"] = kwargs.get("json", {}) + return mock_resp + + mock_http.post = capturing_post + monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) + await self._make_client().get_task_lists() + assert TASK_CAPABILITY in captured["json"]["using"] + assert CALENDAR_CAPABILITY not in captured["json"]["using"] + + def _capturing_async_session(self, monkeypatch, resp_json): + captured = {} + mock_resp = self._make_mock_response(resp_json) + mock_http = MagicMock() + mock_http.__aenter__ = AsyncMock(return_value=mock_http) + mock_http.__aexit__ = AsyncMock(return_value=None) + + async def capturing_post(*args, **kwargs): + captured["json"] = kwargs.get("json", {}) + return mock_resp + + mock_http.post = capturing_post + monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) + return self._make_client(), captured + + @pytest.mark.asyncio + async def test_create_event_passes_calendar_id(self, monkeypatch): + resp = self._event_set_resp(created={"new-0": {"id": "ev-new-1"}}, notCreated={}) + client, captured = self._capturing_async_session(monkeypatch, resp) + await client.create_event("my-cal", self._MINIMAL_ICAL) + create_args = captured["json"]["methodCalls"][0][1] + new_event = create_args["create"]["new-0"] + assert "my-cal" in new_event.get("calendarIds", {}) + + @pytest.mark.asyncio + async def test_update_event_drops_uid_from_patch(self, monkeypatch): + resp = self._event_set_resp(updated={"ev-async-1": None}, notUpdated={}) + client, captured = self._capturing_async_session(monkeypatch, resp) + await client.update_event("ev-async-1", self._MINIMAL_ICAL) + update_args = captured["json"]["methodCalls"][0][1] + patch = update_args["update"]["ev-async-1"] + assert "uid" not in patch + + @pytest.mark.asyncio + async def test_search_events_passes_calendar_id_filter(self, monkeypatch): + client, captured = self._capturing_async_session( + monkeypatch, self._query_get_resp([self._RAW_EVENT]) + ) + await client.search_events(calendar_id="my-cal") + query_args = captured["json"]["methodCalls"][0][1] + assert query_args["filter"]["inCalendars"] == ["my-cal"] + + @pytest.mark.asyncio + async def test_search_events_passes_date_range_filter(self, monkeypatch): + client, captured = self._capturing_async_session( + monkeypatch, self._query_get_resp([self._RAW_EVENT]) + ) + await client.search_events(start="2026-01-01T00:00:00", end="2026-12-31T23:59:59") + query_args = captured["json"]["methodCalls"][0][1] + assert query_args["filter"]["after"] == "2026-01-01T00:00:00" + assert query_args["filter"]["before"] == "2026-12-31T23:59:59" + + @pytest.mark.asyncio + async def test_search_events_passes_text_filter(self, monkeypatch): + client, captured = self._capturing_async_session( + monkeypatch, self._query_get_resp([self._RAW_EVENT]) + ) + await client.search_events(text="standup") + query_args = captured["json"]["methodCalls"][0][1] + assert query_args["filter"]["text"] == "standup" + + @pytest.mark.asyncio + async def test_search_events_no_filter_when_no_args(self, monkeypatch): + client, captured = self._capturing_async_session( + monkeypatch, self._query_get_resp([self._RAW_EVENT]) + ) + await client.search_events() + query_args = captured["json"]["methodCalls"][0][1] + assert "filter" not in query_args + + @pytest.mark.asyncio + async def test_get_sync_token_sends_empty_ids(self, monkeypatch): + resp = { + "methodResponses": [ + [ + "CalendarEvent/get", + {"accountId": _USERNAME, "state": "tok-1", "list": [], "notFound": []}, + "ev-get-0", + ] + ] + } + client, captured = self._capturing_async_session(monkeypatch, resp) + await client.get_sync_token() + assert captured["json"]["methodCalls"][0][1]["ids"] == [] + + @pytest.mark.asyncio + async def test_get_objects_modified_returns_ical(self, monkeypatch): + mock_http = MagicMock() + mock_http.__aenter__ = AsyncMock(return_value=mock_http) + mock_http.__aexit__ = AsyncMock(return_value=None) + mock_http.post = AsyncMock( + side_effect=[ + self._make_mock_response(self._changes_resp(updated=["ev-async-1"])), + self._make_mock_response(self._event_get_resp([self._RAW_EVENT])), + ] + ) + monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) + added, modified, deleted = await self._make_client().get_objects_by_sync_token("state-1") + assert len(modified) == 1 + assert "VCALENDAR" in modified[0] + assert added == [] and deleted == [] + + @pytest.mark.asyncio + async def test_create_task_passes_task_list_id(self, monkeypatch): + resp = self._task_set_resp(created={"new-0": {"id": "task-new-1"}}, notCreated={}) + client, captured = self._capturing_async_session(monkeypatch, resp) + await client.create_task("tl-target", "My Task") + create_args = captured["json"]["methodCalls"][0][1] + new_task = create_args["create"]["new-0"] + assert new_task["taskListId"] == "tl-target" From 206360cc22551ef34203f3eae66e6561a68207cb Mon Sep 17 00:00:00 2001 From: Sashank Date: Fri, 20 Feb 2026 21:24:07 +0530 Subject: [PATCH 23/31] fix(jmap): replace deprecated pytz with stdlib zoneinfo in jscal_to_ical Apply suggestions from code review --- caldav/jmap/convert/jscal_to_ical.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/caldav/jmap/convert/jscal_to_ical.py b/caldav/jmap/convert/jscal_to_ical.py index 21fb7c5a..0175e4a2 100644 --- a/caldav/jmap/convert/jscal_to_ical.py +++ b/caldav/jmap/convert/jscal_to_ical.py @@ -11,6 +11,7 @@ from __future__ import annotations from datetime import date, datetime, timedelta, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import icalendar from icalendar import vCalAddress, vText @@ -75,6 +76,16 @@ def _start_to_dtstart( def _add_dtstart_tzid_passthrough(): dtstart = icalendar.vDatetime(dt_naive) dtstart.params["TZID"] = time_zone + if time_zone: + try: + tz = ZoneInfo(time_zone) + dt = dt_naive.replace(tzinfo=tz) + component.add("dtstart", dt) + except ZoneInfoNotFoundError: + # Non-IANA TZID (e.g. "Eastern Standard Time") — pass through as-is + # so the consuming calendar client can resolve it. + dtstart = icalendar.vDatetime(dt_naive) + dtstart.params["TZID"] = time_zone component.add("dtstart", dtstart) try: From 791aa420116fa73c49936abbcd7b544c855f50e4 Mon Sep 17 00:00:00 2001 From: Sashank Date: Fri, 20 Feb 2026 21:24:48 +0530 Subject: [PATCH 24/31] fix(jmap): replace deprecated pytz with stdlib zoneinfo in jscal_to_ical Apply suggestions from code review --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f1339a85..8c27bbd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,6 @@ ignore = ["DEP002"] # Test dependencies (pytest, coverage, etc.) are not import [tool.deptry.per_rule_ignores] DEP001 = ["conf", "h2"] # conf: Local test config, h2: Optional HTTP/2 support -DEP003 = ["pytz"] # Optional timezone library; imported inside try/except with graceful fallback [tool.ruff] line-length = 100 From 98fa9cc3b1db899532157437918007ace095bda9 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Fri, 20 Feb 2026 21:32:37 +0530 Subject: [PATCH 25/31] fix(jmap): remove duplicate pytz block left by partial suggestion apply --- caldav/jmap/convert/jscal_to_ical.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/caldav/jmap/convert/jscal_to_ical.py b/caldav/jmap/convert/jscal_to_ical.py index 0175e4a2..a9652f07 100644 --- a/caldav/jmap/convert/jscal_to_ical.py +++ b/caldav/jmap/convert/jscal_to_ical.py @@ -71,11 +71,6 @@ def _start_to_dtstart( dt_naive = datetime.strptime(start_str[:19], "%Y-%m-%dT%H:%M:%S") - if time_zone: - - def _add_dtstart_tzid_passthrough(): - dtstart = icalendar.vDatetime(dt_naive) - dtstart.params["TZID"] = time_zone if time_zone: try: tz = ZoneInfo(time_zone) @@ -87,18 +82,6 @@ def _add_dtstart_tzid_passthrough(): dtstart = icalendar.vDatetime(dt_naive) dtstart.params["TZID"] = time_zone component.add("dtstart", dtstart) - - try: - import pytz # type: ignore[import] - - try: - tz = pytz.timezone(time_zone) - dt = tz.localize(dt_naive) - component.add("dtstart", dt) - except pytz.exceptions.UnknownTimeZoneError: - _add_dtstart_tzid_passthrough() - except ImportError: - _add_dtstart_tzid_passthrough() else: component.add("dtstart", dt_naive) From c85c62650fa8de22eedcf9546e4a18a469f9fefc Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Sat, 21 Feb 2026 10:59:13 +0530 Subject: [PATCH 26/31] feat(jmap): add event integration tests; fix UTC start encoding in ical_to_jscal --- caldav/jmap/convert/ical_to_jscal.py | 4 +- tests/test_jmap_integration.py | 175 ++++++++++++++++++++++++++- tests/test_jmap_unit.py | 7 +- 3 files changed, 180 insertions(+), 6 deletions(-) diff --git a/caldav/jmap/convert/ical_to_jscal.py b/caldav/jmap/convert/ical_to_jscal.py index 9ef998d5..13db9008 100644 --- a/caldav/jmap/convert/ical_to_jscal.py +++ b/caldav/jmap/convert/ical_to_jscal.py @@ -54,8 +54,8 @@ def _dtstart_to_jscal(dtstart_prop) -> tuple[str, str | None, bool]: return f"{dt.isoformat()}T00:00:00", None, True if dt.tzinfo is not None and dt.utcoffset() == timedelta(0): - # UTC (Z suffix) - return dt.strftime("%Y-%m-%dT%H:%M:%SZ"), None, False + # UTC — JSCalendar start is LocalDateTime; express via timeZone="Etc/UTC" + return dt.strftime("%Y-%m-%dT%H:%M:%S"), "Etc/UTC", False if dt.tzinfo is not None: # Timezone-aware — prefer the TZID parameter (IANA name) over tzinfo repr diff --git a/tests/test_jmap_integration.py b/tests/test_jmap_integration.py index 4b2f42b7..853bc05d 100644 --- a/tests/test_jmap_integration.py +++ b/tests/test_jmap_integration.py @@ -12,15 +12,20 @@ Test credentials: user1 / x """ +import uuid +from datetime import datetime, timedelta, timezone + import pytest +import pytest_asyncio try: from niquests.auth import HTTPBasicAuth except ImportError: from requests.auth import HTTPBasicAuth # type: ignore[no-redef] -from caldav.jmap import JMAPClient +from caldav.jmap import AsyncJMAPClient, JMAPClient from caldav.jmap.constants import CALENDAR_CAPABILITY +from caldav.jmap.error import JMAPMethodError from caldav.jmap.session import fetch_session CYRUS_HOST = "localhost" @@ -47,6 +52,25 @@ def _cyrus_reachable() -> bool: ) +def _minimal_ical(title: str = "Test Event", start: datetime | None = None) -> str: + if start is None: + start = datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + uid = str(uuid.uuid4()) + return ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//test//test//EN\r\n" + "BEGIN:VEVENT\r\n" + f"UID:{uid}\r\n" + f"SUMMARY:{title}\r\n" + f"DTSTART:{start.strftime('%Y%m%dT%H%M%SZ')}\r\n" + f"DTEND:{end.strftime('%Y%m%dT%H%M%SZ')}\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + + @pytest.fixture(scope="module") def client(): return JMAPClient(url=JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD) @@ -57,6 +81,47 @@ def session(): return fetch_session(JMAP_URL, auth=HTTPBasicAuth(CYRUS_USERNAME, CYRUS_PASSWORD)) +@pytest.fixture(scope="module") +def calendar_id(client): + calendars = client.get_calendars() + assert calendars, "Cyrus did not provision any calendars for user1" + return calendars[0].id + + +@pytest.fixture +def created_event_id(client, calendar_id): + event_id = client.create_event(calendar_id, _minimal_ical("Integration Test Event")) + yield event_id + try: + client.delete_event(event_id) + except Exception: + pass + + +@pytest_asyncio.fixture +async def async_client(): + return AsyncJMAPClient(url=JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD) + + +@pytest_asyncio.fixture +async def async_calendar_id(async_client): + calendars = await async_client.get_calendars() + assert calendars, "Cyrus did not provision any calendars for user1" + return calendars[0].id + + +@pytest_asyncio.fixture +async def async_created_event_id(async_client, async_calendar_id): + event_id = await async_client.create_event( + async_calendar_id, _minimal_ical("Async Integration Test Event") + ) + yield event_id + try: + await async_client.delete_event(event_id) + except Exception: + pass + + class TestJMAPSessionIntegration: def test_session_fetch_returns_api_url(self, session): assert session.api_url @@ -80,3 +145,111 @@ def test_calendars_have_id_and_name(self, client): for cal in calendars: assert cal.id, f"Calendar missing id: {cal}" assert cal.name, f"Calendar has empty name: {cal}" + + +class TestJMAPEventIntegration: + def test_event_create_get(self, client, created_event_id): + ical = client.get_event(created_event_id) + assert "BEGIN:VCALENDAR" in ical + assert "Integration Test Event" in ical + + def test_event_update(self, client, created_event_id): + client.update_event(created_event_id, _minimal_ical("Updated Title")) + fetched = client.get_event(created_event_id) + assert "Updated Title" in fetched + + def test_event_delete(self, client, calendar_id): + event_id = client.create_event(calendar_id, _minimal_ical("To Be Deleted")) + client.delete_event(event_id) + with pytest.raises(JMAPMethodError): + client.get_event(event_id) + + def test_event_query_time_range(self, client, calendar_id, created_event_id): + results = client.search_events( + calendar_id=calendar_id, + start="2026-06-01T00:00:00", + end="2026-06-02T00:00:00", + ) + assert len(results) >= 1 + assert any("Integration Test Event" in r for r in results) + + def test_event_sync(self, client, calendar_id): + token_before = client.get_sync_token() + event_id = client.create_event(calendar_id, _minimal_ical("Sync Test Event")) + try: + added, _modified, _deleted = client.get_objects_by_sync_token(token_before) + assert any("Sync Test Event" in a for a in added) + finally: + client.delete_event(event_id) + + def test_ical_roundtrip(self, client, calendar_id): + start = datetime(2026, 7, 15, 9, 0, 0, tzinfo=timezone.utc) + event_id = client.create_event(calendar_id, _minimal_ical("Roundtrip Event", start=start)) + try: + fetched = client.get_event(event_id) + assert "Roundtrip Event" in fetched + assert "20260715" in fetched + finally: + client.delete_event(event_id) + + +class TestAsyncJMAPEventIntegration: + @pytest.mark.asyncio + async def test_event_create_get(self, async_client, async_created_event_id): + ical = await async_client.get_event(async_created_event_id) + assert "BEGIN:VCALENDAR" in ical + assert "Async Integration Test Event" in ical + + @pytest.mark.asyncio + async def test_event_update(self, async_client, async_created_event_id): + await async_client.update_event( + async_created_event_id, _minimal_ical("Async Updated Title") + ) + fetched = await async_client.get_event(async_created_event_id) + assert "Async Updated Title" in fetched + + @pytest.mark.asyncio + async def test_event_delete(self, async_client, async_calendar_id): + event_id = await async_client.create_event( + async_calendar_id, _minimal_ical("Async To Be Deleted") + ) + await async_client.delete_event(event_id) + with pytest.raises(JMAPMethodError): + await async_client.get_event(event_id) + + @pytest.mark.asyncio + async def test_event_query_time_range( + self, async_client, async_calendar_id, async_created_event_id + ): + results = await async_client.search_events( + calendar_id=async_calendar_id, + start="2026-06-01T00:00:00", + end="2026-06-02T00:00:00", + ) + assert len(results) >= 1 + assert any("Async Integration Test Event" in r for r in results) + + @pytest.mark.asyncio + async def test_event_sync(self, async_client, async_calendar_id): + token_before = await async_client.get_sync_token() + event_id = await async_client.create_event( + async_calendar_id, _minimal_ical("Async Sync Test Event") + ) + try: + added, _modified, _deleted = await async_client.get_objects_by_sync_token(token_before) + assert any("Async Sync Test Event" in a for a in added) + finally: + await async_client.delete_event(event_id) + + @pytest.mark.asyncio + async def test_ical_roundtrip(self, async_client, async_calendar_id): + start = datetime(2026, 7, 15, 9, 0, 0, tzinfo=timezone.utc) + event_id = await async_client.create_event( + async_calendar_id, _minimal_ical("Async Roundtrip Event", start=start) + ) + try: + fetched = await async_client.get_event(event_id) + assert "Async Roundtrip Event" in fetched + assert "20260715" in fetched + finally: + await async_client.delete_event(event_id) diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index 5c1e3a16..a8316af9 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -1011,7 +1011,8 @@ def test_minimal_event(self): result = ical_to_jscal(ical) assert result["uid"] == "test-uid@example.com" assert result["title"] == "Test Event" - assert result["start"] == "2024-06-15T10:00:00Z" + assert result["start"] == "2024-06-15T10:00:00" + assert result["timeZone"] == "Etc/UTC" assert result["duration"] == "PT1H" def test_all_day_event(self): @@ -1036,8 +1037,8 @@ def test_timezone_aware_event(self): def test_utc_event(self): ical = _make_ical("DTSTART:20240615T100000Z\r\nDURATION:PT30M\r\nSUMMARY:UTC Event\r\n") result = ical_to_jscal(ical) - assert result["start"].endswith("Z") - assert "timeZone" not in result + assert result["start"] == "2024-06-15T10:00:00" + assert result["timeZone"] == "Etc/UTC" def test_duration_from_dtend(self): ical = _make_ical( From 01d643efe68f6e62fbb7c4da29a9d5a5b2b777b4 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Sat, 21 Feb 2026 11:12:12 +0530 Subject: [PATCH 27/31] fix(jmap): remove unused _format_local_dt import from jscal_to_ical --- caldav/jmap/convert/jscal_to_ical.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caldav/jmap/convert/jscal_to_ical.py b/caldav/jmap/convert/jscal_to_ical.py index a9652f07..26e1c76b 100644 --- a/caldav/jmap/convert/jscal_to_ical.py +++ b/caldav/jmap/convert/jscal_to_ical.py @@ -16,7 +16,7 @@ import icalendar from icalendar import vCalAddress, vText -from caldav.jmap.convert._utils import _duration_to_timedelta, _format_local_dt +from caldav.jmap.convert._utils import _duration_to_timedelta from caldav.lib import vcal _PRIVACY_TO_CLASS = { From 6712b944642017080bd78b1a193d6fd38a16b87e Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Sat, 21 Feb 2026 11:52:35 +0530 Subject: [PATCH 28/31] fix(jmap): collapse double participants loop; fix _start_to_dtstart docstring --- caldav/jmap/convert/jscal_to_ical.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/caldav/jmap/convert/jscal_to_ical.py b/caldav/jmap/convert/jscal_to_ical.py index 26e1c76b..e00c30f5 100644 --- a/caldav/jmap/convert/jscal_to_ical.py +++ b/caldav/jmap/convert/jscal_to_ical.py @@ -53,7 +53,7 @@ def _start_to_dtstart( ) -> None: """Add a DTSTART property to component from JSCalendar start fields. - Handles three cases: + Handles four cases: - All-day (showWithoutTime): VALUE=DATE - UTC (start ends with Z): UTC DATETIME - Timezone-aware: DATETIME;TZID=... @@ -404,8 +404,6 @@ def jscal_to_ical(jscal: dict) -> str: if org and not organizer_added: event.add("organizer", org) organizer_added = True - - for p in (jscal.get("participants") or {}).values(): att = _participant_to_attendee(p) if att is not None: event.add("attendee", att) From 98cf02e167f10a8328435f3ac368c1bffc7f2a5d Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Sat, 21 Feb 2026 13:26:45 +0530 Subject: [PATCH 29/31] docs(jmap): add JMAP usage documentation and autodoc stubs --- docs/source/caldav/jmap_client.rst | 10 + docs/source/caldav/jmap_objects.rst | 14 ++ docs/source/index.rst | 1 + docs/source/jmap.rst | 377 ++++++++++++++++++++++++++++ docs/source/reference.rst | 2 + 5 files changed, 404 insertions(+) create mode 100644 docs/source/caldav/jmap_client.rst create mode 100644 docs/source/caldav/jmap_objects.rst create mode 100644 docs/source/jmap.rst diff --git a/docs/source/caldav/jmap_client.rst b/docs/source/caldav/jmap_client.rst new file mode 100644 index 00000000..a4eab9b8 --- /dev/null +++ b/docs/source/caldav/jmap_client.rst @@ -0,0 +1,10 @@ +:mod:`JMAPClient` -- JMAP calendar client +========================================== + +.. automodule:: caldav.jmap.client + :synopsis: Synchronous JMAP client for calendar operations + :members: + +.. automodule:: caldav.jmap.async_client + :synopsis: Asynchronous JMAP client for calendar operations + :members: diff --git a/docs/source/caldav/jmap_objects.rst b/docs/source/caldav/jmap_objects.rst new file mode 100644 index 00000000..5e09a124 --- /dev/null +++ b/docs/source/caldav/jmap_objects.rst @@ -0,0 +1,14 @@ +:mod:`jmap.objects` -- JMAP data objects +========================================= + +.. automodule:: caldav.jmap.objects.calendar + :members: + +.. automodule:: caldav.jmap.objects.event + :members: + +.. automodule:: caldav.jmap.objects.task + :members: + +.. automodule:: caldav.jmap.error + :members: diff --git a/docs/source/index.rst b/docs/source/index.rst index 6c51e558..28d06b0b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -18,6 +18,7 @@ Contents about tutorial async + jmap howtos performance reference diff --git a/docs/source/jmap.rst b/docs/source/jmap.rst new file mode 100644 index 00000000..a37d0932 --- /dev/null +++ b/docs/source/jmap.rst @@ -0,0 +1,377 @@ +==== +JMAP +==== + +The caldav library includes a JMAP client for servers that speak +`RFC 8620 `_ (JMAP Core) and +`RFC 8984 `_ (JMAP Calendars). +It covers calendar listing, event CRUD, incremental sync, and task CRUD — the same +operations as the CalDAV client — so the choice of protocol comes down to what the +server supports. + +.. note:: + + The JMAP client targets servers implementing + ``urn:ietf:params:jmap:calendars``. Cyrus IMAP and Fastmail are known to work. + Task support (``urn:ietf:params:jmap:tasks``, RFC 9553) requires a separate + server capability; Cyrus does not implement it yet. + +Quick Start +=========== + +.. code-block:: python + + 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() + for cal in calendars: + print(cal.name) + +:func:`~caldav.jmap.get_jmap_client` reads configuration from the same sources +as :func:`caldav.get_davclient`: explicit keyword arguments, then the +``CALDAV_URL`` / ``CALDAV_USERNAME`` / ``CALDAV_PASSWORD`` environment variables, +then a config file. If none of those are set it returns ``None``. + +With environment variables or a config file in place, no arguments are needed: + +.. code-block:: python + + client = get_jmap_client() # reads env vars or config file + +Authentication +============== + +HTTP Basic auth is used when a ``username`` is supplied alongside a ``password``. +Bearer token auth is used when only a ``password`` (token) is given and no username. +You can also pass any ``requests``-compatible auth object directly via the ``auth`` +parameter (niquests is API-compatible with requests). + +.. code-block:: python + + # Basic auth + client = get_jmap_client( + url="https://jmap.example.com/.well-known/jmap", + username="alice", + password="secret", + ) + + # Bearer token (password argument holds the token; no username supplied) + client = get_jmap_client( + url="https://jmap.example.com/.well-known/jmap", + password="my-bearer-token", + ) + + # Pre-built auth object + try: + from niquests.auth import HTTPBasicAuth + except ImportError: + from requests.auth import HTTPBasicAuth + client = get_jmap_client( + url="https://jmap.example.com/.well-known/jmap", + auth=HTTPBasicAuth("alice", "secret"), + ) + +Unlike CalDAV, JMAP does not use a 401-challenge-retry dance — credentials are sent +on every request, and a 401 or 403 is a hard :class:`~caldav.jmap.error.JMAPAuthError`. + +Context manager usage is supported but not required — no persistent TCP connection is +held between calls (the JMAP Session object is cached after the first request, but +that is just a JSON document, not a socket): + +.. code-block:: python + + with get_jmap_client(...) as client: + calendars = client.get_calendars() + +Listing Calendars +================= + +.. code-block:: python + + calendars = client.get_calendars() + for cal in calendars: + print(cal.id, cal.name, cal.color) + +Each item is a :class:`~caldav.jmap.objects.calendar.JMAPCalendar` dataclass. +The fields are ``id``, ``name``, ``description``, ``color`` (CSS string or ``None``), +``is_subscribed``, ``my_rights`` (dict), ``sort_order``, and ``is_visible``. + +Working with Events +=================== + +Events are passed as iCalendar strings — the same format used by the CalDAV client +— so existing iCalendar-producing code works unchanged. + +To create an event, pass a VCALENDAR string and the target calendar ID: + +.. code-block:: python + + ical = ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//example//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:meeting-2026-01-15@example.com\r\n" + "SUMMARY:Team meeting\r\n" + "DTSTART:20260115T100000Z\r\n" + "DTEND:20260115T110000Z\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + calendar_id = calendars[0].id + event_id = client.create_event(calendar_id, ical) + +The return value is the server-assigned JMAP event ID (a string). You can fetch the +event back as a VCALENDAR string, update it by passing a new VCALENDAR string, or +delete it: + +.. code-block:: python + + # Fetch — returns a VCALENDAR string + ical_str = client.get_event(event_id) + + # Update — pass a complete VCALENDAR string with the changes applied + updated = ical_str.replace("Team meeting", "Team standup") + client.update_event(event_id, updated) + + # Delete + client.delete_event(event_id) + +Searching Events +================ + +.. code-block:: python + + # All events in a specific calendar + results = client.search_events(calendar_id=calendar_id) + + # Time-range filter: events that overlap [start, end) + # start — only events ending after this datetime + # end — only events starting before this datetime + results = client.search_events( + calendar_id=calendar_id, + start="2026-01-01T00:00:00", + end="2026-02-01T00:00:00", + ) + + # Free-text search across title, description, locations, and participants + results = client.search_events(text="standup") + + for ical_str in results: + print(ical_str) + +All parameters are optional; omitting all returns every event visible to the account. +Results are returned as a list of VCALENDAR strings. The search uses a single batched +JMAP request (``CalendarEvent/query`` + result reference into ``CalendarEvent/get``), +so only one HTTP round-trip is made regardless of how many events match. + +Incremental Sync +================ + +JMAP's state-based sync lets you fetch only what changed since the last call, without +scanning the full calendar: + +.. code-block:: python + + # Record the current state + token = client.get_sync_token() + + # ... time passes, events are created/modified/deleted ... + + # Fetch only the delta + added, modified, deleted = client.get_objects_by_sync_token(token) + + for ical_str in added: + print("New:", ical_str) + for ical_str in modified: + print("Updated:", ical_str) + for event_id in deleted: + print("Deleted ID:", event_id) + +``added`` and ``modified`` are lists of VCALENDAR strings. ``deleted`` is a list +of event IDs — the objects no longer exist on the server, so their data cannot be +fetched. + +:meth:`~caldav.jmap.client.JMAPClient.get_objects_by_sync_token` raises +:class:`~caldav.jmap.error.JMAPMethodError` (``error_type="serverPartialFail"``) if +the server truncated the change list (``hasMoreChanges: true``). If this happens, +call :meth:`~caldav.jmap.client.JMAPClient.get_sync_token` to establish a fresh +baseline and re-sync from scratch. + +A typical pattern is to persist the token between runs: + +.. code-block:: python + + import json + import pathlib + + TOKEN_FILE = pathlib.Path("sync_token.json") + + def load_token(): + if TOKEN_FILE.exists(): + return json.loads(TOKEN_FILE.read_text())["token"] + return None + + def save_token(token): + TOKEN_FILE.write_text(json.dumps({"token": token})) + + token = load_token() + if token is None: + token = client.get_sync_token() + save_token(token) + else: + added, modified, deleted = client.get_objects_by_sync_token(token) + # process changes ... + token = client.get_sync_token() + save_token(token) + +Tasks +===== + +Task support requires a server implementing ``urn:ietf:params:jmap:tasks`` +(RFC 9553). If the server does not support this capability, +:meth:`~caldav.jmap.client.JMAPClient.get_task_lists` will raise +:class:`~caldav.jmap.error.JMAPMethodError`. + +.. code-block:: python + + # List task lists + task_lists = client.get_task_lists() + for tl in task_lists: + print(tl.id, tl.name) + + task_list_id = task_lists[0].id + + # Create a task — title is required; everything else is optional + task_id = client.create_task( + task_list_id, + title="Review pull request", + due="2026-02-15T17:00:00", + time_zone="Europe/Oslo", + ) + + # Fetch — returns a JMAPTask dataclass + task = client.get_task(task_id) + print(task.title) # str + print(task.progress) # "needs-action" (default) + print(task.percent_complete) # 0 (default) + + # Update — pass a partial patch dict using JMAP wire property names + client.update_task(task_id, {"progress": "completed", "percentComplete": 100}) + + # Delete + client.delete_task(task_id) + +Optional kwargs for :meth:`~caldav.jmap.client.JMAPClient.create_task`: +``description``, ``start``, ``due``, ``time_zone``, ``estimated_duration``, +``percent_complete``, ``progress``, ``priority``. + +Each item from :meth:`~caldav.jmap.client.JMAPClient.get_task` is a +:class:`~caldav.jmap.objects.task.JMAPTask` with fields ``id``, ``uid``, +``task_list_id``, ``title``, ``description``, ``start``, ``due``, ``time_zone``, +``estimated_duration``, ``percent_complete``, ``progress``, ``progress_updated``, +``priority``, ``is_draft``, ``keywords``, ``recurrence_rules``, +``recurrence_overrides``, ``alerts``, ``participants``, ``color``, ``privacy``. + +Each item from :meth:`~caldav.jmap.client.JMAPClient.get_task_lists` is a +:class:`~caldav.jmap.objects.task.JMAPTaskList` with fields ``id``, ``name``, +``description``, ``color``, ``is_subscribed``, ``my_rights``, ``sort_order``, +``time_zone``, ``role`` (``"inbox"``, ``"trash"``, or ``None``). + +Async API +========= + +:class:`~caldav.jmap.async_client.AsyncJMAPClient` mirrors every method of +:class:`~caldav.jmap.client.JMAPClient` as a coroutine. It requires the +``async with`` context manager (sync ``with`` is not supported): + +.. code-block:: python + + import asyncio + from caldav.jmap import get_async_jmap_client + + async def main(): + 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() + for cal in calendars: + print(cal.name) + + asyncio.run(main()) + +All methods — event CRUD, search, sync, and task operations — are available as +coroutines with identical signatures. The async client uses ``niquests.AsyncSession`` +internally; ``niquests`` is a required dependency. + +Error Handling +============== + +All JMAP errors extend :class:`~caldav.jmap.error.JMAPError`, which itself extends +:class:`~caldav.lib.error.DAVError`. Existing CalDAV error handlers will catch JMAP +errors too if they catch ``DAVError``. + +.. code-block:: python + + from caldav.lib.error import DAVError + from caldav.jmap.error import JMAPAuthError, JMAPCapabilityError, JMAPMethodError + + try: + event_id = client.create_event(calendar_id, ical) + except JMAPAuthError: + print("Authentication failed (401/403)") + except JMAPCapabilityError: + print("Server does not support urn:ietf:params:jmap:calendars") + except JMAPMethodError as e: + print(f"Server rejected the request: {e.error_type} — {e.reason}") + except DAVError as e: + print(f"Protocol error: {e}") + +The three specific error classes: + +* :class:`~caldav.jmap.error.JMAPAuthError` — HTTP 401 or 403. JMAP sends no + 401-challenge, so this is always a hard failure. +* :class:`~caldav.jmap.error.JMAPCapabilityError` — the server's Session object + does not advertise ``urn:ietf:params:jmap:calendars``. +* :class:`~caldav.jmap.error.JMAPMethodError` — a JMAP method call returned an error + response. The ``error_type`` attribute holds the RFC 8620 error type string + (e.g. ``"invalidArguments"``, ``"notFound"``, ``"stateMismatch"``). + +Configuration File +================== + +The JMAP client reads from the same configuration file as the CalDAV client. +Connection parameters use the ``caldav_`` prefix: + +.. code-block:: yaml + + --- + default: + caldav_url: https://jmap.example.com/.well-known/jmap + caldav_username: alice + caldav_password: secret + +With the file in place, no arguments are needed: + +.. code-block:: python + + client = get_jmap_client() + +See :doc:`configfile` for file locations, section inheritance, and other options. + +API Reference +============= + +* :doc:`caldav/jmap_client` — :class:`~caldav.jmap.client.JMAPClient` and + :class:`~caldav.jmap.async_client.AsyncJMAPClient` full method reference +* :doc:`caldav/jmap_objects` — :class:`~caldav.jmap.objects.calendar.JMAPCalendar`, + :class:`~caldav.jmap.objects.event.JMAPEvent`, + :class:`~caldav.jmap.objects.task.JMAPTask`, + :class:`~caldav.jmap.objects.task.JMAPTaskList`, and error classes diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 304a8798..1074d5f8 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -19,3 +19,5 @@ Contents caldav/davobject caldav/collection caldav/calendarobjectresource + caldav/jmap_client + caldav/jmap_objects From 992f7395a121e67fe5e2b667f66bcb6c2d8cfee5 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Sat, 21 Feb 2026 13:51:59 +0530 Subject: [PATCH 30/31] fix(jmap): prefer primaryAccounts for account selection; fix test section label; fix async docs wording --- caldav/jmap/session.py | 20 ++++++++++++++------ docs/source/jmap.rst | 2 +- tests/test_jmap_unit.py | 4 ---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/caldav/jmap/session.py b/caldav/jmap/session.py index 81309998..94383b57 100644 --- a/caldav/jmap/session.py +++ b/caldav/jmap/session.py @@ -15,8 +15,7 @@ from niquests import AsyncSession except ImportError: import requests # type: ignore[no-redef] - - AsyncSession = None # type: ignore[assignment,misc] + AsyncSession = None # type: ignore[assignment,misc] # async_fetch_session requires niquests from caldav.jmap.constants import CALENDAR_CAPABILITY from caldav.jmap.error import JMAPAuthError, JMAPCapabilityError @@ -29,7 +28,8 @@ class Session: Attributes: api_url: URL to POST method calls to. account_id: The accountId to use for calendar method calls. - Chosen as the first account advertising the calendars capability. + Chosen from ``primaryAccounts`` if available, otherwise the first + account advertising the calendars capability. state: Current session state string. account_capabilities: Capabilities dict for the chosen account. server_capabilities: Server-level capabilities dict. @@ -62,12 +62,20 @@ def _parse_session_data(url: str, data: dict) -> Session: account_id = None account_capabilities: dict = {} - for acct_id, acct_data in accounts.items(): + primary_acct_id = data.get("primaryAccounts", {}).get(CALENDAR_CAPABILITY) + if primary_acct_id: + acct_data = accounts.get(primary_acct_id, {}) caps = acct_data.get("accountCapabilities", {}) if CALENDAR_CAPABILITY in caps: - account_id = acct_id + account_id = primary_acct_id account_capabilities = caps - break + if account_id is None: + for acct_id, acct_data in accounts.items(): + caps = acct_data.get("accountCapabilities", {}) + if CALENDAR_CAPABILITY in caps: + account_id = acct_id + account_capabilities = caps + break if account_id is None: raise JMAPCapabilityError( diff --git a/docs/source/jmap.rst b/docs/source/jmap.rst index a37d0932..5abfaa81 100644 --- a/docs/source/jmap.rst +++ b/docs/source/jmap.rst @@ -287,7 +287,7 @@ Async API ========= :class:`~caldav.jmap.async_client.AsyncJMAPClient` mirrors every method of -:class:`~caldav.jmap.client.JMAPClient` as a coroutine. It requires the +:class:`~caldav.jmap.client.JMAPClient` as a coroutine. Use it as an ``async with`` context manager (sync ``with`` is not supported): .. code-block:: python diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index a8316af9..e36ef84c 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -920,10 +920,6 @@ def test_parse_event_set_partial_failure(self): assert not_destroyed["ev-old"]["type"] == "notFound" -# =========================================================================== -# iCalendar ↔ JSCalendar conversion layer -# =========================================================================== - from datetime import date, datetime, timedelta, timezone import icalendar as _icalendar From b2968a902ac015d79c3af0bbbbee1b12d5d16caa Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Sat, 21 Feb 2026 14:23:35 +0530 Subject: [PATCH 31/31] style(jmap): fix ruff-format blank line after fallback import in session.py --- caldav/jmap/session.py | 1 + 1 file changed, 1 insertion(+) diff --git a/caldav/jmap/session.py b/caldav/jmap/session.py index 94383b57..057f188c 100644 --- a/caldav/jmap/session.py +++ b/caldav/jmap/session.py @@ -15,6 +15,7 @@ from niquests import AsyncSession except ImportError: import requests # type: ignore[no-redef] + AsyncSession = None # type: ignore[assignment,misc] # async_fetch_session requires niquests from caldav.jmap.constants import CALENDAR_CAPABILITY