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
5 changes: 4 additions & 1 deletion caldav/aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"""

# Import the async client (this is truly async)
from caldav.async_davclient import AsyncDAVClient, AsyncDAVResponse
from caldav.async_davclient import AsyncDAVClient, AsyncDAVResponse, get_calendar, get_calendars
from caldav.async_davclient import get_davclient as get_async_davclient
from caldav.calendarobjectresource import CalendarObjectResource, Event, FreeBusy, Journal, Todo
from caldav.collection import (
Expand Down Expand Up @@ -61,6 +61,9 @@
"AsyncDAVClient",
"AsyncDAVResponse",
"get_async_davclient",
# Factory functions (async equivalents of caldav.get_calendar / get_calendars)
"get_calendar",
"get_calendars",
# Base objects (unified dual-mode)
"DAVObject",
"CalendarObjectResource",
Expand Down
52 changes: 27 additions & 25 deletions caldav/async_davclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -1245,7 +1245,7 @@ async def get_calendars(
calendar_name: Any | None = None,
raise_errors: bool = False,
**kwargs: Any,
) -> list["Calendar"]:
) -> "CalendarCollection":
"""
Get calendars from a CalDAV server asynchronously.

Expand All @@ -1258,15 +1258,14 @@ async def get_calendars(
**kwargs: Connection parameters (url, username, password, etc.)

Returns:
List of Calendar objects matching the criteria.
:class:`~caldav.base_client.CalendarCollection` of matching calendars.
Use as an async context manager to auto-close the connection::

Example::

from caldav.async_davclient import get_calendars

calendars = await get_calendars(url="...", username="...", password="...")
async with await aio.get_calendars() as calendars:
for cal in calendars:
print(await cal.get_display_name())
"""
from caldav.base_client import _normalize_to_list
from caldav.base_client import CalendarCollection, _normalize_to_list

def _try(coro_result, errmsg):
"""Handle errors based on raise_errors flag."""
Expand All @@ -1282,13 +1281,13 @@ def _try(coro_result, errmsg):
if raise_errors:
raise
log.error(f"Failed to create async client: {e}")
return []
return CalendarCollection()

try:
principal = await client.get_principal()
if not principal:
_try(None, "getting principal")
return []
return CalendarCollection(client=client)

calendars = []
calendar_urls = _normalize_to_list(calendar_url)
Expand Down Expand Up @@ -1332,31 +1331,34 @@ def _try(coro_result, errmsg):
if raise_errors:
raise

return calendars
return CalendarCollection(calendars, client=client)

finally:
# Don't close the client - let the caller manage its lifecycle
pass
except Exception:
await client.__aexit__(None, None, None)
raise


async def get_calendar(**kwargs: Any) -> Optional["Calendar"]:
async def get_calendar(**kwargs: Any) -> "CalendarResult":
"""
Get a single calendar from a CalDAV server asynchronously.

This is a convenience function for the common case where only one
calendar is needed. It returns the first matching calendar or None.
calendar is needed. Use as an async context manager to auto-close
the connection::

async with await aio.get_calendar() as cal:
events = await cal.search(event=True)

Args:
Same as :func:`get_calendars`.

Returns:
A single Calendar object, or None if no calendars found.

Example::

from caldav.async_davclient import get_calendar

calendar = await get_calendar(calendar_name="Work", url="...", ...)
:class:`~caldav.base_client.CalendarResult` wrapping the first
matching calendar (or None). Behaves like the calendar via
``__getattr__`` delegation when not used as a context manager.
"""
calendars = await get_calendars(**kwargs)
return calendars[0] if calendars else None
from caldav.base_client import CalendarResult

collection = await get_calendars(**kwargs)
cal = collection[0] if collection else None
return CalendarResult(cal, client=collection.client)
32 changes: 32 additions & 0 deletions caldav/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,26 @@ def __exit__(self, exc_type, exc_val, exc_tb):
self[0].client.__exit__(exc_type, exc_val, exc_tb)
return False

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc_val, exc_tb):
seen: set[int] = set()
for c in self._clients:
if id(c) not in seen:
if hasattr(c, "__aexit__"):
await c.__aexit__(exc_type, exc_val, exc_tb)
else:
c.__exit__(exc_type, exc_val, exc_tb)
seen.add(id(c))
if not self._clients and self:
c = self[0].client
if hasattr(c, "__aexit__"):
await c.__aexit__(exc_type, exc_val, exc_tb)
else:
c.__exit__(exc_type, exc_val, exc_tb)
return False

def close(self):
"""Close all underlying DAV client connections."""
seen: set[int] = set()
Expand Down Expand Up @@ -390,6 +410,18 @@ def __exit__(self, exc_type, exc_val, exc_tb):
client.__exit__(exc_type, exc_val, exc_tb)
return False

async def __aenter__(self):
return self._calendar

async def __aexit__(self, exc_type, exc_val, exc_tb):
client = self.client
if client:
if hasattr(client, "__aexit__"):
await client.__aexit__(exc_type, exc_val, exc_tb)
else:
client.__exit__(exc_type, exc_val, exc_tb)
return False

def close(self):
"""Close the underlying DAV client connection."""
client = self.client
Expand Down
149 changes: 131 additions & 18 deletions caldav/calendarobjectresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,16 +283,27 @@ def set_relation(
else:
# Use cheap accessor to avoid format conversion (issue #613)
uid = other._get_uid_cheap() or other.icalendar_component["uid"]
other_obj = other
else:
uid = other
if set_reverse:
other = self.parent.get_object_by_uid(uid)
other_obj = None # Resolved below (possibly async)

if self.is_async_client:
return self._async_set_relation(uid, other_obj, reltype, set_reverse)

if other_obj is None and set_reverse:
other_obj = self.parent.get_object_by_uid(uid)
if set_reverse:
## TODO: special handling of NEXT/FIRST.
## STARTTOFINISH does not have any equivalent "reverse".
reltype_reverse = self.RELTYPE_REVERSE_MAP[reltype]
other.set_relation(other=self, reltype=reltype_reverse, set_reverse=False)
other_obj.set_relation(other=self, reltype=reltype_reverse, set_reverse=False)

self._add_relation_to_ical(uid, reltype)
self.save()

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

self.save()
async def _async_set_relation(self, uid, other_obj, reltype, set_reverse) -> None:
"""Async implementation of set_relation() for async clients."""
if other_obj is None and set_reverse:
other_obj = await self.parent.get_object_by_uid(uid)
if set_reverse:
## TODO: special handling of NEXT/FIRST.
reltype_reverse = self.RELTYPE_REVERSE_MAP[reltype]
# set_relation() returns a coroutine when is_async_client, so await it
await other_obj.set_relation(other=self, reltype=reltype_reverse, set_reverse=False)

self._add_relation_to_ical(uid, reltype)
await self.save()

## TODO: this method is undertested in the caldav library.
## However, as this consolidated and eliminated quite some duplicated code in the
## plann project, it is extensively tested in plann.
def _parse_relatives_from_ical(
self,
reltypes: "Container[str] | None",
relfilter: "Callable[[Any], bool] | None",
) -> "defaultdict[str, set[str]]":
"""Extract RELATED-TO properties as a {reltype: {uid, ...}} dict (pure, no I/O)."""
ret: defaultdict[str, set[str]] = defaultdict(set)
relations = self.icalendar_component.get("RELATED-TO", [])
if not isinstance(relations, list):
relations = [relations]
for rel in relations:
if relfilter and not relfilter(rel):
continue
reltype = rel.params.get("RELTYPE", "PARENT")
if reltypes and reltype not in reltypes:
continue
ret[reltype].add(str(rel))
return ret

def get_relatives(
self,
reltypes: Container[str] | None = None,
Expand All @@ -340,24 +380,17 @@ def get_relatives(
(but due to backward compatibility requirement, such an object should behave like
the current dict)
"""
if self.is_async_client:
return self._async_get_relatives(reltypes, relfilter, fetch_objects, ignore_missing)

from .collection import Calendar ## late import to avoid cycling imports

ret = defaultdict(set)
relations = self.icalendar_component.get("RELATED-TO", [])
if not isinstance(relations, list):
relations = [relations]
for rel in relations:
if relfilter and not relfilter(rel):
continue
reltype = rel.params.get("RELTYPE", "PARENT")
if reltypes and reltype not in reltypes:
continue
ret[reltype].add(str(rel))
ret = self._parse_relatives_from_ical(reltypes, relfilter)

if fetch_objects:
for reltype in ret:
uids = ret[reltype]
reltype_set = set()
reltype_set: set = set()

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

return ret

async def _async_get_relatives(
self,
reltypes: "Container[str] | None",
relfilter: "Callable[[Any], bool] | None",
fetch_objects: bool,
ignore_missing: bool,
) -> "defaultdict[str, set]":
"""Async implementation of get_relatives() for async clients."""
from .collection import Calendar ## late import to avoid cycling imports

ret = self._parse_relatives_from_ical(reltypes, relfilter)

if fetch_objects:
if self.parent is None:
raise ValueError("Unexpected value None for self.parent")
if not isinstance(self.parent, Calendar):
raise ValueError("self.parent expected to be of type Calendar but it is not")

for reltype in ret:
uids = ret[reltype]
reltype_set: set = set()
for obj in uids:
try:
reltype_set.add(await self.parent.get_object_by_uid(obj))
except error.NotFoundError:
if not ignore_missing:
raise
ret[reltype] = reltype_set

return ret

def _set_reverse_relation(self, other, reltype):
## TODO: handle RFC9253 better! Particularly next/first-lists
reverse_reltype = self.RELTYPE_REVERSE_MAP.get(reltype)
Expand All @@ -384,6 +448,14 @@ def _set_reverse_relation(self, other, reltype):
return
other.set_relation(self, reverse_reltype, other)

async def _async_set_reverse_relation(self, other, reltype):
"""Async version of _set_reverse_relation."""
reverse_reltype = self.RELTYPE_REVERSE_MAP.get(reltype)
if not reverse_reltype:
logging.error("Reltype %s not supported in object uid %s" % (reltype, self.id))
return
await other.set_relation(self, reverse_reltype, other)

def _verify_reverse_relation(self, other, reltype) -> tuple:
revreltype = self.RELTYPE_REVERSE_MAP[reltype]
## TODO: special case FIRST/NEXT needs special handling
Expand All @@ -397,6 +469,36 @@ def _verify_reverse_relation(self, other, reltype) -> tuple:
return (other, revreltype)
return False

async def _async_verify_reverse_relation(self, other, reltype) -> tuple:
"""Async version of _verify_reverse_relation."""
revreltype = self.RELTYPE_REVERSE_MAP[reltype]
other_relations = await other.get_relatives(fetch_objects=False, reltypes={revreltype})
my_uid = self._get_uid_cheap() or str(self.icalendar_component["uid"])
if my_uid not in other_relations[revreltype]:
return (other, revreltype)
return False

async def _async_handle_reverse_relations(
self, verify: bool = False, fix: bool = False, pdb: bool = False
) -> list:
"""Async version of _handle_reverse_relations for async clients."""
ret = []
assert verify or fix
relations = await self.get_relatives()
for reltype in relations:
for other in relations[reltype]:
if verify:
foobar = await self._async_verify_reverse_relation(other, reltype)
if foobar:
ret.append(foobar)
if pdb:
breakpoint()
if fix:
await self._async_set_reverse_relation(other, reltype)
elif fix:
await self._async_set_reverse_relation(other, reltype)
return ret

def _handle_reverse_relations(
self, verify: bool = False, fix: bool = False, pdb: bool = False
) -> list:
Expand Down Expand Up @@ -621,6 +723,11 @@ def tentatively_accept_invite(self, calendar: Any | None = None) -> None:
## partstat can also be set to COMPLETED and IN-PROGRESS.

def _reply_to_invite_request(self, partstat, calendar) -> None:
if self.is_async_client:
raise NotImplementedError(
"accept_invite/decline_invite/tentatively_accept_invite are not yet supported "
"for async clients"
)
error.assert_(self.is_invite_request())
if not calendar:
calendar = self.client.principal().get_calendars()[0]
Expand Down Expand Up @@ -1966,8 +2073,14 @@ def uncomplete(self) -> None:
self.icalendar_component.add("status", "NEEDS-ACTION")
if "completed" in self.icalendar_component:
self.icalendar_component.pop("completed")
if self.is_async_client:
return self._async_uncomplete()
self.save()

async def _async_uncomplete(self) -> None:
"""Async implementation of uncomplete() for async clients."""
await self.save()

## TODO: should be moved up to the base class
def set_duration(self, duration, movable_attr="DTSTART"):
"""
Expand Down
2 changes: 1 addition & 1 deletion caldav/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,7 +848,7 @@ async def _async_add_object_finish(self, o, no_overwrite=False, no_create=False)
"""Async helper for add_object(): awaits save() then handles reverse relations."""
o = await o.save(no_overwrite=no_overwrite, no_create=no_create)
if o.url is not None:
o._handle_reverse_relations(fix=True)
await o._async_handle_reverse_relations(fix=True)
return o

def add_event(self, *largs, **kwargs) -> "Event":
Expand Down
Loading
Loading