From 13fa2d8fe68e38dee4c56904741e629f254e5ea5 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 20 Oct 2026 12:29:21 +0200 Subject: [PATCH] feat: cancel() marks an object as cancelled cancel() sets STATUS:CANCELLED and saves, on the overrides of a series too. On a single occurrence from search(expand=True) it becomes a cancelled override in the series, for an occurrence that was planned but will not take place. Zimbra turns such an override into an EXDATE; a new compatibility feature, save-load.event.recurrences.exception.cancelled, records that. Split out of the work on taking an occurrence out of a series with delete(), which is postponed. Part of https://github.com/python-caldav/caldav/issues/598 Prompt: Fix 782, 799, 598, 795, 805, 806 [python-caldav issues; this commit is https://github.com/python-caldav/caldav/issues/598]. The small ones can share one PR, the bigger ones should probably have separated PRs. (...) Followup-Prompt: Check the updates on the issue - the EXDATE and .delete()-logic here will be procrastinated. However, this branch also implements `.cancel()` and a _delete_headers refacotring (sic) which is "needed". (that's what the other session said - it's 391k tokens big, so it's probably cheaper to work on this from a clean context?). Please create a new branch for `.cancel()`, and consider if the `sync/async twin _delete_headers`-refactoring is also needed. Push as a PR referencing but not closing the issue. Rebase and push the original branch (no review needed) Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- caldav/calendarobjectresource.py | 19 +++++ caldav/compatibility_hints.py | 6 ++ changes/598.added.md | 1 + docs/source/tutorial.rst | 2 + tests/test_async_integration.py | 32 +++++++++ tests/test_caldav.py | 36 ++++++++++ tests/test_caldav_unit.py | 120 +++++++++++++++++++++++++++++++ 7 files changed, 216 insertions(+) create mode 100644 changes/598.added.md diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index 1f55b485..05a628e9 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -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. diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 59770c57..bd68a8c7 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -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" } @@ -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"). diff --git a/changes/598.added.md b/changes/598.added.md new file mode 100644 index 00000000..9f6f6fd6 --- /dev/null +++ b/changes/598.added.md @@ -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`. diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 1054b918..6b881518 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -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 ----- diff --git a/tests/test_async_integration.py b/tests/test_async_integration.py index 1f941b46..404b3a27 100644 --- a/tests/test_async_integration.py +++ b/tests/test_async_integration.py @@ -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.""" diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 5534252f..46e6950f 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -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 diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index 74732943..ec239f33 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -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 ( + '\n' + '' + "/dav/user/mycal/daily.ics" + f'"e1"{data}' + "HTTP/1.1 200 OK" + "" + ).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