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
19 changes: 19 additions & 0 deletions caldav/calendarobjectresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -1350,6 +1350,25 @@ def delete(
headers = {"if-match": if_match}
return self._delete(headers)

def cancel(self, **kwargs: Any) -> "Self | Coroutine[Any, Any, Self]":
"""Mark the object as cancelled (``STATUS:CANCELLED``) and save it.

On a single occurrence, as from ``search(..., expand=True)``, it
cancels that occurrence only: it becomes an override in its
series, which stays in the calendar. This is for an occurrence
that was planned but will not take place. On a whole series,
the overrides of it are cancelled too. ``kwargs`` are passed to
:meth:`save`, and its return value is returned - for an
occurrence, that is the series it was saved into. See
https://github.com/python-caldav/caldav/issues/598
"""
for component in self.icalendar_instance.subcomponents:
if isinstance(component, icalendar.Timezone):
continue
component.pop("STATUS", None)
component.add("STATUS", "CANCELLED")
return self.save(**kwargs)

@staticmethod
def _raise_uid_conflict(e: error.DAVError, consistency: bool = False) -> None:
"""Raise UidConflictError if ``e`` names a UID precondition.
Expand Down
6 changes: 6 additions & 0 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ class FeatureSet:
## 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.event.recurrences.exception.reschedule": {"description": "The server accepts a PUT that reschedules an entire recurring event - changing the master VEVENT's DTSTART (re-anchoring the whole series) while detached exception VEVENT(s) (with RECURRENCE-ID) are present and their RECURRENCE-IDs are shifted to line up with the new series. This is 'ungraceful' for Ox, the server rejects such a PUT with 409 Conflict even when a matching If-Match etag is supplied. Rescheduling a recurring event that has no exceptions still works. Exercised by save(all_recurrences=True) after changing dtstart/dtend.", "default": {"support": "full"}},
"save-load.event.recurrences.exception.cancelled": {"description": "An exception VEVENT (with RECURRENCE-ID) marked STATUS:CANCELLED, as Event.cancel() makes of an occurrence, is stored as such. Zimbra turns it into an EXDATE on the master instead, so the occurrence is gone rather than shown as cancelled.", "default": {"support": "full"}},
"save-load.todo": {
"description": "it's possible to save and load tasks to the calendar",
"default": { "support": "full" }
Expand Down Expand Up @@ -1584,6 +1585,11 @@ def compare(self, observed):
zimbra = {
## Measured with caldav-server-tester, 2026-10-19.
"save.no-uid-conflict": {"support": "full"},
## Observed by testCancelSingleRecurrence, 2026-10-20
"save-load.event.recurrences.exception.cancelled": {
"support": "unsupported",
"behaviour": "turned into an EXDATE",
},
'auto-connect.url': {'basepath': '/dav/'},
## Genuinely returns matching objects for a comp-type-less query that carries
## a time-range (verified: the event is returned, not just "no error").
Expand Down
1 change: 1 addition & 0 deletions changes/598.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`cancel()` marks an object, or a single occurrence from `search(..., expand=True)`, as `STATUS:CANCELLED` and saves it - for an occurrence that was planned but will not take place. A new compatibility feature, `save-load.event.recurrences.exception.cancelled`, records that Zimbra turns a cancelled occurrence into an `EXDATE`.
2 changes: 2 additions & 0 deletions docs/source/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ This is not best practice - the thing above may even break due to line wrapping,

How does the new may17-event look from a technical point of view, when we're editing only the 2026-edition? Enable the breakpoint and find out! Use ``.get_icalendar_instance()`` or ``.data``

A single recurrence from an expanded search can also be cancelled: ``my_events[0].cancel()`` marks only the 2026-edition as cancelled (``STATUS:CANCELLED``), while the other years stay. On most servers, the cancelled recurrence stays in the calendar.


Tasks
-----
Expand Down
32 changes: 32 additions & 0 deletions tests/test_async_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1450,6 +1450,38 @@ async def test_recurring_date_search(self, async_calendar: Any) -> None:
assert "RRULE" not in r2[0].data
assert "RRULE" not in r2[1].data

@pytest.mark.asyncio
async def test_cancel_single_recurrence(self, async_calendar: Any) -> None:
"""Async twin of testCancelSingleRecurrence: cancel() keeps an
occurrence in its series, as cancelled."""
self.skip_unless_support("search.recurrences.includes-implicit.event")
c = async_calendar
base = (datetime.now(timezone.utc) + timedelta(days=2)).replace(
hour=8, minute=7, second=6, microsecond=0
)
await c.add_event(
uid="test1",
summary="daily test",
dtstart=base,
dtend=base + timedelta(hours=1),
rrule={"FREQ": "DAILY"},
)

async def search(offset: int) -> list:
day = (base + timedelta(days=offset)).replace(hour=0, minute=0, second=0)
return await c.search(event=True, start=day, end=day + timedelta(days=1), expand=True)

[occurrence] = await search(3)
await occurrence.cancel()
assert len(await search(4)) == 1
## checked on the stored series, as a cancelled occurrence may be
## left out of search results
series = await c.get_event_by_uid("test1")
if self.is_supported("save-load.event.recurrences.exception.cancelled"):
assert [str(x["STATUS"]) for x in series.icalendar_instance.walk("VEVENT")[1:]] == [
"CANCELLED"
]

@pytest.mark.asyncio
async def test_recurring_date_with_exception_search(self, async_calendar: Any) -> None:
"""Bi-weekly event with exception: expanded search returns correct RECURRENCE-IDs."""
Expand Down
36 changes: 36 additions & 0 deletions tests/test_caldav.py
Original file line number Diff line number Diff line change
Expand Up @@ -4729,6 +4729,42 @@ def testRecurringDateWithExceptionSearch(self):
datetime(2024, 4, 25, 12, 30, 0),
}

def testCancelSingleRecurrence(self):
"""
cancel() on an occurrence from an expanded search keeps it in the
series, as an override with STATUS:CANCELLED. Issue
https://github.com/python-caldav/caldav/issues/598
"""
self.skip_unless_support("search.recurrences.includes-implicit.event")
cal = self._fixCalendar()
## future days, in UTC - see testEditSingleRecurrence
base = (datetime.now(timezone.utc) + timedelta(days=2)).replace(
hour=8, minute=7, second=6, microsecond=0
)
cal.add_event(
uid="test1",
summary="daily test",
dtstart=base,
dtend=base + timedelta(hours=1),
rrule={"FREQ": "DAILY"},
)

def search(offset):
day = (base + timedelta(days=offset)).replace(hour=0, minute=0, second=0)
return cal.search(event=True, start=day, end=day + timedelta(days=1), expand=True)

[occurrence] = search(3)
occurrence.cancel()
assert len(search(4)) == 1

## checked on the stored series, as a cancelled occurrence may be
## left out of search results
series = cal.get_event_by_uid("test1")
if self.is_supported("save-load.event.recurrences.exception.cancelled"):
assert [str(x["STATUS"]) for x in series.icalendar_instance.walk("VEVENT")[1:]] == [
"CANCELLED"
]

def testEditSingleRecurrence(self):
"""
It should be possible to fetch a single recurrence from
Expand Down
120 changes: 120 additions & 0 deletions tests/test_caldav_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -7945,6 +7945,126 @@ def _event(self, client, data=ev1, etag=None):
_run = staticmethod(TestPreconditionsOnOtherStatuses._run)


SERIES = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:daily
DTSTAMP:20260101T000000Z
DTSTART:20260105T080000Z
DTEND:20260105T090000Z
SEQUENCE:2
RRULE:FREQ=DAILY
SUMMARY:daily
END:VEVENT
BEGIN:VEVENT
UID:daily
DTSTAMP:20260101T000000Z
RECURRENCE-ID:20260107T080000Z
DTSTART:20260107T100000Z
DTEND:20260107T110000Z
SUMMARY:moved
END:VEVENT
END:VCALENDAR
"""


def _occurrence(series: str, rid: str) -> str:
"""An occurrence as search(expand=True) returns it."""
return (
series.split("BEGIN:VEVENT")[0]
+ "BEGIN:VEVENT\nUID:daily\nDTSTAMP:20260101T000000Z\n"
+ f"RECURRENCE-ID:{rid}\nDTSTART:{rid}\nSUMMARY:daily\nEND:VEVENT\nEND:VCALENDAR\n"
)


def _multistatus(data: str) -> bytes:
"""A REPORT answer holding ``data`` at daily.ics."""
return (
'<?xml version="1.0" encoding="utf-8"?>\n'
'<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<D:response><D:href>/dav/user/mycal/daily.ics</D:href><D:propstat><D:prop>"
f'<D:getetag>"e1"</D:getetag><C:calendar-data>{data}</C:calendar-data></D:prop>'
"<D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>"
"</D:multistatus>"
).encode()


class TestCancel(_SaveRequests):
"""cancel() sets STATUS:CANCELLED and saves; on an occurrence, only
that occurrence is cancelled (issue
https://github.com/python-caldav/caldav/issues/598)."""

def _series_client(self, base, series=SERIES):
sent = []
client = base(url="http://cal.example.com/dav/")
statuses = {"GET": 200, "REPORT": 207, "PUT": 204}

def request(url, method="GET", body="", headers=None):
method = method.upper()
sent.append((method, body))
resp = mock.MagicMock()
resp.status_code = statuses[method]
resp.reason = "whatever"
## save() of an occurrence finds the series by its UID
content = {"GET": series.encode(), "REPORT": _multistatus(series)}.get(method, b"")
content_type = "text/calendar" if method == "GET" else "application/xml"
## a plain dict: the key is spelled as the client looks it up
resp.headers = {"Content-Type": content_type, "Etag": '"e1"'} if content else {}
resp.content = content
resp.text = content.decode()
return DAVResponse(resp, client)

if base is DAVClient:
client.request = lambda url, method="GET", body="", headers=None: request(
url, method, body, headers
)
else:

async def async_request(url, method="GET", body="", headers=None):
return request(url, method, body, headers)

client.request = async_request
return client, sent

def _instance(self, client, data):
cal = Calendar(client, url=self.URL)
return Event(client, data=data, parent=cal, url=self.URL + "daily.ics")

def _put(self, sent):
[body] = [body for method, body in sent if method == "PUT"]
return icalendar.Calendar.from_ical(to_normal_str(body))

def test_cancel_occurrence(self, base) -> None:
client, sent = self._series_client(base)
occurrence = self._instance(client, _occurrence(SERIES, "20260106T080000Z"))
self._run(occurrence.cancel())
master, override, cancelled = self._put(sent).walk("VEVENT")
assert "EXDATE" not in master and "STATUS" not in master
assert str(override["SUMMARY"]) == "moved"
assert str(cancelled["STATUS"]) == "CANCELLED"
assert cancelled["RECURRENCE-ID"].dt == datetime(2026, 1, 6, 8, tzinfo=timezone.utc)

def test_cancel_series_cancels_overrides(self, base) -> None:
"""An override replaces its occurrence, so it is cancelled too."""
client, sent = self._series_client(base)
self._run(self._instance(client, SERIES).cancel())
assert [str(x["STATUS"]) for x in self._put(sent).walk("VEVENT")] == [
"CANCELLED",
"CANCELLED",
]

def test_cancel_replaces_status(self, base) -> None:
client, sent = self._series_client(base)
single = (
SERIES.split("RRULE:FREQ=DAILY\n")[0] + "STATUS:CONFIRMED\nEND:VEVENT\nEND:VCALENDAR\n"
)
self._run(self._instance(client, single).cancel())
[event] = self._put(sent).walk("VEVENT")
assert event.to_ical().count(b"STATUS") == 1
assert str(event["STATUS"]) == "CANCELLED"


class TestNoOverwriteIfNoneMatch(_SaveRequests):
"""save(no_overwrite=True) sends If-None-Match: * (RFC 9110 section
13.1.2), so the server refuses an overwrite of the same URL in the
Expand Down
Loading