Skip to content

ENH: allow concat of Styler objects #46105

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 48 commits into from
Feb 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
4cfb671
skeleton
attack68 Feb 19, 2022
ba3d421
html template
attack68 Feb 19, 2022
2f36cef
test internals
attack68 Feb 19, 2022
3927adc
additional tests
attack68 Feb 19, 2022
8d6dabc
additional tests
attack68 Feb 19, 2022
56262b8
additional tests
attack68 Feb 19, 2022
aeeca3b
additional tests
attack68 Feb 19, 2022
f8cf8af
additional tests
attack68 Feb 19, 2022
7703343
additional tests
attack68 Feb 19, 2022
5d70293
errors testing
attack68 Feb 19, 2022
6811f4b
errors testing
attack68 Feb 19, 2022
409fad2
doc edits
attack68 Feb 20, 2022
d516b2f
LaTeX footer
attack68 Feb 20, 2022
6890e56
to_string mods, and tests
attack68 Feb 20, 2022
7281a9c
doc edits
attack68 Feb 20, 2022
e6fbf53
valueerror test
attack68 Feb 21, 2022
88db947
REMOVE CUSTOM FORMATTING
attack68 Feb 21, 2022
8c8ae96
doc edits
attack68 Feb 21, 2022
5511e80
doc edits
attack68 Feb 21, 2022
5400614
test moved
attack68 Feb 21, 2022
28bc86b
Merge remote-tracking branch 'upstream/main' into styler_footer
attack68 Feb 22, 2022
f158eeb
concatenate instead
attack68 Feb 22, 2022
bf06ce1
removing redundant code
attack68 Feb 22, 2022
af287e2
removing redundant code
attack68 Feb 22, 2022
aa02e86
removing redundant code
attack68 Feb 22, 2022
0ae6b53
removing redundant code
attack68 Feb 22, 2022
7ebddd0
removing redundant code
attack68 Feb 22, 2022
5a7ccc7
doc edits
attack68 Feb 22, 2022
bba6077
doc edits
attack68 Feb 22, 2022
1032336
doc edits
attack68 Feb 22, 2022
394b9e0
edit tests
attack68 Feb 22, 2022
cf42933
edit tests
attack68 Feb 22, 2022
dfcdf6b
general render method
attack68 Feb 23, 2022
64cf7bc
doc warnings
attack68 Feb 23, 2022
ad23748
doc warnings
attack68 Feb 23, 2022
f92c54c
revert merge
attack68 Feb 23, 2022
5aeb8b8
html test
attack68 Feb 23, 2022
1417e7e
Merge remote-tracking branch 'upstream/main' into styler_footer
attack68 Feb 23, 2022
70184fa
doc test fix
attack68 Feb 23, 2022
0c107d2
edit docs
attack68 Feb 23, 2022
3b1cac3
Merge remote-tracking branch 'upstream/main' into styler_footer
attack68 Feb 24, 2022
5b44e92
fix typing
attack68 Feb 25, 2022
a21beef
reorder calculation for recursion
attack68 Feb 25, 2022
5debdf6
allow working chained multiple concatenation
attack68 Feb 25, 2022
eccf6db
fix test with chaining
attack68 Feb 25, 2022
e2a1bbd
Merge remote-tracking branch 'upstream/main' into styler_footer
attack68 Feb 26, 2022
f2701a2
mypy fix
attack68 Feb 26, 2022
534d5c0
mypy fix
attack68 Feb 27, 2022
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
Binary file added doc/source/_static/style/footer_extended.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/source/_static/style/footer_simple.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions doc/source/reference/style.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Style application
Styler.format
Styler.format_index
Styler.hide
Styler.concat
Styler.set_td_classes
Styler.set_table_styles
Styler.set_table_attributes
Expand Down
1 change: 1 addition & 0 deletions doc/source/whatsnew/v1.5.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Styler

- New method :meth:`.Styler.to_string` for alternative customisable output methods (:issue:`44502`)
- Added the ability to render ``border`` and ``border-{side}`` CSS properties in Excel (:issue:`42276`)
- Added a new method :meth:`.Styler.concat` which allows adding customised footer rows to visualise additional calculations on the data, e.g. totals and counts etc. (:issue:`43875`)

.. _whatsnew_150.enhancements.enhancement2:

Expand Down
87 changes: 86 additions & 1 deletion pandas/io/formats/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,87 @@ def __init__(
thousands=thousands,
)

