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.
@@ -0,0 +1,425 @@
1
+ """
2
+ Módulo para extracción de tablas desde archivos Excel (.xlsx, .xls, .xlsm) y CSV usando xlwings.
3
+ Soporta:
4
+ 1. Reconocimiento por nombres de tablas oficiales de Excel (ListObjects / Named Tables).
5
+ 2. Extracción por celda de inicio donde parte la tabla (ej: celda_inicio="C4" o "B3").
6
+ 3. Extracción por rango exacto (ej: rango="C4:F20").
7
+ 4. Archivos ABIERTOS o CERRADOS en Microsoft Excel.
8
+ 5. Rutas relativas o absolutas.
9
+ """
10
+
11
+ from typing import List, Any, Optional, Union, Dict, Tuple
12
+ from dataclasses import dataclass
13
+ import os
14
+ import re
15
+ import pandas as pd
16
+
17
+ # Intentar importar xlwings
18
+ try:
19
+ import xlwings as xw
20
+ HAS_XLWINGS = True
21
+ except ImportError:
22
+ HAS_XLWINGS = False
23
+
24
+ try:
25
+ import openpyxl
26
+ from openpyxl.utils.cell import coordinate_to_tuple, get_column_letter
27
+ HAS_OPENPYXL = True
28
+ except ImportError:
29
+ HAS_OPENPYXL = False
30
+
31
+
32
+ @dataclass
33
+ class RawExcelTableInfo:
34
+ """Información y contenido crudo de una tabla u hoja detectada en Excel/CSV."""
35
+ table_id: int
36
+ source_file: str
37
+ sheet_name: str
38
+ raw_data: List[List[Any]]
39
+ num_rows: int
40
+ num_cols: int
41
+ table_name: Optional[str] = None
42
+ start_cell: Optional[str] = None
43
+ range_address: Optional[str] = None
44
+
45
+ def get_preview(self, max_rows: int = 5) -> List[List[Any]]:
46
+ """Retorna las primeras filas crudas para inspección visual."""
47
+ return self.raw_data[:max_rows]
48
+
49
+ from .file_utils import resolve_file_path
50
+
51
+
52
+ class ExcelTableExtractor:
53
+ """
54
+ Extractor de tablas para Excel basado en xlwings con soporte para:
55
+ - Tablas con nombre oficial de Excel (ListObjects).
56
+ - Celdas de inicio (ej: C4) y rangos (ej: B3:F15).
57
+ - Archivos abiertos o cerrados.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ file_path: str,
63
+ archivo_abierto: Optional[bool] = None,
64
+ preferir_xlwings: bool = True
65
+ ):
66
+ self.file_path = resolve_file_path(file_path)
67
+ if not os.path.exists(self.file_path):
68
+ raise FileNotFoundError(f"No se encontró el archivo Excel en: {self.file_path}")
69
+
70
+ self.ext = os.path.splitext(self.file_path)[1].lower()
71
+ self.archivo_abierto = archivo_abierto
72
+ self.preferir_xlwings = preferir_xlwings and HAS_XLWINGS
73
+
74
+ def _is_workbook_open_in_excel(self, file_name: str) -> bool:
75
+ """Comprueba si el libro ya está abierto en alguna sesión activa de Excel."""
76
+ if not HAS_XLWINGS:
77
+ return False
78
+ try:
79
+ for book in xw.books:
80
+ if book.name.lower() == file_name.lower() or book.fullname.lower() == self.file_path.lower():
81
+ return True
82
+ except Exception:
83
+ return False
84
+ return False
85
+
86
+ def extract_by_cell_or_range(
87
+ self,
88
+ celda_inicio: Optional[str] = None,
89
+ rango: Optional[str] = None,
90
+ hoja: Optional[Union[str, int]] = None
91
+ ) -> List[List[Any]]:
92
+ """
93
+ Extrae datos a partir de una celda de inicio (ej: 'C4') o un rango específico (ej: 'C4:G20').
94
+ """
95
+ if self.preferir_xlwings:
96
+ try:
97
+ return self._extract_range_xlwings(celda_inicio=celda_inicio, rango=rango, hoja=hoja)
98
+ except Exception:
99
+ return self._extract_range_openpyxl(celda_inicio=celda_inicio, rango=rango, hoja=hoja)
100
+ else:
101
+ return self._extract_range_openpyxl(celda_inicio=celda_inicio, rango=rango, hoja=hoja)
102
+
103
+ def _extract_range_xlwings(
104
+ self,
105
+ celda_inicio: Optional[str] = None,
106
+ rango: Optional[str] = None,
107
+ hoja: Optional[Union[str, int]] = None
108
+ ) -> List[List[Any]]:
109
+ file_name = os.path.basename(self.file_path)
110
+ is_already_open = self._is_workbook_open_in_excel(file_name)
111
+ should_connect = self.archivo_abierto is True or (self.archivo_abierto is None and is_already_open)
112
+
113
+ app = None
114
+ book = None
115
+ needs_close = False
116
+
117
+ try:
118
+ if should_connect:
119
+ try:
120
+ book = xw.books[file_name]
121
+ except Exception:
122
+ book = xw.Book(self.file_path)
123
+ else:
124
+ app = xw.App(visible=False, add_book=False)
125
+ app.display_alerts = False
126
+ app.screen_updating = False
127
+ book = app.books.open(self.file_path, read_only=True)
128
+ needs_close = True
129
+
130
+ # Seleccionar hoja
131
+ if hoja is None:
132
+ sht = book.sheets[0]
133
+ elif isinstance(hoja, int):
134
+ sht = book.sheets[hoja - 1]
135
+ else:
136
+ sht = book.sheets[hoja]
137
+
138
+ # Leer por rango o por celda_inicio
139
+ if rango:
140
+ val = sht.range(rango).value
141
+ elif celda_inicio:
142
+ # Expandir tabla a partir de la celda de inicio
143
+ clean_cell = celda_inicio.replace("$", "").upper()
144
+ val = sht.range(clean_cell).expand('table').value
145
+ else:
146
+ val = sht.used_range.value
147
+
148
+ if val is None:
149
+ return []
150
+ if not isinstance(val, list):
151
+ return [[val]]
152
+ if val and not isinstance(val[0], list):
153
+ return [val]
154
+ return val
155
+
156
+ finally:
157
+ if needs_close:
158
+ if book:
159
+ try: book.close()
160
+ except Exception: pass
161
+ if app:
162
+ try: app.quit()
163
+ except Exception: pass
164
+
165
+ def _extract_range_openpyxl(
166
+ self,
167
+ celda_inicio: Optional[str] = None,
168
+ rango: Optional[str] = None,
169
+ hoja: Optional[Union[str, int]] = None
170
+ ) -> List[List[Any]]:
171
+ """Fallback con openpyxl para extraer por celda de inicio o rango."""
172
+ wb = openpyxl.load_workbook(self.file_path, data_only=True)
173
+ if hoja is None:
174
+ ws = wb.active
175
+ elif isinstance(hoja, int):
176
+ ws = wb.worksheets[hoja - 1]
177
+ else:
178
+ ws = wb[hoja]
179
+
180
+ if rango:
181
+ cells = ws[rango]
182
+ if isinstance(cells, tuple):
183
+ if isinstance(cells[0], tuple):
184
+ return [[c.value for c in row] for row in cells]
185
+ return [[c.value for c in cells]]
186
+ return [[cells.value]]
187
+
188
+ if celda_inicio:
189
+ row_idx, col_idx = coordinate_to_tuple(celda_inicio.replace("$", "").upper())
190
+ # Leer desde row_idx, col_idx hasta el final de la región de datos
191
+ rows = []
192
+ for r in range(row_idx, ws.max_row + 1):
193
+ row_vals = [ws.cell(row=r, column=c).value for c in range(col_idx, ws.max_column + 1)]
194
+ # Detener si la fila está completamente vacía
195
+ if all(v is None for v in row_vals):
196
+ break
197
+ rows.append(row_vals)
198
+
199
+ # Recortar columnas vacías al final
200
+ if rows:
201
+ max_valid_col = 0
202
+ for r in rows:
203
+ for i in reversed(range(len(r))):
204
+ if r[i] is not None:
205
+ max_valid_col = max(max_valid_col, i + 1)
206
+ break
207
+ if max_valid_col > 0:
208
+ rows = [r[:max_valid_col] for r in rows]
209
+ return rows
210
+
211
+ # Toda la hoja
212
+ return [[c.value for c in row] for row in ws.iter_rows()]
213
+
214
+ def extract_all_tables(
215
+ self,
216
+ sheets: Optional[List[Union[str, int]]] = None,
217
+ split_blank_blocks: bool = False
218
+ ) -> List[RawExcelTableInfo]:
219
+ """
220
+ Extrae todas las tablas u hojas del archivo Excel.
221
+ Detecta automáticamente si existen tablas con nombre oficial (ListObjects) en las hojas.
222
+ """
223
+ if self.ext == '.csv':
224
+ df_raw = pd.read_csv(self.file_path, header=None, dtype=object)
225
+ raw_list = df_raw.values.tolist()
226
+ info = RawExcelTableInfo(
227
+ table_id=1,
228
+ source_file=self.file_path,
229
+ sheet_name="CSV",
230
+ table_name="CSV",
231
+ raw_data=raw_list,
232
+ num_rows=len(raw_list),
233
+ num_cols=len(raw_list[0]) if raw_list else 0
234
+ )
235
+ return [info]
236
+
237
+ if self.preferir_xlwings:
238
+ try:
239
+ return self._extract_all_xlwings(sheets=sheets)
240
+ except Exception:
241
+ return self._extract_all_openpyxl(sheets=sheets, split_blank_blocks=split_blank_blocks)
242
+ else:
243
+ return self._extract_all_openpyxl(sheets=sheets, split_blank_blocks=split_blank_blocks)
244
+
245
+ def _extract_all_xlwings(
246
+ self,
247
+ sheets: Optional[List[Union[str, int]]] = None
248
+ ) -> List[RawExcelTableInfo]:
249
+ file_name = os.path.basename(self.file_path)
250
+ is_already_open = self._is_workbook_open_in_excel(file_name)
251
+ should_connect = self.archivo_abierto is True or (self.archivo_abierto is None and is_already_open)
252
+
253
+ tables_found: List[RawExcelTableInfo] = []
254
+ app = None
255
+ book = None
256
+ needs_close = False
257
+
258
+ try:
259
+ if should_connect:
260
+ try:
261
+ book = xw.books[file_name]
262
+ except Exception:
263
+ book = xw.Book(self.file_path)
264
+ else:
265
+ app = xw.App(visible=False, add_book=False)
266
+ app.display_alerts = False
267
+ app.screen_updating = False
268
+ book = app.books.open(self.file_path, read_only=True)
269
+ needs_close = True
270
+
271
+ all_sheet_names = [s.name for s in book.sheets]
272
+ target_sheets = []
273
+ if sheets is None:
274
+ target_sheets = all_sheet_names
275
+ else:
276
+ for s in sheets:
277
+ if isinstance(s, int) and 1 <= s <= len(all_sheet_names):
278
+ target_sheets.append(all_sheet_names[s - 1])
279
+ elif str(s) in all_sheet_names:
280
+ target_sheets.append(str(s))
281
+
282
+ global_id = 1
283
+ for sheet_name in target_sheets:
284
+ sht = book.sheets[sheet_name]
285
+
286
+ # 1. Comprobar si la hoja tiene tablas oficiales de Excel (ListObjects)
287
+ named_tables = list(sht.tables)
288
+ if named_tables:
289
+ for nt in named_tables:
290
+ t_vals = nt.range.value
291
+ if not isinstance(t_vals, list):
292
+ t_vals = [[t_vals]]
293
+ elif t_vals and not isinstance(t_vals[0], list):
294
+ t_vals = [t_vals]
295
+
296
+ addr = nt.range.address
297
+ start_c = addr.split(":")[0].replace("$", "") if ":" in addr else addr.replace("$", "")
298
+
299
+ info = RawExcelTableInfo(
300
+ table_id=global_id,
301
+ source_file=self.file_path,
302
+ sheet_name=sheet_name,
303
+ table_name=nt.name,
304
+ start_cell=start_c,
305
+ range_address=addr,
306
+ raw_data=t_vals,
307
+ num_rows=len(t_vals),
308
+ num_cols=max((len(r) for r in t_vals if isinstance(r, list)), default=0)
309
+ )
310
+ tables_found.append(info)
311
+ global_id += 1
312
+ else:
313
+ # Hoja estándar
314
+ used_range = sht.used_range
315
+ raw_values = used_range.value
316
+ if raw_values is None:
317
+ continue
318
+ if not isinstance(raw_values, list):
319
+ raw_matrix = [[raw_values]]
320
+ elif raw_values and not isinstance(raw_values[0], list):
321
+ raw_matrix = [raw_values]
322
+ else:
323
+ raw_matrix = raw_values
324
+
325
+ addr = used_range.address
326
+ start_c = addr.split(":")[0].replace("$", "") if ":" in addr else "A1"
327
+
328
+ info = RawExcelTableInfo(
329
+ table_id=global_id,
330
+ source_file=self.file_path,
331
+ sheet_name=sheet_name,
332
+ table_name=sheet_name,
333
+ start_cell=start_c,
334
+ range_address=addr,
335
+ raw_data=raw_matrix,
336
+ num_rows=len(raw_matrix),
337
+ num_cols=max((len(r) for r in raw_matrix if isinstance(r, list)), default=0)
338
+ )
339
+ tables_found.append(info)
340
+ global_id += 1
341
+
342
+ return tables_found
343
+
344
+ finally:
345
+ if needs_close:
346
+ if book:
347
+ try: book.close()
348
+ except Exception: pass
349
+ if app:
350
+ try: app.quit()
351
+ except Exception: pass
352
+
353
+ def _extract_all_openpyxl(
354
+ self,
355
+ sheets: Optional[List[Union[str, int]]] = None,
356
+ split_blank_blocks: bool = False
357
+ ) -> List[RawExcelTableInfo]:
358
+ wb = openpyxl.load_workbook(self.file_path, data_only=True)
359
+ all_sheet_names = wb.sheetnames
360
+
361
+ target_sheets = []
362
+ if sheets is None:
363
+ target_sheets = all_sheet_names
364
+ else:
365
+ for s in sheets:
366
+ if isinstance(s, int) and 1 <= s <= len(all_sheet_names):
367
+ target_sheets.append(all_sheet_names[s - 1])
368
+ elif str(s) in all_sheet_names:
369
+ target_sheets.append(str(s))
370
+
371
+ tables_found: List[RawExcelTableInfo] = []
372
+ global_id = 1
373
+
374
+ for sheet_name in target_sheets:
375
+ ws = wb[sheet_name]
376
+
377
+ # Tablas oficiales openpyxl
378
+ if hasattr(ws, 'tables') and ws.tables:
379
+ for t_name, t_obj in ws.tables.items():
380
+ # t_obj puede ser el objeto Table o tener ref
381
+ ref = getattr(t_obj, 'ref', str(t_obj))
382
+ cells = ws[ref]
383
+ if isinstance(cells, tuple):
384
+ if isinstance(cells[0], tuple):
385
+ raw_matrix = [[c.value for c in row] for row in cells]
386
+ else:
387
+ raw_matrix = [[c.value for c in cells]]
388
+ else:
389
+ raw_matrix = [[cells.value]]
390
+
391
+ start_c = ref.split(":")[0] if ":" in ref else ref
392
+
393
+ info = RawExcelTableInfo(
394
+ table_id=global_id,
395
+ source_file=self.file_path,
396
+ sheet_name=sheet_name,
397
+ table_name=t_name,
398
+ start_cell=start_c,
399
+ range_address=ref,
400
+ raw_data=raw_matrix,
401
+ num_rows=len(raw_matrix),
402
+ num_cols=max((len(r) for r in raw_matrix if isinstance(r, list)), default=0)
403
+ )
404
+ tables_found.append(info)
405
+ global_id += 1
406
+ else:
407
+ # Leer hoja completa
408
+ raw_matrix = [[c.value for c in row] for row in ws.iter_rows()]
409
+ if not raw_matrix:
410
+ continue
411
+ info = RawExcelTableInfo(
412
+ table_id=global_id,
413
+ source_file=self.file_path,
414
+ sheet_name=sheet_name,
415
+ table_name=sheet_name,
416
+ start_cell="A1",
417
+ range_address=f"A1:{get_column_letter(ws.max_column)}{ws.max_row}",
418
+ raw_data=raw_matrix,
419
+ num_rows=len(raw_matrix),
420
+ num_cols=max((len(r) for r in raw_matrix if isinstance(r, list)), default=0)
421
+ )
422
+ tables_found.append(info)
423
+ global_id += 1
424
+
425
+ return tables_found
utils/excel_writer.py ADDED
@@ -0,0 +1,188 @@
1
+ """
2
+ Módulo para escribir y actualizar datos en archivos Excel usando xlwings.
3
+ Permite pegar DataFrames directamente en celdas específicas (ej: 'B5') de libros
4
+ abiertos en pantalla (en vivo) o cerrados en segundo plano, sin dañar formatos ni fórmulas existentes.
5
+ """
6
+
7
+ from typing import Optional, Union, Any
8
+ import os
9
+ import pandas as pd
10
+
11
+ try:
12
+ import xlwings as xw
13
+ HAS_XLWINGS = True
14
+ except ImportError:
15
+ HAS_XLWINGS = False
16
+
17
+ try:
18
+ import openpyxl
19
+ from openpyxl.utils.cell import coordinate_to_tuple
20
+ HAS_OPENPYXL = True
21
+ except ImportError:
22
+ HAS_OPENPYXL = False
23
+
24
+ from .file_utils import resolve_file_path
25
+
26
+
27
+ def _is_workbook_open_in_excel(file_path: str, file_name: str) -> bool:
28
+ """Comprueba si el libro ya está abierto en Excel activo."""
29
+ if not HAS_XLWINGS:
30
+ return False
31
+ try:
32
+ for book in xw.books:
33
+ if book.name.lower() == file_name.lower() or book.fullname.lower() == file_path.lower():
34
+ return True
35
+ except Exception:
36
+ return False
37
+ return False
38
+
39
+
40
+ def escribir_en_excel(
41
+ df: pd.DataFrame,
42
+ archivo_excel: str,
43
+ hoja: Union[str, int] = 1,
44
+ celda_inicio: str = "A1",
45
+ incluir_encabezados: bool = True,
46
+ incluir_indice: bool = False,
47
+ guardar: bool = True,
48
+ archivo_abierto: Optional[bool] = None,
49
+ crear_si_no_existe: bool = True
50
+ ) -> str:
51
+ """
52
+ Escribe un DataFrame directamente en una celda específica de un libro de Excel.
53
+
54
+ Parámetros:
55
+ -----------
56
+ df : pd.DataFrame
57
+ El DataFrame con los datos a escribir.
58
+ archivo_excel : str
59
+ Ruta o nombre del archivo Excel.
60
+ hoja : str o int (por defecto 1)
61
+ Nombre o número de la hoja donde se escribirán los datos.
62
+ celda_inicio : str (por defecto 'A1')
63
+ Coordenada de la celda superior izquierda donde comenzará a escribirse (ej: 'B5', 'C4').
64
+ incluir_encabezados : bool (por defecto True)
65
+ Si escribe los nombres de las columnas en la primera fila.
66
+ incluir_indice : bool (por defecto False)
67
+ Si incluye la columna de índice de pandas.
68
+ guardar : bool (por defecto True)
69
+ Si guarda el archivo tras escribir.
70
+ archivo_abierto : bool o None
71
+ - True: Se conecta a la ventana activa de Excel.
72
+ - False: Trabaja en segundo plano cerrado.
73
+ - None: Detecta automáticamente si está abierto o cerrado.
74
+ crear_si_no_existe : bool (por defecto True)
75
+ Crea un nuevo archivo Excel si no existe en la ruta dada.
76
+
77
+ Retorna:
78
+ --------
79
+ str
80
+ Ruta absoluta del archivo modificado.
81
+ """
82
+ try:
83
+ resolved_path = resolve_file_path(archivo_excel)
84
+ except Exception:
85
+ resolved_path = os.path.abspath(archivo_excel)
86
+
87
+ if not os.path.exists(resolved_path):
88
+ if crear_si_no_existe:
89
+ os.makedirs(os.path.dirname(resolved_path), exist_ok=True)
90
+ # Crear libro vacío con openpyxl o pandas
91
+ df_init = pd.DataFrame()
92
+ with pd.ExcelWriter(resolved_path, engine='openpyxl') as writer:
93
+ sheet_title = hoja if isinstance(hoja, str) else "Hoja1"
94
+ df_init.to_excel(writer, sheet_name=sheet_title)
95
+ else:
96
+ raise FileNotFoundError(f"No existe el archivo Excel: {resolved_path}")
97
+
98
+ file_name = os.path.basename(resolved_path)
99
+
100
+ # Intentar con xlwings si está disponible
101
+ if HAS_XLWINGS:
102
+ try:
103
+ is_open = _is_workbook_open_in_excel(resolved_path, file_name)
104
+ should_connect = archivo_abierto is True or (archivo_abierto is None and is_open)
105
+
106
+ app = None
107
+ book = None
108
+ needs_close = False
109
+
110
+ try:
111
+ if should_connect:
112
+ try:
113
+ book = xw.books[file_name]
114
+ except Exception:
115
+ book = xw.Book(resolved_path)
116
+ else:
117
+ app = xw.App(visible=False, add_book=False)
118
+ app.display_alerts = False
119
+ app.screen_updating = False
120
+ book = app.books.open(resolved_path)
121
+ needs_close = True
122
+
123
+ # Seleccionar o crear hoja
124
+ sheet_names = [s.name for s in book.sheets]
125
+ if isinstance(hoja, int):
126
+ if 1 <= hoja <= len(book.sheets):
127
+ sht = book.sheets[hoja - 1]
128
+ else:
129
+ sht = book.sheets.add(f"Hoja{hoja}")
130
+ else:
131
+ if str(hoja) in sheet_names:
132
+ sht = book.sheets[str(hoja)]
133
+ else:
134
+ sht = book.sheets.add(str(hoja))
135
+
136
+ # Escribir DataFrame en la celda indicada
137
+ clean_cell = celda_inicio.replace("$", "").upper()
138
+ sht.range(clean_cell).options(
139
+ index=incluir_indice,
140
+ header=incluir_encabezados
141
+ ).value = df
142
+
143
+ if guardar:
144
+ book.save()
145
+
146
+ return resolved_path
147
+
148
+ finally:
149
+ if needs_close:
150
+ if book:
151
+ try: book.close()
152
+ except Exception: pass
153
+ if app:
154
+ try: app.quit()
155
+ except Exception: pass
156
+
157
+ except Exception as e:
158
+ # Fallback a openpyxl si ocurre algún error COM
159
+ pass
160
+
161
+ # Fallback con openpyxl si xlwings no pudo ejecutarse
162
+ if HAS_OPENPYXL:
163
+ wb = openpyxl.load_workbook(resolved_path)
164
+ sheet_title = hoja if isinstance(hoja, str) else (f"Hoja{hoja}" if isinstance(hoja, int) and hoja > len(wb.sheetnames) else wb.sheetnames[hoja-1])
165
+ if sheet_title in wb.sheetnames:
166
+ ws = wb[sheet_title]
167
+ else:
168
+ ws = wb.create_sheet(title=sheet_title)
169
+
170
+ start_row, start_col = coordinate_to_tuple(celda_inicio.replace("$", "").upper())
171
+
172
+ current_r = start_row
173
+ if incluir_encabezados:
174
+ for c_idx, col_name in enumerate(df.columns, start=start_col):
175
+ ws.cell(row=current_r, column=c_idx, value=str(col_name))
176
+ current_r += 1
177
+
178
+ for _, row in df.iterrows():
179
+ for c_idx, val in enumerate(row.values, start=start_col):
180
+ cell_val = None if pd.isna(val) else val
181
+ ws.cell(row=current_r, column=c_idx, value=cell_val)
182
+ current_r += 1
183
+
184
+ if guardar:
185
+ wb.save(resolved_path)
186
+ return resolved_path
187
+
188
+ raise RuntimeError("Se requiere xlwings u openpyxl para escribir en Excel.")