##// END OF EJS Templates
Update widget serializer/unserializer to allow custom serialization on a trait-by-trait basis....
Jason Grout -
Show More
@@ -1,440 +1,455 b''
1 1 """Base Widget class. Allows user to create widgets in the back-end that render
2 2 in the IPython notebook front-end.
3 3 """
4 4 #-----------------------------------------------------------------------------
5 5 # Copyright (c) 2013, the IPython Development Team.
6 6 #
7 7 # Distributed under the terms of the Modified BSD License.
8 8 #
9 9 # The full license is in the file COPYING.txt, distributed with this software.
10 10 #-----------------------------------------------------------------------------
11 11
12 12 #-----------------------------------------------------------------------------
13 13 # Imports
14 14 #-----------------------------------------------------------------------------
15 15 from contextlib import contextmanager
16 16
17 17 from IPython.core.getipython import get_ipython
18 18 from IPython.kernel.comm import Comm
19 19 from IPython.config import LoggingConfigurable
20 20 from IPython.utils.traitlets import Unicode, Dict, Instance, Bool, List, Tuple, Int
21 21 from IPython.utils.py3compat import string_types
22 22
23 23 #-----------------------------------------------------------------------------
24 24 # Classes
25 25 #-----------------------------------------------------------------------------
26 26 class CallbackDispatcher(LoggingConfigurable):
27 27 """A structure for registering and running callbacks"""
28 28 callbacks = List()
29 29
30 30 def __call__(self, *args, **kwargs):
31 31 """Call all of the registered callbacks."""
32 32 value = None
33 33 for callback in self.callbacks:
34 34 try:
35 35 local_value = callback(*args, **kwargs)
36 36 except Exception as e:
37 37 ip = get_ipython()
38 38 if ip is None:
39 39 self.log.warn("Exception in callback %s: %s", callback, e, exc_info=True)
40 40 else:
41 41 ip.showtraceback()
42 42 else:
43 43 value = local_value if local_value is not None else value
44 44 return value
45 45
46 46 def register_callback(self, callback, remove=False):
47 47 """(Un)Register a callback
48 48
49 49 Parameters
50 50 ----------
51 51 callback: method handle
52 52 Method to be registered or unregistered.
53 53 remove=False: bool
54 54 Whether to unregister the callback."""
55 55
56 56 # (Un)Register the callback.
57 57 if remove and callback in self.callbacks:
58 58 self.callbacks.remove(callback)
59 59 elif not remove and callback not in self.callbacks:
60 60 self.callbacks.append(callback)
61 61
62 62 def _show_traceback(method):
63 63 """decorator for showing tracebacks in IPython"""
64 64 def m(self, *args, **kwargs):
65 65 try:
66 66 return(method(self, *args, **kwargs))
67 67 except Exception as e:
68 68 ip = get_ipython()
69 69 if ip is None:
70 70 self.log.warn("Exception in widget method %s: %s", method, e, exc_info=True)
71 71 else:
72 72 ip.showtraceback()
73 73 return m
74 74
75 75 class Widget(LoggingConfigurable):
76 76 #-------------------------------------------------------------------------
77 77 # Class attributes
78 78 #-------------------------------------------------------------------------
79 79 _widget_construction_callback = None
80 80 widgets = {}
81 81
82 82 @staticmethod
83 83 def on_widget_constructed(callback):
84 84 """Registers a callback to be called when a widget is constructed.
85 85
86 86 The callback must have the following signature:
87 87 callback(widget)"""
88 88 Widget._widget_construction_callback = callback
89 89
90 90 @staticmethod
91 91 def _call_widget_constructed(widget):
92 92 """Static method, called when a widget is constructed."""
93 93 if Widget._widget_construction_callback is not None and callable(Widget._widget_construction_callback):
94 94 Widget._widget_construction_callback(widget)
95 95
96 96 #-------------------------------------------------------------------------
97 97 # Traits
98 98 #-------------------------------------------------------------------------
99 99 _model_name = Unicode('WidgetModel', help="""Name of the backbone model
100 100 registered in the front-end to create and sync this widget with.""")
101 101 _view_name = Unicode(help="""Default view registered in the front-end
102 102 to use to represent the widget.""", sync=True)
103 103 _comm = Instance('IPython.kernel.comm.Comm')
104 104
105 105 msg_throttle = Int(3, sync=True, help="""Maximum number of msgs the
106 106 front-end can send before receiving an idle msg from the back-end.""")
107 107
108 108 keys = List()
109 109 def _keys_default(self):
110 110 return [name for name in self.traits(sync=True)]
111 111
112 112 _property_lock = Tuple((None, None))
113 113
114 114 _display_callbacks = Instance(CallbackDispatcher, ())
115 115 _msg_callbacks = Instance(CallbackDispatcher, ())
116 116
117 117 #-------------------------------------------------------------------------
118 118 # (Con/de)structor
119 119 #-------------------------------------------------------------------------
120 120 def __init__(self, **kwargs):
121 121 """Public constructor"""
122 122 super(Widget, self).__init__(**kwargs)
123 123
124 124 self.on_trait_change(self._handle_property_changed, self.keys)
125 125 Widget._call_widget_constructed(self)
126 126
127 127 def __del__(self):
128 128 """Object disposal"""
129 129 self.close()
130 130
131 131 #-------------------------------------------------------------------------
132 132 # Properties
133 133 #-------------------------------------------------------------------------
134 134
135 135 @property
136 136 def comm(self):
137 137 """Gets the Comm associated with this widget.
138 138
139 139 If a Comm doesn't exist yet, a Comm will be created automagically."""
140 140 if self._comm is None:
141 141 # Create a comm.
142 142 self._comm = Comm(target_name=self._model_name)
143 143 self._comm.on_msg(self._handle_msg)
144 144 self._comm.on_close(self._close)
145 145 Widget.widgets[self.model_id] = self
146 146
147 147 # first update
148 148 self.send_state()
149 149 return self._comm
150 150
151 151 @property
152 152 def model_id(self):
153 153 """Gets the model id of this widget.
154 154
155 155 If a Comm doesn't exist yet, a Comm will be created automagically."""
156 156 return self.comm.comm_id
157 157
158 158 #-------------------------------------------------------------------------
159 159 # Methods
160 160 #-------------------------------------------------------------------------
161 161 def _close(self):
162 162 """Private close - cleanup objects, registry entries"""
163 163 del Widget.widgets[self.model_id]
164 164 self._comm = None
165 165
166 166 def close(self):
167 167 """Close method.
168 168
169 169 Closes the widget which closes the underlying comm.
170 170 When the comm is closed, all of the widget views are automatically
171 171 removed from the front-end."""
172 172 if self._comm is not None:
173 173 self._comm.close()
174 174 self._close()
175 175
176 176 def send_state(self, key=None):
177 177 """Sends the widget state, or a piece of it, to the front-end.
178 178
179 179 Parameters
180 180 ----------
181 181 key : unicode (optional)
182 182 A single property's name to sync with the front-end.
183 183 """
184 184 self._send({
185 185 "method" : "update",
186 186 "state" : self.get_state()
187 187 })
188 188
189 189 def get_state(self, key=None):
190 190 """Gets the widget state, or a piece of it.
191 191
192 192 Parameters
193 193 ----------
194 194 key : unicode (optional)
195 195 A single property's name to get.
196 196 """
197 197 keys = self.keys if key is None else [key]
198 return {k: self._pack_widgets(getattr(self, k)) for k in keys}
199
198 state = {}
199 for k in keys:
200 f = self.trait_metadata(k, 'to_json')
201 value = getattr(self, k)
202 if f is not None:
203 state[k] = f(value)
204 else:
205 state[k] = self._serialize_trait(value)
206 return state
207
200 208 def send(self, content):
201 209 """Sends a custom msg to the widget model in the front-end.
202 210
203 211 Parameters
204 212 ----------
205 213 content : dict
206 214 Content of the message to send.
207 215 """
208 216 self._send({"method": "custom", "content": content})
209 217
210 218 def on_msg(self, callback, remove=False):
211 219 """(Un)Register a custom msg receive callback.
212 220
213 221 Parameters
214 222 ----------
215 223 callback: callable
216 224 callback will be passed two arguments when a message arrives::
217 225
218 226 callback(widget, content)
219 227
220 228 remove: bool
221 229 True if the callback should be unregistered."""
222 230 self._msg_callbacks.register_callback(callback, remove=remove)
223 231
224 232 def on_displayed(self, callback, remove=False):
225 233 """(Un)Register a widget displayed callback.
226 234
227 235 Parameters
228 236 ----------
229 237 callback: method handler
230 238 Must have a signature of::
231 239
232 240 callback(widget, **kwargs)
233 241
234 242 kwargs from display are passed through without modification.
235 243 remove: bool
236 244 True if the callback should be unregistered."""
237 245 self._display_callbacks.register_callback(callback, remove=remove)
238 246
239 247 #-------------------------------------------------------------------------
240 248 # Support methods
241 249 #-------------------------------------------------------------------------
242 250 @contextmanager
243 251 def _lock_property(self, key, value):
244 252 """Lock a property-value pair.
245 253
246 254 NOTE: This, in addition to the single lock for all state changes, is
247 255 flawed. In the future we may want to look into buffering state changes
248 256 back to the front-end."""
249 257 self._property_lock = (key, value)
250 258 try:
251 259 yield
252 260 finally:
253 261 self._property_lock = (None, None)
254 262
255 263 def _should_send_property(self, key, value):
256 264 """Check the property lock (property_lock)"""
257 265 return key != self._property_lock[0] or \
258 266 value != self._property_lock[1]
259 267
260 268 # Event handlers
261 269 @_show_traceback
262 270 def _handle_msg(self, msg):
263 271 """Called when a msg is received from the front-end"""
264 272 data = msg['content']['data']
265 273 method = data['method']
266 274 if not method in ['backbone', 'custom']:
267 275 self.log.error('Unknown front-end to back-end widget msg with method "%s"' % method)
268 276
269 277 # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
270 278 if method == 'backbone' and 'sync_data' in data:
271 279 sync_data = data['sync_data']
272 280 self._handle_receive_state(sync_data) # handles all methods
273 281
274 282 # Handle a custom msg from the front-end
275 283 elif method == 'custom':
276 284 if 'content' in data:
277 285 self._handle_custom_msg(data['content'])
278 286
279 287 def _handle_receive_state(self, sync_data):
280 288 """Called when a state is received from the front-end."""
281 289 for name in self.keys:
282 290 if name in sync_data:
283 value = self._unpack_widgets(sync_data[name])
291 f = self.trait_metadata(name, 'from_json')
292 if f is not None:
293 value = f(sync_data[name])
294 else:
295 value = self._unserialize_trait(sync_data[name])
284 296 with self._lock_property(name, value):
285 297 setattr(self, name, value)
286 298
287 299 def _handle_custom_msg(self, content):
288 300 """Called when a custom msg is received."""
289 301 self._msg_callbacks(self, content)
290 302
291 303 def _handle_property_changed(self, name, old, new):
292 304 """Called when a property has been changed."""
293 305 # Make sure this isn't information that the front-end just sent us.
294 306 if self._should_send_property(name, new):
295 307 # Send new state to front-end
296 308 self.send_state(key=name)
297 309
298 310 def _handle_displayed(self, **kwargs):
299 311 """Called when a view has been displayed for this widget instance"""
300 312 self._display_callbacks(self, **kwargs)
301 313
302 def _pack_widgets(self, x):
303 """Recursively converts all widget instances to model id strings.
314 def _serialize_trait(self, x):
315 """Serialize a trait value to json
304 316
305 Children widgets will be stored and transmitted to the front-end by
306 their model ids. Return value must be JSON-able."""
317 Traverse lists/tuples and dicts and serialize their values as well.
318 Replace any widgets with their model_id
319 """
307 320 if isinstance(x, dict):
308 return {k: self._pack_widgets(v) for k, v in x.items()}
321 return {k: self._serialize_trait(v) for k, v in x.items()}
309 322 elif isinstance(x, (list, tuple)):
310 return [self._pack_widgets(v) for v in x]
323 return [self._serialize_trait(v) for v in x]
311 324 elif isinstance(x, Widget):
312 325 return x.model_id
313 326 else:
314 327 return x # Value must be JSON-able
315 328
316 def _unpack_widgets(self, x):
317 """Recursively converts all model id strings to widget instances.
329 def _unserialize_trait(self, x):
330 """Convert json values to objects
318 331
319 Children widgets will be stored and transmitted to the front-end by
320 their model ids."""
332 We explicitly support converting valid string widget UUIDs to Widget references.
333 """
321 334 if isinstance(x, dict):
322 return {k: self._unpack_widgets(v) for k, v in x.items()}
335 return {k: self._unserialize_trait(v) for k, v in x.items()}
323 336 elif isinstance(x, (list, tuple)):
324 return [self._unpack_widgets(v) for v in x]
325 elif isinstance(x, string_types):
326 return x if x not in Widget.widgets else Widget.widgets[x]
337 return [self._unserialize_trait(v) for v in x]
338 elif isinstance(x, string_types) and x in Widget.widgets:
339 # we want to support having child widgets at any level in a hierarchy
340 # trusting that a widget UUID will not appear out in the wild
341 return Widget.widgets[x]
327 342 else:
328 343 return x
329 344
330 345 def _ipython_display_(self, **kwargs):
331 346 """Called when `IPython.display.display` is called on the widget."""
332 347 # Show view. By sending a display message, the comm is opened and the
333 348 # initial state is sent.
334 349 self._send({"method": "display"})
335 350 self._handle_displayed(**kwargs)
336 351
337 352 def _send(self, msg):
338 353 """Sends a message to the model in the front-end."""
339 354 self.comm.send(msg)
340 355
341 356
342 357 class DOMWidget(Widget):
343 358 visible = Bool(True, help="Whether the widget is visible.", sync=True)
344 359 _css = List(sync=True) # Internal CSS property list: (selector, key, value)
345 360
346 361 def get_css(self, key, selector=""):
347 362 """Get a CSS property of the widget.
348 363
349 364 Note: This function does not actually request the CSS from the
350 365 front-end; Only properties that have been set with set_css can be read.
351 366
352 367 Parameters
353 368 ----------
354 369 key: unicode
355 370 CSS key
356 371 selector: unicode (optional)
357 372 JQuery selector used when the CSS key/value was set.
358 373 """
359 374 if selector in self._css and key in self._css[selector]:
360 375 return self._css[selector][key]
361 376 else:
362 377 return None
363 378
364 379 def set_css(self, dict_or_key, value=None, selector=''):
365 380 """Set one or more CSS properties of the widget.
366 381
367 382 This function has two signatures:
368 383 - set_css(css_dict, selector='')
369 384 - set_css(key, value, selector='')
370 385
371 386 Parameters
372 387 ----------
373 388 css_dict : dict
374 389 CSS key/value pairs to apply
375 390 key: unicode
376 391 CSS key
377 392 value:
378 393 CSS value
379 394 selector: unicode (optional, kwarg only)
380 395 JQuery selector to use to apply the CSS key/value. If no selector
381 396 is provided, an empty selector is used. An empty selector makes the
382 397 front-end try to apply the css to a default element. The default
383 398 element is an attribute unique to each view, which is a DOM element
384 399 of the view that should be styled with common CSS (see
385 400 `$el_to_style` in the Javascript code).
386 401 """
387 402 if value is None:
388 403 css_dict = dict_or_key
389 404 else:
390 405 css_dict = {dict_or_key: value}
391 406
392 407 for (key, value) in css_dict.items():
393 408 # First remove the selector/key pair from the css list if it exists.
394 409 # Then add the selector/key pair and new value to the bottom of the
395 410 # list.
396 411 self._css = [x for x in self._css if not (x[0]==selector and x[1]==key)]
397 412 self._css += [(selector, key, value)]
398 413 self.send_state('_css')
399 414
400 415 def add_class(self, class_names, selector=""):
401 416 """Add class[es] to a DOM element.
402 417
403 418 Parameters
404 419 ----------
405 420 class_names: unicode or list
406 421 Class name(s) to add to the DOM element(s).
407 422 selector: unicode (optional)
408 423 JQuery selector to select the DOM element(s) that the class(es) will
409 424 be added to.
410 425 """
411 426 class_list = class_names
412 427 if isinstance(class_list, (list, tuple)):
413 428 class_list = ' '.join(class_list)
414 429
415 430 self.send({
416 431 "msg_type" : "add_class",
417 432 "class_list" : class_list,
418 433 "selector" : selector
419 434 })
420 435
421 436 def remove_class(self, class_names, selector=""):
422 437 """Remove class[es] from a DOM element.
423 438
424 439 Parameters
425 440 ----------
426 441 class_names: unicode or list
427 442 Class name(s) to remove from the DOM element(s).
428 443 selector: unicode (optional)
429 444 JQuery selector to select the DOM element(s) that the class(es) will
430 445 be removed from.
431 446 """
432 447 class_list = class_names
433 448 if isinstance(class_list, (list, tuple)):
434 449 class_list = ' '.join(class_list)
435 450
436 451 self.send({
437 452 "msg_type" : "remove_class",
438 453 "class_list" : class_list,
439 454 "selector" : selector,
440 455 })
General Comments 0
You need to be logged in to leave comments. Login now