Skip to content

Commit e2bdee7

Browse files
tobixenclaude
andcommitted
fix: async support for save() recurrence handling and complete()
save() called get_self() (a sync helper that invokes parent collection methods) even for async clients, but those methods return unawaited coroutines in async mode, causing AttributeError when accessing .icalendar_instance on the coroutine. Fix by delegating to _async_save() at the top of save() when is_async_client is True, with an async get_self() that properly awaits the parent collection methods. Similarly, complete() returned None for async clients (self.save() returned a coroutine that was ignored), so "await task.complete()" raised TypeError: 'NoneType' object can't be awaited. Fix by adding _async_complete() that awaits self.save(). To avoid duplicating the icalendar manipulation logic, extract three shared helpers: - _validate_save_constraints(): raises on no_overwrite/no_create violations - _incorporate_recurrence_into_parent(): merges a recurrence instance into the parent event's icalendar_instance (pure, no I/O) - _maybe_increment_sequence(): increments SEQUENCE if present The now-redundant _async_save_final() is removed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 022840d commit e2bdee7

1 file changed

Lines changed: 149 additions & 86 deletions

File tree

caldav/calendarobjectresource.py

Lines changed: 149 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,19 @@ def save(
10011001
if not self.is_loaded():
10021002
return self
10031003

1004+
# Delegate to async version for async clients (all logic that calls parent
1005+
# collection methods must be async-aware to avoid getting unawaited coroutines)
1006+
if self.is_async_client:
1007+
return self._async_save(
1008+
no_overwrite=no_overwrite,
1009+
no_create=no_create,
1010+
obj_type=obj_type,
1011+
increase_seqno=increase_seqno,
1012+
if_schedule_tag_match=if_schedule_tag_match,
1013+
only_this_recurrence=only_this_recurrence,
1014+
all_recurrences=all_recurrences,
1015+
)
1016+
10041017
# Helper function to get the full object by UID
10051018
def get_self():
10061019
from caldav.lib import error
@@ -1022,113 +1035,145 @@ def get_self():
10221035
return None
10231036
return None
10241037

1025-
# Handle no_overwrite/no_create validation BEFORE async delegation
1026-
# This must be done here because it requires collection methods (get_event_by_uid, etc.)
1027-
# which are sync and can't be called from async context (nested event loop issue)
10281038
if no_overwrite or no_create:
1029-
from caldav.lib import error
1030-
1031-
if not obj_type:
1032-
obj_type = self.__class__.__name__.lower()
1033-
1034-
# Determine the ID
10351039
uid = self.id or self.icalendar_component.get("uid")
1036-
1037-
# Check if object exists using parent collection methods
10381040
existing = get_self()
1041+
self._validate_save_constraints(existing, uid, no_overwrite, no_create)
10391042

1040-
# Validate constraints
1041-
if not uid and no_create:
1042-
raise error.ConsistencyError("no_create flag was set, but no ID given")
1043-
if no_overwrite and existing:
1044-
raise error.ConsistencyError("no_overwrite flag was set, but object already exists")
1045-
if no_create and not existing:
1046-
raise error.ConsistencyError("no_create flag was set, but object does not exist")
1047-
1048-
# Handle recurrence instances BEFORE async delegation
1049-
# When saving a single recurrence instance, we need to:
1050-
# - Get the full recurring event from the server
1051-
# - Add/update the recurrence instance in the event's subcomponents
1052-
# - Save the full event back
1053-
# This prevents overwriting the entire recurring event with just one instance
10541043
if (
10551044
only_this_recurrence or all_recurrences
10561045
) and "RECURRENCE-ID" in self.icalendar_component:
1057-
import icalendar
1058-
10591046
from caldav.lib import error
10601047

1061-
obj = get_self() # Get the full object, not only the recurrence
1048+
obj = get_self()
10621049
if obj is None:
10631050
raise error.NotFoundError("Could not find parent recurring event")
1051+
self._incorporate_recurrence_into_parent(obj, only_this_recurrence, all_recurrences)
1052+
return obj.save(increase_seqno=increase_seqno)
10641053

1065-
ici = obj.icalendar_instance # ical instance
1066-
1067-
if all_recurrences:
1068-
occ = obj.icalendar_component # original calendar component
1069-
ncc = self.icalendar_component.copy() # new calendar component
1070-
for prop in ["exdate", "exrule", "rdate", "rrule"]:
1071-
if prop in occ:
1072-
ncc[prop] = occ[prop]
1073-
1074-
# dtstart_diff = how much we've moved the time
1075-
dtstart_diff = ncc.start.astimezone() - ncc["recurrence-id"].dt.astimezone()
1076-
new_duration = ncc.duration
1077-
ncc.pop("dtstart")
1078-
ncc.add("dtstart", occ.start + dtstart_diff)
1079-
for ep in ("duration", "dtend"):
1080-
if ep in ncc:
1081-
ncc.pop(ep)
1082-
ncc.add("dtend", ncc.start + new_duration)
1083-
ncc.pop("recurrence-id")
1084-
s = ici.subcomponents
1085-
1086-
# Replace the "root" subcomponent
1087-
comp_idxes = [
1088-
i for i in range(0, len(s)) if not isinstance(s[i], icalendar.Timezone)
1089-
]
1090-
comp_idx = comp_idxes[0]
1091-
s[comp_idx] = ncc
1092-
1093-
# The recurrence-ids of all objects has to be recalculated
1094-
if dtstart_diff:
1095-
for i in comp_idxes[1:]:
1096-
rid = s[i].pop("recurrence-id")
1097-
s[i].add("recurrence-id", rid.dt + dtstart_diff)
1098-
1099-
return obj.save(increase_seqno=increase_seqno)
1100-
1101-
if only_this_recurrence:
1102-
existing_idx = [
1103-
i
1104-
for i in range(0, len(ici.subcomponents))
1105-
if ici.subcomponents[i].get("recurrence-id")
1106-
== self.icalendar_component["recurrence-id"]
1107-
]
1108-
error.assert_(len(existing_idx) <= 1)
1109-
if existing_idx:
1110-
ici.subcomponents[existing_idx[0]] = self.icalendar_component
1111-
else:
1112-
ici.add_component(self.icalendar_component)
1113-
return obj.save(increase_seqno=increase_seqno)
1054+
self._maybe_increment_sequence(increase_seqno)
1055+
path = self.url.path if self.url else None
1056+
self._create(id=self.id, path=path)
1057+
return self
1058+
1059+
def _validate_save_constraints(self, existing, uid, no_overwrite, no_create):
1060+
"""Raise ConsistencyError if no_overwrite/no_create constraints are violated."""
1061+
from caldav.lib import error
1062+
1063+
if not uid and no_create:
1064+
raise error.ConsistencyError("no_create flag was set, but no ID given")
1065+
if no_overwrite and existing:
1066+
raise error.ConsistencyError("no_overwrite flag was set, but object already exists")
1067+
if no_create and not existing:
1068+
raise error.ConsistencyError("no_create flag was set, but object does not exist")
1069+
1070+
def _incorporate_recurrence_into_parent(self, obj, only_this_recurrence, all_recurrences):
1071+
"""Mutate obj's icalendar_instance to include/update self (a recurrence instance).
1072+
1073+
When saving a single recurrence instance we need to merge it into the
1074+
full recurring event rather than overwrite it on the server. This method
1075+
performs that pure icalendar manipulation so it can be shared between the
1076+
sync and async save paths.
1077+
"""
1078+
import icalendar
1079+
1080+
from caldav.lib import error
1081+
1082+
ici = obj.icalendar_instance
1083+
1084+
if all_recurrences:
1085+
occ = obj.icalendar_component
1086+
ncc = self.icalendar_component.copy()
1087+
for prop in ["exdate", "exrule", "rdate", "rrule"]:
1088+
if prop in occ:
1089+
ncc[prop] = occ[prop]
1090+
1091+
# dtstart_diff = how much we've moved the time
1092+
dtstart_diff = ncc.start.astimezone() - ncc["recurrence-id"].dt.astimezone()
1093+
new_duration = ncc.duration
1094+
ncc.pop("dtstart")
1095+
ncc.add("dtstart", occ.start + dtstart_diff)
1096+
for ep in ("duration", "dtend"):
1097+
if ep in ncc:
1098+
ncc.pop(ep)
1099+
ncc.add("dtend", ncc.start + new_duration)
1100+
ncc.pop("recurrence-id")
1101+
s = ici.subcomponents
1102+
1103+
# Replace the "root" subcomponent
1104+
comp_idxes = [i for i in range(len(s)) if not isinstance(s[i], icalendar.Timezone)]
1105+
s[comp_idxes[0]] = ncc
1106+
1107+
# The recurrence-ids of all objects has to be recalculated
1108+
if dtstart_diff:
1109+
for i in comp_idxes[1:]:
1110+
rid = s[i].pop("recurrence-id")
1111+
s[i].add("recurrence-id", rid.dt + dtstart_diff)
1112+
1113+
elif only_this_recurrence:
1114+
existing_idx = [
1115+
i
1116+
for i in range(len(ici.subcomponents))
1117+
if ici.subcomponents[i].get("recurrence-id")
1118+
== self.icalendar_component["recurrence-id"]
1119+
]
1120+
error.assert_(len(existing_idx) <= 1)
1121+
if existing_idx:
1122+
ici.subcomponents[existing_idx[0]] = self.icalendar_component
1123+
else:
1124+
ici.add_component(self.icalendar_component)
11141125

1115-
# Handle SEQUENCE increment
1126+
def _maybe_increment_sequence(self, increase_seqno):
1127+
"""Increment SEQUENCE number if present and increase_seqno is True."""
11161128
if increase_seqno and "SEQUENCE" in self.icalendar_component:
11171129
seqno = self.icalendar_component.pop("SEQUENCE", None)
11181130
if seqno is not None:
11191131
self.icalendar_component.add("SEQUENCE", seqno + 1)
11201132

1121-
path = self.url.path if self.url else None
1133+
async def _async_save(
1134+
self,
1135+
no_overwrite: bool = False,
1136+
no_create: bool = False,
1137+
obj_type: str | None = None,
1138+
increase_seqno: bool = True,
1139+
if_schedule_tag_match: bool = False,
1140+
only_this_recurrence: bool = True,
1141+
all_recurrences: bool = False,
1142+
) -> Self:
1143+
"""Async implementation of save() for async clients."""
1144+
from caldav.lib import error
11221145

1123-
# Dual-mode support: async clients return a coroutine
1124-
if self.is_async_client:
1125-
return self._async_save_final(path)
1146+
async def get_self():
1147+
uid = self.id or self.icalendar_component.get("uid")
1148+
if uid and self.parent:
1149+
try:
1150+
_obj_type = obj_type or self.__class__.__name__.lower()
1151+
if _obj_type:
1152+
method_name = f"get_{_obj_type}_by_uid"
1153+
if hasattr(self.parent, method_name):
1154+
return await getattr(self.parent, method_name)(uid)
1155+
if hasattr(self.parent, "get_object_by_uid"):
1156+
return await self.parent.get_object_by_uid(uid)
1157+
except error.NotFoundError:
1158+
return None
1159+
return None
11261160

1127-
self._create(id=self.id, path=path)
1128-
return self
1161+
if no_overwrite or no_create:
1162+
uid = self.id or self.icalendar_component.get("uid")
1163+
existing = await get_self()
1164+
self._validate_save_constraints(existing, uid, no_overwrite, no_create)
11291165

1130-
async def _async_save_final(self, path) -> Self:
1131-
"""Async helper for the final save operation."""
1166+
if (
1167+
only_this_recurrence or all_recurrences
1168+
) and "RECURRENCE-ID" in self.icalendar_component:
1169+
obj = await get_self()
1170+
if obj is None:
1171+
raise error.NotFoundError("Could not find parent recurring event")
1172+
self._incorporate_recurrence_into_parent(obj, only_this_recurrence, all_recurrences)
1173+
return await obj.save(increase_seqno=increase_seqno)
1174+
1175+
self._maybe_increment_sequence(increase_seqno)
1176+
path = self.url.path if self.url else None
11321177
await self._async_create(id=self.id, path=path)
11331178
return self
11341179

@@ -1869,11 +1914,29 @@ def complete(
18691914
if not completion_timestamp:
18701915
completion_timestamp = datetime.now(timezone.utc)
18711916

1917+
if self.is_async_client:
1918+
return self._async_complete(completion_timestamp, handle_rrule, rrule_mode)
1919+
18721920
if "RRULE" in self.icalendar_component and handle_rrule:
18731921
return getattr(self, "_complete_recurring_%s" % rrule_mode)(completion_timestamp)
18741922
self._complete_ical(completion_timestamp=completion_timestamp)
18751923
self.save()
18761924

1925+
async def _async_complete(
1926+
self,
1927+
completion_timestamp: datetime,
1928+
handle_rrule: bool = False,
1929+
rrule_mode: str = "safe",
1930+
) -> None:
1931+
"""Async implementation of complete()."""
1932+
if "RRULE" in self.icalendar_component and handle_rrule:
1933+
# _complete_recurring_* methods are sync-only for now; they internally
1934+
# call self.save() which would return an unawaited coroutine in async mode.
1935+
# This is a known limitation - handle_rrule is not yet async-safe.
1936+
raise NotImplementedError("handle_rrule=True is not yet supported for async clients")
1937+
self._complete_ical(completion_timestamp=completion_timestamp)
1938+
await self.save()
1939+
18771940
def _complete_ical(self, i=None, completion_timestamp=None) -> None:
18781941
if i is None:
18791942
i = self.icalendar_component

0 commit comments

Comments
 (0)