"""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}")