"""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>(.*?)", re.DOTALL | re.IGNORECASE, ) _HREF_RE = re.compile( r"<(?:[A-Za-z0-9]+:)?href>\s*(.*?)\s*", re.DOTALL | re.IGNORECASE, ) _ETAG_RE = re.compile( r"<(?:[A-Za-z0-9]+:)?getetag>\s*(.*?)\s*", 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 `` 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 = "" % data_prop if report == "calendar-query": filt = '' else: filt = "" return ( '' '%s%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 (``); el contenido va XML-escapado. Capturamos el cuerpo y lo des-escapamos con html.unescape. """ return re.compile( r"<(?:[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}