From 32b14c7ce707734a74e3cdc5a159eda6bc3aa8c1 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 24 Nov 2025 09:44:17 +0100 Subject: [PATCH 1/8] Revert "Move chained assignment detection to cython for Python 3.14 compat (#62070)" This reverts commit a505423613f473e058e1b30b5816cd3f99731e4d. --- pandas/_libs/internals.pyi | 4 -- pandas/_libs/internals.pyx | 50 ------------------- pandas/_testing/contexts.py | 16 ++---- pandas/compat/__init__.py | 6 +-- pandas/compat/_constants.py | 3 +- pandas/compat/pickle_compat.py | 5 -- pandas/core/frame.py | 23 +++++---- pandas/core/generic.py | 23 +++++---- pandas/core/indexing.py | 9 ++-- pandas/core/series.py | 23 +++++---- .../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/io/test_spss.py | 6 +++ pandas/tests/series/indexing/test_indexing.py | 2 +- pandas/tests/series/methods/test_update.py | 2 +- scripts/validate_unwanted_patterns.py | 5 +- 23 files changed, 75 insertions(+), 138 deletions(-) diff --git a/pandas/_libs/internals.pyi b/pandas/_libs/internals.pyi index 1064995a51797..11d059ec53920 100644 --- a/pandas/_libs/internals.pyi +++ b/pandas/_libs/internals.pyi @@ -94,7 +94,3 @@ 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: ... diff --git a/pandas/_libs/internals.pyx b/pandas/_libs/internals.pyx index c651743895995..4fb24c9ad1538 100644 --- a/pandas/_libs/internals.pyx +++ b/pandas/_libs/internals.pyx @@ -1,9 +1,6 @@ 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 @@ -23,9 +20,6 @@ from numpy cimport ( cnp.import_array() from pandas._libs.algos import ensure_int64 -from pandas.compat import CHAINED_WARNING_DISABLED -from pandas.errors import ChainedAssignmentError -from pandas.errors.cow import _chained_assignment_msg from pandas._libs.util cimport ( is_array, @@ -1002,47 +996,3 @@ cdef class BlockValuesRefs: return self._has_reference_maybe_locked() ELSE: return self._has_reference_maybe_locked() - - -cdef extern from "Python.h": - """ - // python version < 3.14 - #if PY_VERSION_HEX < 0x030E0000 - // This function is unused and is declared to avoid a build warning - int __Pyx_PyUnstable_Object_IsUniqueReferencedTemporary(PyObject *ref) { - return Py_REFCNT(ref) == 1; - } - #else - #define __Pyx_PyUnstable_Object_IsUniqueReferencedTemporary \ - PyUnstable_Object_IsUniqueReferencedTemporary - #endif - """ - int PyUnstable_Object_IsUniqueReferencedTemporary\ - "__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 - - -cdef class SetitemMixin: - # class used in DataFrame and Series for checking for chained assignment - - def __setitem__(self, key, value) -> None: - cdef bint is_unique = 0 - if not CHAINED_WARNING_DISABLED: - is_unique = _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: - self._delitem(key) diff --git a/pandas/_testing/contexts.py b/pandas/_testing/contexts.py index 8bbd20c742c9c..ed30b2022db10 100644 --- a/pandas/_testing/contexts.py +++ b/pandas/_testing/contexts.py @@ -13,8 +13,8 @@ import uuid from pandas.compat import ( - CHAINED_WARNING_DISABLED, - CHAINED_WARNING_DISABLED_INPLACE_METHOD, + PYPY, + WARNING_CHECK_DISABLED, ) from pandas.errors import ChainedAssignmentError @@ -163,18 +163,10 @@ def with_csv_dialect(name: str, **kwargs) -> Generator[None]: csv.unregister_dialect(name) -def raises_chained_assignment_error( - extra_warnings=(), extra_match=(), inplace_method=False -): +def raises_chained_assignment_error(extra_warnings=(), extra_match=()): from pandas._testing import assert_produces_warning - WARNING_DISABLED = ( - CHAINED_WARNING_DISABLED_INPLACE_METHOD - if inplace_method - else CHAINED_WARNING_DISABLED - ) - - if WARNING_DISABLED: + if PYPY or WARNING_CHECK_DISABLED: if not extra_warnings: from contextlib import nullcontext diff --git a/pandas/compat/__init__.py b/pandas/compat/__init__.py index 72d9c2555d16e..f38abafd2db78 100644 --- a/pandas/compat/__init__.py +++ b/pandas/compat/__init__.py @@ -16,13 +16,12 @@ 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 @@ -153,8 +152,6 @@ def is_ci_environment() -> bool: __all__ = [ - "CHAINED_WARNING_DISABLED", - "CHAINED_WARNING_DISABLED_INPLACE_METHOD", "HAS_PYARROW", "IS64", "ISMUSL", @@ -162,6 +159,7 @@ 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 102f6fef6e4e1..674afc5c62009 100644 --- a/pandas/compat/_constants.py +++ b/pandas/compat/_constants.py @@ -19,8 +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()) # type: ignore[attr-defined] -CHAINED_WARNING_DISABLED_INPLACE_METHOD = PYPY or PY314 +WARNING_CHECK_DISABLED = PY314 __all__ = [ diff --git a/pandas/compat/pickle_compat.py b/pandas/compat/pickle_compat.py index 8247356f25f4d..beb4a69232b27 100644 --- a/pandas/compat/pickle_compat.py +++ b/pandas/compat/pickle_compat.py @@ -22,7 +22,6 @@ PeriodArray, TimedeltaArray, ) -from pandas.core.generic import NDFrame from pandas.core.internals import BlockManager if TYPE_CHECKING: @@ -91,10 +90,6 @@ 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 c8c246434f6d8..ab667d92b7e04 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -48,11 +48,11 @@ 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 ( - 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 @@ -63,6 +63,7 @@ ) from pandas.errors.cow import ( _chained_assignment_method_msg, + _chained_assignment_msg, ) from pandas.util._decorators import ( Appender, @@ -520,7 +521,7 @@ @set_module("pandas") -class DataFrame(SetitemMixin, NDFrame, OpsMixin): +class DataFrame(NDFrame, OpsMixin): """ Two-dimensional, size-mutable, potentially heterogeneous tabular data. @@ -667,11 +668,6 @@ class DataFrame(SetitemMixin, 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 @@ -4325,8 +4321,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: + def __setitem__(self, key, value) -> None: """ Set item(s) in DataFrame by key. @@ -4410,6 +4405,12 @@ def _setitem(self, key, value) -> None: z 3 50 # Values for 'a' and 'b' are completely ignored! """ + if not PYPY and not WARNING_CHECK_DISABLED: + if sys.getrefcount(self) <= REF_COUNT + 1: + warnings.warn( + _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 + ) + key = com.apply_if_callable(key, self) # see if we can slice the rows @@ -9362,7 +9363,7 @@ def update( 1 2 500.0 2 3 6.0 """ - if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: + if not PYPY and not WARNING_CHECK_DISABLED: 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 940231233e308..bbc63bbfad791 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -82,9 +82,10 @@ 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 @@ -2074,6 +2075,7 @@ def __getstate__(self) -> dict[str, Any]: **meta, } + @final def __setstate__(self, state) -> None: if isinstance(state, BlockManager): self._mgr = state @@ -4264,9 +4266,8 @@ def _slice(self, slobj: slice, axis: AxisInt = 0) -> Self: result = result.__finalize__(self) return result - # def __delitem__() is implemented in SetitemMixin and dispatches to this method @final - def _delitem(self, key) -> None: + def __delitem__(self, key) -> None: """ Delete item """ @@ -7081,7 +7082,7 @@ def fillna( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: + if not PYPY and not WARNING_CHECK_DISABLED: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -7328,7 +7329,7 @@ def ffill( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: + if not PYPY and not WARNING_CHECK_DISABLED: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -7468,7 +7469,7 @@ def bfill( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: + if not PYPY and not WARNING_CHECK_DISABLED: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -7553,7 +7554,7 @@ def replace( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: + if not PYPY and not WARNING_CHECK_DISABLED: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -7916,7 +7917,7 @@ def interpolate( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: + if not PYPY and not WARNING_CHECK_DISABLED: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -8571,7 +8572,7 @@ def clip( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: + if not PYPY and not WARNING_CHECK_DISABLED: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -10206,7 +10207,7 @@ def where( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: + if not PYPY and not WARNING_CHECK_DISABLED: if sys.getrefcount(self) <= REF_COUNT: warnings.warn( _chained_assignment_method_msg, @@ -10270,7 +10271,7 @@ def mask( ) -> Self | None: inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: + if not PYPY and not WARNING_CHECK_DISABLED: 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 6487bd449f222..aa1bc8878dcb2 100644 --- a/pandas/core/indexing.py +++ b/pandas/core/indexing.py @@ -15,9 +15,10 @@ 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, @@ -919,7 +920,7 @@ def _ensure_listlike_indexer(self, key, axis=None, value=None) -> None: @final def __setitem__(self, key, value) -> None: - if not CHAINED_WARNING_DISABLED: + if not PYPY and not WARNING_CHECK_DISABLED: if sys.getrefcount(self.obj) <= REF_COUNT: warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 @@ -2587,7 +2588,7 @@ def __getitem__(self, key): return super().__getitem__(key) def __setitem__(self, key, value) -> None: - if not CHAINED_WARNING_DISABLED: + if not PYPY and not WARNING_CHECK_DISABLED: if sys.getrefcount(self.obj) <= REF_COUNT: warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 @@ -2618,7 +2619,7 @@ def _convert_key(self, key): return key def __setitem__(self, key, value) -> None: - if not CHAINED_WARNING_DISABLED: + if not PYPY and not WARNING_CHECK_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 6d7e713b7ad6b..6c9bf6fd58c86 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -33,11 +33,11 @@ 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 ( - 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 @@ -48,6 +48,7 @@ ) from pandas.errors.cow import ( _chained_assignment_method_msg, + _chained_assignment_msg, ) from pandas.util._decorators import ( Appender, @@ -231,7 +232,7 @@ # class "NDFrame") # definition in base class "NDFrame" @set_module("pandas") -class Series(SetitemMixin, base.IndexOpsMixin, NDFrame): # type: ignore[misc] +class Series(base.IndexOpsMixin, NDFrame): # type: ignore[misc] """ One-dimensional ndarray with axis labels (including time series). @@ -357,11 +358,6 @@ class Series(SetitemMixin, 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 @@ -1061,8 +1057,13 @@ 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: + def __setitem__(self, key, value) -> None: + if not PYPY and not WARNING_CHECK_DISABLED: + if sys.getrefcount(self) <= REF_COUNT + 1: + warnings.warn( + _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 + ) + check_dict_or_set_indexers(key) key = com.apply_if_callable(key, self) @@ -3351,7 +3352,7 @@ def update(self, other: Series | Sequence | Mapping) -> None: 2 3 dtype: int64 """ - if not CHAINED_WARNING_DISABLED_INPLACE_METHOD: + if not PYPY and not WARNING_CHECK_DISABLED: 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 d8a75fcd380c4..f6de6af994b93 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 CHAINED_WARNING_DISABLED +from pandas.compat import WARNING_CHECK_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 CHAINED_WARNING_DISABLED: + if WARNING_CHECK_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 dcc232f334a92..56df33db6d416 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(inplace_method=True): + with tm.raises_chained_assignment_error(): df["a"].clip(1, 2, inplace=True) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(inplace_method=True): + with tm.raises_chained_assignment_error(): 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 eb621fefc4295..9aed5d3a8d320 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(inplace_method=True): + with tm.raises_chained_assignment_error(): df["a"].fillna(100, inplace=True) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(inplace_method=True): + with tm.raises_chained_assignment_error(): 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(inplace_method=True): + with tm.raises_chained_assignment_error(): getattr(df["a"], func)(inplace=True) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(inplace_method=True): + with tm.raises_chained_assignment_error(): 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 9d3c2f3b929f5..49ce689e5f517 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(inplace_method=True): + with tm.raises_chained_assignment_error(): getattr(df["a"], func)(df["a"] > 2, 5, inplace=True) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(inplace_method=True): + with tm.raises_chained_assignment_error(): 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(inplace_method=True): + with tm.raises_chained_assignment_error(): df["a"].update(ser2) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(inplace_method=True): + with tm.raises_chained_assignment_error(): 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 bbdd759128e46..d4838a5e68ab8 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(inplace_method=True): + with tm.raises_chained_assignment_error(): df["a"].replace(1, 100, inplace=True) tm.assert_frame_equal(df, df_orig) - with tm.raises_chained_assignment_error(inplace_method=True): + with tm.raises_chained_assignment_error(): 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 d229fe5aaaa84..e4e6975ecd9af 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(inplace_method=True): + with tm.raises_chained_assignment_error(): 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 f512ed3e4a0af..25d4019fda9f8 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(inplace_method=True): + with tm.raises_chained_assignment_error(): 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 0caaafec909ac..444e02844a454 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(inplace_method=True): + with tm.raises_chained_assignment_error(): 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 7c4fbc21e7f63..c7ed21a2cc001 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(inplace_method=True): + with tm.raises_chained_assignment_error(): zed["eyes"]["right"].fillna(value=555, inplace=True) diff --git a/pandas/tests/io/test_spss.py b/pandas/tests/io/test_spss.py index 6210c0289a160..61d7225cab02e 100644 --- a/pandas/tests/io/test_spss.py +++ b/pandas/tests/io/test_spss.py @@ -13,6 +13,7 @@ # 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/) @@ -30,6 +31,7 @@ def test_spss_labelled_num(path_klass, datapath): @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 @@ -46,6 +48,7 @@ def test_spss_labelled_num_na(datapath): @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 @@ -62,6 +65,7 @@ def test_spss_labelled_str(datapath): @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 @@ -77,6 +81,7 @@ def test_spss_kwargs(datapath): @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 @@ -135,6 +140,7 @@ def test_invalid_dtype_backend(): @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") diff --git a/pandas/tests/series/indexing/test_indexing.py b/pandas/tests/series/indexing/test_indexing.py index 0dc5b8824958e..f2a604dcc0787 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(inplace_method=True): + with tm.raises_chained_assignment_error(): 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 9debf67af4622..9b5fb098bf3ee 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(inplace_method=True): + with tm.raises_chained_assignment_error(): df["c"].update(Series(["foo"], index=[0])) expected = df_orig tm.assert_frame_equal(df, expected) diff --git a/scripts/validate_unwanted_patterns.py b/scripts/validate_unwanted_patterns.py index f6ede99ecf040..435914d3f3c91 100755 --- a/scripts/validate_unwanted_patterns.py +++ b/scripts/validate_unwanted_patterns.py @@ -95,10 +95,7 @@ def _get_literal_string_prefix_len(token_string: str) -> int: return 0 -PRIVATE_FUNCTIONS_ALLOWED = { - "sys._getframe", - "sys._is_gil_enabled", -} # no known alternative +PRIVATE_FUNCTIONS_ALLOWED = {"sys._getframe"} # no known alternative def private_function_across_module(file_obj: IO[str]) -> Iterable[tuple[int, str]]: From 58e244663680b81c3b6442d1b76c10c6a7df90b4 Mon Sep 17 00:00:00 2001 From: Matt Page Date: Fri, 15 Aug 2025 15:00:49 -0700 Subject: [PATCH 2/8] Update chained assignment checks --- pandas/core/frame.py | 8 +++---- pandas/core/generic.py | 49 +++++++++++++++++++++++++++--------------- pandas/core/series.py | 13 ++++++----- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index ab667d92b7e04..605f464257227 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -4405,8 +4405,8 @@ def __setitem__(self, key, value) -> None: z 3 50 # Values for 'a' and 'b' are completely ignored! """ - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT + 1: + if not PYPY: + if sys.getrefcount(self) <= REF_COUNT and not sys._is_local_in_caller_frame(self): warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 ) @@ -9363,8 +9363,8 @@ def update( 1 2 500.0 2 3 6.0 """ - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT: + if not PYPY: + if sys.getrefcount(self) < REF_COUNT and not sys._is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, diff --git a/pandas/core/generic.py b/pandas/core/generic.py index bbc63bbfad791..8435f3300e1ef 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -85,7 +85,6 @@ from pandas.compat import PYPY from pandas.compat._constants import ( REF_COUNT, - WARNING_CHECK_DISABLED, ) from pandas.compat._optional import import_optional_dependency from pandas.compat.numpy import function as nv @@ -7082,8 +7081,10 @@ def fillna( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT: + if not PYPY: + if sys.getrefcount( + self + ) < REF_COUNT and not sys._is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7329,8 +7330,10 @@ def ffill( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT: + if not PYPY: + if sys.getrefcount( + self + ) < REF_COUNT and not sys._is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7469,8 +7472,10 @@ def bfill( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT: + if not PYPY: + if sys.getrefcount( + self + ) < REF_COUNT and not sys._is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7554,8 +7559,10 @@ def replace( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT: + if not PYPY: + if sys.getrefcount( + self + ) < REF_COUNT and not sys._is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7917,8 +7924,10 @@ def interpolate( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT: + if not PYPY: + if sys.getrefcount( + self + ) < REF_COUNT and not sys._is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -8572,8 +8581,10 @@ def clip( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT: + if not PYPY: + if sys.getrefcount( + self + ) < REF_COUNT and not sys._is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -10207,8 +10218,10 @@ def where( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT: + if not PYPY: + if sys.getrefcount( + self + ) < REF_COUNT and not sys._is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -10271,8 +10284,10 @@ def mask( ) -> Self | None: inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT: + if not PYPY: + if sys.getrefcount( + self + ) < REF_COUNT and not sys._is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, diff --git a/pandas/core/series.py b/pandas/core/series.py index 6c9bf6fd58c86..3cc2e03ff3174 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -37,7 +37,6 @@ from pandas.compat import PYPY from pandas.compat._constants import ( REF_COUNT, - WARNING_CHECK_DISABLED, ) from pandas.compat._optional import import_optional_dependency from pandas.compat.numpy import function as nv @@ -1058,8 +1057,10 @@ def _get_value(self, label, takeable: bool = False): return self.iloc[loc] def __setitem__(self, key, value) -> None: - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT + 1: + if not PYPY: + if sys.getrefcount(self) <= REF_COUNT and not sys._is_local_in_caller_frame( + self + ): warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 ) @@ -3352,8 +3353,10 @@ def update(self, other: Series | Sequence | Mapping) -> None: 2 3 dtype: int64 """ - if not PYPY and not WARNING_CHECK_DISABLED: - if sys.getrefcount(self) <= REF_COUNT: + if not PYPY: + if sys.getrefcount(self) < REF_COUNT and not sys._is_local_in_caller_frame( + self + ): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, From 2337961ad41a273c739c2903d29c3df40c37fae4 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 27 Oct 2025 10:34:55 +0100 Subject: [PATCH 3/8] add is_local_in_caller_frame impl version --- pandas/_libs/include/pandas/frame_utils.h | 16 ++++++++ pandas/_libs/lib.pyx | 8 ++++ pandas/_libs/meson.build | 4 +- pandas/_libs/src/frame_utils.c | 45 +++++++++++++++++++++++ pandas/compat/_constants.py | 4 +- pandas/core/frame.py | 9 +++-- pandas/core/generic.py | 16 ++++---- pandas/core/series.py | 4 +- 8 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 pandas/_libs/include/pandas/frame_utils.h create mode 100644 pandas/_libs/src/frame_utils.c diff --git a/pandas/_libs/include/pandas/frame_utils.h b/pandas/_libs/include/pandas/frame_utils.h new file mode 100644 index 0000000000000..c797a96bc7780 --- /dev/null +++ b/pandas/_libs/include/pandas/frame_utils.h @@ -0,0 +1,16 @@ +/* +Copyright (c) 2025, Pandas Development Team +All rights reserved. + +Distributed under the terms of the BSD Simplified License. + +The full license is in the LICENSE file, distributed with this software. +*/ + +#pragma once + +#define PY_SSIZE_T_CLEAN +#include + +// Return whether or not the object is a local in the caller's frame +int is_local_in_caller_frame_impl(PyObject *object); diff --git a/pandas/_libs/lib.pyx b/pandas/_libs/lib.pyx index 7ed36419babf1..cbfad4b6283ef 100644 --- a/pandas/_libs/lib.pyx +++ b/pandas/_libs/lib.pyx @@ -77,6 +77,9 @@ cdef extern from "pandas/parser/pd_parser.h": PandasParser_IMPORT +cdef extern from "pandas/frame_utils.h": + int is_local_in_caller_frame_impl(PyObject *object) + from pandas._libs cimport util from pandas._libs.util cimport ( INT64_MAX, @@ -3327,3 +3330,8 @@ def is_np_dtype(object dtype, str kinds=None) -> bool: if kinds is None: return True return dtype.kind in kinds + + +def is_local_in_caller_frame(object obj): + """Return whether or not the object is a local in the caller's frame.""" + return is_local_in_caller_frame_impl(obj) diff --git a/pandas/_libs/meson.build b/pandas/_libs/meson.build index 33fc65e5034d0..216ee0fb1c3f6 100644 --- a/pandas/_libs/meson.build +++ b/pandas/_libs/meson.build @@ -97,7 +97,9 @@ libs_sources = { 'sources': ['join.pyx', _khash_primitive_helper], 'deps': _khash_primitive_helper_dep, }, - 'lib': {'sources': ['lib.pyx', 'src/parser/tokenizer.c']}, + 'lib': { + 'sources': ['lib.pyx', 'src/parser/tokenizer.c', 'src/frame_utils.c'], + }, 'missing': {'sources': ['missing.pyx']}, 'pandas_datetime': { 'sources': [ diff --git a/pandas/_libs/src/frame_utils.c b/pandas/_libs/src/frame_utils.c new file mode 100644 index 0000000000000..356aeed1cb561 --- /dev/null +++ b/pandas/_libs/src/frame_utils.c @@ -0,0 +1,45 @@ +/* +Copyright (c) 2025, Pandas Development Team +All rights reserved. + +Distributed under the terms of the BSD Simplified License. + +The full license is in the LICENSE file, distributed with this software. +*/ + +#include "pandas/frame_utils.h" + +/* Return whether or not the object is a local in the caller's frame.*/ +int is_local_in_caller_frame_impl(PyObject *object) { + PyFrameObject *frame = PyEval_GetFrame(); + if (frame == NULL) { + return 0; + } + + // Get the caller's frame (skip the current frame) + PyFrameObject *caller_frame = PyFrame_GetBack(frame); + if (caller_frame == NULL) { + return 0; + } + + // Get local variables of caller's frame and check if the object is in it + PyObject *locals_dict = PyFrame_GetLocals(caller_frame); + if (locals_dict == NULL) { + Py_DECREF(caller_frame); + return 0; + } + + int result = 0; + PyObject *key, *value; + Py_ssize_t pos = 0; + while (PyDict_Next(locals_dict, &pos, &key, &value)) { + if (object == value) { + result = 1; + break; + } + } + + Py_DECREF(locals_dict); + Py_DECREF(caller_frame); + return result; +} diff --git a/pandas/compat/_constants.py b/pandas/compat/_constants.py index 674afc5c62009..28d24d8d1c453 100644 --- a/pandas/compat/_constants.py +++ b/pandas/compat/_constants.py @@ -18,8 +18,8 @@ PYPY = platform.python_implementation() == "PyPy" 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 +REF_COUNT = 3 if PY314 else 2 +CHAINED_WARNING_DISABLED = False # PY314 __all__ = [ diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 605f464257227..44110a382a084 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -52,7 +52,6 @@ from pandas.compat import PYPY from pandas.compat._constants import ( REF_COUNT, - WARNING_CHECK_DISABLED, ) from pandas.compat._optional import import_optional_dependency from pandas.compat.numpy import function as nv @@ -4406,7 +4405,9 @@ def __setitem__(self, key, value) -> None: # Values for 'a' and 'b' are completely ignored! """ if not PYPY: - if sys.getrefcount(self) <= REF_COUNT and not sys._is_local_in_caller_frame(self): + if sys.getrefcount(self) <= REF_COUNT and not lib.is_local_in_caller_frame( + self + ): warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 ) @@ -9364,7 +9365,9 @@ def update( 2 3 6.0 """ if not PYPY: - if sys.getrefcount(self) < REF_COUNT and not sys._is_local_in_caller_frame(self): + if sys.getrefcount(self) < REF_COUNT and not lib.is_local_in_caller_frame( + self + ): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 8435f3300e1ef..dfd7a852131a0 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -7084,7 +7084,7 @@ def fillna( if not PYPY: if sys.getrefcount( self - ) < REF_COUNT and not sys._is_local_in_caller_frame(self): + ) < REF_COUNT and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7333,7 +7333,7 @@ def ffill( if not PYPY: if sys.getrefcount( self - ) < REF_COUNT and not sys._is_local_in_caller_frame(self): + ) < REF_COUNT and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7475,7 +7475,7 @@ def bfill( if not PYPY: if sys.getrefcount( self - ) < REF_COUNT and not sys._is_local_in_caller_frame(self): + ) < REF_COUNT and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7562,7 +7562,7 @@ def replace( if not PYPY: if sys.getrefcount( self - ) < REF_COUNT and not sys._is_local_in_caller_frame(self): + ) < REF_COUNT and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7927,7 +7927,7 @@ def interpolate( if not PYPY: if sys.getrefcount( self - ) < REF_COUNT and not sys._is_local_in_caller_frame(self): + ) < REF_COUNT and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -8584,7 +8584,7 @@ def clip( if not PYPY: if sys.getrefcount( self - ) < REF_COUNT and not sys._is_local_in_caller_frame(self): + ) < REF_COUNT and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -10221,7 +10221,7 @@ def where( if not PYPY: if sys.getrefcount( self - ) < REF_COUNT and not sys._is_local_in_caller_frame(self): + ) < REF_COUNT and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -10287,7 +10287,7 @@ def mask( if not PYPY: if sys.getrefcount( self - ) < REF_COUNT and not sys._is_local_in_caller_frame(self): + ) < REF_COUNT and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, diff --git a/pandas/core/series.py b/pandas/core/series.py index 3cc2e03ff3174..a6436735a7399 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -1058,7 +1058,7 @@ def _get_value(self, label, takeable: bool = False): def __setitem__(self, key, value) -> None: if not PYPY: - if sys.getrefcount(self) <= REF_COUNT and not sys._is_local_in_caller_frame( + if sys.getrefcount(self) <= REF_COUNT and not lib.is_local_in_caller_frame( self ): warnings.warn( @@ -3354,7 +3354,7 @@ def update(self, other: Series | Sequence | Mapping) -> None: dtype: int64 """ if not PYPY: - if sys.getrefcount(self) < REF_COUNT and not sys._is_local_in_caller_frame( + if sys.getrefcount(self) < REF_COUNT and not lib.is_local_in_caller_frame( self ): warnings.warn( From 4b1338cea2a1714374c20903d5fb80e26c841a34 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 27 Oct 2025 10:52:15 +0100 Subject: [PATCH 4/8] rename WARNING_CHECK_DISABLED -> CHAINED_WARNING_DISABLED and combine with PYPY check --- pandas/_testing/contexts.py | 7 ++----- pandas/compat/__init__.py | 4 ++-- pandas/compat/_constants.py | 2 +- pandas/core/frame.py | 6 +++--- pandas/core/generic.py | 18 +++++++++--------- pandas/core/indexing.py | 13 +++++-------- pandas/core/series.py | 6 +++--- .../test_chained_assignment_deprecation.py | 4 ++-- 8 files changed, 27 insertions(+), 33 deletions(-) diff --git a/pandas/_testing/contexts.py b/pandas/_testing/contexts.py index ed30b2022db10..c92a63b625a39 100644 --- a/pandas/_testing/contexts.py +++ b/pandas/_testing/contexts.py @@ -12,10 +12,7 @@ ) import uuid -from pandas.compat import ( - PYPY, - WARNING_CHECK_DISABLED, -) +from pandas.compat import CHAINED_WARNING_DISABLED from pandas.errors import ChainedAssignmentError from pandas.io.common import get_handle @@ -166,7 +163,7 @@ def with_csv_dialect(name: str, **kwargs) -> Generator[None]: def raises_chained_assignment_error(extra_warnings=(), extra_match=()): from pandas._testing import assert_produces_warning - if PYPY or WARNING_CHECK_DISABLED: + if CHAINED_WARNING_DISABLED: if not extra_warnings: from contextlib import nullcontext diff --git a/pandas/compat/__init__.py b/pandas/compat/__init__.py index f38abafd2db78..49b56c6396155 100644 --- a/pandas/compat/__init__.py +++ b/pandas/compat/__init__.py @@ -16,12 +16,12 @@ from typing import TYPE_CHECKING from pandas.compat._constants import ( + CHAINED_WARNING_DISABLED, IS64, ISMUSL, PY312, PY314, PYPY, - WARNING_CHECK_DISABLED, WASM, ) from pandas.compat.numpy import is_numpy_dev @@ -152,6 +152,7 @@ def is_ci_environment() -> bool: __all__ = [ + "CHAINED_WARNING_DISABLED", "HAS_PYARROW", "IS64", "ISMUSL", @@ -159,7 +160,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 28d24d8d1c453..1348342ac0150 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 = 3 if PY314 else 2 -CHAINED_WARNING_DISABLED = False # PY314 +CHAINED_WARNING_DISABLED = PYPY __all__ = [ diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 44110a382a084..5e95a56f7dc59 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -49,7 +49,7 @@ ) from pandas._libs.hashtable import duplicated from pandas._libs.lib import is_range_indexer -from pandas.compat import PYPY +from pandas.compat import CHAINED_WARNING_DISABLED from pandas.compat._constants import ( REF_COUNT, ) @@ -4404,7 +4404,7 @@ def __setitem__(self, key, value) -> None: z 3 50 # Values for 'a' and 'b' are completely ignored! """ - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount(self) <= REF_COUNT and not lib.is_local_in_caller_frame( self ): @@ -9364,7 +9364,7 @@ def update( 1 2 500.0 2 3 6.0 """ - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount(self) < REF_COUNT and not lib.is_local_in_caller_frame( self ): diff --git a/pandas/core/generic.py b/pandas/core/generic.py index dfd7a852131a0..c12e750404b10 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -82,7 +82,7 @@ WriteExcelBuffer, npt, ) -from pandas.compat import PYPY +from pandas.compat import CHAINED_WARNING_DISABLED from pandas.compat._constants import ( REF_COUNT, ) @@ -7081,7 +7081,7 @@ def fillna( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self ) < REF_COUNT and not lib.is_local_in_caller_frame(self): @@ -7330,7 +7330,7 @@ def ffill( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self ) < REF_COUNT and not lib.is_local_in_caller_frame(self): @@ -7472,7 +7472,7 @@ def bfill( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self ) < REF_COUNT and not lib.is_local_in_caller_frame(self): @@ -7559,7 +7559,7 @@ def replace( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self ) < REF_COUNT and not lib.is_local_in_caller_frame(self): @@ -7924,7 +7924,7 @@ def interpolate( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self ) < REF_COUNT and not lib.is_local_in_caller_frame(self): @@ -8581,7 +8581,7 @@ def clip( inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self ) < REF_COUNT and not lib.is_local_in_caller_frame(self): @@ -10218,7 +10218,7 @@ def where( """ inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self ) < REF_COUNT and not lib.is_local_in_caller_frame(self): @@ -10284,7 +10284,7 @@ def mask( ) -> Self | None: inplace = validate_bool_kwarg(inplace, "inplace") if inplace: - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self ) < REF_COUNT and not lib.is_local_in_caller_frame(self): diff --git a/pandas/core/indexing.py b/pandas/core/indexing.py index aa1bc8878dcb2..716b03a176554 100644 --- a/pandas/core/indexing.py +++ b/pandas/core/indexing.py @@ -15,11 +15,8 @@ from pandas._libs.indexing import NDFrameIndexerBase from pandas._libs.lib import item_from_zerodim -from pandas.compat import PYPY -from pandas.compat._constants import ( - REF_COUNT, - WARNING_CHECK_DISABLED, -) +from pandas.compat import CHAINED_WARNING_DISABLED +from pandas.compat._constants import REF_COUNT from pandas.errors import ( AbstractMethodError, ChainedAssignmentError, @@ -920,7 +917,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 +2585,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 +2616,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 a6436735a7399..c0525313c32c8 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -34,7 +34,7 @@ reshape, ) from pandas._libs.lib import is_range_indexer -from pandas.compat import PYPY +from pandas.compat import CHAINED_WARNING_DISABLED from pandas.compat._constants import ( REF_COUNT, ) @@ -1057,7 +1057,7 @@ def _get_value(self, label, takeable: bool = False): return self.iloc[loc] def __setitem__(self, key, value) -> None: - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount(self) <= REF_COUNT and not lib.is_local_in_caller_frame( self ): @@ -3353,7 +3353,7 @@ def update(self, other: Series | Sequence | Mapping) -> None: 2 3 dtype: int64 """ - if not PYPY: + if not CHAINED_WARNING_DISABLED: if sys.getrefcount(self) < REF_COUNT and not lib.is_local_in_caller_frame( self ): 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 From a46965698bc692b34876e25069383ee7c1ef4280 Mon Sep 17 00:00:00 2001 From: Matt Page Date: Fri, 21 Nov 2025 15:00:06 -0800 Subject: [PATCH 5/8] Use `PyMapping_Values()` to retrieve the frame's locals `PyFrame_GetLocals()` returns a proxy object in CPython 3.13+. Calling `PyDict_Next()` on the resulting object will not work because it is not a dictionary. Use `PyMapping_Values()` instead. --- pandas/_libs/src/frame_utils.c | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pandas/_libs/src/frame_utils.c b/pandas/_libs/src/frame_utils.c index 356aeed1cb561..8e3f8865697ff 100644 --- a/pandas/_libs/src/frame_utils.c +++ b/pandas/_libs/src/frame_utils.c @@ -30,15 +30,22 @@ int is_local_in_caller_frame_impl(PyObject *object) { } int result = 0; - PyObject *key, *value; Py_ssize_t pos = 0; - while (PyDict_Next(locals_dict, &pos, &key, &value)) { - if (object == value) { + PyObject *values = PyMapping_Values(locals_dict); + if (values == NULL) { + Py_DECREF(locals_dict); + Py_DECREF(caller_frame); + return 0; + } + Py_ssize_t num_values = PyList_Size(values); + for (Py_ssize_t i = 0; i < num_values; i++) { + if (PyList_GetItem(values, i) == object) { result = 1; break; } } + Py_DECREF(values); Py_DECREF(locals_dict); Py_DECREF(caller_frame); return result; From b77f3969fe84faf778a78e486f661cdd8685fcc9 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 24 Nov 2025 09:40:14 +0100 Subject: [PATCH 6/8] normalize ref_count usage --- pandas/compat/_constants.py | 5 ++++- pandas/core/frame.py | 5 +++-- pandas/core/generic.py | 18 +++++++++--------- pandas/core/indexing.py | 8 ++++---- pandas/core/series.py | 5 +++-- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/pandas/compat/_constants.py b/pandas/compat/_constants.py index 1348342ac0150..8ad31e0725bd4 100644 --- a/pandas/compat/_constants.py +++ b/pandas/compat/_constants.py @@ -18,7 +18,10 @@ PYPY = platform.python_implementation() == "PyPy" WASM = (sys.platform == "emscripten") or (platform.machine() in ["wasm32", "wasm64"]) ISMUSL = "musl" in (sysconfig.get_config_var("HOST_GNU_TYPE") or "") -REF_COUNT = 3 if PY314 else 2 +# the refcount for self in a chained __setitem__/.(i)loc indexing/method call +REF_COUNT = 2 if PY314 else 3 +REF_COUNT_IDX = 2 +REF_COUNT_METHOD = 1 if PY314 else 2 CHAINED_WARNING_DISABLED = PYPY diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 5e95a56f7dc59..8572aa8352aa6 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -52,6 +52,7 @@ from pandas.compat import CHAINED_WARNING_DISABLED from pandas.compat._constants import ( REF_COUNT, + REF_COUNT_METHOD, ) from pandas.compat._optional import import_optional_dependency from pandas.compat.numpy import function as nv @@ -9365,9 +9366,9 @@ def update( 2 3 6.0 """ if not CHAINED_WARNING_DISABLED: - if sys.getrefcount(self) < REF_COUNT and not lib.is_local_in_caller_frame( + if sys.getrefcount( self - ): + ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, diff --git a/pandas/core/generic.py b/pandas/core/generic.py index c12e750404b10..6899b4163202a 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -84,7 +84,7 @@ ) from pandas.compat import CHAINED_WARNING_DISABLED from pandas.compat._constants import ( - REF_COUNT, + REF_COUNT_METHOD, ) from pandas.compat._optional import import_optional_dependency from pandas.compat.numpy import function as nv @@ -7084,7 +7084,7 @@ def fillna( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) < REF_COUNT and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7333,7 +7333,7 @@ def ffill( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) < REF_COUNT and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7475,7 +7475,7 @@ def bfill( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) < REF_COUNT and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7562,7 +7562,7 @@ def replace( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) < REF_COUNT and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7927,7 +7927,7 @@ def interpolate( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) < REF_COUNT and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -8584,7 +8584,7 @@ def clip( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) < REF_COUNT and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -10221,7 +10221,7 @@ def where( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) < REF_COUNT and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -10287,7 +10287,7 @@ def mask( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) < REF_COUNT and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, diff --git a/pandas/core/indexing.py b/pandas/core/indexing.py index 716b03a176554..5cda56b9956ea 100644 --- a/pandas/core/indexing.py +++ b/pandas/core/indexing.py @@ -16,7 +16,7 @@ from pandas._libs.indexing import NDFrameIndexerBase from pandas._libs.lib import item_from_zerodim from pandas.compat import CHAINED_WARNING_DISABLED -from pandas.compat._constants import REF_COUNT +from pandas.compat._constants import REF_COUNT_IDX from pandas.errors import ( AbstractMethodError, ChainedAssignmentError, @@ -918,7 +918,7 @@ def _ensure_listlike_indexer(self, key, axis=None, value=None) -> None: @final def __setitem__(self, key, value) -> None: if not CHAINED_WARNING_DISABLED: - if sys.getrefcount(self.obj) <= REF_COUNT: + if sys.getrefcount(self.obj) <= REF_COUNT_IDX: warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 ) @@ -2586,7 +2586,7 @@ def __getitem__(self, key): def __setitem__(self, key, value) -> None: if not CHAINED_WARNING_DISABLED: - if sys.getrefcount(self.obj) <= REF_COUNT: + if sys.getrefcount(self.obj) <= REF_COUNT_IDX: warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 ) @@ -2617,7 +2617,7 @@ def _convert_key(self, key): def __setitem__(self, key, value) -> None: if not CHAINED_WARNING_DISABLED: - if sys.getrefcount(self.obj) <= REF_COUNT: + if sys.getrefcount(self.obj) <= REF_COUNT_IDX: warnings.warn( _chained_assignment_msg, ChainedAssignmentError, stacklevel=2 ) diff --git a/pandas/core/series.py b/pandas/core/series.py index c0525313c32c8..609c820f77923 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -37,6 +37,7 @@ from pandas.compat import CHAINED_WARNING_DISABLED from pandas.compat._constants import ( REF_COUNT, + REF_COUNT_METHOD, ) from pandas.compat._optional import import_optional_dependency from pandas.compat.numpy import function as nv @@ -3354,9 +3355,9 @@ def update(self, other: Series | Sequence | Mapping) -> None: dtype: int64 """ if not CHAINED_WARNING_DISABLED: - if sys.getrefcount(self) < REF_COUNT and not lib.is_local_in_caller_frame( + if sys.getrefcount( self - ): + ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, From 624be6085c1aaf510ad0c01e898a34b845a3d6a4 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 24 Nov 2025 09:54:12 +0100 Subject: [PATCH 7/8] cleanup --- pandas/_libs/src/frame_utils.c | 1 - pandas/tests/io/test_spss.py | 6 ------ 2 files changed, 7 deletions(-) diff --git a/pandas/_libs/src/frame_utils.c b/pandas/_libs/src/frame_utils.c index 8e3f8865697ff..3800196c1eb39 100644 --- a/pandas/_libs/src/frame_utils.c +++ b/pandas/_libs/src/frame_utils.c @@ -30,7 +30,6 @@ int is_local_in_caller_frame_impl(PyObject *object) { } int result = 0; - Py_ssize_t pos = 0; PyObject *values = PyMapping_Values(locals_dict); if (values == NULL) { Py_DECREF(locals_dict); diff --git a/pandas/tests/io/test_spss.py b/pandas/tests/io/test_spss.py index 61d7225cab02e..6210c0289a160 100644 --- a/pandas/tests/io/test_spss.py +++ b/pandas/tests/io/test_spss.py @@ -13,7 +13,6 @@ # 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,7 +30,6 @@ def test_spss_labelled_num(path_klass, datapath): @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,7 +46,6 @@ def test_spss_labelled_num_na(datapath): @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,7 +62,6 @@ def test_spss_labelled_str(datapath): @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,7 +77,6 @@ def test_spss_kwargs(datapath): @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,7 +135,6 @@ def test_invalid_dtype_backend(): @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 18cbadc2b28e2463738797d8a1a2439078b10297 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 24 Nov 2025 22:56:31 +0100 Subject: [PATCH 8/8] use python instead of c implementation --- pandas/_libs/include/pandas/frame_utils.h | 16 ------- pandas/_libs/lib.pyx | 8 ---- pandas/_libs/meson.build | 4 +- pandas/_libs/src/frame_utils.c | 51 ----------------------- pandas/core/common.py | 27 ++++++++++++ pandas/core/frame.py | 4 +- pandas/core/generic.py | 16 +++---- pandas/core/series.py | 4 +- 8 files changed, 40 insertions(+), 90 deletions(-) delete mode 100644 pandas/_libs/include/pandas/frame_utils.h delete mode 100644 pandas/_libs/src/frame_utils.c diff --git a/pandas/_libs/include/pandas/frame_utils.h b/pandas/_libs/include/pandas/frame_utils.h deleted file mode 100644 index c797a96bc7780..0000000000000 --- a/pandas/_libs/include/pandas/frame_utils.h +++ /dev/null @@ -1,16 +0,0 @@ -/* -Copyright (c) 2025, Pandas Development Team -All rights reserved. - -Distributed under the terms of the BSD Simplified License. - -The full license is in the LICENSE file, distributed with this software. -*/ - -#pragma once - -#define PY_SSIZE_T_CLEAN -#include - -// Return whether or not the object is a local in the caller's frame -int is_local_in_caller_frame_impl(PyObject *object); diff --git a/pandas/_libs/lib.pyx b/pandas/_libs/lib.pyx index cbfad4b6283ef..7ed36419babf1 100644 --- a/pandas/_libs/lib.pyx +++ b/pandas/_libs/lib.pyx @@ -77,9 +77,6 @@ cdef extern from "pandas/parser/pd_parser.h": PandasParser_IMPORT -cdef extern from "pandas/frame_utils.h": - int is_local_in_caller_frame_impl(PyObject *object) - from pandas._libs cimport util from pandas._libs.util cimport ( INT64_MAX, @@ -3330,8 +3327,3 @@ def is_np_dtype(object dtype, str kinds=None) -> bool: if kinds is None: return True return dtype.kind in kinds - - -def is_local_in_caller_frame(object obj): - """Return whether or not the object is a local in the caller's frame.""" - return is_local_in_caller_frame_impl(obj) diff --git a/pandas/_libs/meson.build b/pandas/_libs/meson.build index 216ee0fb1c3f6..33fc65e5034d0 100644 --- a/pandas/_libs/meson.build +++ b/pandas/_libs/meson.build @@ -97,9 +97,7 @@ libs_sources = { 'sources': ['join.pyx', _khash_primitive_helper], 'deps': _khash_primitive_helper_dep, }, - 'lib': { - 'sources': ['lib.pyx', 'src/parser/tokenizer.c', 'src/frame_utils.c'], - }, + 'lib': {'sources': ['lib.pyx', 'src/parser/tokenizer.c']}, 'missing': {'sources': ['missing.pyx']}, 'pandas_datetime': { 'sources': [ diff --git a/pandas/_libs/src/frame_utils.c b/pandas/_libs/src/frame_utils.c deleted file mode 100644 index 3800196c1eb39..0000000000000 --- a/pandas/_libs/src/frame_utils.c +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright (c) 2025, Pandas Development Team -All rights reserved. - -Distributed under the terms of the BSD Simplified License. - -The full license is in the LICENSE file, distributed with this software. -*/ - -#include "pandas/frame_utils.h" - -/* Return whether or not the object is a local in the caller's frame.*/ -int is_local_in_caller_frame_impl(PyObject *object) { - PyFrameObject *frame = PyEval_GetFrame(); - if (frame == NULL) { - return 0; - } - - // Get the caller's frame (skip the current frame) - PyFrameObject *caller_frame = PyFrame_GetBack(frame); - if (caller_frame == NULL) { - return 0; - } - - // Get local variables of caller's frame and check if the object is in it - PyObject *locals_dict = PyFrame_GetLocals(caller_frame); - if (locals_dict == NULL) { - Py_DECREF(caller_frame); - return 0; - } - - int result = 0; - PyObject *values = PyMapping_Values(locals_dict); - if (values == NULL) { - Py_DECREF(locals_dict); - Py_DECREF(caller_frame); - return 0; - } - Py_ssize_t num_values = PyList_Size(values); - for (Py_ssize_t i = 0; i < num_values; i++) { - if (PyList_GetItem(values, i) == object) { - result = 1; - break; - } - } - - Py_DECREF(values); - Py_DECREF(locals_dict); - Py_DECREF(caller_frame); - return result; -} diff --git a/pandas/core/common.py b/pandas/core/common.py index 4f1c8d1800c00..b6aab3ddfee63 100644 --- a/pandas/core/common.py +++ b/pandas/core/common.py @@ -22,6 +22,7 @@ import contextlib from functools import partial import inspect +import sys from typing import ( TYPE_CHECKING, Any, @@ -650,3 +651,29 @@ def fill_missing_names(names: Sequence[Hashable | None]) -> list[Hashable]: list of column names with the None values replaced. """ return [f"level_{i}" if name is None else name for i, name in enumerate(names)] + + +def is_local_in_caller_frame(obj): + """ + Helper function used in detecting chained assignment. + + If the pandas object (DataFrame/Series) is a local variable + in the caller's frame, it should not be a case of chained + assignment or method call. + + For example: + + def test(): + df = pd.DataFrame(...) + df["a"] = 1 # not chained assignment + + Inside ``df.__setitem__``, we call this function to check whether `df` + (`self`) is a local variable in `test` frame (the frame calling setitem). If + so, we know it is not a case of chained assignment (even when the refcount + of `df` is below the threshold due to optimization of local variables). + """ + frame = sys._getframe(2) + for v in frame.f_locals.values(): + if v is obj: + return True + return False diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 8572aa8352aa6..cab6f3702d805 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -4406,7 +4406,7 @@ def __setitem__(self, key, value) -> None: # Values for 'a' and 'b' are completely ignored! """ if not CHAINED_WARNING_DISABLED: - if sys.getrefcount(self) <= REF_COUNT and not lib.is_local_in_caller_frame( + if sys.getrefcount(self) <= REF_COUNT and not com.is_local_in_caller_frame( self ): warnings.warn( @@ -9368,7 +9368,7 @@ def update( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not com.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 6899b4163202a..2669546d52af2 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -7084,7 +7084,7 @@ def fillna( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not common.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7333,7 +7333,7 @@ def ffill( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not common.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7475,7 +7475,7 @@ def bfill( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not common.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7562,7 +7562,7 @@ def replace( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not common.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -7927,7 +7927,7 @@ def interpolate( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not common.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -8584,7 +8584,7 @@ def clip( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not common.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -10221,7 +10221,7 @@ def where( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not common.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, @@ -10287,7 +10287,7 @@ def mask( if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not common.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError, diff --git a/pandas/core/series.py b/pandas/core/series.py index 609c820f77923..89c59b6788cd2 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -1059,7 +1059,7 @@ def _get_value(self, label, takeable: bool = False): def __setitem__(self, key, value) -> None: if not CHAINED_WARNING_DISABLED: - if sys.getrefcount(self) <= REF_COUNT and not lib.is_local_in_caller_frame( + if sys.getrefcount(self) <= REF_COUNT and not com.is_local_in_caller_frame( self ): warnings.warn( @@ -3357,7 +3357,7 @@ def update(self, other: Series | Sequence | Mapping) -> None: if not CHAINED_WARNING_DISABLED: if sys.getrefcount( self - ) <= REF_COUNT_METHOD and not lib.is_local_in_caller_frame(self): + ) <= REF_COUNT_METHOD and not com.is_local_in_caller_frame(self): warnings.warn( _chained_assignment_method_msg, ChainedAssignmentError,