"""Anade un grafico nativo de openpyxl a una hoja existente de un libro .xlsx. Funcion impura: abre un libro Excel existente, crea un objeto Chart de openpyxl (BarChart/LineChart/PieChart/ScatterChart) sobre rangos de celdas YA escritos, lo ancla en una celda destino y guarda el libro. Es la pieza que faltaba en el grupo `excel` para producir hojas de Excel con graficos: primero se escriben los datos (p.ej. con write_xlsx_sheets) y luego se les anade el chart refiriendo sus rangos. No lanza: cualquier fallo (libro inexistente, hoja inexistente, chart_type invalido, openpyxl ausente) se devuelve como dict {"status": "error", ...}. """ import os # chart_type -> clase de openpyxl.chart. Se resuelve perezosamente en runtime # para no fallar al importar el modulo si openpyxl no esta instalado. _VALID_CHART_TYPES = ("bar", "line", "pie", "scatter") def add_xlsx_chart( xlsx_path: str, sheet_name: str, chart_type: str, data_range: str, cats_range: str = None, anchor: str = "H2", title: str = "", x_title: str = "", y_title: str = "", ) -> dict: """Anade un grafico nativo a una hoja existente de un libro existente. Args: xlsx_path: Ruta al archivo .xlsx existente. Debe existir (esta funcion no crea el libro: escribe primero los datos con otra funcion del grupo). sheet_name: Nombre de la hoja (ya existente) donde se ancla el grafico y de la que provienen los rangos. chart_type: Tipo de grafico. Uno de: "bar", "line", "pie", "scatter". data_range: Rango de celdas de los valores a graficar, en notacion Excel tipo "B1:B10". Se convierte a openpyxl.chart.Reference. Si abarca la cabecera (fila 1), se pasa titles_from_data=True para tomar el nombre de la serie de esa primera celda. cats_range: Rango de las categorias/etiquetas del eje X (o labels de pie), tipo "A2:A10". None (default) = sin categorias explicitas. Ignorado para scatter (que usa xvalues, ver Gotchas). anchor: Celda destino (esquina superior izquierda) del grafico, p.ej. "H2". Default "H2". title: Titulo del grafico. Vacio (default) = sin titulo. x_title: Titulo del eje X. Vacio (default) = sin titulo. Ignorado por pie. y_title: Titulo del eje Y. Vacio (default) = sin titulo. Ignorado por pie. Returns: Dict. En exito: {"status": "ok", "chart_type": , "sheet": , "anchor": }. En error: {"status": "error", "error": ""}. """ if not xlsx_path: return {"status": "error", "error": "xlsx_path no puede estar vacio"} ct = (chart_type or "").strip().lower() if ct not in _VALID_CHART_TYPES: return { "status": "error", "error": ( f"chart_type invalido: '{chart_type}'. " f"Validos: {list(_VALID_CHART_TYPES)}" ), } abs_path = os.path.abspath(xlsx_path) if not os.path.exists(abs_path): return {"status": "error", "error": f"libro no encontrado: {abs_path}"} try: from openpyxl import load_workbook from openpyxl.chart import ( BarChart, LineChart, PieChart, Reference, ScatterChart, Series, ) from openpyxl.utils.cell import range_boundaries except ImportError: # pragma: no cover - dependencia del entorno return { "status": "error", "error": ( "openpyxl es requerido para add_xlsx_chart. " "Instalar con: cd python && uv add openpyxl" ), } try: wb = load_workbook(abs_path) except Exception as exc: # noqa: BLE001 - contrato del grupo: no lanzar return {"status": "error", "error": f"no se pudo abrir el libro: {exc}"} if sheet_name not in wb.sheetnames: return { "status": "error", "error": ( f"hoja '{sheet_name}' no existe. " f"Hojas disponibles: {wb.sheetnames}" ), } ws = wb[sheet_name] # Convierte "B1:B10" -> (min_col, min_row, max_col, max_row) en 1-index. try: d_min_col, d_min_row, d_max_col, d_max_row = range_boundaries(data_range) except Exception as exc: # noqa: BLE001 return { "status": "error", "error": f"data_range invalido '{data_range}': {exc}", } chart_classes = { "bar": BarChart, "line": LineChart, "pie": PieChart, "scatter": ScatterChart, } try: chart = chart_classes[ct]() if title: chart.title = title if ct == "scatter": # Scatter empareja X (cats_range) con Y (data_range) como una serie. chart.style = 13 if x_title: chart.x_axis.title = x_title if y_title: chart.y_axis.title = y_title yvalues = Reference( ws, min_col=d_min_col, min_row=d_min_row, max_col=d_max_col, max_row=d_max_row, ) if cats_range: try: x_min_col, x_min_row, x_max_col, x_max_row = range_boundaries( cats_range ) except Exception as exc: # noqa: BLE001 return { "status": "error", "error": f"cats_range invalido '{cats_range}': {exc}", } xvalues = Reference( ws, min_col=x_min_col, min_row=x_min_row, max_col=x_max_col, max_row=x_max_row, ) series = Series(yvalues, xvalues, title_from_data=False) else: series = Series(yvalues, title_from_data=False) chart.series.append(series) else: # bar/line/pie: add_data + set_categories. if ct in ("bar", "line"): if x_title: chart.x_axis.title = x_title if y_title: chart.y_axis.title = y_title # titles_from_data toma el nombre de serie de la primera fila del # rango cuando este incluye la cabecera (fila 1). from_data = d_min_row == 1 data = Reference( ws, min_col=d_min_col, min_row=d_min_row, max_col=d_max_col, max_row=d_max_row, ) chart.add_data(data, titles_from_data=from_data) if cats_range: try: c_min_col, c_min_row, c_max_col, c_max_row = range_boundaries( cats_range ) except Exception as exc: # noqa: BLE001 return { "status": "error", "error": f"cats_range invalido '{cats_range}': {exc}", } cats = Reference( ws, min_col=c_min_col, min_row=c_min_row, max_col=c_max_col, max_row=c_max_row, ) chart.set_categories(cats) ws.add_chart(chart, anchor) wb.save(abs_path) except Exception as exc: # noqa: BLE001 - contrato del grupo: no lanzar return {"status": "error", "error": f"no se pudo anadir el grafico: {exc}"} return { "status": "ok", "chart_type": ct, "sheet": sheet_name, "anchor": anchor, } if __name__ == "__main__": # pragma: no cover - smoke manual import sys import tempfile sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from infra.write_xlsx_sheets import write_xlsx_sheets tmp = os.path.join(tempfile.gettempdir(), "add_xlsx_chart_demo.xlsx") write_xlsx_sheets( tmp, { "Ventas": [ ["Mes", "Unidades"], ["Ene", 120], ["Feb", 150], ["Mar", 90], ["Abr", 200], ], }, ) res = add_xlsx_chart( xlsx_path=tmp, sheet_name="Ventas", chart_type="bar", data_range="B1:B5", # incluye cabecera "Unidades" -> nombre de serie cats_range="A2:A5", # meses anchor="D2", title="Unidades por mes", x_title="Mes", y_title="Unidades", ) print(res)