feat: Enhance CSV cleaning service with new endpoints for CSV analysis and preview functionality. Introduce support for job management linked to extraction batches, and update the cleaning rules structure. Add new utility functions for handling CSV data and improve documentation for clarity on usage and available endpoints.
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
"""Detección y normalización de fechas."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date
|
||||
from typing import Any, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .nullish import _is_nullish_scalar
|
||||
|
||||
_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*$"
|
||||
)
|
||||
|
||||
_MONTH_NAME_TO_NUM = {
|
||||
"enero": 1,
|
||||
"ene": 1,
|
||||
"febrero": 2,
|
||||
"feb": 2,
|
||||
"marzo": 3,
|
||||
"mar": 3,
|
||||
"abril": 4,
|
||||
"abr": 4,
|
||||
"mayo": 5,
|
||||
"may": 5,
|
||||
"junio": 6,
|
||||
"jun": 6,
|
||||
"julio": 7,
|
||||
"jul": 7,
|
||||
"agosto": 8,
|
||||
"ago": 8,
|
||||
"septiembre": 9,
|
||||
"setiembre": 9,
|
||||
"sep": 9,
|
||||
"sept": 9,
|
||||
"octubre": 10,
|
||||
"oct": 10,
|
||||
"noviembre": 11,
|
||||
"nov": 11,
|
||||
"diciembre": 12,
|
||||
"dic": 12,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
# Quitar separadores habituales entre fecha y hora (p. ej. "12/27/24, 12:00 AM").
|
||||
date_part = s[: m.start()].rstrip(" \t,;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)
|
||||
|
||||
|
||||
def _normalize_date_text(value: Any) -> str:
|
||||
text = unicodedata.normalize("NFKD", str(value or ""))
|
||||
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||
return text.lower()
|
||||
|
||||
|
||||
def _valid_date_tuple(d: int, m: int, y: int) -> Optional[tuple[int, int, int]]:
|
||||
if y < 100:
|
||||
y = 2000 + y if y < 50 else 1900 + y
|
||||
if not (1000 <= y <= 9999):
|
||||
return None
|
||||
try:
|
||||
date(y, m, d)
|
||||
except ValueError:
|
||||
return None
|
||||
return (d, m, y)
|
||||
|
||||
|
||||
def _split_glued_year_suffix_hhmm(digits: str) -> Optional[tuple[int, int, int]]:
|
||||
"""Detecta `YYYY` + `HHMM` en una sola tira de 8 cifras (p. ej. `20241344`).
|
||||
|
||||
Tras quitar separadores puede quedar pegado al mes (p. ej. `15may20241344`).
|
||||
Devuelve (año, hora, minuto) si la parte horaria es válida; si no, None.
|
||||
"""
|
||||
if len(digits) != 8 or not digits.isdigit():
|
||||
return None
|
||||
y = int(digits[:4])
|
||||
hh = int(digits[4:6])
|
||||
mm = int(digits[6:8])
|
||||
if not (1000 <= y <= 9999 and 0 <= hh <= 23 and 0 <= mm <= 59):
|
||||
return None
|
||||
return (y, hh, mm)
|
||||
|
||||
|
||||
def _parse_text_month_candidate(
|
||||
first: tuple[int, str],
|
||||
month: int,
|
||||
second: tuple[int, str],
|
||||
*,
|
||||
layout: Literal["around", "after", "before"],
|
||||
) -> Optional[tuple[int, int, int]]:
|
||||
a, astr = first
|
||||
b, bstr = second
|
||||
if layout == "around":
|
||||
if len(astr) == 4:
|
||||
return _valid_date_tuple(b, month, a)
|
||||
if len(bstr) == 4:
|
||||
return _valid_date_tuple(a, month, b)
|
||||
return _valid_date_tuple(a, month, b)
|
||||
if layout == "after":
|
||||
if len(astr) == 4:
|
||||
return _valid_date_tuple(b, month, a)
|
||||
return _valid_date_tuple(a, month, b)
|
||||
if len(astr) == 4:
|
||||
return _valid_date_tuple(b, month, a)
|
||||
return _valid_date_tuple(a, month, b)
|
||||
|
||||
|
||||
def _parse_date_with_text_month(raw: Any) -> Optional[tuple[int, int, int]]:
|
||||
if _is_nullish_scalar(raw):
|
||||
return None
|
||||
date_part, _ = _split_date_time(str(raw))
|
||||
normalized = _normalize_date_text(date_part or raw)
|
||||
# `\d{8}` antes que `\d{1,4}` para no partir año+hora pegados tras strip agresivo.
|
||||
tokens = re.findall(r"[a-z]+|\d{8}|\d{1,4}", normalized)
|
||||
if not tokens:
|
||||
return None
|
||||
|
||||
for i, token in enumerate(tokens):
|
||||
month = _MONTH_NAME_TO_NUM.get(token)
|
||||
if month is None:
|
||||
continue
|
||||
nums_before = [
|
||||
(int(t), t) for t in tokens[:i] if t.isdigit()
|
||||
]
|
||||
nums_after = [
|
||||
(int(t), t) for t in tokens[i + 1 :] if t.isdigit()
|
||||
]
|
||||
candidates: list[Optional[tuple[int, int, int]]] = []
|
||||
if nums_before and len(nums_after) == 1:
|
||||
glued = _split_glued_year_suffix_hhmm(nums_after[0][1])
|
||||
if glued is not None:
|
||||
y, _hh, _mm = glued
|
||||
candidates.append(
|
||||
_parse_text_month_candidate(
|
||||
nums_before[-1], month, (y, str(y)), layout="around"
|
||||
)
|
||||
)
|
||||
if nums_before and nums_after:
|
||||
candidates.append(
|
||||
_parse_text_month_candidate(
|
||||
nums_before[-1], month, nums_after[0], layout="around"
|
||||
)
|
||||
)
|
||||
if len(nums_after) >= 2:
|
||||
candidates.append(
|
||||
_parse_text_month_candidate(
|
||||
nums_after[0], month, nums_after[1], layout="after"
|
||||
)
|
||||
)
|
||||
if len(nums_before) >= 2:
|
||||
candidates.append(
|
||||
_parse_text_month_candidate(
|
||||
nums_before[-2], month, nums_before[-1], layout="before"
|
||||
)
|
||||
)
|
||||
for parsed in candidates:
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _detect_compact_date_formats(
|
||||
series: pd.Series,
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
cser = series.map(_normalize_messy_date_to_compact)
|
||||
c8s = pd.Series(
|
||||
[x for x in cser.tolist() if isinstance(x, str) and len(x) == 8]
|
||||
)
|
||||
c6s = pd.Series(
|
||||
[x for x in cser.tolist() if isinstance(x, str) and len(x) == 6]
|
||||
)
|
||||
fmt8 = detect_date_format(c8s) if not c8s.empty else None
|
||||
fmt6 = detect_date_format(c6s) if not c6s.empty else None
|
||||
return fmt8, fmt6
|
||||
|
||||
|
||||
def _parse_any_date_to_tuple(
|
||||
raw: Any,
|
||||
*,
|
||||
sep_fmt: Optional[str],
|
||||
compact8_fmt: Optional[str],
|
||||
compact6_fmt: Optional[str],
|
||||
) -> Optional[tuple[int, int, int]]:
|
||||
parsed_text = _parse_date_with_text_month(raw)
|
||||
if parsed_text is not None:
|
||||
return parsed_text
|
||||
|
||||
if _is_nullish_scalar(raw):
|
||||
return None
|
||||
|
||||
raw_text = str(raw)
|
||||
date_part, _ = _split_date_time(raw_text)
|
||||
target = date_part if date_part else raw_text
|
||||
if sep_fmt:
|
||||
parsed = _parse_date_with_format(target, sep_fmt)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
for fallback_fmt in ("DD/MM/YYYY", "MM/DD/YYYY", "YYYY/MM/DD"):
|
||||
if fallback_fmt == sep_fmt:
|
||||
continue
|
||||
parsed = _parse_date_with_format(target, fallback_fmt)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
|
||||
compact = _normalize_messy_date_to_compact(raw)
|
||||
if not isinstance(compact, str):
|
||||
return None
|
||||
if len(compact) == 8:
|
||||
for fmt in (compact8_fmt, "DDMMYYYY", "YYYYMMDD"):
|
||||
if not fmt:
|
||||
continue
|
||||
parsed = _parse_date_with_format(compact, fmt)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
if len(compact) == 6:
|
||||
parsed = _parse_date_with_format(compact, compact6_fmt or "DDMMYY")
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_messy_date_to_compact(raw: Any) -> Optional[str]:
|
||||
"""Quita ruido (* + _ / espacios, etc.); deja 6 o 8 dígitos o None.
|
||||
|
||||
Soporta separadores entremezclados; hora al final vía `_split_date_time`.
|
||||
Con 9+ dígitos, heurística: tomar 8 iniciales (p. ej. 9.º = 0 colado) y
|
||||
validar luego con `_parse_date_with_format`; con más de 8 sobrantes,
|
||||
8 iniciales.
|
||||
"""
|
||||
if _is_nullish_scalar(raw):
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if s.lower() in ("nan", "none", "nat", "<na>"):
|
||||
return None
|
||||
date_part, _ = _split_date_time(s)
|
||||
body = date_part if date_part else s
|
||||
d = re.sub(r"[^\d]", "", body)
|
||||
if not d:
|
||||
return None
|
||||
n = len(d)
|
||||
if n in (6, 8):
|
||||
return d
|
||||
if n < 6 or n == 7:
|
||||
return None
|
||||
# 9+ dígitos: 8 iniciales (p. ej. 9.º 0 sobrante, o ruido al final)
|
||||
return d[:8]
|
||||
|
||||
Reference in New Issue
Block a user