Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion caldav/async_davclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -1095,7 +1095,11 @@ async def check_dav_support(self) -> str | None:

Returns the DAV header from an OPTIONS request, or None if not supported.
"""
response = await self.options(str(self.url))
try:
principal = await self.principal()
response = await self.options(principal.url)
except Exception:
response = await self.options(str(self.url))
return response.headers.get("DAV")

async def check_cdav_support(self) -> bool:
Expand Down
91 changes: 87 additions & 4 deletions caldav/calendarobjectresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -2267,6 +2267,8 @@ def complete(
self._complete_ical(completion_timestamp=completion_timestamp)
self.save()

## TODO: there is TERRIBLY much code duplication here. We should try to consolidate
## the sync and async code better.
async def _async_complete(
self,
completion_timestamp: datetime,
Expand All @@ -2275,13 +2277,94 @@ async def _async_complete(
) -> None:
"""Async implementation of complete()."""
if "RRULE" in self.icalendar_component and handle_rrule:
# _complete_recurring_* methods are sync-only for now; they internally
# call self.save() which would return an unawaited coroutine in async mode.
# This is a known limitation - handle_rrule is not yet async-safe.
raise NotImplementedError("handle_rrule=True is not yet supported for async clients")
await getattr(self, "_async_complete_recurring_%s" % rrule_mode)(completion_timestamp)
return
self._complete_ical(completion_timestamp=completion_timestamp)
await self.save()

async def _async_complete_recurring_safe(self, completion_timestamp: datetime) -> None:
"""Async version of _complete_recurring_safe."""
if not self._reduce_count():
return await self._async_complete(completion_timestamp, handle_rrule=False)
next_dtstart = self._next(completion_timestamp)
if not next_dtstart:
return await self._async_complete(completion_timestamp, handle_rrule=False)

completed = self.copy()
completed.url = self.parent.url.join(completed.id + ".ics")
completed.icalendar_component.pop("RRULE")
await completed.save()
completed._complete_ical(completion_timestamp=completion_timestamp)
await completed.save()

duration = self.get_duration()
i = self.icalendar_component
i.pop("DTSTART", None)
i.add("DTSTART", next_dtstart)
self.set_duration(duration, movable_attr="DUE")
await self.save()

async def _async_complete_recurring_thisandfuture(self, completion_timestamp: datetime) -> None:
"""Async version of _complete_recurring_thisandfuture."""
recurrences = self.icalendar_instance.subcomponents
orig = recurrences[0]
if "STATUS" not in orig:
orig["STATUS"] = "NEEDS-ACTION"

if len(recurrences) == 1:
just_completed = orig.copy()
just_completed.pop("RRULE")
just_completed.add("RECURRENCE-ID", orig.get("DTSTART", completion_timestamp))
seqno = just_completed.pop("SEQUENCE", 0)
just_completed.add("SEQUENCE", seqno + 1)
recurrences.append(just_completed)

prev = recurrences[-1]
rrule = prev.get("RRULE", orig["RRULE"])
thisandfuture = prev.copy()
seqno = thisandfuture.pop("SEQUENCE", 0)
thisandfuture.add("SEQUENCE", seqno + 1)

if len(recurrences) > 2:
if prev["RECURRENCE-ID"].params.get("RANGE", None) == "THISANDFUTURE":
prev["RECURRENCE-ID"].params.pop("RANGE")
else:
raise NotImplementedError(
"multiple instances found, but last one is not of type THISANDFUTURE, possibly this has been created by some incompatible client, but we should deal with it"
)
self._complete_ical(prev, completion_timestamp)

thisandfuture.pop("RECURRENCE-ID", None)
thisandfuture.add("RECURRENCE-ID", self._next(i=prev, rrule=rrule))
thisandfuture["RECURRENCE-ID"].params["RANGE"] = "THISANDFUTURE"
rrule2 = thisandfuture.pop("RRULE", None)

if rrule2 is not None:
count = rrule2.get("COUNT", None)
if count is not None and count[0] in (0, 1):
for i in recurrences:
self._complete_ical(i, completion_timestamp=completion_timestamp)
thisandfuture.add("RRULE", rrule2)
else:
count = rrule.get("COUNT", None)
if count is not None and count[0] <= len(
[x for x in recurrences if not self.is_pending(x)]
):
self._complete_ical(recurrences[0], completion_timestamp=completion_timestamp)
await self.save(increase_seqno=False)
return

rrule = rrule2 or rrule

duration = self._get_duration(i=prev)
thisandfuture.pop("DTSTART", None)
thisandfuture.pop("DUE", None)
next_dtstart = self._next(i=prev, rrule=rrule, ts=completion_timestamp)
thisandfuture.add("DTSTART", next_dtstart)
self._set_duration(i=thisandfuture, duration=duration, movable_attr="DUE")
self.icalendar_instance.subcomponents.append(thisandfuture)
await self.save(increase_seqno=False)

def _complete_ical(self, i=None, completion_timestamp=None) -> None:
if i is None:
i = self.icalendar_component
Expand Down
39 changes: 35 additions & 4 deletions caldav/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,17 @@ def calendar(
It will not initiate any communication with the server.
"""
if not cal_url:
## For full-URL cal_id, skip calendar_home_set (which may be async-lazy)
if cal_id and (
isinstance(cal_id, URL)
or (
isinstance(cal_id, str)
and (cal_id.startswith("https://") or cal_id.startswith("http://"))
)
):
if self.client is None:
raise ValueError("Unexpected value None for self.client")
return Calendar(self.client, url=URL.objectify(cal_id))
return self.calendar_home_set.calendar(name, cal_id)
else:
if self.client is None:
Expand Down Expand Up @@ -611,10 +622,14 @@ async def _async_freebusy_request(self, outbox, fb_obj) -> dict:
response = await self.client.post(outbox.url, fb_obj.data, headers)
return response._parse_scheduling_response_objects(parent=self)

