holoviz / panel
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 sys
9 7
import threading
10

11 7
from collections import Counter, defaultdict, namedtuple
12 7
from functools import partial
13

14 7
import bleach
15 7
import numpy as np
16 7
import param
17

18 7
from bokeh.models import LayoutDOM
19 7
from param.parameterized import ParameterizedMetaclass
20 7
from tornado import gen
21

22 7
from .config import config
23 7
from .io.callbacks import PeriodicCallback
24 7
from .io.model import hold
25 7
from .io.notebook import push
26 7
from .io.server import unlocked
27 7
from .io.state import state
28 7
from .models.reactive_html import (
29
    ReactiveHTML as _BkReactiveHTML, ReactiveHTMLParser, construct_data_model
30
)
31 7
from .util import edit_readonly, escape, updating
32 7
from .viewable import Layoutable, Renderable, Viewable
33

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

36

37 7
class Syncable(Renderable):
38
    """
39
    Syncable is an extension of the Renderable object which can not
40
    only render to a bokeh model but also sync the parameters on the
41
    object with the properties on the model.
42

43
    In order to bi-directionally link parameters with bokeh model
44
    instances the _link_params and _link_props methods define
45
    callbacks triggered when either the parameter or bokeh property
46
    values change. Since there may not be a 1-to-1 mapping between
47
    parameter and the model property the _process_property_change and
48
    _process_param_change may be overridden to apply any necessary
49
    transformations.
50
    """
51

52
    # Timeout if a notebook comm message is swallowed
53 7
    _timeout = 20000
54

55
    # Timeout before the first event is processed
56 7
    _debounce = 50
57

58
    # Any parameters that require manual updates handling for the models
59
    # e.g. parameters which affect some sub-model
60 7
    _manual_params = []
61

62
    # Mapping from parameter name to bokeh model property name
63 7
    _rename = {}
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 7
    __abstract = True
74

75 7
    def __init__(self, **params):
76 7
        super().__init__(**params)
77

78
        # Useful when updating model properties which trigger potentially
79
        # recursive events
80 7
        self._updating = False
81

82
        # A dictionary of current property change events
83 7
        self._events = {}
84

85
        # Any watchers associated with links between two objects
86 7
        self._links = []
87 7
        self._link_params()
88

89
        # A dictionary of bokeh property changes being processed
90 7
        self._changing = {}
91

92
        # Sets up watchers to process manual updates to models
93 7
        if self._manual_params:
94 7
            self.param.watch(self._update_manual, self._manual_params)
95

96
    #----------------------------------------------------------------
97
    # Model API
98
    #----------------------------------------------------------------
99

100 7
    def _process_property_change(self, msg):
101
        """
102
        Transform bokeh model property changes into parameter updates.
103
        Should be overridden to provide appropriate mapping between
104
        parameter value and bokeh model change. By default uses the
105
        _rename class level attribute to map between parameter and
106
        property names.
107
        """
108 7
        inverted = {v: k for k, v in self._rename.items()}
109 7
        return {inverted.get(k, k): v for k, v in msg.items()}
110

111 7
    def _process_param_change(self, msg):
112
        """
113
        Transform parameter changes into bokeh model property updates.
114
        Should be overridden to provide appropriate mapping between
115
        parameter value and bokeh model change. By default uses the
116
        _rename class level attribute to map between parameter and
117
        property names.
118
        """
119 7
        properties = {self._rename.get(k, k): v for k, v in msg.items()
120
                      if self._rename.get(k, False) is not None}
121 7
        if 'width' in properties and self.sizing_mode is None:
122 7
            properties['min_width'] = properties['width']
123 7
        if 'height' in properties and self.sizing_mode is None:
124 7
            properties['min_height'] = properties['height']
125 7
        return properties
126

127 7
    @property
128 2
    def _linkable_params(self):
129
        """
130
        Parameters that can be linked in JavaScript via source
131
        transforms.
132
        """
133 7
        return [p for p in self._synced_params if self._rename.get(p, False) is not None
134
                and self._source_transforms.get(p, False) is not None] + ['loading']
135

136 7
    @property
137 2
    def _synced_params(self):
138
        """
139
        Parameters which are synced with properties using transforms
140
        applied in the _process_param_change method.
141
        """
142 7
        ignored = ['default_layout', 'loading']
143 7
        return [p for p in self.param if p not in self._manual_params+ignored]
144

145 7
    def _init_params(self):
146 7
        return {k: v for k, v in self.param.get_param_values()
147
                if k in self._synced_params and v is not None}
148

149 7
    def _link_params(self):
150 7
        params = self._synced_params
151 7
        if params:
152 7
            watcher = self.param.watch(self._param_change, params)
153 7
            self._callbacks.append(watcher)
154

155 7
    def _link_props(self, model, properties, doc, root, comm=None):
156 7
        ref = root.ref['id']
157 7
        if config.embed:
158 7
            return
159

160 7
        for p in properties:
161 7
            if isinstance(p, tuple):
162 0
                _, p = p
163 7
            if comm:
164 7
                model.on_change(p, partial(self._comm_change, doc, ref, comm))
165
            else:
166 7
                model.on_change(p, partial(self._server_change, doc, ref))
167

168 7
    def _manual_update(self, events, model, doc, root, parent, comm):
169
        """
170
        Method for handling any manual update events, i.e. events triggered
171
        by changes in the manual params.
172
        """
173

174 7
    def _update_manual(self, *events):
175 7
        for ref, (model, parent) in self._models.items():
176 7
            if ref not in state._views or ref in state._fake_roots:
177 0
                continue
178 7
            viewable, root, doc, comm = state._views[ref]
179 7
            if comm or state._unblocked(doc):
180 7
                with unlocked():
181 7
                    self._manual_update(events, model, doc, root, parent, comm)
182 7
                if comm and 'embedded' not in root.tags:
183 7
                    push(doc, comm)
184
            else:
185 0
                cb = partial(self._manual_update, events, model, doc, root, parent, comm)
186 0
                if doc.session_context:
187 0
                    doc.add_next_tick_callback(cb)
188
                else:
189 0
                    cb()
190

191 7
    def _apply_update(self, events, msg, model, ref):
192 7
        if ref not in state._views or ref in state._fake_roots:
193 7
            return
194 7
        viewable, root, doc, comm = state._views[ref]
195 7
        if comm or not doc.session_context or state._unblocked(doc):
196 7
            with unlocked():
197 7
                self._update_model(events, msg, root, model, doc, comm)
198 7
            if comm and 'embedded' not in root.tags:
199 7
                push(doc, comm)
200
        else:
201 7
            cb = partial(self._update_model, events, msg, root, model, doc, comm)
202 7
            doc.add_next_tick_callback(cb)
203

204 7
    def _update_model(self, events, msg, root, model, doc, comm):
205 7
        self._changing[root.ref['id']] = [
206
            attr for attr, value in msg.items()
207
            if not model.lookup(attr).property.matches(getattr(model, attr), value)
208
        ]
209 7
        try:
210 7
            model.update(**msg)
211
        finally:
