Skip to content

Commit e4614cb

Browse files
authored
ENH: Implement dict-like support for rename and set_names in MultiIndex (#38126)
1 parent 8f26de1 commit e4614cb

File tree

3 files changed

+92
-5
lines changed

3 files changed

+92
-5
lines changed

doc/source/whatsnew/v1.3.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Other enhancements
5151
- :func:`pandas.read_sql_query` now accepts a ``dtype`` argument to cast the columnar data from the SQL database based on user input (:issue:`10285`)
5252
- Improved integer type mapping from pandas to SQLAlchemy when using :meth:`DataFrame.to_sql` (:issue:`35076`)
5353
- :func:`to_numeric` now supports downcasting of nullable ``ExtensionDtype`` objects (:issue:`33013`)
54+
- Add support for dict-like names in :class:`MultiIndex.set_names` and :class:`MultiIndex.rename` (:issue:`20421`)
5455
- :func:`pandas.read_excel` can now auto detect .xlsb files (:issue:`35416`)
5556

5657
.. ---------------------------------------------------------------------------

pandas/core/indexes/base.py

+38-5
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
ABCSeries,
8181
ABCTimedeltaIndex,
8282
)
83+
from pandas.core.dtypes.inference import is_dict_like
8384
from pandas.core.dtypes.missing import array_equivalent, isna
8485

8586
from pandas.core import missing, ops
@@ -1378,11 +1379,18 @@ def set_names(self, names, level=None, inplace: bool = False):
13781379
13791380
Parameters
13801381
----------
1381-
names : label or list of label
1382+
1383+
names : label or list of label or dict-like for MultiIndex
13821384
Name(s) to set.
1385+
1386+
.. versionchanged:: 1.3.0
1387+
13831388
level : int, label or list of int or label, optional
1384-
If the index is a MultiIndex, level(s) to set (None for all
1385-
levels). Otherwise level must be None.
1389+
If the index is a MultiIndex and names is not dict-like, level(s) to set
1390+
(None for all levels). Otherwise level must be None.
1391+
1392+
.. versionchanged:: 1.3.0
1393+
13861394
inplace : bool, default False
13871395
Modifies the object directly, instead of creating a new Index or
13881396
MultiIndex.
@@ -1425,16 +1433,40 @@ def set_names(self, names, level=None, inplace: bool = False):
14251433
( 'cobra', 2018),
14261434
( 'cobra', 2019)],
14271435
names=['species', 'year'])
1436+
1437+
When renaming levels with a dict, levels can not be passed.
1438+
1439+
>>> idx.set_names({'kind': 'snake'})
1440+
MultiIndex([('python', 2018),
1441+
('python', 2019),
1442+
( 'cobra', 2018),
1443+
( 'cobra', 2019)],
1444+
names=['snake', 'year'])
14281445
"""
14291446
if level is not None and not isinstance(self, ABCMultiIndex):
14301447
raise ValueError("Level must be None for non-MultiIndex")
14311448

1432-
if level is not None and not is_list_like(level) and is_list_like(names):
1449+
elif level is not None and not is_list_like(level) and is_list_like(names):
14331450
raise TypeError("Names must be a string when a single level is provided.")
14341451

1435-
if not is_list_like(names) and level is None and self.nlevels > 1:
1452+
elif not is_list_like(names) and level is None and self.nlevels > 1:
14361453
raise TypeError("Must pass list-like as `names`.")
14371454

1455+
elif is_dict_like(names) and not isinstance(self, ABCMultiIndex):
1456+
raise TypeError("Can only pass dict-like as `names` for MultiIndex.")
1457+
1458+
elif is_dict_like(names) and level is not None:
1459+
raise TypeError("Can not pass level for dictlike `names`.")
1460+
1461+
if isinstance(self, ABCMultiIndex) and is_dict_like(names) and level is None:
1462+
# Transform dict to list of new names and corresponding levels
1463+
level, names_adjusted = [], []
1464+
for i, name in enumerate(self.names):
1465+
if name in names.keys():
1466+
level.append(i)
1467+
names_adjusted.append(names[name])
1468+
names = names_adjusted
1469+
14381470
if not is_list_like(names):
14391471
names = [names]
14401472
if level is not None and not is_list_like(level):
@@ -1444,6 +1476,7 @@ def set_names(self, names, level=None, inplace: bool = False):
14441476
idx = self
14451477
else:
14461478
idx = self._shallow_copy()
1479+
14471480
idx._set_names(names, level=level)
14481481
if not inplace:
14491482
return idx

pandas/tests/indexes/multi/test_names.py

+53
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,56 @@ def test_setting_names_from_levels_raises():
150150
assert pd.Index._no_setting_name is False
151151
assert pd.Int64Index._no_setting_name is False
152152
assert pd.RangeIndex._no_setting_name is False
153+
154+
155+
@pytest.mark.parametrize("func", ["rename", "set_names"])
156+
@pytest.mark.parametrize(
157+
"rename_dict, exp_names",
158+
[
159+
({"x": "z"}, ["z", "y", "z"]),
160+
({"x": "z", "y": "x"}, ["z", "x", "z"]),
161+
({"y": "z"}, ["x", "z", "x"]),
162+
({}, ["x", "y", "x"]),
163+
({"z": "a"}, ["x", "y", "x"]),
164+
({"y": "z", "a": "b"}, ["x", "z", "x"]),
165+
],
166+
)
167+
def test_name_mi_with_dict_like_duplicate_names(func, rename_dict, exp_names):
168+
# GH#20421
169+
mi = MultiIndex.from_arrays([[1, 2], [3, 4], [5, 6]], names=["x", "y", "x"])
170+
result = getattr(mi, func)(rename_dict)
171+
expected = MultiIndex.from_arrays([[1, 2], [3, 4], [5, 6]], names=exp_names)
172+
tm.assert_index_equal(result, expected)
173+
174+
175+
@pytest.mark.parametrize("func", ["rename", "set_names"])
176+
@pytest.mark.parametrize(
177+
"rename_dict, exp_names",
178+
[
179+
({"x": "z"}, ["z", "y"]),
180+
({"x": "z", "y": "x"}, ["z", "x"]),
181+
({"a": "z"}, ["x", "y"]),
182+
({}, ["x", "y"]),
183+
],
184+
)
185+
def test_name_mi_with_dict_like(func, rename_dict, exp_names):
186+
# GH#20421
187+
mi = MultiIndex.from_arrays([[1, 2], [3, 4]], names=["x", "y"])
188+
result = getattr(mi, func)(rename_dict)
189+
expected = MultiIndex.from_arrays([[1, 2], [3, 4]], names=exp_names)
190+
tm.assert_index_equal(result, expected)
191+
192+
193+
def test_index_name_with_dict_like_raising():
194+
# GH#20421
195+
ix = pd.Index([1, 2])
196+
msg = "Can only pass dict-like as `names` for MultiIndex."
197+
with pytest.raises(TypeError, match=msg):
198+
ix.set_names({"x": "z"})
199+
200+
201+
def test_multiindex_name_and_level_raising():
202+
# GH#20421
203+
mi = MultiIndex.from_arrays([[1, 2], [3, 4]], names=["x", "y"])
204+
with pytest.raises(TypeError, match="Can not pass level for dictlike `names`."):
205+
mi.set_names(names={"x": "z"}, level={"x": "z"})

0 commit comments

Comments
 (0)