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
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,13 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
* Compatibility feature `sync-token.invalid-token`: whether a sync token the server never issued is refused with `DAV:valid-sync-token` as RFC 6578 section 3.2 says. Xandikos and CCS answer some such tokens with a 500, and Stalwart and Ox with a report and no error. https://github.com/jelmer/xandikos/issues/743
* Compatibility feature `propfind.principal-depth-1`: whether a Depth: 1 PROPFIND on the principal URL works. GMX answers it with a 400. https://github.com/python-caldav/caldav/pull/755
* Negated searches: `add_property_filter()` passes the `!=` (alias `<>`), `!contains` and `def` operators of icalendar-searcher 1.1.0 on to the search. They are decided client-side (unless `post_filter=False`): the server query leaves them out, as no server has been probed for `negate-condition` support yet. `build_search_xml_query()` renders `!contains` as a negated `text-match`, and `!=` and `def` as a bare `prop-filter` - a negated `text-match` is a negated *substring* match, so it cannot express `!=`. https://github.com/python-caldav/caldav/issues/568
* Compatibility features `sync-token.limit` and `sync-token.limit.initial-sync`, for DAV:limit and the checkpoint tokens of a truncated sync-collection report (RFC 6578 sections 3.6 and 3.7). Hints for Stalwart, Xandikos, Radicale, Ba茂kal, Davis, Nextcloud, CCS and Infomaniak.

### Changed

* icalendar-searcher 1.1.0 or later is required (for the negated searches).
* `search()` and `multiget()` (also used to load search results in bulk) ask the server for the ETag and the Schedule-Tag of each object, so a `save()` after a search or multiget now sends `If-Match` (or `If-Schedule-Tag-Match`, RFC 6638 section 3.2), and raises `ETagMismatchError` (or `ScheduleTagMismatchError`) if someone else changed the object in the meantime. Previously such a save overwrote the other change silently.

* Compatibility features `sync-token.limit` and `sync-token.limit.initial-sync`, for DAV:limit and the checkpoint tokens of a truncated sync-collection report (RFC 6578 sections 3.6 and 3.7). Hints for Stalwart, Xandikos, Radicale and Ba茂kal.

### Fixed

* `SynchronizableCalendarObjectCollection.sync()` no longer returns objects the collection never held as deleted. Nextcloud reports a trashed object as removed when it purges it, e.g. because an object with the same UID is added again.
Expand Down
29 changes: 27 additions & 2 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,7 @@ class FeatureSet:
"links": ["https://datatracker.ietf.org/doc/html/rfc6578#section-3.2"],
},
"sync-token.limit": {
"description": "Server honours DAV:limit in a sync-collection report from an issued token: it truncates the reply with a 507 for the collection and a checkpoint token, and following the checkpoints yields every change. 'unsupported' means the limit is ignored and everything comes in one reply, which is harmless. 'broken' means changes are lost, e.g. the reply is cut without a 507 (Xandikos) or a checkpoint token skips changes",
"description": "Server honours DAV:limit in a sync-collection report from an issued token: it truncates the reply with a 507 for the collection and a checkpoint token, and following the checkpoints yields every change. 'unsupported' means the limit is ignored and everything comes in one reply, or the request is refused with a 507 carrying DAV:number-of-matches-within-limits as section 3.7 allows (CCS); both are harmless, as the library never sends DAV:limit. 'broken' means changes are lost, e.g. the reply is cut without a 507 (Xandikos, Nextcloud) or a checkpoint token skips changes",
"links": ["https://datatracker.ietf.org/doc/html/rfc6578#section-3.7"],
},
"sync-token.limit.initial-sync": {
Expand Down Expand Up @@ -1508,6 +1508,19 @@ def compare(self, observed):
## Observed with Nextcloud 33: server delivers iTIP notification to the inbox AND
## auto-schedules into the attendee's calendar.
'scheduling.schedule-tag': False,
## Probed 2026-10-13 by the server tester. From an issued token the
## reply is cut at DAV:limit without a 507, and the newest token comes
## back, so the changes past the limit are never reported:
## getChangesForCalendar() applies the limit to the query but always
## returns the current token.
## https://github.com/nextcloud/server/blob/5da6d15f5242717f792bd276339d3a7f95b0012b/apps/dav/lib/CalDAV/CalDavBackend.php#L2870-L2914
## An initial sync with DAV:limit is refused with a 507 and
## number-of-matches-within-limits, as RFC 6578 section 3.7 says.
'sync-token.limit': {
'support': 'broken',
'behaviour': 'the reply is cut at DAV:limit without a 507, and the token skips the rest',
},
'sync-token.limit.initial-sync': {'support': 'unsupported', 'behaviour': 'refused with 507 number-of-matches-within-limits'},
}

ecloud = nextcloud | {
Expand Down Expand Up @@ -2188,6 +2201,8 @@ def compare(self, observed):
'sync-token': {'support': 'ungraceful'},
'principal-search': {'support': 'unsupported'},
"scheduling": {"support": "unsupported"},
## Probed 2026-10-13. The profile predates the If-Match: * probe, added 2026-09-15.
'save-load.mutable.if-match-wildcard': {'support': 'broken', 'behaviour': 'If-Match: * is ignored: it created a missing object (201)'},
}

