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
@@ -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