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
+100
View File
@@ -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.
+232
View File
@@ -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"
+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}")