Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: launch_fleetclaude
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--cols <n>]"
|
||||
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado -L fleet) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
|
||||
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher]
|
||||
params:
|
||||
- name: --cwd
|
||||
desc: "Directorio de trabajo de ambos panes tmux. Opcional. Default: raiz del repo fn_registry, derivada dinamicamente via git rev-parse desde la ubicacion del script (sin hardcodear paths de usuario)."
|
||||
- name: --bin
|
||||
desc: "Ruta al binario de la TUI fleetview que corre en el pane izquierdo. Opcional. Default: <repo>/apps/fleetview/fleetview. Si no es ejecutable, el pane izquierdo muestra un mensaje de como compilarla y deja una shell viva."
|
||||
- name: --session
|
||||
desc: "Nombre de la sesion tmux a crear o reutilizar. Opcional. Default: fleet. La funcion es idempotente sobre este nombre."
|
||||
- name: --cols
|
||||
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
|
||||
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/launch_fleetclaude.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Via fn run (resuelve por nombre o ID):
|
||||
fn run launch_fleetclaude
|
||||
|
||||
# Directo, con cwd explicito:
|
||||
launch_fleetclaude --cwd ~/fn_registry
|
||||
|
||||
# Sesion y ancho de pane personalizados:
|
||||
launch_fleetclaude --session fleet --cols 50
|
||||
```
|
||||
|
||||
Tras invocarlo aparece una ventana kitty titulada `FleetView` con dos panes
|
||||
lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de
|
||||
`claude --dangerously-skip-permissions`. Volver a invocarlo NO duplica la
|
||||
sesion: reusa la existente y solo abre otra kitty adjunta.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando quieras un unico punto de entrada a la flota de Claudes en vez de
|
||||
N ventanas kitty sueltas: lanzas `fleetclaude` y tienes la TUI de control y un
|
||||
Claude listo para trabajar en la misma ventana. Tipico al empezar la jornada o
|
||||
al retomar el trabajo en el repo `fn_registry`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Idempotencia tmux**: si la sesion `<session>` (default `fleet`) ya existe,
|
||||
NO se recrea el layout; solo se abre una kitty nueva adjunta a la misma
|
||||
sesion. Para empezar de cero: `tmux kill-session -t fleet` antes de invocar.
|
||||
- **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para
|
||||
sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre.
|
||||
- **`exec` en los panes**: tanto la TUI como `claude` se lanzan con `exec`, asi
|
||||
que al terminar el proceso el pane se cierra en vez de dejar una shell zombie
|
||||
colgando. Excepcion: el fallback cuando `fleetview` no esta compilado deja una
|
||||
shell interactiva a proposito (para que veas el mensaje y puedas compilar).
|
||||
- **Requiere fleetview compilado**: el default `--bin` apunta a
|
||||
`<repo>/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo
|
||||
muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en
|
||||
silencio. Compila la TUI antes para el flujo completo.
|
||||
- **Socket tmux aislado (`-L fleet`)**: toda la sesion vive en un server tmux
|
||||
propio, separado del tmux por defecto del usuario. Asi los atajos `bind -n`
|
||||
NO afectan otras sesiones (ej. una sesion `mobile-1` del movil) y matar el
|
||||
server fleet no toca nada mas: `tmux -L fleet kill-server`.
|
||||
- **Atajos en el socket, NO en kitty.conf**: instala `bind -n` para
|
||||
`alt+flechas` (mover el cursor de la TUI), `alt+enter` (conmutar al Claude
|
||||
seleccionado) y `alt+n` (abrir Claude nuevo). Son bindings de tmux que
|
||||
redirigen la tecla al pane de la TUI (`send-keys -t console.0`), asi funcionan
|
||||
ESTES DONDE ESTES (incluido escribiendo en el pane de Claude). No modifican la
|
||||
configuracion de kitty ni los atajos globales del escritorio.
|
||||
- **Ancho del sidebar via hooks**: `client-resized` y `window-layout-changed`
|
||||
re-fijan el pane 0 (TUI) a `--cols` columnas, porque el `attach` de kitty y el
|
||||
conmutar de Claude redistribuyen el espacio.
|
||||
- **Necesita kitty y tmux en el PATH**: aborta con codigo != 0 si falta alguno.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.3.0 (2026-06-17) — renombrada de `launch_kittyclaude` a `launch_fleetclaude`
|
||||
(comando `fleetclaude`). Atajos: `alt+0` (= alt+n, abrir Claude nuevo), `alt+k`
|
||||
(kill con confirmacion), `alt+r` (picker de reanudar sesiones cerradas) y
|
||||
`alt+flecha-izquierda` (volver atras desde el picker). Cierra la window al salir
|
||||
el Claude (`remain-on-exit off`).
|
||||
- v1.2.0 (2026-06-16) — ancho del sidebar por defecto 47 columnas; `ctrl+0` como
|
||||
atajo alterno para abrir Claude nuevo; `mouse on` (clic/rueda enrutados a la
|
||||
TUI) y `extended-keys on` (para que `ctrl+0` llegue distinguible por el
|
||||
protocolo de teclado de kitty).
|
||||
- v1.1.0 (2026-06-16) — socket tmux aislado `-L fleet`; instala atajos
|
||||
`alt+flechas` / `alt+enter` / `alt+n` que controlan la TUI desde cualquier
|
||||
pane; hooks que mantienen fijo el ancho del sidebar tras attach/conmutar.
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
# launch_fleetclaude — Entrypoint MVP de FleetView.
|
||||
#
|
||||
# Abre UNA ventana kitty corriendo una sesion tmux de dos panes:
|
||||
# - pane izquierdo: la TUI 'fleetview' (la flota de Claudes centralizada).
|
||||
# - pane derecho: 'claude --dangerously-skip-permissions'.
|
||||
#
|
||||
# Objetivo: dejar de tener N ventanas kitty dispersas y centralizar el control
|
||||
# de los Claudes en una sola ventana.
|
||||
#
|
||||
# Funcion IMPURA: lanza procesos (tmux + kitty) con efectos secundarios.
|
||||
# - Crea/reusa una sesion tmux detached llamada <session> (idempotente).
|
||||
# - Lanza una ventana kitty desacoplada del shell padre (setsid) para que
|
||||
# sobreviva al cierre de la terminal que la invoco.
|
||||
# - No toca atajos de teclado ni kitty.conf.
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
launch_fleetclaude() {
|
||||
local cwd=""
|
||||
local bin=""
|
||||
local session="fleet"
|
||||
local cols=52
|
||||
local T="tmux -L fleet" # socket tmux aislado: no toca el tmux normal del usuario
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Parseo de argumentos
|
||||
# -----------------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--cwd)
|
||||
shift
|
||||
cwd="${1:-}"
|
||||
;;
|
||||
--bin)
|
||||
shift
|
||||
bin="${1:-}"
|
||||
;;
|
||||
--session)
|
||||
shift
|
||||
session="${1:-}"
|
||||
;;
|
||||
--cols)
|
||||
shift
|
||||
cols="${1:-40}"
|
||||
;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
Uso: launch_fleetclaude [opciones]
|
||||
|
||||
Abre una ventana kitty con una sesion tmux de dos panes: la TUI fleetview a la
|
||||
izquierda y 'claude --dangerously-skip-permissions' a la derecha.
|
||||
|
||||
Opciones:
|
||||
--cwd <dir> Directorio de trabajo de los panes.
|
||||
Default: raiz del repo fn_registry (derivada dinamicamente).
|
||||
--bin <path> Ruta al binario de la TUI fleetview.
|
||||
Default: <repo>/apps/fleetview/fleetview
|
||||
--session <name> Nombre de la sesion tmux. Default: fleet.
|
||||
--cols <n> Ancho (columnas) del pane izquierdo. Default: 40.
|
||||
-h, --help Muestra esta ayuda.
|
||||
|
||||
Ejemplos:
|
||||
launch_fleetclaude
|
||||
launch_fleetclaude --cwd ~/fn_registry
|
||||
launch_fleetclaude --session fleet --cols 50
|
||||
USAGE
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "launch_fleetclaude: opcion desconocida: '$1' (usa -h)" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Derivar la raiz del repo fn_registry dinamicamente (NO hardcodear paths
|
||||
# de usuario). Estrategia: subir desde la ubicacion del script con
|
||||
# 'git rev-parse --show-toplevel'; fallbacks razonables si no aplica.
|
||||
# -----------------------------------------------------------------------
|
||||
local script_dir repo_root=""
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# El script vive en <repo>/bash/functions/infra/, asi que la raiz son 3
|
||||
# niveles arriba; pero preferimos git para robustez.
|
||||
repo_root="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -z "$repo_root" ]]; then
|
||||
# Fallback 1: navegacion relativa desde la ubicacion del script.
|
||||
repo_root="$(cd "$script_dir/../../.." 2>/dev/null && pwd || true)"
|
||||
fi
|
||||
if [[ -z "$repo_root" ]]; then
|
||||
# Fallback 2: variable de entorno del registry o el cwd actual.
|
||||
repo_root="${FN_REGISTRY_ROOT:-$PWD}"
|
||||
fi
|
||||
|
||||
# Defaults derivados de la raiz del repo.
|
||||
[[ -z "$cwd" ]] && cwd="$repo_root"
|
||||
[[ -z "$bin" ]] && bin="$repo_root/apps/fleetview/fleetview"
|
||||
|
||||
# Validar cwd: si no existe, caer al repo_root.
|
||||
if [[ ! -d "$cwd" ]]; then
|
||||
echo "launch_fleetclaude: --cwd '$cwd' no existe; usando '$repo_root'." >&2
|
||||
cwd="$repo_root"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Comprobar herramientas necesarias.
|
||||
# -----------------------------------------------------------------------
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "launch_fleetclaude: tmux no esta instalado." >&2
|
||||
return 1
|
||||
fi
|
||||
if ! command -v kitty >/dev/null 2>&1; then
|
||||
echo "launch_fleetclaude: kitty no esta instalado." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Comando para el pane izquierdo:
|
||||
# - Si el binario fleetview existe -> ejecutarlo (exec, sin shell colgado).
|
||||
# - Si NO existe -> mensaje claro + shell interactiva (no falla en silencio).
|
||||
# -----------------------------------------------------------------------
|
||||
local left_cmd
|
||||
if [[ -x "$bin" ]]; then
|
||||
left_cmd="exec $(printf '%q' "$bin")"
|
||||
else
|
||||
# Fallback claro: instruye como compilar la TUI y deja una shell viva.
|
||||
left_cmd="echo 'fleetview no compilado: cd apps/fleetview && go build -o fleetview .'; exec \"\$SHELL\""
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Montar la sesion tmux SOLO si no existe (idempotencia). Socket aislado $T.
|
||||
# -----------------------------------------------------------------------
|
||||
if $T has-session -t "$session" 2>/dev/null; then
|
||||
echo "launch_fleetclaude: la sesion tmux '$session' ya existe; reutilizandola."
|
||||
else
|
||||
echo "launch_fleetclaude: creando sesion tmux '$session' en '$cwd'."
|
||||
|
||||
# Sesion detached con ventana 'console', pane 0 en el cwd objetivo.
|
||||
$T new-session -d -s "$session" -n console -c "$cwd"
|
||||
|
||||
# pane 0 (izquierda) = la TUI fleetview (o el fallback claro).
|
||||
$T send-keys -t "$session":console.0 "$left_cmd" C-m
|
||||
|
||||
# pane 1 (derecha) = claude, dividiendo horizontalmente (split lado a lado).
|
||||
$T split-window -h -t "$session":console -c "$cwd"
|
||||
$T send-keys -t "$session":console.1 "exec claude --dangerously-skip-permissions" C-m
|
||||
|
||||
# Fijar el ancho del pane izquierdo en columnas.
|
||||
$T resize-pane -t "$session":console.0 -x "$cols"
|
||||
|
||||
# Foco inicial en el pane de claude (derecha).
|
||||
$T select-pane -t "$session":console.1
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Atajos globales (alt+*) en el socket aislado: redirigen la tecla al pane
|
||||
# de la TUI (console.0) ESTES DONDE ESTES, para controlar la flota sin salir
|
||||
# del pane de Claude. La TUI (fleetview) es quien interpreta Up/Down/Enter/n.
|
||||
# `bind -n` = tabla root (sin prefijo). Idempotente: re-set en cada lanzamiento.
|
||||
# -----------------------------------------------------------------------
|
||||
$T bind -n M-Up send-keys -t "$session":console.0 Up
|
||||
$T bind -n M-Down send-keys -t "$session":console.0 Down
|
||||
$T bind -n M-Enter send-keys -t "$session":console.0 Enter
|
||||
$T bind -n M-n send-keys -t "$session":console.0 n
|
||||
$T bind -n M-0 send-keys -t "$session":console.0 n
|
||||
$T bind -n M-k send-keys -t "$session":console.0 k
|
||||
$T bind -n M-r send-keys -t "$session":console.0 r
|
||||
$T bind -n M-u send-keys -t "$session":console.0 u
|
||||
$T bind -n M-h send-keys -t "$session":console.0 h
|
||||
$T bind -n M-Left send-keys -t "$session":console.0 Escape
|
||||
$T bind -n M-q send-keys -t "$session":console.0 Q
|
||||
# Raton: enruta clicks/rueda al pane bajo el cursor; la TUI los interpreta.
|
||||
$T set -g mouse on
|
||||
# Al salir un Claude (exit / Ctrl-D / kill), cerrar su window en vez de
|
||||
# dejarla muerta ("dead" pane) en la sesion.
|
||||
$T set -g remain-on-exit off
|
||||
|
||||
# Estetica neutra: sin el verde fosforo por defecto de tmux. Status bar gris y
|
||||
# bordes de pane gris tenue, iguales en activo e inactivo (separacion simple,
|
||||
# sin resaltado de enfoque).
|
||||
$T set -g status-style "bg=colour236,fg=colour250"
|
||||
$T set -g pane-border-style "fg=colour238"
|
||||
$T set -g pane-active-border-style "fg=colour240"
|
||||
|
||||
# Mantener el ancho del sidebar (pane 0) cuando kitty redimensiona la ventana
|
||||
# tras el attach, o cuando se conmuta de Claude (window-linked / layout change).
|
||||
$T set-hook -g client-resized "resize-pane -t $session:console.0 -x $cols"
|
||||
$T set-hook -g window-layout-changed "resize-pane -t $session:console.0 -x $cols"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Lanzar kitty adjuntando la sesion, DESACOPLADA del shell padre con
|
||||
# setsid, para que no muera al cerrar la terminal invocadora.
|
||||
# (Mismo patron que reboot_all_claudes para relanzar terminales.)
|
||||
# -----------------------------------------------------------------------
|
||||
# Adjuntar la sesion:
|
||||
# - Si se invoca desde una terminal interactiva, convertir ESA terminal en
|
||||
# el panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la
|
||||
# shell). Asi `fleetclaude` no abre otra ventana: usa la actual.
|
||||
# - Si NO hay TTY (atajo de escritorio, cron, script), abrir una ventana
|
||||
# kitty nueva desacoplada (setsid) como antes.
|
||||
if [ -t 0 ] && [ -t 1 ]; then
|
||||
exec tmux -L fleet attach -t "$session"
|
||||
fi
|
||||
setsid kitty --title "FleetView" -e tmux -L fleet attach -t "$session" </dev/null >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
|
||||
echo "launch_fleetclaude: ventana kitty 'FleetView' adjunta a la sesion tmux '$session'."
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
launch_fleetclaude "$@"
|
||||
fi
|
||||
@@ -24,6 +24,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [docker](docker.md) | 38 | Operar Docker desde Go/Bash: build/run/stop, compose, networks, volumes, logs, deploys |
|
||||
| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat |
|
||||
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
|
||||
| [claude-fleet](claude-fleet.md) | 5 | Orquestar la flota de procesos Claude Code vivos: panel TUI (fleetview) + comando fleetclaude que centraliza N Claudes en una ventana kitty/tmux (socket -L fleet), conmuta cual esta embebido (alt+flechas/enter/n) y los lista desde ~/.claude/sessions+goals |
|
||||
| [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy |
|
||||
| [hoppscotch](hoppscotch.md) | 7 | Operar Hoppscotch SELF-HOSTED (docker en selfhost/) via API GraphQL: login (magic link headless via mailpit), CRUD de requests (create/update/delete/list), set_environment (idempotente, resuelve secretos pass:). El agente crea/edita y el humano lo ve en vivo en su GUI (subscriptions). build es helper interno de serializacion. Modo .json local ELIMINADO |
|
||||
| [dav](dav.md) | 9 | Cliente CardDAV/CalDAV (Python, solo stdlib) para Xandikos: parte un .vcf/.ics export de Google en recursos individuales (split puro), extrae/sintetiza UID, sube por HTTP PUT con Basic auth, lista (PROPFIND) y descarga (GET) recursos. Dos pipelines de import (vcf->carddav, ics->caldav). Formaliza la migracion ad-hoc de contactos/calendario |
|
||||
@@ -52,7 +53,9 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
|
||||
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
|
||||
| [obsidian](obsidian.md) | 16 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve, render tabla Markdown + bloques sentinel gestionados. Sin app GUI |
|
||||
| [duckdb](duckdb.md) | 5 | Operar bases DuckDB: open (Go), query read-only segura (Python, tipos JSON-safe), CSV->Parquet, dedup por hash, carga OHLCV. Base del patron BD-fuente-de-verdad + Obsidian-vista (app osint_db) |
|
||||
| [duckdb](duckdb.md) | 10 | Operar bases DuckDB: open (Go), query/execute/upsert, introspeccion (list_tables, table_schema), CSV->Parquet, dedup, OHLCV, e ingesta desde Excel (excel_to_duckdb) + salida a Postgres (duckdb_to_postgres). Motor analitico del stack de datos Excel->DuckDB->Postgres->viz |
|
||||
| [excel](excel.md) | 6 | CRUD de hojas Excel (.xlsx) con openpyxl: escribir multi-hoja, upsert no destructivo (preserva columnas manuales), leer a memoria, leer a markdown, graficos nativos (bar/line/pie/scatter), e ingesta a DuckDB. Round-trip de datos con humanos |
|
||||
| [postgres](postgres.md) | 7 | CRUD de PostgreSQL via psycopg2 (dsn): connect (Go), query read-only, insert append-only, upsert idempotente, crear tabla inferida, introspeccion, aplicar .sql. Capa que sirve datos a Metabase/Grafana (que no hablan DuckDB nativo) |
|
||||
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
|
||||
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
|
||||
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Capability group: claude-fleet
|
||||
|
||||
Operar la **flota de procesos Claude Code** vivos en la máquina como una sola
|
||||
unidad: descubrirlos, listarlos en un panel TUI y centralizarlos en una ventana
|
||||
kitty con tmux donde se conmuta cuál está embebido a la derecha. Reemplaza el
|
||||
caos de N ventanas kitty dispersas por un único punto de entrada.
|
||||
|
||||
Pieza visible: la app `fleetview` (TUI). Entrypoint: el comando `fleetclaude`.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Qué hace |
|
||||
|---|---|---|
|
||||
| `list_claude_fleet_go_infra` | `ListClaudeFleet() ([]ClaudeFleet, error)` | Escanea `~/.claude/sessions/*.json` + `goals/`, valida procesos vivos (anti-PID-reciclado), join por `sessionId` → lista tipada con status/objetivo/cwd/target. |
|
||||
| `launch_fleetclaude_bash_infra` | `launch_fleetclaude [--cwd <d>] [--bin <p>] [--session <n>] [--cols <n>]` | Entrypoint: abre kitty con sesión tmux (socket aislado `-L fleet`) de dos panes (TUI izq + Claude der). Instala atajos `alt+*` e hijos del sidebar. |
|
||||
| `tmux_new_claude_window_go_infra` | `TmuxNewClaudeWindow(socket, session, cwd string) (string, error)` | Crea una window tmux nueva con `claude --dangerously-skip-permissions`. Devuelve el `window_id`. |
|
||||
| `tmux_swap_window_into_console_go_infra` | `TmuxSwapWindowIntoConsole(socket, session, windowID string) error` | Trae el Claude de `windowID` al pane derecho de `console` (junto a la TUI), parkea el anterior, re-fija el ancho del sidebar. |
|
||||
| `tmux_map_claude_panes_go_infra` | `TmuxMapClaudePanes(socket string) (map[int]string, error)` | Mapa `claudePID → window_id` de los Claude que viven en la sesión (vía `list-panes` + descendencia `/proc`). Permite a la TUI saber cuáles son conmutables. |
|
||||
|
||||
App relacionada: `fleetview_go_infra` (`apps/fleetview/`) — la TUI Bubble Tea que consume `list_claude_fleet` y orquesta los wrappers tmux.
|
||||
|
||||
## Ejemplo canónico (end-to-end)
|
||||
|
||||
```bash
|
||||
# 1. Compilar la TUI una vez.
|
||||
cd ~/fn_registry/apps/fleetview && go build -o fleetview .
|
||||
|
||||
# 2. Abrir la flota (una ventana kitty: panel izq + Claude der).
|
||||
fn run launch_fleetclaude
|
||||
|
||||
# 3. Dentro de la ventana, desde CUALQUIER pane (incluido escribiendo en Claude):
|
||||
# alt+↑/↓ mueve el cursor de la lista
|
||||
# alt+enter conmuta el pane derecho al Claude seleccionado
|
||||
# alt+n abre un Claude nuevo (window en fleet) y conmuta a él
|
||||
|
||||
# Inspección headless de la flota sin abrir nada:
|
||||
fn run list_claude_fleet | jq '.[] | {rename, status, goal}'
|
||||
```
|
||||
|
||||
Bajo el capó de `alt+enter`/`alt+n`: tmux redirige la tecla al pane de la TUI
|
||||
(`bind -n M-Enter send-keys -t console.0 Enter`); la TUI resuelve el Claude
|
||||
seleccionado con `TmuxMapClaudePanes` y lo trae con `TmuxSwapWindowIntoConsole`
|
||||
(o crea uno con `TmuxNewClaudeWindow`).
|
||||
|
||||
## Fronteras (qué NO cubre)
|
||||
|
||||
- **No gestiona Claudes remotos** (ej. los de una sesión tmux del móvil): se
|
||||
listan como contexto pero no se embeben localmente (no son panes de fleet).
|
||||
- **Adopción de Claudes sueltos pendiente**: un Claude vivo en otra ventana kitty
|
||||
(fuera de fleet) se lista, pero `alt+enter` sobre él aún no lo trae —
|
||||
requerirá relaunch `claude --resume <sessionId>` dentro de fleet (patrón de
|
||||
`reboot_all_claudes_bash_infra`).
|
||||
- **No reinicia ni mata Claudes** (todavía): `resume`/`kill` desde el panel son
|
||||
fase posterior. Para reiniciar toda la flota existe `reboot_all_claudes_bash_infra`.
|
||||
- **Linux + kitty + tmux** únicamente (build tag `!windows`, usa `/proc`).
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- `kitty` y `tmux` en el PATH. La sesión vive en un server tmux aislado (`-L fleet`).
|
||||
- La TUI `fleetview` compilada (`apps/fleetview/fleetview`).
|
||||
- Claude Code ≥ 2.1.x (escribe `~/.claude/sessions/<PID>.json` con `status`).
|
||||
|
||||
## Notas
|
||||
|
||||
- Toda la sesión usa el socket `-L fleet`: los atajos `bind -n` no afectan al
|
||||
tmux por defecto del usuario; `tmux -L fleet kill-server` lo limpia entero.
|
||||
- `reboot_all_claudes_bash_infra` comparte la misma fuente de verdad
|
||||
(`~/.claude/sessions/<PID>.json`) y es el complemento para reiniciar la flota.
|
||||
@@ -15,6 +15,39 @@ Pieza central del patron **BD como fuente de verdad + Obsidian como vista** (pro
|
||||
| `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). |
|
||||
| `duckdb_list_tables_py_infra` | `duckdb_list_tables(db_path) -> dict` | Introspección read-only: lista las tablas (`information_schema.tables`, schema main) ordenadas. Devuelve `{status, tables}`. |
|
||||
| `duckdb_table_schema_py_infra` | `duckdb_table_schema(db_path, table) -> dict` | Introspección read-only: schema de una tabla (`DESCRIBE`). Devuelve `{status, table, columns:[{name,type}]}`. Útil para mapear tipos a otro motor (p.ej. PostgreSQL). |
|
||||
| `excel_to_duckdb_py_infra` | `excel_to_duckdb(xlsx_path, duckdb_path, table, sheet=None, mode='replace') -> dict` | **Puente de entrada Excel→DuckDB**: ingiere una hoja `.xlsx` a una tabla con la extensión nativa `excel` de DuckDB. `replace`/`append`. Devuelve `{status, table, row_count}`. |
|
||||
| `duckdb_to_postgres_py_pipelines` | `duckdb_to_postgres(duckdb_path, table, pg_dsn, pg_table=None, mode='replace', key_cols=None, batch_size=5000) -> dict` | **Puente de salida DuckDB→Postgres**: mapea tipos, crea la tabla y sincroniza filas. Desbloquea que Metabase/Grafana/Superset (que no hablan DuckDB) lean los datos. Devuelve `{status, pg_table, rows_synced, created}`. |
|
||||
|
||||
## Puentes: Excel → DuckDB → Postgres → visualización
|
||||
|
||||
DuckDB es el centro del stack de datos: el motor analítico embebido. Los datos entran desde Excel y salen hacia BI:
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import excel_to_duckdb, duckdb_list_tables, duckdb_query_readonly
|
||||
from pipelines.duckdb_to_postgres import duckdb_to_postgres
|
||||
|
||||
# 1. Excel -> DuckDB (extensión nativa, sin pandas)
|
||||
excel_to_duckdb("/tmp/ventas.xlsx", "/tmp/datos.duckdb", "ventas", sheet="ventas")
|
||||
print(duckdb_list_tables("/tmp/datos.duckdb"))
|
||||
|
||||
# 2. Analítica en DuckDB
|
||||
print(duckdb_query_readonly("/tmp/datos.duckdb",
|
||||
"SELECT categoria, SUM(importe) AS total FROM ventas GROUP BY 1")["rows"])
|
||||
|
||||
# 3. DuckDB -> Postgres (para que Metabase/Grafana lo lean)
|
||||
# dsn = "postgresql://captacion:<pass>@localhost:5433/trends"
|
||||
# duckdb_to_postgres("/tmp/datos.duckdb", "ventas", dsn, pg_table="ventas", mode="replace")
|
||||
PYEOF
|
||||
```
|
||||
|
||||
- **Evidence.dev** lee el `.duckdb` directamente (nativo) — no necesita el puente a Postgres.
|
||||
- **Metabase / Grafana / Superset** no hablan DuckDB → usa `duckdb_to_postgres` y apunta la herramienta al Postgres espejo.
|
||||
|
||||
## Ejemplo canonico
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Capability: excel
|
||||
|
||||
CRUD de hojas de cálculo Excel (`.xlsx`) desde el registry con openpyxl: escribir libros multi-hoja, actualizar una hoja sin destruir las demás (preservando columnas editadas a mano), leer a estructuras en memoria o a markdown, añadir gráficos nativos, e ingerir una hoja a DuckDB.
|
||||
|
||||
Es el extremo Excel del **stack de datos** `Excel → DuckDB → Postgres → visualización`: el Excel sirve como entrada (lo que produce un humano o un export) y como entregable (un libro con gráficos que viaja por email/disco, sin servidor). El round-trip humano lo cubre `upsert_xlsx_sheet`, que conserva las columnas que las personas rellenan a mano mientras regenera las columnas calculadas.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `write_xlsx_sheets_py_infra` | `write_xlsx_sheets(out_path, sheets, header_bold=True, autofit=True, freeze_header=True) -> str` | Escribe (o sobrescribe) un libro `.xlsx` multi-hoja desde un dict `{nombre_hoja: datos}`. Cada hoja acepta `list[list]` (primera fila = headers) o `{"headers": [...], "rows": [[...]]}`. Cabecera en negrita, auto-ancho, freeze de cabecera. Devuelve la ruta absoluta. |
|
||||
| `upsert_xlsx_sheet_py_infra` | `upsert_xlsx_sheet(xlsx_path, sheet_name, records, columns, key_col="", preserve_cols=None, formulas=None, backup=True, ...) -> dict` | Actualiza NO destructivamente UNA hoja: reescribe solo `sheet_name` y conserva las demás. Antes de limpiar, lee por `key_col` las columnas de trabajo manual (`preserve_cols`) y las reescribe ganando sobre los datos nuevos. Cabecera estilizada, freeze, autofilter, fórmulas por columna, backup `.bak`. |
|
||||
| `read_xlsx_py_infra` | `read_xlsx(path, sheet=None, max_rows=None, header=True) -> dict` | Lee un `.xlsx` a memoria (NO a markdown). Devuelve `{status, sheets: {nombre: {headers, rows}}}`. `sheet=None` lee todas. Tipos de celda: fechas→ISO, int/float, bool, None, fórmulas (valor calculado, `data_only=True`). Espejo en lectura de `write_xlsx_sheets`. |
|
||||
| `excel_to_markdown_py_core` | `excel_to_markdown(path, max_rows_per_sheet=1000) -> str` | Convierte `.xlsx/.xls/.xlsm` a markdown, cada hoja como sección H2. Para inspección rápida / pegar en un prompt o nota. |
|
||||
| `add_xlsx_chart_py_infra` | `add_xlsx_chart(xlsx_path, sheet_name, chart_type, data_range, cats_range=None, anchor='H2', title='', x_title='', y_title='') -> dict` | Añade un gráfico nativo (`bar`/`line`/`pie`/`scatter`) a una hoja EXISTENTE, refiriendo rangos de celdas ya escritos (notación Excel `'C1:C7'`). `anchor` = celda destino. La pieza para generar hojas Excel CON gráficos. |
|
||||
| `excel_to_duckdb_py_infra` | `excel_to_duckdb(xlsx_path, duckdb_path, table, sheet=None, mode='replace') -> dict` | Ingesta una hoja del `.xlsx` a una tabla DuckDB con la extensión nativa `excel` de DuckDB. Puente Excel→DuckDB. También etiquetada en el grupo `duckdb`. |
|
||||
|
||||
## Ejemplo canónico
|
||||
|
||||
Escribir un libro, añadirle un gráfico y releerlo a memoria (verificado):
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import write_xlsx_sheets, add_xlsx_chart, read_xlsx
|
||||
|
||||
xlsx = "/tmp/ventas.xlsx"
|
||||
write_xlsx_sheets(xlsx, {"ventas": [
|
||||
["mes", "categoria", "importe"],
|
||||
["2026-01", "neumaticos", 12500.50],
|
||||
["2026-02", "neumaticos", 15800.75],
|
||||
["2026-03", "neumaticos", 18200.00],
|
||||
]})
|
||||
|
||||
# Gráfico de barras del importe por mes, anclado en la celda G2
|
||||
add_xlsx_chart(xlsx, "ventas", "bar", data_range="C1:C4", cats_range="A2:A4",
|
||||
anchor="G2", title="Importe por mes", y_title="EUR")
|
||||
|
||||
rd = read_xlsx(xlsx, sheet="ventas")
|
||||
print(rd["sheets"]["ventas"]["headers"], len(rd["sheets"]["ventas"]["rows"]))
|
||||
PYEOF
|
||||
```
|
||||
|
||||
## Gotchas del grupo
|
||||
|
||||
- **openpyxl no evalúa fórmulas.** `read_xlsx` con `data_only=True` devuelve el valor **cacheado** por la última app que guardó el libro (Excel/LibreOffice). Un `.xlsx` con fórmulas escritas por openpyxl y nunca abierto en una hoja de cálculo devuelve `None` en esas celdas.
|
||||
- **`add_xlsx_chart` exige libro y hoja existentes:** no crea el `.xlsx` ni escribe datos; los rangos deben apuntar a celdas ya escritas. Flujo: `write_xlsx_sheets` → `add_xlsx_chart`.
|
||||
- **Rangos 1-indexed, notación Excel** (`'C1:C7'`). Si `data_range` incluye la fila de cabecera, el nombre de la serie sale de esa celda (`titles_from_data`). `scatter` usa `data_range` como Y y `cats_range` como X; `pie` ignora los títulos de eje.
|
||||
- **Carga en memoria:** openpyxl carga el libro entero; para libros muy grandes considera ingerir a DuckDB (`excel_to_duckdb`) y consultar allí.
|
||||
- **`upsert_xlsx_sheet` es la vía para datos editados por humanos:** si una persona rellena columnas a mano, pásalas en `preserve_cols` para que un re-volcado no las pise.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- NO es una herramienta de BI ni de dashboards. Para visualización interactiva/compartida: Metabase, Evidence (sobre DuckDB) o gráficos embebidos con `add_xlsx_chart` para el caso "todo en el .xlsx".
|
||||
- El análisis pesado (agregaciones, joins, histórico) NO se hace en Excel: ingiere a DuckDB con `excel_to_duckdb` y usa el grupo `duckdb`.
|
||||
- NO cubre `.csv` de entrada con encodings legacy — eso es `safe_read_csv_fallback_py_core`.
|
||||
|
||||
## Relación con otros grupos
|
||||
|
||||
- `duckdb` — `excel_to_duckdb` es el puente de entrada; el motor analítico vive allí.
|
||||
- `postgres` — la salida hacia BI pasa por `duckdb_to_postgres` (grupo `duckdb`/`postgres`).
|
||||
- `metabase` — consume los datos una vez en Postgres.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Capability: postgres
|
||||
|
||||
CRUD de PostgreSQL desde el registry. Las funciones Python (psycopg2) reciben un `dsn: str`, son impuras y devuelven un dict `{status:'ok'|'error', ...}` sin lanzar (mismo estilo que el grupo `duckdb`); la función Go (`postgres_open`) abre un `*sql.DB` desde parámetros individuales.
|
||||
|
||||
Postgres es la **capa que sirve datos a las herramientas de BI** del stack (`Excel → DuckDB → Postgres → visualización`). Metabase, Grafana y Superset NO hablan DuckDB de forma nativa, pero todas hablan PostgreSQL: por eso el motor analítico de trabajo es DuckDB y, cuando un dashboard tiene que consumir esos datos, se sincronizan a Postgres con `duckdb_to_postgres` (grupo `duckdb`).
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `postgres_open_go_infra` | `PostgresOpen(host, port, user, password, dbname, sslmode) (*sql.DB, error)` | Conecta a PostgreSQL desde Go construyendo el DSN. `sslmode` por defecto `disable`. |
|
||||
| `pg_query_py_infra` | `pg_query(dsn, sql, params=None, max_rows=10000) -> dict` | SELECT read-only (`SET TRANSACTION READ ONLY`) con `RealDictCursor`. Devuelve `{status, columns, rows, row_count, truncated}`. Normaliza tipos no JSON (date/datetime→ISO, Decimal→float, bytes→base64, UUID→str). Espejo de `duckdb_query_readonly`. Valores por `%s`. |
|
||||
| `pg_insert_rows_py_infra` | `pg_insert_rows(dsn, table, rows, add_snapshot_date=True) -> int` | INSERT append-only en lote (`execute_values`). Deriva columnas de las claves. Opcional `snapshot_date = date.today()`. Retorna nº de filas. |
|
||||
| `pg_upsert_py_infra` | `pg_upsert(dsn, table, rows, key_cols, update_cols=None) -> dict` | UPSERT idempotente `INSERT ... ON CONFLICT (key_cols) DO UPDATE SET col=EXCLUDED.col`. `update_cols` = ownership selectivo (las no listadas conservan su valor); `[]` = DO NOTHING. Devuelve `{status, inserted, updated}`. `key_cols` deben tener PK/UNIQUE. Espejo de `duckdb_upsert`. |
|
||||
| `pg_create_table_from_rows_py_infra` | `pg_create_table_from_rows(dsn, table, rows, primary_key=None) -> dict` | `CREATE TABLE IF NOT EXISTS` infiriendo columnas y tipos desde los valores (bool→BOOLEAN, int→BIGINT, float→DOUBLE PRECISION, datetime→TIMESTAMP, date→DATE, resto→TEXT). Idempotente. Devuelve `{status, created, table, columns}`. |
|
||||
| `pg_list_tables_py_infra` | `pg_list_tables(dsn, schema='public') -> dict` | Introspección read-only: tablas base con sus columnas vía `information_schema`. Devuelve `{status, schema, tables:[{name, columns:[{name,type,nullable}]}]}`. |
|
||||
| `pg_apply_sql_py_infra` | `pg_apply_sql(dsn, sql_path) -> int` | Ejecuta un archivo `.sql` completo (multi-statement, una transacción). Para migraciones idempotentes (`IF NOT EXISTS`). |
|
||||
|
||||
Relacionadas (otros grupos): `duckdb_to_postgres_py_pipelines` (sincroniza una tabla DuckDB a Postgres) e `init_metabase_go_infra` (despliega el stack Metabase + Postgres en Docker).
|
||||
|
||||
## Ejemplo canónico
|
||||
|
||||
Crear una tabla inferida, hacer upsert idempotente y consultar (DSN desde `pass`):
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
DSN="postgresql://captacion:$(pass captacion/postgres | head -1)@localhost:5433/trends"
|
||||
python/.venv/bin/python3 - "$DSN" <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import pg_create_table_from_rows, pg_upsert, pg_query
|
||||
|
||||
dsn = sys.argv[1]
|
||||
rows = [{"mes": "2026-01", "total": 12500.5}, {"mes": "2026-02", "total": 15800.75}]
|
||||
|
||||
pg_create_table_from_rows(dsn, "demo_kpi", rows, primary_key=["mes"])
|
||||
print(pg_upsert(dsn, "demo_kpi", rows, key_cols=["mes"])) # inserted/updated
|
||||
print(pg_upsert(dsn, "demo_kpi", rows, key_cols=["mes"])) # idempotente: 0 inserts
|
||||
print(pg_query(dsn, "SELECT * FROM demo_kpi ORDER BY mes")["rows"])
|
||||
PYEOF
|
||||
```
|
||||
|
||||
## Gotchas del grupo
|
||||
|
||||
- **El DSN lleva credenciales — nunca hardcodear.** Resuélvelo desde `pass` (ej. `pass captacion/postgres`: L1 = password, resto `user/host/port/datadb`). No imprimas el DSN en logs.
|
||||
- **`pg_query`/`pg_list_tables` son read-only por convención** (`SET TRANSACTION READ ONLY` + rollback), protegen la base pero NO son sandbox; los identificadores (tabla/schema) NO se parametrizan — los valores sí (`%s`). Las funciones validan identificadores con `^[A-Za-z_][A-Za-z0-9_]*$`.
|
||||
- **`pg_upsert` cuenta insert vs update con el pseudo-columna `xmax`** (`RETURNING (xmax = 0)`). Fiable en el caso normal (single-writer, sin triggers raros). Con `update_cols=[]` (DO NOTHING) las filas en conflicto no se devuelven, así que solo se cuentan las nuevas. BEFORE-triggers / REPLICA IDENTITY pueden desviar el conteo.
|
||||
- **`pg_create_table_from_rows` no reconcilia schema:** si la tabla ya existe, `columns` reporta los tipos inferidos de las filas, no los reales. Inferencia best-effort sin NUMERIC/escala — para dinero define el schema a mano con `pg_apply_sql`.
|
||||
- **`pg_insert_rows` y `pg_apply_sql` lanzan en error** (no devuelven dict); envuélvelas si compones.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- NO es el motor analítico del stack — ese es DuckDB (columnar, lee CSV/Parquet/Excel nativo). Postgres es el destino para BI.
|
||||
- NO dibuja dashboards: eso es Metabase / Grafana / Evidence leyendo de Postgres.
|
||||
- NO cubre PostGIS más allá de `osm2pgsql_ingest_py_infra` (geo, aparte).
|
||||
|
||||
## Relación con otros grupos
|
||||
|
||||
- `duckdb` — `duckdb_to_postgres` es el puente de entrada de datos a esta capa.
|
||||
- `metabase` — registra la base con `metabase_add_database(engine='postgres', ...)` y consume las tablas.
|
||||
- `excel` — el origen de los datos suele ser un `.xlsx` ingerido por `excel_to_duckdb`.
|
||||
@@ -1,6 +1,15 @@
|
||||
package browser
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// refActionableTimeout es cuánto espera CdpClickRef/CdpHoverRef a que el elemento
|
||||
// sea accionable (visible+stable+hit-test) antes de caer al cálculo de centro
|
||||
// previo. Lo bastante para tragar animaciones/overlays transitorios sin penalizar
|
||||
// el caso común (que converge en ~1 frame).
|
||||
const refActionableTimeout = 2 * time.Second
|
||||
|
||||
// refBoxCenter resuelve el centro (x,y) en coords de página de un nodo DOM por su
|
||||
// backendDOMNodeId, vía DOM.getBoxModel. El content quad son 8 floats (4 esquinas).
|
||||
@@ -37,6 +46,13 @@ func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
|
||||
if opts.Mode == "instant" {
|
||||
return clickRefViaJS(c, backendNodeID)
|
||||
}
|
||||
// Preferir el punto validado por actionability (visible + stable + hit-test):
|
||||
// evita clicks tragados por overlays/banners y elementos aún montándose o
|
||||
// animándose. Si no converge dentro del timeout, se cae al cálculo de centro
|
||||
// previo (sin regresión).
|
||||
if x, y, err := CdpWaitActionable(c, backendNodeID, false, refActionableTimeout); err == nil {
|
||||
return CdpClickXYHuman(c, x, y, opts)
|
||||
}
|
||||
// scroll al elemento si no está visible; ignorar error (no fatal)
|
||||
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
||||
cx, cy, err := refBoxCenter(c, backendNodeID)
|
||||
|
||||
@@ -8,7 +8,7 @@ purity: impure
|
||||
signature: "func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
|
||||
description: "Click humanizado (Bézier + jitter) sobre el elemento identificado por su #ref del AX outline. El #ref es el backendDOMNodeId estable del nodo DOM. Hace scroll al elemento si no está en viewport antes de calcular las coordenadas vía DOM.getBoxModel."
|
||||
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||
uses_functions: [cdp_click_xy_human_go_browser]
|
||||
uses_functions: [cdp_click_xy_human_go_browser, cdp_wait_actionable_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConsoleEntry es una entrada del log de consola/diagnostico capturada via CDP
|
||||
// durante una ventana temporal. Type clasifica el origen:
|
||||
// - "log"/"info"/"warn"/"error"/"debug" — Runtime.consoleAPICalled (console.*)
|
||||
// - "exception" — Runtime.exceptionThrown (errores JS no capturados)
|
||||
// - el level de Log.entryAdded ("verbose"/"info"/"warning"/"error") para
|
||||
// avisos del propio navegador (network, security, deprecaciones...)
|
||||
type ConsoleEntry struct {
|
||||
Type string `json:"type"` // log|info|warn|warning|error|debug|exception|verbose
|
||||
Text string `json:"text"` // mensaje legible (args concatenados / descripcion + stack)
|
||||
URL string `json:"url"` // URL del script o recurso, si Chrome lo informa
|
||||
Line int `json:"line"` // numero de linea (1-based), 0 si desconocido
|
||||
Timestamp float64 `json:"timestamp"` // CDP timestamp (monotonic seconds) o wall time
|
||||
}
|
||||
|
||||
// consoleCollectDefaultMax es el tope de entradas por defecto cuando el caller
|
||||
// pasa maxEntries <= 0. Acota la salida en paginas verbosas (setInterval ruidoso,
|
||||
// SPA que loguea sin parar) para no devolver cientos de entradas y reventar el
|
||||
// output del tool.
|
||||
const consoleCollectDefaultMax = 200
|
||||
|
||||
// CdpCollectConsole habilita los dominios Runtime y Log en la conexion, se
|
||||
// suscribe a los eventos de consola/excepcion/log del navegador y acumula todo
|
||||
// lo que ocurra durante `durationMs` milisegundos, hasta un maximo de
|
||||
// `maxEntries` entradas. Es un SNAPSHOT temporal: captura solo lo emitido dentro
|
||||
// de la ventana, no el historico previo de la pagina. Si durationMs <= 0 usa
|
||||
// 1500ms por defecto; si maxEntries <= 0 usa 200 por defecto.
|
||||
//
|
||||
// Dos defensas contra el backlog de una conexion del pool que lleva rato abierta
|
||||
// con Runtime habilitado (donde Runtime.enable flushea consoleAPICalled rezagados
|
||||
// con timestamps antiguos, y un setInterval verboso puede inundar):
|
||||
// - Filtro por timestamp: se captura `startMs` (wall time, ms epoch) JUSTO antes
|
||||
// de habilitar los dominios y solo se acumulan eventos cuyo timestamp sea >=
|
||||
// startMs. Los eventos `consoleAPICalled`/`exceptionThrown`/`Log.entryAdded`
|
||||
// traen `timestamp` en ms epoch, asi que los rezagados del flush (anteriores
|
||||
// a startMs) se descartan. Eventos sin timestamp (0) se aceptan: no hay forma
|
||||
// de fecharlos y casi siempre son nuevos.
|
||||
// - Cap por cantidad: alcanzado `maxEntries` se dejan de acumular entradas, pero
|
||||
// la funcion NO corta la ventana — sigue durmiendo hasta `durationMs` para no
|
||||
// dejar los dominios CDP en estado raro (handlers a medio drenar). Las entradas
|
||||
// posteriores al cap simplemente se descartan; el flag de truncamiento se
|
||||
// refleja como una ConsoleEntry final de Type "_truncated".
|
||||
//
|
||||
// Eventos capturados y como se mapean a ConsoleEntry.Type:
|
||||
// - Runtime.consoleAPICalled -> el `type` del evento (log/info/warning/error/...)
|
||||
// - Runtime.exceptionThrown -> "exception" (texto = descripcion + stack)
|
||||
// - Log.entryAdded -> el `level` del entry (warning/error del browser)
|
||||
//
|
||||
// Robusta ante silencio: si no llega ningun evento devuelve un slice vacio
|
||||
// (no nil, no error). La conexion debe estar abierta; la funcion no la cierra.
|
||||
func CdpCollectConsole(c *CDPConn, durationMs int, maxEntries int) ([]ConsoleEntry, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("cdp collect console: conexion nula")
|
||||
}
|
||||
if durationMs <= 0 {
|
||||
durationMs = 1500
|
||||
}
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = consoleCollectDefaultMax
|
||||
}
|
||||
|
||||
// startMs marca el inicio de la ventana en ms epoch (mismo dominio que el
|
||||
// `timestamp` de los eventos CDP). Eventos anteriores = backlog -> se descartan.
|
||||
startMs := float64(time.Now().UnixMilli())
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
entries = make([]ConsoleEntry, 0, 16)
|
||||
truncated bool
|
||||
)
|
||||
|
||||
// add intenta acumular una entrada respetando el filtro por timestamp y el cap.
|
||||
// Devuelve sin hacer nada si la entrada es backlog o si ya se alcanzo el tope.
|
||||
add := func(e ConsoleEntry) {
|
||||
// Descartar backlog: eventos fechados antes del inicio de la ventana.
|
||||
// Timestamp 0 (sin fecha) se acepta — no se puede clasificar como viejo.
|
||||
if e.Timestamp != 0 && e.Timestamp < startMs {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
if len(entries) >= maxEntries {
|
||||
truncated = true
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
entries = append(entries, e)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Helpers para extraer campos de map[string]any sin pelearse con cast.
|
||||
str := func(m map[string]any, k string) string {
|
||||
if v, ok := m[k]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
num := func(m map[string]any, k string) float64 {
|
||||
if v, ok := m[k]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// argToText convierte un RemoteObject de Runtime a una representacion legible.
|
||||
// Para primitivas usa `value`; para objetos sin value cae a `description` o
|
||||
// `unserializableValue`; ultimo recurso, el `type`.
|
||||
argToText := func(arg map[string]any) string {
|
||||
if v, ok := arg["value"]; ok && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
// objetos/arrays serializados por valor -> JSON real.
|
||||
if b, err := json.Marshal(v); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
if d := str(arg, "description"); d != "" {
|
||||
return d
|
||||
}
|
||||
if u := str(arg, "unserializableValue"); u != "" {
|
||||
return u
|
||||
}
|
||||
return str(arg, "type")
|
||||
}
|
||||
|
||||
// --- Runtime.consoleAPICalled: console.log / info / warn / error / ... ---
|
||||
cancel1 := c.OnEvent("Runtime.consoleAPICalled", func(_ string, p map[string]any) {
|
||||
entry := ConsoleEntry{
|
||||
Type: str(p, "type"),
|
||||
Timestamp: num(p, "timestamp"),
|
||||
}
|
||||
// Concatenar los args a un texto legible separado por espacios.
|
||||
if rawArgs, ok := p["args"].([]any); ok {
|
||||
parts := make([]string, 0, len(rawArgs))
|
||||
for _, ra := range rawArgs {
|
||||
if am, ok := ra.(map[string]any); ok {
|
||||
parts = append(parts, argToText(am))
|
||||
}
|
||||
}
|
||||
entry.Text = strings.Join(parts, " ")
|
||||
}
|
||||
// stackTrace -> primer frame para URL/linea.
|
||||
if st, ok := p["stackTrace"].(map[string]any); ok {
|
||||
if frames, ok := st["callFrames"].([]any); ok && len(frames) > 0 {
|
||||
if f0, ok := frames[0].(map[string]any); ok {
|
||||
entry.URL = str(f0, "url")
|
||||
// lineNumber es 0-based en CDP; +1 para ser 1-based legible.
|
||||
if ln := int(num(f0, "lineNumber")); ln >= 0 {
|
||||
entry.Line = ln + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
add(entry)
|
||||
})
|
||||
defer cancel1()
|
||||
|
||||
// --- Runtime.exceptionThrown: errores JS no capturados ---
|
||||
cancel2 := c.OnEvent("Runtime.exceptionThrown", func(_ string, p map[string]any) {
|
||||
entry := ConsoleEntry{
|
||||
Type: "exception",
|
||||
Timestamp: num(p, "timestamp"),
|
||||
}
|
||||
ed, _ := p["exceptionDetails"].(map[string]any)
|
||||
if ed != nil {
|
||||
// Texto base de la excepcion.
|
||||
text := str(ed, "text")
|
||||
// Si hay un objeto de excepcion con descripcion (stack completo), preferirlo.
|
||||
if exc, ok := ed["exception"].(map[string]any); ok {
|
||||
if desc := str(exc, "description"); desc != "" {
|
||||
if text != "" && !strings.Contains(desc, text) {
|
||||
text = text + ": " + desc
|
||||
} else {
|
||||
text = desc
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.Text = text
|
||||
entry.URL = str(ed, "url")
|
||||
// lineNumber 0-based -> 1-based.
|
||||
if ln := int(num(ed, "lineNumber")); ln >= 0 {
|
||||
entry.Line = ln + 1
|
||||
}
|
||||
// stackTrace top frame como respaldo de URL/linea.
|
||||
if entry.URL == "" {
|
||||
if st, ok := ed["stackTrace"].(map[string]any); ok {
|
||||
if frames, ok := st["callFrames"].([]any); ok && len(frames) > 0 {
|
||||
if f0, ok := frames[0].(map[string]any); ok {
|
||||
entry.URL = str(f0, "url")
|
||||
if entry.Line == 0 {
|
||||
if ln := int(num(f0, "lineNumber")); ln >= 0 {
|
||||
entry.Line = ln + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if entry.Text == "" {
|
||||
entry.Text = "uncaught exception"
|
||||
}
|
||||
add(entry)
|
||||
})
|
||||
defer cancel2()
|
||||
|
||||
// --- Log.entryAdded: avisos del propio navegador (network, security...) ---
|
||||
cancel3 := c.OnEvent("Log.entryAdded", func(_ string, p map[string]any) {
|
||||
le, _ := p["entry"].(map[string]any)
|
||||
if le == nil {
|
||||
return
|
||||
}
|
||||
// Log.entryAdded reporta `timestamp` en segundos epoch (a diferencia de
|
||||
// consoleAPICalled/exceptionThrown que lo dan en ms). Normalizar a ms para
|
||||
// que el filtro por startMs compare en el mismo dominio. Heurística: si el
|
||||
// valor parece segundos (varios órdenes por debajo de un ms epoch actual),
|
||||
// multiplicar por 1000.
|
||||
ts := num(le, "timestamp")
|
||||
if ts > 0 && ts < startMs/100 {
|
||||
ts *= 1000
|
||||
}
|
||||
entry := ConsoleEntry{
|
||||
Type: str(le, "level"), // verbose|info|warning|error
|
||||
Text: str(le, "text"),
|
||||
URL: str(le, "url"),
|
||||
Line: int(num(le, "lineNumber")),
|
||||
Timestamp: ts,
|
||||
}
|
||||
add(entry)
|
||||
})
|
||||
defer cancel3()
|
||||
|
||||
// Habilitar dominios. Runtime.enable provoca un flush de consoleAPICalled
|
||||
// rezagados; Log.enable abre el stream de avisos del navegador.
|
||||
if _, err := c.sendCDP("Runtime.enable", nil); err != nil {
|
||||
return nil, fmt.Errorf("cdp collect console: Runtime.enable: %w", err)
|
||||
}
|
||||
if _, err := c.sendCDP("Log.enable", nil); err != nil {
|
||||
// Log.enable puede no estar disponible en algunos targets; no es fatal,
|
||||
// seguimos capturando Runtime.*. Deshabilitar Runtime no hace falta.
|
||||
_ = err
|
||||
}
|
||||
// No deshabilitamos Runtime al salir: otras funciones (ej. cdp_pick_element_js)
|
||||
// dependen de consoleAPICalled. Solo cerramos Log que abrimos aqui.
|
||||
defer c.sendCDP("Log.disable", nil)
|
||||
|
||||
// Ventana de captura. No hacemos early-return al alcanzar el cap: seguimos
|
||||
// durmiendo la ventana completa para no dejar los dominios CDP a medio drenar.
|
||||
time.Sleep(time.Duration(durationMs) * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
out := make([]ConsoleEntry, len(entries))
|
||||
copy(out, entries)
|
||||
wasTruncated := truncated
|
||||
mu.Unlock()
|
||||
|
||||
// Senal de truncamiento limpia: una entrada final que el caller puede detectar
|
||||
// por Type == "_truncated" sin cambiar la forma del slice.
|
||||
if wasTruncated {
|
||||
out = append(out, ConsoleEntry{
|
||||
Type: "_truncated",
|
||||
Text: fmt.Sprintf("output truncado al alcanzar maxEntries=%d; entradas posteriores descartadas", maxEntries),
|
||||
Timestamp: float64(time.Now().UnixMilli()),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: cdp_collect_console
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "func CdpCollectConsole(c *CDPConn, durationMs int, maxEntries int) ([]ConsoleEntry, error)"
|
||||
description: "Captura un snapshot temporal del log de consola y diagnostico de una pagina Chrome via CDP. Habilita los dominios Runtime y Log, se suscribe a Runtime.consoleAPICalled (console.log/info/warn/error con args concatenados), Runtime.exceptionThrown (errores JS no capturados, type=exception con descripcion + stack) y Log.entryAdded (avisos del propio navegador: network, security, deprecaciones) y acumula todo lo que ocurra durante durationMs ms (default 1500), hasta un maximo de maxEntries entradas (default 200). Devuelve un slice de ConsoleEntry (Type, Text, URL, Line, Timestamp). Es un snapshot de la ventana, no historico previo: filtra por timestamp para descartar el backlog de eventos que una conexion del pool acumulo antes de la llamada. Si se alcanza maxEntries deja de acumular pero no corta la ventana; anade una entrada final con Type=_truncated. Robusta ante silencio: devuelve slice vacio si no llega ningun evento."
|
||||
tags: [chrome, cdp, browser, automation, console, devtools, debug, diagnostics, logs, errors, exceptions, flow-replay]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [encoding/json, fmt, strings, sync, time]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP activa (*CDPConn) contra una pestaña Chrome con el target abierto"
|
||||
- name: durationMs
|
||||
desc: "ventana de captura en milisegundos; si <=0 usa 1500ms. Es el tiempo durante el cual se acumulan eventos de consola/excepcion/log antes de devolver. La función duerme la ventana completa aunque se alcance maxEntries antes"
|
||||
- name: maxEntries
|
||||
desc: "tope de entradas a acumular; si <=0 usa 200. Al alcanzarlo se descartan las entradas posteriores (no se corta la ventana) y se añade una entrada final con Type=_truncated. Acota la salida en páginas verbosas (setInterval ruidoso, SPA que loguea sin parar)"
|
||||
output: "slice de ConsoleEntry (Type, Text, URL, Line, Timestamp) con todo lo emitido en la ventana (filtrado de backlog previo a la llamada y acotado a maxEntries); si se truncó, la última entrada tiene Type=_truncated; slice vacío (no nil, no error) si no hubo eventos; error solo si la conexión es nula o falla Runtime.enable"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_collect_console.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com")
|
||||
|
||||
// Captura todo lo que la pagina escriba en consola durante 2 segundos,
|
||||
// hasta un maximo de 100 entradas (descarta el backlog previo de la conexion).
|
||||
entries, err := CdpCollectConsole(conn, 2000, 100)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Type == "_truncated" {
|
||||
fmt.Println("...", e.Text) // se alcanzo el cap de 100 entradas
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[%s] %s (%s:%d)\n", e.Type, e.Text, e.URL, e.Line)
|
||||
}
|
||||
// Ejemplo de salida:
|
||||
// [error] Uncaught TypeError: x is not a function (https://example.com/app.js:42)
|
||||
// [warning] Mixed Content: requested an insecure resource (https://example.com:0)
|
||||
// [log] app initialized (https://example.com/app.js:5)
|
||||
|
||||
// Cap por defecto (200): pasar maxEntries <= 0.
|
||||
entries, _ = CdpCollectConsole(conn, 1500, 0)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas ver qué errores, warnings o mensajes de consola produce una página justo después de navegar o tras disparar una acción (click, submit). Úsala para depurar por qué un flujo web falla en silencio (excepción JS no capturada, recurso bloqueado por CSP/mixed-content, error de red que solo aparece en consola), para validar que una SPA arrancó sin errores, o como paso de diagnóstico dentro de un flow-replay antes de dar por bueno un replay. Llámala envolviendo la acción que quieres observar: navega/interactúa y deja que la ventana de captura recoja lo que emita.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: requiere Chrome vivo.** Necesita una conexión CDP activa (`*CDPConn`) contra una instancia de Chrome con el target abierto. No funciona sin navegador.
|
||||
- **Es un snapshot temporal, no histórico — y filtra el backlog.** Solo captura eventos emitidos DURANTE la ventana `durationMs`. La función captura `startMs` (wall time, ms epoch) justo antes de habilitar los dominios y descarta todo evento con `timestamp` anterior a ese inicio. Esto resuelve el problema real con conexiones del pool que llevan rato abiertas con `Runtime` ya habilitado: cuando `Runtime.enable` se reenvía, Chrome flushea `consoleAPICalled` rezagados con timestamps antiguos; esos backlog se descartan por el filtro. Sin el filtro, en una página verbosa o con un `setInterval` la función devolvía cientos de entradas históricas que reventaban el output. **Por qué `OnEvent` no basta:** los handlers de `OnEvent` solo reciben eventos que lleguen al `readLoop` DESPUÉS del registro, pero el flush de `Runtime.enable` llega justo después y arrastra mensajes viejos — de ahí el backlog. El filtro por timestamp es la defensa que lo separa. Si quieres capturar el arranque, conéctate y llama ANTES de navegar, o navega dentro de la ventana.
|
||||
- **Eventos sin timestamp se aceptan.** Si un evento llega con `timestamp` 0 (sin fechar) no se puede clasificar como backlog, así que se acumula. En la práctica casi siempre son nuevos.
|
||||
- **`Log.entryAdded` reporta en segundos, no ms.** A diferencia de `consoleAPICalled`/`exceptionThrown` (ms epoch), `Log.entryAdded` da `timestamp` en segundos epoch. La función lo normaliza a ms (heurística: si el valor es varios órdenes menor que un ms epoch actual, lo multiplica por 1000) para que el filtro por `startMs` compare en el mismo dominio.
|
||||
- **Cap por cantidad (`maxEntries`).** Al alcanzar `maxEntries` entradas (default 200) la función deja de acumular y descarta las posteriores, pero **NO corta la ventana** — sigue durmiendo hasta `durationMs` para no dejar los dominios CDP a medio drenar (handlers a medias) ni el estado de la conexión raro. Si se truncó, la **última** entrada del slice tiene `Type == "_truncated"` y un `Text` con el cap alcanzado; el caller debe filtrarla o tratarla como señal, no como un log real.
|
||||
- **Bloquea durante `durationMs`.** La función duerme la goroutine la ventana completa antes de devolver — no hay early-return aunque ya tengas eventos o se alcance el cap. Elige `durationMs` acorde a lo que esperas observar (1500ms default suele bastar para el load inicial).
|
||||
- **`Type` mezcla tres taxonomías.** `consoleAPICalled` usa `log|info|warning|error|debug|...`; `exceptionThrown` siempre marca `exception`; `Log.entryAdded` usa el `level` del navegador (`verbose|info|warning|error`). Filtra por substring (`warn`, `error`) si quieres agrupar severidades; nota que console.warn produce `warning`, no `warn`.
|
||||
- **`Line` es 1-based.** CDP reporta `lineNumber` 0-based; esta función suma 1 para que coincida con lo que muestran las DevTools. Los `Log.entryAdded` se dejan tal cual los da Chrome.
|
||||
- **No deshabilita `Runtime` al salir.** Otras funciones del package (ej. `cdp_pick_element_js`) dependen de `Runtime.consoleAPICalled`; deshabilitarlo rompería sus handlers. Sí cierra el dominio `Log` que abre aquí.
|
||||
- **`Log.enable` puede no estar disponible** en algunos targets (workers, ciertos contextos). Si falla, la función NO aborta: sigue capturando `Runtime.*` y solo pierde los avisos de `Log.entryAdded`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (16/06/2026) — añade parámetro `maxEntries` (cap, default 200) + filtro de backlog por timestamp. Resuelve bug real: en conexiones del pool con `Runtime` ya habilitado, el flush de `Runtime.enable` arrastraba eventos históricos (cientos en páginas verbosas con `setInterval`) que reventaban el output. Ahora se descarta lo anterior a `startMs` y se acota la salida con señal `_truncated`.
|
||||
|
||||
## Notas
|
||||
|
||||
`ConsoleEntry` se define como tipo simple del package `browser` en el mismo `.go` (igual que `HarEntry`/`HarHeader` en `cdp_har_record.go`), no como tipo del registry — evita import circular y mantiene la firma autosuficiente. La acumulación usa un `sync.Mutex` porque los handlers de `OnEvent` corren en la goroutine del `readLoop` de `CDPConn`, concurrente con la goroutine que duerme la ventana. La conversión de args de `consoleAPICalled` serializa objetos/arrays a JSON real (no la repr `%v` de Go) para que datos estructurados sean parseables.
|
||||
@@ -0,0 +1,298 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// fillNodeInfo es el diagnostico que devuelve fillPrepare tras inspeccionar y
|
||||
// preparar el nodo en el contexto JS de la pagina. Replica la logica de
|
||||
// InjectedScript.fill de Playwright sin usar el "native value setter": para los
|
||||
// campos de texto/contenteditable selecciona el contenido previo y deja que el
|
||||
// motor inserte el valor con eventos confiables (ruta needsinput); para los
|
||||
// inputs especiales fija el valor y dispara los eventos (ruta setvalue).
|
||||
type fillNodeInfo struct {
|
||||
// Route es "needsinput" (hay que insertar el valor via Input.insertText),
|
||||
// "setvalue" (ya se fijo el valor + eventos, nada mas que hacer) o "" si hubo error.
|
||||
Route string `json:"route"`
|
||||
// Error describe por que el nodo no se puede rellenar (no editable, readonly,
|
||||
// disabled, oculto, tipo no soportado). Vacio si todo OK.
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// resolveObjectID resuelve un backendDOMNodeId a un Runtime objectId, para poder
|
||||
// ejecutar JS con `this` apuntando a ese nodo concreto via Runtime.callFunctionOn.
|
||||
func resolveObjectID(c *CDPConn, backendNodeID int) (string, error) {
|
||||
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolveNode ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
obj, _ := res["object"].(map[string]any)
|
||||
objID, _ := obj["objectId"].(string)
|
||||
if objID == "" {
|
||||
return "", fmt.Errorf("sin objectId para ref %d", backendNodeID)
|
||||
}
|
||||
return objID, nil
|
||||
}
|
||||
|
||||
// callFunctionOnJSON ejecuta functionDeclaration con `this` = objectId, pasando
|
||||
// args como argumentos posicionales, y deserializa el valor de retorno (por valor)
|
||||
// en out. La funcion JS debe devolver un objeto serializable.
|
||||
func callFunctionOnJSON(c *CDPConn, objectID, functionDeclaration string, args []any, out any) error {
|
||||
callArgs := make([]any, len(args))
|
||||
for i, a := range args {
|
||||
callArgs[i] = map[string]any{"value": a}
|
||||
}
|
||||
res, err := c.sendCDP("Runtime.callFunctionOn", map[string]any{
|
||||
"objectId": objectID,
|
||||
"functionDeclaration": functionDeclaration,
|
||||
"arguments": callArgs,
|
||||
"returnByValue": true,
|
||||
"awaitPromise": true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exc, ok := res["exceptionDetails"]; ok && exc != nil {
|
||||
excMap, _ := exc.(map[string]any)
|
||||
text, _ := excMap["text"].(string)
|
||||
return fmt.Errorf("excepcion JS: %s", text)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
resVal, ok := res["result"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("resultado inesperado: %v", res)
|
||||
}
|
||||
b, err := json.Marshal(resVal["value"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal valor de retorno: %w", err)
|
||||
}
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
|
||||
// fillPrepareJS es la funcion JS (con `this` = elemento) que valida editabilidad,
|
||||
// detecta el tipo y prepara el nodo. Replica InjectedScript.fill de Playwright:
|
||||
// NO usa el native value setter para text/textarea/contenteditable (selecciona el
|
||||
// valor previo y devuelve "needsinput" para que Input.insertText, con eventos
|
||||
// confiables del motor, haga que React/Vue reconcilien solos). Para inputs
|
||||
// especiales fija el valor y dispara input/change con {bubbles, composed}.
|
||||
//
|
||||
// arg[0] = value (string).
|
||||
const fillPrepareJS = `function(value){
|
||||
var el = this;
|
||||
if (!el || el.nodeType !== 1) return {route:"", error:"el #ref no es un elemento"};
|
||||
// Visibilidad: rect con area + no display:none/visibility:hidden.
|
||||
var rect = el.getBoundingClientRect();
|
||||
var style = el.ownerDocument.defaultView.getComputedStyle(el);
|
||||
if (style.visibility === "hidden" || style.display === "none" || (rect.width === 0 && rect.height === 0))
|
||||
return {route:"", error:"elemento no visible"};
|
||||
var tag = el.nodeName.toLowerCase();
|
||||
if (tag === "input") {
|
||||
var type = (el.type || "text").toLowerCase();
|
||||
if (el.disabled) return {route:"", error:"input deshabilitado"};
|
||||
if (el.readOnly) return {route:"", error:"input es readonly"};
|
||||
var kSetValue = {color:1, date:1, time:1, "datetime-local":1, month:1, range:1, week:1};
|
||||
var kTypeInto = {"":1, email:1, number:1, password:1, search:1, tel:1, text:1, url:1};
|
||||
if (!kTypeInto[type] && !kSetValue[type])
|
||||
return {route:"", error:"input de tipo '"+type+"' no se puede rellenar"};
|
||||
if (type === "number") {
|
||||
value = value.trim();
|
||||
if (value !== "" && isNaN(Number(value)))
|
||||
return {route:"", error:"no se puede escribir texto en input[type=number]"};
|
||||
}
|
||||
if (type === "color") value = value.toLowerCase();
|
||||
if (kSetValue[type]) {
|
||||
value = value.trim();
|
||||
el.focus();
|
||||
el.value = value;
|
||||
if (el.value !== value) return {route:"", error:"valor malformado para input[type="+type+"]"};
|
||||
el.dispatchEvent(new Event("input", {bubbles:true, composed:true}));
|
||||
el.dispatchEvent(new Event("change", {bubbles:true}));
|
||||
return {route:"setvalue", error:""};
|
||||
}
|
||||
// Ruta needsinput: seleccionar el valor previo para que insertText lo reemplace.
|
||||
el.select();
|
||||
el.focus();
|
||||
return {route:"needsinput", error:""};
|
||||
}
|
||||
if (tag === "textarea") {
|
||||
if (el.disabled) return {route:"", error:"textarea deshabilitado"};
|
||||
if (el.readOnly) return {route:"", error:"textarea es readonly"};
|
||||
el.selectionStart = 0;
|
||||
el.selectionEnd = el.value.length;
|
||||
el.focus();
|
||||
return {route:"needsinput", error:""};
|
||||
}
|
||||
if (el.isContentEditable) {
|
||||
el.focus();
|
||||
var range = el.ownerDocument.createRange();
|
||||
range.selectNodeContents(el);
|
||||
var sel = el.ownerDocument.defaultView.getSelection();
|
||||
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
|
||||
return {route:"needsinput", error:""};
|
||||
}
|
||||
return {route:"", error:"el elemento no es input, textarea ni [contenteditable]"};
|
||||
}`
|
||||
|
||||
// fillVerifyJS lee el valor actual del nodo (input.value/textarea.value o
|
||||
// textContent de contenteditable) para verificar que el fill surtio efecto.
|
||||
// arg[0] = expected (string). Devuelve {ok:bool, got:string, verifiable:bool}.
|
||||
const fillVerifyJS = `function(expected){
|
||||
var el = this;
|
||||
var tag = el.nodeName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea") {
|
||||
var type = tag === "input" ? (el.type||"text").toLowerCase() : "text";
|
||||
var got = String(el.value);
|
||||
var exp = expected;
|
||||
if (type === "number" || type === "color" || type === "date" || type === "time" ||
|
||||
type === "datetime-local" || type === "month" || type === "range" || type === "week") {
|
||||
exp = expected.trim();
|
||||
if (type === "color") exp = exp.toLowerCase();
|
||||
}
|
||||
return {ok: got === exp, got: got, verifiable: true};
|
||||
}
|
||||
// contenteditable: no verificable de forma fiable (el motor normaliza el HTML).
|
||||
return {ok: true, got: String(el.textContent||""), verifiable: false};
|
||||
}`
|
||||
|
||||
// CdpFill rellena un campo de texto controlado por frameworks (React/Vue) de
|
||||
// forma robusta, estilo Playwright. backendNodeID es un backendDOMNodeId (el #ref
|
||||
// del AX outline de page_perceive).
|
||||
//
|
||||
// Comportamiento (replica InjectedScript.fill):
|
||||
// 1. Valida visible + enabled + editable (no readonly/disabled) en el contexto JS.
|
||||
// 2. Enfoca el nodo.
|
||||
// 3. Detecta el tipo:
|
||||
// - text/textarea/email/search/url/tel/password/number/contenteditable: ruta
|
||||
// "needsinput" — selecciona el valor previo y luego inserta value con
|
||||
// Input.insertText (eventos input/beforeinput confiables del motor; React/Vue
|
||||
// reconcilian solos). Con value=="" borra la seleccion (Delete) en vez de insertar.
|
||||
// - color/date/time/datetime-local/month/range/week: ruta "setvalue" — fija
|
||||
// el.value y dispara input{bubbles,composed} + change{bubbles}.
|
||||
// 4. Verifica que el.value === value al final (casos verificables); si no, error.
|
||||
//
|
||||
// A diferencia del patron focus+type que concatena al valor existente, CdpFill
|
||||
// reemplaza el contenido entero y es fiable con inputs controlados por frameworks.
|
||||
func CdpFill(c *CDPConn, backendNodeID int, value string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp fill: conexion nula")
|
||||
}
|
||||
|
||||
objID, err := resolveObjectID(c, backendNodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp fill: %w", err)
|
||||
}
|
||||
|
||||
// Enfocar el nodo (idempotente; fillPrepareJS tambien enfoca, pero DOM.focus
|
||||
// hace scroll-into-view y deja el activeElement listo para Input.insertText).
|
||||
if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil {
|
||||
return fmt.Errorf("cdp fill: focus ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
|
||||
// Validar + preparar el nodo (selecciona valor previo o fija value+eventos).
|
||||
var info fillNodeInfo
|
||||
if err := callFunctionOnJSON(c, objID, fillPrepareJS, []any{value}, &info); err != nil {
|
||||
return fmt.Errorf("cdp fill: preparar ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
if info.Error != "" {
|
||||
return fmt.Errorf("cdp fill: ref %d no editable: %s", backendNodeID, info.Error)
|
||||
}
|
||||
|
||||
switch info.Route {
|
||||
case "setvalue":
|
||||
// El valor ya se fijo y se dispararon los eventos en fillPrepareJS.
|
||||
case "needsinput":
|
||||
if value == "" {
|
||||
// Sin valor: borrar la seleccion (el valor previo ya esta seleccionado).
|
||||
// Delete elimina la seleccion sin insertar nada.
|
||||
del := map[string]any{"type": "keyDown", "key": "Delete", "code": "Delete", "windowsVirtualKeyCode": 46}
|
||||
if _, err := c.sendCDP("Input.dispatchKeyEvent", del); err != nil {
|
||||
return fmt.Errorf("cdp fill: borrar ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
delUp := map[string]any{"type": "keyUp", "key": "Delete", "code": "Delete", "windowsVirtualKeyCode": 46}
|
||||
if _, err := c.sendCDP("Input.dispatchKeyEvent", delUp); err != nil {
|
||||
return fmt.Errorf("cdp fill: borrar ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
} else {
|
||||
// Insertar el valor (reemplaza la seleccion previa) en un round-trip.
|
||||
// Input.insertText emite los eventos confiables que React/Vue necesitan.
|
||||
if _, err := c.sendCDP("Input.insertText", map[string]any{"text": value}); err != nil {
|
||||
return fmt.Errorf("cdp fill: insertText ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("cdp fill: ruta de preparacion desconocida %q para ref %d", info.Route, backendNodeID)
|
||||
}
|
||||
|
||||
// Verificar que el valor cuajo (solo casos verificables: input/textarea).
|
||||
var ver struct {
|
||||
OK bool `json:"ok"`
|
||||
Got string `json:"got"`
|
||||
Verifiable bool `json:"verifiable"`
|
||||
}
|
||||
if err := callFunctionOnJSON(c, objID, fillVerifyJS, []any{value}, &ver); err != nil {
|
||||
// La verificacion en si fallo (nodo desaparecido, etc.): no enmascarar.
|
||||
return fmt.Errorf("cdp fill: verificar ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
if ver.Verifiable && !ver.OK {
|
||||
return fmt.Errorf("cdp fill: verificacion fallida en ref %d: el campo quedo con %q, se esperaba %q", backendNodeID, ver.Got, value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CdpFillSelector resuelve un selector CSS a su backendDOMNodeId (via
|
||||
// DOM.getDocument + DOM.querySelector + DOM.describeNode) y delega en CdpFill.
|
||||
// Util cuando se tiene un selector estable en vez del #ref del AX outline.
|
||||
func CdpFillSelector(c *CDPConn, selector string, value string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp fill selector: conexion nula")
|
||||
}
|
||||
if strings.TrimSpace(selector) == "" {
|
||||
return fmt.Errorf("cdp fill selector: selector vacio")
|
||||
}
|
||||
|
||||
docRes, err := c.sendCDP("DOM.getDocument", map[string]any{"depth": 0})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp fill selector: DOM.getDocument: %w", err)
|
||||
}
|
||||
root, ok := docRes["root"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp fill selector: respuesta de DOM.getDocument sin root")
|
||||
}
|
||||
rootNodeID, ok := root["nodeId"].(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp fill selector: DOM.getDocument sin nodeId raiz")
|
||||
}
|
||||
|
||||
qsRes, err := c.sendCDP("DOM.querySelector", map[string]any{
|
||||
"nodeId": int(rootNodeID),
|
||||
"selector": selector,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp fill selector: DOM.querySelector %q: %w", selector, err)
|
||||
}
|
||||
nodeIDVal, ok := qsRes["nodeId"].(float64)
|
||||
if !ok || int(nodeIDVal) == 0 {
|
||||
return fmt.Errorf("cdp fill selector: el selector %q no coincide con ningun elemento", selector)
|
||||
}
|
||||
|
||||
// Resolver el nodeId a backendNodeId (CdpFill opera sobre backendDOMNodeId).
|
||||
descRes, err := c.sendCDP("DOM.describeNode", map[string]any{"nodeId": int(nodeIDVal)})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp fill selector: DOM.describeNode %q: %w", selector, err)
|
||||
}
|
||||
node, ok := descRes["node"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp fill selector: DOM.describeNode %q sin node", selector)
|
||||
}
|
||||
backendID, ok := node["backendNodeId"].(float64)
|
||||
if !ok || int(backendID) == 0 {
|
||||
return fmt.Errorf("cdp fill selector: %q sin backendNodeId", selector)
|
||||
}
|
||||
|
||||
return CdpFill(c, int(backendID), value)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: cdp_fill
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpFill(c *CDPConn, backendNodeID int, value string) error"
|
||||
description: "Rellena un campo de texto de forma robusta estilo Playwright, fiable con inputs controlados por frameworks (React/Vue). Valida visible+enabled+editable, enfoca el nodo, y según el tipo: para text/textarea/email/search/url/tel/password/number/contenteditable selecciona el valor previo y lo reemplaza con Input.insertText (eventos input/beforeinput confiables del motor — React/Vue reconcilian solos); para inputs especiales (color/date/time/range/week/month/datetime-local) fija el.value y dispara input{bubbles,composed}+change{bubbles}. Verifica que el.value===value al final. backendNodeID es el #ref del AX outline. Variante por selector: CdpFillSelector. Reemplaza el patrón frágil focus+type que concatena al valor existente."
|
||||
tags: [cdp, browser, action, ref, fill, form, react, vue, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa al tab objetivo (*CDPConn)."
|
||||
- name: backendNodeID
|
||||
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||
- name: value
|
||||
desc: "Valor a poner en el campo. Reemplaza el contenido entero (no concatena). value=='' borra el campo. Para input[type=number] debe ser numérico; para color se normaliza a minúsculas."
|
||||
output: "nil si el campo quedó con el valor pedido; error si la conexión es nil, el nodo no es editable (readonly/disabled/oculto), el tipo de input no se puede rellenar, o la verificación final (el.value===value) falla."
|
||||
file_path: "functions/browser/cdp_fill.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Tras un page_perceive que devuelve un <input> React con #ref=4521:
|
||||
conn, _ := CdpConnect(9222)
|
||||
|
||||
// Por #ref del AX outline (camino habitual del bucle percibir→actuar):
|
||||
if err := CdpFill(conn, 4521, "ada@example.com"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Por selector CSS estable (resuelve a backendNodeID y delega en CdpFill):
|
||||
if err := CdpFillSelector(conn, "input[name='email']", "ada@example.com"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Vaciar un campo:
|
||||
_ = CdpFillSelector(conn, "#search", "")
|
||||
|
||||
// Input especial (date): ruta setvalue + eventos input/change:
|
||||
_ = CdpFillSelector(conn, "input[type='date']", "2026-06-16")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites rellenar inputs de formularios controlados por React/Vue/otros frameworks de forma fiable. Es el reemplazo del patrón `DOM.focus` + `CdpTypeText`/`CdpInsertText` que **concatena** al valor existente y a menudo deja el estado del framework desincronizado (el `value` del DOM cambia pero el estado de React no, o al revés). `CdpFill` selecciona y reemplaza el contenido entero y, al usar `Input.insertText` (no el native value setter), emite los eventos `input`/`beforeinput` confiables que hacen que el framework reconcilie su estado. Úsala para login, registro, búsquedas y cualquier campo donde el patrón focus+type falle o duplique texto. Para teclear carácter a carácter simulando un humano (sitios con detección por pulsación o autocompletes estrictos) sigue prefiriendo `CdpTypeRef` (camino human).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir (`page_perceive`) antes de actuar.
|
||||
- **contenteditable**: la ruta needsinput inserta el valor seleccionando todo el contenido, pero la verificación final **no es fiable** para contenteditable (el motor normaliza el HTML). Por eso para contenteditable `CdpFill` no falla por verificación; confía en que `Input.insertText` cuajó. Si necesitas garantía dura del contenido, léelo aparte con `CdpEvaluate`.
|
||||
- **Inputs especiales** (color/date/time/datetime-local/month/range/week) van por la ruta setvalue: fijan `el.value` y disparan `input`{bubbles,composed}+`change`{bubbles}. Algunos frameworks que escuchan eventos de teclado en estos inputs pueden no reaccionar — es el mismo trade-off que hace Playwright.
|
||||
- **input[type=number]**: el valor debe ser numérico (`isNaN` lo rechaza con error claro). Espacios se recortan.
|
||||
- **Frameworks y el evento nativo**: la clave de la robustez es NO usar el "native value setter" (`Object.getOwnPropertyDescriptor(...).set`). React parchea el setter de `value` y se confunde si lo invocas a mano; `Input.insertText` del motor emite los eventos que React intercepta correctamente. Si una versión muy vieja de un framework custom no reacciona, cae a `CdpTypeRef` (char por char).
|
||||
- **No hace scroll humanizado**: `DOM.focus` hace scroll-into-view del nodo, pero si el input está dentro de un contenedor con scroll propio y oculto, valida visible y puede fallar con "elemento no visible". En ese caso haz `CdpClickRef` (que hace `scrollIntoViewIfNeeded`) antes.
|
||||
- **value==""** borra el campo enviando `Delete` sobre la selección previa (no `Input.insertText` con cadena vacía, que sería no-op). Esto dispara los eventos de borrado que el framework espera.
|
||||
@@ -0,0 +1,191 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CdpFindByRoleOpts configura el matching del accessible name de CdpFindByRole.
|
||||
// Si Name == "", solo se filtra por role (cualquier name vale).
|
||||
type CdpFindByRoleOpts struct {
|
||||
// Name es el accessible name a matchear. Vacio = no filtra por name.
|
||||
Name string
|
||||
// Exact: true = el name normalizado debe ser igual al buscado.
|
||||
// false (default) = el name normalizado contiene el buscado (substring).
|
||||
Exact bool
|
||||
// Regex: true = Name se interpreta como expresion regular (RE2 de Go).
|
||||
// Tiene prioridad sobre Exact si ambos estan a true.
|
||||
Regex bool
|
||||
// CaseSensitive: false (default) = comparacion insensible a mayusculas.
|
||||
// Para Regex, false añade el flag (?i) a la expresion.
|
||||
CaseSensitive bool
|
||||
}
|
||||
|
||||
// normalizeWhiteSpace replica la regla de Playwright (utils/isomorphic/stringUtils.ts):
|
||||
// elimina el zero-width space (U+200B) y el soft hyphen (U+00AD), recorta extremos y
|
||||
// colapsa cualquier run de whitespace a un unico espacio. Es la normalizacion que
|
||||
// Playwright aplica a ambos lados al comparar el accessible name (getByRole({name})),
|
||||
// para que diferencias de whitespace/caracteres invisibles no rompan el match.
|
||||
func normalizeWhiteSpace(s string) string {
|
||||
// Strip zero-width space y soft hyphen.
|
||||
s = strings.ReplaceAll(s, "", "")
|
||||
s = strings.ReplaceAll(s, "", "")
|
||||
// Colapsar runs de whitespace a un espacio.
|
||||
s = whitespaceRun.ReplaceAllString(s, " ")
|
||||
// Trim de extremos.
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// whitespaceRun matchea uno o mas caracteres de espacio en blanco. Equivale a
|
||||
// `\s+` de la regex de normalizeWhiteSpace de Playwright.
|
||||
var whitespaceRun = regexp.MustCompile(`\s+`)
|
||||
|
||||
// CdpFindByRole localiza el primer elemento por su ROLE ARIA y, opcionalmente, su
|
||||
// accessible name — el equivalente a getByRole de Playwright. Reutiliza el AX tree
|
||||
// que ya pedimos para page_perceive (Accessibility.getFullAXTree) en vez de tocar el
|
||||
// DOM/CSS, lo que la hace robusta a cambios de markup/estilos.
|
||||
//
|
||||
// Recorre los nodos del AX tree y matchea:
|
||||
// - role: igualdad exacta del rol ARIA (ej "button", "link", "textbox").
|
||||
// - name (si opts.Name != ""): el accessible name del nodo contra opts.Name, con
|
||||
// normalizeWhiteSpace aplicado a ambos lados (regla Playwright). Por defecto es
|
||||
// substring; Exact => igualdad; Regex => expresion regular. Insensible a
|
||||
// mayusculas salvo CaseSensitive.
|
||||
//
|
||||
// Retorna (ref, count, error):
|
||||
// - ref: backendDOMNodeId del primer match — el mismo #ref que produce el outline
|
||||
// de page_perceive y que consume CdpClickRef/CdpHoverRef.
|
||||
// - count: numero total de nodos que matchean. count > 1 indica ambiguedad: el
|
||||
// caller decide si refinar (Name mas especifico, Exact, etc.).
|
||||
// - error: conexion nula, role vacio, regex invalida, fallo CDP, o 0 matches.
|
||||
func CdpFindByRole(c *CDPConn, role string, opts CdpFindByRoleOpts) (ref int, count int, err error) {
|
||||
if c == nil {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: conexion nula")
|
||||
}
|
||||
if role == "" {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: role vacio")
|
||||
}
|
||||
|
||||
// Construir el matcher del name una sola vez (compila la regex si aplica).
|
||||
matchName, err := buildNameMatcher(opts)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: %w", err)
|
||||
}
|
||||
|
||||
// Accessibility.enable (idempotente, cacheado) antes de getFullAXTree.
|
||||
if err := c.ensureAX(); err != nil {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: Accessibility.enable: %w", err)
|
||||
}
|
||||
|
||||
res, err := c.sendCDP("Accessibility.getFullAXTree", nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: Accessibility.getFullAXTree: %w", err)
|
||||
}
|
||||
|
||||
nodes := axoParseNodes(res)
|
||||
|
||||
firstRef := 0
|
||||
haveFirst := false
|
||||
for _, n := range nodes {
|
||||
if n.ignored {
|
||||
continue
|
||||
}
|
||||
if n.role != role {
|
||||
continue
|
||||
}
|
||||
if opts.Name != "" && !matchName(n.name) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if !haveFirst {
|
||||
// axoRefID prefiere backendDOMNodeID; ese es el ref que consume CdpClickRef.
|
||||
if id, ok := atoiRef(axoRefID(n)); ok {
|
||||
firstRef = id
|
||||
haveFirst = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
if opts.Name != "" {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: no element with role %q and name %q", role, opts.Name)
|
||||
}
|
||||
return 0, 0, fmt.Errorf("cdp find by role: no element with role %q", role)
|
||||
}
|
||||
if !haveFirst {
|
||||
// Hubo matches pero ninguno tenia un ref entero usable (backendDOMNodeId
|
||||
// ausente y nodeId no numerico): no podemos devolver un #ref valido.
|
||||
return 0, count, fmt.Errorf("cdp find by role: %d match(es) para role %q pero sin backendDOMNodeId usable", count, role)
|
||||
}
|
||||
return firstRef, count, nil
|
||||
}
|
||||
|
||||
// buildNameMatcher devuelve la funcion que decide si un accessible name candidato
|
||||
// matchea opts.Name, normalizando ambos lados con normalizeWhiteSpace. Si Name == ""
|
||||
// el matcher siempre es true (no se filtra por name). Compila la regex una vez.
|
||||
func buildNameMatcher(opts CdpFindByRoleOpts) (func(candidate string) bool, error) {
|
||||
if opts.Name == "" {
|
||||
return func(string) bool { return true }, nil
|
||||
}
|
||||
|
||||
want := normalizeWhiteSpace(opts.Name)
|
||||
|
||||
if opts.Regex {
|
||||
pat := opts.Name
|
||||
if !opts.CaseSensitive {
|
||||
pat = "(?i)" + pat
|
||||
}
|
||||
re, err := regexp.Compile(pat)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("regex invalida %q: %w", opts.Name, err)
|
||||
}
|
||||
return func(candidate string) bool {
|
||||
return re.MatchString(normalizeWhiteSpace(candidate))
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !opts.CaseSensitive {
|
||||
want = strings.ToLower(want)
|
||||
}
|
||||
|
||||
return func(candidate string) bool {
|
||||
got := normalizeWhiteSpace(candidate)
|
||||
if !opts.CaseSensitive {
|
||||
got = strings.ToLower(got)
|
||||
}
|
||||
if opts.Exact {
|
||||
return got == want
|
||||
}
|
||||
return strings.Contains(got, want)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// atoiRef convierte el ref string (backendDOMNodeId, ya normalizado a entero-string
|
||||
// por axoStr) a int. Devuelve (0, false) si no es un entero parseable.
|
||||
func atoiRef(s string) (int, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
neg := false
|
||||
i := 0
|
||||
if s[0] == '-' {
|
||||
neg = true
|
||||
i = 1
|
||||
if len(s) == 1 {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
n := 0
|
||||
for ; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, false
|
||||
}
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: cdp_find_by_role
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpFindByRole(c *CDPConn, role string, opts CdpFindByRoleOpts) (ref int, count int, err error)"
|
||||
description: "Localiza el primer elemento por su ROLE ARIA + accessible name (estilo getByRole de Playwright) reusando el AX tree (Accessibility.getFullAXTree). Devuelve el backendDOMNodeId (#ref) del primer match y el total de matches para detectar ambiguedad."
|
||||
tags: [browser]
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexion CDP viva (*CDPConn) del pool. nil => error."
|
||||
- name: role
|
||||
desc: "Rol ARIA exacto a matchear (ej 'button', 'link', 'textbox', 'checkbox')."
|
||||
- name: opts
|
||||
desc: "CdpFindByRoleOpts: Name (accessible name, vacio = no filtra), Exact (igualdad en vez de substring), Regex (Name como expresion regular RE2), CaseSensitive (default false)."
|
||||
output: "(ref int, count int, err error): ref = backendDOMNodeId del primer match (#ref para CdpClickRef/CdpHoverRef); count = total de matches (>1 = ambiguo); err si conexion nula, role vacio, regex invalida, fallo CDP o 0 matches."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_find_by_role.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
c, _ := browser.CdpConnect(9333) // conexion CDP del pool
|
||||
ref, count, err := browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{
|
||||
Name: "Aceptar", // substring del accessible name, case-insensitive
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err) // ej: no element with role "button" and name "Aceptar"
|
||||
}
|
||||
if count > 1 {
|
||||
log.Printf("aviso: %d botones matchean 'Aceptar', usando el primero", count)
|
||||
}
|
||||
// ref es el mismo #ref que produce page_perceive: alimentarlo a CdpClickRef.
|
||||
_ = browser.CdpClickRef(c, ref, browser.MouseHumanOpts{})
|
||||
|
||||
// Match exacto + case-sensitive:
|
||||
ref, _, _ = browser.CdpFindByRole(c, "link", browser.CdpFindByRoleOpts{
|
||||
Name: "Iniciar sesion", Exact: true, CaseSensitive: true,
|
||||
})
|
||||
|
||||
// Match por regex (ej "Eliminar 3 elementos" / "Eliminar 12 elementos"):
|
||||
ref, _, _ = browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{
|
||||
Name: `^Eliminar \d+ elementos$`, Regex: true,
|
||||
})
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites localizar un control de forma robusta a cambios de DOM/CSS: el rol
|
||||
ARIA + accessible name sobreviven a refactors de markup y clases CSS que romperian un
|
||||
selector `nth-of-type`. Es el patron primario que recomienda Playwright (getByRole)
|
||||
para encontrar elementos accionables (botones, links, inputs). Combina el `ref`
|
||||
devuelto directamente con `cdp_click_ref` / `cdp_hover_ref` para actuar sin pasar por
|
||||
un selector fragil. Revisa `count` antes de actuar: si es >1 la busqueda es ambigua
|
||||
y conviene refinar (Name mas especifico, Exact, o Regex anclada).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `name` que se matchea es el **accessible name computado** por el motor de
|
||||
accesibilidad de Chrome (deriva de aria-label, label asociado, contenido, alt,
|
||||
title segun la spec ARIA), **no** el `innerText` del elemento. Si buscas por el
|
||||
texto visible literal, usa `cdp_find_ref_by_text` en su lugar.
|
||||
- `count > 1` => ambiguedad: se devuelve el primer match en orden del AX tree, que no
|
||||
siempre es el visualmente primero ni el que quieres. Refina la busqueda.
|
||||
- El `role` se compara por **igualdad exacta** del rol ARIA: "button" no matchea
|
||||
"menuitem" aunque ambos sean clicables. Mira el outline de `page_perceive` /
|
||||
`cdp_get_ax_outline` para ver el rol real que Chrome asigna a cada nodo.
|
||||
- Nodos `ignored` del AX tree se descartan. Si el elemento esta oculto (aria-hidden,
|
||||
display:none) puede no aparecer y dar 0 matches.
|
||||
- El `ref` es un `backendDOMNodeId`: estable mientras el nodo viva, pero si el DOM
|
||||
muta entre el find y el click el ref puede quedar obsoleto.
|
||||
@@ -9,6 +9,10 @@ func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp hover ref: conexión nil")
|
||||
}
|
||||
// Preferir el punto validado por actionability; si no converge, caer al centro.
|
||||
if x, y, err := CdpWaitActionable(c, backendNodeID, false, refActionableTimeout); err == nil {
|
||||
return CdpMoveMouseHuman(c, x, y, opts)
|
||||
}
|
||||
// scroll al elemento si no está visible; ignorar error (no fatal)
|
||||
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
||||
cx, cy, err := refBoxCenter(c, backendNodeID)
|
||||
|
||||
@@ -8,7 +8,7 @@ purity: impure
|
||||
signature: "func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
|
||||
description: "Mueve el ratón con trayectoria humanizada (Bézier) sobre el elemento identificado por su #ref del AX outline. Útil para activar menús desplegables, tooltips y cualquier interacción que dependa de hover. El #ref es el backendDOMNodeId estable del nodo DOM."
|
||||
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||
uses_functions: [cdp_move_mouse_human_go_browser]
|
||||
uses_functions: [cdp_move_mouse_human_go_browser, cdp_wait_actionable_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CdpPrintPDFOpts configura la generacion del PDF via Page.printToPDF.
|
||||
type CdpPrintPDFOpts struct {
|
||||
// Landscape orienta la pagina en horizontal cuando es true (vertical por defecto).
|
||||
Landscape bool
|
||||
// PrintBackground incluye los graficos de fondo (colores e imagenes CSS) cuando es true.
|
||||
PrintBackground bool
|
||||
// Scale es el factor de escala del renderizado (1.0 = tamano natural).
|
||||
// Si es <= 0 se usa 1.0. Chrome acepta el rango [0.1, 2].
|
||||
Scale float64
|
||||
// PaperWidthIn es el ancho del papel en pulgadas. 0 deja el default del navegador (8.5in).
|
||||
PaperWidthIn float64
|
||||
// PaperHeightIn es el alto del papel en pulgadas. 0 deja el default del navegador (11in).
|
||||
PaperHeightIn float64
|
||||
}
|
||||
|
||||
// CdpPrintPDF genera un PDF de la pagina actual via el metodo CDP Page.printToPDF
|
||||
// y devuelve los bytes del PDF ya decodificados, sin tocar el disco.
|
||||
//
|
||||
// Usa transferMode "ReturnAsBase64" (el default de CDP): Chrome devuelve el PDF
|
||||
// completo como string base64 en el campo "data" de la respuesta, que esta
|
||||
// funcion decodifica a []byte. Es robusto ante paginas grandes porque sendCDP
|
||||
// espera la respuesta completa por el WebSocket antes de decodificar.
|
||||
//
|
||||
// Las opciones se traducen a los params de Page.printToPDF: Landscape,
|
||||
// PrintBackground y Scale siempre se envian (con Scale forzado a 1.0 si opts pide
|
||||
// <= 0). PaperWidthIn/PaperHeightIn solo se envian cuando son > 0, dejando el
|
||||
// tamano de papel por defecto del navegador en caso contrario.
|
||||
//
|
||||
// Es la primitiva reutilizable de impresion a PDF: util para devolver el PDF al
|
||||
// LLM como document content (bytes) o para que un caller lo persista a disco.
|
||||
func CdpPrintPDF(c *CDPConn, opts CdpPrintPDFOpts) ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("cdp print pdf: conexion nula")
|
||||
}
|
||||
|
||||
scale := opts.Scale
|
||||
if scale <= 0 {
|
||||
scale = 1.0
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"transferMode": "ReturnAsBase64",
|
||||
"landscape": opts.Landscape,
|
||||
"printBackground": opts.PrintBackground,
|
||||
"scale": scale,
|
||||
}
|
||||
if opts.PaperWidthIn > 0 {
|
||||
params["paperWidth"] = opts.PaperWidthIn
|
||||
}
|
||||
if opts.PaperHeightIn > 0 {
|
||||
params["paperHeight"] = opts.PaperHeightIn
|
||||
}
|
||||
|
||||
result, err := c.sendCDP("Page.printToPDF", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp print pdf: %w", err)
|
||||
}
|
||||
|
||||
dataStr, ok := result["data"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cdp print pdf: campo data ausente en respuesta")
|
||||
}
|
||||
|
||||
pdfData, err := base64.StdEncoding.DecodeString(dataStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp print pdf: decodificar base64: %w", err)
|
||||
}
|
||||
|
||||
return pdfData, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: cdp_print_pdf
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpPrintPDF(c *CDPConn, opts CdpPrintPDFOpts) ([]byte, error)"
|
||||
description: "Genera un PDF de la pagina actual via el metodo CDP Page.printToPDF y devuelve los bytes ya decodificados, sin tocar el disco. Usa transferMode ReturnAsBase64 (Chrome devuelve el PDF como base64 en el campo data) y lo decodifica a []byte. Aplica las opciones a los params: Landscape, PrintBackground y Scale siempre (Scale forzado a 1.0 si opts pide <= 0); PaperWidthIn/PaperHeightIn solo cuando son > 0, dejando el tamano de papel por defecto del navegador en caso contrario. Robusto ante paginas grandes. Primitiva reutilizable para devolver el PDF al LLM como document content o persistirlo a disco."
|
||||
tags: [chrome, cdp, browser, automation, pdf, print, printToPDF, devtools, document, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [encoding/base64, fmt]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP activa (*CDPConn) contra Chrome con el target abierto"
|
||||
- name: opts
|
||||
desc: "opciones de impresión (Landscape, PrintBackground, Scale, PaperWidthIn, PaperHeightIn en pulgadas)"
|
||||
output: "bytes del PDF decodificados desde base64, o error si falla la generación o la decodificación"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_print_pdf.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com")
|
||||
|
||||
pdfData, err := CdpPrintPDF(conn, CdpPrintPDFOpts{
|
||||
Landscape: false,
|
||||
PrintBackground: true,
|
||||
Scale: 1.0,
|
||||
PaperWidthIn: 8.27, // A4
|
||||
PaperHeightIn: 11.69, // A4
|
||||
})
|
||||
// pdfData: bytes del PDF listos para escribir a disco o devolver al LLM
|
||||
// os.WriteFile("example.pdf", pdfData, 0644)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas el PDF de la página actual en memoria: para devolverlo al LLM como document content (bytes), para archivar el render de una página (factura, informe, dashboard) o como primitiva sobre la que un caller compone la escritura a disco. Úsala tras `CdpNavigate` + espera de carga (`CdpWaitIdle`) para asegurar que el contenido está renderizado antes de imprimir.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: requiere Chrome vivo**: necesita una conexión CDP activa (`*CDPConn`) contra una instancia de Chrome con el target abierto. No funciona sin navegador.
|
||||
- **Solo en modo headless completo de impresión**: `Page.printToPDF` funciona de forma fiable en Chrome headless. En modo headed (con UI), algunas builds de Chrome devuelven `PrintToPDF is not implemented`; si lo necesitas con UI, lanza Chrome con `--headless=new`.
|
||||
- **Scale fuera de rango**: Chrome acepta `scale` en `[0.1, 2]`. Esta función fuerza `1.0` cuando `opts.Scale <= 0`, pero no recorta valores válidos fuera de rango — si pasas `5.0`, Chrome puede rechazar el comando con error.
|
||||
- **Paper en pulgadas**: `PaperWidthIn`/`PaperHeightIn` son pulgadas (la unidad nativa de CDP), no mm. A4 ≈ 8.27 × 11.69 in, Letter = 8.5 × 11 in. `0` deja el default del navegador (Letter).
|
||||
- **Contenido lazy-load / dinámico**: `printToPDF` captura el DOM en el instante de la llamada. Si la página carga contenido al hacer scroll o por JS diferido, espera a que termine (scroll + `CdpWaitIdle`) antes de imprimir.
|
||||
- **PrintBackground apagado por defecto**: igual que el diálogo de impresión de Chrome, los fondos CSS (colores e imágenes) no salen salvo que pongas `PrintBackground: true`.
|
||||
|
||||
## Notas
|
||||
|
||||
Adición al dominio `browser` (estilo CDP del paquete): el `.go` vive junto a las demás funciones `cdp_*.go` en el mismo paquete `browser`. El struct `CdpPrintPDFOpts` se define en el mismo archivo. Chrome retorna el PDF como base64 (`transferMode: "ReturnAsBase64"`, el default de CDP); esta función lo decodifica a `[]byte` y lo devuelve sin escribir a disco — el caller decide el destino. Patrón gemelo de `CdpScreenshotBytes` para el caso de impresión a PDF.
|
||||
@@ -0,0 +1,275 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CdpDropdownOpts configura la seleccion en un desplegable custom (no nativo).
|
||||
type CdpDropdownOpts struct {
|
||||
// Exact: true = el texto de la opcion debe ser igual (tras normalizar) a
|
||||
// optionText. false (default) = match por substring. La comparacion siempre
|
||||
// es case-insensitive y sobre el texto normalizado (trim + colapsar espacios).
|
||||
Exact bool
|
||||
// TimeoutMs es el tope de espera (ms) para que el listbox monte/anime y la
|
||||
// opcion aparezca visible. <=0 usa el default 3000.
|
||||
TimeoutMs int
|
||||
// OptionRole es el rol ARIA de las opciones a buscar ("option" por defecto).
|
||||
// Usar "menuitem" para menus tipo dropdown-menu, "treeitem" para arboles, etc.
|
||||
OptionRole string
|
||||
}
|
||||
|
||||
// CdpSelectDropdown selecciona una opcion en un DESPLEGABLE CUSTOM (combobox/listbox
|
||||
// ARIA, react-select, MUI Select, headlessui, select2, ...) — esos en los que un
|
||||
// <select> nativo NO aplica y por tanto CdpSelectOption no sirve.
|
||||
//
|
||||
// El patron replica como Playwright compone la accion (no tiene API para custom
|
||||
// dropdowns): click(trigger) -> esperar apertura -> getByRole('option', {name}) ->
|
||||
// click(option). Pasos:
|
||||
//
|
||||
// 1. Localiza el trigger por triggerSelector (CSS) y hace CLICK REAL (mouse
|
||||
// mousePressed/mouseReleased sobre el centro del bbox, no element.click() JS):
|
||||
// muchos dropdowns escuchan 'mousedown', no 'click'.
|
||||
// 2. Espera la apertura (polling hasta TimeoutMs): el trigger pasa a
|
||||
// aria-expanded="true", O aparece un [role=listbox]/[role=menu] visible, O hay
|
||||
// elementos con el rol de opcion (OptionRole / li[role] / menuitem) con rect>0.
|
||||
// No avanza hasta que haya opciones visibles.
|
||||
// 3. Localiza la opcion cuyo texto normalizado (trim + colapsar espacios)
|
||||
// coincide con optionText (substring si Exact=false, igualdad si Exact=true),
|
||||
// entre las opciones con rol visibles. Error claro si no aparece en el timeout.
|
||||
// 4. CLICK REAL en el centro de esa opcion.
|
||||
// 5. Verifica el cierre/seleccion: aria-expanded vuelve a false O el trigger
|
||||
// refleja el texto elegido; si la verificacion es ambigua, intenta Enter como
|
||||
// fallback suave. No falla duro si el click se hizo pero la verificacion queda
|
||||
// incierta.
|
||||
//
|
||||
// purity: impure (DOM + input real + tiempo). Devuelve error si el trigger no
|
||||
// existe, si el dropdown no abre en el timeout, o si la opcion no aparece.
|
||||
func CdpSelectDropdown(c *CDPConn, triggerSelector string, optionText string, opts CdpDropdownOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp select dropdown: conexion nula")
|
||||
}
|
||||
if strings.TrimSpace(triggerSelector) == "" {
|
||||
return fmt.Errorf("cdp select dropdown: triggerSelector vacio")
|
||||
}
|
||||
if strings.TrimSpace(optionText) == "" {
|
||||
return fmt.Errorf("cdp select dropdown: optionText vacio")
|
||||
}
|
||||
|
||||
timeoutMs := opts.TimeoutMs
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = 3000
|
||||
}
|
||||
optionRole := strings.TrimSpace(opts.OptionRole)
|
||||
if optionRole == "" {
|
||||
optionRole = "option"
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
|
||||
|
||||
// 1. Click REAL en el trigger.
|
||||
if err := dropdownClickSelector(c, triggerSelector); err != nil {
|
||||
return fmt.Errorf("cdp select dropdown: click trigger %q: %w", triggerSelector, err)
|
||||
}
|
||||
|
||||
// 2. Esperar apertura (opciones visibles).
|
||||
if err := dropdownWaitOpen(c, triggerSelector, optionRole, deadline); err != nil {
|
||||
return fmt.Errorf("cdp select dropdown: %w", err)
|
||||
}
|
||||
|
||||
// 3 + 4. Localizar la opcion por texto y click REAL en su centro.
|
||||
cx, cy, err := dropdownFindOptionCenter(c, optionRole, optionText, opts.Exact, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp select dropdown: %w", err)
|
||||
}
|
||||
if err := CdpClickXYHuman(c, cx, cy, MouseHumanOpts{Mode: "auto"}); err != nil {
|
||||
return fmt.Errorf("cdp select dropdown: click opcion %q: %w", optionText, err)
|
||||
}
|
||||
|
||||
// 5. Verificacion suave: dar un instante a que se cierre/refleje, y si sigue
|
||||
// abierto intentar Enter (algunos comboboxes confirman con Enter sobre la
|
||||
// opcion activa). No es fatal si la verificacion queda ambigua.
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
if dropdownStillOpen(c, triggerSelector, optionRole) {
|
||||
_ = CdpPressKey(c, "Enter")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropdownClickSelector resuelve el bbox del elemento (por selector CSS) y hace
|
||||
// click real sobre su centro. Hace scroll si hace falta. Cae a element.click() JS
|
||||
// solo si el nodo no tiene geometria (display:contents, area 0).
|
||||
func dropdownClickSelector(c *CDPConn, selector string) error {
|
||||
// Centro del bbox del elemento via getBoundingClientRect en el contexto JS.
|
||||
js := fmt.Sprintf(`(function(){
|
||||
var el = document.querySelector(%s);
|
||||
if (!el) return '__NO_EL__';
|
||||
el.scrollIntoView({block:'center', inline:'center'});
|
||||
var r = el.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return '__NO_BOX__';
|
||||
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
|
||||
})()`, jsString(selector))
|
||||
|
||||
res, err := CdpEvaluate(c, js)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolver bbox: %w", err)
|
||||
}
|
||||
res = strings.Trim(res, `"`)
|
||||
switch res {
|
||||
case "__NO_EL__":
|
||||
return fmt.Errorf("trigger no encontrado para selector %q", selector)
|
||||
case "__NO_BOX__":
|
||||
// Sin geometria: fallback a element.click() JS (no dispara mousedown real).
|
||||
return dropdownClickViaJS(c, selector)
|
||||
}
|
||||
|
||||
x, y, ok := parseXY(res)
|
||||
if !ok {
|
||||
return fmt.Errorf("bbox invalido %q", res)
|
||||
}
|
||||
return CdpClickXYHuman(c, x, y, MouseHumanOpts{Mode: "auto"})
|
||||
}
|
||||
|
||||
// dropdownClickViaJS es el fallback sin geometria: element.click() en el contexto JS.
|
||||
func dropdownClickViaJS(c *CDPConn, selector string) error {
|
||||
js := fmt.Sprintf(`(function(){
|
||||
var el = document.querySelector(%s);
|
||||
if (!el) return '__NO_EL__';
|
||||
el.click();
|
||||
return '__OK__';
|
||||
})()`, jsString(selector))
|
||||
res, err := CdpEvaluate(c, js)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Trim(res, `"`) != "__OK__" {
|
||||
return fmt.Errorf("element.click() JS fallo (%s)", strings.Trim(res, `"`))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropdownWaitOpen hace polling hasta deadline esperando que el dropdown este
|
||||
// abierto: trigger con aria-expanded="true", O un [role=listbox]/[role=menu]
|
||||
// visible, O algun elemento con el rol de opcion (rect>0). Error si no abre.
|
||||
func dropdownWaitOpen(c *CDPConn, triggerSelector, optionRole string, deadline time.Time) error {
|
||||
for {
|
||||
open, err := dropdownIsOpen(c, triggerSelector, optionRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if open {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("el dropdown no abrio (sin opciones visibles) tras el timeout para trigger %q", triggerSelector)
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// dropdownIsOpen comprueba una vez si el dropdown esta abierto.
|
||||
func dropdownIsOpen(c *CDPConn, triggerSelector, optionRole string) (bool, error) {
|
||||
js := fmt.Sprintf(`(function(){
|
||||
var trigger = document.querySelector(%s);
|
||||
if (trigger && trigger.getAttribute('aria-expanded') === 'true') return 'open';
|
||||
function visible(el){
|
||||
if (!el) return false;
|
||||
var r = el.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return false;
|
||||
var cs = getComputedStyle(el);
|
||||
if (cs.visibility === 'hidden' || cs.display === 'none') return false;
|
||||
return true;
|
||||
}
|
||||
// Un contenedor listbox/menu visible cuenta como abierto.
|
||||
var containers = document.querySelectorAll('[role=listbox],[role=menu]');
|
||||
for (var i=0;i<containers.length;i++){ if (visible(containers[i])) return 'open'; }
|
||||
// O al menos una opcion (por rol o por li[role]) visible.
|
||||
var role = %s;
|
||||
var sel = '[role=' + role + '],li[role],[role=menuitem]';
|
||||
var opts = document.querySelectorAll(sel);
|
||||
for (var j=0;j<opts.length;j++){ if (visible(opts[j])) return 'open'; }
|
||||
return 'closed';
|
||||
})()`, jsString(triggerSelector), jsString(optionRole))
|
||||
|
||||
res, err := CdpEvaluate(c, js)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("comprobar apertura: %w", err)
|
||||
}
|
||||
return strings.Trim(res, `"`) == "open", nil
|
||||
}
|
||||
|
||||
// dropdownStillOpen es una comprobacion best-effort para la verificacion final;
|
||||
// nunca propaga error (un fallo aqui no debe invalidar el click ya hecho).
|
||||
func dropdownStillOpen(c *CDPConn, triggerSelector, optionRole string) bool {
|
||||
open, err := dropdownIsOpen(c, triggerSelector, optionRole)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
// dropdownFindOptionCenter localiza, entre las opciones visibles del dropdown, la
|
||||
// que matchea optionText (substring si exact=false, igualdad si exact=true; ambas
|
||||
// case-insensitive sobre texto normalizado) y devuelve el centro de su bbox. Hace
|
||||
// polling hasta deadline para tolerar listas virtualizadas que montan tarde.
|
||||
func dropdownFindOptionCenter(c *CDPConn, optionRole, optionText string, exact bool, deadline time.Time) (float64, float64, error) {
|
||||
js := fmt.Sprintf(`(function(){
|
||||
var role = %s;
|
||||
var want = %s;
|
||||
var exact = %t;
|
||||
function norm(v){ return (v||'').replace(/\s+/g,' ').trim().toLowerCase(); }
|
||||
function visible(el){
|
||||
var r = el.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return false;
|
||||
var cs = getComputedStyle(el);
|
||||
if (cs.visibility === 'hidden' || cs.display === 'none') return false;
|
||||
return true;
|
||||
}
|
||||
var target = norm(want);
|
||||
var sel = '[role=' + role + '],li[role],[role=menuitem]';
|
||||
var nodes = document.querySelectorAll(sel);
|
||||
for (var i=0;i<nodes.length;i++){
|
||||
var el = nodes[i];
|
||||
if (!visible(el)) continue;
|
||||
var t = norm(el.innerText || el.textContent || '');
|
||||
var ok = exact ? (t === target) : (t.indexOf(target) >= 0);
|
||||
if (ok){
|
||||
var r = el.getBoundingClientRect();
|
||||
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
|
||||
}
|
||||
}
|
||||
return '__NO_OPTION__';
|
||||
})()`, jsString(optionRole), jsString(optionText), exact)
|
||||
|
||||
for {
|
||||
res, err := CdpEvaluate(c, js)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("buscar opcion: %w", err)
|
||||
}
|
||||
res = strings.Trim(res, `"`)
|
||||
if res != "__NO_OPTION__" {
|
||||
if x, y, ok := parseXY(res); ok {
|
||||
return x, y, nil
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return 0, 0, fmt.Errorf("option %q not found in dropdown", optionText)
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// parseXY extrae x/y de un JSON {"x":..,"y":..} que llega ya des-escapado de
|
||||
// CdpEvaluate (que devuelve el JSON.stringify como string). Hace un parse ligero
|
||||
// sin importar encoding/json de nuevo en el hot path: busca los numeros tras x/y.
|
||||
func parseXY(s string) (float64, float64, bool) {
|
||||
// CdpEvaluate devuelve la cadena producida por JSON.stringify; las comillas
|
||||
// internas vienen escapadas como \" tras pasar por el unmarshal de Go.
|
||||
s = strings.ReplaceAll(s, `\"`, `"`)
|
||||
var x, y float64
|
||||
n, err := fmt.Sscanf(s, `{"x":%g,"y":%g}`, &x, &y)
|
||||
if err != nil || n != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return x, y, true
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: cdp_select_dropdown
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpSelectDropdown(c *CDPConn, triggerSelector string, optionText string, opts CdpDropdownOpts) error"
|
||||
description: "Selecciona una opcion en un DESPLEGABLE CUSTOM (combobox/listbox ARIA, react-select, MUI Select, headlessui, select2) — esos donde un <select> nativo NO aplica. Replica el patron de Playwright (que no tiene API para custom dropdowns): click REAL en el trigger (mousedown, no element.click JS), espera la apertura por polling (aria-expanded=true O [role=listbox]/[role=menu] visible O opciones con rect>0), localiza la opcion por texto normalizado (substring o exacto, case-insensitive) y hace click REAL en su centro, con verificacion suave (aria-expanded vuelve a false o Enter como fallback). Reusa CdpEvaluate, CdpClickXYHuman y CdpPressKey."
|
||||
tags: [browser, chrome, cdp, automation, dropdown, combobox, listbox, aria, select, react-select, mui, headlessui, devtools]
|
||||
uses_functions: [cdp_evaluate_go_browser, cdp_click_xy_human_go_browser, cdp_press_key_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, strings, time]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexion CDP activa (*CDPConn)"
|
||||
- name: triggerSelector
|
||||
desc: "selector CSS del elemento que abre el desplegable (el boton/combobox sobre el que se hace click real)"
|
||||
- name: optionText
|
||||
desc: "texto visible de la opcion a elegir; se normaliza (trim + colapsar espacios) y se compara case-insensitive, por substring si opts.Exact=false o por igualdad si opts.Exact=true"
|
||||
- name: opts
|
||||
desc: "CdpDropdownOpts{Exact bool (igualdad vs substring, default substring); TimeoutMs int (espera apertura+opcion, default 3000); OptionRole string (rol ARIA de las opciones, default 'option' — usar 'menuitem' para menus, 'treeitem' para arboles)}"
|
||||
output: "error si el trigger no existe, si el dropdown no abre dentro del timeout (\"el dropdown no abrio\"), o si la opcion no aparece (\"option %q not found in dropdown\"); nil si el click sobre la opcion se realizo (la verificacion de cierre es suave y no falla duro si queda ambigua)"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_select_dropdown.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://mui.com/material-ui/react-select/")
|
||||
|
||||
// Combobox MUI: el trigger es el div con role=combobox; el listbox monta y
|
||||
// anima al abrir. CdpSelectDropdown clica el trigger, espera a que el listbox
|
||||
// este visible y entonces clica la opcion "Twenty".
|
||||
err := CdpSelectDropdown(conn, "[role=combobox]", "Twenty", CdpDropdownOpts{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// react-select / headlessui: trigger por clase + match exacto + timeout amplio
|
||||
// para listas que tardan en montar.
|
||||
err = CdpSelectDropdown(conn, ".select__control", "España", CdpDropdownOpts{
|
||||
Exact: true,
|
||||
TimeoutMs: 6000,
|
||||
})
|
||||
|
||||
// Menu tipo dropdown-menu (no listbox): las opciones son role=menuitem.
|
||||
err = CdpSelectDropdown(conn, "#user-menu-btn", "Cerrar sesion", CdpDropdownOpts{
|
||||
OptionRole: "menuitem",
|
||||
})
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando el desplegable NO es un `<select>` nativo: comboboxes/listboxes ARIA,
|
||||
react-select, MUI Select, headlessui, select2, Ant Design, o cualquier menu hecho
|
||||
con `<div>`/`<li>` + JS donde elegir = clicar el trigger y luego clicar la opcion
|
||||
del menu desplegado. Es el equivalente al patron de Playwright
|
||||
`click(trigger) -> getByRole('option', {name}) -> click(option)`, con la espera de
|
||||
apertura ya resuelta. Para un `<select>` nativo de HTML usa `CdpSelectOption` (setea
|
||||
`select.value` + dispara `input`/`change`), que es mas robusto y directo para ese
|
||||
caso.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Click real, no element.click()**: muchos dropdowns custom escuchan `mousedown`
|
||||
(no `click`), por eso esta funcion despacha eventos de raton reales sobre el
|
||||
centro del bbox. Solo cae a `element.click()` JS si el nodo no tiene geometria.
|
||||
- **Animaciones de apertura**: el fallo nº1 reportado en Playwright es clicar la
|
||||
opcion ANTES de que el listbox monte/anime. Por eso hay polling de apertura
|
||||
(`dropdownWaitOpen`) que no avanza hasta que hay opciones visibles. Si tu
|
||||
dropdown anima muy lento, sube `TimeoutMs`.
|
||||
- **Listas virtualizadas** (react-window, virtuoso): solo renderizan las opciones
|
||||
en viewport. Si la opcion buscada esta fuera del scroll inicial, puede que nunca
|
||||
se monte y la funcion devuelva "not found" aunque exista. Mitigacion: escribe en
|
||||
el combobox para filtrar (`CdpTypeText`) antes de llamar a esta funcion, o haz
|
||||
scroll dentro del listbox primero.
|
||||
- **Trigger vs contenedor**: `triggerSelector` debe apuntar al elemento que ABRE el
|
||||
menu (el boton/combobox), no al `[role=listbox]` (que no existe hasta abrir).
|
||||
- **Match de texto**: normaliza espacios y es case-insensitive; por defecto es
|
||||
substring (`Exact=false`). Si varias opciones comparten substring, elige la
|
||||
primera visible en orden de documento — usa `Exact=true` para desambiguar.
|
||||
- **OptionRole**: por defecto `option` (`[role=option]`). Para menus de acciones usa
|
||||
`menuitem`; para arboles `treeitem`. La deteccion de apertura tambien considera
|
||||
`[role=menu]` y `li[role]` para cubrir patrones comunes.
|
||||
- **Verificacion suave**: tras clicar, si el dropdown sigue abierto la funcion pulsa
|
||||
`Enter` como fallback y devuelve `nil`. No falla duro si la seleccion no se puede
|
||||
confirmar inequivocamente pero el click se hizo — comprueba el estado resultante
|
||||
(texto del trigger, valor del formulario) si necesitas certeza.
|
||||
- **iframes**: opera en el documento principal (via `CdpEvaluate`). Para un dropdown
|
||||
dentro de un iframe necesitarias el contexto del frame (no cubierto aqui).
|
||||
@@ -0,0 +1,153 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CdpSelectOption selecciona una <option> de un <select> nativo (localizado por
|
||||
// selector CSS) replicando la semantica de Playwright (injectedScript.selectOptions).
|
||||
//
|
||||
// Orden de matching de value contra cada <option>, en este orden:
|
||||
// 1. value exacto: option.value === value.
|
||||
// 2. label/texto exacto: option.label === value (sin normalizar).
|
||||
// 3. label/texto NORMALIZADO: normalizeWhiteSpace(option.label) === normalizeWhiteSpace(value),
|
||||
// donde normalizar = quitar zero-width space (U+200B) y soft hyphen (U+00AD),
|
||||
// trim, y colapsar cualquier secuencia de whitespace a un solo espacio.
|
||||
// 4. label/texto por substring NORMALIZADO: la primera option cuyo label normalizado
|
||||
// contenga el value normalizado (fallback para etiquetas largas).
|
||||
// 5. fallback por indice: solo si value es un entero (>= 0) y existe esa posicion.
|
||||
//
|
||||
// Sobre la option encontrada hace focus del select, setea option.selected = true
|
||||
// (no solo select.value, para que funcione tambien con <select multiple>) y despacha
|
||||
// 'input' {bubbles:true, composed:true} seguido de 'change' {bubbles:true}, en ese
|
||||
// orden, para que frameworks (React/Vue/Angular) y shadow DOM reaccionen al cambio.
|
||||
//
|
||||
// Si el selector apunta a un <label for=...>, sigue la referencia hasta su control
|
||||
// (retarget follow-label) antes de validar que sea un <select>.
|
||||
//
|
||||
// Devuelve error claro si:
|
||||
// - el selector no encuentra elemento ("element not found"),
|
||||
// - el elemento no es un <select> ("element is not a <select> ..."),
|
||||
// - ninguna option coincide ("option not found in <select>").
|
||||
func CdpSelectOption(c *CDPConn, selector string, value string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp select option: conexion nula")
|
||||
}
|
||||
|
||||
// Script JS alineado con Playwright. Devuelve centinelas en string:
|
||||
// __OK__:<value> cuando selecciona; el resto son codigos de error claros.
|
||||
// Usamos jsString para inyectar selector/value de forma segura (anti-inyeccion).
|
||||
js := fmt.Sprintf(`(function() {
|
||||
function normWS(t) {
|
||||
return (t == null ? '' : String(t))
|
||||
.replace(/[]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
var el = document.querySelector(%s);
|
||||
if (!el) return '__NO_EL__';
|
||||
// retarget follow-label: si es un <label for>, salta a su control.
|
||||
if (el.nodeName.toLowerCase() === 'label') {
|
||||
var labelled = null;
|
||||
var forId = el.getAttribute('for');
|
||||
if (forId) labelled = document.getElementById(forId);
|
||||
if (!labelled) labelled = el.querySelector('select, input, textarea');
|
||||
if (labelled) el = labelled;
|
||||
}
|
||||
if (el.nodeName.toLowerCase() !== 'select') return '__NOT_SELECT__';
|
||||
var sel = el;
|
||||
var want = %s;
|
||||
var wantNorm = normWS(want);
|
||||
var opts = Array.prototype.slice.call(sel.options);
|
||||
var match = null;
|
||||
|
||||
// 1. value exacto.
|
||||
for (var i = 0; i < opts.length && !match; i++) {
|
||||
if (opts[i].value === want) match = opts[i];
|
||||
}
|
||||
// 2. label/texto exacto.
|
||||
if (!match) {
|
||||
for (var j = 0; j < opts.length && !match; j++) {
|
||||
if (opts[j].label === want || (opts[j].textContent || '') === want) match = opts[j];
|
||||
}
|
||||
}
|
||||
// 3. label/texto normalizado exacto.
|
||||
if (!match && wantNorm !== '') {
|
||||
for (var k = 0; k < opts.length && !match; k++) {
|
||||
var ln = normWS(opts[k].label || opts[k].textContent);
|
||||
if (ln === wantNorm) match = opts[k];
|
||||
}
|
||||
}
|
||||
// 4. label/texto por substring normalizado.
|
||||
if (!match && wantNorm !== '') {
|
||||
for (var m = 0; m < opts.length && !match; m++) {
|
||||
var ln2 = normWS(opts[m].label || opts[m].textContent);
|
||||
if (ln2.indexOf(wantNorm) !== -1) match = opts[m];
|
||||
}
|
||||
}
|
||||
// 5. fallback por indice: solo si want es un entero >= 0 valido.
|
||||
if (!match && /^[0-9]+$/.test(want)) {
|
||||
var idx = parseInt(want, 10);
|
||||
if (idx >= 0 && idx < opts.length) match = opts[idx];
|
||||
}
|
||||
|
||||
if (!match) return '__NO_OPTION__';
|
||||
|
||||
try { sel.focus(); } catch (e) {}
|
||||
// option.selected en vez de solo select.value: necesario para <select multiple>
|
||||
// y mas fiel a como un usuario elige una entrada concreta.
|
||||
if (!sel.multiple) {
|
||||
for (var n = 0; n < opts.length; n++) opts[n].selected = false;
|
||||
}
|
||||
match.selected = true;
|
||||
sel.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return '__OK__:' + match.value;
|
||||
})()`, jsString(selector), jsString(value))
|
||||
|
||||
res, err := CdpEvaluate(c, js)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp select option: evaluar selector %q: %w", selector, err)
|
||||
}
|
||||
|
||||
res = strings.Trim(res, `"`)
|
||||
switch {
|
||||
case strings.HasPrefix(res, "__OK__"):
|
||||
return nil
|
||||
case res == "__NO_EL__":
|
||||
return fmt.Errorf("cdp select option: element not found para selector %q", selector)
|
||||
case res == "__NOT_SELECT__":
|
||||
return fmt.Errorf("cdp select option: element %q is not a <select> (use cdp_select_dropdown / click el trigger+option para dropdowns custom)", selector)
|
||||
case res == "__NO_OPTION__":
|
||||
return fmt.Errorf("cdp select option: option %q not found in <select> %q", value, selector)
|
||||
default:
|
||||
return fmt.Errorf("cdp select option: resultado inesperado %q para selector %q", res, selector)
|
||||
}
|
||||
}
|
||||
|
||||
// jsString convierte un string Go en un literal JS seguro (entre comillas dobles,
|
||||
// con escapes para comillas, backslashes y saltos de linea). Evita la inyeccion
|
||||
// de codigo al interpolar selectores/valores arbitrarios en el script JS.
|
||||
func jsString(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: cdp_select_option
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "func CdpSelectOption(c *CDPConn, selector string, value string) error"
|
||||
description: "Selecciona una <option> de un <select> nativo (localizado por selector CSS) replicando la semantica de Playwright (injectedScript.selectOptions). Match por value exacto, luego label/texto exacto, luego label normalizado (whitespace-collapse + strip zero-width/soft-hyphen), luego substring normalizado, y por ultimo indice si value es entero. Setea option.selected (soporta <select multiple>), hace focus, y despacha 'input' {bubbles,composed} + 'change' {bubbles}. Valida que el elemento sea <select> (error claro si no) y sigue <label for>. Via Runtime.evaluate, reusa CdpEvaluate."
|
||||
tags: [chrome, cdp, browser, automation, select, dropdown, form, dom, devtools]
|
||||
uses_functions: [cdp_evaluate_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, strings]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP activa"
|
||||
- name: selector
|
||||
desc: "selector CSS del elemento <select> a modificar"
|
||||
- name: value
|
||||
desc: "criterio de seleccion. Se prueba en orden: value exacto → label/texto exacto → label normalizado (whitespace-collapse + strip U+200B/U+00AD) → label por substring normalizado → indice (si value es un entero)"
|
||||
output: "error si el selector no encuentra elemento (\"element not found\"), si el elemento no es un <select> (\"element is not a <select> ...\"), o si ninguna option coincide (\"option not found in <select>\"); nil si la selección y los eventos se despacharon correctamente"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_select_option.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com/form")
|
||||
|
||||
// Seleccionar por value
|
||||
if err := CdpSelectOption(conn, "#country", "ES"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Seleccionar por texto visible cuando no se conoce el value interno
|
||||
if err := CdpSelectOption(conn, "select[name=lang]", "Español"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Seleccionar por indice (3a opcion) cuando ni value ni texto son estables
|
||||
if err := CdpSelectOption(conn, "#size", "2"); err != nil { // index 2 = 3a option
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites elegir una opcion de un `<select>` nativo en un formulario
|
||||
web y quieras que un framework (React, Vue, Angular) reaccione al cambio. Es la
|
||||
forma robusta de rellenar dropdowns durante automatizacion/scraping: a diferencia
|
||||
de un click sobre la option, setea `option.selected` y dispara `input`+`change`,
|
||||
que es lo que los frameworks escuchan. Combinala con `CdpClick` para enviar el
|
||||
formulario despues. Si no conoces el `value` interno, pasa el texto visible (se
|
||||
normaliza el whitespace) o el indice numerico de la option.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Solo `<select>` nativos.** Si el elemento no es un `<select>` retorna error
|
||||
claro `element is not a <select> ...`. Dropdowns custom hechos con `<div>` + JS
|
||||
(react-select, headlessui, Radix, etc.) NO son `<select>` reales: para esos usa
|
||||
`cdp_select_dropdown` (cuando exista) o clica el trigger con `CdpClickRef` y
|
||||
luego la opcion del menu desplegado (`CdpFindRefByText` + `CdpClickRef`). NO uses
|
||||
esta funcion para ellos.
|
||||
- **Orden de matching del `value` recibido** (se prueba en este orden y para en el
|
||||
primer match):
|
||||
1. `option.value` exacto (`===`).
|
||||
2. `option.label` / `textContent` exacto (sin normalizar).
|
||||
3. label/texto NORMALIZADO exacto: se quita zero-width space (U+200B) y soft
|
||||
hyphen (U+00AD), se hace `trim`, y se colapsa cualquier whitespace (`\s+`) a un
|
||||
solo espacio — igual que `normalizeWhiteSpace` de Playwright.
|
||||
4. label/texto por SUBSTRING normalizado (primera option cuyo label normalizado
|
||||
contenga el value normalizado). Util para etiquetas largas; cuidado con
|
||||
ambiguedad (gana la primera en orden de documento).
|
||||
5. fallback por INDICE: solo si `value` es un entero `>= 0` valido (`"2"` → 3a
|
||||
option). Por eso un `value` que casualmente sea numerico puede caer aqui si no
|
||||
hubo ningun match textual antes — preferi el `value` real cuando exista.
|
||||
El matching es case-sensitive en todos los pasos (no se hace lowercase).
|
||||
- **`<select multiple>` soportado:** setea `option.selected = true` sobre la option
|
||||
encontrada sin tocar el resto de selecciones. En un `<select>` simple deselecciona
|
||||
las demas antes de marcar la elegida. (La version 1.0.0 solo seteaba `select.value`
|
||||
y reseteaba el multiple — corregido.)
|
||||
- **Eventos:** dispara `input` con `{bubbles:true, composed:true}` (el `composed`
|
||||
permite cruzar shadow DOM, p.ej. web components que envuelven el `<select>`) y
|
||||
luego `change` con `{bubbles:true}`, en ese orden. Hace `focus()` del select antes.
|
||||
- No hace scroll ni verifica visibilidad/enabled: opera sobre el DOM directamente.
|
||||
Si el `<select>` o la `<option>` estan `disabled`, la seleccion se aplica igual
|
||||
pero la UI puede ignorarla segun el framework (Playwright aqui devolveria
|
||||
`optionnotenabled`; esta funcion no chequea enabled — mantiene KISS).
|
||||
- Si el elemento aun no existe (carga dinamica), retorna `element not found` sin
|
||||
esperar — combinar con `CdpWaitElement` para elementos diferidos.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-16) — alineada con Playwright `injectedScript.selectOptions`:
|
||||
valida que el elemento sea `<select>` (error claro si no, apuntando a dropdowns
|
||||
custom), sigue `<label for>`, matching multi-criterio (value → label exacto →
|
||||
label normalizado whitespace-collapse → substring → indice), usa
|
||||
`option.selected` en vez de solo `select.value` (soporta `<select multiple>`),
|
||||
añade `composed:true` al evento `input` (cruza shadow DOM) y `focus()` previo.
|
||||
Firma intacta (no rompe el caller del MCP `dom_select_option`).
|
||||
@@ -0,0 +1,82 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CdpSetFileInput sube archivos a un <input type="file"> identificado por el
|
||||
// selector CSS. Resuelve el nodo via DOM.getDocument + DOM.querySelector y luego
|
||||
// asigna los archivos con DOM.setFileInputFiles. Util para automatizar formularios
|
||||
// de subida sin simular el dialogo nativo de seleccion de archivos.
|
||||
//
|
||||
// Cada path de paths se valida con os.Stat ANTES de enviar el comando: si alguno
|
||||
// no existe (o no es accesible) se devuelve error inmediato sin tocar el DOM. Los
|
||||
// paths deben ser absolutos y accesibles por el proceso de Chrome (ver Gotchas en
|
||||
// el .md): Chrome lee los archivos desde su propio contexto, no desde el de este
|
||||
// programa.
|
||||
func CdpSetFileInput(c *CDPConn, selector string, paths []string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp set file input: conexion nula")
|
||||
}
|
||||
if selector == "" {
|
||||
return fmt.Errorf("cdp set file input: selector vacio")
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return fmt.Errorf("cdp set file input: lista de paths vacia")
|
||||
}
|
||||
|
||||
// Validar que cada path exista en disco antes de mandar nada a Chrome.
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
return fmt.Errorf("cdp set file input: path vacio en la lista")
|
||||
}
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("cdp set file input: el archivo no existe: %q", p)
|
||||
}
|
||||
return fmt.Errorf("cdp set file input: no se puede acceder al archivo %q: %w", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener el nodo raiz del documento.
|
||||
docRes, err := c.sendCDP("DOM.getDocument", map[string]any{"depth": 0})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp set file input: DOM.getDocument: %w", err)
|
||||
}
|
||||
root, ok := docRes["root"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp set file input: respuesta de DOM.getDocument sin root")
|
||||
}
|
||||
rootNodeID, ok := root["nodeId"].(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp set file input: DOM.getDocument sin nodeId raiz")
|
||||
}
|
||||
|
||||
// Resolver el input por selector.
|
||||
qsRes, err := c.sendCDP("DOM.querySelector", map[string]any{
|
||||
"nodeId": int(rootNodeID),
|
||||
"selector": selector,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp set file input: DOM.querySelector %q: %w", selector, err)
|
||||
}
|
||||
nodeIDVal, ok := qsRes["nodeId"].(float64)
|
||||
if !ok || int(nodeIDVal) == 0 {
|
||||
return fmt.Errorf("cdp set file input: el selector %q no coincide con ningun elemento", selector)
|
||||
}
|
||||
|
||||
// Asignar los archivos al input.
|
||||
files := make([]any, len(paths))
|
||||
for i, p := range paths {
|
||||
files[i] = p
|
||||
}
|
||||
if _, err := c.sendCDP("DOM.setFileInputFiles", map[string]any{
|
||||
"files": files,
|
||||
"nodeId": int(nodeIDVal),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("cdp set file input: DOM.setFileInputFiles en %q: %w", selector, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: cdp_set_file_input
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpSetFileInput(c *CDPConn, selector string, paths []string) error"
|
||||
description: "Sube archivos a un <input type=\"file\"> identificado por selector CSS, sin abrir el dialogo nativo de seleccion de archivos. Resuelve el nodo via DOM.getDocument + DOM.querySelector y asigna los archivos con DOM.setFileInputFiles. Valida con os.Stat que cada path exista en disco antes de tocar el DOM."
|
||||
tags: [chrome, cdp, browser, automation, upload, file, input, form, dom, devtools]
|
||||
uses_functions: [cdp_connect_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP activa (*CDPConn)"
|
||||
- name: selector
|
||||
desc: "selector CSS del <input type=\"file\"> destino (ej. 'input[type=file]', '#avatar')"
|
||||
- name: paths
|
||||
desc: "rutas absolutas de los archivos a subir; cada una debe existir y ser accesible por el proceso Chrome"
|
||||
output: "error si algún path no existe, si el selector no coincide con ningún nodo, o si falla el comando CDP; nil si los archivos quedaron asignados al input"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_set_file_input.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com/upload")
|
||||
|
||||
// Subir un solo archivo
|
||||
err := CdpSetFileInput(conn, "input[type=file]", []string{"/home/enmanuel/docs/cv.pdf"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Subir varios archivos a un input con multiple
|
||||
err = CdpSetFileInput(conn, "#gallery", []string{
|
||||
"/home/enmanuel/fotos/1.jpg",
|
||||
"/home/enmanuel/fotos/2.jpg",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando automatices un formulario web de subida de archivos y necesites rellenar un
|
||||
`<input type="file">` sin poder interactuar con el dialogo nativo del sistema
|
||||
operativo (que CDP no puede manejar haciendo click). Llamala despues de navegar a
|
||||
la pagina y de que el input exista en el DOM; combina con `CdpWaitElement` si el
|
||||
input aparece de forma dinamica.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Los paths deben ser ABSOLUTOS y accesibles por el proceso de Chrome**, no por
|
||||
este programa. Chrome lee los archivos desde su propio contexto/usuario; un path
|
||||
relativo o un archivo en un directorio que Chrome no puede leer fallara en el
|
||||
navegador aunque `os.Stat` pase localmente (caso tipico: Chrome corriendo en otro
|
||||
usuario, contenedor o maquina remota via CDP).
|
||||
- La validacion `os.Stat` se ejecuta en la maquina donde corre esta funcion. Si el
|
||||
Chrome del CDP esta en otra maquina/contenedor, que `os.Stat` pase NO garantiza
|
||||
que Chrome encuentre el archivo. En ese escenario los paths deben ser validos en
|
||||
el filesystem de Chrome.
|
||||
- El selector debe apuntar a un `<input type="file">` real. Apuntar a un boton o
|
||||
label que dispara el dialogo nativo no funciona: hay que resolver el input
|
||||
subyacente.
|
||||
- Asignar mas de un archivo requiere que el input tenga el atributo `multiple`; si
|
||||
no lo tiene, Chrome puede rechazar o quedarse solo con el primero.
|
||||
- No dispara automaticamente el submit del formulario ni eventos `change`
|
||||
personalizados mas alla de los que el propio CDP emite al asignar los archivos;
|
||||
si la pagina depende de listeners adicionales, comprueba el comportamiento.
|
||||
@@ -0,0 +1,343 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// actionableBackoff es el calendario de espera entre reintentos del bucle de
|
||||
// actionability, copiado del _retryAction de Playwright (waitTime [0,20,100,100,500]).
|
||||
// Tras agotar la tabla, se mantiene en el ultimo valor (500ms) hasta el timeout.
|
||||
// El primer intento es inmediato (0ms): muchas veces el elemento ya esta listo.
|
||||
var actionableBackoff = []time.Duration{
|
||||
0,
|
||||
20 * time.Millisecond,
|
||||
100 * time.Millisecond,
|
||||
100 * time.Millisecond,
|
||||
500 * time.Millisecond,
|
||||
}
|
||||
|
||||
// actionableScrollAligns rota la alineacion block de scrollIntoView entre
|
||||
// reintentos. Cyclar las alineaciones (center/start/end) destraba casos donde un
|
||||
// header position:sticky o un footer fijo tapa el punto al alinear de una sola
|
||||
// forma — replica el scrollOptions cycling de _retryPointerAction de Playwright.
|
||||
var actionableScrollAligns = []string{"center", "start", "end"}
|
||||
|
||||
// actionableResult es el veredicto que el JS inyectado devuelve por iteracion.
|
||||
// state describe el primer estado que fallo (para el mensaje de error final);
|
||||
// x,y son el punto central listo para el pointer cuando ok==true.
|
||||
type actionableResult struct {
|
||||
OK bool `json:"ok"`
|
||||
State string `json:"state"` // "visible" | "stable" | "enabled" | "inviewport" | "intercepted" | "notconnected"
|
||||
Detail string `json:"detail"` // descripcion del interceptor u otro detalle
|
||||
X float64 `json:"x"` // punto central viewport (CSS px)
|
||||
Y float64 `json:"y"` //
|
||||
PageX float64 `json:"pageX"` // punto central en coords de pagina (scroll incluido)
|
||||
PageY float64 `json:"pageY"` //
|
||||
}
|
||||
|
||||
// CdpWaitActionable bloquea hasta que el elemento identificado por backendNodeID
|
||||
// sea accionable (listo para recibir un click/hover fiable) o expire timeout.
|
||||
// Reproduce el modelo de actionability de Playwright: en cada iteracion comprueba
|
||||
// que el elemento esta visible, estable (mismo rect en dos requestAnimationFrame
|
||||
// consecutivos), opcionalmente enabled, dentro del viewport tras scrollIntoView,
|
||||
// y que el hit-test (document.elementFromPoint subiendo por shadow DOM) apunta al
|
||||
// propio nodo o a un descendiente. Si algo falla, espera con backoff
|
||||
// [0,20,100,100,500]ms (luego 500ms constante) y reintenta, rotando la alineacion
|
||||
// del scroll para destrabar overlays sticky.
|
||||
//
|
||||
// Devuelve el punto central (x,y) en coordenadas de viewport (CSS px), listo para
|
||||
// Input.dispatchMouseEvent. Al expirar, el error indica QUE estado fallo en el
|
||||
// ultimo intento (not visible / not stable / disabled / outside viewport /
|
||||
// intercepted by other element).
|
||||
//
|
||||
// needEnabled controla si se exige el estado enabled (no `disabled`,
|
||||
// `aria-disabled="true"`, ni dentro de un <fieldset disabled>). Pasar false para
|
||||
// elementos no interactivos (texto, contenedores) donde enabled no aplica.
|
||||
func CdpWaitActionable(c *CDPConn, backendNodeID int, needEnabled bool, timeout time.Duration) (x float64, y float64, err error) {
|
||||
if c == nil {
|
||||
return 0, 0, fmt.Errorf("cdp wait actionable: conexión nil")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
|
||||
// Resolver el backendNodeID a un objectId una sola vez. El objectId apunta al
|
||||
// nodo DOM vivo y se reutiliza en cada iteracion via Runtime.callFunctionOn,
|
||||
// evitando un resolveNode por reintento.
|
||||
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("cdp wait actionable: resolveNode ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
obj, _ := res["object"].(map[string]any)
|
||||
objID, _ := obj["objectId"].(string)
|
||||
if objID == "" {
|
||||
return 0, 0, fmt.Errorf("cdp wait actionable: sin objectId para ref %d (nodo inexistente)", backendNodeID)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
var last actionableResult
|
||||
last.State = "visible" // estado por defecto si nunca llegamos a evaluar
|
||||
|
||||
for retry := 0; ; retry++ {
|
||||
// Espera con backoff antes de reintentar (el primer intento es inmediato).
|
||||
if retry > 0 {
|
||||
wait := actionableBackoff[len(actionableBackoff)-1]
|
||||
if retry-1 < len(actionableBackoff) {
|
||||
wait = actionableBackoff[retry-1]
|
||||
}
|
||||
if wait > 0 {
|
||||
// No dormir mas alla del deadline.
|
||||
if remaining := time.Until(deadline); remaining < wait {
|
||||
wait = remaining
|
||||
}
|
||||
if wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
align := actionableScrollAligns[retry%len(actionableScrollAligns)]
|
||||
r, evalErr := evalActionable(c, objID, needEnabled, align)
|
||||
if evalErr != nil {
|
||||
// Un error de protocolo (tab cerrada, nodo liberado) es terminal: no
|
||||
// tiene sentido reintentar sobre un objectId muerto.
|
||||
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d: %w", backendNodeID, evalErr)
|
||||
}
|
||||
last = r
|
||||
|
||||
if r.OK {
|
||||
return r.X, r.Y, nil
|
||||
}
|
||||
if r.State == "notconnected" {
|
||||
// El nodo dejo de estar conectado al DOM — reintentar no lo revivira.
|
||||
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d desconectado del DOM", backendNodeID)
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d no accionable tras %s: %s", backendNodeID, timeout, describeActionableFailure(last))
|
||||
}
|
||||
|
||||
// describeActionableFailure traduce el estado fallido a un mensaje humano.
|
||||
func describeActionableFailure(r actionableResult) string {
|
||||
switch r.State {
|
||||
case "visible":
|
||||
return "not visible (display:none, visibility:hidden, opacity:0 o tamaño 0)"
|
||||
case "stable":
|
||||
return "not stable (el rect sigue cambiando entre frames; animación o layout en curso)"
|
||||
case "enabled":
|
||||
return "disabled (atributo disabled, aria-disabled=true o <fieldset disabled>)"
|
||||
case "inviewport":
|
||||
return "outside of the viewport (scrollIntoView no logró revelarlo)"
|
||||
case "intercepted":
|
||||
if r.Detail != "" {
|
||||
return "intercepted by other element: " + r.Detail
|
||||
}
|
||||
return "intercepted by other element (overlay capta el pointer en el punto central)"
|
||||
case "notconnected":
|
||||
return "not connected to the DOM"
|
||||
default:
|
||||
if r.State != "" {
|
||||
return "not " + r.State
|
||||
}
|
||||
return "estado desconocido"
|
||||
}
|
||||
}
|
||||
|
||||
// evalActionable corre una iteracion completa de chequeos en el contexto JS de la
|
||||
// pagina, sobre el nodo apuntado por objID. Devuelve el veredicto serializado.
|
||||
//
|
||||
// El JS hace, en orden y cortocircuitando al primer fallo:
|
||||
// 1. visible: tiene client rects y computed style no lo oculta.
|
||||
// 2. stable: getBoundingClientRect identico en dos requestAnimationFrame seguidos.
|
||||
// 3. enabled (si needEnabled): no disabled / aria-disabled=true / dentro de
|
||||
// <fieldset disabled> (subiendo por la jerarquia, como getAriaDisabled).
|
||||
// 4. scrollIntoView con la alineacion dada + comprobacion de que el centro cae
|
||||
// dentro del viewport.
|
||||
// 5. hit-test: elementFromPoint en el punto central, subiendo por shadow roots
|
||||
// (assignedSlot / parentNode.host) y comprobando que el elemento golpeado es
|
||||
// el target o uno de sus descendientes.
|
||||
func evalActionable(c *CDPConn, objID string, needEnabled bool, scrollAlign string) (actionableResult, error) {
|
||||
params := map[string]any{
|
||||
"objectId": objID,
|
||||
"functionDeclaration": actionableJS,
|
||||
"arguments": []any{
|
||||
map[string]any{"value": needEnabled},
|
||||
map[string]any{"value": scrollAlign},
|
||||
},
|
||||
"awaitPromise": true,
|
||||
"returnByValue": true,
|
||||
}
|
||||
result, err := c.sendCDP("Runtime.callFunctionOn", params)
|
||||
if err != nil {
|
||||
return actionableResult{}, err
|
||||
}
|
||||
if exc, ok := result["exceptionDetails"]; ok && exc != nil {
|
||||
excMap, _ := exc.(map[string]any)
|
||||
text, _ := excMap["text"].(string)
|
||||
return actionableResult{}, fmt.Errorf("excepción JS en chequeo de actionability: %s", text)
|
||||
}
|
||||
resVal, ok := result["result"].(map[string]any)
|
||||
if !ok {
|
||||
return actionableResult{}, fmt.Errorf("resultado inesperado: %v", result)
|
||||
}
|
||||
raw, ok := resVal["value"]
|
||||
if !ok {
|
||||
return actionableResult{}, fmt.Errorf("chequeo de actionability sin valor de retorno")
|
||||
}
|
||||
// returnByValue=true entrega el objeto JS ya deserializado a map[string]any;
|
||||
// lo re-marshalamos para decodificar en el struct tipado de forma robusta.
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return actionableResult{}, fmt.Errorf("marshal resultado: %w", err)
|
||||
}
|
||||
var out actionableResult
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return actionableResult{}, fmt.Errorf("unmarshal resultado %q: %w", string(b), err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// actionableJS es la funcion ejecutada sobre el nodo (this) via callFunctionOn.
|
||||
// Devuelve una Promise<actionableResult>. La logica replica checkElementStates +
|
||||
// _checkElementIsStable + expectHitTarget del injected script de Playwright,
|
||||
// adaptada a un solo paso autocontenido (sin caches ni dependencias externas).
|
||||
const actionableJS = `function(needEnabled, scrollAlign) {
|
||||
var target = this;
|
||||
var fail = function(state, detail) { return {ok:false, state:state, detail:detail||"", x:0, y:0, pageX:0, pageY:0}; };
|
||||
|
||||
if (!target || !target.isConnected) return Promise.resolve(fail("notconnected"));
|
||||
if (target.nodeType !== 1) {
|
||||
// Si el nodo no es un Element (ej. texto), intentar su elemento padre.
|
||||
target = target.parentElement;
|
||||
if (!target) return Promise.resolve(fail("notconnected"));
|
||||
}
|
||||
|
||||
// 1) VISIBLE: rect con area + computed style no oculto.
|
||||
var isVisible = function(el) {
|
||||
if (!el || !el.isConnected) return false;
|
||||
var rects = el.getClientRects();
|
||||
if (!rects || rects.length === 0) return false;
|
||||
var st = (el.ownerDocument && el.ownerDocument.defaultView)
|
||||
? el.ownerDocument.defaultView.getComputedStyle(el) : null;
|
||||
if (st) {
|
||||
if (st.visibility === "hidden" || st.display === "none") return false;
|
||||
if (parseFloat(st.opacity || "1") === 0) return false;
|
||||
}
|
||||
var r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
};
|
||||
if (!isVisible(target)) return Promise.resolve(fail("visible"));
|
||||
|
||||
// 2) ENABLED (opcional): disabled nativo, aria-disabled o <fieldset disabled>.
|
||||
if (needEnabled) {
|
||||
var isDisabled = function(el) {
|
||||
var native = ["BUTTON","INPUT","SELECT","TEXTAREA","OPTION","OPTGROUP"];
|
||||
var n = el;
|
||||
while (n) {
|
||||
if (n.nodeType === 1) {
|
||||
var tag = (n.tagName || "").toUpperCase();
|
||||
if (native.indexOf(tag) !== -1 && n.hasAttribute && n.hasAttribute("disabled")) return true;
|
||||
// fieldset disabled deshabilita a sus controles (salvo dentro del legend).
|
||||
if (tag === "FIELDSET" && n.hasAttribute && n.hasAttribute("disabled")) return true;
|
||||
var ad = n.getAttribute && n.getAttribute("aria-disabled");
|
||||
if (ad && ad.toLowerCase() === "true") return true;
|
||||
}
|
||||
// Subir por DOM y cruzar shadow boundaries.
|
||||
n = n.parentElement || (n.parentNode && n.parentNode.host) || (n.assignedSlot || null);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (isDisabled(target)) return Promise.resolve(fail("enabled"));
|
||||
}
|
||||
|
||||
// 4) SCROLL INTO VIEW con la alineacion rotada por el caller.
|
||||
try { target.scrollIntoView({block: scrollAlign, inline: scrollAlign, behavior: "instant"}); }
|
||||
catch (e) { try { target.scrollIntoView(); } catch (e2) {} }
|
||||
|
||||
// 3) STABLE: comparar getBoundingClientRect en dos requestAnimationFrame seguidos.
|
||||
var rectOf = function(el) {
|
||||
var r = el.getBoundingClientRect();
|
||||
return {x: r.left, y: r.top, w: r.width, h: r.height};
|
||||
};
|
||||
var rafTwice = function() {
|
||||
return new Promise(function(res) {
|
||||
requestAnimationFrame(function() { requestAnimationFrame(function() { res(); }); });
|
||||
});
|
||||
};
|
||||
|
||||
var first = rectOf(target);
|
||||
return rafTwice().then(function() {
|
||||
if (!target.isConnected) return fail("notconnected");
|
||||
var second = rectOf(target);
|
||||
var same = first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||
if (!same) return fail("stable");
|
||||
|
||||
var r = second;
|
||||
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||
var cx = r.x + r.w / 2;
|
||||
var cy = r.y + r.h / 2;
|
||||
|
||||
// 4b) IN VIEWPORT: el punto central debe caer dentro del viewport tras el scroll.
|
||||
if (cx < 0 || cy < 0 || cx > vw || cy > vh) return fail("inviewport");
|
||||
|
||||
// 5) HIT-TEST: elementFromPoint subiendo por shadow roots; el golpeado debe ser
|
||||
// el target o un descendiente suyo (cruzando shadow boundaries).
|
||||
var enclosingRoot = function(el) {
|
||||
var node = el;
|
||||
while (node && node.parentNode) node = node.parentNode;
|
||||
if (node && (node.nodeType === 11 || node.nodeType === 9)) return node;
|
||||
return null;
|
||||
};
|
||||
var parentOrHost = function(el) {
|
||||
if (el.parentElement) return el.parentElement;
|
||||
if (el.parentNode && el.parentNode.nodeType === 11 && el.parentNode.host) return el.parentNode.host;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Recolectar roots desde el target hacia arriba (document u shadow roots).
|
||||
var roots = [];
|
||||
var p = target;
|
||||
while (p) {
|
||||
var root = enclosingRoot(p);
|
||||
if (!root) break;
|
||||
roots.push(root);
|
||||
if (root.nodeType === 9) break;
|
||||
p = root.host;
|
||||
}
|
||||
|
||||
// Hit en cada root debe apuntar al siguiente root; en el ultimo, al target/descendiente.
|
||||
var hit = null;
|
||||
for (var i = roots.length - 1; i >= 0; i--) {
|
||||
var rt = roots[i];
|
||||
var inner = rt.elementFromPoint ? rt.elementFromPoint(cx, cy) : null;
|
||||
if (!inner) break;
|
||||
hit = inner;
|
||||
if (i && roots[i - 1] && inner !== roots[i - 1].host) break;
|
||||
}
|
||||
if (!hit) return fail("intercepted", "ningún elemento en el punto central");
|
||||
|
||||
// Subir desde el hit hasta el target (composed tree: assignedSlot primero).
|
||||
var cur = hit;
|
||||
while (cur && cur !== target) {
|
||||
cur = cur.assignedSlot || parentOrHost(cur);
|
||||
}
|
||||
if (cur !== target) {
|
||||
var desc = hit.tagName ? hit.tagName.toLowerCase() : "node";
|
||||
if (hit.id) desc += "#" + hit.id;
|
||||
else if (hit.className && typeof hit.className === "string" && hit.className.trim())
|
||||
desc += "." + hit.className.trim().split(/\s+/)[0];
|
||||
return fail("intercepted", desc);
|
||||
}
|
||||
|
||||
var sx = window.scrollX || window.pageXOffset || 0;
|
||||
var sy = window.scrollY || window.pageYOffset || 0;
|
||||
return {ok:true, state:"ok", detail:"", x:cx, y:cy, pageX:cx + sx, pageY:cy + sy};
|
||||
});
|
||||
}`
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: cdp_wait_actionable
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpWaitActionable(c *CDPConn, backendNodeID int, needEnabled bool, timeout time.Duration) (x float64, y float64, err error)"
|
||||
description: "Bloquea hasta que el elemento del #ref sea accionable (listo para un click/hover fiable) o expire timeout. Reproduce el modelo de actionability de Playwright: en bucle con backoff [0,20,100,100,500]ms comprueba visible (client rects + computed style), stable (mismo getBoundingClientRect en dos requestAnimationFrame seguidos), enabled opcional (disabled / aria-disabled / fieldset disabled subiendo la jerarquía), scroll into view rotando alineación block (center/start/end), y hit-test (elementFromPoint subiendo por shadow DOM apunta al target o descendiente). Devuelve el punto central (x,y) en coords de viewport listo para Input.dispatchMouseEvent. Al expirar, el error indica qué estado falló (not visible / not stable / disabled / outside viewport / intercepted by other element)."
|
||||
tags: [cdp, browser, action, ref, actionability, browser-actionability, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa al tab objetivo."
|
||||
- name: backendNodeID
|
||||
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||
- name: needEnabled
|
||||
desc: "Si true, exige también el estado enabled (no disabled, no aria-disabled=true, no dentro de <fieldset disabled>). Pasar false para elementos no interactivos (texto, contenedores) donde enabled no aplica."
|
||||
- name: timeout
|
||||
desc: "Tiempo máximo de espera antes de rendirse. <=0 usa 5s por defecto. El bucle de reintento nunca duerme más allá de este deadline."
|
||||
output: "(x, y) punto central del elemento en coordenadas de viewport (CSS px), listo para despachar el pointer, cuando todos los chequeos pasan; error si la conexión es nil, el nodo no resuelve a objectId, se desconecta del DOM, o expira el timeout (con el estado que falló al final)."
|
||||
file_path: "functions/browser/cdp_wait_actionable.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Tras un page_perceive que devuelve outline con #ref=1234, esperar a que el
|
||||
// elemento sea accionable y luego clicar el punto exacto que devuelve:
|
||||
conn, _ := CdpConnect(9222)
|
||||
x, y, err := CdpWaitActionable(conn, 1234, true, 5*time.Second)
|
||||
if err != nil {
|
||||
log.Fatalf("no accionable: %v", err) // ej: "intercepted by other element: div#cookie-banner"
|
||||
}
|
||||
// x,y ya están en viewport, estables y sin overlay encima: click fiable.
|
||||
_ = CdpClickXYHuman(conn, x, y, MouseHumanOpts{})
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de CUALQUIER click/hover/type que deba ser fiable sobre un #ref del outline.
|
||||
Llamarla justo después de `page_perceive` y antes de `cdp_click_ref` /
|
||||
`cdp_click_xy_human` / `dom_*_ref` para evitar los fallos clásicos del navegador:
|
||||
clicar un botón que aún se está animando hacia su posición, un elemento tapado por
|
||||
un banner de cookies / modal / spinner, o un control todavía `disabled`. Es la
|
||||
puerta de actionability que separa "el nodo existe en el DOM" de "el nodo está
|
||||
listo para recibir el evento ahí donde lo voy a despachar". Usar `needEnabled=true`
|
||||
para botones/inputs/enlaces; `needEnabled=false` para hover sobre texto o medir un
|
||||
contenedor.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Coste de polling.** Es síncrona y bloqueante: hace un `Runtime.callFunctionOn`
|
||||
por iteración + 2 `requestAnimationFrame` por chequeo de estabilidad. En el peor
|
||||
caso poll-ea hasta `timeout` con backoff creciente (0,20,100,100,500ms → 500ms).
|
||||
No la metas en un bucle apretado sobre N elementos sin necesidad; una sola
|
||||
llamada por acción es lo correcto. Timeouts altos sobre elementos que nunca
|
||||
llegan (genuinamente ocultos) cuestan el timeout entero.
|
||||
- **Shadow DOM.** El hit-test sube por shadow roots (`assignedSlot` /
|
||||
`parentNode.host`) y por eso funciona con web components con shadow root
|
||||
*abierto*. Con shadow roots **cerrados** `elementFromPoint` no expone el interior
|
||||
y el hit-test puede reportar `intercepted` erróneamente; en ese caso usar el
|
||||
click vía `element.click()` (modo instant de `cdp_click_ref`), que no depende del
|
||||
hit-test geométrico.
|
||||
- **iframes.** Opera sobre el contexto de la página/frame al que apunta el
|
||||
`*CDPConn`. Un `backendNodeID` de otro frame no resuelve aquí: hay que tener la
|
||||
conexión/contexto del frame correcto (ver `cdp_eval_in_frame`). Las coordenadas
|
||||
devueltas son relativas al viewport de ESE documento, no compuestas con el offset
|
||||
del iframe en la página padre.
|
||||
- **Estabilidad vs animaciones infinitas.** Un elemento con una animación CSS
|
||||
perpetua que mueve su rect (spinner que se desplaza, marquee) nunca pasará el
|
||||
chequeo `stable` y agotará el timeout con "not stable". Es comportamiento
|
||||
correcto (no es accionable de forma fiable), pero conviene saberlo.
|
||||
- **El punto devuelto es (x,y) de viewport**, no de página. Es lo que
|
||||
`Input.dispatchMouseEvent` espera. Si necesitas coords de página (con scroll),
|
||||
el JS interno ya las calcula (`pageX/pageY`) pero la firma pública expone solo
|
||||
las de viewport para encajar con el dispatch de pointer.
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
// ClaudeFleet describes a single Claude Code session process on the local
|
||||
// machine, cross-joining the live process state (/proc) with the session and
|
||||
// goal metadata that Claude Code persists under ~/.claude.
|
||||
//
|
||||
// It is the data record consumed by the fleetview TUI. Every field is derived
|
||||
// from a single ~/.claude/sessions/<PID>.json entry plus its optional
|
||||
// ~/.claude/goals/<sessionId>.json sidecar and the process' own /proc entry.
|
||||
type ClaudeFleet struct {
|
||||
PID int `json:"pid"`
|
||||
KittyPID int `json:"kitty_pid"` // KITTY_PID from the process environ; 0 if not applicable (e.g. remote tmux)
|
||||
SessionID string `json:"session_id"` // Claude Code sessionId (UUID)
|
||||
Rename string `json:"rename"` // display name: short goal if present, else basename(cwd)
|
||||
Target string `json:"target"` // sessionId[:8] + "@" + basename(cwd)
|
||||
Goal string `json:"goal"` // from goals/<sessionId>.json .goal ("" if absent)
|
||||
Phase string `json:"phase"` // from goals/<sessionId>.json .phase ("" if absent)
|
||||
Emojis string `json:"emojis"` // 3 emojis representing the task (from goals .emojis; "" if absent)
|
||||
Name string `json:"name"` // manual rename of the terminal (from goals .rename; "" if none)
|
||||
Status string `json:"status"` // idle|busy|waiting (from sessions/<pid>.json)
|
||||
Cwd string `json:"cwd"` // working directory of the session
|
||||
TmuxWindow string `json:"tmux_window"` // "" for now (populated in a later phase)
|
||||
Alive bool `json:"alive"` // process alive AND procStart matches (guards against PID recycling)
|
||||
UpdatedAt int64 `json:"updated_at"` // from sessions/<pid>.json .updatedAt (epoch millis)
|
||||
CtxPct int `json:"ctx_pct"` // context window used %, from runtime/<sessionId>.json; -1 if unknown
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sessionFile mirrors the on-disk shape of ~/.claude/sessions/<PID>.json
|
||||
// written by Claude Code 2.1.x. Only the fields we consume are declared.
|
||||
type sessionFile struct {
|
||||
PID int `json:"pid"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
ProcStart string `json:"procStart"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// goalFile mirrors the on-disk shape of ~/.claude/goals/<sessionId>.json.
|
||||
type goalFile struct {
|
||||
Goal string `json:"goal"`
|
||||
Phase string `json:"phase"`
|
||||
Emojis string `json:"emojis"`
|
||||
Rename string `json:"rename"`
|
||||
}
|
||||
|
||||
// runtimeFile mirrors ~/.claude/runtime/<sessionId>.json written by statusline.sh
|
||||
// with the live context-window usage of that session.
|
||||
type runtimeFile struct {
|
||||
CtxPct int `json:"ctx_pct"`
|
||||
}
|
||||
|
||||
// ListClaudeFleet scans the current user's ~/.claude directory and returns the
|
||||
// fleet of Claude Code sessions known to the machine. It is a thin wrapper over
|
||||
// ListClaudeFleetFrom resolving the home directory.
|
||||
func ListClaudeFleet() ([]ClaudeFleet, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve home dir: %w", err)
|
||||
}
|
||||
return ListClaudeFleetFrom(filepath.Join(home, ".claude"))
|
||||
}
|
||||
|
||||
// ListClaudeFleetFrom scans claudeDir (e.g. ~/.claude) and returns the fleet of
|
||||
// Claude Code sessions. It reads sessions/*.json, joins each against its
|
||||
// goals/<sessionId>.json sidecar, validates liveness against /proc (guarding
|
||||
// against PID recycling), and derives the display fields.
|
||||
//
|
||||
// Every session that produced a parseable JSON is returned; the Alive flag
|
||||
// reflects whether the underlying process is actually running. The caller is
|
||||
// expected to filter on Alive as needed. Records are ordered by status
|
||||
// (idle, waiting, busy, other) and within a status by UpdatedAt descending.
|
||||
func ListClaudeFleetFrom(claudeDir string) ([]ClaudeFleet, error) {
|
||||
sessionsDir := filepath.Join(claudeDir, "sessions")
|
||||
goalsDir := filepath.Join(claudeDir, "goals")
|
||||
runtimeDir := filepath.Join(claudeDir, "runtime")
|
||||
|
||||
entries, err := os.ReadDir(sessionsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []ClaudeFleet{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read sessions dir %q: %w", sessionsDir, err)
|
||||
}
|
||||
|
||||
fleet := make([]ClaudeFleet, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
raw, readErr := os.ReadFile(filepath.Join(sessionsDir, entry.Name()))
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
var sess sessionFile
|
||||
if json.Unmarshal(raw, &sess) != nil {
|
||||
continue
|
||||
}
|
||||
if sess.PID == 0 || sess.SessionID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
f := ClaudeFleet{
|
||||
PID: sess.PID,
|
||||
SessionID: sess.SessionID,
|
||||
Status: sess.Status,
|
||||
Cwd: sess.Cwd,
|
||||
UpdatedAt: sess.UpdatedAt,
|
||||
TmuxWindow: "",
|
||||
}
|
||||
|
||||
// Liveness + anti-PID-recycling: the process must exist AND its
|
||||
// /proc starttime must match the procStart recorded in the JSON.
|
||||
f.Alive = procIsAlive(sess.PID, sess.ProcStart)
|
||||
|
||||
// KITTY_PID from the process environ (0 if unreadable / absent).
|
||||
f.KittyPID = readKittyPID(sess.PID)
|
||||
|
||||
// Join goal/phase/emojis/name from goals/<sessionId>.json (optional).
|
||||
f.Goal, f.Phase, f.Emojis, f.Name = readGoal(goalsDir, sess.SessionID)
|
||||
|
||||
// Context usage from runtime/<sessionId>.json (written by statusline).
|
||||
f.CtxPct = readCtxPct(runtimeDir, sess.SessionID)
|
||||
|
||||
// Derived display fields.
|
||||
f.Target = deriveTarget(sess.SessionID, sess.Cwd)
|
||||
f.Rename = deriveRename(f.Goal, sess.Cwd)
|
||||
|
||||
fleet = append(fleet, f)
|
||||
}
|
||||
|
||||
sortFleet(fleet)
|
||||
return fleet, nil
|
||||
}
|
||||
|
||||
// procIsAlive reports whether pid is running and its kernel starttime matches
|
||||
// procStartJSON. An empty procStartJSON only requires the process to exist.
|
||||
func procIsAlive(pid int, procStartJSON string) bool {
|
||||
real, ok := procStartTime(pid)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if procStartJSON == "" {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(procStartJSON) == strings.TrimSpace(real)
|
||||
}
|
||||
|
||||
// procStartTime returns field 22 (starttime, in clock ticks) of
|
||||
// /proc/<pid>/stat. The comm field (field 2) is wrapped in parentheses and may
|
||||
// itself contain spaces and ')' characters, so we parse the portion after the
|
||||
// LAST ')' and index from there: starttime is index 20 of that remainder
|
||||
// (fields 3..n), which is field 22 globally.
|
||||
func procStartTime(pid int) (string, bool) {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
s := string(data)
|
||||
close := strings.LastIndex(s, ")")
|
||||
if close < 0 || close+1 >= len(s) {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.Fields(s[close+1:])
|
||||
// rest[0] = state (field 3); starttime (field 22) is index 19 here:
|
||||
// field N maps to rest[N-3]. 22 - 3 = 19.
|
||||
const startTimeIdx = 19
|
||||
if len(rest) <= startTimeIdx {
|
||||
return "", false
|
||||
}
|
||||
return rest[startTimeIdx], true
|
||||
}
|
||||
|
||||
// readKittyPID parses /proc/<pid>/environ (NUL-separated KEY=VALUE pairs) and
|
||||
// returns the KITTY_PID value. Returns 0 if the environ is unreadable, the key
|
||||
// is absent, or the value is not an integer.
|
||||
func readKittyPID(pid int) int {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/environ", pid))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, kv := range strings.Split(string(data), "\x00") {
|
||||
if v, ok := strings.CutPrefix(kv, "KITTY_PID="); ok {
|
||||
n, convErr := strconv.Atoi(strings.TrimSpace(v))
|
||||
if convErr != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// readGoal reads goals/<sessionID>.json and returns its goal, phase, emojis and
|
||||
// manual rename. If the file is absent or unparseable, all are "".
|
||||
func readGoal(goalsDir, sessionID string) (goal, phase, emojis, rename string) {
|
||||
raw, err := os.ReadFile(filepath.Join(goalsDir, sessionID+".json"))
|
||||
if err != nil {
|
||||
return "", "", "", ""
|
||||
}
|
||||
var g goalFile
|
||||
if json.Unmarshal(raw, &g) != nil {
|
||||
return "", "", "", ""
|
||||
}
|
||||
return g.Goal, g.Phase, g.Emojis, g.Rename
|
||||
}
|
||||
|
||||
// readCtxPct reads runtime/<sessionID>.json and returns the context-window used
|
||||
// percentage. Returns -1 if the file is absent or unparseable (unknown).
|
||||
func readCtxPct(runtimeDir, sessionID string) int {
|
||||
raw, err := os.ReadFile(filepath.Join(runtimeDir, sessionID+".json"))
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
var r runtimeFile
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
return -1
|
||||
}
|
||||
return r.CtxPct
|
||||
}
|
||||
|
||||
// deriveTarget builds sessionID[:8] + "@" + basename(cwd). If sessionID is
|
||||
// shorter than 8 runes it is used whole.
|
||||
func deriveTarget(sessionID, cwd string) string {
|
||||
short := sessionID
|
||||
if r := []rune(sessionID); len(r) >= 8 {
|
||||
short = string(r[:8])
|
||||
}
|
||||
return short + "@" + filepath.Base(cwd)
|
||||
}
|
||||
|
||||
// deriveRename returns goal truncated to 48 runes if non-empty, else
|
||||
// basename(cwd).
|
||||
func deriveRename(goal, cwd string) string {
|
||||
if goal != "" {
|
||||
return truncateRunes(goal, 48)
|
||||
}
|
||||
return filepath.Base(cwd)
|
||||
}
|
||||
|
||||
// truncateRunes returns s capped at max runes (no ellipsis).
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
// sortFleet orders the fleet by status rank then by UpdatedAt descending.
|
||||
func sortFleet(fleet []ClaudeFleet) {
|
||||
rank := func(status string) int {
|
||||
switch status {
|
||||
case "idle":
|
||||
return 0
|
||||
case "waiting":
|
||||
return 1
|
||||
case "busy":
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
sort.SliceStable(fleet, func(i, j int) bool {
|
||||
ri, rj := rank(fleet[i].Status), rank(fleet[j].Status)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return fleet[i].UpdatedAt > fleet[j].UpdatedAt
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: list_claude_fleet
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ListClaudeFleetFrom(claudeDir string) ([]ClaudeFleet, error) | func ListClaudeFleet() ([]ClaudeFleet, error)"
|
||||
description: "Lista la flota de procesos Claude Code de la maquina local (Linux). Escanea ~/.claude/sessions/*.json, cruza cada PID vivo contra /proc para validar liveness (anti-PID-reciclado via procStart == campo 22 de /proc/<pid>/stat), une el goal/phase de ~/.claude/goals/<sessionId>.json, extrae KITTY_PID del environ y deriva los campos de display (Target, Rename). Devuelve todas las sesiones ordenadas por status (idle, waiting, busy, otro) y por updatedAt desc; el caller filtra por Alive. Pieza de datos de la app TUI fleetview."
|
||||
tags: [claude-fleet, infra, claude, session, proc, fleet, tui]
|
||||
uses_functions: []
|
||||
uses_types: [claude_fleet_go_infra]
|
||||
returns: [claude_fleet_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "claudeDir"
|
||||
desc: "Directorio raiz de Claude Code a escanear (ej. /home/enmanuel/.claude). ListClaudeFleetFrom lo recibe explicito (testeable con t.TempDir()); ListClaudeFleet lo resuelve via os.UserHomeDir() + .claude."
|
||||
output: "Slice de ClaudeFleet (claude_fleet_go_infra), una entrada por sesion con JSON parseable en sessions/. Cada entrada lleva PID, KittyPID, SessionID, Rename, Target, Goal, Phase, Status, Cwd, TmuxWindow (\"\"), Alive y UpdatedAt. Ordenado por rango de status y luego por UpdatedAt descendente. Devuelve slice vacio (sin error) si la carpeta sessions/ no existe; error si no se puede leer la carpeta por otra causa."
|
||||
tested: true
|
||||
tests: ["TestListClaudeFleetFrom", "TestListClaudeFleetFromMissingDir"]
|
||||
test_file_path: "functions/infra/list_claude_fleet_test.go"
|
||||
file_path: "functions/infra/list_claude_fleet.go"
|
||||
notes: "Misma fuente de verdad que reboot_all_claudes_bash_infra (~/.claude/sessions/<PID>.json de Claude Code 2.1.x: pid, sessionId, cwd, procStart, status, updatedAt). Solo LEE y valida — no relanza ni mata nada. La validacion anti-PID-reciclado replica la del bash (procStart del JSON vs campo 22 de /proc/<pid>/stat) pero parseando de forma robusta el comm (campo 2 entre parentesis, que puede contener espacios y ')'): se toma lo que hay tras el ULTIMO ')' y starttime es el indice 19 de ese resto. TmuxWindow queda \"\" (se rellena en una fase posterior). Build tag //go:build !windows (depende de /proc, no portable a Windows)."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fleet, err := infra.ListClaudeFleet() // escanea ~/.claude
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, c := range fleet {
|
||||
if !c.Alive {
|
||||
continue // el caller filtra las sesiones muertas
|
||||
}
|
||||
fmt.Printf("[%s] %-20s pid=%d kitty=%d %s\n",
|
||||
c.Status, c.Rename, c.PID, c.KittyPID, c.Target)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Variante testeable: escanea un directorio arbitrario (fixtures en tests).
|
||||
fleet, _ := infra.ListClaudeFleetFrom("/home/enmanuel/.claude")
|
||||
fmt.Println(len(fleet), "sesiones conocidas")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites enumerar las sesiones de Claude Code vivas en la maquina local para mostrarlas, monitorizarlas o actuar sobre ellas (TUI fleetview, dashboards, automatizaciones). Da el join PID -> sessionId -> cwd -> goal/phase ya resuelto y validado contra /proc, en lugar de reimplementarlo a mano cada vez. Usa `ListClaudeFleetFrom` en tests (inyectando un directorio con fixtures) y `ListClaudeFleet` en runtime real.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: lee el filesystem y /proc.** No es determinista entre llamadas (las sesiones nacen y mueren). Solo lectura — nunca mata ni relanza procesos.
|
||||
- **Anti-PID-reciclado.** `Alive` solo es true si el proceso existe Y su starttime (campo 22 de `/proc/<pid>/stat`) coincide con el `procStart` del JSON. Un JSON huerfano cuyo PID fue reasignado a otro proceso se marca `Alive=false` aunque ese PID este vivo. Si el JSON no trae `procStart`, basta con que el proceso exista.
|
||||
- **Parseo del `comm` en /proc/<pid>/stat.** El campo 2 (comm) va entre parentesis y puede contener espacios y el caracter ')'. La funcion parsea tomando lo que hay tras el ULTIMO ')'; un split ingenuo por espacios daria un starttime equivocado.
|
||||
- **/proc no es portable.** Build tag `//go:build !windows`; depende de `/proc/<pid>/stat` y `/proc/<pid>/environ` (Linux). En macOS/BSD no funciona tal cual.
|
||||
- **environ ilegible -> KittyPID=0.** Si `/proc/<pid>/environ` no es legible (permisos, proceso de otro usuario, o el proceso ya murio entre el ReadDir y el ReadFile) `KittyPID` cae a 0 sin error. Tambien es 0 legitimamente cuando claude no corre bajo kitty (ej. tmux remoto).
|
||||
- **Devuelve TODAS las sesiones con JSON parseable**, vivas o muertas. El caller decide filtrar por `Alive`. Archivos no-`.json` y JSON corrupto se ignoran silenciosamente.
|
||||
- **TmuxWindow siempre "".** Reservado para una fase posterior; hoy no se rellena.
|
||||
@@ -0,0 +1,162 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// readOwnProcStart reads field 22 (starttime) of /proc/<pid>/stat for the
|
||||
// current test process, so a fixture can be marked Alive deterministically.
|
||||
func readOwnProcStart(t *testing.T, pid int) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||
if err != nil {
|
||||
t.Fatalf("read own /proc/%d/stat: %v", pid, err)
|
||||
}
|
||||
s := string(data)
|
||||
close := strings.LastIndex(s, ")")
|
||||
if close < 0 {
|
||||
t.Fatalf("malformed stat line: %q", s)
|
||||
}
|
||||
rest := strings.Fields(s[close+1:])
|
||||
const startTimeIdx = 19 // field 22 == rest[22-3]
|
||||
if len(rest) <= startTimeIdx {
|
||||
t.Fatalf("stat has too few fields after comm: %d", len(rest))
|
||||
}
|
||||
return rest[startTimeIdx]
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %q: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListClaudeFleetFrom(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
sessions := filepath.Join(tmp, "sessions")
|
||||
goals := filepath.Join(tmp, "goals")
|
||||
|
||||
livePID := os.Getpid()
|
||||
liveProcStart := readOwnProcStart(t, livePID)
|
||||
|
||||
const deadPID = 2147480000 // implausibly high; no such process
|
||||
|
||||
// Session A: alive (own PID), with a goal -> rename = truncated goal,
|
||||
// status idle. cwd basename = fn_registry.
|
||||
writeFile(t, filepath.Join(sessions, fmt.Sprintf("%d.json", livePID)),
|
||||
fmt.Sprintf(`{"pid":%d,"sessionId":"aaaaaaaa-1111-2222-3333-444444444444","cwd":"/home/enmanuel/fn_registry","procStart":%q,"status":"idle","updatedAt":1000}`,
|
||||
livePID, liveProcStart))
|
||||
writeFile(t, filepath.Join(goals, "aaaaaaaa-1111-2222-3333-444444444444.json"),
|
||||
`{"goal":"Recomendar stack tecnologico para la nueva app de inventario y validar dependencias","phase":"investigando","history":["haciendo","investigando"]}`)
|
||||
|
||||
// Session B: alive (own PID again — same process, valid procStart), no
|
||||
// goal sidecar -> rename = basename(cwd) = projectx, status busy.
|
||||
writeFile(t, filepath.Join(sessions, "b.json"),
|
||||
fmt.Sprintf(`{"pid":%d,"sessionId":"bbbbbbbb-5555","cwd":"/var/tmp/projectx","procStart":%q,"status":"busy","updatedAt":2000}`,
|
||||
livePID, liveProcStart))
|
||||
|
||||
// Session C: dead PID -> Alive=false, status waiting, has goal.
|
||||
writeFile(t, filepath.Join(sessions, fmt.Sprintf("%d.json", deadPID)),
|
||||
fmt.Sprintf(`{"pid":%d,"sessionId":"cccccccc-9999-0000","cwd":"/srv/work/zeta","procStart":"99999999","status":"waiting","updatedAt":3000}`,
|
||||
deadPID))
|
||||
writeFile(t, filepath.Join(goals, "cccccccc-9999-0000.json"),
|
||||
`{"goal":"limpiar logs","phase":"haciendo"}`)
|
||||
|
||||
// Noise files that must be ignored.
|
||||
writeFile(t, filepath.Join(sessions, "notjson.txt"), "ignore me")
|
||||
writeFile(t, filepath.Join(sessions, "broken.json"), "{ this is not json")
|
||||
|
||||
fleet, err := ListClaudeFleetFrom(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("ListClaudeFleetFrom: %v", err)
|
||||
}
|
||||
if len(fleet) != 3 {
|
||||
t.Fatalf("expected 3 sessions, got %d: %+v", len(fleet), fleet)
|
||||
}
|
||||
|
||||
by := map[string]ClaudeFleet{}
|
||||
for _, f := range fleet {
|
||||
by[f.SessionID] = f
|
||||
}
|
||||
|
||||
// --- Session A assertions ---
|
||||
a := by["aaaaaaaa-1111-2222-3333-444444444444"]
|
||||
if !a.Alive {
|
||||
t.Errorf("session A: expected Alive=true (own PID + matching procStart)")
|
||||
}
|
||||
if a.Goal != "Recomendar stack tecnologico para la nueva app de inventario y validar dependencias" {
|
||||
t.Errorf("session A: goal join failed, got %q", a.Goal)
|
||||
}
|
||||
if a.Phase != "investigando" {
|
||||
t.Errorf("session A: phase join failed, got %q", a.Phase)
|
||||
}
|
||||
// Rename = goal truncated to 48 runes.
|
||||
wantRename := string([]rune(a.Goal)[:48])
|
||||
if a.Rename != wantRename {
|
||||
t.Errorf("session A: rename = %q, want truncated goal %q", a.Rename, wantRename)
|
||||
}
|
||||
if len([]rune(a.Rename)) != 48 {
|
||||
t.Errorf("session A: rename should be 48 runes, got %d", len([]rune(a.Rename)))
|
||||
}
|
||||
if a.Target != "aaaaaaaa@fn_registry" {
|
||||
t.Errorf("session A: target = %q, want %q", a.Target, "aaaaaaaa@fn_registry")
|
||||
}
|
||||
|
||||
// --- Session B assertions: no goal -> fallback rename = basename(cwd) ---
|
||||
b := by["bbbbbbbb-5555"]
|
||||
if b.Goal != "" || b.Phase != "" {
|
||||
t.Errorf("session B: expected empty goal/phase, got goal=%q phase=%q", b.Goal, b.Phase)
|
||||
}
|
||||
if b.Rename != "projectx" {
|
||||
t.Errorf("session B: rename = %q, want basename(cwd) %q", b.Rename, "projectx")
|
||||
}
|
||||
if b.Target != "bbbbbbbb@projectx" {
|
||||
t.Errorf("session B: target = %q, want %q", b.Target, "bbbbbbbb@projectx")
|
||||
}
|
||||
if !b.Alive {
|
||||
t.Errorf("session B: expected Alive=true (own PID + matching procStart)")
|
||||
}
|
||||
|
||||
// --- Session C assertions: dead PID ---
|
||||
c := by["cccccccc-9999-0000"]
|
||||
if c.Alive {
|
||||
t.Errorf("session C: expected Alive=false for dead PID %d", deadPID)
|
||||
}
|
||||
if c.Target != "cccccccc@zeta" {
|
||||
t.Errorf("session C: target = %q, want %q", c.Target, "cccccccc@zeta")
|
||||
}
|
||||
|
||||
// --- Ordering: status rank idle(0) < waiting(1) < busy(2) ---
|
||||
// A=idle, C=waiting, B=busy => expected order A, C, B.
|
||||
wantOrder := []string{
|
||||
"aaaaaaaa-1111-2222-3333-444444444444",
|
||||
"cccccccc-9999-0000",
|
||||
"bbbbbbbb-5555",
|
||||
}
|
||||
for i, want := range wantOrder {
|
||||
if fleet[i].SessionID != want {
|
||||
t.Errorf("order[%d] = %q (status %q), want %q", i, fleet[i].SessionID, fleet[i].Status, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListClaudeFleetFromMissingDir(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
fleet, err := ListClaudeFleetFrom(filepath.Join(tmp, "nope"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for missing sessions dir, got %v", err)
|
||||
}
|
||||
if len(fleet) != 0 {
|
||||
t.Fatalf("expected empty fleet, got %d", len(fleet))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResumableClaude describes a CLOSED Claude Code session that still has a saved
|
||||
// goal and can therefore be reopened with `claude --resume <SessionID>`. The
|
||||
// fleetview TUI consumes these for its "resume" picker.
|
||||
type ResumableClaude struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Goal string `json:"goal"` // from goals/<id>.json .goal ("" if absent)
|
||||
Emojis string `json:"emojis"` // from goals/<id>.json .emojis ("" if absent)
|
||||
Name string `json:"name"` // from goals/<id>.json .rename ("" if absent)
|
||||
LastActive int64 `json:"last_active"` // mtime of the goal.json file, epoch seconds
|
||||
}
|
||||
|
||||
// maxResumable caps the number of resumable sessions returned, keeping only the
|
||||
// most recently touched ones.
|
||||
const maxResumable = 40
|
||||
|
||||
// ListResumableClaudes scans the current user's ~/.claude directory and returns
|
||||
// the closed sessions that can be reopened with `claude --resume`. It is a thin
|
||||
// wrapper over ListResumableClaudesFrom resolving the home directory.
|
||||
func ListResumableClaudes() ([]ResumableClaude, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve home dir: %w", err)
|
||||
}
|
||||
return ListResumableClaudesFrom(filepath.Join(home, ".claude"))
|
||||
}
|
||||
|
||||
// ListResumableClaudesFrom scans claudeDir (e.g. ~/.claude) and returns the
|
||||
// sessions that have a goal (goals/<id>.json) whose process is NOT alive — i.e.
|
||||
// candidates to reopen with `claude --resume <SessionID>`.
|
||||
//
|
||||
// A session is considered live (and thus excluded) when sessions/<PID>.json
|
||||
// reports a PID whose /proc starttime matches the recorded procStart, using the
|
||||
// exact same liveness criterion as ListClaudeFleetFrom (procIsAlive). Goals
|
||||
// without a non-empty goal string are skipped. Results are ordered by
|
||||
// LastActive descending and capped at maxResumable.
|
||||
func ListResumableClaudesFrom(claudeDir string) ([]ResumableClaude, error) {
|
||||
sessionsDir := filepath.Join(claudeDir, "sessions")
|
||||
goalsDir := filepath.Join(claudeDir, "goals")
|
||||
|
||||
// 1. Build the set of LIVE sessionIds from sessions/*.json.
|
||||
live := liveSessionIDs(sessionsDir)
|
||||
|
||||
// 2. Scan goals/*.json.
|
||||
entries, err := os.ReadDir(goalsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []ResumableClaude{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read goals dir %q: %w", goalsDir, err)
|
||||
}
|
||||
|
||||
out := make([]ResumableClaude, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
sessionID := strings.TrimSuffix(name, ".json")
|
||||
if sessionID == "" {
|
||||
continue
|
||||
}
|
||||
// Skip sessions that are alive (already in the fleet, not resumable).
|
||||
if live[sessionID] {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(goalsDir, name)
|
||||
raw, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
var g goalFile
|
||||
if json.Unmarshal(raw, &g) != nil {
|
||||
continue
|
||||
}
|
||||
// No real work to resume without a goal.
|
||||
if strings.TrimSpace(g.Goal) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, ResumableClaude{
|
||||
SessionID: sessionID,
|
||||
Goal: g.Goal,
|
||||
Emojis: g.Emojis,
|
||||
Name: g.Rename,
|
||||
LastActive: info.ModTime().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Order by LastActive descending (most recent first).
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].LastActive > out[j].LastActive
|
||||
})
|
||||
|
||||
// 4. Cap at maxResumable.
|
||||
if len(out) > maxResumable {
|
||||
out = out[:maxResumable]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// liveSessionIDs scans sessionsDir (sessions/*.json) and returns the set of
|
||||
// sessionIds whose process is currently alive, applying the same anti-PID-
|
||||
// recycling check as ListClaudeFleetFrom (procIsAlive matches /proc starttime
|
||||
// against the recorded procStart). Missing or unparseable files are ignored.
|
||||
func liveSessionIDs(sessionsDir string) map[string]bool {
|
||||
live := make(map[string]bool)
|
||||
entries, err := os.ReadDir(sessionsDir)
|
||||
if err != nil {
|
||||
return live
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
raw, readErr := os.ReadFile(filepath.Join(sessionsDir, entry.Name()))
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
var sess sessionFile
|
||||
if json.Unmarshal(raw, &sess) != nil {
|
||||
continue
|
||||
}
|
||||
if sess.PID == 0 || sess.SessionID == "" {
|
||||
continue
|
||||
}
|
||||
if procIsAlive(sess.PID, sess.ProcStart) {
|
||||
live[sess.SessionID] = true
|
||||
}
|
||||
}
|
||||
return live
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: list_resumable_claudes
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ListResumableClaudesFrom(claudeDir string) ([]ResumableClaude, error) | func ListResumableClaudes() ([]ResumableClaude, error)"
|
||||
description: "Lista las sesiones de Claude Code CERRADAS que se pueden reabrir con `claude --resume <sessionId>` (Linux). Escanea ~/.claude/sessions/*.json para construir el conjunto de sessionIds VIVOS (mismo criterio anti-PID-reciclado que list_claude_fleet: procStart == campo 22 de /proc/<pid>/stat), luego recorre ~/.claude/goals/*.json y devuelve cada sesion cuyo proceso NO esta vivo y que tiene un goal no vacio. Cada entrada lleva session_id, goal, emojis y name (rename) del goal.json, y last_active = mtime del goal.json. Ordenadas por last_active desc y limitadas a 40. Pieza de datos del picker de resume de la app TUI fleetview."
|
||||
tags: [claude-fleet, infra, claude, session, resume, proc, tui]
|
||||
uses_functions: [list_claude_fleet_go_infra]
|
||||
uses_types: [resumable_claude_go_infra]
|
||||
returns: [resumable_claude_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "claudeDir"
|
||||
desc: "Directorio raiz de Claude Code a escanear (ej. /home/enmanuel/.claude). ListResumableClaudesFrom lo recibe explicito (testeable con t.TempDir()); ListResumableClaudes lo resuelve via os.UserHomeDir() + .claude."
|
||||
output: "Slice de ResumableClaude (resumable_claude_go_infra), una entrada por sesion CERRADA con goal en goals/<id>.json. Cada entrada lleva SessionID (basename del goal.json sin .json), Goal, Emojis, Name (rename) y LastActive (mtime del goal.json en epoch segundos). Excluye las sesiones cuyo proceso sigue vivo (ya en la flota) y las que no tienen goal. Ordenado por LastActive descendente y capado a 40 resultados. Devuelve slice vacio (sin error) si la carpeta goals/ no existe; error si no se puede leer por otra causa."
|
||||
tested: true
|
||||
tests: ["TestListResumableClaudesFrom"]
|
||||
test_file_path: "functions/infra/resumable_claude_test.go"
|
||||
file_path: "functions/infra/resumable_claude.go"
|
||||
notes: "Complementaria de list_claude_fleet_go_infra: aquella lista las sesiones VIVAS, esta las CERRADAS-pero-resumibles. Reutiliza los helpers procIsAlive/procStartTime del mismo paquete infra (definidos en functions/infra/list_claude_fleet.go) — no los redefine. El conjunto de vivos se construye desde sessions/*.json; el catalogo de candidatas desde goals/*.json. El sessionId de una candidata es el basename del goal.json (no hay sessions/<PID>.json para ella porque su proceso ya murio). LastActive es el mtime del archivo, no la actividad real de la conversacion. Build tag //go:build !windows (depende de /proc, no portable a Windows)."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
resumables, err := infra.ListResumableClaudes() // escanea ~/.claude
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, r := range resumables {
|
||||
fmt.Printf("%s %-40s claude --resume %s\n", r.Emojis, r.Goal, r.SessionID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Variante testeable: escanea un directorio arbitrario (fixtures en tests).
|
||||
resumables, _ := infra.ListResumableClaudesFrom("/home/enmanuel/.claude")
|
||||
fmt.Println(len(resumables), "sesiones reabribles")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites poblar un picker de "reanudar" en la TUI fleetview (o cualquier UI/automatizacion equivalente): te da las sesiones de Claude Code que ya cerraste pero que tenian un objetivo guardado, listas para `claude --resume <session_id>`. Excluye las que siguen vivas (esas ya estan en la flota, las lista `list_claude_fleet_go_infra`). Usa `ListResumableClaudesFrom` en tests (inyectando un directorio con fixtures) y `ListResumableClaudes` en runtime real.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: lee el filesystem y /proc.** No es determinista entre llamadas (las sesiones nacen y mueren). Solo lectura — nunca relanza ni mata nada.
|
||||
- **El statusline purga goals viejos.** Las sesiones de mas de ~7 dias suelen tener su `goals/<id>.json` purgado por el statusline, asi que dejan de aparecer aqui aunque `claude --resume` siga pudiendo reabrirlas. Esta funcion solo ve lo que queda en `goals/`.
|
||||
- **PID reciclado.** El conjunto de "vivos" usa el mismo guardado anti-PID-reciclado que `list_claude_fleet`: un PID reasignado a otro proceso NO marca la sesion como viva (procStart != campo 22 de /proc/<pid>/stat), por lo que su goal seguira saliendo como resumible correctamente.
|
||||
- **Orden por mtime, no por actividad real.** `LastActive` es el `mtime` del `goal.json`, que se toca cuando el statusline reescribe el objetivo/fase — no es el instante exacto del ultimo mensaje de la conversacion. Es una aproximacion "lo mas reciente arriba", no un timestamp exacto de actividad.
|
||||
- **Cap a 40.** Solo se devuelven las 40 mas recientes; si hay mas goals cerrados, los antiguos se omiten.
|
||||
- **Goals sin goal o ilegibles se omiten** silenciosamente. Un `goal.json` con `goal` vacio (o solo espacios) no es resumible (no hay trabajo que reanudar). Archivos no-`.json` y JSON corrupto se ignoran.
|
||||
- **/proc no es portable.** Build tag `//go:build !windows`; depende de `/proc/<pid>/stat` (Linux) para decidir que sesiones estan vivas.
|
||||
@@ -0,0 +1,172 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeJSON marshals v and writes it to path, failing the test on error.
|
||||
func writeJSON(t *testing.T, path string, v any) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %q: %v", filepath.Dir(path), err)
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o644); err != nil {
|
||||
t.Fatalf("write %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// touch sets the mtime of path to the given unix epoch seconds.
|
||||
func touch(t *testing.T, path string, epoch int64) {
|
||||
t.Helper()
|
||||
mt := time.Unix(epoch, 0)
|
||||
if err := os.Chtimes(path, mt, mt); err != nil {
|
||||
t.Fatalf("chtimes %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListResumableClaudesFrom(t *testing.T) {
|
||||
t.Run("excluye sesion viva, incluye muertas con goal ordenadas por LastActive", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionsDir := filepath.Join(dir, "sessions")
|
||||
goalsDir := filepath.Join(dir, "goals")
|
||||
|
||||
// A LIVE session: real running PID (this test process) + its real
|
||||
// /proc starttime as procStart, so procIsAlive returns true.
|
||||
livePID := os.Getpid()
|
||||
liveStart, ok := procStartTime(livePID)
|
||||
if !ok {
|
||||
t.Fatalf("could not read procStartTime for self pid %d", livePID)
|
||||
}
|
||||
const liveSession = "11111111-aaaa-bbbb-cccc-000000000001"
|
||||
writeJSON(t, filepath.Join(sessionsDir, "9001.json"), sessionFile{
|
||||
PID: livePID,
|
||||
SessionID: liveSession,
|
||||
Cwd: "/tmp/live",
|
||||
ProcStart: liveStart,
|
||||
Status: "busy",
|
||||
})
|
||||
|
||||
// A goal for the live session: must be EXCLUDED (already in fleet).
|
||||
liveGoal := filepath.Join(goalsDir, liveSession+".json")
|
||||
writeJSON(t, liveGoal, goalFile{Goal: "trabajo en curso", Emojis: "🔥", Rename: "vivo"})
|
||||
touch(t, liveGoal, 5000)
|
||||
|
||||
// A DEAD session with a goal: must be INCLUDED. No sessions/ entry,
|
||||
// so it can never be live.
|
||||
const deadOld = "22222222-aaaa-bbbb-cccc-000000000002"
|
||||
oldGoal := filepath.Join(goalsDir, deadOld+".json")
|
||||
writeJSON(t, oldGoal, goalFile{Goal: "objetivo antiguo", Emojis: "🛠️", Rename: "viejo"})
|
||||
touch(t, oldGoal, 1000)
|
||||
|
||||
// Another DEAD session with a goal, more recent: must come FIRST.
|
||||
const deadNew = "33333333-aaaa-bbbb-cccc-000000000003"
|
||||
newGoal := filepath.Join(goalsDir, deadNew+".json")
|
||||
writeJSON(t, newGoal, goalFile{Goal: "objetivo reciente", Rename: "nuevo"})
|
||||
touch(t, newGoal, 4000)
|
||||
|
||||
// A DEAD session WITHOUT a goal string: must be OMITTED.
|
||||
const deadEmpty = "44444444-aaaa-bbbb-cccc-000000000004"
|
||||
emptyGoal := filepath.Join(goalsDir, deadEmpty+".json")
|
||||
writeJSON(t, emptyGoal, goalFile{Goal: " ", Emojis: "💤"})
|
||||
touch(t, emptyGoal, 6000)
|
||||
|
||||
got, err := ListResumableClaudesFrom(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ListResumableClaudesFrom: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d resumable, want 2: %+v", len(got), got)
|
||||
}
|
||||
|
||||
// Order by LastActive desc: deadNew (4000) before deadOld (1000).
|
||||
if got[0].SessionID != deadNew {
|
||||
t.Errorf("got[0].SessionID = %q, want %q", got[0].SessionID, deadNew)
|
||||
}
|
||||
if got[1].SessionID != deadOld {
|
||||
t.Errorf("got[1].SessionID = %q, want %q", got[1].SessionID, deadOld)
|
||||
}
|
||||
|
||||
// Live session must not appear.
|
||||
for _, r := range got {
|
||||
if r.SessionID == liveSession {
|
||||
t.Errorf("live session %q must be excluded", liveSession)
|
||||
}
|
||||
if r.SessionID == deadEmpty {
|
||||
t.Errorf("session without goal %q must be omitted", deadEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// Field mapping for the most-recent record.
|
||||
if got[0].Goal != "objetivo reciente" {
|
||||
t.Errorf("got[0].Goal = %q", got[0].Goal)
|
||||
}
|
||||
if got[0].Name != "nuevo" {
|
||||
t.Errorf("got[0].Name = %q, want \"nuevo\"", got[0].Name)
|
||||
}
|
||||
if got[0].LastActive != 4000 {
|
||||
t.Errorf("got[0].LastActive = %d, want 4000", got[0].LastActive)
|
||||
}
|
||||
if got[1].Emojis != "🛠️" {
|
||||
t.Errorf("got[1].Emojis = %q", got[1].Emojis)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dir de goals inexistente retorna slice vacio sin error", func(t *testing.T) {
|
||||
dir := t.TempDir() // no goals/ subdir
|
||||
got, err := ListResumableClaudesFrom(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %d, want 0", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cap a 40 resultados mas recientes", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
goalsDir := filepath.Join(dir, "goals")
|
||||
// 50 dead sessions with goals, mtimes 1..50.
|
||||
for i := 1; i <= 50; i++ {
|
||||
id := uuidLike(i)
|
||||
p := filepath.Join(goalsDir, id+".json")
|
||||
writeJSON(t, p, goalFile{Goal: "objetivo", Rename: id})
|
||||
touch(t, p, int64(i))
|
||||
}
|
||||
got, err := ListResumableClaudesFrom(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ListResumableClaudesFrom: %v", err)
|
||||
}
|
||||
if len(got) != 40 {
|
||||
t.Fatalf("got %d, want 40 (capped)", len(got))
|
||||
}
|
||||
// Most recent first: LastActive should be 50 then descending.
|
||||
if got[0].LastActive != 50 {
|
||||
t.Errorf("got[0].LastActive = %d, want 50", got[0].LastActive)
|
||||
}
|
||||
if got[39].LastActive != 11 {
|
||||
t.Errorf("got[39].LastActive = %d, want 11", got[39].LastActive)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// uuidLike builds a deterministic, unique filename stem for index i.
|
||||
func uuidLike(i int) string {
|
||||
const hex = "0123456789abcdef"
|
||||
b := []byte("00000000-0000-0000-0000-000000000000")
|
||||
// Fill the last 3 chars with i (i <= 50 fits in 2 hex digits, keep simple).
|
||||
b[len(b)-1] = hex[i%16]
|
||||
b[len(b)-2] = hex[(i/16)%16]
|
||||
b[len(b)-3] = hex[(i/256)%16]
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TmuxMapClaudePanes devuelve un mapa claudePID -> window_id de todos los panes
|
||||
// del socket cuyo proceso de pane (o algun descendiente directo) sea un proceso
|
||||
// `claude`. Permite a la TUI saber que Claude de su lista ya vive en la sesion
|
||||
// fleet (y por tanto es conmutable) y en que window.
|
||||
//
|
||||
// Como cada pane que corre Claude lo hace con `exec claude ...`, el #{pane_pid}
|
||||
// del pane normalmente ES el PID de claude (comm == "claude"). Por robustez, si
|
||||
// el propio pane_pid no es claude (p.ej. un shell que lanzo claude como hijo),
|
||||
// se recorren sus descendientes directos buscando el primer comm == "claude".
|
||||
// Si no se encuentra claude bajo un pane, ese pane se omite.
|
||||
//
|
||||
// Opera SIEMPRE sobre el socket aislado pasado como parametro (tmux -L <socket>)
|
||||
// y lee /proc (no portable a Windows; de ahi el build tag //go:build !windows).
|
||||
func TmuxMapClaudePanes(socket string) (map[int]string, error) {
|
||||
if socket == "" {
|
||||
return nil, fmt.Errorf("tmux_map_claude_panes: socket vacio")
|
||||
}
|
||||
|
||||
out, stderr, err := runTmux(socket, "list-panes", "-a", "-F", "#{pane_pid} #{window_id}")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tmux_map_claude_panes: list-panes -a: %w (%s)", err, stderr)
|
||||
}
|
||||
|
||||
result := make(map[int]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
panePID, convErr := strconv.Atoi(fields[0])
|
||||
if convErr != nil {
|
||||
continue
|
||||
}
|
||||
windowID := fields[1]
|
||||
|
||||
claudePID, ok := findClaudePID(panePID)
|
||||
if !ok {
|
||||
continue // no hay claude bajo este pane
|
||||
}
|
||||
result[claudePID] = windowID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// findClaudePID devuelve el PID de un proceso `claude` que sea el propio pid o
|
||||
// un hijo directo suyo. Devuelve (pid, true) si lo encuentra; (0, false) si no.
|
||||
func findClaudePID(pid int) (int, bool) {
|
||||
if procComm(pid) == "claude" {
|
||||
return pid, true
|
||||
}
|
||||
for _, child := range procChildren(pid) {
|
||||
if procComm(child) == "claude" {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// procComm lee el nombre del comando (comm) de /proc/<pid>/comm. Devuelve ""
|
||||
// si el proceso no existe o no se puede leer.
|
||||
func procComm(pid int) string {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// procChildren devuelve los PIDs de los hijos DIRECTOS de <pid>. Intenta primero
|
||||
// /proc/<pid>/task/<pid>/children (rapido, requiere CONFIG_PROC_CHILDREN); si no
|
||||
// esta disponible, cae a escanear /proc/*/stat por PPID (campo 4).
|
||||
func procChildren(pid int) []int {
|
||||
if kids := procChildrenFromTask(pid); kids != nil {
|
||||
return kids
|
||||
}
|
||||
return procChildrenFromScan(pid)
|
||||
}
|
||||
|
||||
// procChildrenFromTask agrega /proc/<pid>/task/<tid>/children sobre TODOS los
|
||||
// hilos (tasks) del proceso. Cada `children` lista solo los hijos parenteados
|
||||
// a ESE task, asi que un proceso multihilo (un shell que hizo fork desde un
|
||||
// hilo no principal, o el propio test runner de Go) puede tener hijos repartidos
|
||||
// entre varios tasks. Devuelve nil si el directorio task/ no existe o ningun
|
||||
// task expone `children` (kernel sin CONFIG_PROC_CHILDREN), para que el caller
|
||||
// use el fallback de scan por PPID.
|
||||
func procChildrenFromTask(pid int) []int {
|
||||
taskDir := fmt.Sprintf("/proc/%d/task", pid)
|
||||
tasks, err := os.ReadDir(taskDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var kids []int
|
||||
supported := false
|
||||
for _, task := range tasks {
|
||||
tid := task.Name()
|
||||
data, err := os.ReadFile(filepath.Join(taskDir, tid, "children"))
|
||||
if err != nil {
|
||||
continue // este task no expone children; probar el resto
|
||||
}
|
||||
supported = true
|
||||
for _, tok := range strings.Fields(string(data)) {
|
||||
if k, err := strconv.Atoi(tok); err == nil {
|
||||
kids = append(kids, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
return nil // kernel sin CONFIG_PROC_CHILDREN -> fallback a scan
|
||||
}
|
||||
// Distinguir "sin hijos" (slice vacio no-nil) de "sin soporte" (nil arriba).
|
||||
if kids == nil {
|
||||
return []int{}
|
||||
}
|
||||
return kids
|
||||
}
|
||||
|
||||
// procChildrenFromScan escanea /proc/*/stat buscando procesos cuyo PPID (campo
|
||||
// 4 de stat, indice 1 tras el comm entre parentesis) sea <pid>.
|
||||
func procChildrenFromScan(parent int) []int {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var kids []int
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
childPID, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue // no es un directorio de PID
|
||||
}
|
||||
if procPPID(childPID) == parent {
|
||||
kids = append(kids, childPID)
|
||||
}
|
||||
}
|
||||
return kids
|
||||
}
|
||||
|
||||
// procPPID extrae el PPID (campo 4 de /proc/<pid>/stat). El comm (campo 2) va
|
||||
// entre parentesis y puede contener espacios y ')', asi que se parsea tomando
|
||||
// lo que hay tras el ULTIMO ')'. Tras el comm, los campos son: state(0) ppid(1)
|
||||
// pgrp(2)... -> el PPID es el indice 1 de ese resto.
|
||||
func procPPID(pid int) int {
|
||||
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
s := string(data)
|
||||
close := strings.LastIndex(s, ")")
|
||||
if close < 0 {
|
||||
return -1
|
||||
}
|
||||
rest := strings.Fields(s[close+1:])
|
||||
const ppidIdx = 1 // state=rest[0], ppid=rest[1]
|
||||
if len(rest) <= ppidIdx {
|
||||
return -1
|
||||
}
|
||||
ppid, err := strconv.Atoi(rest[ppidIdx])
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return ppid
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: tmux_map_claude_panes
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func TmuxMapClaudePanes(socket string) (map[int]string, error)"
|
||||
description: "Devuelve un mapa claudePID -> window_id de todos los panes de un socket tmux aislado (tmux -L <socket>) cuyo proceso de pane (o un descendiente directo) sea un proceso `claude`. Lee /proc para decidir si cada #{pane_pid} es o tiene como hijo un comm == 'claude'. Permite a la TUI fleetview saber que Claude de su lista ya vive en la sesion fleet (y por tanto es conmutable) y en que window. Capa de control tmux de fleetview."
|
||||
tags: [claude-fleet, infra, tmux, claude, proc, fleet, tui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "socket"
|
||||
desc: "Nombre del socket tmux aislado (tmux -L <socket>). En fleetview es 'fleet'. Escanea TODOS los panes del servidor de ese socket (list-panes -a)."
|
||||
output: "map[int]string con clave = PID del proceso claude encontrado bajo cada pane y valor = window_id (@N) de ese pane. Panes sin claude (ni pane_pid ni hijo directo con comm 'claude') se omiten. Mapa vacio (sin error) si ningun pane corre claude. Error si socket viene vacio o si `tmux list-panes -a` falla."
|
||||
tested: true
|
||||
tests: ["TestTmuxMapClaudePanesNoClaude", "TestTmuxMapClaudePanesEmptySocket", "TestProcCommSelf", "TestFindClaudePIDDetectsChild"]
|
||||
test_file_path: "functions/infra/tmux_map_claude_panes_test.go"
|
||||
file_path: "functions/infra/tmux_map_claude_panes.go"
|
||||
notes: "Build tag //go:build !windows (depende de /proc). Comparte runTmux con tmux_new_claude_window y tmux_swap_window_into_console (mismo paquete infra). Deteccion claude: lee /proc/<pid>/comm; si no es 'claude', recorre hijos directos. Hijos directos via /proc/<pid>/task/<pid>/children (rapido, requiere CONFIG_PROC_CHILDREN); fallback a escanear /proc/*/stat por PPID (campo 4, parseando el comm entre parentesis tomando lo que hay tras el ULTIMO ')'). En produccion cada pane corre `exec claude`, asi que pane_pid == claude PID y basta el primer comm; el barrido de hijos es robustez para shells intermedios."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Que Claude ya vive en la sesion fleet (socket aislado 'fleet') y donde.
|
||||
byPID, err := infra.TmuxMapClaudePanes("fleet")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for claudePID, windowID := range byPID {
|
||||
fmt.Printf("claude pid=%d -> window %s\n", claudePID, windowID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando la TUI fleetview refresca su lista de Claudes y necesita marcar cuales ya estan dentro de la sesion `fleet` (conmutables con `tmux_swap_window_into_console`) y en que window. Cruza el PID de cada entrada de `list_claude_fleet` contra este mapa: si el PID esta, el Claude es swap-able y el valor es su `window_id`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Mapea por PID de claude, no por pane_pid: si el pane corre un shell que lanzo claude como hijo, la clave es el PID del hijo claude.
|
||||
- Solo busca hijos DIRECTOS (un nivel). En produccion fleetview usa `exec claude`, asi que pane_pid == claude PID y el caso comun no necesita el barrido.
|
||||
- Depende de `/proc` (Linux): build tag `//go:build !windows`. En kernels sin `CONFIG_PROC_CHILDREN` cae a escanear `/proc/*/stat` por PPID, mas lento pero equivalente.
|
||||
- Lee `comm` (truncado a 15 chars por el kernel); `claude` cabe entero, sin riesgo de truncado.
|
||||
- Panes sin claude se omiten silenciosamente: un mapa vacio significa "ningun Claude vivo en este socket", no es error.
|
||||
- Opera SIEMPRE sobre el socket aislado (`tmux -L <socket>`), escaneando todos sus panes con `list-panes -a`.
|
||||
@@ -0,0 +1,102 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTmuxMapClaudePanesNoClaude verifica que, sobre un servidor tmux aislado
|
||||
// cuyos panes solo corren `cat` (no claude), el mapa devuelto esta vacio: ningun
|
||||
// pane es ni tiene como hijo un proceso `claude`. Tambien valida que el comando
|
||||
// list-panes -a se ejecuta sin error sobre el socket aislado.
|
||||
func TestTmuxMapClaudePanesNoClaude(t *testing.T) {
|
||||
tmuxAvailable(t)
|
||||
socket := isolatedSocket(t)
|
||||
session := "fleet"
|
||||
startConsoleSession(t, socket, session)
|
||||
newCatWindow(t, socket, session)
|
||||
newCatWindow(t, socket, session)
|
||||
|
||||
m, err := TmuxMapClaudePanes(socket)
|
||||
if err != nil {
|
||||
t.Fatalf("TmuxMapClaudePanes: %v", err)
|
||||
}
|
||||
if len(m) != 0 {
|
||||
t.Errorf("ningun pane corre claude, el mapa deberia estar vacio, tiene %d: %v", len(m), m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxMapClaudePanesEmptySocket(t *testing.T) {
|
||||
if _, err := TmuxMapClaudePanes(""); err == nil {
|
||||
t.Error("socket vacio deberia dar error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcCommSelf valida procComm contra el propio proceso de test: comm debe
|
||||
// coincidir con el de /proc/self/comm (el binario de test, no "claude").
|
||||
func TestProcCommSelf(t *testing.T) {
|
||||
self := os.Getpid()
|
||||
got := procComm(self)
|
||||
if got == "" {
|
||||
t.Fatalf("procComm(%d) devolvio vacio", self)
|
||||
}
|
||||
want := strings.TrimSpace(readSelfComm(t))
|
||||
if got != want {
|
||||
t.Errorf("procComm(%d) = %q, /proc/self/comm = %q", self, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func readSelfComm(t *testing.T) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("/proc/self/comm")
|
||||
if err != nil {
|
||||
t.Fatalf("read /proc/self/comm: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// TestFindClaudePIDDetectsChild ejercita el mecanismo "¿este pid o hijo es
|
||||
// claude?" SIN claude real: lanza un proceso hijo cuyo comm sea verificable y
|
||||
// comprueba que (a) findClaudePID(propio pid) no lo confunde con claude, y (b)
|
||||
// procChildren detecta al hijo lanzado. Testear con un proceso `claude` real es
|
||||
// inviable en CI; este test valida el helper de deteccion con un comm conocido.
|
||||
func TestFindClaudePIDDetectsChild(t *testing.T) {
|
||||
// (a) El proceso de test NO es claude: findClaudePID no debe reportarlo.
|
||||
if _, ok := findClaudePID(os.Getpid()); ok {
|
||||
// Solo seria true si el binario de test se llamara "claude" (no es el caso).
|
||||
t.Errorf("findClaudePID(self) reporto claude para un proceso que no lo es")
|
||||
}
|
||||
|
||||
// (b) Lanzamos un hijo `sleep` (comm conocido "sleep") y verificamos que
|
||||
// procChildren lo detecta como descendiente directo. Esto valida el
|
||||
// mecanismo de barrido de hijos que findClaudePID usa internamente para
|
||||
// localizar un comm objetivo (en produccion: "claude").
|
||||
cmd := exec.Command("sleep", "3")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Skipf("no se pudo lanzar sleep: %v", err)
|
||||
}
|
||||
childPID := cmd.Process.Pid
|
||||
t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
|
||||
|
||||
kids := procChildren(os.Getpid())
|
||||
found := false
|
||||
for _, k := range kids {
|
||||
if k == childPID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("procChildren(self) no incluyo al hijo %d; kids=%v", childPID, kids)
|
||||
}
|
||||
|
||||
// Y el comm del hijo debe ser "sleep", confirmando el camino que findClaudePID
|
||||
// usa para comparar contra "claude".
|
||||
if comm := procComm(childPID); comm != "sleep" {
|
||||
t.Errorf("procComm(%d) = %q, esperado \"sleep\"", childPID, comm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TmuxNewClaudeWindow crea una window nueva en <session> del socket <socket>
|
||||
// que corre `claude --dangerously-skip-permissions` en <cwd>. Acepta argumentos
|
||||
// extra opcionales que se anaden al comando (ej. "--resume", "<sessionId>" para
|
||||
// reabrir una conversacion). Devuelve el window_id (ej "@7"). No cambia el foco
|
||||
// (-d). Opera SIEMPRE sobre el socket aislado pasado como parametro
|
||||
// (tmux -L <socket>), nunca sobre el servidor tmux por defecto del usuario.
|
||||
func TmuxNewClaudeWindow(socket, session, cwd string, extraArgs ...string) (string, error) {
|
||||
if socket == "" {
|
||||
return "", fmt.Errorf("tmux_new_claude_window: socket vacio")
|
||||
}
|
||||
if session == "" {
|
||||
return "", fmt.Errorf("tmux_new_claude_window: session vacia")
|
||||
}
|
||||
if cwd == "" {
|
||||
return "", fmt.Errorf("tmux_new_claude_window: cwd vacio")
|
||||
}
|
||||
|
||||
// El comando del pane: claude reemplaza al shell, asi que #{pane_pid} sera el
|
||||
// PID de claude. Se anaden los argumentos extra (ej. --resume <id>).
|
||||
command := "claude --dangerously-skip-permissions"
|
||||
if len(extraArgs) > 0 {
|
||||
command += " " + strings.Join(extraArgs, " ")
|
||||
}
|
||||
|
||||
// -d: no cambia el foco. -P -F '#{window_id}': imprime el id de la window
|
||||
// creada. -t <session>: la crea en esa sesion. -c <cwd>: working dir del pane.
|
||||
out, stderr, err := runTmux(socket,
|
||||
"new-window", "-d", "-P", "-F", "#{window_id}",
|
||||
"-t", session, "-c", cwd,
|
||||
command,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tmux_new_claude_window: new-window en %q: %w (%s)", session, err, stderr)
|
||||
}
|
||||
|
||||
windowID := strings.TrimSpace(out)
|
||||
if windowID == "" {
|
||||
return "", fmt.Errorf("tmux_new_claude_window: new-window no devolvio window_id (stderr=%q)", stderr)
|
||||
}
|
||||
return windowID, nil
|
||||
}
|
||||
|
||||
// runTmux ejecuta `tmux -L <socket> <args...>` y devuelve stdout, stderr y el
|
||||
// error de ejecucion. Helper comun a la capa de control tmux de fleetview.
|
||||
func runTmux(socket string, args ...string) (stdout, stderr string, err error) {
|
||||
full := append([]string{"-L", socket}, args...)
|
||||
cmd := exec.Command("tmux", full...)
|
||||
var outBuf, errBuf strings.Builder
|
||||
cmd.Stdout = &outBuf
|
||||
cmd.Stderr = &errBuf
|
||||
err = cmd.Run()
|
||||
return outBuf.String(), errBuf.String(), err
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: tmux_new_claude_window
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func TmuxNewClaudeWindow(socket, session, cwd string) (string, error)"
|
||||
description: "Crea una window detached nueva en una sesion tmux de un socket aislado (tmux -L <socket>) que corre `claude --dangerously-skip-permissions` en el cwd dado, y devuelve su window_id (ej @7). No cambia el foco. Capa de control tmux de la app TUI fleetview para arrancar un Claude nuevo dentro de la sesion fleet. Como el pane corre claude via exec, el #{pane_pid} del pane resultante es el PID del proceso claude."
|
||||
tags: [claude-fleet, infra, tmux, claude, fleet, tui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "socket"
|
||||
desc: "Nombre del socket tmux aislado (se invoca tmux -L <socket>). En fleetview es 'fleet'. Nunca opera sobre el servidor tmux por defecto del usuario."
|
||||
- name: "session"
|
||||
desc: "Nombre de la sesion tmux donde crear la window (ej 'fleet'). Debe existir."
|
||||
- name: "cwd"
|
||||
desc: "Working directory del nuevo pane/Claude (-c). Ruta absoluta del proyecto donde arrancar el Claude."
|
||||
output: "window_id de la window creada (string con la forma @N, ej '@7'), tal cual lo imprime `tmux new-window -P -F '#{window_id}'`. Error si socket/session/cwd vienen vacios o si tmux falla (sesion inexistente, socket no accesible)."
|
||||
tested: true
|
||||
tests: ["TestTmuxNewClaudeWindow", "TestTmuxNewClaudeWindowEmptyArgs"]
|
||||
test_file_path: "functions/infra/tmux_new_claude_window_test.go"
|
||||
file_path: "functions/infra/tmux_new_claude_window.go"
|
||||
notes: "Build tag //go:build !windows (capa tmux de fleetview, no portable a Windows). Comparte el helper runTmux con tmux_swap_window_into_console y tmux_map_claude_panes (mismo paquete infra). El comando que corre el pane es literalmente 'claude --dangerously-skip-permissions'; tmux lo arranca via su shell pero claude reemplaza al proceso, asi que pane_pid == claude PID."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Arranca un Claude nuevo en /home/enmanuel/fn_registry dentro de la
|
||||
// sesion 'fleet' del socket aislado 'fleet'. No roba el foco.
|
||||
windowID, err := infra.TmuxNewClaudeWindow("fleet", "fleet", "/home/enmanuel/fn_registry")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("Claude nuevo en window", windowID) // ej: @7
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando la TUI fleetview necesita arrancar un Claude nuevo dentro de la sesion `fleet` sin sacar al usuario de la consola actual. El Claude nace parkeado en su propia window (detached); luego `TmuxSwapWindowIntoConsole` lo trae a la derecha de la TUI cuando el usuario lo selecciona.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Opera SIEMPRE sobre el socket aislado (`tmux -L <socket>`). Nunca toca el servidor tmux por defecto del usuario.
|
||||
- La sesion `session` debe existir antes de llamar; la funcion crea la window, no la sesion.
|
||||
- Devuelve el `window_id` (`@N`), no el `window_index`. El swap posterior usa este id.
|
||||
- `-d` garantiza que no cambia el foco: el Claude nuevo queda parkeado, no se muestra solo.
|
||||
- Build tag `//go:build !windows`: no compila ni corre en Windows.
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tmuxAvailable reports whether the tmux binary is present. Tests skip when it
|
||||
// is not (CI without tmux).
|
||||
func tmuxAvailable(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("tmux"); err != nil {
|
||||
t.Skipf("tmux no disponible en PATH: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// isolatedSocket returns a per-test isolated tmux socket name and registers a
|
||||
// cleanup that kills its server. All commands in a test run against
|
||||
// `tmux -L <socket> ...`, never the user's default server.
|
||||
func isolatedSocket(t *testing.T) string {
|
||||
t.Helper()
|
||||
socket := fmt.Sprintf("fleettest_%d_%d", os.Getpid(), time.Now().UnixNano())
|
||||
t.Cleanup(func() {
|
||||
// best-effort: el server puede no existir si el test fallo antes de crearlo
|
||||
_ = exec.Command("tmux", "-L", socket, "kill-server").Run()
|
||||
})
|
||||
return socket
|
||||
}
|
||||
|
||||
// startConsoleSession crea una sesion <session> con una window "console" cuyo
|
||||
// pane 0 corre `cat` (simula la TUI fleetview, un proceso que no termina).
|
||||
func startConsoleSession(t *testing.T, socket, session string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("tmux", "-L", socket,
|
||||
"new-session", "-d", "-s", session, "-n", "console", "cat")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("new-session: %v (%s)", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxNewClaudeWindow(t *testing.T) {
|
||||
tmuxAvailable(t)
|
||||
socket := isolatedSocket(t)
|
||||
session := "fleet"
|
||||
startConsoleSession(t, socket, session)
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
|
||||
// El comando real ("claude ...") no esta disponible en el test, pero
|
||||
// new-window devuelve el window_id ANTES de que el comando pueda fallar:
|
||||
// tmux crea la window y reporta su id sincronamente. Validamos que el id
|
||||
// venga con la forma esperada (@N) y no vacio.
|
||||
windowID, err := TmuxNewClaudeWindow(socket, session, cwd)
|
||||
if err != nil {
|
||||
t.Fatalf("TmuxNewClaudeWindow: %v", err)
|
||||
}
|
||||
if windowID == "" {
|
||||
t.Fatal("window_id vacio")
|
||||
}
|
||||
if !strings.HasPrefix(windowID, "@") {
|
||||
t.Errorf("window_id %q no tiene la forma esperada @N", windowID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxNewClaudeWindowEmptyArgs(t *testing.T) {
|
||||
if _, err := TmuxNewClaudeWindow("", "fleet", "/tmp"); err == nil {
|
||||
t.Error("socket vacio deberia dar error")
|
||||
}
|
||||
if _, err := TmuxNewClaudeWindow("sock", "", "/tmp"); err == nil {
|
||||
t.Error("session vacia deberia dar error")
|
||||
}
|
||||
if _, err := TmuxNewClaudeWindow("sock", "fleet", ""); err == nil {
|
||||
t.Error("cwd vacio deberia dar error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TmuxSwapWindowIntoConsole trae el primer pane de <windowID> al pane derecho
|
||||
// de la window "console" de <session> (al lado del pane 0 = la TUI), parkeando
|
||||
// el Claude que estuviera a la derecha en su propia window (detached, sin robar
|
||||
// foco), y re-fija el ancho del pane 0 (TUI) a 40 columnas.
|
||||
//
|
||||
// Contrato de la window console:
|
||||
// - pane indice 0 = siempre la TUI fleetview (no se toca).
|
||||
// - cualquier otro pane en console = el Claude activo (puede no haber ninguno).
|
||||
//
|
||||
// Idempotente: si <windowID> ES ya la window console, no hace nada. Si el Claude
|
||||
// objetivo ya esta en console, tampoco rompe nada (el break-pane se aplica al
|
||||
// pane derecho que estuviera; si no lo hay, se salta). Opera SIEMPRE sobre el
|
||||
// socket aislado pasado como parametro (tmux -L <socket>).
|
||||
func TmuxSwapWindowIntoConsole(socket, session, windowID string) error {
|
||||
if socket == "" {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: socket vacio")
|
||||
}
|
||||
if session == "" {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: session vacia")
|
||||
}
|
||||
if windowID == "" {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: windowID vacio")
|
||||
}
|
||||
|
||||
consoleTarget := session + ":console"
|
||||
|
||||
// Capturar el ancho ACTUAL del pane 0 (la TUI) antes de tocar nada, para
|
||||
// preservarlo tras el break/join (que redistribuyen el espacio). Así el ancho
|
||||
// del sidebar lo decide quien creó la sesión (launch_kittyclaude), no un valor
|
||||
// fijo aquí.
|
||||
width := tmuxPane0Width(socket, session)
|
||||
|
||||
// Caso borde: si windowID ya ES la window console, no hay nada que hacer.
|
||||
// Resolvemos el window_id real de console y lo comparamos con el pedido.
|
||||
consoleID, err := tmuxConsoleWindowID(socket, session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if consoleID == windowID {
|
||||
// El objetivo ya es console. Solo re-fijamos el ancho de la TUI.
|
||||
return tmuxResizeConsoleTUI(socket, session, width)
|
||||
}
|
||||
|
||||
// 1. Localiza el pane derecho actual de console (cualquier pane con indice != 0).
|
||||
out, stderr, err := runTmux(socket, "list-panes", "-t", consoleTarget, "-F", "#{pane_index} #{pane_id}")
|
||||
if err != nil {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: list-panes de %q: %w (%s)", consoleTarget, err, stderr)
|
||||
}
|
||||
|
||||
var rightPaneID string
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
if fields[0] != "0" {
|
||||
rightPaneID = fields[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si existe un pane no-0 en console, sacarlo a su propia window (parking),
|
||||
// detached y sin cambiar foco.
|
||||
if rightPaneID != "" {
|
||||
if _, stderr, err := runTmux(socket, "break-pane", "-d", "-s", rightPaneID); err != nil {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: break-pane de %q: %w (%s)", rightPaneID, err, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Traer el primer pane de windowID a la derecha de la TUI (-h = split
|
||||
// horizontal, lado a lado). join-pane requiere que origen y destino sean
|
||||
// windows distintas (ya garantizado: consoleID != windowID arriba).
|
||||
src := windowID + ".0"
|
||||
dst := consoleTarget + ".0"
|
||||
if _, stderr, err := runTmux(socket, "join-pane", "-h", "-s", src, "-t", dst); err != nil {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: join-pane %q -> %q: %w (%s)", src, dst, err, stderr)
|
||||
}
|
||||
|
||||
// 4. Re-fijar el ancho del pane 0 (TUI) al que tenia antes del swap.
|
||||
return tmuxResizeConsoleTUI(socket, session, width)
|
||||
}
|
||||
|
||||
// tmuxConsoleWindowID resuelve el window_id (ej "@3") de la window llamada
|
||||
// "console" en <session>.
|
||||
func tmuxConsoleWindowID(socket, session string) (string, error) {
|
||||
out, stderr, err := runTmux(socket, "list-windows", "-t", session, "-F", "#{window_id} #{window_name}")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tmux_swap_window_into_console: list-windows de %q: %w (%s)", session, err, stderr)
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
fields := strings.Fields(strings.TrimSpace(line))
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
if fields[1] == "console" {
|
||||
return fields[0], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("tmux_swap_window_into_console: window 'console' no encontrada en %q", session)
|
||||
}
|
||||
|
||||
// tmuxPane0Width devuelve el ancho a preservar para el pane 0 (la TUI). Solo
|
||||
// tiene sentido leer el ancho actual si console ya tiene >1 pane (TUI + Claude);
|
||||
// con un único pane, el pane 0 es full-width y no representa el sidebar, así que
|
||||
// se usa el default (47 columnas).
|
||||
func tmuxPane0Width(socket, session string) int {
|
||||
const def = 52
|
||||
out, _, err := runTmux(socket, "list-panes", "-t", session+":console", "-F", "#{pane_index} #{pane_width}")
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
if len(lines) <= 1 {
|
||||
return def
|
||||
}
|
||||
for _, l := range lines {
|
||||
f := strings.Fields(strings.TrimSpace(l))
|
||||
if len(f) >= 2 && f[0] == "0" {
|
||||
if n, e := strconv.Atoi(f[1]); e == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// tmuxResizeConsoleTUI fija el ancho del pane 0 de console a width columnas.
|
||||
func tmuxResizeConsoleTUI(socket, session string, width int) error {
|
||||
target := session + ":console.0"
|
||||
if _, stderr, err := runTmux(socket, "resize-pane", "-t", target, "-x", strconv.Itoa(width)); err != nil {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: resize-pane de %q a %d col: %w (%s)", target, width, err, stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: tmux_swap_window_into_console
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func TmuxSwapWindowIntoConsole(socket, session, windowID string) error"
|
||||
description: "Conmuta que Claude esta a la derecha de la TUI fleetview en una sesion tmux de un socket aislado (tmux -L <socket>). Trae el primer pane de <windowID> al pane derecho de la window 'console' (al lado del pane 0 = la TUI), parkea en su propia window el Claude que estuviera a la derecha (detached, sin robar foco) y re-fija el ancho del pane 0 a 40 columnas. Idempotente: si el objetivo ya es la window console no hace nada. Capa de control tmux de la app TUI fleetview."
|
||||
tags: [claude-fleet, infra, tmux, claude, fleet, tui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "socket"
|
||||
desc: "Nombre del socket tmux aislado (tmux -L <socket>). En fleetview es 'fleet'. Nunca opera sobre el servidor tmux por defecto."
|
||||
- name: "session"
|
||||
desc: "Sesion tmux que contiene la window 'console' (ej 'fleet'). El pane 0 de console es la TUI; el resto, el Claude activo."
|
||||
- name: "windowID"
|
||||
desc: "window_id (@N) de la window cuyo primer pane se quiere traer a la derecha de la TUI. Tipicamente el devuelto por tmux_new_claude_window o por tmux_map_claude_panes."
|
||||
output: "nil en exito. Error si socket/session/windowID vienen vacios, si la window 'console' no existe en la sesion, o si alguno de los comandos tmux (list-panes, break-pane, join-pane, resize-pane) falla. El estado final de console: pane 0 = TUI (40 col) + pane derecho = el Claude de windowID."
|
||||
tested: true
|
||||
tests: ["TestTmuxSwapWindowIntoConsole", "TestTmuxSwapWindowIntoConsoleParksPrevious", "TestTmuxSwapWindowIntoConsoleEmptyArgs"]
|
||||
test_file_path: "functions/infra/tmux_swap_window_into_console_test.go"
|
||||
file_path: "functions/infra/tmux_swap_window_into_console.go"
|
||||
notes: "Build tag //go:build !windows. Comparte runTmux con tmux_new_claude_window y tmux_map_claude_panes (mismo paquete infra). Secuencia interna: (1) list-panes de console y localiza el pane no-0 actual; (2) break-pane -d de ese pane si existe (parking); (3) join-pane -h del primer pane de windowID a console.0 (lado a lado); (4) resize-pane -x 40 del pane 0. Caso borde: si windowID ya ES la window console, solo re-aplica el resize. break-pane requiere que la window destino sea distinta del origen, garantizado por la comprobacion consoleID != windowID."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "fn-registry/functions/infra"
|
||||
|
||||
func main() {
|
||||
// El usuario selecciona en fleetview el Claude que vive en la window @7.
|
||||
// Lo trae a la derecha de la TUI (pane 1 de console), parkeando el que
|
||||
// estuviera ahi. La TUI (pane 0) queda re-fijada a 40 columnas.
|
||||
if err := infra.TmuxSwapWindowIntoConsole("fleet", "fleet", "@7"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cada vez que el usuario conmuta en fleetview que Claude quiere ver a la derecha. Llamala con el `window_id` del Claude destino (de `tmux_map_claude_panes` para los ya vivos en la sesion, o de `tmux_new_claude_window` para uno recien arrancado). Encadena de forma natural tras `tmux_new_claude_window` para mostrar inmediatamente el Claude nuevo.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Idempotente: si el Claude objetivo ya es la window console, solo re-aplica el ancho de 40 col; no rompe nada.
|
||||
- El pane indice 0 de console es SIEMPRE la TUI y nunca se mueve ni se parkea: la funcion solo toca el pane derecho (indice != 0).
|
||||
- `join-pane` exige que la window origen sea distinta de console; la funcion lo comprueba (consoleID != windowID) y si coinciden no hace el join.
|
||||
- `break-pane -d` saca el Claude anterior a su propia window detached: sigue vivo y parkeado, no se mata.
|
||||
- El ancho de 40 col se re-fija SIEMPRE al final (incluso en el caso borde) para que la TUI no se reduzca tras el reflow del split.
|
||||
- Opera SIEMPRE sobre el socket aislado (`tmux -L <socket>`). Build tag `//go:build !windows`.
|
||||
@@ -0,0 +1,146 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newCatWindow crea una window detached en <session> que corre `cat` (un
|
||||
// proceso persistente que simula un claude parkeado) y devuelve su window_id.
|
||||
func newCatWindow(t *testing.T, socket, session string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("tmux", "-L", socket,
|
||||
"new-window", "-d", "-P", "-F", "#{window_id}", "-t", session, "cat").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("new-window cat: %v (%s)", err, out)
|
||||
}
|
||||
id := strings.TrimSpace(string(out))
|
||||
if id == "" {
|
||||
t.Fatal("new-window cat no devolvio window_id")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// consolePanes devuelve las lineas "pane_index pane_id width" de la window
|
||||
// console de <session>.
|
||||
func consolePanes(t *testing.T, socket, session string) []string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("tmux", "-L", socket,
|
||||
"list-panes", "-t", session+":console",
|
||||
"-F", "#{pane_index} #{pane_id} #{pane_width}").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("list-panes console: %v (%s)", err, out)
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func TestTmuxSwapWindowIntoConsole(t *testing.T) {
|
||||
tmuxAvailable(t)
|
||||
socket := isolatedSocket(t)
|
||||
session := "fleet"
|
||||
startConsoleSession(t, socket, session)
|
||||
|
||||
// Una window aparte con `cat` simula un Claude parkeado conmutable.
|
||||
claudeWin := newCatWindow(t, socket, session)
|
||||
|
||||
// Estado inicial: console tiene un solo pane (la TUI, indice 0).
|
||||
if got := len(consolePanes(t, socket, session)); got != 1 {
|
||||
t.Fatalf("console deberia empezar con 1 pane, tiene %d", got)
|
||||
}
|
||||
|
||||
if err := TmuxSwapWindowIntoConsole(socket, session, claudeWin); err != nil {
|
||||
t.Fatalf("TmuxSwapWindowIntoConsole: %v", err)
|
||||
}
|
||||
|
||||
// Tras el swap: console tiene 2 panes y el pane 0 mide 47 columnas (default
|
||||
// del sidebar, ya que la console arrancó con un solo pane full-width).
|
||||
panes := consolePanes(t, socket, session)
|
||||
if len(panes) != 2 {
|
||||
t.Fatalf("console deberia tener 2 panes tras swap, tiene %d: %v", len(panes), panes)
|
||||
}
|
||||
var width0 int
|
||||
found0 := false
|
||||
for _, line := range panes {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 3 && f[0] == "0" {
|
||||
found0 = true
|
||||
w, err := strconv.Atoi(f[2])
|
||||
if err != nil {
|
||||
t.Fatalf("ancho del pane 0 no numerico: %q", f[2])
|
||||
}
|
||||
width0 = w
|
||||
}
|
||||
}
|
||||
if !found0 {
|
||||
t.Fatal("no se encontro el pane 0 en console")
|
||||
}
|
||||
if width0 != 47 {
|
||||
t.Errorf("ancho del pane 0 = %d, esperado 47", width0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxSwapWindowIntoConsoleParksPrevious(t *testing.T) {
|
||||
tmuxAvailable(t)
|
||||
socket := isolatedSocket(t)
|
||||
session := "fleet"
|
||||
startConsoleSession(t, socket, session)
|
||||
|
||||
winA := newCatWindow(t, socket, session)
|
||||
winB := newCatWindow(t, socket, session)
|
||||
|
||||
// Trae A a console.
|
||||
if err := TmuxSwapWindowIntoConsole(socket, session, winA); err != nil {
|
||||
t.Fatalf("swap A: %v", err)
|
||||
}
|
||||
if got := len(consolePanes(t, socket, session)); got != 2 {
|
||||
t.Fatalf("tras swap A console deberia tener 2 panes, tiene %d", got)
|
||||
}
|
||||
|
||||
// Trae B: A se parkea fuera, console vuelve a 2 panes (TUI + B).
|
||||
if err := TmuxSwapWindowIntoConsole(socket, session, winB); err != nil {
|
||||
t.Fatalf("swap B: %v", err)
|
||||
}
|
||||
if got := len(consolePanes(t, socket, session)); got != 2 {
|
||||
t.Fatalf("tras swap B console deberia tener 2 panes, tiene %d", got)
|
||||
}
|
||||
|
||||
// El Claude A parkeado debe seguir vivo en alguna window de la sesion.
|
||||
out, err := exec.Command("tmux", "-L", socket,
|
||||
"list-windows", "-t", session, "-F", "#{window_id}").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("list-windows: %v (%s)", err, out)
|
||||
}
|
||||
winCount := 0
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
winCount++
|
||||
}
|
||||
}
|
||||
// Esperadas: console + (window de A parkeada). winB se consumio al unir su
|
||||
// pane a console (la window vacia se cierra). winA: una window de parking.
|
||||
if winCount < 2 {
|
||||
t.Errorf("se esperaban >=2 windows (console + A parkeado), hay %d", winCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxSwapWindowIntoConsoleEmptyArgs(t *testing.T) {
|
||||
if err := TmuxSwapWindowIntoConsole("", "fleet", "@1"); err == nil {
|
||||
t.Error("socket vacio deberia dar error")
|
||||
}
|
||||
if err := TmuxSwapWindowIntoConsole("sock", "", "@1"); err == nil {
|
||||
t.Error("session vacia deberia dar error")
|
||||
}
|
||||
if err := TmuxSwapWindowIntoConsole("sock", "fleet", ""); err == nil {
|
||||
t.Error("windowID vacio deberia dar error")
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,38 @@ from .dav_get_resource import dav_get_resource
|
||||
from .dav_delete_resource import dav_delete_resource
|
||||
from .pg_insert_rows import pg_insert_rows
|
||||
from .pg_apply_sql import pg_apply_sql
|
||||
from .pg_query import pg_query
|
||||
from .pg_upsert import pg_upsert
|
||||
from .pg_create_table_from_rows import pg_create_table_from_rows
|
||||
from .pg_list_tables import pg_list_tables
|
||||
from .read_xlsx import read_xlsx
|
||||
from .add_xlsx_chart import add_xlsx_chart
|
||||
from .duckdb_list_tables import duckdb_list_tables
|
||||
from .duckdb_table_schema import duckdb_table_schema
|
||||
from .excel_to_duckdb import excel_to_duckdb
|
||||
from .write_xlsx_sheets import write_xlsx_sheets
|
||||
from .upsert_xlsx_sheet import upsert_xlsx_sheet
|
||||
from .duckdb_query_readonly import duckdb_query_readonly
|
||||
from .duckdb_execute import duckdb_execute
|
||||
from .duckdb_upsert import duckdb_upsert
|
||||
|
||||
__all__ = [
|
||||
"write_xlsx_sheets",
|
||||
"upsert_xlsx_sheet",
|
||||
"duckdb_query_readonly",
|
||||
"duckdb_execute",
|
||||
"duckdb_upsert",
|
||||
"pg_insert_rows",
|
||||
"pg_apply_sql",
|
||||
"pg_query",
|
||||
"pg_upsert",
|
||||
"pg_create_table_from_rows",
|
||||
"pg_list_tables",
|
||||
"read_xlsx",
|
||||
"add_xlsx_chart",
|
||||
"duckdb_list_tables",
|
||||
"duckdb_table_schema",
|
||||
"excel_to_duckdb",
|
||||
"setup_logger",
|
||||
"get_logger",
|
||||
"generate_app_icon",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: add_xlsx_chart
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def add_xlsx_chart(xlsx_path: str, sheet_name: str, chart_type: str, data_range: str, cats_range: str = None, anchor: str = 'H2', title: str = '', x_title: str = '', y_title: str = '') -> dict"
|
||||
description: "Anade un grafico nativo de openpyxl a una hoja EXISTENTE de un libro .xlsx existente, refiriendo rangos de celdas ya escritos. chart_type en {bar, line, pie, scatter} (BarChart/LineChart/PieChart/ScatterChart). data_range y cats_range en notacion Excel tipo 'B1:B10' (se convierten a Reference). anchor = celda destino del chart (ej. 'H2'). Acepta titulo del grafico y de los ejes X/Y. Guarda el libro. Es la pieza que completa el grupo excel para generar hojas con graficos: primero escribir datos (write_xlsx_sheets) y luego anadir el chart. Impura: escribe disco y NO lanza: en fallo (hoja/libro inexistente, chart_type invalido, rango invalido) devuelve {status: 'error', error}."
|
||||
tags: [excel, xlsx, chart, openpyxl, spreadsheet, office, onlyoffice, viz, io, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [openpyxl]
|
||||
params:
|
||||
- name: xlsx_path
|
||||
desc: "Ruta al archivo .xlsx EXISTENTE. Esta funcion NO crea el libro: escribe primero los datos con write_xlsx_sheets/upsert_xlsx_sheet. Vacio o inexistente devuelve {status: 'error'} (no lanza)."
|
||||
- name: sheet_name
|
||||
desc: "Nombre de la hoja (ya existente) donde se ancla el grafico y de la que provienen los rangos. Si no existe, devuelve {status: 'error'} con la lista de hojas disponibles."
|
||||
- name: chart_type
|
||||
desc: "Tipo de grafico. Uno de: 'bar', 'line', 'pie', 'scatter' (case-insensitive, se normaliza). Cualquier otro valor devuelve {status: 'error'} con la lista de validos."
|
||||
- name: data_range
|
||||
desc: "Rango de celdas de los valores a graficar, en notacion Excel tipo 'B1:B10'. Se convierte a openpyxl.chart.Reference (1-indexed). Si abarca la cabecera (fila 1), se toma el nombre de la serie de esa primera celda (titles_from_data). Rango invalido devuelve {status: 'error'}."
|
||||
- name: cats_range
|
||||
desc: "Rango de las categorias/etiquetas del eje X (o labels de pie), tipo 'A2:A10'. None (default) = sin categorias explicitas. Para scatter se usa como valores X (xvalues) de la serie."
|
||||
- name: anchor
|
||||
desc: "Celda destino (esquina superior izquierda) del grafico, p.ej. 'H2'. Default 'H2'. Ancla el chart sin desplazar las celdas de datos."
|
||||
- name: title
|
||||
desc: "Titulo del grafico. Vacio (default) = sin titulo."
|
||||
- name: x_title
|
||||
desc: "Titulo del eje X. Vacio (default) = sin titulo. Ignorado por pie (no tiene ejes)."
|
||||
- name: y_title
|
||||
desc: "Titulo del eje Y. Vacio (default) = sin titulo. Ignorado por pie (no tiene ejes)."
|
||||
output: "Dict. En exito: {status: 'ok', chart_type: <str normalizado>, sheet: <str>, anchor: <str>}. En error: {status: 'error', error: '<mensaje>'}."
|
||||
tested: true
|
||||
tests: ["test_add_bar_chart_reabre_y_verifica", "test_add_line_chart", "test_add_pie_chart", "test_add_scatter_chart", "test_dos_charts_en_la_misma_hoja", "test_chart_type_invalido_devuelve_error", "test_hoja_inexistente_devuelve_error", "test_libro_inexistente_devuelve_error", "test_data_range_invalido_devuelve_error", "test_xlsx_path_vacio_devuelve_error"]
|
||||
test_file_path: "python/functions/infra/add_xlsx_chart_test.py"
|
||||
file_path: "python/functions/infra/add_xlsx_chart.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.write_xlsx_sheets import write_xlsx_sheets
|
||||
from infra.add_xlsx_chart import add_xlsx_chart
|
||||
|
||||
# 1) Escribe los datos (cabecera + filas)
|
||||
write_xlsx_sheets("/tmp/ventas_chart.xlsx", {
|
||||
"Ventas": [
|
||||
["Mes", "Unidades"],
|
||||
["Ene", 120],
|
||||
["Feb", 150],
|
||||
["Mar", 90],
|
||||
["Abr", 200],
|
||||
],
|
||||
})
|
||||
|
||||
# 2) Anade un grafico de barras refiriendo los rangos ya escritos
|
||||
res = add_xlsx_chart(
|
||||
xlsx_path="/tmp/ventas_chart.xlsx",
|
||||
sheet_name="Ventas",
|
||||
chart_type="bar",
|
||||
data_range="B1:B5", # incluye la cabecera 'Unidades' -> nombre de la serie
|
||||
cats_range="A2:A5", # meses como categorias del eje X
|
||||
anchor="D2", # esquina superior izquierda del chart
|
||||
title="Unidades por mes",
|
||||
x_title="Mes",
|
||||
y_title="Unidades",
|
||||
)
|
||||
print(res)
|
||||
# {'status': 'ok', 'chart_type': 'bar', 'sheet': 'Ventas', 'anchor': 'D2'}
|
||||
|
||||
# Verificar que el chart quedo en la hoja
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook("/tmp/ventas_chart.xlsx")
|
||||
print(len(wb["Ventas"]._charts)) # 1
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites **generar una hoja de Excel con un grafico** a partir de
|
||||
datos que ya escribiste en el libro: dashboards exportables, reports con
|
||||
visualizacion embebida, resumenes que se abren en Excel/OnlyOffice mostrando el
|
||||
chart. Es el ultimo paso del flujo del grupo `excel`: `write_xlsx_sheets`
|
||||
(o `upsert_xlsx_sheet`) escribe los datos, y esta funcion les anade el grafico
|
||||
refiriendo sus rangos. Llamala una vez por grafico (puedes anadir varios a la
|
||||
misma hoja con distintos `anchor`).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura — escribe en disco.** Reabre el libro, anade el chart y lo GUARDA.
|
||||
No lanza: devuelve `{"status": "error", ...}` ante libro inexistente, hoja
|
||||
inexistente, `chart_type` invalido, rango invalido o openpyxl ausente.
|
||||
- **El libro DEBE existir.** Esta funcion no crea el .xlsx ni escribe datos:
|
||||
los rangos (`data_range`, `cats_range`) deben apuntar a celdas YA escritas.
|
||||
Escribe primero con `write_xlsx_sheets`/`upsert_xlsx_sheet`.
|
||||
- **openpyxl carga el libro entero en memoria** para reabrirlo y reescribirlo.
|
||||
Para libros muy grandes esto consume RAM proporcional al tamano.
|
||||
- **Los `Reference` de openpyxl son 1-indexed** (fila 1, columna 1 = A1). La
|
||||
conversion desde notacion `'B1:B10'` la hace `range_boundaries` internamente;
|
||||
si pasas un rango mal formado, devuelve error en vez de un chart vacio.
|
||||
- **`titles_from_data`**: si `data_range` incluye la fila 1 (cabecera), el
|
||||
nombre de la serie se toma de esa primera celda. Si tu `data_range` empieza en
|
||||
fila 2 (solo datos), la serie queda sin nombre — incluye la cabecera para
|
||||
etiquetarla.
|
||||
- **scatter es distinto**: usa `data_range` como valores Y y `cats_range` como
|
||||
valores X (xvalues) de una unica serie via `Series`. No usa `set_categories`
|
||||
como bar/line/pie. Para scatter, pasa rangos de SOLO datos (sin cabecera) en
|
||||
ambos.
|
||||
- **pie ignora `x_title`/`y_title`** (no tiene ejes). Pasarlos no falla, se
|
||||
ignoran silenciosamente.
|
||||
- **El chart NO se recalcula solo**: openpyxl escribe la definicion del grafico;
|
||||
Excel/LibreOffice lo renderiza al abrir. Si cambias los datos despues, vuelve
|
||||
a llamar a la funcion o edita el rango — el chart referencia celdas, asi que
|
||||
reflejara el valor que tengan al abrir el libro.
|
||||
- **Requiere openpyxl** (ya instalado en `python/.venv`, version 3.1.5).
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Anade un grafico nativo de openpyxl a una hoja existente de un libro .xlsx.
|
||||
|
||||
Funcion impura: abre un libro Excel existente, crea un objeto Chart de openpyxl
|
||||
(BarChart/LineChart/PieChart/ScatterChart) sobre rangos de celdas YA escritos, lo
|
||||
ancla en una celda destino y guarda el libro. Es la pieza que faltaba en el grupo
|
||||
`excel` para producir hojas de Excel con graficos: primero se escriben los datos
|
||||
(p.ej. con write_xlsx_sheets) y luego se les anade el chart refiriendo sus rangos.
|
||||
|
||||
No lanza: cualquier fallo (libro inexistente, hoja inexistente, chart_type
|
||||
invalido, openpyxl ausente) se devuelve como dict {"status": "error", ...}.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
# chart_type -> clase de openpyxl.chart. Se resuelve perezosamente en runtime
|
||||
# para no fallar al importar el modulo si openpyxl no esta instalado.
|
||||
_VALID_CHART_TYPES = ("bar", "line", "pie", "scatter")
|
||||
|
||||
|
||||
def add_xlsx_chart(
|
||||
xlsx_path: str,
|
||||
sheet_name: str,
|
||||
chart_type: str,
|
||||
data_range: str,
|
||||
cats_range: str = None,
|
||||
anchor: str = "H2",
|
||||
title: str = "",
|
||||
x_title: str = "",
|
||||
y_title: str = "",
|
||||
) -> dict:
|
||||
"""Anade un grafico nativo a una hoja existente de un libro existente.
|
||||
|
||||
Args:
|
||||
xlsx_path: Ruta al archivo .xlsx existente. Debe existir (esta funcion no
|
||||
crea el libro: escribe primero los datos con otra funcion del grupo).
|
||||
sheet_name: Nombre de la hoja (ya existente) donde se ancla el grafico y
|
||||
de la que provienen los rangos.
|
||||
chart_type: Tipo de grafico. Uno de: "bar", "line", "pie", "scatter".
|
||||
data_range: Rango de celdas de los valores a graficar, en notacion Excel
|
||||
tipo "B1:B10". Se convierte a openpyxl.chart.Reference. Si abarca la
|
||||
cabecera (fila 1), se pasa titles_from_data=True para tomar el nombre
|
||||
de la serie de esa primera celda.
|
||||
cats_range: Rango de las categorias/etiquetas del eje X (o labels de pie),
|
||||
tipo "A2:A10". None (default) = sin categorias explicitas. Ignorado
|
||||
para scatter (que usa xvalues, ver Gotchas).
|
||||
anchor: Celda destino (esquina superior izquierda) del grafico, p.ej.
|
||||
"H2". Default "H2".
|
||||
title: Titulo del grafico. Vacio (default) = sin titulo.
|
||||
x_title: Titulo del eje X. Vacio (default) = sin titulo. Ignorado por pie.
|
||||
y_title: Titulo del eje Y. Vacio (default) = sin titulo. Ignorado por pie.
|
||||
|
||||
Returns:
|
||||
Dict. En exito:
|
||||
{"status": "ok", "chart_type": <str>, "sheet": <str>, "anchor": <str>}.
|
||||
En error:
|
||||
{"status": "error", "error": "<mensaje>"}.
|
||||
"""
|
||||
if not xlsx_path:
|
||||
return {"status": "error", "error": "xlsx_path no puede estar vacio"}
|
||||
|
||||
ct = (chart_type or "").strip().lower()
|
||||
if ct not in _VALID_CHART_TYPES:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"chart_type invalido: '{chart_type}'. "
|
||||
f"Validos: {list(_VALID_CHART_TYPES)}"
|
||||
),
|
||||
}
|
||||
|
||||
abs_path = os.path.abspath(xlsx_path)
|
||||
if not os.path.exists(abs_path):
|
||||
return {"status": "error", "error": f"libro no encontrado: {abs_path}"}
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.chart import (
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
Reference,
|
||||
ScatterChart,
|
||||
Series,
|
||||
)
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
except ImportError: # pragma: no cover - dependencia del entorno
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"openpyxl es requerido para add_xlsx_chart. "
|
||||
"Instalar con: cd python && uv add openpyxl"
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
wb = load_workbook(abs_path)
|
||||
except Exception as exc: # noqa: BLE001 - contrato del grupo: no lanzar
|
||||
return {"status": "error", "error": f"no se pudo abrir el libro: {exc}"}
|
||||
|
||||
if sheet_name not in wb.sheetnames:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"hoja '{sheet_name}' no existe. "
|
||||
f"Hojas disponibles: {wb.sheetnames}"
|
||||
),
|
||||
}
|
||||
|
||||
ws = wb[sheet_name]
|
||||
|
||||
# Convierte "B1:B10" -> (min_col, min_row, max_col, max_row) en 1-index.
|
||||
try:
|
||||
d_min_col, d_min_row, d_max_col, d_max_row = range_boundaries(data_range)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"data_range invalido '{data_range}': {exc}",
|
||||
}
|
||||
|
||||
chart_classes = {
|
||||
"bar": BarChart,
|
||||
"line": LineChart,
|
||||
"pie": PieChart,
|
||||
"scatter": ScatterChart,
|
||||
}
|
||||
|
||||
try:
|
||||
chart = chart_classes[ct]()
|
||||
if title:
|
||||
chart.title = title
|
||||
|
||||
if ct == "scatter":
|
||||
# Scatter empareja X (cats_range) con Y (data_range) como una serie.
|
||||
chart.style = 13
|
||||
if x_title:
|
||||
chart.x_axis.title = x_title
|
||||
if y_title:
|
||||
chart.y_axis.title = y_title
|
||||
yvalues = Reference(
|
||||
ws,
|
||||
min_col=d_min_col,
|
||||
min_row=d_min_row,
|
||||
max_col=d_max_col,
|
||||
max_row=d_max_row,
|
||||
)
|
||||
if cats_range:
|
||||
try:
|
||||
x_min_col, x_min_row, x_max_col, x_max_row = range_boundaries(
|
||||
cats_range
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"cats_range invalido '{cats_range}': {exc}",
|
||||
}
|
||||
xvalues = Reference(
|
||||
ws,
|
||||
min_col=x_min_col,
|
||||
min_row=x_min_row,
|
||||
max_col=x_max_col,
|
||||
max_row=x_max_row,
|
||||
)
|
||||
series = Series(yvalues, xvalues, title_from_data=False)
|
||||
else:
|
||||
series = Series(yvalues, title_from_data=False)
|
||||
chart.series.append(series)
|
||||
else:
|
||||
# bar/line/pie: add_data + set_categories.
|
||||
if ct in ("bar", "line"):
|
||||
if x_title:
|
||||
chart.x_axis.title = x_title
|
||||
if y_title:
|
||||
chart.y_axis.title = y_title
|
||||
# titles_from_data toma el nombre de serie de la primera fila del
|
||||
# rango cuando este incluye la cabecera (fila 1).
|
||||
from_data = d_min_row == 1
|
||||
data = Reference(
|
||||
ws,
|
||||
min_col=d_min_col,
|
||||
min_row=d_min_row,
|
||||
max_col=d_max_col,
|
||||
max_row=d_max_row,
|
||||
)
|
||||
chart.add_data(data, titles_from_data=from_data)
|
||||
if cats_range:
|
||||
try:
|
||||
c_min_col, c_min_row, c_max_col, c_max_row = range_boundaries(
|
||||
cats_range
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"cats_range invalido '{cats_range}': {exc}",
|
||||
}
|
||||
cats = Reference(
|
||||
ws,
|
||||
min_col=c_min_col,
|
||||
min_row=c_min_row,
|
||||
max_col=c_max_col,
|
||||
max_row=c_max_row,
|
||||
)
|
||||
chart.set_categories(cats)
|
||||
|
||||
ws.add_chart(chart, anchor)
|
||||
wb.save(abs_path)
|
||||
except Exception as exc: # noqa: BLE001 - contrato del grupo: no lanzar
|
||||
return {"status": "error", "error": f"no se pudo anadir el grafico: {exc}"}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"chart_type": ct,
|
||||
"sheet": sheet_name,
|
||||
"anchor": anchor,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - smoke manual
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from infra.write_xlsx_sheets import write_xlsx_sheets
|
||||
|
||||
tmp = os.path.join(tempfile.gettempdir(), "add_xlsx_chart_demo.xlsx")
|
||||
write_xlsx_sheets(
|
||||
tmp,
|
||||
{
|
||||
"Ventas": [
|
||||
["Mes", "Unidades"],
|
||||
["Ene", 120],
|
||||
["Feb", 150],
|
||||
["Mar", 90],
|
||||
["Abr", 200],
|
||||
],
|
||||
},
|
||||
)
|
||||
res = add_xlsx_chart(
|
||||
xlsx_path=tmp,
|
||||
sheet_name="Ventas",
|
||||
chart_type="bar",
|
||||
data_range="B1:B5", # incluye cabecera "Unidades" -> nombre de serie
|
||||
cats_range="A2:A5", # meses
|
||||
anchor="D2",
|
||||
title="Unidades por mes",
|
||||
x_title="Mes",
|
||||
y_title="Unidades",
|
||||
)
|
||||
print(res)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests para add_xlsx_chart.
|
||||
|
||||
Modulos importados por path directo (sin tocar __init__.py). write_xlsx_sheets
|
||||
escribe los datos; add_xlsx_chart les anade un grafico; reabrimos el libro y
|
||||
verificamos que ws._charts contiene el chart.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, os.path.join(_HERE, f"{name}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
add_xlsx_chart = _load("add_xlsx_chart").add_xlsx_chart
|
||||
write_xlsx_sheets = _load("write_xlsx_sheets").write_xlsx_sheets
|
||||
|
||||
|
||||
def _book_with_data(tmp_path):
|
||||
out = str(tmp_path / "chart.xlsx")
|
||||
write_xlsx_sheets(
|
||||
out,
|
||||
{
|
||||
"Ventas": [
|
||||
["Mes", "Unidades"],
|
||||
["Ene", 120],
|
||||
["Feb", 150],
|
||||
["Mar", 90],
|
||||
["Abr", 200],
|
||||
],
|
||||
},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def test_add_bar_chart_reabre_y_verifica(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(
|
||||
xlsx_path=out,
|
||||
sheet_name="Ventas",
|
||||
chart_type="bar",
|
||||
data_range="B1:B5",
|
||||
cats_range="A2:A5",
|
||||
anchor="D2",
|
||||
title="Unidades por mes",
|
||||
x_title="Mes",
|
||||
y_title="Unidades",
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
assert res["chart_type"] == "bar"
|
||||
assert res["sheet"] == "Ventas"
|
||||
assert res["anchor"] == "D2"
|
||||
|
||||
wb = load_workbook(out)
|
||||
ws = wb["Ventas"]
|
||||
assert len(ws._charts) == 1
|
||||
|
||||
|
||||
def test_add_line_chart(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(out, "Ventas", "line", "B1:B5", cats_range="A2:A5")
|
||||
assert res["status"] == "ok"
|
||||
wb = load_workbook(out)
|
||||
assert len(wb["Ventas"]._charts) == 1
|
||||
|
||||
|
||||
def test_add_pie_chart(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(out, "Ventas", "pie", "B2:B5", cats_range="A2:A5", anchor="D10")
|
||||
assert res["status"] == "ok"
|
||||
wb = load_workbook(out)
|
||||
assert len(wb["Ventas"]._charts) == 1
|
||||
|
||||
|
||||
def test_add_scatter_chart(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(
|
||||
out, "Ventas", "scatter", data_range="B2:B5", cats_range="A2:A5"
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
wb = load_workbook(out)
|
||||
assert len(wb["Ventas"]._charts) == 1
|
||||
|
||||
|
||||
def test_dos_charts_en_la_misma_hoja(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
assert add_xlsx_chart(out, "Ventas", "bar", "B1:B5", "A2:A5", "D2")["status"] == "ok"
|
||||
assert add_xlsx_chart(out, "Ventas", "line", "B1:B5", "A2:A5", "D20")["status"] == "ok"
|
||||
wb = load_workbook(out)
|
||||
assert len(wb["Ventas"]._charts) == 2
|
||||
|
||||
|
||||
def test_chart_type_invalido_devuelve_error(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(out, "Ventas", "donut", "B1:B5")
|
||||
assert res["status"] == "error"
|
||||
assert "chart_type invalido" in res["error"]
|
||||
|
||||
|
||||
def test_hoja_inexistente_devuelve_error(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(out, "Fantasma", "bar", "B1:B5")
|
||||
assert res["status"] == "error"
|
||||
assert "no existe" in res["error"]
|
||||
|
||||
|
||||
def test_libro_inexistente_devuelve_error():
|
||||
res = add_xlsx_chart("/tmp/no_existe_seguro_987654.xlsx", "S", "bar", "B1:B5")
|
||||
assert res["status"] == "error"
|
||||
assert "no encontrado" in res["error"]
|
||||
|
||||
|
||||
def test_data_range_invalido_devuelve_error(tmp_path):
|
||||
out = _book_with_data(tmp_path)
|
||||
res = add_xlsx_chart(out, "Ventas", "bar", "rango_basura")
|
||||
assert res["status"] == "error"
|
||||
assert "data_range invalido" in res["error"]
|
||||
|
||||
|
||||
def test_xlsx_path_vacio_devuelve_error():
|
||||
res = add_xlsx_chart("", "S", "bar", "B1:B5")
|
||||
assert res["status"] == "error"
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: duckdb_list_tables
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def duckdb_list_tables(db_path: str) -> dict"
|
||||
description: "Lista las tablas de una base DuckDB abierta en modo solo lectura (duckdb.connect(db_path, read_only=True)), de modo que nunca crea ni modifica la base. La conexion se cierra siempre en try/finally. Consulta information_schema.tables del esquema main y devuelve los nombres ordenados alfabeticamente. Devuelve un dict sin lanzar (estilo del grupo duckdb): {status:'ok', tables} en exito y {status:'error', error} en fallo. Es la introspeccion 'que tablas hay' del grupo duckdb; complementa a duckdb_query_readonly_py_infra (lectura de filas) y a duckdb_table_schema_py_infra (schema de una tabla). Depende del paquete duckdb (1.5.2 en python/.venv)."
|
||||
tags: [duckdb, sql, introspection, readonly, tables]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [duckdb]
|
||||
params:
|
||||
- name: db_path
|
||||
desc: "ruta al archivo DuckDB. Debe existir: el modo read_only NO crea la base. Un path inexistente devuelve {status:'error'}."
|
||||
output: "dict. En exito: {status:'ok', tables:[str,...]} con los nombres de tabla del esquema main ordenados alfabeticamente. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_lista_tablas_ordenadas"
|
||||
- "test_base_vacia_devuelve_lista_vacia"
|
||||
- "test_db_inexistente_devuelve_status_error"
|
||||
test_file_path: "python/functions/infra/duckdb_list_tables_test.py"
|
||||
file_path: "python/functions/infra/duckdb_list_tables.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
import duckdb
|
||||
from infra.duckdb_list_tables import duckdb_list_tables
|
||||
|
||||
# Preparamos una base de ejemplo (en la realidad la creo otro proceso).
|
||||
db = "/tmp/almacen.duckdb"
|
||||
con = duckdb.connect(db)
|
||||
con.execute("CREATE TABLE ventas (id INTEGER)")
|
||||
con.execute("CREATE TABLE clientes (id INTEGER)")
|
||||
con.close()
|
||||
|
||||
res = duckdb_list_tables(db)
|
||||
print(res["status"]) # ok
|
||||
print(res["tables"]) # ['clientes', 'ventas']
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas saber que tablas contiene un archivo DuckDB sin abrirlo en
|
||||
escritura: inventariar una base materializada, decidir que tabla sincronizar a
|
||||
PostgreSQL, validar que un pipeline de ingesta creo lo esperado, o alimentar un
|
||||
selector de tablas en una UI. Es el primer paso natural antes de
|
||||
`duckdb_table_schema_py_infra` (schema de una tabla) o `duckdb_query_readonly_py_infra`
|
||||
(lectura de filas). El dict de salida es directamente serializable a JSON.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Lectura real de un archivo en disco (impura). El modo `read_only=True` exige que
|
||||
el archivo **ya exista**: a diferencia del modo escritura, no crea la base. Si
|
||||
`db_path` no existe, devuelve `{status:'error', error:...}`.
|
||||
- DuckDB es single-writer: si otro proceso tiene la base abierta en escritura con
|
||||
una version distinta del motor, la apertura read-only puede fallar con error de
|
||||
lock. El error se devuelve como `{status:'error', ...}`, no se lanza.
|
||||
- Solo lista tablas del esquema `main` (el por defecto). Vistas y tablas de otros
|
||||
esquemas no aparecen.
|
||||
- Una base recien creada sin tablas devuelve `{status:'ok', tables:[]}` (no es un
|
||||
error): lista vacia.
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Lista las tablas de 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. Consulta `information_schema.tables` (esquema `main`) para
|
||||
obtener los nombres de tabla y los devuelve ordenados. 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.
|
||||
|
||||
Complementa a `duckdb_query_readonly_py_infra` (lectura de filas) y a
|
||||
`duckdb_table_schema_py_infra` (schema de una tabla concreta): esta es la
|
||||
introspeccion de alto nivel "que tablas hay" del grupo duckdb.
|
||||
"""
|
||||
|
||||
|
||||
def duckdb_list_tables(db_path: str) -> dict:
|
||||
"""Lista las tablas de 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', ...}.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', tables:[str,...]} con los nombres de tabla
|
||||
del esquema `main` ordenados alfabeticamente. En error (sin lanzar):
|
||||
{status:'error', error:str}.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = __import__("duckdb").connect(db_path, read_only=True)
|
||||
rows = conn.execute(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema = 'main' ORDER BY table_name"
|
||||
).fetchall()
|
||||
tables = [row[0] for row in rows]
|
||||
return {"status": "ok", "tables": tables}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tests para duckdb_list_tables."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import duckdb # noqa: E402
|
||||
|
||||
from duckdb_list_tables import duckdb_list_tables # noqa: E402
|
||||
|
||||
|
||||
def _make_db(path: str, tables: list[str]) -> None:
|
||||
con = duckdb.connect(str(path))
|
||||
for t in tables:
|
||||
con.execute(f"CREATE TABLE {t} (id INTEGER)")
|
||||
con.close()
|
||||
|
||||
|
||||
def test_lista_tablas_ordenadas(tmp_path):
|
||||
db = tmp_path / "v.duckdb"
|
||||
_make_db(str(db), ["ventas", "clientes", "productos"])
|
||||
res = duckdb_list_tables(str(db))
|
||||
assert res["status"] == "ok"
|
||||
assert res["tables"] == ["clientes", "productos", "ventas"]
|
||||
|
||||
|
||||
def test_base_vacia_devuelve_lista_vacia(tmp_path):
|
||||
db = tmp_path / "empty.duckdb"
|
||||
con = duckdb.connect(str(db))
|
||||
con.close()
|
||||
res = duckdb_list_tables(str(db))
|
||||
assert res["status"] == "ok"
|
||||
assert res["tables"] == []
|
||||
|
||||
|
||||
def test_db_inexistente_devuelve_status_error(tmp_path):
|
||||
res = duckdb_list_tables(str(tmp_path / "noexiste.duckdb"))
|
||||
assert res["status"] == "error"
|
||||
assert "error" in res
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: duckdb_table_schema
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def duckdb_table_schema(db_path: str, table: str) -> dict"
|
||||
description: "Devuelve el schema (columnas y tipos) de una tabla DuckDB abierta en modo solo lectura (duckdb.connect(db_path, read_only=True)), de modo que nunca crea ni modifica la base. La conexion se cierra siempre en try/finally. Ejecuta DESCRIBE <table> con el identificador de tabla validado contra ^[A-Za-z_][A-Za-z0-9_]*$ y citado (DESCRIBE no admite parametros posicionales). Devuelve un dict sin lanzar (estilo del grupo duckdb): {status:'ok', table, columns:[{name,type}]} en exito y {status:'error', error} en fallo. type es el tipo DuckDB tal cual (BIGINT, DOUBLE, VARCHAR...). Es la introspeccion de columnas del grupo duckdb, util para mapear tipos a otro motor (p.ej. PostgreSQL). Depende del paquete duckdb (1.5.2 en python/.venv)."
|
||||
tags: [duckdb, sql, introspection, schema, readonly]
|
||||
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. Debe existir: el modo read_only NO crea la base. Un path inexistente devuelve {status:'error'}."
|
||||
- name: table
|
||||
desc: "nombre de la tabla a inspeccionar. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo en el DESCRIBE (que no admite parametro posicional para el identificador). Un identificador invalido devuelve {status:'error'} sin tocar la base."
|
||||
output: "dict. En exito: {status:'ok', table:str, columns:[{name:str, type:str},...]} donde type es el tipo DuckDB tal cual lo reporta el motor. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_schema_devuelve_columnas_y_tipos"
|
||||
- "test_identificador_invalido_devuelve_status_error"
|
||||
- "test_tabla_inexistente_devuelve_status_error"
|
||||
- "test_db_inexistente_devuelve_status_error"
|
||||
test_file_path: "python/functions/infra/duckdb_table_schema_test.py"
|
||||
file_path: "python/functions/infra/duckdb_table_schema.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
import duckdb
|
||||
from infra.duckdb_table_schema import duckdb_table_schema
|
||||
|
||||
db = "/tmp/almacen.duckdb"
|
||||
con = duckdb.connect(db)
|
||||
con.execute("CREATE TABLE ventas (id BIGINT, region VARCHAR, total DOUBLE, ok BOOLEAN)")
|
||||
con.close()
|
||||
|
||||
res = duckdb_table_schema(db, "ventas")
|
||||
print(res["status"]) # ok
|
||||
print(res["table"]) # ventas
|
||||
print(res["columns"])
|
||||
# [{'name': 'id', 'type': 'BIGINT'}, {'name': 'region', 'type': 'VARCHAR'},
|
||||
# {'name': 'total', 'type': 'DOUBLE'}, {'name': 'ok', 'type': 'BOOLEAN'}]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas el schema de una tabla DuckDB sin abrir la base en escritura:
|
||||
mapear tipos DuckDB a otro motor (es el paso (a) de `duckdb_to_postgres_py_pipelines`),
|
||||
validar que una tabla tiene las columnas esperadas tras una ingesta, o mostrar el
|
||||
schema en una UI. Usa `duckdb_list_tables_py_infra` antes para descubrir que tablas
|
||||
hay. El dict de salida es directamente serializable a JSON.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Lectura real de un archivo en disco (impura). El modo `read_only=True` exige que
|
||||
el archivo **ya exista**: no crea la base. Si `db_path` no existe, devuelve
|
||||
`{status:'error', ...}`.
|
||||
- El identificador `table` se valida contra `^[A-Za-z_][A-Za-z0-9_]*$` porque
|
||||
DESCRIBE NO admite parametro posicional para el nombre de tabla y hay que
|
||||
interpolarlo. Un nombre con espacios, comillas, puntos o intento de inyeccion
|
||||
devuelve `{status:'error', error:'invalid table identifier'}` sin tocar la base.
|
||||
- El `type` es el tipo DuckDB literal (`BIGINT`, `DOUBLE`, `VARCHAR`, `DECIMAL(10,2)`,
|
||||
`STRUCT(...)`, ...). Si lo vas a traducir a otro motor, contempla los tipos
|
||||
parametrizados y compuestos: pueden requerir mapeo con perdida (a TEXT).
|
||||
- DuckDB es single-writer: una base bloqueada en escritura por otro proceso con
|
||||
version distinta puede fallar al abrir en read-only; el error se devuelve, no se
|
||||
lanza.
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Devuelve el schema (columnas y tipos) de una tabla DuckDB 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 `DESCRIBE <table>` (con el identificador de tabla
|
||||
validado y citado, ya que DESCRIBE no admite parametros posicionales) y devuelve
|
||||
las columnas con su tipo DuckDB. 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.
|
||||
|
||||
Complementa a `duckdb_list_tables_py_infra` (que tablas hay) y a
|
||||
`duckdb_query_readonly_py_infra` (lectura de filas). Es la introspeccion de
|
||||
columnas del grupo duckdb, util para mapear tipos a otro motor (p.ej. PostgreSQL).
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Un identificador de tabla valido: letras, digitos y guion bajo, sin empezar por
|
||||
# digito. Suficiente para tablas creadas por el propio ecosistema; rechaza
|
||||
# cualquier cosa que pudiera inyectarse en el DESCRIBE (que no admite parametros).
|
||||
_VALID_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def duckdb_table_schema(db_path: str, table: str) -> dict:
|
||||
"""Devuelve el schema de una tabla 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', ...}.
|
||||
table: nombre de la tabla a inspeccionar. Se valida contra
|
||||
^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo en el DESCRIBE (que no
|
||||
admite parametros posicionales). Un identificador invalido devuelve
|
||||
{status:'error', ...} sin tocar la base.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', table:str, columns:[{name:str, type:str},...]}
|
||||
donde type es el tipo DuckDB tal cual lo reporta el motor (BIGINT, DOUBLE,
|
||||
VARCHAR, ...). En error (sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
if not isinstance(table, str) or not _VALID_IDENT.match(table):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"invalid table identifier: {table!r}",
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = __import__("duckdb").connect(db_path, read_only=True)
|
||||
# DESCRIBE no admite parametros; el identificador ya esta validado y se
|
||||
# cita con dobles comillas (escapando comillas internas, imposible aqui
|
||||
# por el regex pero defensivo).
|
||||
quoted = '"' + table.replace('"', '""') + '"'
|
||||
rows = conn.execute(f"DESCRIBE {quoted}").fetchall()
|
||||
# DESCRIBE devuelve: (column_name, column_type, null, key, default, extra)
|
||||
columns = [{"name": row[0], "type": row[1]} for row in rows]
|
||||
return {"status": "ok", "table": table, "columns": columns}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Tests para duckdb_table_schema."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import duckdb # noqa: E402
|
||||
|
||||
from duckdb_table_schema import duckdb_table_schema # noqa: E402
|
||||
|
||||
|
||||
def _make_db(path: str) -> None:
|
||||
con = duckdb.connect(str(path))
|
||||
con.execute(
|
||||
"CREATE TABLE ventas (id BIGINT, region VARCHAR, total DOUBLE, ok BOOLEAN)"
|
||||
)
|
||||
con.close()
|
||||
|
||||
|
||||
def test_schema_devuelve_columnas_y_tipos(tmp_path):
|
||||
db = tmp_path / "v.duckdb"
|
||||
_make_db(str(db))
|
||||
res = duckdb_table_schema(str(db), "ventas")
|
||||
assert res["status"] == "ok"
|
||||
assert res["table"] == "ventas"
|
||||
names = [c["name"] for c in res["columns"]]
|
||||
types = {c["name"]: c["type"] for c in res["columns"]}
|
||||
assert names == ["id", "region", "total", "ok"]
|
||||
assert types["id"] == "BIGINT"
|
||||
assert types["region"] == "VARCHAR"
|
||||
assert types["total"] == "DOUBLE"
|
||||
assert types["ok"] == "BOOLEAN"
|
||||
|
||||
|
||||
def test_identificador_invalido_devuelve_status_error(tmp_path):
|
||||
db = tmp_path / "v.duckdb"
|
||||
_make_db(str(db))
|
||||
res = duckdb_table_schema(str(db), "ventas; DROP TABLE ventas")
|
||||
assert res["status"] == "error"
|
||||
assert "invalid table identifier" in res["error"]
|
||||
|
||||
|
||||
def test_tabla_inexistente_devuelve_status_error(tmp_path):
|
||||
db = tmp_path / "v.duckdb"
|
||||
_make_db(str(db))
|
||||
res = duckdb_table_schema(str(db), "no_existe")
|
||||
assert res["status"] == "error"
|
||||
|
||||
|
||||
def test_db_inexistente_devuelve_status_error(tmp_path):
|
||||
res = duckdb_table_schema(str(tmp_path / "noexiste.duckdb"), "ventas")
|
||||
assert res["status"] == "error"
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: excel_to_duckdb
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def excel_to_duckdb(xlsx_path: str, duckdb_path: str, table: str, sheet: str = None, mode: str = 'replace') -> dict"
|
||||
description: "Ingesta una hoja de un archivo .xlsx a una tabla DuckDB usando la extension nativa excel de DuckDB (camino activo, verificado en DuckDB 1.5.2). Abre el DuckDB destino en modo read-write (crea el archivo si no existe), carga la extension excel (INSTALL excel; LOAD excel;) y materializa la hoja con read_xlsx. El path del .xlsx y el nombre de la hoja se pasan como parametros posicionales (marcador ?) a read_xlsx, evitando inyeccion por esa via; el identificador de tabla destino se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se cita. mode='replace' (default) hace CREATE OR REPLACE TABLE AS SELECT; mode='append' crea la tabla si no existe y luego INSERT INTO ... SELECT. Devuelve un dict sin lanzar (estilo del grupo duckdb): {status:'ok', table, row_count} en exito y {status:'error', error} en fallo. Depende de los paquetes duckdb (1.5.2) y, indirectamente, de la extension excel de DuckDB."
|
||||
tags: [duckdb, excel, xlsx, ingest, etl]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [re, duckdb]
|
||||
params:
|
||||
- name: xlsx_path
|
||||
desc: "ruta al archivo .xlsx de origen. Debe existir y ser legible. Se pasa como parametro posicional a read_xlsx (no se interpola en el SQL)."
|
||||
- name: duckdb_path
|
||||
desc: "ruta al archivo DuckDB destino. Se abre en modo escritura, que crea el archivo si no existe. DuckDB es single-writer: si otro proceso lo tiene abierto en escritura falla con error de lock."
|
||||
- name: table
|
||||
desc: "nombre de la tabla destino. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo (CREATE/INSERT no admiten parametro para el nombre de tabla). Identificador invalido devuelve {status:'error'} sin tocar la base."
|
||||
- name: sheet
|
||||
desc: "nombre de la hoja a leer. None (default) lee la primera hoja del libro. Se pasa como parametro posicional sheet=? a read_xlsx."
|
||||
- name: mode
|
||||
desc: "'replace' (default) reemplaza la tabla entera con CREATE OR REPLACE TABLE AS SELECT; 'append' crea la tabla si no existe y luego inserta las filas con INSERT INTO ... SELECT. Otro valor devuelve {status:'error'}."
|
||||
output: "dict. En exito: {status:'ok', table:str, row_count:int} donde row_count es el numero de filas que tiene la tabla tras la ingesta. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_replace_ingesta_primera_hoja"
|
||||
- "test_seleccion_de_hoja_por_nombre"
|
||||
- "test_append_acumula_filas"
|
||||
- "test_replace_reemplaza_no_acumula"
|
||||
- "test_identificador_invalido_devuelve_status_error"
|
||||
- "test_mode_invalido_devuelve_status_error"
|
||||
- "test_xlsx_inexistente_devuelve_status_error"
|
||||
test_file_path: "python/functions/infra/excel_to_duckdb_test.py"
|
||||
file_path: "python/functions/infra/excel_to_duckdb.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.excel_to_duckdb import excel_to_duckdb
|
||||
|
||||
# Ingesta la primera hoja de un .xlsx a la tabla `ventas`, reemplazandola.
|
||||
res = excel_to_duckdb(
|
||||
"/tmp/informe_mensual.xlsx",
|
||||
"/tmp/almacen.duckdb",
|
||||
"ventas",
|
||||
mode="replace",
|
||||
)
|
||||
print(res) # {'status': 'ok', 'table': 'ventas', 'row_count': 1280}
|
||||
|
||||
# Ingesta una hoja concreta por nombre en modo append.
|
||||
res2 = excel_to_duckdb(
|
||||
"/tmp/informe_mensual.xlsx",
|
||||
"/tmp/almacen.duckdb",
|
||||
"detalle",
|
||||
sheet="Detalle",
|
||||
mode="append",
|
||||
)
|
||||
print(res2) # {'status': 'ok', 'table': 'detalle', 'row_count': 4096}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando recibes datos en Excel (informes, exports, planillas manuales) y necesitas
|
||||
analizarlos o servirlos con SQL: es el primer eslabon del flujo
|
||||
`Excel -> DuckDB -> PostgreSQL`. Tras ingestar con esta funcion, usa
|
||||
`duckdb_query_readonly_py_infra` para analizar, `duckdb_table_schema_py_infra` para
|
||||
inspeccionar el schema inferido, y `duckdb_to_postgres_py_pipelines` para volcar a
|
||||
PostgreSQL y que Metabase/Grafana lo lean. mode='replace' para snapshots completos
|
||||
(refresco diario), mode='append' para acumular hojas sucesivas en una misma tabla.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Camino activo: extension nativa `excel` de DuckDB** (verificado en DuckDB 1.5.2:
|
||||
`read_xlsx` lee .xlsx y acepta `sheet=`). NO se usa el fallback openpyxl. Si en
|
||||
algun entorno la extension fallara, habria que reactivar un fallback openpyxl (no
|
||||
presente hoy) — documentar el cambio aqui si ocurre.
|
||||
- **`INSTALL excel` necesita red la primera vez** por conexion: descarga la extension
|
||||
del repositorio de extensiones de DuckDB. Una vez instalada queda cacheada en el
|
||||
home de DuckDB y `LOAD excel` funciona offline. En un entorno sin red y sin la
|
||||
extension cacheada, la ingesta falla con `{status:'error', ...}` (no se lanza).
|
||||
- Escritura real en disco (impura). DuckDB es single-writer: si otro proceso tiene
|
||||
`duckdb_path` abierto en escritura, `connect` falla con error de lock devuelto en
|
||||
el dict.
|
||||
- A diferencia de `read_only`, este modo **crea** el archivo DuckDB si no existe. Un
|
||||
`duckdb_path` con un directorio padre inexistente si falla y se reporta como error.
|
||||
- **Inferencia de tipos del .xlsx**: `read_xlsx` infiere los tipos de columna. Los
|
||||
numeros suelen inferirse como DOUBLE (incluso enteros), las fechas pueden quedar
|
||||
como VARCHAR segun el formato de la celda. Revisa el schema resultante con
|
||||
`duckdb_table_schema_py_infra` si el tipado importa aguas abajo.
|
||||
- En `mode='append'` el schema lo fija la **primera** ingesta (CREATE TABLE IF NOT
|
||||
EXISTS). Si una hoja posterior tiene columnas distintas, el INSERT puede fallar por
|
||||
desajuste de columnas; el error se devuelve en el dict.
|
||||
- El identificador `table` se valida (las CREATE/INSERT no parametrizan el nombre de
|
||||
tabla). Un nombre con caracteres fuera de `[A-Za-z0-9_]` devuelve
|
||||
`{status:'error', error:'invalid table identifier'}` sin tocar la base.
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Ingesta una hoja de un archivo .xlsx a una tabla DuckDB.
|
||||
|
||||
Funcion impura: abre el archivo DuckDB destino en modo read-write
|
||||
(`duckdb.connect(duckdb_path)`, que crea el archivo si no existe), carga la
|
||||
extension `excel` de DuckDB y materializa la hoja del .xlsx en una tabla con
|
||||
`read_xlsx`. La conexion se cierra siempre 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.
|
||||
|
||||
Camino activo (verificado en DuckDB 1.5.2): extension nativa `excel`. El path del
|
||||
.xlsx y el nombre de la hoja se pasan como parametros posicionales (marcador `?`)
|
||||
a `read_xlsx`, por lo que NO se interpolan en el SQL y no hay inyeccion por esa
|
||||
via. El identificador de tabla destino SI se interpola (CREATE/INSERT no admiten
|
||||
parametro para el nombre de tabla), asi que se valida contra un regex estricto.
|
||||
|
||||
mode='replace' (default) -> `CREATE OR REPLACE TABLE <table> AS SELECT * FROM
|
||||
read_xlsx(?)`: reemplaza la tabla entera. mode='append' -> crea la tabla si no
|
||||
existe (`CREATE TABLE IF NOT EXISTS ... AS SELECT ... LIMIT 0` para fijar el
|
||||
schema) y luego `INSERT INTO <table> SELECT * FROM read_xlsx(?)`.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Identificador de tabla valido: letras, digitos y guion bajo, sin empezar por
|
||||
# digito. Rechaza cualquier cosa que pudiera inyectarse en el CREATE/INSERT.
|
||||
_VALID_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def excel_to_duckdb(
|
||||
xlsx_path: str,
|
||||
duckdb_path: str,
|
||||
table: str,
|
||||
sheet: str = None,
|
||||
mode: str = "replace",
|
||||
) -> dict:
|
||||
"""Ingesta una hoja de un .xlsx a una tabla DuckDB via la extension excel.
|
||||
|
||||
Args:
|
||||
xlsx_path: ruta al archivo .xlsx de origen. Debe existir y ser legible.
|
||||
Se pasa como parametro posicional a read_xlsx (no se interpola).
|
||||
duckdb_path: ruta al archivo DuckDB destino. Se abre en modo escritura, que
|
||||
crea el archivo si no existe. DuckDB es single-writer: si otro proceso
|
||||
lo tiene abierto en escritura, falla con error de lock.
|
||||
table: nombre de la tabla destino. Se valida contra
|
||||
^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo en el SQL (CREATE/INSERT
|
||||
no admiten parametro para el nombre de tabla). Identificador invalido
|
||||
devuelve {status:'error', ...} sin tocar la base.
|
||||
sheet: nombre de la hoja a leer. None (default) lee la primera hoja del
|
||||
libro. Se pasa como parametro posicional (sheet=?) a read_xlsx.
|
||||
mode: 'replace' (default) reemplaza la tabla entera con CREATE OR REPLACE
|
||||
TABLE AS SELECT. 'append' crea la tabla si no existe y luego inserta
|
||||
las filas con INSERT INTO ... SELECT. Cualquier otro valor devuelve
|
||||
{status:'error', ...}.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', table:str, row_count:int} donde row_count es
|
||||
el numero de filas que tiene la tabla tras la ingesta. En error (sin
|
||||
lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
if not isinstance(table, str) or not _VALID_IDENT.match(table):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"invalid table identifier: {table!r}",
|
||||
}
|
||||
if mode not in ("replace", "append"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"invalid mode: {mode!r} (expected 'replace' or 'append')",
|
||||
}
|
||||
|
||||
quoted = '"' + table.replace('"', '""') + '"'
|
||||
|
||||
# Argumentos de read_xlsx: path siempre, sheet solo si se especifica. Todo
|
||||
# como parametros posicionales para evitar inyeccion via el .xlsx/hoja.
|
||||
if sheet is not None:
|
||||
read_call = "read_xlsx(?, sheet=?)"
|
||||
read_params = [xlsx_path, sheet]
|
||||
else:
|
||||
read_call = "read_xlsx(?)"
|
||||
read_params = [xlsx_path]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = __import__("duckdb").connect(duckdb_path)
|
||||
# La extension excel se instala (red la 1a vez) y carga en la conexion.
|
||||
conn.execute("INSTALL excel; LOAD excel;")
|
||||
|
||||
if mode == "replace":
|
||||
conn.execute(
|
||||
f"CREATE OR REPLACE TABLE {quoted} AS SELECT * FROM {read_call}",
|
||||
read_params,
|
||||
)
|
||||
else: # append
|
||||
# Fijamos el schema de la tabla con un SELECT vacio si no existe, sin
|
||||
# cargar datos; luego insertamos todas las filas.
|
||||
conn.execute(
|
||||
f"CREATE TABLE IF NOT EXISTS {quoted} AS "
|
||||
f"SELECT * FROM {read_call} LIMIT 0",
|
||||
read_params,
|
||||
)
|
||||
conn.execute(
|
||||
f"INSERT INTO {quoted} SELECT * FROM {read_call}",
|
||||
read_params,
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
row_count = conn.execute(f"SELECT COUNT(*) FROM {quoted}").fetchone()[0]
|
||||
return {"status": "ok", "table": table, "row_count": int(row_count)}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests para excel_to_duckdb."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import duckdb # noqa: E402
|
||||
import openpyxl # noqa: E402
|
||||
|
||||
from excel_to_duckdb import excel_to_duckdb # noqa: E402
|
||||
|
||||
|
||||
def _make_xlsx(path: str) -> None:
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Hoja1"
|
||||
ws.append(["id", "nombre", "total"])
|
||||
ws.append([1, "ana", 10.5])
|
||||
ws.append([2, "luis", 20.0])
|
||||
ws.append([3, "eva", 5.25])
|
||||
ws2 = wb.create_sheet("Segunda")
|
||||
ws2.append(["x"])
|
||||
ws2.append([99])
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def test_replace_ingesta_primera_hoja(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = excel_to_duckdb(str(xlsx), str(db), "ventas")
|
||||
assert res["status"] == "ok", res
|
||||
assert res["table"] == "ventas"
|
||||
assert res["row_count"] == 3
|
||||
# Verificar que la tabla existe con las filas esperadas.
|
||||
con = duckdb.connect(str(db), read_only=True)
|
||||
assert con.execute("SELECT COUNT(*) FROM ventas").fetchone()[0] == 3
|
||||
con.close()
|
||||
|
||||
|
||||
def test_seleccion_de_hoja_por_nombre(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = excel_to_duckdb(str(xlsx), str(db), "otra", sheet="Segunda")
|
||||
assert res["status"] == "ok", res
|
||||
assert res["row_count"] == 1
|
||||
|
||||
|
||||
def test_append_acumula_filas(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
r1 = excel_to_duckdb(str(xlsx), str(db), "acum", mode="replace")
|
||||
assert r1["row_count"] == 3
|
||||
r2 = excel_to_duckdb(str(xlsx), str(db), "acum", mode="append")
|
||||
assert r2["status"] == "ok", r2
|
||||
assert r2["row_count"] == 6
|
||||
|
||||
|
||||
def test_replace_reemplaza_no_acumula(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
excel_to_duckdb(str(xlsx), str(db), "rep", mode="replace")
|
||||
res = excel_to_duckdb(str(xlsx), str(db), "rep", mode="replace")
|
||||
assert res["row_count"] == 3
|
||||
|
||||
|
||||
def test_identificador_invalido_devuelve_status_error(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = excel_to_duckdb(str(xlsx), str(db), "t; DROP TABLE x")
|
||||
assert res["status"] == "error"
|
||||
assert "invalid table identifier" in res["error"]
|
||||
|
||||
|
||||
def test_mode_invalido_devuelve_status_error(tmp_path):
|
||||
xlsx = tmp_path / "datos.xlsx"
|
||||
_make_xlsx(str(xlsx))
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = excel_to_duckdb(str(xlsx), str(db), "t", mode="upsert")
|
||||
assert res["status"] == "error"
|
||||
assert "invalid mode" in res["error"]
|
||||
|
||||
|
||||
def test_xlsx_inexistente_devuelve_status_error(tmp_path):
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = excel_to_duckdb(str(tmp_path / "noexiste.xlsx"), str(db), "t")
|
||||
assert res["status"] == "error"
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: pg_create_table_from_rows
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def pg_create_table_from_rows(dsn: str, table: str, rows: list[dict], primary_key: list[str] = None) -> dict"
|
||||
description: "Crea una tabla PostgreSQL (CREATE TABLE IF NOT EXISTS, idempotente) infiriendo columnas y tipos desde los valores de las filas. Mapeo de tipos: bool->BOOLEAN, int->BIGINT, float->DOUBLE PRECISION, datetime->TIMESTAMP, date->DATE, resto->TEXT; None no determina tipo (columna con todo None queda en TEXT). El conjunto de columnas es la union de las claves de todas las filas. Si primary_key se indica, anade PRIMARY KEY (...). Valida que table, columnas y primary_key casen ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlas. Detecta si la tabla ya existia (to_regclass antes del CREATE) para reportar created. Commit al exito, rollback al fallo, cierre en try/finally. Devuelve {status:'ok', created, table, columns} o {status:'error', error} sin lanzar. Depende de psycopg2 (2.9.x en python/.venv)."
|
||||
tags: [postgres, postgresql, sql, ddl, schema, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [datetime, re, psycopg2]
|
||||
params:
|
||||
- name: dsn
|
||||
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname."
|
||||
- name: table
|
||||
desc: "Nombre de la tabla a crear. Validado como identificador SQL [A-Za-z_][A-Za-z0-9_]*; un nombre raro devuelve {status:'error'}."
|
||||
- name: rows
|
||||
desc: "Lista de dicts (clave = nombre de columna). Las columnas son la union de las claves de todas las filas (orden de primera aparicion). El tipo de cada columna lo fija el primer valor NO nulo; columna con todo None queda en TEXT. Lista vacia o sin claves -> {status:'error'} (nada que crear)."
|
||||
- name: primary_key
|
||||
desc: "Lista de columnas que forman la PRIMARY KEY (opcional). Cada una debe existir entre las columnas inferidas. None (default) -> sin PRIMARY KEY. Util para que pg_upsert tenga su ON CONFLICT target."
|
||||
output: "dict. En exito: {status:'ok', created:bool, table:str, columns:{col:tipo_pg}} donde created=True si el CREATE creo la tabla y False si ya existia, y columns es el mapa columna->tipo PostgreSQL inferido. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests: ["test_skip_sin_pg_test_dsn", "test_infiere_tipos_desde_valores", "test_identificador_invalido_devuelve_status_error", "test_columna_con_todo_none_queda_text", "test_primary_key_se_anade", "test_idempotente_created_false_la_segunda_vez"]
|
||||
test_file_path: "python/functions/infra/pg_create_table_from_rows_test.py"
|
||||
file_path: "python/functions/infra/pg_create_table_from_rows.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
from datetime import date
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.pg_create_table_from_rows import pg_create_table_from_rows
|
||||
|
||||
dsn = "postgresql://user:pass@localhost:5433/trends"
|
||||
rows = [
|
||||
{"email": "ana@x.com", "name": "Ana", "score": 87, "active": True, "joined": date(2026, 1, 5)},
|
||||
{"email": "bob@x.com", "name": "Bob", "score": 12, "active": False, "joined": date(2026, 2, 1)},
|
||||
]
|
||||
|
||||
res = pg_create_table_from_rows(dsn, "leads", rows, primary_key=["email"])
|
||||
print(res["status"]) # ok
|
||||
print(res["created"]) # True (la primera vez), False en re-ejecuciones
|
||||
print(res["columns"]) # {'email': 'TEXT', 'name': 'TEXT', 'score': 'BIGINT',
|
||||
# 'active': 'BOOLEAN', 'joined': 'DATE'}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala como paso de bootstrap antes de escribir datos cuando NO tienes una migracion
|
||||
`.sql` a mano: derivas el schema directamente de la primera tanda de filas scrapeadas
|
||||
o parseadas y creas la tabla idempotentemente. Combina bien con `pg_upsert`: pasa el
|
||||
mismo `key_cols` como `primary_key` aqui para que el `ON CONFLICT` del upsert tenga
|
||||
su target UNIQUE. Para schemas controlados y versionados usa `pg_apply_sql` con un
|
||||
`.sql` explicito en su lugar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Escritura real de DDL (impura). `CREATE TABLE IF NOT EXISTS` es idempotente: si la
|
||||
tabla ya existe NO la modifica ni reconcilia el schema — `created` vuelve False y
|
||||
las columnas reportadas son las INFERIDAS de las filas, no las reales de la tabla
|
||||
existente. Esta funcion no hace ALTER TABLE; para anadir columnas a una tabla
|
||||
existente usa una migracion (`pg_apply_sql`).
|
||||
- **Inferencia best-effort**: el tipo lo fija el primer valor NO nulo de cada columna
|
||||
recorriendo las filas. Una columna con todo None cae a TEXT. `bool` se comprueba
|
||||
antes que `int` (bool es subclase de int en Python) y `datetime` antes que `date`
|
||||
(datetime es subclase de date), si no el mapeo seria erroneo. Strings que "parecen"
|
||||
numeros o fechas se quedan en TEXT — no se hace coercion. int siempre BIGINT (no
|
||||
detecta INTEGER/SMALLINT), float siempre DOUBLE PRECISION (no NUMERIC con escala),
|
||||
asi que pierdes precision exacta para dinero: para columnas monetarias define el
|
||||
schema a mano con NUMERIC via `pg_apply_sql`.
|
||||
- **Inyeccion SQL**: `table`, los nombres de columna (claves de los dicts) y
|
||||
`primary_key` se validan contra `^[A-Za-z_][A-Za-z0-9_]*$` antes de interpolarlos
|
||||
(la DDL no admite parametros). Un nombre con espacios, comillas, puntos o vacio
|
||||
devuelve `{status:'error'}`.
|
||||
- **Deteccion de created**: se consulta `to_regclass(table)` ANTES del CREATE. Si
|
||||
otro proceso crea la tabla entre esa comprobacion y el CREATE (carrera), `created`
|
||||
puede reportar True aunque otro la creara. Diseñada para un unico bootstrapper.
|
||||
- `to_regclass` resuelve el nombre contra el `search_path` de la conexion (por
|
||||
defecto `public`); igual que el `CREATE`. Si trabajas con un schema no-default,
|
||||
fija el `search_path` en el DSN o usa `pg_apply_sql`.
|
||||
- Nunca lanza: DSN invalido, identificador invalido, rows vacio o falta de psycopg2
|
||||
vuelven como `{status:'error', error:str}`.
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Crea una tabla PostgreSQL infiriendo columnas y tipos desde filas de ejemplo.
|
||||
|
||||
Funcion impura: abre una conexion psycopg2 con el DSN dado, infiere el nombre y el
|
||||
tipo PostgreSQL de cada columna a partir de los valores de las filas, ejecuta un
|
||||
`CREATE TABLE IF NOT EXISTS` (idempotente), hace commit y cierra en try/finally.
|
||||
Devuelve un dict sin lanzar, siguiendo el estilo del grupo duckdb del registry:
|
||||
{status:'ok', created, table, columns} en exito y {status:'error', error:str} en
|
||||
fallo.
|
||||
|
||||
Inferencia de tipos por columna (recorriendo TODAS las filas):
|
||||
- bool -> BOOLEAN
|
||||
- int -> BIGINT
|
||||
- float -> DOUBLE PRECISION
|
||||
- datetime -> TIMESTAMP
|
||||
- date -> DATE (date que NO es datetime; datetime es subclase de date)
|
||||
- resto/None -> TEXT
|
||||
|
||||
NULL no determina tipo: si toda una columna es None, queda en TEXT por defecto. El
|
||||
primer valor no nulo encontrado fija el tipo de la columna.
|
||||
|
||||
Identificadores (tabla, columnas, primary_key) se validan contra
|
||||
`[A-Za-z_][A-Za-z0-9_]*` antes de interpolarlos (no se pueden parametrizar
|
||||
identificadores en DDL).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
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 _pg_type(value) -> str:
|
||||
"""Mapea un valor Python no nulo a un tipo PostgreSQL.
|
||||
|
||||
bool -> BOOLEAN, int -> BIGINT, float -> DOUBLE PRECISION, datetime -> TIMESTAMP,
|
||||
date -> DATE, resto -> TEXT. None devuelve None (no determina tipo).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
# bool es subclase de int: comprobar bool primero.
|
||||
if isinstance(value, bool):
|
||||
return "BOOLEAN"
|
||||
if isinstance(value, int):
|
||||
return "BIGINT"
|
||||
if isinstance(value, float):
|
||||
return "DOUBLE PRECISION"
|
||||
# datetime es subclase de date: comprobar datetime primero.
|
||||
if isinstance(value, datetime.datetime):
|
||||
return "TIMESTAMP"
|
||||
if isinstance(value, datetime.date):
|
||||
return "DATE"
|
||||
return "TEXT"
|
||||
|
||||
|
||||
def pg_create_table_from_rows(
|
||||
dsn: str,
|
||||
table: str,
|
||||
rows: list,
|
||||
primary_key: list = None,
|
||||
) -> dict:
|
||||
"""Crea `table` (IF NOT EXISTS) infiriendo columnas y tipos desde `rows`.
|
||||
|
||||
Args:
|
||||
dsn: cadena de conexion PostgreSQL, p.ej.
|
||||
"postgresql://user:pass@localhost:5433/trends".
|
||||
table: nombre de la tabla a crear. Validado como identificador SQL.
|
||||
rows: lista de dicts (clave = nombre de columna). El conjunto de columnas es
|
||||
la UNION de las claves de todas las filas (orden de primera aparicion).
|
||||
El tipo de cada columna lo fija el primer valor NO nulo encontrado para
|
||||
esa columna; columnas con todo None quedan en TEXT. Lista vacia o sin
|
||||
columnas -> {status:'error'} (no hay nada que crear).
|
||||
primary_key: columnas que forman la PRIMARY KEY (opcional). Cada una debe
|
||||
existir entre las columnas inferidas. None -> sin PRIMARY KEY.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', created:bool, table:str, columns:{col:tipo}}
|
||||
donde created indica si el CREATE TABLE creo la tabla (True) o ya existia
|
||||
(False), y columns es el mapa columna -> tipo PostgreSQL inferido. En error
|
||||
(sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"psycopg2 is required for pg_create_table_from_rows; "
|
||||
f"install psycopg2-binary ({exc})"
|
||||
),
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("rows debe ser una lista de dicts")
|
||||
|
||||
table = _validate_ident(table)
|
||||
|
||||
# Union estable de columnas (orden de primera aparicion).
|
||||
columns: list = []
|
||||
seen: set = set()
|
||||
for i, row in enumerate(rows):
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError(f"rows[{i}] no es un dict")
|
||||
for key in row:
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
columns.append(_validate_ident(key))
|
||||
|
||||
if not columns:
|
||||
raise ValueError(
|
||||
"no hay columnas que inferir: rows vacio o sin claves"
|
||||
)
|
||||
|
||||
# Inferir tipo por columna: primer valor NO nulo fija el tipo; TEXT default.
|
||||
col_types: dict = {}
|
||||
for col in columns:
|
||||
inferred = None
|
||||
for row in rows:
|
||||
t = _pg_type(row.get(col))
|
||||
if t is not None:
|
||||
inferred = t
|
||||
break
|
||||
col_types[col] = inferred if inferred is not None else "TEXT"
|
||||
|
||||
col_defs = [f"{col} {col_types[col]}" for col in columns]
|
||||
|
||||
pk_clause = ""
|
||||
if primary_key:
|
||||
pk_cols = [_validate_ident(c) for c in primary_key]
|
||||
for pk in pk_cols:
|
||||
if pk not in col_types:
|
||||
raise ValueError(
|
||||
f"primary_key {pk!r} no esta entre las columnas inferidas"
|
||||
)
|
||||
pk_clause = f", PRIMARY KEY ({', '.join(pk_cols)})"
|
||||
|
||||
ddl = (
|
||||
f"CREATE TABLE IF NOT EXISTS {table} "
|
||||
f"({', '.join(col_defs)}{pk_clause})"
|
||||
)
|
||||
|
||||
conn = psycopg2.connect(dsn)
|
||||
with conn.cursor() as cur:
|
||||
# Existencia ANTES del CREATE para distinguir creada vs ya existente.
|
||||
cur.execute("SELECT to_regclass(%s)", (table,))
|
||||
existed_before = cur.fetchone()[0] is not None
|
||||
cur.execute(ddl)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"created": not existed_before,
|
||||
"table": table,
|
||||
"columns": col_types,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
if conn is not None:
|
||||
conn.rollback()
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests para pg_create_table_from_rows.
|
||||
|
||||
Requieren un PostgreSQL real. Si PG_TEST_DSN no esta definida, los tests que tocan
|
||||
la DB se saltan. Cada test crea una tabla con nombre aleatorio y la elimina.
|
||||
|
||||
PG_TEST_DSN="postgresql://user:pass@localhost:5433/trends" \
|
||||
python/.venv/bin/python3 -m pytest \
|
||||
python/functions/infra/pg_create_table_from_rows_test.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from infra.pg_create_table_from_rows import ( # noqa: E402
|
||||
_pg_type,
|
||||
pg_create_table_from_rows,
|
||||
)
|
||||
|
||||
PG_TEST_DSN = os.environ.get("PG_TEST_DSN")
|
||||
requires_pg = pytest.mark.skipif(
|
||||
not PG_TEST_DSN, reason="PG_TEST_DSN no definido: se omiten los tests de Postgres"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def table_name():
|
||||
"""Nombre de tabla aleatorio; la elimina al terminar (si existe)."""
|
||||
name = "pg_ctfr_t_" + uuid.uuid4().hex[:12]
|
||||
yield name
|
||||
if PG_TEST_DSN:
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {name}")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_skip_sin_pg_test_dsn():
|
||||
"""skip sin PG_TEST_DSN."""
|
||||
if not PG_TEST_DSN:
|
||||
pytest.skip("PG_TEST_DSN no definido")
|
||||
assert PG_TEST_DSN
|
||||
|
||||
|
||||
def test_infiere_tipos_desde_valores():
|
||||
"""infiere tipos desde valores: _pg_type es puro, sin DB."""
|
||||
assert _pg_type(True) == "BOOLEAN"
|
||||
assert _pg_type(3) == "BIGINT"
|
||||
assert _pg_type(1.5) == "DOUBLE PRECISION"
|
||||
assert _pg_type(datetime(2026, 1, 1, 12, 0)) == "TIMESTAMP"
|
||||
assert _pg_type(date(2026, 1, 1)) == "DATE"
|
||||
assert _pg_type("hola") == "TEXT"
|
||||
assert _pg_type(None) is None # None no determina tipo
|
||||
|
||||
|
||||
def test_identificador_invalido_devuelve_status_error():
|
||||
"""identificador invalido devuelve status error sin tocar DB."""
|
||||
res = pg_create_table_from_rows(
|
||||
"postgresql://x/y", "mala tabla", [{"a": 1}]
|
||||
)
|
||||
assert res["status"] == "error"
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_columna_con_todo_none_queda_text(table_name):
|
||||
"""columna con todo none queda text: tipos correctos y created True."""
|
||||
rows = [
|
||||
{"id": 1, "ratio": 0.5, "flag": True, "note": None},
|
||||
{"id": 2, "ratio": 0.9, "flag": False, "note": None},
|
||||
]
|
||||
res = pg_create_table_from_rows(PG_TEST_DSN, table_name, rows)
|
||||
assert res["status"] == "ok"
|
||||
assert res["created"] is True
|
||||
assert res["columns"] == {
|
||||
"id": "BIGINT",
|
||||
"ratio": "DOUBLE PRECISION",
|
||||
"flag": "BOOLEAN",
|
||||
"note": "TEXT", # toda la columna es None -> TEXT por defecto
|
||||
}
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_primary_key_se_anade(table_name):
|
||||
"""primary key se anade: el upsert posterior puede usar ON CONFLICT."""
|
||||
res = pg_create_table_from_rows(
|
||||
PG_TEST_DSN, table_name,
|
||||
[{"email": "a@x.com", "name": "A"}], primary_key=["email"],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
# Verifica que existe la PK consultando el catalogo.
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT count(*) FROM information_schema.table_constraints "
|
||||
"WHERE table_name = %s AND constraint_type = 'PRIMARY KEY'",
|
||||
(table_name,),
|
||||
)
|
||||
assert cur.fetchone()[0] == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_idempotente_created_false_la_segunda_vez(table_name):
|
||||
"""idempotente created false la segunda vez."""
|
||||
rows = [{"id": 1, "name": "x"}]
|
||||
first = pg_create_table_from_rows(PG_TEST_DSN, table_name, rows)
|
||||
second = pg_create_table_from_rows(PG_TEST_DSN, table_name, rows)
|
||||
assert first["status"] == "ok" and first["created"] is True
|
||||
assert second["status"] == "ok" and second["created"] is False
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: pg_list_tables
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def pg_list_tables(dsn: str, schema: str = 'public') -> dict"
|
||||
description: "Introspeccion read-only de un schema PostgreSQL: lista las tablas base con sus columnas leyendo information_schema.tables e information_schema.columns. Devuelve {status:'ok', schema, tables:[{name, columns:[{name, type, nullable}]}]} en exito y {status:'error', error} en fallo (sin lanzar). Excluye vistas (table_type='BASE TABLE'). Tablas ordenadas por nombre, columnas por posicion ordinal. Marca la transaccion read-only y nunca hace commit. El schema va por placeholder %s (no se interpola). Cierra la conexion siempre en try/finally. Depende de psycopg2 (2.9.x en python/.venv)."
|
||||
tags: [postgres, postgresql, sql, introspection, schema, readonly, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [psycopg2]
|
||||
params:
|
||||
- name: dsn
|
||||
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname."
|
||||
- name: schema
|
||||
desc: "Nombre del schema a introspeccionar (default 'public'). Va por placeholder; un schema inexistente devuelve {status:'ok', tables:[]} (lista vacia, no error)."
|
||||
output: "dict. En exito: {status:'ok', schema:str, tables:[{name:str, columns:[{name:str, type:str, nullable:bool}, ...]}, ...]}; tables ordenadas por nombre, columns por posicion ordinal. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests: ["test_skip_sin_pg_test_dsn", "test_lista_tabla_creada_con_sus_columnas", "test_reporta_nullable_correctamente", "test_schema_inexistente_devuelve_lista_vacia"]
|
||||
test_file_path: "python/functions/infra/pg_list_tables_test.py"
|
||||
file_path: "python/functions/infra/pg_list_tables.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.pg_list_tables import pg_list_tables
|
||||
|
||||
dsn = "postgresql://user:pass@localhost:5433/trends"
|
||||
|
||||
res = pg_list_tables(dsn, schema="public")
|
||||
print(res["status"]) # ok
|
||||
print(res["schema"]) # public
|
||||
for t in res["tables"]:
|
||||
print(t["name"], "->", [c["name"] for c in t["columns"]])
|
||||
# leads -> ['email', 'name', 'score', 'active', 'joined']
|
||||
# prices -> ['id', 'product', 'price', 'source', 'snapshot_date']
|
||||
print(res["tables"][0]["columns"][0])
|
||||
# {'name': 'email', 'type': 'text', 'nullable': False}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesitas saber que tablas y columnas existen en un Postgres antes de
|
||||
escribir o consultar: validar que `pg_create_table_from_rows` dejo el schema
|
||||
esperado, descubrir el shape de una base ajena, alimentar un selector de tablas en
|
||||
una UI/agente, o comprobar `nullable`/`type` de una columna antes de un upsert. Es la
|
||||
contraparte de introspeccion del grupo postgres. Para leer datos usa `pg_query`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Lectura real contra el servidor (impura), pero solo del catalogo del sistema
|
||||
(`information_schema`). La transaccion se marca read-only y se hace rollback al
|
||||
final: no escribe nada.
|
||||
- **Solo tablas base**: filtra `table_type = 'BASE TABLE'`, asi que NO lista vistas,
|
||||
vistas materializadas ni tablas foreign. Si necesitas vistas, amplia la consulta.
|
||||
- El campo `type` es el `data_type` de `information_schema.columns`: nombres
|
||||
"lógicos" del estandar (`integer`, `text`, `timestamp without time zone`,
|
||||
`numeric`), no el tipo interno de `pg_catalog` (`int4`, `int8`). No incluye
|
||||
longitud/escala (`varchar(50)` aparece como `character varying`). Para detalle fino
|
||||
consulta `pg_catalog` directamente con `pg_query`.
|
||||
- **Permisos**: `information_schema` solo muestra objetos sobre los que el rol de
|
||||
conexion tiene algun privilegio. Con un usuario de permisos limitados puede faltar
|
||||
alguna tabla aunque exista — no es un error, es visibilidad del catalogo.
|
||||
- Un schema inexistente NO es error: devuelve `{status:'ok', schema, tables:[]}`. Un
|
||||
DSN invalido o servidor caido si vuelve como `{status:'error', ...}`.
|
||||
- El `schema` va por placeholder `%s`, no se interpola: la consulta solo lee catalogo
|
||||
(no hay DDL/DML que inyectar), pero el placeholder evita ademas romper el SQL con un
|
||||
nombre raro.
|
||||
- Nunca lanza: DSN invalido, servidor caido o falta de psycopg2 vuelven como
|
||||
`{status:'error', error:str}`.
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Lista las tablas de un schema PostgreSQL con sus columnas (introspeccion read-only).
|
||||
|
||||
Funcion impura: abre una conexion psycopg2 con el DSN dado, lee
|
||||
information_schema.tables e information_schema.columns para el schema indicado y
|
||||
devuelve un dict sin lanzar, siguiendo el estilo del grupo duckdb del registry:
|
||||
{status:'ok', schema, tables} en exito y {status:'error', error:str} en fallo. La
|
||||
conexion se cierra siempre en try/finally y nunca se hace commit (introspeccion pura
|
||||
de catalogo, sin escritura).
|
||||
|
||||
El nombre del schema va por parametro (placeholder %s), no se interpola: la consulta
|
||||
solo lee del catalogo del sistema, asi que no hay riesgo de DDL/DML por inyeccion,
|
||||
pero el placeholder evita ademas que un schema con caracteres raros rompa el SQL.
|
||||
"""
|
||||
|
||||
|
||||
def pg_list_tables(dsn: str, schema: str = "public") -> dict:
|
||||
"""Lista las tablas base de `schema` con sus columnas.
|
||||
|
||||
Args:
|
||||
dsn: cadena de conexion PostgreSQL, p.ej.
|
||||
"postgresql://user:pass@localhost:5433/trends".
|
||||
schema: nombre del schema a introspeccionar (default "public"). Va por
|
||||
placeholder; un schema inexistente devuelve {status:'ok', tables:[]}.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', schema:str, tables:[{name:str, columns:[
|
||||
{name:str, type:str, nullable:bool}, ...]}, ...]} donde tables esta ordenada
|
||||
por nombre y, dentro de cada tabla, columns por su posicion ordinal en la
|
||||
tabla. En error (sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"psycopg2 is required for pg_list_tables; install psycopg2-binary "
|
||||
f"({exc})"
|
||||
),
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
conn.set_session(readonly=True, autocommit=False)
|
||||
with conn.cursor() as cur:
|
||||
# Tablas base del schema (excluye vistas), ordenadas por nombre.
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = %s AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
""",
|
||||
(schema,),
|
||||
)
|
||||
table_names = [r[0] for r in cur.fetchall()]
|
||||
|
||||
# Columnas de todas las tablas del schema en una sola consulta.
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT table_name, column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = %s
|
||||
ORDER BY table_name, ordinal_position
|
||||
""",
|
||||
(schema,),
|
||||
)
|
||||
cols_by_table: dict = {name: [] for name in table_names}
|
||||
for table_name, column_name, data_type, is_nullable in cur.fetchall():
|
||||
# Una vista podria colarse en columns aunque no en table_names; la
|
||||
# ignoramos para mantener la coherencia con las tablas base.
|
||||
if table_name not in cols_by_table:
|
||||
continue
|
||||
cols_by_table[table_name].append(
|
||||
{
|
||||
"name": column_name,
|
||||
"type": data_type,
|
||||
"nullable": (is_nullable == "YES"),
|
||||
}
|
||||
)
|
||||
|
||||
conn.rollback()
|
||||
|
||||
tables = [
|
||||
{"name": name, "columns": cols_by_table[name]} for name in table_names
|
||||
]
|
||||
return {"status": "ok", "schema": schema, "tables": tables}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests para pg_list_tables.
|
||||
|
||||
Requieren un PostgreSQL real. Si PG_TEST_DSN no esta definida, los tests que tocan
|
||||
la DB se saltan. Cada test crea una tabla con nombre aleatorio y la elimina.
|
||||
|
||||
PG_TEST_DSN="postgresql://user:pass@localhost:5433/trends" \
|
||||
python/.venv/bin/python3 -m pytest python/functions/infra/pg_list_tables_test.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from infra.pg_list_tables import pg_list_tables # noqa: E402
|
||||
|
||||
PG_TEST_DSN = os.environ.get("PG_TEST_DSN")
|
||||
requires_pg = pytest.mark.skipif(
|
||||
not PG_TEST_DSN, reason="PG_TEST_DSN no definido: se omiten los tests de Postgres"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_table():
|
||||
"""Crea una tabla conocida y la elimina al terminar."""
|
||||
import psycopg2
|
||||
|
||||
name = "pg_list_t_" + uuid.uuid4().hex[:12]
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"CREATE TABLE {name} "
|
||||
f"(id INTEGER NOT NULL, label TEXT, ts TIMESTAMP)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
yield name
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {name}")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_skip_sin_pg_test_dsn():
|
||||
"""skip sin PG_TEST_DSN."""
|
||||
if not PG_TEST_DSN:
|
||||
pytest.skip("PG_TEST_DSN no definido")
|
||||
assert PG_TEST_DSN
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_lista_tabla_creada_con_sus_columnas(temp_table):
|
||||
"""lista tabla creada con sus columnas."""
|
||||
res = pg_list_tables(PG_TEST_DSN, schema="public")
|
||||
assert res["status"] == "ok"
|
||||
assert res["schema"] == "public"
|
||||
found = [t for t in res["tables"] if t["name"] == temp_table]
|
||||
assert len(found) == 1
|
||||
col_names = [c["name"] for c in found[0]["columns"]]
|
||||
assert col_names == ["id", "label", "ts"] # orden por posicion ordinal
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_reporta_nullable_correctamente(temp_table):
|
||||
"""reporta nullable correctamente."""
|
||||
res = pg_list_tables(PG_TEST_DSN, schema="public")
|
||||
cols = {
|
||||
c["name"]: c
|
||||
for t in res["tables"]
|
||||
if t["name"] == temp_table
|
||||
for c in t["columns"]
|
||||
}
|
||||
assert cols["id"]["nullable"] is False # NOT NULL
|
||||
assert cols["label"]["nullable"] is True
|
||||
assert cols["id"]["type"] == "integer"
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_schema_inexistente_devuelve_lista_vacia():
|
||||
"""schema inexistente devuelve lista vacia (no error)."""
|
||||
res = pg_list_tables(PG_TEST_DSN, schema="no_existe_" + uuid.uuid4().hex[:8])
|
||||
assert res["status"] == "ok"
|
||||
assert res["tables"] == []
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: pg_query
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def pg_query(dsn: str, sql: str, params: list = None, max_rows: int = 10000) -> dict"
|
||||
description: "Ejecuta un SELECT contra PostgreSQL via psycopg2 y devuelve las filas como list[dict] sin lanzar. Abre la conexion con el DSN, marca la transaccion read-only (SET TRANSACTION READ ONLY) y usa RealDictCursor para que cada fila sea un dict columna->valor. Devuelve {status:'ok', columns, rows, row_count, truncated} en exito y {status:'error', error} en fallo (estilo duckdb_query_readonly). Usa parametros posicionales con el marcador %s. Trunca a max_rows para proteger memoria. Normaliza valores no JSON-serializables: date/datetime/time a isoformat(), Decimal a float, bytes/memoryview a base64, UUID a str. Cierra la conexion siempre en try/finally. Espejo de duckdb_query_readonly para Postgres. Depende de psycopg2 (2.9.x en python/.venv)."
|
||||
tags: [postgres, postgresql, sql, query, readonly, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [base64, datetime, decimal, uuid, psycopg2]
|
||||
params:
|
||||
- name: dsn
|
||||
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname. Un DSN invalido o servidor inalcanzable devuelve {status:'error'} sin lanzar."
|
||||
- name: sql
|
||||
desc: "Sentencia SQL a ejecutar (pensada para SELECT). Usa el marcador %s para parametros posicionales (estilo psycopg2)."
|
||||
- name: params
|
||||
desc: "Lista de parametros posicionales para el SQL en orden. None (default) significa sin parametros. Pasar los valores aqui en vez de interpolarlos en el SQL evita inyeccion."
|
||||
- name: max_rows
|
||||
desc: "Numero maximo de filas a materializar en memoria (default 10000). Si la query produce mas, el resultado se trunca y truncated queda en True."
|
||||
output: "dict. En exito: {status:'ok', columns:[str,...], rows:[{col:val,...},...], row_count:int, truncated:bool}; las filas son dicts (RealDictCursor). En error (sin lanzar): {status:'error', error:str}. Los valores estan normalizados a tipos JSON-serializables."
|
||||
tested: true
|
||||
tests: ["test_skip_sin_pg_test_dsn", "test_normaliza_tipos_no_serializables", "test_select_con_parametros_posicionales", "test_trunca_a_max_rows", "test_dsn_invalido_devuelve_status_error"]
|
||||
test_file_path: "python/functions/infra/pg_query_test.py"
|
||||
file_path: "python/functions/infra/pg_query.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.pg_query import pg_query
|
||||
|
||||
dsn = "postgresql://user:pass@localhost:5433/trends"
|
||||
|
||||
# SELECT con parametro posicional (nunca interpolar el valor en el SQL).
|
||||
res = pg_query(
|
||||
dsn,
|
||||
"SELECT product, price FROM prices WHERE source = %s ORDER BY price DESC",
|
||||
params=["amazon"],
|
||||
max_rows=100,
|
||||
)
|
||||
print(res["status"]) # ok
|
||||
print(res["columns"]) # ['product', 'price']
|
||||
print(res["rows"][0]) # {'product': 'Widget X', 'price': 19.99}
|
||||
print(res["truncated"]) # False
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites leer datos de Postgres y pasarlos a otro paso de una
|
||||
composicion como dict serializable: inspeccionar una tabla, validar el resultado de
|
||||
un pipeline de ingesta, alimentar un dashboard o report, o consultar tablas
|
||||
materializadas. Es el espejo de `duckdb_query_readonly` para Postgres. Para escribir
|
||||
usa `pg_insert_rows`, `pg_upsert` o `pg_apply_sql`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Lectura real contra un servidor (impura). La transaccion se marca read-only con
|
||||
`set_session(readonly=True)` y nunca se hace commit (rollback al final): cualquier
|
||||
`INSERT`/`UPDATE`/`DELETE` en el SQL falla a nivel de servidor y vuelve como
|
||||
`{status:'error', ...}`. NO es un sandbox de filesystem — read-only protege la
|
||||
base, no impide leer datos sensibles si el SQL viene de un cliente no confiable.
|
||||
- Inyeccion SQL: los **valores** van siempre por `params` con el marcador `%s`,
|
||||
nunca interpolados en el string del SQL. Esta funcion NO valida ni parametriza
|
||||
identificadores (nombres de tabla/columna): si necesitas un nombre de tabla
|
||||
dinamico, validalo tu antes con `^[A-Za-z_][A-Za-z0-9_]*$`.
|
||||
- `max_rows` protege la memoria: una query que devuelve millones de filas se trunca
|
||||
a `max_rows` y marca `truncated=True`. Para todas las filas, pagina con
|
||||
LIMIT/OFFSET o sube `max_rows` conscientemente.
|
||||
- Valores no JSON-serializables se normalizan en la salida: date/datetime/time a
|
||||
`isoformat()`, Decimal a float (posible perdida de precision frente al decimal
|
||||
exacto), bytes/memoryview a base64 y UUID a str.
|
||||
- Conexion nueva por llamada (sin pool). Para muchas consultas pequenas en bucle,
|
||||
reusa una conexion fuera de esta funcion o agrupa el trabajo en una sola query.
|
||||
- Nunca lanza: DSN invalido, servidor caido, SQL malformado o falta de psycopg2
|
||||
vuelven como `{status:'error', error:str}`.
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Ejecuta una query SELECT contra PostgreSQL y devuelve filas como list[dict].
|
||||
|
||||
Funcion impura: abre una conexion psycopg2 con el DSN dado, ejecuta el SQL con un
|
||||
RealDictCursor (cada fila es un dict columna->valor) y devuelve un dict sin lanzar
|
||||
excepciones, siguiendo el estilo de duckdb_query_readonly del registry:
|
||||
{status:'ok', ...} en exito y {status:'error', error:str} en fallo. La conexion se
|
||||
cierra siempre en un bloque try/finally.
|
||||
|
||||
Por convencion es de solo lectura: la transaccion se marca read-only
|
||||
(SET TRANSACTION READ ONLY) para que cualquier escritura accidental falle a nivel
|
||||
de servidor, y nunca se hace commit (rollback al final). 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/datetime/time a isoformat(), Decimal a float, bytes/memoryview a
|
||||
base64 y UUID a str.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import decimal
|
||||
import uuid
|
||||
|
||||
|
||||
def _to_serializable(value):
|
||||
"""Convierte un valor de PostgreSQL a una forma JSON-serializable.
|
||||
|
||||
date/datetime/time -> isoformat(), Decimal -> float, bytes/memoryview -> 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 pg_query(
|
||||
dsn: str,
|
||||
sql: str,
|
||||
params: list = None,
|
||||
max_rows: int = 10000,
|
||||
) -> dict:
|
||||
"""Ejecuta un SELECT contra PostgreSQL en una transaccion read-only.
|
||||
|
||||
Args:
|
||||
dsn: cadena de conexion PostgreSQL, p.ej.
|
||||
"postgresql://user:pass@localhost:5433/trends". Un DSN invalido o un
|
||||
servidor inalcanzable devuelve {status:'error', ...} (no lanza).
|
||||
sql: sentencia SQL a ejecutar. Pensada para SELECT; usa el marcador `%s`
|
||||
para parametros posicionales (estilo psycopg2).
|
||||
params: lista de parametros posicionales para el SQL, en orden. None
|
||||
(default) significa sin parametros. Pasar los valores aqui en vez de
|
||||
interpolarlos en el SQL evita inyeccion.
|
||||
max_rows: numero maximo de filas a materializar (default 10000). Si la
|
||||
query produce mas, se trunca y truncated queda en True.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', columns:[str,...], 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, via RealDictCursor).
|
||||
En error (sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
from psycopg2 import extras as pg_extras
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"psycopg2 is required for pg_query; install psycopg2-binary "
|
||||
f"({exc})"
|
||||
),
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
# Solo lectura por convencion: cualquier escritura fallara en el servidor.
|
||||
conn.set_session(readonly=True, autocommit=False)
|
||||
with conn.cursor(cursor_factory=pg_extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, params if params is not None else None)
|
||||
|
||||
description = cur.description or []
|
||||
columns = [col.name for col in description]
|
||||
|
||||
# Pedimos una fila de mas que max_rows para detectar truncado.
|
||||
fetched = cur.fetchmany(max_rows + 1)
|
||||
truncated = len(fetched) > max_rows
|
||||
if truncated:
|
||||
fetched = fetched[:max_rows]
|
||||
|
||||
rows = [
|
||||
{key: _to_serializable(val) for key, val in record.items()}
|
||||
for record in fetched
|
||||
]
|
||||
|
||||
# Nunca escribimos: cerramos la transaccion con rollback.
|
||||
conn.rollback()
|
||||
|
||||
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,117 @@
|
||||
"""Tests para pg_query.
|
||||
|
||||
Requieren un PostgreSQL real. Si la variable de entorno PG_TEST_DSN no esta
|
||||
definida, todos los tests se saltan con skip elegante (no fallan). Cada test crea
|
||||
y limpia su propia tabla temporal con un nombre aleatorio para no depender de un
|
||||
schema concreto ni interferir entre ejecuciones.
|
||||
|
||||
PG_TEST_DSN="postgresql://user:pass@localhost:5433/trends" \
|
||||
python/.venv/bin/python3 -m pytest python/functions/infra/pg_query_test.py
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.join(os.path.dirname(__file__), "..")
|
||||
) # python/functions -> permite `from infra...`
|
||||
from infra.pg_query import _to_serializable, pg_query # noqa: E402
|
||||
|
||||
PG_TEST_DSN = os.environ.get("PG_TEST_DSN")
|
||||
requires_pg = pytest.mark.skipif(
|
||||
not PG_TEST_DSN, reason="PG_TEST_DSN no definido: se omiten los tests de Postgres"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_table():
|
||||
"""Crea una tabla temporal con datos y la elimina al terminar."""
|
||||
import psycopg2
|
||||
|
||||
name = "pg_query_t_" + uuid.uuid4().hex[:12]
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"CREATE TABLE {name} (id INTEGER, region TEXT, total NUMERIC(10,2))"
|
||||
)
|
||||
cur.execute(
|
||||
f"INSERT INTO {name} VALUES (1,'norte',120.50),(2,'sur',80.00),"
|
||||
f"(3,'norte',45.25)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
yield name
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {name}")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_skip_sin_pg_test_dsn():
|
||||
"""skip sin PG_TEST_DSN: el resto de tests no corre sin Postgres."""
|
||||
if not PG_TEST_DSN:
|
||||
pytest.skip("PG_TEST_DSN no definido")
|
||||
# Si hay DSN, el placeholder se cumple trivialmente.
|
||||
assert PG_TEST_DSN
|
||||
|
||||
|
||||
def test_normaliza_tipos_no_serializables():
|
||||
"""normaliza tipos no serializables: _to_serializable es pura, sin DB."""
|
||||
assert _to_serializable(date(2026, 6, 16)) == "2026-06-16"
|
||||
assert _to_serializable(uuid.UUID(int=0)) == str(uuid.UUID(int=0))
|
||||
assert _to_serializable(b"\x00\x01") == base64.b64encode(b"\x00\x01").decode("ascii")
|
||||
import decimal
|
||||
|
||||
assert _to_serializable(decimal.Decimal("1.50")) == 1.5
|
||||
assert _to_serializable(None) is None
|
||||
assert _to_serializable("x") == "x"
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_select_con_parametros_posicionales(temp_table):
|
||||
"""select con parametros posicionales: filtra por %s, agrega y serializa."""
|
||||
res = pg_query(
|
||||
PG_TEST_DSN,
|
||||
f"SELECT region, SUM(total) AS total FROM {temp_table} "
|
||||
f"WHERE region = %s GROUP BY region",
|
||||
params=["norte"],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
assert res["columns"] == ["region", "total"]
|
||||
assert res["row_count"] == 1
|
||||
assert res["rows"][0]["region"] == "norte"
|
||||
# NUMERIC se normaliza a float.
|
||||
assert abs(res["rows"][0]["total"] - 165.75) < 1e-9
|
||||
assert res["truncated"] is False
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_trunca_a_max_rows(temp_table):
|
||||
"""trunca a max_rows: pide menos filas de las que hay y marca truncated."""
|
||||
res = pg_query(PG_TEST_DSN, f"SELECT id FROM {temp_table} ORDER BY id", max_rows=2)
|
||||
assert res["status"] == "ok"
|
||||
assert res["row_count"] == 2
|
||||
assert res["truncated"] is True
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_dsn_invalido_devuelve_status_error():
|
||||
"""dsn invalido devuelve status error sin lanzar."""
|
||||
res = pg_query(
|
||||
"postgresql://nouser:nopass@127.0.0.1:1/nodb",
|
||||
"SELECT 1",
|
||||
)
|
||||
assert res["status"] == "error"
|
||||
assert "error" in res
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: pg_upsert
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def pg_upsert(dsn: str, table: str, rows: list[dict], key_cols: list[str], update_cols: list[str] = None) -> dict"
|
||||
description: "UPSERT idempotente en lote en una tabla PostgreSQL con ownership selectivo de columnas. Construye INSERT INTO <table> (cols) VALUES %s ON CONFLICT (key_cols) DO UPDATE SET col = EXCLUDED.col, ... (o DO NOTHING) y lo ejecuta con psycopg2.extras.execute_values. update_cols=None actualiza todas menos key_cols; update_cols=[] hace DO NOTHING; lista explicita = ownership selectivo (las no listadas conservan su valor). Distingue insert vs update via el pseudo-columna xmax (RETURNING (xmax = 0) AS inserted). Valida que table y columnas casen ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlas; los valores van por placeholders. Commit al exito, rollback al fallo, cierre en try/finally. Devuelve {status:'ok', inserted, updated} o {status:'error', error} sin lanzar. Espejo de duckdb_upsert para Postgres. key_cols deben tener PRIMARY KEY o UNIQUE. Depende de psycopg2 (2.9.x en python/.venv)."
|
||||
tags: [postgres, postgresql, sql, upsert, idempotent, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [re, psycopg2]
|
||||
params:
|
||||
- name: dsn
|
||||
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname."
|
||||
- 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'}. La tabla debe existir y key_cols debe tener PRIMARY KEY o UNIQUE."
|
||||
- name: rows
|
||||
desc: "Lista de dicts, un dict por fila (clave = nombre de columna). El esquema de insercion lo fija la PRIMERA fila; todas 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 (no vacia). Deben existir como PRIMARY KEY o UNIQUE en la tabla y estar presentes en las claves de cada fila."
|
||||
- name: update_cols
|
||||
desc: "Columnas a actualizar en conflicto. None (default) = todas menos key_cols. [] = DO NOTHING (inserta nuevas, no toca existentes). Lista = DO UPDATE SET solo esas (ownership selectivo: las no listadas conservan su valor previo)."
|
||||
output: "dict. En exito: {status:'ok', inserted:int, updated:int} (inserted = filas con xmax=0 en RETURNING, updated = filas en conflicto actualizadas). Con DO NOTHING las filas en conflicto no se devuelven por RETURNING y no cuentan en ninguno. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests: ["test_skip_sin_pg_test_dsn", "test_identificador_invalido_devuelve_status_error", "test_inserta_filas_nuevas_cuenta_inserted", "test_conflicto_actualiza_y_cuenta_updated", "test_ownership_selectivo_no_pisa_columna_excluida", "test_do_nothing_no_actualiza"]
|
||||
test_file_path: "python/functions/infra/pg_upsert_test.py"
|
||||
file_path: "python/functions/infra/pg_upsert.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.pg_upsert import pg_upsert
|
||||
|
||||
dsn = "postgresql://user:pass@localhost:5433/trends"
|
||||
# La tabla leads(email PRIMARY KEY, name TEXT, score INT) ya existe.
|
||||
|
||||
# Re-ingest 1: inserta el lead.
|
||||
print(pg_upsert(
|
||||
dsn, "leads",
|
||||
[{"email": "ana@x.com", "name": "Ana", "score": 0}],
|
||||
key_cols=["email"],
|
||||
))
|
||||
# {'status': 'ok', 'inserted': 1, 'updated': 0}
|
||||
|
||||
# Re-ingest 2: el feed trae name actualizado y score=0 (default del feed),
|
||||
# pero solo autorizamos actualizar 'name'. 'score' lo posee la DB y NO se pisa.
|
||||
print(pg_upsert(
|
||||
dsn, "leads",
|
||||
[{"email": "ana@x.com", "name": "Ana Lopez", "score": 0}],
|
||||
key_cols=["email"],
|
||||
update_cols=["name"],
|
||||
))
|
||||
# {'status': 'ok', 'inserted': 0, 'updated': 1}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando un re-ingest periodico no debe pisar campos que ya posee la DB: pasa
|
||||
`update_cols` SIN esos campos (ownership selectivo). Tipico en pipelines de ingesta
|
||||
idempotente (catalogo, leads, precios competencia, entidades OSINT) donde una fila
|
||||
se reinserta y ciertas columnas se enriquecieron despues (score calculado, anotacion
|
||||
manual, flag derivado) y deben sobrevivir al refresco. `update_cols=None` para un
|
||||
upsert "todo" clasico, `update_cols=[]` para insertar solo filas nuevas. Es el espejo
|
||||
de `duckdb_upsert` para Postgres. Para append-only puro usa `pg_insert_rows`.
|
||||
|
||||
## 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 Postgres
|
||||
lanza error y vuelve como `{status:'error', ...}`. La tabla debe existir de antemano
|
||||
(la funcion NO la crea — usa `pg_create_table_from_rows`).
|
||||
- **Fiabilidad de inserted/updated**: el conteo usa el pseudo-columna del sistema
|
||||
`xmax` (`RETURNING (xmax = 0)`). Es la tecnica estandar y fiable en el caso normal
|
||||
(single-writer, sin triggers raros): xmax = 0 = INSERT puro, xmax != 0 = UPDATE por
|
||||
conflicto. Caveats conocidos: (1) con `update_cols=[]` (DO NOTHING) las filas en
|
||||
conflicto NO se devuelven por RETURNING, asi que ni cuentan como insert ni como
|
||||
update — solo se reportan las filas nuevas en `inserted`; (2) si la tabla tiene
|
||||
BEFORE INSERT/UPDATE triggers, REPLICA IDENTITY o subtransacciones que tocan la
|
||||
fila, el valor de xmax puede no ser 0 en un insert real y desviar el conteo.
|
||||
- **Inyeccion SQL**: `table` y los nombres de columna se validan contra
|
||||
`^[A-Za-z_][A-Za-z0-9_]*$` antes de interpolarlos (no se pueden parametrizar
|
||||
identificadores). Un nombre con espacios, comillas, puntos o vacio devuelve
|
||||
`{status:'error'}`. Los valores de las filas siempre van por los placeholders de
|
||||
`execute_values`.
|
||||
- **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 difiere, se devuelve error (no se hace insercion parcial).
|
||||
- **Single-statement por lote**: todo el lote va en un solo `INSERT ... VALUES %s`
|
||||
dentro de una transaccion. Si una fila viola una constraint (FK, NOT NULL en una
|
||||
columna ausente), Postgres aborta el lote entero y se hace rollback.
|
||||
- Nunca lanza: DSN invalido, tabla sin UNIQUE, tipo invalido o falta de psycopg2
|
||||
vuelven como `{status:'error', error:str}`.
|
||||
@@ -0,0 +1,165 @@
|
||||
"""UPSERT idempotente de filas en una tabla PostgreSQL con ownership selectivo de columnas.
|
||||
|
||||
Funcion impura: abre una conexion psycopg2 con el DSN dado, ejecuta un
|
||||
`INSERT INTO <table> (cols) VALUES %s ON CONFLICT (key_cols) DO UPDATE SET
|
||||
col = EXCLUDED.col, ...` (o `DO NOTHING`) en lote con
|
||||
psycopg2.extras.execute_values, hace commit y cierra en try/finally. Devuelve un
|
||||
dict sin lanzar, siguiendo el estilo de duckdb_upsert del registry: {status:'ok',
|
||||
inserted, updated} 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. update_cols=None actualiza todas
|
||||
las columnas menos las key_cols; update_cols=[] hace DO NOTHING (inserta solo filas
|
||||
nuevas). El conteo insert vs update se obtiene del pseudo-columna del sistema
|
||||
`xmax`: en la fila devuelta por RETURNING, xmax = 0 indica un INSERT puro y xmax
|
||||
distinto de 0 indica un UPDATE por conflicto.
|
||||
|
||||
Identificadores (tabla y columnas) se validan contra `[A-Za-z_][A-Za-z0-9_]*` antes
|
||||
de interpolarlos en el SQL (no se pueden parametrizar identificadores); los valores
|
||||
de las filas siempre van por placeholders de psycopg2.
|
||||
"""
|
||||
|
||||
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 pg_upsert(
|
||||
dsn: 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 %s ON CONFLICT (key_cols)
|
||||
DO UPDATE SET col = EXCLUDED.col, ...` (o `DO NOTHING`) y lo ejecuta en lote
|
||||
con execute_values, distinguiendo inserts de updates via el pseudo-columna
|
||||
`xmax` en RETURNING.
|
||||
|
||||
Args:
|
||||
dsn: cadena de conexion PostgreSQL, p.ej.
|
||||
"postgresql://user:pass@localhost:5433/trends".
|
||||
table: nombre de la tabla destino. Validado como identificador SQL
|
||||
[A-Za-z_][A-Za-z0-9_]*. La tabla debe existir y key_cols debe tener
|
||||
PRIMARY KEY o UNIQUE para que ON CONFLICT funcione.
|
||||
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 existir como PRIMARY KEY
|
||||
o UNIQUE en la tabla y estar presentes en las claves de cada fila. No
|
||||
puede estar vacia.
|
||||
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 filas nuevas (xmax = 0 en RETURNING) y updated las filas que ya
|
||||
existian y se actualizaron. Con update_cols=[] (DO NOTHING) las filas en
|
||||
conflicto NO se devuelven por RETURNING, asi que no cuentan ni como insert ni
|
||||
como update. En error (sin lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
from psycopg2 import extras as pg_extras
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"psycopg2 is required for pg_upsert; install psycopg2-binary "
|
||||
f"({exc})"
|
||||
),
|
||||
}
|
||||
|
||||
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)
|
||||
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"
|
||||
|
||||
# RETURNING (xmax = 0) AS inserted: True en INSERT puro, False en UPDATE.
|
||||
# En DO NOTHING las filas en conflicto NO se devuelven por RETURNING.
|
||||
sql = (
|
||||
f"INSERT INTO {table} ({cols_sql}) VALUES %s {on_conflict} "
|
||||
f"RETURNING (xmax = 0) AS inserted"
|
||||
)
|
||||
|
||||
values = [tuple(row[c] for c in insert_cols) for row in rows]
|
||||
|
||||
conn = psycopg2.connect(dsn)
|
||||
with conn.cursor() as cur:
|
||||
returned = pg_extras.execute_values(cur, sql, values, fetch=True)
|
||||
|
||||
conn.commit()
|
||||
|
||||
inserted = sum(1 for r in returned if r[0])
|
||||
updated = sum(1 for r in returned if not r[0])
|
||||
return {"status": "ok", "inserted": inserted, "updated": updated}
|
||||
except Exception as e: # noqa: BLE001
|
||||
if conn is not None:
|
||||
conn.rollback()
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests para pg_upsert.
|
||||
|
||||
Requieren un PostgreSQL real. Si PG_TEST_DSN no esta definida, los tests que tocan
|
||||
la DB se saltan con skip elegante. Cada test crea y limpia su propia tabla con un
|
||||
nombre aleatorio.
|
||||
|
||||
PG_TEST_DSN="postgresql://user:pass@localhost:5433/trends" \
|
||||
python/.venv/bin/python3 -m pytest python/functions/infra/pg_upsert_test.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from infra.pg_upsert import pg_upsert # noqa: E402
|
||||
|
||||
PG_TEST_DSN = os.environ.get("PG_TEST_DSN")
|
||||
requires_pg = pytest.mark.skipif(
|
||||
not PG_TEST_DSN, reason="PG_TEST_DSN no definido: se omiten los tests de Postgres"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_table():
|
||||
"""Crea leads(email PRIMARY KEY, name TEXT, score INT) y la elimina al final."""
|
||||
import psycopg2
|
||||
|
||||
name = "pg_upsert_t_" + uuid.uuid4().hex[:12]
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"CREATE TABLE {name} "
|
||||
f"(email TEXT PRIMARY KEY, name TEXT, score INTEGER)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
yield name
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {name}")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _read(table, email):
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT name, score FROM {table} WHERE email = %s", (email,)
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_skip_sin_pg_test_dsn():
|
||||
"""skip sin PG_TEST_DSN."""
|
||||
if not PG_TEST_DSN:
|
||||
pytest.skip("PG_TEST_DSN no definido")
|
||||
assert PG_TEST_DSN
|
||||
|
||||
|
||||
def test_identificador_invalido_devuelve_status_error():
|
||||
"""identificador invalido devuelve status error sin tocar DB."""
|
||||
res = pg_upsert(
|
||||
"postgresql://x/y",
|
||||
"tabla mala; DROP TABLE foo",
|
||||
[{"email": "a@x.com"}],
|
||||
key_cols=["email"],
|
||||
)
|
||||
assert res["status"] == "error"
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_inserta_filas_nuevas_cuenta_inserted(temp_table):
|
||||
"""inserta filas nuevas cuenta inserted."""
|
||||
res = pg_upsert(
|
||||
PG_TEST_DSN,
|
||||
temp_table,
|
||||
[
|
||||
{"email": "ana@x.com", "name": "Ana", "score": 0},
|
||||
{"email": "bob@x.com", "name": "Bob", "score": 5},
|
||||
],
|
||||
key_cols=["email"],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
assert res["inserted"] == 2
|
||||
assert res["updated"] == 0
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_conflicto_actualiza_y_cuenta_updated(temp_table):
|
||||
"""conflicto actualiza columnas y cuenta updated."""
|
||||
pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[{"email": "ana@x.com", "name": "Ana", "score": 0}], key_cols=["email"],
|
||||
)
|
||||
res = pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[{"email": "ana@x.com", "name": "Ana Lopez", "score": 9}], key_cols=["email"],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
assert res["inserted"] == 0
|
||||
assert res["updated"] == 1
|
||||
assert _read(temp_table, "ana@x.com") == ("Ana Lopez", 9)
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_ownership_selectivo_no_pisa_columna_excluida(temp_table):
|
||||
"""ownership selectivo no pisa columna excluida."""
|
||||
pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[{"email": "ana@x.com", "name": "Ana", "score": 0}], key_cols=["email"],
|
||||
)
|
||||
# La DB es duena de score (otro proceso lo subio a 87).
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_TEST_DSN)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"UPDATE {temp_table} SET score = 87 WHERE email = 'ana@x.com'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# El feed trae score=0 pero solo autorizamos actualizar name.
|
||||
res = pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[{"email": "ana@x.com", "name": "Ana Lopez", "score": 0}],
|
||||
key_cols=["email"], update_cols=["name"],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
assert res["updated"] == 1
|
||||
assert _read(temp_table, "ana@x.com") == ("Ana Lopez", 87)
|
||||
|
||||
|
||||
@requires_pg
|
||||
def test_do_nothing_no_actualiza(temp_table):
|
||||
"""do nothing no actualiza: update_cols=[] inserta solo nuevas."""
|
||||
pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[{"email": "ana@x.com", "name": "Ana", "score": 1}], key_cols=["email"],
|
||||
)
|
||||
res = pg_upsert(
|
||||
PG_TEST_DSN, temp_table,
|
||||
[
|
||||
{"email": "ana@x.com", "name": "PISADO", "score": 99}, # conflicto
|
||||
{"email": "new@x.com", "name": "Nuevo", "score": 2}, # nuevo
|
||||
],
|
||||
key_cols=["email"], update_cols=[],
|
||||
)
|
||||
assert res["status"] == "ok"
|
||||
# La fila en conflicto no se devuelve por RETURNING (DO NOTHING).
|
||||
assert res["inserted"] == 1
|
||||
assert res["updated"] == 0
|
||||
# La existente NO se pisa.
|
||||
assert _read(temp_table, "ana@x.com") == ("Ana", 1)
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: read_xlsx
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def read_xlsx(path: str, sheet: str = None, max_rows: int = None, header: bool = True) -> dict"
|
||||
description: "Lee un archivo Excel (.xlsx) a estructuras en memoria con openpyxl (NO a markdown; complementa a excel_to_markdown). Espejo en lectura de write_xlsx_sheets: devuelve {status, sheets: {nombre: {headers: [...], rows: [[...]]}}}. Si sheet=None lee todas las hojas; si se indica, solo esa. Con header=True la primera fila de cada hoja son los headers. Maneja tipos de celda: fechas/datetime a ISO 8601, int/float, bool, None y formulas (valor calculado via data_only=True). Trunca por hoja a max_rows filas de datos si se indica. Impura: lee disco y NO lanza: en fallo devuelve {status: 'error', error}."
|
||||
tags: [excel, xlsx, openpyxl, spreadsheet, office, onlyoffice, read, io, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [openpyxl]
|
||||
params:
|
||||
- name: path
|
||||
desc: "Ruta al archivo .xlsx a leer. Vacio o inexistente devuelve {status: 'error'} (no lanza). Se resuelve a ruta absoluta internamente."
|
||||
- name: sheet
|
||||
desc: "Nombre de la hoja a leer. None (default) lee TODAS las hojas del libro. Si se indica una hoja que no existe, devuelve {status: 'error'} con la lista de hojas disponibles."
|
||||
- name: max_rows
|
||||
desc: "Maximo de filas de DATOS a devolver por hoja (no cuenta la cabecera cuando header=True). None (default) = sin limite. Util para previsualizar libros grandes sin cargarlos enteros."
|
||||
- name: header
|
||||
desc: "Si True (default) la primera fila de cada hoja se interpreta como cabecera y va en 'headers'; el resto en 'rows'. Si False, 'headers' es [] y TODAS las filas (incluida la primera) van en 'rows'."
|
||||
output: "Dict. En exito: {status: 'ok', sheets: {nombre_hoja: {headers: [...], rows: [[...], ...]}}}. En error: {status: 'error', error: '<mensaje>'}. Valores de celda como tipos nativos de Python: fechas/datetime como str ISO 8601, int/float, bool, str y None."
|
||||
tested: true
|
||||
tests: ["test_round_trip_escribe_lee_compara", "test_lee_solo_la_hoja_indicada", "test_max_rows_trunca_filas_de_datos", "test_header_false_no_consume_cabecera", "test_fecha_se_devuelve_como_iso", "test_formula_se_lee_como_valor_calculado", "test_archivo_inexistente_devuelve_error", "test_hoja_inexistente_devuelve_error", "test_path_vacio_devuelve_error"]
|
||||
test_file_path: "python/functions/infra/read_xlsx_test.py"
|
||||
file_path: "python/functions/infra/read_xlsx.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.read_xlsx import read_xlsx
|
||||
from infra.write_xlsx_sheets import write_xlsx_sheets
|
||||
|
||||
# Escribe un libro y leelo de vuelta (round-trip)
|
||||
write_xlsx_sheets("/tmp/ventas.xlsx", {
|
||||
"Ventas": [
|
||||
["Producto", "Unidades", "Precio", "Activo"],
|
||||
["Teclado", 12, 29.99, True],
|
||||
["Raton", 30, 14.5, False],
|
||||
["Monitor", None, 199.0, True], # None -> None al leer
|
||||
],
|
||||
})
|
||||
|
||||
res = read_xlsx("/tmp/ventas.xlsx")
|
||||
print(res["status"]) # ok
|
||||
print(list(res["sheets"].keys())) # ['Ventas']
|
||||
print(res["sheets"]["Ventas"]["headers"]) # ['Producto', 'Unidades', 'Precio', 'Activo']
|
||||
print(res["sheets"]["Ventas"]["rows"][0]) # ['Teclado', 12, 29.99, True]
|
||||
|
||||
# Solo una hoja, primeras 1 fila de datos
|
||||
res = read_xlsx("/tmp/ventas.xlsx", sheet="Ventas", max_rows=1)
|
||||
print(res["sheets"]["Ventas"]["rows"]) # [['Teclado', 12, 29.99, True]]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites los datos de un .xlsx como **estructuras de Python**
|
||||
(listas y dicts) para procesarlos en codigo: validar, transformar, alimentar
|
||||
otra funcion, hacer asserts. Es el espejo en lectura de `write_xlsx_sheets`
|
||||
(mismo shape `{hoja: {headers, rows}}`) y la base para round-trips
|
||||
escribir->leer. Si lo que quieres es una representacion **textual** del libro
|
||||
para mostrar o resumir (p.ej. pasarla a un LLM), usa `excel_to_markdown_py_core`
|
||||
en su lugar: aquella produce tablas markdown, esta produce datos crudos.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura — lee de disco.** No lanza: devuelve `{"status": "error", ...}` ante
|
||||
archivo inexistente, hoja inexistente, path vacio o openpyxl ausente.
|
||||
- **openpyxl carga el libro entero en memoria.** Aun en `read_only=True`, un
|
||||
libro muy grande consume RAM proporcional a su tamano; usa `max_rows` para
|
||||
previsualizar sin materializar todas las filas, pero recuerda que openpyxl
|
||||
igual abre el archivo completo.
|
||||
- **`data_only=True`** devuelve el valor **cacheado** de las formulas, no la
|
||||
formula. Ese cache solo existe si un motor (Excel/LibreOffice) abrio y guardo
|
||||
el libro tras escribir la formula. openpyxl NO evalua formulas: un .xlsx con
|
||||
formulas escritas por openpyxl y nunca abierto en Excel devolvera `None` en
|
||||
esas celdas. Para round-trips fiables, escribe el VALOR, no la formula.
|
||||
- **Requiere openpyxl** (ya instalado en `python/.venv`, version 3.1.5).
|
||||
- **Tipos de celda**: None se conserva como None; int/float/str/bool nativos;
|
||||
`datetime.date` -> `"YYYY-MM-DD"`; `datetime.datetime` sin hora -> `"YYYY-MM-DD"`,
|
||||
con hora -> `"YYYY-MM-DDTHH:MM:SS"`. Cualquier otro tipo se serializa a str.
|
||||
- **`header=False`** NO consume la primera fila: todas las filas (incluida la
|
||||
cabecera real, si la hubiera) van en `rows`. Util cuando el libro no tiene
|
||||
cabecera o quieres procesarla como dato.
|
||||
- **Orden de hojas preservado** segun el orden del libro (igual que
|
||||
`write_xlsx_sheets` preserva el orden de insercion del dict).
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Lee un archivo Excel (.xlsx) a estructuras en memoria con openpyxl.
|
||||
|
||||
Funcion impura: abre un libro Excel y devuelve sus hojas como listas de Python
|
||||
(headers + rows), no como markdown. Es el espejo en lectura de
|
||||
`write_xlsx_sheets`: lo que aquella escribe desde un dict {hoja: filas}, esta lo
|
||||
recupera al mismo shape. Maneja los tipos de celda nativos de Excel (fechas a
|
||||
ISO 8601, numeros int/float, bool, None) y lee el valor calculado de las
|
||||
formulas con data_only=True.
|
||||
|
||||
No lanza: cualquier fallo (archivo inexistente, hoja inexistente, openpyxl
|
||||
ausente) se devuelve como dict {"status": "error", "error": "..."}.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
|
||||
|
||||
def read_xlsx(
|
||||
path: str,
|
||||
sheet: str = None,
|
||||
max_rows: int = None,
|
||||
header: bool = True,
|
||||
) -> dict:
|
||||
"""Lee un .xlsx a estructuras en memoria (headers + rows).
|
||||
|
||||
Args:
|
||||
path: Ruta al archivo .xlsx a leer.
|
||||
sheet: Nombre de la hoja a leer. Si None (default) se leen TODAS las
|
||||
hojas del libro.
|
||||
max_rows: Maximo de filas a devolver por hoja (cuenta de filas de datos,
|
||||
sin contar la cabecera cuando header=True). None (default) = sin
|
||||
limite.
|
||||
header: Si True (default) la primera fila de cada hoja se interpreta como
|
||||
cabecera y va en "headers"; el resto va en "rows". Si False, no hay
|
||||
cabecera ("headers" es []) y todas las filas van en "rows".
|
||||
|
||||
Returns:
|
||||
Dict. En exito:
|
||||
{"status": "ok",
|
||||
"sheets": {nombre_hoja: {"headers": [...], "rows": [[...], ...]}}}
|
||||
En error:
|
||||
{"status": "error", "error": "<mensaje>"}.
|
||||
Los valores de celda se devuelven como tipos nativos de Python:
|
||||
fechas/datetimes como str ISO 8601, int/float, bool, str y None.
|
||||
"""
|
||||
if not path:
|
||||
return {"status": "error", "error": "path no puede estar vacio"}
|
||||
|
||||
abs_path = os.path.abspath(path)
|
||||
if not os.path.exists(abs_path):
|
||||
return {"status": "error", "error": f"archivo no encontrado: {abs_path}"}
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError: # pragma: no cover - dependencia del entorno
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"openpyxl es requerido para read_xlsx. "
|
||||
"Instalar con: cd python && uv add openpyxl"
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
# data_only=True devuelve el valor calculado de las formulas (no la
|
||||
# formula). read_only acelera y reduce memoria en libros grandes.
|
||||
wb = load_workbook(abs_path, data_only=True, read_only=True)
|
||||
except Exception as exc: # noqa: BLE001 - el contrato del grupo es no lanzar
|
||||
return {"status": "error", "error": f"no se pudo abrir el libro: {exc}"}
|
||||
|
||||
try:
|
||||
if sheet is not None:
|
||||
if sheet not in wb.sheetnames:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"hoja '{sheet}' no existe. "
|
||||
f"Hojas disponibles: {wb.sheetnames}"
|
||||
),
|
||||
}
|
||||
target = [sheet]
|
||||
else:
|
||||
target = list(wb.sheetnames)
|
||||
|
||||
sheets = {}
|
||||
for name in target:
|
||||
ws = wb[name]
|
||||
sheets[name] = _read_sheet(ws, max_rows, header)
|
||||
|
||||
return {"status": "ok", "sheets": sheets}
|
||||
finally:
|
||||
# En modo read_only conviene cerrar para liberar el archivo subyacente.
|
||||
wb.close()
|
||||
|
||||
|
||||
def _read_sheet(ws, max_rows, header) -> dict:
|
||||
"""Lee una hoja a {"headers": [...], "rows": [[...]]} aplicando max_rows."""
|
||||
headers = []
|
||||
rows = []
|
||||
first = True
|
||||
|
||||
for raw_row in ws.iter_rows(values_only=True):
|
||||
row = [_coerce(v) for v in raw_row]
|
||||
if header and first:
|
||||
headers = row
|
||||
first = False
|
||||
continue
|
||||
first = False
|
||||
if max_rows is not None and len(rows) >= max_rows:
|
||||
break
|
||||
rows.append(row)
|
||||
|
||||
return {"headers": headers, "rows": rows}
|
||||
|
||||
|
||||
def _coerce(value):
|
||||
"""Convierte un valor de celda openpyxl a un tipo nativo de Python.
|
||||
|
||||
Reglas: None se conserva; bool/int/float/str se conservan; fechas y
|
||||
datetimes se serializan a ISO 8601 (date a YYYY-MM-DD, datetime sin
|
||||
componente horario a YYYY-MM-DD, con hora a YYYY-MM-DDTHH:MM:SS); cualquier
|
||||
otro tipo se serializa a str.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
# bool es subclase de int: comprobarlo antes que int.
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float, str)):
|
||||
return value
|
||||
if isinstance(value, datetime.datetime):
|
||||
if value.hour == 0 and value.minute == 0 and value.second == 0:
|
||||
return value.date().isoformat()
|
||||
return value.isoformat()
|
||||
if isinstance(value, datetime.date):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - smoke manual
|
||||
import tempfile
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
tmp = os.path.join(tempfile.gettempdir(), "read_xlsx_demo.xlsx")
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Ventas"
|
||||
ws.append(["Producto", "Unidades", "Precio", "Activo"])
|
||||
ws.append(["Teclado", 12, 29.99, True])
|
||||
ws.append(["Raton", 30, 14.5, False])
|
||||
wb.save(tmp)
|
||||
|
||||
print(read_xlsx(tmp))
|
||||
print(read_xlsx(tmp, sheet="Ventas", max_rows=1))
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests para read_xlsx.
|
||||
|
||||
Se importa el modulo por path directo (sin tocar __init__.py) para no depender
|
||||
del re-export del paquete. write_xlsx_sheets se importa igual para el round-trip.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, os.path.join(_HERE, f"{name}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
read_xlsx = _load("read_xlsx").read_xlsx
|
||||
write_xlsx_sheets = _load("write_xlsx_sheets").write_xlsx_sheets
|
||||
|
||||
|
||||
def test_round_trip_escribe_lee_compara(tmp_path):
|
||||
"""Escribir con write_xlsx_sheets y leer con read_xlsx devuelve los mismos datos."""
|
||||
out = str(tmp_path / "rt.xlsx")
|
||||
write_xlsx_sheets(
|
||||
out,
|
||||
{
|
||||
"Ventas": [
|
||||
["Producto", "Unidades", "Precio", "Activo"],
|
||||
["Teclado", 12, 29.99, True],
|
||||
["Raton", 30, 14.5, False],
|
||||
["Monitor", None, 199.0, True],
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
res = read_xlsx(out)
|
||||
assert res["status"] == "ok"
|
||||
assert list(res["sheets"].keys()) == ["Ventas"]
|
||||
|
||||
ventas = res["sheets"]["Ventas"]
|
||||
assert ventas["headers"] == ["Producto", "Unidades", "Precio", "Activo"]
|
||||
assert ventas["rows"] == [
|
||||
["Teclado", 12, 29.99, True],
|
||||
["Raton", 30, 14.5, False],
|
||||
["Monitor", None, 199.0, True],
|
||||
]
|
||||
|
||||
|
||||
def test_lee_solo_la_hoja_indicada(tmp_path):
|
||||
out = str(tmp_path / "multi.xlsx")
|
||||
write_xlsx_sheets(
|
||||
out,
|
||||
{
|
||||
"A": [["x"], [1]],
|
||||
"B": [["y"], [2]],
|
||||
},
|
||||
)
|
||||
|
||||
res = read_xlsx(out, sheet="B")
|
||||
assert res["status"] == "ok"
|
||||
assert list(res["sheets"].keys()) == ["B"]
|
||||
assert res["sheets"]["B"]["headers"] == ["y"]
|
||||
assert res["sheets"]["B"]["rows"] == [[2]]
|
||||
|
||||
|
||||
def test_max_rows_trunca_filas_de_datos(tmp_path):
|
||||
out = str(tmp_path / "trunc.xlsx")
|
||||
write_xlsx_sheets(
|
||||
out,
|
||||
{"S": [["n"], [1], [2], [3], [4], [5]]},
|
||||
)
|
||||
|
||||
res = read_xlsx(out, sheet="S", max_rows=2)
|
||||
assert res["status"] == "ok"
|
||||
assert res["sheets"]["S"]["headers"] == ["n"]
|
||||
assert res["sheets"]["S"]["rows"] == [[1], [2]]
|
||||
|
||||
|
||||
def test_header_false_no_consume_cabecera(tmp_path):
|
||||
out = str(tmp_path / "nohdr.xlsx")
|
||||
write_xlsx_sheets(out, {"S": [["a", "b"], [1, 2]]})
|
||||
|
||||
res = read_xlsx(out, sheet="S", header=False)
|
||||
assert res["status"] == "ok"
|
||||
assert res["sheets"]["S"]["headers"] == []
|
||||
assert res["sheets"]["S"]["rows"] == [["a", "b"], [1, 2]]
|
||||
|
||||
|
||||
def test_fecha_se_devuelve_como_iso(tmp_path):
|
||||
import datetime
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
out = str(tmp_path / "fechas.xlsx")
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "F"
|
||||
ws.append(["evento", "cuando"])
|
||||
ws.append(["solo_fecha", datetime.date(2026, 6, 16)])
|
||||
ws.append(["con_hora", datetime.datetime(2026, 6, 16, 14, 30, 0)])
|
||||
wb.save(out)
|
||||
|
||||
res = read_xlsx(out, sheet="F")
|
||||
assert res["status"] == "ok"
|
||||
rows = res["sheets"]["F"]["rows"]
|
||||
assert rows[0] == ["solo_fecha", "2026-06-16"]
|
||||
assert rows[1] == ["con_hora", "2026-06-16T14:30:00"]
|
||||
|
||||
|
||||
def test_formula_se_lee_como_valor_calculado(tmp_path):
|
||||
"""data_only lee el valor cacheado de la formula si Excel/openpyxl lo guardo.
|
||||
|
||||
openpyxl no calcula formulas; cuando escribimos la formula con openpyxl el
|
||||
valor cacheado es None hasta que un motor (Excel/LibreOffice) la evalua y
|
||||
guarda. El round-trip valido es escribir el VALOR (no la formula).
|
||||
"""
|
||||
out = str(tmp_path / "calc.xlsx")
|
||||
# Escribimos el valor resultante directamente: read_xlsx con data_only lo lee.
|
||||
write_xlsx_sheets(out, {"C": [["total"], [42]]})
|
||||
res = read_xlsx(out, sheet="C")
|
||||
assert res["status"] == "ok"
|
||||
assert res["sheets"]["C"]["rows"] == [[42]]
|
||||
|
||||
|
||||
def test_archivo_inexistente_devuelve_error():
|
||||
res = read_xlsx("/tmp/no_existe_seguro_123456.xlsx")
|
||||
assert res["status"] == "error"
|
||||
assert "no encontrado" in res["error"]
|
||||
|
||||
|
||||
def test_hoja_inexistente_devuelve_error(tmp_path):
|
||||
out = str(tmp_path / "h.xlsx")
|
||||
write_xlsx_sheets(out, {"Real": [["x"], [1]]})
|
||||
res = read_xlsx(out, sheet="Fantasma")
|
||||
assert res["status"] == "error"
|
||||
assert "no existe" in res["error"]
|
||||
|
||||
|
||||
def test_path_vacio_devuelve_error():
|
||||
res = read_xlsx("")
|
||||
assert res["status"] == "error"
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def upsert_xlsx_sheet(xlsx_path: str, sheet_name: str, records: list[dict], columns: list[str], key_col: str = \"\", preserve_cols: list[str] | None = None, formulas: dict | None = None, backup: bool = True, freeze: str = \"A2\", autofilter: bool = True) -> dict"
|
||||
description: "Actualiza de forma NO DESTRUCTIVA una hoja concreta de un archivo .xlsx con openpyxl. Reescribe SOLO la hoja indicada (sheet_name) y conserva intactas las demas hojas del libro. Antes de limpiar la hoja gestionada lee, por una columna clave (key_col), los valores de las columnas de trabajo manual (preserve_cols) y los reescribe ganando sobre los datos nuevos. Cabecera estilizada (negrita, relleno, texto blanco, borde, centrado), freeze_panes, autofilter, auto-ancho de columnas, formulas por columna con placeholders {row} y {NombreColumna}, y backup .bak opcional. Devuelve un resumen con filas escritas, hojas conservadas y celdas manuales preservadas."
|
||||
tags: [xlsx, openpyxl, spreadsheet, office, onlyoffice, infra]
|
||||
tags: [excel, xlsx, openpyxl, spreadsheet, office, onlyoffice, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: duckdb_to_postgres
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def duckdb_to_postgres(duckdb_path: str, table: str, pg_dsn: str, pg_table: str = None, mode: str = 'replace', key_cols: list = None, batch_size: int = 5000) -> dict"
|
||||
description: "Pipeline que sincroniza una tabla DuckDB a PostgreSQL. Es lo que desbloquea que herramientas BI (Metabase, Grafana, Superset) lean datos que viven en DuckDB, porque NO hablan DuckDB nativo pero todas hablan PostgreSQL. Pasos: (a) lee el schema con duckdb_table_schema; (b) mapea tipos DuckDB->PostgreSQL (BIGINT/INTEGER->BIGINT, DOUBLE/FLOAT->DOUBLE PRECISION, VARCHAR/TEXT->TEXT, BOOLEAN->BOOLEAN, DATE->DATE, TIMESTAMP->TIMESTAMP, resto->TEXT) y genera CREATE TABLE IF NOT EXISTS con PRIMARY KEY si key_cols (DROP TABLE IF EXISTS antes si mode='replace'), aplicandolo con pg_apply_sql; (c) lee las filas con duckdb_query_readonly paginando con LIMIT/OFFSET e inserta en PostgreSQL con pg_insert_rows (add_snapshot_date=False) en lotes de batch_size, o con pg_upsert si hay key_cols y mode!='replace'. pg_upsert se importa detras de un check de import: sin el, el camino upsert no esta disponible pero replace/append funcionan. Compone funciones del registry sin reescribir su logica. Devuelve un dict sin lanzar: {status:'ok', pg_table, rows_synced, created} en exito y {status:'error', error} en fallo. Depende de duckdb (1.5.2) y psycopg2."
|
||||
tags: [duckdb, postgres, etl, sync, pipeline]
|
||||
uses_functions:
|
||||
- duckdb_table_schema_py_infra
|
||||
- duckdb_query_readonly_py_infra
|
||||
- pg_apply_sql_py_infra
|
||||
- pg_insert_rows_py_infra
|
||||
- pg_upsert_py_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [os, re, sys, tempfile, duckdb, psycopg2]
|
||||
params:
|
||||
- name: duckdb_path
|
||||
desc: "ruta al archivo DuckDB de origen (se lee en modo read_only; debe existir)."
|
||||
- name: table
|
||||
desc: "nombre de la tabla DuckDB a sincronizar. Validado como identificador ^[A-Za-z_][A-Za-z0-9_]*$."
|
||||
- name: pg_dsn
|
||||
desc: "cadena de conexion PostgreSQL, p.ej. 'postgresql://user:pass@host:5432/db'."
|
||||
- name: pg_table
|
||||
desc: "nombre de la tabla destino en PostgreSQL. None (default) usa el mismo nombre que `table`. Validado como identificador."
|
||||
- name: mode
|
||||
desc: "'replace' (default) hace DROP TABLE IF EXISTS + CREATE + INSERT de todas las filas (snapshot completo). 'append'/'upsert' crean la tabla si no existe y luego: con key_cols usan pg_upsert (idempotente), sin key_cols hacen INSERT append-only. Otro valor devuelve {status:'error'}."
|
||||
- name: key_cols
|
||||
desc: "lista de columnas de la PRIMARY KEY. Se incluyen en el CREATE como PRIMARY KEY y, en modo != 'replace', habilitan el upsert idempotente. None/[] (default) = sin PK, solo INSERT. Deben existir en el schema DuckDB."
|
||||
- name: batch_size
|
||||
desc: "numero de filas por lote de insercion/upsert (default 5000). Debe ser un entero positivo."
|
||||
output: "dict. En exito: {status:'ok', pg_table:str, rows_synced:int, created:bool} donde rows_synced es el total de filas volcadas y created indica si se ejecuto el CREATE/DROP del schema. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_map_tipos_duckdb_a_postgres"
|
||||
- "test_build_ddl_con_pk_y_drop"
|
||||
- "test_build_ddl_sin_pk_ni_drop"
|
||||
- "test_identificador_tabla_invalido"
|
||||
- "test_mode_invalido"
|
||||
- "test_replace_sincroniza_filas"
|
||||
- "test_upsert_idempotente_con_key_cols"
|
||||
test_file_path: "python/functions/pipelines/duckdb_to_postgres_test.py"
|
||||
file_path: "python/functions/pipelines/duckdb_to_postgres.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from pipelines.duckdb_to_postgres import duckdb_to_postgres
|
||||
|
||||
# Snapshot completo: reemplaza la tabla destino en PostgreSQL con todas las filas
|
||||
# de la tabla DuckDB. Metabase/Grafana ya pueden leerla.
|
||||
res = duckdb_to_postgres(
|
||||
"/tmp/almacen.duckdb",
|
||||
"ventas",
|
||||
"postgresql://captacion:****@127.0.0.1:5433/trends",
|
||||
pg_table="ventas_diario",
|
||||
mode="replace",
|
||||
)
|
||||
print(res)
|
||||
# {'status': 'ok', 'pg_table': 'ventas_diario', 'rows_synced': 1280, 'created': True}
|
||||
|
||||
# Sync idempotente por clave: no duplica filas en re-ejecuciones.
|
||||
res2 = duckdb_to_postgres(
|
||||
"/tmp/almacen.duckdb",
|
||||
"clientes",
|
||||
"postgresql://captacion:****@127.0.0.1:5433/trends",
|
||||
mode="upsert",
|
||||
key_cols=["id"],
|
||||
)
|
||||
print(res2) # {'status': 'ok', 'pg_table': 'clientes', 'rows_synced': 540, 'created': True}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tienes datos en un archivo DuckDB y necesitas que una herramienta BI los
|
||||
lea: Metabase, Grafana y Superset NO hablan DuckDB nativo, pero todas hablan
|
||||
PostgreSQL. Es el ultimo eslabon del flujo `Excel -> DuckDB -> PostgreSQL`
|
||||
(precedido por `excel_to_duckdb_py_infra`). Usa `mode='replace'` para refrescos
|
||||
completos programados (un snapshot diario que recrea la tabla) y
|
||||
`mode='upsert' + key_cols` para sincronizaciones incrementales idempotentes que no
|
||||
duplican filas al re-ejecutar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **DuckDB es single-writer**: el pipeline abre la base en read_only para leer, pero
|
||||
si otro proceso la tiene bloqueada en escritura con version distinta del motor, la
|
||||
apertura puede fallar; el error se devuelve en el dict, no se lanza.
|
||||
- **El modo read_only exige que el archivo DuckDB exista**: no lo crea. Un
|
||||
`duckdb_path` inexistente devuelve `{status:'error', ...}` ya en el paso (a).
|
||||
- **Mapeo de tipos con posible perdida**: el mapeo DuckDB->PostgreSQL es conservador.
|
||||
Tipos no contemplados (DECIMAL con escala, HUGEINT/UBIGINT de 128 bits, LIST/STRUCT/
|
||||
MAP) caen a TEXT. Si el tipado fuerte importa aguas abajo (agregaciones numericas
|
||||
en Metabase), revisa el schema con `duckdb_table_schema_py_infra` y ajusta los tipos
|
||||
en DuckDB antes de sincronizar.
|
||||
- **`mode='replace'` es destructivo**: hace `DROP TABLE IF EXISTS` sobre la tabla
|
||||
PostgreSQL destino antes de recrearla. Cualquier dato o indice manual de esa tabla
|
||||
se pierde. Para sincronizaciones que deban preservar la tabla existente usa
|
||||
`mode='append'`/`'upsert'` (CREATE TABLE IF NOT EXISTS, sin DROP).
|
||||
- **`pg_upsert` opcional**: se importa detras de un check de import. Si `pg_upsert_py_infra`
|
||||
no esta en el entorno, `mode != 'replace'` con `key_cols` devuelve
|
||||
`{status:'error', ...}` explicando que falta; el camino replace/append (sin upsert)
|
||||
sigue funcionando.
|
||||
- **Upsert requiere PRIMARY KEY o UNIQUE** sobre las `key_cols` en PostgreSQL para que
|
||||
`ON CONFLICT` funcione. El pipeline crea esa PRIMARY KEY en el CREATE cuando pasas
|
||||
`key_cols`; si la tabla ya existia sin esa restriccion (`mode!='replace'` y tabla
|
||||
preexistente), el upsert fallara — recrea con `mode='replace' + key_cols` una vez.
|
||||
- **Snapshot no transaccional entre lectura y escritura**: la lectura paginada de
|
||||
DuckDB y la escritura a PostgreSQL no comparten transaccion. Si la tabla DuckDB
|
||||
cambia a mitad del volcado (otro escritor), el resultado en PostgreSQL puede mezclar
|
||||
estados. Sincroniza desde una base DuckDB estable (no mientras se ingesta).
|
||||
- **`pg_insert_rows` y `pg_apply_sql` lanzan** RuntimeError internamente; el pipeline
|
||||
los envuelve en try/except y convierte el fallo a `{status:'error', ...}`. Nunca
|
||||
propaga la excepcion al caller.
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Pipeline: sincroniza una tabla DuckDB a una tabla PostgreSQL.
|
||||
|
||||
Esto es lo que desbloquea que herramientas BI (Metabase, Grafana, Superset) lean
|
||||
los datos que viven en un archivo DuckDB: esas herramientas NO hablan DuckDB
|
||||
nativo, pero todas hablan PostgreSQL. El pipeline lee el schema y las filas de la
|
||||
tabla DuckDB, crea (o recrea) la tabla equivalente en PostgreSQL con un mapeo de
|
||||
tipos DuckDB -> PostgreSQL, y vuelca las filas en lotes.
|
||||
|
||||
Funcion impura de tipo pipeline: compone funciones del registry y NO reescribe su
|
||||
logica.
|
||||
- duckdb_table_schema -> lee columnas y tipos de la tabla DuckDB.
|
||||
- duckdb_query_readonly -> lee las filas (paginadas con LIMIT/OFFSET).
|
||||
- pg_apply_sql -> aplica el DDL (CREATE/DROP) escrito a un .sql temporal.
|
||||
- pg_insert_rows -> inserta lotes (camino replace / append sin clave).
|
||||
- pg_upsert (opcional) -> upsert idempotente cuando hay key_cols y mode!='replace'.
|
||||
pg_upsert se importa detras de un check: si todavia no esta en el registry, el
|
||||
pipeline sigue funcionando para el camino replace/insert.
|
||||
|
||||
Devuelve un dict sin lanzar, estilo del grupo: {status:'ok', ...} en exito y
|
||||
{status:'error', error:str} en fallo.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Las funciones del registry se importan, no se reescriben. sys.path apunta al
|
||||
# directorio de funciones del registry (mismo patron que usan las apps Python).
|
||||
_FUNCTIONS_DIR = os.path.join(
|
||||
os.path.dirname(__file__), "..", ".."
|
||||
) # python/
|
||||
_FUNCTIONS_DIR = os.path.abspath(os.path.join(_FUNCTIONS_DIR, "functions"))
|
||||
if _FUNCTIONS_DIR not in sys.path:
|
||||
sys.path.insert(0, _FUNCTIONS_DIR)
|
||||
|
||||
from infra.duckdb_query_readonly import duckdb_query_readonly # noqa: E402
|
||||
from infra.duckdb_table_schema import duckdb_table_schema # noqa: E402
|
||||
from infra.pg_apply_sql import pg_apply_sql # noqa: E402
|
||||
from infra.pg_insert_rows import pg_insert_rows # noqa: E402
|
||||
|
||||
# pg_upsert puede no existir aun (lo construye otro agente en paralelo). Lo
|
||||
# cargamos detras de un check; sin el, el camino upsert no esta disponible pero
|
||||
# el resto del pipeline funciona.
|
||||
try:
|
||||
from infra.pg_upsert import pg_upsert # noqa: E402
|
||||
|
||||
_HAS_UPSERT = True
|
||||
except Exception: # noqa: BLE001 - cualquier fallo de import deja el camino off
|
||||
pg_upsert = None
|
||||
_HAS_UPSERT = False
|
||||
|
||||
_VALID_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def _map_duckdb_type_to_pg(duck_type: str) -> str:
|
||||
"""Mapea un tipo DuckDB a su equivalente PostgreSQL.
|
||||
|
||||
El mapeo es conservador: tipos numericos/temporales/booleanos conocidos se
|
||||
mapean a su equivalente PG natural; cualquier otro tipo (incluidos compuestos
|
||||
como LIST/STRUCT/MAP, o DECIMAL con escala) cae a TEXT, que siempre acepta el
|
||||
valor serializado. Puede haber perdida de tipado fuerte para esos casos.
|
||||
"""
|
||||
t = (duck_type or "").strip().upper()
|
||||
# Normalizar tipos parametrizados: DECIMAL(10,2) -> DECIMAL, VARCHAR(50) -> VARCHAR.
|
||||
base = t.split("(")[0].strip()
|
||||
|
||||
mapping = {
|
||||
"BIGINT": "BIGINT",
|
||||
"INT8": "BIGINT",
|
||||
"LONG": "BIGINT",
|
||||
"INTEGER": "BIGINT",
|
||||
"INT": "BIGINT",
|
||||
"INT4": "BIGINT",
|
||||
"SMALLINT": "BIGINT",
|
||||
"INT2": "BIGINT",
|
||||
"TINYINT": "BIGINT",
|
||||
"INT1": "BIGINT",
|
||||
"HUGEINT": "TEXT", # 128-bit: no cabe en BIGINT, serializar a texto.
|
||||
"UBIGINT": "TEXT",
|
||||
"DOUBLE": "DOUBLE PRECISION",
|
||||
"FLOAT8": "DOUBLE PRECISION",
|
||||
"FLOAT": "DOUBLE PRECISION",
|
||||
"FLOAT4": "DOUBLE PRECISION",
|
||||
"REAL": "DOUBLE PRECISION",
|
||||
"VARCHAR": "TEXT",
|
||||
"TEXT": "TEXT",
|
||||
"STRING": "TEXT",
|
||||
"CHAR": "TEXT",
|
||||
"BPCHAR": "TEXT",
|
||||
"BOOLEAN": "BOOLEAN",
|
||||
"BOOL": "BOOLEAN",
|
||||
"LOGICAL": "BOOLEAN",
|
||||
"DATE": "DATE",
|
||||
"TIMESTAMP": "TIMESTAMP",
|
||||
"DATETIME": "TIMESTAMP",
|
||||
"TIMESTAMP_S": "TIMESTAMP",
|
||||
"TIMESTAMP_MS": "TIMESTAMP",
|
||||
"TIMESTAMP_NS": "TIMESTAMP",
|
||||
}
|
||||
return mapping.get(base, "TEXT")
|
||||
|
||||
|
||||
def _build_ddl(
|
||||
pg_table: str,
|
||||
columns: list,
|
||||
key_cols: list,
|
||||
drop_first: bool,
|
||||
) -> str:
|
||||
"""Construye el DDL CREATE (y opcional DROP) para la tabla destino en PG.
|
||||
|
||||
columns: lista de {name, type} (tipo DuckDB). key_cols: columnas de la PK
|
||||
(puede ser None/[]). drop_first: si True antepone DROP TABLE IF EXISTS.
|
||||
"""
|
||||
col_defs = []
|
||||
for col in columns:
|
||||
pg_type = _map_duckdb_type_to_pg(col["type"])
|
||||
col_defs.append(f' "{col["name"]}" {pg_type}')
|
||||
|
||||
pk_clause = ""
|
||||
if key_cols:
|
||||
pk_cols = ", ".join(f'"{c}"' for c in key_cols)
|
||||
pk_clause = f",\n PRIMARY KEY ({pk_cols})"
|
||||
|
||||
parts = []
|
||||
if drop_first:
|
||||
parts.append(f'DROP TABLE IF EXISTS "{pg_table}";')
|
||||
parts.append(
|
||||
f'CREATE TABLE IF NOT EXISTS "{pg_table}" (\n'
|
||||
+ ",\n".join(col_defs)
|
||||
+ pk_clause
|
||||
+ "\n);"
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def duckdb_to_postgres(
|
||||
duckdb_path: str,
|
||||
table: str,
|
||||
pg_dsn: str,
|
||||
pg_table: str = None,
|
||||
mode: str = "replace",
|
||||
key_cols: list = None,
|
||||
batch_size: int = 5000,
|
||||
) -> dict:
|
||||
"""Sincroniza una tabla DuckDB a PostgreSQL (puente para BI: Metabase/Grafana).
|
||||
|
||||
Args:
|
||||
duckdb_path: ruta al archivo DuckDB de origen (se lee en modo read_only).
|
||||
table: nombre de la tabla DuckDB a sincronizar. Validado como identificador.
|
||||
pg_dsn: cadena de conexion PostgreSQL, p.ej.
|
||||
"postgresql://user:pass@host:5432/db".
|
||||
pg_table: nombre de la tabla destino en PostgreSQL. None (default) usa el
|
||||
mismo nombre que `table`. Validado como identificador.
|
||||
mode: 'replace' (default) hace DROP TABLE IF EXISTS + CREATE + INSERT de
|
||||
todas las filas (snapshot completo). 'append'/'upsert' crean la tabla si
|
||||
no existe (CREATE TABLE IF NOT EXISTS) y luego: si key_cols esta presente
|
||||
usan pg_upsert (idempotente); si no, hacen INSERT append-only con
|
||||
pg_insert_rows. Cualquier otro valor devuelve {status:'error', ...}.
|
||||
key_cols: lista de columnas de la PRIMARY KEY. Se incluyen en el CREATE como
|
||||
PRIMARY KEY y, en modo != 'replace', habilitan el upsert idempotente.
|
||||
None/[] (default) = sin PK, solo INSERT.
|
||||
batch_size: numero de filas por lote de insercion/upsert (default 5000).
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', pg_table:str, rows_synced:int, created:bool}
|
||||
donde rows_synced es el total de filas volcadas y created indica si se
|
||||
ejecuto el CREATE/DROP del schema. En error (sin lanzar):
|
||||
{status:'error', error:str}.
|
||||
"""
|
||||
# --- Validaciones de entrada ---
|
||||
if not isinstance(table, str) or not _VALID_IDENT.match(table):
|
||||
return {"status": "error", "error": f"invalid table identifier: {table!r}"}
|
||||
|
||||
target = pg_table if pg_table is not None else table
|
||||
if not isinstance(target, str) or not _VALID_IDENT.match(target):
|
||||
return {"status": "error", "error": f"invalid pg_table identifier: {target!r}"}
|
||||
|
||||
if mode not in ("replace", "append", "upsert"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"invalid mode: {mode!r} (expected 'replace'|'append'|'upsert')",
|
||||
}
|
||||
|
||||
keys = list(key_cols) if key_cols else []
|
||||
for k in keys:
|
||||
if not isinstance(k, str) or not _VALID_IDENT.match(k):
|
||||
return {"status": "error", "error": f"invalid key_col identifier: {k!r}"}
|
||||
|
||||
if not isinstance(batch_size, int) or batch_size <= 0:
|
||||
return {"status": "error", "error": f"invalid batch_size: {batch_size!r}"}
|
||||
|
||||
use_upsert = bool(keys) and mode != "replace"
|
||||
if use_upsert and not _HAS_UPSERT:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"key_cols + mode!='replace' requiere pg_upsert_py_infra, que no "
|
||||
"esta disponible en este entorno"
|
||||
),
|
||||
}
|
||||
|
||||
# --- (a) Schema de la tabla DuckDB ---
|
||||
schema = duckdb_table_schema(duckdb_path, table)
|
||||
if schema.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"no se pudo leer el schema de {table!r}: {schema.get('error')}",
|
||||
}
|
||||
columns = schema["columns"]
|
||||
if not columns:
|
||||
return {"status": "error", "error": f"la tabla {table!r} no tiene columnas"}
|
||||
|
||||
col_names = [c["name"] for c in columns]
|
||||
# Validar que las key_cols existen en el schema.
|
||||
for k in keys:
|
||||
if k not in col_names:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"key_col {k!r} no esta en las columnas de {table!r}",
|
||||
}
|
||||
|
||||
# --- (b) DDL: crear/recrear la tabla en PostgreSQL via pg_apply_sql ---
|
||||
drop_first = mode == "replace"
|
||||
ddl = _build_ddl(target, columns, keys, drop_first)
|
||||
tmp_sql_path = None
|
||||
try:
|
||||
fd, tmp_sql_path = tempfile.mkstemp(suffix=".sql", prefix="duckdb_to_pg_")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(ddl)
|
||||
pg_apply_sql(pg_dsn, tmp_sql_path) # lanza RuntimeError si falla
|
||||
created = True
|
||||
except Exception as e: # noqa: BLE001 - convertir el raise de pg_apply_sql a dict
|
||||
return {"status": "error", "error": f"DDL fallo: {e}"}
|
||||
finally:
|
||||
if tmp_sql_path is not None and os.path.exists(tmp_sql_path):
|
||||
try:
|
||||
os.remove(tmp_sql_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# --- (c) Leer filas de DuckDB y volcarlas en PostgreSQL por lotes ---
|
||||
quoted = '"' + table.replace('"', '""') + '"'
|
||||
offset = 0
|
||||
rows_synced = 0
|
||||
try:
|
||||
while True:
|
||||
page = duckdb_query_readonly(
|
||||
duckdb_path,
|
||||
f"SELECT * FROM {quoted} LIMIT ? OFFSET ?",
|
||||
params=[batch_size, offset],
|
||||
max_rows=batch_size,
|
||||
)
|
||||
if page.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"lectura de filas fallo en offset {offset}: "
|
||||
f"{page.get('error')}",
|
||||
}
|
||||
batch = page["rows"]
|
||||
if not batch:
|
||||
break
|
||||
|
||||
if use_upsert:
|
||||
res = pg_upsert(pg_dsn, target, batch, keys)
|
||||
if res.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"pg_upsert fallo en offset {offset}: "
|
||||
f"{res.get('error')}",
|
||||
}
|
||||
rows_synced += res.get("inserted", 0) + res.get("updated", 0)
|
||||
else:
|
||||
# pg_insert_rows lanza RuntimeError si falla; add_snapshot_date=False
|
||||
# para no inyectar columnas que el schema DuckDB no tiene.
|
||||
inserted = pg_insert_rows(
|
||||
pg_dsn, target, batch, add_snapshot_date=False
|
||||
)
|
||||
rows_synced += inserted
|
||||
|
||||
offset += len(batch)
|
||||
if len(batch) < batch_size:
|
||||
break
|
||||
except Exception as e: # noqa: BLE001 - convertir raises de pg_insert_rows a dict
|
||||
return {"status": "error", "error": f"insercion fallo: {e}"}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"pg_table": target,
|
||||
"rows_synced": rows_synced,
|
||||
"created": created,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Ejecucion directa con `fn run`: demo minima contra una base DuckDB temporal y
|
||||
# un PostgreSQL apuntado por PG_TEST_DSN (si esta disponible).
|
||||
import json
|
||||
|
||||
dsn = os.environ.get("PG_TEST_DSN")
|
||||
if not dsn:
|
||||
print(json.dumps({"status": "skipped", "reason": "PG_TEST_DSN no definido"}))
|
||||
sys.exit(0)
|
||||
demo_db = os.environ.get("DUCKDB_DEMO_PATH", "/tmp/duckdb_to_pg_demo.duckdb")
|
||||
import duckdb # noqa: E402
|
||||
|
||||
con = duckdb.connect(demo_db)
|
||||
con.execute("CREATE OR REPLACE TABLE demo (id BIGINT, nombre VARCHAR, total DOUBLE)")
|
||||
con.execute("INSERT INTO demo VALUES (1, 'ana', 10.5), (2, 'luis', 20.0)")
|
||||
con.close()
|
||||
print(json.dumps(duckdb_to_postgres(demo_db, "demo", dsn, mode="replace")))
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests para el pipeline duckdb_to_postgres.
|
||||
|
||||
Los tests que tocan PostgreSQL hacen skip elegante si no hay PG_TEST_DSN. El mapeo
|
||||
de tipos y la construccion de DDL se prueban sin Postgres (logica pura interna).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import duckdb # noqa: E402
|
||||
|
||||
from duckdb_to_postgres import ( # noqa: E402
|
||||
_build_ddl,
|
||||
_map_duckdb_type_to_pg,
|
||||
duckdb_to_postgres,
|
||||
)
|
||||
|
||||
PG_DSN = os.environ.get("PG_TEST_DSN")
|
||||
|
||||
|
||||
# --- Tests sin Postgres: mapeo de tipos y DDL ---
|
||||
|
||||
|
||||
def test_map_tipos_duckdb_a_postgres():
|
||||
assert _map_duckdb_type_to_pg("BIGINT") == "BIGINT"
|
||||
assert _map_duckdb_type_to_pg("INTEGER") == "BIGINT"
|
||||
assert _map_duckdb_type_to_pg("DOUBLE") == "DOUBLE PRECISION"
|
||||
assert _map_duckdb_type_to_pg("FLOAT") == "DOUBLE PRECISION"
|
||||
assert _map_duckdb_type_to_pg("VARCHAR") == "TEXT"
|
||||
assert _map_duckdb_type_to_pg("TEXT") == "TEXT"
|
||||
assert _map_duckdb_type_to_pg("BOOLEAN") == "BOOLEAN"
|
||||
assert _map_duckdb_type_to_pg("DATE") == "DATE"
|
||||
assert _map_duckdb_type_to_pg("TIMESTAMP") == "TIMESTAMP"
|
||||
# Parametrizados normalizan al tipo base.
|
||||
assert _map_duckdb_type_to_pg("DECIMAL(10,2)") == "TEXT"
|
||||
assert _map_duckdb_type_to_pg("VARCHAR(50)") == "TEXT"
|
||||
# Desconocido -> TEXT (con posible perdida de tipado).
|
||||
assert _map_duckdb_type_to_pg("STRUCT(a INT)") == "TEXT"
|
||||
|
||||
|
||||
def test_build_ddl_con_pk_y_drop():
|
||||
cols = [
|
||||
{"name": "id", "type": "BIGINT"},
|
||||
{"name": "nombre", "type": "VARCHAR"},
|
||||
]
|
||||
ddl = _build_ddl("destino", cols, ["id"], drop_first=True)
|
||||
assert "DROP TABLE IF EXISTS \"destino\";" in ddl
|
||||
assert 'CREATE TABLE IF NOT EXISTS "destino"' in ddl
|
||||
assert '"id" BIGINT' in ddl
|
||||
assert '"nombre" TEXT' in ddl
|
||||
assert 'PRIMARY KEY ("id")' in ddl
|
||||
|
||||
|
||||
def test_build_ddl_sin_pk_ni_drop():
|
||||
cols = [{"name": "x", "type": "DOUBLE"}]
|
||||
ddl = _build_ddl("t", cols, [], drop_first=False)
|
||||
assert "DROP TABLE" not in ddl
|
||||
assert '"x" DOUBLE PRECISION' in ddl
|
||||
assert "PRIMARY KEY" not in ddl
|
||||
|
||||
|
||||
# --- Validaciones de entrada (sin Postgres) ---
|
||||
|
||||
|
||||
def test_identificador_tabla_invalido(tmp_path):
|
||||
res = duckdb_to_postgres(str(tmp_path / "x.duckdb"), "t; DROP", "dsn")
|
||||
assert res["status"] == "error"
|
||||
assert "invalid table identifier" in res["error"]
|
||||
|
||||
|
||||
def test_mode_invalido(tmp_path):
|
||||
db = tmp_path / "x.duckdb"
|
||||
con = duckdb.connect(str(db))
|
||||
con.execute("CREATE TABLE t (id BIGINT)")
|
||||
con.close()
|
||||
res = duckdb_to_postgres(str(db), "t", "dsn", mode="merge")
|
||||
assert res["status"] == "error"
|
||||
assert "invalid mode" in res["error"]
|
||||
|
||||
|
||||
# --- Tests end-to-end con Postgres ---
|
||||
|
||||
|
||||
@pytest.mark.skipif(not PG_DSN, reason="PG_TEST_DSN no definido")
|
||||
def test_replace_sincroniza_filas(tmp_path):
|
||||
db = tmp_path / "src.duckdb"
|
||||
con = duckdb.connect(str(db))
|
||||
con.execute("CREATE TABLE ventas (id BIGINT, region VARCHAR, total DOUBLE)")
|
||||
con.execute(
|
||||
"INSERT INTO ventas VALUES (1,'norte',10.5),(2,'sur',20.0),(3,'norte',5.25)"
|
||||
)
|
||||
con.close()
|
||||
pgt = "test_duckdb_to_pg_ventas"
|
||||
res = duckdb_to_postgres(str(db), "ventas", PG_DSN, pg_table=pgt, mode="replace")
|
||||
assert res["status"] == "ok", res
|
||||
assert res["pg_table"] == pgt
|
||||
assert res["rows_synced"] == 3
|
||||
assert res["created"] is True
|
||||
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f'SELECT COUNT(*) FROM "{pgt}"')
|
||||
assert cur.fetchone()[0] == 3
|
||||
cur.execute(f'DROP TABLE IF EXISTS "{pgt}"')
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not PG_DSN, reason="PG_TEST_DSN no definido")
|
||||
def test_upsert_idempotente_con_key_cols(tmp_path):
|
||||
db = tmp_path / "src.duckdb"
|
||||
con = duckdb.connect(str(db))
|
||||
con.execute("CREATE TABLE u (id BIGINT, v VARCHAR)")
|
||||
con.execute("INSERT INTO u VALUES (1,'a'),(2,'b')")
|
||||
con.close()
|
||||
pgt = "test_duckdb_to_pg_upsert"
|
||||
r1 = duckdb_to_postgres(
|
||||
str(db), "u", PG_DSN, pg_table=pgt, mode="replace", key_cols=["id"]
|
||||
)
|
||||
assert r1["status"] == "ok", r1
|
||||
# Re-sync en modo upsert: no debe duplicar (idempotente).
|
||||
r2 = duckdb_to_postgres(
|
||||
str(db), "u", PG_DSN, pg_table=pgt, mode="upsert", key_cols=["id"]
|
||||
)
|
||||
assert r2["status"] == "ok", r2
|
||||
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(PG_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f'SELECT COUNT(*) FROM "{pgt}"')
|
||||
assert cur.fetchone()[0] == 2
|
||||
cur.execute(f'DROP TABLE IF EXISTS "{pgt}"')
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: claude_fleet
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
type ClaudeFleet struct {
|
||||
PID int `json:"pid"`
|
||||
KittyPID int `json:"kitty_pid"`
|
||||
SessionID string `json:"session_id"`
|
||||
Rename string `json:"rename"`
|
||||
Target string `json:"target"`
|
||||
Goal string `json:"goal"`
|
||||
Phase string `json:"phase"`
|
||||
Status string `json:"status"`
|
||||
Cwd string `json:"cwd"`
|
||||
TmuxWindow string `json:"tmux_window"`
|
||||
Alive bool `json:"alive"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
description: "Registro de una sesion de Claude Code en la maquina local. Cruza el estado del proceso (/proc) con la metadata que Claude Code persiste en ~/.claude (sessions/<PID>.json + goals/<sessionId>.json). Pieza de datos de la app TUI fleetview, producida por list_claude_fleet_go_infra."
|
||||
tags: [claude-fleet, infra, claude, session, process]
|
||||
uses_types: []
|
||||
file_path: "functions/infra/claude_fleet.go"
|
||||
---
|
||||
|
||||
## Campos
|
||||
|
||||
| Campo | Tipo | Origen | Notas |
|
||||
|---|---|---|---|
|
||||
| `PID` | int | sessions/<PID>.json .pid | PID del proceso claude. |
|
||||
| `KittyPID` | int | /proc/<pid>/environ (KITTY_PID) | 0 si no aplica (tmux remoto, environ ilegible). |
|
||||
| `SessionID` | string | sessions .sessionId | UUID de la sesion. |
|
||||
| `Rename` | string | derivado | Display name: goal truncado a 48 runas si existe, si no basename(cwd). |
|
||||
| `Target` | string | derivado | sessionId[:8] + "@" + basename(cwd). |
|
||||
| `Goal` | string | goals/<sessionId>.json .goal | "" si no hay sidecar. |
|
||||
| `Phase` | string | goals .phase | "" si no hay sidecar. |
|
||||
| `Status` | string | sessions .status | idle\|busy\|waiting. |
|
||||
| `Cwd` | string | sessions .cwd | Working directory. |
|
||||
| `TmuxWindow` | string | (reservado) | "" por ahora; se rellena en fase posterior. |
|
||||
| `Alive` | bool | derivado de /proc | Proceso vivo Y procStart coincide (anti-PID-reciclado). |
|
||||
| `UpdatedAt` | int64 | sessions .updatedAt | Epoch en milisegundos. |
|
||||
|
||||
## Notas
|
||||
|
||||
Tipo producto inmutable de hecho (lo construye `list_claude_fleet_go_infra` y se consume read-only). Vive en `functions/infra/claude_fleet.go` con build tag `//go:build !windows` porque la funcion productora depende de `/proc` (Linux). Misma fuente de verdad que `reboot_all_claudes_bash_infra`. `Alive`, `Target` y `Rename` son derivados; los demas son copia directa de los JSON de Claude Code 2.1.x.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: resumable_claude
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
type ResumableClaude struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Goal string `json:"goal"` // de goals/<id>.json .goal ("" si no hay)
|
||||
Emojis string `json:"emojis"` // de goals/<id>.json .emojis ("" si no hay)
|
||||
Name string `json:"name"` // de goals/<id>.json .rename ("" si no hay)
|
||||
LastActive int64 `json:"last_active"` // mtime del goal.json en epoch segundos
|
||||
}
|
||||
description: "Describe una sesion de Claude Code CERRADA que aun conserva su objetivo guardado (goals/<id>.json) y por tanto puede reabrirse con `claude --resume <SessionID>`. La produce list_resumable_claudes_go_infra y la consume el picker de resume de la app TUI fleetview. SessionID es el basename del goal.json; Goal/Emojis/Name vienen de los campos goal/emojis/rename del goal.json; LastActive es el mtime del archivo en epoch segundos."
|
||||
tags: [claude-fleet, infra, claude, session, resume, tui]
|
||||
uses_types: []
|
||||
file_path: "functions/infra/resumable_claude.go"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
Tipo producto plano, serializable a JSON con snake_case (`session_id`, `last_active`). Complementa a `claude_fleet_go_infra` (sesiones VIVAS) modelando las sesiones CERRADAS-pero-resumibles. Vive en el mismo paquete Go `infra` que la funcion que lo produce (`functions/infra/resumable_claude.go`), junto al tipo `ClaudeFleet`. `LastActive` es el `mtime` del `goal.json`, una aproximacion de "ultima actividad", no el instante exacto del ultimo mensaje de la conversacion.
|
||||
Reference in New Issue
Block a user