feat(infra): auto-commit con 56 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,9 +56,13 @@
|
||||
{
|
||||
"type": "command",
|
||||
"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
|
||||
@@ -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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
@@ -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/` |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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
|
||||
|
||||
|
||||
@@ -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). |
|
||||
| `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}`. |
|
||||
| `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
|
||||
|
||||
@@ -79,7 +80,7 @@ Conversion CSV -> Parquet en una linea:
|
||||
|
||||
## 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.
|
||||
- `read_only=True` exige que el archivo exista — no crea bases nuevas.
|
||||
|
||||
|
||||
+100
-48
@@ -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`.
|
||||
|
||||
## Funciones
|
||||
|
||||
### Perfilado base (tabla y columna)
|
||||
| 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`. |
|
||||
| `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_categorical_py_datascience` | pure | Bloque `categorical`: top-k frecuencias, mode, distinct, entropía de Shannon (bits), imbalance, longitudes. |
|
||||
| `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. |
|
||||
| `column_quality_score_py_datascience` | pure | Score de calidad 0-100 (completeness/validity/consistency) + issues legibles para un `ColumnProfile`. |
|
||||
| `render_eda_markdown_py_datascience` | pure | `TableProfile` → report markdown autosuficiente (Overview, Columnas, Numéricas con sparkline ASCII, Categóricas, Calidad). |
|
||||
| `summary_stats_py_datascience` | pure | Descriptiva mínima (n, mean, median, p25, p75) de una lista de floats. |
|
||||
| `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. |
|
||||
| `summarize_table_duckdb_py_datascience` | impure | Corazón (DuckDB): `SUMMARIZE` push-down + `COUNT DISTINCT` exacto (≤200k filas) → esqueleto del `TableProfile`. |
|
||||
| `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). |
|
||||
| `describe_numeric_py_datascience` | pure | Bloque numérico: min/max/mean/median/std/cv, p1-p99, IQR, skew, kurtosis, outliers, distribución, histograma. |
|
||||
| `summarize_categorical_py_datascience` | pure | top-k frecuencias, mode, distinct, entropía, imbalance, longitudes. |
|
||||
| `infer_semantic_type_py_datascience` | pure | Tipo semántico por regex (email/url/ip/uuid/iban/currency/datetime/...). |
|
||||
| `column_quality_score_py_datascience` | pure | Score 0-100 (completeness/validity/consistency) + issues. |
|
||||
| `render_eda_markdown_py_datascience` | pure | `TableProfile` → report markdown con sparklines ASCII. |
|
||||
| `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
|
||||
|
||||
Todas las funciones producen/consumen el mismo shape (dict JSON), lo que desacopla cálculo, render y (futuro) LLM:
|
||||
|
||||
```
|
||||
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
|
||||
}
|
||||
TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes,
|
||||
duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct,
|
||||
type_breakdown:{numeric,categorical,datetime,text,boolean},
|
||||
columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models}
|
||||
|
||||
ColumnProfile = {
|
||||
name, physical_type, inferred_type, # numeric|categorical|datetime|boolean|text|id
|
||||
semantic_type, count, n_rows, null_count, null_pct, empty_count, empty_pct,
|
||||
distinct_count, unique_pct, # *_pct son FRACCIONES 0-1; el render las muestra ×100
|
||||
flags:[constant|possible_id|high_cardinality|mostly_null],
|
||||
quality_score,
|
||||
numeric: {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:[{lo,hi,count}]} | None,
|
||||
categorical: {top:[{value,count,pct}],mode,mode_pct,n_distinct,entropy,imbalance,
|
||||
len_mean,len_min,len_max} | None,
|
||||
datetime: {min,max,range_days,granularity,n_gaps,future_pct,monotonic} | None
|
||||
}
|
||||
ColumnProfile = {name, physical_type, inferred_type, semantic_type, count, n_rows,
|
||||
null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct,
|
||||
flags:[constant|possible_id|high_cardinality|mostly_null], quality_score,
|
||||
numeric:{...}|None, categorical:{...}|None, datetime:{...}|None}
|
||||
# *_pct son FRACCIONES 0-1; el render las muestra ×100
|
||||
|
||||
correlations = {pairs:[{a,b,a_type,b_type,method,value,extra}], strong:[...], methods_legend}
|
||||
models = {n_numeric_cols, pca, kmeans, outliers, normality, note}
|
||||
llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}],
|
||||
pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}
|
||||
```
|
||||
|
||||
## 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
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from pipelines.profile_table import profile_table
|
||||
|
||||
r = profile_table(os.path.expanduser("~/.fn_freelance/freelance.duckdb"), "freelance_projects")
|
||||
print(r["status"], r["report_md_path"])
|
||||
r = profile_table("/ruta/datos.duckdb", "clientes", run_models=True, run_llm=True)
|
||||
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
|
||||
|
||||
- **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.
|
||||
- **Distinct exacto solo hasta 200k filas**; por encima usa aproximado (HyperLogLog) capado a nº de filas.
|
||||
- **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).
|
||||
- **No es estadística inferencial ni modelado**: es perfilado descriptivo. Correlaciones, modelos baratos (PCA/KMeans/IsolationForest) y capa LLM son fases siguientes del grupo.
|
||||
- **NO carga la tabla entera a RAM**: metadata SQL + muestra por columna/filas (`sample`, default 5000).
|
||||
- **Distinct exacto hasta 200k filas**; por encima aproximado capado.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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.
|
||||
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.
|
||||
|
||||
Validado sobre PostgreSQL real (tablas del Metabase local del proyecto captacion_clientes).
|
||||
|
||||
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).
|
||||
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -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_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_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_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. |
|
||||
@@ -134,6 +136,22 @@ dash = metabase_get_dashboard(client, dashboard_id=42)
|
||||
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)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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_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`). |
|
||||
| `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).
|
||||
|
||||
## 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
|
||||
cd /home/enmanuel/fn_registry
|
||||
@@ -42,7 +52,7 @@ PYEOF
|
||||
|
||||
## Gotchas del grupo
|
||||
|
||||
- **El DSN lleva credenciales — nunca hardcodear.** Resuélvelo desde `pass` (ej. `pass captacion/postgres`: L1 = password, resto `user/host/port/datadb`). No imprimas el DSN en logs.
|
||||
- **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_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`.
|
||||
|
||||
@@ -7,6 +7,7 @@ Operar hosts remotos via SSH. Cubre: CRUD de `~/.ssh/config` (`ssh_config_add_en
|
||||
| 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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
- **NO genera ni rota llaves SSH automaticamente**. Asume llave ya generada con `ssh-keygen`.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -20,6 +20,7 @@ from .fetch_hackernews_search import fetch_hackernews_search
|
||||
from .score_demand_signal import score_demand_signal
|
||||
from .pull_gsc_search_analytics import pull_gsc_search_analytics
|
||||
from .summarize_table_duckdb import summarize_table_duckdb
|
||||
from .summarize_table_pg import summarize_table_pg
|
||||
from .describe_numeric import describe_numeric
|
||||
from .summarize_categorical import summarize_categorical
|
||||
from .infer_semantic_type import infer_semantic_type
|
||||
@@ -46,6 +47,7 @@ from .build_eda_notebook import build_eda_notebook
|
||||
|
||||
__all__ = [
|
||||
"summarize_table_duckdb",
|
||||
"summarize_table_pg",
|
||||
"spearman_corr",
|
||||
"cramers_v",
|
||||
"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
|
||||
|
||||
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.
|
||||
_EXPECTED_KEYS = {
|
||||
|
||||
@@ -135,7 +135,9 @@ def test_eda_llm_insights_ok_with_monkeypatched_llm(monkeypatch):
|
||||
"analyses": ["ventas por categoria"],
|
||||
}
|
||||
|
||||
import datascience.eda_llm_insights as mod
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("datascience.eda_llm_insights")
|
||||
|
||||
monkeypatch.setattr(
|
||||
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):
|
||||
"""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(
|
||||
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):
|
||||
import datascience.eda_llm_insights as mod
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("datascience.eda_llm_insights")
|
||||
|
||||
monkeypatch.setattr(
|
||||
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):
|
||||
import datascience.eda_llm_insights as mod
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("datascience.eda_llm_insights")
|
||||
|
||||
monkeypatch.setattr(
|
||||
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"
|
||||
@@ -15,40 +15,73 @@ from datascience import (
|
||||
)
|
||||
|
||||
|
||||
def _to_numeric_subset(columns: dict) -> dict:
|
||||
"""Extrae las columnas numericas como {nombre: [float values]}.
|
||||
def _pf(v):
|
||||
"""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
|
||||
parseables se descartan (la lista resultante puede ser mas corta que la
|
||||
original). Mantiene el orden de aparicion de las columnas.
|
||||
|
||||
def _to_numeric_subset(columns: dict) -> dict:
|
||||
"""Extrae las columnas numericas alineadas por fila (listwise deletion).
|
||||
|
||||
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:
|
||||
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:
|
||||
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):
|
||||
return numeric
|
||||
return {}
|
||||
raw: dict[str, list] = {}
|
||||
for name, meta in columns.items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
if meta.get("type") != "numeric":
|
||||
continue
|
||||
values = meta.get("values")
|
||||
if not isinstance(values, (list, tuple)):
|
||||
continue
|
||||
parsed: list[float] = []
|
||||
for v in values:
|
||||
if v is None or isinstance(v, bool):
|
||||
continue
|
||||
try:
|
||||
parsed.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
numeric[name] = parsed
|
||||
if isinstance(values, (list, tuple)):
|
||||
raw[name] = list(values)
|
||||
if not raw:
|
||||
return {}
|
||||
|
||||
# Longitud comun (min, defensivo si llegaran desalineadas).
|
||||
n = min(len(v) for v in raw.values())
|
||||
if n == 0:
|
||||
return {}
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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']`.
|
||||
@@ -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())
|
||||
@@ -38,8 +38,9 @@ from datascience import (
|
||||
run_eda_models,
|
||||
summarize_categorical,
|
||||
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".
|
||||
_NUMERIC_SEMANTIC = ("integer", "decimal", "currency")
|
||||
@@ -82,10 +83,13 @@ def _to_float(value):
|
||||
return None
|
||||
|
||||
|
||||
def _sample_values(db_path: str, table: str, name: str, sample: int) -> list:
|
||||
"""Trae hasta `sample` valores no nulos de una columna (read-only)."""
|
||||
q = duckdb_query_readonly(
|
||||
db_path,
|
||||
def _sample_values(query_fn, table: str, name: str, sample: int) -> list:
|
||||
"""Trae hasta `sample` valores no nulos de una columna (read-only).
|
||||
|
||||
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"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", [])]
|
||||
|
||||
|
||||
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.
|
||||
|
||||
A diferencia de _sample_values (una columna, solo no nulos), esto preserva la
|
||||
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:
|
||||
return []
|
||||
cols_sql = ", ".join(f'"{n}"' for n in names)
|
||||
q = duckdb_query_readonly(
|
||||
db_path, f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}'
|
||||
)
|
||||
q = query_fn(f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}')
|
||||
if q.get("status") != "ok":
|
||||
return []
|
||||
return q.get("rows", [])
|
||||
@@ -115,17 +118,20 @@ def _sample_rows(db_path: str, table: str, names: list, sample: int) -> list:
|
||||
def profile_table(
|
||||
db_path: str,
|
||||
table: str,
|
||||
backend: str = "duckdb",
|
||||
sample: int = 5000,
|
||||
run_models: bool = False,
|
||||
run_llm: bool = False,
|
||||
report_dir: str = "reports",
|
||||
write_report: bool = True,
|
||||
) -> 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:
|
||||
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.
|
||||
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
|
||||
enriquecimiento (describe_numeric / summarize_categorical /
|
||||
infer_semantic_type). Default 5000.
|
||||
@@ -141,8 +147,22 @@ def profile_table(
|
||||
lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
# 1) Perfil base por columna (push-down SQL).
|
||||
r = summarize_table_duckdb(db_path, table)
|
||||
# 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)
|
||||
|
||||
def _q(sql):
|
||||
return duckdb_query_readonly(db_path, sql)
|
||||
|
||||
else:
|
||||
return {"status": "error", "error": f"backend desconocido: {backend}"}
|
||||
if r.get("status") != "ok":
|
||||
return {"status": "error", "error": r.get("error", "summarize failed")}
|
||||
prof = r["profile"]
|
||||
@@ -153,7 +173,7 @@ def profile_table(
|
||||
inferred = col.get("inferred_type")
|
||||
|
||||
# 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.
|
||||
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)]
|
||||
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 = {}
|
||||
for c in assoc_cols:
|
||||
@@ -256,12 +276,18 @@ def profile_table(
|
||||
prof["correlations"] = (
|
||||
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
|
||||
prof["correlations"] = None
|
||||
prof["models"] = 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
|
||||
|
||||
# 8.6) Capa LLM opcional: interpreta el perfil ya calculado en UNA
|
||||
# llamada (data dictionary, resumen, granularidad de fila, PII, limpieza,
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user