cuando termines y verifica que esté todo subido

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 01:33:35 +02:00
parent e1e9bb7499
commit a90b7443e4
20 changed files with 1855 additions and 2 deletions
+172
View File
@@ -0,0 +1,172 @@
"""Escribe un archivo Excel (.xlsx) multi-hoja desde datos en memoria con openpyxl.
Funcion impura: crea (o sobrescribe) un libro Excel completo a partir de un dict
{nombre_hoja: datos}. Pensada para volcar datasets de un proceso (scraping,
queries, reports) a un .xlsx limpio de una sola pasada, sin preservar estado
previo del archivo. Para actualizar UNA hoja de un libro existente sin tocar las
demas, usar `upsert_xlsx_sheet` en su lugar.
"""
import os
def write_xlsx_sheets(
out_path: str,
sheets: dict,
header_bold: bool = True,
autofit: bool = True,
freeze_header: bool = True,
) -> str:
"""Escribe un .xlsx multi-hoja desde datos en memoria.
Args:
out_path: Ruta del archivo .xlsx a escribir. Se crean los directorios
padre si faltan. Si el archivo ya existe se sobrescribe por completo.
sheets: Dict {nombre_hoja: datos}, una hoja por key en orden de
insercion. Cada valor admite dos formas:
- list[list]: la primera fila son los headers, el resto son filas
de datos.
- dict {"headers": [...], "rows": [[...], ...]}: forma explicita
separando cabeceras de filas.
Un dict sin la clave "rows" se trata como filas vacias; un dict sin
"headers" produce una hoja sin fila de cabecera.
header_bold: Si True (default) la primera fila (cabecera) se escribe en
negrita.
autofit: Si True (default) ajusta el ancho de cada columna a la longitud
maxima del contenido de esa columna (cap a 60 caracteres).
freeze_header: Si True (default) congela la fila de cabecera
(freeze_panes="A2") en cada hoja que tenga cabecera.
Returns:
La ruta absoluta del archivo .xlsx escrito.
Raises:
ValueError: si `sheets` esta vacio o si `out_path` esta vacio.
ImportError: si openpyxl no esta instalado en el entorno.
"""
if not out_path:
raise ValueError("out_path no puede estar vacio")
if not sheets:
raise ValueError("sheets no puede estar vacio: se necesita al menos una hoja")
try:
from openpyxl import Workbook
from openpyxl.styles import Font
from openpyxl.utils import get_column_letter
except ImportError as exc: # pragma: no cover - dependencia del entorno
raise ImportError(
"openpyxl es requerido para write_xlsx_sheets. "
"Instalar con: cd python && uv add openpyxl"
) from exc
abs_path = os.path.abspath(out_path)
parent = os.path.dirname(abs_path)
if parent:
os.makedirs(parent, exist_ok=True)
wb = Workbook()
# El Workbook nace con una hoja por defecto; la eliminamos para controlar el
# orden y los nombres exactamente desde `sheets`.
default_ws = wb.active
wb.remove(default_ws)
bold = Font(bold=True)
max_width = 60
for raw_name, data in sheets.items():
# openpyxl limita el nombre de hoja a 31 chars y prohibe ciertos caracteres.
name = str(raw_name)[:31] if raw_name else "Sheet"
ws = wb.create_sheet(title=name)
headers, rows = _normalize_sheet(data)
all_rows = []
if headers is not None:
all_rows.append(headers)
all_rows.extend(rows)
# Ancho aproximado por columna basado en el contenido de TODAS las filas.
col_widths = {}
for r_idx, row in enumerate(all_rows, start=1):
for c_idx, value in enumerate(row, start=1):
cell = ws.cell(row=r_idx, column=c_idx, value=_coerce(value))
if header_bold and headers is not None and r_idx == 1:
cell.font = bold
if autofit:
length = len(_display(value))
if length > col_widths.get(c_idx, 0):
col_widths[c_idx] = length
if autofit:
for c_idx, width in col_widths.items():
# +2 de holgura para que el contenido no quede pegado al borde.
ws.column_dimensions[get_column_letter(c_idx)].width = min(
width + 2, max_width
)
if freeze_header and headers is not None:
ws.freeze_panes = "A2"
wb.save(abs_path)
return abs_path
def _normalize_sheet(data):
"""Devuelve (headers, rows) a partir de cualquiera de las dos formas aceptadas.
headers es una lista o None (si no hay cabecera). rows es una lista de listas.
"""
if isinstance(data, dict):
headers = data.get("headers")
rows = data.get("rows", [])
rows = [list(r) for r in rows]
return (list(headers) if headers is not None else None, rows)
# Forma list[list]: primera fila = headers, resto = datos.
seq = list(data)
if not seq:
return (None, [])
headers = list(seq[0])
rows = [list(r) for r in seq[1:]]
return (headers, rows)
def _coerce(value):
"""Convierte un valor a algo que openpyxl pueda escribir directamente.
None, int, float, str y bool son nativos. Cualquier otro tipo se serializa
a str para evitar que openpyxl lance.
"""
if value is None or isinstance(value, (int, float, str, bool)):
return value
return str(value)
def _display(value) -> str:
"""Representacion en texto de un valor, para medir el ancho de columna."""
if value is None:
return ""
return str(value)
if __name__ == "__main__": # pragma: no cover - smoke manual
import tempfile
out = os.path.join(tempfile.gettempdir(), "write_xlsx_sheets_demo.xlsx")
path = write_xlsx_sheets(
out,
{
"Ventas": [
["Producto", "Unidades", "Precio", "Activo"],
["Teclado", 12, 29.99, True],
["Raton", 30, 14.5, False],
["Monitor", None, 199.0, True],
],
"Resumen": {
"headers": ["Metrica", "Valor"],
"rows": [["Total productos", 3], ["Ingreso estimado", 6359.99]],
},
},
)
print(f"Escrito: {path}")