diff --git a/caldav/async_davclient.py b/caldav/async_davclient.py index 445594ea..26bf1a20 100644 --- a/caldav/async_davclient.py +++ b/caldav/async_davclient.py @@ -842,7 +842,7 @@ async def sync_collection( url: str | None = None, sync_token: str | None = None, props: list[str] | None = None, - depth: int = 1, + depth: int = 0, headers: Mapping[str, str] | None = None, ) -> DAVResponse: """ @@ -852,7 +852,7 @@ async def sync_collection( url: Target calendar URL (defaults to self.url). sync_token: Previous sync token (None for initial sync). props: Properties to include in response. - depth: Search depth. + depth: Depth header; RFC 6578 defines sync-collection for 0 only. headers: Additional headers. Returns: diff --git a/caldav/collection.py b/caldav/collection.py index 9e40246f..950eac03 100644 --- a/caldav/collection.py +++ b/caldav/collection.py @@ -1796,23 +1796,26 @@ def _href_to_url(self, href: str) -> "URL | str": return url def _request_report_build_resultlist( - self, xml, comp_class=None, props=None, no_calendardata=False + self, xml, comp_class=None, props=None, no_calendardata=False, depth=1 ) -> "tuple[Any, list[CalendarObjectResource]] | Coroutine[Any, Any, tuple[Any, list[CalendarObjectResource]]]": """ Takes some input XML, does a report query on a calendar object and returns the resource objects found. + ``depth`` is 1 for calendar-query; sync-collection needs 0 + (RFC 6578 section 3.2). + For async clients, returns a coroutine that must be awaited. """ props_ = [cdav.CalendarData()] if props is None else [cdav.CalendarData()] + props if self.is_async_client: - return self._async_request_report_build_resultlist(xml, comp_class, props_) - response = self._query(xml, 1, "report") + return self._async_request_report_build_resultlist(xml, comp_class, props_, depth) + response = self._query(xml, depth, "report") return self._post_request_report_build_resultlist(response, comp_class, props_) - async def _async_request_report_build_resultlist(self, xml, comp_class, props_): + async def _async_request_report_build_resultlist(self, xml, comp_class, props_, depth=1): """Async implementation of _request_report_build_resultlist.""" - response = await self._query(xml, 1, "report") + response = await self._query(xml, depth, "report") return self._post_request_report_build_resultlist(response, comp_class, props_) def _populate_searcher(self, my_searcher, searchargs: dict, sort_reverse: bool) -> None: @@ -2541,7 +2544,7 @@ def get_objects_by_sync_token( sync_token=token, props=["getetag"] ) (response, objects) = self._request_report_build_resultlist( - root, props=[dav.GetEtag()], no_calendardata=True + root, props=[dav.GetEtag()], no_calendardata=True, depth=0 ) (token, truncated, again) = self._absorb_sync_page( response, objects, collected, token, deleted @@ -2642,7 +2645,7 @@ async def _async_get_objects_by_sync_token( sync_token=token, props=["getetag"] ) (response, objects) = await self._request_report_build_resultlist( - root, props=[dav.GetEtag()], no_calendardata=True + root, props=[dav.GetEtag()], no_calendardata=True, depth=0 ) (token, truncated, again) = self._absorb_sync_page( response, objects, collected, token, deleted diff --git a/changes/+sync-collection-depth-0.fixed.md b/changes/+sync-collection-depth-0.fixed.md new file mode 100644 index 00000000..f4c8b4db --- /dev/null +++ b/changes/+sync-collection-depth-0.fixed.md @@ -0,0 +1 @@ +The sync-collection REPORT (`Calendar.get_objects_by_sync_token()`, `AsyncDAVClient.sync_collection()`) is now sent with `Depth: 0`, as [RFC 6578 section 3.2](https://datatracker.ietf.org/doc/html/rfc6578#section-3.2) requires, instead of `Depth: 1`; the `depth` parameter of `AsyncDAVClient.sync_collection()` now defaults to 0. None of the servers in the test suite behaves differently, but a strict server may reject the old request with a 400. diff --git a/tests/test_async_davclient.py b/tests/test_async_davclient.py index e3a12c53..0e5eab6b 100644 --- a/tests/test_async_davclient.py +++ b/tests/test_async_davclient.py @@ -2301,6 +2301,8 @@ async def test_follows_the_token_until_complete(self) -> None: assert client.report.await_count == 2 bodies = [to_normal_str(c.args[1]) for c in client.report.call_args_list] + ## RFC 6578 section 3.2: sync-collection is only defined for Depth: 0 + assert [c.args[2] for c in client.report.call_args_list] == [0, 0] assert "tok-1" in bodies[0] assert "tok-2" in bodies[1] assert result.sync_token == "tok-3" @@ -2434,3 +2436,4 @@ async def test_sync_collection_reports_truncation(self) -> None: assert response.sync_truncated is True assert response.sync_token == "tok-2" assert [r.href for r in response.results] == ["/dav/cal/a.ics"] + assert client.request.call_args.args[3]["Depth"] == "0" diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index 334b6153..aa8e1f2e 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -4382,10 +4382,12 @@ class CapturingClient(MockedDAVClient): def __init__(self): super().__init__("") self.report_bodies = [] + self.report_depths = [] def request(self, url=None, method="GET", body=None, headers=None, *args, **kwargs): if method == "REPORT" and body: self.report_bodies.append(body) + self.report_depths.append((headers or {}).get("Depth")) return super().request(url, method, body, headers, *args, **kwargs) client = CapturingClient() @@ -4402,6 +4404,9 @@ def request(self, url=None, method="GET", body=None, headers=None, *args, **kwar # Must NOT contain bare date strings without time component assert "20260501Z" not in body assert "20260601Z" not in body + ## calendar-query is a Depth: 1 REPORT (RFC 4791 section 7.8), + ## unlike sync-collection, which shares the helper + assert client.report_depths[0] == "1" def test_searcher_coerces_date_to_datetime(self): """calendar.searcher(start=date(...)) must store datetime on the searcher, @@ -7596,6 +7601,8 @@ def test_untruncated_reply_is_a_single_request(self): client, calendar = self._calendar(sync_page({"a.ics": '"a1"'}, "tok-2", truncated=False)) result = calendar.get_objects_by_sync_token("tok-1") assert client.report.call_count == 1 + ## RFC 6578 section 3.2: sync-collection is only defined for Depth: 0 + assert client.report.call_args.args[2] == 0 assert result.truncated is False assert result.sync_token == "tok-2" assert len(result) == 1