Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
parent 3bcb04ff76
commit b328a6d649
21 changed files with 5718 additions and 714 deletions

View File

@@ -0,0 +1,275 @@
from __future__ import annotations
import base64
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from typing import Mapping, Protocol
from xml.etree import ElementTree
class CalDAVError(RuntimeError):
pass
class CalDAVSyncUnsupported(CalDAVError):
pass
class CalDAVPreconditionFailed(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]:
...
@dataclass(frozen=True, slots=True)
class CalDAVObject:
href: str
etag: str | None = None
calendar_data: str | None = None
deleted: bool = False
@dataclass(frozen=True, slots=True)
class CalDAVReportResult:
objects: list[CalDAVObject] = field(default_factory=list)
sync_token: str | None = None
ctag: str | None = None
@dataclass(frozen=True, slots=True)
class CalDAVWriteResult:
href: str
etag: str | None = None
status: int = 0
class CalDAVClient:
def __init__(
self,
*,
collection_url: str,
username: str | None = None,
password: str | None = None,
bearer_token: str | None = None,
timeout: int = 30,
transport: CalDAVTransport | None = None,
) -> None:
self.collection_url = ensure_collection_url(collection_url)
self.username = username
self.password = password
self.bearer_token = bearer_token
self.timeout = timeout
self.transport = transport or urllib_transport
def propfind_collection(self) -> CalDAVReportResult:
body = b"""<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:" xmlns:CS="http://calendarserver.org/ns/">
<D:prop>
<D:displayname/>
<D:sync-token/>
<CS:getctag/>
</D:prop>
</D:propfind>"""
payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207})
return parse_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">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT"/>
</C:comp-filter>
</C:filter>
</C:calendar-query>"""
payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207})
return parse_multistatus(payload)
def sync_collection(self, sync_token: str) -> CalDAVReportResult:
body = f"""<?xml version="1.0" encoding="utf-8"?>
<D:sync-collection xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:sync-token>{xml_escape(sync_token)}</D:sync-token>
<D:sync-level>1</D:sync-level>
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
</D:sync-collection>""".encode("utf-8")
try:
payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207})
except CalDAVError as exc:
raise CalDAVSyncUnsupported(str(exc)) from exc
return parse_multistatus(payload)
def fetch_object(self, href: str) -> str:
payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200})
return payload.decode("utf-8")
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
headers = {"Content-Type": "text/calendar; charset=utf-8"}
if create:
headers["If-None-Match"] = "*"
elif etag and not overwrite:
headers["If-Match"] = etag
elif not overwrite:
raise CalDAVPreconditionFailed("Refusing CalDAV PUT without an ETag")
status, response_headers, _payload = self.request_raw(
"PUT",
self.object_url(href),
body=ics.encode("utf-8"),
depth=None,
expected={200, 201, 204},
extra_headers=headers,
)
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> CalDAVWriteResult:
headers: dict[str, str] = {}
if etag and not overwrite:
headers["If-Match"] = etag
elif not overwrite:
raise CalDAVPreconditionFailed("Refusing CalDAV DELETE without an ETag")
status, response_headers, _payload = self.request_raw(
"DELETE",
self.object_url(href),
body=None,
depth=None,
expected={200, 202, 204, 404},
extra_headers=headers,
)
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
def object_url(self, href: str) -> str:
return urllib.parse.urljoin(self.collection_url, href)
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
return payload
def request_raw(
self,
method: str,
url: str,
*,
body: bytes | None,
depth: str | None,
expected: set[int],
extra_headers: Mapping[str, str] | None = None,
) -> tuple[int, Mapping[str, str], bytes]:
headers: dict[str, str] = {
"Accept": "application/xml,text/calendar,*/*",
"User-Agent": "govoplan-calendar-caldav/0.1",
}
if body is not None:
headers["Content-Type"] = "application/xml; charset=utf-8"
if depth is not None:
headers["Depth"] = depth
if self.bearer_token:
headers["Authorization"] = f"Bearer {self.bearer_token}"
elif self.username and self.password:
token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {token}"
if extra_headers:
headers.update(dict(extra_headers))
status, response_headers, payload = self.transport(method, url, headers, body, self.timeout)
if status not in expected:
if status == 412:
raise CalDAVPreconditionFailed(f"{method} {url} failed because the remote resource changed")
raise CalDAVError(f"{method} {url} returned HTTP {status}")
return status, response_headers, payload
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:
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
def parse_multistatus(payload: bytes) -> CalDAVReportResult:
try:
root = ElementTree.fromstring(payload)
except ElementTree.ParseError as exc:
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
objects: list[CalDAVObject] = []
sync_token = first_child_text(root, "sync-token")
ctag = first_child_text(root, "getctag")
for response in child_elements(root, "response"):
href = first_child_text(response, "href")
if not href:
continue
deleted = False
etag = None
calendar_data = None
for propstat in child_elements(response, "propstat"):
status = first_child_text(propstat, "status") or ""
prop = first_child(propstat, "prop")
if prop is None:
continue
if " 404 " in status or status.endswith(" 404"):
deleted = True
continue
if " 200 " not in status and not status.endswith(" 200"):
continue
etag = first_child_text(prop, "getetag") or etag
calendar_data = first_child_text(prop, "calendar-data") or calendar_data
sync_token = first_child_text(prop, "sync-token") or sync_token
ctag = first_child_text(prop, "getctag") or ctag
objects.append(CalDAVObject(href=href, etag=strip_weak_etag(etag), calendar_data=calendar_data, deleted=deleted))
return CalDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag)
def ensure_collection_url(value: str) -> str:
return value if value.endswith("/") else f"{value}/"
def strip_weak_etag(value: str | None) -> str | None:
if value is None:
return None
return value.strip()
def response_etag(headers: Mapping[str, str]) -> str | None:
for key, value in headers.items():
if key.lower() == "etag" and value:
return strip_weak_etag(value)
return None
def xml_escape(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def first_child(element: ElementTree.Element, name: str) -> ElementTree.Element | None:
for child in element:
if local_name(child.tag) == name:
return child
return None
def first_child_text(element: ElementTree.Element, name: str) -> str | None:
found = first_child(element, name)
if found is None or found.text is None:
return None
return found.text.strip()
def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.Element]:
return [child for child in element if local_name(child.tag) == name]
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if "}" in tag else tag