Skip to content

Commit b1f4b55

Browse files
feat(jmap): add Task and TaskList support via RFC 9553
1 parent 23d578b commit b1f4b55

4 files changed

Lines changed: 918 additions & 5 deletions

File tree

caldav/jmap/client.py

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from __future__ import annotations
1212

1313
import logging
14+
import uuid
1415

1516
try:
1617
import niquests as requests
@@ -19,7 +20,7 @@
1920
import requests # type: ignore[no-redef]
2021
from requests.auth import HTTPBasicAuth # type: ignore[no-redef]
2122

22-
from caldav.jmap.constants import CALENDAR_CAPABILITY, CORE_CAPABILITY
23+
from caldav.jmap.constants import CALENDAR_CAPABILITY, CORE_CAPABILITY, TASK_CAPABILITY
2324
from caldav.jmap.convert import ical_to_jscal, jscal_to_ical
2425
from caldav.jmap.error import JMAPAuthError, JMAPMethodError
2526
from caldav.jmap.methods.calendar import build_calendar_get, parse_calendar_get
@@ -32,13 +33,24 @@
3233
parse_event_changes,
3334
parse_event_set,
3435
)
36+
from caldav.jmap.methods.task import (
37+
build_task_get,
38+
build_task_list_get,
39+
build_task_set_create,
40+
build_task_set_destroy,
41+
build_task_set_update,
42+
parse_task_list_get,
43+
parse_task_set,
44+
)
3545
from caldav.jmap.objects.calendar import JMAPCalendar
46+
from caldav.jmap.objects.task import JMAPTask, JMAPTaskList
3647
from caldav.jmap.session import Session, fetch_session
3748
from caldav.requests import HTTPBearerAuth
3849

3950
log = logging.getLogger("caldav.jmap")
4051

4152
_DEFAULT_USING = [CORE_CAPABILITY, CALENDAR_CAPABILITY]
53+
_TASK_USING = [CORE_CAPABILITY, TASK_CAPABILITY]
4254

4355

4456
class JMAPClient:
@@ -130,7 +142,7 @@ def _build_auth(self, auth_type: str | None):
130142
def _raise_set_error(self, session: Session, err: dict) -> None:
131143
raise JMAPMethodError(
132144
url=session.api_url,
133-
reason=f"CalendarEvent/set failed: {err}",
145+
reason=f"set failed: {err}",
134146
error_type=err.get("type", "serverError"),
135147
)
136148

@@ -140,11 +152,13 @@ def _get_session(self) -> Session:
140152
self._session_cache = fetch_session(self.url, auth=self._auth, timeout=self.timeout)
141153
return self._session_cache
142154

