diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3ee40e..1167e607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,6 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0 ## [Unreleased] -### Breaking Changes - -* 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. - ### Added * **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 * 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. * 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). * Fixed `expand_simple_props()` return value handling. +* 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 + +### Added (compatibility) + +* 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. +* 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. ### Test Framework * Added async rate-limit unit tests matching the sync test suite. +* 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. ## [3.0.0a2] - 2026-02-25 (Alpha Release) diff --git a/caldav/collection.py b/caldav/collection.py index 2d60e019..1b6ee00a 100644 --- a/caldav/collection.py +++ b/caldav/collection.py @@ -840,6 +840,8 @@ def add_object( ), parent=self, ) + if self.is_async_client: + return self._async_add_object_finish(o, no_overwrite=no_overwrite, no_create=no_create) o = o.save(no_overwrite=no_overwrite, no_create=no_create) ## TODO: Saving nothing is currently giving an object with None as URL. ## This should probably be changed in some future version to raise an error @@ -848,6 +850,13 @@ def add_object( o._handle_reverse_relations(fix=True) return o + 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) + return o + def add_event(self, *largs, **kwargs) -> "Event": """ Add an event to the calendar. diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index bbbd53af..ad626933 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -130,6 +130,16 @@ class FeatureSet: "save-load.event": {"description": "it's possible to save and load events to the calendar"}, "save-load.event.recurrences": {"description": "it's possible to save and load recurring events to the calendar - events with an RRULE property set, including recurrence sets"}, "save-load.event.recurrences.count": {"description": "The server will receive and store a recurring event with a count set in the RRULE", "default": {"support": "full"}}, + ## This was Claude's suggestion and it works as of today, the + ## "unsupported" description matches the behaviour of the Stalwart server. + ## Stalwart apparently (in a breach with the RFC) stores the exception + ## information as a separate CalendarObjectResource. + ## Currently the search logic will do server-side expansion + ## if this flag is set to "unsupported", which is the correct behaviour for Stalwart. + ## The problem is that logically, this feature would also be "unsupported" if the exception + ## information was simply discarded, and the current search behaviour would in + ## such a case be incorrect if the exception is simply discarded. + "save-load.event.recurrences.exception": {"description": "When a VCALENDAR containing a master VEVENT (with RRULE) and exception VEVENT(s) (with RECURRENCE-ID) is stored, the server keeps them together as a single calendar object resource. When unsupported, the server splits exception VEVENTs into separate calendar objects, making client-side expansion unreliable (the master expands without knowing about its exceptions)."}, "save-load.todo": {"description": "it's possible to save and load tasks to the calendar"}, "save-load.todo.recurrences": {"description": "it's possible to save and load recurring tasks to the calendar"}, "save-load.todo.recurrences.count": {"description": "The server will receive and store a recurring task with a count set in the RRULE", "default": {"support": "full"}}, @@ -1014,6 +1024,7 @@ def dotted_feature_set_list(self, compact=False): 'auto-connect.url': {'basepath': '/ucaldav/'}, 'save-load.journal': {'support': 'ungraceful'}, 'save-load.todo.recurrences.thisandfuture': {'support': 'ungraceful'}, + 'save-load.event.recurrences.exception': False, ## search.time-range.alarm: not checked by the server tester 'search.time-range.alarm': {'support': 'unsupported'}, ## Huh? Non-deterministic behaviour of the checking script? @@ -1365,8 +1376,27 @@ def dotted_feature_set_list(self, compact=False): }, 'create-calendar.auto': True, 'principal-search': {'support': 'ungraceful'}, - 'search.recurrences.expanded.exception': False, 'search.time-range.alarm': False, + ## Stalwart supports implicit recurrence for datetime events but not for + ## all-day (VALUE=DATE) recurring events in time-range searches. + 'search.recurrences.includes-implicit.event': {'support': 'fragile', 'behaviour': 'broken for all-day (VALUE=DATE) events'}, + ## Stalwart returns the recurring todo in search results but doesn't return the + ## RRULE intact, so client-side expansion can't expand it to specific occurrences. + 'search.recurrences.includes-implicit.todo': {'support': 'fragile'}, + ## Stalwart doesn't handle exceptions properly in server-side CALDAV:expand: + ## returns 3 items instead of 2 for a recurring event with one exception + ## (the exception is stored as a separate object and returned twice). + 'search.recurrences.expanded.exception': False, + ## Stalwart doesn't store master+exception VEVENTs correctly as a single resource + ## (returns 3 VEVENTs instead of 2 when the master+exception event is expanded). + ## Since server-side expansion is also broken, both paths give wrong results. + 'save-load.event.recurrences.exception': {'support': 'unsupported'}, + 'old_flags': [ + ## Stalwart does not return VTODO items without DTSTART in date searches + 'vtodo_datesearch_nodtstart_task_is_skipped', + ## Stalwart does not return results for open-ended date searches on VTODOs + 'no_search_openended', + ], } ## Lots of transient problems with purelymail diff --git a/caldav/search.py b/caldav/search.py index 7ace8c1e..85bc1aa0 100644 --- a/caldav/search.py +++ b/caldav/search.py @@ -273,6 +273,17 @@ def _search_impl( if not self.expand and not server_expand: split_expanded = False + ## If the server stores exception VEVENTs as separate calendar objects, client-side + ## expansion is unreliable (the master expands without knowing its exceptions, yielding + ## duplicate occurrences). Fall back to server-side expansion when it handles exceptions. + if ( + self.expand + and not server_expand + and not calendar.client.features.is_supported("save-load.event.recurrences.exception") + and calendar.client.features.is_supported("search.recurrences.expanded.exception") + ): + server_expand = True + if self.expand or server_expand: if not self.start or not self.end: raise error.ReportError("can't expand without a date range") diff --git a/pyproject.toml b/pyproject.toml index 1cbf742b..dcc67e05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dependencies = [ "niquests", "recurring-ical-events>=2.0.0", "typing_extensions;python_version<'3.11'", - "icalendar>7.0.0", + "icalendar>6.0.0", "icalendar-searcher>=1.0.5,<2", "dnspython", "python-dateutil", @@ -79,7 +79,7 @@ test = [ "manuel", "proxy.py", "tzlocal", - "xandikos>=0.2.12", + "xandikos>=0.3.3", "radicale", "pyfakefs", "httpx", diff --git a/tests/test_async_davclient.py b/tests/test_async_davclient.py index 3ad5a3b1..78debca7 100644 --- a/tests/test_async_davclient.py +++ b/tests/test_async_davclient.py @@ -821,6 +821,61 @@ def test_has_component_with_only_vcalendar(self) -> None: assert obj.has_component() is False +class TestAsyncCalendarAddObject: + """Tests for Calendar.add_object/add_event/add_todo with async clients (issue #631).""" + + SIMPLE_EVENT = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:test-async-add-event@example.com +DTSTART:20200101T100000Z +DTEND:20200101T110000Z +SUMMARY:Test Async Add Event +END:VEVENT +END:VCALENDAR""" + + @pytest.mark.asyncio + async def test_add_event_returns_coroutine_with_async_client(self) -> None: + """Calendar.add_event() must be awaitable when using AsyncDAVClient. + + Regression test for issue #631: o.save() returns a coroutine for async + clients, so add_object() must await it instead of doing o.url on the + coroutine object. + """ + import inspect + + from caldav.aio import AsyncEvent + from caldav.collection import Calendar + + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + calendar = Calendar(client=client, url="https://caldav.example.com/dav/calendars/test/") + + with patch.object(AsyncEvent, "_async_create", new_callable=AsyncMock): + result = calendar.add_event(self.SIMPLE_EVENT) + # With an async client, add_event must return a coroutine + assert inspect.isawaitable(result), ( + "add_event() should return a coroutine when using AsyncDAVClient, " + "got %r instead" % result + ) + event = await result + assert isinstance(event, AsyncEvent) + + @pytest.mark.asyncio + async def test_add_event_result_has_url(self) -> None: + """Awaiting add_event() with async client returns an Event with a URL.""" + from caldav.aio import AsyncEvent + from caldav.collection import Calendar + + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + calendar = Calendar(client=client, url="https://caldav.example.com/dav/calendars/test/") + + with patch.object(AsyncEvent, "_async_create", new_callable=AsyncMock): + event = await calendar.add_event(self.SIMPLE_EVENT) + # Should have a URL set (or None, but not crash) + _ = event.url # must not raise AttributeError + + class TestAsyncRateLimiting: """ Unit tests for 429/503 rate-limit handling in AsyncDAVClient. diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 2463c61b..9f6b2fdc 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -762,7 +762,7 @@ def setup_method(self): foo = self.is_supported("rate-limit", dict) if foo.get("enable"): - rate_delay = foo["interval"] / foo["count"] + rate_delay = foo.get("interval", 0) / foo.get("count", 1) self.caldav.request = _delay_decorator(self.caldav.request, t=rate_delay) foo = self.is_supported("search-cache", dict) if foo.get("behaviour") == "delay": @@ -2642,9 +2642,9 @@ def testTodoDatesearch(self): todos2 = c.search(start=datetime(2025, 4, 14), todo=True, include_completed=True) todos3 = c.search(start=datetime(2025, 4, 14), todo=True) - assert isinstance(todos1[0], Todo) - assert isinstance(todos2[0], Todo) if not self.check_compatibility_flag("no_search_openended"): + assert isinstance(todos1[0], Todo) + assert isinstance(todos2[0], Todo) assert isinstance(todos3[0], Todo) ## * t6 should be returned, as it's a yearly task spanning over 2025 @@ -3283,12 +3283,19 @@ def testRecurringDateWithExceptionSearch(self): server_expand=True, ) - assert len(r) == 2 - if self.is_supported("search.recurrences.expanded.event"): - assert len(rs) == 2 + ## Only assert exact count and RRULE-free output when exception handling + ## is reliable (either client-side or server-side expansion works correctly). + if self.is_supported("save-load.event.recurrences.exception") or self.is_supported( + "search.recurrences.expanded.exception" + ): + assert len(r) == 2 + assert "RRULE" not in r[0].data + assert "RRULE" not in r[1].data - assert "RRULE" not in r[0].data - assert "RRULE" not in r[1].data + if self.is_supported("search.recurrences.expanded.event") and self.is_supported( + "search.recurrences.expanded.exception" + ): + assert len(rs) == 2 asserts_on_results = [r] if self.is_supported("search.recurrences.expanded.exception"):