chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:18 +02:00
parent c3c867391c
commit 15d5613f9b
20 changed files with 4704 additions and 902 deletions

View File

@@ -21,6 +21,10 @@ class CalDAVPreconditionFailed(CalDAVError):
pass
class CalDAVNotFound(CalDAVError):
pass
class CalDAVTransport(Protocol):
def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
...
@@ -48,6 +52,29 @@ class CalDAVWriteResult:
status: int = 0
@dataclass(frozen=True, slots=True)
class CalDAVDiscoveryCalendar:
collection_url: str
href: str
display_name: str | None = None
color: str | None = None
ctag: str | None = None
sync_token: str | None = None
@dataclass(frozen=True, slots=True)
class _DAVDiscoveryResponse:
href: str
display_name: str | None = None
color: str | None = None
ctag: str | None = None
sync_token: str | None = None
is_calendar: bool = False
principal_hrefs: tuple[str, ...] = ()
calendar_home_set_hrefs: tuple[str, ...] = ()
supported_components: tuple[str, ...] = ()
class CalDAVClient:
def __init__(
self,
@@ -78,6 +105,96 @@ class CalDAVClient:
payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207})
return parse_multistatus(payload)
def discover_calendars(self) -> list[CalDAVDiscoveryCalendar]:
start_url = self.collection_url
calendars: dict[str, CalDAVDiscoveryCalendar] = {}
home_urls: list[str] = []
principal_urls: list[str] = []
visited_urls: set[tuple[str, str]] = set()
errors: list[str] = []
def add_home_href(base_url: str, href: str) -> None:
url = ensure_collection_url(absolute_dav_url(base_url, href))
if url not in home_urls:
home_urls.append(url)
def add_principal_href(base_url: str, href: str) -> None:
url = ensure_collection_url(absolute_dav_url(base_url, href))
if url not in principal_urls:
principal_urls.append(url)
def add_calendar(base_url: str, response: _DAVDiscoveryResponse) -> None:
if not response.is_calendar:
return
if response.supported_components and "VEVENT" not in response.supported_components:
return
url = ensure_collection_url(absolute_dav_url(base_url, response.href or base_url))
calendars[url] = CalDAVDiscoveryCalendar(
collection_url=url,
href=response.href,
display_name=response.display_name,
color=response.color,
ctag=response.ctag,
sync_token=response.sync_token,
)
def propfind(url: str, depth: str) -> list[_DAVDiscoveryResponse]:
key = (url, depth)
if key in visited_urls:
return []
visited_urls.add(key)
return self.propfind_discovery(url, depth=depth)
try:
for response in propfind(start_url, "0"):
add_calendar(start_url, response)
for href in response.calendar_home_set_hrefs:
add_home_href(start_url, href)
for href in response.principal_hrefs:
add_principal_href(start_url, href)
except CalDAVError as exc:
errors.append(str(exc))
for principal_url in principal_urls[:6]:
try:
for response in propfind(principal_url, "0"):
for href in response.calendar_home_set_hrefs:
add_home_href(principal_url, href)
except CalDAVError as exc:
errors.append(str(exc))
if not home_urls:
home_urls.append(start_url)
for home_url in home_urls:
try:
for response in propfind(home_url, "1"):
add_calendar(home_url, response)
except CalDAVError as exc:
errors.append(str(exc))
if not calendars and errors:
raise CalDAVError(f"CalDAV discovery did not find any calendar collections: {errors[0]}")
return sorted(calendars.values(), key=lambda item: ((item.display_name or item.collection_url).lower(), item.collection_url))
def propfind_discovery(self, url: str, *, depth: str) -> list[_DAVDiscoveryResponse]:
body = b"""<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<D:prop>
<D:displayname/>
<D:current-user-principal/>
<D:principal-URL/>
<D:resourcetype/>
<D:sync-token/>
<C:calendar-home-set/>
<C:supported-calendar-component-set/>
<CS:getctag/>
<ICAL:calendar-color/>
</D:prop>
</D:propfind>"""
payload = self.request("PROPFIND", ensure_collection_url(url), body=body, depth=depth, expected={207})
return parse_discovery_multistatus(payload)
def list_objects(self) -> CalDAVReportResult:
body = b"""<?xml version="1.0" encoding="utf-8"?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
@@ -182,6 +299,8 @@ class CalDAVClient:
headers.update(dict(extra_headers))
status, response_headers, payload = self.transport(method, url, headers, body, self.timeout)
if status not in expected:
if status == 404:
raise CalDAVNotFound(f"{method} {url} returned HTTP {status}")
if status == 412:
raise CalDAVPreconditionFailed(f"{method} {url} failed because the remote resource changed")
raise CalDAVError(f"{method} {url} returned HTTP {status}")
@@ -189,14 +308,16 @@ class CalDAVClient:
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
try:
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.status, dict(response.headers.items()), response.read()
except urllib.error.HTTPError as exc:
return exc.code, dict(exc.headers.items()), exc.read()
except urllib.error.URLError as exc:
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
except ValueError as exc:
raise CalDAVError(f"{method} {url} failed: {exc}") from exc
def parse_multistatus(payload: bytes) -> CalDAVReportResult:
@@ -232,10 +353,80 @@ def parse_multistatus(payload: bytes) -> CalDAVReportResult:
return CalDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag)
def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]:
try:
root = ElementTree.fromstring(payload)
except ElementTree.ParseError as exc:
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
responses: list[_DAVDiscoveryResponse] = []
for response in child_elements(root, "response"):
href = first_child_text(response, "href")
if not href:
continue
display_name = None
color = None
ctag = None
sync_token = None
is_calendar = False
principal_hrefs: list[str] = []
calendar_home_set_hrefs: list[str] = []
supported_components: list[str] = []
for propstat in child_elements(response, "propstat"):
status = first_child_text(propstat, "status") or ""
if status and " 200 " not in status and not status.endswith(" 200") and " 207 " not in status:
continue
prop = first_child(propstat, "prop")
if prop is None:
continue
for item in prop:
name = local_name(item.tag)
if name == "displayname" and item.text:
display_name = item.text.strip() or display_name
elif name == "calendar-color" and item.text:
color = item.text.strip() or color
elif name == "getctag" and item.text:
ctag = item.text.strip() or ctag
elif name == "sync-token" and item.text:
sync_token = item.text.strip() or sync_token
elif name == "resourcetype":
is_calendar = is_calendar or any(local_name(child.tag) == "calendar" for child in item)
elif name in {"current-user-principal", "principal-URL"}:
principal_hrefs.extend(nested_href_texts(item))
elif name == "calendar-home-set":
calendar_home_set_hrefs.extend(nested_href_texts(item))
elif name == "supported-calendar-component-set":
for component in item:
if local_name(component.tag) == "comp":
component_name = (component.attrib.get("name") or "").strip().upper()
if component_name:
supported_components.append(component_name)
responses.append(
_DAVDiscoveryResponse(
href=href,
display_name=display_name,
color=color,
ctag=ctag,
sync_token=sync_token,
is_calendar=is_calendar,
principal_hrefs=tuple(dict.fromkeys(principal_hrefs)),
calendar_home_set_hrefs=tuple(dict.fromkeys(calendar_home_set_hrefs)),
supported_components=tuple(dict.fromkeys(supported_components)),
)
)
return responses
def ensure_collection_url(value: str) -> str:
value = value.strip()
if value and "://" not in value and not value.startswith("/"):
value = f"https://{value}"
return value if value.endswith("/") else f"{value}/"
def absolute_dav_url(base_url: str, href: str) -> str:
return urllib.parse.urljoin(ensure_collection_url(base_url), href)
def strip_weak_etag(value: str | None) -> str | None:
if value is None:
return None
@@ -271,5 +462,13 @@ def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.
return [child for child in element if local_name(child.tag) == name]
def nested_href_texts(element: ElementTree.Element) -> list[str]:
hrefs: list[str] = []
for child in element.iter():
if local_name(child.tag) == "href" and child.text and child.text.strip():
hrefs.append(child.text.strip())
return hrefs
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if "}" in tag else tag