Skip to content

Commit cf6dfa7

Browse files
tobixenclaude
andcommitted
fix: async support for uncomplete(), set_relation(), get_relatives(), invite methods
Similar to the save()/complete() fix in e819a3a, these methods called self.save() or self.parent.get_object_by_uid() without awaiting, so in async mode the returned coroutines were silently discarded. Changes: - uncomplete(): delegate to new _async_uncomplete() when is_async_client - set_relation(): refactor uid/other resolution to share between sync and async paths; delegate to new _async_set_relation() when is_async_client; extract _add_relation_to_ical() helper to avoid duplicating ical logic - get_relatives(): extract _parse_relatives_from_ical() helper (pure, no I/O) to avoid duplicating ical-parsing logic; delegate to new _async_get_relatives() when is_async_client - _reply_to_invite_request() / accept_invite() / decline_invite() / tentatively_accept_invite(): raise NotImplementedError for async clients (the entire invite reply flow uses load/save/add_event/schedule_outbox which all require async-aware wiring not yet implemented) Tests: add TestAsyncCalendarObjectResource with 6 unit tests that verify each fixed method returns a coroutine for async clients and that awaiting it produces the expected side-effects. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d131af0 commit cf6dfa7

2 files changed

Lines changed: 255 additions & 18 deletions

File tree

caldav/calendarobjectresource.py

