feat(duckdb,dav): primitivas de escritura DuckDB + libretas CardDAV + vCard multi-valor

Cinco funciones nuevas para soportar DuckDB como fuente de verdad del project osint:

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 00:33:12 +02:00
parent 1c8a86594f
commit 1c4a4b9259
17 changed files with 1773 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
"""Ejecuta una sentencia de escritura (INSERT/UPDATE/DELETE/DDL) contra DuckDB.
Funcion impura: abre un archivo DuckDB con `duckdb.connect(db_path)` en modo
read-write (crea el archivo si no existe, cosa que el modo escritura de DuckDB
permite). Ejecuta UNA sentencia con parametros posicionales (DuckDB usa el
marcador `?`), hace commit y cierra la conexion siempre en un bloque try/finally.
Es el primitivo de escritura del grupo `duckdb` del registry; complementa a
`duckdb_query_readonly_py_infra`, que es solo lectura.
Devuelve un dict sin lanzar excepciones, siguiendo el estilo del grupo
(`{status:'ok', ...}` en exito, `{status:'error', error:str}` en fallo). En exito
incluye `rowcount`: el numero de filas afectadas por la sentencia. DuckDB no expone
un `rowcount` fiable en su cursor (siempre devuelve -1), pero tras un
INSERT/UPDATE/DELETE el `fetchall()` del cursor devuelve `[(n,)]` con el conteo;
de ahi se extrae. Para DDL u operaciones que no reportan filas, `rowcount` queda
en -1 y eso NUNCA hace fallar la funcion.
"""
def _affected_rowcount(cursor) -> int:
"""Extrae el numero de filas afectadas de un cursor DuckDB de escritura.
Estrategia robusta para DuckDB:
1. Si `cursor.rowcount` esta disponible y es >= 0, usarlo.
2. Si no, intentar `cursor.fetchall()`: tras INSERT/UPDATE/DELETE DuckDB
devuelve `[(n,)]` con el conteo. Se extrae el primer entero.
3. Si nada aplica (DDL, sin filas), devolver -1.
Nunca lanza: cualquier problema al leer el conteo cae a -1.
"""
try:
rc = getattr(cursor, "rowcount", -1)
if isinstance(rc, int) and rc >= 0:
return rc
except Exception: # noqa: BLE001
pass
try:
fetched = cursor.fetchall()
except Exception: # noqa: BLE001
return -1
if fetched and fetched[0]:
candidate = fetched[0][0]
if isinstance(candidate, int):
return candidate
return -1
def duckdb_execute(db_path: str, sql: str, params: list = None) -> dict:
"""Ejecuta una sentencia de escritura DuckDB en conexion read-write.
Args:
db_path: ruta al archivo DuckDB. En modo escritura DuckDB crea el archivo
si no existe. Un directorio inexistente o un lock de otro proceso
devuelve {status:'error', ...}.
sql: sentencia SQL de escritura (INSERT/UPDATE/DELETE/DDL). Usa el
marcador `?` para parametros posicionales.
params: lista de parametros posicionales para el SQL en orden. None
(default) significa sin parametros.
Returns:
dict. En exito: {status:'ok', rowcount:int} donde rowcount es el numero
de filas afectadas (o -1 cuando la sentencia no reporta filas, p.ej. DDL).
En error (sin lanzar): {status:'error', error:str}.
"""
conn = None
try:
conn = __import__("duckdb").connect(db_path)
cursor = conn.execute(sql, params if params is not None else [])
rowcount = _affected_rowcount(cursor)
# DuckDB autocommitea por defecto, pero llamar a commit es seguro e
# idempotente: garantiza la durabilidad de la escritura.
conn.commit()
return {"status": "ok", "rowcount": rowcount}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
finally:
if conn is not None:
conn.close()