778 lines
24 KiB
Python
778 lines
24 KiB
Python
"""Limpieza de CSV con análisis inteligente local y reglas configurables.
|
|
|
|
Este módulo es pura lógica (sin GUI, sin LLM). Provee:
|
|
- `analyze_dataframe` para obtener un resumen por columna (tipo inferido,
|
|
ejemplo aleatorio, nulos, únicos, estadísticas específicas por tipo).
|
|
- Un registro `CLEANING_RULES` con reglas aplicables a columnas.
|
|
- `apply_rules` que aplica las reglas seleccionadas por columna y devuelve
|
|
el DataFrame resultante junto con un log de operaciones/advertencias.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv as _csv
|
|
import logging
|
|
import random
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Callable, Optional
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MODELO DE DATOS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
TYPE_INTEGER = "integer"
|
|
TYPE_DECIMAL = "decimal"
|
|
TYPE_DATE = "date"
|
|
TYPE_BOOLEAN = "boolean"
|
|
TYPE_CATEGORICAL = "categorical"
|
|
TYPE_TEXT = "text"
|
|
TYPE_MIXED = "mixed"
|
|
TYPE_EMPTY = "empty"
|
|
|
|
|
|
@dataclass
|
|
class ColumnSummary:
|
|
"""Resumen inteligente para una columna del DataFrame."""
|
|
name: str
|
|
inferred_type: str
|
|
example: str = ""
|
|
null_count: int = 0
|
|
null_pct: float = 0.0
|
|
unique_count: int = 0
|
|
unique_pct: float = 0.0
|
|
min: Optional[float] = None
|
|
max: Optional[float] = None
|
|
mean: Optional[float] = None
|
|
avg_length: Optional[float] = None
|
|
top_values: list[tuple[str, int]] = field(default_factory=list)
|
|
detected_date_format: Optional[str] = None
|
|
applicable_rules: list[str] = field(default_factory=list)
|
|
|
|
def info_text(self) -> str:
|
|
"""Texto humano con información adicional (para subtítulo de la UI)."""
|
|
parts: list[str] = []
|
|
parts.append(f"nulos: {self.null_count} ({self.null_pct:.0f}%)")
|
|
parts.append(f"únicos: {self.unique_count}")
|
|
if self.inferred_type in (TYPE_INTEGER, TYPE_DECIMAL):
|
|
if self.min is not None:
|
|
parts.append(f"min: {_fmt_num(self.min)}")
|
|
if self.max is not None:
|
|
parts.append(f"max: {_fmt_num(self.max)}")
|
|
if self.mean is not None:
|
|
parts.append(f"media: {_fmt_num(self.mean)}")
|
|
elif self.inferred_type == TYPE_DATE:
|
|
if self.detected_date_format:
|
|
parts.append(f"formato: {self.detected_date_format}")
|
|
elif self.inferred_type in (TYPE_TEXT, TYPE_CATEGORICAL):
|
|
if self.avg_length is not None:
|
|
parts.append(f"long. prom: {self.avg_length:.1f}")
|
|
if self.top_values:
|
|
tops = ", ".join(f'"{v}" ({c})' for v, c in self.top_values[:3])
|
|
parts.append(f"top: {tops}")
|
|
return " · ".join(parts)
|
|
|
|
|
|
def _fmt_num(x: float) -> str:
|
|
if x is None:
|
|
return ""
|
|
if isinstance(x, float) and x.is_integer():
|
|
return str(int(x))
|
|
if isinstance(x, float):
|
|
return f"{x:.2f}"
|
|
return str(x)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DETECCIÓN DE FECHAS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_DATE_NUM_RE = re.compile(r"^\s*(\d{1,4})[\s\/\-\.](\d{1,2})[\s\/\-\.](\d{1,4})\s*$")
|
|
_DATE_COMPACT_RE = re.compile(r"^\s*(\d{6}|\d{8})\s*$")
|
|
# Hora opcional: HH[:MM[:SS]][ AM|PM] al final de la cadena
|
|
_TIME_TAIL_RE = re.compile(
|
|
r"[\sT]+(\d{1,2})(?::(\d{1,2})(?::(\d{1,2}))?)?\s*([APap]\.?\s*[Mm]\.?)?\s*$"
|
|
)
|
|
|
|
|
|
def _split_date_time(value: str) -> tuple[str, Optional[str]]:
|
|
"""Separa la parte de fecha de la parte de hora si está presente.
|
|
|
|
Devuelve `(date_part, time_part_normalized)`. `time_part_normalized` es
|
|
None si no hay hora. La hora se normaliza a `HH:MM[:SS][ AM|PM]`.
|
|
"""
|
|
if value is None:
|
|
return "", None
|
|
s = str(value).strip()
|
|
if not s:
|
|
return "", None
|
|
|
|
m = _TIME_TAIL_RE.search(s)
|
|
if not m:
|
|
return s, None
|
|
|
|
hh = int(m.group(1))
|
|
mm = int(m.group(2)) if m.group(2) else 0
|
|
ss = m.group(3)
|
|
ampm = m.group(4)
|
|
if not (0 <= hh <= 23 and 0 <= mm <= 59):
|
|
return s, None
|
|
if ss is not None:
|
|
try:
|
|
ssi = int(ss)
|
|
if not (0 <= ssi <= 59):
|
|
return s, None
|
|
except ValueError:
|
|
return s, None
|
|
# Validar contra AM/PM (con AM/PM, hora 1-12)
|
|
if ampm and not (1 <= hh <= 12):
|
|
return s, None
|
|
|
|
date_part = s[: m.start()].rstrip(" T")
|
|
if not date_part:
|
|
return s, None
|
|
|
|
parts: list[str] = [f"{hh:02d}:{mm:02d}"]
|
|
if ss is not None:
|
|
parts[0] += f":{int(ss):02d}"
|
|
if ampm:
|
|
norm_ampm = ampm.replace(".", "").replace(" ", "").upper()
|
|
if norm_ampm in ("AM", "PM"):
|
|
parts.append(norm_ampm)
|
|
time_norm = " ".join(parts)
|
|
return date_part, time_norm
|
|
|
|
|
|
def _extract_date_parts(value: str) -> Optional[tuple[int, int, int, str]]:
|
|
"""Intenta extraer (a, b, c, layout) de una fecha en cualquier formato común.
|
|
|
|
`layout` es uno de: 'sep' (con separadores), 'compact8' (YYYYMMDD o
|
|
DDMMYYYY), 'compact6' (DDMMYY o YYMMDD). No interpreta todavía qué
|
|
componente es día/mes/año; eso lo decide `detect_date_format` viendo la
|
|
columna completa. Acepta valores con hora al final (ignorada para los
|
|
componentes de fecha).
|
|
"""
|
|
if value is None:
|
|
return None
|
|
s = str(value).strip()
|
|
if not s or s.lower() in ("nan", "none", "nat"):
|
|
return None
|
|
|
|
date_part, _ = _split_date_time(s)
|
|
if date_part:
|
|
s = date_part
|
|
|
|
m = _DATE_NUM_RE.match(s)
|
|
if m:
|
|
try:
|
|
a, b, c = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
return (a, b, c, "sep")
|
|
except ValueError:
|
|
return None
|
|
|
|
m2 = _DATE_COMPACT_RE.match(s)
|
|
if m2:
|
|
digits = m2.group(1)
|
|
if len(digits) == 8:
|
|
return (int(digits[0:2]), int(digits[2:4]), int(digits[4:8]), "compact8")
|
|
if len(digits) == 6:
|
|
return (int(digits[0:2]), int(digits[2:4]), int(digits[4:6]), "compact6")
|
|
|
|
return None
|
|
|
|
|
|
def detect_date_format(series: pd.Series, sample_size: int = 500) -> Optional[str]:
|
|
"""Analiza la serie y determina el formato de fecha dominante.
|
|
|
|
Devuelve un string con el layout detectado (p. ej. "DD/MM/YYYY",
|
|
"MM/DD/YYYY", "YYYYMMDD", "DDMMYYYY") o None si no parece fechas.
|
|
"""
|
|
if series is None or len(series) == 0:
|
|
return None
|
|
|
|
non_null = series.dropna()
|
|
if non_null.empty:
|
|
return None
|
|
|
|
if len(non_null) > sample_size:
|
|
non_null = non_null.sample(sample_size, random_state=42)
|
|
|
|
layouts = {"sep": 0, "compact8": 0, "compact6": 0}
|
|
sep_first_gt12 = 0 # primer componente > 12 → probable día
|
|
sep_second_gt12 = 0 # segundo componente > 12 → probable mes solo posible si MM/DD
|
|
sep_third_4digits = 0 # tercer componente de 4 dígitos → año al final
|
|
sep_first_4digits = 0 # primer componente de 4 dígitos → año al inicio
|
|
compact8_year_first = 0 # primeros 4 dígitos > 1900 → YYYYMMDD
|
|
compact8_year_last = 0 # últimos 4 dígitos > 1900 → DDMMYYYY
|
|
total_parsed = 0
|
|
|
|
for val in non_null:
|
|
parsed = _extract_date_parts(val)
|
|
if parsed is None:
|
|
continue
|
|
total_parsed += 1
|
|
a, b, c, layout = parsed
|
|
layouts[layout] = layouts.get(layout, 0) + 1
|
|
|
|
if layout == "sep":
|
|
# año puede estar al inicio o al final según cantidad de dígitos
|
|
if _digit_count(a) == 4:
|
|
sep_first_4digits += 1
|
|
if _digit_count(c) == 4:
|
|
sep_third_4digits += 1
|
|
if a > 12:
|
|
sep_first_gt12 += 1
|
|
if b > 12:
|
|
sep_second_gt12 += 1
|
|
elif layout == "compact8":
|
|
# a son los primeros 2 dígitos, c son los últimos 4
|
|
if c >= 1900:
|
|
compact8_year_last += 1
|
|
# a concatenado con b son los primeros 4 dígitos
|
|
first4 = a * 100 + b
|
|
if first4 >= 1900:
|
|
compact8_year_first += 1
|
|
|
|
if total_parsed == 0:
|
|
return None
|
|
|
|
# Requerimos que >=70% de los no nulos parseen como fecha para considerarla
|
|
# columna de fechas.
|
|
if total_parsed / len(non_null) < 0.7:
|
|
return None
|
|
|
|
dominant = max(layouts, key=lambda k: layouts[k])
|
|
|
|
if dominant == "compact8":
|
|
if compact8_year_first > compact8_year_last:
|
|
return "YYYYMMDD"
|
|
return "DDMMYYYY"
|
|
if dominant == "compact6":
|
|
return "DDMMYY"
|
|
|
|
# Caso con separadores
|
|
if sep_first_4digits > sep_third_4digits:
|
|
return "YYYY/MM/DD"
|
|
# año al final
|
|
if sep_first_gt12 > 0 and sep_second_gt12 == 0:
|
|
return "DD/MM/YYYY"
|
|
if sep_second_gt12 > 0 and sep_first_gt12 == 0:
|
|
return "MM/DD/YYYY"
|
|
# ambiguo → preferencia hispana
|
|
return "DD/MM/YYYY"
|
|
|
|
|
|
def _digit_count(n: int) -> int:
|
|
return len(str(abs(n)))
|
|
|
|
|
|
def _parse_date_with_format(value: str, fmt: str) -> Optional[tuple[int, int, int]]:
|
|
"""Convierte un valor de fecha a (día, mes, año) según el layout detectado."""
|
|
parsed = _extract_date_parts(value)
|
|
if parsed is None:
|
|
return None
|
|
a, b, c, layout = parsed
|
|
|
|
if fmt == "DD/MM/YYYY":
|
|
d, m, y = a, b, c
|
|
elif fmt == "MM/DD/YYYY":
|
|
m, d, y = a, b, c
|
|
elif fmt == "YYYY/MM/DD":
|
|
y, m, d = a, b, c
|
|
elif fmt == "YYYYMMDD" and layout == "compact8":
|
|
# reconstruir: primer4 = a*100 + b, luego mes y día
|
|
y = a * 100 + b
|
|
m = c // 100
|
|
d = c % 100
|
|
elif fmt == "DDMMYYYY" and layout == "compact8":
|
|
d = a
|
|
m = b
|
|
y = c
|
|
elif fmt == "DDMMYY" and layout == "compact6":
|
|
d = a
|
|
m = b
|
|
y = 2000 + c if c < 50 else 1900 + c
|
|
else:
|
|
# intentar como sep genérico
|
|
d, m, y = a, b, c
|
|
|
|
if not (1 <= m <= 12 and 1 <= d <= 31 and 1000 <= y <= 9999):
|
|
# año en 2 dígitos
|
|
if y < 100:
|
|
y = 2000 + y if y < 50 else 1900 + y
|
|
if not (1 <= m <= 12 and 1 <= d <= 31 and 1000 <= y <= 9999):
|
|
return None
|
|
return (d, m, y)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ANÁLISIS DE COLUMNAS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_BOOLEAN_VALUES = {"true", "false", "si", "sí", "no", "1", "0", "yes"}
|
|
|
|
|
|
def _infer_column_type(series: pd.Series) -> tuple[str, Optional[str]]:
|
|
"""Devuelve (tipo, formato_fecha_detectado)."""
|
|
non_null = series.dropna().astype(str).str.strip()
|
|
non_null = non_null[non_null != ""]
|
|
if non_null.empty:
|
|
return TYPE_EMPTY, None
|
|
|
|
# Booleano
|
|
lowered = non_null.str.lower()
|
|
if lowered.isin(_BOOLEAN_VALUES).all() and lowered.nunique() <= 3:
|
|
return TYPE_BOOLEAN, None
|
|
|
|
# Numérico
|
|
# Aceptamos separador de miles con "." o "," y decimal con "," o ".".
|
|
# Estrategia: intentar to_numeric tras normalizar.
|
|
numeric_candidate = _to_numeric_lenient(non_null)
|
|
numeric_ratio = numeric_candidate.notna().mean()
|
|
if numeric_ratio >= 0.9:
|
|
is_all_integer = (numeric_candidate.dropna() % 1 == 0).all()
|
|
return (TYPE_INTEGER if is_all_integer else TYPE_DECIMAL), None
|
|
|
|
# Fecha
|
|
date_fmt = detect_date_format(non_null)
|
|
if date_fmt:
|
|
return TYPE_DATE, date_fmt
|
|
|
|
# Categórico vs texto
|
|
unique_ratio = non_null.nunique() / len(non_null)
|
|
if non_null.nunique() <= 15 and unique_ratio < 0.3:
|
|
return TYPE_CATEGORICAL, None
|
|
|
|
# Mixto si mezcla números con texto y nada es dominante
|
|
if 0.1 < numeric_ratio < 0.9:
|
|
return TYPE_MIXED, None
|
|
|
|
return TYPE_TEXT, None
|
|
|
|
|
|
def _to_numeric_lenient(series: pd.Series) -> pd.Series:
|
|
"""Intenta convertir a numérico aceptando separadores comunes."""
|
|
s = series.astype(str).str.strip()
|
|
s = s.str.replace(r"\s+", "", regex=True)
|
|
|
|
# Si hay ambos "." y ",", asumir "." como miles y "," como decimal
|
|
def _norm(x: str) -> str:
|
|
if "." in x and "," in x:
|
|
return x.replace(".", "").replace(",", ".")
|
|
if "," in x and "." not in x:
|
|
# coma decimal
|
|
return x.replace(",", ".")
|
|
return x
|
|
|
|
s = s.map(_norm)
|
|
return pd.to_numeric(s, errors="coerce")
|
|
|
|
|
|
def _applicable_rules_for_type(inferred_type: str) -> list[str]:
|
|
"""Reglas sugeridas según tipo inferido."""
|
|
base = ["strip_special_spaces", "strip_special_keep_date_seps", "strip_spaces"]
|
|
if inferred_type in (TYPE_INTEGER, TYPE_DECIMAL):
|
|
return base + ["to_integer", "round_decimals_custom"]
|
|
if inferred_type == TYPE_DATE:
|
|
return base + ["date_numeric_to_ddmmyyyy", "date_alpha_to_ddmmyyyy"]
|
|
if inferred_type == TYPE_MIXED:
|
|
return base + [
|
|
"to_integer",
|
|
"round_decimals_custom",
|
|
"date_numeric_to_ddmmyyyy",
|
|
"date_alpha_to_ddmmyyyy",
|
|
]
|
|
# Texto / categórico / boolean / empty → todas las de limpieza de texto + las
|
|
# conversoras (el usuario sabrá si aplica).
|
|
return base + [
|
|
"to_integer",
|
|
"round_decimals_custom",
|
|
"date_numeric_to_ddmmyyyy",
|
|
"date_alpha_to_ddmmyyyy",
|
|
]
|
|
|
|
|
|
def analyze_dataframe(df: pd.DataFrame) -> list[ColumnSummary]:
|
|
"""Genera un resumen por columna del DataFrame."""
|
|
summaries: list[ColumnSummary] = []
|
|
total_rows = len(df)
|
|
rng = random.Random(42)
|
|
|
|
for col in df.columns:
|
|
series = df[col]
|
|
null_count = int(series.isna().sum() + (series.astype(str).str.strip() == "").sum())
|
|
# Evitar doble conteo: considerar vacío solo si no-NaN
|
|
null_count = int((series.isna() | (series.astype(str).str.strip() == "")).sum())
|
|
null_pct = (null_count / total_rows * 100) if total_rows else 0.0
|
|
|
|
non_null = series.dropna().astype(str)
|
|
non_null = non_null[non_null.str.strip() != ""]
|
|
|
|
example = ""
|
|
if not non_null.empty:
|
|
example = str(rng.choice(non_null.tolist()))
|
|
if len(example) > 80:
|
|
example = example[:77] + "..."
|
|
|
|
unique_count = int(non_null.nunique())
|
|
unique_pct = (unique_count / total_rows * 100) if total_rows else 0.0
|
|
|
|
inferred_type, date_fmt = _infer_column_type(series)
|
|
|
|
summary = ColumnSummary(
|
|
name=str(col),
|
|
inferred_type=inferred_type,
|
|
example=example,
|
|
null_count=null_count,
|
|
null_pct=null_pct,
|
|
unique_count=unique_count,
|
|
unique_pct=unique_pct,
|
|
detected_date_format=date_fmt,
|
|
)
|
|
|
|
if inferred_type in (TYPE_INTEGER, TYPE_DECIMAL):
|
|
numeric = _to_numeric_lenient(non_null)
|
|
if numeric.notna().any():
|
|
summary.min = float(numeric.min())
|
|
summary.max = float(numeric.max())
|
|
summary.mean = float(numeric.mean())
|
|
elif inferred_type in (TYPE_TEXT, TYPE_CATEGORICAL):
|
|
if not non_null.empty:
|
|
summary.avg_length = float(non_null.str.len().mean())
|
|
counts = non_null.value_counts().head(3)
|
|
summary.top_values = [(str(v), int(c)) for v, c in counts.items()]
|
|
|
|
summary.applicable_rules = _applicable_rules_for_type(inferred_type)
|
|
summaries.append(summary)
|
|
|
|
return summaries
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# REGLAS DE LIMPIEZA
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _rule_strip_special_spaces(series: pd.Series) -> pd.Series:
|
|
"""Elimina todo lo que no sea alfanumérico (incluye espacios)."""
|
|
pattern = re.compile(r"[^A-Za-z0-9]", flags=re.UNICODE)
|
|
|
|
def _fn(x):
|
|
if pd.isna(x):
|
|
return x
|
|
return pattern.sub("", str(x))
|
|
|
|
return series.map(_fn)
|
|
|
|
|
|
def _rule_strip_special_keep_date_seps(series: pd.Series) -> pd.Series:
|
|
"""Conserva alfanuméricos y separadores de fecha: '/', '-', espacio."""
|
|
pattern = re.compile(r"[^A-Za-z0-9\/\- ]", flags=re.UNICODE)
|
|
|
|
def _fn(x):
|
|
if pd.isna(x):
|
|
return x
|
|
return pattern.sub("", str(x))
|
|
|
|
return series.map(_fn)
|
|
|
|
|
|
def _rule_strip_spaces(series: pd.Series) -> pd.Series:
|
|
"""Elimina únicamente espacios (incluidos tabs y saltos de línea)."""
|
|
def _fn(x):
|
|
if pd.isna(x):
|
|
return x
|
|
return re.sub(r"\s+", "", str(x))
|
|
|
|
return series.map(_fn)
|
|
|
|
|
|
def _rule_to_integer(series: pd.Series) -> pd.Series:
|
|
"""Convierte a entero (Int64 nullable) con parser tolerante."""
|
|
numeric = _to_numeric_lenient(series.astype(str))
|
|
return numeric.round().astype("Int64")
|
|
|
|
|
|
def _rule_round_decimals_custom(series: pd.Series) -> pd.Series:
|
|
"""Aproximación custom: fracción > 0.5 → entero superior, resto → inferior.
|
|
|
|
Funciona con positivos y negativos (usa parte entera hacia 0 y suma o
|
|
resta 1 según el signo y la fracción).
|
|
"""
|
|
numeric = _to_numeric_lenient(series.astype(str))
|
|
|
|
def _fn(x):
|
|
if pd.isna(x):
|
|
return pd.NA
|
|
sign = -1 if x < 0 else 1
|
|
ax = abs(float(x))
|
|
integer_part = int(ax)
|
|
frac = ax - integer_part
|
|
rounded = integer_part + (1 if frac > 0.5 else 0)
|
|
return sign * rounded
|
|
|
|
return numeric.map(_fn).astype("Int64")
|
|
|
|
|
|
def _rule_date_numeric_to_ddmmyyyy(series: pd.Series) -> pd.Series:
|
|
"""Entrada: fechas en formato numérico (ej. 20240315 o 15032024).
|
|
|
|
Salida: "DD/MM/AAAA" tras detectar el formato predominante.
|
|
"""
|
|
# Para esta regla consideramos los valores "numéricos" (quitamos separadores
|
|
# comunes si los hay para detectar un YYYYMMDD/DDMMYYYY confiable).
|
|
stripped = series.astype(str).str.replace(r"[\s\/\-\.]", "", regex=True)
|
|
stripped = stripped.where(series.notna(), other=pd.NA)
|
|
fmt = detect_date_format(stripped)
|
|
if fmt is None:
|
|
# fallback: intentar con detector normal
|
|
fmt = detect_date_format(series)
|
|
return _format_dates_to_ddmmyyyy(series, fmt)
|
|
|
|
|
|
def _rule_date_alpha_to_ddmmyyyy(series: pd.Series) -> pd.Series:
|
|
"""Entrada: fechas en formato alfanumérico con separadores.
|
|
|
|
Salida: "DD/MM/AAAA".
|
|
"""
|
|
fmt = detect_date_format(series)
|
|
return _format_dates_to_ddmmyyyy(series, fmt)
|
|
|
|
|
|
def _format_dates_to_ddmmyyyy(series: pd.Series, fmt: Optional[str]) -> pd.Series:
|
|
"""Convierte cada valor al formato DD/MM/AAAA usando `fmt` como layout de
|
|
entrada detectado. La parte de hora (si existe) se descarta. Mantiene el
|
|
valor original si no puede parsear la fecha.
|
|
"""
|
|
if fmt is None:
|
|
return series.copy()
|
|
|
|
def _fn(x):
|
|
if pd.isna(x) or str(x).strip() == "":
|
|
return x
|
|
raw = str(x)
|
|
date_part, _time_part = _split_date_time(raw)
|
|
target = date_part if date_part else raw
|
|
parsed = _parse_date_with_format(target, fmt)
|
|
if parsed is None:
|
|
return x
|
|
d, m, y = parsed
|
|
return f"{d:02d}/{m:02d}/{y:04d}"
|
|
|
|
return series.map(_fn)
|
|
|
|
|
|
# Registro de reglas
|
|
CLEANING_RULES: list[dict[str, Any]] = [
|
|
{
|
|
"id": "strip_special_spaces",
|
|
"name": "Eliminar caracteres especiales y espacios",
|
|
"description": "Deja solo caracteres alfanuméricos (A-Z, a-z, 0-9).",
|
|
"fn": _rule_strip_special_spaces,
|
|
},
|
|
{
|
|
"id": "strip_special_keep_date_seps",
|
|
"name": "Eliminar caracteres especiales y espacios (excepto / - y espacio, p. ej. Fecha Servicio)",
|
|
"description": "Conserva alfanuméricos y los separadores '/', '-' y espacios.",
|
|
"fn": _rule_strip_special_keep_date_seps,
|
|
},
|
|
{
|
|
"id": "strip_spaces",
|
|
"name": "Eliminar únicamente espacios",
|
|
"description": "Elimina todos los caracteres de espacio en blanco.",
|
|
"fn": _rule_strip_spaces,
|
|
},
|
|
{
|
|
"id": "to_integer",
|
|
"name": "Convertir a Entero",
|
|
"description": "Convierte la columna a enteros (los decimales se redondean).",
|
|
"fn": _rule_to_integer,
|
|
},
|
|
{
|
|
"id": "date_numeric_to_ddmmyyyy",
|
|
"name": "Conversión de formato numérico (Fechas) → DD/MM/AAAA",
|
|
"description": "Detecta el formato numérico predominante (YYYYMMDD, DDMMYYYY, etc.) y normaliza a DD/MM/AAAA.",
|
|
"fn": _rule_date_numeric_to_ddmmyyyy,
|
|
},
|
|
{
|
|
"id": "date_alpha_to_ddmmyyyy",
|
|
"name": "Conversión de formato alfanumérico (Fechas) → DD/MM/AAAA",
|
|
"description": "Detecta el formato con separadores predominante (DD/MM/YYYY, MM/DD/YYYY, YYYY/MM/DD, etc.) y normaliza a DD/MM/AAAA.",
|
|
"fn": _rule_date_alpha_to_ddmmyyyy,
|
|
},
|
|
{
|
|
"id": "round_decimals_custom",
|
|
"name": "Aproximación Decimales (>0.5 sube, ≤0.5 baja)",
|
|
"description": "Si la fracción decimal es mayor a 0.5 se aproxima al entero superior; de lo contrario, al inferior.",
|
|
"fn": _rule_round_decimals_custom,
|
|
},
|
|
]
|
|
|
|
_RULE_BY_ID: dict[str, Callable[[pd.Series], pd.Series]] = {
|
|
r["id"]: r["fn"] for r in CLEANING_RULES
|
|
}
|
|
|
|
_RULE_NAME_BY_ID: dict[str, str] = {r["id"]: r["name"] for r in CLEANING_RULES}
|
|
|
|
|
|
def get_rule(rule_id: str) -> Optional[dict[str, Any]]:
|
|
"""Devuelve el registro de una regla por id."""
|
|
for r in CLEANING_RULES:
|
|
if r["id"] == rule_id:
|
|
return r
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# APLICACIÓN DE REGLAS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def apply_rules(
|
|
df: pd.DataFrame,
|
|
rules_per_column: dict[str, list[str]],
|
|
) -> tuple[pd.DataFrame, list[str]]:
|
|
"""Aplica las reglas seleccionadas a cada columna.
|
|
|
|
`rules_per_column` es un dict de `columna -> lista de IDs de reglas en el
|
|
orden a aplicar`. Devuelve el DataFrame resultante y un log.
|
|
"""
|
|
result = df.copy()
|
|
log: list[str] = []
|
|
|
|
for col, rule_ids in rules_per_column.items():
|
|
if not rule_ids:
|
|
continue
|
|
if col not in result.columns:
|
|
log.append(f"[WARN] Columna no encontrada: {col}")
|
|
continue
|
|
for rule_id in rule_ids:
|
|
fn = _RULE_BY_ID.get(rule_id)
|
|
if fn is None:
|
|
log.append(f"[WARN] Regla desconocida '{rule_id}' en columna '{col}'")
|
|
continue
|
|
try:
|
|
result[col] = fn(result[col])
|
|
log.append(f"[OK] '{col}' <- {_RULE_NAME_BY_ID.get(rule_id, rule_id)}")
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("Error aplicando regla %s a %s", rule_id, col)
|
|
log.append(f"[ERROR] '{col}' regla '{rule_id}': {exc}")
|
|
|
|
return result, log
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CARGA ROBUSTA DE CSV
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_CANDIDATE_DELIMITERS = ["|", ";", "\t", ",", ":"]
|
|
|
|
|
|
def detect_delimiter(filepath: str) -> str:
|
|
"""Detecta el delimitador del CSV de forma robusta.
|
|
|
|
Estrategia:
|
|
1. Intentar `csv.Sniffer` con un sample grande.
|
|
2. Si falla o el resultado parece incorrecto, contar ocurrencias por línea
|
|
de cada delimitador candidato y elegir el que produce el mayor número
|
|
de columnas estable entre líneas.
|
|
"""
|
|
encodings = ("utf-8", "utf-8-sig", "latin-1", "cp1252")
|
|
sample = ""
|
|
for enc in encodings:
|
|
try:
|
|
with open(filepath, "r", encoding=enc, errors="replace") as f:
|
|
sample = f.read(65536)
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
if not sample:
|
|
return ","
|
|
|
|
try:
|
|
dialect = _csv.Sniffer().sniff(sample, delimiters="".join(_CANDIDATE_DELIMITERS))
|
|
sniffed = dialect.delimiter
|
|
except _csv.Error:
|
|
sniffed = None
|
|
|
|
lines = [ln for ln in sample.splitlines() if ln.strip()]
|
|
if not lines:
|
|
return sniffed or ","
|
|
|
|
# Para cada candidato, calcular el número promedio de columnas y la
|
|
# consistencia (cuántas líneas tienen el mismo conteo dominante).
|
|
scores: dict[str, tuple[int, int]] = {}
|
|
for delim in _CANDIDATE_DELIMITERS:
|
|
counts = [ln.count(delim) for ln in lines[:50]]
|
|
if max(counts) == 0:
|
|
continue
|
|
# Modo (valor más frecuente)
|
|
from collections import Counter
|
|
common = Counter(counts).most_common(1)[0]
|
|
mode_count, mode_freq = common
|
|
if mode_count == 0:
|
|
continue
|
|
scores[delim] = (mode_count, mode_freq)
|
|
|
|
if scores:
|
|
# Elegir el delimitador con mayor número de columnas (mode_count) y,
|
|
# como desempate, mayor consistencia (mode_freq).
|
|
best = max(scores.items(), key=lambda kv: (kv[1][0], kv[1][1]))
|
|
best_delim = best[0]
|
|
# Si el sniffer dio algo razonable y coincide, prevalece; si difiere y
|
|
# nuestro candidato es claramente mejor, usamos el nuestro.
|
|
if sniffed and sniffed in scores:
|
|
sniffed_score = scores[sniffed]
|
|
if sniffed_score[0] >= best[1][0] - 1:
|
|
return sniffed
|
|
return best_delim
|
|
|
|
return sniffed or ","
|
|
|
|
|
|
def load_csv(filepath: str) -> tuple[pd.DataFrame, str]:
|
|
"""Carga un CSV con detección de delimitador y tolerancia a filas malas.
|
|
|
|
Devuelve `(df, delimiter)`. Lanza excepción si todos los intentos fallan.
|
|
"""
|
|
delimiter = detect_delimiter(filepath)
|
|
|
|
encodings_to_try = ["utf-8", "utf-8-sig", "latin-1", "cp1252"]
|
|
last_error: Optional[Exception] = None
|
|
|
|
for enc in encodings_to_try:
|
|
try:
|
|
df = pd.read_csv(
|
|
filepath,
|
|
delimiter=delimiter,
|
|
dtype=str,
|
|
encoding=enc,
|
|
engine="python",
|
|
on_bad_lines="skip",
|
|
)
|
|
return df, delimiter
|
|
except Exception as exc: # noqa: BLE001
|
|
last_error = exc
|
|
continue
|
|
|
|
# Último intento con quoting permisivo y sin separador detectado
|
|
try:
|
|
df = pd.read_csv(
|
|
filepath,
|
|
sep=None,
|
|
dtype=str,
|
|
engine="python",
|
|
on_bad_lines="skip",
|
|
)
|
|
return df, "(auto)"
|
|
except Exception as exc: # noqa: BLE001
|
|
last_error = exc
|
|
|
|
raise RuntimeError(f"No se pudo leer el CSV: {last_error}")
|