caldav v3.5.0a0 - v3.5.0 without the niquests dependency
Yet another release primarily focusing on bugfixes and compatibility. iCloud has now been tested and got its compatibility profile in compatibility_hints.py. Test runs towards GMX, Posteo and Purelymail have been skipped for the last few releases; with 3.5.0 the tests pass towards those. More work on getting synchronization right, and on avoiding overwriting changes when multiple clients update the same object.
Also released without the niquests dependency as 3.5.0a0.
Security
- A small DoS-risk found. An attacker controlling the icalendar data (a hostile server, a shared calendar, calendar invites, etc.) flowing through the library can aggressively burn CPU-cycles.
vcal.fix(), run on every iCalendar object fetched from a server, took quadratic time on a long run of blanks or backslashes - a 100 kB icalendar fragment was measured to take 20 s. The regexp has been fixed up and now takes linear time to run; the output is unchanged. Reported by @BrianWillows. GHSA-5h4q-xm57-3mh5 Also fixed in 3.4.1 and 3.3.2.
Potentially breaking changes
These are bug fixes, but code relying on the old behaviour may notice them.
get_calendars()raisesNotFoundErrororPropfindErrorwhen the server answers the PROPFIND for the calendar home or the calendar list with an error. Earlier it would return an empty list. Transient errors may happen with any server. The empty list could not be told apart from an account without calendars, and a Home Assistant user was reporting that their calendars would sometimes "disappear". Code that relied on getting an empty list on such errors must now catch the exception; 2.x raised as well.NotFoundErroris raised for a 404,PropfindErrorotherwise, and the same goes forCalendarSet.get_calendars(). #741 #742- A time-range search (also
date_search()) filters the result client-side by default and drops objects the server returned outside the range. This affects every server, for users not in UTC: CalDAV servers compare all-day events and floating times as UTC, so a search in another time zone missed all-day events at the edges of the range and returned some from the neighbour days (reported on Baïkal). The server is now asked for one extra day on each side (also for alarm searches and server-side expansion), and the exact cut is made client-side: with a timezone-awarestart/end, all-day events and floating times are matched in the time zone of the search, not in that of the Python process. A search may therefore return fewer objects than before, and a plaindateasstart/endnow means a day in UTC, also for matching all-day events (previously, client-side, a day in the time zone of the Python process). Passpost_filter=Falseorcompatibility_workarounds=Falseto send the range as given and get what the server returned. #545 #758 - A
save()on an object loaded throughsearch()ormultiget()sendsIf-Match(orIf-Schedule-Tag-Match) and raisesETagMismatchError(orScheduleTagMismatchError) if someone else changed the object since it was loaded. This affects every server that returns ETags, i.e. nearly all. Previously no ETag was requested in searches, so such a save silently overwrote the other client's change. Code that saves search results must now be prepared to reload the object and retry. (Servers sending only weak ETags, like GMX, still get an unconditional save.) #751 - Sync: a 401/403 on the sync-collection REPORT naming a precondition other than
DAV:valid-sync-token- or naming none, on a server configured with full sync-token support (not with nofeaturesconfigured, orfragile) - is raised instead of silently falling back to a full fetch. An expired sync token (RFC 6578 section 3.2) could not be told apart from a permission error, so a real permission problem was hidden behind an expensive full fetch. An expired token (also Xandikos' 412) and an unsupported sync-collection report (Zimbra) still lead to the fallback. #738 #756 #748 #759 FeatureSet.is_supported()gave inverted answers for server observations (e.g.get-current-user-principal.has-calendar) and client features (e.g.rate-limit) when asked for a bool. This concerns code querying the compatibility hints directly, not any particular server; code that compensated for the inverted answers will now be wrong. Asking for a server-peculiarity, tests-behaviour or client-hints feature as a bool now raisesTypeError, as no meaningful bool exists for those. #732- icalendar-searcher 1.1.0 or later is required, for the negated searches and the time-zone-correct cut of time-range searches. This matters to packagers and to environments pinning an older version. #750
Added
Library API
SynchronizableCalendarObjectCollection.deleted_urls: the URLs of the members a sync-collection report listed as removed (RFC 6578 section 3.2). They are still among the returned objects, as before, but a caller no longer has to guess from a missing ETag, andsync()no longer fetches each removed object to find out. The set is empty after the full-retrieval fallback, which reports no removals. PyCalendar/calendaring-sync#19 (comment)delete(if_match=...)on events, todos and journals: withif_match=Truethe DELETE carries the same preconditionsave()sends (If-Schedule-Tag-Matchif a Schedule-Tag is cached, elseIf-Matchwith the cached ETag); a string is sent asIf-Match. A 412 raisesScheduleTagMismatchError/ETagMismatchError, and a 404 raisesNotFoundErrorinstead of counting as success. Plaindelete()is unchanged.DAVClient.delete()takes aheadersargument, likeAsyncDAVClient.delete()already did. #740 #747save(precondition="etag")anddelete(if_match=True, precondition="etag")preferIf-Matchwith the cached ETag over a cached Schedule-Tag, so a 412 raisesETagMismatchErrorwhen anything changed, also an attendee's reply. UnderIf-Schedule-Tag-Match, which the default"auto"still prefers, the server merges such a reply into the write (RFC 6638 section 3.2.10), and a sync client was never told its copy was stale. With no strong ETag cached,"etag"falls back to the Schedule-Tag. #762 #764- Negated searches:
add_property_filter()passes the!=(alias<>),!containsanddefoperators of icalendar-searcher 1.1.0 on to the search. They are decided client-side (unlesspost_filter=False): the server query leaves them out, as no server has been probed fornegate-conditionsupport yet.build_search_xml_query()renders!containsas a negatedtext-match, and!=anddefas a bareprop-filter- a negatedtext-matchis a negated substring match, so it cannot express!=. #568 #750
Server compatibility
- A
features="icloud"profile, from a caldav-server-tester run by a user - the documentation already referred to it, but it did not exist. #730 #733 - Compatibility feature
sync-token.invalid-token: whether a sync token the server never issued is refused withDAV:valid-sync-tokenas RFC 6578 section 3.2 says. Xandikos and CCS answer some such tokens with a 500, and Stalwart, Ox and Purelymail with a report and no error. jelmer/xandikos#743 #752 - Compatibility features
sync-token.limitandsync-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. #754 #770 - Compatibility feature
propfind.principal-depth-1: whether a Depth: 1 PROPFIND on the principal URL works. GMX answers it with a 400. #755 - Compatibility feature
save-load.event.no-dtend: whether a VEVENT with neither DTEND nor DURATION is stored, as RFC 5545 section 3.6.1 allows. iCloud refuses one with a 404. python-caldav/caldav-server-tester#24 #772 - Compatibility feature
search-cachetakes afiltered-onlykey, saying only searches with a time range or a property filter come from the lagging cache while a plain listing is up to date at once. The library itself ignores it; the integration tests and the caldav-server-tester wait less where it is set. Set for Purelymail. #775
Fixed
Calendar discovery
- On a server that advertises no
calendar-home-set,Principal.calendar(cal_id=...),Principal.make_calendar()and their async twins resolved against the client URL, whileget_calendars()looks below the principal URL. They now use the principal URL as the calendar home too. #749 #755 get_calendars()returned an empty list when the client URL points directly at a calendar on a server that advertises nocalendar-home-setand keeps no calendars below the principal, such as a Nextcloud public calendar share (/remote.php/dav/public-calendars/<token>, principal/principals/system/public/). Without acalendar-home-set, the principal URL is still tried first (GMX); if it holds no calendars, the client URL is tried as well, as in 2.x. An error or a 401/403 on the client URL then means no calendars rather than an exception. Regression since 3.0. #744- iCloud: searches could fail with
ValueError: ... can't be joined with ...once the client had moved to the account'spNN-caldav.icloud.comhost, on calendars found before the move. Also, a configuredurl.encode-at.*feature was lost at the move. #730 #733
Searching and time ranges
- A naive
datetime.minordatetime.max(e.g. as the open end of a search) raisedOverflowErrorin time zones behind UTC (e.g. the Americas). It now converts, and a time that UTC cannot hold is clamped to the earliest or latest UTC time. A timezone-aware datetime within a day ofdatetime.minordatetime.maxis now converted from its own time zone rather than from the local one. #758 - On Linux with Python 3.13 and older, a time-range search starting before the year 1000 (e.g. at
datetime.min) sent an invalid date, such as10101T000000Zfor year 1: the year was not zero-padded to four digits. #758
Synchronization
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. #767Calendar.get_objects_by_sync_token()(and soobjects()andSynchronizableCalendarObjectCollection.sync()) threw away a truncated sync-collection reply - RFC 6578 section 3.6, a507response for the collection itself - and silently fell back to fetching the whole calendar. It now keeps the members and the new token, and repeats the REPORT with that token until the reply is complete, at mostCalendar.sync_max_pages(100) times. If the server stops early or keeps truncating, the returned collection hastruncatedset. So does a truncated initial sync on a server whose checkpoint token would skip the remaining objects (sync-token.limit.initial-syncbroken, Stalwart: it truncates every report at its maxResults setting, default 2000).SyncCollectionResult.truncatedandDAVResponse.sync_truncatedreport a truncated page to callers of the lower-level API. #737 #745AsyncDAVClient.sync_collection()raisedAttributeErroron every reply with a body, as it assigned to the read-onlyDAVResponse.sync_token. #745
ETags and concurrent updates
- A cached ETag or Schedule-Tag was kept after a
save()whose response carried none - common, since RFC 4791 section 5.3.4 forbids a strong ETag when the server altered the data - so the nextsave()sent a staleIf-Matchand raisedETagMismatchError. The stale tag is now dropped. #751 - A weak ETag (
W/"...") was sent asIf-Match, which can never match since If-Match compares strongly (RFC 9110 section 13.1.1); everysave()after anadd_event()raisedETagMismatchErroron servers answering a PUT with a weak ETag (seen on GMX). A weak ETag is no longer sent, so such an update goes out unconditionally, anddelete(if_match=True)with only a weak ETag cached raisesValueError. #751 - An ETag the server sent unasked in a search REPORT reply (seen on GMX) was cached as an lxml element instead of a string, so
obj.etagreturned an element, and lxml raised aFutureWarningwhen it was checked. #751 - A
save()refused because another object in the calendar holds the UID (CALDAV:no-uid-conflict, RFC 4791 section 5.3.2.1, orCALDAV:unique-scheduling-object-resource, RFC 6638 section 3.2.4.1) now raises the newUidConflictError, aPutError. Previously a 412 (Xandikos, Stalwart) on a save carryingIf-MatchraisedETagMismatchError, so "the UID is taken" looked like "someone changed the object"; and a 409 (Radicale) was retried once. Where earlier versions raisedETagMismatchError,ScheduleTagMismatchErrororAuthorizationError(a 403 from Cyrus), theUidConflictErroris a subclass of that as well, so existing handlers keep working. #756 #759
Server responses and error reporting
- An error response lost its body, so a 403 meaning "your sync token has expired" (RFC 6578 section 3.2) could not be told apart from a permission error, nor a 412 meaning the same (Xandikos) from any other failed REPORT. Every
DAVError(AuthorizationError,ReportError,PutError,ETagMismatchError, ...) raised for a failed response now carriesbodyandpreconditions, the latter listing the elements of aDAV:errorbody, e.g.["{DAV:}valid-sync-token"], whatever the status. For the effect on sync, see "Potentially breaking changes" above. #738 #756 #748 #759 - The async client raised an XML parse error instead of
AuthorizationErrorfor a 401/403 whose body claimed to be XML but was not (e.g. an HTML error page sent asapplication/xml). It now handles 401/403 like the sync client. #748
Calendar objects, tasks and invites
SEQUENCEhandling on the save paths - #739 #746:add_object(),add_event(),add_todo(),add_journal()(and the deprecatedsave_*()aliases) tookincrease_seqnoas an iCalendar property: it did not reachsave(), soSEQUENCEwas bumped anyway, and the bogus property was written to the object. It is now passed through, andsave_with_invites()accepts it too.- Saving a single modified recurrence bumped the master's
SEQUENCErather than the override's. The override is now bumped (an override withoutSEQUENCEgets the master's plus one) and the master left alone (withall_recurrences=Truethe master is still bumped). - Accepting, declining or tentatively accepting an invite bumped the
SEQUENCEon the attendee's copy. Only the organizer revises an event; replies no longer bump it.
- Completing a recurring task with
Todo.complete(handle_rrule=True, rrule_mode="this_and_future")raisedAttributeError; only the undeclared"thisandfuture"worked. Both spellings are now accepted. Fix by @muratatar06 in #736 #735 Todo.complete(handle_rrule=True, rrule_mode="safe")on a recurring task with no further occurrences (e.g.COUNT=1) ignored the givencompletion_timestampand stampedCOMPLETEDwith the current time. The sync path now passes it on, as the async one already did. #746- The same fallback, reached through an
RRULEwith bothCOUNTandUNTIL(invalid, but seen in the wild), saved the task withCOUNTlowered by one.COUNTis now only touched once a next occurrence is found. #746 change_attendee_status()now awaits async principal and address discovery when the attendee is omitted or a Principal is passed, filters empty addresses, and shares the principal URL and username fallbacks with invite replies. Explicit attendee strings remain synchronous. #678 #734- Empty
CATEGORIES:lines are dropped when calendar data is loaded. The icalendar library inserts one when thecategoriesof a component without categories is read, so such tasks could end up with bothCATEGORIES:and the real CATEGORIES line. Data saved from such a component is not cleaned until it is loaded again. Temporary workaround for collective/icalendar#1722 #773 - The warning logged when
vcal.fix()modifies icalendar data no longer says the calendar server broke the standard - the server, the client or a library in between may be the cause. #773
Configuration
compatibility_hints: the invertedFeatureSet.is_supported()answers (see "Potentially breaking changes" above) came from how the features were stored. An observation is now stored as{"value": <bool>}-set_feature(observation, False)writes{"value": False}rather than{"support": "unsupported"}- and read back as that; the legacyobservedkey andsupportlevels are still understood. A client-feature reads as itsenableflag, and a bool given for one is stored as{"enable": <bool>}. Asked for as a str, both spell the bool out ("True"/"False"). The Radicale, DAViCal and Davis profiles declare that a fresh account has no calendar. #732