Skip to content

Commit adfee9c

Browse files
author
Alan
committed
Change from using Widget._instances class variable to the global _widget_instances to fix a Segmentation Fault that was causing test failure.
Also added the static method Widget.all_widgets that returns a copy of the current widgets.
1 parent 4869104 commit adfee9c

File tree

5 files changed

+95
-38
lines changed

5 files changed

+95
-38
lines changed

python/ipywidgets/ipywidgets/embed.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def _get_recursive_state(widget, store=None, drop_defaults=False):
129129

130130
def add_resolved_links(store, drop_defaults):
131131
"""Adds the state of any link models between two models in store"""
132-
for widget_id, widget in Widget._instances.items(): # go over all widgets
132+
for widget_id, widget in Widget.all_widgets().items(): # go over all widgets
133133
if isinstance(widget, Link) and widget_id not in store:
134134
if widget.source[0].model_id in store and widget.target[0].model_id in store:
135135
store[widget.model_id] = widget._get_embed_state(drop_defaults=drop_defaults)
@@ -207,7 +207,7 @@ def embed_data(views, drop_defaults=True, state=None):
207207
view_specs: a list of widget view specs
208208
"""
209209
if views is None:
210-
views = [w for w in Widget._instances.values() if isinstance(w, DOMWidget)]
210+
views = [w for w in Widget.all_widgets().values() if isinstance(w, DOMWidget)]
211211
else:
212212
try:
213213
views[0]

python/ipywidgets/ipywidgets/tests/test_embed.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from ..widgets import IntSlider, IntText, Text, Widget, jslink, HBox, widget_serialization
1313
from ..embed import embed_data, embed_snippet, embed_minimal_html, dependency_state
1414

15-
1615
class CaseWidget(Widget):
1716
"""Widget to test dependency traversal"""
1817

@@ -29,7 +28,7 @@ class CaseWidget(Widget):
2928
class TestEmbed:
3029

3130
def teardown(self):
32-
for w in tuple(Widget._instances.values()):
31+
for w in tuple(Widget.all_widgets().values()):
3332
w.close()
3433

3534
def test_embed_data_simple(self):

python/ipywidgets/ipywidgets/widgets/tests/test_widget.py

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,12 @@ def test_close_all():
6060
# create a couple of widgets
6161
widgets = [Button() for i in range(10)]
6262

63-
assert len(Widget._instances) > 0, "expect active widgets"
64-
assert Widget._instances[widgets[0].model_id] is widgets[0]
63+
assert len(Widget.all_widgets()) > 0, "expect active widgets"
64+
assert Widget.all_widgets()[widgets[0].model_id] is widgets[0]
6565
# close all the widgets
6666
Widget.close_all()
6767

68-
assert len(Widget._instances) == 0, "active widgets should be cleared"
68+
assert len(Widget.all_widgets()) == 0, "active widgets should be cleared"
6969

7070

7171
def test_widget_copy():
@@ -79,12 +79,12 @@ def test_widget_copy():
7979
def test_widget_open():
8080
button = Button()
8181
model_id = button.model_id
82-
assert model_id in Widget._instances
82+
assert model_id in Widget.all_widgets()
8383
spec = button.get_view_spec()
8484
assert list(spec) == ["version_major", "version_minor", "model_id"]
8585
assert spec["model_id"]
8686
button.close()
87-
assert model_id not in Widget._instances
87+
assert model_id not in Widget.all_widgets()
8888
with pytest.raises(RuntimeError, match="Widget is closed"):
8989
button.open()
9090
with pytest.raises(RuntimeError, match="Widget is closed"):
@@ -159,28 +159,33 @@ def test_widget_open():
159159
"Widget",
160160
],
161161
)
162-
def test_weakreference(class_name):
162+
@pytest.mark.parametrize("enable_weakref", [True, False])
163+
def test_weakreference(class_name, enable_weakref):
163164
# Ensure the base instance of all widgets can be deleted / garbage collected.
164-
ipw.enable_weakreference()
165+
if enable_weakref:
166+
ipw.enable_weakreference()
167+
cls = getattr(ipw, class_name)
168+
if class_name in ["SelectionRangeSlider", "SelectionSlider"]:
169+
kwgs = {"options": [1, 2, 4]}
170+
else:
171+
kwgs = {}
165172
try:
166-
cls = getattr(ipw, class_name)
167-
if class_name in ["SelectionRangeSlider", "SelectionSlider"]:
168-
kwgs = {"options": [1, 2, 4]}
169-
else:
170-
kwgs = {}
171-
w: Widget = cls(**kwgs)
173+
w = cls(**kwgs)
172174
deleted = False
173175
def on_delete():
174176
nonlocal deleted
175177
deleted = True
176178
weakref.finalize(w, on_delete)
177179
# w should be the only strong ref to the widget.
178180
# calling `del` should invoke its immediate deletion calling the `__del__` method.
181+
if not enable_weakref:
182+
w.close()
179183
del w
180184
gc.collect()
181185
assert deleted
182186
finally:
183-
ipw.disable_weakreference()
187+
if enable_weakref:
188+
ipw.disable_weakreference()
184189

185190

186191
@pytest.mark.parametrize("weakref_enabled", [True, False])
@@ -201,7 +206,7 @@ def my_click(self, b):
201206
b = TestButton(description="button")
202207
weakref.finalize(b, on_delete)
203208
b_ref = weakref.ref(b)
204-
assert b in Widget._instances.values()
209+
assert b in Widget.all_widgets().values()
205210

206211
b.on_click(b.my_click)
207212
b.on_click(lambda x: setattr(x, "clicked", True))
@@ -211,11 +216,11 @@ def my_click(self, b):
211216

212217
if weakref_enabled:
213218
ipw.enable_weakreference()
214-
assert b in Widget._instances.values(), "Instances not transferred"
219+
assert b in Widget.all_widgets().values(), "Instances not transferred"
215220
ipw.disable_weakreference()
216-
assert b in Widget._instances.values(), "Instances not transferred"
221+
assert b in Widget.all_widgets().values(), "Instances not transferred"
217222
ipw.enable_weakreference()
218-
assert b in Widget._instances.values(), "Instances not transferred"
223+
assert b in Widget.all_widgets().values(), "Instances not transferred"
219224

220225
b.click()
221226
assert click_count == 2
@@ -227,7 +232,7 @@ def my_click(self, b):
227232
assert deleted
228233
else:
229234
assert not deleted
230-
assert b_ref() in Widget._instances.values()
235+
assert b_ref() in Widget.all_widgets().values()
231236
b_ref().close()
232237
gc.collect()
233238
assert deleted, "Closing should remove the last strong reference."

python/ipywidgets/ipywidgets/widgets/tests/test_widget_box.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,30 @@ def test_box_validate_mode():
5555
with pytest.raises(TraitError):
5656
box.children += ("Not a widget", closed_button)
5757

58+
59+
def test_box_gc():
60+
widgets.enable_weakreference()
61+
# Test Box gc collected and children lifecycle managed.
62+
try:
63+
deleted = False
64+
65+
class TestButton(widgets.Button):
66+
def my_click(self, b):
67+
pass
68+
69+
button = TestButton(description="button")
70+
button.on_click(button.my_click)
71+
72+
b = widgets.VBox(children=[button])
73+
74+
def on_delete():
75+
nonlocal deleted
76+
deleted = True
77+
78+
weakref.finalize(b, on_delete)
79+
del b
80+
gc.collect()
81+
assert deleted
82+
finally:
83+
pass
84+
widgets.disable_weakreference()

python/ipywidgets/ipywidgets/widgets/widget.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
"""Base Widget class. Allows user to create widgets in the back-end that render
66
in the Jupyter notebook front-end.
77
"""
8+
from typing import Any
9+
10+
811
import os
9-
import typing
1012
import weakref
1113
from contextlib import contextmanager
1214
from collections.abc import Iterable
@@ -42,13 +44,19 @@ def envset(name, default):
4244
# for a discussion on using weak references see:
4345
# https://github.com/jupyter-widgets/ipywidgets/issues/1345
4446

47+
_widget_instances = {}
48+
49+
4550
def enable_weakreference():
4651
"""Use a WeakValueDictionary to store references to Widget instances.
4752
4853
A strong reference must be kept to widgets.
4954
"""
50-
if not isinstance(Widget._instances, weakref.WeakValueDictionary):
51-
Widget._instances = weakref.WeakValueDictionary(Widget._instances)
55+
global _widget_instances
56+
if not isinstance(_widget_instances, weakref.WeakValueDictionary):
57+
_widget_instances = weakref.WeakValueDictionary(_widget_instances)
58+
# Widget.__dict__["_instances"] = weakref.WeakValueDictionary()
59+
5260

5361
def disable_weakreference():
5462
"""Use a standard dictionary to store references to Widget instances (default behavior).
@@ -57,10 +65,19 @@ def disable_weakreference():
5765
the widget preventing automatic garbage collection. When the widget is closed
5866
it can be garbage collected.
5967
"""
60-
if isinstance(Widget._instances, weakref.WeakValueDictionary):
61-
Widget._instances = dict(Widget._instances)
62-
63-
def _widget_to_json(x, obj):
68+
global _widget_instances
69+
if isinstance(_widget_instances, weakref.WeakValueDictionary):
70+
_widget_instances = dict(_widget_instances)
71+
72+
73+
def _widget_to_json(
74+
x, obj
75+
) -> (
76+
str
77+
| list[str | list[Any] | dict[Any, str | list[Any] | dict[Any, Any] | Any] | Any]
78+
| dict[Any, str | list[Any] | dict[Any, Any] | Any]
79+
| Any
80+
):
6481
if isinstance(x, Widget):
6582
return f"IPY_MODEL_{x.model_id}"
6683
elif isinstance(x, (list, tuple)):
@@ -75,8 +92,11 @@ def _json_to_widget(x, obj):
7592
return {k: _json_to_widget(v, obj) for k, v in x.items()}
7693
elif isinstance(x, (list, tuple)):
7794
return [_json_to_widget(v, obj) for v in x]
78-
elif isinstance(x, str) and x.startswith("IPY_MODEL_") and x[10:] in Widget._instances:
79-
return Widget._instances[x[10:]]
95+
elif isinstance(x, str) and x.startswith("IPY_MODEL_"):
96+
try:
97+
return _widget_instances[x[10:]]
98+
except (KeyError, IndexError):
99+
pass
80100
else:
81101
return x
82102

@@ -308,13 +328,18 @@ class Widget(LoggingHasTraits):
308328
_widget_construction_callback = None
309329
_control_comm = None
310330

311-
_instances: typing.ClassVar[typing.MutableMapping[str, "Widget"]] = {}
312331

313332
@classmethod
314333
def close_all(cls):
315-
for widget in list(Widget._instances.values()):
334+
while _widget_instances:
335+
_, widget = _widget_instances.popitem()
316336
widget.close()
317337

338+
@staticmethod
339+
def all_widgets() -> dict[str, "Widget"]:
340+
"Returns a dict mapping `comm_id` to Widget of all Widget instances."
341+
return dict(_widget_instances)
342+
318343
@staticmethod
319344
def on_widget_constructed(callback):
320345
"""Registers a callback to be called when a widget is constructed.
@@ -354,7 +379,7 @@ def _handle_control_comm_msg(cls, msg):
354379
if method == 'request_states':
355380
# Send back the full widgets state
356381
cls.get_manager_state()
357-
widgets = cls._instances.values()
382+
widgets = _widget_instances.values()
358383
full_state = {}
359384
drop_defaults = False
360385
for widget in widgets:
@@ -405,7 +430,7 @@ def get_manager_state(cls, drop_defaults=False, widgets=None):
405430
"""
406431
state = {}
407432
if widgets is None:
408-
widgets = cls._instances.values()
433+
widgets = _widget_instances.values()
409434
for widget in widgets:
410435
state[widget.model_id] = widget._get_embed_state(drop_defaults=drop_defaults)
411436
return {'version_major': 2, 'version_minor': 0, 'state': state}
@@ -516,9 +541,10 @@ def _comm_changed(self, change):
516541
if change['old']:
517542
change['old'].on_msg(None)
518543
change['old'].close()
519-
self._instances.pop(change['old'].comm_id, None)
544+
if _widget_instances: # This check is needed to avoid errors on cleanup
545+
_widget_instances.pop(change["old"].comm_id, None)
520546
if change['new']:
521-
self._instances[change["new"].comm_id] = self
547+
_widget_instances[change["new"].comm_id] = self
522548
self._model_id = change["new"].comm_id
523549

524550
# prevent memory leaks by using a weak reference to self.

0 commit comments

Comments
 (0)