diff --git a/.gitignore b/.gitignore index 8caeec5..864ce93 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist/ *.egg-info .vscode +.venv + diff --git a/README.md b/README.md index 9a51313..a0407ff 100644 --- a/README.md +++ b/README.md @@ -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 ` ## Other command line args @@ -37,3 +40,33 @@ To run directly in python: `python -m bokeh_root_cmd.main --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. diff --git a/bokeh_root_cmd/main.py b/bokeh_root_cmd/main.py index 201dd1b..35bb127 100644 --- a/bokeh_root_cmd/main.py +++ b/bokeh_root_cmd/main.py @@ -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") +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)) diff --git a/requirements.txt b/requirements.txt index 484bbbe..cd64de4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ Click>=7.0 +bokeh +panel diff --git a/test_apps/test_bokeh_hello.py b/test_apps/test_bokeh_hello.py new file mode 100644 index 0000000..452b07f --- /dev/null +++ b/test_apps/test_bokeh_hello.py @@ -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() \ No newline at end of file diff --git a/test_apps/test_panel_hello.py b/test_apps/test_panel_hello.py new file mode 100644 index 0000000..3c29773 --- /dev/null +++ b/test_apps/test_panel_hello.py @@ -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() diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..237d937 --- /dev/null +++ b/tests.py @@ -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)