1#-----------------------------------------------------------------------------
2# Copyright (c) 2012 - 2021, Anaconda, Inc., and Bokeh Contributors.
3# All rights reserved.
4#
5# The full license is in the file LICENSE.txt, distributed with this software.
6#-----------------------------------------------------------------------------
7'''
8
9'''
10
11#-----------------------------------------------------------------------------
12# Boilerplate
13#-----------------------------------------------------------------------------
14import logging # isort:skip
15log = logging.getLogger(__name__)
16
17#-----------------------------------------------------------------------------
18# Imports
19#-----------------------------------------------------------------------------
20
21# Standard library imports
22from collections import OrderedDict
23from collections.abc import Sequence
24from contextlib import contextmanager
25
26# Bokeh imports
27from ..document.document import Document
28from ..model import Model, collect_models
29from ..settings import settings
30from ..util.serialization import make_globally_unique_id
31
32#-----------------------------------------------------------------------------
33# Globals and constants
34#-----------------------------------------------------------------------------
35
36__all__ = (
37    'FromCurdoc',
38    'OutputDocumentFor',
39    'RenderItem',
40    'RenderRoot',
41    'RenderRoots',
42    'standalone_docs_json',
43    'standalone_docs_json_and_render_items',
44    'submodel_has_python_callbacks',
45)
46
47#-----------------------------------------------------------------------------
48# General API
49#-----------------------------------------------------------------------------
50
51#-----------------------------------------------------------------------------
52# Dev API
53#-----------------------------------------------------------------------------
54
55class FromCurdoc:
56    ''' This class merely provides a non-None default value for ``theme``
57    arguments, since ``None`` itself is a meaningful value for users to pass.
58
59    '''
60    pass
61
62@contextmanager
63def OutputDocumentFor(objs, apply_theme=None, always_new=False):
64    ''' Find or create a (possibly temporary) Document to use for serializing
65    Bokeh content.
66
67    Typical usage is similar to:
68
69    .. code-block:: python
70
71         with OutputDocumentFor(models):
72            (docs_json, [render_item]) = standalone_docs_json_and_render_items(models)
73
74    Inside the context manager, the models will be considered to be part of a single
75    Document, with any theme specified, which can thus be serialized as a unit. Where
76    possible, OutputDocumentFor attempts to use an existing Document. However, this is
77    not possible in three cases:
78
79    * If passed a series of models that have no Document at all, a new Document will
80      be created, and all the models will be added as roots. After the context manager
81      exits, the new Document will continue to be the models' document.
82
83    * If passed a subset of Document.roots, then OutputDocumentFor temporarily "re-homes"
84      the models in a new bare Document that is only available inside the context manager.
85
86    * If passed a list of models that have different documents, then OutputDocumentFor
87      temporarily "re-homes" the models in a new bare Document that is only available
88      inside the context manager.
89
90    OutputDocumentFor will also perfom document validation before yielding, if
91    ``settings.perform_document_validation()`` is True.
92
93
94        objs (seq[Model]) :
95            a sequence of Models that will be serialized, and need a common document
96
97        apply_theme (Theme or FromCurdoc or None, optional):
98            Sets the theme for the doc while inside this context manager. (default: None)
99
100            If None, use whatever theme is on the document that is found or created
101
102            If FromCurdoc, use curdoc().theme, restoring any previous theme afterwards
103
104            If a Theme instance, use that theme, restoring any previous theme afterwards
105
106        always_new (bool, optional) :
107            Always return a new document, even in cases where it is otherwise possible
108            to use an existing document on models.
109
110    Yields:
111        Document
112
113    '''
114    # Note: Comms handling relies on the fact that the new_doc returned
115    # has models with the same IDs as they were started with
116
117    if not isinstance(objs, Sequence) or len(objs) == 0 or not all(isinstance(x, Model) for x in objs):
118        raise ValueError("OutputDocumentFor expects a sequence of Models")
119
120    def finish(): pass
121
122    docs = {x.document for x in objs}
123    docs.discard(None)
124
125    if always_new:
126        def finish(): # NOQA
127            _dispose_temp_doc(objs)
128        doc = _create_temp_doc(objs)
129
130    else:
131        if len(docs) == 0:
132            doc = Document()
133            for model in objs:
134                doc.add_root(model)
135
136        # handle a single shared document
137        elif len(docs) == 1:
138            doc = docs.pop()
139
140            # we are not using all the roots, make a quick clone for outputting purposes
141            if set(objs) != set(doc.roots):
142                def finish(): # NOQA
143                    _dispose_temp_doc(objs)
144                doc = _create_temp_doc(objs)
145
146            # we are using all the roots of a single doc, just use doc as-is
147            pass  # lgtm [py/unnecessary-pass]
148
149        # models have mixed docs, just make a quick clone
150        else:
151            def finish(): # NOQA
152                _dispose_temp_doc(objs)
153            doc = _create_temp_doc(objs)
154
155    if settings.perform_document_validation():
156        doc.validate()
157
158    _set_temp_theme(doc, apply_theme)
159
160    yield doc
161
162    _unset_temp_theme(doc)
163
164    finish()
165
166
167class RenderItem:
168    def __init__(self, docid=None, token=None, elementid=None, roots=None, use_for_title=None):
169        if (docid is None and token is None) or (docid is not None and token is not None):
170            raise ValueError("either docid or sessionid must be provided")
171
172        if roots is None:
173            roots = OrderedDict()
174        elif isinstance(roots, list):
175            roots = OrderedDict([ (root, make_globally_unique_id()) for root in roots ])
176
177        self.docid = docid
178        self.token = token
179        self.elementid = elementid
180        self.roots = RenderRoots(roots)
181        self.use_for_title = use_for_title
182
183    def to_json(self):
184        json = {}
185
186        if self.docid is not None:
187            json["docid"] = self.docid
188        else:
189            json["token"] = self.token
190
191        if self.elementid is not None:
192            json["elementid"] = self.elementid
193
194        if self.roots:
195            json["roots"] = self.roots.to_json()
196            json["root_ids"] = [root.id for root in self.roots]
197
198        if self.use_for_title is not None:
199            json["use_for_title"] = self.use_for_title
200
201        return json
202
203    def __eq__(self, other):
204        if not isinstance(other, self.__class__):
205            return False
206        else:
207            return self.to_json() == other.to_json()
208
209
210class RenderRoot:
211    def __init__(self, elementid, id, name=None, tags=None):
212        self.elementid = elementid
213        self.id = id
214        self.name = name or ""
215        self.tags = tags or []
216
217    def __eq__(self, other):
218        if not isinstance(other, self.__class__):
219            return False
220        else:
221            return self.elementid == other.elementid
222
223
224class RenderRoots:
225    def __init__(self, roots):
226        self._roots = roots
227
228    def __len__(self):
229        return len(self._roots.items())
230
231    def __getitem__(self, key):
232        if isinstance(key, int):
233            (root, elementid) = list(self._roots.items())[key]
234        else:
235            for root, elementid in self._roots.items():
236                if root.name == key:
237                    break
238            else:
239                raise ValueError("root with '%s' name not found" % key)
240
241        return RenderRoot(elementid, root.id, root.name, root.tags)
242
243    def __getattr__(self, key):
244        return self.__getitem__(key)
245
246    def to_json(self):
247        return OrderedDict([ (root.id, elementid) for root, elementid in self._roots.items() ])
248
249def standalone_docs_json(models):
250    '''
251
252    '''
253    docs_json, render_items = standalone_docs_json_and_render_items(models)
254    return docs_json
255
256def standalone_docs_json_and_render_items(models, suppress_callback_warning=False):
257    '''
258
259    '''
260    if isinstance(models, (Model, Document)):
261        models = [models]
262
263    if not (isinstance(models, Sequence) and all(isinstance(x, (Model, Document)) for x in models)):
264        raise ValueError("Expected a Model, Document, or Sequence of Models or Documents")
265
266    if submodel_has_python_callbacks(models) and not suppress_callback_warning:
267        log.warning(_CALLBACKS_WARNING)
268
269    docs = {}
270    for model_or_doc in models:
271        if isinstance(model_or_doc, Document):
272            model = None
273            doc = model_or_doc
274        else:
275            model = model_or_doc
276            doc = model.document
277
278            if doc is None:
279                raise ValueError("A Bokeh Model must be part of a Document to render as standalone content")
280
281        if doc not in docs:
282            docs[doc] = (make_globally_unique_id(), OrderedDict())
283
284        (docid, roots) = docs[doc]
285
286        if model is not None:
287            roots[model] = make_globally_unique_id()
288        else:
289            for model in doc.roots:
290                roots[model] = make_globally_unique_id()
291
292    docs_json = {}
293    for doc, (docid, _) in docs.items():
294        docs_json[docid] = doc.to_json()
295
296    render_items = []
297    for _, (docid, roots) in docs.items():
298        render_items.append(RenderItem(docid, roots=roots))
299
300    return (docs_json, render_items)
301
302def submodel_has_python_callbacks(models):
303    ''' Traverses submodels to check for Python (event) callbacks
304
305    '''
306    has_python_callback = False
307    for model in collect_models(models):
308        if len(model._callbacks) > 0 or len(model._event_callbacks) > 0:
309            has_python_callback = True
310            break
311
312    return has_python_callback
313
314#-----------------------------------------------------------------------------
315# Private API
316#-----------------------------------------------------------------------------
317
318_CALLBACKS_WARNING = """
319You are generating standalone HTML/JS output, but trying to use real Python
320callbacks (i.e. with on_change or on_event). This combination cannot work.
321
322Only JavaScript callbacks may be used with standalone output. For more
323information on JavaScript callbacks with Bokeh, see:
324
325    https://docs.bokeh.org/en/latest/docs/user_guide/interaction/callbacks.html
326
327Alternatively, to use real Python callbacks, a Bokeh server application may
328be used. For more information on building and running Bokeh applications, see:
329
330    https://docs.bokeh.org/en/latest/docs/user_guide/server.html
331"""
332
333def _create_temp_doc(models):
334    doc = Document()
335    for m in models:
336        doc._all_models[m.id] = m
337        m._temp_document = doc
338        for ref in m.references():
339            doc._all_models[ref.id] = ref
340            ref._temp_document = doc
341    doc._roots = models
342    return doc
343
344def _dispose_temp_doc(models):
345    for m in models:
346        m._temp_document = None
347        for ref in m.references():
348            ref._temp_document = None
349
350def _set_temp_theme(doc, apply_theme):
351    doc._old_theme = doc.theme
352    if apply_theme is FromCurdoc:
353        from ..io import curdoc; curdoc
354        doc.theme = curdoc().theme
355    elif apply_theme is not None:
356        doc.theme = apply_theme
357
358def _unset_temp_theme(doc):
359    if not hasattr(doc, "_old_theme"):
360        return
361    doc.theme = doc._old_theme
362    del doc._old_theme
363
364#-----------------------------------------------------------------------------
365# Code
366#-----------------------------------------------------------------------------
367