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"]
|
||||
Reference in New Issue
Block a user