From 34d6148294a77dafdc64a23b494131cb935f5556 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 19 Mar 2026 00:46:37 +0100 Subject: [PATCH 1/6] fix: export get_calendar/get_calendars from caldav.aio The async factory functions already existed in caldav.async_davclient but were not re-exported from caldav.aio, leaving no parity with the sync API where caldav.get_calendars / caldav.get_calendar are available. Co-Authored-By: Claude Sonnet 4.6 --- caldav/aio.py | 5 +++- caldav/async_davclient.py | 52 ++++++++++++++++++++------------------- caldav/base_client.py | 32 ++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/caldav/aio.py b/caldav/aio.py index 1bccaa03..55da69dd 100644 --- a/caldav/aio.py +++ b/caldav/aio.py @@ -27,7 +27,7 @@ """ # Import the async client (this is truly async) -from caldav.async_davclient import AsyncDAVClient, AsyncDAVResponse +from caldav.async_davclient import AsyncDAVClient, AsyncDAVResponse, get_calendar, get_calendars from caldav.async_davclient import get_davclient as get_async_davclient from caldav.calendarobjectresource import CalendarObjectResource, Event, FreeBusy, Journal, Todo from caldav.collection import ( @@ -61,6 +61,9 @@ "AsyncDAVClient", "AsyncDAVResponse", "get_async_davclient", + # Factory functions (async equivalents of caldav.get_calendar / get_calendars) + "get_calendar", + "get_calendars", # Base objects (unified dual-mode) "DAVObject", "CalendarObjectResource", diff --git a/caldav/async_davclient.py b/caldav/async_davclient.py index 35db3b86..d7630e14 100644 --- a/caldav/async_davclient.py +++ b/caldav/async_davclient.py @@ -1245,7 +1245,7 @@ async def get_calendars( calendar_name: Any | None = None, raise_errors: bool = False, **kwargs: Any, -) -> list["Calendar"]: +) -> "CalendarCollection": """ Get calendars from a CalDAV server asynchronously. @@ -1258,15 +1258,14 @@ async def get_calendars( **kwargs: Connection parameters (url, username, password, etc.) Returns: - List of Calendar objects matching the criteria. + :class:`~caldav.base_client.CalendarCollection` of matching calendars. + Use as an async context manager to auto-close the connection:: - Example:: - - from caldav.async_davclient import get_calendars - - calendars = await get_calendars(url="...", username="...", password="...") + async with await aio.get_calendars() as calendars: + for cal in calendars: + print(await cal.get_display_name()) """ - from caldav.base_client import _normalize_to_list + from caldav.base_client import CalendarCollection, _normalize_to_list def _try(coro_result, errmsg): """Handle errors based on raise_errors flag.""" @@ -1282,13 +1281,13 @@ def _try(coro_result, errmsg): if raise_errors: raise log.error(f"Failed to create async client: {e}") - return [] + return CalendarCollection() try: principal = await client.get_principal() if not principal: _try(None, "getting principal") - return [] + return CalendarCollection(client=client) calendars = [] calendar_urls = _normalize_to_list(calendar_url) @@ -1332,31 +1331,34 @@ def _try(coro_result, errmsg): if raise_errors: raise - return calendars + return CalendarCollection(calendars, client=client) - finally: - # Don't close the client - let the caller manage its lifecycle - pass + except Exception: + await client.__aexit__(None, None, None) + raise -async def get_calendar(**kwargs: Any) -> Optional["Calendar"]: +async def get_calendar(**kwargs: Any) -> "CalendarResult": """ Get a single calendar from a CalDAV server asynchronously. This is a convenience function for the common case where only one - calendar is needed. It returns the first matching calendar or None. + calendar is needed. Use as an async context manager to auto-close + the connection:: + + async with await aio.get_calendar() as cal: + events = await cal.search(event=True) Args: Same as :func:`get_calendars`. Returns: - A single Calendar object, or None if no calendars found. - - Example:: - - from caldav.async_davclient import get_calendar - - calendar = await get_calendar(calendar_name="Work", url="...", ...) + :class:`~caldav.base_client.CalendarResult` wrapping the first + matching calendar (or None). Behaves like the calendar via + ``__getattr__`` delegation when not used as a context manager. """ - calendars = await get_calendars(**kwargs) - return calendars[0] if calendars else None + from caldav.base_client import CalendarResult + + collection = await get_calendars(**kwargs) + cal = collection[0] if collection else None + return CalendarResult(cal, client=collection.client) diff --git a/caldav/base_client.py b/caldav/base_client.py index 01e808d4..434cd9de 100644 --- a/caldav/base_client.py +++ b/caldav/base_client.py @@ -328,6 +328,26 @@ def __exit__(self, exc_type, exc_val, exc_tb): self[0].client.__exit__(exc_type, exc_val, exc_tb) return False + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + seen: set[int] = set() + for c in self._clients: + if id(c) not in seen: + if hasattr(c, "__aexit__"): + await c.__aexit__(exc_type, exc_val, exc_tb) + else: + c.__exit__(exc_type, exc_val, exc_tb) + seen.add(id(c)) + if not self._clients and self: + c = self[0].client + if hasattr(c, "__aexit__"): + await c.__aexit__(exc_type, exc_val, exc_tb) + else: + c.__exit__(exc_type, exc_val, exc_tb) + return False + def close(self): """Close all underlying DAV client connections.""" seen: set[int] = set() @@ -390,6 +410,18 @@ def __exit__(self, exc_type, exc_val, exc_tb): client.__exit__(exc_type, exc_val, exc_tb) return False + async def __aenter__(self): + return self._calendar + + async def __aexit__(self, exc_type, exc_val, exc_tb): + client = self.client + if client: + if hasattr(client, "__aexit__"): + await client.__aexit__(exc_type, exc_val, exc_tb) + else: + client.__exit__(exc_type, exc_val, exc_tb) + return False + def close(self): """Close the underlying DAV client connection.""" client = self.client From 0f64c65925a3118c02b2845e7533537b1381e537 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 19 Mar 2026 14:26:48 +0100 Subject: [PATCH 2/6] fix: async support for uncomplete(), set_relation(), get_relatives(), invite methods Similar to the save()/complete() fix in e819a3a5, these methods called self.save() or self.parent.get_object_by_uid() without awaiting, so in async mode the returned coroutines were silently discarded. Changes: - uncomplete(): delegate to new _async_uncomplete() when is_async_client - set_relation(): refactor uid/other resolution to share between sync and async paths; delegate to new _async_set_relation() when is_async_client; extract _add_relation_to_ical() helper to avoid duplicating ical logic - get_relatives(): extract _parse_relatives_from_ical() helper (pure, no I/O) to avoid duplicating ical-parsing logic; delegate to new _async_get_relatives() when is_async_client - _reply_to_invite_request() / accept_invite() / decline_invite() / tentatively_accept_invite(): raise NotImplementedError for async clients (the entire invite reply flow uses load/save/add_event/schedule_outbox which all require async-aware wiring not yet implemented) Tests: add TestAsyncCalendarObjectResource with 6 unit tests that verify each fixed method returns a coroutine for async clients and that awaiting it produces the expected side-effects. Co-Authored-By: Claude Sonnet 4.6 --- caldav/calendarobjectresource.py | 111 +++++++++++++++++---- tests/test_caldav_unit.py | 162 +++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 18 deletions(-) diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index f56e2799..4451d430 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -283,16 +283,27 @@ def set_relation( else: # Use cheap accessor to avoid format conversion (issue #613) uid = other._get_uid_cheap() or other.icalendar_component["uid"] + other_obj = other else: uid = other - if set_reverse: - other = self.parent.get_object_by_uid(uid) + other_obj = None # Resolved below (possibly async) + + if self.is_async_client: + return self._async_set_relation(uid, other_obj, reltype, set_reverse) + + if other_obj is None and set_reverse: + other_obj = self.parent.get_object_by_uid(uid) if set_reverse: ## TODO: special handling of NEXT/FIRST. ## STARTTOFINISH does not have any equivalent "reverse". reltype_reverse = self.RELTYPE_REVERSE_MAP[reltype] - other.set_relation(other=self, reltype=reltype_reverse, set_reverse=False) + other_obj.set_relation(other=self, reltype=reltype_reverse, set_reverse=False) + + self._add_relation_to_ical(uid, reltype) + self.save() + def _add_relation_to_ical(self, uid, reltype) -> None: + """Add a RELATED-TO property to the icalendar component (no-op if already present).""" existing_relation = self.icalendar_component.get("related-to", None) existing_relations = ( existing_relation if isinstance(existing_relation, list) else [existing_relation] @@ -306,16 +317,45 @@ def set_relation( # then Component._encode does miss adding properties # see https://github.com/collective/icalendar/issues/557 # workaround should be safe to remove if issue gets fixed - uid = str(uid) self.icalendar_component.add( - "related-to", uid, parameters={"RELTYPE": reltype}, encode=True + "related-to", str(uid), parameters={"RELTYPE": reltype}, encode=True ) - self.save() + async def _async_set_relation(self, uid, other_obj, reltype, set_reverse) -> None: + """Async implementation of set_relation() for async clients.""" + if other_obj is None and set_reverse: + other_obj = await self.parent.get_object_by_uid(uid) + if set_reverse: + ## TODO: special handling of NEXT/FIRST. + reltype_reverse = self.RELTYPE_REVERSE_MAP[reltype] + # set_relation() returns a coroutine when is_async_client, so await it + await other_obj.set_relation(other=self, reltype=reltype_reverse, set_reverse=False) + + self._add_relation_to_ical(uid, reltype) + await self.save() ## TODO: this method is undertested in the caldav library. ## However, as this consolidated and eliminated quite some duplicated code in the ## plann project, it is extensively tested in plann. + def _parse_relatives_from_ical( + self, + reltypes: "Container[str] | None", + relfilter: "Callable[[Any], bool] | None", + ) -> "defaultdict[str, set[str]]": + """Extract RELATED-TO properties as a {reltype: {uid, ...}} dict (pure, no I/O).""" + ret: defaultdict[str, set[str]] = defaultdict(set) + relations = self.icalendar_component.get("RELATED-TO", []) + if not isinstance(relations, list): + relations = [relations] + for rel in relations: + if relfilter and not relfilter(rel): + continue + reltype = rel.params.get("RELTYPE", "PARENT") + if reltypes and reltype not in reltypes: + continue + ret[reltype].add(str(rel)) + return ret + def get_relatives( self, reltypes: Container[str] | None = None, @@ -340,24 +380,17 @@ def get_relatives( (but due to backward compatibility requirement, such an object should behave like the current dict) """ + if self.is_async_client: + return self._async_get_relatives(reltypes, relfilter, fetch_objects, ignore_missing) + from .collection import Calendar ## late import to avoid cycling imports - ret = defaultdict(set) - relations = self.icalendar_component.get("RELATED-TO", []) - if not isinstance(relations, list): - relations = [relations] - for rel in relations: - if relfilter and not relfilter(rel): - continue - reltype = rel.params.get("RELTYPE", "PARENT") - if reltypes and reltype not in reltypes: - continue - ret[reltype].add(str(rel)) + ret = self._parse_relatives_from_ical(reltypes, relfilter) if fetch_objects: for reltype in ret: uids = ret[reltype] - reltype_set = set() + reltype_set: set = set() if self.parent is None: raise ValueError("Unexpected value None for self.parent") @@ -376,6 +409,37 @@ def get_relatives( return ret + async def _async_get_relatives( + self, + reltypes: "Container[str] | None", + relfilter: "Callable[[Any], bool] | None", + fetch_objects: bool, + ignore_missing: bool, + ) -> "defaultdict[str, set]": + """Async implementation of get_relatives() for async clients.""" + from .collection import Calendar ## late import to avoid cycling imports + + ret = self._parse_relatives_from_ical(reltypes, relfilter) + + if fetch_objects: + if self.parent is None: + raise ValueError("Unexpected value None for self.parent") + if not isinstance(self.parent, Calendar): + raise ValueError("self.parent expected to be of type Calendar but it is not") + + for reltype in ret: + uids = ret[reltype] + reltype_set: set = set() + for obj in uids: + try: + reltype_set.add(await self.parent.get_object_by_uid(obj)) + except error.NotFoundError: + if not ignore_missing: + raise + ret[reltype] = reltype_set + + return ret + def _set_reverse_relation(self, other, reltype): ## TODO: handle RFC9253 better! Particularly next/first-lists reverse_reltype = self.RELTYPE_REVERSE_MAP.get(reltype) @@ -621,6 +685,11 @@ def tentatively_accept_invite(self, calendar: Any | None = None) -> None: ## partstat can also be set to COMPLETED and IN-PROGRESS. def _reply_to_invite_request(self, partstat, calendar) -> None: + if self.is_async_client: + raise NotImplementedError( + "accept_invite/decline_invite/tentatively_accept_invite are not yet supported " + "for async clients" + ) error.assert_(self.is_invite_request()) if not calendar: calendar = self.client.principal().get_calendars()[0] @@ -1966,8 +2035,14 @@ def uncomplete(self) -> None: self.icalendar_component.add("status", "NEEDS-ACTION") if "completed" in self.icalendar_component: self.icalendar_component.pop("completed") + if self.is_async_client: + return self._async_uncomplete() self.save() + async def _async_uncomplete(self) -> None: + """Async implementation of uncomplete() for async clients.""" + await self.save() + ## TODO: should be moved up to the base class def set_duration(self, duration, movable_attr="DTSTART"): """ diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index fc5eaf69..5777a73a 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -1902,6 +1902,168 @@ async def fake_search(**kwargs): assert obj.id == uid +class TestAsyncCalendarObjectResource: + """Tests that CalendarObjectResource methods return coroutines (not None) for async clients. + + These guard against the pattern where a sync method calls self.save() or + self.parent.some_method() without awaiting, silently discarding the coroutine. + """ + + completed_todo = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:20070313T123432Z-456553@example.com +DTSTAMP:20070313T123432Z +DUE;VALUE=DATE:20070501 +SUMMARY:Submit Quebec Income Tax Return for 2006 +STATUS:COMPLETED +COMPLETED:20070501T000000Z +END:VTODO +END:VCALENDAR""" + + todo_with_relation = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:20070313T123432Z-456553@example.com +DTSTAMP:20070313T123432Z +DUE;VALUE=DATE:20070501 +SUMMARY:Submit Quebec Income Tax Return for 2006 +RELATED-TO;RELTYPE=PARENT:parent-uid-001 +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR""" + + def _make_async_client_and_calendar(self): + from caldav.async_davclient import AsyncDAVClient + + client = MockedDAVClient("") + client.__class__ = type( + "AsyncDAVClient", (MockedDAVClient,), {"__module__": AsyncDAVClient.__module__} + ) + calendar = Calendar(client, url="/calendar/") + return client, calendar + + def test_uncomplete_returns_coroutine_for_async_client(self): + """uncomplete() must return a coroutine for async clients, not silently discard save().""" + import asyncio + + client, calendar = self._make_async_client_and_calendar() + todo = Todo( + client=client, + url="/calendar/todo1.ics", + data=self.completed_todo, + parent=calendar, + ) + result = todo.uncomplete() + assert asyncio.iscoroutine(result), ( + f"expected coroutine from uncomplete(), got {type(result)}" + ) + result.close() + + def test_uncomplete_async_saves_and_clears_status(self): + """Awaiting uncomplete() must actually save the object and clear STATUS/COMPLETED.""" + import asyncio + + client, calendar = self._make_async_client_and_calendar() + todo = Todo( + client=client, + url="/calendar/todo1.ics", + data=self.completed_todo, + parent=calendar, + ) + + saved = False + + async def fake_async_put(*args, **kwargs): + nonlocal saved + saved = True + + todo._async_put = fake_async_put + asyncio.run(todo.uncomplete()) + + assert saved, "uncomplete() did not call _async_put for async client" + assert todo.icalendar_component.get("STATUS") == "NEEDS-ACTION" + assert "COMPLETED" not in todo.icalendar_component + + def test_get_relatives_returns_coroutine_for_async_client(self): + """get_relatives(fetch_objects=True) must return a coroutine for async clients.""" + import asyncio + + client, calendar = self._make_async_client_and_calendar() + todo = Todo( + client=client, + url="/calendar/todo1.ics", + data=self.todo_with_relation, + parent=calendar, + ) + result = todo.get_relatives() + assert asyncio.iscoroutine(result), ( + f"expected coroutine from get_relatives(), got {type(result)}" + ) + result.close() + + def test_get_relatives_async_returns_objects(self): + """Awaiting get_relatives() must return fetched objects, not coroutines.""" + import asyncio + + client, calendar = self._make_async_client_and_calendar() + todo = Todo( + client=client, + url="/calendar/todo1.ics", + data=self.todo_with_relation, + parent=calendar, + ) + + parent_todo = Todo(client=client, url="/calendar/parent.ics", data=ev1, parent=calendar) + + async def fake_get_object_by_uid(uid): + return parent_todo + + calendar.get_object_by_uid = fake_get_object_by_uid + + result = asyncio.run(todo.get_relatives()) + assert "PARENT" in result + parent_set = result["PARENT"] + assert len(parent_set) == 1 + obj = next(iter(parent_set)) + assert obj is parent_todo, f"expected the parent todo object, got {obj!r}" + + def test_set_relation_returns_coroutine_for_async_client(self): + """set_relation() must return a coroutine for async clients, not silently drop save().""" + import asyncio + + client, calendar = self._make_async_client_and_calendar() + todo = Todo( + client=client, + url="/calendar/todo1.ics", + data=self.completed_todo, + parent=calendar, + ) + other_todo = Todo( + client=client, + url="/calendar/todo2.ics", + data=self.completed_todo, + parent=calendar, + ) + # Patch id so set_relation can extract a UID without a full ical parse + other_todo._id = "some-other-uid" + + result = todo.set_relation(other_todo, reltype="PARENT", set_reverse=False) + assert asyncio.iscoroutine(result), ( + f"expected coroutine from set_relation(), got {type(result)}" + ) + result.close() + + def test_accept_invite_raises_not_implemented_for_async_client(self): + """accept_invite() must raise NotImplementedError for async clients (not silently fail).""" + client, calendar = self._make_async_client_and_calendar() + event = Event(client=client, url="/calendar/ev1.ics", data=ev1, parent=calendar) + with pytest.raises(NotImplementedError): + event.accept_invite() + + class TestRateLimitHelpers: """Unit tests for the shared rate-limit helper functions in caldav.lib.error.""" From 90c9106ba400f324f58657fcfe8dbbd98471fc35 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 19 Mar 2026 16:15:28 +0100 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20async=20=5Fhandle=5Freverse=5Frelati?= =?UTF-8?q?ons=20=E2=80=94=20was=20calling=20get=5Frelatives()=20without?= =?UTF-8?q?=20await?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _async_add_object_finish called _handle_reverse_relations(fix=True), which internally called self.get_relatives() — returning a coroutine in async mode instead of the dict, causing TypeError: 'coroutine' object is not iterable. Added _async_set_reverse_relation, _async_verify_reverse_relation, and _async_handle_reverse_relations; updated _async_add_object_finish to await the new async variant. Co-Authored-By: Claude Sonnet 4.6 --- caldav/calendarobjectresource.py | 38 ++++++++++++++++++++++++++++++++ caldav/collection.py | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index 4451d430..a4d9c6c9 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -448,6 +448,14 @@ def _set_reverse_relation(self, other, reltype): return other.set_relation(self, reverse_reltype, other) + async def _async_set_reverse_relation(self, other, reltype): + """Async version of _set_reverse_relation.""" + reverse_reltype = self.RELTYPE_REVERSE_MAP.get(reltype) + if not reverse_reltype: + logging.error("Reltype %s not supported in object uid %s" % (reltype, self.id)) + return + await other.set_relation(self, reverse_reltype, other) + def _verify_reverse_relation(self, other, reltype) -> tuple: revreltype = self.RELTYPE_REVERSE_MAP[reltype] ## TODO: special case FIRST/NEXT needs special handling @@ -461,6 +469,36 @@ def _verify_reverse_relation(self, other, reltype) -> tuple: return (other, revreltype) return False + async def _async_verify_reverse_relation(self, other, reltype) -> tuple: + """Async version of _verify_reverse_relation.""" + revreltype = self.RELTYPE_REVERSE_MAP[reltype] + other_relations = await other.get_relatives(fetch_objects=False, reltypes={revreltype}) + my_uid = self._get_uid_cheap() or str(self.icalendar_component["uid"]) + if my_uid not in other_relations[revreltype]: + return (other, revreltype) + return False + + async def _async_handle_reverse_relations( + self, verify: bool = False, fix: bool = False, pdb: bool = False + ) -> list: + """Async version of _handle_reverse_relations for async clients.""" + ret = [] + assert verify or fix + relations = await self.get_relatives() + for reltype in relations: + for other in relations[reltype]: + if verify: + foobar = await self._async_verify_reverse_relation(other, reltype) + if foobar: + ret.append(foobar) + if pdb: + breakpoint() + if fix: + await self._async_set_reverse_relation(other, reltype) + elif fix: + await self._async_set_reverse_relation(other, reltype) + return ret + def _handle_reverse_relations( self, verify: bool = False, fix: bool = False, pdb: bool = False ) -> list: diff --git a/caldav/collection.py b/caldav/collection.py index c0dd74f9..b0d9336d 100644 --- a/caldav/collection.py +++ b/caldav/collection.py @@ -848,7 +848,7 @@ async def _async_add_object_finish(self, o, no_overwrite=False, no_create=False) """Async helper for add_object(): awaits save() then handles reverse relations.""" o = await o.save(no_overwrite=no_overwrite, no_create=no_create) if o.url is not None: - o._handle_reverse_relations(fix=True) + await o._async_handle_reverse_relations(fix=True) return o def add_event(self, *largs, **kwargs) -> "Event": From 19f70a67bd22a018f6b62084c94684669552149e Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Wed, 18 Mar 2026 21:32:24 +0100 Subject: [PATCH 4/6] docs: add async tutorial Add docs/source/async_tutorial.rst, a step-by-step async tutorial that mirrors the existing sync tutorial.rst. Covers the same topics (creating calendars, accessing calendars, creating events, searching, investigating events, modifying events, tasks) plus a "Parallel Operations" section demonstrating asyncio.gather(). Also update tutorial.rst to link to the new async tutorial instead of saying "will come soon", and add async_tutorial to the docs index. Co-Authored-By: Claude Sonnet 4.6 --- docs/source/async_tutorial.rst | 380 +++++++++++++++++++++++++++++++++ docs/source/index.rst | 1 + docs/source/tutorial.rst | 4 +- tests/test_docs.py | 1 + 4 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 docs/source/async_tutorial.rst diff --git a/docs/source/async_tutorial.rst b/docs/source/async_tutorial.rst new file mode 100644 index 00000000..b807b784 --- /dev/null +++ b/docs/source/async_tutorial.rst @@ -0,0 +1,380 @@ +============== +Async Tutorial +============== + +This tutorial covers async usage of the Python CalDAV client library. +It mirrors :doc:`tutorial`, but uses the ``caldav.aio`` module. This +tutorial assumes you've already browsed through the sync tutorial. + +Copy code examples into a Python file and run them with ``python``. Do not +name your file ``caldav.py`` or ``calendar.py``, as this may break imports. + +All examples run inside an ``async def`` function launched via +``asyncio.run()``. You are encouraged to add a ``breakpoint()`` inside the +``async with`` blocks to inspect return objects. + +Go through the tutorial twice, first against a Xandikos test server, and then +against a server of your own choice. + +Configuration +------------- + +The same applies here as in the sync tutorial, use ``export PYTHON_CALDAV_USE_TEST_SERVER=1`` and install Xandikos and the instructions below will give you a test server. Unset ``PYTHON_CALDAV_USE_TEST_SERVER`` and edit ``~/.config/caldav/calendar.conf`` or adjust the environment variables to test with a real server. + +Creating Calendars +------------------ + +The async API lives in ``caldav.aio``. Obtain a client by awaiting +:func:`~caldav.aio.get_async_davclient`, then use it as an async context +manager. When the ``async with`` block exits the HTTP session is closed. + +.. code-block:: python + + import asyncio + from caldav import aio + + async def main(): + client = await aio.get_async_davclient() + async with client: + my_principal = await client.get_principal() + my_new_calendar = await my_principal.make_calendar(name="Teest calendar") + ## Enable the debug breakpoint to investigate the calendar object + #breakpoint() + await my_new_calendar.delete() + + asyncio.run(main()) + +The delete step is unimportant when running towards an ephemeral test server. + +The async version probes the server with an OPTIONS request by default (``probe=True``). It may and may not cause an immediate failure on wrong credentials, depending on the server setup. Feel free to play with it. This code will never fail: + +.. code-block:: python + + import asyncio + from caldav import aio + + async def main(): + ## Invalid domain, invalid password ... + ## ... this probably ought to raise an error? + client = await aio.get_async_davclient( + username='alice', + password='hunter2', + url='https://calendar.example.com/dav/', + probe=False) + async with client: + ... + + asyncio.run(main()) + +Accessing Calendars +------------------- + +Use :func:`aio.get_calendars` to list all calendars in one call. Like the +sync version it returns a collection that can be used as an async context +manager — the HTTP session is terminated on exit: + +.. code-block:: python + + import asyncio + from caldav import aio + + async def main(): + async with await aio.get_calendars() as calendars: + for calendar in calendars: + print(f"Calendar \"{await calendar.get_display_name()}\" has URL {calendar.url}") + + asyncio.run(main()) + +:func:`aio.get_calendar` is the async counterpart of :func:`caldav.get_calendar` +and is the **recommended starting point** for most code: + +.. code-block:: python + + import asyncio + from caldav import aio + + async def main(): + async with await aio.get_calendar() as calendar: + print(f"Calendar \"{await calendar.get_display_name()}\" has URL {calendar.url}") + ## You may add a debugger breakpoint and investigate the object + #breakpoint() + + asyncio.run(main()) + +The calendar has a ``.client`` property which gives the client. + +Creating Events +--------------- + +From the :class:`~caldav.collection.Calendar` object, use +:meth:`~caldav.collection.Calendar.add_event` to create an event: + +.. code-block:: python + + import asyncio + import datetime + from caldav import aio + + async def main(): + async with await aio.get_calendar() as cal: + ## Add a may 17 event + may17 = await cal.add_event( + dtstart=datetime.datetime(2020,5,17,8), + dtend=datetime.datetime(2020,5,18,1), + uid="may17", + summary="Do the needful", + rrule={'FREQ': 'YEARLY'}) + ## You may want to inspect the event + #breakpoint() + + asyncio.run(main()) + +You have icalendar code and want to put it into the calendar? Easy! + +.. code-block:: python + + import asyncio + from caldav import aio + + async def main(): + async with await aio.get_calendar() as cal: + may17 = await cal.add_event("""BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VEVENT + UID:20200516T060000Z-123401@example.com + DTSTAMP:20200516T060000Z + DTSTART:20200517T060000Z + DTEND:20200517T230000Z + RRULE:FREQ=YEARLY + SUMMARY:Do the needful + END:VEVENT + END:VCALENDAR + """) + #breakpoint() + + asyncio.run(main()) + + +Searching +--------- + +The search API is identical to the sync version; just add ``await``: + +.. code-block:: python + + import asyncio + from caldav import aio + from datetime import datetime, date + + async def main(): + async with await aio.get_calendar() as cal: + await cal.add_event( + dtstart=datetime(2023,5,17,8), + dtend=datetime(2023,5,18,1), + uid="may17", + summary="Do the needful", + rrule={'FREQ': 'YEARLY'}) + + my_events = await cal.search( + event=True, + start=date(2026,5,1), + end=date(2026,6,1), + expand=True) + + print(my_events[0].data) + #breakpoint() + + asyncio.run(main()) + +The ``expand``, ``event``, and other parameters work exactly as in the sync +API. See the sync tutorial for a full explanation of the search options. + +Investigating Events +-------------------- + +Use ``.data`` for raw icalendar data, or +:meth:`~caldav.calendarobjectresource.CalendarObjectResource.get_icalendar_component` +for convenient property access: + +.. code-block:: python + + import asyncio + from caldav import aio + from datetime import datetime, date + + async def main(): + async with await aio.get_calendar() as cal: + await cal.add_event( + dtstart=datetime(2023,5,17,8), + dtend=datetime(2023,5,18,1), + uid="may17", + summary="Do the needful", + rrule={'FREQ': 'YEARLY'}) + + my_events = await cal.search( + event=True, + start=date(2026,5,1), + end=date(2026,6,1), + expand=True) + + print(my_events[0].get_icalendar_component()['summary']) + print(my_events[0].get_icalendar_component().duration) + #breakpoint() + + asyncio.run(main()) + +The caveat about recurring events from the sync tutorial applies here too: +``get_icalendar_component()`` is safe after an expanded search. + +Modifying Events +---------------- + +Replace the raw ``data`` string: + +.. code-block:: python + + import asyncio + from caldav import aio + from datetime import date + import datetime + + async def main(): + async with await aio.get_calendar() as cal: + await cal.add_event( + dtstart=datetime.datetime(2023,5,17,8), + dtend=datetime.datetime(2023,5,18,1), + uid="may17", + summary="Do the needful", + rrule={'FREQ': 'YEARLY'}) + + my_events = await cal.search( + event=True, + start=date(2026,5,1), + end=date(2026,6,1), + expand=True) + + my_events[0].data = my_events[0].data.replace("Do the needful", "Have fun!") + await my_events[0].save() + #breakpoint() + + asyncio.run(main()) + +Best practice is to use +:meth:`~caldav.calendarobjectresource.CalendarObjectResource.edit_icalendar_component`: + +.. code-block:: python + + import asyncio + from caldav import aio + from datetime import date + import datetime + + async def main(): + async with await aio.get_calendar() as cal: + await cal.add_event( + dtstart=datetime.datetime(2023,5,17,8), + dtend=datetime.datetime(2023,5,18,1), + uid="may17", + summary="Do the needful", + rrule={'FREQ': 'YEARLY'}) + + my_events = await cal.search( + event=True, + start=date(2026,5,1), + end=date(2026,6,1), + expand=True) + + ## Edit the summary using the "borrowing pattern": + with my_events[0].edit_icalendar_component() as event_ical: + ## "component" is always safe after an expanded search + event_ical['summary'] = "Norwegian national day celebrations" + await my_events[0].save() + + ## Let's take out the event again: + may17 = await cal.get_event_by_uid('may17') + + ## Inspect may17 in a debug breakpoint + #breakpoint() + + asyncio.run(main()) + +Note that ``edit_icalendar_component()`` is a plain (synchronous) context +manager — no ``await`` or ``async with`` needed there. + +Tasks +----- + +Tasks work just like events, with ``await`` added: + +.. code-block:: python + + import asyncio + from caldav import aio + from datetime import date + + async def main(): + client = await aio.get_async_davclient() + async with client: + my_principal = await client.get_principal() + ## This can be read as "create me a tasklist" + cal = await my_principal.make_calendar( + name="Test tasklist", supported_calendar_component_set=['VTODO']) + ## ... but for most servers it's an ordinary calendar! + await cal.add_todo( + summary="prepare for the Norwegian national day", due=date(2025,5,16)) + + my_tasks = await cal.search(todo=True) + assert len(my_tasks) == 1 + await my_tasks[0].complete() + my_tasks = await cal.search(todo=True) + assert len(my_tasks) == 0 + my_tasks = await cal.search(todo=True, include_completed=True) + assert my_tasks + + asyncio.run(main()) + +The :meth:`~caldav.calendarobjectresource.Todo.complete` method is awaitable in +async mode. See the sync tutorial for a note on tasklist vs calendar support +differences between servers. + +Parallel Operations +------------------- + +The main benefit of the async API is the ability to run multiple I/O operations +*concurrently* using :func:`asyncio.gather`. The following example fetches +events from all calendars at the same time, instead of one by one: + +.. code-block:: python + + import asyncio + from caldav import aio + + async def main(): + async with await aio.get_calendars() as calendars: + ## Kick off all searches in parallel, then collect the results + results = await asyncio.gather( + *[cal.search(event=True) for cal in calendars]) + + for cal, events in zip(calendars, results): + print(f"{await cal.get_display_name()}: {len(events)} event(s)") + + asyncio.run(main()) + +``asyncio.gather`` runs all the coroutines concurrently. For a single server +the speed gain is modest (one connection), but when talking to multiple servers +or doing many independent fetches the difference can be significant. + +Further Reading +--------------- + +See the :ref:`examples:examples` folder for more code, including +`async examples `_ +and `sync examples `_ +for comparison. + +See :doc:`async` for the async API reference, including a migration guide from +the sync API. + +The `integration tests `_ +cover most async features. diff --git a/docs/source/index.rst b/docs/source/index.rst index f1213c57..d6869f59 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -20,6 +20,7 @@ Contents about v3-migration tutorial + async_tutorial configfile async jmap diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index f1857a9a..531668b2 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -11,8 +11,8 @@ imports. Go through the tutorial twice, first against a Xandikos test server, and then against a server of your own choice. -This tutorial only covers the sync API. The async API is quite -similar. A tutorial on the async API will come soon. +This tutorial only covers the sync API. See :doc:`async_tutorial` for the +async equivalent. Ad-hoc Configuration -------------------- diff --git a/tests/test_docs.py b/tests/test_docs.py index 107f52fd..9a9190ec 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -33,3 +33,4 @@ def tearDown(self): os.environ.pop("PYTHON_CALDAV_USE_TEST_SERVER", None) test_tutorial = manueltest("../docs/source/tutorial.rst") + test_tutorial = manueltest("../docs/source/async_tutorial.rst") From 287cc5630cd432f089281a5255360ff94a5069d3 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 19 Mar 2026 14:38:57 +0100 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20async=20design=20critique=20?= =?UTF-8?q?=E2=80=94=20dual-mode=20pattern=20fragility=20and=20alternative?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the structural problem with the current is_async_client branching approach: silent coroutine discard, incorrect type annotations, growing method-pair count, and which methods still lack async support. Also covers alternative designs (separate async classes, full sans-IO) and practical recommendations (mandatory coroutine tests, type overloads, lint rule). Co-Authored-By: Claude Sonnet 4.6 --- docs/design/ASYNC_DESIGN_CRITIQUE.md | 191 +++++++++++++++++++++++++++ docs/design/README.md | 5 + 2 files changed, 196 insertions(+) create mode 100644 docs/design/ASYNC_DESIGN_CRITIQUE.md diff --git a/docs/design/ASYNC_DESIGN_CRITIQUE.md b/docs/design/ASYNC_DESIGN_CRITIQUE.md new file mode 100644 index 00000000..53ba2f6f --- /dev/null +++ b/docs/design/ASYNC_DESIGN_CRITIQUE.md @@ -0,0 +1,191 @@ +# Async Design: Current Approach and Its Problems + +**Written:** March 2026 +**Context:** Bug hunt following commit e819a3a5 (async save/complete fixes) + +--- + +## The Current Approach + +The library adds async support through a **dual-mode single-class** pattern: + +- `DAVClient` and `AsyncDAVClient` are separate classes at the HTTP layer. +- `Calendar`, `Event`, `Todo`, and friends are **shared** between sync and async use. +- Methods detect at runtime whether they are being called from an async context + via the `is_async_client` property, then either do the work directly or delegate + to a private `_async_*` counterpart that uses `await`. + +Example (simplified): + +```python +def save(self, ...): + if self.is_async_client: + return self._async_save(...) # returns a coroutine + # ... sync implementation ... + self._create(...) + return self + +async def _async_save(self, ...): + # ... same logic, with await ... + await self._async_create(...) + return self +``` + +The caller then does either `obj.save()` or `await obj.save()` depending on context. + +--- + +## Why This Is Fragile + +### Silent coroutine discard + +The single biggest problem: **if a method calls `self.save()` internally and +forgets the async check, the coroutine is silently discarded with no error**. + +```python +def uncomplete(self): + ...ical manipulation... + self.save() # BUG: returns a coroutine in async mode, discarded silently +``` + +The object appears to work — `uncomplete()` returns `None` as expected — but the +change is never written to the server. There is no exception, no warning, nothing. + +Commit e819a3a5 fixed `save()` and `complete()`. The subsequent commit fixed +`uncomplete()`, `set_relation()`, `get_relatives()`, and the invite-reply methods. +Each was caught only because someone wrote an explicit unit test checking +`asyncio.iscoroutine(result)`. + +### Every new I/O method is a latent bug + +The pattern requires that **every** method touching I/O has: + +1. An async check at the top. +2. A corresponding `_async_*` method. +3. A unit test verifying the coroutine is returned. + +Miss any one of these three and you have a silent bug. There is no compiler +enforcement, no type checker that catches it (the return type annotations currently +lie — `-> None` but actually `-> Coroutine | None`), and no runtime warning. + +### Type annotations are incorrect + +`save()` is annotated `-> Self`. In async mode it actually returns +`Coroutine[Any, Any, Self]`. These are not the same type. Any type-checked +caller (`mypy`, `pyright`) that writes: + +```python +obj = event.save() +obj.icalendar_component # AttributeError: Coroutine has no attribute icalendar_component +``` + +will get a runtime error that mypy would not catch. + +### Growing method count + +Each I/O-touching method produces a pair: the public method and its `_async_*` +twin. As the feature surface grows, so does the duplication. Helpers like +`_add_relation_to_ical()` and `_parse_relatives_from_ical()` reduce *some* of the +duplication, but the structural problem remains. + +--- + +## Alternative Approaches + +### 1. Separate async classes (the "motor" pattern) + +Create `AsyncCalendar`, `AsyncEvent`, `AsyncTodo` etc. that inherit all the pure +ical-manipulation logic from the base classes but override every I/O method as +`async def`: + +```python +class AsyncEvent(Event): + async def save(self, ...): ... + async def load(self, ...): ... + async def complete(self, ...): ... + # etc. +``` + +Pros: +- Correct type annotations — `AsyncEvent.save()` returns `Coroutine`, `Event.save()` returns `Self`. +- No runtime branching — no `if self.is_async_client`. +- Missing overrides become obvious (the sync version just works, perhaps wrongly, but at least it's detectable). +- Familiar pattern (aiohttp vs requests, motor vs pymongo, etc.). + +Cons: +- Breaking API change for anyone currently using `Calendar` and `AsyncDAVClient` together. +- More class hierarchy to maintain. +- Factory functions (`get_calendar()`, `get_calendars()`) would need to return the right subclass. + +### 2. Full Sans-I/O at the object level + +Push all I/O out of `CalendarObjectResource` entirely. Methods like `save()` and +`load()` would produce **request descriptors** that the caller passes to the client: + +```python +req = event.build_save_request() +response = await client.execute(req) +event.apply_save_response(response) +``` + +Pros: Completely decoupled, fully testable without mocks, one code path. +Cons: Very different API, massive refactor, probably not worth it for a library +at this level of abstraction. + +### 3. asyncio.run() wrapper (rejected) + +Wrap all async methods with `asyncio.run()` for the sync case. Rejected because +nested event loops are forbidden, and `asyncio.run()` cannot be called from an +already-running loop. + +--- + +## Recommendation + +The dual-mode pattern is pragmatic and probably good enough for the near term, +but it needs systematic guarding: + +1. **Every public method that does I/O must have a unit test** asserting + `asyncio.iscoroutine(result)` for async clients. The test should also assert + that awaiting the coroutine produces the expected side-effect (not just that it + returned a coroutine). + +2. **Fix the type annotations** — either accept that they are wrong and document + it, or use an overload: + ```python + @overload + def save(self: "SyncSelf", ...) -> Self: ... + @overload + def save(self: "AsyncSelf", ...) -> Coroutine[Any, Any, Self]: ... + ``` + This is verbose but gives type checkers a chance. + +3. **Consider a linting rule or metaclass hook** that walks all `CalendarObjectResource` + subclass methods looking for `self.save()`, `self.load()`, `self.parent.*()` calls + without a preceding `is_async_client` guard. This could be a simple AST check run + in CI. + +4. **Long-term**: if async use grows significantly, the separate-class approach is + cleaner. The migration could be done incrementally — `AsyncCalendar` delegates to + `Calendar` for now, then gradually overrides methods. + +--- + +## Methods Still Missing Async Support (as of March 2026) + +The following methods call I/O internally but do **not** yet have async support. +They will silently misbehave when called on objects associated with an async client: + +- `_complete_recurring_safe()` — calls `self.complete()` and `completed.save()` and + `completed.complete()`. Protected by the `handle_rrule=True → NotImplementedError` + guard in `_async_complete()`, but the underlying methods are not async-safe. +- `_complete_recurring_thisandfuture()` — same protection, same underlying issue. +- `accept_invite()` / `decline_invite()` / `tentatively_accept_invite()` — now raise + `NotImplementedError` for async clients. A proper async implementation would need + to await `load()`, `add_event()`, `schedule_outbox()`, and `save()`. +- `_handle_reverse_relations()` / `check_reverse_relations()` / `fix_reverse_relations()` + — call `get_relatives()` (now async-aware) and `set_relation()` (now async-aware), but + the methods themselves are not async-aware and will get a coroutine back from + `get_relatives()` and try to iterate it synchronously. +- `is_invite_request()` / `is_invite_reply()` — call `self.load(only_if_unloaded=True)`, + which returns a coroutine in async mode; these return the wrong thing silently. diff --git a/docs/design/README.md b/docs/design/README.md index 7c9222b1..e673e98a 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -88,6 +88,11 @@ Proposed Ruff configuration for linting and formatting. ### [RUFF_REMAINING_ISSUES.md](RUFF_REMAINING_ISSUES.md) Remaining linting issues to address. +### [ASYNC_DESIGN_CRITIQUE.md](ASYNC_DESIGN_CRITIQUE.md) +Critique of the current dual-mode async pattern (same class, runtime `is_async_client` +branching): why it is fragile, what alternative designs exist, and which methods still +lack async support as of March 2026. + ## Historical Note Some design documents from the exploration phase were removed in January 2026 after From 9bdf93b052849f39a15aa0c5fe0ab2b14ac763e4 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 19 Mar 2026 17:51:13 +0100 Subject: [PATCH 6/6] docs: document caldav-server-tester in the config file guide Add a "Testing server compatibility" section to configfile.rst explaining how to find, install, and run caldav-server-tester, and how to use its output to populate the features key in calendar.conf. Co-Authored-By: Claude Sonnet 4.6 --- docs/source/configfile.rst | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/source/configfile.rst b/docs/source/configfile.rst index 825d0874..464450f7 100644 --- a/docs/source/configfile.rst +++ b/docs/source/configfile.rst @@ -271,3 +271,43 @@ Shared base with per-calendar overrides inherits: _server calendar_name: Tasks calendar_url: https://caldav.example.com/cal/tasks/ + +Testing server compatibility +============================ + +The `caldav-server-tester `_ +companion tool probes a CalDAV server and reports which features and RFC +requirements it supports, which ones it handles in a quirky way, and which ones +it gets wrong. Its output can be used to populate the ``features`` key in your +config file so the caldav library applies the right workarounds automatically. + +Installation:: + + pip install caldav-server-tester + +Run against a config section defined in your ``calendar.conf``:: + + caldav-server-tester --config-section myserver + +Or supply connection details directly:: + + caldav-server-tester --caldav-url https://example.com/dav \ + --caldav-username alice \ + --caldav-password secret + +The tool will run a series of checks and print which features deviate from the +CalDAV standard. To get machine-readable output that you can paste into your +``calendar.conf``, use ``--format yaml``:: + + caldav-server-tester --config-section myserver --format yaml + +Copy the ``features:`` block from the output into your config section. +Alternatively, if your server matches one of the named profiles in +:mod:`caldav.compatibility_hints` (e.g. ``radicale``, ``baikal``, +``xandikos``), you can just name the profile:: + + myserver: + caldav_url: https://example.com/dav + caldav_username: alice + caldav_password: secret + features: radicale