212 7
            del self._changing[root.ref['id']]
213

214 7
    def _cleanup(self, root):
215 7
        super()._cleanup(root)
216 7
        ref = root.ref['id']
217 7
        self._models.pop(ref, None)
218 7
        comm, client_comm = self._comms.pop(ref, (None, None))
219 7
        if comm:
220 0
            try:
221 0
                comm.close()
222 0
            except Exception:
223 0
                pass
224 7
        if client_comm:
225 0
            try:
226 0
                client_comm.close()
227 0
            except Exception:
228 0
                pass
229

230 7
    def _param_change(self, *events):
231 7
        msgs = []
232 7
        for event in events:
233 7
            msg = self._process_param_change({event.name: event.new})
234 7
            if msg:
235 7
                msgs.append(msg)
236

237 7
        events = {event.name: event for event in events}
238 7
        msg = {k: v for msg in msgs for k, v in msg.items()}
239 7
        if not msg:
240 7
            return
241

242 7
        for ref, (model, parent) in self._models.items():
243 7
            self._apply_update(events, msg, model, ref)
244

245 7
    def _process_events(self, events):
246 7
        with edit_readonly(state):
247 7
            state.busy = True
248 7
        try:
249 7
            with edit_readonly(self):
250 7
                self.param.set_param(**self._process_property_change(events))
251
        finally:
252 7
            with edit_readonly(state):
253 7
                state.busy = False
254

255 7
    @gen.coroutine
256 7
    def _change_coroutine(self, doc=None):
257 0
        self._change_event(doc)
258

259 7
    def _change_event(self, doc=None):
260 7
        try:
261 7
            state.curdoc = doc
262 7
            thread = threading.current_thread()
263 7
            thread_id = thread.ident if thread else None
264 7
            state._thread_id = thread_id
265 7
            events = self._events
266 7
            self._events = {}
267 7
            self._process_events(events)
268
        finally:
269 7
            state.curdoc = None
270 7
            state._thread_id = None
271

272 7
    def _comm_change(self, doc, ref, comm, attr, old, new):
273 7
        if attr in self._changing.get(ref, []):
274 7
            self._changing[ref].remove(attr)
275 7
            return
276

277 7
        with hold(doc, comm=comm):
278 7
            self._process_events({attr: new})
279

280 7
    def _server_change(self, doc, ref, attr, old, new):
281 7
        if attr in self._changing.get(ref, []):
282 7
            self._changing[ref].remove(attr)
283 7
            return
284

285 7
        state._locks.clear()
286 7
        processing = bool(self._events)
287 7
        self._events.update({attr: new})
288 7
        if not processing:
289 7
            if doc.session_context:
290 0
                doc.add_timeout_callback(
291
                    partial(self._change_coroutine, doc),
292
                    self._debounce
293
                )
294
            else:
295 7
                self._change_event(doc)
296

297

298 7
class Reactive(Syncable, Viewable):
299
    """
300
    Reactive is a Viewable object that also supports syncing between
301
    the objects parameters and the underlying bokeh model either via
302
    the defined pyviz_comms.Comm type or using bokeh server.
303

304
    In addition it defines various methods which make it easy to link
305
    the parameters to other objects.
306
    """
307

308
    #----------------------------------------------------------------
309
    # Public API
310
    #----------------------------------------------------------------
311

312 7
    def add_periodic_callback(self, callback, period=500, count=None,
313
                              timeout=None, start=True):
314
        """
315
        Schedules a periodic callback to be run at an interval set by
316
        the period. Returns a PeriodicCallback object with the option
317
        to stop and start the callback.
318

319
        Arguments
320
        ---------
321
        callback: callable
322
          Callable function to be executed at periodic interval.
323
        period: int
324
          Interval in milliseconds at which callback will be executed.
325
        count: int
326
          Maximum number of times callback will be invoked.
327
        timeout: int
328
          Timeout in seconds when the callback should be stopped.
329
        start: boolean (default=True)
330
          Whether to start callback immediately.
331

332
        Returns
333
        -------
334
        Return a PeriodicCallback object with start and stop methods.
335
        """
336 0
        self.param.warning(
337
            "Calling add_periodic_callback on a Panel component is "
338
            "deprecated and will be removed in the next minor release. "
339
            "Use the pn.state.add_periodic_callback API instead."
340
        )
341 0
        cb = PeriodicCallback(callback=callback, period=period,
342
                              count=count, timeout=timeout)
343 0
        if start:
344 0
            cb.start()
345 0
        return cb
346

347 7
    def link(self, target, callbacks=None, bidirectional=False,  **links):
348
        """
349
        Links the parameters on this object to attributes on another
350
        object in Python. Supports two modes, either specify a mapping
351
        between the source and target object parameters as keywords or
352
        provide a dictionary of callbacks which maps from the source
353
        parameter to a callback which is triggered when the parameter
354
        changes.
355

356
        Arguments
357
        ---------
358
        target: object
359
          The target object of the link.
360
        callbacks: dict
361
          Maps from a parameter in the source object to a callback.
362
        bidirectional: boolean
363
          Whether to link source and target bi-directionally
364
        **links: dict
365
          Maps between parameters on this object to the parameters
366
          on the supplied object.
367
        """
368 7
        if links and callbacks:
369 0
            raise ValueError('Either supply a set of parameters to '
370
                             'link as keywords or a set of callbacks, '
371
                             'not both.')
372 7
        elif not links and not callbacks:
373 0
            raise ValueError('Declare parameters to link or a set of '
374
                             'callbacks, neither was defined.')
375 7
        elif callbacks and bidirectional:
376 0
            raise ValueError('Bidirectional linking not supported for '
377
                             'explicit callbacks. You must define '
378
                             'separate callbacks for each direction.')
379

380 7
        _updating = []
381 7
        def link(*events):
382 7
            for event in events:
383 7
                if event.name in _updating: continue
384 7
                _updating.append(event.name)
385 7
                try:
386 7
                    if callbacks:
387 7
                        callbacks[event.name](target, event)
388
                    else:
389 7
                        setattr(target, links[event.name], event.new)
390
                finally:
391 7
                    _updating.pop(_updating.index(event.name))
392 7
        params = list(callbacks) if callbacks else list(links)
393 7
        cb = self.param.watch(link, params)
394

395 7
        bidirectional_watcher = None
396 7
        if bidirectional:
397 7
            _reverse_updating = []
398 7
            reverse_links = {v: k for k, v in links.items()}
399 7
            def reverse_link(*events):
400 7
                for event in events:
401 7
                    if event.name in _reverse_updating: continue
402 7
                    _reverse_updating.append(event.name)
403 7
                    try:
404 7
                        setattr(self, reverse_links[event.name], event.new)
405
                    finally:
406 7
                        _reverse_updating.remove(event.name)
407 7
            bidirectional_watcher = target.param.watch(reverse_link, list(reverse_links))
408

409 7
        link = LinkWatcher(*tuple(cb)+(target, links, callbacks is not None, bidirectional_watcher))
410 7
        self._links.append(link)
