From 027f629dd5711be1348e5461177b3de7ed0dc44e Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sun, 20 Jul 2025 16:57:40 -0700 Subject: [PATCH 1/3] 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 e0d61f7a8655e7a3f1fcc035e3c1b9fc4af4d9b4 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 21 Jul 2025 19:20:36 -0700 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Alyssa Coghlan --- Lib/dataclasses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 9a1d5071e4157c..d8bb6976aa7095 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1339,9 +1339,10 @@ 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 old_cls_dict = cls.__dict__ | _deproxier - old_cls_dict.pop('__weakref__', None) old_cls_dict.pop('__dict__', None) + del cls.__weakref__ return newcls From d5450023143fc6cb5229b37ddf616e222d2d99e4 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 21 Jul 2025 19:29:43 -0700 Subject: [PATCH 3/3] Update Lib/dataclasses.py --- Lib/dataclasses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index d8bb6976aa7095..22b78bb2fbe6ed 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1342,7 +1342,8 @@ def _add_slots(cls, is_frozen, weakref_slot, defined_fields): # Bypass mapping proxy to allow __dict__ to be removed old_cls_dict = cls.__dict__ | _deproxier old_cls_dict.pop('__dict__', None) - del cls.__weakref__ + if "__weakref__" in cls.__dict__: + del cls.__weakref__ return newcls