diff --git a/fps_plugins/voila/fps_voila/config.py b/fps_plugins/voila/fps_voila/config.py new file mode 100644 index 000000000..384cd1e96 --- /dev/null +++ b/fps_plugins/voila/fps_voila/config.py @@ -0,0 +1,14 @@ +from fps.config import PluginModel, get_config # type: ignore +from fps.hooks import register_config, register_plugin_name # type: ignore + + +class VoilaConfig(PluginModel): + notebook_path: str = "" + + +def get_voila_config(): + return get_config(VoilaConfig) + + +c = register_config(VoilaConfig) +n = register_plugin_name("Voila") diff --git a/fps_plugins/voila/fps_voila/routes.py b/fps_plugins/voila/fps_voila/routes.py new file mode 100644 index 000000000..fb5910981 --- /dev/null +++ b/fps_plugins/voila/fps_voila/routes.py @@ -0,0 +1,103 @@ +import sys +import os +import uuid +from pathlib import Path +from typing import Optional + +from voila.handler import _VoilaHandler, _get + +from mimetypes import guess_type +from fastapi import APIRouter, Depends +from fastapi.responses import RedirectResponse, HTMLResponse, Response +from fastapi.staticfiles import StaticFiles +from fps.hooks import register_router # type: ignore +from fps_kernels.kernel_server.server import KernelServer, kernels # type: ignore +from kernel_driver import KernelDriver +from kernel_driver.driver import receive_message + +from .config import get_voila_config + + +class FPSVoilaHandler(_VoilaHandler): + is_fps = True + _arguments = {} + html = [] + + def redirect(self, url): + return RedirectResponse(url) + + def write(self, html): + self.html += [html] + + def flush(self): + pass + + def return_html(self): + return HTMLResponse("".join(self.html)) + + def get_argument(self, name, default): + if self._arguments[name] is None: + return default + return self._arguments[name] + + +def init_voila_handler( + notebook_path, + template_paths, + config, + voila_configuration, + contents_manager, + base_url, + kernel_manager, + kernel_spec_manager, + allow_remote_access, + autoreload, + voila_jinja2_env, + jinja2_env, + static_path, + server_root_dir, + config_manager, + static_paths, +): + global fps_voila_handler + fps_voila_handler = FPSVoilaHandler() + fps_voila_handler.initialize( + notebook_path=notebook_path, + template_paths=template_paths, + traitlet_config=config, + voila_configuration=voila_configuration, + ) + fps_voila_handler.contents_manager = contents_manager + fps_voila_handler.base_url = base_url + fps_voila_handler.kernel_manager = kernel_manager + fps_voila_handler.kernel_spec_manager = kernel_spec_manager + fps_voila_handler.allow_remote_access = allow_remote_access + fps_voila_handler.autoreload = autoreload + fps_voila_handler.voila_jinja2_env = voila_jinja2_env + fps_voila_handler.jinja2_env = jinja2_env + fps_voila_handler.static_path = static_path + fps_voila_handler.server_root_dir = server_root_dir + fps_voila_handler.config_manager = config_manager + fps_voila_handler.static_paths = static_paths + + +router = APIRouter() + +@router.get("/") +async def get_root(voila_template: Optional[str] = None, voila_theme: Optional[str] = None, voila_config=Depends(get_voila_config)): + fps_voila_handler._arguments["voila-template"] = voila_template + fps_voila_handler._arguments["voila-theme"] = voila_theme + path = "" #voila_config.notebook_path or "/" + return await _get(fps_voila_handler, path) + +@router.get("/voila/templates/lab/static/{path:path}") +async def get_file(path): + for i, static_path in enumerate(fps_voila_handler.static_paths): + file_path = Path(static_path) / path + if os.path.exists(file_path): + with open(file_path) as f: + content = f.read() + content_type, _ = guess_type(file_path) + return Response(content, media_type=content_type) + +r = register_router(router) diff --git a/fps_plugins/voila/setup.py b/fps_plugins/voila/setup.py new file mode 100644 index 000000000..e2e2732fe --- /dev/null +++ b/fps_plugins/voila/setup.py @@ -0,0 +1,12 @@ +from setuptools import setup, find_packages # type: ignore + +setup( + name="fps_voila", + version="0.0.1", + packages=find_packages(), + install_requires=["fps", "fps-kernels", "aiofiles"], + entry_points={ + "fps_router": ["fps-voila = fps_voila.routes"], + "fps_config": ["fps-voila = fps_voila.config"], + }, +) diff --git a/setup.cfg b/setup.cfg index 9e8abfa62..442a2cad9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,6 +51,9 @@ test = pytest pytest-tornasync +fps = + fps[uvicorn] + [options.entry_points] console_scripts = - voila = voila.app:main \ No newline at end of file + voila = voila.app:main diff --git a/voila/app.py b/voila/app.py index d6f3095fb..1a2aad07f 100644 --- a/voila/app.py +++ b/voila/app.py @@ -45,6 +45,9 @@ from jupyter_server.utils import url_path_join, run_sync from jupyter_server.services.config import ConfigManager +from fps_uvicorn.cli import app as fps_app +from fps_voila.routes import init_voila_handler + from jupyter_client.kernelspec import KernelSpecManager from jupyter_core.paths import jupyter_config_path, jupyter_path @@ -81,7 +84,8 @@ class Voila(Application): }, _("Set the log level to logging.DEBUG, and show exception tracebacks in output.") ), - 'no-browser': ({'Voila': {'open_browser': False}}, _('Don\'t open the notebook in a browser after startup.')) + 'no-browser': ({'Voila': {'open_browser': False}}, _('Don\'t open the notebook in a browser after startup.')), + 'fps': ({'Voila': {'fps': True}}, _('Use FPS instead of Jupyter Server.')), } description = Unicode( @@ -198,6 +202,11 @@ class Voila(Application): ip = Unicode('localhost', config=True, help=_("The IP address the notebook server will listen on.")) + fps = Bool(False, config=True, + help=_("""Whether to user FPS for the server, + instead of Jupyter Server. + """)) + open_browser = Bool(True, config=True, help=_("""Whether to open in a browser after starting. The specific browser used is platform dependent and @@ -437,98 +446,129 @@ def start(self): # default server_url to base_url self.server_url = self.server_url or self.base_url - self.app = tornado.web.Application( - base_url=self.base_url, - server_url=self.server_url or self.base_url, - kernel_manager=self.kernel_manager, - kernel_spec_manager=self.kernel_spec_manager, - allow_remote_access=True, - autoreload=self.autoreload, - voila_jinja2_env=env, - jinja2_env=env, - static_path='/', - server_root_dir='/', - contents_manager=self.contents_manager, - config_manager=self.config_manager - ) + if self.fps: + # pass options to FPS app + options = sys.argv[1:] + sys.argv = sys.argv[:1] + fps_options = [f"--fps.root_path={self.server_url}", f"--port={self.port}"] + for path in options: + if not path.startswith("--"): + break + else: + path = "/" + sys.argv += fps_options + [f"--Voila.notebook_path={path}", "--authenticator.mode=noauth"] + init_voila_handler( + self.notebook_path, + self.template_paths, + self.config, + self.voila_configuration, + self.contents_manager, + self.base_url, + self.kernel_manager,#MultiKernelManager(), + self.kernel_spec_manager, + True, + self.autoreload, + env, + env, + '/', + '/', + self.config_manager, + self.static_paths, + ) + fps_app() + else: + self.app = tornado.web.Application( + base_url=self.base_url, + server_url=self.server_url or self.base_url, + kernel_manager=self.kernel_manager, + kernel_spec_manager=self.kernel_spec_manager, + allow_remote_access=True, + autoreload=self.autoreload, + voila_jinja2_env=env, + jinja2_env=env, + static_path='/', + server_root_dir='/', + contents_manager=self.contents_manager, + config_manager=self.config_manager + ) + + self.app.settings.update(self.tornado_settings) - self.app.settings.update(self.tornado_settings) - - handlers = [] - - handlers.extend([ - (url_path_join(self.server_url, r'/api/kernels/%s' % _kernel_id_regex), KernelHandler), - (url_path_join(self.server_url, r'/api/kernels/%s/channels' % _kernel_id_regex), ZMQChannelsHandler), - ( - url_path_join(self.server_url, r'/voila/templates/(.*)'), - TemplateStaticFileHandler - ), - ( - url_path_join(self.server_url, r'/voila/static/(.*)'), - MultiStaticFileHandler, - { - 'paths': self.static_paths, - 'default_filename': 'index.html' - }, - ), - (url_path_join(self.server_url, r'/voila/api/shutdown/(.*)'), VoilaShutdownKernelHandler) - ]) - - # Serving notebook extensions - if self.voila_configuration.enable_nbextensions: + handlers = [] + + handlers.extend([ + (url_path_join(self.server_url, r'/api/kernels/%s' % _kernel_id_regex), KernelHandler), + (url_path_join(self.server_url, r'/api/kernels/%s/channels' % _kernel_id_regex), ZMQChannelsHandler), + ( + url_path_join(self.server_url, r'/voila/templates/(.*)'), + TemplateStaticFileHandler + ), + ( + url_path_join(self.server_url, r'/voila/static/(.*)'), + MultiStaticFileHandler, + { + 'paths': self.static_paths, + 'default_filename': 'index.html' + }, + ), + (url_path_join(self.server_url, r'/voila/api/shutdown/(.*)'), VoilaShutdownKernelHandler) + ]) + + # Serving notebook extensions + if self.voila_configuration.enable_nbextensions: + handlers.append( + ( + url_path_join(self.server_url, r'/voila/nbextensions/(.*)'), + FileFindHandler, + { + 'path': self.nbextensions_path, + 'no_cache_paths': ['/'], # don't cache anything in nbextensions + }, + ) + ) handlers.append( ( - url_path_join(self.server_url, r'/voila/nbextensions/(.*)'), - FileFindHandler, + url_path_join(self.server_url, r'/voila/files/(.*)'), + WhiteListFileHandler, { - 'path': self.nbextensions_path, - 'no_cache_paths': ['/'], # don't cache anything in nbextensions + 'whitelist': self.voila_configuration.file_whitelist, + 'blacklist': self.voila_configuration.file_blacklist, + 'path': self.root_dir, }, ) ) - handlers.append( - ( - url_path_join(self.server_url, r'/voila/files/(.*)'), - WhiteListFileHandler, - { - 'whitelist': self.voila_configuration.file_whitelist, - 'blacklist': self.voila_configuration.file_blacklist, - 'path': self.root_dir, - }, - ) - ) - tree_handler_conf = { - 'voila_configuration': self.voila_configuration - } - if self.notebook_path: - handlers.append(( - url_path_join(self.server_url, r'/(.*)'), - VoilaHandler, - { - 'notebook_path': os.path.relpath(self.notebook_path, self.root_dir), - 'template_paths': self.template_paths, - 'config': self.config, - 'voila_configuration': self.voila_configuration - } - )) - else: - self.log.debug('serving directory: %r', self.root_dir) - handlers.extend([ - (self.server_url, VoilaTreeHandler, tree_handler_conf), - (url_path_join(self.server_url, r'/voila/tree' + path_regex), - VoilaTreeHandler, tree_handler_conf), - (url_path_join(self.server_url, r'/voila/render/(.*)'), - VoilaHandler, - { - 'template_paths': self.template_paths, - 'config': self.config, - 'voila_configuration': self.voila_configuration - }), - ]) - - self.app.add_handlers('.*$', handlers) - self.listen() + tree_handler_conf = { + 'voila_configuration': self.voila_configuration + } + if self.notebook_path: + handlers.append(( + url_path_join(self.server_url, r'/(.*)'), + VoilaHandler, + { + 'notebook_path': os.path.relpath(self.notebook_path, self.root_dir), + 'template_paths': self.template_paths, + 'config': self.config, + 'voila_configuration': self.voila_configuration + } + )) + else: + self.log.debug('serving directory: %r', self.root_dir) + handlers.extend([ + (self.server_url, VoilaTreeHandler, tree_handler_conf), + (url_path_join(self.server_url, r'/voila/tree' + path_regex), + VoilaTreeHandler, tree_handler_conf), + (url_path_join(self.server_url, r'/voila/render/(.*)'), + VoilaHandler, + { + 'template_paths': self.template_paths, + 'config': self.config, + 'voila_configuration': self.voila_configuration + }), + ]) + + self.app.add_handlers('.*$', handlers) + self.listen() def stop(self): shutil.rmtree(self.connection_dir) diff --git a/voila/handler.py b/voila/handler.py index 2a5df8846..f88988be6 100644 --- a/voila/handler.py +++ b/voila/handler.py @@ -23,6 +23,9 @@ from nbclient.exceptions import CellExecutionError from nbclient.util import ensure_async from tornado.httputil import split_host_and_port +from fastapi.responses import RedirectResponse + +from fps_kernels.kernel_server.server import KernelServer, kernels, connect_channel from ._version import __version__ from .execute import VoilaExecutor, strip_code_cell_warnings @@ -30,48 +33,39 @@ from .paths import collect_template_paths -class VoilaHandler(JupyterHandler): - - def initialize(self, **kwargs): - self.notebook_path = kwargs.pop('notebook_path', []) # should it be [] - self.template_paths = kwargs.pop('template_paths', []) - self.traitlet_config = kwargs.pop('config', None) - self.voila_configuration = kwargs['voila_configuration'] - # we want to avoid starting multiple kernels due to template mistakes - self.kernel_started = False - - @tornado.web.authenticated - async def get(self, path=None): - # if the handler got a notebook_path argument, always serve that - notebook_path = self.notebook_path or path - if self.notebook_path and path: # when we are in single notebook mode but have a path - self.redirect_to_file(path) - return - - if self.voila_configuration.enable_nbextensions: - # generate a list of nbextensions that are enabled for the classical notebook - # a template can use that to load classical notebook extensions, but does not have to - notebook_config = self.config_manager.get('notebook') - # except for the widget extension itself, since VoilĂ  has its own - load_extensions = notebook_config.get('load_extensions', {}) - if 'jupyter-js-widgets/extension' in load_extensions: - load_extensions['jupyter-js-widgets/extension'] = False - if 'voila/extension' in load_extensions: - load_extensions['voila/extension'] = False - nbextensions = [name for name, enabled in load_extensions.items() if enabled] - else: - nbextensions = [] - - notebook = await self.load_notebook(notebook_path) - if not notebook: - return - self.cwd = os.path.dirname(notebook_path) +async def _get(self, path=None): + # if the handler got a notebook_path argument, always serve that + notebook_path = self.notebook_path or path + if self.notebook_path and path: # when we are in single notebook mode but have a path + return self.redirect_to_file(path) + + if self.voila_configuration.enable_nbextensions: + # generate a list of nbextensions that are enabled for the classical notebook + # a template can use that to load classical notebook extensions, but does not have to + notebook_config = self.config_manager.get('notebook') + # except for the widget extension itself, since VoilĂ  has its own + load_extensions = notebook_config.get('load_extensions', {}) + if 'jupyter-js-widgets/extension' in load_extensions: + load_extensions['jupyter-js-widgets/extension'] = False + if 'voila/extension' in load_extensions: + load_extensions['voila/extension'] = False + nbextensions = [name for name, enabled in load_extensions.items() if enabled] + else: + nbextensions = [] + + notebook = await self.load_notebook(notebook_path) + if not notebook: + return + if isinstance(notebook, RedirectResponse): + return notebook + self.cwd = os.path.dirname(notebook_path) - path, basename = os.path.split(notebook_path) - notebook_name = os.path.splitext(basename)[0] + path, basename = os.path.split(notebook_path) + notebook_name = os.path.splitext(basename)[0] - # Adding request uri to kernel env - self.kernel_env = os.environ.copy() + # Adding request uri to kernel env + self.kernel_env = os.environ.copy() + if not self.is_fps: self.kernel_env['SCRIPT_NAME'] = self.request.path self.kernel_env['PATH_INFO'] = '' # would be /foo/bar if voila.ipynb/foo/bar was supported self.kernel_env['QUERY_STRING'] = str(self.request.query) @@ -90,84 +84,99 @@ async def get(self, path=None): env_name = f'HTTP_{header_name.upper().replace("-", "_")}' self.kernel_env[env_name] = self.request.headers.get(header_name) - # we can override the template via notebook metadata or a query parameter - template_override = None - if 'voila' in notebook.metadata and self.voila_configuration.allow_template_override in ['YES', 'NOTEBOOK']: - template_override = notebook.metadata['voila'].get('template') - if self.voila_configuration.allow_template_override == 'YES': - template_override = self.get_argument("voila-template", template_override) - if template_override: - self.template_paths = collect_template_paths(['voila', 'nbconvert'], template_override) - template_name = template_override or self.voila_configuration.template - - theme = self.voila_configuration.theme - if 'voila' in notebook.metadata and self.voila_configuration.allow_theme_override in ['YES', 'NOTEBOOK']: - theme = notebook.metadata['voila'].get('theme', theme) - if self.voila_configuration.allow_theme_override == 'YES': - theme = self.get_argument("voila-theme", theme) - - # render notebook to html - resources = { - 'base_url': self.base_url, - 'nbextensions': nbextensions, - 'theme': theme, - 'template': template_name, - 'metadata': { - 'name': notebook_name - } - } - - # include potential extra resources - extra_resources = self.voila_configuration.config.VoilaConfiguration.resources - # if no resources get configured from neither the CLI nor a config file, - # extra_resources is a traitlets.config.loader.LazyConfigValue object - # This seems to only happy with the notebook server and traitlets 5 - # Note that we use string checking for backward compatibility - if 'DeferredConfigString' in str(type(extra_resources)): - from .configuration import VoilaConfiguration - extra_resources = VoilaConfiguration.resources.from_string(extra_resources) - if not isinstance(extra_resources, dict): - extra_resources = extra_resources.to_dict() - if extra_resources: - recursive_update(resources, extra_resources) - - self.exporter = VoilaExporter( - template_paths=self.template_paths, - template_name=template_name, - config=self.traitlet_config, - contents_manager=self.contents_manager, # for the image inlining - theme=theme, # we now have the theme in two places - base_url=self.base_url, - ) - if self.voila_configuration.strip_sources: - self.exporter.exclude_input = True - self.exporter.exclude_output_prompt = True - self.exporter.exclude_input_prompt = True - - # These functions allow the start of a kernel and execution of the notebook after (parts of) the template - # has been rendered and send to the client to allow progressive rendering. - # Template should first call kernel_start, and then decide to use notebook_execute - # or cell_generator to implement progressive cell rendering - extra_context = { - 'kernel_start': self._jinja_kernel_start, - 'cell_generator': self._jinja_cell_generator, - 'notebook_execute': self._jinja_notebook_execute, + # we can override the template via notebook metadata or a query parameter + template_override = None + if 'voila' in notebook.metadata and self.voila_configuration.allow_template_override in ['YES', 'NOTEBOOK']: + template_override = notebook.metadata['voila'].get('template') + if self.voila_configuration.allow_template_override == 'YES': + template_override = self.get_argument("voila-template", template_override) + if template_override: + self.template_paths = collect_template_paths(['voila', 'nbconvert'], template_override) + template_name = template_override or self.voila_configuration.template + + theme = self.voila_configuration.theme + if 'voila' in notebook.metadata and self.voila_configuration.allow_theme_override in ['YES', 'NOTEBOOK']: + theme = notebook.metadata['voila'].get('theme', theme) + if self.voila_configuration.allow_theme_override == 'YES': + theme = self.get_argument("voila-theme", theme) + + # render notebook to html + resources = { + 'base_url': self.base_url, + 'nbextensions': nbextensions, + 'theme': theme, + 'template': template_name, + 'metadata': { + 'name': notebook_name } - - # Compose reply + } + + # include potential extra resources + extra_resources = self.voila_configuration.config.VoilaConfiguration.resources + # if no resources get configured from neither the CLI nor a config file, + # extra_resources is a traitlets.config.loader.LazyConfigValue object + # This seems to only happy with the notebook server and traitlets 5 + # Note that we use string checking for backward compatibility + if 'DeferredConfigString' in str(type(extra_resources)): + from .configuration import VoilaConfiguration + extra_resources = VoilaConfiguration.resources.from_string(extra_resources) + if not isinstance(extra_resources, dict): + extra_resources = extra_resources.to_dict() + if extra_resources: + recursive_update(resources, extra_resources) + + self.exporter = VoilaExporter( + template_paths=self.template_paths, + template_name=template_name, + config=self.traitlet_config, + contents_manager=self.contents_manager, # for the image inlining + theme=theme, # we now have the theme in two places + base_url=self.base_url, + ) + if self.voila_configuration.strip_sources: + self.exporter.exclude_input = True + self.exporter.exclude_output_prompt = True + self.exporter.exclude_input_prompt = True + + # These functions allow the start of a kernel and execution of the notebook after (parts of) the template + # has been rendered and send to the client to allow progressive rendering. + # Template should first call kernel_start, and then decide to use notebook_execute + # or cell_generator to implement progressive cell rendering + extra_context = { + 'kernel_start': self._jinja_kernel_start, + 'cell_generator': self._jinja_cell_generator, + 'notebook_execute': self._jinja_notebook_execute, + } + + # Compose reply + if not self.is_fps: self.set_header('Content-Type', 'text/html') self.set_header('Cache-Control', 'no-cache, no-store, must-revalidate') self.set_header('Pragma', 'no-cache') self.set_header('Expires', '0') - # render notebook in snippets, and flush them out to the browser can render progresssively - async for html_snippet, resources in self.exporter.generate_from_notebook_node(notebook, resources=resources, extra_context=extra_context): - self.write(html_snippet) - self.flush() # we may not want to consider not flushing after each snippet, but add an explicit flush function to the jinja context - # yield # give control back to tornado's IO loop, so it can handle static files or other requests - self.flush() + # render notebook in snippets, and flush them out to the browser can render progresssively + async for html_snippet, resources in self.exporter.generate_from_notebook_node(notebook, resources=resources, extra_context=extra_context): + self.write(html_snippet) + self.flush() # we may not want to consider not flushing after each snippet, but add an explicit flush function to the jinja context + # yield # give control back to tornado's IO loop, so it can handle static files or other requests + self.flush() + if self.is_fps: + return self.return_html() + + +class _VoilaHandler: + is_fps = False + + def initialize(self, **kwargs): + self.notebook_path = kwargs.pop('notebook_path', []) # should it be [] + self.template_paths = kwargs.pop('template_paths', []) + self.traitlet_config = kwargs.pop('config', None) + self.voila_configuration = kwargs['voila_configuration'] + # we want to avoid starting multiple kernels due to template mistakes + self.kernel_started = False def redirect_to_file(self, path): - self.redirect(url_path_join(self.base_url, 'voila', 'files', path)) + return self.redirect(url_path_join(self.base_url, 'voila', 'files', path)) async def _jinja_kernel_start(self, nb): assert not self.kernel_started, "kernel was already started" @@ -179,6 +188,25 @@ async def _jinja_kernel_start(self, nb): )) km = await ensure_async(self.kernel_manager.get_kernel(kernel_id)) + if self.is_fps: + connection_cfg = km.get_connection_info() + connection_cfg["key"] = connection_cfg["key"].decode() + kernel_server = KernelServer(connection_cfg=connection_cfg, write_connection_file=False) + kernel_server.last_activity = { + "date": "2021-09-29T15:21:51.913303Z", + "execution_state": "idle", + } + kernel_server.shell_channel = connect_channel("shell", connection_cfg) + kernel_server.iopub_channel = connect_channel("iopub", connection_cfg) + kernel_server.control_channel = connect_channel("control", connection_cfg) + asyncio.create_task(kernel_server.listen_shell()) + asyncio.create_task(kernel_server.listen_iopub()) + asyncio.create_task(kernel_server.listen_control()) + kernels[kernel_id] = { + "name": nb.metadata.kernelspec.name, + "server": kernel_server, + } + self.executor = VoilaExecutor(nb, km=km, config=self.traitlet_config, show_tracebacks=self.voila_configuration.show_tracebacks) @@ -272,8 +300,7 @@ async def load_notebook(self, path): notebook = await self.create_notebook(model, language=language) return notebook else: - self.redirect_to_file(path) - return None + return self.redirect_to_file(path) async def fix_notebook(self, notebook): """Returns a notebook object with a valid kernelspec. @@ -335,3 +362,9 @@ async def find_kernel_name_for_language(self, kernel_language, kernel_specs=None return matches[0] else: raise tornado.web.HTTPError(500, 'No Jupyter kernel for language %r found' % kernel_language) + + +class VoilaHandler(_VoilaHandler, JupyterHandler): + @tornado.web.authenticated + async def get(self, path=None): + return await _get(self, path=path)