diff --git a/doc/source/whatsnew/v1.2.0.rst b/doc/source/whatsnew/v1.2.0.rst index 2a8b6fe3ade6a..3c020e7c20876 100644 --- a/doc/source/whatsnew/v1.2.0.rst +++ b/doc/source/whatsnew/v1.2.0.rst @@ -240,7 +240,7 @@ Bug fixes Categorical ^^^^^^^^^^^ - :meth:`Categorical.fillna` will always return a copy, will validate a passed fill value regardless of whether there are any NAs to fill, and will disallow a ``NaT`` as a fill value for numeric categories (:issue:`36530`) -- +- Bug in :meth:`Categorical.__setitem__` that incorrectly raised when trying to set a tuple value (:issue:`20439`) - Datetimelike diff --git a/pandas/core/arrays/categorical.py b/pandas/core/arrays/categorical.py index d2f88b353e1c1..1902d3ec1f9cc 100644 --- a/pandas/core/arrays/categorical.py +++ b/pandas/core/arrays/categorical.py @@ -28,6 +28,7 @@ is_dict_like, is_dtype_equal, is_extension_array_dtype, + is_hashable, is_integer_dtype, is_list_like, is_object_dtype, @@ -61,8 +62,9 @@ def _cat_compare_op(op): @unpack_zerodim_and_defer(opname) def func(self, other): - if is_list_like(other) and len(other) != len(self): - # TODO: Could this fail if the categories are listlike objects? + hashable = is_hashable(other) + if is_list_like(other) and len(other) != len(self) and not hashable: + # in hashable case we may have a tuple that is itself a category raise ValueError("Lengths must match.") if not self.ordered: @@ -90,7 +92,7 @@ def func(self, other): ret[mask] = fill_value return ret - if is_scalar(other): + if hashable: if other in self.categories: i = self._unbox_scalar(other) ret = op(self._codes, i) @@ -1883,7 +1885,8 @@ def _validate_setitem_value(self, value): new_codes = self._validate_listlike(value) value = Categorical.from_codes(new_codes, dtype=self.dtype) - rvalue = value if is_list_like(value) else [value] + # wrap scalars and hashable-listlikes in list + rvalue = value if not is_hashable(value) else [value] from pandas import Index diff --git a/pandas/tests/arrays/categorical/test_indexing.py b/pandas/tests/arrays/categorical/test_indexing.py index ab8606ef9258d..2c4dd8fe64057 100644 --- a/pandas/tests/arrays/categorical/test_indexing.py +++ b/pandas/tests/arrays/categorical/test_indexing.py @@ -75,7 +75,7 @@ def test_setitem_different_unordered_raises(self, other): pd.Categorical(["b", "a"], categories=["a", "b", "c"], ordered=True), ], ) - def test_setitem_same_ordered_rasies(self, other): + def test_setitem_same_ordered_raises(self, other): # Gh-24142 target = pd.Categorical(["a", "b"], categories=["a", "b"], ordered=True) mask = np.array([True, False]) @@ -83,6 +83,14 @@ def test_setitem_same_ordered_rasies(self, other): with pytest.raises(ValueError, match=msg): target[mask] = other[mask] + def test_setitem_tuple(self): + # GH#20439 + cat = pd.Categorical([(0, 1), (0, 2), (0, 1)]) + + # This should not raise + cat[1] = cat[0] + assert cat[1] == (0, 1) + class TestCategoricalIndexing: def test_getitem_slice(self): diff --git a/pandas/tests/arrays/categorical/test_operators.py b/pandas/tests/arrays/categorical/test_operators.py index 34194738bf4ab..ed70417523491 100644 --- a/pandas/tests/arrays/categorical/test_operators.py +++ b/pandas/tests/arrays/categorical/test_operators.py @@ -179,6 +179,20 @@ def test_comparison_with_unknown_scalars(self): tm.assert_numpy_array_equal(cat == 4, np.array([False, False, False])) tm.assert_numpy_array_equal(cat != 4, np.array([True, True, True])) + def test_comparison_with_tuple(self): + cat = pd.Categorical(np.array(["foo", (0, 1), 3, (0, 1)], dtype=object)) + + result = cat == "foo" + expected = np.array([True, False, False, False], dtype=bool) + tm.assert_numpy_array_equal(result, expected) + + result = cat == (0, 1) + expected = np.array([False, True, False, True], dtype=bool) + tm.assert_numpy_array_equal(result, expected) + + result = cat != (0, 1) + tm.assert_numpy_array_equal(result, ~expected) + def test_comparison_of_ordered_categorical_with_nan_to_scalar( self, compare_operators_no_eq_ne ):