Skip to content

Commit 90bb1aa

Browse files
authored
API: allow step!=1 slice with IntervalIndex (#31658)
* Allow step!=1 in slicing Series with IntervalIndex * Whatsnew * doc suggestion * test for interval step * remove commented-out * reword whatsnew * disallow label-based with step!=1 * black fixup * update error message * typo fixup
1 parent 6f5287b commit 90bb1aa

File tree

5 files changed

+69
-9
lines changed

5 files changed

+69
-9
lines changed

doc/source/whatsnew/v1.1.0.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ Other enhancements
6767
- When writing directly to a sqlite connection :func:`to_sql` now supports the ``multi`` method (:issue:`29921`)
6868
- `OptionError` is now exposed in `pandas.errors` (:issue:`27553`)
6969
- :func:`timedelta_range` will now infer a frequency when passed ``start``, ``stop``, and ``periods`` (:issue:`32377`)
70+
- Positional slicing on a :class:`IntervalIndex` now supports slices with ``step > 1`` (:issue:`31658`)
7071
-
7172

7273
.. ---------------------------------------------------------------------------
@@ -248,7 +249,6 @@ Strings
248249

249250
Interval
250251
^^^^^^^^
251-
252252
-
253253
-
254254

pandas/core/indexers.py

+29
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
is_array_like,
1212
is_bool_dtype,
1313
is_extension_array_dtype,
14+
is_integer,
1415
is_integer_dtype,
1516
is_list_like,
1617
)
@@ -20,6 +21,34 @@
2021
# Indexer Identification
2122

2223

24+
def is_valid_positional_slice(slc: slice) -> bool:
25+
"""
26+
Check if a slice object can be interpreted as a positional indexer.
27+
28+
Parameters
29+
----------
30+
slc : slice
31+
32+
Returns
33+
-------
34+
bool
35+
36+
Notes
37+
-----
38+
A valid positional slice may also be interpreted as a label-based slice
39+
depending on the index being sliced.
40+
"""
41+
42+
def is_int_or_none(val):
43+
return val is None or is_integer(val)
44+
45+
return (
46+
is_int_or_none(slc.start)
47+
and is_int_or_none(slc.stop)
48+
and is_int_or_none(slc.step)
49+
)
50+
51+
2352
def is_list_like_indexer(key) -> bool:
2453
"""
2554
Check if we have a list-like indexer that is *not* a NamedTuple.

pandas/core/indexes/interval.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from pandas.core.algorithms import take_1d
4040
from pandas.core.arrays.interval import IntervalArray, _interval_shared_docs
4141
import pandas.core.common as com
42+
from pandas.core.indexers import is_valid_positional_slice
4243
import pandas.core.indexes.base as ibase
4344
from pandas.core.indexes.base import (
4445
Index,
@@ -866,7 +867,16 @@ def get_indexer_for(self, target: AnyArrayLike, **kwargs) -> np.ndarray:
866867

867868
def _convert_slice_indexer(self, key: slice, kind: str):
868869
if not (key.step is None or key.step == 1):
869-
raise ValueError("cannot support not-default step in a slice")
870+
# GH#31658 if label-based, we require step == 1,
871+
# if positional, we disallow float start/stop
872+
msg = "label-based slicing with step!=1 is not supported for IntervalIndex"
873+
if kind == "loc":
874+
raise ValueError(msg)
875+
elif kind == "getitem":
876+
if not is_valid_positional_slice(key):
877+
# i.e. this cannot be interpreted as a positional slice
878+
raise ValueError(msg)
879+
870880
return super()._convert_slice_indexer(key, kind)
871881

872882
@Appender(Index.where.__doc__)

pandas/tests/indexes/test_base.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -2598,7 +2598,8 @@ def test_convert_almost_null_slice(indices):
25982598
key = slice(None, None, "foo")
25992599

26002600
if isinstance(idx, pd.IntervalIndex):
2601-
with pytest.raises(ValueError, match="cannot support not-default step"):
2601+
msg = "label-based slicing with step!=1 is not supported for IntervalIndex"
2602+
with pytest.raises(ValueError, match=msg):
26022603
idx._convert_slice_indexer(key, "loc")
26032604
else:
26042605
msg = "'>=' not supported between instances of 'str' and 'int'"

pandas/tests/indexing/interval/test_interval_new.py

+26-6
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,6 @@ def test_loc_with_slices(self):
128128
with pytest.raises(NotImplementedError, match=msg):
129129
s[Interval(3, 4, closed="left") :]
130130

131-
# TODO with non-existing intervals ?
132-
# s.loc[Interval(-1, 0):Interval(2, 3)]
133-
134131
# slice of scalar
135132

136133
expected = s.iloc[:3]
@@ -143,9 +140,32 @@ def test_loc_with_slices(self):
143140
tm.assert_series_equal(expected, s[:2.5])
144141
tm.assert_series_equal(expected, s[0.1:2.5])
145142

146-
# slice of scalar with step != 1
147-
with pytest.raises(ValueError):
148-
s[0:4:2]
143+
def test_slice_step_ne1(self):
144+
# GH#31658 slice of scalar with step != 1
145+
s = self.s
146+
expected = s.iloc[0:4:2]
147+
148+
result = s[0:4:2]
149+
tm.assert_series_equal(result, expected)
150+
151+
result2 = s[0:4][::2]
152+
tm.assert_series_equal(result2, expected)
153+
154+
def test_slice_float_start_stop(self):
155+
# GH#31658 slicing with integers is positional, with floats is not
156+
# supported
157+
ser = Series(np.arange(5), IntervalIndex.from_breaks(np.arange(6)))
158+
159+
msg = "label-based slicing with step!=1 is not supported for IntervalIndex"
160+
with pytest.raises(ValueError, match=msg):
161+
ser[1.5:9.5:2]
162+
163+
def test_slice_interval_step(self):
164+
# GH#31658 allows for integer step!=1, not Interval step
165+
s = self.s
166+
msg = "label-based slicing with step!=1 is not supported for IntervalIndex"
167+
with pytest.raises(ValueError, match=msg):
168+
s[0 : 4 : Interval(0, 1)]
149169

150170
def test_loc_with_overlap(self):
151171

0 commit comments

Comments
 (0)