Skip to content

Commit 3541511

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 3541511

File tree

5 files changed

+91
-41
lines changed

5 files changed

+91
-41
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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class CaseWidget(Widget):
2929
class TestEmbed:
3030

3131
def teardown(self):
32-
for w in tuple(Widget._instances.values()):
32+
for w in tuple(Widget.all_widgets().values()):
3333
w.close()
3434

3535
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: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
in the Jupyter notebook front-end.
77
"""
88
import os
9-
import typing
109
import weakref
1110
from contextlib import contextmanager
1211
from collections.abc import Iterable
@@ -42,23 +41,33 @@ def envset(name, default):
4241
# for a discussion on using weak references see:
4342
# https://github.com/jupyter-widgets/ipywidgets/issues/1345
4443

44+
# A global to store all widget instances so that it can be swapped out when
45+
# enabling/disabling weakreferences
46+
_widget_instances = {}
47+
48+
4549
def enable_weakreference():
46-
"""Use a WeakValueDictionary to store references to Widget instances.
50+
"""Configure the module to only maintain a weakreference between the comm_id and widget for all widgets.
4751
48-
A strong reference must be kept to widgets.
52+
With this enabled the user must maintain a strong reference to widgets. The
53+
advantage being that memory leaks prevented in long running programs.
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():
54-
"""Use a standard dictionary to store references to Widget instances (default behavior).
62+
"""Configure the module to only maintain a strong reference between the comm_id and widget for all widgets.
5563
56-
Note: this is the default setting and maintains a strong reference to the
57-
the widget preventing automatic garbage collection. When the widget is closed
58-
it can be garbage collected.
64+
!!! Note:
65+
This is the default behavior. The method `Widget.close` should be called when it is no longer required.
5966
"""
60-
if isinstance(Widget._instances, weakref.WeakValueDictionary):
61-
Widget._instances = dict(Widget._instances)
67+
global _widget_instances
68+
if isinstance(_widget_instances, weakref.WeakValueDictionary):
69+
_widget_instances = dict(_widget_instances)
70+
6271

6372
def _widget_to_json(x, obj):
6473
if isinstance(x, Widget):
@@ -75,8 +84,11 @@ def _json_to_widget(x, obj):
7584
return {k: _json_to_widget(v, obj) for k, v in x.items()}
7685
elif isinstance(x, (list, tuple)):
7786
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:]]
87+
elif isinstance(x, str) and x.startswith("IPY_MODEL_"):
88+
try:
89+
return _widget_instances[x[10:]]
90+
except (KeyError, IndexError):
91+
pass
8092
else:
8193
return x
8294

@@ -308,13 +320,18 @@ class Widget(LoggingHasTraits):
308320
_widget_construction_callback = None
309321
_control_comm = None
310322

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

313324
@classmethod
314325
def close_all(cls):
315-
for widget in list(Widget._instances.values()):
326+
while _widget_instances:
327+
_, widget = _widget_instances.popitem()
316328
widget.close()
317329

330+
@staticmethod
331+
def all_widgets() -> dict[str, "Widget"]:
332+
"Returns a dict mapping `comm_id` to Widget of all Widget instances."
333+
return dict(_widget_instances)
334+
318335
@staticmethod
319336
def on_widget_constructed(callback):
320337
"""Registers a callback to be called when a widget is constructed.
@@ -354,7 +371,7 @@ def _handle_control_comm_msg(cls, msg):
354371
if method == 'request_states':
355372
# Send back the full widgets state
356373
cls.get_manager_state()
357-
widgets = cls._instances.values()
374+
widgets = _widget_instances.values()
358375
full_state = {}
359376
drop_defaults = False
360377
for widget in widgets:
@@ -405,7 +422,7 @@ def get_manager_state(cls, drop_defaults=False, widgets=None):
405422
"""
406423
state = {}
407424
if widgets is None:
408-
widgets = cls._instances.values()
425+
widgets = _widget_instances.values()
409426
for widget in widgets:
410427
state[widget.model_id] = widget._get_embed_state(drop_defaults=drop_defaults)
411428
return {'version_major': 2, 'version_minor': 0, 'state': state}
@@ -516,9 +533,10 @@ def _comm_changed(self, change):
516533
if change['old']:
517534
change['old'].on_msg(None)
518535
change['old'].close()
519-
self._instances.pop(change['old'].comm_id, None)
536+
if _widget_instances: # This check is needed to avoid errors on cleanup
537+
_widget_instances.pop(change["old"].comm_id, None)
520538
if change['new']:
521-
self._instances[change["new"].comm_id] = self
539+
_widget_instances[change["new"].comm_id] = self
522540
self._model_id = change["new"].comm_id
523541

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

0 commit comments

Comments
 (0)