Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Allow to assign/create custom body_style and header_style property to an instance of ExcelFormatter used in method df.to_excel() #53973

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions doc/source/user_guide/io.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3880,6 +3880,19 @@ The look and feel of Excel worksheets created from pandas can be modified using
* ``float_format`` : Format string for floating point numbers (default ``None``).
* ``freeze_panes`` : A tuple of two integers representing the bottommost row and rightmost column to freeze. Each of these parameters is one-based, so (1, 1) will freeze the first row and first column (default ``None``).

The styling and formatting of a worksheet's header and body can be easily customized using CSS by passing a dictionary object to
``pd.io.formats.excel.ExcelFormatter.header_style`` and ``pd.io.formats.excel.ExcelFormatter.body_style``.

.. code-block:: python

# Set Excel worksheet header and body style
>>> df = pd.DataFrame([{"A": 1, "B": 2, "C": 3}, {"A": 1, "B": 2, "C": 3}])
>>> pd.io.formats.excel.ExcelFormatter.header_style = {"font": {"bold": True},}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is doing what you might expect. In particular, this is not using getters/setters, but rather just overwriting a property.

class Foo:
    @property
    def foo(self):
        raise ValueError
        
    @foo.setter
    def foo(self, other):
        raise ValueError
        
print(Foo.foo)
# <property object at 0x7f5438093290>
Foo.foo = 5
print(Foo.foo)
# 5

Copy link
Contributor Author

@rmhowe425 rmhowe425 Jul 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rhshadrach @attack68

What do we think would be the appropriate implementation for this request then? It seems like the initial suggestion is not ideal.

What do we think about setting the default style to None for both header_style and body_style and have users rely on Styler to format output of to_excel as mentioned here?

>>> pd.io.formats.excel.ExcelFormatter.body_style = {
... "font": {"bold": True},"alignment": {"horizontal": "center", "vertical": "top"},
... }
>>> df.to_excel("path_to_file.xlsx", "Sheet1", index=False)

