73f41a3474
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>
201 lines
7.3 KiB
Python
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}
|