Skip to content

Commit 115c49f

Browse files
attack68yehoshuadimarsky
authored andcommitted
ENH: allow concat of Styler objects (pandas-dev#46105)
1 parent 61f5715 commit 115c49f

File tree

11 files changed

+217
-17
lines changed

11 files changed

+217
-17
lines changed
12 KB
Loading
8.51 KB
Loading

doc/source/reference/style.rst

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Style application
4242
Styler.format
4343
Styler.format_index
4444
Styler.hide
45+
Styler.concat
4546
Styler.set_td_classes
4647
Styler.set_table_styles
4748
Styler.set_table_attributes

doc/source/whatsnew/v1.5.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Styler
2121

2222
- New method :meth:`.Styler.to_string` for alternative customisable output methods (:issue:`44502`)
2323
- Added the ability to render ``border`` and ``border-{side}`` CSS properties in Excel (:issue:`42276`)
24+
- 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`)
2425

2526
.. _whatsnew_150.enhancements.enhancement2:
2627

pandas/io/formats/style.py

+86-1
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,87 @@ def __init__(
271271
thousands=thousands,
272272
)
273273

274+
def concat(self, other: Styler) -> Styler:
275+
"""
276+
Append another Styler to combine the output into a single table.
277+
278+
.. versionadded:: 1.5.0
279+
280+
Parameters
281+
----------
282+
other : Styler
283+
The other Styler object which has already been styled and formatted. The
284+
data for this Styler must have the same columns as the original.
285+
286+
Returns
287+
-------
288+
self : Styler
289+
290+
Notes
291+
-----
292+
The purpose of this method is to extend existing styled dataframes with other
293+
metrics that may be useful but may not conform to the original's structure.
294+
For example adding a sub total row, or displaying metrics such as means,
295+
variance or counts.
296+
297+
Styles that are applied using the ``apply``, ``applymap``, ``apply_index``
298+
and ``applymap_index``, and formatting applied with ``format`` and
299+
``format_index`` will be preserved.
300+
301+
.. warning::
302+
Only the output methods ``to_html`` and ``to_string`` currently work with
303+
concatenated Stylers.
304+
305+
The output methods ``to_latex`` and ``to_excel`` **do not** work with
306+
concatenated Stylers.
307+
308+
The following should be noted:
309+
310+
- ``table_styles``, ``table_attributes``, ``caption`` and ``uuid`` are all
311+
inherited from the original Styler and not ``other``.
312+
- hidden columns and hidden index levels will be inherited from the
313+
original Styler
314+
315+
A common use case is to concatenate user defined functions with
316+
``DataFrame.agg`` or with described statistics via ``DataFrame.describe``.
317+
See examples.
318+
319+
Examples
320+
--------
321+
A common use case is adding totals rows, or otherwise, via methods calculated
322+
in ``DataFrame.agg``.
323+
324+
>>> df = DataFrame([[4, 6], [1, 9], [3, 4], [5, 5], [9,6]],
325+
... columns=["Mike", "Jim"],
326+
... index=["Mon", "Tue", "Wed", "Thurs", "Fri"])
327+
>>> styler = df.style.concat(df.agg(["sum"]).style) # doctest: +SKIP
328+
329+
.. figure:: ../../_static/style/footer_simple.png
330+
331+
Since the concatenated object is a Styler the existing functionality can be
332+
used to conditionally format it as well as the original.
333+
334+
>>> descriptors = df.agg(["sum", "mean", lambda s: s.dtype])
335+
>>> descriptors.index = ["Total", "Average", "dtype"]
336+
>>> other = (descriptors.style
337+
... .highlight_max(axis=1, subset=(["Total", "Average"], slice(None)))
338+
... .format(subset=("Average", slice(None)), precision=2, decimal=",")
339+
... .applymap(lambda v: "font-weight: bold;"))
340+
>>> styler = (df.style
341+
... .highlight_max(color="salmon")
342+
... .set_table_styles([{"selector": ".foot_row0",
343+
... "props": "border-top: 1px solid black;"}]))
344+
>>> styler.concat(other) # doctest: +SKIP
345+
346+
.. figure:: ../../_static/style/footer_extended.png
347+
"""
348+
if not isinstance(other, Styler):
349+
raise TypeError("`other` must be of type `Styler`")
350+
if not self.data.columns.equals(other.data.columns):
351+
raise ValueError("`other.data` must have same columns as `Styler.data`")
352+
self.concatenated = other
353+
return self
354+
274355
def _repr_html_(self) -> str | None:
275356
"""
276357
Hooks into Jupyter notebook rich display system, which calls _repr_html_ by
@@ -1405,6 +1486,7 @@ def _copy(self, deepcopy: bool = False) -> Styler:
14051486
- cell_context (cell css classes)
14061487
- ctx (cell css styles)
14071488
- caption
1489+
- concatenated stylers
14081490
14091491
Non-data dependent attributes [copied and exported]:
14101492
- css
@@ -1435,6 +1517,7 @@ def _copy(self, deepcopy: bool = False) -> Styler:
14351517
]
14361518
deep = [ # nested lists or dicts
14371519
"css",
1520+
"concatenated",
14381521
"_display_funcs",
14391522
"_display_funcs_index",
14401523
"_display_funcs_columns",
@@ -2348,11 +2431,13 @@ def set_table_styles(
23482431
"col_heading": "col_heading",
23492432
"index_name": "index_name",
23502433
"col": "col",
2434+
"row": "row",
23512435
"col_trim": "col_trim",
23522436
"row_trim": "row_trim",
23532437
"level": "level",
23542438
"data": "data",
2355-
"blank": "blank}
2439+
"blank": "blank",
2440+
"foot": "foot"}
23562441
23572442
Examples
23582443
--------

pandas/io/formats/style_render.py

+45-16
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,9 @@ def __init__(
120120
"level": "level",
121121
"data": "data",
122122
"blank": "blank",
123+
"foot": "foot",
123124
}
124-
125+
self.concatenated: StylerRenderer | None = None
125126
# add rendering variables
126127
self.hide_index_names: bool = False
127128
self.hide_column_names: bool = False
@@ -148,6 +149,35 @@ def __init__(
148149
tuple[int, int], Callable[[Any], str]
149150
] = defaultdict(lambda: partial(_default_formatter, precision=precision))
150151

152+
def _render(
153+
self,
154+
sparse_index: bool,
155+
sparse_columns: bool,
156+
max_rows: int | None = None,
157+
max_cols: int | None = None,
158+
blank: str = "",
159+
):
160+
"""
161+
Computes and applies styles and then generates the general render dicts
162+
"""
163+
self._compute()
164+
dx = None
165+
if self.concatenated is not None:
166+
self.concatenated.hide_index_ = self.hide_index_
167+
self.concatenated.hidden_columns = self.hidden_columns
168+
self.concatenated.css = {
169+
**self.css,
170+
"data": f"{self.css['foot']}_{self.css['data']}",
171+
"row_heading": f"{self.css['foot']}_{self.css['row_heading']}",
172+
"row": f"{self.css['foot']}_{self.css['row']}",
173+
"foot": self.css["foot"],
174+
}
175+
dx, _ = self.concatenated._render(
176+
sparse_index, sparse_columns, max_rows, max_cols, blank
177+
)
178+
d = self._translate(sparse_index, sparse_columns, max_rows, max_cols, blank, dx)
179+
return d, dx
180+
151181
def _render_html(
152182
self,
153183
sparse_index: bool,
@@ -160,9 +190,7 @@ def _render_html(
160190
Renders the ``Styler`` including all applied styles to HTML.
161191
Generates a dict with necessary kwargs passed to jinja2 template.
162192
"""
163-
self._compute()
164-
# TODO: namespace all the pandas keys
165-
d = self._translate(sparse_index, sparse_columns, max_rows, max_cols)
193+
d, _ = self._render(sparse_index, sparse_columns, max_rows, max_cols, " ")
166194
d.update(kwargs)
167195
return self.template_html.render(
168196
**d,
@@ -176,16 +204,12 @@ def _render_latex(
176204
"""
177205
Render a Styler in latex format
178206
"""
179-
self._compute()
180-
181-
d = self._translate(sparse_index, sparse_columns, blank="")
207+
d, _ = self._render(sparse_index, sparse_columns, None, None)
182208
self._translate_latex(d, clines=clines)
183-
184209
self.template_latex.globals["parse_wrap"] = _parse_latex_table_wrapping
185210
self.template_latex.globals["parse_table"] = _parse_latex_table_styles
186211
self.template_latex.globals["parse_cell"] = _parse_latex_cell_styles
187212
self.template_latex.globals["parse_header"] = _parse_latex_header_span
188-
189213
d.update(kwargs)
190214
return self.template_latex.render(**d)
191215

@@ -200,10 +224,7 @@ def _render_string(
200224
"""
201225
Render a Styler in string format
202226
"""
203-
self._compute()
204-
205-
d = self._translate(sparse_index, sparse_columns, max_rows, max_cols, blank="")
206-
227+
d, _ = self._render(sparse_index, sparse_columns, max_rows, max_cols)
207228
d.update(kwargs)
208229
return self.template_string.render(**d)
209230

@@ -231,6 +252,7 @@ def _translate(
231252
max_rows: int | None = None,
232253
max_cols: int | None = None,
233254
blank: str = " ",
255+
dx: dict | None = None,
234256
):
235257
"""
236258
Process Styler data and settings into a dict for template rendering.
@@ -246,10 +268,12 @@ def _translate(
246268
sparse_cols : bool
247269
Whether to sparsify the columns or print all hierarchical column elements.
248270
Upstream defaults are typically to `pandas.options.styler.sparse.columns`.
249-
blank : str
250-
Entry to top-left blank cells.
251271
max_rows, max_cols : int, optional
252272
Specific max rows and cols. max_elements always take precedence in render.
273+
blank : str
274+
Entry to top-left blank cells.
275+
dx : dict
276+
The render dict of the concatenated Styler.
253277
254278
Returns
255279
-------
@@ -295,7 +319,7 @@ def _translate(
295319
self.cellstyle_map_index: DefaultDict[
296320
tuple[CSSPair, ...], list[str]
297321
] = defaultdict(list)
298-
body = self._translate_body(idx_lengths, max_rows, max_cols)
322+
body: list = self._translate_body(idx_lengths, max_rows, max_cols)
299323
d.update({"body": body})
300324

301325
ctx_maps = {
@@ -310,6 +334,11 @@ def _translate(
310334
]
311335
d.update({k: map})
312336

337+
if dx is not None: # self.concatenated is not None
338+
d["body"].extend(dx["body"]) # type: ignore[union-attr]
339+
d["cellstyle"].extend(dx["cellstyle"]) # type: ignore[union-attr]
340+
d["cellstyle_index"].extend(dx["cellstyle"]) # type: ignore[union-attr]
341+
313342
table_attr = self.table_attributes
314343
if not get_option("styler.html.mathjax"):
315344
table_attr = table_attr or ""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import pytest
2+
3+
jinja2 = pytest.importorskip("jinja2")
4+
5+
from pandas import DataFrame
6+
7+
from pandas.io.formats.style import Styler
8+
9+
10+
@pytest.fixture
11+
def df():
12+
return DataFrame(
13+
data=[[0, -0.609], [1, -1.228]],
14+
columns=["A", "B"],
15+
index=["x", "y"],
16+
)
17+
18+
19+
@pytest.fixture
20+
def styler(df):
21+
return Styler(df, uuid_len=0)
22+
23+
24+
def test_concat_bad_columns(styler):
25+
msg = "`other.data` must have same columns as `Styler.data"
26+
with pytest.raises(ValueError, match=msg):
27+
styler.concat(DataFrame([[1, 2]]).style)
28+
29+
30+
def test_concat_bad_type(styler):
31+
msg = "`other` must be of type `Styler`"
32+
with pytest.raises(TypeError, match=msg):
33+
styler.concat(DataFrame([[1, 2]]))

pandas/tests/io/formats/style/test_html.py

+21
Original file line numberDiff line numberDiff line change
@@ -804,3 +804,24 @@ def test_multiple_rendered_links():
804804
for link in links:
805805
assert href.format(link) in result
806806
assert href.format("text") not in result
807+
808+
809+
def test_concat(styler):
810+
other = styler.data.agg(["mean"]).style
811+
styler.concat(other).set_uuid("X")
812+
result = styler.to_html()
813+
expected = dedent(
814+
"""\
815+
<tr>
816+
<th id="T_X_level0_row1" class="row_heading level0 row1" >b</th>
817+
<td id="T_X_row1_col0" class="data row1 col0" >2.690000</td>
818+
</tr>
819+
<tr>
820+
<th id="T_X_level0_foot_row0" class="foot_row_heading level0 foot_row0" >mean</th>
821+
<td id="T_X_foot_row0_col0" class="foot_data foot_row0 col0" >2.650000</td>
822+
</tr>
823+
</tbody>
824+
</table>
825+
"""
826+
)
827+
assert expected in result

pandas/tests/io/formats/style/test_style.py

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def mi_styler_comp(mi_styler):
5656
mi_styler.hide(axis="index")
5757
mi_styler.hide([("i0", "i1_a")], axis="index", names=True)
5858
mi_styler.set_table_attributes('class="box"')
59+
mi_styler.concat(mi_styler.data.agg(["mean"]).style)
5960
mi_styler.format(na_rep="MISSING", precision=3)
6061
mi_styler.format_index(precision=2, axis=0)
6162
mi_styler.format_index(precision=4, axis=1)

pandas/tests/io/formats/style/test_to_latex.py

+16
Original file line numberDiff line numberDiff line change
@@ -997,3 +997,19 @@ def test_col_format_len(styler):
997997
result = styler.to_latex(environment="longtable", column_format="lrr{10cm}")
998998
expected = r"\multicolumn{4}{r}{Continued on next page} \\"
999999
assert expected in result
1000+
1001+
1002+
@pytest.mark.xfail # concat not yet implemented for to_latex
1003+
def test_concat(styler):
1004+
result = styler.concat(styler.data.agg(["sum"]).style).to_latex()
1005+
expected = dedent(
1006+
"""\
1007+
\\begin{tabular}{lrrl}
1008+
& A & B & C \\\\
1009+
0 & 0 & -0.61 & ab \\\\
1010+
1 & 1 & -1.22 & cd \\\\
1011+
sum & 1 & -1.830000 & abcd \\\\
1012+
\\end{tabular}
1013+
"""
1014+
)
1015+
assert result == expected

pandas/tests/io/formats/style/test_to_string.py

+13
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,16 @@ def test_string_delimiter(styler):
4040
"""
4141
)
4242
assert result == expected
43+
44+
45+
def test_concat(styler):
46+
result = styler.concat(styler.data.agg(["sum"]).style).to_string()
47+
expected = dedent(
48+
"""\
49+
A B C
50+
0 0 -0.61 ab
51+
1 1 -1.22 cd
52+
sum 1 -1.830000 abcd
53+
"""
54+
)
55+
assert result == expected

0 commit comments

Comments
 (0)