Skip to content

Commit

Permalink
ENH: allow Styler.concat chaining (#49212)
Browse files Browse the repository at this point in the history
Allow chaining of style.concat
Co-authored-by: Tsvika S <tsvikas@dell>
Co-authored-by: JHM Darbyshire <24256554+attack68@users.noreply.github.com>
Co-authored-by: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com>
  • Loading branch information
tsvikas committed Dec 12, 2022
1 parent a0ee90a commit 7ef6a71
Show file tree
Hide file tree
Showing 6 changed files with 251 additions and 35 deletions.
1 change: 1 addition & 0 deletions doc/source/whatsnew/v1.5.3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
-

.. ---------------------------------------------------------------------------
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 = "&nbsp;",
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 @@ -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
Loading

0 comments on commit 7ef6a71

Please sign in to comment.