411 7
        return cb
412

413 7
    def controls(self, parameters=[], jslink=True, **kwargs):
414
        """
415
        Creates a set of widgets which allow manipulating the parameters
416
        on this instance. By default all parameters which support
417
        linking are exposed, but an explicit list of parameters can
418
        be provided.
419

420
        Arguments
421
        ---------
422
        parameters: list(str)
423
           An explicit list of parameters to return controls for.
424
        jslink: bool
425
           Whether to use jslinks instead of Python based links.
426
           This does not allow using all types of parameters.
427
        kwargs: dict
428
           Additional kwargs to pass to the Param pane(s) used to
429
           generate the controls widgets.
430

431
        Returns
432
        -------
433
        A layout of the controls
434
        """
435 7
        from .param import Param
436 7
        from .layout import Tabs, WidgetBox
437 7
        from .widgets import LiteralInput
438

439 7
        if parameters:
440 7
            linkable = parameters
441 7
        elif jslink:
442 7
            linkable = self._linkable_params
443
        else:
444 0
            linkable = list(self.param)
445

446 7
        params = [p for p in linkable if p not in Viewable.param]
447 7
        controls = Param(self.param, parameters=params, default_layout=WidgetBox,
448
                         name='Controls', **kwargs)
449 7
        layout_params = [p for p in linkable if p in Viewable.param]
450 7
        if 'name' not in layout_params and self._rename.get('name', False) is not None and not parameters:
451 7
            layout_params.insert(0, 'name')
452 7
        style = Param(self.param, parameters=layout_params, default_layout=WidgetBox,
453
                      name='Layout', **kwargs)
454 7
        if jslink:
455 7
            for p in params:
456 7
                widget = controls._widgets[p]
457 7
                widget.jslink(self, value=p, bidirectional=True)
458 7
                if isinstance(widget, LiteralInput):
459 7
                    widget.serializer = 'json'
460 7
            for p in layout_params:
461 7
                widget = style._widgets[p]
462 7
                widget.jslink(self, value=p, bidirectional=p != 'loading')
463 7
                if isinstance(widget, LiteralInput):
464 7
                    widget.serializer = 'json'
465

466 7
        if params and layout_params:
467 7
            return Tabs(controls.layout[0], style.layout[0])
468 7
        elif params:
469 7
            return controls.layout[0]
470 7
        return style.layout[0]
471

472 7
    def jscallback(self, args={}, **callbacks):
473
        """
474
        Allows defining a JS callback to be triggered when a property
475
        changes on the source object. The keyword arguments define the
476
        properties that trigger a callback and the JS code that gets
477
        executed.
478

479
        Arguments
480
        ----------
481
        args: dict
482
          A mapping of objects to make available to the JS callback
483
        **callbacks: dict
484
          A mapping between properties on the source model and the code
485
          to execute when that property changes
486

487
        Returns
488
        -------
489
        callback: Callback
490
          The Callback which can be used to disable the callback.
491
        """
492

493 7
        from .links import Callback
494 7
        for k, v in list(callbacks.items()):
495 7
            callbacks[k] = self._rename.get(v, v)
496 7
        return Callback(self, code=callbacks, args=args)
497

498 7
    def jslink(self, target, code=None, args=None, bidirectional=False, **links):
499
        """
500
        Links properties on the source object to those on the target
501
        object in JS code. Supports two modes, either specify a
502
        mapping between the source and target model properties as
503
        keywords or provide a dictionary of JS code snippets which
504
        maps from the source parameter to a JS code snippet which is
505
        executed when the property changes.
506

507
        Arguments
508
        ----------
509
        target: HoloViews object or bokeh Model or panel Viewable
510
          The target to link the value to.
511
        code: dict
512
          Custom code which will be executed when the widget value
513
          changes.
514
        bidirectional: boolean
515
          Whether to link source and target bi-directionally
516
        **links: dict
517
          A mapping between properties on the source model and the
518
          target model property to link it to.
519

520
        Returns
521
        -------
522
        link: GenericLink
523
          The GenericLink which can be used unlink the widget and
524
          the target model.
525
        """
526 7
        if links and code:
527 0
            raise ValueError('Either supply a set of properties to '
528
                             'link as keywords or a set of JS code '
529
                             'callbacks, not both.')
530 7
        elif not links and not code:
531 0
            raise ValueError('Declare parameters to link or a set of '
532
                             'callbacks, neither was defined.')
533 7
        if args is None:
534 7
            args = {}
535

536 7
        mapping = code or links
537 7
        for k in mapping:
538 7
            if k.startswith('event:'):
539 7
                continue
540 7
            elif hasattr(self, 'object') and isinstance(self.object, LayoutDOM):
541 7
                current = self.object
542 7
                for attr in k.split('.'):
543 7
                    if not hasattr(current, attr):
544 0
                        raise ValueError(f"Could not resolve {k} on "
545
                                         f"{self.object} model. Ensure "
546
                                         "you jslink an attribute that "
547
                                         "exists on the bokeh model.")
548 7
                    current = getattr(current, attr)
549 7
            elif (k not in self.param and k not in list(self._rename.values())):
550 7
                matches = difflib.get_close_matches(k, list(self.param))
551 7
                if matches:
552 7
                    matches = ' Similar parameters include: %r' % matches
553
                else:
554 0
                    matches = ''
555 7
                raise ValueError("Could not jslink %r parameter (or property) "
556
                                 "on %s object because it was not found.%s"
557
                                 % (k, type(self).__name__, matches))
558 7
            elif (self._source_transforms.get(k, False) is None or
559
                  self._rename.get(k, False) is None):
560 7
                raise ValueError("Cannot jslink %r parameter on %s object, "
561
                                 "the parameter requires a live Python kernel "
562
                                 "to have an effect." % (k, type(self).__name__))
563

564 7
        if isinstance(target, Syncable) and code is None:
565 7
            for k, p in mapping.items():
566 7
                if k.startswith('event:'):
567 7
                    continue
568 7
                elif p not in target.param and p not in list(target._rename.values()):
569 7
                    matches = difflib.get_close_matches(p, list(target.param))
570 7
                    if matches:
571 7
                        matches = ' Similar parameters include: %r' % matches
572
                    else:
573 0
                        matches = ''
574 7
                    raise ValueError("Could not jslink %r parameter (or property) "
575
                                     "on %s object because it was not found.%s"
576
                                    % (p, type(self).__name__, matches))
577 7
                elif (target._source_transforms.get(p, False) is None or
578
                      target._rename.get(p, False) is None):
579 7
                    raise ValueError("Cannot jslink %r parameter on %s object "
580
                                     "to %r parameter on %s object. It requires "
581
                                     "a live Python kernel to have an effect."
582
                                     % (k, type(self).__name__, p, type(target).__name__))
583

584 7
        from .links import Link
585 7
        return Link(self, target, properties=links, code=code, args=args,
586
                    bidirectional=bidirectional)
587

588

589 7
class SyncableData(Reactive):
590
    """
591
    A baseclass for components which sync one or more data parameters
592
    with the frontend via a ColumnDataSource.
593
    """