143-
def _request(self, method_calls: list[tuple]) -> list:
155+
def _request(self, method_calls: list[tuple], using: list[str] | None = None) -> list:
144156
"""POST a batch of JMAP method calls and return the methodResponses.
145157
146158
Args:
147159
method_calls: List of 3-tuples ``(method_name, args_dict, call_id)``.
160+
using: Capability URN list for the ``using`` field. Defaults to
161+
``_DEFAULT_USING`` (core + calendars).
148162
149163
Returns:
150164
List of 3-tuples ``(method_name, response_args, call_id)`` from
@@ -158,7 +172,7 @@ def _request(self, method_calls: list[tuple]) -> list:
158172
session = self._get_session()
159173

160174
payload = {
161-
"using": _DEFAULT_USING,
175+
"using": using if using is not None else _DEFAULT_USING,
162176
"methodCalls": list(method_calls),
163177
}
164178

@@ -450,3 +464,129 @@ def delete_event(self, event_id: str) -> None:
450464
return
451465

452466
raise JMAPMethodError(url=session.api_url, reason="No CalendarEvent/set response")
467+
468+
def get_task_lists(self) -> list[JMAPTaskList]:
469+
"""Fetch all task lists for the authenticated account.
470+
471+
Returns:
472+
List of :class:`~caldav.jmap.objects.task.JMAPTaskList` objects.
473+
"""
474+
session = self._get_session()
475+
call = build_task_list_get(session.account_id)
476+
responses = self._request([call], using=_TASK_USING)
477+
478+
for method_name, resp_args, _ in responses:
479+
if method_name == "TaskList/get":
480+
return parse_task_list_get(resp_args)
481+
482+
return []
483+
484+
def create_task(self, task_list_id: str, title: str, **kwargs) -> str:
485+
"""Create a task in a task list.
486+
487+
Args:
488+
task_list_id: The JMAP task list ID to create the task in.
489+
title: Task title (maps to VTODO ``SUMMARY``).
490+
**kwargs: Optional task fields: ``description``, ``due``, ``start``,
491+
``time_zone``, ``estimated_duration``, ``percent_complete``,
492+
``progress``, ``priority``.
493+
494+
Returns:
495+
The server-assigned JMAP task ID.
496+
497+
Raises:
498+
JMAPMethodError: If the server rejects the create request.
499+
"""
500+
session = self._get_session()
501+
task = JMAPTask(
502+
id="",
503+
uid=str(uuid.uuid4()),
504+
task_list_id=task_list_id,
505+
title=title,
506+
**kwargs,
507+
)
508+
call = build_task_set_create(session.account_id, {"new-0": task})
509+
responses = self._request([call], using=_TASK_USING)
510+
511+
for method_name, resp_args, _ in responses:
512+
if method_name == "Task/set":
513+
created, _, _, not_created, _, _ = parse_task_set(resp_args)
514+
if "new-0" in not_created:
515+
self._raise_set_error(session, not_created["new-0"])
516+
return created["new-0"]["id"]
517+
518+
raise JMAPMethodError(url=session.api_url, reason="No Task/set response")
519+
520+
def get_task(self, task_id: str) -> JMAPTask:
521+
"""Fetch a task by ID.
522+
523+
Args:
524+
task_id: The JMAP task ID to retrieve.
525+
526+
Returns:
527+
A :class:`~caldav.jmap.objects.task.JMAPTask` object.
528+
529+
Raises:
530+
JMAPMethodError: If the task is not found.
531+
"""
532+
session = self._get_session()
533+
call = build_task_get(session.account_id, ids=[task_id])
534+
responses = self._request([call], using=_TASK_USING)
535+
536+
for method_name, resp_args, _ in responses:
537+
if method_name == "Task/get":
538+
items = resp_args.get("list", [])
539+
if not items:
540+
raise JMAPMethodError(
541+
url=session.api_url,
542+
reason=f"Task not found: {task_id}",
543+
error_type="notFound",
544+
)
545+
return JMAPTask.from_jmap(items[0])
546+
547+
raise JMAPMethodError(url=session.api_url, reason="No Task/get response")
548+
549+
def update_task(self, task_id: str, patch: dict) -> None:
550+
"""Update a task with a partial patch.
551+
552+
Args:
553+
task_id: The JMAP task ID to update.
554+
patch: Partial patch dict mapping property names to new values.
555+
556+
Raises:
557+
JMAPMethodError: If the server rejects the update.
558+
"""
559+
session = self._get_session()
560+
call = build_task_set_update(session.account_id, {task_id: patch})
561+
responses = self._request([call], using=_TASK_USING)
562+
563+
for method_name, resp_args, _ in responses:
564+
if method_name == "Task/set":
565+
_, _, _, _, not_updated, _ = parse_task_set(resp_args)
566+
if task_id in not_updated:
567+
self._raise_set_error(session, not_updated[task_id])
568+
return
569+
570+
raise JMAPMethodError(url=session.api_url, reason="No Task/set response")
571+
572+
def delete_task(self, task_id: str) -> None:
573+
"""Delete a task.
574+
575+
Args:
576+
task_id: The JMAP task ID to delete.
577+
578+
Raises:
579+
JMAPMethodError: If the server rejects the delete.
580+
"""
581+
session = self._get_session()
582+
call = build_task_set_destroy(session.account_id, [task_id])
583+
responses = self._request([call], using=_TASK_USING)
584+
585+
for method_name, resp_args, _ in responses:
586+
if method_name == "Task/set":
587+
_, _, _, _, _, not_destroyed = parse_task_set(resp_args)
588+
if task_id in not_destroyed:
589+
self._raise_set_error(session, not_destroyed[task_id])
590+
return
591+
592+
raise JMAPMethodError(url=session.api_url, reason="No Task/set response")

caldav/jmap/methods/task.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""
2+
JMAP Task and TaskList method builders and response parsers.
3+
4+
These are pure functions — no HTTP, no state. They build the request
5+
tuples that go into a ``methodCalls`` list, and parse the corresponding
6+
``methodResponses`` entries.
7+
8+
Method shapes follow RFC 8620 §3.3 (get), §3.5 (set); Task-specific
9+
properties are defined in RFC 9553 (JMAP for Tasks).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from caldav.jmap.objects.task import JMAPTask, JMAPTaskList
15+
16+
17+
def build_task_list_get(
18+
account_id: str,
19+
ids: list[str] | None = None,
20+
properties: list[str] | None = None,
21+
) -> tuple:
22+
"""Build a ``TaskList/get`` method call tuple.
23+
24+
Args:
25+
account_id: The JMAP accountId to query.
26+
ids: List of task list IDs to fetch, or ``None`` to fetch all.
27+
properties: List of property names to return, or ``None`` for all.
28+
29+
Returns:
30+
A 3-tuple ``("TaskList/get", arguments_dict, call_id)`` suitable
31+
for inclusion in a ``methodCalls`` list.
32+
"""
33+
args: dict = {"accountId": account_id, "ids": ids}
34+
if properties is not None:
35+
args["properties"] = properties
36+
return ("TaskList/get", args, "tasklist-get-0")
37+
38+
39+
def parse_task_list_get(response_args: dict) -> list[JMAPTaskList]:
40+
"""Parse the arguments dict from a ``TaskList/get`` method response.
41+
42+
Args:
43+
response_args: The second element of a ``methodResponses`` entry
44+
whose method name is ``"TaskList/get"``.
45+
46+
Returns:
47+
List of :class:`~caldav.jmap.objects.task.JMAPTaskList` objects.
48+
Returns an empty list if ``"list"`` is absent or empty.
49+
"""
50+
return [JMAPTaskList.from_jmap(item) for item in response_args.get("list", [])]
51+
52+
53+
def build_task_get(
54+
account_id: str,
55+
ids: list[str] | None = None,
56+
properties: list[str] | None = None,
57+
) -> tuple:
58+
"""Build a ``Task/get`` method call tuple.
59+
60+
Args:
61+
account_id: The JMAP accountId to query.
62+
ids: List of task IDs to fetch, or ``None`` to fetch all.
63+
properties: List of property names to return, or ``None`` for all.
64+
65+
Returns:
66+
A 3-tuple ``("Task/get", arguments_dict, call_id)``.
67+
"""
68+
args: dict = {"accountId": account_id, "ids": ids}
69+
if properties is not None:
70+
args["properties"] = properties
71+
return ("Task/get", args, "task-get-0")
72+
73+
74+
def parse_task_get(response_args: dict) -> list[JMAPTask]:
75+
"""Parse the arguments dict from a ``Task/get`` method response.
76+
77+
Args:
78+
response_args: The second element of a ``methodResponses`` entry
79+
whose method name is ``"Task/get"``.
80+
81+
Returns:
82+
List of :class:`~caldav.jmap.objects.task.JMAPTask` objects.
83+
Returns an empty list if ``"list"`` is absent or empty.
84+
"""
85+
return [JMAPTask.from_jmap(item) for item in response_args.get("list", [])]
86+
87+
88+
def build_task_set_create(
89+
account_id: str,
90+
tasks: dict[str, JMAPTask],
91+
) -> tuple:
92+
"""Build a ``Task/set`` method call for creating tasks.
93+
94+
Args:
95+
account_id: The JMAP accountId.
96+
tasks: Map of client-assigned creation ID → :class:`JMAPTask`.
97+
98+
Returns:
99+
A 3-tuple ``("Task/set", arguments_dict, call_id)``.
100+
"""
101+
return (
102+
"Task/set",
103+
{
104+
"accountId": account_id,
105+
"create": {cid: task.to_jmap() for cid, task in tasks.items()},
106+
},
107+
"task-set-create-0",
108+
)
109+
110+
111+
def build_task_set_update(
112+
account_id: str,
113+
updates: dict[str, dict],
114+
) -> tuple:
115+
"""Build a ``Task/set`` method call for updating tasks.
116+
117+
Args:
118+
account_id: The JMAP accountId.
119+
updates: Map of task ID → partial patch dict.
120+
121+
Returns:
122+
A 3-tuple ``("Task/set", arguments_dict, call_id)``.
123+
"""
124+
return (
125+
"Task/set",
126+
{"accountId": account_id, "update": updates},
127+
"task-set-update-0",
128+
)
129+
130+
131+
def build_task_set_destroy(
132+
account_id: str,
133+
ids: list[str],
134+
) -> tuple:
135+
"""Build a ``Task/set`` method call for destroying tasks.
136+
137+
Args:
138+
account_id: The JMAP accountId.
139+
ids: List of task IDs to destroy.
140+
141+
Returns:
142+
A 3-tuple ``("Task/set", arguments_dict, call_id)``.
143+
"""
144+
return (
145+
"Task/set",
146+
{"accountId": account_id, "destroy": ids},
147+
"task-set-destroy-0",
148+
)
149+
150+
151+
def parse_task_set(
152+
response_args: dict,
153+
) -> tuple[dict, dict, list[str], dict, dict, dict]:
154+
"""Parse the arguments dict from a ``Task/set`` method response.
155+
156+
Args:
157+
response_args: The second element of a ``methodResponses`` entry
158+
whose method name is ``"Task/set"``.
159+
160+
Returns:
161+
A 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``:
162+
163+
- ``created``: Map of creation ID → server-assigned task dict.
164+
- ``updated``: Map of task ID → null or partial server-updated object.
165+
- ``destroyed``: List of successfully destroyed task IDs.
166+
- ``not_created``: Map of creation ID → SetError dict for failed creates.
167+
- ``not_updated``: Map of task ID → SetError dict for failed updates.
168+
- ``not_destroyed``: Map of task ID → SetError dict for failed destroys.
169+
"""
170+
created: dict = response_args.get("created") or {}
171+
updated: dict = response_args.get("updated") or {}
172+
destroyed: list[str] = response_args.get("destroyed") or []
173+
not_created: dict = response_args.get("notCreated") or {}
174+
not_updated: dict = response_args.get("notUpdated") or {}
175+
not_destroyed: dict = response_args.get("notDestroyed") or {}
176+
return created, updated, destroyed, not_created, not_updated, not_destroyed

0 commit comments

Comments
 (0)