Skip to content

Commit 698f689

Browse files
jbrockmendeljreback
authored andcommitted
BUG: raise on non-hashable Index name, closes #29069 (#30335)
1 parent 95770df commit 698f689

File tree

13 files changed

+67
-32
lines changed

13 files changed

+67
-32
lines changed

doc/source/whatsnew/v1.0.0.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -895,7 +895,7 @@ Other
895895
- Fixed :class:`IntegerArray` returning ``inf`` rather than ``NaN`` for operations dividing by 0 (:issue:`27398`)
896896
- Fixed ``pow`` operations for :class:`IntegerArray` when the other value is ``0`` or ``1`` (:issue:`29997`)
897897
- Bug in :meth:`Series.count` raises if use_inf_as_na is enabled (:issue:`29478`)
898-
898+
- Bug in :class:`Index` where a non-hashable name could be set without raising ``TypeError`` (:issue:29069`)
899899

900900
.. _whatsnew_1000.contributors:
901901

pandas/core/indexes/base.py

+32-8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from datetime import datetime
22
import operator
33
from textwrap import dedent
4-
from typing import FrozenSet, Union
4+
from typing import FrozenSet, Hashable, Optional, Union
55
import warnings
66

77
import numpy as np
@@ -239,7 +239,7 @@ def _outer_indexer(self, left, right):
239239
_typ = "index"
240240
_data: Union[ExtensionArray, np.ndarray]
241241
_id = None
242-
name = None
242+
_name: Optional[Hashable] = None
243243
_comparables = ["name"]
244244
_attributes = ["name"]
245245
_is_numeric_dtype = False
@@ -274,8 +274,7 @@ def __new__(
274274
from .interval import IntervalIndex
275275
from .category import CategoricalIndex
276276

277-
if name is None and hasattr(data, "name"):
278-
name = data.name
277+
name = maybe_extract_name(name, data, cls)
279278

280279
if isinstance(data, ABCPandasArray):
281280
# ensure users don't accidentally put a PandasArray in an index.
@@ -520,7 +519,7 @@ def _simple_new(cls, values, name=None, dtype=None):
520519
# data buffers and strides. We don't re-use `_ndarray_values`, since
521520
# we actually set this value too.
522521
result._index_data = values
523-
result.name = name
522+
result._name = name
524523

525524
return result._reset_identity()
526525

@@ -1209,6 +1208,15 @@ def to_frame(self, index=True, name=None):
12091208
# --------------------------------------------------------------------
12101209
# Name-Centric Methods
12111210

1211+
@property
1212+
def name(self):
1213+
return self._name
1214+
1215+
@name.setter
1216+
def name(self, value):
1217+
maybe_extract_name(value, None, type(self))
1218+
self._name = value
1219+
12121220
def _validate_names(self, name=None, names=None, deep=False):
12131221
"""
12141222
Handles the quirks of having a singular 'name' parameter for general
@@ -1258,7 +1266,7 @@ def _set_names(self, values, level=None):
12581266
for name in values:
12591267
if not is_hashable(name):
12601268
raise TypeError(f"{type(self).__name__}.name must be a hashable type")
1261-
self.name = values[0]
1269+
self._name = values[0]
12621270

12631271
names = property(fset=_set_names, fget=_get_names)
12641272

@@ -1546,7 +1554,7 @@ def droplevel(self, level=0):
15461554
if mask.any():
15471555
result = result.putmask(mask, np.nan)
15481556

1549-
result.name = new_names[0]
1557+
result._name = new_names[0]
15501558
return result
15511559
else:
15521560
from .multi import MultiIndex
@@ -1777,7 +1785,7 @@ def __setstate__(self, state):
17771785
nd_state, own_state = state
17781786
data = np.empty(nd_state[1], dtype=nd_state[2])
17791787
np.ndarray.__setstate__(data, nd_state)
1780-
self.name = own_state[0]
1788+
self._name = own_state[0]
17811789

17821790
else: # pragma: no cover
17831791
data = np.empty(state)
@@ -5462,3 +5470,19 @@ def default_index(n):
54625470
from pandas.core.indexes.range import RangeIndex
54635471

54645472
return RangeIndex(0, n, name=None)
5473+
5474+
5475+
def maybe_extract_name(name, obj, cls) -> Optional[Hashable]:
5476+
"""
5477+
If no name is passed, then extract it from data, validating hashability.
5478+
"""
5479+
if name is None and isinstance(obj, (Index, ABCSeries)):
5480+
# Note we don't just check for "name" attribute since that would
5481+
# pick up e.g. dtype.name
5482+
name = obj.name
5483+
5484+
# GH#29069
5485+
if not is_hashable(name):
5486+
raise TypeError(f"{cls.__name__}.name must be a hashable type")
5487+
5488+
return name

pandas/core/indexes/category.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from pandas.core.base import _shared_docs
3030
import pandas.core.common as com
3131
import pandas.core.indexes.base as ibase
32-
from pandas.core.indexes.base import Index, _index_shared_docs
32+
from pandas.core.indexes.base import Index, _index_shared_docs, maybe_extract_name
3333
import pandas.core.missing as missing
3434
from pandas.core.ops import get_op_result_name
3535

@@ -175,8 +175,7 @@ def __new__(
175175

176176
dtype = CategoricalDtype._from_values_or_dtype(data, categories, ordered, dtype)
177177

178-
if name is None and hasattr(data, "name"):
179-
name = data.name
178+
name = maybe_extract_name(name, data, cls)
180179

181180
if not is_categorical_dtype(data):
182181
# don't allow scalars

pandas/core/indexes/datetimelike.py

-1
Original file line numberDiff line numberDiff line change
@@ -841,7 +841,6 @@ class DatetimelikeDelegateMixin(PandasDelegate):
841841
_raw_methods: Set[str] = set()
842842
# raw_properties : dispatch properties that shouldn't be boxed in an Index
843843
_raw_properties: Set[str] = set()
844-
name = None
845844
_data: ExtensionArray
846845

847846
@property

pandas/core/indexes/datetimes.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
)
3131
from pandas.core.base import _shared_docs
3232
import pandas.core.common as com
33-
from pandas.core.indexes.base import Index
33+
from pandas.core.indexes.base import Index, maybe_extract_name
3434
from pandas.core.indexes.datetimelike import (
3535
DatetimeIndexOpsMixin,
3636
DatetimelikeDelegateMixin,
@@ -257,8 +257,7 @@ def __new__(
257257

258258
# - Cases checked above all return/raise before reaching here - #
259259

260-
if name is None and hasattr(data, "name"):
261-
name = data.name
260+
name = maybe_extract_name(name, data, cls)
262261

263262
dtarr = DatetimeArray._from_sequence(
264263
data,

pandas/core/indexes/interval.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
_index_shared_docs,
4848
default_pprint,
4949
ensure_index,
50+
maybe_extract_name,
5051
)
5152
from pandas.core.indexes.datetimes import DatetimeIndex, date_range
5253
from pandas.core.indexes.multi import MultiIndex
@@ -217,8 +218,7 @@ def __new__(
217218
verify_integrity: bool = True,
218219
):
219220

220-
if name is None and hasattr(data, "name"):
221-
name = data.name
221+
name = maybe_extract_name(name, data, cls)
222222

223223
with rewrite_exception("IntervalArray", cls.__name__):
224224
array = IntervalArray(

pandas/core/indexes/numeric.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@
3030

3131
from pandas.core import algorithms
3232
import pandas.core.common as com
33-
from pandas.core.indexes.base import Index, InvalidIndexError, _index_shared_docs
33+
from pandas.core.indexes.base import (
34+
Index,
35+
InvalidIndexError,
36+
_index_shared_docs,
37+
maybe_extract_name,
38+
)
3439
from pandas.core.ops import get_op_result_name
3540

3641
_num_index_shared_docs = dict()
@@ -68,8 +73,7 @@ def __new__(cls, data=None, dtype=None, copy=False, name=None):
6873
else:
6974
subarr = data
7075

71-
if name is None and hasattr(data, "name"):
72-
name = data.name
76+
name = maybe_extract_name(name, data, cls)
7377
return cls._simple_new(subarr, name=name)
7478

7579
@classmethod

pandas/core/indexes/period.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@
2525
from pandas.core.base import _shared_docs
2626
import pandas.core.common as com
2727
import pandas.core.indexes.base as ibase
28-
from pandas.core.indexes.base import _index_shared_docs, ensure_index
28+
from pandas.core.indexes.base import (
29+
_index_shared_docs,
30+
ensure_index,
31+
maybe_extract_name,
32+
)
2933
from pandas.core.indexes.datetimelike import (
3034
DatetimeIndexOpsMixin,
3135
DatetimelikeDelegateMixin,
@@ -184,8 +188,7 @@ def __new__(
184188
argument = list(set(fields) - valid_field_set)[0]
185189
raise TypeError(f"__new__() got an unexpected keyword argument {argument}")
186190

187-
if name is None and hasattr(data, "name"):
188-
name = data.name
191+
name = maybe_extract_name(name, data, cls)
189192

190193
if data is None and ordinal is None:
191194
# range-based.

pandas/core/indexes/range.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import pandas.core.common as com
2727
from pandas.core.construction import extract_array
2828
import pandas.core.indexes.base as ibase
29-
from pandas.core.indexes.base import Index, _index_shared_docs
29+
from pandas.core.indexes.base import Index, _index_shared_docs, maybe_extract_name
3030
from pandas.core.indexes.numeric import Int64Index
3131
from pandas.core.ops.common import unpack_zerodim_and_defer
3232

@@ -85,10 +85,10 @@ def __new__(
8585
):
8686

8787
cls._validate_dtype(dtype)
88+
name = maybe_extract_name(name, start, cls)
8889

8990
# RangeIndex
9091
if isinstance(start, RangeIndex):
91-
name = start.name if name is None else name
9292
start = start._range
9393
return cls._simple_new(start, dtype=dtype, name=name)
9494

pandas/core/indexes/timedeltas.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from pandas.core.arrays.timedeltas import TimedeltaArray, _is_convertible_to_td
2626
from pandas.core.base import _shared_docs
2727
import pandas.core.common as com
28-
from pandas.core.indexes.base import Index, _index_shared_docs
28+
from pandas.core.indexes.base import Index, _index_shared_docs, maybe_extract_name
2929
from pandas.core.indexes.datetimelike import (
3030
DatetimeIndexOpsMixin,
3131
DatetimelikeDelegateMixin,
@@ -168,6 +168,7 @@ def __new__(
168168
copy=False,
169169
name=None,
170170
):
171+
name = maybe_extract_name(name, data, cls)
171172

172173
if is_scalar(data):
173174
raise TypeError(
@@ -215,7 +216,7 @@ def _simple_new(cls, values, name=None, freq=None, dtype=_TD_DTYPE):
215216
tdarr = TimedeltaArray._simple_new(values._data, freq=freq)
216217
result = object.__new__(cls)
217218
result._data = tdarr
218-
result.name = name
219+
result._name = name
219220
# For groupby perf. See note in indexes/base about _index_data
220221
result._index_data = tdarr._data
221222

pandas/core/series.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ def __init__(
194194

195195
else:
196196

197+
name = ibase.maybe_extract_name(name, data, type(self))
198+
197199
if is_empty_data(data) and dtype is None:
198200
# gh-17261
199201
warnings.warn(
@@ -219,8 +221,6 @@ def __init__(
219221
"initializing a Series from a MultiIndex is not supported"
220222
)
221223
elif isinstance(data, Index):
222-
if name is None:
223-
name = data.name
224224

225225
if dtype is not None:
226226
# astype copies
@@ -244,8 +244,6 @@ def __init__(
244244
)
245245
pass
246246
elif isinstance(data, ABCSeries):
247-
if name is None:
248-
name = data.name
249247
if index is None:
250248
index = data.index
251249
else:

pandas/tests/indexes/common.py

+7
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ def test_shift(self):
103103
with pytest.raises(NotImplementedError, match=msg):
104104
idx.shift(1, 2)
105105

106+
def test_constructor_name_unhashable(self):
107+
# GH#29069 check that name is hashable
108+
# See also same-named test in tests.series.test_constructors
109+
idx = self.create_index()
110+
with pytest.raises(TypeError, match="Index.name must be a hashable type"):
111+
type(idx)(idx, name=[])
112+
106113
def test_create_index_existing_name(self):
107114

108115
# GH11193, when an existing index is passed, and a new name is not

pandas/tests/indexes/test_base.py

+1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def test_constructor_copy(self, index):
100100
arr[0] = "SOMEBIGLONGSTRING"
101101
assert new_index[0] != "SOMEBIGLONGSTRING"
102102

103+
# FIXME: dont leave commented-out
103104
# what to do here?
104105
# arr = np.array(5.)
105106
# pytest.raises(Exception, arr.view, Index)

0 commit comments

Comments
 (0)