blink-data-lint 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """blink: diagnóstico de datos tabulares desde la terminal."""
blink_data_lint/cli.py ADDED
@@ -0,0 +1,105 @@
1
+ """Entrada de terminal y coordinación; sin reglas de análisis."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from polars.exceptions import PolarsError
8
+ from rich.console import Console
9
+ from rich.text import Text
10
+
11
+ from blink_data_lint.core.analyzer import analyze
12
+ from blink_data_lint.core.comparison import compare
13
+ from blink_data_lint.core.summary import summarize
14
+ from blink_data_lint.io.loader import LoadError, load_dataset
15
+ from blink_data_lint.io.serializer import serialize_error, serialize_report
16
+ from blink_data_lint.ui.reporter import show_comparison, show_report
17
+
18
+ app = typer.Typer(
19
+ help="blink: diagnóstico rápido de calidad de datos CSV y Parquet, de solo lectura.",
20
+ add_completion=False,
21
+ no_args_is_help=True,
22
+ pretty_exceptions_enable=False,
23
+ )
24
+
25
+
26
+ @app.command()
27
+ def main(
28
+ file: Annotated[
29
+ Path,
30
+ typer.Argument(
31
+ metavar="FILE",
32
+ help="Ruta de un archivo CSV o Parquet local.",
33
+ ),
34
+ ],
35
+ ml: Annotated[bool, typer.Option("--ml", help="Ejecutar IsolationForest con muestra acotada y semilla fija.")] = False,
36
+ baseline: Annotated[
37
+ Path | None,
38
+ typer.Option(
39
+ "--baseline",
40
+ metavar="FILE", help="Comparar columnas, tipos y distribuciones con un CSV o Parquet anterior.",
41
+ ),
42
+ ] = None,
43
+ json_output: Annotated[bool, typer.Option("--json", help="Emitir JSON para programas, sin tablas ni color.")] = False,
44
+ exit_code: Annotated[bool, typer.Option("--exit-code", help="Devolver código 1 si hay avisos; útil en CI.")] = False,
45
+ ) -> None:
46
+ """Inspecciona salud, outliers IQR y posibles tipos rotos; --ml habilita IsolationForest."""
47
+ sources = {
48
+ "current": {"path": str(file), "format": None, "policy": None},
49
+ "baseline": {"path": str(baseline), "format": None, "policy": None} if baseline is not None else None,
50
+ }
51
+ stage = "current"
52
+ try:
53
+ dataset = load_dataset(file)
54
+ sources["current"].update(format=dataset.format, policy=dataset.policy)
55
+ stage = "baseline"
56
+ previous = load_dataset(baseline) if baseline is not None else None
57
+ if previous is not None:
58
+ sources["baseline"].update(format=previous.format, policy=previous.policy)
59
+ stage = "analysis"
60
+ report = analyze(dataset.frame, ml=ml)
61
+ comparison = compare(dataset.frame, previous.frame) if previous is not None else None
62
+ except LoadError as exc:
63
+ reading = "baseline" if stage == "baseline" else "archivo actual"
64
+ message = f"Error al leer el {reading}: {exc}"
65
+ _show_error(message, sources=sources, code="read_error", stage=stage, json_output=json_output)
66
+ raise typer.Exit(2) from exc
67
+ except PolarsError as exc:
68
+ message = (
69
+ f"Error: no se pudo analizar este esquema ({type(exc).__name__}). "
70
+ "Puede contener tipos anidados no soportados."
71
+ )
72
+ _show_error(message, sources=sources, code="analysis_error", stage=stage, json_output=json_output)
73
+ raise typer.Exit(2) from exc
74
+ except (ValueError, OverflowError) as exc:
75
+ message = f"Error: no se pudo completar el análisis numérico ({type(exc).__name__})."
76
+ _show_error(message, sources=sources, code="analysis_error", stage=stage, json_output=json_output)
77
+ raise typer.Exit(2) from exc
78
+ summary = summarize(report, comparison)
79
+ result_code = 1 if exit_code and summary.warnings else 0
80
+ if json_output:
81
+ try:
82
+ document = serialize_report(report, comparison, summary, sources=sources, exit_code=result_code)
83
+ except ValueError as exc:
84
+ _show_error("Error: el informe contiene métricas no representables en JSON.", sources=sources,
85
+ code="serialization_error", stage="serialization", json_output=True)
86
+ raise typer.Exit(2) from exc
87
+ typer.echo(document)
88
+ else:
89
+ show_report(report, file, format=dataset.format, policy=dataset.policy)
90
+ if comparison is not None and baseline is not None:
91
+ show_comparison(comparison, baseline)
92
+ if result_code:
93
+ raise typer.Exit(result_code)
94
+
95
+
96
+ def _show_error(message: str, *, sources: dict, code: str, stage: str, json_output: bool) -> None:
97
+ if json_output:
98
+ typer.echo(serialize_error(sources=sources, code=code, stage=stage, message=message))
99
+ typer.echo(message, err=True)
100
+ else:
101
+ Console(stderr=True).print(Text(message, style="red"))
102
+
103
+
104
+ if __name__ == "__main__":
105
+ app()
@@ -0,0 +1 @@
1
+ """Lógica de inspección, independiente de la CLI y la presentación."""
@@ -0,0 +1,98 @@
1
+ """Métricas exactas de salud y composición de detectores; sin I/O ni UI."""
2
+
3
+ import polars as pl
4
+
5
+ from blink_data_lint.core.models import AnalysisReport, ColumnMetrics, Finding
6
+ from blink_data_lint.core.detectors import detect
7
+ from blink_data_lint.core.duplicates import excess_duplicates
8
+ from blink_data_lint.core.roles import code_like
9
+
10
+ ID_MIN_VALUES = 10
11
+ ID_UNIQUE_RATIO = 0.95
12
+
13
+
14
+ def _percent(count: int, total: int) -> float | None:
15
+ return count * 100 / total if total else None
16
+
17
+
18
+ def analyze(frame: pl.DataFrame, *, ml: bool = False) -> AnalysisReport:
19
+ rows, width = frame.shape
20
+ findings: list[Finding] = []
21
+ columns: list[ColumnMetrics] = []
22
+ if not rows or not width:
23
+ findings.append(Finding("empty_dataset", "warning", "Dataset sin datos para evaluar."))
24
+
25
+ # Una sola agregación columnar; no materializa filas en Python.
26
+ expressions = []
27
+ for index, (name, dtype) in enumerate(frame.schema.items()):
28
+ column = pl.col(name)
29
+ observed = column.drop_nulls()
30
+ nan = column.is_nan().sum() if dtype.is_float() else pl.lit(0)
31
+ if dtype.is_float():
32
+ observed = observed.filter(~observed.is_nan())
33
+ expressions.extend([
34
+ column.null_count().alias(f"null_{index}"),
35
+ nan.alias(f"nan_{index}"),
36
+ observed.n_unique().alias(f"unique_{index}"),
37
+ ])
38
+ if dtype == pl.String:
39
+ text = observed.filter(observed != "")
40
+ expressions.extend([
41
+ text.len().alias(f"text_{index}"),
42
+ text.n_unique().alias(f"text_unique_{index}"),
43
+ ])
44
+ stats = frame.select(expressions).row(0, named=True) if width else {}
45
+
46
+ for index, (name, dtype) in enumerate(frame.schema.items()):
47
+ nulls = stats[f"null_{index}"]
48
+ nans = stats[f"nan_{index}"]
49
+ distinct = stats[f"unique_{index}"]
50
+ all_null = rows > 0 and nulls == rows
51
+ constant = distinct == 1
52
+ text_count = stats.get(f"text_{index}", 0)
53
+ text_unique = stats.get(f"text_unique_{index}", 0)
54
+ possible_id = (
55
+ text_count >= ID_MIN_VALUES and text_unique / text_count >= ID_UNIQUE_RATIO
56
+ )
57
+ columns.append(ColumnMetrics(
58
+ name, str(dtype), nulls, _percent(nulls, rows), nans,
59
+ distinct, constant, all_null, possible_id,
60
+ ))
61
+ if nulls:
62
+ findings.append(Finding(
63
+ "all_null" if all_null else "nulls", "warning",
64
+ f"{nulls:,} nulos ({nulls / rows:.1%}).", name,
65
+ ))
66
+ if nans:
67
+ findings.append(Finding("nan", "warning", f"{nans:,} valores NaN (no son null).", name))
68
+ if constant:
69
+ findings.append(Finding(
70
+ "constant", "warning", "Un único valor observado; columna constante.", name,
71
+ ))
72
+ if possible_id:
73
+ findings.append(Finding(
74
+ "possible_id", "info",
75
+ f"Posible ID: {text_unique:,}/{text_count:,} textos no vacíos únicos "
76
+ f"({text_unique / text_count:.1%}).", name,
77
+ ))
78
+ elif code_like(name):
79
+ findings.append(Finding(
80
+ "possible_code", "info",
81
+ "Posible código/identificador por nombre; no se trata como medida numérica.", name,
82
+ ))
83
+
84
+ duplicates = excess_duplicates(frame)
85
+ if duplicates:
86
+ findings.append(Finding(
87
+ "duplicates", "warning",
88
+ f"{duplicates:,} filas duplicadas excedentes ({duplicates / rows:.1%}).",
89
+ ))
90
+ null_count = sum(column.null_count for column in columns)
91
+ detectors, detected_findings = detect(frame, ml=ml)
92
+ findings.extend(detected_findings)
93
+ return AnalysisReport(
94
+ rows, tuple(columns), null_count, _percent(null_count, rows * width),
95
+ sum(column.nan_count for column in columns), duplicates,
96
+ _percent(duplicates, rows), tuple(findings),
97
+ detectors=detectors,
98
+ )
@@ -0,0 +1,25 @@
1
+ """Comparación de esquemas y agregados; sin lectura ni presentación."""
2
+
3
+ import polars as pl
4
+
5
+ from blink_data_lint.core.models import ComparisonReport, SchemaChange
6
+ from blink_data_lint.core.drift import detect_drift
7
+
8
+
9
+ def compare(current: pl.DataFrame, baseline: pl.DataFrame) -> ComparisonReport:
10
+ changes = []
11
+ previous_schema = baseline.schema
12
+ current_schema = current.schema
13
+ # Conservar el orden de las columnas, sin convertir tipos ni adivinar renombres.
14
+ for name, dtype in current_schema.items():
15
+ if name not in previous_schema:
16
+ changes.append(SchemaChange(name, "added", None, str(dtype)))
17
+ elif dtype != previous_schema[name]:
18
+ changes.append(SchemaChange(name, "type_changed", str(previous_schema[name]), str(dtype)))
19
+ for name, dtype in previous_schema.items():
20
+ if name not in current_schema:
21
+ changes.append(SchemaChange(name, "removed", str(dtype), None))
22
+ return ComparisonReport(
23
+ baseline.height, current.height, baseline.width, current.width, tuple(changes),
24
+ detect_drift(current, baseline),
25
+ )
@@ -0,0 +1,143 @@
1
+ """Detectores de solo lectura; cobertura y omisiones explícitas."""
2
+
3
+ import math
4
+
5
+ import polars as pl
6
+
7
+ from blink_data_lint.core.models import DetectorResult, Finding
8
+ from blink_data_lint.core.roles import code_like
9
+
10
+ MIN_VALUES = 10
11
+ TYPE_RATIO = 0.9
12
+ ML_ROWS = 10_000
13
+ ML_COLUMNS = 32
14
+ SEED = 42
15
+
16
+
17
+ def numeric_outliers(series: pl.Series) -> DetectorResult:
18
+ values = series.drop_nulls()
19
+ values = values.filter(values.is_finite())
20
+ base = dict(method="IQR", columns=(series.name,), total_rows=len(series),
21
+ eligible_rows=len(values), evaluated_rows=0, count=None)
22
+ if len(values) < MIN_VALUES:
23
+ return DetectorResult(**base, reason="Menos de 10 valores finitos.")
24
+ if code_like(series.name):
25
+ return DetectorResult(**base, reason="Posible ID/código por nombre; no se aplica IQR a etiquetas.")
26
+ # Escalado evita overflow al restar extremos finitos; las comparaciones
27
+ # conservan el criterio IQR. Las cotas publicadas usan la escala original.
28
+ scale = max(abs(values.min()), abs(values.max())) or 1
29
+ normalized = values.cast(pl.Float64) / scale
30
+ q1 = normalized.quantile(0.25, interpolation="linear")
31
+ q3 = normalized.quantile(0.75, interpolation="linear")
32
+ spread = q3 - q1
33
+ if spread == 0:
34
+ return DetectorResult(**base, reason="IQR cero; no se divide ni se infieren outliers.")
35
+ low, high = q1 - 1.5 * spread, q3 + 1.5 * spread
36
+ lower, upper = low * scale, high * scale
37
+ if not math.isfinite(lower) or not math.isfinite(upper):
38
+ return DetectorResult(**base, reason="Cotas IQR fuera del rango float64.")
39
+ count = ((normalized < low) | (normalized > high)).sum()
40
+ base.update(evaluated_rows=len(values), count=count)
41
+ return DetectorResult(**base, parameters=(
42
+ ("factor", "1.5"), ("cuantiles", "lineales"),
43
+ ("inferior", f"{lower:.12g}"), ("superior", f"{upper:.12g}"),
44
+ ))
45
+
46
+
47
+ def broken_type(series: pl.Series) -> DetectorResult:
48
+ values = series.drop_nulls().str.strip_chars()
49
+ values = values.filter(values != "")
50
+ base = dict(method="tipo textual", columns=(series.name,), total_rows=len(series),
51
+ eligible_rows=len(values), evaluated_rows=0, count=None)
52
+ if len(values) < MIN_VALUES:
53
+ return DetectorResult(**base, reason="Menos de 10 textos no vacíos.")
54
+ if code_like(series.name):
55
+ return DetectorResult(**base, reason="Posible ID/código por nombre; no se infiere un tipo roto.")
56
+ # No convertir la columna original: los valores incompatibles se conservan.
57
+ numeric = values.cast(pl.Float64, strict=False)
58
+ numeric_count = numeric.is_finite().fill_null(False).sum()
59
+ leading_zero = values.str.contains(r"^[+-]?0\d+$").any()
60
+ dates = values.str.contains(r"^\d{4}-\d{2}-\d{2}$") & (
61
+ values.str.to_date(format="%Y-%m-%d", strict=False).is_not_null()
62
+ )
63
+ date_count = dates.sum()
64
+ if numeric_count / len(values) >= TYPE_RATIO and not leading_zero:
65
+ target, compatible = "numérico finito", numeric_count
66
+ elif date_count / len(values) >= TYPE_RATIO:
67
+ target, compatible = "fecha ISO YYYY-MM-DD", date_count
68
+ else:
69
+ reason = ("Posible código con ceros iniciales; conversión numérica omitida."
70
+ if leading_zero and numeric_count / len(values) >= TYPE_RATIO
71
+ else "Sin mayoría ≥90% numérica o de fechas ISO; no se adivina intención.")
72
+ return DetectorResult(**base, reason=reason)
73
+ base.update(evaluated_rows=len(values), count=len(values) - compatible)
74
+ return DetectorResult(**base, parameters=(
75
+ ("tipo candidato", target), ("proporción mínima", "0.9"),
76
+ ("compatibles", str(compatible)),
77
+ ))
78
+
79
+
80
+ def isolation_forest(frame: pl.DataFrame, *, enabled: bool) -> DetectorResult:
81
+ base = dict(method="IsolationForest", columns=(), total_rows=frame.height,
82
+ eligible_rows=0, evaluated_rows=0, count=None)
83
+ if not enabled:
84
+ return DetectorResult(**base, reason="Desactivado; usar --ml para ejecutar.")
85
+ columns = []
86
+ for name, dtype in frame.schema.items():
87
+ if not (dtype.is_integer() or dtype.is_float()):
88
+ continue
89
+ finite = frame[name].drop_nulls()
90
+ finite = finite.filter(finite.is_finite())
91
+ if len(finite) >= MIN_VALUES and finite.n_unique() > 1 and not code_like(name):
92
+ columns.append(name)
93
+ if len(columns) > ML_COLUMNS:
94
+ return DetectorResult(**base, reason="Más de 32 columnas elegibles; trabajo ML acotado.")
95
+ base.update(columns=tuple(columns))
96
+ if not columns:
97
+ return DetectorResult(**base, reason="Sin columnas numéricas variables elegibles.")
98
+ eligible = frame.select(columns).filter(pl.all_horizontal(
99
+ pl.col(name).is_finite().fill_null(False) for name in columns
100
+ ))
101
+ base.update(eligible_rows=eligible.height)
102
+ if eligible.height < 32:
103
+ return DetectorResult(**base, reason="Menos de 32 filas completas y finitas.")
104
+ sample = eligible.sample(n=ML_ROWS, seed=SEED, shuffle=True) if eligible.height > ML_ROWS else eligible
105
+ # Solo la muestra acotada cruza a NumPy. sklearn no se importa al arrancar.
106
+ import numpy as np
107
+ from sklearn.ensemble import IsolationForest
108
+
109
+ matrix = sample.to_numpy().astype(np.float64)
110
+ # sklearn convierte internamente a float32; escalar evita overflow.
111
+ matrix /= np.maximum(np.abs(matrix).max(axis=0), 1)
112
+ if not np.any(np.ptp(matrix.astype(np.float32), axis=0) > 0):
113
+ return DetectorResult(**base, reason="Sin variación representable en la muestra float32.")
114
+ model = IsolationForest(n_estimators=64, max_samples=min(256, sample.height), contamination="auto",
115
+ random_state=SEED, n_jobs=1)
116
+ count = int((model.fit_predict(matrix) == -1).sum())
117
+ base.update(evaluated_rows=sample.height, count=count)
118
+ return DetectorResult(**base, parameters=(
119
+ ("seed", str(SEED)), ("árboles", "64"), ("max_samples", str(min(256, sample.height))),
120
+ ("límite de filas", str(ML_ROWS)), ("límite de columnas", str(ML_COLUMNS)),
121
+ ("contamination", "auto"), ("umbral decision_function", "<0"), ("n_jobs", "1"),
122
+ ))
123
+
124
+
125
+ def detect(frame: pl.DataFrame, *, ml: bool = False) -> tuple[tuple[DetectorResult, ...], tuple[Finding, ...]]:
126
+ results = []
127
+ findings = []
128
+ for name, dtype in frame.schema.items():
129
+ if dtype.is_integer() or dtype.is_float():
130
+ results.append(numeric_outliers(frame[name]))
131
+ elif dtype == pl.String:
132
+ results.append(broken_type(frame[name]))
133
+ results.append(isolation_forest(frame, enabled=ml))
134
+ for result in results:
135
+ if result.count:
136
+ code = {"IQR": "numeric_outliers", "tipo textual": "broken_type",
137
+ "IsolationForest": "isolation_outliers"}[result.method]
138
+ findings.append(Finding(
139
+ code, "warning", f"{result.count:,} indicios por {result.method} "
140
+ f"sobre {result.evaluated_rows:,} filas evaluadas; revisar contexto.",
141
+ result.columns[0] if len(result.columns) == 1 else None,
142
+ ))
143
+ return tuple(results), tuple(findings)
@@ -0,0 +1,147 @@
1
+ """Comparación exacta de agregados; umbrales de revisión, no tests de hipótesis."""
2
+
3
+ import math
4
+
5
+ import polars as pl
6
+
7
+ from blink_data_lint.core.models import DriftMetric
8
+ from blink_data_lint.core.roles import code_like
9
+
10
+ MIN_ROWS = 100
11
+ MEAN_THRESHOLD = 0.5
12
+ DISTRIBUTION_THRESHOLD = 0.2
13
+ MISSING_THRESHOLD = 0.1
14
+ MAX_CATEGORIES = 50
15
+ CDF_CHUNK_ROWS = 65_536
16
+
17
+
18
+ def _metric(
19
+ previous: pl.Series, current: pl.Series, method: str,
20
+ previous_count: int, current_count: int, *, score: float | None = None,
21
+ threshold: float | None = None, reason: str | None = None,
22
+ previous_value: float | None = None, current_value: float | None = None,
23
+ ) -> DriftMetric:
24
+ return DriftMetric(current.name, method, len(previous), len(current),
25
+ previous_count, current_count, score, threshold, reason,
26
+ previous_value, current_value)
27
+
28
+
29
+ def _missing(previous: pl.Series, current: pl.Series) -> DriftMetric:
30
+ if min(len(previous), len(current)) < MIN_ROWS:
31
+ return _metric(previous, current, "missing_rate", len(previous), len(current),
32
+ threshold=MISSING_THRESHOLD, reason="Menos de 100 filas en alguno de los archivos.")
33
+
34
+ def rate(series: pl.Series) -> float:
35
+ missing = series.null_count()
36
+ if series.dtype.is_float():
37
+ missing += series.is_nan().sum()
38
+ return missing / len(series)
39
+
40
+ before, after = rate(previous), rate(current)
41
+ return _metric(previous, current, "missing_rate", len(previous), len(current),
42
+ score=abs(after - before), threshold=MISSING_THRESHOLD,
43
+ previous_value=before, current_value=after)
44
+
45
+
46
+ def _numeric(previous: pl.Series, current: pl.Series) -> tuple[DriftMetric, ...]:
47
+ before = previous.drop_nulls()
48
+ before = before.filter(before.is_finite())
49
+ after = current.drop_nulls()
50
+ after = after.filter(after.is_finite())
51
+
52
+ def result(method: str, threshold: float, **kwargs) -> DriftMetric:
53
+ return _metric(previous, current, method, len(before), len(after), threshold=threshold, **kwargs)
54
+
55
+ def skipped(reason: str) -> tuple[DriftMetric, ...]:
56
+ return (result("mean_shift", MEAN_THRESHOLD, reason=reason),
57
+ result("ks", DISTRIBUTION_THRESHOLD, reason=reason))
58
+
59
+ if min(len(before), len(after)) < MIN_ROWS:
60
+ return skipped("Menos de 100 valores finitos en alguno de los archivos.")
61
+ if current.dtype.is_integer():
62
+ if any(max(abs(values.min()), abs(values.max())) > 2**53 for values in (before, after)):
63
+ return skipped("Enteros fuera de ±2^53; float64 perdería precisión.")
64
+
65
+ # NumPy solo materializa dos columnas finitas cuando se usa --baseline.
66
+ import numpy as np
67
+
68
+ old = before.cast(pl.Float64).to_numpy()
69
+ new = after.cast(pl.Float64).to_numpy()
70
+ scale = max(float(np.abs(old).max()), float(np.abs(new).max())) or 1.0
71
+ old_scaled, new_scaled = old / scale, new / scale
72
+ lost_precision = (np.unique(old_scaled).size != before.n_unique()
73
+ or np.unique(new_scaled).size != after.n_unique())
74
+ old_mean, new_mean = float(old_scaled.mean()), float(new_scaled.mean())
75
+ std = float(old_scaled.std(ddof=0))
76
+ if lost_precision:
77
+ mean = result("mean_shift", MEAN_THRESHOLD, reason="El escalado pierde valores distintos en float64.")
78
+ elif std == 0:
79
+ mean = result("mean_shift", MEAN_THRESHOLD, reason="Desviación del baseline cero o no representable.")
80
+ else:
81
+ score = abs(new_mean - old_mean) / std
82
+ if not math.isfinite(score):
83
+ mean = result("mean_shift", MEAN_THRESHOLD, reason="Desplazamiento de media no representable.")
84
+ else:
85
+ mean = result("mean_shift", MEAN_THRESHOLD, score=score,
86
+ previous_value=old_mean * scale, current_value=new_mean * scale)
87
+
88
+ # Distancia empírica exacta: evaluar ambas CDF a la derecha de cada valor
89
+ # observado contempla empates y tamaños distintos, sin p-valores ni muestreo.
90
+ old_sorted, new_sorted = np.sort(old), np.sort(new)
91
+ distance = 0.0
92
+ # Recorrer todo el soporte en bloques, sin construir la unión ni dos CDF
93
+ # completas. Es el mismo máximo exacto, no una muestra de observaciones.
94
+ for values in (old_sorted, new_sorted):
95
+ for offset in range(0, len(values), CDF_CHUNK_ROWS):
96
+ support = np.unique(values[offset:offset + CDF_CHUNK_ROWS])
97
+ delta = np.searchsorted(new_sorted, support, side="right") / len(new_sorted)
98
+ delta -= np.searchsorted(old_sorted, support, side="right") / len(old_sorted)
99
+ distance = max(distance, float(np.max(np.abs(delta))))
100
+ return mean, result("ks", DISTRIBUTION_THRESHOLD, score=distance)
101
+
102
+
103
+ def _categorical(previous: pl.Series, current: pl.Series) -> DriftMetric:
104
+ before, after = previous.drop_nulls(), current.drop_nulls()
105
+
106
+ def result(**kwargs) -> DriftMetric:
107
+ return _metric(previous, current, "total_variation", len(before), len(after),
108
+ threshold=DISTRIBUTION_THRESHOLD, **kwargs)
109
+
110
+ if min(len(before), len(after)) < MIN_ROWS:
111
+ return result(reason="Menos de 100 valores no nulos en alguno de los archivos.")
112
+ if pl.concat([before, after]).n_unique() > MAX_CATEGORIES:
113
+ return result(reason="Más de 50 categorías; posible ID o texto libre.")
114
+ # Solo se materializan los conteos de hasta 50 categorías en Python.
115
+ def counts(values: pl.Series) -> dict:
116
+ table = values.rename("value").value_counts(name="frequency")
117
+ return dict(zip(table["value"].to_list(), table["frequency"].to_list()))
118
+
119
+ old_counts, new_counts = counts(before), counts(after)
120
+ categories = old_counts.keys() | new_counts.keys()
121
+ distance = math.fsum(abs(new_counts.get(key, 0) / len(after) - old_counts.get(key, 0) / len(before))
122
+ for key in categories) / 2
123
+ return result(score=distance)
124
+
125
+
126
+ def detect_drift(current: pl.DataFrame, baseline: pl.DataFrame) -> tuple[DriftMetric, ...]:
127
+ results = []
128
+ previous_schema = baseline.schema
129
+ for name, dtype in current.schema.items():
130
+ if name not in previous_schema:
131
+ continue
132
+ previous, column = baseline[name], current[name]
133
+ if dtype != previous_schema[name]:
134
+ results.append(_metric(previous, column, "distribution", 0, 0,
135
+ reason="Tipos diferentes; no se convierten valores para comparar."))
136
+ continue
137
+ results.append(_missing(previous, column))
138
+ if (dtype.is_integer() or dtype.is_float()) and code_like(name):
139
+ results.append(_categorical(previous, column))
140
+ elif dtype.is_integer() or dtype.is_float():
141
+ results.extend(_numeric(previous, column))
142
+ elif dtype in (pl.String, pl.Boolean):
143
+ results.append(_categorical(previous, column))
144
+ else:
145
+ results.append(_metric(previous, column, "distribution", 0, 0,
146
+ reason="Tipo sin comparación de distribución implementada."))
147
+ return tuple(results)
@@ -0,0 +1,17 @@
1
+ """Conteo exacto con prefiltrado de candidatos; los hashes no deciden igualdad."""
2
+
3
+ import polars as pl
4
+
5
+
6
+ def excess_duplicates(frame: pl.DataFrame) -> int:
7
+ if not frame.height or not frame.width:
8
+ return 0
9
+ # Filas iguales tienen el mismo hash. Una colisión solo agrega candidatos;
10
+ # la comparación final se hace sobre sus valores completos con Polars.
11
+ candidates = frame.hash_rows(seed=0).is_duplicated()
12
+ count = candidates.sum()
13
+ if not count:
14
+ return 0
15
+ if count == frame.height:
16
+ return frame.height - frame.n_unique()
17
+ return count - frame.filter(candidates).n_unique()
@@ -0,0 +1,98 @@
1
+ """Resultados de inspección independientes de la terminal y del almacenamiento."""
2
+
3
+ from dataclasses import dataclass
4
+ import math
5
+ from typing import Literal
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Finding:
10
+ code: str
11
+ severity: Literal["info", "warning"]
12
+ message: str
13
+ column: str | None = None
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ColumnMetrics:
18
+ name: str
19
+ dtype: str
20
+ null_count: int
21
+ null_percent: float | None
22
+ nan_count: int
23
+ distinct_count: int # Valores observados; excluye null y NaN.
24
+ constant: bool
25
+ all_null: bool
26
+ possible_id: bool
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class DetectorResult:
31
+ method: str
32
+ columns: tuple[str, ...]
33
+ total_rows: int
34
+ eligible_rows: int
35
+ evaluated_rows: int
36
+ count: int | None
37
+ parameters: tuple[tuple[str, str], ...] = ()
38
+ reason: str | None = None
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class AnalysisReport:
43
+ rows: int
44
+ columns: tuple[ColumnMetrics, ...]
45
+ null_count: int
46
+ null_percent: float | None
47
+ nan_count: int
48
+ duplicate_count: int
49
+ duplicate_percent: float | None
50
+ findings: tuple[Finding, ...]
51
+ coverage: str = "exacta · todas las filas"
52
+ detectors: tuple[DetectorResult, ...] = ()
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SchemaChange:
57
+ column: str
58
+ kind: Literal["added", "removed", "type_changed"]
59
+ previous_type: str | None
60
+ current_type: str | None
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class DriftMetric:
65
+ column: str
66
+ method: str
67
+ baseline_rows: int
68
+ current_rows: int
69
+ baseline_eligible: int
70
+ current_eligible: int
71
+ score: float | None
72
+ threshold: float | None
73
+ reason: str | None = None
74
+ baseline_value: float | None = None
75
+ current_value: float | None = None
76
+
77
+ @property
78
+ def flagged(self) -> bool:
79
+ return (self.score is not None and self.threshold is not None
80
+ and (self.score >= self.threshold
81
+ or math.isclose(self.score, self.threshold, rel_tol=0, abs_tol=1e-12)))
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class ComparisonReport:
86
+ baseline_rows: int
87
+ current_rows: int
88
+ baseline_columns: int
89
+ current_columns: int
90
+ changes: tuple[SchemaChange, ...]
91
+ drift: tuple[DriftMetric, ...] = ()
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class ReportSummary:
96
+ warnings: int
97
+ information: int
98
+ skipped_analyses: int
@@ -0,0 +1,10 @@
1
+ """Indicios conservadores por nombre; nunca afirman semántica de negocio."""
2
+
3
+ import re
4
+
5
+ CODE_TOKENS = {"id", "code", "codigo", "código", "type", "tipo", "status", "estado"}
6
+
7
+
8
+ def code_like(name: str) -> bool:
9
+ separated = re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", "_", name)
10
+ return bool(CODE_TOKENS.intersection(re.split(r"[\W_]+", separated.casefold())))
@@ -0,0 +1,12 @@
1
+ """Resumen compartido por la CLI y sus formatos; sin I/O ni códigos de proceso."""
2
+
3
+ from blink_data_lint.core.models import AnalysisReport, ComparisonReport, ReportSummary
4
+
5
+
6
+ def summarize(report: AnalysisReport, comparison: ComparisonReport | None = None) -> ReportSummary:
7
+ warnings = sum(item.severity == "warning" for item in report.findings)
8
+ skipped = sum(item.reason is not None for item in report.detectors)
9
+ if comparison is not None:
10
+ warnings += len(comparison.changes) + sum(item.flagged for item in comparison.drift)
11
+ skipped += sum(item.reason is not None for item in comparison.drift)
12
+ return ReportSummary(warnings, sum(item.severity == "info" for item in report.findings), skipped)
@@ -0,0 +1 @@
1
+ """Lectura de archivos tabulares locales."""
@@ -0,0 +1,49 @@
1
+ """Lectura local y estricta; no corrige ni escribe los datos de entrada."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import polars as pl
7
+
8
+
9
+ class LoadError(Exception):
10
+ """La entrada no puede inspeccionarse como dataset soportado."""
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class LoadedDataset:
15
+ frame: pl.DataFrame
16
+ format: str
17
+ policy: str
18
+
19
+
20
+ def load_dataset(path: Path) -> LoadedDataset:
21
+ suffix = path.suffix.lower()
22
+ try:
23
+ if not path.is_file():
24
+ raise LoadError("La entrada debe ser un archivo local regular existente.")
25
+ # Un handle evita interpretar caracteres como '[' o '*' como un glob.
26
+ with path.open("rb") as source:
27
+ if suffix == ".csv":
28
+ frame = pl.read_csv(
29
+ source,
30
+ infer_schema_length=None,
31
+ try_parse_dates=False,
32
+ ignore_errors=False,
33
+ encoding="utf8",
34
+ raise_if_empty=True,
35
+ )
36
+ return LoadedDataset(
37
+ frame,
38
+ "CSV",
39
+ "UTF-8 · coma · cabecera · inferencia completa · fechas como texto",
40
+ )
41
+ if suffix == ".parquet":
42
+ return LoadedDataset(pl.read_parquet(source), "Parquet", "Esquema del archivo")
43
+ raise LoadError("Formato no soportado. Usá un archivo .csv o .parquet.")
44
+ except (OSError, pl.exceptions.PolarsError, UnicodeError) as exc:
45
+ # No mostrar valores de celdas que algunos errores de Polars incluyen.
46
+ raise LoadError(
47
+ f"No se pudo leer el archivo {suffix or 'sin extensión'} "
48
+ f"({type(exc).__name__}). Revisá permisos, codificación y estructura."
49
+ ) from exc
@@ -0,0 +1,44 @@
1
+ """Contrato JSON versionado; sin Rich, lectura de archivos ni cálculos estadísticos."""
2
+
3
+ from dataclasses import asdict
4
+ import json
5
+
6
+ from blink_data_lint.core.models import AnalysisReport, ComparisonReport, ReportSummary
7
+
8
+ SCHEMA_VERSION = "1.0"
9
+
10
+
11
+ def serialize_report(
12
+ report: AnalysisReport, comparison: ComparisonReport | None, summary: ReportSummary,
13
+ *, sources: dict, exit_code: int,
14
+ ) -> str:
15
+ analysis = asdict(report)
16
+ analysis["detectors"] = [
17
+ dict(asdict(item), parameters=dict(item.parameters),
18
+ status="skipped" if item.reason is not None else "completed")
19
+ for item in report.detectors
20
+ ]
21
+ compared = asdict(comparison) if comparison is not None else None
22
+ if compared is not None:
23
+ compared["drift"] = [
24
+ dict(asdict(item), flagged=item.flagged,
25
+ status="skipped" if item.reason is not None else "completed")
26
+ for item in comparison.drift
27
+ ]
28
+ return _encode(dict(
29
+ schema_version=SCHEMA_VERSION, status="completed", sources=sources,
30
+ analysis=analysis, comparison=compared, summary=asdict(summary),
31
+ error=None, exit_code=exit_code,
32
+ ))
33
+
34
+
35
+ def serialize_error(*, sources: dict, code: str, stage: str, message: str) -> str:
36
+ return _encode(dict(
37
+ schema_version=SCHEMA_VERSION, status="error", sources=sources,
38
+ analysis=None, comparison=None, summary=None,
39
+ error=dict(code=code, stage=stage, message=message), exit_code=2,
40
+ ))
41
+
42
+
43
+ def _encode(document: dict) -> str:
44
+ return json.dumps(document, ensure_ascii=True, allow_nan=False, indent=2)
@@ -0,0 +1 @@
1
+ """Presentación de resultados en terminal."""
@@ -0,0 +1,134 @@
1
+ """Presentación Rich a partir de resultados; sin cálculos de calidad."""
2
+
3
+ from pathlib import Path
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ from blink_data_lint.core.models import AnalysisReport, ComparisonReport
11
+
12
+
13
+ def _percent(value: float | None) -> str:
14
+ return f"{value:.1f}%" if value is not None else "n/d"
15
+
16
+
17
+ def show_report(
18
+ report: AnalysisReport, path: Path, *, format: str, policy: str,
19
+ console: Console | None = None,
20
+ ) -> None:
21
+ output = console if console is not None else Console()
22
+ warnings = sum(item.severity == "warning" for item in report.findings)
23
+ status = "REVISAR" if warnings else "CON INDICIOS" if report.findings else "SIN ALERTAS BÁSICAS"
24
+ summary = Text(str(path), style="bold")
25
+ summary.append(f"\n{format} · {report.rows:,} filas · {len(report.columns):,} columnas")
26
+ summary.append(f"\nNulos: {report.null_count:,} ({_percent(report.null_percent)})")
27
+ summary.append(f" · NaN: {report.nan_count:,}")
28
+ summary.append(f"\nDuplicados excedentes: {report.duplicate_count:,} ({_percent(report.duplicate_percent)})")
29
+ summary.append(f"\nCobertura: {report.coverage}\n{policy}", style="dim")
30
+ output.print(Panel(summary, title=f"blink · {status}", border_style="yellow" if warnings else "cyan"))
31
+
32
+ if report.columns:
33
+ if output.width >= 72:
34
+ table = Table(title="Salud por columna", expand=True, show_lines=False)
35
+ for label in ("Columna", "Tipo", "Nulos", "NaN", "Únicos¹", "Estado"):
36
+ table.add_column(label, overflow="fold")
37
+ for column in report.columns:
38
+ flags = []
39
+ if column.all_null:
40
+ flags.append("SOLO NULOS")
41
+ if column.constant:
42
+ flags.append("CONSTANTE")
43
+ if column.possible_id:
44
+ flags.append("POSIBLE ID")
45
+ if column.null_count or column.nan_count:
46
+ flags.append("FALTANTES")
47
+ table.add_row(
48
+ Text(column.name), Text(column.dtype),
49
+ f"{column.null_count:,} ({_percent(column.null_percent)})",
50
+ f"{column.nan_count:,}", f"{column.distinct_count:,}",
51
+ " · ".join(flags) or "Sin alertas",
52
+ )
53
+ output.print(table)
54
+ else:
55
+ for column in report.columns:
56
+ detail = Text(column.name, style="bold")
57
+ detail.append(f"\n{column.dtype} · únicos¹: {column.distinct_count:,}")
58
+ detail.append(f"\nNulos: {column.null_count:,} ({_percent(column.null_percent)}) · NaN: {column.nan_count:,}")
59
+ output.print(Panel(detail))
60
+ output.print(Text("¹ Únicos observados: excluyen null y NaN.", style="dim"))
61
+
62
+ if report.findings:
63
+ output.print(Text(f"\nHallazgos: {len(report.findings)}", style="bold"))
64
+ for item in report.findings:
65
+ line = Text("AVISO" if item.severity == "warning" else "INFO", style="yellow" if item.severity == "warning" else "cyan")
66
+ if item.column is not None:
67
+ line.append(f" · {item.column}", style="bold")
68
+ line.append(f": {item.message}")
69
+ output.print(line)
70
+ else:
71
+ output.print("Sin hallazgos en las reglas ejecutadas. Revisá la cobertura y las omisiones.")
72
+
73
+ if report.detectors:
74
+ output.print(Text("\nDetectores · cobertura independiente de la salud básica", style="bold"))
75
+ for result in report.detectors:
76
+ line = Text(f"{result.method} · ", style="bold")
77
+ line.append(", ".join(result.columns) or "dataset")
78
+ if result.reason:
79
+ line.append(f"\nOMITIDO: {result.reason}")
80
+ line.append(f"\n0 evaluadas / {result.eligible_rows:,} elegibles / {result.total_rows:,} totales")
81
+ else:
82
+ scope = "muestra" if result.evaluated_rows < result.eligible_rows else "todas las elegibles"
83
+ line.append(f"\n{result.count:,} indicios · {result.evaluated_rows:,} evaluadas "
84
+ f"/ {result.eligible_rows:,} elegibles / {result.total_rows:,} totales · {scope}")
85
+ if result.parameters:
86
+ line.append("\n" + " · ".join(f"{key}={value}" for key, value in result.parameters))
87
+ output.print(line)
88
+
89
+
90
+ def show_comparison(
91
+ report: ComparisonReport, baseline: Path, *, console: Console | None = None,
92
+ ) -> None:
93
+ output = console if console is not None else Console()
94
+ summary = Text("Archivo anterior: ", style="bold")
95
+ summary.append(str(baseline))
96
+ summary.append(f"\nFilas: {report.baseline_rows:,} → {report.current_rows:,}")
97
+ summary.append(f" · Columnas: {report.baseline_columns:,} → {report.current_columns:,}")
98
+ summary.append("\nCompara esquema y valores agregados, sin emparejar filas.")
99
+ warnings = bool(report.changes) or any(metric.flagged for metric in report.drift)
100
+ status = "REVISAR" if warnings else "SIN CAMBIOS DE ESQUEMA"
101
+ output.print(Panel(summary, title=f"Comparación · {status}",
102
+ border_style="yellow" if warnings else "cyan"))
103
+ labels = {"added": "AGREGADA", "removed": "ELIMINADA", "type_changed": "CAMBIO DE TIPO"}
104
+ for change in report.changes:
105
+ line = Text(f"AVISO · {labels[change.kind]} · ", style="yellow")
106
+ line.append(change.column, style="bold")
107
+ line.append(f": {change.previous_type or 'ausente'} → {change.current_type or 'ausente'}")
108
+ output.print(line)
109
+ if not report.changes:
110
+ output.print("Sin cambios de nombres o tipos de columnas.")
111
+ if not report.drift:
112
+ output.print("Distribuciones: sin columnas comunes para comparar.")
113
+ return
114
+ output.print(Text("\nCambios de valores · exactos sobre observaciones elegibles", style="bold"))
115
+ methods = {"mean_shift": "CAMBIO DE MEDIA (σ anterior)", "ks": "DISTRIBUCIÓN KS",
116
+ "total_variation": "DISTRIBUCIÓN DE CATEGORÍAS", "missing_rate": "FALTANTES",
117
+ "distribution": "DISTRIBUCIÓN"}
118
+ for metric in report.drift:
119
+ status = "OMITIDO" if metric.reason else "AVISO" if metric.flagged else "INFO"
120
+ line = Text(f"{status} · {methods[metric.method]} · ",
121
+ style="yellow" if metric.flagged else "dim" if metric.reason else "cyan")
122
+ line.append(metric.column, style="bold")
123
+ if metric.reason:
124
+ line.append(f": {metric.reason}")
125
+ else:
126
+ factor = 100 if metric.method == "missing_rate" else 1
127
+ unit = " pp" if metric.method == "missing_rate" else ""
128
+ line.append(f": {metric.score * factor:.6g}{unit} · umbral ≥{metric.threshold * factor:g}{unit}")
129
+ if metric.baseline_value is not None and metric.current_value is not None:
130
+ unit = "%" if metric.method == "missing_rate" else ""
131
+ line.append(f" · {metric.baseline_value * factor:.6g}{unit} → {metric.current_value * factor:.6g}{unit}")
132
+ line.append(f"\nElegibles: anterior {metric.baseline_eligible:,}/{metric.baseline_rows:,}"
133
+ f" · actual {metric.current_eligible:,}/{metric.current_rows:,}")
134
+ output.print(line)
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.5
2
+ Name: blink-data-lint
3
+ Version: 0.1.0
4
+ Summary: Zero-config, read-only data quality diagnostics for CSV and Parquet.
5
+ Project-URL: Homepage, https://github.com/Gianni300x/blink
6
+ Project-URL: Repository, https://github.com/Gianni300x/blink
7
+ Project-URL: Issues, https://github.com/Gianni300x/blink/issues
8
+ Author: Gianni Luigi Messina
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: anomaly-detection,cli,csv,data-quality,parquet
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: numpy<3,>=2.0
24
+ Requires-Dist: polars<2,>=1.0
25
+ Requires-Dist: rich<15,>=13.9
26
+ Requires-Dist: scikit-learn<2,>=1.5
27
+ Requires-Dist: typer<1,>=0.16
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest<10,>=8.3; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # blink
33
+
34
+ **Un diagnóstico de calidad de datos, a un comando de distancia.**
35
+
36
+ Blink es una herramienta de terminal que analiza archivos CSV y Parquet locales
37
+ y muestra problemas de calidad e indicios de anomalías. No requiere configuración
38
+ previa, no envía datos a servicios externos y no modifica tus archivos.
39
+
40
+ ## Qué hace
41
+
42
+ - Muestra dimensiones y tipos de las columnas.
43
+ - Detecta nulos, NaN, filas duplicadas y columnas constantes.
44
+ - Señala posibles identificadores de texto.
45
+ - Detecta outliers numéricos con IQR.
46
+ - Advierte sobre columnas de texto mayormente numéricas o de fechas con valores incompatibles.
47
+ - Permite analizar anomalías con IsolationForest mediante `--ml`.
48
+ - Compara columnas, tipos y distribuciones con un archivo anterior mediante `--baseline`.
49
+
50
+ Los resultados aparecen en tablas de terminal, con conteos, cobertura y motivos
51
+ cuando un análisis se omite. Son indicios para revisar en contexto.
52
+
53
+ ## Instalación
54
+
55
+ Requiere **Python 3.11 o superior**. Con [uv](https://docs.astral.sh/uv/getting-started/installation/),
56
+ instalá la app en un entorno aislado y comprobá la ayuda:
57
+
58
+ ```bash
59
+ uv tool install blink-data-lint
60
+ blink --help
61
+ ```
62
+
63
+ El paquete se llama **`blink-data-lint`** en PyPI; el comando es **`blink`**.
64
+ Si acabás de instalar uv y la terminal no encuentra `blink`, ejecutá
65
+ `uv tool update-shell` y abrí una terminal nueva.
66
+
67
+ <details>
68
+ <summary>Alternativas: pip o instalación desde el repositorio</summary>
69
+
70
+ Con pip, en macOS/Linux:
71
+
72
+ ```bash
73
+ python3 -m venv .venv
74
+ source .venv/bin/activate
75
+ python -m pip install blink-data-lint
76
+ blink --help
77
+ ```
78
+
79
+ En Windows, desde PowerShell:
80
+
81
+ ```powershell
82
+ py -3 -m venv .venv
83
+ .venv\Scripts\Activate.ps1
84
+ python -m pip install blink-data-lint
85
+ blink --help
86
+ ```
87
+
88
+ Mantené el entorno activo. En una terminal nueva, volvé a activarlo con
89
+ `source .venv/bin/activate` (macOS/Linux) o `.venv\Scripts\Activate.ps1` (Windows).
90
+
91
+ Para trabajar desde el código fuente, necesitás [Git](https://git-scm.com/downloads/):
92
+
93
+ ```bash
94
+ git clone https://github.com/Gianni300x/blink.git
95
+ cd blink
96
+ uv sync
97
+ uv run blink tests/data/sample_dirty.csv
98
+ ```
99
+
100
+ En este caso, usá `uv run blink` en lugar de `blink` en los ejemplos de abajo.
101
+ Con pip desde el repositorio, podés usar `python -m pip install -e .` dentro del
102
+ entorno virtual en lugar de instalar el paquete desde PyPI.
103
+
104
+ </details>
105
+
106
+ ## Uso
107
+
108
+ ```bash
109
+ blink datos.csv
110
+ blink datos.parquet
111
+ ```
112
+
113
+ Reemplazá `datos.csv` o `datos.parquet` por la ruta de tu archivo. Si tiene
114
+ espacios, escribila entre comillas: `blink "mis datos/ventas.csv"`.
115
+
116
+ Para habilitar el análisis con ML:
117
+
118
+ ```bash
119
+ blink datos.csv --ml
120
+ ```
121
+
122
+ Para comparar con un archivo anterior y detectar cambios de columnas, tipos,
123
+ medias, distribuciones o proporción de faltantes:
124
+
125
+ ```bash
126
+ blink datos.csv --baseline anterior.csv
127
+ ```
128
+
129
+ Ambos archivos pueden ser CSV o Parquet. Se comparan columnas por nombre;
130
+ un cambio de orden no genera un aviso. Los valores se comparan de forma agregada,
131
+ sin emparejar filas. La salida muestra las métricas, umbrales y cobertura.
132
+
133
+ Para consultar la ayuda:
134
+
135
+ ```bash
136
+ blink --help
137
+ ```
138
+
139
+ Para desactivar los colores en macOS/Linux: `NO_COLOR=1 blink datos.csv`.
140
+
141
+ ## Límites de uso
142
+
143
+ - Archivos locales CSV o Parquet que entren en memoria. CSV requiere UTF-8,
144
+ comas y una primera fila de cabecera; no se adivinan otros separadores.
145
+ - IQR necesita al menos 10 valores numéricos finitos. Si el rango intercuartílico
146
+ es cero, se omite el detector y se indica el motivo.
147
+ - Los tipos rotos requieren al menos 10 textos no vacíos y una mayoría ≥90 %
148
+ de números finitos o fechas válidas `YYYY-MM-DD`. No se interpretan fechas
149
+ locales como `01/02/2026`.
150
+ - ML necesita al menos 32 filas completas y finitas en sus columnas numéricas
151
+ elegibles. Si hay más de 32 columnas elegibles, se omite. Evalúa hasta 10.000
152
+ filas; por encima toma una muestra y muestra su cobertura, sin extrapolar conteos.
153
+ - Nombres como `VendorID`, `PULocationID`, `payment_type` o `codigo_postal`
154
+ indican posibles códigos o etiquetas. Se omiten de IQR y ML; al comparar
155
+ archivos se usan frecuencias, no medias o distancias numéricas. Es una
156
+ heurística por nombre y se informa como tal; no demuestra una clave primaria.
157
+ - Una anomalía es un indicio, no una prueba de error. Un análisis omitido tampoco
158
+ equivale a un resultado sin problemas. Revisá los motivos que muestra la salida.
159
+ - La comparación de valores requiere tipos idénticos y al menos 100 observaciones
160
+ elegibles por archivo. Avisa si la media se desplaza ≥0.5 desviaciones estándar
161
+ del archivo anterior, la distancia de distribución es ≥0.2 (KS en números o
162
+ variación total en categorías), o los faltantes cambian ≥10 puntos porcentuales.
163
+ Son umbrales de revisión; no son pruebas de significación estadística.
164
+ Con desviación anterior cero se omite la comparación de medias, pero se compara
165
+ la distribución. Las categorías se limitan a 50 valores distintos entre ambos
166
+ archivos; IDs/texto libre y tipos no compatibles pueden omitir ese análisis.
167
+
168
+ ## Integración con otros programas y CI
169
+
170
+ El uso habitual sigue siendo `blink archivo.csv`. Si necesitás un informe para
171
+ otro programa, agregá `--json`:
172
+
173
+ ```bash
174
+ blink datos.csv --json > informe.json
175
+ ```
176
+
177
+ Para hacer fallar una validación automatizada cuando aparezcan avisos:
178
+
179
+ ```bash
180
+ blink datos.csv --json --exit-code > informe.json
181
+ ```
182
+
183
+ También podés combinarlo con `--baseline anterior.csv` y `--ml`. En un paso de CI,
184
+ el código de salida determina si continúa o falla la validación:
185
+
186
+ | Código | Significado |
187
+ | --- | --- |
188
+ | `0` | Inspección completada; sin `--exit-code`, los avisos también devuelven `0`. |
189
+ | `1` | Hay avisos y se activó `--exit-code`. |
190
+ | `2` | Error de uso, lectura o análisis, con o sin `--exit-code`. |
191
+
192
+ Si preferís ver las tablas en el log de CI, usá `--exit-code` sin `--json`.
193
+
194
+ Se consideran avisos los hallazgos de calidad, cambios de esquema y métricas de
195
+ drift que superan su umbral. Los posibles IDs informativos y los análisis omitidos
196
+ no hacen fallar CI por sí solos. Dos métricas alertadas en una columna cuentan
197
+ como dos avisos. Un aviso sigue siendo un indicio para revisar en contexto.
198
+
199
+ El JSON usa `schema_version: "1.0"` e incluye `analysis`, `comparison`, `summary`,
200
+ `error` y `exit_code`, además de los archivos y políticas de lectura en `sources`.
201
+ `status: "completed"` indica que la ejecución terminó, no que todos los datos sean
202
+ correctos. Cada detector indica `completed` o `skipped` y su cobertura. Los
203
+ errores de lectura/análisis generan `status: "error"` y un diagnóstico por stderr,
204
+ sin informe parcial. Los errores de sintaxis del comando se muestran por stderr
205
+ sin generar un documento JSON; comprobá siempre el código de salida.
206
+
207
+ Si clonaste el repositorio, podés reproducir un fallo de validación con su CSV de ejemplo:
208
+
209
+ ```bash
210
+ blink tests/data/sample_dirty.csv --json --exit-code > informe.json
211
+ ```
212
+
213
+ Este ejemplo devuelve `1` porque contiene problemas conocidos; el informe JSON
214
+ queda disponible para revisar los avisos.
215
+
216
+ Licencia [MIT](https://github.com/Gianni300x/blink/blob/main/LICENSE).
@@ -0,0 +1,21 @@
1
+ blink_data_lint/__init__.py,sha256=48jzLSUAoJ3XRh33yAxTV5_Ga4UGHR-zdJaUaIEozq8,64
2
+ blink_data_lint/cli.py,sha256=SOtZPAPbisMwJfV9l8KZqpvSkhvP-a5Eh_-suMvAa9s,4549
3
+ blink_data_lint/core/__init__.py,sha256=D5wYM50tLYl3BqVMNy3zk_rFMNVyFqosL4cbMo0y9uo,74
4
+ blink_data_lint/core/analyzer.py,sha256=HZee1PCd0pG-xuAzZMbYuknTvC-uDcX9k17HiNg80dQ,3969
5
+ blink_data_lint/core/comparison.py,sha256=mSFGXyOmi4XS28o5Ma-5eEDv8GvCDiii0IlB2IHNHDg,1113
6
+ blink_data_lint/core/detectors.py,sha256=9ywsdZs3JyUDdey7Gd_ErZTyDmKC4WQ-aPO9_kRTPN4,7159
7
+ blink_data_lint/core/drift.py,sha256=tOSBPRNhdwohFBjbEk34U-BLdlMUu3cBHk2NdN9V2fE,7094
8
+ blink_data_lint/core/duplicates.py,sha256=e8StoPWhM10kl5L16f_msITzhJZKxc_VdkjDDQAn8FI,627
9
+ blink_data_lint/core/models.py,sha256=3ClxuxRg4xCVqSMRu1Ekw0jv1uHNKQsrI2fX2w63bzc,2302
10
+ blink_data_lint/core/roles.py,sha256=pQDzKsQsl6wi6LgBgjNfTgZjcOQcK4WFdTCURf7JAOo,383
11
+ blink_data_lint/core/summary.py,sha256=NcOm-yGJbcXpdJGyC7luVLvQ7aVwApxKq312G8Q3-Ek,725
12
+ blink_data_lint/io/__init__.py,sha256=SA-l-GOkg-LNZA3x9W9GQgqrXxEuz_agHHXe50Xxk1Q,45
13
+ blink_data_lint/io/loader.py,sha256=2GqG8z-tGZteTgGqAYZxJkUgFT03jfeAOW1_CVbHrf4,1782
14
+ blink_data_lint/io/serializer.py,sha256=C-CvJ8lAdJh1g1g7FcRfQI60SztF6a_GcyUTvCWYaI8,1614
15
+ blink_data_lint/ui/__init__.py,sha256=IeAVCNxf76nXqyAgZt3eOHmze-vvZXeNroa3PjYMYWA,47
16
+ blink_data_lint/ui/reporter.py,sha256=nHU64otqDYZMmVt8VUWYZo6NZ-U6-hIrnC0uCsgHs1I,7334
17
+ blink_data_lint-0.1.0.dist-info/METADATA,sha256=DHC1A-PuJjbFY7Az40P1UKjJSE6FylyEIQlgUQAk2xk,8357
18
+ blink_data_lint-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
19
+ blink_data_lint-0.1.0.dist-info/entry_points.txt,sha256=cfrt68J8GprQD2s6Uiw_s3hn8riaRxTYt0vrmbJ2apg,50
20
+ blink_data_lint-0.1.0.dist-info/licenses/LICENSE,sha256=cX8oUqNWFxpctpgUVunNH97LMIj8eCOYWjwkry3XySc,1077
21
+ blink_data_lint-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ blink = blink_data_lint.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gianni Luigi Messina
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.