Skip to content

Commit 625693a

Browse files
authored
Add SharedField type as shared escape hatch from freeze system (#88)
* Add SharedField type as shared escape hatch from freeze system SharedField provides a mutable field inside a frozen object that only holds frozen values. Unlike InterpreterLocal (which gives each sub-interpreter its own independent value), SharedField is truly shared: writes from one sub-interpreter are visible to all others. API: get() - read the current value set(val) - replace the value (must be frozen) swap(val) - replace and return the old value (must be frozen) compare_and_swap(old, new) - CAS: replace only if current value is old All operations are protected by a PyMutex embedded in the object. A PyMutex is used rather than Py_BEGIN_CRITICAL_SECTION because the latter is a no-op on GIL builds, but SharedField can be accessed concurrently from different sub-interpreters (each with its own GIL). tp_reachable visits the stored value (unlike InterpreterLocal which hides its per-interpreter values), since SharedField values are always frozen and belong to the shared immutable graph. set() is implemented in terms of swap() to avoid duplication. Files changed: Modules/_immutablemodule.c - SharedField type implementation Lib/immutable.py - Re-export SharedField Lib/test/test_freeze/test_sharedfield.py - Tests including cross-interpreter sharing, CAS semantics, and frozen-value enforcement * Change reachable * Change reachable * Don't visit value as could cause issues trying to collect state between interpreters.
1 parent 7275308 commit 625693a

3 files changed

Lines changed: 458 additions & 1 deletion

File tree

Lib/immutable.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
FREEZABLE_EXPLICIT = _c.FREEZABLE_EXPLICIT
1919
FREEZABLE_PROXY = _c.FREEZABLE_PROXY
2020
InterpreterLocal = _c.InterpreterLocal
21+
SharedField = _c.SharedField
2122

2223
# FIXME(immutable): For the longest time we used the name `isfrozen`
2324
# without the underscore. This keeps the function name for now, but
@@ -60,6 +61,7 @@ def frozen(cls):
6061
"FREEZABLE_EXPLICIT",
6162
"FREEZABLE_PROXY",
6263
"InterpreterLocal",
64+
"SharedField",
6365
"freezable",
6466
"unfreezable",
6567
"explicitlyFreezable",
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import os
2+
import unittest
3+
from immutable import freeze, is_frozen, SharedField, set_freezable, FREEZABLE_NO
4+
from test.support import import_helper
5+
6+
7+
class TestSharedFieldBasic(unittest.TestCase):
8+
"""Test basic SharedField with frozen values."""
9+
10+
def test_get_returns_initial(self):
11+
sf = SharedField(42)
12+
self.assertEqual(sf.get(), 42)
13+
14+
def test_set_frozen_value(self):
15+
sf = SharedField(42)
16+
sf.set(99)
17+
self.assertEqual(sf.get(), 99)
18+
19+
def test_set_none(self):
20+
sf = SharedField(42)
21+
sf.set(None)
22+
self.assertIsNone(sf.get())
23+
24+
def test_set_frozen_tuple(self):
25+
sf = SharedField(())
26+
sf.set((1, 2, 3))
27+
self.assertEqual(sf.get(), (1, 2, 3))
28+
29+
def test_set_rejects_mutable(self):
30+
sf = SharedField(42)
31+
with self.assertRaises(TypeError):
32+
sf.set([1, 2, 3])
33+
34+
def test_initial_value_is_frozen(self):
35+
sf = SharedField(42)
36+
self.assertTrue(is_frozen(sf.get()))
37+
38+
def test_get_consistent(self):
39+
sf = SharedField("hello")
40+
self.assertIs(sf.get(), sf.get())
41+
42+
43+
class TestSharedFieldSwap(unittest.TestCase):
44+
"""Test the unconditional swap operation."""
45+
46+
def test_swap_returns_old_value(self):
47+
sf = SharedField(42)
48+
old = sf.swap(99)
49+
self.assertEqual(old, 42)
50+
self.assertEqual(sf.get(), 99)
51+
52+
def test_swap_rejects_mutable(self):
53+
sf = SharedField(42)
54+
with self.assertRaises(TypeError):
55+
sf.swap([])
56+
57+
58+
class TestSharedFieldCompareAndSwap(unittest.TestCase):
59+
"""Test the atomic compare-and-swap operation."""
60+
61+
def test_cas_succeeds(self):
62+
sf = SharedField(42)
63+
old = sf.get()
64+
result = sf.compare_and_swap(old, 99)
65+
self.assertTrue(result)
66+
self.assertEqual(sf.get(), 99)
67+
68+
def test_cas_fails_wrong_expected(self):
69+
sf = SharedField(42)
70+
result = sf.compare_and_swap(0, 99)
71+
self.assertFalse(result)
72+
self.assertEqual(sf.get(), 42)
73+
74+
def test_cas_rejects_mutable_new(self):
75+
sf = SharedField(42)
76+
with self.assertRaises(TypeError):
77+
sf.compare_and_swap(42, [])
78+
79+
def test_cas_wrong_arg_count(self):
80+
sf = SharedField(42)
81+
with self.assertRaises(TypeError):
82+
sf.compare_and_swap(42)
83+
with self.assertRaises(TypeError):
84+
sf.compare_and_swap(1, 2, 3)
85+
86+
87+
class TestSharedFieldFreeze(unittest.TestCase):
88+
"""Test SharedField within frozen object graphs."""
89+
90+
def test_freeze_object_with_sharedfield(self):
91+
class Container:
92+
pass
93+
94+
c = Container()
95+
c.field = SharedField(42)
96+
freeze(c)
97+
self.assertTrue(is_frozen(c))
98+
99+
def test_get_after_freeze(self):
100+
class Container:
101+
pass
102+
103+
c = Container()
104+
c.field = SharedField(42)
105+
freeze(c)
106+
self.assertEqual(c.field.get(), 42)
107+
108+
def test_set_after_freeze(self):
109+
class Container:
110+
pass
111+
112+
c = Container()
113+
c.field = SharedField(42)
114+
freeze(c)
115+
c.field.set(99)
116+
self.assertEqual(c.field.get(), 99)
117+
118+
def test_swap_after_freeze(self):
119+
class Container:
120+
pass
121+
122+
c = Container()
123+
c.field = SharedField(42)
124+
freeze(c)
125+
old = c.field.swap(99)
126+
self.assertEqual(old, 42)
127+
self.assertEqual(c.field.get(), 99)
128+
129+
def test_compare_and_swap_after_freeze(self):
130+
class Container:
131+
pass
132+
133+
c = Container()
134+
c.field = SharedField(42)
135+
freeze(c)
136+
old = c.field.get()
137+
self.assertTrue(c.field.compare_and_swap(old, 99))
138+
self.assertEqual(c.field.get(), 99)
139+
140+
def test_sharedfield_itself_frozen(self):
141+
sf = SharedField(42)
142+
freeze(sf)
143+
self.assertTrue(is_frozen(sf))
144+
145+
def test_set_rejects_mutable_after_freeze(self):
146+
class Container:
147+
pass
148+
149+
c = Container()
150+
c.field = SharedField(42)
151+
freeze(c)
152+
with self.assertRaises(TypeError):
153+
c.field.set([1, 2, 3])
154+
155+
156+
class TestSharedFieldErrors(unittest.TestCase):
157+
"""Test error cases."""
158+
159+
def test_no_args(self):
160+
with self.assertRaises(TypeError):
161+
SharedField()
162+
163+
def test_non_freezable_initial(self):
164+
class NF:
165+
pass
166+
obj = NF()
167+
set_freezable(obj, FREEZABLE_NO)
168+
with self.assertRaises(TypeError):
169+
SharedField(obj)
170+
171+
def test_multiple_independent_fields(self):
172+
f1 = SharedField(1)
173+
f2 = SharedField(2)
174+
self.assertEqual(f1.get(), 1)
175+
self.assertEqual(f2.get(), 2)
176+
f1.set(10)
177+
self.assertEqual(f1.get(), 10)
178+
self.assertEqual(f2.get(), 2)
179+
180+
181+
class TestSharedFieldSubinterpreters(unittest.TestCase):
182+
"""Test that SharedField is shared across sub-interpreters."""
183+
184+
def setUp(self):
185+
self._interpreters = import_helper.import_module('_interpreters')
186+
187+
def _run_in_subinterp(self, code, shared=None):
188+
r, w = os.pipe()
189+
wrapped = (
190+
"import contextlib, os\n"
191+
f"with open({w}, 'w', encoding='utf-8') as spipe:\n"
192+
" with contextlib.redirect_stdout(spipe):\n"
193+
)
194+
for line in code.splitlines():
195+
wrapped += " " + line + "\n"
196+
197+
interp = self._interpreters.create()
198+
try:
199+
self._interpreters.run_string(
200+
interp, wrapped, shared=shared or {})
201+
finally:
202+
with os.fdopen(r, encoding='utf-8') as rpipe:
203+
result = rpipe.read()
204+
self._interpreters.destroy(interp)
205+
return result
206+
207+
def test_shared_field_readable_in_subinterp(self):
208+
"""A frozen SharedField shared to a sub-interpreter should
209+
be readable there."""
210+
sf = SharedField(42)
211+
freeze(sf)
212+
213+
output = self._run_in_subinterp(
214+
"print(sf.get())\n",
215+
shared={"sf": sf},
216+
)
217+
self.assertEqual(output.strip(), "42")
218+
219+
def test_shared_field_set_visible_across_interps(self):
220+
"""Setting a SharedField in a sub-interpreter should be
221+
visible in the main interpreter (shared state)."""
222+
sf = SharedField(0)
223+
freeze(sf)
224+
225+
self._run_in_subinterp(
226+
"sf.set(42)\n",
227+
shared={"sf": sf},
228+
)
229+
# Unlike InterpreterLocal, SharedField is shared — change is visible
230+
self.assertEqual(sf.get(), 42)
231+
232+
def test_shared_frozen_container_with_sharedfield(self):
233+
"""A frozen container with a SharedField should share
234+
state across interpreters."""
235+
class Container:
236+
pass
237+
238+
c = Container()
239+
c.counter = SharedField(0)
240+
freeze(c)
241+
242+
self._run_in_subinterp(
243+
"c.counter.set(99)\n",
244+
shared={"c": c},
245+
)
246+
self.assertEqual(c.counter.get(), 99)
247+
248+
249+
if __name__ == "__main__":
250+
unittest.main()

0 commit comments

Comments
 (0)