Skip to content

Commit 3cba87a

Browse files
tobixenclaude
andcommitted
Fix Stalwart test failures: add compatibility hints and guards
Three tests for TestForServerStalwart were failing: - testTodoDatesearch (assert 3==5, then DTSTART expansion check) - testRecurringDateSearch (assert 0==1) - testRecurringDateWithExceptionSearch (assert 3==2) Compatibility hints added for Stalwart: - search.recurrences.includes-implicit.event: fragile (works for datetime events but not for all-day/VALUE=DATE events) - search.recurrences.includes-implicit.todo: fragile (server returns recurring todos but expansion doesn't work reliably) - search.recurrences.expanded.exception: unsupported (server-side expansion is wrong for events with exceptions) - save-load.event.recurrences.exception: unsupported (Stalwart stores master+exception VEVENTs as separate calendar objects) - vtodo_datesearch_nodtstart_task_is_skipped old_flag (already existed in prior commits but consolidated here) - no_search_openended old_flag (open-ended VTODO date searches fail) Library behavior change (search.py): when expand=True is requested and the server splits exception VEVENTs into separate objects (unsupported save-load.event.recurrences.exception), automatically fall back to server-side CALDAV:expand when the server supports it correctly. Test changes: - testRecurringDateWithExceptionSearch: guard "assert len(r)==2" and "assert len(rs)==2" for servers that can't handle exceptions; the rs assertion now also requires expanded.exception support - testTodoDatesearch: guard isinstance(todos[0]) assertions with no_search_openended flag (consolidating todos1/todos2/todos3 under one condition) New FEATURES entry: save-load.event.recurrences.exception Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 212e723 commit 3cba87a

4 files changed

Lines changed: 61 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,15 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
2727
* 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).
2828
* Fixed `expand_simple_props()` return value handling.
2929

30+
### Added (compatibility)
31+
32+
* 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.
33+
* 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.
34+
3035
### Test Framework
3136

3237
* Added async rate-limit unit tests matching the sync test suite.
38+
* 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.
3339

3440
## [3.0.0a2] - 2026-02-25 (Alpha Release)
3541

caldav/compatibility_hints.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,16 @@ class FeatureSet:
130130
"save-load.event": {"description": "it's possible to save and load events to the calendar"},
131131
"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"},
132132
"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"}},
133+
## This was Claude's suggestion and it works as of today, the
134+
## "unsupported" description matches the behaviour of the Stalwart server.
135+
## Stalwart apparently (in a breach with the RFC) stores the exception
136+
## information as a separate CalendarObjectResource.
137+
## Currently the search logic will do server-side expansion
138+
## if this flag is set to "unsupported", which is the correct behaviour for Stalwart.
139+
## The problem is that logically, this feature would also be "unsupported" if the exception
140+
## information was simply discarded, and the current search behaviour would in
141+
## such a case be incorrect if the exception is simply discarded.
142+
"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)."},
133143
"save-load.todo": {"description": "it's possible to save and load tasks to the calendar"},
134144
"save-load.todo.recurrences": {"description": "it's possible to save and load recurring tasks to the calendar"},
135145
"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"}},
@@ -1365,8 +1375,27 @@ def dotted_feature_set_list(self, compact=False):
13651375
},
13661376
'create-calendar.auto': True,
13671377
'principal-search': {'support': 'ungraceful'},
1368-
'search.recurrences.expanded.exception': False,
13691378
'search.time-range.alarm': False,
1379+
## Stalwart supports implicit recurrence for datetime events but not for
1380+
## all-day (VALUE=DATE) recurring events in time-range searches.
1381+
'search.recurrences.includes-implicit.event': {'support': 'fragile', 'behaviour': 'broken for all-day (VALUE=DATE) events'},
1382+
## Stalwart returns the recurring todo in search results but doesn't return the
1383+
## RRULE intact, so client-side expansion can't expand it to specific occurrences.
1384+
'search.recurrences.includes-implicit.todo': {'support': 'fragile'},
1385+
## Stalwart doesn't handle exceptions properly in server-side CALDAV:expand:
1386+
## returns 3 items instead of 2 for a recurring event with one exception
1387+
## (the exception is stored as a separate object and returned twice).
1388+
'search.recurrences.expanded.exception': False,
1389+
## Stalwart doesn't store master+exception VEVENTs correctly as a single resource
1390+
## (returns 3 VEVENTs instead of 2 when the master+exception event is expanded).
1391+
## Since server-side expansion is also broken, both paths give wrong results.
1392+
'save-load.event.recurrences.exception': {'support': 'unsupported'},
1393+
'old_flags': [
1394+
## Stalwart does not return VTODO items without DTSTART in date searches
1395+
'vtodo_datesearch_nodtstart_task_is_skipped',
1396+
## Stalwart does not return results for open-ended date searches on VTODOs
1397+
'no_search_openended',
1398+
],
13701399
}
13711400

13721401
## Lots of transient problems with purelymail

caldav/search.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,17 @@ def _search_impl(
273273
if not self.expand and not server_expand:
274274
split_expanded = False
275275

276+
## If the server stores exception VEVENTs as separate calendar objects, client-side
277+
## expansion is unreliable (the master expands without knowing its exceptions, yielding
278+
## duplicate occurrences). Fall back to server-side expansion when it handles exceptions.
279+
if (
280+
self.expand
281+
and not server_expand
282+
and not calendar.client.features.is_supported("save-load.event.recurrences.exception")
283+
and calendar.client.features.is_supported("search.recurrences.expanded.exception")
284+
):
285+
server_expand = True
286+
276287
if self.expand or server_expand:
277288
if not self.start or not self.end:
278289
raise error.ReportError("can't expand without a date range")

tests/test_caldav.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2642,9 +2642,9 @@ def testTodoDatesearch(self):
26422642
todos2 = c.search(start=datetime(2025, 4, 14), todo=True, include_completed=True)
26432643
todos3 = c.search(start=datetime(2025, 4, 14), todo=True)
26442644

2645-
assert isinstance(todos1[0], Todo)
2646-
assert isinstance(todos2[0], Todo)
26472645
if not self.check_compatibility_flag("no_search_openended"):
2646+
assert isinstance(todos1[0], Todo)
2647+
assert isinstance(todos2[0], Todo)
26482648
assert isinstance(todos3[0], Todo)
26492649

26502650
## * t6 should be returned, as it's a yearly task spanning over 2025
@@ -3283,12 +3283,19 @@ def testRecurringDateWithExceptionSearch(self):
32833283
server_expand=True,
32843284
)
32853285

3286-
assert len(r) == 2
3287-
if self.is_supported("search.recurrences.expanded.event"):
3288-
assert len(rs) == 2
3286+
## Only assert exact count and RRULE-free output when exception handling
3287+
## is reliable (either client-side or server-side expansion works correctly).
3288+
if self.is_supported("save-load.event.recurrences.exception") or self.is_supported(
3289+
"search.recurrences.expanded.exception"
3290+
):
3291+
assert len(r) == 2
3292+
assert "RRULE" not in r[0].data
3293+
assert "RRULE" not in r[1].data
32893294

3290-
assert "RRULE" not in r[0].data
3291-
assert "RRULE" not in r[1].data
3295+
if self.is_supported("search.recurrences.expanded.event") and self.is_supported(
3296+
"search.recurrences.expanded.exception"
3297+
):
3298+
assert len(rs) == 2
32923299

32933300
asserts_on_results = [r]
32943301
if self.is_supported("search.recurrences.expanded.exception"):

0 commit comments

Comments
 (0)