3
3
import copy
4
4
import json
5
5
import os
6
- from typing import Any , Optional , Sequence , Union , cast
6
+ from contextlib import contextmanager
7
+ from typing import TYPE_CHECKING , Any , Optional , Sequence , TypeGuard , Union , cast
7
8
from uuid import uuid4
8
9
from weakref import WeakSet
9
10
26
27
from ._cdn import SHINYWIDGETS_CDN_ONLY , SHINYWIDGETS_EXTENSION_WARNING
27
28
from ._comm import BufferType , ShinyComm , ShinyCommManager
28
29
from ._dependencies import require_dependency
29
- from ._utils import is_instance_of_class , package_dir
30
+ from ._render_widget_base import has_current_context
31
+ from ._utils import package_dir
30
32
31
33
__all__ = (
32
34
"register_widget" ,
33
35
"reactive_read" ,
34
36
)
35
37
38
+ if TYPE_CHECKING :
39
+ from traitlets .traitlets import Instance
40
+
36
41
37
42
# --------------------------------------------------------------------------------------------
38
43
# When a widget is initialized, also initialize a communication channel (via the Shiny
@@ -54,19 +59,43 @@ def init_shiny_widget(w: Widget):
54
59
while hasattr (session , "_parent" ):
55
60
session = cast (Session , session ._parent ) # pyright: ignore
56
61
57
- # Previous versions of ipywidgets (< 8.0.5) had
58
- # `Widget.comm = Instance('ipykernel.comm.Comm')`
59
- # which meant we'd get a runtime error when setting `Widget.comm = ShinyComm()`.
60
- # In more recent versions, this is no longer necessary since they've (correctly)
61
- # changed comm from an Instance() to Any().
62
- # https://github.com/jupyter-widgets/ipywidgets/pull/3533/files#diff-522bb5e7695975cba0199c6a3d6df5be827035f4dc18ed6da22ac216b5615c77R482
63
- old_comm_klass = None
64
- if is_instance_of_class (Widget .comm , "Instance" , "traitlets.traitlets" ): # type: ignore
65
- old_comm_klass = copy .copy (Widget .comm .klass ) # type: ignore
66
- Widget .comm .klass = object # type: ignore
62
+ # If this is the first time we've seen this session, initialize some things
63
+ if session not in SESSIONS :
64
+ SESSIONS .add (session )
65
+
66
+ # Somewhere inside ipywidgets, it makes requests for static files
67
+ # under the publicPath set by the webpack.config.js file.
68
+ session .app ._dependency_handler .mount (
69
+ "/dist/" ,
70
+ StaticFiles (directory = os .path .join (package_dir ("shinywidgets" ), "static" )),
71
+ name = "shinywidgets-static-resources" ,
72
+ )
73
+
74
+ # Handle messages from the client. Note that widgets like qgrid send client->server messages
75
+ # to figure out things like what filter to be shown in the table.
76
+ @reactive .effect
77
+ @reactive .event (session .input .shinywidgets_comm_send )
78
+ def _ ():
79
+ msg_txt = session .input .shinywidgets_comm_send ()
80
+ msg = json .loads (msg_txt )
81
+ comm_id = msg ["content" ]["comm_id" ]
82
+ if comm_id in COMM_MANAGER .comms :
83
+ comm : ShinyComm = COMM_MANAGER .comms [comm_id ]
84
+ comm .handle_msg (msg )
85
+
86
+ def _cleanup_session_state ():
87
+ SESSIONS .remove (session )
88
+ # Cleanup any widgets that were created in this session
89
+ for id in SESSION_WIDGET_ID_MAP [session .id ]:
90
+ widget = WIDGET_INSTANCE_MAP .get (id )
91
+ if widget :
92
+ widget .close ()
93
+ del SESSION_WIDGET_ID_MAP [session .id ]
94
+
95
+ session .on_ended (_cleanup_session_state )
67
96
68
97
# Get the initial state of the widget
69
- state , buffer_paths , buffers = _remove_buffers (w .get_state ()) # type: ignore
98
+ state , buffer_paths , buffers = _remove_buffers (w .get_state ())
70
99
71
100
# Make sure window.require() calls made by 3rd party widgets
72
101
# (via handle_comm_open() -> new_model() -> loadClass() -> requireLoader())
@@ -81,17 +110,29 @@ def init_shiny_widget(w: Widget):
81
110
if getattr (w , "_model_id" , None ) is None :
82
111
w ._model_id = uuid4 ().hex
83
112
113
+ id = cast (str , w ._model_id ) # pyright: ignore[reportUnknownMemberType]
114
+
84
115
# Initialize the comm...this will also send the initial state of the widget
85
- w .comm = ShinyComm (
86
- comm_id = w ._model_id , # pyright: ignore
87
- comm_manager = COMM_MANAGER ,
88
- target_name = "jupyter.widgets" ,
89
- data = {"state" : state , "buffer_paths" : buffer_paths },
90
- buffers = cast (BufferType , buffers ),
91
- # TODO: should this be hard-coded?
92
- metadata = {"version" : __protocol_version__ },
93
- html_deps = session ._process_ui (TagList (widget_dep ))["deps" ],
94
- )
116
+ with widget_comm_patch ():
117
+ w .comm = ShinyComm (
118
+ comm_id = id ,
119
+ comm_manager = COMM_MANAGER ,
120
+ target_name = "jupyter.widgets" ,
121
+ data = {"state" : state , "buffer_paths" : buffer_paths },
122
+ buffers = cast (BufferType , buffers ),
123
+ # TODO: should this be hard-coded?
124
+ metadata = {"version" : __protocol_version__ },
125
+ html_deps = session ._process_ui (TagList (widget_dep ))["deps" ],
126
+ )
127
+
128
+ # If we're in a reactive context, close this widget when the context is invalidated
129
+ if has_current_context ():
130
+ ctx = get_current_context ()
131
+ ctx .on_invalidate (lambda : w .close ())
132
+
133
+ # Keep track of what session this widget belongs to (so we can close it when the
134
+ # session ends)
135
+ SESSION_WIDGET_ID_MAP .setdefault (session .id , []).append (id )
95
136
96
137
# Some widget's JS make external requests for static files (e.g.,
97
138
# ipyleaflet markers) under this resource path. Note that this assumes that
@@ -107,45 +148,21 @@ def init_shiny_widget(w: Widget):
107
148
name = f"{ widget_dep .name } -nbextension-static-resources" ,
108
149
)
109
150
110
- # everything after this point should be done once per session
111
- if session in SESSIONS :
112
- return
113
- SESSIONS .add (session ) # type: ignore
114
-
115
- # Somewhere inside ipywidgets, it makes requests for static files
116
- # under the publicPath set by the webpack.config.js file.
117
- session .app ._dependency_handler .mount (
118
- "/dist/" ,
119
- StaticFiles (directory = os .path .join (package_dir ("shinywidgets" ), "static" )),
120
- name = "shinywidgets-static-resources" ,
121
- )
122
-
123
- # Handle messages from the client. Note that widgets like qgrid send client->server messages
124
- # to figure out things like what filter to be shown in the table.
125
- @reactive .Effect
126
- @reactive .event (session .input .shinywidgets_comm_send )
127
- def _ ():
128
- msg_txt = session .input .shinywidgets_comm_send ()
129
- msg = json .loads (msg_txt )
130
- comm_id = msg ["content" ]["comm_id" ]
131
- comm : ShinyComm = COMM_MANAGER .comms [comm_id ]
132
- comm .handle_msg (msg )
133
-
134
- def _restore_state ():
135
- if old_comm_klass is not None :
136
- Widget .comm .klass = old_comm_klass # type: ignore
137
- SESSIONS .remove (session ) # type: ignore
138
-
139
- session .on_ended (_restore_state )
140
-
141
151
142
152
# TODO: can we restore the widget constructor in a sensible way?
143
153
Widget .on_widget_constructed (init_shiny_widget ) # type: ignore
144
154
145
155
# Use WeakSet() over Set() so that the session can be garbage collected
146
- SESSIONS = WeakSet () # type: ignore
156
+ SESSIONS : WeakSet [ Session ] = WeakSet ()
147
157
COMM_MANAGER = ShinyCommManager ()
148
158
159
+ # Dictionary mapping session id to widget ids
160
+ # The key is the session id, and the value is a list of widget ids
161
+ SESSION_WIDGET_ID_MAP : dict [str , list [str ]] = {}
162
+
163
+ # Dictionary of all "active" widgets (ipywidgets automatically adds to this dictionary as
164
+ # new widgets are created, but they won't get removed until the widget is explictly closed)
165
+ WIDGET_INSTANCE_MAP = cast (dict [str , Widget ], Widget .widgets ) # pyright: ignore[reportUnknownMemberType]
149
166
150
167
# --------------------------------------
151
168
# Reactivity
@@ -216,6 +233,32 @@ def _():
216
233
217
234
return w
218
235
236
+ # Previous versions of ipywidgets (< 8.0.5) had
237
+ # `Widget.comm = Instance('ipykernel.comm.Comm')`
238
+ # which meant we'd get a runtime error when setting `Widget.comm = ShinyComm()`.
239
+ # In more recent versions, this is no longer necessary since they've (correctly)
240
+ # changed comm from an Instance() to Any().
241
+ # https://github.com/jupyter-widgets/ipywidgets/pull/3533/files#diff-522bb5e7695975cba0199c6a3d6df5be827035f4dc18ed6da22ac216b5615c77R482
242
+ @contextmanager
243
+ def widget_comm_patch ():
244
+ if not is_traitlet_instance (Widget .comm ):
245
+ yield
246
+ return
247
+
248
+ comm_klass = copy .copy (Widget .comm .klass )
249
+ Widget .comm .klass = object
250
+
251
+ yield
252
+
253
+ Widget .comm .klass = comm_klass
254
+
255
+
256
+ def is_traitlet_instance (x : object ) -> "TypeGuard[Instance]" :
257
+ try :
258
+ from traitlets .traitlets import Instance
259
+ except ImportError :
260
+ return False
261
+ return isinstance (x , Instance )
219
262
220
263
# It doesn't, at the moment, seem feasible to establish a comm with statically rendered widgets,
221
264
# and partially for this reason, it may not be sensible to provide an input-like API for them.
0 commit comments