-
Notifications
You must be signed in to change notification settings - Fork 2
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
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
694e38f
support using a .venv virtual environment
5367a7c
add panel app for testing purposes
ea1d150
added test apps
99b12e2
fix linting errors
6b715ef
added support for glob like *.py
105512e
added basic tests
7832287
refactor and clean up
523cc20
refactor: prepare for bokeh and panel server
9574526
added support for Panel server
efa2843
refactor
563655c
changed print to logger
91b54eb
added logging
5775a5d
change flag to --server
c62ca41
remove ready-check from index page
bc2aab1
small improvements
9400bad
add bokeh and panel as requiremnts
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,3 +6,5 @@ dist/ | |
*.egg-info | ||
.vscode | ||
|
||
.venv | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
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)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,3 @@ | ||
Click>=7.0 | ||
bokeh | ||
panel |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.