feat(infra): auto-commit con 56 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 14:22:55 +02:00
parent c1071a82b3
commit 32c7336bf6
56 changed files with 5307 additions and 100 deletions
+2
View File
@@ -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"
+54 -21
View File
@@ -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"]