Lines changed: 93 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -283,16 +283,27 @@ def set_relation(
283283
else:
284284
# Use cheap accessor to avoid format conversion (issue #613)
285285
uid = other._get_uid_cheap() or other.icalendar_component["uid"]
286+
other_obj = other
286287
else:
287288
uid = other
288-
if set_reverse:
289-
other = self.parent.get_object_by_uid(uid)
289+
other_obj = None # Resolved below (possibly async)
290+
291+
if self.is_async_client:
292+
return self._async_set_relation(uid, other_obj, reltype, set_reverse)
293+
294+
if other_obj is None and set_reverse:
295+
other_obj = self.parent.get_object_by_uid(uid)
290296
if set_reverse:
291297
## TODO: special handling of NEXT/FIRST.
292298
## STARTTOFINISH does not have any equivalent "reverse".
293299
reltype_reverse = self.RELTYPE_REVERSE_MAP[reltype]
294-
other.set_relation(other=self, reltype=reltype_reverse, set_reverse=False)
300+
other_obj.set_relation(other=self, reltype=reltype_reverse, set_reverse=False)
301+
302+
self._add_relation_to_ical(uid, reltype)
303+
self.save()
295304

305+
def _add_relation_to_ical(self, uid, reltype) -> None:
306+
"""Add a RELATED-TO property to the icalendar component (no-op if already present)."""
296307
existing_relation = self.icalendar_component.get("related-to", None)
297308
existing_relations = (
298309
existing_relation if isinstance(existing_relation, list) else [existing_relation]
@@ -306,16 +317,45 @@ def set_relation(
306317
# then Component._encode does miss adding properties
307318
# see https://github.com/collective/icalendar/issues/557
308319
# workaround should be safe to remove if issue gets fixed
309-
uid = str(uid)
310320
self.icalendar_component.add(
311-
"related-to", uid, parameters={"RELTYPE": reltype}, encode=True
321+
"related-to", str(uid), parameters={"RELTYPE": reltype}, encode=True
312322
)
313323

314-
self.save()
324+
async def _async_set_relation(self, uid, other_obj, reltype, set_reverse) -> None:
325+
"""Async implementation of set_relation() for async clients."""
326+
if other_obj is None and set_reverse:
327+
other_obj = await self.parent.get_object_by_uid(uid)
328+
if set_reverse:
329+
## TODO: special handling of NEXT/FIRST.
330+
reltype_reverse = self.RELTYPE_REVERSE_MAP[reltype]
331+
# set_relation() returns a coroutine when is_async_client, so await it
332+
await other_obj.set_relation(other=self, reltype=reltype_reverse, set_reverse=False)
333+
334+
self._add_relation_to_ical(uid, reltype)
335+
await self.save()
315336

316337
## TODO: this method is undertested in the caldav library.
317338
## However, as this consolidated and eliminated quite some duplicated code in the
318339
## plann project, it is extensively tested in plann.
340+
def _parse_relatives_from_ical(
341+
self,
342+
reltypes: "Container[str] | None",
343+
relfilter: "Callable[[Any], bool] | None",
344+
) -> "defaultdict[str, set[str]]":
345+
"""Extract RELATED-TO properties as a {reltype: {uid, ...}} dict (pure, no I/O)."""
346+
ret: defaultdict[str, set[str]] = defaultdict(set)
347+
relations = self.icalendar_component.get("RELATED-TO", [])
348+
if not isinstance(relations, list):
349+
relations = [relations]
350+
for rel in relations:
351+
if relfilter and not relfilter(rel):
352+
continue
353+
reltype = rel.params.get("RELTYPE", "PARENT")
354+
if reltypes and reltype not in reltypes:
355+
continue
356+
ret[reltype].add(str(rel))
357+
return ret
358+
319359
def get_relatives(
320360
self,
321361
reltypes: Container[str] | None = None,
@@ -340,24 +380,17 @@ def get_relatives(
340380
(but due to backward compatibility requirement, such an object should behave like
341381
the current dict)
342382
"""
383+
if self.is_async_client:
384+
return self._async_get_relatives(reltypes, relfilter, fetch_objects, ignore_missing)
385+
343386
from .collection import Calendar ## late import to avoid cycling imports
344387

345-
ret = defaultdict(set)
346-
relations = self.icalendar_component.get("RELATED-TO", [])
347-
if not isinstance(relations, list):
348-
relations = [relations]
349-
for rel in relations:
350-
if relfilter and not relfilter(rel):
351-
continue
352-
reltype = rel.params.get("RELTYPE", "PARENT")
353-
if reltypes and reltype not in reltypes:
354-
continue
355-
ret[reltype].add(str(rel))
388+
ret = self._parse_relatives_from_ical(reltypes, relfilter)
356389

357390
if fetch_objects:
358391
for reltype in ret:
359392
uids = ret[reltype]
360-
reltype_set = set()
393+
reltype_set: set = set()
361394

362395
if self.parent is None:
363396
raise ValueError("Unexpected value None for self.parent")
@@ -376,6 +409,37 @@ def get_relatives(
376409

377410
return ret
378411

412+
async def _async_get_relatives(
413+
self,
414+
reltypes: "Container[str] | None",
415+
relfilter: "Callable[[Any], bool] | None",
416+
fetch_objects: bool,
417+
ignore_missing: bool,
418+
) -> "defaultdict[str, set]":
419+
"""Async implementation of get_relatives() for async clients."""
420+
from .collection import Calendar ## late import to avoid cycling imports
421+
422+
ret = self._parse_relatives_from_ical(reltypes, relfilter)
423+
424+
if fetch_objects:
425+
if self.parent is None:
426+
raise ValueError("Unexpected value None for self.parent")
427+
if not isinstance(self.parent, Calendar):
428+
raise ValueError("self.parent expected to be of type Calendar but it is not")
429+
430+
for reltype in ret:
431+
uids = ret[reltype]
432+
reltype_set: set = set()
433+
for obj in uids:
434+
try:
435+
reltype_set.add(await self.parent.get_object_by_uid(obj))
436+
except error.NotFoundError:
437+
if not ignore_missing:
438+
raise
439+
ret[reltype] = reltype_set
440+
441+
return ret
442+
379443
def _set_reverse_relation(self, other, reltype):
380444
## TODO: handle RFC9253 better! Particularly next/first-lists
381445
reverse_reltype = self.RELTYPE_REVERSE_MAP.get(reltype)
@@ -621,6 +685,11 @@ def tentatively_accept_invite(self, calendar: Any | None = None) -> None:
621685
## partstat can also be set to COMPLETED and IN-PROGRESS.
622686

623687
def _reply_to_invite_request(self, partstat, calendar) -> None:
688+
if self.is_async_client:
689+
raise NotImplementedError(
690+
"accept_invite/decline_invite/tentatively_accept_invite are not yet supported "
691+
"for async clients"
692+
)
624693
error.assert_(self.is_invite_request())
625694
if not calendar:
626695
calendar = self.client.principal().get_calendars()[0]
@@ -1966,8 +2035,14 @@ def uncomplete(self) -> None:
19662035
self.icalendar_component.add("status", "NEEDS-ACTION")
19672036
if "completed" in self.icalendar_component:
19682037
self.icalendar_component.pop("completed")
2038+
if self.is_async_client:
2039+
return self._async_uncomplete()
19692040
self.save()
19702041

2042+
async def _async_uncomplete(self) -> None:
2043+
"""Async implementation of uncomplete() for async clients."""
2044+
await self.save()
2045+
19712046
## TODO: should be moved up to the base class
19722047
def set_duration(self, duration, movable_attr="DTSTART"):
19732048
"""

tests/test_caldav_unit.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1902,6 +1902,168 @@ async def fake_search(**kwargs):
19021902
assert obj.id == uid
19031903

19041904

1905+
class TestAsyncCalendarObjectResource:
1906+
"""Tests that CalendarObjectResource methods return coroutines (not None) for async clients.
1907+
1908+
These guard against the pattern where a sync method calls self.save() or
1909+
self.parent.some_method() without awaiting, silently discarding the coroutine.
1910+
"""
1911+
1912+
completed_todo = """BEGIN:VCALENDAR
1913+
VERSION:2.0
1914+
PRODID:-//Example Corp.//CalDAV Client//EN
1915+
BEGIN:VTODO
1916+
UID:20070313T123432Z-456553@example.com
1917+
DTSTAMP:20070313T123432Z
1918+
DUE;VALUE=DATE:20070501
1919+
SUMMARY:Submit Quebec Income Tax Return for 2006
1920+
STATUS:COMPLETED
1921+
COMPLETED:20070501T000000Z
1922+
END:VTODO
1923+
END:VCALENDAR"""
1924+
1925+
todo_with_relation = """BEGIN:VCALENDAR
1926+
VERSION:2.0
1927+
PRODID:-//Example Corp.//CalDAV Client//EN
1928+
BEGIN:VTODO
1929+
UID:20070313T123432Z-456553@example.com
1930+
DTSTAMP:20070313T123432Z
1931+
DUE;VALUE=DATE:20070501
1932+
SUMMARY:Submit Quebec Income Tax Return for 2006
1933+
RELATED-TO;RELTYPE=PARENT:parent-uid-001
1934+
STATUS:NEEDS-ACTION
1935+
END:VTODO
1936+
END:VCALENDAR"""
1937+
1938+
def _make_async_client_and_calendar(self):
1939+
from caldav.async_davclient import AsyncDAVClient
1940+
1941+
client = MockedDAVClient("")
1942+
client.__class__ = type(
1943+
"AsyncDAVClient", (MockedDAVClient,), {"__module__": AsyncDAVClient.__module__}
1944+
)
1945+
calendar = Calendar(client, url="/calendar/")
1946+
return client, calendar
1947+
1948+
def test_uncomplete_returns_coroutine_for_async_client(self):
1949+
"""uncomplete() must return a coroutine for async clients, not silently discard save()."""
1950+
import asyncio
1951+
1952+
client, calendar = self._make_async_client_and_calendar()
1953+
todo = Todo(
1954+
client=client,
1955+
url="/calendar/todo1.ics",
1956+
data=self.completed_todo,
1957+
parent=calendar,
1958+
)
1959+
result = todo.uncomplete()
1960+
assert asyncio.iscoroutine(result), (
1961+
f"expected coroutine from uncomplete(), got {type(result)}"
1962+
)
1963+
result.close()
1964+
1965+
def test_uncomplete_async_saves_and_clears_status(self):
1966+
"""Awaiting uncomplete() must actually save the object and clear STATUS/COMPLETED."""
1967+
import asyncio
1968+
1969+
client, calendar = self._make_async_client_and_calendar()
1970+
todo = Todo(
1971+
client=client,
1972+
url="/calendar/todo1.ics",
1973+
data=self.completed_todo,
1974+
parent=calendar,
1975+
)
1976+
1977+
saved = False
1978+
1979+
async def fake_async_put(*args, **kwargs):
1980+
nonlocal saved
1981+
saved = True
1982+
1983+
todo._async_put = fake_async_put
1984+
asyncio.run(todo.uncomplete())
1985+
1986+
assert saved, "uncomplete() did not call _async_put for async client"
1987+
assert todo.icalendar_component.get("STATUS") == "NEEDS-ACTION"
1988+
assert "COMPLETED" not in todo.icalendar_component
1989+
1990+
def test_get_relatives_returns_coroutine_for_async_client(self):
1991+
"""get_relatives(fetch_objects=True) must return a coroutine for async clients."""
1992+
import asyncio
1993+
1994+
client, calendar = self._make_async_client_and_calendar()
1995+
todo = Todo(
1996+
client=client,
1997+
url="/calendar/todo1.ics",
1998+
data=self.todo_with_relation,
1999+
parent=calendar,
2000+
)
2001+
result = todo.get_relatives()
2002+
assert asyncio.iscoroutine(result), (
2003+
f"expected coroutine from get_relatives(), got {type(result)}"
2004+
)
2005+
result.close()
2006+
2007+
def test_get_relatives_async_returns_objects(self):
2008+
"""Awaiting get_relatives() must return fetched objects, not coroutines."""
2009+
import asyncio
2010+
2011+
client, calendar = self._make_async_client_and_calendar()
2012+
todo = Todo(
2013+
client=client,
2014+
url="/calendar/todo1.ics",
2015+
data=self.todo_with_relation,
2016+
parent=calendar,
2017+
)
2018+
2019+
parent_todo = Todo(client=client, url="/calendar/parent.ics", data=ev1, parent=calendar)
2020+
2021+
async def fake_get_object_by_uid(uid):
2022+
return parent_todo
2023+
2024+
calendar.get_object_by_uid = fake_get_object_by_uid
2025+
2026+
result = asyncio.run(todo.get_relatives())
2027+
assert "PARENT" in result
2028+
parent_set = result["PARENT"]
2029+
assert len(parent_set) == 1
2030+
obj = next(iter(parent_set))
2031+
assert obj is parent_todo, f"expected the parent todo object, got {obj!r}"
2032+
2033+
def test_set_relation_returns_coroutine_for_async_client(self):
2034+
"""set_relation() must return a coroutine for async clients, not silently drop save()."""
2035+
import asyncio
2036+
2037+
client, calendar = self._make_async_client_and_calendar()
2038+
todo = Todo(
2039+
client=client,
2040+
url="/calendar/todo1.ics",
2041+
data=self.completed_todo,
2042+
parent=calendar,
2043+
)
2044+
other_todo = Todo(
2045+
client=client,
2046+
url="/calendar/todo2.ics",
2047+
data=self.completed_todo,
2048+
parent=calendar,
2049+
)
2050+
# Patch id so set_relation can extract a UID without a full ical parse
2051+
other_todo._id = "some-other-uid"
2052+
2053+
result = todo.set_relation(other_todo, reltype="PARENT", set_reverse=False)
2054+
assert asyncio.iscoroutine(result), (
2055+
f"expected coroutine from set_relation(), got {type(result)}"
2056+
)
2057+
result.close()
2058+
2059+
def test_accept_invite_raises_not_implemented_for_async_client(self):
2060+
"""accept_invite() must raise NotImplementedError for async clients (not silently fail)."""
2061+
client, calendar = self._make_async_client_and_calendar()
2062+
event = Event(client=client, url="/calendar/ev1.ics", data=ev1, parent=calendar)
2063+
with pytest.raises(NotImplementedError):
2064+
event.accept_invite()
2065+
2066+
19052067
class TestRateLimitHelpers:
19062068
"""Unit tests for the shared rate-limit helper functions in caldav.lib.error."""
19072069

0 commit comments

Comments
 (0)