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
4 changes: 3 additions & 1 deletion caldav/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2352,7 +2352,9 @@ def _sync_page_truncated(self, response: Any) -> bool:
## while the calendar URL may keep a literal @ (Nextcloud).
marker = unquote(self.url.join(self._href_to_url(href)).path).rstrip("/")
if marker != unquote(self.url.path).rstrip("/"):
raise error.ResponseError(f"sync-collection: 507 Insufficient Storage for {href}")
raise error.ResponseError(
f"sync-collection: 507 Insufficient Storage for {href}", status=507
)
return True

def _absorb_sync_page(
Expand Down
23 changes: 19 additions & 4 deletions caldav/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,16 @@ def _validate_status(status: str | None) -> None:
if status is None:
return
if not any(code in status for code in (" 200 ", " 201 ", " 207 ", " 404 ")):
raise error.ResponseError(status)
raise error.ResponseError(status, status=_parse_status_code(status))


def _parse_status_code(status: str) -> int | None:
"""The code in a status line like "HTTP/1.1 507 Insufficient Storage",
or None if there is none (unlike _status_to_code, no 200 fallback)."""
parts = status.split()
if len(parts) >= 2 and parts[1].isdigit():
return int(parts[1])
return None


def _is_sync_truncation(response: _Element, siblings: Iterable[_Element]) -> bool:
Expand Down Expand Up @@ -497,7 +506,9 @@ def parse_propfind(self) -> "list[PropfindResult]":
if self.status == 404:
return []
if self.status not in (200, 207):
raise error.ResponseError(f"PROPFIND failed with status {self.status}")
raise error.error_from_response(
error.ResponseError, self, f"PROPFIND failed with status {self.status}"
)
if self.tree is None:
return []
results: list[PropfindResult] = []
Expand All @@ -520,7 +531,9 @@ def parse_propfind(self) -> "list[PropfindResult]":
def parse_calendar_query(self) -> "list[CalendarQueryResult]":
"""Parse the response body as a calendar-query or calendar-multiget REPORT reply."""
if self.status not in (200, 207):
raise error.ResponseError(f"REPORT failed with status {self.status}")
raise error.error_from_response(
error.ResponseError, self, f"REPORT failed with status {self.status}"
)
if self.tree is None:
return []
results: list[CalendarQueryResult] = []
Expand All @@ -542,7 +555,9 @@ def parse_calendar_query(self) -> "list[CalendarQueryResult]":
def parse_sync_collection(self) -> "SyncCollectionResult":
"""Parse the response body as a sync-collection REPORT reply."""
if self.status not in (200, 207):
raise error.ResponseError(f"sync-collection failed with status {self.status}")
raise error.error_from_response(
error.ResponseError, self, f"sync-collection failed with status {self.status}"
)
if self.tree is None:
return SyncCollectionResult()
changed: list[CalendarQueryResult] = []
Expand Down
1 change: 1 addition & 0 deletions changes/+response-error-status.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A `ResponseError` raised for a failed PROPFIND, REPORT or sync-collection reply, or for a failing `<propstat>` status inside a multistatus (e.g. `507 Insufficient Storage`), now has its `status` attribute set; it was `None`, so the code had to be parsed out of the message. The failed replies also keep `body` and `preconditions`, like other `DAVError`s.
20 changes: 17 additions & 3 deletions tests/test_caldav_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -5886,13 +5886,15 @@ class TestPropstatStatusValidation:
"""

def test_server_error_propstat_raises(self):
with pytest.raises(error.ResponseError):
with pytest.raises(error.ResponseError) as excinfo:
MockedDAVResponse(self.XML).expand_simple_props(props=[dav.DisplayName()])
assert excinfo.value.status == 500

def test_insufficient_storage_propstat_raises(self):
xml = self.XML.replace("500 Internal Server Error", "507 Insufficient Storage")
with pytest.raises(error.ResponseError):
with pytest.raises(error.ResponseError) as excinfo:
MockedDAVResponse(xml).expand_simple_props(props=[dav.DisplayName()])
assert excinfo.value.status == 507

def test_404_propstat_is_still_a_missing_property(self):
"""The 404 quirk must survive: a 404 propstat means "not set here",
Expand All @@ -5901,6 +5903,17 @@ def test_404_propstat_is_still_a_missing_property(self):
result = MockedDAVResponse(xml).expand_simple_props(props=[dav.DisplayName()])
assert result == {"/dav/calendars/user/broken/": {"{DAV:}displayname": None}}

@pytest.mark.parametrize(
"status", ["500 Internal Server Error", "HTTP/1.1 Internal Server Error"]
)
def test_unparseable_status_line_leaves_status_unset(self, status):
"""A status line the client cannot read is no evidence of a code:
``status`` stays None rather than claiming 200."""
xml = self.XML.replace("HTTP/1.1 500 Internal Server Error", status)
with pytest.raises(error.ResponseError) as excinfo:
MockedDAVResponse(xml).expand_simple_props(props=[dav.DisplayName()])
assert excinfo.value.status is None


class TestAsyncDAVClientCredentialPairing:
"""Gate finding F6, async twin: same field-by-field merge, same result."""
Expand Down Expand Up @@ -7665,8 +7678,9 @@ def test_507_for_another_href_is_not_a_truncation(self):
b"<D:href>/dav/cal/</D:href>", b"<D:href>/dav/othercal/</D:href>"
)
_, calendar = self._calendar(page)
with pytest.raises(error.DAVError):
with pytest.raises(error.DAVError) as excinfo:
calendar.get_objects_by_sync_token("tok-1", disable_fallback=True)
assert excinfo.value.status == 507

def test_truncated_initial_sync_on_stalwart_stops_short(self):
"""Stalwart's checkpoint of a truncated initial sync skips members.
Expand Down
14 changes: 13 additions & 1 deletion tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,17 @@ def test_parse_propfind_404_returns_empty(self):
results = DAVResponse.from_bytes(b"", status_code=404).parse_propfind()
assert results == []

@pytest.mark.parametrize(
"method", ["parse_propfind", "parse_calendar_query", "parse_sync_collection"]
)
def test_parse_failure_keeps_status(self, method):
"""A failing reply raises a ResponseError carrying the HTTP status."""
from caldav.lib import error

with pytest.raises(error.ResponseError) as excinfo:
getattr(DAVResponse.from_bytes(b"", status_code=507), method)()
assert excinfo.value.status == 507

def test_parse_calendar_query_response(self):
"""Parse calendar-query response with calendar data."""
xml = b"""<?xml version="1.0"?>
Expand Down Expand Up @@ -279,8 +290,9 @@ def test_507_outside_sync_reply_still_raises(self):
<D:status>HTTP/1.1 507 Insufficient Storage</D:status>
</D:response>
</D:multistatus>"""
with pytest.raises(error.ResponseError):
with pytest.raises(error.ResponseError) as excinfo:
DAVResponse.from_bytes(xml).expand_simple_props([dav.GetEtag()])
assert excinfo.value.status == 507

def test_parse_sync_collection_generic_responsedescription(self):
"""A 404 <response> may carry an arbitrary <responsedescription>.
Expand Down
Loading