Skip to content

Commit d4f8054

Browse files
authored
Add get_freezable, unset_freezable, and FreezabilityOverride context … (#89)
* Add get_freezable, unset_freezable, and FreezabilityOverride context manager Add C-level get_freezable() and unset_freezable() functions to query and clear per-object freezability status, and a Python-level FreezabilityOverride context manager that temporarily overrides an object's freezability. C API changes (Python/immutability.c, Include/cpython/immutability.h): - _PyImmutability_GetFreezable(obj): returns the effective freezable status (checking object, then ob_flags, then type, then type's ob_flags). Returns -1 if unset, -2 on error. - _PyImmutability_UnsetFreezable(obj): removes any per-object freezable status by deleting __freezable__ and clearing ob_flags. Includes a failing assert on 32-bit for the ob_flags path, matching the pattern in SetFreezable. Module changes (Modules/_immutablemodule.c): - Expose get_freezable() and unset_freezable() via Argument Clinic. Python changes (Lib/immutable.py): - Export get_freezable and unset_freezable. - Add FreezabilityOverride context manager. On enter, snapshots the effective status via get_freezable() and applies the override. On exit, restores the saved status via set_freezable(). Uses a "no-effort unset" design: always restores the effective status that was observed on entry, even if it was inherited from the type. This may leave an explicit per-object status where none existed before, but is simple, predictable, and correct for all object kinds including C types with custom tp_getattro. The alternative "best-effort unset" approach (using a get_direct_freezable that inspects __dict__ directly) was explored and rejected because it cannot reliably distinguish per-object from inherited status for C extension types with custom attribute storage. Tests (Lib/test/test_freeze/): - test_get_freezable.py: tests for get_freezable() (7 tests) and unset_freezable() (5 tests). - test_freezability_override.py: tests for FreezabilityOverride (10 tests) covering basic override/restore, freeze prevention, freeze allowance, exception safety, nested overrides, class-level override, and the no-effort unset behavior. * Lint fix. * Add version from paper.
1 parent 625693a commit d4f8054

7 files changed

Lines changed: 476 additions & 1 deletion

File tree

Include/cpython/immutability.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ PyAPI_FUNC(int) _PyImmutability_RegisterShallowImmutable(PyTypeObject*);
1515
PyAPI_FUNC(int) _PyImmutability_CanViewAsImmutable(PyObject*);
1616
PyAPI_FUNC(int) _PyImmutability_SetFreezable(PyObject *, _Py_freezable_status);
1717
PyAPI_FUNC(int) _PyImmutability_GetFreezable(PyObject *);
18+
PyAPI_FUNC(int) _PyImmutability_UnsetFreezable(PyObject *);

Lib/immutable.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
freeze = _c.freeze
1212
is_frozen = _c.is_frozen
1313
set_freezable = _c.set_freezable
14+
get_freezable = _c.get_freezable
15+
unset_freezable = _c.unset_freezable
1416
NotFreezableError = _c.NotFreezableError
1517
ImmutableModule = _c.ImmutableModule
1618
FREEZABLE_YES = _c.FREEZABLE_YES
@@ -50,6 +52,79 @@ def frozen(cls):
5052
return cls
5153

5254

