Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand Down
9 changes: 9 additions & 0 deletions caldav/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
32 changes: 31 additions & 1 deletion caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions caldav/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -79,7 +79,7 @@ test = [
"manuel",
"proxy.py",
"tzlocal",
"xandikos>=0.2.12",
"xandikos>=0.3.3",
"radicale",
"pyfakefs",
"httpx",
Expand Down
55 changes: 55 additions & 0 deletions tests/test_async_davclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 15 additions & 8 deletions tests/test_caldav.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"):
Expand Down
Loading