594

595 7
    selection = param.List(default=[], doc="""
596
        The currently selected rows in the data.""")
597

598
    # Parameters which when changed require an update of the data
599 7
    _data_params = []
600

601 7
    _rename = {'selection': None}
602

603 7
    __abstract = True
604

605 7
    def __init__(self, **params):
606 7
        super().__init__(**params)
607 7
        self._data = None
608 7
        self._processed = None
609 7
        self.param.watch(self._validate, self._data_params)
610 7
        if self._data_params:
611 7
            self.param.watch(self._update_cds, self._data_params)
612 7
        self.param.watch(self._update_selected, 'selection')
613 7
        self._validate(None)
614 7
        self._update_cds()
615

616 7
    def _validate(self, event):
617
        """
618
        Allows implementing validation for the data parameters.
619
        """
620

621 7
    def _get_data(self):
622
        """
623
        Implemented by subclasses converting data parameter(s) into
624
        a ColumnDataSource compatible data dictionary.
625

626
        Returns
627
        -------
628
        processed: object
629
          Raw data after pre-processing (e.g. after filtering)
630
        data: dict
631
          Dictionary of columns used to instantiate and update the
632
          ColumnDataSource
633
        """
634

635 7
    def _update_column(self, column, array):
636
        """
637
        Implemented by subclasses converting changes in columns to
638
        changes in the data parameter.
639

640
        Parameters
641
        ----------
642
        column: str
643
          The name of the column to update.
644
        array: numpy.ndarray
645
          The array data to update the column with.
646
        """
647 7
        data = getattr(self, self._data_params[0])
648 7
        data[column] = array
649

650 7
    def _update_data(self, data):
651 0
        self.param.set_param(**{self._data_params[0]: data})
652

653 7
    def _manual_update(self, events, model, doc, root, parent, comm):
654 0
        for event in events:
655 0
            if event.type == 'triggered' and self._updating:
656 0
                continue
657 0
            elif hasattr(self, '_update_' + event.name):
658 0
                getattr(self, '_update_' + event.name)(model)
659

660 7
    @updating
661 2
    def _update_cds(self, *events):
662 7
        self._processed, self._data = self._get_data()
663 7
        msg = {'data': self._data}
664 7
        for ref, (m, _) in self._models.items():
665 7
            self._apply_update(events, msg, m.source, ref)
666

667 7
    @updating
668 7
    def _update_selected(self, *events, indices=None):
669 7
        indices = self.selection if indices is None else indices
670 7
        msg = {'indices': indices}
671 7
        for ref, (m, _) in self._models.items():
672 7
            self._apply_update(events, msg, m.source.selected, ref)
673

674 7
    @updating
675 7
    def _stream(self, stream, rollover=None):
676 7
        for ref, (m, _) in self._models.items():
677 7
            if ref not in state._views or ref in state._fake_roots:
678 0
                continue
679 7
            viewable, root, doc, comm = state._views[ref]
680 7
            if comm or not doc.session_context or state._unblocked(doc):
681 7
                with unlocked():
682 7
                    m.source.stream(stream, rollover)
683 7
                if comm and 'embedded' not in root.tags:
684 7
                    push(doc, comm)
685
            else:
686 0
                cb = partial(m.source.stream, stream, rollover)
687 0
                doc.add_next_tick_callback(cb)
688

689 7
    @updating
690 2
    def _patch(self, patch):
691 7
        for ref, (m, _) in self._models.items():
692 7
            if ref not in state._views or ref in state._fake_roots:
693 0
                continue
694 7
            viewable, root, doc, comm = state._views[ref]
695 7
            if comm or not doc.session_context or state._unblocked(doc):
696 7
                with unlocked():
697 7
                    m.source.patch(patch)
698 7
                if comm and 'embedded' not in root.tags:
699 7
                    push(doc, comm)
700
            else:
701 0
                cb = partial(m.source.patch, patch)
702 0
                doc.add_next_tick_callback(cb)
703

704 7
    def stream(self, stream_value, rollover=None, reset_index=True):
705
        """
706
        Streams (appends) the `stream_value` provided to the existing
707
        value in an efficient manner.
708

709
        Arguments
710
        ---------
711
        stream_value: (Union[pd.DataFrame, pd.Series, Dict])
712
          The new value(s) to append to the existing value.
713
        rollover: int
714
           A maximum column size, above which data from the start of
715
           the column begins to be discarded. If None, then columns
716
           will continue to grow unbounded.
717
        reset_index (bool, default=True):
718
          If True and the stream_value is a DataFrame, then its index
719
          is reset. Helps to keep the index unique and named `index`.
720

721
        Raises
722
        ------
723
        ValueError: Raised if the stream_value is not a supported type.
724

725
        Examples
726
        --------
727

728
        Stream a Series to a DataFrame
729
        >>> value = pd.DataFrame({"x": [1, 2], "y": ["a", "b"]})
730
        >>> obj = DataComponent(value)
731
        >>> stream_value = pd.Series({"x": 4, "y": "d"})
732
        >>> obj.stream(stream_value)
733
        >>> obj.value.to_dict("list")
734
        {'x': [1, 2, 4], 'y': ['a', 'b', 'd']}
735

736
        Stream a Dataframe to a Dataframe
737
        >>> value = pd.DataFrame({"x": [1, 2], "y": ["a", "b"]})
738
        >>> obj = DataComponent(value)
739
        >>> stream_value = pd.DataFrame({"x": [3, 4], "y": ["c", "d"]})
740
        >>> obj.stream(stream_value)
741
        >>> obj.value.to_dict("list")
742
        {'x': [1, 2, 3, 4], 'y': ['a', 'b', 'c', 'd']}
743

744
        Stream a Dictionary row to a DataFrame
745
        >>> value = pd.DataFrame({"x": [1, 2], "y": ["a", "b"]})
746
        >>> tabulator = DataComponent(value)
747
        >>> stream_value = {"x": 4, "y": "d"}
748
        >>> obj.stream(stream_value)
749
        >>> obj.value.to_dict("list")
750
        {'x': [1, 2, 4], 'y': ['a', 'b', 'd']}
751

752
        Stream a Dictionary of Columns to a Dataframe
753
        >>> value = pd.DataFrame({"x": [1, 2], "y": ["a", "b"]})
754
        >>> obj = DataComponent(value)
755
        >>> stream_value = {"x": [3, 4], "y": ["c", "d"]}
756
        >>> obj.stream(stream_value)
757
        >>> obj.value.to_dict("list")
758
        {'x': [1, 2, 3, 4], 'y': ['a', 'b', 'c', 'd']}
759
        """
760 7
        if 'pandas' in sys.modules:
761 7
            import pandas as pd
762
        else:
763 0
            pd = None
764 7
        if pd and isinstance(stream_value, pd.DataFrame):
765 0
            if isinstance(self._processed, dict):
766 0
                self.stream(stream_value.to_dict(), rollover)
767 0
                return
768 0
            if reset_index:
769 0
                value_index_start = self._processed.index.max() + 1
