Compare commits
7 Commits
73f41a3474
...
b8ec97e477
| Author | SHA1 | Date | |
|---|---|---|---|
| b8ec97e477 | |||
| 40400c0b88 | |||
| 236a4740b0 | |||
| 1c4a4b9259 | |||
| 1c8a86594f | |||
| a76760edba | |||
| 4a0f0e9dc0 |
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 & 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 & 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 & 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 & <Ocio>" 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 & B <c></C:addressbook-description>"
|
||||
in xml
|
||||
)
|
||||
|
||||
|
||||
def test_mkcol_xml_omite_descripcion_vacia():
|
||||
xml = _build_mkcol_xml("Trabajo")
|
||||
assert "addressbook-description" not in xml
|
||||
@@ -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.
|
||||
@@ -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 & <Ocio>" 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 & B <c>" in xml
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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}`.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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") == []
|
||||
@@ -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")}))
|
||||
Reference in New Issue
Block a user