def concat(self, other: Styler) -> Styler:
"""
Append another Styler to combine the output into a single table.

.. versionadded:: 1.5.0

Parameters
----------
other : Styler
The other Styler object which has already been styled and formatted. The
data for this Styler must have the same columns as the original.

Returns
-------
self : Styler

Notes
-----
The purpose of this method is to extend existing styled dataframes with other
metrics that may be useful but may not conform to the original's structure.
For example adding a sub total row, or displaying metrics such as means,
variance or counts.

Styles that are applied using the ``apply``, ``applymap``, ``apply_index``
and ``applymap_index``, and formatting applied with ``format`` and
``format_index`` will be preserved.

.. warning::
Only the output methods ``to_html`` and ``to_string`` currently work with
concatenated Stylers.

The output methods ``to_latex`` and ``to_excel`` **do not** work with
concatenated Stylers.

The following should be noted:

- ``table_styles``, ``table_attributes``, ``caption`` and ``uuid`` are all
inherited from the original Styler and not ``other``.
- hidden columns and hidden index levels will be inherited from the
original Styler

A common use case is to concatenate user defined functions with
``DataFrame.agg`` or with described statistics via ``DataFrame.describe``.
See examples.

Examples
--------
A common use case is adding totals rows, or otherwise, via methods calculated
in ``DataFrame.agg``.

>>> df = DataFrame([[4, 6], [1, 9], [3, 4], [5, 5], [9,6]],
... columns=["Mike", "Jim"],
... index=["Mon", "Tue", "Wed", "Thurs", "Fri"])
>>> styler = df.style.concat(df.agg(["sum"]).style) # doctest: +SKIP

.. figure:: ../../_static/style/footer_simple.png

Since the concatenated object is a Styler the existing functionality can be
used to conditionally format it as well as the original.

>>> descriptors = df.agg(["sum", "mean", lambda s: s.dtype])
>>> descriptors.index = ["Total", "Average", "dtype"]
>>> other = (descriptors.style
... .highlight_max(axis=1, subset=(["Total", "Average"], slice(None)))
... .format(subset=("Average", slice(None)), precision=2, decimal=",")
... .applymap(lambda v: "font-weight: bold;"))
>>> styler = (df.style
... .highlight_max(color="salmon")
... .set_table_styles([{"selector": ".foot_row0",
... "props": "border-top: 1px solid black;"}]))
>>> styler.concat(other) # doctest: +SKIP

.. figure:: ../../_static/style/footer_extended.png
"""
if not isinstance(other, Styler):
raise TypeError("`other` must be of type `Styler`")
if not self.data.columns.equals(other.data.columns):
raise ValueError("`other.data` must have same columns as `Styler.data`")
self.concatenated = other
return self

