feat(dav,obsidian): grupo dav completo (CardDAV/CalDAV client + split vcf/ics + import pipelines) + build_obsidian_graph + dav_list_calendars

Funciones reutilizables creadas esta sesion para el sistema self-hosted de contactos/calendario (Xandikos) y la app osint_web:
- grupo dav (infra): split_vcards, split_vevents_to_vcalendars, extract_or_make_uid, carddav_put_vcard, caldav_put_event, dav_list_resources, dav_get_resource, dav_list_calendars
- pipelines: import_vcf_to_carddav, import_ics_to_caldav
- obsidian: build_obsidian_graph (grafo agregado del vault)
This commit is contained in:
2026-06-12 00:43:59 +02:00
parent 4a0f0e9dc0
commit a76760edba
32 changed files with 2814 additions and 0 deletions
@@ -0,0 +1,91 @@
---
name: caldav_put_event
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def caldav_put_event(base_url: str, username: str, password: str, collection_path: str, uid: str, vcalendar_text: str, *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
description: "Sube (HTTP PUT) un VCALENDAR (con un VEVENT) a una coleccion CalDAV con HTTP Basic auth. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib. El nombre del recurso se deriva del UID saneado (safe(uid)+'.ics'). verify_tls=True por defecto. Idempotente por UID: re-subir el mismo UID sobrescribe el recurso. Maneja errores sin lanzar (HTTPError/URLError -> {status:'error'}). Solo stdlib (urllib, base64, re, ssl). Probado contra Xandikos."
tags: [dav, caldav, ical, ics, vevent, http, put, calendar, infra, upload]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [base64, re, ssl, urllib.error, urllib.request]
params:
- name: base_url
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
- name: username
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
- name: password
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
- name: collection_path
desc: "ruta de la coleccion CalDAV (p.ej. '/enmanuel/calendars/calendar/')."
- name: uid
desc: "UID del evento; se sanea ([^A-Za-z0-9_.-]->_ , max 120 chars) para formar el nombre del recurso .ics."
- name: vcalendar_text
desc: "texto completo del VCALENDAR (BEGIN:VCALENDAR..END:VCALENDAR) con un VEVENT. Se asegura terminacion en CRLF."
- name: timeout_s
desc: "timeout de la peticion HTTP en segundos. Default 20.0."
- name: verify_tls
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
output: "dict. En exito: {status:'ok', http_status:int, url:str}. En error (sin lanzar): {status:'error', error:str, http_status:int|None}. http_status es el codigo HTTP devuelto (201 created / 204 no content tipico en CalDAV)."
tested: true
tests:
- "test_construye_request_put_con_headers_correctos"
- "test_url_se_forma_con_uid_saneado"
- "test_content_type_es_text_calendar"
- "test_extension_es_ics"
- "test_httperror_devuelve_status_error"
test_file_path: "python/functions/infra/caldav_put_event_test.py"
file_path: "python/functions/infra/caldav_put_event.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from infra.caldav_put_event import caldav_put_event
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
cal = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\nCALSCALE:GREGORIAN\r\n"
"BEGIN:VEVENT\r\nUID:evt-1@google.com\r\nSUMMARY:Reunion\r\n"
"DTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
res = caldav_put_event(
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
collection_path="/enmanuel/calendars/calendar/",
uid="evt-1@google.com",
vcalendar_text=cal,
)
print(res) # {"status": "ok", "http_status": 201, "url": ".../evt-1_google.com.ics"}
```
## Cuando usarla
Cuando quieres subir un evento individual a Xandikos (u otro servidor CalDAV)
por HTTP. Es la primitiva de escritura de calendario del grupo `dav`; el
pipeline `import_ics_to_caldav` la invoca por cada VCALENDAR producido por
`split_vevents_to_vcalendars`. Antes de llamarla, resuelve el UID con
`extract_or_make_uid` y la password con `pass_get_secret`.
## Gotchas
- Escritura remota real: re-subir el mismo UID SOBRESCRIBE el recurso
(idempotente, no duplica).
- El VCALENDAR debe ser completo y autonomo (header + VTIMEZONE necesarias +
un VEVENT). Subir un VEVENT suelto sin envolver en VCALENDAR fallara.
- Contrasena en header Basic sobre TLS; nunca hardcodear, leer de `pass`. No se
logea.
- `verify_tls=False` solo en pruebas; abre MITM.
- Devuelve dict (status/http_status/error), NO un int crudo: captura errores
HTTP/red sin lanzar.
@@ -0,0 +1,83 @@
"""Sube (PUT) un VCALENDAR a una coleccion CalDAV via HTTP Basic auth.
Funcion impura: hace una peticion HTTP PUT. Construye el header
`Authorization: Basic base64(user:pass)` a mano con stdlib. El nombre del
recurso se deriva del UID saneado (`safe(uid) + '.ics'`). Maneja errores sin
lanzar: devuelve {status: 'ok', http_status: int} en exito o
{status: 'error', error: str}. Solo usa stdlib (urllib, base64, re, ssl).
"""
import base64
import re
import ssl
import urllib.error
import urllib.request
_UNSAFE_RE = re.compile(r"[^A-Za-z0-9_.-]")
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 _safe_resource_name(uid: str, ext: str) -> str:
safe = _UNSAFE_RE.sub("_", uid)[:120]
return safe + ext
def _join_url(base_url: str, collection_path: str, resource: str) -> str:
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/" + resource
def caldav_put_event(
base_url: str,
username: str,
password: str,
collection_path: str,
uid: str,
vcalendar_text: str,
*,
timeout_s: float = 20.0,
verify_tls: bool = True,
) -> dict:
"""Sube un VCALENDAR (con un VEVENT) a una coleccion CalDAV (PUT).
Args:
base_url: URL base del servidor DAV (p.ej. 'https://dav-x.example.com').
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth.
collection_path: ruta de la coleccion CalDAV (p.ej.
'/enmanuel/calendars/calendar/').
uid: UID del evento; se sanea para formar el nombre del recurso.
vcalendar_text: texto completo del VCALENDAR (BEGIN:VCALENDAR..END).
timeout_s: timeout de la peticion en segundos. Default 20.0.
verify_tls: si True (default) verifica el certificado TLS. No desactivar
salvo en entornos de prueba controlados.
Returns:
dict. En exito: {status: 'ok', http_status: int, url: str}. En error
(sin lanzar): {status: 'error', error: str, http_status: int|None}.
"""
resource = _safe_resource_name(uid, ".ics")
url = _join_url(base_url, collection_path, resource)
body = vcalendar_text
if not body.endswith("\r\n"):
body = body.rstrip("\r\n") + "\r\n"
data = body.encode("utf-8")
headers = {
"Authorization": _basic_auth_header(username, password),
"Content-Type": "text/calendar; charset=utf-8",
}
req = urllib.request.Request(url, data=data, method="PUT", 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:
return {"status": "ok", "http_status": resp.status, "url": url}
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}
@@ -0,0 +1,88 @@
"""Tests para caldav_put_event.
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
Request object (URL, method, headers) sin enviarlo a un servidor real.
"""
import sys
import infra.caldav_put_event # noqa: F401
mod = sys.modules["infra.caldav_put_event"]
caldav_put_event = mod.caldav_put_event
_CAL = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\nCALSCALE:GREGORIAN\r\n"
"BEGIN:VEVENT\r\nUID:evt-1@google.com\r\nSUMMARY:Reunion\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
class _FakeResp:
status = 201
def __enter__(self):
return self
def __exit__(self, *a):
return False
def _capture(monkeypatch):
captured = {}
def fake_urlopen(req, timeout=None, context=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
return _FakeResp()
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
return captured
def _call():
return caldav_put_event(
base_url="https://dav.example.com",
username="enmanuel",
password="secret-pw",
collection_path="/enmanuel/calendars/calendar/",
uid="evt-1@google.com",
vcalendar_text=_CAL,
)
def test_construye_request_put_con_headers_correctos(monkeypatch):
cap = _capture(monkeypatch)
res = _call()
assert res["status"] == "ok"
assert res["http_status"] == 201
assert cap["method"] == "PUT"
def test_url_se_forma_con_uid_saneado(monkeypatch):
cap = _capture(monkeypatch)
_call()
assert cap["url"].endswith("/enmanuel/calendars/calendar/evt-1_google.com.ics")
def test_content_type_es_text_calendar(monkeypatch):
cap = _capture(monkeypatch)
_call()
assert cap["headers"]["content-type"] == "text/calendar; charset=utf-8"
def test_extension_es_ics(monkeypatch):
cap = _capture(monkeypatch)
_call()
assert cap["url"].endswith(".ics")
def test_httperror_devuelve_status_error(monkeypatch):
def fake_urlopen(req, timeout=None, context=None):
raise mod.urllib.error.HTTPError(req.full_url, 403, "Forbidden", {}, None)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
res = _call()
assert res["status"] == "error"
assert res["http_status"] == 403
@@ -0,0 +1,85 @@
---
name: carddav_put_vcard
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def carddav_put_vcard(base_url: str, username: str, password: str, collection_path: str, uid: str, vcard_text: str, *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
description: "Sube (HTTP PUT) un VCARD a una coleccion CardDAV con HTTP Basic auth. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib. El nombre del recurso se deriva del UID saneado (safe(uid)+'.vcf'). verify_tls=True por defecto. Idempotente por UID: re-subir el mismo UID sobrescribe el recurso. Maneja errores sin lanzar (HTTPError/URLError -> {status:'error'}). Solo stdlib (urllib, base64, re, ssl). Probado contra Xandikos."
tags: [dav, carddav, vcard, http, put, contacts, infra, upload]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [base64, re, ssl, urllib.error, urllib.request]
params:
- name: base_url
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
- name: username
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
- name: password
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
- name: collection_path
desc: "ruta de la coleccion CardDAV (p.ej. '/enmanuel/contacts/addressbook/')."
- name: uid
desc: "UID del contacto; se sanea ([^A-Za-z0-9_.-]->_ , max 120 chars) para formar el nombre del recurso .vcf."
- name: vcard_text
desc: "texto completo del VCARD (BEGIN:VCARD..END:VCARD). Se asegura terminacion en CRLF."
- name: timeout_s
desc: "timeout de la peticion HTTP en segundos. Default 20.0."
- name: verify_tls
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
output: "dict. En exito: {status:'ok', http_status:int, url:str}. En error (sin lanzar): {status:'error', error:str, http_status:int|None}. http_status es el codigo HTTP devuelto por el servidor (201 created / 204 no content tipico en CardDAV)."
tested: true
tests:
- "test_construye_request_put_con_headers_correctos"
- "test_url_se_forma_con_uid_saneado"
- "test_content_type_es_text_vcard"
- "test_basic_auth_header_correcto"
- "test_httperror_devuelve_status_error"
test_file_path: "python/functions/infra/carddav_put_vcard_test.py"
file_path: "python/functions/infra/carddav_put_vcard.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from infra.carddav_put_vcard import carddav_put_vcard
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
res = carddav_put_vcard(
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
collection_path="/enmanuel/contacts/addressbook/",
uid="abc-123@google.com",
vcard_text="BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nUID:abc-123@google.com\r\nEND:VCARD\r\n",
)
print(res) # {"status": "ok", "http_status": 201, "url": ".../abc-123_google.com.vcf"}
```
## Cuando usarla
Cuando quieres subir un contacto individual a Xandikos (u otro servidor CardDAV)
por HTTP. Es la primitiva de escritura del grupo `dav`; el pipeline
`import_vcf_to_carddav` la invoca por cada tarjeta de un .vcf. Antes de llamarla,
resuelve el UID con `extract_or_make_uid` y la password con `pass_get_secret`.
## Gotchas
- Hace una escritura remota real: re-subir el mismo UID SOBRESCRIBE el recurso
en el servidor (idempotente, no acumula duplicados — esa es la intencion).
- La contrasena va en el header Basic en claro sobre TLS; nunca hardcodear, leer
de `pass`. La funcion no logea la password.
- `verify_tls=False` solo para entornos de prueba; deja un agujero MITM.
- El servidor puede rechazar (4xx) si el path de la coleccion no existe o el UID
del nombre del recurso no coincide con el UID dentro del VCARD: asegurate de
que el mismo UID se usa para el nombre del archivo y para el campo UID:.
- Devuelve dict (status/http_status/error), NO un int crudo: asi captura errores
HTTP/red sin lanzar. Consulta `res["http_status"]` para el codigo.
@@ -0,0 +1,83 @@
"""Sube (PUT) un VCARD a una coleccion CardDAV via HTTP Basic auth.
Funcion impura: hace una peticion HTTP PUT. Construye el header
`Authorization: Basic base64(user:pass)` a mano con stdlib. El nombre del
recurso se deriva del UID saneado (`safe(uid) + '.vcf'`). Maneja errores sin
lanzar: devuelve {status: 'ok', http_status: int} en exito o
{status: 'error', error: str}. Solo usa stdlib (urllib, base64, re, ssl).
"""
import base64
import re
import ssl
import urllib.error
import urllib.request
_UNSAFE_RE = re.compile(r"[^A-Za-z0-9_.-]")
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 _safe_resource_name(uid: str, ext: str) -> str:
safe = _UNSAFE_RE.sub("_", uid)[:120]
return safe + ext
def _join_url(base_url: str, collection_path: str, resource: str) -> str:
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/" + resource
def carddav_put_vcard(
base_url: str,
username: str,
password: str,
collection_path: str,
uid: str,
vcard_text: str,
*,
timeout_s: float = 20.0,
verify_tls: bool = True,
) -> dict:
"""Sube un VCARD a una coleccion CardDAV (PUT).
Args:
base_url: URL base del servidor DAV (p.ej. 'https://dav-x.example.com').
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth.
collection_path: ruta de la coleccion CardDAV (p.ej.
'/enmanuel/contacts/addressbook/').
uid: UID del contacto; se sanea para formar el nombre del recurso.
vcard_text: texto completo del VCARD (BEGIN:VCARD..END:VCARD).
timeout_s: timeout de la peticion en segundos. Default 20.0.
verify_tls: si True (default) verifica el certificado TLS. No desactivar
salvo en entornos de prueba controlados.
Returns:
dict. En exito: {status: 'ok', http_status: int, url: str}. En error
(sin lanzar): {status: 'error', error: str, http_status: int|None}.
"""
resource = _safe_resource_name(uid, ".vcf")
url = _join_url(base_url, collection_path, resource)
body = vcard_text
if not body.endswith("\r\n"):
body = body.rstrip("\r\n") + "\r\n"
data = body.encode("utf-8")
headers = {
"Authorization": _basic_auth_header(username, password),
"Content-Type": "text/vcard; charset=utf-8",
}
req = urllib.request.Request(url, data=data, method="PUT", 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:
return {"status": "ok", "http_status": resp.status, "url": url}
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}
@@ -0,0 +1,88 @@
"""Tests para carddav_put_vcard.
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
Request object (URL, method, headers, body) sin enviarlo a un servidor real.
"""
import base64
import sys
import infra.carddav_put_vcard # noqa: F401
mod = sys.modules["infra.carddav_put_vcard"]
carddav_put_vcard = mod.carddav_put_vcard
class _FakeResp:
status = 201
def __enter__(self):
return self
def __exit__(self, *a):
return False
def _capture(monkeypatch):
captured = {}
def fake_urlopen(req, timeout=None, context=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["headers"] = dict(req.header_items())
captured["body"] = req.data
return _FakeResp()
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
return captured
def _call():
return carddav_put_vcard(
base_url="https://dav.example.com",
username="enmanuel",
password="secret-pw",
collection_path="/enmanuel/contacts/addressbook/",
uid="abc-123@google.com",
vcard_text="BEGIN:VCARD\r\nFN:Ada\r\nUID:abc-123@google.com\r\nEND:VCARD",
)
def test_construye_request_put_con_headers_correctos(monkeypatch):
cap = _capture(monkeypatch)
res = _call()
assert res == {"status": "ok", "http_status": 201, "url": cap["url"]}
assert cap["method"] == "PUT"
def test_url_se_forma_con_uid_saneado(monkeypatch):
cap = _capture(monkeypatch)
_call()
# El '@' del uid se sanea a '_'.
assert cap["url"].endswith("/enmanuel/contacts/addressbook/abc-123_google.com.vcf")
def test_content_type_es_text_vcard(monkeypatch):
cap = _capture(monkeypatch)
_call()
# urllib capitaliza las claves de header.
headers = {k.lower(): v for k, v in cap["headers"].items()}
assert headers["content-type"] == "text/vcard; charset=utf-8"
def test_basic_auth_header_correcto(monkeypatch):
cap = _capture(monkeypatch)
_call()
headers = {k.lower(): v for k, v in cap["headers"].items()}
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
assert headers["authorization"] == expected
def test_httperror_devuelve_status_error(monkeypatch):
def fake_urlopen(req, timeout=None, context=None):
raise mod.urllib.error.HTTPError(req.full_url, 409, "Conflict", {}, None)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
res = _call()
assert res["status"] == "error"
assert res["http_status"] == 409
@@ -0,0 +1,75 @@
---
name: dav_get_resource
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def dav_get_resource(base_url: str, username: str, password: str, resource_path: str, *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
description: "Descarga (HTTP GET) el contenido de un recurso DAV individual (un VCARD o un VCALENDAR) con HTTP Basic auth. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib. El resource_path puede ser un href absoluto (como los que devuelve dav_list_resources) o una URL completa. verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, ssl). Probado contra Xandikos."
tags: [dav, carddav, caldav, get, download, http, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [base64, ssl, urllib.error, urllib.request]
params:
- name: base_url
desc: "URL base del servidor DAV. Se ignora si resource_path ya es una URL absoluta."
- name: username
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
- name: password
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
- name: resource_path
desc: "href absoluto (p.ej. '/enmanuel/contacts/addressbook/x.vcf') o URL completa del recurso a descargar. Acepta directamente los hrefs que devuelve dav_list_resources."
- name: timeout_s
desc: "timeout de la peticion HTTP en segundos. Default 20.0."
- name: verify_tls
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
output: "dict. En exito: {status:'ok', http_status:int, text:str, url:str} donde text es el cuerpo del recurso (VCARD o VCALENDAR). En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
tested: true
tests:
- "test_construye_request_get_con_auth"
- "test_resource_path_relativo_se_resuelve_con_base_url"
- "test_resource_path_absoluto_se_respeta"
- "test_devuelve_texto_del_recurso"
- "test_httperror_devuelve_status_error"
test_file_path: "python/functions/infra/dav_get_resource_test.py"
file_path: "python/functions/infra/dav_get_resource.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from infra.dav_list_resources import dav_list_resources
from infra.dav_get_resource import dav_get_resource
base = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
listing = dav_list_resources(base, "enmanuel", pw, "/enmanuel/contacts/addressbook/")
first = listing["resources"][0]["href"]
res = dav_get_resource(base, "enmanuel", pw, first)
print(res["text"][:13]) # BEGIN:VCARD
```
## Cuando usarla
Cuando quieres leer el contenido de un recurso concreto cuyo href ya conoces
(por `dav_list_resources` o porque lo construyes tu). Util para hacer backup de
una coleccion (listar + get cada uno), validar que un import quedo bien escrito,
o comparar etags en un sync. Acepta directamente los hrefs del listing sin
reconstruir la URL.
## Gotchas
- Lectura remota real sobre TLS; password de `pass`, no se logea.
- Decodifica el cuerpo como UTF-8 con `errors='replace'`: bytes invalidos se
sustituyen por el caracter de reemplazo en vez de fallar.
- Si resource_path es relativo se concatena a base_url; si es absoluto
(http/https) se usa tal cual y base_url se ignora.
- `verify_tls=False` solo en pruebas; abre MITM.
@@ -0,0 +1,67 @@
"""Descarga (GET) un recurso DAV individual via HTTP Basic auth.
Funcion impura: hace una peticion HTTP GET. Construye el header
`Authorization: Basic base64(user:pass)` a mano con stdlib. Devuelve el cuerpo
del recurso como texto (un VCARD o un VCALENDAR). El resource_path puede ser un
href absoluto (como los que devuelve dav_list_resources) o una ruta relativa al
base_url. Maneja errores sin lanzar. Solo usa stdlib (urllib, base64, ssl).
"""
import base64
import ssl
import urllib.error
import urllib.request
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 _resolve_url(base_url: str, resource_path: str) -> str:
if resource_path.startswith("http://") or resource_path.startswith("https://"):
return resource_path
return base_url.rstrip("/") + "/" + resource_path.lstrip("/")
def dav_get_resource(
base_url: str,
username: str,
password: str,
resource_path: str,
*,
timeout_s: float = 20.0,
verify_tls: bool = True,
) -> dict:
"""Descarga el contenido de un recurso DAV (GET).
Args:
base_url: URL base del servidor DAV. Se ignora si resource_path ya es
una URL absoluta.
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth.
resource_path: href absoluto (p.ej. '/enmanuel/contacts/addressbook/x.vcf')
o URL completa del recurso a descargar.
timeout_s: timeout de la peticion en segundos. Default 20.0.
verify_tls: si True (default) verifica el certificado TLS.
Returns:
dict. En exito: {status:'ok', http_status:int, text:str, url:str} donde
text es el cuerpo del recurso (VCARD o VCALENDAR). En error (sin lanzar):
{status:'error', error:str, http_status:int|None}.
"""
url = _resolve_url(base_url, resource_path)
headers = {"Authorization": _basic_auth_header(username, password)}
req = urllib.request.Request(url, method="GET", 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:
text = resp.read().decode("utf-8", "replace")
return {"status": "ok", "http_status": resp.status, "text": text, "url": url}
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}
@@ -0,0 +1,88 @@
"""Tests para dav_get_resource.
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
Request (method GET, auth, URL) y devolver un cuerpo simulado.
"""
import base64
import sys
import infra.dav_get_resource # noqa: F401
mod = sys.modules["infra.dav_get_resource"]
dav_get_resource = mod.dav_get_resource
_BODY = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nEND:VCARD\r\n"
class _FakeResp:
status = 200
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return _BODY.encode("utf-8")
def _capture(monkeypatch):
captured = {}
def fake_urlopen(req, timeout=None, context=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
return _FakeResp()
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
return captured
def test_construye_request_get_con_auth(monkeypatch):
cap = _capture(monkeypatch)
res = dav_get_resource(
"https://dav.example.com", "enmanuel", "secret-pw",
"/enmanuel/contacts/addressbook/ada.vcf",
)
assert res["status"] == "ok"
assert cap["method"] == "GET"
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
assert cap["headers"]["authorization"] == expected
def test_resource_path_relativo_se_resuelve_con_base_url(monkeypatch):
cap = _capture(monkeypatch)
dav_get_resource(
"https://dav.example.com", "u", "p",
"/enmanuel/contacts/addressbook/ada.vcf",
)
assert cap["url"] == "https://dav.example.com/enmanuel/contacts/addressbook/ada.vcf"
def test_resource_path_absoluto_se_respeta(monkeypatch):
cap = _capture(monkeypatch)
abs_url = "https://otra.example.com/path/x.vcf"
dav_get_resource("https://dav.example.com", "u", "p", abs_url)
assert cap["url"] == abs_url
def test_devuelve_texto_del_recurso(monkeypatch):
_capture(monkeypatch)
res = dav_get_resource(
"https://dav.example.com", "u", "p", "/x.vcf",
)
assert res["text"] == _BODY
assert res["http_status"] == 200
def test_httperror_devuelve_status_error(monkeypatch):
def fake_urlopen(req, timeout=None, context=None):
raise mod.urllib.error.HTTPError(req.full_url, 404, "Not Found", {}, None)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
res = dav_get_resource("https://dav.example.com", "u", "p", "/x.vcf")
assert res["status"] == "error"
assert res["http_status"] == 404
@@ -0,0 +1,85 @@
---
name: dav_list_calendars
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def dav_list_calendars(base_url: str, username: str, password: str, home_path: str, *, timeout_s: float = 15.0, verify_tls: bool = True) -> dict"
description: "Lista las colecciones de calendario CalDAV bajo un calendar-home en UNA peticion PROPFIND Depth:1. Devuelve solo las colecciones hijas que son calendarios CalDAV de verdad (resourcetype {urn:ietf:params:xml:ns:caldav}calendar), cada una con su href, su displayname (DAV) y su color (calendar-color de Apple, ej. #FF2968FF) si el servidor lo expone. El propio calendar-home (coleccion plana sin el resourcetype calendar) y cualquier coleccion no-calendario se excluyen. Considera solo los propstat con estado 2xx para no leer props marcadas 404. Construye Authorization: Basic base64(user:pass) a mano y parsea el multistatus con regex. verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, re, ssl, html). Probado contra Xandikos."
tags: [dav, caldav, calendar, calendars, propfind, displayname, color, selector, http, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [base64, html, re, ssl, urllib.error, urllib.request]
params:
- name: base_url
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
- name: username
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
- name: password
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
- name: home_path
desc: "ruta del calendar-home del usuario (p.ej. '/enmanuel/calendars/'). Las colecciones de calendario cuelgan de el."
- name: timeout_s
desc: "timeout de la peticion HTTP en segundos. Default 15.0."
- name: verify_tls
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
output: "dict. En exito: {status:'ok', http_status:int, calendars:[{href:str, name:str, color:str|None}, ...]} con un elemento por coleccion de calendario, ordenadas por nombre. color es el calendar-color de Apple (ej. '#FF2968FF') o None. En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
tested: true
tests:
- "test_lista_solo_calendarios"
- "test_excluye_home_plano"
- "test_extrae_nombre_y_color"
- "test_color_ausente_es_none"
- "test_ignora_props_404"
- "test_httperror_devuelve_status_error"
- "test_urlerror_sin_red"
test_file_path: "python/functions/infra/dav_list_calendars_test.py"
file_path: "python/functions/infra/dav_list_calendars.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from infra.dav_list_calendars import dav_list_calendars
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
res = dav_list_calendars(
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
home_path="/enmanuel/calendars/",
)
for c in res["calendars"]:
print(c["name"], c["href"], c["color"])
# calendar /enmanuel/calendars/calendar/ None
```
## Cuando usarla
Cuando una UI necesita un selector de calendario: el usuario tiene varias
colecciones CalDAV bajo su calendar-home y quiere elegir una, con su nombre y su
color para pintarla. Devuelve el href de cada calendario (lo que luego pasas a
`dav_get_collection` / `caldav_put_event` como `collection_path`) sin que el
caller tenga que conocerlos de antemano ni distinguir el calendar-home de los
calendarios reales.
## Gotchas
- Filtra por el resourcetype `caldav:calendar`: el propio calendar-home es una
coleccion plana (sin ese resourcetype) y queda fuera, igual que carpetas
intermedias. Si tu servidor anida calendarios mas profundo que Depth:1, llama
con `home_path` apuntando al nivel correcto.
- El `color` es el `calendar-color` de Apple (`http://apple.com/ns/ical/`), que
Xandikos puede no tener seteado (devuelve None). Apple usa formato `#RRGGBBAA`
(8 digitos, con alfa); recortalo a `#RRGGBB` si tu UI no soporta alfa.
- Solo lee los `<propstat>` con estado 2xx para no confundir un
`<calendar-color/>` vacio de un propstat 404 con un color real.
- Lectura remota real sobre TLS; password de `pass`, no se logea.
@@ -0,0 +1,180 @@
"""Lista las colecciones de calendario CalDAV bajo un calendar-home (PROPFIND).
Funcion impura: hace UNA peticion HTTP PROPFIND Depth:1 sobre el directorio
calendar-home de un usuario (p.ej. `/enmanuel/calendars/`) y devuelve solo las
colecciones hijas que son calendarios CalDAV de verdad — las que declaran el
resourcetype `{urn:ietf:params:xml:ns:caldav}calendar`. Por cada una extrae su
href, su `displayname` (DAV) y, si el servidor lo expone, su color
(`calendar-color` de Apple, ej. `#FF2968FF`). El propio calendar-home (que es una
coleccion plana sin el resourcetype calendar) se excluye.
Esto es lo que necesita un selector de calendario en una UI: el usuario tiene
varias colecciones bajo su calendar-home y quiere elegir una, con su nombre y su
color. `dav_list_resources` solo devuelve hrefs+etag de los recursos de UNA
coleccion (los eventos), no las colecciones hijas con su metadata.
Construye el header `Authorization: Basic base64(user:pass)` a mano con stdlib y
parsea el multistatus con regex simple (sin parser XML externo), considerando
solo los `<propstat>` con estado 2xx para no recoger props que el servidor marca
404. 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
_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,
)
_PROPSTAT_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?propstat>(.*?)</(?:[A-Za-z0-9]+:)?propstat>",
re.DOTALL | re.IGNORECASE,
)
_STATUS_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?status>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?status>",
re.DOTALL | re.IGNORECASE,
)
_DISPLAYNAME_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?displayname>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?displayname>",
re.DOTALL | re.IGNORECASE,
)
# Color de Apple: <ns:calendar-color>#RRGGBBAA</ns:calendar-color> (o sin alfa).
_COLOR_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?calendar-color[^>]*>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?calendar-color>",
re.DOTALL | re.IGNORECASE,
)
# Marca de calendario CalDAV en el resourcetype. El elemento `<C:calendar/>`
# puede venir con o sin prefijo de namespace (`<ns2:calendar/>`, `<calendar/>`).
_CALENDAR_TYPE_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?calendar(?:\s[^>]*)?/?>", re.IGNORECASE
)
# El PROPFIND pide nombre, tipo y color (Apple). Declarar el namespace de Apple y
# el de CalDAV permite que el servidor responda esas props cuando existan.
_PROPFIND_BODY = (
'<?xml version="1.0" encoding="utf-8" ?>'
'<D:propfind xmlns:D="DAV:" '
'xmlns:A="http://apple.com/ns/ical/" '
'xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<D:prop>"
"<D:displayname/><D:resourcetype/><A:calendar-color/>"
"</D:prop></D:propfind>"
)
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 _ok_propstats(response_block: str) -> str:
"""Concatena solo los `<propstat>` con estado 2xx de un `<response>`.
El servidor agrupa las props por estado: las presentes en un propstat 200 y
las ausentes en un propstat 404. Tomar solo los 2xx evita leer un
`<calendar-color/>` vacio del bloque 404 como si fuera el valor real.
"""
parts = []
for ps in _PROPSTAT_RE.findall(response_block):
status_m = _STATUS_RE.search(ps)
if status_m and " 2" in (" " + status_m.group(1)):
parts.append(ps)
# Si no hay propstat (servidor minimalista), usar el bloque entero.
return "".join(parts) if parts else response_block
def dav_list_calendars(
base_url: str,
username: str,
password: str,
home_path: str,
*,
timeout_s: float = 15.0,
verify_tls: bool = True,
) -> dict:
"""Lista las colecciones de calendario CalDAV bajo un calendar-home.
Hace un PROPFIND Depth:1 sobre `home_path` y devuelve solo las colecciones
hijas marcadas como calendario CalDAV (resourcetype `caldav:calendar`), con
su nombre y color. El propio `home_path` y cualquier coleccion no-calendario
se excluyen.
Args:
base_url: URL base del servidor DAV (p.ej. 'https://dav-x.example.com').
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth. Resolver desde pass.
home_path: ruta del calendar-home del usuario (p.ej.
'/enmanuel/calendars/'). Las colecciones de calendario cuelgan de el.
timeout_s: timeout de la peticion en segundos. Default 15.0.
verify_tls: si True (default) verifica el certificado TLS.
Returns:
dict. En exito: {status:'ok', http_status:int,
calendars:[{href:str, name:str, color:str|None}, ...]} con un elemento
por coleccion de calendario (ordenadas por nombre). `color` es el
`calendar-color` de Apple (ej. '#FF2968FF') si el servidor lo expone, o
None. En error (sin lanzar): {status:'error', error:str,
http_status:int|None}.
"""
url = _join_url(base_url, home_path)
headers = {
"Authorization": _basic_auth_header(username, password),
"Content-Type": "application/xml; charset=utf-8",
"Depth": "1",
}
req = urllib.request.Request(
url, data=_PROPFIND_BODY.encode("utf-8"), method="PROPFIND", 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}
home_tail = home_path.strip("/").rsplit("/", 1)[-1]
calendars = []
for block in _RESPONSE_RE.findall(xml):
href_m = _HREF_RE.search(block)
if not href_m:
continue
href = href_m.group(1).strip()
tail = href.rstrip("/").rsplit("/", 1)[-1]
# El propio calendar-home (o un href identico al home): se excluye.
if tail == home_tail:
continue
# Solo las colecciones marcadas como calendario CalDAV. El home plano no
# lleva el resourcetype `caldav:calendar` y queda fuera.
if not _CALENDAR_TYPE_RE.search(block):
continue
ok = _ok_propstats(block)
name_m = _DISPLAYNAME_RE.search(ok)
name = html.unescape(name_m.group(1).strip()) if name_m else tail
if not name:
name = tail
color_m = _COLOR_RE.search(ok)
color = html.unescape(color_m.group(1).strip()) if color_m else None
if color == "":
color = None
calendars.append({"href": href, "name": name, "color": color})
calendars.sort(key=lambda c: c["name"].lower())
return {"status": "ok", "http_status": status, "calendars": calendars}
@@ -0,0 +1,180 @@
"""Tests para dav_list_calendars.
Smoke deterministas: monkeypatchean urllib.request.urlopen para devolver un
multistatus simulado al estilo Xandikos (PROPFIND Depth:1 sobre un calendar-home
con el propio home plano + dos calendarios CalDAV, uno con color y otro sin) y
una coleccion no-calendario que debe quedar fuera. Cubren: filtrado por
resourcetype caldav:calendar, exclusion del home plano, extraccion de nombre y
color, color ausente -> None, props marcadas 404 ignoradas, y los paths de
error HTTP / sin red.
"""
import sys
import infra.dav_list_calendars # noqa: F401
mod = sys.modules["infra.dav_list_calendars"]
dav_list_calendars = mod.dav_list_calendars
# Multistatus estilo Xandikos:
# - /enmanuel/calendars/ -> home plano (collection, sin caldav:calendar)
# - /enmanuel/calendars/calendar/ -> calendario CalDAV sin color (color en 404)
# - /enmanuel/calendars/trabajo/ -> calendario CalDAV con calendar-color
# - /enmanuel/calendars/inbox/ -> coleccion NO-calendario (debe excluirse)
_XML_HOME = (
'<?xml version="1.0"?>'
'<ns0:multistatus xmlns:ns0="DAV:" '
'xmlns:ns1="http://apple.com/ns/ical/" '
'xmlns:ns2="urn:ietf:params:xml:ns:caldav">'
# Home plano: collection, sin <ns2:calendar/>.
"<ns0:response><ns0:href>/enmanuel/calendars/</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
"<ns0:displayname>calendars</ns0:displayname>"
"<ns0:resourcetype><ns0:collection /></ns0:resourcetype>"
"</ns0:prop></ns0:propstat>"
"<ns0:propstat><ns0:status>HTTP/1.1 404 Not Found</ns0:status>"
"<ns0:prop><ns1:calendar-color /></ns0:prop></ns0:propstat>"
"</ns0:response>"
# Calendario sin color: el color va en un propstat 404 (no debe leerse).
"<ns0:response><ns0:href>/enmanuel/calendars/calendar/</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
"<ns0:displayname>calendar</ns0:displayname>"
"<ns0:resourcetype><ns0:collection /><ns2:calendar /></ns0:resourcetype>"
"</ns0:prop></ns0:propstat>"
"<ns0:propstat><ns0:status>HTTP/1.1 404 Not Found</ns0:status>"
"<ns0:prop><ns1:calendar-color /></ns0:prop></ns0:propstat>"
"</ns0:response>"
# Calendario con color (Apple #RRGGBBAA) y displayname con acento.
"<ns0:response><ns0:href>/enmanuel/calendars/trabajo/</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
"<ns0:displayname>Trabajo &amp; ocio</ns0:displayname>"
"<ns0:resourcetype><ns0:collection /><ns2:calendar /></ns0:resourcetype>"
"<ns1:calendar-color>#FF2968FF</ns1:calendar-color>"
"</ns0:prop></ns0:propstat></ns0:response>"
# Coleccion NO-calendario (p.ej. un inbox de scheduling): excluida.
"<ns0:response><ns0:href>/enmanuel/calendars/inbox/</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
"<ns0:displayname>inbox</ns0:displayname>"
"<ns0:resourcetype><ns0:collection /><ns2:schedule-inbox /></ns0:resourcetype>"
"</ns0:prop></ns0:propstat></ns0:response>"
"</ns0:multistatus>"
)
class _FakeResp:
def __init__(self, payload: str):
self._payload = payload
self.status = 207
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return self._payload.encode("utf-8")
def _capture(monkeypatch, payload: str):
captured = {}
def fake_urlopen(req, timeout=None, context=None):
captured["method"] = req.get_method()
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
return _FakeResp(payload)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
return captured
def _call(home="/enmanuel/calendars/"):
return dav_list_calendars(
"https://dav.example.com", "enmanuel", "secret-pw", home
)
def test_construye_propfind_depth_1(monkeypatch):
cap = _capture(monkeypatch, _XML_HOME)
_call()
assert cap["method"] == "PROPFIND"
assert cap["headers"]["depth"] == "1"
def test_lista_solo_calendarios(monkeypatch):
_capture(monkeypatch, _XML_HOME)
res = _call()
assert res["status"] == "ok"
hrefs = [c["href"] for c in res["calendars"]]
# calendar + trabajo (los dos caldav:calendar), nada mas.
assert "/enmanuel/calendars/calendar/" in hrefs
assert "/enmanuel/calendars/trabajo/" in hrefs
assert len(res["calendars"]) == 2
def test_excluye_home_plano(monkeypatch):
_capture(monkeypatch, _XML_HOME)
res = _call()
hrefs = [c["href"] for c in res["calendars"]]
assert "/enmanuel/calendars/" not in hrefs
def test_excluye_coleccion_no_calendario(monkeypatch):
_capture(monkeypatch, _XML_HOME)
res = _call()
hrefs = [c["href"] for c in res["calendars"]]
assert "/enmanuel/calendars/inbox/" not in hrefs
def test_extrae_nombre_y_color(monkeypatch):
_capture(monkeypatch, _XML_HOME)
res = _call()
by_href = {c["href"]: c for c in res["calendars"]}
trabajo = by_href["/enmanuel/calendars/trabajo/"]
assert trabajo["name"] == "Trabajo & ocio" # entidad XML des-escapada
assert trabajo["color"] == "#FF2968FF"
def test_color_ausente_es_none(monkeypatch):
_capture(monkeypatch, _XML_HOME)
res = _call()
by_href = {c["href"]: c for c in res["calendars"]}
cal = by_href["/enmanuel/calendars/calendar/"]
assert cal["name"] == "calendar"
assert cal["color"] is None
def test_ignora_props_404(monkeypatch):
"""El <calendar-color/> vacio de un propstat 404 NO se lee como color real."""
_capture(monkeypatch, _XML_HOME)
res = _call()
by_href = {c["href"]: c for c in res["calendars"]}
# calendar tiene calendar-color SOLO en el propstat 404 -> color None, no "".
assert by_href["/enmanuel/calendars/calendar/"]["color"] is None
def test_ordenado_por_nombre(monkeypatch):
_capture(monkeypatch, _XML_HOME)
res = _call()
names = [c["name"] for c in res["calendars"]]
assert names == sorted(names, key=str.lower)
def test_httperror_devuelve_status_error(monkeypatch):
def fake_urlopen(req, timeout=None, context=None):
raise mod.urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, None)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
res = _call()
assert res["status"] == "error"
assert res["http_status"] == 401
def test_urlerror_sin_red(monkeypatch):
def fake_urlopen(req, timeout=None, context=None):
raise mod.urllib.error.URLError("sin red")
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
res = _call()
assert res["status"] == "error"
assert res["http_status"] is None
@@ -0,0 +1,82 @@
---
name: dav_list_resources
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def dav_list_resources(base_url: str, username: str, password: str, collection_path: str, *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
description: "Lista los recursos de una coleccion DAV (CardDAV o CalDAV) via PROPFIND Depth:1 con HTTP Basic auth. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib. Parsea el XML multistatus con regex simple (sin parser XML externo) y devuelve los hrefs + getetag de cada recurso, excluyendo la propia coleccion. verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, re, ssl). Probado contra Xandikos."
tags: [dav, carddav, caldav, propfind, list, http, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [base64, re, ssl, urllib.error, urllib.request]
params:
- name: base_url
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
- name: username
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
- name: password
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
- name: collection_path
desc: "ruta de la coleccion a listar (CardDAV '/enmanuel/contacts/addressbook/' o CalDAV '/enmanuel/calendars/calendar/')."
- name: timeout_s
desc: "timeout de la peticion HTTP en segundos. Default 20.0."
- name: verify_tls
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
output: "dict. En exito: {status:'ok', http_status:int, resources:[{href:str, etag:str|None}, ...]} con un elemento por recurso de la coleccion (excluida la propia coleccion). En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
tested: true
tests:
- "test_construye_request_propfind_depth_1"
- "test_basic_auth_header_correcto"
- "test_parsea_hrefs_y_etags_del_multistatus"
- "test_excluye_la_propia_coleccion"
- "test_httperror_devuelve_status_error"
test_file_path: "python/functions/infra/dav_list_resources_test.py"
file_path: "python/functions/infra/dav_list_resources.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from infra.dav_list_resources import dav_list_resources
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
res = dav_list_resources(
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
collection_path="/enmanuel/contacts/addressbook/",
)
print(res["status"], len(res["resources"]))
# ok 820
print(res["resources"][0]) # {"href": "/enmanuel/contacts/addressbook/abc.vcf", "etag": '"..."'}
```
## Cuando usarla
Cuando quieres enumerar lo que ya hay en una coleccion CardDAV/CalDAV: contar
contactos/eventos importados, verificar una migracion, o construir un mapa
href->etag para sync incremental. Sirve igual para libretas de direcciones y
calendarios (PROPFIND es generico). Combinala con `dav_get_resource` para
descargar el contenido de cada href.
## Gotchas
- Usa PROPFIND Depth:1 (no addressbook-query REPORT): lista TODOS los recursos
hijos de la coleccion. Para colecciones enormes la respuesta XML puede ser
grande; el timeout default es 20s.
- El parseo es regex simple sobre el multistatus, no un parser XML completo: es
robusto para la salida estandar de Xandikos pero podria fallar con servidores
que devuelvan XML muy exotico. La intencion es KISS sin dependencias.
- Excluye la propia coleccion comparando el ultimo segmento del href; si tu
coleccion y un recurso comparten exactamente el ultimo segmento (raro), ese
recurso se omitiria.
- Lectura remota real sobre TLS; password de `pass`, no se logea.
@@ -0,0 +1,101 @@
"""Lista los recursos de una coleccion DAV via PROPFIND Depth:1.
Funcion impura: hace una peticion HTTP PROPFIND. Construye el header
`Authorization: Basic base64(user:pass)` a mano con stdlib. Devuelve los hrefs
(y getetag cuando el servidor los expone) de los recursos de la coleccion,
parseados del XML multistatus con regex simple (sin dependencias de parser XML
externas). Sirve tanto para colecciones CardDAV como CalDAV. Maneja errores sin
lanzar. Solo usa stdlib (urllib, base64, re, ssl).
"""
import base64
import re
import ssl
import urllib.error
import urllib.request
_HREF_RE = re.compile(r"<(?:[A-Za-z0-9]+:)?href>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?href>", re.DOTALL | re.IGNORECASE)
_RESPONSE_RE = re.compile(r"<(?:[A-Za-z0-9]+:)?response>(.*?)</(?:[A-Za-z0-9]+:)?response>", 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)
_PROPFIND_BODY = (
'<?xml version="1.0" encoding="utf-8" ?>'
'<D:propfind xmlns:D="DAV:"><D:prop>'
"<D:getetag/><D:resourcetype/>"
"</D:prop></D:propfind>"
)
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 dav_list_resources(
base_url: str,
username: str,
password: str,
collection_path: str,
*,
timeout_s: float = 20.0,
verify_tls: bool = True,
) -> dict:
"""Lista los recursos de una coleccion DAV (PROPFIND Depth:1).
Args:
base_url: URL base del servidor DAV.
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth.
collection_path: ruta de la coleccion (CardDAV o CalDAV).
timeout_s: timeout de la peticion en segundos. Default 20.0.
verify_tls: si True (default) verifica el certificado TLS.
Returns:
dict. En exito: {status:'ok', http_status:int,
resources:[{href:str, etag:str|None}, ...]}. El primer <response> suele
ser la propia coleccion; se excluye comparando su href con la ruta de la
coleccion. En error (sin lanzar): {status:'error', error:str,
http_status:int|None}.
"""
url = _join_url(base_url, collection_path)
headers = {
"Authorization": _basic_auth_header(username, password),
"Content-Type": "application/xml; charset=utf-8",
"Depth": "1",
}
req = urllib.request.Request(
url, data=_PROPFIND_BODY.encode("utf-8"), method="PROPFIND", 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]
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()
# El ultimo segmento del href identifica el recurso. Si coincide con el
# ultimo segmento de la coleccion, ese <response> ES la coleccion: skip.
tail = href.rstrip("/").rsplit("/", 1)[-1]
if tail == coll_tail:
continue
etag_m = _ETAG_RE.search(block)
etag = etag_m.group(1).strip() if etag_m else None
resources.append({"href": href, "etag": etag})
return {"status": "ok", "http_status": status, "resources": resources}
@@ -0,0 +1,107 @@
"""Tests para dav_list_resources.
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
Request (method PROPFIND, Depth, auth) y devolver un XML multistatus simulado.
"""
import base64
import io
import sys
import infra.dav_list_resources # noqa: F401
mod = sys.modules["infra.dav_list_resources"]
dav_list_resources = mod.dav_list_resources
_XML = (
'<?xml version="1.0"?>'
'<D:multistatus xmlns:D="DAV:">'
"<D:response><D:href>/enmanuel/contacts/addressbook/</D:href>"
"<D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype>"
"</D:prop></D:propstat></D:response>"
"<D:response><D:href>/enmanuel/contacts/addressbook/ada.vcf</D:href>"
'<D:propstat><D:prop><D:getetag>"etag-ada"</D:getetag></D:prop></D:propstat>'
"</D:response>"
"<D:response><D:href>/enmanuel/contacts/addressbook/alan.vcf</D:href>"
'<D:propstat><D:prop><D:getetag>"etag-alan"</D:getetag></D:prop></D:propstat>'
"</D:response>"
"</D:multistatus>"
)
class _FakeResp:
status = 207
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return _XML.encode("utf-8")
def _capture(monkeypatch):
captured = {}
def fake_urlopen(req, timeout=None, context=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
return _FakeResp()
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
return captured
def _call():
return dav_list_resources(
base_url="https://dav.example.com",
username="enmanuel",
password="secret-pw",
collection_path="/enmanuel/contacts/addressbook/",
)
def test_construye_request_propfind_depth_1(monkeypatch):
cap = _capture(monkeypatch)
_call()
assert cap["method"] == "PROPFIND"
assert cap["headers"]["depth"] == "1"
def test_basic_auth_header_correcto(monkeypatch):
cap = _capture(monkeypatch)
_call()
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
assert cap["headers"]["authorization"] == expected
def test_parsea_hrefs_y_etags_del_multistatus(monkeypatch):
_capture(monkeypatch)
res = _call()
assert res["status"] == "ok"
hrefs = [r["href"] for r in res["resources"]]
assert "/enmanuel/contacts/addressbook/ada.vcf" in hrefs
assert "/enmanuel/contacts/addressbook/alan.vcf" in hrefs
etags = {r["href"]: r["etag"] for r in res["resources"]}
assert etags["/enmanuel/contacts/addressbook/ada.vcf"] == '"etag-ada"'
def test_excluye_la_propia_coleccion(monkeypatch):
_capture(monkeypatch)
res = _call()
hrefs = [r["href"] for r in res["resources"]]
assert "/enmanuel/contacts/addressbook/" not in hrefs
assert len(res["resources"]) == 2
def test_httperror_devuelve_status_error(monkeypatch):
def fake_urlopen(req, timeout=None, context=None):
raise mod.urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, None)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
res = _call()
assert res["status"] == "error"
assert res["http_status"] == 401
@@ -0,0 +1,63 @@
---
name: extract_or_make_uid
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def extract_or_make_uid(component_text: str, prefix: str = 'goog-') -> str"
description: "Extrae el campo UID: de un componente iCal (VEVENT/VCALENDAR) o vCard (VCARD). Si el componente no declara UID, sintetiza uno determinista derivado del contenido: '<prefix><md5(text)[:16]>'. El mismo texto produce siempre el mismo UID. Pura, solo stdlib (re, hashlib). Imprescindible al importar a CardDAV/CalDAV: el nombre del recurso se deriva del UID y cada componente necesita uno estable para idempotencia (re-importar no duplica)."
tags: [dav, carddav, caldav, uid, vcard, vevent, ical, infra, extract]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [re, hashlib]
params:
- name: component_text
desc: "texto de un componente VCARD o VEVENT/VCALENDAR del que extraer (o derivar) el UID."
- name: prefix
desc: "prefijo del UID sintetico cuando el componente no declara UID. Default 'goog-' (origen Google export)."
output: "str. El valor del campo UID stripeado si el componente lo declara. Si no, un UID determinista '<prefix><md5(component_text)[:16]>'. No lanza para input no vacio."
tested: true
tests:
- "test_uid_presente_se_extrae"
- "test_sin_uid_genera_determinista"
- "test_mismo_texto_mismo_uid_sintetico"
- "test_prefix_personalizado"
- "test_uid_con_espacios_se_stripea"
test_file_path: "python/functions/infra/extract_or_make_uid_test.py"
file_path: "python/functions/infra/extract_or_make_uid.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.extract_or_make_uid import extract_or_make_uid
with_uid = "BEGIN:VEVENT\r\nUID:abc-123@google.com\r\nSUMMARY:x\r\nEND:VEVENT"
print(extract_or_make_uid(with_uid)) # abc-123@google.com
no_uid = "BEGIN:VCARD\r\nFN:Ada\r\nEND:VCARD"
print(extract_or_make_uid(no_uid)) # goog-<md5 16 hex> (estable)
```
## Cuando usarla
Cuando importas un VCARD o un VEVENT a un servidor DAV y necesitas el UID para
(a) construir el nombre del recurso (`safe(uid) + .vcf/.ics`) y (b) garantizar
idempotencia: re-subir el mismo componente sobrescribe en vez de duplicar.
Usada por los pipelines `import_vcf_to_carddav` e `import_ics_to_caldav` por
cada elemento antes del PUT. Si el export no trae UID (comun en algunas tarjetas
de Google), esta funcion lo fabrica de forma estable a partir del contenido.
## Gotchas
Funcion pura. El UID sintetico depende del contenido EXACTO del componente: si
el texto cambia (aunque sea un espacio), el md5 cambia y se generaria un recurso
nuevo en vez de actualizar el existente. Para componentes con UID propio esto no
ocurre. md5 se usa solo como hash de identidad determinista, no como primitiva
de seguridad.
@@ -0,0 +1,32 @@
"""Extrae el UID de un componente iCal/vCard, o genera uno determinista.
Funcion pura: sin I/O, sin estado, determinista. Busca la propiedad `UID:` en
el texto del componente (VCARD o VEVENT/VCALENDAR). Si no existe, sintetiza un
UID estable derivado del contenido: `<prefix><md5(text)[:16]>`. El mismo texto
de entrada produce siempre el mismo UID sintetico. Solo usa stdlib (re, hashlib).
"""
import hashlib
import re
_UID_RE = re.compile(r"(?:^|\n)UID:(.+)")
def extract_or_make_uid(component_text: str, prefix: str = "goog-") -> str:
"""Devuelve el UID del componente, o uno determinista si no lo tiene.
Args:
component_text: texto de un componente VCARD o VEVENT/VCALENDAR.
prefix: prefijo del UID sintetico cuando el componente no declara UID.
Default 'goog-' (origen Google export).
Returns:
El valor del campo UID stripeado si existe. Si no, un UID determinista
'<prefix><md5(component_text)[:16]>'. Nunca lanza ni devuelve vacio para
un input no vacio.
"""
m = _UID_RE.search(component_text or "")
if m:
return m.group(1).strip()
digest = hashlib.md5((component_text or "").encode("utf-8")).hexdigest()[:16]
return "%s%s" % (prefix, digest)
@@ -0,0 +1,36 @@
"""Tests para extract_or_make_uid. Puros, deterministas, sin I/O."""
import sys
import infra.extract_or_make_uid # noqa: F401
mod = sys.modules["infra.extract_or_make_uid"]
extract = mod.extract_or_make_uid
def test_uid_presente_se_extrae():
txt = "BEGIN:VEVENT\r\nUID:abc-123@google.com\r\nSUMMARY:x\r\nEND:VEVENT"
assert extract(txt) == "abc-123@google.com"
def test_sin_uid_genera_determinista():
txt = "BEGIN:VCARD\r\nFN:Ada\r\nEND:VCARD"
uid = extract(txt)
assert uid.startswith("goog-")
assert len(uid) == len("goog-") + 16
def test_mismo_texto_mismo_uid_sintetico():
txt = "BEGIN:VCARD\r\nFN:Ada\r\nEND:VCARD"
assert extract(txt) == extract(txt)
def test_prefix_personalizado():
txt = "BEGIN:VCARD\r\nFN:Ada\r\nEND:VCARD"
uid = extract(txt, prefix="mig-")
assert uid.startswith("mig-")
def test_uid_con_espacios_se_stripea():
txt = "BEGIN:VCARD\nUID: spaced-uid \nEND:VCARD"
assert extract(txt) == "spaced-uid"
+61
View File
@@ -0,0 +1,61 @@
---
name: split_vcards
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def split_vcards(vcf_text: str) -> list"
description: "Divide el texto completo de un archivo .vcf en sus VCARDs individuales. Devuelve una lista de strings, cada uno un VCARD completo (BEGIN:VCARD..END:VCARD) stripeado. Pura, solo stdlib (re). Util para importar a CardDAV un .vcf exportado de Google Contacts que concatena N tarjetas en un solo archivo: cada string resultante se sube como recurso .vcf independiente."
tags: [dav, carddav, vcard, vcf, contacts, infra, split]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [re]
params:
- name: vcf_text
desc: "contenido completo del archivo .vcf, con una o varias tarjetas VCARD concatenadas. Tolera saltos de linea LF o CRLF."
output: "list[str]. Cada elemento es un VCARD completo ('BEGIN:VCARD'..'END:VCARD') stripeado de espacios al inicio/fin. Lista vacia si el input es vacio o no contiene ninguna tarjeta."
tested: true
tests:
- "test_dos_vcards_devuelve_dos"
- "test_vcard_unico"
- "test_input_vacio_devuelve_lista_vacia"
- "test_crlf_se_tolera"
- "test_cada_card_es_begin_end"
test_file_path: "python/functions/infra/split_vcards_test.py"
file_path: "python/functions/infra/split_vcards.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.split_vcards import split_vcards
vcf = (
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nEND:VCARD\r\n"
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alan Turing\r\nEND:VCARD\r\n"
)
cards = split_vcards(vcf)
print(len(cards)) # 2
print(cards[0][:13]) # BEGIN:VCARD
```
## Cuando usarla
Cuando exportas contactos de Google (o cualquier fuente) a un unico `.vcf` con
muchas tarjetas concatenadas y necesitas subir cada una como recurso CardDAV
separado. Es el primer paso del pipeline `import_vcf_to_carddav`: split → extraer
UID por tarjeta → `carddav_put_vcard`. Tambien para contar/validar un `.vcf`
sin parsear cada campo.
## Gotchas
Funcion pura sin gotchas relevantes. No valida el contenido interno del VCARD
(no comprueba VERSION ni campos obligatorios); solo segmenta por
BEGIN:VCARD..END:VCARD. Si una tarjeta esta truncada (sin END:VCARD) no aparece
en la salida.
+28
View File
@@ -0,0 +1,28 @@
"""Divide un archivo .vcf en sus VCARDs individuales.
Funcion pura: sin I/O, sin estado, determinista. Recibe el texto completo de un
archivo .vcf (que puede contener N tarjetas concatenadas) y devuelve una lista
de strings, cada uno un VCARD completo (BEGIN:VCARD..END:VCARD) ya stripeado.
Solo usa stdlib (re).
"""
import re
_VCARD_RE = re.compile(r"BEGIN:VCARD.*?END:VCARD", re.DOTALL)
def split_vcards(vcf_text: str) -> list:
"""Divide el texto de un .vcf en VCARDs individuales.
Args:
vcf_text: contenido completo del archivo .vcf, con una o varias tarjetas
concatenadas. Tolera saltos de linea LF o CRLF.
Returns:
Lista de strings. Cada elemento es un VCARD completo
('BEGIN:VCARD'..'END:VCARD') stripeado de espacios al inicio/fin.
Lista vacia si no hay ninguna tarjeta.
"""
if not vcf_text:
return []
return [m.strip() for m in _VCARD_RE.findall(vcf_text)]
@@ -0,0 +1,40 @@
"""Tests para split_vcards. Puros, deterministas, sin I/O."""
import sys
import infra.split_vcards # noqa: F401
mod = sys.modules["infra.split_vcards"]
split_vcards = mod.split_vcards
_TWO = (
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nEND:VCARD\r\n"
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alan Turing\r\nEND:VCARD\r\n"
)
def test_dos_vcards_devuelve_dos():
cards = split_vcards(_TWO)
assert len(cards) == 2
def test_vcard_unico():
cards = split_vcards("BEGIN:VCARD\nFN:Solo\nEND:VCARD\n")
assert len(cards) == 1
assert "Solo" in cards[0]
def test_input_vacio_devuelve_lista_vacia():
assert split_vcards("") == []
assert split_vcards("ruido sin tarjetas") == []
def test_crlf_se_tolera():
lf = _TWO.replace("\r\n", "\n")
assert len(split_vcards(lf)) == 2
def test_cada_card_es_begin_end():
for c in split_vcards(_TWO):
assert c.startswith("BEGIN:VCARD")
assert c.endswith("END:VCARD")
@@ -0,0 +1,68 @@
---
name: split_vevents_to_vcalendars
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def split_vevents_to_vcalendars(ics_text: str, prodid: str = '-//xandikos-migracion//google-export//EN') -> list"
description: "Divide un .ics (un VCALENDAR con N VEVENT) en N VCALENDARs independientes, cada uno con un unico VEVENT, header VERSION/PRODID/CALSCALE y las VTIMEZONE del original. Pura, solo stdlib (re). Util para importar a CalDAV un .ics exportado de Google Calendar que mete todos los eventos en un solo VCALENDAR: cada salida se sube como recurso .ics independiente. Normaliza saltos de linea a CRLF (RFC 5545)."
tags: [dav, caldav, ical, ics, vevent, vcalendar, calendar, infra, split]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [re]
params:
- name: ics_text
desc: "contenido completo del .ics: un VCALENDAR con uno o varios VEVENT. Tolera LF o CRLF."
- name: prodid
desc: "valor del campo PRODID del header de cada VCALENDAR de salida. Default identifica la migracion a Xandikos."
output: "list[str]. Cada elemento es un VCALENDAR completo y autonomo ('BEGIN:VCALENDAR'..'END:VCALENDAR' terminado en CRLF) con header VERSION:2.0 / PRODID / CALSCALE:GREGORIAN, las VTIMEZONE del original (si las habia, replicadas en cada salida) y un unico VEVENT. Lista vacia si no hay ningun VEVENT."
tested: true
tests:
- "test_dos_vevents_devuelve_dos_vcalendars"
- "test_cada_salida_tiene_un_solo_vevent"
- "test_header_vcalendar_correcto"
- "test_vtimezone_se_replica_en_cada_salida"
- "test_salida_termina_en_crlf"
- "test_input_vacio_devuelve_lista_vacia"
test_file_path: "python/functions/infra/split_vevents_to_vcalendars_test.py"
file_path: "python/functions/infra/split_vevents_to_vcalendars.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.split_vevents_to_vcalendars import split_vevents_to_vcalendars
ics = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Google//EN\r\n"
"BEGIN:VEVENT\r\nUID:a@x\r\nSUMMARY:Reunion\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:b@x\r\nSUMMARY:Comida\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
cals = split_vevents_to_vcalendars(ics)
print(len(cals)) # 2
print(cals[0].count("BEGIN:VEVENT")) # 1 (un evento por VCALENDAR)
```
## Cuando usarla
Cuando exportas tu calendario de Google a un unico `.ics` (un VCALENDAR con
todos los eventos dentro) y necesitas subir cada evento como recurso CalDAV
separado a Xandikos. Es el primer paso del pipeline `import_ics_to_caldav`:
split → extraer UID por evento → `caldav_put_event`. Cada salida es un `.ics`
valido y autonomo que un cliente de calendario puede consumir por si solo.
## Gotchas
Funcion pura. Replica TODAS las VTIMEZONE del VCALENDAR original en cada salida
(conservador: garantiza que cualquier TZID referenciado por el VEVENT este
definido, aunque algun evento no use ninguna). No deduplica ni filtra
timezones por evento. No valida que el VEVENT este completo ni reescribe DTSTART
/DTEND. Si el .ics no contiene VEVENT (p.ej. solo VTODO o VJOURNAL) devuelve
lista vacia.
@@ -0,0 +1,56 @@
"""Divide un .ics (un VCALENDAR con N VEVENT) en N VCALENDARs independientes.
Funcion pura: sin I/O, sin estado, determinista. Cada salida es un VCALENDAR
completo y autonomo con un unico VEVENT, listo para subir como recurso CalDAV
individual. Las VTIMEZONE del original se incluyen en cada salida (un VEVENT
puede referenciar un TZID definido en el VCALENDAR padre). Solo usa stdlib (re).
"""
import re
_VEVENT_RE = re.compile(r"BEGIN:VEVENT.*?END:VEVENT", re.DOTALL)
_VTIMEZONE_RE = re.compile(r"BEGIN:VTIMEZONE.*?END:VTIMEZONE", re.DOTALL)
_DEFAULT_PRODID = "-//xandikos-migracion//google-export//EN"
def _to_crlf(text: str) -> str:
"""Normaliza saltos de linea a CRLF (RFC 5545)."""
return text.strip().replace("\r\n", "\n").replace("\n", "\r\n")
def split_vevents_to_vcalendars(ics_text: str, prodid: str = _DEFAULT_PRODID) -> list:
"""Divide un VCALENDAR con N VEVENT en N VCALENDARs independientes.
Args:
ics_text: contenido completo del .ics (un VCALENDAR con uno o varios
VEVENT). Tolera LF o CRLF.
prodid: valor del campo PRODID a usar en el header de cada VCALENDAR de
salida. Default: identifica la migracion a Xandikos.
Returns:
Lista de strings. Cada elemento es un VCALENDAR completo y autonomo
('BEGIN:VCALENDAR'..'END:VCALENDAR' terminado en CRLF) con header
VERSION/PRODID/CALSCALE, las VTIMEZONE del original (si las habia) y un
unico VEVENT. Lista vacia si no hay ningun VEVENT.
"""
if not ics_text:
return []
events = _VEVENT_RE.findall(ics_text)
timezones = _VTIMEZONE_RE.findall(ics_text)
tz_block = ""
for tz in timezones:
tz_block += _to_crlf(tz) + "\r\n"
header = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:%s\r\n"
"CALSCALE:GREGORIAN\r\n" % prodid
)
out = []
for ev in events:
body = header + tz_block + _to_crlf(ev) + "\r\nEND:VCALENDAR\r\n"
out.append(body)
return out
@@ -0,0 +1,61 @@
"""Tests para split_vevents_to_vcalendars. Puros, deterministas, sin I/O."""
import sys
import infra.split_vevents_to_vcalendars # noqa: F401
mod = sys.modules["infra.split_vevents_to_vcalendars"]
split = mod.split_vevents_to_vcalendars
_TWO = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Google//EN\r\n"
"BEGIN:VEVENT\r\nUID:a@x\r\nSUMMARY:Reunion\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:b@x\r\nSUMMARY:Comida\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
_WITH_TZ = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Google//EN\r\n"
"BEGIN:VTIMEZONE\r\nTZID:Europe/Madrid\r\nEND:VTIMEZONE\r\n"
"BEGIN:VEVENT\r\nUID:a@x\r\nDTSTART;TZID=Europe/Madrid:20260101T100000\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:b@x\r\nDTSTART;TZID=Europe/Madrid:20260102T100000\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
def test_dos_vevents_devuelve_dos_vcalendars():
cals = split(_TWO)
assert len(cals) == 2
def test_cada_salida_tiene_un_solo_vevent():
for cal in split(_TWO):
assert cal.count("BEGIN:VEVENT") == 1
assert cal.count("END:VEVENT") == 1
assert cal.startswith("BEGIN:VCALENDAR")
assert cal.rstrip("\r\n").endswith("END:VCALENDAR")
def test_header_vcalendar_correcto():
cal = split(_TWO)[0]
assert "VERSION:2.0" in cal
assert "PRODID:" in cal
assert "CALSCALE:GREGORIAN" in cal
def test_vtimezone_se_replica_en_cada_salida():
cals = split(_WITH_TZ)
assert len(cals) == 2
for cal in cals:
assert "BEGIN:VTIMEZONE" in cal
assert "TZID:Europe/Madrid" in cal
def test_salida_termina_en_crlf():
cal = split(_TWO)[0]
assert cal.endswith("END:VCALENDAR\r\n")
def test_input_vacio_devuelve_lista_vacia():
assert split("") == []
assert split("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n") == []