pandas-dev/pandas · #70499

BUG: Index.copy treating name as a list of names (GH#19171)

jbrockmendel · merged Oct 30, 20266 files · 74 + / 44 −
doc/source/whatsnew/v3.2.0.rst2 + / 0 −
@@ -116,6 +116,7 @@ Other deprecations - Deprecated ``median``, ``min``, ``max``, ``idxmin``, ``idxmax``, ``argmin``, ``argmax``, ``cummin``, ``cummax``, ``clip``, and the comparisons ``<``, ``<=``, ``>``, ``>=`` on ``complex`` data; complex numbers have no natural ordering, so in a future version these will raise ``TypeError`` (:issue:`43770`, :issue:`70087`) - Deprecated ``pandas.to_pickle``, use the :meth:`DataFrame.to_pickle` or :meth:`Series.to_pickle` method instead (:issue:`48402`) - Deprecated passing ``None`` entries in ``objs`` to :func:`concat`; filter them out before calling :func:`concat` instead (:issue:`57846`)+- Deprecated passing a list-like ``name`` to :meth:`Index.copy`; pass the label itself instead (:issue:`19171`) - Deprecated passing a negative ``min_count`` to :meth:`DataFrame.sum`, :meth:`DataFrame.prod`, :meth:`Series.sum`, :meth:`Series.prod`, :meth:`TimedeltaIndex.sum`, the ``sum`` and ``prod`` methods of pandas arrays, and the ``sum``, ``prod``, ``min``, ``max``, ``first``, and ``last`` methods of :class:`.DataFrameGroupBy`, :class:`.SeriesGroupBy`, and :class:`.Resampler`. Pass ``min_count=0`` instead, which behaves the same (:issue:`50022`) - Deprecated the ``cache_dates`` argument in :func:`read_csv`, :func:`read_table`, and :func:`read_fwf` (:issue:`68705`) - Deprecated the ``columns`` keyword in :meth:`Series.drop`, which was silently ignored; use ``index`` or ``labels`` instead (:issue:`39509`)@@ -203,6 +204,7 @@ Indexing - Bug in :meth:`DataFrame.loc` and :meth:`Series.loc` raising ``KeyError`` for a ``True`` or ``False`` label present in an object-dtype :class:`Index` or the first level of a :class:`MultiIndex` (:issue:`50165`) - Bug in :meth:`DataFrame.loc` and :meth:`Series.loc` setitem with an integer slice raising ``TypeError`` where the same ``.loc`` getitem succeeds, e.g. on an index of :class:`decimal.Decimal` values (:issue:`70219`) - Bug in :meth:`DataFrame.loc` and :meth:`Series.loc` slicing with an integer or float bound that is not in the index: an object-dtype index of numbers (e.g. :class:`decimal.Decimal`) raised ``TypeError`` instead of slicing by sort order, and a non-monotonic index of non-string values raised ``TypeError`` instead of ``KeyError`` (:issue:`70295`)+- Bug in :meth:`Index.copy` treating a tuple ``name`` as a list of names (:issue:`19171`) - Bug in :meth:`Index.difference` dropping elements that are not in ``other`` when ``other`` was a :class:`DatetimeIndex`, :class:`TimedeltaIndex` or :class:`PeriodIndex` and the elements were strings that parse to its values, or when ``other`` was an :class:`IntervalIndex` and the elements were scalars contained in its intervals (:issue:`58971`)  Missing
pandas/core/indexes/base.py24 + / 37 −
@@ -1,6 +1,7 @@ from __future__ import annotations  from collections import abc+from copy import deepcopy from datetime import (     datetime,     tzinfo,@@ -1417,14 +1418,36 @@ def copy(         False         """ -        name = self._validate_names(name=name, deep=deep)[0]+        name = self._validate_copy_name(name, deep)         if deep:             new_data = self._data.copy()             new_index = type(self)._simple_new(new_data, name=name)         else:             new_index = self._rename(name=name)         return new_index +    @final+    def _validate_copy_name(self, name: Hashable, deep: bool) -> Hashable:+        """+        Validate the ``name`` passed to ``copy``, defaulting to ``self.name``.+        """+        if name is None:+            return deepcopy(self.name) if deep else self.name+        if is_list_like(name) and not is_hashable(name):+            labels = list(cast("Iterable[Hashable]", name))+            if len(labels) == 1:+                # GH#19171+                warnings.warn(+                    f"Passing a list-like 'name' to {type(self).__name__}.copy is "+                    "deprecated and will raise in a future version. Pass the label "+                    "itself instead.",+                    Pandas4Warning,+                    stacklevel=find_stack_level(),+                )+                name = labels[0]+        validate_all_hashable(name, error_name=f"{type(self).__name__}.name")+        return name+     @final     def __copy__(self) -> Self:         return self.copy(deep=False)@@ -1880,42 +1903,6 @@ def name(self, value: Hashable) -> None:         maybe_extract_name(value, None, type(self))         self._name = value -    @final-    def _validate_names(-        self,-        name: Hashable = None,-        names: Sequence[Hashable] | None = None,-        deep: bool = False,-    ) -> list[Hashable]:-        """-        Handles the quirks of having a singular 'name' parameter for general-        Index and plural 'names' parameter for MultiIndex.-        """-        from copy import deepcopy--        if names is not None and name is not None:-            raise TypeError("Can only provide one of `names` and `name`")-        if names is None and name is None:-            new_names = deepcopy(self.names) if deep else self.names-        elif names is not None:-            if not is_list_like(names):-                raise TypeError("Must pass list-like as `names`.")-            new_names = names-        elif not is_list_like(name):-            new_names = [name]-        else:-            new_names = name--        if len(new_names) != len(self.names):  # pyright: ignore[reportArgumentType]-            raise ValueError(-                f"Length of new names must be {len(self.names)}, got {len(new_names)}"  # pyright: ignore[reportArgumentType]-            )--        # All items in 'new_names' need to be hashable-        validate_all_hashable(*new_names, error_name=f"{type(self).__name__}.name")  # pyright: ignore[reportOptionalIterable, reportGeneralTypeIssues]--        return new_names  # pyright: ignore[reportReturnType]-     def _get_default_index_names(         self,         names: Sequence[Hashable] | int | str_t | None = None,
pandas/core/indexes/multi.py30 + / 3 −
@@ -8,6 +8,7 @@     Iterable,     Sequence, )+from copy import deepcopy from functools import wraps from itertools import zip_longest from sys import getsizeof@@ -1377,6 +1378,31 @@ def _view(self) -> MultiIndex:      # -------------------------------------------------------------------- +    def _validate_names(+        self,+        name: Hashable = None,+        names: Sequence[Hashable] | None = None,+    ) -> list[Hashable]:+        """+        Validate the ``name`` or ``names`` passed to ``copy``.+        """+        if names is not None and name is not None:+            raise TypeError("Can only provide one of `names` and `name`")+        if names is not None:+            if not is_list_like(names):+                raise TypeError("Must pass list-like as `names`.")+            new_names = list(names)+        elif not is_list_like(name):+            new_names = [name]+        else:+            new_names = list(cast("Iterable[Hashable]", name))++        if len(new_names) != self.nlevels:+            raise ValueError(+                f"Length of new names must be {self.nlevels}, got {len(new_names)}"+            )+        return new_names+     # error: Signature of "copy" incompatible with supertype "Index"     def copy(  # type: ignore[override]         self,@@ -1433,13 +1459,14 @@ def copy(  # type: ignore[override]         MultiIndex([('a', 'b', 'c')],                    )         """-        names = self._validate_names(name=name, names=names, deep=deep)+        if names is None and name is None:+            names = deepcopy(self.names) if deep else self.names+        else:+            names = self._validate_names(name=name, names=names)         keep_id = not deep         levels, codes = None, None          if deep:-            from copy import deepcopy-             levels = deepcopy(self.levels)             codes = deepcopy(self.codes) 
pandas/core/indexes/range.py1 + / 1 −
@@ -701,7 +701,7 @@ def copy(self, name: Hashable | None = None, deep: bool = False) -> Self:         >>> idx is new_idx         False         """-        name = self._validate_names(name=name, deep=deep)[0]+        name = self._validate_copy_name(name, deep)         new_index = self._rename(name=name)         return new_index 
pandas/tests/indexes/ranges/test_constructors.py7 + / 0 −
@@ -139,6 +139,13 @@ def test_constructor_name(self):         assert copy.name == "copy"         assert new.name == "new" +    def test_constructor_tuple_name(self):+        # GH#19171+        orig = pd.RangeIndex(2, name=("a", "b"))+        assert pd.Index(orig).name == ("a", "b")+        assert pd.RangeIndex(orig).name == ("a", "b")+        assert pd.RangeIndex(orig, name=("x",)).name == ("x",)+     def test_constructor_corner(self):         arr = np.array([1, 2, 3, 4], dtype=object)         index = pd.RangeIndex(1, 5)
pandas/tests/indexes/test_common.py10 + / 3 −
@@ -171,12 +171,19 @@ def test_copy_name2(self, index_flat):          assert index.copy(name="mario").name == "mario" -        with pytest.raises(ValueError, match="Length of new names must be 1, got 2"):-            index.copy(name=["mario", "luigi"])+        # GH#19171 hashable list-likes are valid names, not a list of names+        assert index.copy(name=("mario", "luigi")).name == ("mario", "luigi")+        assert index.copy(name=("mario",)).name == ("mario",)+        assert index.copy(name=("mario",), deep=True).name == ("mario",)+        assert index.copy(name=frozenset({"mario"})).name == frozenset({"mario"})++        msg = "Passing a list-like 'name'"+        with tm.assert_produces_warning(Pandas4Warning, match=msg):+            assert index.copy(name=["mario"]).name == "mario"          msg = f"{type(index).__name__}.name must be a hashable type"         with pytest.raises(TypeError, match=msg):-            index.copy(name=[["mario"]])+            index.copy(name=["mario", "luigi"])      def test_unique_level(self, index_flat):         # don't test a MultiIndex here (as its tested separated)