Skip to content

Commit b2c4d0f

Browse files
authored
Backport PR #49212 on branch 1.5.x (Style concats) (#50203)
1 parent 08a4e74 commit b2c4d0f

File tree

6 files changed

+251
-36
lines changed

6 files changed

+251
-36
lines changed

doc/source/whatsnew/v1.5.3.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Fixed regressions
2626

2727
Bug fixes
2828
~~~~~~~~~
29-
-
29+
- Bug when chaining several :meth:`.Styler.concat` calls, only the last styler was concatenated (:issue:`49207`)
3030
-
3131

3232
.. ---------------------------------------------------------------------------

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