From 027f629dd5711be1348e5461177b3de7ed0dc44e Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sun, 20 Jul 2025 16:57:40 -0700 Subject: [PATCH 1/8] gh-135228: When @dataclass(slots=True) replaces a dataclass, make the original class collectible 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. --- Lib/dataclasses.py | 13 +++++++ Lib/test/test_dataclasses/__init__.py | 35 +++++++++++++++++++ ...-07-20-16-56-55.gh-issue-135228.n_XIao.rst | 4 +++ 3 files changed, 52 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..9a1d5071e4157c 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1338,6 +1338,11 @@ 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. + old_cls_dict = cls.__dict__ | _deproxier + old_cls_dict.pop('__weakref__', None) + old_cls_dict.pop('__dict__', None) + return newcls @@ -1732,3 +1737,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 c82d86bb7a6c7dc1887fa95f407d355f0e1f6421 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Tue, 22 Jul 2025 08:32:17 +0200 Subject: [PATCH 2/8] Keep just the test --- Lib/dataclasses.py | 13 ------------- .../2025-07-20-16-56-55.gh-issue-135228.n_XIao.rst | 4 ---- 2 files changed, 17 deletions(-) delete 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 9a1d5071e4157c..83ea623dce6281 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1338,11 +1338,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. - old_cls_dict = cls.__dict__ | _deproxier - old_cls_dict.pop('__weakref__', None) - old_cls_dict.pop('__dict__', None) - return newcls @@ -1737,11 +1732,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/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 deleted file mode 100644 index ee8962c6f46e75..00000000000000 --- a/Misc/NEWS.d/next/Library/2025-07-20-16-56-55.gh-issue-135228.n_XIao.rst +++ /dev/null @@ -1,4 +0,0 @@ -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 9ef8632fbfddf90d5a0eb4060f7e511adf6d235b Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Tue, 22 Jul 2025 09:14:34 +0200 Subject: [PATCH 3/8] Create __dict__ and __weakref__ descriptors for `object` --- Objects/typeobject.c | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/Objects/typeobject.c b/Objects/typeobject.c index 379c4d0467c487..bef59746acab96 100644 --- a/Objects/typeobject.c +++ b/Objects/typeobject.c @@ -4038,22 +4038,25 @@ subtype_getweakref(PyObject *obj, void *context) /* Three variants on the subtype_getsets list. */ +static char subtype_getset_dict_name[] = "__dict__"; +static char subtype_getset_weakref_name[] = "__weakref__"; + static PyGetSetDef subtype_getsets_full[] = { - {"__dict__", subtype_dict, subtype_setdict, + {subtype_getset_dict_name, subtype_dict, subtype_setdict, PyDoc_STR("dictionary for instance variables")}, - {"__weakref__", subtype_getweakref, NULL, + {subtype_getset_weakref_name, subtype_getweakref, NULL, PyDoc_STR("list of weak references to the object")}, {0} }; static PyGetSetDef subtype_getsets_dict_only[] = { - {"__dict__", subtype_dict, subtype_setdict, + {subtype_getset_dict_name, subtype_dict, subtype_setdict, PyDoc_STR("dictionary for instance variables")}, {0} }; static PyGetSetDef subtype_getsets_weakref_only[] = { - {"__weakref__", subtype_getweakref, NULL, + {subtype_getset_weakref_name, subtype_getweakref, NULL, PyDoc_STR("list of weak references to the object")}, {0} }; @@ -8329,7 +8332,16 @@ type_add_getset(PyTypeObject *type) PyObject *dict = lookup_tp_dict(type); for (; gsp->name != NULL; gsp++) { - PyObject *descr = PyDescr_NewGetSet(type, gsp); + PyTypeObject *descr_type = type; + // Hack: dict and weakref descriptors are created for `object`, + // rather than this specific type. + // We identify their PyGetSetDef by pointer equality on name. + if (gsp->name == subtype_getset_dict_name + || gsp->name == subtype_getset_weakref_name) + { + descr_type = &PyBaseObject_Type; + } + PyObject *descr = PyDescr_NewGetSet(descr_type, gsp); if (descr == NULL) { return -1; } From 696f1d0e6a2c8eba84c005cd0f103d7f865ac1f8 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Tue, 22 Jul 2025 09:26:45 +0200 Subject: [PATCH 4/8] Adjust internals of inspect.getattr_static --- Lib/inspect.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/inspect.py b/Lib/inspect.py index 183e67fabf966e..d7814bfeb2b885 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -1698,7 +1698,8 @@ def _shadowed_dict_from_weakref_mro_tuple(*weakref_mro): class_dict = dunder_dict['__dict__'] if not (type(class_dict) is types.GetSetDescriptorType and class_dict.__name__ == "__dict__" and - class_dict.__objclass__ is entry): + (class_dict.__objclass__ is object or + class_dict.__objclass__ is entry)): return class_dict return _sentinel From d0d2f73566699e2c0114fcd1466e023b53e01b77 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 30 Jul 2025 17:05:15 +0200 Subject: [PATCH 5/8] Share __dict__ and __weakref__ descriptors --- Include/internal/pycore_interp_structs.h | 7 ++ Include/internal/pycore_typeobject.h | 1 + Objects/typeobject.c | 114 ++++++++++++++--------- Python/pylifecycle.c | 1 + 4 files changed, 78 insertions(+), 45 deletions(-) diff --git a/Include/internal/pycore_interp_structs.h b/Include/internal/pycore_interp_structs.h index 542a75617b4d3c..540e25d46c4df3 100644 --- a/Include/internal/pycore_interp_structs.h +++ b/Include/internal/pycore_interp_structs.h @@ -691,6 +691,13 @@ struct _Py_interp_cached_objects { PyTypeObject *paramspecargs_type; PyTypeObject *paramspeckwargs_type; PyTypeObject *constevaluator_type; + + /* Descriptors for __dict__ and __weakref__ */ +#ifdef Py_GIL_DISABLED + PyMutex descriptor_mutex; +#endif + PyObject *dict_descriptor; + PyObject *weakref_descriptor; }; struct _Py_interp_static_objects { diff --git a/Include/internal/pycore_typeobject.h b/Include/internal/pycore_typeobject.h index 0ee7d555c56cdd..24df69aa93fda2 100644 --- a/Include/internal/pycore_typeobject.h +++ b/Include/internal/pycore_typeobject.h @@ -40,6 +40,7 @@ extern void _PyTypes_FiniTypes(PyInterpreterState *); extern void _PyTypes_FiniExtTypes(PyInterpreterState *interp); extern void _PyTypes_Fini(PyInterpreterState *); extern void _PyTypes_AfterFork(void); +extern void _PyTypes_FiniCachedDescriptors(PyInterpreterState *); static inline PyObject ** _PyStaticType_GET_WEAKREFS_LISTPTR(managed_static_type_state *state) diff --git a/Objects/typeobject.c b/Objects/typeobject.c index 04df120860c621..992c045028c22b 100644 --- a/Objects/typeobject.c +++ b/Objects/typeobject.c @@ -4036,29 +4036,15 @@ subtype_getweakref(PyObject *obj, void *context) return Py_NewRef(result); } -/* Three variants on the subtype_getsets list. */ - -static char subtype_getset_dict_name[] = "__dict__"; -static char subtype_getset_weakref_name[] = "__weakref__"; - -static PyGetSetDef subtype_getsets_full[] = { - {subtype_getset_dict_name, subtype_dict, subtype_setdict, - PyDoc_STR("dictionary for instance variables")}, - {subtype_getset_weakref_name, subtype_getweakref, NULL, - PyDoc_STR("list of weak references to the object")}, - {0} +/* getset definitions for common descriptors */ +static PyGetSetDef subtype_getset_dict = { + "__dict__", subtype_dict, subtype_setdict, + PyDoc_STR("dictionary for instance variables"), }; -static PyGetSetDef subtype_getsets_dict_only[] = { - {subtype_getset_dict_name, subtype_dict, subtype_setdict, - PyDoc_STR("dictionary for instance variables")}, - {0} -}; - -static PyGetSetDef subtype_getsets_weakref_only[] = { - {subtype_getset_weakref_name, subtype_getweakref, NULL, - PyDoc_STR("list of weak references to the object")}, - {0} +static PyGetSetDef subtype_getset_weakref = { + "__weakref__", subtype_getweakref, NULL, + PyDoc_STR("list of weak references to the object"), }; static int @@ -4594,10 +4580,36 @@ type_new_classmethod(PyObject *dict, PyObject *attr) return 0; } +/* Add __dict__ or __weakref__ descriptor */ +static int +type_add_common_descriptor(PyInterpreterState *interp, + PyObject **cache, + PyGetSetDef *getset_def, + PyObject *dict) +{ +#ifdef Py_GIL_DISABLED + PyMutex_Lock(&interp->cached_objects.descriptor_mutex); +#endif + PyObject *descr = *cache; + if (!descr) { + descr = PyDescr_NewGetSet(&PyBaseObject_Type, getset_def); + *cache = descr; + } +#ifdef Py_GIL_DISABLED + PyMutex_Unlock(&interp->cached_objects.descriptor_mutex); +#endif + if (!descr) { + return -1; + } + if (PyDict_SetDefaultRef(dict, PyDescr_NAME(descr), descr, NULL) < 0) { + return -1; + } + return 0; +} /* Add descriptors for custom slots from __slots__, or for __dict__ */ static int -type_new_descriptors(const type_new_ctx *ctx, PyTypeObject *type) +type_new_descriptors(const type_new_ctx *ctx, PyTypeObject *type, PyObject *dict) { PyHeapTypeObject *et = (PyHeapTypeObject *)type; Py_ssize_t slotoffset = ctx->base->tp_basicsize; @@ -4635,6 +4647,30 @@ type_new_descriptors(const type_new_ctx *ctx, PyTypeObject *type) type->tp_basicsize = slotoffset; type->tp_itemsize = ctx->base->tp_itemsize; type->tp_members = _PyHeapType_GET_MEMBERS(et); + + PyInterpreterState *interp = _PyInterpreterState_GET(); + + if (type->tp_dictoffset) { + if (type_add_common_descriptor( + interp, + &interp->cached_objects.dict_descriptor, + &subtype_getset_dict, + dict) < 0) + { + return -1; + } + } + if (type->tp_weaklistoffset) { + if (type_add_common_descriptor( + interp, + &interp->cached_objects.weakref_descriptor, + &subtype_getset_weakref, + dict) < 0) + { + return -1; + } + } + return 0; } @@ -4642,18 +4678,7 @@ type_new_descriptors(const type_new_ctx *ctx, PyTypeObject *type) static void type_new_set_slots(const type_new_ctx *ctx, PyTypeObject *type) { - if (type->tp_weaklistoffset && type->tp_dictoffset) { - type->tp_getset = subtype_getsets_full; - } - else if (type->tp_weaklistoffset && !type->tp_dictoffset) { - type->tp_getset = subtype_getsets_weakref_only; - } - else if (!type->tp_weaklistoffset && type->tp_dictoffset) { - type->tp_getset = subtype_getsets_dict_only; - } - else { - type->tp_getset = NULL; - } + type->tp_getset = NULL; /* Special case some slots */ if (type->tp_dictoffset != 0 || ctx->nslot > 0) { @@ -4758,7 +4783,7 @@ type_new_set_attrs(const type_new_ctx *ctx, PyTypeObject *type) return -1; } - if (type_new_descriptors(ctx, type) < 0) { + if (type_new_descriptors(ctx, type, dict) < 0) { return -1; } @@ -6642,6 +6667,14 @@ _PyStaticType_FiniBuiltin(PyInterpreterState *interp, PyTypeObject *type) } +void +_PyTypes_FiniCachedDescriptors(PyInterpreterState *interp) +{ + Py_CLEAR(interp->cached_objects.dict_descriptor); + Py_CLEAR(interp->cached_objects.weakref_descriptor); +} + + static void type_dealloc(PyObject *self) { @@ -8332,16 +8365,7 @@ type_add_getset(PyTypeObject *type) PyObject *dict = lookup_tp_dict(type); for (; gsp->name != NULL; gsp++) { - PyTypeObject *descr_type = type; - // Hack: dict and weakref descriptors are created for `object`, - // rather than this specific type. - // We identify their PyGetSetDef by pointer equality on name. - if (gsp->name == subtype_getset_dict_name - || gsp->name == subtype_getset_weakref_name) - { - descr_type = &PyBaseObject_Type; - } - PyObject *descr = PyDescr_NewGetSet(descr_type, gsp); + PyObject *descr = PyDescr_NewGetSet(type, gsp); if (descr == NULL) { return -1; } diff --git a/Python/pylifecycle.c b/Python/pylifecycle.c index e22a9cc1c75050..b6b1d2845ec2f1 100644 --- a/Python/pylifecycle.c +++ b/Python/pylifecycle.c @@ -1906,6 +1906,7 @@ finalize_interp_clear(PyThreadState *tstate) _PyXI_Fini(tstate->interp); _PyExc_ClearExceptionGroupType(tstate->interp); _Py_clear_generic_types(tstate->interp); + _PyTypes_FiniCachedDescriptors(tstate->interp); /* Clear interpreter state and all thread states */ _PyInterpreterState_Clear(tstate); From dc541cdcfcd2f2c7acd788fb546b3b8dcfd6b3ea Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 30 Jul 2025 17:22:18 +0200 Subject: [PATCH 6/8] Adjust __repr__ of generic descriptors (ones whose __objclass__ is type) --- Objects/descrobject.c | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Objects/descrobject.c b/Objects/descrobject.c index d3d17e92b6d1e8..06a81a4fdbd865 100644 --- a/Objects/descrobject.c +++ b/Objects/descrobject.c @@ -39,41 +39,41 @@ descr_name(PyDescrObject *descr) } static PyObject * -descr_repr(PyDescrObject *descr, const char *format) +descr_repr(PyDescrObject *descr, const char *kind) { PyObject *name = NULL; if (descr->d_name != NULL && PyUnicode_Check(descr->d_name)) name = descr->d_name; - return PyUnicode_FromFormat(format, name, "?", descr->d_type->tp_name); + if (descr->d_type == &PyBaseObject_Type) { + return PyUnicode_FromFormat("<%s '%V'>", kind, name, "?"); + } + return PyUnicode_FromFormat("<%s '%V' of '%s' objects>", + kind, name, "?", descr->d_type->tp_name); } static PyObject * method_repr(PyObject *descr) { - return descr_repr((PyDescrObject *)descr, - ""); + return descr_repr((PyDescrObject *)descr, "method"); } static PyObject * member_repr(PyObject *descr) { - return descr_repr((PyDescrObject *)descr, - ""); + return descr_repr((PyDescrObject *)descr, "member"); } static PyObject * getset_repr(PyObject *descr) { - return descr_repr((PyDescrObject *)descr, - ""); + return descr_repr((PyDescrObject *)descr, "attribute"); } static PyObject * wrapperdescr_repr(PyObject *descr) { - return descr_repr((PyDescrObject *)descr, - ""); + return descr_repr((PyDescrObject *)descr, "slot wrapper"); } static int From 1e2fd8a8aca77b32dadcbc50aedf64afc9981d9b Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 30 Jul 2025 17:40:18 +0200 Subject: [PATCH 7/8] Add PyGetSetDef to c-analyzer's list of types --- Tools/c-analyzer/cpython/_analyzer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Tools/c-analyzer/cpython/_analyzer.py b/Tools/c-analyzer/cpython/_analyzer.py index 6204353e9bd26a..6f0f464892845f 100644 --- a/Tools/c-analyzer/cpython/_analyzer.py +++ b/Tools/c-analyzer/cpython/_analyzer.py @@ -67,6 +67,7 @@ 'PyMethodDef', 'PyMethodDef[]', 'PyMemberDef[]', + 'PyGetSetDef', 'PyGetSetDef[]', 'PyNumberMethods', 'PySequenceMethods', From 9a40a986f3d105542546d505b09a5c1115b90ac5 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Tue, 5 Aug 2025 10:25:42 +0200 Subject: [PATCH 8/8] Add blurb --- .../2025-08-05-10-22-15.gh-issue-136966.J5lrE0.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-08-05-10-22-15.gh-issue-136966.J5lrE0.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-08-05-10-22-15.gh-issue-136966.J5lrE0.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-08-05-10-22-15.gh-issue-136966.J5lrE0.rst new file mode 100644 index 00000000000000..cc40ba1b3ddee4 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-08-05-10-22-15.gh-issue-136966.J5lrE0.rst @@ -0,0 +1,3 @@ +The :attr:`type.__dict__` and :attr:`!__weakref__` descriptors now use a +single descriptor instance per interpreter, shared across all types that +need them.