chore: sync GovOPlaN module split state
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user