|
#############################################################################
|
|
# Copyright (c) 2018, Voila Contributors #
|
|
# Copyright (c) 2018, QuantStack #
|
|
# #
|
|
# Distributed under the terms of the BSD 3-Clause License. #
|
|
# #
|
|
# The full license is in the file LICENSE, distributed with this software. #
|
|
#############################################################################
|
|
|
|
import os
|
|
|
|
import tornado.web
|
|
|
|
from jupyter_server.base.handlers import JupyterHandler
|
|
from jupyter_server.config_manager import recursive_update
|
|
from jupyter_server.utils import url_path_join
|
|
import nbformat
|
|
|
|
from nbconvert.preprocessors import ClearOutputPreprocessor
|
|
|
|
from .execute import executenb, VoilaExecutePreprocessor
|
|
from .exporter import VoilaExporter
|
|
|
|
|
|
class VoilaHandler(JupyterHandler):
|
|
|
|
def initialize(self, **kwargs):
|
|
self.notebook_path = kwargs.pop('notebook_path', []) # should it be []
|
|
self.nbconvert_template_paths = kwargs.pop('nbconvert_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 voila 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 = []
|
|
|
|
self.notebook = await self.load_notebook(notebook_path)
|
|
if not self.notebook:
|
|
return
|
|
self.cwd = os.path.dirname(notebook_path)
|
|
|
|
path, basename = os.path.split(notebook_path)
|
|
notebook_name = os.path.splitext(basename)[0]
|
|
|
|
# render notebook to html
|
|
resources = {
|
|
'base_url': self.base_url,
|
|
'nbextensions': nbextensions,
|
|
'theme': self.voila_configuration.theme,
|
|
'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
|
|
if not isinstance(extra_resources, dict):
|
|
extra_resources = extra_resources.to_dict()
|
|
if extra_resources:
|
|
recursive_update(resources, extra_resources)
|
|
|
|
self.exporter = VoilaExporter(
|
|
template_path=self.nbconvert_template_paths,
|
|
config=self.traitlet_config,
|
|
contents_manager=self.contents_manager # for the image inlining
|
|
)
|
|
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 = {
|
|
# NOTE: we can remove the lambda is we use jinja's async feature, which will automatically await the future
|
|
'kernel_start': lambda: self._jinja_kernel_start().result(), # pass the result (not the future) to the template
|
|
'cell_generator': self._jinja_cell_generator,
|
|
'notebook_execute': self._jinja_notebook_execute,
|
|
}
|
|
|
|
# Currenly _jinja_kernel_start is executed from a different thread, which causes the websocket connection from
|
|
# the frontend to fail. Instead, we start it beforehand, and just return the kernel_id in _jinja_kernel_start
|
|
self.kernel_id = await tornado.gen.maybe_future(self.kernel_manager.start_kernel(kernel_name=self.notebook.metadata.kernelspec.name, path=self.cwd))
|
|
|
|
# Compose reply
|
|
self.set_header('Content-Type', 'text/html')
|
|
# 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(self.notebook, resources=resources, extra_context=extra_context):
|
|
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()
|
|
|
|
def redirect_to_file(self, path):
|
|
self.redirect(url_path_join(self.base_url, 'voila', 'files', path))
|
|
|
|
@tornado.gen.coroutine
|
|
def _jinja_kernel_start(self):
|
|
assert not self.kernel_started, "kernel was already started"
|
|
# See command above aboout not being able to start the kernel from a different thread
|
|
self.kernel_started = True
|
|
return self.kernel_id
|
|
|
|
def _jinja_notebook_execute(self, nb, kernel_id):
|
|
km = self.kernel_manager.get_kernel(kernel_id)
|
|
result = executenb(nb, km=km, cwd=self.cwd, config=self.traitlet_config)
|
|
# we modify the notebook in place, since the nb variable cannot be reassigned it seems in jinja2
|
|
# e.g. if we do {% with nb = notebook_execute(nb, kernel_id) %}, the base template/blocks will not
|
|
# see the updated variable (it seems to be local to our block)
|
|
nb.cells = result.cells
|
|
|
|
def _jinja_cell_generator(self, nb, kernel_id):
|
|
"""Generator that will execute a single notebook cell at a time"""
|
|
km = self.kernel_manager.get_kernel(kernel_id)
|
|
|
|
nb, resources = ClearOutputPreprocessor().preprocess(nb, {'metadata': {'path': self.cwd}})
|
|
ep = VoilaExecutePreprocessor(config=self.traitlet_config)
|
|
|
|
with ep.setup_preprocessor(nb, resources, km=km):
|
|
for cell_idx, cell in enumerate(nb.cells):
|
|
res = ep.preprocess_cell(cell, resources, cell_idx, store_history=False)
|
|
|
|
yield res[0]
|
|
|
|
# @tornado.gen.coroutine
|
|
async def load_notebook(self, path):
|
|
model = self.contents_manager.get(path=path)
|
|
if 'content' not in model:
|
|
raise tornado.web.HTTPError(404, 'file not found')
|
|
__, extension = os.path.splitext(model.get('path', ''))
|
|
if model.get('type') == 'notebook':
|
|
notebook = model['content']
|
|
notebook = await self.fix_notebook(notebook)
|
|
return notebook
|
|
elif extension in self.voila_configuration.extension_language_mapping:
|
|
language = self.voila_configuration.extension_language_mapping[extension]
|
|
notebook = await self.create_notebook(model, language=language)
|
|
return notebook
|
|
else:
|
|
self.redirect_to_file(path)
|
|
return None
|
|
|
|
async def fix_notebook(self, notebook):
|
|
"""Returns a notebook object with a valid kernelspec.
|
|
|
|
In case the kernel is not found, we search for a matching kernel based on the language.
|
|
"""
|
|
|
|
# Fetch kernel name from the notebook metadata
|
|
if 'kernelspec' not in notebook.metadata:
|
|
notebook.metadata.kernelspec = nbformat.NotebookNode()
|
|
kernelspec = notebook.metadata.kernelspec
|
|
kernel_name = kernelspec.get('name', self.kernel_manager.default_kernel_name)
|
|
# We use `maybe_future` to support RemoteKernelSpecManager
|
|
all_kernel_specs = await tornado.gen.maybe_future(self.kernel_spec_manager.get_all_specs())
|
|
# Find a spec matching the language if the kernel name does not exist in the kernelspecs
|
|
if kernel_name not in all_kernel_specs:
|
|
missing_kernel_name = kernel_name
|
|
kernel_name = await self.find_kernel_name_for_language(kernelspec.language.lower(), kernel_specs=all_kernel_specs)
|
|
self.log.warning('Could not find a kernel named %r, will use %r', missing_kernel_name, kernel_name)
|
|
# We make sure the notebook's kernelspec is correct
|
|
notebook.metadata.kernelspec.name = kernel_name
|
|
notebook.metadata.kernelspec.display_name = all_kernel_specs[kernel_name]['spec']['display_name']
|
|
notebook.metadata.kernelspec.language = all_kernel_specs[kernel_name]['spec']['language']
|
|
return notebook
|
|
|
|
async def create_notebook(self, model, language):
|
|
all_kernel_specs = await tornado.gen.maybe_future(self.kernel_spec_manager.get_all_specs())
|
|
kernel_name = await self.find_kernel_name_for_language(language, kernel_specs=all_kernel_specs)
|
|
spec = all_kernel_specs[kernel_name]
|
|
notebook = nbformat.v4.new_notebook(
|
|
metadata={
|
|
'kernelspec': {
|
|
'display_name': spec['spec']['display_name'],
|
|
'language': spec['spec']['language'],
|
|
'name': kernel_name
|
|
}
|
|
},
|
|
cells=[nbformat.v4.new_code_cell(model['content'])],
|
|
)
|
|
return notebook
|
|
|
|
async def find_kernel_name_for_language(self, kernel_language, kernel_specs=None):
|
|
"""Finds a best matching kernel name given a kernel language.
|
|
|
|
If multiple kernels matches are found, we try to return the same kernel name each time.
|
|
"""
|
|
if kernel_language in self.voila_configuration.language_kernel_mapping:
|
|
return self.voila_configuration.language_kernel_mapping[kernel_language]
|
|
if kernel_specs is None:
|
|
kernel_specs = await tornado.gen.maybe_future(self.kernel_spec_manager.get_all_specs())
|
|
matches = [
|
|
name for name, kernel in kernel_specs.items()
|
|
if kernel["spec"]["language"].lower() == kernel_language.lower()
|
|
]
|
|
if matches:
|
|
# Sort by display name to get the same kernel each time.
|
|
matches.sort(key=lambda name: kernel_specs[name]["spec"]["display_name"])
|
|
return matches[0]
|
|
else:
|
|
raise tornado.web.HTTPError(500, 'No Jupyter kernel for language %r found' % kernel_language)
|