Skip to content

Commit

Permalink
ENH: make Styler compatible with non-unique indexes (pandas-dev#41269)
Browse files Browse the repository at this point in the history
  • Loading branch information
attack68 authored and JulianWgs committed Jul 3, 2021
1 parent a1fb89f commit a24bf3b
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 20 deletions.
2 changes: 2 additions & 0 deletions doc/source/whatsnew/v1.3.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 16 additions & 1 deletion pandas/io/formats/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -442,6 +446,10 @@ def set_td_classes(self, classes: DataFrame) -> Styler:
' </tbody>'
'</table>'
"""
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()):
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 7 additions & 10 deletions pandas/io/formats/style_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
124 changes: 124 additions & 0 deletions pandas/tests/io/formats/style/test_non_unique.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 0 additions & 9 deletions pandas/tests/io/formats/style/test_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down

0 comments on commit a24bf3b

Please sign in to comment.