feat(infra): auto-commit con 56 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 14:22:55 +02:00
parent c1071a82b3
commit 32c7336bf6
56 changed files with 5307 additions and 100 deletions
@@ -0,0 +1,77 @@
---
name: build_relief_glb_from_image
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def build_relief_glb_from_image(image_path: str, out_glb_path: str, model_name: str = 'depth-anything/Depth-Anything-V2-Small-hf', device: str = 'auto', z_scale: float = 0.35, max_dim: int = 220) -> dict"
description: "Pipeline one-shot imagen 2D -> .glb de relieve texturizado. Compone estimate_image_depth (profundidad monocular Depth-Anything-V2) + depth_to_relief_glb (malla heightmap + textura) en una sola llamada. Promueve a un paso la secuencia que img_to_3d_webapp hacia en dos (issue 0087). Grupo img-to-3d."
tags: [img-to-3d, pipelines, depth, glb, gltf, mesh, relief, 3d, computer-vision, launcher]
uses_functions: [estimate_image_depth_py_datascience, depth_to_relief_glb_py_datascience]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: image_path
desc: "Ruta a la imagen de entrada (cualquier formato que PIL abra). Si no existe vuelve {status:error, stage:estimate}."
- name: out_glb_path
desc: "Ruta de salida del .glb. Su directorio padre debe existir o falla en la etapa relief (status error)."
- name: model_name
desc: "Id de modelo HuggingFace de depth-estimation. Default Depth-Anything-V2-Small-hf (rapido)."
- name: device
desc: "'auto' (GPU0 si hay), 'cpu', o indice/cadena cuda. Ver gotchas de estimate_image_depth para el detalle de 'cuda:N' vs indice entero."
- name: z_scale
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas exagerado."
- name: max_dim
desc: "Lado maximo del grid tras downsample (default 220, ~36k-48k vertices segun aspect ratio). Controla detalle vs peso del .glb."
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int, model:str, device:str}. Error: {status:'error', stage:'estimate'|'relief', error:str}. No lanza. Salida JSON-serializable (sin ndarray ni PIL), apta para `fn run`."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/build_relief_glb_from_image.py"
---
## Ejemplo
```bash
# Requiere venv con torch + transformers + trimesh + pillow + numpy (p.ej. apps/img_to_3d_webapp/backend/.venv).
./fn run build_relief_glb_from_image_py_pipelines apps/img_to_3d_webapp/samples/cats.jpg /tmp/cats_relief.glb
# {"status": "ok", "glb_path": "/tmp/cats_relief.glb", "vertices": 36300, "faces": 71832, "height": 165, "width": 220, "model": "...", "device": "auto"}
```
Como import (composicion en codigo):
```python
import sys
sys.path.insert(0, "python/functions/pipelines")
from build_relief_glb_from_image import build_relief_glb_from_image
res = build_relief_glb_from_image("apps/img_to_3d_webapp/samples/cats.jpg", "/tmp/cats_relief.glb")
print(res["status"], res["vertices"], res["faces"]) # ok 36300 71832
```
## Cuando usarla
Cuando quieras el modelo 3D (.glb) directamente desde una imagen y NO necesites el mapa de
profundidad intermedio para otra cosa. Es el atajo del grupo `img-to-3d`: una sola llamada en vez
de orquestar `estimate_image_depth` + `depth_to_relief_glb` a mano. Si necesitas el `depth` por
separado (para inspeccionarlo, reusarlo, o aplicar otra reconstruccion), llama a las dos funciones
sueltas en su lugar.
## Gotchas
- **Impuro (pipeline)**: carga modelo HuggingFace (descarga pesos la 1a vez), usa GPU/CPU y
escribe el .glb en disco. Hereda todos los gotchas de `estimate_image_depth_py_datascience` y
`depth_to_relief_glb_py_datascience` (estado de proceso del pipeline cacheado, profundidad
relativa, relieve 2.5D, directorio de salida debe existir).
- **Deps de vision**: requiere `torch`+`transformers`+`trimesh`+`pillow`+`numpy`. Importa las dos
funciones del registry de forma PLANA (anade `python/functions/datascience` a `sys.path`) para
NO arrastrar el `datascience.__init__` (que trae bs4/duckdb de otros dominios). Por eso `fn run`
de este pipeline corre limpio en el venv de vision sin necesitar las deps de los scrapers.
- **stage en el error**: el campo `stage` (`estimate` o `relief`) indica en cual de los dos pasos
fallo, util para depurar (p.ej. ruta de imagen mala -> stage estimate; dir de salida inexistente
-> stage relief).
- Tag `launcher`: aparece en el Pipeline Launcher TUI; es un subproceso one-shot (no interactivo).
@@ -0,0 +1,87 @@
"""
Pipeline one-shot imagen -> modelo 3D: estima la profundidad monocular y reconstruye una malla
de relieve texturizada exportada a .glb, en una sola llamada.
Compone (grupo de capacidad `img-to-3d`):
estimate_image_depth_py_datascience -> depth_to_relief_glb_py_datascience
Promueve a un solo paso la secuencia que la app `img_to_3d_webapp` hacia en dos (issue 0087):
en vez de orquestar estimate + relief a mano, el caller pasa la ruta de la imagen y la del .glb.
"""
from __future__ import annotations
import os
import sys
# Import PLANO de las funciones del registry (no `from datascience import ...`): el __init__ del
# paquete datascience arrastra deps de otros dominios (bs4, duckdb...) ausentes en el venv de
# vision (torch/transformers/trimesh). Importar los modulos directos evita esa dependencia.
_DS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "datascience")
if _DS_DIR not in sys.path:
sys.path.insert(0, _DS_DIR)
from estimate_image_depth import estimate_image_depth # noqa: E402
from depth_to_relief_glb import depth_to_relief_glb # noqa: E402
def build_relief_glb_from_image(
image_path: str,
out_glb_path: str,
model_name: str = "depth-anything/Depth-Anything-V2-Small-hf",
device: str = "auto",
z_scale: float = 0.35,
max_dim: int = 220,
) -> dict:
"""
Convierte una imagen 2D en un .glb de relieve texturizado en una sola llamada.
Parámetros:
image_path: ruta a la imagen de entrada (lo que PIL abra).
out_glb_path: ruta de salida del .glb (su directorio padre debe existir).
model_name: id de modelo HuggingFace de depth-estimation.
device: "auto" / "cpu" / índice o cadena cuda.
z_scale: amplitud del relieve (fracción del lado de la malla).
max_dim: lado máximo del grid tras downsample.
Devuelve (dict, nunca lanza):
Éxito: {"status":"ok", "glb_path":str, "vertices":int, "faces":int, "height":int,
"width":int, "model":str, "device":str}.
Error: {"status":"error", "stage":"estimate"|"relief", "error":str}.
"""
est = estimate_image_depth(image_path, model_name=model_name, device=device)
if est.get("status") != "ok":
return {"status": "error", "stage": "estimate", "error": est.get("error", "unknown")}
res = depth_to_relief_glb(
est["image"], est["depth"], out_glb_path, z_scale=z_scale, max_dim=max_dim
)
if res.get("status") != "ok":
return {"status": "error", "stage": "relief", "error": res.get("error", "unknown")}
return {
"status": "ok",
"glb_path": res["glb_path"],
"vertices": res["vertices"],
"faces": res["faces"],
"height": res["height"],
"width": res["width"],
"model": est["model"],
"device": est["device"],
}
if __name__ == "__main__":
# Demo directo: `python build_relief_glb_from_image.py <image_path> <out.glb> [z_scale] [max_dim]`.
import json
if len(sys.argv) < 3:
print(json.dumps({"status": "error", "error": "uso: <image_path> <out_glb_path> [z_scale] [max_dim]"}))
sys.exit(1)
zs = float(sys.argv[3]) if len(sys.argv) > 3 else 0.35
md = int(sys.argv[4]) if len(sys.argv) > 4 else 220
out = build_relief_glb_from_image(sys.argv[1], sys.argv[2], z_scale=zs, max_dim=md)
print(json.dumps(out))
if out["status"] != "ok":
sys.exit(1)
@@ -0,0 +1,86 @@
---
name: ingest_market_trends_headless
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def ingest_market_trends_headless(sources: str = '', port: int = 9334, profile_dir: str = '') -> dict"
description: "Ingesta de las fuentes CDP de tendencias (AliExpress / Amazon movers / saturación Amazon) en un Chrome headless AISLADO con perfil dedicado, lanzándolo y cerrándolo en cada corrida. Evita abrir pestañas en el navegador diario del usuario (chromium-personal, CDP 9222). Wrapper de ingest_market_trends. Proyecto captacion_clientes."
tags: [market-intel, headless, cdp, dropship, scraper, ingest]
uses_functions: [ingest_market_trends_py_pipelines]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/ingest_market_trends_headless.py"
params:
- name: sources
desc: "Fuentes CDP separadas por coma. Vacío -> las 3 del proyecto (aliexpress_cdp,amazon_movers_cdp,amazon_saturation_cdp). Un source que falle no aborta el resto."
- name: port
desc: "Puerto de remote-debugging del Chrome headless aislado. DEBE coincidir con el `port` de los bloques *_cdp en sources.json (allí ya está a 9334). Default 9334."
- name: profile_dir
desc: "user-data-dir dedicado del Chrome aislado. Vacío -> ~/.config/fn_scrape_chrome (se crea si no existe). Perfil persistente entre corridas."
output: "dict {status: 'ok'|'error', port, profile_dir, launched: bool, closed: bool, results: [{source, scraped, inserted} | {source, error}]}. Nunca lanza excepción al caller: el finally cierra siempre la instancia que lanzó."
---
## Ejemplo
```bash
# Las 3 fuentes CDP en Chrome headless aislado (lanzar -> scrape -> cerrar).
# OJO: fn run pasa los args POSICIONALES (no flags --), en el orden sources, port, profile_dir.
fn run ingest_market_trends_headless
# -> {"status":"ok","port":9334,"profile_dir":"/home/<user>/.config/fn_scrape_chrome",
# "launched":true,"closed":true,
# "results":[{"source":"aliexpress_cdp",...},{"source":"amazon_movers_cdp",...},
# {"source":"amazon_saturation_cdp","scraped":20,"inserted":20}]}
# Solo la fuente de saturación de Amazon (la validada headless sin login):
fn run ingest_market_trends_headless amazon_saturation_cdp
# -> {"status":"ok","port":9334,"launched":true,"closed":true,
# "results":[{"source":"amazon_saturation_cdp","scraped":5,"inserted":5}]}
# Puerto/perfil custom (args posicionales: sources, port, profile_dir):
fn run ingest_market_trends_headless amazon_saturation_cdp 9340 ~/.config/otro_scrape
```
Invocación directa del módulo (acepta flags `--sources`/`--port`/`--profile-dir`):
```bash
python/.venv/bin/python3 python/functions/pipelines/ingest_market_trends_headless.py \
--sources amazon_saturation_cdp
```
## Cuando usarla
Úsala para la ingesta diaria/programada (dag_engine) de las fuentes CDP del proyecto
captacion_clientes cuando NO quieras que el scraping abra pestañas en tu navegador diario.
Levanta su propio Chromium headless con perfil dedicado y lo cierra al terminar — el
navegador personal (`chromium-personal`, CDP 9222) queda intacto. Es el reemplazo de llamar
`ingest_market_trends <source_cdp>` a pelo (que usaría el 9222 con sesión interactiva).
## Gotchas
- **Impura: lanza y mata Chrome.** Arranca un Chromium headless vía `systemd-run --user`
(scope `fnscrape_dag_<port>`); si `systemd-run` no está, cae a `subprocess.Popen` con
grupo de proceso propio. Lo lanzar con `exec` directo desde el agente da **exit-144** — por
eso systemd-run. En el `finally` siempre cierra lo que lanzó (`systemctl --user stop` del
scope/service + respaldo `pkill -f "user-data-dir=<perfil>"`) y verifica con un GET final
que el puerto ya no responde (`closed`).
- **Perfil dedicado persistente.** `~/.config/fn_scrape_chrome` sobrevive entre corridas
(cookies/cache del scraping). No se borra. Bórralo a mano si quieres sesión limpia.
- **Reutiliza CDP existente.** Si el puerto ya responde al arrancar, NO lanza otro Chrome:
reutiliza el vivo y `launched=False` + `closed=False` (no cierra algo que no abrió).
- **Puerto = el del config.** Los bloques `*_cdp` de `sources.json` deben tener `port`
igual al `port` de este wrapper (ya a 9334). `ingest_market_trends(source)` lee el puerto
del config, así reutiliza la instancia que este wrapper levantó. No se le pasa el puerto.
- **AliExpress headless puede pedir captcha.** Sin login, `aliexpress_cdp` puede devolver
`status: captcha` (0 filas) — es esperado, no es bug del wrapper. El lifecycle
(launched -> scrape -> closed) se valida mejor con `amazon_saturation_cdp`.
- **DSN Postgres.** La ingesta necesita el DSN de `trends` (CAPTACION_DSN / .env / pass
captacion/postgres). Si falla la resolución, esa fuente cae en `{source, error}` pero el
Chrome igual se cierra.
@@ -0,0 +1,287 @@
"""ingest_market_trends_headless — ingesta de fuentes CDP en un Chrome headless aislado.
Wrapper de `ingest_market_trends` (pipeline del proyecto captacion_clientes) que lanza un
Chromium **headless** con un **perfil dedicado** y un puerto de remote-debugging propio,
scrapea las fuentes CDP indicadas, y **cierra la instancia al terminar** — siempre, incluso
si falla el scraping.
Motivo: el scraping NO debe abrir pestañas en el navegador diario del usuario
(`chromium-personal`, puerto 9222). Norma: perfil dedicado + headless + cerrar al terminar.
El Chrome se lanza vía `systemd-run --user` (un scope transitorio), porque lanzar chromium
con un `exec`/`Popen` directo desde el proceso del agente da exit-144 cuando hereda el grupo
de controlado del agente. Si `systemd-run` no está disponible, se cae a `subprocess.Popen`
en un grupo de proceso nuevo (`start_new_session=True`).
Las fuentes CDP (`aliexpress_cdp`, `amazon_movers_cdp`, `amazon_saturation_cdp`) leen su
`port` del config `sources.json` del proyecto. Ese config debe apuntar al MISMO puerto que
este wrapper usa para lanzar el Chrome headless (default 9334). De ese modo
`ingest_market_trends(source)` reutiliza la instancia que este wrapper levantó.
"""
import argparse
import json
import os
import shutil
import signal
import subprocess
import sys
import time
import urllib.request
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "python", "functions"))
from pipelines.ingest_market_trends import ingest_market_trends # noqa: E402
DEFAULT_SOURCES = "aliexpress_cdp,amazon_movers_cdp,amazon_saturation_cdp"
DEFAULT_PORT = 9334
DEFAULT_PROFILE = "~/.config/fn_scrape_chrome"
# Candidatos de binario chromium/chrome. shutil.which primero (respeta PATH), luego
# rutas absolutas conocidas del sistema (el `chromium` del usuario suele ser un alias de
# shell no visible a subprocess, y el binario real vive en /usr/lib/chromium/chromium).
_CHROME_NAMES = ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable")
_CHROME_ABS = (
"/usr/bin/chromium",
"/usr/lib/chromium/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/snap/bin/chromium",
)
def _find_chrome() -> str | None:
"""Devuelve la ruta a un binario chromium/chrome ejecutable, o None."""
for name in _CHROME_NAMES:
path = shutil.which(name)
if path:
return path
for path in _CHROME_ABS:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def _cdp_alive(port: int, timeout: float = 1.0) -> bool:
"""True si el endpoint CDP responde en 127.0.0.1:<port>/json/version."""
url = f"http://127.0.0.1:{port}/json/version"
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
return 200 <= resp.status < 300
except Exception: # noqa: BLE001
return False
def _wait_cdp(port: int, deadline_s: float = 12.0) -> bool:
"""Espera a que el CDP responda hasta `deadline_s` (sondea cada 0.5s)."""
end = time.time() + deadline_s
while time.time() < end:
if _cdp_alive(port):
return True
time.sleep(0.5)
return False
def _chrome_args(chrome_bin: str, port: int, profile_dir: str) -> list[str]:
return [
chrome_bin,
"--headless=new",
"--disable-gpu",
f"--remote-debugging-port={port}",
f"--user-data-dir={profile_dir}",
"--no-first-run",
"--no-default-browser-check",
"--remote-allow-origins=*",
"--disable-extensions",
]
def _launch(chrome_bin: str, port: int, profile_dir: str) -> tuple[str, int | None]:
"""Lanza Chrome headless aislado. Devuelve (mecanismo, pid).
mecanismo: 'systemd' (scope transitorio) o 'popen' (grupo de proceso propio).
pid: solo poblado en modo 'popen' (para poder matar el grupo en el cierre).
"""
unit = f"fnscrape_dag_{port}"
systemd_run = shutil.which("systemd-run")
if systemd_run:
cmd = [
systemd_run, "--user", "--quiet", "--collect", f"--unit={unit}",
*_chrome_args(chrome_bin, port, profile_dir),
]
try:
subprocess.run(cmd, check=True, timeout=15,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return "systemd", None
except Exception: # noqa: BLE001
# systemd-run falló (sin --user bus, etc.) -> fallback a Popen.
pass
proc = subprocess.Popen(
_chrome_args(chrome_bin, port, profile_dir),
start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
return "popen", proc.pid
def _close(mechanism: str, pid: int | None, port: int, profile_dir: str) -> bool:
"""Cierra la instancia que ESTE wrapper lanzó. Devuelve True si el puerto ya no responde."""
unit = f"fnscrape_dag_{port}"
if mechanism == "systemd":
systemctl = shutil.which("systemctl")
if systemctl:
for kind in (f"{unit}.scope", f"{unit}.service"):
try:
subprocess.run([systemctl, "--user", "stop", kind],
timeout=10, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
except Exception: # noqa: BLE001
pass
elif mechanism == "popen" and pid is not None:
try:
pgid = os.getpgid(pid)
os.killpg(pgid, signal.SIGTERM)
for _ in range(20): # hasta ~2s para salida limpia
time.sleep(0.1)
if not _cdp_alive(port):
break
if _cdp_alive(port):
os.killpg(pgid, signal.SIGKILL)
except ProcessLookupError:
pass
except Exception: # noqa: BLE001
pass
# Respaldo: matar cualquier chromium colgado de este perfil concreto.
pkill = shutil.which("pkill")
if pkill:
try:
subprocess.run([pkill, "-f", f"user-data-dir={profile_dir}"],
timeout=10, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
except Exception: # noqa: BLE001
pass
# Esperar a que el puerto deje de responder (cierre asíncrono del cgroup).
for _ in range(20): # hasta ~2s
if not _cdp_alive(port):
return True
time.sleep(0.1)
return not _cdp_alive(port)
def ingest_market_trends_headless(
sources: str = "",
port: int = DEFAULT_PORT,
profile_dir: str = "",
) -> dict:
"""Lanza un Chrome headless aislado, scrapea las fuentes CDP y lo cierra al terminar.
Args:
sources: fuentes CDP separadas por coma. Vacío -> las 3 del proyecto
(aliexpress_cdp,amazon_movers_cdp,amazon_saturation_cdp).
port: puerto de remote-debugging del Chrome aislado (debe coincidir con el `port`
de los bloques `*_cdp` en sources.json). Default 9334.
profile_dir: user-data-dir dedicado. Vacío -> ~/.config/fn_scrape_chrome.
Returns:
dict con {status, port, profile_dir, launched, closed, results:[...]}. Nunca lanza
excepción al caller: cualquier fallo se refleja en `status`/`results` y el finally
cierra la instancia.
"""
if not sources:
sources = DEFAULT_SOURCES
if not profile_dir:
profile_dir = os.path.expanduser(DEFAULT_PROFILE)
profile_dir = os.path.abspath(os.path.expanduser(profile_dir))
os.makedirs(profile_dir, exist_ok=True)
out: dict = {
"status": "error",
"port": port,
"profile_dir": profile_dir,
"launched": False,
"closed": False,
"results": [],
}
mechanism = ""
pid: int | None = None
reuse = False
# 1) Si ya hay un CDP vivo en el puerto, reutilizarlo (no lo cerraremos).
if _cdp_alive(port):
reuse = True
else:
chrome_bin = _find_chrome()
if not chrome_bin:
out["error"] = (
"no se encontró binario chromium/chrome "
f"(probados: {', '.join(_CHROME_NAMES)} + rutas absolutas conocidas)"
)
return out
try:
mechanism, pid = _launch(chrome_bin, port, profile_dir)
out["launched"] = True
except Exception as exc: # noqa: BLE001
out["error"] = f"fallo al lanzar chromium: {exc}"
return out
# 2) Esperar a que el CDP responda.
if not _wait_cdp(port, deadline_s=12.0):
out["error"] = f"el CDP no respondió en 127.0.0.1:{port} tras 12s"
out["closed"] = _close(mechanism, pid, port, profile_dir)
return out
# 3) Scrapear cada fuente. Un fallo de una fuente no aborta el resto.
try:
for raw in sources.split(","):
source = raw.strip()
if not source:
continue
try:
summary = ingest_market_trends(source)
out["results"].append({
"source": source,
"scraped": summary.get("scraped"),
"inserted": summary.get("inserted"),
**({"note": summary["note"]} if summary.get("note") else {}),
**({"status": summary["status"]} if summary.get("status") else {}),
})
except Exception as exc: # noqa: BLE001
out["results"].append({"source": source, "error": str(exc)})
out["status"] = "ok"
finally:
# 4) Cerrar SIEMPRE lo que nosotros lanzamos (no si reutilizamos uno ajeno).
if out["launched"] and not reuse:
out["closed"] = _close(mechanism, pid, port, profile_dir)
else:
out["closed"] = False
return out
def main() -> int:
ap = argparse.ArgumentParser(
description="Ingesta de fuentes CDP en un Chrome headless aislado (perfil dedicado)."
)
ap.add_argument("--sources", default="",
help=f"Fuentes CDP separadas por coma. Vacío -> {DEFAULT_SOURCES}.")
ap.add_argument("--port", type=int, default=DEFAULT_PORT,
help="Puerto remote-debugging del Chrome aislado.")
ap.add_argument("--profile-dir", default="",
help="user-data-dir dedicado (vacío -> ~/.config/fn_scrape_chrome).")
args = ap.parse_args()
result = ingest_market_trends_headless(
sources=args.sources, port=args.port, profile_dir=args.profile_dir,
)
print(json.dumps(result, ensure_ascii=False))
return 0 if result.get("status") == "ok" else 1
if __name__ == "__main__":
sys.exit(main())
+46 -20
View File
@@ -38,8 +38,9 @@ from datascience import (
run_eda_models,
summarize_categorical,
summarize_table_duckdb,
summarize_table_pg,
)
from infra import duckdb_query_readonly
from infra import duckdb_query_readonly, pg_query
# semantic_types que justifican promocionar inferred_type -> "numeric".
_NUMERIC_SEMANTIC = ("integer", "decimal", "currency")
@@ -82,10 +83,13 @@ def _to_float(value):
return None
def _sample_values(db_path: str, table: str, name: str, sample: int) -> list:
"""Trae hasta `sample` valores no nulos de una columna (read-only)."""
q = duckdb_query_readonly(
db_path,
def _sample_values(query_fn, table: str, name: str, sample: int) -> list:
"""Trae hasta `sample` valores no nulos de una columna (read-only).
query_fn(sql) -> dict es el lector read-only del backend activo
(duckdb_query_readonly o pg_query), inyectado por profile_table.
"""
q = query_fn(
f'SELECT "{name}" AS v FROM "{table}" WHERE "{name}" IS NOT NULL '
f"LIMIT {int(sample)}",
)
@@ -94,19 +98,18 @@ def _sample_values(db_path: str, table: str, name: str, sample: int) -> list:
return [row.get("v") for row in q.get("rows", [])]
def _sample_rows(db_path: str, table: str, names: list, sample: int) -> list:
def _sample_rows(query_fn, table: str, names: list, sample: int) -> list:
"""Trae hasta `sample` filas completas con las columnas alineadas por fila.
A diferencia de _sample_values (una columna, solo no nulos), esto preserva la
alineacion por fila entre columnas, requisito de la matriz de asociacion
(los pares (a_i, b_i) deben venir de la misma fila).
(los pares (a_i, b_i) deben venir de la misma fila). query_fn es el lector
read-only del backend activo, inyectado por profile_table.
"""
if not names:
return []
cols_sql = ", ".join(f'"{n}"' for n in names)
q = duckdb_query_readonly(
db_path, f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}'
)
q = query_fn(f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}')
if q.get("status") != "ok":
return []
return q.get("rows", [])
@@ -115,17 +118,20 @@ def _sample_rows(db_path: str, table: str, names: list, sample: int) -> list:
def profile_table(
db_path: str,
table: str,
backend: str = "duckdb",
sample: int = 5000,
run_models: bool = False,
run_llm: bool = False,
report_dir: str = "reports",
write_report: bool = True,
) -> dict:
"""Perfila una tabla DuckDB end-to-end y emite el TableProfile completo.
"""Perfila una tabla (DuckDB o PostgreSQL) end-to-end y emite el TableProfile.
Args:
db_path: ruta al archivo DuckDB (read-only, debe existir).
db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres".
table: nombre de la tabla a perfilar.
backend: "duckdb" (default) o "postgres". Selecciona el motor de
perfilado base (summarize) y de muestreo read-only.
sample: maximo de valores no nulos muestreados por columna para el
enriquecimiento (describe_numeric / summarize_categorical /
infer_semantic_type). Default 5000.
@@ -141,8 +147,22 @@ def profile_table(
lanzar): {status:'error', error:str}.
"""
try:
# 1) Perfil base por columna (push-down SQL).
r = summarize_table_duckdb(db_path, table)
# 1) Perfil base por columna (push-down SQL) + lector read-only del
# backend activo, inyectado en el muestreo (_sample_values/_sample_rows).
if backend == "postgres":
r = summarize_table_pg(db_path, table)
def _q(sql):
return pg_query(db_path, sql)
elif backend == "duckdb":
r = summarize_table_duckdb(db_path, table)
def _q(sql):
return duckdb_query_readonly(db_path, sql)
else:
return {"status": "error", "error": f"backend desconocido: {backend}"}
if r.get("status") != "ok":
return {"status": "error", "error": r.get("error", "summarize failed")}
prof = r["profile"]
@@ -153,7 +173,7 @@ def profile_table(
inferred = col.get("inferred_type")
# 2) Muestra de valores no nulos.
vals = _sample_values(db_path, table, name, sample)
vals = _sample_values(_q, table, name, sample)
# 3) Promocion de tipo sobre columnas textuales.
if inferred in ("categorical", "text"):
@@ -239,7 +259,7 @@ def profile_table(
assoc_cols = [c for c in cols if not _skip_for_assoc(c)]
rows = _sample_rows(
db_path, table, [c["name"] for c in assoc_cols], corr_sample
_q, table, [c["name"] for c in assoc_cols], corr_sample
)
assoc_input = {}
for c in assoc_cols:
@@ -256,12 +276,18 @@ def profile_table(
prof["correlations"] = (
association_matrix(assoc_input) if len(assoc_input) >= 2 else None
)
# Modelos baratos opt-in (PCA/KMeans/IsolationForest/normalidad).
if run_models:
prof["models"] = run_eda_models(assoc_input)
except Exception: # noqa: BLE001
prof["correlations"] = None
prof["models"] = None
assoc_input = {}
# Modelos baratos opt-in en su PROPIO try: un fallo de los modelos NUNCA
# debe tumbar las correlaciones ya calculadas (bug detectado en EDAs PG
# reales: un try/except compartido ponia ambos campos a None).
if run_models:
try:
prof["models"] = run_eda_models(assoc_input)
except Exception: # noqa: BLE001
prof["models"] = None
# 8.6) Capa LLM opcional: interpreta el perfil ya calculado en UNA
# llamada (data dictionary, resumen, granularidad de fila, PII, limpieza,
@@ -0,0 +1,70 @@
---
name: query_project_pg
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def query_project_pg(project: str, sql: str, max_rows: int = 10000) -> dict"
description: "Pipeline one-shot que ejecuta un SELECT contra el Postgres de un proyecto conocido del ecosistema (captacion_clientes, seo_analytics) sin reescribir la resolucion del DSN ni la conexion a mano. Compone resolve_pg_dsn(project) (resuelve el DSN desde env var / .env / pass) con pg_query(dsn, sql, max_rows) (SELECT read-only via psycopg2 que devuelve filas como list[dict]). Elimina el patron inline que el agente repetia: grep al .env + fallback a pass + psql crudo. El caller solo pasa el nombre del proyecto y el SQL; el password sale de pass en runtime, nunca hardcodeado. Devuelve lo que devuelve pg_query en exito {status:'ok', columns, rows, row_count, truncated}, o propaga el {status:'error', error} de resolve_pg_dsn si falla la resolucion del DSN (sin tocar Postgres). Sin lanzar."
tags: [postgres, postgresql, sql, query, pipelines]
uses_functions: [resolve_pg_dsn_py_infra, pg_query_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/query_project_pg.py"
params:
- name: project
desc: "Nombre del proyecto conocido. Acepta clave canonica ('captacion', 'seo') o alias largo ('captacion_clientes', 'seo_analytics'). Se pasa tal cual a resolve_pg_dsn; un proyecto desconocido propaga su {status:'error'}."
- name: sql
desc: "Sentencia SQL a ejecutar (pensada para SELECT). Este pipeline no expone parametros posicionales: interpola solo valores constantes y de confianza. Para entradas no confiables usa pg_query directamente con su argumento params (%s)."
- name: max_rows
desc: "Numero maximo de filas a materializar en memoria (default 10000). Se pasa tal cual a pg_query; si la query produce mas, el resultado se trunca y truncated queda en True."
output: "dict. En exito (propaga pg_query): {status:'ok', columns:[str,...], rows:[{col:val,...},...], row_count:int, truncated:bool}. En error: si la resolucion del DSN falla, {status:'error', error:str} de resolve_pg_dsn; si la query falla, {status:'error', error:str} de pg_query. Sin lanzar."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from pipelines.query_project_pg import query_project_pg
# Una sola llamada: resuelve el DSN de captacion_clientes y cuenta filas.
res = query_project_pg("captacion", "SELECT COUNT(*) FROM product_opportunities")
print(res["status"]) # ok
print(res["rows"][0]) # {'count': 42}
# Lanzable directo desde la CLI del registry (corre la demo del __main__):
# ./fn run query_project_pg
```
## Cuando usarla
Usala cada vez que necesites leer datos del Postgres de un proyecto del
ecosistema (captacion_clientes, seo_analytics) en un solo paso, en vez de
resolver el DSN a mano y abrir la conexion tu mismo. Es el reemplazo directo del
bloque inline `DSN=$(grep ... .env) ; psql "$DSN" -c "SELECT ..."`. Para varias
queries con el mismo proyecto, o si necesitas parametros posicionales seguros
(%s), resuelve el DSN una vez con `resolve_pg_dsn` y llama a `pg_query`
directamente reusando el DSN.
## Gotchas
- Impuro: resuelve el DSN (lee env / .env / pass) y abre una conexion a
Postgres. Depende del entorno de la maquina y de que el contenedor del
proyecto este levantado.
- Solo lectura: `pg_query` marca la transaccion `SET TRANSACTION READ ONLY`.
No uses este pipeline para INSERT/UPDATE/DELETE.
- No expone `params` posicionales: el SQL se ejecuta tal cual. NO interpoles
entradas no confiables en el string (riesgo de inyeccion); para eso usa
`pg_query` con su argumento `params`.
- El resultado se trunca a `max_rows` filas (default 10000) para proteger
memoria; revisa `truncated` en la salida.
- La ruta del `.env` del proyecto se resuelve relativa a `FN_REGISTRY_ROOT` o,
en su defecto, al cwd. Lanza desde la raiz del registry o exporta esa env var.
@@ -0,0 +1,54 @@
"""Pipeline one-shot: ejecuta un SELECT contra el Postgres de un proyecto conocido.
Compone dos funciones del registry sin reescribir su lógica:
1. resolve_pg_dsn(project) -> resuelve el DSN del proyecto (env / .env / pass).
2. pg_query(dsn, sql, max_rows=...) -> ejecuta el SELECT read-only y devuelve
las filas como list[dict].
Elimina el patrón inline que el agente repetía: resolver el DSN a mano y luego
lanzar psql/psycopg2 con él. El caller solo necesita el nombre del proyecto y
el SQL; el password sale de pass en runtime, nunca está hardcodeado.
Es un pipeline (kind: pipeline -> siempre impuro). Devuelve un dict sin lanzar:
lo que devuelve pg_query en éxito, o el error de resolución del DSN si falla
el primer paso.
"""
from infra.resolve_pg_dsn import resolve_pg_dsn
from infra.pg_query import pg_query
def query_project_pg(project: str, sql: str, max_rows: int = 10000) -> dict:
"""Resuelve el DSN de un proyecto y ejecuta un SELECT contra su Postgres.
Args:
project: nombre del proyecto conocido ('captacion' / 'captacion_clientes',
'seo' / 'seo_analytics'). Se pasa tal cual a resolve_pg_dsn.
sql: sentencia SQL a ejecutar (pensada para SELECT). Para parámetros, usa
el marcador %s; este pipeline no expone params posicionales, así que
interpola valores constantes y de confianza solo (para entradas no
confiables usa pg_query directamente con params).
max_rows: número máximo de filas a materializar (default 10000). Se pasa
tal cual a pg_query; si la query produce más, el resultado se trunca.
Returns:
dict. En éxito propaga el resultado de pg_query:
{status:'ok', columns, rows, row_count, truncated}. Si la resolución del
DSN falla, propaga {status:'error', error} de resolve_pg_dsn sin tocar
Postgres.
"""
resolved = resolve_pg_dsn(project)
if resolved.get("status") != "ok":
return resolved
return pg_query(resolved["dsn"], sql, max_rows=max_rows)
if __name__ == "__main__":
# Demo lanzable: cuenta de oportunidades de producto en captacion_clientes.
import json
out = query_project_pg(
"captacion",
"SELECT COUNT(*) AS n FROM product_opportunities",
)
print(json.dumps(out, ensure_ascii=False))
@@ -0,0 +1,74 @@
---
name: refresh_local_hub
kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "def refresh_local_hub(manifest_path: str | None = None, reload: bool = True) -> dict"
description: "Orquesta el refresco del sistema local_hub: descubre los servicios locales, regenera el fragmento de Caddyfile y la config de Glance, recarga Caddy (admin API) y reinicia la user-unit glance. Compone discover_local_services + render_caddyfile + render_glance_config. Lo corre el dag_engine a diario y tambien el usuario a mano."
tags: [local-hub, pipelines, pipeline, caddy, glance, infra, dashboard]
uses_functions: [discover_local_services_py_infra, render_caddyfile_py_infra, render_glance_config_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [pyyaml]
tested: true
tests:
- "compone las tres funciones del registry y escribe ambas configs"
- "la config completa de Glance generada es YAML parseable"
- "con reload=False no se llama a subprocess.run"
- "con reload=True se invoca caddy reload y systemctl --user restart glance"
- "el dict de retorno tiene todas las claves del contrato"
test_file_path: "python/functions/pipelines/refresh_local_hub_test.py"
file_path: "python/functions/pipelines/refresh_local_hub.py"
params:
- name: manifest_path
desc: "Ruta al manifiesto YAML del local_hub. Si es None se usa <RAIZ>/apps/local_hub/local_services.yaml (RAIZ derivada de FN_REGISTRY_ROOT o del path del modulo)."
- name: reload
desc: "Si True recarga Caddy (admin API localhost:2019, sin sudo) y reinicia la user-unit glance (sin sudo). Si False solo regenera las configs y no toca servicios."
output: "dict {total, up, down, caddy_path, glance_path, reloaded, caddy_reload_rc, glance_restart_rc, services:[{name,subdomain,port,up}, ...]}"
---
## Ejemplo
```bash
./fn run refresh_local_hub
```
Sin recargar servicios (solo regenera las configs):
```bash
$HOME/fn_registry/python/.venv/bin/python3 \
python/functions/pipelines/refresh_local_hub.py --no-reload
```
Desde Python:
```python
from pipelines.refresh_local_hub import refresh_local_hub
r = refresh_local_hub(reload=True)
print(r)
# {"total": 8, "up": 6, "down": 2, "caddy_path": "/etc/caddy/conf.d/local_hub.caddy",
# "glance_path": ".../apps/local_hub/glance/glance.yml", "reloaded": True,
# "caddy_reload_rc": 0, "glance_restart_rc": 0, "services": [...]}
```
## Cuando usarla
Cuando cambien los servicios locales expuestos como subdominios `*.localhost` (alta/baja de un servicio en el manifiesto, o un service nuevo del registry con bloque `service:`) y quieras que Caddy y el dashboard Glance reflejen el estado actual. Es el paso `function:` que el dag_engine corre a diario para mantener el `local_hub` sincronizado, y el comando que lanzas a mano tras editar `apps/local_hub/local_services.yaml`.
## Gotchas
- **Impura: escribe en `/etc/caddy/conf.d/local_hub.caddy` via ACL**, no via sudo. El usuario debe tener permiso de escritura ahi (ACL ya configurada en este PC). Sin la ACL, el `open(..., "w")` lanza `PermissionError`.
- **Recarga Caddy por su admin API** (`caddy reload` habla con `localhost:2019`), no reinicia el servicio: requiere que Caddy este corriendo con la admin API activa. Si Caddy no esta levantado, `caddy_reload_rc` queda en un valor != 0 (o -1 si el binario falla) pero el pipeline NO lanza.
- **Reinicia la user-unit `glance`** (`systemctl --user restart glance`), no la system-unit: requiere que la user-unit `glance` este instalada y el bus de usuario disponible. Si falta, `glance_restart_rc` refleja el fallo sin abortar.
- **Valida el YAML de Glance antes de escribir**: si `render_glance_config` produjera YAML invalido, el pipeline lanza `RuntimeError` con mensaje claro y no escribe el archivo (fail-fast).
- **Raiz dinamica**: la raiz del registry se deriva de `FN_REGISTRY_ROOT` o del path del modulo; nunca se hardcodea ningun `/home/<user>`.
- **`reload=False` no toca ningun servicio**: util para previsualizar las configs generadas sin recargar Caddy ni reiniciar Glance (lo que hace el test).
## Capability growth log
(sin entradas — v1.0.0 inicial)
@@ -0,0 +1,208 @@
"""refresh_local_hub — orquesta el refresco del sistema local_hub.
Pipeline impuro del dominio `pipelines`. Compone tres funciones del registry para
regenerar la infraestructura que expone los servicios locales como subdominios
`*.localhost`:
1. discover_local_services_py_infra — descubre y normaliza los servicios (manifiesto
+ servicios del registry con bloque `service:`), comprobando estado up/down.
2. render_caddyfile_py_infra — genera el fragmento de Caddyfile.
3. render_glance_config_py_infra — genera la config del widget monitor de Glance.
Después escribe el fragmento de Caddyfile en /etc/caddy/conf.d/local_hub.caddy (el
usuario tiene ACL de escritura ahí, sin sudo) y la config de Glance en
apps/local_hub/glance/glance.yml. Si `reload=True`, recarga Caddy (admin API en
localhost:2019, sin sudo) y reinicia la user-unit `glance` (sin sudo).
Pensado para correrse a diario desde dag_engine con un step `function:`, o a mano:
`fn run refresh_local_hub`.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from typing import Any
import yaml
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "python", "functions"))
from infra.discover_local_services import discover_local_services # noqa: E402
from infra.render_caddyfile import render_caddyfile # noqa: E402
from infra.render_glance_config import render_glance_config # noqa: E402
# Ruta del fragmento de Caddyfile. El usuario tiene ACL de escritura aquí (sin sudo).
CADDY_FRAGMENT_PATH = "/etc/caddy/conf.d/local_hub.caddy"
# Bloque fijo de servidor + tema que precede a la salida de render_glance_config.
GLANCE_SERVER_BLOCK = (
"server:\n"
" host: 127.0.0.1\n"
" port: 8585\n"
"\n"
"theme:\n"
" background-color: 240 8 9\n"
" contrast-multiplier: 1.2\n"
" primary-color: 210 90 70\n"
"\n"
)
def _default_manifest_path() -> str:
"""Ruta por defecto del manifiesto, derivada de la raíz del registry."""
root = os.environ.get("FN_REGISTRY_ROOT") or ROOT
return os.path.join(root, "apps", "local_hub", "local_services.yaml")
def _registry_root() -> str:
"""Raíz del registry: FN_REGISTRY_ROOT si está, si no la derivada del path del módulo."""
return os.environ.get("FN_REGISTRY_ROOT") or ROOT
def refresh_local_hub(
manifest_path: str | None = None,
reload: bool = True,
) -> dict[str, Any]:
"""Refresca el sistema local_hub: descubre servicios, regenera configs y recarga.
Args:
manifest_path: ruta al manifiesto YAML del local_hub. Si es None, se usa
``<RAIZ>/apps/local_hub/local_services.yaml`` (RAIZ derivada de
FN_REGISTRY_ROOT o del path del propio módulo).
reload: si True, recarga Caddy (admin API en localhost:2019) y reinicia la
user-unit ``glance``. Si False, solo escribe las configs y no toca
ningún servicio.
Returns:
dict resumen con las claves: total, up, down, caddy_path, glance_path,
reloaded, caddy_reload_rc, glance_restart_rc, services.
Raises:
RuntimeError: si la config de Glance generada no es YAML parseable.
"""
if manifest_path is None:
manifest_path = _default_manifest_path()
# 1. Lee el manifiesto para extraer dashboard_subdomain y glance_port.
try:
with open(manifest_path, "r", encoding="utf-8") as fh:
manifest = yaml.safe_load(fh) or {}
except (OSError, yaml.YAMLError) as exc:
raise RuntimeError(
f"refresh_local_hub: no se puede leer el manifiesto {manifest_path}: {exc}"
) from exc
dashboard_subdomain = manifest.get("dashboard_subdomain") or "home"
glance_port = manifest.get("glance_port") or 8585
# 2. Descubre los servicios (manifiesto + registry).
services = discover_local_services(manifest_path, include_registry=True)
# 3. Bloque del dashboard para el Caddyfile.
dashboard = {"subdomain": dashboard_subdomain, "port": glance_port}
# 4. Renderiza el fragmento de Caddyfile.
caddy_text = render_caddyfile(services, dashboard)
# 5. Construye la config completa de Glance (bloque fijo + render_glance_config).
glance_full = GLANCE_SERVER_BLOCK + render_glance_config(services)
# Verifica que el YAML resultante es parseable antes de escribir nada.
try:
yaml.safe_load(glance_full)
except yaml.YAMLError as exc:
raise RuntimeError(
f"refresh_local_hub: la config de Glance generada no es YAML válido: {exc}"
) from exc
# 6. Escribe el fragmento de Caddyfile (ACL del usuario, sin sudo).
with open(CADDY_FRAGMENT_PATH, "w", encoding="utf-8") as fh:
fh.write(caddy_text)
# 7. Escribe la config de Glance (crea el dir si falta).
glance_path = os.path.join(_registry_root(), "apps", "local_hub", "glance", "glance.yml")
os.makedirs(os.path.dirname(glance_path), exist_ok=True)
with open(glance_path, "w", encoding="utf-8") as fh:
fh.write(glance_full)
up = sum(1 for s in services if s.get("up"))
down = len(services) - up
caddy_reload_rc: int | None = None
glance_restart_rc: int | None = None
# 8. Recarga Caddy y reinicia Glance (ambos sin sudo).
if reload:
try:
caddy_proc = subprocess.run(
["caddy", "reload", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"],
capture_output=True,
text=True,
timeout=30,
)
caddy_reload_rc = caddy_proc.returncode
except (OSError, subprocess.SubprocessError) as exc:
caddy_reload_rc = -1
sys.stderr.write(f"refresh_local_hub: fallo recargando Caddy: {exc}\n")
try:
glance_proc = subprocess.run(
["systemctl", "--user", "restart", "glance"],
capture_output=True,
text=True,
timeout=30,
)
glance_restart_rc = glance_proc.returncode
except (OSError, subprocess.SubprocessError) as exc:
glance_restart_rc = -1
sys.stderr.write(f"refresh_local_hub: fallo reiniciando Glance: {exc}\n")
return {
"total": len(services),
"up": up,
"down": down,
"caddy_path": CADDY_FRAGMENT_PATH,
"glance_path": glance_path,
"reloaded": reload,
"caddy_reload_rc": caddy_reload_rc,
"glance_restart_rc": glance_restart_rc,
"services": [
{
"name": s.get("name"),
"subdomain": s.get("subdomain"),
"port": s.get("port"),
"up": s.get("up"),
}
for s in services
],
}
def main() -> None:
parser = argparse.ArgumentParser(description="Refresca el sistema local_hub.")
parser.add_argument(
"--manifest-path",
default=None,
help="Ruta al manifiesto YAML del local_hub (default: <RAIZ>/apps/local_hub/local_services.yaml).",
)
parser.add_argument(
"--no-reload",
action="store_true",
help="No recargar Caddy ni reiniciar Glance; solo regenerar las configs.",
)
args = parser.parse_args()
result = refresh_local_hub(
manifest_path=args.manifest_path,
reload=not args.no_reload,
)
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,167 @@
"""Tests para refresh_local_hub.
No recarga Caddy ni reinicia Glance de verdad: mockea `subprocess.run`. Escribe las
configs generadas en un `tmp_path` parcheando las rutas de salida. Mockea
`discover_local_services` para devolver 2 servicios fijos (no depende de puertos reales).
"""
from __future__ import annotations
import os
import yaml
import pytest
from pipelines import refresh_local_hub as rlh_module
from pipelines.refresh_local_hub import refresh_local_hub
FAKE_SERVICES = [
{
"name": "metabase",
"subdomain": "metabase",
"port": 3030,
"health_path": "/api/health",
"title": "Metabase",
"icon": "si:metabase",
"category": "Datos",
"up": True,
},
{
"name": "portainer",
"subdomain": "portainer",
"port": 9000,
"health_path": "/",
"title": "Portainer",
"icon": "si:portainer",
"category": "Infra",
"up": False,
},
]
def _write_manifest(tmp_path) -> str:
"""Crea un manifiesto YAML mínimo y devuelve su ruta."""
manifest = {
"dashboard_subdomain": "home",
"glance_port": 8585,
"services": [],
}
path = os.path.join(str(tmp_path), "local_services.yaml")
with open(path, "w", encoding="utf-8") as fh:
yaml.safe_dump(manifest, fh)
return path
@pytest.fixture
def patched_env(tmp_path, monkeypatch):
"""Parchea discover_local_services, las rutas de salida y subprocess.run."""
# discover_local_services devuelve 2 servicios fijos.
monkeypatch.setattr(rlh_module, "discover_local_services", lambda *a, **k: list(FAKE_SERVICES))
# Rutas de salida hacia tmp_path.
caddy_path = os.path.join(str(tmp_path), "local_hub.caddy")
monkeypatch.setattr(rlh_module, "CADDY_FRAGMENT_PATH", caddy_path)
monkeypatch.setattr(rlh_module, "_registry_root", lambda: str(tmp_path))
# subprocess.run mockeado: registra las llamadas y devuelve un objeto con returncode 0.
calls: list[list[str]] = []
class _FakeProc:
def __init__(self, rc: int = 0) -> None:
self.returncode = rc
self.stdout = ""
self.stderr = ""
def _fake_run(cmd, *args, **kwargs):
calls.append(list(cmd))
return _FakeProc(0)
monkeypatch.setattr(rlh_module.subprocess, "run", _fake_run)
manifest_path = _write_manifest(tmp_path)
return {
"manifest_path": manifest_path,
"caddy_path": caddy_path,
"glance_path": os.path.join(str(tmp_path), "apps", "local_hub", "glance", "glance.yml"),
"calls": calls,
"tmp_path": str(tmp_path),
}
def test_compone_las_tres_funciones_y_escribe_configs(patched_env):
"""compone las tres funciones del registry y escribe ambas configs"""
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
# Caddyfile escrito con un bloque por servicio + dashboard.
assert os.path.exists(patched_env["caddy_path"])
caddy_text = open(patched_env["caddy_path"], encoding="utf-8").read()
assert "metabase.localhost" in caddy_text
assert "portainer.localhost" in caddy_text
assert "home.localhost" in caddy_text # bloque del dashboard
assert "reverse_proxy 127.0.0.1:3030" in caddy_text
# Glance escrito con el bloque servidor fijo + la salida de render_glance_config.
assert os.path.exists(result["glance_path"])
glance_text = open(result["glance_path"], encoding="utf-8").read()
assert "host: 127.0.0.1" in glance_text
assert "port: 8585" in glance_text
assert "primary-color: 210 90 70" in glance_text
assert "type: monitor" in glance_text
def test_glance_full_es_yaml_parseable(patched_env):
"""la config completa de Glance generada es YAML parseable"""
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
glance_text = open(result["glance_path"], encoding="utf-8").read()
parsed = yaml.safe_load(glance_text)
assert isinstance(parsed, dict)
assert parsed["server"]["host"] == "127.0.0.1"
assert parsed["server"]["port"] == 8585
assert "theme" in parsed
assert "pages" in parsed # la salida de render_glance_config
def test_reload_false_no_llama_subprocess(patched_env):
"""con reload=False no se llama a subprocess.run"""
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
assert patched_env["calls"] == []
assert result["reloaded"] is False
assert result["caddy_reload_rc"] is None
assert result["glance_restart_rc"] is None
def test_reload_true_recarga_caddy_y_reinicia_glance(patched_env):
"""con reload=True se invoca caddy reload y systemctl --user restart glance"""
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=True)
cmds = patched_env["calls"]
assert len(cmds) == 2
assert cmds[0][0] == "caddy" and "reload" in cmds[0]
assert cmds[1] == ["systemctl", "--user", "restart", "glance"]
assert result["reloaded"] is True
assert result["caddy_reload_rc"] == 0
assert result["glance_restart_rc"] == 0
def test_dict_retorno_tiene_claves_esperadas(patched_env):
"""el dict de retorno tiene todas las claves del contrato"""
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
expected_keys = {
"total", "up", "down", "caddy_path", "glance_path",
"reloaded", "caddy_reload_rc", "glance_restart_rc", "services",
}
assert expected_keys <= set(result.keys())
assert result["total"] == 2
assert result["up"] == 1
assert result["down"] == 1
assert len(result["services"]) == 2
assert result["services"][0]["subdomain"] == "metabase"
assert {"name", "subdomain", "port", "up"} <= set(result["services"][0].keys())
if __name__ == "__main__":
pytest.main([__file__, "-v"])