1
"""
2
Various utilities for recording and embedding state in a rendered app.
3
"""
4 7
from __future__ import absolute_import, division, unicode_literals
5

6 7
import os
7 7
import json
8 7
import uuid
9 7
import param
10 7
import sys
11

12 7
from collections import defaultdict
13 7
from contextlib import contextmanager
14 7
from itertools import product
15

16 7
from bokeh.core.property.bases import Property
17 7
from bokeh.models import CustomJS
18 7
from param.parameterized import Watcher
19

20 7
from .model import add_to_doc, diff
21 7
from .state import state
22

23
#---------------------------------------------------------------------
24
# Private API
25
#---------------------------------------------------------------------
26

27 7
STATE_JS = """
28
var state = null
29
for (var root of cb_obj.document.roots()) {{
30
  if (root.id == '{id}') {{
31
    state = root;
32
    break;
33
  }}
34
}}
35
if (!state) {{ return; }}
36
state.set_state(cb_obj, {js_getter})
37
"""
38

39

40 7
@contextmanager
41 2
def always_changed(enable):
42 7
    def matches(self, new, old):
43 0
        return False
44 7
    if enable:
45 0
        backup = Property.matches
46 0
        Property.matches = matches
47 7
    try:
48 7
        yield
49
    finally:
50 7
        if enable:
51 0
            Property.matches = backup
52

53

54 7
def record_events(doc):
55 7
    msg = diff(doc, False)
56 7
    if msg is None:
57 7
        return {'header': '{}', 'metadata': '{}', 'content': '{}'}
58 7
    return {'header': msg.header_json, 'metadata': msg.metadata_json,
59
            'content': msg.content_json}
60

61

62 7
def save_dict(state, key=(), depth=0, max_depth=None, save_path='', load_path=None):
63 7
    filename_dict = {}
64 7
    for k, v in state.items():
65 7
        curkey = key+(k,)
66 7
        if depth < max_depth:
67 0
            filename_dict[k] = save_dict(v, curkey, depth+1, max_depth,
68
                                         save_path, load_path)
69
        else:
70 7
            filename = '_'.join([str(i) for i in curkey]) +'.json'
71 7
            filepath = os.path.join(save_path, filename)
72 7
            directory = os.path.dirname(filepath)
73 7
            if not os.path.exists(directory):
74 7
                os.makedirs(directory)
75 7
            with open(filepath, 'w') as f:
76 7
                json.dump(v, f)
77 7
            refpath = filepath
78 7
            if load_path:
79 0
                refpath = os.path.join(load_path, filename)
80 7
            filename_dict[k] = refpath
81 7
    return filename_dict
82

83

84 7
def get_watchers(reactive):
85 7
    return [w for pwatchers in reactive._param_watchers.values()
86
            for awatchers in pwatchers.values() for w in awatchers]
87

88

89 7
def param_to_jslink(model, widget):
90
    """
91
    Converts Param pane widget links into JS links if possible.
92
    """
93 7
    from ..reactive import Reactive
94 7
    from ..widgets import Widget, LiteralInput
95

96 7
    param_pane = widget._param_pane
97 7
    pobj = param_pane.object
98 7
    pname = [k for k, v in param_pane._widgets.items() if v is widget]
99 7
    watchers = [w for w in get_watchers(widget) if w not in widget._callbacks
100
                and w not in param_pane._callbacks]
101

102 7
    if isinstance(pobj, Reactive):
103 7
        tgt_links = [Watcher(*l[:-4]) for l in pobj._links]
104 7
        tgt_watchers = [w for w in get_watchers(pobj) if w not in pobj._callbacks
105
                        and w not in tgt_links and w not in param_pane._callbacks]
106
    else:
107 0
        tgt_watchers = []
108

109 7
    for widget in param_pane._widgets.values():
110 7
        if isinstance(widget, LiteralInput):
111 0
            widget.serializer = 'json'
112

113 7
    if (not pname or not isinstance(pobj, Reactive) or watchers or
114
        pname[0] not in pobj._linkable_params or
115
        (not isinstance(pobj, Widget) and tgt_watchers)):
116 0
        return
117 7
    return link_to_jslink(model, widget, 'value', pobj, pname[0])
118

119

120 7
def link_to_jslink(model, source, src_spec, target, tgt_spec):
121
    """
122
    Converts links declared in Python into JS Links by using the
123
    declared forward and reverse JS transforms on the source and target.
124
    """
125 7
    ref = model.ref['id']
126

127 7
    if ((source._source_transforms.get(src_spec, False) is None) or
128
        (target._target_transforms.get(tgt_spec, False) is None) or
129
        ref not in source._models or ref not in target._models):
130
        # We cannot jslink if either source or target declare
131
        # that they apply Python transforms
132 0
        return
133

134 7
    from ..links import Link, JSLinkCallbackGenerator
