From fd851c2cf5da31a9e65bb0fb97ba00516d0def6a Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Sun, 2 Jul 2023 14:56:56 -0400 Subject: [PATCH 01/12] Adding logic for implementation, unit tests, and updated documentation. --- doc/source/whatsnew/v2.1.0.rst | 1 + pandas/io/formats/excel.py | 20 ++++++-------- pandas/tests/io/excel/test_style.py | 43 +++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/doc/source/whatsnew/v2.1.0.rst b/doc/source/whatsnew/v2.1.0.rst index ebbdbcb0f61f5..e192bc334158e 100644 --- a/doc/source/whatsnew/v2.1.0.rst +++ b/doc/source/whatsnew/v2.1.0.rst @@ -119,6 +119,7 @@ Other enhancements - 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`) +- Made `ExcelFormatter.header_style` a class attribute instead of a property. Default styles for :meth:`DataFrameGroupby.to_excel` are set to None. (:issue:`52369`) - .. --------------------------------------------------------------------------- diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 0dbb6529cd384..d28a6be97151e 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -577,19 +577,15 @@ def __init__( self.header = header self.merge_cells = merge_cells self.inf_rep = inf_rep + self.header_style = None + self.body_style = 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 set_header_style(self, style): + if not isinstance(style, dict): + self.header_style = None + else: + self.header_style = style + return self.header_style def _format_value(self, val): if is_scalar(val) and missing.isna(val): diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index 710f1f272cd7f..8c76e14209ded 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -30,6 +30,31 @@ def assert_equal_cell_styles(cell1, cell2): assert cell1.number_format == cell2.number_format assert cell1.protection.__dict__ == cell2.protection.__dict__ +def test_styler_to_excel_set_instance(): + style = { + "font": {"bold": True}, + "borders": { + "top": "thin", + "right": "thin", + "bottom": "thin", + "left": "thin", + }, + "alignment": {"horizontal": "center", "vertical": "top"}, + } + + df = DataFrame([{'A': 1, 'B': 2, 'C': 3}, {'A': 1, 'B': 2, 'C': 3}]) + fmt = ExcelFormatter(df) + + # Check default value + assert fmt.header_style == None + + # Check updated value + header_style = fmt.set_body_style(style) + assert header_style == style + + # Check value for invalid input + header_style = fmt.set_body_style('abcd') + assert header_style == None @pytest.mark.parametrize( "engine", @@ -253,6 +278,24 @@ def test_styler_to_excel_border_style(engine, border_style): assert u_cell is None or u_cell != expected assert s_cell == expected +def test_styler_change_values(): + openpyxl = pytest.importorskip("openpyxl") + font1 = {"font": {"color": {"rgb": "111222"}}} + font2 = {"font": {"color": {"rgb": "000222"}}} + + + df = DataFrame(np.random.randn(1, 1)) + with tm.ensure_clean(".xlsx") as path: + with ExcelWriter(path, engine="openpyxl") as writer: + fil = ExcelFormatter(df) + fil.set_body_style(font1) + fil.write(writer, sheet_name="custom") + + with contextlib.closing(openpyxl.load_workbook(path)) as wb: + assert wb["custom"].cell(2, 2).font.color.value == "00111222" + + + def test_styler_custom_converter(): openpyxl = pytest.importorskip("openpyxl") From 22fe8dac47eed07eaab4923c65a56942722c542b Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Sun, 2 Jul 2023 15:03:14 -0400 Subject: [PATCH 02/12] Adding logic for implementation, unit tests, and updated documentation. --- doc/source/whatsnew/v2.1.0.rst | 2 +- pandas/tests/io/excel/test_style.py | 35 ++++++++++++++--------------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/doc/source/whatsnew/v2.1.0.rst b/doc/source/whatsnew/v2.1.0.rst index e192bc334158e..dbaa06d712b73 100644 --- a/doc/source/whatsnew/v2.1.0.rst +++ b/doc/source/whatsnew/v2.1.0.rst @@ -116,10 +116,10 @@ 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`) -- Made `ExcelFormatter.header_style` a class attribute instead of a property. Default styles for :meth:`DataFrameGroupby.to_excel` are set to None. (:issue:`52369`) - .. --------------------------------------------------------------------------- diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index 8c76e14209ded..d20dd5b2977ad 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -30,31 +30,33 @@ def assert_equal_cell_styles(cell1, cell2): assert cell1.number_format == cell2.number_format assert cell1.protection.__dict__ == cell2.protection.__dict__ + def test_styler_to_excel_set_instance(): style = { - "font": {"bold": True}, - "borders": { - "top": "thin", - "right": "thin", - "bottom": "thin", - "left": "thin", - }, - "alignment": {"horizontal": "center", "vertical": "top"}, - } - - df = DataFrame([{'A': 1, 'B': 2, 'C': 3}, {'A': 1, 'B': 2, 'C': 3}]) + "font": {"bold": True}, + "borders": { + "top": "thin", + "right": "thin", + "bottom": "thin", + "left": "thin", + }, + "alignment": {"horizontal": "center", "vertical": "top"}, + } + + df = DataFrame([{"A": 1, "B": 2, "C": 3}, {"A": 1, "B": 2, "C": 3}]) fmt = ExcelFormatter(df) # Check default value - assert fmt.header_style == None + assert fmt.header_style is None # Check updated value header_style = fmt.set_body_style(style) assert header_style == style # Check value for invalid input - header_style = fmt.set_body_style('abcd') - assert header_style == None + header_style = fmt.set_body_style("abcd") + assert header_style is None + @pytest.mark.parametrize( "engine", @@ -278,11 +280,10 @@ def test_styler_to_excel_border_style(engine, border_style): assert u_cell is None or u_cell != expected assert s_cell == expected + def test_styler_change_values(): openpyxl = pytest.importorskip("openpyxl") font1 = {"font": {"color": {"rgb": "111222"}}} - font2 = {"font": {"color": {"rgb": "000222"}}} - df = DataFrame(np.random.randn(1, 1)) with tm.ensure_clean(".xlsx") as path: @@ -295,8 +296,6 @@ def test_styler_change_values(): assert wb["custom"].cell(2, 2).font.color.value == "00111222" - - def test_styler_custom_converter(): openpyxl = pytest.importorskip("openpyxl") From 14b815059f05e9682d5e872cb8d9ba20cdd73306 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Sun, 2 Jul 2023 18:26:16 -0400 Subject: [PATCH 03/12] Updating implementation and unit tests --- pandas/io/formats/excel.py | 7 ---- pandas/tests/io/excel/test_style.py | 51 +++++++++-------------------- 2 files changed, 16 insertions(+), 42 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index d28a6be97151e..672b8ebc7d1ed 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -580,13 +580,6 @@ def __init__( self.header_style = None self.body_style = None - def set_header_style(self, style): - if not isinstance(style, dict): - self.header_style = None - else: - self.header_style = style - return self.header_style - def _format_value(self, val): if is_scalar(val) and missing.isna(val): val = self.na_rep diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index d20dd5b2977ad..0aacf4f82e0cf 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -15,7 +15,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. @@ -31,33 +31,6 @@ def assert_equal_cell_styles(cell1, cell2): assert cell1.protection.__dict__ == cell2.protection.__dict__ -def test_styler_to_excel_set_instance(): - style = { - "font": {"bold": True}, - "borders": { - "top": "thin", - "right": "thin", - "bottom": "thin", - "left": "thin", - }, - "alignment": {"horizontal": "center", "vertical": "top"}, - } - - df = DataFrame([{"A": 1, "B": 2, "C": 3}, {"A": 1, "B": 2, "C": 3}]) - fmt = ExcelFormatter(df) - - # Check default value - assert fmt.header_style is None - - # Check updated value - header_style = fmt.set_body_style(style) - assert header_style == style - - # Check value for invalid input - header_style = fmt.set_body_style("abcd") - assert header_style is None - - @pytest.mark.parametrize( "engine", ["xlsxwriter", "openpyxl"], @@ -281,19 +254,27 @@ def test_styler_to_excel_border_style(engine, border_style): assert s_cell == expected -def test_styler_change_values(): +def test_styler_default_values(): openpyxl = pytest.importorskip("openpyxl") - font1 = {"font": {"color": {"rgb": "111222"}}} - df = DataFrame(np.random.randn(1, 1)) + 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: - fil = ExcelFormatter(df) - fil.set_body_style(font1) - fil.write(writer, sheet_name="custom") + df.to_excel(writer, sheet_name='custom') with contextlib.closing(openpyxl.load_workbook(path)) as wb: - assert wb["custom"].cell(2, 2).font.color.value == "00111222" + # Check font, spacing, indentation + assert wb["custom"].cell(1, 1).font.color.value == 1 + assert wb["custom"].cell(1, 1).alignment.horizontal == None + assert wb["custom"].cell(1, 1).alignment.vertical == None + assert wb["custom"].cell(1, 1).alignment.indent == 0.0 + + # Check border + x = wb["custom"].cell(1, 1).border + assert wb["custom"].cell(1, 1).border.bottom.color == None + assert wb["custom"].cell(1, 1).border.top.color == None + assert wb["custom"].cell(1, 1).border.left.color == None + assert wb["custom"].cell(1, 1).border.right.color == None def test_styler_custom_converter(): From f2a9f534c3d21e1c05c13802d32336692e2d0afa Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Sun, 2 Jul 2023 18:27:28 -0400 Subject: [PATCH 04/12] Updating implementation and unit tests --- pandas/tests/io/excel/test_style.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index 0aacf4f82e0cf..cb239dc52d9ff 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -15,7 +15,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. @@ -257,24 +257,24 @@ def test_styler_to_excel_border_style(engine, border_style): def test_styler_default_values(): openpyxl = pytest.importorskip("openpyxl") - df = DataFrame([{'A': 1, 'B': 2, 'C': 3}, {'A': 1, 'B': 2, 'C': 3}]) + 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') + 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.color.value == 1 - assert wb["custom"].cell(1, 1).alignment.horizontal == None - assert wb["custom"].cell(1, 1).alignment.vertical == None + assert wb["custom"].cell(1, 1).alignment.horizontal is None + assert wb["custom"].cell(1, 1).alignment.vertical is None assert wb["custom"].cell(1, 1).alignment.indent == 0.0 # Check border - x = wb["custom"].cell(1, 1).border - assert wb["custom"].cell(1, 1).border.bottom.color == None - assert wb["custom"].cell(1, 1).border.top.color == None - assert wb["custom"].cell(1, 1).border.left.color == None - assert wb["custom"].cell(1, 1).border.right.color == None + wb["custom"].cell(1, 1).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(): From 461c05a0d21e93300e2f1aacf986a41f7a802f53 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Mon, 3 Jul 2023 21:53:43 -0400 Subject: [PATCH 05/12] Updating logic and unit tests. --- pandas/io/formats/excel.py | 28 ++++++++++++++++++++++-- pandas/tests/io/excel/test_style.py | 33 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 672b8ebc7d1ed..7806f9ad67e68 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -577,8 +577,32 @@ def __init__( self.header = header self.merge_cells = merge_cells self.inf_rep = inf_rep - self.header_style = None - self.body_style = None + self._header_style = None + self._body_style = None + + @property + def header_style(self): + return self._header_style + + @header_style.setter + def set_header_style(self, val): + if not isinstance(val, dict): + return None + else: + self._header_style = val + return self._header_style + + @property + def body_style(self): + return self._body_style + + @body_style.setter + def set_body_style(self, val): + if not isinstance(val, dict): + return None + else: + self._body_style = val + return self._body_style def _format_value(self, val): if is_scalar(val) and missing.isna(val): diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index cb239dc52d9ff..5ec9a637db643 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -6,6 +6,7 @@ import pandas.util._test_decorators as td +import pandas as pd from pandas import ( DataFrame, read_excel, @@ -254,6 +255,38 @@ def test_styler_to_excel_border_style(engine, border_style): assert s_cell == expected +def test_styler_update_values(): + 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"}, + } + + 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(1, 2).alignment.horizontal == "center" + assert wb["custom"].cell(1, 2).alignment.vertical == "top" + + # Check border + wb["custom"].cell(1, 2).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" + + def test_styler_default_values(): openpyxl = pytest.importorskip("openpyxl") From b09c3e5fb95127c40d9a4305c20cd03a108bf61a Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Tue, 4 Jul 2023 10:38:56 -0400 Subject: [PATCH 06/12] Updating implementation to fix CI test. --- pandas/io/formats/excel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 7806f9ad67e68..5f527e2bec59c 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -585,7 +585,7 @@ def header_style(self): return self._header_style @header_style.setter - def set_header_style(self, val): + def header_style(self, val): if not isinstance(val, dict): return None else: @@ -597,7 +597,7 @@ def body_style(self): return self._body_style @body_style.setter - def set_body_style(self, val): + def body_style(self, val): if not isinstance(val, dict): return None else: From 255a1bd56619c59430a512cc653aa9736cdc2321 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Tue, 4 Jul 2023 11:36:23 -0400 Subject: [PATCH 07/12] Updating implementation to fix CI test. --- pandas/io/formats/excel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 5f527e2bec59c..a88f8c2832be5 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -577,8 +577,8 @@ def __init__( self.header = header self.merge_cells = merge_cells self.inf_rep = inf_rep - self._header_style = None - self._body_style = None + self._header_style = dict[str] | None + self._body_style = dict[str] | None @property def header_style(self): From 161b3f68f264d065c52026d801e6ff1ae89cb952 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Tue, 4 Jul 2023 11:49:30 -0400 Subject: [PATCH 08/12] Fixing failed unit test. --- pandas/io/formats/excel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index a88f8c2832be5..955e311a53bd0 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -577,8 +577,8 @@ def __init__( self.header = header self.merge_cells = merge_cells self.inf_rep = inf_rep - self._header_style = dict[str] | None - self._body_style = dict[str] | None + self._header_style = {} + self._body_style = {} @property def header_style(self): From 646673e1bade96081db8f2ee52afb72fd062d0ef Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Tue, 4 Jul 2023 13:40:23 -0400 Subject: [PATCH 09/12] Fixing failed unit test. --- pandas/io/formats/excel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 955e311a53bd0..70920f7dee10f 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -577,8 +577,8 @@ def __init__( self.header = header self.merge_cells = merge_cells self.inf_rep = inf_rep - self._header_style = {} - self._body_style = {} + self._header_styledict[str, Any] = None + self._body_styledict[str, Any] = None @property def header_style(self): From c9bd6c99554ae66c186b298c1ee03fea108d8d34 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Thu, 6 Jul 2023 20:23:59 -0400 Subject: [PATCH 10/12] Updating implementation based on reviewer feedback. --- pandas/io/formats/excel.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 70920f7dee10f..f50187ca02fe2 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -577,8 +577,8 @@ def __init__( self.header = header self.merge_cells = merge_cells self.inf_rep = inf_rep - self._header_styledict[str, Any] = None - self._body_styledict[str, Any] = None + self._header_style: dict[str, Any] | None = None + self._body_style: dict[str, Any] | None = None @property def header_style(self): @@ -586,11 +586,8 @@ def header_style(self): @header_style.setter def header_style(self, val): - if not isinstance(val, dict): - return None - else: + if isinstance(val, dict): self._header_style = val - return self._header_style @property def body_style(self): @@ -598,11 +595,8 @@ def body_style(self): @body_style.setter def body_style(self, val): - if not isinstance(val, dict): - return None - else: + if isinstance(val, dict): self._body_style = val - return self._body_style def _format_value(self, val): if is_scalar(val) and missing.isna(val): From 5273500276976f7a065d3069d2612978bcfc1807 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Thu, 6 Jul 2023 21:02:35 -0400 Subject: [PATCH 11/12] Updating implementation based on reviewer feedback. --- pandas/io/formats/excel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index f50187ca02fe2..4990a271ab423 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -585,7 +585,7 @@ def header_style(self): return self._header_style @header_style.setter - def header_style(self, val): + def header_style(self, val) -> None: if isinstance(val, dict): self._header_style = val @@ -594,7 +594,7 @@ def body_style(self): return self._body_style @body_style.setter - def body_style(self, val): + def body_style(self, val) -> None: if isinstance(val, dict): self._body_style = val From 0299e72967565d2930cd63b14239cf216614b5d0 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Sat, 8 Jul 2023 20:09:08 -0400 Subject: [PATCH 12/12] Adding additional unit tests and updating documentation. --- doc/source/user_guide/io.rst | 13 +++++ pandas/io/formats/excel.py | 2 +- pandas/tests/io/excel/test_style.py | 74 +++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/doc/source/user_guide/io.rst b/doc/source/user_guide/io.rst index 0084e885db2b5..acaac910d7af0 100644 --- a/doc/source/user_guide/io.rst +++ b/doc/source/user_guide/io.rst @@ -3881,6 +3881,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},} + >>> 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 diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 4990a271ab423..f74c42260531f 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -883,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, diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index 5ec9a637db643..ab6b265b7334c 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -36,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)) @@ -256,6 +256,54 @@ def test_styler_to_excel_border_style(engine, border_style): 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}, @@ -268,6 +316,17 @@ def test_styler_update_values(): "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: @@ -276,18 +335,25 @@ def test_styler_update_values(): 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 - wb["custom"].cell(1, 2).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}]) @@ -297,13 +363,11 @@ def test_styler_default_values(): with contextlib.closing(openpyxl.load_workbook(path)) as wb: # Check font, spacing, indentation - assert wb["custom"].cell(1, 1).font.color.value == 1 + 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 - assert wb["custom"].cell(1, 1).alignment.indent == 0.0 # Check border - wb["custom"].cell(1, 1).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