Skip to content

Commit e7b53b2

Browse files
tobixenclaude
andcommitted
fix: improve get_supported_components() — RFC compliance, async, hints
RFC 4791 §5.2.3 states that absent supported-calendar-component-set means the server accepts all component types. Previously the method raised KeyError (fallback path) or returned an empty list (protocol-layer path). Changes: - Returns the RFC-compliant component set when the property is absent, adjusted per compatibility hints (e.g. excludes VTODO if save-load.todo is unsupported for the given server) - Fixes async path that returned an unawaited coroutine instead of the result - Removes dead _find_objects_and_props() fallback code - Replaces no_supported_components_support flag with get-supported-components feature, allowing caldav-server-tester to track it - Adds create-calendar.with-supported-component-types feature definition Fixes #653 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1df3770 commit e7b53b2

File tree

5 files changed

+115
-46
lines changed

5 files changed

+115
-46
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
1717
### Fixed
1818

1919
* Reusing a `CalDAVSearcher` across multiple `search()` calls could yield inconsistent results: the first call would return only pending tasks (correct), but subsequent calls would change behaviour because `icalendar_searcher.Searcher.check_component()` mutated the `include_completed` field from `None` to `False` as a side-effect. Fixed by passing a copy with `include_completed` already resolved to `filter_search_results()`, leaving the original searcher object unchanged. Fixes https://github.com/python-caldav/caldav/issues/650
20+
* `Calendar.get_supported_components()` raised `KeyError` when the server did not include the `supported-calendar-component-set` property in its response. RFC 4791 section 5.2.3 states this property is optional and that its absence means all component types are accepted; the method now returns the RFC default `["VEVENT", "VTODO", "VJOURNAL"]` in that case, trimmed by any known server limitations from the compatibility hints (e.g. if `save-load.todo` is `unsupported`, `VTODO` is excluded). Fixes https://github.com/python-caldav/caldav/issues/653
2021

2122
## [3.1.0] - 2026-03-19
2223

caldav/collection.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -745,29 +745,49 @@ async def _async_calendar_delete(self):
745745
else:
746746
await self._async_delete()
747747

748-
def get_supported_components(self) -> list[Any]:
748+
def _supported_components_from_response(self, response: Any, with_fallback: bool) -> list[Any]:
749+
"""Extract supported component types from a propfind DAVResponse.
750+
751+
Both the sync and async paths produce a DAVResponse via propfind,
752+
which always populates response.results via the protocol layer.
753+
"""
754+
for result in response.results or []:
755+
components = result.properties.get(cdav.SupportedCalendarComponentSet().tag)
756+
if components:
757+
return components
758+
## Property absent: RFC 4791 s.5.2.3 says accept all types; trim by known server limits
759+
if not with_fallback:
760+
raise error.PropfindError("supported-calendar-component-set not supported")
761+
rfc_default = ["VEVENT"]
762+
if self.client and self.client.features.is_supported("save-load.todo"):
763+
rfc_default.append("VTODO")
764+
if self.client and self.client.features.is_supported("save-load.journal"):
765+
rfc_default.append("VJOURNAL")
766+
return rfc_default
767+
768+
def get_supported_components(self, with_fallback=True) -> list[Any]:
749769
"""
750770
returns a list of component types supported by the calendar, in
751771
string format (typically ['VJOURNAL', 'VTODO', 'VEVENT'])
772+
773+
RFC 4791 section 5.2.3: supported-calendar-component-set is optional.
774+
When absent, the server MUST accept all component types, so we return
775+
the RFC default list (trimmed by known server limitations from
776+
compatibility hints) rather than raising or returning empty.
777+
See https://github.com/python-caldav/caldav/issues/653
752778
"""
753779
if self.url is None:
754780
raise ValueError("Unexpected value None for self.url")
755-
781+
if self.is_async_client:
782+
return self._async_get_supported_components(with_fallback)
756783
props = [cdav.SupportedCalendarComponentSet()]
757784
response = self.get_properties(props, parse_response_xml=False)
785+
return self._supported_components_from_response(response, with_fallback)
758786