def calendar_user_address_set(self) -> list[str | None]:
def calendar_user_address_set(
self,
) -> "list[str | None] | Coroutine[Any, Any, list[str | None]]":
"""
defined in RFC6638
"""
if self.is_async_client:
return self._async_calendar_user_address_set()
_addresses: _Element | None = self.get_property(
cdav.CalendarUserAddressSet(), parse_props=False
)
Expand All @@ -629,6 +644,15 @@ def calendar_user_address_set(self) -> list[str | None]:
addresses.sort(key=lambda x: -int(x.get("preferred", 0)))
return [x.text for x in addresses]

async def _async_calendar_user_address_set(self) -> list[str | None]:
_addresses = await self.get_property(cdav.CalendarUserAddressSet(), parse_props=False)
if _addresses is None:
raise error.NotFoundError("No calendar user addresses given from server")
assert not [x for x in _addresses if x.tag != dav.Href().tag]
addresses = list(_addresses)
addresses.sort(key=lambda x: -int(x.get("preferred", 0)))
return [x.text for x in addresses]

def schedule_inbox(self) -> "ScheduleInbox | Coroutine[Any, Any, ScheduleInbox]":
"""
Returns the schedule inbox, as defined in RFC6638.
Expand Down Expand Up @@ -1512,7 +1536,9 @@ def search(
self, server_expand, split_expanded, props, xml, post_filter, _hacks
)

def freebusy_request(self, start: datetime, end: datetime) -> "FreeBusy":
def freebusy_request(
self, start: datetime, end: datetime
) -> "FreeBusy | Coroutine[Any, Any, FreeBusy]":
"""
Search the calendar, but return only the free/busy information.

Expand All @@ -1521,13 +1547,18 @@ def freebusy_request(self, start: datetime, end: datetime) -> "FreeBusy":
end : same as above.

Returns:
[FreeBusy(), ...]
FreeBusy object (or a coroutine for async clients)
"""
## TODO: async variant?
root = cdav.FreeBusyQuery() + [cdav.TimeRange(start, end)]
if self.is_async_client:
return self._async_freebusy_request(root)
response = self._query(root, 1, "report")
return FreeBusy(self, response.raw)

async def _async_freebusy_request(self, root) -> "FreeBusy":
response = await self._query(root, 1, "report")
return FreeBusy(self, response.raw)

def get_todos(
self,
sort_keys: Sequence[str] = ("due", "priority"),
Expand Down
Loading
Loading