diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index edc36817..9b97db30 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -39,4 +39,4 @@ python:
- method: pip
path: .
extra_requirements:
- - doc
+ - jmap
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4da0680c..62e2a131 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,25 @@ Changelogs prior to v3.0 are pruned, but are available in the v3.1 release
This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), though for pre-releases PEP 440 takes precedence.
+## [Unreleased]
+
+### Breaking changes
+
+The JMAP support was declared experimental in 3.0, hence the changes below are deemed allowable in a minor release:
+
+* **Breaking:** `caldav.jmap` no longer works on a plain `pip install caldav`. The implementation moved to the standalone [calendaring-jmap](https://pypi.org/project/calendaring-jmap/) package, which is an optional dependency - install `caldav[jmap]` (or `calendaring-jmap`). Importing `caldav.jmap` without it raises an `ImportError` saying so.
+* **Breaking:** `caldav[jmap]` brings dependencies caldav itself does not have. calendaring-jmap 1.1.0 requires `icalendar>=7.3.0` (caldav asks only for `icalendar>6.0.0`, so the extra raises the floor), and it requires both `niquests` and `requests` outright - so `requests` is installed even in the environments that deliberately avoid it, see [HTTP Library Configuration](https://caldav.readthedocs.io/stable/http-libraries.html).
+* **Breaking:** the JMAP code is licensed differently from the rest of caldav. caldav is `GPL-3.0-or-later OR Apache-2.0`; calendaring-jmap is `AGPL-3.0-or-later`. The import path is unchanged, so this is easy to miss: if you relied on the Apache-2.0 option, note that the JMAP code you get through `caldav[jmap]` carries the AGPL network-copyleft obligation. Nothing changes for users of caldav without the `jmap` extra.
+
+### Changed
+
+* `caldav.lib.http_sync` no longer exports `AsyncSession` or `require_async_session()`. They existed only for the async JMAP client, which has moved out of this library; `caldav.lib.http_libraries.required_library_error()` is kept, but has no caller in caldav today.
+* `caldav.jmap` no longer carries its own JMAP client implementation. It's now a thin re-export of calendaring-jmap.
+ * Old imports still work, including the submodule paths the 3.3 documentation used: `from caldav.jmap import JMAPClient`, `from caldav.jmap.error import JMAPAuthError`, `caldav.jmap.convert.jscal_to_ical` and so on all resolve to calendaring-jmap's own modules.
+ * Importing `caldav.jmap` now emits a `DeprecationWarning`. Use `from calendaring_jmap import JMAPClient` going forward; the wrapper will be removed in a future release.
+ * `get_jmap_client()`/`get_async_jmap_client()` still resolve configuration the same way `get_davclient()` does - that is the one thing the wrapper adds over importing calendaring-jmap directly.
+ * JMAP errors remain catchable as `DAVError`.
+
## [3.3.1] - 2026-09-16
The two main things in this release:
diff --git a/caldav/jmap/__init__.py b/caldav/jmap/__init__.py
index 4309d9f2..3521c14d 100644
--- a/caldav/jmap/__init__.py
+++ b/caldav/jmap/__init__.py
@@ -1,6 +1,11 @@
"""
JMAP calendar support for python-caldav.
+.. deprecated::
+ Thin re-export of the standalone `calendaring-jmap
+ `_ package. Import from
+ ``calendaring_jmap`` directly instead.
+
Provides synchronous and asynchronous JMAP clients with the same public API as
the CalDAV client, so user code works regardless of server protocol.
@@ -27,16 +32,77 @@
calendars = await client.get_calendars()
"""
-from caldav.jmap.async_client import AsyncJMAPClient
-from caldav.jmap.client import JMAPClient
-from caldav.jmap.error import (
- JMAPAuthError,
- JMAPCapabilityError,
- JMAPError,
- JMAPMethodError,
+import importlib
+import sys
+import warnings
+
+_SUBMODULES = (
+ "async_client",
+ "client",
+ "constants",
+ "convert",
+ "convert.ical_to_jscal",
+ "convert.jscal_to_ical",
+ "error",
+ "objects",
+ "objects.calendar",
+ "objects.calendar_object",
+ "session",
+)
+
+try:
+ from calendaring_jmap import (
+ AsyncJMAPClient,
+ JMAPAuthError,
+ JMAPCalendar,
+ JMAPCalendarObject,
+ JMAPCapabilityError,
+ JMAPClient,
+ JMAPError,
+ JMAPMethodError,
+ )
+
+ ## calendaring-jmap mirrors the submodule layout caldav.jmap used to have,
+ ## so alias each public submodule into place. Without this, the import
+ ## line the v3.3 documentation spelled out - `from caldav.jmap.error import
+ ## JMAPAuthError` - dies with ModuleNotFoundError instead of going through
+ ## the DeprecationWarning below. sys.modules is what `from X.Y import Z`
+ ## consults; globals() is what makes `caldav.jmap.error` work as an
+ ## attribute, which the import machinery would otherwise have set itself.
+ for _name in _SUBMODULES:
+ _module = importlib.import_module(f"calendaring_jmap.{_name}")
+ sys.modules[f"{__name__}.{_name}"] = _module
+ if "." not in _name:
+ globals()[_name] = _module
+ del _name, _module
+except ImportError as e:
+ ## Python removes a half-imported caldav.jmap from sys.modules, but not the
+ ## aliases the loop above may already have registered. Left behind, a
+ ## later `from caldav.jmap.error import ...` would resolve against a
+ ## package that never finished importing. (calendaring-jmap 1.1.0 imports
+ ## all of these eagerly, so the loop cannot currently be the thing that
+ ## fails - this is here for the version that makes one of them lazy.)
+ for _name in _SUBMODULES:
+ sys.modules.pop(f"{__name__}.{_name}", None)
+ ## Only the top-level package being absent means "not installed". Anything
+ ## raised from inside calendaring-jmap is a broken install or a version
+ ## mismatch, and saying "install it" sends the reader after something they
+ ## already have.
+ if e.name != "calendaring_jmap":
+ raise
+ raise ImportError(
+ "caldav.jmap requires the standalone calendaring-jmap package "
+ "(>=1.1.0), which is not installed. Install it with "
+ "`pip install caldav[jmap]` or `pip install 'calendaring-jmap>=1.1.0'`."
+ ) from e
+
+warnings.warn(
+ "caldav.jmap is deprecated; import from the standalone calendaring-jmap "
+ "package instead (pip install calendaring-jmap). caldav.jmap now just "
+ "re-exports it and will be removed in a future release.",
+ DeprecationWarning,
+ stacklevel=2,
)
-from caldav.jmap.objects.calendar import JMAPCalendar
-from caldav.jmap.objects.calendar_object import JMAPCalendarObject
_JMAP_KEYS = {"url", "username", "password", "auth", "auth_type", "timeout"}
diff --git a/caldav/jmap/_methods/__init__.py b/caldav/jmap/_methods/__init__.py
deleted file mode 100644
index c2bf779d..00000000
--- a/caldav/jmap/_methods/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-def parse_set_response(response_args: dict) -> tuple[dict, dict, list[str], dict, dict, dict]:
- """Parse the arguments dict from any JMAP ``*/set`` method response.
-
- Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``.
- """
- created: dict = response_args.get("created") or {}
- updated: dict = response_args.get("updated") or {}
- destroyed: list[str] = response_args.get("destroyed") or []
- not_created: dict = response_args.get("notCreated") or {}
- not_updated: dict = response_args.get("notUpdated") or {}
- not_destroyed: dict = response_args.get("notDestroyed") or {}
- return created, updated, destroyed, not_created, not_updated, not_destroyed
diff --git a/caldav/jmap/_methods/calendar.py b/caldav/jmap/_methods/calendar.py
deleted file mode 100644
index 3dcbe49a..00000000
--- a/caldav/jmap/_methods/calendar.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""
-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/_methods/event.py b/caldav/jmap/_methods/event.py
deleted file mode 100644
index fdda4e06..00000000
--- a/caldav/jmap/_methods/event.py
+++ /dev/null
@@ -1,268 +0,0 @@
-"""
-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._methods import parse_set_response
-
-
-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[dict]:
- """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 raw JSCalendar dicts as returned by the server.
- Returns an empty list if ``"list"`` is absent or empty.
- """
- return list(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 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_condition: 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_condition: 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_condition is not None:
- args["filter"] = filter_condition
- 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_condition: 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_condition: 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_condition is not None:
- args["filter"] = filter_condition
- 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, dict],
-) -> tuple:
- """Build a ``CalendarEvent/set`` method call for creating events.
-
- Args:
- account_id: The JMAP accountId.
- events: Map of client-assigned creation ID → JSCalendar dict.
- 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": dict(events),
- },
- "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], dict, dict, dict]:
- """Parse the arguments dict from a ``CalendarEvent/set`` method response.
-
- Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``.
- See :func:`caldav.jmap._methods.parse_set_response` for field semantics.
- """
- return parse_set_response(response_args)
diff --git a/caldav/jmap/_methods/task.py b/caldav/jmap/_methods/task.py
deleted file mode 100644
index 619a1d78..00000000
--- a/caldav/jmap/_methods/task.py
+++ /dev/null
@@ -1,159 +0,0 @@
-"""
-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 draft-ietf-jmap-tasks (built on RFC 8984).
-"""
-
-from __future__ import annotations
-
-from caldav.jmap._methods import parse_set_response
-
-
-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[dict]:
- """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 raw JMAP TaskList dicts as returned by the server.
- Returns an empty list if ``"list"`` is absent or empty.
- """
- return list(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[dict]:
- """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 raw JMAP Task dicts as returned by the server.
- Returns an empty list if ``"list"`` is absent or empty.
- """
- return list(response_args.get("list", []))
-
-
-def build_task_set_create(
- account_id: str,
- tasks: dict[str, dict],
-) -> tuple:
- """Build a ``Task/set`` method call for creating tasks.
-
- Args:
- account_id: The JMAP accountId.
- tasks: Map of client-assigned creation ID → JMAP Task dict.
-
- Returns:
- A 3-tuple ``("Task/set", arguments_dict, call_id)``.
- """
- return (
- "Task/set",
- {
- "accountId": account_id,
- "create": dict(tasks),
- },
- "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.
-
- Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``.
- See :func:`caldav.jmap._methods.parse_set_response` for field semantics.
- """
- return parse_set_response(response_args)
diff --git a/caldav/jmap/async_client.py b/caldav/jmap/async_client.py
deleted file mode 100644
index 084e6b40..00000000
--- a/caldav/jmap/async_client.py
+++ /dev/null
@@ -1,464 +0,0 @@
-"""
-Asynchronous JMAP client.
-
-Mirrors JMAPClient with all public methods as coroutines.
-Uses niquests' AsyncSession for HTTP - the one part of caldav with no
-fallback to another HTTP library, see caldav.lib.http_sync.
-
-All response-parsing logic lives in _JMAPClientBase (client.py); each method
-here is a ~3-line async wrapper: get session, send request, delegate to parser.
-"""
-
-from __future__ import annotations
-
-import logging
-import uuid
-import warnings
-
-from caldav.lib.http_sync import require_async_session
-
-## The async JMAP client is built on niquests' AsyncSession and, unlike the
-## CalDAV clients, has no httpx fallback - so this raises if niquests is absent.
-AsyncSession = require_async_session()
-
-from caldav.jmap._methods.calendar import build_calendar_get
-from caldav.jmap._methods.event import (
- build_event_changes,
- build_event_get,
- build_event_set_create,
- build_event_set_destroy,
- build_event_set_update,
-)
-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,
-)
-from caldav.jmap.client import _DEFAULT_USING, _TASK_USING, _JMAPClientBase
-from caldav.jmap.convert import ical_to_jscal
-from caldav.jmap.error import JMAPAuthError, JMAPMethodError
-from caldav.jmap.objects.calendar import JMAPCalendar
-from caldav.jmap.objects.calendar_object import JMAPCalendarObject
-from caldav.jmap.session import Session, async_fetch_session
-
-log = logging.getLogger("caldav.jmap")
-
-
-class AsyncJMAPClient(_JMAPClientBase):
- """Asynchronous JMAP client for calendar operations.
-
- **The JMAP support is experimental, the API may change in minor-releases**
-
- 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.
- """
-
- def _get_http_session(self) -> AsyncSession:
- """Return the persistent async HTTP session, creating it on first call."""
- if self._http_session is None:
- sess = AsyncSession()
- sess.auth = self._auth
- sess.headers.update({"Content-Type": "application/json", "Accept": "application/json"})
- self._http_session = sess
- return self._http_session
-
- async def aclose(self) -> None:
- """Release the persistent HTTP session and its connection pool.
-
- Only needed when the client was not used as an async context manager
- -- the documented Quick Start builds one directly. Idempotent; the
- session is recreated on the next request.
- """
- if self._http_session is not None:
- await self._http_session.close()
- self._http_session = None
-
- async def __aenter__(self) -> AsyncJMAPClient:
- self._get_http_session()
- return self
-
- async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
- await self.aclose()
-
- def __del__(self) -> None:
- ## Closing an async session needs an event loop, which is long gone by the
- ## time __del__ runs, so all we can do is say so. getattr() rather than
- ## attribute access: __init__ may have raised before setting it, and an
- ## AttributeError here would be reported as "exception ignored in __del__"
- ## on top of whatever actually went wrong.
- if getattr(self, "_http_session", None) is None:
- return
- try:
- warnings.warn(
- f"{type(self).__name__} was garbage collected with an open HTTP "
- "session; use 'async with' or await aclose()",
- ResourceWarning,
- ## stacklevel=1 on purpose, and explicitly because ruff's B028
- ## wants it stated: pointing any higher is a lie in __del__, where
- ## the caller is the garbage collector. source= is what makes the
- ## warning actionable instead - with `python -X tracemalloc` it
- ## reports where the leaked client was allocated.
- stacklevel=1,
- source=self,
- )
- except Exception:
- ## __del__ must not raise. At interpreter shutdown the warnings
- ## machinery may already be torn down and stderr may be closed, and
- ## there is neither anything to recover nor anywhere to report it.
- pass
-
- 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))
-
- response = await self._get_http_session().post(
- session.api_url,
- json=payload,
- 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()
- responses = await self._request([build_calendar_get(session.account_id)])
- return self._parse_get_calendars(responses, self, True)
-
- 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 = build_event_set_create(session.account_id, {"new-0": jscal})
- responses = await self._request([call])
- return self._parse_create_event_response(responses, session.api_url)
-
- async def get_event(self, event_id: str) -> JMAPCalendarObject:
- """Fetch a calendar event as an iCalendar string.
-
- Args:
- event_id: The JMAP event ID to retrieve.
-
- Returns:
- A :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject`
- wrapping the raw JSCalendar dict. ``parent`` is ``None`` since
- no :class:`~caldav.jmap.objects.calendar.JMAPCalendar` is available
- at the client level.
-
- Raises:
- JMAPMethodError: If the event is not found.
- """
- session = await self._get_session()
- responses = await self._request([build_event_get(session.account_id, ids=[event_id])])
- return self._parse_get_event_response(responses, session.api_url, event_id)
-
- 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, nulled = self._build_event_update_patch(ical_str)
- while True:
- responses = await self._request(
- [build_event_set_update(session.account_id, {event_id: patch})]
- )
- drop = self._unsupported_null_keys(responses, event_id, patch, nulled)
- if not drop:
- break
- for key in drop:
- patch.pop(key, None)
- self._parse_update_event_response(responses, session.api_url, event_id)
-
- async def _search(
- self,
- calendar_id: str | None = None,
- start: str | None = None,
- end: str | None = None,
- text: str | None = None,
- parent: JMAPCalendar | None = None,
- ) -> list[JMAPCalendarObject]:
- session = await self._get_session()
- calls = self._build_event_search_calls(session.account_id, calendar_id, start, end, text)
- responses = await self._request(calls)
- return self._parse_search_response(responses, parent)
-
- async def search_events(
- self,
- calendar_id: str | None = None,
- start: str | None = None,
- end: str | None = None,
- text: str | None = None,
- ) -> list[JMAPCalendarObject]:
- """Search for calendar events.
-
- 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 :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject`
- instances. ``parent`` is ``None`` on these objects; use
- :meth:`JMAPCalendar.search` if you need ``parent`` set.
- """
- return await self._search(calendar_id=calendar_id, start=start, end=end, text=text)
-
- 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()
- responses = await self._request([build_event_get(session.account_id, ids=[])])
- return self._parse_get_sync_token_response(responses, session.api_url)
-
- async def get_objects_by_sync_token(
- self, sync_token: str
- ) -> tuple[list[JMAPCalendarObject], list[JMAPCalendarObject], list[str], 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
- :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject` instances;
- 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 4-tuple ``(added, modified, deleted, new_sync_token)``:
-
- - ``added``: objects for newly created events (``parent`` is ``None``).
- - ``modified``: objects for updated events (``parent`` is ``None``).
- - ``deleted``: Event IDs that were destroyed.
- - ``new_sync_token``: Pass to the next call to this method as ``sync_token``.
-
- Raises:
- JMAPMethodError: If the server reports ``hasMoreChanges: true``.
- """
- session = await self._get_session()
- responses = await self._request([build_event_changes(session.account_id, sync_token)])
- created_ids, updated_ids, destroyed, new_sync_token = self._parse_event_changes_response(
- responses, session.api_url
- )
- fetch_ids = created_ids + updated_ids
- if not fetch_ids:
- return [], [], destroyed, new_sync_token
- get_responses = await self._request([build_event_get(session.account_id, ids=fetch_ids)])
- return self._assemble_sync_token_result(
- get_responses, created_ids, updated_ids, destroyed, new_sync_token
- )
-
- 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()
- responses = await self._request([build_event_set_destroy(session.account_id, [event_id])])
- self._parse_delete_event_response(responses, session.api_url, event_id)
-
- async def _get_object_by_uid(
- self, uid: str, calendar_id: str | None = None, parent: JMAPCalendar | None = None
- ) -> JMAPCalendarObject:
- # RFC 8984 FilterCondition has no uid field; UID matching is done client-side.
- for obj in await self._search(calendar_id=calendar_id, parent=parent):
- if obj.data.get("uid") == uid:
- return obj
- session = await self._get_session()
- raise JMAPMethodError(
- url=session.api_url, reason=f"No calendar object found with UID: {uid}"
- )
-
- async def get_task_lists(self) -> list[dict]:
- """Fetch all task lists for the authenticated account.
-
- Returns:
- List of raw JMAP TaskList dicts as returned by the server.
- """
- session = await self._get_session()
- responses = await self._request(
- [build_task_list_get(session.account_id)], using=_TASK_USING
- )
- return self._parse_get_task_lists_response(responses)
-
- 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 JMAP Task fields using wire names: ``description``,
- ``due``, ``start``, ``timeZone``, ``estimatedDuration``,
- ``percentComplete``, ``progress``, ``priority``.
-
- Returns:
- The server-assigned JMAP task ID.
-
- Raises:
- JMAPMethodError: If the server rejects the create request.
- """
- session = await self._get_session()
- task_dict = {
- "@type": "Task",
- "uid": str(uuid.uuid4()),
- "taskListId": task_list_id,
- "title": title,
- "percentComplete": 0,
- "progress": "needs-action",
- "priority": 0,
- }
- task_dict.update(kwargs)
- call = build_task_set_create(session.account_id, {"new-0": task_dict})
- responses = await self._request([call], using=_TASK_USING)
- return self._parse_create_task_response(responses, session.api_url)
-
- async def get_task(self, task_id: str) -> dict:
- """Fetch a task by ID.
-
- Args:
- task_id: The JMAP task ID to retrieve.
-
- Returns:
- Raw JMAP Task dict as returned by the server.
-
- Raises:
- JMAPMethodError: If the task is not found.
- """
- session = await self._get_session()
- responses = await self._request(
- [build_task_get(session.account_id, ids=[task_id])], using=_TASK_USING
- )
- return self._parse_get_task_response(responses, session.api_url, task_id)
-
- 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)
- self._parse_update_task_response(responses, session.api_url, task_id)
-
- 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()
- responses = await self._request(
- [build_task_set_destroy(session.account_id, [task_id])], using=_TASK_USING
- )
- self._parse_delete_task_response(responses, session.api_url, task_id)
diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py
deleted file mode 100644
index d30f794c..00000000
--- a/caldav/jmap/client.py
+++ /dev/null
@@ -1,804 +0,0 @@
-"""
-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
-import uuid
-
-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_create,
- build_event_set_destroy,
- build_event_set_update,
- parse_event_changes,
- parse_event_get,
- 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.constants import CALENDAR_CAPABILITY, CORE_CAPABILITY, TASK_CAPABILITY
-from caldav.jmap.convert import ical_to_jscal
-from caldav.jmap.convert._patch import _NULL_FOR_UPDATE
-from caldav.jmap.error import JMAPAuthError, JMAPMethodError
-from caldav.jmap.objects.calendar import JMAPCalendar
-from caldav.jmap.objects.calendar_object import JMAPCalendarObject
-from caldav.jmap.session import Session, fetch_session
-from caldav.lib.http_sync import HTTPBasicAuth, requests
-from caldav.requests import HTTPBearerAuth
-
-log = logging.getLogger("caldav.jmap")
-
-_DEFAULT_USING = [CORE_CAPABILITY, CALENDAR_CAPABILITY]
-_TASK_USING = [CORE_CAPABILITY, TASK_CAPABILITY]
-
-
-class _JMAPClientBase:
- 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
- self._http_session = 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.
-
- **The JMAP support is experimental, the API may change in minor-releases**
-
- 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'.",
- )
-
- @staticmethod
- def _raise_set_error(api_url: str, err: dict) -> None:
- raise JMAPMethodError(
- url=api_url,
- reason=f"set failed: {err}",
- error_type=err.get("type", "serverError"),
- )
-
- @staticmethod
- def _build_event_search_calls(
- account_id: str,
- calendar_id: str | None,
- start: str | None,
- end: str | None,
- text: str | None,
- ) -> list[tuple]:
- """Return a batched [CalendarEvent/query, CalendarEvent/get] call list for _search."""
- 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(account_id, filter_condition=filter_dict or None)
- get_call = (
- "CalendarEvent/get",
- {
- "accountId": account_id,
- "#ids": {
- "resultOf": "ev-query-0",
- "name": "CalendarEvent/query",
- "path": "/ids",
- },
- },
- "ev-get-1",
- )
- return [query_call, get_call]
-
- @staticmethod
- def _build_event_update_patch(ical_str: str) -> tuple[dict, frozenset[str]]:
- """Build a JSCalendar PatchObject for a ``CalendarEvent/set`` update.
-
- RFC 8620 merge semantics preserve properties absent from the patch, so
- any optional property removed client-side must be explicitly nulled to
- actually clear it server-side. Returns the patch together with the set
- of keys that were null-injected purely for this cleanup (i.e. were not
- present in the converted iCalendar) so the caller can drop them if the
- server refuses to null a property it does not support.
- """
- patch = ical_to_jscal(ical_str)
- patch.pop("uid", None) # uid is server-immutable after creation; patch must omit it
- nulled: set[str] = set()
- for key in _NULL_FOR_UPDATE:
- if key not in patch:
- patch[key] = None
- nulled.add(key)
- return patch, frozenset(nulled)
-
- @staticmethod
- def _unsupported_null_keys(
- responses: list, event_id: str, patch: dict, nulled: frozenset[str]
- ) -> set[str] | None:
- """Detect an update that failed *only* because the server rejects
- null-clearing of properties it does not support.
-
- Some servers (e.g. Stalwart for ``recurrenceRules``) reject a property
- outright in ``CalendarEvent/set``, even when it is being set to ``null``.
- Nulling such a property is harmless cleanup — it was absent from the new
- iCalendar — so we report it as droppable, letting the caller retry the
- update without it.
-
- Returns the set of droppable keys when the failure is exactly this case,
- or ``None`` when the update succeeded or failed for a genuine reason (in
- which case the caller proceeds to :meth:`_parse_update_event_response`,
- which raises the real error). Some servers report only one offending
- property per response, so the caller retries in a loop, dropping the
- reported keys until the update succeeds or hits a genuine error; each
- returned key is guaranteed still present in ``patch``, so the loop
- strictly shrinks the patch and terminates.
- """
- for method_name, resp_args, _ in responses:
- if method_name == "CalendarEvent/set":
- _, _, _, _, not_updated, _ = parse_event_set(resp_args)
- err = not_updated.get(event_id)
- if not err or err.get("type") != "invalidProperties":
- return None
- props = set(err.get("properties") or [])
- droppable = {p for p in props if p in nulled and p in patch and patch[p] is None}
- # Only retry when every offending property is null-cleanup we can
- # safely omit; if the client actually set one of them to a value,
- # the rejection is genuine and must surface.
- if props and props == droppable:
- return droppable
- return None
- return None
-
- # ---------------------------------------------------------------------------
- # Shared response parsers — pure synchronous; used by both sync and async
- # clients. Each method takes the raw ``methodResponses`` list returned by
- # ``_request()`` plus whatever extra context is needed to build the result
- # or raise an informative error, and returns/raises exactly what the public
- # method should return/raise.
- # ---------------------------------------------------------------------------
-
- @staticmethod
- def _parse_get_calendars(responses: list, client, is_async: bool) -> list[JMAPCalendar]:
- for method_name, resp_args, _ in responses:
- if method_name == "Calendar/get":
- calendars = parse_calendar_get(resp_args)
- for cal in calendars:
- cal._client = client
- cal._is_async = is_async
- return calendars
- return []
-
- @staticmethod
- def _parse_create_event_response(responses: list, api_url: str) -> str:
- 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:
- _JMAPClientBase._raise_set_error(api_url, not_created["new-0"])
- if "new-0" not in created:
- raise JMAPMethodError(
- url=api_url,
- reason="CalendarEvent/set response missing created entry for new-0",
- )
- return created["new-0"]["id"]
- raise JMAPMethodError(url=api_url, reason="No CalendarEvent/set response")
-
- @staticmethod
- def _parse_get_event_response(
- responses: list, api_url: str, event_id: str
- ) -> JMAPCalendarObject:
- for method_name, resp_args, _ in responses:
- if method_name == "CalendarEvent/get":
- items = parse_event_get(resp_args)
- if not items:
- raise JMAPMethodError(
- url=api_url,
- reason=f"Event not found: {event_id}",
- error_type="notFound",
- )
- return JMAPCalendarObject(data=items[0], parent=None)
- raise JMAPMethodError(url=api_url, reason="No CalendarEvent/get response")
-
- @staticmethod
- def _parse_update_event_response(responses: list, api_url: str, event_id: str) -> None:
- 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:
- _JMAPClientBase._raise_set_error(api_url, not_updated[event_id])
- return
- raise JMAPMethodError(url=api_url, reason="No CalendarEvent/set response")
-
- @staticmethod
- def _parse_search_response(
- responses: list, parent: JMAPCalendar | None
- ) -> list[JMAPCalendarObject]:
- for method_name, resp_args, _ in responses:
- if method_name == "CalendarEvent/get":
- return [
- JMAPCalendarObject(data=item, parent=parent)
- for item in parse_event_get(resp_args)
- ]
- return []
-
- @staticmethod
- def _parse_get_sync_token_response(responses: list, api_url: str) -> str:
- for method_name, resp_args, _ in responses:
- if method_name == "CalendarEvent/get":
- return resp_args.get("state", "")
- raise JMAPMethodError(url=api_url, reason="No CalendarEvent/get response")
-
- @staticmethod
- def _parse_event_changes_response(
- responses: list, api_url: str
- ) -> tuple[list[str], list[str], list[str], str]:
- """Parse a CalendarEvent/changes response.
-
- Returns ``(created_ids, updated_ids, destroyed_ids, new_sync_token)``.
- Raises :class:`JMAPMethodError` when the server truncated the result.
- """
- created_ids: list[str] = []
- updated_ids: list[str] = []
- destroyed: list[str] = []
- new_sync_token: str = ""
- for method_name, resp_args, _ in responses:
- if method_name == "CalendarEvent/changes":
- _, new_sync_token, has_more, created_ids, updated_ids, destroyed = (
- parse_event_changes(resp_args)
- )
- if has_more:
- raise JMAPMethodError(
- url=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",
- )
- return created_ids, updated_ids, destroyed, new_sync_token
-
- @staticmethod
- def _assemble_sync_token_result(
- get_responses: list,
- created_ids: list[str],
- updated_ids: list[str],
- destroyed: list[str],
- new_sync_token: str,
- ) -> tuple[list[JMAPCalendarObject], list[JMAPCalendarObject], list[str], str]:
- events_by_id: dict[str, JMAPCalendarObject] = {}
- for method_name, resp_args, _ in get_responses:
- if method_name == "CalendarEvent/get":
- for item in parse_event_get(resp_args):
- events_by_id[item["id"]] = JMAPCalendarObject(data=item, parent=None)
- 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, new_sync_token
-
- @staticmethod
- def _parse_delete_event_response(responses: list, api_url: str, event_id: str) -> None:
- 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:
- _JMAPClientBase._raise_set_error(api_url, not_destroyed[event_id])
- return
- raise JMAPMethodError(url=api_url, reason="No CalendarEvent/set response")
-
- @staticmethod
- def _parse_get_task_lists_response(responses: list) -> list[dict]:
- for method_name, resp_args, _ in responses:
- if method_name == "TaskList/get":
- return parse_task_list_get(resp_args)
- return []
-
- @staticmethod
- def _parse_create_task_response(responses: list, api_url: str) -> str:
- 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:
- _JMAPClientBase._raise_set_error(api_url, not_created["new-0"])
- if "new-0" not in created:
- raise JMAPMethodError(
- url=api_url,
- reason="Task/set response missing created entry for new-0",
- )
- return created["new-0"]["id"]
- raise JMAPMethodError(url=api_url, reason="No Task/set response")
-
- @staticmethod
- def _parse_get_task_response(responses: list, api_url: str, task_id: str) -> dict:
- for method_name, resp_args, _ in responses:
- if method_name == "Task/get":
- items = resp_args.get("list", [])
- if not items:
- raise JMAPMethodError(
- url=api_url,
- reason=f"Task not found: {task_id}",
- error_type="notFound",
- )
- return items[0]
- raise JMAPMethodError(url=api_url, reason="No Task/get response")
-
- @staticmethod
- def _parse_update_task_response(responses: list, api_url: str, task_id: str) -> None:
- 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:
- _JMAPClientBase._raise_set_error(api_url, not_updated[task_id])
- return
- raise JMAPMethodError(url=api_url, reason="No Task/set response")
-
- @staticmethod
- def _parse_delete_task_response(responses: list, api_url: str, task_id: str) -> None:
- 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:
- _JMAPClientBase._raise_set_error(api_url, not_destroyed[task_id])
- return
- raise JMAPMethodError(url=api_url, reason="No Task/set response")
-
-
-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 _get_http_session(self):
- """Return the persistent HTTP session, creating it on first call."""
- if self._http_session is None:
- sess = requests.Session()
- sess.auth = self._auth
- sess.headers.update({"Content-Type": "application/json", "Accept": "application/json"})
- self._http_session = sess
- return self._http_session
-
- def close(self) -> None:
- """Release the persistent HTTP session and its connection pool.
-
- Only needed when the client was not used as a context manager -- the
- documented Quick Start builds one directly, and without this there
- was no way to hand the sockets back. Idempotent; the session is
- recreated on the next request.
- """
- if self._http_session is not None:
- self._http_session.close()
- self._http_session = None
-
- def __enter__(self) -> JMAPClient:
- self._get_http_session()
- return self
-
- def __exit__(self, exc_type, exc_val, exc_tb) -> None:
- self.close()
-
- def __del__(self) -> None:
- ## Last-resort net for a client that was neither closed nor used as a
- ## context manager. Interpreter shutdown can have torn down enough
- ## for this to fail, and an exception here is unraisable noise.
- try:
- self.close()
- except Exception:
- pass
-
- 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, timeout=self.timeout)
- return self._session_cache
-
- 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.
- requests.HTTPError: On other non-2xx HTTP responses.
- """
- session = 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))
-
- response = self._get_http_session().post(
- session.api_url,
- json=payload,
- 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
-
- 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()
- responses = self._request([build_calendar_get(session.account_id)])
- return self._parse_get_calendars(responses, self, False)
-
- 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 = build_event_set_create(session.account_id, {"new-0": jscal})
- responses = self._request([call])
- return self._parse_create_event_response(responses, session.api_url)
-
- def get_event(self, event_id: str) -> JMAPCalendarObject:
- """Fetch a calendar event by JMAP event ID.
-
- Args:
- event_id: The JMAP event ID to retrieve.
-
- Returns:
- A :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject`
- wrapping the raw JSCalendar dict. ``parent`` is ``None`` since
- no :class:`~caldav.jmap.objects.calendar.JMAPCalendar` is available
- at the client level.
-
- Raises:
- JMAPMethodError: If the event is not found.
- """
- session = self._get_session()
- responses = self._request([build_event_get(session.account_id, ids=[event_id])])
- return self._parse_get_event_response(responses, session.api_url, event_id)
-
- 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, nulled = self._build_event_update_patch(ical_str)
- while True:
- responses = self._request(
- [build_event_set_update(session.account_id, {event_id: patch})]
- )
- drop = self._unsupported_null_keys(responses, event_id, patch, nulled)
- if not drop:
- break
- for key in drop:
- patch.pop(key, None)
- self._parse_update_event_response(responses, session.api_url, event_id)
-
- def _search(
- self,
- calendar_id: str | None = None,
- start: str | None = None,
- end: str | None = None,
- text: str | None = None,
- parent: JMAPCalendar | None = None,
- ) -> list[JMAPCalendarObject]:
- session = self._get_session()
- calls = self._build_event_search_calls(session.account_id, calendar_id, start, end, text)
- responses = self._request(calls)
- return self._parse_search_response(responses, parent)
-
- def search_events(
- self,
- calendar_id: str | None = None,
- start: str | None = None,
- end: str | None = None,
- text: str | None = None,
- ) -> list[JMAPCalendarObject]:
- """Search for calendar events.
-
- 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 :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject`
- instances. ``parent`` is ``None`` on these objects since no
- :class:`~caldav.jmap.objects.calendar.JMAPCalendar` is available at
- the client level; use :meth:`JMAPCalendar.search` if you need ``parent``
- set.
- """
- return self._search(calendar_id=calendar_id, start=start, end=end, text=text)
-
- 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()
- responses = self._request([build_event_get(session.account_id, ids=[])])
- return self._parse_get_sync_token_response(responses, session.api_url)
-
- def get_objects_by_sync_token(
- self, sync_token: str
- ) -> tuple[list[JMAPCalendarObject], list[JMAPCalendarObject], list[str], 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
- :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject` instances;
- 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 4-tuple ``(added, modified, deleted, new_sync_token)``:
-
- - ``added``: objects for newly created events (``parent`` is ``None``).
- - ``modified``: objects for updated events (``parent`` is ``None``).
- - ``deleted``: Event IDs that were destroyed.
- - ``new_sync_token``: Pass to the next call to this method as ``sync_token``.
-
- Raises:
- JMAPMethodError: If the server reports ``hasMoreChanges: true``.
- """
- session = self._get_session()
- responses = self._request([build_event_changes(session.account_id, sync_token)])
- created_ids, updated_ids, destroyed, new_sync_token = self._parse_event_changes_response(
- responses, session.api_url
- )
- fetch_ids = created_ids + updated_ids
- if not fetch_ids:
- return [], [], destroyed, new_sync_token
- get_responses = self._request([build_event_get(session.account_id, ids=fetch_ids)])
- return self._assemble_sync_token_result(
- get_responses, created_ids, updated_ids, destroyed, new_sync_token
- )
-
- 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()
- responses = self._request([build_event_set_destroy(session.account_id, [event_id])])
- self._parse_delete_event_response(responses, session.api_url, event_id)
-
- def _get_object_by_uid(
- self, uid: str, calendar_id: str | None = None, parent: JMAPCalendar | None = None
- ) -> JMAPCalendarObject:
- # RFC 8984 FilterCondition has no uid field; UID matching is done client-side.
- for obj in self._search(calendar_id=calendar_id, parent=parent):
- if obj.data.get("uid") == uid:
- return obj
-
- raise JMAPMethodError(
- url=self._get_session().api_url, reason=f"No calendar object found with UID: {uid}"
- )
-
- def get_task_lists(self) -> list[dict]:
- """Fetch all task lists for the authenticated account.
-
- Returns:
- List of raw JMAP TaskList dicts as returned by the server.
- """
- session = self._get_session()
- responses = self._request([build_task_list_get(session.account_id)], using=_TASK_USING)
- return self._parse_get_task_lists_response(responses)
-
- 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 JMAP Task fields using wire names: ``description``,
- ``due``, ``start``, ``timeZone``, ``estimatedDuration``,
- ``percentComplete``, ``progress``, ``priority``.
-
- Returns:
- The server-assigned JMAP task ID.
-
- Raises:
- JMAPMethodError: If the server rejects the create request.
- """
- session = self._get_session()
- task_dict = {
- "@type": "Task",
- "uid": str(uuid.uuid4()),
- "taskListId": task_list_id,
- "title": title,
- "percentComplete": 0,
- "progress": "needs-action",
- "priority": 0,
- }
- task_dict.update(kwargs)
- call = build_task_set_create(session.account_id, {"new-0": task_dict})
- responses = self._request([call], using=_TASK_USING)
- return self._parse_create_task_response(responses, session.api_url)
-
- def get_task(self, task_id: str) -> dict:
- """Fetch a task by ID.
-
- Args:
- task_id: The JMAP task ID to retrieve.
-
- Returns:
- Raw JMAP Task dict as returned by the server.
-
- Raises:
- JMAPMethodError: If the task is not found.
- """
- session = self._get_session()
- responses = self._request(
- [build_task_get(session.account_id, ids=[task_id])], using=_TASK_USING
- )
- return self._parse_get_task_response(responses, session.api_url, task_id)
-
- 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)
- self._parse_update_task_response(responses, session.api_url, task_id)
-
- 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()
- responses = self._request(
- [build_task_set_destroy(session.account_id, [task_id])], using=_TASK_USING
- )
- self._parse_delete_task_response(responses, session.api_url, task_id)
diff --git a/caldav/jmap/constants.py b/caldav/jmap/constants.py
deleted file mode 100644
index 6b2554a9..00000000
--- a/caldav/jmap/constants.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""
-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 (RFC 8620) — required in every ``using`` declaration.
-CORE_CAPABILITY = "urn:ietf:params:jmap:core"
-
-#: JMAP Calendars capability (JMAP Calendars specification).
-CALENDAR_CAPABILITY = "urn:ietf:params:jmap:calendars"
-
-#: JMAP Tasks capability (JMAP Tasks specification).
-TASK_CAPABILITY = "urn:ietf:params:jmap:tasks"
diff --git a/caldav/jmap/convert/__init__.py b/caldav/jmap/convert/__init__.py
deleted file mode 100644
index 76c231d6..00000000
--- a/caldav/jmap/convert/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""
-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/_patch.py b/caldav/jmap/convert/_patch.py
deleted file mode 100644
index db31a2ab..00000000
--- a/caldav/jmap/convert/_patch.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""
-RFC 8620 PatchObject helpers for CalendarEvent/set update calls.
-
-When updating an event, absent keys preserve the server's current value.
-To delete an optional property the patch must set it to null explicitly.
-"""
-
-from __future__ import annotations
-
-# Optional JSCalendar top-level properties that must be explicitly nulled in
-# a CalendarEvent/set update when they are absent from the converted result.
-# This ensures properties removed client-side (e.g. LOCATION deleted from
-# the iCalendar) are actually removed on the server, not silently preserved.
-_NULL_FOR_UPDATE: frozenset[str] = frozenset(
- {
- "description",
- "color",
- "locations",
- "keywords",
- "priority",
- "privacy",
- "freeBusyStatus",
- "status",
- "sequence",
- "showWithoutTime",
- "timeZone",
- "recurrenceRules",
- "excludedRecurrenceRules",
- "recurrenceOverrides",
- "participants",
- "alerts",
- }
-)
diff --git a/caldav/jmap/convert/_utils.py b/caldav/jmap/convert/_utils.py
deleted file mode 100644
index 8f19b493..00000000
--- a/caldav/jmap/convert/_utils.py
+++ /dev/null
@@ -1,141 +0,0 @@
-"""
-Shared datetime and duration utilities for JSCalendar ↔ iCalendar conversion.
-"""
-
-from __future__ import annotations
-
-from datetime import date, datetime, timedelta
-from datetime import tzinfo as tzinfo_t
-
-
-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, tzinfo: tzinfo_t | None = None) -> str:
- """Format a datetime or date as a JSCalendar LocalDateTime string.
-
- RFC 8984 requires LocalDateTime (no Z suffix) for override keys and RRULE
- ``until`` values, and those are expressed in the *event's* timezone. An
- aware datetime is therefore converted into ``tzinfo`` before the offset is
- dropped; merely stripping it would shift the value by the UTC offset, and
- a floating ``UNTIL`` against a TZID ``DTSTART`` is forbidden outright by
- RFC 5545 3.3.10.
-
- ``tzinfo`` is the event's timezone, normally ``DTSTART.dt.tzinfo``. When
- it is None the event is floating or all-day: there is nothing to convert
- into, so the value is passed through as-is.
-
- For date objects (all-day), uses T00:00:00 suffix.
-
- Args:
- dt: A datetime (with or without tzinfo) or a date.
- tzinfo: The event's timezone, or None for a floating/all-day event.
-
- Returns:
- Formatted string suitable for use as a JSCalendar override key or RRULE until.
- """
- if isinstance(dt, datetime):
- if tzinfo is not None and dt.tzinfo is not None:
- dt = dt.astimezone(tzinfo)
- 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
deleted file mode 100644
index 6bd50e3d..00000000
--- a/caldav/jmap/convert/ical_to_jscal.py
+++ /dev/null
@@ -1,487 +0,0 @@
-"""
-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.
-"""
-
-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
-
-# RFC 5545 STATUS -> RFC 8984 status; module-level constant (cf. _CLASS_MAP etc.)
-_STATUS_ICAL_TO_JSCAL = {
- "CONFIRMED": "confirmed",
- "TENTATIVE": "tentative",
- "CANCELLED": "cancelled",
-}
-
-_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 — 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
- # 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, tzinfo=None) -> 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.
-
- ``tzinfo`` is the event's timezone; ``until`` is a LocalDateTime in that
- zone, so a UTC ``UNTIL`` off the wire has to be converted, not truncated.
- """
- 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], tzinfo)
-
- 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, tzinfo=None) -> dict:
- """Convert an EXDATE property (single or list) to recurrenceOverrides entries.
-
- ``tzinfo`` is the event's timezone — see :func:`_format_local_dt`.
-
- 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, tzinfo)] = {"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 per the JSCalendar Alert spec (RFC 8984 §4.5.2).
- """
- 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)
- if str(trigger_prop.params.get("RELATED", "START")).upper() == "END":
- alert["relativeTo"] = "end"
- 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 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
- override_components: list[icalendar.Event] = []
-
- for component in cal.subcomponents:
- if not isinstance(component, icalendar.Event):
- continue
- if component.get("RECURRENCE-ID") is not None:
- override_components.append(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)
-
- ## The event's own timezone. Every LocalDateTime slot below (RRULE
- ## until, EXDATE keys, RECURRENCE-ID keys) is expressed in it, so it has
- ## to be known before any of them can be formatted — which is why the
- ## override keys cannot be built in the loop above.
- event_tzinfo = getattr(getattr(dtstart_prop, "dt", None), "tzinfo", None)
-
- overrides_by_recurrence_id: dict[str, icalendar.Event] = {
- _format_local_dt(component["RECURRENCE-ID"].dt, event_tzinfo): component
- for component in override_components
- }
-
- 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 = {
- "@type": "Event",
- "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))
-
- status = master.get("STATUS")
- if status:
- jscal_status = _STATUS_ICAL_TO_JSCAL.get(str(status).upper())
- if jscal_status:
- jscal["status"] = jscal_status
-
- 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, event_tzinfo) 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, event_tzinfo) for r in exrules]
-
- recurrence_overrides: dict = {}
-
- exdate = master.get("EXDATE")
- if exdate is not None:
- recurrence_overrides.update(_exdate_to_overrides(exdate, event_tzinfo))
-
- 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
deleted file mode 100644
index f947f1dd..00000000
--- a/caldav/jmap/convert/jscal_to_ical.py
+++ /dev/null
@@ -1,462 +0,0 @@
-"""
-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
-from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
-
-import icalendar
-from icalendar import vCalAddress, vText
-
-from caldav.jmap.convert._utils import _duration_to_timedelta
-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",
-}
-# RFC 8984 status -> RFC 5545 STATUS; module-level constant (cf. _KIND_TO_CUTYPE etc.)
-_STATUS_JSCAL_TO_ICAL = {
- "confirmed": "CONFIRMED",
- "tentative": "TENTATIVE",
- "cancelled": "CANCELLED",
-}
-
-
-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 four 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:
- 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)
- else:
- component.add("dtstart", dt_naive)
-
-
-def _jscal_rrule_to_rrule(rule: dict, time_zone: str | None = None) -> 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.
-
- ``time_zone`` is the event's IANA time zone. The JSCalendar ``until`` is a
- LocalDateTime in that zone; RFC 5545 §3.3.10 requires the iCalendar UNTIL to
- be UTC whenever DTSTART is a TZID or UTC date-time, so a non-Z ``until`` is
- converted back to UTC here.
- """
- 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
- )
- elif time_zone:
- # RFC 5545 §3.3.10: a TZID/UTC DTSTART requires a UTC UNTIL. The
- # JSCalendar until is LocalDateTime in the event timeZone; convert
- # it back to UTC so the emitted UNTIL carries the Z suffix.
- naive = datetime.strptime(until[:19], "%Y-%m-%dT%H:%M:%S")
- try:
- ical_rule["UNTIL"] = naive.replace(tzinfo=ZoneInfo(time_zone)).astimezone(
- timezone.utc
- )
- except ZoneInfoNotFoundError:
- ical_rule["UNTIL"] = naive
- 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"] = [
- 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:
- 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_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
-
- addr = vCalAddress(_participant_imip(p))
- 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", {})
- has_attendee_role = any(
- roles.get(r) for r in ("attendee", "chair", "informational", "optional")
- )
- if not has_attendee_role and (roles.get("owner") or roles.get("organizer")):
- return None
-
- addr = vCalAddress(_participant_imip(p))
- 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)
- trigger = icalendar.vDuration(td)
- if alert.get("relativeTo") == "end":
- trigger.params["RELATED"] = "END"
- alarm.add("trigger", trigger)
- 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 raw JSCalendar CalendarEvent dict as returned by ``CalendarEvent/get``.
-
- 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)
-
- status = jscal.get("status")
- if status:
- ical_status = _STATUS_JSCAL_TO_ICAL.get(status)
- if ical_status:
- event.add("status", ical_status)
-
- for rule in jscal.get("recurrenceRules") or []:
- ical_rule = _jscal_rrule_to_rrule(rule, time_zone)
- if ical_rule:
- event.add("rrule", ical_rule)
-
- for rule in jscal.get("excludedRecurrenceRules") or []:
- ical_rule = _jscal_rrule_to_rrule(rule, time_zone)
- 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
- )
- elif show_without_time:
- rid_dt = date.fromisoformat(override_key[:10])
- elif time_zone:
- try:
- rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S").replace(
- tzinfo=ZoneInfo(time_zone)
- )
- except ZoneInfoNotFoundError:
- rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S")
- 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)
- # Default child start to the occurrence time (override key), not the master start.
- child_start = patch.get("start", override_key)
- 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
- 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/caldav/jmap/error.py b/caldav/jmap/error.py
deleted file mode 100644
index 8b7f704d..00000000
--- a/caldav/jmap/error.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""
-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/objects/__init__.py b/caldav/jmap/objects/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/caldav/jmap/objects/calendar.py b/caldav/jmap/objects/calendar.py
deleted file mode 100644
index 6be47bd7..00000000
--- a/caldav/jmap/objects/calendar.py
+++ /dev/null
@@ -1,201 +0,0 @@
-"""
-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
-from datetime import datetime, timezone
-from typing import TYPE_CHECKING
-
-from caldav.jmap.objects.calendar_object import JMAPCalendarObject
-
-if TYPE_CHECKING:
- from caldav.jmap.async_client import AsyncJMAPClient
- from caldav.jmap.client import JMAPClient
-
-
-def _to_utcdate(dt: datetime) -> str:
- """Convert a datetime to JMAP UTCDate format (YYYY-MM-DDTHH:MM:SSZ).
-
- Naive datetimes are assumed to be UTC. Aware datetimes are converted to
- UTC before formatting. Microseconds are dropped as JMAP does not allow them.
- """
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
-
-
-@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
-
- # Injected by JMAPClient.get_calendars() / AsyncJMAPClient.get_calendars()
- _client: JMAPClient | AsyncJMAPClient | None = field(
- default=None, init=False, repr=False, compare=False
- )
- _is_async: bool = field(default=False, init=False, repr=False, compare=False)
-
- @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 for ``Calendar/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,
- "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
-
- def search(self, **searchargs):
- """Search for calendar objects in this calendar.
-
- Mirrors :meth:`caldav.collection.Calendar.search`. When called on an
- async-backed calendar, returns a coroutine that must be awaited.
-
- Accepted keyword arguments (all optional):
-
- - ``start`` (datetime or str): only events ending after this time
- (maps to JMAP ``after`` filter).
- - ``end`` (datetime or str): only events starting before this time
- (maps to JMAP ``before`` filter).
- - ``text`` (str): free-text search across title, description,
- locations, and participants.
-
- Unknown parameters are silently ignored for backward compatibility.
-
- Returns:
- List of :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject`
- for all matching objects.
- """
- if self._is_async:
- return self._async_search(**searchargs)
- start = searchargs.get("start")
- end = searchargs.get("end")
- if isinstance(start, datetime):
- start = _to_utcdate(start)
- if isinstance(end, datetime):
- end = _to_utcdate(end)
- return self._client._search(
- calendar_id=self.id,
- start=start,
- end=end,
- text=searchargs.get("text"),
- parent=self,
- )
-
- async def _async_search(self, **searchargs) -> list[JMAPCalendarObject]:
- start = searchargs.get("start")
- end = searchargs.get("end")
- if isinstance(start, datetime):
- start = _to_utcdate(start)
- if isinstance(end, datetime):
- end = _to_utcdate(end)
- return await self._client._search(
- calendar_id=self.id,
- start=start,
- end=end,
- text=searchargs.get("text"),
- parent=self,
- )
-
- def get_object_by_uid(self, uid: str, comp_class=None):
- """Get a calendar object by its iCalendar UID.
-
- Mirrors :meth:`caldav.collection.Calendar.get_object_by_uid`. When
- called on an async-backed calendar, returns a coroutine that must be
- awaited.
-
- Args:
- uid: The iCalendar UID to search for.
- comp_class: Accepted for API compatibility with the CalDAV interface;
- JMAP ``CalendarEvent/query`` has no native component-type filter,
- so this argument is currently ignored.
-
- Returns:
- A :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject`
- for the matching object.
-
- Raises:
- JMAPMethodError: If no object with this UID is found.
- """
- if self._is_async:
- return self._async_get_object_by_uid(uid)
- return self._client._get_object_by_uid(uid, calendar_id=self.id, parent=self)
-
- async def _async_get_object_by_uid(self, uid: str) -> JMAPCalendarObject:
- return await self._client._get_object_by_uid(uid, calendar_id=self.id, parent=self)
-
- def add_event(self, ical_str: str) -> str:
- """Add an event to this calendar from an iCalendar string.
-
- Mirrors :meth:`caldav.collection.Calendar.add_event`. When called on
- an async-backed calendar, returns a coroutine that must be awaited.
-
- Args:
- ical_str: A VCALENDAR string representing the event.
-
- Returns:
- The server-assigned JMAP event ID. Unlike the CalDAV equivalent,
- this returns a string ID rather than a calendar object — the
- ``CalendarEvent/set`` response does not include the full object,
- so a follow-up GET would be required.
-
- Raises:
- JMAPMethodError: If the server rejects the create request.
- """
- if self._is_async:
- return self._async_add_event(ical_str)
- return self._client.create_event(self.id, ical_str)
-
- async def _async_add_event(self, ical_str: str) -> str:
- return await self._client.create_event(self.id, ical_str)
diff --git a/caldav/jmap/objects/calendar_object.py b/caldav/jmap/objects/calendar_object.py
deleted file mode 100644
index 602b8baf..00000000
--- a/caldav/jmap/objects/calendar_object.py
+++ /dev/null
@@ -1,108 +0,0 @@
-"""
-JMAP calendar resource object.
-
-Wraps a raw JSCalendar CalendarEvent dict with the same minimal interface
-as :class:`caldav.calendarobjectresource.CalendarObjectResource`:
-``.id``, ``.parent``, :meth:`get_data`, :meth:`get_icalendar_instance`,
-:meth:`edit_icalendar_instance`, and :meth:`save`.
-"""
-
-from __future__ import annotations
-
-from contextlib import contextmanager
-from dataclasses import dataclass, field
-from typing import TYPE_CHECKING
-
-import icalendar
-
-from caldav.jmap.convert import jscal_to_ical
-from caldav.jmap.error import JMAPMethodError
-
-if TYPE_CHECKING:
- from caldav.jmap.objects.calendar import JMAPCalendar
-
-
-@dataclass
-class JMAPCalendarObject:
- """Thin wrapper around a raw JSCalendar CalendarEvent dict.
-
- Stores the server's JSON response as-is. No JMAP field names are mapped
- to typed attributes — callers work with the dict directly via
- :meth:`get_data`, or convert to iCalendar via :meth:`get_icalendar_instance`.
-
- Attributes:
- data: Raw JSCalendar CalendarEvent dict as returned by ``CalendarEvent/get``.
- parent: The :class:`~caldav.jmap.objects.calendar.JMAPCalendar` this object
- belongs to, or ``None`` when fetched without a calendar context
- (e.g. via :meth:`~caldav.jmap.client.JMAPClient.get_event`).
- """
-
- data: dict
- parent: JMAPCalendar | None
-
- _ical_cache: icalendar.Calendar | None = field(
- default=None, init=False, repr=False, compare=False
- )
-
- @property
- def id(self) -> str:
- """Server-assigned JMAP event ID."""
- return self.data["id"]
-
- def get_data(self) -> dict:
- """Return the raw JSCalendar dict as returned by ``CalendarEvent/get``."""
- return self.data
-
- def get_icalendar_instance(self) -> icalendar.Calendar:
- """Return an :class:`icalendar.Calendar` for this object.
-
- The result is cached after the first conversion. Treat it as
- read-only; use :meth:`edit_icalendar_instance` to make and persist
- changes.
- """
- if self._ical_cache is None:
- self._ical_cache = icalendar.Calendar.from_ical(jscal_to_ical(self.data))
- return self._ical_cache
-
- @contextmanager
- def edit_icalendar_instance(self):
- """Borrow an editable :class:`icalendar.Calendar` for this object.
-
- Yields the cached :class:`icalendar.Calendar` for in-place editing.
- Call :meth:`save` after the ``with`` block to persist changes to the server.
-
- Note: :meth:`save` is sync-only. Async-backed calendars cannot use
- this path yet.
-
- Example::
-
- with obj.edit_icalendar_instance() as cal:
- cal.subcomponents[0]["SUMMARY"] = vText("New title")
- obj.save()
- """
- cal = self.get_icalendar_instance()
- yield cal
-
- def save(self) -> None:
- """Persist changes made via :meth:`edit_icalendar_instance` to the server.
-
- Serialises the (possibly edited) icalendar object back to an iCalendar
- string and calls ``update_event()`` on the parent calendar's client.
-
- Raises:
- JMAPMethodError: If no parent calendar is set (``parent`` is ``None``).
- RuntimeError: If called on an async-backed calendar.
- """
- if self.parent is None:
- raise JMAPMethodError(url="N/A", reason="Cannot save: no parent calendar is set")
- if self.parent._is_async:
- raise RuntimeError(
- "save() is not supported for async-backed calendars. "
- "Use await parent._client.update_event() directly."
- )
- ical_str = (
- self._ical_cache.to_ical().decode()
- if self._ical_cache is not None
- else jscal_to_ical(self.data)
- )
- self.parent._client.update_event(self.id, ical_str)
diff --git a/caldav/jmap/session.py b/caldav/jmap/session.py
deleted file mode 100644
index 5700ed01..00000000
--- a/caldav/jmap/session.py
+++ /dev/null
@@ -1,154 +0,0 @@
-"""
-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
-from urllib.parse import urljoin, urlparse, urlunparse
-
-from caldav.jmap.constants import CALENDAR_CAPABILITY
-from caldav.jmap.error import JMAPAuthError, JMAPCapabilityError
-from caldav.lib.http_sync import AsyncSession, requests
-
-
-@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 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.
- 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 _parse_session_data(url: str, data: dict) -> Session:
- api_url = data.get("apiUrl")
- if not api_url:
- raise JMAPCapabilityError(
- url=url,
- 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)
-
- # Some servers (e.g. Stalwart) advertise an api_url whose host matches ours
- # but with a different scheme (https vs http) and/or port than the one we
- # actually connected through. Rewrite both scheme and netloc to match the
- # session endpoint so that subsequent calls succeed without TLS errors.
- session_parsed = urlparse(url)
- api_parsed = urlparse(api_url)
- if api_parsed.hostname == session_parsed.hostname and (
- api_parsed.port != session_parsed.port or api_parsed.scheme != session_parsed.scheme
- ):
- api_url = urlunparse(
- api_parsed._replace(scheme=session_parsed.scheme, netloc=session_parsed.netloc)
- )
-
- state = data.get("state", "")
- server_capabilities = data.get("capabilities", {})
- accounts = data.get("accounts", {})
-
- account_id = None
- account_capabilities: dict = {}
- 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 = primary_acct_id
- account_capabilities = caps
- 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(
- 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,
- )
-
-
-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/caldav/lib/http_sync.py b/caldav/lib/http_sync.py
index 7fe8441a..ad027f89 100644
--- a/caldav/lib/http_sync.py
+++ b/caldav/lib/http_sync.py
@@ -9,23 +9,14 @@
different order and lives in :mod:`caldav.async_davclient`.
"""
-from typing import Any
-
from caldav.lib.http_libraries import (
SYNC_CANDIDATES,
no_http_library_error,
- required_library_error,
)
USE_NIQUESTS = False
USE_REQUESTS = False
-## niquests' AsyncSession has no requests equivalent, so it is None on the
-## fallback. The JMAP async client is the only thing that needs it; it goes
-## through require_async_session() to get a decent error rather than a
-## TypeError on None.
-AsyncSession: Any = None
-
try:
import niquests as requests
from niquests.auth import AuthBase, HTTPBasicAuth
@@ -49,31 +40,7 @@
except ImportError as e:
raise ImportError(no_http_library_error(SYNC_CANDIDATES)) from e
-if USE_NIQUESTS:
- ## Deliberately its own try: an ImportError here must not fall through to
- ## the requests branch and flip USE_NIQUESTS off on an install that does
- ## have niquests. Only the async JMAP client needs it.
- try:
- from niquests import AsyncSession
- except ImportError:
- ## Old niquests without AsyncSession: leave it None, and let
- ## require_async_session() explain it if anything asks for it.
- pass
-
-
-def require_async_session() -> Any:
- """Return niquests' ``AsyncSession``, or explain why there isn't one.
-
- Used by the async JMAP client, which is built on it and has no httpx
- equivalent to fall back to.
- """
- if AsyncSession is None:
- raise ImportError(required_library_error("niquests", "The async JMAP client"))
- return AsyncSession
-
-
__all__ = [
- "AsyncSession",
"AuthBase",
"CaseInsensitiveDict",
"HTTPBasicAuth",
@@ -81,5 +48,4 @@ def require_async_session() -> Any:
"USE_NIQUESTS",
"USE_REQUESTS",
"requests",
- "require_async_session",
]
diff --git a/docs/source/caldav/jmap_client.rst b/docs/source/caldav/jmap_client.rst
deleted file mode 100644
index a4eab9b8..00000000
--- a/docs/source/caldav/jmap_client.rst
+++ /dev/null
@@ -1,10 +0,0 @@
-: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
deleted file mode 100644
index 5e09a124..00000000
--- a/docs/source/caldav/jmap_objects.rst
+++ /dev/null
@@ -1,14 +0,0 @@
-: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/caldav/jmap_wrapper.rst b/docs/source/caldav/jmap_wrapper.rst
new file mode 100644
index 00000000..a4d4690a
--- /dev/null
+++ b/docs/source/caldav/jmap_wrapper.rst
@@ -0,0 +1,6 @@
+:mod:`jmap` -- JMAP wrapper
+============================
+
+.. automodule:: caldav.jmap
+ :synopsis: Thin wrapper around the standalone calendaring-jmap package
+ :members:
diff --git a/docs/source/http-libraries.rst b/docs/source/http-libraries.rst
index c0251caf..1d662a0b 100644
--- a/docs/source/http-libraries.rst
+++ b/docs/source/http-libraries.rst
@@ -71,10 +71,11 @@ document. Install one of them (``pip install niquests`` is the recommended
choice), or, if you are declaring caldav as a dependency of your own project,
depend on ``caldav[niquests]`` rather than plain ``caldav``.
-Note that the async *JMAP* client is the one exception to the fallback chain:
-it is built on niquests' ``AsyncSession`` and has no httpx equivalent, so
-``caldav.jmap`` requires niquests regardless of what the CalDAV clients are
-using.
+Note that none of this governs JMAP. The JMAP client moved out into the
+standalone `calendaring-jmap `_
+package, which picks its own HTTP library and requires both niquests and
+requests outright; ``caldav.jmap`` is only a wrapper around it. See
+:doc:`jmap`.
Recommendations
---------------
diff --git a/docs/source/jmap.rst b/docs/source/jmap.rst
index b679ed72..554dd3fa 100644
--- a/docs/source/jmap.rst
+++ b/docs/source/jmap.rst
@@ -2,22 +2,38 @@
JMAP
====
-**The JMAP support in v3.0 is experimental, the API may change in v3.1 of the library**
+JMAP (:rfc:`8620`, JMAP Core, plus the JMAP Calendars protocol using
+:rfc:`8984` JSCalendar) support moved out of this library into the standalone
+`calendaring-jmap `_ package.
+``caldav.jmap`` is now a thin wrapper around it, so the ``from caldav.jmap
+import get_jmap_client`` usage you may already have keeps working - it emits
+a ``DeprecationWarning`` and will be removed in a future release. Import
+from ``calendaring_jmap`` directly in new code.
-The caldav library includes a JMAP client for servers that speak
-:rfc:`8620` (JMAP Core) and
-the JMAP Calendars protocol (``urn:ietf:params:jmap:calendars``), which uses
-:rfc:`8984` (JSCalendar) as its data format.
-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.
+calendaring-jmap is an optional dependency; ``caldav.jmap`` raises an
+``ImportError`` without it. Install it with:
+
+.. code-block:: shell
+
+ pip install caldav[jmap]
+
+The extra brings dependencies caldav itself does not have: calendaring-jmap
+requires ``icalendar>=7.3.0``, which is a higher floor than caldav's own
+``icalendar>6.0.0``, and it requires both ``niquests`` and ``requests``, so
+``requests`` arrives even in an environment built to avoid it (see
+:doc:`http-libraries`).
.. note::
- The JMAP client targets servers implementing
- ``urn:ietf:params:jmap:calendars``. Cyrus IMAP is the primary tested server.
- Task support (``urn:ietf:params:jmap:tasks``) requires a separate server
- capability; Cyrus does not implement it yet.
+ calendaring-jmap is licensed **AGPL-3.0-or-later**, while caldav itself
+ is ``GPL-3.0-or-later OR Apache-2.0``. Pulling in the ``jmap`` extra
+ therefore brings the AGPL network-copyleft obligation into your
+ dependency tree, even though the import path is unchanged. This does
+ not affect caldav installed without the extra.
+
+For the full client API, calendar/event/task operations, and conversion
+details, see `calendaring-jmap's own documentation
+`_.
Quick Start
===========
@@ -35,390 +51,42 @@ Quick Start
for cal in calendars:
print(cal.name)
-The client keeps a persistent HTTP session, so connections are reused across
-requests. The ``with`` block releases it at the end; if you would rather hold
-on to the client, call ``client.close()`` when you are done
-(``await client.aclose()`` on the async client).
-
-: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``.
+Configuration
+=============
-With environment variables or a config file in place, no arguments are needed:
+Unlike calendaring-jmap used standalone (which reads ``JMAP_*`` env vars and
+its own config file), :func:`~caldav.jmap.get_jmap_client` reads
+configuration from the same sources as :func:`caldav.get_davclient`: explicit
+keyword arguments, then ``CALDAV_URL`` / ``CALDAV_USERNAME`` /
+``CALDAV_PASSWORD`` environment variables, then a config file, the same file
+CalDAV settings live in, so both protocols can share one config. See
+:doc:`configfile` for file locations and section options.
.. 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`.
-
-The client holds a persistent HTTP session so connections are reused between calls,
-so it is worth releasing it when you are done — either with a context manager or by
-calling ``close()`` (``aclose()`` on the async client):
-
-.. 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.
-
-The calendar-scoped API mirrors :class:`caldav.collection.Calendar`:
-
-.. code-block:: python
-
- cal = calendars[0]
-
- 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"
- )
-
- # Add an event to this calendar — returns the server-assigned JMAP event ID
- event_id = cal.add_event(ical)
-
- # Look up an event by its iCalendar UID — returns a VCALENDAR string
- ical_str = cal.get_object_by_uid("meeting-2026-01-15@example.com")
-
-If you already have a JMAP event ID (from :meth:`~caldav.jmap.client.JMAPClient.get_sync_token`
-results, for example), you can also use the lower-level client methods directly:
-
-.. code-block:: python
-
- # Fetch by JMAP event ID — 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
-================
-
-Use :meth:`~caldav.jmap.objects.calendar.JMAPCalendar.search` on a calendar object,
-mirroring the CalDAV :meth:`caldav.collection.Calendar.search` interface:
-
-.. code-block:: python
-
- cal = calendars[0]
-
- # All events in this calendar
- results = cal.search(event=True)
-
- # Time-range filter: events that overlap [start, end)
- # start — only events ending after this datetime
- # end — only events starting before this datetime
- results = cal.search(
- event=True,
- start="2026-01-01T00:00:00",
- end="2026-02-01T00:00:00",
- )
-
- # Free-text search across title, description, locations, and participants
- results = cal.search(text="standup")
-
- for ical_str in results:
- print(ical_str)
-
-All parameters are optional; omitting all returns every event in the calendar.
-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, token = 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. The fourth element is the server's new sync token; chaining straight
-from it avoids the race window a separate
-:meth:`~caldav.jmap.client.JMAPClient.get_sync_token` round-trip would open.
-
-: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, token = client.get_objects_by_sync_token(token)
- # process changes ...
- save_token(token)
-
-Tasks
-=====
-
-Task support requires a server implementing ``urn:ietf:params:jmap:tasks``
-(the JMAP Tasks specification). 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. Use it as an
-``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)
-
- # Calendar-scoped methods return coroutines when the calendar
- # was obtained from an async client
- cal = calendars[0]
- results = await cal.search(event=True)
- ical_str = await cal.get_object_by_uid("some-uid@example.com")
- event_id = await cal.add_event(ical)
-
- 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``.
+JMAP errors raised through ``caldav.jmap`` (:class:`~caldav.jmap.JMAPError`
+and subclasses) are also :class:`caldav.lib.error.DAVError` subclasses, so
+existing ``except DAVError`` handling around CalDAV code catches JMAP errors
+too:
.. 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}")
+ client.get_calendars()
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()
-
-JMAP and CalDAV settings can coexist in the same file using separate named sections:
-
-.. code-block:: yaml
-
- ---
- default:
- caldav_url: https://caldav.example.com
- caldav_username: alice
- caldav_password: secret
-
- jmap:
- caldav_url: https://jmap.example.com/.well-known/jmap
- caldav_username: alice
- caldav_password: secret
- protocol: jmap
-
-.. code-block:: python
-
- from caldav.jmap import get_jmap_client
- client = get_jmap_client(config_section="jmap")
-
-See :doc:`configfile` for file locations, section inheritance, and other options.
+ print(f"JMAP request failed: {e}")
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
+* :doc:`caldav/jmap_wrapper`: the wrapper's own surface (:func:`~caldav.jmap.get_jmap_client`,
+ :func:`~caldav.jmap.get_async_jmap_client`, and the ``DAVError``-compatible error classes)
+* `calendaring-jmap reference docs `_:
+ full ``JMAPClient``/``AsyncJMAPClient`` method reference, ``JMAPCalendar``, ``JMAPCalendarObject``
diff --git a/docs/source/reference.rst b/docs/source/reference.rst
index 1074d5f8..5b52db1f 100644
--- a/docs/source/reference.rst
+++ b/docs/source/reference.rst
@@ -19,5 +19,4 @@ Contents
caldav/davobject
caldav/collection
caldav/calendarobjectresource
- caldav/jmap_client
- caldav/jmap_objects
+ caldav/jmap_wrapper
diff --git a/docs/source/v3-migration.rst b/docs/source/v3-migration.rst
index 238608bb..66fec467 100644
--- a/docs/source/v3-migration.rst
+++ b/docs/source/v3-migration.rst
@@ -379,7 +379,8 @@ JMAP client (experimental)
A new ``caldav.jmap`` package provides ``JMAPClient`` and ``AsyncJMAPClient``
for servers implementing :rfc:`8620` (JMAP Core) and :rfc:`8984` (JMAP Calendars).
-The public API may change in minor releases. See :doc:`jmap`.
+The public API may change in minor releases. ``caldav.jmap`` is now a thin
+wrapper around the standalone ``calendaring-jmap`` package. See :doc:`jmap`.
Advanced search
---------------
diff --git a/pyproject.toml b/pyproject.toml
index 7a16e6fd..a381c7cf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -97,6 +97,10 @@ Changelog = "https://github.com/python-caldav/caldav/blob/master/CHANGELOG.md"
## behaviour, so they can depend on it now and change nothing later. See
## docs/source/http-libraries.rst
niquests = ["niquests"]
+## caldav.jmap is a thin wrapper around the standalone calendaring-jmap
+## package (JMAP support was extracted out of this repo, see CHANGELOG).
+## Optional because caldav/__init__.py never imports caldav.jmap eagerly.
+jmap = ["calendaring-jmap>=1.1.0"]
test = [
"vobject",
"pytest",
@@ -130,7 +134,7 @@ test = [
ignore = ["DEP002"] # Test dependencies (pytest, coverage, etc.) are not imported in main code
[tool.deptry.per_rule_ignores]
-DEP001 = ["conf", "h2"] # conf: Local test config, h2: Optional HTTP/2 support.
+DEP001 = ["conf", "h2", "calendaring_jmap"] # conf: Local test config, h2: Optional HTTP/2 support, calendaring_jmap: optional dep for caldav.jmap.
## httpxyz needed an entry here while it was imported by name; the httpx-family
## libraries are now imported dynamically (see _ASYNC_HTTPX_CANDIDATES), which
## deptry does not see at all.
@@ -202,6 +206,10 @@ filterwarnings = [
# Show conf_private.py deprecation warning once (not as error) during migration period
"once:conf_private.py is deprecated:DeprecationWarning",
+ # caldav.jmap is deprecated in favour of the standalone calendaring-jmap
+ # package (see caldav/jmap/__init__.py); show it once, not as an error
+ "once:caldav.jmap is deprecated:DeprecationWarning",
+
# Zimbra test server uses HTTPS with self-signed cert (ssl_verify_cert=False)
"ignore:Unverified HTTPS request:urllib3_future.exceptions.InsecureRequestWarning",
]
diff --git a/tests/test_http_libraries.py b/tests/test_http_libraries.py
index 52512577..7a37613c 100644
--- a/tests/test_http_libraries.py
+++ b/tests/test_http_libraries.py
@@ -24,11 +24,10 @@
)
## Modules that reach the HTTP-library import on their own, so the message has
-## to come out of their own import. caldav.jmap.client and caldav.jmap.session
-## are deliberately absent: importing either runs caldav/jmap/__init__.py
-## first, which imports async_client -> http_sync, so the error never comes
-## from the module under test and the case would pass even if the module were
-## reverted. TestOnlyOneModuleImportsTheHTTPLibrary is what covers those two.
+## to come out of their own import. caldav.jmap.* is deliberately absent:
+## since caldav/jmap became a thin wrapper around the standalone
+## calendaring-jmap package, its HTTP-library selection is calendaring-jmap's
+## own concern, not caldav's.
SYNC_MODULES = [
"caldav.davclient",
"caldav.discovery",
@@ -182,29 +181,29 @@ def test_async_candidates_is_niquests_then_the_httpx_family(self) -> None:
class TestRequiredLibraryMessage:
"""A library that has no fallback needs different wording from "none of
- them is installed" - the sync stack may well be running on requests."""
+ them is installed" - the sync stack may well be running on requests.
+
+ The helper has no caller in caldav today; its last one was
+ require_async_session(), removed when JMAP moved out into
+ calendaring-jmap. It is kept because the condition it describes recurs
+ whenever a component is built on one library, and the example below is
+ written as a hypothetical rather than naming something that no longer
+ exists."""
def test_names_the_required_library(self) -> None:
- message = required_library_error("niquests", "the async JMAP client")
+ message = required_library_error("niquests", "some niquests-only component")
assert "niquests" in message
- assert "the async JMAP client" in message
+ assert "some niquests-only component" in message
def test_does_not_claim_nothing_is_installed(self) -> None:
- message = required_library_error("niquests", "the async JMAP client")
+ message = required_library_error("niquests", "some niquests-only component")
assert "none of the supported" not in message
def test_still_points_at_the_extra_and_the_docs(self) -> None:
- message = required_library_error("niquests", "the async JMAP client")
+ message = required_library_error("niquests", "some niquests-only component")
assert "caldav[niquests]" in message
assert DOCS_URL in message
- def test_jmap_async_client_uses_it(self) -> None:
- """Only niquests blocked: the sync stack is fine on requests, so the
- "nothing is installed" wording would be a lie."""
- message = _import_with_libraries_blocked("caldav.jmap.async_client", ("niquests",))
- assert "none of the supported" not in message
- assert "niquests" in message
-
class TestAsyncOnlyInstall:
"""niquests gone, an httpx present, requests gone - the shape an async-only
diff --git a/tests/test_jmap_integration.py b/tests/test_jmap_integration.py
deleted file mode 100644
index 255d249e..00000000
--- a/tests/test_jmap_integration.py
+++ /dev/null
@@ -1,358 +0,0 @@
-"""
-Integration tests for the caldav.jmap package against live JMAP servers.
-
-Cyrus (port 8802):
- docker-compose -f tests/docker-test-servers/cyrus/docker-compose.yml up -d
-
-Stalwart (port 8806):
- docker-compose -f tests/docker-test-servers/stalwart/docker-compose.yml up -d
- ./tests/docker-test-servers/stalwart/setup_stalwart.sh
-
-Each server's test classes are skipped automatically when that server is not
-reachable — no failure, no noise.
-"""
-
-import socket
-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 AsyncJMAPClient, JMAPClient
-from caldav.jmap.constants import CALENDAR_CAPABILITY
-from caldav.jmap.convert import jscal_to_ical
-from caldav.jmap.error import JMAPMethodError
-from caldav.jmap.session import fetch_session
-
-CYRUS_HOST = "localhost"
-CYRUS_PORT = 8802
-CYRUS_JMAP_URL = f"http://{CYRUS_HOST}:{CYRUS_PORT}/.well-known/jmap"
-CYRUS_USERNAME = "user1"
-CYRUS_PASSWORD = "x"
-
-STALWART_HOST = "localhost"
-STALWART_PORT = 8809
-STALWART_JMAP_URL = f"http://{STALWART_HOST}:{STALWART_PORT}/.well-known/jmap"
-STALWART_USERNAME = "testuser@example.org"
-STALWART_PASSWORD = "testcaldav"
-
-
-def _reachable(host: str, port: int) -> bool:
- try:
- with socket.create_connection((host, port), timeout=2):
- return True
- except OSError:
- return False
-
-
-_cyrus_up = _reachable(CYRUS_HOST, CYRUS_PORT)
-_stalwart_up = _reachable(STALWART_HOST, STALWART_PORT)
-
-# Backward-compatible alias used by the module-level pytestmark below.
-# The mark only gates tests that don't carry their own skipif marker.
-pytestmark = pytest.mark.skipif(
- not _cyrus_up,
- 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",
-)
-
-
-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=CYRUS_JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD)
-
-
-@pytest.fixture(scope="module")
-def session():
- return fetch_session(CYRUS_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=CYRUS_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
-
-
-_stalwart_skip = pytest.mark.skipif(
- not _stalwart_up,
- reason=f"Stalwart Docker not reachable on {STALWART_HOST}:{STALWART_PORT} — "
- "start it with: cd tests/docker-test-servers/stalwart && ./start.sh",
-)
-
-
-@pytest.fixture(scope="module")
-def stalwart_client():
- return JMAPClient(url=STALWART_JMAP_URL, username=STALWART_USERNAME, password=STALWART_PASSWORD)
-
-
-@pytest.fixture(scope="module")
-def stalwart_calendar_id(stalwart_client):
- calendars = stalwart_client.get_calendars()
- assert calendars, "Stalwart did not return any calendars for user1"
- return calendars[0].id
-
-
-@pytest.fixture
-def stalwart_event_id(stalwart_client, stalwart_calendar_id):
- event_id = stalwart_client.create_event(
- stalwart_calendar_id, _minimal_ical("Stalwart Test Event")
- )
- yield event_id
- try:
- stalwart_client.delete_event(event_id)
- except Exception:
- pass
-
-
-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
-
-
-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}"
-
-
-class TestJMAPEventIntegration:
- def test_event_create_get(self, client, created_event_id):
- obj = client.get_event(created_event_id)
- ical = jscal_to_ical(obj.get_data())
- 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"))
- obj = client.get_event(created_event_id)
- assert "Updated Title" in jscal_to_ical(obj.get_data())
-
- 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 jscal_to_ical(r.get_data()) 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, _new_token = client.get_objects_by_sync_token(token_before)
- assert any("Sync Test Event" in jscal_to_ical(a.get_data()) 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 = jscal_to_ical(client.get_event(event_id).get_data())
- 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):
- obj = await async_client.get_event(async_created_event_id)
- ical = jscal_to_ical(obj.get_data())
- 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")
- )
- obj = await async_client.get_event(async_created_event_id)
- assert "Async Updated Title" in jscal_to_ical(obj.get_data())
-
- @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 jscal_to_ical(r.get_data()) 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, _new_token = await async_client.get_objects_by_sync_token(
- token_before
- )
- assert any("Async Sync Test Event" in jscal_to_ical(a.get_data()) 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 = jscal_to_ical((await async_client.get_event(event_id)).get_data())
- assert "Async Roundtrip Event" in fetched
- assert "20260715" in fetched
- finally:
- await async_client.delete_event(event_id)
-
-
-@_stalwart_skip
-class TestStalwartJMAPCalendarListIntegration:
- def test_list_calendars_returns_list(self, stalwart_client):
- calendars = stalwart_client.get_calendars()
- assert isinstance(calendars, list)
-
- def test_calendars_have_id_and_name(self, stalwart_client):
- calendars = stalwart_client.get_calendars()
- assert len(calendars) >= 1, "Expected at least one calendar on Stalwart for user1"
- for cal in calendars:
- assert cal.id, f"Calendar missing id: {cal}"
- assert cal.name, f"Calendar has empty name: {cal}"
-
-
-@_stalwart_skip
-class TestStalwartJMAPEventIntegration:
- def test_event_create_get(self, stalwart_client, stalwart_event_id):
- obj = stalwart_client.get_event(stalwart_event_id)
- ical = jscal_to_ical(obj.get_data())
- assert "BEGIN:VCALENDAR" in ical
- assert "Stalwart Test Event" in ical
-
- def test_event_update(self, stalwart_client, stalwart_event_id):
- stalwart_client.update_event(stalwart_event_id, _minimal_ical("Stalwart Updated Title"))
- obj = stalwart_client.get_event(stalwart_event_id)
- assert "Stalwart Updated Title" in jscal_to_ical(obj.get_data())
-
- def test_event_delete(self, stalwart_client, stalwart_calendar_id):
- event_id = stalwart_client.create_event(
- stalwart_calendar_id, _minimal_ical("Stalwart To Be Deleted")
- )
- stalwart_client.delete_event(event_id)
- with pytest.raises(JMAPMethodError):
- stalwart_client.get_event(event_id)
-
- def test_event_query_time_range(self, stalwart_client, stalwart_event_id):
- # Stalwart does not support the inCalendars filter; query without calendar_id.
- results = stalwart_client.search_events(
- start="2026-06-01T00:00:00",
- end="2026-06-02T00:00:00",
- )
- assert len(results) >= 1
- assert any("Stalwart Test Event" in jscal_to_ical(r.get_data()) for r in results)
-
- def test_ical_roundtrip(self, stalwart_client, stalwart_calendar_id):
- start = datetime(2026, 7, 15, 9, 0, 0, tzinfo=timezone.utc)
- event_id = stalwart_client.create_event(
- stalwart_calendar_id, _minimal_ical("Stalwart Roundtrip Event", start=start)
- )
- try:
- fetched = jscal_to_ical(stalwart_client.get_event(event_id).get_data())
- assert "Stalwart Roundtrip Event" in fetched
- assert "20260715" in fetched
- finally:
- stalwart_client.delete_event(event_id)
diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py
deleted file mode 100644
index 7cf316ae..00000000
--- a/tests/test_jmap_unit.py
+++ /dev/null
@@ -1,3012 +0,0 @@
-"""
-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 AsyncMock, MagicMock, patch
-
-import pytest
-
-try:
- from niquests.auth import HTTPBasicAuth
-except ImportError:
- from requests.auth import HTTPBasicAuth # type: ignore[no-redef]
-
-_JMAP_URL = "http://localhost:8802/.well-known/jmap"
-_API_URL = "http://localhost:8802/jmap/api"
-_USERNAME = "user1"
-_PASSWORD = "x"
-
-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()
-
-
-from caldav.jmap.constants import CALENDAR_CAPABILITY, TASK_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"
-
-
-from datetime import datetime, timezone
-
-from caldav.jmap.objects.calendar import JMAPCalendar
-from caldav.jmap.objects.calendar_object import JMAPCalendarObject
-
-_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["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()
- 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"})
-
- _RAW_EVENT = {
- "id": "ev1",
- "uid": "test-uid@example.com",
- "calendarIds": {"cal1": True},
- "title": "Staff Meeting",
- "start": "2026-01-15T09:00:00",
- "duration": "PT1H",
- }
-
- 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 _set_response(self, created=None, notCreated=None):
- return {
- "methodResponses": [
- [
- "CalendarEvent/set",
- {
- "accountId": _USERNAME,
- "created": created or {},
- "updated": {},
- "destroyed": [],
- "notCreated": notCreated or {},
- "notUpdated": {},
- "notDestroyed": {},
- },
- "ev-set-create-0",
- ]
- ]
- }
-
- def _capturing_calendar(self, monkeypatch, resp, calendar_id="cal1"):
- 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
-
- mock_http = MagicMock()
- mock_http.post.side_effect = capturing_post
- client._http_session = mock_http
- cal = JMAPCalendar(id=calendar_id, name="Test")
- cal._client = client
- cal._is_async = False
- return cal, captured
-
- def test_calendar_search_returns_ical_list(self, monkeypatch):
- event2 = {**self._RAW_EVENT, "id": "ev2", "title": "Standup"}
- resp = self._query_get_response([self._RAW_EVENT, event2])
- cal = _make_calendar_with_client(monkeypatch, resp)
- results = cal.search()
- assert len(results) == 2
- assert all(isinstance(r, JMAPCalendarObject) for r in results)
- assert all(r.parent is cal for r in results)
- assert results[0].id == "ev1"
-
- def test_calendar_search_passes_calendar_id_filter(self, monkeypatch):
- resp = self._query_get_response([self._RAW_EVENT])
- cal, captured = self._capturing_calendar(monkeypatch, resp, calendar_id="my-cal")
- cal.search()
- query_args = captured["json"]["methodCalls"][0][1]
- assert query_args["filter"]["inCalendars"] == ["my-cal"]
-
- def test_calendar_search_with_date_range(self, monkeypatch):
- resp = self._query_get_response([self._RAW_EVENT])
- cal, captured = self._capturing_calendar(monkeypatch, resp)
- cal.search(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"
-
- def test_calendar_search_datetime_converted_to_utcdate(self, monkeypatch):
- """§4.6: datetime.isoformat() produced wrong format for JMAP UTCDate.
- Naive datetimes produce no Z, aware non-UTC produce +HH:MM offset;
- JMAP requires ...Z (UTC, no microseconds)."""
- import datetime as _dt
-
- resp = self._query_get_response([self._RAW_EVENT])
- cal, captured = self._capturing_calendar(monkeypatch, resp)
- tz_plus2 = _dt.timezone(_dt.timedelta(hours=2))
- start_aware = datetime(2026, 6, 1, 12, 0, 0, tzinfo=tz_plus2) # +02:00 noon → UTC 10:00
- end_utc = datetime(2026, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
- cal.search(start=start_aware, end=end_utc)
- query_args = captured["json"]["methodCalls"][0][1]
- assert query_args["filter"]["after"] == "2026-06-01T10:00:00Z", (
- f"Expected UTC Z-format, got {query_args['filter']['after']!r}"
- )
- assert query_args["filter"]["before"] == "2026-12-31T23:59:59Z", (
- f"Expected UTC Z-format, got {query_args['filter']['before']!r}"
- )
-
- def test_calendar_search_ignores_unknown_params(self, monkeypatch):
- """Verify that unknown search parameters are silently ignored."""
- resp = self._query_get_response([self._RAW_EVENT])
- cal, captured = self._capturing_calendar(monkeypatch, resp)
- # Should not raise an error even with legacy/unknown parameters
- cal.search(event=True, todo=False, unknown_param="value")
- query_args = captured["json"]["methodCalls"][0][1]
- # Should only contain the calendar filter, no unknown params
- assert query_args["filter"] == {"inCalendars": [cal.id]}
-
- def test_calendar_search_with_text(self, monkeypatch):
- resp = self._query_get_response([self._RAW_EVENT])
- cal, captured = self._capturing_calendar(monkeypatch, resp)
- cal.search(text="standup")
- query_args = captured["json"]["methodCalls"][0][1]
- assert query_args["filter"]["text"] == "standup"
-
- def test_calendar_get_object_by_uid_found(self, monkeypatch):
- resp = self._query_get_response([self._RAW_EVENT])
- cal = _make_calendar_with_client(monkeypatch, resp)
- result = cal.get_object_by_uid("test-uid@example.com")
- assert isinstance(result, JMAPCalendarObject)
- assert result.id == "ev1"
- assert result.get_data()["title"] == "Staff Meeting"
- assert result.parent is cal
-
- def test_calendar_get_object_by_uid_not_found(self, monkeypatch):
- resp = self._query_get_response([self._RAW_EVENT])
- cal = _make_calendar_with_client(monkeypatch, resp)
- with pytest.raises(JMAPMethodError):
- cal.get_object_by_uid("nonexistent-uid@example.com")
-
- def test_calendar_add_event_delegates_to_create_event(self, monkeypatch):
- _MINIMAL_ICAL = (
- "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
- "UID:test@example.com\r\nSUMMARY:Test\r\n"
- "DTSTART:20260115T090000Z\r\nDTEND:20260115T100000Z\r\n"
- "END:VEVENT\r\nEND:VCALENDAR\r\n"
- )
- resp = self._set_response(created={"new-0": {"id": "sv-cal-1"}})
- cal, captured = self._capturing_calendar(monkeypatch, resp, calendar_id="my-calendar")
- cal.add_event(_MINIMAL_ICAL)
- create_args = captured["json"]["methodCalls"][0][1]
- event_payload = create_args["create"]["new-0"]
- assert event_payload.get("calendarIds") == {"my-calendar": True}
-
-
-_MINIMAL_JSCAL_DICT = {
- "id": "ev-obj-1",
- "uid": "obj-uid@example.com",
- "calendarIds": {"cal1": True},
- "title": "Object Test Event",
- "start": "2026-03-01T10:00:00",
- "timeZone": "Europe/Berlin",
- "duration": "PT1H",
-}
-
-
-class TestJMAPCalendarObject:
- def test_id_from_data(self):
- obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None)
- assert obj.id == "ev-obj-1"
-
- def test_get_data_returns_dict(self):
- obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None)
- assert obj.get_data() is _MINIMAL_JSCAL_DICT
-
- def test_get_icalendar_instance_returns_calendar(self):
- import icalendar
-
- obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None)
- cal = obj.get_icalendar_instance()
- assert isinstance(cal, icalendar.Calendar)
-
- def test_get_icalendar_instance_is_cached(self):
- obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None)
- assert obj.get_icalendar_instance() is obj.get_icalendar_instance()
-
- def test_edit_icalendar_instance_yields_calendar(self):
- import icalendar
-
- obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None)
- with obj.edit_icalendar_instance() as cal:
- assert isinstance(cal, icalendar.Calendar)
-
- def test_save_calls_update_event(self):
- mock_client = MagicMock()
- mock_parent = MagicMock()
- mock_parent._is_async = False
- mock_parent._client = mock_client
-
- obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=mock_parent)
- with obj.edit_icalendar_instance():
- pass
- obj.save()
-
- mock_client.update_event.assert_called_once()
- call_args = mock_client.update_event.call_args
- assert call_args[0][0] == "ev-obj-1"
- assert isinstance(call_args[0][1], str)
-
- def test_save_raises_without_parent(self):
- obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None)
- with pytest.raises(JMAPMethodError, match="no parent calendar"):
- obj.save()
-
- def test_save_raises_for_async_parent(self):
- mock_parent = MagicMock()
- mock_parent._is_async = True
- obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=mock_parent)
- with pytest.raises(RuntimeError):
- obj.save()
-
-
-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)
-
-
-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()
- mock_http = MagicMock()
- mock_http.post.return_value = mock_resp
- client._http_session = mock_http
- return client
-
-
-def _make_calendar_with_client(monkeypatch, api_response_json, calendar_id="cal1"):
- """Return a JMAPCalendar backed by a fully mocked JMAPClient."""
- client = _make_client_with_mocked_session(monkeypatch, api_response_json)
- cal = JMAPCalendar(id=calendar_id, name="Test")
- cal._client = client
- cal._is_async = False
- return cal
-
-
-class TestJMAPClient:
- def test_context_manager(self):
- with JMAPClient(url="http://x", username="u", password="p") as client:
- assert isinstance(client, JMAPClient)
-
- def test_context_manager_closes_http_session(self):
- mock_close = MagicMock()
- mock_http = MagicMock()
- mock_http.close = mock_close
- with patch("caldav.jmap.client.requests.Session", return_value=mock_http):
- client = JMAPClient(url="http://x", username="u", password="p")
- with client:
- assert client._http_session is mock_http
- mock_close.assert_called_once()
- assert client._http_session is None
-
- def test_http_session_reused_across_requests(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 = 200
- mock_resp.json.return_value = {"methodResponses": []}
- mock_resp.raise_for_status = MagicMock()
- with patch("caldav.jmap.client.requests.Session") as MockSession:
- mock_sess = MagicMock()
- mock_sess.post.return_value = mock_resp
- MockSession.return_value = mock_sess
- client._request([("Calendar/get", {}, "c0")])
- client._request([("Calendar/get", {}, "c1")])
- MockSession.assert_called_once()
- assert mock_sess.post.call_count == 2
-
- 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()
- mock_http = MagicMock()
- mock_http.post.return_value = mock_resp
- client._http_session = mock_http
-
- 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 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 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_changes,
- parse_event_get,
- 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,
-)
-
-
-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):
- event_dict = {
- "id": "ev1",
- "uid": "abc@example.com",
- "calendarIds": {"cal1": True},
- "title": "Test",
- "start": "2024-01-01T09:00:00",
- }
- response_args = {"list": [event_dict]}
- events = parse_event_get(response_args)
- assert len(events) == 1
- assert isinstance(events[0], dict)
- assert events[0]["id"] == "ev1"
-
- 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_condition=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_condition=f, sort=s)
- assert args["filter"] == f
- assert args["sort"] == s
-
- def test_build_event_set_create_structure(self):
- ev = {
- "uid": "abc@example.com",
- "calendarIds": {"cal1": True},
- "title": "Test",
- "start": "2024-01-01T09:00:00",
- }
- 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, 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, 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, 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"
-
-
-from datetime import date, timedelta
-
-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,
-)
-
-
-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
-
-
-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):
- # RFC 8984: LocalDateTime slots (override keys, RRULE until) must not carry Z suffix.
- dt = datetime(2024, 6, 15, 9, 0, 0, tzinfo=timezone.utc)
- assert _format_local_dt(dt) == "2024-06-15T09:00:00"
-
- 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"
-
-
-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["@type"] == "Event"
- assert result["uid"] == "test-uid@example.com"
- assert result["title"] == "Test Event"
- assert result["start"] == "2024-06-15T10:00:00"
- assert result["timeZone"] == "Etc/UTC"
- 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"] == "2024-06-15T10:00:00"
- assert result["timeZone"] == "Etc/UTC"
-
- 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_valarm_related_end(self):
- ical = _make_ical(
- "DTSTART:20240615T100000Z\r\n"
- "SUMMARY:End Alarm Event\r\n"
- "BEGIN:VALARM\r\n"
- "ACTION:DISPLAY\r\n"
- "TRIGGER;RELATED=END:-PT5M\r\n"
- "END:VALARM\r\n"
- )
- result = ical_to_jscal(ical)
- alert = next(iter(result["alerts"].values()))
- assert alert["trigger"] == "-PT5M"
- assert alert.get("relativeTo") == "end"
-
- 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)
-
-
-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_alert_related_end(self):
- jscal = _minimal_jscal(
- alerts={"al1": {"trigger": "-PT5M", "action": "display", "relativeTo": "end"}}
- )
- result = jscal_to_ical(jscal)
- assert "RELATED=END" in result
- assert "-PT5M" 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
-
-
-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"]
-
-
-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"
- )
-
- _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"]]}
-
- 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_raises_on_malformed_response(self, monkeypatch):
- resp = self._set_response(created={}, notCreated={})
- client = _make_client_with_mocked_session(monkeypatch, resp)
- with pytest.raises(JMAPMethodError):
- client.create_event("cal1", self._MINIMAL_ICAL)
-
- def test_create_event_passes_calendar_id(self, monkeypatch):
- resp = self._set_response(created={"new-0": {"id": "sv-2"}})
- client, captured = self._capturing_client(monkeypatch, resp)
- 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 isinstance(result, JMAPCalendarObject)
- assert result.id == "ev1"
- assert result.get_data()["title"] == "Staff Meeting"
- assert result.parent is None
-
- 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):
- resp = self._set_response(updated={"ev1": None})
- client, captured = self._capturing_client(monkeypatch, resp)
- 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_update_event_nulls_removed_optional_properties(self, monkeypatch):
- # RFC 8620 §3.3: absent keys in a PatchObject preserve the server value.
- # To actually delete a property the patch must set it to null.
- # An ical → jscal conversion that omits LOCATION/DESCRIPTION must send
- # {"locations": null, "description": null, ...} so the server removes them.
- _ICAL_WITHOUT_LOCATION = (
- "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
- "BEGIN:VEVENT\r\n"
- "UID:loc-uid@example.com\r\n"
- "DTSTART:20240615T090000Z\r\n"
- "SUMMARY:Event without Location\r\n"
- "END:VEVENT\r\nEND:VCALENDAR\r\n"
- )
- resp = self._set_response(updated={"ev1": None})
- client, captured = self._capturing_client(monkeypatch, resp)
- # First, pretend the event had a location (we don't need to call create; just update)
- client.update_event("ev1", _ICAL_WITHOUT_LOCATION)
- patch = captured["json"]["methodCalls"][0][1]["update"]["ev1"]
- # The patch must contain explicit null for 'locations' to remove it from the server
- assert "locations" in patch
- assert patch["locations"] is None
-
- def _sequence_client(self, responses):
- """Return (client, captured) replaying ``responses`` POST-by-POST.
-
- ``captured["patches"]`` collects the ``update`` patch dict sent on each
- CalendarEvent/set POST, in order.
- """
- captured: dict = {"patches": []}
- client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD)
- client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-abc")
- seq = iter(responses)
-
- def post(*args, **kwargs):
- body = kwargs.get("json", {})
- update = body["methodCalls"][0][1].get("update")
- if update:
- captured["patches"].append(dict(update["ev1"])) # copy: caller mutates in place
- mock_resp = MagicMock()
- mock_resp.status_code = 200
- mock_resp.json.return_value = next(seq)
- mock_resp.raise_for_status = MagicMock()
- return mock_resp
-
- mock_http = MagicMock()
- mock_http.post.side_effect = post
- client._http_session = mock_http
- return client, captured
-
- def test_update_event_retries_dropping_server_rejected_null_keys(self, monkeypatch):
- # A server (e.g. Stalwart) rejects null-clearing of recurrence properties
- # it does not support, reporting one offending property per response.
- # update_event must drop each reported null-cleanup key and retry until
- # the update succeeds — never failing on harmless cleanup.
- def reject(prop):
- return self._set_response(
- notUpdated={
- "ev1": {
- "type": "invalidProperties",
- "description": "Invalid property.",
- "properties": [prop],
- }
- }
- )
-
- responses = [
- reject("recurrenceRules"),
- reject("excludedRecurrenceRules"),
- self._set_response(updated={"ev1": None}),
- ]
- client, captured = self._sequence_client(responses)
- client.update_event("ev1", self._MINIMAL_ICAL)
-
- assert len(captured["patches"]) == 3
- # First attempt nulled both recurrence keys; the final accepted patch dropped them.
- assert captured["patches"][0]["recurrenceRules"] is None
- assert "recurrenceRules" not in captured["patches"][2]
- assert "excludedRecurrenceRules" not in captured["patches"][2]
-
- def test_update_event_does_not_drop_explicitly_set_property(self, monkeypatch):
- # If the rejected property was actually assigned a value by the client
- # (not null-cleanup), the rejection is genuine and must surface — no retry.
- resp = self._set_response(
- notUpdated={
- "ev1": {
- "type": "invalidProperties",
- "description": "Invalid property.",
- "properties": ["title"],
- }
- }
- )
- client, captured = self._sequence_client([resp])
- with pytest.raises(JMAPMethodError) as exc_info:
- client.update_event("ev1", self._MINIMAL_ICAL)
- assert exc_info.value.error_type == "invalidProperties"
- assert len(captured["patches"]) == 1 # no retry
-
- 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"
-
- 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
-
- mock_http = MagicMock()
- mock_http.post.side_effect = capturing_post
- client._http_session = mock_http
- 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(isinstance(r, JMAPCalendarObject) for r in results)
- assert all(r.parent is None 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
-
-
-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 _mock_http(self, client, response=None, side_effect=None):
- mock_http = MagicMock()
- if side_effect is not None:
- mock_http.post.side_effect = side_effect
- elif response is not None:
- mock_http.post.return_value = response
- client._http_session = mock_http
- return mock_http
-
- def test_get_sync_token_returns_state(self):
- resp = self._get_resp_with_state([], state="tok-1")
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- assert client.get_sync_token() == "tok-1"
-
- def test_get_sync_token_sends_empty_ids(self):
- captured = {}
- resp = self._get_resp_with_state([])
-
- def capturing_post(*args, **kwargs):
- captured["json"] = kwargs.get("json", {})
- return self._make_mock(resp)
-
- client = self._make_client()
- self._mock_http(client, side_effect=capturing_post)
- client.get_sync_token()
- assert captured["json"]["methodCalls"][0][1]["ids"] == []
-
- def test_get_objects_no_changes(self):
- resp = self._changes_resp()
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- added, modified, deleted, _ = client.get_objects_by_sync_token("state-1")
- assert added == [] and modified == [] and deleted == []
-
- def test_get_objects_deleted_returns_ids(self):
- resp = self._changes_resp(destroyed=["ev1"])
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- added, modified, deleted, _ = client.get_objects_by_sync_token("state-1")
- assert deleted == ["ev1"] and added == [] and modified == []
-
- def test_get_objects_added_returns_ical(self):
- changes_resp = self._changes_resp(created=["ev1"])
- get_resp = self._get_resp_with_state([self._RAW_EVENT])
- client = self._make_client()
- self._mock_http(
- client,
- side_effect=[self._make_mock(changes_resp), self._make_mock(get_resp)],
- )
- added, modified, deleted, _ = client.get_objects_by_sync_token("state-1")
- assert len(added) == 1
- assert isinstance(added[0], JMAPCalendarObject)
- assert added[0].id == "ev1"
- assert modified == [] and deleted == []
-
- def test_get_objects_modified_returns_ical(self):
- changes_resp = self._changes_resp(updated=["ev1"])
- get_resp = self._get_resp_with_state([self._RAW_EVENT])
- client = self._make_client()
- self._mock_http(
- client,
- side_effect=[self._make_mock(changes_resp), self._make_mock(get_resp)],
- )
- added, modified, deleted, _ = client.get_objects_by_sync_token("state-1")
- assert len(modified) == 1
- assert isinstance(modified[0], JMAPCalendarObject)
- assert modified[0].id == "ev1"
- assert added == [] and deleted == []
-
- def test_get_objects_has_more_raises(self):
- resp = self._changes_resp(created=["ev1"], has_more=True)
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- with pytest.raises(JMAPMethodError) as exc_info:
- client.get_objects_by_sync_token("state-1")
- assert exc_info.value.error_type == "serverPartialFail"
-
- def test_get_objects_returns_new_sync_token(self):
- """§4.7: newState from /changes was discarded into _. Callers had no
- way to chain sync calls without a separate get_sync_token() round-trip,
- creating a race window where intervening changes would be silently missed."""
- resp = self._changes_resp(new_state="state-99")
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- result = client.get_objects_by_sync_token("state-1")
- assert len(result) == 4, "expected 4-tuple (added, modified, deleted, new_sync_token)"
- added, modified, deleted, new_token = result
- assert new_token == "state-99"
- assert added == [] and modified == [] and deleted == []
-
- 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"]
-
-
-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 = {"@type": "Task", "uid": "uid-1", "taskListId": "tl1", "title": "Buy milk"}
- 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, dict) 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, dict) 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 _mock_http(self, client, response=None, side_effect=None):
- mock_http = MagicMock()
- if side_effect is not None:
- mock_http.post.side_effect = side_effect
- elif response is not None:
- mock_http.post.return_value = response
- client._http_session = mock_http
- return mock_http
-
- def test_get_task_lists_returns_list(self):
- resp = self._tasklist_response([self._MINIMAL_TASKLIST])
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- result = client.get_task_lists()
- assert len(result) == 1
- assert isinstance(result[0], dict)
- assert result[0]["name"] == "My Tasks"
-
- def test_create_task_returns_server_id(self):
- resp = self._set_response(created={"new-0": {"id": "sv-task-1"}})
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- task_id = client.create_task("tl1", "Buy groceries")
- assert task_id == "sv-task-1"
-
- def test_create_task_passes_task_list_id(self):
- 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)
-
- client = self._make_client()
- self._mock_http(client, side_effect=capturing_post)
- 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):
- resp = self._set_response(notCreated={"new-0": {"type": "invalidArguments"}})
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- with pytest.raises(JMAPMethodError) as exc_info:
- client.create_task("tl1", "Test")
- assert exc_info.value.error_type == "invalidArguments"
-
- def test_create_task_raises_jmap_error_when_created_is_empty(self):
- """§1.13: create_task must raise JMAPMethodError (not KeyError) when the server
- returns a Task/set response with an empty 'created' dict and no 'notCreated' entry."""
- resp = self._set_response(created={}, notCreated={})
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- with pytest.raises(JMAPMethodError):
- client.create_task("tl1", "Test")
-
- def test_get_task_returns_task_object(self):
- resp = self._get_response([self._MINIMAL_TASK])
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- task = client.get_task("task1")
- assert isinstance(task, dict)
- assert task["id"] == "task1"
-
- def test_get_task_raises_on_not_found(self):
- resp = self._get_response([])
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- with pytest.raises(JMAPMethodError) as exc_info:
- client.get_task("missing")
- assert exc_info.value.error_type == "notFound"
-
- def test_update_task_success(self):
- resp = self._set_response(updated={"task1": None})
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- client.update_task("task1", {"title": "Updated"})
-
- def test_update_task_raises_on_failure(self):
- resp = self._set_response(notUpdated={"task1": {"type": "notFound"}})
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- with pytest.raises(JMAPMethodError) as exc_info:
- client.update_task("task1", {"title": "X"})
- assert exc_info.value.error_type == "notFound"
-
- def test_delete_task_success(self):
- resp = self._set_response(destroyed=["task1"])
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- client.delete_task("task1")
-
- def test_delete_task_raises_on_failure(self):
- resp = self._set_response(notDestroyed={"task1": {"type": "notFound"}})
- client = self._make_client()
- self._mock_http(client, self._make_mock(resp))
- with pytest.raises(JMAPMethodError) as exc_info:
- client.delete_task("task1")
- assert exc_info.value.error_type == "notFound"
-
- def test_task_requests_use_task_capability(self):
- captured = {}
- resp = self._tasklist_response([self._MINIMAL_TASKLIST])
-
- def capturing_post(*args, **kwargs):
- captured["json"] = kwargs.get("json", {})
- return self._make_mock(resp)
-
- client = self._make_client()
- self._mock_http(client, side_effect=capturing_post)
- 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_context_manager_closes_http_session(self, monkeypatch):
- mock_close = AsyncMock()
- mock_http = MagicMock()
- mock_http.close = mock_close
- mock_http.headers = MagicMock()
- monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http)
- client = AsyncJMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD)
- async with client:
- assert client._http_session is mock_http
- mock_close.assert_called_once()
- assert client._http_session is None
-
- @pytest.mark.asyncio
- async def test_http_session_reused_across_requests(self, monkeypatch):
- client = self._make_client()
- mock_resp = self._make_mock_response({"methodResponses": []})
- mock_http = MagicMock()
- mock_http.post = AsyncMock(return_value=mock_resp)
- mock_http.headers = MagicMock()
- with patch("caldav.jmap.async_client.AsyncSession") as MockAsyncSession:
- MockAsyncSession.return_value = mock_http
- await client._request([("Calendar/get", {}, "c0")])
- await client._request([("Calendar/get", {}, "c1")])
- MockAsyncSession.assert_called_once()
- assert mock_http.post.call_count == 2
-
- @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 isinstance(result, JMAPCalendarObject)
- assert result.id == "ev-async-1"
- assert result.get_data()["title"] == "Async Test Event"
- assert result.parent is None
-
- @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(isinstance(r, JMAPCalendarObject) for r in results)
- assert all(r.parent is None 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 isinstance(added[0], JMAPCalendarObject)
- assert added[0].id == "ev-async-1"
- 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], dict)
- 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, dict)
- assert result["id"] == "task-async-1"
-
- @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 isinstance(modified[0], JMAPCalendarObject)
- assert modified[0].id == "ev-async-1"
- 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"
-
-
-class TestOverrideWithoutStartUsesOccurrenceTime:
- """§4.1: override child VEVENT must use occurrence time as DTSTART, not master start."""
-
- def test_title_only_override_dtstart_equals_occurrence(self):
- # Master: 2024-06-17T09:00:00Z (UTC), weekly recurrence.
- # Override for 2024-06-24T09:00:00Z changes only title — no "start" in patch.
- # Child DTSTART must be 20240624T090000Z, not 20240617T090000Z.
- jscal = {
- "uid": "override-dtstart@example.com",
- "title": "Master Title",
- "start": "2024-06-17T09:00:00Z",
- "duration": "PT1H",
- "recurrenceRules": [{"@type": "RecurrenceRule", "frequency": "weekly"}],
- "recurrenceOverrides": {
- "2024-06-24T09:00:00Z": {"title": "Changed Title"},
- },
- }
- result = jscal_to_ical(jscal)
- import icalendar as _ic
-
- cal = _ic.Calendar.from_ical(result)
- events = [c for c in cal.subcomponents if isinstance(c, _ic.Event)]
- assert len(events) == 2
- child = next(e for e in events if e.get("RECURRENCE-ID") is not None)
- # DTSTART of the child must match its own occurrence, not the master start
- child_dtstart = child["DTSTART"].dt
- if hasattr(child_dtstart, "utctimetuple"):
- import datetime as _dt
-
- assert child_dtstart == _dt.datetime(2024, 6, 24, 9, 0, 0, tzinfo=_dt.timezone.utc)
- else:
- assert str(child_dtstart) == "2024-06-24"
-
-
-class TestExdateValueType:
- """§4.2: EXDATE value type must match DTSTART (TZID or DATE, not floating)."""
-
- def test_exdate_for_tzid_event_has_tzid_param(self):
- # A TZID-anchored event's excluded override must produce EXDATE with TZID,
- # not a floating EXDATE (which per RFC 5545 won't match the instance).
- jscal = _minimal_jscal(
- start="2024-06-17T14:00:00",
- timeZone="Europe/Berlin",
- recurrenceRules=[{"@type": "RecurrenceRule", "frequency": "weekly"}],
- recurrenceOverrides={"2024-06-24T14:00:00": {"excluded": True}},
- )
- result = jscal_to_ical(jscal)
- # Must have TZID on EXDATE; a plain EXDATE:... without TZID is a floating datetime
- assert "EXDATE;TZID=Europe/Berlin:" in result
-
- def test_exdate_for_allday_event_is_date_value(self):
- jscal = {
- "uid": "allday-exdate@example.com",
- "title": "All Day Recurring",
- "start": "2024-06-17T00:00:00",
- "showWithoutTime": True,
- "duration": "P1D",
- "recurrenceRules": [{"@type": "RecurrenceRule", "frequency": "weekly"}],
- "recurrenceOverrides": {"2024-06-24T00:00:00": {"excluded": True}},
- }
- result = jscal_to_ical(jscal)
- # All-day EXDATE must be a DATE value (8-digit YYYYMMDD, not YYYYMMDDTHHMMSS datetime).
- # The icalendar library may or may not emit explicit VALUE=DATE — either form is acceptable.
- assert "EXDATE" in result
- assert "20240624" in result
- assert "20240624T" not in result # must not be a datetime
-
-
-class TestStatusMapping:
- """§4.4: STATUS must be mapped in both ical→jscal and jscal→ical directions."""
-
- def test_ical_status_cancelled_to_jscal(self):
- ical = _make_ical(
- "DTSTART:20240615T100000Z\r\nSUMMARY:Cancelled Meeting\r\nSTATUS:CANCELLED\r\n"
- )
- result = ical_to_jscal(ical)
- assert result.get("status") == "cancelled"
-
- def test_ical_status_tentative_to_jscal(self):
- ical = _make_ical(
- "DTSTART:20240615T100000Z\r\nSUMMARY:Tentative Meeting\r\nSTATUS:TENTATIVE\r\n"
- )
- result = ical_to_jscal(ical)
- assert result.get("status") == "tentative"
-
- def test_ical_status_confirmed_to_jscal(self):
- ical = _make_ical(
- "DTSTART:20240615T100000Z\r\nSUMMARY:Confirmed Meeting\r\nSTATUS:CONFIRMED\r\n"
- )
- result = ical_to_jscal(ical)
- assert result.get("status") == "confirmed"
-
- def test_ical_no_status_omits_jscal_status(self):
- ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:No Status\r\n")
- result = ical_to_jscal(ical)
- assert "status" not in result
-
- def test_jscal_status_cancelled_to_ical(self):
- result = jscal_to_ical(_minimal_jscal(status="cancelled"))
- assert "STATUS:CANCELLED" in result
-
- def test_jscal_status_tentative_to_ical(self):
- result = jscal_to_ical(_minimal_jscal(status="tentative"))
- assert "STATUS:TENTATIVE" in result
-
- def test_jscal_status_confirmed_to_ical(self):
- result = jscal_to_ical(_minimal_jscal(status="confirmed"))
- assert "STATUS:CONFIRMED" in result
-
- def test_jscal_no_status_omits_ical_status(self):
- result = jscal_to_ical(_minimal_jscal())
- assert "STATUS:" not in result
-
- def test_status_cancelled_round_trips(self):
- original = _make_ical(
- "DTSTART:20240615T100000Z\r\nSUMMARY:Cancelled\r\nSTATUS:CANCELLED\r\n"
- )
- jscal = ical_to_jscal(original)
- assert jscal.get("status") == "cancelled"
- round_tripped = jscal_to_ical(jscal)
- assert "STATUS:CANCELLED" in round_tripped
-
-
-class TestLocalDateTimeIsEventLocal:
- """Gate finding F3: RFC 8984 LocalDateTime slots (RRULE ``until``,
- ``recurrenceOverrides`` keys) are expressed in the event's own timezone.
- ``_format_local_dt()`` merely dropped the tzinfo, so a UTC value coming
- off the wire was off by the UTC offset -- and the resulting floating
- ``UNTIL`` against a TZID ``DTSTART`` is forbidden by RFC 5545 3.3.10."""
-
- ICAL_HEAD = (
- "BEGIN:VCALENDAR\r\n"
- "VERSION:2.0\r\n"
- "PRODID:-//Test//Test//EN\r\n"
- "BEGIN:VEVENT\r\n"
- "UID:tz-local@example.com\r\n"
- "DTSTAMP:20240101T000000Z\r\n"
- "DTSTART;TZID=Europe/Berlin:20240615T090000\r\n"
- "DURATION:PT1H\r\n"
- )
-
- def _convert(self, extra: str) -> dict:
- return ical_to_jscal(self.ICAL_HEAD + extra + "END:VEVENT\r\nEND:VCALENDAR\r\n")
-
- def test_until_is_converted_to_event_timezone(self):
- # 2024-06-30T07:00:00Z is 09:00 in Europe/Berlin (CEST, UTC+2).
- jscal = self._convert("RRULE:FREQ=WEEKLY;UNTIL=20240630T070000Z\r\n")
- assert jscal["recurrenceRules"][0]["until"] == "2024-06-30T09:00:00"
-
- def test_exdate_key_is_converted_to_event_timezone(self):
- jscal = self._convert("RRULE:FREQ=WEEKLY\r\nEXDATE;VALUE=DATE-TIME:20240622T070000Z\r\n")
- assert "2024-06-22T09:00:00" in jscal["recurrenceOverrides"]
-
- def test_until_round_trips_back_to_utc(self):
- """The other half of the same rule: RFC 5545 3.3.10 requires a UTC UNTIL
- whenever DTSTART carries a TZID, so the LocalDateTime ``until`` has to be
- converted back -- not merely parsed as naive -- on the way out. Raised in
- review of https://github.com/python-caldav/caldav/pull/688 ("the round-trip
- back through jscal_to_ical produces UNTIL=20240701T120000 with no Z
- suffix, which RFC 5545 3.3.10 forbids for TZID events")."""
- jscal = self._convert("RRULE:FREQ=WEEKLY;UNTIL=20240630T070000Z\r\n")
- assert jscal["recurrenceRules"][0]["until"] == "2024-06-30T09:00:00"
- ical = jscal_to_ical(jscal)
- assert "DTSTART;TZID=Europe/Berlin:20240615T090000" in ical
- assert "UNTIL=20240630T070000Z" in ical, (
- "a TZID DTSTART requires a UTC UNTIL; got a floating one"
- )
-
- def test_recurrence_id_key_is_converted_to_event_timezone(self):
- ical = (
- self.ICAL_HEAD + "RRULE:FREQ=WEEKLY\r\n"
- "END:VEVENT\r\n"
- "BEGIN:VEVENT\r\n"
- "UID:tz-local@example.com\r\n"
- "DTSTAMP:20240101T000000Z\r\n"
- "RECURRENCE-ID:20240622T070000Z\r\n"
- "DTSTART;TZID=Europe/Berlin:20240622T100000\r\n"
- "SUMMARY:Moved\r\n"
- "END:VEVENT\r\n"
- "END:VCALENDAR\r\n"
- )
- jscal = ical_to_jscal(ical)
- assert "2024-06-22T09:00:00" in jscal["recurrenceOverrides"]
-
- def test_naive_dtstart_leaves_utc_until_alone(self):
- """A floating DTSTART has no timezone to convert into; the value is
- passed through rather than guessed at."""
- ical = (
- "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\n"
- "BEGIN:VEVENT\r\nUID:floating@example.com\r\n"
- "DTSTAMP:20240101T000000Z\r\n"
- "DTSTART:20240615T090000\r\nDURATION:PT1H\r\n"
- "RRULE:FREQ=WEEKLY;UNTIL=20240630T070000Z\r\n"
- "END:VEVENT\r\nEND:VCALENDAR\r\n"
- )
- jscal = ical_to_jscal(ical)
- assert jscal["recurrenceRules"][0]["until"] == "2024-06-30T07:00:00"
-
-
-class TestJMAPSessionRelease:
- """Gate finding F9: the persistent HTTP session was released only by
- ``__exit__``/``__aexit__``. The documented Quick Start does not use
- ``with``, so a client built that way leaked its connection pool with no
- way to release it short of dropping the object and hoping."""
-
- def _make_client(self):
- from caldav.jmap.client import JMAPClient
-
- return JMAPClient(url="https://jmap.example.com/.well-known/jmap", password="token")
-
- def test_close_releases_the_session(self):
- client = self._make_client()
- session = client._get_http_session()
- assert session is not None
- client.close()
- assert client._http_session is None
-
- def test_close_is_idempotent(self):
- client = self._make_client()
- client._get_http_session()
- client.close()
- client.close()
- assert client._http_session is None
-
- def test_context_manager_still_releases_the_session(self):
- with self._make_client() as client:
- client._get_http_session()
- assert client._http_session is None
-
- def _make_async_client(self):
- from caldav.jmap.async_client import AsyncJMAPClient
-
- return AsyncJMAPClient(url="https://jmap.example.com/.well-known/jmap", password="token")
-
- @pytest.mark.asyncio
- async def test_aclose_releases_the_session(self):
- client = self._make_async_client()
- client._get_http_session()
- await client.aclose()
- assert client._http_session is None
-
- @pytest.mark.asyncio
- async def test_async_context_manager_still_releases_the_session(self):
- async with self._make_async_client() as client:
- client._get_http_session()
- assert client._http_session is None
diff --git a/tests/test_jmap_wrapper.py b/tests/test_jmap_wrapper.py
new file mode 100644
index 00000000..337b8764
--- /dev/null
+++ b/tests/test_jmap_wrapper.py
@@ -0,0 +1,351 @@
+"""
+Tests for caldav.jmap, the thin wrapper around the standalone
+calendaring-jmap package.
+
+The JMAP client/conversion/protocol logic itself is calendaring-jmap's own
+concern and is covered by that package's test suite; these tests only cover
+the wrapper: does the public surface still resolve, does get_jmap_client()
+still read caldav's config sources, and are JMAP errors still catchable as
+DAVError.
+"""
+
+import importlib
+import subprocess
+import sys
+import textwrap
+
+import pytest
+
+## calendaring-jmap is the `jmap` extra, which `pip install -e ".[test]"` does
+## not bring in - skip rather than error the whole module out at collection.
+## It has to be resolved before caldav.jmap is imported, since that is what
+## caldav.jmap itself imports.
+calendaring_jmap = pytest.importorskip("calendaring_jmap")
+
+import caldav.jmap as jmap # noqa: E402
+from caldav.lib.error import AuthorizationError, DAVError # noqa: E402
+
+
+class TestDeprecationWarning:
+ """caldav.jmap must warn on import - issue #10's explicit requirement,
+ so old imports keep working but say they're deprecated."""
+
+ def test_import_warns(self):
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ textwrap.dedent(
+ """
+ import warnings
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ import caldav.jmap
+ assert len(caught) == 1, caught
+ assert issubclass(caught[0].category, DeprecationWarning)
+ assert "caldav.jmap is deprecated" in str(caught[0].message)
+ print("ok")
+ """
+ ),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ assert result.returncode == 0, result.stderr
+ assert "ok" in result.stdout
+
+
+class TestMissingDependency:
+ """caldav.jmap must explain itself when calendaring-jmap isn't installed,
+ not surface a bare ModuleNotFoundError."""
+
+ def test_import_without_calendaring_jmap_raises_helpful_error(self):
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ textwrap.dedent(
+ """
+ import sys
+
+ class Blocker:
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname.split(".")[0] == "calendaring_jmap":
+ ## as the real import system reports it: a
+ ## ModuleNotFoundError carrying the name
+ raise ModuleNotFoundError(
+ "blocked by test", name=fullname
+ )
+ return None
+
+ sys.meta_path.insert(0, Blocker())
+
+ try:
+ import caldav.jmap
+ except ImportError as e:
+ assert "caldav[jmap]" in str(e)
+ assert "calendaring-jmap" in str(e)
+ assert ">=1.1.0" in str(e)
+ print("ok")
+ else:
+ print("no ImportError raised")
+ """
+ ),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ assert result.returncode == 0, result.stderr
+ assert "ok" in result.stdout
+
+
+class TestSubmoduleCompat:
+ """The old submodule layout must keep resolving.
+
+ ``caldav.jmap`` used to be a package with submodules, and the v3.3
+ documentation told people to write e.g. ``from caldav.jmap.error import
+ JMAPAuthError``. calendaring-jmap mirrors that layout 1:1, so the
+ wrapper aliases each public submodule rather than letting those imports
+ die with ModuleNotFoundError - which is not a deprecation path, just a
+ break.
+ """
+
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "async_client",
+ "client",
+ "constants",
+ "convert",
+ "convert.ical_to_jscal",
+ "convert.jscal_to_ical",
+ "error",
+ "objects",
+ "objects.calendar",
+ "objects.calendar_object",
+ "session",
+ ],
+ )
+ def test_submodule_is_calendaring_jmap_s(self, name):
+ assert importlib.import_module(f"caldav.jmap.{name}") is importlib.import_module(
+ f"calendaring_jmap.{name}"
+ )
+
+ def test_documented_error_import_works(self):
+ """docs/source/jmap.rst in v3.3 spelled this one out verbatim."""
+ from caldav.jmap.error import (
+ JMAPAuthError,
+ JMAPCapabilityError,
+ JMAPMethodError,
+ )
+
+ assert JMAPAuthError is calendaring_jmap.JMAPAuthError
+ assert JMAPCapabilityError is calendaring_jmap.JMAPCapabilityError
+ assert JMAPMethodError is calendaring_jmap.JMAPMethodError
+
+ def test_submodule_attribute_access_works(self):
+ """``import caldav.jmap.error`` must also bind the attribute."""
+ import caldav.jmap.error
+
+ assert caldav.jmap.error.JMAPError is calendaring_jmap.JMAPError
+ assert jmap.session.fetch_session is calendaring_jmap.session.fetch_session
+
+
+class TestBrokenInstall:
+ """A calendaring-jmap that is present but broken must not be reported as
+ missing - that diagnosis sends the reader off to install what they already
+ have."""
+
+ def _run(self, blocked):
+ return subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ textwrap.dedent(
+ f"""
+ import sys
+
+ class Blocker:
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname == {blocked!r}:
+ raise ModuleNotFoundError(
+ "blocked by test", name={blocked!r}
+ )
+ return None
+
+ sys.meta_path.insert(0, Blocker())
+
+ try:
+ import caldav.jmap
+ except ImportError as e:
+ print("ERROR:" + str(e))
+ print("NAME:" + str(e.name))
+ """
+ ),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+
+ def test_broken_submodule_is_not_reported_as_not_installed(self):
+ result = self._run("calendaring_jmap.session")
+ assert result.returncode == 0, result.stderr
+ lines = result.stdout.splitlines()
+ error = [line for line in lines if line.startswith("ERROR:")]
+ name = [line for line in lines if line.startswith("NAME:")]
+ assert error, result.stdout
+ ## the original error is re-raised untouched, naming the module that
+ ## actually failed - not caldav.jmap's "go and install it" message
+ assert name == ["NAME:calendaring_jmap.session"], name
+ assert "is not installed" not in error[0]
+ assert "caldav[jmap]" not in error[0]
+
+
+class TestPublicSurface:
+ """The wrapper must keep exactly the same __all__ as before extraction."""
+
+ def test_all_matches_expected_names(self):
+ assert sorted(jmap.__all__) == sorted(
+ [
+ "JMAPClient",
+ "AsyncJMAPClient",
+ "get_jmap_client",
+ "get_async_jmap_client",
+ "JMAPError",
+ "JMAPCapabilityError",
+ "JMAPAuthError",
+ "JMAPMethodError",
+ "JMAPCalendar",
+ "JMAPCalendarObject",
+ ]
+ )
+
+ def test_client_classes_are_calendaring_jmap_s(self):
+ assert jmap.JMAPClient is calendaring_jmap.JMAPClient
+ assert jmap.AsyncJMAPClient is calendaring_jmap.AsyncJMAPClient
+
+ def test_object_classes_are_calendaring_jmap_s(self):
+ assert jmap.JMAPCalendar is calendaring_jmap.JMAPCalendar
+ assert jmap.JMAPCalendarObject is calendaring_jmap.JMAPCalendarObject
+
+
+class TestGetJmapClient:
+ """get_jmap_client()/get_async_jmap_client() still resolve config the
+ way get_davclient() does, via caldav.config.get_connection_params()."""
+
+ def test_explicit_kwargs_build_a_client(self):
+ client = jmap.get_jmap_client(
+ url="https://jmap.example.com/.well-known/jmap",
+ username="alice",
+ password="secret",
+ )
+ assert isinstance(client, calendaring_jmap.JMAPClient)
+ assert client.url == "https://jmap.example.com/.well-known/jmap"
+ assert client.username == "alice"
+
+ def test_async_explicit_kwargs_build_a_client(self):
+ client = jmap.get_async_jmap_client(
+ url="https://jmap.example.com/.well-known/jmap",
+ username="alice",
+ password="secret",
+ )
+ assert isinstance(client, calendaring_jmap.AsyncJMAPClient)
+
+ def test_no_config_returns_none(self, monkeypatch):
+ monkeypatch.delenv("CALDAV_URL", raising=False)
+ monkeypatch.setattr(
+ "caldav.config.get_connection_params",
+ lambda **kwargs: None,
+ )
+ assert jmap.get_jmap_client(check_config_file=False, environment=False) is None
+ assert jmap.get_async_jmap_client(check_config_file=False, environment=False) is None
+
+ def test_non_jmap_keys_are_filtered_out(self, monkeypatch):
+ """get_connection_params() can return CalDAV-only keys (proxy, headers,
+ ...); JMAPClient's constructor does not accept those and must not see
+ them."""
+ monkeypatch.setattr(
+ "caldav.config.get_connection_params",
+ lambda **kwargs: {
+ "url": "https://jmap.example.com/.well-known/jmap",
+ "username": "alice",
+ "password": "secret",
+ "proxy": "http://localhost:8080",
+ "headers": {"X-Test": "1"},
+ },
+ )
+ client = jmap.get_jmap_client()
+ assert isinstance(client, calendaring_jmap.JMAPClient)
+
+
+class TestErrorHierarchy:
+ """caldav.jmap's error classes ARE calendaring_jmap's (plain re-exports,
+ no wrapper subclassing). calendaring_jmap/error.py is what conditionally
+ adds the DAVError/AuthorizationError parentage when caldav is importable."""
+
+ def test_error_classes_are_calendaring_jmap_s(self):
+ assert jmap.JMAPError is calendaring_jmap.JMAPError
+ assert jmap.JMAPCapabilityError is calendaring_jmap.JMAPCapabilityError
+ assert jmap.JMAPAuthError is calendaring_jmap.JMAPAuthError
+ assert jmap.JMAPMethodError is calendaring_jmap.JMAPMethodError
+
+ @pytest.mark.parametrize(
+ "error_cls",
+ [jmap.JMAPError, jmap.JMAPCapabilityError, jmap.JMAPAuthError, jmap.JMAPMethodError],
+ )
+ def test_is_a_daverror(self, error_cls):
+ assert issubclass(error_cls, DAVError)
+
+ def test_jmap_auth_error_is_also_an_authorizationerror(self):
+ assert issubclass(jmap.JMAPAuthError, AuthorizationError)
+
+ def test_except_daverror_catches_jmap_auth_error(self):
+ with pytest.raises(DAVError):
+ raise jmap.JMAPAuthError(url="https://x", reason="nope")
+
+ def test_error_type_and_reason_survive_construction(self):
+ err = jmap.JMAPMethodError(
+ url="https://jmap.example.com",
+ reason="bad request",
+ error_type="invalidArguments",
+ )
+ assert err.error_type == "invalidArguments"
+ assert err.reason == "bad request"
+ assert err.url == "https://jmap.example.com"
+
+ def test_calendaring_jmap_falls_back_without_caldav(self):
+ """calendaring_jmap must not hard-depend on caldav."""
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ textwrap.dedent(
+ """
+ import sys
+
+ class Blocker:
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname.split(".")[0] == "caldav":
+ raise ImportError("blocked by test")
+ return None
+
+ sys.meta_path.insert(0, Blocker())
+
+ from calendaring_jmap.error import JMAPError, JMAPAuthError
+
+ assert "caldav" not in sys.modules
+ err = JMAPAuthError(url="https://x", reason="nope")
+ assert isinstance(err, Exception)
+ print("ok")
+ """
+ ),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ assert result.returncode == 0, result.stderr
+ assert "ok" in result.stdout
diff --git a/tox.ini b/tox.ini
index f9178c66..53da5f7b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,7 +5,7 @@
envlist = py310,py311,py312,py313,py314,docs,style,deptry,audit,package
[testenv]
-deps = --editable .[test]
+deps = --editable .[test,jmap]
## TODO: too much duplication here, and it's getting worse for every docker-based server we decide to add to the test framework. Can this be redone somehow?
passenv =
BAIKAL_URL
@@ -27,6 +27,7 @@ commands = coverage run -m pytest
[testenv:docs]
## TODO - I don't like duplication, this is now both here and in docs/requirements.txt
+extras = jmap
deps =
sphinx<9
manuel