diff --git a/doc/source/whatsnew/v1.0.0.rst b/doc/source/whatsnew/v1.0.0.rst index ec6ad38bbc7cf..04607531fb2ce 100755 --- a/doc/source/whatsnew/v1.0.0.rst +++ b/doc/source/whatsnew/v1.0.0.rst @@ -687,7 +687,6 @@ Other API changes - :meth:`Series.str.__iter__` was deprecated and will be removed in future releases (:issue:`28277`). - Added ```` to the list of default NA values for :meth:`read_csv` (:issue:`30821`) - .. _whatsnew_100.api.documentation: Documentation Improvements diff --git a/doc/source/whatsnew/v1.1.0.rst b/doc/source/whatsnew/v1.1.0.rst index c8e811ce82b1f..a2627baaab101 100644 --- a/doc/source/whatsnew/v1.1.0.rst +++ b/doc/source/whatsnew/v1.1.0.rst @@ -55,7 +55,12 @@ Other API changes - :meth:`Series.describe` will now show distribution percentiles for ``datetime`` dtypes, statistics ``first`` and ``last`` will now be ``min`` and ``max`` to match with numeric dtypes in :meth:`DataFrame.describe` (:issue:`30164`) - -- + +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`) + .. --------------------------------------------------------------------------- diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 4257083cc8dc5..d2ea2623f108b 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -38,7 +38,7 @@ from pandas._config import get_option -from pandas._libs import algos as libalgos, lib +from pandas._libs import algos as libalgos, lib, properties from pandas._typing import Axes, Axis, Dtype, FilePathOrBuffer, Level, Renamer from pandas.compat import PY37 from pandas.compat._optional import import_optional_dependency @@ -91,8 +91,10 @@ ) from pandas.core.dtypes.generic import ( ABCDataFrame, + ABCDatetimeIndex, ABCIndexClass, ABCMultiIndex, + ABCPeriodIndex, ABCSeries, ) from pandas.core.dtypes.missing import isna, notna @@ -394,6 +396,7 @@ class DataFrame(NDFrame): 2 7 8 9 """ + _internal_names_set = {"columns", "index"} | NDFrame._internal_names_set _typ = "dataframe" @property @@ -5290,9 +5293,15 @@ def swaplevel(self, i=-2, j=-1, axis=0) -> "DataFrame": result = self.copy() axis = self._get_axis_number(axis) + + if not isinstance(result._get_axis(axis), ABCMultiIndex): # pragma: no cover + raise TypeError("Can only swap levels on a hierarchical axis.") + if axis == 0: + assert isinstance(result.index, ABCMultiIndex) result.index = result.index.swaplevel(i, j) else: + assert isinstance(result.columns, ABCMultiIndex) result.columns = result.columns.swaplevel(i, j) return result @@ -5319,8 +5328,10 @@ def reorder_levels(self, order, axis=0) -> "DataFrame": result = self.copy() if axis == 0: + assert isinstance(result.index, ABCMultiIndex) result.index = result.index.reorder_levels(order) else: + assert isinstance(result.columns, ABCMultiIndex) result.columns = result.columns.reorder_levels(order) return result @@ -8344,8 +8355,10 @@ def to_timestamp(self, freq=None, how="start", axis=0, copy=True) -> "DataFrame" axis = self._get_axis_number(axis) if axis == 0: + assert isinstance(self.index, (ABCDatetimeIndex, ABCPeriodIndex)) new_data.set_axis(1, self.index.to_timestamp(freq=freq, how=how)) elif axis == 1: + assert isinstance(self.columns, (ABCDatetimeIndex, ABCPeriodIndex)) new_data.set_axis(0, self.columns.to_timestamp(freq=freq, how=how)) else: # pragma: no cover raise AssertionError(f"Axis must be 0 or 1. Got {axis}") @@ -8378,8 +8391,10 @@ def to_period(self, freq=None, axis=0, copy=True) -> "DataFrame": axis = self._get_axis_number(axis) if axis == 0: + assert isinstance(self.index, ABCDatetimeIndex) new_data.set_axis(1, self.index.to_period(freq=freq)) elif axis == 1: + assert isinstance(self.columns, ABCDatetimeIndex) new_data.set_axis(0, self.columns.to_period(freq=freq)) else: # pragma: no cover raise AssertionError(f"Axis must be 0 or 1. Got {axis}") @@ -8482,6 +8497,15 @@ def isin(self, values) -> "DataFrame": self.columns, ) + # ---------------------------------------------------------------------- + # Add index and columns + index: "Index" = properties.AxisProperty( + axis=1, doc="The index (row labels) of the DataFrame." + ) + columns: "Index" = properties.AxisProperty( + axis=0, doc="The column labels of the DataFrame." + ) + # ---------------------------------------------------------------------- # Add plotting methods to DataFrame plot = CachedAccessor("plot", pandas.plotting.PlotAccessor) diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 6c04212e26924..6ca461241d4e1 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -30,7 +30,7 @@ from pandas._config import config -from pandas._libs import Timestamp, iNaT, lib, properties +from pandas._libs import Timestamp, iNaT, lib from pandas._typing import ( Axis, Dtype, @@ -333,18 +333,6 @@ def _setup_axes(cls, axes: List[str], docs: Dict[str, str]) -> None: cls._info_axis_number = info_axis cls._info_axis_name = axes[info_axis] - # setup the actual axis - def set_axis(a, i): - setattr(cls, a, properties.AxisProperty(i, docs.get(a, a))) - cls._internal_names_set.add(a) - - if axes_are_reversed: - for i, a in cls._AXIS_NAMES.items(): - set_axis(a, 1 - i) - else: - for i, a in cls._AXIS_NAMES.items(): - set_axis(a, i) - def _construct_axes_dict(self, axes=None, **kwargs): """Return an axes dictionary for myself.""" d = {a: self._get_axis(a) for a in (axes or self._AXIS_ORDERS)} @@ -5083,6 +5071,7 @@ def __finalize__( self.attrs[name] = other.attrs[name] # For subclasses using _metadata. for name in self._metadata: + assert isinstance(name, str) object.__setattr__(self, name, getattr(other, name, None)) return self diff --git a/pandas/core/reshape/pivot.py b/pandas/core/reshape/pivot.py index e250a072766e3..a5a9ec9fb79ba 100644 --- a/pandas/core/reshape/pivot.py +++ b/pandas/core/reshape/pivot.py @@ -134,13 +134,13 @@ def pivot_table( table = agged.unstack(to_unstack) if not dropna: - if table.index.nlevels > 1: + if isinstance(table.index, MultiIndex): m = MultiIndex.from_arrays( cartesian_product(table.index.levels), names=table.index.names ) table = table.reindex(m, axis=0) - if table.columns.nlevels > 1: + if isinstance(table.columns, MultiIndex): m = MultiIndex.from_arrays( cartesian_product(table.columns.levels), names=table.columns.names ) @@ -373,7 +373,7 @@ def _generate_marginal_results_without_values( ): if len(cols) > 0: # need to "interleave" the margins - margin_keys = [] + margin_keys: Union[List, Index] = [] def _all_key(): if len(cols) == 1: diff --git a/pandas/core/series.py b/pandas/core/series.py index ffe0642f799fa..dcc243bb1c601 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -22,7 +22,7 @@ from pandas._config import get_option -from pandas._libs import index as libindex, lib, reshape, tslibs +from pandas._libs import index as libindex, lib, properties, reshape, tslibs from pandas._typing import Label from pandas.compat.numpy import function as nv from pandas.util._decorators import Appender, Substitution @@ -46,6 +46,8 @@ from pandas.core.dtypes.generic import ( ABCDataFrame, ABCDatetimeIndex, + ABCMultiIndex, + ABCPeriodIndex, ABCSeries, ABCSparseArray, ) @@ -176,6 +178,7 @@ class Series(base.IndexOpsMixin, generic.NDFrame): _name: Optional[Hashable] _metadata: List[str] = ["name"] + _internal_names_set = {"index"} | generic.NDFrame._internal_names_set _accessors = {"dt", "cat", "str", "sparse"} _deprecations = ( base.IndexOpsMixin._deprecations @@ -3347,6 +3350,7 @@ def swaplevel(self, i=-2, j=-1, copy=True) -> "Series": Series Series with levels swapped in MultiIndex. """ + assert isinstance(self.index, ABCMultiIndex) new_index = self.index.swaplevel(i, j) return self._constructor(self._values, index=new_index, copy=copy).__finalize__( self @@ -3371,6 +3375,7 @@ def reorder_levels(self, order) -> "Series": raise Exception("Can only reorder levels on a hierarchical axis.") result = self.copy() + assert isinstance(result.index, ABCMultiIndex) result.index = result.index.reorder_levels(order) return result @@ -4448,6 +4453,7 @@ def to_timestamp(self, freq=None, how="start", copy=True) -> "Series": if copy: new_values = new_values.copy() + assert isinstance(self.index, (ABCDatetimeIndex, ABCPeriodIndex)) new_index = self.index.to_timestamp(freq=freq, how=how) return self._constructor(new_values, index=new_index).__finalize__(self) @@ -4472,9 +4478,16 @@ def to_period(self, freq=None, copy=True) -> "Series": if copy: new_values = new_values.copy() + assert isinstance(self.index, ABCDatetimeIndex) new_index = self.index.to_period(freq=freq) return self._constructor(new_values, index=new_index).__finalize__(self) + # ---------------------------------------------------------------------- + # Add index and columns + index: "Index" = properties.AxisProperty( + axis=0, doc="The index (axis labels) of the Series." + ) + # ---------------------------------------------------------------------- # Accessor Methods # ---------------------------------------------------------------------- diff --git a/pandas/io/formats/format.py b/pandas/io/formats/format.py index 296b305f41dd2..c6496411ea9d0 100644 --- a/pandas/io/formats/format.py +++ b/pandas/io/formats/format.py @@ -57,10 +57,13 @@ is_timedelta64_dtype, ) from pandas.core.dtypes.generic import ( + ABCDatetimeIndex, ABCIndexClass, ABCMultiIndex, + ABCPeriodIndex, ABCSeries, ABCSparseArray, + ABCTimedeltaIndex, ) from pandas.core.dtypes.missing import isna, notna @@ -295,6 +298,9 @@ def _get_footer(self) -> str: footer = "" if getattr(self.series.index, "freq", None) is not None: + assert isinstance( + self.series.index, (ABCDatetimeIndex, ABCPeriodIndex, ABCTimedeltaIndex) + ) footer += "Freq: {freq}".format(freq=self.series.index.freqstr) if self.name is not False and name is not None: diff --git a/pandas/tests/test_multilevel.py b/pandas/tests/test_multilevel.py index 1adc5011a0c31..ed9d0cffe2304 100644 --- a/pandas/tests/test_multilevel.py +++ b/pandas/tests/test_multilevel.py @@ -957,6 +957,10 @@ def test_swaplevel(self): exp = self.frame.swaplevel("first", "second").T tm.assert_frame_equal(swapped, exp) + msg = "Can only swap levels on a hierarchical axis." + with pytest.raises(TypeError, match=msg): + DataFrame(range(3)).swaplevel() + def test_reorder_levels(self): result = self.ymd.reorder_levels(["month", "day", "year"]) expected = self.ymd.swaplevel(0, 1).swaplevel(1, 2)