Skip to content

Commit 3c0fa51

Browse files
tobixenclaude
andcommitted
refactor: scrap operations and protocol
After an audit of caldav/operations/ and caldav/protocol/ (documented in docs/design/OPERATIONS_PROTOCOL_AUDIT.md), both directories are deleted, the code that was in use have been moved elsewhere. The code changes are predominantly AI-written. Tedious refactoring work, chances for mistakes are bigger when doing it by hand than by AI. I've been looking through the changes, and I trust the tests to uncover any errors slipping through. prompt: During the great attempt on Sans-IO refactoring, a directory `caldav/operations/` was made. Please check up how much code is duplicated and/or dead there, and come with recommendations on whether to keep "operations" there or not. Same with the protocols folder. followup-prompt: Save the analysis to the docs/design folder followup-prompt: Kill the operations directory ref the document followup-prompt: All response-related logic in the protocol directory should be moved back to the response class. Make sure there is no duplicated code or logic. followup-prompt: move xml builders to the dav base client, and ensure sync and async code paths uses the same builder methods prompt: Deal with the code duplication in response.py followup-prompt: It seems like the last commit, with purpose "remove code duplication in response.py" has more code additions than removed code? Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3cd8942 commit 3c0fa51

28 files changed

Lines changed: 838 additions & 5492 deletions

caldav/async_davclient.py

Lines changed: 14 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ def auth_flow(self, request):
7171
from caldav.compatibility_hints import FeatureSet
7272
from caldav.lib import error
7373
from caldav.lib.python_utilities import to_wire
74+
from caldav.lib.url import URL
75+
7476
from caldav.requests import HTTPBearerAuth
7577
from caldav.response import CalendarQueryResult, DAVResponse, PropfindResult
7678

