feat(infra): auto-commit con 56 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 14:22:55 +02:00
parent c1071a82b3
commit 32c7336bf6
56 changed files with 5307 additions and 100 deletions
+4
View File
@@ -56,6 +56,10 @@
{ {
"type": "command", "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh" "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh"
},
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fleet_state_inject.sh"
} }
] ]
} }
@@ -0,0 +1,99 @@
---
name: check_service_health_via_ssh
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "check_service_health_via_ssh(ssh_host: string, local_url: string, [--token-from-env <remote_env_path> <ENV_VAR>], [--token <literal>], [--expect-status <code>], [--connect-timeout <s>], [--curl-timeout <s>]) -> json"
description: "Comprueba la salud de un service HTTP que solo escucha en loopback (127.0.0.1) de un host remoto, entrando por SSH y haciendo curl con bearer token opcional. El token se resuelve dentro del host remoto (leyendo una variable de un .env remoto via grep, o pasado literal) y NUNCA se imprime ni se hardcodea. Emite JSON con http_code y healthy. Reemplaza el patron inline 'ssh host -> grep token .env -> curl -H Authorization: Bearer' repetido en monitorizacion."
tags: [ssh, systemd, health, curl, remote, service, bearer, loopback, monitoring, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: ssh_host
desc: "alias SSH del host remoto definido en ~/.ssh/config (ej: om, organic-machine). Resuelve user/puerto/identityfile del config."
- name: local_url
desc: "URL del endpoint que el service expone en loopback del host remoto (ej: http://127.0.0.1:8487/agent). No es accesible desde fuera del host."
- name: --token-from-env
desc: "dos valores: <remote_env_path> <ENV_VAR>. Lee el bearer del .env remoto con grep '^ENV_VAR=' (ej: /home/ubuntu/app/.env AGENTS_API_KEY). El token se resuelve dentro del host, no viaja en argv local."
- name: --token
desc: "bearer literal (alternativa a --token-from-env). Util para tokens ya en variables de entorno locales; preferir --token-from-env para secretos en disco remoto."
- name: --expect-status
desc: "codigo HTTP exacto que marca healthy (ej: 200). Si se omite, cualquier 2xx cuenta como healthy."
- name: --connect-timeout
desc: "timeout de conexion SSH en segundos (default 5)."
- name: --curl-timeout
desc: "timeout maximo del curl remoto en segundos (default 10)."
output: "JSON a stdout: {\"status\":\"ok|error\",\"host\":\"...\",\"url\":\"...\",\"http_code\":NNN,\"healthy\":true|false}. status=error si el SSH fallo sin obtener codigo. healthy=true si http_code coincide con expect-status (o es 2xx por defecto). Exit 0 si healthy, 1 si no, 2 en error de uso."
tested: true
tests: ["service healthy con token desde env remoto", "service no healthy con http_code 503", "salida JSON nunca filtra el token", "sin token 2xx por defecto es healthy", "falta argumento obligatorio devuelve error de uso", "falta argumento sale con codigo distinto de 0"]
test_file_path: "bash/functions/infra/check_service_health_via_ssh_test.sh"
file_path: "bash/functions/infra/check_service_health_via_ssh.sh"
---
## Ejemplo
```bash
source bash/functions/infra/check_service_health_via_ssh.sh
# 1) Service en loopback del host 'om' con bearer leido de un .env remoto.
# Reemplaza el patron inline de monitorizacion del agents_and_robots.
result=$(check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
--token-from-env /home/ubuntu/CodeProyects/agents_and_robots/.env AGENTS_API_KEY \
--expect-status 200)
echo "$result"
# {"status":"ok","host":"om","url":"http://127.0.0.1:8487/agent","http_code":200,"healthy":true}
# 2) Sin token (endpoint publico del host pero solo accesible por loopback).
check_service_health_via_ssh organic-machine "http://127.0.0.1:8080/healthz"
# {"status":"ok","host":"organic-machine","url":"http://127.0.0.1:8080/healthz","http_code":200,"healthy":true}
# 3) Uso como gate en un script de monitorizacion (exit code).
if check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
--token-from-env /home/ubuntu/CodeProyects/agents_and_robots/.env AGENTS_API_KEY >/dev/null; then
echo "service vivo"
else
echo "service caido — alertar"
fi
```
## Cuando usarla
Usala cuando necesites comprobar si un service HTTP de un host remoto esta sano y ese
service **solo escucha en loopback** (127.0.0.1) del host, por lo que no puedes
curl-earlo directamente desde tu maquina. Tipico de APIs internas detras de un reverse
proxy, daemons con bearer auth, o services systemd que exponen un `/health` privado.
Antes de reiniciar un service, en un cron de monitorizacion, o como `e2e_check` de un
deploy.
## Gotchas
- Requiere **SSH por key auth** al host (usa `-o BatchMode=yes`): si el host pide
password, falla en vez de colgarse. El alias debe estar en `~/.ssh/config`.
- El service objetivo **debe escuchar en loopback del host remoto** — la URL se
resuelve *dentro* del host. `http://127.0.0.1:PORT` apunta al host remoto, no a tu PC.
- **No requiere sudo**: solo lee un `.env` (grep) y hace curl como el usuario SSH.
El usuario SSH debe tener permiso de lectura sobre el `.env` remoto.
- El **token nunca se imprime ni se hardcodea**: con `--token-from-env` se resuelve
dentro del host y solo se usa en el header `Authorization`. Con `--token <literal>`
el secreto queda en el argv del comando ssh local — preferir `--token-from-env`
para secretos persistidos en disco.
- `grep` del `.env` toma la **primera** linea que matchea `^<ENV_VAR>=` y recorta
comillas/espacios. Si la var aparece varias veces o usa interpolacion, revisa el match.
- `curl -sf` no sigue redirects: un 3xx cuenta como no-2xx (healthy=false salvo
`--expect-status` explicito).
- Requiere `curl` instalado en el **host remoto** (no en el local).
- El JSON de salida se emite siempre (incluso en fallo); el caller decide por el
`exit code` (0 healthy, 1 no healthy, 2 error de uso) o por el campo `healthy`.
## Notas
- Testeable sin red: el runner SSH es inyectable via `CHECK_HEALTH_SSH_BIN` (un stub
que emite el `http_code` deseado), por eso los tests no abren conexiones reales.
- El snippet remoto normaliza la salida de curl a un unico `http_code` aunque
`curl -sf` devuelva error (emite `<curl_rc>:<http_code>` y la funcion extrae el codigo).
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# check_service_health_via_ssh — Comprueba la salud de un service HTTP que solo
# escucha en loopback de un host remoto, entrando por SSH y haciendo curl con
# bearer token opcional (leido de un .env remoto o pasado literal).
set -euo pipefail
check_service_health_via_ssh() {
local ssh_host="" local_url=""
local remote_env_path="" env_var=""
local token_literal=""
local expect_status="" # vacio = aceptar cualquier 2xx
local connect_timeout=5
local curl_timeout=10
# --- parseo de args (posicionales + flags) ---
local positional=()
while [[ $# -gt 0 ]]; do
case "$1" in
--token-from-env)
remote_env_path="${2:-}"
env_var="${3:-}"
if [[ -z "$remote_env_path" || -z "$env_var" ]]; then
echo "check_service_health_via_ssh: --token-from-env requiere <remote_env_path> <ENV_VAR>" >&2
return 2
fi
shift 3
;;
--token)
token_literal="${2:-}"
shift 2
;;
--expect-status)
expect_status="${2:-}"
shift 2
;;
--connect-timeout)
connect_timeout="${2:-5}"
shift 2
;;
--curl-timeout)
curl_timeout="${2:-10}"
shift 2
;;
--)
shift
;;
-*)
echo "check_service_health_via_ssh: flag desconocida '$1'" >&2
return 2
;;
*)
positional+=("$1")
shift
;;
esac
done
ssh_host="${positional[0]:-}"
local_url="${positional[1]:-}"
if [[ -z "$ssh_host" || -z "$local_url" ]]; then
echo "check_service_health_via_ssh: uso: check_service_health_via_ssh <ssh_host> <local_url> [--token-from-env <remote_env_path> <ENV_VAR>] [--token <literal>] [--expect-status 200]" >&2
return 2
fi
# --- construir el snippet remoto que se ejecuta dentro del host via SSH ---
# El token NUNCA se imprime: se resuelve dentro del host remoto y se usa
# directamente en el header Authorization. El snippet emite SOLO el http_code.
#
# Casos de token:
# 1) --token-from-env: lee el valor de <ENV_VAR>= del .env remoto.
# 2) --token <literal>: el literal se inyecta en el snippet (cuidado: queda
# en argv del comando ssh local; preferir --token-from-env para secretos).
# 3) sin token: curl sin header Authorization.
local remote_script
if [[ -n "$remote_env_path" ]]; then
# grep el valor del .env remoto, recortando posibles comillas y espacios.
remote_script=$(cat <<REMOTE
set -e
TOKEN=\$(grep -E '^[[:space:]]*${env_var}[[:space:]]*=' '${remote_env_path}' 2>/dev/null | head -n1 | cut -d= -f2- | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*\$//' -e 's/^["'\'']//' -e 's/["'\'']\$//')
if [ -z "\$TOKEN" ]; then
echo "000"
exit 7
fi
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' 2>/dev/null)"
REMOTE
)
elif [[ -n "$token_literal" ]]; then
remote_script=$(cat <<REMOTE
set -e
TOKEN='${token_literal}'
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' 2>/dev/null)"
REMOTE
)
else
remote_script=$(cat <<REMOTE
set -e
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} '${local_url}' 2>/dev/null)"
REMOTE
)
fi
# --- ejecutar via SSH (o via runner inyectado en tests) ---
# CHECK_HEALTH_SSH_BIN permite a los tests sustituir el comando ssh por un
# stub que devuelve un http_code fijo, sin tocar la red.
local ssh_bin="${CHECK_HEALTH_SSH_BIN:-ssh}"
local raw rc=0
raw=$("$ssh_bin" -o BatchMode=yes -o ConnectTimeout="$connect_timeout" "$ssh_host" "$remote_script" 2>/dev/null) || rc=$?
# El snippet remoto, cuando curl -sf falla, emite "<curl_rc>:<http_code>".
# Cuando curl tiene exito, emite solo "<http_code>". Normalizamos a http_code.
local http_code
if [[ "$raw" == *:* ]]; then
http_code="${raw##*:}"
else
http_code="$raw"
fi
# sanitizar: solo digitos; cualquier otra cosa => 000
if [[ ! "$http_code" =~ ^[0-9]+$ ]]; then
http_code="000"
fi
# Si el SSH en si fallo (conexion, host caido) y no hay codigo util.
local status="ok"
if [[ "$rc" -ne 0 && "$http_code" == "000" ]]; then
status="error"
fi
# --- decidir healthy ---
local healthy="false"
if [[ -n "$expect_status" ]]; then
[[ "$http_code" == "$expect_status" ]] && healthy="true"
else
# default: cualquier 2xx
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] && healthy="true"
fi
printf '{"status":"%s","host":"%s","url":"%s","http_code":%s,"healthy":%s}\n' \
"$status" "$ssh_host" "$local_url" "$http_code" "$healthy"
[[ "$healthy" == "true" ]] && return 0 || return 1
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
check_service_health_via_ssh "$@"
fi
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Tests para check_service_health_via_ssh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/check_service_health_via_ssh.sh"
PASS=0
FAIL=0
assert_contains() {
local test_name="$1" needle="$2" haystack="$3"
if echo "$haystack" | grep -qF "$needle"; then
echo "PASS: $test_name"
PASS=$((PASS+1))
else
echo "FAIL: $test_name — expected to contain '$needle'"
echo " got: $haystack"
FAIL=$((FAIL+1))
fi
}
assert_not_contains() {
local test_name="$1" needle="$2" haystack="$3"
if ! echo "$haystack" | grep -qF "$needle"; then
echo "PASS: $test_name"
PASS=$((PASS+1))
else
echo "FAIL: $test_name — expected NOT to contain '$needle'"
echo " got: $haystack"
FAIL=$((FAIL+1))
fi
}
# --- stub SSH: en vez de conectarse, lee el .env remoto fake (si el snippet lo
# referencia) y emite el http_code de la env var STUB_HTTP_CODE. Simula tanto el
# caso "curl exito" (solo http_code) como "curl fallo" (<rc>:<http_code>). ---
STUB=$(mktemp)
chmod +x "$STUB"
cat > "$STUB" <<'STUBEOF'
#!/usr/bin/env bash
# Stub de ssh para tests. Ignora flags -o ... y el host; el ultimo arg es el
# script remoto. Emite el codigo segun STUB_HTTP_CODE / STUB_CURL_RC.
code="${STUB_HTTP_CODE:-200}"
rc="${STUB_CURL_RC:-0}"
# Si el script remoto referencia un .env y STUB_TOKEN_EMPTY=1, simular token vacio.
if [[ "${STUB_TOKEN_EMPTY:-0}" == "1" ]]; then
echo "000"
exit 7
fi
if [[ "$rc" == "0" ]]; then
echo "$code"
else
echo "${rc}:${code}"
exit 0
fi
STUBEOF
chmod +x "$STUB"
FAKE_ENV=$(mktemp)
cat > "$FAKE_ENV" <<'ENVEOF'
SOME_OTHER=foo
AGENTS_API_KEY=supersecret-token-123
ANOTHER=bar
ENVEOF
trap 'rm -f "$STUB" "$FAKE_ENV"' EXIT
# --- Test: service healthy con token desde .env remoto (200 esperado) ---
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=200 \
check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
--token-from-env "$FAKE_ENV" AGENTS_API_KEY --expect-status 200) || true
assert_contains "service healthy con token desde env remoto" '"healthy":true' "$result"
assert_contains "service healthy con token desde env remoto" '"http_code":200' "$result"
assert_contains "service healthy con token desde env remoto" '"status":"ok"' "$result"
assert_not_contains "service healthy con token desde env remoto" 'supersecret' "$result"
# --- Test: service no healthy cuando http_code no coincide con expect-status ---
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=503 STUB_CURL_RC=22 \
check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
--token-from-env "$FAKE_ENV" AGENTS_API_KEY --expect-status 200) || true
assert_contains "service no healthy con http_code 503" '"healthy":false' "$result"
assert_contains "service no healthy con http_code 503" '"http_code":503' "$result"
# --- Test: salida JSON nunca filtra el token ---
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=200 \
check_service_health_via_ssh om "http://127.0.0.1:9000/health" \
--token literal-secret-xyz) || true
assert_not_contains "salida JSON nunca filtra el token" 'literal-secret-xyz' "$result"
assert_contains "salida JSON nunca filtra el token" '"healthy":true' "$result"
# --- Test: sin token y 2xx por defecto cuenta como healthy ---
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=204 \
check_service_health_via_ssh om "http://127.0.0.1:8080/ping") || true
assert_contains "sin token 2xx por defecto es healthy" '"healthy":true' "$result"
assert_contains "sin token 2xx por defecto es healthy" '"http_code":204' "$result"
# --- Test: falta argumento obligatorio devuelve error de uso ---
set +e
err=$(check_service_health_via_ssh om 2>&1)
ec=$?
set -e
assert_contains "falta argumento obligatorio devuelve error de uso" 'uso:' "$err"
if [[ "$ec" -ne 0 ]]; then
echo "PASS: falta argumento sale con codigo distinto de 0"
PASS=$((PASS+1))
else
echo "FAIL: falta argumento deberia salir != 0 (got $ec)"
FAIL=$((FAIL+1))
fi
echo "---"
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
+3 -1
View File
@@ -42,6 +42,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output | | [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs | | [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI | | [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
| [img-to-3d](img-to-3d.md) | 3 | Imagen 2D -> modelo 3D: profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E | | [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers | | [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit | | [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
@@ -64,8 +65,9 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [consent](consent.md) | 3 | CMP / IAB TCF / data brokers: detectar el CMP de un sitio (Didomi/OneTrust/Sourcepoint/Quantcast), leer `__tcfapi` para contar vendors y propositos, aceptar el banner (selectores + fallback LLM con haiku que localiza Aceptar/Ver socios), y descargar la GVL de IAB para nominar cada broker y que datos recopila. Nacio de `projects/databrokers/` | | [consent](consent.md) | 3 | CMP / IAB TCF / data brokers: detectar el CMP de un sitio (Didomi/OneTrust/Sourcepoint/Quantcast), leer `__tcfapi` para contar vendors y propositos, aceptar el banner (selectores + fallback LLM con haiku que localiza Aceptar/Ver socios), y descargar la GVL de IAB para nominar cada broker y que datos recopila. Nacio de `projects/databrokers/` |
| [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_<instance>): abrir un archivo en ventana propia, cerrar+reabrir para mostrar datos editados en disco (no hay reload nativo, Issue #2313), y matar el proceso del slot. Solo gestiona la ventana, NO edita ni crea archivos. Requiere X11 + wmctrl + xdotool. No confundir con el Document Server (web/Docker) | | [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_<instance>): abrir un archivo en ventana propia, cerrar+reabrir para mostrar datos editados en disco (no hay reload nativo, Issue #2313), y matar el proceso del slot. Solo gestiona la ventana, NO edita ni crea archivos. Requiere X11 + wmctrl + xdotool. No confundir con el Document Server (web/Docker) |
| [email](email.md) | 21 | Gestionar cuentas de correo por IMAP+SMTP directo (Python stdlib, sin browser ni MCP Gmail): conectar/listar/buscar/leer (imap_*), mutar estado (mark_seen/move/delete/save_draft) por UID, y construir+enviar (email_build_html/smtp_send). Auth user+app-password (NO OAuth; Outlook fuera). Credenciales desde pass, resueltas por la capa app. Complementa al browser (interactivo) — no lo reemplaza | | [email](email.md) | 21 | Gestionar cuentas de correo por IMAP+SMTP directo (Python stdlib, sin browser ni MCP Gmail): conectar/listar/buscar/leer (imap_*), mutar estado (mark_seen/move/delete/save_draft) por UID, y construir+enviar (email_build_html/smtp_send). Auth user+app-password (NO OAuth; Outlook fuera). Credenciales desde pass, resueltas por la capa app. Complementa al browser (interactivo) — no lo reemplaza |
| [eda](eda.md) | 8 | Exploratory Data Analysis por tabla con motor DuckDB push-down: perfil base SQL (SUMMARIZE), estadística numérica/categórica sobre muestra, tipo semántico por regex, score de calidad, render markdown con sparklines y el orquestador one-shot `profile_table` (promueve VARCHAR→numeric/datetime, emite TableProfile + report md/json). Fases siguientes: correlaciones, relaciones inter-tabla, modelos baratos, LLM, notebook | | [eda](eda.md) | 27 | Exploratory Data Analysis por tabla y base con motor DuckDB + PostgreSQL push-down: perfil base SQL (SUMMARIZE + distinct exacto), estadística numérica/categórica, tipo semántico regex, calidad, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK containment + join graph mermaid), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM (dictionary/PII/limpieza/análisis) y generación de notebook. Orquestadores `profile_table` (backend duckdb/postgres, flags run_models/run_llm) y `profile_database` |
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay | | [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
## Como anadir grupo ## Como anadir grupo
+2 -1
View File
@@ -19,6 +19,7 @@ Pieza central del patron **BD como fuente de verdad + Obsidian como vista** (pro
| `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). | | `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}`. | | `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}`. | | `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}`. |
| `query_osint_db_py_datascience` | `query_osint_db(sql, base_url='http://127.0.0.1:8771', timeout=30) -> dict` | **Cliente HTTP del service `osint_db`**: hace `POST {base_url}/api/query` con `{"sql": sql}` y devuelve `{status, columns, rows, row_count, truncated}` sin lanzar (mismo estilo que `duckdb_query_readonly`). Vía correcta para leer la DuckDB maestra del proyecto `osint` desde otro proceso sin abrir el archivo (respeta el single-writer). Service caído → `{status:'error', error}` claro. Solo stdlib. |
## Puentes: Excel → DuckDB → Postgres → visualización ## Puentes: Excel → DuckDB → Postgres → visualización
@@ -79,7 +80,7 @@ Conversion CSV -> Parquet en una linea:
## Gotchas del grupo ## Gotchas del grupo
- **Single-writer**: DuckDB permite UN solo proceso escritor por archivo. Si un service (ej. `osint_db`) posee la base, el resto de procesos deben leer con `read_only=True` (`duckdb_query_readonly` ya lo hace) o pasar por la API HTTP del service. Las funciones de escritura (`duckdb_execute`, `duckdb_upsert`) abren en read-write y SOLO debe usarlas el proceso dueño de la base (dentro de su write lock), nunca un cliente concurrente. - **Single-writer**: DuckDB permite UN solo proceso escritor por archivo. Si un service (ej. `osint_db`) posee la base, el resto de procesos deben leer con `read_only=True` (`duckdb_query_readonly` ya lo hace) o pasar por la API HTTP del service (`query_osint_db` para `osint_db`). Las funciones de escritura (`duckdb_execute`, `duckdb_upsert`) abren en read-write y SOLO debe usarlas el proceso dueño de la base (dentro de su write lock), nunca un cliente concurrente.
- **Version del motor**: el formato de archivo puede cambiar entre versiones mayores de DuckDB. El venv del registry lleva `duckdb` 1.5.x; no mezclar con CLIs/WASM antiguos sobre el mismo archivo. - **Version del motor**: el formato de archivo puede cambiar entre versiones mayores de DuckDB. El venv del registry lleva `duckdb` 1.5.x; no mezclar con CLIs/WASM antiguos sobre el mismo archivo.
- `read_only=True` exige que el archivo exista — no crea bases nuevas. - `read_only=True` exige que el archivo exista — no crea bases nuevas.
+100 -48
View File
@@ -1,80 +1,132 @@
# eda — Exploratory Data Analysis por tabla # eda — Exploratory Data Analysis por tabla y base
Grupo de capacidad para perfilar tablas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, outliers). Grupo de capacidad para perfilar tablas y bases de datos completas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, `corr()`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, correlación mixta, modelos).
El orquestador one-shot es `profile_table_py_pipelines`: "hazme un EDA de esta tabla" → un `TableProfile` completo + report markdown + JSON sidecar en `reports/`. Orquestadores one-shot:
- `profile_table_py_pipelines` — "hazme un EDA de esta tabla" → `TableProfile` completo + report markdown + JSON. Flags `run_models` (modelos baratos) y `run_llm` (interpretación LLM).
- `profile_database_py_pipelines` — "hazme un EDA de esta base" → perfila todas las tablas + infiere FK + join graph (mermaid).
> Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`. > Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`.
## Funciones ## Funciones
### Perfilado base (tabla y columna)
| ID | Pureza | Qué hace | | ID | Pureza | Qué hace |
|---|---|---| |---|---|---|
| `summarize_table_duckdb_py_datascience` | impure | Corazón: `SUMMARIZE` push-down → esqueleto del `TableProfile` con perfil base por columna (tipo inferido, nulls, distinct exacto ≤200k filas, flags). Reusa `duckdb_query_readonly`. | | `summarize_table_duckdb_py_datascience` | impure | Corazón (DuckDB): `SUMMARIZE` push-down + `COUNT DISTINCT` exacto (≤200k filas) → esqueleto del `TableProfile`. |
| `describe_numeric_py_datascience` | pure | Bloque `numeric` sobre una muestra: min/max/mean/median/mode/std/cv, percentiles p1-p99, IQR, skew, kurtosis, outliers, %zeros/%neg, tipo de distribución, histograma. | | `summarize_table_pg_py_datascience` | impure | Adaptador PostgreSQL: mismo esqueleto `TableProfile` vía SQL push-down (information_schema + count/distinct/min/max/avg/stddev/percentile_cont). |
| `summarize_categorical_py_datascience` | pure | Bloque `categorical`: top-k frecuencias, mode, distinct, entropía de Shannon (bits), imbalance, longitudes. | | `describe_numeric_py_datascience` | pure | Bloque numérico: min/max/mean/median/std/cv, p1-p99, IQR, skew, kurtosis, outliers, distribución, histograma. |
| `infer_semantic_type_py_datascience` | pure | Tipo semántico por regex (email/url/ip/uuid/iban/currency/datetime/integer/decimal/...) sin LLM. Primera pasada barata. | | `summarize_categorical_py_datascience` | pure | top-k frecuencias, mode, distinct, entropía, imbalance, longitudes. |
| `column_quality_score_py_datascience` | pure | Score de calidad 0-100 (completeness/validity/consistency) + issues legibles para un `ColumnProfile`. | | `infer_semantic_type_py_datascience` | pure | Tipo semántico por regex (email/url/ip/uuid/iban/currency/datetime/...). |
| `render_eda_markdown_py_datascience` | pure | `TableProfile` → report markdown autosuficiente (Overview, Columnas, Numéricas con sparkline ASCII, Categóricas, Calidad). | | `column_quality_score_py_datascience` | pure | Score 0-100 (completeness/validity/consistency) + issues. |
| `summary_stats_py_datascience` | pure | Descriptiva mínima (n, mean, median, p25, p75) de una lista de floats. | | `render_eda_markdown_py_datascience` | pure | `TableProfile` → report markdown con sparklines ASCII. |
| `profile_table_py_pipelines` | pipeline | Orquestador end-to-end: compone todo lo anterior, promueve tipos VARCHAR→numeric/datetime por contenido, y emite `TableProfile` + report markdown + JSON. | | `summary_stats_py_datascience` | pure | Descriptiva mínima (n, mean, median, p25, p75). |
### Correlación / asociación
| ID | Pureza | Qué hace |
|---|---|---|
| `pearson_py_datascience` | pure | Correlación lineal num↔num (preexistente). |
| `spearman_corr_py_datascience` | pure | Correlación de rangos (monotónica no lineal) num↔num. |
| `cramers_v_py_datascience` | pure | Asociación simétrica cat↔cat (corrección Bergsma-Wicher). |
| `theils_u_py_datascience` | pure | Asociación direccional U(a\|b) cat↔cat. |
| `correlation_ratio_py_datascience` | pure | η: cuánto explica una categórica a una numérica. |
| `mutual_info_columns_py_datascience` | pure | Información mutua (no lineal, general) entre cualquier par. |
| `association_matrix_py_datascience` | pure | Matriz unificada: elige métrica por par de tipos + pares fuertes. |
| `correlation_matrix_duckdb_py_datascience` | impure | Matriz Pearson push-down (`corr()` SQL) para muchas filas. |
### Relaciones inter-tabla
| ID | Pureza | Qué hace |
|---|---|---|
| `infer_fk_containment_duckdb_py_datascience` | impure | Infiere FK candidatas por containment de valores (inclusion coefficient). |
| `build_join_graph_py_datascience` | pure | FK candidates → grafo (roles fact/dimension) + diagrama Mermaid. |
### Modelos baratos (flag `run_models`)
| ID | Pureza | Qué hace |
|---|---|---|
| `pca_explained_py_datascience` | pure | PCA: varianza explicada + loadings + proyección. |
| `kmeans_segments_py_datascience` | pure | Segmentos naturales, auto-k por silhouette. |
| `isolation_forest_outliers_py_datascience` | pure | Outliers multivariante (filas anómalas). |
| `normality_tests_py_datascience` | pure | Jarque-Bera + D'Agostino + Shapiro → ¿normal? |
| `trend_slope_py_datascience` | pure | Tendencia de una serie (up/down/flat) por regresión lineal. |
| `run_eda_models_py_datascience` | pure | Wrapper: compone PCA + KMeans + IsolationForest + normalidad → bloque `models`. |
### Capa LLM y entrega
| ID | Pureza | Qué hace |
|---|---|---|
| `eda_llm_insights_py_datascience` | impure | 1 call LLM sobre el perfil agregado (no filas crudas): data dictionary, resumen, granularidad de fila, PII/RGPD, limpieza, análisis sugeridos. |
| `build_eda_notebook_py_datascience` | impure | Genera un `.ipynb` (nbformat v4) que perfila la tabla, listo para lanzar en Jupyter colaborativo. |
### Orquestadores (pipelines)
| ID | Qué hace |
|---|---|
| `profile_table_py_pipelines` | EDA de una tabla end-to-end, `backend="duckdb"` (default) o `"postgres"` (base + correlación + `run_models` + `run_llm`) → JSON + markdown. |
| `profile_database_py_pipelines` | EDA de una base entera: todas las tablas + FK + join graph. |
## Contrato de datos ## Contrato de datos
Todas las funciones producen/consumen el mismo shape (dict JSON), lo que desacopla cálculo, render y (futuro) LLM:
``` ```
TableProfile = { TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes,
table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct,
duplicate_rows, duplicate_pct, constant_cols:[str], all_null_cols:[str], type_breakdown:{numeric,categorical,datetime,text,boolean},
null_cell_pct, type_breakdown:{numeric,categorical,datetime,text,boolean}, columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models}
columns:[ColumnProfile], correlations, key_candidates:[str],
quality_score, llm, models
}
ColumnProfile = { ColumnProfile = {name, physical_type, inferred_type, semantic_type, count, n_rows,
name, physical_type, inferred_type, # numeric|categorical|datetime|boolean|text|id null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct,
semantic_type, count, n_rows, null_count, null_pct, empty_count, empty_pct, flags:[constant|possible_id|high_cardinality|mostly_null], quality_score,
distinct_count, unique_pct, # *_pct son FRACCIONES 0-1; el render las muestra ×100 numeric:{...}|None, categorical:{...}|None, datetime:{...}|None}
flags:[constant|possible_id|high_cardinality|mostly_null], # *_pct son FRACCIONES 0-1; el render las muestra ×100
quality_score,
numeric: {min,max,mean,median,mode,std,variance,cv,p1,p5,p25,p50,p75,p95,p99,iqr, correlations = {pairs:[{a,b,a_type,b_type,method,value,extra}], strong:[...], methods_legend}
skew,kurtosis,n_outliers,outlier_pct,zero_pct,negative_pct,distribution_type, models = {n_numeric_cols, pca, kmeans, outliers, normality, note}
histogram:[{lo,hi,count}]} | None, llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}],
categorical: {top:[{value,count,pct}],mode,mode_pct,n_distinct,entropy,imbalance, pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}
len_mean,len_min,len_max} | None,
datetime: {min,max,range_days,granularity,n_gaps,future_pct,monotonic} | None
}
``` ```
## Ejemplo canónico ## Ejemplo canónico
EDA de una tabla DuckDB en una línea (escribe `reports/eda_<table>_<ts>.md` + `.json`): EDA completo de una tabla (estadística + correlación + modelos + LLM + report):
```python ```python
import sys, os import sys, os
sys.path.insert(0, os.path.join("python", "functions")) sys.path.insert(0, os.path.join("python", "functions"))
from pipelines.profile_table import profile_table from pipelines.profile_table import profile_table
r = profile_table(os.path.expanduser("~/.fn_freelance/freelance.duckdb"), "freelance_projects") r = profile_table("/ruta/datos.duckdb", "clientes", run_models=True, run_llm=True)
print(r["status"], r["report_md_path"])
prof = r["profile"] prof = r["profile"]
print(prof["type_breakdown"], "key_candidates:", prof["key_candidates"], "calidad:", prof["quality_score"]) print(r["report_md_path"]) # reports/eda_clientes_<ts>.md
print(prof["correlations"]["strong"]) # pares correlacionados
print(prof["models"]["kmeans"]["best_k"]) # segmentos
print(prof["llm"]["row_meaning"]) # qué representa 1 fila
``` ```
La promoción de tipo por contenido resuelve el caso típico de scrapers/CSV donde los números y fechas llegan como `VARCHAR`: `bids` ('10','20') se detecta `integer` y se perfila como numérica (mean/median/percentiles); `scraped_at` se detecta `datetime_iso`. EDA de una base entera con relaciones:
```python
from pipelines.profile_database import profile_database
r = profile_database("/ruta/datos.duckdb") # todas las tablas
print(r["db_profile"]["join_graph"]["mermaid"]) # diagrama de relaciones FK
```
Notebook ejecutable:
```python
from datascience import build_eda_notebook
build_eda_notebook("/ruta/datos.duckdb", "clientes", "/tmp/eda.ipynb", run_models=True)
```
## Fronteras ## Fronteras
- **NO carga la tabla entera a RAM**: solo metadata SQL + una muestra (`sample`, default 5000) por columna. Para distribución exacta de una columna enorme, sube `sample` o consulta SQL directa. - **NO carga la tabla entera a RAM**: metadata SQL + muestra por columna/filas (`sample`, default 5000).
- **Distinct exacto solo hasta 200k filas**; por encima usa aproximado (HyperLogLog) capado a nº de filas. - **Distinct exacto hasta 200k filas**; por encima aproximado capado.
- **Solo DuckDB** por ahora (CSV/Parquet/Excel entran gratis vía `read_csv_auto`/`read_parquet`/`read_xlsx` cargándolos antes a DuckDB). PostgreSQL y BigQuery requieren adaptador (pendiente). - **Correlación de tabla** se calcula sobre la muestra de filas alineadas; excluye columnas id-like (alta cardinalidad) para evitar asociación espuria. `correlation_matrix_duckdb` ofrece Pearson push-down exacto a escala si hace falta.
- **No es estadística inferencial ni modelado**: es perfilado descriptivo. Correlaciones, modelos baratos (PCA/KMeans/IsolationForest) y capa LLM son fases siguientes del grupo. - **Modelos** (`run_models`) requieren ≥2 columnas numéricas para PCA/KMeans/IsolationForest; normalidad funciona con 1.
- **LLM** (`run_llm`) hace 1 llamada (haiku) y envía solo el perfil agregado, nunca filas crudas; requiere token OAuth de Claude.
- **Fuentes**: DuckDB nativo (CSV/Parquet/Excel cargándolos antes a DuckDB) y **PostgreSQL** (`backend="postgres"`, DSN vía `resolve_pg_dsn`). BigQuery pendiente. `profile_database` (multi-tabla + FK) es solo DuckDB por ahora.
## Roadmap (fases siguientes) ## Estado
- **Correlación / asociación**: Spearman, Cramér's V, Theil's U, correlation ratio η², Mutual Information, VIF → `correlations` del `TableProfile`. Implementado y validado end-to-end (152 tests verdes): perfilado base, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK + join graph), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM y generación de notebook.
- **Relaciones inter-tabla**: FK inference por containment, cardinalidad de relación, join graph (mermaid), star-schema hints → `profile_database`.
- **Modelos baratos** (flag `--models`, sklearn/scipy): PCA 2D, KMeans + silhouette, Isolation Forest, feature importance, tests de normalidad, tendencia temporal. Validado sobre PostgreSQL real (tablas del Metabase local del proyecto captacion_clientes).
- **Capa LLM** (flag `--llm`, grupo `claude-direct`): data dictionary, resumen ejecutivo (qué es 1 fila + granularidad), flag PII/RGPD, limpieza sugerida, análisis sugeridos.
- **Entrega notebook**: analysis Jupyter auto-generado y ejecutado en el navegador colaborativo. Pendiente: adaptador BigQuery; `profile_database` multi-tabla para PostgreSQL (hoy solo DuckDB); perfil fino de columnas datetime (`profile_datetime`); excluir columnas numéricas `possible_id` de la matriz de asociación (hoy solo se excluyen las categóricas id-like).
+84
View File
@@ -0,0 +1,84 @@
---
group: img-to-3d
description: "Convertir una imagen 2D en un modelo 3D: estimacion de profundidad monocular (Depth-Anything-V2) + reconstruccion de una malla de relieve texturizada exportada a glTF binario (.glb)."
---
# img-to-3d — Capability Group
Cluster de funciones Python (dominio `datascience`) para el flujo **imagen 2D → modelo 3D**. A
partir de una sola foto se estima un mapa de profundidad monocular con un modelo de vision y se
reconstruye una malla de relieve (heightmap) texturizada con la imagen original, exportada como
`.glb` cargable por cualquier visor glTF (three.js `useGLTF`/`GLTFLoader`, Babylon, model-viewer).
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas dos funciones; ver su
`backend/depth.py`). El flujo canonico es de **dos pasos encadenados**:
```
estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image -> .glb)
```
## Funciones
| ID | Firma corta | Que hace |
|---|---|---|
| `estimate_image_depth_py_datascience` | `estimate_image_depth(image_path, model_name?, device?, use_cache?) -> dict` | Estima profundidad monocular con Depth-Anything-V2 (GPU/CPU). Devuelve `depth` ndarray [0,1] + `image` PIL. Paso 1. |
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Paso 2. |
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone los dos pasos en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
Las tres son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
(`ok`/`error`) y **nunca lanzan**: los fallos vuelven como `{status:'error', error:str}`. El
pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
## Ejemplo canonico (end-to-end imagen → glb)
```python
# Requiere un venv con torch + transformers + trimesh + pillow + numpy.
# Import PLANO: el paquete datascience.__init__ arrastra deps de otros dominios (bs4, duckdb...)
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
import sys
sys.path.insert(0, "python/functions/datascience")
from estimate_image_depth import estimate_image_depth
from depth_to_relief_glb import depth_to_relief_glb
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
OUT = "/tmp/cats_relief.glb"
est = estimate_image_depth(IMG) # device='auto' -> GPU si hay
assert est["status"] == "ok"
# est["depth"]: ndarray HxW float32 [0,1] (1=mas cerca) | est["image"]: PIL.Image RGB
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220)
assert res["status"] == "ok"
print(res["glb_path"], res["vertices"], res["faces"]) # /tmp/cats_relief.glb 36300 71832
# OUT es un glTF binario valido: trimesh.load(OUT) devuelve una Scene texturizada.
```
O en una sola llamada con el pipeline (recomendado para fn run / Launcher TUI):
```bash
./fn run build_relief_glb_from_image_py_pipelines apps/img_to_3d_webapp/samples/cats.jpg /tmp/cats_relief.glb
# {"status": "ok", "glb_path": "/tmp/cats_relief.glb", "vertices": 36300, "faces": 71832, ...}
```
## Fronteras
- **Es relieve 2.5D, no reconstruccion volumetrica.** Deforma un plano segun la profundidad
(heightmap); no recupera caras ocultas ni el volumen trasero del objeto. Para 3D real
multivista/fotogrametria, NSP/Gaussian Splatting, esto NO aplica.
- **Profundidad relativa, no metrica.** Depth-Anything devuelve disparidad normalizada a [0,1];
no comparable entre imagenes ni en unidades del mundo real.
- **No cubre el render/visualizacion.** Producir el `.glb` es el limite del grupo. Cargarlo y
subirlo a GPU (OpenGL) en una app C++/ImGui es el grupo **`mesh-3d`** (`gltf_load_mesh_cpp_gfx`
carga justamente este tipo de `.glb`). img-to-3d **produce**; mesh-3d **consume/renderiza**.
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision) y `trimesh` (mesh),
que hoy viven en el venv de `img_to_3d_webapp`, NO en el venv del registry. Ademas el
`datascience.__init__` arrastra deps de scrapers (`bs4`...) que no estan en el venv de vision,
por eso el import es **plano** (al modulo) y no via el paquete. `fn run` de estas funciones
exige un venv que combine ambos mundos (torch + transformers + trimesh + las deps del dominio
datascience). Ver gotchas en cada `.md`.
## Prerequisitos
- GPU NVIDIA + CUDA recomendada (corre en CPU pero lento). Primera ejecucion descarga los pesos
del modelo a `~/.cache/huggingface/` (cientos de MB segun la variante).
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`.
+77
View File
@@ -0,0 +1,77 @@
# Capability: local-hub
Exponer los procesos locales de la maquina como subdominios `*.localhost` (via Caddy) y reunirlos
en una pantalla principal (Glance) con estado online/offline en vivo, refrescada a diario por
`dag_engine`. Cubre el ciclo: descubrir servicios -> renderizar config de proxy -> renderizar
config de dashboard -> recargar y reiniciar (pipeline `refresh_local_hub`).
Fuente de verdad de los servicios: `apps/local_hub/local_services.yaml`.
## Por que existe
Una maquina con muchos procesos locales (Metabase :3030, Portainer :9000, Grafana, Jupyter,
dag_engine, registry_api...) obliga a recordar puerto por puerto. Este grupo los pone detras de
nombres legibles (`metabase.localhost`, `portainer.localhost`) sin tocar DNS ni `/etc/hosts`
(systemd-resolved resuelve `*.localhost` a 127.0.0.1 por defecto, RFC 6761) y los lista en una
sola pagina con su salud en vivo.
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `discover_local_services_py_infra` | `discover_local_services(manifest_path, include_registry=True) -> list[dict]` | Lee el manifiesto `local_services.yaml`, normaliza cada servicio (name, subdomain, port, health_path, title, icon, category) y resuelve `up` por chequeo TCP. Con `include_registry` anade los servicios del registry (via `fn doctor services-spec`) deduplicados. |
| `render_caddyfile_py_infra` | `render_caddyfile(services, dashboard=None) -> str` | PURA. Convierte la lista de servicios en el texto de un fragmento de Caddyfile (`http://<sub>.localhost { reverse_proxy 127.0.0.1:<port> }`), ordenado y determinista. El dashboard va primero. |
| `render_glance_config_py_infra` | `render_glance_config(services, title="Procesos locales", host_suffix="localhost") -> str` | PURA. Convierte la lista en YAML de Glance: una pagina con un widget `monitor` por categoria, cada site apuntando a `http://<sub>.localhost`. |
| `refresh_local_hub_py_pipelines` | `refresh_local_hub(manifest_path=..., reload=True) -> dict` | PIPELINE. Compone las 3 anteriores: descubre, renderiza Caddyfile + Glance, los escribe (`/etc/caddy/conf.d/local_hub.caddy` via ACL + `apps/local_hub/glance/glance.yml`), recarga Caddy (admin API :2019, sin sudo) y reinicia la user-unit `glance`. |
## Ejemplo canonico
```bash
# Refrescar todo el hub (descubrir + regenerar configs + recargar):
./fn run refresh_local_hub
# Acceder a un servicio por su subdominio (cualquier navegador del host):
# http://metabase.localhost
# http://portainer.localhost
# http://home.localhost <- la pantalla principal (Glance)
# Anadir un servicio nuevo: editar el manifiesto y refrescar
$EDITOR apps/local_hub/local_services.yaml # name, subdomain, port, health_path, title, icon, category
./fn run refresh_local_hub
```
Composicion ad-hoc (heredoc) si se necesita solo una parte:
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.discover_local_services import discover_local_services
from infra.render_caddyfile import render_caddyfile
services = discover_local_services("apps/local_hub/local_services.yaml")
print(render_caddyfile(services, dashboard={"subdomain": "home", "port": 8585}))
```
## Infraestructura (one-time, ya provisionada)
- **Caddy** (`apt`, systemd system service, puerto 80): `/etc/caddy/Caddyfile` hace
`import /etc/caddy/conf.d/*.caddy`. El usuario tiene ACL de escritura sobre `conf.d/` para que
el pipeline regenere sin sudo. Reload via admin API en `localhost:2019`.
- **Glance** (binario nativo en `~/.local/bin/glance`, systemd user service `glance.service`,
`127.0.0.1:8585`). Corre como binario del host —no contenedor— para que `*.localhost` resuelva
igual que en el resto del sistema.
- **dag_engine**: DAG `refresh_local_hub` diario que ejecuta el pipeline.
## Fronteras
- **NO gestiona TLS**: sirve HTTP plano (`http://`) porque es trafico loopback. Para HTTPS con CA
interna habria que quitar el prefijo `http://` en `render_caddyfile` y dejar que Caddy emita
certs internos.
- **NO arranca ni para los servicios** que expone: asume que ya corren. Solo crea el mapeo de
subdominio y los lista. Encender/apagar un servicio es trabajo de su propia unit / `systemd`.
- **NO hace el health-check en vivo**: eso lo hace Glance client-side. El pipeline solo resuelve
un `up/down` puntual al regenerar (snapshot del momento).
- **Servicios no-HTTP** (Postgres :5433, etc.) quedan fuera del proxy y del dashboard: Caddy y el
widget `monitor` de Glance son HTTP.
- **Solo loopback / un PC**: el manifiesto y los subdominios son locales a la maquina. No expone
nada a la red. Para acceso remoto se usa SSH port-forward o el grupo `ssh`/`wireguard`.
+19 -1
View File
@@ -1,6 +1,6 @@
# Capability: metabase # Capability: metabase
Operar Metabase 100% via API REST. Cubre: auth (`metabase_auth`), CRUD de cards/dashboards/collections/snippets/permissions/databases, ejecucion de queries (`metabase_execute_card`, `metabase_query`), refresh metadata + result_metadata, listado y archivado, gestion de pulses, y composiciones (`init_metabase`, `setup_metabase_volume`). 106 funciones Go+Py. Cliente reutilizable: `MetabaseClient` (Go: `metabase_client_go_infra`; Py: `MetabaseClient_py_infra`). Operar Metabase 100% via API REST. Cubre: auth (`metabase_auth` con email/password, `metabase_client_from_pass` cargando la credencial desde `pass` — sesión o API-key), CRUD de cards/dashboards/collections/snippets/permissions/databases, ejecucion de queries (`metabase_execute_card`, `metabase_query`), refresh metadata + result_metadata, listado y archivado, gestion de pulses, y composiciones (`init_metabase`, `setup_metabase_volume`). 108 funciones Go+Py. Cliente reutilizable: `MetabaseClient` (Go: `metabase_client_go_infra`; Py: `MetabaseClient_py_infra`) — el cliente Py detecta el prefijo `mb_` y autentica por header `X-API-KEY`.
## Funciones ## Funciones
@@ -15,6 +15,8 @@ Operar Metabase 100% via API REST. Cubre: auth (`metabase_auth`), CRUD de cards/
| `metabase_archive_snippet_py_infra` | `def metabase_archive_snippet(client: MetabaseClient, snippet_id: int) -> dict` | Archiva un SQL snippet en Metabase. Wrapper sobre metabase_update_snippet con archived=True. | | `metabase_archive_snippet_py_infra` | `def metabase_archive_snippet(client: MetabaseClient, snippet_id: int) -> dict` | Archiva un SQL snippet en Metabase. Wrapper sobre metabase_update_snippet con archived=True. |
| `metabase_auth_go_infra` | `func MetabaseAuth(baseURL, email, password string) (MetabaseClient, error)` | Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias (configurable con MAX_SESSION_AGE en Metabase). Endpoint: POST /api/session. | | `metabase_auth_go_infra` | `func MetabaseAuth(baseURL, email, password string) (MetabaseClient, error)` | Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias (configurable con MAX_SESSION_AGE en Metabase). Endpoint: POST /api/session. |
| `metabase_auth_py_infra` | `def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient` | Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias. Endpoint: POST /api/session. | | `metabase_auth_py_infra` | `def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient` | Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias. Endpoint: POST /api/session. |
| `metabase_client_from_pass_py_infra` | `def metabase_client_from_pass(pass_key: str, base_url: str, mode: str = 'auto') -> MetabaseClient \| dict` | Construye un `MetabaseClient` autenticado leyendo la credencial desde `pass`. `mode='session'` (secreto multi-línea: L1 password, línea `email:`) usa `metabase_auth`; `mode='api_key'` (secreto de una línea tipo `mb_...`) autentica por header; `mode='auto'` detecta por la forma del secreto. Compone `pass_get_secret` + `parse_metabase_secret` + `metabase_auth`. Devuelve el cliente o `{status:'error', error}` sin lanzar. Cubre Aurgi (API-key) y captación (sesión) sin reescribir la carga de credenciales. |
| `parse_metabase_secret_py_infra` | `def parse_metabase_secret(secret_text: str, mode: str = 'auto') -> dict` | Núcleo **puro** y testeable de `metabase_client_from_pass`: parsea el texto del secreto de `pass` y devuelve `{mode, email, password}` (sesión) o `{mode, api_key}` (API-key). `mode='auto'` clasifica: una sola línea sin `email:`/`login:` → api_key; multi-línea con email → session. Sin I/O. |
| `metabase_copy_card_py_infra` | `def metabase_copy_card(client: MetabaseClient, card_id: int, name: str \| None = None, collection_id: int \| None = None, description: str \| None = None) -> dict` | Crea una copia de una card/pregunta en Metabase via el endpoint nativo POST /api/card/:id/copy. Permite sobrescribir nombre, coleccion y descripcion en la copia. | | `metabase_copy_card_py_infra` | `def metabase_copy_card(client: MetabaseClient, card_id: int, name: str \| None = None, collection_id: int \| None = None, description: str \| None = None) -> dict` | Crea una copia de una card/pregunta en Metabase via el endpoint nativo POST /api/card/:id/copy. Permite sobrescribir nombre, coleccion y descripcion en la copia. |
| `metabase_copy_dashboard_py_infra` | `def metabase_copy_dashboard(client: MetabaseClient, dashboard_id: int, name: str \| None = None, collection_id: int \| None = None, description: str \| None = None, is_deep_copy: bool = False) -> dict` | Crea una copia de un dashboard en Metabase via POST /api/dashboard/:id/copy. Con is_deep_copy=True tambien clona las cards referenciadas. | | `metabase_copy_dashboard_py_infra` | `def metabase_copy_dashboard(client: MetabaseClient, dashboard_id: int, name: str \| None = None, collection_id: int \| None = None, description: str \| None = None, is_deep_copy: bool = False) -> dict` | Crea una copia de un dashboard en Metabase via POST /api/dashboard/:id/copy. Con is_deep_copy=True tambien clona las cards referenciadas. |
| `metabase_copy_dashcard_mappings_py_infra` | `def metabase_copy_dashcard_mappings(client: MetabaseClient, *, dashboard_id: int, source_card_id: int, dest_card_id: int) -> list[dict]` | Copia los parameter_mappings del primer dashcard con source_card_id al card destino (dest_card_id), devolviendo una lista nueva de mappings sin mutar el original. Util para replicar filtros de dashboard a cards nuevas sin copiar manualmente cada mapping. | | `metabase_copy_dashcard_mappings_py_infra` | `def metabase_copy_dashcard_mappings(client: MetabaseClient, *, dashboard_id: int, source_card_id: int, dest_card_id: int) -> list[dict]` | Copia los parameter_mappings del primer dashcard con source_card_id al card destino (dest_card_id), devolviendo una lista nueva de mappings sin mutar el original. Util para replicar filtros de dashboard a cards nuevas sin copiar manualmente cada mapping. |
@@ -134,6 +136,22 @@ dash = metabase_get_dashboard(client, dashboard_id=42)
cards = metabase_list_cards(client, collection_id=dash["collection_id"]) cards = metabase_list_cards(client, collection_id=dash["collection_id"])
``` ```
### Cliente autenticado desde `pass` (sin manejar credenciales a mano)
```python
import os, sys
sys.path.insert(0, os.path.join(os.environ["FN_REGISTRY_ROOT"], "python", "functions"))
from metabase import metabase_client_from_pass, metabase_get_dashboard
# Aurgi: API-key de una línea en pass (mb_...)
client = metabase_client_from_pass("metabase/aurgi-api-key", "https://reports.autingo.es", mode="api_key")
# Captación: secreto multi-línea (password + email:) → sesión
# client = metabase_client_from_pass("captacion/metabase", "http://localhost:3030", mode="session")
dash = metabase_get_dashboard(client, dashboard_id=734)
```
### Crear card + dashboard + ejecutar (Go) ### Crear card + dashboard + ejecutar (Go)
```bash ```bash
+12 -2
View File
@@ -15,12 +15,22 @@ Postgres es la **capa que sirve datos a las herramientas de BI** del stack (`Exc
| `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_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_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`). | | `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`). |
| `resolve_pg_dsn_py_infra` | `resolve_pg_dsn(project) -> dict` | Resuelve el DSN de Postgres de un proyecto conocido (`captacion`/`captacion_clientes` vía `CAPTACION_DSN`, `seo`/`seo_analytics` vía `SEO_DSN`) en este orden: (1) variable de entorno, (2) línea `<ENV_VAR>=` del `.env` del proyecto, (3) construido desde `pass` en runtime. Devuelve `{status, project, dsn, source}` (`source` = `env`\|`dotenv`\|`pass`) sin lanzar. Mapa de proyectos explícito en el código — añadir un proyecto = editar `_PROJECTS`. Nunca hardcodea el password. |
| `query_project_pg_py_pipelines` | `query_project_pg(project, sql, max_rows=10000) -> dict` | **Pipeline one-shot**: compone `resolve_pg_dsn` + `pg_query`. Lee el DSN del proyecto y ejecuta el SELECT en un solo paso, sin que el caller toque el DSN. Devuelve lo de `pg_query` (`{status, columns, rows, row_count, truncated}`) o propaga el error de resolución. Reemplaza el patrón inline de resolver el DSN a mano antes de consultar. |
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). 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 ## Ejemplo canónico
Crear una tabla inferida, hacer upsert idempotente y consultar (DSN desde `pass`): Atajo de un paso — consultar un proyecto conocido sin tocar el DSN (resuelto desde `.env`/`pass`):
```bash
cd /home/enmanuel/fn_registry
./fn run query_project_pg captacion "SELECT COUNT(*) AS n FROM product_opportunities"
# {"status":"ok","columns":["n"],"rows":[{"n":19}],"row_count":1,"truncated":false}
```
Camino completo — crear una tabla inferida, hacer upsert idempotente y consultar (DSN desde `pass`):
```bash ```bash
cd /home/enmanuel/fn_registry cd /home/enmanuel/fn_registry
@@ -42,7 +52,7 @@ PYEOF
## Gotchas del grupo ## 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. - **El DSN lleva credenciales — nunca hardcodear.** Resuélvelo desde `pass` (ej. `pass captacion/postgres`: L1 = password, resto `user/host/port/datadb`), o mejor con `resolve_pg_dsn(project)` que centraliza la convención por proyecto. No imprimas el DSN en logs. Para proyectos no mapeados en `resolve_pg_dsn`, pasa el DSN a `pg_query` directamente.
- **`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_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_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_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`.
+10
View File
@@ -7,6 +7,7 @@ Operar hosts remotos via SSH. Cubre: CRUD de `~/.ssh/config` (`ssh_config_add_en
| ID | Firma | Que hace | | ID | Firma | Que hace |
|---|---|---| |---|---|---|
| `audit_ssh_config_bash_cybersecurity` | `audit_ssh_config(config_path: string) -> void` | Audita la configuración de sshd_config evaluando parámetros de seguridad críticos (PermitRootLogin, PasswordAuthentication, Port, MaxAuthTries, X11Forwarding, AllowUsers). También revisa intentos de login fallidos en los logs y lista las claves autorizadas del usuario actual. | | `audit_ssh_config_bash_cybersecurity` | `audit_ssh_config(config_path: string) -> void` | Audita la configuración de sshd_config evaluando parámetros de seguridad críticos (PermitRootLogin, PasswordAuthentication, Port, MaxAuthTries, X11Forwarding, AllowUsers). También revisa intentos de login fallidos en los logs y lista las claves autorizadas del usuario actual. |
| `check_service_health_via_ssh_bash_infra` | `check_service_health_via_ssh <ssh_host> <local_url> [--token-from-env <remote_env> <VAR>] [--token <literal>] [--expect-status 200]` | Comprueba la salud de un service HTTP que solo escucha en loopback de un host remoto: entra por SSH, lee opcionalmente un bearer token de un `.env` remoto, y hace `curl` al endpoint local con `Authorization: Bearer`. Emite JSON (`{status, host, url, http_code, healthy}`), exit 0 si sano. El token nunca se imprime; prefiere `--token-from-env` sobre `--token` (este deja el secreto en argv local). |
| `docker_compose_remote_deploy_bash_infra` | `docker_compose_remote_deploy(host: string, remote_dir: string, branch: string, compose_files: string) -> json` | Despliega un stack Docker Compose en un host remoto via SSH. Verifica conectividad, hace git pull del branch indicado, actualiza imagenes con docker-compose pull y levanta/recrea los servicios modificados con docker-compose up -d. Soporta compose files adicionales. Retorna JSON con status, containers corriendo y duracion. | | `docker_compose_remote_deploy_bash_infra` | `docker_compose_remote_deploy(host: string, remote_dir: string, branch: string, compose_files: string) -> json` | Despliega un stack Docker Compose en un host remoto via SSH. Verifica conectividad, hace git pull del branch indicado, actualiza imagenes con docker-compose pull y levanta/recrea los servicios modificados con docker-compose up -d. Soporta compose files adicionales. Retorna JSON con status, containers corriendo y duracion. |
| `rsync_deploy_bash_infra` | `rsync_deploy(local_dir: string, ssh_alias: string, remote_dir: string) -> json` | Sincroniza un directorio local a un host remoto via rsync+SSH. Excluye archivos de desarrollo y bases de datos locales. Crea el directorio remoto si no existe. | | `rsync_deploy_bash_infra` | `rsync_deploy(local_dir: string, ssh_alias: string, remote_dir: string) -> json` | Sincroniza un directorio local a un host remoto via rsync+SSH. Excluye archivos de desarrollo y bases de datos locales. Crea el directorio remoto si no existe. |
| `setup_registry_api_bash_infra` | `setup_registry_api(ssh_host: string, api_token: string, basic_auth_user: string, basic_auth_pass: string) -> json` | Deploy completo de registry_api en VPS con Docker + Traefik (Coolify proxy). Sincroniza el repo via rsync, genera el hash bcrypt para basicAuth, sube el traefik-dynamic.yml, crea el .env con el token, hace docker compose build+up y verifica el health check. | | `setup_registry_api_bash_infra` | `setup_registry_api(ssh_host: string, api_token: string, basic_auth_user: string, basic_auth_pass: string) -> json` | Deploy completo de registry_api en VPS con Docker + Traefik (Coolify proxy). Sincroniza el repo via rsync, genera el hash bcrypt para basicAuth, sube el traefik-dynamic.yml, crea el .env con el token, hace docker compose build+up y verifica el health check. |
@@ -50,6 +51,15 @@ Operar hosts remotos via SSH. Cubre: CRUD de `~/.ssh/config` (`ssh_config_add_en
./fn run wait_for_http https://myapp.example.com/health 30 ./fn run wait_for_http https://myapp.example.com/health 30
``` ```
### Health-check de un service que solo escucha en loopback del host remoto
```bash
./fn run check_service_health_via_ssh om "http://127.0.0.1:8487/agents" \
--token-from-env /home/ubuntu/CodeProyects/agents_and_robots/.env AGENTS_API_KEY \
--expect-status 200
# {"status":"ok","host":"om","url":"http://127.0.0.1:8487/agents","http_code":200,"healthy":true}
```
## Fronteras ## Fronteras
- **NO genera ni rota llaves SSH automaticamente**. Asume llave ya generada con `ssh-keygen`. - **NO genera ni rota llaves SSH automaticamente**. Asume llave ya generada con `ssh-keygen`.
+40
View File
@@ -0,0 +1,40 @@
package infra
import (
"fmt"
"os/exec"
)
// NotifyDesktop sends a desktop notification on Linux via the `notify-send`
// binary (libnotify). It is impure: it shells out to an external program.
//
// Degradation is intentional and silent: if `notify-send` is not on the PATH,
// the function returns nil without error. A machine without a notification
// server is not a failure condition for the caller — the notification is simply
// skipped. Only a real execution failure of an existing `notify-send` is
// returned (wrapped with context).
//
// When `notify-send` is present it runs:
//
// notify-send --app-name=fleetview --urgency=normal -- <title> <body>
//
// The `--` separator guarantees that a title or body starting with "-" is
// treated as positional text, not as a flag. An empty title falls back to a
// sensible default; an empty body is accepted by notify-send as-is.
func NotifyDesktop(title, body string) error {
bin, err := exec.LookPath("notify-send")
if err != nil {
// No notification server / binary on this machine: skip silently.
return nil
}
if title == "" {
title = "Notificación"
}
cmd := exec.Command(bin, "--app-name=fleetview", "--urgency=normal", "--", title, body)
if err := cmd.Run(); err != nil {
return fmt.Errorf("notify-send failed: %w", err)
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
---
name: notify_desktop
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func NotifyDesktop(title, body string) error"
description: "Lanza una notificacion de escritorio en Linux via el binario notify-send (libnotify). Degradacion limpia: si notify-send no esta en el PATH devuelve nil sin error (no es fallo que la maquina no tenga servidor de notificaciones). Cuando existe ejecuta: notify-send --app-name=fleetview --urgency=normal -- <title> <body>, usando -- para que un texto que empiece por - no se interprete como flag. title vacio cae a un default; body puede ir vacio."
tags: [orchestration, notify, infra, desktop, libnotify]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["fmt", "os/exec"]
params:
- name: title
desc: "titulo de la notificacion; si es cadena vacia usa el default 'Notificación'"
- name: body
desc: "cuerpo de la notificacion; puede ir vacio (notify-send lo acepta)"
output: "error: nil si la notificacion se mostro o si notify-send no esta instalado (degradacion silenciosa); error envuelto con contexto solo si la ejecucion real de notify-send falla"
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/notify_desktop.go"
---
## Ejemplo
```go
// Avisar al usuario en el escritorio de que un agente termino.
err := infra.NotifyDesktop("✅ Agente terminó", "EDA dataset X — revísalo")
if err != nil {
// notify-send existe pero fallo al ejecutarse
log.Printf("no se pudo notificar: %v", err)
}
// En una maquina sin notify-send, err es nil y la notificacion se omite.
```
## Cuando usarla
Usala para avisar al usuario en el escritorio cuando un proceso largo o un agente termina su trabajo (fin de un EDA, build, deploy, o tarea desatendida del orquestador). Es el toque final tras una operacion que el humano no esta mirando en directo: dispara la notificacion y sigue, sin preocuparte de si la maquina destino tiene servidor de notificaciones.
## Gotchas
- **Solo Linux con servidor de notificaciones (libnotify).** Depende del binario `notify-send`; en otros SO no aplica.
- **Headless / sin DBUS no muestra nada pero NO falla.** Si `notify-send` no esta en el PATH, devuelve `nil` (degradacion silenciosa): el caller no se rompe por carecer de notificaciones.
- **Requiere sesion grafica activa.** Aunque `notify-send` exista, sin una sesion grafica con DBUS la notificacion puede no aparecer; en ese caso `Run()` puede devolver error real, que se devuelve envuelto.
- **`--` antes de los argumentos posicionales** evita que un `title`/`body` que empiece por `-` se interprete como flag. No lo quites.
+2
View File
@@ -20,6 +20,7 @@ from .fetch_hackernews_search import fetch_hackernews_search
from .score_demand_signal import score_demand_signal from .score_demand_signal import score_demand_signal
from .pull_gsc_search_analytics import pull_gsc_search_analytics from .pull_gsc_search_analytics import pull_gsc_search_analytics
from .summarize_table_duckdb import summarize_table_duckdb from .summarize_table_duckdb import summarize_table_duckdb
from .summarize_table_pg import summarize_table_pg
from .describe_numeric import describe_numeric from .describe_numeric import describe_numeric
from .summarize_categorical import summarize_categorical from .summarize_categorical import summarize_categorical
from .infer_semantic_type import infer_semantic_type from .infer_semantic_type import infer_semantic_type
@@ -46,6 +47,7 @@ from .build_eda_notebook import build_eda_notebook
__all__ = [ __all__ = [
"summarize_table_duckdb", "summarize_table_duckdb",
"summarize_table_pg",
"spearman_corr", "spearman_corr",
"cramers_v", "cramers_v",
"theils_u", "theils_u",
@@ -0,0 +1,83 @@
---
name: depth_to_relief_glb
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220) -> dict"
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth."
tags: [img-to-3d, datascience, mesh, glb, gltf, relief, heightmap, trimesh, 3d, texture]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: image
desc: "PIL.Image RGB usada como textura de la malla. Tipicamente la 'image' devuelta por estimate_image_depth (la imagen original)."
- name: depth
desc: "ndarray HxW float32 en [0,1] (1=mas cerca). Tipicamente el 'depth' devuelto por estimate_image_depth. Si ndim != 2 se devuelve status error."
- name: out_glb_path
desc: "Ruta de salida del .glb. El directorio padre debe existir (si no, la exportacion de trimesh falla -> status error)."
- name: z_scale
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas pronunciado/exagerado."
- name: max_dim
desc: "Lado maximo del grid tras downsample bilineal (default 220, ~48k vertices / ~96k caras). Controla resolucion de la malla vs tamano del .glb. Imagenes mayores se reducen; menores se dejan igual."
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/depth_to_relief_glb.py"
---
## Ejemplo
```python
# Requiere un venv con torch + transformers + trimesh + pillow (el de apps/img_to_3d_webapp/backend/.venv).
# Import PLANO a los modulos (el paquete datascience.__init__ arrastra deps de otros dominios).
import sys
sys.path.insert(0, "python/functions/datascience")
from estimate_image_depth import estimate_image_depth
from depth_to_relief_glb import depth_to_relief_glb
est = estimate_image_depth("apps/img_to_3d_webapp/samples/cats.jpg")
assert est["status"] == "ok"
res = depth_to_relief_glb(est["image"], est["depth"], "/tmp/cats_relief.glb", z_scale=0.35, max_dim=220)
print(res["status"], res["vertices"], res["faces"]) # ok 48400 96114
print(res["glb_path"]) # /tmp/cats_relief.glb (cargable con useGLTF/GLTFLoader)
```
Lanzable end-to-end (el demo CLI encadena estimate_image_depth internamente):
```bash
./fn run depth_to_relief_glb_py_datascience apps/img_to_3d_webapp/samples/cats.jpg /tmp/cats_relief.glb
# {"status": "ok", "glb_path": "/tmp/cats_relief.glb", "vertices": ..., "faces": ..., ...}
```
## Cuando usarla
Tras `estimate_image_depth`, cuando quieras un modelo 3D real (no solo el mapa de profundidad):
visualizar una foto en relieve navegable, exportar a un visor web (three.js `useGLTF`/`GLTFLoader`,
Babylon, model-viewer) o a cualquier herramienta que lea glTF. Es el paso 2 (final) del grupo
`img-to-3d`. Usa `max_dim` para equilibrar detalle vs peso del .glb y `z_scale` para exagerar o
suavizar el relieve.
## Gotchas
- **Impura**: escribe el archivo .glb en `out_glb_path`. El directorio padre debe existir o
`trimesh.export` falla (vuelve como status error, no crash).
- **Dep**: requiere `trimesh` (4.5.x) + `pillow` + `numpy`. `trimesh` se importa dentro de la
funcion. No esta en el venv del registry; vive en el venv de la app `img_to_3d_webapp`.
- **No es reconstruccion real de geometria**: es un heightmap (relieve 2.5D). Solo deforma un
plano segun la profundidad; no recupera las caras ocultas ni el volumen trasero del objeto.
- El downsample a `max_dim` usa interpolacion bilineal sobre el depth cuantizado a uint8 (0-255)
para reescalar; introduce una ligera perdida de precision en la profundidad de la malla.
- UV con V invertido (`1 - v`) por convencion glTF; la textura es la imagen convertida a RGB.
- `process=False` en Trimesh: no se hace merge de vertices ni limpieza, para preservar la
correspondencia 1:1 vertice<->pixel (necesaria para el mapeo UV del grid).
- **Import plano**: importa el modulo directo, NO `from datascience import ...` (el `__init__` del
paquete arrastra deps de otros dominios ausentes en el venv de vision). Ver misma gotcha en
`estimate_image_depth`.
@@ -0,0 +1,131 @@
"""
Construcción de una malla de relieve (heightmap) texturizada exportada como glTF binario (.glb).
Función del registry (grupo de capacidad `img-to-3d`, dominio `datascience`). Promovida desde la
app `img_to_3d_webapp`. A partir de un mapa de profundidad y la imagen original construye un grid
regular de vértices cuyo eje Z es la profundidad y mapea la imagen como textura mediante
coordenadas UV. El resultado es un modelo 3D navegable que conserva el aspecto de la imagen vista
en relieve, cargable con useGLTF / GLTFLoader directamente.
Impura: escribe el archivo .glb en disco.
"""
from __future__ import annotations
import numpy as np
from PIL import Image
def depth_to_relief_glb(
image: "Image.Image",
depth: "np.ndarray",
out_glb_path: str,
z_scale: float = 0.35,
max_dim: int = 220,
) -> dict:
"""
Construye una malla de relieve texturizada y la exporta como .glb.
Parámetros:
image: PIL.Image RGB usada como textura.
depth: ndarray HxW float32 en [0,1] (1 = más cerca de la cámara).
out_glb_path: ruta de salida del .glb.
z_scale: amplitud del relieve (fracción del lado de la malla). Default 0.35.
max_dim: lado máximo del grid tras downsample (controla nº de vértices/caras).
Default 220 (~48k vértices, ~96k caras).
Devuelve (dict, nunca lanza):
Éxito: {"status": "ok", "glb_path": out_glb_path, "vertices": int, "faces": int,
"height": H, "width": W}.
Error: {"status": "error", "error": str} (depth con forma inválida, directorio de
salida inexistente, fallo de exportación de trimesh).
"""
try:
import trimesh
depth = np.asarray(depth, dtype=np.float32)
if depth.ndim != 2:
raise ValueError(f"depth debe ser un array 2D HxW, recibido ndim={depth.ndim}")
H, W = depth.shape
# Downsample para acotar el número de vértices (max_dim^2 ~ 48k vértices a 220).
scale = max(H, W) / float(max_dim)
if scale > 1.0:
new_w, new_h = max(2, int(round(W / scale))), max(2, int(round(H / scale)))
depth_img = Image.fromarray((np.clip(depth, 0, 1) * 255).astype(np.uint8))
depth_img = depth_img.resize((new_w, new_h), Image.BILINEAR)
depth = np.asarray(depth_img, dtype=np.float32) / 255.0
H, W = depth.shape
# Coordenadas del grid: X corrige aspect ratio, Y hacia abajo, Z = profundidad.
aspect = W / float(H)
xs = np.linspace(-aspect / 2.0, aspect / 2.0, W, dtype=np.float32)
ys = np.linspace(0.5, -0.5, H, dtype=np.float32)
gx, gy = np.meshgrid(xs, ys)
gz = (depth * z_scale).astype(np.float32)
vertices = np.column_stack([gx.ravel(), gy.ravel(), gz.ravel()])
# Caras: dos triángulos por celda del grid.
idx = np.arange(H * W, dtype=np.int64).reshape(H, W)
v00 = idx[:-1, :-1].ravel()
v01 = idx[:-1, 1:].ravel()
v10 = idx[1:, :-1].ravel()
v11 = idx[1:, 1:].ravel()
faces = np.vstack(
[
np.column_stack([v00, v10, v11]),
np.column_stack([v00, v11, v01]),
]
)
# UV mapeando cada vértice al pixel de la imagen (V invertido para convención glTF).
u = np.linspace(0.0, 1.0, W, dtype=np.float32)
v = np.linspace(0.0, 1.0, H, dtype=np.float32)
uu, vv = np.meshgrid(u, v)
uv = np.column_stack([uu.ravel(), (1.0 - vv).ravel()])
visual = trimesh.visual.TextureVisuals(uv=uv, image=image.convert("RGB"))
mesh = trimesh.Trimesh(vertices=vertices, faces=faces, visual=visual, process=False)
mesh.export(out_glb_path)
return {
"status": "ok",
"glb_path": out_glb_path,
"vertices": int(vertices.shape[0]),
"faces": int(faces.shape[0]),
"height": int(H),
"width": int(W),
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
if __name__ == "__main__":
# Demo runner end-to-end para `fn run depth_to_relief_glb_py_datascience <image_path> <out.glb>`.
# Encadena estimate_image_depth (misma carpeta) para producir un .glb desde una imagen sin
# tener que pasar el ndarray por CLI. La función en sí toma (image, depth); esto es solo glue
# de demostración del flujo img→glb del grupo `img-to-3d`.
import json
import sys
if len(sys.argv) < 3:
print(json.dumps({"status": "error", "error": "uso: <image_path> <out_glb_path> [z_scale] [max_dim]"}))
sys.exit(1)
from estimate_image_depth import estimate_image_depth
img_path = sys.argv[1]
out_path = sys.argv[2]
zs = float(sys.argv[3]) if len(sys.argv) > 3 else 0.35
md = int(sys.argv[4]) if len(sys.argv) > 4 else 220
est = estimate_image_depth(img_path)
if est["status"] != "ok":
print(json.dumps(est))
sys.exit(1)
res = depth_to_relief_glb(est["image"], est["depth"], out_path, z_scale=zs, max_dim=md)
print(json.dumps(res))
if res["status"] != "ok":
sys.exit(1)
@@ -18,7 +18,7 @@ LLM, parseo) devuelve {status:'error', error:str}.
import json import json
from core import ask_llm from core.ask_llm import ask_llm
# Claves que el LLM debe devolver. Las que falten se rellenan con estos defaults. # Claves que el LLM debe devolver. Las que falten se rellenan con estos defaults.
_EXPECTED_KEYS = { _EXPECTED_KEYS = {
@@ -135,7 +135,9 @@ def test_eda_llm_insights_ok_with_monkeypatched_llm(monkeypatch):
"analyses": ["ventas por categoria"], "analyses": ["ventas por categoria"],
} }
import datascience.eda_llm_insights as mod import importlib
mod = importlib.import_module("datascience.eda_llm_insights")
monkeypatch.setattr( monkeypatch.setattr(
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: json.dumps(fake) mod, "ask_llm", lambda prompt, model="x", system="", echo=True: json.dumps(fake)
@@ -158,7 +160,9 @@ def test_eda_llm_insights_ok_with_monkeypatched_llm(monkeypatch):
def test_eda_llm_insights_fills_missing_keys(monkeypatch): def test_eda_llm_insights_fills_missing_keys(monkeypatch):
"""Si el LLM omite claves, se rellenan con defaults vacios.""" """Si el LLM omite claves, se rellenan con defaults vacios."""
import datascience.eda_llm_insights as mod import importlib
mod = importlib.import_module("datascience.eda_llm_insights")
monkeypatch.setattr( monkeypatch.setattr(
mod, mod,
@@ -184,7 +188,9 @@ def test_eda_llm_insights_error_on_empty_profile():
def test_eda_llm_insights_error_on_empty_llm_response(monkeypatch): def test_eda_llm_insights_error_on_empty_llm_response(monkeypatch):
import datascience.eda_llm_insights as mod import importlib
mod = importlib.import_module("datascience.eda_llm_insights")
monkeypatch.setattr( monkeypatch.setattr(
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: "" mod, "ask_llm", lambda prompt, model="x", system="", echo=True: ""
@@ -194,7 +200,9 @@ def test_eda_llm_insights_error_on_empty_llm_response(monkeypatch):
def test_eda_llm_insights_error_on_unparseable_llm_response(monkeypatch): def test_eda_llm_insights_error_on_unparseable_llm_response(monkeypatch):
import datascience.eda_llm_insights as mod import importlib
mod = importlib.import_module("datascience.eda_llm_insights")
monkeypatch.setattr( monkeypatch.setattr(
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: "sin json" mod, "ask_llm", lambda prompt, model="x", system="", echo=True: "sin json"
@@ -0,0 +1,87 @@
---
name: estimate_image_depth
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def estimate_image_depth(image_path: str, model_name: str = 'depth-anything/Depth-Anything-V2-Small-hf', device: str = 'auto', use_cache: bool = True) -> dict"
description: "Estima un mapa de profundidad monocular a partir de una sola imagen con Depth-Anything-V2 (transformers, GPU si hay). Devuelve el depth normalizado a [0,1] (1=mas cerca) y la PIL.Image original. Paso 1 del flujo img->3D (grupo img-to-3d): su salida alimenta depth_to_relief_glb."
tags: [img-to-3d, datascience, depth, depth-estimation, monocular, transformers, depth-anything, gpu, ml, computer-vision]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: image_path
desc: "Ruta a la imagen de entrada. Cualquier formato que PIL.Image.open abra (jpg, png, webp...). Si no existe o no es imagen valida, se devuelve status error."
- name: model_name
desc: "Id de modelo HuggingFace de depth-estimation. Default 'depth-anything/Depth-Anything-V2-Small-hf' (rapido). Variantes: ...-Base-hf, ...-Large-hf (mas precision, mas VRAM)."
- name: device
desc: "'auto' (GPU0 si torch.cuda.is_available() else CPU), 'cpu', o indice/cadena cuda explicita ('cuda:0', '0'). Forma 'cuda:N' no parseable cae a GPU0; un indice entero inexistente ('99') falla en inferencia y vuelve como status error."
- name: use_cache
desc: "True (default) reutiliza el pipeline cacheado por (model_name, device) a nivel de proceso (evita recargar pesos en cada llamada). False construye uno nuevo y no toca la cache."
output: "dict. Exito: {status:'ok', depth: ndarray HxW float32 en [0,1] (1=mas cerca de la camara), image: PIL.Image RGB original, height:int, width:int, model:str, device:str}. Error: {status:'error', error:str} (no lanza). El demo CLI (__main__) imprime un resumen JSON sin el ndarray ni la imagen."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/estimate_image_depth.py"
---
## Ejemplo
```python
# Requiere un venv con torch + transformers + pillow (p.ej. el de apps/img_to_3d_webapp/backend/.venv).
# Import PLANO al modulo: el paquete datascience.__init__ arrastra deps de otros dominios
# (bs4, duckdb...) que no estan en ese venv. Ver Gotchas.
import sys
sys.path.insert(0, "python/functions/datascience")
from estimate_image_depth import estimate_image_depth
res = estimate_image_depth("apps/img_to_3d_webapp/samples/cats.jpg") # device='auto' -> GPU si hay
print(res["status"]) # ok
print(res["height"], res["width"]) # p.ej. 1024 768
print(res["depth"].min(), res["depth"].max()) # 0.0 1.0 (normalizado)
# res["depth"] (ndarray) + res["image"] (PIL) alimentan depth_to_relief_glb.
```
Lanzable como demo (imprime resumen JSON, sin serializar el ndarray):
```bash
./fn run estimate_image_depth_py_datascience apps/img_to_3d_webapp/samples/cats.jpg
# {"status": "ok", "height": ..., "width": ..., "depth_min": 0.0, "depth_max": 1.0, ...}
```
## Cuando usarla
Cuando necesites un mapa de profundidad de una imagen 2D y NO tengas sensor de profundidad:
reconstruccion de relieve 3D (paso 1 de img->glb), efectos de paralaje, segmentacion por capas,
ordenacion de objetos por cercania. Es el primer paso del grupo `img-to-3d`: su `depth` + `image`
se pasan directamente a `depth_to_relief_glb_py_datascience` para generar el .glb. Para una sola
imagen monocular; no hace SLAM, multi-vista ni metrica absoluta (la profundidad es relativa).
## Gotchas
- **Impura**: carga un modelo HuggingFace (la primera vez DESCARGA pesos a `~/.cache/huggingface/`,
cientos de MB segun la variante) y corre inferencia en GPU/CPU. Requiere red en la primera carga.
- **Estado de proceso**: `_PIPE_CACHE` cachea el pipeline por (model_name, device) a nivel de
modulo para no recargar pesos en cada llamada. Es estado mutable compartido del proceso. Pasa
`use_cache=False` para construir uno aislado (no lo cachea ni lo lee). La cache persiste mientras
viva el interprete; en un servicio de larga duracion ocupa VRAM hasta que el proceso muere.
- **Deps pesadas**: requiere `torch`, `transformers` y `pillow` instalados. No estan en el venv del
registry; viven en el venv de la app `img_to_3d_webapp` (torch 2.5.1+cu124). `torch`/`transformers`
se importan dentro de la funcion, asi que el modulo se puede importar para introspeccion sin ellas.
- **device**: 'auto' usa GPU0 si `torch.cuda.is_available()`. El resolver es tolerante con la
forma `'cuda:N'`: si `N` no es parseable junto a 'cuda', cae a GPU0 (p.ej. `'cuda:99'` -> GPU0,
NO error). En cambio un indice ENTERO inexistente (`'99'`) se pasa tal cual a transformers y
falla en inferencia con `CUDA error: invalid device ordinal`, devuelto como `{status:'error'}`.
- La profundidad es **relativa y normalizada a [0,1]**, no metrica. 1 = mas cerca de la camara
(Depth-Anything devuelve disparidad). No comparable entre imagenes distintas.
- Nunca lanza: errores (ruta invalida, modelo no disponible, OOM de GPU) vuelven como
`{status:'error', error:str}`.
- **Import plano**: importa el modulo directo (`sys.path` a `python/functions/datascience` +
`from estimate_image_depth import estimate_image_depth`), NO `from datascience import ...`. El
`datascience.__init__` carga todo el dominio (scrapers con bs4, duckdb...) que no esta en el venv
de vision; el import del paquete fallaria por esas deps ajenas a esta funcion.
@@ -0,0 +1,135 @@
"""
Estimación de profundidad monocular a partir de una sola imagen con Depth-Anything-V2.
Función del registry (grupo de capacidad `img-to-3d`, dominio `datascience`). Promovida desde
la app `img_to_3d_webapp` para que cualquier artefacto pueda estimar un mapa de profundidad sin
reimplementar la carga del modelo HuggingFace ni la normalización del resultado.
Impura: descarga/carga pesos de un modelo de transformers, usa GPU si está disponible y mantiene
una caché de pipelines a nivel de proceso para no recargar en cada llamada.
"""
from __future__ import annotations
import numpy as np
from PIL import Image
# El pipeline de transformers es caro de instanciar (carga de pesos). Se cachea por
# (modelo, device) a nivel de módulo para que un servicio no recargue en cada request.
# Es estado mutable de PROCESO: documentado como impureza (ver .md "Gotchas"). Se puede
# desactivar por llamada con use_cache=False.
_PIPE_CACHE: dict = {}
def _resolve_device(device: str) -> int:
"""Resuelve el índice de device para transformers.pipeline (0=GPU0, -1=CPU)."""
import torch
if device == "cpu":
return -1
if device == "auto":
return 0 if torch.cuda.is_available() else -1
# device explícito tipo "cuda:0" o un índice
try:
return int(device)
except ValueError:
return 0 if device.startswith("cuda") else -1
def _build_pipe(model_name: str, device: str):
from transformers import pipeline
return pipeline("depth-estimation", model=model_name, device=_resolve_device(device))
def _get_pipe(model_name: str, device: str, use_cache: bool):
if not use_cache:
return _build_pipe(model_name, device)
key = (model_name, device)
pipe = _PIPE_CACHE.get(key)
if pipe is None:
pipe = _build_pipe(model_name, device)
_PIPE_CACHE[key] = pipe
return pipe
def estimate_image_depth(
image_path: str,
model_name: str = "depth-anything/Depth-Anything-V2-Small-hf",
device: str = "auto",
use_cache: bool = True,
) -> dict:
"""
Estima un mapa de profundidad monocular a partir de una única imagen.
Parámetros:
image_path: ruta a la imagen de entrada (cualquier formato que PIL abra).
model_name: id de modelo HuggingFace de estimación de profundidad.
device: "auto" (GPU si hay), "cpu", o índice/cadena cuda explícita ("cuda:0", "0").
use_cache: si True (default) reutiliza el pipeline cacheado por (modelo, device) a
nivel de proceso; si False construye uno nuevo y no toca la caché.
Devuelve (dict, nunca lanza):
Éxito: {"status": "ok", "depth": ndarray HxW float32 normalizado a [0,1]
(1 = más cerca de la cámara), "image": PIL.Image RGB original,
"height": H, "width": W, "model": model_name, "device": device}.
Error: {"status": "error", "error": str} (ruta inválida, modelo no disponible,
device inválido, fallo de inferencia).
"""
try:
image = Image.open(image_path).convert("RGB")
pipe = _get_pipe(model_name, device, use_cache)
result = pipe(image)
depth = np.asarray(result["depth"], dtype=np.float32)
# Normalizar a [0,1]. Depth-Anything devuelve disparidad relativa (mayor = más cerca).
d = depth - depth.min()
peak = d.max()
if peak > 0:
d = d / peak
H, W = d.shape
return {
"status": "ok",
"depth": d,
"image": image,
"height": int(H),
"width": int(W),
"model": model_name,
"device": device,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
if __name__ == "__main__":
# Demo runner para `fn run estimate_image_depth_py_datascience <image_path> [model] [device]`.
# Imprime un resumen JSON-serializable (el ndarray y la PIL.Image no se serializan).
import json
import sys
if len(sys.argv) < 2:
print(json.dumps({"status": "error", "error": "uso: <image_path> [model_name] [device]"}))
sys.exit(1)
path = sys.argv[1]
model = sys.argv[2] if len(sys.argv) > 2 else "depth-anything/Depth-Anything-V2-Small-hf"
dev = sys.argv[3] if len(sys.argv) > 3 else "auto"
res = estimate_image_depth(path, model_name=model, device=dev)
if res["status"] == "ok":
depth = res["depth"]
summary = {
"status": "ok",
"height": res["height"],
"width": res["width"],
"depth_min": float(depth.min()),
"depth_max": float(depth.max()),
"depth_mean": round(float(depth.mean()), 4),
"model": res["model"],
"device": res["device"],
}
print(json.dumps(summary))
else:
print(json.dumps(res))
sys.exit(1)
@@ -0,0 +1,78 @@
---
name: query_osint_db
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def query_osint_db(sql: str, base_url: str = 'http://127.0.0.1:8771', timeout: int = 30) -> dict"
description: "Ejecuta un SELECT contra el service osint_db (DuckDB, FastAPI single-writer en 127.0.0.1:8771) por HTTP POST a /api/query y devuelve {status, columns, rows, row_count} sin lanzar. Normaliza service caido a un error claro."
tags: [duckdb, osint, http, query, readonly]
params:
- name: sql
desc: "Sentencia SQL a ejecutar. Pensada para SELECT read-only; el osint_db la corre con una conexion DuckDB en modo solo lectura, asi que una escritura falla a nivel de service."
- name: base_url
desc: "URL base del service osint_db. Default 'http://127.0.0.1:8771'. Se le anade '/api/query' al hacer el POST."
- name: timeout
desc: "Timeout por peticion en segundos (default 30). El osint_db es local (loopback): si tarda mas, mejor degradar que colgar al llamante."
output: "dict. En exito reenvia el cuerpo del service: {status:'ok', columns:[str,...], rows:[{col:val,...},...], row_count:int, truncated:bool}. En error (sin lanzar): {status:'error', error:str}. Service inalcanzable -> error 'osint_db service not reachable on <url>: <detalle>'."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_query_ok_devuelve_cuerpo_del_service", "test_query_error_de_dominio_se_reenvia", "test_service_caido_devuelve_error_claro", "test_base_url_custom_se_respeta", "test_http_error_con_cuerpo_json_se_reenvia"]
test_file_path: "python/functions/datascience/query_osint_db_test.py"
file_path: "python/functions/datascience/query_osint_db.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.query_osint_db import query_osint_db
res = query_osint_db("SELECT COUNT(*) FROM personas")
# {'status': 'ok', 'columns': ['count_star()'], 'rows': [{'count_star()': 545}],
# 'row_count': 1, 'truncated': False}
print(res["rows"])
```
Lanzable directo:
```bash
./fn run query_osint_db_py_datascience
# o pasandole el SQL como arg:
python/.venv/bin/python3 python/functions/datascience/query_osint_db.py "SELECT COUNT(*) FROM personas"
```
## Cuando usarla
Cuando necesites leer la verdad OSINT que vive en el service osint_db (DuckDB, fuente
de verdad del proyecto osint: personas, dominios, network_scans, etc.). Sustituye el
patron inline repetido `curl -s -X POST 127.0.0.1:8771/api/query ...` por una sola
llamada con retorno estructurado. Usala antes de escribir un heredoc/curl a mano
contra ese endpoint.
## Gotchas
- El service `osint_db` debe estar arriba (escucha en `127.0.0.1:8771`). Si esta
caido, la funcion NO lanza: devuelve `{status:'error', error:'osint_db service not
reachable on <url>: ...'}`. Comprueba el status antes de leer `rows`.
- `osint_db` es **single-writer**: el endpoint `/api/query` es estrictamente
read-only (abre una conexion DuckDB read_only separada). Desde aqui usa **solo
SELECT** — una escritura fallara a nivel de service.
- El service responde **siempre HTTP 200**; el status real del dominio viaja en el
cuerpo (`status: 'ok'|'error'`). La funcion reenvia ese cuerpo tal cual en exito.
- El `max_rows` del service tiene tope (default 500, max 10000); para volcados
grandes pagina o usa la maestra directamente.
## Notas
Solo stdlib (urllib, json): wrapper de transporte puro, sin dependencias de runtime.
Sigue la convencion de retorno de `pg_query_py_infra` y `duckdb_query_readonly`
(`{status:'ok'|'error', ...}`). El service osint_db vive en
`projects/osint/apps/osint_db/` y su `/api/query` delega en `duckdb_query_readonly`.
@@ -0,0 +1,100 @@
"""Ejecuta un SELECT contra el service osint_db (DuckDB, 127.0.0.1:8771) por HTTP.
Funcion impura: hace un POST a ``{base_url}/api/query`` con el cuerpo JSON
``{"sql": sql}`` y devuelve el resultado sin lanzar excepciones, siguiendo el estilo
de pg_query / duckdb_query_readonly del registry: {status:'ok', ...} en exito y
{status:'error', error:str} en fallo.
El osint_db es un FastAPI single-writer sobre DuckDB que es la fuente de verdad del
proyecto osint. Su endpoint /api/query es estrictamente read-only (abre una conexion
DuckDB read_only separada) y responde SIEMPRE con HTTP 200; el status real del
dominio viaja en el cuerpo ({status, columns, rows, row_count, truncated} en exito,
o {status:'error', error}). Esta funcion reenvia ese cuerpo tal cual cuando es ok y
normaliza los errores de red (service caido, timeout, conexion rechazada) a un
{status:'error', ...} con un mensaje claro, para no tumbar al llamante.
Solo stdlib (urllib, json): el wrapper es transporte puro, no reimplementa la logica
del osint_db ni anade dependencias de runtime.
"""
import json
import urllib.error
import urllib.request
def query_osint_db(
sql: str,
base_url: str = "http://127.0.0.1:8771",
timeout: int = 30,
) -> dict:
"""Ejecuta un SELECT contra el service osint_db por HTTP y devuelve un dict.
Args:
sql: sentencia SQL a ejecutar. Pensada para SELECT read-only; el osint_db
la corre con una conexion DuckDB en modo solo lectura, asi que una
escritura fallara a nivel de service.
base_url: URL base del service osint_db (default
"http://127.0.0.1:8771"). Se le anade "/api/query" al hacer el POST.
timeout: timeout por peticion en segundos (default 30). El osint_db es
local (loopback): si tarda mas, mejor degradar que colgar al llamante.
Returns:
dict. En exito reenvia el cuerpo del service:
{status:'ok', columns:[str,...], rows:[{col:val, ...}, ...], row_count:int,
truncated:bool}. En error (sin lanzar): {status:'error', error:str}. Si el
service no es alcanzable (no arrancado, conexion rechazada, host caido) el
error es "osint_db service not reachable on <url>: <detalle>".
"""
url = base_url.rstrip("/") + "/api/query"
data = json.dumps({"sql": sql}).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={
"Content-Type": "application/json",
"Accept": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
except urllib.error.HTTPError as exc:
# El contrato del osint_db es 200 siempre; un HTTPError es anomalo. Intenta
# leer el cuerpo (puede traer {status:error,...}); si no, error claro.
try:
body = json.loads(exc.read().decode("utf-8"))
if isinstance(body, dict):
return body
except (ValueError, OSError):
pass
return {
"status": "error",
"error": f"osint_db returned HTTP {exc.code} on {url}",
}
except (urllib.error.URLError, OSError) as exc:
return {
"status": "error",
"error": f"osint_db service not reachable on {url}: {exc}",
}
try:
parsed = json.loads(raw)
except ValueError as exc:
return {
"status": "error",
"error": f"osint_db returned non-JSON response on {url}: {exc}",
}
if not isinstance(parsed, dict):
return {
"status": "error",
"error": f"osint_db returned unexpected JSON type on {url}",
}
return parsed
if __name__ == "__main__":
import sys
query = sys.argv[1] if len(sys.argv) > 1 else "SELECT COUNT(*) FROM personas"
print(json.dumps(query_osint_db(query), ensure_ascii=False, indent=2))
@@ -0,0 +1,117 @@
"""Tests para query_osint_db.
Mockean urllib.request.urlopen para no depender del service osint_db vivo:
se interceptan el exito (cuerpo {status:ok,...}), el error de dominio del service
({status:error,...}) y el error de red (conexion rechazada).
"""
import io
import json
import urllib.error
from query_osint_db import query_osint_db
class _FakeResponse:
"""Context manager que imita la respuesta de urllib.request.urlopen."""
def __init__(self, payload: dict):
self._raw = json.dumps(payload).encode("utf-8")
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return self._raw
def test_query_ok_devuelve_cuerpo_del_service(monkeypatch):
payload = {
"status": "ok",
"columns": ["count_star()"],
"rows": [{"count_star()": 42}],
"row_count": 1,
"truncated": False,
}
captured = {}
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
captured["body"] = json.loads(req.data.decode("utf-8"))
captured["method"] = req.get_method()
return _FakeResponse(payload)
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
result = query_osint_db("SELECT COUNT(*) FROM personas")
assert result == payload
assert result["status"] == "ok"
assert result["rows"] == [{"count_star()": 42}]
assert captured["url"] == "http://127.0.0.1:8771/api/query"
assert captured["body"] == {"sql": "SELECT COUNT(*) FROM personas"}
assert captured["method"] == "POST"
def test_query_error_de_dominio_se_reenvia(monkeypatch):
payload = {"status": "error", "error": "Catalog Error: Table no existe"}
def fake_urlopen(req, timeout=None):
return _FakeResponse(payload)
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
result = query_osint_db("SELECT * FROM tabla_inexistente")
assert result["status"] == "error"
assert "no existe" in result["error"]
def test_service_caido_devuelve_error_claro(monkeypatch):
def fake_urlopen(req, timeout=None):
raise urllib.error.URLError("Connection refused")
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
result = query_osint_db("SELECT 1")
assert result["status"] == "error"
assert "osint_db service not reachable" in result["error"]
assert "http://127.0.0.1:8771/api/query" in result["error"]
def test_base_url_custom_se_respeta(monkeypatch):
captured = {}
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
return _FakeResponse({"status": "ok", "rows": [], "row_count": 0})
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
query_osint_db("SELECT 1", base_url="http://10.0.0.5:9000/")
assert captured["url"] == "http://10.0.0.5:9000/api/query"
def test_http_error_con_cuerpo_json_se_reenvia(monkeypatch):
body = json.dumps({"status": "error", "error": "boom"}).encode("utf-8")
def fake_urlopen(req, timeout=None):
raise urllib.error.HTTPError(
url=req.full_url,
code=500,
msg="Internal Server Error",
hdrs=None,
fp=io.BytesIO(body),
)
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
result = query_osint_db("SELECT 1")
assert result["status"] == "error"
assert result["error"] == "boom"
+54 -21
View File
@@ -15,40 +15,73 @@ from datascience import (
) )
def _to_numeric_subset(columns: dict) -> dict: def _pf(v):
"""Extrae las columnas numericas como {nombre: [float values]}. """Parsea un valor a float; devuelve None si es None/bool/no parseable."""
if v is None or isinstance(v, bool):
return None
try:
return float(v)
except (TypeError, ValueError):
return None
Solo se quedan las columnas con ``type == "numeric"``. Para cada una, los
valores se convierten a float cuando es posible y los que son None o no def _to_numeric_subset(columns: dict) -> dict:
parseables se descartan (la lista resultante puede ser mas corta que la """Extrae las columnas numericas alineadas por fila (listwise deletion).
original). Mantiene el orden de aparicion de las columnas.
Solo se quedan las columnas con ``type == "numeric"``. CLAVE: la alineacion
por fila se preserva. Pasos:
1. Descarta columnas numericas con menos del 50% de valores parseables
(evita que una columna casi-toda-nula tire todas las filas en el paso 3).
2. Sobre las columnas buenas, conserva SOLO las filas en las que TODAS
tienen un valor numerico (listwise deletion).
El resultado es un mapa {nombre: [float, ...]} donde todas las listas tienen
la MISMA longitud (filas completas) — requisito de PCA/KMeans/IsolationForest
(matriz rectangular sin NaN). El bug previo descartaba None por columna,
dejando longitudes desiguales y reventando sklearn con ValueError.
Args: Args:
columns: mapa {nombre_columna: {"values": list, "type": str}}. columns: mapa {nombre_columna: {"values": list, "type": str}}; las listas
llegan alineadas por fila (misma longitud, None donde no hay dato).
Returns: Returns:
dict {nombre_columna: [float, ...]} solo con columnas numericas. dict {nombre_columna: [float, ...]} con columnas numericas de igual
longitud. Vacio si no hay columnas numericas validas.
""" """
numeric: dict[str, list] = {}
if not isinstance(columns, dict): if not isinstance(columns, dict):
return numeric return {}
raw: dict[str, list] = {}
for name, meta in columns.items(): for name, meta in columns.items():
if not isinstance(meta, dict): if not isinstance(meta, dict):
continue continue
if meta.get("type") != "numeric": if meta.get("type") != "numeric":
continue continue
values = meta.get("values") values = meta.get("values")
if not isinstance(values, (list, tuple)): if isinstance(values, (list, tuple)):
continue raw[name] = list(values)
parsed: list[float] = [] if not raw:
for v in values: return {}
if v is None or isinstance(v, bool):
continue # Longitud comun (min, defensivo si llegaran desalineadas).
try: n = min(len(v) for v in raw.values())
parsed.append(float(v)) if n == 0:
except (TypeError, ValueError): return {}
continue
numeric[name] = parsed # 1) Parsea por celda y descarta columnas con <50% de valores parseables.
good: dict[str, list] = {}
for name, values in raw.items():
parsed = [_pf(values[i]) for i in range(n)]
if sum(1 for x in parsed if x is not None) >= 0.5 * n:
good[name] = parsed
if not good:
return {}
# 2) Listwise: conserva solo filas donde TODAS las columnas tienen valor.
names = list(good.keys())
numeric: dict[str, list] = {name: [] for name in names}
for i in range(n):
if all(good[name][i] is not None for name in names):
for name in names:
numeric[name].append(good[name][i])
return numeric return numeric
@@ -0,0 +1,106 @@
---
name: summarize_table_pg
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def summarize_table_pg(dsn: str, table: str, schema: str = \"public\", high_card_ratio: float = 0.9) -> dict"
description: "Adaptador PostgreSQL del perfilado base del grupo eda: espejo de summarize_table_duckdb. Perfila una tabla PostgreSQL con SQL push-down (count, count(DISTINCT), min/max/avg/stddev_samp, percentile_cont) sin traer filas a RAM, y devuelve EXACTAMENTE el mismo esqueleto TableProfile (mismas claves) para que el resto del grupo eda lo consuma igual con fuente PostgreSQL. dict-no-throw."
tags: [eda, postgres, postgresql, profiling, datascience, exploratory-data-analysis, table-profile]
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 (se propaga el error de pg_query)."
- name: table
desc: "Nombre de la tabla a perfilar. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el SQL (los identificadores no son parametrizables en el cuerpo del SELECT)."
- name: schema
desc: "Schema PostgreSQL donde vive la tabla (default 'public'). Se valida con el mismo patron y se cita."
- name: high_card_ratio
desc: "Umbral de unicidad (unique_pct, 0-1) a partir del cual una columna categorical recibe el flag high_cardinality. Default 0.9."
output: "dict dict-no-throw. En exito {status:'ok', profile: TableProfile} con source='postgres' y el MISMO shape que summarize_table_duckdb (n_rows/n_cols, type_breakdown, constant_cols, all_null_cols, null_cell_pct y columns[] de ColumnProfile con name/physical_type/inferred_type/semantic_type/count/null_count/null_pct/distinct_count/unique_pct/flags y sub-dict numeric con min,max,mean,std,p25,p50,p75 y el resto en None). En error {status:'error', error:str}. Claves estadisticas finas (skew, kurtosis, histograma, percentiles finos, moda, outliers, correlaciones, key_candidates, quality_score) quedan en None/[] para que otras funciones del grupo eda las completen."
uses_functions: [pg_query_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_shape_y_metadatos_tabla", "test_column_profile_shape", "test_null_pct_total", "test_distinct_no_excede_filas", "test_type_breakdown", "test_tabla_invalida_devuelve_error", "test_schema_invalido_devuelve_error", "test_tabla_inexistente_devuelve_error", "test_error_de_lectura_pg_se_propaga"]
test_file_path: "python/functions/datascience/summarize_table_pg_test.py"
file_path: "python/functions/datascience/summarize_table_pg.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import summarize_table_pg
# Perfila la tabla `trends` del PostgreSQL del proyecto captacion_clientes
# (la misma base que alimenta Metabase).
res = summarize_table_pg(
dsn="postgresql://captacion:secret@localhost:5433/trends",
table="amazon_bestsellers",
schema="public",
high_card_ratio=0.9,
)
if res["status"] == "ok":
p = res["profile"]
print(f"{p['table']}: {p['n_rows']} filas x {p['n_cols']} cols (source={p['source']})")
print("type_breakdown:", p["type_breakdown"])
for col in p["columns"]:
print(col["name"], col["inferred_type"], "nulls=", col["null_pct"], col["flags"])
else:
print("error:", res["error"])
```
## Cuando usarla
- Cuando hagas EDA de una tabla PostgreSQL que no conoces y necesites el esqueleto barato de su perfil (tipos inferidos, nulos, cardinalidad, flags) **antes** de gastar en estadistica fina. Tipico: las bases PostgreSQL conectadas a Metabase (trends, captacion_clientes, etc.).
- Como adaptador PostgreSQL del grupo `eda`: produce el mismo TableProfile que `summarize_table_duckdb`, de modo que `profile_table` y el resto del grupo funcionan igual cambiando solo la fuente.
- Cuando quieras perfilar tablas grandes sin traer filas a RAM: todo se calcula con agregados (count, count(DISTINCT), min/max/avg/stddev_samp, percentile_cont) que hacen push-down en el motor de PostgreSQL.
## Gotchas
- **Impura**: lee de un servidor PostgreSQL via `pg_query` (transaccion read-only, nunca escribe). Requiere `psycopg2` (ya en `python/.venv`) y un DSN valido; un servidor inalcanzable devuelve `{status:'error'}` sin lanzar.
- **`distinct_count` exacto solo hasta 200000 filas**: para `n_rows <= 200000` se calcula `count(DISTINCT col)` EXACTO en la query agregada por columna. Por encima de ese umbral NO se estima (PostgreSQL no trae HyperLogLog de serie sin extension) y `distinct_count` se capa de forma conservadora a `min(count_no_nulo, n_rows)`. En ambos casos `unique_pct = min(distinct_count / n_rows, 1.0)`, asi que nunca excede 1.0. Por encima de 200k filas los flags `possible_id` / `high_cardinality` derivan de esa cota conservadora, no de un distinct real.
- **El shape es identico a `summarize_table_duckdb`** (mismas claves de TableProfile y ColumnProfile, mismo sub-dict `numeric`) para que `profile_table` y el grupo `eda` lo consuman sin distinguir la fuente. `source` es `"postgres"` (vs `"duckdb"`). NO calcula skew, kurtosis, histograma, percentiles finos (p1/p5/p95/p99), moda, outliers, correlaciones, key_candidates ni quality_score: esas claves quedan en `None`/`[]` para otras funciones del grupo. El sub-dict `numeric` solo trae min, max, mean, std, p25, p50, p75 (estos tres ultimos via `percentile_cont WITHIN GROUP`).
- **Identificadores (schema/tabla/columna) se interpolan citados, no son parametrizables**: por eso `table` y `schema` se validan contra `^[A-Za-z_][A-Za-z0-9_]*$` antes de citarlos con comillas dobles. Un nombre invalido (con `;`, espacios, etc.) devuelve `{status:'error'}` sin tocar la base. Los **valores** (schema/table de la query a `information_schema`) si van por parametros posicionales `%s`.
- **`count` del ColumnProfile es el no-nulo** (`count(col)`); `null_count = n_rows - count`. Una tabla con 0 filas devuelve perfiles con `null_pct=0.0` y `distinct_count=0`.
## Notas
Contrato compartido por todo el grupo `eda` (identico a `summarize_table_duckdb`,
mantener estable):
```text
TableProfile = {
table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows,
duplicate_pct, constant_cols:[str], all_null_cols:[str], null_cell_pct,
type_breakdown:{numeric, categorical, datetime, text, boolean},
columns:[ColumnProfile], correlations, key_candidates:[str], quality_score,
llm, models
}
ColumnProfile = {
name, physical_type, inferred_type, semantic_type, count, n_rows, null_count,
null_pct, empty_count, empty_pct, distinct_count, unique_pct, flags:[str],
quality_score, numeric:<sub>|None, categorical:<sub>|None, datetime:<sub>|None
}
numeric_sub = {
min, max, mean, median, mode, std, variance, cv, p1, p5, p25, p50, p75, p95,
p99, iqr, skew, kurtosis, n_outliers, outlier_pct, zero_pct, negative_pct,
distribution_type, histogram
}
```
Mapeo de `data_type` (information_schema) PostgreSQL a `inferred_type`:
smallint/integer/bigint/numeric/decimal/real/double precision/serial* -> numeric;
date/time*/timestamp* -> datetime; boolean -> boolean; text/varchar/character* ->
categorical si `distinct_count <= 50` o `distinct_count/n_rows < 0.5`, si no text;
el resto (json, jsonb, uuid, array, bytea, ...) -> text.
Flags por columna: `constant` (distinct_count<=1), `possible_id` (unique_pct>=0.99
y null_pct==0), `high_cardinality` (categorical con unique_pct>=high_card_ratio),
`mostly_null` (null_pct>0.5).
@@ -0,0 +1,377 @@
"""summarize_table_pg — perfil base de una tabla PostgreSQL con SQL push-down.
Funcion impura: lee de un servidor PostgreSQL a traves de la primitiva read-only
del grupo `postgres`, `pg_query`. Es el adaptador PostgreSQL del corazon del grupo
de capacidad `eda` (exploratory data analysis), espejo de `summarize_table_duckdb`:
construye EXACTAMENTE el mismo esqueleto de TableProfile (mismas claves) usando
queries agregadas que hacen push-down en el motor de PostgreSQL y NO traen filas a
RAM (count, count(DISTINCT), min/max/avg/stddev, percentile_cont).
Lo que NO calcula aqui (a proposito, para ser barata): skew, kurtosis, histograma,
percentiles finos (p1/p5/p95/p99), moda, outliers, correlaciones, key_candidates,
quality_score ni el semantic_type. Esas claves quedan en None / [] para que las
rellenen luego otras funciones del grupo `eda` sobre una muestra. El contrato de
claves (TableProfile / ColumnProfile) es compartido por todo el grupo `eda` y es
identico al de `summarize_table_duckdb`, de modo que `profile_table` y el resto del
grupo consumen el resultado igual con fuente PostgreSQL.
Estilo dict-no-throw del grupo: nunca lanza; captura cualquier error y devuelve
{status:'error', error:str}.
"""
import re
from datetime import datetime, timezone
from infra import pg_query
# Identificador SQL valido. PostgreSQL no admite parametros posicionales para el
# nombre de tabla/columna en el cuerpo del SELECT, asi que hay que validar e
# interpolar citado con comillas dobles.
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Umbral de filas por debajo del cual calculamos COUNT(DISTINCT) EXACTO. Por
# encima cap el distinct a n_rows (no estimamos con HLL: PostgreSQL no lo da de
# serie sin extension). Documentado en el .md.
_EXACT_DISTINCT_MAX_ROWS = 200_000
# Tipos PostgreSQL (data_type de information_schema) que mapean a "numeric".
_NUMERIC_TYPES = {
"smallint", "integer", "bigint",
"decimal", "numeric", "real", "double precision",
"smallserial", "serial", "bigserial",
}
# Tipos PostgreSQL que mapean a "datetime".
_DATETIME_TYPES = {
"date", "time", "timestamp",
"timestamp without time zone", "timestamp with time zone",
"time without time zone", "time with time zone",
}
# Tipos PostgreSQL textuales (candidatos a categorical/text).
_TEXT_TYPES = {
"text", "character varying", "varchar", "character", "char", "bpchar",
}
# Claves del sub-dict numeric. summarize solo rellena unas pocas; el resto
# quedan en None hasta que una funcion de muestreo las complete.
_NUMERIC_SUB_KEYS = (
"min", "max", "mean", "median", "mode", "std", "variance", "cv",
"p1", "p5", "p25", "p50", "p75", "p95", "p99", "iqr",
"skew", "kurtosis", "n_outliers", "outlier_pct", "zero_pct",
"negative_pct", "distribution_type", "histogram",
)
def _base_data_type(data_type: str) -> str:
"""Normaliza un data_type de information_schema a su forma base en minusculas.
information_schema.columns.data_type ya viene sin parametros (p.ej. "numeric"
en vez de "numeric(10,2)" y "character varying" en vez de "varchar(50)"), pero
normalizamos a minusculas y quitamos espacios laterales por seguridad.
"""
return (data_type or "").strip().lower()
def _infer_type(data_type: str, distinct_count, n_rows: int) -> str:
"""Mapea el data_type PostgreSQL al inferred_type del contrato eda.
numeric / datetime / boolean salen directos del tipo. Para los tipos textuales
se decide entre categorical y text con la misma heuristica de cardinalidad que
el adaptador DuckDB: categorical si distinct_count <= 50 o
distinct_count/n_rows < 0.5; si no text.
"""
base = _base_data_type(data_type)
if base in _NUMERIC_TYPES:
return "numeric"
if base in _DATETIME_TYPES:
return "datetime"
if base in ("boolean", "bool"):
return "boolean"
if base in _TEXT_TYPES:
au = distinct_count if distinct_count is not None else 0
if n_rows <= 0:
return "categorical"
if au <= 50 or (au / n_rows) < 0.5:
return "categorical"
return "text"
# Tipos complejos (json, jsonb, uuid, array, bytea, ...): tratamos como text.
return "text"
def _to_float(value):
"""Convierte a float un valor agregado de PostgreSQL (Decimal/str/None).
pg_query normaliza Decimal a float, pero min/max de columnas no numericas (o
valores no convertibles) caen aqui y devolvemos None.
"""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _to_int(value):
"""Convierte a int de forma defensiva (count(*), count(col) vienen como int)."""
if value is None:
return 0
try:
return int(value)
except (TypeError, ValueError):
return 0
def summarize_table_pg(
dsn: str,
table: str,
schema: str = "public",
high_card_ratio: float = 0.9,
) -> dict:
"""Perfila una tabla PostgreSQL con SQL push-down (sin traer filas a RAM).
Devuelve el MISMO esqueleto TableProfile que summarize_table_duckdb (mismas
claves exactas), para que el resto del grupo `eda` funcione igual con fuente
PostgreSQL. dict-no-throw.
Args:
dsn: cadena de conexion PostgreSQL, p.ej.
"postgresql://user:pass@localhost:5432/mydb". Un DSN invalido o un
servidor inalcanzable devuelve {status:'error', ...} (no lanza).
table: nombre de la tabla a perfilar. Se valida contra
^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el SQL (los identificadores no
son parametrizables).
schema: schema PostgreSQL donde vive la tabla (default "public"). Se valida
con el mismo patron y se cita.
high_card_ratio: umbral de unicidad (unique_pct) a partir del cual una
columna categorical se marca con el flag "high_cardinality". Default 0.9.
Returns:
dict. En exito: {status:'ok', profile: <TableProfile>}. En error (sin
lanzar): {status:'error', error:str}.
"""
try:
if not _IDENT_RE.match(table or ""):
return {
"status": "error",
"error": (
f"nombre de tabla invalido: {table!r} "
"(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)"
),
}
if not _IDENT_RE.match(schema or ""):
return {
"status": "error",
"error": (
f"nombre de schema invalido: {schema!r} "
"(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)"
),
}
qtable = f'"{schema}"."{table}"'
# 1) Columnas + tipos desde information_schema (parametros posicionales).
cols_res = pg_query(
dsn,
"SELECT column_name, data_type FROM information_schema.columns "
"WHERE table_schema = %s AND table_name = %s "
"ORDER BY ordinal_position",
params=[schema, table],
)
if cols_res["status"] != "ok":
return {"status": "error", "error": cols_res["error"]}
col_rows = cols_res["rows"]
if not col_rows:
return {
"status": "error",
"error": (
f"tabla no encontrada o sin columnas: {schema}.{table}"
),
}
col_meta = [
(r.get("column_name"), r.get("data_type")) for r in col_rows
]
# 2) Numero total de filas.
count_res = pg_query(dsn, f"SELECT count(*) AS n FROM {qtable}")
if count_res["status"] != "ok":
return {"status": "error", "error": count_res["error"]}
n_rows = _to_int(count_res["rows"][0]["n"]) if count_res["rows"] else 0
# 3) Por columna: una query agregada con push-down en el motor. Combina
# count no-nulo + count(DISTINCT) (exacto si n_rows <= umbral) +, para
# columnas numericas, min/max/avg/stddev_samp/percentiles. No trae filas.
exact_distinct_ok = (
0 < n_rows <= _EXACT_DISTINCT_MAX_ROWS
)
columns = []
for name, data_type in col_meta:
if not _IDENT_RE.match(name or ""):
# Columna con identificador no estandar: la perfilamos sin
# agregados numericos (defensivo, no deberia pasar en information_schema).
columns.append(
_build_column_profile(
name, data_type, n_rows, high_card_ratio,
non_null=n_rows, distinct=None, agg=None,
)
)
continue
qcol = f'"{name}"'
base_type = _base_data_type(data_type)
is_numeric = base_type in _NUMERIC_TYPES
select_parts = [f"count({qcol}) AS non_null"]
if exact_distinct_ok:
select_parts.append(f"count(DISTINCT {qcol}) AS distinct_n")
if is_numeric:
select_parts.extend([
f"min({qcol}) AS mn",
f"max({qcol}) AS mx",
f"avg({qcol}) AS av",
f"stddev_samp({qcol}) AS sd",
f"percentile_cont(0.25) WITHIN GROUP (ORDER BY {qcol}) AS p25",
f"percentile_cont(0.5) WITHIN GROUP (ORDER BY {qcol}) AS p50",
f"percentile_cont(0.75) WITHIN GROUP (ORDER BY {qcol}) AS p75",
])
agg_sql = f"SELECT {', '.join(select_parts)} FROM {qtable}"
agg_res = pg_query(dsn, agg_sql)
if agg_res["status"] != "ok":
return {"status": "error", "error": agg_res["error"]}
agg = agg_res["rows"][0] if agg_res["rows"] else {}
non_null = _to_int(agg.get("non_null"))
distinct = (
_to_int(agg.get("distinct_n")) if exact_distinct_ok else None
)
columns.append(
_build_column_profile(
name, data_type, n_rows, high_card_ratio,
non_null=non_null, distinct=distinct,
agg=agg if is_numeric else None,
)
)
type_breakdown = {
"numeric": 0,
"categorical": 0,
"datetime": 0,
"text": 0,
"boolean": 0,
}
for col in columns:
it = col["inferred_type"]
if it in type_breakdown:
type_breakdown[it] += 1
constant_cols = [c["name"] for c in columns if "constant" in c["flags"]]
all_null_cols = [c["name"] for c in columns if c["null_pct"] == 1.0]
null_cell_pct = (
sum(c["null_pct"] for c in columns) / len(columns) if columns else 0.0
)
profile = {
"table": table,
"source": "postgres",
"profiled_at": datetime.now(timezone.utc).isoformat(),
"n_rows": n_rows,
"n_cols": len(columns),
"size_bytes": None,
"duplicate_rows": None,
"duplicate_pct": None,
"constant_cols": constant_cols,
"all_null_cols": all_null_cols,
"null_cell_pct": null_cell_pct,
"type_breakdown": type_breakdown,
"columns": columns,
"correlations": None,
"key_candidates": [],
"quality_score": None,
"llm": None,
"models": None,
}
return {"status": "ok", "profile": profile}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
def _build_column_profile(
name: str,
data_type: str,
n_rows: int,
high_card_ratio: float,
non_null: int,
distinct,
agg: dict = None,
) -> dict:
"""Construye un ColumnProfile del contrato eda a partir de los agregados PG.
name/data_type: metadata de information_schema.
non_null: count(col) no-nulo de la query agregada.
distinct: count(DISTINCT col) exacto si n_rows <= umbral; None si por encima
(entonces se capa a n_rows).
agg: fila de agregados numericos (min/max/avg/stddev/p25/p50/p75) o None para
columnas no numericas.
El shape devuelto es IDENTICO al de summarize_table_duckdb._build_column_profile.
"""
null_count = n_rows - non_null if n_rows > 0 else 0
if null_count < 0:
null_count = 0
null_pct = (null_count / n_rows) if n_rows > 0 else 0.0
# distinct_count: exacto si disponible; si no, capado a n_rows.
if distinct is not None:
distinct_count = min(distinct, n_rows) if n_rows > 0 else distinct
else:
# Tabla grande (> umbral): no calculamos distinct exacto; lo capamos a
# non_null como cota superior conservadora (a lo sumo tantos distintos
# como valores no nulos), y a su vez a n_rows.
distinct_count = min(non_null, n_rows) if n_rows > 0 else non_null
inferred_type = _infer_type(data_type, distinct_count, n_rows)
unique_pct = min(distinct_count / n_rows, 1.0) if n_rows > 0 else 0.0
numeric = None
if inferred_type == "numeric":
numeric = {k: None for k in _NUMERIC_SUB_KEYS}
if agg is not None:
numeric["min"] = _to_float(agg.get("mn"))
numeric["max"] = _to_float(agg.get("mx"))
numeric["mean"] = _to_float(agg.get("av"))
numeric["std"] = _to_float(agg.get("sd"))
numeric["p25"] = _to_float(agg.get("p25"))
numeric["p50"] = _to_float(agg.get("p50"))
numeric["p75"] = _to_float(agg.get("p75"))
flags = []
if distinct_count <= 1:
flags.append("constant")
if unique_pct >= 0.99 and null_pct == 0:
flags.append("possible_id")
if inferred_type == "categorical" and unique_pct >= high_card_ratio:
flags.append("high_cardinality")
if null_pct > 0.5:
flags.append("mostly_null")
return {
"name": name,
"physical_type": data_type,
"inferred_type": inferred_type,
"semantic_type": "",
"count": non_null,
"n_rows": n_rows,
"null_count": null_count,
"null_pct": null_pct,
"empty_count": None,
"empty_pct": None,
"distinct_count": distinct_count,
"unique_pct": unique_pct,
"flags": flags,
"quality_score": None,
"numeric": numeric,
"categorical": None,
"datetime": None,
}
@@ -0,0 +1,253 @@
"""Tests para summarize_table_pg sin servidor PostgreSQL.
Monkeypatchea el primitivo de lectura PG (`pg_query`, importado en el modulo) para
devolver filas simuladas: introspeccion de information_schema, count(*) y los
agregados por columna. Asserta el shape del TableProfile/ColumnProfile (claves,
tipos inferidos, flags, sub-dict numeric) — identico al de summarize_table_duckdb.
No requiere PostgreSQL real.
"""
import sys
import pytest
from .summarize_table_pg import summarize_table_pg
# El objeto-modulo real donde vive la funcion (robusto frente al shadowing
# nombre-modulo/funcion del __init__ y al doble-import de pytest): es el modulo
# cuyo global `pg_query` usa summarize_table_pg, asi el monkeypatch surte efecto.
mod = sys.modules[summarize_table_pg.__module__]
# Tabla simulada `ventas` (mismo esquema conceptual que el test de duckdb):
# id INTEGER -> unico, sin nulls -> possible_id (numeric)
# region TEXT -> categorica baja cardinalidad (3 distintos)
# total NUMERIC -> numerica con un null (count no-nulo = 3)
# pais TEXT -> constante ('ES')
#
# 4 filas. Valores de total no nulos: 120.5, 80.0, 45.25.
_N_ROWS = 4
_COLUMNS = [
{"column_name": "id", "data_type": "integer"},
{"column_name": "region", "data_type": "text"},
{"column_name": "total", "data_type": "numeric"},
{"column_name": "pais", "data_type": "character varying"},
]
# Agregados precomputados por columna (lo que devolveria PostgreSQL).
_AGG_BY_COL = {
"id": {
"non_null": 4, "distinct_n": 4,
"mn": 1, "mx": 4, "av": 2.5, "sd": 1.2909944487358056,
"p25": 1.75, "p50": 2.5, "p75": 3.25,
},
"region": {"non_null": 4, "distinct_n": 3},
"total": {
"non_null": 3, "distinct_n": 3,
"mn": 45.25, "mx": 120.5, "av": 81.91666666666667,
"sd": 37.70159, "p25": 62.625, "p50": 80.0, "p75": 100.25,
},
"pais": {"non_null": 4, "distinct_n": 1},
}
def _fake_pg_query(dsn, sql, params=None, max_rows=10000):
"""Despacha por la forma del SQL para simular pg_query sin servidor."""
sql_l = sql.lower()
# 1) Introspeccion de columnas.
if "information_schema.columns" in sql_l:
return {
"status": "ok",
"columns": ["column_name", "data_type"],
"rows": list(_COLUMNS),
"row_count": len(_COLUMNS),
"truncated": False,
}
# 2) count(*) total de filas.
if "count(*) as n" in sql_l:
return {
"status": "ok",
"columns": ["n"],
"rows": [{"n": _N_ROWS}],
"row_count": 1,
"truncated": False,
}
# 3) Agregados por columna: identificar la columna por su identificador citado.
for col, agg in _AGG_BY_COL.items():
if f'"{col}"' in sql:
return {
"status": "ok",
"columns": list(agg.keys()),
"rows": [dict(agg)],
"row_count": 1,
"truncated": False,
}
raise AssertionError(f"SQL inesperado en fake pg_query: {sql}")
@pytest.fixture(autouse=True)
def patch_pg_query(monkeypatch):
"""Reemplaza el pg_query que el modulo importo por la version simulada."""
monkeypatch.setattr(mod, "pg_query", _fake_pg_query)
def test_shape_y_metadatos_tabla():
res = summarize_table_pg("postgresql://x/y", "ventas")
assert res["status"] == "ok"
profile = res["profile"]
for key in (
"table", "source", "profiled_at", "n_rows", "n_cols", "size_bytes",
"duplicate_rows", "duplicate_pct", "constant_cols", "all_null_cols",
"null_cell_pct", "type_breakdown", "columns", "correlations",
"key_candidates", "quality_score", "llm", "models",
):
assert key in profile, f"falta clave {key} en TableProfile"
assert profile["table"] == "ventas"
assert profile["source"] == "postgres"
assert profile["n_rows"] == 4
assert profile["n_cols"] == 4
assert len(profile["columns"]) == 4
assert profile["key_candidates"] == []
assert profile["quality_score"] is None
assert profile["correlations"] is None
assert profile["models"] is None
assert profile["llm"] is None
def test_column_profile_shape():
profile = summarize_table_pg("postgresql://x/y", "ventas")["profile"]
by_name = {c["name"]: c for c in profile["columns"]}
for col in profile["columns"]:
for key in (
"name", "physical_type", "inferred_type", "semantic_type", "count",
"n_rows", "null_count", "null_pct", "empty_count", "empty_pct",
"distinct_count", "unique_pct", "flags", "quality_score",
"numeric", "categorical", "datetime",
):
assert key in col, f"falta clave {key} en ColumnProfile {col['name']}"
assert col["semantic_type"] == ""
assert col["quality_score"] is None
assert col["categorical"] is None
assert col["datetime"] is None
# id: numerica, sin nulls, unica -> possible_id.
idc = by_name["id"]
assert idc["inferred_type"] == "numeric"
assert idc["null_count"] == 0
assert idc["count"] == 4
assert idc["distinct_count"] == 4
assert idc["unique_pct"] == 1.0
assert "possible_id" in idc["flags"]
# region: categorica baja cardinalidad.
region = by_name["region"]
assert region["inferred_type"] == "categorical"
assert region["distinct_count"] == 3
assert region["numeric"] is None
# total: numerica con un null. count no-nulo = 3.
total = by_name["total"]
assert total["inferred_type"] == "numeric"
assert total["null_count"] == 1
assert total["count"] == 3
assert total["numeric"] is not None
assert total["numeric"]["min"] == pytest.approx(45.25)
assert total["numeric"]["max"] == pytest.approx(120.5)
assert total["numeric"]["mean"] is not None
assert total["numeric"]["std"] is not None
assert total["numeric"]["p25"] == pytest.approx(62.625)
assert total["numeric"]["p50"] == pytest.approx(80.0)
assert total["numeric"]["p75"] == pytest.approx(100.25)
# claves finas siguen en None (las completa otra funcion del grupo eda).
assert total["numeric"]["skew"] is None
assert total["numeric"]["kurtosis"] is None
assert total["numeric"]["histogram"] is None
assert total["numeric"]["p99"] is None
# pais: constante -> flag constant + aparece en constant_cols.
assert "constant" in by_name["pais"]["flags"]
assert "pais" in profile["constant_cols"]
def test_null_pct_total():
profile = summarize_table_pg("postgresql://x/y", "ventas")["profile"]
total = next(c for c in profile["columns"] if c["name"] == "total")
# 1 null sobre 4 filas.
assert total["null_pct"] == pytest.approx(0.25)
def test_distinct_no_excede_filas():
profile = summarize_table_pg("postgresql://x/y", "ventas")["profile"]
n_rows = profile["n_rows"]
for col in profile["columns"]:
assert col["distinct_count"] <= n_rows
assert col["unique_pct"] <= 1.0
def test_type_breakdown():
profile = summarize_table_pg("postgresql://x/y", "ventas")["profile"]
tb = profile["type_breakdown"]
assert set(tb.keys()) == {
"numeric", "categorical", "datetime", "text", "boolean"
}
assert tb["numeric"] == 2 # id, total
assert tb["categorical"] == 2 # region, pais
assert tb["datetime"] == 0
assert tb["boolean"] == 0
assert tb["text"] == 0
def test_tabla_invalida_devuelve_error():
res = summarize_table_pg("postgresql://x/y", "ventas; DROP TABLE ventas")
assert res["status"] == "error"
assert "invalido" in res["error"]
def test_schema_invalido_devuelve_error():
res = summarize_table_pg("postgresql://x/y", "ventas", schema="pub lic")
assert res["status"] == "error"
assert "schema" in res["error"]
def test_tabla_inexistente_devuelve_error(monkeypatch):
"""information_schema sin filas -> error (tabla no encontrada)."""
def empty_pg_query(dsn, sql, params=None, max_rows=10000):
if "information_schema.columns" in sql.lower():
return {
"status": "ok", "columns": ["column_name", "data_type"],
"rows": [], "row_count": 0, "truncated": False,
}
raise AssertionError("no deberia llegar aqui")
monkeypatch.setattr(mod, "pg_query", empty_pg_query)
res = summarize_table_pg("postgresql://x/y", "no_existe")
assert res["status"] == "error"
assert "no encontrada" in res["error"]
def test_error_de_lectura_pg_se_propaga(monkeypatch):
"""Si pg_query devuelve error en el count, summarize lo propaga dict-no-throw."""
def failing_count(dsn, sql, params=None, max_rows=10000):
sql_l = sql.lower()
if "information_schema.columns" in sql_l:
return {
"status": "ok", "columns": ["column_name", "data_type"],
"rows": list(_COLUMNS), "row_count": len(_COLUMNS),
"truncated": False,
}
if "count(*) as n" in sql_l:
return {"status": "error", "error": "connection refused"}
raise AssertionError("no deberia llegar a los agregados")
monkeypatch.setattr(mod, "pg_query", failing_count)
res = summarize_table_pg("postgresql://x/y", "ventas")
assert res["status"] == "error"
assert "connection refused" in res["error"]
@@ -0,0 +1,93 @@
---
name: discover_local_services
kind: function
lang: py
domain: infra
version: "1.1.0"
purity: impure
signature: "discover_local_services(manifest_path: str, include_registry: bool = True) -> list[dict]"
description: "Descubre los servicios locales del sistema local_hub expuestos como subdominios *.localhost. Lee el manifiesto YAML, normaliza la metadata de cada servicio, opcionalmente añade los servicios del registry con puerto via fn doctor, y comprueba up/down por chequeo de puerto TCP en 127.0.0.1. Robusta: no lanza por servicio caido (up=False) ni por fallo de fn doctor."
tags: [local-hub, infra, services, discovery, caddy, glance, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, os, socket, subprocess, sys, yaml]
params:
- name: manifest_path
desc: "ruta al manifiesto YAML de servicios (apps/local_hub/local_services.yaml) con claves dashboard_subdomain, glance_port y services[]"
- name: include_registry
desc: "si True, añade los servicios del registry con port>0 que no esten ya en el manifiesto (dedup por port y por subdomain), obtenidos de fn doctor services-spec --json"
output: "lista de dicts normalizados, cada uno con las claves name, subdomain, port, health_path, title, icon, category, rewrite_host (bool, passthrough del manifiesto; False para servicios del registry; lo consume render_caddyfile para reescribir el header Host), up (bool de estado vivo por puerto TCP)"
tested: true
tests:
- "test_golden_service_up_with_all_keys"
- "test_edge_closed_port_is_down"
- "test_defaults_derived_for_missing_fields"
- "test_empty_manifest_returns_empty_list"
- "test_rewrite_host_passthrough_desde_manifiesto"
test_file_path: "python/functions/infra/discover_local_services_test.py"
file_path: "python/functions/infra/discover_local_services.py"
---
## Ejemplo
```python
from discover_local_services import discover_local_services
# Solo manifiesto (sin tocar el registry):
servicios = discover_local_services(
"apps/local_hub/local_services.yaml",
include_registry=False,
)
for s in servicios:
estado = "UP" if s["up"] else "DOWN"
print(f'{s["title"]:<16} {s["subdomain"]}.localhost -> :{s["port"]} [{estado}]')
# Manifiesto + servicios del registry con puerto:
todos = discover_local_services("apps/local_hub/local_services.yaml")
print(len([s for s in todos if s["up"]]), "servicios vivos")
```
Como script (imprime JSON a stdout):
```bash
python/.venv/bin/python3 python/functions/infra/discover_local_services.py apps/local_hub/local_services.yaml
```
## Cuando usarla
Úsala como fase de descubrimiento del sistema `local_hub` antes de renderizar el
Caddyfile o la config de Glance: cuando necesites la lista normalizada de servicios
locales (`*.localhost`) con su estado up/down resuelto. También cuando quieras un
inventario unificado de servicios manuales (contenedores, daemons de terceros) más
los servicios del registry con puerto, deduplicados.
## Gotchas
- `up` se decide por **conexión TCP** a `127.0.0.1:<port>` con timeout 0.5s, NO por
GET HTTP. Un servicio puede aceptar la conexión y devolver 404/500 en `/` y aun
así marcar `up=True`. Es intencional: solo valida que el puerto esté escuchando.
- Solo comprueba `127.0.0.1` (loopback). Servicios que bindean únicamente a otra
interfaz se reportan como `down`.
- `include_registry=True` ejecuta `fn doctor services-spec --json` (fallback a
`services --json`) como subproceso desde la raíz del repo. Si `fn` no está, falla,
tarda más de 20s o devuelve JSON inválido, la función **no lanza**: sigue solo con
el manifiesto. Por eso el resultado puede variar según el entorno.
- La raíz del repo se resuelve por `FN_REGISTRY_ROOT` o subiendo directorios hasta
encontrar `registry.db`. Si no la encuentra, usa el cwd.
- El dedup del registry es por `port` Y por `subdomain`: un servicio del registry
cuyo puerto o subdominio derivado ya esté en el manifiesto se omite.
- El subdominio de un servicio del registry se deriva por una tabla de alias
(`dag_engine`->`dag`, `registry_api`->`registry`, `sqlite_api`->`sqlite`,
`osint_db`->`osint`, ...) y, para el resto, el primer token antes de `_`.
- Lanza `RuntimeError` solo si el manifiesto no se puede leer o parsear (path
inexistente, YAML inválido). Eso sí es un error duro.
- La clave `rewrite_host` es passthrough del manifiesto (default `False`); para
los servicios añadidos desde el registry siempre es `False`. La consume
`render_caddyfile` para emitir `header_up Host` en el bloque del servicio.
## Capability growth log
- v1.1.0 (2026-06-20) — añade clave rewrite_host (passthrough del manifiesto) para que render_caddyfile reescriba el Host
@@ -0,0 +1,213 @@
"""Descubre servicios locales expuestos como subdominios ``*.localhost``.
Fase de descubrimiento del sistema ``local_hub`` (reverse proxy Caddy +
dashboard Glance). Lee el manifiesto YAML de servicios, normaliza la metadata
de cada uno, opcionalmente añade los servicios del registry con puerto, y
comprueba si cada servicio está vivo (puerto TCP aceptando conexiones).
La función es robusta: nunca lanza por un servicio caído (lo marca ``up=False``)
ni por un fallo de ``fn doctor`` (captura y continúa solo con el manifiesto).
"""
import json
import os
import socket
import subprocess
import sys
import yaml
# Alias para derivar el subdominio de un servicio del registry a partir de su
# nombre. Para los nombres no listados se usa el primer token antes de "_".
_SUBDOMAIN_ALIAS = {
"dag_engine": "dag",
"registry_api": "registry",
"sqlite_api": "sqlite",
"osint_db": "osint",
"services_api": "services",
"web_proxy": "proxy",
}
def _is_port_up(port: int) -> bool:
"""Devuelve True si 127.0.0.1:<port> acepta conexiones TCP.
Comprobación pura de puerto (no HTTP): algunos servicios no responden 200
en ``/`` pero sí aceptan la conexión. Timeout corto para no bloquear.
"""
try:
with socket.create_connection(("127.0.0.1", int(port)), timeout=0.5):
return True
except (OSError, ValueError, TypeError):
return False
def _normalize_manifest_service(svc: dict) -> dict:
"""Normaliza un servicio del manifiesto a todas las claves esperadas."""
name = str(svc.get("name", "")).strip()
subdomain = str(svc.get("subdomain", "") or name).strip()
try:
port = int(svc.get("port", 0) or 0)
except (ValueError, TypeError):
port = 0
return {
"name": name,
"subdomain": subdomain,
"port": port,
"health_path": str(svc.get("health_path") or "/"),
"title": str(svc.get("title") or name),
"icon": str(svc.get("icon") or ""),
"category": str(svc.get("category") or "Otros"),
"rewrite_host": bool(svc.get("rewrite_host", False)),
"up": _is_port_up(port) if port > 0 else False,
}
def _is_registry_root(path: str) -> bool:
"""Una raíz válida del registry tiene registry.db Y el paquete cmd/fn.
El doble marcador evita falsos positivos como un registry.db vacío y
espurio dentro de python/ (la regla db_locations exige que registry.db
solo viva en la raíz, pero protegemos por si acaso).
"""
return os.path.isfile(os.path.join(path, "registry.db")) and os.path.isdir(
os.path.join(path, "cmd", "fn")
)
def _find_registry_root() -> str:
"""Localiza la raíz del registry (FN_REGISTRY_ROOT o subiendo hasta la raíz real)."""
root = os.environ.get("FN_REGISTRY_ROOT")
if root and _is_registry_root(root):
return root
cur = os.path.abspath(os.path.dirname(__file__))
while True:
if _is_registry_root(cur):
return cur
parent = os.path.dirname(cur)
if parent == cur:
break
cur = parent
return os.getcwd()
def _derive_subdomain(name: str) -> str:
"""Deriva un subdominio del nombre de un servicio del registry."""
name = (name or "").strip()
if name in _SUBDOMAIN_ALIAS:
return _SUBDOMAIN_ALIAS[name]
return name.split("_", 1)[0] if name else name
def _fetch_registry_services(root: str) -> list[dict]:
"""Obtiene los servicios del registry con puerto via ``fn doctor``.
Intenta ``fn doctor services-spec --json`` y cae a ``services --json``.
Devuelve lista vacía si ambos fallan (la función sigue con el manifiesto).
"""
fn_bin = os.path.join(root, "fn")
cmd_base = [fn_bin] if os.path.isfile(fn_bin) else ["fn"]
for sub in ("services-spec", "services"):
try:
proc = subprocess.run(
cmd_base + ["doctor", sub, "--json"],
cwd=root,
capture_output=True,
text=True,
timeout=20,
)
except (OSError, subprocess.SubprocessError):
continue
if proc.returncode != 0 or not proc.stdout.strip():
continue
try:
data = json.loads(proc.stdout)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(data, dict):
for key in ("services", "items", "results"):
if isinstance(data.get(key), list):
data = data[key]
break
if isinstance(data, list):
return data
return []
def discover_local_services(manifest_path: str, include_registry: bool = True) -> list[dict]:
"""Descubre y normaliza los servicios locales del manifiesto local_hub.
Args:
manifest_path: ruta al manifiesto YAML (``apps/local_hub/local_services.yaml``).
include_registry: si True añade los servicios del registry con puerto>0 que
no estén ya en el manifiesto (dedup por port y por subdomain).
Returns:
Lista de dicts normalizados, cada uno con las claves: name, subdomain, port,
health_path, title, icon, category, rewrite_host, up. La clave
``rewrite_host`` (bool) es passthrough del manifiesto (default ``False``;
siempre ``False`` para los servicios añadidos desde el registry) y la
consume ``render_caddyfile`` para reescribir el header ``Host`` del
upstream en servicios que lo validan (ej. Jupyter).
"""
try:
with open(manifest_path, "r", encoding="utf-8") as fh:
manifest = yaml.safe_load(fh) or {}
except (OSError, yaml.YAMLError) as exc:
raise RuntimeError(f"discover_local_services: cannot read manifest {manifest_path}: {exc}") from exc
raw_services = manifest.get("services") or []
result: list[dict] = []
seen_ports: set[int] = set()
seen_subdomains: set[str] = set()
for svc in raw_services:
if not isinstance(svc, dict):
continue
norm = _normalize_manifest_service(svc)
result.append(norm)
if norm["port"] > 0:
seen_ports.add(norm["port"])
if norm["subdomain"]:
seen_subdomains.add(norm["subdomain"])
if include_registry:
try:
root = _find_registry_root()
for svc in _fetch_registry_services(root):
if not isinstance(svc, dict):
continue
try:
port = int(svc.get("port", 0) or 0)
except (ValueError, TypeError):
port = 0
if port <= 0 or port in seen_ports:
continue
name = str(svc.get("name", "")).strip()
subdomain = _derive_subdomain(name)
if subdomain in seen_subdomains:
continue
result.append({
"name": name,
"subdomain": subdomain,
"port": port,
"health_path": str(svc.get("health_endpoint") or "/"),
"title": name,
"icon": "",
"category": "Registry",
"rewrite_host": False,
"up": _is_port_up(port),
})
seen_ports.add(port)
seen_subdomains.add(subdomain)
except Exception:
# fn doctor opcional: si algo falla, seguimos solo con el manifiesto.
pass
return result
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else "apps/local_hub/local_services.yaml"
services = discover_local_services(path)
print(json.dumps(services, indent=2, ensure_ascii=False))
@@ -0,0 +1,140 @@
"""Tests para discover_local_services."""
import os
import socket
import sys
import yaml
# El modulo hoja se importa por su nombre directo; aseguramos que su directorio
# esta en sys.path para poder correr el test desde cualquier cwd.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from discover_local_services import discover_local_services
NORMALIZED_KEYS = {
"name", "subdomain", "port", "health_path", "title", "icon", "category",
"rewrite_host", "up",
}
def _write_manifest(tmp_path, services):
manifest = {
"dashboard_subdomain": "home",
"glance_port": 8585,
"services": services,
}
path = tmp_path / "local_services.yaml"
path.write_text(yaml.safe_dump(manifest), encoding="utf-8")
return str(path)
def test_golden_service_up_with_all_keys(tmp_path):
# Abrimos un socket real en un puerto efímero para simular un servicio vivo.
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(1)
port = listener.getsockname()[1]
try:
manifest = _write_manifest(tmp_path, [
{
"name": "metabase",
"subdomain": "metabase",
"port": port,
"health_path": "/api/health",
"title": "Metabase",
"icon": "si:metabase",
"category": "Datos",
},
])
result = discover_local_services(manifest, include_registry=False)
assert len(result) == 1
svc = result[0]
# Todas las claves normalizadas presentes.
assert set(svc.keys()) == NORMALIZED_KEYS
assert svc["up"] is True
assert svc["name"] == "metabase"
assert svc["port"] == port
assert svc["health_path"] == "/api/health"
finally:
listener.close()
def test_edge_closed_port_is_down(tmp_path):
# Tomamos un puerto efímero y lo cerramos inmediatamente -> debe estar down.
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
closed_port = s.getsockname()[1]
s.close()
manifest = _write_manifest(tmp_path, [
{
"name": "ghost",
"subdomain": "ghost",
"port": closed_port,
"health_path": "/",
"title": "Ghost",
"icon": "",
"category": "Otros",
},
])
result = discover_local_services(manifest, include_registry=False)
assert len(result) == 1
assert result[0]["up"] is False
def test_defaults_derived_for_missing_fields(tmp_path):
# Servicio mínimo: solo name + port. El resto debe derivarse con defaults.
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
closed_port = s.getsockname()[1]
s.close()
manifest = _write_manifest(tmp_path, [
{"name": "barebones", "port": closed_port},
])
result = discover_local_services(manifest, include_registry=False)
svc = result[0]
assert set(svc.keys()) == NORMALIZED_KEYS
assert svc["title"] == "barebones" # derivado de name
assert svc["icon"] == "" # default
assert svc["category"] == "Otros" # default
assert svc["health_path"] == "/" # default
assert svc["subdomain"] == "barebones" # derivado de name
assert svc["up"] is False
def test_empty_manifest_returns_empty_list(tmp_path):
manifest = _write_manifest(tmp_path, [])
result = discover_local_services(manifest, include_registry=False)
assert result == []
def test_rewrite_host_passthrough_desde_manifiesto(tmp_path):
# Un servicio con rewrite_host: true en el manifiesto debe propagar
# rewrite_host == True; uno sin la clave debe dar rewrite_host == False.
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(1)
port = listener.getsockname()[1]
try:
manifest = _write_manifest(tmp_path, [
{
"name": "jupyter",
"subdomain": "jupyter",
"port": port,
"rewrite_host": True,
},
{
"name": "metabase",
"subdomain": "metabase",
"port": port,
# sin clave rewrite_host -> default False
},
])
result = discover_local_services(manifest, include_registry=False)
by_name = {s["name"]: s for s in result}
assert by_name["jupyter"]["rewrite_host"] is True
assert by_name["metabase"]["rewrite_host"] is False
finally:
listener.close()
@@ -0,0 +1,88 @@
---
name: render_caddyfile
kind: function
lang: py
domain: infra
version: "1.1.0"
purity: pure
signature: "def render_caddyfile(services: list[dict], dashboard: dict | None = None) -> str"
description: "Parte del sistema local_hub: transforma una lista de servicios normalizados en el texto de un fragmento de Caddyfile que mapea cada subdominio *.localhost a su puerto local via reverse_proxy HTTP plano (loopback, sin TLS). Cada servicio es un dict con subdomain (str) y port (int); el resto de claves se ignoran. Los bloques de servicio se ordenan por subdominio alfabetico para que la salida sea estable y reproducible (clave para diffs y tests). Un dashboard opcional emite su bloque PRIMERO porque es la pagina principal. Ignora servicios sin subdomain o sin port (los salta, no lanza) y no deduplica. Pura: solo stdlib, sin I/O ni red, determinista."
tags: [local-hub, caddy, caddyfile, reverse-proxy, infra, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: services
desc: "lista de dicts de servicio normalizados. Cada uno debe tener al menos subdomain (str, sin el sufijo .localhost) y port (int, puerto local). Otras claves se ignoran. Los servicios sin subdomain o sin port se saltan silenciosamente. No se deduplica: eso es trabajo del discover."
- name: dashboard
desc: "dict opcional {subdomain, port} para la pagina principal del hub (ej. {\"subdomain\": \"home\", \"port\": 8585}). Si se pasa, su bloque va el primero de la salida. None = no se emite bloque de dashboard. Si le falta subdomain o port, se ignora igual que un servicio invalido."
output: "string con el Caddyfile completo: empieza por una cabecera de comentario (# Generado por render_caddyfile_py_infra ...), luego el bloque del dashboard si aplica, y despues los bloques de servicio ordenados alfabeticamente por subdominio. Cada bloque es 'http://<subdomain>.localhost {\\n reverse_proxy 127.0.0.1:<port>\\n}\\n' con 4 espacios de indentacion. La salida termina con un unico \\n."
tested: true
tests:
- "test_golden_dos_servicios_ordenados"
- "test_dashboard_va_primero"
- "test_lista_vacia_solo_cabecera"
- "test_servicio_sin_port_se_ignora"
- "test_servicio_sin_subdomain_se_ignora"
- "test_rewrite_host_emite_header_up"
- "test_rewrite_host_ausente_o_falso_no_reescribe"
test_file_path: "python/functions/infra/render_caddyfile_test.py"
file_path: "python/functions/infra/render_caddyfile.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions", "infra"))
from render_caddyfile import render_caddyfile
services = [
{"subdomain": "metabase", "port": 3030},
{"subdomain": "grafana", "port": 3000},
]
dashboard = {"subdomain": "home", "port": 8585}
print(render_caddyfile(services, dashboard=dashboard))
# # Generado por render_caddyfile_py_infra — NO editar a mano. Fuente: apps/local_hub/local_services.yaml
# http://home.localhost {
# reverse_proxy 127.0.0.1:8585
# }
# http://grafana.localhost {
# reverse_proxy 127.0.0.1:3000
# }
# http://metabase.localhost {
# reverse_proxy 127.0.0.1:3030
# }
```
## Cuando usarla
Cuando, dentro del sistema local_hub, ya tienes la lista de servicios locales
normalizada (cada uno con su `subdomain` y `port`) y necesitas materializar el
fragmento de Caddyfile que enruta `*.localhost` a sus puertos. Usala justo
antes de escribir el archivo a disco y recargar Caddy: esta funcion solo
produce el texto (pura), el I/O y el reload van en una funcion impura o pipeline
aparte. Tambien util para tests/diffs porque la salida es determinista (bloques
ordenados por subdominio).
## Gotchas
- El formato es HTTP plano a proposito (`http://...`, sin TLS): todo el trafico
es loopback (`127.0.0.1`), no hay nada que cifrar y `*.localhost` no necesita
certificado. No es un bug.
- No deduplica subdominios: si dos servicios comparten `subdomain`, ambos
bloques se emiten y Caddy se quedara con el ultimo. La deduplicacion es
responsabilidad del discover que produce `services`.
- `rewrite_host` solo cambia la cabecera `Host` que ve el upstream, no la URL
que abre el usuario. Actívalo unicamente para servicios que validan el header
y rechazan el subdominio (Jupyter devuelve 400, algunos FastAPI/uvicorn con
`--forwarded-allow-ips` estricto). Para el resto dejalo ausente/False: añadir
`header_up Host` sin necesidad puede romper virtual-hosting del upstream.
## Capability growth log
- v1.1.0 (2026-06-20) — añade soporte rewrite_host (header_up Host) para servicios que validan el header Host
@@ -0,0 +1,88 @@
"""Renderiza un fragmento de Caddyfile para el sistema local_hub.
Funcion pura: transforma una lista de servicios normalizados en el texto de un
Caddyfile que mapea cada subdominio `*.localhost` a su puerto local via
reverse_proxy HTTP plano (loopback, sin TLS). Sin I/O, sin red, determinista.
"""
_HEADER = (
"# Generado por render_caddyfile_py_infra — NO editar a mano. "
"Fuente: apps/local_hub/local_services.yaml\n"
)
def _render_block(subdomain: str, port: int, rewrite_host: bool = False) -> str:
"""Construye un bloque reverse_proxy para un subdominio y puerto dados.
Args:
subdomain: subdominio sin el sufijo `.localhost` (ej. "metabase").
port: puerto local al que redirigir (ej. 3030).
rewrite_host: si es True, reescribe la cabecera `Host` enviada al
upstream a `127.0.0.1:<port>`. Necesario para servicios que validan
el header Host y rechazan el subdominio (ej. Jupyter devuelve 400
"Bad Request" si recibe `Host: jupyter.localhost`).
Returns:
el bloque Caddyfile como string, con indentacion de 4 espacios y
terminado en `\n`.
"""
if rewrite_host:
return (
f"http://{subdomain}.localhost {{\n"
f" reverse_proxy 127.0.0.1:{port} {{\n"
f" header_up Host 127.0.0.1:{port}\n"
f" }}\n"
f"}}\n"
)
return (
f"http://{subdomain}.localhost {{\n"
f" reverse_proxy 127.0.0.1:{port}\n"
f"}}\n"
)
def render_caddyfile(services: list[dict], dashboard: dict | None = None) -> str:
"""Renderiza el texto de un fragmento de Caddyfile para local_hub.
Cada servicio se mapea a un bloque `http://<subdomain>.localhost` con un
`reverse_proxy 127.0.0.1:<port>`. Los bloques de servicio se ordenan por
subdominio alfabetico para que la salida sea estable y reproducible. El
bloque del dashboard, si se pasa, va siempre primero (es la pagina
principal). Se usa HTTP plano a proposito: todo es loopback, no hay TLS.
Args:
services: lista de dicts de servicio. Cada uno debe tener al menos
`subdomain` (str) y `port` (int); otras claves se ignoran salvo
`rewrite_host` (bool opcional): si es truthy, el bloque reescribe la
cabecera `Host` enviada al upstream a `127.0.0.1:<port>` (para
servicios que validan Host, ej. Jupyter). Los servicios sin
`subdomain` o sin `port` se saltan (no lanzan error). No se
deduplica (eso es trabajo del discover).
dashboard: dict opcional con `subdomain` y `port` para la pagina
principal. Si es None, no se emite bloque de dashboard. Si le falta
`subdomain` o `port`, se ignora igual que un servicio invalido.
Returns:
el Caddyfile completo como string: empieza por una cabecera de
comentario, luego (si aplica) el bloque del dashboard, y despues los
bloques de servicio ordenados. Termina con un unico `\n`.
"""
parts: list[str] = [_HEADER]
if dashboard is not None:
d_sub = dashboard.get("subdomain")
d_port = dashboard.get("port")
if d_sub is not None and d_port is not None:
parts.append(_render_block(d_sub, d_port))
valid = [
svc
for svc in services
if svc.get("subdomain") is not None and svc.get("port") is not None
]
for svc in sorted(valid, key=lambda s: s["subdomain"]):
parts.append(
_render_block(svc["subdomain"], svc["port"], bool(svc.get("rewrite_host")))
)
return "".join(parts)
@@ -0,0 +1,103 @@
"""Tests para render_caddyfile."""
from render_caddyfile import render_caddyfile
HEADER = (
"# Generado por render_caddyfile_py_infra — NO editar a mano. "
"Fuente: apps/local_hub/local_services.yaml\n"
)
def test_golden_dos_servicios_ordenados():
services = [
{"subdomain": "metabase", "port": 3030},
{"subdomain": "grafana", "port": 3000},
]
result = render_caddyfile(services)
expected = (
HEADER
+ "http://grafana.localhost {\n"
+ " reverse_proxy 127.0.0.1:3000\n"
+ "}\n"
+ "http://metabase.localhost {\n"
+ " reverse_proxy 127.0.0.1:3030\n"
+ "}\n"
)
assert result == expected
# Orden alfabetico: grafana antes que metabase pese al orden de entrada.
assert result.index("grafana.localhost") < result.index("metabase.localhost")
# Termina con un unico newline.
assert result.endswith("}\n")
assert not result.endswith("\n\n")
def test_dashboard_va_primero():
services = [{"subdomain": "metabase", "port": 3030}]
dashboard = {"subdomain": "home", "port": 8585}
result = render_caddyfile(services, dashboard=dashboard)
expected = (
HEADER
+ "http://home.localhost {\n"
+ " reverse_proxy 127.0.0.1:8585\n"
+ "}\n"
+ "http://metabase.localhost {\n"
+ " reverse_proxy 127.0.0.1:3030\n"
+ "}\n"
)
assert result == expected
# El dashboard aparece antes que cualquier servicio.
assert result.index("home.localhost") < result.index("metabase.localhost")
def test_lista_vacia_solo_cabecera():
result = render_caddyfile([])
assert result == HEADER
def test_servicio_sin_port_se_ignora():
services = [
{"subdomain": "valido", "port": 9000},
{"subdomain": "sin_port"},
]
result = render_caddyfile(services)
expected = (
HEADER
+ "http://valido.localhost {\n"
+ " reverse_proxy 127.0.0.1:9000\n"
+ "}\n"
)
assert result == expected
assert "sin_port" not in result
def test_servicio_sin_subdomain_se_ignora():
services = [
{"subdomain": "valido", "port": 9000},
{"port": 1234},
]
result = render_caddyfile(services)
assert "1234" not in result
assert result.count("reverse_proxy") == 1
def test_rewrite_host_emite_header_up():
services = [{"subdomain": "jupyter", "port": 8888, "rewrite_host": True}]
result = render_caddyfile(services)
expected = (
HEADER
+ "http://jupyter.localhost {\n"
+ " reverse_proxy 127.0.0.1:8888 {\n"
+ " header_up Host 127.0.0.1:8888\n"
+ " }\n"
+ "}\n"
)
assert result == expected
def test_rewrite_host_ausente_o_falso_no_reescribe():
# Sin la clave -> bloque simple.
assert "header_up" not in render_caddyfile([{"subdomain": "a", "port": 1}])
# Con rewrite_host falsy -> bloque simple.
assert "header_up" not in render_caddyfile(
[{"subdomain": "a", "port": 1, "rewrite_host": False}]
)
@@ -0,0 +1,112 @@
---
name: render_glance_config
kind: function
lang: py
domain: infra
version: "1.1.0"
purity: pure
signature: "render_glance_config(services: list[dict], title: str = \"Procesos locales\", host_suffix: str = \"localhost\") -> str"
description: "Transforma una lista de servicios normalizados en el YAML de configuración de Glance (dashboard self-hosted). Genera una página con un widget monitor por categoría que hace health-check de cada servicio y lo pinta verde/rojo. Función pura y determinista. Parte del sistema local_hub."
tags: [local-hub, infra, glance, dashboard, yaml, config]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [pyyaml]
params:
- name: services
desc: "Lista de dicts de servicio normalizados. Cada uno requiere 'subdomain' (si falta, el servicio se ignora sin lanzar) y 'title'; opcional 'category' (default 'General'), 'icon' (se omite del site si está vacío o ausente) y 'health_path' (ruta de salud del servicio: si es distinta de '/', el site emite 'check-url' = url+health_path para que Glance haga el health-check ahí; si es '/' o falta, no se emite check-url)."
- name: title
desc: "Nombre de la página de Glance (campo 'name' de la página). Default 'Procesos locales'."
- name: host_suffix
desc: "Sufijo de host para las URLs de los sites. Default 'localhost' -> 'http://<subdomain>.localhost'."
output: "String con el YAML completo de configuración de Glance (cabecera de comentario + pages/columns/widgets), terminado en '\\n'. Parseable con yaml.safe_load. Cada site lleva 'title' y 'url' (raíz del subdominio); además 'check-url' (url+health_path) cuando el servicio trae un health_path distinto de '/', e 'icon' cuando no está vacío."
tested: true
tests:
- "test_golden_dos_categorias_dos_widgets"
- "test_yaml_parseable_y_estructura"
- "test_icon_omitido_cuando_vacio"
- "test_host_suffix_custom"
- "test_title_es_name_de_pagina"
- "test_servicios_sin_subdomain_se_ignoran"
- "test_determinismo"
- "test_orden_sites_por_title"
- "test_categoria_default_general"
- "test_check_url_se_emite_cuando_health_path_no_es_raiz"
test_file_path: "python/functions/infra/render_glance_config_test.py"
file_path: "python/functions/infra/render_glance_config.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.render_glance_config import render_glance_config
services = [
{"subdomain": "metabase", "title": "Metabase", "icon": "si:metabase", "category": "Datos"},
{"subdomain": "jupyter", "title": "Jupyter Lab", "icon": "si:jupyter", "category": "Datos"},
{"subdomain": "portainer","title": "Portainer", "icon": "si:portainer", "category": "Infra"},
]
yaml_text = render_glance_config(services, title="Inicio")
print(yaml_text)
# pages:
# - name: Inicio
# columns:
# - size: full
# widgets:
# - type: monitor
# title: Datos
# cache: 1m
# sites:
# - title: Jupyter Lab # ordenado por title dentro de la categoría
# url: http://jupyter.localhost
# icon: si:jupyter
# - title: Metabase
# url: http://metabase.localhost
# icon: si:metabase
# - type: monitor
# title: Infra
# cache: 1m
# sites:
# - title: Portainer
# url: http://portainer.localhost
# icon: si:portainer
```
## Cuando usarla
Cuando necesites regenerar el `glance.yml` del dashboard local a partir de
`apps/local_hub/local_services.yaml`: tras añadir/quitar un servicio local, o
en el pipeline `refresh_local_hub` que corre diario via dag_engine. La salida se
escribe al archivo de config de Glance (el borde impuro: I/O lo hace el caller).
## Notas
- **Decisión `title` -> `name`:** el parámetro `title` se usa como el campo `name`
de la (única) página de Glance. No es un comentario de cabecera ni se ignora.
Default `"Procesos locales"`. Así la firma queda útil sin añadir un parámetro
extra para el nombre de página.
- **Determinismo:** las categorías se ordenan alfabéticamente y los servicios de
cada categoría por `title` (desempate por `subdomain`). Se serializa con
`yaml.safe_dump(sort_keys=False)` sobre estructuras ya ordenadas, por lo que la
misma entrada (en cualquier orden) produce siempre la misma salida byte a byte.
- **Robustez:** los servicios sin `subdomain` se ignoran silenciosamente (no se
lanza). El `icon` se omite del site cuando está vacío o ausente. Cada categoría
produce un widget `type: monitor` con `cache: 1m`; todos los widgets van en una
sola columna `size: full`.
- **Función pura:** sin I/O, sin estado, determinista. El health-check real lo
hace Glance en runtime (GET a `check-url` si existe, si no a `url`); esta
función solo genera el texto.
- **`check-url` vs `url`:** `url` es siempre la raíz del subdominio (lo que abre
el usuario al clicar). `check-url` solo aparece cuando el servicio trae un
`health_path` distinto de `/`, y vale `url + health_path`. Sirve para APIs que
devuelven 404 en `/` pero 200 en su ruta de salud (ej. `/api/health`), de modo
que Glance las pinta verde sin cambiar el enlace navegable.
## Capability growth log
- v1.1.0 (2026-06-20) — añade check-url (health_path) por site para health-check preciso de APIs sin ruta raíz
@@ -0,0 +1,110 @@
"""Renderiza la configuración YAML de Glance a partir de servicios normalizados.
Glance (https://github.com/glanceapp/glance) es un dashboard self-hosted. Este
módulo transforma una lista de servicios en el YAML que Glance espera: una página
con un widget `monitor` por categoría que hace health-check de cada servicio y lo
pinta verde/rojo. Parte del sistema `local_hub`.
"""
import yaml
_HEADER = (
"# Generado por render_glance_config_py_infra — NO editar a mano. "
"Fuente: apps/local_hub/local_services.yaml\n"
)
def render_glance_config(
services: list[dict],
title: str = "Procesos locales",
host_suffix: str = "localhost",
) -> str:
"""Construye el YAML de configuración de Glance para una lista de servicios.
Función pura y determinista: agrupa los servicios por su clave ``category``,
crea un widget ``type: monitor`` por categoría (ordenadas alfabéticamente) y
dentro de cada uno un site por servicio (ordenados por ``title``). Cada site
apunta a ``http://<subdomain>.<host_suffix>`` (campo ``url``, lo que abre el
usuario al clicar). Si el servicio trae un ``health_path`` distinto de
``"/"``, el site añade además ``check-url`` = ``url + health_path``: es la
ruta que Glance usa para el health-check (muchas APIs dan 404 en ``/`` pero
200 en ``/api/health``), sin cambiar el enlace que ve el usuario.
Args:
services: lista de dicts de servicio normalizados. Cada uno debe traer al
menos ``subdomain`` (si falta, el servicio se ignora sin lanzar) y
``title``; opcionalmente ``category`` (default ``"General"``),
``icon`` (se omite del site si está vacío o ausente) y ``health_path``
(si es distinto de ``"/"``, el site emite ``check-url`` = url +
health_path; si es ``"/"`` o falta, no se emite ``check-url``).
title: nombre de la página de Glance (campo ``name`` de la página).
Default ``"Procesos locales"``.
host_suffix: sufijo de host para las URLs de los sites. Default
``"localhost"`` -> ``http://<subdomain>.localhost``.
Returns:
String con el YAML completo de Glance, terminado en ``\\n``.
"""
# Agrupa por categoría, ignorando servicios sin subdomain.
by_category: dict[str, list[dict]] = {}
for svc in services:
subdomain = svc.get("subdomain")
if not subdomain:
continue
category = svc.get("category") or "General"
by_category.setdefault(category, []).append(svc)
widgets: list[dict] = []
for category in sorted(by_category.keys()):
svcs = sorted(
by_category[category],
key=lambda s: (s.get("title") or "", s.get("subdomain") or ""),
)
sites: list[dict] = []
for svc in svcs:
url = f"http://{svc['subdomain']}.{host_suffix}"
site: dict = {
"title": svc.get("title") or svc["subdomain"],
"url": url,
}
# El health-check apunta al health_path del servicio (no a "/").
# Muchas APIs devuelven 404 en la raiz pero 200 en su ruta de salud
# (ej. /api/health), asi Glance las pinta verde correctamente. El
# campo `url` (lo que abre el usuario al clicar) sigue siendo la raiz.
health = svc.get("health_path") or "/"
if health and health != "/":
site["check-url"] = url + health
icon = svc.get("icon")
if icon:
site["icon"] = icon
sites.append(site)
widgets.append(
{
"type": "monitor",
"title": category,
"cache": "1m",
"sites": sites,
}
)
config = {
"pages": [
{
"name": title,
"columns": [
{
"size": "full",
"widgets": widgets,
}
],
}
]
}
body = yaml.safe_dump(
config,
sort_keys=False,
default_flow_style=False,
allow_unicode=True,
)
return _HEADER + body
@@ -0,0 +1,152 @@
"""Tests para render_glance_config."""
import os
import sys
import yaml
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from functions.infra.render_glance_config import render_glance_config
SERVICES = [
{
"name": "metabase",
"subdomain": "metabase",
"title": "Metabase",
"icon": "si:metabase",
"category": "Datos",
},
{
"name": "portainer",
"subdomain": "portainer",
"title": "Portainer",
"icon": "", # icon vacío -> debe omitirse
"category": "Infra",
},
]
def test_golden_dos_categorias_dos_widgets():
out = render_glance_config(SERVICES)
cfg = yaml.safe_load(out)
widgets = cfg["pages"][0]["columns"][0]["widgets"]
assert len(widgets) == 2
assert all(w["type"] == "monitor" for w in widgets)
# Categorías ordenadas alfabéticamente: Datos antes que Infra.
assert [w["title"] for w in widgets] == ["Datos", "Infra"]
datos_site = widgets[0]["sites"][0]
assert datos_site["title"] == "Metabase"
assert datos_site["url"] == "http://metabase.localhost"
assert datos_site["icon"] == "si:metabase"
def test_yaml_parseable_y_estructura():
out = render_glance_config(SERVICES)
cfg = yaml.safe_load(out) # no debe lanzar
page = cfg["pages"][0]
assert page["name"] == "Procesos locales"
col = page["columns"][0]
assert col["size"] == "full"
assert isinstance(col["widgets"], list)
assert out.endswith("\n")
def test_icon_omitido_cuando_vacio():
out = render_glance_config(SERVICES)
cfg = yaml.safe_load(out)
infra_site = cfg["pages"][0]["columns"][0]["widgets"][1]["sites"][0]
assert infra_site["title"] == "Portainer"
assert "icon" not in infra_site
def test_host_suffix_custom():
out = render_glance_config(SERVICES, host_suffix="home.lan")
cfg = yaml.safe_load(out)
site = cfg["pages"][0]["columns"][0]["widgets"][0]["sites"][0]
assert site["url"] == "http://metabase.home.lan"
def test_title_es_name_de_pagina():
out = render_glance_config(SERVICES, title="Mi Hub")
cfg = yaml.safe_load(out)
assert cfg["pages"][0]["name"] == "Mi Hub"
def test_servicios_sin_subdomain_se_ignoran():
services = SERVICES + [{"name": "roto", "title": "Roto", "category": "Datos"}]
out = render_glance_config(services)
cfg = yaml.safe_load(out)
datos = cfg["pages"][0]["columns"][0]["widgets"][0]
# Solo Metabase en Datos; el servicio sin subdomain se descarta.
assert len(datos["sites"]) == 1
assert datos["sites"][0]["title"] == "Metabase"
def test_determinismo():
a = render_glance_config(SERVICES)
b = render_glance_config(list(reversed(SERVICES)))
# El orden de entrada no afecta: categorías y sites se ordenan internamente.
assert a == b
def test_orden_sites_por_title():
services = [
{"subdomain": "z-svc", "title": "Zeta", "category": "Datos"},
{"subdomain": "a-svc", "title": "Alfa", "category": "Datos"},
]
out = render_glance_config(services)
cfg = yaml.safe_load(out)
sites = cfg["pages"][0]["columns"][0]["widgets"][0]["sites"]
assert [s["title"] for s in sites] == ["Alfa", "Zeta"]
def test_categoria_default_general():
services = [{"subdomain": "x", "title": "X"}]
out = render_glance_config(services)
cfg = yaml.safe_load(out)
assert cfg["pages"][0]["columns"][0]["widgets"][0]["title"] == "General"
def test_check_url_se_emite_cuando_health_path_no_es_raiz():
services = [
{
"subdomain": "api",
"title": "API",
"category": "Datos",
"health_path": "/api/health",
},
{
"subdomain": "web",
"title": "Web",
"category": "Datos",
"health_path": "/", # raiz -> no debe emitir check-url
},
{
"subdomain": "raw",
"title": "Raw",
"category": "Datos",
# sin health_path -> tampoco debe emitir check-url
},
]
out = render_glance_config(services)
cfg = yaml.safe_load(out) # sigue siendo YAML parseable
sites = {
s["title"]: s
for s in cfg["pages"][0]["columns"][0]["widgets"][0]["sites"]
}
# health_path no-raiz -> check-url = url + health_path; url sigue siendo la raiz.
assert sites["API"]["url"] == "http://api.localhost"
assert sites["API"]["check-url"] == "http://api.localhost/api/health"
# health_path == "/" -> sin check-url.
assert sites["Web"]["url"] == "http://web.localhost"
assert "check-url" not in sites["Web"]
# sin health_path -> sin check-url.
assert sites["Raw"]["url"] == "http://raw.localhost"
assert "check-url" not in sites["Raw"]
+69
View File
@@ -0,0 +1,69 @@
---
name: resolve_pg_dsn
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def resolve_pg_dsn(project: str) -> dict"
description: "Resuelve el DSN de PostgreSQL de un proyecto conocido del ecosistema (captacion_clientes, seo_analytics) sin lanzar. Centraliza el patron inline repetido por el agente: leer el DSN desde la variable de entorno del proyecto (CAPTACION_DSN, SEO_DSN), caer a la linea <ENV_VAR>= del .env del proyecto, y como ultimo recurso construirlo desde el secreto de pass (password en runtime, user/host/port/db fijos por proyecto). Cada proyecto declara su politica de resolucion en un mapa interno explicito (_PROJECTS) con alias para el nombre largo. Orden de resolucion: (1) env var, (2) .env, (3) pass. Devuelve {status:'ok', project, dsn, source} con source='env'|'dotenv'|'pass', o {status:'error', error} si el proyecto es desconocido o no se pudo construir el DSN. NUNCA hardcodea el password: lo lee de pass via pass_get_secret en runtime."
tags: [postgres, postgresql, dsn, credential, infra]
uses_functions: [pass_get_secret_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: [os]
tested: true
tests: ["env var seteada gana y source es env", "proyecto desconocido devuelve error sin lanzar", "alias largo resuelve a la clave canonica", "fallback a .env cuando no hay env var"]
test_file_path: "python/functions/infra/resolve_pg_dsn_test.py"
file_path: "python/functions/infra/resolve_pg_dsn.py"
params:
- name: project
desc: "Nombre del proyecto. Acepta la clave canonica ('captacion', 'seo') o el alias largo ('captacion_clientes', 'seo_analytics'). Un nombre no registrado devuelve {status:'error'} con la lista de proyectos conocidos."
output: "dict. En exito: {status:'ok', project:str (clave canonica), dsn:str (cadena postgresql://...), source:str ('env'|'dotenv'|'pass')}. En error (sin lanzar): {status:'error', error:str} para proyecto desconocido o DSN no resoluble."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.resolve_pg_dsn import resolve_pg_dsn
# Por nombre corto o largo, da igual.
res = resolve_pg_dsn("captacion")
print(res["status"]) # ok
print(res["source"]) # 'dotenv' (lee CAPTACION_DSN del .env del proyecto)
# res["dsn"] -> "postgresql://captacion:***@localhost:5433/trends"
# La env var, si esta seteada, gana sobre el .env y sobre pass.
os.environ["SEO_DSN"] = "postgresql://captacion:x@localhost:5433/seo"
print(resolve_pg_dsn("seo_analytics")["source"]) # env
```
## Cuando usarla
Usala antes de cualquier `psql`/`psycopg2`/`pg_query` contra el Postgres de un
proyecto del ecosistema, en vez de reescribir a mano la resolucion del DSN
(grep al .env + fallback a pass). Es el unico sitio que sabe como se llama la
env var de cada proyecto, donde vive su .env y de que entry de pass sale el
password. Si vas a lanzar varias queries seguidas, resuelve el DSN una vez y
reusalo; para el caso comun de "una query a un proyecto" usa el pipeline
`query_project_pg_py_pipelines` que ya compone esta resolucion con `pg_query`.
## Gotchas
- Impura: lee variables de entorno, el `.env` del proyecto en disco y ejecuta
`pass show` como subproceso. El resultado depende del entorno de la maquina.
- El `dsn` devuelto **contiene el password en claro**. NO lo logees ni lo
imprimas en produccion (el `## Ejemplo` lo redacta a proposito).
- La ruta del `.env` se resuelve relativa a `FN_REGISTRY_ROOT` si esa env var
esta seteada; si no, relativa al cwd. Lanza desde la raiz del registry o
exporta `FN_REGISTRY_ROOT` para que el paso (2) `.env` funcione.
- Solo conoce los proyectos del mapa `_PROJECTS`. Anadir uno nuevo = una entrada
de diccionario (env_var + dotenv_path + pass_path + pg fijos), no otro bloque
de bash inline.
- El fallback de `seo` apunta hoy al mismo entry de pass que `captacion`
(mismo contenedor Postgres, distinta db `seo`). Si seo_analytics pasa a tener
credenciales propias, actualiza `_PROJECTS['seo']`.
+142
View File
@@ -0,0 +1,142 @@
"""Resuelve el DSN de PostgreSQL de un proyecto conocido del ecosistema.
Centraliza el patrón que el agente reescribía inline una y otra vez: leer el
DSN de un proyecto desde su variable de entorno, caer al fichero ``.env`` del
proyecto, y como último recurso construirlo desde el secreto guardado en
``pass``. Cada proyecto declara su política de resolución en un mapa interno
explícito (``_PROJECTS``), de modo que añadir un proyecto nuevo es una sola
entrada de diccionario, no otra copia del bloque de bash.
Es una función impura (lee env, ficheros y ``pass``) que NUNCA lanza: devuelve
un dict ``{status:'ok', ...}`` en éxito y ``{status:'error', error}`` en fallo,
siguiendo el estilo del resto de funciones I/O del registry. El password sale
de ``pass`` en runtime — jamás está hardcodeado en este módulo.
"""
import os
from infra.pass_get_secret import pass_get_secret
# Mapa EXPLÍCITO de proyectos conocidos -> cómo resolver su DSN.
#
# Cada entrada declara:
# env_var: variable de entorno que (si está seteada) gana sobre todo.
# dotenv_path: ruta (relativa a la raíz del registry) del .env del proyecto.
# La línea buscada dentro del .env es "<env_var>=<dsn>".
# pass_path: ruta del secreto en `pass` desde la que construir el fallback.
# pg: parámetros fijos para construir el DSN desde el secreto de pass.
# user/host/port/db son estables por proyecto; el password es la
# primera línea del secreto de pass y se lee en runtime.
#
# Los alias (claves múltiples que apuntan a la misma config) permiten llamar a
# la función con el nombre corto ("captacion") o el largo ("captacion_clientes").
_PROJECTS = {
"captacion": {
"env_var": "CAPTACION_DSN",
"dotenv_path": "projects/captacion_clientes/.env",
"pass_path": "captacion/postgres",
"pg": {"user": "captacion", "host": "localhost", "port": "5433", "db": "trends"},
},
"seo": {
"env_var": "SEO_DSN",
# seo_analytics no fija un .env canónico hoy; se resuelve por env var
# (la convención que ya usa ingest_gsc_search_analytics) o por pass.
"dotenv_path": "projects/seo_analytics/.env",
"pass_path": "captacion/postgres",
"pg": {"user": "captacion", "host": "localhost", "port": "5433", "db": "seo"},
},
}
# Alias: nombre largo del proyecto -> clave canónica en _PROJECTS.
_ALIASES = {
"captacion_clientes": "captacion",
"seo_analytics": "seo",
}
def _canonical(project: str) -> str:
"""Normaliza el nombre del proyecto a su clave canónica en _PROJECTS."""
key = (project or "").strip().lower()
return _ALIASES.get(key, key)
def _read_dotenv_line(dotenv_path: str, env_var: str) -> str:
"""Devuelve el valor de la línea ``<env_var>=...`` del .env, o "" si no está.
Resuelve la ruta relativa a la raíz del registry usando FN_REGISTRY_ROOT si
está disponible; en su defecto asume el cwd actual. Quita comillas dobles o
simples envolventes del valor.
"""
root = os.environ.get("FN_REGISTRY_ROOT", "").strip()
full = os.path.join(root, dotenv_path) if root else dotenv_path
try:
with open(full, "r", encoding="utf-8") as fh:
prefix = env_var + "="
for raw in fh:
line = raw.strip()
if line.startswith(prefix):
value = line[len(prefix):].strip()
if len(value) >= 2 and value[0] in "\"'" and value[-1] == value[0]:
value = value[1:-1]
return value
except OSError:
return ""
return ""
def resolve_pg_dsn(project: str) -> dict:
"""Resuelve el DSN PostgreSQL de un proyecto conocido sin lanzar.
Orden de resolución (gana el primero que tenga valor):
1. La variable de entorno del proyecto (``env``).
2. La línea ``<ENV_VAR>=<dsn>`` del ``.env`` del proyecto (``dotenv``).
3. Un DSN construido a partir del secreto de ``pass`` (``pass``): el
password es la primera línea del secreto; user/host/port/db son fijos
por proyecto. El password NO se hardcodea: se lee en runtime.
Args:
project: nombre del proyecto. Acepta la clave canónica ("captacion",
"seo") o el alias largo ("captacion_clientes", "seo_analytics").
Returns:
dict. En éxito: ``{status:'ok', project, dsn, source}`` donde ``source``
es ``'env'`` | ``'dotenv'`` | ``'pass'`` según de dónde salió el DSN.
En error (sin lanzar): ``{status:'error', error}`` (proyecto desconocido
o no se pudo construir el DSN por ningún medio).
"""
canonical = _canonical(project)
cfg = _PROJECTS.get(canonical)
if cfg is None:
known = ", ".join(sorted(set(_PROJECTS) | set(_ALIASES)))
return {
"status": "error",
"error": f"unknown project '{project}'. Known: {known}",
}
env_var = cfg["env_var"]
# 1. Variable de entorno (gana sobre todo).
env_dsn = os.environ.get(env_var, "").strip()
if env_dsn:
return {"status": "ok", "project": canonical, "dsn": env_dsn, "source": "env"}
# 2. Línea del .env del proyecto.
dotenv_dsn = _read_dotenv_line(cfg["dotenv_path"], env_var)
if dotenv_dsn:
return {"status": "ok", "project": canonical, "dsn": dotenv_dsn, "source": "dotenv"}
# 3. Fallback: construir desde el secreto de pass (password en runtime).
secret = pass_get_secret(cfg["pass_path"], line=1)
if secret.get("status") != "ok":
return {
"status": "error",
"error": (
f"could not resolve DSN for '{canonical}': env var {env_var} unset, "
f"no line in .env, and pass failed: {secret.get('error')}"
),
}
password = secret["value"]
pg = cfg["pg"]
dsn = f"postgresql://{pg['user']}:{password}@{pg['host']}:{pg['port']}/{pg['db']}"
return {"status": "ok", "project": canonical, "dsn": dsn, "source": "pass"}
@@ -0,0 +1,56 @@
"""Tests para resolve_pg_dsn.
No tocan pass ni el disco salvo via monkeypatch sobre os.environ y un .env
temporal. El fallback a pass se valida indirectamente (proyecto desconocido,
prioridad del env var) sin invocar el subproceso real.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "functions"))
from infra.resolve_pg_dsn import resolve_pg_dsn
def test_env_var_seteada_gana_y_source_es_env(monkeypatch):
"""La env var del proyecto gana sobre .env y pass; source == 'env'."""
expected = "postgresql://captacion:secret@localhost:5433/trends"
monkeypatch.setenv("CAPTACION_DSN", expected)
res = resolve_pg_dsn("captacion")
assert res["status"] == "ok"
assert res["dsn"] == expected
assert res["source"] == "env"
assert res["project"] == "captacion"
def test_proyecto_desconocido_devuelve_error_sin_lanzar():
"""Un proyecto no registrado devuelve {status:'error'} sin excepcion."""
res = resolve_pg_dsn("no_existe_este_proyecto")
assert res["status"] == "error"
assert "unknown project" in res["error"]
def test_alias_largo_resuelve_a_la_clave_canonica(monkeypatch):
"""El alias largo 'seo_analytics' resuelve a la clave canonica 'seo'."""
monkeypatch.setenv("SEO_DSN", "postgresql://captacion:x@localhost:5433/seo")
res = resolve_pg_dsn("seo_analytics")
assert res["status"] == "ok"
assert res["project"] == "seo"
assert res["source"] == "env"
def test_fallback_a_dotenv_cuando_no_hay_env_var(monkeypatch, tmp_path):
"""Sin env var, lee la linea <ENV_VAR>= del .env del proyecto; source == 'dotenv'."""
monkeypatch.delenv("CAPTACION_DSN", raising=False)
# Monta una raiz falsa con el .env del proyecto en la ruta esperada.
proj_dir = tmp_path / "projects" / "captacion_clientes"
proj_dir.mkdir(parents=True)
dsn = "postgresql://captacion:fromdotenv@localhost:5433/trends"
(proj_dir / ".env").write_text(f'CAPTACION_DSN="{dsn}"\n', encoding="utf-8")
monkeypatch.setenv("FN_REGISTRY_ROOT", str(tmp_path))
res = resolve_pg_dsn("captacion_clientes")
assert res["status"] == "ok"
assert res["dsn"] == dsn # comillas envolventes quitadas
assert res["source"] == "dotenv"
@@ -0,0 +1,74 @@
---
name: metabase_client_from_pass
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_client_from_pass(pass_key: str, base_url: str, mode: str = 'auto') -> MetabaseClient | dict"
description: "Lee las credenciales de Metabase desde pass y devuelve un MetabaseClient autenticado, en una sola llamada. Elimina el patron inline repetido de cargar la credencial del password store y montar el cliente. Soporta dos instancias: API-key (metabase/aurgi-api-key -> header X-API-KEY) y usuario/password (captacion/metabase multi-linea -> login POST /api/session). mode='auto' detecta el formato. Compone pass_get_secret + parse_metabase_secret + metabase_auth/MetabaseClient sin reimplementarlos. Devuelve el cliente o {status:error, error} sin lanzar."
tags: [metabase, pass, secret, credential, auth, client]
uses_functions: ["pass_get_secret_py_infra", "parse_metabase_secret_py_infra", "metabase_auth_py_infra"]
uses_types: []
params:
- name: pass_key
desc: "Ruta del secreto en el password store (p.ej. 'metabase/aurgi-api-key' o 'captacion/metabase')."
- name: base_url
desc: "URL base de la instancia Metabase (p.ej. 'https://reports.autingo.es' o 'http://localhost:3030')."
- name: mode
desc: "'api_key', 'session' o 'auto' (default). En auto se detecta el formato del secreto: una sola linea de clave -> api_key; multi-linea con email/usuario -> session."
output: "MetabaseClient autenticado en exito. En fallo (sin lanzar): {status:'error', error:str} para secreto inexistente en pass, formato no parseable, o fallo de autenticacion contra Metabase."
returns: []
returns_optional: true
error_type: "error_go_core"
imports: [subprocess, httpx]
tested: true
tests: ["test_api_key_builds_client_with_x_api_key", "test_session_secret_parsed_and_auth_called", "test_auto_mode_detects_session", "test_missing_secret_returns_error_dict", "test_session_without_email_returns_error_dict"]
test_file_path: "python/functions/metabase/metabase_client_from_pass_test.py"
file_path: "python/functions/metabase/metabase_client_from_pass.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from metabase.metabase_client_from_pass import metabase_client_from_pass
# Aurgi (API-key en pass, header X-API-KEY):
client = metabase_client_from_pass(
"metabase/aurgi-api-key", "https://reports.autingo.es", mode="api_key")
# client.request("GET", "/api/user/current")
# Captacion (usuario/password multi-linea en pass, login /api/session):
client = metabase_client_from_pass(
"captacion/metabase", "http://localhost:3030", mode="session")
# Sin especificar mode: se autodetecta por el formato del secreto.
client = metabase_client_from_pass("metabase/aurgi-api-key", "https://reports.autingo.es")
```
## Cuando usarla
Cuando necesites un `MetabaseClient` autenticado y la credencial vive en `pass`:
en vez de escribir a mano el `pass show ...` + parseo + `metabase_auth` /
`MetabaseClient(...)`, llama a esta funcion con la ruta del secreto y la URL.
Cubre tanto instancias con API-key (Aurgi) como con usuario/password
(captacion). Es el punto de entrada unico para abrir un cliente desde el
password store.
## Gotchas
- **Impura**: lanza el subproceso `pass show` y abre conexion HTTP a Metabase.
El secreto nunca se logea, pero el `MetabaseClient` retornado lleva el token en
memoria.
- En `mode='session'` el secreto debe tener una linea de usuario con prefijo
`email:` / `login:` / `username:` / `user:`; si falta, devuelve error dict (no
lanza).
- La deteccion de API-key se basa en que la clave empieza por `mb_` (lo gestiona
`MetabaseClient`). Una API-key con otro prefijo se enviaria como session token
-> usa `mode='api_key'` explicito si tu key no empieza por `mb_`.
- Cierra el cliente cuando termines: `client.close()` o usalo como context
manager (`with metabase_client_from_pass(...) as client:`).
- Los fallos de auth (401, instancia caida) se devuelven como
`{status:'error', error}` — comprueba el tipo del retorno antes de usarlo.
@@ -0,0 +1,95 @@
"""Construye un MetabaseClient autenticado leyendo credenciales desde `pass`.
Elimina el patron inline repetido de "leer la credencial de Metabase del
password store y montar un cliente autenticado", que hoy se reescribe a mano
para dos instancias distintas:
- Aurgi: API-key (``metabase/aurgi-api-key``, una sola linea ``mb_...``) ->
header ``X-API-KEY``.
- Captacion: usuario/password (``captacion/metabase``, multi-linea: primera
linea password, linea ``email:`` con el usuario) -> login via
``POST /api/session``.
Compone tres funciones del registry: ``pass_get_secret`` (lee el secreto),
``parse_metabase_secret`` (parser puro que distingue api_key vs session) y
``metabase_auth`` / ``MetabaseClient`` (auth). No reimplementa ninguna.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from infra.pass_get_secret import pass_get_secret
from metabase.parse_metabase_secret import parse_metabase_secret
from metabase.client import MetabaseClient, metabase_auth
# Tope de lineas a leer del secreto. pass_get_secret devuelve una linea por
# llamada; leemos hasta este maximo o hasta "out of range" para reconstruir el
# texto completo sin reimplementar el subproceso de pass.
_MAX_SECRET_LINES = 16
def metabase_client_from_pass(
pass_key: str,
base_url: str,
mode: str = "auto",
) -> MetabaseClient | dict:
"""Lee credenciales de Metabase de `pass` y devuelve un cliente autenticado.
Args:
pass_key: ruta del secreto en el password store (p.ej.
``"metabase/aurgi-api-key"`` o ``"captacion/metabase"``).
base_url: URL base de la instancia Metabase (p.ej.
``"https://reports.autingo.es"``).
mode: ``"api_key"``, ``"session"`` o ``"auto"`` (default). En ``auto`` se
detecta el formato del secreto: una sola linea de clave -> api_key;
multi-linea con email/usuario -> session.
Returns:
``MetabaseClient`` autenticado en exito. En caso de fallo (sin lanzar):
``{"status": "error", "error": str}`` para: secreto no encontrado en
pass, formato no parseable, o fallo de autenticacion contra Metabase.
Example:
>>> client = metabase_client_from_pass(
... "metabase/aurgi-api-key", "https://reports.autingo.es", mode="api_key")
>>> client.request("GET", "/api/user/current") # doctest: +SKIP
"""
secret_text = _read_secret_text(pass_key)
if isinstance(secret_text, dict):
return secret_text # error dict de pass
parsed = parse_metabase_secret(secret_text, mode=mode)
if parsed["status"] != "ok":
return {"status": "error", "error": parsed["error"]}
try:
if parsed["mode"] == "api_key":
# MetabaseClient detecta "mb_" -> usa header X-API-KEY.
return MetabaseClient(base_url, parsed["api_key"])
# mode == "session": login con email/password via POST /api/session.
return metabase_auth(base_url, parsed["email"], parsed["password"])
except Exception as exc: # noqa: BLE001 - cualquier fallo de red/auth se reporta
return {"status": "error", "error": f"metabase auth failed: {exc}"}
def _read_secret_text(pass_key: str) -> str | dict:
"""Reconstruye el texto multi-linea del secreto via pass_get_secret.
Llama a pass_get_secret linea por linea (1-indexed) hasta agotar las lineas
("line N out of range") o llegar al tope. Devuelve el texto unido por
``\\n`` o un dict de error si la primera lectura falla (pass no instalado,
entry inexistente, etc.).
"""
first = pass_get_secret(pass_key, line=1)
if first["status"] != "ok":
return {"status": "error", "error": first["error"]}
lines = [first["value"]]
for n in range(2, _MAX_SECRET_LINES + 1):
res = pass_get_secret(pass_key, line=n)
if res["status"] != "ok":
break # out of range = fin del secreto
lines.append(res["value"])
return "\n".join(lines)
@@ -0,0 +1,95 @@
"""Tests para metabase_client_from_pass (pass mockeado, sin red real)."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import metabase.metabase_client_from_pass as mod
from metabase.client import MetabaseClient
def _fake_pass(store: dict):
"""Devuelve un pass_get_secret falso que lee de un dict {key: [lineas]}."""
def _impl(path, *, line=1, timeout_s=10.0):
lines = store.get(path)
if lines is None:
return {"status": "error", "error": f"{path} is not in the password store"}
if line < 1 or line > len(lines):
return {"status": "error", "error": f"line {line} out of range"}
return {"status": "ok", "value": lines[line - 1]}
return _impl
def test_api_key_builds_client_with_x_api_key(monkeypatch):
store = {"metabase/aurgi-api-key": ["mb_fakekey1234567890"]}
monkeypatch.setattr(mod, "pass_get_secret", _fake_pass(store))
client = mod.metabase_client_from_pass(
"metabase/aurgi-api-key", "https://reports.example.test", mode="api_key"
)
assert isinstance(client, MetabaseClient)
assert client.token == "mb_fakekey1234567890"
# API key -> header X-API-KEY (no toca red).
assert client._http.headers.get("X-API-KEY") == "mb_fakekey1234567890"
client.close()
def test_session_secret_parsed_and_auth_called(monkeypatch):
# Secreto multi-linea estilo captacion/metabase.
store = {
"captacion/metabase": [
"hunter2pass",
"email: admin@captacion.local",
"url: http://localhost:3030",
]
}
monkeypatch.setattr(mod, "pass_get_secret", _fake_pass(store))
captured = {}
def _fake_auth(base_url, email, password):
captured["base_url"] = base_url
captured["email"] = email
captured["password"] = password
return MetabaseClient(base_url, "session_token_abc")
monkeypatch.setattr(mod, "metabase_auth", _fake_auth)
client = mod.metabase_client_from_pass(
"captacion/metabase", "http://localhost:3030", mode="session"
)
assert isinstance(client, MetabaseClient)
assert captured["email"] == "admin@captacion.local"
assert captured["password"] == "hunter2pass"
assert captured["base_url"] == "http://localhost:3030"
client.close()
def test_auto_mode_detects_session(monkeypatch):
store = {"x/mb": ["pw", "login: bob@host.io"]}
monkeypatch.setattr(mod, "pass_get_secret", _fake_pass(store))
monkeypatch.setattr(
mod, "metabase_auth", lambda b, e, p: MetabaseClient(b, "tok")
)
client = mod.metabase_client_from_pass("x/mb", "http://h", mode="auto")
assert isinstance(client, MetabaseClient)
client.close()
def test_missing_secret_returns_error_dict(monkeypatch):
monkeypatch.setattr(mod, "pass_get_secret", _fake_pass({}))
res = mod.metabase_client_from_pass("does/not/exist", "http://h")
assert isinstance(res, dict)
assert res["status"] == "error"
def test_session_without_email_returns_error_dict(monkeypatch):
store = {"x/mb": ["onlypassword", "url: http://h"]}
monkeypatch.setattr(mod, "pass_get_secret", _fake_pass(store))
res = mod.metabase_client_from_pass("x/mb", "http://h", mode="session")
assert isinstance(res, dict)
assert res["status"] == "error"
assert "email" in res["error"]
@@ -0,0 +1,59 @@
---
name: parse_metabase_secret
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def parse_metabase_secret(secret_text: str, mode: str = 'auto') -> dict"
description: "Parser puro que extrae credenciales de Metabase del texto crudo de un secreto de pass. Distingue API-key (una sola linea, p.ej. mb_... de metabase/aurgi-api-key) de sesion (multi-linea estilo captacion/metabase: primera linea password, linea email:/user:/login:/username: con el usuario). mode='auto' detecta el formato; mode='api_key' o 'session' fuerzan. No hace I/O. Devuelve dict {status, mode, api_key} o {status, mode, email, password} o {status:error, error}. Nunca lanza ni logea el secreto."
tags: [metabase, pass, secret, credential, parse, pure]
uses_functions: []
uses_types: []
params:
- name: secret_text
desc: "Contenido completo del secreto leido de pass (varias lineas separadas por \\n). Primera linea = password/clave por convencion de pass; lineas siguientes = metadata."
- name: mode
desc: "'api_key', 'session' o 'auto' (default). En auto: si hay linea email/usuario reconocible -> session; si no -> api_key."
output: "Dict. api_key: {status:'ok', mode:'api_key', api_key:str}. session: {status:'ok', mode:'session', email:str, password:str}. error: {status:'error', error:str} para texto vacio, modo invalido o session sin email/password."
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_api_key_explicit", "test_session_multiline_email_prefix", "test_auto_detects_session_when_email_present", "test_auto_detects_api_key_single_line", "test_session_without_email_line_errors", "test_empty_secret_errors", "test_invalid_mode_errors", "test_user_prefix_variant", "test_email_value_preserves_case"]
test_file_path: "python/functions/metabase/parse_metabase_secret_test.py"
file_path: "python/functions/metabase/parse_metabase_secret.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from metabase.parse_metabase_secret import parse_metabase_secret
# API-key (una sola linea):
parse_metabase_secret("mb_abc123")
# {'status': 'ok', 'mode': 'api_key', 'api_key': 'mb_abc123'}
# Sesion (multi-linea estilo captacion/metabase):
parse_metabase_secret("hunter2\nemail: a@b.com\nurl: http://x")
# {'status': 'ok', 'mode': 'session', 'email': 'a@b.com', 'password': 'hunter2'}
```
## Cuando usarla
Cuando ya tienes el texto de un secreto de Metabase (leido de `pass` u otra
fuente) y necesitas separarlo en credenciales utilizables sin tocar disco ni
red. Es el nucleo puro y testeable de `metabase_client_from_pass`: separa el
parseo (determinista) de la lectura del secreto y la autenticacion (impuras).
## Gotchas
- La linea del email/usuario se identifica por prefijo: `email:`, `login:`,
`username:` o `user:` (case-insensitive). Otros formatos no se detectan como
sesion y `auto` los tratara como api_key.
- Funcion pura: NO lee `pass` ni llama a Metabase. El caller le pasa el texto ya
resuelto. No logea el secreto, pero el dict de retorno SI lleva el valor en
claro — el caller debe tratarlo como sensible.
@@ -0,0 +1,97 @@
"""Parsea el texto de un secreto de `pass` para credenciales de Metabase.
Distingue dos formatos sin tocar disco ni red (funcion pura):
- API-key: una sola linea con la clave (las API keys de Metabase empiezan por
``mb_``, p.ej. el secreto ``metabase/aurgi-api-key``).
- Sesion: multi-linea estilo ``captacion/metabase`` — la primera linea es la
contrasena y una linea posterior lleva el email/usuario con un prefijo
reconocible (``email:``, ``user:``, ``login:`` o ``username:``).
El caller decide el ``mode`` y este parser solo extrae los campos del texto.
"""
# Prefijos (case-insensitive) que identifican la linea del email/usuario en un
# secreto multi-linea de pass. Se prueban en este orden.
_EMAIL_PREFIXES = ("email:", "login:", "username:", "user:")
def parse_metabase_secret(secret_text: str, mode: str = "auto") -> dict:
"""Extrae credenciales de Metabase del texto crudo de un secreto de pass.
No ejecuta `pass` ni hace I/O: recibe el texto ya leido y lo interpreta.
Funcion pura y determinista, apta para tests unitarios.
Args:
secret_text: contenido completo del secreto (varias lineas separadas por
``\\n``). Por convencion de pass la primera linea es la
contrasena/clave; las siguientes son metadata.
mode: ``"api_key"``, ``"session"`` o ``"auto"`` (default). En ``auto`` se
detecta el formato: si hay una linea de email/usuario reconocible se
asume sesion; si no, se asume api_key (una sola linea de clave).
Returns:
Dict. Nunca lanza:
- api_key -> ``{"status": "ok", "mode": "api_key", "api_key": str}``
- session -> ``{"status": "ok", "mode": "session", "email": str,
"password": str}``
- error -> ``{"status": "error", "error": str}`` para texto vacio, modo
invalido, o session sin email/password localizables.
Example:
>>> parse_metabase_secret("mb_abc123")
{'status': 'ok', 'mode': 'api_key', 'api_key': 'mb_abc123'}
>>> parse_metabase_secret("hunter2\\nemail: a@b.com\\nurl: http://x")
{'status': 'ok', 'mode': 'session', 'email': 'a@b.com', 'password': 'hunter2'}
"""
if mode not in ("api_key", "session", "auto"):
return {"status": "error", "error": f"invalid mode {mode!r}"}
lines = secret_text.splitlines()
if not lines or not lines[0].strip():
return {"status": "error", "error": "empty secret"}
email = _find_email(lines)
if mode == "auto":
mode = "session" if email is not None else "api_key"
if mode == "api_key":
return {
"status": "ok",
"mode": "api_key",
"api_key": lines[0].strip(),
}
# mode == "session"
if email is None:
return {
"status": "error",
"error": (
"session secret without email/user line "
f"(expected one of {', '.join(_EMAIL_PREFIXES)})"
),
}
password = lines[0].strip()
if not password:
return {"status": "error", "error": "session secret without password"}
return {
"status": "ok",
"mode": "session",
"email": email,
"password": password,
}
def _find_email(lines: list[str]) -> str | None:
"""Devuelve el email/usuario de la primera linea con prefijo reconocido."""
for raw in lines[1:]:
low = raw.strip().lower()
for prefix in _EMAIL_PREFIXES:
if low.startswith(prefix):
# Conserva el valor original (no el lowercased) tras el prefijo.
value = raw.strip()[len(prefix):].strip()
if value:
return value
return None
@@ -0,0 +1,68 @@
"""Tests para parse_metabase_secret."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from metabase.parse_metabase_secret import parse_metabase_secret
def test_api_key_explicit():
res = parse_metabase_secret("mb_abc123def", mode="api_key")
assert res == {"status": "ok", "mode": "api_key", "api_key": "mb_abc123def"}
def test_session_multiline_email_prefix():
secret = "hunter2\nemail: admin@captacion.local\nurl: http://localhost:3030"
res = parse_metabase_secret(secret, mode="session")
assert res == {
"status": "ok",
"mode": "session",
"email": "admin@captacion.local",
"password": "hunter2",
}
def test_auto_detects_session_when_email_present():
secret = "secretpass\nlogin: bob@example.com"
res = parse_metabase_secret(secret, mode="auto")
assert res["mode"] == "session"
assert res["email"] == "bob@example.com"
assert res["password"] == "secretpass"
def test_auto_detects_api_key_single_line():
res = parse_metabase_secret("mb_singleLineKey", mode="auto")
assert res["mode"] == "api_key"
assert res["api_key"] == "mb_singleLineKey"
def test_session_without_email_line_errors():
res = parse_metabase_secret("onlypassword\nurl: http://x", mode="session")
assert res["status"] == "error"
assert "email" in res["error"]
def test_empty_secret_errors():
res = parse_metabase_secret("", mode="auto")
assert res["status"] == "error"
assert res["error"] == "empty secret"
def test_invalid_mode_errors():
res = parse_metabase_secret("mb_x", mode="bogus")
assert res["status"] == "error"
assert "invalid mode" in res["error"]
def test_user_prefix_variant():
secret = "pw\nuser: someone@host.io"
res = parse_metabase_secret(secret, mode="auto")
assert res["email"] == "someone@host.io"
def test_email_value_preserves_case():
secret = "pw\nEMAIL: MixedCase@Host.COM"
res = parse_metabase_secret(secret, mode="session")
assert res["email"] == "MixedCase@Host.COM"
@@ -0,0 +1,77 @@
---
name: build_relief_glb_from_image
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def build_relief_glb_from_image(image_path: str, out_glb_path: str, model_name: str = 'depth-anything/Depth-Anything-V2-Small-hf', device: str = 'auto', z_scale: float = 0.35, max_dim: int = 220) -> dict"
description: "Pipeline one-shot imagen 2D -> .glb de relieve texturizado. Compone estimate_image_depth (profundidad monocular Depth-Anything-V2) + depth_to_relief_glb (malla heightmap + textura) en una sola llamada. Promueve a un paso la secuencia que img_to_3d_webapp hacia en dos (issue 0087). Grupo img-to-3d."
tags: [img-to-3d, pipelines, depth, glb, gltf, mesh, relief, 3d, computer-vision, launcher]
uses_functions: [estimate_image_depth_py_datascience, depth_to_relief_glb_py_datascience]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: image_path
desc: "Ruta a la imagen de entrada (cualquier formato que PIL abra). Si no existe vuelve {status:error, stage:estimate}."
- name: out_glb_path
desc: "Ruta de salida del .glb. Su directorio padre debe existir o falla en la etapa relief (status error)."
- name: model_name
desc: "Id de modelo HuggingFace de depth-estimation. Default Depth-Anything-V2-Small-hf (rapido)."
- name: device
desc: "'auto' (GPU0 si hay), 'cpu', o indice/cadena cuda. Ver gotchas de estimate_image_depth para el detalle de 'cuda:N' vs indice entero."
- name: z_scale
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas exagerado."
- name: max_dim
desc: "Lado maximo del grid tras downsample (default 220, ~36k-48k vertices segun aspect ratio). Controla detalle vs peso del .glb."
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int, model:str, device:str}. Error: {status:'error', stage:'estimate'|'relief', error:str}. No lanza. Salida JSON-serializable (sin ndarray ni PIL), apta para `fn run`."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/build_relief_glb_from_image.py"
---
## Ejemplo
```bash
# Requiere venv con torch + transformers + trimesh + pillow + numpy (p.ej. apps/img_to_3d_webapp/backend/.venv).
./fn run build_relief_glb_from_image_py_pipelines apps/img_to_3d_webapp/samples/cats.jpg /tmp/cats_relief.glb
# {"status": "ok", "glb_path": "/tmp/cats_relief.glb", "vertices": 36300, "faces": 71832, "height": 165, "width": 220, "model": "...", "device": "auto"}
```
Como import (composicion en codigo):
```python
import sys
sys.path.insert(0, "python/functions/pipelines")
from build_relief_glb_from_image import build_relief_glb_from_image
res = build_relief_glb_from_image("apps/img_to_3d_webapp/samples/cats.jpg", "/tmp/cats_relief.glb")
print(res["status"], res["vertices"], res["faces"]) # ok 36300 71832
```
## Cuando usarla
Cuando quieras el modelo 3D (.glb) directamente desde una imagen y NO necesites el mapa de
profundidad intermedio para otra cosa. Es el atajo del grupo `img-to-3d`: una sola llamada en vez
de orquestar `estimate_image_depth` + `depth_to_relief_glb` a mano. Si necesitas el `depth` por
separado (para inspeccionarlo, reusarlo, o aplicar otra reconstruccion), llama a las dos funciones
sueltas en su lugar.
## Gotchas
- **Impuro (pipeline)**: carga modelo HuggingFace (descarga pesos la 1a vez), usa GPU/CPU y
escribe el .glb en disco. Hereda todos los gotchas de `estimate_image_depth_py_datascience` y
`depth_to_relief_glb_py_datascience` (estado de proceso del pipeline cacheado, profundidad
relativa, relieve 2.5D, directorio de salida debe existir).
- **Deps de vision**: requiere `torch`+`transformers`+`trimesh`+`pillow`+`numpy`. Importa las dos
funciones del registry de forma PLANA (anade `python/functions/datascience` a `sys.path`) para
NO arrastrar el `datascience.__init__` (que trae bs4/duckdb de otros dominios). Por eso `fn run`
de este pipeline corre limpio en el venv de vision sin necesitar las deps de los scrapers.
- **stage en el error**: el campo `stage` (`estimate` o `relief`) indica en cual de los dos pasos
fallo, util para depurar (p.ej. ruta de imagen mala -> stage estimate; dir de salida inexistente
-> stage relief).
- Tag `launcher`: aparece en el Pipeline Launcher TUI; es un subproceso one-shot (no interactivo).
@@ -0,0 +1,87 @@
"""
Pipeline one-shot imagen -> modelo 3D: estima la profundidad monocular y reconstruye una malla
de relieve texturizada exportada a .glb, en una sola llamada.
Compone (grupo de capacidad `img-to-3d`):
estimate_image_depth_py_datascience -> depth_to_relief_glb_py_datascience
Promueve a un solo paso la secuencia que la app `img_to_3d_webapp` hacia en dos (issue 0087):
en vez de orquestar estimate + relief a mano, el caller pasa la ruta de la imagen y la del .glb.
"""
from __future__ import annotations
import os
import sys
# Import PLANO de las funciones del registry (no `from datascience import ...`): el __init__ del
# paquete datascience arrastra deps de otros dominios (bs4, duckdb...) ausentes en el venv de
# vision (torch/transformers/trimesh). Importar los modulos directos evita esa dependencia.
_DS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "datascience")
if _DS_DIR not in sys.path:
sys.path.insert(0, _DS_DIR)
from estimate_image_depth import estimate_image_depth # noqa: E402
from depth_to_relief_glb import depth_to_relief_glb # noqa: E402
def build_relief_glb_from_image(
image_path: str,
out_glb_path: str,
model_name: str = "depth-anything/Depth-Anything-V2-Small-hf",
device: str = "auto",
z_scale: float = 0.35,
max_dim: int = 220,
) -> dict:
"""
Convierte una imagen 2D en un .glb de relieve texturizado en una sola llamada.
Parámetros:
image_path: ruta a la imagen de entrada (lo que PIL abra).
out_glb_path: ruta de salida del .glb (su directorio padre debe existir).
model_name: id de modelo HuggingFace de depth-estimation.
device: "auto" / "cpu" / índice o cadena cuda.
z_scale: amplitud del relieve (fracción del lado de la malla).
max_dim: lado máximo del grid tras downsample.
Devuelve (dict, nunca lanza):
Éxito: {"status":"ok", "glb_path":str, "vertices":int, "faces":int, "height":int,
"width":int, "model":str, "device":str}.
Error: {"status":"error", "stage":"estimate"|"relief", "error":str}.
"""
est = estimate_image_depth(image_path, model_name=model_name, device=device)
if est.get("status") != "ok":
return {"status": "error", "stage": "estimate", "error": est.get("error", "unknown")}
res = depth_to_relief_glb(
est["image"], est["depth"], out_glb_path, z_scale=z_scale, max_dim=max_dim
)
if res.get("status") != "ok":
return {"status": "error", "stage": "relief", "error": res.get("error", "unknown")}
return {
"status": "ok",
"glb_path": res["glb_path"],
"vertices": res["vertices"],
"faces": res["faces"],
"height": res["height"],
"width": res["width"],
"model": est["model"],
"device": est["device"],
}
if __name__ == "__main__":
# Demo directo: `python build_relief_glb_from_image.py <image_path> <out.glb> [z_scale] [max_dim]`.
import json
if len(sys.argv) < 3:
print(json.dumps({"status": "error", "error": "uso: <image_path> <out_glb_path> [z_scale] [max_dim]"}))
sys.exit(1)
zs = float(sys.argv[3]) if len(sys.argv) > 3 else 0.35
md = int(sys.argv[4]) if len(sys.argv) > 4 else 220
out = build_relief_glb_from_image(sys.argv[1], sys.argv[2], z_scale=zs, max_dim=md)
print(json.dumps(out))
if out["status"] != "ok":
sys.exit(1)
@@ -0,0 +1,86 @@
---
name: ingest_market_trends_headless
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def ingest_market_trends_headless(sources: str = '', port: int = 9334, profile_dir: str = '') -> dict"
description: "Ingesta de las fuentes CDP de tendencias (AliExpress / Amazon movers / saturación Amazon) en un Chrome headless AISLADO con perfil dedicado, lanzándolo y cerrándolo en cada corrida. Evita abrir pestañas en el navegador diario del usuario (chromium-personal, CDP 9222). Wrapper de ingest_market_trends. Proyecto captacion_clientes."
tags: [market-intel, headless, cdp, dropship, scraper, ingest]
uses_functions: [ingest_market_trends_py_pipelines]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/ingest_market_trends_headless.py"
params:
- name: sources
desc: "Fuentes CDP separadas por coma. Vacío -> las 3 del proyecto (aliexpress_cdp,amazon_movers_cdp,amazon_saturation_cdp). Un source que falle no aborta el resto."
- name: port
desc: "Puerto de remote-debugging del Chrome headless aislado. DEBE coincidir con el `port` de los bloques *_cdp en sources.json (allí ya está a 9334). Default 9334."
- name: profile_dir
desc: "user-data-dir dedicado del Chrome aislado. Vacío -> ~/.config/fn_scrape_chrome (se crea si no existe). Perfil persistente entre corridas."
output: "dict {status: 'ok'|'error', port, profile_dir, launched: bool, closed: bool, results: [{source, scraped, inserted} | {source, error}]}. Nunca lanza excepción al caller: el finally cierra siempre la instancia que lanzó."
---
## Ejemplo
```bash
# Las 3 fuentes CDP en Chrome headless aislado (lanzar -> scrape -> cerrar).
# OJO: fn run pasa los args POSICIONALES (no flags --), en el orden sources, port, profile_dir.
fn run ingest_market_trends_headless
# -> {"status":"ok","port":9334,"profile_dir":"/home/<user>/.config/fn_scrape_chrome",
# "launched":true,"closed":true,
# "results":[{"source":"aliexpress_cdp",...},{"source":"amazon_movers_cdp",...},
# {"source":"amazon_saturation_cdp","scraped":20,"inserted":20}]}
# Solo la fuente de saturación de Amazon (la validada headless sin login):
fn run ingest_market_trends_headless amazon_saturation_cdp
# -> {"status":"ok","port":9334,"launched":true,"closed":true,
# "results":[{"source":"amazon_saturation_cdp","scraped":5,"inserted":5}]}
# Puerto/perfil custom (args posicionales: sources, port, profile_dir):
fn run ingest_market_trends_headless amazon_saturation_cdp 9340 ~/.config/otro_scrape
```
Invocación directa del módulo (acepta flags `--sources`/`--port`/`--profile-dir`):
```bash
python/.venv/bin/python3 python/functions/pipelines/ingest_market_trends_headless.py \
--sources amazon_saturation_cdp
```
## Cuando usarla
Úsala para la ingesta diaria/programada (dag_engine) de las fuentes CDP del proyecto
captacion_clientes cuando NO quieras que el scraping abra pestañas en tu navegador diario.
Levanta su propio Chromium headless con perfil dedicado y lo cierra al terminar — el
navegador personal (`chromium-personal`, CDP 9222) queda intacto. Es el reemplazo de llamar
`ingest_market_trends <source_cdp>` a pelo (que usaría el 9222 con sesión interactiva).
## Gotchas
- **Impura: lanza y mata Chrome.** Arranca un Chromium headless vía `systemd-run --user`
(scope `fnscrape_dag_<port>`); si `systemd-run` no está, cae a `subprocess.Popen` con
grupo de proceso propio. Lo lanzar con `exec` directo desde el agente da **exit-144** — por
eso systemd-run. En el `finally` siempre cierra lo que lanzó (`systemctl --user stop` del
scope/service + respaldo `pkill -f "user-data-dir=<perfil>"`) y verifica con un GET final
que el puerto ya no responde (`closed`).
- **Perfil dedicado persistente.** `~/.config/fn_scrape_chrome` sobrevive entre corridas
(cookies/cache del scraping). No se borra. Bórralo a mano si quieres sesión limpia.
- **Reutiliza CDP existente.** Si el puerto ya responde al arrancar, NO lanza otro Chrome:
reutiliza el vivo y `launched=False` + `closed=False` (no cierra algo que no abrió).
- **Puerto = el del config.** Los bloques `*_cdp` de `sources.json` deben tener `port`
igual al `port` de este wrapper (ya a 9334). `ingest_market_trends(source)` lee el puerto
del config, así reutiliza la instancia que este wrapper levantó. No se le pasa el puerto.
- **AliExpress headless puede pedir captcha.** Sin login, `aliexpress_cdp` puede devolver
`status: captcha` (0 filas) — es esperado, no es bug del wrapper. El lifecycle
(launched -> scrape -> closed) se valida mejor con `amazon_saturation_cdp`.
- **DSN Postgres.** La ingesta necesita el DSN de `trends` (CAPTACION_DSN / .env / pass
captacion/postgres). Si falla la resolución, esa fuente cae en `{source, error}` pero el
Chrome igual se cierra.
@@ -0,0 +1,287 @@
"""ingest_market_trends_headless — ingesta de fuentes CDP en un Chrome headless aislado.
Wrapper de `ingest_market_trends` (pipeline del proyecto captacion_clientes) que lanza un
Chromium **headless** con un **perfil dedicado** y un puerto de remote-debugging propio,
scrapea las fuentes CDP indicadas, y **cierra la instancia al terminar** — siempre, incluso
si falla el scraping.
Motivo: el scraping NO debe abrir pestañas en el navegador diario del usuario
(`chromium-personal`, puerto 9222). Norma: perfil dedicado + headless + cerrar al terminar.
El Chrome se lanza vía `systemd-run --user` (un scope transitorio), porque lanzar chromium
con un `exec`/`Popen` directo desde el proceso del agente da exit-144 cuando hereda el grupo
de controlado del agente. Si `systemd-run` no está disponible, se cae a `subprocess.Popen`
en un grupo de proceso nuevo (`start_new_session=True`).
Las fuentes CDP (`aliexpress_cdp`, `amazon_movers_cdp`, `amazon_saturation_cdp`) leen su
`port` del config `sources.json` del proyecto. Ese config debe apuntar al MISMO puerto que
este wrapper usa para lanzar el Chrome headless (default 9334). De ese modo
`ingest_market_trends(source)` reutiliza la instancia que este wrapper levantó.
"""
import argparse
import json
import os
import shutil
import signal
import subprocess
import sys
import time
import urllib.request
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "python", "functions"))
from pipelines.ingest_market_trends import ingest_market_trends # noqa: E402
DEFAULT_SOURCES = "aliexpress_cdp,amazon_movers_cdp,amazon_saturation_cdp"
DEFAULT_PORT = 9334
DEFAULT_PROFILE = "~/.config/fn_scrape_chrome"
# Candidatos de binario chromium/chrome. shutil.which primero (respeta PATH), luego
# rutas absolutas conocidas del sistema (el `chromium` del usuario suele ser un alias de
# shell no visible a subprocess, y el binario real vive en /usr/lib/chromium/chromium).
_CHROME_NAMES = ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable")
_CHROME_ABS = (
"/usr/bin/chromium",
"/usr/lib/chromium/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/snap/bin/chromium",
)
def _find_chrome() -> str | None:
"""Devuelve la ruta a un binario chromium/chrome ejecutable, o None."""
for name in _CHROME_NAMES:
path = shutil.which(name)
if path:
return path
for path in _CHROME_ABS:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def _cdp_alive(port: int, timeout: float = 1.0) -> bool:
"""True si el endpoint CDP responde en 127.0.0.1:<port>/json/version."""
url = f"http://127.0.0.1:{port}/json/version"
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
return 200 <= resp.status < 300
except Exception: # noqa: BLE001
return False
def _wait_cdp(port: int, deadline_s: float = 12.0) -> bool:
"""Espera a que el CDP responda hasta `deadline_s` (sondea cada 0.5s)."""
end = time.time() + deadline_s
while time.time() < end:
if _cdp_alive(port):
return True
time.sleep(0.5)
return False
def _chrome_args(chrome_bin: str, port: int, profile_dir: str) -> list[str]:
return [
chrome_bin,
"--headless=new",
"--disable-gpu",
f"--remote-debugging-port={port}",
f"--user-data-dir={profile_dir}",
"--no-first-run",
"--no-default-browser-check",
"--remote-allow-origins=*",
"--disable-extensions",
]
def _launch(chrome_bin: str, port: int, profile_dir: str) -> tuple[str, int | None]:
"""Lanza Chrome headless aislado. Devuelve (mecanismo, pid).
mecanismo: 'systemd' (scope transitorio) o 'popen' (grupo de proceso propio).
pid: solo poblado en modo 'popen' (para poder matar el grupo en el cierre).
"""
unit = f"fnscrape_dag_{port}"
systemd_run = shutil.which("systemd-run")
if systemd_run:
cmd = [
systemd_run, "--user", "--quiet", "--collect", f"--unit={unit}",
*_chrome_args(chrome_bin, port, profile_dir),
]
try:
subprocess.run(cmd, check=True, timeout=15,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return "systemd", None
except Exception: # noqa: BLE001
# systemd-run falló (sin --user bus, etc.) -> fallback a Popen.
pass
proc = subprocess.Popen(
_chrome_args(chrome_bin, port, profile_dir),
start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
return "popen", proc.pid
def _close(mechanism: str, pid: int | None, port: int, profile_dir: str) -> bool:
"""Cierra la instancia que ESTE wrapper lanzó. Devuelve True si el puerto ya no responde."""
unit = f"fnscrape_dag_{port}"
if mechanism == "systemd":
systemctl = shutil.which("systemctl")
if systemctl:
for kind in (f"{unit}.scope", f"{unit}.service"):
try:
subprocess.run([systemctl, "--user", "stop", kind],
timeout=10, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
except Exception: # noqa: BLE001
pass
elif mechanism == "popen" and pid is not None:
try:
pgid = os.getpgid(pid)
os.killpg(pgid, signal.SIGTERM)
for _ in range(20): # hasta ~2s para salida limpia
time.sleep(0.1)
if not _cdp_alive(port):
break
if _cdp_alive(port):
os.killpg(pgid, signal.SIGKILL)
except ProcessLookupError:
pass
except Exception: # noqa: BLE001
pass
# Respaldo: matar cualquier chromium colgado de este perfil concreto.
pkill = shutil.which("pkill")
if pkill:
try:
subprocess.run([pkill, "-f", f"user-data-dir={profile_dir}"],
timeout=10, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
except Exception: # noqa: BLE001
pass
# Esperar a que el puerto deje de responder (cierre asíncrono del cgroup).
for _ in range(20): # hasta ~2s
if not _cdp_alive(port):
return True
time.sleep(0.1)
return not _cdp_alive(port)
def ingest_market_trends_headless(
sources: str = "",
port: int = DEFAULT_PORT,
profile_dir: str = "",
) -> dict:
"""Lanza un Chrome headless aislado, scrapea las fuentes CDP y lo cierra al terminar.
Args:
sources: fuentes CDP separadas por coma. Vacío -> las 3 del proyecto
(aliexpress_cdp,amazon_movers_cdp,amazon_saturation_cdp).
port: puerto de remote-debugging del Chrome aislado (debe coincidir con el `port`
de los bloques `*_cdp` en sources.json). Default 9334.
profile_dir: user-data-dir dedicado. Vacío -> ~/.config/fn_scrape_chrome.
Returns:
dict con {status, port, profile_dir, launched, closed, results:[...]}. Nunca lanza
excepción al caller: cualquier fallo se refleja en `status`/`results` y el finally
cierra la instancia.
"""
if not sources:
sources = DEFAULT_SOURCES
if not profile_dir:
profile_dir = os.path.expanduser(DEFAULT_PROFILE)
profile_dir = os.path.abspath(os.path.expanduser(profile_dir))
os.makedirs(profile_dir, exist_ok=True)
out: dict = {
"status": "error",
"port": port,
"profile_dir": profile_dir,
"launched": False,
"closed": False,
"results": [],
}
mechanism = ""
pid: int | None = None
reuse = False
# 1) Si ya hay un CDP vivo en el puerto, reutilizarlo (no lo cerraremos).
if _cdp_alive(port):
reuse = True
else:
chrome_bin = _find_chrome()
if not chrome_bin:
out["error"] = (
"no se encontró binario chromium/chrome "
f"(probados: {', '.join(_CHROME_NAMES)} + rutas absolutas conocidas)"
)
return out
try:
mechanism, pid = _launch(chrome_bin, port, profile_dir)
out["launched"] = True
except Exception as exc: # noqa: BLE001
out["error"] = f"fallo al lanzar chromium: {exc}"
return out
# 2) Esperar a que el CDP responda.
if not _wait_cdp(port, deadline_s=12.0):
out["error"] = f"el CDP no respondió en 127.0.0.1:{port} tras 12s"
out["closed"] = _close(mechanism, pid, port, profile_dir)
return out
# 3) Scrapear cada fuente. Un fallo de una fuente no aborta el resto.
try:
for raw in sources.split(","):
source = raw.strip()
if not source:
continue
try:
summary = ingest_market_trends(source)
out["results"].append({
"source": source,
"scraped": summary.get("scraped"),
"inserted": summary.get("inserted"),
**({"note": summary["note"]} if summary.get("note") else {}),
**({"status": summary["status"]} if summary.get("status") else {}),
})
except Exception as exc: # noqa: BLE001
out["results"].append({"source": source, "error": str(exc)})
out["status"] = "ok"
finally:
# 4) Cerrar SIEMPRE lo que nosotros lanzamos (no si reutilizamos uno ajeno).
if out["launched"] and not reuse:
out["closed"] = _close(mechanism, pid, port, profile_dir)
else:
out["closed"] = False
return out
def main() -> int:
ap = argparse.ArgumentParser(
description="Ingesta de fuentes CDP en un Chrome headless aislado (perfil dedicado)."
)
ap.add_argument("--sources", default="",
help=f"Fuentes CDP separadas por coma. Vacío -> {DEFAULT_SOURCES}.")
ap.add_argument("--port", type=int, default=DEFAULT_PORT,
help="Puerto remote-debugging del Chrome aislado.")
ap.add_argument("--profile-dir", default="",
help="user-data-dir dedicado (vacío -> ~/.config/fn_scrape_chrome).")
args = ap.parse_args()
result = ingest_market_trends_headless(
sources=args.sources, port=args.port, profile_dir=args.profile_dir,
)
print(json.dumps(result, ensure_ascii=False))
return 0 if result.get("status") == "ok" else 1
if __name__ == "__main__":
sys.exit(main())
+44 -18
View File
@@ -38,8 +38,9 @@ from datascience import (
run_eda_models, run_eda_models,
summarize_categorical, summarize_categorical,
summarize_table_duckdb, summarize_table_duckdb,
summarize_table_pg,
) )
from infra import duckdb_query_readonly from infra import duckdb_query_readonly, pg_query
# semantic_types que justifican promocionar inferred_type -> "numeric". # semantic_types que justifican promocionar inferred_type -> "numeric".
_NUMERIC_SEMANTIC = ("integer", "decimal", "currency") _NUMERIC_SEMANTIC = ("integer", "decimal", "currency")
@@ -82,10 +83,13 @@ def _to_float(value):
return None return None
def _sample_values(db_path: str, table: str, name: str, sample: int) -> list: def _sample_values(query_fn, table: str, name: str, sample: int) -> list:
"""Trae hasta `sample` valores no nulos de una columna (read-only).""" """Trae hasta `sample` valores no nulos de una columna (read-only).
q = duckdb_query_readonly(
db_path, query_fn(sql) -> dict es el lector read-only del backend activo
(duckdb_query_readonly o pg_query), inyectado por profile_table.
"""
q = query_fn(
f'SELECT "{name}" AS v FROM "{table}" WHERE "{name}" IS NOT NULL ' f'SELECT "{name}" AS v FROM "{table}" WHERE "{name}" IS NOT NULL '
f"LIMIT {int(sample)}", f"LIMIT {int(sample)}",
) )
@@ -94,19 +98,18 @@ def _sample_values(db_path: str, table: str, name: str, sample: int) -> list:
return [row.get("v") for row in q.get("rows", [])] return [row.get("v") for row in q.get("rows", [])]
def _sample_rows(db_path: str, table: str, names: list, sample: int) -> list: def _sample_rows(query_fn, table: str, names: list, sample: int) -> list:
"""Trae hasta `sample` filas completas con las columnas alineadas por fila. """Trae hasta `sample` filas completas con las columnas alineadas por fila.
A diferencia de _sample_values (una columna, solo no nulos), esto preserva la A diferencia de _sample_values (una columna, solo no nulos), esto preserva la
alineacion por fila entre columnas, requisito de la matriz de asociacion alineacion por fila entre columnas, requisito de la matriz de asociacion
(los pares (a_i, b_i) deben venir de la misma fila). (los pares (a_i, b_i) deben venir de la misma fila). query_fn es el lector
read-only del backend activo, inyectado por profile_table.
""" """
if not names: if not names:
return [] return []
cols_sql = ", ".join(f'"{n}"' for n in names) cols_sql = ", ".join(f'"{n}"' for n in names)
q = duckdb_query_readonly( q = query_fn(f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}')
db_path, f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}'
)
if q.get("status") != "ok": if q.get("status") != "ok":
return [] return []
return q.get("rows", []) return q.get("rows", [])
@@ -115,17 +118,20 @@ def _sample_rows(db_path: str, table: str, names: list, sample: int) -> list:
def profile_table( def profile_table(
db_path: str, db_path: str,
table: str, table: str,
backend: str = "duckdb",
sample: int = 5000, sample: int = 5000,
run_models: bool = False, run_models: bool = False,
run_llm: bool = False, run_llm: bool = False,
report_dir: str = "reports", report_dir: str = "reports",
write_report: bool = True, write_report: bool = True,
) -> dict: ) -> dict:
"""Perfila una tabla DuckDB end-to-end y emite el TableProfile completo. """Perfila una tabla (DuckDB o PostgreSQL) end-to-end y emite el TableProfile.
Args: Args:
db_path: ruta al archivo DuckDB (read-only, debe existir). db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres".
table: nombre de la tabla a perfilar. table: nombre de la tabla a perfilar.
backend: "duckdb" (default) o "postgres". Selecciona el motor de
perfilado base (summarize) y de muestreo read-only.
sample: maximo de valores no nulos muestreados por columna para el sample: maximo de valores no nulos muestreados por columna para el
enriquecimiento (describe_numeric / summarize_categorical / enriquecimiento (describe_numeric / summarize_categorical /
infer_semantic_type). Default 5000. infer_semantic_type). Default 5000.
@@ -141,8 +147,22 @@ def profile_table(
lanzar): {status:'error', error:str}. lanzar): {status:'error', error:str}.
""" """
try: try:
# 1) Perfil base por columna (push-down SQL). # 1) Perfil base por columna (push-down SQL) + lector read-only del
# backend activo, inyectado en el muestreo (_sample_values/_sample_rows).
if backend == "postgres":
r = summarize_table_pg(db_path, table)
def _q(sql):
return pg_query(db_path, sql)
elif backend == "duckdb":
r = summarize_table_duckdb(db_path, table) r = summarize_table_duckdb(db_path, table)
def _q(sql):
return duckdb_query_readonly(db_path, sql)
else:
return {"status": "error", "error": f"backend desconocido: {backend}"}
if r.get("status") != "ok": if r.get("status") != "ok":
return {"status": "error", "error": r.get("error", "summarize failed")} return {"status": "error", "error": r.get("error", "summarize failed")}
prof = r["profile"] prof = r["profile"]
@@ -153,7 +173,7 @@ def profile_table(
inferred = col.get("inferred_type") inferred = col.get("inferred_type")
# 2) Muestra de valores no nulos. # 2) Muestra de valores no nulos.
vals = _sample_values(db_path, table, name, sample) vals = _sample_values(_q, table, name, sample)
# 3) Promocion de tipo sobre columnas textuales. # 3) Promocion de tipo sobre columnas textuales.
if inferred in ("categorical", "text"): if inferred in ("categorical", "text"):
@@ -239,7 +259,7 @@ def profile_table(
assoc_cols = [c for c in cols if not _skip_for_assoc(c)] assoc_cols = [c for c in cols if not _skip_for_assoc(c)]
rows = _sample_rows( rows = _sample_rows(
db_path, table, [c["name"] for c in assoc_cols], corr_sample _q, table, [c["name"] for c in assoc_cols], corr_sample
) )
assoc_input = {} assoc_input = {}
for c in assoc_cols: for c in assoc_cols:
@@ -256,11 +276,17 @@ def profile_table(
prof["correlations"] = ( prof["correlations"] = (
association_matrix(assoc_input) if len(assoc_input) >= 2 else None association_matrix(assoc_input) if len(assoc_input) >= 2 else None
) )
# Modelos baratos opt-in (PCA/KMeans/IsolationForest/normalidad).
if run_models:
prof["models"] = run_eda_models(assoc_input)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
prof["correlations"] = None prof["correlations"] = None
assoc_input = {}
# Modelos baratos opt-in en su PROPIO try: un fallo de los modelos NUNCA
# debe tumbar las correlaciones ya calculadas (bug detectado en EDAs PG
# reales: un try/except compartido ponia ambos campos a None).
if run_models:
try:
prof["models"] = run_eda_models(assoc_input)
except Exception: # noqa: BLE001
prof["models"] = None prof["models"] = None
# 8.6) Capa LLM opcional: interpreta el perfil ya calculado en UNA # 8.6) Capa LLM opcional: interpreta el perfil ya calculado en UNA
@@ -0,0 +1,70 @@
---
name: query_project_pg
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def query_project_pg(project: str, sql: str, max_rows: int = 10000) -> dict"
description: "Pipeline one-shot que ejecuta un SELECT contra el Postgres de un proyecto conocido del ecosistema (captacion_clientes, seo_analytics) sin reescribir la resolucion del DSN ni la conexion a mano. Compone resolve_pg_dsn(project) (resuelve el DSN desde env var / .env / pass) con pg_query(dsn, sql, max_rows) (SELECT read-only via psycopg2 que devuelve filas como list[dict]). Elimina el patron inline que el agente repetia: grep al .env + fallback a pass + psql crudo. El caller solo pasa el nombre del proyecto y el SQL; el password sale de pass en runtime, nunca hardcodeado. Devuelve lo que devuelve pg_query en exito {status:'ok', columns, rows, row_count, truncated}, o propaga el {status:'error', error} de resolve_pg_dsn si falla la resolucion del DSN (sin tocar Postgres). Sin lanzar."
tags: [postgres, postgresql, sql, query, pipelines]
uses_functions: [resolve_pg_dsn_py_infra, pg_query_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/query_project_pg.py"
params:
- name: project
desc: "Nombre del proyecto conocido. Acepta clave canonica ('captacion', 'seo') o alias largo ('captacion_clientes', 'seo_analytics'). Se pasa tal cual a resolve_pg_dsn; un proyecto desconocido propaga su {status:'error'}."
- name: sql
desc: "Sentencia SQL a ejecutar (pensada para SELECT). Este pipeline no expone parametros posicionales: interpola solo valores constantes y de confianza. Para entradas no confiables usa pg_query directamente con su argumento params (%s)."
- name: max_rows
desc: "Numero maximo de filas a materializar en memoria (default 10000). Se pasa tal cual a pg_query; si la query produce mas, el resultado se trunca y truncated queda en True."
output: "dict. En exito (propaga pg_query): {status:'ok', columns:[str,...], rows:[{col:val,...},...], row_count:int, truncated:bool}. En error: si la resolucion del DSN falla, {status:'error', error:str} de resolve_pg_dsn; si la query falla, {status:'error', error:str} de pg_query. Sin lanzar."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from pipelines.query_project_pg import query_project_pg
# Una sola llamada: resuelve el DSN de captacion_clientes y cuenta filas.
res = query_project_pg("captacion", "SELECT COUNT(*) FROM product_opportunities")
print(res["status"]) # ok
print(res["rows"][0]) # {'count': 42}
# Lanzable directo desde la CLI del registry (corre la demo del __main__):
# ./fn run query_project_pg
```
## Cuando usarla
Usala cada vez que necesites leer datos del Postgres de un proyecto del
ecosistema (captacion_clientes, seo_analytics) en un solo paso, en vez de
resolver el DSN a mano y abrir la conexion tu mismo. Es el reemplazo directo del
bloque inline `DSN=$(grep ... .env) ; psql "$DSN" -c "SELECT ..."`. Para varias
queries con el mismo proyecto, o si necesitas parametros posicionales seguros
(%s), resuelve el DSN una vez con `resolve_pg_dsn` y llama a `pg_query`
directamente reusando el DSN.
## Gotchas
- Impuro: resuelve el DSN (lee env / .env / pass) y abre una conexion a
Postgres. Depende del entorno de la maquina y de que el contenedor del
proyecto este levantado.
- Solo lectura: `pg_query` marca la transaccion `SET TRANSACTION READ ONLY`.
No uses este pipeline para INSERT/UPDATE/DELETE.
- No expone `params` posicionales: el SQL se ejecuta tal cual. NO interpoles
entradas no confiables en el string (riesgo de inyeccion); para eso usa
`pg_query` con su argumento `params`.
- El resultado se trunca a `max_rows` filas (default 10000) para proteger
memoria; revisa `truncated` en la salida.
- La ruta del `.env` del proyecto se resuelve relativa a `FN_REGISTRY_ROOT` o,
en su defecto, al cwd. Lanza desde la raiz del registry o exporta esa env var.
@@ -0,0 +1,54 @@
"""Pipeline one-shot: ejecuta un SELECT contra el Postgres de un proyecto conocido.
Compone dos funciones del registry sin reescribir su lógica:
1. resolve_pg_dsn(project) -> resuelve el DSN del proyecto (env / .env / pass).
2. pg_query(dsn, sql, max_rows=...) -> ejecuta el SELECT read-only y devuelve
las filas como list[dict].
Elimina el patrón inline que el agente repetía: resolver el DSN a mano y luego
lanzar psql/psycopg2 con él. El caller solo necesita el nombre del proyecto y
el SQL; el password sale de pass en runtime, nunca está hardcodeado.
Es un pipeline (kind: pipeline -> siempre impuro). Devuelve un dict sin lanzar:
lo que devuelve pg_query en éxito, o el error de resolución del DSN si falla
el primer paso.
"""
from infra.resolve_pg_dsn import resolve_pg_dsn
from infra.pg_query import pg_query
def query_project_pg(project: str, sql: str, max_rows: int = 10000) -> dict:
"""Resuelve el DSN de un proyecto y ejecuta un SELECT contra su Postgres.
Args:
project: nombre del proyecto conocido ('captacion' / 'captacion_clientes',
'seo' / 'seo_analytics'). Se pasa tal cual a resolve_pg_dsn.
sql: sentencia SQL a ejecutar (pensada para SELECT). Para parámetros, usa
el marcador %s; este pipeline no expone params posicionales, así que
interpola valores constantes y de confianza solo (para entradas no
confiables usa pg_query directamente con params).
max_rows: número máximo de filas a materializar (default 10000). Se pasa
tal cual a pg_query; si la query produce más, el resultado se trunca.
Returns:
dict. En éxito propaga el resultado de pg_query:
{status:'ok', columns, rows, row_count, truncated}. Si la resolución del
DSN falla, propaga {status:'error', error} de resolve_pg_dsn sin tocar
Postgres.
"""
resolved = resolve_pg_dsn(project)
if resolved.get("status") != "ok":
return resolved
return pg_query(resolved["dsn"], sql, max_rows=max_rows)
if __name__ == "__main__":
# Demo lanzable: cuenta de oportunidades de producto en captacion_clientes.
import json
out = query_project_pg(
"captacion",
"SELECT COUNT(*) AS n FROM product_opportunities",
)
print(json.dumps(out, ensure_ascii=False))
@@ -0,0 +1,74 @@
---
name: refresh_local_hub
kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "def refresh_local_hub(manifest_path: str | None = None, reload: bool = True) -> dict"
description: "Orquesta el refresco del sistema local_hub: descubre los servicios locales, regenera el fragmento de Caddyfile y la config de Glance, recarga Caddy (admin API) y reinicia la user-unit glance. Compone discover_local_services + render_caddyfile + render_glance_config. Lo corre el dag_engine a diario y tambien el usuario a mano."
tags: [local-hub, pipelines, pipeline, caddy, glance, infra, dashboard]
uses_functions: [discover_local_services_py_infra, render_caddyfile_py_infra, render_glance_config_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [pyyaml]
tested: true
tests:
- "compone las tres funciones del registry y escribe ambas configs"
- "la config completa de Glance generada es YAML parseable"
- "con reload=False no se llama a subprocess.run"
- "con reload=True se invoca caddy reload y systemctl --user restart glance"
- "el dict de retorno tiene todas las claves del contrato"
test_file_path: "python/functions/pipelines/refresh_local_hub_test.py"
file_path: "python/functions/pipelines/refresh_local_hub.py"
params:
- name: manifest_path
desc: "Ruta al manifiesto YAML del local_hub. Si es None se usa <RAIZ>/apps/local_hub/local_services.yaml (RAIZ derivada de FN_REGISTRY_ROOT o del path del modulo)."
- name: reload
desc: "Si True recarga Caddy (admin API localhost:2019, sin sudo) y reinicia la user-unit glance (sin sudo). Si False solo regenera las configs y no toca servicios."
output: "dict {total, up, down, caddy_path, glance_path, reloaded, caddy_reload_rc, glance_restart_rc, services:[{name,subdomain,port,up}, ...]}"
---
## Ejemplo
```bash
./fn run refresh_local_hub
```
Sin recargar servicios (solo regenera las configs):
```bash
$HOME/fn_registry/python/.venv/bin/python3 \
python/functions/pipelines/refresh_local_hub.py --no-reload
```
Desde Python:
```python
from pipelines.refresh_local_hub import refresh_local_hub
r = refresh_local_hub(reload=True)
print(r)
# {"total": 8, "up": 6, "down": 2, "caddy_path": "/etc/caddy/conf.d/local_hub.caddy",
# "glance_path": ".../apps/local_hub/glance/glance.yml", "reloaded": True,
# "caddy_reload_rc": 0, "glance_restart_rc": 0, "services": [...]}
```
## Cuando usarla
Cuando cambien los servicios locales expuestos como subdominios `*.localhost` (alta/baja de un servicio en el manifiesto, o un service nuevo del registry con bloque `service:`) y quieras que Caddy y el dashboard Glance reflejen el estado actual. Es el paso `function:` que el dag_engine corre a diario para mantener el `local_hub` sincronizado, y el comando que lanzas a mano tras editar `apps/local_hub/local_services.yaml`.
## Gotchas
- **Impura: escribe en `/etc/caddy/conf.d/local_hub.caddy` via ACL**, no via sudo. El usuario debe tener permiso de escritura ahi (ACL ya configurada en este PC). Sin la ACL, el `open(..., "w")` lanza `PermissionError`.
- **Recarga Caddy por su admin API** (`caddy reload` habla con `localhost:2019`), no reinicia el servicio: requiere que Caddy este corriendo con la admin API activa. Si Caddy no esta levantado, `caddy_reload_rc` queda en un valor != 0 (o -1 si el binario falla) pero el pipeline NO lanza.
- **Reinicia la user-unit `glance`** (`systemctl --user restart glance`), no la system-unit: requiere que la user-unit `glance` este instalada y el bus de usuario disponible. Si falta, `glance_restart_rc` refleja el fallo sin abortar.
- **Valida el YAML de Glance antes de escribir**: si `render_glance_config` produjera YAML invalido, el pipeline lanza `RuntimeError` con mensaje claro y no escribe el archivo (fail-fast).
- **Raiz dinamica**: la raiz del registry se deriva de `FN_REGISTRY_ROOT` o del path del modulo; nunca se hardcodea ningun `/home/<user>`.
- **`reload=False` no toca ningun servicio**: util para previsualizar las configs generadas sin recargar Caddy ni reiniciar Glance (lo que hace el test).
## Capability growth log
(sin entradas — v1.0.0 inicial)
@@ -0,0 +1,208 @@
"""refresh_local_hub — orquesta el refresco del sistema local_hub.
Pipeline impuro del dominio `pipelines`. Compone tres funciones del registry para
regenerar la infraestructura que expone los servicios locales como subdominios
`*.localhost`:
1. discover_local_services_py_infra — descubre y normaliza los servicios (manifiesto
+ servicios del registry con bloque `service:`), comprobando estado up/down.
2. render_caddyfile_py_infra — genera el fragmento de Caddyfile.
3. render_glance_config_py_infra — genera la config del widget monitor de Glance.
Después escribe el fragmento de Caddyfile en /etc/caddy/conf.d/local_hub.caddy (el
usuario tiene ACL de escritura ahí, sin sudo) y la config de Glance en
apps/local_hub/glance/glance.yml. Si `reload=True`, recarga Caddy (admin API en
localhost:2019, sin sudo) y reinicia la user-unit `glance` (sin sudo).
Pensado para correrse a diario desde dag_engine con un step `function:`, o a mano:
`fn run refresh_local_hub`.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from typing import Any
import yaml
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "python", "functions"))
from infra.discover_local_services import discover_local_services # noqa: E402
from infra.render_caddyfile import render_caddyfile # noqa: E402
from infra.render_glance_config import render_glance_config # noqa: E402
# Ruta del fragmento de Caddyfile. El usuario tiene ACL de escritura aquí (sin sudo).
CADDY_FRAGMENT_PATH = "/etc/caddy/conf.d/local_hub.caddy"
# Bloque fijo de servidor + tema que precede a la salida de render_glance_config.
GLANCE_SERVER_BLOCK = (
"server:\n"
" host: 127.0.0.1\n"
" port: 8585\n"
"\n"
"theme:\n"
" background-color: 240 8 9\n"
" contrast-multiplier: 1.2\n"
" primary-color: 210 90 70\n"
"\n"
)
def _default_manifest_path() -> str:
"""Ruta por defecto del manifiesto, derivada de la raíz del registry."""
root = os.environ.get("FN_REGISTRY_ROOT") or ROOT
return os.path.join(root, "apps", "local_hub", "local_services.yaml")
def _registry_root() -> str:
"""Raíz del registry: FN_REGISTRY_ROOT si está, si no la derivada del path del módulo."""
return os.environ.get("FN_REGISTRY_ROOT") or ROOT
def refresh_local_hub(
manifest_path: str | None = None,
reload: bool = True,
) -> dict[str, Any]:
"""Refresca el sistema local_hub: descubre servicios, regenera configs y recarga.
Args:
manifest_path: ruta al manifiesto YAML del local_hub. Si es None, se usa
``<RAIZ>/apps/local_hub/local_services.yaml`` (RAIZ derivada de
FN_REGISTRY_ROOT o del path del propio módulo).
reload: si True, recarga Caddy (admin API en localhost:2019) y reinicia la
user-unit ``glance``. Si False, solo escribe las configs y no toca
ningún servicio.
Returns:
dict resumen con las claves: total, up, down, caddy_path, glance_path,
reloaded, caddy_reload_rc, glance_restart_rc, services.
Raises:
RuntimeError: si la config de Glance generada no es YAML parseable.
"""
if manifest_path is None:
manifest_path = _default_manifest_path()
# 1. Lee el manifiesto para extraer dashboard_subdomain y glance_port.
try:
with open(manifest_path, "r", encoding="utf-8") as fh:
manifest = yaml.safe_load(fh) or {}
except (OSError, yaml.YAMLError) as exc:
raise RuntimeError(
f"refresh_local_hub: no se puede leer el manifiesto {manifest_path}: {exc}"
) from exc
dashboard_subdomain = manifest.get("dashboard_subdomain") or "home"
glance_port = manifest.get("glance_port") or 8585
# 2. Descubre los servicios (manifiesto + registry).
services = discover_local_services(manifest_path, include_registry=True)
# 3. Bloque del dashboard para el Caddyfile.
dashboard = {"subdomain": dashboard_subdomain, "port": glance_port}
# 4. Renderiza el fragmento de Caddyfile.
caddy_text = render_caddyfile(services, dashboard)
# 5. Construye la config completa de Glance (bloque fijo + render_glance_config).
glance_full = GLANCE_SERVER_BLOCK + render_glance_config(services)
# Verifica que el YAML resultante es parseable antes de escribir nada.
try:
yaml.safe_load(glance_full)
except yaml.YAMLError as exc:
raise RuntimeError(
f"refresh_local_hub: la config de Glance generada no es YAML válido: {exc}"
) from exc
# 6. Escribe el fragmento de Caddyfile (ACL del usuario, sin sudo).
with open(CADDY_FRAGMENT_PATH, "w", encoding="utf-8") as fh:
fh.write(caddy_text)
# 7. Escribe la config de Glance (crea el dir si falta).
glance_path = os.path.join(_registry_root(), "apps", "local_hub", "glance", "glance.yml")
os.makedirs(os.path.dirname(glance_path), exist_ok=True)
with open(glance_path, "w", encoding="utf-8") as fh:
fh.write(glance_full)
up = sum(1 for s in services if s.get("up"))
down = len(services) - up
caddy_reload_rc: int | None = None
glance_restart_rc: int | None = None
# 8. Recarga Caddy y reinicia Glance (ambos sin sudo).
if reload:
try:
caddy_proc = subprocess.run(
["caddy", "reload", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"],
capture_output=True,
text=True,
timeout=30,
)
caddy_reload_rc = caddy_proc.returncode
except (OSError, subprocess.SubprocessError) as exc:
caddy_reload_rc = -1
sys.stderr.write(f"refresh_local_hub: fallo recargando Caddy: {exc}\n")
try:
glance_proc = subprocess.run(
["systemctl", "--user", "restart", "glance"],
capture_output=True,
text=True,
timeout=30,
)
glance_restart_rc = glance_proc.returncode
except (OSError, subprocess.SubprocessError) as exc:
glance_restart_rc = -1
sys.stderr.write(f"refresh_local_hub: fallo reiniciando Glance: {exc}\n")
return {
"total": len(services),
"up": up,
"down": down,
"caddy_path": CADDY_FRAGMENT_PATH,
"glance_path": glance_path,
"reloaded": reload,
"caddy_reload_rc": caddy_reload_rc,
"glance_restart_rc": glance_restart_rc,
"services": [
{
"name": s.get("name"),
"subdomain": s.get("subdomain"),
"port": s.get("port"),
"up": s.get("up"),
}
for s in services
],
}
def main() -> None:
parser = argparse.ArgumentParser(description="Refresca el sistema local_hub.")
parser.add_argument(
"--manifest-path",
default=None,
help="Ruta al manifiesto YAML del local_hub (default: <RAIZ>/apps/local_hub/local_services.yaml).",
)
parser.add_argument(
"--no-reload",
action="store_true",
help="No recargar Caddy ni reiniciar Glance; solo regenerar las configs.",
)
args = parser.parse_args()
result = refresh_local_hub(
manifest_path=args.manifest_path,
reload=not args.no_reload,
)
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,167 @@
"""Tests para refresh_local_hub.
No recarga Caddy ni reinicia Glance de verdad: mockea `subprocess.run`. Escribe las
configs generadas en un `tmp_path` parcheando las rutas de salida. Mockea
`discover_local_services` para devolver 2 servicios fijos (no depende de puertos reales).
"""
from __future__ import annotations
import os
import yaml
import pytest
from pipelines import refresh_local_hub as rlh_module
from pipelines.refresh_local_hub import refresh_local_hub
FAKE_SERVICES = [
{
"name": "metabase",
"subdomain": "metabase",
"port": 3030,
"health_path": "/api/health",
"title": "Metabase",
"icon": "si:metabase",
"category": "Datos",
"up": True,
},
{
"name": "portainer",
"subdomain": "portainer",
"port": 9000,
"health_path": "/",
"title": "Portainer",
"icon": "si:portainer",
"category": "Infra",
"up": False,
},
]
def _write_manifest(tmp_path) -> str:
"""Crea un manifiesto YAML mínimo y devuelve su ruta."""
manifest = {
"dashboard_subdomain": "home",
"glance_port": 8585,
"services": [],
}
path = os.path.join(str(tmp_path), "local_services.yaml")
with open(path, "w", encoding="utf-8") as fh:
yaml.safe_dump(manifest, fh)
return path
@pytest.fixture
def patched_env(tmp_path, monkeypatch):
"""Parchea discover_local_services, las rutas de salida y subprocess.run."""
# discover_local_services devuelve 2 servicios fijos.
monkeypatch.setattr(rlh_module, "discover_local_services", lambda *a, **k: list(FAKE_SERVICES))
# Rutas de salida hacia tmp_path.
caddy_path = os.path.join(str(tmp_path), "local_hub.caddy")
monkeypatch.setattr(rlh_module, "CADDY_FRAGMENT_PATH", caddy_path)
monkeypatch.setattr(rlh_module, "_registry_root", lambda: str(tmp_path))
# subprocess.run mockeado: registra las llamadas y devuelve un objeto con returncode 0.
calls: list[list[str]] = []
class _FakeProc:
def __init__(self, rc: int = 0) -> None:
self.returncode = rc
self.stdout = ""
self.stderr = ""
def _fake_run(cmd, *args, **kwargs):
calls.append(list(cmd))
return _FakeProc(0)
monkeypatch.setattr(rlh_module.subprocess, "run", _fake_run)
manifest_path = _write_manifest(tmp_path)
return {
"manifest_path": manifest_path,
"caddy_path": caddy_path,
"glance_path": os.path.join(str(tmp_path), "apps", "local_hub", "glance", "glance.yml"),
"calls": calls,
"tmp_path": str(tmp_path),
}
def test_compone_las_tres_funciones_y_escribe_configs(patched_env):
"""compone las tres funciones del registry y escribe ambas configs"""
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
# Caddyfile escrito con un bloque por servicio + dashboard.
assert os.path.exists(patched_env["caddy_path"])
caddy_text = open(patched_env["caddy_path"], encoding="utf-8").read()
assert "metabase.localhost" in caddy_text
assert "portainer.localhost" in caddy_text
assert "home.localhost" in caddy_text # bloque del dashboard
assert "reverse_proxy 127.0.0.1:3030" in caddy_text
# Glance escrito con el bloque servidor fijo + la salida de render_glance_config.
assert os.path.exists(result["glance_path"])
glance_text = open(result["glance_path"], encoding="utf-8").read()
assert "host: 127.0.0.1" in glance_text
assert "port: 8585" in glance_text
assert "primary-color: 210 90 70" in glance_text
assert "type: monitor" in glance_text
def test_glance_full_es_yaml_parseable(patched_env):
"""la config completa de Glance generada es YAML parseable"""
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
glance_text = open(result["glance_path"], encoding="utf-8").read()
parsed = yaml.safe_load(glance_text)
assert isinstance(parsed, dict)
assert parsed["server"]["host"] == "127.0.0.1"
assert parsed["server"]["port"] == 8585
assert "theme" in parsed
assert "pages" in parsed # la salida de render_glance_config
def test_reload_false_no_llama_subprocess(patched_env):
"""con reload=False no se llama a subprocess.run"""
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
assert patched_env["calls"] == []
assert result["reloaded"] is False
assert result["caddy_reload_rc"] is None
assert result["glance_restart_rc"] is None
def test_reload_true_recarga_caddy_y_reinicia_glance(patched_env):
"""con reload=True se invoca caddy reload y systemctl --user restart glance"""
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=True)
cmds = patched_env["calls"]
assert len(cmds) == 2
assert cmds[0][0] == "caddy" and "reload" in cmds[0]
assert cmds[1] == ["systemctl", "--user", "restart", "glance"]
assert result["reloaded"] is True
assert result["caddy_reload_rc"] == 0
assert result["glance_restart_rc"] == 0
def test_dict_retorno_tiene_claves_esperadas(patched_env):
"""el dict de retorno tiene todas las claves del contrato"""
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
expected_keys = {
"total", "up", "down", "caddy_path", "glance_path",
"reloaded", "caddy_reload_rc", "glance_restart_rc", "services",
}
assert expected_keys <= set(result.keys())
assert result["total"] == 2
assert result["up"] == 1
assert result["down"] == 1
assert len(result["services"]) == 2
assert result["services"][0]["subdomain"] == "metabase"
assert {"name", "subdomain", "port", "up"} <= set(result["services"][0].keys())
if __name__ == "__main__":
pytest.main([__file__, "-v"])