From 8fe11e1bc779286b0595cf369879bdbb2f996de6 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 20 Sep 2026 09:10:12 +0200 Subject: [PATCH 1/4] feat(compatibility): xandikos 0.4.7 has scheduling Compatibility hints adjusted to match master version of xandikos. Reviewed-by: Tobias Brox --- caldav/compatibility_hints.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index f9cd58f9..5320e6e8 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -1333,18 +1333,6 @@ def compare(self, observed): ## this only applies for very simple installations "auto-connect.url": {"domain": "localhost", "scheme": "http", "basepath": "/"}, - - "scheduling": {"support": "unsupported"}, - - ## Every collection reports and takes the same hardcoded component list - ## (xandikos/web.py), and the supported-calendar-component-set property has - ## no setter - yet MKCALENDAR still answers 201, though RFC 4791 section - ## 5.3.1 has it fail when a property cannot be set. Measured on 0.4.5, - ## 2026-09-15. - "create-calendar.with-supported-component-types": { - "support": "unsupported", - "behaviour": "the component set is ignored: a VTODO-only calendar advertises VEVENT, VTODO, VJOURNAL, VFREEBUSY and VAVAILABILITY, and a VEVENT can be saved to it", - }, } ## This seems to work as of version 3.5.4 of Radicale. From 3a0d83af51363a0d5bb4d4685d4165c3078d5e6c Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 20 Sep 2026 09:10:39 +0200 Subject: [PATCH 2/4] fix: tolerate an empty calendar-user-address-set get_vcal_address() indexed the address list blindly, so a server that advertises the property and returns it empty produced `IndexError: list index out of range` out of a public API - and out of add_organizer() and add_attendee(), which go through it. RFC 6638 section 2.4.1 covers this: a user with no well-defined calendar user address is identified by the URI of his principal resource, and that is what the sync and async variants now fall back to. An absent property still raises NotFoundError, which the same section defines as not enabled for scheduling. Xandikos 0.4.7 is such a server, and gets the new scheduling.calendar-user-address-set.populated feature graded unsupported rather than a weakened assert in testAddOrganizer. The same fallback goes into change_attendee_status(), which resolves an attendee through the address set too: without it the library invited a principal to an event it could then not update. Prompt: I've checked out master and adjusted the compatibility hints so that the compatibility check passes with the master version of xandikos. `pytest -k xandikos` still gives failures. Please investigate. (sic) (four IndexError test failures pasted) Followup-Prompt: [fix this with a] new feature flag for the xandikos problem Assisted-By: Claude Opus 5 Reviewed-by: Tobias Brox --- CHANGELOG.md | 6 +++++ caldav/calendarobjectresource.py | 5 ++++ caldav/collection.py | 12 +++++++-- caldav/compatibility_hints.py | 14 +++++++++++ tests/test_caldav.py | 43 +++++++++++++++++++++++++++++--- 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0c36681..4b04847f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0 ### 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 feature `scheduling.calendar-user-address-set.populated`, for a server that advertises `calendar-user-address-set` but returns it empty. Graded `unsupported` for Xandikos. + +### Fixed + +* `Principal.get_vcal_address()` raised `IndexError: list index out of range` when the server returned an empty `calendar-user-address-set`. It now falls back to the principal URL, as RFC 6638 section 2.4.1 provides for a user with no well-defined calendar user address. `add_organizer()` and `add_attendee()` go through the same method, so they were affected too. Seen on Xandikos 0.4.7, which advertises `calendar-auto-schedule` and serves schedule-inbox/outbox, but leaves the address set empty. `change_attendee_status()` accepts that same URL back, so an event the library invited a principal to can still have its PARTSTAT changed. A property that is *absent* still raises `NotFoundError`; per the same section that means the user is not enabled for scheduling. En passant, the Xandikos profile is regraded for 0.4.7: scheduling is no longer declared unsupported, and `create-calendar.with-supported-component-types` no longer unsupported either, so `is_supported()` may answer differently with `features: xandikos` configured. + ## [3.3.1] - 2026-09-16 diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index ea15e6b7..94a58064 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -1270,6 +1270,11 @@ def change_attendee_status(self, attendee: Any | None = None, **kwargs) -> None: if isinstance(attendee, Principal): try: attendee_emails = attendee.calendar_user_address_set() + ## Served but empty: the principal has no address of its own, + ## so it was invited under its URL - see get_vcal_address() + ## and RFC 6638 section 2.4.1. + if not attendee_emails: + attendee_emails = [str(attendee.url)] except error.NotFoundError: ## Server does not expose calendar-user-address-set (RFC6638 ยง2.4.1). ## Fall back to client.username if it looks like an email address. diff --git a/caldav/collection.py b/caldav/collection.py index e57d8747..f3fe4b62 100644 --- a/caldav/collection.py +++ b/caldav/collection.py @@ -647,7 +647,13 @@ def get_vcal_address(self) -> "vCalAddress | Coroutine[Any, Any, vCalAddress]": cn = self.get_display_name() ids = self.calendar_user_address_set() cutype = self.get_property(cdav.CalendarUserType()) - ret = vCalAddress(ids[0]) + ## A server may advertise calendar-user-address-set and still return it + ## empty (Xandikos does). RFC 6638 section 2.4.1: "In the event that a + ## user has no well-defined identifier for his calendar user address, + ## the URI of his principal resource can be used." An *absent* + ## property is a different thing - that means the user is not enabled + ## for scheduling at all, and calendar_user_address_set() raises. + ret = vCalAddress(next((i for i in ids if i), None) or str(self.url)) ret.params["cn"] = vText(cn) ret.params["cutype"] = vText(cutype) return ret @@ -666,7 +672,9 @@ async def _async_get_vcal_address(self) -> "vCalAddress": assert not [x for x in addresses_el if x.tag != dav.Href().tag] addresses = sorted(list(addresses_el), key=lambda x: -int(x.get("preferred", 0))) cutype = await self.get_property(cdav.CalendarUserType()) - ret = vCalAddress(addresses[0].text) + ## empty-but-present property: the principal URL is the address, + ## see the comment in the sync get_vcal_address() + ret = vCalAddress(next((a.text for a in addresses if a.text), None) or str(self.url)) ret.params["cn"] = vText(cn) ret.params["cutype"] = vText(cutype) return ret diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 5320e6e8..0825c067 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -689,6 +689,11 @@ class FeatureSet: "description": "Server provides the calendar-user-address-set property on the principal (RFC6638 section 2.4.1), used to identify a user's email/URI for scheduling purposes. When unsupported, calendar_user_address_set() raises NotFoundError.", "links": ["https://datatracker.ietf.org/doc/html/rfc6638#section-2.4.1"], }, + "scheduling.calendar-user-address-set.populated": { + "description": "The calendar-user-address-set property actually carries at least one address. A server can advertise the property (so scheduling.calendar-user-address-set is supported) and still return it empty - Xandikos does, while advertising calendar-auto-schedule and serving schedule-inbox/outbox. When unsupported, the principal has no calendar user address of its own, and RFC 6638 section 2.4.1 has the URI of the principal resource used instead: that is what get_vcal_address() returns and what add_organizer() and add_attendee() put in ORGANIZER/ATTENDEE.", + "links": ["https://datatracker.ietf.org/doc/html/rfc6638#section-2.4.1"], + "default": {"support": "full"}, + }, "scheduling.mailbox.inbox-delivery": { "description": "Server delivers incoming scheduling REQUEST messages to the attendee's schedule-inbox (RFC6638 section 4.1). See also scheduling.auto-schedule for whether the server additionally auto-processes invitations into the attendee's calendar.", "links": [ @@ -1333,6 +1338,15 @@ def compare(self, observed): ## this only applies for very simple installations "auto-connect.url": {"domain": "localhost", "scheme": "http", "basepath": "/"}, + + ## Scheduling is implemented (calendar-auto-schedule in the DAV header, + ## schedule-inbox/outbox served), but the principal has no address of its + ## own: the property is returned as an empty . + ## Measured on 0.4.7, 2026-09-20. + "scheduling.calendar-user-address-set.populated": { + "support": "unsupported", + "behaviour": "the property is advertised but empty, so the principal URL is used as the calendar user address", + }, } ## This seems to work as of version 3.5.4 of Radicale. diff --git a/tests/test_caldav.py b/tests/test_caldav.py index e6ec0b16..6324bb6c 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -1757,9 +1757,46 @@ def testAddOrganizer(self): org = event.icalendar_component.get("organizer") assert org is not None, "ORGANIZER should be set when add_organizer() uses principal" principal_addresses = self.principal.calendar_user_address_set() - assert any(addr in str(org) for addr in principal_addresses), ( - f"ORGANIZER {org!r} should contain one of the principal's addresses {principal_addresses!r}" - ) + populated = self.is_supported("scheduling.calendar-user-address-set.populated", str) + if populated == "unknown": + pytest.skip("nobody has probed whether this server populates the address set") + if populated != "unsupported": + assert any(addr in str(org) for addr in principal_addresses), ( + f"ORGANIZER {org!r} should contain one of the principal's addresses {principal_addresses!r}" + ) + else: + ## The server advertises the property but leaves it empty, so the + ## principal has no address of its own and RFC 6638 section 2.4.1 + ## has its URL stand in. + assert str(self.principal.url) in str(org), ( + f"ORGANIZER {org!r} should fall back to the principal URL {self.principal.url!r} " + f"when the address set is empty" + ) + + def testChangeAttendeeStatusWithEmptyAddressSet(self): + """add_attendee(principal) then change_attendee_status(principal) on a + server whose calendar-user-address-set is served but empty. + + The library writes the principal URL as the ATTENDEE (RFC6638 section + 2.4.1), so it has to accept that same URL back when asked to change + the PARTSTAT - otherwise it builds an event it cannot itself update. + """ + self.skip_unless_support("scheduling.calendar-user-address-set") + if ( + self.is_supported("scheduling.calendar-user-address-set.populated", str) + != "unsupported" + ): + pytest.skip("server populates calendar-user-address-set; nothing to fall back to") + + cal = self._fixCalendar() + event = cal.save_event(ev1) + event.add_attendee(self.principal) + event.save() + + event.change_attendee_status(self.principal, PARTSTAT="ACCEPTED") + attendee = event.icalendar_component.get("attendee") + assert attendee is not None + assert str(attendee.params.get("PARTSTAT")) == "ACCEPTED" def testIssue399ChangeAttendeeStatusUsernameEmailFallback(self): """change_attendee_status() works when the attendee is identified From a9398ec6b23fe5bc5cccf72ab3b82e84a1d0b64e Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 21 Sep 2026 07:22:39 +0200 Subject: [PATCH 3/4] fix: silence deptry DEP001 for optional tests.* import caldav/config.py optionally imports tests.test_servers.registry to auto-start a test server when run from a source checkout, falling back gracefully via ImportError otherwise. deptry's isolated CI install doesn't ship tests/, so it flagged the import as missing, which was also breaking this PR's deptry check independently of its own change. Prompt: Add fix as another commit onto PR 716, after all the purpose of PR 716 is to get the tests green again Assisted-By: Claude Sonnet 5 --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7a16e6fd..0ea18ae9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,10 +130,12 @@ test = [ ignore = ["DEP002"] # Test dependencies (pytest, coverage, etc.) are not imported in main code [tool.deptry.per_rule_ignores] -DEP001 = ["conf", "h2"] # conf: Local test config, h2: Optional HTTP/2 support. +DEP001 = ["conf", "h2", "tests"] # conf: Local test config, h2: Optional HTTP/2 support. ## httpxyz needed an entry here while it was imported by name; the httpx-family ## libraries are now imported dynamically (see _ASYNC_HTTPX_CANDIDATES), which ## deptry does not see at all. +## tests: caldav/config.py optionally imports tests.test_servers.registry (source +## tree only, guarded by ImportError) to auto-start a test server from a checkout. DEP003 = ["aiohttp", "h2"] # aiohttp: optional dep used only in caldav/testing.py ## h2 needs an entry in both DEP001 and DEP003: which of the two the optional ## `import h2` in async_davclient.py trips depends on whether h2 happens to be From e8a6188026b84ce52a49365db322138adda49530 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 21 Sep 2026 16:18:49 +0200 Subject: [PATCH 4/4] docs: clean up the tutorials' tasklist calendar Both tutorials create a "Test tasklist" calendar restricted to VTODO, and left it behind on the test server. Xandikos 0.4.7 honours supported-calendar-component-set, so the leftover poisoned the next doc test: get_calendar() handed out the tasklist, and add_event() got a 403 Forbidden. Deleting it mirrors what the "Creating Calendars" section already does, and keeps the tutorial's claim that the test server has exactly one calendar true. Prompt: [fix broken tests from https://github.com/python-caldav/caldav/pull/716 - tests/test_docs.py::DocTests::test_tutorial] Assisted-By: Claude Opus 5 Reviewed-By: Tobias Brox --- docs/source/async_tutorial.rst | 5 +++++ docs/source/tutorial.rst | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/docs/source/async_tutorial.rst b/docs/source/async_tutorial.rst index b807b784..30d62256 100644 --- a/docs/source/async_tutorial.rst +++ b/docs/source/async_tutorial.rst @@ -332,6 +332,11 @@ Tasks work just like events, with ``await`` added: my_tasks = await cal.search(todo=True, include_completed=True) assert my_tasks + ## Some servers (i.e. Xandikos) will refuse to store events in a + ## VTODO-only calendar, so let's clean up and leave the server as + ## we found it. + await cal.delete() + asyncio.run(main()) The :meth:`~caldav.calendarobjectresource.Todo.complete` method is awaitable in diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 70cce3c3..1054b918 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -320,6 +320,11 @@ There is some extra functionality around tasks, including the possibility to :me todo=True, include_completed=True) assert my_tasks + ## Some servers (i.e. Xandikos) will refuse to store events in a + ## VTODO-only calendar, so let's clean up and leave the server as we + ## found it. + cal.delete() + Further Reading ---------------