Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions caldav/calendarobjectresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions caldav/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 11 additions & 9 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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 <C:calendar-user-address-set/>.
## 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",
},
}

Expand Down
5 changes: 5 additions & 0 deletions docs/source/async_tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/source/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------

Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 40 additions & 3 deletions tests/test_caldav.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading