Files
IPSExtractor/application/csv_cleaning.py
T

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}")