From 442c9d6b451bacad00aeabc19d4d0a9e5433c329 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 22 Sep 2026 00:02:55 +0200 Subject: [PATCH 1/2] feat: add a Yahoo Calendar compatibility profile Transcribed from the caldav-server-tester run posted in issue 713, keeping only the gradings that deviate from the feature defaults. Prompt: Check the yahoo compatibility hints as presented in https://github.com/python-caldav/caldav/issues/713 and add them to compatibility_hints.py Assisted-By: Claude Opus 5 --- .lycheeignore | 1 + CHANGELOG.md | 1 + caldav/compatibility_hints.py | 117 ++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/.lycheeignore b/.lycheeignore index 7dd8e43a..d55ba923 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -13,6 +13,7 @@ http://.*@localhost:\d+/.* http://oxhost2:\d+/.* # CalDAV endpoints that require authentication (401/403 expected) +https://caldav\.calendar\.yahoo\.com/.* https://caldav\.fastmail\.com/.* https://caldav\.gmx\.net/.* https://caldav\.icloud\.com/.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb64eaa..12cd6773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The JMAP support was declared experimental in 3.0, hence the changes below are d ### Added * `compatibility_hints`: `auth.www-authenticate` records whether the server sends the `WWW-Authenticate` header RFC7235 section 3.1 requires on a 401, and `auth.www-authenticate.usable-scheme` whether the schemes it offers include one this library implements. A server failing either one never receives your password, and the 401 looks like a rejected one - so it needs `auth_type` pinned in the configuration, and a profile can now say which. Probed by caldav-server-tester. See https://github.com/python-caldav/caldav/issues/713. +* `compatibility_hints`: new server profile `yahoo`, for Yahoo Calendar (`https://caldav.calendar.yahoo.com/`), probed with caldav-server-tester. Note that the server sends no `WWW-Authenticate` header, so `auth_type` has to be pinned (`basic` works) before anything can be reached at all - see https://github.com/python-caldav/caldav/issues/713. The notable gradings: the comp-filter is silently ignored, `If-Match: *` holds backwards, sync-token and server-side recurrence handling are missing, and a created calendar is served under a numeric id rather than the requested name. * `compatibility_hints`: new feature `scheduling.calendar-user-address-set.populated`, for a server that advertises `calendar-user-address-set` but returns it empty. Graded `unsupported` for Xandikos. ### Changed diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 0825c067..8e1d50be 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -2496,6 +2496,123 @@ def compare(self, observed): 'sync-token.delete': {'support': 'ungraceful', 'behaviour': "418 I'm a teapot"}, } +## Yahoo Calendar (https://caldav.calendar.yahoo.com/), a hosted service. +## Probed 2026-09-21 with the caldav-server-tester, see +## https://github.com/python-caldav/caldav/issues/713 - the profile in that +## issue was run with CALDAV_AUTH_TYPE=basic, because the server cannot be +## reached at all without pinning the auth type (see auth.www-authenticate). +yahoo = { + 'auto-connect.url': { + 'scheme': 'https', + 'domain': 'caldav.calendar.yahoo.com', + 'basepath': '/', + }, + ## The 401 carries a JSON error body and no WWW-Authenticate header of any + ## kind, so there is no scheme to negotiate with: the credentials are never + ## transmitted and the bare 401 surfaces as an AuthorizationError. Pass + ## auth_type="basic" (verified to work) to get through. + 'auth.www-authenticate': { + 'support': 'unsupported', + 'behaviour': 'the 401 carries no WWW-Authenticate header at all, only a JSON error body', + }, + ## No challenge was ever seen, so there is nothing to judge the offered + ## schemes by - the parent above is the whole story. + 'auth.www-authenticate.usable-scheme': {'support': 'unknown'}, + ## Redirects to https://caldav.calendar.yahoo.com/principals/ + 'well-known': {'support': 'full'}, + 'get-current-user-principal.has-calendar': {'support': 'full'}, + + ## Colour and order are stored and read back (the colour name is + ## normalised: 'blue' comes back as '#0252D4'). Stated explicitly since + ## the feature default is the weaker 'fragile'. + 'calendar-color': {'support': 'full'}, + 'calendar-color.hex': {'support': 'full'}, + 'calendar-order': {'support': 'full'}, + + ## MKCALENDAR works and the display name given at creation time sticks, + ## but the collection is served under a numeric id of the server's own + ## choosing rather than under the requested name - so a client has to + ## adopt the canonical URL it is handed back. + 'create-calendar.stable-url': { + 'support': 'unsupported', + 'behaviour': "the created calendar's canonical URL segment is a numeric id (e.g. '869'), not the requested name", + }, + ## Inconclusive: the MKCALENDAR carrying a component-set restriction was + ## accepted, but the collection never became queryable, so whether the + ## restriction is honoured could not be established. + 'create-calendar.with-supported-component-types': { + 'support': 'unknown', + 'behaviour': 'the restricted calendar was accepted but never became queryable', + }, + + ## VJOURNAL is rejected. + 'save-load.journal': {'support': 'ungraceful'}, + ## A task may not share a calendar with events - it needs one of its own. + 'save-load.todo.mixed-calendar': {'support': 'unsupported'}, + ## RRULE:...;COUNT=n on a VTODO does not survive the round trip (the + ## feature carries an explicit 'full' default, hence stated here). + 'save-load.todo.recurrences.count': {'support': 'unsupported'}, + ## An event carrying timezone information is accepted by the PUT and + ## cannot be read back afterwards. + 'save-load.event.timezone': { + 'support': 'broken', + 'behaviour': 'Event with timezone was saved but could not be loaded', + }, + ## A RELATED-TO search is answered with 400 Bad Request. + 'save-load.icalendar.related-to': {'support': 'ungraceful', 'behaviour': '400 Bad Request'}, + ## The same UID in two calendars is refused with a PutError. + 'save.duplicate-uid.cross-calendar': {'support': 'ungraceful', 'behaviour': 'Server error: PutError'}, + ## Measured 2026-09-21: 412 on an object that exists, 201 on one that does + ## not - i.e. exactly the inverse of RFC9110 section 13.1.1. + 'save-load.mutable.if-match-wildcard': { + 'support': 'broken', + 'behaviour': 'If-Match: * holds backwards: refused with 412 on an existing object, and creates a missing object', + }, + + ## The CALDAV comp-filter is silently ignored: a calendar-query asking for + ## one component type returns the whole calendar regardless (a VJOURNAL + ## query returned a VEVENT, and so did a VTODO query). Nothing of the + ## right type is dropped, so the library recovers by post-filtering - + ## hence "unsupported" rather than "broken". + 'search.comp-type': { + 'support': 'unsupported', + 'behaviour': 'comp-filter silently ignored - the whole calendar is returned regardless of the requested component type', + }, + ## ... but leaving the comp-filter out, which the RFC permits, is an + ## error. Note the contrast with search.time-range.comp-type-optional + ## below: a filter-less query carrying a time-range is answered fine. + 'search.comp-type.optional': {'support': 'ungraceful'}, + 'search.time-range.comp-type-optional': {'support': 'full'}, + ## is-not-defined is ignored (all three probed children behave alike, so + ## the parent carries it). + 'search.is-not-defined': {'support': 'unsupported'}, + ## Text search works, but not on categories. + 'search.text.category': {'support': 'unsupported'}, + ## No server-side recurrence handling at all - neither expansion nor + ## implicit recurrences. The library does the expansion client-side. + 'search.recurrences.expanded': {'support': 'unsupported'}, + 'search.recurrences.includes-implicit': {'support': 'unsupported'}, + ## Carries an explicit 'full' default, so it does not inherit the above. + 'search.recurrences.includes-implicit.todo.pending': {'support': 'unsupported'}, + 'search.time-range.alarm': {'support': 'unsupported'}, + ## Dated searches work, including far-past ones, but a search without a + ## time range does not reach year-2000 objects. + 'search.unlimited-time-range': { + 'support': 'broken', + 'behaviour': 'far-past objects (year 2000) are outside the search window', + }, + + 'freebusy-query': {'support': 'ungraceful', 'behaviour': '500 Internal Server Error'}, + 'principal-search': {'support': 'ungraceful'}, + 'scheduling': {'support': 'unsupported'}, + 'sync-token': {'support': 'unsupported'}, + + ## Not concluded by the probe run, so left at their defaults and recorded + ## here only as "still open": save-load.get-by-url, save-load.stable-url, + ## save.etag, search.combined-is-logical-and and + ## url.encode-at.literal.principal. +} + # fmt: on From a6ce475bbcaa85ae3c607c342d354658c371e161 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 22 Sep 2026 00:14:19 +0200 Subject: [PATCH 2/2] ci: move lychee's .lycheeignore exclusion into the hook The "--exclude-path .lycheeignore" arg looked redundant, since lychee reads the URL patterns from that file on its own. It was not: the arg also kept the file out of the *inputs*, and pre-commit hands the hook every changed file - so on a push touching .lycheeignore, lychee read it as plain text, pulled 26 junk URLs out of the regexes ("https://posteo/" and friends) and failed. The arg is dropped and the file is excluded through pre-commit's own "exclude" instead. Prompt: drop the noop-thing from .pre-commit-config.yaml [the "--exclude-path .lycheeignore" arg, which I had wrongly called a no-op] Assisted-By: Claude Opus 5 --- .pre-commit-config.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a2489f42..59c57e0e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,6 +35,10 @@ repos: rev: lychee-v0.24.2 hooks: - id: lychee - args: ["--no-progress", "--timeout", "10", "--exclude-path", ".lycheeignore", "--max-cache-age=30d", "--cache"] + args: ["--no-progress", "--timeout", "10", "--max-cache-age=30d", "--cache"] stages: [pre-push] - exclude: ^tests/test_caldav_unit\.py$ + ## lychee reads the URL patterns from .lycheeignore by itself. The + ## dropped "--exclude-path .lycheeignore" arg did something else: it + ## kept that file out of the *inputs*, which is needed, as lychee + ## otherwise reads it as text and extracts junk URLs from the regexes. + exclude: ^(tests/test_caldav_unit\.py|\.lycheeignore)$