#calendar_mail_ru = [
Expand Down Expand Up @@ -2225,14 +2240,22 @@ def compare(self, observed):
"calendar-color": {"support": "full"},
"calendar-order": {"support": "full"},
## I'm surprised, I'm quite sure this was passing earlier. Caldav commit a98d50490b872e9b9d8e93e2e401c936ad193003, caldav server checker commit 3cae24cf99da1702b851b5a74a9b88c8e5317dad
'search.combined-is-logical-and': False
'search.combined-is-logical-and': False,
## Probed 2026-10-13 by the server tester: same as Baikal, from an issued
## token the limit is honoured, on an initial sync it is ignored
"sync-token.limit": {"support": "full"},
"sync-token.limit.initial-sync": {"support": "unsupported", "behaviour": "DAV:limit is ignored"},
}

## Apple CalendarServer (CCS) - archived 2019, Python 2/Twisted.
## MKCALENDAR always creates VEVENT-only calendars; supported-calendar-component-set
## cannot be changed. The pre-provisioned "tasks" calendar supports VTODO only.
## VJOURNAL is not supported at all.
ccs = {
## Probed 2026-10-13: DAV:limit is refused with a 507 and
## number-of-matches-within-limits ("Report limit not supported"), as
## RFC 6578 section 3.7 says
"sync-token.limit": {"support": "unsupported", "behaviour": "refused with 507 number-of-matches-within-limits"},
## Probed 2026-10-11: a 40-digit hex token gets 403 with valid-sync-token
"sync-token.invalid-token": {"support": "ungraceful", "behaviour": "'http://example.invalid/never-issued': 500 without valid-sync-token"},
"scheduling.freebusy-query": {"support": "ungraceful"},
Expand Down Expand Up @@ -2672,6 +2695,8 @@ def compare(self, observed):
## caldav's _validate_status turns into a ResponseError - the sync raises rather
## than silently coming back wrong, hence "ungraceful".
'sync-token.delete': {'support': 'ungraceful', 'behaviour': "418 I'm a teapot"},
## Probed 2026-10-13 by the server tester
'sync-token.limit': {'support': 'unsupported', 'behaviour': 'DAV:limit is ignored'},
}

## Yahoo Calendar (https://caldav.calendar.yahoo.com/), a hosted service.
Expand Down
9 changes: 8 additions & 1 deletion tests/fixture_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,14 @@ async def afix_calendar(
):
calendar_name = None

if principal is not None and _supports(client, "delete-calendar.free-namespace"):
## Without create-calendar there is nothing to recreate, so a leftover must
## not be deleted - and the calendar at cal_id most likely never existed
## (Posteo answers the DELETE of a missing collection with 403, not 404).
if (
principal is not None
and _supports(client, "create-calendar")
and _supports(client, "delete-calendar.free-namespace")
):
try:
await adelete_calendar_if_present(principal, cal_id)
except error.DeleteError:
Expand Down
3 changes: 2 additions & 1 deletion tests/test_async_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2143,7 +2143,8 @@ async def test_edit_single_recurrence(self, async_calendar: Any) -> None:
## search.time-range.event.old-dates) can still serve the time ranges.
## The integer passed to search()/summary_on() is a day offset from this
## anchor day; the values just need to be distinct future days.
base = (datetime.now() + timedelta(days=2)).replace(
## UTC, as in the sync testEditSingleRecurrence (DST-safe there).
base = (datetime.now(timezone.utc) + timedelta(days=2)).replace(
hour=8, minute=7, second=6, microsecond=0
)

Expand Down
5 changes: 4 additions & 1 deletion tests/test_caldav.py
Original file line number Diff line number Diff line change
Expand Up @@ -4649,7 +4649,10 @@ def testEditSingleRecurrence(self):
## search.time-range.event.old-dates) can still serve the time ranges.
## The integer passed to search()/summary_on() is a day offset from this
## anchor day; the values just need to be distinct future days.
base = (datetime.now() + timedelta(days=2)).replace(
## UTC, so that no DST change between the days shifts the wall clock
## (a series rescheduled to 08:00 local on a CET day would land at
## 09:00 on the CEST days before it).
base = (datetime.now(timezone.utc) + timedelta(days=2)).replace(
hour=8, minute=7, second=6, microsecond=0
)

Expand Down
24 changes: 24 additions & 0 deletions tests/test_fixture_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,30 @@ async def delete(wipe: bool | None = None) -> None:
assert calendar is not leftover


@pytest.mark.asyncio
async def test_afix_calendar_keeps_calendar_when_it_cannot_be_recreated() -> None:
"""Without create-calendar there is no pre-delete: what is there gets reused.

Posteo answers the DELETE of a calendar it does not have with 403, which
used to error out every async test taking a calendar fixture.
"""
from caldav.lib.error import AuthorizationError

existing = FakeCalendar(url="http://dav.example.com/testcal/")
client = FakeClient({"create-calendar": False})
principal = FakePrincipal({"testcal": existing})

async def delete(wipe: bool | None = None) -> None:
raise AuthorizationError("Forbidden")

existing.delete = delete # type: ignore[method-assign]

calendar, created = await afix_calendar(client, principal, cal_id="testcal")

assert calendar is existing
assert not created


@pytest.mark.parametrize(
"hints",
[
Expand Down
Loading