@@ -16,27 +16,111 @@ async def _maybe_await(result: Any) -> Any:
1616 return result
1717
1818
19- async def get_or_create_test_calendar (
19+ def _build_make_calendar_kwargs (
20+ calendar_name : str | None ,
21+ cal_id : str | None ,
22+ supported_calendar_component_set : list [str ] | None ,
23+ ) -> dict [str , Any ]:
24+ """Build kwargs dict for principal.make_calendar()."""
25+ kwargs : dict [str , Any ] = {}
26+ if calendar_name is not None :
27+ kwargs ["name" ] = calendar_name
28+ if cal_id :
29+ kwargs ["cal_id" ] = cal_id
30+ if supported_calendar_component_set :
31+ kwargs ["supported_calendar_component_set" ] = supported_calendar_component_set
32+ return kwargs
33+
34+
35+ def _filter_calendars_by_component_set (
36+ calendars : list [Any ],
37+ supported_calendar_component_set : list [str ],
38+ get_properties_fn : Any = None ,
39+ ) -> list [Any ] | None :
40+ """Filter calendars by supported component set.
41+
42+ Uses property lookup first, then URL-based heuristics as fallback.
43+ Returns None if no matching calendars found (caller should skip test).
44+
45+ Args:
46+ calendars: List of calendar objects to filter
47+ supported_calendar_component_set: Required component types
48+ get_properties_fn: Callable that takes (calendar, keys) and returns
49+ properties dict. If None, uses calendar.get_properties() directly.
50+ """
51+ comp_set_key = "{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set"
52+
53+ matching_calendars = []
54+ for c in calendars :
55+ try :
56+ if get_properties_fn :
57+ props = get_properties_fn (c , [comp_set_key ])
58+ else :
59+ props = c .get_properties ([comp_set_key ])
60+ cal_components = props .get (comp_set_key , [])
61+ if cal_components and all (
62+ comp in cal_components for comp in supported_calendar_component_set
63+ ):
64+ matching_calendars .append (c )
65+ except Exception :
66+ pass
67+
68+ # Fallback: URL/name pattern heuristics (some servers like Zimbra don't
69+ # return the supported-calendar-component-set property)
70+ if not matching_calendars :
71+ for c in calendars :
72+ url_path = str (c .url ).lower ()
73+ if "VTODO" in supported_calendar_component_set :
74+ if "/tasks/" in url_path or "_tasks/" in url_path :
75+ matching_calendars .append (c )
76+ elif "VJOURNAL" in supported_calendar_component_set :
77+ if "/journal" in url_path or "_journal" in url_path :
78+ matching_calendars .append (c )
79+
80+ return matching_calendars or None
81+
82+
83+ def _find_test_calendar (
84+ calendars : list [Any ],
85+ get_properties_fn : Any = None ,
86+ ) -> Any :
87+ """Find a dedicated test calendar by display name, or return first calendar.
88+
89+ Args:
90+ calendars: List of calendar objects to search
91+ get_properties_fn: Callable that takes (calendar, keys) and returns
92+ properties dict. If None, uses calendar.get_properties() directly.
93+ """
94+ for c in calendars :
95+ try :
96+ if get_properties_fn :
97+ props = get_properties_fn (c , [])
98+ else :
99+ props = c .get_properties ([])
100+ display_name = props .get ("{DAV:}displayname" , "" )
101+ if "pythoncaldav-test" in str (display_name ):
102+ return c
103+ except Exception :
104+ pass
105+ return calendars [0 ] if calendars else None
106+
107+
108+ def get_or_create_test_calendar (
20109 client : Any ,
21110 principal : Any ,
22- calendar_name : str = "pythoncaldav-test" ,
111+ calendar_name : str | None = "pythoncaldav-test" ,
23112 cal_id : str | None = None ,
24113 supported_calendar_component_set : list [str ] | None = None ,
25114) -> tuple [Any , bool ]:
26115 """
27- Get or create a test calendar, with fallback to existing calendars.
28-
29- This implements the same logic as the sync _fixCalendar_ method,
30- providing safeguards against accidentally overwriting user data.
116+ Get or create a test calendar (sync version), with fallback to existing calendars.
31117
32118 Args:
33- client: The DAV client (sync or async)
119+ client: The DAV client
34120 principal: The principal object (or None to skip principal-based creation)
35- calendar_name: Name for the test calendar
121+ calendar_name: Name for the test calendar, or None to skip setting name
36122 cal_id: Optional calendar ID
37123 supported_calendar_component_set: Component types this calendar should support
38- (e.g., ["VTODO"] for task lists, ["VEVENT"] for event calendars).
39- Important for servers like Zimbra that don't support mixed calendars.
40124
41125 Returns:
42126 Tuple of (calendar, was_created) where was_created indicates if
@@ -54,18 +138,90 @@ async def get_or_create_test_calendar(
54138 supports_create = client .features .is_supported ("create-calendar" )
55139
56140 if supports_create and principal is not None :
57- # Try to create a new calendar
58141 try :
59- kwargs : dict [str , Any ] = {"name" : calendar_name }
142+ kwargs = _build_make_calendar_kwargs (
143+ calendar_name , cal_id , supported_calendar_component_set
144+ )
145+ calendar = principal .make_calendar (** kwargs )
146+ created = True
147+ except (error .MkcalendarError , error .AuthorizationError , error .NotFoundError ):
148+ # Creation failed - try to get by cal_id if available
60149 if cal_id :
61- kwargs ["cal_id" ] = cal_id
150+ try :
151+ calendar = principal .calendar (cal_id = cal_id )
152+ except Exception :
153+ pass
154+
155+ if calendar is None :
156+ # Fall back to finding an existing calendar
157+ calendars = None
158+
159+ if principal is not None :
160+ try :
161+ calendars = principal .get_calendars ()
162+ except (error .NotFoundError , error .AuthorizationError ):
163+ pass
164+
165+ if calendars :
62166 if supported_calendar_component_set :
63- kwargs ["supported_calendar_component_set" ] = supported_calendar_component_set
167+ filtered = _filter_calendars_by_component_set (
168+ calendars , supported_calendar_component_set
169+ )
170+ if filtered is None :
171+ return None , False
172+ calendars = filtered
173+
174+ calendar = _find_test_calendar (calendars )
175+
176+ return calendar , created
177+
178+
179+ async def aget_or_create_test_calendar (
180+ client : Any ,
181+ principal : Any ,
182+ calendar_name : str | None = "pythoncaldav-test" ,
183+ cal_id : str | None = None ,
184+ supported_calendar_component_set : list [str ] | None = None ,
185+ ) -> tuple [Any , bool ]:
186+ """
187+ Get or create a test calendar (async version), with fallback to existing calendars.
188+
189+ Args:
190+ client: The DAV client (sync or async)
191+ principal: The principal object (or None to skip principal-based creation)
192+ calendar_name: Name for the test calendar, or None to skip setting name
193+ cal_id: Optional calendar ID
194+ supported_calendar_component_set: Component types this calendar should support
195+
196+ Returns:
197+ Tuple of (calendar, was_created) where was_created indicates if
198+ we created the calendar (and should clean it up) or are using
199+ an existing one.
200+ """
201+ from caldav .lib import error
202+
203+ calendar = None
204+ created = False
205+
206+ # Check if server supports calendar creation via features
207+ supports_create = True
208+ if hasattr (client , "features" ) and client .features :
209+ supports_create = client .features .is_supported ("create-calendar" )
210+
211+ if supports_create and principal is not None :
212+ try :
213+ kwargs = _build_make_calendar_kwargs (
214+ calendar_name , cal_id , supported_calendar_component_set
215+ )
64216 calendar = await _maybe_await (principal .make_calendar (** kwargs ))
65217 created = True
66218 except (error .MkcalendarError , error .AuthorizationError , error .NotFoundError ):
67- # Creation failed - fall back to finding existing calendar
68- pass
219+ # Creation failed - try to get by cal_id if available
220+ if cal_id :
221+ try :
222+ calendar = await _maybe_await (principal .calendar (cal_id = cal_id ))
223+ except Exception :
224+ pass
69225
70226 if calendar is None :
71227 # Fall back to finding an existing calendar
@@ -78,41 +234,36 @@ async def get_or_create_test_calendar(
78234 pass
79235
80236 if calendars :
81- # Property key for supported component set
82- comp_set_key = "{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set"
83-
84- # If we need specific component support, filter calendars
85237 if supported_calendar_component_set :
86- matching_calendars = []
238+
239+ async def async_get_props (cal : Any , keys : list [str ]) -> dict :
240+ return await _maybe_await (cal .get_properties (keys ))
241+
242+ # Can't use the sync helper with async get_properties,
243+ # so inline the filtering
244+ comp_set_key = "{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set"
245+ matching_calendars : list [Any ] = []
87246 for c in calendars :
88247 try :
89- props = await _maybe_await ( c . get_properties ( [comp_set_key ]) )
248+ props = await async_get_props ( c , [comp_set_key ])
90249 cal_components = props .get (comp_set_key , [])
91- # Check if calendar supports all required components
92250 if cal_components and all (
93251 comp in cal_components for comp in supported_calendar_component_set
94252 ):
95253 matching_calendars .append (c )
96254 except Exception :
97255 pass
98256
99- # If no matching calendars found by component set, try heuristics
100- # based on URL/name patterns (some servers like Zimbra don't return
101- # the supported-calendar-component-set property)
102257 if not matching_calendars :
103258 for c in calendars :
104259 url_path = str (c .url ).lower ()
105- # For VTODO, look for task-related calendars
106260 if "VTODO" in supported_calendar_component_set :
107261 if "/tasks/" in url_path or "_tasks/" in url_path :
108262 matching_calendars .append (c )
109- # For VJOURNAL, look for journal-related calendars
110263 elif "VJOURNAL" in supported_calendar_component_set :
111264 if "/journal" in url_path or "_journal" in url_path :
112265 matching_calendars .append (c )
113266
114- # Only use matching calendars - if none found, return None
115- # (caller should skip the test)
116267 if not matching_calendars :
117268 return None , False
118269 calendars = matching_calendars
0 commit comments