759-
# Use protocol layer results if available
760-
if response.results:
761-
for result in response.results:
762-
components = result.properties.get(cdav.SupportedCalendarComponentSet().tag)
763-
if components:
764-
return components
765-
return []
766-
767-
# Fallback for mocked responses without protocol parsing
768-
response_list = response._find_objects_and_props()
769-
prop = response_list[unquote(self.url.path)][cdav.SupportedCalendarComponentSet().tag]
770-
return [supported.get("name") for supported in prop]
787+
async def _async_get_supported_components(self, with_fallback=True) -> list[Any]:
788+
props = [cdav.SupportedCalendarComponentSet()]
789+
response = await self._async_get_properties(props, parse_response_xml=False)
790+
return self._supported_components_from_response(response, with_fallback)
771791

772792
def save_with_invites(self, ical: str, attendees, **attendeeoptions) -> None:
773793
"""

caldav/compatibility_hints.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ class FeatureSet:
8282
"get-current-user-principal.has-calendar": {
8383
"type": "server-observation",
8484
"description": "Principal has one or more calendars. Some servers and providers comes with a pre-defined calendar for each user, for other servers a calendar has to be explicitly created (supported means there exists a calendar - it may be because the calendar was already provisioned together with the principal, or it may be because a calendar was created manually, the checks can't see the difference)"},
85+
"get-supported-components": {
86+
"description": "Server returns the supported-calendar-component-set property (RFC 4791 section 5.2.3). The property is optional: when absent the RFC mandates that all component types are accepted, so 'unsupported' here is not a protocol violation, but the client cannot determine the actual supported set without trying.",
87+
},
88+
"create-calendar.with-supported-component-types": {
89+
"description": "Server honours the supported-calendar-component-set restriction set at MKCALENDAR time. When 'full', the server both advertises (or enforces) the restriction; when 'unsupported', the restriction is silently ignored (wrong-type objects can be saved to the calendar). When 'ungraceful', the MKCALENDAR request itself fails when a component set is specified.",
90+
},
8591
"rate-limit": {
8692
"type": "client-feature",
8793
"description": "client (or test code) must sleep a bit between requests. Pro-active rate limiting is done through interval and count, server-flagged rate-limiting is controlled through default_sleep/max_sleep",
@@ -809,9 +815,6 @@ def dotted_feature_set_list(self, compact=False):
809815
'non_existing_raises_other':
810816
"""Robur raises AuthorizationError when trying to access a non-existing resource (while 404 is expected). Probably so one shouldn't probe a public name space?""",
811817

812-
'no_supported_components_support':
813-
"""The supported components prop query does not work""",
814-
815818
'no_relships':
816819
"""The calendar server does not support child/parent relationships between calendar components""",
817820

@@ -1227,11 +1230,11 @@ def dotted_feature_set_list(self, compact=False):
12271230
'old_flags': [
12281231
'non_existing_raises_other', ## AuthorizationError instead of NotFoundError
12291232
'no_scheduling',
1230-
'no_supported_components_support',
12311233
'no_relships',
12321234
],
12331235
'test-calendar': {'cleanup-regime': 'wipe-calendar'},
12341236
"sync-token": {"support": "ungraceful"},
1237+
"get-supported-components": {"support": "unsupported"},
12351238
}
12361239

12371240
posteo = {
@@ -1404,12 +1407,9 @@ def dotted_feature_set_list(self, compact=False):
14041407
'old_flags': [
14051408
## Known, work in progress
14061409
'no_scheduling',
1407-
1408-
## Known, not a breach of standard
1409-
'no_supported_components_support',
1410-
1411-
## I haven't raised this one with them yet
1412-
]
1410+
],
1411+
## Known, not a breach of standard
1412+
"get-supported-components": {"support": "unsupported"},
14131413
}
14141414

14151415
gmx = {

tests/test_caldav.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1819,7 +1819,6 @@ def testCreateCalendarAndEventFromVobject(self):
18191819
assert len(c.get_events()) == cnt
18201820

18211821
def testGetSupportedComponents(self):
1822-
self.skip_on_compatibility_flag("no_supported_components_support")
18231822
c = self._fixCalendar()
18241823

18251824
components = c.get_supported_components()

tests/test_caldav_unit.py

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,76 @@ def testPathWithEscapedCharacters(self):
552552
url="https://somwhere.in.the.universe.example/some/caldav/root/133bahgr6ohlo9ungq0it45vf8%40group.calendar.google.com/events/"
553553
).get_supported_components() == ["VEVENT"]
554554

