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