Skip to content

Commit aa1e00f

Browse files
gpsheadPrometheus3375claude
committed
[3.13] gh-105936: Properly update closure cells for __setattr__ and __delattr__ in frozen dataclasses with slots (GH-144021)
gh-105936: Properly update closure cells for `__setattr__` and `__delattr__` in frozen dataclasses with slots (GH-144021) (cherry picked from commit 8a398bf) The cherry-pick required additional changes beyond the original commit because 3.13 lacks the `__class__` closure cell fixup machinery that was added in 3.14 by GH-124455 (gh-90562). Specifically: - Backported `_update_func_cell_for__class__()` helper function and the closure fixup loop in `_add_slots()` from GH-124455. Without these, renaming the closure variable from `cls` to `__class__` has no effect because nothing updates the cell when the class is recreated with slots. - Changed `_add_slots()` to use `newcls` instead of reusing `cls` for the recreated class, so both old and new class references are available for the fixup loop. - Replaced `assertNotHasAttr` with `assertFalse(hasattr(...))` in tests (assertNotHasAttr was added in 3.14). - Dropped `test_original_class_is_gced` additions (that test does not exist on 3.13; it was added by GH-137047 for gh-135228 which was not backported to 3.13). Co-authored-by: Prometheus3375 <prometheus3375@gmail.com> Co-authored-by: Sviataslau <35541026+Prometheus3375@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a268d3f commit aa1e00f

File tree

3 files changed

+121
-62
lines changed

3 files changed

+121
-62
lines changed

Lib/dataclasses.py

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -665,25 +665,25 @@ def _init_fn(fields, std_fields, kw_only_fields, frozen, has_post_init,
665665
return_type=None)
666666

667667

668-
def _frozen_get_del_attr(cls, fields, func_builder):
669-
locals = {'cls': cls,
668+
def _frozen_set_del_attr(cls, fields, func_builder):
669+
locals = {'__class__': cls,
670670
'FrozenInstanceError': FrozenInstanceError}
671-
condition = 'type(self) is cls'
671+
condition = 'type(self) is __class__'
672672
if fields:
673673
condition += ' or name in {' + ', '.join(repr(f.name) for f in fields) + '}'
674674

675675
func_builder.add_fn('__setattr__',
676676
('self', 'name', 'value'),
677677
(f' if {condition}:',
678678
' raise FrozenInstanceError(f"cannot assign to field {name!r}")',
679-
f' super(cls, self).__setattr__(name, value)'),
679+
f' super(__class__, self).__setattr__(name, value)'),
680680
locals=locals,
681681
overwrite_error=True)
682682
func_builder.add_fn('__delattr__',
683683
('self', 'name'),
684684
(f' if {condition}:',
685685
' raise FrozenInstanceError(f"cannot delete field {name!r}")',
686-
f' super(cls, self).__delattr__(name)'),
686+
f' super(__class__, self).__delattr__(name)'),
687687
locals=locals,
688688
overwrite_error=True)
689689

@@ -1141,7 +1141,7 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen,
11411141
overwrite_error='Consider using functools.total_ordering')
11421142

11431143
if frozen:
1144-
_frozen_get_del_attr(cls, field_list, func_builder)
1144+
_frozen_set_del_attr(cls, field_list, func_builder)
11451145

