Skip to content

Commit 35951a9

Browse files
committed
refactor: move XML builders to BaseDAVClient, delete protocol/ package
All XML request-body builder functions are now static methods on BaseDAVClient so both DAVClient (sync) and AsyncDAVClient (async) inherit the same implementations: _build_propfind_body, _build_proppatch_body, _build_calendar_query_body, _build_calendar_multiget_body, _build_sync_collection_body, _build_mkcalendar_body The _prop_name_to_element helper is a module-level function in base_client.py called by the builders. Updated callers: - async_davclient.py: dropped protocol.xml_builders import; uses self._build_* throughout (propfind, calendar_query, calendar_multiget, sync_collection) - davclient.py: dropped late import in propfind(); uses self._build_propfind_body - collection.py: both sync and async synchronize() now call self.client._build_sync_collection_body() instead of building the sync-collection element tree inline The caldav/protocol/ package is now empty and deleted entirely. test_protocol.py imports the builders directly from BaseDAVClient. prompt: move xml builders to the dav base client, and ensure sync and async code paths uses the same builder methods AI Prompts: claude-sonnet-4-6: For the xml building, do some more research: why is this needed for the async davclient, but not for the sync davclient? The async and sync davclient classes should be as similar as possible, and all non-IO-related logic should be consolidated. I don't think it needs a separate directory for xmlbuilding logic though. Would it make sense moving it to the davclient baseclass? Where else in the code do we build XML structures? Can things be consolidated further? If we are to have an xmlbuilder.py or something, then it xml building logic from everywhere should be moved/consolidated into that file. claude-sonnet-4-6: move xml builders to the dav base client, and ensure sync and async code paths uses the same builder methods
1 parent 648d3ca commit 35951a9

7 files changed

Lines changed: 200 additions & 405 deletions

File tree

caldav/async_davclient.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,6 @@ def auth_flow(self, request):
7171
from caldav.lib import error
7272
from caldav.lib.python_utilities import to_wire
7373
from caldav.lib.url import URL
74-
from caldav.protocol.xml_builders import (
75-
_build_calendar_multiget_body,
76-
_build_calendar_query_body,
77-
_build_propfind_body,
78-
_build_sync_collection_body,
79-
)
8074
from caldav.requests import HTTPBearerAuth
8175
from caldav.response import BaseDAVResponse, CalendarQueryResult, PropfindResult
8276

