##// END OF EJS Templates
Before syncing a widget's state, check first for the property lock, then for the widget state lock
Jason Grout -
Show More
@@ -1,471 +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(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 205 f = self.trait_metadata(k, 'to_json')
206 206 if f is None:
207 207 f = self._trait_to_json
208 208 value = getattr(self, k)
209 209 state[k] = f(value)
210 210 return state
211 211
212 212 def send(self, content):
213 213 """Sends a custom msg to the widget model in the front-end.
214 214
215 215 Parameters
216 216 ----------
217 217 content : dict
218 218 Content of the message to send.
219 219 """
220 220 self._send({"method": "custom", "content": content})
221 221
222 222 def on_msg(self, callback, remove=False):
223 223 """(Un)Register a custom msg receive callback.
224 224
225 225 Parameters
226 226 ----------
227 227 callback: callable
228 228 callback will be passed two arguments when a message arrives::
229 229
230 230 callback(widget, content)
231 231
232 232 remove: bool
233 233 True if the callback should be unregistered."""
234 234 self._msg_callbacks.register_callback(callback, remove=remove)
235 235
236 236 def on_displayed(self, callback, remove=False):
237 237 """(Un)Register a widget displayed callback.
238 238
239 239 Parameters
240 240 ----------
241 241 callback: method handler
242 242 Must have a signature of::
243 243
244 244 callback(widget, **kwargs)
245 245
246 246 kwargs from display are passed through without modification.
247 247 remove: bool
248 248 True if the callback should be unregistered."""
249 249 self._display_callbacks.register_callback(callback, remove=remove)
250 250
251 251 #-------------------------------------------------------------------------
252 252 # Support methods
253 253 #-------------------------------------------------------------------------
254 254 @contextmanager
255 255 def _lock_property(self, key, value):
256 256 """Lock a property-value pair.
257 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 self._send_state_lock > 0:
283 if (key == self._property_lock[0] and value == self._property_lock[1]):
284 return False
285 elif self._send_state_lock > 0:
284 286 self._states_to_send.add(key)
285 287 return False
286 return key != self._property_lock[0] or value != self._property_lock[1]
288 else:
289 return True
287 290
288 291 # Event handlers
289 292 @_show_traceback
290 293 def _handle_msg(self, msg):
291 294 """Called when a msg is received from the front-end"""
292 295 data = msg['content']['data']
293 296 method = data['method']
294 297 if not method in ['backbone', 'custom']:
295 298 self.log.error('Unknown front-end to back-end widget msg with method "%s"' % method)
296 299
297 300 # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
298 301 if method == 'backbone' and 'sync_data' in data:
299 302 sync_data = data['sync_data']
300 303 self._handle_receive_state(sync_data) # handles all methods
301 304
302 305 # Handle a custom msg from the front-end
303 306 elif method == 'custom':
304 307 if 'content' in data:
305 308 self._handle_custom_msg(data['content'])
306 309
307 310 def _handle_receive_state(self, sync_data):
308 311 """Called when a state is received from the front-end."""
309 312 for name in self.keys:
310 313 if name in sync_data:
311 314 f = self.trait_metadata(name, 'from_json')
312 315 if f is None:
313 316 f = self._trait_from_json
314 317 value = f(sync_data[name])
315 318 with self._lock_property(name, value):
316 319 setattr(self, name, value)
317 320
318 321 def _handle_custom_msg(self, content):
319 322 """Called when a custom msg is received."""
320 323 self._msg_callbacks(self, content)
321 324
322 325 def _handle_property_changed(self, name, old, new):
323 326 """Called when a property has been changed."""
324 327 # Make sure this isn't information that the front-end just sent us.
325 328 if self._should_send_property(name, new):
326 329 # Send new state to front-end
327 330 self.send_state(key=name)
328 331
329 332 def _handle_displayed(self, **kwargs):
330 333 """Called when a view has been displayed for this widget instance"""
331 334 self._display_callbacks(self, **kwargs)
332 335
333 336 def _trait_to_json(self, x):
334 337 """Convert a trait value to json
335 338
336 339 Traverse lists/tuples and dicts and serialize their values as well.
337 340 Replace any widgets with their model_id
338 341 """
339 342 if isinstance(x, dict):
340 343 return {k: self._trait_to_json(v) for k, v in x.items()}
341 344 elif isinstance(x, (list, tuple)):
342 345 return [self._trait_to_json(v) for v in x]
343 346 elif isinstance(x, Widget):
344 347 return "IPY_MODEL_" + x.model_id
345 348 else:
346 349 return x # Value must be JSON-able
347 350
348 351 def _trait_from_json(self, x):
349 352 """Convert json values to objects
350 353
351 354 Replace any strings representing valid model id values to Widget references.
352 355 """
353 356 if isinstance(x, dict):
354 357 return {k: self._trait_from_json(v) for k, v in x.items()}
355 358 elif isinstance(x, (list, tuple)):
356 359 return [self._trait_from_json(v) for v in x]
357 360 elif isinstance(x, string_types) and x.startswith('IPY_MODEL_') and x[10:] in Widget.widgets:
358 361 # we want to support having child widgets at any level in a hierarchy
359 362 # trusting that a widget UUID will not appear out in the wild
360 363 return Widget.widgets[x]
361 364 else:
362 365 return x
363 366
364 367 def _ipython_display_(self, **kwargs):
365 368 """Called when `IPython.display.display` is called on the widget."""
366 369 # Show view. By sending a display message, the comm is opened and the
367 370 # initial state is sent.
368 371 self._send({"method": "display"})
369 372 self._handle_displayed(**kwargs)
370 373
371 374 def _send(self, msg):
372 375 """Sends a message to the model in the front-end."""
373 376 self.comm.send(msg)
374 377
375 378
376 379 class DOMWidget(Widget):
377 380 visible = Bool(True, help="Whether the widget is visible.", sync=True)
378 381 _css = List(sync=True) # Internal CSS property list: (selector, key, value)
379 382
380 383 def get_css(self, key, selector=""):
381 384 """Get a CSS property of the widget.
382 385
383 386 Note: This function does not actually request the CSS from the
384 387 front-end; Only properties that have been set with set_css can be read.
385 388
386 389 Parameters
387 390 ----------
388 391 key: unicode
389 392 CSS key
390 393 selector: unicode (optional)
391 394 JQuery selector used when the CSS key/value was set.
392 395 """
393 396 if selector in self._css and key in self._css[selector]:
394 397 return self._css[selector][key]
395 398 else:
396 399 return None
397 400
398 401 def set_css(self, dict_or_key, value=None, selector=''):
399 402 """Set one or more CSS properties of the widget.
400 403
401 404 This function has two signatures:
402 405 - set_css(css_dict, selector='')
403 406 - set_css(key, value, selector='')
404 407
405 408 Parameters
406 409 ----------
407 410 css_dict : dict
408 411 CSS key/value pairs to apply
409 412 key: unicode
410 413 CSS key
411 414 value:
412 415 CSS value
413 416 selector: unicode (optional, kwarg only)
414 417 JQuery selector to use to apply the CSS key/value. If no selector
415 418 is provided, an empty selector is used. An empty selector makes the
416 419 front-end try to apply the css to the top-level element.
417 420 """
418 421 if value is None:
419 422 css_dict = dict_or_key
420 423 else:
421 424 css_dict = {dict_or_key: value}
422 425
423 426 for (key, value) in css_dict.items():
424 427 # First remove the selector/key pair from the css list if it exists.
425 428 # Then add the selector/key pair and new value to the bottom of the
426 429 # list.
427 430 self._css = [x for x in self._css if not (x[0]==selector and x[1]==key)]
428 431 self._css += [(selector, key, value)]
429 432 self.send_state('_css')
430 433
431 434 def add_class(self, class_names, selector=""):
432 435 """Add class[es] to a DOM element.
433 436
434 437 Parameters
435 438 ----------
436 439 class_names: unicode or list
437 440 Class name(s) to add to the DOM element(s).
438 441 selector: unicode (optional)
439 442 JQuery selector to select the DOM element(s) that the class(es) will
440 443 be added to.
441 444 """
442 445 class_list = class_names
443 446 if isinstance(class_list, (list, tuple)):
444 447 class_list = ' '.join(class_list)
445 448
446 449 self.send({
447 450 "msg_type" : "add_class",
448 451 "class_list" : class_list,
449 452 "selector" : selector
450 453 })
451 454
452 455 def remove_class(self, class_names, selector=""):
453 456 """Remove class[es] from a DOM element.
454 457
455 458 Parameters
456 459 ----------
457 460 class_names: unicode or list
458 461 Class name(s) to remove from the DOM element(s).
459 462 selector: unicode (optional)
460 463 JQuery selector to select the DOM element(s) that the class(es) will
461 464 be removed from.
462 465 """
463 466 class_list = class_names
464 467 if isinstance(class_list, (list, tuple)):
465 468 class_list = ' '.join(class_list)
466 469
467 470 self.send({
468 471 "msg_type" : "remove_class",
469 472 "class_list" : class_list,
470 473 "selector" : selector,
471 474 })
General Comments 0
You need to be logged in to leave comments. Login now