Files
fn_registry/python/functions/infra/dav_get_collection.py
T
egutierrez 73f41a3474 feat(dav): dav_get_collection + dav_collection_ctag — bulk DAV en 1 request + ctag cache
dav_get_collection trae TODOS los recursos de una coleccion CardDAV/CalDAV en
UNA peticion REPORT (addressbook-query / calendar-query) con el contenido vCard
/ VCALENDAR inline, evitando el patron N+1 (PROPFIND + un GET por recurso). Para
1064 contactos baja de ~9s a ~1s. dav_collection_ctag lee el ctag de la
coleccion (PROPFIND Depth:0 barato) para validar caches sin descargar cuando
nada cambio. Ambas: solo stdlib, basic auth, verify_tls, error-safe, tests que
mockean el multistatus. Grupo dav, verificadas contra Xandikos real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:07:39 +02:00

201 lines
7.3 KiB
Python

"""Descarga TODOS los recursos de una coleccion DAV en UNA peticion (REPORT).
Funcion impura: hace una unica peticion HTTP REPORT (`addressbook-query` para
CardDAV, `calendar-query` para CalDAV) que el servidor responde con un XML
multistatus que lleva el contenido de cada recurso INLINE (vCard / VCALENDAR).
Esto reemplaza el patron N+1 (un PROPFIND + un GET por recurso) por una sola
ida y vuelta: para 1064 contactos baja de ~9s a ~1s.
Construye el header `Authorization: Basic base64(user:pass)` a mano con stdlib y
parsea el multistatus con regex simple (sin parser XML externo), des-escapando
las entidades XML del contenido inline. Maneja errores sin lanzar. Solo usa
stdlib (urllib, base64, re, ssl, html). Probado contra Xandikos.
"""
import base64
import html
import re
import ssl
import urllib.error
import urllib.request
# Namespaces y elementos por tipo de coleccion. Xandikos (y la mayoria de
# servidores) usan el namespace "legacy" con `:ns:` que es el que aparece en el
# supported-report-set, no `urn:ietf:params:xml:carddav`.
_PROFILES = {
"vcard": {
"ns": "urn:ietf:params:xml:ns:carddav",
"report": "addressbook-query",
"data_prop": "address-data",
},
"ical": {
"ns": "urn:ietf:params:xml:ns:caldav",
"report": "calendar-query",
"data_prop": "calendar-data",
},
}
# Aceptamos sinonimos comunes para no atar al caller a un literal exacto.
_ALIASES = {
"vcard": "vcard",
"carddav": "vcard",
"contacts": "vcard",
"addressbook": "vcard",
"ical": "ical",
"icalendar": "ical",
"caldav": "ical",
"calendar": "ical",
}
_RESPONSE_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?response>(.*?)</(?:[A-Za-z0-9]+:)?response>",
re.DOTALL | re.IGNORECASE,
)
_HREF_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?href>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?href>",
re.DOTALL | re.IGNORECASE,
)
_ETAG_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?getetag>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?getetag>",
re.DOTALL | re.IGNORECASE,
)
def _basic_auth_header(username: str, password: str) -> str:
raw = ("%s:%s" % (username, password)).encode("utf-8")
return "Basic " + base64.b64encode(raw).decode("ascii")
def _join_url(base_url: str, collection_path: str) -> str:
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/"
def _report_body(profile: dict) -> str:
"""Construye el cuerpo XML del REPORT query para el perfil dado.
`addressbook-query` (CardDAV) no lleva filtro de tiempo: trae todos los
vCards. `calendar-query` (CalDAV) exige un `<filter>` con un comp-filter de
VCALENDAR; sin un comp-filter interno trae todos los componentes (todos los
eventos), que es lo que queremos.
"""
ns = profile["ns"]
report = profile["report"]
data_prop = profile["data_prop"]
prop = "<D:prop><D:getetag/><C:%s/></D:prop>" % data_prop
if report == "calendar-query":
filt = '<C:filter><C:comp-filter name="VCALENDAR"/></C:filter>'
else:
filt = ""
return (
'<?xml version="1.0" encoding="utf-8" ?>'
'<C:%s xmlns:D="DAV:" xmlns:C="%s">%s%s</C:%s>'
% (report, ns, prop, filt, report)
)
def _data_re(data_prop: str) -> "re.Pattern":
"""Regex para extraer el contenido inline del elemento de datos.
El servidor namespacea el elemento (`<ns1:address-data>`); el contenido va
XML-escapado. Capturamos el cuerpo y lo des-escapamos con html.unescape.
"""
return re.compile(
r"<(?:[A-Za-z0-9]+:)?%s[^>]*>(.*?)</(?:[A-Za-z0-9]+:)?%s>"
% (re.escape(data_prop), re.escape(data_prop)),
re.DOTALL | re.IGNORECASE,
)
def dav_get_collection(
base_url: str,
username: str,
password: str,
collection_path: str,
content_type: str = "vcard",
*,
timeout_s: float = 30.0,
verify_tls: bool = True,
) -> dict:
"""Descarga el contenido de TODOS los recursos de una coleccion en 1 request.
Hace un REPORT `addressbook-query` (vcard) o `calendar-query` (ical) que
devuelve el multistatus con el contenido inline de cada recurso, evitando
el patron N+1 (PROPFIND + un GET por recurso).
Args:
base_url: URL base del servidor DAV.
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth. Resolver desde pass.
collection_path: ruta de la coleccion (CardDAV o CalDAV).
content_type: 'vcard' (CardDAV) o 'ical' (CalDAV). Acepta sinonimos
('carddav', 'contacts', 'caldav', 'calendar', ...).
timeout_s: timeout de la peticion en segundos. Default 30.0 (la
respuesta puede ser grande: ~600KB para 1000 contactos).
verify_tls: si True (default) verifica el certificado TLS.
Returns:
dict. En exito: {status:'ok', http_status:int,
resources:[{href:str, etag:str|None, data:str}, ...]} con un elemento
por recurso de la coleccion (el `data` es el vCard / VCALENDAR ya
des-escapado). En error (sin lanzar): {status:'error', error:str,
http_status:int|None}.
"""
key = _ALIASES.get((content_type or "").strip().lower())
if key is None:
return {
"status": "error",
"error": "content_type invalido: %r (usa 'vcard' o 'ical')"
% content_type,
"http_status": None,
}
profile = _PROFILES[key]
url = _join_url(base_url, collection_path)
headers = {
"Authorization": _basic_auth_header(username, password),
"Content-Type": "application/xml; charset=utf-8",
# RFC 6352 / 4791: el query REPORT se aplica con Depth:1 sobre la
# coleccion (multiget exigiria Depth:0 + lista de hrefs; query no).
"Depth": "1",
}
req = urllib.request.Request(
url,
data=_report_body(profile).encode("utf-8"),
method="REPORT",
headers=headers,
)
context = None if verify_tls else ssl._create_unverified_context()
try:
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
status = resp.status
xml = resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
except urllib.error.URLError as e:
return {"status": "error", "error": str(e.reason), "http_status": None}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e), "http_status": None}
coll_tail = collection_path.strip("/").rsplit("/", 1)[-1]
data_re = _data_re(profile["data_prop"])
resources = []
for block in _RESPONSE_RE.findall(xml):
href_m = _HREF_RE.search(block)
if not href_m:
continue
href = href_m.group(1).strip()
# Skip la propia coleccion si el servidor la incluyera.
tail = href.rstrip("/").rsplit("/", 1)[-1]
if tail == coll_tail:
continue
data_m = data_re.search(block)
if not data_m:
# Recurso sin contenido inline (404 en ese propstat): se omite.
continue
data = html.unescape(data_m.group(1)).strip()
etag_m = _ETAG_RE.search(block)
etag = etag_m.group(1).strip() if etag_m else None
resources.append({"href": href, "etag": etag, "data": data})
return {"status": "ok", "http_status": status, "resources": resources}