From b47c17ff1a56a8e8716aae432e2b77a382a207ea Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Tue, 15 Sep 2026 15:19:41 +0530 Subject: [PATCH 1/8] feat(jmap)!: re-export caldav.jmap from the standalone calendaring-jmap package --- .readthedocs.yaml | 2 +- CHANGELOG.md | 10 + caldav/jmap/__init__.py | 25 +- caldav/jmap/_methods/__init__.py | 12 - caldav/jmap/_methods/calendar.py | 68 - caldav/jmap/_methods/event.py | 268 --- caldav/jmap/_methods/task.py | 159 -- caldav/jmap/async_client.py | 464 ---- caldav/jmap/client.py | 804 ------- caldav/jmap/constants.py | 15 - caldav/jmap/convert/__init__.py | 12 - caldav/jmap/convert/_patch.py | 33 - caldav/jmap/convert/_utils.py | 141 -- caldav/jmap/convert/ical_to_jscal.py | 487 ---- caldav/jmap/convert/jscal_to_ical.py | 462 ---- caldav/jmap/error.py | 83 - caldav/jmap/objects/__init__.py | 0 caldav/jmap/objects/calendar.py | 201 -- caldav/jmap/objects/calendar_object.py | 108 - caldav/jmap/session.py | 154 -- caldav/lib/http_sync.py | 34 - docs/source/caldav/jmap_client.rst | 10 - docs/source/caldav/jmap_objects.rst | 14 - docs/source/caldav/jmap_wrapper.rst | 6 + docs/source/jmap.rst | 411 +--- docs/source/reference.rst | 3 +- docs/source/v3-migration.rst | 3 +- pyproject.toml | 10 +- tests/test_http_libraries.py | 16 +- tests/test_jmap_integration.py | 358 --- tests/test_jmap_unit.py | 3012 ------------------------ tests/test_jmap_wrapper.py | 197 ++ tox.ini | 1 + 33 files changed, 282 insertions(+), 7301 deletions(-) delete mode 100644 caldav/jmap/_methods/__init__.py delete mode 100644 caldav/jmap/_methods/calendar.py delete mode 100644 caldav/jmap/_methods/event.py delete mode 100644 caldav/jmap/_methods/task.py delete mode 100644 caldav/jmap/async_client.py delete mode 100644 caldav/jmap/client.py delete mode 100644 caldav/jmap/constants.py delete mode 100644 caldav/jmap/convert/__init__.py delete mode 100644 caldav/jmap/convert/_patch.py delete mode 100644 caldav/jmap/convert/_utils.py delete mode 100644 caldav/jmap/convert/ical_to_jscal.py delete mode 100644 caldav/jmap/convert/jscal_to_ical.py delete mode 100644 caldav/jmap/error.py delete mode 100644 caldav/jmap/objects/__init__.py delete mode 100644 caldav/jmap/objects/calendar.py delete mode 100644 caldav/jmap/objects/calendar_object.py delete mode 100644 caldav/jmap/session.py delete mode 100644 docs/source/caldav/jmap_client.rst delete mode 100644 docs/source/caldav/jmap_objects.rst create mode 100644 docs/source/caldav/jmap_wrapper.rst delete mode 100644 tests/test_jmap_integration.py delete mode 100644 tests/test_jmap_unit.py create mode 100644 tests/test_jmap_wrapper.py 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..3d38a892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ 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] + +### Changed + +* `caldav.jmap` no longer carries its own JMAP client implementation. It's now a thin re-export of the standalone [calendaring-jmap](https://pypi.org/project/calendaring-jmap/) package, added as an optional dependency (`caldav[jmap]`). + * Old imports still work: `from caldav.jmap import JMAPClient` etc. are unchanged. + * They now emit a `DeprecationWarning`. Use `from calendaring_jmap import JMAPClient` going forward. + * `get_jmap_client()`/`get_async_jmap_client()` still resolve configuration the same way `get_davclient()` does. + * 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..80a3223c 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,26 @@ calendars = await client.get_calendars() """ -from caldav.jmap.async_client import AsyncJMAPClient -from caldav.jmap.client import JMAPClient -from caldav.jmap.error import ( +import warnings + +from calendaring_jmap import ( + AsyncJMAPClient, JMAPAuthError, + JMAPCalendar, + JMAPCalendarObject, JMAPCapabilityError, + JMAPClient, JMAPError, JMAPMethodError, ) -from caldav.jmap.objects.calendar import JMAPCalendar -from caldav.jmap.objects.calendar_object import JMAPCalendarObject + +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, +) _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/jmap.rst b/docs/source/jmap.rst index b679ed72..0a9d3a67 100644 --- a/docs/source/jmap.rst +++ b/docs/source/jmap.rst @@ -2,22 +2,21 @@ 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 unchanged. -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. +Install it with: -.. note:: +.. code-block:: shell - 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. + pip install caldav[jmap] + +For the full client API, calendar/event/task operations, and conversion +details, see `calendaring-jmap's own documentation +`_. Quick Start =========== @@ -35,390 +34,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..5cfebddc 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", @@ -198,13 +197,6 @@ def test_still_points_at_the_extra_and_the_docs(self) -> None: 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..fa438b8e --- /dev/null +++ b/tests/test_jmap_wrapper.py @@ -0,0 +1,197 @@ +""" +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 subprocess +import sys +import textwrap + +import calendaring_jmap +import pytest + +import caldav.jmap as jmap +from caldav.lib.error import AuthorizationError, DAVError + + +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 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..f1c1b394 100644 --- a/tox.ini +++ b/tox.ini @@ -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 From cfeee210772f41c3c0d790696ec668ee5bb2e19c Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Tue, 15 Sep 2026 16:03:04 +0530 Subject: [PATCH 2/8] fix(jmap): install the jmap extra in the default tox testenv --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index f1c1b394..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 From 70162c5416df41ecdb5f28e8a25c034442b8db1b Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Wed, 16 Sep 2026 23:37:57 +0530 Subject: [PATCH 3/8] fix(jmap): raise a helpful error when calendaring-jmap is missing --- caldav/jmap/__init__.py | 27 +++++++++++++++---------- tests/test_jmap_wrapper.py | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/caldav/jmap/__init__.py b/caldav/jmap/__init__.py index 80a3223c..cfa20afd 100644 --- a/caldav/jmap/__init__.py +++ b/caldav/jmap/__init__.py @@ -34,16 +34,23 @@ import warnings -from calendaring_jmap import ( - AsyncJMAPClient, - JMAPAuthError, - JMAPCalendar, - JMAPCalendarObject, - JMAPCapabilityError, - JMAPClient, - JMAPError, - JMAPMethodError, -) +try: + from calendaring_jmap import ( + AsyncJMAPClient, + JMAPAuthError, + JMAPCalendar, + JMAPCalendarObject, + JMAPCapabilityError, + JMAPClient, + JMAPError, + JMAPMethodError, + ) +except ImportError as e: + raise ImportError( + "caldav.jmap requires the standalone calendaring-jmap package, which is " + "not installed. Install it with `pip install caldav[jmap]` or " + "`pip install calendaring-jmap`." + ) from e warnings.warn( "caldav.jmap is deprecated; import from the standalone calendaring-jmap " diff --git a/tests/test_jmap_wrapper.py b/tests/test_jmap_wrapper.py index fa438b8e..183c92d7 100644 --- a/tests/test_jmap_wrapper.py +++ b/tests/test_jmap_wrapper.py @@ -50,6 +50,46 @@ def test_import_warns(self): 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": + raise ImportError("blocked by test") + 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) + 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 TestPublicSurface: """The wrapper must keep exactly the same __all__ as before extraction.""" From 31da1d34236ce4acaaf92d23e55edcf516d9d274 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 18 Sep 2026 09:25:25 +0200 Subject: [PATCH 4/8] fix(jmap): keep old submodule import paths alive The re-export shim carried only the top-level names, so the import line the 3.3 documentation spells out verbatim - `from caldav.jmap.error import JMAPAuthError` - died with a ModuleNotFoundError instead of going through the new DeprecationWarning. calendaring-jmap mirrors the module layout caldav.jmap used to have, so each public submodule is now aliased into sys.modules and bound as an attribute of the package. Prompt: look into PR [https://github.com/python-caldav/caldav/pull/711] Assisted-By: Claude Opus 5 --- caldav/jmap/__init__.py | 50 ++++++++++++++++- tests/test_jmap_wrapper.py | 111 ++++++++++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/caldav/jmap/__init__.py b/caldav/jmap/__init__.py index cfa20afd..3521c14d 100644 --- a/caldav/jmap/__init__.py +++ b/caldav/jmap/__init__.py @@ -32,8 +32,24 @@ calendars = await client.get_calendars() """ +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, @@ -45,11 +61,39 @@ 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, which is " - "not installed. Install it with `pip install caldav[jmap]` or " - "`pip install calendaring-jmap`." + "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( diff --git a/tests/test_jmap_wrapper.py b/tests/test_jmap_wrapper.py index 183c92d7..b1735eec 100644 --- a/tests/test_jmap_wrapper.py +++ b/tests/test_jmap_wrapper.py @@ -9,6 +9,7 @@ DAVError. """ +import importlib import subprocess import sys import textwrap @@ -66,7 +67,11 @@ def test_import_without_calendaring_jmap_raises_helpful_error(self): class Blocker: def find_spec(self, fullname, path=None, target=None): if fullname.split(".")[0] == "calendaring_jmap": - raise ImportError("blocked by test") + ## 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()) @@ -76,6 +81,7 @@ def find_spec(self, fullname, path=None, target=None): 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") @@ -90,6 +96,109 @@ def find_spec(self, fullname, path=None, target=None): 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.""" From 898405c2e4dd5b33da62c2141fd1380e8a82d65f Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 18 Sep 2026 09:26:03 +0200 Subject: [PATCH 5/8] docs(jmap): state the AGPL change and the extra The CHANGELOG said old imports keep working, but not that a plain `pip install caldav` no longer gives you caldav.jmap at all, and neither it nor jmap.rst mentioned that calendaring-jmap is AGPL-3.0-or-later where caldav is GPL-3.0-or-later OR Apache-2.0. Same import path, different terms - easy to miss for anyone who picked the Apache option. Both are now written up as breaking changes, and the JMAP page carries a licensing note next to the install instructions. Prompt: look into PR [https://github.com/python-caldav/caldav/pull/711] Assisted-By: Claude Opus 5 --- CHANGELOG.md | 16 ++++++++++++---- docs/source/jmap.rst | 21 +++++++++++++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d38a892..cdab079c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,20 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0 ## [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.jmap` no longer carries its own JMAP client implementation. It's now a thin re-export of the standalone [calendaring-jmap](https://pypi.org/project/calendaring-jmap/) package, added as an optional dependency (`caldav[jmap]`). - * Old imports still work: `from caldav.jmap import JMAPClient` etc. are unchanged. - * They now emit a `DeprecationWarning`. Use `from calendaring_jmap import JMAPClient` going forward. - * `get_jmap_client()`/`get_async_jmap_client()` still resolve configuration the same way `get_davclient()` does. +* `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 diff --git a/docs/source/jmap.rst b/docs/source/jmap.rst index 0a9d3a67..554dd3fa 100644 --- a/docs/source/jmap.rst +++ b/docs/source/jmap.rst @@ -6,14 +6,31 @@ 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 unchanged. +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. -Install it with: +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:: + + 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 `_. From d8a3b49b4c1cd5952d2620d59d3e11d4c87c6e8b Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 18 Sep 2026 17:19:20 +0200 Subject: [PATCH 6/8] test(jmap): skip wrapper tests without the extra tests/test_jmap_wrapper.py imported calendaring_jmap at module level, so the documented developer setup - `pip install -e ".[test]"`, which does not carry the jmap extra - ended in a collection error rather than a skip. Only tox installs the extra, so CI never saw it. Resolved with pytest.importorskip, the convention the rest of the test suite already uses for an optional dependency, rather than by adding calendaring-jmap to the test extra: that would put an AGPL package into every contributor's test environment. Prompt: look into PR [https://github.com/python-caldav/caldav/pull/711] Assisted-By: Claude Opus 5 --- tests/test_jmap_wrapper.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_jmap_wrapper.py b/tests/test_jmap_wrapper.py index b1735eec..337b8764 100644 --- a/tests/test_jmap_wrapper.py +++ b/tests/test_jmap_wrapper.py @@ -14,11 +14,16 @@ import sys import textwrap -import calendaring_jmap import pytest -import caldav.jmap as jmap -from caldav.lib.error import AuthorizationError, DAVError +## 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: From d16d961d5aa921fb495d08f1b75a2509397983f3 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 18 Sep 2026 17:20:04 +0200 Subject: [PATCH 7/8] docs: JMAP no longer follows the fallback chain http-libraries.rst still described the async JMAP client as the one exception to the fallback chain, "built on niquests' AsyncSession" - that is require_async_session() in caldav/lib/http_sync.py, which the JMAP extraction deleted. The choice of HTTP library is calendaring-jmap's own concern now, and the advice to depend on caldav[niquests] for it was moot anyway, since calendaring-jmap requires niquests outright. Prompt: look into PR [https://github.com/python-caldav/caldav/pull/711] Assisted-By: Claude Opus 5 --- docs/source/http-libraries.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 --------------- From 7990ddf0d238b3ecce69035ea274ee77694d4caf Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 18 Sep 2026 17:20:54 +0200 Subject: [PATCH 8/8] docs: note the removed http_sync exports required_library_error()'s only caller was require_async_session(), which went with the JMAP extraction, and its tests pinned the exact wording using "the async JMAP client" as the example - a string naming something no longer in the tree, in tests that can no longer go red for a real user. The helper is kept, since the condition it describes recurs whenever a component is built on a single library, but the example is now a hypothetical. The AsyncSession and require_async_session removals from caldav.lib.http_sync get the CHANGELOG line they were missing. Prompt: look into PR [https://github.com/python-caldav/caldav/pull/711] Assisted-By: Claude Opus 5 --- CHANGELOG.md | 1 + tests/test_http_libraries.py | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdab079c..62e2a131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The JMAP support was declared experimental in 3.0, hence the changes below are d ### 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. diff --git a/tests/test_http_libraries.py b/tests/test_http_libraries.py index 5cfebddc..7a37613c 100644 --- a/tests/test_http_libraries.py +++ b/tests/test_http_libraries.py @@ -181,19 +181,26 @@ 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