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.
- blink_data_lint/__init__.py +1 -0
- blink_data_lint/cli.py +105 -0
- blink_data_lint/core/__init__.py +1 -0
- blink_data_lint/core/analyzer.py +98 -0
- blink_data_lint/core/comparison.py +25 -0
- blink_data_lint/core/detectors.py +143 -0
- blink_data_lint/core/drift.py +147 -0
- blink_data_lint/core/duplicates.py +17 -0
- blink_data_lint/core/models.py +98 -0
- blink_data_lint/core/roles.py +10 -0
- blink_data_lint/core/summary.py +12 -0
- blink_data_lint/io/__init__.py +1 -0
- blink_data_lint/io/loader.py +49 -0
- blink_data_lint/io/serializer.py +44 -0
- blink_data_lint/ui/__init__.py +1 -0
- blink_data_lint/ui/reporter.py +134 -0
- blink_data_lint-0.1.0.dist-info/METADATA +216 -0
- blink_data_lint-0.1.0.dist-info/RECORD +21 -0
- blink_data_lint-0.1.0.dist-info/WHEEL +4 -0
- blink_data_lint-0.1.0.dist-info/entry_points.txt +2 -0
- blink_data_lint-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|