770 0
                stream_value = stream_value.reset_index(drop=True)
771 0
                stream_value.index += value_index_start
772 0
            combined = pd.concat([self._processed, stream_value])
773 0
            if rollover is not None:
774 0
                combined = combined.iloc[-rollover:]
775 0
            with param.discard_events(self):
776 0
                self._update_data(combined)
777 0
            try:
778 0
                self._updating = True
779 0
                self.param.trigger(self._data_params[0])
780
            finally:
781 0
                self._updating = False
782 0
            self._stream(stream_value, rollover)
783 7
        elif pd and isinstance(stream_value, pd.Series):
784 0
            if isinstance(self._processed, dict):
785 0
                self.stream({k: [v] for k, v in stream_value.to_dict().items()}, rollover)
786 0
                return
787 0
            value_index_start = self._processed.index.max() + 1
788 0
            self._processed.loc[value_index_start] = stream_value
789 0
            with param.discard_events(self):
790 0
                self._update_data(self._processed)
791 0
            self._stream(self._processed.iloc[-1:], rollover)
792 7
        elif isinstance(stream_value, dict):
793 7
            if isinstance(self._processed, dict):
794 7
                if not all(col in stream_value for col in self._data):
795 0
                    raise ValueError("Stream update must append to all columns.")
796 7
                for col, array in stream_value.items():
797 7
                    combined = np.concatenate([self._data[col], array])
798 7
                    if rollover is not None:
799 7
                        combined = combined[-rollover:]
800 7
                    self._update_column(col, combined)
801 7
                self._stream(stream_value, rollover)
802
            else:
803 0
                try:
804 0
                    stream_value = pd.DataFrame(stream_value)
805 0
                except ValueError:
806 0
                    stream_value = pd.Series(stream_value)
807 0
                self.stream(stream_value)
808
        else:
809 0
            raise ValueError("The stream value provided is not a DataFrame, Series or Dict!")
810

811 7
    def patch(self, patch_value):
812
        """
813
        Efficiently patches (updates) the existing value with the `patch_value`.
814

815
        Arguments
816
        ---------
817
        patch_value: (Union[pd.DataFrame, pd.Series, Dict])
818
          The value(s) to patch the existing value with.
819

820
        Raises
821
        ------
822
        ValueError: Raised if the patch_value is not a supported type.
823

824
        Examples
825
        --------
826

827
        Patch a DataFrame with a Dictionary row.
828
        >>> value = pd.DataFrame({"x": [1, 2], "y": ["a", "b"]})
829
        >>> obj = DataComponent(value)
830
        >>> patch_value = {"x": [(0, 3)]}
831
        >>> obj.patch(patch_value)
832
        >>> obj.value.to_dict("list")
833
        {'x': [3, 2], 'y': ['a', 'b']}
834

835
        Patch a Dataframe with a Dictionary of Columns.
836
        >>> value = pd.DataFrame({"x": [1, 2], "y": ["a", "b"]})
837
        >>> obj = DataComponent(value)
838
        >>> patch_value = {"x": [(slice(2), (3,4))], "y": [(1,'d')]}
839
        >>> obj.patch(patch_value)
840
        >>> obj.value.to_dict("list")
841
        {'x': [3, 4], 'y': ['a', 'd']}
842

843
        Patch a DataFrame with a Series. Please note the index is used in the update.
844
        >>> value = pd.DataFrame({"x": [1, 2], "y": ["a", "b"]})
845
        >>> obj = DataComponent(value)
846
        >>> patch_value = pd.Series({"index": 1, "x": 4, "y": "d"})
847
        >>> obj.patch(patch_value)
848
        >>> obj.value.to_dict("list")
849
        {'x': [1, 4], 'y': ['a', 'd']}
850

851
        Patch a Dataframe with a Dataframe. Please note the index is used in the update.
852
        >>> value = pd.DataFrame({"x": [1, 2], "y": ["a", "b"]})
853
        >>> obj = DataComponent(value)
854
        >>> patch_value = pd.DataFrame({"x": [3, 4], "y": ["c", "d"]})
855
        >>> obj.patch(patch_value)
856
        >>> obj.value.to_dict("list")
857
        {'x': [3, 4], 'y': ['c', 'd']}
858
        """
859 0
        if self._processed is None or isinstance(patch_value, dict):
860 0
            self._patch(patch_value)
861 0
            return
862

863 0
        if 'pandas' in sys.modules:
864 0
            import pandas as pd
865
        else:
866 0
            pd = None
867 0
        data = getattr(self, self._data_params[0])
868 0
        if pd and isinstance(patch_value, pd.DataFrame):
869 0
            patch_value_dict = {}
870 0
            for column in patch_value.columns:
871 0
                patch_value_dict[column] = []
872 0
                for index in patch_value.index:
873 0
                    patch_value_dict[column].append((index, patch_value.loc[index, column]))
874 0
            self.patch(patch_value_dict)
875 0
        elif pd and isinstance(patch_value, pd.Series):
876 0
            if "index" in patch_value:  # Series orient is row
877 0
                patch_value_dict = {
878
                    k: [(patch_value["index"], v)] for k, v in patch_value.items()
879
                }
880 0
                patch_value_dict.pop("index")
881
            else:  # Series orient is column
882 0
                patch_value_dict = {
883
                    patch_value.name: [(index, value) for index, value in patch_value.items()]
884
                }
885 0
            self.patch(patch_value_dict)
886 0
        elif isinstance(patch_value, dict):
887 0
            for k, v in patch_value.items():
888 0
                for index, patch  in v:
889 0
                    if pd and isinstance(self._processed, pd.DataFrame):
890 0
                        data.loc[index, k] = patch
891
                    else:
892 0
                        data[k][index] = patch
893 0
            self._updating = True
894 0
            try:
895 0
                self._patch(patch_value)
896
            finally:
897 0
                self._updating = False
898
        else:
899 0
            raise ValueError(
900
                f"Patching with a patch_value of type {type(patch_value).__name__} "
901
                "is not supported. Please provide a DataFrame, Series or Dict."
902
            )
903

904

905 7
class ReactiveData(SyncableData):
906
    """
907
    An extension of SyncableData which bi-directionally syncs a data
908
    parameter between frontend and backend using a ColumnDataSource.
909
    """
910

911 7
    def _update_selection(self, indices):
912 7
        self.selection = indices
913

914 7
    def _process_events(self, events):
915 7
        if 'data' in events:
916 7
            data = events.pop('data')
917 7
            if self._updating:
918 7
                data = {}
919 7
            _, old_data = self._get_data()
920 7
            updated = False
921 7
            for k, v in data.items():
922 7
                if k in self.indexes:
923 0
                    continue
924 7
                k = self._renamed_cols.get(k, k)
925 7
                if isinstance(v, dict):
926 7
                    v = [v for _, v in sorted(v.items(), key=lambda it: int(it[0]))]
927 7
                try:
928 7
                    isequal = (old_data[k] == np.asarray(v)).all()
929 0
                except Exception:
930 0
                    isequal = False
931 7
                if not isequal:
932 7
                    self._update_column(k, v)
933 7
                    updated = True
934 7
            if updated:
935 7
                self._updating = True
936 7
                try:
937 7
                    self.param.trigger('value')
938
                finally:
939 7
                    self._updating = False
940 7
        if 'indices' in events:
941 7
            self._updating = True
942 7
            try:
943 7
                self._update_selection(events.pop('indices'))
944
            finally:
945 7
                self._updating = False
946 7
        super(ReactiveData, self)._process_events(events)
947

948

949

950 7
class ReactiveHTMLMetaclass(ParameterizedMetaclass):
951
    """
952
    Parses the ReactiveHTML._template of the class and initializes
953
    variables, callbacks and the data model to sync the parameters and
954
    HTML attributes.
955
    """
956

957 7
    _name_counter = Counter()
958

959 7
    def __init__(mcs, name, bases, dict_):
960 7
        mcs.__original_doc__ = mcs.__doc__
961 7
        ParameterizedMetaclass.__init__(mcs, name, bases, dict_)
962 7
        for name, child_type in mcs._child_config.items():
963 0
            if name not in mcs.param:
964 0
                raise ValueError(f"Config for '{name}' does not match any parameters.")
965 0
            elif child_type not in ('model', 'template', 'literal'):
966 0
                raise ValueError(f"Config for {name} child parameter declares "
967
                                 f"unknown type '{child_type}'. Children must "
968
                                 "declare either 'model', 'template' or 'literal'"
969
                                 "type.")
970

971 7
        mcs._parser = ReactiveHTMLParser(mcs)
972 7
        mcs._parser.feed(mcs._template)
973 7
        if mcs._parser._open_for:
974 0
            raise ValueError("Template contains for loop without closing {% endfor %} statement.")
975

976 7
        mcs._attrs, mcs._node_callbacks = {}, {}
977 7
        mcs._inline_callbacks = []
978 7
        for node, attrs in mcs._parser.attrs.items():
979 7
            for (attr, parameters, template) in attrs:
980 7
                param_attrs = []
981 7
                for p in parameters:
982 7
                    if p in mcs.param:
983 7
                        param_attrs.append(p)
984 7
                    elif hasattr(mcs, p):
985 7
                        if node not in mcs._node_callbacks:
986 7
                            mcs._node_callbacks[node] = []
987 7
                        mcs._node_callbacks[node].append((attr, p))
988 7
                        mcs._inline_callbacks.append((node, attr, p))
989
                    else:
990 0
                        matches = difflib.get_close_matches(p, dir(mcs))
991 0
                        raise ValueError("HTML template references unknown "
992
                                         f"parameter or method '{p}', "
993
                                         "similar parameters and methods "
994
                                         f"include {matches}.")
995 7
                if node not in mcs._attrs:
996 7
                    mcs._attrs[node] = []
997 7
                mcs._attrs[node].append((attr, param_attrs, template))
998 7
        ignored = list(Reactive.param)+list(mcs._parser.children.values())
999 7
        ignored.remove('name')
1000

1001
        # Create model with unique name
1002 7
        ReactiveHTMLMetaclass._name_counter[name] += 1
1003 7
        model_name = f'{name}{ReactiveHTMLMetaclass._name_counter[name]}'
1004 7
        mcs._data_model = construct_data_model(mcs, name=model_name, ignore=ignored)
1005

1006

1007

1008 7
class ReactiveHTML(Reactive, metaclass=ReactiveHTMLMetaclass):
1009
    """
1010
    ReactiveHTML provides bi-directional syncing of arbitrary HTML
1011
    attributes and DOM properties with parameters on the subclass.
1012

1013
    HTML templates
1014
    ~~~~~~~~~~~~~~
1015

1016
    A ReactiveHTML component is declared by providing an HTML template
1017
    on the `_template` attribute on the class. Parameters are synced by
1018
    inserting them as template variables of the form `${parameter}`,
1019
    e.g.:
1020

1021
        <div class="${div_class}">${children}</div>
1022

1023
    will interpolate the div_class parameter on the class. In addition
1024
    to providing attributes we can also provide children to an HTML
1025
    tag. By default any parameter referenced as a child will be
1026
    treated as a Panel components to be rendered into the containing
1027
    HTML. This makes it possible to use ReactiveHTML to lay out other
1028
    components.
1029

1030
    Children
1031
    ~~~~~~~~
1032

1033
    As mentioned above parameters may be referenced as children of a
1034
    DOM node and will, by default, be treated as Panel components to
1035
    insert on the DOM node. However by declaring a `_child_config` we
1036
    can control how the DOM nodes are treated. The `_child_config` is
1037
    indexed by parameter name and may declare one of three rendering
1038
    modes:
1039

1040
      - model (default): Create child and render child as a Panel
1041
        component into it.
1042
      - literal: Create child and set child as its innerHTML.
1043
      - template: Set child as innerHTML of the container.
1044

1045
    If the type is 'template' the parameter will be inserted as is and
1046
    the DOM node's innerHTML will be synced with the child parameter.
1047

1048
    DOM Events
1049
    ~~~~~~~~~~
1050

1051
    In certain cases it is necessary to explicitly declare event
1052
    listeners on the DOM node to ensure that changes in their
1053
    properties are synced when an event is fired. To make this possible
1054
    the HTML element in question must be given a unique id, e.g.:
1055

1056
        <input id="input"></input>
1057

1058
    Now we can use this name to declare set of `_dom_events` to
1059
    subscribe to. The following will subscribe to change DOM events
1060
    on the input element:
1061

1062
       {'input': ['change']}
1063

1064
    Once subscribed the class may also define a method following the
1065
    `_{node}_{event}` naming convention which will fire when the DOM
1066
    event triggers, e.g. we could define a `_input_change` method.
1067
    Any such callback will be given a DOMEvent object as the first and
1068
    only argument. The DOMEvent contains information about the event
1069
    on the .data attribute and declares the type of event on the .type
1070
    attribute.
1071

1072
    Inline callbacks
1073
    ~~~~~~~~~~~~~~~~
1074

1075
    Instead of declaring explicit DOM events Python callbacks can also
1076
    be declared inline, e.g.:
1077

1078
        <input id="input" onchange="${_input_change}"></input>
1079

1080
    will look for an `_input_change` method on the ReactiveHTML
1081
    component and call it when the event is fired.
1082

1083
    Scripts
1084
    ~~~~~~~
1085

1086
    In addition to declaring callbacks in Python it is also possible
1087
    to declare Javascript callbacks to execute when any synced
1088
    attribute changes. Let us say we have declared an input element
1089
    with a synced value parameter:
1090

1091
        <input id="input" value="${value}"></input>
1092

1093
    We can now declare a set of `_scripts`, which will fire whenever
1094
    the value updates:
1095

1096
        _scripts = {
1097
            'value': ['console.log(model, data, input)']
1098
       }
1099

1100
    The Javascript is provided multiple objects in its namespace
1101
    including:
1102

1103
      * data :  The data model holds the current values of the synced
1104
                parameters, e.g. data.value will reflect the current
1105
                value of the input node.
1106
      * model:  The ReactiveHTML model which holds layout information
1107
                and information about the children and events.
1108
      * state:  An empty state dictionary which scripts can use to
1109
                store state for the lifetime of the view.
1110
      * <node>: All named DOM nodes in the HTML template, e.g. the
1111
                `input` node in the example above.
1112
    """
