Skip to content

Commit b22a726

Browse files
author
Alan
committed
Merge branch 'weakref' into combine_v1
2 parents 7bd0bbd + adfee9c commit b22a726

File tree

5 files changed

+84
-36
lines changed

5 files changed

+84
-36
lines changed

python/ipywidgets/ipywidgets/embed.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import json
1414
import re
1515
from .widgets import Widget, DOMWidget
16+
from .widgets import Widget, DOMWidget
1617
from .widgets.widget_link import Link
1718
from .widgets.docutils import doc_subst
1819
from ._version import __html_manager_version__
@@ -129,7 +130,7 @@ def _get_recursive_state(widget, store=None, drop_defaults=False):
129130

130131
def add_resolved_links(store, drop_defaults):
131132
"""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
133+
for widget_id, widget in Widget.all_widgets().items(): # go over all widgets
133134
if isinstance(widget, Link) and widget_id not in store:
134135
if widget.source[0].model_id in store and widget.target[0].model_id in store:
135136
store[widget.model_id] = widget._get_embed_state(drop_defaults=drop_defaults)
@@ -207,7 +208,7 @@ def embed_data(views, drop_defaults=True, state=None):
207208
view_specs: a list of widget view specs
208209
"""
209210
if views is None:
210-
views = [w for w in Widget._instances.values() if isinstance(w, DOMWidget)]
211+
views = [w for w in Widget.all_widgets().values() if isinstance(w, DOMWidget)]
211212
else:
212213
try:
213214
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: 29 additions & 13 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,13 +41,19 @@ def envset(name, default):
4241
# for a discussion on using weak references see:
4342
# https://github.com/jupyter-widgets/ipywidgets/issues/1345
4443

44+
_widget_instances = {}
45+
46+
4547
def enable_weakreference():
4648
"""Use a WeakValueDictionary to store references to Widget instances.
4749
4850
A strong reference must be kept to widgets.
4951
"""
50-
if not isinstance(Widget._instances, weakref.WeakValueDictionary):
51-
Widget._instances = weakref.WeakValueDictionary(Widget._instances)
52+
global _widget_instances
53+
if not isinstance(_widget_instances, weakref.WeakValueDictionary):
54+
_widget_instances = weakref.WeakValueDictionary(_widget_instances)
55+
# Widget.__dict__["_instances"] = weakref.WeakValueDictionary()
56+
5257

5358
def disable_weakreference():
5459
"""Use a standard dictionary to store references to Widget instances (default behavior).
@@ -57,8 +62,10 @@ def disable_weakreference():
5762
the widget preventing automatic garbage collection. When the widget is closed
5863
it can be garbage collected.
5964
"""
60-
if isinstance(Widget._instances, weakref.WeakValueDictionary):
61-
Widget._instances = dict(Widget._instances)
65+
global _widget_instances
66+
if isinstance(_widget_instances, weakref.WeakValueDictionary):
67+
_widget_instances = dict(_widget_instances)
68+
6269

6370
def _widget_to_json(x, obj):
6471
if isinstance(x, Widget):
@@ -75,8 +82,11 @@ def _json_to_widget(x, obj):
7582
return {k: _json_to_widget(v, obj) for k, v in x.items()}
7683
elif isinstance(x, (list, tuple)):
7784
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:]]
85+
elif isinstance(x, str) and x.startswith("IPY_MODEL_"):
86+
try:
87+
return _widget_instances[x[10:]]
88+
except (KeyError, IndexError):
89+
pass
8090
else:
8191
return x
8292

@@ -308,13 +318,18 @@ class Widget(LoggingHasTraits):
308318
_widget_construction_callback = None
309319
_control_comm = None
310320

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

313322
@classmethod
314323
def close_all(cls):
315-
for widget in list(Widget._instances.values()):
324+
while _widget_instances:
325+
_, widget = _widget_instances.popitem()
316326
widget.close()
317327

328+
@staticmethod
329+
def all_widgets() -> dict[str, "Widget"]:
330+
"Returns a dict mapping `comm_id` to Widget of all Widget instances."
331+
return dict(_widget_instances)
332+
318333
@staticmethod
319334
def on_widget_constructed(callback):
320335
"""Registers a callback to be called when a widget is constructed.
@@ -354,7 +369,7 @@ def _handle_control_comm_msg(cls, msg):
354369
if method == 'request_states':
355370
# Send back the full widgets state
356371
cls.get_manager_state()
357-
widgets = cls._instances.values()
372+
widgets = _widget_instances.values()
358373
full_state = {}
359374
drop_defaults = False
360375
for widget in widgets:
@@ -405,7 +420,7 @@ def get_manager_state(cls, drop_defaults=False, widgets=None):
405420
"""
406421
state = {}
407422
if widgets is None:
408-
widgets = cls._instances.values()
423+
widgets = _widget_instances.values()
409424
for widget in widgets:
410425
state[widget.model_id] = widget._get_embed_state(drop_defaults=drop_defaults)
411426
return {'version_major': 2, 'version_minor': 0, 'state': state}
@@ -516,9 +531,10 @@ def _comm_changed(self, change):
516531
if change['old']:
517532
change['old'].on_msg(None)
518533
change['old'].close()
519-
self._instances.pop(change['old'].comm_id, None)
534+
if _widget_instances: # This check is needed to avoid errors on cleanup
535+
_widget_instances.pop(change["old"].comm_id, None)
520536
if change['new']:
521-
self._instances[change["new"].comm_id] = self
537+
_widget_instances[change["new"].comm_id] = self
522538
self._model_id = change["new"].comm_id
523539

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

0 commit comments

Comments
 (0)