You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

225 lines
11 KiB

#############################################################################
# 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)