1113

1114 7
    _child_config = {}
1115

1116 7
    _dom_events = {}
1117

1118 7
    _template = ""
1119

1120 7
    _scripts = {}
1121

1122 7
    __abstract = True
1123

1124 7
    def __init__(self, **params):
1125 7
        from .pane import panel
1126 7
        for children_param in self._parser.children.values():
1127 7
            mode = self._child_config.get(children_param, 'model')
1128 7
            if children_param not in params or mode != 'model':
1129 0
                continue
1130 7
            child_value = params[children_param]
1131 7
            if isinstance(child_value, list):
1132 7
                children = []
1133 7
                for pane in child_value:
1134 7
                    if isinstance(pane, tuple):
1135 0
                        name, pane = pane
1136 0
                        children.append((name, panel(pane)))
1137
                    else:
1138 7
                        children.append(panel(pane))
1139 7
                params[children_param] = children
1140 0
            elif isinstance(child_value, dict):
1141 0
                children = {}
1142 0
                for key, pane in child_value.items():
1143 0
                    children[key] = panel(pane)
1144 0
                params[children_param] = children
1145
            else:
1146 0
                params[children_param] = panel(child_value)
1147 7
        super().__init__(**params)
1148 7
        self._event_callbacks = defaultdict(lambda: defaultdict(list))
1149

1150 7
    def _cleanup(self, root):
1151 0
        for children_param in self._parser.children.values():
1152 0
            children = getattr(self, children_param)
1153 0
            mode = self._child_config.get(children_param)
1154 0
            if mode != 'model':
1155 0
                continue
1156 0
            if isinstance(children, dict):
1157 0
                children = children.values()
1158 0
            elif not isinstance(children, list):
1159 0
                children = [children]
1160 0
            for child in children:
1161 0
                child._cleanup(root)
1162 0
        super()._cleanup(root)
1163

1164 7
    @property
1165 2
    def _linkable_params(self):
1166 0
        return [p for p in super()._linkable_params if p not in self._parser.children.values()]
1167

1168 7
    @property
1169 2
    def _child_names(self):
1170 7
        return {}
1171

1172 7
    def _process_children(self, doc, root, model, comm, children):
1173 7
        return children
1174

1175 7
    def _init_params(self):
1176 7
        ignored = list(Reactive.param)+list(self._parser.children.values())
1177 7
        params = {
1178
            p : getattr(self, p) for p in list(Layoutable.param)
1179
            if getattr(self, p) is not None and p != 'name'
1180
        }
1181 7
        data_params = {}
1182 7
        for k, v in self.param.get_param_values():
1183 7
            if (k in ignored and k != 'name') or ((self.param[k].precedence or 0) < 0):
1184 0
                continue
1185 7
            if isinstance(v, str):
1186 7
                v = bleach.clean(v)
1187 7
            data_params[k] = v
1188 7
        params['attrs'] = self._attrs
1189 7
        params['callbacks'] = self._node_callbacks
1190 7
        params['data'] = self._data_model(**self._process_param_change(data_params))
1191 7
        params['events'] = self._get_events()
1192 7
        params['html'] = escape(self._get_template())
1193 7
        params['nodes'] = self._parser.nodes
1194 7
        params['looped'] = [node for node, _ in self._parser.looped]
1195 7
        params['scripts'] = {
1196
            trigger: [escape(script) for script in scripts]
1197
            for trigger, scripts in self._scripts.items()
1198
        }
1199 7
        return params
1200

1201 7
    def _get_events(self):
1202 7
        events = {}
1203 7
        for node, node_events in self._dom_events.items():
1204 7
            if isinstance(node_events, list):
1205 7
                events[node] = {e: True for e in node_events}
1206
            else:
1207 0
                events[node] = node_events
1208 7
        for node, evs in self._event_callbacks.items():
1209 7
            events[node] = node_events = events.get(node, {})
1210 7
            for e in evs:
1211 7
                if e not in node_events:
1212 7
                    node_events[e] = False
1213 7
        return events
1214

1215 7
    def _get_children(self, doc, root, model, comm, old_children=None):
1216 7
        from .pane import panel
1217 7
        old_children = old_children or {}
1218 7
        old_models = model.children
1219 7
        new_models = {parent: [] for parent in self._parser.children}
1220 7
        new_panes = {}
1221

1222 7
        for parent, children_param in self._parser.children.items():
1223 7
            mode = self._child_config.get(children_param, 'model')
1224 7
            if mode == 'literal':
1225 0
                continue
1226 7
            panes = getattr(self, children_param)
1227 7
            if isinstance(panes, dict):
1228 0
                for key, value in panes.items():
1229 0
                    panes[key] = panel(value)
1230 7
            elif isinstance(panes, list):
1231 7
                for i, pane in enumerate(panes):
1232 7
                    panes[i] = panel(pane)
1233
            else:
1234 0
                panes = [panel(panes)]
1235 7
            new_panes[parent] = panes
1236

1237 7
        for children_param, old_panes in old_children.items():
1238 7
            mode = self._child_config.get(children_param, 'model')
1239 7
            if mode == 'literal':
1240 0
                continue
1241 7
            panes = getattr(self, children_param)
1242 7
            if not isinstance(panes, (list, dict)):
1243 0
                panes = [panes]
1244 0
                old_panes = [old_panes]
1245 7
            elif isinstance(panes, dict):
1246 0
                panes = panes.values()
1247 0
                old_panes = old_panes.values()
1248 7
            for old_pane in old_panes:
1249 7
                if old_pane not in panes:
1250 7
                    old_pane._cleanup(root)
1251

1252 7
        for parent, child_panes in new_panes.items():
1253 7
            children_param = self._parser.children[parent]
1254 7
            if isinstance(child_panes, dict):
1255 0
                child_panes = child_panes.values()
1256 7
            mode = self._child_config.get(children_param, 'model')
1257 7
            if mode == 'literal':
1258 0
                new_models[parent] = child_panes
1259 7
            elif children_param in old_children:
1260
                # Find existing models
1261 7
                old_panes = old_children[children_param]
1262 7
                if not isinstance(old_panes, (list, dict)):
1263 0
                    old_panes = [old_panes]
1264 7
                for i, pane in enumerate(child_panes):
1265 7
                    if pane in old_panes and root.ref['id'] in pane._models:
1266 0
                        child, _ = pane._models[root.ref['id']]
1267
                    else:
1268 7
                        child = pane._get_model(doc, root, model, comm)
1269 7
                    new_models[parent].append(child)
1270 7
            elif parent in old_models:
1271
                # Children parameter unchanged
1272 0
                new_models[parent] = old_models[parent]
1273
            else:
