diff --git a/doc/source/whatsnew/v1.3.0.rst b/doc/source/whatsnew/v1.3.0.rst index a81dda4e7dfdd..fec4422bac37e 100644 --- a/doc/source/whatsnew/v1.3.0.rst +++ b/doc/source/whatsnew/v1.3.0.rst @@ -138,6 +138,8 @@ The :meth:`.Styler.format` has had upgrades to easily format missing data, precision, and perform HTML escaping (:issue:`40437` :issue:`40134`). There have been numerous other bug fixes to properly format HTML and eliminate some inconsistencies (:issue:`39942` :issue:`40356` :issue:`39807` :issue:`39889` :issue:`39627`) +: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`). + Documentation has also seen major revisions in light of new features (:issue:`39720` :issue:`39317` :issue:`40493`) .. _whatsnew_130.dataframe_honors_copy_with_dict: diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 02e1369a05b93..8fc2825ffcfc5 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -322,6 +322,10 @@ def set_tooltips( raise NotImplementedError( "Tooltips can only render with 'cell_ids' is True." ) + if not ttips.index.is_unique or not ttips.columns.is_unique: + raise KeyError( + "Tooltips render only if `ttips` has unique index and columns." + ) if self.tooltips is None: # create a default instance if necessary self.tooltips = Tooltips() self.tooltips.tt_data = ttips @@ -442,6 +446,10 @@ def set_td_classes(self, classes: DataFrame) -> Styler: ' ' '' """ + if not classes.index.is_unique or not classes.columns.is_unique: + raise KeyError( + "Classes render only if `classes` has unique index and columns." + ) classes = classes.reindex_like(self.data) for r, row_tup in enumerate(classes.itertuples()): @@ -464,6 +472,12 @@ def _update_ctx(self, attrs: DataFrame) -> None: Whitespace shouldn't matter and the final trailing ';' shouldn't matter. """ + if not self.index.is_unique or not self.columns.is_unique: + raise KeyError( + "`Styler.apply` and `.applymap` are not compatible " + "with non-unique index or columns." + ) + for cn in attrs.columns: for rn, c in attrs[[cn]].itertuples(): if not c: @@ -986,10 +1000,11 @@ def set_table_styles( table_styles = [ { - "selector": str(s["selector"]) + idf + str(obj.get_loc(key)), + "selector": str(s["selector"]) + idf + str(idx), "props": maybe_convert_css_to_tuples(s["props"]), } for key, styles in table_styles.items() + for idx in obj.get_indexer_for([key]) for s in styles ] else: diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index 4aaf1eecde5e8..bd768f4f0a1d4 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -82,8 +82,6 @@ def __init__( data = data.to_frame() if not isinstance(data, DataFrame): raise TypeError("``data`` must be a Series or DataFrame") - if not data.index.is_unique or not data.columns.is_unique: - raise ValueError("style is not supported for non-unique indices.") self.data: DataFrame = data self.index: Index = data.index self.columns: Index = data.columns @@ -481,23 +479,22 @@ def format( subset = non_reducing_slice(subset) data = self.data.loc[subset] - columns = data.columns if not isinstance(formatter, dict): - formatter = {col: formatter for col in columns} + formatter = {col: formatter for col in data.columns} - for col in columns: + cis = self.columns.get_indexer_for(data.columns) + ris = self.index.get_indexer_for(data.index) + for ci in cis: format_func = _maybe_wrap_formatter( - formatter.get(col), + formatter.get(self.columns[ci]), na_rep=na_rep, precision=precision, decimal=decimal, thousands=thousands, escape=escape, ) - - for row, value in data[[col]].itertuples(): - i, j = self.index.get_loc(row), self.columns.get_loc(col) - self._display_funcs[(i, j)] = format_func + for ri in ris: + self._display_funcs[(ri, ci)] = format_func return self diff --git a/pandas/tests/io/formats/style/test_non_unique.py b/pandas/tests/io/formats/style/test_non_unique.py new file mode 100644 index 0000000000000..2dc7433009368 --- /dev/null +++ b/pandas/tests/io/formats/style/test_non_unique.py @@ -0,0 +1,124 @@ +import pytest + +from pandas import ( + DataFrame, + IndexSlice, +) + +pytest.importorskip("jinja2") + +from pandas.io.formats.style import Styler + + +@pytest.fixture +def df(): + return DataFrame( + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + index=["i", "j", "j"], + columns=["c", "d", "d"], + dtype=float, + ) + + +@pytest.fixture +def styler(df): + return Styler(df, uuid_len=0) + + +def test_format_non_unique(df): + # GH 41269 + + # test dict + html = df.style.format({"d": "{:.1f}"}).render() + for val in ["1.000000<", "4.000000<", "7.000000<"]: + assert val in html + for val in ["2.0<", "3.0<", "5.0<", "6.0<", "8.0<", "9.0<"]: + assert val in html + + # test subset + html = df.style.format(precision=1, subset=IndexSlice["j", "d"]).render() + for val in ["1.000000<", "4.000000<", "7.000000<", "2.000000<", "3.000000<"]: + assert val in html + for val in ["5.0<", "6.0<", "8.0<", "9.0<"]: + assert val in html + + +@pytest.mark.parametrize("func", ["apply", "applymap"]) +def test_apply_applymap_non_unique_raises(df, func): + # GH 41269 + if func == "apply": + op = lambda s: ["color: red;"] * len(s) + else: + op = lambda v: "color: red;" + + with pytest.raises(KeyError, match="`Styler.apply` and `.applymap` are not"): + getattr(df.style, func)(op)._compute() + + +def test_table_styles_dict_non_unique_index(styler): + styles = styler.set_table_styles( + {"j": [{"selector": "td", "props": "a: v;"}]}, axis=1 + ).table_styles + assert styles == [ + {"selector": "td.row1", "props": [("a", "v")]}, + {"selector": "td.row2", "props": [("a", "v")]}, + ] + + +def test_table_styles_dict_non_unique_columns(styler): + styles = styler.set_table_styles( + {"d": [{"selector": "td", "props": "a: v;"}]}, axis=0 + ).table_styles + assert styles == [ + {"selector": "td.col1", "props": [("a", "v")]}, + {"selector": "td.col2", "props": [("a", "v")]}, + ] + + +def test_tooltips_non_unique_raises(styler): + # ttips has unique keys + ttips = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "d"], index=["a", "b"]) + styler.set_tooltips(ttips=ttips) # OK + + # ttips has non-unique columns + ttips = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "c"], index=["a", "b"]) + with pytest.raises(KeyError, match="Tooltips render only if `ttips` has unique"): + styler.set_tooltips(ttips=ttips) + + # ttips has non-unique index + ttips = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "d"], index=["a", "a"]) + with pytest.raises(KeyError, match="Tooltips render only if `ttips` has unique"): + styler.set_tooltips(ttips=ttips) + + +def test_set_td_classes_non_unique_raises(styler): + # classes has unique keys + classes = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "d"], index=["a", "b"]) + styler.set_td_classes(classes=classes) # OK + + # classes has non-unique columns + classes = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "c"], index=["a", "b"]) + with pytest.raises(KeyError, match="Classes render only if `classes` has unique"): + styler.set_td_classes(classes=classes) + + # classes has non-unique index + classes = DataFrame([["1", "2"], ["3", "4"]], columns=["c", "d"], index=["a", "a"]) + with pytest.raises(KeyError, match="Classes render only if `classes` has unique"): + styler.set_td_classes(classes=classes) + + +def test_hide_columns_non_unique(styler): + ctx = styler.hide_columns(["d"])._translate() + + assert ctx["head"][0][1]["display_value"] == "c" + assert ctx["head"][0][1]["is_visible"] is True + + assert ctx["head"][0][2]["display_value"] == "d" + assert ctx["head"][0][2]["is_visible"] is False + + assert ctx["head"][0][3]["display_value"] == "d" + assert ctx["head"][0][3]["is_visible"] is False + + assert ctx["body"][0][1]["is_visible"] is True + assert ctx["body"][0][2]["is_visible"] is False + assert ctx["body"][0][3]["is_visible"] is False diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 3b614be770bc5..855def916c2cd 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -671,15 +671,6 @@ def test_set_na_rep(self): assert ctx["body"][0][1]["display_value"] == "NA" assert ctx["body"][0][2]["display_value"] == "-" - def test_nonunique_raises(self): - df = DataFrame([[1, 2]], columns=["A", "A"]) - msg = "style is not supported for non-unique indices." - with pytest.raises(ValueError, match=msg): - df.style - - with pytest.raises(ValueError, match=msg): - Styler(df) - def test_caption(self): styler = Styler(self.df, caption="foo") result = styler.render()