From 60bfeb148cc4a8bd232bbf2a6a930942dd7b6fd7 Mon Sep 17 00:00:00 2001 From: antznette1 Date: Sun, 12 Oct 2025 18:58:56 +0100 Subject: [PATCH] ENH: to_excel engine_kwargs for header autofilter and header bold (xlsxwriter/openpyxl); tests. Closes #62651 --- pandas/io/excel/_openpyxl.py | 43 +++++++++++++++++++ pandas/io/excel/_xlsxwriter.py | 37 ++++++++++++++-- pandas/io/formats/excel.py | 9 ++++ .../io/excel/test_autofilter_openpyxl.py | 24 +++++++++++ .../io/excel/test_autofilter_xlsxwriter.py | 25 +++++++++++ 5 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 pandas/tests/io/excel/test_autofilter_openpyxl.py create mode 100644 pandas/tests/io/excel/test_autofilter_xlsxwriter.py diff --git a/pandas/io/excel/_openpyxl.py b/pandas/io/excel/_openpyxl.py index 867d11583dcc0..3dd1942ae9fe5 100644 --- a/pandas/io/excel/_openpyxl.py +++ b/pandas/io/excel/_openpyxl.py @@ -67,6 +67,9 @@ def __init__( # pyright: ignore[reportInconsistentConstructor] engine_kwargs=engine_kwargs, ) + # Persist engine kwargs for later feature toggles (e.g., autofilter/header bold) + self._engine_kwargs = engine_kwargs + # ExcelWriter replaced "a" by "r+" to allow us to first read the excel file from # the file and later write to it if "r+" in self._mode: # Load from existing workbook @@ -486,6 +489,15 @@ def _write_cells( row=freeze_panes[0] + 1, column=freeze_panes[1] + 1 ) + # Track bounds for autofilter application + min_row = None + min_col = None + max_row = None + max_col = None + + # Prepare header bold setting + header_bold = bool(self._engine_kwargs.get("header_bold", False)) if hasattr(self, "_engine_kwargs") else False + for cell in cells: xcell = wks.cell( row=startrow + cell.row + 1, column=startcol + cell.col + 1 @@ -506,6 +518,26 @@ def _write_cells( for k, v in style_kwargs.items(): setattr(xcell, k, v) + # Update bounds + crow = startrow + cell.row + 1 + ccol = startcol + cell.col + 1 + if min_row is None or crow < min_row: + min_row = crow + if min_col is None or ccol < min_col: + min_col = ccol + if max_row is None or crow > max_row: + max_row = crow + if max_col is None or ccol > max_col: + max_col = ccol + + # Apply bold to first header row cells if requested + if header_bold and (cell.row == 0): + try: + from openpyxl.styles import Font + xcell.font = Font(bold=True) + except Exception: + pass + if cell.mergestart is not None and cell.mergeend is not None: wks.merge_cells( start_row=startrow + cell.row + 1, @@ -532,6 +564,17 @@ def _write_cells( for k, v in style_kwargs.items(): setattr(xcell, k, v) + # Apply autofilter over the used range if requested + if hasattr(self, "_engine_kwargs") and bool(self._engine_kwargs.get("autofilter_header", False)): + if min_row is not None and min_col is not None and max_row is not None and max_col is not None: + try: + from openpyxl.utils import get_column_letter + start_ref = f"{get_column_letter(min_col)}{min_row}" + end_ref = f"{get_column_letter(max_col)}{max_row}" + wks.auto_filter.ref = f"{start_ref}:{end_ref}" + except Exception: + pass + class OpenpyxlReader(BaseExcelReader["Workbook"]): @doc(storage_options=_shared_docs["storage_options"]) diff --git a/pandas/io/excel/_xlsxwriter.py b/pandas/io/excel/_xlsxwriter.py index 5874f720e3bd0..4635944d50ed2 100644 --- a/pandas/io/excel/_xlsxwriter.py +++ b/pandas/io/excel/_xlsxwriter.py @@ -212,6 +212,8 @@ def __init__( # pyright: ignore[reportInconsistentConstructor] engine_kwargs=engine_kwargs, ) + self._engine_kwargs = engine_kwargs + try: self._book = Workbook(self._handles.handle, **engine_kwargs) # type: ignore[arg-type] except TypeError: @@ -258,6 +260,14 @@ def _write_cells( if validate_freeze_panes(freeze_panes): wks.freeze_panes(*(freeze_panes)) + min_row = None + min_col = None + max_row = None + max_col = None + + header_bold = bool(self._engine_kwargs.get("header_bold", False)) if hasattr(self, "_engine_kwargs") else False + bold_format = self.book.add_format({"bold": True}) if header_bold else None + for cell in cells: val, fmt = self._value_with_fmt(cell.val) @@ -271,14 +281,35 @@ def _write_cells( style = self.book.add_format(_XlsxStyler.convert(cell.style, fmt)) style_dict[stylekey] = style + crow = startrow + cell.row + ccol = startcol + cell.col + if min_row is None or crow < min_row: + min_row = crow + if min_col is None or ccol < min_col: + min_col = ccol + if max_row is None or crow > max_row: + max_row = crow + if max_col is None or ccol > max_col: + max_col = ccol + if cell.mergestart is not None and cell.mergeend is not None: wks.merge_range( - startrow + cell.row, - startcol + cell.col, + crow, + ccol, startrow + cell.mergestart, startcol + cell.mergeend, val, style, ) else: - wks.write(startrow + cell.row, startcol + cell.col, val, style) + if bold_format is not None and (startrow == crow): + wks.write(crow, ccol, val, bold_format) + else: + wks.write(crow, ccol, val, style) + + if hasattr(self, "_engine_kwargs") and bool(self._engine_kwargs.get("autofilter_header", False)): + if min_row is not None and min_col is not None and max_row is not None and max_col is not None: + try: + wks.autofilter(min_row, min_col, max_row, max_col) + except Exception: + pass diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index d4d47253a5f82..71d87b14c7559 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -922,6 +922,15 @@ def write( formatted_cells = self.get_formatted_cells() if isinstance(writer, ExcelWriter): need_save = False + # Propagate engine_kwargs to an existing writer instance if provided + if engine_kwargs: + try: + current = getattr(writer, "_engine_kwargs", {}) or {} + merged = {**current, **engine_kwargs} + setattr(writer, "_engine_kwargs", merged) + except Exception: + # Best-effort propagation; ignore if engine does not support it + pass else: writer = ExcelWriter( writer, diff --git a/pandas/tests/io/excel/test_autofilter_openpyxl.py b/pandas/tests/io/excel/test_autofilter_openpyxl.py new file mode 100644 index 0000000000000..fc4854a919e1e --- /dev/null +++ b/pandas/tests/io/excel/test_autofilter_openpyxl.py @@ -0,0 +1,24 @@ +import io +import pytest +import pandas as pd + +openpyxl = pytest.importorskip("openpyxl") + + +def test_to_excel_openpyxl_autofilter_and_bold(): + df = pd.DataFrame({"A": [1, 2], "B": [3, 4]}) + buf = io.BytesIO() + with pd.ExcelWriter(buf, engine="openpyxl") as writer: + df.to_excel( + writer, + index=False, + engine_kwargs={"autofilter_header": True, "header_bold": True}, + ) + buf.seek(0) + wb = openpyxl.load_workbook(buf) + ws = wb.active + # Autofilter should be set spanning header+data + assert ws.auto_filter is not None + assert ws.auto_filter.ref is not None and ws.auto_filter.ref != "" + # Header row (row 1) should be bold + assert all(ws.cell(row=1, column=c).font.bold for c in range(1, df.shape[1] + 1)) diff --git a/pandas/tests/io/excel/test_autofilter_xlsxwriter.py b/pandas/tests/io/excel/test_autofilter_xlsxwriter.py new file mode 100644 index 0000000000000..299c3d17cf64c --- /dev/null +++ b/pandas/tests/io/excel/test_autofilter_xlsxwriter.py @@ -0,0 +1,25 @@ +import io +import pytest +import pandas as pd + +pytest.importorskip("xlsxwriter") +openpyxl = pytest.importorskip("openpyxl") + + +def test_to_excel_xlsxwriter_autofilter_and_bold(): + df = pd.DataFrame({"A": [1, 2], "B": [3, 4]}) + buf = io.BytesIO() + with pd.ExcelWriter(buf, engine="xlsxwriter") as writer: + df.to_excel( + writer, + index=False, + engine_kwargs={"autofilter_header": True, "header_bold": True}, + ) + buf.seek(0) + wb = openpyxl.load_workbook(buf) + ws = wb.active + # Autofilter should be set spanning header+data + assert ws.auto_filter is not None + assert ws.auto_filter.ref is not None and ws.auto_filter.ref != "" + # Header row (row 1) should be bold + assert all(ws.cell(row=1, column=c).font.bold for c in range(1, df.shape[1] + 1))