Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for serving multiple apps with index with Bokeh and/ or Panel Server #1

Merged
merged 16 commits into from
Jun 28, 2021
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ dist/
*.egg-info
.vscode

.venv

53 changes: 43 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
# bokeh-root-cmd

Command line wrapper to run a named Bokeh script/folder as root URL.
Command line wrapper to run one or more named Bokeh/Panel scripts/folders as root URL.

This project is used in [ContainDS Dashboards](https://github.com/ideonate/cdsdashboards), which is a user-friendly
way to launch Jupyter notebooks as shareable dashboards inside JupyterHub. Also works with Streamlit and other
visualization frameworks.
This project is used in [ContainDS Dashboards](https://github.com/ideonate/cdsdashboards), which is a user-friendly
way to launch Jupyter notebooks as shareable dashboards inside JupyterHub. Also works with Bokeh, Dash, Panel, Streamlit and other visualization frameworks.

## Install and Run

Install using pip.

```
```bash
pip install bokeh-root-cmd
```

The file to start is specified on the command line, for example:

```
```bash
bokeh-root-cmd ~/Dev/mybokehscript.py
```

By default the server will listen on port 8888

To specify a different port, use the --port flag.
By default the server will listen on port 8888. To specify a different **port**, use the --port flag.

```
```bash
bokeh-root-cmd --port=8888 ~/Dev/mybokehscript.py
```

To use the Panel server use the --server flag.

```bash
bokeh-root-cmd --server=panel ~/Dev/mybokehscript.py
```

To run directly in python: `python -m bokeh_root_cmd.main <rest of command line>`

## Other command line args
Expand All @@ -37,3 +40,33 @@ To run directly in python: `python -m bokeh_root_cmd.main <rest of command line>
--debug

--ip

## Tests

In order to be able to test manually you would need to `pip install panel pytest`. This will also install bokeh.

### Automated Tests

```bash
pytest tests.py
```

### Single File on Bokeh Server

Run `bokeh-root-cmd test_apps/test_bokeh_hello.py` and verify the app is running at `http://localhost:8888`.

### Single File on Panel Server

Run `bokeh-root-cmd --server=panel test_apps/test_panel_hello.py` and verify the app is running at `http://localhost:8888`.

### Multiple Files on Bokeh Server

Run `bokeh-root-cmd test_apps/*.py` and verify the app index is running at `http://localhost:8888` and test apps at `http://localhost:8888/test_bokeh_hello` and `http://localhost:8888/test_panel_hello`.

### Multiple Files on Panel Server

Run `bokeh-root-cmd --server=panel test_apps/*.py` and verify the app index is running at `http://localhost:8888` and test apps at `http://localhost:8888/test_bokeh_hello` and `http://localhost:8888/test_panel_hello`. Note that no `ready-check' app is included in the index list.

You can also specify them individually

Run `bokeh-root-cmd --server=panel test_apps/test_bokeh_hello.py test_apps/test_panel_hello.py` and verify the app index is running at `http://localhost:8888` and test apps at `http://localhost:8888/test_bokeh_hello` and `http://localhost:8888/test_panel_hello`. Note that no `ready-check' app is included in the index list.
165 changes: 130 additions & 35 deletions bokeh_root_cmd/main.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,156 @@
import os
"""Command line wrapper to serve one or more named Bokeh scripts or folders."""
import logging
import os
import pathlib
from typing import Any, Dict, Tuple

from bokeh.command.util import build_single_handler_application
from bokeh.server.server import Server
import bokeh.server.views
import click
from bokeh.application.application import Application
from bokeh.command.util import build_single_handler_application
from bokeh.server.server import Server as _BkServer
from bokeh.server.views.root_handler import RootHandler
from panel.io.server import INDEX_HTML as _PANEL_INDEX_HTML
from panel.io.server import Server as _PnServer
import logging

from .readycheck import create_ready_app

class BokehException(Exception):
pass
FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'

def make_app(command, debug=False):
logging.basicConfig(format=FORMAT)
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)

# Command can be absolute, or could be relative to cwd
app_py_path = os.path.join(os.getcwd(), command)
logger = logging.getLogger('bokeh_root_cmd')

print("Fetching Bokeh script or folder {}".format(app_py_path))
_BOKEH_INDEX_HTML = str(pathlib.Path(bokeh.server.views.__file__).parent / "app_index.html")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please could these HTML extractions happen inside the Bokeh/PanelServer classes?

So maybe there is a get_index_html() static method that performs this operation (and can 'cache' it in a static property too).

Of course this would save performing the operation when it's not needed. Partly, I suggest this for speed, but more importantly in case something goes wrong - especially if a bokeh user doesn't quite have the right panel installed (or has none at all), so the import breaks (when it wasn't actually needed anyway!)

I also have a related note about imports and requirements which I'll add below.

class BokehServer:
index_html = _BOKEH_INDEX_HTML
server_class = _BkServer

dirname = os.path.dirname(app_py_path)
@staticmethod
def _make_app(command: str, url: str = "/", debug: bool = False) -> Application:
cwd_original = os.getcwd()

if os.path.isdir(dirname):
print("CWD to {}".format(dirname))
os.chdir(dirname)
# Command can be absolute, or could be relative to cwd
app_py_path = os.path.join(os.getcwd(), command)

app = build_single_handler_application(app_py_path, ['/'])
dirname = os.path.dirname(app_py_path)

return app
if app_py_path==dirname:
logger.debug("Fetching folder {}".format(app_py_path))
else:
logger.debug("Fetching script {}".format(app_py_path))

if os.path.isdir(dirname):
logger.debug("Changing working dir to {}".format(dirname))
os.chdir(dirname)

@click.command()
@click.option('--port', default=8888, type=click.INT, help='port for the proxy server to listen on')
@click.option('--ip', default=None, help='Address to listen on')
@click.option('--allow-websocket-origin', default=None, multiple=True, help='Web socket origins allowed')
@click.option('--debug/--no-debug', default=False, help='To display debug level logs')
@click.argument('command', nargs=1, required=True)
def run(port, ip, debug, allow_websocket_origin, command):
app = build_single_handler_application(app_py_path, [url])

if debug:
print('Setting debug')
os.chdir(cwd_original)
logger.debug("Changing working dir back to {}".format(cwd_original))

app = make_app(command, debug)
return app

server_kwargs = {'port': port, 'ip': ip}
@classmethod
def _get_applications(cls, command: Tuple[str], debug=False) -> Dict[str, Application]:
apps = {}
if len(command) == 1:
apps = {"/": cls._make_app(command[0], debug)}
elif len(command) > 1:
for cmd in command:
application = cls._make_app(cmd, debug)
route = application.handlers[0].url_path()
apps[route] = application
return apps

if allow_websocket_origin:
server_kwargs['allow_websocket_origin'] = list(allow_websocket_origin)
@classmethod
def _get_server_kwargs(cls, port, ip, allow_websocket_origin, command) -> Dict[str, Any]:
server_kwargs = {"port": port, "ip": ip}
if allow_websocket_origin:
server_kwargs["allow_websocket_origin"] = list(allow_websocket_origin)
if len(command) > 1:
server_kwargs.update(
{"use_index": True, "redirect_root": True, "index": cls.index_html}
)
return server_kwargs

server = Server({'/': app, '/ready-check': create_ready_app()}, **server_kwargs)
def run(self, port, ip, debug, allow_websocket_origin, command):
logger.info("Starting %s", type(self).__name__)
if debug:
root_logger.setLevel(logging.DEBUG)

server.run_until_shutdown()
logger.debug("ip = %s", ip)
logger.debug("port = %s", port)
logger.debug("debug = %s", debug)
logger.debug("allow_websocket_origin = %s", allow_websocket_origin)
logger.debug("command = %s", command)


if __name__ == '__main__':
applications = self._get_applications(command, debug)
applications["/ready-check"] = create_ready_app()
logger.debug("applications = %s", list(applications.keys()))

try:
server_kwargs = self._get_server_kwargs(port, ip, allow_websocket_origin, command)
if debug:
server_kwargs["log_level"]="debug"
server_kwargs["log_format"]=FORMAT
logger.debug("server_kwargs = %s", server_kwargs)

run()
server = self.server_class(applications, **server_kwargs)

server.run_until_shutdown()


class PanelServer(BokehServer):
index_html = _PANEL_INDEX_HTML
server_class = _PnServer


@click.command()
@click.option("--port", default=8888, type=click.INT, help="port for the proxy server to listen on")
@click.option("--ip", default=None, help="Address to listen on")
@click.option(
"--allow-websocket-origin", default=None, multiple=True, help="Web socket origins allowed"
)
@click.option("--debug/--no-debug", default=False, help="To display debug level logs")
@click.option(
"--server", default="bokeh", type=click.STRING, help="The server to use. One of bokeh or panel. Default is bokeh."
)
@click.argument("command", nargs=-1, required=True)
def run(port, ip, debug, allow_websocket_origin, server, command):
if server=="panel":
server = PanelServer()
else:
server = BokehServer()

server.run(
port=port,
ip=ip,
debug=debug,
allow_websocket_origin=allow_websocket_origin,
command=command,
)


# Bokeh/ Panel can serve an index page with a list of applications at "/"
# The below is a workaround to avoid including the 'ready-check' application
def _root_handler_initialize_without_ready_check(self, *args, **kw):
kw["applications"]=kw["applications"].copy()
if "/ready-check" in kw["applications"]:
kw["applications"].pop("/ready-check")

self.applications = kw["applications"]
self.prefix = kw["prefix"]
self.index = kw["index"]
self.use_redirect = kw["use_redirect"]

RootHandler.initialize = _root_handler_initialize_without_ready_check


if __name__ == "__main__":
try:
run()
except SystemExit as se:
print('Caught SystemExit {}'.format(se))
logger.error("Caught SystemExit {}".format(se))
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
Click>=7.0
bokeh
panel
23 changes: 23 additions & 0 deletions test_apps/test_bokeh_hello.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure
from bokeh.io import curdoc

def modify_doc(doc):
"""Add a plotted function to the document.

Arguments:
doc: A bokeh document to which elements can be added.
"""
x_values = range(10)
y_values = [x ** 2 for x in x_values]
data_source = ColumnDataSource(data=dict(x=x_values, y=y_values))
plot = figure(title="f(x) = x^2",
tools="crosshair,pan,reset,save,wheel_zoom",)
plot.line('x', 'y', source=data_source, line_width=3, line_alpha=0.6)
doc.add_root(plot)
doc.title = "Hello World"

def main():
modify_doc(curdoc())

main()
19 changes: 19 additions & 0 deletions test_apps/test_panel_hello.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""We can use this to test the bokeh_root_cmd"""
import panel as pn
pn.extension(sizing_mode="stretch_width")


def test_panel_app():
"""Returns a Panel test app that has been marked `.servable()`

Returns:
pn.Column: A Column based Panel app
"""
slider = pn.widgets.FloatSlider(name="Slider")
return pn.template.FastListTemplate(
title="Panel Test App", sidebar=[slider], main=[slider.param.value]
).servable()


if __name__.startswith("bokeh"):
test_panel_app()
63 changes: 63 additions & 0 deletions tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Test of the main functionality"""
import pytest
from bokeh.application.application import Application

from bokeh_root_cmd.main import BokehServer, PanelServer


@pytest.fixture(params=[BokehServer, PanelServer])
def server_class(request):
return request.param

def test_get_server_kwargs_single_app(server_class):
"""Test Case: Starting one app"""
actual = server_class._get_server_kwargs(
port=7888,
ip="0.0.0.0",
allow_websocket_origin=("https://awesome-panel.org",),
command=("test_apps/test_bokeh_hello.py",),
)

assert actual == {
"port": 7888,
"ip": "0.0.0.0",
"allow_websocket_origin": ["https://awesome-panel.org"],
}


def test_get_server_kwargs_multiple_apps(server_class):
"""Test Case: Starting multiple apps"""
actual = server_class._get_server_kwargs(
port=7888,
ip="0.0.0.0",
allow_websocket_origin=("https://awesome-panel.org",),
command=("test_apps/test_bokeh_hello.py", "test_apps/test_panel_hello.py"),
)

assert actual == {
"port": 7888,
"ip": "0.0.0.0",
"allow_websocket_origin": ["https://awesome-panel.org"],
"use_index": True,
"redirect_root": True,
"index": server_class.index_html,
}


def test_get_applications_single_app(server_class):
"""Test Case: Starting one app"""
actual = server_class._get_applications(command=("test_apps/test_bokeh_hello.py",), debug=False)

assert len(actual) == 1
assert isinstance(actual["/"], Application)


def test_get_applications_multiple_apps(server_class):
"""Test Case: Starting multiple apps"""
actual = server_class._get_applications(
command=("test_apps/test_bokeh_hello.py", "test_apps/test_panel_hello.py"), debug=False
)

assert len(actual) == 2
assert isinstance(actual["/test_bokeh_hello"], Application)
assert isinstance(actual["/test_panel_hello"], Application)