From d66ab2228085acc64b33d09a2522c5e5db89a71a Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 8 Aug 2025 10:41:06 +0200 Subject: [PATCH 01/14] Test chained assignment detection for Python 3.14 --- pandas/_libs/internals.pyx | 56 ++++++++++++++++++++++++++++++++++++++ pandas/_libs/tslib.pyx | 1 + pandas/core/frame.py | 16 +++++------ pandas/core/generic.py | 2 +- pandas/core/series.py | 16 +++++------ 5 files changed, 74 insertions(+), 17 deletions(-) diff --git a/pandas/_libs/internals.pyx b/pandas/_libs/internals.pyx index 4fb24c9ad1538..f866146249625 100644 --- a/pandas/_libs/internals.pyx +++ b/pandas/_libs/internals.pyx @@ -1,6 +1,9 @@ from collections import defaultdict +import sys +import warnings cimport cython +from cpython cimport PY_VERSION_HEX from cpython.object cimport PyObject from cpython.pyport cimport PY_SSIZE_T_MAX from cpython.slice cimport PySlice_GetIndicesEx @@ -20,6 +23,7 @@ from numpy cimport ( cnp.import_array() from pandas._libs.algos import ensure_int64 +from pandas.errors import ChainedAssignmentError from pandas._libs.util cimport ( is_array, @@ -996,3 +1000,55 @@ cdef class BlockValuesRefs: return self._has_reference_maybe_locked() ELSE: return self._has_reference_maybe_locked() + + +cdef extern from "Python.h": + """ + #if PY_VERSION_HEX < 0x030E0000 + int __Pyx_PyUnstable_Object_IsUniqueReferencedTemporary(PyObject *ref) + { + return 0; + } + #else + #define __Pyx_PyUnstable_Object_IsUniqueReferencedTemporary \ + PyUnstable_Object_IsUniqueReferencedTemporary + #endif + """ + int PyUnstable_Object_IsUniqueReferencedTemporary\ + "__Pyx_PyUnstable_Object_IsUniqueReferencedTemporary"(object o) except -1 + + +cdef inline bint _is_unique_referenced_temporary(object obj) except -1: + if PY_VERSION_HEX >= 0x030E0000: + return PyUnstable_Object_IsUniqueReferencedTemporary(obj) + else: + return sys.getrefcount(obj) == 2 + + +# # Python version compatibility for PyUnstable_Object_IsUniqueReferencedTemporary +# IF PY_VERSION_HEX >= 0x030E0000: +# # Python 3.14+ has PyUnstable_Object_IsUniqueReferencedTemporary +# cdef inline bint _is_unique_referenced_temporary(object obj) except -1: +# return PyUnstable_Object_IsUniqueReferencedTemporary(obj) +# ELSE: +# # Fallback for older Python versions using sys.getrefcount +# cdef inline bint _is_unique_referenced_temporary(object obj) except -1: +# # sys.getrefcount includes the reference from getrefcount itself +# # So if refcount is 2, it means only one external reference exists +# return sys.getrefcount(obj) == 2 + + +cdef class SetitemMixin: + + def __setitem__(self, key, value): + cdef bint is_unique = _is_unique_referenced_temporary(self) + # print("Refcount self: ", sys.getrefcount(self)) + # print("Is unique referenced temporary: ", is_unique) + if is_unique: + warnings.warn( + "A value is trying to be set on a copy of a DataFrame or Series " + "through chained assignment.", + ChainedAssignmentError, + stacklevel=1, + ) + self._setitem(key, value) diff --git a/pandas/_libs/tslib.pyx b/pandas/_libs/tslib.pyx index 3c5854602df53..ba4c432ccd266 100644 --- a/pandas/_libs/tslib.pyx +++ b/pandas/_libs/tslib.pyx @@ -267,6 +267,7 @@ cpdef array_to_datetime( str unit_for_numerics=None, ): """ + TODO no longer up to date Converts a 1D array of date-like values to a numpy array of either: 1) datetime64[ns] data 2) datetime.datetime objects, if OutOfBoundsDatetime or TypeError diff --git a/pandas/core/frame.py b/pandas/core/frame.py index e48620a854edb..effee9ea7811e 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -47,6 +47,7 @@ properties, ) from pandas._libs.hashtable import duplicated +from pandas._libs.internals import SetitemMixin from pandas._libs.lib import is_range_indexer from pandas.compat import PYPY from pandas.compat._constants import REF_COUNT @@ -58,7 +59,6 @@ ) from pandas.errors.cow import ( _chained_assignment_method_msg, - _chained_assignment_msg, ) from pandas.util._decorators import ( Appender, @@ -511,7 +511,7 @@ @set_module("pandas") -class DataFrame(NDFrame, OpsMixin): +class DataFrame(NDFrame, OpsMixin, SetitemMixin): """ Two-dimensional, size-mutable, potentially heterogeneous tabular data. @@ -4212,7 +4212,7 @@ def isetitem(self, loc, value) -> None: arraylike, refs = self._sanitize_column(value) self._iset_item_mgr(loc, arraylike, inplace=False, refs=refs) - def __setitem__(self, key, value) -> None: + def _setitem(self, key, value) -> None: """ Set item(s) in DataFrame by key. @@ -4296,11 +4296,11 @@ def __setitem__(self, key, value) -> None: z 3 50 # Values for 'a' and 'b' are completely ignored! """ - if not PYPY: - if sys.getrefcount(self) <= 3: - warnings.warn( - _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 - ) + # if not PYPY: + # if sys.getrefcount(self) <= 3: + # warnings.warn( + # _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 + # ) key = com.apply_if_callable(key, self) diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 2ae28266266f6..ff59ddae467c6 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -305,7 +305,7 @@ def _from_mgr(cls, mgr: Manager, axes: list[Index]) -> Self: The axes must match mgr.axes, but are required for future-proofing in the event that axes are refactored out of the Manager objects. """ - obj = cls.__new__(cls) + obj = object.__new__(cls) NDFrame.__init__(obj, mgr) return obj diff --git a/pandas/core/series.py b/pandas/core/series.py index 6ae03f2464f76..a36cd3d1de47c 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -32,6 +32,7 @@ properties, reshape, ) +from pandas._libs.internals import SetitemMixin from pandas._libs.lib import is_range_indexer from pandas.compat import PYPY from pandas.compat._constants import REF_COUNT @@ -43,7 +44,6 @@ ) from pandas.errors.cow import ( _chained_assignment_method_msg, - _chained_assignment_msg, ) from pandas.util._decorators import ( Appender, @@ -234,7 +234,7 @@ # class "NDFrame") # definition in base class "NDFrame" @set_module("pandas") -class Series(base.IndexOpsMixin, NDFrame): # type: ignore[misc] +class Series(base.IndexOpsMixin, NDFrame, SetitemMixin): # type: ignore[misc] """ One-dimensional ndarray with axis labels (including time series). @@ -1058,12 +1058,12 @@ def _get_value(self, label, takeable: bool = False): else: return self.iloc[loc] - def __setitem__(self, key, value) -> None: - if not PYPY: - if sys.getrefcount(self) <= 3: - warnings.warn( - _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 - ) + def _setitem(self, key, value) -> None: + # if not PYPY: + # if sys.getrefcount(self) <= 3: + # warnings.warn( + # _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 + # ) check_dict_or_set_indexers(key) key = com.apply_if_callable(key, self) From 7d5a73298c69f7e6ef8fc71f520da305e3dbafa7 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 8 Aug 2025 15:14:36 +0200 Subject: [PATCH 02/14] fix pickling --- pandas/compat/pickle_compat.py | 5 +++++ pandas/core/frame.py | 3 +-- pandas/core/generic.py | 10 ++++++++-- pandas/core/series.py | 3 +-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pandas/compat/pickle_compat.py b/pandas/compat/pickle_compat.py index beb4a69232b27..8247356f25f4d 100644 --- a/pandas/compat/pickle_compat.py +++ b/pandas/compat/pickle_compat.py @@ -22,6 +22,7 @@ PeriodArray, TimedeltaArray, ) +from pandas.core.generic import NDFrame from pandas.core.internals import BlockManager if TYPE_CHECKING: @@ -90,6 +91,10 @@ def load_reduce(self) -> None: cls = args[0] stack[-1] = NDArrayBacked.__new__(*args) return + elif args and issubclass(args[0], NDFrame): + cls = args[0] + stack[-1] = cls.__new__(cls) + return raise dispatch[pickle.REDUCE[0]] = load_reduce # type: ignore[assignment] diff --git a/pandas/core/frame.py b/pandas/core/frame.py index effee9ea7811e..da29179a73588 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -47,7 +47,6 @@ properties, ) from pandas._libs.hashtable import duplicated -from pandas._libs.internals import SetitemMixin from pandas._libs.lib import is_range_indexer from pandas.compat import PYPY from pandas.compat._constants import REF_COUNT @@ -511,7 +510,7 @@ @set_module("pandas") -class DataFrame(NDFrame, OpsMixin, SetitemMixin): +class DataFrame(NDFrame, OpsMixin): """ Two-dimensional, size-mutable, potentially heterogeneous tabular data. diff --git a/pandas/core/generic.py b/pandas/core/generic.py index ff59ddae467c6..8c042ec0d2524 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -27,6 +27,7 @@ from pandas._config import config from pandas._libs import lib +from pandas._libs.internals import SetitemMixin from pandas._libs.lib import is_range_indexer from pandas._libs.tslibs import ( Period, @@ -222,7 +223,7 @@ } -class NDFrame(PandasObject, indexing.IndexingMixin): +class NDFrame(PandasObject, indexing.IndexingMixin, SetitemMixin): """ N-dimensional analogue of DataFrame. Store multi-dimensional in a size-mutable, labeled data structure @@ -252,6 +253,11 @@ class NDFrame(PandasObject, indexing.IndexingMixin): # ---------------------------------------------------------------------- # Constructors + # override those to avoid inheriting from SetitemMixin (cython generates + # them by default) + __new__ = object.__new__ + __reduce__ = object.__reduce__ + def __init__(self, data: Manager) -> None: object.__setattr__(self, "_mgr", data) object.__setattr__(self, "_attrs", {}) @@ -305,7 +311,7 @@ def _from_mgr(cls, mgr: Manager, axes: list[Index]) -> Self: The axes must match mgr.axes, but are required for future-proofing in the event that axes are refactored out of the Manager objects. """ - obj = object.__new__(cls) + obj = cls.__new__(cls) NDFrame.__init__(obj, mgr) return obj diff --git a/pandas/core/series.py b/pandas/core/series.py index a36cd3d1de47c..b747009a989df 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -32,7 +32,6 @@ properties, reshape, ) -from pandas._libs.internals import SetitemMixin from pandas._libs.lib import is_range_indexer from pandas.compat import PYPY from pandas.compat._constants import REF_COUNT @@ -234,7 +233,7 @@ # class "NDFrame") # definition in base class "NDFrame" @set_module("pandas") -class Series(base.IndexOpsMixin, NDFrame, SetitemMixin): # type: ignore[misc] +class Series(base.IndexOpsMixin, NDFrame): # type: ignore[misc] """ One-dimensional ndarray with axis labels (including time series). From ae8432650d17ad3b320136943a7e0f3454b4e56f Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 8 Aug 2025 16:39:30 +0200 Subject: [PATCH 03/14] tweak mro to get it working on python 3.14, fix refcount check for older python --- pandas/_libs/internals.pyx | 6 +++++- pandas/core/frame.py | 8 +++++++- pandas/core/generic.py | 11 +++-------- pandas/core/series.py | 8 +++++++- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/pandas/_libs/internals.pyx b/pandas/_libs/internals.pyx index f866146249625..9fe13b2e7b7c6 100644 --- a/pandas/_libs/internals.pyx +++ b/pandas/_libs/internals.pyx @@ -1022,7 +1022,7 @@ cdef inline bint _is_unique_referenced_temporary(object obj) except -1: if PY_VERSION_HEX >= 0x030E0000: return PyUnstable_Object_IsUniqueReferencedTemporary(obj) else: - return sys.getrefcount(obj) == 2 + return sys.getrefcount(obj) <= 1 # # Python version compatibility for PyUnstable_Object_IsUniqueReferencedTemporary @@ -1038,6 +1038,7 @@ cdef inline bint _is_unique_referenced_temporary(object obj) except -1: # return sys.getrefcount(obj) == 2 +# @cython.auto_pickle(False) cdef class SetitemMixin: def __setitem__(self, key, value): @@ -1052,3 +1053,6 @@ cdef class SetitemMixin: stacklevel=1, ) self._setitem(key, value) + + def __delitem__(self, key) -> None: + self._delitem(key) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index da29179a73588..c607ce2360155 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -47,6 +47,7 @@ properties, ) from pandas._libs.hashtable import duplicated +from pandas._libs.internals import SetitemMixin from pandas._libs.lib import is_range_indexer from pandas.compat import PYPY from pandas.compat._constants import REF_COUNT @@ -510,7 +511,7 @@ @set_module("pandas") -class DataFrame(NDFrame, OpsMixin): +class DataFrame(SetitemMixin, NDFrame, OpsMixin): """ Two-dimensional, size-mutable, potentially heterogeneous tabular data. @@ -659,6 +660,11 @@ class DataFrame(NDFrame, OpsMixin): # and ExtensionArray. Should NOT be overridden by subclasses. __pandas_priority__ = 4000 + # override those to avoid inheriting from SetitemMixin (cython generates + # them by default) + __reduce__ = object.__reduce__ + __setstate__ = NDFrame.__setstate__ + @property def _constructor(self) -> type[DataFrame]: return DataFrame diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 8c042ec0d2524..b562292a0f30a 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -27,7 +27,6 @@ from pandas._config import config from pandas._libs import lib -from pandas._libs.internals import SetitemMixin from pandas._libs.lib import is_range_indexer from pandas._libs.tslibs import ( Period, @@ -223,7 +222,7 @@ } -class NDFrame(PandasObject, indexing.IndexingMixin, SetitemMixin): +class NDFrame(PandasObject, indexing.IndexingMixin): """ N-dimensional analogue of DataFrame. Store multi-dimensional in a size-mutable, labeled data structure @@ -253,11 +252,6 @@ class NDFrame(PandasObject, indexing.IndexingMixin, SetitemMixin): # ---------------------------------------------------------------------- # Constructors - # override those to avoid inheriting from SetitemMixin (cython generates - # them by default) - __new__ = object.__new__ - __reduce__ = object.__reduce__ - def __init__(self, data: Manager) -> None: object.__setattr__(self, "_mgr", data) object.__setattr__(self, "_attrs", {}) @@ -4263,8 +4257,9 @@ def _slice(self, slobj: slice, axis: AxisInt = 0) -> Self: result = result.__finalize__(self) return result + # __delitem__ is implemented in SetitemMixin and dispatches to this method @final - def __delitem__(self, key) -> None: + def _delitem(self, key) -> None: """ Delete item """ diff --git a/pandas/core/series.py b/pandas/core/series.py index b747009a989df..8ba6509723d90 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -32,6 +32,7 @@ properties, reshape, ) +from pandas._libs.internals import SetitemMixin from pandas._libs.lib import is_range_indexer from pandas.compat import PYPY from pandas.compat._constants import REF_COUNT @@ -233,7 +234,7 @@ # class "NDFrame") # definition in base class "NDFrame" @set_module("pandas") -class Series(base.IndexOpsMixin, NDFrame): # type: ignore[misc] +class Series(SetitemMixin, base.IndexOpsMixin, NDFrame): # type: ignore[misc] """ One-dimensional ndarray with axis labels (including time series). @@ -361,6 +362,11 @@ class Series(base.IndexOpsMixin, NDFrame): # type: ignore[misc] ) _mgr: SingleBlockManager + # override those to avoid inheriting from SetitemMixin (cython generates + # them by default) + __reduce__ = object.__reduce__ + __setstate__ = NDFrame.__setstate__ + # ---------------------------------------------------------------------- # Constructors From 29e1a9569b41ff0f258570a374b6fe01769d2a31 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 8 Aug 2025 16:59:47 +0200 Subject: [PATCH 04/14] add explicit tests for loc/iloc/at --- pandas/core/indexing.py | 15 ++++ .../test_chained_assignment_deprecation.py | 68 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/pandas/core/indexing.py b/pandas/core/indexing.py index 42dd8adbead09..ec25c5fa73429 100644 --- a/pandas/core/indexing.py +++ b/pandas/core/indexing.py @@ -2581,6 +2581,12 @@ def __getitem__(self, key): return super().__getitem__(key) def __setitem__(self, key, value) -> None: + if not PYPY: + if sys.getrefcount(self.obj) <= 2: + warnings.warn( + _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 + ) + if self.ndim == 2 and not self._axes_are_unique: # GH#33041 fall back to .loc if not isinstance(key, tuple) or not all(is_scalar(x) for x in key): @@ -2605,6 +2611,15 @@ def _convert_key(self, key): raise ValueError("iAt based indexing can only have integer indexers") return key + def __setitem__(self, key, value) -> None: + if not PYPY: + if sys.getrefcount(self.obj) <= 2: + warnings.warn( + _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 + ) + + return super().__setitem__(key, value) + def _tuplify(ndim: int, loc: Hashable) -> tuple[Hashable | slice, ...]: """ diff --git a/pandas/tests/copy_view/test_chained_assignment_deprecation.py b/pandas/tests/copy_view/test_chained_assignment_deprecation.py index 4aef69a6fde98..b116a99c08605 100644 --- a/pandas/tests/copy_view/test_chained_assignment_deprecation.py +++ b/pandas/tests/copy_view/test_chained_assignment_deprecation.py @@ -31,3 +31,71 @@ def test_frame_setitem(indexer): with tm.raises_chained_assignment_error(): df[0:3][indexer] = 10 + + +@pytest.mark.parametrize( + "indexer", [0, [0, 1], slice(0, 2), np.array([True, False, True])] +) +def test_series_iloc_setitem(indexer): + df = DataFrame({"a": [1, 2, 3], "b": 1}) + + with tm.raises_chained_assignment_error(): + df["a"].iloc[indexer] = 0 + + +@pytest.mark.parametrize( + "indexer", [0, [0, 1], slice(0, 2), np.array([True, False, True])] +) +def test_frame_iloc_setitem(indexer): + df = DataFrame({"a": [1, 2, 3, 4, 5], "b": 1}) + + with tm.raises_chained_assignment_error(): + df[0:3].iloc[indexer] = 10 + + +@pytest.mark.parametrize( + "indexer", [0, [0, 1], slice(0, 2), np.array([True, False, True])] +) +def test_series_loc_setitem(indexer): + df = DataFrame({"a": [1, 2, 3], "b": 1}) + + with tm.raises_chained_assignment_error(): + df["a"].loc[indexer] = 0 + + +@pytest.mark.parametrize( + "indexer", [0, [0, 1], (0, "a"), slice(0, 2), np.array([True, False, True])] +) +def test_frame_loc_setitem(indexer): + df = DataFrame({"a": [1, 2, 3, 4, 5], "b": 1}) + + with tm.raises_chained_assignment_error(): + df[0:3].loc[indexer] = 10 + + +def test_series_at_setitem(): + df = DataFrame({"a": [1, 2, 3], "b": 1}) + + with tm.raises_chained_assignment_error(): + df["a"].at[0] = 0 + + +def test_frame_at_setitem(): + df = DataFrame({"a": [1, 2, 3, 4, 5], "b": 1}) + + with tm.raises_chained_assignment_error(): + df[0:3].at[0, "a"] = 10 + + +def test_series_iat_setitem(): + df = DataFrame({"a": [1, 2, 3], "b": 1}) + + with tm.raises_chained_assignment_error(): + df["a"].iat[0] = 0 + + +def test_frame_iat_setitem(): + df = DataFrame({"a": [1, 2, 3, 4, 5], "b": 1}) + + with tm.raises_chained_assignment_error(): + df[0:3].iat[0, 0] = 10 From 9646c02148b985a2030ec68c4e3a99de1e1b363b Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 8 Aug 2025 17:23:10 +0200 Subject: [PATCH 05/14] remove warning filter for spss --- pandas/tests/io/test_spss.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/pandas/tests/io/test_spss.py b/pandas/tests/io/test_spss.py index 973cb21ac3041..6418bfb1691c6 100644 --- a/pandas/tests/io/test_spss.py +++ b/pandas/tests/io/test_spss.py @@ -11,10 +11,6 @@ pyreadstat = pytest.importorskip("pyreadstat") -# TODO(CoW) - detection of chained assignment in cython -# https://github.com/pandas-dev/pandas/issues/51315 -@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") -@pytest.mark.filterwarnings("ignore:ChainedAssignmentError:FutureWarning") @pytest.mark.parametrize("path_klass", [lambda p: p, Path]) def test_spss_labelled_num(path_klass, datapath): # test file from the Haven project (https://haven.tidyverse.org/) @@ -31,8 +27,6 @@ def test_spss_labelled_num(path_klass, datapath): tm.assert_frame_equal(df, expected) -@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") -@pytest.mark.filterwarnings("ignore:ChainedAssignmentError:FutureWarning") def test_spss_labelled_num_na(datapath): # test file from the Haven project (https://haven.tidyverse.org/) # Licence at LICENSES/HAVEN_LICENSE, LICENSES/HAVEN_MIT @@ -48,8 +42,6 @@ def test_spss_labelled_num_na(datapath): tm.assert_frame_equal(df, expected) -@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") -@pytest.mark.filterwarnings("ignore:ChainedAssignmentError:FutureWarning") def test_spss_labelled_str(datapath): # test file from the Haven project (https://haven.tidyverse.org/) # Licence at LICENSES/HAVEN_LICENSE, LICENSES/HAVEN_MIT @@ -65,8 +57,6 @@ def test_spss_labelled_str(datapath): tm.assert_frame_equal(df, expected) -@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") -@pytest.mark.filterwarnings("ignore:ChainedAssignmentError:FutureWarning") def test_spss_kwargs(datapath): # test file from the Haven project (https://haven.tidyverse.org/) # Licence at LICENSES/HAVEN_LICENSE, LICENSES/HAVEN_MIT @@ -81,8 +71,6 @@ def test_spss_kwargs(datapath): tm.assert_frame_equal(df, expected) -@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") -@pytest.mark.filterwarnings("ignore:ChainedAssignmentError:FutureWarning") def test_spss_umlauts(datapath): # test file from the Haven project (https://haven.tidyverse.org/) # Licence at LICENSES/HAVEN_LICENSE, LICENSES/HAVEN_MIT @@ -140,8 +128,6 @@ def test_invalid_dtype_backend(): pd.read_spss("test", dtype_backend="numpy") -@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") -@pytest.mark.filterwarnings("ignore:ChainedAssignmentError:FutureWarning") def test_spss_metadata(datapath): # GH 54264 fname = datapath("io", "data", "spss", "labelled-num.sav") From efea93ff9ab8520f030a9530afd38e712b058874 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 15 Aug 2025 18:44:01 +0200 Subject: [PATCH 06/14] cleanup --- pandas/_libs/internals.pyx | 36 ++++++++++-------------------------- pandas/_libs/tslib.pyx | 1 - pandas/core/frame.py | 7 +------ pandas/core/generic.py | 2 +- pandas/core/series.py | 7 +------ 5 files changed, 13 insertions(+), 40 deletions(-) diff --git a/pandas/_libs/internals.pyx b/pandas/_libs/internals.pyx index 9fe13b2e7b7c6..70cf20a6e0fd6 100644 --- a/pandas/_libs/internals.pyx +++ b/pandas/_libs/internals.pyx @@ -24,6 +24,8 @@ cnp.import_array() from pandas._libs.algos import ensure_int64 from pandas.errors import ChainedAssignmentError +from pandas.errors.cow import _chained_assignment_msg + from pandas._libs.util cimport ( is_array, @@ -1005,10 +1007,7 @@ cdef class BlockValuesRefs: cdef extern from "Python.h": """ #if PY_VERSION_HEX < 0x030E0000 - int __Pyx_PyUnstable_Object_IsUniqueReferencedTemporary(PyObject *ref) - { - return 0; - } + int __Pyx_PyUnstable_Object_IsUniqueReferencedTemporary(PyObject *ref); #else #define __Pyx_PyUnstable_Object_IsUniqueReferencedTemporary \ PyUnstable_Object_IsUniqueReferencedTemporary @@ -1018,40 +1017,25 @@ cdef extern from "Python.h": "__Pyx_PyUnstable_Object_IsUniqueReferencedTemporary"(object o) except -1 +# Python version compatibility for PyUnstable_Object_IsUniqueReferencedTemporary cdef inline bint _is_unique_referenced_temporary(object obj) except -1: if PY_VERSION_HEX >= 0x030E0000: + # Python 3.14+ has PyUnstable_Object_IsUniqueReferencedTemporary return PyUnstable_Object_IsUniqueReferencedTemporary(obj) else: + # Fallback for older Python versions using sys.getrefcount return sys.getrefcount(obj) <= 1 -# # Python version compatibility for PyUnstable_Object_IsUniqueReferencedTemporary -# IF PY_VERSION_HEX >= 0x030E0000: -# # Python 3.14+ has PyUnstable_Object_IsUniqueReferencedTemporary -# cdef inline bint _is_unique_referenced_temporary(object obj) except -1: -# return PyUnstable_Object_IsUniqueReferencedTemporary(obj) -# ELSE: -# # Fallback for older Python versions using sys.getrefcount -# cdef inline bint _is_unique_referenced_temporary(object obj) except -1: -# # sys.getrefcount includes the reference from getrefcount itself -# # So if refcount is 2, it means only one external reference exists -# return sys.getrefcount(obj) == 2 - - -# @cython.auto_pickle(False) cdef class SetitemMixin: + # class used in DataFrame and Series for checking for chained assignment - def __setitem__(self, key, value): + def __setitem__(self, key, value) -> None: cdef bint is_unique = _is_unique_referenced_temporary(self) - # print("Refcount self: ", sys.getrefcount(self)) - # print("Is unique referenced temporary: ", is_unique) if is_unique: warnings.warn( - "A value is trying to be set on a copy of a DataFrame or Series " - "through chained assignment.", - ChainedAssignmentError, - stacklevel=1, - ) + _chained_assignment_msg, ChainedAssignmentError, stacklevel=1 + ) self._setitem(key, value) def __delitem__(self, key) -> None: diff --git a/pandas/_libs/tslib.pyx b/pandas/_libs/tslib.pyx index ba4c432ccd266..3c5854602df53 100644 --- a/pandas/_libs/tslib.pyx +++ b/pandas/_libs/tslib.pyx @@ -267,7 +267,6 @@ cpdef array_to_datetime( str unit_for_numerics=None, ): """ - TODO no longer up to date Converts a 1D array of date-like values to a numpy array of either: 1) datetime64[ns] data 2) datetime.datetime objects, if OutOfBoundsDatetime or TypeError diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 100824f8967b2..f2f50039724c6 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -4218,6 +4218,7 @@ def isetitem(self, loc, value) -> None: arraylike, refs = self._sanitize_column(value) self._iset_item_mgr(loc, arraylike, inplace=False, refs=refs) + # def __setitem__() is implemented in SetitemMixin and dispatches to this method def _setitem(self, key, value) -> None: """ Set item(s) in DataFrame by key. @@ -4302,12 +4303,6 @@ def _setitem(self, key, value) -> None: z 3 50 # Values for 'a' and 'b' are completely ignored! """ - # if not PYPY: - # if sys.getrefcount(self) <= 3: - # warnings.warn( - # _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 - # ) - key = com.apply_if_callable(key, self) # see if we can slice the rows diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 4fdd671ecbb37..0078d7803819e 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -4258,7 +4258,7 @@ def _slice(self, slobj: slice, axis: AxisInt = 0) -> Self: result = result.__finalize__(self) return result - # __delitem__ is implemented in SetitemMixin and dispatches to this method + # def __delitem__() is implemented in SetitemMixin and dispatches to this method @final def _delitem(self, key) -> None: """ diff --git a/pandas/core/series.py b/pandas/core/series.py index e0dec293a5e89..e726a0569ccbc 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -1064,13 +1064,8 @@ def _get_value(self, label, takeable: bool = False): else: return self.iloc[loc] + # def __setitem__() is implemented in SetitemMixin and dispatches to this method def _setitem(self, key, value) -> None: - # if not PYPY: - # if sys.getrefcount(self) <= 3: - # warnings.warn( - # _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 - # ) - check_dict_or_set_indexers(key) key = com.apply_if_callable(key, self) From 7d506cb6558a348025b11962e1253b4eae5a9543 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 5 Sep 2025 17:41:28 +0200 Subject: [PATCH 07/14] add back filterwarnings for spss cython issue --- pandas/tests/io/test_spss.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pandas/tests/io/test_spss.py b/pandas/tests/io/test_spss.py index 6418bfb1691c6..7a25ab9994698 100644 --- a/pandas/tests/io/test_spss.py +++ b/pandas/tests/io/test_spss.py @@ -11,6 +11,9 @@ pyreadstat = pytest.importorskip("pyreadstat") +# TODO(CoW) - detection of chained assignment in cython +# https://github.com/pandas-dev/pandas/issues/51315 +@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") @pytest.mark.parametrize("path_klass", [lambda p: p, Path]) def test_spss_labelled_num(path_klass, datapath): # test file from the Haven project (https://haven.tidyverse.org/) @@ -27,6 +30,7 @@ def test_spss_labelled_num(path_klass, datapath): tm.assert_frame_equal(df, expected) +@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") def test_spss_labelled_num_na(datapath): # test file from the Haven project (https://haven.tidyverse.org/) # Licence at LICENSES/HAVEN_LICENSE, LICENSES/HAVEN_MIT @@ -42,6 +46,7 @@ def test_spss_labelled_num_na(datapath): tm.assert_frame_equal(df, expected) +@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") def test_spss_labelled_str(datapath): # test file from the Haven project (https://haven.tidyverse.org/) # Licence at LICENSES/HAVEN_LICENSE, LICENSES/HAVEN_MIT @@ -57,6 +62,7 @@ def test_spss_labelled_str(datapath): tm.assert_frame_equal(df, expected) +@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") def test_spss_kwargs(datapath): # test file from the Haven project (https://haven.tidyverse.org/) # Licence at LICENSES/HAVEN_LICENSE, LICENSES/HAVEN_MIT @@ -71,6 +77,7 @@ def test_spss_kwargs(datapath): tm.assert_frame_equal(df, expected) +@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") def test_spss_umlauts(datapath): # test file from the Haven project (https://haven.tidyverse.org/) # Licence at LICENSES/HAVEN_LICENSE, LICENSES/HAVEN_MIT @@ -128,6 +135,7 @@ def test_invalid_dtype_backend(): pd.read_spss("test", dtype_backend="numpy") +@pytest.mark.filterwarnings("ignore::pandas.errors.ChainedAssignmentError") def test_spss_metadata(datapath): # GH 54264 fname = datapath("io", "data", "spss", "labelled-num.sav") From 022de2130e7027dcda9575796aaed2985b22f452 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 5 Sep 2025 17:58:31 +0200 Subject: [PATCH 08/14] update internals.pyi --- pandas/_libs/internals.pyi | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pandas/_libs/internals.pyi b/pandas/_libs/internals.pyi index 11d059ec53920..1064995a51797 100644 --- a/pandas/_libs/internals.pyi +++ b/pandas/_libs/internals.pyi @@ -94,3 +94,7 @@ class BlockValuesRefs: def add_reference(self, blk: Block) -> None: ... def add_index_reference(self, index: Index) -> None: ... def has_reference(self) -> bool: ... + +class SetitemMixin: + def __setitem__(self, key, value) -> None: ... + def __delitem__(self, key) -> None: ... From b875ad25b10810f0cc27040994add6c8b325de4d Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 5 Sep 2025 19:30:04 +0200 Subject: [PATCH 09/14] typing: __setstate__ no longer final --- pandas/core/generic.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pandas/core/generic.py b/pandas/core/generic.py index a7c657b64d74c..eb925e7861eae 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -2062,7 +2062,6 @@ def __getstate__(self) -> dict[str, Any]: **meta, } - @final def __setstate__(self, state) -> None: if isinstance(state, BlockManager): self._mgr = state From 923dc27d019fc7695572da1e8a913db5136ed2f3 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Thu, 9 Oct 2025 09:16:33 +0200 Subject: [PATCH 10/14] keep pypy check in cython version --- pandas/_libs/internals.pyx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pandas/_libs/internals.pyx b/pandas/_libs/internals.pyx index 70cf20a6e0fd6..d126c7ab04be9 100644 --- a/pandas/_libs/internals.pyx +++ b/pandas/_libs/internals.pyx @@ -23,10 +23,10 @@ from numpy cimport ( cnp.import_array() from pandas._libs.algos import ensure_int64 +from pandas.compat import PYPY from pandas.errors import ChainedAssignmentError from pandas.errors.cow import _chained_assignment_msg - from pandas._libs.util cimport ( is_array, is_integer_object, @@ -1031,11 +1031,13 @@ cdef class SetitemMixin: # class used in DataFrame and Series for checking for chained assignment def __setitem__(self, key, value) -> None: - cdef bint is_unique = _is_unique_referenced_temporary(self) - if is_unique: - warnings.warn( - _chained_assignment_msg, ChainedAssignmentError, stacklevel=1 - ) + cdef bint is_unique = 0 + if not PYPY: + _is_unique_referenced_temporary(self) + if is_unique: + warnings.warn( + _chained_assignment_msg, ChainedAssignmentError, stacklevel=1 + ) self._setitem(key, value) def __delitem__(self, key) -> None: From 798fcdcd3cb23bbc6701a8bcdf4e5753808347a8 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Tue, 21 Oct 2025 09:27:17 +0200 Subject: [PATCH 11/14] fixup --- pandas/_libs/internals.pyx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/_libs/internals.pyx b/pandas/_libs/internals.pyx index d126c7ab04be9..2c89ab6e86419 100644 --- a/pandas/_libs/internals.pyx +++ b/pandas/_libs/internals.pyx @@ -1033,7 +1033,7 @@ cdef class SetitemMixin: def __setitem__(self, key, value) -> None: cdef bint is_unique = 0 if not PYPY: - _is_unique_referenced_temporary(self) + is_unique = _is_unique_referenced_temporary(self) if is_unique: warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=1 From de2e68b74eea90fd7ef77d1f77667d75068fe187 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Tue, 21 Oct 2025 10:52:02 +0200 Subject: [PATCH 12/14] differentiate between generic warning and inplace methods -> Py314 still skips check for inplace methods --- pandas/_testing/contexts.py | 16 ++++++++++++---- pandas/compat/__init__.py | 6 ++++-- pandas/compat/_constants.py | 3 ++- pandas/core/frame.py | 5 ++--- pandas/core/generic.py | 19 +++++++++---------- pandas/core/indexing.py | 9 ++++----- pandas/core/series.py | 5 ++--- .../test_chained_assignment_deprecation.py | 4 ++-- pandas/tests/copy_view/test_clip.py | 4 ++-- pandas/tests/copy_view/test_interp_fillna.py | 8 ++++---- pandas/tests/copy_view/test_methods.py | 8 ++++---- pandas/tests/copy_view/test_replace.py | 4 ++-- pandas/tests/frame/methods/test_fillna.py | 2 +- .../tests/frame/methods/test_interpolate.py | 2 +- pandas/tests/frame/test_block_internals.py | 2 +- .../multiindex/test_chaining_and_caching.py | 2 +- pandas/tests/series/indexing/test_indexing.py | 2 +- pandas/tests/series/methods/test_update.py | 2 +- 18 files changed, 55 insertions(+), 48 deletions(-) diff --git a/pandas/_testing/contexts.py b/pandas/_testing/contexts.py index ed30b2022db10..8bbd20c742c9c 100644 --- a/pandas/_testing/contexts.py +++ b/pandas/_testing/contexts.py @@ -13,8 +13,8 @@ import uuid from pandas.compat import ( - PYPY, - WARNING_CHECK_DISABLED, + CHAINED_WARNING_DISABLED, + CHAINED_WARNING_DISABLED_INPLACE_METHOD, ) from pandas.errors import ChainedAssignmentError @@ -163,10 +163,18 @@ def with_csv_dialect(name: str, **kwargs) -> Generator[None]: csv.unregister_dialect(name) -def raises_chained_assignment_error(extra_warnings=(), extra_match=()): +def raises_chained_assignment_error( + extra_warnings=(), extra_match=(), inplace_method=False +): from pandas._testing import assert_produces_warning - if PYPY or WARNING_CHECK_DISABLED: + WARNING_DISABLED = ( + CHAINED_WARNING_DISABLED_INPLACE_METHOD + if inplace_method + else CHAINED_WARNING_DISABLED + ) + + if WARNING_DISABLED: if not extra_warnings: from contextlib import nullcontext diff --git a/pandas/compat/__init__.py b/pandas/compat/__init__.py index f38abafd2db78..72d9c2555d16e 100644 --- a/pandas/compat/__init__.py +++ b/pandas/compat/__init__.py @@ -16,12 +16,13 @@ from typing import TYPE_CHECKING from pandas.compat._constants import ( + CHAINED_WARNING_DISABLED, + CHAINED_WARNING_DISABLED_INPLACE_METHOD, IS64, ISMUSL, PY312, PY314, PYPY, - WARNING_CHECK_DISABLED, WASM, ) from pandas.compat.numpy import is_numpy_dev @@ -152,6 +153,8 @@ def is_ci_environment() -> bool: __all__ = [ + "CHAINED_WARNING_DISABLED", + "CHAINED_WARNING_DISABLED_INPLACE_METHOD", "HAS_PYARROW", "IS64", "ISMUSL", @@ -159,7 +162,6 @@ def is_ci_environment() -> bool: "PY314", "PYARROW_MIN_VERSION", "PYPY", - "WARNING_CHECK_DISABLED", "WASM", "is_numpy_dev", "pa_version_under14p0", diff --git a/pandas/compat/_constants.py b/pandas/compat/_constants.py index 674afc5c62009..9bf004725dc08 100644 --- a/pandas/compat/_constants.py +++ b/pandas/compat/_constants.py @@ -19,7 +19,8 @@ WASM = (sys.platform == "emscripten") or (platform.machine() in ["wasm32", "wasm64"]) ISMUSL = "musl" in (sysconfig.get_config_var("HOST_GNU_TYPE") or "") REF_COUNT = 2 -WARNING_CHECK_DISABLED = PY314 +CHAINED_WARNING_DISABLED = PYPY +CHAINED_WARNING_DISABLED_INPLACE_METHOD = PYPY or PY314 __all__ = [ diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 751f161795367..c70828070a6aa 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -50,10 +50,9 @@ from pandas._libs.hashtable import duplicated from pandas._libs.internals import SetitemMixin from pandas._libs.lib import is_range_indexer -from pandas.compat import PYPY from pandas.compat._constants import ( + CHAINED_WARNING_DISABLED_INPLACE_METHOD, REF_COUNT, - WARNING_CHECK_DISABLED, ) from pandas.compat._optional import import_optional_dependency from pandas.compat.numpy import function as nv @@ -9314,7 +9313,7 @@ def update( 1 2 500.0 2 3 6.0 """ - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, diff --git a/pandas/core/generic.py b/pandas/core/generic.py index fffb7a7a67271..29d5a172ff5c5 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -82,10 +82,9 @@ WriteExcelBuffer, npt, ) -from pandas.compat import PYPY from pandas.compat._constants import ( + CHAINED_WARNING_DISABLED_INPLACE_METHOD, REF_COUNT, - WARNING_CHECK_DISABLED, ) from pandas.compat._optional import import_optional_dependency from pandas.compat.numpy import function as nv @@ -7090,7 +7089,7 @@ def fillna( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -7337,7 +7336,7 @@ def ffill( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -7477,7 +7476,7 @@ def bfill( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -7562,7 +7561,7 @@ def replace( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -7925,7 +7924,7 @@ def interpolate( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -8509,7 +8508,7 @@ def clip( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -10152,7 +10151,7 @@ def where( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -10216,7 +10215,7 @@ def mask( ) -> Self | None: inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, diff --git a/pandas/core/indexing.py b/pandas/core/indexing.py index 3f9749f1f7a99..27164cc3697bb 100644 --- a/pandas/core/indexing.py +++ b/pandas/core/indexing.py @@ -15,10 +15,9 @@ from pandas._libs.indexing import NDFrameIndexerBase from pandas._libs.lib import item_from_zerodim -from pandas.compat import PYPY from pandas.compat._constants import ( + CHAINED_WARNING_DISABLED, REF_COUNT, - WARNING_CHECK_DISABLED, ) from pandas.errors import ( AbstractMethodError, @@ -920,7 +919,7 @@ def _ensure_listlike_indexer(self, key, axis=None, value=None) -> None: @final def __setitem__(self, key, value) -> None: - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount(self.obj) <= REF_COUNT: warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 @@ -2588,7 +2587,7 @@ def __getitem__(self, key): return super().__getitem__(key) def __setitem__(self, key, value) -> None: - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount(self.obj) <= REF_COUNT: warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 @@ -2619,7 +2618,7 @@ def _convert_key(self, key): return key def __setitem__(self, key, value) -> None: - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount(self.obj) <= REF_COUNT: warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 diff --git a/pandas/core/series.py b/pandas/core/series.py index 2f006f306de21..122606465b6d9 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -35,10 +35,9 @@ ) from pandas._libs.internals import SetitemMixin from pandas._libs.lib import is_range_indexer -from pandas.compat import PYPY from pandas.compat._constants import ( + CHAINED_WARNING_DISABLED_INPLACE_METHOD, REF_COUNT, - WARNING_CHECK_DISABLED, ) from pandas.compat._optional import import_optional_dependency from pandas.compat.numpy import function as nv @@ -3327,7 +3326,7 @@ def update(self, other: Series | Sequence | Mapping) -> None: 2 3 dtype: int64 """ - if not PYPY and not WARNING_CHECK_DISABLED: + if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, diff --git a/pandas/tests/copy_view/test_chained_assignment_deprecation.py b/pandas/tests/copy_view/test_chained_assignment_deprecation.py index f6de6af994b93..d8a75fcd380c4 100644 --- a/pandas/tests/copy_view/test_chained_assignment_deprecation.py +++ b/pandas/tests/copy_view/test_chained_assignment_deprecation.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from pandas.compat import WARNING_CHECK_DISABLED +from pandas.compat import CHAINED_WARNING_DISABLED from pandas.errors import ChainedAssignmentError from pandas import DataFrame @@ -18,7 +18,7 @@ def test_series_setitem(indexer): # using custom check instead of tm.assert_produces_warning because that doesn't # fail if multiple warnings are raised - if WARNING_CHECK_DISABLED: + if CHAINED_WARNING_DISABLED: return with pytest.warns() as record: # noqa: TID251 df["a"][indexer] = 0 diff --git a/pandas/tests/copy_view/test_clip.py b/pandas/tests/copy_view/test_clip.py index 56df33db6d416..dcc232f334a92 100644 --- a/pandas/tests/copy_view/test_clip.py +++ b/pandas/tests/copy_view/test_clip.py @@ -63,10 +63,10 @@ def test_clip_no_op(): def test_clip_chained_inplace(): df = DataFrame({"a": [1, 4, 2], "b": 1}) df_orig = df.copy() - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df["a"].clip(1, 2, inplace=True) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df[["a"]].clip(1, 2, inplace=True) tm.assert_frame_equal(df, df_orig) diff --git a/pandas/tests/copy_view/test_interp_fillna.py b/pandas/tests/copy_view/test_interp_fillna.py index 6bcda0ef2c35a..3d2d5448e0eb2 100644 --- a/pandas/tests/copy_view/test_interp_fillna.py +++ b/pandas/tests/copy_view/test_interp_fillna.py @@ -278,11 +278,11 @@ def test_fillna_inplace_ea_noop_shares_memory(any_numeric_ea_and_arrow_dtype): def test_fillna_chained_assignment(): df = DataFrame({"a": [1, np.nan, 2], "b": 1}) df_orig = df.copy() - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df["a"].fillna(100, inplace=True) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df[["a"]].fillna(100, inplace=True) tm.assert_frame_equal(df, df_orig) @@ -291,10 +291,10 @@ def test_fillna_chained_assignment(): def test_interpolate_chained_assignment(func): df = DataFrame({"a": [1, np.nan, 2], "b": 1}) df_orig = df.copy() - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): getattr(df["a"], func)(inplace=True) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): getattr(df[["a"]], func)(inplace=True) tm.assert_frame_equal(df, df_orig) diff --git a/pandas/tests/copy_view/test_methods.py b/pandas/tests/copy_view/test_methods.py index 8abecd13c7038..0f0e89acee1cb 100644 --- a/pandas/tests/copy_view/test_methods.py +++ b/pandas/tests/copy_view/test_methods.py @@ -1205,11 +1205,11 @@ def test_where_mask_noop_on_single_column(dtype, val, func): def test_chained_where_mask(func): df = DataFrame({"a": [1, 4, 2], "b": 1}) df_orig = df.copy() - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): getattr(df["a"], func)(df["a"] > 2, 5, inplace=True) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): getattr(df[["a"]], func)(df["a"] > 2, 5, inplace=True) tm.assert_frame_equal(df, df_orig) @@ -1391,11 +1391,11 @@ def test_update_chained_assignment(): df = DataFrame({"a": [1, 2, 3]}) ser2 = Series([100.0], index=[1]) df_orig = df.copy() - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df["a"].update(ser2) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df[["a"]].update(ser2.to_frame()) tm.assert_frame_equal(df, df_orig) diff --git a/pandas/tests/copy_view/test_replace.py b/pandas/tests/copy_view/test_replace.py index d4838a5e68ab8..bbdd759128e46 100644 --- a/pandas/tests/copy_view/test_replace.py +++ b/pandas/tests/copy_view/test_replace.py @@ -319,11 +319,11 @@ def test_replace_columnwise_no_op(): def test_replace_chained_assignment(): df = DataFrame({"a": [1, np.nan, 2], "b": 1}) df_orig = df.copy() - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df["a"].replace(1, 100, inplace=True) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df[["a"]].replace(1, 100, inplace=True) tm.assert_frame_equal(df, df_orig) diff --git a/pandas/tests/frame/methods/test_fillna.py b/pandas/tests/frame/methods/test_fillna.py index e4e6975ecd9af..d229fe5aaaa84 100644 --- a/pandas/tests/frame/methods/test_fillna.py +++ b/pandas/tests/frame/methods/test_fillna.py @@ -42,7 +42,7 @@ def test_fillna_on_column_view(self): arr = np.full((40, 50), np.nan) df = DataFrame(arr, copy=False) - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df[0].fillna(-1, inplace=True) assert np.isnan(arr[:, 0]).all() diff --git a/pandas/tests/frame/methods/test_interpolate.py b/pandas/tests/frame/methods/test_interpolate.py index 25d4019fda9f8..f512ed3e4a0af 100644 --- a/pandas/tests/frame/methods/test_interpolate.py +++ b/pandas/tests/frame/methods/test_interpolate.py @@ -310,7 +310,7 @@ def test_interp_inplace(self): expected = df.copy() result = df.copy() - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): return_value = result["a"].interpolate(inplace=True) assert return_value is None tm.assert_frame_equal(result, expected) diff --git a/pandas/tests/frame/test_block_internals.py b/pandas/tests/frame/test_block_internals.py index f9056a5487da6..c525a3c6494c5 100644 --- a/pandas/tests/frame/test_block_internals.py +++ b/pandas/tests/frame/test_block_internals.py @@ -378,7 +378,7 @@ def test_update_inplace_sets_valid_block_values(): df = DataFrame({"a": Series([1, 2, None], dtype="category")}) # inplace update of a single column - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df["a"].fillna(1, inplace=True) # check we haven't put a Series into any block.values diff --git a/pandas/tests/indexing/multiindex/test_chaining_and_caching.py b/pandas/tests/indexing/multiindex/test_chaining_and_caching.py index c7ed21a2cc001..7c4fbc21e7f63 100644 --- a/pandas/tests/indexing/multiindex/test_chaining_and_caching.py +++ b/pandas/tests/indexing/multiindex/test_chaining_and_caching.py @@ -28,7 +28,7 @@ def test_detect_chained_assignment(): multiind = MultiIndex.from_tuples(tuples, names=["part", "side"]) zed = DataFrame(events, index=["a", "b"], columns=multiind) - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): zed["eyes"]["right"].fillna(value=555, inplace=True) diff --git a/pandas/tests/series/indexing/test_indexing.py b/pandas/tests/series/indexing/test_indexing.py index f2a604dcc0787..0dc5b8824958e 100644 --- a/pandas/tests/series/indexing/test_indexing.py +++ b/pandas/tests/series/indexing/test_indexing.py @@ -277,7 +277,7 @@ def test_underlying_data_conversion(): df_original = df.copy() df - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df["val"].update(s) expected = df_original tm.assert_frame_equal(df, expected) diff --git a/pandas/tests/series/methods/test_update.py b/pandas/tests/series/methods/test_update.py index 9b5fb098bf3ee..9debf67af4622 100644 --- a/pandas/tests/series/methods/test_update.py +++ b/pandas/tests/series/methods/test_update.py @@ -29,7 +29,7 @@ def test_update(self): df["c"] = df["c"].astype(object) df_orig = df.copy() - with tm.raises_chained_assignment_error(): + with tm.raises_chained_assignment_error(inplace_method=True): df["c"].update(Series(["foo"], index=[0])) expected = df_orig tm.assert_frame_equal(df, expected) From d8214ea44f141d727c3b32389d53c44dacffdd4d Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Tue, 21 Oct 2025 14:08:49 +0200 Subject: [PATCH 13/14] disable warning check on py314t --- pandas/_libs/internals.pyx | 4 ++-- pandas/compat/_constants.py | 2 +- scripts/validate_unwanted_patterns.py | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pandas/_libs/internals.pyx b/pandas/_libs/internals.pyx index 2c89ab6e86419..43b60b2356b5e 100644 --- a/pandas/_libs/internals.pyx +++ b/pandas/_libs/internals.pyx @@ -23,7 +23,7 @@ from numpy cimport ( cnp.import_array() from pandas._libs.algos import ensure_int64 -from pandas.compat import PYPY +from pandas.compat import CHAINED_WARNING_DISABLED from pandas.errors import ChainedAssignmentError from pandas.errors.cow import _chained_assignment_msg @@ -1032,7 +1032,7 @@ cdef class SetitemMixin: def __setitem__(self, key, value) -> None: cdef bint is_unique = 0 - if not PYPY: + if not CHAINED_WARNING_DISABLED: is_unique = _is_unique_referenced_temporary(self) if is_unique: warnings.warn( diff --git a/pandas/compat/_constants.py b/pandas/compat/_constants.py index 9bf004725dc08..1863b3407ebd3 100644 --- a/pandas/compat/_constants.py +++ b/pandas/compat/_constants.py @@ -19,7 +19,7 @@ WASM = (sys.platform == "emscripten") or (platform.machine() in ["wasm32", "wasm64"]) ISMUSL = "musl" in (sysconfig.get_config_var("HOST_GNU_TYPE") or "") REF_COUNT = 2 -CHAINED_WARNING_DISABLED = PYPY +CHAINED_WARNING_DISABLED = PYPY or (PY314 and not sys._is_gil_enabled()) CHAINED_WARNING_DISABLED_INPLACE_METHOD = PYPY or PY314 diff --git a/scripts/validate_unwanted_patterns.py b/scripts/validate_unwanted_patterns.py index 04b4fde6a7c0e..80db1fc5efa6b 100755 --- a/scripts/validate_unwanted_patterns.py +++ b/scripts/validate_unwanted_patterns.py @@ -95,7 +95,10 @@ def _get_literal_string_prefix_len(token_string: str) -> int: return 0 -PRIVATE_FUNCTIONS_ALLOWED = {"sys._getframe"} # no known alternative +PRIVATE_FUNCTIONS_ALLOWED = { + "sys._getframe", + "sys._is_gil_enabled", +} # no known alternative def private_function_across_module(file_obj: IO[str]) -> Iterable[tuple[int, str]]: From 818c8d475b4d9a6d5aeac6ff2994522136657198 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Tue, 21 Oct 2025 14:29:22 +0200 Subject: [PATCH 14/14] suppress mypy about _is_gil_enabled --- pandas/compat/_constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/compat/_constants.py b/pandas/compat/_constants.py index 1863b3407ebd3..102f6fef6e4e1 100644 --- a/pandas/compat/_constants.py +++ b/pandas/compat/_constants.py @@ -19,7 +19,7 @@ WASM = (sys.platform == "emscripten") or (platform.machine() in ["wasm32", "wasm64"]) ISMUSL = "musl" in (sysconfig.get_config_var("HOST_GNU_TYPE") or "") REF_COUNT = 2 -CHAINED_WARNING_DISABLED = PYPY or (PY314 and not sys._is_gil_enabled()) +CHAINED_WARNING_DISABLED = PYPY or (PY314 and not sys._is_gil_enabled()) # type: ignore[attr-defined] CHAINED_WARNING_DISABLED_INPLACE_METHOD = PYPY or PY314