diff --git a/caldav/__init__.py b/caldav/__init__.py
index 319a6eaa..326215bb 100644
--- a/caldav/__init__.py
+++ b/caldav/__init__.py
@@ -10,7 +10,9 @@
warnings.warn(
"You need to install the `build` package and do a `python -m build` to get caldav.__version__ set correctly"
)
-from .davclient import DAVClient
+
+# Import from the new async-first implementation with sync wrappers
+from ._sync.davclient import DAVClient
from .search import CalDAVSearcher
## TODO: this should go away in some future version of the library.
diff --git a/caldav/_async/__init__.py b/caldav/_async/__init__.py
new file mode 100644
index 00000000..c4fb4cc0
--- /dev/null
+++ b/caldav/_async/__init__.py
@@ -0,0 +1,9 @@
+"""
+Async CalDAV client implementation using httpx.
+
+This module contains the primary async implementation of the caldav library.
+The sync API in caldav._sync wraps these async implementations.
+"""
+from .davclient import AsyncDAVClient
+
+__all__ = ["AsyncDAVClient"]
diff --git a/caldav/_async/calendarobjectresource.py b/caldav/_async/calendarobjectresource.py
new file mode 100644
index 00000000..afeb7def
--- /dev/null
+++ b/caldav/_async/calendarobjectresource.py
@@ -0,0 +1,632 @@
+#!/usr/bin/env python
+"""
+Async Calendar Object Resources - Event, Todo, Journal, FreeBusy.
+
+This is the async implementation that the sync wrapper delegates to.
+"""
+import logging
+import re
+import sys
+import uuid
+from collections import defaultdict
+from datetime import datetime
+from datetime import timedelta
+from datetime import timezone
+from typing import Any
+from typing import ClassVar
+from typing import List
+from typing import Optional
+from typing import Set
+from typing import TYPE_CHECKING
+from typing import Union
+from urllib.parse import ParseResult
+from urllib.parse import quote
+from urllib.parse import SplitResult
+
+import icalendar
+from icalendar import vCalAddress
+from icalendar import vText
+
+if TYPE_CHECKING:
+ from caldav._async.davclient import AsyncDAVClient
+
+if sys.version_info < (3, 9):
+ from typing import Callable, Container
+ from typing_extensions import DefaultDict
+else:
+ from collections import defaultdict as DefaultDict
+ from collections.abc import Callable, Container
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Self
+else:
+ from typing import Self
+
+from caldav._async.davobject import AsyncDAVObject
+from caldav.elements import cdav, dav
+from caldav.lib import error, vcal
+from caldav.lib.error import errmsg
+from caldav.lib.python_utilities import to_normal_str, to_unicode, to_wire
+from caldav.lib.url import URL
+
+
+log = logging.getLogger("caldav")
+
+
+class AsyncCalendarObjectResource(AsyncDAVObject):
+ """Async Calendar Object Resource - base class for Event, Todo, Journal, FreeBusy."""
+
+ RELTYPE_REVERSE_MAP: ClassVar = {
+ "PARENT": "CHILD",
+ "CHILD": "PARENT",
+ "SIBLING": "SIBLING",
+ "DEPENDS-ON": "FINISHTOSTART",
+ "FINISHTOSTART": "DEPENDENT",
+ }
+
+ _ENDPARAM = None
+ _vobject_instance = None
+ _icalendar_instance = None
+ _data = None
+
+ def __init__(
+ self,
+ client: Optional["AsyncDAVClient"] = None,
+ url: Union[str, ParseResult, SplitResult, URL, None] = None,
+ data: Optional[Any] = None,
+ parent: Optional[Any] = None,
+ id: Optional[Any] = None,
+ props: Optional[Any] = None,
+ ) -> None:
+ """
+ CalendarObjectResource has an additional parameter for its constructor:
+ * data = "...", vCal data for the event
+ """
+ super(AsyncCalendarObjectResource, self).__init__(
+ client=client, url=url, parent=parent, id=id, props=props
+ )
+ if data is not None:
+ self.data = data
+ if id:
+ old_id = self.icalendar_component.pop("UID", None)
+ self.icalendar_component.add("UID", id)
+
+ def _get_icalendar_component(self, assert_one=False):
+ """Returns the icalendar subcomponent."""
+ if not self.icalendar_instance:
+ return None
+ ret = [
+ x
+ for x in self.icalendar_instance.subcomponents
+ if not isinstance(x, icalendar.Timezone)
+ ]
+ error.assert_(len(ret) == 1 or not assert_one)
+ for x in ret:
+ for cl in (
+ icalendar.Event,
+ icalendar.Journal,
+ icalendar.Todo,
+ icalendar.FreeBusy,
+ ):
+ if isinstance(x, cl):
+ return x
+ error.assert_(False)
+
+ def _set_icalendar_component(self, value) -> None:
+ s = self.icalendar_instance.subcomponents
+ i = [i for i in range(0, len(s)) if not isinstance(s[i], icalendar.Timezone)]
+ if len(i) == 1:
+ self.icalendar_instance.subcomponents[i[0]] = value
+ else:
+ my_instance = icalendar.Calendar()
+ my_instance.add("prodid", self.icalendar_instance["prodid"])
+ my_instance.add("version", self.icalendar_instance["version"])
+ my_instance.add_component(value)
+ self.icalendar_instance = my_instance
+
+ icalendar_component = property(
+ _get_icalendar_component,
+ _set_icalendar_component,
+ doc="icalendar component",
+ )
+ component = icalendar_component
+
+ def _set_data(self, data):
+ if type(data).__module__.startswith("vobject"):
+ self._set_vobject_instance(data)
+ return self
+ if type(data).__module__.startswith("icalendar"):
+ self._set_icalendar_instance(data)
+ return self
+ self._data = vcal.fix(data)
+ self._vobject_instance = None
+ self._icalendar_instance = None
+ return self
+
+ def _get_data(self):
+ if self._data:
+ return to_normal_str(self._data)
+ elif self._vobject_instance:
+ return to_normal_str(self._vobject_instance.serialize())
+ elif self._icalendar_instance:
+ return to_normal_str(self._icalendar_instance.to_ical())
+ return None
+
+ def _get_wire_data(self):
+ if self._data:
+ return to_wire(self._data)
+ elif self._vobject_instance:
+ return to_wire(self._vobject_instance.serialize())
+ elif self._icalendar_instance:
+ return to_wire(self._icalendar_instance.to_ical())
+ return None
+
+ data: str = property(_get_data, _set_data, doc="vCal representation")
+ wire_data = property(_get_wire_data, _set_data, doc="vCal in wire format")
+
+ def _set_vobject_instance(self, inst):
+ self._vobject_instance = inst
+ self._data = None
+ self._icalendar_instance = None
+ return self
+
+ def _get_vobject_instance(self):
+ try:
+ import vobject
+ except ImportError:
+ logging.critical("vobject library not installed")
+ return None
+ if not self._vobject_instance:
+ if self._get_data() is None:
+ return None
+ try:
+ self._set_vobject_instance(
+ vobject.readOne(to_unicode(self._get_data()))
+ )
+ except:
+ log.critical(
+ "Error loading icalendar data into vobject. URL: " + str(self.url)
+ )
+ raise
+ return self._vobject_instance
+
+ vobject_instance = property(_get_vobject_instance, _set_vobject_instance)
+
+ def _set_icalendar_instance(self, inst):
+ if not isinstance(inst, icalendar.Calendar):
+ try:
+ cal = icalendar.Calendar.new()
+ except:
+ cal = icalendar.Calendar()
+ cal.add("prodid", "-//python-caldav//caldav//en_DK")
+ cal.add("version", "2.0")
+ cal.add_component(inst)
+ inst = cal
+ self._icalendar_instance = inst
+ self._data = None
+ self._vobject_instance = None
+ return self
+
+ def _get_icalendar_instance(self):
+ if not self._icalendar_instance:
+ if not self.data:
+ return None
+ self.icalendar_instance = icalendar.Calendar.from_ical(
+ to_unicode(self.data)
+ )
+ return self._icalendar_instance
+
+ icalendar_instance: Any = property(_get_icalendar_instance, _set_icalendar_instance)
+
+ async def load(self, only_if_unloaded: bool = False) -> Self:
+ """(Re)load the object from the caldav server."""
+ if only_if_unloaded and self.is_loaded():
+ return self
+
+ if self.url is None:
+ raise ValueError("Unexpected value None for self.url")
+ if self.client is None:
+ raise ValueError("Unexpected value None for self.client")
+
+ try:
+ r = await self.client.request(str(self.url))
+ if r.status and r.status == 404:
+ raise error.NotFoundError(errmsg(r))
+ self.data = r.raw
+ except error.NotFoundError:
+ raise
+ except:
+ return await self.load_by_multiget()
+ if "Etag" in r.headers:
+ self.props[dav.GetEtag.tag] = r.headers["Etag"]
+ if "Schedule-Tag" in r.headers:
+ self.props[cdav.ScheduleTag.tag] = r.headers["Schedule-Tag"]
+ return self
+
+ async def load_by_multiget(self) -> Self:
+ """Load via REPORT multiget query."""
+ error.assert_(self.url)
+ mydata = self.parent._multiget(event_urls=[self.url], raise_notfound=True)
+ try:
+ url, self.data = next(mydata)
+ except StopIteration:
+ raise error.NotFoundError(self.url)
+ error.assert_(self.data)
+ error.assert_(next(mydata, None) is None)
+ return self
+
+ def is_loaded(self):
+ """Returns True if there is data in the object."""
+ return (
+ (self._data and self._data.count("BEGIN:") > 1)
+ or self._vobject_instance
+ or self._icalendar_instance
+ )
+
+ def has_component(self):
+ """
+ Returns True if there exists a VEVENT, VTODO or VJOURNAL in the data.
+ Returns False if it's only a VFREEBUSY, VTIMEZONE or unknown components.
+
+ Used internally after search to remove empty search results.
+ """
+ return (
+ self._data
+ or self._vobject_instance
+ or (self._icalendar_instance and self.icalendar_component)
+ ) and self.data.count("BEGIN:VEVENT") + self.data.count(
+ "BEGIN:VTODO"
+ ) + self.data.count(
+ "BEGIN:VJOURNAL"
+ ) > 0
+
+ def _find_id_path(self, id=None, path=None) -> None:
+ """Find or generate UID and path."""
+ i = self._get_icalendar_component(assert_one=False)
+ if not id and getattr(self, "id", None):
+ id = self.id
+ if not id:
+ id = i.pop("UID", None)
+ if id:
+ id = str(id)
+ if not path and getattr(self, "path", None):
+ path = self.path
+ if id is None and path is not None and str(path).endswith(".ics"):
+ id = re.search("(/|^)([^/]*).ics", str(path)).group(2)
+ if id is None:
+ id = str(uuid.uuid1())
+
+ i.pop("UID", None)
+ i.add("UID", id)
+ self.id = id
+
+ for x in self.icalendar_instance.subcomponents:
+ if not isinstance(x, icalendar.Timezone):
+ error.assert_(x.get("UID", None) == self.id)
+
+ if path is None:
+ path = self._generate_url()
+ else:
+ path = self.parent.url.join(path)
+
+ self.url = URL.objectify(path)
+
+ async def _put(self, retry_on_failure=True):
+ """PUT the calendar data to the server."""
+ r = await self.client.put(
+ self.url, self.data, {"Content-Type": 'text/calendar; charset="utf-8"'}
+ )
+ if r.status == 302:
+ path = [x[1] for x in r.headers if x[0] == "location"][0]
+ elif r.status not in (204, 201):
+ if retry_on_failure:
+ try:
+ import vobject
+ except ImportError:
+ retry_on_failure = False
+ if retry_on_failure:
+ self.vobject_instance
+ return await self._put(False)
+ else:
+ raise error.PutError(errmsg(r))
+
+ async def _create(self, id=None, path=None, retry_on_failure=True) -> None:
+ """Create the calendar object on the server."""
+ self._find_id_path(id=id, path=path)
+ await self._put()
+
+ def _generate_url(self):
+ """Generate URL from ID."""
+ if not self.id:
+ self.id = self._get_icalendar_component(assert_one=False)["UID"]
+ return self.parent.url.join(quote(self.id.replace("/", "%2F")) + ".ics")
+
+ async def save(
+ self,
+ no_overwrite: bool = False,
+ no_create: bool = False,
+ obj_type: Optional[str] = None,
+ increase_seqno: bool = True,
+ if_schedule_tag_match: bool = False,
+ only_this_recurrence: bool = True,
+ all_recurrences: bool = False,
+ ) -> Self:
+ """Save the object."""
+ if not obj_type:
+ obj_type = self.__class__.__name__.lower().replace("async", "")
+ if (
+ self._vobject_instance is None
+ and self._data is None
+ and self._icalendar_instance is None
+ ):
+ return self
+
+ path = self.url.path if self.url else None
+
+ async def get_self():
+ self.id = self.id or self.icalendar_component.get("uid")
+ if self.id:
+ try:
+ if obj_type:
+ return await getattr(self.parent, "%s_by_uid" % obj_type)(
+ self.id
+ )
+ else:
+ return await self.parent.object_by_uid(self.id)
+ except error.NotFoundError:
+ return None
+ return None
+
+ if no_overwrite or no_create:
+ existing = await get_self()
+ if not self.id and no_create:
+ raise error.ConsistencyError("no_create flag was set, but no ID given")
+ if no_overwrite and existing:
+ raise error.ConsistencyError(
+ "no_overwrite flag was set, but object already exists"
+ )
+ if no_create and not existing:
+ raise error.ConsistencyError(
+ "no_create flag was set, but object does not exist"
+ )
+
+ if (
+ only_this_recurrence or all_recurrences
+ ) and "RECURRENCE-ID" in self.icalendar_component:
+ obj = await get_self()
+ ici = obj.icalendar_instance
+ if all_recurrences:
+ occ = obj.icalendar_component
+ ncc = self.icalendar_component.copy()
+ for prop in ["exdate", "exrule", "rdate", "rrule"]:
+ if prop in occ:
+ ncc[prop] = occ[prop]
+ dtstart_diff = (
+ ncc.start.astimezone() - ncc["recurrence-id"].dt.astimezone()
+ )
+ new_duration = ncc.duration
+ ncc.pop("dtstart")
+ ncc.add("dtstart", occ.start + dtstart_diff)
+ for ep in ("duration", "dtend"):
+ if ep in ncc:
+ ncc.pop(ep)
+ ncc.add("dtend", ncc.start + new_duration)
+ ncc.pop("recurrence-id")
+ s = ici.subcomponents
+ comp_idxes = (
+ i
+ for i in range(0, len(s))
+ if not isinstance(s[i], icalendar.Timezone)
+ )
+ comp_idx = next(comp_idxes)
+ s[comp_idx] = ncc
+ if dtstart_diff:
+ for i in comp_idxes:
+ rid = s[i].pop("recurrence-id")
+ s[i].add("recurrence-id", rid.dt + dtstart_diff)
+ return await obj.save(increase_seqno=increase_seqno)
+ if only_this_recurrence:
+ existing_idx = [
+ i
+ for i in range(0, len(ici.subcomponents))
+ if ici.subcomponents[i].get("recurrence-id")
+ == self.icalendar_component["recurrence-id"]
+ ]
+ error.assert_(len(existing_idx) <= 1)
+ if existing_idx:
+ ici.subcomponents[existing_idx[0]] = self.icalendar_component
+ else:
+ ici.add_component(self.icalendar_component)
+ return await obj.save(increase_seqno=increase_seqno)
+
+ if "SEQUENCE" in self.icalendar_component:
+ seqno = self.icalendar_component.pop("SEQUENCE", None)
+ if seqno is not None:
+ self.icalendar_component.add("SEQUENCE", seqno + 1)
+
+ await self._create(id=self.id, path=path)
+ return self
+
+ def copy(self, keep_uid: bool = False, new_parent: Optional[Any] = None) -> Self:
+ """Copy the calendar object."""
+ obj = self.__class__(
+ parent=new_parent or self.parent,
+ data=self.data,
+ id=self.id if keep_uid else str(uuid.uuid1()),
+ )
+ if new_parent or not keep_uid:
+ obj.url = obj._generate_url()
+ else:
+ obj.url = self.url
+ return obj
+
+ async def add_organizer(self) -> None:
+ """Add organizer line to the event from the principal."""
+ if self.client is None:
+ raise ValueError("Unexpected value None for self.client")
+ principal = await self.client.principal()
+ self.icalendar_component.add("organizer", await principal.get_vcal_address())
+
+ def add_attendee(
+ self, attendee, no_default_parameters: bool = False, **parameters
+ ) -> None:
+ """Add an attendee to the event/todo/journal."""
+ from caldav._async.collection import AsyncPrincipal
+
+ if isinstance(attendee, AsyncPrincipal):
+ raise NotImplementedError("Must await get_vcal_address for async principal")
+ elif isinstance(attendee, vCalAddress):
+ attendee_obj = attendee
+ elif isinstance(attendee, tuple):
+ if attendee[1].startswith("mailto:"):
+ attendee_obj = vCalAddress(attendee[1])
+ else:
+ attendee_obj = vCalAddress("mailto:" + attendee[1])
+ attendee_obj.params["cn"] = vText(attendee[0])
+ elif isinstance(attendee, str):
+ if attendee.startswith("ATTENDEE"):
+ raise NotImplementedError("ATTENDEE string parsing not implemented")
+ elif attendee.startswith("mailto:"):
+ attendee_obj = vCalAddress(attendee)
+ elif "@" in attendee and ":" not in attendee and ";" not in attendee:
+ attendee_obj = vCalAddress("mailto:" + attendee)
+ else:
+ error.assert_(False)
+ attendee_obj = vCalAddress()
+
+ if not no_default_parameters:
+ attendee_obj.params["partstat"] = "NEEDS-ACTION"
+ if "cutype" not in attendee_obj.params:
+ attendee_obj.params["cutype"] = "UNKNOWN"
+ attendee_obj.params["rsvp"] = "TRUE"
+ attendee_obj.params["role"] = "REQ-PARTICIPANT"
+
+ params = {}
+ for key in parameters:
+ new_key = key.replace("_", "-")
+ if parameters[key] is True:
+ params[new_key] = "TRUE"
+ else:
+ params[new_key] = parameters[key]
+ attendee_obj.params.update(params)
+ self.icalendar_component.add("attendee", attendee_obj)
+
+ def get_duration(self) -> timedelta:
+ """Get duration from DURATION or calculate from DTSTART and DUE/DTEND."""
+ i = self.icalendar_component
+ return self._get_duration(i)
+
+ def _get_duration(self, i):
+ if "DURATION" in i:
+ return i["DURATION"].dt
+ elif "DTSTART" in i and self._ENDPARAM in i:
+ end = i[self._ENDPARAM].dt
+ start = i["DTSTART"].dt
+ if isinstance(end, datetime) != isinstance(start, datetime):
+ start = datetime(start.year, start.month, start.day)
+ end = datetime(end.year, end.month, end.day)
+ return end - start
+ elif "DTSTART" in i and not isinstance(i["DTSTART"], datetime):
+ return timedelta(days=1)
+ else:
+ return timedelta(0)
+
+ def __str__(self) -> str:
+ return "%s: %s" % (self.__class__.__name__, self.url)
+
+
+class AsyncEvent(AsyncCalendarObjectResource):
+ """Async Event (VEVENT) object."""
+
+ _ENDPARAM = "DTEND"
+ set_dtend = (
+ AsyncCalendarObjectResource.set_end
+ if hasattr(AsyncCalendarObjectResource, "set_end")
+ else None
+ )
+
+
+class AsyncJournal(AsyncCalendarObjectResource):
+ """Async Journal (VJOURNAL) object."""
+
+ pass
+
+
+class AsyncFreeBusy(AsyncCalendarObjectResource):
+ """Async FreeBusy (VFREEBUSY) object."""
+
+ def __init__(
+ self,
+ parent,
+ data,
+ url: Union[str, ParseResult, SplitResult, URL, None] = None,
+ id: Optional[Any] = None,
+ ) -> None:
+ AsyncCalendarObjectResource.__init__(
+ self, client=parent.client, url=url, data=data, parent=parent, id=id
+ )
+
+
+class AsyncTodo(AsyncCalendarObjectResource):
+ """Async Todo (VTODO) object."""
+
+ _ENDPARAM = "DUE"
+
+ async def complete(
+ self,
+ completion_timestamp: Optional[datetime] = None,
+ handle_rrule: bool = False,
+ rrule_mode: str = "safe",
+ ) -> None:
+ """Mark the task as completed."""
+ if not completion_timestamp:
+ completion_timestamp = datetime.now(timezone.utc)
+
+ if "RRULE" in self.icalendar_component and handle_rrule:
+ return await getattr(self, "_complete_recurring_%s" % rrule_mode)(
+ completion_timestamp
+ )
+ self._complete_ical(completion_timestamp=completion_timestamp)
+ await self.save()
+
+ def _complete_ical(self, i=None, completion_timestamp=None) -> None:
+ if i is None:
+ i = self.icalendar_component
+ assert self.is_pending(i)
+ status = i.pop("STATUS", None)
+ i.add("STATUS", "COMPLETED")
+ i.add("COMPLETED", completion_timestamp)
+
+ def is_pending(self, i=None) -> Optional[bool]:
+ if i is None:
+ i = self.icalendar_component
+ if i.get("COMPLETED", None) is not None:
+ return False
+ if i.get("STATUS", "NEEDS-ACTION") in ("NEEDS-ACTION", "IN-PROCESS"):
+ return True
+ if i.get("STATUS", "NEEDS-ACTION") in ("CANCELLED", "COMPLETED"):
+ return False
+ assert False
+
+ async def uncomplete(self) -> None:
+ """Undo completion - marks a completed task as not completed."""
+ if "status" in self.icalendar_component:
+ self.icalendar_component.pop("status")
+ self.icalendar_component.add("status", "NEEDS-ACTION")
+ if "completed" in self.icalendar_component:
+ self.icalendar_component.pop("completed")
+ await self.save()
+
+ def get_due(self):
+ """Get due date/time."""
+ i = self.icalendar_component
+ if "DUE" in i:
+ return i["DUE"].dt
+ elif "DTEND" in i:
+ return i["DTEND"].dt
+ elif "DURATION" in i and "DTSTART" in i:
+ return i["DTSTART"].dt + i["DURATION"].dt
+ else:
+ return None
+
+ get_dtend = get_due
diff --git a/caldav/_async/collection.py b/caldav/_async/collection.py
new file mode 100644
index 00000000..0eef86e3
--- /dev/null
+++ b/caldav/_async/collection.py
@@ -0,0 +1,784 @@
+#!/usr/bin/env python
+"""
+Async collection classes - CalendarSet, Principal, Calendar, etc.
+
+This is the async implementation that the sync wrapper delegates to.
+"""
+import logging
+import sys
+import uuid
+from typing import Any
+from typing import List
+from typing import Optional
+from typing import TYPE_CHECKING
+from typing import Union
+from urllib.parse import ParseResult
+from urllib.parse import quote
+from urllib.parse import SplitResult
+from urllib.parse import unquote
+
+import icalendar
+
+if TYPE_CHECKING:
+ from caldav._async.davclient import AsyncDAVClient
+
+if sys.version_info < (3, 9):
+ from typing import Iterable, Sequence
+else:
+ from collections.abc import Iterable, Sequence
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Self
+else:
+ from typing import Self
+
+from caldav._async.davobject import AsyncDAVObject
+from caldav.elements import cdav, dav
+from caldav.lib import error
+from caldav.lib.url import URL
+
+
+log = logging.getLogger("caldav")
+
+
+class AsyncCalendarSet(AsyncDAVObject):
+ """
+ A CalendarSet is a set of calendars.
+ """
+
+ async def calendars(self) -> List["AsyncCalendar"]:
+ """
+ List all calendar collections in this set.
+
+ Returns:
+ * [AsyncCalendar(), ...]
+ """
+ cals = []
+
+ data = await self.children(cdav.Calendar.tag)
+ for c_url, c_type, c_name in data:
+ try:
+ cal_id = c_url.split("/")[-2]
+ if not cal_id:
+ continue
+ except:
+ log.error(f"Calendar {c_name} has unexpected url {c_url}")
+ cal_id = None
+ cals.append(
+ AsyncCalendar(
+ self.client, id=cal_id, url=c_url, parent=self, name=c_name
+ )
+ )
+
+ return cals
+
+ async def make_calendar(
+ self,
+ name: Optional[str] = None,
+ cal_id: Optional[str] = None,
+ supported_calendar_component_set: Optional[Any] = None,
+ method=None,
+ ) -> "AsyncCalendar":
+ """
+ Utility method for creating a new calendar.
+
+ Args:
+ name: the display name of the new calendar
+ cal_id: the uuid of the new calendar
+ supported_calendar_component_set: what kind of objects
+ (EVENT, VTODO, VFREEBUSY, VJOURNAL) the calendar should handle.
+
+ Returns:
+ AsyncCalendar(...)-object
+ """
+ cal = AsyncCalendar(
+ self.client,
+ name=name,
+ parent=self,
+ id=cal_id,
+ supported_calendar_component_set=supported_calendar_component_set,
+ )
+ return await cal.save(method=method)
+
+ async def calendar(
+ self, name: Optional[str] = None, cal_id: Optional[str] = None
+ ) -> "AsyncCalendar":
+ """
+ The calendar method will return a calendar object. If it gets a cal_id
+ but no name, it will not initiate any communication with the server
+
+ Args:
+ name: return the calendar with this display name
+ cal_id: return the calendar with this calendar id or URL
+
+ Returns:
+ AsyncCalendar(...)-object
+ """
+ if name and not cal_id:
+ for calendar in await self.calendars():
+ display_name = await calendar.get_display_name()
+ if display_name == name:
+ return calendar
+ if name and not cal_id:
+ raise error.NotFoundError(
+ "No calendar with name %s found under %s" % (name, self.url)
+ )
+ if not cal_id and not name:
+ cals = await self.calendars()
+ if not cals:
+ raise error.NotFoundError("no calendars found")
+ return cals[0]
+
+ if self.client is None:
+ raise ValueError("Unexpected value None for self.client")
+
+ if cal_id is None:
+ raise ValueError("Unexpected value None for cal_id")
+
+ if str(URL.objectify(cal_id).canonical()).startswith(
+ str(self.client.url.canonical())
+ ):
+ url = self.client.url.join(cal_id)
+ elif isinstance(cal_id, URL) or (
+ isinstance(cal_id, str)
+ and (cal_id.startswith("https://") or cal_id.startswith("http://"))
+ ):
+ if self.url is None:
+ raise ValueError("Unexpected value None for self.url")
+
+ url = self.url.join(cal_id)
+ else:
+ if self.url is None:
+ raise ValueError("Unexpected value None for self.url")
+
+ if cal_id is None:
+ raise ValueError("Unexpected value None for cal_id")
+
+ url = self.url.join(quote(cal_id) + "/")
+
+ return AsyncCalendar(self.client, name=name, parent=self, url=url, id=cal_id)
+
+
+class AsyncPrincipal(AsyncDAVObject):
+ """
+ This class represents a DAV Principal. It doesn't do much, except
+ keep track of the URLs for the calendar-home-set, etc.
+ """
+
+ def __init__(
+ self,
+ client: Optional["AsyncDAVClient"] = None,
+ url: Union[str, ParseResult, SplitResult, URL, None] = None,
+ calendar_home_set: URL = None,
+ **kwargs,
+ ) -> None:
+ """
+ Returns a Principal.
+ """
+ self._calendar_home_set = calendar_home_set
+ self._initialized = False
+ super(AsyncPrincipal, self).__init__(client=client, url=url, **kwargs)
+
+ async def _ensure_initialized(self) -> None:
+ """Initialize the principal URL if not already done."""
+ if self._initialized:
+ return
+
+ if self.url is None:
+ if self.client is None:
+ raise ValueError("Unexpected value None for self.client")
+
+ self.url = self.client.url
+ cup = await self.get_property(dav.CurrentUserPrincipal())
+
+ if cup is None:
+ log.warning("calendar server lacking a feature:")
+ log.warning("current-user-principal property not found")
+ log.warning("assuming %s is the principal URL" % self.client.url)
+
+ self.url = self.client.url.join(URL.objectify(cup))
+
+ self._initialized = True
+
+ async def make_calendar(
+ self,
+ name: Optional[str] = None,
+ cal_id: Optional[str] = None,
+ supported_calendar_component_set: Optional[Any] = None,
+ method=None,
+ ) -> "AsyncCalendar":
+ """
+ Convenience method, bypasses the self.calendar_home_set object.
+ See AsyncCalendarSet.make_calendar for details.
+ """
+ calendar_home = await self.get_calendar_home_set()
+ return await calendar_home.make_calendar(
+ name,
+ cal_id,
+ supported_calendar_component_set=supported_calendar_component_set,
+ method=method,
+ )
+
+ async def calendar(
+ self,
+ name: Optional[str] = None,
+ cal_id: Optional[str] = None,
+ cal_url: Optional[str] = None,
+ ) -> "AsyncCalendar":
+ """
+ The calendar method will return a calendar object.
+ It will not initiate any communication with the server.
+ """
+ if not cal_url:
+ calendar_home = await self.get_calendar_home_set()
+ return await calendar_home.calendar(name, cal_id)
+ else:
+ if self.client is None:
+ raise ValueError("Unexpected value None for self.client")
+
+ return AsyncCalendar(self.client, url=self.client.url.join(cal_url))
+
+ async def get_vcal_address(self) -> "icalendar.vCalAddress":
+ """
+ Returns the principal, as an icalendar.vCalAddress object
+ """
+ from icalendar import vCalAddress, vText
+
+ cn = await self.get_display_name()
+ ids = await self.calendar_user_address_set()
+ cutype = await self.get_property(cdav.CalendarUserType())
+ ret = vCalAddress(ids[0])
+ ret.params["cn"] = vText(cn)
+ ret.params["cutype"] = vText(cutype)
+ return ret
+
+ async def get_calendar_home_set(self) -> AsyncCalendarSet:
+ """Get the calendar home set for this principal."""
+ await self._ensure_initialized()
+
+ if not self._calendar_home_set:
+ calendar_home_set_url = await self.get_property(cdav.CalendarHomeSet())
+ if (
+ calendar_home_set_url is not None
+ and "@" in calendar_home_set_url
+ and "://" not in calendar_home_set_url
+ ):
+ calendar_home_set_url = quote(calendar_home_set_url)
+ await self._set_calendar_home_set(calendar_home_set_url)
+ return self._calendar_home_set
+
+ async def _set_calendar_home_set(self, url) -> None:
+ if isinstance(url, AsyncCalendarSet):
+ self._calendar_home_set = url
+ return
+ sanitized_url = URL.objectify(url)
+ if sanitized_url is not None:
+ if (
+ sanitized_url.hostname
+ and sanitized_url.hostname != self.client.url.hostname
+ ):
+ self.client.url = sanitized_url
+ self._calendar_home_set = AsyncCalendarSet(
+ self.client, self.client.url.join(sanitized_url)
+ )
+
+ async def calendars(self) -> List["AsyncCalendar"]:
+ """
+ Return the principal's calendars
+ """
+ calendar_home = await self.get_calendar_home_set()
+ return await calendar_home.calendars()
+
+ async def freebusy_request(self, dtstart, dtend, attendees):
+ """Sends a freebusy-request for some attendee to the server
+ as per RFC6638
+ """
+ from caldav._async.calendarobjectresource import AsyncFreeBusy
+ from datetime import datetime
+
+ freebusy_ical = icalendar.Calendar()
+ freebusy_ical.add("prodid", "-//tobixen/python-caldav//EN")
+ freebusy_ical.add("version", "2.0")
+ freebusy_ical.add("method", "REQUEST")
+ uid = uuid.uuid1()
+ freebusy_comp = icalendar.FreeBusy()
+ freebusy_comp.add("uid", uid)
+ freebusy_comp.add("dtstamp", datetime.now())
+ freebusy_comp.add("dtstart", dtstart)
+ freebusy_comp.add("dtend", dtend)
+ freebusy_ical.add_component(freebusy_comp)
+ outbox = await self.schedule_outbox()
+ caldavobj = AsyncFreeBusy(data=freebusy_ical, parent=outbox)
+ await caldavobj.add_organizer()
+ for attendee in attendees:
+ caldavobj.add_attendee(attendee, no_default_parameters=True)
+
+ response = await self.client.post(
+ outbox.url,
+ caldavobj.data,
+ headers={"Content-Type": "text/calendar; charset=utf-8"},
+ )
+ return response.find_objects_and_props()
+
+ async def calendar_user_address_set(self) -> List[Optional[str]]:
+ """
+ defined in RFC6638
+ """
+ _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]
+
+ async def schedule_inbox(self) -> "AsyncScheduleInbox":
+ """
+ Returns the schedule inbox, as defined in RFC6638
+ """
+ return AsyncScheduleInbox(principal=self)
+
+ async def schedule_outbox(self) -> "AsyncScheduleOutbox":
+ """
+ Returns the schedule outbox, as defined in RFC6638
+ """
+ return AsyncScheduleOutbox(principal=self)
+
+
+class AsyncCalendar(AsyncDAVObject):
+ """
+ The `AsyncCalendar` object is used to represent a calendar collection.
+ """
+
+ async def _create(
+ self, name=None, id=None, supported_calendar_component_set=None, method=None
+ ) -> None:
+ """
+ Create a new calendar with display name `name` in `parent`.
+ """
+ if id is None:
+ id = str(uuid.uuid1())
+ self.id = id
+
+ if method is None:
+ if self.client:
+ supported = self.client.features.is_supported(
+ "create-calendar", return_type=dict
+ )
+ if supported["support"] not in ("full", "fragile", "quirk"):
+ raise error.MkcalendarError(
+ "Creation of calendars (allegedly) not supported on this server"
+ )
+ if (
+ supported["support"] == "quirk"
+ and supported["behaviour"] == "mkcol-required"
+ ):
+ method = "mkcol"
+ else:
+ method = "mkcalendar"
+ else:
+ method = "mkcalendar"
+
+ path = self.parent.url.join(id + "/")
+ self.url = path
+
+ prop = dav.Prop()
+ if name:
+ display_name = dav.DisplayName(name)
+ prop += [display_name]
+ if supported_calendar_component_set:
+ sccs = cdav.SupportedCalendarComponentSet()
+ for scc in supported_calendar_component_set:
+ sccs += cdav.Comp(scc)
+ prop += sccs
+ if method == "mkcol":
+ prop += dav.ResourceType() + [dav.Collection(), cdav.Calendar()]
+
+ set = dav.Set() + prop
+
+ mkcol = (dav.Mkcol() if method == "mkcol" else cdav.Mkcalendar()) + set
+
+ r = await self._query(
+ root=mkcol, query_method=method, url=path, expected_return_value=201
+ )
+
+ if name:
+ try:
+ await self.set_properties([display_name])
+ except Exception as e:
+ try:
+ current_display_name = await self.get_display_name()
+ error.assert_(current_display_name == name)
+ except:
+ log.warning(
+ "calendar server does not support display name on calendar? Ignoring",
+ exc_info=True,
+ )
+
+ async def get_supported_components(self) -> List[Any]:
+ """
+ returns a list of component types supported by the calendar
+ """
+ if self.url is None:
+ raise ValueError("Unexpected value None for self.url")
+
+ props = [cdav.SupportedCalendarComponentSet()]
+ response = await self.get_properties(props, parse_response_xml=False)
+ response_list = response.find_objects_and_props()
+ prop = response_list[unquote(self.url.path)][
+ cdav.SupportedCalendarComponentSet().tag
+ ]
+ return [supported.get("name") for supported in prop]
+
+ async def save(self, method=None) -> Self:
+ """
+ The save method for a calendar is only used to create it, for now.
+ We know we have to create it when we don't have a url.
+
+ Returns:
+ * self
+ """
+ if self.url is None:
+ await self._create(
+ id=self.id, name=self.name, method=method, **self.extra_init_options
+ )
+ return self
+
+ async def _request_report_build_resultlist(
+ self, xml, comp_class=None, props=None, no_calendardata=False
+ ):
+ """
+ Takes some input XML, does a report query on a calendar object
+ and returns the resource objects found.
+ """
+ from caldav._async.calendarobjectresource import AsyncCalendarObjectResource
+
+ matches = []
+ if props is None:
+ props_ = [cdav.CalendarData()]
+ else:
+ props_ = [cdav.CalendarData()] + props
+ response = await self._query(xml, 1, "report")
+ results = response.expand_simple_props(props_)
+ for r in results:
+ pdata = results[r]
+ if cdav.CalendarData.tag in pdata:
+ cdata = pdata.pop(cdav.CalendarData.tag)
+ comp_class_ = (
+ self._calendar_comp_class_by_data(cdata)
+ if comp_class is None
+ else comp_class
+ )
+ else:
+ cdata = None
+ if comp_class_ is None:
+ comp_class_ = AsyncCalendarObjectResource
+ url = URL(r)
+ if url.hostname is None:
+ url = quote(r)
+ if self.url.join(url) == self.url:
+ continue
+ matches.append(
+ comp_class_(
+ self.client,
+ url=self.url.join(url),
+ data=cdata,
+ parent=self,
+ props=pdata,
+ )
+ )
+ return (response, matches)
+
+ async def search(
+ self,
+ xml: str = None,
+ server_expand: bool = False,
+ split_expanded: bool = True,
+ sort_reverse: bool = False,
+ props=None,
+ filters=None,
+ post_filter=None,
+ _hacks=None,
+ **searchargs,
+ ) -> List:
+ """Sends a search request towards the server."""
+ from caldav.search import CalDAVSearcher
+
+ my_searcher = CalDAVSearcher()
+ for key in searchargs:
+ alias = key
+ if key == "class_":
+ alias = "class"
+ if key == "category":
+ alias = "categories"
+ if key == "no_category":
+ alias = "no_categories"
+ if key == "no_class_":
+ alias = "no_class"
+ if key == "sort_keys":
+ if isinstance(searchargs["sort_keys"], str):
+ searchargs["sort_keys"] = [searchargs["sort_keys"]]
+ for sortkey in searchargs["sort_keys"]:
+ my_searcher.add_sort_key(sortkey, sort_reverse)
+ continue
+ elif key == "comp_class" or key in my_searcher.__dataclass_fields__:
+ setattr(my_searcher, key, searchargs[key])
+ continue
+ elif alias.startswith("no_"):
+ my_searcher.add_property_filter(
+ alias[3:], searchargs[key], operator="undef"
+ )
+ else:
+ my_searcher.add_property_filter(alias, searchargs[key])
+
+ if not xml and filters:
+ xml = filters
+
+ return await my_searcher.async_search(
+ self, server_expand, split_expanded, props, xml, post_filter, _hacks
+ )
+
+ async def events(self) -> List:
+ """
+ List all events from the calendar.
+ """
+ from caldav._async.calendarobjectresource import AsyncEvent
+
+ return await self.search(comp_class=AsyncEvent)
+
+ async def todos(
+ self,
+ sort_keys: Sequence[str] = ("due", "priority"),
+ include_completed: bool = False,
+ sort_key: Optional[str] = None,
+ ) -> List:
+ """
+ Fetches a list of todo events
+ """
+ if sort_key:
+ sort_keys = (sort_key,)
+
+ return await self.search(
+ todo=True, include_completed=include_completed, sort_keys=sort_keys
+ )
+
+ async def journals(self) -> List:
+ """
+ List all journals from the calendar.
+ """
+ from caldav._async.calendarobjectresource import AsyncJournal
+
+ return await self.search(comp_class=AsyncJournal)
+
+ async def freebusy_request(self, start, end):
+ """
+ Search the calendar, but return only the free/busy information.
+ """
+ from caldav._async.calendarobjectresource import AsyncFreeBusy
+
+ root = cdav.FreeBusyQuery() + [cdav.TimeRange(start, end)]
+ response = await self._query(root, 1, "report")
+ return AsyncFreeBusy(self, response.raw)
+
+ def _calendar_comp_class_by_data(self, data):
+ """
+ Returns the appropriate CalendarResourceObject child class.
+ """
+ from caldav._async.calendarobjectresource import (
+ AsyncCalendarObjectResource,
+ AsyncEvent,
+ AsyncTodo,
+ AsyncJournal,
+ AsyncFreeBusy,
+ )
+
+ if data is None:
+ return AsyncCalendarObjectResource
+ if hasattr(data, "split"):
+ for line in data.split("\n"):
+ line = line.strip()
+ if line == "BEGIN:VEVENT":
+ return AsyncEvent
+ if line == "BEGIN:VTODO":
+ return AsyncTodo
+ if line == "BEGIN:VJOURNAL":
+ return AsyncJournal
+ if line == "BEGIN:VFREEBUSY":
+ return AsyncFreeBusy
+ elif hasattr(data, "subcomponents"):
+ if not len(data.subcomponents):
+ return AsyncCalendarObjectResource
+
+ ical2caldav = {
+ icalendar.Event: AsyncEvent,
+ icalendar.Todo: AsyncTodo,
+ icalendar.Journal: AsyncJournal,
+ icalendar.FreeBusy: AsyncFreeBusy,
+ }
+ for sc in data.subcomponents:
+ if sc.__class__ in ical2caldav:
+ return ical2caldav[sc.__class__]
+ return AsyncCalendarObjectResource
+
+ async def object_by_uid(
+ self,
+ uid: str,
+ comp_filter=None,
+ comp_class=None,
+ ):
+ """
+ Get one event from the calendar by UID.
+ """
+ from caldav.search import CalDAVSearcher
+
+ searcher = CalDAVSearcher(comp_class=comp_class)
+ searcher.add_property_filter("uid", uid, "==")
+ items_found = await searcher.async_search(
+ self, xml=comp_filter, _hacks="insist", post_filter=True
+ )
+
+ if not items_found:
+ raise error.NotFoundError("%s not found on server" % uid)
+ error.assert_(len(items_found) == 1)
+ return items_found[0]
+
+ async def event_by_uid(self, uid: str):
+ """Returns the event with the given uid"""
+ return await self.object_by_uid(uid, comp_filter=cdav.CompFilter("VEVENT"))
+
+ async def todo_by_uid(self, uid: str):
+ """Returns the task with the given uid"""
+ return await self.object_by_uid(uid, comp_filter=cdav.CompFilter("VTODO"))
+
+ async def journal_by_uid(self, uid: str):
+ """Returns the journal with the given uid"""
+ return await self.object_by_uid(uid, comp_filter=cdav.CompFilter("VJOURNAL"))
+
+ def _use_or_create_ics(self, ical, objtype, **ical_data):
+ """Create or use an ical object."""
+ from caldav.lib import vcal
+ from caldav.lib.python_utilities import to_wire
+
+ if ical_data or (
+ (isinstance(ical, str) or isinstance(ical, bytes))
+ and b"BEGIN:VCALENDAR" not in to_wire(ical)
+ ):
+ if ical and "ical_fragment" not in ical_data:
+ ical_data["ical_fragment"] = ical
+ return vcal.create_ical(objtype=objtype, **ical_data)
+ return ical
+
+ async def save_object(
+ self,
+ objclass,
+ ical=None,
+ no_overwrite=False,
+ no_create=False,
+ **ical_data,
+ ):
+ """Add a new object to the calendar.
+
+ Args:
+ objclass: AsyncEvent, AsyncTodo, AsyncJournal
+ ical: ical object (text, icalendar or vobject instance)
+ no_overwrite: existing calendar objects should not be overwritten
+ no_create: don't create a new object
+ """
+ o = objclass(
+ self.client,
+ data=self._use_or_create_ics(
+ ical,
+ objtype=f"V{objclass.__name__.replace('Async', '').upper()}",
+ **ical_data,
+ ),
+ parent=self,
+ )
+ o = await o.save(no_overwrite=no_overwrite, no_create=no_create)
+ return o
+
+ async def save_event(
+ self, ical=None, no_overwrite=False, no_create=False, **ical_data
+ ):
+ """Save an event to the calendar."""
+ from caldav._async.calendarobjectresource import AsyncEvent
+
+ return await self.save_object(
+ AsyncEvent, ical, no_overwrite, no_create, **ical_data
+ )
+
+ async def save_todo(
+ self, ical=None, no_overwrite=False, no_create=False, **ical_data
+ ):
+ """Save a todo to the calendar."""
+ from caldav._async.calendarobjectresource import AsyncTodo
+
+ return await self.save_object(
+ AsyncTodo, ical, no_overwrite, no_create, **ical_data
+ )
+
+ async def save_journal(
+ self, ical=None, no_overwrite=False, no_create=False, **ical_data
+ ):
+ """Save a journal entry to the calendar."""
+ from caldav._async.calendarobjectresource import AsyncJournal
+
+ return await self.save_object(
+ AsyncJournal, ical, no_overwrite, no_create, **ical_data
+ )
+
+
+class AsyncScheduleMailbox(AsyncCalendar):
+ """
+ RFC6638 defines an inbox and an outbox for handling event scheduling.
+ """
+
+ def __init__(
+ self,
+ client: Optional["AsyncDAVClient"] = None,
+ principal: Optional[AsyncPrincipal] = None,
+ url: Union[str, ParseResult, SplitResult, URL, None] = None,
+ ) -> None:
+ super(AsyncScheduleMailbox, self).__init__(client=client, url=url)
+ self._items = None
+ self._principal = principal
+ if not client and principal:
+ self.client = principal.client
+
+ async def _ensure_url(self) -> None:
+ """Ensure the URL is set by querying the principal if needed."""
+ if self.url is not None:
+ return
+
+ principal = self._principal
+ if not principal and self.client:
+ principal = await self.client.principal()
+
+ if principal is None:
+ raise ValueError("Unexpected value None for principal")
+
+ if self.client is None:
+ raise ValueError("Unexpected value None for self.client")
+
+ self.url = principal.url
+ try:
+ prop_url = await self.get_property(self.findprop())
+ self.url = self.client.url.join(URL(prop_url))
+ except:
+ logging.error("something bad happened", exc_info=True)
+ error.assert_(await self.client.check_scheduling_support())
+ self.url = None
+ raise error.NotFoundError(
+ "principal has no %s. %s" % (str(self.findprop()), error.ERR_FRAGMENT)
+ )
+
+
+class AsyncScheduleInbox(AsyncScheduleMailbox):
+ findprop = cdav.ScheduleInboxURL
+
+
+class AsyncScheduleOutbox(AsyncScheduleMailbox):
+ findprop = cdav.ScheduleOutboxURL
diff --git a/caldav/_async/davclient.py b/caldav/_async/davclient.py
new file mode 100644
index 00000000..36a1e2cf
--- /dev/null
+++ b/caldav/_async/davclient.py
@@ -0,0 +1,724 @@
+#!/usr/bin/env python
+"""
+Async DAVClient implementation using httpx.
+
+This is the primary implementation - the sync API wraps this.
+"""
+import logging
+import sys
+from typing import Any
+from typing import Dict
+from typing import Mapping
+from typing import Optional
+from typing import Tuple
+from typing import Union
+from urllib.parse import unquote
+
+import httpx
+from lxml import etree
+from lxml.etree import _Element
+
+try:
+ from caldav._version import __version__
+except ImportError:
+ __version__ = "0.0.0.dev"
+
+from caldav.lib import error
+from caldav.lib.python_utilities import to_normal_str
+from caldav.lib.python_utilities import to_wire
+from caldav.lib.url import URL
+
+if sys.version_info < (3, 9):
+ from typing import Iterable
+else:
+ from collections.abc import Iterable
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Self
+else:
+ from typing import Self
+
+log = logging.getLogger("caldav")
+
+
+class HTTPBearerAuth(httpx.Auth):
+ """Bearer token authentication for httpx."""
+
+ def __init__(self, token: Union[str, bytes]):
+ if isinstance(token, bytes):
+ token = token.decode("utf-8")
+ self.token = token
+
+ def auth_flow(self, request: httpx.Request):
+ request.headers["Authorization"] = f"Bearer {self.token}"
+ yield request
+
+
+class DAVResponse:
+ """
+ This class is a response from a DAV request. It is instantiated from
+ the AsyncDAVClient class. End users of the library should not need to
+ know anything about this class. Since we often get XML responses,
+ it tries to parse it into `self.tree`
+ """
+
+ raw = ""
+ reason: str = ""
+ tree: Optional[_Element] = None
+ headers: httpx.Headers = None
+ status: int = 0
+ davclient: Optional["AsyncDAVClient"] = None
+ huge_tree: bool = False
+
+ def __init__(
+ self, response: httpx.Response, davclient: Optional["AsyncDAVClient"] = None
+ ) -> None:
+ from caldav.elements import cdav, dav
+
+ self.headers = response.headers
+ self.status = response.status_code
+ log.debug("response headers: " + str(self.headers))
+ log.debug("response status: " + str(self.status))
+
+ self._raw = response.content
+ self.davclient = davclient
+ if davclient:
+ self.huge_tree = davclient.huge_tree
+
+ content_type = self.headers.get("Content-Type", "")
+ xml = ["text/xml", "application/xml"]
+ no_xml = ["text/plain", "text/calendar", "application/octet-stream"]
+ expect_xml = any((content_type.startswith(x) for x in xml))
+ expect_no_xml = any((content_type.startswith(x) for x in no_xml))
+ if (
+ content_type
+ and not expect_xml
+ and not expect_no_xml
+ and response.status_code < 400
+ ):
+ error.weirdness(f"Unexpected content type: {content_type}")
+ try:
+ content_length = int(self.headers.get("Content-Length", -1))
+ except:
+ content_length = -1
+ if content_length == 0 or not self._raw:
+ self._raw = ""
+ self.tree = None
+ log.debug("No content delivered")
+ else:
+ try:
+ self.tree = etree.XML(
+ self._raw,
+ parser=etree.XMLParser(
+ remove_blank_text=True, huge_tree=self.huge_tree
+ ),
+ )
+ except:
+ if not expect_no_xml or log.level <= logging.DEBUG:
+ if not expect_no_xml:
+ _log = logging.critical
+ else:
+ _log = logging.debug
+ _log(
+ "Expected some valid XML from the server, but got this: \n"
+ + str(self._raw),
+ exc_info=True,
+ )
+ if expect_xml:
+ raise
+ else:
+ if log.level <= logging.DEBUG:
+ log.debug(etree.tostring(self.tree, pretty_print=True))
+
+ if hasattr(self, "_raw"):
+ log.debug(self._raw)
+ if isinstance(self._raw, bytes):
+ self._raw = self._raw.replace(b"\r\n", b"\n")
+ elif isinstance(self._raw, str):
+ self._raw = self._raw.replace("\r\n", "\n")
+ self.status = response.status_code
+ self.reason = response.reason_phrase or ""
+
+ @property
+ def raw(self) -> str:
+ if not hasattr(self, "_raw"):
+ from lxml.etree import _Element
+ from typing import cast
+
+ self._raw = etree.tostring(cast(_Element, self.tree), pretty_print=True)
+ return to_normal_str(self._raw)
+
+ def _strip_to_multistatus(self):
+ """Strip down to the multistatus element or response list."""
+ from caldav.elements import dav
+
+ tree = self.tree
+ if tree.tag == "xml" and tree[0].tag == dav.MultiStatus.tag:
+ return tree[0]
+ if tree.tag == dav.MultiStatus.tag:
+ return self.tree
+ return [self.tree]
+
+ def validate_status(self, status: str) -> None:
+ """Validate HTTP status string."""
+ if (
+ " 200 " not in status
+ and " 201 " not in status
+ and " 207 " not in status
+ and " 404 " not in status
+ ):
+ raise error.ResponseError(status)
+
+ def _parse_response(self, response) -> Tuple[str, list, Optional[Any]]:
+ """Parse a single DAV response element."""
+ from caldav.elements import dav
+ from typing import cast
+
+ status = None
+ href: Optional[str] = None
+ propstats: list = []
+ check_404 = False
+
+ error.assert_(response.tag == dav.Response.tag)
+ for elem in response:
+ if elem.tag == dav.Status.tag:
+ error.assert_(not status)
+ status = elem.text
+ error.assert_(status)
+ self.validate_status(status)
+ elif elem.tag == dav.Href.tag:
+ assert not href
+ if "%2540" in elem.text:
+ elem.text = elem.text.replace("%2540", "%40")
+ href = unquote(elem.text)
+ elif elem.tag == dav.PropStat.tag:
+ propstats.append(elem)
+ elif elem.tag == "{DAV:}error":
+ children = elem.getchildren()
+ error.assert_(len(children) == 1)
+ error.assert_(
+ children[0].tag == "{https://purelymail.com}does-not-exist"
+ )
+ check_404 = True
+ else:
+ error.weirdness("unexpected element found in response", elem)
+
+ error.assert_(href)
+ if check_404:
+ error.assert_("404" in status)
+
+ if ":" in href:
+ href = unquote(URL(href).path)
+ return (cast(str, href), propstats, status)
+
+ def find_objects_and_props(self) -> Dict[str, Dict[str, _Element]]:
+ """Parse response and extract hrefs and properties."""
+ from caldav.elements import dav
+
+ self.objects: Dict[str, Dict[str, _Element]] = {}
+ self.statuses: Dict[str, str] = {}
+
+ if "Schedule-Tag" in self.headers:
+ self.schedule_tag = self.headers["Schedule-Tag"]
+
+ responses = self._strip_to_multistatus()
+ for r in responses:
+ if r.tag == dav.SyncToken.tag:
+ self.sync_token = r.text
+ continue
+ error.assert_(r.tag == dav.Response.tag)
+
+ (href, propstats, status) = self._parse_response(r)
+
+ if href not in self.objects:
+ self.objects[href] = {}
+ self.statuses[href] = status
+
+ for propstat in propstats:
+ cnt = 0
+ status_elem = propstat.find(dav.Status.tag)
+ error.assert_(status_elem is not None)
+ if status_elem is not None and status_elem.text is not None:
+ error.assert_(len(status_elem) == 0)
+ cnt += 1
+ self.validate_status(status_elem.text)
+ if " 404 " in status_elem.text:
+ continue
+ for prop in propstat.iterfind(dav.Prop.tag):
+ cnt += 1
+ for theprop in prop:
+ self.objects[href][theprop.tag] = theprop
+
+ error.assert_(cnt == len(propstat))
+
+ return self.objects
+
+ def _expand_simple_prop(
+ self, proptag, props_found, multi_value_allowed=False, xpath=None
+ ):
+ """Expand a simple property to its text value(s)."""
+ values = []
+ if proptag in props_found:
+ prop_xml = props_found[proptag]
+ for item in prop_xml.items():
+ if proptag == "{urn:ietf:params:xml:ns:caldav}calendar-data":
+ if (
+ item[0].lower().endswith("content-type")
+ and item[1].lower() == "text/calendar"
+ ):
+ continue
+ if item[0].lower().endswith("version") and item[1] in ("2", "2.0"):
+ continue
+ log.error(
+ f"If you see this, please add a report at https://github.com/python-caldav/caldav/issues/209 - in _expand_simple_prop, dealing with {proptag}, extra item found: {'='.join(item)}."
+ )
+ if not xpath and len(prop_xml) == 0:
+ if prop_xml.text:
+ values.append(prop_xml.text)
+ else:
+ _xpath = xpath if xpath else ".//*"
+ leafs = prop_xml.findall(_xpath)
+ values = []
+ for leaf in leafs:
+ error.assert_(not leaf.items())
+ if leaf.text:
+ values.append(leaf.text)
+ else:
+ values.append(leaf.tag)
+ if multi_value_allowed:
+ return values
+ else:
+ if not values:
+ return None
+ error.assert_(len(values) == 1)
+ return values[0]
+
+ def expand_simple_props(
+ self,
+ props: Iterable = None,
+ multi_value_props: Iterable[Any] = None,
+ xpath: Optional[str] = None,
+ ) -> Dict[str, Dict[str, str]]:
+ """Expand properties to text values."""
+ from typing import cast
+
+ props = props or []
+ multi_value_props = multi_value_props or []
+
+ if not hasattr(self, "objects"):
+ self.find_objects_and_props()
+ for href in self.objects:
+ props_found = self.objects[href]
+ for prop in props:
+ if prop.tag is None:
+ continue
+ props_found[prop.tag] = self._expand_simple_prop(
+ prop.tag, props_found, xpath=xpath
+ )
+ for prop in multi_value_props:
+ if prop.tag is None:
+ continue
+ props_found[prop.tag] = self._expand_simple_prop(
+ prop.tag, props_found, xpath=xpath, multi_value_allowed=True
+ )
+ return cast(Dict[str, Dict[str, str]], self.objects)
+
+
+class AsyncDAVClient:
+ """
+ Async CalDAV client using httpx.
+
+ This is the primary implementation. The sync DAVClient wraps this class.
+ """
+
+ proxy: Optional[str] = None
+ url: URL = None
+ huge_tree: bool = False
+
+ def __init__(
+ self,
+ url: Optional[str] = "",
+ proxy: Optional[str] = None,
+ username: Optional[str] = None,
+ password: Optional[str] = None,
+ auth: Optional[httpx.Auth] = None,
+ auth_type: Optional[str] = None,
+ timeout: Optional[float] = None,
+ ssl_verify_cert: Union[bool, str] = True,
+ ssl_cert: Union[str, Tuple[str, str], None] = None,
+ headers: Mapping[str, str] = None,
+ huge_tree: bool = False,
+ features: Union["FeatureSet", dict, str] = None,
+ ) -> None:
+ """
+ Initialize async DAV client.
+
+ Args:
+ url: CalDAV server URL
+ proxy: Proxy server URL
+ username: Username for authentication
+ password: Password for authentication
+ auth: httpx.Auth object for custom authentication
+ auth_type: Auth type ('basic', 'digest', 'bearer')
+ timeout: Request timeout in seconds
+ ssl_verify_cert: SSL certificate verification (bool or CA bundle path)
+ ssl_cert: Client SSL certificate
+ headers: Additional headers
+ huge_tree: Enable huge XML tree parsing
+ features: Server compatibility features
+ """
+ import caldav.compatibility_hints
+ from caldav.compatibility_hints import FeatureSet
+
+ headers = headers or {}
+
+ if isinstance(features, str):
+ features = getattr(caldav.compatibility_hints, features)
+ self.features = FeatureSet(features)
+ self.huge_tree = huge_tree
+
+ # Auto-configure URL based on features
+ url = self._auto_url(url, self.features)
+
+ log.debug("url: " + str(url))
+ self.url = URL.objectify(url)
+
+ # Configure proxy
+ self._proxy = None
+ if proxy is not None:
+ _proxy = proxy
+ if "://" not in proxy:
+ _proxy = self.url.scheme + "://" + proxy
+ p = _proxy.split(":")
+ if len(p) == 2:
+ _proxy += ":8080"
+ log.debug("init - proxy: %s" % (_proxy))
+ self._proxy = _proxy
+
+ # Build headers
+ self.headers = {
+ "User-Agent": "python-caldav/" + __version__,
+ "Content-Type": "text/xml",
+ "Accept": "text/xml, text/calendar",
+ }
+ self.headers.update(headers or {})
+
+ # Handle credentials from URL
+ if self.url.username is not None:
+ username = unquote(self.url.username)
+ password = unquote(self.url.password)
+
+ self.username = username
+ self.password = password
+ self.auth = auth
+ self.auth_type = auth_type
+
+ if isinstance(self.password, str):
+ self.password = self.password.encode("utf-8")
+ if auth and self.auth_type:
+ logging.error(
+ "both auth object and auth_type sent to AsyncDAVClient. The latter will be ignored."
+ )
+ elif self.auth_type:
+ self._build_auth_object()
+
+ self.timeout = timeout
+ self.ssl_verify_cert = ssl_verify_cert
+ self.ssl_cert = ssl_cert
+ self.url = self.url.unauth()
+ log.debug("self.url: " + str(url))
+
+ self._principal = None
+ self._client: Optional[httpx.AsyncClient] = None
+
+ def _auto_url(self, url, features):
+ """Auto-configure URL based on features."""
+ from caldav.compatibility_hints import FeatureSet
+
+ if isinstance(features, dict):
+ features = FeatureSet(features)
+ if "/" not in str(url):
+ url_hints = features.is_supported("auto-connect.url", dict)
+ if not url and "domain" in url_hints:
+ url = url_hints["domain"]
+ url = f"{url_hints.get('scheme', 'https')}://{url}{url_hints.get('basepath', '')}"
+ return url
+
+ async def _get_client(self) -> httpx.AsyncClient:
+ """Get or create the httpx AsyncClient.
+
+ When used from sync wrappers (via anyio.run()), we need to create
+ a fresh client each time because the connection pool gets invalidated
+ when the event loop closes.
+ """
+ # Always create a fresh client to avoid event loop issues
+ # The overhead is minimal compared to connection establishment
+ transport = None
+ if self._proxy:
+ transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
+
+ # Disable connection pooling to avoid stale connections
+ # when used from sync context with multiple anyio.run() calls
+ limits = httpx.Limits(max_keepalive_connections=0)
+
+ client = httpx.AsyncClient(
+ auth=self.auth,
+ timeout=self.timeout,
+ verify=self.ssl_verify_cert,
+ cert=self.ssl_cert,
+ transport=transport,
+ limits=limits,
+ )
+ return client
+
+ async def __aenter__(self) -> Self:
+ await self._get_client()
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback) -> None:
+ await self.close()
+
+ async def close(self) -> None:
+ """Close the httpx client."""
+ if self._client:
+ await self._client.aclose()
+ self._client = None
+
+ def extract_auth_types(self, header: str):
+ """Extract authentication types from WWW-Authenticate header."""
+ return {h.split()[0] for h in header.lower().split(",")}
+
+ def _build_auth_object(self, auth_types: Optional[list] = None):
+ """Build authentication object based on auth_type or server response."""
+ auth_type = self.auth_type
+ if not auth_type and not auth_types:
+ raise error.AuthorizationError("No auth-type given. This shouldn't happen.")
+ if auth_types and auth_type and auth_type not in auth_types:
+ raise error.AuthorizationError(
+ reason=f"Configuration specifies to use {auth_type}, but server only accepts {auth_types}"
+ )
+ if not auth_type and auth_types:
+ if self.username and "digest" in auth_types:
+ auth_type = "digest"
+ elif self.username and "basic" in auth_types:
+ auth_type = "basic"
+ elif self.password and "bearer" in auth_types:
+ auth_type = "bearer"
+ elif "bearer" in auth_types:
+ raise error.AuthorizationError(
+ reason="Server provides bearer auth, but no password given."
+ )
+
+ if auth_type == "digest":
+ self.auth = httpx.DigestAuth(self.username, self.password)
+ elif auth_type == "basic":
+ self.auth = httpx.BasicAuth(self.username, self.password)
+ elif auth_type == "bearer":
+ self.auth = HTTPBearerAuth(self.password)
+
+ async def request(
+ self,
+ url: str,
+ method: str = "GET",
+ body: str = "",
+ headers: Mapping[str, str] = None,
+ ) -> DAVResponse:
+ """
+ Send an HTTP request to the CalDAV server.
+
+ This is the core method that all other HTTP methods use.
+ """
+ headers = headers or {}
+
+ combined_headers = dict(self.headers)
+ combined_headers.update(headers or {})
+ if (body is None or body == "") and "Content-Type" in combined_headers:
+ del combined_headers["Content-Type"]
+
+ url_obj = URL.objectify(url)
+
+ log.debug(
+ "sending request - method={0}, url={1}, headers={2}\nbody:\n{3}".format(
+ method, str(url_obj), combined_headers, to_normal_str(body)
+ )
+ )
+
+ # Create a fresh client for each request to avoid event loop issues
+ # when used from sync context with multiple anyio.run() calls
+ async with await self._get_client() as client:
+ try:
+ r = await client.request(
+ method,
+ str(url_obj),
+ content=to_wire(body) if body else None,
+ headers=combined_headers,
+ )
+ log.debug(
+ "server responded with %i %s" % (r.status_code, r.reason_phrase)
+ )
+
+ if (
+ r.status_code == 401
+ and "text/html" in self.headers.get("Content-Type", "")
+ and not self.auth
+ ):
+ msg = (
+ "No authentication object was provided. "
+ "HTML was returned when probing the server for supported authentication types."
+ )
+ if r.headers.get("WWW-Authenticate"):
+ auth_types = [
+ t
+ for t in self.extract_auth_types(
+ r.headers["WWW-Authenticate"]
+ )
+ if t in ["basic", "digest", "bearer"]
+ ]
+ if auth_types:
+ msg += "\nSupported authentication types: %s" % (
+ ", ".join(auth_types)
+ )
+ log.warning(msg)
+ response = DAVResponse(r, self)
+ except httpx.RequestError:
+ if self.auth or not self.password:
+ raise
+ # Workaround for servers that abort connection instead of 401
+ r = await client.request(
+ method="GET",
+ url=str(url_obj),
+ headers=combined_headers,
+ )
+ if r.status_code != 401:
+ raise
+ response = DAVResponse(r, self)
+
+ # Handle authentication (outside the client context - will create new client if needed)
+ if (
+ r.status_code == 401
+ and "WWW-Authenticate" in r.headers
+ and not self.auth
+ and (self.username or self.password)
+ ):
+ auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"])
+ self._build_auth_object(list(auth_types))
+
+ if not self.auth:
+ raise NotImplementedError(
+ "The server does not provide any of the currently "
+ "supported authentication methods: basic, digest, bearer"
+ )
+
+ return await self.request(url, method, body, headers)
+
+ elif (
+ r.status_code == 401
+ and "WWW-Authenticate" in r.headers
+ and self.auth
+ and self.password
+ and isinstance(self.password, bytes)
+ ):
+ # Retry with decoded password for charset issues
+ auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"])
+ self.password = self.password.decode()
+ self._build_auth_object(list(auth_types))
+
+ self.username = None
+ self.password = None
+
+ return await self.request(str(url_obj), method, body, headers)
+
+ # Handle authorization errors
+ if response.status in (403, 401):
+ try:
+ reason = response.reason
+ except AttributeError:
+ reason = "None given"
+ raise error.AuthorizationError(url=str(url_obj), reason=reason)
+
+ return response
+
+ async def propfind(
+ self, url: Optional[str] = None, props: str = "", depth: int = 0
+ ) -> DAVResponse:
+ """Send a PROPFIND request."""
+ return await self.request(
+ url or str(self.url), "PROPFIND", props, {"Depth": str(depth)}
+ )
+
+ async def proppatch(self, url: str, body: str, dummy: None = None) -> DAVResponse:
+ """Send a PROPPATCH request."""
+ return await self.request(url, "PROPPATCH", body)
+
+ async def report(self, url: str, query: str = "", depth: int = 0) -> DAVResponse:
+ """Send a REPORT request."""
+ return await self.request(
+ url,
+ "REPORT",
+ query,
+ {"Depth": str(depth), "Content-Type": 'application/xml; charset="utf-8"'},
+ )
+
+ async def mkcol(self, url: str, body: str, dummy: None = None) -> DAVResponse:
+ """Send a MKCOL request."""
+ return await self.request(url, "MKCOL", body)
+
+ async def mkcalendar(
+ self, url: str, body: str = "", dummy: None = None
+ ) -> DAVResponse:
+ """Send a MKCALENDAR request."""
+ return await self.request(url, "MKCALENDAR", body)
+
+ async def put(
+ self, url: str, body: str, headers: Mapping[str, str] = None
+ ) -> DAVResponse:
+ """Send a PUT request."""
+ return await self.request(url, "PUT", body, headers or {})
+
+ async def post(
+ self, url: str, body: str, headers: Mapping[str, str] = None
+ ) -> DAVResponse:
+ """Send a POST request."""
+ return await self.request(url, "POST", body, headers or {})
+
+ async def delete(self, url: str) -> DAVResponse:
+ """Send a DELETE request."""
+ return await self.request(url, "DELETE")
+
+ async def options(self, url: str) -> DAVResponse:
+ """Send an OPTIONS request."""
+ return await self.request(url, "OPTIONS")
+
+ async def check_dav_support(self) -> Optional[str]:
+ """Check if server supports DAV."""
+ try:
+ principal = await self.principal()
+ response = await self.options(str(principal.url))
+ except:
+ response = await self.options(str(self.url))
+ return response.headers.get("DAV", None)
+
+ async def check_cdav_support(self) -> bool:
+ """Check if server supports CalDAV."""
+ support_list = await self.check_dav_support()
+ return support_list is not None and "calendar-access" in support_list
+
+ async def check_scheduling_support(self) -> bool:
+ """Check if server supports CalDAV scheduling."""
+ support_list = await self.check_dav_support()
+ return support_list is not None and "calendar-auto-schedule" in support_list
+
+ async def principal(self, *args, **kwargs):
+ """Get the principal for this client."""
+ # Lazy import to avoid circular imports
+ from caldav._async.collection import AsyncPrincipal
+
+ if not self._principal:
+ self._principal = AsyncPrincipal(client=self, *args, **kwargs)
+ return self._principal
+
+ def calendar(self, **kwargs):
+ """Get a calendar object by URL."""
+ from caldav._async.collection import AsyncCalendar
+
+ return AsyncCalendar(client=self, **kwargs)
diff --git a/caldav/_async/davobject.py b/caldav/_async/davobject.py
new file mode 100644
index 00000000..7065b744
--- /dev/null
+++ b/caldav/_async/davobject.py
@@ -0,0 +1,304 @@
+#!/usr/bin/env python
+"""
+Async DAVObject base class - the foundation for all DAV objects.
+
+This is the async implementation that the sync wrapper delegates to.
+"""
+import logging
+import sys
+from typing import Any
+from typing import List
+from typing import Optional
+from typing import Sequence
+from typing import Tuple
+from typing import TYPE_CHECKING
+from typing import Union
+from urllib.parse import ParseResult
+from urllib.parse import quote
+from urllib.parse import SplitResult
+from urllib.parse import unquote
+
+from lxml import etree
+
+if TYPE_CHECKING:
+ from caldav._async.davclient import AsyncDAVClient
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Self
+else:
+ from typing import Self
+
+from caldav.elements import cdav, dav
+from caldav.elements.base import BaseElement
+from caldav.lib import error
+from caldav.lib.error import errmsg
+from caldav.lib.python_utilities import to_wire
+from caldav.lib.url import URL
+
+log = logging.getLogger("caldav")
+
+
+class AsyncDAVObject:
+ """
+ Async base class for all DAV objects.
+ """
+
+ id: Optional[str] = None
+ url: Optional[URL] = None
+ client: Optional["AsyncDAVClient"] = None
+ parent: Optional["AsyncDAVObject"] = None
+ name: Optional[str] = None
+
+ def __init__(
+ self,
+ client: Optional["AsyncDAVClient"] = None,
+ url: Union[str, ParseResult, SplitResult, URL, None] = None,
+ parent: Optional["AsyncDAVObject"] = None,
+ name: Optional[str] = None,
+ id: Optional[str] = None,
+ props=None,
+ **extra,
+ ) -> None:
+ """
+ Default constructor.
+
+ Args:
+ client: An AsyncDAVClient instance
+ url: The url for this object
+ parent: The parent object
+ name: A displayname
+ props: a dict with known properties
+ id: The resource id (UID for an Event)
+ """
+ if client is None and parent is not None:
+ client = parent.client
+ self.client = client
+ self.parent = parent
+ self.name = name
+ self.id = id
+ self.props = props or {}
+ self.extra_init_options = extra
+
+ if client and url:
+ self.url = client.url.join(url)
+ elif url is None:
+ self.url = None
+ else:
+ self.url = URL.objectify(url)
+
+ @property
+ def canonical_url(self) -> str:
+ if self.url is None:
+ raise ValueError("Unexpected value None for self.url")
+ return str(self.url.canonical())
+
+ async def children(self, type: Optional[str] = None) -> List[Tuple[URL, Any, Any]]:
+ """List children using a propfind at depth=1."""
+ from caldav._async.collection import AsyncCalendarSet
+
+ c = []
+ depth = 1
+
+ if self.url is None:
+ raise ValueError("Unexpected value None for self.url")
+
+ props = [dav.DisplayName()]
+ multiprops = [dav.ResourceType()]
+ props_multiprops = props + multiprops
+ response = await self._query_properties(props_multiprops, depth)
+ properties = response.expand_simple_props(
+ props=props, multi_value_props=multiprops
+ )
+
+ for path in properties:
+ resource_types = properties[path][dav.ResourceType.tag]
+ resource_name = properties[path][dav.DisplayName.tag]
+
+ if type is None or type in resource_types:
+ url = URL(path)
+ if url.hostname is None:
+ path = quote(path)
+ if (
+ isinstance(self, AsyncCalendarSet) and type == cdav.Calendar.tag
+ ) or (
+ self.url.canonical().strip_trailing_slash()
+ != self.url.join(path).canonical().strip_trailing_slash()
+ ):
+ c.append((self.url.join(path), resource_types, resource_name))
+
+ return c
+
+ async def _query_properties(
+ self, props: Optional[Sequence[BaseElement]] = None, depth: int = 0
+ ):
+ """Internal method for doing a propfind query."""
+ root = None
+ if props is not None and len(props) > 0:
+ prop = dav.Prop() + props
+ root = dav.Propfind() + prop
+
+ return await self._query(root, depth)
+
+ async def _query(
+ self,
+ root=None,
+ depth=0,
+ query_method="propfind",
+ url=None,
+ expected_return_value=None,
+ ):
+ """Internal method for doing a query."""
+ body = ""
+ if root:
+ if hasattr(root, "xmlelement"):
+ body = etree.tostring(
+ root.xmlelement(),
+ encoding="utf-8",
+ xml_declaration=True,
+ pretty_print=error.debug_dump_communication,
+ )
+ else:
+ body = root
+ if url is None:
+ url = self.url
+
+ ret = await getattr(self.client, query_method)(url, body, depth)
+
+ if ret.status == 404:
+ raise error.NotFoundError(errmsg(ret))
+ if (
+ expected_return_value is not None and ret.status != expected_return_value
+ ) or ret.status >= 400:
+ body = to_wire(body)
+ if (
+ ret.status == 500
+ and b"D:getetag" not in body
+ and b" Optional[str]:
+ """Get a single property."""
+ if use_cached:
+ if prop.tag in self.props:
+ return self.props[prop.tag]
+ foo = await self.get_properties([prop], **passthrough)
+ return foo.get(prop.tag, None)
+
+ async def get_properties(
+ self,
+ props: Optional[Sequence[BaseElement]] = None,
+ depth: int = 0,
+ parse_response_xml: bool = True,
+ parse_props: bool = True,
+ ):
+ """Get properties (PROPFIND) for this object."""
+ from caldav._async.collection import AsyncPrincipal
+
+ rc = None
+ response = await self._query_properties(props, depth)
+ if not parse_response_xml:
+ return response
+
+ if not parse_props:
+ properties = response.find_objects_and_props()
+ else:
+ properties = response.expand_simple_props(props)
+
+ error.assert_(properties)
+
+ if self.url is None:
+ raise ValueError("Unexpected value None for self.url")
+
+ path = unquote(self.url.path)
+ if path.endswith("/"):
+ exchange_path = path[:-1]
+ else:
+ exchange_path = path + "/"
+
+ if path in properties:
+ rc = properties[path]
+ elif exchange_path in properties:
+ if not isinstance(self, AsyncPrincipal):
+ log.warning(
+ "potential path handling problem with ending slashes. Path given: %s, path found: %s. %s"
+ % (path, exchange_path, error.ERR_FRAGMENT)
+ )
+ error.assert_(False)
+ rc = properties[exchange_path]
+ elif self.url in properties:
+ rc = properties[self.url]
+ elif "/principal/" in properties and path.endswith("/principal/"):
+ rc = properties["/principal/"]
+ elif "//" in path and path.replace("//", "/") in properties:
+ rc = properties[path.replace("//", "/")]
+ elif len(properties) == 1:
+ log.warning(
+ "Possibly the server has a path handling problem, possibly the URL configured is wrong.\n"
+ "Path expected: %s, path found: %s %s.\n"
+ "Continuing, probably everything will be fine"
+ % (path, str(list(properties)), error.ERR_FRAGMENT)
+ )
+ rc = list(properties.values())[0]
+ else:
+ log.warning(
+ "Possibly the server has a path handling problem. Path expected: %s, paths found: %s %s"
+ % (path, str(list(properties)), error.ERR_FRAGMENT)
+ )
+ error.assert_(False)
+
+ if parse_props:
+ if rc is None:
+ raise ValueError("Unexpected value None for rc")
+ self.props.update(rc)
+ return rc
+
+ async def set_properties(self, props: Optional[Any] = None) -> Self:
+ """Set properties (PROPPATCH) for this object."""
+ props = [] if props is None else props
+ prop = dav.Prop() + props
+ set = dav.Set() + prop
+ root = dav.PropertyUpdate() + set
+
+ r = await self._query(root, query_method="proppatch")
+
+ statuses = r.tree.findall(".//" + dav.Status.tag)
+ for s in statuses:
+ if " 200 " not in s.text:
+ raise error.PropsetError(s.text)
+
+ return self
+
+ async def save(self) -> Self:
+ """Save the object - abstract method."""
+ raise NotImplementedError()
+
+ async def delete(self) -> None:
+ """Delete the object."""
+ if self.url is not None:
+ if self.client is None:
+ raise ValueError("Unexpected value None for self.client")
+
+ r = await self.client.delete(str(self.url))
+
+ if r.status not in (200, 204, 404):
+ raise error.DeleteError(errmsg(r))
+
+ async def get_display_name(self):
+ """Get display name."""
+ return await self.get_property(dav.DisplayName(), use_cached=True)
+
+ def __str__(self) -> str:
+ return str(self.url)
+
+ def __repr__(self) -> str:
+ return "%s(%s)" % (self.__class__.__name__, self.url)
diff --git a/caldav/_sync/__init__.py b/caldav/_sync/__init__.py
new file mode 100644
index 00000000..83c9d857
--- /dev/null
+++ b/caldav/_sync/__init__.py
@@ -0,0 +1,9 @@
+"""
+Sync CalDAV client implementation - thin wrappers around async implementation.
+
+This module provides synchronous API by wrapping the async implementation
+using anyio.from_thread.run().
+"""
+from .davclient import DAVClient
+
+__all__ = ["DAVClient"]
diff --git a/caldav/_sync/calendarobjectresource.py b/caldav/_sync/calendarobjectresource.py
new file mode 100644
index 00000000..5150ac42
--- /dev/null
+++ b/caldav/_sync/calendarobjectresource.py
@@ -0,0 +1,329 @@
+#!/usr/bin/env python
+"""
+Sync Calendar Object Resources - thin wrappers around async implementations.
+
+This provides backward-compatible synchronous API.
+"""
+import sys
+from datetime import datetime
+from typing import Any
+from typing import Optional
+from typing import Union
+from urllib.parse import ParseResult
+from urllib.parse import SplitResult
+
+import anyio
+
+from caldav._async.calendarobjectresource import AsyncCalendarObjectResource
+from caldav._async.calendarobjectresource import AsyncEvent
+from caldav._async.calendarobjectresource import AsyncFreeBusy
+from caldav._async.calendarobjectresource import AsyncJournal
+from caldav._async.calendarobjectresource import AsyncTodo
+from caldav.lib.url import URL
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Self
+else:
+ from typing import Self
+
+
+def _run_sync(async_fn, *args, **kwargs):
+ """Execute an async function synchronously."""
+
+ async def _wrapper():
+ return await async_fn(*args, **kwargs)
+
+ return anyio.run(_wrapper)
+
+
+class CalendarObjectResource:
+ """Sync CalendarObjectResource - thin wrapper around AsyncCalendarObjectResource."""
+
+ def __init__(
+ self,
+ client=None,
+ url: Union[str, ParseResult, SplitResult, URL, None] = None,
+ data: Optional[Any] = None,
+ parent=None,
+ id: Optional[Any] = None,
+ props: Optional[Any] = None,
+ ):
+ async_client = client._async if client and hasattr(client, "_async") else client
+ async_parent = parent._async if parent and hasattr(parent, "_async") else parent
+ self._async = AsyncCalendarObjectResource(
+ client=async_client,
+ url=url,
+ data=data,
+ parent=async_parent,
+ id=id,
+ props=props,
+ )
+ self._sync_client = client
+ self._sync_parent = parent
+
+ @classmethod
+ def _from_async(cls, async_obj, sync_client, sync_parent=None):
+ """Create a sync object from an async one."""
+ sync_obj = cls.__new__(cls)
+ sync_obj._async = async_obj
+ sync_obj._sync_client = sync_client
+ sync_obj._sync_parent = sync_parent
+ return sync_obj
+
+ @property
+ def client(self):
+ return self._sync_client
+
+ @property
+ def parent(self):
+ return self._sync_parent
+
+ @property
+ def url(self):
+ return self._async.url
+
+ @url.setter
+ def url(self, value):
+ self._async.url = value
+
+ @property
+ def id(self):
+ return self._async.id
+
+ @id.setter
+ def id(self, value):
+ self._async.id = value
+
+ @property
+ def props(self):
+ return self._async.props
+
+ @property
+ def data(self):
+ return self._async.data
+
+ @data.setter
+ def data(self, value):
+ self._async.data = value
+
+ @property
+ def wire_data(self):
+ return self._async.wire_data
+
+ @property
+ def icalendar_instance(self):
+ return self._async.icalendar_instance
+
+ @icalendar_instance.setter
+ def icalendar_instance(self, value):
+ self._async.icalendar_instance = value
+
+ @property
+ def icalendar_component(self):
+ return self._async.icalendar_component
+
+ @icalendar_component.setter
+ def icalendar_component(self, value):
+ self._async.icalendar_component = value
+
+ @property
+ def component(self):
+ return self._async.component
+
+ @property
+ def vobject_instance(self):
+ return self._async.vobject_instance
+
+ @vobject_instance.setter
+ def vobject_instance(self, value):
+ self._async.vobject_instance = value
+
+ def load(self, only_if_unloaded: bool = False) -> Self:
+ _run_sync(self._async.load, only_if_unloaded)
+ return self
+
+ def save(
+ self,
+ no_overwrite: bool = False,
+ no_create: bool = False,
+ obj_type: Optional[str] = None,
+ increase_seqno: bool = True,
+ if_schedule_tag_match: bool = False,
+ only_this_recurrence: bool = True,
+ all_recurrences: bool = False,
+ ) -> Self:
+ _run_sync(
+ self._async.save,
+ no_overwrite,
+ no_create,
+ obj_type,
+ increase_seqno,
+ if_schedule_tag_match,
+ only_this_recurrence,
+ all_recurrences,
+ )
+ return self
+
+ def delete(self):
+ _run_sync(self._async.delete)
+
+ def is_loaded(self):
+ return self._async.is_loaded()
+
+ def copy(self, keep_uid: bool = False, new_parent=None) -> Self:
+ async_parent = (
+ new_parent._async
+ if new_parent and hasattr(new_parent, "_async")
+ else new_parent
+ )
+ async_copy = self._async.copy(keep_uid, async_parent)
+ return self._from_async(
+ async_copy, self._sync_client, new_parent or self._sync_parent
+ )
+
+ def add_organizer(self):
+ _run_sync(self._async.add_organizer)
+
+ def add_attendee(self, attendee, no_default_parameters: bool = False, **parameters):
+ self._async.add_attendee(attendee, no_default_parameters, **parameters)
+
+ def get_duration(self):
+ return self._async.get_duration()
+
+ def get_property(self, prop, use_cached=False, **passthrough):
+ return _run_sync(self._async.get_property, prop, use_cached, **passthrough)
+
+ def get_properties(
+ self, props=None, depth=0, parse_response_xml=True, parse_props=True
+ ):
+ return _run_sync(
+ self._async.get_properties, props, depth, parse_response_xml, parse_props
+ )
+
+ def set_properties(self, props=None):
+ _run_sync(self._async.set_properties, props)
+ return self
+
+ def get_display_name(self):
+ return _run_sync(self._async.get_display_name)
+
+ def __str__(self):
+ return str(self._async)
+
+
+class Event(CalendarObjectResource):
+ """Sync Event - thin wrapper around AsyncEvent."""
+
+ def __init__(
+ self,
+ client=None,
+ url: Union[str, ParseResult, SplitResult, URL, None] = None,
+ data: Optional[Any] = None,
+ parent=None,
+ id: Optional[Any] = None,
+ props: Optional[Any] = None,
+ ):
+ async_client = client._async if client and hasattr(client, "_async") else client
+ async_parent = parent._async if parent and hasattr(parent, "_async") else parent
+ self._async = AsyncEvent(
+ client=async_client,
+ url=url,
+ data=data,
+ parent=async_parent,
+ id=id,
+ props=props,
+ )
+ self._sync_client = client
+ self._sync_parent = parent
+
+
+class Journal(CalendarObjectResource):
+ """Sync Journal - thin wrapper around AsyncJournal."""
+
+ def __init__(
+ self,
+ client=None,
+ url: Union[str, ParseResult, SplitResult, URL, None] = None,
+ data: Optional[Any] = None,
+ parent=None,
+ id: Optional[Any] = None,
+ props: Optional[Any] = None,
+ ):
+ async_client = client._async if client and hasattr(client, "_async") else client
+ async_parent = parent._async if parent and hasattr(parent, "_async") else parent
+ self._async = AsyncJournal(
+ client=async_client,
+ url=url,
+ data=data,
+ parent=async_parent,
+ id=id,
+ props=props,
+ )
+ self._sync_client = client
+ self._sync_parent = parent
+
+
+class FreeBusy(CalendarObjectResource):
+ """Sync FreeBusy - thin wrapper around AsyncFreeBusy."""
+
+ def __init__(
+ self,
+ parent,
+ data,
+ url: Union[str, ParseResult, SplitResult, URL, None] = None,
+ id: Optional[Any] = None,
+ ):
+ async_parent = parent._async if parent and hasattr(parent, "_async") else parent
+ self._async = AsyncFreeBusy(
+ parent=async_parent,
+ data=data,
+ url=url,
+ id=id,
+ )
+ self._sync_client = parent.client if hasattr(parent, "client") else None
+ self._sync_parent = parent
+
+
+class Todo(CalendarObjectResource):
+ """Sync Todo - thin wrapper around AsyncTodo."""
+
+ def __init__(
+ self,
+ client=None,
+ url: Union[str, ParseResult, SplitResult, URL, None] = None,
+ data: Optional[Any] = None,
+ parent=None,
+ id: Optional[Any] = None,
+ props: Optional[Any] = None,
+ ):
+ async_client = client._async if client and hasattr(client, "_async") else client
+ async_parent = parent._async if parent and hasattr(parent, "_async") else parent
+ self._async = AsyncTodo(
+ client=async_client,
+ url=url,
+ data=data,
+ parent=async_parent,
+ id=id,
+ props=props,
+ )
+ self._sync_client = client
+ self._sync_parent = parent
+
+ def complete(
+ self,
+ completion_timestamp: Optional[datetime] = None,
+ handle_rrule: bool = False,
+ rrule_mode: str = "safe",
+ ):
+ _run_sync(self._async.complete, completion_timestamp, handle_rrule, rrule_mode)
+
+ def uncomplete(self):
+ _run_sync(self._async.uncomplete)
+
+ def is_pending(self, i=None):
+ return self._async.is_pending(i)
+
+ def get_due(self):
+ return self._async.get_due()
+
+ get_dtend = get_due
diff --git a/caldav/_sync/collection.py b/caldav/_sync/collection.py
new file mode 100644
index 00000000..9841aad6
--- /dev/null
+++ b/caldav/_sync/collection.py
@@ -0,0 +1,416 @@
+#!/usr/bin/env python
+"""
+Sync collection classes - thin wrappers around async implementations.
+
+This provides backward-compatible synchronous API.
+"""
+import sys
+from typing import Any
+from typing import List
+from typing import Optional
+from typing import Union
+from urllib.parse import ParseResult
+from urllib.parse import SplitResult
+
+import anyio
+
+from caldav._async.collection import AsyncCalendar
+from caldav._async.collection import AsyncCalendarSet
+from caldav._async.collection import AsyncPrincipal
+from caldav._async.collection import AsyncScheduleInbox
+from caldav._async.collection import AsyncScheduleMailbox
+from caldav._async.collection import AsyncScheduleOutbox
+from caldav._async.davobject import AsyncDAVObject
+from caldav.lib.url import URL
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Self
+else:
+ from typing import Self
+
+
+def _run_sync(async_fn, *args, **kwargs):
+ """Execute an async function synchronously."""
+
+ async def _wrapper():
+ return await async_fn(*args, **kwargs)
+
+ return anyio.run(_wrapper)
+
+
+class DAVObject:
+ """Sync DAVObject - thin wrapper around AsyncDAVObject."""
+
+ def __init__(self, client=None, url=None, parent=None, **kwargs):
+ # Get the async client if we have a sync client
+ async_client = client._async if hasattr(client, "_async") else client
+ async_parent = parent._async if hasattr(parent, "_async") else parent
+ self._async = AsyncDAVObject(
+ client=async_client, url=url, parent=async_parent, **kwargs
+ )
+ self._sync_client = client
+ self._sync_parent = parent
+
+ @property
+ def client(self):
+ return self._sync_client
+
+ @property
+ def parent(self):
+ return self._sync_parent
+
+ @property
+ def url(self):
+ return self._async.url
+
+ @url.setter
+ def url(self, value):
+ self._async.url = value
+
+ @property
+ def id(self):
+ return self._async.id
+
+ @id.setter
+ def id(self, value):
+ self._async.id = value
+
+ @property
+ def name(self):
+ return self._async.name
+
+ @property
+ def props(self):
+ return self._async.props
+
+ def children(self, type=None):
+ return _run_sync(self._async.children, type)
+
+ def get_property(self, prop, use_cached=False, **passthrough):
+ return _run_sync(self._async.get_property, prop, use_cached, **passthrough)
+
+ def get_properties(
+ self, props=None, depth=0, parse_response_xml=True, parse_props=True
+ ):
+ return _run_sync(
+ self._async.get_properties, props, depth, parse_response_xml, parse_props
+ )
+
+ def set_properties(self, props=None):
+ _run_sync(self._async.set_properties, props)
+ return self
+
+ def save(self):
+ _run_sync(self._async.save)
+ return self
+
+ def delete(self):
+ _run_sync(self._async.delete)
+
+ def get_display_name(self):
+ return _run_sync(self._async.get_display_name)
+
+
+class CalendarSet(DAVObject):
+ """Sync CalendarSet - thin wrapper around AsyncCalendarSet."""
+
+ def __init__(self, client=None, url=None, parent=None, **kwargs):
+ async_client = client._async if hasattr(client, "_async") else client
+ async_parent = parent._async if hasattr(parent, "_async") else parent
+ self._async = AsyncCalendarSet(
+ client=async_client, url=url, parent=async_parent, **kwargs
+ )
+ self._sync_client = client
+ self._sync_parent = parent
+
+ def calendars(self) -> List["Calendar"]:
+ async_cals = _run_sync(self._async.calendars)
+ return [Calendar._from_async(cal, self._sync_client) for cal in async_cals]
+
+ def make_calendar(
+ self,
+ name: Optional[str] = None,
+ cal_id: Optional[str] = None,
+ supported_calendar_component_set: Optional[Any] = None,
+ method=None,
+ ) -> "Calendar":
+ async_cal = _run_sync(
+ self._async.make_calendar,
+ name,
+ cal_id,
+ supported_calendar_component_set,
+ method,
+ )
+ return Calendar._from_async(async_cal, self._sync_client)
+
+ def calendar(
+ self, name: Optional[str] = None, cal_id: Optional[str] = None
+ ) -> "Calendar":
+ async_cal = _run_sync(self._async.calendar, name, cal_id)
+ return Calendar._from_async(async_cal, self._sync_client)
+
+
+class Principal(DAVObject):
+ """Sync Principal - thin wrapper around AsyncPrincipal."""
+
+ def __init__(
+ self,
+ client=None,
+ url: Union[str, ParseResult, SplitResult, URL, None] = None,
+ calendar_home_set: URL = None,
+ **kwargs,
+ ):
+ async_client = client._async if hasattr(client, "_async") else client
+ self._async = AsyncPrincipal(
+ client=async_client, url=url, calendar_home_set=calendar_home_set, **kwargs
+ )
+ self._sync_client = client
+ self._sync_parent = None
+
+ def make_calendar(
+ self,
+ name: Optional[str] = None,
+ cal_id: Optional[str] = None,
+ supported_calendar_component_set: Optional[Any] = None,
+ method=None,
+ ) -> "Calendar":
+ async_cal = _run_sync(
+ self._async.make_calendar,
+ name,
+ cal_id,
+ supported_calendar_component_set,
+ method,
+ )
+ return Calendar._from_async(async_cal, self._sync_client)
+
+ def calendar(
+ self,
+ name: Optional[str] = None,
+ cal_id: Optional[str] = None,
+ cal_url: Optional[str] = None,
+ ) -> "Calendar":
+ async_cal = _run_sync(self._async.calendar, name, cal_id, cal_url)
+ return Calendar._from_async(async_cal, self._sync_client)
+
+ def calendars(self) -> List["Calendar"]:
+ async_cals = _run_sync(self._async.calendars)
+ return [Calendar._from_async(cal, self._sync_client) for cal in async_cals]
+
+ def get_vcal_address(self):
+ return _run_sync(self._async.get_vcal_address)
+
+ @property
+ def calendar_home_set(self):
+ async_home = _run_sync(self._async.get_calendar_home_set)
+ sync_home = CalendarSet.__new__(CalendarSet)
+ sync_home._async = async_home
+ sync_home._sync_client = self._sync_client
+ sync_home._sync_parent = self
+ return sync_home
+
+ def freebusy_request(self, dtstart, dtend, attendees):
+ return _run_sync(self._async.freebusy_request, dtstart, dtend, attendees)
+
+ def calendar_user_address_set(self):
+ return _run_sync(self._async.calendar_user_address_set)
+
+ def schedule_inbox(self):
+ async_inbox = _run_sync(self._async.schedule_inbox)
+ sync_inbox = ScheduleInbox.__new__(ScheduleInbox)
+ sync_inbox._async = async_inbox
+ sync_inbox._sync_client = self._sync_client
+ sync_inbox._sync_parent = self
+ return sync_inbox
+
+ def schedule_outbox(self):
+ async_outbox = _run_sync(self._async.schedule_outbox)
+ sync_outbox = ScheduleOutbox.__new__(ScheduleOutbox)
+ sync_outbox._async = async_outbox
+ sync_outbox._sync_client = self._sync_client
+ sync_outbox._sync_parent = self
+ return sync_outbox
+
+
+class Calendar(DAVObject):
+ """Sync Calendar - thin wrapper around AsyncCalendar."""
+
+ def __init__(self, client=None, url=None, parent=None, **kwargs):
+ async_client = client._async if hasattr(client, "_async") else client
+ async_parent = parent._async if hasattr(parent, "_async") else parent
+ self._async = AsyncCalendar(
+ client=async_client, url=url, parent=async_parent, **kwargs
+ )
+ self._sync_client = client
+ self._sync_parent = parent
+
+ @classmethod
+ def _from_async(cls, async_cal, sync_client):
+ """Create a sync Calendar from an async one."""
+ sync_cal = cls.__new__(cls)
+ sync_cal._async = async_cal
+ sync_cal._sync_client = sync_client
+ sync_cal._sync_parent = None
+ return sync_cal
+
+ def save(self, method=None) -> Self:
+ _run_sync(self._async.save, method)
+ return self
+
+ def get_supported_components(self):
+ return _run_sync(self._async.get_supported_components)
+
+ def search(self, **kwargs):
+ from caldav._sync.calendarobjectresource import (
+ CalendarObjectResource,
+ Event,
+ Todo,
+ Journal,
+ FreeBusy,
+ )
+
+ async_results = _run_sync(self._async.search, **kwargs)
+ # Wrap results in sync classes
+ results = []
+ for obj in async_results:
+ cls_name = obj.__class__.__name__.replace("Async", "")
+ if cls_name == "Event":
+ sync_obj = Event._from_async(obj, self._sync_client, self)
+ elif cls_name == "Todo":
+ sync_obj = Todo._from_async(obj, self._sync_client, self)
+ elif cls_name == "Journal":
+ sync_obj = Journal._from_async(obj, self._sync_client, self)
+ elif cls_name == "FreeBusy":
+ sync_obj = FreeBusy._from_async(obj, self._sync_client, self)
+ else:
+ sync_obj = CalendarObjectResource._from_async(
+ obj, self._sync_client, self
+ )
+ results.append(sync_obj)
+ return results
+
+ def events(self):
+ from caldav._sync.calendarobjectresource import Event
+
+ async_events = _run_sync(self._async.events)
+ return [Event._from_async(e, self._sync_client, self) for e in async_events]
+
+ def todos(
+ self, sort_keys=("due", "priority"), include_completed=False, sort_key=None
+ ):
+ from caldav._sync.calendarobjectresource import Todo
+
+ async_todos = _run_sync(
+ self._async.todos, sort_keys, include_completed, sort_key
+ )
+ return [Todo._from_async(t, self._sync_client, self) for t in async_todos]
+
+ def journals(self):
+ from caldav._sync.calendarobjectresource import Journal
+
+ async_journals = _run_sync(self._async.journals)
+ return [Journal._from_async(j, self._sync_client, self) for j in async_journals]
+
+ def freebusy_request(self, start, end):
+ from caldav._sync.calendarobjectresource import FreeBusy
+
+ async_fb = _run_sync(self._async.freebusy_request, start, end)
+ return FreeBusy._from_async(async_fb, self._sync_client, self)
+
+ def object_by_uid(self, uid, comp_filter=None, comp_class=None):
+ from caldav._sync.calendarobjectresource import CalendarObjectResource
+
+ async_obj = _run_sync(self._async.object_by_uid, uid, comp_filter, comp_class)
+ return CalendarObjectResource._from_async(async_obj, self._sync_client, self)
+
+ def event_by_uid(self, uid):
+ from caldav._sync.calendarobjectresource import Event
+
+ async_event = _run_sync(self._async.event_by_uid, uid)
+ return Event._from_async(async_event, self._sync_client, self)
+
+ def todo_by_uid(self, uid):
+ from caldav._sync.calendarobjectresource import Todo
+
+ async_todo = _run_sync(self._async.todo_by_uid, uid)
+ return Todo._from_async(async_todo, self._sync_client, self)
+
+ def journal_by_uid(self, uid):
+ from caldav._sync.calendarobjectresource import Journal
+
+ async_journal = _run_sync(self._async.journal_by_uid, uid)
+ return Journal._from_async(async_journal, self._sync_client, self)
+
+ def save_event(self, ical=None, no_overwrite=False, no_create=False, **ical_data):
+ from caldav._sync.calendarobjectresource import Event
+
+ async_event = _run_sync(
+ self._async.save_event, ical, no_overwrite, no_create, **ical_data
+ )
+ return Event._from_async(async_event, self._sync_client, self)
+
+ def save_todo(self, ical=None, no_overwrite=False, no_create=False, **ical_data):
+ from caldav._sync.calendarobjectresource import Todo
+
+ async_todo = _run_sync(
+ self._async.save_todo, ical, no_overwrite, no_create, **ical_data
+ )
+ return Todo._from_async(async_todo, self._sync_client, self)
+
+ def save_journal(self, ical=None, no_overwrite=False, no_create=False, **ical_data):
+ from caldav._sync.calendarobjectresource import Journal
+
+ async_journal = _run_sync(
+ self._async.save_journal, ical, no_overwrite, no_create, **ical_data
+ )
+ return Journal._from_async(async_journal, self._sync_client, self)
+
+
+class ScheduleMailbox(Calendar):
+ """Sync ScheduleMailbox - thin wrapper around AsyncScheduleMailbox."""
+
+ def __init__(self, client=None, principal=None, url=None):
+ async_client = client._async if client and hasattr(client, "_async") else client
+ async_principal = (
+ principal._async
+ if principal and hasattr(principal, "_async")
+ else principal
+ )
+ self._async = AsyncScheduleMailbox(
+ client=async_client, principal=async_principal, url=url
+ )
+ self._sync_client = client
+ self._sync_parent = principal
+
+
+class ScheduleInbox(ScheduleMailbox):
+ """Sync ScheduleInbox."""
+
+ def __init__(self, client=None, principal=None, url=None):
+ async_client = client._async if client and hasattr(client, "_async") else client
+ async_principal = (
+ principal._async
+ if principal and hasattr(principal, "_async")
+ else principal
+ )
+ self._async = AsyncScheduleInbox(
+ client=async_client, principal=async_principal, url=url
+ )
+ self._sync_client = client
+ self._sync_parent = principal
+
+
+class ScheduleOutbox(ScheduleMailbox):
+ """Sync ScheduleOutbox."""
+
+ def __init__(self, client=None, principal=None, url=None):
+ async_client = client._async if client and hasattr(client, "_async") else client
+ async_principal = (
+ principal._async
+ if principal and hasattr(principal, "_async")
+ else principal
+ )
+ self._async = AsyncScheduleOutbox(
+ client=async_client, principal=async_principal, url=url
+ )
+ self._sync_client = client
+ self._sync_parent = principal
diff --git a/caldav/_sync/davclient.py b/caldav/_sync/davclient.py
new file mode 100644
index 00000000..3bafc6a0
--- /dev/null
+++ b/caldav/_sync/davclient.py
@@ -0,0 +1,236 @@
+#!/usr/bin/env python
+"""
+Sync DAVClient - thin wrapper around AsyncDAVClient using anyio.
+
+This provides backward-compatible synchronous API by wrapping the
+async implementation.
+"""
+import sys
+from functools import wraps
+from typing import Mapping
+from typing import Optional
+from typing import Tuple
+from typing import Union
+
+import anyio
+from anyio.from_thread import BlockingPortal
+from anyio.from_thread import start_blocking_portal
+
+from caldav._async.davclient import AsyncDAVClient
+from caldav._async.davclient import DAVResponse
+from caldav._async.davclient import HTTPBearerAuth
+from caldav.lib.url import URL
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Self
+else:
+ from typing import Self
+
+# Re-export for backward compatibility
+__all__ = ["DAVClient", "DAVResponse", "HTTPBearerAuth"]
+
+
+def _run_sync(async_fn, *args, **kwargs):
+ """
+ Execute an async function synchronously.
+
+ Uses anyio.run() to execute the coroutine in a new event loop.
+ This is the simplest approach for running async code from sync.
+ """
+
+ async def _wrapper():
+ return await async_fn(*args, **kwargs)
+
+ return anyio.from_thread.run_sync(_wrapper) if False else anyio.run(_wrapper)
+
+
+class DAVClient:
+ """
+ Synchronous CalDAV client - thin wrapper around AsyncDAVClient.
+
+ This class provides the same interface as the original DAVClient
+ but delegates all operations to the async implementation.
+ """
+
+ def __init__(
+ self,
+ url: Optional[str] = "",
+ proxy: Optional[str] = None,
+ username: Optional[str] = None,
+ password: Optional[str] = None,
+ auth=None,
+ auth_type: Optional[str] = None,
+ timeout: Optional[float] = None,
+ ssl_verify_cert: Union[bool, str] = True,
+ ssl_cert: Union[str, Tuple[str, str], None] = None,
+ headers: Mapping[str, str] = None,
+ huge_tree: bool = False,
+ features=None,
+ ) -> None:
+ """
+ Initialize sync DAV client.
+
+ All parameters are passed to AsyncDAVClient.
+ """
+ self._async = AsyncDAVClient(
+ url=url,
+ proxy=proxy,
+ username=username,
+ password=password,
+ auth=auth,
+ auth_type=auth_type,
+ timeout=timeout,
+ ssl_verify_cert=ssl_verify_cert,
+ ssl_cert=ssl_cert,
+ headers=headers,
+ huge_tree=huge_tree,
+ features=features,
+ )
+
+ # Expose commonly accessed attributes
+ @property
+ def url(self) -> URL:
+ return self._async.url
+
+ @url.setter
+ def url(self, value):
+ self._async.url = value
+
+ @property
+ def headers(self):
+ return self._async.headers
+
+ @property
+ def huge_tree(self) -> bool:
+ return self._async.huge_tree
+
+ @property
+ def features(self):
+ return self._async.features
+
+ @property
+ def username(self):
+ return self._async.username
+
+ @property
+ def password(self):
+ return self._async.password
+
+ @property
+ def auth(self):
+ return self._async.auth
+
+ @property
+ def timeout(self):
+ return self._async.timeout
+
+ @property
+ def ssl_verify_cert(self):
+ return self._async.ssl_verify_cert
+
+ @property
+ def ssl_cert(self):
+ return self._async.ssl_cert
+
+ @property
+ def proxy(self):
+ return self._async._proxy
+
+ def __enter__(self) -> Self:
+ """Context manager entry."""
+ _run_sync(self._async.__aenter__)
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
+ """Context manager exit."""
+ _run_sync(self._async.__aexit__, exc_type, exc_value, traceback)
+
+ def close(self) -> None:
+ """Close the client connection."""
+ _run_sync(self._async.close)
+
+ def request(
+ self,
+ url: str,
+ method: str = "GET",
+ body: str = "",
+ headers: Mapping[str, str] = None,
+ ) -> DAVResponse:
+ """Send an HTTP request."""
+ return _run_sync(self._async.request, url, method, body, headers)
+
+ def propfind(
+ self, url: Optional[str] = None, props: str = "", depth: int = 0
+ ) -> DAVResponse:
+ """Send a PROPFIND request."""
+ return _run_sync(self._async.propfind, url, props, depth)
+
+ def proppatch(self, url: str, body: str, dummy: None = None) -> DAVResponse:
+ """Send a PROPPATCH request."""
+ return _run_sync(self._async.proppatch, url, body, dummy)
+
+ def report(self, url: str, query: str = "", depth: int = 0) -> DAVResponse:
+ """Send a REPORT request."""
+ return _run_sync(self._async.report, url, query, depth)
+
+ def mkcol(self, url: str, body: str, dummy: None = None) -> DAVResponse:
+ """Send a MKCOL request."""
+ return _run_sync(self._async.mkcol, url, body, dummy)
+
+ def mkcalendar(self, url: str, body: str = "", dummy: None = None) -> DAVResponse:
+ """Send a MKCALENDAR request."""
+ return _run_sync(self._async.mkcalendar, url, body, dummy)
+
+ def put(
+ self, url: str, body: str, headers: Mapping[str, str] = None
+ ) -> DAVResponse:
+ """Send a PUT request."""
+ return _run_sync(self._async.put, url, body, headers)
+
+ def post(
+ self, url: str, body: str, headers: Mapping[str, str] = None
+ ) -> DAVResponse:
+ """Send a POST request."""
+ return _run_sync(self._async.post, url, body, headers)
+
+ def delete(self, url: str) -> DAVResponse:
+ """Send a DELETE request."""
+ return _run_sync(self._async.delete, url)
+
+ def options(self, url: str) -> DAVResponse:
+ """Send an OPTIONS request."""
+ return _run_sync(self._async.options, url)
+
+ def check_dav_support(self) -> Optional[str]:
+ """Check if server supports DAV."""
+ return _run_sync(self._async.check_dav_support)
+
+ def check_cdav_support(self) -> bool:
+ """Check if server supports CalDAV."""
+ return _run_sync(self._async.check_cdav_support)
+
+ def check_scheduling_support(self) -> bool:
+ """Check if server supports CalDAV scheduling."""
+ return _run_sync(self._async.check_scheduling_support)
+
+ def principal(self, *args, **kwargs):
+ """Get the principal for this client."""
+ from caldav._sync.collection import Principal
+
+ # Note: principal() in async returns an async principal
+ # We need to wrap it in a sync principal
+ if not hasattr(self, "_principal") or self._principal is None:
+ self._principal = Principal(client=self, *args, **kwargs)
+ return self._principal
+
+ def calendar(self, **kwargs):
+ """Get a calendar object by URL."""
+ from caldav._sync.collection import Calendar
+
+ return Calendar(client=self, **kwargs)
+
+ # For backward compatibility with tests that check for session
+ @property
+ def session(self):
+ """Backward compatibility - return the httpx client."""
+ return self._async._client
diff --git a/caldav/aio.py b/caldav/aio.py
new file mode 100644
index 00000000..f905ff97
--- /dev/null
+++ b/caldav/aio.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+"""
+Async CalDAV client API.
+
+This module provides the async-first implementation of the CalDAV library.
+Users who want to use async/await can import from here:
+
+ from caldav.aio import AsyncDAVClient, AsyncCalendar, AsyncEvent
+
+For synchronous usage, continue to use the main caldav module:
+
+ from caldav import DAVClient
+"""
+from caldav._async.calendarobjectresource import AsyncCalendarObjectResource
+from caldav._async.calendarobjectresource import AsyncEvent
+from caldav._async.calendarobjectresource import AsyncFreeBusy
+from caldav._async.calendarobjectresource import AsyncJournal
+from caldav._async.calendarobjectresource import AsyncTodo
+from caldav._async.collection import AsyncCalendar
+from caldav._async.collection import AsyncCalendarSet
+from caldav._async.collection import AsyncPrincipal
+from caldav._async.collection import AsyncScheduleInbox
+from caldav._async.collection import AsyncScheduleMailbox
+from caldav._async.collection import AsyncScheduleOutbox
+from caldav._async.davclient import AsyncDAVClient
+from caldav._async.davclient import DAVResponse
+from caldav._async.davclient import HTTPBearerAuth
+from caldav._async.davobject import AsyncDAVObject
+
+__all__ = [
+ # Client
+ "AsyncDAVClient",
+ "DAVResponse",
+ "HTTPBearerAuth",
+ # Base
+ "AsyncDAVObject",
+ # Collections
+ "AsyncCalendar",
+ "AsyncCalendarSet",
+ "AsyncPrincipal",
+ "AsyncScheduleInbox",
+ "AsyncScheduleMailbox",
+ "AsyncScheduleOutbox",
+ # Calendar objects
+ "AsyncCalendarObjectResource",
+ "AsyncEvent",
+ "AsyncFreeBusy",
+ "AsyncJournal",
+ "AsyncTodo",
+]
diff --git a/caldav/objects.py b/caldav/objects.py
index aeecab2e..79af5961 100755
--- a/caldav/objects.py
+++ b/caldav/objects.py
@@ -10,8 +10,12 @@
* CalendarObjectResource base class -> calendarobjectresource.py
* Event/Todo/Journal/FreeBusy -> calendarobjectresource.py
* Everything else (mostly collection objects) -> collection.py
+
+The async-first implementation lives in _async/ and _sync/ subdirectories.
+This module exports the sync (backward-compatible) versions.
"""
-## For backward compatibility
+## For backward compatibility - import from original modules
+## These will be gradually migrated to use the _sync wrappers
from .calendarobjectresource import *
from .collection import *
from .davobject import *
diff --git a/caldav/search.py b/caldav/search.py
index ea82474a..01907e00 100644
--- a/caldav/search.py
+++ b/caldav/search.py
@@ -1,3 +1,4 @@
+import logging
from copy import deepcopy
from dataclasses import dataclass
from dataclasses import field
@@ -648,27 +649,36 @@ def build_search_xml_query(
## The only thing I don't support is the component name ('VEVENT').
## Anyway, this code section ensures both comp_filter and comp_class
## is given. Or at least, it tries to ensure it.
- for flag, comp_name, comp_class_ in (
- ("event", "VEVENT", Event),
- ("todo", "VTODO", Todo),
- ("journal", "VJOURNAL", Journal),
+ ## Import async classes for comparison
+ from caldav._async.calendarobjectresource import (
+ AsyncEvent,
+ AsyncJournal,
+ AsyncTodo,
+ )
+
+ for flag, comp_name, comp_classes in (
+ ("event", "VEVENT", (Event, AsyncEvent)),
+ ("todo", "VTODO", (Todo, AsyncTodo)),
+ ("journal", "VJOURNAL", (Journal, AsyncJournal)),
):
flagged = getattr(self, flag)
if flagged:
## event/journal/todo is set, we adjust comp_class accordingly
- if self.comp_class is not None and self.comp_class is not comp_class_:
+ if self.comp_class is not None and self.comp_class not in comp_classes:
raise error.ConsistencyError(
- f"inconsistent search parameters - comp_class = {self.comp_class}, want {comp_class_}"
+ f"inconsistent search parameters - comp_class = {self.comp_class}, want one of {comp_classes}"
)
- self.comp_class = comp_class_
+ if self.comp_class is None:
+ self.comp_class = comp_classes[0]
if comp_filter and comp_filter.attributes["name"] == comp_name:
- self.comp_class = comp_class_
+ if self.comp_class is None:
+ self.comp_class = comp_classes[0]
if flag == "todo" and not self.todo and self.include_completed is None:
self.include_completed = True
setattr(self, flag, True)
- if self.comp_class == comp_class_:
+ if self.comp_class in comp_classes:
if comp_filter:
assert comp_filter.attributes["name"] == comp_name
else:
@@ -730,3 +740,174 @@ def build_search_xml_query(
root = cdav.CalendarQuery() + [prop, filter]
return (root, self.comp_class)
+
+ async def async_search(
+ self,
+ calendar,
+ server_expand: bool = False,
+ split_expanded: bool = True,
+ props=None,
+ xml: str = None,
+ post_filter=None,
+ _hacks: str = None,
+ ):
+ """Async version of search for use with AsyncCalendar.
+
+ This is a simplified async search that handles the basic flow.
+ For complex searches with compatibility hacks, the sync version
+ may still be needed via anyio.run().
+ """
+ from icalendar import Timezone
+
+ from caldav._async.calendarobjectresource import (
+ AsyncCalendarObjectResource,
+ AsyncEvent,
+ AsyncJournal,
+ AsyncTodo,
+ )
+
+ if post_filter is None and (
+ (self.todo and not self.include_completed or self.expand)
+ ):
+ post_filter = True
+
+ if not self.expand and not server_expand:
+ split_expanded = False
+
+ if self.expand or server_expand:
+ if not self.start or not self.end:
+ raise error.ReportError("can't expand without a date range")
+
+ orig_xml = xml
+
+ if not xml or (
+ not isinstance(xml, str) and not xml.tag.endswith("calendar-query")
+ ):
+ (xml, self.comp_class) = self.build_search_xml_query(
+ server_expand, props=props, filters=xml, _hacks=_hacks
+ )
+
+ # Convert sync comp_class to async comp_class for async search
+ sync_to_async_class = {
+ Event: AsyncEvent,
+ Todo: AsyncTodo,
+ Journal: AsyncJournal,
+ }
+ if self.comp_class in sync_to_async_class:
+ self.comp_class = sync_to_async_class[self.comp_class]
+
+ if not self.comp_class and not calendar.client.features.is_supported(
+ "search.comp-type-optional"
+ ):
+ if self.include_completed is None:
+ self.include_completed = True
+ return await self._async_search_with_comptypes(
+ calendar, server_expand, split_expanded, props, orig_xml, _hacks
+ )
+
+ try:
+ (response, objects) = await calendar._request_report_build_resultlist(
+ xml, self.comp_class, props=props
+ )
+ except error.ReportError as err:
+ if (
+ calendar.client.features.backward_compatibility_mode
+ and not self.comp_class
+ and "400" not in err.reason
+ ):
+ return await self._async_search_with_comptypes(
+ calendar, server_expand, split_expanded, props, orig_xml, _hacks
+ )
+ raise
+
+ if not objects and not self.comp_class and _hacks == "insist":
+ return await self._async_search_with_comptypes(
+ calendar, server_expand, split_expanded, props, orig_xml, _hacks
+ )
+
+ obj2 = []
+ for o in objects:
+ try:
+ await o.load(only_if_unloaded=True)
+ obj2.append(o)
+ except:
+ logging.error(
+ "Server does not want to reveal details about the calendar object",
+ exc_info=True,
+ )
+ pass
+ objects = obj2
+
+ objects = [o for o in objects if o.has_component()]
+
+ if post_filter or self.expand or (split_expanded and server_expand):
+ objects_ = objects
+ objects = []
+ for o in objects_:
+ if self.expand or post_filter:
+ filtered = self.check_component(o, expand_only=not post_filter)
+ if not filtered:
+ continue
+ else:
+ filtered = [
+ x
+ for x in o.icalendar_instance.subcomponents
+ if not isinstance(x, Timezone)
+ ]
+ i = o.icalendar_instance
+ tz_ = [x for x in i.subcomponents if isinstance(x, Timezone)]
+ i.subcomponents = tz_
+ for comp in filtered:
+ if isinstance(comp, Timezone):
+ continue
+ if split_expanded:
+ new_obj = o.copy(keep_uid=True)
+ new_i = new_obj.icalendar_instance
+ new_i.subcomponents = []
+ for tz in tz_:
+ new_i.add_component(tz)
+ objects.append(new_obj)
+ else:
+ new_i = i
+ new_i.add_component(comp)
+ if not (split_expanded):
+ objects.append(o)
+
+ for obj in objects:
+ try:
+ await obj.load(only_if_unloaded=True)
+ except:
+ pass
+
+ return self.sort(objects)
+
+ async def _async_search_with_comptypes(
+ self,
+ calendar,
+ server_expand: bool = False,
+ split_expanded: bool = True,
+ props=None,
+ xml: str = None,
+ _hacks: str = None,
+ ):
+ """Internal method - does three searches, one for each comp class."""
+ from dataclasses import replace
+
+ from caldav._async.calendarobjectresource import (
+ AsyncEvent,
+ AsyncJournal,
+ AsyncTodo,
+ )
+
+ if xml and (isinstance(xml, str) or "calendar-query" in xml.tag):
+ raise NotImplementedError(
+ "full xml given, and it has to be patched to include comp_type"
+ )
+ clone = replace(self)
+ objects = []
+ for comp_class in (AsyncEvent, AsyncTodo, AsyncJournal):
+ clone.comp_class = comp_class
+ objects += await clone.async_search(
+ calendar, server_expand, split_expanded, props, xml
+ )
+ return self.sort(objects)
diff --git a/docs/source/adr/0001-httpx-async-first-architecture.md b/docs/source/adr/0001-httpx-async-first-architecture.md
new file mode 100644
index 00000000..a578fdef
--- /dev/null
+++ b/docs/source/adr/0001-httpx-async-first-architecture.md
@@ -0,0 +1,487 @@
+# ADR 0001: HTTPX Async-First Architecture with Thin Sync Wrappers
+
+## Status
+
+**Proposed** - For caldav v3.0
+**Supersedes** - #555
+
+## Context
+
+The caldav library currently uses `requests` (with optional `niquests` support) for HTTP communication. This synchronous-only approach limits the library's usefulness in modern async Python applications. A previous attempt to add async support via parallel implementations created significant code duplication and maintenance burden.
+
+### Current Architecture
+
+The library's HTTP layer is centralized in `DAVClient` (`davclient.py`), which provides:
+- Core HTTP methods: `request()`, `propfind()`, `proppatch()`, `report()`, `mkcalendar()`, `put()`, `post()`, `delete()`, `options()`
+- Session management via `requests.Session()`
+- Authentication handling (Basic, Digest, Bearer)
+- Response parsing via `DAVResponse`
+
+All other classes (`DAVObject`, `Calendar`, `Principal`, `CalendarSet`, `CalendarObjectResource`, `Event`, `Todo`, `Journal`) delegate HTTP operations through `self.client` to `DAVClient`.
+
+### Key Files and Structure
+
+```
+caldav/
+├── davclient.py # DAVClient, DAVResponse, HTTP layer (~1100 lines)
+├── davobject.py # DAVObject base class (~430 lines)
+├── collection.py # Principal, Calendar, CalendarSet (~1300 lines)
+├── calendarobjectresource.py # Event, Todo, Journal, FreeBusy (~1660 lines)
+├── search.py # CalDAVSearcher (~510 lines)
+├── requests.py # HTTPBearerAuth (~20 lines)
+└── lib/
+ ├── url.py # URL handling
+ ├── vcal.py # iCalendar utilities
+ └── error.py # Exception classes
+```
+
+### Why HTTPX + AnyIO
+
+HTTPX is a modern HTTP client built on top of **anyio**, providing:
+- Both sync and async APIs from a single codebase
+- Drop-in compatibility with requests API patterns
+- HTTP/2 support
+- Superior timeout handling
+- Active maintenance and modern Python support (3.8+)
+- Built-in authentication classes similar to requests
+
+**AnyIO** is the async compatibility layer that httpx uses internally:
+- Provides backend-agnostic async primitives (works with asyncio and trio)
+- Offers `anyio.from_thread.run()` for cleanly running async code from sync contexts
+- Handles event loop management automatically
+- No need for `nest_asyncio` hacks - anyio's thread-based approach is cleaner
+- Already a transitive dependency via httpx
+
+## Decision
+
+We will adopt an **async-first architecture using HTTPX**, where:
+
+1. **Primary Implementation**: All core HTTP and DAV logic is written as async code
+2. **Sync API**: Provided via thin wrappers that execute async code synchronously
+3. **Single Source of Truth**: Only async code is maintained; sync wrappers are minimal
+
+This differs from the previous ADR proposal which suggested code generation (unasync). Instead, we use runtime wrapping, which is simpler to implement and maintain.
+
+## Implementation Strategy
+
+### Phase 1: HTTP Layer (AsyncDAVClient)
+
+Replace `requests`/`niquests` with `httpx.AsyncClient`:
+
+```python
+# caldav/_async/davclient.py
+import httpx
+from typing import Optional, Mapping
+
+class AsyncDAVClient:
+ """Async CalDAV client using httpx."""
+
+ def __init__(
+ self,
+ url: str = "",
+ username: Optional[str] = None,
+ password: Optional[str] = None,
+ auth: Optional[httpx.Auth] = None,
+ timeout: Optional[float] = None,
+ ssl_verify_cert: bool = True,
+ # ... other params
+ ) -> None:
+ self._client = httpx.AsyncClient(
+ auth=auth or self._build_auth(username, password),
+ timeout=timeout,
+ verify=ssl_verify_cert,
+ # ...
+ )
+ self.url = URL.objectify(url)
+ # ...
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ await self.close()
+
+ async def close(self) -> None:
+ await self._client.aclose()
+
+ async def request(
+ self,
+ url: str,
+ method: str = "GET",
+ body: str = "",
+ headers: Mapping[str, str] = None,
+ ) -> "DAVResponse":
+ """Core HTTP request method."""
+ combined_headers = {**self.headers, **(headers or {})}
+
+ response = await self._client.request(
+ method=method,
+ url=str(url),
+ content=to_wire(body) if body else None,
+ headers=combined_headers,
+ )
+
+ return DAVResponse(response, self)
+
+ async def propfind(self, url: str = None, props: str = "", depth: int = 0) -> "DAVResponse":
+ return await self.request(url or str(self.url), "PROPFIND", props, {"Depth": str(depth)})
+
+ async def report(self, url: str, query: str = "", depth: int = 0) -> "DAVResponse":
+ return await self.request(url, "REPORT", query, {"Depth": str(depth), "Content-Type": 'application/xml; charset="utf-8"'})
+
+ # ... other methods: proppatch, mkcalendar, put, post, delete, options
+```
+
+### Phase 2: DAV Object Layer
+
+Convert `DAVObject` and subclasses to async:
+
+```python
+# caldav/_async/davobject.py
+
+class AsyncDAVObject:
+ """Async base class for DAV objects."""
+
+ client: Optional["AsyncDAVClient"] = None
+
+ async def _query_properties(self, props=None, depth: int = 0):
+ """Internal propfind query."""
+ root = None
+ if props:
+ prop = dav.Prop() + props
+ root = dav.Propfind() + prop
+ return await self._query(root, depth)
+
+ async def _query(self, root=None, depth=0, query_method="propfind", url=None, expected_return_value=None):
+ body = etree.tostring(root.xmlelement(), ...) if root else ""
+ url = url or self.url
+ ret = await getattr(self.client, query_method)(url, body, depth)
+ # ... error handling
+ return ret
+
+ async def get_properties(self, props=None, depth: int = 0, ...):
+ response = await self._query_properties(props, depth)
+ # ... process response
+ return properties
+
+ async def set_properties(self, props=None):
+ # ... build XML
+ r = await self._query(root, query_method="proppatch")
+ return self
+
+ async def delete(self) -> None:
+ if self.url:
+ r = await self.client.delete(str(self.url))
+ if r.status not in (200, 204, 404):
+ raise error.DeleteError(errmsg(r))
+```
+
+### Phase 3: Thin Sync Wrappers
+
+Create sync wrappers that delegate to async implementation using **anyio**:
+
+```python
+# caldav/_sync/davclient.py
+import anyio
+from caldav._async.davclient import AsyncDAVClient as _AsyncDAVClient
+from caldav._async.davclient import DAVResponse
+
+def _run_sync(async_fn, *args, **kwargs):
+ """Execute an async function synchronously using anyio.
+
+ Uses anyio.from_thread.run() which properly handles:
+ - Running from a sync context (creates new event loop)
+ - Running from within an async context (uses existing loop via thread)
+ - Works with both asyncio and trio backends
+ """
+ return anyio.from_thread.run(async_fn, *args, **kwargs)
+
+
+class DAVClient:
+ """Synchronous CalDAV client - thin wrapper around AsyncDAVClient."""
+
+ def __init__(self, *args, **kwargs):
+ self._async_client = _AsyncDAVClient(*args, **kwargs)
+ # Copy attributes for compatibility
+ self.url = self._async_client.url
+ self.headers = self._async_client.headers
+ # ...
+
+ def __enter__(self):
+ _run_sync(self._async_client.__aenter__)
+ return self
+
+ def __exit__(self, *args):
+ _run_sync(self._async_client.__aexit__, *args)
+
+ def close(self) -> None:
+ _run_sync(self._async_client.close)
+
+ def request(self, url: str, method: str = "GET", body: str = "", headers=None) -> DAVResponse:
+ return _run_sync(self._async_client.request, url, method, body, headers)
+
+ def propfind(self, url: str = None, props: str = "", depth: int = 0) -> DAVResponse:
+ return _run_sync(self._async_client.propfind, url, props, depth)
+
+ def report(self, url: str, query: str = "", depth: int = 0) -> DAVResponse:
+ return _run_sync(self._async_client.report, url, query, depth)
+
+ # ... delegate all other methods
+
+ def principal(self, *args, **kwargs):
+ from caldav._sync.collection import Principal
+ return Principal(client=self, *args, **kwargs)
+```
+
+### Phase 4: Collection Classes
+
+Apply the same pattern to Calendar, Principal, CalendarSet:
+
+```python
+# caldav/_async/collection.py
+
+class AsyncCalendar(AsyncDAVObject):
+ """Async Calendar implementation."""
+
+ async def save(self, method=None):
+ if self.url is None:
+ await self._create(id=self.id, name=self.name, method=method, **self.extra_init_options)
+ return self
+
+ async def search(self, **kwargs):
+ # ... search implementation
+ pass
+
+ async def events(self):
+ return await self.search(comp_class=Event)
+
+ async def save_event(self, *args, **kwargs):
+ return await self.save_object(Event, *args, **kwargs)
+
+
+# caldav/_sync/collection.py
+
+class Calendar(DAVObject):
+ """Sync Calendar - thin wrapper around AsyncCalendar."""
+
+ def __init__(self, client=None, *args, **kwargs):
+ from caldav._async.collection import AsyncCalendar
+ self._async = AsyncCalendar(client._async_client if client else None, *args, **kwargs)
+ super().__init__(client=client, *args, **kwargs)
+
+ def save(self, method=None):
+ return _run_sync(self._async.save, method)
+
+ def search(self, **kwargs):
+ return _run_sync(self._async.search, **kwargs)
+
+ def events(self):
+ return _run_sync(self._async.events)
+```
+
+### Phase 5: Public API Structure
+
+```python
+# caldav/__init__.py - Default sync API (backward compatible)
+from .davclient import DAVClient
+from .collection import Calendar, Principal, CalendarSet
+from .calendarobjectresource import Event, Todo, Journal, FreeBusy
+
+# caldav/aio.py - Async API
+from ._async.davclient import AsyncDAVClient as DAVClient
+from ._async.collection import AsyncCalendar as Calendar
+from ._async.collection import AsyncPrincipal as Principal
+# ... etc
+```
+
+### Directory Structure
+
+```
+caldav/
+├── __init__.py # Re-exports sync API (backward compatible)
+├── aio.py # Re-exports async API
+├── _async/
+│ ├── __init__.py
+│ ├── davclient.py # AsyncDAVClient, DAVResponse (PRIMARY)
+│ ├── davobject.py # AsyncDAVObject (PRIMARY)
+│ ├── collection.py # AsyncCalendar, AsyncPrincipal, etc. (PRIMARY)
+│ └── calendarobjectresource.py # AsyncEvent, etc. (PRIMARY)
+├── _sync/
+│ ├── __init__.py
+│ ├── davclient.py # DAVClient (wrapper)
+│ ├── davobject.py # DAVObject (wrapper)
+│ ├── collection.py # Calendar, Principal, etc. (wrapper)
+│ └── calendarobjectresource.py # Event, Todo, Journal (wrapper)
+├── davclient.py # -> _sync/davclient.py (compatibility re-export)
+├── davobject.py # -> _sync/davobject.py (compatibility re-export)
+├── collection.py # -> _sync/collection.py (compatibility re-export)
+├── calendarobjectresource.py # -> _sync/calendarobjectresource.py
+├── elements/ # Unchanged - XML elements
+├── lib/ # Unchanged - utilities
+└── requests.py # Replaced with httpx auth classes
+```
+
+## Consequences
+
+### Positive
+
+1. **Single Source of Truth**: All business logic lives in async code; sync is purely delegation
+2. **Modern HTTP Client**: HTTPX provides HTTP/2, better timeouts, and async support
+3. **Reduced Maintenance**: ~50% less code to maintain vs. parallel implementations
+4. **Simple Implementation**: Runtime wrapping is simpler than code generation
+5. **Backward Compatible**: Sync API remains unchanged for existing users
+6. **Clean Async API**: `caldav.aio` provides first-class async support
+
+### Negative
+
+1. **Runtime Overhead**: Each sync call incurs thread-based async bridging overhead
+2. **Dependency Change**: Adds httpx (with anyio), potentially removes requests
+3. **Migration Effort**: Significant refactoring required (~4000 lines)
+
+### Performance Considerations
+
+The `anyio.from_thread.run()` overhead is typically negligible for network-bound operations like CalDAV. HTTP latency (typically 50-500ms) far exceeds the thread coordination overhead. Unlike `asyncio.run()`, anyio's approach:
+- Reuses event loops when possible
+- Works correctly in nested async contexts (Jupyter, etc.) without hacks
+- Handles connection pooling efficiently via httpx
+
+For performance-critical applications, users should migrate to the async API where they can:
+- Perform concurrent calendar operations
+- Share a single event loop
+- Avoid `asyncio.run()` overhead
+
+## Alternatives Considered
+
+### Alternative 1: Code Generation (unasync)
+
+The previous ADR proposed using `unasync` to generate sync code from async sources.
+
+**Rejected because:**
+- Adds build complexity
+- Generated code can have subtle bugs
+- Harder to debug (users see generated code)
+- Thin wrappers are simpler and more maintainable
+
+### Alternative 2: Keep requests, Add Async Separately
+
+Maintain requests for sync, add httpx for async.
+
+**Rejected because:**
+- Two HTTP libraries to maintain
+- Different behavior between sync/async
+- Doesn't solve the code duplication problem
+
+### Alternative 3: Sync Inherits from Async
+
+Make sync classes inherit from async and override methods.
+
+**Rejected because:**
+- Inheritance creates tight coupling
+- Still requires method-by-method implementation
+- Confusing class hierarchy
+
+## Migration Guide
+
+### For Sync Users (No Changes Required)
+
+```python
+# Before (v2.x)
+from caldav import DAVClient
+client = DAVClient(url="...", username="...", password="...")
+calendars = client.principal().calendars()
+
+# After (v3.x) - identical
+from caldav import DAVClient
+client = DAVClient(url="...", username="...", password="...")
+calendars = client.principal().calendars()
+```
+
+### New Async API (v3.x)
+
+```python
+from caldav.aio import DAVClient
+
+async def main():
+ async with DAVClient(url="...", username="...", password="...") as client:
+ principal = await client.principal()
+ calendars = await principal.calendars()
+
+ for cal in calendars:
+ events = await cal.events()
+```
+
+## Implementation Checklist
+
+### Phase 1: Foundation (2-3 weeks)
+- [ ] Add httpx and anyio dependencies to pyproject.toml
+- [ ] Create `caldav/_async/` directory structure
+- [ ] Implement `AsyncDAVClient` with core HTTP methods
+- [ ] Implement `DAVResponse` (shared between sync/async)
+- [ ] Create `_run_sync()` utility using `anyio.from_thread.run()`
+
+### Phase 2: HTTP Layer Complete (2-3 weeks)
+- [ ] Create `caldav/_sync/davclient.py` wrapper
+- [ ] Implement all DAVClient methods in async
+- [ ] Port authentication handling to httpx
+- [ ] Add connection pooling configuration
+- [ ] Unit tests for HTTP layer
+
+### Phase 3: Object Model (4-6 weeks)
+- [ ] Port `AsyncDAVObject` and `DAVObject` wrapper
+- [ ] Port `AsyncPrincipal`, `AsyncCalendarSet`
+- [ ] Port `AsyncCalendar` with all methods
+- [ ] Port `AsyncCalendarObjectResource` and subclasses
+- [ ] Port `CalDAVSearcher` to async
+
+### Phase 4: Integration & Testing (3-4 weeks)
+- [ ] Create `caldav/aio.py` with async exports
+- [ ] Update `caldav/__init__.py` for backward compatibility
+- [ ] Port all existing tests to work with new structure
+- [ ] Add async-specific tests
+- [ ] Integration tests with real CalDAV servers
+
+### Phase 5: Documentation & Release (2-3 weeks)
+- [ ] Update all documentation
+- [ ] Write migration guide
+- [ ] Update examples
+- [ ] Beta release for community testing
+- [ ] Final v3.0 release
+
+## Dependencies
+
+```toml
+# pyproject.toml changes
+[project]
+dependencies = [
+ "httpx>=0.25.0", # Includes anyio as transitive dependency
+ "anyio>=4.0.0", # Explicit dependency for sync wrappers
+ "lxml",
+ "icalendar",
+ # ... other existing deps
+]
+
+[project.optional-dependencies]
+# requests no longer needed for core functionality
+legacy = ["requests"] # For users who need requests compatibility
+trio = ["trio"] # For users who prefer trio backend over asyncio
+```
+
+Note: `anyio` is already a transitive dependency of `httpx`, but we declare it explicitly since we use it directly in the sync wrappers.
+
+## References
+
+- [HTTPX Documentation](https://www.python-httpx.org/)
+- [HTTPX Async Support](https://www.python-httpx.org/async/)
+- [AnyIO Documentation](https://anyio.readthedocs.io/)
+- [AnyIO - Running sync code from async](https://anyio.readthedocs.io/en/stable/threads.html)
+- [RFC 4791 - CalDAV](https://tools.ietf.org/html/rfc4791)
+
+## Decision Makers
+
+- Proposed by: @cbcoutinho
+- Requires review by: @tobixen (project maintainer)
+
+## Changelog
+
+- 2025-11-22: Updated to use anyio instead of asyncio/nest_asyncio for sync wrappers
+- 2025-11-22: Initial draft proposing httpx + thin sync wrappers approach
diff --git a/docs/source/adr/README.md b/docs/source/adr/README.md
new file mode 100644
index 00000000..b7bbcd6c
--- /dev/null
+++ b/docs/source/adr/README.md
@@ -0,0 +1,38 @@
+# Architecture Decision Records
+
+This directory contains Architecture Decision Records (ADRs) for the caldav library.
+
+ADRs are documents that capture important architectural decisions made during the project's development, along with their context and consequences.
+
+## Index
+
+| ADR | Title | Status |
+|-----|-------|--------|
+| [0001](./0001-httpx-async-first-architecture.md) | HTTPX Async-First Architecture with Thin Sync Wrappers | Proposed |
+
+## ADR Status Definitions
+
+- **Proposed**: Under discussion, not yet accepted
+- **Accepted**: Approved and ready for implementation
+- **Deprecated**: No longer relevant or superseded
+- **Superseded**: Replaced by another ADR
+
+## Template
+
+When creating a new ADR, use this structure:
+
+```markdown
+# ADR NNNN: Title
+
+## Status
+[Proposed | Accepted | Deprecated | Superseded by ADR-XXXX]
+
+## Context
+What is the issue that we're seeing that is motivating this decision?
+
+## Decision
+What is the change that we're proposing and/or doing?
+
+## Consequences
+What becomes easier or more difficult to do because of this change?
+```
diff --git a/pyproject.toml b/pyproject.toml
index 4d76193f..cb6b86ff 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,7 +33,8 @@ classifiers = [
dependencies = [
"lxml",
- "niquests",
+ "httpx>=0.25.0",
+ "anyio>=4.0.0",
"recurring-ical-events>=2.0.0",
"typing_extensions;python_version<'3.11'",
"icalendar>6.0.0",
diff --git a/tests/test_httpx_integration.py b/tests/test_httpx_integration.py
new file mode 100644
index 00000000..3708da9e
--- /dev/null
+++ b/tests/test_httpx_integration.py
@@ -0,0 +1,337 @@
+#!/usr/bin/env python
+"""
+Simple integration tests for the httpx async-first implementation.
+
+Tests both sync and async clients against a live CalDAV server.
+Requires a running CalDAV server (e.g., Nextcloud, Radicale).
+
+These tests are skipped by default unless CALDAV_TEST_URL is set,
+or a server is reachable at the default URL.
+
+Usage:
+ # With explicit server URL
+ CALDAV_TEST_URL=http://localhost:8080/remote.php/dav pytest tests/test_httpx_integration.py -v
+
+ # Or just run if server is at default location
+ pytest tests/test_httpx_integration.py -v
+
+Environment variables:
+ CALDAV_TEST_URL - CalDAV server URL (enables tests when set)
+ CALDAV_USER - Username (default: admin)
+ CALDAV_PASS - Password (default: admin)
+"""
+import os
+import uuid
+from datetime import datetime
+from datetime import timedelta
+
+import pytest
+
+# Test configuration from environment or defaults
+# Use CALDAV_TEST_URL to explicitly enable these tests
+CALDAV_URL = os.environ.get("CALDAV_TEST_URL", "http://localhost:8080/remote.php/dav")
+CALDAV_USER = os.environ.get("CALDAV_USER", "admin")
+CALDAV_PASS = os.environ.get("CALDAV_PASS", "admin")
+
+
+def _server_reachable():
+ """Check if the CalDAV server is reachable."""
+ try:
+ import httpx
+
+ with httpx.Client(timeout=5.0) as client:
+ response = client.get(CALDAV_URL)
+ # Accept any response - server is reachable
+ return True
+ except Exception:
+ return False
+
+
+# Skip all tests in this module if server not reachable and not explicitly enabled
+_explicit_url = "CALDAV_TEST_URL" in os.environ
+_server_available = _server_reachable() if not _explicit_url else True
+
+pytestmark = pytest.mark.skipif(
+ not _explicit_url and not _server_available,
+ reason="CalDAV server not available. Set CALDAV_TEST_URL to enable.",
+)
+
+
+# Sample iCalendar data
+def make_event_ical(uid: str, summary: str, start: datetime, end: datetime) -> str:
+ """Create a simple VEVENT iCalendar string."""
+ return f"""BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//Test//EN
+BEGIN:VEVENT
+UID:{uid}
+DTSTAMP:{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}
+DTSTART:{start.strftime('%Y%m%dT%H%M%SZ')}
+DTEND:{end.strftime('%Y%m%dT%H%M%SZ')}
+SUMMARY:{summary}
+END:VEVENT
+END:VCALENDAR"""
+
+
+def make_todo_ical(uid: str, summary: str) -> str:
+ """Create a simple VTODO iCalendar string."""
+ return f"""BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//Test//EN
+BEGIN:VTODO
+UID:{uid}
+DTSTAMP:{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}
+SUMMARY:{summary}
+STATUS:NEEDS-ACTION
+END:VTODO
+END:VCALENDAR"""
+
+
+class TestSyncClient:
+ """Tests for the synchronous DAVClient."""
+
+ @pytest.fixture
+ def client(self):
+ """Create a sync DAVClient."""
+ from caldav import DAVClient
+
+ client = DAVClient(url=CALDAV_URL, username=CALDAV_USER, password=CALDAV_PASS)
+ yield client
+
+ @pytest.fixture
+ def test_calendar(self, client):
+ """Create a test calendar, yield it, then clean up."""
+ principal = client.principal()
+ cal_id = f"test-sync-{uuid.uuid4().hex[:8]}"
+ calendar = principal.make_calendar(
+ name=f"Test Calendar {cal_id}", cal_id=cal_id
+ )
+ yield calendar
+ # Cleanup
+ try:
+ calendar.delete()
+ except Exception:
+ pass
+
+ def test_connect_and_get_principal(self, client):
+ """Test basic connection and principal retrieval."""
+ principal = client.principal()
+ assert principal is not None
+
+ def test_list_calendars(self, client):
+ """Test listing calendars."""
+ principal = client.principal()
+ calendars = principal.calendars()
+ assert isinstance(calendars, list)
+ # Should have at least one calendar (default)
+ assert len(calendars) >= 1
+
+ def test_create_and_delete_calendar(self, client):
+ """Test calendar creation and deletion."""
+ principal = client.principal()
+ cal_id = f"test-create-{uuid.uuid4().hex[:8]}"
+
+ # Create
+ calendar = principal.make_calendar(name=f"Test {cal_id}", cal_id=cal_id)
+ assert calendar is not None
+
+ # Verify it exists
+ calendars = principal.calendars()
+ cal_urls = [str(c.url) for c in calendars]
+ assert any(cal_id in url for url in cal_urls)
+
+ # Delete
+ calendar.delete()
+
+ # Verify it's gone
+ calendars = principal.calendars()
+ cal_urls = [str(c.url) for c in calendars]
+ assert not any(cal_id in url for url in cal_urls)
+
+ def test_create_read_delete_event(self, test_calendar):
+ """Test event CRUD operations."""
+ uid = f"test-event-{uuid.uuid4().hex}"
+ start = datetime.utcnow() + timedelta(days=1)
+ end = start + timedelta(hours=1)
+ ical_data = make_event_ical(uid, "Test Event", start, end)
+
+ # Create
+ event = test_calendar.save_event(ical_data)
+ assert event is not None
+
+ # Read back
+ events = test_calendar.events()
+ assert len(events) >= 1
+ assert any(uid in e.data for e in events)
+
+ # Delete
+ event.delete()
+
+ # Verify deleted
+ events = test_calendar.events()
+ assert not any(uid in e.data for e in events)
+
+ def test_create_read_delete_todo(self, test_calendar):
+ """Test todo CRUD operations."""
+ uid = f"test-todo-{uuid.uuid4().hex}"
+ ical_data = make_todo_ical(uid, "Test Todo")
+
+ # Create
+ todo = test_calendar.save_todo(ical_data)
+ assert todo is not None
+
+ # Read back
+ todos = test_calendar.todos()
+ assert len(todos) >= 1
+ assert any(uid in t.data for t in todos)
+
+ # Delete
+ todo.delete()
+
+ def test_search_events(self, test_calendar):
+ """Test event search functionality."""
+ # Create a few events
+ now = datetime.utcnow()
+ events_data = []
+ for i in range(3):
+ uid = f"search-test-{uuid.uuid4().hex}"
+ start = now + timedelta(days=i + 1)
+ end = start + timedelta(hours=1)
+ ical_data = make_event_ical(uid, f"Search Event {i}", start, end)
+ test_calendar.save_event(ical_data)
+ events_data.append((uid, start, end))
+
+ # Search for events in the date range
+ search_start = now
+ search_end = now + timedelta(days=5)
+ results = test_calendar.search(
+ start=search_start,
+ end=search_end,
+ event=True,
+ expand=False,
+ )
+
+ # Should find all 3 events
+ assert len(results) >= 3
+
+
+class TestAsyncClient:
+ """Tests for the asynchronous AsyncDAVClient."""
+
+ pytestmark = pytest.mark.anyio
+
+ @pytest.fixture
+ def anyio_backend(self):
+ """Use asyncio backend for async tests."""
+ return "asyncio"
+
+ @pytest.fixture
+ def async_client(self):
+ """Create an async DAVClient."""
+ from caldav.aio import AsyncDAVClient
+
+ client = AsyncDAVClient(
+ url=CALDAV_URL, username=CALDAV_USER, password=CALDAV_PASS
+ )
+ return client
+
+ async def test_connect_and_get_principal(self, async_client):
+ """Test basic async connection and principal retrieval."""
+ principal = await async_client.principal()
+ assert principal is not None
+
+ async def test_list_calendars(self, async_client):
+ """Test async listing calendars."""
+ principal = await async_client.principal()
+ calendars = await principal.calendars()
+ assert isinstance(calendars, list)
+ assert len(calendars) >= 1
+
+ async def test_create_and_delete_calendar(self, async_client):
+ """Test async calendar creation and deletion."""
+ principal = await async_client.principal()
+ cal_id = f"test-async-{uuid.uuid4().hex[:8]}"
+
+ # Create
+ calendar = await principal.make_calendar(
+ name=f"Async Test {cal_id}", cal_id=cal_id
+ )
+ assert calendar is not None
+
+ # Verify exists
+ calendars = await principal.calendars()
+ cal_urls = [str(c.url) for c in calendars]
+ assert any(cal_id in url for url in cal_urls)
+
+ # Delete
+ await calendar.delete()
+
+ # Verify gone
+ calendars = await principal.calendars()
+ cal_urls = [str(c.url) for c in calendars]
+ assert not any(cal_id in url for url in cal_urls)
+
+ async def test_create_read_delete_event(self, async_client):
+ """Test async event CRUD operations."""
+ principal = await async_client.principal()
+ cal_id = f"test-async-event-{uuid.uuid4().hex[:8]}"
+ calendar = await principal.make_calendar(
+ name=f"Async Event Test {cal_id}", cal_id=cal_id
+ )
+
+ try:
+ uid = f"async-event-{uuid.uuid4().hex}"
+ start = datetime.utcnow() + timedelta(days=1)
+ end = start + timedelta(hours=1)
+ ical_data = make_event_ical(uid, "Async Test Event", start, end)
+
+ # Create
+ event = await calendar.save_event(ical_data)
+ assert event is not None
+
+ # Read back
+ events = await calendar.events()
+ assert len(events) >= 1
+ assert any(uid in e.data for e in events)
+
+ # Delete event
+ await event.delete()
+ finally:
+ # Cleanup calendar
+ await calendar.delete()
+
+ async def test_search_events(self, async_client):
+ """Test async event search functionality."""
+ principal = await async_client.principal()
+ cal_id = f"test-async-search-{uuid.uuid4().hex[:8]}"
+ calendar = await principal.make_calendar(
+ name=f"Async Search Test {cal_id}", cal_id=cal_id
+ )
+
+ try:
+ now = datetime.utcnow()
+ for i in range(3):
+ uid = f"async-search-{uuid.uuid4().hex}"
+ start = now + timedelta(days=i + 1)
+ end = start + timedelta(hours=1)
+ ical_data = make_event_ical(uid, f"Async Search Event {i}", start, end)
+ await calendar.save_event(ical_data)
+
+ # Search
+ search_start = now
+ search_end = now + timedelta(days=5)
+ results = await calendar.search(
+ start=search_start,
+ end=search_end,
+ event=True,
+ expand=False,
+ )
+
+ assert len(results) >= 3
+ finally:
+ await calendar.delete()
+
+
+# Allow running directly
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])