@@ -548,7 +542,7 @@ async def propfind(
548542
"""
549543
# Use protocol layer to build XML if props provided
550544
if props is not None and not body:
551-
body = _build_propfind_body(props).decode("utf-8")
545+
body = self._build_propfind_body(props).decode("utf-8")
552546

553547
final_headers = self._build_method_headers("PROPFIND", depth, headers)
554548
response = await self.request(url or str(self.url), "PROPFIND", body, final_headers)
@@ -750,7 +744,7 @@ async def calendar_query(
750744
AsyncDAVResponse with results containing List[CalendarQueryResult].
751745
"""
752746

753-
body, _ = _build_calendar_query_body(
747+
body, _ = self._build_calendar_query_body(
754748
start=start,
755749
end=end,
756750
event=event,
@@ -788,7 +782,7 @@ async def calendar_multiget(
788782
Returns:
789783
AsyncDAVResponse with results containing List[CalendarQueryResult].
790784
"""
791-
body = _build_calendar_multiget_body(hrefs or [])
785+
body = self._build_calendar_multiget_body(hrefs or [])
792786

793787
final_headers = self._build_method_headers("REPORT", depth, headers)
794788
response = await self.request(
@@ -821,7 +815,7 @@ async def sync_collection(
821815
Returns:
822816
AsyncDAVResponse with results containing SyncCollectionResult.
823817
"""
824-
body = _build_sync_collection_body(sync_token=sync_token, props=props)
818+
body = self._build_sync_collection_body(sync_token=sync_token, props=props)
825819

826820
final_headers = self._build_method_headers("REPORT", depth, headers)
827821
response = await self.request(

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.
@@ -196,12 +237,148 @@ def _raise_authorization_error(self, url_str: str, reason_source: Any) -> NoRetu
196237
reason = "None given"
197238
raise error.AuthorizationError(url=url_str, reason=reason)
198239

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

321+
@staticmethod
322+
def _build_calendar_multiget_body(
323+
hrefs: list[str],
324+
include_data: bool = True,
325+
) -> bytes:
326+
"""Build calendar-multiget REPORT request body."""
327+
elements: list[BaseElement] = []
328+
if include_data:
329+
elements.append(dav.Prop() + cdav.CalendarData())
330+
for href in hrefs:
331+
elements.append(dav.Href(href))
332+
multiget = cdav.CalendarMultiGet() + elements
333+
return etree.tostring(multiget.xmlelement(), encoding="utf-8", xml_declaration=True)
334+
335+
@staticmethod
336+
def _build_sync_collection_body(
337+
sync_token: str | None = None,
338+
props: list[str] | None = None,
339+
sync_level: str = "1",
340+
) -> bytes:
341+
"""Build sync-collection REPORT request body."""
342+
elements: list[BaseElement] = [
343+
dav.SyncToken(sync_token or ""),
344+
dav.SyncLevel(sync_level),
345+
]
346+
if props:
347+
prop_elements = [e for name in props if (e := _prop_name_to_element(name)) is not None]
348+
if prop_elements:
349+
elements.append(dav.Prop() + prop_elements)
350+
else:
351+
elements.append(dav.Prop() + [dav.GetEtag(), cdav.CalendarData()])
352+
sync_collection = dav.SyncCollection() + elements
353+
return etree.tostring(sync_collection.xmlelement(), encoding="utf-8", xml_declaration=True)
354+
355+
@staticmethod
356+
def _build_mkcalendar_body(
357+
displayname: str | None = None,
358+
description: str | None = None,
359+
timezone: str | None = None,
360+
supported_components: list[str] | None = None,
361+
) -> bytes:
362+
"""Build MKCALENDAR request body."""
363+
prop = dav.Prop()
364+
if displayname:
365+
prop += dav.DisplayName(displayname)
366+
if description:
367+
prop += cdav.CalendarDescription(description)
368+
if timezone:
369+
prop += cdav.CalendarTimeZone(timezone)
370+
if supported_components:
371+
sccs = cdav.SupportedCalendarComponentSet()
372+
for comp in supported_components:
373+
sccs += cdav.Comp(comp)
374+
prop += sccs
375+
prop += dav.ResourceType() + [dav.Collection(), cdav.Calendar()]
376+
mkcalendar = cdav.Mkcalendar() + (dav.Set() + prop)
377+
return etree.tostring(mkcalendar.xmlelement(), encoding="utf-8", xml_declaration=True)
378+
379+
@staticmethod
380+
def _build_principal_search_query(name: str | None) -> bytes:
381+
"""Build the XML body for a principal-property-search REPORT."""
205382
name_filter = (
206383
[dav.PropertySearch() + [dav.Prop() + [dav.DisplayName()]] + dav.Match(value=name)]
207384
if name

caldav/collection.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1800,11 +1800,9 @@ def get_objects_by_sync_token(
18001800

18011801
if use_sync_token:
18021802
try:
1803-
cmd = dav.SyncCollection()
1804-
token = dav.SyncToken(value=sync_token)
1805-
level = dav.SyncLevel(value="1")
1806-
props = dav.Prop() + dav.GetEtag()
1807-
root = cmd + [level, token, props]
1803+
root = self.client._build_sync_collection_body(
1804+
sync_token=sync_token, props=["getetag"]
1805+
)
18081806
(response, objects) = self._request_report_build_resultlist(
18091807
root, props=[dav.GetEtag()], no_calendardata=True
18101808
)
@@ -1931,11 +1929,9 @@ async def _async_get_objects_by_sync_token(
19311929

19321930
if use_sync_token:
19331931
try:
1934-
cmd = dav.SyncCollection()
1935-
token = dav.SyncToken(value=sync_token)
1936-
level = dav.SyncLevel(value="1")
1937-
props = dav.Prop() + dav.GetEtag()
1938-
root = cmd + [level, token, props]
1932+
root = self.client._build_sync_collection_body(
1933+
sync_token=sync_token, props=["getetag"]
1934+
)
19391935
(response, objects) = await self._request_report_build_resultlist(
19401936
root, props=[dav.GetEtag()], no_calendardata=True
19411937
)

caldav/davclient.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -703,13 +703,11 @@ def propfind(
703703
-------
704704
DAVResponse
705705
"""
706-
from caldav.protocol.xml_builders import _build_propfind_body
707-
708706
# Handle both old interface (props=xml_string) and new interface (props=list)
709707
body = ""
710708
if props is not None:
711709
if isinstance(props, list):
712-
body = _build_propfind_body(props).decode("utf-8")
710+
body = self._build_propfind_body(props).decode("utf-8")
713711
else:
714712
body = props # Old interface: props is XML string
715713

caldav/protocol/__init__.py

Lines changed: 0 additions & 20 deletions
This file was deleted.

0 commit comments

Comments
 (0)