diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 999ae442f..cdb94cc66 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -48,6 +48,8 @@ jobs: run: | python -m pip install --upgrade pip jupyterlab~=3.0 numpy bqplot matplotlib ipympl==0.8.0 ipyvolume scipy python -m pip install ".[test]" + python -m pip install fps[uvicorn] + python -m pip install fps_plugins/voila jlpm jlpm build jupyter labextension develop . --overwrite @@ -59,6 +61,7 @@ jobs: cd ui-tests # Mount a volume to overwrite the server configuration jlpm start 2>&1 > /tmp/jupyterlab_server.log & + jlpm start-fps 2>&1 > /tmp/fps_server.log & - name: Install browser run: | @@ -108,6 +111,8 @@ jobs: # Save PR number for comment publication echo "${{ github.event.number }}" > ./benchmark-results/NR + jlpm run test-fps + - name: Upload Playwright Test assets if: always() uses: actions/upload-artifact@v2 @@ -135,5 +140,7 @@ jobs: - name: Print JupyterLab logs if: always() run: | + echo "Voila log:" cat /tmp/jupyterlab_server.log - + echo "Voila-FPS log:" + cat /tmp/fps_server.log diff --git a/MANIFEST.in b/MANIFEST.in index 7dce21745..f422d8e41 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,6 +8,8 @@ include ts*.json graft voila/labextension +recursive-include fps_plugins *.py + # Javascript files graft src graft style diff --git a/fps_plugins/voila/fps_voila/__init__.py b/fps_plugins/voila/fps_voila/__init__.py new file mode 100644 index 000000000..f102a9cad --- /dev/null +++ b/fps_plugins/voila/fps_voila/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/fps_plugins/voila/fps_voila/config.py b/fps_plugins/voila/fps_voila/config.py new file mode 100644 index 000000000..6c56e5697 --- /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): + pass + + +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..8b378ff6d --- /dev/null +++ b/fps_plugins/voila/fps_voila/routes.py @@ -0,0 +1,229 @@ +import re +import os +from pathlib import Path +from http import HTTPStatus +from typing import Optional + +from voila.handler import _VoilaHandler, _get +from voila.treehandler import _VoilaTreeHandler, _get as _get_tree +from voila.paths import collect_static_paths +from nbclient.util import ensure_async + +from mimetypes import guess_type +from starlette.requests import Request +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import RedirectResponse, StreamingResponse, HTMLResponse, Response +from fastapi.staticfiles import StaticFiles +from fps.hooks import register_router # type: ignore + + +class Config: + pass + +C = Config() + +class WhiteListFileHandler: + pass + +white_list_file_handler = WhiteListFileHandler() + +class FPSVoilaTreeHandler(_VoilaTreeHandler): + is_fps = True + request = Config() + + def redirect(self, url): + return RedirectResponse(url) + + def write(self, html): + return HTMLResponse(html) + + def render_template(self, name, **kwargs): + kwargs["base_url"] = self.base_url + template = self.jinja2_env.get_template(name) + return template.render(**kwargs) + + +class FPSVoilaHandler(_VoilaHandler): + is_fps = True + request = Config() + fps_arguments = {} + html = [] + + def redirect(self, url): + return RedirectResponse(url) + + def get_argument(self, name, default): + if self.fps_arguments.get(name) is None: + return default + return self.fps_arguments[name] + + +fps_voila_handler = FPSVoilaHandler() +fps_voila_tree_handler = FPSVoilaTreeHandler() + +def init_fps( + *, + 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, + settings, + log, + whitelist, + blacklist, + root_dir, +): + white_list_file_handler.whitelist = whitelist + white_list_file_handler.blacklist = blacklist + white_list_file_handler.path = root_dir + + kwargs = { + "template_paths": template_paths, + "traitlet_config": config, + "voila_configuration": voila_configuration, + } + if notebook_path: + kwargs["notebook_path"] = os.path.relpath(notebook_path, root_dir) + + fps_voila_handler.initialize(**kwargs) + fps_voila_handler.root_dir = root_dir + 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 + + fps_voila_tree_handler.initialize( + voila_configuration=voila_configuration, + ) + fps_voila_tree_handler.contents_manager = contents_manager + fps_voila_tree_handler.base_url = base_url + fps_voila_tree_handler.voila_jinja2_env = voila_jinja2_env + fps_voila_tree_handler.jinja2_env = jinja2_env + fps_voila_tree_handler.settings = settings + fps_voila_tree_handler.log = log + settings["contents_manager"] = contents_manager + + C.notebook_path = notebook_path + C.root_dir = root_dir + + +router = APIRouter() + +@router.post("/voila/api/shutdown/{kernel_id}", status_code=204) +async def shutdown_kernel(kernel_id): + await ensure_async(fps_voila_handler.kernel_manager.shutdown_kernel(kernel_id)) + return Response(status_code=HTTPStatus.NO_CONTENT.value) + +@router.get("/notebooks/{path:path}") +async def get_root(path, voila_template: Optional[str] = None, voila_theme: Optional[str] = None): + fps_voila_handler.request.query = request.query_params + fps_voila_handler.request.path = request.url.path + fps_voila_handler.request.host = f"{request.url.hostname}:{request.url.port}" + fps_voila_handler.request.headers = request.headers + return StreamingResponse(_get(fps_voila_handler, path)) + +@router.get("/") +async def get_root(request: Request, voila_template: Optional[str] = None, voila_theme: Optional[str] = None): + fps_voila_handler.fps_arguments["voila-template"] = voila_template + fps_voila_handler.fps_arguments["voila-theme"] = voila_theme + path = fps_voila_handler.notebook_path or "/" + if path == "/": + if C.notebook_path: + raise HTTPException(status_code=404, detail="Not found") + else: + fps_voila_tree_handler.request.path = request.url.path + return _get_tree(fps_voila_tree_handler, "/") + else: + fps_voila_handler.request.query = request.query_params + fps_voila_handler.request.path = request.url.path + fps_voila_handler.request.host = f"{request.url.hostname}:{request.url.port}" + fps_voila_handler.request.headers = request.headers + return StreamingResponse(_get(fps_voila_handler, "")) + +@router.get("/voila/render/{path:path}") +async def get_path(request: Request, path): + if C.notebook_path: + raise HTTPException(status_code=404, detail="Not found") + else: + fps_voila_handler.request.query = request.query_params + fps_voila_handler.request.path = request.url.path + fps_voila_handler.request.host = f"{request.url.hostname}:{request.url.port}" + fps_voila_handler.request.headers = request.headers + return StreamingResponse(_get(fps_voila_handler, path)) + +@router.get("/voila/tree{path:path}") +async def get_tree(request: Request, path): + if C.notebook_path: + raise HTTPException(status_code=404, detail="Not found") + else: + fps_voila_tree_handler.request.path = request.url.path + return _get_tree(fps_voila_tree_handler, path) + +# WhiteListFileHandler +@router.get("/voila/files/{path:path}") +def get_whitelisted_file(path): + whitelisted = any(re.fullmatch(pattern, path) for pattern in white_list_file_handler.whitelist) + blacklisted = any(re.fullmatch(pattern, path) for pattern in white_list_file_handler.blacklist) + if not whitelisted: + raise HTTPException(status_code=403, detail="File not whitelisted") + if blacklisted: + raise HTTPException(status_code=403, detail="File blacklisted") + return _get_file(path, in_dir=white_list_file_handler.path) + +@router.get("/voila/static/{path}") +def get_static_file(path): + return _get_file_in_dirs(path, fps_voila_handler.static_paths) + +@router.get("/voila/templates/{path:path}") +def get_template_file(path): + template, static, relpath = os.path.normpath(path).split(os.path.sep, 2) + assert static == "static" + roots = collect_static_paths(["voila", "nbconvert"], template) + for root in roots: + abspath = os.path.abspath(os.path.join(root, relpath)) + if os.path.exists(abspath): + return _get_file(abspath) + break + raise HTTPException(status_code=404, detail="File not found") + +def _get_file_in_dirs(path, dirs): + for d in dirs: + p = Path(d) / path + if p.is_file(): + return _get_file(p) + else: + raise HTTPException(status_code=404, detail="File not found") + +def _get_file(path: str, in_dir: Optional[str] = None): + if in_dir is None: + p = Path(path) + else: + p = Path(in_dir) / path + if p.is_file(): + with open(p) as f: + content = f.read() + content_type, _ = guess_type(p) + return Response(content, media_type=content_type) + raise HTTPException(status_code=404, detail="File not found") + +r = register_router(router) diff --git a/fps_plugins/voila/setup.cfg b/fps_plugins/voila/setup.cfg new file mode 100644 index 000000000..7d5e96b1b --- /dev/null +++ b/fps_plugins/voila/setup.cfg @@ -0,0 +1,17 @@ +[metadata] +name = fps_voila +version = attr: fps_voila.__version__ + +[options] +include_package_data = True +packages = find: + +install_requires = + fps-kernels + aiofiles + +[options.entry_points] +fps_router = + fps-voila = fps_voila.routes +fps_config = + fps-voila = fps_voila.config diff --git a/fps_plugins/voila/setup.py b/fps_plugins/voila/setup.py new file mode 100644 index 000000000..68a125d17 --- /dev/null +++ b/fps_plugins/voila/setup.py @@ -0,0 +1,3 @@ +import setuptools # type: ignore + +setuptools.setup() diff --git a/setup.cfg b/setup.cfg index ff0f3686d..719030c1e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,6 +52,10 @@ test = pytest pytest-tornasync +fps = + fps[uvicorn] + fps-voila + [options.entry_points] console_scripts = voila = voila.app:main diff --git a/ui-tests/copy_png b/ui-tests/copy_png new file mode 100755 index 000000000..0b68db3ef --- /dev/null +++ b/ui-tests/copy_png @@ -0,0 +1,23 @@ +cd tests/voila.test.ts-snapshots + +cp basics-linux.png basics-voila-linux.png +cp query-strings-linux.png query-strings-voila-linux.png +cp multiple-widgets-linux.png multiple-widgets-voila-linux.png +cp ipympl-linux.png ipympl-voila-linux.png +cp reveal-linux.png reveal-voila-linux.png +cp ipyvolume-linux.png ipyvolume-voila-linux.png +cp bqplot-linux.png bqplot-voila-linux.png +cp interactive-linux.png interactive-voila-linux.png +cp gridspecLayout-linux.png gridspecLayout-voila-linux.png + +cp basics-linux.png basics-voila-fps-linux.png +cp query-strings-linux.png query-strings-voila-fps-linux.png +cp multiple-widgets-linux.png multiple-widgets-voila-fps-linux.png +cp ipympl-linux.png ipympl-voila-fps-linux.png +cp reveal-linux.png reveal-voila-fps-linux.png +cp ipyvolume-linux.png ipyvolume-voila-fps-linux.png +cp bqplot-linux.png bqplot-voila-fps-linux.png +cp interactive-linux.png interactive-voila-fps-linux.png +cp gridspecLayout-linux.png gridspecLayout-voila-fps-linux.png + +cd ../.. diff --git a/ui-tests/package.json b/ui-tests/package.json index 5459e9fba..f9690704c 100644 --- a/ui-tests/package.json +++ b/ui-tests/package.json @@ -5,11 +5,13 @@ "private": true, "scripts": { "start": "voila ../notebooks --no-browser", + "start-fps": "voila ../notebooks --no-browser --fps --port=8867", "start:detached": "yarn run start-jlab&", - "test": "playwright test", - "test:debug": "PWDEBUG=1 playwright test", + "test": "./copy_png && playwright test --project voila", + "test-fps": "playwright test --project voila-fps", + "test:debug": "PWDEBUG=1 playwright test --project voila", "test:report": "http-server ./playwright-report -a localhost -o", - "test:update": "playwright test --update-snapshots" + "test:update": "playwright test --project voila --update-snapshots" }, "author": "Project Jupyter", "license": "BSD-3-Clause", diff --git a/ui-tests/playwright.config.js b/ui-tests/playwright.config.js index 2daa0ec71..aca22460f 100644 --- a/ui-tests/playwright.config.js +++ b/ui-tests/playwright.config.js @@ -1,8 +1,30 @@ const baseConfig = require('@jupyterlab/galata/lib/playwright-config'); module.exports = { - ...baseConfig, - timeout: 240000, + projects: [ + { + ...baseConfig, + name: 'voila', + timeout: 240000, + use: { + baseURL: 'http://localhost:8866/voila/', + video: 'retain-on-failure' + }, + // Try one retry as some tests are flaky + retries: 1 + }, + { + ...baseConfig, + name: 'voila-fps', + timeout: 240000, + use: { + baseURL: 'http://localhost:8867/voila/', + video: 'retain-on-failure' + }, + // Try one retry as some tests are flaky + retries: 1 + } + ], reporter: [ [process.env.CI ? 'dot' : 'list'], [ @@ -10,11 +32,5 @@ module.exports = { { outputFile: 'voila-benchmark.json' } ], ['html'] - ], - use: { - baseURL: 'http://localhost:8866/voila/', - video: 'retain-on-failure' - }, - // Try one retry as some tests are flaky - retries: 1 + ] }; diff --git a/voila/app.py b/voila/app.py index 4ea7c75c3..e947b09de 100644 --- a/voila/app.py +++ b/voila/app.py @@ -32,15 +32,12 @@ import jinja2 import tornado.ioloop -import tornado.web from traitlets.config.application import Application from traitlets.config.loader import Config from traitlets import Unicode, Integer, Bool, Dict, List, default -from jupyter_server.services.kernels.handlers import KernelHandler, ZMQChannelsHandler from jupyter_server.services.contents.largefilemanager import LargeFileManager -from jupyter_server.base.handlers import FileFindHandler, path_regex from jupyter_server.config_manager import recursive_update from jupyter_server.utils import url_path_join, run_sync from jupyter_server.services.config import ConfigManager @@ -52,18 +49,13 @@ from ipython_genutils.py3compat import getcwd from .paths import ROOT, STATIC_ROOT, collect_template_paths, collect_static_paths -from .handler import VoilaHandler -from .treehandler import VoilaTreeHandler from ._version import __version__ -from .static_file_handler import MultiStaticFileHandler, TemplateStaticFileHandler, WhiteListFileHandler from .configuration import VoilaConfiguration from .execute import VoilaExecutor from .exporter import VoilaExporter -from .shutdown_kernel_handler import VoilaShutdownKernelHandler from .voila_kernel_manager import voila_kernel_manager_factory -from .query_parameters_handler import QueryStringSocketHandler - -_kernel_id_regex = r"(?P\w+-\w+-\w+-\w+-\w+)" +from .tornado_web_app import web_app as tornado_web_app +from .fps_web_app import web_app as fps_web_app def _(x): @@ -83,7 +75,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( @@ -202,6 +195,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 @@ -447,105 +445,10 @@ 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 - ) - - 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) - ]) - - if preheat_kernel: - handlers.append( - ( - url_path_join(self.server_url, r'/voila/query/%s' % _kernel_id_regex), - QueryStringSocketHandler - ) - ) - # 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/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 - } - )) + if self.fps: + fps_web_app(self, env) 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() + tornado_web_app(self, env, preheat_kernel) def stop(self): shutil.rmtree(self.connection_dir) diff --git a/voila/fps_web_app.py b/voila/fps_web_app.py new file mode 100644 index 000000000..3eef86e1f --- /dev/null +++ b/voila/fps_web_app.py @@ -0,0 +1,39 @@ +import sys + + +def web_app(voila_app, env): + try: + from fps_uvicorn.cli import app as fps_app + from fps_voila.routes import init_fps + except Exception: + print("Please install fps_voila.") + sys.exit(1) + + # pass options to FPS app + sys.argv = sys.argv[:1] + fps_options = [f"--fps_uvicorn.root_path={voila_app.server_url}", f"--port={voila_app.port}", "--authenticator.mode=noauth"] + sys.argv += fps_options + init_fps( + notebook_path=voila_app.notebook_path, + template_paths=voila_app.template_paths, + config=voila_app.config, + voila_configuration=voila_app.voila_configuration, + contents_manager=voila_app.contents_manager, + base_url=voila_app.base_url, + kernel_manager=voila_app.kernel_manager, + kernel_spec_manager=voila_app.kernel_spec_manager, + allow_remote_access=True, + autoreload=voila_app.autoreload, + voila_jinja2_env=env, + jinja2_env=env, + static_path='/', + server_root_dir='/', + config_manager=voila_app.config_manager, + static_paths=voila_app.static_paths, + settings=voila_app.tornado_settings, + log=voila_app.log, + whitelist=voila_app.voila_configuration.file_whitelist, + blacklist=voila_app.voila_configuration.file_blacklist, + root_dir=voila_app.root_dir, + ) + fps_app() diff --git a/voila/handler.py b/voila/handler.py index fa4395466..fa0004dbb 100644 --- a/voila/handler.py +++ b/voila/handler.py @@ -9,6 +9,7 @@ import os +import asyncio from typing import Dict import tornado.web @@ -24,145 +25,175 @@ from .utils import ENV_VARIABLE -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 - cwd = os.path.dirname(notebook_path) - - # Adding request uri to kernel env - kernel_env = os.environ.copy() - kernel_env[ENV_VARIABLE.SCRIPT_NAME] = self.request.path - kernel_env[ - ENV_VARIABLE.PATH_INFO - ] = '' # would be /foo/bar if voila.ipynb/foo/bar was supported - kernel_env[ENV_VARIABLE.QUERY_STRING] = str(self.request.query) - kernel_env[ENV_VARIABLE.SERVER_SOFTWARE] = 'voila/{}'.format(__version__) +async def _get(self: "_VoilaHandler", path=None): + """Backend-agnostic logic of the GET method, used in the following handler classes: + - VoilaHandler: the Tornado-specific handler. + - FPSVoilaHandler: the FastAPI-specific handler. + + It is an async generator that generates HTML snippets as the notebook is executed. + """ + # 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 + cwd = os.path.dirname(notebook_path) + + # Adding request uri to kernel env + kernel_env = os.environ.copy() + kernel_env[ENV_VARIABLE.SCRIPT_NAME] = self.request.path + kernel_env[ + ENV_VARIABLE.PATH_INFO + ] = '' # would be /foo/bar if voila.ipynb/foo/bar was supported + kernel_env[ENV_VARIABLE.QUERY_STRING] = str(self.request.query) + kernel_env[ENV_VARIABLE.SERVER_SOFTWARE] = 'voila/{}'.format(__version__) + host, port = split_host_and_port(self.request.host.lower()) + kernel_env[ENV_VARIABLE.SERVER_PORT] = str(port) if port else '' + kernel_env[ENV_VARIABLE.SERVER_NAME] = host + # Add HTTP Headers as env vars following rfc3875#section-4.1.18 + if len(self.voila_configuration.http_header_envs) > 0: + for header_name in self.request.headers: + config_headers_lower = [header.lower() for header in self.voila_configuration.http_header_envs] + # Use case insensitive comparison of header names as per rfc2616#section-4.2 + if header_name.lower() in config_headers_lower: + env_name = f'HTTP_{header_name.upper().replace("-", "_")}' + kernel_env[env_name] = self.request.headers.get(header_name) + if not self.is_fps: kernel_env[ENV_VARIABLE.SERVER_PROTOCOL] = str(self.request.version) - host, port = split_host_and_port(self.request.host.lower()) - kernel_env[ENV_VARIABLE.SERVER_PORT] = str(port) if port else '' - kernel_env[ENV_VARIABLE.SERVER_NAME] = host - # Add HTTP Headers as env vars following rfc3875#section-4.1.18 - if len(self.voila_configuration.http_header_envs) > 0: - for header_name in self.request.headers: - config_headers_lower = [header.lower() for header in self.voila_configuration.http_header_envs] - # Use case insensitive comparison of header names as per rfc2616#section-4.2 - if header_name.lower() in config_headers_lower: - env_name = f'HTTP_{header_name.upper().replace("-", "_")}' - kernel_env[env_name] = self.request.headers.get(header_name) - - template_arg = self.get_argument("voila-template", None) - theme_arg = self.get_argument("voila-theme", None) - - # Compose reply + + template_arg = self.get_argument("voila-template", None) + theme_arg = self.get_argument("voila-theme", None) + + # 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') - try: - current_notebook_data: Dict = self.kernel_manager.notebook_data.get(notebook_path, {}) - pool_size: int = self.kernel_manager.get_pool_size(notebook_path) - except AttributeError: - # For server extenstion case. - current_notebook_data = {} - pool_size = 0 - # Check if the conditions for using pre-heated kernel are satisfied. - if self.should_use_rendered_notebook( - current_notebook_data, - pool_size, - template_arg, - theme_arg, - self.request.arguments, - ): - # Get the pre-rendered content of notebook, the result can be all rendered cells - # of the notebook or some rendred cells and a generator which can be used by this - # handler to continue rendering calls. - - render_task, rendered_cache, kernel_id = await self.kernel_manager.get_rendered_notebook( - notebook_name=notebook_path, - ) - - QueryStringSocketHandler.send_updates({'kernel_id': kernel_id, 'payload': self.request.query}) - # Send rendered cell to frontend - if len(rendered_cache) > 0: - self.write(''.join(rendered_cache)) - self.flush() - - # Wait for current running cell finish and send this cell to - # frontend. - rendered, rendering = await render_task - if len(rendered) > len(rendered_cache): - html_snippet = ''.join(rendered[len(rendered_cache):]) - self.write(html_snippet) - self.flush() - - # Continue render cell from generator. - async for html_snippet, _ in rendering: - self.write(html_snippet) - self.flush() - self.flush() - - else: - # All kernels are used or pre-heated kernel is disabled, start a normal kernel. - gen = NotebookRenderer( - voila_configuration=self.voila_configuration, - traitlet_config=self.traitlet_config, - notebook_path=notebook_path, - template_paths=self.template_paths, - config_manager=self.config_manager, - contents_manager=self.contents_manager, - base_url=self.base_url, - kernel_spec_manager=self.kernel_spec_manager, - ) - - await gen.initialize(template=template_arg, theme=theme_arg) - - def time_out(): - """If not done within the timeout, we send a heartbeat - this is fundamentally to avoid browser/proxy read-timeouts, but - can be used in a template to give feedback to a user - """ - - self.write('\n') - self.flush() - - kernel_env[ENV_VARIABLE.VOILA_PREHEAT] = 'False' - kernel_env[ENV_VARIABLE.VOILA_BASE_URL] = self.base_url - kernel_id = await ensure_async( - ( - self.kernel_manager.start_kernel( - kernel_name=gen.notebook.metadata.kernelspec.name, - path=cwd, - env=kernel_env, - ) + try: + current_notebook_data: Dict = self.kernel_manager.notebook_data.get(notebook_path, {}) + pool_size: int = self.kernel_manager.get_pool_size(notebook_path) + except AttributeError: + # For server extenstion case. + current_notebook_data = {} + pool_size = 0 + # Check if the conditions for using pre-heated kernel are satisfied. + if self.is_fps: + request_args = self.fps_arguments + else: + request_args = self.request.arguments + if self.should_use_rendered_notebook( + current_notebook_data, + pool_size, + template_arg, + theme_arg, + request_args, + ): + # Get the pre-rendered content of notebook, the result can be all rendered cells + # of the notebook or some rendred cells and a generator which can be used by this + # handler to continue rendering calls. + + render_task, rendered_cache, kernel_id = await self.kernel_manager.get_rendered_notebook( + notebook_name=notebook_path, + ) + + QueryStringSocketHandler.send_updates({'kernel_id': kernel_id, 'payload': self.request.query}) + # Send rendered cell to frontend + if len(rendered_cache) > 0: + yield ''.join(rendered_cache) + + # Wait for current running cell finish and send this cell to + # frontend. + rendered, rendering = await render_task + if len(rendered) > len(rendered_cache): + html_snippet = ''.join(rendered[len(rendered_cache):]) + yield html_snippet + + # Continue render cell from generator. + async for html_snippet, _ in rendering: + yield html_snippet + + else: + # All kernels are used or pre-heated kernel is disabled, start a normal kernel. + gen = NotebookRenderer( + is_fps=self.is_fps, + voila_configuration=self.voila_configuration, + traitlet_config=self.traitlet_config, + notebook_path=notebook_path, + template_paths=self.template_paths, + config_manager=self.config_manager, + contents_manager=self.contents_manager, + base_url=self.base_url, + kernel_spec_manager=self.kernel_spec_manager, + ) + + await gen.initialize(template=template_arg, theme=theme_arg) + + def time_out(): + """If not done within the timeout, we send a heartbeat + this is fundamentally to avoid browser/proxy read-timeouts, but + can be used in a template to give feedback to a user + """ + + return '\n' + + kernel_env[ENV_VARIABLE.VOILA_PREHEAT] = 'False' + kernel_env[ENV_VARIABLE.VOILA_BASE_URL] = self.base_url + kernel_id = await ensure_async( + ( + self.kernel_manager.start_kernel( + kernel_name=gen.notebook.metadata.kernelspec.name, + path=cwd, + env=kernel_env, ) ) - kernel_future = self.kernel_manager.get_kernel(kernel_id) + ) + kernel_future = self.kernel_manager.get_kernel(kernel_id) + queue = asyncio.Queue() + + async def put_html(): async for html_snippet, _ in gen.generate_content_generator( - kernel_id, kernel_future, time_out + kernel_id, kernel_future ): - self.write(html_snippet) - self.flush() - # we may not want to consider not flusing 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() + await queue.put(html_snippet) + await queue.put(None) + + asyncio.ensure_future(put_html()) + + # If not done within the timeout, we send a heartbeat + # this is fundamentally to avoid browser/proxy read-timeouts, but + # can be used in a template to give feedback to a user + while True: + try: + html_snippet = await asyncio.wait_for(queue.get(), self.voila_configuration.http_keep_alive_timeout) + except asyncio.TimeoutError: + yield time_out() + else: + if html_snippet is None: + break + yield html_snippet + + +class _VoilaHandler: + """Backend-agnostic handler class, from which the following classes derive: + - VoilaHandler: the Tornado-specific handler. + - FPSVoilaHandler: the FastAPI-specific handler. + """ + 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 + + @tornado.web.authenticated def redirect_to_file(self, path): self.redirect(url_path_join(self.base_url, 'voila', 'files', path)) @@ -187,3 +218,16 @@ def should_use_rendered_notebook( return False return True + + +class VoilaHandler(_VoilaHandler, JupyterHandler): + """Tornado-specific handler. + """ + @tornado.web.authenticated + async def get(self, path=None): + it = _get(self, path=path) + async for html in it: + self.write(html) + self.flush() + + return it diff --git a/voila/notebook_renderer.py b/voila/notebook_renderer.py index bb634712a..87ed67706 100644 --- a/voila/notebook_renderer.py +++ b/voila/notebook_renderer.py @@ -12,7 +12,7 @@ import os import sys import traceback -from typing import Callable, Generator, Tuple, Union, List +from typing import Generator, Tuple, Union, List import nbformat import tornado.web @@ -33,6 +33,7 @@ class NotebookRenderer(LoggingConfigurable): def __init__(self, **kwargs): super().__init__() + self.is_fps = kwargs.pop("is_fps", False) self.root_dir = kwargs.get('root_dir', []) self.notebook_path = kwargs.get('notebook_path', []) # should it be [] self.template_paths = kwargs.get('template_paths', []) @@ -150,13 +151,12 @@ def generate_content_generator( self, kernel_id: Union[str, None] = None, kernel_future=None, - timeout_callback: Union[Callable, None] = None, ) -> Generator: async def inner_kernel_start(nb): return await self._jinja_kernel_start(nb, kernel_id, kernel_future) def inner_cell_generator(nb, kernel_id): - return self._jinja_cell_generator(nb, kernel_id, timeout_callback) + return self._jinja_cell_generator(nb, kernel_id) # These functions allow the start of a kernel and execution of the # notebook after (parts of) the template has been rendered and send @@ -207,6 +207,25 @@ async def generate_content_str( async def _jinja_kernel_start(self, nb, kernel_id, kernel_future): assert not self.kernel_started, 'kernel was already started' km = await ensure_async(kernel_future) + if self.is_fps: + from fps_kernels.kernel_server.server import KernelServer, kernels, connect_channel + 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.ensure_future(kernel_server.listen_shell()) + asyncio.ensure_future(kernel_server.listen_iopub()) + asyncio.ensure_future(kernel_server.listen_control()) + kernels[kernel_id] = { + "name": nb.metadata.kernelspec.name, + "server": kernel_server, + } self.executor = VoilaExecutor( nb, km=km, @@ -248,32 +267,16 @@ async def _jinja_notebook_execute(self, nb, kernel_id): await self._cleanup_resources() - async def _jinja_cell_generator(self, nb, kernel_id, timeout_callback): + async def _jinja_cell_generator(self, nb, kernel_id): """Generator that will execute a single notebook cell at a time""" nb, _ = ClearOutputPreprocessor().preprocess( nb, {'metadata': {'path': self.cwd}} ) for cell_idx, input_cell in enumerate(nb.cells): try: - task = asyncio.ensure_future( - self.executor.execute_cell( - input_cell, None, cell_idx, store_history=False - ) + output_cell = await self.executor.execute_cell( + input_cell, None, cell_idx, store_history=False ) - while True: - _, pending = await asyncio.wait( - {task}, timeout=self.voila_configuration.http_keep_alive_timeout - ) - if pending: - # If not done within the timeout, we send a heartbeat - # this is fundamentally to avoid browser/proxy read-timeouts, but - # can be used in a template to give feedback to a user - if timeout_callback is not None: - timeout_callback() - - continue - output_cell = await task - break except TimeoutError: output_cell = input_cell break diff --git a/voila/tornado_web_app.py b/voila/tornado_web_app.py new file mode 100644 index 000000000..6f3adfce5 --- /dev/null +++ b/voila/tornado_web_app.py @@ -0,0 +1,117 @@ +import os + +import tornado.web + +from jupyter_server.utils import url_path_join +from jupyter_server.services.kernels.handlers import KernelHandler, ZMQChannelsHandler +from jupyter_server.base.handlers import FileFindHandler, path_regex +from .shutdown_kernel_handler import VoilaShutdownKernelHandler +from .static_file_handler import MultiStaticFileHandler, TemplateStaticFileHandler, WhiteListFileHandler +from .query_parameters_handler import QueryStringSocketHandler +from .handler import VoilaHandler +from .treehandler import VoilaTreeHandler + + +_kernel_id_regex = r"(?P\w+-\w+-\w+-\w+-\w+)" + + +def web_app(voila_app, env, preheat_kernel): + voila_app.app = tornado.web.Application( + base_url=voila_app.base_url, + server_url=voila_app.server_url or voila_app.base_url, + kernel_manager=voila_app.kernel_manager, + kernel_spec_manager=voila_app.kernel_spec_manager, + allow_remote_access=True, + autoreload=voila_app.autoreload, + voila_jinja2_env=env, + jinja2_env=env, + static_path='/', + server_root_dir='/', + contents_manager=voila_app.contents_manager, + config_manager=voila_app.config_manager + ) + + voila_app.app.settings.update(voila_app.tornado_settings) + + handlers = [] + + handlers.extend([ + (url_path_join(voila_app.server_url, r'/api/kernels/%s' % _kernel_id_regex), KernelHandler), + (url_path_join(voila_app.server_url, r'/api/kernels/%s/channels' % _kernel_id_regex), ZMQChannelsHandler), + ( + url_path_join(voila_app.server_url, r'/voila/templates/(.*)'), + TemplateStaticFileHandler + ), + ( + url_path_join(voila_app.server_url, r'/voila/static/(.*)'), + MultiStaticFileHandler, + { + 'paths': voila_app.static_paths, + 'default_filename': 'index.html' + }, + ), + (url_path_join(voila_app.server_url, r'/voila/api/shutdown/(.*)'), VoilaShutdownKernelHandler) + ]) + + if preheat_kernel: + handlers.append( + ( + url_path_join(voila_app.server_url, r'/voila/query/%s' % _kernel_id_regex), + QueryStringSocketHandler + ) + ) + # Serving notebook extensions + if voila_app.voila_configuration.enable_nbextensions: + handlers.append( + ( + url_path_join(voila_app.server_url, r'/voila/nbextensions/(.*)'), + FileFindHandler, + { + 'path': voila_app.nbextensions_path, + 'no_cache_paths': ['/'], # don't cache anything in nbextensions + }, + ) + ) + handlers.append( + ( + url_path_join(voila_app.server_url, r'/voila/files/(.*)'), + WhiteListFileHandler, + { + 'whitelist': voila_app.voila_configuration.file_whitelist, + 'blacklist': voila_app.voila_configuration.file_blacklist, + 'path': voila_app.root_dir, + }, + ) + ) + + tree_handler_conf = { + 'voila_configuration': voila_app.voila_configuration + } + if voila_app.notebook_path: + handlers.append(( + url_path_join(voila_app.server_url, r'/(.*)'), + VoilaHandler, + { + 'notebook_path': os.path.relpath(voila_app.notebook_path, voila_app.root_dir), + 'template_paths': voila_app.template_paths, + 'config': voila_app.config, + 'voila_configuration': voila_app.voila_configuration + } + )) + else: + voila_app.log.debug('serving directory: %r', voila_app.root_dir) + handlers.extend([ + (voila_app.server_url, VoilaTreeHandler, tree_handler_conf), + (url_path_join(voila_app.server_url, r'/voila/tree' + path_regex), + VoilaTreeHandler, tree_handler_conf), + (url_path_join(voila_app.server_url, r'/voila/render/(.*)'), + VoilaHandler, + { + 'template_paths': voila_app.template_paths, + 'config': voila_app.config, + 'voila_configuration': voila_app.voila_configuration + }), + ]) + + voila_app.app.add_handlers('.*$', handlers) + voila_app.listen() diff --git a/voila/treehandler.py b/voila/treehandler.py index 53013c83d..3cfac4a3b 100644 --- a/voila/treehandler.py +++ b/voila/treehandler.py @@ -16,7 +16,61 @@ from .utils import get_server_root_dir -class VoilaTreeHandler(JupyterHandler): +def _get(self: "_VoilaTreeHandler", path=''): + """Backend-agnostic logic of the GET method, used in the following handler classes: + - VoilaTreeHandler: the Tornado-specific handler. + - FPSVoilaTreeHandler: the FastAPI-specific handler. + """ + cm = self.contents_manager + + if cm.dir_exists(path=path): + if cm.is_hidden(path) and not cm.allow_hidden: + self.log.info("Refusing to serve hidden directory, via 404 Error") + raise web.HTTPError(404) + breadcrumbs = self.generate_breadcrumbs(path) + page_title = self.generate_page_title(path) + contents = cm.get(path) + + def allowed_content(content): + if content['type'] in ['directory', 'notebook']: + return True + __, ext = os.path.splitext(content.get('path')) + return ext in self.allowed_extensions + + contents['content'] = sorted(contents['content'], key=lambda i: i['name']) + contents['content'] = filter(allowed_content, contents['content']) + return self.write(self.render_template('tree.html', + page_title=page_title, + notebook_path=path, + breadcrumbs=breadcrumbs, + contents=contents, + terminals_available=False, + server_root=get_server_root_dir(self.settings))) + elif cm.file_exists(path): + # it's not a directory, we have redirecting to do + model = cm.get(path, content=False) + # redirect to /api/notebooks if it's a notebook, otherwise /api/files + service = 'notebooks' if model['type'] == 'notebook' else 'files' + url = url_path_join( + self.base_url, service, url_escape(path), + ) + self.log.debug("Redirecting %s to %s", self.request.path, url) + return self.redirect(url) + else: + if self.is_fps: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail=f"{path} not found") + else: + raise web.HTTPError(404) + + +class _VoilaTreeHandler: + """Backend-agnostic handler class, from which the following classes derive: + - VoilaTreeHandler: the Tornado-specific handler. + - FPSVoilaTreeHandler: the FastAPI-specific handler. + """ + is_fps = False + def initialize(self, **kwargs): self.voila_configuration = kwargs['voila_configuration'] self.allowed_extensions = list(self.voila_configuration.extension_language_mapping.keys()) + ['.ipynb'] @@ -46,42 +100,10 @@ def generate_page_title(self, path): else: return 'VoilĂ  Home' + +class VoilaTreeHandler(_VoilaTreeHandler, JupyterHandler): + """Tornado-specific handler. + """ @web.authenticated def get(self, path=''): - cm = self.contents_manager - - if cm.dir_exists(path=path): - if cm.is_hidden(path) and not cm.allow_hidden: - self.log.info("Refusing to serve hidden directory, via 404 Error") - raise web.HTTPError(404) - breadcrumbs = self.generate_breadcrumbs(path) - page_title = self.generate_page_title(path) - contents = cm.get(path) - - def allowed_content(content): - if content['type'] in ['directory', 'notebook']: - return True - __, ext = os.path.splitext(content.get('path')) - return ext in self.allowed_extensions - - contents['content'] = sorted(contents['content'], key=lambda i: i['name']) - contents['content'] = filter(allowed_content, contents['content']) - self.write(self.render_template('tree.html', - page_title=page_title, - notebook_path=path, - breadcrumbs=breadcrumbs, - contents=contents, - terminals_available=False, - server_root=get_server_root_dir(self.settings))) - elif cm.file_exists(path): - # it's not a directory, we have redirecting to do - model = cm.get(path, content=False) - # redirect to /api/notebooks if it's a notebook, otherwise /api/files - service = 'notebooks' if model['type'] == 'notebook' else 'files' - url = url_path_join( - self.base_url, service, url_escape(path), - ) - self.log.debug("Redirecting %s to %s", self.request.path, url) - self.redirect(url) - else: - raise web.HTTPError(404) + return _get(self, path=path)