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,100 @@
|
||||
---
|
||||
name: upsert_xlsx_sheet
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def upsert_xlsx_sheet(xlsx_path: str, sheet_name: str, records: list[dict], columns: list[str], key_col: str = \"\", preserve_cols: list[str] | None = None, formulas: dict | None = None, backup: bool = True, freeze: str = \"A2\", autofilter: bool = True) -> dict"
|
||||
description: "Actualiza de forma NO DESTRUCTIVA una hoja concreta de un archivo .xlsx con openpyxl. Reescribe SOLO la hoja indicada (sheet_name) y conserva intactas las demas hojas del libro. Antes de limpiar la hoja gestionada lee, por una columna clave (key_col), los valores de las columnas de trabajo manual (preserve_cols) y los reescribe ganando sobre los datos nuevos. Cabecera estilizada (negrita, relleno, texto blanco, borde, centrado), freeze_panes, autofilter, auto-ancho de columnas, formulas por columna con placeholders {row} y {NombreColumna}, y backup .bak opcional. Devuelve un resumen con filas escritas, hojas conservadas y celdas manuales preservadas."
|
||||
tags: [xlsx, openpyxl, spreadsheet, office, onlyoffice, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [openpyxl]
|
||||
params:
|
||||
- name: xlsx_path
|
||||
desc: "Ruta del archivo .xlsx. Si existe se abre con openpyxl (y se respaldan las demas hojas); si no existe se crea un libro nuevo eliminando la hoja por defecto vacia."
|
||||
- name: sheet_name
|
||||
desc: "Nombre de la hoja a (re)escribir. Es la UNICA hoja que la funcion toca; el resto del libro queda intacto. Si la hoja no existe, se crea."
|
||||
- name: records
|
||||
desc: "Lista de dicts; cada dict es una fila y sus claves son nombres de columna. Se escribe una fila por record en el orden definido por columns."
|
||||
- name: columns
|
||||
desc: "Orden canonico de columnas (nombres de cabecera). Define que columnas se escriben y en que orden. Una clave de un record que no este en columns se ignora."
|
||||
- name: key_col
|
||||
desc: "Nombre de la columna clave usada para emparejar filas existentes con records al preservar trabajo manual. Vacio (default) desactiva la preservacion. El match normaliza el valor (lowercase + espacios colapsados)."
|
||||
- name: preserve_cols
|
||||
desc: "Lista de columnas cuyos valores manuales existentes en el libro se conservan: si una celda ya tenia valor para esa clave, ese valor gana sobre el de records. None o lista vacia desactiva la preservacion."
|
||||
- name: formulas
|
||||
desc: "Dict opcional {columna: \"plantilla\"} o {columna: {\"f\": plantilla, \"fmt\": formato_numerico}}. La plantilla admite {row} (numero de fila actual) y {NombreColumna} (letra de la columna con ese nombre). Estas columnas se escriben como formula en cada fila y NO se rellenan desde records."
|
||||
- name: backup
|
||||
desc: "Si True y el archivo existe, copia a xlsx_path + '.bak' antes de escribir. El .bak es rotativo: cada llamada lo sobrescribe. Default True."
|
||||
- name: freeze
|
||||
desc: "Celda para freeze_panes (inmoviliza filas/columnas por encima/izquierda). Default 'A2' (congela la cabecera)."
|
||||
- name: autofilter
|
||||
desc: "Si True activa auto_filter sobre el rango A1 hasta la ultima columna y fila de datos. Default True."
|
||||
output: "Dict con: sheet (nombre de la hoja escrita), rows_written (numero de filas de datos), other_sheets_preserved (lista con los nombres de las demas hojas conservadas), manual_cells_preserved (cuantas celdas de trabajo manual se conservaron) y backup_path (ruta del .bak creado, o cadena vacia si no hubo backup)."
|
||||
tested: true
|
||||
tests: ["test_no_destructivo_y_preserva_trabajo_manual", "test_crea_libro_nuevo_si_no_existe"]
|
||||
test_file_path: "python/functions/infra/upsert_xlsx_sheet_test.py"
|
||||
file_path: "python/functions/infra/upsert_xlsx_sheet.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.upsert_xlsx_sheet import upsert_xlsx_sheet
|
||||
|
||||
result = upsert_xlsx_sheet(
|
||||
xlsx_path="/home/enmanuel/afiliados/programas_afiliados.xlsx",
|
||||
sheet_name="Datos",
|
||||
records=[
|
||||
{"Programa": "Awin", "Clicks": 1200, "Ingreso": 340},
|
||||
{"Programa": "CJ", "Clicks": 800, "Ingreso": 210},
|
||||
],
|
||||
columns=["Programa", "Clicks", "Ingreso", "EPC"],
|
||||
key_col="Programa",
|
||||
preserve_cols=["Ingreso"], # el ingreso anotado a mano gana sobre el dato nuevo
|
||||
formulas={
|
||||
"EPC": {"f": '=IFERROR({Ingreso}{row}/{Clicks}{row},"")', "fmt": "0.00"},
|
||||
},
|
||||
)
|
||||
print(result)
|
||||
# {'sheet': 'Datos', 'rows_written': 2, 'other_sheets_preserved': ['Personal'],
|
||||
# 'manual_cells_preserved': 1, 'backup_path': '/home/enmanuel/afiliados/programas_afiliados.xlsx.bak'}
|
||||
```
|
||||
|
||||
La columna EPC se escribe como formula `=IFERROR(C2/B2,"")` (las letras se
|
||||
resuelven a partir de la posicion de `Ingreso` y `Clicks` en `columns`), y la
|
||||
hoja "Personal" del usuario no se toca.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites regenerar UNA hoja de un .xlsx que el usuario tambien
|
||||
edita a mano, sin destruir su trabajo: refrescar datos de research/scraping
|
||||
manteniendo columnas anotadas manualmente, o publicar un dataset en una hoja
|
||||
concreta de un libro que contiene otras hojas personales. Es la pieza de
|
||||
escritura para flujos donde un editor (OnlyOffice/Excel) y un proceso
|
||||
automatico comparten el mismo archivo.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura — escribe en disco.** Pisa SOLO la hoja `sheet_name`; las demas hojas
|
||||
del libro se conservan tal cual (esa es la garantia central de la funcion).
|
||||
- **Requiere openpyxl** (ya instalado en `python/.venv`).
|
||||
- **El .bak es rotativo**: cada llamada con `backup=True` sobrescribe
|
||||
`xlsx_path + ".bak"`. No es un historial; es la copia de la version anterior.
|
||||
- **Lee del disco.** Si el archivo esta abierto en OnlyOffice/Excel, GUARDA en
|
||||
el editor ANTES de llamar a la funcion: ella lee la version en disco y, al
|
||||
recargar el editor despues, perderias los cambios no guardados.
|
||||
- **Las columnas se localizan por nombre en la cabecera (fila 1).** Si renombras
|
||||
una columna entre ejecuciones, el match de `preserve_cols`/`key_col` con la
|
||||
version anterior se rompe para esa columna (se trata como columna nueva).
|
||||
- **`key_col` vacio o `preserve_cols` vacio** desactivan la preservacion: la hoja
|
||||
se reescribe por completo desde `records`.
|
||||
- Las columnas declaradas en `formulas` se escriben siempre como formula y NO se
|
||||
rellenan desde `records` ni se preservan.
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Actualiza de forma NO DESTRUCTIVA una hoja concreta de un archivo .xlsx.
|
||||
|
||||
Generalizacion de la logica probada en afiliados/build_xlsx.py. La garantia
|
||||
central es que SOLO se reescribe la hoja indicada (sheet_name); cualquier otra
|
||||
hoja del libro (trabajo del usuario) se conserva intacta. Dentro de la hoja
|
||||
gestionada se preservan las columnas de trabajo manual (preserve_cols) haciendo
|
||||
match por una columna clave (key_col): si la celda ya tiene valor en el libro
|
||||
existente, el valor manual gana sobre el dato nuevo.
|
||||
|
||||
Requiere openpyxl (instalado en python/.venv).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
|
||||
def _norm(value):
|
||||
"""Normaliza una clave para el match (lowercase, espacios colapsados)."""
|
||||
return " ".join(str(value).lower().split())
|
||||
|
||||
|
||||
def _read_preserved(ws, key_col, preserve_cols):
|
||||
"""Lee de una hoja existente los valores manuales por clave.
|
||||
|
||||
Localiza las columnas por su nombre en la cabecera (fila 1). Devuelve un
|
||||
dict {clave_normalizada: {nombre_columna: valor}} con solo las celdas no
|
||||
vacias de las columnas en preserve_cols.
|
||||
"""
|
||||
preserved = {}
|
||||
if not preserve_cols or not key_col or ws.max_row < 2:
|
||||
return preserved
|
||||
header = {}
|
||||
for c in range(1, ws.max_column + 1):
|
||||
name = ws.cell(row=1, column=c).value
|
||||
if name is not None:
|
||||
header[name] = c
|
||||
if key_col not in header:
|
||||
return preserved
|
||||
for r in range(2, ws.max_row + 1):
|
||||
raw_key = ws.cell(row=r, column=header[key_col]).value
|
||||
if raw_key in (None, ""):
|
||||
continue
|
||||
key = _norm(raw_key)
|
||||
vals = {}
|
||||
for col in preserve_cols:
|
||||
if col in header:
|
||||
v = ws.cell(row=r, column=header[col]).value
|
||||
if v not in (None, ""):
|
||||
vals[col] = v
|
||||
if vals:
|
||||
preserved[key] = vals
|
||||
return preserved
|
||||
|
||||
|
||||
def _style_objects():
|
||||
return {
|
||||
"header_fill": PatternFill("solid", fgColor="1F4E78"),
|
||||
"header_font": Font(bold=True, color="FFFFFF", size=11),
|
||||
"border": Border(*[Side(style="thin", color="D9D9D9")] * 4),
|
||||
"center": Alignment(horizontal="center", vertical="center"),
|
||||
"wrap": Alignment(vertical="center", wrap_text=True),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_formulas(formulas):
|
||||
"""Acepta {col: "plantilla"} o {col: {"f": ..., "fmt": ...}} y normaliza
|
||||
siempre a {col: {"f": plantilla, "fmt": formato_o_None}}.
|
||||
"""
|
||||
out = {}
|
||||
if not formulas:
|
||||
return out
|
||||
for col, spec in formulas.items():
|
||||
if isinstance(spec, dict):
|
||||
out[col] = {"f": spec.get("f", ""), "fmt": spec.get("fmt")}
|
||||
else:
|
||||
out[col] = {"f": str(spec), "fmt": None}
|
||||
return out
|
||||
|
||||
|
||||
def upsert_xlsx_sheet(
|
||||
xlsx_path: str,
|
||||
sheet_name: str,
|
||||
records: list,
|
||||
columns: list,
|
||||
key_col: str = "",
|
||||
preserve_cols=None,
|
||||
formulas=None,
|
||||
backup: bool = True,
|
||||
freeze: str = "A2",
|
||||
autofilter: bool = True,
|
||||
) -> dict:
|
||||
"""Reescribe una sola hoja de un .xlsx conservando el resto del libro.
|
||||
|
||||
Args:
|
||||
xlsx_path: Ruta del archivo .xlsx. Si no existe se crea un libro nuevo.
|
||||
sheet_name: Nombre de la hoja a (re)escribir. Es la UNICA hoja tocada.
|
||||
records: Lista de dicts; cada dict es una fila, sus claves son nombres
|
||||
de columna.
|
||||
columns: Orden canonico de columnas (nombres de cabecera). Define que
|
||||
columnas se escriben y en que orden.
|
||||
key_col: Nombre de la columna clave usada para el match al preservar
|
||||
trabajo manual. Vacio (default) desactiva la preservacion.
|
||||
preserve_cols: Lista de columnas cuyos valores manuales se conservan si
|
||||
ya existian en el libro (ganan sobre el dato nuevo de records).
|
||||
formulas: Dict opcional {columna: {"f": plantilla, "fmt": formato}} o
|
||||
{columna: "plantilla"}. La plantilla admite {row} (numero de fila)
|
||||
y {NombreColumna} (letra de esa columna). Estas columnas se escriben
|
||||
como formula y NO se rellenan desde records.
|
||||
backup: Si True y el archivo existe, copia a xlsx_path + ".bak" antes
|
||||
de escribir.
|
||||
freeze: Celda para freeze_panes (default "A2").
|
||||
autofilter: Si True activa auto_filter sobre el rango de datos.
|
||||
|
||||
Returns:
|
||||
Dict con sheet, rows_written, other_sheets_preserved, manual_cells_preserved
|
||||
y backup_path.
|
||||
"""
|
||||
preserve_cols = preserve_cols or []
|
||||
formulas = _normalize_formulas(formulas)
|
||||
|
||||
backup_path = ""
|
||||
if os.path.exists(xlsx_path):
|
||||
if backup:
|
||||
backup_path = xlsx_path + ".bak"
|
||||
shutil.copy2(xlsx_path, backup_path)
|
||||
wb = load_workbook(xlsx_path)
|
||||
else:
|
||||
wb = Workbook()
|
||||
# Quitar la hoja por defecto vacia; se crearan/usaran las gestionadas.
|
||||
wb.remove(wb.active)
|
||||
|
||||
# Indice de columna por nombre y letra correspondiente (basado en `columns`).
|
||||
col_index = {name: i + 1 for i, name in enumerate(columns)}
|
||||
col_letter = {name: get_column_letter(i + 1) for i, name in enumerate(columns)}
|
||||
formula_cols = set(formulas.keys())
|
||||
|
||||
# Preservar trabajo manual ANTES de limpiar la hoja.
|
||||
if sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
preserved = _read_preserved(ws, key_col, preserve_cols)
|
||||
if ws.max_row:
|
||||
ws.delete_rows(1, ws.max_row)
|
||||
else:
|
||||
ws = wb.create_sheet(title=sheet_name)
|
||||
preserved = {}
|
||||
|
||||
manual_cells_preserved = sum(len(v) for v in preserved.values())
|
||||
|
||||
st = _style_objects()
|
||||
|
||||
# Cabecera estilizada (fila 1).
|
||||
for name in columns:
|
||||
cell = ws.cell(row=1, column=col_index[name], value=name)
|
||||
cell.fill = st["header_fill"]
|
||||
cell.font = st["header_font"]
|
||||
cell.alignment = st["center"]
|
||||
cell.border = st["border"]
|
||||
|
||||
# Filas de datos.
|
||||
for idx, record in enumerate(records):
|
||||
er = idx + 2
|
||||
key = _norm(record.get(key_col, "")) if key_col else ""
|
||||
pres = preserved.get(key, {}) if key else {}
|
||||
for name in columns:
|
||||
c = ws.cell(row=er, column=col_index[name])
|
||||
c.border = st["border"]
|
||||
c.alignment = st["wrap"]
|
||||
if name in formula_cols:
|
||||
# Sustituir {row} y {NombreColumna} -> letra de columna.
|
||||
template = formulas[name]["f"]
|
||||
rendered = template.format(row=er, **col_letter)
|
||||
c.value = rendered
|
||||
fmt = formulas[name]["fmt"]
|
||||
if fmt:
|
||||
c.number_format = fmt
|
||||
continue
|
||||
if name in preserve_cols and name in pres:
|
||||
c.value = pres[name]
|
||||
else:
|
||||
c.value = record.get(name, "")
|
||||
|
||||
# Auto-ancho por columna: max(len(nombre)+2, 10) acotado a 48, mirando datos.
|
||||
for name in columns:
|
||||
max_len = len(name)
|
||||
for record in records:
|
||||
if name in formula_cols:
|
||||
break
|
||||
v = str(record.get(name, ""))
|
||||
if len(v) > max_len:
|
||||
max_len = len(v)
|
||||
ws.column_dimensions[col_letter[name]].width = min(max(max_len + 2, 10), 48)
|
||||
|
||||
ws.freeze_panes = freeze
|
||||
|
||||
if autofilter and columns:
|
||||
last_col = col_letter[columns[-1]]
|
||||
ws.auto_filter.ref = f"A1:{last_col}{max(len(records) + 1, 1)}"
|
||||
|
||||
wb.save(xlsx_path)
|
||||
|
||||
other_sheets = [s for s in wb.sheetnames if s != sheet_name]
|
||||
|
||||
return {
|
||||
"sheet": sheet_name,
|
||||
"rows_written": len(records),
|
||||
"other_sheets_preserved": other_sheets,
|
||||
"manual_cells_preserved": manual_cells_preserved,
|
||||
"backup_path": backup_path,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
tmp = os.path.join(tempfile.mkdtemp(), "demo.xlsx")
|
||||
result = upsert_xlsx_sheet(
|
||||
xlsx_path=tmp,
|
||||
sheet_name="Datos",
|
||||
records=[
|
||||
{"Programa": "Alpha", "Clicks": 100, "Ingreso": 50},
|
||||
{"Programa": "Beta", "Clicks": 200, "Ingreso": 80},
|
||||
],
|
||||
columns=["Programa", "Clicks", "Ingreso", "EPC"],
|
||||
key_col="Programa",
|
||||
preserve_cols=["Ingreso"],
|
||||
formulas={"EPC": {"f": '=IFERROR({Ingreso}{row}/{Clicks}{row},"")', "fmt": "0.00"}},
|
||||
)
|
||||
print(result)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests para upsert_xlsx_sheet."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from functions.infra.upsert_xlsx_sheet import upsert_xlsx_sheet
|
||||
|
||||
|
||||
def _build_initial_book(path):
|
||||
"""Crea un libro con dos hojas: 'Datos' (gestionada con valor manual) y
|
||||
'Personal' (trabajo del usuario que NO debe tocarse).
|
||||
"""
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
|
||||
datos = wb.create_sheet("Datos")
|
||||
# Cabecera de la hoja gestionada.
|
||||
for c, name in enumerate(["Programa", "Clicks", "Ingreso", "EPC"], start=1):
|
||||
datos.cell(row=1, column=c, value=name)
|
||||
# Fila existente para Alpha con un valor manual en "Ingreso".
|
||||
datos.cell(row=2, column=1, value="Alpha")
|
||||
datos.cell(row=2, column=2, value=10)
|
||||
datos.cell(row=2, column=3, value=999) # valor manual a preservar
|
||||
|
||||
personal = wb.create_sheet("Personal")
|
||||
personal.cell(row=1, column=1, value="mis_notas")
|
||||
personal.cell(row=2, column=1, value="no me toques")
|
||||
personal.cell(row=2, column=2, value=1234)
|
||||
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def test_no_destructivo_y_preserva_trabajo_manual(tmp_path):
|
||||
path = str(tmp_path / "libro.xlsx")
|
||||
_build_initial_book(path)
|
||||
|
||||
result = upsert_xlsx_sheet(
|
||||
xlsx_path=path,
|
||||
sheet_name="Datos",
|
||||
records=[
|
||||
{"Programa": "Alpha", "Clicks": 100, "Ingreso": 50},
|
||||
{"Programa": "Beta", "Clicks": 200, "Ingreso": 80},
|
||||
{"Programa": "Gamma", "Clicks": 300, "Ingreso": 120},
|
||||
],
|
||||
columns=["Programa", "Clicks", "Ingreso", "EPC"],
|
||||
key_col="Programa",
|
||||
preserve_cols=["Ingreso"],
|
||||
formulas={"EPC": {"f": '=IFERROR({Ingreso}{row}/{Clicks}{row},"")', "fmt": "0.00"}},
|
||||
)
|
||||
|
||||
# El dict de retorno reporta lo esperado.
|
||||
assert result["sheet"] == "Datos"
|
||||
assert result["rows_written"] == 3
|
||||
assert "Personal" in result["other_sheets_preserved"]
|
||||
assert result["manual_cells_preserved"] == 1
|
||||
assert result["backup_path"] == path + ".bak"
|
||||
|
||||
wb = load_workbook(path)
|
||||
|
||||
# (a) La hoja "Personal" sigue existiendo con sus valores intactos.
|
||||
assert "Personal" in wb.sheetnames
|
||||
personal = wb["Personal"]
|
||||
assert personal.cell(row=1, column=1).value == "mis_notas"
|
||||
assert personal.cell(row=2, column=1).value == "no me toques"
|
||||
assert personal.cell(row=2, column=2).value == 1234
|
||||
|
||||
datos = wb["Datos"]
|
||||
header = {datos.cell(row=1, column=c).value: c for c in range(1, datos.max_column + 1)}
|
||||
|
||||
# Localizar la fila de Alpha por la columna clave.
|
||||
alpha_row = None
|
||||
for r in range(2, datos.max_row + 1):
|
||||
if datos.cell(row=r, column=header["Programa"]).value == "Alpha":
|
||||
alpha_row = r
|
||||
break
|
||||
assert alpha_row is not None
|
||||
|
||||
# (b) El valor manual en "Ingreso" para Alpha (999) NO se pisó con 50.
|
||||
assert datos.cell(row=alpha_row, column=header["Ingreso"]).value == 999
|
||||
|
||||
# (c) Se añadieron las filas nuevas (Beta y Gamma no existían antes).
|
||||
programas = {
|
||||
datos.cell(row=r, column=header["Programa"]).value
|
||||
for r in range(2, datos.max_row + 1)
|
||||
}
|
||||
assert {"Alpha", "Beta", "Gamma"} <= programas
|
||||
|
||||
# (d) La columna de fórmula contiene una fórmula.
|
||||
epc = datos.cell(row=alpha_row, column=header["EPC"]).value
|
||||
assert isinstance(epc, str) and epc.startswith("=")
|
||||
assert "IFERROR" in epc
|
||||
|
||||
|
||||
def test_crea_libro_nuevo_si_no_existe(tmp_path):
|
||||
path = str(tmp_path / "nuevo.xlsx")
|
||||
assert not os.path.exists(path)
|
||||
|
||||
result = upsert_xlsx_sheet(
|
||||
xlsx_path=path,
|
||||
sheet_name="Hoja1",
|
||||
records=[{"A": "x", "B": "y"}],
|
||||
columns=["A", "B"],
|
||||
)
|
||||
|
||||
assert os.path.exists(path)
|
||||
# Sin archivo previo no hay backup.
|
||||
assert result["backup_path"] == ""
|
||||
assert result["rows_written"] == 1
|
||||
|
||||
wb = load_workbook(path)
|
||||
assert wb.sheetnames == ["Hoja1"]
|
||||
ws = wb["Hoja1"]
|
||||
assert ws.cell(row=1, column=1).value == "A"
|
||||
assert ws.cell(row=2, column=1).value == "x"
|
||||
@@ -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