calendaring-jmap 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ # SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """
5
+ Calendar operations over JMAP (RFC 8620 + JMAP Calendars).
6
+
7
+ Provides synchronous and asynchronous JMAP clients for calendar listing,
8
+ event CRUD, incremental sync, and task CRUD.
9
+
10
+ Basic usage::
11
+
12
+ from calendaring_jmap import get_jmap_client
13
+
14
+ client = get_jmap_client(
15
+ url="https://jmap.example.com/.well-known/jmap",
16
+ username="alice",
17
+ password="secret",
18
+ )
19
+ calendars = client.get_calendars()
20
+
21
+ Async usage::
22
+
23
+ from calendaring_jmap import get_async_jmap_client
24
+
25
+ async with get_async_jmap_client(
26
+ url="https://jmap.example.com/.well-known/jmap",
27
+ username="alice",
28
+ password="secret",
29
+ ) as client:
30
+ calendars = await client.get_calendars()
31
+ """
32
+
33
+ from calendaring_jmap._config import get_connection_params
34
+ from calendaring_jmap._version import __version__
35
+ from calendaring_jmap.async_client import AsyncJMAPClient
36
+ from calendaring_jmap.client import JMAPClient
37
+ from calendaring_jmap.error import (
38
+ JMAPAuthError,
39
+ JMAPCapabilityError,
40
+ JMAPError,
41
+ JMAPMethodError,
42
+ )
43
+ from calendaring_jmap.objects.calendar import JMAPCalendar
44
+ from calendaring_jmap.objects.calendar_object import JMAPCalendarObject
45
+
46
+ _JMAP_KEYS = {"url", "username", "password", "auth_type", "timeout"}
47
+
48
+
49
+ def get_jmap_client(**kwargs) -> JMAPClient | None:
50
+ """Create a :class:`JMAPClient` from configuration.
51
+
52
+ Configuration is read in priority order:
53
+
54
+ 1. Explicit keyword arguments (``url``, ``username``, ``password``, …)
55
+ 2. Environment variables (``JMAP_URL``, ``JMAP_USERNAME``, …)
56
+ 3. A YAML config file (``~/.config/calendaring-jmap/calendar.yaml`` or equivalent)
57
+
58
+ Returns ``None`` if no configuration is found, rather than raising.
59
+
60
+ Example::
61
+
62
+ client = get_jmap_client(url="https://jmap.example.com/.well-known/jmap",
63
+ username="alice", password="secret")
64
+ """
65
+ conn_params = get_connection_params(**kwargs)
66
+ if conn_params is None:
67
+ return None
68
+ return JMAPClient(**{k: v for k, v in conn_params.items() if k in _JMAP_KEYS})
69
+
70
+
71
+ def get_async_jmap_client(**kwargs) -> AsyncJMAPClient | None:
72
+ """Create an :class:`AsyncJMAPClient` from configuration.
73
+
74
+ Accepts the same arguments and reads configuration from the same sources
75
+ as :func:`get_jmap_client`. Returns ``None`` if no configuration is found.
76
+
77
+ Example::
78
+
79
+ async with get_async_jmap_client(
80
+ url="https://jmap.example.com/.well-known/jmap",
81
+ username="alice", password="secret"
82
+ ) as client:
83
+ calendars = await client.get_calendars()
84
+ """
85
+ conn_params = get_connection_params(**kwargs)
86
+ if conn_params is None:
87
+ return None
88
+ return AsyncJMAPClient(**{k: v for k, v in conn_params.items() if k in _JMAP_KEYS})
89
+
90
+
91
+ __all__ = [
92
+ "AsyncJMAPClient",
93
+ "JMAPAuthError",
94
+ "JMAPCalendar",
95
+ "JMAPCalendarObject",
96
+ "JMAPCapabilityError",
97
+ "JMAPClient",
98
+ "JMAPError",
99
+ "JMAPMethodError",
100
+ "__version__",
101
+ "get_async_jmap_client",
102
+ "get_jmap_client",
103
+ ]
@@ -0,0 +1,91 @@
1
+ # SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """Standalone configuration loading for calendaring-jmap.
5
+
6
+ Resolves connection parameters in priority order:
7
+
8
+ 1. Explicit keyword arguments
9
+ 2. Environment variables (``JMAP_URL``, ``JMAP_USERNAME``, ``JMAP_PASSWORD``,
10
+ ``JMAP_AUTH_TYPE``, ``JMAP_TIMEOUT``)
11
+ 3. A YAML config file (``JMAP_CONFIG_FILE`` env var, or
12
+ ``~/.config/calendaring-jmap/calendar.yaml``)
13
+
14
+ Returns ``None`` if none of the three sources yields a URL, rather than
15
+ raising. Callers decide whether "no configuration" is an error.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ _CONN_KEYS = {"url", "username", "password", "auth_type", "timeout"}
25
+ _DEFAULT_CONFIG_PATH = Path.home() / ".config" / "calendaring-jmap" / "calendar.yaml"
26
+
27
+
28
+ _ENV_VARS = {
29
+ "url": "JMAP_URL",
30
+ "username": "JMAP_USERNAME",
31
+ "password": "JMAP_PASSWORD",
32
+ "auth_type": "JMAP_AUTH_TYPE",
33
+ "timeout": "JMAP_TIMEOUT",
34
+ }
35
+
36
+
37
+ def _from_environment() -> dict[str, Any]:
38
+ env: dict[str, Any] = {}
39
+ for key, var in _ENV_VARS.items():
40
+ value = os.environ.get(var)
41
+ if value:
42
+ env[key] = int(value) if key == "timeout" else value
43
+ return env
44
+
45
+
46
+ def _from_config_file(config_file: str | Path | None) -> dict[str, Any]:
47
+ path = Path(config_file) if config_file else _DEFAULT_CONFIG_PATH
48
+ if not path.is_file():
49
+ return {}
50
+
51
+ import yaml
52
+
53
+ with path.open("rb") as f:
54
+ data = yaml.safe_load(f) or {}
55
+ if not isinstance(data, dict):
56
+ return {}
57
+ return {k: v for k, v in data.items() if k in _CONN_KEYS}
58
+
59
+
60
+ def get_connection_params(
61
+ config_file: str | Path | None = None,
62
+ **explicit_params: Any,
63
+ ) -> dict[str, Any] | None:
64
+ """Resolve JMAP connection parameters from kwargs, env vars, or a config file.
65
+
66
+ Args:
67
+ config_file: Explicit path to a YAML config file. Defaults to the
68
+ ``JMAP_CONFIG_FILE`` environment variable, then
69
+ ``~/.config/calendaring-jmap/calendar.yaml``.
70
+ **explicit_params: ``url``, ``username``, ``password``, ``auth_type``,
71
+ ``timeout``. A value of ``None`` is treated as "not supplied",
72
+ not "unset": this lets a thin CLI wrapper pass every option
73
+ through unconditionally without wiping out env vars for the
74
+ ones the user left off.
75
+
76
+ Returns:
77
+ A dict of connection parameters, or ``None`` if no source yields a URL.
78
+ """
79
+ explicit = {k: v for k, v in explicit_params.items() if k in _CONN_KEYS and v is not None}
80
+ if explicit.get("url"):
81
+ return explicit
82
+
83
+ env = _from_environment()
84
+ if env.get("url"):
85
+ return {**env, **explicit}
86
+
87
+ file_config = _from_config_file(config_file or os.environ.get("JMAP_CONFIG_FILE"))
88
+ if file_config.get("url"):
89
+ return {**file_config, **env, **explicit}
90
+
91
+ return None
@@ -0,0 +1,100 @@
1
+ # SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """HTTP library selection, imported once for the whole package.
5
+
6
+ Everything that needs niquests-or-requests takes it from here rather than
7
+ repeating the fallback. ``requests`` is niquests when niquests is installed:
8
+ the name is the one the API is compatible with, not necessarily the package
9
+ that provides it.
10
+ """
11
+
12
+ from typing import Any
13
+
14
+ _DOCS_URL = "https://calendaring-jmap.readthedocs.io/"
15
+
16
+ USE_NIQUESTS = False
17
+ USE_REQUESTS = False
18
+
19
+ ## niquests' AsyncSession has no requests equivalent, so it's None on the
20
+ ## fallback. The async client is the only thing that needs it; it goes
21
+ ## through require_async_session() for a clear error instead of a
22
+ ## TypeError on None.
23
+ AsyncSession: Any = None
24
+
25
+ try:
26
+ import niquests as requests
27
+ from niquests.auth import AuthBase, HTTPBasicAuth
28
+
29
+ USE_NIQUESTS = True
30
+ except ImportError:
31
+ try:
32
+ import requests # type: ignore[no-redef]
33
+ from requests.auth import ( # type: ignore[assignment]
34
+ AuthBase,
35
+ HTTPBasicAuth,
36
+ )
37
+
38
+ USE_REQUESTS = True
39
+ except ImportError as e:
40
+ raise ImportError(
41
+ "calendaring-jmap needs an HTTP library, and none of the supported "
42
+ "ones is installed (tried: niquests, requests). Install one; "
43
+ "`pip install niquests` is the recommended choice. "
44
+ f"See {_DOCS_URL} for details."
45
+ ) from e
46
+
47
+ if USE_NIQUESTS:
48
+ ## Deliberately its own try: an ImportError here must not fall through to
49
+ ## the requests branch and flip USE_NIQUESTS off on an install that does
50
+ ## have niquests. Only the async client needs this.
51
+ try:
52
+ from niquests import AsyncSession
53
+ except ImportError:
54
+ ## Old niquests without AsyncSession: leave it None, and let
55
+ ## require_async_session() explain it if anything asks for it.
56
+ pass
57
+
58
+
59
+ def require_async_session() -> Any:
60
+ """Return niquests' ``AsyncSession``, or explain why there isn't one.
61
+
62
+ The async client is built on it and has no fallback.
63
+ """
64
+ if AsyncSession is None:
65
+ reason = "an old niquests install without AsyncSession" if USE_NIQUESTS else "not installed"
66
+ raise ImportError(
67
+ f"The async JMAP client requires niquests with AsyncSession support ({reason}). "
68
+ "Unlike the sync client it has no fallback to another HTTP library. "
69
+ f"Install or upgrade it with `pip install -U niquests`. See {_DOCS_URL} for details."
70
+ )
71
+ return AsyncSession
72
+
73
+
74
+ class HTTPBearerAuth(AuthBase):
75
+ """Bearer token authentication for niquests/requests."""
76
+
77
+ def __init__(self, password: str) -> None:
78
+ self.password = password
79
+
80
+ def __eq__(self, other: object) -> bool:
81
+ return self.password == getattr(other, "password", None)
82
+
83
+ def __ne__(self, other: object) -> bool:
84
+ return not self == other
85
+
86
+ def __call__(self, r):
87
+ r.headers["Authorization"] = f"Bearer {self.password}"
88
+ return r
89
+
90
+
91
+ __all__ = [
92
+ "AsyncSession",
93
+ "AuthBase",
94
+ "HTTPBasicAuth",
95
+ "HTTPBearerAuth",
96
+ "USE_NIQUESTS",
97
+ "USE_REQUESTS",
98
+ "requests",
99
+ "require_async_session",
100
+ ]
@@ -0,0 +1,16 @@
1
+ # SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+
5
+ def parse_set_response(response_args: dict) -> tuple[dict, dict, list[str], dict, dict, dict]:
6
+ """Parse the arguments dict from any JMAP ``*/set`` method response.
7
+
8
+ Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``.
9
+ """
10
+ created: dict = response_args.get("created") or {}
11
+ updated: dict = response_args.get("updated") or {}
12
+ destroyed: list[str] = response_args.get("destroyed") or []
13
+ not_created: dict = response_args.get("notCreated") or {}
14
+ not_updated: dict = response_args.get("notUpdated") or {}
15
+ not_destroyed: dict = response_args.get("notDestroyed") or {}
16
+ return created, updated, destroyed, not_created, not_updated, not_destroyed
@@ -0,0 +1,71 @@
1
+ # SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """
5
+ JMAP Calendar method builders and response parsers.
6
+
7
+ These are pure functions — no HTTP, no state. They build the request
8
+ tuples that go into a ``methodCalls`` list, and parse the corresponding
9
+ ``methodResponses`` entries.
10
+
11
+ Method shapes follow RFC 8620 §3.3 (get), §3.4 (changes), §3.5 (set); Calendar-specific
12
+ properties are defined in the JMAP Calendars specification.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from calendaring_jmap.objects.calendar import JMAPCalendar
18
+
19
+
20
+ def build_calendar_get(
21
+ account_id: str,
22
+ ids: list[str] | None = None,
23
+ properties: list[str] | None = None,
24
+ ) -> tuple:
25
+ """Build a ``Calendar/get`` method call tuple.
26
+
27
+ Args:
28
+ account_id: The JMAP accountId to query.
29
+ ids: List of calendar IDs to fetch, or ``None`` to fetch all.
30
+ properties: List of property names to return, or ``None`` for all.
31
+
32
+ Returns:
33
+ A 3-tuple ``("Calendar/get", arguments_dict, call_id)`` suitable
34
+ for inclusion in a ``methodCalls`` list.
35
+ """
36
+ args: dict = {"accountId": account_id, "ids": ids}
37
+ if properties is not None:
38
+ args["properties"] = properties
39
+ return ("Calendar/get", args, "cal-get-0")
40
+
41
+
42
+ def parse_calendar_get(response_args: dict) -> list[JMAPCalendar]:
43
+ """Parse the arguments dict from a ``Calendar/get`` method response.
44
+
45
+ Args:
46
+ response_args: The second element of a ``methodResponses`` entry
47
+ whose method name is ``"Calendar/get"``.
48
+
49
+ Returns:
50
+ List of :class:`~calendaring_jmap.objects.calendar.JMAPCalendar` objects.
51
+ Returns an empty list if ``"list"`` is absent or empty.
52
+ """
53
+ return [JMAPCalendar.from_jmap(item) for item in response_args.get("list", [])]
54
+
55
+
56
+ def build_calendar_changes(account_id: str, since_state: str) -> tuple:
57
+ """Build a ``Calendar/changes`` method call tuple.
58
+
59
+ Args:
60
+ account_id: The JMAP accountId to query.
61
+ since_state: The ``state`` string from a previous ``Calendar/get``
62
+ or ``Calendar/changes`` response.
63
+
64
+ Returns:
65
+ A 3-tuple ``("Calendar/changes", arguments_dict, call_id)``.
66
+ """
67
+ return (
68
+ "Calendar/changes",
69
+ {"accountId": account_id, "sinceState": since_state},
70
+ "cal-changes-0",
71
+ )
@@ -0,0 +1,271 @@
1
+ # SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """
5
+ JMAP CalendarEvent method builders and response parsers.
6
+
7
+ These are pure functions — no HTTP, no state. They build the request
8
+ tuples that go into a ``methodCalls`` list, and parse the corresponding
9
+ ``methodResponses`` entries.
10
+
11
+ Method shapes follow RFC 8620 §3.3 (get), §3.4 (changes), §3.5 (set),
12
+ §3.6 (query), §3.7 (queryChanges); CalendarEvent-specific properties are
13
+ defined in the JMAP Calendars specification.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from calendaring_jmap._methods import parse_set_response
19
+
20
+
21
+ def build_event_get(
22
+ account_id: str,
23
+ ids: list[str] | None = None,
24
+ properties: list[str] | None = None,
25
+ ) -> tuple:
26
+ """Build a ``CalendarEvent/get`` method call tuple.
27
+
28
+ Args:
29
+ account_id: The JMAP accountId to query.
30
+ ids: List of event IDs to fetch, or ``None`` to fetch all.
31
+ properties: List of property names to return, or ``None`` for all.
32
+
33
+ Returns:
34
+ A 3-tuple ``("CalendarEvent/get", arguments_dict, call_id)`` suitable
35
+ for inclusion in a ``methodCalls`` list.
36
+ """
37
+ args: dict = {"accountId": account_id, "ids": ids}
38
+ if properties is not None:
39
+ args["properties"] = properties
40
+ return ("CalendarEvent/get", args, "ev-get-0")
41
+
42
+
43
+ def parse_event_get(response_args: dict) -> list[dict]:
44
+ """Parse the arguments dict from a ``CalendarEvent/get`` method response.
45
+
46
+ Args:
47
+ response_args: The second element of a ``methodResponses`` entry
48
+ whose method name is ``"CalendarEvent/get"``.
49
+
50
+ Returns:
51
+ List of raw JSCalendar dicts as returned by the server.
52
+ Returns an empty list if ``"list"`` is absent or empty.
53
+ """
54
+ return list(response_args.get("list", []))
55
+
56
+
57
+ def build_event_changes(
58
+ account_id: str,
59
+ since_state: str,
60
+ max_changes: int | None = None,
61
+ ) -> tuple:
62
+ """Build a ``CalendarEvent/changes`` method call tuple.
63
+
64
+ Args:
65
+ account_id: The JMAP accountId to query.
66
+ since_state: The ``state`` string from a previous ``CalendarEvent/get``
67
+ or ``CalendarEvent/changes`` response.
68
+ max_changes: Optional upper bound on the number of changes returned.
69
+ The server may return fewer.
70
+
71
+ Returns:
72
+ A 3-tuple ``("CalendarEvent/changes", arguments_dict, call_id)``.
73
+ """
74
+ args: dict = {"accountId": account_id, "sinceState": since_state}
75
+ if max_changes is not None:
76
+ args["maxChanges"] = max_changes
77
+ return ("CalendarEvent/changes", args, "ev-changes-0")
78
+
79
+
80
+ def parse_event_changes(
81
+ response_args: dict,
82
+ ) -> tuple[str, str, bool, list[str], list[str], list[str]]:
83
+ """Parse the arguments dict from a ``CalendarEvent/changes`` response.
84
+
85
+ Args:
86
+ response_args: The second element of a ``methodResponses`` entry
87
+ whose method name is ``"CalendarEvent/changes"``.
88
+
89
+ Returns:
90
+ A 6-tuple ``(old_state, new_state, has_more_changes, created, updated, destroyed)``:
91
+
92
+ - ``old_state``: Echo of the ``sinceState`` argument.
93
+ - ``new_state``: State string to store as the next sync token.
94
+ - ``has_more_changes``: True if the server capped the response.
95
+ - ``created``: IDs of newly created events.
96
+ - ``updated``: IDs of modified events.
97
+ - ``destroyed``: IDs of deleted events.
98
+ """
99
+ return (
100
+ response_args.get("oldState", ""),
101
+ response_args.get("newState", ""),
102
+ response_args.get("hasMoreChanges", False),
103
+ response_args.get("created") or [],
104
+ response_args.get("updated") or [],
105
+ response_args.get("destroyed") or [],
106
+ )
107
+
108
+
109
+ def build_event_query(
110
+ account_id: str,
111
+ filter_condition: dict | None = None,
112
+ sort: list[dict] | None = None,
113
+ position: int = 0,
114
+ limit: int | None = None,
115
+ ) -> tuple:
116
+ """Build a ``CalendarEvent/query`` method call tuple.
117
+
118
+ Args:
119
+ account_id: The JMAP accountId to query.
120
+ filter_condition: A ``FilterCondition`` or ``FilterOperator`` dict, e.g.
121
+ ``{"after": "2024-01-01T00:00:00Z", "before": "2024-12-31T23:59:59Z"}``.
122
+ ``None`` means no filter (return all events).
123
+ sort: List of ``Comparator`` dicts, e.g.
124
+ ``[{"property": "start", "isAscending": True}]``.
125
+ ``None`` means server default ordering.
126
+ position: Zero-based index of the first result to return.
127
+ limit: Maximum number of IDs to return. ``None`` means no limit.
128
+
129
+ Returns:
130
+ A 3-tuple ``("CalendarEvent/query", arguments_dict, call_id)``.
131
+ """
132
+ args: dict = {"accountId": account_id, "position": position}
133
+ if filter_condition is not None:
134
+ args["filter"] = filter_condition
135
+ if sort is not None:
136
+ args["sort"] = sort
137
+ if limit is not None:
138
+ args["limit"] = limit
139
+ return ("CalendarEvent/query", args, "ev-query-0")
140
+
141
+
142
+ def parse_event_query(response_args: dict) -> tuple[list[str], str, int]:
143
+ """Parse the arguments dict from a ``CalendarEvent/query`` response.
144
+
145
+ Args:
146
+ response_args: The second element of a ``methodResponses`` entry
147
+ whose method name is ``"CalendarEvent/query"``.
148
+
149
+ Returns:
150
+ A 3-tuple ``(ids, query_state, total)``:
151
+
152
+ - ``ids``: Ordered list of matching event IDs.
153
+ - ``query_state``: Opaque state string for use with
154
+ ``CalendarEvent/queryChanges``.
155
+ - ``total``: Total number of matching events (may exceed ``len(ids)``
156
+ when a limit was applied).
157
+ """
158
+ ids: list[str] = response_args.get("ids", [])
159
+ query_state: str = response_args.get("queryState", "")
160
+ total: int = response_args.get("total", len(ids))
161
+ return ids, query_state, total
162
+
163
+
164
+ def build_event_query_changes(
165
+ account_id: str,
166
+ since_query_state: str,
167
+ filter_condition: dict | None = None,
168
+ sort: list[dict] | None = None,
169
+ max_changes: int | None = None,
170
+ ) -> tuple:
171
+ """Build a ``CalendarEvent/queryChanges`` method call tuple.
172
+
173
+ Args:
174
+ account_id: The JMAP accountId to query.
175
+ since_query_state: The ``queryState`` string from a previous
176
+ ``CalendarEvent/query`` or ``CalendarEvent/queryChanges`` response.
177
+ filter_condition: Same filter as the original ``CalendarEvent/query`` call.
178
+ sort: Same sort as the original ``CalendarEvent/query`` call.
179
+ max_changes: Optional upper bound on the number of changes returned.
180
+
181
+ Returns:
182
+ A 3-tuple ``("CalendarEvent/queryChanges", arguments_dict, call_id)``.
183
+ """
184
+ args: dict = {"accountId": account_id, "sinceQueryState": since_query_state}
185
+ if filter_condition is not None:
186
+ args["filter"] = filter_condition
187
+ if sort is not None:
188
+ args["sort"] = sort
189
+ if max_changes is not None:
190
+ args["maxChanges"] = max_changes
191
+ return ("CalendarEvent/queryChanges", args, "ev-qchanges-0")
192
+
193
+
194
+ def build_event_set_create(
195
+ account_id: str,
196
+ events: dict[str, dict],
197
+ ) -> tuple:
198
+ """Build a ``CalendarEvent/set`` method call for creating events.
199
+
200
+ Args:
201
+ account_id: The JMAP accountId.
202
+ events: Map of client-assigned creation ID → JSCalendar dict.
203
+ The creation IDs are ephemeral — they are used to correlate
204
+ server responses with individual creation requests within the
205
+ same batch call.
206
+
207
+ Returns:
208
+ A 3-tuple ``("CalendarEvent/set", arguments_dict, call_id)``.
209
+ """
210
+ return (
211
+ "CalendarEvent/set",
212
+ {
213
+ "accountId": account_id,
214
+ "create": dict(events),
215
+ },
216
+ "ev-set-create-0",
217
+ )
218
+
219
+
220
+ def build_event_set_update(
221
+ account_id: str,
222
+ updates: dict[str, dict],
223
+ ) -> tuple:
224
+ """Build a ``CalendarEvent/set`` method call for updating events.
225
+
226
+ Args:
227
+ account_id: The JMAP accountId.
228
+ updates: Map of event ID → partial patch dict. Keys are property
229
+ names (or JSON Pointer paths for nested properties); values are
230
+ the new values. Use ``None`` as a value to reset a property to
231
+ its server default.
232
+
233
+ Returns:
234
+ A 3-tuple ``("CalendarEvent/set", arguments_dict, call_id)``.
235
+ """
236
+ return (
237
+ "CalendarEvent/set",
238
+ {"accountId": account_id, "update": updates},
239
+ "ev-set-update-0",
240
+ )
241
+
242
+
243
+ def build_event_set_destroy(
244
+ account_id: str,
245
+ ids: list[str],
246
+ ) -> tuple:
247
+ """Build a ``CalendarEvent/set`` method call for destroying events.
248
+
249
+ Args:
250
+ account_id: The JMAP accountId.
251
+ ids: List of event IDs to destroy.
252
+
253
+ Returns:
254
+ A 3-tuple ``("CalendarEvent/set", arguments_dict, call_id)``.
255
+ """
256
+ return (
257
+ "CalendarEvent/set",
258
+ {"accountId": account_id, "destroy": ids},
259
+ "ev-set-destroy-0",
260
+ )
261
+
262
+
263
+ def parse_event_set(
264
+ response_args: dict,
265
+ ) -> tuple[dict, dict, list[str], dict, dict, dict]:
266
+ """Parse the arguments dict from a ``CalendarEvent/set`` method response.
267
+
268
+ Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``.
269
+ See :func:`calendaring_jmap._methods.parse_set_response` for field semantics.
270
+ """
271
+ return parse_set_response(response_args)