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:
@@ -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}")
|
||||
Reference in New Issue
Block a user