7 Commits

Author SHA1 Message Date
egutierrez b8ec97e477 fix(security): build_vcard neutraliza el retorno de carro crudo (anti CR-injection vCard)
El escape de valores vCard solo escapaba el salto de linea, no el retorno de
carro crudo. Un \r sin \n sobrevivia al escape y los parsers que lo normalizan
a salto de linea (como _unfold_lines de osint_web) leian propiedades inyectadas
(p.ej. X-OSINT-DNI), burlando el control de no exponer datos OSINT al movil.
Ahora _vcard_escape elimina el \r, en paridad con el escape iCal. Test de
regresion anadido.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:19:43 +02:00
egutierrez 40400c0b88 fix(security): duckdb_query_readonly sandbox por defecto (enable_external_access=false)
CRÍTICO: read_only=True protege la base de datos pero NO el sistema de ficheros. Un
SELECT con read_csv/read_blob/glob/COPY...TO podía leer ficheros arbitrarios (claves SSH)
o escribirlos (camino a RCE). Añadido parámetro sandbox (default True) que abre la conexión
con enable_external_access=false, bloqueando todo acceso a FS/red desde la query. Los SELECT
normales sobre tablas siguen funcionando. Único consumidor (osint_db /api/query) queda
protegido sin cambios. Tests nuevos: sandbox bloquea read_csv; sandbox=False lo permite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:21:01 +02:00
egutierrez 236a4740b0 fix(dav): error_type en dav_make_addressbook/dav_make_calendar (impure requiere error_type)
El indexer rechaza funciones impure con error_type vacío. Ambas funciones del grupo dav
declaran error_go_core como el resto de las funciones DAV Python del registry.
2026-06-13 00:45:00 +02:00
egutierrez 1c4a4b9259 feat(duckdb,dav): primitivas de escritura DuckDB + libretas CardDAV + vCard multi-valor
Cinco funciones nuevas para soportar DuckDB como fuente de verdad del project osint:

Grupo duckdb (escritura, complementan a duckdb_query_readonly):
- duckdb_execute_py_infra (impure): ejecuta INSERT/UPDATE/DELETE/DDL en read-write, commit, {status,rowcount}. 6 tests.
- duckdb_upsert_py_infra (impure): UPSERT ON CONFLICT actualizando solo update_cols → ownership selectivo (un re-upsert no pisa columnas excluidas). 7 tests.

Grupo dav (libretas de contactos + vCard multi-valor):
- dav_make_addressbook_py_infra (impure): crea una libreta CardDAV nueva via extended MKCOL (RFC 5689). Idempotente. 12 tests.
- dav_list_addressbooks_py_infra (impure): lista las libretas del contacts-home (PROPFIND Depth:1). 7 tests.
- build_vcard_py_core (pure): serializa un contacto a vCard 3.0 multi-valor (N TEL/EMAIL/ADR + X-OSINT-*). 5 tests.

Paginas de capacidad duckdb.md y dav.md actualizadas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:33:12 +02:00
egutierrez 1c8a86594f feat(dav): expand_rrule + dav_make_calendar para recurrencia y multi-calendario
Dos funciones nuevas del grupo de capacidad `dav`:
- expand_rrule_py_infra (pure): expande una RRULE iCalendar a las fechas de
  cada ocurrencia dentro de un rango [from, to]. Solo stdlib (datetime, re).
  Soporta FREQ DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, COUNT, UNTIL, BYDAY. 9 tests.
- dav_make_calendar_py_infra (impure): crea una coleccion de calendario nueva
  via MKCALENDAR + PROPPATCH de nombre/color. Idempotente si ya existe. 11 tests.

