diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst index f86c45ae8a86c..8754286ee7d11 100644 --- a/doc/source/whatsnew/v1.4.0.rst +++ b/doc/source/whatsnew/v1.4.0.rst @@ -34,7 +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`) +- Add keyword ``level`` to :meth:`.Styler.hide_index` and :meth:`.Styler.hide_columns` 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 525e55290af17..1a891d76a376c 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -32,6 +32,7 @@ import pandas as pd from pandas import ( + Index, IndexSlice, RangeIndex, ) @@ -1785,7 +1786,7 @@ def set_na_rep(self, na_rep: str) -> StylerRenderer: def hide_index( self, subset: Subset | None = None, - levels: Level | list[Level] | None = None, + level: Level | list[Level] | None = None, ) -> Styler: """ Hide the entire index, or specific keys in the index from rendering. @@ -1805,7 +1806,7 @@ def hide_index( 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 + level : int, str, list The level(s) to hide in a MultiIndex if hiding the entire index. Cannot be used simultaneously with ``subset``. @@ -1862,7 +1863,7 @@ def hide_index( Hide a specific level: - >>> df.style.format("{:,.1f").hide_index(levels=1) # doctest: +SKIP + >>> df.style.format("{:,.1f").hide_index(level=1) # doctest: +SKIP x y a b c a b c x 0.1 0.0 0.4 1.3 0.6 -1.4 @@ -1872,27 +1873,11 @@ def hide_index( -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 level is not None and subset is not None: + raise ValueError("`subset` and `level` cannot be passed simultaneously") if subset is None: - 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" - ) + levels_ = _refactor_levels(level, self.index) self.hide_index_ = [ True if lev in levels_ else False for lev in range(self.index.nlevels) ] @@ -1906,14 +1891,18 @@ def hide_index( self.hidden_rows = hrows # type: ignore[assignment] return self - def hide_columns(self, subset: Subset | None = None) -> Styler: + def hide_columns( + self, + subset: Subset | None = None, + level: Level | list[Level] | None = None, + ) -> Styler: """ Hide the column headers or specific keys in the columns from rendering. This method has dual functionality: - - if ``subset`` is ``None`` then the entire column headers row will be hidden - whilst the data-values remain visible. + - if ``subset`` is ``None`` then the entire column headers row, or + specific levels, will be hidden whilst the data-values remain visible. - if a ``subset`` is given then those specific columns, including the data-values will be hidden, whilst the column headers row remains visible. @@ -1925,6 +1914,11 @@ def hide_columns(self, subset: Subset | None = None) -> Styler: A valid 1d input or single key along the columns axis within `DataFrame.loc[:, ]`, to limit ``data`` to *before* applying the function. + level : int, str, list + The level(s) to hide in a MultiIndex if hiding the entire column headers + row. Cannot be used simultaneously with ``subset``. + + .. versionadded:: 1.4.0 Returns ------- @@ -1979,9 +1973,26 @@ def hide_columns(self, subset: Subset | None = None) -> Styler: y a 1.0 -1.2 b 1.2 0.3 c 0.5 2.2 + + Hide a specific level: + + >>> df.style.format("{:.1f}").hide_columns(level=1) # doctest: +SKIP + x y + x a 0.1 0.0 0.4 1.3 0.6 -1.4 + b 0.7 1.0 1.3 1.5 -0.0 -0.2 + c 1.4 -0.8 1.6 -0.2 -0.4 -0.3 + y a 0.4 1.0 -0.2 -0.8 -1.2 1.1 + b -0.6 1.2 1.8 1.9 0.3 0.3 + c 0.8 0.5 -0.3 1.2 2.2 -0.8 """ + if level is not None and subset is not None: + raise ValueError("`subset` and `level` cannot be passed simultaneously") + if subset is None: - self.hide_columns_ = True + levels_ = _refactor_levels(level, self.columns) + self.hide_columns_ = [ + True if lev in levels_ else False for lev in range(self.columns.nlevels) + ] else: subset_ = IndexSlice[:, subset] # new var so mypy reads not Optional subset = non_reducing_slice(subset_) @@ -3172,3 +3183,37 @@ def css_calc(x, left: float, right: float, align: str): index=data.index, columns=data.columns, ) + + +def _refactor_levels( + level: Level | list[Level] | None, + obj: Index, +) -> list[Level]: + """ + Returns a consistent levels arg for use in ``hide_index`` or ``hide_columns``. + + Parameters + ---------- + level : int, str, list + Original ``level`` arg supplied to above methods. + obj: + Either ``self.index`` or ``self.columns`` + + Returns + ------- + list : refactored arg with a list of levels to hide + """ + if level is None: + levels_: list[Level] = list(range(obj.nlevels)) + elif isinstance(level, int): + levels_ = [level] + elif isinstance(level, str): + levels_ = [obj._get_level_number(level)] + elif isinstance(level, list): + levels_ = [ + obj._get_level_number(lev) if not isinstance(lev, int) else lev + for lev in level + ] + else: + raise ValueError("`level` must be of type `int`, `str` or list of such") + return levels_ diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index 176468315a487..e89d4519543c6 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -98,7 +98,7 @@ def __init__( # add rendering variables self.hide_index_: list = [False] * self.index.nlevels - self.hide_columns_: bool = False + self.hide_columns_: list = [False] * self.columns.nlevels 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) @@ -303,8 +303,10 @@ def _translate_header( head = [] # 1) column headers - if not self.hide_columns_: - for r in range(self.data.columns.nlevels): + for r, hide in enumerate(self.hide_columns_): + if hide: + continue + else: # 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 @@ -354,7 +356,7 @@ def _translate_header( self.data.index.names and com.any_not_none(*self.data.index.names) and not all(self.hide_index_) - and not self.hide_columns_ + and not all(self.hide_columns_) ): index_names = [ _element( diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 6b084ecc2ca6c..3c042e130981c 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -261,19 +261,19 @@ def test_clear(mi_styler_comp): def test_hide_raises(mi_styler): - msg = "`subset` and `levels` cannot be passed simultaneously" + msg = "`subset` and `level` cannot be passed simultaneously" with pytest.raises(ValueError, match=msg): - mi_styler.hide_index(subset="something", levels="something else") + mi_styler.hide_index(subset="something", level="something else") - msg = "`levels` must be of type `int`, `str` or list of such" + msg = "`level` 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}) + mi_styler.hide_index(level={"bad": 1, "type": 2}) -@pytest.mark.parametrize("levels", [1, "one", [1], ["one"]]) -def test_hide_level(mi_styler, levels): +@pytest.mark.parametrize("level", [1, "one", [1], ["one"]]) +def test_hide_index_level(mi_styler, level): mi_styler.index.names, mi_styler.columns.names = ["zero", "one"], ["zero", "one"] - ctx = mi_styler.hide_index(levels=levels)._translate(False, True) + ctx = mi_styler.hide_index(level=level)._translate(False, True) assert len(ctx["head"][0]) == 3 assert len(ctx["head"][1]) == 3 assert len(ctx["head"][2]) == 4 @@ -286,6 +286,16 @@ def test_hide_level(mi_styler, levels): assert not ctx["body"][1][1]["is_visible"] +@pytest.mark.parametrize("level", [1, "one", [1], ["one"]]) +@pytest.mark.parametrize("names", [True, False]) +def test_hide_columns_level(mi_styler, level, names): + mi_styler.columns.names = ["zero", "one"] + if names: + mi_styler.index.names = ["zero", "one"] + ctx = mi_styler.hide_columns(level=level)._translate(True, False) + assert len(ctx["head"]) == (2 if names else 1) + + class TestStyler: def setup_method(self, method): np.random.seed(24)