Skip to content

Commit 8e0ca2d

Browse files
attack68JulianWgs
authored andcommitted
ENH: Styler builtins: highlight_between (pandas-dev#39821)
1 parent 8a9978f commit 8e0ca2d

File tree

8 files changed

+231
-1
lines changed

8 files changed

+231
-1
lines changed
7.14 KB
Loading
7.33 KB
Loading
7.59 KB
Loading
7.1 KB
Loading

doc/source/reference/style.rst

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Builtin styles
5353
Styler.highlight_null
5454
Styler.highlight_max
5555
Styler.highlight_min
56+
Styler.highlight_between
5657
Styler.background_gradient
5758
Styler.bar
5859

doc/source/whatsnew/v1.3.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ Other enhancements
209209
- :meth:`.Styler.background_gradient` now allows the ability to supply a specific gradient map (:issue:`22727`)
210210
- :meth:`.Styler.clear` now clears :attr:`Styler.hidden_index` and :attr:`Styler.hidden_columns` as well (:issue:`40484`)
211211
- Builtin highlighting methods in :class:`Styler` have a more consistent signature and css customisability (:issue:`40242`)
212+
- :meth:`.Styler.highlight_between` added to list of builtin styling methods (:issue:`39821`)
212213
- :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`)
213214
- :meth:`pandas.read_stata` and :class:`StataReader` support reading data from compressed files.
214215
- Add support for parsing ``ISO 8601``-like timestamps with negative signs to :meth:`pandas.Timedelta` (:issue:`37172`)

pandas/io/formats/style.py

+156
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from contextlib import contextmanager
77
import copy
88
from functools import partial
9+
import operator
910
from typing import (
1011
Any,
1112
Callable,
@@ -21,6 +22,7 @@
2122
FrameOrSeries,
2223
FrameOrSeriesUnion,
2324
IndexLabel,
25+
Scalar,
2426
)
2527
from pandas.compat._optional import import_optional_dependency
2628
from pandas.util._decorators import doc
@@ -1352,6 +1354,7 @@ def highlight_null(
13521354
--------
13531355
Styler.highlight_max: Highlight the maximum with a style.
13541356
Styler.highlight_min: Highlight the minimum with a style.
1357+
Styler.highlight_between: Highlight a defined range with a style.
13551358
"""
13561359

13571360
def f(data: DataFrame, props: str) -> np.ndarray:
@@ -1399,6 +1402,7 @@ def highlight_max(
13991402
--------
14001403
Styler.highlight_null: Highlight missing values with a style.
14011404
Styler.highlight_min: Highlight the minimum with a style.
1405+
Styler.highlight_between: Highlight a defined range with a style.
14021406
"""
14031407

14041408
def f(data: FrameOrSeries, props: str) -> np.ndarray:
@@ -1446,6 +1450,7 @@ def highlight_min(
14461450
--------
14471451
Styler.highlight_null: Highlight missing values with a style.
14481452
Styler.highlight_max: Highlight the maximum with a style.
1453+
Styler.highlight_between: Highlight a defined range with a style.
14491454
"""
14501455

14511456
def f(data: FrameOrSeries, props: str) -> np.ndarray:
@@ -1459,6 +1464,157 @@ def f(data: FrameOrSeries, props: str) -> np.ndarray:
14591464
f, axis=axis, subset=subset, props=props # type: ignore[arg-type]
14601465
)
14611466

1467+
def highlight_between(
1468+
self,
1469+
subset: IndexLabel | None = None,
1470+
color: str = "yellow",
1471+
axis: Axis | None = 0,
1472+
left: Scalar | Sequence | None = None,
1473+
right: Scalar | Sequence | None = None,
1474+
inclusive: str = "both",
1475+
props: str | None = None,
1476+
) -> Styler:
1477+
"""
1478+
Highlight a defined range with a style.
1479+
1480+
.. versionadded:: 1.3.0
1481+
1482+
Parameters
1483+
----------
1484+
subset : IndexSlice, default None
1485+
A valid slice for ``data`` to limit the style application to.
1486+
color : str, default 'yellow'
1487+
Background color to use for highlighting.
1488+
axis : {0 or 'index', 1 or 'columns', None}, default 0
1489+
If ``left`` or ``right`` given as sequence, axis along which to apply those
1490+
boundaries. See examples.
1491+
left : scalar or datetime-like, or sequence or array-like, default None
1492+
Left bound for defining the range.
1493+
right : scalar or datetime-like, or sequence or array-like, default None
1494+
Right bound for defining the range.
1495+
inclusive : {'both', 'neither', 'left', 'right'}
1496+
Identify whether bounds are closed or open.
1497+
props : str, default None
1498+
CSS properties to use for highlighting. If ``props`` is given, ``color``
1499+
is not used.
1500+
1501+
Returns
1502+
-------
1503+
self : Styler
1504+
1505+
See Also
1506+
--------
1507+
Styler.highlight_null: Highlight missing values with a style.
1508+
Styler.highlight_max: Highlight the maximum with a style.
1509+
Styler.highlight_min: Highlight the minimum with a style.
1510+
1511+
Notes
1512+
-----
1513+
If ``left`` is ``None`` only the right bound is applied.
1514+
If ``right`` is ``None`` only the left bound is applied. If both are ``None``
1515+
all values are highlighted.
1516+
1517+
``axis`` is only needed if ``left`` or ``right`` are provided as a sequence or
1518+
an array-like object for aligning the shapes. If ``left`` and ``right`` are
1519+
both scalars then all ``axis`` inputs will give the same result.
1520+
1521+
This function only works with compatible ``dtypes``. For example a datetime-like
1522+
region can only use equivalent datetime-like ``left`` and ``right`` arguments.
1523+
Use ``subset`` to control regions which have multiple ``dtypes``.
1524+
1525+
Examples
1526+
--------
1527+
Basic usage
1528+
1529+
>>> df = pd.DataFrame({
1530+
... 'One': [1.2, 1.6, 1.5],
1531+
... 'Two': [2.9, 2.1, 2.5],
1532+
... 'Three': [3.1, 3.2, 3.8],
1533+
... })
1534+
>>> df.style.highlight_between(left=2.1, right=2.9)
1535+
1536+
.. figure:: ../../_static/style/hbetw_basic.png
1537+
1538+
Using a range input sequnce along an ``axis``, in this case setting a ``left``
1539+
and ``right`` for each column individually
1540+
1541+
>>> df.style.highlight_between(left=[1.4, 2.4, 3.4], right=[1.6, 2.6, 3.6],
1542+
... axis=1, color="#fffd75")
1543+
1544+
.. figure:: ../../_static/style/hbetw_seq.png
1545+
1546+
Using ``axis=None`` and providing the ``left`` argument as an array that
1547+
matches the input DataFrame, with a constant ``right``
1548+
1549+
>>> df.style.highlight_between(left=[[2,2,3],[2,2,3],[3,3,3]], right=3.5,
1550+
... axis=None, color="#fffd75")
1551+
1552+
.. figure:: ../../_static/style/hbetw_axNone.png
1553+
1554+
Using ``props`` instead of default background coloring
1555+
1556+
>>> df.style.highlight_between(left=1.5, right=3.5,
1557+
... props='font-weight:bold;color:#e83e8c')
1558+
1559+
.. figure:: ../../_static/style/hbetw_props.png
1560+
"""
1561+
1562+
def f(
1563+
data: FrameOrSeries,
1564+
props: str,
1565+
left: Scalar | Sequence | np.ndarray | FrameOrSeries | None = None,
1566+
right: Scalar | Sequence | np.ndarray | FrameOrSeries | None = None,
1567+
inclusive: bool | str = True,
1568+
) -> np.ndarray:
1569+
if np.iterable(left) and not isinstance(left, str):
1570+
left = _validate_apply_axis_arg(
1571+
left, "left", None, data # type: ignore[arg-type]
1572+
)
1573+
1574+
if np.iterable(right) and not isinstance(right, str):
1575+
right = _validate_apply_axis_arg(
1576+
right, "right", None, data # type: ignore[arg-type]
1577+
)
1578+
1579+
# get ops with correct boundary attribution
1580+
if inclusive == "both":
1581+
ops = (operator.ge, operator.le)
1582+
elif inclusive == "neither":
1583+
ops = (operator.gt, operator.lt)
1584+
elif inclusive == "left":
1585+
ops = (operator.ge, operator.lt)
1586+
elif inclusive == "right":
1587+
ops = (operator.gt, operator.le)
1588+
else:
1589+
raise ValueError(
1590+
f"'inclusive' values can be 'both', 'left', 'right', or 'neither' "
1591+
f"got {inclusive}"
1592+
)
1593+
1594+
g_left = (
1595+
ops[0](data, left)
1596+
if left is not None
1597+
else np.full(data.shape, True, dtype=bool)
1598+
)
1599+
l_right = (
1600+
ops[1](data, right)
1601+
if right is not None
1602+
else np.full(data.shape, True, dtype=bool)
1603+
)
1604+
return np.where(g_left & l_right, props, "")
1605+
1606+
if props is None:
1607+
props = f"background-color: {color};"
1608+
return self.apply(
1609+
f, # type: ignore[arg-type]
1610+
axis=axis,
1611+
subset=subset,
1612+
props=props,
1613+
left=left,
1614+
right=right,
1615+
inclusive=inclusive,
1616+
)
1617+
14621618
@classmethod
14631619
def from_custom_template(cls, searchpath, name):
14641620
"""

pandas/tests/io/formats/style/test_highlight.py

+73-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import numpy as np
22
import pytest
33

4-
from pandas import DataFrame
4+
from pandas import (
5+
DataFrame,
6+
IndexSlice,
7+
)
58

69
pytest.importorskip("jinja2")
710

@@ -70,3 +73,72 @@ def test_highlight_minmax_ext(df, f, kwargs):
7073
df = -df
7174
result = getattr(df.style, f)(**kwargs)._compute().ctx
7275
assert result == expected
76+
77+
78+
@pytest.mark.parametrize(
79+
"kwargs",
80+
[
81+
{"left": 0, "right": 1}, # test basic range
82+
{"left": 0, "right": 1, "props": "background-color: yellow"}, # test props
83+
{"left": -100, "right": 100, "subset": IndexSlice[[0, 1], :]}, # test subset
84+
{"left": 0, "subset": IndexSlice[[0, 1], :]}, # test no right
85+
{"right": 1}, # test no left
86+
{"left": [0, 0, 11], "axis": 0}, # test left as sequence
87+
{"left": DataFrame({"A": [0, 0, 11], "B": [1, 1, 11]}), "axis": None}, # axis
88+
{"left": 0, "right": [0, 1], "axis": 1}, # test sequence right
89+
],
90+
)
91+
def test_highlight_between(styler, kwargs):
92+
expected = {
93+
(0, 0): [("background-color", "yellow")],
94+
(0, 1): [("background-color", "yellow")],
95+
}
96+
result = styler.highlight_between(**kwargs)._compute().ctx
97+
assert result == expected
98+
99+
100+
@pytest.mark.parametrize(
101+
"arg, map, axis",
102+
[
103+
("left", [1, 2], 0), # 0 axis has 3 elements not 2
104+
("left", [1, 2, 3], 1), # 1 axis has 2 elements not 3
105+
("left", np.array([[1, 2], [1, 2]]), None), # df is (2,3) not (2,2)
106+
("right", [1, 2], 0), # same tests as above for 'right' not 'left'
107+
("right", [1, 2, 3], 1), # ..
108+
("right", np.array([[1, 2], [1, 2]]), None), # ..
109+
],
110+
)
111+
def test_highlight_between_raises(arg, styler, map, axis):
112+
msg = f"supplied '{arg}' is not correct shape"
113+
with pytest.raises(ValueError, match=msg):
114+
styler.highlight_between(**{arg: map, "axis": axis})._compute()
115+
116+
117+
def test_highlight_between_raises2(styler):
118+
msg = "values can be 'both', 'left', 'right', or 'neither'"
119+
with pytest.raises(ValueError, match=msg):
120+
styler.highlight_between(inclusive="badstring")._compute()
121+
122+
with pytest.raises(ValueError, match=msg):
123+
styler.highlight_between(inclusive=1)._compute()
124+
125+
126+
@pytest.mark.parametrize(
127+
"inclusive, expected",
128+
[
129+
(
130+
"both",
131+
{
132+
(0, 0): [("background-color", "yellow")],
133+
(0, 1): [("background-color", "yellow")],
134+
},
135+
),
136+
("neither", {}),
137+
("left", {(0, 0): [("background-color", "yellow")]}),
138+
("right", {(0, 1): [("background-color", "yellow")]}),
139+
],
140+
)
141+
def test_highlight_between_inclusive(styler, inclusive, expected):
142+
kwargs = {"left": 0, "right": 1, "subset": IndexSlice[[0, 1], :]}
143+
result = styler.highlight_between(**kwargs, inclusive=inclusive)._compute()
144+
assert result.ctx == expected

0 commit comments

Comments
 (0)