Skip to content

Commit 1210694

Browse files
tobixenclaude
andcommitted
Fix add_object/add_event/add_todo for async clients (issue #631)
Calendar.add_object() was calling o.save() without await, so with an AsyncDAVClient the method returned a coroutine object instead of an awaited Event/Todo. Added _async_add_object_finish() helper that properly awaits save() and handles reverse relations, and route async clients through it. Added regression tests in TestAsyncCalendarAddObject: - test_add_event_returns_coroutine_with_async_client: verifies the return value is awaitable - test_add_event_result_has_url: verifies the awaited result has a URL Also fixes test pollution in test_async_davclient.py: switched from direct class attribute mutation (AsyncEvent._async_create = AsyncMock()) to patch.object(), which restores the original after the test. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8173ee3 commit 1210694

3 files changed

Lines changed: 71 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
1414

1515
## [Unreleased]
1616

17-
### Breaking Changes
18-
19-
* The icalendar dependency is updated from 6 to 7 - not because 3.0 depends on icalendar7, but because I'm planning to use icalendar7-features in some upcoming 3.x. If this causes problems for you, just reach out and I will downgrade the dependency, release a new 3.0.1, and possibly procrastinate the icalendar7-stuff until 4.0.
20-
2117
### Added
2218

2319
* **Stalwart CalDAV server** added to Docker test server framework.
@@ -30,10 +26,17 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
3026
* Fixed `Principal._async_get_property()` override having an incompatible signature (missing `use_cached` and `**passthrough`) and reimplementing PROPFIND logic already handled correctly by the parent `DAVObject._async_get_property()`. The override has been removed.
3127
* Fixed inconsistent URL quoting for calendar object UIDs containing slashes -- both `_generate_url()` and `_find_id_and_path()` in `calendarobject_ops.py` now share a single `_quote_uid()` helper (related to https://github.com/python-caldav/caldav/issues/143).
3228
* Fixed `expand_simple_props()` return value handling.
29+
* Fixed `Calendar.add_object()` (and `add_event()` / `add_todo()`) not being awaitable when using `AsyncDAVClient` -- `save()` returns a coroutine for async clients, but the code was calling it without `await`, making the method uncallable in async contexts. https://github.com/python-caldav/caldav/issues/631
30+
31+
### Added (compatibility)
32+
33+
* New feature flag `save-load.event.recurrences.exception` to express whether the server stores master+exception VEVENTs as a single calendar object (per RFC) or splits them into separate objects. When a server stores them separately, `expand=True` searches now automatically fall back to server-side `CALDAV:expand` (when supported), since client-side expansion of the master alone would otherwise yield duplicate occurrences.
34+
* Added Stalwart compatibility hints: `search.recurrences.includes-implicit.event` (fragile — broken for all-day/VALUE=DATE events), `search.recurrences.includes-implicit.todo` (fragile), `search.recurrences.expanded.exception` (unsupported), `save-load.event.recurrences.exception` (unsupported — exceptions stored as separate objects), `vtodo_datesearch_nodtstart_task_is_skipped` and `no_search_openended` old-flags.
3335

3436
### Test Framework
3537

3638
* Added async rate-limit unit tests matching the sync test suite.
39+
* caldav-server-tester: `CheckRecurrenceSearch` now also verifies implicit recurrence support for all-day (VALUE=DATE) recurring events, marking the feature as `fragile` (with behaviour description) when only datetime recurring events work.
3740

3841
## [3.0.0a2] - 2026-02-25 (Alpha Release)
3942

caldav/collection.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,8 @@ def add_object(
840840
),
841841
parent=self,
842842
)
843+
if self.is_async_client:
844+
return self._async_add_object_finish(o, no_overwrite=no_overwrite, no_create=no_create)
843845
o = o.save(no_overwrite=no_overwrite, no_create=no_create)
844846
## TODO: Saving nothing is currently giving an object with None as URL.
845847
## This should probably be changed in some future version to raise an error
@@ -848,6 +850,13 @@ def add_object(
848850
o._handle_reverse_relations(fix=True)
849851
return o
850852

853+
async def _async_add_object_finish(self, o, no_overwrite=False, no_create=False):
854+
"""Async helper for add_object(): awaits save() then handles reverse relations."""
855+
o = await o.save(no_overwrite=no_overwrite, no_create=no_create)
856+
if o.url is not None:
857+
o._handle_reverse_relations(fix=True)
858+
return o
859+
851860
def add_event(self, *largs, **kwargs) -> "Event":
852861
"""
853862
Add an event to the calendar.

tests/test_async_davclient.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,61 @@ def test_has_component_with_only_vcalendar(self) -> None:
821821
assert obj.has_component() is False
822822

823823

824+
class TestAsyncCalendarAddObject:
825+
"""Tests for Calendar.add_object/add_event/add_todo with async clients (issue #631)."""
826+
827+
SIMPLE_EVENT = """BEGIN:VCALENDAR
828+
VERSION:2.0
829+
PRODID:-//Test//Test//EN
830+
BEGIN:VEVENT
831+
UID:test-async-add-event@example.com
832+
DTSTART:20200101T100000Z
833+
DTEND:20200101T110000Z
834+
SUMMARY:Test Async Add Event
835+
END:VEVENT
836+
END:VCALENDAR"""
837+
838+
@pytest.mark.asyncio
839+
async def test_add_event_returns_coroutine_with_async_client(self) -> None:
840+
"""Calendar.add_event() must be awaitable when using AsyncDAVClient.
841+
842+
Regression test for issue #631: o.save() returns a coroutine for async
843+
clients, so add_object() must await it instead of doing o.url on the
844+
coroutine object.
845+
"""
846+
import inspect
847+
848+
from caldav.aio import AsyncEvent
849+
from caldav.collection import Calendar
850+
851+
client = AsyncDAVClient(url="https://caldav.example.com/dav/")
852+
calendar = Calendar(client=client, url="https://caldav.example.com/dav/calendars/test/")
853+
854+
with patch.object(AsyncEvent, "_async_create", new_callable=AsyncMock):
855+
result = calendar.add_event(self.SIMPLE_EVENT)
856+
# With an async client, add_event must return a coroutine
857+
assert inspect.isawaitable(result), (
858+
"add_event() should return a coroutine when using AsyncDAVClient, "
859+
"got %r instead" % result
860+
)
861+
event = await result
862+
assert isinstance(event, AsyncEvent)
863+
864+
@pytest.mark.asyncio
865+
async def test_add_event_result_has_url(self) -> None:
866+
"""Awaiting add_event() with async client returns an Event with a URL."""
867+
from caldav.aio import AsyncEvent
868+
from caldav.collection import Calendar
869+
870+
client = AsyncDAVClient(url="https://caldav.example.com/dav/")
871+
calendar = Calendar(client=client, url="https://caldav.example.com/dav/calendars/test/")
872+
873+
with patch.object(AsyncEvent, "_async_create", new_callable=AsyncMock):
874+
event = await calendar.add_event(self.SIMPLE_EVENT)
875+
# Should have a URL set (or None, but not crash)
876+
_ = event.url # must not raise AttributeError
877+
878+
824879
class TestAsyncRateLimiting:
825880
"""
826881
Unit tests for 429/503 rate-limit handling in AsyncDAVClient.

0 commit comments

Comments
 (0)