diff --git a/.readthedocs.yaml b/.readthedocs.yaml index edc36817..9b97db30 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -39,4 +39,4 @@ python: - method: pip path: . extra_requirements: - - doc + - jmap diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da0680c..62e2a131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,25 @@ Changelogs prior to v3.0 are pruned, but are available in the v3.1 release This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), though for pre-releases PEP 440 takes precedence. +## [Unreleased] + +### Breaking changes + +The JMAP support was declared experimental in 3.0, hence the changes below are deemed allowable in a minor release: + +* **Breaking:** `caldav.jmap` no longer works on a plain `pip install caldav`. The implementation moved to the standalone [calendaring-jmap](https://pypi.org/project/calendaring-jmap/) package, which is an optional dependency - install `caldav[jmap]` (or `calendaring-jmap`). Importing `caldav.jmap` without it raises an `ImportError` saying so. +* **Breaking:** `caldav[jmap]` brings dependencies caldav itself does not have. calendaring-jmap 1.1.0 requires `icalendar>=7.3.0` (caldav asks only for `icalendar>6.0.0`, so the extra raises the floor), and it requires both `niquests` and `requests` outright - so `requests` is installed even in the environments that deliberately avoid it, see [HTTP Library Configuration](https://caldav.readthedocs.io/stable/http-libraries.html). +* **Breaking:** the JMAP code is licensed differently from the rest of caldav. caldav is `GPL-3.0-or-later OR Apache-2.0`; calendaring-jmap is `AGPL-3.0-or-later`. The import path is unchanged, so this is easy to miss: if you relied on the Apache-2.0 option, note that the JMAP code you get through `caldav[jmap]` carries the AGPL network-copyleft obligation. Nothing changes for users of caldav without the `jmap` extra. + +### Changed + +* `caldav.lib.http_sync` no longer exports `AsyncSession` or `require_async_session()`. They existed only for the async JMAP client, which has moved out of this library; `caldav.lib.http_libraries.required_library_error()` is kept, but has no caller in caldav today. +* `caldav.jmap` no longer carries its own JMAP client implementation. It's now a thin re-export of calendaring-jmap. + * Old imports still work, including the submodule paths the 3.3 documentation used: `from caldav.jmap import JMAPClient`, `from caldav.jmap.error import JMAPAuthError`, `caldav.jmap.convert.jscal_to_ical` and so on all resolve to calendaring-jmap's own modules. + * Importing `caldav.jmap` now emits a `DeprecationWarning`. Use `from calendaring_jmap import JMAPClient` going forward; the wrapper will be removed in a future release. + * `get_jmap_client()`/`get_async_jmap_client()` still resolve configuration the same way `get_davclient()` does - that is the one thing the wrapper adds over importing calendaring-jmap directly. + * JMAP errors remain catchable as `DAVError`. + ## [3.3.1] - 2026-09-16 The two main things in this release: diff --git a/caldav/jmap/__init__.py b/caldav/jmap/__init__.py index 4309d9f2..3521c14d 100644 --- a/caldav/jmap/__init__.py +++ b/caldav/jmap/__init__.py @@ -1,6 +1,11 @@ """ JMAP calendar support for python-caldav. +.. deprecated:: + Thin re-export of the standalone `calendaring-jmap + `_ package. Import from + ``calendaring_jmap`` directly instead. + Provides synchronous and asynchronous JMAP clients with the same public API as the CalDAV client, so user code works regardless of server protocol. @@ -27,16 +32,77 @@ calendars = await client.get_calendars() """ -from caldav.jmap.async_client import AsyncJMAPClient -from caldav.jmap.client import JMAPClient -from caldav.jmap.error import ( - JMAPAuthError, - JMAPCapabilityError, - JMAPError, - JMAPMethodError, +import importlib +import sys +import warnings + +_SUBMODULES = ( + "async_client", + "client", + "constants", + "convert", + "convert.ical_to_jscal", + "convert.jscal_to_ical", + "error", + "objects", + "objects.calendar", + "objects.calendar_object", + "session", +) + +try: + from calendaring_jmap import ( + AsyncJMAPClient, + JMAPAuthError, + JMAPCalendar, + JMAPCalendarObject, + JMAPCapabilityError, + JMAPClient, + JMAPError, + JMAPMethodError, + ) + + ## calendaring-jmap mirrors the submodule layout caldav.jmap used to have, + ## so alias each public submodule into place. Without this, the import + ## line the v3.3 documentation spelled out - `from caldav.jmap.error import + ## JMAPAuthError` - dies with ModuleNotFoundError instead of going through + ## the DeprecationWarning below. sys.modules is what `from X.Y import Z` + ## consults; globals() is what makes `caldav.jmap.error` work as an + ## attribute, which the import machinery would otherwise have set itself. + for _name in _SUBMODULES: + _module = importlib.import_module(f"calendaring_jmap.{_name}") + sys.modules[f"{__name__}.{_name}"] = _module + if "." not in _name: + globals()[_name] = _module + del _name, _module +except ImportError as e: + ## Python removes a half-imported caldav.jmap from sys.modules, but not the + ## aliases the loop above may already have registered. Left behind, a + ## later `from caldav.jmap.error import ...` would resolve against a + ## package that never finished importing. (calendaring-jmap 1.1.0 imports + ## all of these eagerly, so the loop cannot currently be the thing that + ## fails - this is here for the version that makes one of them lazy.) + for _name in _SUBMODULES: + sys.modules.pop(f"{__name__}.{_name}", None) + ## Only the top-level package being absent means "not installed". Anything + ## raised from inside calendaring-jmap is a broken install or a version + ## mismatch, and saying "install it" sends the reader after something they + ## already have. + if e.name != "calendaring_jmap": + raise + raise ImportError( + "caldav.jmap requires the standalone calendaring-jmap package " + "(>=1.1.0), which is not installed. Install it with " + "`pip install caldav[jmap]` or `pip install 'calendaring-jmap>=1.1.0'`." + ) from e + +warnings.warn( + "caldav.jmap is deprecated; import from the standalone calendaring-jmap " + "package instead (pip install calendaring-jmap). caldav.jmap now just " + "re-exports it and will be removed in a future release.", + DeprecationWarning, + stacklevel=2, ) -from caldav.jmap.objects.calendar import JMAPCalendar -from caldav.jmap.objects.calendar_object import JMAPCalendarObject _JMAP_KEYS = {"url", "username", "password", "auth", "auth_type", "timeout"} diff --git a/caldav/jmap/_methods/__init__.py b/caldav/jmap/_methods/__init__.py deleted file mode 100644 index c2bf779d..00000000 --- a/caldav/jmap/_methods/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -def parse_set_response(response_args: dict) -> tuple[dict, dict, list[str], dict, dict, dict]: - """Parse the arguments dict from any JMAP ``*/set`` method response. - - Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``. - """ - created: dict = response_args.get("created") or {} - updated: dict = response_args.get("updated") or {} - destroyed: list[str] = response_args.get("destroyed") or [] - not_created: dict = response_args.get("notCreated") or {} - not_updated: dict = response_args.get("notUpdated") or {} - not_destroyed: dict = response_args.get("notDestroyed") or {} - return created, updated, destroyed, not_created, not_updated, not_destroyed diff --git a/caldav/jmap/_methods/calendar.py b/caldav/jmap/_methods/calendar.py deleted file mode 100644 index 3dcbe49a..00000000 --- a/caldav/jmap/_methods/calendar.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -JMAP Calendar method builders and response parsers. - -These are pure functions — no HTTP, no state. They build the request -tuples that go into a ``methodCalls`` list, and parse the corresponding -``methodResponses`` entries. - -Method shapes follow RFC 8620 §3.3 (get), §3.4 (changes), §3.5 (set); Calendar-specific -properties are defined in the JMAP Calendars specification. -""" - -from __future__ import annotations - -from caldav.jmap.objects.calendar import JMAPCalendar - - -def build_calendar_get( - account_id: str, - ids: list[str] | None = None, - properties: list[str] | None = None, -) -> tuple: - """Build a ``Calendar/get`` method call tuple. - - Args: - account_id: The JMAP accountId to query. - ids: List of calendar IDs to fetch, or ``None`` to fetch all. - properties: List of property names to return, or ``None`` for all. - - Returns: - A 3-tuple ``("Calendar/get", arguments_dict, call_id)`` suitable - for inclusion in a ``methodCalls`` list. - """ - args: dict = {"accountId": account_id, "ids": ids} - if properties is not None: - args["properties"] = properties - return ("Calendar/get", args, "cal-get-0") - - -def parse_calendar_get(response_args: dict) -> list[JMAPCalendar]: - """Parse the arguments dict from a ``Calendar/get`` method response. - - Args: - response_args: The second element of a ``methodResponses`` entry - whose method name is ``"Calendar/get"``. - - Returns: - List of :class:`~caldav.jmap.objects.calendar.JMAPCalendar` objects. - Returns an empty list if ``"list"`` is absent or empty. - """ - return [JMAPCalendar.from_jmap(item) for item in response_args.get("list", [])] - - -def build_calendar_changes(account_id: str, since_state: str) -> tuple: - """Build a ``Calendar/changes`` method call tuple. - - Args: - account_id: The JMAP accountId to query. - since_state: The ``state`` string from a previous ``Calendar/get`` - or ``Calendar/changes`` response. - - Returns: - A 3-tuple ``("Calendar/changes", arguments_dict, call_id)``. - """ - return ( - "Calendar/changes", - {"accountId": account_id, "sinceState": since_state}, - "cal-changes-0", - ) diff --git a/caldav/jmap/_methods/event.py b/caldav/jmap/_methods/event.py deleted file mode 100644 index fdda4e06..00000000 --- a/caldav/jmap/_methods/event.py +++ /dev/null @@ -1,268 +0,0 @@ -""" -JMAP CalendarEvent method builders and response parsers. - -These are pure functions — no HTTP, no state. They build the request -tuples that go into a ``methodCalls`` list, and parse the corresponding -``methodResponses`` entries. - -Method shapes follow RFC 8620 §3.3 (get), §3.4 (changes), §3.5 (set), -§3.6 (query), §3.7 (queryChanges); CalendarEvent-specific properties are -defined in the JMAP Calendars specification. -""" - -from __future__ import annotations - -from caldav.jmap._methods import parse_set_response - - -def build_event_get( - account_id: str, - ids: list[str] | None = None, - properties: list[str] | None = None, -) -> tuple: - """Build a ``CalendarEvent/get`` method call tuple. - - Args: - account_id: The JMAP accountId to query. - ids: List of event IDs to fetch, or ``None`` to fetch all. - properties: List of property names to return, or ``None`` for all. - - Returns: - A 3-tuple ``("CalendarEvent/get", arguments_dict, call_id)`` suitable - for inclusion in a ``methodCalls`` list. - """ - args: dict = {"accountId": account_id, "ids": ids} - if properties is not None: - args["properties"] = properties - return ("CalendarEvent/get", args, "ev-get-0") - - -def parse_event_get(response_args: dict) -> list[dict]: - """Parse the arguments dict from a ``CalendarEvent/get`` method response. - - Args: - response_args: The second element of a ``methodResponses`` entry - whose method name is ``"CalendarEvent/get"``. - - Returns: - List of raw JSCalendar dicts as returned by the server. - Returns an empty list if ``"list"`` is absent or empty. - """ - return list(response_args.get("list", [])) - - -def build_event_changes( - account_id: str, - since_state: str, - max_changes: int | None = None, -) -> tuple: - """Build a ``CalendarEvent/changes`` method call tuple. - - Args: - account_id: The JMAP accountId to query. - since_state: The ``state`` string from a previous ``CalendarEvent/get`` - or ``CalendarEvent/changes`` response. - max_changes: Optional upper bound on the number of changes returned. - The server may return fewer. - - Returns: - A 3-tuple ``("CalendarEvent/changes", arguments_dict, call_id)``. - """ - args: dict = {"accountId": account_id, "sinceState": since_state} - if max_changes is not None: - args["maxChanges"] = max_changes - return ("CalendarEvent/changes", args, "ev-changes-0") - - -def parse_event_changes( - response_args: dict, -) -> tuple[str, str, bool, list[str], list[str], list[str]]: - """Parse the arguments dict from a ``CalendarEvent/changes`` response. - - Args: - response_args: The second element of a ``methodResponses`` entry - whose method name is ``"CalendarEvent/changes"``. - - Returns: - A 6-tuple ``(old_state, new_state, has_more_changes, created, updated, destroyed)``: - - - ``old_state``: Echo of the ``sinceState`` argument. - - ``new_state``: State string to store as the next sync token. - - ``has_more_changes``: True if the server capped the response. - - ``created``: IDs of newly created events. - - ``updated``: IDs of modified events. - - ``destroyed``: IDs of deleted events. - """ - return ( - response_args.get("oldState", ""), - response_args.get("newState", ""), - response_args.get("hasMoreChanges", False), - response_args.get("created") or [], - response_args.get("updated") or [], - response_args.get("destroyed") or [], - ) - - -def build_event_query( - account_id: str, - filter_condition: dict | None = None, - sort: list[dict] | None = None, - position: int = 0, - limit: int | None = None, -) -> tuple: - """Build a ``CalendarEvent/query`` method call tuple. - - Args: - account_id: The JMAP accountId to query. - filter_condition: A ``FilterCondition`` or ``FilterOperator`` dict, e.g. - ``{"after": "2024-01-01T00:00:00Z", "before": "2024-12-31T23:59:59Z"}``. - ``None`` means no filter (return all events). - sort: List of ``Comparator`` dicts, e.g. - ``[{"property": "start", "isAscending": True}]``. - ``None`` means server default ordering. - position: Zero-based index of the first result to return. - limit: Maximum number of IDs to return. ``None`` means no limit. - - Returns: - A 3-tuple ``("CalendarEvent/query", arguments_dict, call_id)``. - """ - args: dict = {"accountId": account_id, "position": position} - if filter_condition is not None: - args["filter"] = filter_condition - if sort is not None: - args["sort"] = sort - if limit is not None: - args["limit"] = limit - return ("CalendarEvent/query", args, "ev-query-0") - - -def parse_event_query(response_args: dict) -> tuple[list[str], str, int]: - """Parse the arguments dict from a ``CalendarEvent/query`` response. - - Args: - response_args: The second element of a ``methodResponses`` entry - whose method name is ``"CalendarEvent/query"``. - - Returns: - A 3-tuple ``(ids, query_state, total)``: - - - ``ids``: Ordered list of matching event IDs. - - ``query_state``: Opaque state string for use with - ``CalendarEvent/queryChanges``. - - ``total``: Total number of matching events (may exceed ``len(ids)`` - when a limit was applied). - """ - ids: list[str] = response_args.get("ids", []) - query_state: str = response_args.get("queryState", "") - total: int = response_args.get("total", len(ids)) - return ids, query_state, total - - -def build_event_query_changes( - account_id: str, - since_query_state: str, - filter_condition: dict | None = None, - sort: list[dict] | None = None, - max_changes: int | None = None, -) -> tuple: - """Build a ``CalendarEvent/queryChanges`` method call tuple. - - Args: - account_id: The JMAP accountId to query. - since_query_state: The ``queryState`` string from a previous - ``CalendarEvent/query`` or ``CalendarEvent/queryChanges`` response. - filter_condition: Same filter as the original ``CalendarEvent/query`` call. - sort: Same sort as the original ``CalendarEvent/query`` call. - max_changes: Optional upper bound on the number of changes returned. - - Returns: - A 3-tuple ``("CalendarEvent/queryChanges", arguments_dict, call_id)``. - """ - args: dict = {"accountId": account_id, "sinceQueryState": since_query_state} - if filter_condition is not None: - args["filter"] = filter_condition - if sort is not None: - args["sort"] = sort - if max_changes is not None: - args["maxChanges"] = max_changes - return ("CalendarEvent/queryChanges", args, "ev-qchanges-0") - - -def build_event_set_create( - account_id: str, - events: dict[str, dict], -) -> tuple: - """Build a ``CalendarEvent/set`` method call for creating events. - - Args: - account_id: The JMAP accountId. - events: Map of client-assigned creation ID → JSCalendar dict. - The creation IDs are ephemeral — they are used to correlate - server responses with individual creation requests within the - same batch call. - - Returns: - A 3-tuple ``("CalendarEvent/set", arguments_dict, call_id)``. - """ - return ( - "CalendarEvent/set", - { - "accountId": account_id, - "create": dict(events), - }, - "ev-set-create-0", - ) - - -def build_event_set_update( - account_id: str, - updates: dict[str, dict], -) -> tuple: - """Build a ``CalendarEvent/set`` method call for updating events. - - Args: - account_id: The JMAP accountId. - updates: Map of event ID → partial patch dict. Keys are property - names (or JSON Pointer paths for nested properties); values are - the new values. Use ``None`` as a value to reset a property to - its server default. - - Returns: - A 3-tuple ``("CalendarEvent/set", arguments_dict, call_id)``. - """ - return ( - "CalendarEvent/set", - {"accountId": account_id, "update": updates}, - "ev-set-update-0", - ) - - -def build_event_set_destroy( - account_id: str, - ids: list[str], -) -> tuple: - """Build a ``CalendarEvent/set`` method call for destroying events. - - Args: - account_id: The JMAP accountId. - ids: List of event IDs to destroy. - - Returns: - A 3-tuple ``("CalendarEvent/set", arguments_dict, call_id)``. - """ - return ( - "CalendarEvent/set", - {"accountId": account_id, "destroy": ids}, - "ev-set-destroy-0", - ) - - -def parse_event_set( - response_args: dict, -) -> tuple[dict, dict, list[str], dict, dict, dict]: - """Parse the arguments dict from a ``CalendarEvent/set`` method response. - - Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``. - See :func:`caldav.jmap._methods.parse_set_response` for field semantics. - """ - return parse_set_response(response_args) diff --git a/caldav/jmap/_methods/task.py b/caldav/jmap/_methods/task.py deleted file mode 100644 index 619a1d78..00000000 --- a/caldav/jmap/_methods/task.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -JMAP Task and TaskList method builders and response parsers. - -These are pure functions — no HTTP, no state. They build the request -tuples that go into a ``methodCalls`` list, and parse the corresponding -``methodResponses`` entries. - -Method shapes follow RFC 8620 §3.3 (get), §3.5 (set); Task-specific -properties are defined in draft-ietf-jmap-tasks (built on RFC 8984). -""" - -from __future__ import annotations - -from caldav.jmap._methods import parse_set_response - - -def build_task_list_get( - account_id: str, - ids: list[str] | None = None, - properties: list[str] | None = None, -) -> tuple: - """Build a ``TaskList/get`` method call tuple. - - Args: - account_id: The JMAP accountId to query. - ids: List of task list IDs to fetch, or ``None`` to fetch all. - properties: List of property names to return, or ``None`` for all. - - Returns: - A 3-tuple ``("TaskList/get", arguments_dict, call_id)`` suitable - for inclusion in a ``methodCalls`` list. - """ - args: dict = {"accountId": account_id, "ids": ids} - if properties is not None: - args["properties"] = properties - return ("TaskList/get", args, "tasklist-get-0") - - -def parse_task_list_get(response_args: dict) -> list[dict]: - """Parse the arguments dict from a ``TaskList/get`` method response. - - Args: - response_args: The second element of a ``methodResponses`` entry - whose method name is ``"TaskList/get"``. - - Returns: - List of raw JMAP TaskList dicts as returned by the server. - Returns an empty list if ``"list"`` is absent or empty. - """ - return list(response_args.get("list", [])) - - -def build_task_get( - account_id: str, - ids: list[str] | None = None, - properties: list[str] | None = None, -) -> tuple: - """Build a ``Task/get`` method call tuple. - - Args: - account_id: The JMAP accountId to query. - ids: List of task IDs to fetch, or ``None`` to fetch all. - properties: List of property names to return, or ``None`` for all. - - Returns: - A 3-tuple ``("Task/get", arguments_dict, call_id)``. - """ - args: dict = {"accountId": account_id, "ids": ids} - if properties is not None: - args["properties"] = properties - return ("Task/get", args, "task-get-0") - - -def parse_task_get(response_args: dict) -> list[dict]: - """Parse the arguments dict from a ``Task/get`` method response. - - Args: - response_args: The second element of a ``methodResponses`` entry - whose method name is ``"Task/get"``. - - Returns: - List of raw JMAP Task dicts as returned by the server. - Returns an empty list if ``"list"`` is absent or empty. - """ - return list(response_args.get("list", [])) - - -def build_task_set_create( - account_id: str, - tasks: dict[str, dict], -) -> tuple: - """Build a ``Task/set`` method call for creating tasks. - - Args: - account_id: The JMAP accountId. - tasks: Map of client-assigned creation ID → JMAP Task dict. - - Returns: - A 3-tuple ``("Task/set", arguments_dict, call_id)``. - """ - return ( - "Task/set", - { - "accountId": account_id, - "create": dict(tasks), - }, - "task-set-create-0", - ) - - -def build_task_set_update( - account_id: str, - updates: dict[str, dict], -) -> tuple: - """Build a ``Task/set`` method call for updating tasks. - - Args: - account_id: The JMAP accountId. - updates: Map of task ID → partial patch dict. - - Returns: - A 3-tuple ``("Task/set", arguments_dict, call_id)``. - """ - return ( - "Task/set", - {"accountId": account_id, "update": updates}, - "task-set-update-0", - ) - - -def build_task_set_destroy( - account_id: str, - ids: list[str], -) -> tuple: - """Build a ``Task/set`` method call for destroying tasks. - - Args: - account_id: The JMAP accountId. - ids: List of task IDs to destroy. - - Returns: - A 3-tuple ``("Task/set", arguments_dict, call_id)``. - """ - return ( - "Task/set", - {"accountId": account_id, "destroy": ids}, - "task-set-destroy-0", - ) - - -def parse_task_set( - response_args: dict, -) -> tuple[dict, dict, list[str], dict, dict, dict]: - """Parse the arguments dict from a ``Task/set`` method response. - - Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``. - See :func:`caldav.jmap._methods.parse_set_response` for field semantics. - """ - return parse_set_response(response_args) diff --git a/caldav/jmap/async_client.py b/caldav/jmap/async_client.py deleted file mode 100644 index 084e6b40..00000000 --- a/caldav/jmap/async_client.py +++ /dev/null @@ -1,464 +0,0 @@ -""" -Asynchronous JMAP client. - -Mirrors JMAPClient with all public methods as coroutines. -Uses niquests' AsyncSession for HTTP - the one part of caldav with no -fallback to another HTTP library, see caldav.lib.http_sync. - -All response-parsing logic lives in _JMAPClientBase (client.py); each method -here is a ~3-line async wrapper: get session, send request, delegate to parser. -""" - -from __future__ import annotations - -import logging -import uuid -import warnings - -from caldav.lib.http_sync import require_async_session - -## The async JMAP client is built on niquests' AsyncSession and, unlike the -## CalDAV clients, has no httpx fallback - so this raises if niquests is absent. -AsyncSession = require_async_session() - -from caldav.jmap._methods.calendar import build_calendar_get -from caldav.jmap._methods.event import ( - build_event_changes, - build_event_get, - build_event_set_create, - build_event_set_destroy, - build_event_set_update, -) -from caldav.jmap._methods.task import ( - build_task_get, - build_task_list_get, - build_task_set_create, - build_task_set_destroy, - build_task_set_update, -) -from caldav.jmap.client import _DEFAULT_USING, _TASK_USING, _JMAPClientBase -from caldav.jmap.convert import ical_to_jscal -from caldav.jmap.error import JMAPAuthError, JMAPMethodError -from caldav.jmap.objects.calendar import JMAPCalendar -from caldav.jmap.objects.calendar_object import JMAPCalendarObject -from caldav.jmap.session import Session, async_fetch_session - -log = logging.getLogger("caldav.jmap") - - -class AsyncJMAPClient(_JMAPClientBase): - """Asynchronous JMAP client for calendar operations. - - **The JMAP support is experimental, the API may change in minor-releases** - - Usage:: - - from caldav.jmap import get_async_jmap_client - async with get_async_jmap_client(url="https://jmap.example.com/.well-known/jmap", - username="alice", password="secret") as client: - calendars = await client.get_calendars() - - Args: - url: URL of the JMAP session endpoint (``/.well-known/jmap``). - username: Username for Basic auth. - password: Password for Basic auth, or bearer token if no username. - auth: A pre-built niquests-compatible auth object. Takes precedence - over username/password if provided. - auth_type: Force a specific auth type: ``"basic"`` or ``"bearer"``. - timeout: HTTP request timeout in seconds. - """ - - def _get_http_session(self) -> AsyncSession: - """Return the persistent async HTTP session, creating it on first call.""" - if self._http_session is None: - sess = AsyncSession() - sess.auth = self._auth - sess.headers.update({"Content-Type": "application/json", "Accept": "application/json"}) - self._http_session = sess - return self._http_session - - async def aclose(self) -> None: - """Release the persistent HTTP session and its connection pool. - - Only needed when the client was not used as an async context manager - -- the documented Quick Start builds one directly. Idempotent; the - session is recreated on the next request. - """ - if self._http_session is not None: - await self._http_session.close() - self._http_session = None - - async def __aenter__(self) -> AsyncJMAPClient: - self._get_http_session() - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: - await self.aclose() - - def __del__(self) -> None: - ## Closing an async session needs an event loop, which is long gone by the - ## time __del__ runs, so all we can do is say so. getattr() rather than - ## attribute access: __init__ may have raised before setting it, and an - ## AttributeError here would be reported as "exception ignored in __del__" - ## on top of whatever actually went wrong. - if getattr(self, "_http_session", None) is None: - return - try: - warnings.warn( - f"{type(self).__name__} was garbage collected with an open HTTP " - "session; use 'async with' or await aclose()", - ResourceWarning, - ## stacklevel=1 on purpose, and explicitly because ruff's B028 - ## wants it stated: pointing any higher is a lie in __del__, where - ## the caller is the garbage collector. source= is what makes the - ## warning actionable instead - with `python -X tracemalloc` it - ## reports where the leaked client was allocated. - stacklevel=1, - source=self, - ) - except Exception: - ## __del__ must not raise. At interpreter shutdown the warnings - ## machinery may already be torn down and stderr may be closed, and - ## there is neither anything to recover nor anywhere to report it. - pass - - async def _get_session(self) -> Session: - """Return the cached Session, fetching it on first call.""" - if self._session_cache is None: - self._session_cache = await async_fetch_session( - self.url, auth=self._auth, timeout=self.timeout - ) - return self._session_cache - - async def _request(self, method_calls: list[tuple], using: list[str] | None = None) -> list: - """POST a batch of JMAP method calls and return the methodResponses. - - Args: - method_calls: List of 3-tuples ``(method_name, args_dict, call_id)``. - using: Capability URN list for the ``using`` field. Defaults to - ``_DEFAULT_USING`` (core + calendars). - - Returns: - List of 3-tuples ``(method_name, response_args, call_id)`` from - the server's ``methodResponses`` array. - - Raises: - JMAPAuthError: On HTTP 401 or 403. - JMAPMethodError: If any methodResponse is an ``error`` response. - """ - session = await self._get_session() - - payload = { - "using": using if using is not None else _DEFAULT_USING, - "methodCalls": list(method_calls), - } - - log.debug("JMAP POST to %s: %d method call(s)", session.api_url, len(method_calls)) - - response = await self._get_http_session().post( - session.api_url, - json=payload, - timeout=self.timeout, - ) - - if response.status_code in (401, 403): - raise JMAPAuthError( - url=session.api_url, - reason=f"HTTP {response.status_code} from API endpoint", - ) - - response.raise_for_status() - - data = response.json() - method_responses = data.get("methodResponses", []) - - for resp in method_responses: - method_name, resp_args, call_id = resp - if method_name == "error": - error_type = resp_args.get("type", "serverError") - raise JMAPMethodError( - url=session.api_url, - reason=f"Method call failed: {resp_args}", - error_type=error_type, - ) - - return method_responses - - async def get_calendars(self) -> list[JMAPCalendar]: - """Fetch all calendars for the authenticated account. - - Returns: - List of :class:`~caldav.jmap.objects.calendar.JMAPCalendar` objects. - """ - session = await self._get_session() - responses = await self._request([build_calendar_get(session.account_id)]) - return self._parse_get_calendars(responses, self, True) - - async def create_event(self, calendar_id: str, ical_str: str) -> str: - """Create a calendar event from an iCalendar string. - - Args: - calendar_id: The JMAP calendar ID to create the event in. - ical_str: A VCALENDAR string representing the event. - - Returns: - The server-assigned JMAP event ID. - - Raises: - JMAPMethodError: If the server rejects the create request. - """ - session = await self._get_session() - jscal = ical_to_jscal(ical_str, calendar_id=calendar_id) - call = build_event_set_create(session.account_id, {"new-0": jscal}) - responses = await self._request([call]) - return self._parse_create_event_response(responses, session.api_url) - - async def get_event(self, event_id: str) -> JMAPCalendarObject: - """Fetch a calendar event as an iCalendar string. - - Args: - event_id: The JMAP event ID to retrieve. - - Returns: - A :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject` - wrapping the raw JSCalendar dict. ``parent`` is ``None`` since - no :class:`~caldav.jmap.objects.calendar.JMAPCalendar` is available - at the client level. - - Raises: - JMAPMethodError: If the event is not found. - """ - session = await self._get_session() - responses = await self._request([build_event_get(session.account_id, ids=[event_id])]) - return self._parse_get_event_response(responses, session.api_url, event_id) - - async def update_event(self, event_id: str, ical_str: str) -> None: - """Update a calendar event from an iCalendar string. - - Args: - event_id: The JMAP event ID to update. - ical_str: A VCALENDAR string with the updated event data. - - Raises: - JMAPMethodError: If the server rejects the update. - """ - session = await self._get_session() - patch, nulled = self._build_event_update_patch(ical_str) - while True: - responses = await self._request( - [build_event_set_update(session.account_id, {event_id: patch})] - ) - drop = self._unsupported_null_keys(responses, event_id, patch, nulled) - if not drop: - break - for key in drop: - patch.pop(key, None) - self._parse_update_event_response(responses, session.api_url, event_id) - - async def _search( - self, - calendar_id: str | None = None, - start: str | None = None, - end: str | None = None, - text: str | None = None, - parent: JMAPCalendar | None = None, - ) -> list[JMAPCalendarObject]: - session = await self._get_session() - calls = self._build_event_search_calls(session.account_id, calendar_id, start, end, text) - responses = await self._request(calls) - return self._parse_search_response(responses, parent) - - async def search_events( - self, - calendar_id: str | None = None, - start: str | None = None, - end: str | None = None, - text: str | None = None, - ) -> list[JMAPCalendarObject]: - """Search for calendar events. - - All parameters are optional; omitting all returns every event in the account. - Results are fetched in a single batched JMAP request using a result reference - from ``CalendarEvent/query`` into ``CalendarEvent/get``. - - Args: - calendar_id: Limit results to this calendar. - start: Only events ending after this datetime (``YYYY-MM-DDTHH:MM:SS``). - end: Only events starting before this datetime (``YYYY-MM-DDTHH:MM:SS``). - text: Free-text search across title, description, locations, and participants. - - Returns: - List of :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject` - instances. ``parent`` is ``None`` on these objects; use - :meth:`JMAPCalendar.search` if you need ``parent`` set. - """ - return await self._search(calendar_id=calendar_id, start=start, end=end, text=text) - - async def get_sync_token(self) -> str: - """Return the current CalendarEvent state string for use as a sync token. - - Calls ``CalendarEvent/get`` with an empty ID list — no event data is - transferred, only the ``state`` field from the response. - - Returns: - Opaque state string. Pass to :meth:`get_objects_by_sync_token` to - retrieve only what changed since this point. - """ - session = await self._get_session() - responses = await self._request([build_event_get(session.account_id, ids=[])]) - return self._parse_get_sync_token_response(responses, session.api_url) - - async def get_objects_by_sync_token( - self, sync_token: str - ) -> tuple[list[JMAPCalendarObject], list[JMAPCalendarObject], list[str], str]: - """Fetch events changed since a previous sync token. - - Calls ``CalendarEvent/changes`` to discover which events were created, - modified, or destroyed since ``sync_token`` was issued. Created and - modified events are returned as - :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject` instances; - destroyed events are returned as IDs (the objects no longer exist on the server). - - Args: - sync_token: A state string previously returned by :meth:`get_sync_token` - or by a prior call to this method. - - Returns: - A 4-tuple ``(added, modified, deleted, new_sync_token)``: - - - ``added``: objects for newly created events (``parent`` is ``None``). - - ``modified``: objects for updated events (``parent`` is ``None``). - - ``deleted``: Event IDs that were destroyed. - - ``new_sync_token``: Pass to the next call to this method as ``sync_token``. - - Raises: - JMAPMethodError: If the server reports ``hasMoreChanges: true``. - """ - session = await self._get_session() - responses = await self._request([build_event_changes(session.account_id, sync_token)]) - created_ids, updated_ids, destroyed, new_sync_token = self._parse_event_changes_response( - responses, session.api_url - ) - fetch_ids = created_ids + updated_ids - if not fetch_ids: - return [], [], destroyed, new_sync_token - get_responses = await self._request([build_event_get(session.account_id, ids=fetch_ids)]) - return self._assemble_sync_token_result( - get_responses, created_ids, updated_ids, destroyed, new_sync_token - ) - - async def delete_event(self, event_id: str) -> None: - """Delete a calendar event. - - Args: - event_id: The JMAP event ID to delete. - - Raises: - JMAPMethodError: If the server rejects the delete. - """ - session = await self._get_session() - responses = await self._request([build_event_set_destroy(session.account_id, [event_id])]) - self._parse_delete_event_response(responses, session.api_url, event_id) - - async def _get_object_by_uid( - self, uid: str, calendar_id: str | None = None, parent: JMAPCalendar | None = None - ) -> JMAPCalendarObject: - # RFC 8984 FilterCondition has no uid field; UID matching is done client-side. - for obj in await self._search(calendar_id=calendar_id, parent=parent): - if obj.data.get("uid") == uid: - return obj - session = await self._get_session() - raise JMAPMethodError( - url=session.api_url, reason=f"No calendar object found with UID: {uid}" - ) - - async def get_task_lists(self) -> list[dict]: - """Fetch all task lists for the authenticated account. - - Returns: - List of raw JMAP TaskList dicts as returned by the server. - """ - session = await self._get_session() - responses = await self._request( - [build_task_list_get(session.account_id)], using=_TASK_USING - ) - return self._parse_get_task_lists_response(responses) - - async def create_task(self, task_list_id: str, title: str, **kwargs) -> str: - """Create a task in a task list. - - Args: - task_list_id: The JMAP task list ID to create the task in. - title: Task title (maps to VTODO ``SUMMARY``). - **kwargs: Optional JMAP Task fields using wire names: ``description``, - ``due``, ``start``, ``timeZone``, ``estimatedDuration``, - ``percentComplete``, ``progress``, ``priority``. - - Returns: - The server-assigned JMAP task ID. - - Raises: - JMAPMethodError: If the server rejects the create request. - """ - session = await self._get_session() - task_dict = { - "@type": "Task", - "uid": str(uuid.uuid4()), - "taskListId": task_list_id, - "title": title, - "percentComplete": 0, - "progress": "needs-action", - "priority": 0, - } - task_dict.update(kwargs) - call = build_task_set_create(session.account_id, {"new-0": task_dict}) - responses = await self._request([call], using=_TASK_USING) - return self._parse_create_task_response(responses, session.api_url) - - async def get_task(self, task_id: str) -> dict: - """Fetch a task by ID. - - Args: - task_id: The JMAP task ID to retrieve. - - Returns: - Raw JMAP Task dict as returned by the server. - - Raises: - JMAPMethodError: If the task is not found. - """ - session = await self._get_session() - responses = await self._request( - [build_task_get(session.account_id, ids=[task_id])], using=_TASK_USING - ) - return self._parse_get_task_response(responses, session.api_url, task_id) - - async def update_task(self, task_id: str, patch: dict) -> None: - """Update a task with a partial patch. - - Args: - task_id: The JMAP task ID to update. - patch: Partial patch dict mapping property names to new values. - - Raises: - JMAPMethodError: If the server rejects the update. - """ - session = await self._get_session() - call = build_task_set_update(session.account_id, {task_id: patch}) - responses = await self._request([call], using=_TASK_USING) - self._parse_update_task_response(responses, session.api_url, task_id) - - async def delete_task(self, task_id: str) -> None: - """Delete a task. - - Args: - task_id: The JMAP task ID to delete. - - Raises: - JMAPMethodError: If the server rejects the delete. - """ - session = await self._get_session() - responses = await self._request( - [build_task_set_destroy(session.account_id, [task_id])], using=_TASK_USING - ) - self._parse_delete_task_response(responses, session.api_url, task_id) diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py deleted file mode 100644 index d30f794c..00000000 --- a/caldav/jmap/client.py +++ /dev/null @@ -1,804 +0,0 @@ -""" -Synchronous JMAP client. - -Wraps session establishment, HTTP communication, and method dispatching -into a single object with a clean public API. - -Auth note: JMAP has no 401-challenge-retry dance (unlike CalDAV). -Credentials are sent upfront on every request. A 401/403 is a hard failure. -""" - -from __future__ import annotations - -import logging -import uuid - -from caldav.jmap._methods.calendar import build_calendar_get, parse_calendar_get -from caldav.jmap._methods.event import ( - build_event_changes, - build_event_get, - build_event_query, - build_event_set_create, - build_event_set_destroy, - build_event_set_update, - parse_event_changes, - parse_event_get, - parse_event_set, -) -from caldav.jmap._methods.task import ( - build_task_get, - build_task_list_get, - build_task_set_create, - build_task_set_destroy, - build_task_set_update, - parse_task_list_get, - parse_task_set, -) -from caldav.jmap.constants import CALENDAR_CAPABILITY, CORE_CAPABILITY, TASK_CAPABILITY -from caldav.jmap.convert import ical_to_jscal -from caldav.jmap.convert._patch import _NULL_FOR_UPDATE -from caldav.jmap.error import JMAPAuthError, JMAPMethodError -from caldav.jmap.objects.calendar import JMAPCalendar -from caldav.jmap.objects.calendar_object import JMAPCalendarObject -from caldav.jmap.session import Session, fetch_session -from caldav.lib.http_sync import HTTPBasicAuth, requests -from caldav.requests import HTTPBearerAuth - -log = logging.getLogger("caldav.jmap") - -_DEFAULT_USING = [CORE_CAPABILITY, CALENDAR_CAPABILITY] -_TASK_USING = [CORE_CAPABILITY, TASK_CAPABILITY] - - -class _JMAPClientBase: - def __init__( - self, - url: str, - username: str | None = None, - password: str | None = None, - auth=None, - auth_type: str | None = None, - timeout: int = 30, - ) -> None: - self.url = url - self.username = username - self.password = password - self.timeout = timeout - self._session_cache: Session | None = None - self._http_session = None - - if auth is not None: - self._auth = auth - else: - self._auth = self._build_auth(auth_type) - - def _build_auth(self, auth_type: str | None): - """Select and construct the auth object. - - **The JMAP support is experimental, the API may change in minor-releases** - - JMAP supports Basic and Bearer auth; Digest is not supported. - When ``auth_type`` is ``None`` the type is inferred from the - credentials supplied: a username triggers Basic, a password - alone triggers Bearer, and neither raises :class:`JMAPAuthError`. - """ - effective_type = auth_type - if effective_type is None: - if self.username: - effective_type = "basic" - elif self.password: - effective_type = "bearer" - else: - raise JMAPAuthError( - url=self.url, - reason="No credentials provided. Supply username+password or a bearer token.", - ) - - if effective_type == "basic": - if not self.username or not self.password: - raise JMAPAuthError( - url=self.url, - reason="Basic auth requires both username and password.", - ) - return HTTPBasicAuth(self.username, self.password) - elif effective_type == "bearer": - if not self.password: - raise JMAPAuthError( - url=self.url, - reason="Bearer auth requires a token supplied as the password argument.", - ) - return HTTPBearerAuth(self.password) - else: - raise JMAPAuthError( - url=self.url, - reason=f"Unsupported auth_type {effective_type!r}. Use 'basic' or 'bearer'.", - ) - - @staticmethod - def _raise_set_error(api_url: str, err: dict) -> None: - raise JMAPMethodError( - url=api_url, - reason=f"set failed: {err}", - error_type=err.get("type", "serverError"), - ) - - @staticmethod - def _build_event_search_calls( - account_id: str, - calendar_id: str | None, - start: str | None, - end: str | None, - text: str | None, - ) -> list[tuple]: - """Return a batched [CalendarEvent/query, CalendarEvent/get] call list for _search.""" - filter_dict: dict = {} - if calendar_id is not None: - filter_dict["inCalendars"] = [calendar_id] - if start is not None: - filter_dict["after"] = start - if end is not None: - filter_dict["before"] = end - if text is not None: - filter_dict["text"] = text - query_call = build_event_query(account_id, filter_condition=filter_dict or None) - get_call = ( - "CalendarEvent/get", - { - "accountId": account_id, - "#ids": { - "resultOf": "ev-query-0", - "name": "CalendarEvent/query", - "path": "/ids", - }, - }, - "ev-get-1", - ) - return [query_call, get_call] - - @staticmethod - def _build_event_update_patch(ical_str: str) -> tuple[dict, frozenset[str]]: - """Build a JSCalendar PatchObject for a ``CalendarEvent/set`` update. - - RFC 8620 merge semantics preserve properties absent from the patch, so - any optional property removed client-side must be explicitly nulled to - actually clear it server-side. Returns the patch together with the set - of keys that were null-injected purely for this cleanup (i.e. were not - present in the converted iCalendar) so the caller can drop them if the - server refuses to null a property it does not support. - """ - patch = ical_to_jscal(ical_str) - patch.pop("uid", None) # uid is server-immutable after creation; patch must omit it - nulled: set[str] = set() - for key in _NULL_FOR_UPDATE: - if key not in patch: - patch[key] = None - nulled.add(key) - return patch, frozenset(nulled) - - @staticmethod - def _unsupported_null_keys( - responses: list, event_id: str, patch: dict, nulled: frozenset[str] - ) -> set[str] | None: - """Detect an update that failed *only* because the server rejects - null-clearing of properties it does not support. - - Some servers (e.g. Stalwart for ``recurrenceRules``) reject a property - outright in ``CalendarEvent/set``, even when it is being set to ``null``. - Nulling such a property is harmless cleanup — it was absent from the new - iCalendar — so we report it as droppable, letting the caller retry the - update without it. - - Returns the set of droppable keys when the failure is exactly this case, - or ``None`` when the update succeeded or failed for a genuine reason (in - which case the caller proceeds to :meth:`_parse_update_event_response`, - which raises the real error). Some servers report only one offending - property per response, so the caller retries in a loop, dropping the - reported keys until the update succeeds or hits a genuine error; each - returned key is guaranteed still present in ``patch``, so the loop - strictly shrinks the patch and terminates. - """ - for method_name, resp_args, _ in responses: - if method_name == "CalendarEvent/set": - _, _, _, _, not_updated, _ = parse_event_set(resp_args) - err = not_updated.get(event_id) - if not err or err.get("type") != "invalidProperties": - return None - props = set(err.get("properties") or []) - droppable = {p for p in props if p in nulled and p in patch and patch[p] is None} - # Only retry when every offending property is null-cleanup we can - # safely omit; if the client actually set one of them to a value, - # the rejection is genuine and must surface. - if props and props == droppable: - return droppable - return None - return None - - # --------------------------------------------------------------------------- - # Shared response parsers — pure synchronous; used by both sync and async - # clients. Each method takes the raw ``methodResponses`` list returned by - # ``_request()`` plus whatever extra context is needed to build the result - # or raise an informative error, and returns/raises exactly what the public - # method should return/raise. - # --------------------------------------------------------------------------- - - @staticmethod - def _parse_get_calendars(responses: list, client, is_async: bool) -> list[JMAPCalendar]: - for method_name, resp_args, _ in responses: - if method_name == "Calendar/get": - calendars = parse_calendar_get(resp_args) - for cal in calendars: - cal._client = client - cal._is_async = is_async - return calendars - return [] - - @staticmethod - def _parse_create_event_response(responses: list, api_url: str) -> str: - for method_name, resp_args, _ in responses: - if method_name == "CalendarEvent/set": - created, _, _, not_created, _, _ = parse_event_set(resp_args) - if "new-0" in not_created: - _JMAPClientBase._raise_set_error(api_url, not_created["new-0"]) - if "new-0" not in created: - raise JMAPMethodError( - url=api_url, - reason="CalendarEvent/set response missing created entry for new-0", - ) - return created["new-0"]["id"] - raise JMAPMethodError(url=api_url, reason="No CalendarEvent/set response") - - @staticmethod - def _parse_get_event_response( - responses: list, api_url: str, event_id: str - ) -> JMAPCalendarObject: - for method_name, resp_args, _ in responses: - if method_name == "CalendarEvent/get": - items = parse_event_get(resp_args) - if not items: - raise JMAPMethodError( - url=api_url, - reason=f"Event not found: {event_id}", - error_type="notFound", - ) - return JMAPCalendarObject(data=items[0], parent=None) - raise JMAPMethodError(url=api_url, reason="No CalendarEvent/get response") - - @staticmethod - def _parse_update_event_response(responses: list, api_url: str, event_id: str) -> None: - for method_name, resp_args, _ in responses: - if method_name == "CalendarEvent/set": - _, _, _, _, not_updated, _ = parse_event_set(resp_args) - if event_id in not_updated: - _JMAPClientBase._raise_set_error(api_url, not_updated[event_id]) - return - raise JMAPMethodError(url=api_url, reason="No CalendarEvent/set response") - - @staticmethod - def _parse_search_response( - responses: list, parent: JMAPCalendar | None - ) -> list[JMAPCalendarObject]: - for method_name, resp_args, _ in responses: - if method_name == "CalendarEvent/get": - return [ - JMAPCalendarObject(data=item, parent=parent) - for item in parse_event_get(resp_args) - ] - return [] - - @staticmethod - def _parse_get_sync_token_response(responses: list, api_url: str) -> str: - for method_name, resp_args, _ in responses: - if method_name == "CalendarEvent/get": - return resp_args.get("state", "") - raise JMAPMethodError(url=api_url, reason="No CalendarEvent/get response") - - @staticmethod - def _parse_event_changes_response( - responses: list, api_url: str - ) -> tuple[list[str], list[str], list[str], str]: - """Parse a CalendarEvent/changes response. - - Returns ``(created_ids, updated_ids, destroyed_ids, new_sync_token)``. - Raises :class:`JMAPMethodError` when the server truncated the result. - """ - created_ids: list[str] = [] - updated_ids: list[str] = [] - destroyed: list[str] = [] - new_sync_token: str = "" - for method_name, resp_args, _ in responses: - if method_name == "CalendarEvent/changes": - _, new_sync_token, has_more, created_ids, updated_ids, destroyed = ( - parse_event_changes(resp_args) - ) - if has_more: - raise JMAPMethodError( - url=api_url, - reason=( - "CalendarEvent/changes response was truncated by the server " - "(hasMoreChanges=true). Call get_sync_token() to obtain a " - "fresh baseline and re-sync." - ), - error_type="serverPartialFail", - ) - return created_ids, updated_ids, destroyed, new_sync_token - - @staticmethod - def _assemble_sync_token_result( - get_responses: list, - created_ids: list[str], - updated_ids: list[str], - destroyed: list[str], - new_sync_token: str, - ) -> tuple[list[JMAPCalendarObject], list[JMAPCalendarObject], list[str], str]: - events_by_id: dict[str, JMAPCalendarObject] = {} - for method_name, resp_args, _ in get_responses: - if method_name == "CalendarEvent/get": - for item in parse_event_get(resp_args): - events_by_id[item["id"]] = JMAPCalendarObject(data=item, parent=None) - added = [events_by_id[i] for i in created_ids if i in events_by_id] - modified = [events_by_id[i] for i in updated_ids if i in events_by_id] - return added, modified, destroyed, new_sync_token - - @staticmethod - def _parse_delete_event_response(responses: list, api_url: str, event_id: str) -> None: - for method_name, resp_args, _ in responses: - if method_name == "CalendarEvent/set": - _, _, _, _, _, not_destroyed = parse_event_set(resp_args) - if event_id in not_destroyed: - _JMAPClientBase._raise_set_error(api_url, not_destroyed[event_id]) - return - raise JMAPMethodError(url=api_url, reason="No CalendarEvent/set response") - - @staticmethod - def _parse_get_task_lists_response(responses: list) -> list[dict]: - for method_name, resp_args, _ in responses: - if method_name == "TaskList/get": - return parse_task_list_get(resp_args) - return [] - - @staticmethod - def _parse_create_task_response(responses: list, api_url: str) -> str: - for method_name, resp_args, _ in responses: - if method_name == "Task/set": - created, _, _, not_created, _, _ = parse_task_set(resp_args) - if "new-0" in not_created: - _JMAPClientBase._raise_set_error(api_url, not_created["new-0"]) - if "new-0" not in created: - raise JMAPMethodError( - url=api_url, - reason="Task/set response missing created entry for new-0", - ) - return created["new-0"]["id"] - raise JMAPMethodError(url=api_url, reason="No Task/set response") - - @staticmethod - def _parse_get_task_response(responses: list, api_url: str, task_id: str) -> dict: - for method_name, resp_args, _ in responses: - if method_name == "Task/get": - items = resp_args.get("list", []) - if not items: - raise JMAPMethodError( - url=api_url, - reason=f"Task not found: {task_id}", - error_type="notFound", - ) - return items[0] - raise JMAPMethodError(url=api_url, reason="No Task/get response") - - @staticmethod - def _parse_update_task_response(responses: list, api_url: str, task_id: str) -> None: - for method_name, resp_args, _ in responses: - if method_name == "Task/set": - _, _, _, _, not_updated, _ = parse_task_set(resp_args) - if task_id in not_updated: - _JMAPClientBase._raise_set_error(api_url, not_updated[task_id]) - return - raise JMAPMethodError(url=api_url, reason="No Task/set response") - - @staticmethod - def _parse_delete_task_response(responses: list, api_url: str, task_id: str) -> None: - for method_name, resp_args, _ in responses: - if method_name == "Task/set": - _, _, _, _, _, not_destroyed = parse_task_set(resp_args) - if task_id in not_destroyed: - _JMAPClientBase._raise_set_error(api_url, not_destroyed[task_id]) - return - raise JMAPMethodError(url=api_url, reason="No Task/set response") - - -class JMAPClient(_JMAPClientBase): - """Synchronous JMAP client for calendar operations. - - Usage:: - - from caldav.jmap import get_jmap_client - client = get_jmap_client(url="https://jmap.example.com/.well-known/jmap", - username="alice", password="secret") - calendars = client.get_calendars() - - Args: - url: URL of the JMAP session endpoint (``/.well-known/jmap``). - username: Username for Basic auth. - password: Password for Basic auth, or bearer token if no username. - auth: A pre-built requests-compatible auth object. Takes precedence - over username/password if provided. - auth_type: Force a specific auth type: ``"basic"`` or ``"bearer"``. - timeout: HTTP request timeout in seconds. - """ - - def _get_http_session(self): - """Return the persistent HTTP session, creating it on first call.""" - if self._http_session is None: - sess = requests.Session() - sess.auth = self._auth - sess.headers.update({"Content-Type": "application/json", "Accept": "application/json"}) - self._http_session = sess - return self._http_session - - def close(self) -> None: - """Release the persistent HTTP session and its connection pool. - - Only needed when the client was not used as a context manager -- the - documented Quick Start builds one directly, and without this there - was no way to hand the sockets back. Idempotent; the session is - recreated on the next request. - """ - if self._http_session is not None: - self._http_session.close() - self._http_session = None - - def __enter__(self) -> JMAPClient: - self._get_http_session() - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - self.close() - - def __del__(self) -> None: - ## Last-resort net for a client that was neither closed nor used as a - ## context manager. Interpreter shutdown can have torn down enough - ## for this to fail, and an exception here is unraisable noise. - try: - self.close() - except Exception: - pass - - def _get_session(self) -> Session: - """Return the cached Session, fetching it on first call.""" - if self._session_cache is None: - self._session_cache = fetch_session(self.url, auth=self._auth, timeout=self.timeout) - return self._session_cache - - def _request(self, method_calls: list[tuple], using: list[str] | None = None) -> list: - """POST a batch of JMAP method calls and return the methodResponses. - - Args: - method_calls: List of 3-tuples ``(method_name, args_dict, call_id)``. - using: Capability URN list for the ``using`` field. Defaults to - ``_DEFAULT_USING`` (core + calendars). - - Returns: - List of 3-tuples ``(method_name, response_args, call_id)`` from - the server's ``methodResponses`` array. - - Raises: - JMAPAuthError: On HTTP 401 or 403. - JMAPMethodError: If any methodResponse is an ``error`` response. - requests.HTTPError: On other non-2xx HTTP responses. - """ - session = self._get_session() - - payload = { - "using": using if using is not None else _DEFAULT_USING, - "methodCalls": list(method_calls), - } - - log.debug("JMAP POST to %s: %d method call(s)", session.api_url, len(method_calls)) - - response = self._get_http_session().post( - session.api_url, - json=payload, - timeout=self.timeout, - ) - - if response.status_code in (401, 403): - raise JMAPAuthError( - url=session.api_url, - reason=f"HTTP {response.status_code} from API endpoint", - ) - - response.raise_for_status() - - data = response.json() - method_responses = data.get("methodResponses", []) - - for resp in method_responses: - method_name, resp_args, call_id = resp - if method_name == "error": - error_type = resp_args.get("type", "serverError") - raise JMAPMethodError( - url=session.api_url, - reason=f"Method call failed: {resp_args}", - error_type=error_type, - ) - - return method_responses - - def get_calendars(self) -> list[JMAPCalendar]: - """Fetch all calendars for the authenticated account. - - Returns: - List of :class:`~caldav.jmap.objects.calendar.JMAPCalendar` objects. - """ - session = self._get_session() - responses = self._request([build_calendar_get(session.account_id)]) - return self._parse_get_calendars(responses, self, False) - - def create_event(self, calendar_id: str, ical_str: str) -> str: - """Create a calendar event from an iCalendar string. - - Args: - calendar_id: The JMAP calendar ID to create the event in. - ical_str: A VCALENDAR string representing the event. - - Returns: - The server-assigned JMAP event ID. - - Raises: - JMAPMethodError: If the server rejects the create request. - """ - session = self._get_session() - jscal = ical_to_jscal(ical_str, calendar_id=calendar_id) - call = build_event_set_create(session.account_id, {"new-0": jscal}) - responses = self._request([call]) - return self._parse_create_event_response(responses, session.api_url) - - def get_event(self, event_id: str) -> JMAPCalendarObject: - """Fetch a calendar event by JMAP event ID. - - Args: - event_id: The JMAP event ID to retrieve. - - Returns: - A :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject` - wrapping the raw JSCalendar dict. ``parent`` is ``None`` since - no :class:`~caldav.jmap.objects.calendar.JMAPCalendar` is available - at the client level. - - Raises: - JMAPMethodError: If the event is not found. - """ - session = self._get_session() - responses = self._request([build_event_get(session.account_id, ids=[event_id])]) - return self._parse_get_event_response(responses, session.api_url, event_id) - - def update_event(self, event_id: str, ical_str: str) -> None: - """Update a calendar event from an iCalendar string. - - Args: - event_id: The JMAP event ID to update. - ical_str: A VCALENDAR string with the updated event data. - - Raises: - JMAPMethodError: If the server rejects the update. - """ - session = self._get_session() - patch, nulled = self._build_event_update_patch(ical_str) - while True: - responses = self._request( - [build_event_set_update(session.account_id, {event_id: patch})] - ) - drop = self._unsupported_null_keys(responses, event_id, patch, nulled) - if not drop: - break - for key in drop: - patch.pop(key, None) - self._parse_update_event_response(responses, session.api_url, event_id) - - def _search( - self, - calendar_id: str | None = None, - start: str | None = None, - end: str | None = None, - text: str | None = None, - parent: JMAPCalendar | None = None, - ) -> list[JMAPCalendarObject]: - session = self._get_session() - calls = self._build_event_search_calls(session.account_id, calendar_id, start, end, text) - responses = self._request(calls) - return self._parse_search_response(responses, parent) - - def search_events( - self, - calendar_id: str | None = None, - start: str | None = None, - end: str | None = None, - text: str | None = None, - ) -> list[JMAPCalendarObject]: - """Search for calendar events. - - All parameters are optional; omitting all returns every event in the account. - Results are fetched in a single batched JMAP request using a result reference - from ``CalendarEvent/query`` into ``CalendarEvent/get``. - - Args: - calendar_id: Limit results to this calendar. - start: Only events ending after this datetime (``YYYY-MM-DDTHH:MM:SS``). - end: Only events starting before this datetime (``YYYY-MM-DDTHH:MM:SS``). - text: Free-text search across title, description, locations, and participants. - - Returns: - List of :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject` - instances. ``parent`` is ``None`` on these objects since no - :class:`~caldav.jmap.objects.calendar.JMAPCalendar` is available at - the client level; use :meth:`JMAPCalendar.search` if you need ``parent`` - set. - """ - return self._search(calendar_id=calendar_id, start=start, end=end, text=text) - - def get_sync_token(self) -> str: - """Return the current CalendarEvent state string for use as a sync token. - - Calls ``CalendarEvent/get`` with an empty ID list — no event data is - transferred, only the ``state`` field from the response. - - Returns: - Opaque state string. Pass to :meth:`get_objects_by_sync_token` to - retrieve only what changed since this point. - """ - session = self._get_session() - responses = self._request([build_event_get(session.account_id, ids=[])]) - return self._parse_get_sync_token_response(responses, session.api_url) - - def get_objects_by_sync_token( - self, sync_token: str - ) -> tuple[list[JMAPCalendarObject], list[JMAPCalendarObject], list[str], str]: - """Fetch events changed since a previous sync token. - - Calls ``CalendarEvent/changes`` to discover which events were created, - modified, or destroyed since ``sync_token`` was issued. Created and - modified events are returned as - :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject` instances; - destroyed events are returned as IDs (the objects no longer exist on the server). - - Args: - sync_token: A state string previously returned by :meth:`get_sync_token` - or by a prior call to this method. - - Returns: - A 4-tuple ``(added, modified, deleted, new_sync_token)``: - - - ``added``: objects for newly created events (``parent`` is ``None``). - - ``modified``: objects for updated events (``parent`` is ``None``). - - ``deleted``: Event IDs that were destroyed. - - ``new_sync_token``: Pass to the next call to this method as ``sync_token``. - - Raises: - JMAPMethodError: If the server reports ``hasMoreChanges: true``. - """ - session = self._get_session() - responses = self._request([build_event_changes(session.account_id, sync_token)]) - created_ids, updated_ids, destroyed, new_sync_token = self._parse_event_changes_response( - responses, session.api_url - ) - fetch_ids = created_ids + updated_ids - if not fetch_ids: - return [], [], destroyed, new_sync_token - get_responses = self._request([build_event_get(session.account_id, ids=fetch_ids)]) - return self._assemble_sync_token_result( - get_responses, created_ids, updated_ids, destroyed, new_sync_token - ) - - def delete_event(self, event_id: str) -> None: - """Delete a calendar event. - - Args: - event_id: The JMAP event ID to delete. - - Raises: - JMAPMethodError: If the server rejects the delete. - """ - session = self._get_session() - responses = self._request([build_event_set_destroy(session.account_id, [event_id])]) - self._parse_delete_event_response(responses, session.api_url, event_id) - - def _get_object_by_uid( - self, uid: str, calendar_id: str | None = None, parent: JMAPCalendar | None = None - ) -> JMAPCalendarObject: - # RFC 8984 FilterCondition has no uid field; UID matching is done client-side. - for obj in self._search(calendar_id=calendar_id, parent=parent): - if obj.data.get("uid") == uid: - return obj - - raise JMAPMethodError( - url=self._get_session().api_url, reason=f"No calendar object found with UID: {uid}" - ) - - def get_task_lists(self) -> list[dict]: - """Fetch all task lists for the authenticated account. - - Returns: - List of raw JMAP TaskList dicts as returned by the server. - """ - session = self._get_session() - responses = self._request([build_task_list_get(session.account_id)], using=_TASK_USING) - return self._parse_get_task_lists_response(responses) - - def create_task(self, task_list_id: str, title: str, **kwargs) -> str: - """Create a task in a task list. - - Args: - task_list_id: The JMAP task list ID to create the task in. - title: Task title (maps to VTODO ``SUMMARY``). - **kwargs: Optional JMAP Task fields using wire names: ``description``, - ``due``, ``start``, ``timeZone``, ``estimatedDuration``, - ``percentComplete``, ``progress``, ``priority``. - - Returns: - The server-assigned JMAP task ID. - - Raises: - JMAPMethodError: If the server rejects the create request. - """ - session = self._get_session() - task_dict = { - "@type": "Task", - "uid": str(uuid.uuid4()), - "taskListId": task_list_id, - "title": title, - "percentComplete": 0, - "progress": "needs-action", - "priority": 0, - } - task_dict.update(kwargs) - call = build_task_set_create(session.account_id, {"new-0": task_dict}) - responses = self._request([call], using=_TASK_USING) - return self._parse_create_task_response(responses, session.api_url) - - def get_task(self, task_id: str) -> dict: - """Fetch a task by ID. - - Args: - task_id: The JMAP task ID to retrieve. - - Returns: - Raw JMAP Task dict as returned by the server. - - Raises: - JMAPMethodError: If the task is not found. - """ - session = self._get_session() - responses = self._request( - [build_task_get(session.account_id, ids=[task_id])], using=_TASK_USING - ) - return self._parse_get_task_response(responses, session.api_url, task_id) - - def update_task(self, task_id: str, patch: dict) -> None: - """Update a task with a partial patch. - - Args: - task_id: The JMAP task ID to update. - patch: Partial patch dict mapping property names to new values. - - Raises: - JMAPMethodError: If the server rejects the update. - """ - session = self._get_session() - call = build_task_set_update(session.account_id, {task_id: patch}) - responses = self._request([call], using=_TASK_USING) - self._parse_update_task_response(responses, session.api_url, task_id) - - def delete_task(self, task_id: str) -> None: - """Delete a task. - - Args: - task_id: The JMAP task ID to delete. - - Raises: - JMAPMethodError: If the server rejects the delete. - """ - session = self._get_session() - responses = self._request( - [build_task_set_destroy(session.account_id, [task_id])], using=_TASK_USING - ) - self._parse_delete_task_response(responses, session.api_url, task_id) diff --git a/caldav/jmap/constants.py b/caldav/jmap/constants.py deleted file mode 100644 index 6b2554a9..00000000 --- a/caldav/jmap/constants.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -JMAP capability URN constants. - -All JMAP capability strings are defined here so they are never duplicated -across the package. Every other module should import from this file. -""" - -#: Core JMAP capability (RFC 8620) — required in every ``using`` declaration. -CORE_CAPABILITY = "urn:ietf:params:jmap:core" - -#: JMAP Calendars capability (JMAP Calendars specification). -CALENDAR_CAPABILITY = "urn:ietf:params:jmap:calendars" - -#: JMAP Tasks capability (JMAP Tasks specification). -TASK_CAPABILITY = "urn:ietf:params:jmap:tasks" diff --git a/caldav/jmap/convert/__init__.py b/caldav/jmap/convert/__init__.py deleted file mode 100644 index 76c231d6..00000000 --- a/caldav/jmap/convert/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -JSCalendar ↔ iCalendar conversion utilities. - -Public API: - ical_to_jscal(ical_str, calendar_id=None) -> dict - jscal_to_ical(jscal) -> str -""" - -from caldav.jmap.convert.ical_to_jscal import ical_to_jscal -from caldav.jmap.convert.jscal_to_ical import jscal_to_ical - -__all__ = ["ical_to_jscal", "jscal_to_ical"] diff --git a/caldav/jmap/convert/_patch.py b/caldav/jmap/convert/_patch.py deleted file mode 100644 index db31a2ab..00000000 --- a/caldav/jmap/convert/_patch.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -RFC 8620 PatchObject helpers for CalendarEvent/set update calls. - -When updating an event, absent keys preserve the server's current value. -To delete an optional property the patch must set it to null explicitly. -""" - -from __future__ import annotations - -# Optional JSCalendar top-level properties that must be explicitly nulled in -# a CalendarEvent/set update when they are absent from the converted result. -# This ensures properties removed client-side (e.g. LOCATION deleted from -# the iCalendar) are actually removed on the server, not silently preserved. -_NULL_FOR_UPDATE: frozenset[str] = frozenset( - { - "description", - "color", - "locations", - "keywords", - "priority", - "privacy", - "freeBusyStatus", - "status", - "sequence", - "showWithoutTime", - "timeZone", - "recurrenceRules", - "excludedRecurrenceRules", - "recurrenceOverrides", - "participants", - "alerts", - } -) diff --git a/caldav/jmap/convert/_utils.py b/caldav/jmap/convert/_utils.py deleted file mode 100644 index 8f19b493..00000000 --- a/caldav/jmap/convert/_utils.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -Shared datetime and duration utilities for JSCalendar ↔ iCalendar conversion. -""" - -from __future__ import annotations - -from datetime import date, datetime, timedelta -from datetime import tzinfo as tzinfo_t - - -def _timedelta_to_duration(td: timedelta) -> str: - """Convert a timedelta to an ISO 8601 duration string. - - Examples: - timedelta(hours=1, minutes=30) → "PT1H30M" - timedelta(days=1, hours=2) → "P1DT2H" - timedelta(0) → "P0D" - timedelta(seconds=-900) → "-PT15M" - - Args: - td: The duration to convert. - - Returns: - ISO 8601 duration string, always positive or negative prefix, - never fractional components. - """ - total_seconds = int(td.total_seconds()) - sign = "-" if total_seconds < 0 else "" - total_seconds = abs(total_seconds) - - days, rem = divmod(total_seconds, 86400) - hours, rem = divmod(rem, 3600) - minutes, seconds = divmod(rem, 60) - - day_part = f"{days}D" if days else "" - time_parts = [] - if hours: - time_parts.append(f"{hours}H") - if minutes: - time_parts.append(f"{minutes}M") - if seconds: - time_parts.append(f"{seconds}S") - - time_part = ("T" + "".join(time_parts)) if time_parts else "" - - body = day_part + time_part or "0D" - return f"{sign}P{body}" - - -def _duration_to_timedelta(duration_str: str) -> timedelta: - """Parse an ISO 8601 duration string into a timedelta. - - Handles the subset used in JSCalendar: P[nW][nD][T[nH][nM][nS]]. - Does not handle months or years (JSCalendar uses recurrenceRules for those). - - Examples: - "PT1H30M" → timedelta(hours=1, minutes=30) - "P1DT2H" → timedelta(days=1, hours=2) - "P0D" → timedelta(0) - "-PT15M" → timedelta(seconds=-900) - - Args: - duration_str: ISO 8601 duration string. - - Returns: - Equivalent timedelta. - - Raises: - ValueError: If the string cannot be parsed. - """ - s = duration_str.strip() - sign = 1 - if s.startswith("-"): - sign = -1 - s = s[1:] - elif s.startswith("+"): - s = s[1:] - - if not s.startswith("P"): - raise ValueError(f"Invalid duration string: {duration_str!r}") - s = s[1:] - - weeks = days = hours = minutes = seconds = 0 - - if "T" in s: - date_part, time_part = s.split("T", 1) - else: - date_part, time_part = s, "" - - if date_part: - if "W" in date_part: - w, date_part = date_part.split("W", 1) - weeks = int(w) - if "D" in date_part: - d, _ = date_part.split("D", 1) - days = int(d) - - if time_part: - remaining = time_part - if "H" in remaining: - h, remaining = remaining.split("H", 1) - hours = int(h) - if "M" in remaining: - m, remaining = remaining.split("M", 1) - minutes = int(m) - if "S" in remaining: - sec_str, _ = remaining.split("S", 1) - seconds = int(float(sec_str)) # truncate fractional seconds to whole seconds - - td = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds) - return sign * td - - -def _format_local_dt(dt: datetime | date, tzinfo: tzinfo_t | None = None) -> str: - """Format a datetime or date as a JSCalendar LocalDateTime string. - - RFC 8984 requires LocalDateTime (no Z suffix) for override keys and RRULE - ``until`` values, and those are expressed in the *event's* timezone. An - aware datetime is therefore converted into ``tzinfo`` before the offset is - dropped; merely stripping it would shift the value by the UTC offset, and - a floating ``UNTIL`` against a TZID ``DTSTART`` is forbidden outright by - RFC 5545 3.3.10. - - ``tzinfo`` is the event's timezone, normally ``DTSTART.dt.tzinfo``. When - it is None the event is floating or all-day: there is nothing to convert - into, so the value is passed through as-is. - - For date objects (all-day), uses T00:00:00 suffix. - - Args: - dt: A datetime (with or without tzinfo) or a date. - tzinfo: The event's timezone, or None for a floating/all-day event. - - Returns: - Formatted string suitable for use as a JSCalendar override key or RRULE until. - """ - if isinstance(dt, datetime): - if tzinfo is not None and dt.tzinfo is not None: - dt = dt.astimezone(tzinfo) - return dt.strftime("%Y-%m-%dT%H:%M:%S") - return f"{dt.isoformat()}T00:00:00" diff --git a/caldav/jmap/convert/ical_to_jscal.py b/caldav/jmap/convert/ical_to_jscal.py deleted file mode 100644 index 6bd50e3d..00000000 --- a/caldav/jmap/convert/ical_to_jscal.py +++ /dev/null @@ -1,487 +0,0 @@ -""" -iCalendar → JSCalendar conversion (RFC 5545 → RFC 8984). - -Public API: - ical_to_jscal(ical_str, calendar_id=None) -> dict - -The output dict is a raw JSCalendar CalendarEvent object suitable for passing -directly to CalendarEvent/set. -""" - -from __future__ import annotations - -import uuid -from datetime import date, datetime, timedelta - -import icalendar - -from caldav.jmap.convert._utils import _format_local_dt, _timedelta_to_duration -from caldav.lib import vcal - -# RFC 5545 STATUS -> RFC 8984 status; module-level constant (cf. _CLASS_MAP etc.) -_STATUS_ICAL_TO_JSCAL = { - "CONFIRMED": "confirmed", - "TENTATIVE": "tentative", - "CANCELLED": "cancelled", -} - -_CLASS_MAP = { - "PRIVATE": "private", - "CONFIDENTIAL": "secret", -} - -_PARTSTAT_MAP = { - "NEEDS-ACTION": "needs-action", - "ACCEPTED": "accepted", - "DECLINED": "declined", - "TENTATIVE": "tentative", - "DELEGATED": "delegated", -} - -_CUTYPE_MAP = { - "INDIVIDUAL": "individual", - "GROUP": "group", - "RESOURCE": "resource", - "ROOM": "room", -} - -_BYDAY_ABBR = {"SU", "MO", "TU", "WE", "TH", "FR", "SA"} - - -def _dtstart_to_jscal(dtstart_prop) -> tuple[str, str | None, bool]: - """Extract JSCalendar start, timeZone, showWithoutTime from a DTSTART property. - - Returns: - (start_str, time_zone, show_without_time) - """ - dt = dtstart_prop.dt - - if isinstance(dt, date) and not isinstance(dt, datetime): - # VALUE=DATE — all-day event - return f"{dt.isoformat()}T00:00:00", None, True - - if dt.tzinfo is not None and dt.utcoffset() == timedelta(0): - # UTC — JSCalendar start is LocalDateTime; express via timeZone="Etc/UTC" - return dt.strftime("%Y-%m-%dT%H:%M:%S"), "Etc/UTC", False - - if dt.tzinfo is not None: - # Timezone-aware — prefer the TZID parameter (IANA name) over tzinfo repr - # NOTE: non-IANA TZIDs (e.g. "Eastern Standard Time" from Outlook) - # are passed through unchanged; mapping to IANA is out of scope. - tz_str = dtstart_prop.params.get("TZID") - return dt.strftime("%Y-%m-%dT%H:%M:%S"), tz_str, False - - # Floating (no timezone) - return dt.strftime("%Y-%m-%dT%H:%M:%S"), None, False - - -def _rrule_to_jscal(rrule_prop, tzinfo=None) -> dict: - """Convert an iCalendar RRULE property to a JSCalendar RecurrenceRule dict. - - Always emits @type, interval, rscale, skip, firstDayOfWeek to match the - fields Cyrus returns — makes round-trip comparison predictable. - - ``tzinfo`` is the event's timezone; ``until`` is a LocalDateTime in that - zone, so a UTC ``UNTIL`` off the wire has to be converted, not truncated. - """ - rule: dict = { - "@type": "RecurrenceRule", - "rscale": "gregorian", - "skip": "omit", - } - - freq_list = rrule_prop.get("FREQ", []) - if not freq_list: - raise ValueError(f"RRULE is missing required FREQ component: {rrule_prop!r}") - rule["frequency"] = freq_list[0].lower() - - interval_list = rrule_prop.get("INTERVAL", []) - rule["interval"] = int(interval_list[0]) if interval_list else 1 - - wkst_list = rrule_prop.get("WKST", []) - rule["firstDayOfWeek"] = wkst_list[0].lower() if wkst_list else "mo" - - count_list = rrule_prop.get("COUNT", []) - if count_list: - rule["count"] = int(count_list[0]) - - until_list = rrule_prop.get("UNTIL", []) - if until_list: - rule["until"] = _format_local_dt(until_list[0], tzinfo) - - byday_list = rrule_prop.get("BYDAY", []) - if byday_list: - by_day = [] - for item in byday_list: - s = str(item) - day_abbr = s.lstrip("+-0123456789") - nth_str = s[: len(s) - len(day_abbr)] - nday: dict = {"@type": "NDay", "day": day_abbr.lower()} - if nth_str: - nday["nthOfPeriod"] = int(nth_str) - by_day.append(nday) - rule["byDay"] = by_day - - bymonth_list = rrule_prop.get("BYMONTH", []) - if bymonth_list: - rule["byMonth"] = [str(m) for m in bymonth_list] - - bymonthday = rrule_prop.get("BYMONTHDAY", []) - if bymonthday: - rule["byMonthDay"] = [int(d) for d in bymonthday] - - byyearday = rrule_prop.get("BYYEARDAY", []) - if byyearday: - rule["byYearDay"] = [int(d) for d in byyearday] - - byweekno = rrule_prop.get("BYWEEKNO", []) - if byweekno: - rule["byWeekNo"] = [int(n) for n in byweekno] - - byhour = rrule_prop.get("BYHOUR", []) - if byhour: - rule["byHour"] = [int(h) for h in byhour] - byminute = rrule_prop.get("BYMINUTE", []) - if byminute: - rule["byMinute"] = [int(m) for m in byminute] - bysecond = rrule_prop.get("BYSECOND", []) - if bysecond: - rule["bySecond"] = [int(s) for s in bysecond] - - bysetpos = rrule_prop.get("BYSETPOS", []) - if bysetpos: - rule["bySetPosition"] = [int(p) for p in bysetpos] - - return rule - - -def _exdate_to_overrides(exdate_prop, tzinfo=None) -> dict: - """Convert an EXDATE property (single or list) to recurrenceOverrides entries. - - ``tzinfo`` is the event's timezone — see :func:`_format_local_dt`. - - Returns: - Dict mapping LocalDateTime/UTCDateTime string → {"excluded": True} - """ - # EXDATE may be a single vDDDLists or a list of them - if not isinstance(exdate_prop, list): - exdate_prop = [exdate_prop] - - overrides: dict = {} - for ex in exdate_prop: - dts = getattr(ex, "dts", [ex]) - for dt_prop in dts: - dt = getattr(dt_prop, "dt", dt_prop) - overrides[_format_local_dt(dt, tzinfo)] = {"excluded": True} - return overrides - - -def _organizer_to_participant(organizer) -> tuple[str, dict]: - """Convert an ORGANIZER property to a (participant_id, Participant dict) tuple.""" - email = str(organizer).removeprefix("mailto:") - pid = str(uuid.uuid4()) - p: dict = { - "roles": {"owner": True, "organizer": True}, - "sendTo": { - "imip": str(organizer) if str(organizer).startswith("mailto:") else f"mailto:{email}" - }, - } - cn = organizer.params.get("CN") - if cn: - p["name"] = str(cn) - p["email"] = email - return pid, p - - -def _attendee_to_participant(attendee) -> tuple[str, dict]: - """Convert an ATTENDEE property to a (participant_id, Participant dict) tuple.""" - addr = str(attendee) - email = addr.removeprefix("mailto:") - pid = str(uuid.uuid4()) - p: dict = { - "roles": {"attendee": True}, - "sendTo": {"imip": addr if addr.startswith("mailto:") else f"mailto:{email}"}, - "email": email, - } - cn = attendee.params.get("CN") - if cn: - p["name"] = str(cn) - - partstat = attendee.params.get("PARTSTAT") - if partstat: - p["participationStatus"] = _PARTSTAT_MAP.get(partstat.upper(), partstat.lower()) - - rsvp = attendee.params.get("RSVP", "") - if str(rsvp).upper() == "TRUE": - p["expectReply"] = True - - cutype = attendee.params.get("CUTYPE") - if cutype: - p["kind"] = _CUTYPE_MAP.get(cutype.upper(), cutype.lower()) - - role = attendee.params.get("ROLE") - if role and role.upper() == "CHAIR": - p["roles"]["chair"] = True - - return pid, p - - -def _valarm_to_alert(alarm) -> tuple[str, dict]: - """Convert a VALARM component to a (alert_id, Alert dict) tuple. - - Trigger is emitted as a plain SignedDuration string (e.g. "-PT15M") or - UTCDateTime string per the JSCalendar Alert spec (RFC 8984 §4.5.2). - """ - alert_id = str(uuid.uuid4()) - action = str(alarm.get("ACTION", "display")).lower() - alert: dict = {"action": action} - - trigger_prop = alarm.get("TRIGGER") - if trigger_prop is not None: - trigger_val = trigger_prop.dt - if isinstance(trigger_val, timedelta): - # Relative trigger — convert to SignedDuration string - alert["trigger"] = _timedelta_to_duration(trigger_val) - if str(trigger_prop.params.get("RELATED", "START")).upper() == "END": - alert["relativeTo"] = "end" - elif isinstance(trigger_val, datetime): - # Absolute trigger — UTCDateTime string - alert["trigger"] = trigger_val.strftime("%Y-%m-%dT%H:%M:%SZ") - - description = alarm.get("DESCRIPTION") - if description: - alert["description"] = str(description) - - return alert_id, alert - - -def _location_str_to_jscal(location_str: str) -> dict: - """Convert a LOCATION string to a JSCalendar locations map entry. - - Returns: - {"": {"name": location_str}} - """ - return {str(uuid.uuid4()): {"name": location_str}} - - -def _categories_to_keywords(categories_prop) -> dict: - """Convert a CATEGORIES property to a JSCalendar keywords map. - - icalendar returns one of three types depending on how CATEGORIES appears: - - vCategory (single CATEGORIES line, possibly multi-value): access .cats - - list of vCategory (multiple CATEGORIES lines): flatten .cats from each - - vText (rare, single bare string value): str() and comma-split - """ - if hasattr(categories_prop, "cats"): - values = [str(c) for c in categories_prop.cats] - elif isinstance(categories_prop, list): - values = [] - for item in categories_prop: - if hasattr(item, "cats"): - values.extend(str(c) for c in item.cats) - else: - values.append(str(item)) - else: - raw = str(categories_prop) - values = [v.strip() for v in raw.split(",") if v.strip()] - - return {v: True for v in values} - - -def ical_to_jscal(ical_str: str, calendar_id: str | None = None) -> dict: - """Convert an iCalendar string to a JSCalendar CalendarEvent dict (RFC 8984). - - Processes the first VEVENT found in the string. Any sibling VEVENTs with a - RECURRENCE-ID are folded into the ``recurrenceOverrides`` map of the master - event. EXDATE entries are also added to ``recurrenceOverrides``. - - Args: - ical_str: A VCALENDAR string (or bare VEVENT — vcal.fix normalises it). - calendar_id: If provided, sets ``calendarIds: {calendar_id: true}`` - on the output. Required when the result will be used in - ``CalendarEvent/set`` (the server needs to know which calendar). - - Returns: - Raw JSCalendar dict suitable for passing directly to ``CalendarEvent/set``. - - Raises: - ValueError: If no VEVENT component is found. - """ - # Normalize iCal string (fixes common server-generated violations) - fixed = vcal.fix(ical_str) - - cal = icalendar.Calendar.from_ical(fixed) - - # Split subcomponents into master VEVENTs and override VEVENTs - master: icalendar.Event | None = None - override_components: list[icalendar.Event] = [] - - for component in cal.subcomponents: - if not isinstance(component, icalendar.Event): - continue - if component.get("RECURRENCE-ID") is not None: - override_components.append(component) - elif master is None: - master = component - - if master is None: - raise ValueError("No VEVENT component found in iCalendar string") - - uid = str(master["UID"]) - summary = master.get("SUMMARY") - title = str(summary) if summary else "" - dtstart_prop = master["DTSTART"] - start, time_zone, show_without_time = _dtstart_to_jscal(dtstart_prop) - - ## The event's own timezone. Every LocalDateTime slot below (RRULE - ## until, EXDATE keys, RECURRENCE-ID keys) is expressed in it, so it has - ## to be known before any of them can be formatted — which is why the - ## override keys cannot be built in the loop above. - event_tzinfo = getattr(getattr(dtstart_prop, "dt", None), "tzinfo", None) - - overrides_by_recurrence_id: dict[str, icalendar.Event] = { - _format_local_dt(component["RECURRENCE-ID"].dt, event_tzinfo): component - for component in override_components - } - - if master.get("DURATION"): - duration = _timedelta_to_duration(master["DURATION"].dt) - elif master.get("DTEND"): - delta = master["DTEND"].dt - dtstart_prop.dt - duration = _timedelta_to_duration(delta) - else: - duration = "P0D" - - jscal: dict = { - "@type": "Event", - "uid": uid, - "title": title, - "start": start, - "duration": duration, - } - - if calendar_id is not None: - jscal["calendarIds"] = {calendar_id: True} - - if time_zone is not None: - jscal["timeZone"] = time_zone - - if show_without_time: - jscal["showWithoutTime"] = True - - description = master.get("DESCRIPTION") - if description: - jscal["description"] = str(description) - - sequence = master.get("SEQUENCE") - if sequence is not None: - jscal["sequence"] = int(sequence) - - priority = master.get("PRIORITY") - if priority is not None: - p_int = int(priority) - if p_int != 0: - jscal["priority"] = p_int - - cls = master.get("CLASS") - if cls: - privacy = _CLASS_MAP.get(str(cls).upper()) - if privacy: - jscal["privacy"] = privacy - - transp = master.get("TRANSP") - if transp and str(transp).upper() == "TRANSPARENT": - jscal["freeBusyStatus"] = "free" - - color = master.get("COLOR") - if color: - jscal["color"] = str(color) - - categories = master.get("CATEGORIES") - if categories is not None: - kw = _categories_to_keywords(categories) - if kw: - jscal["keywords"] = kw - - location = master.get("LOCATION") - if location: - jscal["locations"] = _location_str_to_jscal(str(location)) - - status = master.get("STATUS") - if status: - jscal_status = _STATUS_ICAL_TO_JSCAL.get(str(status).upper()) - if jscal_status: - jscal["status"] = jscal_status - - participants: dict = {} - organizer = master.get("ORGANIZER") - if organizer is not None: - pid, p = _organizer_to_participant(organizer) - participants[pid] = p - - # .get() returns a single vCalAddress or a list; normalise to list - raw_attendees = master.get("ATTENDEE") - if raw_attendees is None: - attendees = [] - elif isinstance(raw_attendees, list): - attendees = raw_attendees - else: - attendees = [raw_attendees] - for attendee in attendees: - pid, p = _attendee_to_participant(attendee) - participants[pid] = p - - if participants: - jscal["participants"] = participants - - rrules = master.get("RRULE") - if rrules is not None: - if not isinstance(rrules, list): - rrules = [rrules] - jscal["recurrenceRules"] = [_rrule_to_jscal(r, event_tzinfo) for r in rrules] - - exrules = master.get("EXRULE") - if exrules is not None: - if not isinstance(exrules, list): - exrules = [exrules] - jscal["excludedRecurrenceRules"] = [_rrule_to_jscal(r, event_tzinfo) for r in exrules] - - recurrence_overrides: dict = {} - - exdate = master.get("EXDATE") - if exdate is not None: - recurrence_overrides.update(_exdate_to_overrides(exdate, event_tzinfo)) - - for rid_key, child in overrides_by_recurrence_id.items(): - # Build a patch: only fields that differ from the master - patch: dict = {} - child_summary = child.get("SUMMARY") - if child_summary and str(child_summary) != title: - patch["title"] = str(child_summary) - child_start_prop = child.get("DTSTART") - if child_start_prop: - child_start, _, _ = _dtstart_to_jscal(child_start_prop) - if child_start != start: - patch["start"] = child_start - child_duration_prop = child.get("DURATION") - if child_duration_prop: - child_dur = _timedelta_to_duration(child_duration_prop.dt) - if child_dur != duration: - patch["duration"] = child_dur - child_description = child.get("DESCRIPTION") - if child_description and str(child_description) != jscal.get("description"): - patch["description"] = str(child_description) - recurrence_overrides[rid_key] = patch or {} - - if recurrence_overrides: - jscal["recurrenceOverrides"] = recurrence_overrides - - alarms = [c for c in master.subcomponents if getattr(c, "name", None) == "VALARM"] - if alarms: - alerts: dict = {} - for alarm in alarms: - alert_id, alert = _valarm_to_alert(alarm) - alerts[alert_id] = alert - jscal["alerts"] = alerts - - return jscal diff --git a/caldav/jmap/convert/jscal_to_ical.py b/caldav/jmap/convert/jscal_to_ical.py deleted file mode 100644 index f947f1dd..00000000 --- a/caldav/jmap/convert/jscal_to_ical.py +++ /dev/null @@ -1,462 +0,0 @@ -""" -JSCalendar → iCalendar conversion (RFC 8984 → RFC 5545). - -Public API: - jscal_to_ical(jscal: dict) -> str - -Accepts a raw JSCalendar CalendarEvent dict (as returned by CalendarEvent/get -or produced by ical_to_jscal). Returns a VCALENDAR string. -""" - -from __future__ import annotations - -from datetime import date, datetime, timedelta, timezone -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError - -import icalendar -from icalendar import vCalAddress, vText - -from caldav.jmap.convert._utils import _duration_to_timedelta -from caldav.lib import vcal - -_PRIVACY_TO_CLASS = { - "private": "PRIVATE", - "secret": "CONFIDENTIAL", -} - -_FREE_BUSY_TO_TRANSP = { - "free": "TRANSPARENT", - "busy": "OPAQUE", -} - -_PARTSTAT_MAP = { - "needs-action": "NEEDS-ACTION", - "accepted": "ACCEPTED", - "declined": "DECLINED", - "tentative": "TENTATIVE", - "delegated": "DELEGATED", -} - -_KIND_TO_CUTYPE = { - "individual": "INDIVIDUAL", - "group": "GROUP", - "resource": "RESOURCE", - "room": "ROOM", -} -# RFC 8984 status -> RFC 5545 STATUS; module-level constant (cf. _KIND_TO_CUTYPE etc.) -_STATUS_JSCAL_TO_ICAL = { - "confirmed": "CONFIRMED", - "tentative": "TENTATIVE", - "cancelled": "CANCELLED", -} - - -def _start_to_dtstart( - component: icalendar.Event, - start_str: str, - time_zone: str | None, - show_without_time: bool, -) -> None: - """Add a DTSTART property to component from JSCalendar start fields. - - Handles four cases: - - All-day (showWithoutTime): VALUE=DATE - - UTC (start ends with Z): UTC DATETIME - - Timezone-aware: DATETIME;TZID=... - - Floating (no timeZone, no Z): plain DATETIME - """ - if show_without_time: - dt = date.fromisoformat(start_str[:10]) - component.add("dtstart", dt) - return - - if start_str.endswith("Z"): - dt = datetime.strptime(start_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) - component.add("dtstart", dt) - return - - dt_naive = datetime.strptime(start_str[:19], "%Y-%m-%dT%H:%M:%S") - - if time_zone: - try: - tz = ZoneInfo(time_zone) - dt = dt_naive.replace(tzinfo=tz) - component.add("dtstart", dt) - except ZoneInfoNotFoundError: - # Non-IANA TZID (e.g. "Eastern Standard Time") — pass through as-is - # so the consuming calendar client can resolve it. - dtstart = icalendar.vDatetime(dt_naive) - dtstart.params["TZID"] = time_zone - component.add("dtstart", dtstart) - else: - component.add("dtstart", dt_naive) - - -def _jscal_rrule_to_rrule(rule: dict, time_zone: str | None = None) -> dict: - """Convert a JSCalendar RecurrenceRule dict to an iCalendar vRecur-compatible dict. - - Strips @type and NDay @type fields — icalendar library rejects them. - Returns a plain dict suitable for icalendar.vRecur. - - ``time_zone`` is the event's IANA time zone. The JSCalendar ``until`` is a - LocalDateTime in that zone; RFC 5545 §3.3.10 requires the iCalendar UNTIL to - be UTC whenever DTSTART is a TZID or UTC date-time, so a non-Z ``until`` is - converted back to UTC here. - """ - freq = rule.get("frequency", "").upper() - if not freq: - return {} - - ical_rule: dict = {"FREQ": freq} - - interval = rule.get("interval") - if interval and interval != 1: - ical_rule["INTERVAL"] = interval - - count = rule.get("count") - if count is not None: - ical_rule["COUNT"] = count - - until = rule.get("until") - if until: - if until.endswith("Z"): - ical_rule["UNTIL"] = datetime.strptime(until, "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=timezone.utc - ) - elif time_zone: - # RFC 5545 §3.3.10: a TZID/UTC DTSTART requires a UTC UNTIL. The - # JSCalendar until is LocalDateTime in the event timeZone; convert - # it back to UTC so the emitted UNTIL carries the Z suffix. - naive = datetime.strptime(until[:19], "%Y-%m-%dT%H:%M:%S") - try: - ical_rule["UNTIL"] = naive.replace(tzinfo=ZoneInfo(time_zone)).astimezone( - timezone.utc - ) - except ZoneInfoNotFoundError: - ical_rule["UNTIL"] = naive - else: - ical_rule["UNTIL"] = datetime.strptime(until[:19], "%Y-%m-%dT%H:%M:%S") - - by_day = rule.get("byDay", []) - if by_day: - byday_strs = [] - for nday in by_day: - day = nday.get("day", "").upper() - nth = nday.get("nthOfPeriod") - if nth: - byday_strs.append(f"{nth}{day}") - else: - byday_strs.append(day) - ical_rule["BYDAY"] = byday_strs - - by_month = rule.get("byMonth", []) - if by_month: - ical_rule["BYMONTH"] = [ - m if isinstance(m, int) else int(str(m).rstrip("L")) for m in by_month - ] - - by_month_day = rule.get("byMonthDay", []) - if by_month_day: - ical_rule["BYMONTHDAY"] = by_month_day - - by_year_day = rule.get("byYearDay", []) - if by_year_day: - ical_rule["BYYEARDAY"] = by_year_day - - by_week_no = rule.get("byWeekNo", []) - if by_week_no: - ical_rule["BYWEEKNO"] = by_week_no - - by_hour = rule.get("byHour", []) - if by_hour: - ical_rule["BYHOUR"] = by_hour - - by_minute = rule.get("byMinute", []) - if by_minute: - ical_rule["BYMINUTE"] = by_minute - - by_second = rule.get("bySecond", []) - if by_second: - ical_rule["BYSECOND"] = by_second - - by_set_pos = rule.get("bySetPosition", []) - if by_set_pos: - ical_rule["BYSETPOS"] = by_set_pos - - first_day = rule.get("firstDayOfWeek") - if first_day and first_day != "mo": - ical_rule["WKST"] = first_day.upper() - - return ical_rule - - -def _participant_imip(p: dict) -> str: - send_to = p.get("sendTo", {}) - imip = send_to.get("imip") or send_to.get("other") or p.get("email", "") - if imip and not imip.startswith("mailto:"): - imip = f"mailto:{imip}" - return imip - - -def _participant_to_organizer(p: dict) -> vCalAddress | None: - """Build a vCalAddress for ORGANIZER, or None if this participant is not an organizer.""" - roles = p.get("roles", {}) - if not (roles.get("owner") or roles.get("organizer")): - return None - - addr = vCalAddress(_participant_imip(p)) - name = p.get("name") - if name: - addr.params["CN"] = vText(name) - return addr - - -def _participant_to_attendee(p: dict) -> vCalAddress | None: - """Build a vCalAddress for ATTENDEE, or None if participant is purely an organizer.""" - roles = p.get("roles", {}) - has_attendee_role = any( - roles.get(r) for r in ("attendee", "chair", "informational", "optional") - ) - if not has_attendee_role and (roles.get("owner") or roles.get("organizer")): - return None - - addr = vCalAddress(_participant_imip(p)) - name = p.get("name") - if name: - addr.params["CN"] = vText(name) - - partstat = p.get("participationStatus") - if partstat: - addr.params["PARTSTAT"] = _PARTSTAT_MAP.get(partstat, partstat.upper()) - else: - addr.params["PARTSTAT"] = "NEEDS-ACTION" - - if p.get("expectReply"): - addr.params["RSVP"] = "TRUE" - - kind = p.get("kind") - if kind: - addr.params["CUTYPE"] = _KIND_TO_CUTYPE.get(kind, kind.upper()) - - if roles.get("chair"): - addr.params["ROLE"] = "CHAIR" - elif roles.get("attendee") or has_attendee_role: - addr.params["ROLE"] = "REQ-PARTICIPANT" - - return addr - - -def _alert_to_valarm(alert: dict) -> icalendar.Alarm: - """Convert a JSCalendar Alert dict to an icalendar.Alarm component.""" - alarm = icalendar.Alarm() - action = alert.get("action", "display").upper() - alarm.add("action", action) - - trigger_str = alert.get("trigger", "") - if trigger_str: - if trigger_str.endswith("Z"): - try: - dt = datetime.strptime(trigger_str, "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=timezone.utc - ) - alarm.add("trigger", dt) - except ValueError: - alarm.add("trigger", timedelta(0)) - else: - try: - td = _duration_to_timedelta(trigger_str) - trigger = icalendar.vDuration(td) - if alert.get("relativeTo") == "end": - trigger.params["RELATED"] = "END" - alarm.add("trigger", trigger) - except ValueError: - alarm.add("trigger", timedelta(0)) - else: - alarm.add("trigger", timedelta(0)) - - description = alert.get("description") - if description: - alarm.add("description", description) - elif action == "DISPLAY": - alarm.add("description", "Reminder") - - return alarm - - -def _keywords_to_categories(keywords: dict) -> list[str]: - """Convert JSCalendar keywords map to a list of CATEGORIES strings.""" - return [k for k, v in keywords.items() if v] - - -def _locations_to_location(locations: dict) -> str | None: - """Extract the first location name from a JSCalendar locations map.""" - for loc in locations.values(): - name = loc.get("name") - if name: - return str(name) - return None - - -def jscal_to_ical(jscal: dict) -> str: - """Convert a JSCalendar CalendarEvent dict to an iCalendar VCALENDAR string. - - Handles the full set of fields supported by ``ical_to_jscal`` for round-trip - fidelity. ``recurrenceOverrides`` entries with ``excluded: true`` become - EXDATE properties; patch dicts become child VEVENTs with RECURRENCE-ID. - - Args: - jscal: A raw JSCalendar CalendarEvent dict as returned by ``CalendarEvent/get``. - - Returns: - An iCalendar VCALENDAR string, normalised by ``vcal.fix()``. - """ - cal = icalendar.Calendar() - cal.add("prodid", "-//python-caldav//JMAP//EN") - cal.add("version", "2.0") - - event = icalendar.Event() - - uid = jscal.get("uid", "") - if uid: - event.add("uid", uid) - event.add("dtstamp", datetime.now(tz=timezone.utc)) - - sequence = jscal.get("sequence", 0) - if sequence: - event.add("sequence", sequence) - - start_str = jscal.get("start", "") - time_zone = jscal.get("timeZone") - show_without_time = jscal.get("showWithoutTime", False) - if start_str: - _start_to_dtstart(event, start_str, time_zone, show_without_time) - - duration_str = jscal.get("duration", "P0D") - if duration_str and duration_str != "P0D": - td = _duration_to_timedelta(duration_str) - event.add("duration", td) - - title = jscal.get("title", "") - if title: - event.add("summary", title) - - description = jscal.get("description") - if description: - event.add("description", description) - - priority = jscal.get("priority", 0) - if priority: - event.add("priority", priority) - - privacy = jscal.get("privacy") - if privacy: - cls = _PRIVACY_TO_CLASS.get(privacy) - if cls: - event.add("class", cls) - - free_busy = jscal.get("freeBusyStatus", "busy") - transp = _FREE_BUSY_TO_TRANSP.get(free_busy, "OPAQUE") - if transp != "OPAQUE": - event.add("transp", transp) - - color = jscal.get("color") - if color: - event.add("color", color) - - keywords = jscal.get("keywords") or {} - if keywords: - cats = _keywords_to_categories(keywords) - if cats: - event.add("categories", cats) - - locations = jscal.get("locations") or {} - if locations: - loc_name = _locations_to_location(locations) - if loc_name: - event.add("location", loc_name) - - status = jscal.get("status") - if status: - ical_status = _STATUS_JSCAL_TO_ICAL.get(status) - if ical_status: - event.add("status", ical_status) - - for rule in jscal.get("recurrenceRules") or []: - ical_rule = _jscal_rrule_to_rrule(rule, time_zone) - if ical_rule: - event.add("rrule", ical_rule) - - for rule in jscal.get("excludedRecurrenceRules") or []: - ical_rule = _jscal_rrule_to_rrule(rule, time_zone) - if ical_rule: - event.add("exrule", ical_rule) - - exdates: list[datetime | date] = [] - child_events: list[icalendar.Event] = [] - - for override_key, patch in (jscal.get("recurrenceOverrides") or {}).items(): - if override_key.endswith("Z"): - rid_dt: datetime | date = datetime.strptime(override_key, "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=timezone.utc - ) - elif show_without_time: - rid_dt = date.fromisoformat(override_key[:10]) - elif time_zone: - try: - rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S").replace( - tzinfo=ZoneInfo(time_zone) - ) - except ZoneInfoNotFoundError: - rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S") - else: - rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S") - - if patch is None or (isinstance(patch, dict) and patch.get("excluded")): - exdates.append(rid_dt) - else: - child = icalendar.Event() - child.add("uid", uid) - child.add("dtstamp", datetime.now(tz=timezone.utc)) - child.add("recurrence-id", rid_dt) - # Default child start to the occurrence time (override key), not the master start. - child_start = patch.get("start", override_key) - child_tz = patch.get("timeZone", time_zone) - child_swt = patch.get("showWithoutTime", show_without_time) - if child_start: - _start_to_dtstart(child, child_start, child_tz, child_swt) - child_dur = patch.get("duration", duration_str) - if child_dur and child_dur != "P0D": - child.add("duration", _duration_to_timedelta(child_dur)) - child_title = patch.get("title", title) - if child_title: - child.add("summary", child_title) - child_desc = patch.get("description", description) - if child_desc: - child.add("description", child_desc) - child_events.append(child) - - if exdates: - for exdate_dt in exdates: - event.add("exdate", exdate_dt) - - organizer_added = False - for p in (jscal.get("participants") or {}).values(): - org = _participant_to_organizer(p) - if org and not organizer_added: - event.add("organizer", org) - organizer_added = True - att = _participant_to_attendee(p) - if att is not None: - event.add("attendee", att) - - for alert in (jscal.get("alerts") or {}).values(): - alarm = _alert_to_valarm(alert) - event.add_component(alarm) - - cal.add_component(event) - - for child in child_events: - cal.add_component(child) - - raw = cal.to_ical().decode("utf-8") - return vcal.fix(raw) diff --git a/caldav/jmap/error.py b/caldav/jmap/error.py deleted file mode 100644 index 8b7f704d..00000000 --- a/caldav/jmap/error.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -JMAP error hierarchy. - -Extends the existing caldav.lib.error.DAVError base so that JMAP errors -integrate naturally with existing exception handling in user code. - -RFC 8620 §3.6.2 defines the standard method-level error types. -""" - -from caldav.lib.error import AuthorizationError, DAVError - - -class JMAPError(DAVError): - """Base class for all JMAP errors. - - Adds ``error_type`` to carry the RFC 8620 error type string - (e.g. ``"unknownMethod"``, ``"invalidArguments"``). - """ - - error_type: str = "serverError" - - def __init__( - self, - url: str | None = None, - reason: str | None = None, - error_type: str | None = None, - ) -> None: - super().__init__(url=url, reason=reason) - if error_type is not None: - self.error_type = error_type - - def __str__(self) -> str: - return "%s (type=%s) at '%s', reason: %s" % ( - self.__class__.__name__, - self.error_type, - self.url, - self.reason, - ) - - -class JMAPCapabilityError(JMAPError): - """Server does not advertise the required JMAP capability. - - Raised when the Session object returned by the server does not include - ``urn:ietf:params:jmap:calendars`` in the account capabilities. - """ - - error_type = "capabilityNotSupported" - reason = "Server does not support urn:ietf:params:jmap:calendars" - - -class JMAPAuthError(AuthorizationError, JMAPError): - """HTTP 401 or 403 received from JMAP server. - - Unlike CalDAV, JMAP does not use a 401-challenge-retry dance. - A 401/403 on the session GET or any API call is a hard failure. - """ - - error_type = "forbidden" - reason = "Authentication failed" - - -class JMAPMethodError(JMAPError): - """A JMAP method call returned an error response. - - RFC 8620 §3.6.2 error types that may be set as ``error_type``: - - - ``serverError`` — unexpected server-side error - - ``unknownMethod`` — method name not recognised - - ``invalidArguments`` — bad argument types or values - - ``invalidResultReference`` — bad ``#result`` reference - - ``forbidden`` — not allowed to perform this call - - ``accountNotFound`` — ``accountId`` does not exist - - ``accountNotSupportedByMethod`` — account lacks needed capability - - ``accountReadOnly`` — account is read-only - - ``requestTooLarge`` — request exceeds server limits - - ``stateMismatch`` — ``ifInState`` check failed - - ``serverPartialFail`` — partial failure; some calls succeeded - - ``notFound`` — requested object does not exist - - ``notDraft`` — object is not in draft state - """ - - error_type = "serverError" diff --git a/caldav/jmap/objects/__init__.py b/caldav/jmap/objects/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/caldav/jmap/objects/calendar.py b/caldav/jmap/objects/calendar.py deleted file mode 100644 index 6be47bd7..00000000 --- a/caldav/jmap/objects/calendar.py +++ /dev/null @@ -1,201 +0,0 @@ -""" -JMAP Calendar object. - -Represents a JMAP Calendar resource as returned by ``Calendar/get``. -Properties are defined in the JMAP Calendars specification. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import datetime, timezone -from typing import TYPE_CHECKING - -from caldav.jmap.objects.calendar_object import JMAPCalendarObject - -if TYPE_CHECKING: - from caldav.jmap.async_client import AsyncJMAPClient - from caldav.jmap.client import JMAPClient - - -def _to_utcdate(dt: datetime) -> str: - """Convert a datetime to JMAP UTCDate format (YYYY-MM-DDTHH:MM:SSZ). - - Naive datetimes are assumed to be UTC. Aware datetimes are converted to - UTC before formatting. Microseconds are dropped as JMAP does not allow them. - """ - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -@dataclass -class JMAPCalendar: - """A JMAP Calendar object. - - Attributes: - id: Server-assigned calendar identifier. - name: Display name of the calendar. - description: Optional longer description. - color: Optional CSS color string (e.g. ``"#ff0000"``). - is_subscribed: Whether the user is subscribed to this calendar. - my_rights: Dict of right names → bool for the current user. - sort_order: Hint for display ordering (lower = first). - is_visible: Whether the calendar should be displayed. - """ - - id: str - name: str - description: str | None = None - color: str | None = None - is_subscribed: bool = True - my_rights: dict = field(default_factory=dict) - sort_order: int = 0 - is_visible: bool = True - - # Injected by JMAPClient.get_calendars() / AsyncJMAPClient.get_calendars() - _client: JMAPClient | AsyncJMAPClient | None = field( - default=None, init=False, repr=False, compare=False - ) - _is_async: bool = field(default=False, init=False, repr=False, compare=False) - - @classmethod - def from_jmap(cls, data: dict) -> JMAPCalendar: - """Construct a JMAPCalendar from a raw JMAP Calendar JSON dict. - - Unknown keys in ``data`` are silently ignored so that forward - compatibility is maintained as the spec evolves. - """ - return cls( - id=data["id"], - name=data["name"], - description=data.get("description"), - color=data.get("color"), - is_subscribed=data.get("isSubscribed", True), - my_rights=data.get("myRights", {}), - sort_order=data.get("sortOrder", 0), - is_visible=data.get("isVisible", True), - ) - - def to_jmap(self) -> dict: - """Serialise to a JMAP Calendar JSON dict for ``Calendar/set``. - - ``id`` and ``myRights`` are intentionally excluded — both are - server-set and must not appear in create or update payloads. - Optional fields are included only when they hold a non-default value. - """ - d: dict = { - "name": self.name, - "isSubscribed": self.is_subscribed, - "sortOrder": self.sort_order, - "isVisible": self.is_visible, - } - if self.description is not None: - d["description"] = self.description - if self.color is not None: - d["color"] = self.color - return d - - def search(self, **searchargs): - """Search for calendar objects in this calendar. - - Mirrors :meth:`caldav.collection.Calendar.search`. When called on an - async-backed calendar, returns a coroutine that must be awaited. - - Accepted keyword arguments (all optional): - - - ``start`` (datetime or str): only events ending after this time - (maps to JMAP ``after`` filter). - - ``end`` (datetime or str): only events starting before this time - (maps to JMAP ``before`` filter). - - ``text`` (str): free-text search across title, description, - locations, and participants. - - Unknown parameters are silently ignored for backward compatibility. - - Returns: - List of :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject` - for all matching objects. - """ - if self._is_async: - return self._async_search(**searchargs) - start = searchargs.get("start") - end = searchargs.get("end") - if isinstance(start, datetime): - start = _to_utcdate(start) - if isinstance(end, datetime): - end = _to_utcdate(end) - return self._client._search( - calendar_id=self.id, - start=start, - end=end, - text=searchargs.get("text"), - parent=self, - ) - - async def _async_search(self, **searchargs) -> list[JMAPCalendarObject]: - start = searchargs.get("start") - end = searchargs.get("end") - if isinstance(start, datetime): - start = _to_utcdate(start) - if isinstance(end, datetime): - end = _to_utcdate(end) - return await self._client._search( - calendar_id=self.id, - start=start, - end=end, - text=searchargs.get("text"), - parent=self, - ) - - def get_object_by_uid(self, uid: str, comp_class=None): - """Get a calendar object by its iCalendar UID. - - Mirrors :meth:`caldav.collection.Calendar.get_object_by_uid`. When - called on an async-backed calendar, returns a coroutine that must be - awaited. - - Args: - uid: The iCalendar UID to search for. - comp_class: Accepted for API compatibility with the CalDAV interface; - JMAP ``CalendarEvent/query`` has no native component-type filter, - so this argument is currently ignored. - - Returns: - A :class:`~caldav.jmap.objects.calendar_object.JMAPCalendarObject` - for the matching object. - - Raises: - JMAPMethodError: If no object with this UID is found. - """ - if self._is_async: - return self._async_get_object_by_uid(uid) - return self._client._get_object_by_uid(uid, calendar_id=self.id, parent=self) - - async def _async_get_object_by_uid(self, uid: str) -> JMAPCalendarObject: - return await self._client._get_object_by_uid(uid, calendar_id=self.id, parent=self) - - def add_event(self, ical_str: str) -> str: - """Add an event to this calendar from an iCalendar string. - - Mirrors :meth:`caldav.collection.Calendar.add_event`. When called on - an async-backed calendar, returns a coroutine that must be awaited. - - Args: - ical_str: A VCALENDAR string representing the event. - - Returns: - The server-assigned JMAP event ID. Unlike the CalDAV equivalent, - this returns a string ID rather than a calendar object — the - ``CalendarEvent/set`` response does not include the full object, - so a follow-up GET would be required. - - Raises: - JMAPMethodError: If the server rejects the create request. - """ - if self._is_async: - return self._async_add_event(ical_str) - return self._client.create_event(self.id, ical_str) - - async def _async_add_event(self, ical_str: str) -> str: - return await self._client.create_event(self.id, ical_str) diff --git a/caldav/jmap/objects/calendar_object.py b/caldav/jmap/objects/calendar_object.py deleted file mode 100644 index 602b8baf..00000000 --- a/caldav/jmap/objects/calendar_object.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -JMAP calendar resource object. - -Wraps a raw JSCalendar CalendarEvent dict with the same minimal interface -as :class:`caldav.calendarobjectresource.CalendarObjectResource`: -``.id``, ``.parent``, :meth:`get_data`, :meth:`get_icalendar_instance`, -:meth:`edit_icalendar_instance`, and :meth:`save`. -""" - -from __future__ import annotations - -from contextlib import contextmanager -from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -import icalendar - -from caldav.jmap.convert import jscal_to_ical -from caldav.jmap.error import JMAPMethodError - -if TYPE_CHECKING: - from caldav.jmap.objects.calendar import JMAPCalendar - - -@dataclass -class JMAPCalendarObject: - """Thin wrapper around a raw JSCalendar CalendarEvent dict. - - Stores the server's JSON response as-is. No JMAP field names are mapped - to typed attributes — callers work with the dict directly via - :meth:`get_data`, or convert to iCalendar via :meth:`get_icalendar_instance`. - - Attributes: - data: Raw JSCalendar CalendarEvent dict as returned by ``CalendarEvent/get``. - parent: The :class:`~caldav.jmap.objects.calendar.JMAPCalendar` this object - belongs to, or ``None`` when fetched without a calendar context - (e.g. via :meth:`~caldav.jmap.client.JMAPClient.get_event`). - """ - - data: dict - parent: JMAPCalendar | None - - _ical_cache: icalendar.Calendar | None = field( - default=None, init=False, repr=False, compare=False - ) - - @property - def id(self) -> str: - """Server-assigned JMAP event ID.""" - return self.data["id"] - - def get_data(self) -> dict: - """Return the raw JSCalendar dict as returned by ``CalendarEvent/get``.""" - return self.data - - def get_icalendar_instance(self) -> icalendar.Calendar: - """Return an :class:`icalendar.Calendar` for this object. - - The result is cached after the first conversion. Treat it as - read-only; use :meth:`edit_icalendar_instance` to make and persist - changes. - """ - if self._ical_cache is None: - self._ical_cache = icalendar.Calendar.from_ical(jscal_to_ical(self.data)) - return self._ical_cache - - @contextmanager - def edit_icalendar_instance(self): - """Borrow an editable :class:`icalendar.Calendar` for this object. - - Yields the cached :class:`icalendar.Calendar` for in-place editing. - Call :meth:`save` after the ``with`` block to persist changes to the server. - - Note: :meth:`save` is sync-only. Async-backed calendars cannot use - this path yet. - - Example:: - - with obj.edit_icalendar_instance() as cal: - cal.subcomponents[0]["SUMMARY"] = vText("New title") - obj.save() - """ - cal = self.get_icalendar_instance() - yield cal - - def save(self) -> None: - """Persist changes made via :meth:`edit_icalendar_instance` to the server. - - Serialises the (possibly edited) icalendar object back to an iCalendar - string and calls ``update_event()`` on the parent calendar's client. - - Raises: - JMAPMethodError: If no parent calendar is set (``parent`` is ``None``). - RuntimeError: If called on an async-backed calendar. - """ - if self.parent is None: - raise JMAPMethodError(url="N/A", reason="Cannot save: no parent calendar is set") - if self.parent._is_async: - raise RuntimeError( - "save() is not supported for async-backed calendars. " - "Use await parent._client.update_event() directly." - ) - ical_str = ( - self._ical_cache.to_ical().decode() - if self._ical_cache is not None - else jscal_to_ical(self.data) - ) - self.parent._client.update_event(self.id, ical_str) diff --git a/caldav/jmap/session.py b/caldav/jmap/session.py deleted file mode 100644 index 5700ed01..00000000 --- a/caldav/jmap/session.py +++ /dev/null @@ -1,154 +0,0 @@ -""" -JMAP session establishment (RFC 8620 §2). - -Fetches the Session object from /.well-known/jmap and extracts the -information needed to make subsequent API calls. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from urllib.parse import urljoin, urlparse, urlunparse - -from caldav.jmap.constants import CALENDAR_CAPABILITY -from caldav.jmap.error import JMAPAuthError, JMAPCapabilityError -from caldav.lib.http_sync import AsyncSession, requests - - -@dataclass -class Session: - """Parsed JMAP Session object (RFC 8620 §2). - - Attributes: - api_url: URL to POST method calls to. - account_id: The accountId to use for calendar method calls. - Chosen from ``primaryAccounts`` if available, otherwise the first - account advertising the calendars capability. - state: Current session state string. - account_capabilities: Capabilities dict for the chosen account. - server_capabilities: Server-level capabilities dict. - raw: The full parsed Session JSON for anything not captured above. - """ - - api_url: str - account_id: str - state: str - account_capabilities: dict = field(default_factory=dict) - server_capabilities: dict = field(default_factory=dict) - raw: dict = field(default_factory=dict) - - -def _parse_session_data(url: str, data: dict) -> Session: - api_url = data.get("apiUrl") - if not api_url: - raise JMAPCapabilityError( - url=url, - reason="Session response missing 'apiUrl'", - ) - - # RFC 8620 §2 says apiUrl SHOULD be absolute, but some servers (e.g. Cyrus) - # return a relative path. Resolve it against the session endpoint URL. - api_url = urljoin(url, api_url) - - # Some servers (e.g. Stalwart) advertise an api_url whose host matches ours - # but with a different scheme (https vs http) and/or port than the one we - # actually connected through. Rewrite both scheme and netloc to match the - # session endpoint so that subsequent calls succeed without TLS errors. - session_parsed = urlparse(url) - api_parsed = urlparse(api_url) - if api_parsed.hostname == session_parsed.hostname and ( - api_parsed.port != session_parsed.port or api_parsed.scheme != session_parsed.scheme - ): - api_url = urlunparse( - api_parsed._replace(scheme=session_parsed.scheme, netloc=session_parsed.netloc) - ) - - state = data.get("state", "") - server_capabilities = data.get("capabilities", {}) - accounts = data.get("accounts", {}) - - account_id = None - account_capabilities: dict = {} - primary_acct_id = data.get("primaryAccounts", {}).get(CALENDAR_CAPABILITY) - if primary_acct_id: - acct_data = accounts.get(primary_acct_id, {}) - caps = acct_data.get("accountCapabilities", {}) - if CALENDAR_CAPABILITY in caps: - account_id = primary_acct_id - account_capabilities = caps - if account_id is None: - for acct_id, acct_data in accounts.items(): - caps = acct_data.get("accountCapabilities", {}) - if CALENDAR_CAPABILITY in caps: - account_id = acct_id - account_capabilities = caps - break - - if account_id is None: - raise JMAPCapabilityError( - url=url, - reason=( - f"No account found with capability {CALENDAR_CAPABILITY!r}. " - f"Available accounts: {list(accounts.keys())}" - ), - ) - - return Session( - api_url=api_url, - account_id=account_id, - state=state, - account_capabilities=account_capabilities, - server_capabilities=server_capabilities, - raw=data, - ) - - -def fetch_session(url: str, auth, timeout: int = 30) -> Session: - """Fetch and parse the JMAP Session object. - - Performs a GET request to ``url`` (expected to be ``/.well-known/jmap`` - or equivalent), authenticates with ``auth``, and returns a parsed - :class:`Session`. - - Args: - url: Full URL to the JMAP session endpoint. - auth: A requests-compatible auth object (e.g. HTTPBasicAuth, - HTTPBearerAuth). - - Returns: - Parsed :class:`Session` with ``api_url`` and ``account_id`` set. - - Raises: - JMAPAuthError: If the server returns HTTP 401 or 403. - JMAPCapabilityError: If no account advertises the calendars capability. - requests.HTTPError: For other non-2xx responses. - """ - response = requests.get(url, auth=auth, headers={"Accept": "application/json"}, timeout=timeout) - if response.status_code in (401, 403): - raise JMAPAuthError(url=url, reason=f"HTTP {response.status_code} from session endpoint") - response.raise_for_status() - return _parse_session_data(url, response.json()) - - -async def async_fetch_session(url: str, auth, timeout: int = 30) -> Session: - """Async variant of :func:`fetch_session` using niquests.AsyncSession. - - Args: - url: Full URL to the JMAP session endpoint. - auth: A niquests-compatible auth object. - - Returns: - Parsed :class:`Session` with ``api_url`` and ``account_id`` set. - - Raises: - JMAPAuthError: If the server returns HTTP 401 or 403. - JMAPCapabilityError: If no account advertises the calendars capability. - """ - async with AsyncSession() as session: - response = await session.get( - url, auth=auth, headers={"Accept": "application/json"}, timeout=timeout - ) - if response.status_code in (401, 403): - raise JMAPAuthError(url=url, reason=f"HTTP {response.status_code} from session endpoint") - response.raise_for_status() - return _parse_session_data(url, response.json()) diff --git a/caldav/lib/http_sync.py b/caldav/lib/http_sync.py index 7fe8441a..ad027f89 100644 --- a/caldav/lib/http_sync.py +++ b/caldav/lib/http_sync.py @@ -9,23 +9,14 @@ different order and lives in :mod:`caldav.async_davclient`. """ -from typing import Any - from caldav.lib.http_libraries import ( SYNC_CANDIDATES, no_http_library_error, - required_library_error, ) USE_NIQUESTS = False USE_REQUESTS = False -## niquests' AsyncSession has no requests equivalent, so it is None on the -## fallback. The JMAP async client is the only thing that needs it; it goes -## through require_async_session() to get a decent error rather than a -## TypeError on None. -AsyncSession: Any = None - try: import niquests as requests from niquests.auth import AuthBase, HTTPBasicAuth @@ -49,31 +40,7 @@ except ImportError as e: raise ImportError(no_http_library_error(SYNC_CANDIDATES)) from e -if USE_NIQUESTS: - ## Deliberately its own try: an ImportError here must not fall through to - ## the requests branch and flip USE_NIQUESTS off on an install that does - ## have niquests. Only the async JMAP client needs it. - try: - from niquests import AsyncSession - except ImportError: - ## Old niquests without AsyncSession: leave it None, and let - ## require_async_session() explain it if anything asks for it. - pass - - -def require_async_session() -> Any: - """Return niquests' ``AsyncSession``, or explain why there isn't one. - - Used by the async JMAP client, which is built on it and has no httpx - equivalent to fall back to. - """ - if AsyncSession is None: - raise ImportError(required_library_error("niquests", "The async JMAP client")) - return AsyncSession - - __all__ = [ - "AsyncSession", "AuthBase", "CaseInsensitiveDict", "HTTPBasicAuth", @@ -81,5 +48,4 @@ def require_async_session() -> Any: "USE_NIQUESTS", "USE_REQUESTS", "requests", - "require_async_session", ] diff --git a/docs/source/caldav/jmap_client.rst b/docs/source/caldav/jmap_client.rst deleted file mode 100644 index a4eab9b8..00000000 --- a/docs/source/caldav/jmap_client.rst +++ /dev/null @@ -1,10 +0,0 @@ -:mod:`JMAPClient` -- JMAP calendar client -========================================== - -.. automodule:: caldav.jmap.client - :synopsis: Synchronous JMAP client for calendar operations - :members: - -.. automodule:: caldav.jmap.async_client - :synopsis: Asynchronous JMAP client for calendar operations - :members: diff --git a/docs/source/caldav/jmap_objects.rst b/docs/source/caldav/jmap_objects.rst deleted file mode 100644 index 5e09a124..00000000 --- a/docs/source/caldav/jmap_objects.rst +++ /dev/null @@ -1,14 +0,0 @@ -:mod:`jmap.objects` -- JMAP data objects -========================================= - -.. automodule:: caldav.jmap.objects.calendar - :members: - -.. automodule:: caldav.jmap.objects.event - :members: - -.. automodule:: caldav.jmap.objects.task - :members: - -.. automodule:: caldav.jmap.error - :members: diff --git a/docs/source/caldav/jmap_wrapper.rst b/docs/source/caldav/jmap_wrapper.rst new file mode 100644 index 00000000..a4d4690a --- /dev/null +++ b/docs/source/caldav/jmap_wrapper.rst @@ -0,0 +1,6 @@ +:mod:`jmap` -- JMAP wrapper +============================ + +.. automodule:: caldav.jmap + :synopsis: Thin wrapper around the standalone calendaring-jmap package + :members: diff --git a/docs/source/http-libraries.rst b/docs/source/http-libraries.rst index c0251caf..1d662a0b 100644 --- a/docs/source/http-libraries.rst +++ b/docs/source/http-libraries.rst @@ -71,10 +71,11 @@ document. Install one of them (``pip install niquests`` is the recommended choice), or, if you are declaring caldav as a dependency of your own project, depend on ``caldav[niquests]`` rather than plain ``caldav``. -Note that the async *JMAP* client is the one exception to the fallback chain: -it is built on niquests' ``AsyncSession`` and has no httpx equivalent, so -``caldav.jmap`` requires niquests regardless of what the CalDAV clients are -using. +Note that none of this governs JMAP. The JMAP client moved out into the +standalone `calendaring-jmap `_ +package, which picks its own HTTP library and requires both niquests and +requests outright; ``caldav.jmap`` is only a wrapper around it. See +:doc:`jmap`. Recommendations --------------- diff --git a/docs/source/jmap.rst b/docs/source/jmap.rst index b679ed72..554dd3fa 100644 --- a/docs/source/jmap.rst +++ b/docs/source/jmap.rst @@ -2,22 +2,38 @@ JMAP ==== -**The JMAP support in v3.0 is experimental, the API may change in v3.1 of the library** +JMAP (:rfc:`8620`, JMAP Core, plus the JMAP Calendars protocol using +:rfc:`8984` JSCalendar) support moved out of this library into the standalone +`calendaring-jmap `_ package. +``caldav.jmap`` is now a thin wrapper around it, so the ``from caldav.jmap +import get_jmap_client`` usage you may already have keeps working - it emits +a ``DeprecationWarning`` and will be removed in a future release. Import +from ``calendaring_jmap`` directly in new code. -The caldav library includes a JMAP client for servers that speak -:rfc:`8620` (JMAP Core) and -the JMAP Calendars protocol (``urn:ietf:params:jmap:calendars``), which uses -:rfc:`8984` (JSCalendar) as its data format. -It covers calendar listing, event CRUD, incremental sync, and task CRUD — the same -operations as the CalDAV client — so the choice of protocol comes down to what the -server supports. +calendaring-jmap is an optional dependency; ``caldav.jmap`` raises an +``ImportError`` without it. Install it with: + +.. code-block:: shell + + pip install caldav[jmap] + +The extra brings dependencies caldav itself does not have: calendaring-jmap +requires ``icalendar>=7.3.0``, which is a higher floor than caldav's own +``icalendar>6.0.0``, and it requires both ``niquests`` and ``requests``, so +``requests`` arrives even in an environment built to avoid it (see +:doc:`http-libraries`). .. note:: - The JMAP client targets servers implementing - ``urn:ietf:params:jmap:calendars``. Cyrus IMAP is the primary tested server. - Task support (``urn:ietf:params:jmap:tasks``) requires a separate server - capability; Cyrus does not implement it yet. + calendaring-jmap is licensed **AGPL-3.0-or-later**, while caldav itself + is ``GPL-3.0-or-later OR Apache-2.0``. Pulling in the ``jmap`` extra + therefore brings the AGPL network-copyleft obligation into your + dependency tree, even though the import path is unchanged. This does + not affect caldav installed without the extra. + +For the full client API, calendar/event/task operations, and conversion +details, see `calendaring-jmap's own documentation +`_. Quick Start =========== @@ -35,390 +51,42 @@ Quick Start for cal in calendars: print(cal.name) -The client keeps a persistent HTTP session, so connections are reused across -requests. The ``with`` block releases it at the end; if you would rather hold -on to the client, call ``client.close()`` when you are done -(``await client.aclose()`` on the async client). - -:func:`~caldav.jmap.get_jmap_client` reads configuration from the same sources -as :func:`caldav.get_davclient`: explicit keyword arguments, then the -``CALDAV_URL`` / ``CALDAV_USERNAME`` / ``CALDAV_PASSWORD`` environment variables, -then a config file. If none of those are set it returns ``None``. +Configuration +============= -With environment variables or a config file in place, no arguments are needed: +Unlike calendaring-jmap used standalone (which reads ``JMAP_*`` env vars and +its own config file), :func:`~caldav.jmap.get_jmap_client` reads +configuration from the same sources as :func:`caldav.get_davclient`: explicit +keyword arguments, then ``CALDAV_URL`` / ``CALDAV_USERNAME`` / +``CALDAV_PASSWORD`` environment variables, then a config file, the same file +CalDAV settings live in, so both protocols can share one config. See +:doc:`configfile` for file locations and section options. .. code-block:: python client = get_jmap_client() # reads env vars or config file -Authentication -============== - -HTTP Basic auth is used when a ``username`` is supplied alongside a ``password``. -Bearer token auth is used when only a ``password`` (token) is given and no username. -You can also pass any ``requests``-compatible auth object directly via the ``auth`` -parameter (niquests is API-compatible with requests). - -.. code-block:: python - - # Basic auth - client = get_jmap_client( - url="https://jmap.example.com/.well-known/jmap", - username="alice", - password="secret", - ) - - # Bearer token (password argument holds the token; no username supplied) - client = get_jmap_client( - url="https://jmap.example.com/.well-known/jmap", - password="my-bearer-token", - ) - - # Pre-built auth object - try: - from niquests.auth import HTTPBasicAuth - except ImportError: - from requests.auth import HTTPBasicAuth - client = get_jmap_client( - url="https://jmap.example.com/.well-known/jmap", - auth=HTTPBasicAuth("alice", "secret"), - ) - -Unlike CalDAV, JMAP does not use a 401-challenge-retry dance — credentials are sent -on every request, and a 401 or 403 is a hard :class:`~caldav.jmap.error.JMAPAuthError`. - -The client holds a persistent HTTP session so connections are reused between calls, -so it is worth releasing it when you are done — either with a context manager or by -calling ``close()`` (``aclose()`` on the async client): - -.. code-block:: python - - with get_jmap_client(...) as client: - calendars = client.get_calendars() - -Listing Calendars -================= - -.. code-block:: python - - calendars = client.get_calendars() - for cal in calendars: - print(cal.id, cal.name, cal.color) - -Each item is a :class:`~caldav.jmap.objects.calendar.JMAPCalendar` dataclass. -The fields are ``id``, ``name``, ``description``, ``color`` (CSS string or ``None``), -``is_subscribed``, ``my_rights`` (dict), ``sort_order``, and ``is_visible``. - -Working with Events -=================== - -Events are passed as iCalendar strings — the same format used by the CalDAV client -— so existing iCalendar-producing code works unchanged. - -The calendar-scoped API mirrors :class:`caldav.collection.Calendar`: - -.. code-block:: python - - cal = calendars[0] - - ical = ( - "BEGIN:VCALENDAR\r\n" - "VERSION:2.0\r\n" - "PRODID:-//example//EN\r\n" - "BEGIN:VEVENT\r\n" - "UID:meeting-2026-01-15@example.com\r\n" - "SUMMARY:Team meeting\r\n" - "DTSTART:20260115T100000Z\r\n" - "DTEND:20260115T110000Z\r\n" - "END:VEVENT\r\n" - "END:VCALENDAR\r\n" - ) - - # Add an event to this calendar — returns the server-assigned JMAP event ID - event_id = cal.add_event(ical) - - # Look up an event by its iCalendar UID — returns a VCALENDAR string - ical_str = cal.get_object_by_uid("meeting-2026-01-15@example.com") - -If you already have a JMAP event ID (from :meth:`~caldav.jmap.client.JMAPClient.get_sync_token` -results, for example), you can also use the lower-level client methods directly: - -.. code-block:: python - - # Fetch by JMAP event ID — returns a VCALENDAR string - ical_str = client.get_event(event_id) - - # Update — pass a complete VCALENDAR string with the changes applied - updated = ical_str.replace("Team meeting", "Team standup") - client.update_event(event_id, updated) - - # Delete - client.delete_event(event_id) - -Searching Events -================ - -Use :meth:`~caldav.jmap.objects.calendar.JMAPCalendar.search` on a calendar object, -mirroring the CalDAV :meth:`caldav.collection.Calendar.search` interface: - -.. code-block:: python - - cal = calendars[0] - - # All events in this calendar - results = cal.search(event=True) - - # Time-range filter: events that overlap [start, end) - # start — only events ending after this datetime - # end — only events starting before this datetime - results = cal.search( - event=True, - start="2026-01-01T00:00:00", - end="2026-02-01T00:00:00", - ) - - # Free-text search across title, description, locations, and participants - results = cal.search(text="standup") - - for ical_str in results: - print(ical_str) - -All parameters are optional; omitting all returns every event in the calendar. -Results are returned as a list of VCALENDAR strings. The search uses a single batched -JMAP request (``CalendarEvent/query`` + result reference into ``CalendarEvent/get``), -so only one HTTP round-trip is made regardless of how many events match. - -Incremental Sync -================ - -JMAP's state-based sync lets you fetch only what changed since the last call, without -scanning the full calendar: - -.. code-block:: python - - # Record the current state - token = client.get_sync_token() - - # ... time passes, events are created/modified/deleted ... - - # Fetch only the delta - added, modified, deleted, token = client.get_objects_by_sync_token(token) - - for ical_str in added: - print("New:", ical_str) - for ical_str in modified: - print("Updated:", ical_str) - for event_id in deleted: - print("Deleted ID:", event_id) - -``added`` and ``modified`` are lists of VCALENDAR strings. ``deleted`` is a list -of event IDs — the objects no longer exist on the server, so their data cannot be -fetched. The fourth element is the server's new sync token; chaining straight -from it avoids the race window a separate -:meth:`~caldav.jmap.client.JMAPClient.get_sync_token` round-trip would open. - -:meth:`~caldav.jmap.client.JMAPClient.get_objects_by_sync_token` raises -:class:`~caldav.jmap.error.JMAPMethodError` (``error_type="serverPartialFail"``) if -the server truncated the change list (``hasMoreChanges: true``). If this happens, -call :meth:`~caldav.jmap.client.JMAPClient.get_sync_token` to establish a fresh -baseline and re-sync from scratch. - -A typical pattern is to persist the token between runs: - -.. code-block:: python - - import json - import pathlib - - TOKEN_FILE = pathlib.Path("sync_token.json") - - def load_token(): - if TOKEN_FILE.exists(): - return json.loads(TOKEN_FILE.read_text())["token"] - return None - - def save_token(token): - TOKEN_FILE.write_text(json.dumps({"token": token})) - - token = load_token() - if token is None: - token = client.get_sync_token() - save_token(token) - else: - added, modified, deleted, token = client.get_objects_by_sync_token(token) - # process changes ... - save_token(token) - -Tasks -===== - -Task support requires a server implementing ``urn:ietf:params:jmap:tasks`` -(the JMAP Tasks specification). If the server does not support this capability, -:meth:`~caldav.jmap.client.JMAPClient.get_task_lists` will raise -:class:`~caldav.jmap.error.JMAPMethodError`. - -.. code-block:: python - - # List task lists - task_lists = client.get_task_lists() - for tl in task_lists: - print(tl.id, tl.name) - - task_list_id = task_lists[0].id - - # Create a task — title is required; everything else is optional - task_id = client.create_task( - task_list_id, - title="Review pull request", - due="2026-02-15T17:00:00", - time_zone="Europe/Oslo", - ) - - # Fetch — returns a JMAPTask dataclass - task = client.get_task(task_id) - print(task.title) # str - print(task.progress) # "needs-action" (default) - print(task.percent_complete) # 0 (default) - - # Update — pass a partial patch dict using JMAP wire property names - client.update_task(task_id, {"progress": "completed", "percentComplete": 100}) - - # Delete - client.delete_task(task_id) - -Optional kwargs for :meth:`~caldav.jmap.client.JMAPClient.create_task`: -``description``, ``start``, ``due``, ``time_zone``, ``estimated_duration``, -``percent_complete``, ``progress``, ``priority``. - -Each item from :meth:`~caldav.jmap.client.JMAPClient.get_task` is a -:class:`~caldav.jmap.objects.task.JMAPTask` with fields ``id``, ``uid``, -``task_list_id``, ``title``, ``description``, ``start``, ``due``, ``time_zone``, -``estimated_duration``, ``percent_complete``, ``progress``, ``progress_updated``, -``priority``, ``is_draft``, ``keywords``, ``recurrence_rules``, -``recurrence_overrides``, ``alerts``, ``participants``, ``color``, ``privacy``. - -Each item from :meth:`~caldav.jmap.client.JMAPClient.get_task_lists` is a -:class:`~caldav.jmap.objects.task.JMAPTaskList` with fields ``id``, ``name``, -``description``, ``color``, ``is_subscribed``, ``my_rights``, ``sort_order``, -``time_zone``, ``role`` (``"inbox"``, ``"trash"``, or ``None``). - -Async API -========= - -:class:`~caldav.jmap.async_client.AsyncJMAPClient` mirrors every method of -:class:`~caldav.jmap.client.JMAPClient` as a coroutine. Use it as an -``async with`` context manager (sync ``with`` is not supported): - -.. code-block:: python - - import asyncio - from caldav.jmap import get_async_jmap_client - - async def main(): - async with get_async_jmap_client( - url="https://jmap.example.com/.well-known/jmap", - username="alice", - password="secret", - ) as client: - calendars = await client.get_calendars() - for cal in calendars: - print(cal.name) - - # Calendar-scoped methods return coroutines when the calendar - # was obtained from an async client - cal = calendars[0] - results = await cal.search(event=True) - ical_str = await cal.get_object_by_uid("some-uid@example.com") - event_id = await cal.add_event(ical) - - asyncio.run(main()) - -All methods — event CRUD, search, sync, and task operations — are available as -coroutines with identical signatures. The async client uses ``niquests.AsyncSession`` -internally; ``niquests`` is a required dependency. - Error Handling ============== -All JMAP errors extend :class:`~caldav.jmap.error.JMAPError`, which itself extends -:class:`~caldav.lib.error.DAVError`. Existing CalDAV error handlers will catch JMAP -errors too if they catch ``DAVError``. +JMAP errors raised through ``caldav.jmap`` (:class:`~caldav.jmap.JMAPError` +and subclasses) are also :class:`caldav.lib.error.DAVError` subclasses, so +existing ``except DAVError`` handling around CalDAV code catches JMAP errors +too: .. code-block:: python from caldav.lib.error import DAVError - from caldav.jmap.error import JMAPAuthError, JMAPCapabilityError, JMAPMethodError try: - event_id = client.create_event(calendar_id, ical) - except JMAPAuthError: - print("Authentication failed (401/403)") - except JMAPCapabilityError: - print("Server does not support urn:ietf:params:jmap:calendars") - except JMAPMethodError as e: - print(f"Server rejected the request: {e.error_type} — {e.reason}") + client.get_calendars() except DAVError as e: - print(f"Protocol error: {e}") - -The three specific error classes: - -* :class:`~caldav.jmap.error.JMAPAuthError` — HTTP 401 or 403. JMAP sends no - 401-challenge, so this is always a hard failure. -* :class:`~caldav.jmap.error.JMAPCapabilityError` — the server's Session object - does not advertise ``urn:ietf:params:jmap:calendars``. -* :class:`~caldav.jmap.error.JMAPMethodError` — a JMAP method call returned an error - response. The ``error_type`` attribute holds the :rfc:`8620` error type string - (e.g. ``"invalidArguments"``, ``"notFound"``, ``"stateMismatch"``). - -Configuration File -================== - -The JMAP client reads from the same configuration file as the CalDAV client. -Connection parameters use the ``caldav_`` prefix: - -.. code-block:: yaml - - --- - default: - caldav_url: https://jmap.example.com/.well-known/jmap - caldav_username: alice - caldav_password: secret - -With the file in place, no arguments are needed: - -.. code-block:: python - - client = get_jmap_client() - -JMAP and CalDAV settings can coexist in the same file using separate named sections: - -.. code-block:: yaml - - --- - default: - caldav_url: https://caldav.example.com - caldav_username: alice - caldav_password: secret - - jmap: - caldav_url: https://jmap.example.com/.well-known/jmap - caldav_username: alice - caldav_password: secret - protocol: jmap - -.. code-block:: python - - from caldav.jmap import get_jmap_client - client = get_jmap_client(config_section="jmap") - -See :doc:`configfile` for file locations, section inheritance, and other options. + print(f"JMAP request failed: {e}") API Reference ============= -* :doc:`caldav/jmap_client` — :class:`~caldav.jmap.client.JMAPClient` and - :class:`~caldav.jmap.async_client.AsyncJMAPClient` full method reference -* :doc:`caldav/jmap_objects` — :class:`~caldav.jmap.objects.calendar.JMAPCalendar`, - :class:`~caldav.jmap.objects.event.JMAPEvent`, - :class:`~caldav.jmap.objects.task.JMAPTask`, - :class:`~caldav.jmap.objects.task.JMAPTaskList`, and error classes +* :doc:`caldav/jmap_wrapper`: the wrapper's own surface (:func:`~caldav.jmap.get_jmap_client`, + :func:`~caldav.jmap.get_async_jmap_client`, and the ``DAVError``-compatible error classes) +* `calendaring-jmap reference docs `_: + full ``JMAPClient``/``AsyncJMAPClient`` method reference, ``JMAPCalendar``, ``JMAPCalendarObject`` diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 1074d5f8..5b52db1f 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -19,5 +19,4 @@ Contents caldav/davobject caldav/collection caldav/calendarobjectresource - caldav/jmap_client - caldav/jmap_objects + caldav/jmap_wrapper diff --git a/docs/source/v3-migration.rst b/docs/source/v3-migration.rst index 238608bb..66fec467 100644 --- a/docs/source/v3-migration.rst +++ b/docs/source/v3-migration.rst @@ -379,7 +379,8 @@ JMAP client (experimental) A new ``caldav.jmap`` package provides ``JMAPClient`` and ``AsyncJMAPClient`` for servers implementing :rfc:`8620` (JMAP Core) and :rfc:`8984` (JMAP Calendars). -The public API may change in minor releases. See :doc:`jmap`. +The public API may change in minor releases. ``caldav.jmap`` is now a thin +wrapper around the standalone ``calendaring-jmap`` package. See :doc:`jmap`. Advanced search --------------- diff --git a/pyproject.toml b/pyproject.toml index 7a16e6fd..a381c7cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,10 @@ Changelog = "https://github.com/python-caldav/caldav/blob/master/CHANGELOG.md" ## behaviour, so they can depend on it now and change nothing later. See ## docs/source/http-libraries.rst niquests = ["niquests"] +## caldav.jmap is a thin wrapper around the standalone calendaring-jmap +## package (JMAP support was extracted out of this repo, see CHANGELOG). +## Optional because caldav/__init__.py never imports caldav.jmap eagerly. +jmap = ["calendaring-jmap>=1.1.0"] test = [ "vobject", "pytest", @@ -130,7 +134,7 @@ test = [ ignore = ["DEP002"] # Test dependencies (pytest, coverage, etc.) are not imported in main code [tool.deptry.per_rule_ignores] -DEP001 = ["conf", "h2"] # conf: Local test config, h2: Optional HTTP/2 support. +DEP001 = ["conf", "h2", "calendaring_jmap"] # conf: Local test config, h2: Optional HTTP/2 support, calendaring_jmap: optional dep for caldav.jmap. ## httpxyz needed an entry here while it was imported by name; the httpx-family ## libraries are now imported dynamically (see _ASYNC_HTTPX_CANDIDATES), which ## deptry does not see at all. @@ -202,6 +206,10 @@ filterwarnings = [ # Show conf_private.py deprecation warning once (not as error) during migration period "once:conf_private.py is deprecated:DeprecationWarning", + # caldav.jmap is deprecated in favour of the standalone calendaring-jmap + # package (see caldav/jmap/__init__.py); show it once, not as an error + "once:caldav.jmap is deprecated:DeprecationWarning", + # Zimbra test server uses HTTPS with self-signed cert (ssl_verify_cert=False) "ignore:Unverified HTTPS request:urllib3_future.exceptions.InsecureRequestWarning", ] diff --git a/tests/test_http_libraries.py b/tests/test_http_libraries.py index 52512577..7a37613c 100644 --- a/tests/test_http_libraries.py +++ b/tests/test_http_libraries.py @@ -24,11 +24,10 @@ ) ## Modules that reach the HTTP-library import on their own, so the message has -## to come out of their own import. caldav.jmap.client and caldav.jmap.session -## are deliberately absent: importing either runs caldav/jmap/__init__.py -## first, which imports async_client -> http_sync, so the error never comes -## from the module under test and the case would pass even if the module were -## reverted. TestOnlyOneModuleImportsTheHTTPLibrary is what covers those two. +## to come out of their own import. caldav.jmap.* is deliberately absent: +## since caldav/jmap became a thin wrapper around the standalone +## calendaring-jmap package, its HTTP-library selection is calendaring-jmap's +## own concern, not caldav's. SYNC_MODULES = [ "caldav.davclient", "caldav.discovery", @@ -182,29 +181,29 @@ def test_async_candidates_is_niquests_then_the_httpx_family(self) -> None: class TestRequiredLibraryMessage: """A library that has no fallback needs different wording from "none of - them is installed" - the sync stack may well be running on requests.""" + them is installed" - the sync stack may well be running on requests. + + The helper has no caller in caldav today; its last one was + require_async_session(), removed when JMAP moved out into + calendaring-jmap. It is kept because the condition it describes recurs + whenever a component is built on one library, and the example below is + written as a hypothetical rather than naming something that no longer + exists.""" def test_names_the_required_library(self) -> None: - message = required_library_error("niquests", "the async JMAP client") + message = required_library_error("niquests", "some niquests-only component") assert "niquests" in message - assert "the async JMAP client" in message + assert "some niquests-only component" in message def test_does_not_claim_nothing_is_installed(self) -> None: - message = required_library_error("niquests", "the async JMAP client") + message = required_library_error("niquests", "some niquests-only component") assert "none of the supported" not in message def test_still_points_at_the_extra_and_the_docs(self) -> None: - message = required_library_error("niquests", "the async JMAP client") + message = required_library_error("niquests", "some niquests-only component") assert "caldav[niquests]" in message assert DOCS_URL in message - def test_jmap_async_client_uses_it(self) -> None: - """Only niquests blocked: the sync stack is fine on requests, so the - "nothing is installed" wording would be a lie.""" - message = _import_with_libraries_blocked("caldav.jmap.async_client", ("niquests",)) - assert "none of the supported" not in message - assert "niquests" in message - class TestAsyncOnlyInstall: """niquests gone, an httpx present, requests gone - the shape an async-only diff --git a/tests/test_jmap_integration.py b/tests/test_jmap_integration.py deleted file mode 100644 index 255d249e..00000000 --- a/tests/test_jmap_integration.py +++ /dev/null @@ -1,358 +0,0 @@ -""" -Integration tests for the caldav.jmap package against live JMAP servers. - -Cyrus (port 8802): - docker-compose -f tests/docker-test-servers/cyrus/docker-compose.yml up -d - -Stalwart (port 8806): - docker-compose -f tests/docker-test-servers/stalwart/docker-compose.yml up -d - ./tests/docker-test-servers/stalwart/setup_stalwart.sh - -Each server's test classes are skipped automatically when that server is not -reachable — no failure, no noise. -""" - -import socket -import uuid -from datetime import datetime, timedelta, timezone - -import pytest -import pytest_asyncio - -try: - from niquests.auth import HTTPBasicAuth -except ImportError: - from requests.auth import HTTPBasicAuth # type: ignore[no-redef] - -from caldav.jmap import AsyncJMAPClient, JMAPClient -from caldav.jmap.constants import CALENDAR_CAPABILITY -from caldav.jmap.convert import jscal_to_ical -from caldav.jmap.error import JMAPMethodError -from caldav.jmap.session import fetch_session - -CYRUS_HOST = "localhost" -CYRUS_PORT = 8802 -CYRUS_JMAP_URL = f"http://{CYRUS_HOST}:{CYRUS_PORT}/.well-known/jmap" -CYRUS_USERNAME = "user1" -CYRUS_PASSWORD = "x" - -STALWART_HOST = "localhost" -STALWART_PORT = 8809 -STALWART_JMAP_URL = f"http://{STALWART_HOST}:{STALWART_PORT}/.well-known/jmap" -STALWART_USERNAME = "testuser@example.org" -STALWART_PASSWORD = "testcaldav" - - -def _reachable(host: str, port: int) -> bool: - try: - with socket.create_connection((host, port), timeout=2): - return True - except OSError: - return False - - -_cyrus_up = _reachable(CYRUS_HOST, CYRUS_PORT) -_stalwart_up = _reachable(STALWART_HOST, STALWART_PORT) - -# Backward-compatible alias used by the module-level pytestmark below. -# The mark only gates tests that don't carry their own skipif marker. -pytestmark = pytest.mark.skipif( - not _cyrus_up, - reason=f"Cyrus Docker not reachable on {CYRUS_HOST}:{CYRUS_PORT} — " - "start it with: docker-compose -f tests/docker-test-servers/cyrus/docker-compose.yml up -d", -) - - -def _minimal_ical(title: str = "Test Event", start: datetime | None = None) -> str: - if start is None: - start = datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc) - end = start + timedelta(hours=1) - uid = str(uuid.uuid4()) - return ( - "BEGIN:VCALENDAR\r\n" - "VERSION:2.0\r\n" - "PRODID:-//test//test//EN\r\n" - "BEGIN:VEVENT\r\n" - f"UID:{uid}\r\n" - f"SUMMARY:{title}\r\n" - f"DTSTART:{start.strftime('%Y%m%dT%H%M%SZ')}\r\n" - f"DTEND:{end.strftime('%Y%m%dT%H%M%SZ')}\r\n" - "END:VEVENT\r\n" - "END:VCALENDAR\r\n" - ) - - -@pytest.fixture(scope="module") -def client(): - return JMAPClient(url=CYRUS_JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD) - - -@pytest.fixture(scope="module") -def session(): - return fetch_session(CYRUS_JMAP_URL, auth=HTTPBasicAuth(CYRUS_USERNAME, CYRUS_PASSWORD)) - - -@pytest.fixture(scope="module") -def calendar_id(client): - calendars = client.get_calendars() - assert calendars, "Cyrus did not provision any calendars for user1" - return calendars[0].id - - -@pytest.fixture -def created_event_id(client, calendar_id): - event_id = client.create_event(calendar_id, _minimal_ical("Integration Test Event")) - yield event_id - try: - client.delete_event(event_id) - except Exception: - pass - - -@pytest_asyncio.fixture -async def async_client(): - return AsyncJMAPClient(url=CYRUS_JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD) - - -@pytest_asyncio.fixture -async def async_calendar_id(async_client): - calendars = await async_client.get_calendars() - assert calendars, "Cyrus did not provision any calendars for user1" - return calendars[0].id - - -@pytest_asyncio.fixture -async def async_created_event_id(async_client, async_calendar_id): - event_id = await async_client.create_event( - async_calendar_id, _minimal_ical("Async Integration Test Event") - ) - yield event_id - try: - await async_client.delete_event(event_id) - except Exception: - pass - - -_stalwart_skip = pytest.mark.skipif( - not _stalwart_up, - reason=f"Stalwart Docker not reachable on {STALWART_HOST}:{STALWART_PORT} — " - "start it with: cd tests/docker-test-servers/stalwart && ./start.sh", -) - - -@pytest.fixture(scope="module") -def stalwart_client(): - return JMAPClient(url=STALWART_JMAP_URL, username=STALWART_USERNAME, password=STALWART_PASSWORD) - - -@pytest.fixture(scope="module") -def stalwart_calendar_id(stalwart_client): - calendars = stalwart_client.get_calendars() - assert calendars, "Stalwart did not return any calendars for user1" - return calendars[0].id - - -@pytest.fixture -def stalwart_event_id(stalwart_client, stalwart_calendar_id): - event_id = stalwart_client.create_event( - stalwart_calendar_id, _minimal_ical("Stalwart Test Event") - ) - yield event_id - try: - stalwart_client.delete_event(event_id) - except Exception: - pass - - -class TestJMAPSessionIntegration: - def test_session_fetch_returns_api_url(self, session): - assert session.api_url - assert session.api_url.startswith("http") - - def test_session_has_account_id(self, session): - assert session.account_id - - def test_session_has_calendar_capability(self, session): - assert CALENDAR_CAPABILITY in session.account_capabilities - - -class TestJMAPCalendarListIntegration: - def test_list_calendars_returns_list(self, client): - calendars = client.get_calendars() - assert isinstance(calendars, list) - - def test_calendars_have_id_and_name(self, client): - calendars = client.get_calendars() - assert len(calendars) >= 1, "Expected at least one calendar on Cyrus for user1" - for cal in calendars: - assert cal.id, f"Calendar missing id: {cal}" - assert cal.name, f"Calendar has empty name: {cal}" - - -class TestJMAPEventIntegration: - def test_event_create_get(self, client, created_event_id): - obj = client.get_event(created_event_id) - ical = jscal_to_ical(obj.get_data()) - assert "BEGIN:VCALENDAR" in ical - assert "Integration Test Event" in ical - - def test_event_update(self, client, created_event_id): - client.update_event(created_event_id, _minimal_ical("Updated Title")) - obj = client.get_event(created_event_id) - assert "Updated Title" in jscal_to_ical(obj.get_data()) - - def test_event_delete(self, client, calendar_id): - event_id = client.create_event(calendar_id, _minimal_ical("To Be Deleted")) - client.delete_event(event_id) - with pytest.raises(JMAPMethodError): - client.get_event(event_id) - - def test_event_query_time_range(self, client, calendar_id, created_event_id): - results = client.search_events( - calendar_id=calendar_id, - start="2026-06-01T00:00:00", - end="2026-06-02T00:00:00", - ) - assert len(results) >= 1 - assert any("Integration Test Event" in jscal_to_ical(r.get_data()) for r in results) - - def test_event_sync(self, client, calendar_id): - token_before = client.get_sync_token() - event_id = client.create_event(calendar_id, _minimal_ical("Sync Test Event")) - try: - added, _modified, _deleted, _new_token = client.get_objects_by_sync_token(token_before) - assert any("Sync Test Event" in jscal_to_ical(a.get_data()) for a in added) - finally: - client.delete_event(event_id) - - def test_ical_roundtrip(self, client, calendar_id): - start = datetime(2026, 7, 15, 9, 0, 0, tzinfo=timezone.utc) - event_id = client.create_event(calendar_id, _minimal_ical("Roundtrip Event", start=start)) - try: - fetched = jscal_to_ical(client.get_event(event_id).get_data()) - assert "Roundtrip Event" in fetched - assert "20260715" in fetched - finally: - client.delete_event(event_id) - - -class TestAsyncJMAPEventIntegration: - @pytest.mark.asyncio - async def test_event_create_get(self, async_client, async_created_event_id): - obj = await async_client.get_event(async_created_event_id) - ical = jscal_to_ical(obj.get_data()) - assert "BEGIN:VCALENDAR" in ical - assert "Async Integration Test Event" in ical - - @pytest.mark.asyncio - async def test_event_update(self, async_client, async_created_event_id): - await async_client.update_event( - async_created_event_id, _minimal_ical("Async Updated Title") - ) - obj = await async_client.get_event(async_created_event_id) - assert "Async Updated Title" in jscal_to_ical(obj.get_data()) - - @pytest.mark.asyncio - async def test_event_delete(self, async_client, async_calendar_id): - event_id = await async_client.create_event( - async_calendar_id, _minimal_ical("Async To Be Deleted") - ) - await async_client.delete_event(event_id) - with pytest.raises(JMAPMethodError): - await async_client.get_event(event_id) - - @pytest.mark.asyncio - async def test_event_query_time_range( - self, async_client, async_calendar_id, async_created_event_id - ): - results = await async_client.search_events( - calendar_id=async_calendar_id, - start="2026-06-01T00:00:00", - end="2026-06-02T00:00:00", - ) - assert len(results) >= 1 - assert any("Async Integration Test Event" in jscal_to_ical(r.get_data()) for r in results) - - @pytest.mark.asyncio - async def test_event_sync(self, async_client, async_calendar_id): - token_before = await async_client.get_sync_token() - event_id = await async_client.create_event( - async_calendar_id, _minimal_ical("Async Sync Test Event") - ) - try: - added, _modified, _deleted, _new_token = await async_client.get_objects_by_sync_token( - token_before - ) - assert any("Async Sync Test Event" in jscal_to_ical(a.get_data()) for a in added) - finally: - await async_client.delete_event(event_id) - - @pytest.mark.asyncio - async def test_ical_roundtrip(self, async_client, async_calendar_id): - start = datetime(2026, 7, 15, 9, 0, 0, tzinfo=timezone.utc) - event_id = await async_client.create_event( - async_calendar_id, _minimal_ical("Async Roundtrip Event", start=start) - ) - try: - fetched = jscal_to_ical((await async_client.get_event(event_id)).get_data()) - assert "Async Roundtrip Event" in fetched - assert "20260715" in fetched - finally: - await async_client.delete_event(event_id) - - -@_stalwart_skip -class TestStalwartJMAPCalendarListIntegration: - def test_list_calendars_returns_list(self, stalwart_client): - calendars = stalwart_client.get_calendars() - assert isinstance(calendars, list) - - def test_calendars_have_id_and_name(self, stalwart_client): - calendars = stalwart_client.get_calendars() - assert len(calendars) >= 1, "Expected at least one calendar on Stalwart for user1" - for cal in calendars: - assert cal.id, f"Calendar missing id: {cal}" - assert cal.name, f"Calendar has empty name: {cal}" - - -@_stalwart_skip -class TestStalwartJMAPEventIntegration: - def test_event_create_get(self, stalwart_client, stalwart_event_id): - obj = stalwart_client.get_event(stalwart_event_id) - ical = jscal_to_ical(obj.get_data()) - assert "BEGIN:VCALENDAR" in ical - assert "Stalwart Test Event" in ical - - def test_event_update(self, stalwart_client, stalwart_event_id): - stalwart_client.update_event(stalwart_event_id, _minimal_ical("Stalwart Updated Title")) - obj = stalwart_client.get_event(stalwart_event_id) - assert "Stalwart Updated Title" in jscal_to_ical(obj.get_data()) - - def test_event_delete(self, stalwart_client, stalwart_calendar_id): - event_id = stalwart_client.create_event( - stalwart_calendar_id, _minimal_ical("Stalwart To Be Deleted") - ) - stalwart_client.delete_event(event_id) - with pytest.raises(JMAPMethodError): - stalwart_client.get_event(event_id) - - def test_event_query_time_range(self, stalwart_client, stalwart_event_id): - # Stalwart does not support the inCalendars filter; query without calendar_id. - results = stalwart_client.search_events( - start="2026-06-01T00:00:00", - end="2026-06-02T00:00:00", - ) - assert len(results) >= 1 - assert any("Stalwart Test Event" in jscal_to_ical(r.get_data()) for r in results) - - def test_ical_roundtrip(self, stalwart_client, stalwart_calendar_id): - start = datetime(2026, 7, 15, 9, 0, 0, tzinfo=timezone.utc) - event_id = stalwart_client.create_event( - stalwart_calendar_id, _minimal_ical("Stalwart Roundtrip Event", start=start) - ) - try: - fetched = jscal_to_ical(stalwart_client.get_event(event_id).get_data()) - assert "Stalwart Roundtrip Event" in fetched - assert "20260715" in fetched - finally: - stalwart_client.delete_event(event_id) diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py deleted file mode 100644 index 7cf316ae..00000000 --- a/tests/test_jmap_unit.py +++ /dev/null @@ -1,3012 +0,0 @@ -""" -Unit tests for the caldav.jmap package. - -Rule: zero network calls, zero Docker dependency, all tests are fast. -External HTTP is mocked via unittest.mock wherever needed. -""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -try: - from niquests.auth import HTTPBasicAuth -except ImportError: - from requests.auth import HTTPBasicAuth # type: ignore[no-redef] - -_JMAP_URL = "http://localhost:8802/.well-known/jmap" -_API_URL = "http://localhost:8802/jmap/api" -_USERNAME = "user1" -_PASSWORD = "x" - -from caldav.jmap.error import ( - JMAPAuthError, - JMAPCapabilityError, - JMAPError, - JMAPMethodError, -) -from caldav.lib.error import AuthorizationError, DAVError - - -class TestJMAPErrorHierarchy: - def test_jmap_error_is_dav_error(self): - assert issubclass(JMAPError, DAVError) - - def test_jmap_capability_error_is_jmap_error(self): - assert issubclass(JMAPCapabilityError, JMAPError) - - def test_jmap_auth_error_is_authorization_error(self): - assert issubclass(JMAPAuthError, AuthorizationError) - - def test_jmap_auth_error_is_jmap_error(self): - assert issubclass(JMAPAuthError, JMAPError) - - def test_jmap_method_error_is_jmap_error(self): - assert issubclass(JMAPMethodError, JMAPError) - - def test_jmap_error_default_error_type(self): - e = JMAPError() - assert e.error_type == "serverError" - - def test_jmap_error_custom_error_type(self): - e = JMAPError(error_type="unknownMethod") - assert e.error_type == "unknownMethod" - - def test_jmap_error_str_contains_type(self): - e = JMAPError(url="http://example.com", reason="boom", error_type="invalidArguments") - s = str(e) - assert "invalidArguments" in s - assert "boom" in s - assert "http://example.com" in s - - def test_jmap_capability_error_default_type(self): - e = JMAPCapabilityError() - assert e.error_type == "capabilityNotSupported" - - def test_jmap_auth_error_default_type(self): - e = JMAPAuthError() - assert e.error_type == "forbidden" - - def test_jmap_method_error_custom_type(self): - e = JMAPMethodError(error_type="stateMismatch", reason="state changed") - assert e.error_type == "stateMismatch" - assert e.reason == "state changed" - - def test_jmap_error_catchable_as_dav_error(self): - with pytest.raises(DAVError): - raise JMAPMethodError(error_type="notFound") - - def test_jmap_auth_error_catchable_as_authorization_error(self): - with pytest.raises(AuthorizationError): - raise JMAPAuthError() - - -from caldav.jmap.constants import CALENDAR_CAPABILITY, TASK_CAPABILITY -from caldav.jmap.session import Session, fetch_session - -# Minimal valid Session JSON fixture -_SESSION_JSON = { - "apiUrl": _API_URL, - "state": "state-abc", - "capabilities": { - "urn:ietf:params:jmap:core": {"maxCallsInRequest": 32}, - CALENDAR_CAPABILITY: {}, - }, - "accounts": { - _USERNAME: { - "name": f"{_USERNAME}@example.com", - "isPersonalAccount": True, - "accountCapabilities": { - CALENDAR_CAPABILITY: {}, - }, - } - }, -} - - -def _make_mock_response(json_data, status_code=200): - mock_resp = MagicMock() - mock_resp.status_code = status_code - mock_resp.json.return_value = json_data - mock_resp.raise_for_status = MagicMock() - return mock_resp - - -class TestFetchSession: - def test_parses_api_url(self): - with patch("caldav.jmap.session.requests.get") as mock_get: - mock_get.return_value = _make_mock_response(_SESSION_JSON) - session = fetch_session(_JMAP_URL, auth=None) - assert session.api_url == _API_URL - - def test_parses_account_id(self): - with patch("caldav.jmap.session.requests.get") as mock_get: - mock_get.return_value = _make_mock_response(_SESSION_JSON) - session = fetch_session(_JMAP_URL, auth=None) - assert session.account_id == _USERNAME - - def test_parses_state(self): - with patch("caldav.jmap.session.requests.get") as mock_get: - mock_get.return_value = _make_mock_response(_SESSION_JSON) - session = fetch_session(_JMAP_URL, auth=None) - assert session.state == "state-abc" - - def test_parses_account_capabilities(self): - with patch("caldav.jmap.session.requests.get") as mock_get: - mock_get.return_value = _make_mock_response(_SESSION_JSON) - session = fetch_session(_JMAP_URL, auth=None) - assert CALENDAR_CAPABILITY in session.account_capabilities - - def test_raw_is_full_response(self): - with patch("caldav.jmap.session.requests.get") as mock_get: - mock_get.return_value = _make_mock_response(_SESSION_JSON) - session = fetch_session(_JMAP_URL, auth=None) - assert session.raw == _SESSION_JSON - - def test_raises_auth_error_on_401(self): - with patch("caldav.jmap.session.requests.get") as mock_get: - mock_get.return_value = _make_mock_response({}, status_code=401) - with pytest.raises(JMAPAuthError): - fetch_session(_JMAP_URL, auth=None) - - def test_raises_auth_error_on_403(self): - with patch("caldav.jmap.session.requests.get") as mock_get: - mock_get.return_value = _make_mock_response({}, status_code=403) - with pytest.raises(JMAPAuthError): - fetch_session(_JMAP_URL, auth=None) - - def test_raises_capability_error_when_no_calendar_account(self): - data = dict(_SESSION_JSON) - data["accounts"] = { - _USERNAME: { - "name": f"{_USERNAME}@example.com", - "isPersonalAccount": True, - "accountCapabilities": { - "urn:ietf:params:jmap:mail": {}, # no calendars - }, - } - } - with patch("caldav.jmap.session.requests.get") as mock_get: - mock_get.return_value = _make_mock_response(data) - with pytest.raises(JMAPCapabilityError): - fetch_session(_JMAP_URL, auth=None) - - def test_raises_capability_error_when_no_accounts(self): - data = dict(_SESSION_JSON) - data["accounts"] = {} - with patch("caldav.jmap.session.requests.get") as mock_get: - mock_get.return_value = _make_mock_response(data) - with pytest.raises(JMAPCapabilityError): - fetch_session(_JMAP_URL, auth=None) - - def test_raises_capability_error_when_missing_api_url(self): - data = dict(_SESSION_JSON) - del data["apiUrl"] - with patch("caldav.jmap.session.requests.get") as mock_get: - mock_get.return_value = _make_mock_response(data) - with pytest.raises(JMAPCapabilityError): - fetch_session(_JMAP_URL, auth=None) - - def test_picks_first_calendar_capable_account(self): - data = dict(_SESSION_JSON) - data["accounts"] = { - "user_mail_only": { - "name": "mailonly@example.com", - "isPersonalAccount": True, - "accountCapabilities": {"urn:ietf:params:jmap:mail": {}}, - }, - "user_calendar": { - "name": "calendar@example.com", - "isPersonalAccount": True, - "accountCapabilities": {CALENDAR_CAPABILITY: {}}, - }, - } - with patch("caldav.jmap.session.requests.get") as mock_get: - mock_get.return_value = _make_mock_response(data) - session = fetch_session(_JMAP_URL, auth=None) - assert session.account_id == "user_calendar" - - -from datetime import datetime, timezone - -from caldav.jmap.objects.calendar import JMAPCalendar -from caldav.jmap.objects.calendar_object import JMAPCalendarObject - -_CALENDAR_JSON_FULL = { - "id": "cal1", - "name": "Personal", - "description": "My personal calendar", - "color": "#3a86ff", - "isSubscribed": True, - "myRights": {"mayReadItems": True, "mayAddItems": True}, - "sortOrder": 1, - "isVisible": True, -} - -_CALENDAR_JSON_MINIMAL = { - "id": "cal2", - "name": "Work", -} - - -class TestJMAPCalendar: - def test_from_jmap_full(self): - cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_FULL) - assert cal.id == "cal1" - assert cal.name == "Personal" - assert cal.description == "My personal calendar" - assert cal.color == "#3a86ff" - assert cal.is_subscribed is True - assert cal.my_rights == {"mayReadItems": True, "mayAddItems": True} - assert cal.sort_order == 1 - assert cal.is_visible is True - - def test_from_jmap_minimal_uses_defaults(self): - cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_MINIMAL) - assert cal.id == "cal2" - assert cal.name == "Work" - assert cal.description is None - assert cal.color is None - assert cal.is_subscribed is True - assert cal.my_rights == {} - assert cal.sort_order == 0 - assert cal.is_visible is True - - def test_to_jmap_includes_required_fields(self): - cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_MINIMAL) - d = cal.to_jmap() - assert d["name"] == "Work" - assert "isSubscribed" in d - - def test_to_jmap_excludes_server_set_fields(self): - cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_FULL) - d = cal.to_jmap() - assert "id" not in d - assert "myRights" not in d - - def test_to_jmap_omits_none_optional_fields(self): - cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_MINIMAL) - d = cal.to_jmap() - assert "description" not in d - assert "color" not in d - - def test_to_jmap_includes_optional_when_set(self): - cal = JMAPCalendar.from_jmap(_CALENDAR_JSON_FULL) - d = cal.to_jmap() - assert d["description"] == "My personal calendar" - assert d["color"] == "#3a86ff" - - def test_from_jmap_ignores_unknown_keys(self): - data = dict(_CALENDAR_JSON_FULL) - data["unknownFutureField"] = "something" - cal = JMAPCalendar.from_jmap(data) - assert cal.id == "cal1" - - def test_from_jmap_raises_when_name_missing(self): - with pytest.raises(KeyError): - JMAPCalendar.from_jmap({"id": "cal3"}) - - _RAW_EVENT = { - "id": "ev1", - "uid": "test-uid@example.com", - "calendarIds": {"cal1": True}, - "title": "Staff Meeting", - "start": "2026-01-15T09:00:00", - "duration": "PT1H", - } - - def _query_get_response(self, items): - return { - "methodResponses": [ - [ - "CalendarEvent/query", - {"ids": [i["id"] for i in items], "queryState": "qs-1", "total": len(items)}, - "ev-query-0", - ], - [ - "CalendarEvent/get", - {"accountId": _USERNAME, "list": items, "notFound": []}, - "ev-get-1", - ], - ] - } - - def _set_response(self, created=None, notCreated=None): - return { - "methodResponses": [ - [ - "CalendarEvent/set", - { - "accountId": _USERNAME, - "created": created or {}, - "updated": {}, - "destroyed": [], - "notCreated": notCreated or {}, - "notUpdated": {}, - "notDestroyed": {}, - }, - "ev-set-create-0", - ] - ] - } - - def _capturing_calendar(self, monkeypatch, resp, calendar_id="cal1"): - captured = {} - client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) - client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-abc") - - def capturing_post(*args, **kwargs): - captured["json"] = kwargs.get("json", {}) - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = resp - mock_resp.raise_for_status = MagicMock() - return mock_resp - - mock_http = MagicMock() - mock_http.post.side_effect = capturing_post - client._http_session = mock_http - cal = JMAPCalendar(id=calendar_id, name="Test") - cal._client = client - cal._is_async = False - return cal, captured - - def test_calendar_search_returns_ical_list(self, monkeypatch): - event2 = {**self._RAW_EVENT, "id": "ev2", "title": "Standup"} - resp = self._query_get_response([self._RAW_EVENT, event2]) - cal = _make_calendar_with_client(monkeypatch, resp) - results = cal.search() - assert len(results) == 2 - assert all(isinstance(r, JMAPCalendarObject) for r in results) - assert all(r.parent is cal for r in results) - assert results[0].id == "ev1" - - def test_calendar_search_passes_calendar_id_filter(self, monkeypatch): - resp = self._query_get_response([self._RAW_EVENT]) - cal, captured = self._capturing_calendar(monkeypatch, resp, calendar_id="my-cal") - cal.search() - query_args = captured["json"]["methodCalls"][0][1] - assert query_args["filter"]["inCalendars"] == ["my-cal"] - - def test_calendar_search_with_date_range(self, monkeypatch): - resp = self._query_get_response([self._RAW_EVENT]) - cal, captured = self._capturing_calendar(monkeypatch, resp) - cal.search(start="2026-01-01T00:00:00", end="2026-12-31T23:59:59") - query_args = captured["json"]["methodCalls"][0][1] - assert query_args["filter"]["after"] == "2026-01-01T00:00:00" - assert query_args["filter"]["before"] == "2026-12-31T23:59:59" - - def test_calendar_search_datetime_converted_to_utcdate(self, monkeypatch): - """§4.6: datetime.isoformat() produced wrong format for JMAP UTCDate. - Naive datetimes produce no Z, aware non-UTC produce +HH:MM offset; - JMAP requires ...Z (UTC, no microseconds).""" - import datetime as _dt - - resp = self._query_get_response([self._RAW_EVENT]) - cal, captured = self._capturing_calendar(monkeypatch, resp) - tz_plus2 = _dt.timezone(_dt.timedelta(hours=2)) - start_aware = datetime(2026, 6, 1, 12, 0, 0, tzinfo=tz_plus2) # +02:00 noon → UTC 10:00 - end_utc = datetime(2026, 12, 31, 23, 59, 59, tzinfo=timezone.utc) - cal.search(start=start_aware, end=end_utc) - query_args = captured["json"]["methodCalls"][0][1] - assert query_args["filter"]["after"] == "2026-06-01T10:00:00Z", ( - f"Expected UTC Z-format, got {query_args['filter']['after']!r}" - ) - assert query_args["filter"]["before"] == "2026-12-31T23:59:59Z", ( - f"Expected UTC Z-format, got {query_args['filter']['before']!r}" - ) - - def test_calendar_search_ignores_unknown_params(self, monkeypatch): - """Verify that unknown search parameters are silently ignored.""" - resp = self._query_get_response([self._RAW_EVENT]) - cal, captured = self._capturing_calendar(monkeypatch, resp) - # Should not raise an error even with legacy/unknown parameters - cal.search(event=True, todo=False, unknown_param="value") - query_args = captured["json"]["methodCalls"][0][1] - # Should only contain the calendar filter, no unknown params - assert query_args["filter"] == {"inCalendars": [cal.id]} - - def test_calendar_search_with_text(self, monkeypatch): - resp = self._query_get_response([self._RAW_EVENT]) - cal, captured = self._capturing_calendar(monkeypatch, resp) - cal.search(text="standup") - query_args = captured["json"]["methodCalls"][0][1] - assert query_args["filter"]["text"] == "standup" - - def test_calendar_get_object_by_uid_found(self, monkeypatch): - resp = self._query_get_response([self._RAW_EVENT]) - cal = _make_calendar_with_client(monkeypatch, resp) - result = cal.get_object_by_uid("test-uid@example.com") - assert isinstance(result, JMAPCalendarObject) - assert result.id == "ev1" - assert result.get_data()["title"] == "Staff Meeting" - assert result.parent is cal - - def test_calendar_get_object_by_uid_not_found(self, monkeypatch): - resp = self._query_get_response([self._RAW_EVENT]) - cal = _make_calendar_with_client(monkeypatch, resp) - with pytest.raises(JMAPMethodError): - cal.get_object_by_uid("nonexistent-uid@example.com") - - def test_calendar_add_event_delegates_to_create_event(self, monkeypatch): - _MINIMAL_ICAL = ( - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" - "UID:test@example.com\r\nSUMMARY:Test\r\n" - "DTSTART:20260115T090000Z\r\nDTEND:20260115T100000Z\r\n" - "END:VEVENT\r\nEND:VCALENDAR\r\n" - ) - resp = self._set_response(created={"new-0": {"id": "sv-cal-1"}}) - cal, captured = self._capturing_calendar(monkeypatch, resp, calendar_id="my-calendar") - cal.add_event(_MINIMAL_ICAL) - create_args = captured["json"]["methodCalls"][0][1] - event_payload = create_args["create"]["new-0"] - assert event_payload.get("calendarIds") == {"my-calendar": True} - - -_MINIMAL_JSCAL_DICT = { - "id": "ev-obj-1", - "uid": "obj-uid@example.com", - "calendarIds": {"cal1": True}, - "title": "Object Test Event", - "start": "2026-03-01T10:00:00", - "timeZone": "Europe/Berlin", - "duration": "PT1H", -} - - -class TestJMAPCalendarObject: - def test_id_from_data(self): - obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None) - assert obj.id == "ev-obj-1" - - def test_get_data_returns_dict(self): - obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None) - assert obj.get_data() is _MINIMAL_JSCAL_DICT - - def test_get_icalendar_instance_returns_calendar(self): - import icalendar - - obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None) - cal = obj.get_icalendar_instance() - assert isinstance(cal, icalendar.Calendar) - - def test_get_icalendar_instance_is_cached(self): - obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None) - assert obj.get_icalendar_instance() is obj.get_icalendar_instance() - - def test_edit_icalendar_instance_yields_calendar(self): - import icalendar - - obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None) - with obj.edit_icalendar_instance() as cal: - assert isinstance(cal, icalendar.Calendar) - - def test_save_calls_update_event(self): - mock_client = MagicMock() - mock_parent = MagicMock() - mock_parent._is_async = False - mock_parent._client = mock_client - - obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=mock_parent) - with obj.edit_icalendar_instance(): - pass - obj.save() - - mock_client.update_event.assert_called_once() - call_args = mock_client.update_event.call_args - assert call_args[0][0] == "ev-obj-1" - assert isinstance(call_args[0][1], str) - - def test_save_raises_without_parent(self): - obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=None) - with pytest.raises(JMAPMethodError, match="no parent calendar"): - obj.save() - - def test_save_raises_for_async_parent(self): - mock_parent = MagicMock() - mock_parent._is_async = True - obj = JMAPCalendarObject(data=_MINIMAL_JSCAL_DICT, parent=mock_parent) - with pytest.raises(RuntimeError): - obj.save() - - -from caldav.jmap._methods.calendar import ( - build_calendar_changes, - build_calendar_get, - parse_calendar_get, -) - - -class TestCalendarMethodBuilders: - def test_build_calendar_get_structure(self): - method, args, call_id = build_calendar_get("u1") - assert method == "Calendar/get" - assert args["accountId"] == "u1" - assert args["ids"] is None - assert isinstance(call_id, str) - - def test_build_calendar_get_with_ids(self): - _, args, _ = build_calendar_get("u1", ids=["cal1", "cal2"]) - assert args["ids"] == ["cal1", "cal2"] - - def test_build_calendar_get_with_properties(self): - _, args, _ = build_calendar_get("u1", properties=["id", "name"]) - assert args["properties"] == ["id", "name"] - - def test_build_calendar_get_no_properties_key_when_not_set(self): - _, args, _ = build_calendar_get("u1") - assert "properties" not in args - - def test_parse_calendar_get_returns_calendars(self): - response_args = {"list": [_CALENDAR_JSON_FULL, _CALENDAR_JSON_MINIMAL]} - cals = parse_calendar_get(response_args) - assert len(cals) == 2 - assert isinstance(cals[0], JMAPCalendar) - assert cals[0].id == "cal1" - assert cals[1].id == "cal2" - - def test_parse_calendar_get_empty_list(self): - cals = parse_calendar_get({"list": []}) - assert cals == [] - - def test_parse_calendar_get_missing_list_key(self): - cals = parse_calendar_get({}) - assert cals == [] - - def test_build_calendar_changes_structure(self): - method, args, call_id = build_calendar_changes("u1", "state-abc") - assert method == "Calendar/changes" - assert args["accountId"] == "u1" - assert args["sinceState"] == "state-abc" - assert isinstance(call_id, str) - - -from caldav.jmap.client import JMAPClient - -_CALENDAR_GET_RESPONSE = { - "methodResponses": [ - [ - "Calendar/get", - { - "accountId": _USERNAME, - "state": "cal-state-1", - "list": [_CALENDAR_JSON_FULL, _CALENDAR_JSON_MINIMAL], - "notFound": [], - }, - "cal-get-0", - ] - ] -} - - -def _make_client_with_mocked_session(monkeypatch, api_response_json): - """Return a JMAPClient whose HTTP calls are fully mocked.""" - client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) - client._session_cache = Session( - api_url=_API_URL, - account_id=_USERNAME, - state="state-abc", - ) - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = api_response_json - mock_resp.raise_for_status = MagicMock() - mock_http = MagicMock() - mock_http.post.return_value = mock_resp - client._http_session = mock_http - return client - - -def _make_calendar_with_client(monkeypatch, api_response_json, calendar_id="cal1"): - """Return a JMAPCalendar backed by a fully mocked JMAPClient.""" - client = _make_client_with_mocked_session(monkeypatch, api_response_json) - cal = JMAPCalendar(id=calendar_id, name="Test") - cal._client = client - cal._is_async = False - return cal - - -class TestJMAPClient: - def test_context_manager(self): - with JMAPClient(url="http://x", username="u", password="p") as client: - assert isinstance(client, JMAPClient) - - def test_context_manager_closes_http_session(self): - mock_close = MagicMock() - mock_http = MagicMock() - mock_http.close = mock_close - with patch("caldav.jmap.client.requests.Session", return_value=mock_http): - client = JMAPClient(url="http://x", username="u", password="p") - with client: - assert client._http_session is mock_http - mock_close.assert_called_once() - assert client._http_session is None - - def test_http_session_reused_across_requests(self, monkeypatch): - client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) - client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="s") - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = {"methodResponses": []} - mock_resp.raise_for_status = MagicMock() - with patch("caldav.jmap.client.requests.Session") as MockSession: - mock_sess = MagicMock() - mock_sess.post.return_value = mock_resp - MockSession.return_value = mock_sess - client._request([("Calendar/get", {}, "c0")]) - client._request([("Calendar/get", {}, "c1")]) - MockSession.assert_called_once() - assert mock_sess.post.call_count == 2 - - def test_build_auth_basic_when_username_given(self): - client = JMAPClient(url="http://x", username="u", password="p") - assert isinstance(client._auth, HTTPBasicAuth) - - def test_build_auth_bearer_when_no_username(self): - from caldav.requests import HTTPBearerAuth - - client = JMAPClient(url="http://x", password="token") - assert isinstance(client._auth, HTTPBearerAuth) - - def test_build_auth_raises_when_no_credentials(self): - with pytest.raises(JMAPAuthError): - JMAPClient(url="http://x") - - def test_build_auth_explicit_bearer_type(self): - from caldav.requests import HTTPBearerAuth - - client = JMAPClient(url="http://x", username="u", password="token", auth_type="bearer") - assert isinstance(client._auth, HTTPBearerAuth) - - def test_build_auth_unsupported_type_raises(self): - with pytest.raises(JMAPAuthError): - JMAPClient(url="http://x", username="u", password="p", auth_type="digest") - - def test_build_auth_basic_without_username_raises(self): - with pytest.raises(JMAPAuthError): - JMAPClient(url="http://x", password="p", auth_type="basic") - - def test_build_auth_basic_without_password_raises(self): - with pytest.raises(JMAPAuthError): - JMAPClient(url="http://x", username="u", auth_type="basic") - - def test_build_auth_bearer_without_token_raises(self): - with pytest.raises(JMAPAuthError): - JMAPClient(url="http://x", username="u", auth_type="bearer") - - def test_get_calendars_returns_list(self, monkeypatch): - client = _make_client_with_mocked_session(monkeypatch, _CALENDAR_GET_RESPONSE) - cals = client.get_calendars() - assert len(cals) == 2 - assert isinstance(cals[0], JMAPCalendar) - assert cals[0].id == "cal1" - assert cals[1].id == "cal2" - - def test_get_calendars_empty_response(self, monkeypatch): - empty_response = { - "methodResponses": [ - ["Calendar/get", {"accountId": _USERNAME, "state": "s1", "list": []}, "c0"] - ] - } - client = _make_client_with_mocked_session(monkeypatch, empty_response) - assert client.get_calendars() == [] - - def test_request_raises_auth_error_on_401(self, monkeypatch): - client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) - client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="s") - - mock_resp = MagicMock() - mock_resp.status_code = 401 - mock_resp.raise_for_status = MagicMock() - mock_http = MagicMock() - mock_http.post.return_value = mock_resp - client._http_session = mock_http - - with pytest.raises(JMAPAuthError): - client._request([("Calendar/get", {"accountId": _USERNAME, "ids": None}, "c0")]) - - def test_request_raises_method_error_on_error_response(self, monkeypatch): - error_response = {"methodResponses": [["error", {"type": "unknownMethod"}, "c0"]]} - client = _make_client_with_mocked_session(monkeypatch, error_response) - with pytest.raises(JMAPMethodError) as exc_info: - client._request([("Calendar/get", {"accountId": _USERNAME}, "c0")]) - assert exc_info.value.error_type == "unknownMethod" - - -from caldav.jmap import get_jmap_client - - -class TestGetJMAPClient: - def test_returns_client_with_explicit_params(self): - client = get_jmap_client(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) - assert isinstance(client, JMAPClient) - assert client.url == _JMAP_URL - - def test_returns_none_when_no_config(self, monkeypatch): - monkeypatch.delenv("CALDAV_URL", raising=False) - client = get_jmap_client(check_config_file=False, environment=False) - assert client is None - - def test_strips_caldav_only_keys(self, monkeypatch): - client = get_jmap_client( - url=_JMAP_URL, - username=_USERNAME, - password=_PASSWORD, - ssl_verify_cert=True, - ) - assert isinstance(client, JMAPClient) - assert not hasattr(client, "ssl_verify_cert") - - -from caldav.jmap._methods.event import ( - build_event_changes, - build_event_get, - build_event_query, - build_event_query_changes, - build_event_set_create, - build_event_set_destroy, - build_event_set_update, - parse_event_changes, - parse_event_get, - parse_event_query, - parse_event_set, -) -from caldav.jmap._methods.task import ( - build_task_get, - build_task_list_get, - build_task_set_create, - build_task_set_destroy, - build_task_set_update, - parse_task_get, - parse_task_list_get, - parse_task_set, -) - - -class TestEventMethodBuilders: - def test_build_event_get_structure(self): - method, args, call_id = build_event_get("u1") - assert method == "CalendarEvent/get" - assert args["accountId"] == "u1" - assert args["ids"] is None - assert isinstance(call_id, str) - - def test_build_event_get_with_ids(self): - _, args, _ = build_event_get("u1", ids=["ev1", "ev2"]) - assert args["ids"] == ["ev1", "ev2"] - - def test_build_event_get_with_properties(self): - _, args, _ = build_event_get("u1", properties=["id", "title", "start"]) - assert args["properties"] == ["id", "title", "start"] - - def test_build_event_get_no_properties_key_when_not_set(self): - _, args, _ = build_event_get("u1") - assert "properties" not in args - - def test_parse_event_get_returns_events(self): - event_dict = { - "id": "ev1", - "uid": "abc@example.com", - "calendarIds": {"cal1": True}, - "title": "Test", - "start": "2024-01-01T09:00:00", - } - response_args = {"list": [event_dict]} - events = parse_event_get(response_args) - assert len(events) == 1 - assert isinstance(events[0], dict) - assert events[0]["id"] == "ev1" - - def test_parse_event_get_empty_list(self): - assert parse_event_get({"list": []}) == [] - - def test_parse_event_get_missing_list_key(self): - assert parse_event_get({}) == [] - - def test_build_event_changes_structure(self): - method, args, call_id = build_event_changes("u1", "state-abc") - assert method == "CalendarEvent/changes" - assert args["accountId"] == "u1" - assert args["sinceState"] == "state-abc" - assert isinstance(call_id, str) - - def test_build_event_changes_with_max_changes(self): - _, args, _ = build_event_changes("u1", "state-abc", max_changes=50) - assert args["maxChanges"] == 50 - - def test_build_event_changes_no_max_changes_key_when_not_set(self): - _, args, _ = build_event_changes("u1", "state-abc") - assert "maxChanges" not in args - - def test_build_event_query_structure(self): - method, args, call_id = build_event_query("u1") - assert method == "CalendarEvent/query" - assert args["accountId"] == "u1" - assert args["position"] == 0 - assert isinstance(call_id, str) - - def test_build_event_query_with_filter(self): - f = {"after": "2024-01-01T00:00:00Z", "before": "2024-12-31T23:59:59Z"} - _, args, _ = build_event_query("u1", filter_condition=f) - assert args["filter"] == f - - def test_build_event_query_with_sort(self): - s = [{"property": "start", "isAscending": True}] - _, args, _ = build_event_query("u1", sort=s) - assert args["sort"] == s - - def test_build_event_query_with_limit(self): - _, args, _ = build_event_query("u1", limit=100) - assert args["limit"] == 100 - - def test_build_event_query_no_optional_keys_when_not_set(self): - _, args, _ = build_event_query("u1") - assert "filter" not in args - assert "sort" not in args - assert "limit" not in args - - def test_parse_event_query_returns_ids_state_total(self): - response_args = { - "ids": ["ev1", "ev2", "ev3"], - "queryState": "qstate-1", - "total": 10, - } - ids, query_state, total = parse_event_query(response_args) - assert ids == ["ev1", "ev2", "ev3"] - assert query_state == "qstate-1" - assert total == 10 - - def test_parse_event_query_total_defaults_to_ids_length(self): - response_args = {"ids": ["ev1", "ev2"], "queryState": "q1"} - ids, _, total = parse_event_query(response_args) - assert total == 2 - - def test_parse_event_query_empty_response(self): - ids, query_state, total = parse_event_query({}) - assert ids == [] - assert query_state == "" - assert total == 0 - - def test_build_event_query_changes_structure(self): - method, args, call_id = build_event_query_changes("u1", "qstate-1") - assert method == "CalendarEvent/queryChanges" - assert args["accountId"] == "u1" - assert args["sinceQueryState"] == "qstate-1" - assert isinstance(call_id, str) - - def test_build_event_query_changes_with_filter_and_sort(self): - f = {"calendarIds": {"cal1": True}} - s = [{"property": "start", "isAscending": True}] - _, args, _ = build_event_query_changes("u1", "qstate-1", filter_condition=f, sort=s) - assert args["filter"] == f - assert args["sort"] == s - - def test_build_event_set_create_structure(self): - ev = { - "uid": "abc@example.com", - "calendarIds": {"cal1": True}, - "title": "Test", - "start": "2024-01-01T09:00:00", - } - method, args, call_id = build_event_set_create("u1", {"new-1": ev}) - assert method == "CalendarEvent/set" - assert "create" in args - assert "new-1" in args["create"] - assert "id" not in args["create"]["new-1"] - - def test_build_event_set_update_structure(self): - method, args, call_id = build_event_set_update("u1", {"ev1": {"title": "Updated title"}}) - assert method == "CalendarEvent/set" - assert args["update"] == {"ev1": {"title": "Updated title"}} - - def test_build_event_set_destroy_structure(self): - method, args, call_id = build_event_set_destroy("u1", ["ev1", "ev2"]) - assert method == "CalendarEvent/set" - assert args["destroy"] == ["ev1", "ev2"] - - def test_parse_event_set_created(self): - response_args = { - "created": {"new-1": {"id": "server-ev-99", "uid": "def456@example.com"}}, - "updated": None, - "destroyed": None, - } - created, updated, destroyed, not_created, not_updated, not_destroyed = parse_event_set( - response_args - ) - assert created["new-1"]["id"] == "server-ev-99" - assert updated == {} - assert destroyed == [] - assert not_created == {} - assert not_updated == {} - assert not_destroyed == {} - - def test_parse_event_set_destroyed(self): - response_args = {"created": None, "updated": None, "destroyed": ["ev1", "ev2"]} - created, updated, destroyed, not_created, not_updated, not_destroyed = parse_event_set( - response_args - ) - assert created == {} - assert updated == {} - assert destroyed == ["ev1", "ev2"] - assert not_created == {} - - def test_parse_event_set_empty_response(self): - created, updated, destroyed, not_created, not_updated, not_destroyed = parse_event_set({}) - assert created == {} - assert updated == {} - assert destroyed == [] - assert not_created == {} - assert not_updated == {} - assert not_destroyed == {} - - def test_parse_event_set_partial_failure(self): - # notCreated/notUpdated/notDestroyed carry SetError objects for failed operations - response_args = { - "created": {"new-1": {"id": "server-ev-99"}}, - "notCreated": {"new-2": {"type": "invalidArguments", "description": "bad uid"}}, - "notDestroyed": {"ev-old": {"type": "notFound"}}, - } - created, updated, destroyed, not_created, not_updated, not_destroyed = parse_event_set( - response_args - ) - assert "new-1" in created - assert not_created["new-2"]["type"] == "invalidArguments" - assert not_destroyed["ev-old"]["type"] == "notFound" - - -from datetime import date, timedelta - -import icalendar as _icalendar - -from caldav.jmap.convert import ical_to_jscal, jscal_to_ical -from caldav.jmap.convert._utils import ( - _duration_to_timedelta, - _format_local_dt, - _timedelta_to_duration, -) - - -def _make_ical(extra_lines: str = "", uid: str = "test-uid@example.com") -> str: - return ( - "BEGIN:VCALENDAR\r\n" - "VERSION:2.0\r\n" - "PRODID:-//Test//Test//EN\r\n" - "BEGIN:VEVENT\r\n" - f"UID:{uid}\r\n" - "DTSTAMP:20240101T000000Z\r\n" + extra_lines + "END:VEVENT\r\n" - "END:VCALENDAR\r\n" - ) - - -def _minimal_jscal(**kwargs) -> dict: - base = { - "uid": "test-uid@example.com", - "title": "Test Event", - "start": "2024-06-15T10:00:00", - "timeZone": "Europe/Berlin", - "duration": "PT1H", - } - base.update(kwargs) - return base - - -class TestUtils: - def test_timedelta_to_duration_hours(self): - assert _timedelta_to_duration(timedelta(hours=1, minutes=30)) == "PT1H30M" - - def test_timedelta_to_duration_days(self): - assert _timedelta_to_duration(timedelta(days=1)) == "P1D" - - def test_timedelta_to_duration_mixed(self): - assert _timedelta_to_duration(timedelta(days=1, hours=2)) == "P1DT2H" - - def test_timedelta_to_duration_zero(self): - assert _timedelta_to_duration(timedelta(0)) == "P0D" - - def test_timedelta_to_duration_negative(self): - assert _timedelta_to_duration(timedelta(seconds=-900)) == "-PT15M" - - def test_duration_to_timedelta_hours(self): - assert _duration_to_timedelta("PT1H30M") == timedelta(hours=1, minutes=30) - - def test_duration_to_timedelta_days(self): - assert _duration_to_timedelta("P1D") == timedelta(days=1) - - def test_duration_to_timedelta_zero(self): - assert _duration_to_timedelta("P0D") == timedelta(0) - - def test_duration_to_timedelta_negative(self): - assert _duration_to_timedelta("-PT15M") == timedelta(seconds=-900) - - def test_duration_round_trip(self): - td = timedelta(days=2, hours=3, minutes=45, seconds=30) - assert _duration_to_timedelta(_timedelta_to_duration(td)) == td - - def test_format_local_dt_utc(self): - # RFC 8984: LocalDateTime slots (override keys, RRULE until) must not carry Z suffix. - dt = datetime(2024, 6, 15, 9, 0, 0, tzinfo=timezone.utc) - assert _format_local_dt(dt) == "2024-06-15T09:00:00" - - def test_format_local_dt_naive(self): - dt = datetime(2024, 6, 15, 9, 0, 0) - assert _format_local_dt(dt) == "2024-06-15T09:00:00" - - def test_format_local_dt_date(self): - d = date(2024, 6, 15) - assert _format_local_dt(d) == "2024-06-15T00:00:00" - - -class TestIcalToJscal: - def test_minimal_event(self): - ical = _make_ical("DTSTART:20240615T100000Z\r\nDURATION:PT1H\r\nSUMMARY:Test Event\r\n") - result = ical_to_jscal(ical) - assert result["@type"] == "Event" - assert result["uid"] == "test-uid@example.com" - assert result["title"] == "Test Event" - assert result["start"] == "2024-06-15T10:00:00" - assert result["timeZone"] == "Etc/UTC" - assert result["duration"] == "PT1H" - - def test_all_day_event(self): - ical = _make_ical( - "DTSTART;VALUE=DATE:20240615\r\nDTEND;VALUE=DATE:20240616\r\nSUMMARY:All Day\r\n" - ) - result = ical_to_jscal(ical) - assert result["start"] == "2024-06-15T00:00:00" - assert result["showWithoutTime"] is True - assert "timeZone" not in result - assert result["duration"] == "P1D" - - def test_timezone_aware_event(self): - ical = _make_ical( - "DTSTART;TZID=America/New_York:20240615T100000\r\nDURATION:PT1H\r\nSUMMARY:TZ Event\r\n" - ) - result = ical_to_jscal(ical) - assert result["start"] == "2024-06-15T10:00:00" - assert result["timeZone"] == "America/New_York" - assert "showWithoutTime" not in result - - def test_utc_event(self): - ical = _make_ical("DTSTART:20240615T100000Z\r\nDURATION:PT30M\r\nSUMMARY:UTC Event\r\n") - result = ical_to_jscal(ical) - assert result["start"] == "2024-06-15T10:00:00" - assert result["timeZone"] == "Etc/UTC" - - def test_duration_from_dtend(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\nDTEND:20240615T113000Z\r\nSUMMARY:DTEND Event\r\n" - ) - result = ical_to_jscal(ical) - assert result["duration"] == "PT1H30M" - - def test_duration_explicit(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\nDURATION:P1DT2H\r\nSUMMARY:Duration Event\r\n" - ) - result = ical_to_jscal(ical) - assert result["duration"] == "P1DT2H" - - def test_duration_zero_when_missing(self): - ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:No Duration\r\n") - result = ical_to_jscal(ical) - assert result["duration"] == "P0D" - - def test_categories_to_keywords(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\nSUMMARY:Cat Event\r\nCATEGORIES:work,standup\r\n" - ) - result = ical_to_jscal(ical) - assert "keywords" in result - assert result["keywords"].get("work") is True - assert result["keywords"].get("standup") is True - - def test_categories_multiple_lines(self): - # Two separate CATEGORIES lines — icalendar returns a list of vCategory objects - ical = _make_ical( - "DTSTART:20240615T100000Z\r\nSUMMARY:Cat Event\r\n" - "CATEGORIES:Work\r\nCATEGORIES:Standup\r\n" - ) - result = ical_to_jscal(ical) - assert "keywords" in result - assert result["keywords"].get("Work") is True - assert result["keywords"].get("Standup") is True - - def test_location_string(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\nSUMMARY:Located Event\r\nLOCATION:Conference Room A\r\n" - ) - result = ical_to_jscal(ical) - assert "locations" in result - locs = result["locations"] - assert len(locs) == 1 - first_loc = next(iter(locs.values())) - assert first_loc["name"] == "Conference Room A" - - def test_priority(self): - ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:Priority Event\r\nPRIORITY:5\r\n") - result = ical_to_jscal(ical) - assert result["priority"] == 5 - - def test_class_private(self): - ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:Private Event\r\nCLASS:PRIVATE\r\n") - result = ical_to_jscal(ical) - assert result["privacy"] == "private" - - def test_class_confidential(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\nSUMMARY:Confidential Event\r\nCLASS:CONFIDENTIAL\r\n" - ) - result = ical_to_jscal(ical) - assert result["privacy"] == "secret" - - def test_transp_transparent(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\nSUMMARY:Free Event\r\nTRANSP:TRANSPARENT\r\n" - ) - result = ical_to_jscal(ical) - assert result["freeBusyStatus"] == "free" - - def test_rrule_weekly(self): - ical = _make_ical( - "DTSTART;TZID=Europe/Berlin:20240617T140000\r\n" - "DURATION:PT1H\r\n" - "SUMMARY:Team Meeting\r\n" - "RRULE:FREQ=WEEKLY;BYDAY=MO,WE\r\n" - ) - result = ical_to_jscal(ical) - assert "recurrenceRules" in result - rule = result["recurrenceRules"][0] - assert rule["@type"] == "RecurrenceRule" - assert rule["frequency"] == "weekly" - assert rule["interval"] == 1 - assert rule["rscale"] == "gregorian" - days = [d["day"] for d in rule["byDay"]] - assert "mo" in days - assert "we" in days - - def test_exdate(self): - ical = _make_ical( - "DTSTART;TZID=Europe/Berlin:20240617T140000\r\n" - "DURATION:PT1H\r\n" - "SUMMARY:Recurring\r\n" - "RRULE:FREQ=WEEKLY\r\n" - "EXDATE;TZID=Europe/Berlin:20240624T140000\r\n" - ) - result = ical_to_jscal(ical) - assert "recurrenceOverrides" in result - overrides = result["recurrenceOverrides"] - assert any(v == {"excluded": True} for v in overrides.values()) - - def test_valarm_relative(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\n" - "SUMMARY:Alarm Event\r\n" - "BEGIN:VALARM\r\n" - "ACTION:DISPLAY\r\n" - "TRIGGER:-PT15M\r\n" - "DESCRIPTION:Reminder\r\n" - "END:VALARM\r\n" - ) - result = ical_to_jscal(ical) - assert "alerts" in result - alert = next(iter(result["alerts"].values())) - assert alert["trigger"] == "-PT15M" - assert alert["action"] == "display" - - def test_valarm_absolute(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\n" - "SUMMARY:Abs Alarm Event\r\n" - "BEGIN:VALARM\r\n" - "ACTION:DISPLAY\r\n" - "TRIGGER;VALUE=DATE-TIME:20240615T093000Z\r\n" - "DESCRIPTION:Reminder\r\n" - "END:VALARM\r\n" - ) - result = ical_to_jscal(ical) - assert "alerts" in result - alert = next(iter(result["alerts"].values())) - assert alert["trigger"].endswith("Z") - - def test_valarm_related_end(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\n" - "SUMMARY:End Alarm Event\r\n" - "BEGIN:VALARM\r\n" - "ACTION:DISPLAY\r\n" - "TRIGGER;RELATED=END:-PT5M\r\n" - "END:VALARM\r\n" - ) - result = ical_to_jscal(ical) - alert = next(iter(result["alerts"].values())) - assert alert["trigger"] == "-PT5M" - assert alert.get("relativeTo") == "end" - - def test_organizer_attendee(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\n" - "SUMMARY:Meeting\r\n" - "ORGANIZER;CN=Alice:mailto:alice@example.com\r\n" - "ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED:mailto:bob@example.com\r\n" - ) - result = ical_to_jscal(ical) - assert "participants" in result - participants = result["participants"] - # Find organizer - organizer = next( - (p for p in participants.values() if p.get("roles", {}).get("owner")), None - ) - assert organizer is not None - assert organizer["roles"].get("organizer") is True - # Find attendee - attendee = next( - (p for p in participants.values() if p.get("roles", {}).get("attendee")), None - ) - assert attendee is not None - - def test_attendee_partstat(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\n" - "SUMMARY:Meeting\r\n" - "ATTENDEE;PARTSTAT=DECLINED:mailto:bob@example.com\r\n" - ) - result = ical_to_jscal(ical) - attendee = next(iter(result["participants"].values())) - assert attendee["participationStatus"] == "declined" - - def test_calendar_id_set(self): - ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:Cal Event\r\n") - result = ical_to_jscal(ical, calendar_id="Default") - assert result["calendarIds"] == {"Default": True} - - def test_no_calendar_id_omits_key(self): - ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:No Cal\r\n") - result = ical_to_jscal(ical) - assert "calendarIds" not in result - - def test_floating_datetime(self): - ical = _make_ical("DTSTART:20240615T100000\r\nDURATION:PT1H\r\nSUMMARY:Floating\r\n") - result = ical_to_jscal(ical) - assert result["start"] == "2024-06-15T10:00:00" - assert "timeZone" not in result - assert result.get("showWithoutTime") is not True - - def test_recurrence_id_child_vevent(self): - ical = ( - "BEGIN:VCALENDAR\r\n" - "VERSION:2.0\r\n" - "PRODID:-//Test//Test//EN\r\n" - "BEGIN:VEVENT\r\n" - "UID:recur-uid@example.com\r\n" - "DTSTAMP:20240101T000000Z\r\n" - "DTSTART:20240617T140000Z\r\n" - "DURATION:PT1H\r\n" - "SUMMARY:Weekly Meeting\r\n" - "RRULE:FREQ=WEEKLY\r\n" - "END:VEVENT\r\n" - "BEGIN:VEVENT\r\n" - "UID:recur-uid@example.com\r\n" - "DTSTAMP:20240101T000000Z\r\n" - "RECURRENCE-ID:20240624T140000Z\r\n" - "DTSTART:20240624T160000Z\r\n" - "DURATION:PT2H\r\n" - "SUMMARY:Rescheduled Meeting\r\n" - "END:VEVENT\r\n" - "END:VCALENDAR\r\n" - ) - result = ical_to_jscal(ical) - assert "recurrenceOverrides" in result - overrides = result["recurrenceOverrides"] - assert len(overrides) == 1 - key = next(iter(overrides)) - patch = overrides[key] - assert isinstance(patch, dict) - assert patch.get("excluded") is not True - assert patch.get("title") == "Rescheduled Meeting" - - def test_color_and_sequence(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\nSUMMARY:Colored\r\nCOLOR:red\r\nSEQUENCE:3\r\n" - ) - result = ical_to_jscal(ical) - assert result.get("color") == "red" - assert result.get("sequence") == 3 - - def test_rrule_missing_freq_raises(self): - ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:Bad RRULE\r\nRRULE:INTERVAL=2\r\n") - with pytest.raises((ValueError, Exception)): - ical_to_jscal(ical) - - -class TestJscalToIcal: - def test_minimal_event(self): - jscal = _minimal_jscal() - result = jscal_to_ical(jscal) - assert "BEGIN:VCALENDAR" in result - assert "BEGIN:VEVENT" in result - assert "SUMMARY:Test Event" in result - assert "UID:test-uid@example.com" in result - - def test_all_day_event(self): - jscal = _minimal_jscal( - start="2024-06-15T00:00:00", - showWithoutTime=True, - duration="P1D", - ) - del jscal["timeZone"] - result = jscal_to_ical(jscal) - assert "DTSTART;VALUE=DATE:20240615" in result - - def test_timezone_aware_event(self): - jscal = _minimal_jscal(start="2024-06-15T10:00:00", timeZone="Europe/Berlin") - result = jscal_to_ical(jscal) - assert "DTSTART;TZID=Europe/Berlin:" in result - - def test_utc_event(self): - jscal = _minimal_jscal(start="2024-06-15T10:00:00Z") - del jscal["timeZone"] - result = jscal_to_ical(jscal) - assert "20240615T100000Z" in result - - def test_duration(self): - jscal = _minimal_jscal(duration="PT2H30M") - result = jscal_to_ical(jscal) - assert "DURATION:PT2H30M" in result - - def test_keywords_to_categories(self): - jscal = _minimal_jscal(keywords={"work": True, "standup": True}) - result = jscal_to_ical(jscal) - assert "CATEGORIES" in result - assert "work" in result or "standup" in result - - def test_location(self): - jscal = _minimal_jscal(locations={"loc1": {"name": "Room A"}}) - result = jscal_to_ical(jscal) - assert "LOCATION:Room A" in result - - def test_priority(self): - jscal = _minimal_jscal(priority=5) - result = jscal_to_ical(jscal) - assert "PRIORITY:5" in result - - def test_privacy_private(self): - jscal = _minimal_jscal(privacy="private") - result = jscal_to_ical(jscal) - assert "CLASS:PRIVATE" in result - - def test_privacy_secret(self): - jscal = _minimal_jscal(privacy="secret") - result = jscal_to_ical(jscal) - assert "CLASS:CONFIDENTIAL" in result - - def test_free_busy_free(self): - jscal = _minimal_jscal(freeBusyStatus="free") - result = jscal_to_ical(jscal) - assert "TRANSP:TRANSPARENT" in result - - def test_rrule(self): - jscal = _minimal_jscal( - recurrenceRules=[ - { - "@type": "RecurrenceRule", - "frequency": "weekly", - "interval": 1, - "byDay": [{"@type": "NDay", "day": "mo"}], - "rscale": "gregorian", - "skip": "omit", - "firstDayOfWeek": "mo", - } - ] - ) - result = jscal_to_ical(jscal) - assert "RRULE" in result - assert "FREQ=WEEKLY" in result - assert "BYDAY=MO" in result - - def test_exdate_from_overrides(self): - jscal = _minimal_jscal( - recurrenceRules=[{"frequency": "weekly", "@type": "RecurrenceRule"}], - recurrenceOverrides={"2024-06-22T10:00:00": {"excluded": True}}, - ) - result = jscal_to_ical(jscal) - assert "EXDATE" in result - - def test_alert_relative(self): - jscal = _minimal_jscal(alerts={"al1": {"trigger": "-PT15M", "action": "display"}}) - result = jscal_to_ical(jscal) - assert "BEGIN:VALARM" in result - assert "TRIGGER:-PT15M" in result - - def test_alert_related_end(self): - jscal = _minimal_jscal( - alerts={"al1": {"trigger": "-PT5M", "action": "display", "relativeTo": "end"}} - ) - result = jscal_to_ical(jscal) - assert "RELATED=END" in result - assert "-PT5M" in result - - def test_participants_organizer(self): - jscal = _minimal_jscal( - participants={ - "p1": { - "roles": {"owner": True, "organizer": True}, - "name": "Alice", - "email": "alice@example.com", - "sendTo": {"imip": "mailto:alice@example.com"}, - } - } - ) - result = jscal_to_ical(jscal) - assert "ORGANIZER" in result - assert "alice@example.com" in result - - def test_sequence_emitted(self): - result = jscal_to_ical(_minimal_jscal(sequence=5)) - assert "SEQUENCE:5" in result - - def test_color_emitted(self): - result = jscal_to_ical(_minimal_jscal(color="blue")) - assert "COLOR:blue" in result - - def test_exrule_from_excluded_recurrence_rules(self): - jscal = _minimal_jscal( - recurrenceRules=[{"@type": "RecurrenceRule", "frequency": "weekly"}], - excludedRecurrenceRules=[ - {"@type": "RecurrenceRule", "frequency": "weekly", "byDay": [{"day": "mo"}]} - ], - ) - assert "EXRULE" in jscal_to_ical(jscal) - - def test_recurrence_override_patch_becomes_child_vevent(self): - jscal = _minimal_jscal( - start="2024-06-17T14:00:00Z", - recurrenceRules=[{"@type": "RecurrenceRule", "frequency": "weekly"}], - recurrenceOverrides={ - "2024-06-24T14:00:00Z": {"title": "Rescheduled", "start": "2024-06-24T16:00:00Z"} - }, - ) - del jscal["timeZone"] - result = jscal_to_ical(jscal) - assert result.count("BEGIN:VEVENT") == 2 - assert "RECURRENCE-ID" in result - assert "Rescheduled" in result - - def test_floating_datetime_emitted(self): - jscal = { - "uid": "float-uid@example.com", - "title": "Floating", - "start": "2024-06-15T10:00:00", - "duration": "PT1H", - } - result = jscal_to_ical(jscal) - assert "DTSTART:20240615T100000" in result - assert "TZID" not in result - - -class TestRoundTrip: - def _key_fields_survive(self, original_ical: str) -> dict: - """ical → jscal → ical → parse back and check.""" - jscal = ical_to_jscal(original_ical) - round_tripped = jscal_to_ical(jscal) - cal = _icalendar.Calendar.from_ical(round_tripped) - event = next(c for c in cal.subcomponents if isinstance(c, _icalendar.Event)) - return {"jscal": jscal, "ical": round_tripped, "event": event} - - def test_basic_event_round_trip(self): - ical = _make_ical("DTSTART:20240615T100000Z\r\nDURATION:PT1H\r\nSUMMARY:Basic Event\r\n") - ctx = self._key_fields_survive(ical) - assert str(ctx["event"]["SUMMARY"]) == "Basic Event" - assert ctx["jscal"]["title"] == "Basic Event" - assert ctx["jscal"]["duration"] == "PT1H" - - def test_all_day_round_trip(self): - ical = _make_ical( - "DTSTART;VALUE=DATE:20240615\r\nDTEND;VALUE=DATE:20240616\r\nSUMMARY:All Day Event\r\n" - ) - ctx = self._key_fields_survive(ical) - assert ctx["jscal"]["showWithoutTime"] is True - assert ctx["jscal"]["duration"] == "P1D" - - def test_recurring_event_round_trip(self): - ical = _make_ical( - "DTSTART;TZID=Europe/Berlin:20240617T140000\r\n" - "DURATION:PT1H\r\n" - "SUMMARY:Weekly\r\n" - "RRULE:FREQ=WEEKLY;COUNT=4\r\n" - ) - ctx = self._key_fields_survive(ical) - assert "recurrenceRules" in ctx["jscal"] - assert ctx["jscal"]["recurrenceRules"][0]["frequency"] == "weekly" - assert "RRULE" in ctx["ical"] - - def test_with_alert_round_trip(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\n" - "DURATION:PT1H\r\n" - "SUMMARY:Alert Event\r\n" - "BEGIN:VALARM\r\n" - "ACTION:DISPLAY\r\n" - "TRIGGER:-PT15M\r\n" - "DESCRIPTION:Reminder\r\n" - "END:VALARM\r\n" - ) - ctx = self._key_fields_survive(ical) - assert "alerts" in ctx["jscal"] - alert = next(iter(ctx["jscal"]["alerts"].values())) - assert alert["trigger"] == "-PT15M" - assert "BEGIN:VALARM" in ctx["ical"] - - def test_with_attendees_round_trip(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\n" - "DURATION:PT1H\r\n" - "SUMMARY:Meeting\r\n" - "ORGANIZER;CN=Alice:mailto:alice@example.com\r\n" - "ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED:mailto:bob@example.com\r\n" - ) - ctx = self._key_fields_survive(ical) - assert "participants" in ctx["jscal"] - assert len(ctx["jscal"]["participants"]) >= 1 - assert "alice@example.com" in ctx["ical"] or "ORGANIZER" in ctx["ical"] - - -class TestJMAPClientEvents: - _MINIMAL_ICAL = ( - "BEGIN:VCALENDAR\r\n" - "VERSION:2.0\r\n" - "BEGIN:VEVENT\r\n" - "UID:test-uid-123@example.com\r\n" - "DTSTART:20240615T090000Z\r\n" - "SUMMARY:Test Event\r\n" - "END:VEVENT\r\n" - "END:VCALENDAR\r\n" - ) - - _RAW_EVENT = { - "id": "ev1", - "uid": "test-uid@example.com", - "calendarIds": {"cal1": True}, - "title": "Staff Meeting", - "start": "2024-06-15T09:00:00", - "duration": "PT1H", - } - - def _set_response(self, **kwargs): - return {"methodResponses": [["CalendarEvent/set", kwargs, "ev-set-create-0"]]} - - def _get_response(self, items): - return { - "methodResponses": [ - [ - "CalendarEvent/get", - {"accountId": _USERNAME, "list": items, "notFound": []}, - "ev-get-0", - ] - ] - } - - def test_create_event_returns_server_id(self, monkeypatch): - resp = self._set_response(created={"new-0": {"id": "sv-1"}}) - client = _make_client_with_mocked_session(monkeypatch, resp) - event_id = client.create_event("cal1", self._MINIMAL_ICAL) - assert event_id == "sv-1" - - def test_create_event_raises_on_failure(self, monkeypatch): - resp = self._set_response( - notCreated={"new-0": {"type": "invalidArguments", "description": "bad"}} - ) - client = _make_client_with_mocked_session(monkeypatch, resp) - with pytest.raises(JMAPMethodError) as exc_info: - client.create_event("cal1", self._MINIMAL_ICAL) - assert exc_info.value.error_type == "invalidArguments" - - def test_create_event_raises_on_malformed_response(self, monkeypatch): - resp = self._set_response(created={}, notCreated={}) - client = _make_client_with_mocked_session(monkeypatch, resp) - with pytest.raises(JMAPMethodError): - client.create_event("cal1", self._MINIMAL_ICAL) - - def test_create_event_passes_calendar_id(self, monkeypatch): - resp = self._set_response(created={"new-0": {"id": "sv-2"}}) - client, captured = self._capturing_client(monkeypatch, resp) - client.create_event("my-calendar", self._MINIMAL_ICAL) - - method_calls = captured["json"]["methodCalls"] - create_args = method_calls[0][1] - event_payload = create_args["create"]["new-0"] - assert event_payload.get("calendarIds") == {"my-calendar": True} - - def test_get_event_returns_ical(self, monkeypatch): - raw_event = { - "id": "ev1", - "uid": "test-uid@example.com", - "calendarIds": {"cal1": True}, - "title": "Staff Meeting", - "start": "2024-06-15T09:00:00Z", - "duration": "PT1H", - } - client = _make_client_with_mocked_session(monkeypatch, self._get_response([raw_event])) - result = client.get_event("ev1") - assert isinstance(result, JMAPCalendarObject) - assert result.id == "ev1" - assert result.get_data()["title"] == "Staff Meeting" - assert result.parent is None - - def test_get_event_raises_on_not_found(self, monkeypatch): - client = _make_client_with_mocked_session(monkeypatch, self._get_response([])) - with pytest.raises(JMAPMethodError) as exc_info: - client.get_event("missing-id") - assert exc_info.value.error_type == "notFound" - - def test_update_event_success(self, monkeypatch): - resp = self._set_response(updated={"ev1": None}) - client = _make_client_with_mocked_session(monkeypatch, resp) - client.update_event("ev1", self._MINIMAL_ICAL) - - def test_update_event_raises_on_failure(self, monkeypatch): - resp = self._set_response(notUpdated={"ev1": {"type": "notFound"}}) - client = _make_client_with_mocked_session(monkeypatch, resp) - with pytest.raises(JMAPMethodError) as exc_info: - client.update_event("ev1", self._MINIMAL_ICAL) - assert exc_info.value.error_type == "notFound" - - def test_update_event_drops_uid_from_patch(self, monkeypatch): - resp = self._set_response(updated={"ev1": None}) - client, captured = self._capturing_client(monkeypatch, resp) - client.update_event("ev1", self._MINIMAL_ICAL) - - method_calls = captured["json"]["methodCalls"] - update_args = method_calls[0][1] - patch = update_args["update"]["ev1"] - assert "uid" not in patch - - def test_update_event_nulls_removed_optional_properties(self, monkeypatch): - # RFC 8620 §3.3: absent keys in a PatchObject preserve the server value. - # To actually delete a property the patch must set it to null. - # An ical → jscal conversion that omits LOCATION/DESCRIPTION must send - # {"locations": null, "description": null, ...} so the server removes them. - _ICAL_WITHOUT_LOCATION = ( - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" - "BEGIN:VEVENT\r\n" - "UID:loc-uid@example.com\r\n" - "DTSTART:20240615T090000Z\r\n" - "SUMMARY:Event without Location\r\n" - "END:VEVENT\r\nEND:VCALENDAR\r\n" - ) - resp = self._set_response(updated={"ev1": None}) - client, captured = self._capturing_client(monkeypatch, resp) - # First, pretend the event had a location (we don't need to call create; just update) - client.update_event("ev1", _ICAL_WITHOUT_LOCATION) - patch = captured["json"]["methodCalls"][0][1]["update"]["ev1"] - # The patch must contain explicit null for 'locations' to remove it from the server - assert "locations" in patch - assert patch["locations"] is None - - def _sequence_client(self, responses): - """Return (client, captured) replaying ``responses`` POST-by-POST. - - ``captured["patches"]`` collects the ``update`` patch dict sent on each - CalendarEvent/set POST, in order. - """ - captured: dict = {"patches": []} - client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) - client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-abc") - seq = iter(responses) - - def post(*args, **kwargs): - body = kwargs.get("json", {}) - update = body["methodCalls"][0][1].get("update") - if update: - captured["patches"].append(dict(update["ev1"])) # copy: caller mutates in place - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = next(seq) - mock_resp.raise_for_status = MagicMock() - return mock_resp - - mock_http = MagicMock() - mock_http.post.side_effect = post - client._http_session = mock_http - return client, captured - - def test_update_event_retries_dropping_server_rejected_null_keys(self, monkeypatch): - # A server (e.g. Stalwart) rejects null-clearing of recurrence properties - # it does not support, reporting one offending property per response. - # update_event must drop each reported null-cleanup key and retry until - # the update succeeds — never failing on harmless cleanup. - def reject(prop): - return self._set_response( - notUpdated={ - "ev1": { - "type": "invalidProperties", - "description": "Invalid property.", - "properties": [prop], - } - } - ) - - responses = [ - reject("recurrenceRules"), - reject("excludedRecurrenceRules"), - self._set_response(updated={"ev1": None}), - ] - client, captured = self._sequence_client(responses) - client.update_event("ev1", self._MINIMAL_ICAL) - - assert len(captured["patches"]) == 3 - # First attempt nulled both recurrence keys; the final accepted patch dropped them. - assert captured["patches"][0]["recurrenceRules"] is None - assert "recurrenceRules" not in captured["patches"][2] - assert "excludedRecurrenceRules" not in captured["patches"][2] - - def test_update_event_does_not_drop_explicitly_set_property(self, monkeypatch): - # If the rejected property was actually assigned a value by the client - # (not null-cleanup), the rejection is genuine and must surface — no retry. - resp = self._set_response( - notUpdated={ - "ev1": { - "type": "invalidProperties", - "description": "Invalid property.", - "properties": ["title"], - } - } - ) - client, captured = self._sequence_client([resp]) - with pytest.raises(JMAPMethodError) as exc_info: - client.update_event("ev1", self._MINIMAL_ICAL) - assert exc_info.value.error_type == "invalidProperties" - assert len(captured["patches"]) == 1 # no retry - - def test_delete_event_success(self, monkeypatch): - resp = self._set_response(destroyed=["ev1"]) - client = _make_client_with_mocked_session(monkeypatch, resp) - client.delete_event("ev1") - - def test_delete_event_raises_on_failure(self, monkeypatch): - resp = self._set_response(notDestroyed={"ev1": {"type": "notFound"}}) - client = _make_client_with_mocked_session(monkeypatch, resp) - with pytest.raises(JMAPMethodError) as exc_info: - client.delete_event("ev1") - assert exc_info.value.error_type == "notFound" - - def _capturing_client(self, monkeypatch, resp): - """Return (client, captured) where captured["json"] is set on each POST.""" - captured = {} - client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) - client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-abc") - - def capturing_post(*args, **kwargs): - captured["json"] = kwargs.get("json", {}) - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = resp - mock_resp.raise_for_status = MagicMock() - return mock_resp - - mock_http = MagicMock() - mock_http.post.side_effect = capturing_post - client._http_session = mock_http - return client, captured - - def _query_get_response(self, items): - return { - "methodResponses": [ - [ - "CalendarEvent/query", - {"ids": [i["id"] for i in items], "queryState": "qs-1", "total": len(items)}, - "ev-query-0", - ], - [ - "CalendarEvent/get", - {"accountId": _USERNAME, "list": items, "notFound": []}, - "ev-get-1", - ], - ] - } - - def test_search_events_returns_ical_list(self, monkeypatch): - event2 = {**self._RAW_EVENT, "id": "ev2", "title": "Standup"} - resp = self._query_get_response([self._RAW_EVENT, event2]) - client = _make_client_with_mocked_session(monkeypatch, resp) - results = client.search_events() - assert len(results) == 2 - assert all(isinstance(r, JMAPCalendarObject) for r in results) - assert all(r.parent is None for r in results) - - def test_search_events_empty_result(self, monkeypatch): - resp = self._query_get_response([]) - client = _make_client_with_mocked_session(monkeypatch, resp) - assert client.search_events() == [] - - def test_search_events_passes_calendar_id_filter(self, monkeypatch): - resp = self._query_get_response([self._RAW_EVENT]) - client, captured = self._capturing_client(monkeypatch, resp) - client.search_events(calendar_id="my-cal") - query_args = captured["json"]["methodCalls"][0][1] - assert query_args["filter"]["inCalendars"] == ["my-cal"] - - def test_search_events_passes_date_range_filter(self, monkeypatch): - resp = self._query_get_response([self._RAW_EVENT]) - client, captured = self._capturing_client(monkeypatch, resp) - client.search_events(start="2024-01-01T00:00:00", end="2024-12-31T23:59:59") - query_args = captured["json"]["methodCalls"][0][1] - assert query_args["filter"]["after"] == "2024-01-01T00:00:00" - assert query_args["filter"]["before"] == "2024-12-31T23:59:59" - - def test_search_events_passes_text_filter(self, monkeypatch): - resp = self._query_get_response([self._RAW_EVENT]) - client, captured = self._capturing_client(monkeypatch, resp) - client.search_events(text="standup") - query_args = captured["json"]["methodCalls"][0][1] - assert query_args["filter"]["text"] == "standup" - - def test_search_events_no_filter_when_no_args(self, monkeypatch): - resp = self._query_get_response([self._RAW_EVENT]) - client, captured = self._capturing_client(monkeypatch, resp) - client.search_events() - query_args = captured["json"]["methodCalls"][0][1] - assert "filter" not in query_args - - -class TestJMAPClientSync: - _RAW_EVENT = { - "id": "ev1", - "uid": "test-uid@example.com", - "calendarIds": {"cal1": True}, - "title": "Staff Meeting", - "start": "2026-01-15T09:00:00", - "duration": "PT1H", - } - - def _changes_resp( - self, - created=None, - updated=None, - destroyed=None, - old_state="state-1", - new_state="state-2", - has_more=False, - ): - return { - "methodResponses": [ - [ - "CalendarEvent/changes", - { - "accountId": _USERNAME, - "oldState": old_state, - "newState": new_state, - "hasMoreChanges": has_more, - "created": created or [], - "updated": updated or [], - "destroyed": destroyed or [], - }, - "ev-changes-0", - ] - ] - } - - def _get_resp_with_state(self, items, state="state-2"): - return { - "methodResponses": [ - [ - "CalendarEvent/get", - {"accountId": _USERNAME, "state": state, "list": items, "notFound": []}, - "ev-get-0", - ] - ] - } - - def _make_mock(self, resp_json): - m = MagicMock() - m.status_code = 200 - m.json.return_value = resp_json - m.raise_for_status = MagicMock() - return m - - def _make_client(self): - client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) - client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-abc") - return client - - def _mock_http(self, client, response=None, side_effect=None): - mock_http = MagicMock() - if side_effect is not None: - mock_http.post.side_effect = side_effect - elif response is not None: - mock_http.post.return_value = response - client._http_session = mock_http - return mock_http - - def test_get_sync_token_returns_state(self): - resp = self._get_resp_with_state([], state="tok-1") - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - assert client.get_sync_token() == "tok-1" - - def test_get_sync_token_sends_empty_ids(self): - captured = {} - resp = self._get_resp_with_state([]) - - def capturing_post(*args, **kwargs): - captured["json"] = kwargs.get("json", {}) - return self._make_mock(resp) - - client = self._make_client() - self._mock_http(client, side_effect=capturing_post) - client.get_sync_token() - assert captured["json"]["methodCalls"][0][1]["ids"] == [] - - def test_get_objects_no_changes(self): - resp = self._changes_resp() - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - added, modified, deleted, _ = client.get_objects_by_sync_token("state-1") - assert added == [] and modified == [] and deleted == [] - - def test_get_objects_deleted_returns_ids(self): - resp = self._changes_resp(destroyed=["ev1"]) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - added, modified, deleted, _ = client.get_objects_by_sync_token("state-1") - assert deleted == ["ev1"] and added == [] and modified == [] - - def test_get_objects_added_returns_ical(self): - changes_resp = self._changes_resp(created=["ev1"]) - get_resp = self._get_resp_with_state([self._RAW_EVENT]) - client = self._make_client() - self._mock_http( - client, - side_effect=[self._make_mock(changes_resp), self._make_mock(get_resp)], - ) - added, modified, deleted, _ = client.get_objects_by_sync_token("state-1") - assert len(added) == 1 - assert isinstance(added[0], JMAPCalendarObject) - assert added[0].id == "ev1" - assert modified == [] and deleted == [] - - def test_get_objects_modified_returns_ical(self): - changes_resp = self._changes_resp(updated=["ev1"]) - get_resp = self._get_resp_with_state([self._RAW_EVENT]) - client = self._make_client() - self._mock_http( - client, - side_effect=[self._make_mock(changes_resp), self._make_mock(get_resp)], - ) - added, modified, deleted, _ = client.get_objects_by_sync_token("state-1") - assert len(modified) == 1 - assert isinstance(modified[0], JMAPCalendarObject) - assert modified[0].id == "ev1" - assert added == [] and deleted == [] - - def test_get_objects_has_more_raises(self): - resp = self._changes_resp(created=["ev1"], has_more=True) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - with pytest.raises(JMAPMethodError) as exc_info: - client.get_objects_by_sync_token("state-1") - assert exc_info.value.error_type == "serverPartialFail" - - def test_get_objects_returns_new_sync_token(self): - """§4.7: newState from /changes was discarded into _. Callers had no - way to chain sync calls without a separate get_sync_token() round-trip, - creating a race window where intervening changes would be silently missed.""" - resp = self._changes_resp(new_state="state-99") - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - result = client.get_objects_by_sync_token("state-1") - assert len(result) == 4, "expected 4-tuple (added, modified, deleted, new_sync_token)" - added, modified, deleted, new_token = result - assert new_token == "state-99" - assert added == [] and modified == [] and deleted == [] - - def test_parse_event_changes_all_fields(self): - resp_args = { - "oldState": "s1", - "newState": "s2", - "hasMoreChanges": True, - "created": ["ev1"], - "updated": ["ev2"], - "destroyed": ["ev3"], - } - old, new, has_more, created, updated, destroyed = parse_event_changes(resp_args) - assert old == "s1" - assert new == "s2" - assert has_more is True - assert created == ["ev1"] - assert updated == ["ev2"] - assert destroyed == ["ev3"] - - -class TestTaskMethodBuilders: - def test_build_task_list_get_structure(self): - method, args, call_id = build_task_list_get("u1") - assert method == "TaskList/get" - assert args["accountId"] == "u1" - assert args["ids"] is None - assert call_id == "tasklist-get-0" - - def test_build_task_get_structure(self): - method, args, call_id = build_task_get("u1") - assert method == "Task/get" - assert args["accountId"] == "u1" - assert args["ids"] is None - assert call_id == "task-get-0" - - def test_build_task_get_with_ids(self): - _, args, _ = build_task_get("u1", ids=["t1", "t2"]) - assert args["ids"] == ["t1", "t2"] - - def test_build_task_set_create_structure(self): - task = {"@type": "Task", "uid": "uid-1", "taskListId": "tl1", "title": "Buy milk"} - method, args, call_id = build_task_set_create("acct1", {"new-0": task}) - assert method == "Task/set" - assert "create" in args - assert "@type" in args["create"]["new-0"] - assert call_id == "task-set-create-0" - - def test_build_task_set_update_structure(self): - method, args, call_id = build_task_set_update("acct1", {"t1": {"title": "New"}}) - assert method == "Task/set" - assert args["update"] == {"t1": {"title": "New"}} - assert call_id == "task-set-update-0" - - def test_build_task_set_destroy_structure(self): - method, args, call_id = build_task_set_destroy("acct1", ["t1"]) - assert method == "Task/set" - assert args["destroy"] == ["t1"] - assert call_id == "task-set-destroy-0" - - def test_parse_task_list_get_returns_tasklists(self): - resp_args = {"list": [{"id": "tl1", "name": "Work"}, {"id": "tl2", "name": "Home"}]} - results = parse_task_list_get(resp_args) - assert len(results) == 2 - assert all(isinstance(r, dict) for r in results) - assert results[0]["name"] == "Work" - - def test_parse_task_get_returns_tasks(self): - resp_args = { - "list": [ - {"id": "t1", "uid": "uid-1", "taskListId": "tl1", "title": "Buy milk"}, - {"id": "t2", "uid": "uid-2", "taskListId": "tl1", "title": "Call dentist"}, - ] - } - results = parse_task_get(resp_args) - assert len(results) == 2 - assert all(isinstance(r, dict) for r in results) - assert results[0]["title"] == "Buy milk" - - def test_parse_task_set_all_fields(self): - resp_args = { - "created": {"new-0": {"id": "t1"}}, - "updated": {"t2": None}, - "destroyed": ["t3"], - "notCreated": {"new-1": {"type": "invalidArguments"}}, - "notUpdated": {}, - "notDestroyed": {}, - } - created, updated, destroyed, not_created, not_updated, not_destroyed = parse_task_set( - resp_args - ) - assert created == {"new-0": {"id": "t1"}} - assert destroyed == ["t3"] - assert not_created == {"new-1": {"type": "invalidArguments"}} - - -class TestJMAPClientTasks: - _MINIMAL_TASK = { - "id": "task1", - "uid": "uid-task-1@example.com", - "taskListId": "tl1", - "title": "Buy groceries", - "percentComplete": 0, - "progress": "needs-action", - "priority": 0, - } - - _MINIMAL_TASKLIST = { - "id": "tl1", - "name": "My Tasks", - } - - def _set_response(self, **kwargs): - return {"methodResponses": [["Task/set", kwargs, "task-set-create-0"]]} - - def _get_response(self, items): - return { - "methodResponses": [ - [ - "Task/get", - {"accountId": _USERNAME, "list": items, "notFound": []}, - "task-get-0", - ] - ] - } - - def _tasklist_response(self, items): - return { - "methodResponses": [ - [ - "TaskList/get", - {"accountId": _USERNAME, "list": items, "notFound": []}, - "tasklist-get-0", - ] - ] - } - - def _make_mock(self, resp_json): - m = MagicMock() - m.status_code = 200 - m.json.return_value = resp_json - m.raise_for_status = MagicMock() - return m - - def _make_client(self): - client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) - client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-abc") - return client - - def _mock_http(self, client, response=None, side_effect=None): - mock_http = MagicMock() - if side_effect is not None: - mock_http.post.side_effect = side_effect - elif response is not None: - mock_http.post.return_value = response - client._http_session = mock_http - return mock_http - - def test_get_task_lists_returns_list(self): - resp = self._tasklist_response([self._MINIMAL_TASKLIST]) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - result = client.get_task_lists() - assert len(result) == 1 - assert isinstance(result[0], dict) - assert result[0]["name"] == "My Tasks" - - def test_create_task_returns_server_id(self): - resp = self._set_response(created={"new-0": {"id": "sv-task-1"}}) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - task_id = client.create_task("tl1", "Buy groceries") - assert task_id == "sv-task-1" - - def test_create_task_passes_task_list_id(self): - captured = {} - resp = self._set_response(created={"new-0": {"id": "sv-task-1"}}) - - def capturing_post(*args, **kwargs): - captured["json"] = kwargs.get("json", {}) - return self._make_mock(resp) - - client = self._make_client() - self._mock_http(client, side_effect=capturing_post) - client.create_task("my-list", "Test Task") - create_args = captured["json"]["methodCalls"][0][1] - assert create_args["create"]["new-0"]["taskListId"] == "my-list" - - def test_create_task_raises_on_failure(self): - resp = self._set_response(notCreated={"new-0": {"type": "invalidArguments"}}) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - with pytest.raises(JMAPMethodError) as exc_info: - client.create_task("tl1", "Test") - assert exc_info.value.error_type == "invalidArguments" - - def test_create_task_raises_jmap_error_when_created_is_empty(self): - """§1.13: create_task must raise JMAPMethodError (not KeyError) when the server - returns a Task/set response with an empty 'created' dict and no 'notCreated' entry.""" - resp = self._set_response(created={}, notCreated={}) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - with pytest.raises(JMAPMethodError): - client.create_task("tl1", "Test") - - def test_get_task_returns_task_object(self): - resp = self._get_response([self._MINIMAL_TASK]) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - task = client.get_task("task1") - assert isinstance(task, dict) - assert task["id"] == "task1" - - def test_get_task_raises_on_not_found(self): - resp = self._get_response([]) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - with pytest.raises(JMAPMethodError) as exc_info: - client.get_task("missing") - assert exc_info.value.error_type == "notFound" - - def test_update_task_success(self): - resp = self._set_response(updated={"task1": None}) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - client.update_task("task1", {"title": "Updated"}) - - def test_update_task_raises_on_failure(self): - resp = self._set_response(notUpdated={"task1": {"type": "notFound"}}) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - with pytest.raises(JMAPMethodError) as exc_info: - client.update_task("task1", {"title": "X"}) - assert exc_info.value.error_type == "notFound" - - def test_delete_task_success(self): - resp = self._set_response(destroyed=["task1"]) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - client.delete_task("task1") - - def test_delete_task_raises_on_failure(self): - resp = self._set_response(notDestroyed={"task1": {"type": "notFound"}}) - client = self._make_client() - self._mock_http(client, self._make_mock(resp)) - with pytest.raises(JMAPMethodError) as exc_info: - client.delete_task("task1") - assert exc_info.value.error_type == "notFound" - - def test_task_requests_use_task_capability(self): - captured = {} - resp = self._tasklist_response([self._MINIMAL_TASKLIST]) - - def capturing_post(*args, **kwargs): - captured["json"] = kwargs.get("json", {}) - return self._make_mock(resp) - - client = self._make_client() - self._mock_http(client, side_effect=capturing_post) - client.get_task_lists() - assert TASK_CAPABILITY in captured["json"]["using"] - assert CALENDAR_CAPABILITY not in captured["json"]["using"] - - -from caldav.jmap.async_client import AsyncJMAPClient - - -class TestAsyncJMAPClient: - _MINIMAL_ICAL = "\r\n".join( - [ - "BEGIN:VCALENDAR", - "VERSION:2.0", - "BEGIN:VEVENT", - "UID:async-test-uid@example.com", - "SUMMARY:Async Test Event", - "DTSTART:20260101T100000Z", - "DTEND:20260101T110000Z", - "END:VEVENT", - "END:VCALENDAR", - ] - ) - - _RAW_EVENT = { - "id": "ev-async-1", - "uid": "async-test-uid@example.com", - "calendarIds": {"cal1": True}, - "title": "Async Test Event", - "start": "2026-01-01T10:00:00", - "duration": "PT1H", - } - - _MINIMAL_TASK = { - "id": "task-async-1", - "uid": "uid-async-task@example.com", - "taskListId": "tl1", - "title": "Async Task", - "percentComplete": 0, - "progress": "needs-action", - "priority": 0, - } - - _MINIMAL_TASKLIST = {"id": "tl1", "name": "Async Tasks"} - - def _make_client(self): - client = AsyncJMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) - client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-async") - return client - - def _make_mock_response(self, resp_json): - m = MagicMock() - m.status_code = 200 - m.json.return_value = resp_json - m.raise_for_status = MagicMock() - return m - - def _patch_async_session(self, monkeypatch, resp_json): - mock_resp = self._make_mock_response(resp_json) - mock_http = MagicMock() - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - mock_http.post = AsyncMock(return_value=mock_resp) - monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - return mock_http - - def _calendar_get_resp(self, items): - return { - "methodResponses": [ - [ - "Calendar/get", - {"accountId": _USERNAME, "list": items, "notFound": []}, - "cal-get-0", - ] - ] - } - - def _event_set_resp(self, **kwargs): - return {"methodResponses": [["CalendarEvent/set", kwargs, "ev-set-0"]]} - - def _event_get_resp(self, items): - return { - "methodResponses": [ - [ - "CalendarEvent/get", - {"accountId": _USERNAME, "list": items, "notFound": []}, - "ev-get-0", - ] - ] - } - - def _query_get_resp(self, items): - return { - "methodResponses": [ - [ - "CalendarEvent/query", - {"ids": [i["id"] for i in items], "queryState": "qs-1", "total": len(items)}, - "ev-query-0", - ], - [ - "CalendarEvent/get", - {"accountId": _USERNAME, "list": items, "notFound": []}, - "ev-get-1", - ], - ] - } - - def _changes_resp(self, created=None, updated=None, destroyed=None, has_more=False): - return { - "methodResponses": [ - [ - "CalendarEvent/changes", - { - "accountId": _USERNAME, - "oldState": "state-1", - "newState": "state-2", - "hasMoreChanges": has_more, - "created": created or [], - "updated": updated or [], - "destroyed": destroyed or [], - }, - "ev-changes-0", - ] - ] - } - - def _task_set_resp(self, **kwargs): - return {"methodResponses": [["Task/set", kwargs, "task-set-0"]]} - - def _task_get_resp(self, items): - return { - "methodResponses": [ - [ - "Task/get", - {"accountId": _USERNAME, "list": items, "notFound": []}, - "task-get-0", - ] - ] - } - - def _tasklist_resp(self, items): - return { - "methodResponses": [ - [ - "TaskList/get", - {"accountId": _USERNAME, "list": items, "notFound": []}, - "tasklist-get-0", - ] - ] - } - - @pytest.mark.asyncio - async def test_context_manager(self): - async with AsyncJMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) as client: - assert isinstance(client, AsyncJMAPClient) - - @pytest.mark.asyncio - async def test_context_manager_closes_http_session(self, monkeypatch): - mock_close = AsyncMock() - mock_http = MagicMock() - mock_http.close = mock_close - mock_http.headers = MagicMock() - monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - client = AsyncJMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) - async with client: - assert client._http_session is mock_http - mock_close.assert_called_once() - assert client._http_session is None - - @pytest.mark.asyncio - async def test_http_session_reused_across_requests(self, monkeypatch): - client = self._make_client() - mock_resp = self._make_mock_response({"methodResponses": []}) - mock_http = MagicMock() - mock_http.post = AsyncMock(return_value=mock_resp) - mock_http.headers = MagicMock() - with patch("caldav.jmap.async_client.AsyncSession") as MockAsyncSession: - MockAsyncSession.return_value = mock_http - await client._request([("Calendar/get", {}, "c0")]) - await client._request([("Calendar/get", {}, "c1")]) - MockAsyncSession.assert_called_once() - assert mock_http.post.call_count == 2 - - @pytest.mark.asyncio - async def test_get_calendars_returns_list(self, monkeypatch): - cal = {"id": "cal1", "name": "Personal", "isSubscribed": True, "myRights": {}} - self._patch_async_session(monkeypatch, self._calendar_get_resp([cal])) - result = await self._make_client().get_calendars() - assert len(result) == 1 - assert isinstance(result[0], JMAPCalendar) - assert result[0].name == "Personal" - - @pytest.mark.asyncio - async def test_create_event_returns_id(self, monkeypatch): - resp = self._event_set_resp(created={"new-0": {"id": "ev-new-1"}}, notCreated={}) - self._patch_async_session(monkeypatch, resp) - event_id = await self._make_client().create_event("cal1", self._MINIMAL_ICAL) - assert event_id == "ev-new-1" - - @pytest.mark.asyncio - async def test_create_event_raises_on_failure(self, monkeypatch): - resp = self._event_set_resp(created={}, notCreated={"new-0": {"type": "invalidArguments"}}) - self._patch_async_session(monkeypatch, resp) - with pytest.raises(JMAPMethodError) as exc_info: - await self._make_client().create_event("cal1", self._MINIMAL_ICAL) - assert exc_info.value.error_type == "invalidArguments" - - @pytest.mark.asyncio - async def test_get_event_returns_ical(self, monkeypatch): - self._patch_async_session(monkeypatch, self._event_get_resp([self._RAW_EVENT])) - result = await self._make_client().get_event("ev-async-1") - assert isinstance(result, JMAPCalendarObject) - assert result.id == "ev-async-1" - assert result.get_data()["title"] == "Async Test Event" - assert result.parent is None - - @pytest.mark.asyncio - async def test_get_event_raises_on_not_found(self, monkeypatch): - self._patch_async_session(monkeypatch, self._event_get_resp([])) - with pytest.raises(JMAPMethodError) as exc_info: - await self._make_client().get_event("missing") - assert exc_info.value.error_type == "notFound" - - @pytest.mark.asyncio - async def test_update_event_success(self, monkeypatch): - resp = self._event_set_resp(updated={"ev-async-1": None}, notUpdated={}) - self._patch_async_session(monkeypatch, resp) - await self._make_client().update_event("ev-async-1", self._MINIMAL_ICAL) - - @pytest.mark.asyncio - async def test_update_event_raises_on_failure(self, monkeypatch): - resp = self._event_set_resp(updated={}, notUpdated={"ev-async-1": {"type": "notFound"}}) - self._patch_async_session(monkeypatch, resp) - with pytest.raises(JMAPMethodError) as exc_info: - await self._make_client().update_event("ev-async-1", self._MINIMAL_ICAL) - assert exc_info.value.error_type == "notFound" - - @pytest.mark.asyncio - async def test_delete_event_success(self, monkeypatch): - resp = self._event_set_resp(destroyed=["ev-async-1"], notDestroyed={}) - self._patch_async_session(monkeypatch, resp) - await self._make_client().delete_event("ev-async-1") - - @pytest.mark.asyncio - async def test_delete_event_raises_on_failure(self, monkeypatch): - resp = self._event_set_resp(destroyed=[], notDestroyed={"ev-async-1": {"type": "notFound"}}) - self._patch_async_session(monkeypatch, resp) - with pytest.raises(JMAPMethodError) as exc_info: - await self._make_client().delete_event("ev-async-1") - assert exc_info.value.error_type == "notFound" - - @pytest.mark.asyncio - async def test_search_events_returns_ical_list(self, monkeypatch): - event2 = {**self._RAW_EVENT, "id": "ev-async-2", "title": "Another"} - self._patch_async_session(monkeypatch, self._query_get_resp([self._RAW_EVENT, event2])) - results = await self._make_client().search_events() - assert len(results) == 2 - assert all(isinstance(r, JMAPCalendarObject) for r in results) - assert all(r.parent is None for r in results) - - @pytest.mark.asyncio - async def test_search_events_empty_result(self, monkeypatch): - self._patch_async_session(monkeypatch, self._query_get_resp([])) - assert await self._make_client().search_events() == [] - - @pytest.mark.asyncio - async def test_get_sync_token_returns_state(self, monkeypatch): - resp = { - "methodResponses": [ - [ - "CalendarEvent/get", - {"accountId": _USERNAME, "state": "tok-async-1", "list": [], "notFound": []}, - "ev-get-0", - ] - ] - } - self._patch_async_session(monkeypatch, resp) - token = await self._make_client().get_sync_token() - assert token == "tok-async-1" - - @pytest.mark.asyncio - async def test_get_objects_no_changes(self, monkeypatch): - mock_http = MagicMock() - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - mock_http.post = AsyncMock(return_value=self._make_mock_response(self._changes_resp())) - monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - added, modified, deleted, _ = await self._make_client().get_objects_by_sync_token("state-1") - assert added == [] and modified == [] and deleted == [] - - @pytest.mark.asyncio - async def test_get_objects_deleted_returns_ids(self, monkeypatch): - mock_http = MagicMock() - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - mock_http.post = AsyncMock( - return_value=self._make_mock_response(self._changes_resp(destroyed=["ev1"])) - ) - monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - added, modified, deleted, _ = await self._make_client().get_objects_by_sync_token("state-1") - assert deleted == ["ev1"] and added == [] and modified == [] - - @pytest.mark.asyncio - async def test_get_objects_added_returns_ical(self, monkeypatch): - mock_http = MagicMock() - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - mock_http.post = AsyncMock( - side_effect=[ - self._make_mock_response(self._changes_resp(created=["ev-async-1"])), - self._make_mock_response(self._event_get_resp([self._RAW_EVENT])), - ] - ) - monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - added, modified, deleted, _ = await self._make_client().get_objects_by_sync_token("state-1") - assert len(added) == 1 - assert isinstance(added[0], JMAPCalendarObject) - assert added[0].id == "ev-async-1" - assert modified == [] and deleted == [] - - @pytest.mark.asyncio - async def test_get_task_lists_returns_list(self, monkeypatch): - self._patch_async_session(monkeypatch, self._tasklist_resp([self._MINIMAL_TASKLIST])) - result = await self._make_client().get_task_lists() - assert len(result) == 1 - assert isinstance(result[0], dict) - assert result[0]["name"] == "Async Tasks" - - @pytest.mark.asyncio - async def test_create_task_returns_id(self, monkeypatch): - resp = self._task_set_resp(created={"new-0": {"id": "task-new-1"}}, notCreated={}) - self._patch_async_session(monkeypatch, resp) - task_id = await self._make_client().create_task("tl1", "Async Task") - assert task_id == "task-new-1" - - @pytest.mark.asyncio - async def test_get_task_returns_task(self, monkeypatch): - self._patch_async_session(monkeypatch, self._task_get_resp([self._MINIMAL_TASK])) - result = await self._make_client().get_task("task-async-1") - assert isinstance(result, dict) - assert result["id"] == "task-async-1" - - @pytest.mark.asyncio - async def test_get_task_raises_on_not_found(self, monkeypatch): - self._patch_async_session(monkeypatch, self._task_get_resp([])) - with pytest.raises(JMAPMethodError) as exc_info: - await self._make_client().get_task("missing") - assert exc_info.value.error_type == "notFound" - - @pytest.mark.asyncio - async def test_update_task_success(self, monkeypatch): - resp = self._task_set_resp(updated={"task-async-1": None}, notUpdated={}) - self._patch_async_session(monkeypatch, resp) - await self._make_client().update_task("task-async-1", {"title": "Updated"}) - - @pytest.mark.asyncio - async def test_update_task_raises_on_failure(self, monkeypatch): - resp = self._task_set_resp(updated={}, notUpdated={"task-async-1": {"type": "notFound"}}) - self._patch_async_session(monkeypatch, resp) - with pytest.raises(JMAPMethodError) as exc_info: - await self._make_client().update_task("task-async-1", {"title": "X"}) - assert exc_info.value.error_type == "notFound" - - @pytest.mark.asyncio - async def test_delete_task_success(self, monkeypatch): - resp = self._task_set_resp(destroyed=["task-async-1"], notDestroyed={}) - self._patch_async_session(monkeypatch, resp) - await self._make_client().delete_task("task-async-1") - - @pytest.mark.asyncio - async def test_delete_task_raises_on_failure(self, monkeypatch): - resp = self._task_set_resp( - destroyed=[], notDestroyed={"task-async-1": {"type": "notFound"}} - ) - self._patch_async_session(monkeypatch, resp) - with pytest.raises(JMAPMethodError) as exc_info: - await self._make_client().delete_task("task-async-1") - assert exc_info.value.error_type == "notFound" - - @pytest.mark.asyncio - async def test_task_requests_use_task_capability(self, monkeypatch): - captured = {} - mock_resp = self._make_mock_response(self._tasklist_resp([self._MINIMAL_TASKLIST])) - mock_http = MagicMock() - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - - async def capturing_post(*args, **kwargs): - captured["json"] = kwargs.get("json", {}) - return mock_resp - - mock_http.post = capturing_post - monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - await self._make_client().get_task_lists() - assert TASK_CAPABILITY in captured["json"]["using"] - assert CALENDAR_CAPABILITY not in captured["json"]["using"] - - def _capturing_async_session(self, monkeypatch, resp_json): - captured = {} - mock_resp = self._make_mock_response(resp_json) - mock_http = MagicMock() - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - - async def capturing_post(*args, **kwargs): - captured["json"] = kwargs.get("json", {}) - return mock_resp - - mock_http.post = capturing_post - monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - return self._make_client(), captured - - @pytest.mark.asyncio - async def test_create_event_passes_calendar_id(self, monkeypatch): - resp = self._event_set_resp(created={"new-0": {"id": "ev-new-1"}}, notCreated={}) - client, captured = self._capturing_async_session(monkeypatch, resp) - await client.create_event("my-cal", self._MINIMAL_ICAL) - create_args = captured["json"]["methodCalls"][0][1] - new_event = create_args["create"]["new-0"] - assert "my-cal" in new_event.get("calendarIds", {}) - - @pytest.mark.asyncio - async def test_update_event_drops_uid_from_patch(self, monkeypatch): - resp = self._event_set_resp(updated={"ev-async-1": None}, notUpdated={}) - client, captured = self._capturing_async_session(monkeypatch, resp) - await client.update_event("ev-async-1", self._MINIMAL_ICAL) - update_args = captured["json"]["methodCalls"][0][1] - patch = update_args["update"]["ev-async-1"] - assert "uid" not in patch - - @pytest.mark.asyncio - async def test_search_events_passes_calendar_id_filter(self, monkeypatch): - client, captured = self._capturing_async_session( - monkeypatch, self._query_get_resp([self._RAW_EVENT]) - ) - await client.search_events(calendar_id="my-cal") - query_args = captured["json"]["methodCalls"][0][1] - assert query_args["filter"]["inCalendars"] == ["my-cal"] - - @pytest.mark.asyncio - async def test_search_events_passes_date_range_filter(self, monkeypatch): - client, captured = self._capturing_async_session( - monkeypatch, self._query_get_resp([self._RAW_EVENT]) - ) - await client.search_events(start="2026-01-01T00:00:00", end="2026-12-31T23:59:59") - query_args = captured["json"]["methodCalls"][0][1] - assert query_args["filter"]["after"] == "2026-01-01T00:00:00" - assert query_args["filter"]["before"] == "2026-12-31T23:59:59" - - @pytest.mark.asyncio - async def test_search_events_passes_text_filter(self, monkeypatch): - client, captured = self._capturing_async_session( - monkeypatch, self._query_get_resp([self._RAW_EVENT]) - ) - await client.search_events(text="standup") - query_args = captured["json"]["methodCalls"][0][1] - assert query_args["filter"]["text"] == "standup" - - @pytest.mark.asyncio - async def test_search_events_no_filter_when_no_args(self, monkeypatch): - client, captured = self._capturing_async_session( - monkeypatch, self._query_get_resp([self._RAW_EVENT]) - ) - await client.search_events() - query_args = captured["json"]["methodCalls"][0][1] - assert "filter" not in query_args - - @pytest.mark.asyncio - async def test_get_sync_token_sends_empty_ids(self, monkeypatch): - resp = { - "methodResponses": [ - [ - "CalendarEvent/get", - {"accountId": _USERNAME, "state": "tok-1", "list": [], "notFound": []}, - "ev-get-0", - ] - ] - } - client, captured = self._capturing_async_session(monkeypatch, resp) - await client.get_sync_token() - assert captured["json"]["methodCalls"][0][1]["ids"] == [] - - @pytest.mark.asyncio - async def test_get_objects_modified_returns_ical(self, monkeypatch): - mock_http = MagicMock() - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=None) - mock_http.post = AsyncMock( - side_effect=[ - self._make_mock_response(self._changes_resp(updated=["ev-async-1"])), - self._make_mock_response(self._event_get_resp([self._RAW_EVENT])), - ] - ) - monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - added, modified, deleted, _ = await self._make_client().get_objects_by_sync_token("state-1") - assert len(modified) == 1 - assert isinstance(modified[0], JMAPCalendarObject) - assert modified[0].id == "ev-async-1" - assert added == [] and deleted == [] - - @pytest.mark.asyncio - async def test_create_task_passes_task_list_id(self, monkeypatch): - resp = self._task_set_resp(created={"new-0": {"id": "task-new-1"}}, notCreated={}) - client, captured = self._capturing_async_session(monkeypatch, resp) - await client.create_task("tl-target", "My Task") - create_args = captured["json"]["methodCalls"][0][1] - new_task = create_args["create"]["new-0"] - assert new_task["taskListId"] == "tl-target" - - -class TestOverrideWithoutStartUsesOccurrenceTime: - """§4.1: override child VEVENT must use occurrence time as DTSTART, not master start.""" - - def test_title_only_override_dtstart_equals_occurrence(self): - # Master: 2024-06-17T09:00:00Z (UTC), weekly recurrence. - # Override for 2024-06-24T09:00:00Z changes only title — no "start" in patch. - # Child DTSTART must be 20240624T090000Z, not 20240617T090000Z. - jscal = { - "uid": "override-dtstart@example.com", - "title": "Master Title", - "start": "2024-06-17T09:00:00Z", - "duration": "PT1H", - "recurrenceRules": [{"@type": "RecurrenceRule", "frequency": "weekly"}], - "recurrenceOverrides": { - "2024-06-24T09:00:00Z": {"title": "Changed Title"}, - }, - } - result = jscal_to_ical(jscal) - import icalendar as _ic - - cal = _ic.Calendar.from_ical(result) - events = [c for c in cal.subcomponents if isinstance(c, _ic.Event)] - assert len(events) == 2 - child = next(e for e in events if e.get("RECURRENCE-ID") is not None) - # DTSTART of the child must match its own occurrence, not the master start - child_dtstart = child["DTSTART"].dt - if hasattr(child_dtstart, "utctimetuple"): - import datetime as _dt - - assert child_dtstart == _dt.datetime(2024, 6, 24, 9, 0, 0, tzinfo=_dt.timezone.utc) - else: - assert str(child_dtstart) == "2024-06-24" - - -class TestExdateValueType: - """§4.2: EXDATE value type must match DTSTART (TZID or DATE, not floating).""" - - def test_exdate_for_tzid_event_has_tzid_param(self): - # A TZID-anchored event's excluded override must produce EXDATE with TZID, - # not a floating EXDATE (which per RFC 5545 won't match the instance). - jscal = _minimal_jscal( - start="2024-06-17T14:00:00", - timeZone="Europe/Berlin", - recurrenceRules=[{"@type": "RecurrenceRule", "frequency": "weekly"}], - recurrenceOverrides={"2024-06-24T14:00:00": {"excluded": True}}, - ) - result = jscal_to_ical(jscal) - # Must have TZID on EXDATE; a plain EXDATE:... without TZID is a floating datetime - assert "EXDATE;TZID=Europe/Berlin:" in result - - def test_exdate_for_allday_event_is_date_value(self): - jscal = { - "uid": "allday-exdate@example.com", - "title": "All Day Recurring", - "start": "2024-06-17T00:00:00", - "showWithoutTime": True, - "duration": "P1D", - "recurrenceRules": [{"@type": "RecurrenceRule", "frequency": "weekly"}], - "recurrenceOverrides": {"2024-06-24T00:00:00": {"excluded": True}}, - } - result = jscal_to_ical(jscal) - # All-day EXDATE must be a DATE value (8-digit YYYYMMDD, not YYYYMMDDTHHMMSS datetime). - # The icalendar library may or may not emit explicit VALUE=DATE — either form is acceptable. - assert "EXDATE" in result - assert "20240624" in result - assert "20240624T" not in result # must not be a datetime - - -class TestStatusMapping: - """§4.4: STATUS must be mapped in both ical→jscal and jscal→ical directions.""" - - def test_ical_status_cancelled_to_jscal(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\nSUMMARY:Cancelled Meeting\r\nSTATUS:CANCELLED\r\n" - ) - result = ical_to_jscal(ical) - assert result.get("status") == "cancelled" - - def test_ical_status_tentative_to_jscal(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\nSUMMARY:Tentative Meeting\r\nSTATUS:TENTATIVE\r\n" - ) - result = ical_to_jscal(ical) - assert result.get("status") == "tentative" - - def test_ical_status_confirmed_to_jscal(self): - ical = _make_ical( - "DTSTART:20240615T100000Z\r\nSUMMARY:Confirmed Meeting\r\nSTATUS:CONFIRMED\r\n" - ) - result = ical_to_jscal(ical) - assert result.get("status") == "confirmed" - - def test_ical_no_status_omits_jscal_status(self): - ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:No Status\r\n") - result = ical_to_jscal(ical) - assert "status" not in result - - def test_jscal_status_cancelled_to_ical(self): - result = jscal_to_ical(_minimal_jscal(status="cancelled")) - assert "STATUS:CANCELLED" in result - - def test_jscal_status_tentative_to_ical(self): - result = jscal_to_ical(_minimal_jscal(status="tentative")) - assert "STATUS:TENTATIVE" in result - - def test_jscal_status_confirmed_to_ical(self): - result = jscal_to_ical(_minimal_jscal(status="confirmed")) - assert "STATUS:CONFIRMED" in result - - def test_jscal_no_status_omits_ical_status(self): - result = jscal_to_ical(_minimal_jscal()) - assert "STATUS:" not in result - - def test_status_cancelled_round_trips(self): - original = _make_ical( - "DTSTART:20240615T100000Z\r\nSUMMARY:Cancelled\r\nSTATUS:CANCELLED\r\n" - ) - jscal = ical_to_jscal(original) - assert jscal.get("status") == "cancelled" - round_tripped = jscal_to_ical(jscal) - assert "STATUS:CANCELLED" in round_tripped - - -class TestLocalDateTimeIsEventLocal: - """Gate finding F3: RFC 8984 LocalDateTime slots (RRULE ``until``, - ``recurrenceOverrides`` keys) are expressed in the event's own timezone. - ``_format_local_dt()`` merely dropped the tzinfo, so a UTC value coming - off the wire was off by the UTC offset -- and the resulting floating - ``UNTIL`` against a TZID ``DTSTART`` is forbidden by RFC 5545 3.3.10.""" - - ICAL_HEAD = ( - "BEGIN:VCALENDAR\r\n" - "VERSION:2.0\r\n" - "PRODID:-//Test//Test//EN\r\n" - "BEGIN:VEVENT\r\n" - "UID:tz-local@example.com\r\n" - "DTSTAMP:20240101T000000Z\r\n" - "DTSTART;TZID=Europe/Berlin:20240615T090000\r\n" - "DURATION:PT1H\r\n" - ) - - def _convert(self, extra: str) -> dict: - return ical_to_jscal(self.ICAL_HEAD + extra + "END:VEVENT\r\nEND:VCALENDAR\r\n") - - def test_until_is_converted_to_event_timezone(self): - # 2024-06-30T07:00:00Z is 09:00 in Europe/Berlin (CEST, UTC+2). - jscal = self._convert("RRULE:FREQ=WEEKLY;UNTIL=20240630T070000Z\r\n") - assert jscal["recurrenceRules"][0]["until"] == "2024-06-30T09:00:00" - - def test_exdate_key_is_converted_to_event_timezone(self): - jscal = self._convert("RRULE:FREQ=WEEKLY\r\nEXDATE;VALUE=DATE-TIME:20240622T070000Z\r\n") - assert "2024-06-22T09:00:00" in jscal["recurrenceOverrides"] - - def test_until_round_trips_back_to_utc(self): - """The other half of the same rule: RFC 5545 3.3.10 requires a UTC UNTIL - whenever DTSTART carries a TZID, so the LocalDateTime ``until`` has to be - converted back -- not merely parsed as naive -- on the way out. Raised in - review of https://github.com/python-caldav/caldav/pull/688 ("the round-trip - back through jscal_to_ical produces UNTIL=20240701T120000 with no Z - suffix, which RFC 5545 3.3.10 forbids for TZID events").""" - jscal = self._convert("RRULE:FREQ=WEEKLY;UNTIL=20240630T070000Z\r\n") - assert jscal["recurrenceRules"][0]["until"] == "2024-06-30T09:00:00" - ical = jscal_to_ical(jscal) - assert "DTSTART;TZID=Europe/Berlin:20240615T090000" in ical - assert "UNTIL=20240630T070000Z" in ical, ( - "a TZID DTSTART requires a UTC UNTIL; got a floating one" - ) - - def test_recurrence_id_key_is_converted_to_event_timezone(self): - ical = ( - self.ICAL_HEAD + "RRULE:FREQ=WEEKLY\r\n" - "END:VEVENT\r\n" - "BEGIN:VEVENT\r\n" - "UID:tz-local@example.com\r\n" - "DTSTAMP:20240101T000000Z\r\n" - "RECURRENCE-ID:20240622T070000Z\r\n" - "DTSTART;TZID=Europe/Berlin:20240622T100000\r\n" - "SUMMARY:Moved\r\n" - "END:VEVENT\r\n" - "END:VCALENDAR\r\n" - ) - jscal = ical_to_jscal(ical) - assert "2024-06-22T09:00:00" in jscal["recurrenceOverrides"] - - def test_naive_dtstart_leaves_utc_until_alone(self): - """A floating DTSTART has no timezone to convert into; the value is - passed through rather than guessed at.""" - ical = ( - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\n" - "BEGIN:VEVENT\r\nUID:floating@example.com\r\n" - "DTSTAMP:20240101T000000Z\r\n" - "DTSTART:20240615T090000\r\nDURATION:PT1H\r\n" - "RRULE:FREQ=WEEKLY;UNTIL=20240630T070000Z\r\n" - "END:VEVENT\r\nEND:VCALENDAR\r\n" - ) - jscal = ical_to_jscal(ical) - assert jscal["recurrenceRules"][0]["until"] == "2024-06-30T07:00:00" - - -class TestJMAPSessionRelease: - """Gate finding F9: the persistent HTTP session was released only by - ``__exit__``/``__aexit__``. The documented Quick Start does not use - ``with``, so a client built that way leaked its connection pool with no - way to release it short of dropping the object and hoping.""" - - def _make_client(self): - from caldav.jmap.client import JMAPClient - - return JMAPClient(url="https://jmap.example.com/.well-known/jmap", password="token") - - def test_close_releases_the_session(self): - client = self._make_client() - session = client._get_http_session() - assert session is not None - client.close() - assert client._http_session is None - - def test_close_is_idempotent(self): - client = self._make_client() - client._get_http_session() - client.close() - client.close() - assert client._http_session is None - - def test_context_manager_still_releases_the_session(self): - with self._make_client() as client: - client._get_http_session() - assert client._http_session is None - - def _make_async_client(self): - from caldav.jmap.async_client import AsyncJMAPClient - - return AsyncJMAPClient(url="https://jmap.example.com/.well-known/jmap", password="token") - - @pytest.mark.asyncio - async def test_aclose_releases_the_session(self): - client = self._make_async_client() - client._get_http_session() - await client.aclose() - assert client._http_session is None - - @pytest.mark.asyncio - async def test_async_context_manager_still_releases_the_session(self): - async with self._make_async_client() as client: - client._get_http_session() - assert client._http_session is None diff --git a/tests/test_jmap_wrapper.py b/tests/test_jmap_wrapper.py new file mode 100644 index 00000000..337b8764 --- /dev/null +++ b/tests/test_jmap_wrapper.py @@ -0,0 +1,351 @@ +""" +Tests for caldav.jmap, the thin wrapper around the standalone +calendaring-jmap package. + +The JMAP client/conversion/protocol logic itself is calendaring-jmap's own +concern and is covered by that package's test suite; these tests only cover +the wrapper: does the public surface still resolve, does get_jmap_client() +still read caldav's config sources, and are JMAP errors still catchable as +DAVError. +""" + +import importlib +import subprocess +import sys +import textwrap + +import pytest + +## calendaring-jmap is the `jmap` extra, which `pip install -e ".[test]"` does +## not bring in - skip rather than error the whole module out at collection. +## It has to be resolved before caldav.jmap is imported, since that is what +## caldav.jmap itself imports. +calendaring_jmap = pytest.importorskip("calendaring_jmap") + +import caldav.jmap as jmap # noqa: E402 +from caldav.lib.error import AuthorizationError, DAVError # noqa: E402 + + +class TestDeprecationWarning: + """caldav.jmap must warn on import - issue #10's explicit requirement, + so old imports keep working but say they're deprecated.""" + + def test_import_warns(self): + result = subprocess.run( + [ + sys.executable, + "-c", + textwrap.dedent( + """ + import warnings + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + import caldav.jmap + assert len(caught) == 1, caught + assert issubclass(caught[0].category, DeprecationWarning) + assert "caldav.jmap is deprecated" in str(caught[0].message) + print("ok") + """ + ), + ], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert "ok" in result.stdout + + +class TestMissingDependency: + """caldav.jmap must explain itself when calendaring-jmap isn't installed, + not surface a bare ModuleNotFoundError.""" + + def test_import_without_calendaring_jmap_raises_helpful_error(self): + result = subprocess.run( + [ + sys.executable, + "-c", + textwrap.dedent( + """ + import sys + + class Blocker: + def find_spec(self, fullname, path=None, target=None): + if fullname.split(".")[0] == "calendaring_jmap": + ## as the real import system reports it: a + ## ModuleNotFoundError carrying the name + raise ModuleNotFoundError( + "blocked by test", name=fullname + ) + return None + + sys.meta_path.insert(0, Blocker()) + + try: + import caldav.jmap + except ImportError as e: + assert "caldav[jmap]" in str(e) + assert "calendaring-jmap" in str(e) + assert ">=1.1.0" in str(e) + print("ok") + else: + print("no ImportError raised") + """ + ), + ], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert "ok" in result.stdout + + +class TestSubmoduleCompat: + """The old submodule layout must keep resolving. + + ``caldav.jmap`` used to be a package with submodules, and the v3.3 + documentation told people to write e.g. ``from caldav.jmap.error import + JMAPAuthError``. calendaring-jmap mirrors that layout 1:1, so the + wrapper aliases each public submodule rather than letting those imports + die with ModuleNotFoundError - which is not a deprecation path, just a + break. + """ + + @pytest.mark.parametrize( + "name", + [ + "async_client", + "client", + "constants", + "convert", + "convert.ical_to_jscal", + "convert.jscal_to_ical", + "error", + "objects", + "objects.calendar", + "objects.calendar_object", + "session", + ], + ) + def test_submodule_is_calendaring_jmap_s(self, name): + assert importlib.import_module(f"caldav.jmap.{name}") is importlib.import_module( + f"calendaring_jmap.{name}" + ) + + def test_documented_error_import_works(self): + """docs/source/jmap.rst in v3.3 spelled this one out verbatim.""" + from caldav.jmap.error import ( + JMAPAuthError, + JMAPCapabilityError, + JMAPMethodError, + ) + + assert JMAPAuthError is calendaring_jmap.JMAPAuthError + assert JMAPCapabilityError is calendaring_jmap.JMAPCapabilityError + assert JMAPMethodError is calendaring_jmap.JMAPMethodError + + def test_submodule_attribute_access_works(self): + """``import caldav.jmap.error`` must also bind the attribute.""" + import caldav.jmap.error + + assert caldav.jmap.error.JMAPError is calendaring_jmap.JMAPError + assert jmap.session.fetch_session is calendaring_jmap.session.fetch_session + + +class TestBrokenInstall: + """A calendaring-jmap that is present but broken must not be reported as + missing - that diagnosis sends the reader off to install what they already + have.""" + + def _run(self, blocked): + return subprocess.run( + [ + sys.executable, + "-c", + textwrap.dedent( + f""" + import sys + + class Blocker: + def find_spec(self, fullname, path=None, target=None): + if fullname == {blocked!r}: + raise ModuleNotFoundError( + "blocked by test", name={blocked!r} + ) + return None + + sys.meta_path.insert(0, Blocker()) + + try: + import caldav.jmap + except ImportError as e: + print("ERROR:" + str(e)) + print("NAME:" + str(e.name)) + """ + ), + ], + capture_output=True, + text=True, + timeout=30, + ) + + def test_broken_submodule_is_not_reported_as_not_installed(self): + result = self._run("calendaring_jmap.session") + assert result.returncode == 0, result.stderr + lines = result.stdout.splitlines() + error = [line for line in lines if line.startswith("ERROR:")] + name = [line for line in lines if line.startswith("NAME:")] + assert error, result.stdout + ## the original error is re-raised untouched, naming the module that + ## actually failed - not caldav.jmap's "go and install it" message + assert name == ["NAME:calendaring_jmap.session"], name + assert "is not installed" not in error[0] + assert "caldav[jmap]" not in error[0] + + +class TestPublicSurface: + """The wrapper must keep exactly the same __all__ as before extraction.""" + + def test_all_matches_expected_names(self): + assert sorted(jmap.__all__) == sorted( + [ + "JMAPClient", + "AsyncJMAPClient", + "get_jmap_client", + "get_async_jmap_client", + "JMAPError", + "JMAPCapabilityError", + "JMAPAuthError", + "JMAPMethodError", + "JMAPCalendar", + "JMAPCalendarObject", + ] + ) + + def test_client_classes_are_calendaring_jmap_s(self): + assert jmap.JMAPClient is calendaring_jmap.JMAPClient + assert jmap.AsyncJMAPClient is calendaring_jmap.AsyncJMAPClient + + def test_object_classes_are_calendaring_jmap_s(self): + assert jmap.JMAPCalendar is calendaring_jmap.JMAPCalendar + assert jmap.JMAPCalendarObject is calendaring_jmap.JMAPCalendarObject + + +class TestGetJmapClient: + """get_jmap_client()/get_async_jmap_client() still resolve config the + way get_davclient() does, via caldav.config.get_connection_params().""" + + def test_explicit_kwargs_build_a_client(self): + client = jmap.get_jmap_client( + url="https://jmap.example.com/.well-known/jmap", + username="alice", + password="secret", + ) + assert isinstance(client, calendaring_jmap.JMAPClient) + assert client.url == "https://jmap.example.com/.well-known/jmap" + assert client.username == "alice" + + def test_async_explicit_kwargs_build_a_client(self): + client = jmap.get_async_jmap_client( + url="https://jmap.example.com/.well-known/jmap", + username="alice", + password="secret", + ) + assert isinstance(client, calendaring_jmap.AsyncJMAPClient) + + def test_no_config_returns_none(self, monkeypatch): + monkeypatch.delenv("CALDAV_URL", raising=False) + monkeypatch.setattr( + "caldav.config.get_connection_params", + lambda **kwargs: None, + ) + assert jmap.get_jmap_client(check_config_file=False, environment=False) is None + assert jmap.get_async_jmap_client(check_config_file=False, environment=False) is None + + def test_non_jmap_keys_are_filtered_out(self, monkeypatch): + """get_connection_params() can return CalDAV-only keys (proxy, headers, + ...); JMAPClient's constructor does not accept those and must not see + them.""" + monkeypatch.setattr( + "caldav.config.get_connection_params", + lambda **kwargs: { + "url": "https://jmap.example.com/.well-known/jmap", + "username": "alice", + "password": "secret", + "proxy": "http://localhost:8080", + "headers": {"X-Test": "1"}, + }, + ) + client = jmap.get_jmap_client() + assert isinstance(client, calendaring_jmap.JMAPClient) + + +class TestErrorHierarchy: + """caldav.jmap's error classes ARE calendaring_jmap's (plain re-exports, + no wrapper subclassing). calendaring_jmap/error.py is what conditionally + adds the DAVError/AuthorizationError parentage when caldav is importable.""" + + def test_error_classes_are_calendaring_jmap_s(self): + assert jmap.JMAPError is calendaring_jmap.JMAPError + assert jmap.JMAPCapabilityError is calendaring_jmap.JMAPCapabilityError + assert jmap.JMAPAuthError is calendaring_jmap.JMAPAuthError + assert jmap.JMAPMethodError is calendaring_jmap.JMAPMethodError + + @pytest.mark.parametrize( + "error_cls", + [jmap.JMAPError, jmap.JMAPCapabilityError, jmap.JMAPAuthError, jmap.JMAPMethodError], + ) + def test_is_a_daverror(self, error_cls): + assert issubclass(error_cls, DAVError) + + def test_jmap_auth_error_is_also_an_authorizationerror(self): + assert issubclass(jmap.JMAPAuthError, AuthorizationError) + + def test_except_daverror_catches_jmap_auth_error(self): + with pytest.raises(DAVError): + raise jmap.JMAPAuthError(url="https://x", reason="nope") + + def test_error_type_and_reason_survive_construction(self): + err = jmap.JMAPMethodError( + url="https://jmap.example.com", + reason="bad request", + error_type="invalidArguments", + ) + assert err.error_type == "invalidArguments" + assert err.reason == "bad request" + assert err.url == "https://jmap.example.com" + + def test_calendaring_jmap_falls_back_without_caldav(self): + """calendaring_jmap must not hard-depend on caldav.""" + result = subprocess.run( + [ + sys.executable, + "-c", + textwrap.dedent( + """ + import sys + + class Blocker: + def find_spec(self, fullname, path=None, target=None): + if fullname.split(".")[0] == "caldav": + raise ImportError("blocked by test") + return None + + sys.meta_path.insert(0, Blocker()) + + from calendaring_jmap.error import JMAPError, JMAPAuthError + + assert "caldav" not in sys.modules + err = JMAPAuthError(url="https://x", reason="nope") + assert isinstance(err, Exception) + print("ok") + """ + ), + ], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert "ok" in result.stdout diff --git a/tox.ini b/tox.ini index f9178c66..53da5f7b 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ envlist = py310,py311,py312,py313,py314,docs,style,deptry,audit,package [testenv] -deps = --editable .[test] +deps = --editable .[test,jmap] ## TODO: too much duplication here, and it's getting worse for every docker-based server we decide to add to the test framework. Can this be redone somehow? passenv = BAIKAL_URL @@ -27,6 +27,7 @@ commands = coverage run -m pytest [testenv:docs] ## TODO - I don't like duplication, this is now both here and in docs/requirements.txt +extras = jmap deps = sphinx<9 manuel