1274 7
                new_models[parent] = [
1275
                    pane._get_model(doc, root, model, comm)
1276
                    for pane in child_panes
1277
                ]
1278 7
        return self._process_children(doc, root, model, comm, new_models)
1279

1280 7
    def _get_template(self):
1281 7
        import jinja2
1282

1283
        # Replace loop variables with indexed child parameter e.g.:
1284
        #   {% for obj in objects %}
1285
        #     ${obj}
1286
        #   {% endfor %}
1287
        # becomes:
1288
        #   {% for obj in objects %}
1289
        #     ${objects[{{ loop.index0 }}]}
1290
        #  {% endfor %}
1291 7
        template_string = self._template
1292 7
        for var, obj in self._parser.loop_map.items():
1293 7
            template_string = template_string.replace(
1294
                '${%s}' % var, '${%s[{{ loop.index0 }}]}' % obj)
1295

1296
        # Add index to templated loop node ids
1297 7
        for dom_node, _ in self._parser.looped:
1298 7
            replacement = 'id="%s-{{ loop.index0 }}"' % dom_node
1299 7
            if f'id="{dom_node}"' in template_string:
1300 7
                template_string = template_string.replace(
1301
                    f'id="{dom_node}"', replacement)
1302
            else:
1303 7
                template_string = template_string.replace(
1304
                    f"id='{dom_node}'", replacement)
1305

1306
        # Render Jinja template
1307 7
        template = jinja2.Template(template_string)
1308 7
        context = {'param': self.param, '__doc__': self.__original_doc__}
1309 7
        for parameter, value in self.param.get_param_values():
1310 7
            context[parameter] = value
1311 7
            if parameter in self._child_names:
1312 0
                context[f'{parameter}_names'] = self._child_names[parameter]
1313 7
        html = template.render(context)
1314

1315
        # Parse templated HTML
1316 7
        parser = ReactiveHTMLParser(self.__class__, template=False)
1317 7
        parser.feed(html)
1318

1319
        # Add node ids to all parsed nodes
1320 7
        for name in list(parser.nodes):
1321 7
            html = (
1322
                html
1323
                .replace(f"id='{name}'", f"id='{name}-${{id}}'")
1324
                .replace(f'id="{name}"', f'id="{name}-${{id}}"')
1325
            )
1326

1327
        # Remove child node template syntax
1328 7
        for parent, child_name in self._parser.children.items():
1329 7
            if (parent, child_name) in self._parser.looped:
1330 7
                for i, _ in enumerate(getattr(self, child_name)):
1331 7
                    html = html.replace('${%s[%d]}' % (child_name, i), '')
1332
            else:
1333 7
                html = html.replace('${%s}' % child_name, '')
1334 7
        return html
1335

1336 7
    def _get_model(self, doc, root=None, parent=None, comm=None):
1337 7
        properties = self._process_param_change(self._init_params())
1338 7
        model = _BkReactiveHTML(**properties)
1339 7
        if not root:
1340 7
            root = model
1341 7
        model.children = self._get_children(doc, root, model, comm)
1342 7
        model.on_event('dom_event', self._process_event)
1343

1344 7
        linked_properties = [p for pss in self._attrs.values() for _, ps, _ in pss for p in ps]
1345 7
        self._link_props(model.data, linked_properties, doc, root, comm)
1346

1347 7
        self._models[root.ref['id']] = (model, parent)
1348 7
        return model
1349

1350 7
    def _process_event(self, event):
1351 0
        cb = getattr(self, f"_{event.node}_{event.data['type']}", None)
1352 0
        if cb is not None:
1353 0
            cb(event)
1354 0
        event_type = event.data['type']
1355 0
        star_cbs = self._event_callbacks.get('*', {})
1356 0
        node_cbs = self._event_callbacks.get(event.node, {})
1357 0
        inline_cbs = {attr: [getattr(self, p)] for node, attr, p in self._inline_callbacks
1358
                      if node == event.node}
1359 0
        event_cbs = (
1360
            node_cbs.get(event_type, []) + node_cbs.get('*', []) +
1361
            star_cbs.get(event_type, []) + star_cbs.get('*', []) +
1362
            inline_cbs.get(event_type, [])
1363
        )
1364 0
        for cb in event_cbs:
1365 0
            cb(event)
1366

1367 7
    def _set_on_model(self, msg, root, model):
1368 7
        if not msg:
1369 7
            return
1370 7
        self._changing[root.ref['id']] = [
1371
            attr for attr, value in msg.items()
1372
            if not model.lookup(attr).property.matches(getattr(model, attr), value)
1373
        ]
1374 7
        try:
1375 7
            model.update(**msg)
1376
        finally:
1377 7
            del self._changing[root.ref['id']]
1378

1379 7
    def _update_model(self, events, msg, root, model, doc, comm):
1380 7
        child_params = self._parser.children.values()
1381 7
        new_children, model_msg, data_msg  = {}, {}, {}
1382 7
        for prop, v in list(msg.items()):
1383 7
            if prop in child_params:
1384 7
                new_children[prop] = prop
1385 7
            elif prop in list(Reactive.param)+['events']:
1386 7
                model_msg[prop] = v
1387 0
            elif prop in self.param and (self.param[prop].precedence or 0) < 0:
1388 0
                continue
1389 0
            elif isinstance(v, str):
1390 0
                data_msg[prop] = bleach.clean(v)
1391
            else:
1392 0
                data_msg[prop] = v
1393 7
        if new_children:
1394 7
            old_children = {key: events[key].old for key in new_children}
1395 7
            if self._parser.looped:
1396 7
                model_msg['html'] = escape(self._get_template())
1397 7
            children = self._get_children(doc, root, model, comm, old_children)
1398
        else:
1399 7
            children = None
1400 7
        self._set_on_model(data_msg, root, model.data)
1401 7
        self._set_on_model(model_msg, root, model)
1402 7
        if children is not None:
1403 7
            self._set_on_model({'children': children}, root, model)
1404

1405 7
    def on_event(self, node, event, callback):
1406
        """
1407
        Registers a callback to be executed when the specified DOM
1408
        event is triggered on the named node. Note that the named node
1409
        must be declared in the HTML. To create a named node you must
1410
        give it an id of the form `id="name"`, where `name` will
1411
        be the node identifier.
1412

1413
        Arguments
1414
        ---------
1415
        node: str
1416
          Named node in the HTML identifiable via id of the form `id="name"`.
1417
        event: str
1418
          Name of the DOM event to add an event listener to.
1419
        callback: callable
1420
          A callable which will be given the DOMEvent object.
1421
        """
1422 7
        if node not in self._parser.nodes and node != '*':
1423 0
            raise ValueError(f"Named node '{node}' not found. Available "
1424
                             f"nodes include: {self._parser.nodes}.")
1425 7
        self._event_callbacks[node][event].append(callback)
1426 7
        events = self._get_events()
1427 7
        for ref, (model, _) in self._models.items():
1428 7
            print(model)
1429 7
            self._apply_update([], {'events': events}, model, ref)

Read our documentation on viewing source code .

Loading