Skip to content

Commit 010328f

Browse files
quangngdattack68mroeschke
authored
EHN: add ability to format index and col names to Styler (#57880)
* add new method to styler * add html test * fix type * rename to format_index_names * Update pandas/io/formats/style_render.py Co-authored-by: JHM Darbyshire <[email protected]> * Update pandas/io/formats/style_render.py Co-authored-by: JHM Darbyshire <[email protected]> * add tests * add test * more doc * doc * update code_checks * add example * update test * Update pandas/io/formats/style_render.py Co-authored-by: Matthew Roeschke <[email protected]> * Update pandas/io/formats/style_render.py Co-authored-by: Matthew Roeschke <[email protected]> * update doc --------- Co-authored-by: JHM Darbyshire <[email protected]> Co-authored-by: Matthew Roeschke <[email protected]>
1 parent c032845 commit 010328f

File tree

8 files changed

+295
-11
lines changed

8 files changed

+295
-11
lines changed

ci/code_checks.sh

-3
Original file line numberDiff line numberDiff line change
@@ -796,8 +796,6 @@ if [[ -z "$CHECK" || "$CHECK" == "docstrings" ]]; then
796796
-i "pandas.io.formats.style.Styler.clear SA01" \
797797
-i "pandas.io.formats.style.Styler.concat RT03,SA01" \
798798
-i "pandas.io.formats.style.Styler.export RT03" \
799-
-i "pandas.io.formats.style.Styler.format RT03" \
800-
-i "pandas.io.formats.style.Styler.format_index RT03" \
801799
-i "pandas.io.formats.style.Styler.from_custom_template SA01" \
802800
-i "pandas.io.formats.style.Styler.hide RT03,SA01" \
803801
-i "pandas.io.formats.style.Styler.highlight_between RT03" \
@@ -807,7 +805,6 @@ if [[ -z "$CHECK" || "$CHECK" == "docstrings" ]]; then
807805
-i "pandas.io.formats.style.Styler.highlight_quantile RT03" \
808806
-i "pandas.io.formats.style.Styler.map RT03" \
809807
-i "pandas.io.formats.style.Styler.map_index RT03" \
810-
-i "pandas.io.formats.style.Styler.relabel_index RT03" \
811808
-i "pandas.io.formats.style.Styler.set_caption RT03,SA01" \
812809
-i "pandas.io.formats.style.Styler.set_properties RT03,SA01" \
813810
-i "pandas.io.formats.style.Styler.set_sticky RT03,SA01" \

doc/source/reference/style.rst

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Style application
4141
Styler.map_index
4242
Styler.format
4343
Styler.format_index
44+
Styler.format_index_names
4445
Styler.relabel_index
4546
Styler.hide
4647
Styler.concat

doc/source/whatsnew/v3.0.0.rst

+2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Other enhancements
3434
- Allow dictionaries to be passed to :meth:`pandas.Series.str.replace` via ``pat`` parameter (:issue:`51748`)
3535
- Support passing a :class:`Series` input to :func:`json_normalize` that retains the :class:`Series` :class:`Index` (:issue:`51452`)
3636
- Users can globally disable any ``PerformanceWarning`` by setting the option ``mode.performance_warnings`` to ``False`` (:issue:`56920`)
37+
- :meth:`Styler.format_index_names` can now be used to format the index and column names (:issue:`48936` and :issue:`47489`)
38+
-
3739

3840
.. ---------------------------------------------------------------------------
3941
.. _whatsnew_300.notable_bug_fixes:

pandas/io/formats/style.py

+2
Original file line numberDiff line numberDiff line change
@@ -1683,6 +1683,8 @@ def _copy(self, deepcopy: bool = False) -> Styler:
16831683
"_display_funcs",
16841684
"_display_funcs_index",
16851685
"_display_funcs_columns",
1686+
"_display_funcs_index_names",
1687+
"_display_funcs_column_names",
16861688
"hidden_rows",
16871689
"hidden_columns",
16881690
"ctx",

pandas/io/formats/style_render.py

+155-4
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,15 @@ def __init__(
140140
self._display_funcs_index: DefaultDict[ # maps (row, level) -> format func
141141
tuple[int, int], Callable[[Any], str]
142142
] = defaultdict(lambda: partial(_default_formatter, precision=precision))
143+
self._display_funcs_index_names: DefaultDict[ # maps index level -> format func
144+
int, Callable[[Any], str]
145+
] = defaultdict(lambda: partial(_default_formatter, precision=precision))
143146
self._display_funcs_columns: DefaultDict[ # maps (level, col) -> format func
144147
tuple[int, int], Callable[[Any], str]
145148
] = defaultdict(lambda: partial(_default_formatter, precision=precision))
149+
self._display_funcs_column_names: DefaultDict[ # maps col level -> format func
150+
int, Callable[[Any], str]
151+
] = defaultdict(lambda: partial(_default_formatter, precision=precision))
146152

147153
def _render(
148154
self,
@@ -460,6 +466,12 @@ def _generate_col_header_row(
460466
] * (self.index.nlevels - sum(self.hide_index_) - 1)
461467

462468
name = self.data.columns.names[r]
469+
470+
is_display = name is not None and not self.hide_column_names
471+
value = name if is_display else self.css["blank_value"]
472+
display_value = (
473+
self._display_funcs_column_names[r](value) if is_display else None
474+
)
463475
column_name = [
464476
_element(
465477
"th",
@@ -468,10 +480,9 @@ def _generate_col_header_row(
468480
if name is None
469481
else f"{self.css['index_name']} {self.css['level']}{r}"
470482
),
471-
name
472-
if (name is not None and not self.hide_column_names)
473-
else self.css["blank_value"],
483+
value,
474484
not all(self.hide_index_),
485+
display_value=display_value,
475486
)
476487
]
477488

@@ -553,6 +564,9 @@ def _generate_index_names_row(
553564
f"{self.css['index_name']} {self.css['level']}{c}",
554565
self.css["blank_value"] if name is None else name,
555566
not self.hide_index_[c],
567+
display_value=(
568+
None if name is None else self._display_funcs_index_names[c](name)
569+
),
556570
)
557571
for c, name in enumerate(self.data.index.names)
558572
]
@@ -1005,6 +1019,7 @@ def format(
10051019
Returns
10061020
-------
10071021
Styler
1022+
Returns itself for chaining.
10081023
10091024
See Also
10101025
--------
@@ -1261,6 +1276,7 @@ def format_index(
12611276
Returns
12621277
-------
12631278
Styler
1279+
Returns itself for chaining.
12641280
12651281
See Also
12661282
--------
@@ -1425,6 +1441,7 @@ def relabel_index(
14251441
Returns
14261442
-------
14271443
Styler
1444+
Returns itself for chaining.
14281445
14291446
See Also
14301447
--------
@@ -1560,6 +1577,140 @@ def alias_(x, value):
15601577

15611578
return self
15621579

1580+
def format_index_names(
1581+
self,
1582+
formatter: ExtFormatter | None = None,
1583+
axis: Axis = 0,
1584+
level: Level | list[Level] | None = None,
1585+
na_rep: str | None = None,
1586+
precision: int | None = None,
1587+
decimal: str = ".",
1588+
thousands: str | None = None,
1589+
escape: str | None = None,
1590+
hyperlinks: str | None = None,
1591+
) -> StylerRenderer:
1592+
r"""
1593+
Format the text display value of index names or column names.
1594+
1595+
.. versionadded:: 3.0
1596+
1597+
Parameters
1598+
----------
1599+
formatter : str, callable, dict or None
1600+
Object to define how values are displayed. See notes.
1601+
axis : {0, "index", 1, "columns"}
1602+
Whether to apply the formatter to the index or column headers.
1603+
level : int, str, list
1604+
The level(s) over which to apply the generic formatter.
1605+
na_rep : str, optional
1606+
Representation for missing values.
1607+
If ``na_rep`` is None, no special formatting is applied.
1608+
precision : int, optional
1609+
Floating point precision to use for display purposes, if not determined by
1610+
the specified ``formatter``.
1611+
decimal : str, default "."
1612+
Character used as decimal separator for floats, complex and integers.
1613+
thousands : str, optional, default None
1614+
Character used as thousands separator for floats, complex and integers.
1615+
escape : str, optional
1616+
Use 'html' to replace the characters ``&``, ``<``, ``>``, ``'``, and ``"``
1617+
in cell display string with HTML-safe sequences.
1618+
Use 'latex' to replace the characters ``&``, ``%``, ``$``, ``#``, ``_``,
1619+
``{``, ``}``, ``~``, ``^``, and ``\`` in the cell display string with
1620+
LaTeX-safe sequences.
1621+
Escaping is done before ``formatter``.
1622+
hyperlinks : {"html", "latex"}, optional
1623+
Convert string patterns containing https://, http://, ftp:// or www. to
1624+
HTML <a> tags as clickable URL hyperlinks if "html", or LaTeX \href
1625+
commands if "latex".
1626+
1627+
Returns
1628+
-------
1629+
Styler
1630+
Returns itself for chaining.
1631+
1632+
Raises
1633+
------
1634+
ValueError
1635+
If the `formatter` is a string and the dtypes are incompatible.
1636+
1637+
See Also
1638+
--------
1639+
Styler.format_index: Format the text display value of index labels
1640+
or column headers.
1641+
1642+
Notes
1643+
-----
1644+
This method has a similar signature to :meth:`Styler.format_index`. Since
1645+
`names` are generally label based, and often not numeric, the typical features
1646+
expected to be more frequently used here are ``escape`` and ``hyperlinks``.
1647+
1648+
.. warning::
1649+
`Styler.format_index_names` is ignored when using the output format
1650+
`Styler.to_excel`, since Excel and Python have inherrently different
1651+
formatting structures.
1652+
1653+
Examples
1654+
--------
1655+
>>> df = pd.DataFrame(
1656+
... [[1, 2], [3, 4]],
1657+
... index=pd.Index(["a", "b"], name="idx"),
1658+
... )
1659+
>>> df # doctest: +SKIP
1660+
0 1
1661+
idx
1662+
a 1 2
1663+
b 3 4
1664+
>>> df.style.format_index_names(lambda x: x.upper(), axis=0) # doctest: +SKIP
1665+
0 1
1666+
IDX
1667+
a 1 2
1668+
b 3 4
1669+
"""
1670+
axis = self.data._get_axis_number(axis)
1671+
if axis == 0:
1672+
display_funcs_, obj = self._display_funcs_index_names, self.index
1673+
else:
1674+
display_funcs_, obj = self._display_funcs_column_names, self.columns
1675+
levels_ = refactor_levels(level, obj)
1676+
1677+
if all(
1678+
(
1679+
formatter is None,
1680+
level is None,
1681+
precision is None,
1682+
decimal == ".",
1683+
thousands is None,
1684+
na_rep is None,
1685+
escape is None,
1686+
hyperlinks is None,
1687+
)
1688+
):
1689+
display_funcs_.clear()
1690+
return self # clear the formatter / revert to default and avoid looping
1691+
1692+
if not isinstance(formatter, dict):
1693+
formatter = {level: formatter for level in levels_}
1694+
else:
1695+
formatter = {
1696+
obj._get_level_number(level): formatter_
1697+
for level, formatter_ in formatter.items()
1698+
}
1699+
1700+
for lvl in levels_:
1701+
format_func = _maybe_wrap_formatter(
1702+
formatter.get(lvl),
1703+
na_rep=na_rep,
1704+
precision=precision,
1705+
decimal=decimal,
1706+
thousands=thousands,
1707+
escape=escape,
1708+
hyperlinks=hyperlinks,
1709+
)
1710+
display_funcs_[lvl] = format_func
1711+
1712+
return self
1713+
15631714

15641715
def _element(
15651716
html_element: str,
@@ -1571,7 +1722,7 @@ def _element(
15711722
"""
15721723
Template to return container with information for a <td></td> or <th></th> element.
15731724
"""
1574-
if "display_value" not in kwargs:
1725+
if "display_value" not in kwargs or kwargs["display_value"] is None:
15751726
kwargs["display_value"] = value
15761727
return {
15771728
"type": html_element,

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

+103-4
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,14 @@ def styler(df):
3232

3333
@pytest.fixture
3434
def df_multi():
35-
return DataFrame(
36-
data=np.arange(16).reshape(4, 4),
37-
columns=MultiIndex.from_product([["A", "B"], ["a", "b"]]),
38-
index=MultiIndex.from_product([["X", "Y"], ["x", "y"]]),
35+
return (
36+
DataFrame(
37+
data=np.arange(16).reshape(4, 4),
38+
columns=MultiIndex.from_product([["A", "B"], ["a", "b"]]),
39+
index=MultiIndex.from_product([["X", "Y"], ["x", "y"]]),
40+
)
41+
.rename_axis(["0_0", "0_1"], axis=0)
42+
.rename_axis(["1_0", "1_1"], axis=1)
3943
)
4044

4145

@@ -560,3 +564,98 @@ def test_relabel_roundtrip(styler):
560564
ctx = styler._translate(True, True)
561565
assert {"value": "x", "display_value": "x"}.items() <= ctx["body"][0][0].items()
562566
assert {"value": "y", "display_value": "y"}.items() <= ctx["body"][1][0].items()
567+
568+
569+
@pytest.mark.parametrize("axis", [0, 1])
570+
@pytest.mark.parametrize(
571+
"level, expected",
572+
[
573+
(0, ["X", "one"]), # level int
574+
("zero", ["X", "one"]), # level name
575+
(1, ["zero", "X"]), # other level int
576+
("one", ["zero", "X"]), # other level name
577+
([0, 1], ["X", "X"]), # both levels
578+
([0, "zero"], ["X", "one"]), # level int and name simultaneous
579+
([0, "one"], ["X", "X"]), # both levels as int and name
580+
(["one", "zero"], ["X", "X"]), # both level names, reversed
581+
],
582+
)
583+
def test_format_index_names_level(axis, level, expected):
584+
midx = MultiIndex.from_arrays([["_", "_"], ["_", "_"]], names=["zero", "one"])
585+
df = DataFrame([[1, 2], [3, 4]])
586+
if axis == 0:
587+
df.index = midx
588+
else:
589+
df.columns = midx
590+
591+
styler = df.style.format_index_names(lambda v: "X", level=level, axis=axis)
592+
ctx = styler._translate(True, True)
593+
594+
if axis == 0: # compare index
595+
result = [ctx["head"][1][s]["display_value"] for s in range(2)]
596+
else: # compare columns
597+
result = [ctx["head"][s][0]["display_value"] for s in range(2)]
598+
assert expected == result
599+
600+
601+
@pytest.mark.parametrize(
602+
"attr, kwargs",
603+
[
604+
("_display_funcs_index_names", {"axis": 0}),
605+
("_display_funcs_column_names", {"axis": 1}),
606+
],
607+
)
608+
def test_format_index_names_clear(styler, attr, kwargs):
609+
assert 0 not in getattr(styler, attr) # using default
610+
styler.format_index_names("{:.2f}", **kwargs)
611+
assert 0 in getattr(styler, attr) # formatter is specified
612+
styler.format_index_names(**kwargs)
613+
assert 0 not in getattr(styler, attr) # formatter cleared to default
614+
615+
616+
@pytest.mark.parametrize("axis", [0, 1])
617+
def test_format_index_names_callable(styler_multi, axis):
618+
ctx = styler_multi.format_index_names(
619+
lambda v: v.replace("_", "A"), axis=axis
620+
)._translate(True, True)
621+
result = [
622+
ctx["head"][2][0]["display_value"],
623+
ctx["head"][2][1]["display_value"],
624+
ctx["head"][0][1]["display_value"],
625+
ctx["head"][1][1]["display_value"],
626+
]
627+
if axis == 0:
628+
expected = ["0A0", "0A1", "1_0", "1_1"]
629+
else:
630+
expected = ["0_0", "0_1", "1A0", "1A1"]
631+
assert result == expected
632+
633+
634+
def test_format_index_names_dict(styler_multi):
635+
ctx = (
636+
styler_multi.format_index_names({"0_0": "{:<<5}"})
637+
.format_index_names({"1_1": "{:>>4}"}, axis=1)
638+
._translate(True, True)
639+
)
640+
assert ctx["head"][2][0]["display_value"] == "0_0<<"
641+
assert ctx["head"][1][1]["display_value"] == ">1_1"
642+
643+
644+
def test_format_index_names_with_hidden_levels(styler_multi):
645+
ctx = styler_multi._translate(True, True)
646+
full_head_height = len(ctx["head"])
647+
full_head_width = len(ctx["head"][0])
648+
assert full_head_height == 3
649+
assert full_head_width == 6
650+
651+
ctx = (
652+
styler_multi.hide(axis=0, level=1)
653+
.hide(axis=1, level=1)
654+
.format_index_names("{:>>4}", axis=1)
655+
.format_index_names("{:!<5}")
656+
._translate(True, True)
657+
)
658+
assert len(ctx["head"]) == full_head_height - 1
659+
assert len(ctx["head"][0]) == full_head_width - 1
660+
assert ctx["head"][0][0]["display_value"] == ">1_0"
661+
assert ctx["head"][1][0]["display_value"] == "0_0!!"

0 commit comments

Comments
 (0)