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"])