handlers.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """Tornado handlers for nbconvert."""
  2. # Copyright (c) Jupyter Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. import io
  5. import os
  6. import sys
  7. import zipfile
  8. from anyio.to_thread import run_sync
  9. from jupyter_core.utils import ensure_async
  10. from nbformat import from_dict
  11. from tornado import web
  12. from tornado.log import app_log
  13. from jupyter_server.auth.decorator import authorized
  14. from ..base.handlers import FilesRedirectHandler, JupyterHandler, path_regex
  15. AUTH_RESOURCE = "nbconvert"
  16. # datetime.strftime date format for jupyter
  17. # inlined from ipython_genutils
  18. if sys.platform == "win32":
  19. date_format = "%B %d, %Y"
  20. else:
  21. date_format = "%B %-d, %Y"
  22. def find_resource_files(output_files_dir):
  23. """Find the resource files in a directory."""
  24. files = []
  25. for dirpath, _, filenames in os.walk(output_files_dir):
  26. files.extend([os.path.join(dirpath, f) for f in filenames])
  27. return files
  28. def respond_zip(handler, name, output, resources):
  29. """Zip up the output and resource files and respond with the zip file.
  30. Returns True if it has served a zip file, False if there are no resource
  31. files, in which case we serve the plain output file.
  32. """
  33. # Check if we have resource files we need to zip
  34. output_files = resources.get("outputs", None)
  35. if not output_files:
  36. return False
  37. # Headers
  38. zip_filename = os.path.splitext(name)[0] + ".zip"
  39. handler.set_attachment_header(zip_filename)
  40. handler.set_header("Content-Type", "application/zip")
  41. handler.set_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
  42. # Prepare the zip file
  43. buffer = io.BytesIO()
  44. zipf = zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED)
  45. output_filename = os.path.splitext(name)[0] + resources["output_extension"]
  46. zipf.writestr(output_filename, output.encode("utf-8"))
  47. for filename, data in output_files.items():
  48. zipf.writestr(os.path.basename(filename), data)
  49. zipf.close()
  50. handler.finish(buffer.getvalue())
  51. return True
  52. def get_exporter(format, **kwargs):
  53. """get an exporter, raising appropriate errors"""
  54. # if this fails, will raise 500
  55. try:
  56. from nbconvert.exporters.base import get_exporter
  57. except ImportError as e:
  58. raise web.HTTPError(500, "Could not import nbconvert: %s" % e) from e
  59. try:
  60. exporter = get_exporter(format)
  61. except KeyError as e:
  62. # should this be 400?
  63. raise web.HTTPError(404, "No exporter for format: %s" % format) from e
  64. try:
  65. return exporter(**kwargs)
  66. except Exception as e:
  67. app_log.exception("Could not construct Exporter: %s", exporter)
  68. raise web.HTTPError(500, "Could not construct Exporter: %s" % e) from e
  69. class NbconvertFileHandler(JupyterHandler):
  70. """An nbconvert file handler."""
  71. auth_resource = AUTH_RESOURCE
  72. SUPPORTED_METHODS = ("GET",)
  73. @web.authenticated
  74. @authorized
  75. async def get(self, format, path):
  76. """Get a notebook file in a desired format."""
  77. self.check_xsrf_cookie()
  78. exporter = get_exporter(format, config=self.config, log=self.log)
  79. path = path.strip("/")
  80. # If the notebook relates to a real file (default contents manager),
  81. # give its path to nbconvert.
  82. if hasattr(self.contents_manager, "_get_os_path"):
  83. os_path = self.contents_manager._get_os_path(path)
  84. ext_resources_dir, basename = os.path.split(os_path)
  85. else:
  86. ext_resources_dir = None
  87. model = await ensure_async(self.contents_manager.get(path=path))
  88. name = model["name"]
  89. if model["type"] != "notebook":
  90. # not a notebook, redirect to files
  91. return FilesRedirectHandler.redirect_to_files(self, path)
  92. nb = model["content"]
  93. self.set_header("Last-Modified", model["last_modified"])
  94. # create resources dictionary
  95. mod_date = model["last_modified"].strftime(date_format)
  96. nb_title = os.path.splitext(name)[0]
  97. resource_dict = {
  98. "metadata": {"name": nb_title, "modified_date": mod_date},
  99. "config_dir": self.application.settings["config_dir"],
  100. }
  101. if ext_resources_dir:
  102. resource_dict["metadata"]["path"] = ext_resources_dir
  103. # Exporting can take a while, delegate to a thread so we don't block the event loop
  104. try:
  105. output, resources = await run_sync(
  106. lambda: exporter.from_notebook_node(nb, resources=resource_dict)
  107. )
  108. except Exception as e:
  109. self.log.exception("nbconvert failed: %r", e)
  110. raise web.HTTPError(500, "nbconvert failed: %s" % e) from e
  111. if respond_zip(self, name, output, resources):
  112. return None
  113. # Force download if requested
  114. if self.get_argument("download", "false").lower() == "true":
  115. filename = os.path.splitext(name)[0] + resources["output_extension"]
  116. self.set_attachment_header(filename)
  117. # MIME type
  118. if exporter.output_mimetype:
  119. self.set_header("Content-Type", "%s; charset=utf-8" % exporter.output_mimetype)
  120. self.set_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
  121. self.finish(output)
  122. class NbconvertPostHandler(JupyterHandler):
  123. """An nbconvert post handler."""
  124. SUPPORTED_METHODS = ("POST",)
  125. auth_resource = AUTH_RESOURCE
  126. @web.authenticated
  127. @authorized
  128. async def post(self, format):
  129. """Convert a notebook file to a desired format."""
  130. exporter = get_exporter(format, config=self.config)
  131. model = self.get_json_body()
  132. assert model is not None
  133. name = model.get("name", "notebook.ipynb")
  134. nbnode = from_dict(model["content"])
  135. try:
  136. output, resources = await run_sync(
  137. lambda: exporter.from_notebook_node(
  138. nbnode,
  139. resources={
  140. "metadata": {"name": name[: name.rfind(".")]},
  141. "config_dir": self.application.settings["config_dir"],
  142. },
  143. )
  144. )
  145. except Exception as e:
  146. raise web.HTTPError(500, "nbconvert failed: %s" % e) from e
  147. if respond_zip(self, name, output, resources):
  148. return
  149. # MIME type
  150. if exporter.output_mimetype:
  151. self.set_header("Content-Type", "%s; charset=utf-8" % exporter.output_mimetype)
  152. self.finish(output)
  153. # -----------------------------------------------------------------------------
  154. # URL to handler mappings
  155. # -----------------------------------------------------------------------------
  156. _format_regex = r"(?P<format>\w+)"
  157. default_handlers = [
  158. (r"/nbconvert/%s" % _format_regex, NbconvertPostHandler),
  159. (rf"/nbconvert/{_format_regex}{path_regex}", NbconvertFileHandler),
  160. ]