diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 730ced7299865e..e4656dd7944000 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -725,10 +725,10 @@ def _init_fn(fields, std_fields, kw_only_fields, frozen, has_post_init, annotation_fields=annotation_fields) -def _frozen_get_del_attr(cls, fields, func_builder): - locals = {'cls': cls, +def _frozen_set_del_attr(cls, fields, func_builder): + locals = {'__class__': cls, 'FrozenInstanceError': FrozenInstanceError} - condition = 'type(self) is cls' + condition = 'type(self) is __class__' if fields: condition += ' or name in {' + ', '.join(repr(f.name) for f in fields) + '}' @@ -736,14 +736,14 @@ def _frozen_get_del_attr(cls, fields, func_builder): ('self', 'name', 'value'), (f' if {condition}:', ' raise FrozenInstanceError(f"cannot assign to field {name!r}")', - f' super(cls, self).__setattr__(name, value)'), + f' super(__class__, self).__setattr__(name, value)'), locals=locals, overwrite_error=True) func_builder.add_fn('__delattr__', ('self', 'name'), (f' if {condition}:', ' raise FrozenInstanceError(f"cannot delete field {name!r}")', - f' super(cls, self).__delattr__(name)'), + f' super(__class__, self).__delattr__(name)'), locals=locals, overwrite_error=True) @@ -1199,7 +1199,7 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, overwrite_error='Consider using functools.total_ordering') if frozen: - _frozen_get_del_attr(cls, field_list, func_builder) + _frozen_set_del_attr(cls, field_list, func_builder) # Decide if/how we're going to create a hash function. hash_action = _hash_action[bool(unsafe_hash), diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 3b335429b98500..c93b7965e16c61 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -3076,6 +3076,20 @@ class C: with self.assertRaises(FrozenInstanceError): del c.i + def test_frozen_slotted(self): + # See https://github.com/python/cpython/pull/144021 + @dataclass(frozen=True, slots=True) + class C: + pass + + c = C() + # Mutating not defined fields must raise FrozenInstanceError. + with self.assertRaises(FrozenInstanceError): + c.any_field = 5 + + with self.assertRaises(FrozenInstanceError): + del c.any_field + def test_inherit(self): @dataclass(frozen=True) class C: @@ -3405,6 +3419,33 @@ class C: c = C('hello') self.assertEqual(deepcopy(c), c) + def test_slotted_set_del_attr_reference_new_class_via__class__(self): + # See https://github.com/python/cpython/pull/144021 + @dataclass(frozen=True, slots=True) + class SetDelAttrTest: + pass + + for method_name in ('__setattr__', '__delattr__'): + with self.subTest(method_name=method_name): + method = getattr(SetDelAttrTest, method_name) + cell_idx = method.__code__.co_freevars.index('__class__') + reference = method.__closure__[cell_idx].cell_contents + self.assertIs(reference, SetDelAttrTest) + + def test_slotted_set_del_attr_do_not_reference_old_class(self): + # See https://github.com/python/cpython/pull/144021 + class SetDelAttrTest: + pass + + OriginalCls = SetDelAttrTest + SetDelAttrTest = dataclass(frozen=True, slots=True)(SetDelAttrTest) + + for method_name in ('__setattr__', '__delattr__'): + with self.subTest(method_name=method_name): + method = getattr(SetDelAttrTest, method_name) + cell_contents = [cell.cell_contents for cell in method.__closure__] + self.assertNotIn(OriginalCls, cell_contents) + class TestSlots(unittest.TestCase): def test_simple(self): @@ -3971,6 +4012,14 @@ class SlotsTest: return SlotsTest + # See https://github.com/python/cpython/issues/135228#issuecomment-3755979059 + def make_frozen(): + @dataclass(frozen=True, slots=True) + class SlotsTest: + pass + + return SlotsTest + def make_with_annotations(): @dataclass(slots=True) class SlotsTest: @@ -3996,7 +4045,7 @@ class SlotsTest: return SlotsTest - for make in (make_simple, make_with_annotations, make_with_annotations_and_method, make_with_forwardref): + for make in (make_simple, make_frozen, make_with_annotations, make_with_annotations_and_method, make_with_forwardref): with self.subTest(make=make): C = make() support.gc_collect() diff --git a/Misc/NEWS.d/next/Library/2026-01-19-21-23-18.gh-issue-135228.dGrzjM.rst b/Misc/NEWS.d/next/Library/2026-01-19-21-23-18.gh-issue-135228.dGrzjM.rst new file mode 100644 index 00000000000000..26a1061572a71e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-01-19-21-23-18.gh-issue-135228.dGrzjM.rst @@ -0,0 +1,4 @@ +When both *frozen* and *slots* are set to ``True`` +in :func:`~dataclasses.dataclass`, added methods +``__setattr__`` and ``__delattr__`` were still referencing the original class, +preventing it from being garbage collected and causing unexpected exceptions.