135 7
    properties = dict(value=target._rename.get(tgt_spec, tgt_spec))
136 7
    link = Link(source, target, bidirectional=True, properties=properties)
137 7
    JSLinkCallbackGenerator(model, link, source, target)
138 7
    return link
139

140

141 7
def links_to_jslinks(model, widget):
142 7
    from ..widgets import Widget
143

144 7
    src_links = [Watcher(*l[:-4]) for l in widget._links]
145 7
    if any(w not in widget._callbacks and w not in src_links for w in get_watchers(widget)):
146 0
        return
147

148 7
    links = []
149 7
    for link in widget._links:
150 7
        target = link.target
151 7
        tgt_watchers = [w for w in get_watchers(target) if w not in target._callbacks]
152 7
        if link.transformed or (tgt_watchers and not isinstance(target, Widget)):
153 7
            return
154

155 7
        mappings = []
156 7
        for pname, tgt_spec in link.links.items():
157 7
            if Watcher(*link[:-4]) in widget._param_watchers[pname]['value']:
158 7
                mappings.append((pname, tgt_spec))
159

160 7
        if mappings:
161 7
            links.append((link, mappings))
162 7
    jslinks = []
163 7
    for link, mapping in links:
164 7
        for src_spec, tgt_spec in mapping:
165 7
            jslink = link_to_jslink(model, widget, src_spec, link.target, tgt_spec)
166 7
            if jslink is None:
167 0
                return
168 7
            widget.param.trigger(src_spec)
169 7
            jslinks.append(jslink)
170 7
    return jslinks
171

172

173
#---------------------------------------------------------------------
174
# Public API
175
#---------------------------------------------------------------------
176

177 7
def embed_state(panel, model, doc, max_states=1000, max_opts=3,
178
                json=False, json_prefix='', save_path='./',
179
                load_path=None, progress=True, states={}):
180
    """
181
    Embeds the state of the application on a State model which allows
182
    exporting a static version of an app. This works by finding all
183
    widgets with a predefined set of options and evaluating the cross
184
    product of the widget values and recording the resulting events to
185
    be replayed when exported. The state is recorded on a State model
186
    which is attached as an additional root on the Document.
187

188
    Arguments
189
    ---------
190
    panel: panel.reactive.Reactive
191
      The Reactive component being exported
192
    model: bokeh.model.Model
193
      The bokeh model being exported
194
    doc: bokeh.document.Document
195
      The bokeh Document being exported
196
    max_states: int (default=1000)
197
      The maximum number of states to export
198
    max_opts: int (default=3)
199
      The max number of ticks sampled in a continuous widget like a slider
200
    json: boolean (default=True)
201
      Whether to export the data to json files
202
    json_prefix: str (default='')
203
      Prefix for JSON filename
204
    save_path: str (default='./')
205
      The path to save json files to
206
    load_path: str (default=None)
207
      The path or URL the json files will be loaded from.
208
    progress: boolean (default=True)
209
      Whether to report progress
210
    states: dict (default={})
211
      A dictionary specifying the widget values to embed for each widget
212
    """
213 7
    from tqdm import tqdm
214

215 7
    from ..config import config
216 7
    from ..layout import Panel
217 7
    from ..links import Link
218 7
    from ..models.state import State
219 7
    from ..pane import PaneBase
220 7
    from ..widgets import Widget, DiscreteSlider
221

222 7
    ref = model.ref['id']
223 7
    if isinstance(panel, PaneBase) and ref in panel.layout._models:
224 0
        panel = panel.layout
225

226 7
    if not isinstance(panel, Panel):
227 0
        add_to_doc(model, doc)
228 0
        return
229 7
    _, _, _, comm = state._views[ref]
230

231 7
    model.tags.append('embedded')
232

233 7
    def is_embeddable(object):
234 7
        if not isinstance(object, Widget):
235 7
            return False
236 7
        if isinstance(object, DiscreteSlider):
237 0
            return ref in object._composite[1]._models
238 7
        return ref in object._models
239

240 7
    widgets = [w for w in panel.select(is_embeddable)
241
               if w not in Link.registry]
242 7
    state_model = State()
243

244 7
    widget_data, merged, ignore = [], {}, []
245 7
    for widget in widgets:
246 7
        if widget._param_pane is not None:
247
            # Replace parameter links with JS links
248 7
            link = param_to_jslink(model, widget)
249 7
            if link is not None:
250 7
                pobj = widget._param_pane.object
251 7
                if isinstance(pobj, Widget):
252 7
                    if not any(w not in pobj._callbacks and w not in widget._param_pane._callbacks
253
                               for w in get_watchers(pobj)):
254 7
                        ignore.append(pobj)
255 7
                continue # Skip if we were able to attach JS link
256

257 7
        if widget._links:
258 7
            jslinks = links_to_jslinks(model, widget)
259 7
            if jslinks:
