tablas-python 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.
utils/table_cleaner.py ADDED
@@ -0,0 +1,246 @@
1
+ """
2
+ Módulo para limpiar y estructurar datos tabulares crudos en DataFrames de pandas.
3
+ Permite descartar filas de 'basura' o metadatos superiores indicando la fila exacta
4
+ del encabezado (por ejemplo, fila 4), eliminar filas/columnas vacías y normalizar nombres.
5
+ """
6
+
7
+ from typing import List, Any, Optional, Union
8
+ import pandas as pd
9
+ import numpy as np
10
+
11
+
12
+ class TableCleaner:
13
+ """
14
+ Clase utilitaria para transformar matrices de datos crudos (listas de listas o DataFrames sucios)
15
+ en DataFrames limpios y listos para análisis.
16
+ """
17
+
18
+ @staticmethod
19
+ def _normalize_cell(val: Any) -> Any:
20
+ """Limpia un valor individual: elimina saltos de línea molestos y espacios extra."""
21
+ if val is None:
22
+ return np.nan
23
+ if isinstance(val, str):
24
+ val_str = val.strip().replace("\r\n", " ").replace("\n", " ")
25
+ # Reducir múltiples espacios consecutivos
26
+ val_str = " ".join(val_str.split())
27
+ return val_str if val_str != "" else np.nan
28
+ return val
29
+
30
+ @classmethod
31
+ def _matrix_to_dataframe(cls, raw_data: Union[List[List[Any]], pd.DataFrame]) -> pd.DataFrame:
32
+ """Convierte una matriz o DataFrame existente en un DataFrame estandarizado."""
33
+ if isinstance(raw_data, pd.DataFrame):
34
+ df = raw_data.copy()
35
+ elif isinstance(raw_data, list):
36
+ if not raw_data:
37
+ return pd.DataFrame()
38
+ # Asegurar longitud uniforme en todas las filas
39
+ max_cols = max((len(r) for r in raw_data if isinstance(r, (list, tuple))), default=0)
40
+ if max_cols == 0:
41
+ return pd.DataFrame()
42
+
43
+ padded_rows = []
44
+ for row in raw_data:
45
+ if isinstance(row, (list, tuple)):
46
+ r_list = list(row)
47
+ if len(r_list) < max_cols:
48
+ r_list.extend([None] * (max_cols - len(r_list)))
49
+ padded_rows.append(r_list[:max_cols])
50
+ else:
51
+ padded_rows.append([row] + [None] * (max_cols - 1))
52
+ df = pd.DataFrame(padded_rows)
53
+ else:
54
+ raise TypeError(f"Tipo de datos no soportado: {type(raw_data)}. Debe ser list o pd.DataFrame.")
55
+
56
+ # Limpieza básica celda por celda
57
+ return df.map(cls._normalize_cell)
58
+
59
+ @classmethod
60
+ def auto_detect_header_row(cls, df: pd.DataFrame, max_search_rows: int = 10) -> int:
61
+ """
62
+ Heurística para detectar automáticamente qué fila contiene los encabezados.
63
+ Busca la primera fila con alta densidad de texto y pocos valores nulos.
64
+ Retorna el número de fila basado en 1 (1-indexed).
65
+ """
66
+ best_row = 1
67
+ best_score = -1.0
68
+
69
+ limit = min(len(df), max_search_rows)
70
+ for idx in range(limit):
71
+ row_vals = df.iloc[idx].dropna().tolist()
72
+ if not row_vals:
73
+ continue
74
+
75
+ # Cantidad de celdas no nulas
76
+ non_null_ratio = len(row_vals) / max(len(df.columns), 1)
77
+ # Celdas que son strings y tienen longitud representativa
78
+ str_count = sum(1 for v in row_vals if isinstance(v, str) and len(v.strip()) > 0 and not v.strip().replace('.', '', 1).isdigit())
79
+ str_ratio = str_count / max(len(row_vals), 1)
80
+
81
+ # Puntuación combinada
82
+ score = (non_null_ratio * 0.6) + (str_ratio * 0.4)
83
+ if score > best_score:
84
+ best_score = score
85
+ best_row = idx + 1 # 1-indexed
86
+
87
+ return best_row
88
+
89
+ @classmethod
90
+ def clean(
91
+ cls,
92
+ raw_data: Union[List[List[Any]], pd.DataFrame],
93
+ header_row: Optional[Union[int, List[int], str]] = 1,
94
+ skip_footer: int = 0,
95
+ drop_empty_rows: bool = True,
96
+ drop_empty_cols: bool = True,
97
+ auto_clean_types: bool = True,
98
+ ) -> pd.DataFrame:
99
+ """
100
+ Limpia y estructura una tabla cruda.
101
+
102
+ Parámetros:
103
+ -----------
104
+ raw_data : list of lists o pd.DataFrame
105
+ Los datos crudos extraídos del PDF o Excel.
106
+ header_row : int, list of int, 'auto' o None (por defecto 1)
107
+ - int: Fila (1-indexed) que contiene los nombres de columnas. Todo lo que esté
108
+ arriba de esta fila se descarta como metadatos/basura.
109
+ - list[int]: Por ejemplo [3, 4] si los encabezados ocupan múltiples filas continuas.
110
+ - 'auto': Detecta automáticamente la fila más probable de encabezado.
111
+ - None / 0: No usa encabezado, asigna nombres genéricos (Col_1, Col_2, ...).
112
+ skip_footer : int (por defecto 0)
113
+ Cantidad de filas finales a descartar (por ejemplo notas al pie, totales agregados, etc.).
114
+ drop_empty_rows : bool (por defecto True)
115
+ Si elimina filas que estén completamente vacías.
116
+ drop_empty_cols : bool (por defecto True)
117
+ Si elimina columnas que estén completamente vacías.
118
+ auto_clean_types : bool (por defecto True)
119
+ Intenta convertir números y formatos evidentes.
120
+
121
+ Retorna:
122
+ --------
123
+ pd.DataFrame
124
+ DataFrame limpio listo para ser utilizado en Python.
125
+ """
126
+ df_raw = cls._matrix_to_dataframe(raw_data)
127
+ if df_raw.empty:
128
+ return pd.DataFrame()
129
+
130
+ # Si se solicita detección automática
131
+ if header_row == 'auto':
132
+ header_row = cls.auto_detect_header_row(df_raw)
133
+
134
+ # Tratar footer si aplica
135
+ if skip_footer > 0 and len(df_raw) > skip_footer:
136
+ df_raw = df_raw.iloc[:-skip_footer]
137
+
138
+ if header_row is None or header_row == 0:
139
+ # Sin fila de encabezado: las columnas serán numéricas o genéricas
140
+ clean_df = df_raw.copy()
141
+ clean_df.columns = [f"Col_{i+1}" for i in range(len(clean_df.columns))]
142
+ elif isinstance(header_row, (list, tuple)):
143
+ # Encabezado multi-fila (e.g. [3, 4])
144
+ # Convertir a 0-indexed
145
+ h_indices = [h - 1 for h in header_row if 1 <= h <= len(df_raw)]
146
+ if not h_indices:
147
+ clean_df = df_raw.copy()
148
+ clean_df.columns = [f"Col_{i+1}" for i in range(len(clean_df.columns))]
149
+ else:
150
+ header_parts = []
151
+ for h_idx in h_indices:
152
+ header_parts.append(df_raw.iloc[h_idx].fillna("").astype(str).tolist())
153
+
154
+ # Combinar partes del encabezado
155
+ combined_cols = []
156
+ for col_idx in range(len(df_raw.columns)):
157
+ parts = [header_parts[row_i][col_idx].strip() for row_i in range(len(header_parts))]
158
+ parts = [p for p in parts if p]
159
+ col_name = " _ ".join(parts) if parts else f"Col_{col_idx+1}"
160
+ combined_cols.append(col_name)
161
+
162
+ last_header_idx = max(h_indices)
163
+ clean_df = df_raw.iloc[last_header_idx + 1:].copy()
164
+ clean_df.columns = combined_cols
165
+ else:
166
+ # header_row es un entero (1-indexed)
167
+ h_idx = int(header_row) - 1
168
+ if h_idx < 0 or h_idx >= len(df_raw):
169
+ raise ValueError(
170
+ f"La fila de encabezado {header_row} está fuera de rango. La tabla tiene {len(df_raw)} filas."
171
+ )
172
+
173
+ raw_headers = df_raw.iloc[h_idx].tolist()
174
+ # Las filas de datos empiezan después de header_row
175
+ clean_df = df_raw.iloc[h_idx + 1:].copy()
176
+
177
+ # Formatear nombres de columnas
178
+ cols = []
179
+ for i, h in enumerate(raw_headers):
180
+ if pd.isna(h) or str(h).strip() == "":
181
+ cols.append(f"Col_{i+1}")
182
+ else:
183
+ cols.append(str(h).strip())
184
+ clean_df.columns = cols
185
+
186
+ # Resolver nombres de columnas duplicados añadiendo sufijo
187
+ seen_cols = {}
188
+ unique_cols = []
189
+ for col in clean_df.columns:
190
+ if col in seen_cols:
191
+ seen_cols[col] += 1
192
+ unique_cols.append(f"{col}_{seen_cols[col]}")
193
+ else:
194
+ seen_cols[col] = 0
195
+ unique_cols.append(col)
196
+ clean_df.columns = unique_cols
197
+
198
+ # Eliminar filas completamente vacías
199
+ if drop_empty_rows:
200
+ clean_df = clean_df.dropna(how='all')
201
+
202
+ # Eliminar columnas completamente vacías
203
+ if drop_empty_cols:
204
+ clean_df = clean_df.dropna(axis=1, how='all')
205
+
206
+ # Reiniciar índice
207
+ clean_df = clean_df.reset_index(drop=True)
208
+
209
+ # Conversión automática de tipos numéricos si aplica
210
+ if auto_clean_types:
211
+ clean_df = cls._try_infer_types(clean_df)
212
+
213
+ return clean_df
214
+
215
+ @classmethod
216
+ def _try_infer_types(cls, df: pd.DataFrame) -> pd.DataFrame:
217
+ """Intenta convertir columnas numéricas limpiando signos de moneda o separadores."""
218
+ df_out = df.copy()
219
+ for col in df_out.columns:
220
+ series = df_out[col]
221
+ if series.dtype == object:
222
+ # Probar si es convertible a numérico tras remover formato común
223
+ try:
224
+ # Limpiar comas/puntos comunes en español/inglés si es puro número
225
+ cleaned_series = series.astype(str).str.replace('$', '', regex=False).str.replace('€', '', regex=False).str.strip()
226
+ # Si tiene formato 1.000,00 cambiar por 1000.00
227
+ # o formato 1,000.00
228
+ sample_non_null = cleaned_series.dropna()
229
+ if not sample_non_null.empty:
230
+ # Intentar conversión directa
231
+ converted = pd.to_numeric(cleaned_series.str.replace(',', ''), errors='coerce')
232
+ if converted.notna().sum() > len(sample_non_null) * 0.7:
233
+ df_out[col] = converted
234
+ except Exception:
235
+ pass
236
+ return df_out
237
+
238
+
239
+ def clean_raw_table(
240
+ raw_data: Union[List[List[Any]], pd.DataFrame],
241
+ header_row: Optional[Union[int, List[int], str]] = 1,
242
+ skip_footer: int = 0,
243
+ **kwargs
244
+ ) -> pd.DataFrame:
245
+ """Función de acceso directo para TableCleaner.clean()"""
246
+ return TableCleaner.clean(raw_data, header_row=header_row, skip_footer=skip_footer, **kwargs)
utils/validator.py ADDED
@@ -0,0 +1,155 @@
1
+ """
2
+ Módulo para validación y auditoría de calidad de datos en DataFrames.
3
+ Permite verificar esquemas requeridos, detectar valores nulos, registros duplicados
4
+ y generar reportes de integridad antes de procesar o exportar.
5
+ """
6
+
7
+ from typing import List, Dict, Any, Optional, Union
8
+ import pandas as pd
9
+ import numpy as np
10
+
11
+
12
+ class DataValidator:
13
+ """
14
+ Validador de calidad e integridad de datos para DataFrames.
15
+ """
16
+
17
+ @staticmethod
18
+ def validar(
19
+ df: pd.DataFrame,
20
+ columnas_requeridas: Optional[List[str]] = None,
21
+ no_nulos: Optional[List[str]] = None,
22
+ tipos_esperados: Optional[Dict[str, str]] = None,
23
+ min_filas: int = 1
24
+ ) -> Dict[str, Any]:
25
+ """
26
+ Valida que un DataFrame cumpla con una serie de reglas de negocio.
27
+
28
+ Parámetros:
29
+ -----------
30
+ df : pd.DataFrame
31
+ El DataFrame a validar.
32
+ columnas_requeridas : list of str, opcional
33
+ Lista de nombres de columnas que DEBEN existir.
34
+ no_nulos : list of str, opcional
35
+ Lista de columnas que no pueden tener valores vacíos/nulos.
36
+ tipos_esperados : dict, opcional
37
+ Diccionario {'columna': 'numeric'|'string'|'datetime'} para verificar tipos.
38
+ min_filas : int (por defecto 1)
39
+ Cantidad mínima de filas esperadas.
40
+
41
+ Retorna:
42
+ --------
43
+ dict con:
44
+ - 'es_valido': bool (True si pasó todas las pruebas)
45
+ - 'errores': list[str] (detalles de cada fallo)
46
+ - 'alertas': list[str] (advertencias menores)
47
+ """
48
+ errores = []
49
+ alertas = []
50
+
51
+ if df is None:
52
+ return {"es_valido": False, "errores": ["El DataFrame es None."], "alertas": []}
53
+
54
+ # 1. Cantidad de filas
55
+ if len(df) < min_filas:
56
+ errores.append(f"El DataFrame tiene {len(df)} filas (se esperaban al menos {min_filas}).")
57
+
58
+ # 2. Columnas requeridas
59
+ if columnas_requeridas:
60
+ columnas_actuales = set(df.columns)
61
+ faltantes = [col for col in columnas_requeridas if col not in columnas_actuales]
62
+ if faltantes:
63
+ errores.append(f"Faltan columnas requeridas: {faltantes}")
64
+
65
+ # 3. Columnas sin nulos
66
+ if no_nulos:
67
+ for col in no_nulos:
68
+ if col in df.columns:
69
+ n_nulos = df[col].isna().sum()
70
+ if n_nulos > 0:
71
+ errores.append(f"La columna '{col}' tiene {n_nulos} valores nulos/vacíos.")
72
+
73
+ # 4. Tipos de datos esperados
74
+ if tipos_esperados:
75
+ for col, tipo in tipos_esperados.items():
76
+ if col in df.columns:
77
+ if tipo == 'numeric' and not pd.api.types.is_numeric_dtype(df[col]):
78
+ errores.append(f"La columna '{col}' no es de tipo numérico (tipo actual: {df[col].dtype}).")
79
+ elif tipo == 'datetime' and not pd.api.types.is_datetime64_any_dtype(df[col]):
80
+ errores.append(f"La columna '{col}' no es de tipo fecha/datetime.")
81
+
82
+ es_valido = len(errores) == 0
83
+ return {
84
+ "es_valido": es_valido,
85
+ "errores": errores,
86
+ "alertas": alertas,
87
+ "total_filas": len(df),
88
+ "total_columnas": len(df.columns)
89
+ }
90
+
91
+ @staticmethod
92
+ def reporte_calidad(df: pd.DataFrame) -> pd.DataFrame:
93
+ """
94
+ Genera una tabla de diagnóstico de calidad de datos con conteo de nulos,
95
+ porcentaje de completitud, valores únicos y tipos.
96
+ """
97
+ if df.empty:
98
+ return pd.DataFrame(columns=["Columna", "Tipo", "No_Nulos", "Nulos", "% Nulos", "Valores_Unicos", "Muestra"])
99
+
100
+ total_filas = len(df)
101
+ reporte = []
102
+
103
+ for col in df.columns:
104
+ serie = df[col]
105
+ n_nulos = serie.isna().sum()
106
+ pct_nulos = round((n_nulos / total_filas) * 100, 1)
107
+ n_unicos = serie.nunique(dropna=True)
108
+ tipo = str(serie.dtype)
109
+
110
+ # Muestra del primer valor no nulo
111
+ muestra_val = serie.dropna().iloc[0] if not serie.dropna().empty else None
112
+
113
+ reporte.append({
114
+ "Columna": col,
115
+ "Tipo": tipo,
116
+ "No_Nulos": total_filas - n_nulos,
117
+ "Nulos": n_nulos,
118
+ "% Nulos": f"{pct_nulos}%",
119
+ "Valores_Unicos": n_unicos,
120
+ "Muestra": str(muestra_val)[:30] if muestra_val is not None else ""
121
+ })
122
+
123
+ return pd.DataFrame(reporte)
124
+
125
+ @staticmethod
126
+ def detectar_duplicados(df: pd.DataFrame, columnas_clave: Optional[List[str]] = None) -> pd.DataFrame:
127
+ """
128
+ Extrae las filas duplicadas del DataFrame para auditoría.
129
+ """
130
+ if columnas_clave:
131
+ duplicados = df[df.duplicated(subset=columnas_clave, keep=False)]
132
+ else:
133
+ duplicados = df[df.duplicated(keep=False)]
134
+ return duplicados.sort_values(by=columnas_clave) if columnas_clave else duplicados
135
+
136
+
137
+ def validar_dataframe(
138
+ df: pd.DataFrame,
139
+ columnas_requeridas: Optional[List[str]] = None,
140
+ no_nulos: Optional[List[str]] = None,
141
+ tipos_esperados: Optional[Dict[str, str]] = None,
142
+ min_filas: int = 1
143
+ ) -> Dict[str, Any]:
144
+ """Acceso directo a DataValidator.validar()"""
145
+ return DataValidator.validar(df, columnas_requeridas, no_nulos, tipos_esperados, min_filas)
146
+
147
+
148
+ def reporte_calidad(df: pd.DataFrame) -> pd.DataFrame:
149
+ """Acceso directo a DataValidator.reporte_calidad()"""
150
+ return DataValidator.reporte_calidad(df)
151
+
152
+
153
+ def detectar_duplicados(df: pd.DataFrame, columnas_clave: Optional[List[str]] = None) -> pd.DataFrame:
154
+ """Acceso directo a DataValidator.detectar_duplicados()"""
155
+ return DataValidator.detectar_duplicados(df, columnas_clave)