diff --git a/doc/source/whatsnew/v1.1.0.rst b/doc/source/whatsnew/v1.1.0.rst index 2f4e961ff433f..dabe589f86ee9 100644 --- a/doc/source/whatsnew/v1.1.0.rst +++ b/doc/source/whatsnew/v1.1.0.rst @@ -137,7 +137,9 @@ Other API changes Backwards incompatible API changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :meth:`DataFrame.swaplevels` now raises a ``TypeError`` if the axis is not a :class:`MultiIndex`. - Previously a ``AttributeError`` was raised (:issue:`31126`) + Previously an ``AttributeError`` was raised (:issue:`31126`) +- :meth:`DataFrame.xs` now raises a ``TypeError`` if a ``level`` keyword is supplied and the axis is not a :class:`MultiIndex`. + Previously an ``AttributeError`` was raised (:issue:`33610`) - :meth:`DataFrameGroupby.mean` and :meth:`SeriesGroupby.mean` (and similarly for :meth:`~DataFrameGroupby.median`, :meth:`~DataFrameGroupby.std` and :meth:`~DataFrameGroupby.var`) now raise a ``TypeError`` if a not-accepted keyword argument is passed into it. Previously a ``UnsupportedFunctionCall`` was raised (``AssertionError`` if ``min_count`` passed into :meth:`~DataFrameGroupby.median`) (:issue:`31485`) diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 6a4f83427310e..c897827502eda 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -353,7 +353,7 @@ def _construct_axes_from_arguments( return axes, kwargs @classmethod - def _get_axis_number(cls, axis): + def _get_axis_number(cls, axis) -> int: axis = cls._AXIS_ALIASES.get(axis, axis) if is_integer(axis): if axis in cls._AXIS_NAMES: @@ -366,7 +366,7 @@ def _get_axis_number(cls, axis): raise ValueError(f"No axis named {axis} for object type {cls.__name__}") @classmethod - def _get_axis_name(cls, axis): + def _get_axis_name(cls, axis) -> str: axis = cls._AXIS_ALIASES.get(axis, axis) if isinstance(axis, str): if axis in cls._AXIS_NUMBERS: @@ -378,12 +378,12 @@ def _get_axis_name(cls, axis): pass raise ValueError(f"No axis named {axis} for object type {cls.__name__}") - def _get_axis(self, axis): + def _get_axis(self, axis) -> Index: name = self._get_axis_name(axis) return getattr(self, name) @classmethod - def _get_block_manager_axis(cls, axis): + def _get_block_manager_axis(cls, axis) -> int: """Map the axis to the block_manager axis.""" axis = cls._get_axis_number(axis) if cls._AXIS_REVERSED: @@ -590,7 +590,9 @@ def swapaxes(self: FrameOrSeries, axis1, axis2, copy=True) -> FrameOrSeries: if copy: new_values = new_values.copy() - return self._constructor(new_values, *new_axes).__finalize__( + # ignore needed because of NDFrame constructor is different than + # DataFrame/Series constructors. + return self._constructor(new_values, *new_axes).__finalize__( # type: ignore self, method="swapaxes" ) @@ -3490,6 +3492,8 @@ class animal locomotion axis = self._get_axis_number(axis) labels = self._get_axis(axis) if level is not None: + if not isinstance(labels, MultiIndex): + raise TypeError("Index must be a MultiIndex") loc, new_ax = labels.get_loc_level(key, level=level, drop_level=drop_level) # create the tuple of the indexer @@ -7621,11 +7625,11 @@ def at_time( axis = self._get_axis_number(axis) index = self._get_axis(axis) - try: - indexer = index.indexer_at_time(time, asof=asof) - except AttributeError as err: - raise TypeError("Index must be DatetimeIndex") from err + if not isinstance(index, DatetimeIndex): + raise TypeError("Index must be DatetimeIndex") + + indexer = index.indexer_at_time(time, asof=asof) return self._take_with_is_copy(indexer, axis=axis) def between_time( @@ -7704,16 +7708,12 @@ def between_time( axis = self._get_axis_number(axis) index = self._get_axis(axis) - try: - indexer = index.indexer_between_time( - start_time, - end_time, - include_start=include_start, - include_end=include_end, - ) - except AttributeError as err: - raise TypeError("Index must be DatetimeIndex") from err + if not isinstance(index, DatetimeIndex): + raise TypeError("Index must be DatetimeIndex") + indexer = index.indexer_between_time( + start_time, end_time, include_start=include_start, include_end=include_end, + ) return self._take_with_is_copy(indexer, axis=axis) def resample( diff --git a/pandas/tests/indexing/multiindex/test_xs.py b/pandas/tests/indexing/multiindex/test_xs.py index db8c0c643a623..ff748d755c063 100644 --- a/pandas/tests/indexing/multiindex/test_xs.py +++ b/pandas/tests/indexing/multiindex/test_xs.py @@ -243,3 +243,15 @@ def test_series_getitem_multiindex_xs_by_label(): result = s.xs("one", level="L2") tm.assert_series_equal(result, expected) + + +def test_xs_levels_raises(): + df = DataFrame({"A": [1, 2, 3]}) + + msg = "Index must be a MultiIndex" + with pytest.raises(TypeError, match=msg): + df.xs(0, level="as") + + s = df.A + with pytest.raises(TypeError, match=msg): + s.xs(0, level="as")