Skip to content

Commit 2b05c91

Browse files
authored
ENH: support Ellipsis in loc/iloc (#37750)
1 parent 5c3b640 commit 2b05c91

File tree

4 files changed

+119
-10
lines changed

4 files changed

+119
-10
lines changed

doc/source/whatsnew/v1.4.0.rst

+2
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,13 @@ Other enhancements
148148
- Methods that relied on hashmap based algos such as :meth:`DataFrameGroupBy.value_counts`, :meth:`DataFrameGroupBy.count` and :func:`factorize` ignored imaginary component for complex numbers (:issue:`17927`)
149149
- Add :meth:`Series.str.removeprefix` and :meth:`Series.str.removesuffix` introduced in Python 3.9 to remove pre-/suffixes from string-type :class:`Series` (:issue:`36944`)
150150
- Attempting to write into a file in missing parent directory with :meth:`DataFrame.to_csv`, :meth:`DataFrame.to_html`, :meth:`DataFrame.to_excel`, :meth:`DataFrame.to_feather`, :meth:`DataFrame.to_parquet`, :meth:`DataFrame.to_stata`, :meth:`DataFrame.to_json`, :meth:`DataFrame.to_pickle`, and :meth:`DataFrame.to_xml` now explicitly mentions missing parent directory, the same is true for :class:`Series` counterparts (:issue:`24306`)
151+
- Indexing with ``.loc`` and ``.iloc`` now supports ``Ellipsis`` (:issue:`37750`)
151152
- :meth:`IntegerArray.all` , :meth:`IntegerArray.any`, :meth:`FloatingArray.any`, and :meth:`FloatingArray.all` use Kleene logic (:issue:`41967`)
152153
- Added support for nullable boolean and integer types in :meth:`DataFrame.to_stata`, :class:`~pandas.io.stata.StataWriter`, :class:`~pandas.io.stata.StataWriter117`, and :class:`~pandas.io.stata.StataWriterUTF8` (:issue:`40855`)
153154
- :meth:`DataFrame.__pos__`, :meth:`DataFrame.__neg__` now retain ``ExtensionDtype`` dtypes (:issue:`43883`)
154155
- The error raised when an optional dependency can't be imported now includes the original exception, for easier investigation (:issue:`43882`)
155156
- Added :meth:`.ExponentialMovingWindow.sum` (:issue:`13297`)
157+
-
156158

157159
.. ---------------------------------------------------------------------------
158160

pandas/core/indexes/multi.py

+5
Original file line numberDiff line numberDiff line change
@@ -3254,6 +3254,11 @@ def get_locs(self, seq):
32543254
# entry in `seq`
32553255
indexer = Index(np.arange(n))
32563256

3257+
if any(x is Ellipsis for x in seq):
3258+
raise NotImplementedError(
3259+
"MultiIndex does not support indexing with Ellipsis"
3260+
)
3261+
32573262
def _convert_to_indexer(r) -> Int64Index:
32583263
# return an indexer
32593264
if isinstance(r, slice):

pandas/core/indexing.py

+48-10
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
from contextlib import suppress
44
from typing import (
55
TYPE_CHECKING,
6-
Any,
76
Hashable,
8-
Sequence,
97
)
108
import warnings
119

@@ -67,6 +65,7 @@
6765

6866
# "null slice"
6967
_NS = slice(None, None)
68+
_one_ellipsis_message = "indexer may only contain one '...' entry"
7069

7170

7271
# the public IndexSlicerMaker
@@ -731,11 +730,33 @@ def _validate_key(self, key, axis: int):
731730
"""
732731
raise AbstractMethodError(self)
733732

734-
def _has_valid_tuple(self, key: tuple):
733+
def _expand_ellipsis(self, tup: tuple) -> tuple:
734+
"""
735+
If a tuple key includes an Ellipsis, replace it with an appropriate
736+
number of null slices.
737+
"""
738+
if any(x is Ellipsis for x in tup):
739+
if tup.count(Ellipsis) > 1:
740+
raise IndexingError(_one_ellipsis_message)
741+
742+
if len(tup) == self.ndim:
743+
# It is unambiguous what axis this Ellipsis is indexing,
744+
# treat as a single null slice.
745+
i = tup.index(Ellipsis)
746+
# FIXME: this assumes only one Ellipsis
747+
new_key = tup[:i] + (_NS,) + tup[i + 1 :]
748+
return new_key
749+
750+
# TODO: other cases? only one test gets here, and that is covered
751+
# by _validate_key_length
752+
return tup
753+
754+
def _validate_tuple_indexer(self, key: tuple) -> tuple:
735755
"""
736756
Check the key for valid keys across my indexer.
737757
"""
738-
self._validate_key_length(key)
758+
key = self._validate_key_length(key)
759+
key = self._expand_ellipsis(key)
739760
for i, k in enumerate(key):
740761
try:
741762
self._validate_key(k, i)
@@ -744,6 +765,7 @@ def _has_valid_tuple(self, key: tuple):
744765
"Location based indexing can only have "
745766
f"[{self._valid_types}] types"
746767
) from err
768+
return key
747769

748770
def _is_nested_tuple_indexer(self, tup: tuple) -> bool:
749771
"""
@@ -772,9 +794,16 @@ def _convert_tuple(self, key):
772794

773795
return tuple(keyidx)
774796

775-
def _validate_key_length(self, key: Sequence[Any]) -> None:
797+
def _validate_key_length(self, key: tuple) -> tuple:
776798
if len(key) > self.ndim:
799+
if key[0] is Ellipsis:
800+
# e.g. Series.iloc[..., 3] reduces to just Series.iloc[3]
801+
key = key[1:]
802+
if Ellipsis in key:
803+
raise IndexingError(_one_ellipsis_message)
804+
return self._validate_key_length(key)
777805
raise IndexingError("Too many indexers")
806+
return key
778807

779808
def _getitem_tuple_same_dim(self, tup: tuple):
780809
"""
@@ -822,7 +851,7 @@ def _getitem_lowerdim(self, tup: tuple):
822851
with suppress(IndexingError):
823852
return self._handle_lowerdim_multi_index_axis0(tup)
824853

825-
self._validate_key_length(tup)
854+
tup = self._validate_key_length(tup)
826855

827856
for i, key in enumerate(tup):
828857
if is_label_like(key):
@@ -1093,10 +1122,11 @@ def _getitem_iterable(self, key, axis: int):
10931122

10941123
def _getitem_tuple(self, tup: tuple):
10951124
with suppress(IndexingError):
1125+
tup = self._expand_ellipsis(tup)
10961126
return self._getitem_lowerdim(tup)
10971127

10981128
# no multi-index, so validate all of the indexers
1099-
self._has_valid_tuple(tup)
1129+
tup = self._validate_tuple_indexer(tup)
11001130

11011131
# ugly hack for GH #836
11021132
if self._multi_take_opportunity(tup):
@@ -1126,6 +1156,8 @@ def _getitem_axis(self, key, axis: int):
11261156
key = item_from_zerodim(key)
11271157
if is_iterator(key):
11281158
key = list(key)
1159+
if key is Ellipsis:
1160+
key = slice(None)
11291161

11301162
labels = self.obj._get_axis(axis)
11311163

@@ -1409,7 +1441,7 @@ def _validate_integer(self, key: int, axis: int) -> None:
14091441

14101442
def _getitem_tuple(self, tup: tuple):
14111443

1412-
self._has_valid_tuple(tup)
1444+
tup = self._validate_tuple_indexer(tup)
14131445
with suppress(IndexingError):
14141446
return self._getitem_lowerdim(tup)
14151447

@@ -1439,7 +1471,9 @@ def _get_list_axis(self, key, axis: int):
14391471
raise IndexError("positional indexers are out-of-bounds") from err
14401472

14411473
def _getitem_axis(self, key, axis: int):
1442-
if isinstance(key, ABCDataFrame):
1474+
if key is Ellipsis:
1475+
key = slice(None)
1476+
elif isinstance(key, ABCDataFrame):
14431477
raise IndexError(
14441478
"DataFrame indexer is not allowed for .iloc\n"
14451479
"Consider using .loc for automatic alignment."
@@ -2381,7 +2415,11 @@ def is_label_like(key) -> bool:
23812415
bool
23822416
"""
23832417
# select a label or row
2384-
return not isinstance(key, slice) and not is_list_like_indexer(key)
2418+
return (
2419+
not isinstance(key, slice)
2420+
and not is_list_like_indexer(key)
2421+
and key is not Ellipsis
2422+
)
23852423

23862424

23872425
def need_slice(obj: slice) -> bool:

pandas/tests/indexing/test_loc.py

+64
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
import pandas._testing as tm
3838
from pandas.api.types import is_scalar
3939
from pandas.core.api import Float64Index
40+
from pandas.core.indexing import (
41+
IndexingError,
42+
_one_ellipsis_message,
43+
)
4044
from pandas.tests.indexing.common import Base
4145

4246

@@ -1524,6 +1528,66 @@ def test_loc_setitem_cast3(self):
15241528
assert df.dtypes.one == np.dtype(np.int8)
15251529

15261530

1531+
class TestLocWithEllipsis:
1532+
@pytest.fixture(params=[tm.loc, tm.iloc])
1533+
def indexer(self, request):
1534+
# Test iloc while we're here
1535+
return request.param
1536+
1537+
@pytest.fixture
1538+
def obj(self, series_with_simple_index, frame_or_series):
1539+
obj = series_with_simple_index
1540+
if frame_or_series is not Series:
1541+
obj = obj.to_frame()
1542+
return obj
1543+
1544+
def test_loc_iloc_getitem_ellipsis(self, obj, indexer):
1545+
result = indexer(obj)[...]
1546+
tm.assert_equal(result, obj)
1547+
1548+
def test_loc_iloc_getitem_leading_ellipses(self, series_with_simple_index, indexer):
1549+
obj = series_with_simple_index
1550+
key = 0 if (indexer is tm.iloc or len(obj) == 0) else obj.index[0]
1551+
1552+
if indexer is tm.loc and obj.index.is_boolean():
1553+
# passing [False] will get interpreted as a boolean mask
1554+
# TODO: should it? unambiguous when lengths dont match?
1555+
return
1556+
if indexer is tm.loc and isinstance(obj.index, MultiIndex):
1557+
msg = "MultiIndex does not support indexing with Ellipsis"
1558+
with pytest.raises(NotImplementedError, match=msg):
1559+
result = indexer(obj)[..., [key]]
1560+
1561+
elif len(obj) != 0:
1562+
result = indexer(obj)[..., [key]]
1563+
expected = indexer(obj)[[key]]
1564+
tm.assert_series_equal(result, expected)
1565+
1566+
key2 = 0 if indexer is tm.iloc else obj.name
1567+
df = obj.to_frame()
1568+
result = indexer(df)[..., [key2]]
1569+
expected = indexer(df)[:, [key2]]
1570+
tm.assert_frame_equal(result, expected)
1571+
1572+
def test_loc_iloc_getitem_ellipses_only_one_ellipsis(self, obj, indexer):
1573+
# GH37750
1574+
key = 0 if (indexer is tm.iloc or len(obj) == 0) else obj.index[0]
1575+
1576+
with pytest.raises(IndexingError, match=_one_ellipsis_message):
1577+
indexer(obj)[..., ...]
1578+
1579+
with pytest.raises(IndexingError, match=_one_ellipsis_message):
1580+
indexer(obj)[..., [key], ...]
1581+
1582+
with pytest.raises(IndexingError, match=_one_ellipsis_message):
1583+
indexer(obj)[..., ..., key]
1584+
1585+
# one_ellipsis_message takes precedence over "Too many indexers"
1586+
# only when the first key is Ellipsis
1587+
with pytest.raises(IndexingError, match="Too many indexers"):
1588+
indexer(obj)[key, ..., ...]
1589+
1590+
15271591
class TestLocWithMultiIndex:
15281592
@pytest.mark.parametrize(
15291593
"keys, expected",

0 commit comments

Comments
 (0)