1
"""
2
Declares Syncable and Reactive classes which provides baseclasses
3
for Panel components which sync their state with one or more bokeh
4
models rendered on the frontend. 
5
"""
6

7 7
import difflib
8 7
import threading
9

10 7
from collections import namedtuple
11 7
from functools import partial
12

13 7
from bokeh.models import LayoutDOM
14 7
from tornado import gen
15

16 7
from .config import config
17 7
from .io.callbacks import PeriodicCallback
18 7
from .io.model import hold
19 7
from .io.notebook import push
20 7
from .io.server import unlocked
21 7
from .io.state import state
22 7
from .util import edit_readonly
23 7
from .viewable import Layoutable, Renderable, Viewable
24

25 7
LinkWatcher = namedtuple("Watcher","inst cls fn mode onlychanged parameter_names what queued target links transformed bidirectional_watcher")
26

27

28 7
class Syncable(Renderable):
29
    """
30
    Syncable is an extension of the Renderable object which can not
31
    only render to a bokeh model but also sync the parameters on the
32
    object with the properties on the model.
33

34
    In order to bi-directionally link parameters with bokeh model
35
    instances the _link_params and _link_props methods define
36
    callbacks triggered when either the parameter or bokeh property
37
    values change. Since there may not be a 1-to-1 mapping between
38
    parameter and the model property the _process_property_change and
39
    _process_param_change may be overridden to apply any necessary
40
    transformations.
41
    """
42

43
    # Timeout if a notebook comm message is swallowed
44 7
    _timeout = 20000
45

46
    # Timeout before the first event is processed
47 7
    _debounce = 50
48

49
    # Mapping from parameter name to bokeh model property name
50 7
    _rename = {}
51

52 7
    __abstract = True
53

54 7
    events = []
55

56 7
    def __init__(self, **params):
57 7
        super(Syncable, self).__init__(**params)
58 7
        self._processing = False
59 7
        self._events = {}
60 7
        self._callbacks = []
61 7
        self._links = []
62 7
        self._link_params()
63 7
        self._changing = {}
64

65
    # Allows defining a mapping from model property name to a JS code
66
    # snippet that transforms the object before serialization
67 7
    _js_transforms = {}
68

69
    # Transforms from input value to bokeh property value
70 7
    _source_transforms = {}
71 7
    _target_transforms = {}
72

73
    #----------------------------------------------------------------
74
    # Model API
75
    #----------------------------------------------------------------
76

77 7
    def _process_property_change(self, msg):
78
        """
79
        Transform bokeh model property changes into parameter updates.
80
        Should be overridden to provide appropriate mapping between
81
        parameter value and bokeh model change. By default uses the
82
        _rename class level attribute to map between parameter and
83
        property names.
84
        """
85 7
        inverted = {v: k for k, v in self._rename.items()}
86 7
        return {inverted.get(k, k): v for k, v in msg.items()}
87

88 7
    def _process_param_change(self, msg):
89
        """
90
        Transform parameter changes into bokeh model property updates.
91
        Should be overridden to provide appropriate mapping between
92
        parameter value and bokeh model change. By default uses the
93
        _rename class level attribute to map between parameter and
94
        property names.
95
        """
96 7
        properties = {self._rename.get(k, k): v for k, v in msg.items()
97
                      if self._rename.get(k, False) is not None}
98 7
        if 'width' in properties and self.sizing_mode is None:
99 7
            properties['min_width'] = properties['width']
100 7
        if 'height' in properties and self.sizing_mode is None:
101 7
            properties['min_height'] = properties['height']
102 7
        return properties
103

104 7
    def _link_params(self):
105 7
        params = self._synced_params()
106 7
        if params:
107 7
            watcher = self.param.watch(self._param_change, params)
108 7
            self._callbacks.append(watcher)
109

110 7
    def _link_props(self, model, properties, doc, root, comm=None):
111 7
        ref = root.ref['id']
112 7
        if config.embed:
113 7
            return
114

115 7
        for p in properties:
116 7
            if isinstance(p, tuple):
117 7
                _, p = p
118 7
            if comm:
119 7
                model.on_change(p, partial(self._comm_change, doc, ref, comm))
120
            else:
121 7
                model.on_change(p, partial(self._server_change, doc, ref))
122

123 7
    @property
124 2
    def _linkable_params(self):
125 0
        return [p for p in self._synced_params()
126
                if self._source_transforms.get(p, False) is not None]
127

128 7
    def _synced_params(self):
129 7
        return list(self.param)
130

131 7
    def _update_model(self, events, msg, root, model, doc, comm):
132 7
        self._changing[root.ref['id']] = [
133
            attr for attr, value in msg.items()
134
            if not model.lookup(attr).property.matches(getattr(model, attr), value)
135
        ]
136 7
        try:
137 7
            model.update(**msg)