Using the `Xlsxwriter`_ engine provides many options for controlling the
format of an Excel worksheet created with the ``to_excel`` method. Excellent examples can be found in the
`Xlsxwriter`_ documentation here: https://xlsxwriter.readthedocs.io/working_with_pandas.html
Expand Down
1 change: 1 addition & 0 deletions doc/source/whatsnew/v2.1.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ Other enhancements
- Added a new parameter ``by_row`` to :meth:`Series.apply` and :meth:`DataFrame.apply`. When set to ``False`` the supplied callables will always operate on the whole Series or DataFrame (:issue:`53400`, :issue:`53601`).
- Groupby aggregations (such as :meth:`DataFrameGroupby.sum`) now can preserve the dtype of the input instead of casting to ``float64`` (:issue:`44952`)
- Improved error message when :meth:`DataFrameGroupBy.agg` failed (:issue:`52930`)
- Made :attr:`ExcelFormatter.header_style` a class attribute instead of a property. Default styles for :meth:`DataFrame.to_excel` are set to None. (:issue:`52369`)
- Many read/to_* functions, such as :meth:`DataFrame.to_pickle` and :func:`read_csv`, support forwarding compression arguments to lzma.LZMAFile (:issue:`52979`)
- Performance improvement in :func:`concat` with homogeneous ``np.float64`` or ``np.float32`` dtypes (:issue:`52685`)
- Performance improvement in :meth:`DataFrame.filter` when ``items`` is given (:issue:`52941`)
Expand Down
31 changes: 19 additions & 12 deletions pandas/io/formats/excel.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,19 +577,26 @@ def __init__(
self.header = header
self.merge_cells = merge_cells
self.inf_rep = inf_rep
self._header_style: dict[str, Any] | None = None
self._body_style: dict[str, Any] | None = None

@property
def header_style(self) -> dict[str, dict[str, str | bool]]:
return {
"font": {"bold": True},
"borders": {
"top": "thin",
"right": "thin",
"bottom": "thin",
"left": "thin",
},
"alignment": {"horizontal": "center", "vertical": "top"},
}
def header_style(self):
return self._header_style

@header_style.setter
def header_style(self, val) -> None:
if isinstance(val, dict):
self._header_style = val

@property
def body_style(self):
return self._body_style

@body_style.setter
def body_style(self, val) -> None:
if isinstance(val, dict):
self._body_style = val

def _format_value(self, val):
if is_scalar(val) and missing.isna(val):
Expand Down Expand Up @@ -876,7 +883,7 @@ def _generate_body(self, coloffset: int) -> Iterable[ExcelCell]:
row=self.rowcounter + i,
col=colidx + coloffset,
val=val,
style=None,
style=self.body_style,
css_styles=getattr(self.styler, "ctx", None),
css_row=i,
css_col=colidx,
Expand Down
124 changes: 122 additions & 2 deletions pandas/tests/io/excel/test_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import pandas.util._test_decorators as td

import pandas as pd
from pandas import (
DataFrame,
read_excel,
Expand All @@ -15,7 +16,7 @@
from pandas.io.excel import ExcelWriter
from pandas.io.formats.excel import ExcelFormatter

pytest.importorskip("jinja2")
# pytest.importorskip("jinja2")
# jinja2 is currently required for Styler.__init__(). Technically Styler.to_excel
# could compute styles and render to excel without jinja2, since there is no
# 'template' file, but this needs the import error to delayed until render time.
Expand All @@ -35,7 +36,7 @@ def assert_equal_cell_styles(cell1, cell2):
"engine",
["xlsxwriter", "openpyxl"],
)
def test_styler_to_excel_unstyled(engine):
def test_styler_to_excel_nostyle(engine):
# compare DataFrame.to_excel and Styler.to_excel when no styles applied
pytest.importorskip(engine)
df = DataFrame(np.random.randn(2, 2))
Expand Down Expand Up @@ -254,6 +255,125 @@ def test_styler_to_excel_border_style(engine, border_style):
assert s_cell == expected


def test_styler_update_values():
# GH 53973
openpyxl = pytest.importorskip("openpyxl")
df = DataFrame([{"A": 1, "B": 2, "C": 3}, {"A": 1, "B": 2, "C": 3}])
style = {
"font": {"bold": True},
"borders": {
"top": "thin",
"right": "thin",
"bottom": "thin",
"left": "thin",
},
"alignment": {"horizontal": "center", "vertical": "top"},
}

with tm.ensure_clean(".xlsx") as path:
with ExcelWriter(path, engine="openpyxl") as writer:
# write to sheet 'custom'
pd.io.formats.excel.ExcelFormatter.header_style = style
pd.io.formats.excel.ExcelFormatter.body_style = style
df.to_excel(writer, sheet_name="custom")

# write to sheet 'default'
pd.io.formats.excel.ExcelFormatter.header_style = None
pd.io.formats.excel.ExcelFormatter.body_style = None
df.to_excel(writer, sheet_name="default")

with contextlib.closing(openpyxl.load_workbook(path)) as wb:
# Check font, spacing, indentation
assert wb["custom"].cell(1, 2).font.bold is True
assert wb["custom"].cell(1, 2).alignment.horizontal == "center"
assert wb["custom"].cell(1, 2).alignment.vertical == "top"
assert wb["default"].cell(1, 2).font.bold is False
assert wb["default"].cell(1, 2).alignment.horizontal is None
assert wb["default"].cell(1, 2).alignment.vertical is None

# Check border
assert wb["custom"].cell(1, 2).border.bottom.border_style == "thin"
assert wb["custom"].cell(1, 2).border.top.border_style == "thin"
assert wb["custom"].cell(1, 2).border.left.border_style == "thin"
assert wb["custom"].cell(1, 2).border.right.border_style == "thin"
assert wb["default"].cell(1, 2).border.bottom.border_style is None
assert wb["default"].cell(1, 2).border.top.border_style is None
assert wb["default"].cell(1, 2).border.left.border_style is None
assert wb["default"].cell(1, 2).border.right.border_style is None


def test_styler_custom_values():
# GH 53973
openpyxl = pytest.importorskip("openpyxl")
pd.io.formats.excel.ExcelFormatter.header_style = {
"font": {"bold": True},
"borders": {
"top": "thin",
"right": "thin",
"bottom": "thin",
"left": "thin",
},
"alignment": {"horizontal": "center", "vertical": "top"},
}

pd.io.formats.excel.ExcelFormatter.body_style = {
"font": {"bold": False},
"borders": {
"top": "thin",
"right": "thin",
"bottom": "thin",
"left": "thin",
},
"alignment": {"horizontal": "right", "vertical": "top"},
}

df = DataFrame([{"A": 1, "B": 2, "C": 3}, {"A": 1, "B": 2, "C": 3}])
with tm.ensure_clean(".xlsx") as path:
with ExcelWriter(path, engine="openpyxl") as writer:
df.to_excel(writer, sheet_name="custom")

with contextlib.closing(openpyxl.load_workbook(path)) as wb:
# Check font, spacing, indentation
assert wb["custom"].cell(1, 2).font.bold is True
assert wb["custom"].cell(2, 2).font.bold is False
assert wb["custom"].cell(1, 2).alignment.horizontal == "center"
assert wb["custom"].cell(1, 2).alignment.vertical == "top"
assert wb["custom"].cell(2, 2).alignment.horizontal == "right"
assert wb["custom"].cell(2, 2).alignment.vertical == "top"

# Check border
assert wb["custom"].cell(1, 2).border.bottom.border_style == "thin"
assert wb["custom"].cell(1, 2).border.top.border_style == "thin"
assert wb["custom"].cell(1, 2).border.left.border_style == "thin"
assert wb["custom"].cell(1, 2).border.right.border_style == "thin"
assert wb["custom"].cell(2, 2).border.bottom.border_style == "thin"
assert wb["custom"].cell(2, 2).border.top.border_style == "thin"
assert wb["custom"].cell(2, 2).border.left.border_style == "thin"
assert wb["custom"].cell(2, 2).border.right.border_style == "thin"


def test_styler_default_values():
# GH 53973
openpyxl = pytest.importorskip("openpyxl")

df = DataFrame([{"A": 1, "B": 2, "C": 3}, {"A": 1, "B": 2, "C": 3}])
with tm.ensure_clean(".xlsx") as path:
with ExcelWriter(path, engine="openpyxl") as writer:
df.to_excel(writer, sheet_name="custom")

with contextlib.closing(openpyxl.load_workbook(path)) as wb:
# Check font, spacing, indentation
assert wb["custom"].cell(1, 1).font.bold is False
assert wb["custom"].cell(1, 1).alignment.horizontal is None
assert wb["custom"].cell(1, 1).alignment.vertical is None

# Check border
assert wb["custom"].cell(1, 1).border.bottom.color is None
assert wb["custom"].cell(1, 1).border.top.color is None
assert wb["custom"].cell(1, 1).border.left.color is None
assert wb["custom"].cell(1, 1).border.right.color is None


def test_styler_custom_converter():
openpyxl = pytest.importorskip("openpyxl")

Expand Down
Loading