"""extract_text_sample — muestrea columnas de texto de una tabla sin cargarla en RAM. Funcion impura (lee de la base de datos) del grupo de capacidad `eda`. Dado un ``db_path`` + ``table`` (DuckDB o PostgreSQL) y una lista de ``columns`` de texto, trae una MUESTRA de esas columnas con push-down SQL (``LIMIT sample``), nunca la tabla entera. La usan los capitulos de texto/NLP del AutomaticEDA que necesitan valores crudos de texto (longitudes, tokens, ejemplos) sin materializar millones de filas en memoria. El lector read-only ``query_fn(sql) -> dict`` se construye igual que en ``build_eda_render_ctx`` / ``profile_table``: un closure sobre el wrapper del registry (``duckdb_query_readonly`` / ``pg_query``), importado perezosamente dentro de la funcion para no crear ciclos al cargar el ``__init__`` del paquete ``datascience``. Nunca abre conexiones fuera de esos wrappers. Estilo dict-no-throw del grupo `eda`: la funcion NUNCA lanza. Captura cualquier excepcion (query, conversion) y devuelve ``{"status":"error", "error":str(e), "columns":{}, "n":0}``. Si la query subyacente devuelve ``status != "ok"``, se propaga como error con el mensaje del wrapper. Por columna, la lista de strings solo contiene valores NO nulos y NO vacios: cada celda no-None se convierte con ``str(...)`` y se descarta si queda ``""``. La clave ``n`` reporta el numero de FILAS leidas por la query (antes de filtrar los None/vacios), util para saber cuanto se muestreo realmente. """ def extract_text_sample(db_path, table, columns, backend="duckdb", sample=2000): """Muestrea columnas de texto de una tabla DuckDB/Postgres con push-down SQL. Args: db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres". Se inyecta en el closure query_fn. No se valida aqui: si la base no existe o el DSN es invalido, la query devuelve status error y el resultado es {status:'error', ...} (no lanza). table: nombre de la tabla. Se escapa con comillas dobles en la query. columns: lista de nombres de columna de texto a muestrear. Se filtra a las entradas que sean str no vacio; cada nombre se escapa con comillas dobles. Si tras filtrar queda vacia -> {status:'ok', columns:{}, n:0}. backend: "duckdb" (default) o "postgres". Selecciona el lector read-only del registry (duckdb_query_readonly / pg_query). Cualquier otro valor -> {status:'error', error:'backend desconocido: ...', columns:{}, n:0}. sample: maximo de filas a muestrear (clausula LIMIT). Default 2000. Acota memoria y tiempo: con tablas grandes obtienes el primer tramo por orden fisico, no un muestreo uniforme. Returns: dict (dict-no-throw, NUNCA lanza): {"status": "ok"|"error", "columns": {col_name: [str, str, ...], ...}, # solo no-None, no-"" "n": int, # nº de filas leidas por la query (antes de filtrar) "error": str} # solo presente si status == "error" """ try: # 1) Lector read-only del backend activo, construido como en # build_eda_render_ctx (closure sobre el wrapper del registry). Imports # perezosos: este modulo vive en el paquete `datascience`, importar a # `infra` a nivel de modulo crearia un ciclo al cargar el __init__. if backend == "duckdb": from infra import duckdb_query_readonly def query_fn(sql): return duckdb_query_readonly(db_path, sql) elif backend == "postgres": from infra import pg_query def query_fn(sql): return pg_query(db_path, sql) else: return { "status": "error", "error": f"backend desconocido: {backend}", "columns": {}, "n": 0, } # 2) Columnas validas (str no vacio). Si no queda ninguna, nada que # muestrear: ok con columns vacio. cols = [] if isinstance(columns, (list, tuple)): cols = [c for c in columns if isinstance(c, str) and c != ""] if not cols: return {"status": "ok", "columns": {}, "n": 0} # 3) Push-down: una sola query con LIMIT. Identificadores escapados con # comillas dobles, igual que build_eda_render_ctx. cols_sql = ", ".join(f'"{c}"' for c in cols) sql = f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}' q = query_fn(sql) if not isinstance(q, dict) or q.get("status") != "ok": err = q.get("error") if isinstance(q, dict) else "query sin resultado" return {"status": "error", "error": str(err), "columns": {}, "n": 0} rows = q.get("rows") or [] out = {c: [] for c in cols} for row in rows: if not isinstance(row, dict): continue for c in cols: value = row.get(c) if value is None: continue s = str(value) if s == "": continue out[c].append(s) return {"status": "ok", "columns": out, "n": len(rows)} except Exception as exc: # noqa: BLE001 - dict-no-throw del grupo eda return {"status": "error", "error": str(exc), "columns": {}, "n": 0}