138
        finally:
139 7
            del self._changing[root.ref['id']]
140

141 7
    def _cleanup(self, root):
142 7
        super(Syncable, self)._cleanup(root)
143 7
        ref = root.ref['id']
144 7
        self._models.pop(ref, None)
145 7
        comm, client_comm = self._comms.pop(ref, (None, None))
146 7
        if comm:
147 0
            try:
148 0
                comm.close()
149 0
            except Exception:
150 0
                pass
151 7
        if client_comm:
152 0
            try:
153 0
                client_comm.close()
154 0
            except Exception:
155 0
                pass
156

157 7
    def _param_change(self, *events):
158 7
        msgs = []
159 7
        for event in events:
160 7
            msg = self._process_param_change({event.name: event.new})
161 7
            if msg:
162 7
                msgs.append(msg)
163

164 7
        events = {event.name: event for event in events}
165 7
        msg = {k: v for msg in msgs for k, v in msg.items()}
166 7
        if not msg:
167 7
            return
168

169 7
        for ref, (model, parent) in self._models.items():
170 7
            if ref not in state._views or ref in state._fake_roots:
171 0
                continue
172 7
            viewable, root, doc, comm = state._views[ref]
173 7
            if comm or not doc.session_context or state._unblocked(doc):
174 7
                with unlocked():
175 7
                    self._update_model(events, msg, root, model, doc, comm)
176 7
                if comm and 'embedded' not in root.tags:
177 7
                    push(doc, comm)
178
            else:
179 0
                cb = partial(self._update_model, events, msg, root, model, doc, comm)
180 0
                doc.add_next_tick_callback(cb)
181

182 7
    def _process_events(self, events):
183 7
        with edit_readonly(state):
184 7
            state.busy = True
185 7
        try:
186 7
            with edit_readonly(self):
187 7
                self.param.set_param(**self._process_property_change(events))
188
        finally:
189 7
            with edit_readonly(state):
190 7
                state.busy = False
191

192 7
    @gen.coroutine
193 7
    def _change_coroutine(self, doc=None):
194 0
        self._change_event(doc)
195

196 7
    def _change_event(self, doc=None):
197 7
        try:
198 7
            state.curdoc = doc
199 7
            thread = threading.current_thread()
200 7
            thread_id = thread.ident if thread else None
201 7
            state._thread_id = thread_id
202 7
            events = self._events
203 7
            self._events = {}
204 7
            self._process_events(events)
205
        finally:
206 7
            self._processing = False
207 7
            state.curdoc = None
208 7
            state._thread_id = None
209

210 7
    def _comm_change(self, doc, ref, comm, attr, old, new):
211 7
        if attr in self._changing.get(ref, []):
212 7
            self._changing[ref].remove(attr)
213 7
            return
214

215 7
        with hold(doc, comm=comm):
216 7
            self._process_events({attr: new})
217

218 7
    def _server_change(self, doc, ref, attr, old, new):
219 7
        if attr in self._changing.get(ref, []):
220 0
            self._changing[ref].remove(attr)
221 0
            return
222

223 7
        state._locks.clear()
224 7
        self._events.update({attr: new})
225 7
        if not self._processing:
226 7
            self._processing = True
227 7
            if doc.session_context:
228 0
                doc.add_timeout_callback(partial(self._change_coroutine, doc), self._debounce)
229
            else:
230 7
                self._change_event(doc)
231

232

233 7
class Reactive(Syncable, Viewable):
234
    """
235
    Reactive is a Viewable object that also supports syncing between
236
    the objects parameters and the underlying bokeh model either via
237
    the defined pyviz_comms.Comm type or using bokeh server.
238

239
    In addition it defines various methods which make it easy to link
240
    the parameters to other objects.
241
    """
242

243
    #----------------------------------------------------------------
244
    # Public API
245
    #----------------------------------------------------------------
246

247 7
    def add_periodic_callback(self, callback, period=500, count=None,
248
                              timeout=None, start=True):
249
        """
250
        Schedules a periodic callback to be run at an interval set by
251
        the period. Returns a PeriodicCallback object with the option
252
        to stop and start the callback.
253

254
        Arguments
255
        ---------
256
        callback: callable
257
          Callable function to be executed at periodic interval.
258
        period: int
259
          Interval in milliseconds at which callback will be executed.
260
        count: int
261
          Maximum number of times callback will be invoked.
262
        timeout: int
263
          Timeout in seconds when the callback should be stopped.
264
        start: boolean (default=True)
265
          Whether to start callback immediately.
266

267
        Returns
268
        -------
269
        Return a PeriodicCallback object with start and stop methods.
270
        """
271 0
        self.param.warning(
272
            "Calling add_periodic_callback on a Panel component is "
273
            "deprecated and will be removed in the next minor release. "
274
            "Use the pn.state.add_periodic_callback API instead."
275
        )
