handlers.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. """Tornado handlers for the contents web service.
  2. Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-27%3A-Contents-Service
  3. """
  4. # Copyright (c) Jupyter Development Team.
  5. # Distributed under the terms of the Modified BSD License.
  6. import json
  7. from tornado import gen, web
  8. from notebook.utils import url_path_join, url_escape
  9. from jupyter_client.jsonutil import date_default
  10. from notebook.base.handlers import (
  11. IPythonHandler, APIHandler, path_regex,
  12. )
  13. def validate_model(model, expect_content):
  14. """
  15. Validate a model returned by a ContentsManager method.
  16. If expect_content is True, then we expect non-null entries for 'content'
  17. and 'format'.
  18. """
  19. required_keys = {
  20. "name",
  21. "path",
  22. "type",
  23. "writable",
  24. "created",
  25. "last_modified",
  26. "mimetype",
  27. "content",
  28. "format",
  29. }
  30. missing = required_keys - set(model.keys())
  31. if missing:
  32. raise web.HTTPError(
  33. 500,
  34. u"Missing Model Keys: {missing}".format(missing=missing),
  35. )
  36. maybe_none_keys = ['content', 'format']
  37. if expect_content:
  38. errors = [key for key in maybe_none_keys if model[key] is None]
  39. if errors:
  40. raise web.HTTPError(
  41. 500,
  42. u"Keys unexpectedly None: {keys}".format(keys=errors),
  43. )
  44. else:
  45. errors = {
  46. key: model[key]
  47. for key in maybe_none_keys
  48. if model[key] is not None
  49. }
  50. if errors:
  51. raise web.HTTPError(
  52. 500,
  53. u"Keys unexpectedly not None: {keys}".format(keys=errors),
  54. )
  55. class ContentsHandler(APIHandler):
  56. def location_url(self, path):
  57. """Return the full URL location of a file.
  58. Parameters
  59. ----------
  60. path : unicode
  61. The API path of the file, such as "foo/bar.txt".
  62. """
  63. return url_path_join(
  64. self.base_url, 'api', 'contents', url_escape(path)
  65. )
  66. def _finish_model(self, model, location=True):
  67. """Finish a JSON request with a model, setting relevant headers, etc."""
  68. if location:
  69. location = self.location_url(model['path'])
  70. self.set_header('Location', location)
  71. self.set_header('Last-Modified', model['last_modified'])
  72. self.set_header('Content-Type', 'application/json')
  73. self.finish(json.dumps(model, default=date_default))
  74. @web.authenticated
  75. @gen.coroutine
  76. def get(self, path=''):
  77. """Return a model for a file or directory.
  78. A directory model contains a list of models (without content)
  79. of the files and directories it contains.
  80. """
  81. path = path or ''
  82. type = self.get_query_argument('type', default=None)
  83. if type not in {None, 'directory', 'file', 'notebook'}:
  84. raise web.HTTPError(400, u'Type %r is invalid' % type)
  85. format = self.get_query_argument('format', default=None)
  86. if format not in {None, 'text', 'base64'}:
  87. raise web.HTTPError(400, u'Format %r is invalid' % format)
  88. content = self.get_query_argument('content', default='1')
  89. if content not in {'0', '1'}:
  90. raise web.HTTPError(400, u'Content %r is invalid' % content)
  91. content = int(content)
  92. model = yield gen.maybe_future(self.contents_manager.get(
  93. path=path, type=type, format=format, content=content,
  94. ))
  95. validate_model(model, expect_content=content)
  96. self._finish_model(model, location=False)
  97. @web.authenticated
  98. @gen.coroutine
  99. def patch(self, path=''):
  100. """PATCH renames a file or directory without re-uploading content."""
  101. cm = self.contents_manager
  102. model = self.get_json_body()
  103. if model is None:
  104. raise web.HTTPError(400, u'JSON body missing')
  105. model = yield gen.maybe_future(cm.update(model, path))
  106. validate_model(model, expect_content=False)
  107. self._finish_model(model)
  108. @gen.coroutine
  109. def _copy(self, copy_from, copy_to=None):
  110. """Copy a file, optionally specifying a target directory."""
  111. self.log.info(u"Copying {copy_from} to {copy_to}".format(
  112. copy_from=copy_from,
  113. copy_to=copy_to or '',
  114. ))
  115. model = yield gen.maybe_future(self.contents_manager.copy(copy_from, copy_to))
  116. self.set_status(201)
  117. validate_model(model, expect_content=False)
  118. self._finish_model(model)
  119. @gen.coroutine
  120. def _upload(self, model, path):
  121. """Handle upload of a new file to path"""
  122. self.log.info(u"Uploading file to %s", path)
  123. model = yield gen.maybe_future(self.contents_manager.new(model, path))
  124. self.set_status(201)
  125. validate_model(model, expect_content=False)
  126. self._finish_model(model)
  127. @gen.coroutine
  128. def _new_untitled(self, path, type='', ext=''):
  129. """Create a new, empty untitled entity"""
  130. self.log.info(u"Creating new %s in %s", type or 'file', path)
  131. model = yield gen.maybe_future(self.contents_manager.new_untitled(path=path, type=type, ext=ext))
  132. self.set_status(201)
  133. validate_model(model, expect_content=False)
  134. self._finish_model(model)
  135. @gen.coroutine
  136. def _save(self, model, path):
  137. """Save an existing file."""
  138. chunk = model.get("chunk", None)
  139. if not chunk or chunk == -1: # Avoid tedious log information
  140. self.log.info(u"Saving file at %s", path)
  141. model = yield gen.maybe_future(self.contents_manager.save(model, path))
  142. validate_model(model, expect_content=False)
  143. self._finish_model(model)
  144. @web.authenticated
  145. @gen.coroutine
  146. def post(self, path=''):
  147. """Create a new file in the specified path.
  148. POST creates new files. The server always decides on the name.
  149. POST /api/contents/path
  150. New untitled, empty file or directory.
  151. POST /api/contents/path
  152. with body {"copy_from" : "/path/to/OtherNotebook.ipynb"}
  153. New copy of OtherNotebook in path
  154. """
  155. cm = self.contents_manager
  156. if cm.file_exists(path):
  157. raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
  158. if not cm.dir_exists(path):
  159. raise web.HTTPError(404, "No such directory: %s" % path)
  160. model = self.get_json_body()
  161. if model is not None:
  162. copy_from = model.get('copy_from')
  163. ext = model.get('ext', '')
  164. type = model.get('type', '')
  165. if copy_from:
  166. yield self._copy(copy_from, path)
  167. else:
  168. yield self._new_untitled(path, type=type, ext=ext)
  169. else:
  170. yield self._new_untitled(path)
  171. @web.authenticated
  172. @gen.coroutine
  173. def put(self, path=''):
  174. """Saves the file in the location specified by name and path.
  175. PUT is very similar to POST, but the requester specifies the name,
  176. whereas with POST, the server picks the name.
  177. PUT /api/contents/path/Name.ipynb
  178. Save notebook at ``path/Name.ipynb``. Notebook structure is specified
  179. in `content` key of JSON request body. If content is not specified,
  180. create a new empty notebook.
  181. """
  182. model = self.get_json_body()
  183. if model:
  184. if model.get('copy_from'):
  185. raise web.HTTPError(400, "Cannot copy with PUT, only POST")
  186. exists = yield gen.maybe_future(self.contents_manager.file_exists(path))
  187. if exists:
  188. yield gen.maybe_future(self._save(model, path))
  189. else:
  190. yield gen.maybe_future(self._upload(model, path))
  191. else:
  192. yield gen.maybe_future(self._new_untitled(path))
  193. @web.authenticated
  194. @gen.coroutine
  195. def delete(self, path=''):
  196. """delete a file in the given path"""
  197. cm = self.contents_manager
  198. self.log.warning('delete %s', path)
  199. yield gen.maybe_future(cm.delete(path))
  200. self.set_status(204)
  201. self.finish()
  202. class CheckpointsHandler(APIHandler):
  203. @web.authenticated
  204. @gen.coroutine
  205. def get(self, path=''):
  206. """get lists checkpoints for a file"""
  207. cm = self.contents_manager
  208. checkpoints = yield gen.maybe_future(cm.list_checkpoints(path))
  209. data = json.dumps(checkpoints, default=date_default)
  210. self.finish(data)
  211. @web.authenticated
  212. @gen.coroutine
  213. def post(self, path=''):
  214. """post creates a new checkpoint"""
  215. cm = self.contents_manager
  216. checkpoint = yield gen.maybe_future(cm.create_checkpoint(path))
  217. data = json.dumps(checkpoint, default=date_default)
  218. location = url_path_join(self.base_url, 'api/contents',
  219. url_escape(path), 'checkpoints', url_escape(checkpoint['id']))
  220. self.set_header('Location', location)
  221. self.set_status(201)
  222. self.finish(data)
  223. class ModifyCheckpointsHandler(APIHandler):
  224. @web.authenticated
  225. @gen.coroutine
  226. def post(self, path, checkpoint_id):
  227. """post restores a file from a checkpoint"""
  228. cm = self.contents_manager
  229. yield gen.maybe_future(cm.restore_checkpoint(checkpoint_id, path))
  230. self.set_status(204)
  231. self.finish()
  232. @web.authenticated
  233. @gen.coroutine
  234. def delete(self, path, checkpoint_id):
  235. """delete clears a checkpoint for a given file"""
  236. cm = self.contents_manager
  237. yield gen.maybe_future(cm.delete_checkpoint(checkpoint_id, path))
  238. self.set_status(204)
  239. self.finish()
  240. class NotebooksRedirectHandler(IPythonHandler):
  241. """Redirect /api/notebooks to /api/contents"""
  242. SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH', 'POST', 'DELETE')
  243. def get(self, path):
  244. self.log.warning("/api/notebooks is deprecated, use /api/contents")
  245. self.redirect(url_path_join(
  246. self.base_url,
  247. 'api/contents',
  248. path
  249. ))
  250. put = patch = post = delete = get
  251. class TrustNotebooksHandler(IPythonHandler):
  252. """ Handles trust/signing of notebooks """
  253. @web.authenticated
  254. @gen.coroutine
  255. def post(self,path=''):
  256. cm = self.contents_manager
  257. yield gen.maybe_future(cm.trust_notebook(path))
  258. self.set_status(201)
  259. self.finish()
  260. #-----------------------------------------------------------------------------
  261. # URL to handler mappings
  262. #-----------------------------------------------------------------------------
  263. _checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
  264. default_handlers = [
  265. (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler),
  266. (r"/api/contents%s/checkpoints/%s" % (path_regex, _checkpoint_id_regex),
  267. ModifyCheckpointsHandler),
  268. (r"/api/contents%s/trust" % path_regex, TrustNotebooksHandler),
  269. (r"/api/contents%s" % path_regex, ContentsHandler),
  270. (r"/api/notebooks/?(.*)", NotebooksRedirectHandler),
  271. ]