Consumidas por la app osint_web (eventos recurrentes + creacion de agendas).
Pagina del grupo dav actualizada con ambas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:30:01 +02:00
egutierrez a76760edba 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)
2026-06-12 00:43:59 +02:00
egutierrez 4a0f0e9dc0 feat(infra): dav_delete_resource — DELETE de recurso CardDAV/CalDAV (grupo dav)
Completa el CRUD del grupo dav (put/get/list/get-collection/delete). HTTP DELETE
con Basic auth, If-Match opcional para borrado condicional, maneja 404 como
idempotente. Solo stdlib. 7 tests deterministas (monkeypatch urlopen). Probado
contra Xandikos real durante la limpieza del ciclo de sync OSINT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:30:02 +02:00
61 changed files with 6052 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
# dav — Cliente CardDAV/CalDAV (Python, solo stdlib)
Grupo de capacidad para operar un servidor **CardDAV/CalDAV** (Xandikos, git-backed,
en el VPS `magnus`) desde Python sin dependencias externas. Cubre el flujo de
**migracion**: partir un export de Google (un `.vcf` con N contactos, un `.ics` con
N eventos) en recursos individuales y subirlos uno a uno por HTTP PUT con Basic auth.
Tambien listar y descargar recursos para verificar o hacer backup.
Formaliza el flujo ad-hoc (heredocs) que migro 820 contactos + 98 eventos a Xandikos
(regla `function_growth_and_self_docs`: una composicion repetida >2 veces se promueve
a funciones/pipelines del registry).
## Restriccion de diseno
**Solo stdlib** (`urllib.request`, `re`, `hashlib`, `base64`, `ssl`). Sin `requests`,
`caldav` ni `vobject`. El header `Authorization: Basic base64(user:pass)` se construye
a mano. `verify_tls=True` por defecto. Coherente con el grupo `osint-passive` (sin deps).
## Funciones
| ID | Firma corta | Que hace | Purity |
|---|---|---|---|
| `split_vcards_py_infra` | `split_vcards(vcf_text) -> list` | Parte un `.vcf` en VCARDs individuales | pure |
| `split_vevents_to_vcalendars_py_infra` | `split_vevents_to_vcalendars(ics_text, prodid?) -> list` | Parte un VCALENDAR con N VEVENT en N VCALENDARs autonomos (replica VTIMEZONE) | pure |
| `extract_or_make_uid_py_infra` | `extract_or_make_uid(text, prefix?) -> str` | Extrae el `UID:` o sintetiza `<prefix><md5[:16]>` determinista | pure |
| `carddav_put_vcard_py_infra` | `carddav_put_vcard(base_url, user, pw, coll, uid, vcard) -> dict` | PUT de un VCARD (`.vcf`, `text/vcard`) | impure |
| `caldav_put_event_py_infra` | `caldav_put_event(base_url, user, pw, coll, uid, vcal) -> dict` | PUT de un VCALENDAR (`.ics`, `text/calendar`) | impure |
| `dav_list_resources_py_infra` | `dav_list_resources(base_url, user, pw, coll) -> dict` | PROPFIND Depth:1 -> lista de `{href, etag}` | impure |
| `dav_get_resource_py_infra` | `dav_get_resource(base_url, user, pw, href) -> dict` | GET de un recurso -> texto VCARD/VCALENDAR | impure |
| `dav_make_calendar_py_infra` | `dav_make_calendar(base_url, user, pw, calendar_home, slug, name?, color?, desc?) -> dict` | MKCALENDAR + PROPPATCH: crea una coleccion de calendario (agenda) nueva | impure |
| `dav_make_addressbook_py_infra` | `dav_make_addressbook(base_url, user, pw, contacts_home, slug, name?, desc?) -> dict` | Extended MKCOL: crea una coleccion CardDAV (libreta/agenda de contactos) nueva | impure |
| `dav_list_addressbooks_py_infra` | `dav_list_addressbooks(base_url, user, pw, contacts_home) -> dict` | PROPFIND Depth:1: lista las libretas CardDAV del contacts-home con nombre y descripcion | impure |
| `build_vcard_py_core` | `build_vcard(contact: dict) -> str` | Serializa un contacto a VCARD 3.0 MULTI-VALOR (N TEL/EMAIL/ADR + X-OSINT-*); pura | pure |
| `expand_rrule_py_infra` | `expand_rrule(dtstart_ical, rrule, range_start, range_end, all_day?) -> list` | Expande una RRULE iCalendar a las fechas de cada ocurrencia dentro de un rango | pure |
| `import_vcf_to_carddav_py_pipelines` | `import_vcf_to_carddav(vcf_path, base_url, user, pw, coll) -> dict` | Pipeline: .vcf -> split -> uid -> PUT por tarjeta | impure |
| `import_ics_to_caldav_py_pipelines` | `import_ics_to_caldav(ics_path, base_url, user, pw, coll) -> dict` | Pipeline: .ics -> split -> uid -> PUT por evento | impure |
## Sistema real (para los ejemplos)
- Servidor: **Xandikos** en `https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com`, Basic auth, usuario `enmanuel`.
- Password: `pass dav/xandikos-enmanuel` (primera linea). Resolver con `pass_get_secret_py_infra`, NUNCA hardcodear.
- Principal: `/enmanuel/`. Colecciones:
- CardDAV: `/enmanuel/contacts/addressbook/`
- CalDAV: `/enmanuel/calendars/calendar/`
## Ejemplo canonico end-to-end
Importar un `.vcf` exportado de Google a Xandikos, leyendo la password de `pass`:
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from pipelines.import_vcf_to_carddav import import_vcf_to_carddav
BASE = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
summary = import_vcf_to_carddav(
vcf_path="/home/enmanuel/Descargas/contacts.vcf",
base_url=BASE,
username="enmanuel",
password=pw,
collection_path="/enmanuel/contacts/addressbook/",
)
print(summary["ok"], summary["fail"], summary["total"]) # 820 0 820
```
Verificar el resultado listando la coleccion:
```python
from infra.dav_list_resources import dav_list_resources
res = dav_list_resources(BASE, "enmanuel", pw, "/enmanuel/contacts/addressbook/")
print(res["status"], len(res["resources"])) # ok 820
```
El calendario es analogo con `import_ics_to_caldav` + `/enmanuel/calendars/calendar/`.
Desde la CLI del registry (resuelve la pass como variable, no la pongas en claro):
```bash
PW=$(pass show dav/xandikos-enmanuel | head -n1)
./fn run import_vcf_to_carddav /home/enmanuel/Descargas/contacts.vcf \
https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com \
enmanuel "$PW" /enmanuel/contacts/addressbook/
```
## Fronteras
- **No descubre el principal ni las colecciones**: hay que conocer los paths
(`/enmanuel/contacts/addressbook/`, etc.). No implementa `current-user-principal`
ni `addressbook-home-set` discovery.
- **No hace sync incremental** real: `dav_list_resources` devuelve etags pero no
hay logica de diff/merge. Re-importar es idempotente por UID (sobrescribe), no
incremental.
- **No parsea campos VCARD/VEVENT**: trata cada componente como texto opaco. Para
transformar contenido (renombrar, deduplicar por nombre) usa otra herramienta.
- **Solo VEVENT** en calendario: VTODO/VJOURNAL se ignoran al partir el `.ics`.
- **Escrituras irreversibles**: los PUT sobrescriben en el servidor. Idempotente
por UID pero no hay confirmacion previa; valida el `.vcf`/`.ics` antes de importar.
## Prerequisitos
- `pass` configurado con la entrada `dav/xandikos-enmanuel`.
- Conectividad TLS al endpoint publico (`verify_tls=True`).
- Python del registry: `python/.venv/bin/python3`.
+57
View File
@@ -0,0 +1,57 @@
# Capability: duckdb
Operar bases de datos DuckDB desde el registry: abrir/crear bases, consultas read-only seguras, conversion CSV -> Parquet, deduplicacion por hash y carga de series temporales. DuckDB es el motor analitico embebido del ecosistema (OLAP local, archivos `.duckdb`, lectura directa de CSV/Parquet/JSON).
Pieza central del patron **BD como fuente de verdad + Obsidian como vista** (project `osint`): la app `osint_db` posee la DuckDB maestra y este grupo aporta las primitivas de acceso.
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `duckdb_open_go_infra` | `DuckDBOpen(path string) (*sql.DB, error)` | Abre (o crea) una base DuckDB desde Go. Path vacio o `:memory:` abre en memoria. |
| `duckdb_query_readonly_py_infra` | `duckdb_query_readonly(db_path, sql, params=None, max_rows=10000) -> dict` | Consulta read-only segura: conexion `read_only=True`, params posicionales `?`, filas como `list[dict]` con tipos normalizados a JSON (date/datetime -> isoformat, Decimal -> float, bytes -> base64). Devuelve `{status, columns, rows, row_count, truncated}` sin lanzar. |
| `duckdb_execute_py_infra` | `duckdb_execute(db_path, sql, params=None) -> dict` | Ejecuta UNA sentencia de escritura (INSERT/UPDATE/DELETE/DDL) en conexion read-write, commit, devuelve `{status, rowcount}` sin lanzar. Primitivo de escritura del grupo (complementa a `duckdb_query_readonly`). |
| `duckdb_upsert_py_infra` | `duckdb_upsert(db_path, table, rows, key_cols, update_cols=None) -> dict` | UPSERT idempotente `INSERT ... ON CONFLICT (key_cols) DO UPDATE SET ...` actualizando SOLO `update_cols`. Excluir columnas de `update_cols` permite que un re-upsert NO las pise (ownership selectivo: la DB es la verdad). Devuelve `{status, inserted, updated}`. |
| `csv_to_parquet_duckdb_py_core` | `csv_to_parquet_duckdb(csv_path, parquet_path, column_casts=None, overwrite=False) -> bool` | Convierte CSV -> Parquet con `read_csv_auto`. `column_casts` fuerza tipos por columna. No reescribe si el parquet existe y `overwrite=False`. |
| `dedup_duckdb_table_by_hash_py_pipelines` | `dedup_duckdb_table_by_hash(duckdb_path, table, exclude_cols=None) -> dict` | Pipeline: anade columna `row_hash` (md5 de columnas de datos) idempotentemente y borra filas duplicadas conservando la primera insercion. |
| `load_ohlcv_from_duckdb_go_finance` | `LoadOHLCVFromDuckDB(dbPath, query string) ([][]float64, error)` | Carga datos OHLCV ejecutando una query SQL sobre una base DuckDB (consumo desde apps Go de finanzas). |
## Ejemplo canonico
Consulta read-only desde cualquier sesion (la conexion se abre `read_only=True` y se cierra siempre):
```bash
cd /home/enmanuel/fn_registry
python/.venv/bin/python3 - <<'PYEOF'
import sys
sys.path.insert(0, "python/functions")
from infra import duckdb_query_readonly
res = duckdb_query_readonly(
"projects/osint/apps/osint_db/data/osint.duckdb",
"SELECT contexto, COUNT(*) AS n FROM persons GROUP BY contexto ORDER BY n DESC",
max_rows=50,
)
print(res["status"], res["row_count"])
for row in res["rows"]:
print(row)
PYEOF
```
Conversion CSV -> Parquet en una linea:
```bash
./fn run csv_to_parquet_duckdb datos.csv datos.parquet
```
## Gotchas del grupo
- **Single-writer**: DuckDB permite UN solo proceso escritor por archivo. Si un service (ej. `osint_db`) posee la base, el resto de procesos deben leer con `read_only=True` (`duckdb_query_readonly` ya lo hace) o pasar por la API HTTP del service. Las funciones de escritura (`duckdb_execute`, `duckdb_upsert`) abren en read-write y SOLO debe usarlas el proceso dueño de la base (dentro de su write lock), nunca un cliente concurrente.
- **Version del motor**: el formato de archivo puede cambiar entre versiones mayores de DuckDB. El venv del registry lleva `duckdb` 1.5.x; no mezclar con CLIs/WASM antiguos sobre el mismo archivo.
- `read_only=True` exige que el archivo exista — no crea bases nuevas.
## Fronteras
- NO cubre SQLite (`sqlite_open_go_infra` y el grupo de operations.db van aparte).
- NO cubre el render de resultados a Markdown/notas — eso es `render_markdown_table_py_core` + `upsert_sentinel_block_py_core` (grupo `obsidian`).
- El analisis exploratorio pesado (notebooks) vive en `analysis/` con sus propios venvs.
+81
View File
@@ -0,0 +1,81 @@
---
name: build_vcard
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def build_vcard(contact: dict) -> str"
description: "Serializa un contacto (dict) a un VCARD 3.0 con soporte multi-valor: varias lineas TEL, EMAIL y ADR. Pura, solo compone texto. Acepta claves en espanol e ingles. Generaliza el _build_vcard inline de osint_web."
tags: [dav, vcard, carddav, contact, serialize, osint]
params:
- name: contact
desc: "dict del contacto. Claves opcionales (acepta nombre ES o EN): uid/slug (identificador, uno obligatorio), fn/nombre (FN), aliases (list -> NICKNAME CSV), org (ORG), tels/telefonos (list -> N lineas TEL;TYPE=CELL), emails/correos (list -> N lineas EMAIL;TYPE=INTERNET), adrs/direcciones (list -> N lineas ADR;TYPE=HOME con la direccion en el componente street), osint (dict con dni/pais/contexto/sexo/fecha_nacimiento -> lineas X-OSINT-*), note/notas (NOTE). Una lista que venga como string suelto se envuelve en [valor]."
output: "Texto VCARD 3.0 con lineas separadas por CRLF, empezando en BEGIN:VCARD / VERSION:3.0 y terminando en END:VCARD\\r\\n. Valores escapados segun RFC 6350; el ADR es un valor estructurado de 7 componentes cuyos separadores ';' NO se escapan."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_multivalor_tels_emails_adr", "test_escape_en_fn", "test_campos_osint", "test_claves_ingles_y_espanol_equivalentes", "test_falta_uid_y_slug_lanza_valueerror"]
test_file_path: "python/functions/core/build_vcard_test.py"
file_path: "python/functions/core/build_vcard.py"
---
## Ejemplo
```python
from core.build_vcard import build_vcard
vcard = build_vcard({
"uid": "ada-lovelace",
"fn": "Ada Lovelace",
"org": "Analytical Engine Co.",
"tels": ["+34600111222", "+34600333444"], # 2 telefonos -> 2 lineas TEL
"emails": ["ada@example.com"],
"adrs": ["Calle Mayor 1, Madrid"],
"osint": {"dni": "12345678Z", "pais": "ES"},
"note": "Contacto de prueba",
})
print(vcard)
# BEGIN:VCARD
# VERSION:3.0
# UID:ada-lovelace
# FN:Ada Lovelace
# ORG:Analytical Engine Co.
# TEL;TYPE=CELL:+34600111222
# TEL;TYPE=CELL:+34600333444
# EMAIL;TYPE=INTERNET:ada@example.com
# ADR;TYPE=HOME:;;Calle Mayor 1\, Madrid;;;;
# X-OSINT-DNI:12345678Z
# X-OSINT-PAIS:ES
# NOTE:Contacto de prueba
# END:VCARD
```
## Cuando usarla
Cuando hay que materializar un contacto multi-valor (varios telefonos, emails o
direcciones) a vCard para subirlo a CardDAV. Es el paso "componer el texto vCard"
previo a `carddav_put_vcard_py_infra`. La reusan el service `osint_db` (push
DB -> Xandikos) y `osint_web`. Usa el UID como identificador del recurso
`<uid>.vcf`, asi re-subir el mismo UID sobrescribe (idempotente).
## Gotchas
- **Pura salvo `ValueError`**: es determinista y sin efectos (no red ni disco).
La unica excepcion posible es `ValueError` cuando faltan a la vez `uid` y
`slug` (no hay identificador) — validacion de entrada aceptable en una pura.
- **ADR estructurado de 7 campos**: el ADR del vCard es un valor estructurado
`po-box;extended;street;locality;region;postal-code;country`. La direccion se
coloca en el 3er componente (street) y el resto van vacios:
`ADR;TYPE=HOME:;;<street>;;;;`. Los `;` que separan los 7 componentes NO se
escapan; solo se escapa el contenido de cada componente (RFC 6350).
- **Claves ES/EN**: para cada lista/campo acepta el nombre espanol o el ingles
(`tels`/`telefonos`, `emails`/`correos`, `adrs`/`direcciones`, `note`/`notas`,
`fn`/`nombre`). Si vienen ambos, gana el primero presente segun el orden
documentado.
- **Lista como string suelto**: si una clave de lista llega como string en vez
de lista, se envuelve en `[valor]` y produce una sola linea.
+139
View File
@@ -0,0 +1,139 @@
"""Serializa un contacto (dict) a un VCARD 3.0 con soporte multi-valor.
Generaliza el ``_build_vcard`` inline de ``osint_web/server/main.py`` (que solo
emitia un TEL y un EMAIL): aqui acepta listas de telefonos, emails y direcciones
y emite una linea por elemento. Es una funcion pura — solo compone texto, sin red
ni disco. La unica excepcion posible es ``ValueError`` por validacion de entrada
(falta de identificador), lo cual es aceptable para una funcion pura.
"""
# Orden + nombre de propiedad X-OSINT para cada clave del bloque osint.
_OSINT_FIELDS = (
("dni", "X-OSINT-DNI"),
("pais", "X-OSINT-PAIS"),
("contexto", "X-OSINT-CONTEXTO"),
("sexo", "X-OSINT-SEXO"),
("fecha_nacimiento", "X-OSINT-FECHA-NACIMIENTO"),
)
def _vcard_escape(value: str) -> str:
"""Escapa un valor de texto para una linea vCard (RFC 6350).
Reglas: ``\\`` -> ``\\\\``, salto de linea -> ``\\n``, ``,`` -> ``\\,``,
``;`` -> ``\\;``. Se aplica al contenido de cada propiedad, NO a los
separadores estructurales del ADR.
El retorno de carro ``\\r`` crudo se ELIMINA (no se escapa): un ``\\r`` solo,
sin ``\\n`` que lo siga, sobrevive al escape de ``\\n`` y queda como carácter de
control dentro del valor. Varios parsers de vCard (y el propio ``_unfold_lines``
de osint_web, que normaliza ``\\r`` a ``\\n``) lo tratan como un separador de
línea, lo que permitiría inyectar propiedades nuevas (p. ej. ``X-OSINT-DNI``)
en la tarjeta. Eliminarlo cierra ese vector, en paridad con el escape iCal.
"""
return (
value.replace("\\", "\\\\")
.replace("\r", "")
.replace("\n", "\\n")
.replace(",", "\\,")
.replace(";", "\\;")
)
def _as_list(value) -> list:
"""Normaliza un valor a lista: ``None`` -> ``[]``, string suelto -> ``[s]``.
Tolera que una clave que deberia ser lista venga como string suelto.
"""
if value is None:
return []
if isinstance(value, str):
return [value]
if isinstance(value, (list, tuple)):
return list(value)
return [value]
def _pick(contact: dict, *keys):
"""Devuelve el primer valor no vacio entre ``keys`` (acepta ES/EN)."""
for key in keys:
val = contact.get(key)
if val:
return val
return None
def build_vcard(contact: dict) -> str:
"""Serializa un contacto (dict) a un VCARD 3.0 con soporte multi-valor.
Args:
contact: dict con claves opcionales (acepta nombre ES o EN):
- ``uid`` / ``slug``: identificador del vCard. Uno obligatorio.
- ``fn`` / ``nombre``: nombre completo (FN).
- ``aliases``: lista -> NICKNAME (CSV escapado).
- ``org``: organizacion -> ORG.
- ``tels`` / ``telefonos``: lista -> una linea TEL;TYPE=CELL por item.
- ``emails`` / ``correos``: lista -> una linea EMAIL;TYPE=INTERNET por item.
- ``adrs`` / ``direcciones``: lista -> una linea ADR;TYPE=HOME por item
(la direccion va en el componente street del ADR estructurado).
- ``osint``: dict con ``dni, pais, contexto, sexo, fecha_nacimiento``
-> lineas X-OSINT-* (solo las presentes/no vacias).
- ``note`` / ``notas``: texto -> NOTE.
Returns:
Texto VCARD 3.0 con lineas separadas por CRLF, terminando en
``END:VCARD\\r\\n``.
Raises:
ValueError: si faltan ``uid`` y ``slug`` (no hay identificador).
"""
uid = contact.get("uid") or contact.get("slug")
if not uid:
raise ValueError("build_vcard: falta identificador (uid o slug)")
uid = str(uid).strip()
nombre = _pick(contact, "fn", "nombre")
nombre = str(nombre).strip() if nombre else uid
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
"UID:%s" % _vcard_escape(uid),
"FN:%s" % _vcard_escape(nombre),
]
aliases = _as_list(contact.get("aliases"))
if aliases:
joined = ",".join(_vcard_escape(str(a)) for a in aliases)
lines.append("NICKNAME:%s" % joined)
org = contact.get("org")
if org:
lines.append("ORG:%s" % _vcard_escape(str(org)))
for tel in _as_list(_pick(contact, "tels", "telefonos")):
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(str(tel)))
for email in _as_list(_pick(contact, "emails", "correos")):
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(str(email)))
for adr in _as_list(_pick(contact, "adrs", "direcciones")):
# ADR estructurado: 7 componentes separados por ';' SIN escapar los
# separadores. La direccion va en el 3er componente (street); el resto
# vacios: po-box;extended;street;locality;region;postal-code;country.
street = _vcard_escape(str(adr))
lines.append("ADR;TYPE=HOME:;;%s;;;;" % street)
osint = contact.get("osint")
if isinstance(osint, dict):
for key, x_name in _OSINT_FIELDS:
val = osint.get(key)
if val:
lines.append("%s:%s" % (x_name, _vcard_escape(str(val))))
note = _pick(contact, "note", "notas")
if note:
lines.append("NOTE:%s" % _vcard_escape(str(note)))
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
+98
View File
@@ -0,0 +1,98 @@
"""Tests para build_vcard."""
import pytest
from build_vcard import build_vcard
def _count_lines(vcard: str, prefix: str) -> int:
return sum(1 for ln in vcard.split("\r\n") if ln.startswith(prefix))
def test_multivalor_tels_emails_adr():
vcard = build_vcard(
{
"uid": "ada-lovelace",
"fn": "Ada Lovelace",
"tels": ["+34600111222", "+34600333444"],
"emails": ["ada@example.com", "lovelace@example.org"],
"adrs": ["Calle Mayor 1, Madrid"],
}
)
assert _count_lines(vcard, "TEL") == 2
assert _count_lines(vcard, "EMAIL") == 2
assert _count_lines(vcard, "ADR") == 1
assert vcard.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n")
assert vcard.endswith("END:VCARD\r\n")
def test_escape_en_fn():
vcard = build_vcard({"uid": "x", "fn": "Doe, John; Jr"})
# ',' -> '\,' y ';' -> '\;' en el valor del FN.
assert "FN:Doe\\, John\\; Jr" in vcard.split("\r\n")
def test_campos_osint():
vcard = build_vcard(
{
"uid": "target-1",
"fn": "Target One",
"osint": {
"dni": "12345678Z",
"pais": "ES",
"contexto": "investigacion",
"sexo": "M",
"fecha_nacimiento": "1990-01-01",
"vacio": "",
},
}
)
lines = vcard.split("\r\n")
assert "X-OSINT-DNI:12345678Z" in lines
assert "X-OSINT-PAIS:ES" in lines
assert "X-OSINT-CONTEXTO:investigacion" in lines
assert "X-OSINT-SEXO:M" in lines
assert "X-OSINT-FECHA-NACIMIENTO:1990-01-01" in lines
# Una clave vacia o desconocida no emite linea.
assert _count_lines(vcard, "X-OSINT-") == 5
def test_claves_ingles_y_espanol_equivalentes():
ingles = build_vcard(
{"uid": "a", "fn": "A", "tels": ["+1"], "emails": ["a@b.c"]}
)
espanol = build_vcard(
{"uid": "a", "fn": "A", "telefonos": ["+1"], "correos": ["a@b.c"]}
)
assert ingles == espanol
def test_falta_uid_y_slug_lanza_valueerror():
with pytest.raises(ValueError):
build_vcard({"fn": "Sin identificador"})
def test_cr_crudo_no_inyecta_propiedades():
"""Un '\\r' crudo en un valor no debe poder inyectar una propiedad nueva.
Sin neutralizar el '\\r', un parser que normalice '\\r' a salto de línea (como
el _unfold_lines de osint_web) leería 'X-OSINT-DNI' / 'X-EVIL' como propiedades
legítimas, burlando el control de "no exponer X-OSINT-* al móvil". El escape
debe eliminar el '\\r' para que el valor quede en una sola línea física.
"""
vcard = build_vcard(
{
"uid": "victima",
"fn": "Bob\rX-OSINT-DNI:11111111H\rX-EVIL:pwned",
"tels": ["911\rNOTE:leak"],
}
)
# Simula el unfold de osint_web: '\r\n' y '\r' sueltos pasan a salto de línea.
physical_lines = vcard.replace("\r\n", "\n").replace("\r", "\n").split("\n")
inyectadas = [
ln for ln in physical_lines if ln.startswith(("X-OSINT-DNI", "X-EVIL", "NOTE"))
]
assert inyectadas == [], f"propiedades inyectadas via CR: {inyectadas}"
# El '\r' no debe sobrevivir en el texto serializado salvo como CRLF de línea.
assert "\rX-OSINT" not in vcard
assert "\rNOTE" not in vcard
+16
View File
@@ -10,6 +10,14 @@ from .hoppscotch_list_requests import hoppscotch_list_requests
from .pass_get_secret import pass_get_secret
from .hoppscotch_set_environment import hoppscotch_set_environment
from .hoppscotch_run_request import hoppscotch_run_request
from .split_vcards import split_vcards
from .split_vevents_to_vcalendars import split_vevents_to_vcalendars
from .extract_or_make_uid import extract_or_make_uid
from .carddav_put_vcard import carddav_put_vcard
from .caldav_put_event import caldav_put_event
from .dav_list_resources import dav_list_resources
from .dav_get_resource import dav_get_resource
from .dav_delete_resource import dav_delete_resource
__all__ = [
"setup_logger",
@@ -25,4 +33,12 @@ __all__ = [
"pass_get_secret",
"hoppscotch_set_environment",
"hoppscotch_run_request",
"split_vcards",
"split_vevents_to_vcalendars",
"extract_or_make_uid",
"carddav_put_vcard",
"caldav_put_event",
"dav_list_resources",
"dav_get_resource",
"dav_delete_resource",
]
@@ -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,87 @@
---
name: dav_delete_resource
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def dav_delete_resource(base_url: str, username: str, password: str, resource_path: str, *, etag: str = '', timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
description: "Borra (HTTP DELETE) 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 devuelven dav_list_resources / dav_get_collection) o una URL completa. Opcionalmente envia If-Match: <etag> para un borrado condicional que evita pisar una edicion concurrente. DESTRUCTIVO e IRREVERSIBLE: usar con confirmacion explicita, nunca a ciegas. verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, ssl). Probado contra Xandikos."
tags: [dav, carddav, caldav, delete, remove, 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 borrar. Acepta directamente los hrefs que devuelven dav_list_resources / dav_get_collection."
- name: etag
desc: "etag del recurso para borrado condicional via If-Match. Si se da, el servidor solo borra cuando el etag actual coincide (412 si cambio). Vacio = borrado incondicional."
- 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} (DELETE devuelve normalmente 204 No Content o 200). En error (sin lanzar): {status:'error', error:str, http_status:int|None}. Un 404 (ya no existe) llega como error con http_status=404, tratable como idempotente."
tested: true
tests:
- "test_construye_request_delete_con_auth"
- "test_resource_path_relativo_se_resuelve_con_base_url"
- "test_resource_path_absoluto_se_respeta"
- "test_if_match_se_envia_cuando_hay_etag"
- "test_sin_etag_no_envia_if_match"
- "test_204_devuelve_ok"
- "test_404_devuelve_status_error"
test_file_path: "python/functions/infra/dav_delete_resource_test.py"
file_path: "python/functions/infra/dav_delete_resource.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
from infra.dav_delete_resource import dav_delete_resource
base = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
coll = "/enmanuel/contacts/addressbook/"
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
# Sube un vCard de prueba y luego lo borra (limpieza de un test):
vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Tmp\r\nUID:zz-tmp\r\nEND:VCARD\r\n"
carddav_put_vcard(base, "enmanuel", pw, coll, "zz-tmp", vcard)
res = dav_delete_resource(base, "enmanuel", pw, coll + "zz-tmp.vcf")
print(res["status"], res["http_status"]) # ok 204
```
## Cuando usarla
Cuando necesitas RETIRAR un recurso de una coleccion CardDAV/CalDAV: limpiar el
vCard de prueba que subiste para validar un sync, borrar un contacto obsoleto,
o eliminar un evento cancelado. Completa el CRUD del grupo `dav` (put / get /
list / get-collection / **delete**). Para limpieza segura tras un test usa el
href que devuelve `carddav_put_vcard` (campo `url`) o el `href` de
`dav_get_collection`.
## Gotchas
- DESTRUCTIVO e IRREVERSIBLE en el servidor. No llamarla en un bucle de sync sin
confirmacion explicita (`confirm=True` / `--yes` en el caller). Pensada para
acciones puntuales controladas, no para reconciliacion automatica.
- Un 404 (el recurso ya no existe) llega como `{status:'error', http_status:404}`.
Para un borrado idempotente, el caller puede tratar 404 como exito ("ya estaba
borrado").
- Pasa `etag` para borrado condicional (If-Match): si el recurso cambio desde que
lo leiste, el servidor responde 412 Precondition Failed y NO borra — evita
pisar una edicion concurrente del movil.
- Borrado remoto real sobre TLS; password de `pass`, no se logea.
- `verify_tls=False` solo en pruebas; abre MITM.
@@ -0,0 +1,83 @@
"""Borra (DELETE) un recurso DAV individual via HTTP Basic auth.
Funcion impura: hace una peticion HTTP DELETE. 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 /
dav_get_collection) o una ruta relativa al base_url. Opcionalmente envia el
header `If-Match: <etag>` para un borrado condicional (solo borra si el etag
coincide, evita pisar una edicion concurrente). Maneja errores sin lanzar.
Solo usa stdlib (urllib, base64, ssl).
ATENCION: DELETE es DESTRUCTIVO e IRREVERSIBLE en el servidor. Usar con
confirmacion explicita del caller (nunca a ciegas en un bucle de sync). Pensado
para limpiar recursos de prueba o retirar contactos obsoletos de forma
controlada.
"""
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_delete_resource(
base_url: str,
username: str,
password: str,
resource_path: str,
*,
etag: str = "",
timeout_s: float = 20.0,
verify_tls: bool = True,
) -> dict:
"""Borra un recurso DAV (DELETE). DESTRUCTIVO e IRREVERSIBLE.
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. Resolver desde pass.
resource_path: href absoluto (p.ej. '/enmanuel/contacts/addressbook/x.vcf')
o URL completa del recurso a borrar. Acepta directamente los hrefs
que devuelven dav_list_resources / dav_get_collection.
etag: si se da, se envia como header If-Match para un borrado
condicional (el servidor solo borra si el etag actual coincide;
devuelve 412 Precondition Failed si cambio). Vacio = borrado
incondicional.
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, url:str} (DELETE devuelve
normalmente 204 No Content o 200). En error (sin lanzar):
{status:'error', error:str, http_status:int|None}. Un 404 (ya no existe)
se devuelve como error con http_status=404; el caller puede tratarlo
como idempotente (ya borrado).
"""
url = _resolve_url(base_url, resource_path)
headers = {"Authorization": _basic_auth_header(username, password)}
if etag:
headers["If-Match"] = etag
req = urllib.request.Request(url, method="DELETE", 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,96 @@
"""Tests para dav_delete_resource.
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
Request (method DELETE, auth, URL, headers If-Match) y simular respuestas.
"""
import base64
import sys
import infra.dav_delete_resource # noqa: F401
mod = sys.modules["infra.dav_delete_resource"]
dav_delete_resource = mod.dav_delete_resource
class _FakeResp:
def __init__(self, status=204):
self.status = status
def __enter__(self):
return self
def __exit__(self, *a):
return False
def _capture(monkeypatch, status=204):
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(status)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
return captured
def test_construye_request_delete_con_auth(monkeypatch):
cap = _capture(monkeypatch)
res = dav_delete_resource(
"https://dav.example.com", "enmanuel", "secret-pw",
"/enmanuel/contacts/addressbook/ada.vcf",
)
assert res["status"] == "ok"
assert cap["method"] == "DELETE"
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_delete_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_delete_resource("https://dav.example.com", "u", "p", abs_url)
assert cap["url"] == abs_url
def test_if_match_se_envia_cuando_hay_etag(monkeypatch):
cap = _capture(monkeypatch)
dav_delete_resource(
"https://dav.example.com", "u", "p", "/x.vcf", etag='"abc123"',
)
assert cap["headers"]["if-match"] == '"abc123"'
def test_sin_etag_no_envia_if_match(monkeypatch):
cap = _capture(monkeypatch)
dav_delete_resource("https://dav.example.com", "u", "p", "/x.vcf")
assert "if-match" not in cap["headers"]
def test_204_devuelve_ok(monkeypatch):
_capture(monkeypatch, status=204)
res = dav_delete_resource("https://dav.example.com", "u", "p", "/x.vcf")
assert res["status"] == "ok"
assert res["http_status"] == 204
def test_404_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_delete_resource("https://dav.example.com", "u", "p", "/x.vcf")
assert res["status"] == "error"
assert res["http_status"] == 404
@@ -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,91 @@
---
name: dav_list_addressbooks
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def dav_list_addressbooks(base_url: str, username: str, password: str, contacts_home: str, *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
description: "Lista las colecciones de libreta de contactos CardDAV bajo un contacts-home en UNA peticion PROPFIND Depth:1. Devuelve solo las colecciones hijas que son libretas CardDAV de verdad (resourcetype {urn:ietf:params:xml:ns:carddav}addressbook), cada una con su href, su displayname (DAV) y su descripcion (addressbook-description de CardDAV) si el servidor la expone. El propio contacts-home (coleccion plana sin el resourcetype addressbook) y cualquier coleccion no-addressbook 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 (parser puro _parse_multistatus aislado para testeo sin red). verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, re, ssl, html). Es la analoga CardDAV de dav_list_calendars. Probada contra Xandikos."
tags: [dav, carddav, addressbook, addressbooks, contacts, propfind, displayname, description, ingest, 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: contacts_home
desc: "ruta del contacts-home del usuario (p.ej. '/enmanuel/contacts/'). Las libretas de contactos cuelgan de el."
- 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, addressbooks:[{href:str, name:str, description:str|None}, ...]} con un elemento por libreta de contactos, ordenadas por nombre. description es el addressbook-description de CardDAV o None. En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
tested: true
tests:
- "test_devuelve_dos_libretas_descarta_home"
- "test_ordenadas_por_nombre"
- "test_extrae_nombre_y_descripcion"
- "test_descripcion_ausente_es_none"
- "test_ignora_props_404"
- "test_multistatus_vacio_devuelve_lista_vacia"
- "test_home_sin_libretas_solo_home"
test_file_path: "python/functions/infra/dav_list_addressbooks_test.py"
file_path: "python/functions/infra/dav_list_addressbooks.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from infra.dav_list_addressbooks import dav_list_addressbooks
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
res = dav_list_addressbooks(
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
contacts_home="/enmanuel/contacts/",
)
for a in res["addressbooks"]:
print(a["name"], a["href"], a["description"])
# Personal /enmanuel/contacts/personal/ Contactos personales & familia
# contacts /enmanuel/contacts/contacts/ None
```
## Cuando usarla
Cuando un ingest necesita recorrer **todas** las libretas de contactos del usuario,
no solo la principal: el contacts-home CardDAV puede tener varias colecciones
(personal, trabajo, familia, ...) y necesitas el href de cada una para luego
volcar sus vCards con `dav_list_resources` / `dav_get_collection`. Tambien sirve
para un selector de libreta en una UI (nombre + descripcion). Devuelve los hrefs
sin que el caller los conozca de antemano ni tenga que distinguir el contacts-home
de las libretas reales. Es la analoga CardDAV de `dav_list_calendars`.
## Gotchas
- **Impura: red + auth.** Lectura remota real sobre TLS; el password viene de
`pass` (`dav/xandikos-enmanuel`) y no se logea. `verify_tls=True` por defecto;
no desactivar salvo entorno de prueba.
- **Depende de que el servidor exponga `resourcetype` en el PROPFIND.** Filtra por
el resourcetype `carddav:addressbook`: el propio contacts-home es una coleccion
plana (sin ese resourcetype) y queda fuera, igual que carpetas intermedias. Si
un servidor no devuelve `resourcetype`, ninguna coleccion pasa el filtro y la
lista sale vacia. Si tu servidor anida libretas mas profundo que Depth:1, llama
con `contacts_home` apuntando al nivel correcto.
- La `description` es el `addressbook-description` de CardDAV
(`urn:ietf:params:xml:ns:carddav`), que Xandikos puede no tener seteado
(devuelve None).
- Solo lee los `<propstat>` con estado 2xx para no confundir un
`<addressbook-description/>` vacio de un propstat 404 con una descripcion real.
@@ -0,0 +1,202 @@
"""Lista las colecciones de libreta de contactos CardDAV bajo un contacts-home (PROPFIND).
Funcion impura: hace UNA peticion HTTP PROPFIND Depth:1 sobre el directorio
contacts-home de un usuario (p.ej. `/enmanuel/contacts/`) y devuelve solo las
colecciones hijas que son libretas CardDAV de verdad — las que declaran el
resourcetype `{urn:ietf:params:xml:ns:carddav}addressbook`. Por cada una extrae
su href, su `displayname` (DAV) y, si el servidor lo expone, su descripcion
(`addressbook-description` de CardDAV). El propio contacts-home (que es una
coleccion plana sin el resourcetype addressbook) se excluye.
Es la analoga CardDAV de `dav_list_calendars`: lo que necesita un ingest o un
selector de libreta cuando el usuario tiene varias colecciones de contactos bajo
su contacts-home y hay que recorrerlas todas (o elegir una), con su nombre y su
descripcion. `dav_list_resources` solo devuelve hrefs+etag de los recursos de UNA
coleccion (las vCards), 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,
)
# Descripcion de CardDAV: <ns:addressbook-description>texto</ns:addressbook-description>.
_DESCRIPTION_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?addressbook-description[^>]*>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?addressbook-description>",
re.DOTALL | re.IGNORECASE,
)
# Marca de libreta CardDAV en el resourcetype. El elemento `<C:addressbook/>`
# puede venir con o sin prefijo de namespace (`<ns2:addressbook/>`, `<addressbook/>`).
_ADDRESSBOOK_TYPE_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?addressbook(?:\s[^>]*)?/?>", re.IGNORECASE
)
# El PROPFIND pide nombre, tipo y descripcion. Declarar el namespace de CardDAV
# permite que el servidor responda `addressbook-description` cuando exista.
_PROPFIND_BODY = (
'<?xml version="1.0" encoding="utf-8" ?>'
'<D:propfind xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:carddav">'
"<D:prop>"
"<D:displayname/><D:resourcetype/><C:addressbook-description/>"
"</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
`<addressbook-description/>` 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 _parse_multistatus(xml_text: str, contacts_home: str) -> list:
"""Parsea un multistatus 207 y devuelve las libretas CardDAV hijas.
Helper puro (sin red): recorre los `<response>` del XML, descarta el propio
contacts-home y las colecciones que no declaran el resourcetype
`carddav:addressbook`, y por cada libreta extrae href, displayname y
addressbook-description (de los propstat 2xx). Devuelve la lista ordenada por
nombre. Aislado para poder testear el parseo sin tocar la red.
Args:
xml_text: cuerpo XML del multistatus (respuesta del PROPFIND Depth:1).
contacts_home: ruta del contacts-home (p.ej. '/enmanuel/contacts/'),
usada para excluir la entrada del propio home.
Returns:
list de dicts {href:str, name:str, description:str|None}, ordenada por
name (case-insensitive).
"""
home_tail = contacts_home.strip("/").rsplit("/", 1)[-1]
addressbooks = []
for block in _RESPONSE_RE.findall(xml_text):
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 contacts-home (o un href identico al home): se excluye.
if tail == home_tail:
continue
# Solo las colecciones marcadas como libreta CardDAV. El home plano no
# lleva el resourcetype `carddav:addressbook` y queda fuera.
if not _ADDRESSBOOK_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
desc_m = _DESCRIPTION_RE.search(ok)
description = html.unescape(desc_m.group(1).strip()) if desc_m else None
if description == "":
description = None
addressbooks.append({"href": href, "name": name, "description": description})
addressbooks.sort(key=lambda a: a["name"].lower())
return addressbooks
def dav_list_addressbooks(
base_url: str,
username: str,
password: str,
contacts_home: str,
*,
timeout_s: float = 20.0,
verify_tls: bool = True,
) -> dict:
"""Lista las colecciones de libreta de contactos CardDAV bajo un contacts-home.
Hace un PROPFIND Depth:1 sobre `contacts_home` y devuelve solo las colecciones
hijas marcadas como libreta CardDAV (resourcetype `carddav:addressbook`), con
su nombre y descripcion. El propio `contacts_home` y cualquier coleccion
no-addressbook 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.
contacts_home: ruta del contacts-home del usuario (p.ej.
'/enmanuel/contacts/'). Las libretas de contactos cuelgan de el.
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,
addressbooks:[{href:str, name:str, description:str|None}, ...]} con un
elemento por libreta de contactos (ordenadas por nombre). `description`
es el `addressbook-description` de CardDAV si el servidor lo expone, o
None. En error (sin lanzar): {status:'error', error:str,
http_status:int|None}.
"""
url = _join_url(base_url, contacts_home)
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}
addressbooks = _parse_multistatus(xml, contacts_home)
return {"status": "ok", "http_status": status, "addressbooks": addressbooks}
@@ -0,0 +1,112 @@
"""Tests para dav_list_addressbooks.
Sin red: ejercitan el parser puro `_parse_multistatus` con un multistatus 207
realista (2 libretas CardDAV + el contacts-home). Verifican que descarta el home,
extrae nombre y descripcion correctos, normaliza descripcion ausente a None e
ignora props marcadas 404 por el servidor.
"""
from infra.dav_list_addressbooks import _parse_multistatus
# Multistatus 207 realista al estilo Xandikos: el contacts-home plano + dos
# libretas CardDAV. La segunda no expone addressbook-description en su propstat
# 2xx (Xandikos la devuelve vacia en un propstat 404).
_MULTISTATUS = """<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:response>
<D:href>/enmanuel/contacts/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
<D:displayname>contacts</D:displayname>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
<D:propstat>
<D:prop><C:addressbook-description/></D:prop>
<D:status>HTTP/1.1 404 Not Found</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/enmanuel/contacts/personal/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><C:addressbook/></D:resourcetype>
<D:displayname>Personal</D:displayname>
<C:addressbook-description>Contactos personales &amp; familia</C:addressbook-description>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/enmanuel/contacts/work/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><C:addressbook/></D:resourcetype>
<D:displayname>Trabajo</D:displayname>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
<D:propstat>
<D:prop><C:addressbook-description/></D:prop>
<D:status>HTTP/1.1 404 Not Found</D:status>
</D:propstat>
</D:response>
</D:multistatus>
"""
def test_devuelve_dos_libretas_descarta_home():
libros = _parse_multistatus(_MULTISTATUS, "/enmanuel/contacts/")
assert len(libros) == 2
hrefs = [a["href"] for a in libros]
assert "/enmanuel/contacts/" not in hrefs
def test_ordenadas_por_nombre():
libros = _parse_multistatus(_MULTISTATUS, "/enmanuel/contacts/")
# Orden case-insensitive: "Personal" < "Trabajo".
assert [a["name"] for a in libros] == ["Personal", "Trabajo"]
def test_extrae_nombre_y_descripcion():
libros = _parse_multistatus(_MULTISTATUS, "/enmanuel/contacts/")
personal = next(a for a in libros if a["href"] == "/enmanuel/contacts/personal/")
assert personal["name"] == "Personal"
# html.unescape convierte &amp; en &.
assert personal["description"] == "Contactos personales & familia"
def test_descripcion_ausente_es_none():
libros = _parse_multistatus(_MULTISTATUS, "/enmanuel/contacts/")
trabajo = next(a for a in libros if a["href"] == "/enmanuel/contacts/work/")
assert trabajo["name"] == "Trabajo"
assert trabajo["description"] is None
def test_ignora_props_404():
# El home tiene un addressbook-description en un propstat 404; aunque no se
# descartara por resourcetype, no debe colarse como libreta ni su prop vacia
# mezclarse con otra entrada.
libros = _parse_multistatus(_MULTISTATUS, "/enmanuel/contacts/")
for a in libros:
# Ninguna descripcion debe ser cadena vacia (404 -> None, no "").
assert a["description"] != ""
def test_multistatus_vacio_devuelve_lista_vacia():
xml = '<D:multistatus xmlns:D="DAV:"></D:multistatus>'
assert _parse_multistatus(xml, "/enmanuel/contacts/") == []
def test_home_sin_libretas_solo_home():
xml = """<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/enmanuel/contacts/</D:href>
<D:propstat>
<D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
assert _parse_multistatus(xml, "/enmanuel/contacts/") == []
@@ -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,110 @@
---
name: dav_make_addressbook
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def dav_make_addressbook(base_url: str, username: str, password: str, contacts_home: str, slug: str, display_name: str = \"\", description: str = \"\", *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
description: "Crea una nueva coleccion de contactos CardDAV (una libreta/agenda de contactos nueva) bajo el contacts-home de un principal via MKCOL extendido (RFC 5689), declarando el resourcetype como addressbook y fijando el displayname y la descripcion (addressbook-description) en el propio cuerpo XML. La coleccion se crea en <contacts_home><slug>/. El slug se sanea a [a-z0-9_-] (minusculas, espacios->guion); si queda vacio devuelve error de validacion. Idempotente: 201 Created es exito; 405/301 (ya existe) devuelve {status:'ok', existed:True}. Escapa display_name/description para XML. Construye Authorization: Basic base64(user:pass) a mano. Maneja errores sin lanzar (salvo validacion de args). Solo stdlib (urllib, base64, re, ssl, xml.sax.saxutils). Probado contra Xandikos. Analoga de dav_make_calendar para CardDAV."
tags: [dav, carddav, addressbook, contacts, mkcol, create, collection, http, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [base64, re, ssl, urllib.error, urllib.request, xml.sax.saxutils]
params:
- name: base_url
desc: "URL base del servidor DAV sin barra final (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: contacts_home
desc: "ruta del contacts-home del principal con barra final (p.ej. '/enmanuel/contacts/'). La nueva coleccion cuelga de el."
- name: slug
desc: "segmento de path de la coleccion en la URL (p.ej. 'trabajo'); se sanea a [a-z0-9_-]. La coleccion se crea en <contacts_home><slug>/. Si queda vacio tras sanear, devuelve error de validacion."
- name: display_name
desc: "nombre visible de la coleccion (DAV:displayname). Si vacio, usa el slug saneado."
- name: description
desc: "descripcion de la coleccion (addressbook-description de CardDAV). Opcional; '' lo omite."
- name: timeout_s
desc: "timeout de cada 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, href:str} y, si la coleccion ya existia, ademas existed:True. En error (sin lanzar): {status:'error', http_status:int|None, href:str, error:str}. href es la ruta de la coleccion (contacts_home + slug saneado + '/')."
tested: true
tests:
- "test_sanitize_slug_minusculas"
- "test_sanitize_slug_espacios_a_guion"
- "test_sanitize_slug_elimina_caracteres_raros"
- "test_sanitize_slug_colapsa_guiones_y_recorta"
- "test_sanitize_slug_vacio"
- "test_join_url_compone_la_coleccion"
- "test_mkcol_xml_es_mkcol_extendido"
- "test_mkcol_xml_declara_resourcetype_addressbook"
- "test_mkcol_xml_incluye_displayname"
- "test_mkcol_xml_escapa_displayname"
- "test_mkcol_xml_incluye_y_escapa_descripcion"
- "test_mkcol_xml_omite_descripcion_vacia"
test_file_path: "python/functions/infra/dav_make_addressbook_test.py"
file_path: "python/functions/infra/dav_make_addressbook.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from infra.dav_make_addressbook import dav_make_addressbook
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
res = dav_make_addressbook(
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
contacts_home="/enmanuel/contacts/",
slug="trabajo",
display_name="Trabajo",
)
print(res)
# {'status': 'ok', 'http_status': 201, 'href': '/enmanuel/contacts/trabajo/'}
# Volver a llamar con el mismo slug:
# {'status': 'ok', 'http_status': 405, 'href': '/enmanuel/contacts/trabajo/', 'existed': True}
```
## Cuando usarla
Cuando el usuario quiere una libreta/agenda de contactos nueva ademas de la
principal: una coleccion CardDAV separada ("Trabajo", "Personal", "Familia") con
su propio nombre visible, bajo el contacts-home del principal. Es la analoga de
`dav_make_calendar` para CardDAV. El `href` devuelto es la ruta de la coleccion
que luego usas para escribir vCards (PUT de cada contacto) o para listarla en el
selector de libretas.
## Gotchas
- Impura: requiere red + Basic auth contra el servidor DAV. El password viene de
`pass`, no se logea ni se hardcodea.
- Idempotente: si la coleccion ya existe en ese path el servidor responde 405
(Method Not Allowed) o 301; ambos se traducen a `{status:'ok', existed:True}`
en vez de error, asi que es seguro reintentar.
- A diferencia de los calendarios (que tienen el metodo HTTP dedicado
MKCALENDAR), CardDAV NO define un "MKADDRESSBOOK". La creacion se hace con
**MKCOL extendido (RFC 5689)**: metodo HTTP `MKCOL` con un cuerpo XML que
declara el `resourcetype` como `D:collection` + `C:addressbook`. Probado contra
Xandikos, que lo soporta.
- Fallback para servidores sin MKCOL extendido: algunos servidores CardDAV viejos
no aceptan cuerpo en MKCOL y devuelven 415/400. En ese caso el patron es
`MKCOL` simple (sin cuerpo) para crear la coleccion + un `PROPPATCH` posterior
que fije el `resourcetype` addressbook, el `displayname` y la
`addressbook-description`. Esta funcion implementa solo el camino extendido (un
request); si te topas con un servidor que no lo soporta, anade el fallback
MKCOL+PROPPATCH antes de promoverlo.
- El `slug` se sanea a `[a-z0-9_-]` (minusculas, espacios->guion, resto fuera).
Un slug que queda vacio tras sanear (p.ej. solo simbolos) devuelve error de
validacion sin tocar la red. El `display_name` y la `description` se escapan
para XML, pero el `slug` que va en la URL ya esta restringido al charset seguro.
@@ -0,0 +1,171 @@
"""Crea una nueva coleccion de contactos CardDAV bajo un contacts-home.
Funcion impura: hace una peticion HTTP MKCOL extendido (RFC 5689) para crear una
"libreta/agenda de contactos" nueva bajo el contacts-home de un principal. El
cuerpo XML del MKCOL declara el resourcetype como addressbook
(`{urn:ietf:params:xml:ns:carddav}addressbook`) y fija de paso el nombre visible
(DAV:displayname) y la descripcion (CardDAV addressbook-description).
El slug (segmento de path de la coleccion) se sanea a `[a-z0-9_-]` (minusculas,
espacios -> '-'); si queda vacio se devuelve un error de validacion. La coleccion
se crea en `<contacts_home><slug>/`.
Idempotente: un 201 (Created) es exito; un 405 (Method Not Allowed) o un 301 (la
coleccion ya existe en ese path) se devuelven como {status:'ok', existed:True}.
El display_name y la description se escapan para XML. Construye
`Authorization: Basic base64(user:pass)` a mano con stdlib. Maneja errores sin
lanzar (salvo validacion de args). Solo usa stdlib (urllib, base64, re, ssl,
xml.sax.saxutils). Probado contra Xandikos.
"""
import base64
import re
import ssl
import urllib.error
import urllib.request
from xml.sax.saxutils import escape as _xml_escape
_UNSAFE_SLUG_RE = re.compile(r"[^a-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 _sanitize_slug(slug: str) -> str:
"""Sanea un slug a `[a-z0-9_-]`.
Pasa a minusculas, convierte espacios (y runs de espacios) en un guion, y
elimina cualquier otro caracter no permitido. Colapsa guiones repetidos y
recorta guiones de los extremos. Puede devolver "" si no queda nada usable;
el caller trata "" como error de validacion.
"""
s = slug.strip().lower()
s = re.sub(r"\s+", "-", s)
s = _UNSAFE_SLUG_RE.sub("", s)
s = re.sub(r"-{2,}", "-", s).strip("-")
return s
def _build_mkcol_xml(display_name: str, description: str = "") -> str:
"""Cuerpo XML del MKCOL extendido (RFC 5689) para crear un addressbook.
Declara el resourcetype como `D:collection` + `C:addressbook` (CardDAV) y
setea el displayname; si hay descripcion, anade `C:addressbook-description`.
Ambos valores se escapan para XML.
"""
name = _xml_escape(display_name)
props = [
"<D:resourcetype><D:collection/><C:addressbook/></D:resourcetype>",
"<D:displayname>%s</D:displayname>" % name,
]
if description:
props.append(
"<C:addressbook-description>%s</C:addressbook-description>"
% _xml_escape(description)
)
return (
'<?xml version="1.0" encoding="utf-8" ?>'
'<D:mkcol xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:carddav">'
"<D:set><D:prop>%s</D:prop></D:set>"
"</D:mkcol>"
) % "".join(props)
def _join_url(base_url: str, contacts_home: str, slug: str) -> str:
return base_url.rstrip("/") + "/" + contacts_home.strip("/") + "/" + slug + "/"
def dav_make_addressbook(
base_url: str,
username: str,
password: str,
contacts_home: str,
slug: str,
display_name: str = "",
description: str = "",
*,
timeout_s: float = 20.0,
verify_tls: bool = True,
) -> dict:
"""Crea una nueva coleccion de contactos CardDAV (MKCOL extendido RFC 5689).
Crea la coleccion en `<contacts_home><slug>/` via MKCOL extendido, declarando
el resourcetype como addressbook y fijando el displayname (y la descripcion si
se pasa) en el propio cuerpo. Idempotente: si la coleccion ya existe (405/301)
devuelve {status:'ok', existed:True}.
Args:
base_url: URL base del servidor DAV (sin barra final), p.ej.
'https://dav-x.organic-machine.com'.
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth. Resolver desde pass.
contacts_home: ruta del contacts-home del principal (con barra final),
p.ej. '/enmanuel/contacts/'. La coleccion cuelga de el.
slug: segmento de path de la coleccion (p.ej. 'trabajo'); se sanea a
[a-z0-9_-]. Si queda vacio tras sanear, error de validacion.
display_name: nombre visible (DAV:displayname). Si vacio, usa el slug.
description: descripcion (CardDAV addressbook-description). Opcional.
timeout_s: timeout de cada peticion en segundos. Default 20.0.
verify_tls: si True (default) verifica el certificado TLS.
Returns:
dict. En exito: {status:'ok', http_status:int, href:str} (y existed:True
si ya existia). En error (sin lanzar): {status:'error', http_status:
int|None, href:str, error:str}.
"""
clean = _sanitize_slug(slug)
href = (contacts_home.rstrip("/") + "/" + clean + "/") if clean else ""
if not clean:
return {
"status": "error",
"http_status": None,
"href": href,
"error": "slug invalido: queda vacio tras sanear a [a-z0-9_-]",
}
name = display_name if display_name else clean
url = _join_url(base_url, contacts_home, clean)
context = None if verify_tls else ssl._create_unverified_context()
headers = {
"Authorization": _basic_auth_header(username, password),
"Content-Type": "application/xml; charset=utf-8",
}
# MKCOL extendido (RFC 5689) — crea la coleccion + resourcetype addressbook +
# displayname + (opcional) descripcion, todo en un solo request.
mk_body = _build_mkcol_xml(name, description).encode("utf-8")
req = urllib.request.Request(url, data=mk_body, method="MKCOL", headers=headers)
existed = False
http_status = None
try:
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
http_status = resp.status
except urllib.error.HTTPError as e:
# 405/301: la coleccion ya existe en ese path -> idempotente.
if e.code in (301, 405):
existed = True
http_status = e.code
else:
return {
"status": "error",
"http_status": e.code,
"href": href,
"error": "http %s" % e.code,
}
except urllib.error.URLError as e:
return {
"status": "error",
"http_status": None,
"href": href,
"error": str(e.reason),
}
except Exception as e: # noqa: BLE001
return {"status": "error", "http_status": None, "href": href, "error": str(e)}
result = {"status": "ok", "http_status": http_status, "href": href}
if existed:
result["existed"] = True
return result
@@ -0,0 +1,78 @@
"""Tests para dav_make_addressbook.
La funcion publica es impura (hace HTTP), asi que no se prueba contra un servidor
real. Se ejercitan los helpers puros extraidos a nivel de modulo: la
sanitizacion del slug, la construccion de la URL de la coleccion y la generacion
del cuerpo XML del MKCOL extendido (resourcetype addressbook + displayname +
descripcion escapados). Sin red.
"""
from infra.dav_make_addressbook import (
_build_mkcol_xml,
_join_url,
_sanitize_slug,
)
def test_sanitize_slug_minusculas():
assert _sanitize_slug("Trabajo") == "trabajo"
def test_sanitize_slug_espacios_a_guion():
assert _sanitize_slug("agenda de trabajo") == "agenda-de-trabajo"
def test_sanitize_slug_elimina_caracteres_raros():
assert _sanitize_slug("Casa/Ocio!! 2026") == "casaocio-2026"
def test_sanitize_slug_colapsa_guiones_y_recorta():
assert _sanitize_slug(" --Foo Bar-- ") == "foo-bar"
def test_sanitize_slug_vacio():
assert _sanitize_slug(" !!! ") == ""
def test_join_url_compone_la_coleccion():
url = _join_url(
"https://dav-x.organic-machine.com",
"/enmanuel/contacts/",
"trabajo",
)
assert url == "https://dav-x.organic-machine.com/enmanuel/contacts/trabajo/"
def test_mkcol_xml_es_mkcol_extendido():
xml = _build_mkcol_xml("Trabajo")
assert "<D:mkcol" in xml
assert 'xmlns:C="urn:ietf:params:xml:ns:carddav"' in xml
def test_mkcol_xml_declara_resourcetype_addressbook():
xml = _build_mkcol_xml("Trabajo")
assert "<D:resourcetype><D:collection/><C:addressbook/></D:resourcetype>" in xml
def test_mkcol_xml_incluye_displayname():
xml = _build_mkcol_xml("Trabajo")
assert "<D:displayname>Trabajo</D:displayname>" in xml
def test_mkcol_xml_escapa_displayname():
xml = _build_mkcol_xml("Casa & <Ocio>")
assert "Casa &amp; &lt;Ocio&gt;" in xml
assert "<Ocio>" not in xml
def test_mkcol_xml_incluye_y_escapa_descripcion():
xml = _build_mkcol_xml("Trabajo", description="A & B <c>")
assert (
"<C:addressbook-description>A &amp; B &lt;c&gt;</C:addressbook-description>"
in xml
)
def test_mkcol_xml_omite_descripcion_vacia():
xml = _build_mkcol_xml("Trabajo")
assert "addressbook-description" not in xml
+106
View File
@@ -0,0 +1,106 @@
---
name: dav_make_calendar
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def dav_make_calendar(base_url: str, username: str, password: str, calendar_home: str, slug: str, display_name: str = \"\", color: str = \"\", description: str = \"\", *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
description: "Crea una nueva coleccion de calendario CalDAV (una agenda nueva) bajo el calendar-home de un principal via MKCALENDAR, fijando el displayname en el cuerpo, y opcionalmente fija color (Apple calendar-color) y descripcion (CalDAV calendar-description) con un PROPPATCH posterior. La coleccion se crea en <calendar_home><slug>/. El slug se sanea a [a-z0-9_-] (minusculas, espacios->guion); si queda vacio devuelve error de validacion. Idempotente: 201 Created es exito; 405/301 (ya existe) devuelve {status:'ok', existed:True}. Escapa display_name/description para XML. Construye Authorization: Basic base64(user:pass) a mano. Maneja errores sin lanzar (salvo validacion de args). Solo stdlib (urllib, base64, re, ssl, xml.sax.saxutils). Probado contra Xandikos."
tags: [dav, caldav, calendar, mkcalendar, proppatch, create, collection, color, http, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [base64, re, ssl, urllib.error, urllib.request, xml.sax.saxutils]
params:
- name: base_url
desc: "URL base del servidor DAV sin barra final (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: calendar_home
desc: "ruta del calendar-home del principal con barra final (p.ej. '/enmanuel/calendars/'). La nueva coleccion cuelga de el."
- name: slug
desc: "segmento de path de la coleccion en la URL (p.ej. 'trabajo'); se sanea a [a-z0-9_-]. La coleccion se crea en <calendar_home><slug>/. Si queda vacio tras sanear, devuelve error de validacion."
- name: display_name
desc: "nombre visible de la coleccion (DAV:displayname). Si vacio, usa el slug saneado."
- name: color
desc: "color de la coleccion como hex '#rrggbb' (propiedad calendar-color de Apple, http://apple.com/ns/ical/). Opcional; '' lo omite."
- name: description
desc: "descripcion de la coleccion (calendar-description de CalDAV). Opcional; '' lo omite."
- name: timeout_s
desc: "timeout de cada 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, href:str} y, si la coleccion ya existia, ademas existed:True. En error (sin lanzar): {status:'error', http_status:int|None, href:str, error:str}. href es la ruta de la coleccion (calendar_home + slug saneado + '/')."
tested: true
tests:
- "test_sanitize_slug_minusculas"
- "test_sanitize_slug_espacios_a_guion"
- "test_sanitize_slug_elimina_caracteres_raros"
- "test_sanitize_slug_colapsa_guiones_y_recorta"
- "test_sanitize_slug_vacio"
- "test_join_url_compone_la_coleccion"
- "test_mkcalendar_xml_incluye_displayname"
- "test_mkcalendar_xml_escapa_displayname"
- "test_proppatch_xml_color_y_descripcion"
- "test_proppatch_xml_solo_color"
- "test_proppatch_xml_escapa_descripcion"
test_file_path: "python/functions/infra/dav_make_calendar_test.py"
file_path: "python/functions/infra/dav_make_calendar.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from infra.dav_make_calendar import dav_make_calendar
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
res = dav_make_calendar(
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
calendar_home="/enmanuel/calendars/",
slug="trabajo",
display_name="Trabajo",
color="#e8590c",
)
print(res)
# {'status': 'ok', 'http_status': 201, 'href': '/enmanuel/calendars/trabajo/'}
# Volver a llamar con el mismo slug:
# {'status': 'ok', 'http_status': 405, 'href': '/enmanuel/calendars/trabajo/', 'existed': True}
```
## Cuando usarla
Cuando el usuario quiere anadir una agenda/calendario nuevo ademas del
principal: una coleccion CalDAV separada ("Trabajo", "Personal", "Cumpleanos")
con su propio nombre visible y color, bajo el calendar-home del principal. El
`href` devuelto es lo que luego pasas como `collection_path` a
`caldav_put_event` para crear eventos en esa agenda, o a `dav_list_calendars`
para verla en el selector.
## Gotchas
- Impura: requiere red + Basic auth contra el servidor DAV. El password viene de
`pass`, no se logea ni se hardcodea.
- Idempotente: si la coleccion ya existe en ese path el servidor responde 405
(Method Not Allowed) o 301; ambos se traducen a `{status:'ok', existed:True}`
en vez de error, asi que es seguro reintentar.
- El PROPPATCH de color usa el `calendar-color` de Apple
(`http://apple.com/ns/ical/`). Servidores que no lo soporten pueden ignorarlo:
el fallo del PROPPATCH NO es fatal (el calendario ya quedo creado) y se ignora
silenciosamente; el color simplemente no se aplica. Si necesitas confirmar el
color, leelo despues con `dav_list_calendars`.
- El `slug` se sanea a `[a-z0-9_-]` (minusculas, espacios->guion, resto fuera).
Un slug que queda vacio tras sanear (p.ej. solo simbolos) devuelve error de
validacion sin tocar la red. El `display_name` y la `description` se escapan
para XML, pero el `slug` que va en la URL ya esta restringido al charset
seguro.
+202
View File
@@ -0,0 +1,202 @@
"""Crea una nueva coleccion de calendario CalDAV bajo un calendar-home.
Funcion impura: hace una peticion HTTP MKCALENDAR (metodo HTTP literal) para
crear una "agenda" nueva bajo el calendar-home de un principal, y opcionalmente
un PROPPATCH posterior para fijarle el color (Apple `calendar-color`) y la
descripcion (`{urn:ietf:params:xml:ns:caldav}calendar-description`). El nombre
visible (DAV:displayname) se setea ya en el cuerpo del MKCALENDAR.
El slug (segmento de path de la coleccion) se sanea a `[a-z0-9_-]` (minusculas,
espacios -> '-'); si queda vacio se devuelve un error de validacion. La
coleccion se crea en `<calendar_home><slug>/`.
Idempotente: un 201 (Created) es exito; un 405 (Method Not Allowed) o un 301
(la coleccion ya existe en ese path) se devuelven como
{status:'ok', existed:True}. El display_name y la description se escapan para
XML. Construye `Authorization: Basic base64(user:pass)` a mano con stdlib.
Maneja errores sin lanzar (salvo validacion de args). Solo usa stdlib
(urllib, base64, re, ssl, xml.sax.saxutils). Probado contra Xandikos.
"""
import base64
import re
import ssl
import urllib.error
import urllib.request
from xml.sax.saxutils import escape as _xml_escape
_UNSAFE_SLUG_RE = re.compile(r"[^a-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 _sanitize_slug(slug: str) -> str:
"""Sanea un slug a `[a-z0-9_-]`.
Pasa a minusculas, convierte espacios (y runs de espacios) en un guion, y
elimina cualquier otro caracter no permitido. Colapsa guiones repetidos y
recorta guiones de los extremos. Puede devolver "" si no queda nada usable;
el caller trata "" como error de validacion.
"""
s = slug.strip().lower()
s = re.sub(r"\s+", "-", s)
s = _UNSAFE_SLUG_RE.sub("", s)
s = re.sub(r"-{2,}", "-", s).strip("-")
return s
def _build_mkcalendar_xml(display_name: str) -> str:
"""Cuerpo XML minimo del MKCALENDAR que setea el displayname (escapado)."""
name = _xml_escape(display_name)
return (
'<?xml version="1.0" encoding="utf-8" ?>'
'<C:mkcalendar xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">'
"<D:set><D:prop>"
"<D:displayname>%s</D:displayname>"
"</D:prop></D:set>"
"</C:mkcalendar>"
) % name
def _build_proppatch_xml(color: str = "", description: str = "") -> str:
"""Cuerpo XML del PROPPATCH que fija color (Apple) y/o descripcion (CalDAV).
Solo incluye las props no vacias. El color va como `calendar-color` del
namespace `http://apple.com/ns/ical/` con el hex tal cual lo pasa el caller
(p.ej. '#RRGGBB'). La descripcion es `calendar-description` de CalDAV. Ambos
valores se escapan para XML.
"""
props = []
if color:
props.append("<A:calendar-color>%s</A:calendar-color>" % _xml_escape(color))
if description:
props.append(
"<C:calendar-description>%s</C:calendar-description>"
% _xml_escape(description)
)
return (
'<?xml version="1.0" encoding="utf-8" ?>'
'<D:propertyupdate xmlns:D="DAV:" '
'xmlns:A="http://apple.com/ns/ical/" '
'xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<D:set><D:prop>%s</D:prop></D:set>"
"</D:propertyupdate>"
) % "".join(props)
def _join_url(base_url: str, calendar_home: str, slug: str) -> str:
return base_url.rstrip("/") + "/" + calendar_home.strip("/") + "/" + slug + "/"
def dav_make_calendar(
base_url: str,
username: str,
password: str,
calendar_home: str,
slug: str,
display_name: str = "",
color: str = "",
description: str = "",
*,
timeout_s: float = 20.0,
verify_tls: bool = True,
) -> dict:
"""Crea una nueva coleccion de calendario CalDAV (MKCALENDAR + PROPPATCH).
Crea la coleccion en `<calendar_home><slug>/` via MKCALENDAR, fijando el
displayname en el propio cuerpo. Si se pasa `color` y/o `description`, hace
un PROPPATCH posterior para setearlos. Idempotente: si la coleccion ya
existe (405/301) devuelve {status:'ok', existed:True}.
Args:
base_url: URL base del servidor DAV (sin barra final), p.ej.
'https://dav-x.organic-machine.com'.
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth. Resolver desde pass.
calendar_home: ruta del calendar-home del principal (con barra final),
p.ej. '/enmanuel/calendars/'. La coleccion cuelga de el.
slug: segmento de path de la coleccion (p.ej. 'trabajo'); se sanea a
[a-z0-9_-]. Si queda vacio tras sanear, error de validacion.
display_name: nombre visible (DAV:displayname). Si vacio, usa el slug.
color: color de la coleccion como hex '#rrggbb' (Apple calendar-color).
Opcional.
description: descripcion (CalDAV calendar-description). Opcional.
timeout_s: timeout de cada peticion en segundos. Default 20.0.
verify_tls: si True (default) verifica el certificado TLS.
Returns:
dict. En exito: {status:'ok', http_status:int, href:str} (y existed:True
si ya existia). En error (sin lanzar): {status:'error', http_status:
int|None, href:str, error:str}.
"""
clean = _sanitize_slug(slug)
href = (calendar_home.rstrip("/") + "/" + clean + "/") if clean else ""
if not clean:
return {
"status": "error",
"http_status": None,
"href": href,
"error": "slug invalido: queda vacio tras sanear a [a-z0-9_-]",
}
name = display_name if display_name else clean
url = _join_url(base_url, calendar_home, clean)
context = None if verify_tls else ssl._create_unverified_context()
headers = {
"Authorization": _basic_auth_header(username, password),
"Content-Type": "application/xml; charset=utf-8",
}
# 1) MKCALENDAR — crea la coleccion + displayname.
mk_body = _build_mkcalendar_xml(name).encode("utf-8")
req = urllib.request.Request(
url, data=mk_body, method="MKCALENDAR", headers=headers
)
existed = False
http_status = None
try:
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
http_status = resp.status
except urllib.error.HTTPError as e:
# 405/301: la coleccion ya existe en ese path -> idempotente.
if e.code in (301, 405):
existed = True
http_status = e.code
else:
return {
"status": "error",
"http_status": e.code,
"href": href,
"error": "http %s" % e.code,
}
except urllib.error.URLError as e:
return {
"status": "error",
"http_status": None,
"href": href,
"error": str(e.reason),
}
except Exception as e: # noqa: BLE001
return {"status": "error", "http_status": None, "href": href, "error": str(e)}
# 2) PROPPATCH — color y/o descripcion. Fallo aqui no es fatal: la coleccion
# ya existe. Servidores que no soporten calendar-color pueden ignorarlo.
if color or description:
pp_body = _build_proppatch_xml(color, description).encode("utf-8")
pp_req = urllib.request.Request(
url, data=pp_body, method="PROPPATCH", headers=headers
)
try:
urllib.request.urlopen(pp_req, timeout=timeout_s, context=context).close()
except Exception: # noqa: BLE001
# No fatal: el calendario quedo creado; el color/desc puede no
# soportarse en este servidor. Se ignora silenciosamente.
pass
result = {"status": "ok", "http_status": http_status, "href": href}
if existed:
result["existed"] = True
return result
@@ -0,0 +1,73 @@
"""Tests para dav_make_calendar.
La funcion publica es impura (hace HTTP), asi que no se prueba contra un servidor
real. Se ejercitan los helpers puros extraidos a nivel de modulo: la
sanitizacion del slug, la construccion de la URL de la coleccion y la generacion
de los cuerpos XML del MKCALENDAR y del PROPPATCH (displayname/color/descripcion
escapados). Sin red.
"""
from infra.dav_make_calendar import (
_build_mkcalendar_xml,
_build_proppatch_xml,
_join_url,
_sanitize_slug,
)
def test_sanitize_slug_minusculas():
assert _sanitize_slug("Trabajo") == "trabajo"
def test_sanitize_slug_espacios_a_guion():
assert _sanitize_slug("agenda de trabajo") == "agenda-de-trabajo"
def test_sanitize_slug_elimina_caracteres_raros():
assert _sanitize_slug("Casa/Ocio!! 2026") == "casaocio-2026"
def test_sanitize_slug_colapsa_guiones_y_recorta():
assert _sanitize_slug(" --Foo Bar-- ") == "foo-bar"
def test_sanitize_slug_vacio():
assert _sanitize_slug(" !!! ") == ""
def test_join_url_compone_la_coleccion():
url = _join_url(
"https://dav-x.organic-machine.com",
"/enmanuel/calendars/",
"trabajo",
)
assert url == "https://dav-x.organic-machine.com/enmanuel/calendars/trabajo/"
def test_mkcalendar_xml_incluye_displayname():
xml = _build_mkcalendar_xml("Trabajo")
assert "<C:mkcalendar" in xml
assert "<D:displayname>Trabajo</D:displayname>" in xml
def test_mkcalendar_xml_escapa_displayname():
xml = _build_mkcalendar_xml("Casa & <Ocio>")
assert "Casa &amp; &lt;Ocio&gt;" in xml
assert "<Ocio>" not in xml
def test_proppatch_xml_color_y_descripcion():
xml = _build_proppatch_xml(color="#e8590c", description="Mi agenda")
assert "<A:calendar-color>#e8590c</A:calendar-color>" in xml
assert "<C:calendar-description>Mi agenda</C:calendar-description>" in xml
def test_proppatch_xml_solo_color():
xml = _build_proppatch_xml(color="#e8590c")
assert "<A:calendar-color>#e8590c</A:calendar-color>" in xml
assert "calendar-description" not in xml
def test_proppatch_xml_escapa_descripcion():
xml = _build_proppatch_xml(description="A & B <c>")
assert "A &amp; B &lt;c&gt;" in xml
+94
View File
@@ -0,0 +1,94 @@
---
name: duckdb_execute
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def duckdb_execute(db_path: str, sql: str, params: list = None) -> dict"
description: "Ejecuta UNA sentencia de escritura (INSERT/UPDATE/DELETE/DDL) contra una base DuckDB abierta en conexion read-write (duckdb.connect(db_path)), hace commit y cierra siempre en try/finally. En modo escritura DuckDB crea el archivo si no existe. Es el primitivo de escritura del grupo duckdb; complementa a duckdb_query_readonly_py_infra (solo lectura). Usa parametros posicionales con el marcador '?'. Devuelve un dict sin lanzar (estilo del grupo): {status:'ok', rowcount} en exito y {status:'error', error} en fallo. rowcount es el numero de filas afectadas; DuckDB no expone un rowcount fiable (siempre -1) pero tras INSERT/UPDATE/DELETE el fetchall() del cursor devuelve [(n,)] de donde se extrae; para DDL u operaciones sin filas queda en -1 sin fallar. Depende del paquete duckdb (1.5.2 en python/.venv)."
tags: [duckdb, sql, execute, write, ddl, dml]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: [duckdb]
params:
- name: db_path
desc: "ruta al archivo DuckDB. En modo escritura DuckDB crea el archivo si no existe. Un directorio padre inexistente o un lock de otro proceso devuelve {status:'error'}."
- name: sql
desc: "sentencia SQL de escritura (INSERT/UPDATE/DELETE/DDL). Usa el marcador '?' para parametros posicionales."
- name: params
desc: "lista de parametros posicionales para el SQL en orden. None (default) significa sin parametros. Pasar valores aqui en vez de interpolarlos en el SQL evita inyeccion."
output: "dict. En exito: {status:'ok', rowcount:int} donde rowcount es el numero de filas afectadas (o -1 cuando la sentencia no reporta filas, p.ej. DDL). En error (sin lanzar): {status:'error', error:str}."
tested: true
tests:
- "test_insert_devuelve_status_ok_y_persiste"
- "test_update_afecta_filas_y_persiste"
- "test_delete_afecta_filas_y_persiste"
- "test_ddl_create_table_status_ok"
- "test_crea_la_base_si_no_existe"
- "test_sql_invalido_devuelve_status_error"
test_file_path: "python/functions/infra/duckdb_execute_test.py"
file_path: "python/functions/infra/duckdb_execute.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.duckdb_execute import duckdb_execute
db = "/tmp/eventos.duckdb"
# DDL: crear la tabla (la base se crea sola si no existia).
print(duckdb_execute(db, "CREATE TABLE eventos (id INTEGER, tipo VARCHAR)"))
# {'status': 'ok', 'rowcount': -1} (DDL no reporta filas)
# DML: insertar con parametros posicionales.
res = duckdb_execute(
db,
"INSERT INTO eventos VALUES (?, ?), (?, ?)",
params=[1, "login", 2, "logout"],
)
print(res)
# {'status': 'ok', 'rowcount': 2}
# UPDATE.
print(duckdb_execute(db, "UPDATE eventos SET tipo = ? WHERE id = ?", params=["signin", 1]))
# {'status': 'ok', 'rowcount': 1}
```
## Cuando usarla
Cuando un service single-writer necesita escribir DDL/DML en su DuckDB: crear o
migrar tablas, insertar registros nuevos, actualizar estado o borrar filas en un
archivo DuckDB que ese proceso posee. Es la mitad de escritura del grupo `duckdb`:
usa `duckdb_query_readonly_py_infra` para leer (sin riesgo de modificar la base) y
`duckdb_execute_py_infra` para escribir con commit. El dict de salida con
`rowcount` es directamente serializable a JSON para pasarlo al siguiente paso de
una composicion.
## Gotchas
- Escritura real de un archivo en disco (impura). Abre en modo read-write y hace
commit; cualquier fallo se devuelve como `{status:'error', ...}`, nunca se lanza.
- DuckDB es single-writer: solo un proceso puede tener la base abierta en
escritura a la vez. Si otro proceso ya la tiene abierta en write, `connect`
falla con un error de lock (`Could not set lock on file ...`) que se devuelve
como `{status:'error', ...}`. Diseña el acceso para que un unico proceso sea el
escritor; los lectores deben usar `duckdb_query_readonly` (read_only=True).
- `rowcount` no es fiable en todos los casos. DuckDB no expone un `cursor.rowcount`
util (siempre devuelve -1); esta funcion lee el conteo del `fetchall()` que
DuckDB emite tras INSERT/UPDATE/DELETE (`[(n,)]`). Para DDL (`CREATE`/`DROP`/
`ALTER`) y operaciones que no reportan filas, `rowcount` queda en `-1` a
proposito: NO trates `-1` como error.
- Ejecuta UNA sentencia por llamada (`con.execute(sql, params)`). No es para
scripts multi-statement separados por `;`; para eso encadena varias llamadas o
usa una funcion/pipeline dedicada.
- Los parametros van en `params` con el marcador `?`, nunca interpolados en el
string del SQL (previene inyeccion).
- A diferencia del modo read-only, este modo **crea** el archivo si no existe. Un
`db_path` con un directorio padre inexistente si falla y se reporta como error.
+82
View File
@@ -0,0 +1,82 @@
"""Ejecuta una sentencia de escritura (INSERT/UPDATE/DELETE/DDL) contra DuckDB.
Funcion impura: abre un archivo DuckDB con `duckdb.connect(db_path)` en modo
read-write (crea el archivo si no existe, cosa que el modo escritura de DuckDB
permite). Ejecuta UNA sentencia con parametros posicionales (DuckDB usa el
marcador `?`), hace commit y cierra la conexion siempre en un bloque try/finally.
Es el primitivo de escritura del grupo `duckdb` del registry; complementa a
`duckdb_query_readonly_py_infra`, que es solo lectura.
Devuelve un dict sin lanzar excepciones, siguiendo el estilo del grupo
(`{status:'ok', ...}` en exito, `{status:'error', error:str}` en fallo). En exito
incluye `rowcount`: el numero de filas afectadas por la sentencia. DuckDB no expone
un `rowcount` fiable en su cursor (siempre devuelve -1), pero tras un
INSERT/UPDATE/DELETE el `fetchall()` del cursor devuelve `[(n,)]` con el conteo;
de ahi se extrae. Para DDL u operaciones que no reportan filas, `rowcount` queda
en -1 y eso NUNCA hace fallar la funcion.
"""
def _affected_rowcount(cursor) -> int:
"""Extrae el numero de filas afectadas de un cursor DuckDB de escritura.
Estrategia robusta para DuckDB:
1. Si `cursor.rowcount` esta disponible y es >= 0, usarlo.
2. Si no, intentar `cursor.fetchall()`: tras INSERT/UPDATE/DELETE DuckDB
devuelve `[(n,)]` con el conteo. Se extrae el primer entero.
3. Si nada aplica (DDL, sin filas), devolver -1.
Nunca lanza: cualquier problema al leer el conteo cae a -1.
"""
try:
rc = getattr(cursor, "rowcount", -1)
if isinstance(rc, int) and rc >= 0:
return rc
except Exception: # noqa: BLE001
pass
try:
fetched = cursor.fetchall()
except Exception: # noqa: BLE001
return -1
if fetched and fetched[0]:
candidate = fetched[0][0]
if isinstance(candidate, int):
return candidate
return -1
def duckdb_execute(db_path: str, sql: str, params: list = None) -> dict:
"""Ejecuta una sentencia de escritura DuckDB en conexion read-write.
Args:
db_path: ruta al archivo DuckDB. En modo escritura DuckDB crea el archivo
si no existe. Un directorio inexistente o un lock de otro proceso
devuelve {status:'error', ...}.
sql: sentencia SQL de escritura (INSERT/UPDATE/DELETE/DDL). Usa el
marcador `?` para parametros posicionales.
params: lista de parametros posicionales para el SQL en orden. None
(default) significa sin parametros.
Returns:
dict. En exito: {status:'ok', rowcount:int} donde rowcount es el numero
de filas afectadas (o -1 cuando la sentencia no reporta filas, p.ej. DDL).
En error (sin lanzar): {status:'error', error:str}.
"""
conn = None
try:
conn = __import__("duckdb").connect(db_path)
cursor = conn.execute(sql, params if params is not None else [])
rowcount = _affected_rowcount(cursor)
# DuckDB autocommitea por defecto, pero llamar a commit es seguro e
# idempotente: garantiza la durabilidad de la escritura.
conn.commit()
return {"status": "ok", "rowcount": rowcount}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
finally:
if conn is not None:
conn.close()
@@ -0,0 +1,85 @@
"""Tests para duckdb_execute."""
import duckdb
import pytest
from .duckdb_execute import duckdb_execute
@pytest.fixture
def db(tmp_path):
"""Crea una base DuckDB temporal con una tabla vacia y devuelve su path."""
path = str(tmp_path / "test.duckdb")
con = duckdb.connect(path)
con.execute("CREATE TABLE t (id INTEGER, name VARCHAR)")
con.close()
return path
def _read_rows(path: str) -> list:
"""Relee la tabla t en una conexion read_only y devuelve las filas."""
con = duckdb.connect(path, read_only=True)
try:
return con.execute("SELECT id, name FROM t ORDER BY id").fetchall()
finally:
con.close()
def test_insert_devuelve_status_ok_y_persiste(db):
res = duckdb_execute(
db,
"INSERT INTO t VALUES (?, ?), (?, ?), (?, ?)",
params=[1, "a", 2, "b", 3, "c"],
)
assert res["status"] == "ok"
assert res["rowcount"] == 3
# Releemos para confirmar el efecto en disco.
assert _read_rows(db) == [(1, "a"), (2, "b"), (3, "c")]
def test_update_afecta_filas_y_persiste(db):
duckdb_execute(db, "INSERT INTO t VALUES (1,'a'),(2,'b'),(3,'c')")
res = duckdb_execute(db, "UPDATE t SET name = ? WHERE id <= ?", params=["x", 2])
assert res["status"] == "ok"
assert res["rowcount"] == 2
assert _read_rows(db) == [(1, "x"), (2, "x"), (3, "c")]
def test_delete_afecta_filas_y_persiste(db):
duckdb_execute(db, "INSERT INTO t VALUES (1,'a'),(2,'b'),(3,'c')")
res = duckdb_execute(db, "DELETE FROM t WHERE id = ?", params=[3])
assert res["status"] == "ok"
assert res["rowcount"] == 1
assert _read_rows(db) == [(1, "a"), (2, "b")]
def test_ddl_create_table_status_ok(db):
res = duckdb_execute(db, "CREATE TABLE u (x INTEGER)")
assert res["status"] == "ok"
# DDL no reporta filas: rowcount queda en -1, no falla.
assert res["rowcount"] == -1
# Confirmamos que la tabla existe insertando en ella.
res2 = duckdb_execute(db, "INSERT INTO u VALUES (42)")
assert res2["status"] == "ok"
con = duckdb.connect(db, read_only=True)
try:
assert con.execute("SELECT x FROM u").fetchall() == [(42,)]
finally:
con.close()
def test_crea_la_base_si_no_existe(tmp_path):
path = str(tmp_path / "nueva.duckdb")
res = duckdb_execute(path, "CREATE TABLE nueva (a INTEGER)")
assert res["status"] == "ok"
res2 = duckdb_execute(path, "INSERT INTO nueva VALUES (7)")
assert res2["status"] == "ok"
assert res2["rowcount"] == 1
def test_sql_invalido_devuelve_status_error(db):
res = duckdb_execute(db, "INSERT INTO tabla_que_no_existe VALUES (1)")
assert res["status"] == "error"
assert "error" in res
assert isinstance(res["error"], str) and res["error"]
# La funcion no lanza: el flujo del test llega hasta aqui sin excepcion.
@@ -0,0 +1,111 @@
"""Ejecuta una query SELECT contra una base DuckDB abierta en modo solo lectura.
Funcion impura: abre un archivo DuckDB con `duckdb.connect(db_path, read_only=True)`,
de modo que nunca crea ni modifica la base. La conexion se cierra siempre en un
bloque try/finally. Ejecuta el SQL con parametros posicionales (DuckDB usa el
marcador `?`) y devuelve un dict sin lanzar excepciones, siguiendo el estilo del
grupo dav del registry: {status:'ok', ...} en exito y {status:'error', error:str}
en fallo.
Las filas se devuelven como lista de dicts (un dict por fila, mapeando el nombre
de columna a su valor). El resultado se trunca a max_rows para proteger la memoria
y marca truncated=True si la query producia mas filas. Los valores que no son
JSON-serializables se convierten a una forma serializable: date y datetime a
isoformat(), Decimal a float, bytes a una cadena base64 y UUID a str.
"""
import base64
import datetime
import decimal
import uuid
def _to_serializable(value):
"""Convierte un valor de DuckDB a una forma JSON-serializable.
date/datetime/time -> isoformat(), Decimal -> float, bytes -> base64 str,
UUID -> str. El resto de valores (int, float, str, bool, None) se devuelven
sin cambios.
"""
if value is None:
return None
if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
return value.isoformat()
if isinstance(value, decimal.Decimal):
return float(value)
if isinstance(value, (bytes, bytearray, memoryview)):
return base64.b64encode(bytes(value)).decode("ascii")
if isinstance(value, uuid.UUID):
return str(value)
return value
def duckdb_query_readonly(
db_path: str,
sql: str,
params: list = None,
max_rows: int = 10000,
sandbox: bool = True,
) -> dict:
"""Ejecuta un SELECT contra una base DuckDB en modo solo lectura.
Args:
db_path: ruta al archivo DuckDB. Debe existir: el modo read_only NO crea
la base. Un path inexistente devuelve {status:'error', ...}.
sql: sentencia SQL a ejecutar. Pensada para SELECT; usa el marcador `?`
para parametros posicionales.
params: lista de parametros posicionales para el SQL. None (default)
significa sin parametros.
max_rows: numero maximo de filas a materializar (default 10000). Si la
query produce mas, se trunca y truncated queda en True.
sandbox: si True (default), abre la conexion con
``enable_external_access=False``, lo que prohibe a la query acceder al
sistema de ficheros y a la red (``read_csv``/``read_blob``/``glob``/
``COPY ... TO``/``httpfs``/``ATTACH`` a paths externos). CRITICO cuando
el SQL viene de un cliente no confiable: ``read_only=True`` solo protege
la base de datos, NO el sistema de ficheros, asi que sin el sandbox un
SELECT malicioso puede leer ficheros arbitrarios (p.ej. claves SSH) o
escribirlos. Ponlo en False solo para usos internos confiables que
necesiten leer CSV/Parquet del disco.
Returns:
dict. En exito: {status:'ok', columns:[...], rows:[{col:val, ...}, ...],
row_count:int, truncated:bool} donde columns es la lista de nombres de
columna y rows es la lista de filas (cada fila un dict). En error
(sin lanzar): {status:'error', error:str}.
"""
conn = None
try:
config = {"enable_external_access": False} if sandbox else {}
conn = __import__("duckdb").connect(
db_path, read_only=True, config=config
)
cursor = conn.execute(sql, params if params is not None else [])
description = cursor.description or []
columns = [col[0] for col in description]
# Pedimos una fila de mas que max_rows para detectar truncado sin
# materializar todo el resultado en memoria.
fetched = cursor.fetchmany(max_rows + 1)
truncated = len(fetched) > max_rows
if truncated:
fetched = fetched[:max_rows]
rows = [
{columns[i]: _to_serializable(value) for i, value in enumerate(record)}
for record in fetched
]
return {
"status": "ok",
"columns": columns,
"rows": rows,
"row_count": len(rows),
"truncated": truncated,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
finally:
if conn is not None:
conn.close()
@@ -0,0 +1,119 @@
"""Tests para duckdb_query_readonly."""
import datetime
import decimal
import duckdb
import pytest
from .duckdb_query_readonly import duckdb_query_readonly
@pytest.fixture
def db(tmp_path):
"""Crea una base DuckDB temporal con datos de ejemplo y devuelve su path."""
path = str(tmp_path / "test.duckdb")
con = duckdb.connect(path)
con.execute(
"CREATE TABLE ventas ("
" id INTEGER,"
" region VARCHAR,"
" total DECIMAL(10,2),"
" fecha DATE"
")"
)
con.execute(
"INSERT INTO ventas VALUES "
"(1, 'norte', 120.50, DATE '2026-01-01'), "
"(2, 'sur', 80.00, DATE '2026-01-02'), "
"(3, 'norte', 45.25, DATE '2026-01-03')"
)
con.close()
return path
def test_query_ok_devuelve_filas_como_dicts(db):
res = duckdb_query_readonly(db, "SELECT id, region FROM ventas ORDER BY id")
assert res["status"] == "ok"
assert res["columns"] == ["id", "region"]
assert res["row_count"] == 3
assert res["truncated"] is False
assert res["rows"][0] == {"id": 1, "region": "norte"}
assert res["rows"][1] == {"id": 2, "region": "sur"}
def test_query_con_params_posicionales(db):
res = duckdb_query_readonly(
db,
"SELECT id FROM ventas WHERE region = ? ORDER BY id",
params=["norte"],
)
assert res["status"] == "ok"
assert res["row_count"] == 2
assert [row["id"] for row in res["rows"]] == [1, 3]
def test_sql_invalido_devuelve_status_error(db):
res = duckdb_query_readonly(db, "SELECT * FROM tabla_que_no_existe")
assert res["status"] == "error"
assert "error" in res
assert isinstance(res["error"], str) and res["error"]
def test_db_inexistente_devuelve_status_error(tmp_path):
missing = str(tmp_path / "no_existe.duckdb")
res = duckdb_query_readonly(missing, "SELECT 1")
assert res["status"] == "error"
assert "error" in res
def test_truncado_a_max_rows(db):
res = duckdb_query_readonly(db, "SELECT id FROM ventas ORDER BY id", max_rows=2)
assert res["status"] == "ok"
assert res["row_count"] == 2
assert res["truncated"] is True
assert [row["id"] for row in res["rows"]] == [1, 2]
def test_valores_no_serializables_se_convierten(db):
res = duckdb_query_readonly(
db,
"SELECT total, fecha FROM ventas WHERE id = ?",
params=[1],
)
assert res["status"] == "ok"
row = res["rows"][0]
# Decimal -> float
assert isinstance(row["total"], float)
assert row["total"] == pytest.approx(120.50)
# date -> isoformat str
assert isinstance(row["fecha"], str)
assert row["fecha"] == "2026-01-01"
# Verificamos que NO quedan tipos crudos no serializables.
assert not isinstance(row["total"], decimal.Decimal)
assert not isinstance(row["fecha"], datetime.date)
def test_sandbox_por_defecto_bloquea_acceso_a_ficheros(db, tmp_path):
"""Por defecto (sandbox=True) la query no puede tocar el sistema de ficheros."""
csv = tmp_path / "externo.csv"
csv.write_text("a\n1\n")
res = duckdb_query_readonly(db, f"SELECT * FROM read_csv_auto('{csv}')")
assert res["status"] == "error"
assert (
"access" in res["error"].lower() or "permission" in res["error"].lower()
)
# El SELECT normal sobre la propia base sigue funcionando con el sandbox.
ok = duckdb_query_readonly(db, "SELECT count(*) AS n FROM ventas")
assert ok["status"] == "ok" and ok["rows"][0]["n"] == 3
def test_sandbox_off_permite_leer_csv_del_disco(db, tmp_path):
"""Con sandbox=False (uso interno confiable) sí puede leer ficheros."""
csv = tmp_path / "externo.csv"
csv.write_text("a\n1\n2\n")
res = duckdb_query_readonly(
db, f"SELECT count(*) AS n FROM read_csv_auto('{csv}')", sandbox=False
)
assert res["status"] == "ok"
assert res["rows"][0]["n"] == 2
+116
View File
@@ -0,0 +1,116 @@
---
name: duckdb_upsert
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def duckdb_upsert(db_path: str, table: str, rows: list[dict], key_cols: list[str], update_cols: list[str] | None = None) -> dict"
description: "UPSERT idempotente de filas en una tabla DuckDB con ownership selectivo de columnas. Construye INSERT INTO <table> (cols) VALUES (?,...) ON CONFLICT (key_cols) DO UPDATE SET col=excluded.col, ... (o DO NOTHING) y lo ejecuta fila por fila para contar inserts vs updates. La clave del patron es update_cols: en un conflicto solo se actualizan esas columnas, de modo que las columnas excluidas conservan su valor previo (la DB es duena de ellas y un re-ingest no las pisa). update_cols=None actualiza todas menos key_cols; update_cols=[] hace DO NOTHING. Abre duckdb.connect(db_path) en lectura-escritura, commit y close en try/finally. Valida que tabla y columnas casen [A-Za-z_][A-Za-z0-9_]* antes de interpolarlas; los valores van por placeholders '?'. Devuelve dict sin lanzar: {status:'ok', inserted, updated} o {status:'error', error}. key_cols deben tener PRIMARY KEY o UNIQUE en la tabla. Depende del paquete duckdb (1.5.2 en python/.venv)."
tags: [duckdb, sql, upsert, idempotent, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: [re, duckdb]
params:
- name: db_path
desc: "ruta al archivo DuckDB. Se abre en lectura-escritura (duckdb.connect), por lo que se crea si no existe; pero la tabla destino debe existir y tener PRIMARY KEY o UNIQUE en key_cols para que ON CONFLICT funcione."
- name: table
desc: "nombre de la tabla destino. Validado como identificador SQL [A-Za-z_][A-Za-z0-9_]*; un nombre raro devuelve {status:'error'} (no se interpola sin validar)."
- name: rows
desc: "lista de dicts, un dict por fila (clave=nombre de columna). El esquema de insercion lo fija el conjunto de claves de la PRIMERA fila; todas las filas deben tener exactamente las mismas claves o se devuelve error. Lista vacia -> {status:'ok', inserted:0, updated:0}."
- name: key_cols
desc: "columnas de la clave de conflicto. Deben existir como PRIMARY KEY o UNIQUE en la tabla y estar presentes en las claves de cada fila. No puede estar vacia."
- name: update_cols
desc: "columnas a actualizar en caso de conflicto. None (default) = todas las columnas de la fila menos key_cols. Lista vacia [] = DO NOTHING (inserta nuevas, no toca existentes). Lista con columnas = DO UPDATE SET solo esas; las no listadas conservan su valor previo (ownership selectivo)."
output: "dict. En exito: {status:'ok', inserted:int, updated:int} donde inserted cuenta las claves que no existian y updated las que ya existian (con update_cols=[] / DO NOTHING, updated cuenta los conflictos vistos pero la fila no cambia). En error (sin lanzar): {status:'error', error:str}."
tested: true
tests:
- "test_upsert_fila_nueva_inserta"
- "test_update_cols_selectivo_no_pisa_columnas_excluidas"
- "test_update_cols_vacio_do_nothing_no_cambia_existente"
- "test_varias_filas_a_la_vez_mezcla_insert_y_update"
- "test_rows_vacio_devuelve_cero"
- "test_columnas_inconsistentes_devuelve_error"
- "test_identificador_invalido_devuelve_error"
test_file_path: "python/functions/infra/duckdb_upsert_test.py"
file_path: "python/functions/infra/duckdb_upsert.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
import duckdb
from infra.duckdb_upsert import duckdb_upsert
db = "/tmp/leads.duckdb"
con = duckdb.connect(db)
con.execute("CREATE TABLE leads (email VARCHAR PRIMARY KEY, name VARCHAR, score INTEGER)")
con.close()
# Re-ingest 1: inserta el lead.
print(duckdb_upsert(
db, "leads",
[{"email": "ana@x.com", "name": "Ana", "score": 0}],
key_cols=["email"],
))
# {'status': 'ok', 'inserted': 1, 'updated': 0}
# Mientras tanto, un proceso de scoring escribio score=87 en la DB (fuente de verdad).
con = duckdb.connect(db)
con.execute("UPDATE leads SET score = 87 WHERE email = 'ana@x.com'")
con.close()
# Re-ingest 2: el feed trae name actualizado y score=0 (valor por defecto del feed),
# pero solo autorizamos actualizar 'name'. 'score' lo posee la DB y NO se pisa.
print(duckdb_upsert(
db, "leads",
[{"email": "ana@x.com", "name": "Ana Lopez", "score": 0}],
key_cols=["email"],
update_cols=["name"],
))
# {'status': 'ok', 'inserted': 0, 'updated': 1}
con = duckdb.connect(db, read_only=True)
print(con.execute("SELECT name, score FROM leads WHERE email = 'ana@x.com'").fetchone())
# ('Ana Lopez', 87) <- name actualizado, score conservado
con.close()
```
## Cuando usarla
Cuando la DB es la fuente de verdad y un re-ingest no debe pisar campos que ya
posee la DB: pasa `update_cols` SIN esos campos. Tipico en pipelines de ingesta
idempotente donde una fila se reinserta periodicamente (catalogo, leads, entidades
OSINT, snapshots) pero ciertas columnas se enriquecieron despues (score calculado,
anotacion manual, flag derivado) y deben sobrevivir al refresco. Usa
`update_cols=None` para un upsert "todo" clasico, `update_cols=[]` para insertar
solo filas nuevas sin tocar las existentes, y una lista explicita para ownership
selectivo. Util como paso de escritura en una composicion: el dict de salida es
serializable y reporta cuantas filas se insertaron vs actualizaron.
## Gotchas
- Escritura real en disco (impura). `ON CONFLICT (key_cols)` solo funciona si esas
columnas tienen **PRIMARY KEY o UNIQUE** en la tabla; sin esa restriccion DuckDB
no detecta el conflicto y devolveria `{status:'error', ...}` o duplicaria. La
tabla debe existir de antemano (la funcion no la crea).
- **Single-writer**: la cuenta inserted/updated consulta la existencia de cada
clave en la misma conexion/transaccion justo antes de insertarla. Si otro
proceso escribe concurrentemente la misma base, las cuentas pueden desviarse y
DuckDB puede rechazar abrir el archivo por lock. Diseñada para un unico escritor.
- **Identificadores validados**: `table` y los nombres de columna deben casar
`[A-Za-z_][A-Za-z0-9_]*` (DuckDB no permite parametrizar identificadores, asi que
se interpolan tras validar). Un nombre con espacios, comillas, puntos o vacio
devuelve `{status:'error'}`. Los valores de las filas siempre van por `?`.
- **Esquema fijo por la primera fila**: el conjunto de columnas de insercion lo
determina `rows[0]`. Todas las filas deben tener exactamente las mismas claves; si
una fila difiere, se devuelve error (no se hace insercion parcial).
- `update_cols=[]` genera `DO NOTHING`: la fila existente queda intacta, pero el
contador `updated` sigue reflejando los conflictos vistos (no son inserts nuevos).
- Nunca lanza: todo fallo (path bloqueado, tabla inexistente, tipo invalido) vuelve
como `{status:'error', error:str}`.
+155
View File
@@ -0,0 +1,155 @@
"""UPSERT idempotente de filas en una tabla DuckDB con ownership selectivo de columnas.
Funcion impura: abre un archivo DuckDB con `duckdb.connect(db_path)` en modo
lectura-escritura, ejecuta un `INSERT ... ON CONFLICT (key_cols) DO UPDATE SET ...`
fila por fila, hace commit y cierra la conexion en un bloque try/finally. Devuelve
un dict sin lanzar excepciones, siguiendo el estilo del grupo duckdb del registry:
{status:'ok', ...} en exito y {status:'error', error:str} en fallo.
El valor de esta funcion es el "ownership selectivo": al actualizar solo las
columnas indicadas en `update_cols` en caso de conflicto, un re-upsert de la misma
clave NO pisa las columnas que se dejaron fuera de `update_cols`. Asi la DB puede
ser la fuente de verdad de ciertos campos (enriquecidos, anotados a mano, derivados)
mientras un proceso de re-ingest refresca solo los campos que aporta.
Identificadores (tabla y columnas) se validan contra `[A-Za-z_][A-Za-z0-9_]*` antes
de interpolarlos en el SQL (DuckDB no permite parametrizar identificadores); los
valores de las filas siempre van por placeholders `?`.
"""
import re
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _validate_ident(name: str) -> str:
"""Valida que `name` sea un identificador SQL seguro y lo devuelve.
Acepta solo nombres que casen `[A-Za-z_][A-Za-z0-9_]*`. Lanza ValueError
para cualquier otro (espacios, comillas, puntos, vacio), que el caller
convierte en {status:'error'}.
"""
if not isinstance(name, str) or not _IDENT_RE.match(name):
raise ValueError(f"identificador invalido: {name!r}")
return name
def duckdb_upsert(
db_path: str,
table: str,
rows: list,
key_cols: list,
update_cols: list = None,
) -> dict:
"""Hace UPSERT idempotente de `rows` en `table`, con ownership selectivo.
Construye `INSERT INTO <table> (cols) VALUES (?,...) ON CONFLICT (key_cols)
DO UPDATE SET col=excluded.col, ...` (o `DO NOTHING`) y lo ejecuta fila por
fila para poder contar inserts vs updates.
Args:
db_path: ruta al archivo DuckDB. Se abre en lectura-escritura
(`duckdb.connect`), por lo que se crea si no existe — pero la tabla
destino debe existir y tener PRIMARY KEY o UNIQUE en `key_cols`.
table: nombre de la tabla destino. Validado como identificador SQL.
rows: lista de dicts, un dict por fila (clave=nombre de columna). El
esquema de insercion lo fija el conjunto de claves de la PRIMERA fila;
todas las filas deben tener exactamente las mismas claves o se devuelve
{status:'error'}. Lista vacia -> {status:'ok', inserted:0, updated:0}.
key_cols: columnas de la clave de conflicto. Deben tener PRIMARY KEY o
UNIQUE en la tabla para que ON CONFLICT funcione. Deben estar presentes
en las claves de las filas.
update_cols: columnas a actualizar en caso de conflicto.
None (default) -> todas las columnas de la fila MENOS las key_cols.
Lista vacia [] -> DO NOTHING (inserta nuevas, no toca existentes).
Lista con columnas -> DO UPDATE SET solo esas (las no listadas
conservan su valor previo: ownership selectivo).
Returns:
dict. En exito: {status:'ok', inserted:int, updated:int} donde inserted
cuenta las claves que no existian y updated las que ya existian (para
update_cols=[] -> DO NOTHING, updated es 0). En error (sin lanzar):
{status:'error', error:str}.
"""
conn = None
try:
if not isinstance(rows, list):
raise ValueError("rows debe ser una lista de dicts")
if not rows:
return {"status": "ok", "inserted": 0, "updated": 0}
# Esquema de insercion = claves de la primera fila, en orden estable.
first_keys = list(rows[0].keys())
insert_cols = [_validate_ident(c) for c in first_keys]
insert_set = set(first_keys)
# Todas las filas deben tener exactamente las mismas claves.
for i, row in enumerate(rows):
if not isinstance(row, dict):
raise ValueError(f"rows[{i}] no es un dict")
if set(row.keys()) != insert_set:
raise ValueError(
f"rows[{i}] tiene columnas distintas a la primera fila: "
f"{sorted(row.keys())} vs {sorted(first_keys)}"
)
keys = [_validate_ident(c) for c in key_cols]
if not keys:
raise ValueError("key_cols no puede estar vacio")
for k in keys:
if k not in insert_set:
raise ValueError(f"key_col {k!r} no esta en las columnas de las filas")
# Resolver update_cols.
if update_cols is None:
updates = [c for c in insert_cols if c not in keys]
else:
updates = [_validate_ident(c) for c in update_cols]
for u in updates:
if u not in insert_set:
raise ValueError(
f"update_col {u!r} no esta en las columnas de las filas"
)
cols_sql = ", ".join(insert_cols)
placeholders = ", ".join(["?"] * len(insert_cols))
conflict_sql = ", ".join(keys)
if updates:
set_sql = ", ".join(f"{c} = excluded.{c}" for c in updates)
on_conflict = f"ON CONFLICT ({conflict_sql}) DO UPDATE SET {set_sql}"
else:
on_conflict = f"ON CONFLICT ({conflict_sql}) DO NOTHING"
sql = (
f"INSERT INTO {table} ({cols_sql}) VALUES ({placeholders}) {on_conflict}"
)
conn = __import__("duckdb").connect(db_path)
# Contamos insert vs update consultando la existencia de la clave antes
# de ejecutar cada fila. Misma conexion/transaccion, single-writer.
where_keys = " AND ".join(f"{k} = ?" for k in keys)
exists_sql = f"SELECT 1 FROM {table} WHERE {where_keys} LIMIT 1"
inserted = 0
updated = 0
for row in rows:
key_vals = [row[k] for k in keys]
existed = conn.execute(exists_sql, key_vals).fetchone() is not None
values = [row[c] for c in insert_cols]
conn.execute(sql, values)
if existed:
updated += 1
else:
inserted += 1
conn.commit()
return {"status": "ok", "inserted": inserted, "updated": updated}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
finally:
if conn is not None:
conn.close()
@@ -0,0 +1,133 @@
"""Tests para duckdb_upsert."""
import os
import sys
import tempfile
import duckdb
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from infra.duckdb_upsert import duckdb_upsert # noqa: E402
def _fresh_db():
"""Crea un .duckdb temporal con tabla t(k PK, a, b) y devuelve su path."""
fd, path = tempfile.mkstemp(suffix=".duckdb")
os.close(fd)
os.remove(path) # DuckDB crea el archivo limpio.
con = duckdb.connect(path)
con.execute("CREATE TABLE t (k INTEGER PRIMARY KEY, a VARCHAR, b VARCHAR)")
con.close()
return path
def _select_row(path, k):
con = duckdb.connect(path, read_only=True)
try:
return con.execute("SELECT k, a, b FROM t WHERE k = ?", [k]).fetchone()
finally:
con.close()
def test_upsert_fila_nueva_inserta():
path = _fresh_db()
try:
res = duckdb_upsert(
path, "t", [{"k": 1, "a": "a1", "b": "b1"}], key_cols=["k"]
)
assert res == {"status": "ok", "inserted": 1, "updated": 0}
assert _select_row(path, 1) == (1, "a1", "b1")
finally:
os.remove(path)
def test_update_cols_selectivo_no_pisa_columnas_excluidas():
path = _fresh_db()
try:
duckdb_upsert(path, "t", [{"k": 1, "a": "a1", "b": "b1"}], key_cols=["k"])
# Re-upsert de la misma k cambiando a y b en el dict, pero solo
# autorizando actualizar 'a'. 'b' debe conservar el valor viejo.
res = duckdb_upsert(
path,
"t",
[{"k": 1, "a": "a2", "b": "b2"}],
key_cols=["k"],
update_cols=["a"],
)
assert res == {"status": "ok", "inserted": 0, "updated": 1}
assert _select_row(path, 1) == (1, "a2", "b1") # a cambio, b NO
finally:
os.remove(path)
def test_update_cols_vacio_do_nothing_no_cambia_existente():
path = _fresh_db()
try:
duckdb_upsert(path, "t", [{"k": 1, "a": "a1", "b": "b1"}], key_cols=["k"])
res = duckdb_upsert(
path,
"t",
[{"k": 1, "a": "X", "b": "Y"}],
key_cols=["k"],
update_cols=[],
)
assert res == {"status": "ok", "inserted": 0, "updated": 1}
assert _select_row(path, 1) == (1, "a1", "b1") # intacta
finally:
os.remove(path)
def test_varias_filas_a_la_vez_mezcla_insert_y_update():
path = _fresh_db()
try:
duckdb_upsert(path, "t", [{"k": 1, "a": "a1", "b": "b1"}], key_cols=["k"])
res = duckdb_upsert(
path,
"t",
[
{"k": 1, "a": "a1b", "b": "b1b"}, # update
{"k": 2, "a": "a2", "b": "b2"}, # insert
{"k": 3, "a": "a3", "b": "b3"}, # insert
],
key_cols=["k"],
)
assert res == {"status": "ok", "inserted": 2, "updated": 1}
assert _select_row(path, 1) == (1, "a1b", "b1b")
assert _select_row(path, 2) == (2, "a2", "b2")
assert _select_row(path, 3) == (3, "a3", "b3")
finally:
os.remove(path)
def test_rows_vacio_devuelve_cero():
path = _fresh_db()
try:
res = duckdb_upsert(path, "t", [], key_cols=["k"])
assert res == {"status": "ok", "inserted": 0, "updated": 0}
finally:
os.remove(path)
def test_columnas_inconsistentes_devuelve_error():
path = _fresh_db()
try:
res = duckdb_upsert(
path,
"t",
[{"k": 1, "a": "a1", "b": "b1"}, {"k": 2, "a": "a2"}],
key_cols=["k"],
)
assert res["status"] == "error"
finally:
os.remove(path)
def test_identificador_invalido_devuelve_error():
path = _fresh_db()
try:
res = duckdb_upsert(
path, "t; DROP TABLE t", [{"k": 1, "a": "a1", "b": "b1"}], key_cols=["k"]
)
assert res["status"] == "error"
finally:
os.remove(path)
+89
View File
@@ -0,0 +1,89 @@
---
name: expand_rrule
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def expand_rrule(dtstart_ical: str, rrule: str, range_start: str, range_end: str, all_day: bool = False) -> list[str]"
description: "Expande una RRULE iCalendar a la lista ordenada de fechas DTSTART de cada ocurrencia que cae dentro de un rango [range_start, range_end]. Pura, determinista, solo stdlib (sin python-dateutil). Soporta FREQ DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, COUNT, UNTIL y BYDAY (para WEEKLY)."
tags: [dav, calendar, ical, rrule, recurrence, caldav]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_golden_weekly_count_4", "test_edge_monthly_interval_2", "test_edge_weekly_byday_two_days", "test_edge_all_day_vs_with_time", "test_until_recorta", "test_filtro_por_rango_excluye_fuera", "test_dtstart_anterior_al_rango_pero_serie_entra", "test_componente_no_soportado_se_ignora", "test_sin_count_ni_until_acota_a_range_end"]
test_file_path: "python/functions/infra/expand_rrule_test.py"
file_path: "python/functions/infra/expand_rrule.py"
params:
- name: dtstart_ical
desc: "Fecha de inicio del evento maestro en formato iCal crudo: YYYYMMDD (all-day), YYYYMMDDTHHMMSS o YYYYMMDDTHHMMSSZ. Es la primera ocurrencia (la serie la incluye si cae en rango)."
- name: rrule
desc: "Cuerpo de la RRULE SIN el prefijo 'RRULE:', p.ej. 'FREQ=WEEKLY;INTERVAL=1;COUNT=10' o 'FREQ=MONTHLY;UNTIL=20261231;BYDAY=MO,WE'."
- name: range_start
desc: "Limite inferior del rango como YYYYMMDD (inclusive). Solo se devuelven ocurrencias cuya fecha YYYYMMDD del DTSTART cae en [range_start, range_end]."
- name: range_end
desc: "Limite superior del rango como YYYYMMDD (inclusive). Tambien acota la generacion cuando faltan COUNT y UNTIL en la RRULE."
- name: all_day
desc: "Si True las ocurrencias se devuelven como YYYYMMDD; si False conservan la parte de hora del dtstart original (misma hora local en cada ocurrencia) y devuelven YYYYMMDDTHHMMSS sin sufijo Z. Default False."
output: "Lista ordenada de strings DTSTART iCal, una por ocurrencia en rango. Lista vacia si la RRULE no produce ninguna en [range_start, range_end]."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.expand_rrule import expand_rrule
# Reunion semanal los lunes a las 09:00, 4 ocurrencias, ventana de enero 2026.
fechas = expand_rrule(
"20260105T090000",
"FREQ=WEEKLY;COUNT=4",
"20260101",
"20261231",
)
print(fechas)
# ['20260105T090000', '20260112T090000', '20260119T090000', '20260126T090000']
# Evento all-day mensual cada 2 meses, solo las que caen en el primer semestre.
fechas = expand_rrule(
"20260115",
"FREQ=MONTHLY;INTERVAL=2;COUNT=4",
"20260101",
"20260630",
all_day=True,
)
print(fechas)
# ['20260115', '20260315', '20260515']
```
## Cuando usarla
Cuando un cliente CalDAV necesita mostrar las ocurrencias de un evento
recurrente dentro de la ventana visible del calendario: tienes el DTSTART y la
RRULE del VEVENT maestro y quieres la lista concreta de fechas de inicio que
caen entre dos limites para pintarlas en la agenda. Tambien para contar o
iterar instancias de una serie sin instanciar todo el iCal.
## Gotchas
- **No implementa el RFC 5545 completo.** Componentes soportados:
- `FREQ` (obligatorio): `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`.
- `INTERVAL` (default 1).
- `COUNT` (incluye la primera ocurrencia = dtstart).
- `UNTIL` (`YYYYMMDD` o `YYYYMMDDTHHMMSSZ`, inclusive).
- `BYDAY` solo para `FREQ=WEEKLY` (`MO,TU,WE,TH,FR,SA,SU`).
- Cualquier otro componente (`BYMONTHDAY`, `BYSETPOS`, `BYMONTH`, `WKST`
avanzado, EXDATE, RDATE, etc.) se **ignora silenciosamente** — no falla, pero
el resultado puede diferir del esperado por el RFC en esos casos.
- Si faltan **COUNT y UNTIL** a la vez, la generacion se acota por `range_end`
con un tope de seguridad duro de 1000 ocurrencias para no colgar.
- En `FREQ=MONTHLY`/`YEARLY` con dia 29/30/31, los meses sin ese dia recortan al
ultimo dia valido del mes destino.
- No gestiona zonas horarias: con `all_day=False` conserva la hora local del
dtstart sin sufijo `Z`; el llamador es responsable de la tz (TZID/VTIMEZONE).
- El filtro de rango compara solo la parte `YYYYMMDD` del DTSTART, no la hora.
+202
View File
@@ -0,0 +1,202 @@
"""Expandir una RRULE iCalendar a las fechas de inicio de cada ocurrencia.
Implementacion pura y determinista, solo stdlib (datetime, re). NO usa
python-dateutil (no disponible en los venvs del ecosistema). Soporta un
subconjunto pragmatico de RFC 5545 suficiente para una agenda personal:
FREQ (DAILY/WEEKLY/MONTHLY/YEARLY), INTERVAL, COUNT, UNTIL y BYDAY (solo
para FREQ=WEEKLY). Cualquier otro componente se ignora silenciosamente.
"""
import re
from datetime import date, datetime, timedelta
# Tope de seguridad para no colgar cuando faltan COUNT y UNTIL.
_MAX_OCCURRENCES = 1000
# Mapeo de codigos BYDAY iCal -> weekday() de Python (lunes=0 .. domingo=6).
_BYDAY_TO_WEEKDAY = {
"MO": 0,
"TU": 1,
"WE": 2,
"TH": 3,
"FR": 4,
"SA": 5,
"SU": 6,
}
def _parse_ical_date(value: str) -> date:
"""Extrae la parte YYYYMMDD de un valor iCal y la devuelve como date.
Acepta YYYYMMDD, YYYYMMDDTHHMMSS y YYYYMMDDTHHMMSSZ.
"""
digits = value.strip()[:8]
return date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))
def _parse_time_suffix(dtstart_ical: str) -> str:
"""Devuelve la parte de hora 'THHMMSS' del dtstart, o '' si es all-day."""
m = re.search(r"T(\d{6})", dtstart_ical.strip())
return f"T{m.group(1)}" if m else ""
def _parse_rrule(rrule: str) -> dict:
"""Parsea el cuerpo de una RRULE a un dict de componentes en mayusculas."""
parts: dict[str, str] = {}
for token in rrule.strip().split(";"):
if not token or "=" not in token:
continue
key, _, val = token.partition("=")
parts[key.strip().upper()] = val.strip()
return parts
def _add_months(d: date, months: int) -> date:
"""Suma `months` meses a una fecha, recortando el dia al ultimo valido."""
total = (d.year * 12 + (d.month - 1)) + months
year, month = divmod(total, 12)
month += 1
# Recorte de dia: si el dia no existe en el mes destino, usar el ultimo.
if month == 12:
next_first = date(year + 1, 1, 1)
else:
next_first = date(year, month + 1, 1)
last_day = (next_first - timedelta(days=1)).day
return date(year, month, min(d.day, last_day))
def _format_occurrence(d: date, time_suffix: str, all_day: bool) -> str:
"""Formatea una fecha de ocurrencia como string DTSTART iCal."""
ymd = f"{d.year:04d}{d.month:02d}{d.day:02d}"
if all_day or not time_suffix:
return ymd
return f"{ymd}{time_suffix}"
def _generate_dates(dtstart: date, rule: dict) -> list[date]:
"""Genera las fechas de ocurrencia segun la RRULE (antes de filtrar rango).
El recorte por rango lo hace el llamador; aqui se respeta COUNT, UNTIL y
el tope de seguridad _MAX_OCCURRENCES.
"""
freq = rule.get("FREQ", "").upper()
interval = max(1, int(rule["INTERVAL"])) if rule.get("INTERVAL", "").isdigit() else 1
count = int(rule["COUNT"]) if rule.get("COUNT", "").isdigit() else None
until = _parse_ical_date(rule["UNTIL"]) if rule.get("UNTIL") else None
results: list[date] = []
def reached_limit() -> bool:
if count is not None and len(results) >= count:
return True
return len(results) >= _MAX_OCCURRENCES
def past_until(d: date) -> bool:
return until is not None and d > until
if freq == "WEEKLY":
byday = rule.get("BYDAY", "")
weekdays = [
_BYDAY_TO_WEEKDAY[code.strip().upper()]
for code in byday.split(",")
if code.strip().upper() in _BYDAY_TO_WEEKDAY
]
if not weekdays:
weekdays = [dtstart.weekday()]
weekdays = sorted(set(weekdays))
# Lunes de la semana del dtstart.
week_anchor = dtstart - timedelta(days=dtstart.weekday())
week_index = 0
while True:
week_start = week_anchor + timedelta(weeks=week_index * interval)
for wd in weekdays:
occ = week_start + timedelta(days=wd)
if occ < dtstart:
continue
if past_until(occ):
return results
if reached_limit():
return results
results.append(occ)
if reached_limit():
return results
# Corte: si no hay COUNT ni UNTIL, el llamador acota por range_end,
# pero el tope duro evita el bucle infinito.
if until is None and count is None and len(results) >= _MAX_OCCURRENCES:
return results
week_index += 1
# Salvaguarda extra si UNTIL/COUNT no recortan a tiempo.
if week_index > _MAX_OCCURRENCES:
return results
return results
# FREQ por periodos simples (DAILY / MONTHLY / YEARLY).
step = 0
while True:
if freq == "DAILY":
occ = dtstart + timedelta(days=step * interval)
elif freq == "MONTHLY":
occ = _add_months(dtstart, step * interval)
elif freq == "YEARLY":
occ = _add_months(dtstart, step * interval * 12)
else:
# FREQ desconocido o ausente: solo la primera ocurrencia.
occ = dtstart
if not past_until(occ):
results.append(occ)
return results
if past_until(occ):
return results
if reached_limit():
return results
results.append(occ)
if reached_limit():
return results
if until is None and count is None and len(results) >= _MAX_OCCURRENCES:
return results
step += 1
if step > _MAX_OCCURRENCES:
return results
def expand_rrule(
dtstart_ical: str,
rrule: str,
range_start: str,
range_end: str,
all_day: bool = False,
) -> list[str]:
"""Expande una RRULE iCal a las fechas DTSTART de cada ocurrencia en rango.
Args:
dtstart_ical: fecha de inicio del evento maestro en formato iCal crudo.
Puede ser YYYYMMDD (all-day), YYYYMMDDTHHMMSS o YYYYMMDDTHHMMSSZ.
Es la primera ocurrencia (la serie incluye dtstart si cae en rango).
rrule: cuerpo de la RRULE SIN el prefijo 'RRULE:', p.ej.
'FREQ=WEEKLY;INTERVAL=1;COUNT=10' o 'FREQ=MONTHLY;UNTIL=20261231;BYDAY=MO,WE'.
range_start: limite inferior del rango como YYYYMMDD (inclusive).
range_end: limite superior del rango como YYYYMMDD (inclusive).
all_day: si True las ocurrencias se devuelven como YYYYMMDD; si False
conservan la parte de hora del dtstart original (misma hora local en
cada ocurrencia) y devuelven YYYYMMDDTHHMMSS (sin sufijo Z).
Returns:
Lista ordenada de strings DTSTART iCal, una por ocurrencia cuya fecha
(parte YYYYMMDD del DTSTART) cae en [range_start, range_end]. Lista
vacia si la RRULE no produce ninguna en rango.
"""
rule = _parse_rrule(rrule)
dtstart = _parse_ical_date(dtstart_ical)
time_suffix = _parse_time_suffix(dtstart_ical)
lo = _parse_ical_date(range_start)
hi = _parse_ical_date(range_end)
occurrences = _generate_dates(dtstart, rule)
out: list[str] = []
for occ in occurrences:
if lo <= occ <= hi:
out.append(_format_occurrence(occ, time_suffix, all_day))
out.sort()
return out
+144
View File
@@ -0,0 +1,144 @@
"""Tests para expand_rrule."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from functions.infra.expand_rrule import expand_rrule
def test_golden_weekly_count_4():
# FREQ=WEEKLY;COUNT=4 a partir del 2026-01-05 (lunes) -> 4 fechas a 7 dias.
got = expand_rrule(
"20260105T090000",
"FREQ=WEEKLY;COUNT=4",
"20260101",
"20261231",
)
assert got == [
"20260105T090000",
"20260112T090000",
"20260119T090000",
"20260126T090000",
]
def test_edge_monthly_interval_2():
# INTERVAL=2 mensual: cada dos meses, dia 15.
got = expand_rrule(
"20260115",
"FREQ=MONTHLY;INTERVAL=2;COUNT=4",
"20260101",
"20261231",
all_day=True,
)
assert got == ["20260115", "20260315", "20260515", "20260715"]
def test_edge_weekly_byday_two_days():
# BYDAY con 2 dias en WEEKLY: MO y WE, dentro de cada semana del intervalo.
# dtstart 2026-01-05 (lunes). COUNT=4 -> MO,WE de la sem 1 y MO,WE de la sem 2.
got = expand_rrule(
"20260105",
"FREQ=WEEKLY;BYDAY=MO,WE;COUNT=4",
"20260101",
"20261231",
all_day=True,
)
assert got == ["20260105", "20260107", "20260112", "20260114"]
def test_edge_all_day_vs_with_time():
# all_day=True -> YYYYMMDD; all_day=False conserva la hora del dtstart.
all_day = expand_rrule(
"20260105T143000",
"FREQ=DAILY;COUNT=2",
"20260101",
"20261231",
all_day=True,
)
assert all_day == ["20260105", "20260106"]
with_time = expand_rrule(
"20260105T143000",
"FREQ=DAILY;COUNT=2",
"20260101",
"20261231",
all_day=False,
)
assert with_time == ["20260105T143000", "20260106T143000"]
def test_until_recorta():
# UNTIL=20260120 recorta la serie semanal inclusive en esa fecha.
got = expand_rrule(
"20260105T090000",
"FREQ=WEEKLY;UNTIL=20260120T235959Z",
"20260101",
"20261231",
)
assert got == [
"20260105T090000",
"20260112T090000",
"20260119T090000",
]
def test_filtro_por_rango_excluye_fuera():
# COUNT=10 semanal, pero el rango solo cubre 3 ocurrencias intermedias.
got = expand_rrule(
"20260105T090000",
"FREQ=WEEKLY;COUNT=10",
"20260112",
"20260131",
)
assert got == [
"20260112T090000",
"20260119T090000",
"20260126T090000",
]
def test_dtstart_anterior_al_rango_pero_serie_entra():
# dtstart en 2025-12-29 (antes del rango), serie semanal entra en enero 2026.
got = expand_rrule(
"20251229T090000",
"FREQ=WEEKLY;COUNT=8",
"20260101",
"20260115",
)
assert got == [
"20260105T090000",
"20260112T090000",
]
def test_componente_no_soportado_se_ignora():
# BYMONTHDAY no soportado -> se ignora, no falla. Serie mensual normal.
got = expand_rrule(
"20260110",
"FREQ=MONTHLY;BYMONTHDAY=10;COUNT=3",
"20260101",
"20261231",
all_day=True,
)
assert got == ["20260110", "20260210", "20260310"]
def test_sin_count_ni_until_acota_a_range_end():
# Sin COUNT ni UNTIL: la generacion debe acotarse por range_end.
got = expand_rrule(
"20260101",
"FREQ=DAILY",
"20260101",
"20260105",
all_day=True,
)
assert got == [
"20260101",
"20260102",
"20260103",
"20260104",
"20260105",
]
@@ -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") == []
+4
View File
@@ -19,6 +19,9 @@ from .slugify_obsidian_name import slugify_obsidian_name
from .extract_obsidian_embeds import extract_obsidian_embeds
from .resolve_obsidian_embed import resolve_obsidian_embed
# Grafo agregado del vault (grupo obsidian)
from .build_obsidian_graph import build_obsidian_graph
__all__ = [
"parse_obsidian_frontmatter",
"extract_obsidian_wikilinks",
@@ -34,4 +37,5 @@ __all__ = [
"slugify_obsidian_name",
"extract_obsidian_embeds",
"resolve_obsidian_embed",
"build_obsidian_graph",
]
@@ -0,0 +1,76 @@
---
name: build_obsidian_graph
kind: function
lang: py
domain: obsidian
version: "1.0.0"
purity: impure
signature: "def build_obsidian_graph(vault_dir: str, include_dangling: bool = True) -> dict"
description: "Construye el grafo agregado (nodos + aristas) de un vault de Obsidian leyendo todas sus notas .md. Cada nota es un nodo tipado (tipo por carpeta o frontmatter, id = slug, label = frontmatter['nombre'] o slug) y cada wikilink [[...]] del cuerpo es una arista dirigida resuelta por slug del ultimo segmento del destino. Los wikilinks rotos se incluyen como nodos fantasma dangling o se descartan segun include_dangling. Compone list_obsidian_notes, read_obsidian_note y slugify_obsidian_name del grupo obsidian. Es la pieza que cierra la frontera 'el grupo obsidian no indexa el grafo agregado'. Base de la vista grafo (sigma.js) de la app osint_web."
tags: [obsidian, graph, vault, nodes, edges, wikilinks, sigma, osint]
uses_functions: ["list_obsidian_notes_py_obsidian", "read_obsidian_note_py_obsidian", "slugify_obsidian_name_py_obsidian"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "re"]
params:
- name: vault_dir
desc: "ruta (absoluta o relativa) a la raiz del vault de Obsidian a indexar; se excluyen .obsidian/ y .trash/"
- name: include_dangling
desc: "si True (por defecto) los wikilinks que no resuelven a ninguna nota generan un nodo fantasma con dangling=True y su arista; si False, esos enlaces rotos se descartan"
output: "dict con 'nodes' (lista de {id: slug, tipo: str, label: str, frontmatter: dict}; los fantasma anaden dangling=True y frontmatter vacio) y 'edges' (lista de {source: slug, target: slug, kind: str} deduplicada; kind es relacion/lugar/documento segun la seccion ## donde aparece el wikilink, o 'wikilink' por defecto)"
tested: true
tests:
- "golden grafo del mini-vault con nodos y aristas esperados"
- "resuelve wikilink con acentos maria del mar al slug"
- "el kind de la arista sale de la seccion del cuerpo"
- "dangling marcado con true y excluido con false"
- "el tipo cae a la carpeta si falta en frontmatter"
- "wikilink sintacticamente roto no tumba el grafo"
- "vault inexistente lanza filenotfounderror"
test_file_path: "python/functions/obsidian/build_obsidian_graph_test.py"
file_path: "python/functions/obsidian/build_obsidian_graph.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from obsidian.build_obsidian_graph import build_obsidian_graph
graph = build_obsidian_graph("/home/enmanuel/Obsidian/osint")
print(len(graph["nodes"]), "nodos,", len(graph["edges"]), "aristas")
# 1199 nodos (984 reales + 215 fantasma), 618 aristas
# Un nodo tipado listo para sigma.js: color por tipo, label visible.
n = next(x for x in graph["nodes"] if x["tipo"] == "persona")
print(n["id"], n["label"], n["tipo"]) # ana-gomez Ana Gómez persona
# Aristas con su kind (relacion / lugar / documento / wikilink).
for e in graph["edges"][:3]:
print(e["source"], "->", e["target"], f"({e['kind']})")
```
Lanzable directo sobre el vault real (imprime conteo de nodos/aristas/dangling):
```bash
cd /home/enmanuel/fn_registry
PYTHONPATH=python/functions python/.venv/bin/python3 \
python/functions/obsidian/build_obsidian_graph.py /home/enmanuel/Obsidian/osint
```
## Cuando usarla
Cuando necesites el grafo entero de un vault de Obsidian de una sola pasada: para pintar una vista de nodos navegable (sigma.js / graphology), para detectar objetivos OSINT aun sin fichar (nodos `dangling`), o para alimentar el endpoint `/api/graph` de una app que lee el vault sin BD intermedia. Es el agregado que las funciones atomicas del grupo `obsidian` (`list_obsidian_notes`, `read_obsidian_note`) no daban por si solas.
## Gotchas
- **Lee todo el vault de disco** (I/O impuro): el grafo refleja el estado de los `.md` en ese instante; vuelve a llamar para refrescar tras editar notas.
- **El `id` de un nodo es el slug = nombre de archivo sin `.md`**. Si dos notas en carpetas distintas comparten ese nombre (caso real en el vault osint: `dni.md`, `fotos.md`, `certificado-digital.md` repetidos dentro de cada subcarpeta `personas/<slug>/`), colapsan al **mismo nodo** y solo la primera en orden alfabetico sobrevive. Por eso el vault con 1022 `.md` produce 984 nodos reales (13 grupos de slugs colisionan). Para evitarlo habria que usar el path relativo como id — se dejo el slug por compatibilidad con la resolucion de wikilinks `[[slug]]`.
- **Resolucion de wikilinks por ultimo segmento**: `[[organizaciones/acme-sl|Acme SL]]` resuelve a la nota cuyo slug es `acme-sl`; el alias (`|...`) y el ancla (`#...`) se ignoran. Nombres con acentos/mayusculas (`[[María del Mar]]`) se slugifican con `slugify_obsidian_name` antes de buscar, asi que resuelven igual que `[[maria-del-mar]]`.
- **`kind` por seccion es heuristico**: depende del texto del encabezado `## ...` mas cercano por encima del wikilink (`Relaciones`/`Relacionado` -> `relacion`, `Lugares` -> `lugar`, `Documentos` -> `documento`, resto -> `wikilink`). Un wikilink fuera de cualquier seccion conocida es `wikilink`.
- **Auto-enlaces ignorados**: si una nota se enlaza a si misma, esa arista no se emite.
- **Nodos fantasma** (`dangling: true`) llevan `tipo: "desconocido"` y `frontmatter` vacio; no representan un `.md` en disco. Con `include_dangling=False` no aparecen ni ellos ni sus aristas.
- Lanza `FileNotFoundError` si `vault_dir` no existe y `NotADirectoryError` si no es un directorio (heredado de `list_obsidian_notes`). Una nota individual ilegible se omite sin tumbar el grafo.
@@ -0,0 +1,245 @@
"""Construye el grafo agregado de un vault de Obsidian: nodos + aristas.
Funcion impura (lee disco) del grupo de capacidad ``obsidian``. Es la pieza
que cierra la frontera "el grupo obsidian no indexa el grafo agregado":
recorre todas las notas del vault, las convierte en nodos tipados y resuelve
los wikilinks ``[[...]]`` del cuerpo a aristas entre nodos existentes.
Registry-first: compone funciones puras/impuras ya existentes del grupo
``obsidian`` (``list_obsidian_notes``, ``read_obsidian_note``,
``slugify_obsidian_name``) en vez de reimplementar el parseo del vault.
"""
import os
import re
from obsidian.list_obsidian_notes import list_obsidian_notes
from obsidian.read_obsidian_note import read_obsidian_note
from obsidian.slugify_obsidian_name import slugify_obsidian_name
# Carpetas de primer nivel del vault -> tipo de nodo por defecto. El tipo del
# frontmatter (campo ``tipo``) siempre tiene prioridad sobre la carpeta.
_FOLDER_TIPO = {
"personas": "persona",
"organizaciones": "organizacion",
"lugares": "lugar",
"dominios": "dominio",
"casos": "caso",
}
# Encabezados de seccion (## ...) -> kind de las aristas que aparecen debajo.
# Se normaliza el texto del encabezado a un slug para comparar de forma estable
# (acentos, mayusculas y plurales/sinonimos cercanos colapsan al mismo bucket).
_SECTION_KIND = {
"relaciones": "relacion",
"relacionado": "relacion",
"relacion": "relacion",
"lugares": "lugar",
"lugar": "lugar",
"documentos": "documento",
"documento": "documento",
}
# Captura un wikilink [[...]] o embed ![[...]]; el grupo es el interior.
_WIKILINK_RE = re.compile(r"!?\[\[([^\[\]]+?)\]\]")
# Captura un encabezado Markdown ATX (## Titulo). Grupo 1 = texto del titulo.
_HEADING_RE = re.compile(r"^#{1,6}\s+(.*?)\s*#*\s*$")
def _tipo_for_note(path: str, vault_dir: str, frontmatter: dict) -> str:
"""Determina el tipo de un nodo: frontmatter['tipo'] o carpeta de 1er nivel.
El campo ``tipo`` del frontmatter manda. Si falta, se usa la primera
carpeta del path relativo al vault mapeada por ``_FOLDER_TIPO``. Si no
encaja en ninguna, el tipo es la propia carpeta de primer nivel (o
``"nota"`` para notas en la raiz del vault).
"""
tipo = frontmatter.get("tipo")
if isinstance(tipo, str) and tipo.strip():
return tipo.strip()
rel = os.path.relpath(path, vault_dir)
parts = rel.split(os.sep)
if len(parts) >= 2:
top = parts[0]
return _FOLDER_TIPO.get(top, top)
return "nota"
def _wikilink_target_slug(raw_target: str) -> str:
"""Reduce el destino de un wikilink a un slug estable para indexar.
El destino que entrega ``extract_obsidian_wikilinks`` ya viene sin alias
(``|...``) ni ancla (``#...``). Aqui se toma el ultimo segmento del path
(Obsidian resuelve ``[[carpeta/nota]]`` por la nota, no por la carpeta) y
se slugifica con ``slugify_obsidian_name`` para que ``[[Maria del Mar]]``
y ``[[maria-del-mar]]`` apunten al mismo nodo.
"""
# Ultimo segmento del path (soporta tanto '/' como '\\' por robustez).
last = re.split(r"[\\/]", raw_target)[-1]
return slugify_obsidian_name(last)
def _iter_body_links_with_kind(body: str):
"""Itera (slug_destino, kind) por cada wikilink del cuerpo, con su seccion.
Recorre el body linea a linea llevando la cuenta de la ultima seccion
``## ...`` vista para asignar el ``kind`` de cada wikilink segun
``_SECTION_KIND`` (relaciones/lugares/documentos). Fuera de esas secciones
el kind por defecto es ``"wikilink"``. No deduplica: cada aparicion produce
un par (la deduplicacion se hace al construir las aristas).
"""
current_kind = "wikilink"
for line in body.splitlines():
heading = _HEADING_RE.match(line)
if heading:
heading_slug = slugify_obsidian_name(heading.group(1))
current_kind = _SECTION_KIND.get(heading_slug, "wikilink")
continue
for match in _WIKILINK_RE.finditer(line):
inner = match.group(1)
target = inner.split("|", 1)[0].split("#", 1)[0].strip()
if not target:
continue
slug = _wikilink_target_slug(target)
if not slug:
continue
yield slug, current_kind
def build_obsidian_graph(vault_dir: str, include_dangling: bool = True) -> dict:
"""Construye el grafo agregado (nodos + aristas) de un vault de Obsidian.
Recorre cada nota ``.md`` del vault (excluyendo ``.obsidian/`` y
``.trash/`` via ``list_obsidian_notes``; ``attachments/`` no contiene
``.md`` y queda fuera de forma natural). Cada nota es un nodo cuyo ``id``
es su slug (nombre de archivo sin ``.md``) y cuyo ``tipo`` sale del campo
``tipo`` del frontmatter o, en su defecto, de la carpeta de primer nivel.
Cada wikilink ``[[...]]`` del cuerpo es una arista dirigida del nodo origen
al nodo destino, resuelto por slug del ultimo segmento del destino.
Args:
vault_dir: Ruta (absoluta o relativa) a la raiz del vault de Obsidian.
include_dangling: Si es ``True`` (por defecto), los wikilinks que no
resuelven a ninguna nota del vault generan un nodo fantasma con
``dangling: true`` y su arista correspondiente. Si es ``False``,
esos wikilinks rotos se descartan (ni nodo fantasma ni arista).
Returns:
dict con dos claves:
- ``nodes``: lista de ``{"id": slug, "tipo": str, "label": str,
"frontmatter": dict}``. Los nodos fantasma anaden
``"dangling": True`` y llevan ``frontmatter`` vacio.
- ``edges``: lista de ``{"source": slug, "target": slug,
"kind": str}`` deduplicada por (source, target, kind). El
``kind`` se deduce de la seccion (``relacion``/``lugar``/
``documento``) o es ``"wikilink"`` por defecto.
Raises:
FileNotFoundError: si ``vault_dir`` no existe.
NotADirectoryError: si ``vault_dir`` no es un directorio.
"""
note_paths = list_obsidian_notes(vault_dir)
# Indice slug -> nodo real. Si dos notas colapsan al mismo slug (raro), la
# primera en orden alfabetico gana (list_obsidian_notes devuelve ordenado).
nodes_by_slug: dict[str, dict] = {}
# Conserva el orden de descubrimiento de los nodos reales para la salida.
real_order: list[str] = []
for path in note_paths:
slug = os.path.splitext(os.path.basename(path))[0]
if not slug or slug in nodes_by_slug:
continue
try:
note = read_obsidian_note(path)
except OSError:
# Una nota ilegible no debe tumbar el grafo entero: se omite.
continue
frontmatter = note.get("frontmatter", {}) or {}
tipo = _tipo_for_note(path, vault_dir, frontmatter)
nombre = frontmatter.get("nombre")
label = str(nombre).strip() if isinstance(nombre, str) and nombre.strip() else slug
nodes_by_slug[slug] = {
"id": slug,
"tipo": tipo,
"label": label,
"frontmatter": frontmatter,
"_path": path, # interno: para resolver enlaces del body
}
real_order.append(slug)
edges: list[dict] = []
seen_edges: set[tuple] = set()
dangling_slugs: list[str] = []
dangling_seen: set[str] = set()
for slug in real_order:
node = nodes_by_slug[slug]
try:
body = read_obsidian_note(node["_path"]).get("body", "") or ""
except OSError:
continue
for target_slug, kind in _iter_body_links_with_kind(body):
if target_slug == slug:
# Auto-enlace: se ignora (no aporta al grafo).
continue
resolved = target_slug in nodes_by_slug
if not resolved:
if not include_dangling:
continue
if target_slug not in dangling_seen:
dangling_seen.add(target_slug)
dangling_slugs.append(target_slug)
edge_key = (slug, target_slug, kind)
if edge_key in seen_edges:
continue
seen_edges.add(edge_key)
edges.append({"source": slug, "target": target_slug, "kind": kind})
# Serializa nodos reales (sin la clave interna _path) + nodos fantasma.
nodes: list[dict] = []
for slug in real_order:
node = nodes_by_slug[slug]
nodes.append(
{
"id": node["id"],
"tipo": node["tipo"],
"label": node["label"],
"frontmatter": node["frontmatter"],
}
)
if include_dangling:
for slug in dangling_slugs:
nodes.append(
{
"id": slug,
"tipo": "desconocido",
"label": slug,
"frontmatter": {},
"dangling": True,
}
)
return {"nodes": nodes, "edges": edges}
if __name__ == "__main__":
import json
import sys
target_vault = sys.argv[1] if len(sys.argv) > 1 else "/home/enmanuel/Obsidian/osint"
graph = build_obsidian_graph(target_vault)
print(
json.dumps(
{
"vault": target_vault,
"nodes": len(graph["nodes"]),
"edges": len(graph["edges"]),
"dangling": sum(1 for n in graph["nodes"] if n.get("dangling")),
},
ensure_ascii=False,
indent=2,
)
)
@@ -0,0 +1,216 @@
"""Tests para build_obsidian_graph.
Construyen mini-vaults temporales con notas en personas/ y organizaciones/,
wikilinks entre ellas (incluido uno roto y uno con acentos) y comprueban el
grafo resultante: numero de nodos/aristas, resolucion de wikilinks acentuados,
marcado de dangling y robustez ante enlaces rotos.
"""
import os
import tempfile
from obsidian.build_obsidian_graph import build_obsidian_graph
def _write(vault: str, rel_path: str, content: str) -> None:
"""Escribe una nota .md en el mini-vault, creando carpetas si hace falta."""
full = os.path.join(vault, rel_path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as f:
f.write(content)
def _build_sample_vault(vault: str) -> None:
"""Crea un vault de prueba con 4 notas reales + 1 wikilink dangling.
Grafo esperado (aristas dirigidas):
ana -> bruno (## Relaciones, kind relacion)
ana -> maria-del-mar (## Relaciones, kind relacion, acento)
ana -> acme-sl (## Documentos, kind documento)
bruno -> persona-fantasma (## Relaciones, NO existe -> dangling)
acme-sl-> ana (cuerpo suelto, kind wikilink)
"""
# .obsidian/ debe ignorarse por completo.
_write(vault, ".obsidian/app.json", "{}")
_write(
vault,
"personas/ana.md",
"---\n"
"tipo: persona\n"
"nombre: Ana Gómez\n"
"---\n\n"
"## Relaciones\n"
"- [[bruno]] — amigo\n"
"- [[María del Mar]] — vecina\n\n"
"## Documentos\n"
"- [[organizaciones/acme-sl|Acme SL]]\n",
)
_write(
vault,
"personas/bruno.md",
"---\n"
"tipo: persona\n"
"nombre: Bruno Ruiz\n"
"---\n\n"
"## Relaciones\n"
"- [[Persona Fantasma]] — desconocido\n",
)
# Nota con acento en el nombre de archivo cuyo slug es maria-del-mar.
_write(
vault,
"personas/maria-del-mar.md",
"---\n"
"tipo: persona\n"
"nombre: María del Mar\n"
"---\n\n"
"## Notas\n"
"Sin enlaces.\n",
)
_write(
vault,
"organizaciones/acme-sl.md",
"---\n"
"tipo: organizacion\n"
"nombre: Acme SL\n"
"---\n\n"
"Cliente de [[ana]].\n",
)
def test_golden_graph_node_and_edge_counts():
"""Golden: el grafo del mini-vault tiene los nodos y aristas esperados."""
with tempfile.TemporaryDirectory() as vault:
_build_sample_vault(vault)
graph = build_obsidian_graph(vault, include_dangling=True)
real = [n for n in graph["nodes"] if not n.get("dangling")]
dangling = [n for n in graph["nodes"] if n.get("dangling")]
# 4 notas reales + 1 nodo fantasma (persona-fantasma).
assert len(real) == 4, [n["id"] for n in real]
assert len(dangling) == 1, [n["id"] for n in dangling]
# 5 aristas: ana->bruno, ana->maria-del-mar, ana->acme-sl,
# bruno->persona-fantasma, acme-sl->ana.
assert len(graph["edges"]) == 5, graph["edges"]
ids = {n["id"] for n in real}
assert ids == {"ana", "bruno", "maria-del-mar", "acme-sl"}, ids
# El tipo y el label salen del frontmatter.
ana = next(n for n in real if n["id"] == "ana")
assert ana["tipo"] == "persona"
assert ana["label"] == "Ana Gómez"
assert ana["frontmatter"]["nombre"] == "Ana Gómez"
def test_edge_resolves_wikilink_with_accents():
"""Edge: [[María del Mar]] resuelve al nodo slug maria-del-mar."""
with tempfile.TemporaryDirectory() as vault:
_build_sample_vault(vault)
graph = build_obsidian_graph(vault, include_dangling=True)
edge = next(
(e for e in graph["edges"] if e["source"] == "ana" and e["target"] == "maria-del-mar"),
None,
)
assert edge is not None, graph["edges"]
assert edge["kind"] == "relacion", edge
def test_edge_kind_from_section():
"""Edge: el kind de la arista se deduce de la seccion ## donde aparece."""
with tempfile.TemporaryDirectory() as vault:
_build_sample_vault(vault)
graph = build_obsidian_graph(vault, include_dangling=True)
kinds = {(e["source"], e["target"]): e["kind"] for e in graph["edges"]}
assert kinds[("ana", "bruno")] == "relacion", kinds
assert kinds[("ana", "acme-sl")] == "documento", kinds
# acme-sl -> ana esta fuera de cualquier seccion -> wikilink por defecto.
assert kinds[("acme-sl", "ana")] == "wikilink", kinds
def test_edge_dangling_marked_and_excluded():
"""Edge: dangling=True crea nodo fantasma; dangling=False lo descarta."""
with tempfile.TemporaryDirectory() as vault:
_build_sample_vault(vault)
with_dangling = build_obsidian_graph(vault, include_dangling=True)
ghost = next(
(n for n in with_dangling["nodes"] if n["id"] == "persona-fantasma"),
None,
)
assert ghost is not None and ghost.get("dangling") is True, with_dangling["nodes"]
assert ghost["tipo"] == "desconocido", ghost
# La arista hacia el fantasma existe.
assert any(
e["target"] == "persona-fantasma" for e in with_dangling["edges"]
), with_dangling["edges"]
without_dangling = build_obsidian_graph(vault, include_dangling=False)
assert all(
n["id"] != "persona-fantasma" for n in without_dangling["nodes"]
), without_dangling["nodes"]
# La arista rota se descarta junto con el nodo fantasma.
assert all(
e["target"] != "persona-fantasma" for e in without_dangling["edges"]
), without_dangling["edges"]
# Pasamos de 5 a 4 aristas (se elimina bruno->persona-fantasma).
assert len(without_dangling["edges"]) == 4, without_dangling["edges"]
def test_edge_tipo_falls_back_to_folder():
"""Edge: sin campo 'tipo' en frontmatter, el tipo sale de la carpeta."""
with tempfile.TemporaryDirectory() as vault:
_write(vault, "personas/sin-tipo.md", "---\nnombre: Sin Tipo\n---\n\nCuerpo.\n")
_write(vault, "organizaciones/org-sin-tipo.md", "Sin frontmatter.\n")
graph = build_obsidian_graph(vault)
by_id = {n["id"]: n for n in graph["nodes"]}
assert by_id["sin-tipo"]["tipo"] == "persona", by_id["sin-tipo"]
assert by_id["org-sin-tipo"]["tipo"] == "organizacion", by_id["org-sin-tipo"]
# Sin frontmatter ni nombre, el label cae al slug.
assert by_id["org-sin-tipo"]["label"] == "org-sin-tipo", by_id["org-sin-tipo"]
def test_error_path_broken_wikilink_no_crash():
"""Error path: un wikilink sintacticamente roto no tumba el grafo."""
with tempfile.TemporaryDirectory() as vault:
# Wikilink sin cerrar, doble corchete suelto y wikilink vacio.
_write(
vault,
"personas/raro.md",
"---\ntipo: persona\nnombre: Raro\n---\n\n"
"Texto con [[ roto y [[ ]] vacio y [[bien]] valido.\n",
)
_write(vault, "personas/bien.md", "---\ntipo: persona\nnombre: Bien\n---\n\nOK\n")
graph = build_obsidian_graph(vault, include_dangling=True)
# No crash; el unico enlace valido se resuelve a 'bien'.
edges = [(e["source"], e["target"]) for e in graph["edges"]]
assert ("raro", "bien") in edges, edges
# El wikilink vacio no genera arista ni nodo fantasma vacio.
assert all(n["id"] for n in graph["nodes"]), graph["nodes"]
def test_error_path_missing_vault_raises():
"""Error path: un vault inexistente lanza FileNotFoundError, no 500 mudo."""
raised = False
try:
build_obsidian_graph("/no/existe/vault/osint")
except FileNotFoundError:
raised = True
assert raised, "build_obsidian_graph deberia lanzar FileNotFoundError"
if __name__ == "__main__":
test_golden_graph_node_and_edge_counts()
test_edge_resolves_wikilink_with_accents()
test_edge_kind_from_section()
test_edge_dangling_marked_and_excluded()
test_edge_tipo_falls_back_to_folder()
test_error_path_broken_wikilink_no_crash()
test_error_path_missing_vault_raises()
print("build_obsidian_graph tests OK")
@@ -0,0 +1,87 @@
---
name: import_ics_to_caldav
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def import_ics_to_caldav(ics_path: str, base_url: str, username: str, password: str, collection_path: str, *, timeout_s: float = 20.0, verify_tls: bool = True, uid_prefix: str = 'goog-') -> dict"
description: "Pipeline que importa un archivo .ics completo a una coleccion CalDAV. Lee el .ics del disco, parte el VCALENDAR en N VCALENDARs independientes con un VEVENT cada uno (split_vevents_to_vcalendars, replicando las VTIMEZONE), por cada uno extrae o sintetiza el UID (extract_or_make_uid) y lo sube por HTTP PUT (caldav_put_event). Devuelve {ok, fail, total, errors}. Idempotente por UID: re-importar el mismo .ics sobrescribe en vez de duplicar. Formaliza el heredoc ad-hoc usado para migrar 98 eventos de Google Calendar a Xandikos. Solo stdlib."
tags: [dav, caldav, ical, ics, vevent, import, calendar, migration, pipelines]
uses_functions: [split_vevents_to_vcalendars_py_infra, extract_or_make_uid_py_infra, caldav_put_event_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os, sys, json]
params:
- name: ics_path
desc: "ruta en disco del archivo .ics a importar (export de Google Calendar: un VCALENDAR con N VEVENT)."
- 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 destino (p.ej. '/enmanuel/calendars/calendar/')."
- name: timeout_s
desc: "timeout por PUT individual en segundos. Default 20.0."
- name: verify_tls
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
- name: uid_prefix
desc: "prefijo del UID sintetico para eventos sin UID. Default 'goog-'."
output: "dict: {ok:int, fail:int, total:int, errors:[{uid, error}, ...]}. ok=eventos subidos con exito, fail=eventos que fallaron, total=VEVENT en el .ics, errors=detalle de los fallos (uid + mensaje, sin datos sensibles)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/import_ics_to_caldav.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from pipelines.import_ics_to_caldav import import_ics_to_caldav
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
summary = import_ics_to_caldav(
ics_path="/home/enmanuel/Descargas/calendar.ics",
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
collection_path="/enmanuel/calendars/calendar/",
)
print(summary["ok"], summary["fail"], summary["total"]) # 98 0 98
```
Desde la CLI del registry (resuelve la pass tu mismo y pasala como arg):
```bash
PW=$(pass show dav/xandikos-enmanuel | head -n1)
./fn run import_ics_to_caldav /home/enmanuel/Descargas/calendar.ics \
https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com \
enmanuel "$PW" /enmanuel/calendars/calendar/
```
## Cuando usarla
Cuando tienes un `.ics` exportado (Google Calendar, otro CalDAV) con todos los
eventos en un unico VCALENDAR y quieres volcarlo entero a Xandikos en una sola
llamada. Reemplaza el flujo ad-hoc hecho a mano en la migracion. Re-ejecutable:
por idempotencia de UID, correrlo dos veces no duplica eventos.
## Gotchas
- Solo importa VEVENT. Si el .ics trae VTODO o VJOURNAL, esos componentes se
ignoran (split_vevents_to_vcalendars solo extrae VEVENT).
- Las VTIMEZONE del original se replican en CADA evento subido (conservador):
garantiza que cualquier TZID referenciado este definido.
- Escritura remota masiva secuencial: una request por evento. Cada PUT respeta
`timeout_s`.
- Password por HTTP Basic sobre TLS; leela de `pass`, no la hardcodees ni la
dejes en el historial del shell sin cuidado.
- `errors` lista solo uid + mensaje, nunca el contenido del evento.
@@ -0,0 +1,83 @@
"""Pipeline: importa un archivo .ics completo a una coleccion CalDAV.
Compone funciones del registry: lee el .ics del disco, parte el VCALENDAR en N
VCALENDARs independientes con un VEVENT cada uno (split_vevents_to_vcalendars),
por cada uno extrae o sintetiza el UID (extract_or_make_uid) y lo sube via HTTP
PUT (caldav_put_event). Devuelve un resumen {ok, fail, total, errors}. Impuro
(I/O de disco + red). Solo stdlib.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from infra.split_vevents_to_vcalendars import split_vevents_to_vcalendars
from infra.extract_or_make_uid import extract_or_make_uid
from infra.caldav_put_event import caldav_put_event
def import_ics_to_caldav(
ics_path: str,
base_url: str,
username: str,
password: str,
collection_path: str,
*,
timeout_s: float = 20.0,
verify_tls: bool = True,
uid_prefix: str = "goog-",
) -> dict:
"""Importa todos los eventos de un .ics a una coleccion CalDAV.
Args:
ics_path: ruta en disco del archivo .ics a importar.
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 CalDAV destino.
timeout_s: timeout por PUT en segundos. Default 20.0.
verify_tls: si True (default) verifica el certificado TLS.
uid_prefix: prefijo del UID sintetico cuando un evento no trae UID.
Returns:
dict: {ok:int, fail:int, total:int, errors:[{uid:str, error:str}, ...]}.
ok = eventos subidos con exito; fail = eventos que fallaron; total =
eventos (VEVENT) encontrados en el .ics.
"""
with open(ics_path, "r", encoding="utf-8", errors="replace") as fh:
data = fh.read()
vcalendars = split_vevents_to_vcalendars(data)
ok = 0
fail = 0
errors = []
for cal in vcalendars:
uid = extract_or_make_uid(cal, prefix=uid_prefix)
res = caldav_put_event(
base_url, username, password, collection_path, uid, cal,
timeout_s=timeout_s, verify_tls=verify_tls,
)
if res.get("status") == "ok":
ok += 1
else:
fail += 1
errors.append({"uid": uid, "error": res.get("error", "unknown")})
return {"ok": ok, "fail": fail, "total": len(vcalendars), "errors": errors}
if __name__ == "__main__":
import json
if len(sys.argv) < 6:
print(
"uso: import_ics_to_caldav.py <ics_path> <base_url> <username> "
"<password> <collection_path>",
file=sys.stderr,
)
sys.exit(2)
summary = import_ics_to_caldav(
sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
)
print(json.dumps({k: summary[k] for k in ("ok", "fail", "total")}))
@@ -0,0 +1,88 @@
---
name: import_vcf_to_carddav
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def import_vcf_to_carddav(vcf_path: str, base_url: str, username: str, password: str, collection_path: str, *, timeout_s: float = 20.0, verify_tls: bool = True, uid_prefix: str = 'goog-') -> dict"
description: "Pipeline que importa un archivo .vcf completo a una coleccion CardDAV. Lee el .vcf del disco, lo parte en VCARDs (split_vcards), por cada tarjeta extrae o sintetiza el UID (extract_or_make_uid), inyecta el UID en la tarjeta si faltaba, y la sube por HTTP PUT (carddav_put_vcard). Devuelve {ok, fail, total, errors}. Idempotente por UID: re-importar el mismo .vcf sobrescribe en vez de duplicar. Formaliza el heredoc ad-hoc usado para migrar 820 contactos de Google a Xandikos. Solo stdlib."
tags: [dav, carddav, vcard, import, contacts, migration, pipelines]
uses_functions: [split_vcards_py_infra, extract_or_make_uid_py_infra, carddav_put_vcard_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os, sys, json]
params:
- name: vcf_path
desc: "ruta en disco del archivo .vcf a importar (export de Google Contacts con N tarjetas concatenadas)."
- 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 destino (p.ej. '/enmanuel/contacts/addressbook/')."
- name: timeout_s
desc: "timeout por PUT individual en segundos. Default 20.0."
- name: verify_tls
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
- name: uid_prefix
desc: "prefijo del UID sintetico para tarjetas sin UID. Default 'goog-'."
output: "dict: {ok:int, fail:int, total:int, errors:[{uid, error}, ...]}. ok=tarjetas subidas con exito, fail=tarjetas que fallaron, total=tarjetas en el .vcf, errors=detalle de los fallos (uid + mensaje, sin datos sensibles)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/import_vcf_to_carddav.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from pipelines.import_vcf_to_carddav import import_vcf_to_carddav
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
summary = import_vcf_to_carddav(
vcf_path="/home/enmanuel/Descargas/contacts.vcf",
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
collection_path="/enmanuel/contacts/addressbook/",
)
print(summary["ok"], summary["fail"], summary["total"]) # 820 0 820
```
Desde la CLI del registry (resuelve la pass tu mismo y pasala como arg):
```bash
PW=$(pass show dav/xandikos-enmanuel | head -n1)
./fn run import_vcf_to_carddav /home/enmanuel/Descargas/contacts.vcf \
https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com \
enmanuel "$PW" /enmanuel/contacts/addressbook/
```
## Cuando usarla
Cuando tienes un `.vcf` exportado (Google Contacts, iCloud, otro CardDAV) y
quieres volcarlo entero a Xandikos en una sola llamada en vez de subir tarjeta a
tarjeta con heredocs. Reemplaza el flujo ad-hoc que se hizo a mano para la
migracion. Re-ejecutable: por idempotencia de UID, correrlo dos veces no
duplica contactos.
## Gotchas
- Escritura remota masiva: sube una request por tarjeta secuencialmente. Para
miles de contactos puede tardar; cada PUT respeta `timeout_s`.
- Lee TODO el .vcf en memoria; para archivos de cientos de MB considera trocear.
- La password va por HTTP Basic sobre TLS; leela de `pass`, no la hardcodees ni
la pongas en el historial del shell sin cuidado (usa una variable como en el
ejemplo CLI).
- `errors` lista solo uid + mensaje, nunca el contenido de la tarjeta.
- Si una tarjeta no traia UID, el pipeline inyecta `UID:<goog-md5>` antes del
END:VCARD para que el campo UID: y el nombre del recurso queden consistentes.
@@ -0,0 +1,87 @@
"""Pipeline: importa un archivo .vcf completo a una coleccion CardDAV.
Compone funciones del registry: lee el .vcf del disco, lo parte en VCARDs
individuales (split_vcards), por cada tarjeta extrae o sintetiza el UID
(extract_or_make_uid) y la sube via HTTP PUT (carddav_put_vcard). Devuelve un
resumen {ok, fail, total, errors}. Impuro (I/O de disco + red). Solo stdlib.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from infra.split_vcards import split_vcards
from infra.extract_or_make_uid import extract_or_make_uid
from infra.carddav_put_vcard import carddav_put_vcard
def import_vcf_to_carddav(
vcf_path: str,
base_url: str,
username: str,
password: str,
collection_path: str,
*,
timeout_s: float = 20.0,
verify_tls: bool = True,
uid_prefix: str = "goog-",
) -> dict:
"""Importa todas las tarjetas de un .vcf a una coleccion CardDAV.
Args:
vcf_path: ruta en disco del archivo .vcf a importar.
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 destino.
timeout_s: timeout por PUT en segundos. Default 20.0.
verify_tls: si True (default) verifica el certificado TLS.
uid_prefix: prefijo del UID sintetico cuando una tarjeta no trae UID.
Returns:
dict: {ok:int, fail:int, total:int, errors:[{uid:str, error:str}, ...]}.
ok = tarjetas subidas con exito; fail = tarjetas que fallaron; total =
tarjetas encontradas en el .vcf.
"""
with open(vcf_path, "r", encoding="utf-8", errors="replace") as fh:
data = fh.read()
cards = split_vcards(data)
ok = 0
fail = 0
errors = []
for card in cards:
uid = extract_or_make_uid(card, prefix=uid_prefix)
# Si la tarjeta no declaraba UID, inyectarlo antes del END:VCARD para que
# el campo UID: y el nombre del recurso queden consistentes.
if "UID:" not in card:
card = card.replace("END:VCARD", "UID:%s\r\nEND:VCARD" % uid)
res = carddav_put_vcard(
base_url, username, password, collection_path, uid, card,
timeout_s=timeout_s, verify_tls=verify_tls,
)
if res.get("status") == "ok":
ok += 1
else:
fail += 1
errors.append({"uid": uid, "error": res.get("error", "unknown")})
return {"ok": ok, "fail": fail, "total": len(cards), "errors": errors}
if __name__ == "__main__":
import json
if len(sys.argv) < 6:
print(
"uso: import_vcf_to_carddav.py <vcf_path> <base_url> <username> "
"<password> <collection_path>",
file=sys.stderr,
)
sys.exit(2)
summary = import_vcf_to_carddav(
sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
)
# No imprimir errores con datos sensibles; solo conteos + uids.
print(json.dumps({k: summary[k] for k in ("ok", "fail", "total")}))