diff --git a/doc/source/_static/style/hbetw_axNone.png b/doc/source/_static/style/hbetw_axNone.png new file mode 100644 index 0000000000000..2918131b40bde Binary files /dev/null and b/doc/source/_static/style/hbetw_axNone.png differ diff --git a/doc/source/_static/style/hbetw_basic.png b/doc/source/_static/style/hbetw_basic.png new file mode 100644 index 0000000000000..1d8e015aec37f Binary files /dev/null and b/doc/source/_static/style/hbetw_basic.png differ diff --git a/doc/source/_static/style/hbetw_props.png b/doc/source/_static/style/hbetw_props.png new file mode 100644 index 0000000000000..56bbe8479d564 Binary files /dev/null and b/doc/source/_static/style/hbetw_props.png differ diff --git a/doc/source/_static/style/hbetw_seq.png b/doc/source/_static/style/hbetw_seq.png new file mode 100644 index 0000000000000..0fc3108a7968c Binary files /dev/null and b/doc/source/_static/style/hbetw_seq.png differ diff --git a/doc/source/reference/style.rst b/doc/source/reference/style.rst index 90ec5a2283f1e..3c06bc30c5db9 100644 --- a/doc/source/reference/style.rst +++ b/doc/source/reference/style.rst @@ -53,6 +53,7 @@ Builtin styles Styler.highlight_null Styler.highlight_max Styler.highlight_min + Styler.highlight_between Styler.background_gradient Styler.bar diff --git a/doc/source/whatsnew/v1.3.0.rst b/doc/source/whatsnew/v1.3.0.rst index 0ec9758477eba..be6c1205810f5 100644 --- a/doc/source/whatsnew/v1.3.0.rst +++ b/doc/source/whatsnew/v1.3.0.rst @@ -206,6 +206,7 @@ Other enhancements - :meth:`.Styler.background_gradient` now allows the ability to supply a specific gradient map (:issue:`22727`) - :meth:`.Styler.clear` now clears :attr:`Styler.hidden_index` and :attr:`Styler.hidden_columns` as well (:issue:`40484`) - Builtin highlighting methods in :class:`Styler` have a more consistent signature and css customisability (:issue:`40242`) +- :meth:`.Styler.highlight_between` added to list of builtin styling methods (:issue:`39821`) - :meth:`Series.loc.__getitem__` and :meth:`Series.loc.__setitem__` with :class:`MultiIndex` now raising helpful error message when indexer has too many dimensions (:issue:`35349`) - :meth:`pandas.read_stata` and :class:`StataReader` support reading data from compressed files. - Add support for parsing ``ISO 8601``-like timestamps with negative signs to :meth:`pandas.Timedelta` (:issue:`37172`) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 7b5347ba2d9a9..271dc766bf441 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -6,6 +6,7 @@ from contextlib import contextmanager import copy from functools import partial +import operator from typing import ( Any, Callable, @@ -21,6 +22,7 @@ FrameOrSeries, FrameOrSeriesUnion, IndexLabel, + Scalar, ) from pandas.compat._optional import import_optional_dependency from pandas.util._decorators import doc @@ -1352,6 +1354,7 @@ def highlight_null( -------- Styler.highlight_max: Highlight the maximum with a style. Styler.highlight_min: Highlight the minimum with a style. + Styler.highlight_between: Highlight a defined range with a style. """ def f(data: DataFrame, props: str) -> np.ndarray: @@ -1399,6 +1402,7 @@ def highlight_max( -------- Styler.highlight_null: Highlight missing values with a style. Styler.highlight_min: Highlight the minimum with a style. + Styler.highlight_between: Highlight a defined range with a style. """ def f(data: FrameOrSeries, props: str) -> np.ndarray: @@ -1446,6 +1450,7 @@ def highlight_min( -------- Styler.highlight_null: Highlight missing values with a style. Styler.highlight_max: Highlight the maximum with a style. + Styler.highlight_between: Highlight a defined range with a style. """ def f(data: FrameOrSeries, props: str) -> np.ndarray: @@ -1459,6 +1464,157 @@ def f(data: FrameOrSeries, props: str) -> np.ndarray: f, axis=axis, subset=subset, props=props # type: ignore[arg-type] ) + def highlight_between( + self, + subset: IndexLabel | None = None, + color: str = "yellow", + axis: Axis | None = 0, + left: Scalar | Sequence | None = None, + right: Scalar | Sequence | None = None, + inclusive: str = "both", + props: str | None = None, + ) -> Styler: + """ + Highlight a defined range with a style. + + .. versionadded:: 1.3.0 + + Parameters + ---------- + subset : IndexSlice, default None + A valid slice for ``data`` to limit the style application to. + color : str, default 'yellow' + Background color to use for highlighting. + axis : {0 or 'index', 1 or 'columns', None}, default 0 + If ``left`` or ``right`` given as sequence, axis along which to apply those + boundaries. See examples. + left : scalar or datetime-like, or sequence or array-like, default None + Left bound for defining the range. + right : scalar or datetime-like, or sequence or array-like, default None + Right bound for defining the range. + inclusive : {'both', 'neither', 'left', 'right'} + Identify whether bounds are closed or open. + props : str, default None + CSS properties to use for highlighting. If ``props`` is given, ``color`` + is not used. + + Returns + ------- + self : Styler + + See Also + -------- + Styler.highlight_null: Highlight missing values with a style. + Styler.highlight_max: Highlight the maximum with a style. + Styler.highlight_min: Highlight the minimum with a style. + + Notes + ----- + If ``left`` is ``None`` only the right bound is applied. + If ``right`` is ``None`` only the left bound is applied. If both are ``None`` + all values are highlighted. + + ``axis`` is only needed if ``left`` or ``right`` are provided as a sequence or + an array-like object for aligning the shapes. If ``left`` and ``right`` are + both scalars then all ``axis`` inputs will give the same result. + + This function only works with compatible ``dtypes``. For example a datetime-like + region can only use equivalent datetime-like ``left`` and ``right`` arguments. + Use ``subset`` to control regions which have multiple ``dtypes``. + + Examples + -------- + Basic usage + + >>> df = pd.DataFrame({ + ... 'One': [1.2, 1.6, 1.5], + ... 'Two': [2.9, 2.1, 2.5], + ... 'Three': [3.1, 3.2, 3.8], + ... }) + >>> df.style.highlight_between(left=2.1, right=2.9) + + .. figure:: ../../_static/style/hbetw_basic.png + + Using a range input sequnce along an ``axis``, in this case setting a ``left`` + and ``right`` for each column individually + + >>> df.style.highlight_between(left=[1.4, 2.4, 3.4], right=[1.6, 2.6, 3.6], + ... axis=1, color="#fffd75") + + .. figure:: ../../_static/style/hbetw_seq.png + + Using ``axis=None`` and providing the ``left`` argument as an array that + matches the input DataFrame, with a constant ``right`` + + >>> df.style.highlight_between(left=[[2,2,3],[2,2,3],[3,3,3]], right=3.5, + ... axis=None, color="#fffd75") + + .. figure:: ../../_static/style/hbetw_axNone.png + + Using ``props`` instead of default background coloring + + >>> df.style.highlight_between(left=1.5, right=3.5, + ... props='font-weight:bold;color:#e83e8c') + + .. figure:: ../../_static/style/hbetw_props.png + """ + + def f( + data: FrameOrSeries, + props: str, + left: Scalar | Sequence | np.ndarray | FrameOrSeries | None = None, + right: Scalar | Sequence | np.ndarray | FrameOrSeries | None = None, + inclusive: bool | str = True, + ) -> np.ndarray: + if np.iterable(left) and not isinstance(left, str): + left = _validate_apply_axis_arg( + left, "left", None, data # type: ignore[arg-type] + ) + + if np.iterable(right) and not isinstance(right, str): + right = _validate_apply_axis_arg( + right, "right", None, data # type: ignore[arg-type] + ) + + # get ops with correct boundary attribution + if inclusive == "both": + ops = (operator.ge, operator.le) + elif inclusive == "neither": + ops = (operator.gt, operator.lt) + elif inclusive == "left": + ops = (operator.ge, operator.lt) + elif inclusive == "right": + ops = (operator.gt, operator.le) + else: + raise ValueError( + f"'inclusive' values can be 'both', 'left', 'right', or 'neither' " + f"got {inclusive}" + ) + + g_left = ( + ops[0](data, left) + if left is not None + else np.full(data.shape, True, dtype=bool) + ) + l_right = ( + ops[1](data, right) + if right is not None + else np.full(data.shape, True, dtype=bool) + ) + return np.where(g_left & l_right, props, "") + + if props is None: + props = f"background-color: {color};" + return self.apply( + f, # type: ignore[arg-type] + axis=axis, + subset=subset, + props=props, + left=left, + right=right, + inclusive=inclusive, + ) + @classmethod def from_custom_template(cls, searchpath, name): """ diff --git a/pandas/tests/io/formats/style/test_highlight.py b/pandas/tests/io/formats/style/test_highlight.py index 8aca3cadff0b4..b8c194f8955ab 100644 --- a/pandas/tests/io/formats/style/test_highlight.py +++ b/pandas/tests/io/formats/style/test_highlight.py @@ -1,7 +1,10 @@ import numpy as np import pytest -from pandas import DataFrame +from pandas import ( + DataFrame, + IndexSlice, +) pytest.importorskip("jinja2") @@ -70,3 +73,72 @@ def test_highlight_minmax_ext(df, f, kwargs): df = -df result = getattr(df.style, f)(**kwargs)._compute().ctx assert result == expected + + +@pytest.mark.parametrize( + "kwargs", + [ + {"left": 0, "right": 1}, # test basic range + {"left": 0, "right": 1, "props": "background-color: yellow"}, # test props + {"left": -100, "right": 100, "subset": IndexSlice[[0, 1], :]}, # test subset + {"left": 0, "subset": IndexSlice[[0, 1], :]}, # test no right + {"right": 1}, # test no left + {"left": [0, 0, 11], "axis": 0}, # test left as sequence + {"left": DataFrame({"A": [0, 0, 11], "B": [1, 1, 11]}), "axis": None}, # axis + {"left": 0, "right": [0, 1], "axis": 1}, # test sequence right + ], +) +def test_highlight_between(styler, kwargs): + expected = { + (0, 0): [("background-color", "yellow")], + (0, 1): [("background-color", "yellow")], + } + result = styler.highlight_between(**kwargs)._compute().ctx + assert result == expected + + +@pytest.mark.parametrize( + "arg, map, axis", + [ + ("left", [1, 2], 0), # 0 axis has 3 elements not 2 + ("left", [1, 2, 3], 1), # 1 axis has 2 elements not 3 + ("left", np.array([[1, 2], [1, 2]]), None), # df is (2,3) not (2,2) + ("right", [1, 2], 0), # same tests as above for 'right' not 'left' + ("right", [1, 2, 3], 1), # .. + ("right", np.array([[1, 2], [1, 2]]), None), # .. + ], +) +def test_highlight_between_raises(arg, styler, map, axis): + msg = f"supplied '{arg}' is not correct shape" + with pytest.raises(ValueError, match=msg): + styler.highlight_between(**{arg: map, "axis": axis})._compute() + + +def test_highlight_between_raises2(styler): + msg = "values can be 'both', 'left', 'right', or 'neither'" + with pytest.raises(ValueError, match=msg): + styler.highlight_between(inclusive="badstring")._compute() + + with pytest.raises(ValueError, match=msg): + styler.highlight_between(inclusive=1)._compute() + + +@pytest.mark.parametrize( + "inclusive, expected", + [ + ( + "both", + { + (0, 0): [("background-color", "yellow")], + (0, 1): [("background-color", "yellow")], + }, + ), + ("neither", {}), + ("left", {(0, 0): [("background-color", "yellow")]}), + ("right", {(0, 1): [("background-color", "yellow")]}), + ], +) +def test_highlight_between_inclusive(styler, inclusive, expected): + kwargs = {"left": 0, "right": 1, "subset": IndexSlice[[0, 1], :]} + result = styler.highlight_between(**kwargs, inclusive=inclusive)._compute() + assert result.ctx == expected