Skip to content

Commit a13e731

Browse files
authored
Merge pull request #770 from python-caldav/chore/compat-hints-sync-limit
chore: add DAV:limit and If-Match: * hints
2 parents 4a3b21a + 176c587 commit a13e731

6 files changed

Lines changed: 66 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,13 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
3333
* 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
3434
* 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
3535
* 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
36+
* 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.
3637

3738
### Changed
3839

3940
* icalendar-searcher 1.1.0 or later is required (for the negated searches).
4041
* `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.
4142

42-
* 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.
43-
4443
### Fixed
4544

4645
* `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.

‎caldav/compatibility_hints.py‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,7 @@ class FeatureSet:
697697
"links": ["https://datatracker.ietf.org/doc/html/rfc6578#section-3.2"],
698698
},
699699
"sync-token.limit": {
700-
"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",
700+
"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",
701701
"links": ["https://datatracker.ietf.org/doc/html/rfc6578#section-3.7"],
702702
},
703703
"sync-token.limit.initial-sync": {
@@ -1508,6 +1508,19 @@ def compare(self, observed):
15081508
## Observed with Nextcloud 33: server delivers iTIP notification to the inbox AND
15091509
## auto-schedules into the attendee's calendar.
15101510
'scheduling.schedule-tag': False,
1511+
## Probed 2026-10-13 by the server tester. From an issued token the
1512+
## reply is cut at DAV:limit without a 507, and the newest token comes
1513+
## back, so the changes past the limit are never reported:
1514+
## getChangesForCalendar() applies the limit to the query but always
1515+
## returns the current token.
1516+
## https://github.com/nextcloud/server/blob/5da6d15f5242717f792bd276339d3a7f95b0012b/apps/dav/lib/CalDAV/CalDavBackend.php#L2870-L2914
1517+
## An initial sync with DAV:limit is refused with a 507 and
1518+
## number-of-matches-within-limits, as RFC 6578 section 3.7 says.
1519+
'sync-token.limit': {
1520+
'support': 'broken',
1521+
'behaviour': 'the reply is cut at DAV:limit without a 507, and the token skips the rest',
1522+
},
1523+
'sync-token.limit.initial-sync': {'support': 'unsupported', 'behaviour': 'refused with 507 number-of-matches-within-limits'},
15111524
}
15121525

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

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

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

26772702
## Yahoo Calendar (https://caldav.calendar.yahoo.com/), a hosted service.

‎tests/fixture_helpers.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,14 @@ async def afix_calendar(
335335
):
336336
calendar_name = None
337337

338-
if principal is not None and _supports(client, "delete-calendar.free-namespace"):
338+
## Without create-calendar there is nothing to recreate, so a leftover must
339+
## not be deleted - and the calendar at cal_id most likely never existed
340+
## (Posteo answers the DELETE of a missing collection with 403, not 404).
341+
if (
342+
principal is not None
343+
and _supports(client, "create-calendar")
344+
and _supports(client, "delete-calendar.free-namespace")
345+
):
339346
try:
340347
await adelete_calendar_if_present(principal, cal_id)
341348
except error.DeleteError:

‎tests/test_async_integration.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2143,7 +2143,8 @@ async def test_edit_single_recurrence(self, async_calendar: Any) -> None:
21432143
## search.time-range.event.old-dates) can still serve the time ranges.
21442144
## The integer passed to search()/summary_on() is a day offset from this
21452145
## anchor day; the values just need to be distinct future days.
2146-
base = (datetime.now() + timedelta(days=2)).replace(
2146+
## UTC, as in the sync testEditSingleRecurrence (DST-safe there).
2147+
base = (datetime.now(timezone.utc) + timedelta(days=2)).replace(
21472148
hour=8, minute=7, second=6, microsecond=0
21482149
)
21492150

‎tests/test_caldav.py‎

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

‎tests/test_fixture_helpers.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,30 @@ async def delete(wipe: bool | None = None) -> None:
318318
assert calendar is not leftover
319319

320320

321+
@pytest.mark.asyncio
322+
async def test_afix_calendar_keeps_calendar_when_it_cannot_be_recreated() -> None:
323+
"""Without create-calendar there is no pre-delete: what is there gets reused.
324+
325+
Posteo answers the DELETE of a calendar it does not have with 403, which
326+
used to error out every async test taking a calendar fixture.
327+
"""
328+
from caldav.lib.error import AuthorizationError
329+
330+
existing = FakeCalendar(url="http://dav.example.com/testcal/")
331+
client = FakeClient({"create-calendar": False})
332+
principal = FakePrincipal({"testcal": existing})
333+
334+
async def delete(wipe: bool | None = None) -> None:
335+
raise AuthorizationError("Forbidden")
336+
337+
existing.delete = delete # type: ignore[method-assign]
338+
339+
calendar, created = await afix_calendar(client, principal, cal_id="testcal")
340+
341+
assert calendar is existing
342+
assert not created
343+
344+
321345
@pytest.mark.parametrize(
322346
"hints",
323347
[

0 commit comments

Comments
 (0)