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