##// END OF EJS Templates
Implement a context manager as a property locking mechanism in Widget.
Jonathan Frederic -
Show More
@@ -1,411 +1,419 b''
1 1 """Base Widget class. Allows user to create widgets in the backend that render
2 2 in the IPython notebook frontend.
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 copy import copy
16 16 from glob import glob
17 from contextlib import contextmanager
17 18 import uuid
18 19 import sys
19 20 import os
20 21 import inspect
21 22 import types
22 23
23 24 import IPython
24 25 from IPython.kernel.comm import Comm
25 26 from IPython.config import LoggingConfigurable
26 27 from IPython.utils.traitlets import Unicode, Dict, List, Instance, Bool
27 28 from IPython.display import Javascript, display
28 29 from IPython.utils.py3compat import string_types
29 30
30 31 #-----------------------------------------------------------------------------
31 32 # Classes
32 33 #-----------------------------------------------------------------------------
34 @contextmanager
35 def PropertyLock(instance, key, value):
36 instance._property_lock = (key, value)
37 yield
38 del instance._property_lock
39
40 def should_send_property(instance, key, value):
41 return not hasattr(instance, _property_lock) or \
42 key != instance._property_lock[0] or \
43 value != instance._property_lock[1]
44
33 45
34 46 class Widget(LoggingConfigurable):
35 47
36 48 # Shared declarations (Class level)
37 49 widget_construction_callback = None
38 50
39 51 keys = ['view_name']
40 52
41 53 def on_widget_constructed(callback):
42 54 """Class method, registers a callback to be called when a widget is
43 55 constructed. The callback must have the following signature:
44 56 callback(widget)"""
45 57 Widget.widget_construction_callback = callback
46 58
47 59 def _call_widget_constructed(widget):
48 60 """Class method, called when a widget is constructed."""
49 61 if Widget.widget_construction_callback is not None and callable(Widget.widget_construction_callback):
50 62 Widget.widget_construction_callback(widget)
51 63
52 64
53 65
54 66 # Public declarations (Instance level)
55 67 target_name = Unicode('widget', help="""Name of the backbone model
56 68 registered in the frontend to create and sync this widget with.""")
57 69 view_name = Unicode(help="""Default view registered in the frontend
58 70 to use to represent the widget.""")
59 71
60 72 # Private/protected declarations
61 # todo: change this to a context manager
62 _property_lock = (None, None) # Last updated (key, value) from the front-end. Prevents echo.
63 73 _comm = Instance('IPython.kernel.comm.Comm')
64 74
65 75 def __init__(self, **kwargs):
66 76 """Public constructor
67 77 """
68 78 self._display_callbacks = []
69 79 self._msg_callbacks = []
70 80 super(Widget, self).__init__(**kwargs)
71 81
72 82 self.on_trait_change(self._handle_property_changed, self.keys)
73 83 Widget._call_widget_constructed(self)
74 84
75 85 def __del__(self):
76 86 """Object disposal"""
77 87 self.close()
78 88
79 89
80 90 def close(self):
81 91 """Close method. Closes the widget which closes the underlying comm.
82 92 When the comm is closed, all of the widget views are automatically
83 93 removed from the frontend."""
84 94 self._close_communication()
85 95
86 96 @property
87 97 def comm(self):
88 98 if self._comm is None:
89 99 self._open_communication()
90 100 return self._comm
91 101
92 102 @property
93 103 def model_id(self):
94 104 return self.comm.comm_id
95 105
96 106 # Event handlers
97 107 def _handle_msg(self, msg):
98 108 """Called when a msg is recieved from the frontend"""
99 109 data = msg['content']['data']
100 110 method = data['method']
101 111
102 112 # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
103 113 if method == 'backbone' and 'sync_data' in data:
104 114 sync_data = data['sync_data']
105 115 self._handle_recieve_state(sync_data) # handles all methods
106 116
107 117 # Handle a custom msg from the front-end
108 118 elif method == 'custom':
109 119 if 'custom_content' in data:
110 120 self._handle_custom_msg(data['custom_content'])
111 121
112 122
113 123 def _handle_recieve_state(self, sync_data):
114 124 """Called when a state is recieved from the frontend."""
115 125 for name in self.keys:
116 126 if name in sync_data:
117 try:
118 self._property_lock = (name, sync_data[name])
119 setattr(self, name, sync_data[name])
120 finally:
121 self._property_lock = (None, None)
127 value = sync_data[name]
128 with PropertyLock(self, name, value):
129 setattr(self, name, value)
122 130
123 131
124 132 def _handle_custom_msg(self, content):
125 133 """Called when a custom msg is recieved."""
126 134 for handler in self._msg_callbacks:
127 135 if callable(handler):
128 136 argspec = inspect.getargspec(handler)
129 137 nargs = len(argspec[0])
130 138
131 139 # Bound methods have an additional 'self' argument
132 140 if isinstance(handler, types.MethodType):
133 141 nargs -= 1
134 142
135 143 # Call the callback
136 144 if nargs == 1:
137 145 handler(content)
138 146 elif nargs == 2:
139 147 handler(self, content)
140 148 else:
141 149 raise TypeError('Widget msg callback must ' \
142 150 'accept 1 or 2 arguments, not %d.' % nargs)
143 151
144 152
145 153 def _handle_property_changed(self, name, old, new):
146 154 """Called when a property has been changed."""
147 155 # Make sure this isn't information that the front-end just sent us.
148 if self._property_lock[0] != name and self._property_lock[1] != new:
156 if should_send_property(self, name, new):
149 157 # Send new state to frontend
150 158 self.send_state(key=name)
151 159
152 160 def _handle_displayed(self, **kwargs):
153 161 """Called when a view has been displayed for this widget instance"""
154 162 for handler in self._display_callbacks:
155 163 if callable(handler):
156 164 argspec = inspect.getargspec(handler)
157 165 nargs = len(argspec[0])
158 166
159 167 # Bound methods have an additional 'self' argument
160 168 if isinstance(handler, types.MethodType):
161 169 nargs -= 1
162 170
163 171 # Call the callback
164 172 if nargs == 0:
165 173 handler()
166 174 elif nargs == 1:
167 175 handler(self)
168 176 else:
169 177 handler(self, **kwargs)
170 178
171 179 # Public methods
172 180 def send_state(self, key=None):
173 181 """Sends the widget state, or a piece of it, to the frontend.
174 182
175 183 Parameters
176 184 ----------
177 185 key : unicode (optional)
178 186 A single property's name to sync with the frontend.
179 187 """
180 188 self._send({"method": "update",
181 189 "state": self.get_state()})
182 190
183 191 def get_state(self, key=None):
184 192 """Gets the widget state, or a piece of it.
185 193
186 194 Parameters
187 195 ----------
188 196 key : unicode (optional)
189 197 A single property's name to get.
190 198 """
191 199 state = {}
192 200
193 201 # If a key is provided, just send the state of that key.
194 202 if key is None:
195 203 keys = self.keys[:]
196 204 else:
197 205 keys = [key]
198 206 for k in keys:
199 207 value = getattr(self, k)
200 208
201 209 # a more elegant solution to encoding Widgets would be
202 210 # to tap into the JSON encoder and teach it how to deal
203 211 # with Widget objects, or maybe just teach the JSON
204 212 # encoder to look for a _repr_json property before giving
205 213 # up encoding
206 214 if isinstance(value, Widget):
207 215 value = value.model_id
208 216 elif isinstance(value, list) and len(value)>0 and isinstance(value[0], Widget):
209 217 # assume all elements of the list are widgets
210 218 value = [i.model_id for i in value]
211 219 state[k] = value
212 220 return state
213 221
214 222
215 223 def send(self, content):
216 224 """Sends a custom msg to the widget model in the front-end.
217 225
218 226 Parameters
219 227 ----------
220 228 content : dict
221 229 Content of the message to send.
222 230 """
223 231 self._send({"method": "custom",
224 232 "custom_content": content})
225 233
226 234
227 235 def on_msg(self, callback, remove=False):
228 236 """Register a callback for when a custom msg is recieved from the front-end
229 237
230 238 Parameters
231 239 ----------
232 240 callback: method handler
233 241 Can have a signature of:
234 242 - callback(content)
235 243 - callback(sender, content)
236 244 remove: bool
237 245 True if the callback should be unregistered."""
238 246 if remove and callback in self._msg_callbacks:
239 247 self._msg_callbacks.remove(callback)
240 248 elif not remove and not callback in self._msg_callbacks:
241 249 self._msg_callbacks.append(callback)
242 250
243 251
244 252 def on_displayed(self, callback, remove=False):
245 253 """Register a callback to be called when the widget has been displayed
246 254
247 255 Parameters
248 256 ----------
249 257 callback: method handler
250 258 Can have a signature of:
251 259 - callback()
252 260 - callback(sender)
253 261 - callback(sender, **kwargs)
254 262 kwargs from display call passed through without modification.
255 263 remove: bool
256 264 True if the callback should be unregistered."""
257 265 if remove and callback in self._display_callbacks:
258 266 self._display_callbacks.remove(callback)
259 267 elif not remove and not callback in self._display_callbacks:
260 268 self._display_callbacks.append(callback)
261 269
262 270
263 271 # Support methods
264 272 def _repr_widget_(self, **kwargs):
265 273 """Function that is called when `IPython.display.display` is called on
266 274 the widget."""
267 275
268 276 # Show view. By sending a display message, the comm is opened and the
269 277 # initial state is sent.
270 278 self._send({"method": "display"})
271 279 self._handle_displayed(**kwargs)
272 280
273 281
274 282 def _open_communication(self):
275 283 """Opens a communication with the front-end."""
276 284 # Create a comm.
277 285 if self._comm is None:
278 286 self._comm = Comm(target_name=self.target_name)
279 287 self._comm.on_msg(self._handle_msg)
280 288 self._comm.on_close(self._close_communication)
281 289
282 290 # first update
283 291 self.send_state()
284 292
285 293
286 294 def _close_communication(self):
287 295 """Closes a communication with the front-end."""
288 296 if self._comm is not None:
289 297 try:
290 298 self._comm.close()
291 299 finally:
292 300 self._comm = None
293 301
294 302
295 303 def _send(self, msg):
296 304 """Sends a message to the model in the front-end"""
297 305 self.comm.send(msg)
298 306
299 307
300 308 class DOMWidget(Widget):
301 309 visible = Bool(True, help="Whether or not the widget is visible.")
302 310
303 311 # Private/protected declarations
304 312 _css = Dict() # Internal CSS property dict
305 313
306 314 keys = ['visible', '_css'] + Widget.keys
307 315
308 316 def get_css(self, key, selector=""):
309 317 """Get a CSS property of the widget. Note, this function does not
310 318 actually request the CSS from the front-end; Only properties that have
311 319 been set with set_css can be read.
312 320
313 321 Parameters
314 322 ----------
315 323 key: unicode
316 324 CSS key
317 325 selector: unicode (optional)
318 326 JQuery selector used when the CSS key/value was set.
319 327 """
320 328 if selector in self._css and key in self._css[selector]:
321 329 return self._css[selector][key]
322 330 else:
323 331 return None
324 332
325 333
326 334 def set_css(self, *args, **kwargs):
327 335 """Set one or more CSS properties of the widget (shared among all of the
328 336 views). This function has two signatures:
329 337 - set_css(css_dict, selector='')
330 338 - set_css(key, value, selector='')
331 339
332 340 Parameters
333 341 ----------
334 342 css_dict : dict
335 343 CSS key/value pairs to apply
336 344 key: unicode
337 345 CSS key
338 346 value
339 347 CSS value
340 348 selector: unicode (optional)
341 349 JQuery selector to use to apply the CSS key/value.
342 350 """
343 351 selector = kwargs.get('selector', '')
344 352
345 353 # Signature 1: set_css(css_dict, selector='')
346 354 if len(args) == 1:
347 355 if isinstance(args[0], dict):
348 356 for (key, value) in args[0].items():
349 357 if not (key in self._css[selector] and value in self._css[selector][key]):
350 358 self._css[selector][key] = value
351 359 self.send_state('_css')
352 360 else:
353 361 raise Exception('css_dict must be a dict.')
354 362
355 363 # Signature 2: set_css(key, value, selector='')
356 364 elif len(args) == 2 or len(args) == 3:
357 365
358 366 # Selector can be a positional arg if it's the 3rd value
359 367 if len(args) == 3:
360 368 selector = args[2]
361 369 if selector not in self._css:
362 370 self._css[selector] = {}
363 371
364 372 # Only update the property if it has changed.
365 373 key = args[0]
366 374 value = args[1]
367 375 if not (key in self._css[selector] and value in self._css[selector][key]):
368 376 self._css[selector][key] = value
369 377 self.send_state('_css') # Send new state to client.
370 378 else:
371 379 raise Exception('set_css only accepts 1-3 arguments')
372 380
373 381
374 382 def add_class(self, class_names, selector=""):
375 383 """Add class[es] to a DOM element
376 384
377 385 Parameters
378 386 ----------
379 387 class_names: unicode or list
380 388 Class name(s) to add to the DOM element(s).
381 389 selector: unicode (optional)
382 390 JQuery selector to select the DOM element(s) that the class(es) will
383 391 be added to.
384 392 """
385 393 class_list = class_names
386 394 if isinstance(list, class_list):
387 395 class_list = ' '.join(class_list)
388 396
389 397 self.send({"msg_type": "add_class",
390 398 "class_list": class_list,
391 399 "selector": selector})
392 400
393 401
394 402 def remove_class(self, class_names, selector=""):
395 403 """Remove class[es] from a DOM element
396 404
397 405 Parameters
398 406 ----------
399 407 class_names: unicode or list
400 408 Class name(s) to remove from the DOM element(s).
401 409 selector: unicode (optional)
402 410 JQuery selector to select the DOM element(s) that the class(es) will
403 411 be removed from.
404 412 """
405 413 class_list = class_names
406 414 if isinstance(list, class_list):
407 415 class_list = ' '.join(class_list)
408 416
409 417 self.send({"msg_type": "remove_class",
410 418 "class_list": class_list,
411 419 "selector": selector})
General Comments 0
You need to be logged in to leave comments. Login now