260 7
                continue
261

262
        # If the widget does not support embedding or has no external callback skip it
263 7
        if not widget._supports_embed or all(w in widget._callbacks for w in get_watchers(widget)):
264 0
            continue
265

266
        # Get data which will let us record the changes on widget events
267 7
        w, w_model, vals, getter, on_change, js_getter = widget._get_embed_state(
268
            model, states.get(widget), max_opts)
269 7
        w_type = w._widget_type
270 7
        if isinstance(w, DiscreteSlider):
271 7
            w_model = w._composite[1]._models[ref][0].select_one({'type': w_type})
272
        else:
273 7
            w_model = w._models[ref][0]
274 7
            if not isinstance(w_model, w_type):
275 0
                w_model = w_model.select_one({'type': w_type})
276

277
        # If there is a widget with the same name, merge with it
278 7
        if widget.name and widget.name in merged:
279 7
            merged[widget.name][0].append(w)
280 7
            merged[widget.name][1].append(w_model)
281 7
            continue
282

283 7
        js_callback = CustomJS(code=STATE_JS.format(
284
            id=state_model.ref['id'], js_getter=js_getter))
285 7
        widget_data.append(([w], [w_model], vals, getter, js_callback, on_change))
286 7
        merged[widget.name] = widget_data[-1]
287

288
    # Ensure we recording state for widgets which could be JS linked
289 7
    values = []
290 7
    for (ws, w_models, vals, getter, js_callback, on_change) in widget_data:
291 7
        if ws[0] in ignore:
292 7
            continue
293 7
        for w_model in w_models:
294 7
            w_model.js_on_change(on_change, js_callback)
295

296
        # Bidirectionally link models with same name
297 7
        wm = w_models[0]
298 7
        for wmo in w_models[1:]:
299 7
            attr = ws[0]._rename.get('value', 'value')
300 7
            wm.js_link(attr, wmo, attr)
301 7
            wmo.js_link(attr, wm, attr)
302

303 7
        values.append((ws, w_models, vals, getter))
304

305 7
    add_to_doc(model, doc, True)
306 7
    doc._held_events = []
307

308 7
    if not widget_data:
309 7
        return
310

311 7
    restore = [ws[0].value for ws, _, _, _ in values]
312 7
    init_vals = [g(ms[0]) for _, ms, _, g in values]
313 7
    cross_product = list(product(*[vals[::-1] for _, _, vals, _ in values]))
314 7
    if len(cross_product) > max_states:
315 0
        if config._doc_build:
316 0
            return
317 0
        param.main.warning('The cross product of different application '
318
                           'states is very large to explore (N=%d), consider '
319
                           'reducing the number of options on the widgets or '
320
                           'increase the max_states specified in the function '
321
                           'to remove this warning' %
322
                           len(cross_product))
323

324 7
    nested_dict = lambda: defaultdict(nested_dict)
325 7
    state_dict = nested_dict()
326 7
    changes = False
327 7
    for key in (tqdm(cross_product, leave=False, file=sys.stdout) if progress else cross_product):
328 7
        sub_dict = state_dict
329 7
        skip = False
330 7
        for i, k in enumerate(key):
331 7
            ws, m, _, g = values[i]
332 7
            try:
333 7
                with always_changed(config.safe_embed):
334 7
                    for w in ws:
335 7
                        w.value = k
336 0
            except Exception:
337 0
                skip = True
338 0
                break
339 7
            sub_dict = sub_dict[g(m[0])]
340 7
        if skip:
341 0
            doc._held_events = []
342 0
            continue
343

344
        # Drop events originating from widgets being varied
345 7
        models = [m for v in values for m in v[1]]
346 7
        doc._held_events = [e for e in doc._held_events if e.model not in models]
347 7
        events = record_events(doc)
348 7
        changes |= events['content'] != '{}'
349 7
        if events:
350 7
            sub_dict.update(events)
351

352 7
    if not changes:
353 7
        return
354

355 7
    for (ws, _, _, _), v in zip(values, restore):
356 7
        try:
357 7
            for w in ws:
358 7
                w.param.set_param(value=v)
359 0
        except Exception:
360 0
            pass
361

362 7
    if json:
363 7
        random_dir = '_'.join([json_prefix, uuid.uuid4().hex])
364 7
        save_path = os.path.join(save_path, random_dir)
365 7
        if load_path is not None:
366 0
            load_path = os.path.join(load_path, random_dir)
367 7
        state_dict = save_dict(state_dict, max_depth=len(values)-1,
368
                               save_path=save_path, load_path=load_path)
369

370 7
    state_model.update(json=json, state=state_dict, values=init_vals,
371
                       widgets={m[0].ref['id']: i for i, (_, m, _, _) in enumerate(values)})
372 7
    doc.add_root(state_model)
373 7
    return state_model

Read our documentation on viewing source code .

Loading