From 916ce1bfd665054ef8133ded21cf03919794524c Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 21 Jul 2025 21:43:34 -0700 Subject: [PATCH 1/9] gh-135228: When @dataclass(slots=True) replaces a dataclass, make the original class collectible (#136893) An interesting hack, but more localized in scope than #135230. This may be a breaking change if people intentionally keep the original class around when using `@dataclass(slots=True)`, and then use `__dict__` or `__weakref__` on the original class. Co-authored-by: Alyssa Coghlan --- Lib/dataclasses.py | 15 ++++++++ Lib/test/test_dataclasses/__init__.py | 35 +++++++++++++++++++ ...-07-20-16-56-55.gh-issue-135228.n_XIao.rst | 4 +++ 3 files changed, 54 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2025-07-20-16-56-55.gh-issue-135228.n_XIao.rst diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 83ea623dce6281..22b78bb2fbe6ed 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1338,6 +1338,13 @@ def _add_slots(cls, is_frozen, weakref_slot, defined_fields): or _update_func_cell_for__class__(member.fdel, cls, newcls)): break + # gh-135228: Make sure the original class can be garbage collected. + # Bypass mapping proxy to allow __dict__ to be removed + old_cls_dict = cls.__dict__ | _deproxier + old_cls_dict.pop('__dict__', None) + if "__weakref__" in cls.__dict__: + del cls.__weakref__ + return newcls @@ -1732,3 +1739,11 @@ def _replace(self, /, **changes): # changes that aren't fields, this will correctly raise a # TypeError. return self.__class__(**changes) + + +# Hack to the get the underlying dict out of a mappingproxy +# Use it with: cls.__dict__ | _deproxier +class _Deproxier: + def __ror__(self, other): + return other +_deproxier = _Deproxier() diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index e98a8f284cec9f..6bf5e5b3e5554b 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -3804,6 +3804,41 @@ class WithCorrectSuper(CorrectSuper): # that we create internally. self.assertEqual(CorrectSuper.args, ["default", "default"]) + def test_original_class_is_gced(self): + # gh-135228: Make sure when we replace the class with slots=True, the original class + # gets garbage collected. + def make_simple(): + @dataclass(slots=True) + class SlotsTest: + pass + + return SlotsTest + + def make_with_annotations(): + @dataclass(slots=True) + class SlotsTest: + x: int + + return SlotsTest + + def make_with_annotations_and_method(): + @dataclass(slots=True) + class SlotsTest: + x: int + + def method(self) -> int: + return self.x + + return SlotsTest + + for make in (make_simple, make_with_annotations, make_with_annotations_and_method): + with self.subTest(make=make): + C = make() + support.gc_collect() + candidates = [cls for cls in object.__subclasses__() if cls.__name__ == 'SlotsTest' + and cls.__firstlineno__ == make.__code__.co_firstlineno + 1] + self.assertEqual(candidates, [C]) + class TestDescriptors(unittest.TestCase): def test_set_name(self): diff --git a/Misc/NEWS.d/next/Library/2025-07-20-16-56-55.gh-issue-135228.n_XIao.rst b/Misc/NEWS.d/next/Library/2025-07-20-16-56-55.gh-issue-135228.n_XIao.rst new file mode 100644 index 00000000000000..ee8962c6f46e75 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-07-20-16-56-55.gh-issue-135228.n_XIao.rst @@ -0,0 +1,4 @@ +When :mod:`dataclasses` replaces a class with a slotted dataclass, the +original class is now garbage collected again. Earlier changes in Python +3.14 caused this class to remain in existence together with the replacement +class synthesized by :mod:`dataclasses`. From 6dc0f4804655b57efd7db6637876910335b09d4d Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 23 Jul 2025 08:29:40 -0700 Subject: [PATCH 2/9] Use a new private sys function instead --- Lib/dataclasses.py | 13 +------------ Python/clinic/sysmodule.c.h | 13 ++++++++++++- Python/sysmodule.c | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 22b78bb2fbe6ed..7db595250e96b9 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1340,10 +1340,7 @@ def _add_slots(cls, is_frozen, weakref_slot, defined_fields): # gh-135228: Make sure the original class can be garbage collected. # Bypass mapping proxy to allow __dict__ to be removed - old_cls_dict = cls.__dict__ | _deproxier - old_cls_dict.pop('__dict__', None) - if "__weakref__" in cls.__dict__: - del cls.__weakref__ + sys._clear_type_descriptors(cls) return newcls @@ -1739,11 +1736,3 @@ def _replace(self, /, **changes): # changes that aren't fields, this will correctly raise a # TypeError. return self.__class__(**changes) - - -# Hack to the get the underlying dict out of a mappingproxy -# Use it with: cls.__dict__ | _deproxier -class _Deproxier: - def __ror__(self, other): - return other -_deproxier = _Deproxier() diff --git a/Python/clinic/sysmodule.c.h b/Python/clinic/sysmodule.c.h index a47e4d11b54441..2e934b60384899 100644 --- a/Python/clinic/sysmodule.c.h +++ b/Python/clinic/sysmodule.c.h @@ -1793,6 +1793,17 @@ sys__baserepl(PyObject *module, PyObject *Py_UNUSED(ignored)) return sys__baserepl_impl(module); } +PyDoc_STRVAR(sys__clear_type_descriptors__doc__, +"_clear_type_descriptors($module, type, /)\n" +"--\n" +"\n" +"Private function for clearing certain descriptors from a type\'s dictionary.\n" +"\n" +"See gh-135228 for context."); + +#define SYS__CLEAR_TYPE_DESCRIPTORS_METHODDEF \ + {"_clear_type_descriptors", (PyCFunction)sys__clear_type_descriptors, METH_O, sys__clear_type_descriptors__doc__}, + PyDoc_STRVAR(sys__is_gil_enabled__doc__, "_is_gil_enabled($module, /)\n" "--\n" @@ -1948,4 +1959,4 @@ _jit_is_active(PyObject *module, PyObject *Py_UNUSED(ignored)) #ifndef SYS_GETANDROIDAPILEVEL_METHODDEF #define SYS_GETANDROIDAPILEVEL_METHODDEF #endif /* !defined(SYS_GETANDROIDAPILEVEL_METHODDEF) */ -/*[clinic end generated code: output=449d16326e69dcf6 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=f75cd2babc1841db input=a9049054013a1b77]*/ diff --git a/Python/sysmodule.c b/Python/sysmodule.c index ae6cf306735939..b7d4a757fafb2e 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2641,6 +2641,38 @@ sys__baserepl_impl(PyObject *module) Py_RETURN_NONE; } +/*[clinic input] +sys._clear_type_descriptors + + type: object + / + +Private function for clearing certain descriptors from a type's dictionary. + +See gh-135228 for context. +[clinic start generated code]*/ + +static PyObject * +sys__clear_type_descriptors(PyObject *module, PyObject *type) +/*[clinic end generated code: output=7d5cefcf861909e0 input=5fdc23500d477de6]*/ +{ + if (!PyType_Check(type)) { + PyErr_SetString(PyExc_TypeError, "argument must be a type"); + return NULL; + } + PyTypeObject *typeobj = (PyTypeObject *)(type); + PyObject *dict = _PyType_GetDict(typeobj); + if (PyDict_PopString(dict, "__dict__", NULL) < 0) { + return NULL; + } + if (PyDict_PopString(dict, "__weakref__", NULL) < 0) { + return NULL; + } + PyType_Modified(typeobj); + Py_RETURN_NONE; +} + + /*[clinic input] sys._is_gil_enabled -> bool @@ -2837,6 +2869,7 @@ static PyMethodDef sys_methods[] = { SYS__STATS_DUMP_METHODDEF #endif SYS__GET_CPU_COUNT_CONFIG_METHODDEF + SYS__CLEAR_TYPE_DESCRIPTORS_METHODDEF SYS__IS_GIL_ENABLED_METHODDEF SYS__DUMP_TRACELETS_METHODDEF {NULL, NULL} // sentinel From b9fb155331c85efcaef5af24a98e4a571a7f71e3 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 23 Jul 2025 08:43:06 -0700 Subject: [PATCH 3/9] obsolete comment --- Lib/dataclasses.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 7db595250e96b9..53b3b54cfb3fc7 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1339,7 +1339,6 @@ def _add_slots(cls, is_frozen, weakref_slot, defined_fields): break # gh-135228: Make sure the original class can be garbage collected. - # Bypass mapping proxy to allow __dict__ to be removed sys._clear_type_descriptors(cls) return newcls From 52c7d2bb6e192b25909850697a3174df689bb6d6 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 23 Jul 2025 14:20:56 -0700 Subject: [PATCH 4/9] restrict to heap types --- Python/sysmodule.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Python/sysmodule.c b/Python/sysmodule.c index b7d4a757fafb2e..b06166f761c030 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2661,6 +2661,10 @@ sys__clear_type_descriptors(PyObject *module, PyObject *type) return NULL; } PyTypeObject *typeobj = (PyTypeObject *)(type); + if (!_PyType_HasFeature(typeobj, Py_TPFLAGS_HEAPTYPE)) { + PyErr_SetString(PyExc_TypeError, "argument must be a heap type"); + return NULL; + } PyObject *dict = _PyType_GetDict(typeobj); if (PyDict_PopString(dict, "__dict__", NULL) < 0) { return NULL; From 250f3e9935d8188c27dcd91e6cb61293166475c9 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 24 Jul 2025 17:13:36 -0700 Subject: [PATCH 5/9] more paranoid --- Python/sysmodule.c | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Python/sysmodule.c b/Python/sysmodule.c index b06166f761c030..bca07f7c4e4cee 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2666,13 +2666,22 @@ sys__clear_type_descriptors(PyObject *module, PyObject *type) return NULL; } PyObject *dict = _PyType_GetDict(typeobj); - if (PyDict_PopString(dict, "__dict__", NULL) < 0) { + PyObject *dunder_dict = NULL; + if (PyDict_PopString(dict, "__dict__", &dunder_dict) < 0) { return NULL; } - if (PyDict_PopString(dict, "__weakref__", NULL) < 0) { + PyObject *dunder_weakref = NULL; + if (PyDict_PopString(dict, "__weakref__", &dunder_weakref) < 0) { + PyType_Modified(typeobj); + Py_XDECREF(dunder_dict); return NULL; } PyType_Modified(typeobj); + // We try to hold onto a reference to these until after we call + // PyType_Modified(), in case their deallocation triggers somer user code + // that tries to do something to the type. + Py_XDECREF(dunder_dict); + Py_XDECREF(dunder_weakref); Py_RETURN_NONE; } From a9483b405746c6e5b60f7177fc34152208c62cba Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 29 Jul 2025 07:29:01 -0700 Subject: [PATCH 6/9] Apply suggestions from code review Co-authored-by: Petr Viktorin Co-authored-by: Serhiy Storchaka --- Python/sysmodule.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Python/sysmodule.c b/Python/sysmodule.c index bca07f7c4e4cee..84acae66da6c12 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2661,17 +2661,17 @@ sys__clear_type_descriptors(PyObject *module, PyObject *type) return NULL; } PyTypeObject *typeobj = (PyTypeObject *)(type); - if (!_PyType_HasFeature(typeobj, Py_TPFLAGS_HEAPTYPE)) { - PyErr_SetString(PyExc_TypeError, "argument must be a heap type"); + if (!_PyType_HasFeature(typeobj, Py_TPFLAGS_IMMUTABLETYPE)) { + PyErr_SetString(PyExc_TypeError, "argument is immutable"); return NULL; } PyObject *dict = _PyType_GetDict(typeobj); PyObject *dunder_dict = NULL; - if (PyDict_PopString(dict, "__dict__", &dunder_dict) < 0) { + if (PyDict_Pop(dict, &_Py_ID(__dict__), &dunder_dict) < 0) { return NULL; } PyObject *dunder_weakref = NULL; - if (PyDict_PopString(dict, "__weakref__", &dunder_weakref) < 0) { + if (PyDict_Pop(dict, &_Py_ID(__weakref__), &dunder_weakref) < 0) { PyType_Modified(typeobj); Py_XDECREF(dunder_dict); return NULL; From 8c2c1e1849753b6c1fa1588f7837eea55792bb33 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 29 Jul 2025 17:28:33 -0700 Subject: [PATCH 7/9] Update Python/sysmodule.c --- Python/sysmodule.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python/sysmodule.c b/Python/sysmodule.c index 84acae66da6c12..a25da631feabaf 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2661,7 +2661,7 @@ sys__clear_type_descriptors(PyObject *module, PyObject *type) return NULL; } PyTypeObject *typeobj = (PyTypeObject *)(type); - if (!_PyType_HasFeature(typeobj, Py_TPFLAGS_IMMUTABLETYPE)) { + if (_PyType_HasFeature(typeobj, Py_TPFLAGS_IMMUTABLETYPE)) { PyErr_SetString(PyExc_TypeError, "argument is immutable"); return NULL; } From 2d3bd7c475778eb9c12f2be9428fea1529d42afb Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 5 Aug 2025 06:25:30 -0700 Subject: [PATCH 8/9] Feedback from Petr --- Lib/dataclasses.py | 13 ++++--------- .../2025-07-20-16-56-55.gh-issue-135228.n_XIao.rst | 4 ++-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 53b3b54cfb3fc7..d29f1615f276d2 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1283,6 +1283,10 @@ def _add_slots(cls, is_frozen, weakref_slot, defined_fields): if '__slots__' in cls.__dict__: raise TypeError(f'{cls.__name__} already specifies __slots__') + # gh-102069: Remove existing __weakref__ descriptor. + # gh-135228: Make sure the original class can be garbage collected. + sys._clear_type_descriptors(cls) + # Create a new dict for our new class. cls_dict = dict(cls.__dict__) field_names = tuple(f.name for f in fields(cls)) @@ -1300,12 +1304,6 @@ def _add_slots(cls, is_frozen, weakref_slot, defined_fields): # available in _MARKER. cls_dict.pop(field_name, None) - # Remove __dict__ itself. - cls_dict.pop('__dict__', None) - - # Clear existing `__weakref__` descriptor, it belongs to a previous type: - cls_dict.pop('__weakref__', None) # gh-102069 - # And finally create the class. qualname = getattr(cls, '__qualname__', None) newcls = type(cls)(cls.__name__, cls.__bases__, cls_dict) @@ -1338,9 +1336,6 @@ def _add_slots(cls, is_frozen, weakref_slot, defined_fields): or _update_func_cell_for__class__(member.fdel, cls, newcls)): break - # gh-135228: Make sure the original class can be garbage collected. - sys._clear_type_descriptors(cls) - return newcls diff --git a/Misc/NEWS.d/next/Library/2025-07-20-16-56-55.gh-issue-135228.n_XIao.rst b/Misc/NEWS.d/next/Library/2025-07-20-16-56-55.gh-issue-135228.n_XIao.rst index ee8962c6f46e75..517a37feb37ae6 100644 --- a/Misc/NEWS.d/next/Library/2025-07-20-16-56-55.gh-issue-135228.n_XIao.rst +++ b/Misc/NEWS.d/next/Library/2025-07-20-16-56-55.gh-issue-135228.n_XIao.rst @@ -1,4 +1,4 @@ When :mod:`dataclasses` replaces a class with a slotted dataclass, the -original class is now garbage collected again. Earlier changes in Python -3.14 caused this class to remain in existence together with the replacement +original class can now be garbage collected again. Earlier changes in Python +3.14 caused this class to always remain in existence together with the replacement class synthesized by :mod:`dataclasses`. From 06618d8804ecc123f8eb1da1fb6a6f131da93af0 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 11 Aug 2025 18:12:00 -0700 Subject: [PATCH 9/9] use AC for type checking --- Python/clinic/sysmodule.c.h | 22 +++++++++++++++++++++- Python/sysmodule.c | 12 ++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/Python/clinic/sysmodule.c.h b/Python/clinic/sysmodule.c.h index 2e934b60384899..09ce77fd12608f 100644 --- a/Python/clinic/sysmodule.c.h +++ b/Python/clinic/sysmodule.c.h @@ -1804,6 +1804,26 @@ PyDoc_STRVAR(sys__clear_type_descriptors__doc__, #define SYS__CLEAR_TYPE_DESCRIPTORS_METHODDEF \ {"_clear_type_descriptors", (PyCFunction)sys__clear_type_descriptors, METH_O, sys__clear_type_descriptors__doc__}, +static PyObject * +sys__clear_type_descriptors_impl(PyObject *module, PyObject *type); + +static PyObject * +sys__clear_type_descriptors(PyObject *module, PyObject *arg) +{ + PyObject *return_value = NULL; + PyObject *type; + + if (!PyObject_TypeCheck(arg, &PyType_Type)) { + _PyArg_BadArgument("_clear_type_descriptors", "argument", (&PyType_Type)->tp_name, arg); + goto exit; + } + type = arg; + return_value = sys__clear_type_descriptors_impl(module, type); + +exit: + return return_value; +} + PyDoc_STRVAR(sys__is_gil_enabled__doc__, "_is_gil_enabled($module, /)\n" "--\n" @@ -1959,4 +1979,4 @@ _jit_is_active(PyObject *module, PyObject *Py_UNUSED(ignored)) #ifndef SYS_GETANDROIDAPILEVEL_METHODDEF #define SYS_GETANDROIDAPILEVEL_METHODDEF #endif /* !defined(SYS_GETANDROIDAPILEVEL_METHODDEF) */ -/*[clinic end generated code: output=f75cd2babc1841db input=a9049054013a1b77]*/ +/*[clinic end generated code: output=9052f399f40ca32d input=a9049054013a1b77]*/ diff --git a/Python/sysmodule.c b/Python/sysmodule.c index a25da631feabaf..e4bc27d2ce624c 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2644,7 +2644,7 @@ sys__baserepl_impl(PyObject *module) /*[clinic input] sys._clear_type_descriptors - type: object + type: object(subclass_of='&PyType_Type') / Private function for clearing certain descriptors from a type's dictionary. @@ -2653,14 +2653,10 @@ See gh-135228 for context. [clinic start generated code]*/ static PyObject * -sys__clear_type_descriptors(PyObject *module, PyObject *type) -/*[clinic end generated code: output=7d5cefcf861909e0 input=5fdc23500d477de6]*/ +sys__clear_type_descriptors_impl(PyObject *module, PyObject *type) +/*[clinic end generated code: output=5ad17851b762b6d9 input=dc536c97fde07251]*/ { - if (!PyType_Check(type)) { - PyErr_SetString(PyExc_TypeError, "argument must be a type"); - return NULL; - } - PyTypeObject *typeobj = (PyTypeObject *)(type); + PyTypeObject *typeobj = (PyTypeObject *)type; if (_PyType_HasFeature(typeobj, Py_TPFLAGS_IMMUTABLETYPE)) { PyErr_SetString(PyExc_TypeError, "argument is immutable"); return NULL;