From 32c7336bf666bbcfe65bf4548ee2239d1633367c Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 21 Jun 2026 14:22:55 +0200 Subject: [PATCH] feat(infra): auto-commit con 56 cambios Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.local.json | 6 +- .../infra/check_service_health_via_ssh.md | 99 +++++ .../infra/check_service_health_via_ssh.sh | 146 +++++++ .../check_service_health_via_ssh_test.sh | 114 ++++++ docs/capabilities/INDEX.md | 4 +- docs/capabilities/duckdb.md | 3 +- docs/capabilities/eda.md | 148 ++++--- docs/capabilities/img-to-3d.md | 84 ++++ docs/capabilities/local-hub.md | 77 ++++ docs/capabilities/metabase.md | 20 +- docs/capabilities/postgres.md | 14 +- docs/capabilities/ssh.md | 10 + functions/infra/notify_desktop.go | 40 ++ functions/infra/notify_desktop.md | 50 +++ python/functions/datascience/__init__.py | 2 + .../datascience/depth_to_relief_glb.md | 83 ++++ .../datascience/depth_to_relief_glb.py | 131 ++++++ .../functions/datascience/eda_llm_insights.py | 2 +- .../datascience/eda_llm_insights_test.py | 16 +- .../datascience/estimate_image_depth.md | 87 ++++ .../datascience/estimate_image_depth.py | 135 +++++++ .../functions/datascience/query_osint_db.md | 78 ++++ .../functions/datascience/query_osint_db.py | 100 +++++ .../datascience/query_osint_db_test.py | 117 ++++++ .../functions/datascience/run_eda_models.py | 75 +++- .../datascience/summarize_table_pg.md | 106 +++++ .../datascience/summarize_table_pg.py | 377 ++++++++++++++++++ .../datascience/summarize_table_pg_test.py | 253 ++++++++++++ .../infra/discover_local_services.md | 93 +++++ .../infra/discover_local_services.py | 213 ++++++++++ .../infra/discover_local_services_test.py | 140 +++++++ python/functions/infra/render_caddyfile.md | 88 ++++ python/functions/infra/render_caddyfile.py | 88 ++++ .../functions/infra/render_caddyfile_test.py | 103 +++++ .../functions/infra/render_glance_config.md | 112 ++++++ .../functions/infra/render_glance_config.py | 110 +++++ .../infra/render_glance_config_test.py | 152 +++++++ python/functions/infra/resolve_pg_dsn.md | 69 ++++ python/functions/infra/resolve_pg_dsn.py | 142 +++++++ python/functions/infra/resolve_pg_dsn_test.py | 56 +++ .../metabase/metabase_client_from_pass.md | 74 ++++ .../metabase/metabase_client_from_pass.py | 95 +++++ .../metabase_client_from_pass_test.py | 95 +++++ .../metabase/parse_metabase_secret.md | 59 +++ .../metabase/parse_metabase_secret.py | 97 +++++ .../metabase/parse_metabase_secret_test.py | 68 ++++ .../pipelines/build_relief_glb_from_image.md | 77 ++++ .../pipelines/build_relief_glb_from_image.py | 87 ++++ .../ingest_market_trends_headless.md | 86 ++++ .../ingest_market_trends_headless.py | 287 +++++++++++++ python/functions/pipelines/profile_table.py | 66 ++- .../functions/pipelines/query_project_pg.md | 70 ++++ .../functions/pipelines/query_project_pg.py | 54 +++ .../functions/pipelines/refresh_local_hub.md | 74 ++++ .../functions/pipelines/refresh_local_hub.py | 208 ++++++++++ .../pipelines/refresh_local_hub_test.py | 167 ++++++++ 56 files changed, 5307 insertions(+), 100 deletions(-) create mode 100644 bash/functions/infra/check_service_health_via_ssh.md create mode 100644 bash/functions/infra/check_service_health_via_ssh.sh create mode 100644 bash/functions/infra/check_service_health_via_ssh_test.sh create mode 100644 docs/capabilities/img-to-3d.md create mode 100644 docs/capabilities/local-hub.md create mode 100644 functions/infra/notify_desktop.go create mode 100644 functions/infra/notify_desktop.md create mode 100644 python/functions/datascience/depth_to_relief_glb.md create mode 100644 python/functions/datascience/depth_to_relief_glb.py create mode 100644 python/functions/datascience/estimate_image_depth.md create mode 100644 python/functions/datascience/estimate_image_depth.py create mode 100644 python/functions/datascience/query_osint_db.md create mode 100644 python/functions/datascience/query_osint_db.py create mode 100644 python/functions/datascience/query_osint_db_test.py create mode 100644 python/functions/datascience/summarize_table_pg.md create mode 100644 python/functions/datascience/summarize_table_pg.py create mode 100644 python/functions/datascience/summarize_table_pg_test.py create mode 100644 python/functions/infra/discover_local_services.md create mode 100644 python/functions/infra/discover_local_services.py create mode 100644 python/functions/infra/discover_local_services_test.py create mode 100644 python/functions/infra/render_caddyfile.md create mode 100644 python/functions/infra/render_caddyfile.py create mode 100644 python/functions/infra/render_caddyfile_test.py create mode 100644 python/functions/infra/render_glance_config.md create mode 100644 python/functions/infra/render_glance_config.py create mode 100644 python/functions/infra/render_glance_config_test.py create mode 100644 python/functions/infra/resolve_pg_dsn.md create mode 100644 python/functions/infra/resolve_pg_dsn.py create mode 100644 python/functions/infra/resolve_pg_dsn_test.py create mode 100644 python/functions/metabase/metabase_client_from_pass.md create mode 100644 python/functions/metabase/metabase_client_from_pass.py create mode 100644 python/functions/metabase/metabase_client_from_pass_test.py create mode 100644 python/functions/metabase/parse_metabase_secret.md create mode 100644 python/functions/metabase/parse_metabase_secret.py create mode 100644 python/functions/metabase/parse_metabase_secret_test.py create mode 100644 python/functions/pipelines/build_relief_glb_from_image.md create mode 100644 python/functions/pipelines/build_relief_glb_from_image.py create mode 100644 python/functions/pipelines/ingest_market_trends_headless.md create mode 100644 python/functions/pipelines/ingest_market_trends_headless.py create mode 100644 python/functions/pipelines/query_project_pg.md create mode 100644 python/functions/pipelines/query_project_pg.py create mode 100644 python/functions/pipelines/refresh_local_hub.md create mode 100644 python/functions/pipelines/refresh_local_hub.py create mode 100644 python/functions/pipelines/refresh_local_hub_test.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index eaffd723..97736831 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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" } ] } ] } -} +} \ No newline at end of file diff --git a/bash/functions/infra/check_service_health_via_ssh.md b/bash/functions/infra/check_service_health_via_ssh.md new file mode 100644 index 00000000..ca4dcc5a --- /dev/null +++ b/bash/functions/infra/check_service_health_via_ssh.md @@ -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 ], [--token ], [--expect-status ], [--connect-timeout ], [--curl-timeout ]) -> 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: . 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 ` + 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 `^=` 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 `:` y la funcion extrae el codigo). diff --git a/bash/functions/infra/check_service_health_via_ssh.sh b/bash/functions/infra/check_service_health_via_ssh.sh new file mode 100644 index 00000000..c1cc1636 --- /dev/null +++ b/bash/functions/infra/check_service_health_via_ssh.sh @@ -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 " >&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 [--token-from-env ] [--token ] [--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 = del .env remoto. + # 2) --token : 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 </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 </dev/null)" +REMOTE +) + else + remote_script=$(cat </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 ":". + # Cuando curl tiene exito, emite solo "". 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 diff --git a/bash/functions/infra/check_service_health_via_ssh_test.sh b/bash/functions/infra/check_service_health_via_ssh_test.sh new file mode 100644 index 00000000..d2b5565c --- /dev/null +++ b/bash/functions/infra/check_service_health_via_ssh_test.sh @@ -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" (:). --- +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 diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index f6715139..b5c6b488 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -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_): 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 diff --git a/docs/capabilities/duckdb.md b/docs/capabilities/duckdb.md index 0f660520..f8f0270c 100644 --- a/docs/capabilities/duckdb.md +++ b/docs/capabilities/duckdb.md @@ -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. diff --git a/docs/capabilities/eda.md b/docs/capabilities/eda.md index 1a8a33e9..bf1b1bbd 100644 --- a/docs/capabilities/eda.md +++ b/docs/capabilities/eda.md @@ -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__.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_.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). diff --git a/docs/capabilities/img-to-3d.md b/docs/capabilities/img-to-3d.md new file mode 100644 index 00000000..9d3cf73a --- /dev/null +++ b/docs/capabilities/img-to-3d.md @@ -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`. diff --git a/docs/capabilities/local-hub.md b/docs/capabilities/local-hub.md new file mode 100644 index 00000000..03fcfaf5 --- /dev/null +++ b/docs/capabilities/local-hub.md @@ -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://.localhost { reverse_proxy 127.0.0.1: }`), 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://.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`. diff --git a/docs/capabilities/metabase.md b/docs/capabilities/metabase.md index 9ee0ffae..82bd90d1 100644 --- a/docs/capabilities/metabase.md +++ b/docs/capabilities/metabase.md @@ -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 diff --git a/docs/capabilities/postgres.md b/docs/capabilities/postgres.md index 135d945c..1bc3970f 100644 --- a/docs/capabilities/postgres.md +++ b/docs/capabilities/postgres.md @@ -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 `=` 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`. diff --git a/docs/capabilities/ssh.md b/docs/capabilities/ssh.md index de55733f..6e77b71b 100644 --- a/docs/capabilities/ssh.md +++ b/docs/capabilities/ssh.md @@ -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 [--token-from-env ] [--token ] [--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`. diff --git a/functions/infra/notify_desktop.go b/functions/infra/notify_desktop.go new file mode 100644 index 00000000..d2dc4101 --- /dev/null +++ b/functions/infra/notify_desktop.go @@ -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 -- <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 +} diff --git a/functions/infra/notify_desktop.md b/functions/infra/notify_desktop.md new file mode 100644 index 00000000..b4eb02da --- /dev/null +++ b/functions/infra/notify_desktop.md @@ -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. diff --git a/python/functions/datascience/__init__.py b/python/functions/datascience/__init__.py index e79e2f3e..e95503e0 100644 --- a/python/functions/datascience/__init__.py +++ b/python/functions/datascience/__init__.py @@ -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", diff --git a/python/functions/datascience/depth_to_relief_glb.md b/python/functions/datascience/depth_to_relief_glb.md new file mode 100644 index 00000000..a9949d20 --- /dev/null +++ b/python/functions/datascience/depth_to_relief_glb.md @@ -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`. diff --git a/python/functions/datascience/depth_to_relief_glb.py b/python/functions/datascience/depth_to_relief_glb.py new file mode 100644 index 00000000..59b92578 --- /dev/null +++ b/python/functions/datascience/depth_to_relief_glb.py @@ -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) diff --git a/python/functions/datascience/eda_llm_insights.py b/python/functions/datascience/eda_llm_insights.py index 77062291..0467d7b7 100644 --- a/python/functions/datascience/eda_llm_insights.py +++ b/python/functions/datascience/eda_llm_insights.py @@ -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 = { diff --git a/python/functions/datascience/eda_llm_insights_test.py b/python/functions/datascience/eda_llm_insights_test.py index c79a2e15..9daed268 100644 --- a/python/functions/datascience/eda_llm_insights_test.py +++ b/python/functions/datascience/eda_llm_insights_test.py @@ -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" diff --git a/python/functions/datascience/estimate_image_depth.md b/python/functions/datascience/estimate_image_depth.md new file mode 100644 index 00000000..dfa61069 --- /dev/null +++ b/python/functions/datascience/estimate_image_depth.md @@ -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. diff --git a/python/functions/datascience/estimate_image_depth.py b/python/functions/datascience/estimate_image_depth.py new file mode 100644 index 00000000..1ad36707 --- /dev/null +++ b/python/functions/datascience/estimate_image_depth.py @@ -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) diff --git a/python/functions/datascience/query_osint_db.md b/python/functions/datascience/query_osint_db.md new file mode 100644 index 00000000..b86871fd --- /dev/null +++ b/python/functions/datascience/query_osint_db.md @@ -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`. diff --git a/python/functions/datascience/query_osint_db.py b/python/functions/datascience/query_osint_db.py new file mode 100644 index 00000000..043b8535 --- /dev/null +++ b/python/functions/datascience/query_osint_db.py @@ -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)) diff --git a/python/functions/datascience/query_osint_db_test.py b/python/functions/datascience/query_osint_db_test.py new file mode 100644 index 00000000..c9bfb847 --- /dev/null +++ b/python/functions/datascience/query_osint_db_test.py @@ -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" diff --git a/python/functions/datascience/run_eda_models.py b/python/functions/datascience/run_eda_models.py index fddc4ff6..732a4704 100644 --- a/python/functions/datascience/run_eda_models.py +++ b/python/functions/datascience/run_eda_models.py @@ -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 diff --git a/python/functions/datascience/summarize_table_pg.md b/python/functions/datascience/summarize_table_pg.md new file mode 100644 index 00000000..5c213a07 --- /dev/null +++ b/python/functions/datascience/summarize_table_pg.md @@ -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). diff --git a/python/functions/datascience/summarize_table_pg.py b/python/functions/datascience/summarize_table_pg.py new file mode 100644 index 00000000..5ddadf77 --- /dev/null +++ b/python/functions/datascience/summarize_table_pg.py @@ -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, + } diff --git a/python/functions/datascience/summarize_table_pg_test.py b/python/functions/datascience/summarize_table_pg_test.py new file mode 100644 index 00000000..1c9fc3d7 --- /dev/null +++ b/python/functions/datascience/summarize_table_pg_test.py @@ -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"] diff --git a/python/functions/infra/discover_local_services.md b/python/functions/infra/discover_local_services.md new file mode 100644 index 00000000..3f153997 --- /dev/null +++ b/python/functions/infra/discover_local_services.md @@ -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 diff --git a/python/functions/infra/discover_local_services.py b/python/functions/infra/discover_local_services.py new file mode 100644 index 00000000..1437a2a9 --- /dev/null +++ b/python/functions/infra/discover_local_services.py @@ -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)) diff --git a/python/functions/infra/discover_local_services_test.py b/python/functions/infra/discover_local_services_test.py new file mode 100644 index 00000000..a5bd3aa9 --- /dev/null +++ b/python/functions/infra/discover_local_services_test.py @@ -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() diff --git a/python/functions/infra/render_caddyfile.md b/python/functions/infra/render_caddyfile.md new file mode 100644 index 00000000..469b5a41 --- /dev/null +++ b/python/functions/infra/render_caddyfile.md @@ -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 diff --git a/python/functions/infra/render_caddyfile.py b/python/functions/infra/render_caddyfile.py new file mode 100644 index 00000000..de0cccc6 --- /dev/null +++ b/python/functions/infra/render_caddyfile.py @@ -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) diff --git a/python/functions/infra/render_caddyfile_test.py b/python/functions/infra/render_caddyfile_test.py new file mode 100644 index 00000000..e43cb7ac --- /dev/null +++ b/python/functions/infra/render_caddyfile_test.py @@ -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}] + ) diff --git a/python/functions/infra/render_glance_config.md b/python/functions/infra/render_glance_config.md new file mode 100644 index 00000000..bdfd1c4d --- /dev/null +++ b/python/functions/infra/render_glance_config.md @@ -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 diff --git a/python/functions/infra/render_glance_config.py b/python/functions/infra/render_glance_config.py new file mode 100644 index 00000000..09cc3887 --- /dev/null +++ b/python/functions/infra/render_glance_config.py @@ -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 diff --git a/python/functions/infra/render_glance_config_test.py b/python/functions/infra/render_glance_config_test.py new file mode 100644 index 00000000..4f874a45 --- /dev/null +++ b/python/functions/infra/render_glance_config_test.py @@ -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"] diff --git a/python/functions/infra/resolve_pg_dsn.md b/python/functions/infra/resolve_pg_dsn.md new file mode 100644 index 00000000..745e99d9 --- /dev/null +++ b/python/functions/infra/resolve_pg_dsn.md @@ -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']`. diff --git a/python/functions/infra/resolve_pg_dsn.py b/python/functions/infra/resolve_pg_dsn.py new file mode 100644 index 00000000..0bf52973 --- /dev/null +++ b/python/functions/infra/resolve_pg_dsn.py @@ -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"} diff --git a/python/functions/infra/resolve_pg_dsn_test.py b/python/functions/infra/resolve_pg_dsn_test.py new file mode 100644 index 00000000..87514113 --- /dev/null +++ b/python/functions/infra/resolve_pg_dsn_test.py @@ -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" diff --git a/python/functions/metabase/metabase_client_from_pass.md b/python/functions/metabase/metabase_client_from_pass.md new file mode 100644 index 00000000..f39c1048 --- /dev/null +++ b/python/functions/metabase/metabase_client_from_pass.md @@ -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. diff --git a/python/functions/metabase/metabase_client_from_pass.py b/python/functions/metabase/metabase_client_from_pass.py new file mode 100644 index 00000000..71b60800 --- /dev/null +++ b/python/functions/metabase/metabase_client_from_pass.py @@ -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) diff --git a/python/functions/metabase/metabase_client_from_pass_test.py b/python/functions/metabase/metabase_client_from_pass_test.py new file mode 100644 index 00000000..d20f724a --- /dev/null +++ b/python/functions/metabase/metabase_client_from_pass_test.py @@ -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"] diff --git a/python/functions/metabase/parse_metabase_secret.md b/python/functions/metabase/parse_metabase_secret.md new file mode 100644 index 00000000..4a24fdd1 --- /dev/null +++ b/python/functions/metabase/parse_metabase_secret.md @@ -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. diff --git a/python/functions/metabase/parse_metabase_secret.py b/python/functions/metabase/parse_metabase_secret.py new file mode 100644 index 00000000..0233d73f --- /dev/null +++ b/python/functions/metabase/parse_metabase_secret.py @@ -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 diff --git a/python/functions/metabase/parse_metabase_secret_test.py b/python/functions/metabase/parse_metabase_secret_test.py new file mode 100644 index 00000000..22dbc8bf --- /dev/null +++ b/python/functions/metabase/parse_metabase_secret_test.py @@ -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" diff --git a/python/functions/pipelines/build_relief_glb_from_image.md b/python/functions/pipelines/build_relief_glb_from_image.md new file mode 100644 index 00000000..cc9a5493 --- /dev/null +++ b/python/functions/pipelines/build_relief_glb_from_image.md @@ -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). diff --git a/python/functions/pipelines/build_relief_glb_from_image.py b/python/functions/pipelines/build_relief_glb_from_image.py new file mode 100644 index 00000000..38e529b5 --- /dev/null +++ b/python/functions/pipelines/build_relief_glb_from_image.py @@ -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) diff --git a/python/functions/pipelines/ingest_market_trends_headless.md b/python/functions/pipelines/ingest_market_trends_headless.md new file mode 100644 index 00000000..cd9e651d --- /dev/null +++ b/python/functions/pipelines/ingest_market_trends_headless.md @@ -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. diff --git a/python/functions/pipelines/ingest_market_trends_headless.py b/python/functions/pipelines/ingest_market_trends_headless.py new file mode 100644 index 00000000..a4f28462 --- /dev/null +++ b/python/functions/pipelines/ingest_market_trends_headless.py @@ -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()) diff --git a/python/functions/pipelines/profile_table.py b/python/functions/pipelines/profile_table.py index 0fd91b03..7f62bb31 100644 --- a/python/functions/pipelines/profile_table.py +++ b/python/functions/pipelines/profile_table.py @@ -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, diff --git a/python/functions/pipelines/query_project_pg.md b/python/functions/pipelines/query_project_pg.md new file mode 100644 index 00000000..f5eb7708 --- /dev/null +++ b/python/functions/pipelines/query_project_pg.md @@ -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. diff --git a/python/functions/pipelines/query_project_pg.py b/python/functions/pipelines/query_project_pg.py new file mode 100644 index 00000000..23c17edc --- /dev/null +++ b/python/functions/pipelines/query_project_pg.py @@ -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)) diff --git a/python/functions/pipelines/refresh_local_hub.md b/python/functions/pipelines/refresh_local_hub.md new file mode 100644 index 00000000..1cd36951 --- /dev/null +++ b/python/functions/pipelines/refresh_local_hub.md @@ -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) diff --git a/python/functions/pipelines/refresh_local_hub.py b/python/functions/pipelines/refresh_local_hub.py new file mode 100644 index 00000000..cbcc9341 --- /dev/null +++ b/python/functions/pipelines/refresh_local_hub.py @@ -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() diff --git a/python/functions/pipelines/refresh_local_hub_test.py b/python/functions/pipelines/refresh_local_hub_test.py new file mode 100644 index 00000000..fe34420e --- /dev/null +++ b/python/functions/pipelines/refresh_local_hub_test.py @@ -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"])