├── .gitignore ├── LICENSE ├── README.md ├── bokeh_django ├── __init__.py ├── apps.py ├── consumers.py ├── routing.py └── static.py ├── conda.recipe └── meta.yaml ├── examples └── django_embed │ ├── .gitignore │ ├── README.md │ ├── bokeh_apps │ └── sea_surface.py │ ├── django_embed │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── shape_viewer.py │ ├── templates │ │ ├── embed.html │ │ └── index.html │ ├── themes │ │ └── theme.yaml │ ├── urls.py │ └── views.py │ └── manage.py └── pyproject.toml /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Bokeh 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bokeh-django 2 | Support for running Bokeh apps with Django 3 | 4 | ## Introduction 5 | Both Bokeh and Django are web frameworks that can be used independently to build and host web applications. They each have their own strengths and the purpose of the ``bokeh_django`` package is to integrate these two frameworks so their strengths can be used together. 6 | 7 | ## Installation 8 | 9 | ```commandline 10 | pip install bokeh-django 11 | ``` 12 | 13 | ## Configuration 14 | 15 | This documentation assumes that you have already started a [Django project](https://docs.djangoproject.com/en/4.2/intro/tutorial01/). 16 | 17 | `bokeh-django` enables you to define routes (URLs) in your Django project that will map to Bokeh applications or embed Bokeh applications into a template rendered by Django. However, before defining the routes there are several configuration steps that need to be completed first. 18 | 19 | 1. Configure ``INSTALLED_APPS``: 20 | 21 | In the ``settings.py`` file ensure that both ``channels`` and ``bokeh_django`` are added to the ``INSTALLED_APPS`` list: 22 | 23 | ```python 24 | INSTALLED_APPS = [ 25 | ..., 26 | 'channels', 27 | 'bokeh_django', 28 | ] 29 | ``` 30 | 31 | 2. Set Up an ASGI Application: 32 | 33 | By default, the Django project will be configured to use a WSGI application, but the ``startproject`` command should have also created an ``asgi.py`` file. 34 | 35 | In ``settings.py`` change the ``WSGI_APPLICATION`` setting to ``ASGI_APPLICATION`` and modify the path accordingly. It should look something like this: 36 | 37 | ```python 38 | ASGI_APPLICATION = 'mysite.asgi.application' 39 | ``` 40 | 41 | Next, modify the contents of the ``asgi.py`` file to get the URL patterns from the ``bokeh_django`` app config. Something similar to this will work: 42 | 43 | ```python 44 | from channels.auth import AuthMiddlewareStack 45 | from channels.routing import ProtocolTypeRouter, URLRouter 46 | from django.apps import apps 47 | 48 | bokeh_app_config = apps.get_app_config('bokeh_django') 49 | 50 | application = ProtocolTypeRouter({ 51 | 'websocket': AuthMiddlewareStack(URLRouter(bokeh_app_config.routes.get_websocket_urlpatterns())), 52 | 'http': AuthMiddlewareStack(URLRouter(bokeh_app_config.routes.get_http_urlpatterns())), 53 | }) 54 | ``` 55 | 56 | 3. Configure Static Files: 57 | 58 | Both Bokeh and Django have several ways of configuring serving static resources. This documentation will describe several possible configuration approaches. 59 | 60 | The Bokeh [``resources`` setting](https://docs.bokeh.org/en/latest/docs/reference/settings.html#resources) can be set to one of several values (e.g ``server``, ``inline``, ``cdn``), the default is ``cdn``. If this setting is set to ``inline``, or ``cdn`` then Bokeh resources will be served independently of Django resources. However, if the Bokeh ``resources`` setting is set to ``server``, then the Bokeh resources are served up by the Django server in the same way that the Django static resources are and so Django must be configured to be able to find the Bokeh resources. 61 | 62 | To specify the Bokeh ``resources`` setting add the following to the Django ``settings.py`` file: 63 | 64 | ```python 65 | from bokeh.settings import settings as bokeh_settings 66 | 67 | bokeh_settings.resources = 'server' 68 | ``` 69 | 70 | If the Bokeh ``resources`` setting is set to ``server`` then we must add the location of the Bokeh resources to the ``STATICFILES_DIRS`` setting: 71 | 72 | ```python 73 | from bokeh.settings import settings as bokeh_settings 74 | 75 | try: 76 | bokeh_js_dir = bokeh_settings.bokehjs_path() 77 | except AttributeError: 78 | # support bokeh versions < 3.4 79 | bokeh_js_dir = bokeh_settings.bokehjsdir() 80 | 81 | STATICFILES_DIRS = [ 82 | ..., 83 | bokeh_js_dir, 84 | ] 85 | ``` 86 | 87 | Django can be configured to automatically find and collect static files using the [``staticfiles`` app](https://docs.djangoproject.com/en/4.2/ref/contrib/staticfiles/), or the static file URL patterns can be explicitly added to the list of ``urlpatterns`` in the ``urls.py`` file. 88 | 89 | To explicitly add the static file ``urlpatterns`` add the following to the ``urls.py`` file: 90 | 91 | ```python 92 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 93 | from bokeh_django import static_extensions 94 | 95 | urlpatterns = [ 96 | ..., 97 | *static_extensions(), 98 | *staticfiles_urlpatterns(), 99 | ] 100 | ``` 101 | 102 | Be sure that the ``static_extensions`` are listed before the ``staticfiles_urlpatterns``. 103 | 104 | Alternatively, you can configure the [``staticfiles`` app](https://docs.djangoproject.com/en/4.2/ref/contrib/staticfiles/) by adding ``'django.contrib.staticfiles',`` to ``INSTALLED_APPS``: 105 | 106 | ```python 107 | INSTALLED_APPS = [ 108 | ..., 109 | 'django.contrib.staticfiles', 110 | 'channels', 111 | 'bokeh_django', 112 | ] 113 | ``` 114 | 115 | Next add ``bokeh_django.static.BokehExtensionFinder`` to the ``STATICFILES_FINDERS`` setting. The default value for ``STATICFILES_FINDERS`` has two items. If you override the default by adding the ``STATICFILES_FINDERS`` setting to your ``settings.py`` file, then be sure to also list the two default values in addition to the ``BokehExtensionFinder``: 116 | 117 | ```python 118 | STATICFILES_FINDERS = ( 119 | "django.contrib.staticfiles.finders.FileSystemFinder", 120 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 121 | 'bokeh_django.static.BokehExtensionFinder', 122 | ) 123 | ``` 124 | 125 | ## Define Routes 126 | 127 | Bokeh applications are integrated into Django through routing or URLs. 128 | 129 | In a Django app, the file specified by the ``ROOT_URLCONF`` setting (e.g. ``urls.py``) must define ``urlpatterns`` which is a sequence of ``django.url.path`` and/or ``django.url.re_path`` objects. When integrating a Django app with Bokeh, the ``urls.py`` file must also define ``bokeh_apps`` as a sequence of ``bokeh_django`` routing objects. This should be done using the ``bokeh_djagno.document`` and/or ``bokeh_django.autoload`` functions. 130 | 131 | ### Document 132 | 133 | The first way to define a route is to use ``bokeh_django.document``, which defines a route to a Bokeh app (as either a file-path or a function). 134 | 135 | ```python 136 | from bokeh_django import document 137 | from .views import my_bokeh_app_function 138 | 139 | bokeh_apps = [ 140 | document('url-pattern/', '/path/to/bokeh/app.py'), 141 | document('another-url-pattern/', my_bokeh_app_function) 142 | ] 143 | ``` 144 | When using the ``document`` route Django will route the URL directly to the Bokeh app and all the rendering will be handled by Bokeh. 145 | 146 | ### Directory 147 | 148 | An alternative way to create ``document`` routes is to use ``bokeh_django.directory`` to automatically create a ``document`` route for all the bokeh apps found in a directory. In this case the file name will be used as the URL pattern. 149 | 150 | ```python 151 | from bokeh_django import directory 152 | 153 | bokeh_apps = directory('/path/to/bokeh/apps/') 154 | ``` 155 | 156 | ### Autoload 157 | 158 | To integrate more fully into a Django application routes can be created using ``autoload``. This allows the Bokeh application to be embedded in a template that is rendered by Django. This has the advantage of being able to leverage Django capabilities in the view and the template, but is slightly more involved to set up. There are five components that all need to be configured to work together: the [Bokeh handler](#bokeh-handler), the [Django view](#django-view), the [template](#template), the [Django URL path](#django-url-path), and the [Bokeh URL route](#bokeh-url-route). 159 | 160 | #### Bokeh Handler 161 | 162 | The handler is a function (or any callable) that accepts a ``bokeh.document.Document`` object and configures it with the Bokeh content that should be embedded. This is done by adding a Bokeh object as the document root: 163 | 164 | ```python 165 | from bokeh.document import Document 166 | from bokeh.layouts import column 167 | from bokeh.models import Slider 168 | 169 | def bokeh_handler(doc: Document) -> None: 170 | slider = Slider(start=0, end=30, value=0, step=1, title="Example") 171 | doc.add_root(column(slider)) 172 | ``` 173 | 174 | The handler can also embed a Panel object. In this case the document is passed in to the ``server_doc`` method of the Panel object: 175 | 176 | ```python 177 | import panel as pn 178 | def panel_handler(doc: Document) -> None: 179 | pn.Row().server_doc(doc) 180 | ``` 181 | 182 | #### Django View 183 | 184 | The view is a Django function that accepts a ``request`` object and returns a ``response``. A view that embeds a Bokeh app must create a ``bokeh.embed.server_document`` and pass it in the context to the template when rendering the response. 185 | 186 | ```python 187 | from bokeh.embed import server_document 188 | from django.shortcuts import render 189 | 190 | def view_function(request): 191 | script = server_document(request.build_absolute_uri()) 192 | return render(request, "embed.html", dict(script=script)) 193 | ``` 194 | 195 | #### Template 196 | 197 | The template document is a Django HTML template (e.g. ``"embed.html"``) that will be rendered by Django. It can be as complex as desired, but at the very least must render the ``script`` that was passed in from the context: 198 | 199 | ```html 200 | 201 | 202 |
203 | {{ script|safe }} 204 | 205 | 206 | ``` 207 | 208 | #### Django URL Path 209 | 210 | The [Django URL Path](#django-url-path) is a ``django.url.path`` or ``django.url.re_path`` object that is included in the ``urlpatters`` sequence and that maps a URL pattern to the [Django View](#django-view) as would normally be done with Django. 211 | 212 | ```python 213 | urlpatterns = [ 214 | path("embedded-bokeh-app/", views.view_function), 215 | ] 216 | ``` 217 | 218 | #### Bokeh URL Route 219 | 220 | The [Bokeh URL Route](#bokeh-url-route) is a ``bokeh_django.autoload`` object that is included in the ``bokeh_apps`` sequence and that maps a URL pattern to the [Bokeh handler](#bokeh-handler). 221 | 222 | ```python 223 | from bokeh_django import autoload 224 | 225 | bokeh_apps = [ 226 | autoload("embedded-bokeh-app/", views.handler) 227 | ] 228 | ``` 229 | 230 | Note that the URL pattern should be the same URL pattern that was used in the corresponding [Django URL Path](#django-url-path). In reality the URL pattern must match the URL that the ``server_document`` script is configured with in the [Django View](#django-view). Normally, it is easiest to use the URL from the ``request`` object (e.g. ``script = server_document(request.build_absolute_uri())``), which is the URL of the corresponding [Django URL Path](#django-url-path). 231 | -------------------------------------------------------------------------------- /bokeh_django/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | # Bokeh imports 4 | from bokeh.util.dependencies import import_required 5 | 6 | # Bokeh imports 7 | from .apps import DjangoBokehConfig 8 | from .consumers import AutoloadJsConsumer, WSConsumer 9 | from .routing import autoload, directory, document 10 | from .static import static_extensions 11 | 12 | import_required("django", "django is required by bokeh-django") 13 | import_required("channels", "The package channels is required by bokeh-django and must be installed") 14 | 15 | 16 | def with_request(handler): 17 | # Note that functools.wraps cannot be used here because Bokeh requires that the signature of the returned function 18 | # must only accept single (Document) argument 19 | def wrapper(doc): 20 | return handler(doc, doc.session_context.request) 21 | 22 | async def async_wrapper(doc): 23 | return await handler(doc, doc.session_context.request) 24 | 25 | return async_wrapper if inspect.iscoroutinefunction(handler) else wrapper 26 | 27 | 28 | def _get_args_kwargs_from_doc(doc): 29 | request = doc.session_context.request 30 | args = request.url_route['args'] 31 | kwargs = request.url_route['kwargs'] 32 | return args, kwargs 33 | 34 | 35 | def with_url_args(handler): 36 | # Note that functools.wraps cannot be used here because Bokeh requires that the signature of the returned function 37 | # must only accept single (Document) argument 38 | def wrapper(doc): 39 | args, kwargs = _get_args_kwargs_from_doc(doc) 40 | return handler(doc, *args, **kwargs) 41 | 42 | async def async_wrapper(doc): 43 | args, kwargs = _get_args_kwargs_from_doc(doc) 44 | return await handler(doc, *args, **kwargs) 45 | 46 | return async_wrapper if inspect.iscoroutinefunction(handler) else wrapper 47 | -------------------------------------------------------------------------------- /bokeh_django/apps.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (c) 2012 - 2022, 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 | # Boilerplate 10 | # ----------------------------------------------------------------------------- 11 | from __future__ import annotations 12 | 13 | import logging # isort:skip 14 | log = logging.getLogger(__name__) 15 | 16 | # ----------------------------------------------------------------------------- 17 | # Imports 18 | # ----------------------------------------------------------------------------- 19 | 20 | # Standard library imports 21 | from importlib import import_module 22 | from typing import List 23 | 24 | # External imports 25 | from django.apps import AppConfig 26 | from django.conf import settings 27 | 28 | # Bokeh imports 29 | from .routing import Routing, RoutingConfiguration 30 | 31 | # ----------------------------------------------------------------------------- 32 | # Globals and constants 33 | # ----------------------------------------------------------------------------- 34 | 35 | __all__ = ( 36 | 'DjangoBokehConfig', 37 | ) 38 | 39 | # ----------------------------------------------------------------------------- 40 | # General API 41 | # ----------------------------------------------------------------------------- 42 | 43 | 44 | class DjangoBokehConfig(AppConfig): 45 | 46 | name = 'bokeh_django' 47 | label = 'bokeh_django' 48 | 49 | _routes: RoutingConfiguration | None = None 50 | 51 | @property 52 | def bokeh_apps(self) -> List[Routing]: 53 | module = settings.ROOT_URLCONF 54 | url_conf = import_module(module) if isinstance(module, str) else module 55 | return url_conf.bokeh_apps 56 | 57 | @property 58 | def routes(self) -> RoutingConfiguration: 59 | if self._routes is None: 60 | self._routes = RoutingConfiguration(self.bokeh_apps) 61 | return self._routes 62 | 63 | # ----------------------------------------------------------------------------- 64 | # Dev API 65 | # ----------------------------------------------------------------------------- 66 | 67 | # ----------------------------------------------------------------------------- 68 | # Private API 69 | # ----------------------------------------------------------------------------- 70 | 71 | # ----------------------------------------------------------------------------- 72 | # Code 73 | # ----------------------------------------------------------------------------- 74 | -------------------------------------------------------------------------------- /bokeh_django/consumers.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (c) 2012 - 2022, 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 | # Boilerplate 10 | # ----------------------------------------------------------------------------- 11 | from __future__ import annotations 12 | 13 | import logging # isort:skip 14 | log = logging.getLogger(__name__) 15 | 16 | # ----------------------------------------------------------------------------- 17 | # Imports 18 | # ----------------------------------------------------------------------------- 19 | 20 | # Standard library imports 21 | import asyncio 22 | import calendar 23 | import datetime as dt 24 | import json 25 | from typing import Any, Dict, Set 26 | from urllib.parse import parse_qs, urljoin, urlparse 27 | 28 | # External imports 29 | from channels.consumer import AsyncConsumer 30 | from channels.generic.http import AsyncHttpConsumer 31 | from channels.generic.websocket import AsyncWebsocketConsumer 32 | from tornado import locks 33 | from tornado.ioloop import IOLoop 34 | 35 | # Bokeh imports 36 | from bokeh.core.templates import AUTOLOAD_JS 37 | from bokeh.embed.bundle import Script, bundle_for_objs_and_resources 38 | from bokeh.embed.elements import script_for_render_items 39 | from bokeh.embed.server import server_html_page_for_session 40 | from bokeh.embed.util import RenderItem 41 | from bokeh.protocol import Protocol 42 | from bokeh.protocol.message import Message 43 | from bokeh.protocol.receiver import Receiver 44 | from bokeh.resources import Resources 45 | from bokeh.server.connection import ServerConnection 46 | from bokeh.server.contexts import ApplicationContext 47 | from bokeh.server.protocol_handler import ProtocolHandler 48 | from bokeh.server.session import ServerSession 49 | from bokeh.server.views.static_handler import StaticHandler 50 | from bokeh.settings import settings 51 | from bokeh.util.token import ( 52 | check_token_signature, 53 | generate_jwt_token, 54 | generate_session_id, 55 | get_session_id, 56 | get_token_payload, 57 | ) 58 | 59 | # ----------------------------------------------------------------------------- 60 | # Globals and constants 61 | # ----------------------------------------------------------------------------- 62 | 63 | __all__ = ( 64 | 'DocConsumer', 65 | 'AutoloadJsConsumer', 66 | 'WSConsumer', 67 | ) 68 | 69 | # ----------------------------------------------------------------------------- 70 | # General API 71 | # ----------------------------------------------------------------------------- 72 | 73 | 74 | class ConsumerHelper(AsyncConsumer): 75 | 76 | _prefix = "/" 77 | 78 | @property 79 | def request(self) -> "AttrDict": 80 | request = AttrDict(self.scope) 81 | request["arguments"] = self.arguments 82 | 83 | # patch for panel 1.4 84 | request['protocol'] = request.get('scheme') 85 | for k, v in request.headers: 86 | request[k.decode()] = v.decode() 87 | request['uri'] = request.get('path') 88 | 89 | return request 90 | 91 | @property 92 | def arguments(self) -> Dict[str, str]: 93 | parsed_url = urlparse("/?" + self.scope["query_string"].decode()) 94 | return {name: value for name, [value] in parse_qs(parsed_url.query).items()} 95 | 96 | def get_argument(self, name: str, default: str | None = None) -> str | None: 97 | return self.arguments.get(name, default) 98 | 99 | def resources(self, absolute_url: str | None = None) -> Resources: 100 | mode = settings.resources() 101 | if mode == "server": 102 | root_url = urljoin(absolute_url, self._prefix) if absolute_url else self._prefix 103 | return Resources(mode="server", root_url=root_url, path_versioner=StaticHandler.append_version) 104 | return Resources(mode=mode) 105 | 106 | 107 | class SessionConsumer(AsyncHttpConsumer, ConsumerHelper): 108 | 109 | _application_context: ApplicationContext 110 | 111 | def __init__(self, *args: Any, **kwargs: Any) -> None: 112 | super().__init__(*args, **kwargs) 113 | self._application_context = kwargs.get('app_context') 114 | 115 | @property 116 | def application_context(self) -> ApplicationContext: 117 | # backwards compatibility 118 | if self._application_context is None: 119 | self._application_context = self.scope["url_route"]["kwargs"]["app_context"] 120 | 121 | # XXX: accessing asyncio's IOLoop directly doesn't work 122 | if self._application_context.io_loop is None: 123 | self._application_context._loop = IOLoop.current() 124 | return self._application_context 125 | 126 | async def _get_session(self) -> ServerSession: 127 | session_id = self.arguments.get('bokeh-session-id', 128 | generate_session_id(secret_key=None, signed=False)) 129 | payload = dict( 130 | headers={k.decode('utf-8'): v.decode('utf-8') for k, v in self.request.headers}, 131 | cookies=dict(self.request.cookies), 132 | ) 133 | token = generate_jwt_token(session_id, 134 | secret_key=None, 135 | signed=False, 136 | expiration=300, 137 | extra_payload=payload) 138 | try: 139 | session = await self.application_context.create_session_if_needed(session_id, self.request, token) 140 | except Exception as e: 141 | log.exception(e) 142 | return session 143 | 144 | 145 | class AutoloadJsConsumer(SessionConsumer): 146 | 147 | async def handle(self, body: bytes) -> None: 148 | session = await self._get_session() 149 | 150 | element_id = self.get_argument("bokeh-autoload-element", default=None) 151 | if not element_id: 152 | raise RuntimeError("No bokeh-autoload-element query parameter") 153 | 154 | app_path = self.get_argument("bokeh-app-path", default="/") 155 | absolute_url = self.get_argument("bokeh-absolute-url", default=None) 156 | 157 | server_url: str | None 158 | if absolute_url: 159 | server_url = '{uri.scheme}://{uri.netloc}/'.format(uri=urlparse(absolute_url)) 160 | else: 161 | server_url = None 162 | 163 | resources_param = self.get_argument("resources", "default") 164 | resources = self.resources(server_url) if resources_param != "none" else None 165 | 166 | root_url = urljoin(absolute_url, self._prefix) if absolute_url else self._prefix 167 | try: 168 | bundle = bundle_for_objs_and_resources(None, resources, root_url=root_url) 169 | except TypeError: 170 | bundle = bundle_for_objs_and_resources(None, resources) 171 | 172 | render_items = [RenderItem(token=session.token, elementid=element_id, use_for_title=False)] 173 | bundle.add(Script(script_for_render_items({}, render_items, app_path=app_path, absolute_url=absolute_url))) 174 | 175 | js = AUTOLOAD_JS.render(bundle=bundle, elementid=element_id) 176 | headers = [ 177 | (b"Access-Control-Allow-Headers", b"*"), 178 | (b"Access-Control-Allow-Methods", b"PUT, GET, OPTIONS"), 179 | (b"Access-Control-Allow-Origin", b"*"), 180 | (b"Content-Type", b"application/javascript") 181 | ] 182 | await self.send_response(200, js.encode(), headers=headers) 183 | 184 | 185 | class DocConsumer(SessionConsumer): 186 | 187 | async def handle(self, body: bytes) -> None: 188 | session = await self._get_session() 189 | page = server_html_page_for_session( 190 | session, 191 | resources=self.resources(), 192 | title=session.document.title, 193 | template=session.document.template, 194 | template_variables=session.document.template_variables 195 | ) 196 | await self.send_response(200, page.encode(), headers=[(b"Content-Type", b"text/html")]) 197 | 198 | 199 | class WSConsumer(AsyncWebsocketConsumer, ConsumerHelper): 200 | 201 | _clients: Set[ServerConnection] 202 | 203 | _application_context: ApplicationContext | None 204 | 205 | def __init__(self, *args: Any, **kwargs: Any) -> None: 206 | super().__init__(*args, **kwargs) 207 | self._application_context = kwargs.get('app_context') 208 | self._clients = set() 209 | self.lock = locks.Lock() 210 | 211 | @property 212 | def application_context(self) -> ApplicationContext: 213 | # backward compatiblity 214 | if self._application_context is None: 215 | self._application_context = self.scope["url_route"]["kwargs"]["app_context"] 216 | 217 | if self._application_context.io_loop is None: 218 | raise RuntimeError("io_loop should already been set") 219 | return self._application_context 220 | 221 | async def connect(self): 222 | log.info('WebSocket connection opened') 223 | 224 | subprotocols = self.scope["subprotocols"] 225 | if len(subprotocols) != 2 or subprotocols[0] != 'bokeh': 226 | self.close() 227 | raise RuntimeError("Subprotocol header is not 'bokeh'") 228 | 229 | token = subprotocols[1] 230 | if token is None: 231 | self.close() 232 | raise RuntimeError("No token received in subprotocol header") 233 | 234 | now = calendar.timegm(dt.datetime.utcnow().utctimetuple()) 235 | payload = get_token_payload(token) 236 | if 'session_expiry' not in payload: 237 | self.close() 238 | raise RuntimeError("Session expiry has not been provided") 239 | elif now >= payload['session_expiry']: 240 | self.close() 241 | raise RuntimeError("Token is expired.") 242 | elif not check_token_signature(token, 243 | signed=False, 244 | secret_key=None): 245 | session_id = get_session_id(token) 246 | log.error("Token for session %r had invalid signature", session_id) 247 | raise RuntimeError("Invalid token signature") 248 | 249 | def on_fully_opened(future): 250 | e = future.exception() 251 | if e is not None: 252 | # this isn't really an error (unless we have a 253 | # bug), it just means a client disconnected 254 | # immediately, most likely. 255 | log.debug("Failed to fully open connlocksection %r", e) 256 | 257 | future = self._async_open(token) 258 | 259 | # rewrite above line using asyncio 260 | # this task is scheduled to run soon once context is back to event loop 261 | task = asyncio.ensure_future(future) 262 | task.add_done_callback(on_fully_opened) 263 | await self.accept("bokeh") 264 | 265 | async def disconnect(self, close_code): 266 | self.connection.session.destroy() 267 | 268 | async def receive(self, text_data) -> None: 269 | fragment = text_data 270 | 271 | message = await self.receiver.consume(fragment) 272 | if message: 273 | work = await self.handler.handle(message, self.connection) 274 | if work: 275 | await self._send_bokeh_message(work) 276 | 277 | async def _async_open(self, token: str) -> None: 278 | try: 279 | session_id = get_session_id(token) 280 | await self.application_context.create_session_if_needed(session_id, self.request, token) 281 | session = self.application_context.get_session(session_id) 282 | 283 | protocol = Protocol() 284 | self.receiver = Receiver(protocol) 285 | log.debug("Receiver created for %r", protocol) 286 | 287 | self.handler = ProtocolHandler() 288 | log.debug("ProtocolHandler created for %r", protocol) 289 | 290 | self.connection = self._new_connection(protocol, self, self.application_context, session) 291 | log.info("ServerConnection created") 292 | 293 | except Exception as e: 294 | log.error("Could not create new server session, reason: %s", e) 295 | self.close() 296 | raise e 297 | 298 | msg = self.connection.protocol.create('ACK') 299 | await self._send_bokeh_message(msg) 300 | 301 | async def _send_bokeh_message(self, message: Message) -> int: 302 | sent = 0 303 | try: 304 | async with self.lock: 305 | await self.send(text_data=message.header_json) 306 | sent += len(message.header_json) 307 | 308 | await self.send(text_data=message.metadata_json) 309 | sent += len(message.metadata_json) 310 | 311 | await self.send(text_data=message.content_json) 312 | sent += len(message.content_json) 313 | 314 | for buffer in message._buffers: 315 | if isinstance(buffer, tuple): 316 | header, payload = buffer 317 | else: 318 | # buffer is bokeh.core.serialization.Buffer (Bokeh 3) 319 | header = {'id': buffer.id} 320 | payload = buffer.data.tobytes() 321 | 322 | await self.send(text_data=json.dumps(header)) 323 | await self.send(bytes_data=payload) 324 | sent += len(header) + len(payload) 325 | 326 | except Exception as e: # Tornado 4.x may raise StreamClosedError 327 | # on_close() is / will be called anyway 328 | log.exception(e) 329 | log.warning("Failed sending message as connection was closed") 330 | return sent 331 | 332 | async def send_message(self, message: Message) -> int: 333 | return await self._send_bokeh_message(message) 334 | 335 | def _new_connection(self, 336 | protocol: Protocol, 337 | socket: AsyncConsumer, 338 | application_context: ApplicationContext, 339 | session: ServerSession) -> ServerConnection: 340 | connection = ServerConnection(protocol, socket, application_context, session) 341 | self._clients.add(connection) 342 | return connection 343 | 344 | # ----------------------------------------------------------------------------- 345 | # Dev API 346 | # ----------------------------------------------------------------------------- 347 | 348 | # ----------------------------------------------------------------------------- 349 | # Private API 350 | # ----------------------------------------------------------------------------- 351 | 352 | 353 | class AttrDict(dict): 354 | """ Provide a dict subclass that supports access by named attributes. 355 | 356 | """ 357 | 358 | def __getattr__(self, key): 359 | return self[key] 360 | 361 | # ----------------------------------------------------------------------------- 362 | # Code 363 | # ----------------------------------------------------------------------------- 364 | -------------------------------------------------------------------------------- /bokeh_django/routing.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (c) 2012 - 2022, 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 | # Boilerplate 10 | # ----------------------------------------------------------------------------- 11 | from __future__ import annotations 12 | 13 | import inspect 14 | import logging # isort:skip 15 | log = logging.getLogger(__name__) 16 | 17 | # ----------------------------------------------------------------------------- 18 | # Imports 19 | # ----------------------------------------------------------------------------- 20 | 21 | # Standard library imports 22 | from pathlib import Path 23 | from typing import Callable, List, Union, TYPE_CHECKING 24 | import weakref 25 | 26 | # External imports 27 | from django.core.asgi import get_asgi_application 28 | from django.urls import re_path 29 | from django.urls.resolvers import URLPattern 30 | from channels.db import database_sync_to_async 31 | from tornado import gen 32 | 33 | # Bokeh imports 34 | from bokeh.application import Application 35 | from bokeh.settings import settings as bokeh_settings 36 | from bokeh.application.handlers.document_lifecycle import DocumentLifecycleHandler 37 | from bokeh.application.handlers.function import FunctionHandler, handle_exception 38 | from bokeh.command.util import build_single_handler_application, build_single_handler_applications 39 | from bokeh.server.contexts import ( 40 | ApplicationContext, 41 | BokehSessionContext, 42 | _RequestProxy, 43 | ServerSession, 44 | ProtocolError, 45 | ) 46 | from bokeh.document import Document 47 | from bokeh.util.token import get_token_payload 48 | 49 | # Local imports 50 | from .consumers import AutoloadJsConsumer, DocConsumer, WSConsumer 51 | 52 | if TYPE_CHECKING: 53 | from bokeh.server.contexts import ( 54 | ID, 55 | HTTPServerRequest, 56 | ) 57 | 58 | # ----------------------------------------------------------------------------- 59 | # Globals and constants 60 | # ----------------------------------------------------------------------------- 61 | 62 | __all__ = ( 63 | 'RoutingConfiguration', 64 | ) 65 | 66 | 67 | class AsyncApplication(Application): 68 | async def create_document(self) -> Document: 69 | """ Creates and initializes a document using the Application's handlers. 70 | 71 | """ 72 | doc = Document() 73 | await self.initialize_document(doc) 74 | return doc 75 | 76 | async def initialize_document(self, doc: Document) -> None: 77 | """ Fills in a new document using the Application's handlers. 78 | 79 | """ 80 | for h in self._handlers: 81 | result = h.modify_document(doc) 82 | if inspect.iscoroutine(result): 83 | await result 84 | if h.failed: 85 | log.error("Error running application handler %r: %s %s ", h, h.error, h.error_detail) 86 | 87 | if bokeh_settings.perform_document_validation(): 88 | doc.validate() 89 | 90 | 91 | class AsyncFunctionHandler(FunctionHandler): 92 | async def modify_document(self, doc: Document) -> None: 93 | """ Execute the configured ``func`` to modify the document. 94 | 95 | After this method is first executed, ``safe_to_fork`` will return 96 | ``False``. 97 | 98 | """ 99 | try: 100 | await self._func(doc) 101 | except Exception as e: 102 | if self._trap_exceptions: 103 | handle_exception(self, e) 104 | else: 105 | raise 106 | finally: 107 | self._safe_to_fork = False 108 | 109 | 110 | ApplicationLike = Union[Application, Callable, Path, AsyncApplication] 111 | 112 | # ----------------------------------------------------------------------------- 113 | # General API 114 | # ----------------------------------------------------------------------------- 115 | 116 | 117 | class DjangoApplicationContext(ApplicationContext): 118 | async def create_session_if_needed(self, session_id: ID, request: HTTPServerRequest | None = None, 119 | token: str | None = None) -> ServerSession: 120 | # this is because empty session_ids would be "falsey" and 121 | # potentially open up a way for clients to confuse us 122 | if len(session_id) == 0: 123 | raise ProtocolError("Session ID must not be empty") 124 | 125 | if session_id not in self._sessions and \ 126 | session_id not in self._pending_sessions: 127 | future = self._pending_sessions[session_id] = gen.Future() 128 | 129 | doc = Document() 130 | 131 | session_context = BokehSessionContext(session_id, 132 | self.server_context, 133 | doc, 134 | logout_url=self._logout_url) 135 | if request is not None: 136 | payload = get_token_payload(token) if token else {} 137 | if ('cookies' in payload and 'headers' in payload 138 | and not 'Cookie' in payload['headers']): 139 | # Restore Cookie header from cookies dictionary 140 | payload['headers']['Cookie'] = '; '.join([ 141 | f'{k}={v}' for k, v in payload['cookies'].items() 142 | ]) 143 | # using private attr so users only have access to a read-only property 144 | session_context._request = _RequestProxy(request, 145 | cookies=payload.get('cookies'), 146 | headers=payload.get('headers')) 147 | session_context._token = token 148 | 149 | # expose the session context to the document 150 | # use the _attribute to set the public property .session_context 151 | doc._session_context = weakref.ref(session_context) 152 | 153 | try: 154 | await self._application.on_session_created(session_context) 155 | except Exception as e: 156 | log.error("Failed to run session creation hooks %r", e, exc_info=True) 157 | 158 | if isinstance(self._application, AsyncApplication): 159 | await self._application.initialize_document(doc) 160 | else: 161 | # This needs to be wrapped in the database_sync_to_async wrapper just in case the handler function 162 | # accesses Django ORM. 163 | await database_sync_to_async(self._application.initialize_document)(doc) 164 | 165 | session = ServerSession(session_id, doc, io_loop=self._loop, token=token) 166 | del self._pending_sessions[session_id] 167 | self._sessions[session_id] = session 168 | session_context._set_session(session) 169 | self._session_contexts[session_id] = session_context 170 | 171 | # notify anyone waiting on the pending session 172 | future.set_result(session) 173 | 174 | if session_id in self._pending_sessions: 175 | # another create_session_if_needed is working on 176 | # creating this session 177 | session = await self._pending_sessions[session_id] 178 | else: 179 | session = self._sessions[session_id] 180 | 181 | return session 182 | 183 | 184 | class Routing: 185 | url: str 186 | app: Application 187 | app_context: ApplicationContext 188 | document: bool 189 | autoload: bool 190 | 191 | def __init__(self, url: str, app: ApplicationLike, *, document: bool = False, autoload: bool = False) -> None: 192 | self.url = url 193 | self.app = self._fixup(self._normalize(app)) 194 | self.app_context = DjangoApplicationContext(self.app, url=self.url) 195 | self.document = document 196 | self.autoload = autoload 197 | 198 | def __repr__(self): 199 | doc = 'document' if self.document else '' 200 | return f'<{self.__module__}.{self.__class__.__name__} url="{self.url}" {doc}>' 201 | 202 | def _normalize(self, obj: ApplicationLike) -> Application: 203 | if callable(obj): 204 | if inspect.iscoroutinefunction(obj): 205 | return AsyncApplication(AsyncFunctionHandler(obj, trap_exceptions=True)) 206 | return Application(FunctionHandler(obj, trap_exceptions=True)) 207 | elif isinstance(obj, Path): 208 | return build_single_handler_application(obj) 209 | else: 210 | return obj 211 | 212 | def _fixup(self, app: Application) -> Application: 213 | if not any(isinstance(handler, DocumentLifecycleHandler) for handler in app.handlers): 214 | app.add(DocumentLifecycleHandler()) 215 | return app 216 | 217 | 218 | def document(url: str, app: ApplicationLike) -> Routing: 219 | return Routing(url, app, document=True) 220 | 221 | 222 | def autoload(url: str, app: ApplicationLike) -> Routing: 223 | return Routing(url, app, autoload=True) 224 | 225 | 226 | def directory(*apps_paths: Path) -> List[Routing]: 227 | paths: List[Path] = [] 228 | 229 | for apps_path in apps_paths: 230 | if apps_path.exists(): 231 | paths += [entry for entry in apps_path.glob("*") if is_bokeh_app(entry)] 232 | else: 233 | log.warning(f"bokeh applications directory '{apps_path}' doesn't exist") 234 | 235 | paths = [str(p) for p in paths] 236 | return [document(url, app) for url, app in build_single_handler_applications(paths).items()] 237 | 238 | 239 | class RoutingConfiguration: 240 | _http_urlpatterns: List[str] = [] 241 | _websocket_urlpatterns: List[str] = [] 242 | 243 | def __init__(self, routings: List[Routing]) -> None: 244 | for routing in routings: 245 | self._add_new_routing(routing) 246 | 247 | def get_http_urlpatterns(self) -> List[URLPattern]: 248 | return self._http_urlpatterns + [re_path(r"", get_asgi_application())] 249 | 250 | def get_websocket_urlpatterns(self) -> List[URLPattern]: 251 | return self._websocket_urlpatterns 252 | 253 | def _add_new_routing(self, routing: Routing) -> None: 254 | kwargs = dict(app_context=routing.app_context) 255 | 256 | def urlpattern(suffix=""): 257 | return f"^{routing.url.strip('^$/')}{suffix}$" 258 | 259 | if routing.document: 260 | self._http_urlpatterns.append(re_path(urlpattern(), DocConsumer.as_asgi(**kwargs))) 261 | if routing.autoload: 262 | self._http_urlpatterns.append(re_path(urlpattern("/autoload.js"), AutoloadJsConsumer.as_asgi(**kwargs))) 263 | 264 | self._websocket_urlpatterns.append(re_path(urlpattern("/ws"), WSConsumer.as_asgi(**kwargs))) 265 | 266 | # ----------------------------------------------------------------------------- 267 | # Dev API 268 | # ----------------------------------------------------------------------------- 269 | 270 | # ----------------------------------------------------------------------------- 271 | # Private API 272 | # ----------------------------------------------------------------------------- 273 | 274 | 275 | def is_bokeh_app(entry: Path) -> bool: 276 | return (entry.is_dir() or entry.name.endswith(('.py', '.ipynb'))) and not entry.name.startswith((".", "_")) 277 | 278 | # ----------------------------------------------------------------------------- 279 | # Code 280 | # ----------------------------------------------------------------------------- 281 | -------------------------------------------------------------------------------- /bokeh_django/static.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (c) 2012 - 2022, 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 | # Boilerplate 10 | # ----------------------------------------------------------------------------- 11 | from __future__ import annotations 12 | 13 | import logging # isort:skip 14 | log = logging.getLogger(__name__) 15 | 16 | # ----------------------------------------------------------------------------- 17 | # Imports 18 | # ----------------------------------------------------------------------------- 19 | 20 | # Standard library imports 21 | import os 22 | import re 23 | 24 | # External imports 25 | from django.http import Http404 26 | from django.urls import re_path 27 | from django.views import static 28 | from django.contrib.staticfiles.finders import BaseFinder 29 | from django.utils._os import safe_join 30 | 31 | # Bokeh imports 32 | from bokeh.embed.bundle import extension_dirs 33 | 34 | # ----------------------------------------------------------------------------- 35 | # General API 36 | # ----------------------------------------------------------------------------- 37 | 38 | class BokehExtensionFinder(BaseFinder): 39 | """ 40 | A custom staticfiles finder class to find bokeh resources. 41 | 42 | In Django settings: 43 | When using `django.contrib.staticfiles' in `INSTALLED_APPS` then add 44 | `bokeh_django.static.BokehExtensionFinder` to `STATICFILES_FINDERS` 45 | """ 46 | _root = extension_dirs 47 | _prefix = 'extensions/' 48 | 49 | def find(self, path, all=False): 50 | """ 51 | Given a relative file path, find an absolute file path. 52 | 53 | If the ``all`` parameter is False (default) return only the first found 54 | file path; if True, return a list of all found files paths. 55 | """ 56 | matches = [] 57 | location = self.find_location(path, self._prefix) 58 | if location: 59 | if not all: 60 | return location 61 | else: 62 | matches.append(location) 63 | 64 | return matches 65 | 66 | @classmethod 67 | def find_location(cls, path, prefix=None, as_components=False): 68 | """ 69 | Find the absolute path to a resouces given a relative path. 70 | 71 | Args: 72 | path (str): relative path to resource 73 | prefix (str): if passed then verifies that path starts with `prefix` else returns `None` 74 | as_components (bool): If `True` return tuple of (artifacts_dir, artifact_path) rather than absolute path. 75 | Used when needing seperate components for `static.serve` function to manually serve resources. 76 | """ 77 | prefix = prefix or '' 78 | if not prefix or path.startswith(prefix): 79 | path = path[len(prefix):] 80 | try: 81 | name, artifact_path = path.split(os.sep, 1) 82 | except ValueError: 83 | pass 84 | else: 85 | artifacts_dir = cls._root.get(name, None) 86 | if artifacts_dir is not None: 87 | path = safe_join(artifacts_dir, artifact_path) 88 | if os.path.exists(path): 89 | if as_components: 90 | return artifacts_dir, artifact_path 91 | return path 92 | 93 | 94 | def serve_extensions(request, path): 95 | components = BokehExtensionFinder.find_location(path, as_components=True) 96 | if components is not None: 97 | artifacts_dir, artifact_path = components 98 | return static.serve(request, artifact_path, document_root=artifacts_dir) 99 | else: 100 | raise Http404 101 | 102 | 103 | def static_extensions(prefix: str = "/static/extensions/"): 104 | return [re_path(r'^%s(?P10 | In these examples there are two different apps being used. One is a Bokeh app (Sea Surface), and the other is a Panel app (Shapes). The following examples show how these two apps are loaded in different ways. 11 |
12 | 13 |The examples in this section are loaded with bokeh_django.directory
15 |The examples in this section are loaded with bokeh_django.document
21 |The examples in this section are loaded with bokeh_django.autoload
30 |