Skip to content

Commit 448c76d

Browse files
author
Alan Fleming
committed
Add weakref with opt in automatic widget deletion using 'enable_weakreference'.
1 parent 4690a5d commit 448c76d

File tree

4 files changed

+347
-77
lines changed

4 files changed

+347
-77
lines changed

python/ipywidgets/ipywidgets/widgets/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright (c) Jupyter Development Team.
22
# Distributed under the terms of the Modified BSD License.
33

4-
from .widget import Widget, CallbackDispatcher, register, widget_serialization
4+
from .widget import Widget, CallbackDispatcher, register, widget_serialization, enable_weakreference, disable_weakreference
55
from .domwidget import DOMWidget
66
from .valuewidget import ValueWidget
77

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

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@
33

44
"""Test Widget."""
55

6+
import copy
7+
import gc
68
import inspect
9+
import weakref
710

811
import pytest
912
from IPython.core.interactiveshell import InteractiveShell
1013
from IPython.display import display
1114
from IPython.utils.capture import capture_output
1215

16+
import ipywidgets as ipw
17+
1318
from .. import widget
1419
from ..widget import Widget
1520
from ..widget_button import Button
16-
import copy
1721

1822

1923
def test_no_widget_view():
@@ -88,4 +92,169 @@ def test_widget_copy():
8892
with pytest.raises(NotImplementedError):
8993
copy.copy(button)
9094
with pytest.raises(NotImplementedError):
91-
copy.deepcopy(button)
95+
copy.deepcopy(button)
96+
97+
98+
def test_widget_open():
99+
button = Button()
100+
model_id = button.model_id
101+
assert model_id in widget._instances
102+
spec = button.get_view_spec()
103+
assert list(spec) == ["version_major", "version_minor", "model_id"]
104+
assert spec["model_id"]
105+
button.close()
106+
assert model_id not in widget._instances
107+
with pytest.raises(RuntimeError, match="Widget is closed"):
108+
button.open()
109+
with pytest.raises(RuntimeError, match="Widget is closed"):
110+
button.get_view_spec()
111+
112+
113+
@pytest.mark.parametrize(
114+
"class_name",
115+
[
116+
"Accordion",
117+
"AppLayout",
118+
"Audio",
119+
"BoundedFloatText",
120+
"BoundedIntText",
121+
"Box",
122+
"Button",
123+
"ButtonStyle",
124+
"Checkbox",
125+
"ColorPicker",
126+
"ColorsInput",
127+
"Combobox",
128+
"Controller",
129+
"CoreWidget",
130+
"DOMWidget",
131+
"DatePicker",
132+
"DatetimePicker",
133+
"Dropdown",
134+
"FileUpload",
135+
"FloatLogSlider",
136+
"FloatProgress",
137+
"FloatRangeSlider",
138+
"FloatSlider",
139+
"FloatText",
140+
"FloatsInput",
141+
"GridBox",
142+
"HBox",
143+
"HTML",
144+
"HTMLMath",
145+
"Image",
146+
"IntProgress",
147+
"IntRangeSlider",
148+
"IntSlider",
149+
"IntText",
150+
"IntsInput",
151+
"Label",
152+
"Layout",
153+
"NaiveDatetimePicker",
154+
"Output",
155+
"Password",
156+
"Play",
157+
"RadioButtons",
158+
"Select",
159+
"SelectMultiple",
160+
"SelectionRangeSlider",
161+
"SelectionSlider",
162+
"SliderStyle",
163+
"Stack",
164+
"Style",
165+
"Tab",
166+
"TagsInput",
167+
"Text",
168+
"Textarea",
169+
"TimePicker",
170+
"ToggleButton",
171+
"ToggleButtons",
172+
"ToggleButtonsStyle",
173+
"TwoByTwoLayout",
174+
"VBox",
175+
"Valid",
176+
"ValueWidget",
177+
"Video",
178+
"Widget",
179+
],
180+
)
181+
@pytest.mark.parametrize("enable_weakref", [True, False])
182+
def test_weakreference(class_name, enable_weakref):
183+
# Ensure the base instance of all widgets can be deleted / garbage collected.
184+
if enable_weakref:
185+
ipw.enable_weakreference()
186+
cls = getattr(ipw, class_name)
187+
if class_name in ['SelectionRangeSlider', 'SelectionSlider']:
188+
kwgs = {"options": [1, 2, 4]}
189+
else:
190+
kwgs = {}
191+
try:
192+
w = cls(**kwgs)
193+
deleted = False
194+
def on_delete():
195+
nonlocal deleted
196+
deleted = True
197+
weakref.finalize(w, on_delete)
198+
# w should be the only strong ref to the widget.
199+
# calling `del` should invoke its immediate deletion calling the `__del__` method.
200+
if not enable_weakref:
201+
w.close()
202+
del w
203+
gc.collect()
204+
assert deleted
205+
finally:
206+
if enable_weakref:
207+
ipw.disable_weakreference()
208+
209+
210+
@pytest.mark.parametrize("weakref_enabled", [True, False])
211+
def test_button_weakreference(weakref_enabled: bool):
212+
try:
213+
click_count = 0
214+
deleted = False
215+
216+
def on_delete():
217+
nonlocal deleted
218+
deleted = True
219+
220+
class TestButton(Button):
221+
def my_click(self, b):
222+
nonlocal click_count
223+
click_count += 1
224+
225+
b = TestButton(description="button")
226+
weakref.finalize(b, on_delete)
227+
b_ref = weakref.ref(b)
228+
assert b in widget._instances.values()
229+
230+
b.on_click(b.my_click)
231+
b.on_click(lambda x: setattr(x, "clicked", True))
232+
233+
b.click()
234+
assert click_count == 1
235+
236+
if weakref_enabled:
237+
ipw.enable_weakreference()
238+
assert b in widget._instances.values(), "Instances not transferred"
239+
ipw.disable_weakreference()
240+
assert b in widget._instances.values(), "Instances not transferred"
241+
ipw.enable_weakreference()
242+
assert b in widget._instances.values(), "Instances not transferred"
243+
244+
b.click()
245+
assert click_count == 2
246+
assert getattr(b, "clicked")
247+
248+
del b
249+
gc.collect()
250+
if weakref_enabled:
251+
assert deleted
252+
else:
253+
assert not deleted
254+
assert b_ref() in widget._instances.values()
255+
b_ref().close()
256+
gc.collect()
257+
assert deleted, "Closing should remove the last strong reference."
258+
259+
finally:
260+
ipw.disable_weakreference()
Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,85 @@
11
# Copyright (c) Jupyter Development Team.
22
# Distributed under the terms of the Modified BSD License.
33

