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

4 years ago
  1. #############################################################################
  2. # Copyright (c) 2018, Voila Contributors #
  3. # Copyright (c) 2018, QuantStack #
  4. # #
  5. # Distributed under the terms of the BSD 3-Clause License. #
  6. # #
  7. # The full license is in the file LICENSE, distributed with this software. #
  8. #############################################################################
  9. import os
  10. import tornado.web
  11. from jupyter_server.base.handlers import JupyterHandler
  12. from jupyter_server.config_manager import recursive_update
  13. from jupyter_server.utils import url_path_join
  14. import nbformat
  15. from nbconvert.preprocessors import ClearOutputPreprocessor
  16. from .execute import executenb, VoilaExecutePreprocessor
  17. from .exporter import VoilaExporter
  18. class VoilaHandler(JupyterHandler):
  19. def initialize(self, **kwargs):
  20. self.notebook_path = kwargs.pop('notebook_path', []) # should it be []
  21. self.nbconvert_template_paths = kwargs.pop('nbconvert_template_paths', [])
  22. self.traitlet_config = kwargs.pop('config', None)
  23. self.voila_configuration = kwargs['voila_configuration']
  24. # we want to avoid starting multiple kernels due to template mistakes
  25. self.kernel_started = False
  26. @tornado.web.authenticated
  27. async def get(self, path=None):
  28. # if the handler got a notebook_path argument, always serve that
  29. notebook_path = self.notebook_path or path
  30. if self.notebook_path and path: # when we are in single notebook mode but have a path
  31. self.redirect_to_file(path)
  32. return
  33. if self.voila_configuration.enable_nbextensions:
  34. # generate a list of nbextensions that are enabled for the classical notebook
  35. # a template can use that to load classical notebook extensions, but does not have to
  36. notebook_config = self.config_manager.get('notebook')
  37. # except for the widget extension itself, since voila has its own
  38. load_extensions = notebook_config.get('load_extensions', {})
  39. if 'jupyter-js-widgets/extension' in load_extensions:
  40. load_extensions['jupyter-js-widgets/extension'] = False
  41. if 'voila/extension' in load_extensions:
  42. load_extensions['voila/extension'] = False
  43. nbextensions = [name for name, enabled in load_extensions.items() if enabled]
  44. else:
  45. nbextensions = []
  46. self.notebook = await self.load_notebook(notebook_path)
  47. if not self.notebook:
  48. return
  49. self.cwd = os.path.dirname(notebook_path)
  50. path, basename = os.path.split(notebook_path)
  51. notebook_name = os.path.splitext(basename)[0]
  52. # render notebook to html
  53. resources = {
  54. 'base_url': self.base_url,
  55. 'nbextensions': nbextensions,
  56. 'theme': self.voila_configuration.theme,
  57. 'metadata': {
  58. 'name': notebook_name
  59. }
  60. }
  61. # include potential extra resources
  62. extra_resources = self.voila_configuration.config.VoilaConfiguration.resources
  63. # if no resources get configured from neither the CLI nor a config file,
  64. # extra_resources is a traitlets.config.loader.LazyConfigValue object
  65. if not isinstance(extra_resources, dict):
  66. extra_resources = extra_resources.to_dict()
  67. if extra_resources:
  68. recursive_update(resources, extra_resources)
  69. self.exporter = VoilaExporter(
  70. template_path=self.nbconvert_template_paths,
  71. config=self.traitlet_config,
  72. contents_manager=self.contents_manager # for the image inlining
  73. )
  74. if self.voila_configuration.strip_sources:
  75. self.exporter.exclude_input = True
  76. self.exporter.exclude_output_prompt = True
  77. self.exporter.exclude_input_prompt = True
  78. # These functions allow the start of a kernel and execution of the notebook after (parts of) the template
  79. # has been rendered and send to the client to allow progressive rendering.
  80. # Template should first call kernel_start, and then decide to use notebook_execute
  81. # or cell_generator to implement progressive cell rendering
  82. extra_context = {
  83. # NOTE: we can remove the lambda is we use jinja's async feature, which will automatically await the future
  84. 'kernel_start': lambda: self._jinja_kernel_start().result(), # pass the result (not the future) to the template
  85. 'cell_generator': self._jinja_cell_generator,
  86. 'notebook_execute': self._jinja_notebook_execute,
  87. }
  88. # Currenly _jinja_kernel_start is executed from a different thread, which causes the websocket connection from
  89. # the frontend to fail. Instead, we start it beforehand, and just return the kernel_id in _jinja_kernel_start
  90. self.kernel_id = await tornado.gen.maybe_future(self.kernel_manager.start_kernel(kernel_name=self.notebook.metadata.kernelspec.name, path=self.cwd))
  91. # Compose reply
  92. self.set_header('Content-Type', 'text/html')
  93. # render notebook in snippets, and flush them out to the browser can render progresssively
  94. async for html_snippet, resources in self.exporter.generate_from_notebook_node(self.notebook, resources=resources, extra_context=extra_context):
  95. self.write(html_snippet)
  96. self.flush() # we may not want to consider not flusing after each snippet, but add an explicit flush function to the jinja context
  97. # yield # give control back to tornado's IO loop, so it can handle static files or other requests
  98. self.flush()
  99. def redirect_to_file(self, path):
  100. self.redirect(url_path_join(self.base_url, 'voila', 'files', path))
  101. @tornado.gen.coroutine
  102. def _jinja_kernel_start(self):
  103. assert not self.kernel_started, "kernel was already started"
  104. # See command above aboout not being able to start the kernel from a different thread
  105. self.kernel_started = True
  106. return self.kernel_id
  107. def _jinja_notebook_execute(self, nb, kernel_id):
  108. km = self.kernel_manager.get_kernel(kernel_id)
  109. result = executenb(nb, km=km, cwd=self.cwd, config=self.traitlet_config)
  110. # we modify the notebook in place, since the nb variable cannot be reassigned it seems in jinja2
  111. # e.g. if we do {% with nb = notebook_execute(nb, kernel_id) %}, the base template/blocks will not
  112. # see the updated variable (it seems to be local to our block)
  113. nb.cells = result.cells
  114. def _jinja_cell_generator(self, nb, kernel_id):
  115. """Generator that will execute a single notebook cell at a time"""
  116. km = self.kernel_manager.get_kernel(kernel_id)
  117. nb, resources = ClearOutputPreprocessor().preprocess(nb, {'metadata': {'path': self.cwd}})
  118. ep = VoilaExecutePreprocessor(config=self.traitlet_config)
  119. with ep.setup_preprocessor(nb, resources, km=km):
  120. for cell_idx, cell in enumerate(nb.cells):
  121. res = ep.preprocess_cell(cell, resources, cell_idx, store_history=False)
  122. yield res[0]
  123. # @tornado.gen.coroutine
  124. async def load_notebook(self, path):
  125. model = self.contents_manager.get(path=path)
  126. if 'content' not in model:
  127. raise tornado.web.HTTPError(404, 'file not found')
  128. __, extension = os.path.splitext(model.get('path', ''))
  129. if model.get('type') == 'notebook':
  130. notebook = model['content']
  131. notebook = await self.fix_notebook(notebook)
  132. return notebook
  133. elif extension in self.voila_configuration.extension_language_mapping:
  134. language = self.voila_configuration.extension_language_mapping[extension]
  135. notebook = await self.create_notebook(model, language=language)
  136. return notebook
  137. else:
  138. self.redirect_to_file(path)
  139. return None
  140. async def fix_notebook(self, notebook):
  141. """Returns a notebook object with a valid kernelspec.
  142. In case the kernel is not found, we search for a matching kernel based on the language.
  143. """
  144. # Fetch kernel name from the notebook metadata
  145. if 'kernelspec' not in notebook.metadata:
  146. notebook.metadata.kernelspec = nbformat.NotebookNode()
  147. kernelspec = notebook.metadata.kernelspec
  148. kernel_name = kernelspec.get('name', self.kernel_manager.default_kernel_name)
  149. # We use `maybe_future` to support RemoteKernelSpecManager
  150. all_kernel_specs = await tornado.gen.maybe_future(self.kernel_spec_manager.get_all_specs())
  151. # Find a spec matching the language if the kernel name does not exist in the kernelspecs
  152. if kernel_name not in all_kernel_specs:
  153. missing_kernel_name = kernel_name
  154. kernel_name = await self.find_kernel_name_for_language(kernelspec.language.lower(), kernel_specs=all_kernel_specs)
  155. self.log.warning('Could not find a kernel named %r, will use %r', missing_kernel_name, kernel_name)
  156. # We make sure the notebook's kernelspec is correct
  157. notebook.metadata.kernelspec.name = kernel_name
  158. notebook.metadata.kernelspec.display_name = all_kernel_specs[kernel_name]['spec']['display_name']
  159. notebook.metadata.kernelspec.language = all_kernel_specs[kernel_name]['spec']['language']
  160. return notebook
  161. async def create_notebook(self, model, language):
  162. all_kernel_specs = await tornado.gen.maybe_future(self.kernel_spec_manager.get_all_specs())
  163. kernel_name = await self.find_kernel_name_for_language(language, kernel_specs=all_kernel_specs)
  164. spec = all_kernel_specs[kernel_name]
  165. notebook = nbformat.v4.new_notebook(
  166. metadata={
  167. 'kernelspec': {
  168. 'display_name': spec['spec']['display_name'],
  169. 'language': spec['spec']['language'],
  170. 'name': kernel_name
  171. }
  172. },
  173. cells=[nbformat.v4.new_code_cell(model['content'])],
  174. )
  175. return notebook
  176. async def find_kernel_name_for_language(self, kernel_language, kernel_specs=None):
  177. """Finds a best matching kernel name given a kernel language.
  178. If multiple kernels matches are found, we try to return the same kernel name each time.
  179. """
  180. if kernel_language in self.voila_configuration.language_kernel_mapping:
  181. return self.voila_configuration.language_kernel_mapping[kernel_language]
  182. if kernel_specs is None:
  183. kernel_specs = await tornado.gen.maybe_future(self.kernel_spec_manager.get_all_specs())
  184. matches = [
  185. name for name, kernel in kernel_specs.items()
  186. if kernel["spec"]["language"].lower() == kernel_language.lower()
  187. ]
  188. if matches:
  189. # Sort by display name to get the same kernel each time.
  190. matches.sort(key=lambda name: kernel_specs[name]["spec"]["display_name"])
  191. return matches[0]
  192. else:
  193. raise tornado.web.HTTPError(500, 'No Jupyter kernel for language %r found' % kernel_language)