11461146
# Decide if/how we're going to create a hash function.
11471147
hash_action = _hash_action[bool(unsafe_hash),
@@ -1219,6 +1219,27 @@ def _get_slots(cls):
12191219
raise TypeError(f"Slots of '{cls.__name__}' cannot be determined")
12201220

12211221

1222+
def _update_func_cell_for__class__(f, oldcls, newcls):
1223+
# Returns True if we update a cell, else False.
1224+
if f is None:
1225+
# f will be None in the case of a property where not all of
1226+
# fget, fset, and fdel are used. Nothing to do in that case.
1227+
return False
1228+
try:
1229+
idx = f.__code__.co_freevars.index("__class__")
1230+
except ValueError:
1231+
# This function doesn't reference __class__, so nothing to do.
1232+
return False
1233+
# Fix the cell to point to the new class, if it's already pointing
1234+
# at the old class. I'm not convinced that the "is oldcls" test
1235+
# is needed, but other than performance can't hurt.
1236+
closure = f.__closure__[idx]
1237+
if closure.cell_contents is oldcls:
1238+
closure.cell_contents = newcls
1239+
return True
1240+
return False
1241+
1242+
12221243
def _add_slots(cls, is_frozen, weakref_slot):
12231244
# Need to create a new class, since we can't set __slots__
12241245
# after a class has been created.
@@ -1260,18 +1281,37 @@ def _add_slots(cls, is_frozen, weakref_slot):
12601281

12611282
# And finally create the class.
12621283
qualname = getattr(cls, '__qualname__', None)
1263-
cls = type(cls)(cls.__name__, cls.__bases__, cls_dict)
1284+
newcls = type(cls)(cls.__name__, cls.__bases__, cls_dict)
12641285
if qualname is not None:
1265-
cls.__qualname__ = qualname
1286+
newcls.__qualname__ = qualname
12661287

12671288
if is_frozen:
12681289
# Need this for pickling frozen classes with slots.
12691290
if '__getstate__' not in cls_dict:
1270-
cls.__getstate__ = _dataclass_getstate
1291+
newcls.__getstate__ = _dataclass_getstate
12711292
if '__setstate__' not in cls_dict:
1272-
cls.__setstate__ = _dataclass_setstate
1273-
1274-
return cls
1293+
newcls.__setstate__ = _dataclass_setstate
1294+
1295+
# Fix up any closures which reference __class__. This is used to
1296+
# fix zero argument super so that it points to the correct class
1297+
# (the newly created one, which we're returning) and not the
1298+
# original class. We can break out of this loop as soon as we
1299+
# make an update, since all closures for a class will share a
1300+
# given cell.
1301+
for member in newcls.__dict__.values():
1302+
# If this is a wrapped function, unwrap it.
1303+
member = inspect.unwrap(member)
1304+
1305+
if isinstance(member, types.FunctionType):
1306+
if _update_func_cell_for__class__(member, cls, newcls):
1307+
break
1308+
elif isinstance(member, property):
1309+
if (_update_func_cell_for__class__(member.fget, cls, newcls)
1310+
or _update_func_cell_for__class__(member.fset, cls, newcls)
1311+
or _update_func_cell_for__class__(member.fdel, cls, newcls)):
1312+
break
1313+
1314+
return newcls
12751315

12761316

12771317
def dataclass(cls=None, /, *, init=True, repr=True, eq=True, order=False,

Lib/test/test_dataclasses/__init__.py

Lines changed: 64 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2865,29 +2865,41 @@ class C(base):
28652865

28662866

28672867
class TestFrozen(unittest.TestCase):
2868+
# Some tests have a subtest with a slotted dataclass.
2869+
# See https://github.com/python/cpython/issues/105936 for the reasons.
2870+
28682871
def test_frozen(self):
2869-
@dataclass(frozen=True)
2870-
class C:
2871-
i: int
2872+
for slots in (False, True):
2873+
with self.subTest(slots=slots):
28722874

2873-
c = C(10)
2874-
self.assertEqual(c.i, 10)
2875-
with self.assertRaises(FrozenInstanceError):
2876-
c.i = 5
2877-
self.assertEqual(c.i, 10)
2875+
@dataclass(frozen=True, slots=slots)
2876+
class C:
2877+
i: int
2878+
2879+
c = C(10)
2880+
self.assertEqual(c.i, 10)
2881+
with self.assertRaises(FrozenInstanceError):
2882+
c.i = 5
2883+
self.assertEqual(c.i, 10)
2884+
with self.assertRaises(FrozenInstanceError):
2885+
del c.i
2886+
self.assertEqual(c.i, 10)
28782887

28792888
def test_frozen_empty(self):
2880-
@dataclass(frozen=True)
2881-
class C:
2882-
pass
2889+
for slots in (False, True):
2890+
with self.subTest(slots=slots):
28832891

2884-
c = C()
2885-
self.assertFalse(hasattr(c, 'i'))
2886-
with self.assertRaises(FrozenInstanceError):
2887-
c.i = 5
2888-
self.assertFalse(hasattr(c, 'i'))
2889-
with self.assertRaises(FrozenInstanceError):
2890-
del c.i
2892+
@dataclass(frozen=True, slots=slots)
2893+
class C:
2894+
pass
2895+
2896+
c = C()
2897+
self.assertFalse(hasattr(c, 'i'))
2898+
with self.assertRaises(FrozenInstanceError):
2899+
c.i = 5
2900+
self.assertFalse(hasattr(c, 'i'))
2901+
with self.assertRaises(FrozenInstanceError):
2902+
del c.i
28912903

28922904
def test_inherit(self):
28932905
@dataclass(frozen=True)
@@ -3083,41 +3095,43 @@ class D(I):
30833095
d.i = 5
30843096

30853097
def test_non_frozen_normal_derived(self):
3086-
# See bpo-32953.
3087-
3088-
@dataclass(frozen=True)
3089-
class D:
3090-
x: int
3091-
y: int = 10
3092-
3093-
class S(D):
3094-
pass
3098+
# See bpo-32953 and https://github.com/python/cpython/issues/105936
3099+
for slots in (False, True):
3100+
with self.subTest(slots=slots):
30953101

3096-
s = S(3)
3097-
self.assertEqual(s.x, 3)
3098-
self.assertEqual(s.y, 10)
3099-
s.cached = True
3102+
@dataclass(frozen=True, slots=slots)
3103+
class D:
3104+
x: int
3105+
y: int = 10
31003106

3101-
# But can't change the frozen attributes.
3102-
with self.assertRaises(FrozenInstanceError):
3103-
s.x = 5
3104-
with self.assertRaises(FrozenInstanceError):
3105-
s.y = 5
3106-
self.assertEqual(s.x, 3)
3107-
self.assertEqual(s.y, 10)
3108-
self.assertEqual(s.cached, True)
3107+
class S(D):
3108+
pass
31093109

3110-
with self.assertRaises(FrozenInstanceError):
3111-
del s.x
3112-
self.assertEqual(s.x, 3)
3113-
with self.assertRaises(FrozenInstanceError):
3114-
del s.y
3115-
self.assertEqual(s.y, 10)
3116-
del s.cached
3117-
self.assertFalse(hasattr(s, 'cached'))
3118-
with self.assertRaises(AttributeError) as cm:
3119-
del s.cached
3120-
self.assertNotIsInstance(cm.exception, FrozenInstanceError)
3110+
s = S(3)
3111+
self.assertEqual(s.x, 3)
3112+
self.assertEqual(s.y, 10)
3113+
s.cached = True
3114+
3115+
# But can't change the frozen attributes.
3116+
with self.assertRaises(FrozenInstanceError):
3117+
s.x = 5
3118+
with self.assertRaises(FrozenInstanceError):
3119+
s.y = 5
3120+
self.assertEqual(s.x, 3)
3121+
self.assertEqual(s.y, 10)
3122+
self.assertEqual(s.cached, True)
3123+
3124+
with self.assertRaises(FrozenInstanceError):
3125+
del s.x
3126+
self.assertEqual(s.x, 3)
3127+
with self.assertRaises(FrozenInstanceError):
3128+
del s.y
3129+
self.assertEqual(s.y, 10)
3130+
del s.cached
3131+
self.assertFalse(hasattr(s, 'cached'))
3132+
with self.assertRaises(AttributeError) as cm:
3133+
del s.cached
3134+
self.assertNotIsInstance(cm.exception, FrozenInstanceError)
31213135

31223136
def test_non_frozen_normal_derived_from_empty_frozen(self):
31233137
@dataclass(frozen=True)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Attempting to mutate non-field attributes of :mod:`dataclasses`
2+
with both *frozen* and *slots* being ``True`` now raises
3+
:class:`~dataclasses.FrozenInstanceError` instead of :class:`TypeError`.
4+
Their non-dataclass subclasses can now freely mutate non-field attributes,
5+
and the original non-slotted class can be garbage collected.

0 commit comments

Comments
 (0)