Skip to content

Backport PR #49212 on branch 1.5.x (Style concats) #50203

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/source/whatsnew/v1.5.3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Fixed regressions

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

.. ---------------------------------------------------------------------------
Expand Down
8 changes: 7 additions & 1 deletion pandas/io/formats/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down Expand Up @@ -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:
Expand Down
75 changes: 45 additions & 30 deletions pandas/io/formats/style_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -278,15 +285,17 @@ 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
-------
d : dict
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
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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.
Expand Down
146 changes: 142 additions & 4 deletions pandas/tests/io/formats/style/test_html.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from textwrap import dedent
from textwrap import (
dedent,
indent,
)

import numpy as np
import pytest
Expand Down Expand Up @@ -822,18 +825,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"""\
<tr>
<th id="T_X_level0_row1" class="row_heading level0 row1" >b</th>
<td id="T_X_row1_col0" class="data row1 col0" >2.690000</td>
</tr>
<tr>
<th id="T_X_level0_foot_row0" class="foot_row_heading level0 foot_row0" >mean</th>
<td id="T_X_foot_row0_col0" class="foot_data foot_row0 col0" >2.650000</td>
<th id="T_X_level0_{fp}row0" class="{fp}row_heading level0 {fp}row0" >mean</th>
<td id="T_X_{fp}row0_col0" class="{fp}data {fp}row0 col0" >2.650000</td>
</tr>
</tbody>
</table>
"""
)
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 <tr> of the output html),
# there are two `foot_` in the id and class
fp1 = "foot0_"
fp2 = "foot0_foot0_"
expected = dedent(
f"""\
<tr>
<th id="T_X_level0_row1" class="row_heading level0 row1" >b</th>
<td id="T_X_row1_col0" class="data row1 col0" >2.690000</td>
</tr>
<tr>
<th id="T_X_level0_{fp1}row0" class="{fp1}row_heading level0 {fp1}row0" >mean</th>
<td id="T_X_{fp1}row0_col0" class="{fp1}data {fp1}row0 col0" >2.650</td>
</tr>
<tr>
<th id="T_X_level0_{fp2}row0" class="{fp2}row_heading level0 {fp2}row0" >mean</th>
<td id="T_X_{fp2}row0_col0" class="{fp2}data {fp2}row0 col0" >2.6500</td>
</tr>
</tbody>
</table>
"""
)
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"""\
<tr>
<th id="T_X_level0_row1" class="row_heading level0 row1" >b</th>
<td id="T_X_row1_col0" class="data row1 col0" >2.690000</td>
</tr>
<tr>
<th id="T_X_level0_{fp1}row0" class="{fp1}row_heading level0 {fp1}row0" >mean</th>
<td id="T_X_{fp1}row0_col0" class="{fp1}data {fp1}row0 col0" >2.650</td>
</tr>
<tr>
<th id="T_X_level0_{fp2}row0" class="{fp2}row_heading level0 {fp2}row0" >mean</th>
<td id="T_X_{fp2}row0_col0" class="{fp2}data {fp2}row0 col0" >2.6500</td>
</tr>
</tbody>
</table>
"""
)
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"""\
<tr>
<th id="T_X_level0_{fp}row0" class="{fp}row_heading level0 {fp}row0" >a</th>
<td id="T_X_{fp}row0_col0" class="{fp}data {fp}row0 col0" >2.610000</td>
</tr>
<tr>
<th id="T_X_level0_{fp}row1" class="{fp}row_heading level0 {fp}row1" >b</th>
<td id="T_X_{fp}row1_col0" class="{fp}data {fp}row1 col0" >2.690000</td>
</tr>
"""
),
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(
"""\
<style type="text/css">
#T_X_row1_col0 {
background-color: red;
}
#T_X_foot0_row1_col0 {
background-color: green;
}
#T_X_foot1_row1_col0 {
background-color: blue;
}
#T_X_foot1_foot0_row1_col0 {
background-color: yellow;
}
</style>
"""
)
expected_table = (
dedent(
"""\
<table id="T_X">
<thead>
<tr>
<th class="blank level0" >&nbsp;</th>
<th id="T_X_level0_col0" class="col_heading level0 col0" >A</th>
</tr>
</thead>
<tbody>
"""
)
+ html_lines("")
+ html_lines("foot0_")
+ html_lines("foot1_")
+ html_lines("foot1_foot0_")
+ dedent(
"""\
</tbody>
</table>
"""
)
)
assert expected_css + expected_table == result
20 changes: 20 additions & 0 deletions pandas/tests/io/formats/style/test_to_latex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
Loading