Skip to content

Commit ebf3b98

Browse files
authored
ENH: make Styler compatible with non-unique indexes (#41269)
1 parent 7b45be9 commit ebf3b98

File tree

5 files changed

+149
-20
lines changed

5 files changed

+149
-20
lines changed

doc/source/whatsnew/v1.3.0.rst

+2
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ The :meth:`.Styler.format` has had upgrades to easily format missing data,
138138
precision, and perform HTML escaping (:issue:`40437` :issue:`40134`). There have been numerous other bug fixes to
139139
properly format HTML and eliminate some inconsistencies (:issue:`39942` :issue:`40356` :issue:`39807` :issue:`39889` :issue:`39627`)
140140

141+
:class:`.Styler` has also been compatible with non-unique index or columns, at least for as many features as are fully compatible, others made only partially compatible (:issue:`41269`).
142+
141143
Documentation has also seen major revisions in light of new features (:issue:`39720` :issue:`39317` :issue:`40493`)
142144

143145
.. _whatsnew_130.dataframe_honors_copy_with_dict:

pandas/io/formats/style.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,10 @@ def set_tooltips(
322322
raise NotImplementedError(
323323
"Tooltips can only render with 'cell_ids' is True."
324324
)
325+
if not ttips.index.is_unique or not ttips.columns.is_unique:
326+
raise KeyError(
327+
"Tooltips render only if `ttips` has unique index and columns."
328+
)
325329
if self.tooltips is None: # create a default instance if necessary
326330
self.tooltips = Tooltips()
327331
self.tooltips.tt_data = ttips
@@ -442,6 +446,10 @@ def set_td_classes(self, classes: DataFrame) -> Styler:
442446
' </tbody>'
443447
'</table>'
444448
"""
449+
if not classes.index.is_unique or not classes.columns.is_unique:
450+
raise KeyError(
451+
"Classes render only if `classes` has unique index and columns."
452+
)
445453
classes = classes.reindex_like(self.data)
446454

447455
for r, row_tup in enumerate(classes.itertuples()):
@@ -464,6 +472,12 @@ def _update_ctx(self, attrs: DataFrame) -> None:
464472
Whitespace shouldn't matter and the final trailing ';' shouldn't
465473
matter.
466474
"""
475+
if not self.index.is_unique or not self.columns.is_unique:
476+
raise KeyError(
477+
"`Styler.apply` and `.applymap` are not compatible "
478+
"with non-unique index or columns."
479+
)
480+
467481
for cn in attrs.columns:
468482
for rn, c in attrs[[cn]].itertuples():
469483
if not c:
@@ -986,10 +1000,11 @@ def set_table_styles(
9861000

9871001
table_styles = [
9881002
{
989-
"selector": str(s["selector"]) + idf + str(obj.get_loc(key)),
1003+
"selector": str(s["selector"]) + idf + str(idx),
9901004
"props": maybe_convert_css_to_tuples(s["props"]),
9911005
}
9921006
for key, styles in table_styles.items()
1007+
for idx in obj.get_indexer_for([key])
9931008
for s in styles
9941009
]
9951010
else:

pandas/io/formats/style_render.py

+7-10
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,6 @@ def __init__(
8282
data = data.to_frame()
8383
if not isinstance(data, DataFrame):
8484
raise TypeError("``data`` must be a Series or DataFrame")
85-
if not data.index.is_unique or not data.columns.is_unique:
86-
raise ValueError("style is not supported for non-unique indices.")
8785
self.data: DataFrame = data
8886
self.index: Index = data.index
8987
self.columns: Index = data.columns
@@ -481,23 +479,22 @@ def format(
481479
subset = non_reducing_slice(subset)
482480
data = self.data.loc[subset]
483481

484-
columns = data.columns
485482
if not isinstance(formatter, dict):
486-
formatter = {col: formatter for col in columns}
483+
formatter = {col: formatter for col in data.columns}
487484

488-
for col in columns:
485+
cis = self.columns.get_indexer_for(data.columns)
486+
ris = self.index.get_indexer_for(data.index)
487+
for ci in cis:
489488
format_func = _maybe_wrap_formatter(
490-
formatter.get(col),
489+
formatter.get(self.columns[ci]),
491490
na_rep=na_rep,
492491
precision=precision,
493492
decimal=decimal,
494493
thousands=thousands,
495494
escape=escape,
496495
)
497-
498-
for row, value in data[[col]].itertuples():
499-
i, j = self.index.get_loc(row), self.columns.get_loc(col)
500-
self._display_funcs[(i, j)] = format_func
496+
for ri in ris:
497+
self._display_funcs[(ri, ci)] = format_func
501498

502499
return self
503500

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import pytest
2+
3+
from pandas import (
4+
DataFrame,
5+
IndexSlice,
6+
)
7+
8+
pytest.importorskip("jinja2")
9+
10+
from pandas.io.formats.style import Styler
11+
12+
13+
@pytest.fixture
14+
def df():
15+
return DataFrame(
16+
[[1, 2, 3], [4, 5, 6], [7, 8, 9]],
17+
index=["i", "j", "j"],
18+
columns=["c", "d", "d"],
19+
dtype=float,
20+
)
21+
22+
23+
@pytest.fixture
24+
def styler(df):
25+
return Styler(df, uuid_len=0)
26+
27+
28+
def test_format_non_unique(df):
29+
# GH 41269
30+
31+
# test dict
32+
html = df.style.format({"d": "{:.1f}"}).render()
33+
for val in ["1.000000<", "4.000000<", "7.000000<"]:
34+
assert val in html
35+
for val in ["2.0<", "3.0<", "5.0<", "6.0<", "8.0<", "9.0<"]:
36+
assert val in html
37+
38+
# test subset
39+
html = df.style.format(precision=1, subset=IndexSlice["j", "d"]).render()
40+
for val in ["1.000000<", "4.000000<", "7.000000<", "2.000000<", "3.000000<"]:
41+
assert val in html
42+
for val in ["5.0<", "6.0<", "8.0<", "9.0<"]:
43+
assert val in html
44+
45+
46+
@pytest.mark.parametrize("func", ["apply", "applymap"])
47+
def test_apply_applymap_non_unique_raises(df, func):
48+
# GH 41269
49+
if func == "apply":
50+
op = lambda s: ["color: red;"] * len(s)
51+
else:
52+
op = lambda v: "color: red;"
53+
54+
with pytest.raises(KeyError, match="`Styler.apply` and `.applymap` are not"):
55+
getattr(df.style, func)(op)._compute()
56+
57+
58+
def test_table_styles_dict_non_unique_index(styler):
59+
styles = styler.set_table_styles(
60+
{"j": [{"selector": "td", "props": "a: v;"}]}, axis=1
61+
).table_styles
62+
assert styles == [
63+
{"selector": "td.row1", "props": [("a", "v")]},
64+
{"selector": "td.row2", "props": [("a", "v")]},
65+
]
66+
67+
68+
def test_table_styles_dict_non_unique_columns(styler):
69+
styles = styler.set_table_styles(
70+
{"d": [{"selector": "td", "props": "a: v;"}]}, axis=0
71+
).table_styles
72+
assert styles == [
73+
{"selector": "td.col1", "props": [("a", "v")]},
74+
{"selector": "td.col2", "props": [("a", "v")]},
75+
]
76+
77+
78+
def test_tooltips_non_unique_raises(styler):
79+
# ttips has unique keys
80+
ttips = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "d"], index=["a", "b"])
81+
styler.set_tooltips(ttips=ttips) # OK
82+
83+
# ttips has non-unique columns
84+
ttips = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "c"], index=["a", "b"])
85+
with pytest.raises(KeyError, match="Tooltips render only if `ttips` has unique"):
86+
styler.set_tooltips(ttips=ttips)
87+
88+
# ttips has non-unique index
89+
ttips = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "d"], index=["a", "a"])
90+
with pytest.raises(KeyError, match="Tooltips render only if `ttips` has unique"):
91+
styler.set_tooltips(ttips=ttips)
92+
93+
94+
def test_set_td_classes_non_unique_raises(styler):
95+
# classes has unique keys
96+
classes = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "d"], index=["a", "b"])
97+
styler.set_td_classes(classes=classes) # OK
98+
99+
# classes has non-unique columns
100+
classes = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "c"], index=["a", "b"])
101+
with pytest.raises(KeyError, match="Classes render only if `classes` has unique"):
102+
styler.set_td_classes(classes=classes)
103+
104+
# classes has non-unique index
105+
classes = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "d"], index=["a", "a"])
106+
with pytest.raises(KeyError, match="Classes render only if `classes` has unique"):
107+
styler.set_td_classes(classes=classes)
108+
109+
110+
def test_hide_columns_non_unique(styler):
111+
ctx = styler.hide_columns(["d"])._translate()
112+
113+
assert ctx["head"][0][1]["display_value"] == "c"
114+
assert ctx["head"][0][1]["is_visible"] is True
115+
116+
assert ctx["head"][0][2]["display_value"] == "d"
117+
assert ctx["head"][0][2]["is_visible"] is False
118+
119+
assert ctx["head"][0][3]["display_value"] == "d"
120+
assert ctx["head"][0][3]["is_visible"] is False
121+
122+
assert ctx["body"][0][1]["is_visible"] is True
123+
assert ctx["body"][0][2]["is_visible"] is False
124+
assert ctx["body"][0][3]["is_visible"] is False

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

-9
Original file line numberDiff line numberDiff line change
@@ -671,15 +671,6 @@ def test_set_na_rep(self):
671671
assert ctx["body"][0][1]["display_value"] == "NA"
672672
assert ctx["body"][0][2]["display_value"] == "-"
673673

674-
def test_nonunique_raises(self):
675-
df = DataFrame([[1, 2]], columns=["A", "A"])
676-
msg = "style is not supported for non-unique indices."
677-
with pytest.raises(ValueError, match=msg):
678-
df.style
679-
680-
with pytest.raises(ValueError, match=msg):
681-
Styler(df)
682-
683674
def test_caption(self):
684675
styler = Styler(self.df, caption="foo")
685676
result = styler.render()

0 commit comments

Comments
 (0)