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 f9cd58f9..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": [ @@ -1334,16 +1339,13 @@ 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": { + ## 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 component set is ignored: a VTODO-only calendar advertises VEVENT, VTODO, VJOURNAL, VFREEBUSY and VAVAILABILITY, and a VEVENT can be saved to it", + "behaviour": "the property is advertised but empty, so the principal URL is used as the calendar user address", }, } 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 --------------- 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 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