diff --git a/doc/source/_static/style/appmaphead1.png b/doc/source/_static/style/appmaphead1.png new file mode 100644 index 0000000000000..905bcaa63e900 Binary files /dev/null and b/doc/source/_static/style/appmaphead1.png differ diff --git a/doc/source/_static/style/appmaphead2.png b/doc/source/_static/style/appmaphead2.png new file mode 100644 index 0000000000000..9adde61908378 Binary files /dev/null and b/doc/source/_static/style/appmaphead2.png differ diff --git a/doc/source/reference/style.rst b/doc/source/reference/style.rst index 7b790daea37ff..ac4fc314c6c07 100644 --- a/doc/source/reference/style.rst +++ b/doc/source/reference/style.rst @@ -36,6 +36,8 @@ Style application Styler.apply Styler.applymap + Styler.apply_index + Styler.applymap_index Styler.format Styler.hide_index Styler.hide_columns diff --git a/doc/source/user_guide/style.ipynb b/doc/source/user_guide/style.ipynb index f77d134d75988..10ef65a68eefa 100644 --- a/doc/source/user_guide/style.ipynb +++ b/doc/source/user_guide/style.ipynb @@ -225,13 +225,15 @@ "\n", "- Using [.set_table_styles()][table] to control broader areas of the table with specified internal CSS. Although table styles allow the flexibility to add CSS selectors and properties controlling all individual parts of the table, they are unwieldy for individual cell specifications. Also, note that table styles cannot be exported to Excel. \n", "- Using [.set_td_classes()][td_class] to directly link either external CSS classes to your data cells or link the internal CSS classes created by [.set_table_styles()][table]. See [here](#Setting-Classes-and-Linking-to-External-CSS). These cannot be used on column header rows or indexes, and also won't export to Excel. \n", - "- Using the [.apply()][apply] and [.applymap()][applymap] functions to add direct internal CSS to specific data cells. See [here](#Styler-Functions). These cannot be used on column header rows or indexes, but only these methods add styles that will export to Excel. These methods work in a similar way to [DataFrame.apply()][dfapply] and [DataFrame.applymap()][dfapplymap].\n", + "- Using the [.apply()][apply] and [.applymap()][applymap] functions to add direct internal CSS to specific data cells. See [here](#Styler-Functions). As of v1.4.0 there are also methods that work directly on column header rows or indexes; [.apply_index()][applyindex] and [.applymap_index()][applymapindex]. Note that only these methods add styles that will export to Excel. These methods work in a similar way to [DataFrame.apply()][dfapply] and [DataFrame.applymap()][dfapplymap].\n", "\n", "[table]: ../reference/api/pandas.io.formats.style.Styler.set_table_styles.rst\n", "[styler]: ../reference/api/pandas.io.formats.style.Styler.rst\n", "[td_class]: ../reference/api/pandas.io.formats.style.Styler.set_td_classes.rst\n", "[apply]: ../reference/api/pandas.io.formats.style.Styler.apply.rst\n", "[applymap]: ../reference/api/pandas.io.formats.style.Styler.applymap.rst\n", + "[applyindex]: ../reference/api/pandas.io.formats.style.Styler.apply_index.rst\n", + "[applymapindex]: ../reference/api/pandas.io.formats.style.Styler.applymap_index.rst\n", "[dfapply]: ../reference/api/pandas.DataFrame.apply.rst\n", "[dfapplymap]: ../reference/api/pandas.DataFrame.applymap.rst" ] @@ -432,6 +434,8 @@ "source": [ "## Styler Functions\n", "\n", + "### Acting on Data\n", + "\n", "We use the following methods to pass your style functions. Both of those methods take a function (and some other keyword arguments) and apply it to the DataFrame in a certain way, rendering CSS styles.\n", "\n", "- [.applymap()][applymap] (elementwise): accepts a function that takes a single value and returns a string with the CSS attribute-value pair.\n", @@ -533,6 +537,18 @@ " .apply(highlight_max, props='color:white;background-color:purple', axis=None)" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbsphinx": "hidden" + }, + "outputs": [], + "source": [ + "# Hidden cell to avoid CSS clashes and latter code upcoding previous formatting \n", + "s2.set_uuid('after_apply_again')" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -548,6 +564,33 @@ "" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Acting on the Index and Column Headers\n", + "\n", + "Similar application is acheived for headers by using:\n", + " \n", + "- [.applymap_index()][applymapindex] (elementwise): accepts a function that takes a single value and returns a string with the CSS attribute-value pair.\n", + "- [.apply_index()][applyindex] (level-wise): accepts a function that takes a Series and returns a Series, or numpy array with an identical shape where each element is a string with a CSS attribute-value pair. This method passes each level of your Index one-at-a-time. To style the index use `axis=0` and to style the column headers use `axis=1`.\n", + "\n", + "You can select a `level` of a `MultiIndex` but currently no similar `subset` application is available for these methods.\n", + "\n", + "[applyindex]: ../reference/api/pandas.io.formats.style.Styler.apply_index.rst\n", + "[applymapindex]: ../reference/api/pandas.io.formats.style.Styler.applymap_index.rst" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "s2.applymap_index(lambda v: \"color:pink;\" if v>4 else \"color:darkblue;\", axis=0)\n", + "s2.apply_index(lambda s: np.where(s.isin([\"A\", \"B\"]), \"color:pink;\", \"color:darkblue;\"), axis=1)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -1931,6 +1974,7 @@ } ], "metadata": { + "celltoolbar": "Edit Metadata", "kernelspec": { "display_name": "Python 3", "language": "python", diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst index 6079cfd9b4aa2..ef7ff2d24009e 100644 --- a/doc/source/whatsnew/v1.4.0.rst +++ b/doc/source/whatsnew/v1.4.0.rst @@ -38,6 +38,7 @@ Other enhancements - :meth:`Series.ewm`, :meth:`DataFrame.ewm`, now support a ``method`` argument with a ``'table'`` option that performs the windowing operation over an entire :class:`DataFrame`. See :ref:`Window Overview ` for performance and functional benefits (:issue:`42273`) - Added ``sparse_index`` and ``sparse_columns`` keyword arguments to :meth:`.Styler.to_html` (:issue:`41946`) - Added keyword argument ``environment`` to :meth:`.Styler.to_latex` also allowing a specific "longtable" entry with a separate jinja2 template (:issue:`41866`) +- :meth:`.Styler.apply_index` and :meth:`.Styler.applymap_index` added to allow conditional styling of index and column header values (:issue:`41893`) - :meth:`.GroupBy.cummin` and :meth:`.GroupBy.cummax` now support the argument ``skipna`` (:issue:`34047`) - diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 1a891d76a376c..a72de753d6a8a 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -1012,6 +1012,32 @@ def _update_ctx(self, attrs: DataFrame) -> None: i, j = self.index.get_loc(rn), self.columns.get_loc(cn) self.ctx[(i, j)].extend(css_list) + def _update_ctx_header(self, attrs: DataFrame, axis: str) -> None: + """ + Update the state of the ``Styler`` for header cells. + + Collects a mapping of {index_label: [('', ''), ..]}. + + Parameters + ---------- + attrs : Series + Should contain strings of ': ;: ', and an + integer index. + Whitespace shouldn't matter and the final trailing ';' shouldn't + matter. + axis : str + Identifies whether the ctx object being updated is the index or columns + """ + for j in attrs.columns: + for i, c in attrs[[j]].itertuples(): + if not c: + continue + css_list = maybe_convert_css_to_tuples(c) + if axis == "index": + self.ctx_index[(i, j)].extend(css_list) + else: + self.ctx_columns[(j, i)].extend(css_list) + def _copy(self, deepcopy: bool = False) -> Styler: """ Copies a Styler, allowing for deepcopy or shallow copy @@ -1051,6 +1077,8 @@ def _copy(self, deepcopy: bool = False) -> Styler: "hidden_rows", "hidden_columns", "ctx", + "ctx_index", + "ctx_columns", "cell_context", "_todo", "table_styles", @@ -1172,6 +1200,8 @@ def apply( See Also -------- + Styler.applymap_index: Apply a CSS-styling function to headers elementwise. + Styler.apply_index: Apply a CSS-styling function to headers level-wise. Styler.applymap: Apply a CSS-styling function elementwise. Notes @@ -1215,6 +1245,149 @@ def apply( ) return self + def _apply_index( + self, + func: Callable[..., Styler], + axis: int | str = 0, + level: Level | list[Level] | None = None, + method: str = "apply", + **kwargs, + ) -> Styler: + if axis in [0, "index"]: + obj, axis = self.index, "index" + elif axis in [1, "columns"]: + obj, axis = self.columns, "columns" + else: + raise ValueError( + f"`axis` must be one of 0, 1, 'index', 'columns', got {axis}" + ) + + levels_ = _refactor_levels(level, obj) + data = DataFrame(obj.to_list()).loc[:, levels_] + + if method == "apply": + result = data.apply(func, axis=0, **kwargs) + elif method == "applymap": + result = data.applymap(func, **kwargs) + + self._update_ctx_header(result, axis) + return self + + @doc( + this="apply", + wise="level-wise", + alt="applymap", + altwise="elementwise", + func="take a Series and return a string array of the same length", + axis='{0, 1, "index", "columns"}', + input_note="the index as a Series, if an Index, or a level of a MultiIndex", + output_note="an identically sized array of CSS styles as strings", + var="s", + ret='np.where(s == "B", "background-color: yellow;", "")', + ret2='["background-color: yellow;" if "x" in v else "" for v in s]', + ) + def apply_index( + self, + func: Callable[..., Styler], + axis: int | str = 0, + level: Level | list[Level] | None = None, + **kwargs, + ) -> Styler: + """ + Apply a CSS-styling function to the index or column headers, {wise}. + + Updates the HTML representation with the result. + + .. versionadded:: 1.4.0 + + Parameters + ---------- + func : function + ``func`` should {func}. + axis : {axis} + The headers over which to apply the function. + level : int, str, list, optional + If index is MultiIndex the level(s) over which to apply the function. + **kwargs : dict + Pass along to ``func``. + + Returns + ------- + self : Styler + + See Also + -------- + Styler.{alt}_index: Apply a CSS-styling function to headers {altwise}. + Styler.apply: Apply a CSS-styling function column-wise, row-wise, or table-wise. + Styler.applymap: Apply a CSS-styling function elementwise. + + Notes + ----- + Each input to ``func`` will be {input_note}. The output of ``func`` should be + {output_note}, in the format 'attribute: value; attribute2: value2; ...' + or, if nothing is to be applied to that element, an empty string or ``None``. + + Examples + -------- + Basic usage to conditionally highlight values in the index. + + >>> df = pd.DataFrame([[1,2], [3,4]], index=["A", "B"]) + >>> def color_b(s): + ... return {ret} + >>> df.style.{this}_index(color_b) # doctest: +SKIP + + .. figure:: ../../_static/style/appmaphead1.png + + Selectively applying to specific levels of MultiIndex columns. + + >>> midx = pd.MultiIndex.from_product([['ix', 'jy'], [0, 1], ['x3', 'z4']]) + >>> df = pd.DataFrame([np.arange(8)], columns=midx) + >>> def highlight_x({var}): + ... return {ret2} + >>> df.style.{this}_index(highlight_x, axis="columns", level=[0, 2]) + ... # doctest: +SKIP + + .. figure:: ../../_static/style/appmaphead2.png + """ + self._todo.append( + ( + lambda instance: getattr(instance, "_apply_index"), + (func, axis, level, "apply"), + kwargs, + ) + ) + return self + + @doc( + apply_index, + this="applymap", + wise="elementwise", + alt="apply", + altwise="level-wise", + func="take a scalar and return a string", + axis='{0, 1, "index", "columns"}', + input_note="an index value, if an Index, or a level value of a MultiIndex", + output_note="CSS styles as a string", + var="v", + ret='"background-color: yellow;" if v == "B" else None', + ret2='"background-color: yellow;" if "x" in v else None', + ) + def applymap_index( + self, + func: Callable[..., Styler], + axis: int | str = 0, + level: Level | list[Level] | None = None, + **kwargs, + ) -> Styler: + self._todo.append( + ( + lambda instance: getattr(instance, "_apply_index"), + (func, axis, level, "applymap"), + kwargs, + ) + ) + return self + def _applymap( self, func: Callable, subset: Subset | None = None, **kwargs ) -> Styler: @@ -1237,7 +1410,7 @@ def applymap( Parameters ---------- func : function - ``func`` should take a scalar and return a scalar. + ``func`` should take a scalar and return a string. subset : label, array-like, IndexSlice, optional A valid 2d input to `DataFrame.loc[]`, or, in the case of a 1d input or single key, to `DataFrame.loc[:, ]` where the columns are @@ -1251,6 +1424,8 @@ def applymap( See Also -------- + Styler.applymap_index: Apply a CSS-styling function to headers elementwise. + Styler.apply_index: Apply a CSS-styling function to headers level-wise. Styler.apply: Apply a CSS-styling function column-wise, row-wise, or table-wise. Notes diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index e89d4519543c6..c45519bf31ff2 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -102,6 +102,8 @@ def __init__( self.hidden_rows: Sequence[int] = [] # sequence for specific hidden rows/cols self.hidden_columns: Sequence[int] = [] self.ctx: DefaultDict[tuple[int, int], CSSList] = defaultdict(list) + self.ctx_index: DefaultDict[tuple[int, int], CSSList] = defaultdict(list) + self.ctx_columns: DefaultDict[tuple[int, int], CSSList] = defaultdict(list) self.cell_context: DefaultDict[tuple[int, int], str] = defaultdict(str) self._todo: list[tuple[Callable, tuple, dict]] = [] self.tooltips: Tooltips | None = None @@ -152,6 +154,8 @@ def _compute(self): (application method, *args, **kwargs) """ self.ctx.clear() + self.ctx_index.clear() + self.ctx_columns.clear() r = self for func, args, kwargs in self._todo: r = func(self)(*args, **kwargs) @@ -201,6 +205,9 @@ def _translate(self, sparse_index: bool, sparse_cols: bool, blank: str = "  len(self.data.index), len(self.data.columns), max_elements ) + self.cellstyle_map_columns: DefaultDict[ + tuple[CSSPair, ...], list[str] + ] = defaultdict(list) head = self._translate_header( BLANK_CLASS, BLANK_VALUE, @@ -215,6 +222,9 @@ def _translate(self, sparse_index: bool, sparse_cols: bool, blank: str = "  self.cellstyle_map: DefaultDict[tuple[CSSPair, ...], list[str]] = defaultdict( list ) + self.cellstyle_map_index: DefaultDict[ + tuple[CSSPair, ...], list[str] + ] = defaultdict(list) body = self._translate_body( DATA_CLASS, ROW_HEADING_CLASS, @@ -226,11 +236,17 @@ def _translate(self, sparse_index: bool, sparse_cols: bool, blank: str = "  ) d.update({"body": body}) - cellstyle: list[dict[str, CSSList | list[str]]] = [ - {"props": list(props), "selectors": selectors} - for props, selectors in self.cellstyle_map.items() - ] - d.update({"cellstyle": cellstyle}) + ctx_maps = { + "cellstyle": "cellstyle_map", + "cellstyle_index": "cellstyle_map_index", + "cellstyle_columns": "cellstyle_map_columns", + } # add the cell_ids styles map to the render dictionary in right format + for k, attr in ctx_maps.items(): + map = [ + {"props": list(props), "selectors": selectors} + for props, selectors in getattr(self, attr).items() + ] + d.update({k: map}) table_attr = self.table_attributes use_mathjax = get_option("display.html.use_mathjax") @@ -323,8 +339,9 @@ def _translate_header( ] if clabels: - column_headers = [ - _element( + column_headers = [] + for c, value in enumerate(clabels[r]): + header_element = _element( "th", f"{col_heading_class} level{r} col{c}", value, @@ -335,8 +352,16 @@ def _translate_header( else "" ), ) - for c, value in enumerate(clabels[r]) - ] + + if self.cell_ids: + header_element["id"] = f"level{r}_col{c}" + if (r, c) in self.ctx_columns and self.ctx_columns[r, c]: + header_element["id"] = f"level{r}_col{c}" + self.cellstyle_map_columns[ + tuple(self.ctx_columns[r, c]) + ].append(f"level{r}_col{c}") + + column_headers.append(header_element) if len(self.data.columns) > max_cols: # add an extra column with `...` value to indicate trimming @@ -470,21 +495,30 @@ def _translate_body( body.append(index_headers + data) break - index_headers = [ - _element( + index_headers = [] + for c, value in enumerate(rlabels[r]): + header_element = _element( "th", f"{row_heading_class} level{c} row{r}", value, (_is_visible(r, c, idx_lengths) and not self.hide_index_[c]), - id=f"level{c}_row{r}", attributes=( f'rowspan="{idx_lengths.get((c, r), 0)}"' if idx_lengths.get((c, r), 0) > 1 else "" ), ) - for c, value in enumerate(rlabels[r]) - ] + + if self.cell_ids: + header_element["id"] = f"level{c}_row{r}" # id is specified + if (r, c) in self.ctx_index and self.ctx_index[r, c]: + # always add id if a style is specified + header_element["id"] = f"level{c}_row{r}" + self.cellstyle_map_index[tuple(self.ctx_index[r, c])].append( + f"level{c}_row{r}" + ) + + index_headers.append(header_element) data = [] for c, value in enumerate(row_tup[1:]): @@ -514,13 +548,12 @@ def _translate_body( display_value=self._display_funcs[(r, c)](value), ) - # only add an id if the cell has a style - if self.cell_ids or (r, c) in self.ctx: + if self.cell_ids: data_element["id"] = f"row{r}_col{c}" - if (r, c) in self.ctx and self.ctx[r, c]: # only add if non-empty - self.cellstyle_map[tuple(self.ctx[r, c])].append( - f"row{r}_col{c}" - ) + if (r, c) in self.ctx and self.ctx[r, c]: + # always add id if needed due to specified style + data_element["id"] = f"row{r}_col{c}" + self.cellstyle_map[tuple(self.ctx[r, c])].append(f"row{r}_col{c}") data.append(data_element) diff --git a/pandas/io/formats/templates/html_style.tpl b/pandas/io/formats/templates/html_style.tpl index b34893076bedd..5b0e7a2ed882b 100644 --- a/pandas/io/formats/templates/html_style.tpl +++ b/pandas/io/formats/templates/html_style.tpl @@ -12,13 +12,15 @@ {% endblock table_styles %} {% block before_cellstyle %}{% endblock before_cellstyle %} {% block cellstyle %} -{% for s in cellstyle %} +{% for cs in [cellstyle, cellstyle_index, cellstyle_columns] %} +{% for s in cs %} {% for selector in s.selectors %}{% if not loop.first %}, {% endif %}#T_{{uuid}}{{selector}}{% endfor %} { {% for p,val in s.props %} {{p}}: {{val}}; {% endfor %} } {% endfor %} +{% endfor %} {% endblock cellstyle %} {% endblock style %} diff --git a/pandas/io/formats/templates/html_table.tpl b/pandas/io/formats/templates/html_table.tpl index 33153af6f0882..3e3a40b9fdaa6 100644 --- a/pandas/io/formats/templates/html_table.tpl +++ b/pandas/io/formats/templates/html_table.tpl @@ -27,7 +27,7 @@ {% else %} {% for c in r %} {% if c.is_visible != False %} - <{{c.type}} class="{{c.class}}" {{c.attributes}}>{{c.value}} + <{{c.type}} {%- if c.id is defined %} id="T_{{uuid}}{{c.id}}" {%- endif %} class="{{c.class}}" {{c.attributes}}>{{c.value}} {% endif %} {% endfor %} {% endif %} @@ -49,7 +49,7 @@ {% endif %}{% endfor %} {% else %} {% for c in r %}{% if c.is_visible != False %} - <{{c.type}} {% if c.id is defined -%} id="T_{{uuid}}{{c.id}}" {%- endif %} class="{{c.class}}" {{c.attributes}}>{{c.display_value}} + <{{c.type}} {%- if c.id is defined %} id="T_{{uuid}}{{c.id}}" {%- endif %} class="{{c.class}}" {{c.attributes}}>{{c.display_value}} {% endif %}{% endfor %} {% endif %} diff --git a/pandas/tests/io/formats/style/test_html.py b/pandas/tests/io/formats/style/test_html.py index 9983017652919..bcf3c4dbad3a8 100644 --- a/pandas/tests/io/formats/style/test_html.py +++ b/pandas/tests/io/formats/style/test_html.py @@ -108,7 +108,7 @@ def test_w3_html_format(styler):   - A + A @@ -138,10 +138,7 @@ def test_rowspan_w3(): # GH 38533 df = DataFrame(data=[[1, 2]], index=[["l0", "l0"], ["l1a", "l1b"]]) styler = Styler(df, uuid="_", cell_ids=False) - assert ( - 'l0' in styler.render() - ) + assert 'l0' in styler.render() def test_styles(styler): @@ -165,7 +162,7 @@ def test_styles(styler):   - A + A @@ -400,3 +397,36 @@ def test_sparse_options(sparse_index, sparse_columns): assert (html1 == default_html) is (sparse_index and sparse_columns) html2 = styler.to_html(sparse_index=sparse_index, sparse_columns=sparse_columns) assert html1 == html2 + + +@pytest.mark.parametrize("index", [True, False]) +@pytest.mark.parametrize("columns", [True, False]) +def test_applymap_header_cell_ids(styler, index, columns): + # GH 41893 + func = lambda v: "attr: val;" + styler.uuid, styler.cell_ids = "", False + if index: + styler.applymap_index(func, axis="index") + if columns: + styler.applymap_index(func, axis="columns") + + result = styler.to_html() + + # test no data cell ids + assert '2.610000' in result + assert '2.690000' in result + + # test index header ids where needed and css styles + assert ( + 'a' in result + ) is index + assert ( + 'b' in result + ) is index + assert ("#T_level0_row0, #T_level0_row1 {\n attr: val;\n}" in result) is index + + # test column header ids where needed and css styles + assert ( + 'A' in result + ) is columns + assert ("#T_level0_col0 {\n attr: val;\n}" in result) is columns diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 3c042e130981c..6cc4b889d369a 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -52,6 +52,8 @@ def mi_styler_comp(mi_styler): mi_styler.set_table_attributes('class="box"') mi_styler.format(na_rep="MISSING", precision=3) mi_styler.highlight_max(axis=None) + mi_styler.applymap_index(lambda x: "color: white;", axis=0) + mi_styler.applymap_index(lambda x: "color: black;", axis=1) mi_styler.set_td_classes( DataFrame( [["a", "b"], ["a", "c"]], index=mi_styler.index, columns=mi_styler.columns @@ -198,7 +200,14 @@ def test_copy(comprehensive, render, deepcopy, mi_styler, mi_styler_comp): if render: styler.to_html() - excl = ["na_rep", "precision", "uuid", "cellstyle_map"] # deprecated or special var + excl = [ + "na_rep", # deprecated + "precision", # deprecated + "uuid", # special + "cellstyle_map", # render time vars.. + "cellstyle_map_columns", + "cellstyle_map_index", + ] if not deepcopy: # check memory locations are equal for all included attributes for attr in [a for a in styler.__dict__ if (not callable(a) and a not in excl)]: assert id(getattr(s2, attr)) == id(getattr(styler, attr)) @@ -245,6 +254,8 @@ def test_clear(mi_styler_comp): "uuid_len", "cell_ids", "cellstyle_map", # execution time only + "cellstyle_map_columns", # execution time only + "cellstyle_map_index", # execution time only "precision", # deprecated "na_rep", # deprecated ] @@ -296,6 +307,48 @@ def test_hide_columns_level(mi_styler, level, names): assert len(ctx["head"]) == (2 if names else 1) +@pytest.mark.parametrize("method", ["applymap", "apply"]) +@pytest.mark.parametrize("axis", ["index", "columns"]) +def test_apply_map_header(method, axis): + # GH 41893 + df = DataFrame({"A": [0, 0], "B": [1, 1]}, index=["C", "D"]) + func = { + "apply": lambda s: ["attr: val" if ("A" in v or "C" in v) else "" for v in s], + "applymap": lambda v: "attr: val" if ("A" in v or "C" in v) else "", + } + + # test execution added to todo + result = getattr(df.style, f"{method}_index")(func[method], axis=axis) + assert len(result._todo) == 1 + assert len(getattr(result, f"ctx_{axis}")) == 0 + + # test ctx object on compute + result._compute() + expected = { + (0, 0): [("attr", "val")], + } + assert getattr(result, f"ctx_{axis}") == expected + + +@pytest.mark.parametrize("method", ["apply", "applymap"]) +@pytest.mark.parametrize("axis", ["index", "columns"]) +def test_apply_map_header_mi(mi_styler, method, axis): + # GH 41893 + func = { + "apply": lambda s: ["attr: val;" if "b" in v else "" for v in s], + "applymap": lambda v: "attr: val" if "b" in v else "", + } + result = getattr(mi_styler, f"{method}_index")(func[method], axis=axis)._compute() + expected = {(1, 1): [("attr", "val")]} + assert getattr(result, f"ctx_{axis}") == expected + + +def test_apply_map_header_raises(mi_styler): + # GH 41893 + with pytest.raises(ValueError, match="`axis` must be one of 0, 1, 'index', 'col"): + mi_styler.applymap_index(lambda v: "attr: val;", axis="bad-axis")._compute() + + class TestStyler: def setup_method(self, method): np.random.seed(24) @@ -410,161 +463,58 @@ def test_empty_index_name_doesnt_display(self): # https://github.com/pandas-dev/pandas/pull/12090#issuecomment-180695902 df = DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) result = df.style._translate(True, True) - - expected = [ - [ - { - "class": "blank level0", - "type": "th", - "value": self.blank_value, - "is_visible": True, - "display_value": self.blank_value, - }, - { - "class": "col_heading level0 col0", - "display_value": "A", - "type": "th", - "value": "A", - "is_visible": True, - "attributes": "", - }, - { - "class": "col_heading level0 col1", - "display_value": "B", - "type": "th", - "value": "B", - "is_visible": True, - "attributes": "", - }, - { - "class": "col_heading level0 col2", - "display_value": "C", - "type": "th", - "value": "C", - "is_visible": True, - "attributes": "", - }, - ] - ] - - assert result["head"] == expected + assert len(result["head"]) == 1 + expected = { + "class": "blank level0", + "type": "th", + "value": self.blank_value, + "is_visible": True, + "display_value": self.blank_value, + } + assert expected.items() <= result["head"][0][0].items() def test_index_name(self): # https://github.com/pandas-dev/pandas/issues/11655 - # TODO: this test can be minimised to address the test more directly df = DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) result = df.set_index("A").style._translate(True, True) - - expected = [ - [ - { - "class": "blank level0", - "type": "th", - "value": self.blank_value, - "display_value": self.blank_value, - "is_visible": True, - }, - { - "class": "col_heading level0 col0", - "type": "th", - "value": "B", - "display_value": "B", - "is_visible": True, - "attributes": "", - }, - { - "class": "col_heading level0 col1", - "type": "th", - "value": "C", - "display_value": "C", - "is_visible": True, - "attributes": "", - }, - ], - [ - { - "class": "index_name level0", - "type": "th", - "value": "A", - "is_visible": True, - "display_value": "A", - }, - { - "class": "blank col0", - "type": "th", - "value": self.blank_value, - "is_visible": True, - "display_value": self.blank_value, - }, - { - "class": "blank col1", - "type": "th", - "value": self.blank_value, - "is_visible": True, - "display_value": self.blank_value, - }, - ], - ] - - assert result["head"] == expected + expected = { + "class": "index_name level0", + "type": "th", + "value": "A", + "is_visible": True, + "display_value": "A", + } + assert expected.items() <= result["head"][1][0].items() def test_multiindex_name(self): # https://github.com/pandas-dev/pandas/issues/11655 - # TODO: this test can be minimised to address the test more directly df = DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) result = df.set_index(["A", "B"]).style._translate(True, True) expected = [ - [ - { - "class": "blank", - "type": "th", - "value": self.blank_value, - "display_value": self.blank_value, - "is_visible": True, - }, - { - "class": "blank level0", - "type": "th", - "value": self.blank_value, - "display_value": self.blank_value, - "is_visible": True, - }, - { - "class": "col_heading level0 col0", - "type": "th", - "value": "C", - "display_value": "C", - "is_visible": True, - "attributes": "", - }, - ], - [ - { - "class": "index_name level0", - "type": "th", - "value": "A", - "is_visible": True, - "display_value": "A", - }, - { - "class": "index_name level1", - "type": "th", - "value": "B", - "is_visible": True, - "display_value": "B", - }, - { - "class": "blank col0", - "type": "th", - "value": self.blank_value, - "is_visible": True, - "display_value": self.blank_value, - }, - ], + { + "class": "index_name level0", + "type": "th", + "value": "A", + "is_visible": True, + "display_value": "A", + }, + { + "class": "index_name level1", + "type": "th", + "value": "B", + "is_visible": True, + "display_value": "B", + }, + { + "class": "blank col0", + "type": "th", + "value": self.blank_value, + "is_visible": True, + "display_value": self.blank_value, + }, ] - - assert result["head"] == expected + assert result["head"][1] == expected def test_numeric_columns(self): # https://github.com/pandas-dev/pandas/issues/12125 @@ -1098,7 +1048,6 @@ def test_mi_sparse_index_names(self): assert head == expected def test_mi_sparse_column_names(self): - # TODO this test is verbose - could be minimised df = DataFrame( np.arange(16).reshape(4, 4), index=MultiIndex.from_arrays( @@ -1109,7 +1058,7 @@ def test_mi_sparse_column_names(self): [["C1", "C1", "C2", "C2"], [1, 0, 1, 0]], names=["col_0", "col_1"] ), ) - result = df.style._translate(True, True) + result = Styler(df, cell_ids=False)._translate(True, True) head = result["head"][1] expected = [ { @@ -1320,7 +1269,7 @@ def test_no_cell_ids(self): styler = Styler(df, uuid="_", cell_ids=False) styler.render() s = styler.render() # render twice to ensure ctx is not updated - assert s.find('') != -1 + assert s.find('') != -1 @pytest.mark.parametrize( "classes", @@ -1338,10 +1287,10 @@ def test_set_data_classes(self, classes): # GH 36159 df = DataFrame(data=[[0, 1], [2, 3]], columns=["A", "B"], index=["a", "b"]) s = Styler(df, uuid_len=0, cell_ids=False).set_td_classes(classes).render() - assert '0' in s - assert '1' in s - assert '2' in s - assert '3' in s + assert '0' in s + assert '1' in s + assert '2' in s + assert '3' in s # GH 39317 s = Styler(df, uuid_len=0, cell_ids=True).set_td_classes(classes).render() assert '0' in s