55+
class FreezabilityOverride:
56+
"""Context manager to temporarily override an object's freezability.
57+
58+
On entry, saves the object's current effective freezability (which may
59+
be inherited from the type) and applies the requested override.
60+
On exit, restores the saved status via set_freezable().
61+
62+
Design note: on exit we always call set_freezable() with the value
63+
that get_freezable() returned on entry, even if that value was
64+
inherited from the type rather than set directly on the object.
65+
This means the context manager may leave a per-object status where
66+
none existed before. We considered two alternatives:
67+
68+
* "Best-effort unset": use a get_direct_freezable() that inspects
69+
only the object's own __dict__ / ob_flags, and call
70+
unset_freezable() when no direct status existed. This mostly
71+
works, but C types with custom tp_getattro cannot reliably
72+
distinguish per-object from inherited status, so the "direct"
73+
check is incomplete for some extension types.
74+
75+
* "No-effort unset" (chosen): always snapshot the effective status
76+
and restore it with set_freezable(). Simple, correct for all
77+
object kinds, and predictable. The trade-off is that after the
78+
context manager exits, the object will have an explicit
79+
per-object status even if it previously relied on inheritance.
80+
81+
Usage:
82+
with FreezabilityOverride(obj, FREEZABLE_NO):
83+
# obj is temporarily not freezable
84+
...
85+
# obj's original effective freezability is restored
86+
"""
87+
88+
def __init__(self, obj, status):
89+
self._obj = obj
90+
self._new_status = status
91+
self._old_status = None
92+
93+
def __enter__(self):
94+
self._old_status = get_freezable(self._obj)
95+
set_freezable(self._obj, self._new_status)
96+
return self._obj
97+
98+
def __exit__(self, exc_type, exc_val, exc_tb):
99+
if self._old_status == -1:
100+
# No status was set anywhere (not on object, not on type).
101+
# Restore to the default: FREEZABLE_YES.
102+
set_freezable(self._obj, FREEZABLE_YES)
103+
else:
104+
set_freezable(self._obj, self._old_status)
105+
return False
106+
107+
108+
class require_mutable(FreezabilityOverride):
109+
"""Context manager that ensures an object remains mutable (not freezable).
110+
111+
Raises TypeError if the object is already frozen.
112+
113+
Usage:
114+
with require_mutable(obj):
115+
obj.attr = value # guaranteed not to be frozen during this block
116+
"""
117+
118+
def __init__(self, obj):
119+
super().__init__(obj, FREEZABLE_NO)
120+
121+
def __enter__(self):
122+
if is_frozen(self._obj):
123+
raise TypeError(
124+
"cannot require mutability: object is already frozen")
125+
return super().__enter__()
126+
127+
53128
__all__ = [
54129
"freeze",
55130
"is_frozen",
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Tests for FreezabilityOverride and require_mutable context managers."""
2+
3+
import unittest
4+
from immutable import (
5+
freeze, is_frozen, set_freezable, get_freezable,
6+
FreezabilityOverride, require_mutable,
7+
FREEZABLE_YES, FREEZABLE_NO, FREEZABLE_EXPLICIT,
8+
)
9+
10+
11+
def make_freezable_class():
12+
"""Create a fresh class marked as freezable."""
13+
class C:
14+
pass
15+
set_freezable(C, FREEZABLE_YES)
16+
return C
17+
18+
19+
class TestFreezabilityOverride(unittest.TestCase):
20+
"""Tests for the FreezabilityOverride context manager."""
21+
22+
def test_basic_override_and_restore(self):
23+
C = make_freezable_class()
24+
obj = C()
25+
set_freezable(obj, FREEZABLE_YES)
26+
with FreezabilityOverride(obj, FREEZABLE_NO):
27+
self.assertEqual(get_freezable(obj), FREEZABLE_NO)
28+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
29+
30+
def test_override_prevents_freeze(self):
31+
C = make_freezable_class()
32+
obj = C()
33+
set_freezable(obj, FREEZABLE_YES)
34+
with FreezabilityOverride(obj, FREEZABLE_NO):
35+
with self.assertRaises(TypeError):
36+
freeze(obj)
37+
# After the context manager, freezing should work again.
38+
freeze(obj)
39+
self.assertTrue(is_frozen(obj))
40+
41+
def test_override_allows_freeze(self):
42+
C = make_freezable_class()
43+
obj = C()
44+
set_freezable(obj, FREEZABLE_NO)
45+
with FreezabilityOverride(obj, FREEZABLE_YES):
46+
freeze(obj)
47+
self.assertTrue(is_frozen(obj))
48+
49+
def test_override_to_explicit(self):
50+
C = make_freezable_class()
51+
obj = C()
52+
set_freezable(obj, FREEZABLE_YES)
53+
with FreezabilityOverride(obj, FREEZABLE_EXPLICIT):
54+
self.assertEqual(get_freezable(obj), FREEZABLE_EXPLICIT)
55+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
56+
57+
def test_restores_on_exception(self):
58+
C = make_freezable_class()
59+
obj = C()
60+
set_freezable(obj, FREEZABLE_YES)
61+
with self.assertRaises(RuntimeError):
62+
with FreezabilityOverride(obj, FREEZABLE_NO):
63+
self.assertEqual(get_freezable(obj), FREEZABLE_NO)
64+
raise RuntimeError("deliberate")
65+
# Status should be restored even after an exception.
66+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
67+
68+
def test_returns_obj_from_enter(self):
69+
C = make_freezable_class()
70+
obj = C()
71+
set_freezable(obj, FREEZABLE_YES)
72+
with FreezabilityOverride(obj, FREEZABLE_NO) as returned:
73+
self.assertIs(returned, obj)
74+
75+
def test_no_prior_status_restores_default(self):
76+
C = make_freezable_class()
77+
obj = C()
78+
# Object inherits YES from type, no per-object status.
79+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
80+
with FreezabilityOverride(obj, FREEZABLE_NO):
81+
self.assertEqual(get_freezable(obj), FREEZABLE_NO)
82+
# The inherited YES is restored (now set explicitly on the
83+
# object — this is the "no-effort unset" design choice).
84+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
85+
86+
def test_no_status_anywhere_restores_yes(self):
87+
class C:
88+
pass
89+
obj = C()
90+
self.assertEqual(get_freezable(obj), -1)
91+
with FreezabilityOverride(obj, FREEZABLE_NO):
92+
self.assertEqual(get_freezable(obj), FREEZABLE_NO)
93+
# When no status existed anywhere, restores to FREEZABLE_YES.
94+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
95+
96+
def test_nested_overrides(self):
97+
C = make_freezable_class()
98+
obj = C()
99+
set_freezable(obj, FREEZABLE_YES)
100+
with FreezabilityOverride(obj, FREEZABLE_NO):
101+
self.assertEqual(get_freezable(obj), FREEZABLE_NO)
102+
with FreezabilityOverride(obj, FREEZABLE_EXPLICIT):
103+
self.assertEqual(get_freezable(obj), FREEZABLE_EXPLICIT)
104+
self.assertEqual(get_freezable(obj), FREEZABLE_NO)
105+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
106+
107+
def test_override_on_class(self):
108+
C = make_freezable_class()
109+
self.assertEqual(get_freezable(C), FREEZABLE_YES)
110+
with FreezabilityOverride(C, FREEZABLE_NO):
111+
self.assertEqual(get_freezable(C), FREEZABLE_NO)
112+
self.assertEqual(get_freezable(C), FREEZABLE_YES)
113+
114+
115+
class TestRequireMutable(unittest.TestCase):
116+
"""Tests for the require_mutable context manager."""
117+
118+
def test_sets_freezable_no(self):
119+
C = make_freezable_class()
120+
obj = C()
121+
set_freezable(obj, FREEZABLE_YES)
122+
with require_mutable(obj):
123+
self.assertEqual(get_freezable(obj), FREEZABLE_NO)
124+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
125+
126+
def test_prevents_freeze_inside(self):
127+
C = make_freezable_class()
128+
obj = C()
129+
with require_mutable(obj):
130+
with self.assertRaises(TypeError):
131+
freeze(obj)
132+
133+
def test_raises_if_already_frozen(self):
134+
C = make_freezable_class()
135+
obj = C()
136+
freeze(obj)
137+
with self.assertRaises(TypeError):
138+
with require_mutable(obj):
139+
pass # should never reach here
140+
141+
def test_restores_on_exception(self):
142+
C = make_freezable_class()
143+
obj = C()
144+
set_freezable(obj, FREEZABLE_YES)
145+
with self.assertRaises(RuntimeError):
146+
with require_mutable(obj):
147+
raise RuntimeError("deliberate")
148+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
149+
150+
def test_returns_obj_from_enter(self):
151+
C = make_freezable_class()
152+
obj = C()
153+
with require_mutable(obj) as returned:
154+
self.assertIs(returned, obj)
155+
156+
157+
if __name__ == '__main__':
158+
unittest.main()
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Tests for get_freezable() and unset_freezable()."""
2+
3+
import unittest
4+
from immutable import (
5+
set_freezable, get_freezable, unset_freezable,
6+
FREEZABLE_YES, FREEZABLE_NO, FREEZABLE_EXPLICIT,
7+
)
8+
9+
10+
def make_freezable_class():
11+
"""Create a fresh class marked as freezable."""
12+
class C:
13+
pass
14+
set_freezable(C, FREEZABLE_YES)
15+
return C
16+
17+
18+
class TestGetFreezable(unittest.TestCase):
19+
"""Tests for get_freezable()."""
20+
21+
def test_no_status_set_returns_negative_one(self):
22+
class C:
23+
pass
24+
self.assertEqual(get_freezable(C()), -1)
25+
26+
def test_returns_yes(self):
27+
C = make_freezable_class()
28+
obj = C()
29+
set_freezable(obj, FREEZABLE_YES)
30+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
31+
32+
def test_returns_no(self):
33+
C = make_freezable_class()
34+
obj = C()
35+
set_freezable(obj, FREEZABLE_NO)
36+
self.assertEqual(get_freezable(obj), FREEZABLE_NO)
37+
38+
def test_returns_explicit(self):
39+
C = make_freezable_class()
40+
obj = C()
41+
set_freezable(obj, FREEZABLE_EXPLICIT)
42+
self.assertEqual(get_freezable(obj), FREEZABLE_EXPLICIT)
43+
44+
def test_reflects_override(self):
45+
C = make_freezable_class()
46+
obj = C()
47+
set_freezable(obj, FREEZABLE_YES)
48+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
49+
set_freezable(obj, FREEZABLE_NO)
50+
self.assertEqual(get_freezable(obj), FREEZABLE_NO)
51+
52+
def test_type_level_status(self):
53+
C = make_freezable_class()
54+
# The class has FREEZABLE_YES; an instance with no per-object
55+
# status should inherit from the type.
56+
obj = C()
57+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
58+
59+
def test_object_status_overrides_type(self):
60+
C = make_freezable_class()
61+
obj = C()
62+
set_freezable(obj, FREEZABLE_NO)
63+
# Per-object status should take precedence over the type's.
64+
self.assertEqual(get_freezable(obj), FREEZABLE_NO)
65+
# The type itself should still be YES.
66+
self.assertEqual(get_freezable(C), FREEZABLE_YES)
67+
68+
69+
class TestUnsetFreezable(unittest.TestCase):
70+
"""Tests for unset_freezable()."""
71+
72+
def test_unset_removes_per_object_status(self):
73+
C = make_freezable_class()
74+
obj = C()
75+
set_freezable(obj, FREEZABLE_NO)
76+
self.assertEqual(get_freezable(obj), FREEZABLE_NO)
77+
unset_freezable(obj)
78+
# Falls back to the type's status (FREEZABLE_YES).
79+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
80+
81+
def test_unset_when_nothing_set(self):
82+
class C:
83+
pass
84+
obj = C()
85+
# Should not raise even if nothing was set.
86+
unset_freezable(obj)
87+
self.assertEqual(get_freezable(obj), -1)
88+
89+
def test_unset_reveals_type_status(self):
90+
C = make_freezable_class()
91+
obj = C()
92+
set_freezable(obj, FREEZABLE_EXPLICIT)
93+
self.assertEqual(get_freezable(obj), FREEZABLE_EXPLICIT)
94+
unset_freezable(obj)
95+
# Now the type's YES should show through.
96+
self.assertEqual(get_freezable(obj), FREEZABLE_YES)
97+
98+
def test_unset_on_class(self):
99+
class C:
100+
pass
101+
set_freezable(C, FREEZABLE_NO)
102+
self.assertEqual(get_freezable(C), FREEZABLE_NO)
103+
unset_freezable(C)
104+
# After unsetting, falls back to the metaclass (type) status.
105+
# type may or may not have __freezable__ set depending on
106+
# interpreter state, so just verify it's no longer NO.
107+
self.assertNotEqual(get_freezable(C), FREEZABLE_NO)
108+
109+
def test_unset_then_set_again(self):
110+
C = make_freezable_class()
111+
obj = C()
112+
set_freezable(obj, FREEZABLE_NO)
113+
unset_freezable(obj)
114+
set_freezable(obj, FREEZABLE_EXPLICIT)
115+
self.assertEqual(get_freezable(obj), FREEZABLE_EXPLICIT)
116+
117+
118+
if __name__ == '__main__':
119+
unittest.main()

0 commit comments

Comments
 (0)