@@ -517,19 +519,13 @@ async def propfind(
517519
"""
518520
# Use protocol layer to build XML if props provided
519521
if props is not None and not body:
520-
body = _build_propfind_body(props).decode("utf-8")
522+
body = self._build_propfind_body(props).decode("utf-8")
521523

522524
final_headers = self._build_method_headers("PROPFIND", depth, headers)
523525
response = await self.request(url or str(self.url), "PROPFIND", body, final_headers)
524526

525-
# Parse response using protocol layer
526527
if response.status in (200, 207) and response._raw:
527-
raw_bytes = (
528-
response._raw if isinstance(response._raw, bytes) else response._raw.encode("utf-8")
529-
)
530-
response.results = _parse_propfind_response(
531-
raw_bytes, response.status, response.huge_tree
532-
)
528+
response.results = response.parse_propfind()
533529

534530
return response
535531

@@ -725,7 +721,7 @@ async def calendar_query(
725721
DAVResponse with results containing List[CalendarQueryResult].
726722
"""
727723

728-
body, _ = _build_calendar_query_body(
724+
body, _ = self._build_calendar_query_body(
729725
start=start,
730726
end=end,
731727
event=event,
@@ -739,14 +735,8 @@ async def calendar_query(
739735
url or str(self.url), "REPORT", body.decode("utf-8"), final_headers
740736
)
741737

742-
# Parse response using protocol layer
743738
if response.status in (200, 207) and response._raw:
744-
raw_bytes = (
745-
response._raw if isinstance(response._raw, bytes) else response._raw.encode("utf-8")
746-
)
747-
response.results = _parse_calendar_query_response(
748-
raw_bytes, response.status, response.huge_tree
749-
)
739+
response.results = response.parse_calendar_query()
750740

751741
return response
752742

@@ -769,21 +759,15 @@ async def calendar_multiget(
769759
Returns:
770760
DAVResponse with results containing List[CalendarQueryResult].
771761
"""
772-
body = _build_calendar_multiget_body(hrefs or [])
762+
body = self._build_calendar_multiget_body(hrefs or [])
773763

774764
final_headers = self._build_method_headers("REPORT", depth, headers)
775765
response = await self.request(
776766
url or str(self.url), "REPORT", body.decode("utf-8"), final_headers
777767
)
778768

779-
# Parse response using protocol layer
780769
if response.status in (200, 207) and response._raw:
781-
raw_bytes = (
782-
response._raw if isinstance(response._raw, bytes) else response._raw.encode("utf-8")
783-
)
784-
response.results = _parse_calendar_query_response(
785-
raw_bytes, response.status, response.huge_tree
786-
)
770+
response.results = response.parse_calendar_query()
787771

788772
return response
789773

@@ -808,21 +792,15 @@ async def sync_collection(
808792
Returns:
809793
DAVResponse with results containing SyncCollectionResult.
810794
"""
811-
body = _build_sync_collection_body(sync_token=sync_token, props=props)
795+
body = self._build_sync_collection_body(sync_token=sync_token, props=props)
812796

813797
final_headers = self._build_method_headers("REPORT", depth, headers)
814798
response = await self.request(
815799
url or str(self.url), "REPORT", body.decode("utf-8"), final_headers
816800
)
817801

818-
# Parse response using protocol layer
819802
if response.status in (200, 207) and response._raw:
820-
raw_bytes = (
821-
response._raw if isinstance(response._raw, bytes) else response._raw.encode("utf-8")
822-
)
823-
sync_result = _parse_sync_collection_response(
824-
raw_bytes, response.status, response.huge_tree
825-
)
803+
sync_result = response.parse_sync_collection()
826804
response.results = sync_result.changed
827805
response.sync_token = sync_result.sync_token
828806

@@ -934,12 +912,12 @@ async def get_calendars(self, principal: Optional["Principal"] = None) -> list["
934912
print(f"Calendar: {cal.get_display_name()}")
935913
"""
936914
from caldav.collection import Calendar
937-
from caldav.operations.calendarset_ops import (
938-
_extract_calendars_from_propfind_results as extract_calendars,
939-
)
940-
from caldav.operations.principal_ops import (
915+
from caldav.collection import (
941916
_extract_calendar_home_set_from_results as extract_home_set,
942917
)
918+
from caldav.collection import (
919+
_extract_calendars_from_propfind_results as extract_calendars,
920+
)
943921

944922
if principal is None:
945923
principal = await self.get_principal()

caldav/base_client.py

Lines changed: 182 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@
1010
import logging
1111
from abc import ABC, abstractmethod
1212
from collections.abc import Mapping
13+
from datetime import datetime
1314
from typing import TYPE_CHECKING, Any, NoReturn
1415

16+
from lxml import etree
17+
18+
from caldav.elements import cdav, dav
19+
from caldav.elements.base import BaseElement
1520
from caldav.lib import error
1621
from caldav.lib.auth import extract_auth_types, select_auth_type
1722
from caldav.lib.python_utilities import to_normal_str
@@ -26,6 +31,42 @@
2631
ICALH = {"Content-Type": 'text/calendar; charset="utf-8"'}
2732

2833

34+
def _prop_name_to_element(name: str, value: Any | None = None) -> BaseElement | None:
35+
"""Convert a property name string (plain or Clark-notation) to a DAV element object."""
36+
dav_props: dict[str, Any] = {
37+
"displayname": dav.DisplayName,
38+
"resourcetype": dav.ResourceType,
39+
"getetag": dav.GetEtag,
40+
"current-user-principal": dav.CurrentUserPrincipal,
41+
"owner": dav.Owner,
42+
"sync-token": dav.SyncToken,
43+
"supported-report-set": dav.SupportedReportSet,
44+
}
45+
caldav_props: dict[str, Any] = {
46+
"calendar-data": cdav.CalendarData,
47+
"calendar-home-set": cdav.CalendarHomeSet,
48+
"calendar-user-address-set": cdav.CalendarUserAddressSet,
49+
"calendar-user-type": cdav.CalendarUserType,
50+
"calendar-description": cdav.CalendarDescription,
51+
"calendar-timezone": cdav.CalendarTimeZone,
52+
"supported-calendar-component-set": cdav.SupportedCalendarComponentSet,
53+
"schedule-inbox-url": cdav.ScheduleInboxURL,
54+
"schedule-outbox-url": cdav.ScheduleOutboxURL,
55+
}
56+
# Strip Clark-notation namespace prefix: "{DAV:}displayname" → "displayname"
57+
if name.startswith("{") and "}" in name:
58+
name = name.split("}", 1)[1]
59+
name_lower = name.lower().replace("_", "-")
60+
for props_dict in (dav_props, caldav_props):
61+
if name_lower in props_dict:
62+
cls = props_dict[name_lower]
63+
try:
64+
return cls(value) if value is not None else cls()
65+
except TypeError:
66+
return cls()
67+
return None
68+
69+
2970
class BaseDAVClient(ABC):
3071
"""
3172
Base class for DAV clients providing shared authentication and configuration logic.
@@ -214,12 +255,148 @@ def _raise_authorization_error(self, url_str: str, reason_source: Any) -> NoRetu
214255
reason = "None given"
215256
raise error.AuthorizationError(url=url_str, reason=reason)
216257

217-
def _build_principal_search_query(self, name: str | None) -> bytes:
218-
"""Build the XML body for a principal-property-search REPORT."""
219-
from lxml import etree
220-
221-
from caldav.elements import cdav, dav
258+
# ── XML builders ──────────────────────────────────────────────────────────
259+
# All methods are static: no I/O, no server interaction, pure data
260+
# transformation. Both DAVClient and AsyncDAVClient inherit these so
261+
# every code path that builds request XML uses the same implementation.
262+
263+
@staticmethod
264+
def _build_propfind_body(
265+
props: list[str] | None = None,
266+
allprop: bool = False,
267+
) -> bytes:
268+
"""Build PROPFIND request body XML."""
269+
if allprop:
270+
propfind = dav.Propfind() + dav.Allprop()
271+
elif props:
272+
prop_elements = [e for name in props if (e := _prop_name_to_element(name)) is not None]
273+
propfind = dav.Propfind() + (dav.Prop() + prop_elements)
274+
else:
275+
propfind = dav.Propfind() + dav.Prop()
276+
return etree.tostring(propfind.xmlelement(), encoding="utf-8", xml_declaration=True)
277+
278+
@staticmethod
279+
def _build_proppatch_body(set_props: dict[str, Any] | None = None) -> bytes:
280+
"""Build PROPPATCH request body for setting properties."""
281+
propertyupdate = dav.PropertyUpdate()
282+
if set_props:
283+
set_elements = [
284+
e
285+
for name, value in set_props.items()
286+
if (e := _prop_name_to_element(name, value)) is not None
287+
]
288+
if set_elements:
289+
propertyupdate += dav.Set() + (dav.Prop() + set_elements)
290+
return etree.tostring(propertyupdate.xmlelement(), encoding="utf-8", xml_declaration=True)
291+
292+
@staticmethod
293+
def _build_calendar_query_body(
294+
start: datetime | None = None,
295+
end: datetime | None = None,
296+
expand: bool = False,
297+
comp_filter: str | None = None,
298+
event: bool = False,
299+
todo: bool = False,
300+
journal: bool = False,
301+
props: list[BaseElement] | None = None,
302+
filters: list[BaseElement] | None = None,
303+
) -> tuple[bytes, str | None]:
304+
"""Build calendar-query REPORT request body.
305+
306+
Returns (XML bytes, component type name or None).
307+
"""
308+
data = cdav.CalendarData()
309+
if expand:
310+
if not start or not end:
311+
raise error.ReportError("can't expand without a date range")
312+
data += cdav.Expand(start, end)
313+
314+
props_list: list[BaseElement] = [data] + (list(props) if props else [])
315+
prop = dav.Prop() + props_list
316+
317+
vcalendar = cdav.CompFilter("VCALENDAR")
318+
comp_type = comp_filter or (
319+
"VEVENT" if event else "VTODO" if todo else "VJOURNAL" if journal else None
320+
)
321+
filter_list: list[BaseElement] = list(filters) if filters else []
322+
if start or end:
323+
filter_list.append(cdav.TimeRange(start, end))
324+
325+
if comp_type:
326+
comp_filter_elem = cdav.CompFilter(comp_type)
327+
if filter_list:
328+
comp_filter_elem += filter_list
329+
vcalendar += comp_filter_elem
330+
elif filter_list:
331+
vcalendar += filter_list
332+
333+
root = cdav.CalendarQuery() + [prop, cdav.Filter() + vcalendar]
334+
return (
335+
etree.tostring(root.xmlelement(), encoding="utf-8", xml_declaration=True),
336+
comp_type,
337+
)
222338

339+
@staticmethod
340+
def _build_calendar_multiget_body(
341+
hrefs: list[str],
342+
include_data: bool = True,
343+
) -> bytes:
344+
"""Build calendar-multiget REPORT request body."""
345+
elements: list[BaseElement] = []
346+
if include_data:
347+
elements.append(dav.Prop() + cdav.CalendarData())
348+
for href in hrefs:
349+
elements.append(dav.Href(href))
350+
multiget = cdav.CalendarMultiGet() + elements
351+
return etree.tostring(multiget.xmlelement(), encoding="utf-8", xml_declaration=True)
352+
353+
@staticmethod
354+
def _build_sync_collection_body(
355+
sync_token: str | None = None,
356+
props: list[str] | None = None,
357+
sync_level: str = "1",
358+
) -> bytes:
359+
"""Build sync-collection REPORT request body."""
360+
elements: list[BaseElement] = [
361+
dav.SyncToken(value=sync_token or ""),
362+
dav.SyncLevel(value=sync_level),
363+
]
364+
if props:
365+
prop_elements = [e for name in props if (e := _prop_name_to_element(name)) is not None]
366+
if prop_elements:
367+
elements.append(dav.Prop() + prop_elements)
368+
else:
369+
elements.append(dav.Prop() + [dav.GetEtag(), cdav.CalendarData()])
370+
sync_collection = dav.SyncCollection() + elements
371+
return etree.tostring(sync_collection.xmlelement(), encoding="utf-8", xml_declaration=True)
372+
373+
@staticmethod
374+
def _build_mkcalendar_body(
375+
displayname: str | None = None,
376+
description: str | None = None,
377+
timezone: str | None = None,
378+
supported_components: list[str] | None = None,
379+
) -> bytes:
380+
"""Build MKCALENDAR request body."""
381+
prop = dav.Prop()
382+
if displayname:
383+
prop += dav.DisplayName(displayname)
384+
if description:
385+
prop += cdav.CalendarDescription(description)
386+
if timezone:
387+
prop += cdav.CalendarTimeZone(timezone)
388+
if supported_components:
389+
sccs = cdav.SupportedCalendarComponentSet()
390+
for comp in supported_components:
391+
sccs += cdav.Comp(comp)
392+
prop += sccs
393+
prop += dav.ResourceType() + [dav.Collection(), cdav.Calendar()]
394+
mkcalendar = cdav.Mkcalendar() + (dav.Set() + prop)
395+
return etree.tostring(mkcalendar.xmlelement(), encoding="utf-8", xml_declaration=True)
396+
397+
@staticmethod
398+
def _build_principal_search_query(name: str | None) -> bytes:
399+
"""Build the XML body for a principal-property-search REPORT."""
223400
name_filter = (
224401
[dav.PropertySearch() + [dav.Prop() + [dav.DisplayName()]] + dav.Match(value=name)]
225402
if name

caldav/calendarobjectresource.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from collections import defaultdict
1919
from datetime import datetime, timedelta, timezone
2020
from typing import TYPE_CHECKING, Any, ClassVar, Optional
21-
from urllib.parse import ParseResult, SplitResult
21+
from urllib.parse import ParseResult, SplitResult, quote
2222

2323
import icalendar
2424
from dateutil.rrule import rrulestr
@@ -47,11 +47,19 @@
4747
from .lib.error import errmsg
4848
from .lib.python_utilities import to_normal_str, to_unicode, to_wire
4949
from .lib.url import URL
50-
from .operations.calendarobject_ops import _quote_uid
5150

5251
log = logging.getLogger("caldav")
5352

5453

54+
def _quote_uid(uid: str) -> str:
55+
"""URL-quote a UID for use in a CalDAV object URL.
56+
57+
Slashes are double-quoted (replaced with %2F before percent-encoding)
58+
per https://github.com/python-caldav/caldav/issues/143.
59+
"""
60+
return quote(uid.replace("/", "%2F"))
61+
62+
5563
class CalendarObjectResource(DAVObject):
5664
"""Ref RFC 4791, section 4.1, a "Calendar Object Resource" can be an
5765
event, a todo-item, a journal entry, or a free/busy entry

0 commit comments

Comments
 (0)