def _repr_html_(self) -> str | None:
"""
Hooks into Jupyter notebook rich display system, which calls _repr_html_ by
Expand Down Expand Up @@ -1405,6 +1486,7 @@ def _copy(self, deepcopy: bool = False) -> Styler:
- cell_context (cell css classes)
- ctx (cell css styles)
- caption
- concatenated stylers

Non-data dependent attributes [copied and exported]:
- css
Expand Down Expand Up @@ -1435,6 +1517,7 @@ def _copy(self, deepcopy: bool = False) -> Styler:
]
deep = [ # nested lists or dicts
"css",
"concatenated",
"_display_funcs",
"_display_funcs_index",
"_display_funcs_columns",
Expand Down Expand Up @@ -2348,11 +2431,13 @@ def set_table_styles(
"col_heading": "col_heading",
"index_name": "index_name",
"col": "col",
"row": "row",
"col_trim": "col_trim",
"row_trim": "row_trim",
"level": "level",
"data": "data",
"blank": "blank}
"blank": "blank",
"foot": "foot"}

Examples
--------
Expand Down
61 changes: 45 additions & 16 deletions pandas/io/formats/style_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ def __init__(
"level": "level",
"data": "data",
"blank": "blank",
"foot": "foot",
}

self.concatenated: StylerRenderer | None = None
# add rendering variables
self.hide_index_names: bool = False
self.hide_column_names: bool = False
Expand All @@ -148,6 +149,35 @@ def __init__(
tuple[int, int], Callable[[Any], str]
] = defaultdict(lambda: partial(_default_formatter, precision=precision))

def _render(
self,
sparse_index: bool,
sparse_columns: bool,
max_rows: int | None = None,
max_cols: int | None = None,
blank: str = "",
):
"""
Computes and applies styles and then generates the general render dicts
"""
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 = {
**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"],
}
dx, _ = self.concatenated._render(
sparse_index, sparse_columns, max_rows, max_cols, blank
)
d = self._translate(sparse_index, sparse_columns, max_rows, max_cols, blank, dx)
return d, dx

def _render_html(
self,
sparse_index: bool,
Expand All @@ -160,9 +190,7 @@ def _render_html(
Renders the ``Styler`` including all applied styles to HTML.
Generates a dict with necessary kwargs passed to jinja2 template.
"""
self._compute()
# TODO: namespace all the pandas keys
d = self._translate(sparse_index, sparse_columns, max_rows, max_cols)
d, _ = self._render(sparse_index, sparse_columns, max_rows, max_cols, " ")
d.update(kwargs)
return self.template_html.render(
**d,
Expand All @@ -176,16 +204,12 @@ def _render_latex(
"""
Render a Styler in latex format
"""
self._compute()

d = self._translate(sparse_index, sparse_columns, blank="")
d, _ = self._render(sparse_index, sparse_columns, None, None)
self._translate_latex(d, clines=clines)

self.template_latex.globals["parse_wrap"] = _parse_latex_table_wrapping
self.template_latex.globals["parse_table"] = _parse_latex_table_styles
self.template_latex.globals["parse_cell"] = _parse_latex_cell_styles
self.template_latex.globals["parse_header"] = _parse_latex_header_span

d.update(kwargs)
return self.template_latex.render(**d)

Expand All @@ -200,10 +224,7 @@ def _render_string(
"""
Render a Styler in string format
"""
self._compute()

d = self._translate(sparse_index, sparse_columns, max_rows, max_cols, blank="")

d, _ = self._render(sparse_index, sparse_columns, max_rows, max_cols)
d.update(kwargs)
return self.template_string.render(**d)

Expand Down Expand Up @@ -231,6 +252,7 @@ def _translate(
max_rows: int | None = None,
max_cols: int | None = None,
blank: str = " ",
dx: dict | None = None,
):
"""
Process Styler data and settings into a dict for template rendering.
Expand All @@ -246,10 +268,12 @@ def _translate(
sparse_cols : bool
Whether to sparsify the columns or print all hierarchical column elements.
Upstream defaults are typically to `pandas.options.styler.sparse.columns`.
blank : str
Entry to top-left blank cells.
max_rows, max_cols : int, optional
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.

Returns
-------
Expand Down Expand Up @@ -295,7 +319,7 @@ def _translate(
self.cellstyle_map_index: DefaultDict[
tuple[CSSPair, ...], list[str]
] = defaultdict(list)
body = self._translate_body(idx_lengths, max_rows, max_cols)
body: list = self._translate_body(idx_lengths, max_rows, max_cols)
d.update({"body": body})

ctx_maps = {
Expand All @@ -310,6 +334,11 @@ def _translate(
]
d.update({k: map})

if dx is not None: # self.concatenated is not None
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]

table_attr = self.table_attributes
if not get_option("styler.html.mathjax"):
table_attr = table_attr or ""
Expand Down
33 changes: 33 additions & 0 deletions pandas/tests/io/formats/style/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import pytest

jinja2 = pytest.importorskip("jinja2")

from pandas import DataFrame

from pandas.io.formats.style import Styler


@pytest.fixture
def df():
return DataFrame(
data=[[0, -0.609], [1, -1.228]],
columns=["A", "B"],
index=["x", "y"],
)


@pytest.fixture
def styler(df):
return Styler(df, uuid_len=0)


def test_concat_bad_columns(styler):
msg = "`other.data` must have same columns as `Styler.data"
with pytest.raises(ValueError, match=msg):
styler.concat(DataFrame([[1, 2]]).style)


def test_concat_bad_type(styler):
msg = "`other` must be of type `Styler`"
with pytest.raises(TypeError, match=msg):
styler.concat(DataFrame([[1, 2]]))
21 changes: 21 additions & 0 deletions pandas/tests/io/formats/style/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -804,3 +804,24 @@ def test_multiple_rendered_links():
for link in links:
assert href.format(link) in result
assert href.format("text") not in result


def test_concat(styler):
other = styler.data.agg(["mean"]).style
styler.concat(other).set_uuid("X")
result = styler.to_html()
expected = dedent(
"""\
<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>
</tr>
</tbody>
</table>
"""
)
assert expected in result
1 change: 1 addition & 0 deletions pandas/tests/io/formats/style/test_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def mi_styler_comp(mi_styler):
mi_styler.hide(axis="index")
mi_styler.hide([("i0", "i1_a")], axis="index", names=True)
mi_styler.set_table_attributes('class="box"')
mi_styler.concat(mi_styler.data.agg(["mean"]).style)
mi_styler.format(na_rep="MISSING", precision=3)
mi_styler.format_index(precision=2, axis=0)
mi_styler.format_index(precision=4, axis=1)
Expand Down
16 changes: 16 additions & 0 deletions pandas/tests/io/formats/style/test_to_latex.py
Original file line number Diff line number Diff line change
Expand Up @@ -997,3 +997,19 @@ def test_col_format_len(styler):
result = styler.to_latex(environment="longtable", column_format="lrr{10cm}")
expected = r"\multicolumn{4}{r}{Continued on next page} \\"
assert expected in result


@pytest.mark.xfail # concat not yet implemented for to_latex
def test_concat(styler):
result = styler.concat(styler.data.agg(["sum"]).style).to_latex()
expected = dedent(
"""\
\\begin{tabular}{lrrl}
& A & B & C \\\\
0 & 0 & -0.61 & ab \\\\
1 & 1 & -1.22 & cd \\\\
sum & 1 & -1.830000 & abcd \\\\
\\end{tabular}
"""
)
assert result == expected
13 changes: 13 additions & 0 deletions pandas/tests/io/formats/style/test_to_string.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,16 @@ def test_string_delimiter(styler):
"""
)
assert result == expected


def test_concat(styler):
result = styler.concat(styler.data.agg(["sum"]).style).to_string()
expected = dedent(
"""\
A B C
0 0 -0.61 ab
1 1 -1.22 cd
sum 1 -1.830000 abcd
"""
)
assert result == expected