Skip to content

Commit 7275308

Browse files
mjp41xFrednet
andauthored
Add InterpreterLocal type as escape hatch from freeze system (#82)
InterpreterLocal provides per-sub-interpreter mutable state behind an immutable indirection. When an object graph is frozen, InterpreterLocal appears immutable to the freezer via tp_reachable (which visits only the type and frozen default/factory fields), while each sub-interpreter independently reads and writes its own value through get()/set() methods. Two construction forms: InterpreterLocal(42) - immutable default (frozen at construction) InterpreterLocal(lambda: []) - factory callable (frozen at construction) The type is a heap type created via PyType_FromModuleAndSpec, with per-interpreter storage in the _immutable module's per-interpreter state. This avoids changes to core interpreter structures. Co-authored-by: Fridtjof Stoldt <xFrednet@gmail.com>
1 parent 5f1eae2 commit 7275308

3 files changed

Lines changed: 439 additions & 0 deletions

File tree

Lib/immutable.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
FREEZABLE_NO = _c.FREEZABLE_NO
1818
FREEZABLE_EXPLICIT = _c.FREEZABLE_EXPLICIT
1919
FREEZABLE_PROXY = _c.FREEZABLE_PROXY
20+
InterpreterLocal = _c.InterpreterLocal
2021

2122
# FIXME(immutable): For the longest time we used the name `isfrozen`
2223
# without the underscore. This keeps the function name for now, but
@@ -58,6 +59,7 @@ def frozen(cls):
5859
"FREEZABLE_NO",
5960
"FREEZABLE_EXPLICIT",
6061
"FREEZABLE_PROXY",
62+
"InterpreterLocal",
6163
"freezable",
6264
"unfreezable",
6365
"explicitlyFreezable",
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import os
2+
import unittest
3+
from immutable import freeze, is_frozen, InterpreterLocal
4+
from test.support import import_helper
5+
6+
7+
class TestInterpreterLocalBasic(unittest.TestCase):
8+
"""Test basic InterpreterLocal with immutable default value."""
9+
10+
def test_get_returns_default(self):
11+
field = InterpreterLocal(42)
12+
self.assertEqual(field.get(), 42)
13+
14+
def test_set(self):
15+
field = InterpreterLocal(42)
16+
field.set(99)
17+
self.assertEqual(field.get(), 99)
18+
19+
def test_get_consistent(self):
20+
field = InterpreterLocal("hello")
21+
self.assertIs(field.get(), field.get())
22+
23+
def test_none_default(self):
24+
field = InterpreterLocal(None)
25+
self.assertIsNone(field.get())
26+
27+
def test_tuple_default(self):
28+
t = (1, 2, 3)
29+
field = InterpreterLocal(t)
30+
self.assertEqual(field.get(), (1, 2, 3))
31+
32+
33+
class TestInterpreterLocalFactory(unittest.TestCase):
34+
"""Test InterpreterLocal with factory callable."""
35+
36+
def test_factory_returns_new_value(self):
37+
field = InterpreterLocal(lambda: [])
38+
result = field.get()
39+
self.assertIsInstance(result, list)
40+
self.assertEqual(result, [])
41+
42+
def test_factory_called_once(self):
43+
field = InterpreterLocal(lambda: [])
44+
first = field.get()
45+
second = field.get()
46+
self.assertIs(first, second)
47+
48+
def test_factory_value_is_mutable(self):
49+
field = InterpreterLocal(lambda: [])
50+
field.get().append(1)
51+
self.assertEqual(field.get(), [1])
52+
53+
def test_factory_set_overrides(self):
54+
field = InterpreterLocal(lambda: [])
55+
field.get() # initialise
56+
field.set([1, 2, 3])
57+
self.assertEqual(field.get(), [1, 2, 3])
58+
59+
60+
class TestInterpreterLocalFreeze(unittest.TestCase):
61+
"""Test that InterpreterLocal works within frozen object graphs."""
62+
63+
def test_freeze_object_with_interpreterlocal(self):
64+
class Container:
65+
pass
66+
67+
c = Container()
68+
c.field = InterpreterLocal(42)
69+
freeze(c)
70+
self.assertTrue(is_frozen(c))
71+
72+
def test_value_accessible_after_freeze(self):
73+
class Container:
74+
pass
75+
76+
c = Container()
77+
c.field = InterpreterLocal(42)
78+
freeze(c)
79+
self.assertEqual(c.field.get(), 42)
80+
81+
def test_value_mutable_after_freeze(self):
82+
class Container:
83+
pass
84+
85+
c = Container()
86+
c.field = InterpreterLocal(42)
87+
freeze(c)
88+
c.field.set(99)
89+
self.assertEqual(c.field.get(), 99)
90+
91+
def test_factory_works_after_freeze(self):
92+
class Container:
93+
pass
94+
95+
c = Container()
96+
c.field = InterpreterLocal(lambda: {})
97+
freeze(c)
98+
result = c.field.get()
99+
self.assertIsInstance(result, dict)
100+
101+
def test_interpreterlocal_itself_frozen(self):
102+
field = InterpreterLocal(42)
103+
freeze(field)
104+
self.assertTrue(is_frozen(field))
105+
106+
def test_factory_result_mutable_after_freeze(self):
107+
class Container:
108+
pass
109+
110+
c = Container()
111+
c.field = InterpreterLocal(lambda: [])
112+
freeze(c)
113+
c.field.get().append("item")
114+
self.assertEqual(c.field.get(), ["item"])
115+
116+
117+
class TestInterpreterLocalErrors(unittest.TestCase):
118+
"""Test error cases."""
119+
120+
def test_no_args(self):
121+
with self.assertRaises(TypeError):
122+
InterpreterLocal()
123+
124+
def test_multiple_independent_fields(self):
125+
f1 = InterpreterLocal(1)
126+
f2 = InterpreterLocal(2)
127+
self.assertEqual(f1.get(), 1)
128+
self.assertEqual(f2.get(), 2)
129+
f1.set(10)
130+
self.assertEqual(f1.get(), 10)
131+
self.assertEqual(f2.get(), 2)
132+
133+
def test_non_freezable_default(self):
134+
"""Non-freezable default should raise at construction."""
135+
from immutable import set_freezable, FREEZABLE_NO
136+
class NF:
137+
pass
138+
obj = NF()
139+
set_freezable(obj, FREEZABLE_NO)
140+
with self.assertRaises(TypeError):
141+
InterpreterLocal(obj)
142+
143+
def test_non_freezable_factory(self):
144+
"""Non-freezable factory should raise at construction."""
145+
from immutable import set_freezable, FREEZABLE_NO
146+
def factory():
147+
return []
148+
set_freezable(factory, FREEZABLE_NO)
149+
with self.assertRaises(TypeError):
150+
InterpreterLocal(factory)
151+
152+
153+
class TestInterpreterLocalSubinterpreters(unittest.TestCase):
154+
"""Test that InterpreterLocal provides per-interpreter isolation."""
155+
156+
def setUp(self):
157+
self._interpreters = import_helper.import_module('_interpreters')
158+
159+
def _run_in_subinterp(self, code, shared=None):
160+
r, w = os.pipe()
161+
wrapped = (
162+
"import contextlib, os\n"
163+
f"with open({w}, 'w', encoding='utf-8') as spipe:\n"
164+
" with contextlib.redirect_stdout(spipe):\n"
165+
)
166+
for line in code.splitlines():
167+
wrapped += " " + line + "\n"
168+
169+
interp = self._interpreters.create()
170+
try:
171+
self._interpreters.run_string(
172+
interp, wrapped, shared=shared or {})
173+
finally:
174+
with os.fdopen(r, encoding='utf-8') as rpipe:
175+
result = rpipe.read()
176+
self._interpreters.destroy(interp)
177+
return result
178+
179+
def test_shared_frozen_object_gets_default_in_subinterp(self):
180+
"""A frozen InterpreterLocal shared to a sub-interpreter
181+
should return the default value there, not main's value."""
182+
field = InterpreterLocal(42)
183+
field.set(999)
184+
self.assertEqual(field.get(), 999)
185+
186+
# Freeze so it can be shared directly (immutable sharing)
187+
freeze(field)
188+
189+
output = self._run_in_subinterp(
190+
"print(field.get())\n",
191+
shared={"field": field},
192+
)
193+
self.assertEqual(output.strip(), "42")
194+
195+
def test_shared_frozen_object_set_independent(self):
196+
"""Setting a value in the sub-interpreter should not affect main."""
197+
field = InterpreterLocal(0)
198+
freeze(field)
199+
200+
self._run_in_subinterp(
201+
"field.set(123)\n"
202+
"print(field.get())\n",
203+
shared={"field": field},
204+
)
205+
# Main interpreter's value should still be 0
206+
self.assertEqual(field.get(), 0)
207+
208+
def test_shared_frozen_container_with_interpreterlocal(self):
209+
"""A frozen container with an InterpreterLocal field should
210+
provide per-interpreter isolation when shared."""
211+
class Container:
212+
pass
213+
214+
c = Container()
215+
c.counter = InterpreterLocal(lambda: [])
216+
freeze(c)
217+
218+
# Main interpreter uses the field
219+
c.counter.get().append("main")
220+
self.assertEqual(c.counter.get(), ["main"])
221+
222+
# Sub-interpreter gets its own fresh list from the factory
223+
output = self._run_in_subinterp(
224+
"c.counter.get().append('sub')\n"
225+
"print(c.counter.get())\n",
226+
shared={"c": c},
227+
)
228+
self.assertEqual(output.strip(), "['sub']")
229+
230+
# Main should be unaffected
231+
self.assertEqual(c.counter.get(), ["main"])
232+
233+
234+
if __name__ == "__main__":
235+
unittest.main()

0 commit comments

Comments
 (0)