diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst index 7395f9d2dcb9e..9da2e296a6a78 100644 --- a/doc/source/whatsnew/v1.4.0.rst +++ b/doc/source/whatsnew/v1.4.0.rst @@ -34,6 +34,7 @@ Other enhancements - :meth:`Series.sample`, :meth:`DataFrame.sample`, and :meth:`.GroupBy.sample` now accept a ``np.random.Generator`` as input to ``random_state``. A generator will be more performant, especially with ``replace=False`` (:issue:`38100`) - Additional options added to :meth:`.Styler.bar` to control alignment and display, with keyword only arguments (:issue:`26070`, :issue:`36419`) - :meth:`Styler.bar` now validates the input argument ``width`` and ``height`` (:issue:`42511`) +- Add keyword ``levels`` to :meth:`.Styler.hide_index` for optionally controlling hidden levels in a MultiIndex (:issue:`25475`) - :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`) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 64b6de5722a56..53ae2daa31235 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -24,6 +24,7 @@ FilePathOrBuffer, FrameOrSeries, IndexLabel, + Level, Scalar, ) from pandas.compat._optional import import_optional_dependency @@ -748,7 +749,9 @@ def to_latex( self.data.columns = RangeIndex(stop=len(self.data.columns)) numeric_cols = self.data._get_numeric_data().columns.to_list() self.data.columns = _original_columns - column_format = "" if self.hide_index_ else "l" * self.data.index.nlevels + column_format = "" + for level in range(self.index.nlevels): + column_format += "" if self.hide_index_[level] else "l" for ci, _ in enumerate(self.data.columns): if ci not in self.hidden_columns: column_format += ( @@ -1746,14 +1749,18 @@ def set_na_rep(self, na_rep: str) -> StylerRenderer: self.na_rep = na_rep return self.format(na_rep=na_rep, precision=self.precision) - def hide_index(self, subset: Subset | None = None) -> Styler: + def hide_index( + self, + subset: Subset | None = None, + levels: Level | list[Level] | None = None, + ) -> Styler: """ Hide the entire index, or specific keys in the index from rendering. This method has dual functionality: - - if ``subset`` is ``None`` then the entire index will be hidden whilst - displaying all data-rows. + - if ``subset`` is ``None`` then the entire index, or specified levels, will + be hidden whilst displaying all data-rows. - if a ``subset`` is given then those specific rows will be hidden whilst the index itself remains visible. @@ -1765,6 +1772,11 @@ def hide_index(self, subset: Subset | None = None) -> Styler: A valid 1d input or single key along the index axis within `DataFrame.loc[, :]`, to limit ``data`` to *before* applying the function. + levels : int, str, list + The level(s) to hide in a MultiIndex if hiding the entire index. Cannot be + used simultaneously with ``subset``. + + .. versionadded:: 1.4.0 Returns ------- @@ -1814,9 +1826,43 @@ def hide_index(self, subset: Subset | None = None) -> Styler: a b c a b c 0.7 1.0 1.3 1.5 -0.0 -0.2 -0.6 1.2 1.8 1.9 0.3 0.3 + + Hide a specific level: + + >>> df.style.format("{:,.1f").hide_index(levels=1) # doctest: +SKIP + x y + a b c a b c + x 0.1 0.0 0.4 1.3 0.6 -1.4 + 0.7 1.0 1.3 1.5 -0.0 -0.2 + 1.4 -0.8 1.6 -0.2 -0.4 -0.3 + y 0.4 1.0 -0.2 -0.8 -1.2 1.1 + -0.6 1.2 1.8 1.9 0.3 0.3 + 0.8 0.5 -0.3 1.2 2.2 -0.8 """ + if levels is not None and subset is not None: + raise ValueError("`subset` and `levels` cannot be passed simultaneously") + if subset is None: - self.hide_index_ = True + if levels is None: + levels_: list[Level] = list(range(self.index.nlevels)) + elif isinstance(levels, int): + levels_ = [levels] + elif isinstance(levels, str): + levels_ = [self.index._get_level_number(levels)] + elif isinstance(levels, list): + levels_ = [ + self.index._get_level_number(lev) + if not isinstance(lev, int) + else lev + for lev in levels + ] + else: + raise ValueError( + "`levels` must be of type `int`, `str` or list of such" + ) + self.hide_index_ = [ + True if lev in levels_ else False for lev in range(self.index.nlevels) + ] else: subset_ = IndexSlice[subset, :] # new var so mypy reads not Optional subset = non_reducing_slice(subset_) diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index ea403eaf6f718..176468315a487 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -97,7 +97,7 @@ def __init__( self.cell_ids = cell_ids # add rendering variables - self.hide_index_: bool = False # bools for hiding col/row headers + self.hide_index_: list = [False] * self.index.nlevels self.hide_columns_: bool = False self.hidden_rows: Sequence[int] = [] # sequence for specific hidden rows/cols self.hidden_columns: Sequence[int] = [] @@ -305,9 +305,10 @@ def _translate_header( # 1) column headers if not self.hide_columns_: for r in range(self.data.columns.nlevels): - index_blanks = [ - _element("th", blank_class, blank_value, not self.hide_index_) - ] * (self.data.index.nlevels - 1) + # number of index blanks is governed by number of hidden index levels + index_blanks = [_element("th", blank_class, blank_value, True)] * ( + self.index.nlevels - sum(self.hide_index_) - 1 + ) name = self.data.columns.names[r] column_name = [ @@ -315,7 +316,7 @@ def _translate_header( "th", f"{blank_class if name is None else index_name_class} level{r}", name if name is not None else blank_value, - not self.hide_index_, + not all(self.hide_index_), ) ] @@ -352,7 +353,7 @@ def _translate_header( if ( self.data.index.names and com.any_not_none(*self.data.index.names) - and not self.hide_index_ + and not all(self.hide_index_) and not self.hide_columns_ ): index_names = [ @@ -360,7 +361,7 @@ def _translate_header( "th", f"{index_name_class} level{c}", blank_value if name is None else name, - True, + not self.hide_index_[c], ) for c, name in enumerate(self.data.index.names) ] @@ -435,7 +436,7 @@ def _translate_body( "th", f"{row_heading_class} level{c} {trimmed_row_class}", "...", - not self.hide_index_, + not self.hide_index_[c], attributes="", ) for c in range(self.data.index.nlevels) @@ -472,7 +473,7 @@ def _translate_body( "th", f"{row_heading_class} level{c} row{r}", value, - (_is_visible(r, c, idx_lengths) and not self.hide_index_), + (_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)}"' @@ -537,7 +538,7 @@ def _translate_latex(self, d: dict) -> None: d["head"] = [[col for col in row if col["is_visible"]] for row in d["head"]] body = [] for r, row in enumerate(d["body"]): - if self.hide_index_: + if all(self.hide_index_): row_body_headers = [] else: row_body_headers = [ diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index ffe1e8e547322..7b1dc95335b11 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -260,6 +260,32 @@ def test_clear(mi_styler_comp): assert all(res) if hasattr(res, "__iter__") else res +def test_hide_raises(mi_styler): + msg = "`subset` and `levels` cannot be passed simultaneously" + with pytest.raises(ValueError, match=msg): + mi_styler.hide_index(subset="something", levels="something else") + + msg = "`levels` must be of type `int`, `str` or list of such" + with pytest.raises(ValueError, match=msg): + mi_styler.hide_index(levels={"bad": 1, "type": 2}) + + +@pytest.mark.parametrize("levels", [1, "one", [1], ["one"]]) +def test_hide_level(mi_styler, levels): + mi_styler.index.names, mi_styler.columns.names = ["zero", "one"], ["zero", "one"] + ctx = mi_styler.hide_index(levels=levels)._translate(False, True) + assert len(ctx["head"][0]) == 3 + assert len(ctx["head"][1]) == 3 + assert len(ctx["head"][2]) == 4 + assert ctx["head"][2][0]["is_visible"] + assert not ctx["head"][2][1]["is_visible"] + + assert ctx["body"][0][0]["is_visible"] + assert not ctx["body"][0][1]["is_visible"] + assert ctx["body"][1][0]["is_visible"] + assert not ctx["body"][1][1]["is_visible"] + + class TestStyler: def setup_method(self, method): np.random.seed(24) @@ -1157,7 +1183,7 @@ def test_hide_single_index(self): def test_hide_multiindex(self): # GH 14194 df = DataFrame( - {"A": [1, 2]}, + {"A": [1, 2], "B": [1, 2]}, index=MultiIndex.from_arrays( [["a", "a"], [0, 1]], names=["idx_level_0", "idx_level_1"] ), @@ -1167,16 +1193,17 @@ def test_hide_multiindex(self): assert ctx1["body"][0][0]["is_visible"] assert ctx1["body"][0][1]["is_visible"] # check for blank header rows - assert ctx1["head"][0][0]["is_visible"] - assert ctx1["head"][0][1]["is_visible"] + print(ctx1["head"][0]) + assert len(ctx1["head"][0]) == 4 # two visible indexes and two data columns ctx2 = df.style.hide_index()._translate(True, True) # tests for 'a' and '0' assert not ctx2["body"][0][0]["is_visible"] assert not ctx2["body"][0][1]["is_visible"] # check for blank header rows + print(ctx2["head"][0]) + assert len(ctx2["head"][0]) == 3 # one hidden (col name) and two data columns assert not ctx2["head"][0][0]["is_visible"] - assert not ctx2["head"][0][1]["is_visible"] def test_hide_columns_single_level(self): # GH 14194 @@ -1243,9 +1270,10 @@ def test_hide_columns_index_mult_levels(self): # hide second column and index ctx = df.style.hide_columns([("b", 1)]).hide_index()._translate(True, True) assert not ctx["body"][0][0]["is_visible"] # index - assert ctx["head"][0][2]["is_visible"] # b - assert ctx["head"][1][2]["is_visible"] # 0 - assert not ctx["head"][1][3]["is_visible"] # 1 + assert len(ctx["head"][0]) == 3 + assert ctx["head"][0][1]["is_visible"] # b + assert ctx["head"][1][1]["is_visible"] # 0 + assert not ctx["head"][1][2]["is_visible"] # 1 assert not ctx["body"][1][3]["is_visible"] # 4 assert ctx["body"][1][2]["is_visible"] assert ctx["body"][1][2]["display_value"] == 3