555+
def test_get_supported_components_present(self):
556+
"""Test get_supported_components when the property is present in the server response."""
557+
xml = b"""<multistatus xmlns="DAV:">
558+
<response xmlns="DAV:">
559+
<href>/some/caldav/root/testcal/</href>
560+
<propstat>
561+
<prop>
562+
<supported-calendar-component-set xmlns="urn:ietf:params:xml:ns:caldav">
563+
<comp xmlns="urn:ietf:params:xml:ns:caldav" name="VEVENT"/>
564+
</supported-calendar-component-set>
565+
</prop>
566+
<status>HTTP/1.1 200 OK</status>
567+
</propstat>
568+
</response>
569+
</multistatus>"""
570+
client = MockedDAVClient(xml)
571+
assert client.calendar(
572+
url="https://somwhere.in.the.universe.example/some/caldav/root/testcal/"
573+
).get_supported_components() == ["VEVENT"]
574+
575+
def test_get_supported_components_absent(self):
576+
"""RFC 4791 says supported-calendar-component-set is optional.
577+
When absent, the server MUST accept all component types, and the
578+
client MUST assume that all component types are accepted.
579+
Regression for https://github.com/python-caldav/caldav/issues/653"""
580+
xml = b"""<D:multistatus xmlns:D="DAV:">
581+
<D:response>
582+
<D:href>/some/caldav/root/testcal/</D:href>
583+
<D:propstat>
584+
<D:status>HTTP/1.1 200 OK</D:status>
585+
<D:prop/>
586+
</D:propstat>
587+
</D:response>
588+
</D:multistatus>"""
589+
client = MockedDAVClient(xml)
590+
components = client.calendar(
591+
url="https://somwhere.in.the.universe.example/some/caldav/root/testcal/"
592+
).get_supported_components()
593+
assert "VEVENT" in components
594+
assert "VTODO" in components
595+
assert "VJOURNAL" in components
596+
597+
def test_get_supported_components_absent_hints(self):
598+
"""When supported-calendar-component-set is absent, the RFC default is filtered
599+
by compatibility hints: if the server is known not to support VTODO or VJOURNAL,
600+
those should be excluded from the returned list.
601+
See https://github.com/python-caldav/caldav/issues/653"""
602+
from caldav.compatibility_hints import FeatureSet
603+
604+
xml = b"""<D:multistatus xmlns:D="DAV:">
605+
<D:response>
606+
<D:href>/some/caldav/root/testcal/</D:href>
607+
<D:propstat>
608+
<D:status>HTTP/1.1 200 OK</D:status>
609+
<D:prop/>
610+
</D:propstat>
611+
</D:response>
612+
</D:multistatus>"""
613+
client = MockedDAVClient(xml)
614+
client.features = FeatureSet(
615+
{
616+
"save-load.todo": {"support": "unsupported"},
617+
"save-load.journal": {"support": "unsupported"},
618+
}
619+
)
620+
components = client.calendar(
621+
url="https://somwhere.in.the.universe.example/some/caldav/root/testcal/"
622+
).get_supported_components()
623+
assert components == ["VEVENT"]
624+
555625
def testAbsoluteURL(self):
556626
"""Version 0.7.0 does not handle responses with absolute URLs very well, ref https://github.com/python-caldav/caldav/pull/103"""
557627
## none of this should initiate any communication
@@ -779,27 +849,6 @@ def test_get_calendars(self):
779849
calendar_home_set = CalendarSet(client, url="/dav/tobias%40redpill-linpro.com/")
780850
assert len(calendar_home_set.get_calendars()) == 1
781851

782-
def test_supported_components(self):
783-
xml = """
784-
<multistatus xmlns="DAV:">
785-
<response xmlns="DAV:">
786-
<href>/17149682/calendars/testcalendar-0da571c7-139c-479a-9407-8ce9ed20146d/</href>
787-
<propstat>
788-
<prop>
789-
<supported-calendar-component-set xmlns="urn:ietf:params:xml:ns:caldav">
790-
<comp xmlns="urn:ietf:params:xml:ns:caldav" name="VEVENT"/>
791-
</supported-calendar-component-set>
792-
</prop>
793-
<status>HTTP/1.1 200 OK</status>
794-
</propstat>
795-
</response>
796-
</multistatus>"""
797-
client = MockedDAVClient(xml)
798-
assert Calendar(
799-
client=client,
800-
url="/17149682/calendars/testcalendar-0da571c7-139c-479a-9407-8ce9ed20146d/",
801-
).get_supported_components() == ["VEVENT"]
802-
803852
def test_xml_parsing(self):
804853
"""
805854
DAVResponse has quite some code to parse the XML received from the

0 commit comments

Comments
 (0)