From f8d977027da16a24fa92f232cbaa4d12cdaa7320 Mon Sep 17 00:00:00 2001 From: denx <54038084+deniskrds@users.noreply.github.com> Date: Sun, 23 Nov 2025 02:01:56 +0300 Subject: [PATCH 1/2] BUG: Fix metadata mutation in Index set operations (intersection/union) (#63169) --- pandas/core/indexes/base.py | 5 +-- pandas/tests/indexes/test_setops.py | 65 ++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index 0f9938916b8df..aee19024f60dc 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -2975,13 +2975,12 @@ def __bool__(self) -> NoReturn: def _get_reconciled_name_object(self, other): """ If the result of a set operation will be self, - return self, unless the name changes, in which - case make a shallow copy of self. + return a shallow copy of self. """ name = get_op_result_name(self, other) if self.name is not name: return self.rename(name) - return self + return self.copy() @final def _validate_sort_keyword(self, sort) -> None: diff --git a/pandas/tests/indexes/test_setops.py b/pandas/tests/indexes/test_setops.py index 6922803c325b3..65077f1e29398 100644 --- a/pandas/tests/indexes/test_setops.py +++ b/pandas/tests/indexes/test_setops.py @@ -727,7 +727,7 @@ def test_intersection(self, index, sort): # Corner cases inter = first.intersection(first, sort=sort) - assert inter is first + assert inter is not first @pytest.mark.parametrize( "index2_name,keeps_name", @@ -812,16 +812,16 @@ def test_union_identity(self, index, sort): first = index[5:20] union = first.union(first, sort=sort) - # i.e. identity is not preserved when sort is True - assert (union is first) is (not sort) + # GH#63169 - identity is never preserved now + assert union is not first # This should no longer be the same object, since [] is not consistent, # both objects will be recast to dtype('O') union = first.union(Index([], dtype=first.dtype), sort=sort) - assert (union is first) is (not sort) + assert union is not first union = Index([], dtype=first.dtype).union(first, sort=sort) - assert (union is first) is (not sort) + assert union is not first @pytest.mark.parametrize("index", ["string"], indirect=True) @pytest.mark.parametrize("second_name,expected", [(None, None), ("name", "name")]) @@ -984,3 +984,58 @@ def test_union_pyarrow_timestamp(self): res = left.union(right) expected = Index(["2020-01-01", "2020-01-02"], dtype=left.dtype) tm.assert_index_equal(res, expected) + + +class TestSetOpsMutation: + def test_intersection_mutation_safety(self): + # GH#63169 + index1 = Index([0, 1], name="original") + index2 = Index([0, 1], name="original") + + result = index1.intersection(index2) + + assert result is not index1 + assert result is not index2 + + tm.assert_index_equal(result, index1) + assert result.name == "original" + + index1.name = "changed" + + assert result.name == "original" + assert index1.name == "changed" + + def test_union_mutation_safety(self): + # GH#63169 + index1 = Index([0, 1], name="original") + index2 = Index([0, 1], name="original") + + result = index1.union(index2) + + assert result is not index1 + assert result is not index2 + + tm.assert_index_equal(result, index1) + assert result.name == "original" + + index1.name = "changed" + + assert result.name == "original" + assert index1.name == "changed" + + def test_union_mutation_safety_other(self): + # GH#63169 + index1 = Index([], name="original") + index2 = Index([0, 1], name="original") + + result = index1.union(index2) + + assert result is not index2 + + tm.assert_index_equal(result, index2) + assert result.name == "original" + + index2.name = "changed" + + assert result.name == "original" + assert index2.name == "changed" From 3e9186dc43e6dc21f4090e6f358e47714c02647a Mon Sep 17 00:00:00 2001 From: denx <54038084+deniskrds@users.noreply.github.com> Date: Sun, 23 Nov 2025 03:00:06 +0300 Subject: [PATCH 2/2] BUG: Fix metadata mutation in MultiIndex set operations --- pandas/core/indexes/multi.py | 5 ++-- pandas/tests/indexes/test_setops.py | 26 +++++++++++++++++-- .../tests/indexes/timedeltas/test_setops.py | 4 +-- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/pandas/core/indexes/multi.py b/pandas/core/indexes/multi.py index 43e6469e078f0..b2f336ef10f13 100644 --- a/pandas/core/indexes/multi.py +++ b/pandas/core/indexes/multi.py @@ -4082,13 +4082,12 @@ def _is_comparable_dtype(self, dtype: DtypeObj) -> bool: def _get_reconciled_name_object(self, other) -> MultiIndex: """ If the result of a set operation will be self, - return self, unless the names change, in which - case make a shallow copy of self. + return a shallow copy of self. """ names = self._maybe_match_names(other) if self.names != names: return self.rename(names) - return self + return self.copy() def _maybe_match_names(self, other): """ diff --git a/pandas/tests/indexes/test_setops.py b/pandas/tests/indexes/test_setops.py index 65077f1e29398..8c16d62f6880e 100644 --- a/pandas/tests/indexes/test_setops.py +++ b/pandas/tests/indexes/test_setops.py @@ -812,7 +812,7 @@ def test_union_identity(self, index, sort): first = index[5:20] union = first.union(first, sort=sort) - # GH#63169 - identity is never preserved now + # GH#63169 - identity is not preserved to prevent shared mutable state assert union is not first # This should no longer be the same object, since [] is not consistent, @@ -1025,7 +1025,7 @@ def test_union_mutation_safety(self): def test_union_mutation_safety_other(self): # GH#63169 - index1 = Index([], name="original") + index1 = Index([0, 1], name="original") index2 = Index([0, 1], name="original") result = index1.union(index2) @@ -1039,3 +1039,25 @@ def test_union_mutation_safety_other(self): assert result.name == "original" assert index2.name == "changed" + + def test_multiindex_intersection_mutation_safety(self): + # GH#63169 + mi1 = MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["x", "y"]) + mi2 = MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["x", "y"]) + + result = mi1.intersection(mi2) + assert result is not mi1 + + mi1.names = ["changed1", "changed2"] + assert result.names == ["x", "y"] + + def test_multiindex_union_mutation_safety(self): + # GH#63169 + mi1 = MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["x", "y"]) + mi2 = MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["x", "y"]) + + result = mi1.union(mi2) + assert result is not mi1 + + mi1.names = ["changed1", "changed2"] + assert result.names == ["x", "y"] diff --git a/pandas/tests/indexes/timedeltas/test_setops.py b/pandas/tests/indexes/timedeltas/test_setops.py index 951b8346ac9e6..e729856af230a 100644 --- a/pandas/tests/indexes/timedeltas/test_setops.py +++ b/pandas/tests/indexes/timedeltas/test_setops.py @@ -114,7 +114,7 @@ def test_intersection_bug_1708(self): def test_intersection_equal(self, sort): # GH 24471 Test intersection outcome given the sort keyword - # for equal indices intersection should return the original index + # GH#63169 intersection returns a copy to prevent shared mutable state first = timedelta_range("1 day", periods=4, freq="h") second = timedelta_range("1 day", periods=4, freq="h") intersect = first.intersection(second, sort=sort) @@ -124,7 +124,7 @@ def test_intersection_equal(self, sort): # Corner cases inter = first.intersection(first, sort=sort) - assert inter is first + assert inter is not first @pytest.mark.parametrize("period_1, period_2", [(0, 4), (4, 0)]) def test_intersection_zero_length(self, period_1, period_2, sort):