Skip to content

Commit 7ef6a71

Browse files
authored
ENH: allow Styler.concat chaining (#49212)
Allow chaining of style.concat Co-authored-by: Tsvika S <tsvikas@dell> Co-authored-by: JHM Darbyshire <[email protected]> Co-authored-by: Matthew Roeschke <[email protected]>
1 parent a0ee90a commit 7ef6a71

File tree

6 files changed

+251
-35
lines changed

6 files changed

+251
-35
lines changed

doc/source/whatsnew/v1.5.3.rst

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Fixed regressions
2727
Bug fixes
2828
~~~~~~~~~
2929
- Bug in :meth:`.Styler.to_excel` leading to error when unrecognized ``border-style`` (e.g. ``"hair"``) provided to Excel writers (:issue:`48649`)
30+
- Bug when chaining several :meth:`.Styler.concat` calls, only the last styler was concatenated (:issue:`49207`)
3031
-
3132

3233
.. ---------------------------------------------------------------------------

pandas/io/formats/style.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,12 @@ def concat(self, other: Styler) -> Styler:
316316
inherited from the original Styler and not ``other``.
317317
- hidden columns and hidden index levels will be inherited from the
318318
original Styler
319+
- ``css`` will be inherited from the original Styler, and the value of
320+
keys ``data``, ``row_heading`` and ``row`` will be prepended with
321+
``foot0_``. If more concats are chained, their styles will be prepended
322+
with ``foot1_``, ''foot_2'', etc., and if a concatenated style have
323+
another concatanated style, the second style will be prepended with
324+
``foot{parent}_foot{child}_``.
319325
320326
A common use case is to concatenate user defined functions with
321327
``DataFrame.agg`` or with described statistics via ``DataFrame.describe``.
@@ -367,7 +373,7 @@ def concat(self, other: Styler) -> Styler:
367373
"number of index levels must be same in `other` "
368374
"as in `Styler`. See documentation for suggestions."
369375
)
370-
self.concatenated = other
376+
self.concatenated.append(other)
371377
return self
372378

373379
def _repr_html_(self) -> str | None:

pandas/io/formats/style_render.py

