@@ -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