a90b7443e4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
6.0 KiB
Python
173 lines
6.0 KiB
Python
"""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}")
|