feat(infra): auto-commit con 56 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ from .fetch_hackernews_search import fetch_hackernews_search
|
||||
from .score_demand_signal import score_demand_signal
|
||||
from .pull_gsc_search_analytics import pull_gsc_search_analytics
|
||||
from .summarize_table_duckdb import summarize_table_duckdb
|
||||
from .summarize_table_pg import summarize_table_pg
|
||||
from .describe_numeric import describe_numeric
|
||||
from .summarize_categorical import summarize_categorical
|
||||
from .infer_semantic_type import infer_semantic_type
|
||||
@@ -46,6 +47,7 @@ from .build_eda_notebook import build_eda_notebook
|
||||
|
||||
__all__ = [
|
||||
"summarize_table_duckdb",
|
||||
"summarize_table_pg",
|
||||
"spearman_corr",
|
||||
"cramers_v",
|
||||
"theils_u",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: depth_to_relief_glb
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220) -> dict"
|
||||
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth."
|
||||
tags: [img-to-3d, datascience, mesh, glb, gltf, relief, heightmap, trimesh, 3d, texture]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: image
|
||||
desc: "PIL.Image RGB usada como textura de la malla. Tipicamente la 'image' devuelta por estimate_image_depth (la imagen original)."
|
||||
- name: depth
|
||||
desc: "ndarray HxW float32 en [0,1] (1=mas cerca). Tipicamente el 'depth' devuelto por estimate_image_depth. Si ndim != 2 se devuelve status error."
|
||||
- name: out_glb_path
|
||||
desc: "Ruta de salida del .glb. El directorio padre debe existir (si no, la exportacion de trimesh falla -> status error)."
|
||||
- name: z_scale
|
||||
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas pronunciado/exagerado."
|
||||
- name: max_dim
|
||||
desc: "Lado maximo del grid tras downsample bilineal (default 220, ~48k vertices / ~96k caras). Controla resolucion de la malla vs tamano del .glb. Imagenes mayores se reducen; menores se dejan igual."
|
||||
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/datascience/depth_to_relief_glb.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Requiere un venv con torch + transformers + trimesh + pillow (el de apps/img_to_3d_webapp/backend/.venv).
|
||||
# Import PLANO a los modulos (el paquete datascience.__init__ arrastra deps de otros dominios).
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/datascience")
|
||||
from estimate_image_depth import estimate_image_depth
|
||||
from depth_to_relief_glb import depth_to_relief_glb
|
||||
|
||||
est = estimate_image_depth("apps/img_to_3d_webapp/samples/cats.jpg")
|
||||
assert est["status"] == "ok"
|
||||
|
||||
res = depth_to_relief_glb(est["image"], est["depth"], "/tmp/cats_relief.glb", z_scale=0.35, max_dim=220)
|
||||
print(res["status"], res["vertices"], res["faces"]) # ok 48400 96114
|
||||
print(res["glb_path"]) # /tmp/cats_relief.glb (cargable con useGLTF/GLTFLoader)
|
||||
```
|
||||
|
||||
Lanzable end-to-end (el demo CLI encadena estimate_image_depth internamente):
|
||||
|
||||
```bash
|
||||
./fn run depth_to_relief_glb_py_datascience apps/img_to_3d_webapp/samples/cats.jpg /tmp/cats_relief.glb
|
||||
# {"status": "ok", "glb_path": "/tmp/cats_relief.glb", "vertices": ..., "faces": ..., ...}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras `estimate_image_depth`, cuando quieras un modelo 3D real (no solo el mapa de profundidad):
|
||||
visualizar una foto en relieve navegable, exportar a un visor web (three.js `useGLTF`/`GLTFLoader`,
|
||||
Babylon, model-viewer) o a cualquier herramienta que lea glTF. Es el paso 2 (final) del grupo
|
||||
`img-to-3d`. Usa `max_dim` para equilibrar detalle vs peso del .glb y `z_scale` para exagerar o
|
||||
suavizar el relieve.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe el archivo .glb en `out_glb_path`. El directorio padre debe existir o
|
||||
`trimesh.export` falla (vuelve como status error, no crash).
|
||||
- **Dep**: requiere `trimesh` (4.5.x) + `pillow` + `numpy`. `trimesh` se importa dentro de la
|
||||
funcion. No esta en el venv del registry; vive en el venv de la app `img_to_3d_webapp`.
|
||||
- **No es reconstruccion real de geometria**: es un heightmap (relieve 2.5D). Solo deforma un
|
||||
plano segun la profundidad; no recupera las caras ocultas ni el volumen trasero del objeto.
|
||||
- El downsample a `max_dim` usa interpolacion bilineal sobre el depth cuantizado a uint8 (0-255)
|
||||
para reescalar; introduce una ligera perdida de precision en la profundidad de la malla.
|
||||
- UV con V invertido (`1 - v`) por convencion glTF; la textura es la imagen convertida a RGB.
|
||||
- `process=False` en Trimesh: no se hace merge de vertices ni limpieza, para preservar la
|
||||
correspondencia 1:1 vertice<->pixel (necesaria para el mapeo UV del grid).
|
||||
- **Import plano**: importa el modulo directo, NO `from datascience import ...` (el `__init__` del
|
||||
paquete arrastra deps de otros dominios ausentes en el venv de vision). Ver misma gotcha en
|
||||
`estimate_image_depth`.
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Construcción de una malla de relieve (heightmap) texturizada exportada como glTF binario (.glb).
|
||||
|
||||
Función del registry (grupo de capacidad `img-to-3d`, dominio `datascience`). Promovida desde la
|
||||
app `img_to_3d_webapp`. A partir de un mapa de profundidad y la imagen original construye un grid
|
||||
regular de vértices cuyo eje Z es la profundidad y mapea la imagen como textura mediante
|
||||
coordenadas UV. El resultado es un modelo 3D navegable que conserva el aspecto de la imagen vista
|
||||
en relieve, cargable con useGLTF / GLTFLoader directamente.
|
||||
|
||||
Impura: escribe el archivo .glb en disco.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def depth_to_relief_glb(
|
||||
image: "Image.Image",
|
||||
depth: "np.ndarray",
|
||||
out_glb_path: str,
|
||||
z_scale: float = 0.35,
|
||||
max_dim: int = 220,
|
||||
) -> dict:
|
||||
"""
|
||||
Construye una malla de relieve texturizada y la exporta como .glb.
|
||||
|
||||
Parámetros:
|
||||
image: PIL.Image RGB usada como textura.
|
||||
depth: ndarray HxW float32 en [0,1] (1 = más cerca de la cámara).
|
||||
out_glb_path: ruta de salida del .glb.
|
||||
z_scale: amplitud del relieve (fracción del lado de la malla). Default 0.35.
|
||||
max_dim: lado máximo del grid tras downsample (controla nº de vértices/caras).
|
||||
Default 220 (~48k vértices, ~96k caras).
|
||||
|
||||
Devuelve (dict, nunca lanza):
|
||||
Éxito: {"status": "ok", "glb_path": out_glb_path, "vertices": int, "faces": int,
|
||||
"height": H, "width": W}.
|
||||
Error: {"status": "error", "error": str} (depth con forma inválida, directorio de
|
||||
salida inexistente, fallo de exportación de trimesh).
|
||||
"""
|
||||
try:
|
||||
import trimesh
|
||||
|
||||
depth = np.asarray(depth, dtype=np.float32)
|
||||
if depth.ndim != 2:
|
||||
raise ValueError(f"depth debe ser un array 2D HxW, recibido ndim={depth.ndim}")
|
||||
|
||||
H, W = depth.shape
|
||||
|
||||
# Downsample para acotar el número de vértices (max_dim^2 ~ 48k vértices a 220).
|
||||
scale = max(H, W) / float(max_dim)
|
||||
if scale > 1.0:
|
||||
new_w, new_h = max(2, int(round(W / scale))), max(2, int(round(H / scale)))
|
||||
depth_img = Image.fromarray((np.clip(depth, 0, 1) * 255).astype(np.uint8))
|
||||
depth_img = depth_img.resize((new_w, new_h), Image.BILINEAR)
|
||||
depth = np.asarray(depth_img, dtype=np.float32) / 255.0
|
||||
H, W = depth.shape
|
||||
|
||||
# Coordenadas del grid: X corrige aspect ratio, Y hacia abajo, Z = profundidad.
|
||||
aspect = W / float(H)
|
||||
xs = np.linspace(-aspect / 2.0, aspect / 2.0, W, dtype=np.float32)
|
||||
ys = np.linspace(0.5, -0.5, H, dtype=np.float32)
|
||||
gx, gy = np.meshgrid(xs, ys)
|
||||
gz = (depth * z_scale).astype(np.float32)
|
||||
vertices = np.column_stack([gx.ravel(), gy.ravel(), gz.ravel()])
|
||||
|
||||
# Caras: dos triángulos por celda del grid.
|
||||
idx = np.arange(H * W, dtype=np.int64).reshape(H, W)
|
||||
v00 = idx[:-1, :-1].ravel()
|
||||
v01 = idx[:-1, 1:].ravel()
|
||||
v10 = idx[1:, :-1].ravel()
|
||||
v11 = idx[1:, 1:].ravel()
|
||||
faces = np.vstack(
|
||||
[
|
||||
np.column_stack([v00, v10, v11]),
|
||||
np.column_stack([v00, v11, v01]),
|
||||
]
|
||||
)
|
||||
|
||||
# UV mapeando cada vértice al pixel de la imagen (V invertido para convención glTF).
|
||||
u = np.linspace(0.0, 1.0, W, dtype=np.float32)
|
||||
v = np.linspace(0.0, 1.0, H, dtype=np.float32)
|
||||
uu, vv = np.meshgrid(u, v)
|
||||
uv = np.column_stack([uu.ravel(), (1.0 - vv).ravel()])
|
||||
|
||||
visual = trimesh.visual.TextureVisuals(uv=uv, image=image.convert("RGB"))
|
||||
mesh = trimesh.Trimesh(vertices=vertices, faces=faces, visual=visual, process=False)
|
||||
mesh.export(out_glb_path)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"glb_path": out_glb_path,
|
||||
"vertices": int(vertices.shape[0]),
|
||||
"faces": int(faces.shape[0]),
|
||||
"height": int(H),
|
||||
"width": int(W),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo runner end-to-end para `fn run depth_to_relief_glb_py_datascience <image_path> <out.glb>`.
|
||||
# Encadena estimate_image_depth (misma carpeta) para producir un .glb desde una imagen sin
|
||||
# tener que pasar el ndarray por CLI. La función en sí toma (image, depth); esto es solo glue
|
||||
# de demostración del flujo img→glb del grupo `img-to-3d`.
|
||||
import json
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print(json.dumps({"status": "error", "error": "uso: <image_path> <out_glb_path> [z_scale] [max_dim]"}))
|
||||
sys.exit(1)
|
||||
|
||||
from estimate_image_depth import estimate_image_depth
|
||||
|
||||
img_path = sys.argv[1]
|
||||
out_path = sys.argv[2]
|
||||
zs = float(sys.argv[3]) if len(sys.argv) > 3 else 0.35
|
||||
md = int(sys.argv[4]) if len(sys.argv) > 4 else 220
|
||||
|
||||
est = estimate_image_depth(img_path)
|
||||
if est["status"] != "ok":
|
||||
print(json.dumps(est))
|
||||
sys.exit(1)
|
||||
|
||||
res = depth_to_relief_glb(est["image"], est["depth"], out_path, z_scale=zs, max_dim=md)
|
||||
print(json.dumps(res))
|
||||
if res["status"] != "ok":
|
||||
sys.exit(1)
|
||||
@@ -18,7 +18,7 @@ LLM, parseo) devuelve {status:'error', error:str}.
|
||||
|
||||
import json
|
||||
|
||||
from core import ask_llm
|
||||
from core.ask_llm import ask_llm
|
||||
|
||||
# Claves que el LLM debe devolver. Las que falten se rellenan con estos defaults.
|
||||
_EXPECTED_KEYS = {
|
||||
|
||||
@@ -135,7 +135,9 @@ def test_eda_llm_insights_ok_with_monkeypatched_llm(monkeypatch):
|
||||
"analyses": ["ventas por categoria"],
|
||||
}
|
||||
|
||||
import datascience.eda_llm_insights as mod
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("datascience.eda_llm_insights")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: json.dumps(fake)
|
||||
@@ -158,7 +160,9 @@ def test_eda_llm_insights_ok_with_monkeypatched_llm(monkeypatch):
|
||||
|
||||
def test_eda_llm_insights_fills_missing_keys(monkeypatch):
|
||||
"""Si el LLM omite claves, se rellenan con defaults vacios."""
|
||||
import datascience.eda_llm_insights as mod
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("datascience.eda_llm_insights")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
@@ -184,7 +188,9 @@ def test_eda_llm_insights_error_on_empty_profile():
|
||||
|
||||
|
||||
def test_eda_llm_insights_error_on_empty_llm_response(monkeypatch):
|
||||
import datascience.eda_llm_insights as mod
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("datascience.eda_llm_insights")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: ""
|
||||
@@ -194,7 +200,9 @@ def test_eda_llm_insights_error_on_empty_llm_response(monkeypatch):
|
||||
|
||||
|
||||
def test_eda_llm_insights_error_on_unparseable_llm_response(monkeypatch):
|
||||
import datascience.eda_llm_insights as mod
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("datascience.eda_llm_insights")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: "sin json"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: estimate_image_depth
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def estimate_image_depth(image_path: str, model_name: str = 'depth-anything/Depth-Anything-V2-Small-hf', device: str = 'auto', use_cache: bool = True) -> dict"
|
||||
description: "Estima un mapa de profundidad monocular a partir de una sola imagen con Depth-Anything-V2 (transformers, GPU si hay). Devuelve el depth normalizado a [0,1] (1=mas cerca) y la PIL.Image original. Paso 1 del flujo img->3D (grupo img-to-3d): su salida alimenta depth_to_relief_glb."
|
||||
tags: [img-to-3d, datascience, depth, depth-estimation, monocular, transformers, depth-anything, gpu, ml, computer-vision]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: image_path
|
||||
desc: "Ruta a la imagen de entrada. Cualquier formato que PIL.Image.open abra (jpg, png, webp...). Si no existe o no es imagen valida, se devuelve status error."
|
||||
- name: model_name
|
||||
desc: "Id de modelo HuggingFace de depth-estimation. Default 'depth-anything/Depth-Anything-V2-Small-hf' (rapido). Variantes: ...-Base-hf, ...-Large-hf (mas precision, mas VRAM)."
|
||||
- name: device
|
||||
desc: "'auto' (GPU0 si torch.cuda.is_available() else CPU), 'cpu', o indice/cadena cuda explicita ('cuda:0', '0'). Forma 'cuda:N' no parseable cae a GPU0; un indice entero inexistente ('99') falla en inferencia y vuelve como status error."
|
||||
- name: use_cache
|
||||
desc: "True (default) reutiliza el pipeline cacheado por (model_name, device) a nivel de proceso (evita recargar pesos en cada llamada). False construye uno nuevo y no toca la cache."
|
||||
output: "dict. Exito: {status:'ok', depth: ndarray HxW float32 en [0,1] (1=mas cerca de la camara), image: PIL.Image RGB original, height:int, width:int, model:str, device:str}. Error: {status:'error', error:str} (no lanza). El demo CLI (__main__) imprime un resumen JSON sin el ndarray ni la imagen."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/datascience/estimate_image_depth.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Requiere un venv con torch + transformers + pillow (p.ej. el de apps/img_to_3d_webapp/backend/.venv).
|
||||
# Import PLANO al modulo: el paquete datascience.__init__ arrastra deps de otros dominios
|
||||
# (bs4, duckdb...) que no estan en ese venv. Ver Gotchas.
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/datascience")
|
||||
from estimate_image_depth import estimate_image_depth
|
||||
|
||||
res = estimate_image_depth("apps/img_to_3d_webapp/samples/cats.jpg") # device='auto' -> GPU si hay
|
||||
print(res["status"]) # ok
|
||||
print(res["height"], res["width"]) # p.ej. 1024 768
|
||||
print(res["depth"].min(), res["depth"].max()) # 0.0 1.0 (normalizado)
|
||||
# res["depth"] (ndarray) + res["image"] (PIL) alimentan depth_to_relief_glb.
|
||||
```
|
||||
|
||||
Lanzable como demo (imprime resumen JSON, sin serializar el ndarray):
|
||||
|
||||
```bash
|
||||
./fn run estimate_image_depth_py_datascience apps/img_to_3d_webapp/samples/cats.jpg
|
||||
# {"status": "ok", "height": ..., "width": ..., "depth_min": 0.0, "depth_max": 1.0, ...}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un mapa de profundidad de una imagen 2D y NO tengas sensor de profundidad:
|
||||
reconstruccion de relieve 3D (paso 1 de img->glb), efectos de paralaje, segmentacion por capas,
|
||||
ordenacion de objetos por cercania. Es el primer paso del grupo `img-to-3d`: su `depth` + `image`
|
||||
se pasan directamente a `depth_to_relief_glb_py_datascience` para generar el .glb. Para una sola
|
||||
imagen monocular; no hace SLAM, multi-vista ni metrica absoluta (la profundidad es relativa).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: carga un modelo HuggingFace (la primera vez DESCARGA pesos a `~/.cache/huggingface/`,
|
||||
cientos de MB segun la variante) y corre inferencia en GPU/CPU. Requiere red en la primera carga.
|
||||
- **Estado de proceso**: `_PIPE_CACHE` cachea el pipeline por (model_name, device) a nivel de
|
||||
modulo para no recargar pesos en cada llamada. Es estado mutable compartido del proceso. Pasa
|
||||
`use_cache=False` para construir uno aislado (no lo cachea ni lo lee). La cache persiste mientras
|
||||
viva el interprete; en un servicio de larga duracion ocupa VRAM hasta que el proceso muere.
|
||||
- **Deps pesadas**: requiere `torch`, `transformers` y `pillow` instalados. No estan en el venv del
|
||||
registry; viven en el venv de la app `img_to_3d_webapp` (torch 2.5.1+cu124). `torch`/`transformers`
|
||||
se importan dentro de la funcion, asi que el modulo se puede importar para introspeccion sin ellas.
|
||||
- **device**: 'auto' usa GPU0 si `torch.cuda.is_available()`. El resolver es tolerante con la
|
||||
forma `'cuda:N'`: si `N` no es parseable junto a 'cuda', cae a GPU0 (p.ej. `'cuda:99'` -> GPU0,
|
||||
NO error). En cambio un indice ENTERO inexistente (`'99'`) se pasa tal cual a transformers y
|
||||
falla en inferencia con `CUDA error: invalid device ordinal`, devuelto como `{status:'error'}`.
|
||||
- La profundidad es **relativa y normalizada a [0,1]**, no metrica. 1 = mas cerca de la camara
|
||||
(Depth-Anything devuelve disparidad). No comparable entre imagenes distintas.
|
||||
- Nunca lanza: errores (ruta invalida, modelo no disponible, OOM de GPU) vuelven como
|
||||
`{status:'error', error:str}`.
|
||||
- **Import plano**: importa el modulo directo (`sys.path` a `python/functions/datascience` +
|
||||
`from estimate_image_depth import estimate_image_depth`), NO `from datascience import ...`. El
|
||||
`datascience.__init__` carga todo el dominio (scrapers con bs4, duckdb...) que no esta en el venv
|
||||
de vision; el import del paquete fallaria por esas deps ajenas a esta funcion.
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Estimación de profundidad monocular a partir de una sola imagen con Depth-Anything-V2.
|
||||
|
||||
Función del registry (grupo de capacidad `img-to-3d`, dominio `datascience`). Promovida desde
|
||||
la app `img_to_3d_webapp` para que cualquier artefacto pueda estimar un mapa de profundidad sin
|
||||
reimplementar la carga del modelo HuggingFace ni la normalización del resultado.
|
||||
|
||||
Impura: descarga/carga pesos de un modelo de transformers, usa GPU si está disponible y mantiene
|
||||
una caché de pipelines a nivel de proceso para no recargar en cada llamada.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# El pipeline de transformers es caro de instanciar (carga de pesos). Se cachea por
|
||||
# (modelo, device) a nivel de módulo para que un servicio no recargue en cada request.
|
||||
# Es estado mutable de PROCESO: documentado como impureza (ver .md "Gotchas"). Se puede
|
||||
# desactivar por llamada con use_cache=False.
|
||||
_PIPE_CACHE: dict = {}
|
||||
|
||||
|
||||
def _resolve_device(device: str) -> int:
|
||||
"""Resuelve el índice de device para transformers.pipeline (0=GPU0, -1=CPU)."""
|
||||
import torch
|
||||
|
||||
if device == "cpu":
|
||||
return -1
|
||||
if device == "auto":
|
||||
return 0 if torch.cuda.is_available() else -1
|
||||
# device explícito tipo "cuda:0" o un índice
|
||||
try:
|
||||
return int(device)
|
||||
except ValueError:
|
||||
return 0 if device.startswith("cuda") else -1
|
||||
|
||||
|
||||
def _build_pipe(model_name: str, device: str):
|
||||
from transformers import pipeline
|
||||
|
||||
return pipeline("depth-estimation", model=model_name, device=_resolve_device(device))
|
||||
|
||||
|
||||
def _get_pipe(model_name: str, device: str, use_cache: bool):
|
||||
if not use_cache:
|
||||
return _build_pipe(model_name, device)
|
||||
key = (model_name, device)
|
||||
pipe = _PIPE_CACHE.get(key)
|
||||
if pipe is None:
|
||||
pipe = _build_pipe(model_name, device)
|
||||
_PIPE_CACHE[key] = pipe
|
||||
return pipe
|
||||
|
||||
|
||||
def estimate_image_depth(
|
||||
image_path: str,
|
||||
model_name: str = "depth-anything/Depth-Anything-V2-Small-hf",
|
||||
device: str = "auto",
|
||||
use_cache: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Estima un mapa de profundidad monocular a partir de una única imagen.
|
||||
|
||||
Parámetros:
|
||||
image_path: ruta a la imagen de entrada (cualquier formato que PIL abra).
|
||||
model_name: id de modelo HuggingFace de estimación de profundidad.
|
||||
device: "auto" (GPU si hay), "cpu", o índice/cadena cuda explícita ("cuda:0", "0").
|
||||
use_cache: si True (default) reutiliza el pipeline cacheado por (modelo, device) a
|
||||
nivel de proceso; si False construye uno nuevo y no toca la caché.
|
||||
|
||||
Devuelve (dict, nunca lanza):
|
||||
Éxito: {"status": "ok", "depth": ndarray HxW float32 normalizado a [0,1]
|
||||
(1 = más cerca de la cámara), "image": PIL.Image RGB original,
|
||||
"height": H, "width": W, "model": model_name, "device": device}.
|
||||
Error: {"status": "error", "error": str} (ruta inválida, modelo no disponible,
|
||||
device inválido, fallo de inferencia).
|
||||
"""
|
||||
try:
|
||||
image = Image.open(image_path).convert("RGB")
|
||||
pipe = _get_pipe(model_name, device, use_cache)
|
||||
result = pipe(image)
|
||||
depth = np.asarray(result["depth"], dtype=np.float32)
|
||||
|
||||
# Normalizar a [0,1]. Depth-Anything devuelve disparidad relativa (mayor = más cerca).
|
||||
d = depth - depth.min()
|
||||
peak = d.max()
|
||||
if peak > 0:
|
||||
d = d / peak
|
||||
|
||||
H, W = d.shape
|
||||
return {
|
||||
"status": "ok",
|
||||
"depth": d,
|
||||
"image": image,
|
||||
"height": int(H),
|
||||
"width": int(W),
|
||||
"model": model_name,
|
||||
"device": device,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo runner para `fn run estimate_image_depth_py_datascience <image_path> [model] [device]`.
|
||||
# Imprime un resumen JSON-serializable (el ndarray y la PIL.Image no se serializan).
|
||||
import json
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"status": "error", "error": "uso: <image_path> [model_name] [device]"}))
|
||||
sys.exit(1)
|
||||
|
||||
path = sys.argv[1]
|
||||
model = sys.argv[2] if len(sys.argv) > 2 else "depth-anything/Depth-Anything-V2-Small-hf"
|
||||
dev = sys.argv[3] if len(sys.argv) > 3 else "auto"
|
||||
|
||||
res = estimate_image_depth(path, model_name=model, device=dev)
|
||||
if res["status"] == "ok":
|
||||
depth = res["depth"]
|
||||
summary = {
|
||||
"status": "ok",
|
||||
"height": res["height"],
|
||||
"width": res["width"],
|
||||
"depth_min": float(depth.min()),
|
||||
"depth_max": float(depth.max()),
|
||||
"depth_mean": round(float(depth.mean()), 4),
|
||||
"model": res["model"],
|
||||
"device": res["device"],
|
||||
}
|
||||
print(json.dumps(summary))
|
||||
else:
|
||||
print(json.dumps(res))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: query_osint_db
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def query_osint_db(sql: str, base_url: str = 'http://127.0.0.1:8771', timeout: int = 30) -> dict"
|
||||
description: "Ejecuta un SELECT contra el service osint_db (DuckDB, FastAPI single-writer en 127.0.0.1:8771) por HTTP POST a /api/query y devuelve {status, columns, rows, row_count} sin lanzar. Normaliza service caido a un error claro."
|
||||
tags: [duckdb, osint, http, query, readonly]
|
||||
params:
|
||||
- name: sql
|
||||
desc: "Sentencia SQL a ejecutar. Pensada para SELECT read-only; el osint_db la corre con una conexion DuckDB en modo solo lectura, asi que una escritura falla a nivel de service."
|
||||
- name: base_url
|
||||
desc: "URL base del service osint_db. Default 'http://127.0.0.1:8771'. Se le anade '/api/query' al hacer el POST."
|
||||
- name: timeout
|
||||
desc: "Timeout por peticion en segundos (default 30). El osint_db es local (loopback): si tarda mas, mejor degradar que colgar al llamante."
|
||||
output: "dict. En exito reenvia el cuerpo del service: {status:'ok', columns:[str,...], rows:[{col:val,...},...], row_count:int, truncated:bool}. En error (sin lanzar): {status:'error', error:str}. Service inalcanzable -> error 'osint_db service not reachable on <url>: <detalle>'."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_query_ok_devuelve_cuerpo_del_service", "test_query_error_de_dominio_se_reenvia", "test_service_caido_devuelve_error_claro", "test_base_url_custom_se_respeta", "test_http_error_con_cuerpo_json_se_reenvia"]
|
||||
test_file_path: "python/functions/datascience/query_osint_db_test.py"
|
||||
file_path: "python/functions/datascience/query_osint_db.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from datascience.query_osint_db import query_osint_db
|
||||
|
||||
res = query_osint_db("SELECT COUNT(*) FROM personas")
|
||||
# {'status': 'ok', 'columns': ['count_star()'], 'rows': [{'count_star()': 545}],
|
||||
# 'row_count': 1, 'truncated': False}
|
||||
print(res["rows"])
|
||||
```
|
||||
|
||||
Lanzable directo:
|
||||
|
||||
```bash
|
||||
./fn run query_osint_db_py_datascience
|
||||
# o pasandole el SQL como arg:
|
||||
python/.venv/bin/python3 python/functions/datascience/query_osint_db.py "SELECT COUNT(*) FROM personas"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites leer la verdad OSINT que vive en el service osint_db (DuckDB, fuente
|
||||
de verdad del proyecto osint: personas, dominios, network_scans, etc.). Sustituye el
|
||||
patron inline repetido `curl -s -X POST 127.0.0.1:8771/api/query ...` por una sola
|
||||
llamada con retorno estructurado. Usala antes de escribir un heredoc/curl a mano
|
||||
contra ese endpoint.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El service `osint_db` debe estar arriba (escucha en `127.0.0.1:8771`). Si esta
|
||||
caido, la funcion NO lanza: devuelve `{status:'error', error:'osint_db service not
|
||||
reachable on <url>: ...'}`. Comprueba el status antes de leer `rows`.
|
||||
- `osint_db` es **single-writer**: el endpoint `/api/query` es estrictamente
|
||||
read-only (abre una conexion DuckDB read_only separada). Desde aqui usa **solo
|
||||
SELECT** — una escritura fallara a nivel de service.
|
||||
- El service responde **siempre HTTP 200**; el status real del dominio viaja en el
|
||||
cuerpo (`status: 'ok'|'error'`). La funcion reenvia ese cuerpo tal cual en exito.
|
||||
- El `max_rows` del service tiene tope (default 500, max 10000); para volcados
|
||||
grandes pagina o usa la maestra directamente.
|
||||
|
||||
## Notas
|
||||
|
||||
Solo stdlib (urllib, json): wrapper de transporte puro, sin dependencias de runtime.
|
||||
Sigue la convencion de retorno de `pg_query_py_infra` y `duckdb_query_readonly`
|
||||
(`{status:'ok'|'error', ...}`). El service osint_db vive en
|
||||
`projects/osint/apps/osint_db/` y su `/api/query` delega en `duckdb_query_readonly`.
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Ejecuta un SELECT contra el service osint_db (DuckDB, 127.0.0.1:8771) por HTTP.
|
||||
|
||||
Funcion impura: hace un POST a ``{base_url}/api/query`` con el cuerpo JSON
|
||||
``{"sql": sql}`` y devuelve el resultado sin lanzar excepciones, siguiendo el estilo
|
||||
de pg_query / duckdb_query_readonly del registry: {status:'ok', ...} en exito y
|
||||
{status:'error', error:str} en fallo.
|
||||
|
||||
El osint_db es un FastAPI single-writer sobre DuckDB que es la fuente de verdad del
|
||||
proyecto osint. Su endpoint /api/query es estrictamente read-only (abre una conexion
|
||||
DuckDB read_only separada) y responde SIEMPRE con HTTP 200; el status real del
|
||||
dominio viaja en el cuerpo ({status, columns, rows, row_count, truncated} en exito,
|
||||
o {status:'error', error}). Esta funcion reenvia ese cuerpo tal cual cuando es ok y
|
||||
normaliza los errores de red (service caido, timeout, conexion rechazada) a un
|
||||
{status:'error', ...} con un mensaje claro, para no tumbar al llamante.
|
||||
|
||||
Solo stdlib (urllib, json): el wrapper es transporte puro, no reimplementa la logica
|
||||
del osint_db ni anade dependencias de runtime.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def query_osint_db(
|
||||
sql: str,
|
||||
base_url: str = "http://127.0.0.1:8771",
|
||||
timeout: int = 30,
|
||||
) -> dict:
|
||||
"""Ejecuta un SELECT contra el service osint_db por HTTP y devuelve un dict.
|
||||
|
||||
Args:
|
||||
sql: sentencia SQL a ejecutar. Pensada para SELECT read-only; el osint_db
|
||||
la corre con una conexion DuckDB en modo solo lectura, asi que una
|
||||
escritura fallara a nivel de service.
|
||||
base_url: URL base del service osint_db (default
|
||||
"http://127.0.0.1:8771"). Se le anade "/api/query" al hacer el POST.
|
||||
timeout: timeout por peticion en segundos (default 30). El osint_db es
|
||||
local (loopback): si tarda mas, mejor degradar que colgar al llamante.
|
||||
|
||||
Returns:
|
||||
dict. En exito reenvia el cuerpo del service:
|
||||
{status:'ok', columns:[str,...], rows:[{col:val, ...}, ...], row_count:int,
|
||||
truncated:bool}. En error (sin lanzar): {status:'error', error:str}. Si el
|
||||
service no es alcanzable (no arrancado, conexion rechazada, host caido) el
|
||||
error es "osint_db service not reachable on <url>: <detalle>".
|
||||
"""
|
||||
url = base_url.rstrip("/") + "/api/query"
|
||||
data = json.dumps({"sql": sql}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
# El contrato del osint_db es 200 siempre; un HTTPError es anomalo. Intenta
|
||||
# leer el cuerpo (puede traer {status:error,...}); si no, error claro.
|
||||
try:
|
||||
body = json.loads(exc.read().decode("utf-8"))
|
||||
if isinstance(body, dict):
|
||||
return body
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"osint_db returned HTTP {exc.code} on {url}",
|
||||
}
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"osint_db service not reachable on {url}: {exc}",
|
||||
}
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"osint_db returned non-JSON response on {url}: {exc}",
|
||||
}
|
||||
if not isinstance(parsed, dict):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"osint_db returned unexpected JSON type on {url}",
|
||||
}
|
||||
return parsed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
query = sys.argv[1] if len(sys.argv) > 1 else "SELECT COUNT(*) FROM personas"
|
||||
print(json.dumps(query_osint_db(query), ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests para query_osint_db.
|
||||
|
||||
Mockean urllib.request.urlopen para no depender del service osint_db vivo:
|
||||
se interceptan el exito (cuerpo {status:ok,...}), el error de dominio del service
|
||||
({status:error,...}) y el error de red (conexion rechazada).
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
from query_osint_db import query_osint_db
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Context manager que imita la respuesta de urllib.request.urlopen."""
|
||||
|
||||
def __init__(self, payload: dict):
|
||||
self._raw = json.dumps(payload).encode("utf-8")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return self._raw
|
||||
|
||||
|
||||
def test_query_ok_devuelve_cuerpo_del_service(monkeypatch):
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"columns": ["count_star()"],
|
||||
"rows": [{"count_star()": 42}],
|
||||
"row_count": 1,
|
||||
"truncated": False,
|
||||
}
|
||||
captured = {}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["url"] = req.full_url
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
captured["method"] = req.get_method()
|
||||
return _FakeResponse(payload)
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
|
||||
result = query_osint_db("SELECT COUNT(*) FROM personas")
|
||||
|
||||
assert result == payload
|
||||
assert result["status"] == "ok"
|
||||
assert result["rows"] == [{"count_star()": 42}]
|
||||
assert captured["url"] == "http://127.0.0.1:8771/api/query"
|
||||
assert captured["body"] == {"sql": "SELECT COUNT(*) FROM personas"}
|
||||
assert captured["method"] == "POST"
|
||||
|
||||
|
||||
def test_query_error_de_dominio_se_reenvia(monkeypatch):
|
||||
payload = {"status": "error", "error": "Catalog Error: Table no existe"}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
return _FakeResponse(payload)
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
|
||||
result = query_osint_db("SELECT * FROM tabla_inexistente")
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "no existe" in result["error"]
|
||||
|
||||
|
||||
def test_service_caido_devuelve_error_claro(monkeypatch):
|
||||
def fake_urlopen(req, timeout=None):
|
||||
raise urllib.error.URLError("Connection refused")
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
|
||||
result = query_osint_db("SELECT 1")
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "osint_db service not reachable" in result["error"]
|
||||
assert "http://127.0.0.1:8771/api/query" in result["error"]
|
||||
|
||||
|
||||
def test_base_url_custom_se_respeta(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["url"] = req.full_url
|
||||
return _FakeResponse({"status": "ok", "rows": [], "row_count": 0})
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
|
||||
query_osint_db("SELECT 1", base_url="http://10.0.0.5:9000/")
|
||||
|
||||
assert captured["url"] == "http://10.0.0.5:9000/api/query"
|
||||
|
||||
|
||||
def test_http_error_con_cuerpo_json_se_reenvia(monkeypatch):
|
||||
body = json.dumps({"status": "error", "error": "boom"}).encode("utf-8")
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
raise urllib.error.HTTPError(
|
||||
url=req.full_url,
|
||||
code=500,
|
||||
msg="Internal Server Error",
|
||||
hdrs=None,
|
||||
fp=io.BytesIO(body),
|
||||
)
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
|
||||
result = query_osint_db("SELECT 1")
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "boom"
|
||||
@@ -15,40 +15,73 @@ from datascience import (
|
||||
)
|
||||
|
||||
|
||||
def _to_numeric_subset(columns: dict) -> dict:
|
||||
"""Extrae las columnas numericas como {nombre: [float values]}.
|
||||
def _pf(v):
|
||||
"""Parsea un valor a float; devuelve None si es None/bool/no parseable."""
|
||||
if v is None or isinstance(v, bool):
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
Solo se quedan las columnas con ``type == "numeric"``. Para cada una, los
|
||||
valores se convierten a float cuando es posible y los que son None o no
|
||||
parseables se descartan (la lista resultante puede ser mas corta que la
|
||||
original). Mantiene el orden de aparicion de las columnas.
|
||||
|
||||
def _to_numeric_subset(columns: dict) -> dict:
|
||||
"""Extrae las columnas numericas alineadas por fila (listwise deletion).
|
||||
|
||||
Solo se quedan las columnas con ``type == "numeric"``. CLAVE: la alineacion
|
||||
por fila se preserva. Pasos:
|
||||
1. Descarta columnas numericas con menos del 50% de valores parseables
|
||||
(evita que una columna casi-toda-nula tire todas las filas en el paso 3).
|
||||
2. Sobre las columnas buenas, conserva SOLO las filas en las que TODAS
|
||||
tienen un valor numerico (listwise deletion).
|
||||
El resultado es un mapa {nombre: [float, ...]} donde todas las listas tienen
|
||||
la MISMA longitud (filas completas) — requisito de PCA/KMeans/IsolationForest
|
||||
(matriz rectangular sin NaN). El bug previo descartaba None por columna,
|
||||
dejando longitudes desiguales y reventando sklearn con ValueError.
|
||||
|
||||
Args:
|
||||
columns: mapa {nombre_columna: {"values": list, "type": str}}.
|
||||
columns: mapa {nombre_columna: {"values": list, "type": str}}; las listas
|
||||
llegan alineadas por fila (misma longitud, None donde no hay dato).
|
||||
|
||||
Returns:
|
||||
dict {nombre_columna: [float, ...]} solo con columnas numericas.
|
||||
dict {nombre_columna: [float, ...]} con columnas numericas de igual
|
||||
longitud. Vacio si no hay columnas numericas validas.
|
||||
"""
|
||||
numeric: dict[str, list] = {}
|
||||
if not isinstance(columns, dict):
|
||||
return numeric
|
||||
return {}
|
||||
raw: dict[str, list] = {}
|
||||
for name, meta in columns.items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
if meta.get("type") != "numeric":
|
||||
continue
|
||||
values = meta.get("values")
|
||||
if not isinstance(values, (list, tuple)):
|
||||
continue
|
||||
parsed: list[float] = []
|
||||
for v in values:
|
||||
if v is None or isinstance(v, bool):
|
||||
continue
|
||||
try:
|
||||
parsed.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
numeric[name] = parsed
|
||||
if isinstance(values, (list, tuple)):
|
||||
raw[name] = list(values)
|
||||
if not raw:
|
||||
return {}
|
||||
|
||||
# Longitud comun (min, defensivo si llegaran desalineadas).
|
||||
n = min(len(v) for v in raw.values())
|
||||
if n == 0:
|
||||
return {}
|
||||
|
||||
# 1) Parsea por celda y descarta columnas con <50% de valores parseables.
|
||||
good: dict[str, list] = {}
|
||||
for name, values in raw.items():
|
||||
parsed = [_pf(values[i]) for i in range(n)]
|
||||
if sum(1 for x in parsed if x is not None) >= 0.5 * n:
|
||||
good[name] = parsed
|
||||
if not good:
|
||||
return {}
|
||||
|
||||
# 2) Listwise: conserva solo filas donde TODAS las columnas tienen valor.
|
||||
names = list(good.keys())
|
||||
numeric: dict[str, list] = {name: [] for name in names}
|
||||
for i in range(n):
|
||||
if all(good[name][i] is not None for name in names):
|
||||
for name in names:
|
||||
numeric[name].append(good[name][i])
|
||||
return numeric
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: summarize_table_pg
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def summarize_table_pg(dsn: str, table: str, schema: str = \"public\", high_card_ratio: float = 0.9) -> dict"
|
||||
description: "Adaptador PostgreSQL del perfilado base del grupo eda: espejo de summarize_table_duckdb. Perfila una tabla PostgreSQL con SQL push-down (count, count(DISTINCT), min/max/avg/stddev_samp, percentile_cont) sin traer filas a RAM, y devuelve EXACTAMENTE el mismo esqueleto TableProfile (mismas claves) para que el resto del grupo eda lo consuma igual con fuente PostgreSQL. dict-no-throw."
|
||||
tags: [eda, postgres, postgresql, profiling, datascience, exploratory-data-analysis, table-profile]
|
||||
params:
|
||||
- name: dsn
|
||||
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname. Un DSN invalido o servidor inalcanzable devuelve {status:'error'} sin lanzar (se propaga el error de pg_query)."
|
||||
- name: table
|
||||
desc: "Nombre de la tabla a perfilar. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el SQL (los identificadores no son parametrizables en el cuerpo del SELECT)."
|
||||
- name: schema
|
||||
desc: "Schema PostgreSQL donde vive la tabla (default 'public'). Se valida con el mismo patron y se cita."
|
||||
- name: high_card_ratio
|
||||
desc: "Umbral de unicidad (unique_pct, 0-1) a partir del cual una columna categorical recibe el flag high_cardinality. Default 0.9."
|
||||
output: "dict dict-no-throw. En exito {status:'ok', profile: TableProfile} con source='postgres' y el MISMO shape que summarize_table_duckdb (n_rows/n_cols, type_breakdown, constant_cols, all_null_cols, null_cell_pct y columns[] de ColumnProfile con name/physical_type/inferred_type/semantic_type/count/null_count/null_pct/distinct_count/unique_pct/flags y sub-dict numeric con min,max,mean,std,p25,p50,p75 y el resto en None). En error {status:'error', error:str}. Claves estadisticas finas (skew, kurtosis, histograma, percentiles finos, moda, outliers, correlaciones, key_candidates, quality_score) quedan en None/[] para que otras funciones del grupo eda las completen."
|
||||
uses_functions: [pg_query_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_shape_y_metadatos_tabla", "test_column_profile_shape", "test_null_pct_total", "test_distinct_no_excede_filas", "test_type_breakdown", "test_tabla_invalida_devuelve_error", "test_schema_invalido_devuelve_error", "test_tabla_inexistente_devuelve_error", "test_error_de_lectura_pg_se_propaga"]
|
||||
test_file_path: "python/functions/datascience/summarize_table_pg_test.py"
|
||||
file_path: "python/functions/datascience/summarize_table_pg.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from datascience import summarize_table_pg
|
||||
|
||||
# Perfila la tabla `trends` del PostgreSQL del proyecto captacion_clientes
|
||||
# (la misma base que alimenta Metabase).
|
||||
res = summarize_table_pg(
|
||||
dsn="postgresql://captacion:secret@localhost:5433/trends",
|
||||
table="amazon_bestsellers",
|
||||
schema="public",
|
||||
high_card_ratio=0.9,
|
||||
)
|
||||
|
||||
if res["status"] == "ok":
|
||||
p = res["profile"]
|
||||
print(f"{p['table']}: {p['n_rows']} filas x {p['n_cols']} cols (source={p['source']})")
|
||||
print("type_breakdown:", p["type_breakdown"])
|
||||
for col in p["columns"]:
|
||||
print(col["name"], col["inferred_type"], "nulls=", col["null_pct"], col["flags"])
|
||||
else:
|
||||
print("error:", res["error"])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando hagas EDA de una tabla PostgreSQL que no conoces y necesites el esqueleto barato de su perfil (tipos inferidos, nulos, cardinalidad, flags) **antes** de gastar en estadistica fina. Tipico: las bases PostgreSQL conectadas a Metabase (trends, captacion_clientes, etc.).
|
||||
- Como adaptador PostgreSQL del grupo `eda`: produce el mismo TableProfile que `summarize_table_duckdb`, de modo que `profile_table` y el resto del grupo funcionan igual cambiando solo la fuente.
|
||||
- Cuando quieras perfilar tablas grandes sin traer filas a RAM: todo se calcula con agregados (count, count(DISTINCT), min/max/avg/stddev_samp, percentile_cont) que hacen push-down en el motor de PostgreSQL.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee de un servidor PostgreSQL via `pg_query` (transaccion read-only, nunca escribe). Requiere `psycopg2` (ya en `python/.venv`) y un DSN valido; un servidor inalcanzable devuelve `{status:'error'}` sin lanzar.
|
||||
- **`distinct_count` exacto solo hasta 200000 filas**: para `n_rows <= 200000` se calcula `count(DISTINCT col)` EXACTO en la query agregada por columna. Por encima de ese umbral NO se estima (PostgreSQL no trae HyperLogLog de serie sin extension) y `distinct_count` se capa de forma conservadora a `min(count_no_nulo, n_rows)`. En ambos casos `unique_pct = min(distinct_count / n_rows, 1.0)`, asi que nunca excede 1.0. Por encima de 200k filas los flags `possible_id` / `high_cardinality` derivan de esa cota conservadora, no de un distinct real.
|
||||
- **El shape es identico a `summarize_table_duckdb`** (mismas claves de TableProfile y ColumnProfile, mismo sub-dict `numeric`) para que `profile_table` y el grupo `eda` lo consuman sin distinguir la fuente. `source` es `"postgres"` (vs `"duckdb"`). NO calcula skew, kurtosis, histograma, percentiles finos (p1/p5/p95/p99), moda, outliers, correlaciones, key_candidates ni quality_score: esas claves quedan en `None`/`[]` para otras funciones del grupo. El sub-dict `numeric` solo trae min, max, mean, std, p25, p50, p75 (estos tres ultimos via `percentile_cont WITHIN GROUP`).
|
||||
- **Identificadores (schema/tabla/columna) se interpolan citados, no son parametrizables**: por eso `table` y `schema` se validan contra `^[A-Za-z_][A-Za-z0-9_]*$` antes de citarlos con comillas dobles. Un nombre invalido (con `;`, espacios, etc.) devuelve `{status:'error'}` sin tocar la base. Los **valores** (schema/table de la query a `information_schema`) si van por parametros posicionales `%s`.
|
||||
- **`count` del ColumnProfile es el no-nulo** (`count(col)`); `null_count = n_rows - count`. Una tabla con 0 filas devuelve perfiles con `null_pct=0.0` y `distinct_count=0`.
|
||||
|
||||
## Notas
|
||||
|
||||
Contrato compartido por todo el grupo `eda` (identico a `summarize_table_duckdb`,
|
||||
mantener estable):
|
||||
|
||||
```text
|
||||
TableProfile = {
|
||||
table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows,
|
||||
duplicate_pct, constant_cols:[str], all_null_cols:[str], null_cell_pct,
|
||||
type_breakdown:{numeric, categorical, datetime, text, boolean},
|
||||
columns:[ColumnProfile], correlations, key_candidates:[str], quality_score,
|
||||
llm, models
|
||||
}
|
||||
ColumnProfile = {
|
||||
name, physical_type, inferred_type, semantic_type, count, n_rows, null_count,
|
||||
null_pct, empty_count, empty_pct, distinct_count, unique_pct, flags:[str],
|
||||
quality_score, numeric:<sub>|None, categorical:<sub>|None, datetime:<sub>|None
|
||||
}
|
||||
numeric_sub = {
|
||||
min, max, mean, median, mode, std, variance, cv, p1, p5, p25, p50, p75, p95,
|
||||
p99, iqr, skew, kurtosis, n_outliers, outlier_pct, zero_pct, negative_pct,
|
||||
distribution_type, histogram
|
||||
}
|
||||
```
|
||||
|
||||
Mapeo de `data_type` (information_schema) PostgreSQL a `inferred_type`:
|
||||
smallint/integer/bigint/numeric/decimal/real/double precision/serial* -> numeric;
|
||||
date/time*/timestamp* -> datetime; boolean -> boolean; text/varchar/character* ->
|
||||
categorical si `distinct_count <= 50` o `distinct_count/n_rows < 0.5`, si no text;
|
||||
el resto (json, jsonb, uuid, array, bytea, ...) -> text.
|
||||
|
||||
Flags por columna: `constant` (distinct_count<=1), `possible_id` (unique_pct>=0.99
|
||||
y null_pct==0), `high_cardinality` (categorical con unique_pct>=high_card_ratio),
|
||||
`mostly_null` (null_pct>0.5).
|
||||
@@ -0,0 +1,377 @@
|
||||
"""summarize_table_pg — perfil base de una tabla PostgreSQL con SQL push-down.
|
||||
|
||||
Funcion impura: lee de un servidor PostgreSQL a traves de la primitiva read-only
|
||||
del grupo `postgres`, `pg_query`. Es el adaptador PostgreSQL del corazon del grupo
|
||||
de capacidad `eda` (exploratory data analysis), espejo de `summarize_table_duckdb`:
|
||||
construye EXACTAMENTE el mismo esqueleto de TableProfile (mismas claves) usando
|
||||
queries agregadas que hacen push-down en el motor de PostgreSQL y NO traen filas a
|
||||
RAM (count, count(DISTINCT), min/max/avg/stddev, percentile_cont).
|
||||
|
||||
Lo que NO calcula aqui (a proposito, para ser barata): skew, kurtosis, histograma,
|
||||
percentiles finos (p1/p5/p95/p99), moda, outliers, correlaciones, key_candidates,
|
||||
quality_score ni el semantic_type. Esas claves quedan en None / [] para que las
|
||||
rellenen luego otras funciones del grupo `eda` sobre una muestra. El contrato de
|
||||
claves (TableProfile / ColumnProfile) es compartido por todo el grupo `eda` y es
|
||||
identico al de `summarize_table_duckdb`, de modo que `profile_table` y el resto del
|
||||
grupo consumen el resultado igual con fuente PostgreSQL.
|
||||
|
||||
Estilo dict-no-throw del grupo: nunca lanza; captura cualquier error y devuelve
|
||||
{status:'error', error:str}.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from infra import pg_query
|
||||
|
||||
# Identificador SQL valido. PostgreSQL no admite parametros posicionales para el
|
||||
# nombre de tabla/columna en el cuerpo del SELECT, asi que hay que validar e
|
||||
# interpolar citado con comillas dobles.
|
||||
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
# Umbral de filas por debajo del cual calculamos COUNT(DISTINCT) EXACTO. Por
|
||||
# encima cap el distinct a n_rows (no estimamos con HLL: PostgreSQL no lo da de
|
||||
# serie sin extension). Documentado en el .md.
|
||||
_EXACT_DISTINCT_MAX_ROWS = 200_000
|
||||
|
||||
# Tipos PostgreSQL (data_type de information_schema) que mapean a "numeric".
|
||||
_NUMERIC_TYPES = {
|
||||
"smallint", "integer", "bigint",
|
||||
"decimal", "numeric", "real", "double precision",
|
||||
"smallserial", "serial", "bigserial",
|
||||
}
|
||||
# Tipos PostgreSQL que mapean a "datetime".
|
||||
_DATETIME_TYPES = {
|
||||
"date", "time", "timestamp",
|
||||
"timestamp without time zone", "timestamp with time zone",
|
||||
"time without time zone", "time with time zone",
|
||||
}
|
||||
# Tipos PostgreSQL textuales (candidatos a categorical/text).
|
||||
_TEXT_TYPES = {
|
||||
"text", "character varying", "varchar", "character", "char", "bpchar",
|
||||
}
|
||||
|
||||
# Claves del sub-dict numeric. summarize solo rellena unas pocas; el resto
|
||||
# quedan en None hasta que una funcion de muestreo las complete.
|
||||
_NUMERIC_SUB_KEYS = (
|
||||
"min", "max", "mean", "median", "mode", "std", "variance", "cv",
|
||||
"p1", "p5", "p25", "p50", "p75", "p95", "p99", "iqr",
|
||||
"skew", "kurtosis", "n_outliers", "outlier_pct", "zero_pct",
|
||||
"negative_pct", "distribution_type", "histogram",
|
||||
)
|
||||
|
||||
|
||||
def _base_data_type(data_type: str) -> str:
|
||||
"""Normaliza un data_type de information_schema a su forma base en minusculas.
|
||||
|
||||
information_schema.columns.data_type ya viene sin parametros (p.ej. "numeric"
|
||||
en vez de "numeric(10,2)" y "character varying" en vez de "varchar(50)"), pero
|
||||
normalizamos a minusculas y quitamos espacios laterales por seguridad.
|
||||
"""
|
||||
return (data_type or "").strip().lower()
|
||||
|
||||
|
||||
def _infer_type(data_type: str, distinct_count, n_rows: int) -> str:
|
||||
"""Mapea el data_type PostgreSQL al inferred_type del contrato eda.
|
||||
|
||||
numeric / datetime / boolean salen directos del tipo. Para los tipos textuales
|
||||
se decide entre categorical y text con la misma heuristica de cardinalidad que
|
||||
el adaptador DuckDB: categorical si distinct_count <= 50 o
|
||||
distinct_count/n_rows < 0.5; si no text.
|
||||
"""
|
||||
base = _base_data_type(data_type)
|
||||
if base in _NUMERIC_TYPES:
|
||||
return "numeric"
|
||||
if base in _DATETIME_TYPES:
|
||||
return "datetime"
|
||||
if base in ("boolean", "bool"):
|
||||
return "boolean"
|
||||
if base in _TEXT_TYPES:
|
||||
au = distinct_count if distinct_count is not None else 0
|
||||
if n_rows <= 0:
|
||||
return "categorical"
|
||||
if au <= 50 or (au / n_rows) < 0.5:
|
||||
return "categorical"
|
||||
return "text"
|
||||
# Tipos complejos (json, jsonb, uuid, array, bytea, ...): tratamos como text.
|
||||
return "text"
|
||||
|
||||
|
||||
def _to_float(value):
|
||||
"""Convierte a float un valor agregado de PostgreSQL (Decimal/str/None).
|
||||
|
||||
pg_query normaliza Decimal a float, pero min/max de columnas no numericas (o
|
||||
valores no convertibles) caen aqui y devolvemos None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _to_int(value):
|
||||
"""Convierte a int de forma defensiva (count(*), count(col) vienen como int)."""
|
||||
if value is None:
|
||||
return 0
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def summarize_table_pg(
|
||||
dsn: str,
|
||||
table: str,
|
||||
schema: str = "public",
|
||||
high_card_ratio: float = 0.9,
|
||||
) -> dict:
|
||||
"""Perfila una tabla PostgreSQL con SQL push-down (sin traer filas a RAM).
|
||||
|
||||
Devuelve el MISMO esqueleto TableProfile que summarize_table_duckdb (mismas
|
||||
claves exactas), para que el resto del grupo `eda` funcione igual con fuente
|
||||
PostgreSQL. dict-no-throw.
|
||||
|
||||
Args:
|
||||
dsn: cadena de conexion PostgreSQL, p.ej.
|
||||
"postgresql://user:pass@localhost:5432/mydb". Un DSN invalido o un
|
||||
servidor inalcanzable devuelve {status:'error', ...} (no lanza).
|
||||
table: nombre de la tabla a perfilar. Se valida contra
|
||||
^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el SQL (los identificadores no
|
||||
son parametrizables).
|
||||
schema: schema PostgreSQL donde vive la tabla (default "public"). Se valida
|
||||
con el mismo patron y se cita.
|
||||
high_card_ratio: umbral de unicidad (unique_pct) a partir del cual una
|
||||
columna categorical se marca con el flag "high_cardinality". Default 0.9.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', profile: <TableProfile>}. En error (sin
|
||||
lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
if not _IDENT_RE.match(table or ""):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"nombre de tabla invalido: {table!r} "
|
||||
"(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)"
|
||||
),
|
||||
}
|
||||
if not _IDENT_RE.match(schema or ""):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"nombre de schema invalido: {schema!r} "
|
||||
"(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)"
|
||||
),
|
||||
}
|
||||
|
||||
qtable = f'"{schema}"."{table}"'
|
||||
|
||||
# 1) Columnas + tipos desde information_schema (parametros posicionales).
|
||||
cols_res = pg_query(
|
||||
dsn,
|
||||
"SELECT column_name, data_type FROM information_schema.columns "
|
||||
"WHERE table_schema = %s AND table_name = %s "
|
||||
"ORDER BY ordinal_position",
|
||||
params=[schema, table],
|
||||
)
|
||||
if cols_res["status"] != "ok":
|
||||
return {"status": "error", "error": cols_res["error"]}
|
||||
col_rows = cols_res["rows"]
|
||||
if not col_rows:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"tabla no encontrada o sin columnas: {schema}.{table}"
|
||||
),
|
||||
}
|
||||
col_meta = [
|
||||
(r.get("column_name"), r.get("data_type")) for r in col_rows
|
||||
]
|
||||
|
||||
# 2) Numero total de filas.
|
||||
count_res = pg_query(dsn, f"SELECT count(*) AS n FROM {qtable}")
|
||||
if count_res["status"] != "ok":
|
||||
return {"status": "error", "error": count_res["error"]}
|
||||
n_rows = _to_int(count_res["rows"][0]["n"]) if count_res["rows"] else 0
|
||||
|
||||
# 3) Por columna: una query agregada con push-down en el motor. Combina
|
||||
# count no-nulo + count(DISTINCT) (exacto si n_rows <= umbral) +, para
|
||||
# columnas numericas, min/max/avg/stddev_samp/percentiles. No trae filas.
|
||||
exact_distinct_ok = (
|
||||
0 < n_rows <= _EXACT_DISTINCT_MAX_ROWS
|
||||
)
|
||||
columns = []
|
||||
for name, data_type in col_meta:
|
||||
if not _IDENT_RE.match(name or ""):
|
||||
# Columna con identificador no estandar: la perfilamos sin
|
||||
# agregados numericos (defensivo, no deberia pasar en information_schema).
|
||||
columns.append(
|
||||
_build_column_profile(
|
||||
name, data_type, n_rows, high_card_ratio,
|
||||
non_null=n_rows, distinct=None, agg=None,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
qcol = f'"{name}"'
|
||||
base_type = _base_data_type(data_type)
|
||||
is_numeric = base_type in _NUMERIC_TYPES
|
||||
|
||||
select_parts = [f"count({qcol}) AS non_null"]
|
||||
if exact_distinct_ok:
|
||||
select_parts.append(f"count(DISTINCT {qcol}) AS distinct_n")
|
||||
if is_numeric:
|
||||
select_parts.extend([
|
||||
f"min({qcol}) AS mn",
|
||||
f"max({qcol}) AS mx",
|
||||
f"avg({qcol}) AS av",
|
||||
f"stddev_samp({qcol}) AS sd",
|
||||
f"percentile_cont(0.25) WITHIN GROUP (ORDER BY {qcol}) AS p25",
|
||||
f"percentile_cont(0.5) WITHIN GROUP (ORDER BY {qcol}) AS p50",
|
||||
f"percentile_cont(0.75) WITHIN GROUP (ORDER BY {qcol}) AS p75",
|
||||
])
|
||||
|
||||
agg_sql = f"SELECT {', '.join(select_parts)} FROM {qtable}"
|
||||
agg_res = pg_query(dsn, agg_sql)
|
||||
if agg_res["status"] != "ok":
|
||||
return {"status": "error", "error": agg_res["error"]}
|
||||
agg = agg_res["rows"][0] if agg_res["rows"] else {}
|
||||
|
||||
non_null = _to_int(agg.get("non_null"))
|
||||
distinct = (
|
||||
_to_int(agg.get("distinct_n")) if exact_distinct_ok else None
|
||||
)
|
||||
|
||||
columns.append(
|
||||
_build_column_profile(
|
||||
name, data_type, n_rows, high_card_ratio,
|
||||
non_null=non_null, distinct=distinct,
|
||||
agg=agg if is_numeric else None,
|
||||
)
|
||||
)
|
||||
|
||||
type_breakdown = {
|
||||
"numeric": 0,
|
||||
"categorical": 0,
|
||||
"datetime": 0,
|
||||
"text": 0,
|
||||
"boolean": 0,
|
||||
}
|
||||
for col in columns:
|
||||
it = col["inferred_type"]
|
||||
if it in type_breakdown:
|
||||
type_breakdown[it] += 1
|
||||
|
||||
constant_cols = [c["name"] for c in columns if "constant" in c["flags"]]
|
||||
all_null_cols = [c["name"] for c in columns if c["null_pct"] == 1.0]
|
||||
null_cell_pct = (
|
||||
sum(c["null_pct"] for c in columns) / len(columns) if columns else 0.0
|
||||
)
|
||||
|
||||
profile = {
|
||||
"table": table,
|
||||
"source": "postgres",
|
||||
"profiled_at": datetime.now(timezone.utc).isoformat(),
|
||||
"n_rows": n_rows,
|
||||
"n_cols": len(columns),
|
||||
"size_bytes": None,
|
||||
"duplicate_rows": None,
|
||||
"duplicate_pct": None,
|
||||
"constant_cols": constant_cols,
|
||||
"all_null_cols": all_null_cols,
|
||||
"null_cell_pct": null_cell_pct,
|
||||
"type_breakdown": type_breakdown,
|
||||
"columns": columns,
|
||||
"correlations": None,
|
||||
"key_candidates": [],
|
||||
"quality_score": None,
|
||||
"llm": None,
|
||||
"models": None,
|
||||
}
|
||||
return {"status": "ok", "profile": profile}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def _build_column_profile(
|
||||
name: str,
|
||||
data_type: str,
|
||||
n_rows: int,
|
||||
high_card_ratio: float,
|
||||
non_null: int,
|
||||
distinct,
|
||||
agg: dict = None,
|
||||
) -> dict:
|
||||
"""Construye un ColumnProfile del contrato eda a partir de los agregados PG.
|
||||
|
||||
name/data_type: metadata de information_schema.
|
||||
non_null: count(col) no-nulo de la query agregada.
|
||||
distinct: count(DISTINCT col) exacto si n_rows <= umbral; None si por encima
|
||||
(entonces se capa a n_rows).
|
||||
agg: fila de agregados numericos (min/max/avg/stddev/p25/p50/p75) o None para
|
||||
columnas no numericas.
|
||||
|
||||
El shape devuelto es IDENTICO al de summarize_table_duckdb._build_column_profile.
|
||||
"""
|
||||
null_count = n_rows - non_null if n_rows > 0 else 0
|
||||
if null_count < 0:
|
||||
null_count = 0
|
||||
null_pct = (null_count / n_rows) if n_rows > 0 else 0.0
|
||||
|
||||
# distinct_count: exacto si disponible; si no, capado a n_rows.
|
||||
if distinct is not None:
|
||||
distinct_count = min(distinct, n_rows) if n_rows > 0 else distinct
|
||||
else:
|
||||
# Tabla grande (> umbral): no calculamos distinct exacto; lo capamos a
|
||||
# non_null como cota superior conservadora (a lo sumo tantos distintos
|
||||
# como valores no nulos), y a su vez a n_rows.
|
||||
distinct_count = min(non_null, n_rows) if n_rows > 0 else non_null
|
||||
|
||||
inferred_type = _infer_type(data_type, distinct_count, n_rows)
|
||||
|
||||
unique_pct = min(distinct_count / n_rows, 1.0) if n_rows > 0 else 0.0
|
||||
|
||||
numeric = None
|
||||
if inferred_type == "numeric":
|
||||
numeric = {k: None for k in _NUMERIC_SUB_KEYS}
|
||||
if agg is not None:
|
||||
numeric["min"] = _to_float(agg.get("mn"))
|
||||
numeric["max"] = _to_float(agg.get("mx"))
|
||||
numeric["mean"] = _to_float(agg.get("av"))
|
||||
numeric["std"] = _to_float(agg.get("sd"))
|
||||
numeric["p25"] = _to_float(agg.get("p25"))
|
||||
numeric["p50"] = _to_float(agg.get("p50"))
|
||||
numeric["p75"] = _to_float(agg.get("p75"))
|
||||
|
||||
flags = []
|
||||
if distinct_count <= 1:
|
||||
flags.append("constant")
|
||||
if unique_pct >= 0.99 and null_pct == 0:
|
||||
flags.append("possible_id")
|
||||
if inferred_type == "categorical" and unique_pct >= high_card_ratio:
|
||||
flags.append("high_cardinality")
|
||||
if null_pct > 0.5:
|
||||
flags.append("mostly_null")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"physical_type": data_type,
|
||||
"inferred_type": inferred_type,
|
||||
"semantic_type": "",
|
||||
"count": non_null,
|
||||
"n_rows": n_rows,
|
||||
"null_count": null_count,
|
||||
"null_pct": null_pct,
|
||||
"empty_count": None,
|
||||
"empty_pct": None,
|
||||
"distinct_count": distinct_count,
|
||||
"unique_pct": unique_pct,
|
||||
"flags": flags,
|
||||
"quality_score": None,
|
||||
"numeric": numeric,
|
||||
"categorical": None,
|
||||
"datetime": None,
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Tests para summarize_table_pg sin servidor PostgreSQL.
|
||||
|
||||
Monkeypatchea el primitivo de lectura PG (`pg_query`, importado en el modulo) para
|
||||
devolver filas simuladas: introspeccion de information_schema, count(*) y los
|
||||
agregados por columna. Asserta el shape del TableProfile/ColumnProfile (claves,
|
||||
tipos inferidos, flags, sub-dict numeric) — identico al de summarize_table_duckdb.
|
||||
No requiere PostgreSQL real.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from .summarize_table_pg import summarize_table_pg
|
||||
|
||||
# El objeto-modulo real donde vive la funcion (robusto frente al shadowing
|
||||
# nombre-modulo/funcion del __init__ y al doble-import de pytest): es el modulo
|
||||
# cuyo global `pg_query` usa summarize_table_pg, asi el monkeypatch surte efecto.
|
||||
mod = sys.modules[summarize_table_pg.__module__]
|
||||
|
||||
# Tabla simulada `ventas` (mismo esquema conceptual que el test de duckdb):
|
||||
# id INTEGER -> unico, sin nulls -> possible_id (numeric)
|
||||
# region TEXT -> categorica baja cardinalidad (3 distintos)
|
||||
# total NUMERIC -> numerica con un null (count no-nulo = 3)
|
||||
# pais TEXT -> constante ('ES')
|
||||
#
|
||||
# 4 filas. Valores de total no nulos: 120.5, 80.0, 45.25.
|
||||
|
||||
_N_ROWS = 4
|
||||
|
||||
_COLUMNS = [
|
||||
{"column_name": "id", "data_type": "integer"},
|
||||
{"column_name": "region", "data_type": "text"},
|
||||
{"column_name": "total", "data_type": "numeric"},
|
||||
{"column_name": "pais", "data_type": "character varying"},
|
||||
]
|
||||
|
||||
# Agregados precomputados por columna (lo que devolveria PostgreSQL).
|
||||
_AGG_BY_COL = {
|
||||
"id": {
|
||||
"non_null": 4, "distinct_n": 4,
|
||||
"mn": 1, "mx": 4, "av": 2.5, "sd": 1.2909944487358056,
|
||||
"p25": 1.75, "p50": 2.5, "p75": 3.25,
|
||||
},
|
||||
"region": {"non_null": 4, "distinct_n": 3},
|
||||
"total": {
|
||||
"non_null": 3, "distinct_n": 3,
|
||||
"mn": 45.25, "mx": 120.5, "av": 81.91666666666667,
|
||||
"sd": 37.70159, "p25": 62.625, "p50": 80.0, "p75": 100.25,
|
||||
},
|
||||
"pais": {"non_null": 4, "distinct_n": 1},
|
||||
}
|
||||
|
||||
|
||||
def _fake_pg_query(dsn, sql, params=None, max_rows=10000):
|
||||
"""Despacha por la forma del SQL para simular pg_query sin servidor."""
|
||||
sql_l = sql.lower()
|
||||
|
||||
# 1) Introspeccion de columnas.
|
||||
if "information_schema.columns" in sql_l:
|
||||
return {
|
||||
"status": "ok",
|
||||
"columns": ["column_name", "data_type"],
|
||||
"rows": list(_COLUMNS),
|
||||
"row_count": len(_COLUMNS),
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
# 2) count(*) total de filas.
|
||||
if "count(*) as n" in sql_l:
|
||||
return {
|
||||
"status": "ok",
|
||||
"columns": ["n"],
|
||||
"rows": [{"n": _N_ROWS}],
|
||||
"row_count": 1,
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
# 3) Agregados por columna: identificar la columna por su identificador citado.
|
||||
for col, agg in _AGG_BY_COL.items():
|
||||
if f'"{col}"' in sql:
|
||||
return {
|
||||
"status": "ok",
|
||||
"columns": list(agg.keys()),
|
||||
"rows": [dict(agg)],
|
||||
"row_count": 1,
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
raise AssertionError(f"SQL inesperado en fake pg_query: {sql}")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_pg_query(monkeypatch):
|
||||
"""Reemplaza el pg_query que el modulo importo por la version simulada."""
|
||||
monkeypatch.setattr(mod, "pg_query", _fake_pg_query)
|
||||
|
||||
|
||||
def test_shape_y_metadatos_tabla():
|
||||
res = summarize_table_pg("postgresql://x/y", "ventas")
|
||||
assert res["status"] == "ok"
|
||||
profile = res["profile"]
|
||||
|
||||
for key in (
|
||||
"table", "source", "profiled_at", "n_rows", "n_cols", "size_bytes",
|
||||
"duplicate_rows", "duplicate_pct", "constant_cols", "all_null_cols",
|
||||
"null_cell_pct", "type_breakdown", "columns", "correlations",
|
||||
"key_candidates", "quality_score", "llm", "models",
|
||||
):
|
||||
assert key in profile, f"falta clave {key} en TableProfile"
|
||||
|
||||
assert profile["table"] == "ventas"
|
||||
assert profile["source"] == "postgres"
|
||||
assert profile["n_rows"] == 4
|
||||
assert profile["n_cols"] == 4
|
||||
assert len(profile["columns"]) == 4
|
||||
assert profile["key_candidates"] == []
|
||||
assert profile["quality_score"] is None
|
||||
assert profile["correlations"] is None
|
||||
assert profile["models"] is None
|
||||
assert profile["llm"] is None
|
||||
|
||||
|
||||
def test_column_profile_shape():
|
||||
profile = summarize_table_pg("postgresql://x/y", "ventas")["profile"]
|
||||
by_name = {c["name"]: c for c in profile["columns"]}
|
||||
|
||||
for col in profile["columns"]:
|
||||
for key in (
|
||||
"name", "physical_type", "inferred_type", "semantic_type", "count",
|
||||
"n_rows", "null_count", "null_pct", "empty_count", "empty_pct",
|
||||
"distinct_count", "unique_pct", "flags", "quality_score",
|
||||
"numeric", "categorical", "datetime",
|
||||
):
|
||||
assert key in col, f"falta clave {key} en ColumnProfile {col['name']}"
|
||||
assert col["semantic_type"] == ""
|
||||
assert col["quality_score"] is None
|
||||
assert col["categorical"] is None
|
||||
assert col["datetime"] is None
|
||||
|
||||
# id: numerica, sin nulls, unica -> possible_id.
|
||||
idc = by_name["id"]
|
||||
assert idc["inferred_type"] == "numeric"
|
||||
assert idc["null_count"] == 0
|
||||
assert idc["count"] == 4
|
||||
assert idc["distinct_count"] == 4
|
||||
assert idc["unique_pct"] == 1.0
|
||||
assert "possible_id" in idc["flags"]
|
||||
|
||||
# region: categorica baja cardinalidad.
|
||||
region = by_name["region"]
|
||||
assert region["inferred_type"] == "categorical"
|
||||
assert region["distinct_count"] == 3
|
||||
assert region["numeric"] is None
|
||||
|
||||
# total: numerica con un null. count no-nulo = 3.
|
||||
total = by_name["total"]
|
||||
assert total["inferred_type"] == "numeric"
|
||||
assert total["null_count"] == 1
|
||||
assert total["count"] == 3
|
||||
assert total["numeric"] is not None
|
||||
assert total["numeric"]["min"] == pytest.approx(45.25)
|
||||
assert total["numeric"]["max"] == pytest.approx(120.5)
|
||||
assert total["numeric"]["mean"] is not None
|
||||
assert total["numeric"]["std"] is not None
|
||||
assert total["numeric"]["p25"] == pytest.approx(62.625)
|
||||
assert total["numeric"]["p50"] == pytest.approx(80.0)
|
||||
assert total["numeric"]["p75"] == pytest.approx(100.25)
|
||||
# claves finas siguen en None (las completa otra funcion del grupo eda).
|
||||
assert total["numeric"]["skew"] is None
|
||||
assert total["numeric"]["kurtosis"] is None
|
||||
assert total["numeric"]["histogram"] is None
|
||||
assert total["numeric"]["p99"] is None
|
||||
|
||||
# pais: constante -> flag constant + aparece en constant_cols.
|
||||
assert "constant" in by_name["pais"]["flags"]
|
||||
assert "pais" in profile["constant_cols"]
|
||||
|
||||
|
||||
def test_null_pct_total():
|
||||
profile = summarize_table_pg("postgresql://x/y", "ventas")["profile"]
|
||||
total = next(c for c in profile["columns"] if c["name"] == "total")
|
||||
# 1 null sobre 4 filas.
|
||||
assert total["null_pct"] == pytest.approx(0.25)
|
||||
|
||||
|
||||
def test_distinct_no_excede_filas():
|
||||
profile = summarize_table_pg("postgresql://x/y", "ventas")["profile"]
|
||||
n_rows = profile["n_rows"]
|
||||
for col in profile["columns"]:
|
||||
assert col["distinct_count"] <= n_rows
|
||||
assert col["unique_pct"] <= 1.0
|
||||
|
||||
|
||||
def test_type_breakdown():
|
||||
profile = summarize_table_pg("postgresql://x/y", "ventas")["profile"]
|
||||
tb = profile["type_breakdown"]
|
||||
assert set(tb.keys()) == {
|
||||
"numeric", "categorical", "datetime", "text", "boolean"
|
||||
}
|
||||
assert tb["numeric"] == 2 # id, total
|
||||
assert tb["categorical"] == 2 # region, pais
|
||||
assert tb["datetime"] == 0
|
||||
assert tb["boolean"] == 0
|
||||
assert tb["text"] == 0
|
||||
|
||||
|
||||
def test_tabla_invalida_devuelve_error():
|
||||
res = summarize_table_pg("postgresql://x/y", "ventas; DROP TABLE ventas")
|
||||
assert res["status"] == "error"
|
||||
assert "invalido" in res["error"]
|
||||
|
||||
|
||||
def test_schema_invalido_devuelve_error():
|
||||
res = summarize_table_pg("postgresql://x/y", "ventas", schema="pub lic")
|
||||
assert res["status"] == "error"
|
||||
assert "schema" in res["error"]
|
||||
|
||||
|
||||
def test_tabla_inexistente_devuelve_error(monkeypatch):
|
||||
"""information_schema sin filas -> error (tabla no encontrada)."""
|
||||
def empty_pg_query(dsn, sql, params=None, max_rows=10000):
|
||||
if "information_schema.columns" in sql.lower():
|
||||
return {
|
||||
"status": "ok", "columns": ["column_name", "data_type"],
|
||||
"rows": [], "row_count": 0, "truncated": False,
|
||||
}
|
||||
raise AssertionError("no deberia llegar aqui")
|
||||
|
||||
monkeypatch.setattr(mod, "pg_query", empty_pg_query)
|
||||
res = summarize_table_pg("postgresql://x/y", "no_existe")
|
||||
assert res["status"] == "error"
|
||||
assert "no encontrada" in res["error"]
|
||||
|
||||
|
||||
def test_error_de_lectura_pg_se_propaga(monkeypatch):
|
||||
"""Si pg_query devuelve error en el count, summarize lo propaga dict-no-throw."""
|
||||
def failing_count(dsn, sql, params=None, max_rows=10000):
|
||||
sql_l = sql.lower()
|
||||
if "information_schema.columns" in sql_l:
|
||||
return {
|
||||
"status": "ok", "columns": ["column_name", "data_type"],
|
||||
"rows": list(_COLUMNS), "row_count": len(_COLUMNS),
|
||||
"truncated": False,
|
||||
}
|
||||
if "count(*) as n" in sql_l:
|
||||
return {"status": "error", "error": "connection refused"}
|
||||
raise AssertionError("no deberia llegar a los agregados")
|
||||
|
||||
monkeypatch.setattr(mod, "pg_query", failing_count)
|
||||
res = summarize_table_pg("postgresql://x/y", "ventas")
|
||||
assert res["status"] == "error"
|
||||
assert "connection refused" in res["error"]
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: discover_local_services
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "discover_local_services(manifest_path: str, include_registry: bool = True) -> list[dict]"
|
||||
description: "Descubre los servicios locales del sistema local_hub expuestos como subdominios *.localhost. Lee el manifiesto YAML, normaliza la metadata de cada servicio, opcionalmente añade los servicios del registry con puerto via fn doctor, y comprueba up/down por chequeo de puerto TCP en 127.0.0.1. Robusta: no lanza por servicio caido (up=False) ni por fallo de fn doctor."
|
||||
tags: [local-hub, infra, services, discovery, caddy, glance, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, os, socket, subprocess, sys, yaml]
|
||||
params:
|
||||
- name: manifest_path
|
||||
desc: "ruta al manifiesto YAML de servicios (apps/local_hub/local_services.yaml) con claves dashboard_subdomain, glance_port y services[]"
|
||||
- name: include_registry
|
||||
desc: "si True, añade los servicios del registry con port>0 que no esten ya en el manifiesto (dedup por port y por subdomain), obtenidos de fn doctor services-spec --json"
|
||||
output: "lista de dicts normalizados, cada uno con las claves name, subdomain, port, health_path, title, icon, category, rewrite_host (bool, passthrough del manifiesto; False para servicios del registry; lo consume render_caddyfile para reescribir el header Host), up (bool de estado vivo por puerto TCP)"
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_service_up_with_all_keys"
|
||||
- "test_edge_closed_port_is_down"
|
||||
- "test_defaults_derived_for_missing_fields"
|
||||
- "test_empty_manifest_returns_empty_list"
|
||||
- "test_rewrite_host_passthrough_desde_manifiesto"
|
||||
test_file_path: "python/functions/infra/discover_local_services_test.py"
|
||||
file_path: "python/functions/infra/discover_local_services.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from discover_local_services import discover_local_services
|
||||
|
||||
# Solo manifiesto (sin tocar el registry):
|
||||
servicios = discover_local_services(
|
||||
"apps/local_hub/local_services.yaml",
|
||||
include_registry=False,
|
||||
)
|
||||
for s in servicios:
|
||||
estado = "UP" if s["up"] else "DOWN"
|
||||
print(f'{s["title"]:<16} {s["subdomain"]}.localhost -> :{s["port"]} [{estado}]')
|
||||
|
||||
# Manifiesto + servicios del registry con puerto:
|
||||
todos = discover_local_services("apps/local_hub/local_services.yaml")
|
||||
print(len([s for s in todos if s["up"]]), "servicios vivos")
|
||||
```
|
||||
|
||||
Como script (imprime JSON a stdout):
|
||||
|
||||
```bash
|
||||
python/.venv/bin/python3 python/functions/infra/discover_local_services.py apps/local_hub/local_services.yaml
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala como fase de descubrimiento del sistema `local_hub` antes de renderizar el
|
||||
Caddyfile o la config de Glance: cuando necesites la lista normalizada de servicios
|
||||
locales (`*.localhost`) con su estado up/down resuelto. También cuando quieras un
|
||||
inventario unificado de servicios manuales (contenedores, daemons de terceros) más
|
||||
los servicios del registry con puerto, deduplicados.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `up` se decide por **conexión TCP** a `127.0.0.1:<port>` con timeout 0.5s, NO por
|
||||
GET HTTP. Un servicio puede aceptar la conexión y devolver 404/500 en `/` y aun
|
||||
así marcar `up=True`. Es intencional: solo valida que el puerto esté escuchando.
|
||||
- Solo comprueba `127.0.0.1` (loopback). Servicios que bindean únicamente a otra
|
||||
interfaz se reportan como `down`.
|
||||
- `include_registry=True` ejecuta `fn doctor services-spec --json` (fallback a
|
||||
`services --json`) como subproceso desde la raíz del repo. Si `fn` no está, falla,
|
||||
tarda más de 20s o devuelve JSON inválido, la función **no lanza**: sigue solo con
|
||||
el manifiesto. Por eso el resultado puede variar según el entorno.
|
||||
- La raíz del repo se resuelve por `FN_REGISTRY_ROOT` o subiendo directorios hasta
|
||||
encontrar `registry.db`. Si no la encuentra, usa el cwd.
|
||||
- El dedup del registry es por `port` Y por `subdomain`: un servicio del registry
|
||||
cuyo puerto o subdominio derivado ya esté en el manifiesto se omite.
|
||||
- El subdominio de un servicio del registry se deriva por una tabla de alias
|
||||
(`dag_engine`->`dag`, `registry_api`->`registry`, `sqlite_api`->`sqlite`,
|
||||
`osint_db`->`osint`, ...) y, para el resto, el primer token antes de `_`.
|
||||
- Lanza `RuntimeError` solo si el manifiesto no se puede leer o parsear (path
|
||||
inexistente, YAML inválido). Eso sí es un error duro.
|
||||
- La clave `rewrite_host` es passthrough del manifiesto (default `False`); para
|
||||
los servicios añadidos desde el registry siempre es `False`. La consume
|
||||
`render_caddyfile` para emitir `header_up Host` en el bloque del servicio.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-20) — añade clave rewrite_host (passthrough del manifiesto) para que render_caddyfile reescriba el Host
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Descubre servicios locales expuestos como subdominios ``*.localhost``.
|
||||
|
||||
Fase de descubrimiento del sistema ``local_hub`` (reverse proxy Caddy +
|
||||
dashboard Glance). Lee el manifiesto YAML de servicios, normaliza la metadata
|
||||
de cada uno, opcionalmente añade los servicios del registry con puerto, y
|
||||
comprueba si cada servicio está vivo (puerto TCP aceptando conexiones).
|
||||
|
||||
La función es robusta: nunca lanza por un servicio caído (lo marca ``up=False``)
|
||||
ni por un fallo de ``fn doctor`` (captura y continúa solo con el manifiesto).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
# Alias para derivar el subdominio de un servicio del registry a partir de su
|
||||
# nombre. Para los nombres no listados se usa el primer token antes de "_".
|
||||
_SUBDOMAIN_ALIAS = {
|
||||
"dag_engine": "dag",
|
||||
"registry_api": "registry",
|
||||
"sqlite_api": "sqlite",
|
||||
"osint_db": "osint",
|
||||
"services_api": "services",
|
||||
"web_proxy": "proxy",
|
||||
}
|
||||
|
||||
|
||||
def _is_port_up(port: int) -> bool:
|
||||
"""Devuelve True si 127.0.0.1:<port> acepta conexiones TCP.
|
||||
|
||||
Comprobación pura de puerto (no HTTP): algunos servicios no responden 200
|
||||
en ``/`` pero sí aceptan la conexión. Timeout corto para no bloquear.
|
||||
"""
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", int(port)), timeout=0.5):
|
||||
return True
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_manifest_service(svc: dict) -> dict:
|
||||
"""Normaliza un servicio del manifiesto a todas las claves esperadas."""
|
||||
name = str(svc.get("name", "")).strip()
|
||||
subdomain = str(svc.get("subdomain", "") or name).strip()
|
||||
try:
|
||||
port = int(svc.get("port", 0) or 0)
|
||||
except (ValueError, TypeError):
|
||||
port = 0
|
||||
return {
|
||||
"name": name,
|
||||
"subdomain": subdomain,
|
||||
"port": port,
|
||||
"health_path": str(svc.get("health_path") or "/"),
|
||||
"title": str(svc.get("title") or name),
|
||||
"icon": str(svc.get("icon") or ""),
|
||||
"category": str(svc.get("category") or "Otros"),
|
||||
"rewrite_host": bool(svc.get("rewrite_host", False)),
|
||||
"up": _is_port_up(port) if port > 0 else False,
|
||||
}
|
||||
|
||||
|
||||
def _is_registry_root(path: str) -> bool:
|
||||
"""Una raíz válida del registry tiene registry.db Y el paquete cmd/fn.
|
||||
|
||||
El doble marcador evita falsos positivos como un registry.db vacío y
|
||||
espurio dentro de python/ (la regla db_locations exige que registry.db
|
||||
solo viva en la raíz, pero protegemos por si acaso).
|
||||
"""
|
||||
return os.path.isfile(os.path.join(path, "registry.db")) and os.path.isdir(
|
||||
os.path.join(path, "cmd", "fn")
|
||||
)
|
||||
|
||||
|
||||
def _find_registry_root() -> str:
|
||||
"""Localiza la raíz del registry (FN_REGISTRY_ROOT o subiendo hasta la raíz real)."""
|
||||
root = os.environ.get("FN_REGISTRY_ROOT")
|
||||
if root and _is_registry_root(root):
|
||||
return root
|
||||
cur = os.path.abspath(os.path.dirname(__file__))
|
||||
while True:
|
||||
if _is_registry_root(cur):
|
||||
return cur
|
||||
parent = os.path.dirname(cur)
|
||||
if parent == cur:
|
||||
break
|
||||
cur = parent
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
def _derive_subdomain(name: str) -> str:
|
||||
"""Deriva un subdominio del nombre de un servicio del registry."""
|
||||
name = (name or "").strip()
|
||||
if name in _SUBDOMAIN_ALIAS:
|
||||
return _SUBDOMAIN_ALIAS[name]
|
||||
return name.split("_", 1)[0] if name else name
|
||||
|
||||
|
||||
def _fetch_registry_services(root: str) -> list[dict]:
|
||||
"""Obtiene los servicios del registry con puerto via ``fn doctor``.
|
||||
|
||||
Intenta ``fn doctor services-spec --json`` y cae a ``services --json``.
|
||||
Devuelve lista vacía si ambos fallan (la función sigue con el manifiesto).
|
||||
"""
|
||||
fn_bin = os.path.join(root, "fn")
|
||||
cmd_base = [fn_bin] if os.path.isfile(fn_bin) else ["fn"]
|
||||
for sub in ("services-spec", "services"):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd_base + ["doctor", sub, "--json"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
if proc.returncode != 0 or not proc.stdout.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if isinstance(data, dict):
|
||||
for key in ("services", "items", "results"):
|
||||
if isinstance(data.get(key), list):
|
||||
data = data[key]
|
||||
break
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
|
||||
|
||||
def discover_local_services(manifest_path: str, include_registry: bool = True) -> list[dict]:
|
||||
"""Descubre y normaliza los servicios locales del manifiesto local_hub.
|
||||
|
||||
Args:
|
||||
manifest_path: ruta al manifiesto YAML (``apps/local_hub/local_services.yaml``).
|
||||
include_registry: si True añade los servicios del registry con puerto>0 que
|
||||
no estén ya en el manifiesto (dedup por port y por subdomain).
|
||||
|
||||
Returns:
|
||||
Lista de dicts normalizados, cada uno con las claves: name, subdomain, port,
|
||||
health_path, title, icon, category, rewrite_host, up. La clave
|
||||
``rewrite_host`` (bool) es passthrough del manifiesto (default ``False``;
|
||||
siempre ``False`` para los servicios añadidos desde el registry) y la
|
||||
consume ``render_caddyfile`` para reescribir el header ``Host`` del
|
||||
upstream en servicios que lo validan (ej. Jupyter).
|
||||
"""
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as fh:
|
||||
manifest = yaml.safe_load(fh) or {}
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise RuntimeError(f"discover_local_services: cannot read manifest {manifest_path}: {exc}") from exc
|
||||
|
||||
raw_services = manifest.get("services") or []
|
||||
result: list[dict] = []
|
||||
seen_ports: set[int] = set()
|
||||
seen_subdomains: set[str] = set()
|
||||
|
||||
for svc in raw_services:
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
norm = _normalize_manifest_service(svc)
|
||||
result.append(norm)
|
||||
if norm["port"] > 0:
|
||||
seen_ports.add(norm["port"])
|
||||
if norm["subdomain"]:
|
||||
seen_subdomains.add(norm["subdomain"])
|
||||
|
||||
if include_registry:
|
||||
try:
|
||||
root = _find_registry_root()
|
||||
for svc in _fetch_registry_services(root):
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
try:
|
||||
port = int(svc.get("port", 0) or 0)
|
||||
except (ValueError, TypeError):
|
||||
port = 0
|
||||
if port <= 0 or port in seen_ports:
|
||||
continue
|
||||
name = str(svc.get("name", "")).strip()
|
||||
subdomain = _derive_subdomain(name)
|
||||
if subdomain in seen_subdomains:
|
||||
continue
|
||||
result.append({
|
||||
"name": name,
|
||||
"subdomain": subdomain,
|
||||
"port": port,
|
||||
"health_path": str(svc.get("health_endpoint") or "/"),
|
||||
"title": name,
|
||||
"icon": "",
|
||||
"category": "Registry",
|
||||
"rewrite_host": False,
|
||||
"up": _is_port_up(port),
|
||||
})
|
||||
seen_ports.add(port)
|
||||
seen_subdomains.add(subdomain)
|
||||
except Exception:
|
||||
# fn doctor opcional: si algo falla, seguimos solo con el manifiesto.
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "apps/local_hub/local_services.yaml"
|
||||
services = discover_local_services(path)
|
||||
print(json.dumps(services, indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests para discover_local_services."""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
# El modulo hoja se importa por su nombre directo; aseguramos que su directorio
|
||||
# esta en sys.path para poder correr el test desde cualquier cwd.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from discover_local_services import discover_local_services
|
||||
|
||||
NORMALIZED_KEYS = {
|
||||
"name", "subdomain", "port", "health_path", "title", "icon", "category",
|
||||
"rewrite_host", "up",
|
||||
}
|
||||
|
||||
|
||||
def _write_manifest(tmp_path, services):
|
||||
manifest = {
|
||||
"dashboard_subdomain": "home",
|
||||
"glance_port": 8585,
|
||||
"services": services,
|
||||
}
|
||||
path = tmp_path / "local_services.yaml"
|
||||
path.write_text(yaml.safe_dump(manifest), encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_golden_service_up_with_all_keys(tmp_path):
|
||||
# Abrimos un socket real en un puerto efímero para simular un servicio vivo.
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(1)
|
||||
port = listener.getsockname()[1]
|
||||
try:
|
||||
manifest = _write_manifest(tmp_path, [
|
||||
{
|
||||
"name": "metabase",
|
||||
"subdomain": "metabase",
|
||||
"port": port,
|
||||
"health_path": "/api/health",
|
||||
"title": "Metabase",
|
||||
"icon": "si:metabase",
|
||||
"category": "Datos",
|
||||
},
|
||||
])
|
||||
result = discover_local_services(manifest, include_registry=False)
|
||||
assert len(result) == 1
|
||||
svc = result[0]
|
||||
# Todas las claves normalizadas presentes.
|
||||
assert set(svc.keys()) == NORMALIZED_KEYS
|
||||
assert svc["up"] is True
|
||||
assert svc["name"] == "metabase"
|
||||
assert svc["port"] == port
|
||||
assert svc["health_path"] == "/api/health"
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
|
||||
def test_edge_closed_port_is_down(tmp_path):
|
||||
# Tomamos un puerto efímero y lo cerramos inmediatamente -> debe estar down.
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
closed_port = s.getsockname()[1]
|
||||
s.close()
|
||||
|
||||
manifest = _write_manifest(tmp_path, [
|
||||
{
|
||||
"name": "ghost",
|
||||
"subdomain": "ghost",
|
||||
"port": closed_port,
|
||||
"health_path": "/",
|
||||
"title": "Ghost",
|
||||
"icon": "",
|
||||
"category": "Otros",
|
||||
},
|
||||
])
|
||||
result = discover_local_services(manifest, include_registry=False)
|
||||
assert len(result) == 1
|
||||
assert result[0]["up"] is False
|
||||
|
||||
|
||||
def test_defaults_derived_for_missing_fields(tmp_path):
|
||||
# Servicio mínimo: solo name + port. El resto debe derivarse con defaults.
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
closed_port = s.getsockname()[1]
|
||||
s.close()
|
||||
|
||||
manifest = _write_manifest(tmp_path, [
|
||||
{"name": "barebones", "port": closed_port},
|
||||
])
|
||||
result = discover_local_services(manifest, include_registry=False)
|
||||
svc = result[0]
|
||||
assert set(svc.keys()) == NORMALIZED_KEYS
|
||||
assert svc["title"] == "barebones" # derivado de name
|
||||
assert svc["icon"] == "" # default
|
||||
assert svc["category"] == "Otros" # default
|
||||
assert svc["health_path"] == "/" # default
|
||||
assert svc["subdomain"] == "barebones" # derivado de name
|
||||
assert svc["up"] is False
|
||||
|
||||
|
||||
def test_empty_manifest_returns_empty_list(tmp_path):
|
||||
manifest = _write_manifest(tmp_path, [])
|
||||
result = discover_local_services(manifest, include_registry=False)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_rewrite_host_passthrough_desde_manifiesto(tmp_path):
|
||||
# Un servicio con rewrite_host: true en el manifiesto debe propagar
|
||||
# rewrite_host == True; uno sin la clave debe dar rewrite_host == False.
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(1)
|
||||
port = listener.getsockname()[1]
|
||||
try:
|
||||
manifest = _write_manifest(tmp_path, [
|
||||
{
|
||||
"name": "jupyter",
|
||||
"subdomain": "jupyter",
|
||||
"port": port,
|
||||
"rewrite_host": True,
|
||||
},
|
||||
{
|
||||
"name": "metabase",
|
||||
"subdomain": "metabase",
|
||||
"port": port,
|
||||
# sin clave rewrite_host -> default False
|
||||
},
|
||||
])
|
||||
result = discover_local_services(manifest, include_registry=False)
|
||||
by_name = {s["name"]: s for s in result}
|
||||
assert by_name["jupyter"]["rewrite_host"] is True
|
||||
assert by_name["metabase"]["rewrite_host"] is False
|
||||
finally:
|
||||
listener.close()
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: render_caddyfile
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.1.0"
|
||||
purity: pure
|
||||
signature: "def render_caddyfile(services: list[dict], dashboard: dict | None = None) -> str"
|
||||
description: "Parte del sistema local_hub: transforma una lista de servicios normalizados en el texto de un fragmento de Caddyfile que mapea cada subdominio *.localhost a su puerto local via reverse_proxy HTTP plano (loopback, sin TLS). Cada servicio es un dict con subdomain (str) y port (int); el resto de claves se ignoran. Los bloques de servicio se ordenan por subdominio alfabetico para que la salida sea estable y reproducible (clave para diffs y tests). Un dashboard opcional emite su bloque PRIMERO porque es la pagina principal. Ignora servicios sin subdomain o sin port (los salta, no lanza) y no deduplica. Pura: solo stdlib, sin I/O ni red, determinista."
|
||||
tags: [local-hub, caddy, caddyfile, reverse-proxy, infra, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: services
|
||||
desc: "lista de dicts de servicio normalizados. Cada uno debe tener al menos subdomain (str, sin el sufijo .localhost) y port (int, puerto local). Otras claves se ignoran. Los servicios sin subdomain o sin port se saltan silenciosamente. No se deduplica: eso es trabajo del discover."
|
||||
- name: dashboard
|
||||
desc: "dict opcional {subdomain, port} para la pagina principal del hub (ej. {\"subdomain\": \"home\", \"port\": 8585}). Si se pasa, su bloque va el primero de la salida. None = no se emite bloque de dashboard. Si le falta subdomain o port, se ignora igual que un servicio invalido."
|
||||
output: "string con el Caddyfile completo: empieza por una cabecera de comentario (# Generado por render_caddyfile_py_infra ...), luego el bloque del dashboard si aplica, y despues los bloques de servicio ordenados alfabeticamente por subdominio. Cada bloque es 'http://<subdomain>.localhost {\\n reverse_proxy 127.0.0.1:<port>\\n}\\n' con 4 espacios de indentacion. La salida termina con un unico \\n."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_dos_servicios_ordenados"
|
||||
- "test_dashboard_va_primero"
|
||||
- "test_lista_vacia_solo_cabecera"
|
||||
- "test_servicio_sin_port_se_ignora"
|
||||
- "test_servicio_sin_subdomain_se_ignora"
|
||||
- "test_rewrite_host_emite_header_up"
|
||||
- "test_rewrite_host_ausente_o_falso_no_reescribe"
|
||||
test_file_path: "python/functions/infra/render_caddyfile_test.py"
|
||||
file_path: "python/functions/infra/render_caddyfile.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions", "infra"))
|
||||
from render_caddyfile import render_caddyfile
|
||||
|
||||
services = [
|
||||
{"subdomain": "metabase", "port": 3030},
|
||||
{"subdomain": "grafana", "port": 3000},
|
||||
]
|
||||
dashboard = {"subdomain": "home", "port": 8585}
|
||||
|
||||
print(render_caddyfile(services, dashboard=dashboard))
|
||||
# # Generado por render_caddyfile_py_infra — NO editar a mano. Fuente: apps/local_hub/local_services.yaml
|
||||
# http://home.localhost {
|
||||
# reverse_proxy 127.0.0.1:8585
|
||||
# }
|
||||
# http://grafana.localhost {
|
||||
# reverse_proxy 127.0.0.1:3000
|
||||
# }
|
||||
# http://metabase.localhost {
|
||||
# reverse_proxy 127.0.0.1:3030
|
||||
# }
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando, dentro del sistema local_hub, ya tienes la lista de servicios locales
|
||||
normalizada (cada uno con su `subdomain` y `port`) y necesitas materializar el
|
||||
fragmento de Caddyfile que enruta `*.localhost` a sus puertos. Usala justo
|
||||
antes de escribir el archivo a disco y recargar Caddy: esta funcion solo
|
||||
produce el texto (pura), el I/O y el reload van en una funcion impura o pipeline
|
||||
aparte. Tambien util para tests/diffs porque la salida es determinista (bloques
|
||||
ordenados por subdominio).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El formato es HTTP plano a proposito (`http://...`, sin TLS): todo el trafico
|
||||
es loopback (`127.0.0.1`), no hay nada que cifrar y `*.localhost` no necesita
|
||||
certificado. No es un bug.
|
||||
- No deduplica subdominios: si dos servicios comparten `subdomain`, ambos
|
||||
bloques se emiten y Caddy se quedara con el ultimo. La deduplicacion es
|
||||
responsabilidad del discover que produce `services`.
|
||||
- `rewrite_host` solo cambia la cabecera `Host` que ve el upstream, no la URL
|
||||
que abre el usuario. Actívalo unicamente para servicios que validan el header
|
||||
y rechazan el subdominio (Jupyter devuelve 400, algunos FastAPI/uvicorn con
|
||||
`--forwarded-allow-ips` estricto). Para el resto dejalo ausente/False: añadir
|
||||
`header_up Host` sin necesidad puede romper virtual-hosting del upstream.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-20) — añade soporte rewrite_host (header_up Host) para servicios que validan el header Host
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Renderiza un fragmento de Caddyfile para el sistema local_hub.
|
||||
|
||||
Funcion pura: transforma una lista de servicios normalizados en el texto de un
|
||||
Caddyfile que mapea cada subdominio `*.localhost` a su puerto local via
|
||||
reverse_proxy HTTP plano (loopback, sin TLS). Sin I/O, sin red, determinista.
|
||||
"""
|
||||
|
||||
_HEADER = (
|
||||
"# Generado por render_caddyfile_py_infra — NO editar a mano. "
|
||||
"Fuente: apps/local_hub/local_services.yaml\n"
|
||||
)
|
||||
|
||||
|
||||
def _render_block(subdomain: str, port: int, rewrite_host: bool = False) -> str:
|
||||
"""Construye un bloque reverse_proxy para un subdominio y puerto dados.
|
||||
|
||||
Args:
|
||||
subdomain: subdominio sin el sufijo `.localhost` (ej. "metabase").
|
||||
port: puerto local al que redirigir (ej. 3030).
|
||||
rewrite_host: si es True, reescribe la cabecera `Host` enviada al
|
||||
upstream a `127.0.0.1:<port>`. Necesario para servicios que validan
|
||||
el header Host y rechazan el subdominio (ej. Jupyter devuelve 400
|
||||
"Bad Request" si recibe `Host: jupyter.localhost`).
|
||||
|
||||
Returns:
|
||||
el bloque Caddyfile como string, con indentacion de 4 espacios y
|
||||
terminado en `\n`.
|
||||
"""
|
||||
if rewrite_host:
|
||||
return (
|
||||
f"http://{subdomain}.localhost {{\n"
|
||||
f" reverse_proxy 127.0.0.1:{port} {{\n"
|
||||
f" header_up Host 127.0.0.1:{port}\n"
|
||||
f" }}\n"
|
||||
f"}}\n"
|
||||
)
|
||||
return (
|
||||
f"http://{subdomain}.localhost {{\n"
|
||||
f" reverse_proxy 127.0.0.1:{port}\n"
|
||||
f"}}\n"
|
||||
)
|
||||
|
||||
|
||||
def render_caddyfile(services: list[dict], dashboard: dict | None = None) -> str:
|
||||
"""Renderiza el texto de un fragmento de Caddyfile para local_hub.
|
||||
|
||||
Cada servicio se mapea a un bloque `http://<subdomain>.localhost` con un
|
||||
`reverse_proxy 127.0.0.1:<port>`. Los bloques de servicio se ordenan por
|
||||
subdominio alfabetico para que la salida sea estable y reproducible. El
|
||||
bloque del dashboard, si se pasa, va siempre primero (es la pagina
|
||||
principal). Se usa HTTP plano a proposito: todo es loopback, no hay TLS.
|
||||
|
||||
Args:
|
||||
services: lista de dicts de servicio. Cada uno debe tener al menos
|
||||
`subdomain` (str) y `port` (int); otras claves se ignoran salvo
|
||||
`rewrite_host` (bool opcional): si es truthy, el bloque reescribe la
|
||||
cabecera `Host` enviada al upstream a `127.0.0.1:<port>` (para
|
||||
servicios que validan Host, ej. Jupyter). Los servicios sin
|
||||
`subdomain` o sin `port` se saltan (no lanzan error). No se
|
||||
deduplica (eso es trabajo del discover).
|
||||
dashboard: dict opcional con `subdomain` y `port` para la pagina
|
||||
principal. Si es None, no se emite bloque de dashboard. Si le falta
|
||||
`subdomain` o `port`, se ignora igual que un servicio invalido.
|
||||
|
||||
Returns:
|
||||
el Caddyfile completo como string: empieza por una cabecera de
|
||||
comentario, luego (si aplica) el bloque del dashboard, y despues los
|
||||
bloques de servicio ordenados. Termina con un unico `\n`.
|
||||
"""
|
||||
parts: list[str] = [_HEADER]
|
||||
|
||||
if dashboard is not None:
|
||||
d_sub = dashboard.get("subdomain")
|
||||
d_port = dashboard.get("port")
|
||||
if d_sub is not None and d_port is not None:
|
||||
parts.append(_render_block(d_sub, d_port))
|
||||
|
||||
valid = [
|
||||
svc
|
||||
for svc in services
|
||||
if svc.get("subdomain") is not None and svc.get("port") is not None
|
||||
]
|
||||
for svc in sorted(valid, key=lambda s: s["subdomain"]):
|
||||
parts.append(
|
||||
_render_block(svc["subdomain"], svc["port"], bool(svc.get("rewrite_host")))
|
||||
)
|
||||
|
||||
return "".join(parts)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests para render_caddyfile."""
|
||||
|
||||
from render_caddyfile import render_caddyfile
|
||||
|
||||
HEADER = (
|
||||
"# Generado por render_caddyfile_py_infra — NO editar a mano. "
|
||||
"Fuente: apps/local_hub/local_services.yaml\n"
|
||||
)
|
||||
|
||||
|
||||
def test_golden_dos_servicios_ordenados():
|
||||
services = [
|
||||
{"subdomain": "metabase", "port": 3030},
|
||||
{"subdomain": "grafana", "port": 3000},
|
||||
]
|
||||
result = render_caddyfile(services)
|
||||
expected = (
|
||||
HEADER
|
||||
+ "http://grafana.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:3000\n"
|
||||
+ "}\n"
|
||||
+ "http://metabase.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:3030\n"
|
||||
+ "}\n"
|
||||
)
|
||||
assert result == expected
|
||||
# Orden alfabetico: grafana antes que metabase pese al orden de entrada.
|
||||
assert result.index("grafana.localhost") < result.index("metabase.localhost")
|
||||
# Termina con un unico newline.
|
||||
assert result.endswith("}\n")
|
||||
assert not result.endswith("\n\n")
|
||||
|
||||
|
||||
def test_dashboard_va_primero():
|
||||
services = [{"subdomain": "metabase", "port": 3030}]
|
||||
dashboard = {"subdomain": "home", "port": 8585}
|
||||
result = render_caddyfile(services, dashboard=dashboard)
|
||||
expected = (
|
||||
HEADER
|
||||
+ "http://home.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:8585\n"
|
||||
+ "}\n"
|
||||
+ "http://metabase.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:3030\n"
|
||||
+ "}\n"
|
||||
)
|
||||
assert result == expected
|
||||
# El dashboard aparece antes que cualquier servicio.
|
||||
assert result.index("home.localhost") < result.index("metabase.localhost")
|
||||
|
||||
|
||||
def test_lista_vacia_solo_cabecera():
|
||||
result = render_caddyfile([])
|
||||
assert result == HEADER
|
||||
|
||||
|
||||
def test_servicio_sin_port_se_ignora():
|
||||
services = [
|
||||
{"subdomain": "valido", "port": 9000},
|
||||
{"subdomain": "sin_port"},
|
||||
]
|
||||
result = render_caddyfile(services)
|
||||
expected = (
|
||||
HEADER
|
||||
+ "http://valido.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:9000\n"
|
||||
+ "}\n"
|
||||
)
|
||||
assert result == expected
|
||||
assert "sin_port" not in result
|
||||
|
||||
|
||||
def test_servicio_sin_subdomain_se_ignora():
|
||||
services = [
|
||||
{"subdomain": "valido", "port": 9000},
|
||||
{"port": 1234},
|
||||
]
|
||||
result = render_caddyfile(services)
|
||||
assert "1234" not in result
|
||||
assert result.count("reverse_proxy") == 1
|
||||
|
||||
|
||||
def test_rewrite_host_emite_header_up():
|
||||
services = [{"subdomain": "jupyter", "port": 8888, "rewrite_host": True}]
|
||||
result = render_caddyfile(services)
|
||||
expected = (
|
||||
HEADER
|
||||
+ "http://jupyter.localhost {\n"
|
||||
+ " reverse_proxy 127.0.0.1:8888 {\n"
|
||||
+ " header_up Host 127.0.0.1:8888\n"
|
||||
+ " }\n"
|
||||
+ "}\n"
|
||||
)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_rewrite_host_ausente_o_falso_no_reescribe():
|
||||
# Sin la clave -> bloque simple.
|
||||
assert "header_up" not in render_caddyfile([{"subdomain": "a", "port": 1}])
|
||||
# Con rewrite_host falsy -> bloque simple.
|
||||
assert "header_up" not in render_caddyfile(
|
||||
[{"subdomain": "a", "port": 1, "rewrite_host": False}]
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: render_glance_config
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.1.0"
|
||||
purity: pure
|
||||
signature: "render_glance_config(services: list[dict], title: str = \"Procesos locales\", host_suffix: str = \"localhost\") -> str"
|
||||
description: "Transforma una lista de servicios normalizados en el YAML de configuración de Glance (dashboard self-hosted). Genera una página con un widget monitor por categoría que hace health-check de cada servicio y lo pinta verde/rojo. Función pura y determinista. Parte del sistema local_hub."
|
||||
tags: [local-hub, infra, glance, dashboard, yaml, config]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [pyyaml]
|
||||
params:
|
||||
- name: services
|
||||
desc: "Lista de dicts de servicio normalizados. Cada uno requiere 'subdomain' (si falta, el servicio se ignora sin lanzar) y 'title'; opcional 'category' (default 'General'), 'icon' (se omite del site si está vacío o ausente) y 'health_path' (ruta de salud del servicio: si es distinta de '/', el site emite 'check-url' = url+health_path para que Glance haga el health-check ahí; si es '/' o falta, no se emite check-url)."
|
||||
- name: title
|
||||
desc: "Nombre de la página de Glance (campo 'name' de la página). Default 'Procesos locales'."
|
||||
- name: host_suffix
|
||||
desc: "Sufijo de host para las URLs de los sites. Default 'localhost' -> 'http://<subdomain>.localhost'."
|
||||
output: "String con el YAML completo de configuración de Glance (cabecera de comentario + pages/columns/widgets), terminado en '\\n'. Parseable con yaml.safe_load. Cada site lleva 'title' y 'url' (raíz del subdominio); además 'check-url' (url+health_path) cuando el servicio trae un health_path distinto de '/', e 'icon' cuando no está vacío."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_dos_categorias_dos_widgets"
|
||||
- "test_yaml_parseable_y_estructura"
|
||||
- "test_icon_omitido_cuando_vacio"
|
||||
- "test_host_suffix_custom"
|
||||
- "test_title_es_name_de_pagina"
|
||||
- "test_servicios_sin_subdomain_se_ignoran"
|
||||
- "test_determinismo"
|
||||
- "test_orden_sites_por_title"
|
||||
- "test_categoria_default_general"
|
||||
- "test_check_url_se_emite_cuando_health_path_no_es_raiz"
|
||||
test_file_path: "python/functions/infra/render_glance_config_test.py"
|
||||
file_path: "python/functions/infra/render_glance_config.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.render_glance_config import render_glance_config
|
||||
|
||||
services = [
|
||||
{"subdomain": "metabase", "title": "Metabase", "icon": "si:metabase", "category": "Datos"},
|
||||
{"subdomain": "jupyter", "title": "Jupyter Lab", "icon": "si:jupyter", "category": "Datos"},
|
||||
{"subdomain": "portainer","title": "Portainer", "icon": "si:portainer", "category": "Infra"},
|
||||
]
|
||||
|
||||
yaml_text = render_glance_config(services, title="Inicio")
|
||||
print(yaml_text)
|
||||
# pages:
|
||||
# - name: Inicio
|
||||
# columns:
|
||||
# - size: full
|
||||
# widgets:
|
||||
# - type: monitor
|
||||
# title: Datos
|
||||
# cache: 1m
|
||||
# sites:
|
||||
# - title: Jupyter Lab # ordenado por title dentro de la categoría
|
||||
# url: http://jupyter.localhost
|
||||
# icon: si:jupyter
|
||||
# - title: Metabase
|
||||
# url: http://metabase.localhost
|
||||
# icon: si:metabase
|
||||
# - type: monitor
|
||||
# title: Infra
|
||||
# cache: 1m
|
||||
# sites:
|
||||
# - title: Portainer
|
||||
# url: http://portainer.localhost
|
||||
# icon: si:portainer
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites regenerar el `glance.yml` del dashboard local a partir de
|
||||
`apps/local_hub/local_services.yaml`: tras añadir/quitar un servicio local, o
|
||||
en el pipeline `refresh_local_hub` que corre diario via dag_engine. La salida se
|
||||
escribe al archivo de config de Glance (el borde impuro: I/O lo hace el caller).
|
||||
|
||||
## Notas
|
||||
|
||||
- **Decisión `title` -> `name`:** el parámetro `title` se usa como el campo `name`
|
||||
de la (única) página de Glance. No es un comentario de cabecera ni se ignora.
|
||||
Default `"Procesos locales"`. Así la firma queda útil sin añadir un parámetro
|
||||
extra para el nombre de página.
|
||||
- **Determinismo:** las categorías se ordenan alfabéticamente y los servicios de
|
||||
cada categoría por `title` (desempate por `subdomain`). Se serializa con
|
||||
`yaml.safe_dump(sort_keys=False)` sobre estructuras ya ordenadas, por lo que la
|
||||
misma entrada (en cualquier orden) produce siempre la misma salida byte a byte.
|
||||
- **Robustez:** los servicios sin `subdomain` se ignoran silenciosamente (no se
|
||||
lanza). El `icon` se omite del site cuando está vacío o ausente. Cada categoría
|
||||
produce un widget `type: monitor` con `cache: 1m`; todos los widgets van en una
|
||||
sola columna `size: full`.
|
||||
- **Función pura:** sin I/O, sin estado, determinista. El health-check real lo
|
||||
hace Glance en runtime (GET a `check-url` si existe, si no a `url`); esta
|
||||
función solo genera el texto.
|
||||
- **`check-url` vs `url`:** `url` es siempre la raíz del subdominio (lo que abre
|
||||
el usuario al clicar). `check-url` solo aparece cuando el servicio trae un
|
||||
`health_path` distinto de `/`, y vale `url + health_path`. Sirve para APIs que
|
||||
devuelven 404 en `/` pero 200 en su ruta de salud (ej. `/api/health`), de modo
|
||||
que Glance las pinta verde sin cambiar el enlace navegable.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-20) — añade check-url (health_path) por site para health-check preciso de APIs sin ruta raíz
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Renderiza la configuración YAML de Glance a partir de servicios normalizados.
|
||||
|
||||
Glance (https://github.com/glanceapp/glance) es un dashboard self-hosted. Este
|
||||
módulo transforma una lista de servicios en el YAML que Glance espera: una página
|
||||
con un widget `monitor` por categoría que hace health-check de cada servicio y lo
|
||||
pinta verde/rojo. Parte del sistema `local_hub`.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
|
||||
_HEADER = (
|
||||
"# Generado por render_glance_config_py_infra — NO editar a mano. "
|
||||
"Fuente: apps/local_hub/local_services.yaml\n"
|
||||
)
|
||||
|
||||
|
||||
def render_glance_config(
|
||||
services: list[dict],
|
||||
title: str = "Procesos locales",
|
||||
host_suffix: str = "localhost",
|
||||
) -> str:
|
||||
"""Construye el YAML de configuración de Glance para una lista de servicios.
|
||||
|
||||
Función pura y determinista: agrupa los servicios por su clave ``category``,
|
||||
crea un widget ``type: monitor`` por categoría (ordenadas alfabéticamente) y
|
||||
dentro de cada uno un site por servicio (ordenados por ``title``). Cada site
|
||||
apunta a ``http://<subdomain>.<host_suffix>`` (campo ``url``, lo que abre el
|
||||
usuario al clicar). Si el servicio trae un ``health_path`` distinto de
|
||||
``"/"``, el site añade además ``check-url`` = ``url + health_path``: es la
|
||||
ruta que Glance usa para el health-check (muchas APIs dan 404 en ``/`` pero
|
||||
200 en ``/api/health``), sin cambiar el enlace que ve el usuario.
|
||||
|
||||
Args:
|
||||
services: lista de dicts de servicio normalizados. Cada uno debe traer al
|
||||
menos ``subdomain`` (si falta, el servicio se ignora sin lanzar) y
|
||||
``title``; opcionalmente ``category`` (default ``"General"``),
|
||||
``icon`` (se omite del site si está vacío o ausente) y ``health_path``
|
||||
(si es distinto de ``"/"``, el site emite ``check-url`` = url +
|
||||
health_path; si es ``"/"`` o falta, no se emite ``check-url``).
|
||||
title: nombre de la página de Glance (campo ``name`` de la página).
|
||||
Default ``"Procesos locales"``.
|
||||
host_suffix: sufijo de host para las URLs de los sites. Default
|
||||
``"localhost"`` -> ``http://<subdomain>.localhost``.
|
||||
|
||||
Returns:
|
||||
String con el YAML completo de Glance, terminado en ``\\n``.
|
||||
"""
|
||||
# Agrupa por categoría, ignorando servicios sin subdomain.
|
||||
by_category: dict[str, list[dict]] = {}
|
||||
for svc in services:
|
||||
subdomain = svc.get("subdomain")
|
||||
if not subdomain:
|
||||
continue
|
||||
category = svc.get("category") or "General"
|
||||
by_category.setdefault(category, []).append(svc)
|
||||
|
||||
widgets: list[dict] = []
|
||||
for category in sorted(by_category.keys()):
|
||||
svcs = sorted(
|
||||
by_category[category],
|
||||
key=lambda s: (s.get("title") or "", s.get("subdomain") or ""),
|
||||
)
|
||||
sites: list[dict] = []
|
||||
for svc in svcs:
|
||||
url = f"http://{svc['subdomain']}.{host_suffix}"
|
||||
site: dict = {
|
||||
"title": svc.get("title") or svc["subdomain"],
|
||||
"url": url,
|
||||
}
|
||||
# El health-check apunta al health_path del servicio (no a "/").
|
||||
# Muchas APIs devuelven 404 en la raiz pero 200 en su ruta de salud
|
||||
# (ej. /api/health), asi Glance las pinta verde correctamente. El
|
||||
# campo `url` (lo que abre el usuario al clicar) sigue siendo la raiz.
|
||||
health = svc.get("health_path") or "/"
|
||||
if health and health != "/":
|
||||
site["check-url"] = url + health
|
||||
icon = svc.get("icon")
|
||||
if icon:
|
||||
site["icon"] = icon
|
||||
sites.append(site)
|
||||
widgets.append(
|
||||
{
|
||||
"type": "monitor",
|
||||
"title": category,
|
||||
"cache": "1m",
|
||||
"sites": sites,
|
||||
}
|
||||
)
|
||||
|
||||
config = {
|
||||
"pages": [
|
||||
{
|
||||
"name": title,
|
||||
"columns": [
|
||||
{
|
||||
"size": "full",
|
||||
"widgets": widgets,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
body = yaml.safe_dump(
|
||||
config,
|
||||
sort_keys=False,
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
)
|
||||
return _HEADER + body
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests para render_glance_config."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from functions.infra.render_glance_config import render_glance_config
|
||||
|
||||
SERVICES = [
|
||||
{
|
||||
"name": "metabase",
|
||||
"subdomain": "metabase",
|
||||
"title": "Metabase",
|
||||
"icon": "si:metabase",
|
||||
"category": "Datos",
|
||||
},
|
||||
{
|
||||
"name": "portainer",
|
||||
"subdomain": "portainer",
|
||||
"title": "Portainer",
|
||||
"icon": "", # icon vacío -> debe omitirse
|
||||
"category": "Infra",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_golden_dos_categorias_dos_widgets():
|
||||
out = render_glance_config(SERVICES)
|
||||
cfg = yaml.safe_load(out)
|
||||
|
||||
widgets = cfg["pages"][0]["columns"][0]["widgets"]
|
||||
assert len(widgets) == 2
|
||||
assert all(w["type"] == "monitor" for w in widgets)
|
||||
|
||||
# Categorías ordenadas alfabéticamente: Datos antes que Infra.
|
||||
assert [w["title"] for w in widgets] == ["Datos", "Infra"]
|
||||
|
||||
datos_site = widgets[0]["sites"][0]
|
||||
assert datos_site["title"] == "Metabase"
|
||||
assert datos_site["url"] == "http://metabase.localhost"
|
||||
assert datos_site["icon"] == "si:metabase"
|
||||
|
||||
|
||||
def test_yaml_parseable_y_estructura():
|
||||
out = render_glance_config(SERVICES)
|
||||
cfg = yaml.safe_load(out) # no debe lanzar
|
||||
page = cfg["pages"][0]
|
||||
assert page["name"] == "Procesos locales"
|
||||
col = page["columns"][0]
|
||||
assert col["size"] == "full"
|
||||
assert isinstance(col["widgets"], list)
|
||||
assert out.endswith("\n")
|
||||
|
||||
|
||||
def test_icon_omitido_cuando_vacio():
|
||||
out = render_glance_config(SERVICES)
|
||||
cfg = yaml.safe_load(out)
|
||||
infra_site = cfg["pages"][0]["columns"][0]["widgets"][1]["sites"][0]
|
||||
assert infra_site["title"] == "Portainer"
|
||||
assert "icon" not in infra_site
|
||||
|
||||
|
||||
def test_host_suffix_custom():
|
||||
out = render_glance_config(SERVICES, host_suffix="home.lan")
|
||||
cfg = yaml.safe_load(out)
|
||||
site = cfg["pages"][0]["columns"][0]["widgets"][0]["sites"][0]
|
||||
assert site["url"] == "http://metabase.home.lan"
|
||||
|
||||
|
||||
def test_title_es_name_de_pagina():
|
||||
out = render_glance_config(SERVICES, title="Mi Hub")
|
||||
cfg = yaml.safe_load(out)
|
||||
assert cfg["pages"][0]["name"] == "Mi Hub"
|
||||
|
||||
|
||||
def test_servicios_sin_subdomain_se_ignoran():
|
||||
services = SERVICES + [{"name": "roto", "title": "Roto", "category": "Datos"}]
|
||||
out = render_glance_config(services)
|
||||
cfg = yaml.safe_load(out)
|
||||
datos = cfg["pages"][0]["columns"][0]["widgets"][0]
|
||||
# Solo Metabase en Datos; el servicio sin subdomain se descarta.
|
||||
assert len(datos["sites"]) == 1
|
||||
assert datos["sites"][0]["title"] == "Metabase"
|
||||
|
||||
|
||||
def test_determinismo():
|
||||
a = render_glance_config(SERVICES)
|
||||
b = render_glance_config(list(reversed(SERVICES)))
|
||||
# El orden de entrada no afecta: categorías y sites se ordenan internamente.
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_orden_sites_por_title():
|
||||
services = [
|
||||
{"subdomain": "z-svc", "title": "Zeta", "category": "Datos"},
|
||||
{"subdomain": "a-svc", "title": "Alfa", "category": "Datos"},
|
||||
]
|
||||
out = render_glance_config(services)
|
||||
cfg = yaml.safe_load(out)
|
||||
sites = cfg["pages"][0]["columns"][0]["widgets"][0]["sites"]
|
||||
assert [s["title"] for s in sites] == ["Alfa", "Zeta"]
|
||||
|
||||
|
||||
def test_categoria_default_general():
|
||||
services = [{"subdomain": "x", "title": "X"}]
|
||||
out = render_glance_config(services)
|
||||
cfg = yaml.safe_load(out)
|
||||
assert cfg["pages"][0]["columns"][0]["widgets"][0]["title"] == "General"
|
||||
|
||||
|
||||
def test_check_url_se_emite_cuando_health_path_no_es_raiz():
|
||||
services = [
|
||||
{
|
||||
"subdomain": "api",
|
||||
"title": "API",
|
||||
"category": "Datos",
|
||||
"health_path": "/api/health",
|
||||
},
|
||||
{
|
||||
"subdomain": "web",
|
||||
"title": "Web",
|
||||
"category": "Datos",
|
||||
"health_path": "/", # raiz -> no debe emitir check-url
|
||||
},
|
||||
{
|
||||
"subdomain": "raw",
|
||||
"title": "Raw",
|
||||
"category": "Datos",
|
||||
# sin health_path -> tampoco debe emitir check-url
|
||||
},
|
||||
]
|
||||
out = render_glance_config(services)
|
||||
cfg = yaml.safe_load(out) # sigue siendo YAML parseable
|
||||
sites = {
|
||||
s["title"]: s
|
||||
for s in cfg["pages"][0]["columns"][0]["widgets"][0]["sites"]
|
||||
}
|
||||
|
||||
# health_path no-raiz -> check-url = url + health_path; url sigue siendo la raiz.
|
||||
assert sites["API"]["url"] == "http://api.localhost"
|
||||
assert sites["API"]["check-url"] == "http://api.localhost/api/health"
|
||||
|
||||
# health_path == "/" -> sin check-url.
|
||||
assert sites["Web"]["url"] == "http://web.localhost"
|
||||
assert "check-url" not in sites["Web"]
|
||||
|
||||
# sin health_path -> sin check-url.
|
||||
assert sites["Raw"]["url"] == "http://raw.localhost"
|
||||
assert "check-url" not in sites["Raw"]
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: resolve_pg_dsn
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def resolve_pg_dsn(project: str) -> dict"
|
||||
description: "Resuelve el DSN de PostgreSQL de un proyecto conocido del ecosistema (captacion_clientes, seo_analytics) sin lanzar. Centraliza el patron inline repetido por el agente: leer el DSN desde la variable de entorno del proyecto (CAPTACION_DSN, SEO_DSN), caer a la linea <ENV_VAR>= del .env del proyecto, y como ultimo recurso construirlo desde el secreto de pass (password en runtime, user/host/port/db fijos por proyecto). Cada proyecto declara su politica de resolucion en un mapa interno explicito (_PROJECTS) con alias para el nombre largo. Orden de resolucion: (1) env var, (2) .env, (3) pass. Devuelve {status:'ok', project, dsn, source} con source='env'|'dotenv'|'pass', o {status:'error', error} si el proyecto es desconocido o no se pudo construir el DSN. NUNCA hardcodea el password: lo lee de pass via pass_get_secret en runtime."
|
||||
tags: [postgres, postgresql, dsn, credential, infra]
|
||||
uses_functions: [pass_get_secret_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [os]
|
||||
tested: true
|
||||
tests: ["env var seteada gana y source es env", "proyecto desconocido devuelve error sin lanzar", "alias largo resuelve a la clave canonica", "fallback a .env cuando no hay env var"]
|
||||
test_file_path: "python/functions/infra/resolve_pg_dsn_test.py"
|
||||
file_path: "python/functions/infra/resolve_pg_dsn.py"
|
||||
params:
|
||||
- name: project
|
||||
desc: "Nombre del proyecto. Acepta la clave canonica ('captacion', 'seo') o el alias largo ('captacion_clientes', 'seo_analytics'). Un nombre no registrado devuelve {status:'error'} con la lista de proyectos conocidos."
|
||||
output: "dict. En exito: {status:'ok', project:str (clave canonica), dsn:str (cadena postgresql://...), source:str ('env'|'dotenv'|'pass')}. En error (sin lanzar): {status:'error', error:str} para proyecto desconocido o DSN no resoluble."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.resolve_pg_dsn import resolve_pg_dsn
|
||||
|
||||
# Por nombre corto o largo, da igual.
|
||||
res = resolve_pg_dsn("captacion")
|
||||
print(res["status"]) # ok
|
||||
print(res["source"]) # 'dotenv' (lee CAPTACION_DSN del .env del proyecto)
|
||||
# res["dsn"] -> "postgresql://captacion:***@localhost:5433/trends"
|
||||
|
||||
# La env var, si esta seteada, gana sobre el .env y sobre pass.
|
||||
os.environ["SEO_DSN"] = "postgresql://captacion:x@localhost:5433/seo"
|
||||
print(resolve_pg_dsn("seo_analytics")["source"]) # env
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala antes de cualquier `psql`/`psycopg2`/`pg_query` contra el Postgres de un
|
||||
proyecto del ecosistema, en vez de reescribir a mano la resolucion del DSN
|
||||
(grep al .env + fallback a pass). Es el unico sitio que sabe como se llama la
|
||||
env var de cada proyecto, donde vive su .env y de que entry de pass sale el
|
||||
password. Si vas a lanzar varias queries seguidas, resuelve el DSN una vez y
|
||||
reusalo; para el caso comun de "una query a un proyecto" usa el pipeline
|
||||
`query_project_pg_py_pipelines` que ya compone esta resolucion con `pg_query`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: lee variables de entorno, el `.env` del proyecto en disco y ejecuta
|
||||
`pass show` como subproceso. El resultado depende del entorno de la maquina.
|
||||
- El `dsn` devuelto **contiene el password en claro**. NO lo logees ni lo
|
||||
imprimas en produccion (el `## Ejemplo` lo redacta a proposito).
|
||||
- La ruta del `.env` se resuelve relativa a `FN_REGISTRY_ROOT` si esa env var
|
||||
esta seteada; si no, relativa al cwd. Lanza desde la raiz del registry o
|
||||
exporta `FN_REGISTRY_ROOT` para que el paso (2) `.env` funcione.
|
||||
- Solo conoce los proyectos del mapa `_PROJECTS`. Anadir uno nuevo = una entrada
|
||||
de diccionario (env_var + dotenv_path + pass_path + pg fijos), no otro bloque
|
||||
de bash inline.
|
||||
- El fallback de `seo` apunta hoy al mismo entry de pass que `captacion`
|
||||
(mismo contenedor Postgres, distinta db `seo`). Si seo_analytics pasa a tener
|
||||
credenciales propias, actualiza `_PROJECTS['seo']`.
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Resuelve el DSN de PostgreSQL de un proyecto conocido del ecosistema.
|
||||
|
||||
Centraliza el patrón que el agente reescribía inline una y otra vez: leer el
|
||||
DSN de un proyecto desde su variable de entorno, caer al fichero ``.env`` del
|
||||
proyecto, y como último recurso construirlo desde el secreto guardado en
|
||||
``pass``. Cada proyecto declara su política de resolución en un mapa interno
|
||||
explícito (``_PROJECTS``), de modo que añadir un proyecto nuevo es una sola
|
||||
entrada de diccionario, no otra copia del bloque de bash.
|
||||
|
||||
Es una función impura (lee env, ficheros y ``pass``) que NUNCA lanza: devuelve
|
||||
un dict ``{status:'ok', ...}`` en éxito y ``{status:'error', error}`` en fallo,
|
||||
siguiendo el estilo del resto de funciones I/O del registry. El password sale
|
||||
de ``pass`` en runtime — jamás está hardcodeado en este módulo.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from infra.pass_get_secret import pass_get_secret
|
||||
|
||||
|
||||
# Mapa EXPLÍCITO de proyectos conocidos -> cómo resolver su DSN.
|
||||
#
|
||||
# Cada entrada declara:
|
||||
# env_var: variable de entorno que (si está seteada) gana sobre todo.
|
||||
# dotenv_path: ruta (relativa a la raíz del registry) del .env del proyecto.
|
||||
# La línea buscada dentro del .env es "<env_var>=<dsn>".
|
||||
# pass_path: ruta del secreto en `pass` desde la que construir el fallback.
|
||||
# pg: parámetros fijos para construir el DSN desde el secreto de pass.
|
||||
# user/host/port/db son estables por proyecto; el password es la
|
||||
# primera línea del secreto de pass y se lee en runtime.
|
||||
#
|
||||
# Los alias (claves múltiples que apuntan a la misma config) permiten llamar a
|
||||
# la función con el nombre corto ("captacion") o el largo ("captacion_clientes").
|
||||
_PROJECTS = {
|
||||
"captacion": {
|
||||
"env_var": "CAPTACION_DSN",
|
||||
"dotenv_path": "projects/captacion_clientes/.env",
|
||||
"pass_path": "captacion/postgres",
|
||||
"pg": {"user": "captacion", "host": "localhost", "port": "5433", "db": "trends"},
|
||||
},
|
||||
"seo": {
|
||||
"env_var": "SEO_DSN",
|
||||
# seo_analytics no fija un .env canónico hoy; se resuelve por env var
|
||||
# (la convención que ya usa ingest_gsc_search_analytics) o por pass.
|
||||
"dotenv_path": "projects/seo_analytics/.env",
|
||||
"pass_path": "captacion/postgres",
|
||||
"pg": {"user": "captacion", "host": "localhost", "port": "5433", "db": "seo"},
|
||||
},
|
||||
}
|
||||
|
||||
# Alias: nombre largo del proyecto -> clave canónica en _PROJECTS.
|
||||
_ALIASES = {
|
||||
"captacion_clientes": "captacion",
|
||||
"seo_analytics": "seo",
|
||||
}
|
||||
|
||||
|
||||
def _canonical(project: str) -> str:
|
||||
"""Normaliza el nombre del proyecto a su clave canónica en _PROJECTS."""
|
||||
key = (project or "").strip().lower()
|
||||
return _ALIASES.get(key, key)
|
||||
|
||||
|
||||
def _read_dotenv_line(dotenv_path: str, env_var: str) -> str:
|
||||
"""Devuelve el valor de la línea ``<env_var>=...`` del .env, o "" si no está.
|
||||
|
||||
Resuelve la ruta relativa a la raíz del registry usando FN_REGISTRY_ROOT si
|
||||
está disponible; en su defecto asume el cwd actual. Quita comillas dobles o
|
||||
simples envolventes del valor.
|
||||
"""
|
||||
root = os.environ.get("FN_REGISTRY_ROOT", "").strip()
|
||||
full = os.path.join(root, dotenv_path) if root else dotenv_path
|
||||
try:
|
||||
with open(full, "r", encoding="utf-8") as fh:
|
||||
prefix = env_var + "="
|
||||
for raw in fh:
|
||||
line = raw.strip()
|
||||
if line.startswith(prefix):
|
||||
value = line[len(prefix):].strip()
|
||||
if len(value) >= 2 and value[0] in "\"'" and value[-1] == value[0]:
|
||||
value = value[1:-1]
|
||||
return value
|
||||
except OSError:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_pg_dsn(project: str) -> dict:
|
||||
"""Resuelve el DSN PostgreSQL de un proyecto conocido sin lanzar.
|
||||
|
||||
Orden de resolución (gana el primero que tenga valor):
|
||||
1. La variable de entorno del proyecto (``env``).
|
||||
2. La línea ``<ENV_VAR>=<dsn>`` del ``.env`` del proyecto (``dotenv``).
|
||||
3. Un DSN construido a partir del secreto de ``pass`` (``pass``): el
|
||||
password es la primera línea del secreto; user/host/port/db son fijos
|
||||
por proyecto. El password NO se hardcodea: se lee en runtime.
|
||||
|
||||
Args:
|
||||
project: nombre del proyecto. Acepta la clave canónica ("captacion",
|
||||
"seo") o el alias largo ("captacion_clientes", "seo_analytics").
|
||||
|
||||
Returns:
|
||||
dict. En éxito: ``{status:'ok', project, dsn, source}`` donde ``source``
|
||||
es ``'env'`` | ``'dotenv'`` | ``'pass'`` según de dónde salió el DSN.
|
||||
En error (sin lanzar): ``{status:'error', error}`` (proyecto desconocido
|
||||
o no se pudo construir el DSN por ningún medio).
|
||||
"""
|
||||
canonical = _canonical(project)
|
||||
cfg = _PROJECTS.get(canonical)
|
||||
if cfg is None:
|
||||
known = ", ".join(sorted(set(_PROJECTS) | set(_ALIASES)))
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"unknown project '{project}'. Known: {known}",
|
||||
}
|
||||
|
||||
env_var = cfg["env_var"]
|
||||
|
||||
# 1. Variable de entorno (gana sobre todo).
|
||||
env_dsn = os.environ.get(env_var, "").strip()
|
||||
if env_dsn:
|
||||
return {"status": "ok", "project": canonical, "dsn": env_dsn, "source": "env"}
|
||||
|
||||
# 2. Línea del .env del proyecto.
|
||||
dotenv_dsn = _read_dotenv_line(cfg["dotenv_path"], env_var)
|
||||
if dotenv_dsn:
|
||||
return {"status": "ok", "project": canonical, "dsn": dotenv_dsn, "source": "dotenv"}
|
||||
|
||||
# 3. Fallback: construir desde el secreto de pass (password en runtime).
|
||||
secret = pass_get_secret(cfg["pass_path"], line=1)
|
||||
if secret.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"could not resolve DSN for '{canonical}': env var {env_var} unset, "
|
||||
f"no line in .env, and pass failed: {secret.get('error')}"
|
||||
),
|
||||
}
|
||||
password = secret["value"]
|
||||
pg = cfg["pg"]
|
||||
dsn = f"postgresql://{pg['user']}:{password}@{pg['host']}:{pg['port']}/{pg['db']}"
|
||||
return {"status": "ok", "project": canonical, "dsn": dsn, "source": "pass"}
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests para resolve_pg_dsn.
|
||||
|
||||
No tocan pass ni el disco salvo via monkeypatch sobre os.environ y un .env
|
||||
temporal. El fallback a pass se valida indirectamente (proyecto desconocido,
|
||||
prioridad del env var) sin invocar el subproceso real.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "functions"))
|
||||
|
||||
from infra.resolve_pg_dsn import resolve_pg_dsn
|
||||
|
||||
|
||||
def test_env_var_seteada_gana_y_source_es_env(monkeypatch):
|
||||
"""La env var del proyecto gana sobre .env y pass; source == 'env'."""
|
||||
expected = "postgresql://captacion:secret@localhost:5433/trends"
|
||||
monkeypatch.setenv("CAPTACION_DSN", expected)
|
||||
res = resolve_pg_dsn("captacion")
|
||||
assert res["status"] == "ok"
|
||||
assert res["dsn"] == expected
|
||||
assert res["source"] == "env"
|
||||
assert res["project"] == "captacion"
|
||||
|
||||
|
||||
def test_proyecto_desconocido_devuelve_error_sin_lanzar():
|
||||
"""Un proyecto no registrado devuelve {status:'error'} sin excepcion."""
|
||||
res = resolve_pg_dsn("no_existe_este_proyecto")
|
||||
assert res["status"] == "error"
|
||||
assert "unknown project" in res["error"]
|
||||
|
||||
|
||||
def test_alias_largo_resuelve_a_la_clave_canonica(monkeypatch):
|
||||
"""El alias largo 'seo_analytics' resuelve a la clave canonica 'seo'."""
|
||||
monkeypatch.setenv("SEO_DSN", "postgresql://captacion:x@localhost:5433/seo")
|
||||
res = resolve_pg_dsn("seo_analytics")
|
||||
assert res["status"] == "ok"
|
||||
assert res["project"] == "seo"
|
||||
assert res["source"] == "env"
|
||||
|
||||
|
||||
def test_fallback_a_dotenv_cuando_no_hay_env_var(monkeypatch, tmp_path):
|
||||
"""Sin env var, lee la linea <ENV_VAR>= del .env del proyecto; source == 'dotenv'."""
|
||||
monkeypatch.delenv("CAPTACION_DSN", raising=False)
|
||||
# Monta una raiz falsa con el .env del proyecto en la ruta esperada.
|
||||
proj_dir = tmp_path / "projects" / "captacion_clientes"
|
||||
proj_dir.mkdir(parents=True)
|
||||
dsn = "postgresql://captacion:fromdotenv@localhost:5433/trends"
|
||||
(proj_dir / ".env").write_text(f'CAPTACION_DSN="{dsn}"\n', encoding="utf-8")
|
||||
monkeypatch.setenv("FN_REGISTRY_ROOT", str(tmp_path))
|
||||
res = resolve_pg_dsn("captacion_clientes")
|
||||
assert res["status"] == "ok"
|
||||
assert res["dsn"] == dsn # comillas envolventes quitadas
|
||||
assert res["source"] == "dotenv"
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: metabase_client_from_pass
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_client_from_pass(pass_key: str, base_url: str, mode: str = 'auto') -> MetabaseClient | dict"
|
||||
description: "Lee las credenciales de Metabase desde pass y devuelve un MetabaseClient autenticado, en una sola llamada. Elimina el patron inline repetido de cargar la credencial del password store y montar el cliente. Soporta dos instancias: API-key (metabase/aurgi-api-key -> header X-API-KEY) y usuario/password (captacion/metabase multi-linea -> login POST /api/session). mode='auto' detecta el formato. Compone pass_get_secret + parse_metabase_secret + metabase_auth/MetabaseClient sin reimplementarlos. Devuelve el cliente o {status:error, error} sin lanzar."
|
||||
tags: [metabase, pass, secret, credential, auth, client]
|
||||
uses_functions: ["pass_get_secret_py_infra", "parse_metabase_secret_py_infra", "metabase_auth_py_infra"]
|
||||
uses_types: []
|
||||
params:
|
||||
- name: pass_key
|
||||
desc: "Ruta del secreto en el password store (p.ej. 'metabase/aurgi-api-key' o 'captacion/metabase')."
|
||||
- name: base_url
|
||||
desc: "URL base de la instancia Metabase (p.ej. 'https://reports.autingo.es' o 'http://localhost:3030')."
|
||||
- name: mode
|
||||
desc: "'api_key', 'session' o 'auto' (default). En auto se detecta el formato del secreto: una sola linea de clave -> api_key; multi-linea con email/usuario -> session."
|
||||
output: "MetabaseClient autenticado en exito. En fallo (sin lanzar): {status:'error', error:str} para secreto inexistente en pass, formato no parseable, o fallo de autenticacion contra Metabase."
|
||||
returns: []
|
||||
returns_optional: true
|
||||
error_type: "error_go_core"
|
||||
imports: [subprocess, httpx]
|
||||
tested: true
|
||||
tests: ["test_api_key_builds_client_with_x_api_key", "test_session_secret_parsed_and_auth_called", "test_auto_mode_detects_session", "test_missing_secret_returns_error_dict", "test_session_without_email_returns_error_dict"]
|
||||
test_file_path: "python/functions/metabase/metabase_client_from_pass_test.py"
|
||||
file_path: "python/functions/metabase/metabase_client_from_pass.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from metabase.metabase_client_from_pass import metabase_client_from_pass
|
||||
|
||||
# Aurgi (API-key en pass, header X-API-KEY):
|
||||
client = metabase_client_from_pass(
|
||||
"metabase/aurgi-api-key", "https://reports.autingo.es", mode="api_key")
|
||||
# client.request("GET", "/api/user/current")
|
||||
|
||||
# Captacion (usuario/password multi-linea en pass, login /api/session):
|
||||
client = metabase_client_from_pass(
|
||||
"captacion/metabase", "http://localhost:3030", mode="session")
|
||||
|
||||
# Sin especificar mode: se autodetecta por el formato del secreto.
|
||||
client = metabase_client_from_pass("metabase/aurgi-api-key", "https://reports.autingo.es")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un `MetabaseClient` autenticado y la credencial vive en `pass`:
|
||||
en vez de escribir a mano el `pass show ...` + parseo + `metabase_auth` /
|
||||
`MetabaseClient(...)`, llama a esta funcion con la ruta del secreto y la URL.
|
||||
Cubre tanto instancias con API-key (Aurgi) como con usuario/password
|
||||
(captacion). Es el punto de entrada unico para abrir un cliente desde el
|
||||
password store.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lanza el subproceso `pass show` y abre conexion HTTP a Metabase.
|
||||
El secreto nunca se logea, pero el `MetabaseClient` retornado lleva el token en
|
||||
memoria.
|
||||
- En `mode='session'` el secreto debe tener una linea de usuario con prefijo
|
||||
`email:` / `login:` / `username:` / `user:`; si falta, devuelve error dict (no
|
||||
lanza).
|
||||
- La deteccion de API-key se basa en que la clave empieza por `mb_` (lo gestiona
|
||||
`MetabaseClient`). Una API-key con otro prefijo se enviaria como session token
|
||||
-> usa `mode='api_key'` explicito si tu key no empieza por `mb_`.
|
||||
- Cierra el cliente cuando termines: `client.close()` o usalo como context
|
||||
manager (`with metabase_client_from_pass(...) as client:`).
|
||||
- Los fallos de auth (401, instancia caida) se devuelven como
|
||||
`{status:'error', error}` — comprueba el tipo del retorno antes de usarlo.
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Construye un MetabaseClient autenticado leyendo credenciales desde `pass`.
|
||||
|
||||
Elimina el patron inline repetido de "leer la credencial de Metabase del
|
||||
password store y montar un cliente autenticado", que hoy se reescribe a mano
|
||||
para dos instancias distintas:
|
||||
|
||||
- Aurgi: API-key (``metabase/aurgi-api-key``, una sola linea ``mb_...``) ->
|
||||
header ``X-API-KEY``.
|
||||
- Captacion: usuario/password (``captacion/metabase``, multi-linea: primera
|
||||
linea password, linea ``email:`` con el usuario) -> login via
|
||||
``POST /api/session``.
|
||||
|
||||
Compone tres funciones del registry: ``pass_get_secret`` (lee el secreto),
|
||||
``parse_metabase_secret`` (parser puro que distingue api_key vs session) y
|
||||
``metabase_auth`` / ``MetabaseClient`` (auth). No reimplementa ninguna.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from infra.pass_get_secret import pass_get_secret
|
||||
from metabase.parse_metabase_secret import parse_metabase_secret
|
||||
from metabase.client import MetabaseClient, metabase_auth
|
||||
|
||||
# Tope de lineas a leer del secreto. pass_get_secret devuelve una linea por
|
||||
# llamada; leemos hasta este maximo o hasta "out of range" para reconstruir el
|
||||
# texto completo sin reimplementar el subproceso de pass.
|
||||
_MAX_SECRET_LINES = 16
|
||||
|
||||
|
||||
def metabase_client_from_pass(
|
||||
pass_key: str,
|
||||
base_url: str,
|
||||
mode: str = "auto",
|
||||
) -> MetabaseClient | dict:
|
||||
"""Lee credenciales de Metabase de `pass` y devuelve un cliente autenticado.
|
||||
|
||||
Args:
|
||||
pass_key: ruta del secreto en el password store (p.ej.
|
||||
``"metabase/aurgi-api-key"`` o ``"captacion/metabase"``).
|
||||
base_url: URL base de la instancia Metabase (p.ej.
|
||||
``"https://reports.autingo.es"``).
|
||||
mode: ``"api_key"``, ``"session"`` o ``"auto"`` (default). En ``auto`` se
|
||||
detecta el formato del secreto: una sola linea de clave -> api_key;
|
||||
multi-linea con email/usuario -> session.
|
||||
|
||||
Returns:
|
||||
``MetabaseClient`` autenticado en exito. En caso de fallo (sin lanzar):
|
||||
``{"status": "error", "error": str}`` para: secreto no encontrado en
|
||||
pass, formato no parseable, o fallo de autenticacion contra Metabase.
|
||||
|
||||
Example:
|
||||
>>> client = metabase_client_from_pass(
|
||||
... "metabase/aurgi-api-key", "https://reports.autingo.es", mode="api_key")
|
||||
>>> client.request("GET", "/api/user/current") # doctest: +SKIP
|
||||
"""
|
||||
secret_text = _read_secret_text(pass_key)
|
||||
if isinstance(secret_text, dict):
|
||||
return secret_text # error dict de pass
|
||||
|
||||
parsed = parse_metabase_secret(secret_text, mode=mode)
|
||||
if parsed["status"] != "ok":
|
||||
return {"status": "error", "error": parsed["error"]}
|
||||
|
||||
try:
|
||||
if parsed["mode"] == "api_key":
|
||||
# MetabaseClient detecta "mb_" -> usa header X-API-KEY.
|
||||
return MetabaseClient(base_url, parsed["api_key"])
|
||||
# mode == "session": login con email/password via POST /api/session.
|
||||
return metabase_auth(base_url, parsed["email"], parsed["password"])
|
||||
except Exception as exc: # noqa: BLE001 - cualquier fallo de red/auth se reporta
|
||||
return {"status": "error", "error": f"metabase auth failed: {exc}"}
|
||||
|
||||
|
||||
def _read_secret_text(pass_key: str) -> str | dict:
|
||||
"""Reconstruye el texto multi-linea del secreto via pass_get_secret.
|
||||
|
||||
Llama a pass_get_secret linea por linea (1-indexed) hasta agotar las lineas
|
||||
("line N out of range") o llegar al tope. Devuelve el texto unido por
|
||||
``\\n`` o un dict de error si la primera lectura falla (pass no instalado,
|
||||
entry inexistente, etc.).
|
||||
"""
|
||||
first = pass_get_secret(pass_key, line=1)
|
||||
if first["status"] != "ok":
|
||||
return {"status": "error", "error": first["error"]}
|
||||
|
||||
lines = [first["value"]]
|
||||
for n in range(2, _MAX_SECRET_LINES + 1):
|
||||
res = pass_get_secret(pass_key, line=n)
|
||||
if res["status"] != "ok":
|
||||
break # out of range = fin del secreto
|
||||
lines.append(res["value"])
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Tests para metabase_client_from_pass (pass mockeado, sin red real)."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import metabase.metabase_client_from_pass as mod
|
||||
from metabase.client import MetabaseClient
|
||||
|
||||
|
||||
def _fake_pass(store: dict):
|
||||
"""Devuelve un pass_get_secret falso que lee de un dict {key: [lineas]}."""
|
||||
|
||||
def _impl(path, *, line=1, timeout_s=10.0):
|
||||
lines = store.get(path)
|
||||
if lines is None:
|
||||
return {"status": "error", "error": f"{path} is not in the password store"}
|
||||
if line < 1 or line > len(lines):
|
||||
return {"status": "error", "error": f"line {line} out of range"}
|
||||
return {"status": "ok", "value": lines[line - 1]}
|
||||
|
||||
return _impl
|
||||
|
||||
|
||||
def test_api_key_builds_client_with_x_api_key(monkeypatch):
|
||||
store = {"metabase/aurgi-api-key": ["mb_fakekey1234567890"]}
|
||||
monkeypatch.setattr(mod, "pass_get_secret", _fake_pass(store))
|
||||
|
||||
client = mod.metabase_client_from_pass(
|
||||
"metabase/aurgi-api-key", "https://reports.example.test", mode="api_key"
|
||||
)
|
||||
assert isinstance(client, MetabaseClient)
|
||||
assert client.token == "mb_fakekey1234567890"
|
||||
# API key -> header X-API-KEY (no toca red).
|
||||
assert client._http.headers.get("X-API-KEY") == "mb_fakekey1234567890"
|
||||
client.close()
|
||||
|
||||
|
||||
def test_session_secret_parsed_and_auth_called(monkeypatch):
|
||||
# Secreto multi-linea estilo captacion/metabase.
|
||||
store = {
|
||||
"captacion/metabase": [
|
||||
"hunter2pass",
|
||||
"email: admin@captacion.local",
|
||||
"url: http://localhost:3030",
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(mod, "pass_get_secret", _fake_pass(store))
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_auth(base_url, email, password):
|
||||
captured["base_url"] = base_url
|
||||
captured["email"] = email
|
||||
captured["password"] = password
|
||||
return MetabaseClient(base_url, "session_token_abc")
|
||||
|
||||
monkeypatch.setattr(mod, "metabase_auth", _fake_auth)
|
||||
|
||||
client = mod.metabase_client_from_pass(
|
||||
"captacion/metabase", "http://localhost:3030", mode="session"
|
||||
)
|
||||
assert isinstance(client, MetabaseClient)
|
||||
assert captured["email"] == "admin@captacion.local"
|
||||
assert captured["password"] == "hunter2pass"
|
||||
assert captured["base_url"] == "http://localhost:3030"
|
||||
client.close()
|
||||
|
||||
|
||||
def test_auto_mode_detects_session(monkeypatch):
|
||||
store = {"x/mb": ["pw", "login: bob@host.io"]}
|
||||
monkeypatch.setattr(mod, "pass_get_secret", _fake_pass(store))
|
||||
monkeypatch.setattr(
|
||||
mod, "metabase_auth", lambda b, e, p: MetabaseClient(b, "tok")
|
||||
)
|
||||
client = mod.metabase_client_from_pass("x/mb", "http://h", mode="auto")
|
||||
assert isinstance(client, MetabaseClient)
|
||||
client.close()
|
||||
|
||||
|
||||
def test_missing_secret_returns_error_dict(monkeypatch):
|
||||
monkeypatch.setattr(mod, "pass_get_secret", _fake_pass({}))
|
||||
res = mod.metabase_client_from_pass("does/not/exist", "http://h")
|
||||
assert isinstance(res, dict)
|
||||
assert res["status"] == "error"
|
||||
|
||||
|
||||
def test_session_without_email_returns_error_dict(monkeypatch):
|
||||
store = {"x/mb": ["onlypassword", "url: http://h"]}
|
||||
monkeypatch.setattr(mod, "pass_get_secret", _fake_pass(store))
|
||||
res = mod.metabase_client_from_pass("x/mb", "http://h", mode="session")
|
||||
assert isinstance(res, dict)
|
||||
assert res["status"] == "error"
|
||||
assert "email" in res["error"]
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: parse_metabase_secret
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def parse_metabase_secret(secret_text: str, mode: str = 'auto') -> dict"
|
||||
description: "Parser puro que extrae credenciales de Metabase del texto crudo de un secreto de pass. Distingue API-key (una sola linea, p.ej. mb_... de metabase/aurgi-api-key) de sesion (multi-linea estilo captacion/metabase: primera linea password, linea email:/user:/login:/username: con el usuario). mode='auto' detecta el formato; mode='api_key' o 'session' fuerzan. No hace I/O. Devuelve dict {status, mode, api_key} o {status, mode, email, password} o {status:error, error}. Nunca lanza ni logea el secreto."
|
||||
tags: [metabase, pass, secret, credential, parse, pure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
params:
|
||||
- name: secret_text
|
||||
desc: "Contenido completo del secreto leido de pass (varias lineas separadas por \\n). Primera linea = password/clave por convencion de pass; lineas siguientes = metadata."
|
||||
- name: mode
|
||||
desc: "'api_key', 'session' o 'auto' (default). En auto: si hay linea email/usuario reconocible -> session; si no -> api_key."
|
||||
output: "Dict. api_key: {status:'ok', mode:'api_key', api_key:str}. session: {status:'ok', mode:'session', email:str, password:str}. error: {status:'error', error:str} para texto vacio, modo invalido o session sin email/password."
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_api_key_explicit", "test_session_multiline_email_prefix", "test_auto_detects_session_when_email_present", "test_auto_detects_api_key_single_line", "test_session_without_email_line_errors", "test_empty_secret_errors", "test_invalid_mode_errors", "test_user_prefix_variant", "test_email_value_preserves_case"]
|
||||
test_file_path: "python/functions/metabase/parse_metabase_secret_test.py"
|
||||
file_path: "python/functions/metabase/parse_metabase_secret.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from metabase.parse_metabase_secret import parse_metabase_secret
|
||||
|
||||
# API-key (una sola linea):
|
||||
parse_metabase_secret("mb_abc123")
|
||||
# {'status': 'ok', 'mode': 'api_key', 'api_key': 'mb_abc123'}
|
||||
|
||||
# Sesion (multi-linea estilo captacion/metabase):
|
||||
parse_metabase_secret("hunter2\nemail: a@b.com\nurl: http://x")
|
||||
# {'status': 'ok', 'mode': 'session', 'email': 'a@b.com', 'password': 'hunter2'}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando ya tienes el texto de un secreto de Metabase (leido de `pass` u otra
|
||||
fuente) y necesitas separarlo en credenciales utilizables sin tocar disco ni
|
||||
red. Es el nucleo puro y testeable de `metabase_client_from_pass`: separa el
|
||||
parseo (determinista) de la lectura del secreto y la autenticacion (impuras).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- La linea del email/usuario se identifica por prefijo: `email:`, `login:`,
|
||||
`username:` o `user:` (case-insensitive). Otros formatos no se detectan como
|
||||
sesion y `auto` los tratara como api_key.
|
||||
- Funcion pura: NO lee `pass` ni llama a Metabase. El caller le pasa el texto ya
|
||||
resuelto. No logea el secreto, pero el dict de retorno SI lleva el valor en
|
||||
claro — el caller debe tratarlo como sensible.
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Parsea el texto de un secreto de `pass` para credenciales de Metabase.
|
||||
|
||||
Distingue dos formatos sin tocar disco ni red (funcion pura):
|
||||
|
||||
- API-key: una sola linea con la clave (las API keys de Metabase empiezan por
|
||||
``mb_``, p.ej. el secreto ``metabase/aurgi-api-key``).
|
||||
- Sesion: multi-linea estilo ``captacion/metabase`` — la primera linea es la
|
||||
contrasena y una linea posterior lleva el email/usuario con un prefijo
|
||||
reconocible (``email:``, ``user:``, ``login:`` o ``username:``).
|
||||
|
||||
El caller decide el ``mode`` y este parser solo extrae los campos del texto.
|
||||
"""
|
||||
|
||||
# Prefijos (case-insensitive) que identifican la linea del email/usuario en un
|
||||
# secreto multi-linea de pass. Se prueban en este orden.
|
||||
_EMAIL_PREFIXES = ("email:", "login:", "username:", "user:")
|
||||
|
||||
|
||||
def parse_metabase_secret(secret_text: str, mode: str = "auto") -> dict:
|
||||
"""Extrae credenciales de Metabase del texto crudo de un secreto de pass.
|
||||
|
||||
No ejecuta `pass` ni hace I/O: recibe el texto ya leido y lo interpreta.
|
||||
Funcion pura y determinista, apta para tests unitarios.
|
||||
|
||||
Args:
|
||||
secret_text: contenido completo del secreto (varias lineas separadas por
|
||||
``\\n``). Por convencion de pass la primera linea es la
|
||||
contrasena/clave; las siguientes son metadata.
|
||||
mode: ``"api_key"``, ``"session"`` o ``"auto"`` (default). En ``auto`` se
|
||||
detecta el formato: si hay una linea de email/usuario reconocible se
|
||||
asume sesion; si no, se asume api_key (una sola linea de clave).
|
||||
|
||||
Returns:
|
||||
Dict. Nunca lanza:
|
||||
|
||||
- api_key -> ``{"status": "ok", "mode": "api_key", "api_key": str}``
|
||||
- session -> ``{"status": "ok", "mode": "session", "email": str,
|
||||
"password": str}``
|
||||
- error -> ``{"status": "error", "error": str}`` para texto vacio, modo
|
||||
invalido, o session sin email/password localizables.
|
||||
|
||||
Example:
|
||||
>>> parse_metabase_secret("mb_abc123")
|
||||
{'status': 'ok', 'mode': 'api_key', 'api_key': 'mb_abc123'}
|
||||
>>> parse_metabase_secret("hunter2\\nemail: a@b.com\\nurl: http://x")
|
||||
{'status': 'ok', 'mode': 'session', 'email': 'a@b.com', 'password': 'hunter2'}
|
||||
"""
|
||||
if mode not in ("api_key", "session", "auto"):
|
||||
return {"status": "error", "error": f"invalid mode {mode!r}"}
|
||||
|
||||
lines = secret_text.splitlines()
|
||||
if not lines or not lines[0].strip():
|
||||
return {"status": "error", "error": "empty secret"}
|
||||
|
||||
email = _find_email(lines)
|
||||
|
||||
if mode == "auto":
|
||||
mode = "session" if email is not None else "api_key"
|
||||
|
||||
if mode == "api_key":
|
||||
return {
|
||||
"status": "ok",
|
||||
"mode": "api_key",
|
||||
"api_key": lines[0].strip(),
|
||||
}
|
||||
|
||||
# mode == "session"
|
||||
if email is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"session secret without email/user line "
|
||||
f"(expected one of {', '.join(_EMAIL_PREFIXES)})"
|
||||
),
|
||||
}
|
||||
password = lines[0].strip()
|
||||
if not password:
|
||||
return {"status": "error", "error": "session secret without password"}
|
||||
return {
|
||||
"status": "ok",
|
||||
"mode": "session",
|
||||
"email": email,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
|
||||
def _find_email(lines: list[str]) -> str | None:
|
||||
"""Devuelve el email/usuario de la primera linea con prefijo reconocido."""
|
||||
for raw in lines[1:]:
|
||||
low = raw.strip().lower()
|
||||
for prefix in _EMAIL_PREFIXES:
|
||||
if low.startswith(prefix):
|
||||
# Conserva el valor original (no el lowercased) tras el prefijo.
|
||||
value = raw.strip()[len(prefix):].strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Tests para parse_metabase_secret."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from metabase.parse_metabase_secret import parse_metabase_secret
|
||||
|
||||
|
||||
def test_api_key_explicit():
|
||||
res = parse_metabase_secret("mb_abc123def", mode="api_key")
|
||||
assert res == {"status": "ok", "mode": "api_key", "api_key": "mb_abc123def"}
|
||||
|
||||
|
||||
def test_session_multiline_email_prefix():
|
||||
secret = "hunter2\nemail: admin@captacion.local\nurl: http://localhost:3030"
|
||||
res = parse_metabase_secret(secret, mode="session")
|
||||
assert res == {
|
||||
"status": "ok",
|
||||
"mode": "session",
|
||||
"email": "admin@captacion.local",
|
||||
"password": "hunter2",
|
||||
}
|
||||
|
||||
|
||||
def test_auto_detects_session_when_email_present():
|
||||
secret = "secretpass\nlogin: bob@example.com"
|
||||
res = parse_metabase_secret(secret, mode="auto")
|
||||
assert res["mode"] == "session"
|
||||
assert res["email"] == "bob@example.com"
|
||||
assert res["password"] == "secretpass"
|
||||
|
||||
|
||||
def test_auto_detects_api_key_single_line():
|
||||
res = parse_metabase_secret("mb_singleLineKey", mode="auto")
|
||||
assert res["mode"] == "api_key"
|
||||
assert res["api_key"] == "mb_singleLineKey"
|
||||
|
||||
|
||||
def test_session_without_email_line_errors():
|
||||
res = parse_metabase_secret("onlypassword\nurl: http://x", mode="session")
|
||||
assert res["status"] == "error"
|
||||
assert "email" in res["error"]
|
||||
|
||||
|
||||
def test_empty_secret_errors():
|
||||
res = parse_metabase_secret("", mode="auto")
|
||||
assert res["status"] == "error"
|
||||
assert res["error"] == "empty secret"
|
||||
|
||||
|
||||
def test_invalid_mode_errors():
|
||||
res = parse_metabase_secret("mb_x", mode="bogus")
|
||||
assert res["status"] == "error"
|
||||
assert "invalid mode" in res["error"]
|
||||
|
||||
|
||||
def test_user_prefix_variant():
|
||||
secret = "pw\nuser: someone@host.io"
|
||||
res = parse_metabase_secret(secret, mode="auto")
|
||||
assert res["email"] == "someone@host.io"
|
||||
|
||||
|
||||
def test_email_value_preserves_case():
|
||||
secret = "pw\nEMAIL: MixedCase@Host.COM"
|
||||
res = parse_metabase_secret(secret, mode="session")
|
||||
assert res["email"] == "MixedCase@Host.COM"
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: build_relief_glb_from_image
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def build_relief_glb_from_image(image_path: str, out_glb_path: str, model_name: str = 'depth-anything/Depth-Anything-V2-Small-hf', device: str = 'auto', z_scale: float = 0.35, max_dim: int = 220) -> dict"
|
||||
description: "Pipeline one-shot imagen 2D -> .glb de relieve texturizado. Compone estimate_image_depth (profundidad monocular Depth-Anything-V2) + depth_to_relief_glb (malla heightmap + textura) en una sola llamada. Promueve a un paso la secuencia que img_to_3d_webapp hacia en dos (issue 0087). Grupo img-to-3d."
|
||||
tags: [img-to-3d, pipelines, depth, glb, gltf, mesh, relief, 3d, computer-vision, launcher]
|
||||
uses_functions: [estimate_image_depth_py_datascience, depth_to_relief_glb_py_datascience]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: image_path
|
||||
desc: "Ruta a la imagen de entrada (cualquier formato que PIL abra). Si no existe vuelve {status:error, stage:estimate}."
|
||||
- name: out_glb_path
|
||||
desc: "Ruta de salida del .glb. Su directorio padre debe existir o falla en la etapa relief (status error)."
|
||||
- name: model_name
|
||||
desc: "Id de modelo HuggingFace de depth-estimation. Default Depth-Anything-V2-Small-hf (rapido)."
|
||||
- name: device
|
||||
desc: "'auto' (GPU0 si hay), 'cpu', o indice/cadena cuda. Ver gotchas de estimate_image_depth para el detalle de 'cuda:N' vs indice entero."
|
||||
- name: z_scale
|
||||
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas exagerado."
|
||||
- name: max_dim
|
||||
desc: "Lado maximo del grid tras downsample (default 220, ~36k-48k vertices segun aspect ratio). Controla detalle vs peso del .glb."
|
||||
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int, model:str, device:str}. Error: {status:'error', stage:'estimate'|'relief', error:str}. No lanza. Salida JSON-serializable (sin ndarray ni PIL), apta para `fn run`."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/pipelines/build_relief_glb_from_image.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Requiere venv con torch + transformers + trimesh + pillow + numpy (p.ej. apps/img_to_3d_webapp/backend/.venv).
|
||||
./fn run build_relief_glb_from_image_py_pipelines apps/img_to_3d_webapp/samples/cats.jpg /tmp/cats_relief.glb
|
||||
# {"status": "ok", "glb_path": "/tmp/cats_relief.glb", "vertices": 36300, "faces": 71832, "height": 165, "width": 220, "model": "...", "device": "auto"}
|
||||
```
|
||||
|
||||
Como import (composicion en codigo):
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/pipelines")
|
||||
from build_relief_glb_from_image import build_relief_glb_from_image
|
||||
|
||||
res = build_relief_glb_from_image("apps/img_to_3d_webapp/samples/cats.jpg", "/tmp/cats_relief.glb")
|
||||
print(res["status"], res["vertices"], res["faces"]) # ok 36300 71832
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras el modelo 3D (.glb) directamente desde una imagen y NO necesites el mapa de
|
||||
profundidad intermedio para otra cosa. Es el atajo del grupo `img-to-3d`: una sola llamada en vez
|
||||
de orquestar `estimate_image_depth` + `depth_to_relief_glb` a mano. Si necesitas el `depth` por
|
||||
separado (para inspeccionarlo, reusarlo, o aplicar otra reconstruccion), llama a las dos funciones
|
||||
sueltas en su lugar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impuro (pipeline)**: carga modelo HuggingFace (descarga pesos la 1a vez), usa GPU/CPU y
|
||||
escribe el .glb en disco. Hereda todos los gotchas de `estimate_image_depth_py_datascience` y
|
||||
`depth_to_relief_glb_py_datascience` (estado de proceso del pipeline cacheado, profundidad
|
||||
relativa, relieve 2.5D, directorio de salida debe existir).
|
||||
- **Deps de vision**: requiere `torch`+`transformers`+`trimesh`+`pillow`+`numpy`. Importa las dos
|
||||
funciones del registry de forma PLANA (anade `python/functions/datascience` a `sys.path`) para
|
||||
NO arrastrar el `datascience.__init__` (que trae bs4/duckdb de otros dominios). Por eso `fn run`
|
||||
de este pipeline corre limpio en el venv de vision sin necesitar las deps de los scrapers.
|
||||
- **stage en el error**: el campo `stage` (`estimate` o `relief`) indica en cual de los dos pasos
|
||||
fallo, util para depurar (p.ej. ruta de imagen mala -> stage estimate; dir de salida inexistente
|
||||
-> stage relief).
|
||||
- Tag `launcher`: aparece en el Pipeline Launcher TUI; es un subproceso one-shot (no interactivo).
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Pipeline one-shot imagen -> modelo 3D: estima la profundidad monocular y reconstruye una malla
|
||||
de relieve texturizada exportada a .glb, en una sola llamada.
|
||||
|
||||
Compone (grupo de capacidad `img-to-3d`):
|
||||
estimate_image_depth_py_datascience -> depth_to_relief_glb_py_datascience
|
||||
|
||||
Promueve a un solo paso la secuencia que la app `img_to_3d_webapp` hacia en dos (issue 0087):
|
||||
en vez de orquestar estimate + relief a mano, el caller pasa la ruta de la imagen y la del .glb.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Import PLANO de las funciones del registry (no `from datascience import ...`): el __init__ del
|
||||
# paquete datascience arrastra deps de otros dominios (bs4, duckdb...) ausentes en el venv de
|
||||
# vision (torch/transformers/trimesh). Importar los modulos directos evita esa dependencia.
|
||||
_DS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "datascience")
|
||||
if _DS_DIR not in sys.path:
|
||||
sys.path.insert(0, _DS_DIR)
|
||||
|
||||
from estimate_image_depth import estimate_image_depth # noqa: E402
|
||||
from depth_to_relief_glb import depth_to_relief_glb # noqa: E402
|
||||
|
||||
|
||||
def build_relief_glb_from_image(
|
||||
image_path: str,
|
||||
out_glb_path: str,
|
||||
model_name: str = "depth-anything/Depth-Anything-V2-Small-hf",
|
||||
device: str = "auto",
|
||||
z_scale: float = 0.35,
|
||||
max_dim: int = 220,
|
||||
) -> dict:
|
||||
"""
|
||||
Convierte una imagen 2D en un .glb de relieve texturizado en una sola llamada.
|
||||
|
||||
Parámetros:
|
||||
image_path: ruta a la imagen de entrada (lo que PIL abra).
|
||||
out_glb_path: ruta de salida del .glb (su directorio padre debe existir).
|
||||
model_name: id de modelo HuggingFace de depth-estimation.
|
||||
device: "auto" / "cpu" / índice o cadena cuda.
|
||||
z_scale: amplitud del relieve (fracción del lado de la malla).
|
||||
max_dim: lado máximo del grid tras downsample.
|
||||
|
||||
Devuelve (dict, nunca lanza):
|
||||
Éxito: {"status":"ok", "glb_path":str, "vertices":int, "faces":int, "height":int,
|
||||
"width":int, "model":str, "device":str}.
|
||||
Error: {"status":"error", "stage":"estimate"|"relief", "error":str}.
|
||||
"""
|
||||
est = estimate_image_depth(image_path, model_name=model_name, device=device)
|
||||
if est.get("status") != "ok":
|
||||
return {"status": "error", "stage": "estimate", "error": est.get("error", "unknown")}
|
||||
|
||||
res = depth_to_relief_glb(
|
||||
est["image"], est["depth"], out_glb_path, z_scale=z_scale, max_dim=max_dim
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
return {"status": "error", "stage": "relief", "error": res.get("error", "unknown")}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"glb_path": res["glb_path"],
|
||||
"vertices": res["vertices"],
|
||||
"faces": res["faces"],
|
||||
"height": res["height"],
|
||||
"width": res["width"],
|
||||
"model": est["model"],
|
||||
"device": est["device"],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo directo: `python build_relief_glb_from_image.py <image_path> <out.glb> [z_scale] [max_dim]`.
|
||||
import json
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print(json.dumps({"status": "error", "error": "uso: <image_path> <out_glb_path> [z_scale] [max_dim]"}))
|
||||
sys.exit(1)
|
||||
|
||||
zs = float(sys.argv[3]) if len(sys.argv) > 3 else 0.35
|
||||
md = int(sys.argv[4]) if len(sys.argv) > 4 else 220
|
||||
out = build_relief_glb_from_image(sys.argv[1], sys.argv[2], z_scale=zs, max_dim=md)
|
||||
print(json.dumps(out))
|
||||
if out["status"] != "ok":
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: ingest_market_trends_headless
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def ingest_market_trends_headless(sources: str = '', port: int = 9334, profile_dir: str = '') -> dict"
|
||||
description: "Ingesta de las fuentes CDP de tendencias (AliExpress / Amazon movers / saturación Amazon) en un Chrome headless AISLADO con perfil dedicado, lanzándolo y cerrándolo en cada corrida. Evita abrir pestañas en el navegador diario del usuario (chromium-personal, CDP 9222). Wrapper de ingest_market_trends. Proyecto captacion_clientes."
|
||||
tags: [market-intel, headless, cdp, dropship, scraper, ingest]
|
||||
uses_functions: [ingest_market_trends_py_pipelines]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/pipelines/ingest_market_trends_headless.py"
|
||||
params:
|
||||
- name: sources
|
||||
desc: "Fuentes CDP separadas por coma. Vacío -> las 3 del proyecto (aliexpress_cdp,amazon_movers_cdp,amazon_saturation_cdp). Un source que falle no aborta el resto."
|
||||
- name: port
|
||||
desc: "Puerto de remote-debugging del Chrome headless aislado. DEBE coincidir con el `port` de los bloques *_cdp en sources.json (allí ya está a 9334). Default 9334."
|
||||
- name: profile_dir
|
||||
desc: "user-data-dir dedicado del Chrome aislado. Vacío -> ~/.config/fn_scrape_chrome (se crea si no existe). Perfil persistente entre corridas."
|
||||
output: "dict {status: 'ok'|'error', port, profile_dir, launched: bool, closed: bool, results: [{source, scraped, inserted} | {source, error}]}. Nunca lanza excepción al caller: el finally cierra siempre la instancia que lanzó."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Las 3 fuentes CDP en Chrome headless aislado (lanzar -> scrape -> cerrar).
|
||||
# OJO: fn run pasa los args POSICIONALES (no flags --), en el orden sources, port, profile_dir.
|
||||
fn run ingest_market_trends_headless
|
||||
# -> {"status":"ok","port":9334,"profile_dir":"/home/<user>/.config/fn_scrape_chrome",
|
||||
# "launched":true,"closed":true,
|
||||
# "results":[{"source":"aliexpress_cdp",...},{"source":"amazon_movers_cdp",...},
|
||||
# {"source":"amazon_saturation_cdp","scraped":20,"inserted":20}]}
|
||||
|
||||
# Solo la fuente de saturación de Amazon (la validada headless sin login):
|
||||
fn run ingest_market_trends_headless amazon_saturation_cdp
|
||||
# -> {"status":"ok","port":9334,"launched":true,"closed":true,
|
||||
# "results":[{"source":"amazon_saturation_cdp","scraped":5,"inserted":5}]}
|
||||
|
||||
# Puerto/perfil custom (args posicionales: sources, port, profile_dir):
|
||||
fn run ingest_market_trends_headless amazon_saturation_cdp 9340 ~/.config/otro_scrape
|
||||
```
|
||||
|
||||
Invocación directa del módulo (acepta flags `--sources`/`--port`/`--profile-dir`):
|
||||
|
||||
```bash
|
||||
python/.venv/bin/python3 python/functions/pipelines/ingest_market_trends_headless.py \
|
||||
--sources amazon_saturation_cdp
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala para la ingesta diaria/programada (dag_engine) de las fuentes CDP del proyecto
|
||||
captacion_clientes cuando NO quieras que el scraping abra pestañas en tu navegador diario.
|
||||
Levanta su propio Chromium headless con perfil dedicado y lo cierra al terminar — el
|
||||
navegador personal (`chromium-personal`, CDP 9222) queda intacto. Es el reemplazo de llamar
|
||||
`ingest_market_trends <source_cdp>` a pelo (que usaría el 9222 con sesión interactiva).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: lanza y mata Chrome.** Arranca un Chromium headless vía `systemd-run --user`
|
||||
(scope `fnscrape_dag_<port>`); si `systemd-run` no está, cae a `subprocess.Popen` con
|
||||
grupo de proceso propio. Lo lanzar con `exec` directo desde el agente da **exit-144** — por
|
||||
eso systemd-run. En el `finally` siempre cierra lo que lanzó (`systemctl --user stop` del
|
||||
scope/service + respaldo `pkill -f "user-data-dir=<perfil>"`) y verifica con un GET final
|
||||
que el puerto ya no responde (`closed`).
|
||||
- **Perfil dedicado persistente.** `~/.config/fn_scrape_chrome` sobrevive entre corridas
|
||||
(cookies/cache del scraping). No se borra. Bórralo a mano si quieres sesión limpia.
|
||||
- **Reutiliza CDP existente.** Si el puerto ya responde al arrancar, NO lanza otro Chrome:
|
||||
reutiliza el vivo y `launched=False` + `closed=False` (no cierra algo que no abrió).
|
||||
- **Puerto = el del config.** Los bloques `*_cdp` de `sources.json` deben tener `port`
|
||||
igual al `port` de este wrapper (ya a 9334). `ingest_market_trends(source)` lee el puerto
|
||||
del config, así reutiliza la instancia que este wrapper levantó. No se le pasa el puerto.
|
||||
- **AliExpress headless puede pedir captcha.** Sin login, `aliexpress_cdp` puede devolver
|
||||
`status: captcha` (0 filas) — es esperado, no es bug del wrapper. El lifecycle
|
||||
(launched -> scrape -> closed) se valida mejor con `amazon_saturation_cdp`.
|
||||
- **DSN Postgres.** La ingesta necesita el DSN de `trends` (CAPTACION_DSN / .env / pass
|
||||
captacion/postgres). Si falla la resolución, esa fuente cae en `{source, error}` pero el
|
||||
Chrome igual se cierra.
|
||||
@@ -0,0 +1,287 @@
|
||||
"""ingest_market_trends_headless — ingesta de fuentes CDP en un Chrome headless aislado.
|
||||
|
||||
Wrapper de `ingest_market_trends` (pipeline del proyecto captacion_clientes) que lanza un
|
||||
Chromium **headless** con un **perfil dedicado** y un puerto de remote-debugging propio,
|
||||
scrapea las fuentes CDP indicadas, y **cierra la instancia al terminar** — siempre, incluso
|
||||
si falla el scraping.
|
||||
|
||||
Motivo: el scraping NO debe abrir pestañas en el navegador diario del usuario
|
||||
(`chromium-personal`, puerto 9222). Norma: perfil dedicado + headless + cerrar al terminar.
|
||||
|
||||
El Chrome se lanza vía `systemd-run --user` (un scope transitorio), porque lanzar chromium
|
||||
con un `exec`/`Popen` directo desde el proceso del agente da exit-144 cuando hereda el grupo
|
||||
de controlado del agente. Si `systemd-run` no está disponible, se cae a `subprocess.Popen`
|
||||
en un grupo de proceso nuevo (`start_new_session=True`).
|
||||
|
||||
Las fuentes CDP (`aliexpress_cdp`, `amazon_movers_cdp`, `amazon_saturation_cdp`) leen su
|
||||
`port` del config `sources.json` del proyecto. Ese config debe apuntar al MISMO puerto que
|
||||
este wrapper usa para lanzar el Chrome headless (default 9334). De ese modo
|
||||
`ingest_market_trends(source)` reutiliza la instancia que este wrapper levantó.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
sys.path.insert(0, os.path.join(ROOT, "python", "functions"))
|
||||
|
||||
from pipelines.ingest_market_trends import ingest_market_trends # noqa: E402
|
||||
|
||||
DEFAULT_SOURCES = "aliexpress_cdp,amazon_movers_cdp,amazon_saturation_cdp"
|
||||
DEFAULT_PORT = 9334
|
||||
DEFAULT_PROFILE = "~/.config/fn_scrape_chrome"
|
||||
|
||||
# Candidatos de binario chromium/chrome. shutil.which primero (respeta PATH), luego
|
||||
# rutas absolutas conocidas del sistema (el `chromium` del usuario suele ser un alias de
|
||||
# shell no visible a subprocess, y el binario real vive en /usr/lib/chromium/chromium).
|
||||
_CHROME_NAMES = ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable")
|
||||
_CHROME_ABS = (
|
||||
"/usr/bin/chromium",
|
||||
"/usr/lib/chromium/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/snap/bin/chromium",
|
||||
)
|
||||
|
||||
|
||||
def _find_chrome() -> str | None:
|
||||
"""Devuelve la ruta a un binario chromium/chrome ejecutable, o None."""
|
||||
for name in _CHROME_NAMES:
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
for path in _CHROME_ABS:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _cdp_alive(port: int, timeout: float = 1.0) -> bool:
|
||||
"""True si el endpoint CDP responde en 127.0.0.1:<port>/json/version."""
|
||||
url = f"http://127.0.0.1:{port}/json/version"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
return 200 <= resp.status < 300
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _wait_cdp(port: int, deadline_s: float = 12.0) -> bool:
|
||||
"""Espera a que el CDP responda hasta `deadline_s` (sondea cada 0.5s)."""
|
||||
end = time.time() + deadline_s
|
||||
while time.time() < end:
|
||||
if _cdp_alive(port):
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def _chrome_args(chrome_bin: str, port: int, profile_dir: str) -> list[str]:
|
||||
return [
|
||||
chrome_bin,
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
f"--remote-debugging-port={port}",
|
||||
f"--user-data-dir={profile_dir}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--remote-allow-origins=*",
|
||||
"--disable-extensions",
|
||||
]
|
||||
|
||||
|
||||
def _launch(chrome_bin: str, port: int, profile_dir: str) -> tuple[str, int | None]:
|
||||
"""Lanza Chrome headless aislado. Devuelve (mecanismo, pid).
|
||||
|
||||
mecanismo: 'systemd' (scope transitorio) o 'popen' (grupo de proceso propio).
|
||||
pid: solo poblado en modo 'popen' (para poder matar el grupo en el cierre).
|
||||
"""
|
||||
unit = f"fnscrape_dag_{port}"
|
||||
systemd_run = shutil.which("systemd-run")
|
||||
if systemd_run:
|
||||
cmd = [
|
||||
systemd_run, "--user", "--quiet", "--collect", f"--unit={unit}",
|
||||
*_chrome_args(chrome_bin, port, profile_dir),
|
||||
]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, timeout=15,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return "systemd", None
|
||||
except Exception: # noqa: BLE001
|
||||
# systemd-run falló (sin --user bus, etc.) -> fallback a Popen.
|
||||
pass
|
||||
|
||||
proc = subprocess.Popen(
|
||||
_chrome_args(chrome_bin, port, profile_dir),
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return "popen", proc.pid
|
||||
|
||||
|
||||
def _close(mechanism: str, pid: int | None, port: int, profile_dir: str) -> bool:
|
||||
"""Cierra la instancia que ESTE wrapper lanzó. Devuelve True si el puerto ya no responde."""
|
||||
unit = f"fnscrape_dag_{port}"
|
||||
if mechanism == "systemd":
|
||||
systemctl = shutil.which("systemctl")
|
||||
if systemctl:
|
||||
for kind in (f"{unit}.scope", f"{unit}.service"):
|
||||
try:
|
||||
subprocess.run([systemctl, "--user", "stop", kind],
|
||||
timeout=10, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
elif mechanism == "popen" and pid is not None:
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
for _ in range(20): # hasta ~2s para salida limpia
|
||||
time.sleep(0.1)
|
||||
if not _cdp_alive(port):
|
||||
break
|
||||
if _cdp_alive(port):
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# Respaldo: matar cualquier chromium colgado de este perfil concreto.
|
||||
pkill = shutil.which("pkill")
|
||||
if pkill:
|
||||
try:
|
||||
subprocess.run([pkill, "-f", f"user-data-dir={profile_dir}"],
|
||||
timeout=10, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# Esperar a que el puerto deje de responder (cierre asíncrono del cgroup).
|
||||
for _ in range(20): # hasta ~2s
|
||||
if not _cdp_alive(port):
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return not _cdp_alive(port)
|
||||
|
||||
|
||||
def ingest_market_trends_headless(
|
||||
sources: str = "",
|
||||
port: int = DEFAULT_PORT,
|
||||
profile_dir: str = "",
|
||||
) -> dict:
|
||||
"""Lanza un Chrome headless aislado, scrapea las fuentes CDP y lo cierra al terminar.
|
||||
|
||||
Args:
|
||||
sources: fuentes CDP separadas por coma. Vacío -> las 3 del proyecto
|
||||
(aliexpress_cdp,amazon_movers_cdp,amazon_saturation_cdp).
|
||||
port: puerto de remote-debugging del Chrome aislado (debe coincidir con el `port`
|
||||
de los bloques `*_cdp` en sources.json). Default 9334.
|
||||
profile_dir: user-data-dir dedicado. Vacío -> ~/.config/fn_scrape_chrome.
|
||||
|
||||
Returns:
|
||||
dict con {status, port, profile_dir, launched, closed, results:[...]}. Nunca lanza
|
||||
excepción al caller: cualquier fallo se refleja en `status`/`results` y el finally
|
||||
cierra la instancia.
|
||||
"""
|
||||
if not sources:
|
||||
sources = DEFAULT_SOURCES
|
||||
if not profile_dir:
|
||||
profile_dir = os.path.expanduser(DEFAULT_PROFILE)
|
||||
profile_dir = os.path.abspath(os.path.expanduser(profile_dir))
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
|
||||
out: dict = {
|
||||
"status": "error",
|
||||
"port": port,
|
||||
"profile_dir": profile_dir,
|
||||
"launched": False,
|
||||
"closed": False,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
mechanism = ""
|
||||
pid: int | None = None
|
||||
reuse = False
|
||||
|
||||
# 1) Si ya hay un CDP vivo en el puerto, reutilizarlo (no lo cerraremos).
|
||||
if _cdp_alive(port):
|
||||
reuse = True
|
||||
else:
|
||||
chrome_bin = _find_chrome()
|
||||
if not chrome_bin:
|
||||
out["error"] = (
|
||||
"no se encontró binario chromium/chrome "
|
||||
f"(probados: {', '.join(_CHROME_NAMES)} + rutas absolutas conocidas)"
|
||||
)
|
||||
return out
|
||||
try:
|
||||
mechanism, pid = _launch(chrome_bin, port, profile_dir)
|
||||
out["launched"] = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
out["error"] = f"fallo al lanzar chromium: {exc}"
|
||||
return out
|
||||
|
||||
# 2) Esperar a que el CDP responda.
|
||||
if not _wait_cdp(port, deadline_s=12.0):
|
||||
out["error"] = f"el CDP no respondió en 127.0.0.1:{port} tras 12s"
|
||||
out["closed"] = _close(mechanism, pid, port, profile_dir)
|
||||
return out
|
||||
|
||||
# 3) Scrapear cada fuente. Un fallo de una fuente no aborta el resto.
|
||||
try:
|
||||
for raw in sources.split(","):
|
||||
source = raw.strip()
|
||||
if not source:
|
||||
continue
|
||||
try:
|
||||
summary = ingest_market_trends(source)
|
||||
out["results"].append({
|
||||
"source": source,
|
||||
"scraped": summary.get("scraped"),
|
||||
"inserted": summary.get("inserted"),
|
||||
**({"note": summary["note"]} if summary.get("note") else {}),
|
||||
**({"status": summary["status"]} if summary.get("status") else {}),
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
out["results"].append({"source": source, "error": str(exc)})
|
||||
out["status"] = "ok"
|
||||
finally:
|
||||
# 4) Cerrar SIEMPRE lo que nosotros lanzamos (no si reutilizamos uno ajeno).
|
||||
if out["launched"] and not reuse:
|
||||
out["closed"] = _close(mechanism, pid, port, profile_dir)
|
||||
else:
|
||||
out["closed"] = False
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Ingesta de fuentes CDP en un Chrome headless aislado (perfil dedicado)."
|
||||
)
|
||||
ap.add_argument("--sources", default="",
|
||||
help=f"Fuentes CDP separadas por coma. Vacío -> {DEFAULT_SOURCES}.")
|
||||
ap.add_argument("--port", type=int, default=DEFAULT_PORT,
|
||||
help="Puerto remote-debugging del Chrome aislado.")
|
||||
ap.add_argument("--profile-dir", default="",
|
||||
help="user-data-dir dedicado (vacío -> ~/.config/fn_scrape_chrome).")
|
||||
args = ap.parse_args()
|
||||
|
||||
result = ingest_market_trends_headless(
|
||||
sources=args.sources, port=args.port, profile_dir=args.profile_dir,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0 if result.get("status") == "ok" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -38,8 +38,9 @@ from datascience import (
|
||||
run_eda_models,
|
||||
summarize_categorical,
|
||||
summarize_table_duckdb,
|
||||
summarize_table_pg,
|
||||
)
|
||||
from infra import duckdb_query_readonly
|
||||
from infra import duckdb_query_readonly, pg_query
|
||||
|
||||
# semantic_types que justifican promocionar inferred_type -> "numeric".
|
||||
_NUMERIC_SEMANTIC = ("integer", "decimal", "currency")
|
||||
@@ -82,10 +83,13 @@ def _to_float(value):
|
||||
return None
|
||||
|
||||
|
||||
def _sample_values(db_path: str, table: str, name: str, sample: int) -> list:
|
||||
"""Trae hasta `sample` valores no nulos de una columna (read-only)."""
|
||||
q = duckdb_query_readonly(
|
||||
db_path,
|
||||
def _sample_values(query_fn, table: str, name: str, sample: int) -> list:
|
||||
"""Trae hasta `sample` valores no nulos de una columna (read-only).
|
||||
|
||||
query_fn(sql) -> dict es el lector read-only del backend activo
|
||||
(duckdb_query_readonly o pg_query), inyectado por profile_table.
|
||||
"""
|
||||
q = query_fn(
|
||||
f'SELECT "{name}" AS v FROM "{table}" WHERE "{name}" IS NOT NULL '
|
||||
f"LIMIT {int(sample)}",
|
||||
)
|
||||
@@ -94,19 +98,18 @@ def _sample_values(db_path: str, table: str, name: str, sample: int) -> list:
|
||||
return [row.get("v") for row in q.get("rows", [])]
|
||||
|
||||
|
||||
def _sample_rows(db_path: str, table: str, names: list, sample: int) -> list:
|
||||
def _sample_rows(query_fn, table: str, names: list, sample: int) -> list:
|
||||
"""Trae hasta `sample` filas completas con las columnas alineadas por fila.
|
||||
|
||||
A diferencia de _sample_values (una columna, solo no nulos), esto preserva la
|
||||
alineacion por fila entre columnas, requisito de la matriz de asociacion
|
||||
(los pares (a_i, b_i) deben venir de la misma fila).
|
||||
(los pares (a_i, b_i) deben venir de la misma fila). query_fn es el lector
|
||||
read-only del backend activo, inyectado por profile_table.
|
||||
"""
|
||||
if not names:
|
||||
return []
|
||||
cols_sql = ", ".join(f'"{n}"' for n in names)
|
||||
q = duckdb_query_readonly(
|
||||
db_path, f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}'
|
||||
)
|
||||
q = query_fn(f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}')
|
||||
if q.get("status") != "ok":
|
||||
return []
|
||||
return q.get("rows", [])
|
||||
@@ -115,17 +118,20 @@ def _sample_rows(db_path: str, table: str, names: list, sample: int) -> list:
|
||||
def profile_table(
|
||||
db_path: str,
|
||||
table: str,
|
||||
backend: str = "duckdb",
|
||||
sample: int = 5000,
|
||||
run_models: bool = False,
|
||||
run_llm: bool = False,
|
||||
report_dir: str = "reports",
|
||||
write_report: bool = True,
|
||||
) -> dict:
|
||||
"""Perfila una tabla DuckDB end-to-end y emite el TableProfile completo.
|
||||
"""Perfila una tabla (DuckDB o PostgreSQL) end-to-end y emite el TableProfile.
|
||||
|
||||
Args:
|
||||
db_path: ruta al archivo DuckDB (read-only, debe existir).
|
||||
db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres".
|
||||
table: nombre de la tabla a perfilar.
|
||||
backend: "duckdb" (default) o "postgres". Selecciona el motor de
|
||||
perfilado base (summarize) y de muestreo read-only.
|
||||
sample: maximo de valores no nulos muestreados por columna para el
|
||||
enriquecimiento (describe_numeric / summarize_categorical /
|
||||
infer_semantic_type). Default 5000.
|
||||
@@ -141,8 +147,22 @@ def profile_table(
|
||||
lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
# 1) Perfil base por columna (push-down SQL).
|
||||
r = summarize_table_duckdb(db_path, table)
|
||||
# 1) Perfil base por columna (push-down SQL) + lector read-only del
|
||||
# backend activo, inyectado en el muestreo (_sample_values/_sample_rows).
|
||||
if backend == "postgres":
|
||||
r = summarize_table_pg(db_path, table)
|
||||
|
||||
def _q(sql):
|
||||
return pg_query(db_path, sql)
|
||||
|
||||
elif backend == "duckdb":
|
||||
r = summarize_table_duckdb(db_path, table)
|
||||
|
||||
def _q(sql):
|
||||
return duckdb_query_readonly(db_path, sql)
|
||||
|
||||
else:
|
||||
return {"status": "error", "error": f"backend desconocido: {backend}"}
|
||||
if r.get("status") != "ok":
|
||||
return {"status": "error", "error": r.get("error", "summarize failed")}
|
||||
prof = r["profile"]
|
||||
@@ -153,7 +173,7 @@ def profile_table(
|
||||
inferred = col.get("inferred_type")
|
||||
|
||||
# 2) Muestra de valores no nulos.
|
||||
vals = _sample_values(db_path, table, name, sample)
|
||||
vals = _sample_values(_q, table, name, sample)
|
||||
|
||||
# 3) Promocion de tipo sobre columnas textuales.
|
||||
if inferred in ("categorical", "text"):
|
||||
@@ -239,7 +259,7 @@ def profile_table(
|
||||
|
||||
assoc_cols = [c for c in cols if not _skip_for_assoc(c)]
|
||||
rows = _sample_rows(
|
||||
db_path, table, [c["name"] for c in assoc_cols], corr_sample
|
||||
_q, table, [c["name"] for c in assoc_cols], corr_sample
|
||||
)
|
||||
assoc_input = {}
|
||||
for c in assoc_cols:
|
||||
@@ -256,12 +276,18 @@ def profile_table(
|
||||
prof["correlations"] = (
|
||||
association_matrix(assoc_input) if len(assoc_input) >= 2 else None
|
||||
)
|
||||
# Modelos baratos opt-in (PCA/KMeans/IsolationForest/normalidad).
|
||||
if run_models:
|
||||
prof["models"] = run_eda_models(assoc_input)
|
||||
except Exception: # noqa: BLE001
|
||||
prof["correlations"] = None
|
||||
prof["models"] = None
|
||||
assoc_input = {}
|
||||
|
||||
# Modelos baratos opt-in en su PROPIO try: un fallo de los modelos NUNCA
|
||||
# debe tumbar las correlaciones ya calculadas (bug detectado en EDAs PG
|
||||
# reales: un try/except compartido ponia ambos campos a None).
|
||||
if run_models:
|
||||
try:
|
||||
prof["models"] = run_eda_models(assoc_input)
|
||||
except Exception: # noqa: BLE001
|
||||
prof["models"] = None
|
||||
|
||||
# 8.6) Capa LLM opcional: interpreta el perfil ya calculado en UNA
|
||||
# llamada (data dictionary, resumen, granularidad de fila, PII, limpieza,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: query_project_pg
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def query_project_pg(project: str, sql: str, max_rows: int = 10000) -> dict"
|
||||
description: "Pipeline one-shot que ejecuta un SELECT contra el Postgres de un proyecto conocido del ecosistema (captacion_clientes, seo_analytics) sin reescribir la resolucion del DSN ni la conexion a mano. Compone resolve_pg_dsn(project) (resuelve el DSN desde env var / .env / pass) con pg_query(dsn, sql, max_rows) (SELECT read-only via psycopg2 que devuelve filas como list[dict]). Elimina el patron inline que el agente repetia: grep al .env + fallback a pass + psql crudo. El caller solo pasa el nombre del proyecto y el SQL; el password sale de pass en runtime, nunca hardcodeado. Devuelve lo que devuelve pg_query en exito {status:'ok', columns, rows, row_count, truncated}, o propaga el {status:'error', error} de resolve_pg_dsn si falla la resolucion del DSN (sin tocar Postgres). Sin lanzar."
|
||||
tags: [postgres, postgresql, sql, query, pipelines]
|
||||
uses_functions: [resolve_pg_dsn_py_infra, pg_query_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/pipelines/query_project_pg.py"
|
||||
params:
|
||||
- name: project
|
||||
desc: "Nombre del proyecto conocido. Acepta clave canonica ('captacion', 'seo') o alias largo ('captacion_clientes', 'seo_analytics'). Se pasa tal cual a resolve_pg_dsn; un proyecto desconocido propaga su {status:'error'}."
|
||||
- name: sql
|
||||
desc: "Sentencia SQL a ejecutar (pensada para SELECT). Este pipeline no expone parametros posicionales: interpola solo valores constantes y de confianza. Para entradas no confiables usa pg_query directamente con su argumento params (%s)."
|
||||
- name: max_rows
|
||||
desc: "Numero maximo de filas a materializar en memoria (default 10000). Se pasa tal cual a pg_query; si la query produce mas, el resultado se trunca y truncated queda en True."
|
||||
output: "dict. En exito (propaga pg_query): {status:'ok', columns:[str,...], rows:[{col:val,...},...], row_count:int, truncated:bool}. En error: si la resolucion del DSN falla, {status:'error', error:str} de resolve_pg_dsn; si la query falla, {status:'error', error:str} de pg_query. Sin lanzar."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from pipelines.query_project_pg import query_project_pg
|
||||
|
||||
# Una sola llamada: resuelve el DSN de captacion_clientes y cuenta filas.
|
||||
res = query_project_pg("captacion", "SELECT COUNT(*) FROM product_opportunities")
|
||||
print(res["status"]) # ok
|
||||
print(res["rows"][0]) # {'count': 42}
|
||||
|
||||
# Lanzable directo desde la CLI del registry (corre la demo del __main__):
|
||||
# ./fn run query_project_pg
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cada vez que necesites leer datos del Postgres de un proyecto del
|
||||
ecosistema (captacion_clientes, seo_analytics) en un solo paso, en vez de
|
||||
resolver el DSN a mano y abrir la conexion tu mismo. Es el reemplazo directo del
|
||||
bloque inline `DSN=$(grep ... .env) ; psql "$DSN" -c "SELECT ..."`. Para varias
|
||||
queries con el mismo proyecto, o si necesitas parametros posicionales seguros
|
||||
(%s), resuelve el DSN una vez con `resolve_pg_dsn` y llama a `pg_query`
|
||||
directamente reusando el DSN.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impuro: resuelve el DSN (lee env / .env / pass) y abre una conexion a
|
||||
Postgres. Depende del entorno de la maquina y de que el contenedor del
|
||||
proyecto este levantado.
|
||||
- Solo lectura: `pg_query` marca la transaccion `SET TRANSACTION READ ONLY`.
|
||||
No uses este pipeline para INSERT/UPDATE/DELETE.
|
||||
- No expone `params` posicionales: el SQL se ejecuta tal cual. NO interpoles
|
||||
entradas no confiables en el string (riesgo de inyeccion); para eso usa
|
||||
`pg_query` con su argumento `params`.
|
||||
- El resultado se trunca a `max_rows` filas (default 10000) para proteger
|
||||
memoria; revisa `truncated` en la salida.
|
||||
- La ruta del `.env` del proyecto se resuelve relativa a `FN_REGISTRY_ROOT` o,
|
||||
en su defecto, al cwd. Lanza desde la raiz del registry o exporta esa env var.
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Pipeline one-shot: ejecuta un SELECT contra el Postgres de un proyecto conocido.
|
||||
|
||||
Compone dos funciones del registry sin reescribir su lógica:
|
||||
1. resolve_pg_dsn(project) -> resuelve el DSN del proyecto (env / .env / pass).
|
||||
2. pg_query(dsn, sql, max_rows=...) -> ejecuta el SELECT read-only y devuelve
|
||||
las filas como list[dict].
|
||||
|
||||
Elimina el patrón inline que el agente repetía: resolver el DSN a mano y luego
|
||||
lanzar psql/psycopg2 con él. El caller solo necesita el nombre del proyecto y
|
||||
el SQL; el password sale de pass en runtime, nunca está hardcodeado.
|
||||
|
||||
Es un pipeline (kind: pipeline -> siempre impuro). Devuelve un dict sin lanzar:
|
||||
lo que devuelve pg_query en éxito, o el error de resolución del DSN si falla
|
||||
el primer paso.
|
||||
"""
|
||||
|
||||
from infra.resolve_pg_dsn import resolve_pg_dsn
|
||||
from infra.pg_query import pg_query
|
||||
|
||||
|
||||
def query_project_pg(project: str, sql: str, max_rows: int = 10000) -> dict:
|
||||
"""Resuelve el DSN de un proyecto y ejecuta un SELECT contra su Postgres.
|
||||
|
||||
Args:
|
||||
project: nombre del proyecto conocido ('captacion' / 'captacion_clientes',
|
||||
'seo' / 'seo_analytics'). Se pasa tal cual a resolve_pg_dsn.
|
||||
sql: sentencia SQL a ejecutar (pensada para SELECT). Para parámetros, usa
|
||||
el marcador %s; este pipeline no expone params posicionales, así que
|
||||
interpola valores constantes y de confianza solo (para entradas no
|
||||
confiables usa pg_query directamente con params).
|
||||
max_rows: número máximo de filas a materializar (default 10000). Se pasa
|
||||
tal cual a pg_query; si la query produce más, el resultado se trunca.
|
||||
|
||||
Returns:
|
||||
dict. En éxito propaga el resultado de pg_query:
|
||||
{status:'ok', columns, rows, row_count, truncated}. Si la resolución del
|
||||
DSN falla, propaga {status:'error', error} de resolve_pg_dsn sin tocar
|
||||
Postgres.
|
||||
"""
|
||||
resolved = resolve_pg_dsn(project)
|
||||
if resolved.get("status") != "ok":
|
||||
return resolved
|
||||
return pg_query(resolved["dsn"], sql, max_rows=max_rows)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo lanzable: cuenta de oportunidades de producto en captacion_clientes.
|
||||
import json
|
||||
|
||||
out = query_project_pg(
|
||||
"captacion",
|
||||
"SELECT COUNT(*) AS n FROM product_opportunities",
|
||||
)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: refresh_local_hub
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
purity: impure
|
||||
version: "1.0.0"
|
||||
signature: "def refresh_local_hub(manifest_path: str | None = None, reload: bool = True) -> dict"
|
||||
description: "Orquesta el refresco del sistema local_hub: descubre los servicios locales, regenera el fragmento de Caddyfile y la config de Glance, recarga Caddy (admin API) y reinicia la user-unit glance. Compone discover_local_services + render_caddyfile + render_glance_config. Lo corre el dag_engine a diario y tambien el usuario a mano."
|
||||
tags: [local-hub, pipelines, pipeline, caddy, glance, infra, dashboard]
|
||||
uses_functions: [discover_local_services_py_infra, render_caddyfile_py_infra, render_glance_config_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [pyyaml]
|
||||
tested: true
|
||||
tests:
|
||||
- "compone las tres funciones del registry y escribe ambas configs"
|
||||
- "la config completa de Glance generada es YAML parseable"
|
||||
- "con reload=False no se llama a subprocess.run"
|
||||
- "con reload=True se invoca caddy reload y systemctl --user restart glance"
|
||||
- "el dict de retorno tiene todas las claves del contrato"
|
||||
test_file_path: "python/functions/pipelines/refresh_local_hub_test.py"
|
||||
file_path: "python/functions/pipelines/refresh_local_hub.py"
|
||||
params:
|
||||
- name: manifest_path
|
||||
desc: "Ruta al manifiesto YAML del local_hub. Si es None se usa <RAIZ>/apps/local_hub/local_services.yaml (RAIZ derivada de FN_REGISTRY_ROOT o del path del modulo)."
|
||||
- name: reload
|
||||
desc: "Si True recarga Caddy (admin API localhost:2019, sin sudo) y reinicia la user-unit glance (sin sudo). Si False solo regenera las configs y no toca servicios."
|
||||
output: "dict {total, up, down, caddy_path, glance_path, reloaded, caddy_reload_rc, glance_restart_rc, services:[{name,subdomain,port,up}, ...]}"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
./fn run refresh_local_hub
|
||||
```
|
||||
|
||||
Sin recargar servicios (solo regenera las configs):
|
||||
|
||||
```bash
|
||||
$HOME/fn_registry/python/.venv/bin/python3 \
|
||||
python/functions/pipelines/refresh_local_hub.py --no-reload
|
||||
```
|
||||
|
||||
Desde Python:
|
||||
|
||||
```python
|
||||
from pipelines.refresh_local_hub import refresh_local_hub
|
||||
|
||||
r = refresh_local_hub(reload=True)
|
||||
print(r)
|
||||
# {"total": 8, "up": 6, "down": 2, "caddy_path": "/etc/caddy/conf.d/local_hub.caddy",
|
||||
# "glance_path": ".../apps/local_hub/glance/glance.yml", "reloaded": True,
|
||||
# "caddy_reload_rc": 0, "glance_restart_rc": 0, "services": [...]}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando cambien los servicios locales expuestos como subdominios `*.localhost` (alta/baja de un servicio en el manifiesto, o un service nuevo del registry con bloque `service:`) y quieras que Caddy y el dashboard Glance reflejen el estado actual. Es el paso `function:` que el dag_engine corre a diario para mantener el `local_hub` sincronizado, y el comando que lanzas a mano tras editar `apps/local_hub/local_services.yaml`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: escribe en `/etc/caddy/conf.d/local_hub.caddy` via ACL**, no via sudo. El usuario debe tener permiso de escritura ahi (ACL ya configurada en este PC). Sin la ACL, el `open(..., "w")` lanza `PermissionError`.
|
||||
- **Recarga Caddy por su admin API** (`caddy reload` habla con `localhost:2019`), no reinicia el servicio: requiere que Caddy este corriendo con la admin API activa. Si Caddy no esta levantado, `caddy_reload_rc` queda en un valor != 0 (o -1 si el binario falla) pero el pipeline NO lanza.
|
||||
- **Reinicia la user-unit `glance`** (`systemctl --user restart glance`), no la system-unit: requiere que la user-unit `glance` este instalada y el bus de usuario disponible. Si falta, `glance_restart_rc` refleja el fallo sin abortar.
|
||||
- **Valida el YAML de Glance antes de escribir**: si `render_glance_config` produjera YAML invalido, el pipeline lanza `RuntimeError` con mensaje claro y no escribe el archivo (fail-fast).
|
||||
- **Raiz dinamica**: la raiz del registry se deriva de `FN_REGISTRY_ROOT` o del path del modulo; nunca se hardcodea ningun `/home/<user>`.
|
||||
- **`reload=False` no toca ningun servicio**: util para previsualizar las configs generadas sin recargar Caddy ni reiniciar Glance (lo que hace el test).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
(sin entradas — v1.0.0 inicial)
|
||||
@@ -0,0 +1,208 @@
|
||||
"""refresh_local_hub — orquesta el refresco del sistema local_hub.
|
||||
|
||||
Pipeline impuro del dominio `pipelines`. Compone tres funciones del registry para
|
||||
regenerar la infraestructura que expone los servicios locales como subdominios
|
||||
`*.localhost`:
|
||||
|
||||
1. discover_local_services_py_infra — descubre y normaliza los servicios (manifiesto
|
||||
+ servicios del registry con bloque `service:`), comprobando estado up/down.
|
||||
2. render_caddyfile_py_infra — genera el fragmento de Caddyfile.
|
||||
3. render_glance_config_py_infra — genera la config del widget monitor de Glance.
|
||||
|
||||
Después escribe el fragmento de Caddyfile en /etc/caddy/conf.d/local_hub.caddy (el
|
||||
usuario tiene ACL de escritura ahí, sin sudo) y la config de Glance en
|
||||
apps/local_hub/glance/glance.yml. Si `reload=True`, recarga Caddy (admin API en
|
||||
localhost:2019, sin sudo) y reinicia la user-unit `glance` (sin sudo).
|
||||
|
||||
Pensado para correrse a diario desde dag_engine con un step `function:`, o a mano:
|
||||
`fn run refresh_local_hub`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
sys.path.insert(0, os.path.join(ROOT, "python", "functions"))
|
||||
|
||||
from infra.discover_local_services import discover_local_services # noqa: E402
|
||||
from infra.render_caddyfile import render_caddyfile # noqa: E402
|
||||
from infra.render_glance_config import render_glance_config # noqa: E402
|
||||
|
||||
# Ruta del fragmento de Caddyfile. El usuario tiene ACL de escritura aquí (sin sudo).
|
||||
CADDY_FRAGMENT_PATH = "/etc/caddy/conf.d/local_hub.caddy"
|
||||
|
||||
# Bloque fijo de servidor + tema que precede a la salida de render_glance_config.
|
||||
GLANCE_SERVER_BLOCK = (
|
||||
"server:\n"
|
||||
" host: 127.0.0.1\n"
|
||||
" port: 8585\n"
|
||||
"\n"
|
||||
"theme:\n"
|
||||
" background-color: 240 8 9\n"
|
||||
" contrast-multiplier: 1.2\n"
|
||||
" primary-color: 210 90 70\n"
|
||||
"\n"
|
||||
)
|
||||
|
||||
|
||||
def _default_manifest_path() -> str:
|
||||
"""Ruta por defecto del manifiesto, derivada de la raíz del registry."""
|
||||
root = os.environ.get("FN_REGISTRY_ROOT") or ROOT
|
||||
return os.path.join(root, "apps", "local_hub", "local_services.yaml")
|
||||
|
||||
|
||||
def _registry_root() -> str:
|
||||
"""Raíz del registry: FN_REGISTRY_ROOT si está, si no la derivada del path del módulo."""
|
||||
return os.environ.get("FN_REGISTRY_ROOT") or ROOT
|
||||
|
||||
|
||||
def refresh_local_hub(
|
||||
manifest_path: str | None = None,
|
||||
reload: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Refresca el sistema local_hub: descubre servicios, regenera configs y recarga.
|
||||
|
||||
Args:
|
||||
manifest_path: ruta al manifiesto YAML del local_hub. Si es None, se usa
|
||||
``<RAIZ>/apps/local_hub/local_services.yaml`` (RAIZ derivada de
|
||||
FN_REGISTRY_ROOT o del path del propio módulo).
|
||||
reload: si True, recarga Caddy (admin API en localhost:2019) y reinicia la
|
||||
user-unit ``glance``. Si False, solo escribe las configs y no toca
|
||||
ningún servicio.
|
||||
|
||||
Returns:
|
||||
dict resumen con las claves: total, up, down, caddy_path, glance_path,
|
||||
reloaded, caddy_reload_rc, glance_restart_rc, services.
|
||||
|
||||
Raises:
|
||||
RuntimeError: si la config de Glance generada no es YAML parseable.
|
||||
"""
|
||||
if manifest_path is None:
|
||||
manifest_path = _default_manifest_path()
|
||||
|
||||
# 1. Lee el manifiesto para extraer dashboard_subdomain y glance_port.
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as fh:
|
||||
manifest = yaml.safe_load(fh) or {}
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise RuntimeError(
|
||||
f"refresh_local_hub: no se puede leer el manifiesto {manifest_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
dashboard_subdomain = manifest.get("dashboard_subdomain") or "home"
|
||||
glance_port = manifest.get("glance_port") or 8585
|
||||
|
||||
# 2. Descubre los servicios (manifiesto + registry).
|
||||
services = discover_local_services(manifest_path, include_registry=True)
|
||||
|
||||
# 3. Bloque del dashboard para el Caddyfile.
|
||||
dashboard = {"subdomain": dashboard_subdomain, "port": glance_port}
|
||||
|
||||
# 4. Renderiza el fragmento de Caddyfile.
|
||||
caddy_text = render_caddyfile(services, dashboard)
|
||||
|
||||
# 5. Construye la config completa de Glance (bloque fijo + render_glance_config).
|
||||
glance_full = GLANCE_SERVER_BLOCK + render_glance_config(services)
|
||||
|
||||
# Verifica que el YAML resultante es parseable antes de escribir nada.
|
||||
try:
|
||||
yaml.safe_load(glance_full)
|
||||
except yaml.YAMLError as exc:
|
||||
raise RuntimeError(
|
||||
f"refresh_local_hub: la config de Glance generada no es YAML válido: {exc}"
|
||||
) from exc
|
||||
|
||||
# 6. Escribe el fragmento de Caddyfile (ACL del usuario, sin sudo).
|
||||
with open(CADDY_FRAGMENT_PATH, "w", encoding="utf-8") as fh:
|
||||
fh.write(caddy_text)
|
||||
|
||||
# 7. Escribe la config de Glance (crea el dir si falta).
|
||||
glance_path = os.path.join(_registry_root(), "apps", "local_hub", "glance", "glance.yml")
|
||||
os.makedirs(os.path.dirname(glance_path), exist_ok=True)
|
||||
with open(glance_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(glance_full)
|
||||
|
||||
up = sum(1 for s in services if s.get("up"))
|
||||
down = len(services) - up
|
||||
|
||||
caddy_reload_rc: int | None = None
|
||||
glance_restart_rc: int | None = None
|
||||
|
||||
# 8. Recarga Caddy y reinicia Glance (ambos sin sudo).
|
||||
if reload:
|
||||
try:
|
||||
caddy_proc = subprocess.run(
|
||||
["caddy", "reload", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
caddy_reload_rc = caddy_proc.returncode
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
caddy_reload_rc = -1
|
||||
sys.stderr.write(f"refresh_local_hub: fallo recargando Caddy: {exc}\n")
|
||||
|
||||
try:
|
||||
glance_proc = subprocess.run(
|
||||
["systemctl", "--user", "restart", "glance"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
glance_restart_rc = glance_proc.returncode
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
glance_restart_rc = -1
|
||||
sys.stderr.write(f"refresh_local_hub: fallo reiniciando Glance: {exc}\n")
|
||||
|
||||
return {
|
||||
"total": len(services),
|
||||
"up": up,
|
||||
"down": down,
|
||||
"caddy_path": CADDY_FRAGMENT_PATH,
|
||||
"glance_path": glance_path,
|
||||
"reloaded": reload,
|
||||
"caddy_reload_rc": caddy_reload_rc,
|
||||
"glance_restart_rc": glance_restart_rc,
|
||||
"services": [
|
||||
{
|
||||
"name": s.get("name"),
|
||||
"subdomain": s.get("subdomain"),
|
||||
"port": s.get("port"),
|
||||
"up": s.get("up"),
|
||||
}
|
||||
for s in services
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Refresca el sistema local_hub.")
|
||||
parser.add_argument(
|
||||
"--manifest-path",
|
||||
default=None,
|
||||
help="Ruta al manifiesto YAML del local_hub (default: <RAIZ>/apps/local_hub/local_services.yaml).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-reload",
|
||||
action="store_true",
|
||||
help="No recargar Caddy ni reiniciar Glance; solo regenerar las configs.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
result = refresh_local_hub(
|
||||
manifest_path=args.manifest_path,
|
||||
reload=not args.no_reload,
|
||||
)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests para refresh_local_hub.
|
||||
|
||||
No recarga Caddy ni reinicia Glance de verdad: mockea `subprocess.run`. Escribe las
|
||||
configs generadas en un `tmp_path` parcheando las rutas de salida. Mockea
|
||||
`discover_local_services` para devolver 2 servicios fijos (no depende de puertos reales).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
import pytest
|
||||
|
||||
from pipelines import refresh_local_hub as rlh_module
|
||||
from pipelines.refresh_local_hub import refresh_local_hub
|
||||
|
||||
|
||||
FAKE_SERVICES = [
|
||||
{
|
||||
"name": "metabase",
|
||||
"subdomain": "metabase",
|
||||
"port": 3030,
|
||||
"health_path": "/api/health",
|
||||
"title": "Metabase",
|
||||
"icon": "si:metabase",
|
||||
"category": "Datos",
|
||||
"up": True,
|
||||
},
|
||||
{
|
||||
"name": "portainer",
|
||||
"subdomain": "portainer",
|
||||
"port": 9000,
|
||||
"health_path": "/",
|
||||
"title": "Portainer",
|
||||
"icon": "si:portainer",
|
||||
"category": "Infra",
|
||||
"up": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _write_manifest(tmp_path) -> str:
|
||||
"""Crea un manifiesto YAML mínimo y devuelve su ruta."""
|
||||
manifest = {
|
||||
"dashboard_subdomain": "home",
|
||||
"glance_port": 8585,
|
||||
"services": [],
|
||||
}
|
||||
path = os.path.join(str(tmp_path), "local_services.yaml")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
yaml.safe_dump(manifest, fh)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_env(tmp_path, monkeypatch):
|
||||
"""Parchea discover_local_services, las rutas de salida y subprocess.run."""
|
||||
# discover_local_services devuelve 2 servicios fijos.
|
||||
monkeypatch.setattr(rlh_module, "discover_local_services", lambda *a, **k: list(FAKE_SERVICES))
|
||||
|
||||
# Rutas de salida hacia tmp_path.
|
||||
caddy_path = os.path.join(str(tmp_path), "local_hub.caddy")
|
||||
monkeypatch.setattr(rlh_module, "CADDY_FRAGMENT_PATH", caddy_path)
|
||||
monkeypatch.setattr(rlh_module, "_registry_root", lambda: str(tmp_path))
|
||||
|
||||
# subprocess.run mockeado: registra las llamadas y devuelve un objeto con returncode 0.
|
||||
calls: list[list[str]] = []
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, rc: int = 0) -> None:
|
||||
self.returncode = rc
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
|
||||
def _fake_run(cmd, *args, **kwargs):
|
||||
calls.append(list(cmd))
|
||||
return _FakeProc(0)
|
||||
|
||||
monkeypatch.setattr(rlh_module.subprocess, "run", _fake_run)
|
||||
|
||||
manifest_path = _write_manifest(tmp_path)
|
||||
return {
|
||||
"manifest_path": manifest_path,
|
||||
"caddy_path": caddy_path,
|
||||
"glance_path": os.path.join(str(tmp_path), "apps", "local_hub", "glance", "glance.yml"),
|
||||
"calls": calls,
|
||||
"tmp_path": str(tmp_path),
|
||||
}
|
||||
|
||||
|
||||
def test_compone_las_tres_funciones_y_escribe_configs(patched_env):
|
||||
"""compone las tres funciones del registry y escribe ambas configs"""
|
||||
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
|
||||
|
||||
# Caddyfile escrito con un bloque por servicio + dashboard.
|
||||
assert os.path.exists(patched_env["caddy_path"])
|
||||
caddy_text = open(patched_env["caddy_path"], encoding="utf-8").read()
|
||||
assert "metabase.localhost" in caddy_text
|
||||
assert "portainer.localhost" in caddy_text
|
||||
assert "home.localhost" in caddy_text # bloque del dashboard
|
||||
assert "reverse_proxy 127.0.0.1:3030" in caddy_text
|
||||
|
||||
# Glance escrito con el bloque servidor fijo + la salida de render_glance_config.
|
||||
assert os.path.exists(result["glance_path"])
|
||||
glance_text = open(result["glance_path"], encoding="utf-8").read()
|
||||
assert "host: 127.0.0.1" in glance_text
|
||||
assert "port: 8585" in glance_text
|
||||
assert "primary-color: 210 90 70" in glance_text
|
||||
assert "type: monitor" in glance_text
|
||||
|
||||
|
||||
def test_glance_full_es_yaml_parseable(patched_env):
|
||||
"""la config completa de Glance generada es YAML parseable"""
|
||||
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
|
||||
|
||||
glance_text = open(result["glance_path"], encoding="utf-8").read()
|
||||
parsed = yaml.safe_load(glance_text)
|
||||
assert isinstance(parsed, dict)
|
||||
assert parsed["server"]["host"] == "127.0.0.1"
|
||||
assert parsed["server"]["port"] == 8585
|
||||
assert "theme" in parsed
|
||||
assert "pages" in parsed # la salida de render_glance_config
|
||||
|
||||
|
||||
def test_reload_false_no_llama_subprocess(patched_env):
|
||||
"""con reload=False no se llama a subprocess.run"""
|
||||
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
|
||||
|
||||
assert patched_env["calls"] == []
|
||||
assert result["reloaded"] is False
|
||||
assert result["caddy_reload_rc"] is None
|
||||
assert result["glance_restart_rc"] is None
|
||||
|
||||
|
||||
def test_reload_true_recarga_caddy_y_reinicia_glance(patched_env):
|
||||
"""con reload=True se invoca caddy reload y systemctl --user restart glance"""
|
||||
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=True)
|
||||
|
||||
cmds = patched_env["calls"]
|
||||
assert len(cmds) == 2
|
||||
assert cmds[0][0] == "caddy" and "reload" in cmds[0]
|
||||
assert cmds[1] == ["systemctl", "--user", "restart", "glance"]
|
||||
assert result["reloaded"] is True
|
||||
assert result["caddy_reload_rc"] == 0
|
||||
assert result["glance_restart_rc"] == 0
|
||||
|
||||
|
||||
def test_dict_retorno_tiene_claves_esperadas(patched_env):
|
||||
"""el dict de retorno tiene todas las claves del contrato"""
|
||||
result = refresh_local_hub(manifest_path=patched_env["manifest_path"], reload=False)
|
||||
|
||||
expected_keys = {
|
||||
"total", "up", "down", "caddy_path", "glance_path",
|
||||
"reloaded", "caddy_reload_rc", "glance_restart_rc", "services",
|
||||
}
|
||||
assert expected_keys <= set(result.keys())
|
||||
assert result["total"] == 2
|
||||
assert result["up"] == 1
|
||||
assert result["down"] == 1
|
||||
assert len(result["services"]) == 2
|
||||
assert result["services"][0]["subdomain"] == "metabase"
|
||||
assert {"name", "subdomain", "port", "up"} <= set(result["services"][0].keys())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user