From ece885514892ec377bdc4b1979d63985e5102872 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Wed, 9 Jun 2021 14:36:32 +0200 Subject: [PATCH 01/27] add apply across index --- pandas/io/formats/style.py | 67 ++++++++++++++++++++++ pandas/io/formats/style_render.py | 16 +++++- pandas/io/formats/templates/html_style.tpl | 7 +++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 93c3843b36846..91ab211eb6b59 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -915,6 +915,27 @@ def _update_ctx(self, attrs: DataFrame) -> None: i, j = self.index.get_loc(rn), self.columns.get_loc(cn) self.ctx[(i, j)].extend(css_list) + def _update_ctx_index(self, attrs: DataFrame) -> None: + """ + Update the state of the ``Styler`` for index cells. + + Collects a mapping of {index_label: [('', ''), ..]}. + + Parameters + ---------- + attrs : Series + Should contain strings of ': ;: ', and an + integer index. + Whitespace shouldn't matter and the final trailing ';' shouldn't + matter. + """ + for j in attrs.columns: + for i, c in attrs[[j]].itertuples(): + if not c: + continue + css_list = maybe_convert_css_to_tuples(c) + self.ctx_index[(i, j)].extend(css_list) + def _copy(self, deepcopy: bool = False) -> Styler: styler = Styler( self.data, @@ -1091,6 +1112,52 @@ def apply( ) return self + def _apply_index( + self, func: Callable[..., Styler], levels: list(int) | None = None, **kwargs + ) -> Styler: + if isinstance(self.index, pd.MultiIndex) and levels is not None: + levels = [levels] if isinstance(levels, int) else levels + data = DataFrame(self.index.to_list()).loc[:, levels] + else: + data = DataFrame(self.index.to_list()) + result = data.apply(func, axis=0, **kwargs) + self._update_ctx_index(result) + return self + + def apply_index( + self, + func: Callable[..., Styler], + levels: list(int) | int | None = None, + **kwargs, + ) -> Styler: + """ + Apply a CSS-styling function to the index. + + Updates the HTML representation with the result. + + .. versionadded:: 1.3.0 + + Parameters + ---------- + func : function + ``func`` should take a Series + + .. versionchanged:: 1.3.0 + + levels : int, list of ints, optional + If index is MultiIndex the level(s) over which to apply the function. + **kwargs : dict + Pass along to ``func``. + + Returns + ------- + self : Styler + """ + self._todo.append( + (lambda instance: getattr(instance, "_apply_index"), (func, levels), kwargs) + ) + return self + def _applymap( self, func: Callable, subset: Subset | None = None, **kwargs ) -> Styler: diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index 7686d8a340c37..55d3900b0ef80 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -100,6 +100,7 @@ def __init__( self.hidden_index: bool = False self.hidden_columns: Sequence[int] = [] self.ctx: DefaultDict[tuple[int, int], CSSList] = defaultdict(list) + self.ctx_index: DefaultDict[tuple[int, int], CSSList] = defaultdict(list) self.cell_context: DefaultDict[tuple[int, int], str] = defaultdict(str) self._todo: list[tuple[Callable, tuple, dict]] = [] self.tooltips: Tooltips | None = None @@ -146,6 +147,7 @@ def _compute(self): (application method, *args, **kwargs) """ self.ctx.clear() + self.ctx_index.clear() r = self for func, args, kwargs in self._todo: r = func(self)(*args, **kwargs) @@ -209,6 +211,9 @@ def _translate(self, sparse_index: bool, sparse_cols: bool, blank: str = "  self.cellstyle_map: DefaultDict[tuple[CSSPair, ...], list[str]] = defaultdict( list ) + self.cellstyle_map_index: DefaultDict[ + tuple[CSSPair, ...], list[str] + ] = defaultdict(list) body = self._translate_body( DATA_CLASS, ROW_HEADING_CLASS, @@ -224,7 +229,11 @@ def _translate(self, sparse_index: bool, sparse_cols: bool, blank: str = "  {"props": list(props), "selectors": selectors} for props, selectors in self.cellstyle_map.items() ] - d.update({"cellstyle": cellstyle}) + cellstyle_index: list[dict[str, CSSList | list[str]]] = [ + {"props": list(props), "selectors": selectors} + for props, selectors in self.cellstyle_map_index.items() + ] + d.update({"cellstyle": cellstyle, "cellstyle_index": cellstyle_index}) table_attr = self.table_attributes use_mathjax = get_option("display.html.use_mathjax") @@ -472,6 +481,11 @@ def _translate_body( ) for c, value in enumerate(rlabels[r]) ] + for c, _ in enumerate(rlabels[r]): # add for index css id styling + if (r, c) in self.ctx_index and self.ctx_index[r, c]: # if non-empty + self.cellstyle_map_index[tuple(self.ctx_index[r, c])].append( + f"level{c}_row{r}" + ) data = [] for c, value in enumerate(row_tup[1:]): diff --git a/pandas/io/formats/templates/html_style.tpl b/pandas/io/formats/templates/html_style.tpl index b34893076bedd..5873b1c909a63 100644 --- a/pandas/io/formats/templates/html_style.tpl +++ b/pandas/io/formats/templates/html_style.tpl @@ -19,6 +19,13 @@ {% endfor %} } {% endfor %} +{% for s in cellstyle_index %} +{% for selector in s.selectors %}{% if not loop.first %}, {% endif %}#T_{{uuid}}{{selector}}{% endfor %} { +{% for p,val in s.props %} + {{p}}: {{val}}; +{% endfor %} +} +{% endfor %} {% endblock cellstyle %} {% endblock style %} From a3a88e5cc0a191ed3fa4e0865b9cbb3541534dff Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Wed, 9 Jun 2021 14:48:19 +0200 Subject: [PATCH 02/27] add applymap across index --- pandas/io/formats/style.py | 60 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 91ab211eb6b59..a1ae42eaa865f 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -1113,7 +1113,10 @@ def apply( return self def _apply_index( - self, func: Callable[..., Styler], levels: list(int) | None = None, **kwargs + self, + func: Callable[..., Styler], + levels: list[int] | int | None = None, + **kwargs, ) -> Styler: if isinstance(self.index, pd.MultiIndex) and levels is not None: levels = [levels] if isinstance(levels, int) else levels @@ -1127,7 +1130,7 @@ def _apply_index( def apply_index( self, func: Callable[..., Styler], - levels: list(int) | int | None = None, + levels: list[int] | int | None = None, **kwargs, ) -> Styler: """ @@ -1224,6 +1227,59 @@ def applymap( ) return self + def _applymap_index( + self, + func: Callable[..., Styler], + levels: list[int] | int | None = None, + **kwargs, + ) -> Styler: + if isinstance(self.index, pd.MultiIndex) and levels is not None: + levels = [levels] if isinstance(levels, int) else levels + data = DataFrame(self.index.to_list()).loc[:, levels] + else: + data = DataFrame(self.index.to_list()) + result = data.applymap(func, **kwargs) + self._update_ctx_index(result) + return self + + def applymap_index( + self, + func: Callable[..., Styler], + levels: list[int] | int | None = None, + **kwargs, + ) -> Styler: + """ + Apply a CSS-styling function to the index, element-wise. + + Updates the HTML representation with the result. + + .. versionadded:: 1.3.0 + + Parameters + ---------- + func : function + ``func`` should take a Series + + .. versionchanged:: 1.3.0 + + levels : int, list of ints, optional + If index is MultiIndex the level(s) over which to apply the function. + **kwargs : dict + Pass along to ``func``. + + Returns + ------- + self : Styler + """ + self._todo.append( + ( + lambda instance: getattr(instance, "_applymap_index"), + (func, levels), + kwargs, + ) + ) + return self + def where( self, cond: Callable, From 066e4f3b910769b206668099426f6909c057e54d Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Wed, 9 Jun 2021 16:06:40 +0200 Subject: [PATCH 03/27] improve docs --- pandas/io/formats/style.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index a1ae42eaa865f..25c2779be90de 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -1138,15 +1138,12 @@ def apply_index( Updates the HTML representation with the result. - .. versionadded:: 1.3.0 + .. versionadded:: 1.4.0 Parameters ---------- func : function - ``func`` should take a Series - - .. versionchanged:: 1.3.0 - + ``func`` should take a Series, being the index or level of a MultiIndex. levels : int, list of ints, optional If index is MultiIndex the level(s) over which to apply the function. **kwargs : dict @@ -1253,15 +1250,12 @@ def applymap_index( Updates the HTML representation with the result. - .. versionadded:: 1.3.0 + .. versionadded:: 1.4.0 Parameters ---------- func : function ``func`` should take a Series - - .. versionchanged:: 1.3.0 - levels : int, list of ints, optional If index is MultiIndex the level(s) over which to apply the function. **kwargs : dict From 5e4c1c0144d9825eea6b445b83b2005d77fe1417 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sat, 12 Jun 2021 18:52:44 +0200 Subject: [PATCH 04/27] add column header styling and amend tests --- pandas/io/formats/style.py | 68 ++++--- pandas/io/formats/style_render.py | 63 +++++-- pandas/io/formats/templates/html_style.tpl | 9 +- pandas/io/formats/templates/html_table.tpl | 4 +- pandas/tests/io/formats/style/test_html.py | 9 +- pandas/tests/io/formats/style/test_style.py | 194 +++++--------------- 6 files changed, 137 insertions(+), 210 deletions(-) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 25c2779be90de..b714a4c41df31 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -915,7 +915,7 @@ def _update_ctx(self, attrs: DataFrame) -> None: i, j = self.index.get_loc(rn), self.columns.get_loc(cn) self.ctx[(i, j)].extend(css_list) - def _update_ctx_index(self, attrs: DataFrame) -> None: + def _update_ctx_header(self, attrs: DataFrame, axis: str) -> None: """ Update the state of the ``Styler`` for index cells. @@ -928,13 +928,18 @@ def _update_ctx_index(self, attrs: DataFrame) -> None: integer index. Whitespace shouldn't matter and the final trailing ';' shouldn't matter. + axis : str + Identifies whether the ctx object being updated is the index or columns """ for j in attrs.columns: for i, c in attrs[[j]].itertuples(): if not c: continue css_list = maybe_convert_css_to_tuples(c) - self.ctx_index[(i, j)].extend(css_list) + if axis == "index": + self.ctx_index[(i, j)].extend(css_list) + else: + self.ctx_columns[(j, i)].extend(css_list) def _copy(self, deepcopy: bool = False) -> Styler: styler = Styler( @@ -1112,24 +1117,41 @@ def apply( ) return self - def _apply_index( + def _apply_header( self, func: Callable[..., Styler], + axis: int | str = 0, levels: list[int] | int | None = None, + method: str = "apply", **kwargs, ) -> Styler: - if isinstance(self.index, pd.MultiIndex) and levels is not None: + if axis in [0, "index"]: + obj, axis = self.index, "index" + elif axis in [1, "columns"]: + obj, axis = self.columns, "columns" + else: + raise ValueError( + f"`axis` must be one of 0, 1, 'index', 'columns', got {axis}" + ) + + if isinstance(obj, pd.MultiIndex) and levels is not None: levels = [levels] if isinstance(levels, int) else levels - data = DataFrame(self.index.to_list()).loc[:, levels] + data = DataFrame(obj.to_list()).loc[:, levels] else: - data = DataFrame(self.index.to_list()) - result = data.apply(func, axis=0, **kwargs) - self._update_ctx_index(result) + data = DataFrame(obj.to_list()) + + if method == "apply": + result = data.apply(func, axis=0, **kwargs) + elif method == "applymap": + result = data.applymap(func, **kwargs) + + self._update_ctx_header(result, axis) return self - def apply_index( + def apply_header( self, func: Callable[..., Styler], + axis: int | str = 0, levels: list[int] | int | None = None, **kwargs, ) -> Styler: @@ -1154,7 +1176,11 @@ def apply_index( self : Styler """ self._todo.append( - (lambda instance: getattr(instance, "_apply_index"), (func, levels), kwargs) + ( + lambda instance: getattr(instance, "_apply_header"), + (func, axis, levels, "apply"), + kwargs, + ) ) return self @@ -1224,24 +1250,10 @@ def applymap( ) return self - def _applymap_index( - self, - func: Callable[..., Styler], - levels: list[int] | int | None = None, - **kwargs, - ) -> Styler: - if isinstance(self.index, pd.MultiIndex) and levels is not None: - levels = [levels] if isinstance(levels, int) else levels - data = DataFrame(self.index.to_list()).loc[:, levels] - else: - data = DataFrame(self.index.to_list()) - result = data.applymap(func, **kwargs) - self._update_ctx_index(result) - return self - - def applymap_index( + def applymap_header( self, func: Callable[..., Styler], + axis: int | str = 0, levels: list[int] | int | None = None, **kwargs, ) -> Styler: @@ -1267,8 +1279,8 @@ def applymap_index( """ self._todo.append( ( - lambda instance: getattr(instance, "_applymap_index"), - (func, levels), + lambda instance: getattr(instance, "_apply_header"), + (func, axis, levels, "applymap"), kwargs, ) ) diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index 55d3900b0ef80..9f8ecaa8a4925 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -101,6 +101,7 @@ def __init__( self.hidden_columns: Sequence[int] = [] self.ctx: DefaultDict[tuple[int, int], CSSList] = defaultdict(list) self.ctx_index: DefaultDict[tuple[int, int], CSSList] = defaultdict(list) + self.ctx_columns: DefaultDict[tuple[int, int], CSSList] = defaultdict(list) self.cell_context: DefaultDict[tuple[int, int], str] = defaultdict(str) self._todo: list[tuple[Callable, tuple, dict]] = [] self.tooltips: Tooltips | None = None @@ -148,6 +149,7 @@ def _compute(self): """ self.ctx.clear() self.ctx_index.clear() + self.ctx_columns.clear() r = self for func, args, kwargs in self._todo: r = func(self)(*args, **kwargs) @@ -197,6 +199,9 @@ def _translate(self, sparse_index: bool, sparse_cols: bool, blank: str = "  len(self.data.index), len(self.data.columns), max_elements ) + self.cellstyle_map_columns: DefaultDict[ + tuple[CSSPair, ...], list[str] + ] = defaultdict(list) head = self._translate_header( BLANK_CLASS, BLANK_VALUE, @@ -233,7 +238,17 @@ def _translate(self, sparse_index: bool, sparse_cols: bool, blank: str = "  {"props": list(props), "selectors": selectors} for props, selectors in self.cellstyle_map_index.items() ] - d.update({"cellstyle": cellstyle, "cellstyle_index": cellstyle_index}) + cellstyle_columns: list[dict[str, CSSList | list[str]]] = [ + {"props": list(props), "selectors": selectors} + for props, selectors in self.cellstyle_map_columns.items() + ] + d.update( + { + "cellstyle": cellstyle, + "cellstyle_index": cellstyle_index, + "cellstyle_columns": cellstyle_columns, + } + ) table_attr = self.table_attributes use_mathjax = get_option("display.html.use_mathjax") @@ -322,8 +337,9 @@ def _translate_header( ] if clabels: - column_headers = [ - _element( + column_headers = [] + for c, value in enumerate(clabels[r]): + header_element = _element( "th", f"{col_heading_class} level{r} col{c}", value, @@ -334,8 +350,16 @@ def _translate_header( else "" ), ) - for c, value in enumerate(clabels[r]) - ] + + if self.cell_ids: + header_element["id"] = f"level{r}_col{c}" + if (r, c) in self.ctx_columns and self.ctx_columns[r, c]: + header_element["id"] = f"level{r}_col{c}" + self.cellstyle_map_columns[ + tuple(self.ctx_columns[r, c]) + ].append(f"level{r}_col{c}") + + column_headers.append(header_element) if len(self.data.columns) > max_cols: # add an extra column with `...` value to indicate trimming @@ -466,27 +490,31 @@ def _translate_body( body.append(index_headers + data) break - index_headers = [ - _element( + index_headers = [] + for c, value in enumerate(rlabels[r]): + header_element = _element( "th", f"{row_heading_class} level{c} row{r}", value, (_is_visible(r, c, idx_lengths) and not self.hidden_index), - id=f"level{c}_row{r}", attributes=( f'rowspan="{idx_lengths.get((c, r), 0)}"' if idx_lengths.get((c, r), 0) > 1 else "" ), ) - for c, value in enumerate(rlabels[r]) - ] - for c, _ in enumerate(rlabels[r]): # add for index css id styling - if (r, c) in self.ctx_index and self.ctx_index[r, c]: # if non-empty + + if self.cell_ids: + header_element["id"] = f"level{c}_row{r}" # id is specified + if (r, c) in self.ctx_index and self.ctx_index[r, c]: + # always add id if a style is specified + header_element["id"] = f"level{c}_row{r}" self.cellstyle_map_index[tuple(self.ctx_index[r, c])].append( f"level{c}_row{r}" ) + index_headers.append(header_element) + data = [] for c, value in enumerate(row_tup[1:]): if c >= max_cols: @@ -515,13 +543,12 @@ def _translate_body( display_value=self._display_funcs[(r, c)](value), ) - # only add an id if the cell has a style - if self.cell_ids or (r, c) in self.ctx: + if self.cell_ids: data_element["id"] = f"row{r}_col{c}" - if (r, c) in self.ctx and self.ctx[r, c]: # only add if non-empty - self.cellstyle_map[tuple(self.ctx[r, c])].append( - f"row{r}_col{c}" - ) + if (r, c) in self.ctx and self.ctx[r, c]: + # always add id if needed due to specified style + data_element["id"] = f"row{r}_col{c}" + self.cellstyle_map[tuple(self.ctx[r, c])].append(f"row{r}_col{c}") data.append(data_element) diff --git a/pandas/io/formats/templates/html_style.tpl b/pandas/io/formats/templates/html_style.tpl index 5873b1c909a63..5b0e7a2ed882b 100644 --- a/pandas/io/formats/templates/html_style.tpl +++ b/pandas/io/formats/templates/html_style.tpl @@ -12,19 +12,14 @@ {% endblock table_styles %} {% block before_cellstyle %}{% endblock before_cellstyle %} {% block cellstyle %} -{% for s in cellstyle %} +{% for cs in [cellstyle, cellstyle_index, cellstyle_columns] %} +{% for s in cs %} {% for selector in s.selectors %}{% if not loop.first %}, {% endif %}#T_{{uuid}}{{selector}}{% endfor %} { {% for p,val in s.props %} {{p}}: {{val}}; {% endfor %} } {% endfor %} -{% for s in cellstyle_index %} -{% for selector in s.selectors %}{% if not loop.first %}, {% endif %}#T_{{uuid}}{{selector}}{% endfor %} { -{% for p,val in s.props %} - {{p}}: {{val}}; -{% endfor %} -} {% endfor %} {% endblock cellstyle %} diff --git a/pandas/io/formats/templates/html_table.tpl b/pandas/io/formats/templates/html_table.tpl index 33153af6f0882..3e3a40b9fdaa6 100644 --- a/pandas/io/formats/templates/html_table.tpl +++ b/pandas/io/formats/templates/html_table.tpl @@ -27,7 +27,7 @@ {% else %} {% for c in r %} {% if c.is_visible != False %} - <{{c.type}} class="{{c.class}}" {{c.attributes}}>{{c.value}} + <{{c.type}} {%- if c.id is defined %} id="T_{{uuid}}{{c.id}}" {%- endif %} class="{{c.class}}" {{c.attributes}}>{{c.value}} {% endif %} {% endfor %} {% endif %} @@ -49,7 +49,7 @@ {% endif %}{% endfor %} {% else %} {% for c in r %}{% if c.is_visible != False %} - <{{c.type}} {% if c.id is defined -%} id="T_{{uuid}}{{c.id}}" {%- endif %} class="{{c.class}}" {{c.attributes}}>{{c.display_value}} + <{{c.type}} {%- if c.id is defined %} id="T_{{uuid}}{{c.id}}" {%- endif %} class="{{c.class}}" {{c.attributes}}>{{c.display_value}} {% endif %}{% endfor %} {% endif %} diff --git a/pandas/tests/io/formats/style/test_html.py b/pandas/tests/io/formats/style/test_html.py index 74b4c7ea3977c..29bcf339e5a56 100644 --- a/pandas/tests/io/formats/style/test_html.py +++ b/pandas/tests/io/formats/style/test_html.py @@ -97,7 +97,7 @@ def test_w3_html_format(styler):   - A + A @@ -127,10 +127,7 @@ def test_rowspan_w3(): # GH 38533 df = DataFrame(data=[[1, 2]], index=[["l0", "l0"], ["l1a", "l1b"]]) styler = Styler(df, uuid="_", cell_ids=False) - assert ( - 'l0' in styler.render() - ) + assert 'l0' in styler.render() def test_styles(styler): @@ -154,7 +151,7 @@ def test_styles(styler):   - A + A diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 281170ab6c7cb..61ebb1eb09f8e 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -393,161 +393,58 @@ def test_empty_index_name_doesnt_display(self): # https://github.com/pandas-dev/pandas/pull/12090#issuecomment-180695902 df = DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) result = df.style._translate(True, True) - - expected = [ - [ - { - "class": "blank level0", - "type": "th", - "value": self.blank_value, - "is_visible": True, - "display_value": self.blank_value, - }, - { - "class": "col_heading level0 col0", - "display_value": "A", - "type": "th", - "value": "A", - "is_visible": True, - "attributes": "", - }, - { - "class": "col_heading level0 col1", - "display_value": "B", - "type": "th", - "value": "B", - "is_visible": True, - "attributes": "", - }, - { - "class": "col_heading level0 col2", - "display_value": "C", - "type": "th", - "value": "C", - "is_visible": True, - "attributes": "", - }, - ] - ] - - assert result["head"] == expected + assert len(result["head"]) == 1 + expected = { + "class": "blank level0", + "type": "th", + "value": self.blank_value, + "is_visible": True, + "display_value": self.blank_value, + } + assert expected.items() <= result["head"][0][0].items() def test_index_name(self): # https://github.com/pandas-dev/pandas/issues/11655 - # TODO: this test can be minimised to address the test more directly df = DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) result = df.set_index("A").style._translate(True, True) - - expected = [ - [ - { - "class": "blank level0", - "type": "th", - "value": self.blank_value, - "display_value": self.blank_value, - "is_visible": True, - }, - { - "class": "col_heading level0 col0", - "type": "th", - "value": "B", - "display_value": "B", - "is_visible": True, - "attributes": "", - }, - { - "class": "col_heading level0 col1", - "type": "th", - "value": "C", - "display_value": "C", - "is_visible": True, - "attributes": "", - }, - ], - [ - { - "class": "index_name level0", - "type": "th", - "value": "A", - "is_visible": True, - "display_value": "A", - }, - { - "class": "blank col0", - "type": "th", - "value": self.blank_value, - "is_visible": True, - "display_value": self.blank_value, - }, - { - "class": "blank col1", - "type": "th", - "value": self.blank_value, - "is_visible": True, - "display_value": self.blank_value, - }, - ], - ] - - assert result["head"] == expected + expected = { + "class": "index_name level0", + "type": "th", + "value": "A", + "is_visible": True, + "display_value": "A", + } + assert expected.items() <= result["head"][1][0].items() def test_multiindex_name(self): # https://github.com/pandas-dev/pandas/issues/11655 - # TODO: this test can be minimised to address the test more directly df = DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) result = df.set_index(["A", "B"]).style._translate(True, True) expected = [ - [ - { - "class": "blank", - "type": "th", - "value": self.blank_value, - "display_value": self.blank_value, - "is_visible": True, - }, - { - "class": "blank level0", - "type": "th", - "value": self.blank_value, - "display_value": self.blank_value, - "is_visible": True, - }, - { - "class": "col_heading level0 col0", - "type": "th", - "value": "C", - "display_value": "C", - "is_visible": True, - "attributes": "", - }, - ], - [ - { - "class": "index_name level0", - "type": "th", - "value": "A", - "is_visible": True, - "display_value": "A", - }, - { - "class": "index_name level1", - "type": "th", - "value": "B", - "is_visible": True, - "display_value": "B", - }, - { - "class": "blank col0", - "type": "th", - "value": self.blank_value, - "is_visible": True, - "display_value": self.blank_value, - }, - ], + { + "class": "index_name level0", + "type": "th", + "value": "A", + "is_visible": True, + "display_value": "A", + }, + { + "class": "index_name level1", + "type": "th", + "value": "B", + "is_visible": True, + "display_value": "B", + }, + { + "class": "blank col0", + "type": "th", + "value": self.blank_value, + "is_visible": True, + "display_value": self.blank_value, + }, ] - - assert result["head"] == expected + assert result["head"][1] == expected def test_numeric_columns(self): # https://github.com/pandas-dev/pandas/issues/12125 @@ -1064,7 +961,6 @@ def test_mi_sparse_index_names(self): assert head == expected def test_mi_sparse_column_names(self): - # TODO this test is verbose - could be minimised df = DataFrame( np.arange(16).reshape(4, 4), index=MultiIndex.from_arrays( @@ -1075,7 +971,7 @@ def test_mi_sparse_column_names(self): [["C1", "C1", "C2", "C2"], [1, 0, 1, 0]], names=["col_0", "col_1"] ), ) - result = df.style._translate(True, True) + result = Styler(df, cell_ids=False)._translate(True, True) head = result["head"][1] expected = [ { @@ -1265,7 +1161,7 @@ def test_no_cell_ids(self): styler = Styler(df, uuid="_", cell_ids=False) styler.render() s = styler.render() # render twice to ensure ctx is not updated - assert s.find('') != -1 + assert s.find('') != -1 @pytest.mark.parametrize( "classes", @@ -1283,10 +1179,10 @@ def test_set_data_classes(self, classes): # GH 36159 df = DataFrame(data=[[0, 1], [2, 3]], columns=["A", "B"], index=["a", "b"]) s = Styler(df, uuid_len=0, cell_ids=False).set_td_classes(classes).render() - assert '0' in s - assert '1' in s - assert '2' in s - assert '3' in s + assert '0' in s + assert '1' in s + assert '2' in s + assert '3' in s # GH 39317 s = Styler(df, uuid_len=0, cell_ids=True).set_td_classes(classes).render() assert '0' in s From 20ac7e0c9b590ec14e8c8f681959bd939cd00370 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 13 Jun 2021 09:15:20 +0200 Subject: [PATCH 05/27] doc sharing --- doc/source/_static/style/appmaphead1.png | Bin 0 -> 5023 bytes doc/source/_static/style/appmaphead2.png | Bin 0 -> 7641 bytes doc/source/reference/style.rst | 2 + pandas/io/formats/style.py | 120 +++++++++++++++-------- 4 files changed, 83 insertions(+), 39 deletions(-) create mode 100644 doc/source/_static/style/appmaphead1.png create mode 100644 doc/source/_static/style/appmaphead2.png diff --git a/doc/source/_static/style/appmaphead1.png b/doc/source/_static/style/appmaphead1.png new file mode 100644 index 0000000000000000000000000000000000000000..905bcaa63e900b8577812b75eb604580150fa3c1 GIT binary patch literal 5023 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 yED!Hl0RI0c){#0$Z}XZ}wz2~g#Q=QR$dJN literal 0 HcmV?d00001 diff --git a/doc/source/_static/style/appmaphead2.png b/doc/source/_static/style/appmaphead2.png new file mode 100644 index 0000000000000000000000000000000000000000..9adde619083782a62984ed6e55b24ed9fcaf14bb GIT binary patch literal 7641 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 NQBDm~A!GLb{{UlZLE!)Z literal 0 HcmV?d00001 diff --git a/doc/source/reference/style.rst b/doc/source/reference/style.rst index 5a2ff803f0323..a085d1eff4e7a 100644 --- a/doc/source/reference/style.rst +++ b/doc/source/reference/style.rst @@ -34,6 +34,8 @@ Style application Styler.apply Styler.applymap + Styler.apply_header + Styler.applymap_header Styler.where Styler.format Styler.set_td_classes diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index b714a4c41df31..dfaa61179314d 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -1081,6 +1081,8 @@ def apply( See Also -------- + Styler.applymap_header: Apply a CSS-styling function to headers elementwise. + Styler.apply_header: Apply a CSS-styling function to headers level-wise. Styler.applymap: Apply a CSS-styling function elementwise. Notes @@ -1148,6 +1150,17 @@ def _apply_header( self._update_ctx_header(result, axis) return self + @doc( + this="apply", + alt="applymap", + wise="level-wise", + func="take a Series and return a string array of the same length", + input_note="the index as a Series, if an Index, or a level of a MultiIndex", + output_note="an identically sized array of CSS styles as strings", + var="s", + ret='np.where(s == "B", "background-color: yellow;", "")', + ret2='np.where(["x" in v for v in s], "background-color: yellow;", "")', + ) def apply_header( self, func: Callable[..., Styler], @@ -1156,7 +1169,7 @@ def apply_header( **kwargs, ) -> Styler: """ - Apply a CSS-styling function to the index. + Apply a CSS-styling function to the index, {wise}. Updates the HTML representation with the result. @@ -1165,7 +1178,9 @@ def apply_header( Parameters ---------- func : function - ``func`` should take a Series, being the index or level of a MultiIndex. + ``func`` should {func}. + axis : {0, 1, "index", "columns"} + The headers over which to apply the function. levels : int, list of ints, optional If index is MultiIndex the level(s) over which to apply the function. **kwargs : dict @@ -1174,6 +1189,39 @@ def apply_header( Returns ------- self : Styler + + See Also + -------- + Styler.{alt}_header: Apply a CSS-styling function to headers {wise}. + Styler.apply: Apply a CSS-styling function column-wise, row-wise, or table-wise. + Styler.applymap: Apply a CSS-styling function elementwise. + + Notes + ----- + Each input to ``func`` will be {input_note}. The output of ``func`` should be + {output_note}, in the format 'attribute: value; attribute2: value2; ...' + or, if nothing is to be applied to that element, an empty string or ``None``. + + Examples + -------- + Basic usage to conditionally highlight values in the index. + + >>> df = pd.DataFrame([[1,2], [3,4]], index=["A", "B"]) + >>> def color_b(s): + ... return {ret} + >>> df.style.{this}_header(color_b) + + .. figure:: ../../_static/style/appmaphead1.png + + Selectively applying to specific levels of MultiIndex columns. + + >>> midx = pd.MultiIndex.from_product([['ix', 'jy'], [0, 1], ['x3', 'z4']]) + >>> df = pd.DataFrame([np.arange(8)], columns=midx) + >>> def highlight_x({var}): + ... return {ret2} + >>> df.style.{this}_header(highlight_x, axis="columns", levels=[0, 2]) + + .. figure:: ../../_static/style/appmaphead1.png """ self._todo.append( ( @@ -1184,6 +1232,34 @@ def apply_header( ) return self + @doc( + apply_header, + this="applymap", + alt="apply", + wise="elementwise", + func="take a scalar and return a string", + input_note="an index value, if an Index, or a level value of a MultiIndex", + output_note="CSS styles as a string", + var="v", + ret='"background-color: yellow;" if v == "B" else None', + ret2='"background-color: yellow;" if "x" in v else None', + ) + def applymap_header( + self, + func: Callable[..., Styler], + axis: int | str = 0, + levels: list[int] | int | None = None, + **kwargs, + ) -> Styler: + self._todo.append( + ( + lambda instance: getattr(instance, "_apply_header"), + (func, axis, levels, "applymap"), + kwargs, + ) + ) + return self + def _applymap( self, func: Callable, subset: Subset | None = None, **kwargs ) -> Styler: @@ -1206,7 +1282,7 @@ def applymap( Parameters ---------- func : function - ``func`` should take a scalar and return a scalar. + ``func`` should take a scalar and return a string. subset : label, array-like, IndexSlice, optional A valid 2d input to `DataFrame.loc[]`, or, in the case of a 1d input or single key, to `DataFrame.loc[:, ]` where the columns are @@ -1220,6 +1296,8 @@ def applymap( See Also -------- + Styler.applymap_header: Apply a CSS-styling function to headers elementwise. + Styler.apply_header: Apply a CSS-styling function to headers level-wise. Styler.apply: Apply a CSS-styling function column-wise, row-wise, or table-wise. Notes @@ -1250,42 +1328,6 @@ def applymap( ) return self - def applymap_header( - self, - func: Callable[..., Styler], - axis: int | str = 0, - levels: list[int] | int | None = None, - **kwargs, - ) -> Styler: - """ - Apply a CSS-styling function to the index, element-wise. - - Updates the HTML representation with the result. - - .. versionadded:: 1.4.0 - - Parameters - ---------- - func : function - ``func`` should take a Series - levels : int, list of ints, optional - If index is MultiIndex the level(s) over which to apply the function. - **kwargs : dict - Pass along to ``func``. - - Returns - ------- - self : Styler - """ - self._todo.append( - ( - lambda instance: getattr(instance, "_apply_header"), - (func, axis, levels, "applymap"), - kwargs, - ) - ) - return self - def where( self, cond: Callable, From 26c53406fbf75b1a26f7f29b7e598705b8a3a7f2 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 13 Jun 2021 10:31:23 +0200 Subject: [PATCH 06/27] doc fix --- pandas/io/formats/style.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index dfaa61179314d..35c155a5effa2 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -1152,14 +1152,16 @@ def _apply_header( @doc( this="apply", - alt="applymap", wise="level-wise", + alt="applymap", + altwise="elementwise", func="take a Series and return a string array of the same length", + axis='{0, 1, "index", "columns"}', input_note="the index as a Series, if an Index, or a level of a MultiIndex", output_note="an identically sized array of CSS styles as strings", var="s", ret='np.where(s == "B", "background-color: yellow;", "")', - ret2='np.where(["x" in v for v in s], "background-color: yellow;", "")', + ret2='["background-color: yellow;" if "x" in v else "" for v in s]', ) def apply_header( self, @@ -1169,7 +1171,7 @@ def apply_header( **kwargs, ) -> Styler: """ - Apply a CSS-styling function to the index, {wise}. + Apply a CSS-styling function to the index or column headers, {wise}. Updates the HTML representation with the result. @@ -1179,7 +1181,7 @@ def apply_header( ---------- func : function ``func`` should {func}. - axis : {0, 1, "index", "columns"} + axis : {axis} The headers over which to apply the function. levels : int, list of ints, optional If index is MultiIndex the level(s) over which to apply the function. @@ -1192,7 +1194,7 @@ def apply_header( See Also -------- - Styler.{alt}_header: Apply a CSS-styling function to headers {wise}. + Styler.{alt}_header: Apply a CSS-styling function to headers {altwise}. Styler.apply: Apply a CSS-styling function column-wise, row-wise, or table-wise. Styler.applymap: Apply a CSS-styling function elementwise. @@ -1221,7 +1223,7 @@ def apply_header( ... return {ret2} >>> df.style.{this}_header(highlight_x, axis="columns", levels=[0, 2]) - .. figure:: ../../_static/style/appmaphead1.png + .. figure:: ../../_static/style/appmaphead2.png """ self._todo.append( ( @@ -1235,9 +1237,11 @@ def apply_header( @doc( apply_header, this="applymap", - alt="apply", wise="elementwise", + alt="apply", + altwise="level-wise", func="take a scalar and return a string", + axis='{0, 1, "index", "columns"}', input_note="an index value, if an Index, or a level value of a MultiIndex", output_note="CSS styles as a string", var="v", From 2437b7277fa9df8a61b257cc42a6c2686dc11962 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 13 Jun 2021 10:36:27 +0200 Subject: [PATCH 07/27] doc fix --- pandas/io/formats/style.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 35c155a5effa2..2171477bc0aa3 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -917,7 +917,7 @@ def _update_ctx(self, attrs: DataFrame) -> None: def _update_ctx_header(self, attrs: DataFrame, axis: str) -> None: """ - Update the state of the ``Styler`` for index cells. + Update the state of the ``Styler`` for header cells. Collects a mapping of {index_label: [('', ''), ..]}. From 312a6e624bba674d1ff40a3ce87a8accd2500e8f Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 13 Jun 2021 14:09:33 +0200 Subject: [PATCH 08/27] collapse the cellstyle maps --- pandas/io/formats/style_render.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index 9f8ecaa8a4925..9ab572654b362 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -230,25 +230,17 @@ def _translate(self, sparse_index: bool, sparse_cols: bool, blank: str = "  ) d.update({"body": body}) - cellstyle: list[dict[str, CSSList | list[str]]] = [ - {"props": list(props), "selectors": selectors} - for props, selectors in self.cellstyle_map.items() - ] - cellstyle_index: list[dict[str, CSSList | list[str]]] = [ - {"props": list(props), "selectors": selectors} - for props, selectors in self.cellstyle_map_index.items() - ] - cellstyle_columns: list[dict[str, CSSList | list[str]]] = [ - {"props": list(props), "selectors": selectors} - for props, selectors in self.cellstyle_map_columns.items() - ] - d.update( - { - "cellstyle": cellstyle, - "cellstyle_index": cellstyle_index, - "cellstyle_columns": cellstyle_columns, - } - ) + ctx_maps = { + "cellstyle": "cellstyle_map", + "cellstyle_index": "cellstyle_map_index", + "cellstyle_columns": "cellstyle_map_columns", + } # add the cell_ids styles map to the render dictionary in right format + for k, attr in ctx_maps.items(): + map = [ + {"props": list(props), "selectors": selectors} + for props, selectors in getattr(self, attr).items() + ] + d.update({k: map}) table_attr = self.table_attributes use_mathjax = get_option("display.html.use_mathjax") From 553426fef6060a386b4224726401792a7768d78e Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 13 Jun 2021 15:58:33 +0200 Subject: [PATCH 09/27] add basic test --- pandas/tests/io/formats/style/test_style.py | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 61ebb1eb09f8e..8f033f2cc7661 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -156,6 +156,33 @@ def test_render_trimming_mi(): assert {"attributes": 'colspan="2"'}.items() <= ctx["head"][0][2].items() +def test_apply_map_header_index(): + df = DataFrame({"A": [0, 0], "B": [1, 1]}, index=["C", "D"]) + func = lambda s: ["attr: val" if ("A" in v or "D" in v) else "" for v in s] + func_map = lambda v: "attr: val" if ("A" in v or "D" in v) else "" + + # test over index + result = df.style.apply_header(func, axis="index") + assert len(result._todo) == 1 + assert len(result.ctx_index) == 0 + result._compute() + result_map = df.style.applymap_header(func_map, axis="index") + expected = { + (1, 0): [("attr", "val")], + } + assert result.ctx_index == expected + assert result_map.ctx_index == expected + + # test over columns + result = df.style.apply_header(func, axis="columns")._compute() + result_map = df.style.applymap_header(func_map, axis="columns")._compute() + expected = { + (0, 0): [("attr", "val")], + } + assert result.ctx_columns == expected + assert result_map.ctx_columns == expected + + class TestStyler: def setup_method(self, method): np.random.seed(24) From f01dfee5886a1cca26c91973f331eebb57f586a4 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 13 Jun 2021 16:23:30 +0200 Subject: [PATCH 10/27] add basic test --- pandas/tests/io/formats/style/test_style.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 8f033f2cc7661..9c62f40fe2edd 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -156,17 +156,19 @@ def test_render_trimming_mi(): assert {"attributes": 'colspan="2"'}.items() <= ctx["head"][0][2].items() -def test_apply_map_header_index(): +def test_apply_map_header(): df = DataFrame({"A": [0, 0], "B": [1, 1]}, index=["C", "D"]) func = lambda s: ["attr: val" if ("A" in v or "D" in v) else "" for v in s] func_map = lambda v: "attr: val" if ("A" in v or "D" in v) else "" - # test over index + # test execution added to todo result = df.style.apply_header(func, axis="index") assert len(result._todo) == 1 assert len(result.ctx_index) == 0 + + # test over index result._compute() - result_map = df.style.applymap_header(func_map, axis="index") + result_map = df.style.applymap_header(func_map, axis="index")._compute() expected = { (1, 0): [("attr", "val")], } @@ -183,6 +185,18 @@ def test_apply_map_header_index(): assert result_map.ctx_columns == expected +@pytest.mark.parametrize("method", ["apply", "applymap"]) +@pytest.mark.parametrize("axis", ["index", "columns"]) +def test_apply_map_header_mi(mi_styler, method, axis): + func = { + "apply": lambda s: ["attr: val;" if "b" in v else "" for v in s], + "applymap": lambda v: "attr: val" if "b" in v else "", + } + result = getattr(mi_styler, method + "_header")(func[method], axis=axis)._compute() + expected = {(1, 1): [("attr", "val")]} + assert getattr(result, f"ctx_{axis}") == expected + + class TestStyler: def setup_method(self, method): np.random.seed(24) From f9401659a49cc40b3be8b6dcad03e88f676f864f Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 13 Jun 2021 22:16:56 +0200 Subject: [PATCH 11/27] parametrise tests --- pandas/tests/io/formats/style/test_style.py | 31 ++++++++------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 9c62f40fe2edd..2f9b0902b8889 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -156,33 +156,26 @@ def test_render_trimming_mi(): assert {"attributes": 'colspan="2"'}.items() <= ctx["head"][0][2].items() -def test_apply_map_header(): +@pytest.mark.parametrize("method", ["applymap", "apply"]) +@pytest.mark.parametrize("axis", ["index", "columns"]) +def test_apply_map_header(method, axis): df = DataFrame({"A": [0, 0], "B": [1, 1]}, index=["C", "D"]) - func = lambda s: ["attr: val" if ("A" in v or "D" in v) else "" for v in s] - func_map = lambda v: "attr: val" if ("A" in v or "D" in v) else "" + func = { + "apply": lambda s: ["attr: val" if ("A" in v or "C" in v) else "" for v in s], + "applymap": lambda v: "attr: val" if ("A" in v or "C" in v) else "", + } # test execution added to todo - result = df.style.apply_header(func, axis="index") + result = getattr(df.style, f"{method}_header")(func[method], axis=axis) assert len(result._todo) == 1 - assert len(result.ctx_index) == 0 + assert len(getattr(result, f"ctx_{axis}")) == 0 - # test over index + # test ctx object on compute result._compute() - result_map = df.style.applymap_header(func_map, axis="index")._compute() - expected = { - (1, 0): [("attr", "val")], - } - assert result.ctx_index == expected - assert result_map.ctx_index == expected - - # test over columns - result = df.style.apply_header(func, axis="columns")._compute() - result_map = df.style.applymap_header(func_map, axis="columns")._compute() expected = { (0, 0): [("attr", "val")], } - assert result.ctx_columns == expected - assert result_map.ctx_columns == expected + assert getattr(result, f"ctx_{axis}") == expected @pytest.mark.parametrize("method", ["apply", "applymap"]) @@ -192,7 +185,7 @@ def test_apply_map_header_mi(mi_styler, method, axis): "apply": lambda s: ["attr: val;" if "b" in v else "" for v in s], "applymap": lambda v: "attr: val" if "b" in v else "", } - result = getattr(mi_styler, method + "_header")(func[method], axis=axis)._compute() + result = getattr(mi_styler, f"{method}_header")(func[method], axis=axis)._compute() expected = {(1, 1): [("attr", "val")]} assert getattr(result, f"ctx_{axis}") == expected From 6f5b46c8071f76c8d4153e64e066b55f15449bb8 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 13 Jun 2021 22:20:09 +0200 Subject: [PATCH 12/27] test for raises ValueError --- pandas/tests/io/formats/style/test_style.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 2f9b0902b8889..c9099a18d9346 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -190,6 +190,11 @@ def test_apply_map_header_mi(mi_styler, method, axis): assert getattr(result, f"ctx_{axis}") == expected +def test_apply_map_header_raises(mi_styler): + with pytest.raises(ValueError, match="`axis` must be one of 0, 1, 'index', 'col"): + mi_styler.applymap_header(lambda v: "attr: val;", axis="bad-axis")._compute() + + class TestStyler: def setup_method(self, method): np.random.seed(24) From 75cf6ca5b95bcc0c7afd3a6665f20df97d17bc9a Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 13 Jun 2021 22:40:12 +0200 Subject: [PATCH 13/27] test html working --- pandas/tests/io/formats/style/test_html.py | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/pandas/tests/io/formats/style/test_html.py b/pandas/tests/io/formats/style/test_html.py index 29bcf339e5a56..e6779c6f2fbbb 100644 --- a/pandas/tests/io/formats/style/test_html.py +++ b/pandas/tests/io/formats/style/test_html.py @@ -233,3 +233,35 @@ def test_from_custom_template(tmpdir): def test_caption_as_sequence(styler): styler.set_caption(("full cap", "short cap")) assert "full cap" in styler.render() + + +@pytest.mark.parametrize("index", [True, False]) +@pytest.mark.parametrize("columns", [True, False]) +def test_applymap_header_cell_ids(styler, index, columns): + func = lambda v: "attr: val;" + styler.uuid, styler.cell_ids = "", False + if index: + styler.applymap_header(func, axis="index") + if columns: + styler.applymap_header(func, axis="columns") + + result = styler.to_html() + + # test no data cell ids + assert '2.610000' in result + assert '2.690000' in result + + # test index header ids where needed and css styles + assert ( + 'a' in result + ) is index + assert ( + 'b' in result + ) is index + assert ("#T_level0_row0, #T_level0_row1 {\n attr: val;\n}" in result) is index + + # test column header ids where needed and css styles + assert ( + 'A' in result + ) is columns + assert ("#T_level0_col0 {\n attr: val;\n}" in result) is columns From d6541393970d7005a548ea8c804614058d7991a2 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 13 Jun 2021 22:42:07 +0200 Subject: [PATCH 14/27] test html working --- pandas/tests/io/formats/style/test_html.py | 1 + pandas/tests/io/formats/style/test_style.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/pandas/tests/io/formats/style/test_html.py b/pandas/tests/io/formats/style/test_html.py index e6779c6f2fbbb..183cb8f4937df 100644 --- a/pandas/tests/io/formats/style/test_html.py +++ b/pandas/tests/io/formats/style/test_html.py @@ -238,6 +238,7 @@ def test_caption_as_sequence(styler): @pytest.mark.parametrize("index", [True, False]) @pytest.mark.parametrize("columns", [True, False]) def test_applymap_header_cell_ids(styler, index, columns): + # GH 41893 func = lambda v: "attr: val;" styler.uuid, styler.cell_ids = "", False if index: diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index c9099a18d9346..7db0cbfe8ef75 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -159,6 +159,7 @@ def test_render_trimming_mi(): @pytest.mark.parametrize("method", ["applymap", "apply"]) @pytest.mark.parametrize("axis", ["index", "columns"]) def test_apply_map_header(method, axis): + # GH 41893 df = DataFrame({"A": [0, 0], "B": [1, 1]}, index=["C", "D"]) func = { "apply": lambda s: ["attr: val" if ("A" in v or "C" in v) else "" for v in s], @@ -181,6 +182,7 @@ def test_apply_map_header(method, axis): @pytest.mark.parametrize("method", ["apply", "applymap"]) @pytest.mark.parametrize("axis", ["index", "columns"]) def test_apply_map_header_mi(mi_styler, method, axis): + # GH 41893 func = { "apply": lambda s: ["attr: val;" if "b" in v else "" for v in s], "applymap": lambda v: "attr: val" if "b" in v else "", @@ -191,6 +193,7 @@ def test_apply_map_header_mi(mi_styler, method, axis): def test_apply_map_header_raises(mi_styler): + # GH 41893 with pytest.raises(ValueError, match="`axis` must be one of 0, 1, 'index', 'col"): mi_styler.applymap_header(lambda v: "attr: val;", axis="bad-axis")._compute() From 17787ef66b6211e8c9ba1e20789e170c0beba5b1 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Mon, 14 Jun 2021 08:18:34 +0200 Subject: [PATCH 15/27] whats new 1.4.0 --- doc/source/whatsnew/v1.4.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst index 166ea2f0d4164..eb650ed599dcd 100644 --- a/doc/source/whatsnew/v1.4.0.rst +++ b/doc/source/whatsnew/v1.4.0.rst @@ -29,7 +29,7 @@ enhancement2 Other enhancements ^^^^^^^^^^^^^^^^^^ -- +- :meth:`.Styler.apply_header` and :meth:`.Styler.applymap_header` added to allow conditional styling of index and column header values (:issue:`41893`) - .. --------------------------------------------------------------------------- From e2240f9737435f28bbf0168b2944e5c565d6328a Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Mon, 14 Jun 2021 10:46:55 +0200 Subject: [PATCH 16/27] make apply_header compatible with to_excel --- pandas/io/formats/excel.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index b285fa5f315ed..2275652ada2a0 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -472,6 +472,7 @@ def __init__( self.na_rep = na_rep if not isinstance(df, DataFrame): self.styler = df + self.styler._compute() df = df.data if style_converter is None: style_converter = CSSToExcelConverter() @@ -606,10 +607,13 @@ def _format_header_regular(self) -> Iterable[ExcelCell]: else: colnames = self.header + styles = None if self.styler is None else self.styler.ctx_columns + xlstyle = self.header_style for colindex, colname in enumerate(colnames): - yield ExcelCell( - self.rowcounter, colindex + coloffset, colname, self.header_style - ) + if styles: + css = ";".join(a + ":" + str(v) for (a, v) in styles[0, colindex]) + xlstyle = self.style_converter(css) + yield ExcelCell(self.rowcounter, colindex + coloffset, colname, xlstyle) def _format_header(self) -> Iterable[ExcelCell]: if isinstance(self.columns, MultiIndex): @@ -667,8 +671,13 @@ def _format_regular_rows(self) -> Iterable[ExcelCell]: if isinstance(self.df.index, PeriodIndex): index_values = self.df.index.to_timestamp() + styles = None if self.styler is None else self.styler.ctx_index + xlstyle = self.header_style for idx, idxval in enumerate(index_values): - yield ExcelCell(self.rowcounter + idx, 0, idxval, self.header_style) + if styles: + css = ";".join(a + ":" + str(v) for (a, v) in styles[idx, 0]) + xlstyle = self.style_converter(css) + yield ExcelCell(self.rowcounter + idx, 0, idxval, xlstyle) coloffset = 1 else: @@ -756,19 +765,13 @@ def _has_aliases(self) -> bool: return is_list_like(self.header) def _generate_body(self, coloffset: int) -> Iterable[ExcelCell]: - if self.styler is None: - styles = None - else: - styles = self.styler._compute().ctx - if not styles: - styles = None + styles = None if self.styler is None else self.styler.ctx xlstyle = None - # Write the body of the frame data series by series. for colidx in range(len(self.columns)): series = self.df.iloc[:, colidx] for i, val in enumerate(series): - if styles is not None: + if styles: css = ";".join(a + ":" + str(v) for (a, v) in styles[i, colidx]) xlstyle = self.style_converter(css) yield ExcelCell(self.rowcounter + i, colidx + coloffset, val, xlstyle) From 6a198d25c04b1a18c38f2273e59a5ed91bb7f8b0 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Mon, 14 Jun 2021 10:48:40 +0200 Subject: [PATCH 17/27] whats new --- doc/source/whatsnew/v1.4.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst index eb650ed599dcd..97adf28575a8a 100644 --- a/doc/source/whatsnew/v1.4.0.rst +++ b/doc/source/whatsnew/v1.4.0.rst @@ -29,7 +29,7 @@ enhancement2 Other enhancements ^^^^^^^^^^^^^^^^^^ -- :meth:`.Styler.apply_header` and :meth:`.Styler.applymap_header` added to allow conditional styling of index and column header values (:issue:`41893`) +- :meth:`.Styler.apply_header` and :meth:`.Styler.applymap_header` added to allow conditional styling of index and column header values for HTML and Excel (:issue:`41893`) - .. --------------------------------------------------------------------------- From 13b5fe54aca1ecc3fbaf9e710b9889f7de064dfe Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Tue, 29 Jun 2021 19:39:00 +0200 Subject: [PATCH 18/27] add tests for new cases --- pandas/io/formats/excel.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 2275652ada2a0..7fe6243b15015 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -553,6 +553,9 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: if self.index and isinstance(self.df.index, MultiIndex): coloffset = len(self.df.index[0]) - 1 + styles = None if self.styler is None else self.styler.ctx_columns + xlstyle = self.header_style + if self.merge_cells: # Format multi-index as a merged cells. for lnum, name in enumerate(columns.names): @@ -569,11 +572,14 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: values = levels.take(level_codes) for i, span_val in spans.items(): spans_multiple_cells = span_val > 1 + if styles: + css = ";".join(a + ":" + str(v) for (a, v) in styles[lnum, i]) + xlstyle = self.style_converter(css) yield ExcelCell( row=lnum, col=coloffset + i + 1, val=values[i], - style=self.header_style, + style=xlstyle, mergestart=lnum if spans_multiple_cells else None, mergeend=( coloffset + i + span_val if spans_multiple_cells else None @@ -583,7 +589,10 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: # Format in legacy format with dots to indicate levels. for i, values in enumerate(zip(*level_strs)): v = ".".join(map(pprint_thing, values)) - yield ExcelCell(lnum, coloffset + i + 1, v, self.header_style) + if styles: + css = ";".join(a + ":" + str(v) for (a, v) in styles[lnum, i]) + xlstyle = self.style_converter(css) + yield ExcelCell(lnum, coloffset + i + 1, v, xlstyle) self.rowcounter = lnum @@ -712,6 +721,9 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: for cidx, name in enumerate(index_labels): yield ExcelCell(self.rowcounter - 1, cidx, name, self.header_style) + styles = None if self.styler is None else self.styler.ctx_index + xlstyle = self.header_style + if self.merge_cells: # Format hierarchical rows as merged cells. level_strs = self.df.index.format( @@ -731,11 +743,16 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: for i, span_val in spans.items(): spans_multiple_cells = span_val > 1 + if styles: + css = ";".join( + a + ":" + str(v) for (a, v) in styles[i, gcolidx] + ) + xlstyle = self.style_converter(css) yield ExcelCell( row=self.rowcounter + i, col=gcolidx, val=values[i], - style=self.header_style, + style=xlstyle, mergestart=( self.rowcounter + i + span_val - 1 if spans_multiple_cells @@ -749,11 +766,16 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: # Format hierarchical rows with non-merged values. for indexcolvals in zip(*self.df.index): for idx, indexcolval in enumerate(indexcolvals): + if styles: + css = ";".join( + a + ":" + str(v) for (a, v) in styles[idx, gcolidx] + ) + xlstyle = self.style_converter(css) yield ExcelCell( row=self.rowcounter + idx, col=gcolidx, val=indexcolval, - style=self.header_style, + style=xlstyle, ) gcolidx += 1 From 8d8e88f88c71cb2472e9655ecbd618df59f5afde Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Wed, 14 Jul 2021 19:08:36 +0200 Subject: [PATCH 19/27] update tests --- pandas/io/formats/style.py | 2 ++ pandas/tests/io/formats/style/test_style.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index d7a34269cd40b..e61e6e2c751f0 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -1029,6 +1029,8 @@ def _copy(self, deepcopy: bool = False) -> Styler: "hidden_rows", "hidden_columns", "ctx", + "ctx_index", + "ctx_columns", "cell_context", "_todo", "table_styles", diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 751d4bcf56f0b..fb57da3523436 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -52,6 +52,8 @@ def mi_styler_comp(mi_styler): mi_styler.set_table_attributes('class="box"') mi_styler.format(na_rep="MISSING", precision=3) mi_styler.highlight_max(axis=None) + mi_styler.applymap_header(lambda x: "color: white;", axis=0) + mi_styler.applymap_header(lambda x: "color: black;", axis=1) mi_styler.set_td_classes( DataFrame( [["a", "b"], ["a", "c"]], index=mi_styler.index, columns=mi_styler.columns @@ -198,7 +200,14 @@ def test_copy(comprehensive, render, deepcopy, mi_styler, mi_styler_comp): if render: styler.to_html() - excl = ["na_rep", "precision", "uuid", "cellstyle_map"] # deprecated or special var + excl = [ + "na_rep", # deprecated + "precision", # deprecated + "uuid", # special + "cellstyle_map", # render time vars.. + "cellstyle_map_columns", + "cellstyle_map_index", + ] if not deepcopy: # check memory locations are equal for all included attributes for attr in [a for a in styler.__dict__ if (not callable(a) and a not in excl)]: assert id(getattr(s2, attr)) == id(getattr(styler, attr)) @@ -245,6 +254,8 @@ def test_clear(mi_styler_comp): "uuid_len", "cell_ids", "cellstyle_map", # execution time only + "cellstyle_map_columns", # execution time only + "cellstyle_map_index", # execution time only "precision", # deprecated "na_rep", # deprecated ] From d3fd856b09f0742e9f1a61e9f906ef1b14682be0 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sat, 25 Sep 2021 21:17:06 +0200 Subject: [PATCH 20/27] excel styler tests --- pandas/tests/io/excel/test_style.py | 113 +++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index ed996d32cf2fb..7bdc5b18e00ca 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -7,6 +7,115 @@ from pandas.io.excel import ExcelWriter from pandas.io.formats.excel import ExcelFormatter +pytest.importorskip("jinja2") + + +def assert_equal_cell_styles(cell1, cell2): + # TODO: should find a better way to check equality + assert cell1.alignment.__dict__ == cell2.alignment.__dict__ + assert cell1.border.__dict__ == cell2.border.__dict__ + assert cell1.fill.__dict__ == cell2.fill.__dict__ + assert cell1.font.__dict__ == cell2.font.__dict__ + assert cell1.number_format == cell2.number_format + assert cell1.protection.__dict__ == cell2.protection.__dict__ + + +@pytest.mark.parametrize( + "engine", + ["xlsxwriter", "openpyxl"], +) +def test_styler_to_excel_unstyled(engine): + # compare DataFrame.to_excel and Styler.to_excel when no styles applied + pytest.importorskip(engine) + df = DataFrame(np.random.randn(2, 2)) + with tm.ensure_clean(".xlsx") as path: + with ExcelWriter(path, engine=engine) as writer: + df.to_excel(writer, sheet_name="dataframe") + df.style.to_excel(writer, sheet_name="unstyled") + + openpyxl = pytest.importorskip("openpyxl") # test loading only with openpyxl + wb = openpyxl.load_workbook(path) + + for col1, col2 in zip(wb["dataframe"].columns, wb["unstyled"].columns): + assert len(col1) == len(col2) + for cell1, cell2 in zip(col1, col2): + assert cell1.value == cell2.value + assert_equal_cell_styles(cell1, cell2) + + +@pytest.mark.parametrize( + "engine", + ["xlsxwriter", "openpyxl"], +) +@pytest.mark.parametrize( + "css, attrs, expected", + [ + ( + "background-color: #111222", + ["fill", "fgColor", "rgb"], + {"xlsxwriter": "FF111222", "openpyxl": "00111222"}, + ), + ( + "color: #111222", + ["font", "color", "value"], + {"xlsxwriter": "FF111222", "openpyxl": "00111222"}, + ), + ("font-family: Arial;", ["font", "name"], "arial"), + ("font-weight: bold;", ["font", "b"], True), + ("font-style: italic;", ["font", "i"], True), + ("text-decoration: underline;", ["font", "u"], "single"), + ("number-format: $??,???.00;", ["number_format"], "$??,???.00"), + ("text-align: center;", ["alignment", "horizontal"], "center"), + ( + "vertical-align: middle;", + ["alignment", "vertical"], + {"xlsxwriter": None, "openpyxl": "center"}, # xlsxwriter Fails + ), + ], +) +def test_styler_to_excel_basic(engine, css, attrs, expected): + pytest.importorskip(engine) + df = DataFrame(np.random.randn(1, 1)) + styler = df.style.applymap(lambda x: css) + with tm.ensure_clean(".xlsx") as path: + with ExcelWriter(path, engine=engine) as writer: + df.to_excel(writer, sheet_name="dataframe") + styler.to_excel(writer, sheet_name="styled") + + openpyxl = pytest.importorskip("openpyxl") # test loading only with openpyxl + wb = openpyxl.load_workbook(path) + + # test unstyled data cell does not have expected styles + # test styled cell has expected styles + u_cell, s_cell = wb["dataframe"].cell(2, 2), wb["styled"].cell(2, 2) + for attr in attrs: + u_cell, s_cell = getattr(u_cell, attr), getattr(s_cell, attr) + + if isinstance(expected, dict): + assert u_cell is None or u_cell != expected[engine] + assert s_cell == expected[engine] + else: + assert u_cell is None or u_cell != expected + assert s_cell == expected + + +def test_styler_custom_converter(): + openpyxl = pytest.importorskip("openpyxl") + + def custom_converter(css): + return {"font": {"color": {"rgb": "111222"}}} + + df = DataFrame(np.random.randn(1, 1)) + styler = df.style.applymap(lambda x: "color: #888999") + with tm.ensure_clean(".xlsx") as path: + with ExcelWriter(path, engine="openpyxl") as writer: + ExcelFormatter(styler, style_converter=custom_converter).write( + writer, sheet_name="custom" + ) + + wb = openpyxl.load_workbook(path) + assert wb["custom"].cell(2, 2).font.color.value == "00111222" + @pytest.mark.parametrize( "engine", @@ -22,6 +131,9 @@ ], ) def test_styler_to_excel(request, engine): + # + # This test is redundant and will always Xfail + # def style(df): # TODO: RGB colors not supported in xlwt return DataFrame( @@ -65,7 +177,6 @@ def custom_converter(css): return {"font": {"bold": True}} return {} - pytest.importorskip("jinja2") pytest.importorskip(engine) # Prepare spreadsheets From 702a2e0299c0abba83880394883439d2a3655d3e Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 26 Sep 2021 09:12:37 +0200 Subject: [PATCH 21/27] add tests --- pandas/io/formats/excel.py | 2 +- pandas/tests/io/excel/test_style.py | 262 ++++++++-------------------- 2 files changed, 74 insertions(+), 190 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 7fe6243b15015..4e44dbee3fd21 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -472,7 +472,7 @@ def __init__( self.na_rep = na_rep if not isinstance(df, DataFrame): self.styler = df - self.styler._compute() + self.styler._compute() # calculate applied styles df = df.data if style_converter is None: style_converter = CSSToExcelConverter() diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index 7bdc5b18e00ca..76d546dba182c 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -43,40 +43,41 @@ def test_styler_to_excel_unstyled(engine): assert_equal_cell_styles(cell1, cell2) +shared_style_params = [ + ( + "background-color: #111222", + ["fill", "fgColor", "rgb"], + {"xlsxwriter": "FF111222", "openpyxl": "00111222"}, + ), + ( + "color: #111222", + ["font", "color", "value"], + {"xlsxwriter": "FF111222", "openpyxl": "00111222"}, + ), + ("font-family: Arial;", ["font", "name"], "arial"), + ("font-weight: bold;", ["font", "b"], True), + ("font-style: italic;", ["font", "i"], True), + ("text-decoration: underline;", ["font", "u"], "single"), + ("number-format: $??,???.00;", ["number_format"], "$??,???.00"), + ("text-align: left;", ["alignment", "horizontal"], "left"), + ( + "vertical-align: middle;", + ["alignment", "vertical"], + {"xlsxwriter": None, "openpyxl": "center"}, # xlsxwriter Fails + ), +] + + @pytest.mark.parametrize( "engine", ["xlsxwriter", "openpyxl"], ) -@pytest.mark.parametrize( - "css, attrs, expected", - [ - ( - "background-color: #111222", - ["fill", "fgColor", "rgb"], - {"xlsxwriter": "FF111222", "openpyxl": "00111222"}, - ), - ( - "color: #111222", - ["font", "color", "value"], - {"xlsxwriter": "FF111222", "openpyxl": "00111222"}, - ), - ("font-family: Arial;", ["font", "name"], "arial"), - ("font-weight: bold;", ["font", "b"], True), - ("font-style: italic;", ["font", "i"], True), - ("text-decoration: underline;", ["font", "u"], "single"), - ("number-format: $??,???.00;", ["number_format"], "$??,???.00"), - ("text-align: center;", ["alignment", "horizontal"], "center"), - ( - "vertical-align: middle;", - ["alignment", "vertical"], - {"xlsxwriter": None, "openpyxl": "center"}, # xlsxwriter Fails - ), - ], -) +@pytest.mark.parametrize("css, attrs, expected", shared_style_params) def test_styler_to_excel_basic(engine, css, attrs, expected): pytest.importorskip(engine) df = DataFrame(np.random.randn(1, 1)) styler = df.style.applymap(lambda x: css) + with tm.ensure_clean(".xlsx") as path: with ExcelWriter(path, engine=engine) as writer: df.to_excel(writer, sheet_name="dataframe") @@ -99,182 +100,65 @@ def test_styler_to_excel_basic(engine, css, attrs, expected): assert s_cell == expected -def test_styler_custom_converter(): - openpyxl = pytest.importorskip("openpyxl") - - def custom_converter(css): - return {"font": {"color": {"rgb": "111222"}}} - - df = DataFrame(np.random.randn(1, 1)) - styler = df.style.applymap(lambda x: "color: #888999") - with tm.ensure_clean(".xlsx") as path: - with ExcelWriter(path, engine="openpyxl") as writer: - ExcelFormatter(styler, style_converter=custom_converter).write( - writer, sheet_name="custom" - ) - - wb = openpyxl.load_workbook(path) - assert wb["custom"].cell(2, 2).font.color.value == "00111222" - - @pytest.mark.parametrize( "engine", - [ - pytest.param( - "xlwt", - marks=pytest.mark.xfail( - reason="xlwt does not support openpyxl-compatible style dicts" - ), - ), - "xlsxwriter", - "openpyxl", - ], + ["xlsxwriter", "openpyxl"], ) -def test_styler_to_excel(request, engine): - # - # This test is redundant and will always Xfail - # - def style(df): - # TODO: RGB colors not supported in xlwt - return DataFrame( - [ - ["font-weight: bold", "", ""], - ["", "color: blue", ""], - ["", "", "text-decoration: underline"], - ["border-style: solid", "", ""], - ["", "font-style: italic", ""], - ["", "", "text-align: right"], - ["background-color: red", "", ""], - ["number-format: 0%", "", ""], - ["", "", ""], - ["", "", ""], - ["", "", ""], - ], - index=df.index, - columns=df.columns, - ) - - def assert_equal_style(cell1, cell2, engine): - if engine in ["xlsxwriter", "openpyxl"]: - request.node.add_marker( - pytest.mark.xfail( - reason=( - f"GH25351: failing on some attribute comparisons in {engine}" - ) - ) - ) - # TODO: should find a better way to check equality - assert cell1.alignment.__dict__ == cell2.alignment.__dict__ - assert cell1.border.__dict__ == cell2.border.__dict__ - assert cell1.fill.__dict__ == cell2.fill.__dict__ - assert cell1.font.__dict__ == cell2.font.__dict__ - assert cell1.number_format == cell2.number_format - assert cell1.protection.__dict__ == cell2.protection.__dict__ - - def custom_converter(css): - # use bold iff there is custom style attached to the cell - if css.strip(" \n;"): - return {"font": {"bold": True}} - return {} - +@pytest.mark.parametrize("css, attrs, expected", shared_style_params) +def test_styler_to_excel_basic_indexes(engine, css, attrs, expected): pytest.importorskip(engine) + df = DataFrame(np.random.randn(1, 1)) + + styler = df.style + styler.applymap_index(lambda x: css, axis=0) + styler.applymap_index(lambda x: css, axis=1) - # Prepare spreadsheets + null_styler = df.style + null_styler.applymap(lambda x: "null: css;") + null_styler.applymap_index(lambda x: "null: css;", axis=0) + null_styler.applymap_index(lambda x: "null: css;", axis=1) - df = DataFrame(np.random.randn(11, 3)) - with tm.ensure_clean(".xlsx" if engine != "xlwt" else ".xls") as path: + with tm.ensure_clean(".xlsx") as path: with ExcelWriter(path, engine=engine) as writer: - df.to_excel(writer, sheet_name="frame") - df.style.to_excel(writer, sheet_name="unstyled") - styled = df.style.apply(style, axis=None) - styled.to_excel(writer, sheet_name="styled") - ExcelFormatter(styled, style_converter=custom_converter).write( - writer, sheet_name="custom" - ) + null_styler.to_excel(writer, sheet_name="null_styled") + styler.to_excel(writer, sheet_name="styled") - if engine not in ("openpyxl", "xlsxwriter"): - # For other engines, we only smoke test - return - openpyxl = pytest.importorskip("openpyxl") + openpyxl = pytest.importorskip("openpyxl") # test loading only with openpyxl wb = openpyxl.load_workbook(path) - # (1) compare DataFrame.to_excel and Styler.to_excel when unstyled - n_cells = 0 - for col1, col2 in zip(wb["frame"].columns, wb["unstyled"].columns): - assert len(col1) == len(col2) - for cell1, cell2 in zip(col1, col2): - assert cell1.value == cell2.value - assert_equal_style(cell1, cell2, engine) - n_cells += 1 - - # ensure iteration actually happened: - assert n_cells == (11 + 1) * (3 + 1) - - # (2) check styling with default converter - - # TODO: openpyxl (as at 2.4) prefixes colors with 00, xlsxwriter with FF - alpha = "00" if engine == "openpyxl" else "FF" + # test null styled index cells does not have expected styles + # test styled cell has expected styles + ui_cell, si_cell = wb["null_styled"].cell(2, 1), wb["styled"].cell(2, 1) + uc_cell, sc_cell = wb["null_styled"].cell(1, 2), wb["styled"].cell(1, 2) + for attr in attrs: + ui_cell, si_cell = getattr(ui_cell, attr), getattr(si_cell, attr) + uc_cell, sc_cell = getattr(uc_cell, attr), getattr(sc_cell, attr) - n_cells = 0 - for col1, col2 in zip(wb["frame"].columns, wb["styled"].columns): - assert len(col1) == len(col2) - for cell1, cell2 in zip(col1, col2): - ref = f"{cell2.column}{cell2.row:d}" - # TODO: this isn't as strong a test as ideal; we should - # confirm that differences are exclusive - if ref == "B2": - assert not cell1.font.bold - assert cell2.font.bold - elif ref == "C3": - assert cell1.font.color.rgb != cell2.font.color.rgb - assert cell2.font.color.rgb == alpha + "0000FF" - elif ref == "D4": - assert cell1.font.underline != cell2.font.underline - assert cell2.font.underline == "single" - elif ref == "B5": - assert not cell1.border.left.style - assert ( - cell2.border.top.style - == cell2.border.right.style - == cell2.border.bottom.style - == cell2.border.left.style - == "medium" - ) - elif ref == "C6": - assert not cell1.font.italic - assert cell2.font.italic - elif ref == "D7": - assert cell1.alignment.horizontal != cell2.alignment.horizontal - assert cell2.alignment.horizontal == "right" - elif ref == "B8": - assert cell1.fill.fgColor.rgb != cell2.fill.fgColor.rgb - assert cell1.fill.patternType != cell2.fill.patternType - assert cell2.fill.fgColor.rgb == alpha + "FF0000" - assert cell2.fill.patternType == "solid" - elif ref == "B9": - assert cell1.number_format == "General" - assert cell2.number_format == "0%" - else: - assert_equal_style(cell1, cell2, engine) + if isinstance(expected, dict): + assert ui_cell is None or ui_cell != expected[engine] + assert si_cell == expected[engine] + assert uc_cell is None or uc_cell != expected[engine] + assert sc_cell == expected[engine] + else: + assert ui_cell is None or ui_cell != expected + assert si_cell == expected + assert uc_cell is None or uc_cell != expected + assert sc_cell == expected - assert cell1.value == cell2.value - n_cells += 1 - assert n_cells == (11 + 1) * (3 + 1) +def test_styler_custom_converter(): + openpyxl = pytest.importorskip("openpyxl") - # (3) check styling with custom converter - n_cells = 0 - for col1, col2 in zip(wb["frame"].columns, wb["custom"].columns): - assert len(col1) == len(col2) - for cell1, cell2 in zip(col1, col2): - ref = f"{cell2.column}{cell2.row:d}" - if ref in ("B2", "C3", "D4", "B5", "C6", "D7", "B8", "B9"): - assert not cell1.font.bold - assert cell2.font.bold - else: - assert_equal_style(cell1, cell2, engine) + def custom_converter(css): + return {"font": {"color": {"rgb": "111222"}}} - assert cell1.value == cell2.value - n_cells += 1 + df = DataFrame(np.random.randn(1, 1)) + styler = df.style.applymap(lambda x: "color: #888999") + with tm.ensure_clean(".xlsx") as path: + with ExcelWriter(path, engine="openpyxl") as writer: + ExcelFormatter(styler, style_converter=custom_converter).write( + writer, sheet_name="custom" + ) - assert n_cells == (11 + 1) * (3 + 1) + wb = openpyxl.load_workbook(path) + assert wb["custom"].cell(2, 2).font.color.value == "00111222" From 77704cb3a8fad3fbb80842c222d53f3de32f1247 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 26 Sep 2021 12:22:26 +0200 Subject: [PATCH 22/27] add tests --- pandas/tests/io/excel/test_style.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index 76d546dba182c..c6d48c12113b4 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -61,9 +61,9 @@ def test_styler_to_excel_unstyled(engine): ("number-format: $??,???.00;", ["number_format"], "$??,???.00"), ("text-align: left;", ["alignment", "horizontal"], "left"), ( - "vertical-align: middle;", + "vertical-align: bottom;", ["alignment", "vertical"], - {"xlsxwriter": None, "openpyxl": "center"}, # xlsxwriter Fails + {"xlsxwriter": None, "openpyxl": "bottom"}, # xlsxwriter Fails ), ] From fe42e807af9ea2b33489b2df48f2741817026b06 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Fri, 1 Oct 2021 17:48:08 +0200 Subject: [PATCH 23/27] function --- pandas/io/formats/excel.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 4e44dbee3fd21..3c9715d7a047d 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -531,6 +531,13 @@ def _format_value(self, val): ) return val + def _yield_cell(self, styles, xlstyle, row_i, col_i, **kwargs): + if styles: + css = ";".join(a + ":" + str(v) for (a, v) in styles[row_i, col_i]) + xlstyle = self.style_converter(css) + kwargs["style"] = xlstyle + yield ExcelCell(**kwargs) + def _format_header_mi(self) -> Iterable[ExcelCell]: if self.columns.nlevels > 1: if not self.index: @@ -554,7 +561,7 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: coloffset = len(self.df.index[0]) - 1 styles = None if self.styler is None else self.styler.ctx_columns - xlstyle = self.header_style + # xlstyle = self.header_style if self.merge_cells: # Format multi-index as a merged cells. @@ -572,14 +579,14 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: values = levels.take(level_codes) for i, span_val in spans.items(): spans_multiple_cells = span_val > 1 - if styles: - css = ";".join(a + ":" + str(v) for (a, v) in styles[lnum, i]) - xlstyle = self.style_converter(css) - yield ExcelCell( + self._yield_cell( + styles=styles, + xlstyle=self.header_style, + row_i=lnum, + col_i=i, row=lnum, col=coloffset + i + 1, val=values[i], - style=xlstyle, mergestart=lnum if spans_multiple_cells else None, mergeend=( coloffset + i + span_val if spans_multiple_cells else None @@ -589,10 +596,19 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: # Format in legacy format with dots to indicate levels. for i, values in enumerate(zip(*level_strs)): v = ".".join(map(pprint_thing, values)) - if styles: - css = ";".join(a + ":" + str(v) for (a, v) in styles[lnum, i]) - xlstyle = self.style_converter(css) - yield ExcelCell(lnum, coloffset + i + 1, v, xlstyle) + self._yield_cell( + styles=styles, + xlstyle=self.header_style, + row_i=lnum, + col_i=i, + row=lnum, + col=coloffset + i + 1, + val=v, + ) + # if styles: + # css = ";".join(a + ":" + str(v) for (a, v) in styles[lnum, i]) + # xlstyle = self.style_converter(css) + # yield ExcelCell(lnum, coloffset + i + 1, v, xlstyle) self.rowcounter = lnum From 63915766bd28e34a4ec224b7d0bd1682bc231da4 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Fri, 1 Oct 2021 18:10:17 +0200 Subject: [PATCH 24/27] function --- pandas/io/formats/excel.py | 50 ++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 3c9715d7a047d..2bf4baa96c21b 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -561,7 +561,6 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: coloffset = len(self.df.index[0]) - 1 styles = None if self.styler is None else self.styler.ctx_columns - # xlstyle = self.header_style if self.merge_cells: # Format multi-index as a merged cells. @@ -596,7 +595,7 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: # Format in legacy format with dots to indicate levels. for i, values in enumerate(zip(*level_strs)): v = ".".join(map(pprint_thing, values)) - self._yield_cell( + yield from self._yield_cell( styles=styles, xlstyle=self.header_style, row_i=lnum, @@ -605,10 +604,6 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: col=coloffset + i + 1, val=v, ) - # if styles: - # css = ";".join(a + ":" + str(v) for (a, v) in styles[lnum, i]) - # xlstyle = self.style_converter(css) - # yield ExcelCell(lnum, coloffset + i + 1, v, xlstyle) self.rowcounter = lnum @@ -633,12 +628,16 @@ def _format_header_regular(self) -> Iterable[ExcelCell]: colnames = self.header styles = None if self.styler is None else self.styler.ctx_columns - xlstyle = self.header_style for colindex, colname in enumerate(colnames): - if styles: - css = ";".join(a + ":" + str(v) for (a, v) in styles[0, colindex]) - xlstyle = self.style_converter(css) - yield ExcelCell(self.rowcounter, colindex + coloffset, colname, xlstyle) + yield from self._yield_cell( + styles=styles, + xlstyle=self.header_style, + row_i=0, + col_i=colindex, + row=self.rowcounter, + col=colindex + coloffset, + val=colname, + ) def _format_header(self) -> Iterable[ExcelCell]: if isinstance(self.columns, MultiIndex): @@ -697,13 +696,16 @@ def _format_regular_rows(self) -> Iterable[ExcelCell]: index_values = self.df.index.to_timestamp() styles = None if self.styler is None else self.styler.ctx_index - xlstyle = self.header_style for idx, idxval in enumerate(index_values): - if styles: - css = ";".join(a + ":" + str(v) for (a, v) in styles[idx, 0]) - xlstyle = self.style_converter(css) - yield ExcelCell(self.rowcounter + idx, 0, idxval, xlstyle) - + yield from self._yield_cell( + styles=styles, + xlstyle=self.header_style, + row_i=idx, + col_i=0, + row=self.rowcounter + idx, + col=0, + val=idxval, + ) coloffset = 1 else: coloffset = 0 @@ -738,8 +740,6 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: yield ExcelCell(self.rowcounter - 1, cidx, name, self.header_style) styles = None if self.styler is None else self.styler.ctx_index - xlstyle = self.header_style - if self.merge_cells: # Format hierarchical rows as merged cells. level_strs = self.df.index.format( @@ -759,16 +759,14 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: for i, span_val in spans.items(): spans_multiple_cells = span_val > 1 - if styles: - css = ";".join( - a + ":" + str(v) for (a, v) in styles[i, gcolidx] - ) - xlstyle = self.style_converter(css) - yield ExcelCell( + yield from self._yield_cell( + styles=styles, + xlstyle=self.header_style, + row_i=i, + col_i=gcolidx, row=self.rowcounter + i, col=gcolidx, val=values[i], - style=xlstyle, mergestart=( self.rowcounter + i + span_val - 1 if spans_multiple_cells From ba4340c468f22046aba87771847fbfed1a9fb22e Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (MBP)" Date: Sat, 2 Oct 2021 09:55:43 +0200 Subject: [PATCH 25/27] new CssExcelCell class --- pandas/io/formats/excel.py | 137 ++++++++++++++++++++++--------------- 1 file changed, 80 insertions(+), 57 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 2bf4baa96c21b..2b9ec39f1a053 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -7,6 +7,7 @@ import itertools import re from typing import ( + Any, Callable, Hashable, Iterable, @@ -70,6 +71,26 @@ def __init__( self.mergeend = mergeend +class CssExcelCell(ExcelCell): + def __init__( + self, + row: int, + col: int, + val, + style: dict | None, + css_styles: dict[tuple[int, int], list[tuple[str, Any]]] | None, + css_row: int, + css_col: int, + css_converter: Callable | None, + **kwargs, + ): + if css_styles and css_converter: + css = ";".join(a + ":" + str(v) for (a, v) in css_styles[css_row, css_col]) + style = css_converter(css) + + return super().__init__(row=row, col=col, val=val, style=style, **kwargs) + + class CSSToExcelConverter: """ A callable for converting CSS declarations to ExcelWriter styles @@ -476,9 +497,10 @@ def __init__( df = df.data if style_converter is None: style_converter = CSSToExcelConverter() - self.style_converter = style_converter + self.style_converter: Callable | None = style_converter else: self.styler = None + self.style_converter = None self.df = df if cols is not None: @@ -531,13 +553,6 @@ def _format_value(self, val): ) return val - def _yield_cell(self, styles, xlstyle, row_i, col_i, **kwargs): - if styles: - css = ";".join(a + ":" + str(v) for (a, v) in styles[row_i, col_i]) - xlstyle = self.style_converter(css) - kwargs["style"] = xlstyle - yield ExcelCell(**kwargs) - def _format_header_mi(self) -> Iterable[ExcelCell]: if self.columns.nlevels > 1: if not self.index: @@ -577,32 +592,34 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: ): values = levels.take(level_codes) for i, span_val in spans.items(): - spans_multiple_cells = span_val > 1 - self._yield_cell( - styles=styles, - xlstyle=self.header_style, - row_i=lnum, - col_i=i, + mergestart, mergeend = None, None + if span_val > 1: + mergestart, mergeend = lnum, coloffset + i + span_val + yield CssExcelCell( row=lnum, col=coloffset + i + 1, val=values[i], - mergestart=lnum if spans_multiple_cells else None, - mergeend=( - coloffset + i + span_val if spans_multiple_cells else None - ), + style=self.header_style, + css_styles=styles, + css_row=lnum, + css_col=i, + css_converter=self.style_converter, + mergestart=mergestart, + mergeend=mergeend, ) else: # Format in legacy format with dots to indicate levels. for i, values in enumerate(zip(*level_strs)): v = ".".join(map(pprint_thing, values)) - yield from self._yield_cell( - styles=styles, - xlstyle=self.header_style, - row_i=lnum, - col_i=i, + yield CssExcelCell( row=lnum, col=coloffset + i + 1, val=v, + style=self.header_style, + css_styles=styles, + css_row=lnum, + css_col=i, + css_converter=self.style_converter, ) self.rowcounter = lnum @@ -629,14 +646,15 @@ def _format_header_regular(self) -> Iterable[ExcelCell]: styles = None if self.styler is None else self.styler.ctx_columns for colindex, colname in enumerate(colnames): - yield from self._yield_cell( - styles=styles, - xlstyle=self.header_style, - row_i=0, - col_i=colindex, + yield CssExcelCell( row=self.rowcounter, col=colindex + coloffset, val=colname, + style=self.header_style, + css_styles=styles, + css_row=0, + css_col=colindex, + css_converter=self.style_converter, ) def _format_header(self) -> Iterable[ExcelCell]: @@ -697,14 +715,15 @@ def _format_regular_rows(self) -> Iterable[ExcelCell]: styles = None if self.styler is None else self.styler.ctx_index for idx, idxval in enumerate(index_values): - yield from self._yield_cell( - styles=styles, - xlstyle=self.header_style, - row_i=idx, - col_i=0, + yield CssExcelCell( row=self.rowcounter + idx, col=0, val=idxval, + style=self.header_style, + css_styles=styles, + css_row=idx, + css_col=0, + css_converter=self.style_converter, ) coloffset = 1 else: @@ -758,21 +777,21 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: ) for i, span_val in spans.items(): - spans_multiple_cells = span_val > 1 - yield from self._yield_cell( - styles=styles, - xlstyle=self.header_style, - row_i=i, - col_i=gcolidx, + mergestart, mergeend = None, None + if span_val > 1: + mergestart = self.rowcounter + i + span_val - 1 + mergeend = gcolidx + yield CssExcelCell( row=self.rowcounter + i, col=gcolidx, val=values[i], - mergestart=( - self.rowcounter + i + span_val - 1 - if spans_multiple_cells - else None - ), - mergeend=gcolidx if spans_multiple_cells else None, + style=self.header_style, + css_styles=styles, + css_row=i, + css_col=gcolidx, + css_converter=self.style_converter, + mergestart=mergestart, + mergeend=mergeend, ) gcolidx += 1 @@ -780,16 +799,15 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: # Format hierarchical rows with non-merged values. for indexcolvals in zip(*self.df.index): for idx, indexcolval in enumerate(indexcolvals): - if styles: - css = ";".join( - a + ":" + str(v) for (a, v) in styles[idx, gcolidx] - ) - xlstyle = self.style_converter(css) - yield ExcelCell( + yield CssExcelCell( row=self.rowcounter + idx, col=gcolidx, val=indexcolval, - style=xlstyle, + style=self.header_style, + css_styles=styles, + css_row=idx, + css_col=gcolidx, + css_converter=self.style_converter, ) gcolidx += 1 @@ -802,15 +820,20 @@ def _has_aliases(self) -> bool: def _generate_body(self, coloffset: int) -> Iterable[ExcelCell]: styles = None if self.styler is None else self.styler.ctx - xlstyle = None # Write the body of the frame data series by series. for colidx in range(len(self.columns)): series = self.df.iloc[:, colidx] for i, val in enumerate(series): - if styles: - css = ";".join(a + ":" + str(v) for (a, v) in styles[i, colidx]) - xlstyle = self.style_converter(css) - yield ExcelCell(self.rowcounter + i, colidx + coloffset, val, xlstyle) + yield CssExcelCell( + row=self.rowcounter + i, + col=colidx + coloffset, + val=val, + style=None, + css_styles=styles, + css_row=i, + css_col=colidx, + css_converter=self.style_converter, + ) def get_formatted_cells(self) -> Iterable[ExcelCell]: for cell in itertools.chain(self._format_header(), self._format_body()): From 20d0063ca20a1da90cd47387ca4226fb852e2ac7 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (MBP)" Date: Sat, 2 Oct 2021 10:09:54 +0200 Subject: [PATCH 26/27] direct args --- pandas/io/formats/excel.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 2b9ec39f1a053..7f2905d9a63b9 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -575,8 +575,6 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: if self.index and isinstance(self.df.index, MultiIndex): coloffset = len(self.df.index[0]) - 1 - styles = None if self.styler is None else self.styler.ctx_columns - if self.merge_cells: # Format multi-index as a merged cells. for lnum, name in enumerate(columns.names): @@ -600,7 +598,7 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: col=coloffset + i + 1, val=values[i], style=self.header_style, - css_styles=styles, + css_styles=getattr(self.styler, "ctx_columns", None), css_row=lnum, css_col=i, css_converter=self.style_converter, @@ -616,7 +614,7 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: col=coloffset + i + 1, val=v, style=self.header_style, - css_styles=styles, + css_styles=getattr(self.styler, "ctx_columns", None), css_row=lnum, css_col=i, css_converter=self.style_converter, @@ -644,14 +642,13 @@ def _format_header_regular(self) -> Iterable[ExcelCell]: else: colnames = self.header - styles = None if self.styler is None else self.styler.ctx_columns for colindex, colname in enumerate(colnames): yield CssExcelCell( row=self.rowcounter, col=colindex + coloffset, val=colname, style=self.header_style, - css_styles=styles, + css_styles=getattr(self.styler, "ctx_columns", None), css_row=0, css_col=colindex, css_converter=self.style_converter, @@ -713,14 +710,13 @@ def _format_regular_rows(self) -> Iterable[ExcelCell]: if isinstance(self.df.index, PeriodIndex): index_values = self.df.index.to_timestamp() - styles = None if self.styler is None else self.styler.ctx_index for idx, idxval in enumerate(index_values): yield CssExcelCell( row=self.rowcounter + idx, col=0, val=idxval, style=self.header_style, - css_styles=styles, + css_styles=getattr(self.styler, "ctx_index", None), css_row=idx, css_col=0, css_converter=self.style_converter, @@ -758,7 +754,6 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: for cidx, name in enumerate(index_labels): yield ExcelCell(self.rowcounter - 1, cidx, name, self.header_style) - styles = None if self.styler is None else self.styler.ctx_index if self.merge_cells: # Format hierarchical rows as merged cells. level_strs = self.df.index.format( @@ -786,7 +781,7 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: col=gcolidx, val=values[i], style=self.header_style, - css_styles=styles, + css_styles=getattr(self.styler, "ctx_index", None), css_row=i, css_col=gcolidx, css_converter=self.style_converter, @@ -804,7 +799,7 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: col=gcolidx, val=indexcolval, style=self.header_style, - css_styles=styles, + css_styles=getattr(self.styler, "ctx_index", None), css_row=idx, css_col=gcolidx, css_converter=self.style_converter, @@ -819,7 +814,6 @@ def _has_aliases(self) -> bool: return is_list_like(self.header) def _generate_body(self, coloffset: int) -> Iterable[ExcelCell]: - styles = None if self.styler is None else self.styler.ctx # Write the body of the frame data series by series. for colidx in range(len(self.columns)): series = self.df.iloc[:, colidx] @@ -829,7 +823,7 @@ def _generate_body(self, coloffset: int) -> Iterable[ExcelCell]: col=colidx + coloffset, val=val, style=None, - css_styles=styles, + css_styles=getattr(self.styler, "ctx", None), css_row=i, css_col=colidx, css_converter=self.style_converter, From 0913da9768eb7a825d5ebecff477bb43716fe014 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Mon, 4 Oct 2021 20:37:04 +0200 Subject: [PATCH 27/27] comment on styler import jinja2 for to_excel --- pandas/tests/io/excel/test_style.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index c6d48c12113b4..8a142aebd719d 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -8,6 +8,9 @@ from pandas.io.formats.excel import ExcelFormatter 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. def assert_equal_cell_styles(cell1, cell2):