+45-30
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def __init__(
119119
"blank": "blank",
120120
"foot": "foot",
121121
}
122-
self.concatenated: StylerRenderer | None = None
122+
self.concatenated: list[StylerRenderer] = []
123123
# add rendering variables
124124
self.hide_index_names: bool = False
125125
self.hide_column_names: bool = False
@@ -161,27 +161,34 @@ def _render(
161161
stylers for use within `_translate_latex`
162162
"""
163163
self._compute()
164-
dx = None
165-
if self.concatenated is not None:
166-
self.concatenated.hide_index_ = self.hide_index_
167-
self.concatenated.hidden_columns = self.hidden_columns
168-
self.concatenated.css = {
164+
dxs = []
165+
ctx_len = len(self.index)
166+
for i, concatenated in enumerate(self.concatenated):
167+
concatenated.hide_index_ = self.hide_index_
168+
concatenated.hidden_columns = self.hidden_columns
169+
foot = f"{self.css['foot']}{i}"
170+
concatenated.css = {
169171
**self.css,
170-
"data": f"{self.css['foot']}_{self.css['data']}",
171-
"row_heading": f"{self.css['foot']}_{self.css['row_heading']}",
172-
"row": f"{self.css['foot']}_{self.css['row']}",
173-
"foot": self.css["foot"],
172+
"data": f"{foot}_data",
173+
"row_heading": f"{foot}_row_heading",
174+
"row": f"{foot}_row",
175+
"foot": f"{foot}_foot",
174176
}
175-
dx = self.concatenated._render(
177+
dx = concatenated._render(
176178
sparse_index, sparse_columns, max_rows, max_cols, blank
177179
)
180+
dxs.append(dx)
178181

179-
for (r, c), v in self.concatenated.ctx.items():
180-
self.ctx[(r + len(self.index), c)] = v
181-
for (r, c), v in self.concatenated.ctx_index.items():
182-
self.ctx_index[(r + len(self.index), c)] = v
182+
for (r, c), v in concatenated.ctx.items():
183+
self.ctx[(r + ctx_len, c)] = v
184+
for (r, c), v in concatenated.ctx_index.items():
185+
self.ctx_index[(r + ctx_len, c)] = v
183186

184-
d = self._translate(sparse_index, sparse_columns, max_rows, max_cols, blank, dx)
187+
ctx_len += len(concatenated.index)
188+
189+
d = self._translate(
190+
sparse_index, sparse_columns, max_rows, max_cols, blank, dxs
191+
)
185192
return d
186193

187194
def _render_html(
@@ -258,7 +265,7 @@ def _translate(
258265
max_rows: int | None = None,
259266
max_cols: int | None = None,
260267
blank: str = "&nbsp;",
261-
dx: dict | None = None,
268+
dxs: list[dict] | None = None,
262269
):
263270
"""
264271
Process Styler data and settings into a dict for template rendering.
@@ -278,15 +285,17 @@ def _translate(
278285
Specific max rows and cols. max_elements always take precedence in render.
279286
blank : str
280287
Entry to top-left blank cells.
281-
dx : dict
282-
The render dict of the concatenated Styler.
288+
dxs : list[dict]
289+
The render dicts of the concatenated Stylers.
283290
284291
Returns
285292
-------
286293
d : dict
287294
The following structure: {uuid, table_styles, caption, head, body,
288295
cellstyle, table_attributes}
289296
"""
297+
if dxs is None:
298+
dxs = []
290299
self.css["blank_value"] = blank
291300

292301
# construct render dict
@@ -340,10 +349,12 @@ def _translate(
340349
]
341350
d.update({k: map})
342351

343-
if dx is not None: # self.concatenated is not None
352+
for dx in dxs: # self.concatenated is not empty
344353
d["body"].extend(dx["body"]) # type: ignore[union-attr]
345354
d["cellstyle"].extend(dx["cellstyle"]) # type: ignore[union-attr]
346-
d["cellstyle_index"].extend(dx["cellstyle"]) # type: ignore[union-attr]
355+
d["cellstyle_index"].extend( # type: ignore[union-attr]
356+
dx["cellstyle_index"]
357+
)
347358

348359
table_attr = self.table_attributes
349360
if not get_option("styler.html.mathjax"):
@@ -847,23 +858,27 @@ def _translate_latex(self, d: dict, clines: str | None) -> None:
847858
for r, row in enumerate(d["head"])
848859
]
849860

850-
def concatenated_visible_rows(obj, n, row_indices):
861+
def _concatenated_visible_rows(obj, n, row_indices):
851862
"""
852863
Extract all visible row indices recursively from concatenated stylers.
853864
"""
854865
row_indices.extend(
855866
[r + n for r in range(len(obj.index)) if r not in obj.hidden_rows]
856867
)
857-
return (
858-
row_indices
859-
if obj.concatenated is None
860-
else concatenated_visible_rows(
861-
obj.concatenated, n + len(obj.index), row_indices
862-
)
863-
)
868+
n += len(obj.index)
869+
for concatenated in obj.concatenated:
870+
n = _concatenated_visible_rows(concatenated, n, row_indices)
871+
return n
872+
873+
def concatenated_visible_rows(obj):
874+
row_indices: list[int] = []
875+
_concatenated_visible_rows(obj, 0, row_indices)
876+
# TODO try to consolidate the concat visible rows
877+
# methods to a single function / recursion for simplicity
878+
return row_indices
864879

865880
body = []
866-
for r, row in zip(concatenated_visible_rows(self, 0, []), d["body"]):
881+
for r, row in zip(concatenated_visible_rows(self), d["body"]):
867882
# note: cannot enumerate d["body"] because rows were dropped if hidden
868883
# during _translate_body so must zip to acquire the true r-index associated
869884
# with the ctx obj which contains the cell styles.

pandas/tests/io/formats/style/test_html.py

+142-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from textwrap import dedent
1+
from textwrap import (
2+
dedent,
3+
indent,
4+
)
25

36
import numpy as np
47
import pytest
@@ -823,18 +826,153 @@ def test_concat(styler):
823826
other = styler.data.agg(["mean"]).style
824827
styler.concat(other).set_uuid("X")
825828
result = styler.to_html()
829+
fp = "foot0_"
826830
expected = dedent(
827-
"""\
831+
f"""\
828832
<tr>
829833
<th id="T_X_level0_row1" class="row_heading level0 row1" >b</th>
830834
<td id="T_X_row1_col0" class="data row1 col0" >2.690000</td>
831835
</tr>
832836
<tr>
833-
<th id="T_X_level0_foot_row0" class="foot_row_heading level0 foot_row0" >mean</th>
834-
<td id="T_X_foot_row0_col0" class="foot_data foot_row0 col0" >2.650000</td>
837+
<th id="T_X_level0_{fp}row0" class="{fp}row_heading level0 {fp}row0" >mean</th>
838+
<td id="T_X_{fp}row0_col0" class="{fp}data {fp}row0 col0" >2.650000</td>
835839
</tr>
836840
</tbody>
837841
</table>
838842
"""
839843
)
840844
assert expected in result
845+
846+
847+
def test_concat_recursion(styler):
848+
df = styler.data
849+
styler1 = styler
850+
styler2 = Styler(df.agg(["mean"]), precision=3)
851+
styler3 = Styler(df.agg(["mean"]), precision=4)
852+
styler1.concat(styler2.concat(styler3)).set_uuid("X")
853+
result = styler.to_html()
854+
# notice that the second concat (last <tr> of the output html),
855+
# there are two `foot_` in the id and class
856+
fp1 = "foot0_"
857+
fp2 = "foot0_foot0_"
858+
expected = dedent(
859+
f"""\
860+
<tr>
861+
<th id="T_X_level0_row1" class="row_heading level0 row1" >b</th>
862+
<td id="T_X_row1_col0" class="data row1 col0" >2.690000</td>
863+
</tr>
864+
<tr>
865+
<th id="T_X_level0_{fp1}row0" class="{fp1}row_heading level0 {fp1}row0" >mean</th>
866+
<td id="T_X_{fp1}row0_col0" class="{fp1}data {fp1}row0 col0" >2.650</td>
867+
</tr>
868+
<tr>
869+
<th id="T_X_level0_{fp2}row0" class="{fp2}row_heading level0 {fp2}row0" >mean</th>
870+
<td id="T_X_{fp2}row0_col0" class="{fp2}data {fp2}row0 col0" >2.6500</td>
871+
</tr>
872+
</tbody>
873+
</table>
874+
"""
875+
)
876+
assert expected in result
877+
878+
879+
def test_concat_chain(styler):
880+
df = styler.data
881+
styler1 = styler
882+
styler2 = Styler(df.agg(["mean"]), precision=3)
883+
styler3 = Styler(df.agg(["mean"]), precision=4)
884+
styler1.concat(styler2).concat(styler3).set_uuid("X")
885+
result = styler.to_html()
886+
fp1 = "foot0_"
887+
fp2 = "foot1_"
888+
expected = dedent(
889+
f"""\
890+
<tr>
891+
<th id="T_X_level0_row1" class="row_heading level0 row1" >b</th>
892+
<td id="T_X_row1_col0" class="data row1 col0" >2.690000</td>
893+
</tr>
894+
<tr>
895+
<th id="T_X_level0_{fp1}row0" class="{fp1}row_heading level0 {fp1}row0" >mean</th>
896+
<td id="T_X_{fp1}row0_col0" class="{fp1}data {fp1}row0 col0" >2.650</td>
897+
</tr>
898+
<tr>
899+
<th id="T_X_level0_{fp2}row0" class="{fp2}row_heading level0 {fp2}row0" >mean</th>
900+
<td id="T_X_{fp2}row0_col0" class="{fp2}data {fp2}row0 col0" >2.6500</td>
901+
</tr>
902+
</tbody>
903+
</table>
904+
"""
905+
)
906+
assert expected in result
907+
908+
909+
def test_concat_combined():
910+
def html_lines(foot_prefix: str):
911+
assert foot_prefix.endswith("_") or foot_prefix == ""
912+
fp = foot_prefix
913+
return indent(
914+
dedent(
915+
f"""\
916+
<tr>
917+
<th id="T_X_level0_{fp}row0" class="{fp}row_heading level0 {fp}row0" >a</th>
918+
<td id="T_X_{fp}row0_col0" class="{fp}data {fp}row0 col0" >2.610000</td>
919+
</tr>
920+
<tr>
921+
<th id="T_X_level0_{fp}row1" class="{fp}row_heading level0 {fp}row1" >b</th>
922+
<td id="T_X_{fp}row1_col0" class="{fp}data {fp}row1 col0" >2.690000</td>
923+
</tr>
924+
"""
925+
),
926+
prefix=" " * 4,
927+
)
928+
929+
df = DataFrame([[2.61], [2.69]], index=["a", "b"], columns=["A"])
930+
s1 = df.style.highlight_max(color="red")
931+
s2 = df.style.highlight_max(color="green")
932+
s3 = df.style.highlight_max(color="blue")
933+
s4 = df.style.highlight_max(color="yellow")
934+
935+
result = s1.concat(s2).concat(s3.concat(s4)).set_uuid("X").to_html()
936+
expected_css = dedent(
937+
"""\
938+
<style type="text/css">
939+
#T_X_row1_col0 {
940+
background-color: red;
941+
}
942+
#T_X_foot0_row1_col0 {
943+
background-color: green;
944+
}
945+
#T_X_foot1_row1_col0 {
946+
background-color: blue;
947+
}
948+
#T_X_foot1_foot0_row1_col0 {
949+
background-color: yellow;
950+
}
951+
</style>
952+
"""
953+
)
954+
expected_table = (
955+
dedent(
956+
"""\
957+
<table id="T_X">
958+
<thead>
959+
<tr>
960+
<th class="blank level0" >&nbsp;</th>
961+
<th id="T_X_level0_col0" class="col_heading level0 col0" >A</th>
962+
</tr>
963+
</thead>
964+
<tbody>
965+
"""
966+
)
967+
+ html_lines("")
968+
+ html_lines("foot0_")
969+
+ html_lines("foot1_")
970+
+ html_lines("foot1_foot0_")
971+
+ dedent(
972+
"""\
973+
</tbody>
974+
</table>
975+
"""
976+
)
977+
)
978+
assert expected_css + expected_table == result

pandas/tests/io/formats/style/test_to_latex.py

+20
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,26 @@ def test_concat_recursion():
10341034
assert result == expected
10351035

10361036

1037+
def test_concat_chain():
1038+
# tests hidden row recursion and applied styles
1039+
styler1 = DataFrame([[1], [9]]).style.hide([1]).highlight_min(color="red")
1040+
styler2 = DataFrame([[9], [2]]).style.hide([0]).highlight_min(color="green")
1041+
styler3 = DataFrame([[3], [9]]).style.hide([1]).highlight_min(color="blue")
1042+
1043+
result = styler1.concat(styler2).concat(styler3).to_latex(convert_css=True)
1044+
expected = dedent(
1045+
"""\
1046+
\\begin{tabular}{lr}
1047+
& 0 \\\\
1048+
0 & {\\cellcolor{red}} 1 \\\\
1049+
1 & {\\cellcolor{green}} 2 \\\\
1050+
0 & {\\cellcolor{blue}} 3 \\\\
1051+
\\end{tabular}
1052+
"""
1053+
)
1054+
assert result == expected
1055+
1056+
10371057
@pytest.mark.parametrize(
10381058
"df, expected",
10391059
[

0 commit comments

Comments
 (0)