Skip to content

Style concats #49212

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 21 commits into from
Dec 12, 2022
Merged
Show file tree
Hide file tree
Changes from 12 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
1 change: 1 addition & 0 deletions doc/source/whatsnew/v1.5.2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Fixed regressions
Bug fixes
~~~~~~~~~
- Bug in the Copy-on-Write implementation losing track of views in certain chained indexing cases (:issue:`48996`)
- Bug when chaining several :meth:`.Styler.concat` calls, only the last styler was concatenated (:issue:`49207`)
-

.. ---------------------------------------------------------------------------
Expand Down
6 changes: 5 additions & 1 deletion pandas/io/formats/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,10 @@ 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
``foot_``. If several stylers are chained, they will all have only one
``foot_`` prepended.

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 +371,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
73 changes: 43 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(
dx["cellstyle_index"]
) # type: ignore[union-attr]

table_attr = self.table_attributes
if not get_option("styler.html.mathjax"):
Expand Down Expand Up @@ -847,23 +858,25 @@ 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this function (and the one above) be refactored to just recursively yield each row, since it's just needed in the zip below? From first glance, it seems odd that this is modifying row_indices inplace and the result n isn't used

Copy link
Member

@mroeschke mroeschke Dec 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't appear to have been addressed (please don't resolve unresolved conversations)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree this could be considered for refactor to be made simpler. Does it have to be a blocker for the PR - it is otherwise a very nice addition that addresses a potentially common use case?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure can be a follow up but it would be good to add a TODO comment here with the note to simplify

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 @@ -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"""\
<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
36 changes: 36 additions & 0 deletions pandas/tests/io/formats/style/test_to_string.py
Original file line number Diff line number Diff line change
Expand Up @@ -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