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>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user