From 7ef6a71c5c41eadf9ebea514f4be502e83c95a6f Mon Sep 17 00:00:00 2001 From: Tsvika Shapira Date: Mon, 12 Dec 2022 09:23:03 +0200 Subject: [PATCH] ENH: allow Styler.concat chaining (#49212) Allow chaining of style.concat Co-authored-by: Tsvika S Co-authored-by: JHM Darbyshire <24256554+attack68@users.noreply.github.com> Co-authored-by: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com> --- doc/source/whatsnew/v1.5.3.rst | 1 + pandas/io/formats/style.py | 8 +- pandas/io/formats/style_render.py | 75 +++++---- pandas/tests/io/formats/style/test_html.py | 146 +++++++++++++++++- .../tests/io/formats/style/test_to_latex.py | 20 +++ .../tests/io/formats/style/test_to_string.py | 36 +++++ 6 files changed, 251 insertions(+), 35 deletions(-) diff --git a/doc/source/whatsnew/v1.5.3.rst b/doc/source/whatsnew/v1.5.3.rst index c739c2f3656c5..cb99495b837b2 100644 --- a/doc/source/whatsnew/v1.5.3.rst +++ b/doc/source/whatsnew/v1.5.3.rst @@ -27,6 +27,7 @@ Fixed regressions Bug fixes ~~~~~~~~~ - Bug in :meth:`.Styler.to_excel` leading to error when unrecognized ``border-style`` (e.g. ``"hair"``) provided to Excel writers (:issue:`48649`) +- Bug when chaining several :meth:`.Styler.concat` calls, only the last styler was concatenated (:issue:`49207`) - .. --------------------------------------------------------------------------- diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 6c62c4efde6bb..0f006658bc294 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -316,6 +316,12 @@ def concat(self, other: Styler) -> Styler: inherited from the original Styler and not ``other``. - hidden columns and hidden index levels will be inherited from the original Styler + - ``css`` will be inherited from the original Styler, and the value of + keys ``data``, ``row_heading`` and ``row`` will be prepended with + ``foot0_``. If more concats are chained, their styles will be prepended + with ``foot1_``, ''foot_2'', etc., and if a concatenated style have + another concatanated style, the second style will be prepended with + ``foot{parent}_foot{child}_``. A common use case is to concatenate user defined functions with ``DataFrame.agg`` or with described statistics via ``DataFrame.describe``. @@ -367,7 +373,7 @@ def concat(self, other: Styler) -> Styler: "number of index levels must be same in `other` " "as in `Styler`. See documentation for suggestions." ) - self.concatenated = other + self.concatenated.append(other) return self def _repr_html_(self) -> str | None: diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index b74a8d0632cef..5089d0489c49f 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -119,7 +119,7 @@ def __init__( "blank": "blank", "foot": "foot", } - self.concatenated: StylerRenderer | None = None + self.concatenated: list[StylerRenderer] = [] # add rendering variables self.hide_index_names: bool = False self.hide_column_names: bool = False @@ -161,27 +161,34 @@ def _render( stylers for use within `_translate_latex` """ self._compute() - dx = None - if self.concatenated is not None: - self.concatenated.hide_index_ = self.hide_index_ - self.concatenated.hidden_columns = self.hidden_columns - self.concatenated.css = { + dxs = [] + ctx_len = len(self.index) + for i, concatenated in enumerate(self.concatenated): + concatenated.hide_index_ = self.hide_index_ + concatenated.hidden_columns = self.hidden_columns + foot = f"{self.css['foot']}{i}" + concatenated.css = { **self.css, - "data": f"{self.css['foot']}_{self.css['data']}", - "row_heading": f"{self.css['foot']}_{self.css['row_heading']}", - "row": f"{self.css['foot']}_{self.css['row']}", - "foot": self.css["foot"], + "data": f"{foot}_data", + "row_heading": f"{foot}_row_heading", + "row": f"{foot}_row", + "foot": f"{foot}_foot", } - dx = self.concatenated._render( + dx = concatenated._render( sparse_index, sparse_columns, max_rows, max_cols, blank ) + dxs.append(dx) - for (r, c), v in self.concatenated.ctx.items(): - self.ctx[(r + len(self.index), c)] = v - for (r, c), v in self.concatenated.ctx_index.items(): - self.ctx_index[(r + len(self.index), c)] = v + for (r, c), v in concatenated.ctx.items(): + self.ctx[(r + ctx_len, c)] = v + for (r, c), v in concatenated.ctx_index.items(): + self.ctx_index[(r + ctx_len, c)] = v - d = self._translate(sparse_index, sparse_columns, max_rows, max_cols, blank, dx) + ctx_len += len(concatenated.index) + + d = self._translate( + sparse_index, sparse_columns, max_rows, max_cols, blank, dxs + ) return d def _render_html( @@ -258,7 +265,7 @@ def _translate( max_rows: int | None = None, max_cols: int | None = None, blank: str = " ", - dx: dict | None = None, + dxs: list[dict] | None = None, ): """ Process Styler data and settings into a dict for template rendering. @@ -278,8 +285,8 @@ def _translate( Specific max rows and cols. max_elements always take precedence in render. blank : str Entry to top-left blank cells. - dx : dict - The render dict of the concatenated Styler. + dxs : list[dict] + The render dicts of the concatenated Stylers. Returns ------- @@ -287,6 +294,8 @@ def _translate( The following structure: {uuid, table_styles, caption, head, body, cellstyle, table_attributes} """ + if dxs is None: + dxs = [] self.css["blank_value"] = blank # construct render dict @@ -340,10 +349,12 @@ def _translate( ] d.update({k: map}) - if dx is not None: # self.concatenated is not None + for dx in dxs: # self.concatenated is not empty d["body"].extend(dx["body"]) # type: ignore[union-attr] d["cellstyle"].extend(dx["cellstyle"]) # type: ignore[union-attr] - d["cellstyle_index"].extend(dx["cellstyle"]) # type: ignore[union-attr] + d["cellstyle_index"].extend( # type: ignore[union-attr] + dx["cellstyle_index"] + ) table_attr = self.table_attributes if not get_option("styler.html.mathjax"): @@ -847,23 +858,27 @@ def _translate_latex(self, d: dict, clines: str | None) -> None: for r, row in enumerate(d["head"]) ] - def concatenated_visible_rows(obj, n, row_indices): + def _concatenated_visible_rows(obj, n, row_indices): """ Extract all visible row indices recursively from concatenated stylers. """ row_indices.extend( [r + n for r in range(len(obj.index)) if r not in obj.hidden_rows] ) - return ( - row_indices - if obj.concatenated is None - else concatenated_visible_rows( - obj.concatenated, n + len(obj.index), row_indices - ) - ) + n += len(obj.index) + for concatenated in obj.concatenated: + n = _concatenated_visible_rows(concatenated, n, row_indices) + return n + + def concatenated_visible_rows(obj): + row_indices: list[int] = [] + _concatenated_visible_rows(obj, 0, row_indices) + # TODO try to consolidate the concat visible rows + # methods to a single function / recursion for simplicity + return row_indices body = [] - for r, row in zip(concatenated_visible_rows(self, 0, []), d["body"]): + for r, row in zip(concatenated_visible_rows(self), d["body"]): # note: cannot enumerate d["body"] because rows were dropped if hidden # during _translate_body so must zip to acquire the true r-index associated # with the ctx obj which contains the cell styles. diff --git a/pandas/tests/io/formats/style/test_html.py b/pandas/tests/io/formats/style/test_html.py index 43eb4cb0502a1..d878d82f55e51 100644 --- a/pandas/tests/io/formats/style/test_html.py +++ b/pandas/tests/io/formats/style/test_html.py @@ -1,4 +1,7 @@ -from textwrap import dedent +from textwrap import ( + dedent, + indent, +) import numpy as np import pytest @@ -823,18 +826,153 @@ def test_concat(styler): other = styler.data.agg(["mean"]).style styler.concat(other).set_uuid("X") result = styler.to_html() + fp = "foot0_" expected = dedent( - """\ + f"""\ b 2.690000 - mean - 2.650000 + mean + 2.650000 """ ) assert expected in result + + +def test_concat_recursion(styler): + df = styler.data + styler1 = styler + styler2 = Styler(df.agg(["mean"]), precision=3) + styler3 = Styler(df.agg(["mean"]), precision=4) + styler1.concat(styler2.concat(styler3)).set_uuid("X") + result = styler.to_html() + # notice that the second concat (last of the output html), + # there are two `foot_` in the id and class + fp1 = "foot0_" + fp2 = "foot0_foot0_" + expected = dedent( + f"""\ + + b + 2.690000 + + + mean + 2.650 + + + mean + 2.6500 + + + + """ + ) + assert expected in result + + +def test_concat_chain(styler): + df = styler.data + styler1 = styler + styler2 = Styler(df.agg(["mean"]), precision=3) + styler3 = Styler(df.agg(["mean"]), precision=4) + styler1.concat(styler2).concat(styler3).set_uuid("X") + result = styler.to_html() + fp1 = "foot0_" + fp2 = "foot1_" + expected = dedent( + f"""\ + + b + 2.690000 + + + mean + 2.650 + + + mean + 2.6500 + + + + """ + ) + assert expected in result + + +def test_concat_combined(): + def html_lines(foot_prefix: str): + assert foot_prefix.endswith("_") or foot_prefix == "" + fp = foot_prefix + return indent( + dedent( + f"""\ + + a + 2.610000 + + + b + 2.690000 + + """ + ), + prefix=" " * 4, + ) + + df = DataFrame([[2.61], [2.69]], index=["a", "b"], columns=["A"]) + s1 = df.style.highlight_max(color="red") + s2 = df.style.highlight_max(color="green") + s3 = df.style.highlight_max(color="blue") + s4 = df.style.highlight_max(color="yellow") + + result = s1.concat(s2).concat(s3.concat(s4)).set_uuid("X").to_html() + expected_css = dedent( + """\ + + """ + ) + expected_table = ( + dedent( + """\ + + + + + + + + + """ + ) + + html_lines("") + + html_lines("foot0_") + + html_lines("foot1_") + + html_lines("foot1_foot0_") + + dedent( + """\ + +
 A
+ """ + ) + ) + assert expected_css + expected_table == result diff --git a/pandas/tests/io/formats/style/test_to_latex.py b/pandas/tests/io/formats/style/test_to_latex.py index b295c955a8967..1c67d125664f8 100644 --- a/pandas/tests/io/formats/style/test_to_latex.py +++ b/pandas/tests/io/formats/style/test_to_latex.py @@ -1034,6 +1034,26 @@ def test_concat_recursion(): assert result == expected +def test_concat_chain(): + # tests hidden row recursion and applied styles + styler1 = DataFrame([[1], [9]]).style.hide([1]).highlight_min(color="red") + styler2 = DataFrame([[9], [2]]).style.hide([0]).highlight_min(color="green") + styler3 = DataFrame([[3], [9]]).style.hide([1]).highlight_min(color="blue") + + result = styler1.concat(styler2).concat(styler3).to_latex(convert_css=True) + expected = dedent( + """\ + \\begin{tabular}{lr} + & 0 \\\\ + 0 & {\\cellcolor{red}} 1 \\\\ + 1 & {\\cellcolor{green}} 2 \\\\ + 0 & {\\cellcolor{blue}} 3 \\\\ + \\end{tabular} + """ + ) + assert result == expected + + @pytest.mark.parametrize( "df, expected", [ diff --git a/pandas/tests/io/formats/style/test_to_string.py b/pandas/tests/io/formats/style/test_to_string.py index fcac304b8c3bb..913857396446c 100644 --- a/pandas/tests/io/formats/style/test_to_string.py +++ b/pandas/tests/io/formats/style/test_to_string.py @@ -53,3 +53,39 @@ def test_concat(styler): """ ) assert result == expected + + +def test_concat_recursion(styler): + df = styler.data + styler1 = styler + styler2 = Styler(df.agg(["sum"]), uuid_len=0, precision=3) + styler3 = Styler(df.agg(["sum"]), uuid_len=0, precision=4) + result = styler1.concat(styler2.concat(styler3)).to_string() + expected = dedent( + """\ + A B C + 0 0 -0.61 ab + 1 1 -1.22 cd + sum 1 -1.830 abcd + sum 1 -1.8300 abcd + """ + ) + assert result == expected + + +def test_concat_chain(styler): + df = styler.data + styler1 = styler + styler2 = Styler(df.agg(["sum"]), uuid_len=0, precision=3) + styler3 = Styler(df.agg(["sum"]), uuid_len=0, precision=4) + result = styler1.concat(styler2).concat(styler3).to_string() + expected = dedent( + """\ + A B C + 0 0 -0.61 ab + 1 1 -1.22 cd + sum 1 -1.830 abcd + sum 1 -1.8300 abcd + """ + ) + assert result == expected