276 0
        cb = PeriodicCallback(callback=callback, period=period,
277
                              count=count, timeout=timeout)
278 0
        if start:
279 0
            cb.start()
280 0
        return cb
281

282 7
    def link(self, target, callbacks=None, bidirectional=False,  **links):
283
        """
284
        Links the parameters on this object to attributes on another
285
        object in Python. Supports two modes, either specify a mapping
286
        between the source and target object parameters as keywords or
287
        provide a dictionary of callbacks which maps from the source
288
        parameter to a callback which is triggered when the parameter
289
        changes.
290

291
        Arguments
292
        ---------
293
        target: object
294
          The target object of the link.
295
        callbacks: dict
296
          Maps from a parameter in the source object to a callback.
297
        bidirectional: boolean
298
          Whether to link source and target bi-directionally
299
        **links: dict
300
          Maps between parameters on this object to the parameters
301
          on the supplied object.
302
        """
303 7
        if links and callbacks:
304 0
            raise ValueError('Either supply a set of parameters to '
305
                             'link as keywords or a set of callbacks, '
306
                             'not both.')
307 7
        elif not links and not callbacks:
308 0
            raise ValueError('Declare parameters to link or a set of '
309
                             'callbacks, neither was defined.')
310 7
        elif callbacks and bidirectional:
311 0
            raise ValueError('Bidirectional linking not supported for '
312
                             'explicit callbacks. You must define '
313
                             'separate callbacks for each direction.')
314

315 7
        _updating = []
316 7
        def link(*events):
317 7
            for event in events:
318 7
                if event.name in _updating: continue
319 7
                _updating.append(event.name)
320 7
                try:
321 7
                    if callbacks:
322 7
                        callbacks[event.name](target, event)
323
                    else:
324 7
                        setattr(target, links[event.name], event.new)
325
                finally:
326 7
                    _updating.pop(_updating.index(event.name))
327 7
        params = list(callbacks) if callbacks else list(links)
328 7
        cb = self.param.watch(link, params)
329

330 7
        bidirectional_watcher = None
331 7
        if bidirectional:
332 7
            _reverse_updating = []
333 7
            reverse_links = {v: k for k, v in links.items()}
334 7
            def reverse_link(*events):
335 7
                for event in events:
336 7
                    if event.name in _reverse_updating: continue
337 7
                    _reverse_updating.append(event.name)
338 7
                    try:
339 7
                        setattr(self, reverse_links[event.name], event.new)
340
                    finally:
341 7
                        _reverse_updating.remove(event.name)
342 7
            bidirectional_watcher = target.param.watch(reverse_link, list(reverse_links))
343

344 7
        link = LinkWatcher(*tuple(cb)+(target, links, callbacks is not None, bidirectional_watcher))
345 7
        self._links.append(link)
346 7
        return cb
347

348 7
    def controls(self, parameters=[], jslink=True):
349
        """
350
        Creates a set of widgets which allow manipulating the parameters
351
        on this instance. By default all parameters which support
352
        linking are exposed, but an explicit list of parameters can
353
        be provided.
354

355
        Arguments
356
        ---------
357
        parameters: list(str)
358
           An explicit list of parameters to return controls for.
359
        jslink: bool
360
           Whether to use jslinks instead of Python based links.
361
           This does not allow using all types of parameters.
362

363
        Returns
364
        -------
365
        A layout of the controls
366
        """
367 7
        from .param import Param
368 7
        from .layout import Tabs, WidgetBox
369 7
        from .widgets import LiteralInput
370

371 7
        if parameters:
372 7
            linkable = parameters
373 7
        elif jslink:
374 7
            linkable = self._linkable_params
375
        else:
376 0
            linkable = list(self.param)
377

378 7
        params = [p for p in linkable if p not in Layoutable.param]
379 7
        controls = Param(self.param, parameters=params, default_layout=WidgetBox,
380
                         name='Controls')
381 7
        layout_params = [p for p in linkable if p in Layoutable.param]
382 7
        if 'name' not in layout_params and self._rename.get('name', False) is not None and not parameters:
383 0
            layout_params.insert(0, 'name')
384 7
        style = Param(self.param, parameters=layout_params, default_layout=WidgetBox,
385
                      name='Layout')
386 7
        if jslink:
387 7
            for p in params:
388 7
                widget = controls._widgets[p]
389 7
                widget.jslink(self, value=p, bidirectional=True)
390 7
                if isinstance(widget, LiteralInput):
391 0
                    widget.serializer = 'json'
392 7
            for p in layout_params:
393 7
                widget = style._widgets[p]
394 7
                widget.jslink(self, value=p, bidirectional=True)
395 7
                if isinstance(widget, LiteralInput):
396 7
                    widget.serializer = 'json'
397

398 7
        if params and layout_params:
399 7
            return Tabs(controls.layout[0], style.layout[0])
400 7
        elif params:
401 7
            return controls.layout[0]
402 0
        return style.layout[0]
403

404 7
    def jscallback(self, args={}, **callbacks):
405
        """
406
        Allows defining a JS callback to be triggered when a property
407
        changes on the source object. The keyword arguments define the
408
        properties that trigger a callback and the JS code that gets
409
        executed.
410

411
        Arguments
412
        ----------
413
        args: dict
414
          A mapping of objects to make available to the JS callback
415
        **callbacks: dict
416
          A mapping between properties on the source model and the code
417
          to execute when that property changes
418

419
        Returns
420
        -------
421
        callback: Callback
422
          The Callback which can be used to disable the callback.
423
        """
424

425 7
        from .links import Callback
426 7
        for k, v in list(callbacks.items()):
427 7
            callbacks[k] = self._rename.get(v, v)
428 7
        return Callback(self, code=callbacks, args=args)
429

430 7
    def jslink(self, target, code=None, args=None, bidirectional=False, **links):
431
        """
432
        Links properties on the source object to those on the target
433
        object in JS code. Supports two modes, either specify a
434
        mapping between the source and target model properties as
435
        keywords or provide a dictionary of JS code snippets which
436
        maps from the source parameter to a JS code snippet which is
437
        executed when the property changes.
438

439
        Arguments
440
        ----------
441
        target: HoloViews object or bokeh Model or panel Viewable
442
          The target to link the value to.
443
        code: dict
444
          Custom code which will be executed when the widget value
445
          changes.
446
        bidirectional: boolean
447
          Whether to link source and target bi-directionally
448
        **links: dict
449
          A mapping between properties on the source model and the
450
          target model property to link it to.
451

452
        Returns
453
        -------
454
        link: GenericLink
455
          The GenericLink which can be used unlink the widget and
456
          the target model.
457
        """
458 7
        if links and code:
459 0
            raise ValueError('Either supply a set of properties to '
460
                             'link as keywords or a set of JS code '
461
                             'callbacks, not both.')
462 7
        elif not links and not code:
463 0
            raise ValueError('Declare parameters to link or a set of '
464
                             'callbacks, neither was defined.')
465 7
        if args is None:
466 7
            args = {}
467

468 7
        mapping = code or links
469 7
        for k in mapping:
470 7
            if k.startswith('event:'):
471 0
                continue
472 7
            elif hasattr(self, 'object') and isinstance(self.object, LayoutDOM):
473 7
                current = self.object
474 7
                for attr in k.split('.'):
475 7
                    if not hasattr(current, attr):
476 0
                        raise ValueError(f"Could not resolve {k} on "
477
                                         f"{self.object} model. Ensure "
478
                                         "you jslink an attribute that "
479
                                         "exists on the bokeh model.")
480 7
                    current = getattr(current, attr)
481 7
            elif (k not in self.param and k not in list(self._rename.values())):
482 7
                matches = difflib.get_close_matches(k, list(self.param))
483 7
                if matches:
484 7
                    matches = ' Similar parameters include: %r' % matches
485
                else:
486 0
                    matches = ''
487 7
                raise ValueError("Could not jslink %r parameter (or property) "
488
                                 "on %s object because it was not found.%s"
489
                                 % (k, type(self).__name__, matches))
490 7
            elif (self._source_transforms.get(k, False) is None or
491
                  self._rename.get(k, False) is None):
492 7
                raise ValueError("Cannot jslink %r parameter on %s object, "
493
                                 "the parameter requires a live Python kernel "
494
                                 "to have an effect." % (k, type(self).__name__))
495

496 7
        if isinstance(target, Syncable) and code is None:
497 7
            for k, p in mapping.items():
498 7
                if k.startswith('event:'):
499 0
                    continue
500 7
                elif p not in target.param and p not in list(target._rename.values()):
501 7
                    matches = difflib.get_close_matches(p, list(target.param))
502 7
                    if matches:
503 7
                        matches = ' Similar parameters include: %r' % matches
504
                    else:
505 0
                        matches = ''
506 7
                    raise ValueError("Could not jslink %r parameter (or property) "
507
                                     "on %s object because it was not found.%s"
508
                                    % (p, type(self).__name__, matches))
509 7
                elif (target._source_transforms.get(p, False) is None or
510
                      target._rename.get(p, False) is None):
511 7
                    raise ValueError("Cannot jslink %r parameter on %s object "
512
                                     "to %r parameter on %s object. It requires "
513
                                     "a live Python kernel to have an effect."
514
                                     % (k, type(self).__name__, p, type(target).__name__))
515

516 7
        from .links import Link
517 7
        return Link(self, target, properties=links, code=code, args=args,
518
                    bidirectional=bidirectional)

Read our documentation on viewing source code .

Loading