##// END OF EJS Templates
Merge pull request #6296 from jasongrout/sync-hold...
Jonathan Frederic -
r17653:1ed19915 merge
parent child Browse files
Show More
@@ -1,446 +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 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('WidgetView', 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 (key == self._property_lock[0] and value == self._property_lock[1]):
284 return False
285 elif self._send_state_lock > 0:
286 self._states_to_send.add(key)
287 return False
288 else:
289 return True
262 290
263 291 # Event handlers
264 292 @_show_traceback
265 293 def _handle_msg(self, msg):
266 294 """Called when a msg is received from the front-end"""
267 295 data = msg['content']['data']
268 296 method = data['method']
269 297 if not method in ['backbone', 'custom']:
270 298 self.log.error('Unknown front-end to back-end widget msg with method "%s"' % method)
271 299
272 300 # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
273 301 if method == 'backbone' and 'sync_data' in data:
274 302 sync_data = data['sync_data']
275 303 self._handle_receive_state(sync_data) # handles all methods
276 304
277 305 # Handle a custom msg from the front-end
278 306 elif method == 'custom':
279 307 if 'content' in data:
280 308 self._handle_custom_msg(data['content'])
281 309
282 310 def _handle_receive_state(self, sync_data):
283 311 """Called when a state is received from the front-end."""
284 312 for name in self.keys:
285 313 if name in sync_data:
286 314 f = self.trait_metadata(name, 'from_json')
287 315 if f is None:
288 316 f = self._trait_from_json
289 317 value = f(sync_data[name])
290 318 with self._lock_property(name, value):
291 319 setattr(self, name, value)
292 320
293 321 def _handle_custom_msg(self, content):
294 322 """Called when a custom msg is received."""
295 323 self._msg_callbacks(self, content)
296 324
297 325 def _handle_property_changed(self, name, old, new):
298 326 """Called when a property has been changed."""
299 327 # Make sure this isn't information that the front-end just sent us.
300 328 if self._should_send_property(name, new):
301 329 # Send new state to front-end
302 330 self.send_state(key=name)
303 331
304 332 def _handle_displayed(self, **kwargs):
305 333 """Called when a view has been displayed for this widget instance"""
306 334 self._display_callbacks(self, **kwargs)
307 335
308 336 def _trait_to_json(self, x):
309 337 """Convert a trait value to json
310 338
311 339 Traverse lists/tuples and dicts and serialize their values as well.
312 340 Replace any widgets with their model_id
313 341 """
314 342 if isinstance(x, dict):
315 343 return {k: self._trait_to_json(v) for k, v in x.items()}
316 344 elif isinstance(x, (list, tuple)):
317 345 return [self._trait_to_json(v) for v in x]
318 346 elif isinstance(x, Widget):
319 347 return "IPY_MODEL_" + x.model_id
320 348 else:
321 349 return x # Value must be JSON-able
322 350
323 351 def _trait_from_json(self, x):
324 352 """Convert json values to objects
325 353
326 354 Replace any strings representing valid model id values to Widget references.
327 355 """
328 356 if isinstance(x, dict):
329 357 return {k: self._trait_from_json(v) for k, v in x.items()}
330 358 elif isinstance(x, (list, tuple)):
331 359 return [self._trait_from_json(v) for v in x]
332 360 elif isinstance(x, string_types) and x.startswith('IPY_MODEL_') and x[10:] in Widget.widgets:
333 361 # we want to support having child widgets at any level in a hierarchy
334 362 # trusting that a widget UUID will not appear out in the wild
335 363 return Widget.widgets[x]
336 364 else:
337 365 return x
338 366
339 367 def _ipython_display_(self, **kwargs):
340 368 """Called when `IPython.display.display` is called on the widget."""
341 369 # Show view. By sending a display message, the comm is opened and the
342 370 # initial state is sent.
343 371 self._send({"method": "display"})
344 372 self._handle_displayed(**kwargs)
345 373
346 374 def _send(self, msg):
347 375 """Sends a message to the model in the front-end."""
348 376 self.comm.send(msg)
349 377
350 378
351 379 class DOMWidget(Widget):
352 380 visible = Bool(True, help="Whether the widget is visible.", sync=True)
353 381 _css = List(sync=True) # Internal CSS property list: (selector, key, value)
354 382
355 383 def get_css(self, key, selector=""):
356 384 """Get a CSS property of the widget.
357 385
358 386 Note: This function does not actually request the CSS from the
359 387 front-end; Only properties that have been set with set_css can be read.
360 388
361 389 Parameters
362 390 ----------
363 391 key: unicode
364 392 CSS key
365 393 selector: unicode (optional)
366 394 JQuery selector used when the CSS key/value was set.
367 395 """
368 396 if selector in self._css and key in self._css[selector]:
369 397 return self._css[selector][key]
370 398 else:
371 399 return None
372 400
373 401 def set_css(self, dict_or_key, value=None, selector=''):
374 402 """Set one or more CSS properties of the widget.
375 403
376 404 This function has two signatures:
377 405 - set_css(css_dict, selector='')
378 406 - set_css(key, value, selector='')
379 407
380 408 Parameters
381 409 ----------
382 410 css_dict : dict
383 411 CSS key/value pairs to apply
384 412 key: unicode
385 413 CSS key
386 414 value:
387 415 CSS value
388 416 selector: unicode (optional, kwarg only)
389 417 JQuery selector to use to apply the CSS key/value. If no selector
390 418 is provided, an empty selector is used. An empty selector makes the
391 419 front-end try to apply the css to the top-level element.
392 420 """
393 421 if value is None:
394 422 css_dict = dict_or_key
395 423 else:
396 424 css_dict = {dict_or_key: value}
397 425
398 426 for (key, value) in css_dict.items():
399 427 # First remove the selector/key pair from the css list if it exists.
400 428 # Then add the selector/key pair and new value to the bottom of the
401 429 # list.
402 430 self._css = [x for x in self._css if not (x[0]==selector and x[1]==key)]
403 431 self._css += [(selector, key, value)]
404 432 self.send_state('_css')
405 433
406 434 def add_class(self, class_names, selector=""):
407 435 """Add class[es] to a DOM element.
408 436
409 437 Parameters
410 438 ----------
411 439 class_names: unicode or list
412 440 Class name(s) to add to the DOM element(s).
413 441 selector: unicode (optional)
414 442 JQuery selector to select the DOM element(s) that the class(es) will
415 443 be added to.
416 444 """
417 445 class_list = class_names
418 446 if isinstance(class_list, (list, tuple)):
419 447 class_list = ' '.join(class_list)
420 448
421 449 self.send({
422 450 "msg_type" : "add_class",
423 451 "class_list" : class_list,
424 452 "selector" : selector
425 453 })
426 454
427 455 def remove_class(self, class_names, selector=""):
428 456 """Remove class[es] from a DOM element.
429 457
430 458 Parameters
431 459 ----------
432 460 class_names: unicode or list
433 461 Class name(s) to remove from the DOM element(s).
434 462 selector: unicode (optional)
435 463 JQuery selector to select the DOM element(s) that the class(es) will
436 464 be removed from.
437 465 """
438 466 class_list = class_names
439 467 if isinstance(class_list, (list, tuple)):
440 468 class_list = ' '.join(class_list)
441 469
442 470 self.send({
443 471 "msg_type" : "remove_class",
444 472 "class_list" : class_list,
445 473 "selector" : selector,
446 474 })
General Comments 0
You need to be logged in to leave comments. Login now