Skip to content
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

Style concats #49212

Merged
merged 21 commits into from
Dec 12, 2022
Merged
Show file tree
Hide file tree
Changes from 16 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
41 changes: 41 additions & 0 deletions doc/source/whatsnew/v1.5.3.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
.. _whatsnew_153:

What's new in 1.5.3 (??)
---------------------------------------

These are the changes in pandas 1.5.3. See :ref:`release` for a full changelog
including other versions of pandas.

{{ header }}

.. ---------------------------------------------------------------------------
.. _whatsnew_153.regressions:

Fixed regressions
~~~~~~~~~~~~~~~~~
-
-

.. ---------------------------------------------------------------------------
.. _whatsnew_153.bug_fixes:

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

.. ---------------------------------------------------------------------------
.. _whatsnew_153.other:

Other
~~~~~
-
-

.. ---------------------------------------------------------------------------
.. _whatsnew_153.contributors:

Contributors
~~~~~~~~~~~~

.. contributors:: v1.5.2..v1.5.3|HEAD
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
Copy link
Member

Choose a reason for hiding this comment

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

So if there is only 1 Styler, before foot_ would be prepended and now foot0_will be prepended?

Copy link
Member

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.

When chaining multiple Stylers the CSS needs a unique identifier. Previously it was only possible to compound multiple styler concatenations:

styler1.concat(styler2.concat(styler3))

which resulted in CSS classes None, foot_ and foot_foot_.

When allowing chained stylers you need an integer id, so

styler1.concat(styler2).concat(styler3.concat(styler4)).concat(styler5)

results in None, foot0_, foot1_ and foot1_foot0_, foot2_.

The CSS classes were not documented in 1.5.0 previously and not exposed to the user.
Here they are now amended and documented.

Copy link
Member

Choose a reason for hiding this comment

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

So just to confirm, a result with foot_ wasn't visible to the user previously and wouldn't see that foot0_ would now be returned?

Copy link
Contributor

Choose a reason for hiding this comment

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

foot_ is returned as part of the HTML string in 1.5.0. All of the automatically generated styling CSS ids reference foot_ so that the rendered HTML table, including styles works correctly.

Now the HTML string returned will contain foot0_ and all the auto generated CSS ids will reference that instead.

Unless the user is specifically adding a CSS rule for foot either with an external stylesheet or using set_table_styles there will be no visible difference in the rendered HTML display, either in a JupyterLab or browser.

i.e.

styler2.applymap(lambda v: "color: red;")
styler1.concat(styler2)

will display the same in both versions even though the CSS class names have been changed.

Copy link
Member

Choose a reason for hiding this comment

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

Okay thanks for the explanation!

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}_``.
attack68 marked this conversation as resolved.
Show resolved Hide resolved

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
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] = []
attack68 marked this conversation as resolved.
Show resolved Hide resolved
# 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():
tsvikas marked this conversation as resolved.
Show resolved Hide resolved
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,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

mroeschke marked this conversation as resolved.
Show resolved Hide resolved
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