Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9886e2905d | |||
| 6a1520f458 | |||
| a1e2e3567c | |||
| 833597c831 | |||
| 7158be8142 | |||
| 9be84a48ea | |||
| fd63261444 | |||
| 4099d88eaf |
@@ -72,8 +72,10 @@ from .profile_datetime import profile_datetime
|
||||
from .resample_timeseries import resample_timeseries
|
||||
from .add_pdf_internal_links import add_pdf_internal_links
|
||||
from .suggest_intratable_fk_candidates import suggest_intratable_fk_candidates
|
||||
from .draw_join_graph_figure import draw_join_graph_figure
|
||||
|
||||
__all__ = [
|
||||
"draw_join_graph_figure",
|
||||
"suggest_intratable_fk_candidates",
|
||||
"detect_time_column",
|
||||
"extract_timeseries_raw",
|
||||
|
||||
@@ -561,13 +561,11 @@ def _intro_blocks(gloss=None, mark_term: bool = False) -> list:
|
||||
t_groupby = _term(mark_term, "groupby", "**por grupos** (split-apply-combine)")
|
||||
t_pivot = _term(mark_term, "pivot_table", "**tablas dinámicas** (pivot)")
|
||||
text = (
|
||||
f"Este capítulo analiza la tabla {t_groupby}: "
|
||||
"elige las columnas categóricas más informativas — por su cardinalidad "
|
||||
"y relevancia, no todas contra todas, para no inflar comparaciones "
|
||||
"espurias — y resume las variables numéricas dentro de cada grupo "
|
||||
f"(conteo, media, mediana, desviación). Las {t_pivot} "
|
||||
"cruzan dos categóricas sobre una medida, y los **gráficos de barras** "
|
||||
"(siempre desde cero) comparan los grupos de un vistazo."
|
||||
f"Este capítulo analiza la tabla {t_groupby}: elige las columnas "
|
||||
"categóricas más informativas (por cardinalidad y relevancia, no todas "
|
||||
"contra todas) y resume las variables numéricas dentro de cada grupo "
|
||||
f"(conteo, media, mediana, desviación). Se añaden {t_pivot} y "
|
||||
"**gráficos de barras** (siempre desde cero) para comparar los grupos."
|
||||
)
|
||||
return [model.Heading(text=CHAPTER_TITLE, level=1),
|
||||
model.Markdown(text=text)]
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
Builds the quality chapter from a ``TableProfile`` of the ``eda`` group. The
|
||||
chapter implements the quality model of report 2046:
|
||||
|
||||
1. **En qué se basa la calidad** — an intro paragraph explaining the two scored
|
||||
1. **En qué se basa la calidad** — a concise intro naming the two scored
|
||||
dimensions and their weights (completitud 60%, validez 40%) plus the
|
||||
table-level row uniqueness, BEFORE any number, and stating explicitly that
|
||||
outliers are reported as observations and do **not** lower the score. The
|
||||
criteria terms (calidad de datos, completitud, validez, unicidad de registro)
|
||||
are hooked into the shared glossary as clickable jumps.
|
||||
table-level row uniqueness, BEFORE any number, and stating that outliers are
|
||||
reported as observations and do **not** lower the score. The criteria terms
|
||||
(calidad de datos, completitud, validez, unicidad de registro) are hooked
|
||||
into the shared glossary as clickable jumps; their full definitions live in
|
||||
the GLOSARIO chapter, not inline here.
|
||||
2. **Scores por columna** — a table with, per column, the total quality score and
|
||||
its breakdown into completeness / validity (no consistency dimension).
|
||||
3. **Problemas de calidad** — a table listing ONLY real quality defects
|
||||
@@ -309,30 +310,22 @@ def _term(key: str, label: str, mark: bool) -> str:
|
||||
|
||||
|
||||
def _criteria_intro(mark: bool) -> str:
|
||||
"""Intro paragraph explaining the two scored dimensions and the principle."""
|
||||
"""Intro: how the score is composed, with every term marked clickable.
|
||||
|
||||
Concise on purpose: the definitions of each term (calidad de datos,
|
||||
completitud, validez, unicidad de registro) now live in the GLOSARIO
|
||||
chapter, so the body no longer repeats them — it only states how the score
|
||||
is composed and keeps each term marked so it stays a clickable jump.
|
||||
"""
|
||||
calidad = _term("calidad_datos", "calidad de datos", mark)
|
||||
completitud = _term("completitud", "Completitud (peso 60%)", mark)
|
||||
validez = _term("validez", "Validez (peso 40%, cuando es medible)", mark)
|
||||
completitud = _term("completitud", "completitud", mark)
|
||||
validez = _term("validez", "validez", mark)
|
||||
unicidad = _term("unicidad_registro", "unicidad de registro", mark)
|
||||
return (
|
||||
f"La {calidad} de cada columna es un score de 0 a 100 que combina solo "
|
||||
"dimensiones medibles desde el perfil de la tabla, sin fuente externa "
|
||||
"de verdad:\n\n"
|
||||
f"- {completitud}: proporción de valores presentes (1 − % de nulos; en "
|
||||
"texto, las celdas vacías cuentan como faltantes). Los nulos y vacíos "
|
||||
"bajan el score.\n"
|
||||
f"- {validez}: proporción de valores que encajan con su tipo o formato "
|
||||
"(un número que parsea, una fecha legible, un email con forma de email). "
|
||||
"Si una columna es texto libre sin formato esperado, la validez no se "
|
||||
"mide y el score se basa solo en la completitud.\n\n"
|
||||
f"Score de columna = 100 × (0,6·completitud + 0,4·validez), "
|
||||
"renormalizado cuando la validez no aplica. A nivel de tabla se añade "
|
||||
f"la {unicidad} (1 − % de filas duplicadas).\n\n"
|
||||
"**Los valores atípicos (outliers) NO bajan la calidad.** Un valor "
|
||||
"extremo puede ser real y correcto; detectar atípicos es parte del "
|
||||
"análisis de la distribución, no un juicio de corrección. Por eso, junto "
|
||||
"con las columnas constantes y los identificadores, se listan aparte "
|
||||
"como **observaciones analíticas** que no afectan al score."
|
||||
f"La {calidad} de cada columna es un score de 0 a 100 que combina "
|
||||
f"{completitud} (peso 60%) y {validez} (peso 40%, cuando es medible); "
|
||||
f"a nivel de tabla se añade la {unicidad}. Los valores atípicos no "
|
||||
"bajan el score: se listan aparte como **observaciones analíticas**."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -72,14 +72,16 @@ def test_golden_chapter_estructura_y_version():
|
||||
assert "markdown" in kinds and "kv_table" in kinds and "data_table" in kinds
|
||||
|
||||
|
||||
def test_golden_intro_explica_dos_dimensiones_y_pesos():
|
||||
def test_golden_intro_nombra_dos_dimensiones_y_pesos():
|
||||
# La intro nombra las dos dimensiones, sus pesos y la unicidad, pero ya NO
|
||||
# repite sus definiciones largas: estas viven ahora en el capítulo GLOSARIO.
|
||||
ch = build_calidad(_profile(), {})
|
||||
intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
|
||||
for needle in ("Completitud", "Validez", "60%", "40%",
|
||||
for needle in ("completitud", "validez", "60%", "40%",
|
||||
"unicidad de registro"):
|
||||
assert needle in intro, f"falta {needle!r} en la intro de criterios"
|
||||
# El principio: los outliers NO bajan la calidad.
|
||||
assert "atípicos" in intro and "NO bajan" in intro
|
||||
assert "atípicos" in intro and "no bajan" in intro
|
||||
# Ya no se menciona la dimensión consistencia eliminada.
|
||||
assert "20%" not in intro
|
||||
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
"""Categorical distributions chapter (CAT DISTR).
|
||||
|
||||
Third reference chapter for AutomaticEDA. For every categorical column it shows,
|
||||
fulfilling the user's request:
|
||||
Third reference chapter for AutomaticEDA. Each categorical column gets **its own
|
||||
page (PDF) / slide (PPTX)**: every column is wrapped in a keep-together
|
||||
``model.Group`` with ``page_break_before=True`` (except the first, which may share
|
||||
the intro's page), so its chart sits next to its tables and no column is split.
|
||||
|
||||
1. A short opening explanation of **Shannon entropy** (what it measures, its 0
|
||||
and log2(k) bounds, the normalized 0–1 version) and the dataset row total used
|
||||
as a comparison baseline.
|
||||
2. Per column, a cardinality key/value table: distinct values, ``% distinct``
|
||||
(distinct / total rows), total dataset rows, singleton values (frequency 1),
|
||||
entropy with its theoretical maximum and the normalized ratio, mode, imbalance
|
||||
and string-length stats.
|
||||
3. A short note flagging problematic cardinality (id-like ≈100% distinct, or a
|
||||
A short intro names the clickable **[[term:entropia]]entropía[[/term]]** term —
|
||||
the full definition lives in the GLOSARIO chapter, so it is NOT repeated inline
|
||||
here (one click jumps to the glossary entry). The intro also carries the dataset
|
||||
row total used as a comparison baseline.
|
||||
|
||||
Per column the Group contains, in order:
|
||||
|
||||
1. A cardinality key/value table: distinct values, ``% distinct`` (distinct /
|
||||
total rows), total dataset rows, singleton values (frequency 1), entropy with
|
||||
its theoretical maximum and the normalized ratio, mode, imbalance and
|
||||
string-length stats.
|
||||
2. A short note flagging problematic cardinality (id-like ≈100% distinct, or a
|
||||
single dominating category).
|
||||
4. A ``top-k`` table (value / count / %).
|
||||
5. A **donut pie chart** of the most common categories (top-k + an "Otros"
|
||||
3. A ``top-k`` table (value / count / %).
|
||||
4. A **donut pie chart** of the most common categories (top-k + an "Otros"
|
||||
bucket), drawn lazily so the renderers scale it to fit entirely.
|
||||
|
||||
Data comes from the ``eda`` group: each ``columns[i]['categorical']`` is the
|
||||
@@ -33,7 +39,7 @@ import math
|
||||
|
||||
from .. import model
|
||||
|
||||
CHAPTER_VERSION = "1.1.0"
|
||||
CHAPTER_VERSION = "1.2.0"
|
||||
CHAPTER_ID = "cat_distr"
|
||||
CHAPTER_TITLE = "Distribuciones categóricas"
|
||||
|
||||
@@ -53,11 +59,17 @@ _TERM_ENTROPIA_DEF = (
|
||||
# Cap the number of categorical columns rendered to keep the document bounded;
|
||||
# the rest are summarized in a closing note (no silent truncation).
|
||||
MAX_COLS = 40
|
||||
# Rows shown in each top-k table and explicit slices in the pie.
|
||||
TOP_TABLE_ROWS = 15
|
||||
# Rows shown in each top-k table and explicit slices in the pie. Kept moderate so
|
||||
# the whole column — cardinality table + top-k table + donut — fits on ONE
|
||||
# page/slide with the chart next to its tables; the table note still reports
|
||||
# "top N of M" so nothing is silently hidden. For id-like columns (≈100%
|
||||
# distinct) the top-k table is dropped entirely (it would be a list of unique
|
||||
# values — pure noise), which also frees the room the donut needs (see build).
|
||||
TOP_TABLE_ROWS = 8
|
||||
PIE_TOP_K = 6
|
||||
# Truncate very long category labels in tables (the renderer also wraps).
|
||||
LABEL_MAX = 48
|
||||
# Truncate very long category labels in tables (the renderer also wraps). Kept
|
||||
# tight so a column with long id-like values (names, tickets) still fits its page.
|
||||
LABEL_MAX = 28
|
||||
|
||||
|
||||
def _fmt_int(value) -> str:
|
||||
@@ -267,45 +279,55 @@ def _normalize_card(card: dict) -> dict:
|
||||
|
||||
|
||||
def _cardinality_block(card: dict):
|
||||
"""KVTable with the cardinality / entropy metrics for one column."""
|
||||
"""KVTable with the cardinality / entropy metrics for one column.
|
||||
|
||||
Related metrics are grouped onto a single row each (distinct/%/unique;
|
||||
entropy bits/max/normalized; length min/mean/max) so the whole column —
|
||||
table + chart — fits one page/slide without dropping any datum; the short
|
||||
16:9 PPTX slide does not fit one metric per row plus a chart otherwise."""
|
||||
n_singletons = card.get("n_singletons")
|
||||
if n_singletons is not None and card.get("n_singletons_partial"):
|
||||
singletons = f"≥{_fmt_int(n_singletons)} (en top mostrado)"
|
||||
singletons = f"≥{_fmt_int(n_singletons)}"
|
||||
elif n_singletons is not None:
|
||||
singletons = _fmt_int(n_singletons)
|
||||
else:
|
||||
singletons = "—"
|
||||
|
||||
entropy_ref = _fmt_num(card.get("entropy"))
|
||||
emax = card.get("entropy_max")
|
||||
if emax is not None:
|
||||
entropy_ref = f"{entropy_ref} (máx {_fmt_num(emax)})"
|
||||
# Distinct count · % distinct · unique (frequency 1) on one row.
|
||||
distinct_combo = (f"{_fmt_int(card.get('n_distinct'))} · "
|
||||
f"{_fmt_pct_value(card.get('pct_distinct'))} · "
|
||||
f"{singletons} únicos")
|
||||
|
||||
# Entropy bits · theoretical max · normalized 0–1 on one row.
|
||||
entropy_combo = (f"{_fmt_num(card.get('entropy'))} bits · "
|
||||
f"máx {_fmt_num(card.get('entropy_max'))} · "
|
||||
f"norm {_fmt_num(card.get('entropy_norm'))}")
|
||||
|
||||
mode = card.get("mode")
|
||||
mode_pct = card.get("mode_pct")
|
||||
mode_str = "—" if mode is None else model._safe_str(mode)
|
||||
mode_str = "—" if mode is None else _truncate(mode, 32)
|
||||
if mode is not None and mode_pct is not None:
|
||||
mode_str = f"{mode_str} ({_fmt_pct_value(mode_pct)})"
|
||||
|
||||
rows = [
|
||||
("Valores distintos", _fmt_int(card.get("n_distinct"))),
|
||||
("% distintos", _fmt_pct_value(card.get("pct_distinct"))),
|
||||
("Distintos · % · únicos", distinct_combo),
|
||||
("Total filas (dataset)", _fmt_int(card.get("n_rows"))),
|
||||
("Valores únicos (frecuencia 1)", singletons),
|
||||
("Entropía (bits)", entropy_ref),
|
||||
("Entropía normalizada (0–1)", _fmt_num(card.get("entropy_norm"))),
|
||||
("Entropía (bits · máx · norm)", entropy_combo),
|
||||
("Moda", mode_str),
|
||||
]
|
||||
imbalance = card.get("imbalance")
|
||||
if imbalance is not None:
|
||||
rows.append(("Desbalance", _fmt_num(imbalance)))
|
||||
lm = card.get("len_min")
|
||||
lmean = card.get("len_mean")
|
||||
lmax = card.get("len_max")
|
||||
# Imbalance and string length (both secondary) share one closing row.
|
||||
extras = []
|
||||
if imbalance is not None:
|
||||
extras.append(f"desbalance {_fmt_num(imbalance)}")
|
||||
if any(v is not None for v in (lm, lmean, lmax)):
|
||||
rows.append((
|
||||
"Longitud (mín/media/máx)",
|
||||
f"{_fmt_num(lm)} / {_fmt_num(lmean)} / {_fmt_num(lmax)}"))
|
||||
extras.append(
|
||||
f"long. {_fmt_num(lm)}/{_fmt_num(lmean)}/{_fmt_num(lmax)}")
|
||||
if extras:
|
||||
rows.append(("Desbalance · longitud", " · ".join(extras)))
|
||||
return model.KVTable(rows=rows, title="Cardinalidad")
|
||||
|
||||
|
||||
@@ -315,7 +337,8 @@ def _flag_note(card: dict):
|
||||
return model.Note(
|
||||
"Casi todos los valores son distintos (≈100% distintos): la columna "
|
||||
"se comporta como un identificador y aporta poco para agrupar o "
|
||||
"comparar categorías.")
|
||||
"comparar categorías. No se lista el top de categorías (serían "
|
||||
"valores casi todos únicos).")
|
||||
if card.get("dominated"):
|
||||
mp = card.get("mode_pct")
|
||||
mp_str = _fmt_pct_value(mp) if mp is not None else "muy alta"
|
||||
@@ -335,7 +358,7 @@ def _topk_table(cat: dict):
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
rows.append([
|
||||
model._safe_str(t.get("value")),
|
||||
_truncate(t.get("value")),
|
||||
_fmt_int(t.get("count")),
|
||||
_pct_from_maybe_fraction(t.get("pct")),
|
||||
])
|
||||
@@ -353,20 +376,16 @@ def _topk_table(cat: dict):
|
||||
def _intro_blocks(n_rows, mark_term: bool = False):
|
||||
total = _fmt_int(n_rows)
|
||||
# Mark the first appearance of the term as a clickable glossary jump when the
|
||||
# term was registered (mark_term). The visible text is identical either way.
|
||||
entropia = ("[[term:entropia]]**entropía de Shannon**[[/term]]" if mark_term
|
||||
else "**entropía de Shannon**")
|
||||
# term was registered (mark_term). The full definition of entropy lives in the
|
||||
# GLOSARIO chapter, so the intro only names the clickable term here instead of
|
||||
# repeating the long explanation (avoids the redundancy with the glossary).
|
||||
entropia = ("[[term:entropia]]entropía[[/term]]" if mark_term
|
||||
else "entropía")
|
||||
text = (
|
||||
f"La {entropia} mide cómo de repartidos están los valores de "
|
||||
"una columna categórica, en bits. Vale 0 cuando una sola categoría "
|
||||
"concentra todas las filas (máxima previsibilidad) y alcanza su máximo, "
|
||||
"log2(k) para k categorías distintas, cuando todas aparecen por igual "
|
||||
"(máxima diversidad). La **entropía normalizada** (entropía dividida por "
|
||||
"su máximo) la lleva al rango 0–1 para comparar columnas con distinto "
|
||||
"número de categorías. Para cada columna se muestran los valores "
|
||||
"distintos, el porcentaje que representan sobre el total de filas, los "
|
||||
"valores únicos (que aparecen una sola vez), la tabla de las categorías "
|
||||
"más frecuentes y un gráfico de tarta (donut) de las más comunes."
|
||||
f"Cada columna categórica ocupa su propia página: sus métricas de "
|
||||
f"cardinalidad —incluida la {entropia}—, una nota que señala cardinalidad "
|
||||
"problemática, la tabla de las categorías más frecuentes y un gráfico de "
|
||||
"tarta (donut) de las más comunes, todo junto."
|
||||
)
|
||||
if n_rows is not None:
|
||||
text += f" El dataset tiene {total} filas en total como referencia."
|
||||
@@ -398,24 +417,37 @@ def build_cat_distr(profile: dict, ctx: dict):
|
||||
blocks = list(_intro_blocks(n_rows, mark_term=mark_term))
|
||||
|
||||
rendered = cat_cols[:MAX_COLS]
|
||||
for col in rendered:
|
||||
for idx, col in enumerate(rendered):
|
||||
name = col.get("name") or "(columna)"
|
||||
cat = col.get("categorical") or {}
|
||||
card = _normalize_card(_cardinality(cat, n_rows))
|
||||
|
||||
blocks.append(model.Heading(text=str(name), level=2))
|
||||
blocks.append(_cardinality_block(card))
|
||||
# One Group per categorical column: heading + cardinality table + flag
|
||||
# note + top-k table + donut figure are kept together and the renderer
|
||||
# starts each on a fresh page/slide (page_break_before) so every column
|
||||
# gets its own page with its chart next to its tables. The first column
|
||||
# may share the intro's page (no forced break) to avoid a near-empty page.
|
||||
col_blocks = [
|
||||
model.Heading(text=str(name), level=2),
|
||||
_cardinality_block(card),
|
||||
]
|
||||
note = _flag_note(card)
|
||||
if note is not None:
|
||||
blocks.append(note)
|
||||
topk = _topk_table(cat)
|
||||
if topk is not None:
|
||||
blocks.append(topk)
|
||||
blocks.append(model.Figure(
|
||||
col_blocks.append(note)
|
||||
# For id-like columns (≈100% distinct) the top-k is a list of unique
|
||||
# values — pure noise; skip it (the flag note already explains why) and
|
||||
# let the donut take that room so the whole column fits one page/slide.
|
||||
if not card.get("id_like"):
|
||||
topk = _topk_table(cat)
|
||||
if topk is not None:
|
||||
col_blocks.append(topk)
|
||||
col_blocks.append(model.Figure(
|
||||
make=_pie_make(cat.get("top") or [], card.get("n_distinct"),
|
||||
str(name), n_rows),
|
||||
caption=(f"Categorías más comunes de «{_truncate(name, 32)}» "
|
||||
"(donut: top-k + «Otros»)")))
|
||||
blocks.append(model.Group(blocks=col_blocks,
|
||||
page_break_before=(idx > 0)))
|
||||
|
||||
if len(cat_cols) > len(rendered):
|
||||
omitted = len(cat_cols) - len(rendered)
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
|
||||
and deterministic. Verifies that ``build_cat_distr`` emits the blocks the user
|
||||
asked for (entropy intro, distinct/total/%-distinct/unique metrics, top-k table
|
||||
and a donut figure), that the chapter renders inside the full document to both
|
||||
PDF and PPTX showing that content, that a profile with no categorical columns
|
||||
yields ``None`` without raising, and that long labels / many columns are never
|
||||
cut in either output.
|
||||
asked for (distinct/total/%-distinct/unique metrics, top-k table and a donut
|
||||
figure), that EACH categorical column is wrapped in its own keep-together
|
||||
``Group`` that starts on a fresh page/slide (one column per page, chart next to
|
||||
its tables), that the long entropy explanation is NOT repeated inline (it lives
|
||||
in the glossary — only the clickable term is kept), that the chapter renders
|
||||
inside the full document to both PDF and PPTX showing that content, that a
|
||||
profile with no categorical columns yields ``None`` without raising, and that
|
||||
long labels / many columns are never cut in either output.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -17,7 +20,8 @@ from pypdf import PdfReader
|
||||
from pptx import Presentation
|
||||
|
||||
from datascience.automatic_eda.model import (
|
||||
DataTable, Figure, Heading, KVTable, Note,
|
||||
DataTable, Figure, GlossaryCollector, Group, Heading, KVTable, Markdown,
|
||||
Note,
|
||||
)
|
||||
from datascience.automatic_eda.chapters.cat_distr import (
|
||||
CHAPTER_ID, CHAPTER_VERSION, build_cat_distr,
|
||||
@@ -81,8 +85,20 @@ def _pptx_text(path: str) -> str:
|
||||
return re.sub(r"\s+", " ", " ".join(parts))
|
||||
|
||||
|
||||
def _kinds(chapter):
|
||||
return [b.kind for b in chapter.blocks]
|
||||
def _flatten(blocks):
|
||||
"""Expand keep-together Groups so the per-column heading/table/figure are
|
||||
inspectable as a flat block list (the chapter wraps each column in a Group)."""
|
||||
out = []
|
||||
for b in blocks:
|
||||
if getattr(b, "kind", "") == "group":
|
||||
out.extend(_flatten(getattr(b, "blocks", []) or []))
|
||||
else:
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
|
||||
def _column_groups(chapter):
|
||||
return [b for b in chapter.blocks if isinstance(b, Group)]
|
||||
|
||||
|
||||
def test_golden_build_cat_distr_emite_bloques_pedidos():
|
||||
@@ -90,36 +106,101 @@ def test_golden_build_cat_distr_emite_bloques_pedidos():
|
||||
assert ch is not None
|
||||
assert ch.id == CHAPTER_ID
|
||||
assert ch.version == CHAPTER_VERSION
|
||||
kinds = _kinds(ch)
|
||||
# Entropy intro present.
|
||||
|
||||
# Entropy intro present, but the long explanation is gone (it lives in the
|
||||
# glossary now): only the term is named, no log2/normalizada walkthrough.
|
||||
headings = [b.text for b in ch.blocks if isinstance(b, Heading)]
|
||||
assert any("Entrop" in h for h in headings)
|
||||
md = next(b for b in ch.blocks if b.kind == "markdown")
|
||||
assert "entropía" in md.text.lower() and "log2" in md.text
|
||||
# Cardinality metrics: distinct, total rows, %-distinct, unique values.
|
||||
kv = next(b for b in ch.blocks if isinstance(b, KVTable))
|
||||
md = next(b for b in ch.blocks if isinstance(b, Markdown))
|
||||
assert "entropía" in md.text.lower()
|
||||
assert "log2" not in md.text # redundant explanation removed.
|
||||
assert "máxima diversidad" not in md.text
|
||||
|
||||
# Per-column blocks are wrapped in keep-together Groups: flatten to inspect.
|
||||
flat = _flatten(ch.blocks)
|
||||
kv = next(b for b in flat if isinstance(b, KVTable))
|
||||
labels = [r[0] for r in kv.rows]
|
||||
assert "Valores distintos" in labels
|
||||
assert "% distintos" in labels
|
||||
values = " ".join(str(r[1]) for r in kv.rows)
|
||||
# Cardinality metrics: distinct count, %-distinct, unique values and total
|
||||
# rows are present (grouped onto compact rows so the chart fits the page).
|
||||
assert "Distintos · % · únicos" in labels
|
||||
assert "Total filas (dataset)" in labels
|
||||
assert "Valores únicos (frecuencia 1)" in labels
|
||||
assert any("Entropía" in lbl for lbl in labels)
|
||||
assert "únicos" in values and "%" in values
|
||||
assert "bits" in values and "norm" in values # entropy + max + normalized.
|
||||
# Top-k table + pie figure.
|
||||
dt = next(b for b in ch.blocks if isinstance(b, DataTable))
|
||||
dt = next(b for b in flat if isinstance(b, DataTable))
|
||||
assert dt.header == ["Valor", "Conteo", "%"]
|
||||
assert any("neumaticos" in str(cell) for row in dt.rows for cell in row)
|
||||
assert any(isinstance(b, Figure) for b in ch.blocks)
|
||||
# id-like column flagged with a Note.
|
||||
assert any(isinstance(b, Note) and "identificador" in b.text
|
||||
for b in ch.blocks)
|
||||
assert any(isinstance(b, Figure) for b in flat)
|
||||
# id-like column flagged with a Note that also explains the top-k is dropped.
|
||||
idnote = next((b for b in flat
|
||||
if isinstance(b, Note) and "identificador" in b.text), None)
|
||||
assert idnote is not None
|
||||
assert "No se lista el top" in idnote.text
|
||||
|
||||
|
||||
def test_golden_render_pdf_muestra_categoricas():
|
||||
def test_golden_idlike_omite_topk_y_conserva_donut():
|
||||
# The id-like column (uuid, 100% distinct) must NOT carry a top-k DataTable
|
||||
# (it would be a list of unique values), but must still keep its donut Figure
|
||||
# and its cardinality table so it stays a full per-column page.
|
||||
ch = build_cat_distr(_profile(), {})
|
||||
groups = _column_groups(ch)
|
||||
uuid_group = next(g for g in groups
|
||||
if any(getattr(b, "text", "") == "uuid" for b in g.blocks))
|
||||
kinds = [b.kind for b in uuid_group.blocks]
|
||||
assert "data_table" not in kinds # top-k of unique values dropped.
|
||||
assert "kv_table" in kinds # cardinality kept.
|
||||
assert "figure" in kinds # donut kept (chart per column).
|
||||
# A non-id-like column keeps its top-k table.
|
||||
cat_group = next(g for g in groups
|
||||
if any(getattr(b, "text", "") == "categoria"
|
||||
for b in g.blocks))
|
||||
assert "data_table" in [b.kind for b in cat_group.blocks]
|
||||
|
||||
|
||||
def test_golden_una_pagina_por_columna_groups():
|
||||
ch = build_cat_distr(_profile(), {})
|
||||
groups = _column_groups(ch)
|
||||
# Two categorical columns -> two column Groups (numeric column excluded).
|
||||
assert len(groups) == 2
|
||||
# Each Group carries one column: a heading + its cardinality table + figure.
|
||||
for g in groups:
|
||||
kinds = [b.kind for b in g.blocks]
|
||||
assert kinds[0] == "heading"
|
||||
assert "kv_table" in kinds
|
||||
assert "figure" in kinds
|
||||
# The first column may share the intro page (no forced break); every later
|
||||
# column starts on a fresh page/slide so each column gets its own page.
|
||||
assert groups[0].page_break_before is False
|
||||
assert all(g.page_break_before is True for g in groups[1:])
|
||||
|
||||
|
||||
def test_golden_entropia_clicable_y_definicion_en_glosario():
|
||||
# With a glossary collector the intro marks the clickable term and the FULL
|
||||
# definition (the long explanation removed from the intro) lands in the
|
||||
# glossary, not inline — no data lost, just relocated.
|
||||
gc = GlossaryCollector()
|
||||
ch = build_cat_distr(_profile(), {"glossary": gc})
|
||||
md = next(b for b in ch.blocks if isinstance(b, Markdown))
|
||||
assert "[[term:entropia]]entropía[[/term]]" in md.text
|
||||
assert gc.has("entropia")
|
||||
entry = gc.get("entropia")
|
||||
assert entry is not None
|
||||
# The definition kept in the glossary still carries the detail removed inline.
|
||||
assert "log2" in entry["definition"]
|
||||
assert "normalizada" in entry["definition"].lower()
|
||||
|
||||
|
||||
def test_golden_render_pdf_una_pagina_por_columna():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "eda.pdf")
|
||||
res = render_automatic_eda_pdf(_profile(), out, {"title": "EDA"})
|
||||
assert res["path"] == out and os.path.exists(out)
|
||||
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
|
||||
cat_meta = next(c for c in res["chapters"] if c["id"] == CHAPTER_ID)
|
||||
# Two categorical columns, each on its own page -> >= 2 pages for the
|
||||
# chapter (intro shares the first column's page).
|
||||
assert cat_meta["n_pages"] >= 2
|
||||
txt = _pdf_text(out)
|
||||
assert "Entrop" in txt
|
||||
assert "distintos" in txt
|
||||
@@ -133,13 +214,91 @@ def test_golden_render_pptx_muestra_categoricas():
|
||||
out = os.path.join(d, "eda.pptx")
|
||||
res = render_automatic_eda_pptx(_profile(), out, {"title": "EDA"})
|
||||
assert res["path"] == out and os.path.exists(out)
|
||||
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
|
||||
cat_meta = next(c for c in res["chapters"] if c["id"] == CHAPTER_ID)
|
||||
assert cat_meta["n_slides"] >= 2 # one slide per categorical column.
|
||||
txt = _pptx_text(out)
|
||||
assert "Entrop" in txt
|
||||
assert "categoria" in txt and "neumaticos" in txt
|
||||
assert "distintos" in txt
|
||||
|
||||
|
||||
def _profile_high_card() -> dict:
|
||||
"""Profile with a high-cardinality NON-id-like categorical column whose top-k
|
||||
of long values would split from its donut on a short 16:9 slide unless the
|
||||
renderer trims the table — the exact case the adversarial check flagged
|
||||
(Ticket / Cabin)."""
|
||||
long_vals = [f"Valor largo de categoria numero {i:02d} con texto extra"
|
||||
for i in range(40)]
|
||||
top = [{"value": v, "count": 60 - i, "pct": (60 - i) / 5000.0}
|
||||
for i, v in enumerate(long_vals)]
|
||||
return {
|
||||
"table": "t", "source": "t.csv", "n_rows": 5000, "n_cols": 3,
|
||||
"quality_score": 80.0,
|
||||
"columns": [
|
||||
{"name": "precio", "inferred_type": "numeric", "null_pct": 0.0,
|
||||
"numeric": {"mean": 1.0, "median": 1.0, "min": 0.0, "max": 2.0,
|
||||
"std": 0.5}},
|
||||
# 40 distinct over 5000 rows = 0.8% distinct -> NOT id-like, keeps
|
||||
# its (long) top-k table; the tall table must not push the donut off.
|
||||
{"name": "alta_card_col", "inferred_type": "categorical",
|
||||
"null_pct": 0.0, "distinct_count": 40,
|
||||
"categorical": {"top": top, "mode": long_vals[0], "n_distinct": 40,
|
||||
"entropy": 5.2, "imbalance": 1.2, "len_min": 40,
|
||||
"len_mean": 45, "len_max": 50}},
|
||||
{"name": "baja_card_col", "inferred_type": "categorical",
|
||||
"null_pct": 0.0, "distinct_count": 4,
|
||||
"categorical": {
|
||||
"top": [{"value": "norte", "count": 2000, "pct": 0.4},
|
||||
{"value": "sur", "count": 1500, "pct": 0.3},
|
||||
{"value": "este", "count": 1000, "pct": 0.2},
|
||||
{"value": "oeste", "count": 500, "pct": 0.1}],
|
||||
"mode": "norte", "n_distinct": 4, "entropy": 1.8}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_golden_pptx_una_slide_por_columna_con_su_grafico():
|
||||
"""Each categorical column occupies EXACTLY ONE cat_distr slide that carries
|
||||
BOTH its cardinality table and its donut figure (picture) — i.e. the chart is
|
||||
never separated from its table, even for a high-cardinality column."""
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
|
||||
prof = _profile_high_card()
|
||||
cat_names = ["alta_card_col", "baja_card_col"]
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "eda.pptx")
|
||||
res = render_automatic_eda_pptx(prof, out, {"title": "EDA"})
|
||||
assert res["path"] == out and os.path.exists(out)
|
||||
prs = Presentation(out)
|
||||
|
||||
# Per column: the cat_distr slides whose text mentions it, and whether the
|
||||
# owning slide also has the donut caption + an actual picture shape.
|
||||
slides_with_col = {n: [] for n in cat_names}
|
||||
owner_has_chart = {n: False for n in cat_names}
|
||||
for i, sl in enumerate(prs.slides):
|
||||
texts, has_pic = [], False
|
||||
for sh in sl.shapes:
|
||||
if sh.has_text_frame:
|
||||
texts.append(sh.text_frame.text)
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
||||
has_pic = True
|
||||
txt = re.sub(r"\s+", " ", " ".join(texts))
|
||||
if "Distribuciones categ" not in txt: # footer stamp of the chapter.
|
||||
continue
|
||||
for n in cat_names:
|
||||
if n in txt:
|
||||
slides_with_col[n].append(i)
|
||||
has_table = "Cardinalidad" in txt or "distintos" in txt
|
||||
if has_pic and "donut" in txt and has_table:
|
||||
owner_has_chart[n] = True
|
||||
|
||||
for n in cat_names:
|
||||
# Exactly one slide carries the column (not split across slides).
|
||||
assert len(slides_with_col[n]) == 1, (n, slides_with_col[n])
|
||||
# That single slide also holds its table AND its donut picture.
|
||||
assert owner_has_chart[n], (n, "tabla y donut no están en el mismo slide")
|
||||
|
||||
|
||||
def test_edge_sin_categoricas_devuelve_none():
|
||||
only_numeric = {
|
||||
"n_rows": 10, "columns": [
|
||||
@@ -170,11 +329,15 @@ def test_anti_corte_label_largo_y_muchas_columnas():
|
||||
|
||||
ch = build_cat_distr(profile, {})
|
||||
assert ch is not None
|
||||
# One Group per column, each forcing its own page (except the first).
|
||||
groups = _column_groups(ch)
|
||||
assert len(groups) == 30
|
||||
assert sum(1 for g in groups if g.page_break_before) == 29
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
pdf = os.path.join(d, "anti.pdf")
|
||||
res = render_automatic_eda_pdf(profile, pdf, {"write_manifest": False})
|
||||
assert res["path"] == pdf
|
||||
assert res["n_pages"] > 1 # many columns spilled across pages, OK.
|
||||
assert res["n_pages"] > 1 # one page per column, OK.
|
||||
txt = _pdf_text(pdf)
|
||||
# Long label wrapped (not truncated): every word survives.
|
||||
for word in ("Lorem", "incididunt", "reprehenderit", "voluptate"):
|
||||
|
||||
@@ -356,12 +356,11 @@ def build_correlacion(profile: dict, ctx: dict):
|
||||
t_cramers = _term(mark_term, "cramers_v", "Cramér's V")
|
||||
t_corr_ratio = _term(mark_term, "correlation_ratio", "razón de correlación")
|
||||
blocks.append(model.Markdown(text=(
|
||||
"Asociación entre columnas. Cada par se evalúa con la métrica adecuada a "
|
||||
f"sus tipos ({t_pearson}/{t_spearman} entre numéricas — con **signo**; "
|
||||
f"{t_cramers} entre categóricas; {t_corr_ratio} num-categórica; "
|
||||
"información mutua como medida común no lineal). Sólo las correlaciones "
|
||||
"**num-num** tienen dirección: por eso los pares **negativos** son siempre "
|
||||
"num-num.")))
|
||||
"Asociación entre columnas. Cada par se evalúa con la métrica adecuada "
|
||||
f"a sus tipos: {t_pearson}/{t_spearman} (numéricas), {t_cramers} "
|
||||
f"(categóricas), {t_corr_ratio} (num-categórica) e información mutua. "
|
||||
"Sólo las correlaciones **num-num** llevan **signo** (dirección): por "
|
||||
"eso los pares **negativos** son siempre num-num.")))
|
||||
|
||||
# 1) Association matrix (heatmap).
|
||||
labels, trimmed = _ordered_labels(pairs)
|
||||
|
||||
@@ -6,15 +6,16 @@ normality}``). It renders, as structured markdown/tables/figures that the core
|
||||
paginator never cuts:
|
||||
|
||||
1. **Normalization note** — every multivariate model below standardizes the
|
||||
columns with z-score first; the chapter explains why (different scales would
|
||||
otherwise dominate distance/variance).
|
||||
columns with z-score first (the term is marked clickable; its definition
|
||||
lives in the GLOSARIO chapter, not inline).
|
||||
2. **PCA** — a scree plot (explained + cumulative variance, single Y axis) plus
|
||||
variance and top-loadings tables.
|
||||
3. **KMeans segments** — a PCA scatter **coloured by cluster** (its own
|
||||
page/slide), the cluster-size table, and a per-cluster LLM micro-analysis
|
||||
with a title for each segment.
|
||||
4. **Isolation Forest outliers** — a short explanation of how anomalous rows are
|
||||
isolated multivariately and how the threshold is chosen, plus the counts.
|
||||
4. **Isolation Forest outliers** — the multivariate anomaly counts and decision
|
||||
threshold (the method is marked clickable; its definition lives in the
|
||||
GLOSARIO chapter, not inline).
|
||||
5. **Normality** — per-column Jarque-Bera / D'Agostino / Shapiro verdicts.
|
||||
|
||||
The raw numeric data needed to colour the cluster scatter is **not** in the
|
||||
@@ -314,12 +315,8 @@ def _normalization_intro(gloss=None, mark_term: bool = False) -> list:
|
||||
text = (
|
||||
"Estos modelos son **no supervisados**: buscan estructura latente sin "
|
||||
"una variable objetivo. Antes de aplicarlos, todas las columnas "
|
||||
f"numéricas se {zscore} (cada valor menos la media, dividido por la "
|
||||
"desviación típica). Sin esta normalización, una variable con escala "
|
||||
"grande (p.ej. ingresos en euros) dominaría las distancias y la varianza "
|
||||
"frente a otra de escala pequeña (p.ej. un ratio entre 0 y 1), sesgando "
|
||||
"tanto el PCA como el KMeans. Tras la estandarización todas las variables "
|
||||
"pesan por igual."
|
||||
f"numéricas se {zscore}, para que todas pesen por igual con "
|
||||
"independencia de su escala."
|
||||
)
|
||||
return [model.Heading(text="Modelos no supervisados", level=1),
|
||||
model.Markdown(text=text)]
|
||||
@@ -334,11 +331,11 @@ def _pca_section(pca: dict, gloss=None, mark_term: bool = False) -> list:
|
||||
n_used = pca.get("n_rows_used")
|
||||
n_feat = pca.get("n_features")
|
||||
intro = (
|
||||
f"El {_term(mark_term, 'pca', 'PCA')} resume {_fmt_num(n_feat)} variables "
|
||||
"numéricas en componentes ortogonales ordenados por la varianza que "
|
||||
f"capturan ({_fmt_num(n_used)} filas usadas tras eliminar nulos). El "
|
||||
"gráfico de sedimentación (scree) muestra cuánta varianza aporta cada "
|
||||
"componente y su acumulado: un codo marca cuántos componentes bastan."
|
||||
f"El {_term(mark_term, 'pca', 'PCA')} se aplica sobre "
|
||||
f"{_fmt_num(n_feat)} variables numéricas ({_fmt_num(n_used)} filas "
|
||||
"usadas tras eliminar nulos). El gráfico de sedimentación (scree) "
|
||||
"muestra cuánta varianza aporta cada componente y su acumulado: un "
|
||||
"codo marca cuántos componentes bastan."
|
||||
)
|
||||
blocks.append(model.Markdown(text=intro))
|
||||
|
||||
@@ -403,9 +400,8 @@ def _kmeans_section(kmeans: dict, projection: dict, titles,
|
||||
t_sil = _term(mark_term, "silhouette", "*silhouette*")
|
||||
intro = (
|
||||
f"{t_kmeans} agrupa las filas en **{_fmt_num(best_k)} segmentos** "
|
||||
f"elegidos automáticamente maximizando el coeficiente de {t_sil} "
|
||||
f"(**{_fmt_num(sil)}**, rango −1 a 1: cuanto más alto, segmentos más "
|
||||
"compactos y separados). Los segmentos se proyectan sobre el plano de "
|
||||
f"elegidos automáticamente por el coeficiente de {t_sil} "
|
||||
f"(**{_fmt_num(sil)}**). Los segmentos se proyectan sobre el plano de "
|
||||
"los dos primeros componentes principales para visualizarlos."
|
||||
)
|
||||
blocks.append(model.Markdown(text=intro))
|
||||
@@ -469,14 +465,10 @@ def _outliers_section(outliers: dict, gloss=None, mark_term: bool = False) -> li
|
||||
level=2)]
|
||||
isof = _term(mark_term, "isolation_forest", "**Isolation Forest**")
|
||||
explain = (
|
||||
f"{isof} detecta filas anómalas de forma *multivariante*: "
|
||||
"construye árboles que parten el espacio con cortes aleatorios y mide "
|
||||
"cuántos cortes hacen falta para aislar cada fila. Las filas raras "
|
||||
"(combinaciones de valores poco frecuentes considerando **todas las "
|
||||
"columnas a la vez**, no una sola) se aíslan con muy pocos cortes y "
|
||||
"obtienen un score bajo. El **umbral** de decisión separa las filas "
|
||||
"normales de las anómalas según la contaminación esperada del modelo: "
|
||||
"una fila es outlier cuando su score queda por debajo de ese umbral."
|
||||
f"{isof} marca filas anómalas de forma *multivariante*: combinaciones "
|
||||
"de valores poco frecuentes considerando **todas las columnas a la "
|
||||
"vez**, no una sola. La tabla resume cuántas se detectaron y el umbral "
|
||||
"de decisión empleado."
|
||||
)
|
||||
blocks.append(model.Markdown(text=explain))
|
||||
blocks.append(model.KVTable(rows=[
|
||||
|
||||
@@ -256,14 +256,14 @@ def _pk_candidates_section(profile: dict, mark: bool) -> list:
|
||||
pk = ("[[term:pk]]**clave primaria**[[/term]]" if mark
|
||||
else "**clave primaria**")
|
||||
intro = (
|
||||
f"Estas columnas son **candidatas a {pk}**: su "
|
||||
"[[term:cardinalidad]]cardinalidad[[/term]] iguala al número de filas y no "
|
||||
"tienen nulos, así que cada valor identifica una fila distinta. Son "
|
||||
"candidatas, no una clave declarada: la base no las marca como tal."
|
||||
f"Columnas **candidatas a {pk}**: su "
|
||||
"[[term:cardinalidad]]cardinalidad[[/term]] iguala al número de filas y "
|
||||
"no tienen nulos. Son candidatas, no una clave declarada: la base no "
|
||||
"las marca como tal."
|
||||
if mark else
|
||||
"Estas columnas son **candidatas a clave primaria**: su cardinalidad "
|
||||
"iguala al número de filas y no tienen nulos, así que cada valor "
|
||||
"identifica una fila distinta.")
|
||||
"Columnas **candidatas a clave primaria**: su cardinalidad iguala al "
|
||||
"número de filas y no tienen nulos. Son candidatas, no una clave "
|
||||
"declarada.")
|
||||
|
||||
rows = []
|
||||
for name in keys:
|
||||
@@ -320,10 +320,10 @@ def _inter_table_section(db_path: str, tables: list, mark: bool) -> list:
|
||||
blocks = [
|
||||
model.Heading(text="Claves foráneas candidatas (inter-tabla)", level=2),
|
||||
model.Markdown(text=(
|
||||
f"La fuente tiene varias tablas. Estas {fk_term} candidatas se infieren "
|
||||
f"por señal de nombre y por {containment}: una columna de una tabla cuyos "
|
||||
"valores están contenidos en la clave de otra. No están declaradas por "
|
||||
"la base; son la relación más probable según los datos.")),
|
||||
f"La fuente tiene varias tablas. Estas {fk_term} candidatas se "
|
||||
f"infieren por señal de nombre y por {containment}. No están "
|
||||
"declaradas por la base; son la relación más probable según los "
|
||||
"datos.")),
|
||||
]
|
||||
|
||||
shown = candidates[:MAX_FK_ROWS]
|
||||
@@ -441,13 +441,12 @@ def _intro_blocks(mark: bool) -> list:
|
||||
pk = "[[term:pk]]clave primaria[[/term]]" if mark else "clave primaria"
|
||||
fk = "[[term:fk]]clave foránea[[/term]]" if mark else "clave foránea"
|
||||
text = (
|
||||
f"Este capítulo analiza las **relaciones de clave** de la tabla: qué columna "
|
||||
f"identifica cada fila (la {pk}) y qué columnas referencian a otra tabla (las "
|
||||
f"{fk}). Cuando la base las **declara** como restricciones del esquema, se "
|
||||
"muestran tal cual; cuando no, se proponen las más probables a partir de los "
|
||||
"datos —por inclusión de valores entre tablas (containment) o, en una sola "
|
||||
"tabla, por una heurística de nombre y cardinalidad— siempre marcadas como "
|
||||
"candidatas, nunca como hechos.")
|
||||
f"Este capítulo analiza las **relaciones de clave** de la tabla: cuál es "
|
||||
f"la {pk} y cuáles son las {fk}. Cuando la base las **declara** como "
|
||||
"restricciones del esquema, se muestran tal cual; cuando no, se proponen "
|
||||
"las más probables a partir de los datos —por containment entre tablas o, "
|
||||
"en una sola tabla, por una heurística de nombre y cardinalidad— siempre "
|
||||
"marcadas como candidatas, nunca como hechos.")
|
||||
return [model.Heading(text=CHAPTER_TITLE, level=1), model.Markdown(text=text)]
|
||||
|
||||
|
||||
|
||||
@@ -139,10 +139,17 @@ class Group:
|
||||
it starts on a fresh page and flows (honest degradation, never cut). Use it to
|
||||
bind ``Heading`` + ``Markdown`` + ``Figure`` of one idea together (see the
|
||||
DISTR NUM / AGREGACION chapters).
|
||||
|
||||
When ``page_break_before`` is True the renderer additionally forces the group
|
||||
to *start* on a fresh page/slide (unless the current one is already empty), so
|
||||
a chapter can give each unit its own page — e.g. one categorical column per
|
||||
page (see CAT DISTR). It is purely additive: the default False keeps the plain
|
||||
keep-together behaviour for every existing chapter.
|
||||
"""
|
||||
|
||||
blocks: list = field(default_factory=list)
|
||||
title: Optional[str] = None
|
||||
page_break_before: bool = False
|
||||
kind: str = field(default="group", init=False)
|
||||
|
||||
|
||||
@@ -228,7 +235,9 @@ def as_block(obj: Any):
|
||||
return Note(text=_safe_str(obj.get("text")))
|
||||
if cls is Group:
|
||||
return Group(blocks=as_blocks(obj.get("blocks")),
|
||||
title=obj.get("title"))
|
||||
title=obj.get("title"),
|
||||
page_break_before=bool(
|
||||
obj.get("page_break_before", False)))
|
||||
if cls is GlossaryEntry:
|
||||
return GlossaryEntry(key=_safe_str(obj.get("key")),
|
||||
label=_safe_str(obj.get("label")),
|
||||
|
||||
@@ -675,6 +675,61 @@ def _measure_figure_like(block) -> float:
|
||||
return target_h + 0.04 + cap_h + _GAP
|
||||
|
||||
|
||||
def _measure_kv_table(block) -> float:
|
||||
"""Faithful height of a KVTable — matches ``_place_kv_table``.
|
||||
|
||||
Counts the optional title heading and, per row, the wrapped VALUE column
|
||||
(the label column never wraps in the placer). The previous estimate assumed
|
||||
one line per row and ignored the title, so a column's keep-together Group
|
||||
under-budgeted the figure and the chart spilled to the next page. Keep this in
|
||||
sync with ``_place_kv_table``."""
|
||||
h = 0.0
|
||||
title = getattr(block, "title", None)
|
||||
if title:
|
||||
h += _measure_heading_text(title, 2)
|
||||
rows = getattr(block, "rows", []) or []
|
||||
key_w = 1.9
|
||||
val_chars = tl.chars_per_line(_USABLE_W - key_w - 0.1, _FS_BODY)
|
||||
lh = tl.line_height_in(_FS_BODY)
|
||||
for row in rows:
|
||||
try:
|
||||
value = row[1]
|
||||
except Exception: # noqa: BLE001
|
||||
value = ""
|
||||
v_lines = tl.wrap(model._safe_str(value), val_chars)
|
||||
h += lh * len(v_lines) + _ROW_VPAD
|
||||
return h + _GAP
|
||||
|
||||
|
||||
def _measure_data_table(block) -> float:
|
||||
"""Faithful height of a DataTable — matches ``_place_data_table``.
|
||||
|
||||
Counts the optional title heading, the wrapped header row, every wrapped data
|
||||
row (per-column wrap via the same ``_col_widths``/``_wrap_row`` the placer
|
||||
uses) and the optional note. Keep this in sync with ``_place_data_table``."""
|
||||
h = 0.0
|
||||
title = getattr(block, "title", None)
|
||||
if title:
|
||||
h += _measure_heading_text(title, 2)
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
fs = _FS_CELL
|
||||
widths = _col_widths(header, rows, fs)
|
||||
lh = tl.line_height_in(fs)
|
||||
if header:
|
||||
header_lines = _wrap_row(header, widths, fs)
|
||||
h += lh * max((len(c) for c in header_lines), default=1) + _ROW_VPAD * 2
|
||||
for r in rows:
|
||||
cells_lines = _wrap_row(r, widths, fs)
|
||||
h += lh * max((len(c) for c in cells_lines), default=1) + _ROW_VPAD * 2
|
||||
note = getattr(block, "note", None)
|
||||
if note:
|
||||
nlines = tl.wrap(model._safe_str(note),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
||||
h += tl.line_height_in(_FS_NOTE) * len(nlines)
|
||||
return h + _GAP
|
||||
|
||||
|
||||
def _measure_block(st: _PdfState, block) -> float:
|
||||
kind = getattr(block, "kind", "")
|
||||
try:
|
||||
@@ -690,13 +745,9 @@ def _measure_block(st: _PdfState, block) -> float:
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
||||
return tl.line_height_in(_FS_NOTE) * len(lines) + _GAP
|
||||
if kind == "kv_table":
|
||||
rows = getattr(block, "rows", []) or []
|
||||
return (tl.line_height_in(_FS_BODY) + _ROW_VPAD) * (len(rows) + 1) \
|
||||
+ _GAP
|
||||
return _measure_kv_table(block)
|
||||
if kind == "data_table":
|
||||
rows = getattr(block, "rows", []) or []
|
||||
return (tl.line_height_in(_FS_CELL) + _ROW_VPAD * 2) \
|
||||
* (len(rows) + 1) + _GAP
|
||||
return _measure_data_table(block)
|
||||
if kind == "group":
|
||||
return sum(_measure_block(st, b)
|
||||
for b in (getattr(block, "blocks", []) or []))
|
||||
@@ -735,6 +786,10 @@ def _place_group(st: _PdfState, block) -> None:
|
||||
blocks = getattr(block, "blocks", []) or []
|
||||
if not blocks:
|
||||
return
|
||||
# Opt-in page break: start this group on a fresh page unless the current one
|
||||
# is still empty (so a chapter can give each unit its own page).
|
||||
if getattr(block, "page_break_before", False) and st.y > _CONTENT_TOP + 1e-6:
|
||||
_new_page(st)
|
||||
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
|
||||
_shrink_group_figures(st, blocks, avail_full)
|
||||
total = sum(_measure_block(st, b) for b in blocks)
|
||||
|
||||
@@ -625,6 +625,55 @@ def _measure_figure_like(block) -> float:
|
||||
return target_h + 0.05 + cap_h + _GAP
|
||||
|
||||
|
||||
def _measure_kv_table(block) -> float:
|
||||
"""Faithful KVTable height — matches ``_place_kv_table`` (rendered as a
|
||||
Campo/Valor data table with wrapped cells). The previous estimate assumed one
|
||||
line per row and ignored the title, so a keep-together Group under-budgeted
|
||||
the figure and the chart spilled to the next slide. Keep in sync."""
|
||||
h = 0.0
|
||||
title = getattr(block, "title", None)
|
||||
if title:
|
||||
h += _measure_heading_text(title, 2)
|
||||
rows = getattr(block, "rows", []) or []
|
||||
data_rows = []
|
||||
for row in rows:
|
||||
try:
|
||||
label, value = row[0], row[1]
|
||||
except Exception: # noqa: BLE001
|
||||
label, value = str(row), ""
|
||||
data_rows.append([model._safe_str(label), model._safe_str(value)])
|
||||
header = ["Campo", "Valor"]
|
||||
widths = _col_widths(header, data_rows)
|
||||
fs = _FS_CELL
|
||||
h += _row_height_in(header, widths, fs)
|
||||
for r in data_rows:
|
||||
h += _row_height_in(r, widths, fs)
|
||||
return h + _GAP
|
||||
|
||||
|
||||
def _measure_data_table(block) -> float:
|
||||
"""Faithful DataTable height — matches ``_place_data_table`` (title heading +
|
||||
wrapped header + every wrapped row + optional note). Keep in sync."""
|
||||
h = 0.0
|
||||
title = getattr(block, "title", None)
|
||||
if title:
|
||||
h += _measure_heading_text(title, 2)
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
fs = _FS_CELL
|
||||
widths = _col_widths(header, rows)
|
||||
if header:
|
||||
h += _row_height_in(header, widths, fs)
|
||||
for r in rows:
|
||||
h += _row_height_in(r, widths, fs)
|
||||
note = getattr(block, "note", None)
|
||||
if note:
|
||||
nlines = tl.wrap(model._safe_str(note),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
||||
h += tl.line_height_in(_FS_NOTE) * len(nlines) + 0.05
|
||||
return h + _GAP
|
||||
|
||||
|
||||
def _measure_block(st: _PptxState, block) -> float:
|
||||
kind = getattr(block, "kind", "")
|
||||
try:
|
||||
@@ -639,9 +688,10 @@ def _measure_block(st: _PptxState, block) -> float:
|
||||
lines = tl.wrap(getattr(block, "text", ""),
|
||||
tl.chars_per_line(_USABLE_W, _FS_NOTE))
|
||||
return tl.line_height_in(_FS_NOTE) * len(lines) + 0.05 + _GAP
|
||||
if kind in ("kv_table", "data_table"):
|
||||
rows = getattr(block, "rows", []) or []
|
||||
return (tl.line_height_in(_FS_CELL) + 0.10) * (len(rows) + 1) + _GAP
|
||||
if kind == "kv_table":
|
||||
return _measure_kv_table(block)
|
||||
if kind == "data_table":
|
||||
return _measure_data_table(block)
|
||||
if kind == "group":
|
||||
return sum(_measure_block(st, b)
|
||||
for b in (getattr(block, "blocks", []) or []))
|
||||
@@ -664,10 +714,14 @@ def _shrink_group_figures(st: _PptxState, blocks: list, avail_full: float) -> No
|
||||
if getattr(b, "kind", "") not in ("figure", "image"))
|
||||
fig_overhead = tl.line_height_in(_FS_NOTE) + 0.05 + 0.05 + _GAP
|
||||
budget = avail_full - nonfig_h - 0.10 * len(fig_blocks)
|
||||
if budget <= 1.0:
|
||||
# Low thresholds: a 16:9 slide is short, so a content-heavy column (cardinality
|
||||
# table + top-k + chart) only fits if the chart is allowed to shrink small.
|
||||
# Prefer a small-but-present chart on the SAME slide over splitting the column
|
||||
# across slides (matches the PDF renderer's keep-together philosophy).
|
||||
if budget <= 0.6:
|
||||
return # not enough room to keep together; let it flow (degrade).
|
||||
per = budget / len(fig_blocks) - fig_overhead
|
||||
if per <= 0.8:
|
||||
if per <= 0.35:
|
||||
return
|
||||
for fb in fig_blocks:
|
||||
cur = getattr(fb, "height_in", None)
|
||||
@@ -675,12 +729,90 @@ def _shrink_group_figures(st: _PptxState, blocks: list, avail_full: float) -> No
|
||||
if isinstance(cur, (int, float)) and cur > 0 else per)
|
||||
|
||||
|
||||
# Minimum height (inches) reserved for a figure inside a keep-together group on
|
||||
# the short 16:9 slide. When a high-cardinality column's table(s) would otherwise
|
||||
# leave no room, the data table is trimmed (with an honest note) so the chart
|
||||
# stays on the SAME slide next to its table instead of spilling to the next one.
|
||||
_GROUP_MIN_FIG_H = 1.3
|
||||
|
||||
|
||||
def _trim_data_table_to_budget(block, budget: float):
|
||||
"""Return a copy of a DataTable whose rows fit within ``budget`` inches.
|
||||
|
||||
Keeps the title, header, as many leading rows as fit (at least one) and an
|
||||
honest note reporting how many of the original rows are shown. NEVER mutates
|
||||
the original block — the same Chapter blocks are rendered by the PDF renderer,
|
||||
which keeps the full table (an A5 page fits it)."""
|
||||
header = list(getattr(block, "header", []) or [])
|
||||
rows = list(getattr(block, "rows", []) or [])
|
||||
title = getattr(block, "title", None)
|
||||
fs = _FS_CELL
|
||||
widths = _col_widths(header, rows)
|
||||
fixed = 0.0
|
||||
if title:
|
||||
fixed += _measure_heading_text(title, 2)
|
||||
if header:
|
||||
fixed += _row_height_in(header, widths, fs)
|
||||
note_h = tl.line_height_in(_FS_NOTE) + 0.05
|
||||
avail_rows = budget - fixed - note_h - _GAP
|
||||
kept = []
|
||||
used = 0.0
|
||||
for r in rows:
|
||||
rh = _row_height_in(r, widths, fs)
|
||||
if used + rh > avail_rows and kept:
|
||||
break
|
||||
kept.append(r)
|
||||
used += rh
|
||||
if len(kept) >= len(rows):
|
||||
return block # already fits; keep the original (with its own note).
|
||||
note = (f"top {len(kept)} de {len(rows)} categorías mostradas "
|
||||
"(recortado para caber en el slide; el PDF muestra más)")
|
||||
return model.DataTable(header=header, rows=kept, title=title, note=note)
|
||||
|
||||
|
||||
def _fit_group_blocks(st: _PptxState, blocks: list, avail_full: float) -> list:
|
||||
"""Return a slide-fitting copy of a keep-together group's blocks.
|
||||
|
||||
On the short 16:9 slide a high-cardinality column's top-k table plus its
|
||||
chart can overflow. Reserve ``_GROUP_MIN_FIG_H`` for the (later shrunk) figure
|
||||
and trim the data table(s) to what is left, so every column keeps its chart
|
||||
next to its table on ONE slide. No-op when the group has no figure+table pair
|
||||
(e.g. id-like columns already drop the top-k upstream, or it already fits)."""
|
||||
has_fig = any(getattr(b, "kind", "") in ("figure", "image") for b in blocks)
|
||||
tbls = [b for b in blocks if getattr(b, "kind", "") == "data_table"]
|
||||
if not (has_fig and tbls):
|
||||
return blocks
|
||||
fixed_h = sum(_measure_block(st, b) for b in blocks
|
||||
if getattr(b, "kind", "") not in ("figure", "image",
|
||||
"data_table"))
|
||||
tables_h = sum(_measure_block(st, b) for b in tbls)
|
||||
budget_tables = avail_full - fixed_h - _GROUP_MIN_FIG_H
|
||||
if tables_h <= budget_tables:
|
||||
return blocks # already fits next to a min-height figure; leave intact.
|
||||
out = []
|
||||
for b in blocks:
|
||||
if getattr(b, "kind", "") != "data_table":
|
||||
out.append(b)
|
||||
continue
|
||||
trimmed = _trim_data_table_to_budget(b, max(budget_tables, 0.8))
|
||||
out.append(trimmed)
|
||||
budget_tables -= _measure_data_table(trimmed)
|
||||
return out
|
||||
|
||||
|
||||
def _place_group(st: _PptxState, block) -> None:
|
||||
"""Render a keep-together Group: move it whole to the next slide if needed."""
|
||||
blocks = getattr(block, "blocks", []) or []
|
||||
if not blocks:
|
||||
return
|
||||
# Opt-in slide break: start this group on a fresh slide unless the current one
|
||||
# is still empty (so a chapter can give each unit its own slide).
|
||||
if getattr(block, "page_break_before", False) and st.y > _CONTENT_TOP + 1e-6:
|
||||
_new_slide(st, cont=True)
|
||||
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
|
||||
# Trim oversized tables first (keeps the chart on the same slide), then shrink
|
||||
# the figure to share the remaining room.
|
||||
blocks = _fit_group_blocks(st, blocks, avail_full)
|
||||
_shrink_group_figures(st, blocks, avail_full)
|
||||
total = sum(_measure_block(st, b) for b in blocks)
|
||||
if total <= avail_full:
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
id: draw_join_graph_figure_py_datascience
|
||||
name: draw_join_graph_figure
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def draw_join_graph_figure(join_graph: dict, title: str = None) -> \"matplotlib.figure.Figure\""
|
||||
description: "Rasteriza el join graph de una base (relaciones FK inter-tabla, salida de build_join_graph) a un matplotlib.figure.Figure: nodos circulares con el nombre de cada tabla (hubs en color de acento cálido, el resto neutro) y aristas dirigidas etiquetadas from_col→to_col (más la cardinalidad si viene). Es la contrapartida dibujada del string Mermaid para que el capítulo de relaciones del informe AutomaticEDA muestre un diagrama real. Layout networkx spring_layout determinista (seed=42), backend Agg sin abrir ventanas; defensivo: nunca lanza y nunca hace I/O."
|
||||
tags: [eda, plot, relations, graph, matplotlib, figure, networkx, datascience, impure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [matplotlib, networkx]
|
||||
example: |
|
||||
from draw_join_graph_figure import draw_join_graph_figure
|
||||
join_graph = {
|
||||
"nodes": [
|
||||
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||
{"table": "orders", "out_degree": 1, "in_degree": 0, "role": "fact"},
|
||||
],
|
||||
"edges": [
|
||||
{"from_table": "orders", "from_col": "customer_id",
|
||||
"to_table": "customers", "to_col": "id", "cardinality": "N:1"},
|
||||
],
|
||||
"hubs": ["orders"],
|
||||
}
|
||||
fig = draw_join_graph_figure(join_graph, title="Relaciones FK")
|
||||
fig.savefig("/tmp/join_graph.png")
|
||||
tested: true
|
||||
tests:
|
||||
- "test_returns_figure_with_axis"
|
||||
- "test_savefig_produces_nonempty_png"
|
||||
- "test_empty_dict_does_not_raise_and_savefig_png"
|
||||
- "test_none_does_not_raise_and_savefig_png"
|
||||
test_file_path: "python/functions/datascience/draw_join_graph_figure_test.py"
|
||||
file_path: "python/functions/datascience/draw_join_graph_figure.py"
|
||||
params:
|
||||
- name: join_graph
|
||||
desc: "Dict producido por build_join_graph. Claves: `nodes` (list[dict] con table, out_degree, in_degree, role), `edges` (list[dict] con from_table, from_col, to_table, to_col y opcional cardinality/inclusion) y `hubs` (list[str] de tablas hub a destacar en color cálido). Claves ausentes, items no-dict, None o {} se toleran (devuelve Figure con texto, sin lanzar). Los nombres de nodo se derivan también de las aristas, así que un grafo con edges pero sin nodes explícitos igual se dibuja."
|
||||
- name: title
|
||||
desc: "Título dibujado sobre el diagrama. Si se omite (None) se usa \"Join graph\". Default None."
|
||||
output: "Un matplotlib.figure.Figure (figsize 7x5) con un único Axes que contiene el diagrama node-link dirigido: tablas como nodos circulares etiquetados (hubs en acento cálido #DD8452, resto en azul neutro #4C72B0) y FKs como flechas dirigidas con etiqueta from_col→to_col (+ cardinalidad). Si join_graph no tiene nodos ni aristas (o es None/{}), devuelve igualmente una Figure con el texto centrado \"Sin relaciones FK detectadas.\"; ante cualquier fallo interno devuelve una Figure con un mensaje genérico (nunca lanza). El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from draw_join_graph_figure import draw_join_graph_figure
|
||||
|
||||
# `join_graph` es la salida de build_join_graph (nodes + edges + hubs).
|
||||
join_graph = {
|
||||
"nodes": [
|
||||
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||
{"table": "orders", "out_degree": 2, "in_degree": 0, "role": "fact"},
|
||||
{"table": "products", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||
],
|
||||
"edges": [
|
||||
{"from_table": "orders", "from_col": "customer_id",
|
||||
"to_table": "customers", "to_col": "id", "cardinality": "N:1"},
|
||||
{"from_table": "orders", "from_col": "product_id",
|
||||
"to_table": "products", "to_col": "id", "cardinality": "N:1"},
|
||||
],
|
||||
"hubs": ["orders"], # `orders` se pinta en color de acento (tabla de hechos)
|
||||
}
|
||||
|
||||
fig = draw_join_graph_figure(join_graph, title="Relaciones FK")
|
||||
|
||||
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
|
||||
fig.savefig("/tmp/join_graph.png")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala en el capítulo de relaciones de un informe AutomaticEDA cuando quieras un
|
||||
diagrama **dibujado** del esquema relacional, no solo el bloque Mermaid pegable.
|
||||
Pásale directamente la salida de `build_join_graph` (`nodes` + `edges` + `hubs`)
|
||||
y obtienes una `matplotlib.figure.Figure` lista para que el renderer perezoso la
|
||||
rasterice. Es la pareja visual del string Mermaid: Mermaid sirve para pegar en
|
||||
Markdown/docs que lo soporten; esta función produce la imagen real (PNG/PDF) que
|
||||
va embebida en informes que no renderizan Mermaid.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura por matplotlib.** Fija el backend `Agg` al importar — no abre
|
||||
ventanas ni depende de un display. Segura de llamar en lotes desde el
|
||||
renderer.
|
||||
- **Layout determinista (`seed=42`).** Usa `nx.spring_layout(G, seed=42)`, así
|
||||
que la misma entrada produce el mismo diagrama (test reproducible). Para
|
||||
grafos de 0/1 nodos usa una posición fija centrada en vez del spring layout.
|
||||
- **No hace I/O.** No llama `plt.show()` ni guarda a disco — solo devuelve la
|
||||
`Figure`. Quien la consume la rasteriza y la libera (`plt.close(fig)`) para no
|
||||
acumular memoria en informes con muchas tablas.
|
||||
- **Devuelve una Figure, NO un dict.** A diferencia de `build_join_graph` (que
|
||||
devuelve el dict del grafo), esta función devuelve el objeto de figura ya
|
||||
dibujado.
|
||||
- **Defensiva, nunca lanza.** `None`, `{}`, claves ausentes o items malformados
|
||||
se manejan sin error: en el peor caso devuelve una `Figure` con
|
||||
"Sin relaciones FK detectadas." (vacío) o un mensaje genérico (fallo interno).
|
||||
No la envuelvas en try/except por miedo a un raise — no lo hay.
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Impure EDA helper: rasterize a join graph to a matplotlib Figure (`eda` group).
|
||||
|
||||
Takes the join graph produced by ``build_join_graph`` (inter-table FK relations)
|
||||
and draws it as a directed node-link diagram on a ready-to-rasterize
|
||||
``matplotlib.figure.Figure``. Hub tables (the ones with the highest out-degree,
|
||||
candidate fact tables of a star schema) are highlighted in a warm accent colour;
|
||||
the rest use a neutral colour. Directed edges carry a ``from_col→to_col`` label
|
||||
(plus the cardinality when present).
|
||||
|
||||
This is the *drawn* counterpart of the Mermaid string that ``build_join_graph``
|
||||
also emits: the relations chapter of an AutomaticEDA report can show a real
|
||||
picture instead of only the pasteable Mermaid block.
|
||||
|
||||
Impure because it touches matplotlib's rendering machinery. It pins the headless
|
||||
Agg backend and a deterministic ``spring_layout`` seed so the output is
|
||||
reproducible. It never raises: on any internal failure (or empty input) it
|
||||
returns a ``Figure`` carrying a centered message, so the lazy render of the
|
||||
document is never broken.
|
||||
"""
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
import networkx as nx # noqa: E402
|
||||
|
||||
# Warm accent reserved for hub tables (candidate fact tables / star-schema cores).
|
||||
_HUB_COLOR = "#DD8452"
|
||||
# Neutral blue for every other table.
|
||||
_NODE_COLOR = "#4C72B0"
|
||||
# Muted gray for the empty/error message text.
|
||||
_MUTED_TEXT = "#5f6b7a"
|
||||
# Edge colour and label colour.
|
||||
_EDGE_COLOR = "#7a7a7a"
|
||||
_EDGE_LABEL_COLOR = "#34495e"
|
||||
# Constant node size; shared with the edge drawing so arrowheads stop at the
|
||||
# node boundary instead of being hidden under the marker.
|
||||
_NODE_SIZE = 2200
|
||||
|
||||
|
||||
def _text_figure(message: str) -> "matplotlib.figure.Figure":
|
||||
"""Return a blank Figure carrying a single centered message.
|
||||
|
||||
Used both for the "no relations" case and as the never-raise fallback.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(7, 5))
|
||||
ax.axis("off")
|
||||
ax.text(
|
||||
0.5,
|
||||
0.5,
|
||||
message,
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=12,
|
||||
color=_MUTED_TEXT,
|
||||
transform=ax.transAxes,
|
||||
)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
def _edge_label(edge: dict) -> str:
|
||||
"""Build the ``from_col→to_col`` label of an edge, appending cardinality."""
|
||||
fc = edge.get("from_col")
|
||||
tc = edge.get("to_col")
|
||||
if fc is not None and tc is not None:
|
||||
label = f"{fc}→{tc}"
|
||||
elif fc is not None:
|
||||
label = str(fc)
|
||||
elif tc is not None:
|
||||
label = str(tc)
|
||||
else:
|
||||
label = ""
|
||||
card = edge.get("cardinality")
|
||||
if card:
|
||||
label = f"{label} ({card})" if label else str(card)
|
||||
return label
|
||||
|
||||
|
||||
def draw_join_graph_figure(join_graph: dict, title: str = None):
|
||||
"""Rasterize a join graph to a matplotlib Figure.
|
||||
|
||||
Builds a ``networkx.DiGraph`` from the graph's nodes and edges, lays it out
|
||||
with a deterministic ``spring_layout`` (``seed=42``) and draws it on a
|
||||
``matplotlib.figure.Figure``: tables as labelled circular nodes (hubs in a
|
||||
warm accent, the rest neutral) and FK relations as directed arrows labelled
|
||||
``from_col→to_col`` (plus cardinality when available).
|
||||
|
||||
The function never raises. On empty/``None`` input it returns a Figure with
|
||||
a centered "Sin relaciones FK detectadas." message; on any internal failure
|
||||
it returns a Figure with a generic centered message. It never shows the
|
||||
figure nor writes it to disk — the document renderer rasterizes it.
|
||||
|
||||
Args:
|
||||
join_graph: Dict produced by ``build_join_graph`` with keys ``nodes``
|
||||
(list of ``{table, out_degree, in_degree, role}``), ``edges`` (list
|
||||
of ``{from_table, from_col, to_table, to_col, cardinality?,
|
||||
inclusion?}``) and ``hubs`` (list of hub table names to highlight).
|
||||
Missing keys, non-dict items, ``None`` or ``{}`` are all tolerated.
|
||||
title: Optional title drawn above the diagram. When omitted, the title
|
||||
defaults to "Join graph".
|
||||
|
||||
Returns:
|
||||
A ``matplotlib.figure.Figure`` (figsize 7x5) with a single Axes holding
|
||||
the node-link diagram. The caller rasterizes/closes it.
|
||||
"""
|
||||
try:
|
||||
jg = join_graph if isinstance(join_graph, dict) else {}
|
||||
nodes = jg.get("nodes") or []
|
||||
edges = jg.get("edges") or []
|
||||
hubs = {h for h in (jg.get("hubs") or []) if h is not None}
|
||||
|
||||
# Collect node names from the declared nodes and, defensively, from the
|
||||
# edges (so a graph with edges but no explicit nodes still draws).
|
||||
node_names: list = []
|
||||
seen: set = set()
|
||||
|
||||
def _register(name) -> None:
|
||||
if name is not None and name not in seen:
|
||||
seen.add(name)
|
||||
node_names.append(name)
|
||||
|
||||
for n in nodes:
|
||||
if isinstance(n, dict):
|
||||
_register(n.get("table"))
|
||||
for e in edges:
|
||||
if isinstance(e, dict):
|
||||
_register(e.get("from_table"))
|
||||
_register(e.get("to_table"))
|
||||
|
||||
if not node_names:
|
||||
return _text_figure("Sin relaciones FK detectadas.")
|
||||
|
||||
graph = nx.DiGraph()
|
||||
for name in node_names:
|
||||
graph.add_node(name)
|
||||
|
||||
edge_labels: dict = {}
|
||||
for e in edges:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
ft = e.get("from_table")
|
||||
tt = e.get("to_table")
|
||||
if ft is None or tt is None:
|
||||
continue
|
||||
graph.add_edge(ft, tt)
|
||||
edge_labels[(ft, tt)] = _edge_label(e)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(7, 5))
|
||||
|
||||
# Deterministic layout. Fixed positions for trivial graphs so a single
|
||||
# node sits centered instead of at an arbitrary spring-layout point.
|
||||
if graph.number_of_nodes() <= 1:
|
||||
pos = {name: (0.5, 0.5) for name in graph.nodes()}
|
||||
else:
|
||||
pos = nx.spring_layout(graph, seed=42)
|
||||
|
||||
node_colors = [
|
||||
_HUB_COLOR if name in hubs else _NODE_COLOR for name in graph.nodes()
|
||||
]
|
||||
nx.draw_networkx_nodes(
|
||||
graph,
|
||||
pos,
|
||||
ax=ax,
|
||||
node_color=node_colors,
|
||||
node_size=_NODE_SIZE,
|
||||
node_shape="o",
|
||||
edgecolors="white",
|
||||
linewidths=1.5,
|
||||
)
|
||||
nx.draw_networkx_labels(
|
||||
graph,
|
||||
pos,
|
||||
ax=ax,
|
||||
font_size=9,
|
||||
font_color="white",
|
||||
font_weight="bold",
|
||||
)
|
||||
nx.draw_networkx_edges(
|
||||
graph,
|
||||
pos,
|
||||
ax=ax,
|
||||
arrows=True,
|
||||
arrowstyle="-|>",
|
||||
arrowsize=18,
|
||||
edge_color=_EDGE_COLOR,
|
||||
width=1.4,
|
||||
connectionstyle="arc3,rad=0.06",
|
||||
node_size=_NODE_SIZE,
|
||||
)
|
||||
if any(lbl for lbl in edge_labels.values()):
|
||||
nx.draw_networkx_edge_labels(
|
||||
graph,
|
||||
pos,
|
||||
edge_labels=edge_labels,
|
||||
ax=ax,
|
||||
font_size=7,
|
||||
font_color=_EDGE_LABEL_COLOR,
|
||||
bbox={
|
||||
"boxstyle": "round,pad=0.2",
|
||||
"fc": "white",
|
||||
"ec": "none",
|
||||
"alpha": 0.7,
|
||||
},
|
||||
)
|
||||
|
||||
ax.set_title(title if title else "Join graph", fontsize=13)
|
||||
ax.axis("off")
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
except Exception:
|
||||
# Never raise — the document render is lazy and must not be broken.
|
||||
return _text_figure("No se pudo dibujar el join graph.")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests para draw_join_graph_figure (rasteriza el join graph, grupo eda).
|
||||
|
||||
Usa el backend Agg sin abrir ventanas; cada test cierra la Figure construida
|
||||
(matplotlib.pyplot.close) para no acumular estado entre tests. Las aserciones de
|
||||
guardado escriben a tmp_path (fixture de pytest) y comprueban que el PNG no está
|
||||
vacío.
|
||||
"""
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
from matplotlib.figure import Figure # noqa: E402
|
||||
|
||||
from draw_join_graph_figure import draw_join_graph_figure
|
||||
|
||||
|
||||
def _make_join_graph():
|
||||
"""Join graph mínimo: 3 nodos (customers/orders/products) y 2 aristas.
|
||||
|
||||
orders -> customers y orders -> products. `orders` es el hub (out_degree 2).
|
||||
"""
|
||||
return {
|
||||
"nodes": [
|
||||
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||
{"table": "orders", "out_degree": 2, "in_degree": 0, "role": "fact"},
|
||||
{"table": "products", "out_degree": 0, "in_degree": 1, "role": "dimension"},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from_table": "orders",
|
||||
"from_col": "customer_id",
|
||||
"to_table": "customers",
|
||||
"to_col": "id",
|
||||
"cardinality": "N:1",
|
||||
"inclusion": 1.0,
|
||||
},
|
||||
{
|
||||
"from_table": "orders",
|
||||
"from_col": "product_id",
|
||||
"to_table": "products",
|
||||
"to_col": "id",
|
||||
"cardinality": "N:1",
|
||||
"inclusion": 0.98,
|
||||
},
|
||||
],
|
||||
"hubs": ["orders"],
|
||||
}
|
||||
|
||||
|
||||
def test_returns_figure_with_axis():
|
||||
fig = draw_join_graph_figure(_make_join_graph(), title="Relaciones FK")
|
||||
assert isinstance(fig, Figure)
|
||||
# Al menos un eje con el diagrama.
|
||||
assert len(fig.axes) >= 1
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_savefig_produces_nonempty_png(tmp_path):
|
||||
fig = draw_join_graph_figure(_make_join_graph())
|
||||
out = tmp_path / "g.png"
|
||||
fig.savefig(out)
|
||||
assert out.exists()
|
||||
assert out.stat().st_size > 0
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_empty_dict_does_not_raise_and_savefig_png(tmp_path):
|
||||
fig = draw_join_graph_figure({})
|
||||
assert isinstance(fig, Figure)
|
||||
out = tmp_path / "empty.png"
|
||||
fig.savefig(out)
|
||||
assert out.stat().st_size > 0
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_none_does_not_raise_and_savefig_png(tmp_path):
|
||||
fig = draw_join_graph_figure(None)
|
||||
assert isinstance(fig, Figure)
|
||||
out = tmp_path / "none.png"
|
||||
fig.savefig(out)
|
||||
assert out.stat().st_size > 0
|
||||
plt.close(fig)
|
||||
@@ -34,6 +34,7 @@ from .upsert_xlsx_sheet import upsert_xlsx_sheet
|
||||
from .duckdb_query_readonly import duckdb_query_readonly
|
||||
from .duckdb_execute import duckdb_execute
|
||||
from .duckdb_upsert import duckdb_upsert
|
||||
from .load_folder_to_duckdb import load_folder_to_duckdb
|
||||
from .imap_connect import imap_connect
|
||||
from .imap_list_mailboxes import imap_list_mailboxes
|
||||
from .imap_search import imap_search
|
||||
@@ -50,6 +51,7 @@ __all__ = [
|
||||
"upsert_xlsx_sheet",
|
||||
"duckdb_query_readonly",
|
||||
"duckdb_execute",
|
||||
"load_folder_to_duckdb",
|
||||
"duckdb_upsert",
|
||||
"pg_insert_rows",
|
||||
"pg_apply_sql",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: load_folder_to_duckdb
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def load_folder_to_duckdb(folder: str, db_path: str = None, pattern: str = '*.csv,*.parquet,*.json') -> dict"
|
||||
description: "Escanea el primer nivel de una CARPETA buscando archivos tabulares (CSV/TSV/TXT, Parquet, JSON/NDJSON) y los carga como tablas en una base DuckDB usando los lectores nativos read_csv_auto/read_parquet/read_json_auto. Es la pieza de entrada del EDA a nivel de carpeta (grupo eda). Por cada archivo crea una tabla cuyo nombre se deriva del basename saneado a [0-9a-zA-Z_] en minusculas (prefijo t_ si empieza por digito, sufijos _2/_3 ante colisiones, tabla_<i> si queda vacio). El path se escapa (comilla simple '->'') antes de interpolarlo porque los lectores DuckDB no aceptan el path como parametro posicional. Glob NO recursivo: un glob.glob(os.path.join(folder, g)) por cada patron del CSV, dedup y ordenado. db_path=None genera una DuckDB temporal (mkstemp, se borra el placeholder vacio porque DuckDB rechaza un archivo de 0 bytes) y devuelve su ruta. Un fallo al cargar un archivo concreto no aborta el resto: se registra en errors y se continua. Devuelve siempre un dict sin lanzar (estilo del grupo duckdb): {status:'ok', db_path, tables, errors} en exito (carpeta sin archivos tabulares incluida, tables=[]) y {status:'error', error} cuando la carpeta no existe o falla algo global. Depende del paquete duckdb (1.5.2)."
|
||||
tags: [eda, duckdb, ingest, etl, folder]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [glob, os, re, tempfile, duckdb]
|
||||
params:
|
||||
- name: folder
|
||||
desc: "ruta a un directorio. Se escanea solo su primer nivel (NO recursivo). Si no existe o no es un directorio devuelve {status:'error'} sin lanzar."
|
||||
- name: db_path
|
||||
desc: "ruta del archivo DuckDB destino, abierto en modo read-write (lo crea si no existe). None (default) genera una DuckDB temporal unica con tempfile.mkstemp y devuelve su ruta en el campo db_path del retorno. DuckDB es single-writer: si otro proceso lo tiene abierto en escritura, connect falla con error de lock devuelto en el dict."
|
||||
- name: pattern
|
||||
desc: "CSV de globs separados por coma (default '*.csv,*.parquet,*.json'). Cada glob se aplica con glob.glob(os.path.join(folder, g)) sobre el primer nivel de folder; los resultados de todos los globs se deduplican y ordenan. Los globs con ** NO descienden recursivamente (glob.glob sin recursive=True)."
|
||||
output: "dict. En exito: {status:'ok', db_path:str (ruta DuckDB usada), tables:[{name:str, source_file:str, n_rows:int}], errors:[{name?:str, source_file:str, error:str}]}. La carpeta sin archivos tabulares es un exito con tables=[] y errors=[]. En error (sin lanzar): {status:'error', error:str}."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_carga_dos_csv_como_tablas"
|
||||
- "test_db_path_none_crea_temporal"
|
||||
- "test_carpeta_vacia_es_ok_sin_tablas"
|
||||
- "test_carpeta_inexistente_devuelve_status_error"
|
||||
test_file_path: "python/functions/infra/load_folder_to_duckdb_test.py"
|
||||
file_path: "python/functions/infra/load_folder_to_duckdb.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.load_folder_to_duckdb import load_folder_to_duckdb
|
||||
|
||||
# Preparar una carpeta de demo con dos CSV.
|
||||
import os
|
||||
os.makedirs("/tmp/eda_folder_demo", exist_ok=True)
|
||||
with open("/tmp/eda_folder_demo/ventas.csv", "w") as f:
|
||||
f.write("id,total\n1,10.5\n2,20.0\n3,5.25\n")
|
||||
with open("/tmp/eda_folder_demo/clientes.csv", "w") as f:
|
||||
f.write("id,nombre\n1,ana\n2,luis\n")
|
||||
|
||||
# Cargar todos los tabulares de la carpeta a una DuckDB temporal.
|
||||
res = load_folder_to_duckdb("/tmp/eda_folder_demo")
|
||||
print(res["status"]) # ok
|
||||
print(res["db_path"]) # /tmp/tmpXXXXXXXX.duckdb (temporal)
|
||||
for t in res["tables"]:
|
||||
print(t["name"], t["n_rows"]) # ventas 3 / clientes 2
|
||||
|
||||
# Persistir en una DuckDB concreta y limitar a CSV.
|
||||
res2 = load_folder_to_duckdb(
|
||||
"/tmp/eda_folder_demo",
|
||||
db_path="/tmp/eda_folder_demo/folder.duckdb",
|
||||
pattern="*.csv",
|
||||
)
|
||||
print(res2["tables"]) # [{'name': 'clientes', ...}, {'name': 'ventas', ...}]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tienes una carpeta de datos sueltos (un dump, un export, varios CSV/Parquet
|
||||
descargados) y quieres analizarlos juntos con SQL sin montar la ingesta a mano,
|
||||
archivo por archivo. Es el primer eslabon del EDA a nivel de carpeta (grupo `eda`):
|
||||
deja una DuckDB con una tabla por archivo, lista para perfilar con
|
||||
`duckdb_table_schema_py_infra`, consultar con `duckdb_query_readonly_py_infra`, o
|
||||
correlacionar aguas abajo. Usala antes de cualquier paso de perfilado cuando la
|
||||
unidad de trabajo es "todos los archivos de este directorio".
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Glob NO recursivo**: solo se escanea el primer nivel de `folder`. Archivos en
|
||||
subdirectorios se ignoran (ni siquiera con `**` en el patron, porque
|
||||
`glob.glob` se llama sin `recursive=True`). Si necesitas recursion, aplana la
|
||||
carpeta antes o amplia la funcion.
|
||||
- **Saneo de nombres de tabla**: el basename se reduce a `[0-9a-zA-Z_]` en
|
||||
minusculas. `Ventas 2024.csv` -> tabla `ventas_2024`. Dos archivos distintos
|
||||
pueden sanear al mismo nombre (`a-b.csv` y `a_b.csv`); el segundo se desambigua
|
||||
con sufijo `_2`, `_3`, ... El mapeo real archivo->tabla esta en `tables[].name`
|
||||
/ `tables[].source_file`, no lo asumas.
|
||||
- **`read_json_auto` requiere JSON tabular** (array de objetos u objetos NDJSON
|
||||
homogeneos). Un JSON anidado o irregular puede fallar la carga de ESA tabla; el
|
||||
error se registra en `errors` y el resto de archivos siguen cargandose.
|
||||
- **Extension desconocida = se salta**, no falla: queda anotada en `errors` con
|
||||
`unsupported extension`. Mapeo de lectores: `.csv/.tsv/.txt`->`read_csv_auto`,
|
||||
`.parquet/.pq`->`read_parquet`, `.json/.ndjson`->`read_json_auto`.
|
||||
- **Escritura real en disco (impura)**. DuckDB es single-writer: si otro proceso
|
||||
tiene `db_path` abierto en escritura, `connect` falla con error de lock devuelto
|
||||
en el dict. Un `db_path` con un directorio padre inexistente tambien falla.
|
||||
- **`db_path=None` crea un archivo temporal que NO se borra solo**: la ruta se
|
||||
devuelve en `db_path` para que el llamador la consuma y la limpie cuando termine.
|
||||
- **Tipos inferidos por los lectores `_auto`**: los tipos de columna los infiere
|
||||
DuckDB. Revisa el schema con `duckdb_table_schema_py_infra` si el tipado importa
|
||||
aguas abajo.
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Carga una carpeta de archivos tabulares (CSV/Parquet/JSON) como tablas DuckDB.
|
||||
|
||||
Funcion impura: escanea el primer nivel de un directorio buscando archivos que
|
||||
casen con uno o varios globs, y por cada archivo crea una tabla en una base
|
||||
DuckDB usando los lectores nativos (`read_csv_auto`, `read_parquet`,
|
||||
`read_json_auto`). Es la pieza de entrada del EDA a nivel de carpeta (grupo
|
||||
`eda`): deja una DuckDB con una tabla por archivo, lista para perfilar y
|
||||
correlacionar aguas abajo.
|
||||
|
||||
Devuelve siempre un dict sin lanzar excepciones, siguiendo el estilo del grupo
|
||||
duckdb del registry: {status:'ok', db_path, tables, errors} en exito (incluida
|
||||
la carpeta sin archivos tabulares, que es un exito con tables=[]) y
|
||||
{status:'error', error:str} cuando la carpeta no existe o falla algo global.
|
||||
|
||||
El nombre de cada tabla se deriva del basename del archivo, saneado a
|
||||
`[0-9a-zA-Z_]` en minusculas, prefijado con `t_` si empieza por digito, y
|
||||
desambiguado con sufijos `_2`, `_3`, ... ante colisiones. El path del archivo se
|
||||
escapa (comilla simple, `'`->`''`) antes de interpolarlo en el SQL del lector,
|
||||
ya que los lectores DuckDB no admiten el path como parametro posicional. Un fallo
|
||||
al cargar un archivo concreto NO aborta el resto: se registra en `errors` y se
|
||||
continua con los siguientes.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
|
||||
def _sanitize_table_name(basename_no_ext: str, index: int) -> str:
|
||||
"""Deriva un identificador de tabla valido desde el basename de un archivo.
|
||||
|
||||
Reemplaza todo lo que no sea ``[0-9a-zA-Z_]`` por ``_`` y baja a minusculas.
|
||||
Si tras el saneo queda vacio, usa ``tabla_<index>``. Si empieza por digito,
|
||||
prefija ``t_`` para que sea un identificador SQL valido.
|
||||
"""
|
||||
name = re.sub(r"[^0-9a-zA-Z_]", "_", basename_no_ext).lower()
|
||||
if not name:
|
||||
name = f"tabla_{index}"
|
||||
if name[0].isdigit():
|
||||
name = "t_" + name
|
||||
return name
|
||||
|
||||
|
||||
def _reader_for_extension(ext: str, quoted_path: str):
|
||||
"""Devuelve la expresion de lector DuckDB para una extension, o None.
|
||||
|
||||
El ``quoted_path`` ya viene escapado y entre comillas simples. Extensiones
|
||||
desconocidas devuelven None para que el llamador salte el archivo.
|
||||
"""
|
||||
ext = ext.lower()
|
||||
if ext in (".csv", ".tsv", ".txt"):
|
||||
return f"read_csv_auto('{quoted_path}')"
|
||||
if ext in (".parquet", ".pq"):
|
||||
return f"read_parquet('{quoted_path}')"
|
||||
if ext in (".json", ".ndjson"):
|
||||
return f"read_json_auto('{quoted_path}')"
|
||||
return None
|
||||
|
||||
|
||||
def load_folder_to_duckdb(
|
||||
folder: str,
|
||||
db_path: str = None,
|
||||
pattern: str = "*.csv,*.parquet,*.json",
|
||||
) -> dict:
|
||||
"""Carga los archivos tabulares de una carpeta como tablas en una DuckDB.
|
||||
|
||||
Args:
|
||||
folder: ruta a un directorio. Si no existe o no es un directorio,
|
||||
devuelve {status:'error', ...} sin lanzar.
|
||||
db_path: ruta de la DuckDB destino (read-write, se crea si no existe). Si
|
||||
es None, se genera una base temporal con NamedTemporaryFile y su ruta
|
||||
se devuelve en el retorno (`db_path`).
|
||||
pattern: CSV de globs separados por coma (default
|
||||
"*.csv,*.parquet,*.json"). Cada glob se aplica con
|
||||
glob.glob(os.path.join(folder, g)) en el primer nivel (NO recursivo);
|
||||
los resultados se deduplican y ordenan.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', db_path:str, tables:[{name, source_file,
|
||||
n_rows}], errors:[{name?, source_file, error}]}. La carpeta sin archivos
|
||||
tabulares es un exito con tables=[] y errors=[]. En error (sin lanzar):
|
||||
{status:'error', error:str}.
|
||||
"""
|
||||
if not isinstance(folder, str) or not os.path.isdir(folder):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"folder does not exist or is not a directory: {folder!r}",
|
||||
}
|
||||
|
||||
conn = None
|
||||
try:
|
||||
# Resolver la ruta de la DuckDB destino. Si no se da, reservar un nombre
|
||||
# temporal unico y borrar el archivo vacio que crea mkstemp: DuckDB 1.5.2
|
||||
# rechaza abrir un archivo de 0 bytes ("not a valid DuckDB database
|
||||
# file"), por lo que debe crear el archivo el mismo desde cero.
|
||||
if db_path is None:
|
||||
fd, tmp_name = tempfile.mkstemp(suffix=".duckdb")
|
||||
os.close(fd)
|
||||
os.remove(tmp_name)
|
||||
db_path = tmp_name
|
||||
|
||||
# Resolver los archivos: un glob por cada patron, dedup + orden estable.
|
||||
globs = [g.strip() for g in pattern.split(",") if g.strip()]
|
||||
found = set()
|
||||
for g in globs:
|
||||
for path in glob.glob(os.path.join(folder, g)):
|
||||
if os.path.isfile(path):
|
||||
found.add(path)
|
||||
files = sorted(found)
|
||||
|
||||
conn = __import__("duckdb").connect(db_path)
|
||||
|
||||
tables = []
|
||||
errors = []
|
||||
used_names = set()
|
||||
|
||||
for i, path in enumerate(files):
|
||||
base = os.path.basename(path)
|
||||
stem, ext = os.path.splitext(base)
|
||||
quoted_path = path.replace("'", "''")
|
||||
reader = _reader_for_extension(ext, quoted_path)
|
||||
if reader is None:
|
||||
errors.append(
|
||||
{
|
||||
"source_file": path,
|
||||
"error": f"unsupported extension: {ext!r}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
name = _sanitize_table_name(stem, i)
|
||||
# Desambiguar colisiones con sufijos _2, _3, ...
|
||||
if name in used_names:
|
||||
suffix = 2
|
||||
while f"{name}_{suffix}" in used_names:
|
||||
suffix += 1
|
||||
name = f"{name}_{suffix}"
|
||||
|
||||
quoted_ident = '"' + name.replace('"', '""') + '"'
|
||||
try:
|
||||
conn.execute(
|
||||
f"CREATE TABLE {quoted_ident} AS SELECT * FROM {reader}"
|
||||
)
|
||||
n_rows = conn.execute(
|
||||
f"SELECT count(*) FROM {quoted_ident}"
|
||||
).fetchone()[0]
|
||||
used_names.add(name)
|
||||
tables.append(
|
||||
{
|
||||
"name": name,
|
||||
"source_file": path,
|
||||
"n_rows": int(n_rows),
|
||||
}
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(
|
||||
{
|
||||
"name": name,
|
||||
"source_file": path,
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"db_path": db_path,
|
||||
"tables": tables,
|
||||
"errors": errors,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tests para load_folder_to_duckdb."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import duckdb # noqa: E402
|
||||
|
||||
from load_folder_to_duckdb import load_folder_to_duckdb # noqa: E402
|
||||
|
||||
|
||||
def _write_csv(path: str, header: str, rows: list[str]) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(header + "\n")
|
||||
for r in rows:
|
||||
f.write(r + "\n")
|
||||
|
||||
|
||||
def test_carga_dos_csv_como_tablas(tmp_path):
|
||||
_write_csv(
|
||||
str(tmp_path / "ventas.csv"),
|
||||
"id,total",
|
||||
["1,10.5", "2,20.0", "3,5.25"],
|
||||
)
|
||||
_write_csv(
|
||||
str(tmp_path / "clientes.csv"),
|
||||
"id,nombre",
|
||||
["1,ana", "2,luis"],
|
||||
)
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = load_folder_to_duckdb(str(tmp_path), str(db))
|
||||
|
||||
assert res["status"] == "ok", res
|
||||
assert res["errors"] == []
|
||||
assert len(res["tables"]) == 2
|
||||
assert res["db_path"] == str(db)
|
||||
assert os.path.exists(str(db))
|
||||
|
||||
by_name = {t["name"]: t for t in res["tables"]}
|
||||
assert by_name["ventas"]["n_rows"] == 3
|
||||
assert by_name["clientes"]["n_rows"] == 2
|
||||
|
||||
# Verificar que las tablas existen realmente en la base.
|
||||
con = duckdb.connect(str(db), read_only=True)
|
||||
assert con.execute("SELECT count(*) FROM ventas").fetchone()[0] == 3
|
||||
assert con.execute("SELECT count(*) FROM clientes").fetchone()[0] == 2
|
||||
con.close()
|
||||
|
||||
|
||||
def test_db_path_none_crea_temporal(tmp_path):
|
||||
_write_csv(str(tmp_path / "datos.csv"), "x", ["1", "2"])
|
||||
res = load_folder_to_duckdb(str(tmp_path))
|
||||
assert res["status"] == "ok", res
|
||||
assert res["db_path"]
|
||||
assert os.path.exists(res["db_path"])
|
||||
assert len(res["tables"]) == 1
|
||||
assert res["tables"][0]["n_rows"] == 2
|
||||
os.remove(res["db_path"])
|
||||
|
||||
|
||||
def test_carpeta_vacia_es_ok_sin_tablas(tmp_path):
|
||||
db = tmp_path / "out.duckdb"
|
||||
res = load_folder_to_duckdb(str(tmp_path), str(db))
|
||||
assert res["status"] == "ok", res
|
||||
assert res["tables"] == []
|
||||
assert res["errors"] == []
|
||||
|
||||
|
||||
def test_carpeta_inexistente_devuelve_status_error(tmp_path):
|
||||
res = load_folder_to_duckdb(str(tmp_path / "no_existe"))
|
||||
assert res["status"] == "error"
|
||||
assert "folder" in res["error"]
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: render_automatic_eda_folder
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
purity: impure
|
||||
version: "1.0.0"
|
||||
signature: "def render_automatic_eda_folder(path: str, out_dir: str = \"reports\", basename: str = None, profile_level: str = \"standard\", emit_pdf: bool = True, emit_pptx: bool = True, emit_md: bool = True, per_table_eda: bool = False, min_inclusion: float = 0.9, ctx_extra: dict = None) -> dict"
|
||||
description: "Informe AutomaticEDA a nivel de BASE one-shot de una CARPETA de archivos tabulares (CSV/Parquet/JSON) o de una DuckDB existente. Carga la carpeta a una DuckDB temporal con load_folder_to_duckdb (o usa la DuckDB dada directa), perfila TODA la base con profile_database (resumen de cada tabla + FK candidatas por containment + join graph con diagrama Mermaid), ENSAMBLA un documento-base por capitulos (portada-base con nombre/n tablas/totales/fecha/fuente, resumen de tablas con una fila por tabla, y relaciones inter-tabla con la tabla de FK candidatas + una Figure matplotlib REAL del join graph dibujada con draw_join_graph_figure mas el texto Mermaid) y lo renderiza con el motor AutomaticEDA a PDF (A5 movil), PPTX (16:9) y Markdown autocontenido a la vez. Con per_table_eda=True anexa los capitulos de mini-EDA de cada tabla (build_document por tabla). Es el hermano a nivel de base de render_automatic_eda (que perfila UNA tabla): aqui el informe es de la base y de sus relaciones. Devuelve las rutas de PDF/PPTX/MD, el manifiesto y el DatabaseProfile."
|
||||
tags: [eda, duckdb, database, profiling, relations, pipeline, dataops, report, pdf, pptx, launcher]
|
||||
uses_functions:
|
||||
- load_folder_to_duckdb_py_infra
|
||||
- profile_database_py_pipelines
|
||||
- render_automatic_eda_pdf_py_datascience
|
||||
- render_automatic_eda_pptx_py_datascience
|
||||
- render_automatic_eda_markdown_py_datascience
|
||||
- draw_join_graph_figure_py_datascience
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
tested: true
|
||||
tests:
|
||||
- "golden: carpeta con 3 CSV relacionados (customers/orders/products) emite PDF+PPTX+MD del documento-base con 3 tablas y la FK orders.customer_id->customers.id"
|
||||
- "edge: carpeta vacia -> status ok con documento minimo, sin lanzar"
|
||||
- "edge: 1 sola tabla -> funciona sin relaciones (capitulo relaciones dice 'sin FK')"
|
||||
test_file_path: "python/functions/pipelines/render_automatic_eda_folder_test.py"
|
||||
file_path: "python/functions/pipelines/render_automatic_eda_folder.py"
|
||||
params:
|
||||
- name: path
|
||||
desc: "DIRECTORIO con archivos tabulares (CSV/Parquet/JSON) que se cargan a una DuckDB temporal, o una DuckDB ya existente (.duckdb/.ddb/.db) que se perfila directa."
|
||||
- name: out_dir
|
||||
desc: "Directorio de salida de los informes (se crea si no existe). Default 'reports'."
|
||||
- name: basename
|
||||
desc: "Nombre base de los archivos sin extension. Default 'aeda_base_<nombre>_<timestamp>'."
|
||||
- name: profile_level
|
||||
desc: "Preset de coste del perfil por tabla ('lite'/'standard'/'full'); ajusta el sample que profile_database pasa a cada tabla (lite=2000, standard/full=5000)."
|
||||
- name: emit_pdf
|
||||
desc: "Emite el PDF A5 movil del documento-base. Default True."
|
||||
- name: emit_pptx
|
||||
desc: "Emite el PPTX 16:9 del documento-base. Default True."
|
||||
- name: emit_md
|
||||
desc: "Emite el Markdown autocontenido del documento-base. Default True."
|
||||
- name: per_table_eda
|
||||
desc: "Si True, anexa al documento-base los capitulos de mini-EDA de cada tabla (Heading 'Tabla: <n>' + build_document por tabla). Default False (solo documento-base: portada + resumen + relaciones)."
|
||||
- name: min_inclusion
|
||||
desc: "Umbral de inclusion (0-1) para emitir una FK candidata (se pasa a profile_database). Default 0.9."
|
||||
- name: ctx_extra
|
||||
desc: "Dict opcional de claves de presentacion (p.ej. dataset_name, description) que se mezclan en el contexto de la portada-base."
|
||||
output: "Dict dict-no-throw. En exito: {status:'ok', pdf_path, pptx_path, md_path, manifest_path, n_tables, n_pages, n_slides, md_chars, db_path, db_profile}. En error: {status:'error', error:str}."
|
||||
---
|
||||
|
||||
# render_automatic_eda_folder
|
||||
|
||||
EDA de una **carpeta / base multi-tabla** → informe AutomaticEDA por capítulos
|
||||
en PDF (móvil A5) + PPTX (16:9) + Markdown, en una sola llamada. Es el hermano a
|
||||
nivel de **base** de `render_automatic_eda` (que perfila una sola tabla): aquí el
|
||||
documento resume **todas** las tablas y, sobre todo, sus **relaciones**
|
||||
inter-tabla (FK candidatas por containment + join graph con diagrama Mermaid).
|
||||
|
||||
Compone, sin reimplementar su lógica: `load_folder_to_duckdb` (carga la carpeta),
|
||||
`profile_database` (perfila la base + infiere FK + join graph) y los tres
|
||||
renderers del motor AutomaticEDA (`render_automatic_eda_pdf`/`_pptx`/`_markdown`),
|
||||
que aceptan directamente la lista de capítulos del documento-base que este
|
||||
pipeline ensambla. El pipeline de tabla única (`render_automatic_eda`) queda
|
||||
intacto: esto es aditivo.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Carpeta con varios CSV/Parquet/JSON relacionados:
|
||||
./fn run render_automatic_eda_folder /tmp/eda_folder_demo
|
||||
|
||||
# Una DuckDB ya existente (rama directa):
|
||||
./fn run render_automatic_eda_folder temp/bigdata/taxi.duckdb
|
||||
```
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from pipelines.render_automatic_eda_folder import render_automatic_eda_folder
|
||||
|
||||
r = render_automatic_eda_folder("/tmp/eda_folder_demo", out_dir="reports")
|
||||
# r["status"] == "ok"; r["pdf_path"], r["pptx_path"], r["md_path"]
|
||||
# r["n_tables"] == 3; r["db_profile"]["fk_candidates"] incluye
|
||||
# orders.customer_id -> customers.id
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras un EDA de una **base entera** (una carpeta de exports o una
|
||||
DuckDB con varias tablas), no de una sola tabla: para ver de un vistazo qué
|
||||
tablas hay, su tamaño y calidad, y cómo se relacionan (FK candidatas + diagrama),
|
||||
en el mismo formato rico por capítulos (PDF móvil + PPTX + MD) que el EDA de
|
||||
tabla. Usa `per_table_eda=True` cuando además quieras el mini-EDA de cada tabla
|
||||
anexado.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impuro: lee archivos del disco y escribe PDF/PPTX/MD en `out_dir`. En la rama
|
||||
"carpeta" crea una **DuckDB temporal** (su ruta sale en `db_path`); no se borra
|
||||
automáticamente (queda para reinspección).
|
||||
- `path` se interpreta así: directorio → se carga la carpeta; archivo con
|
||||
extensión `.duckdb`/`.ddb`/`.db` → se usa directo; cualquier otro archivo o un
|
||||
path inexistente → `{status:'error'}` (no lanza).
|
||||
- El escaneo de la carpeta es **no recursivo** (solo el primer nivel) y por
|
||||
defecto cubre `*.csv,*.parquet,*.json` (ver `load_folder_to_duckdb`).
|
||||
- El join graph se rasteriza a una **Figure matplotlib real** (vía
|
||||
`draw_join_graph_figure`) que aparece dibujada en PDF/PPTX (nodos = tablas,
|
||||
flechas = FK). Además, el **texto Mermaid** del grafo se incluye como bloque de
|
||||
código (en el Markdown queda como diagrama renderizable y es útil para pegar a
|
||||
un LLM).
|
||||
- Carpeta vacía o con 1 sola tabla: funciona igual; el capítulo de relaciones
|
||||
dice "sin FK". dict-no-throw en todos los caminos.
|
||||
@@ -0,0 +1,366 @@
|
||||
"""render_automatic_eda_folder — EDA de una CARPETA / base multi-tabla one-shot.
|
||||
|
||||
Pipeline impuro del grupo de capacidad `eda`, a nivel de BASE. Dada una CARPETA
|
||||
de archivos tabulares (CSV/Parquet/JSON) o una DuckDB ya existente, produce el
|
||||
informe AutomaticEDA de la BASE en sus tres formatos a la vez (PDF móvil A5 +
|
||||
PPTX 16:9 + Markdown autocontenido), con los capítulos POBLADOS, en una sola
|
||||
llamada. Es el hermano a nivel de base de ``render_automatic_eda`` (que perfila
|
||||
UNA tabla): aquí el documento por capítulos resume TODAS las tablas y, sobre
|
||||
todo, sus RELACIONES inter-tabla (FK candidatas + join graph).
|
||||
|
||||
Compone funciones del registry SIN reimplementar su lógica:
|
||||
|
||||
- load_folder_to_duckdb : carga una carpeta de archivos a una DuckDB temporal
|
||||
(rama "carpeta"). En la rama "ya es duckdb" se omite.
|
||||
- profile_database : perfila TODA la base (resumen de cada tabla,
|
||||
TableProfiles completos, FK candidatas por
|
||||
containment y join graph con diagrama Mermaid).
|
||||
- render_automatic_eda_pdf : renderiza el documento-base por capítulos a PDF.
|
||||
- render_automatic_eda_pptx : renderiza el mismo documento-base a PPTX.
|
||||
- render_automatic_eda_markdown : serializa el mismo documento-base a Markdown
|
||||
autocontenido (texto + tablas markdown).
|
||||
- build_document : (solo con per_table_eda=True) ensambla los capítulos
|
||||
canónicos de CADA tabla para anexarlos al documento.
|
||||
|
||||
La capa propia de este pipeline es ENSAMBLAR EL DOCUMENTO-BASE de capítulos a
|
||||
partir del ``DatabaseProfile`` que devuelve ``profile_database`` y cablear los
|
||||
tres renderers del motor AutomaticEDA. El documento-base mínimo tiene tres
|
||||
capítulos: portada-base (nombre/nº tablas/totales/fecha/fuente), resumen de
|
||||
tablas (una fila por tabla) y relaciones inter-tabla (FK candidatas + diagrama
|
||||
Mermaid). Con ``per_table_eda=True`` anexa, por cada tabla, sus capítulos de
|
||||
mini-EDA.
|
||||
|
||||
Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier error y
|
||||
degrada a ``{"status": "error", "error": str}``.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from datascience import (
|
||||
draw_join_graph_figure,
|
||||
render_automatic_eda_markdown,
|
||||
render_automatic_eda_pdf,
|
||||
render_automatic_eda_pptx,
|
||||
)
|
||||
from datascience.automatic_eda import build_document
|
||||
from infra import load_folder_to_duckdb
|
||||
from pipelines.profile_database import profile_database
|
||||
|
||||
# Mapa profile_level -> tamaño de muestra por columna del perfil de cada tabla.
|
||||
# A nivel de base el coste lo domina el nº de tablas; el preset solo ajusta el
|
||||
# sample que profile_database pasa a profile_table.
|
||||
_SAMPLE_BY_LEVEL = {"lite": 2000, "standard": 5000, "full": 5000}
|
||||
|
||||
# Extensiones que se consideran "una DuckDB ya hecha" en la rama directa.
|
||||
_DUCKDB_EXTS = (".duckdb", ".ddb", ".db")
|
||||
|
||||
|
||||
def _fmt_num(v) -> str:
|
||||
"""Formatea un entero con separador de millar; '—' si no es número."""
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
||||
return "—"
|
||||
try:
|
||||
return f"{int(v):,}".replace(",", ".")
|
||||
except Exception: # noqa: BLE001
|
||||
return str(v)
|
||||
|
||||
|
||||
def _portada_chapter(db_profile: dict, source_path: str, db_path: str,
|
||||
meta_ctx: dict) -> dict:
|
||||
"""Capítulo de portada a nivel de base (NO reusa chapters/portada.py, que es
|
||||
de tabla única): nombre de la base, nº de tablas, totales y procedencia."""
|
||||
tables = db_profile.get("tables", []) or []
|
||||
total_rows = sum(
|
||||
(t.get("n_rows") or 0) for t in tables if isinstance(t.get("n_rows"), (int, float))
|
||||
)
|
||||
total_cols = sum(
|
||||
(t.get("n_cols") or 0) for t in tables if isinstance(t.get("n_cols"), (int, float))
|
||||
)
|
||||
base_name = (meta_ctx or {}).get("dataset_name") or os.path.basename(
|
||||
os.path.normpath(source_path)
|
||||
) or source_path
|
||||
|
||||
rows = [
|
||||
("Base", base_name),
|
||||
("Tablas", _fmt_num(db_profile.get("n_tables"))),
|
||||
("Filas totales", _fmt_num(total_rows)),
|
||||
("Columnas totales", _fmt_num(total_cols)),
|
||||
("Relaciones FK", _fmt_num(len(db_profile.get("fk_candidates", []) or []))),
|
||||
("Fuente", source_path),
|
||||
("DuckDB", db_path),
|
||||
("Generado", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")),
|
||||
]
|
||||
blocks = [
|
||||
{"kind": "heading", "text": f"EDA de la base — {base_name}", "level": 1},
|
||||
{"kind": "kv_table", "rows": rows, "title": "Resumen de la base"},
|
||||
]
|
||||
errs = db_profile.get("errors", []) or []
|
||||
if errs:
|
||||
blocks.append({
|
||||
"kind": "note",
|
||||
"text": f"{len(errs)} aviso(s) durante el perfilado (ver detalle).",
|
||||
})
|
||||
return {"id": "portada_base", "title": "Portada", "version": "1.0.0",
|
||||
"blocks": blocks}
|
||||
|
||||
|
||||
def _resumen_chapter(db_profile: dict) -> dict:
|
||||
"""Capítulo con una fila por tabla: filas, columnas, calidad, key_candidates."""
|
||||
header = ["Tabla", "Filas", "Columnas", "Calidad", "key_candidates"]
|
||||
rows = []
|
||||
for t in db_profile.get("tables", []) or []:
|
||||
keys = ", ".join(t.get("key_candidates") or []) or "—"
|
||||
rows.append([
|
||||
t.get("table"),
|
||||
_fmt_num(t.get("n_rows")),
|
||||
_fmt_num(t.get("n_cols")),
|
||||
t.get("quality_score"),
|
||||
keys,
|
||||
])
|
||||
if rows:
|
||||
blocks = [{
|
||||
"kind": "data_table", "header": header, "rows": rows,
|
||||
"title": "Tablas de la base",
|
||||
"note": "Una fila por tabla. Calidad = score agregado del TableProfile.",
|
||||
}]
|
||||
else:
|
||||
blocks = [{"kind": "note",
|
||||
"text": "La base no contiene tablas perfilables."}]
|
||||
return {"id": "resumen_tablas", "title": "Resumen de tablas",
|
||||
"version": "1.0.0", "blocks": blocks}
|
||||
|
||||
|
||||
def _relaciones_chapter(db_profile: dict) -> dict:
|
||||
"""Capítulo de relaciones inter-tabla: tabla de FK candidatas + diagrama
|
||||
Mermaid del join graph (vuelca el Mermaid como bloque de código)."""
|
||||
fks = db_profile.get("fk_candidates", []) or []
|
||||
blocks = [{
|
||||
"kind": "heading", "text": "Relaciones inter-tabla", "level": 2,
|
||||
}]
|
||||
if fks:
|
||||
header = ["From", "To", "Inclusión", "Cardinalidad"]
|
||||
rows = []
|
||||
for fk in fks:
|
||||
frm = f"{fk.get('from_table')}.{fk.get('from_col')}"
|
||||
to = f"{fk.get('to_table')}.{fk.get('to_col')}"
|
||||
inc = fk.get("inclusion")
|
||||
inc_s = f"{inc:.3f}" if isinstance(inc, (int, float)) else str(inc)
|
||||
rows.append([frm, to, inc_s, fk.get("cardinality")])
|
||||
blocks.append({
|
||||
"kind": "data_table", "header": header, "rows": rows,
|
||||
"title": "FK candidatas (por containment de valores)",
|
||||
"note": "Inclusión = fracción de valores de From contenidos en To.",
|
||||
})
|
||||
else:
|
||||
blocks.append({
|
||||
"kind": "note",
|
||||
"text": "Sin relaciones FK candidatas detectadas entre las tablas.",
|
||||
})
|
||||
|
||||
join_graph = db_profile.get("join_graph") or {}
|
||||
has_edges = bool(join_graph.get("edges"))
|
||||
if has_edges:
|
||||
blocks.append({"kind": "heading", "text": "Diagrama (join graph)",
|
||||
"level": 3})
|
||||
# Figure matplotlib REAL del grafo de relaciones (nodos = tablas,
|
||||
# aristas = FK). Lazy via `make`: el renderer la construye solo al
|
||||
# paginar, y se rasteriza en PDF/PPTX. draw_join_graph_figure nunca
|
||||
# lanza (devuelve una Figure de error si algo falla).
|
||||
blocks.append({
|
||||
"kind": "figure",
|
||||
"make": (lambda jg=join_graph: draw_join_graph_figure(
|
||||
jg, title="Join graph (relaciones inter-tabla)")),
|
||||
"caption": "Grafo de relaciones: nodos = tablas, flechas = FK "
|
||||
"candidatas (etiqueta from_col→to_col).",
|
||||
"height_in": 4.5,
|
||||
})
|
||||
# Además, el Mermaid en texto: en el Markdown queda como diagrama
|
||||
# renderizable y es útil para pegar a un LLM.
|
||||
mermaid = (join_graph.get("mermaid", "") or "").strip()
|
||||
if mermaid:
|
||||
blocks.append({"kind": "markdown",
|
||||
"text": "```mermaid\n" + mermaid + "\n```"})
|
||||
return {"id": "relaciones", "title": "Relaciones inter-tabla",
|
||||
"version": "1.0.0", "blocks": blocks}
|
||||
|
||||
|
||||
def _build_db_document(db_profile: dict, source_path: str, db_path: str,
|
||||
meta_ctx: dict, per_table_eda: bool) -> list:
|
||||
"""Ensambla el documento-base por capítulos a partir del DatabaseProfile.
|
||||
|
||||
Mínimo: portada-base + resumen de tablas + relaciones. Con per_table_eda
|
||||
True anexa, por cada tabla, un capítulo separador + los capítulos canónicos
|
||||
de su mini-EDA (reusando build_document sobre cada TableProfile)."""
|
||||
chapters = [
|
||||
_portada_chapter(db_profile, source_path, db_path, meta_ctx),
|
||||
_resumen_chapter(db_profile),
|
||||
_relaciones_chapter(db_profile),
|
||||
]
|
||||
if per_table_eda:
|
||||
for prof in db_profile.get("table_profiles", []) or []:
|
||||
tname = prof.get("table") or "tabla"
|
||||
chapters.append({
|
||||
"id": f"tabla_{tname}", "title": f"Tabla: {tname}",
|
||||
"version": "1.0.0",
|
||||
"blocks": [{"kind": "heading", "text": f"Tabla: {tname}",
|
||||
"level": 1}],
|
||||
})
|
||||
try:
|
||||
# build_document devuelve los capítulos canónicos de la tabla.
|
||||
# ctx None -> los capítulos que necesitan datos crudos degradan,
|
||||
# pero salen completos los de portada/overview/distrib/calidad.
|
||||
chapters.extend(build_document(prof, None) or [])
|
||||
except Exception: # noqa: BLE001 — una tabla mala no rompe el doc.
|
||||
chapters.append({
|
||||
"id": f"tabla_{tname}_err", "title": f"Tabla: {tname}",
|
||||
"version": "1.0.0",
|
||||
"blocks": [{"kind": "note",
|
||||
"text": "No se pudo ensamblar el mini-EDA de "
|
||||
"esta tabla."}],
|
||||
})
|
||||
return chapters
|
||||
|
||||
|
||||
def _resolve_db_path(path: str) -> dict:
|
||||
"""Resuelve el DuckDB a perfilar desde ``path``.
|
||||
|
||||
- Directorio -> carga la carpeta con load_folder_to_duckdb (DuckDB temp).
|
||||
- Archivo .duckdb/.ddb/.db -> se usa directo (rama "ya es duckdb").
|
||||
- Otro archivo / inexistente -> error.
|
||||
|
||||
Devuelve {status, db_path, loaded, n_tables, load_errors}.
|
||||
"""
|
||||
if os.path.isdir(path):
|
||||
lr = load_folder_to_duckdb(path)
|
||||
if lr.get("status") != "ok":
|
||||
return {"status": "error",
|
||||
"error": f"load_folder_to_duckdb falló: {lr.get('error')}"}
|
||||
return {
|
||||
"status": "ok",
|
||||
"db_path": lr.get("db_path"),
|
||||
"loaded": True,
|
||||
"n_tables": len(lr.get("tables", []) or []),
|
||||
"load_errors": lr.get("errors", []) or [],
|
||||
}
|
||||
if os.path.isfile(path):
|
||||
if path.lower().endswith(_DUCKDB_EXTS):
|
||||
return {"status": "ok", "db_path": path, "loaded": False,
|
||||
"n_tables": None, "load_errors": []}
|
||||
return {"status": "error",
|
||||
"error": f"'{path}' no es un directorio ni una DuckDB "
|
||||
f"(extensiones {_DUCKDB_EXTS})."}
|
||||
return {"status": "error", "error": f"path no existe: {path}"}
|
||||
|
||||
|
||||
def render_automatic_eda_folder(
|
||||
path: str,
|
||||
out_dir: str = "reports",
|
||||
basename: str = None,
|
||||
profile_level: str = "standard",
|
||||
emit_pdf: bool = True,
|
||||
emit_pptx: bool = True,
|
||||
emit_md: bool = True,
|
||||
per_table_eda: bool = False,
|
||||
min_inclusion: float = 0.9,
|
||||
ctx_extra: dict = None,
|
||||
) -> dict:
|
||||
"""Perfila una CARPETA (o una DuckDB) y emite el informe AutomaticEDA de la base.
|
||||
|
||||
Args:
|
||||
path: o bien un DIRECTORIO con archivos tabulares (CSV/Parquet/JSON) que
|
||||
se cargan a una DuckDB temporal, o bien una DuckDB ya existente
|
||||
(``.duckdb``/``.ddb``/``.db``) que se perfila directa.
|
||||
out_dir: directorio de salida (se crea si no existe). Default "reports".
|
||||
basename: nombre base de los archivos sin extensión. Default
|
||||
"aeda_base_<nombre>_<timestamp>".
|
||||
profile_level: preset de coste del perfil por tabla ("lite"/"standard"/
|
||||
"full"); ajusta el ``sample`` que profile_database pasa a cada tabla.
|
||||
emit_pdf / emit_pptx / emit_md: qué formatos emitir. Default los tres.
|
||||
per_table_eda: si True, anexa al documento-base los capítulos de mini-EDA
|
||||
de cada tabla (un Heading "Tabla: <n>" + build_document por tabla).
|
||||
Default False (solo el documento-base: portada + resumen + relaciones).
|
||||
min_inclusion: umbral de inclusión para emitir una FK candidata (0-1).
|
||||
ctx_extra: dict opcional de claves de presentación (p.ej. dataset_name,
|
||||
description) que se mezclan en el contexto de la portada.
|
||||
|
||||
Returns:
|
||||
dict (nunca lanza). En éxito::
|
||||
|
||||
{"status": "ok", "pdf_path": str|None, "pptx_path": str|None,
|
||||
"md_path": str|None, "manifest_path": str|None,
|
||||
"n_tables": int, "n_pages": int|None, "n_slides": int|None,
|
||||
"md_chars": int|None, "db_path": str, "db_profile": <DatabaseProfile>}
|
||||
|
||||
En error: {"status": "error", "error": str}.
|
||||
"""
|
||||
try:
|
||||
# 1) Resolver la DuckDB a perfilar (cargar carpeta o usar la dada).
|
||||
rdb = _resolve_db_path(path)
|
||||
if rdb.get("status") != "ok":
|
||||
return {"status": "error", "error": rdb.get("error")}
|
||||
db_path = rdb.get("db_path")
|
||||
|
||||
# 2) Perfilar la base entera (resumen + FK + join graph). Sin report
|
||||
# propio (write_report/emit_pdf False): este pipeline emite el suyo.
|
||||
sample = _SAMPLE_BY_LEVEL.get(profile_level, 5000)
|
||||
pres = profile_database(
|
||||
db_path, sample=sample, write_report=False,
|
||||
min_inclusion=min_inclusion, emit_pdf=False,
|
||||
)
|
||||
if pres.get("status") != "ok":
|
||||
return {"status": "error",
|
||||
"error": f"profile_database falló: {pres.get('error')}"}
|
||||
db_profile = pres.get("db_profile") or {}
|
||||
|
||||
# 3) Ensamblar el documento-base por capítulos.
|
||||
meta_ctx = dict(ctx_extra or {})
|
||||
chapters = _build_db_document(
|
||||
db_profile, path, db_path, meta_ctx, per_table_eda
|
||||
)
|
||||
|
||||
# 4) Render a los tres formatos desde el MISMO documento por capítulos.
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
nm = (meta_ctx.get("dataset_name")
|
||||
or os.path.basename(os.path.normpath(path)) or "base")
|
||||
nm = "".join(c if c.isalnum() else "_" for c in str(nm)).strip("_") or "base"
|
||||
base = basename or f"aeda_base_{nm}_{ts}"
|
||||
title = f"EDA base — {meta_ctx.get('dataset_name') or nm}"
|
||||
meta = {"title": title}
|
||||
|
||||
pdf_path = pptx_path = md_path = manifest_path = None
|
||||
n_pages = n_slides = md_chars = None
|
||||
|
||||
if emit_pdf:
|
||||
target = os.path.join(out_dir, base + ".pdf")
|
||||
rpdf = render_automatic_eda_pdf(chapters, target, meta) or {}
|
||||
pdf_path = rpdf.get("path")
|
||||
n_pages = rpdf.get("n_pages")
|
||||
manifest_path = rpdf.get("manifest_path")
|
||||
if emit_pptx:
|
||||
target = os.path.join(out_dir, base + ".pptx")
|
||||
rpptx = render_automatic_eda_pptx(chapters, target, meta) or {}
|
||||
pptx_path = rpptx.get("path")
|
||||
n_slides = rpptx.get("n_slides")
|
||||
if emit_md:
|
||||
target = os.path.join(out_dir, base + ".md")
|
||||
rmd = render_automatic_eda_markdown(chapters, target, meta) or {}
|
||||
md_path = rmd.get("path")
|
||||
md_chars = rmd.get("n_chars")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"pdf_path": pdf_path,
|
||||
"pptx_path": pptx_path,
|
||||
"md_path": md_path,
|
||||
"manifest_path": manifest_path,
|
||||
"n_tables": db_profile.get("n_tables"),
|
||||
"n_pages": n_pages,
|
||||
"n_slides": n_slides,
|
||||
"md_chars": md_chars,
|
||||
"db_path": db_path,
|
||||
"db_profile": db_profile,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
|
||||
return {"status": "error", "error": str(e)}
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests para render_automatic_eda_folder — EDA de una carpeta / base multi-tabla.
|
||||
|
||||
Golden: una carpeta con 3 CSV relacionados (customers/orders/products) produce el
|
||||
documento-base en PDF + PPTX + MD, con las 3 tablas en el resumen y la FK
|
||||
orders.customer_id -> customers.id en el capítulo de relaciones. Edges: carpeta
|
||||
vacía (documento mínimo, sin lanzar), 1 sola tabla (sin relaciones) y la rama
|
||||
"ya es una DuckDB" sobre un archivo .duckdb existente.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import duckdb
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from pipelines.render_automatic_eda_folder import (
|
||||
_relaciones_chapter,
|
||||
render_automatic_eda_folder,
|
||||
)
|
||||
|
||||
|
||||
def _write_demo_folder(folder: str) -> None:
|
||||
"""3 CSV relacionados: orders.customer_id -> customers.id (FK detectable)."""
|
||||
with open(os.path.join(folder, "customers.csv"), "w", encoding="utf-8") as fh:
|
||||
fh.write("id,name,city\n")
|
||||
fh.write("1,Alice,Madrid\n2,Bob,Barcelona\n3,Carol,Valencia\n"
|
||||
"4,Dave,Sevilla\n5,Eve,Madrid\n")
|
||||
with open(os.path.join(folder, "orders.csv"), "w", encoding="utf-8") as fh:
|
||||
fh.write("order_id,customer_id,product_id,total\n")
|
||||
fh.write("100,1,10,49.90\n101,1,11,12.50\n102,2,10,49.90\n"
|
||||
"103,3,12,8.00\n104,3,11,12.50\n105,5,10,49.90\n"
|
||||
"106,2,12,8.00\n")
|
||||
with open(os.path.join(folder, "products.csv"), "w", encoding="utf-8") as fh:
|
||||
fh.write("product_id,product_name,price\n")
|
||||
fh.write("10,Widget,49.90\n11,Gadget,12.50\n12,Gizmo,8.00\n")
|
||||
|
||||
|
||||
def _has_fk(db_profile: dict, from_t: str, from_c: str, to_t: str) -> bool:
|
||||
for fk in db_profile.get("fk_candidates", []) or []:
|
||||
if (fk.get("from_table") == from_t and fk.get("from_col") == from_c
|
||||
and fk.get("to_table") == to_t):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_golden_folder_three_csv(tmp_path):
|
||||
"""Carpeta con 3 CSV relacionados -> PDF+PPTX+MD, 3 tablas, FK detectada."""
|
||||
folder = tmp_path / "demo"
|
||||
folder.mkdir()
|
||||
_write_demo_folder(str(folder))
|
||||
out = tmp_path / "out"
|
||||
|
||||
r = render_automatic_eda_folder(str(folder), out_dir=str(out))
|
||||
|
||||
assert r["status"] == "ok", r
|
||||
assert r["n_tables"] == 3
|
||||
# Los tres formatos se emitieron y existen en disco.
|
||||
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
|
||||
assert r["pptx_path"] and os.path.exists(r["pptx_path"])
|
||||
assert r["md_path"] and os.path.exists(r["md_path"])
|
||||
assert (r["n_pages"] or 0) >= 1
|
||||
assert (r["n_slides"] or 0) >= 1
|
||||
# La FK orders.customer_id -> customers.id se detecta por containment.
|
||||
assert _has_fk(r["db_profile"], "orders", "customer_id", "customers"), \
|
||||
r["db_profile"].get("fk_candidates")
|
||||
# El Markdown menciona las 3 tablas y la relación.
|
||||
md = open(r["md_path"], encoding="utf-8").read()
|
||||
for t in ("customers", "orders", "products"):
|
||||
assert t in md
|
||||
assert "customer_id" in md
|
||||
|
||||
|
||||
def test_edge_empty_folder(tmp_path):
|
||||
"""Carpeta vacía -> status ok con documento mínimo, sin lanzar."""
|
||||
folder = tmp_path / "empty"
|
||||
folder.mkdir()
|
||||
out = tmp_path / "out"
|
||||
|
||||
r = render_automatic_eda_folder(str(folder), out_dir=str(out))
|
||||
|
||||
assert r["status"] == "ok", r
|
||||
assert r["n_tables"] == 0
|
||||
# Aun sin tablas, emite el documento-base mínimo (portada + resumen vacío +
|
||||
# relaciones "sin FK").
|
||||
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
|
||||
assert r["md_path"] and os.path.exists(r["md_path"])
|
||||
|
||||
|
||||
def test_edge_single_table_no_relations(tmp_path):
|
||||
"""Carpeta con 1 sola tabla -> funciona sin relaciones (capítulo 'sin FK')."""
|
||||
folder = tmp_path / "single"
|
||||
folder.mkdir()
|
||||
with open(folder / "lonely.csv", "w", encoding="utf-8") as fh:
|
||||
fh.write("a,b\n1,x\n2,y\n3,z\n")
|
||||
out = tmp_path / "out"
|
||||
|
||||
r = render_automatic_eda_folder(str(folder), out_dir=str(out))
|
||||
|
||||
assert r["status"] == "ok", r
|
||||
assert r["n_tables"] == 1
|
||||
assert not (r["db_profile"].get("fk_candidates") or [])
|
||||
md = open(r["md_path"], encoding="utf-8").read()
|
||||
assert "Sin relaciones FK" in md or "sin FK" in md.lower()
|
||||
|
||||
|
||||
def test_accepts_existing_duckdb(tmp_path):
|
||||
"""Rama 'ya es una DuckDB': un archivo .duckdb existente se perfila directo."""
|
||||
db = tmp_path / "base.duckdb"
|
||||
conn = duckdb.connect(str(db))
|
||||
try:
|
||||
conn.execute("CREATE TABLE customers (id INTEGER, name VARCHAR)")
|
||||
conn.execute("INSERT INTO customers VALUES (1,'Ana'),(2,'Luis'),(3,'Eva')")
|
||||
conn.execute("CREATE TABLE orders (oid INTEGER, customer_id INTEGER)")
|
||||
conn.execute("INSERT INTO orders VALUES (10,1),(11,2),(12,1),(13,3)")
|
||||
finally:
|
||||
conn.close()
|
||||
out = tmp_path / "out"
|
||||
|
||||
r = render_automatic_eda_folder(str(db), out_dir=str(out))
|
||||
|
||||
assert r["status"] == "ok", r
|
||||
assert r["n_tables"] == 2
|
||||
assert r["db_path"] == str(db)
|
||||
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
|
||||
|
||||
|
||||
def test_emit_flags_select_formats(tmp_path):
|
||||
"""emit_pdf/pptx/md controlan qué formatos se emiten."""
|
||||
folder = tmp_path / "demo"
|
||||
folder.mkdir()
|
||||
_write_demo_folder(str(folder))
|
||||
out = tmp_path / "out"
|
||||
|
||||
r = render_automatic_eda_folder(
|
||||
str(folder), out_dir=str(out),
|
||||
emit_pdf=True, emit_pptx=False, emit_md=False,
|
||||
)
|
||||
assert r["status"] == "ok", r
|
||||
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
|
||||
assert r["pptx_path"] is None
|
||||
assert r["md_path"] is None
|
||||
|
||||
|
||||
def test_path_does_not_exist(tmp_path):
|
||||
"""Path inexistente -> status error, sin lanzar."""
|
||||
r = render_automatic_eda_folder(str(tmp_path / "nope"))
|
||||
assert r["status"] == "error"
|
||||
assert "no existe" in r["error"].lower()
|
||||
|
||||
|
||||
def test_relaciones_chapter_has_real_figure_when_edges():
|
||||
"""Con edges, el capítulo de relaciones incluye un bloque Figure matplotlib
|
||||
REAL (no solo el texto Mermaid): su make() devuelve una Figure."""
|
||||
db_profile = {
|
||||
"join_graph": {
|
||||
"nodes": [
|
||||
{"table": "orders", "out_degree": 1, "in_degree": 0, "role": "fact"},
|
||||
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dim"},
|
||||
],
|
||||
"edges": [{"from_table": "orders", "from_col": "customer_id",
|
||||
"to_table": "customers", "to_col": "id",
|
||||
"cardinality": "N:1"}],
|
||||
"mermaid": "graph LR orders --> customers",
|
||||
"hubs": ["orders"],
|
||||
},
|
||||
"fk_candidates": [{"from_table": "orders", "from_col": "customer_id",
|
||||
"to_table": "customers", "to_col": "id",
|
||||
"inclusion": 1.0, "cardinality": "N:1"}],
|
||||
}
|
||||
ch = _relaciones_chapter(db_profile)
|
||||
figs = [b for b in ch["blocks"] if b.get("kind") == "figure"]
|
||||
assert len(figs) == 1, ch["blocks"]
|
||||
# El make() perezoso produce una matplotlib Figure real.
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
fig = figs[0]["make"]()
|
||||
from matplotlib.figure import Figure
|
||||
assert isinstance(fig, Figure)
|
||||
assert fig.get_axes(), "la Figure del join graph debe tener al menos un eje"
|
||||
|
||||
|
||||
def test_relaciones_chapter_no_figure_when_no_edges():
|
||||
"""Sin edges, no se añade bloque Figure (capítulo dice 'sin FK')."""
|
||||
db_profile = {"join_graph": {"nodes": [], "edges": [], "mermaid": "",
|
||||
"hubs": []}, "fk_candidates": []}
|
||||
ch = _relaciones_chapter(db_profile)
|
||||
assert not [b for b in ch["blocks"] if b.get("kind") == "figure"]
|
||||
Reference in New Issue
Block a user