merge(eda): only_chapters con resolucion automatica de dependencias de computo por capitulo
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""Tests del filtro `only` de build_document (selección de capítulos).
|
||||
|
||||
Verifican que:
|
||||
- only=None mantiene el comportamiento histórico (todos los capítulos).
|
||||
- only=[ids] restringe el CUERPO a esos ids, pero portada (primera) y glosario
|
||||
(última) están SIEMPRE presentes.
|
||||
- only=[] produce el documento mínimo (solo portada + glosario).
|
||||
- la selección también viaja por la clave reservada ctx['_only_chapters']
|
||||
(el canal que usan los renderers, que llaman build_document sin `only`), y
|
||||
esa clave nunca se filtra a los capítulos.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
|
||||
if _FUNCTIONS not in sys.path:
|
||||
sys.path.insert(0, _FUNCTIONS)
|
||||
|
||||
from datascience.automatic_eda import build_document # noqa: E402
|
||||
|
||||
|
||||
def _profile_with_cat_and_num():
|
||||
"""Perfil mínimo que hace construir cat_distr y num_distr (cuerpo no vacío)."""
|
||||
return {
|
||||
"table": "ventas", "n_rows": 120, "n_cols": 2, "quality_score": 91,
|
||||
"duplicate_pct": 1.5, "null_cell_pct": 0.8,
|
||||
"columns": [
|
||||
{"name": "region", "inferred_type": "categorical",
|
||||
"categorical": {
|
||||
"top": [{"value": "norte", "count": 50, "pct": 0.42},
|
||||
{"value": "sur", "count": 40, "pct": 0.33},
|
||||
{"value": "este", "count": 30, "pct": 0.25}],
|
||||
"mode": "norte", "n_distinct": 3, "entropy": 1.55,
|
||||
"imbalance": 0.1}},
|
||||
{"name": "importe", "inferred_type": "numeric",
|
||||
"numeric": {"mean": 50.0, "median": 48.0, "std": 10.0,
|
||||
"min": 10, "max": 99, "iqr": 15,
|
||||
"histogram": [{"lo": 0, "hi": 50, "count": 40},
|
||||
{"lo": 50, "hi": 100, "count": 80}]}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_only_none_is_full_document():
|
||||
"""Retro-compat: sin `only`, salen todos los capítulos aplicables."""
|
||||
chs = build_document(_profile_with_cat_and_num(), ctx={"dataset_name": "v"})
|
||||
ids = [c.id for c in chs]
|
||||
assert ids[0] == "portada"
|
||||
assert ids[-1] == "glosario"
|
||||
# El cuerpo trae las distribuciones (cat/num), no solo portada+glosario.
|
||||
assert "num_distr" in ids
|
||||
assert "cat_distr" in ids
|
||||
|
||||
|
||||
def test_only_restricts_body_but_keeps_cover_and_glossary():
|
||||
# cat_distr registra el término "entropía" en el glosario, así que el
|
||||
# glosario (destino del término clicable) aparece — demuestra el contrato
|
||||
# "portada primera + capítulo + glosario última".
|
||||
chs = build_document(_profile_with_cat_and_num(),
|
||||
ctx={"dataset_name": "v"}, only=["cat_distr"])
|
||||
ids = [c.id for c in chs]
|
||||
assert ids[0] == "portada", f"portada no es la primera: {ids}"
|
||||
assert ids[-1] == "glosario", f"glosario no es la última: {ids}"
|
||||
assert "cat_distr" in ids
|
||||
# num_distr quedó fuera de la selección.
|
||||
assert "num_distr" not in ids
|
||||
|
||||
|
||||
def test_only_empty_yields_minimal_document():
|
||||
# only=[] -> cuerpo vacío. La portada está siempre; el glosario solo aparece
|
||||
# si algún capítulo registró términos (patrón preexistente: glosario vacío se
|
||||
# omite). Sin cuerpo no hay términos → documento mínimo = solo portada.
|
||||
chs = build_document(_profile_with_cat_and_num(),
|
||||
ctx={"dataset_name": "v"}, only=[])
|
||||
ids = [c.id for c in chs]
|
||||
assert ids == ["portada"], \
|
||||
f"only=[] debe dar el documento mínimo (solo portada), no {ids}"
|
||||
|
||||
|
||||
def test_selection_via_reserved_ctx_key():
|
||||
"""La selección viaja por ctx['_only_chapters'] cuando no se pasa `only`."""
|
||||
chs = build_document(_profile_with_cat_and_num(),
|
||||
ctx={"dataset_name": "v",
|
||||
"_only_chapters": ["cat_distr"]})
|
||||
ids = [c.id for c in chs]
|
||||
assert "cat_distr" in ids
|
||||
assert "num_distr" not in ids
|
||||
assert ids[0] == "portada" and ids[-1] == "glosario"
|
||||
|
||||
|
||||
def test_explicit_only_arg_wins_over_ctx_key():
|
||||
"""Si se pasan ambos, el argumento `only` manda sobre la clave del ctx."""
|
||||
chs = build_document(_profile_with_cat_and_num(),
|
||||
ctx={"dataset_name": "v",
|
||||
"_only_chapters": ["cat_distr"]},
|
||||
only=["num_distr"])
|
||||
ids = [c.id for c in chs]
|
||||
assert "num_distr" in ids
|
||||
assert "cat_distr" not in ids
|
||||
|
||||
|
||||
def test_reserved_key_not_leaked_to_caller_ctx():
|
||||
"""build_document no muta el ctx del caller (copia interna)."""
|
||||
ctx = {"dataset_name": "v", "_only_chapters": ["num_distr"]}
|
||||
build_document(_profile_with_cat_and_num(), ctx=ctx)
|
||||
# La clave reservada sigue en el dict del caller (no se mutó su copia).
|
||||
assert ctx["_only_chapters"] == ["num_distr"]
|
||||
@@ -0,0 +1,205 @@
|
||||
"""chapter_deps — mapa central de dependencias de cómputo por capítulo del EDA.
|
||||
|
||||
Fuente de verdad ÚNICA de qué necesita cada capítulo de ``CHAPTER_ORDER`` para
|
||||
computarse COMPLETO (sin caer en su rama degradada "datos insuficientes"). Lo
|
||||
consume el pipeline ``render_automatic_eda`` cuando se le pide renderizar un
|
||||
SUBCONJUNTO de capítulos (kwarg ``only_chapters``): antes de perfilar, resuelve
|
||||
los requisitos de los capítulos pedidos y activa SOLO el cómputo que esos
|
||||
capítulos necesitan, de modo que un capítulo suelto siempre llegue poblado y a la
|
||||
vez no se malgaste CPU/LLM en piezas que ningún capítulo pedido usa.
|
||||
|
||||
Diseño: el mapa es CENTRAL (este módulo), NO una constante por capítulo. Así se
|
||||
evita tocar los ``chapters/<id>.py`` (cada agente es dueño de su capítulo) y se
|
||||
elimina el riesgo de colisión entre ramas. Si un capítulo cambia lo que lee del
|
||||
``profile``/``ctx``, se actualiza ESTE mapa — es donde el motor mira.
|
||||
|
||||
Dos clases de dependencia, derivadas inspeccionando qué lee cada capítulo:
|
||||
|
||||
- ``profile_flags``: flags de coste de ``profile_table`` que hay que ACTIVAR
|
||||
para que el ``profile`` traiga el bloque que el capítulo lee. Son los caros:
|
||||
* ``run_models`` -> ``profile['models']`` (KMeans/IsolationForest/PCA).
|
||||
Lo leen ``outliers`` (fallback del multivariante) y ``modelos``.
|
||||
* ``run_series`` -> ``profile['series']`` (análisis de serie temporal).
|
||||
Lo lee ``timeseries``.
|
||||
* ``run_llm`` -> ``profile['llm']`` (interpretación del modelo).
|
||||
Lo lee ``analisis_llm``.
|
||||
|
||||
- ``ctx``: etiquetas de las piezas de DATOS CRUDOS que construye
|
||||
``build_eda_render_ctx`` y que el capítulo lee del ``ctx``. Si la lista está
|
||||
vacía, el capítulo no necesita datos crudos y el pipeline puede saltarse
|
||||
``build_eda_render_ctx`` por completo cuando ningún capítulo pedido los pide.
|
||||
Etiquetas y claves reales que mapean (ver ``CTX_LABEL_TO_KEYS``):
|
||||
* ``head_rows`` -> ``ctx['head_rows']`` (overview: df.head real).
|
||||
* ``raw_numeric`` -> ``ctx['raw_numeric']`` (outliers/modelos/
|
||||
correlacion/missingness/geospatial: muestra numérica alineada por fila).
|
||||
* ``timeseries_raw`` -> ``ctx['timeseries_raw']`` (timeseries: serie cruda).
|
||||
* ``geo_points`` -> ``ctx['geo_points']`` (+ ``raw_numeric``)
|
||||
(geospatial: lat/lon).
|
||||
* ``db_path_table`` -> ``ctx['db_path']`` + ``ctx['table']`` (agregacion/
|
||||
text_distr/missingness/relaciones: push-down de queries propias).
|
||||
|
||||
``portada`` y ``glosario`` NO son opcionales: el pipeline los incluye SIEMPRE
|
||||
(la portada resume el documento y el glosario es el destino de los términos
|
||||
clicables), así que aquí se declaran sin requisitos de cómputo.
|
||||
|
||||
Todas las funciones de este módulo son PURAS (no I/O, deterministas): se prestan
|
||||
a test unitario directo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Mapa central. Una entrada por id de CHAPTER_ORDER. ``profile_flags`` lista los
|
||||
# flags de coste a activar; ``ctx`` las etiquetas de datos crudos que lee. Las
|
||||
# claves vacías significan "no necesita ese tipo de dependencia".
|
||||
CHAPTER_DEPS = {
|
||||
# Portada y glosario: SIEMPRE presentes, sin cómputo propio (la portada lee
|
||||
# el document_summary que arma build_document; el glosario lee los términos
|
||||
# que el resto registró). Se declaran para que el mapa cubra CHAPTER_ORDER
|
||||
# entero y la validación los reconozca.
|
||||
"portada": {"profile_flags": [], "ctx": []},
|
||||
"overview": {"profile_flags": [], "ctx": ["head_rows"]},
|
||||
"analisis_llm": {"profile_flags": ["run_llm"], "ctx": []},
|
||||
"num_distr": {"profile_flags": [], "ctx": []},
|
||||
"cat_distr": {"profile_flags": [], "ctx": []},
|
||||
# text_distr empuja su propia query de texto (no usa raw_numeric); necesita
|
||||
# db_path/table en el ctx para hacerlo.
|
||||
"text_distr": {"profile_flags": [], "ctx": ["db_path_table"]},
|
||||
"calidad": {"profile_flags": [], "ctx": []},
|
||||
# missingness lee la muestra numérica cruda (co-ocurrencia de ausencias) y
|
||||
# puede empujar una query de patrón de nulos con db_path/table.
|
||||
"missingness": {"profile_flags": [], "ctx": ["raw_numeric", "db_path_table"]},
|
||||
# outliers corre IsolationForest EN VIVO sobre ctx['raw_numeric']; run_models
|
||||
# asegura además el fallback profile['models']['outliers'] si el ctx faltara.
|
||||
"outliers": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
|
||||
"correlacion": {"profile_flags": [], "ctx": ["raw_numeric"]},
|
||||
"relaciones": {"profile_flags": [], "ctx": ["db_path_table"]},
|
||||
"modelos": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
|
||||
"timeseries": {"profile_flags": ["run_series"], "ctx": ["timeseries_raw"]},
|
||||
"geospatial": {"profile_flags": [], "ctx": ["geo_points", "raw_numeric"]},
|
||||
"agregacion": {"profile_flags": [], "ctx": ["db_path_table"]},
|
||||
"glosario": {"profile_flags": [], "ctx": []},
|
||||
}
|
||||
|
||||
# Capítulos que el documento incluye SIEMPRE, independientemente de only_chapters.
|
||||
ALWAYS_PRESENT = ("portada", "glosario")
|
||||
|
||||
# Flags de coste reconocidos (el orden no importa; se devuelven como set).
|
||||
KNOWN_PROFILE_FLAGS = ("run_models", "run_series", "run_llm")
|
||||
|
||||
# Mapeo de cada etiqueta de ctx a las claves REALES que produce
|
||||
# build_eda_render_ctx. ``db_path_table`` es especial: db_path/table siempre se
|
||||
# ponen para un backend válido y son inofensivos, por eso no se podan nunca (no
|
||||
# aparecen en DATA_CTX_KEYS). El resto (head_rows/raw_numeric/timeseries_raw/
|
||||
# geo_points) son las piezas de datos podables.
|
||||
CTX_LABEL_TO_KEYS = {
|
||||
"head_rows": {"head_rows"},
|
||||
"raw_numeric": {"raw_numeric"},
|
||||
"timeseries_raw": {"timeseries_raw"},
|
||||
"geo_points": {"geo_points", "raw_numeric"},
|
||||
"db_path_table": set(), # db_path/table siempre presentes; nunca se podan.
|
||||
}
|
||||
|
||||
# Claves de datos crudos del ctx que se pueden podar cuando ningún capítulo
|
||||
# pedido las necesita (las que cuestan muestreo). db_path/table NO entran aquí.
|
||||
DATA_CTX_KEYS = ("head_rows", "raw_numeric", "timeseries_raw", "geo_points")
|
||||
|
||||
|
||||
def _as_id_list(chapter_ids):
|
||||
"""Normaliza la entrada a una lista de ids string, defensiva. None -> []."""
|
||||
if chapter_ids is None:
|
||||
return []
|
||||
if isinstance(chapter_ids, str):
|
||||
return [chapter_ids]
|
||||
return [c for c in chapter_ids if isinstance(c, str)]
|
||||
|
||||
|
||||
def validate_chapter_ids(chapter_ids, order):
|
||||
"""Separa los ids pedidos en válidos y desconocidos respecto a ``order``.
|
||||
|
||||
Args:
|
||||
chapter_ids: lista (o str) de ids de capítulo pedidos.
|
||||
order: lista canónica de ids válidos (CHAPTER_ORDER).
|
||||
|
||||
Returns:
|
||||
dict ``{"valid": [...], "unknown": [...]}`` preservando el orden de
|
||||
aparición de la entrada. Función pura.
|
||||
"""
|
||||
valid_set = set(order or [])
|
||||
valid, unknown = [], []
|
||||
for cid in _as_id_list(chapter_ids):
|
||||
(valid if cid in valid_set else unknown).append(cid)
|
||||
return {"valid": valid, "unknown": unknown}
|
||||
|
||||
|
||||
def resolve_requirements(chapter_ids):
|
||||
"""Une los requisitos de cómputo de los capítulos pedidos.
|
||||
|
||||
Es el corazón de la resolución de dependencias: dado el subconjunto de
|
||||
capítulos a renderizar, devuelve TODO lo que hay que activar/construir para
|
||||
que esos capítulos lleguen COMPLETOS, y solo eso.
|
||||
|
||||
Los capítulos ``ALWAYS_PRESENT`` (portada/glosario) se añaden implícitamente
|
||||
porque el pipeline siempre los incluye; como no tienen requisitos, no alteran
|
||||
el resultado, pero se contemplan para que el conjunto sea coherente.
|
||||
|
||||
Args:
|
||||
chapter_ids: lista (o str) de ids de capítulo. Ids desconocidos se
|
||||
ignoran silenciosamente (la validación estricta es de quien llama).
|
||||
None o lista vacía -> requisitos vacíos.
|
||||
|
||||
Returns:
|
||||
dict ``{"profile_flags": set[str], "ctx_keys": set[str]}`` donde
|
||||
``ctx_keys`` son las ETIQUETAS de ctx (no las claves reales). Función
|
||||
pura.
|
||||
"""
|
||||
ids = set(_as_id_list(chapter_ids)) | set(ALWAYS_PRESENT)
|
||||
profile_flags = set()
|
||||
ctx_keys = set()
|
||||
for cid in ids:
|
||||
dep = CHAPTER_DEPS.get(cid)
|
||||
if not isinstance(dep, dict):
|
||||
continue
|
||||
for f in dep.get("profile_flags", []) or []:
|
||||
if f in KNOWN_PROFILE_FLAGS:
|
||||
profile_flags.add(f)
|
||||
for k in dep.get("ctx", []) or []:
|
||||
ctx_keys.add(k)
|
||||
return {"profile_flags": profile_flags, "ctx_keys": ctx_keys}
|
||||
|
||||
|
||||
def resolve_profile_flags(chapter_ids):
|
||||
"""Atajo: solo el set de profile_flags a activar para los capítulos pedidos.
|
||||
|
||||
Función pura. Devuelve un set ⊆ KNOWN_PROFILE_FLAGS.
|
||||
"""
|
||||
return resolve_requirements(chapter_ids)["profile_flags"]
|
||||
|
||||
|
||||
def needs_render_ctx(chapter_ids):
|
||||
"""True si algún capítulo pedido necesita datos crudos del ctx.
|
||||
|
||||
Cuando es False, el pipeline puede saltarse ``build_eda_render_ctx`` entero
|
||||
(ahorro real de CPU/I/O): los capítulos pedidos no leen ninguna pieza de
|
||||
datos crudos. Función pura.
|
||||
"""
|
||||
return bool(resolve_requirements(chapter_ids)["ctx_keys"])
|
||||
|
||||
|
||||
def resolve_ctx_data_keys(chapter_ids):
|
||||
"""Claves REALES de datos del ctx a CONSERVAR para los capítulos pedidos.
|
||||
|
||||
Traduce las etiquetas de ctx a las claves concretas que produce
|
||||
``build_eda_render_ctx`` (head_rows/raw_numeric/timeseries_raw/geo_points).
|
||||
El pipeline poda del ctx las claves de datos que NO estén en este set, para
|
||||
que un capítulo suelto no arrastre piezas de datos que no usa. db_path/table
|
||||
nunca se podan (no aparecen aquí). Función pura.
|
||||
|
||||
Returns:
|
||||
set[str] subconjunto de DATA_CTX_KEYS.
|
||||
"""
|
||||
req = resolve_requirements(chapter_ids)
|
||||
keep = set()
|
||||
for label in req["ctx_keys"]:
|
||||
keep |= CTX_LABEL_TO_KEYS.get(label, set())
|
||||
# Solo claves de datos podables (db_path/table se gestionan aparte).
|
||||
return {k for k in keep if k in DATA_CTX_KEYS}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Tests del mapa central de dependencias por capítulo (chapter_deps).
|
||||
|
||||
Todas las funciones bajo prueba son PURAS (sin I/O): se ejercitan directamente
|
||||
sin DuckDB ni renderizado. Cubren la resolución de requisitos (golden + edges),
|
||||
la validación de ids y los helpers de eficiencia (qué cómputo se salta).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
|
||||
if _FUNCTIONS not in sys.path:
|
||||
sys.path.insert(0, _FUNCTIONS)
|
||||
|
||||
from datascience.automatic_eda.chapter_deps import ( # noqa: E402
|
||||
ALWAYS_PRESENT,
|
||||
CHAPTER_DEPS,
|
||||
DATA_CTX_KEYS,
|
||||
needs_render_ctx,
|
||||
resolve_ctx_data_keys,
|
||||
resolve_profile_flags,
|
||||
resolve_requirements,
|
||||
validate_chapter_ids,
|
||||
)
|
||||
from datascience.automatic_eda.chapters_registry import CHAPTER_ORDER # noqa: E402
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# El mapa cubre CHAPTER_ORDER entero (sin huecos ni claves de más).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_chapter_deps_covers_every_chapter_in_order():
|
||||
assert set(CHAPTER_DEPS) == set(CHAPTER_ORDER), (
|
||||
"CHAPTER_DEPS debe declarar exactamente los ids de CHAPTER_ORDER")
|
||||
# Cada entrada tiene la forma esperada.
|
||||
for cid, dep in CHAPTER_DEPS.items():
|
||||
assert isinstance(dep.get("profile_flags"), list), cid
|
||||
assert isinstance(dep.get("ctx"), list), cid
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# resolve_requirements — golden: outliers exige run_models + raw_numeric.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_resolve_outliers_requires_run_models_and_raw_numeric():
|
||||
req = resolve_requirements(["outliers"])
|
||||
assert "run_models" in req["profile_flags"]
|
||||
assert "raw_numeric" in req["ctx_keys"]
|
||||
assert "run_series" not in req["profile_flags"]
|
||||
assert "run_llm" not in req["profile_flags"]
|
||||
|
||||
|
||||
def test_resolve_timeseries_requires_run_series():
|
||||
req = resolve_requirements(["timeseries"])
|
||||
assert req["profile_flags"] == {"run_series"}
|
||||
assert "timeseries_raw" in req["ctx_keys"]
|
||||
|
||||
|
||||
def test_resolve_analisis_llm_requires_run_llm():
|
||||
assert resolve_requirements(["analisis_llm"])["profile_flags"] == {"run_llm"}
|
||||
|
||||
|
||||
def test_resolve_union_of_several_chapters():
|
||||
req = resolve_requirements(["outliers", "timeseries", "analisis_llm"])
|
||||
assert req["profile_flags"] == {"run_models", "run_series", "run_llm"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Eficiencia: capítulos que NO necesitan flags caros no los activan.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_resolve_geospatial_needs_no_cost_flags():
|
||||
"""geospatial sale de geo_points/raw_numeric del ctx, NO de los modelos."""
|
||||
req = resolve_requirements(["geospatial"])
|
||||
assert req["profile_flags"] == set(), \
|
||||
"geospatial no debe activar run_models/run_series/run_llm"
|
||||
assert "geo_points" in req["ctx_keys"]
|
||||
|
||||
|
||||
def test_resolve_correlacion_needs_raw_numeric_but_no_models():
|
||||
req = resolve_requirements(["correlacion"])
|
||||
assert req["profile_flags"] == set()
|
||||
assert "raw_numeric" in req["ctx_keys"]
|
||||
|
||||
|
||||
def test_always_present_chapters_add_no_requirements():
|
||||
"""portada y glosario están siempre, pero no arrastran cómputo."""
|
||||
for cid in ALWAYS_PRESENT:
|
||||
req = resolve_requirements([cid])
|
||||
assert req["profile_flags"] == set()
|
||||
assert req["ctx_keys"] == set()
|
||||
|
||||
|
||||
def test_resolve_profile_flags_shortcut():
|
||||
assert resolve_profile_flags(["modelos"]) == {"run_models"}
|
||||
assert resolve_profile_flags(["num_distr"]) == set()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# needs_render_ctx — cuándo se puede saltar build_eda_render_ctx por completo.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_needs_render_ctx_true_when_chapter_reads_raw_data():
|
||||
assert needs_render_ctx(["outliers"]) is True
|
||||
assert needs_render_ctx(["agregacion"]) is True # db_path/table push-down
|
||||
assert needs_render_ctx(["timeseries"]) is True
|
||||
|
||||
|
||||
def test_needs_render_ctx_false_for_purely_aggregated_chapters():
|
||||
"""num_distr / cat_distr / calidad solo leen el profile agregado."""
|
||||
assert needs_render_ctx(["num_distr"]) is False
|
||||
assert needs_render_ctx(["cat_distr", "calidad"]) is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# resolve_ctx_data_keys — poda: qué claves de DATOS conservar (db_path/table no).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_resolve_ctx_data_keys_outliers_keeps_only_raw_numeric():
|
||||
assert resolve_ctx_data_keys(["outliers"]) == {"raw_numeric"}
|
||||
|
||||
|
||||
def test_resolve_ctx_data_keys_geospatial_keeps_geo_and_numeric():
|
||||
assert resolve_ctx_data_keys(["geospatial"]) == {"geo_points", "raw_numeric"}
|
||||
|
||||
|
||||
def test_resolve_ctx_data_keys_aggregation_keeps_nothing_prunable():
|
||||
"""agregacion usa db_path/table (siempre presentes), 0 claves podables."""
|
||||
assert resolve_ctx_data_keys(["agregacion"]) == set()
|
||||
|
||||
|
||||
def test_resolve_ctx_data_keys_subset_of_data_keys():
|
||||
keep = resolve_ctx_data_keys(["overview", "timeseries", "geospatial"])
|
||||
assert keep <= set(DATA_CTX_KEYS)
|
||||
assert {"head_rows", "timeseries_raw", "geo_points", "raw_numeric"} == keep
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# validate_chapter_ids — separa válidos de desconocidos preservando orden.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_validate_separates_known_and_unknown():
|
||||
out = validate_chapter_ids(["outliers", "nope", "timeseries", "ghost"],
|
||||
CHAPTER_ORDER)
|
||||
assert out["valid"] == ["outliers", "timeseries"]
|
||||
assert out["unknown"] == ["nope", "ghost"]
|
||||
|
||||
|
||||
def test_validate_all_known():
|
||||
out = validate_chapter_ids(["portada", "glosario"], CHAPTER_ORDER)
|
||||
assert out["unknown"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Robustez: entradas raras nunca lanzan.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_resolve_handles_none_and_empty():
|
||||
assert resolve_requirements(None)["profile_flags"] == set()
|
||||
assert resolve_requirements([])["profile_flags"] == set()
|
||||
# ids desconocidos se ignoran silenciosamente en la resolución.
|
||||
assert resolve_requirements(["no_existe"])["ctx_keys"] == set()
|
||||
|
||||
|
||||
def test_resolve_accepts_single_string():
|
||||
assert resolve_requirements("outliers")["profile_flags"] == {"run_models"}
|
||||
@@ -73,24 +73,51 @@ def build_chapter(chapter_id: str, profile: dict, ctx: dict):
|
||||
return model.as_chapter(result)
|
||||
|
||||
|
||||
def build_document(profile: dict, ctx: dict = None) -> list:
|
||||
"""Build the full ordered list of chapters for a TableProfile.
|
||||
def build_document(profile: dict, ctx: dict = None, only: list = None) -> list:
|
||||
"""Build the ordered list of chapters for a TableProfile.
|
||||
|
||||
Args:
|
||||
profile: the ``eda`` group TableProfile dict (may be None/empty).
|
||||
ctx: optional context dict carrying presentation metadata not present in
|
||||
the profile (dataset_name, source_origin, storage, generated_at,
|
||||
description, granularity, quality_criteria, head_rows, ...).
|
||||
only: optional list of chapter ids to render. ``None`` (default) keeps
|
||||
the historical behaviour — every implemented & applicable chapter in
|
||||
canonical order. A list restricts the BODY to just those ids (in
|
||||
canonical order), but the cover (``portada``) and glossary
|
||||
(``glosario``) are ALWAYS included so the document stays valid and
|
||||
the clickable terms keep a destination — so passing ``only=["x"]``
|
||||
yields portada + x + glosario. Unknown ids are simply skipped (the
|
||||
caller is responsible for strict validation). ``only=[]`` yields the
|
||||
minimal document (portada + glosario only). This argument is additive
|
||||
and backward-compatible: the signature is unchanged for existing
|
||||
callers (default ``None``).
|
||||
|
||||
Returns:
|
||||
list[Chapter] in canonical order, containing only the chapters that are
|
||||
implemented and applicable. Never raises.
|
||||
implemented, applicable and selected. Never raises.
|
||||
"""
|
||||
if not isinstance(profile, dict):
|
||||
profile = {}
|
||||
# Copy ctx so the shared collector / summary we add do not leak to the caller.
|
||||
ctx = dict(ctx) if isinstance(ctx, dict) else {}
|
||||
|
||||
# only=None -> all body chapters (historical). only=list -> restrict body to
|
||||
# that selection (portada/glosario are added unconditionally below). The
|
||||
# renderers call build_document(profile, meta['ctx']) without an `only`
|
||||
# argument, so the pipeline forwards the selection through a reserved ctx key
|
||||
# (``_only_chapters``); an explicit `only` argument always wins. The key is
|
||||
# popped from the local ctx copy so it never reaches the chapters.
|
||||
if only is None:
|
||||
_carried = ctx.pop("_only_chapters", None)
|
||||
if isinstance(_carried, (list, tuple, set)):
|
||||
only = list(_carried)
|
||||
else:
|
||||
ctx.pop("_only_chapters", None)
|
||||
# A set makes the membership test cheap; the iteration order stays
|
||||
# CHAPTER_ORDER. only=[] is a valid (empty) selection -> minimal document.
|
||||
only_set = set(only) if isinstance(only, (list, tuple, set)) else None
|
||||
|
||||
# A single glossary collector is shared by every chapter via ctx['glossary'].
|
||||
# Chapters call ctx['glossary'].add(key, label, definition) and mark in-text
|
||||
# appearances with [[term:key]]…[[/term]]; the glosario chapter renders the
|
||||
@@ -106,6 +133,10 @@ def build_document(profile: dict, ctx: dict = None) -> list:
|
||||
for cid in CHAPTER_ORDER:
|
||||
if cid in (_PORTADA, _GLOSARIO):
|
||||
continue
|
||||
# When a selection is given, skip body chapters outside it. portada and
|
||||
# glosario are never filtered (handled out of this loop).
|
||||
if only_set is not None and cid not in only_set:
|
||||
continue
|
||||
ch = build_chapter(cid, profile, ctx)
|
||||
if ch is not None and ch.blocks:
|
||||
body.append(ch)
|
||||
|
||||
Reference in New Issue
Block a user