4-
from unittest import TestCase
4+
import gc
5+
import weakref
56

7+
import pytest
68
from traitlets import TraitError
79

810
import ipywidgets as widgets
911

1012

11-
class TestBox(TestCase):
13+
def test_box_construction():
14+
box = widgets.Box()
15+
assert box.get_state()["children"] == []
1216

13-
def test_construction(self):
14-
box = widgets.Box()
15-
assert box.get_state()['children'] == []
1617

17-
def test_construction_with_children(self):
18-
html = widgets.HTML('some html')
19-
slider = widgets.IntSlider()
20-
box = widgets.Box([html, slider])
21-
children_state = box.get_state()['children']
22-
assert children_state == [
23-
widgets.widget._widget_to_json(html, None),
24-
widgets.widget._widget_to_json(slider, None),
25-
]
18+
def test_box_construction_with_children():
19+
html = widgets.HTML("some html")
20+
slider = widgets.IntSlider()
21+
box = widgets.Box([html, slider])
22+
children_state = box.get_state()["children"]
23+
assert children_state == [
24+
widgets.widget._widget_to_json(html, None),
25+
widgets.widget._widget_to_json(slider, None),
26+
]
2627

27-
def test_construction_style(self):
28-
box = widgets.Box(box_style='warning')
29-
assert box.get_state()['box_style'] == 'warning'
3028

31-
def test_construction_invalid_style(self):
32-
with self.assertRaises(TraitError):
33-
widgets.Box(box_style='invalid')
29+
def test_box_construction_style():
30+
box = widgets.Box(box_style="warning")
31+
assert box.get_state()["box_style"] == "warning"
32+
33+
34+
def test_construction_invalid_style():
35+
with pytest.raises(TraitError):
36+
widgets.Box(box_style="invalid")
37+
38+
39+
def test_box_validate_mode():
40+
slider = widgets.IntSlider()
41+
closed_button = widgets.Button()
42+
closed_button.close()
43+
with pytest.raises(TraitError, match="Invalid or closed items found.*"):
44+
widgets.Box(
45+
children=[closed_button, slider, "Not a widget"]
46+
)
47+
box = widgets.Box(
48+
children=[closed_button, slider, "Not a widget"],
49+
validate_mode="log_error",
50+
)
51+
assert len (box.children) == 1, "Invalid items should be dropped."
52+
assert slider in box.children
53+
54+
box.validate_mode = "raise"
55+
with pytest.raises(TraitError):
56+
box.children += ("Not a widget", closed_button)
57+
58+
59+
def test_box_gc():
60+
widgets.VBox._active_widgets
61+
widgets.enable_weakreference()
62+
# Test Box gc collected and children lifecycle managed.
63+
try:
64+
deleted = False
65+
66+
class TestButton(widgets.Button):
67+
def my_click(self, b):
68+
pass
69+
70+
button = TestButton(description="button")
71+
button.on_click(button.my_click)
72+
73+
b = widgets.VBox(children=[button])
74+
75+
def on_delete():
76+
nonlocal deleted
77+
deleted = True
78+
79+
weakref.finalize(b, on_delete)
80+
del b
81+
gc.collect()
82+
assert deleted
83+
widgets.VBox._active_widgets
84+
finally:
85+
widgets.disable_weakreference()

0 commit comments

Comments
 (0)