From 1d7114f92b38bcddcfff979c61997b673ceb9a3c Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Thu, 17 Jun 2021 10:48:14 -0700 Subject: [PATCH] Backport PR #41821: BUG: wrong exception when slicing on TimedeltaIndex --- doc/source/whatsnew/v1.3.0.rst | 1 + pandas/core/indexes/datetimelike.py | 6 ++- pandas/core/indexes/period.py | 10 ++-- pandas/core/indexes/timedeltas.py | 16 +++--- .../tests/indexes/timedeltas/test_indexing.py | 49 +++++++++++++++++++ 5 files changed, 70 insertions(+), 12 deletions(-) diff --git a/doc/source/whatsnew/v1.3.0.rst b/doc/source/whatsnew/v1.3.0.rst index 6c2fef3808566..f20e8f25535cd 100644 --- a/doc/source/whatsnew/v1.3.0.rst +++ b/doc/source/whatsnew/v1.3.0.rst @@ -1025,6 +1025,7 @@ Indexing - Bug in :meth:`PeriodIndex.get_loc` failing to raise a ``KeyError`` when given a :class:`Period` with a mismatched ``freq`` (:issue:`41670`) - Bug ``.loc.__getitem__`` with a :class:`UInt64Index` and negative-integer keys raising ``OverflowError`` instead of ``KeyError`` in some cases, wrapping around to positive integers in others (:issue:`41777`) - Bug in :meth:`Index.get_indexer` failing to raise ``ValueError`` in some cases with invalid ``method``, ``limit``, or ``tolerance`` arguments (:issue:`41918`) +- Bug when slicing a :class:`Series` or :class:`DataFrame` with a :class:`TimedeltaIndex` when passing an invalid string raising ``ValueError`` instead of a ``TypeError`` (:issue:`41821`) Missing ^^^^^^^ diff --git a/pandas/core/indexes/datetimelike.py b/pandas/core/indexes/datetimelike.py index df7fae0763c42..b74881b766ae4 100644 --- a/pandas/core/indexes/datetimelike.py +++ b/pandas/core/indexes/datetimelike.py @@ -26,7 +26,10 @@ Resolution, Tick, ) -from pandas._typing import Callable +from pandas._typing import ( + Callable, + final, +) from pandas.compat.numpy import function as nv from pandas.util._decorators import ( Appender, @@ -399,6 +402,7 @@ def _validate_partial_date_slice(self, reso: Resolution): def _parsed_string_to_bounds(self, reso: Resolution, parsed: datetime): raise NotImplementedError + @final def _partial_date_slice( self, reso: Resolution, diff --git a/pandas/core/indexes/period.py b/pandas/core/indexes/period.py index c1104b80a0a7a..ae8126846d03a 100644 --- a/pandas/core/indexes/period.py +++ b/pandas/core/indexes/period.py @@ -32,7 +32,6 @@ from pandas.core.dtypes.common import ( is_datetime64_any_dtype, - is_float, is_integer, is_scalar, pandas_dtype, @@ -493,13 +492,14 @@ def _maybe_cast_slice_bound(self, label, side: str, kind=lib.no_default): elif isinstance(label, str): try: parsed, reso_str = parse_time_string(label, self.freq) - reso = Resolution.from_attrname(reso_str) - bounds = self._parsed_string_to_bounds(reso, parsed) - return bounds[0 if side == "left" else 1] except ValueError as err: # string cannot be parsed as datetime-like raise self._invalid_indexer("slice", label) from err - elif is_integer(label) or is_float(label): + + reso = Resolution.from_attrname(reso_str) + lower, upper = self._parsed_string_to_bounds(reso, parsed) + return lower if side == "left" else upper + elif not isinstance(label, self._data._recognized_scalars): raise self._invalid_indexer("slice", label) return label diff --git a/pandas/core/indexes/timedeltas.py b/pandas/core/indexes/timedeltas.py index c60ab06dd08f3..4d77f5ffc98e1 100644 --- a/pandas/core/indexes/timedeltas.py +++ b/pandas/core/indexes/timedeltas.py @@ -198,12 +198,16 @@ def _maybe_cast_slice_bound(self, label, side: str, kind=lib.no_default): self._deprecated_arg(kind, "kind", "_maybe_cast_slice_bound") if isinstance(label, str): - parsed = Timedelta(label) - lbound = parsed.round(parsed.resolution_string) - if side == "left": - return lbound - else: - return lbound + to_offset(parsed.resolution_string) - Timedelta(1, "ns") + try: + parsed = Timedelta(label) + except ValueError as err: + # e.g. 'unit abbreviation w/o a number' + raise self._invalid_indexer("slice", label) from err + + # The next two lines are analogous to DTI/PI._parsed_str_to_bounds + lower = parsed.round(parsed.resolution_string) + upper = lower + to_offset(parsed.resolution_string) - Timedelta(1, "ns") + return lower if side == "left" else upper elif not isinstance(label, self._data._recognized_scalars): raise self._invalid_indexer("slice", label) diff --git a/pandas/tests/indexes/timedeltas/test_indexing.py b/pandas/tests/indexes/timedeltas/test_indexing.py index 5f0101eb4478c..ec41956371164 100644 --- a/pandas/tests/indexes/timedeltas/test_indexing.py +++ b/pandas/tests/indexes/timedeltas/test_indexing.py @@ -291,3 +291,52 @@ def test_take_fill_value(self): msg = "index -5 is out of bounds for (axis 0 with )?size 3" with pytest.raises(IndexError, match=msg): idx.take(np.array([1, -5])) + + +class TestMaybeCastSliceBound: + @pytest.fixture(params=["increasing", "decreasing", None]) + def monotonic(self, request): + return request.param + + @pytest.fixture + def tdi(self, monotonic): + tdi = timedelta_range("1 Day", periods=10) + if monotonic == "decreasing": + tdi = tdi[::-1] + elif monotonic is None: + taker = np.arange(10, dtype=np.intp) + np.random.shuffle(taker) + tdi = tdi.take(taker) + return tdi + + def test_maybe_cast_slice_bound_invalid_str(self, tdi): + # test the low-level _maybe_cast_slice_bound and that we get the + # expected exception+message all the way up the stack + msg = ( + "cannot do slice indexing on TimedeltaIndex with these " + r"indexers \[foo\] of type str" + ) + with pytest.raises(TypeError, match=msg): + tdi._maybe_cast_slice_bound("foo", side="left") + with pytest.raises(TypeError, match=msg): + tdi.get_slice_bound("foo", side="left") + with pytest.raises(TypeError, match=msg): + tdi.slice_locs("foo", None, None) + + def test_slice_invalid_str_with_timedeltaindex( + self, tdi, frame_or_series, indexer_sl + ): + obj = frame_or_series(range(10), index=tdi) + + msg = ( + "cannot do slice indexing on TimedeltaIndex with these " + r"indexers \[foo\] of type str" + ) + with pytest.raises(TypeError, match=msg): + indexer_sl(obj)["foo":] + with pytest.raises(TypeError, match=msg): + indexer_sl(obj)["foo":-1] + with pytest.raises(TypeError, match=msg): + indexer_sl(obj)[:"foo"] + with pytest.raises(TypeError, match=msg): + indexer_sl(obj)[tdi[0] : "foo"]