handlers.py
342 lines
| 11.2 KiB
| text/x-python
|
PythonLexer
MinRK
|
r17524 | """Tornado handlers for the contents web service.""" | ||
Brian E. Granger
|
r10641 | |||
MinRK
|
r17524 | # Copyright (c) IPython Development Team. | ||
# Distributed under the terms of the Modified BSD License. | ||||
Brian E. Granger
|
r10641 | |||
Zachary Sailer
|
r13045 | import json | ||
Brian E. Granger
|
r10641 | |||
Min RK
|
r19595 | from tornado import gen, web | ||
Brian E. Granger
|
r10641 | |||
MinRK
|
r13132 | from IPython.html.utils import url_path_join, url_escape | ||
Brian E. Granger
|
r10641 | from IPython.utils.jsonutil import date_default | ||
MinRK
|
r18749 | from IPython.html.base.handlers import ( | ||
Min RK
|
r21469 | IPythonHandler, APIHandler, json_errors, path_regex, | ||
MinRK
|
r18749 | ) | ||
Brian E. Granger
|
r10641 | |||
Zachary Sailer
|
r12984 | |||
MinRK
|
r17529 | def sort_key(model): | ||
"""key function for case-insensitive sort by name and type""" | ||||
iname = model['name'].lower() | ||||
type_key = { | ||||
'directory' : '0', | ||||
'notebook' : '1', | ||||
'file' : '2', | ||||
}.get(model['type'], '9') | ||||
return u'%s%s' % (type_key, iname) | ||||
Scott Sanderson
|
r19395 | |||
def validate_model(model, expect_content): | ||||
Scott Sanderson
|
r19396 | """ | ||
Validate a model returned by a ContentsManager method. | ||||
If expect_content is True, then we expect non-null entries for 'content' | ||||
and 'format'. | ||||
""" | ||||
Scott Sanderson
|
r19395 | required_keys = { | ||
Scott Sanderson
|
r19399 | "name", | ||
"path", | ||||
"type", | ||||
"writable", | ||||
"created", | ||||
"last_modified", | ||||
"mimetype", | ||||
"content", | ||||
"format", | ||||
Scott Sanderson
|
r19395 | } | ||
missing = required_keys - set(model.keys()) | ||||
if missing: | ||||
raise web.HTTPError( | ||||
500, | ||||
u"Missing Model Keys: {missing}".format(missing=missing), | ||||
) | ||||
maybe_none_keys = ['content', 'format'] | ||||
Scott Sanderson
|
r19399 | if model['type'] == 'file': | ||
# mimetype should be populated only for file models | ||||
maybe_none_keys.append('mimetype') | ||||
Scott Sanderson
|
r19395 | if expect_content: | ||
errors = [key for key in maybe_none_keys if model[key] is None] | ||||
if errors: | ||||
raise web.HTTPError( | ||||
500, | ||||
u"Keys unexpectedly None: {keys}".format(keys=errors), | ||||
) | ||||
else: | ||||
Scott Sanderson
|
r19399 | errors = { | ||
key: model[key] | ||||
for key in maybe_none_keys | ||||
if model[key] is not None | ||||
} | ||||
Scott Sanderson
|
r19395 | if errors: | ||
raise web.HTTPError( | ||||
500, | ||||
u"Keys unexpectedly not None: {keys}".format(keys=errors), | ||||
) | ||||
Min RK
|
r21469 | class ContentsHandler(APIHandler): | ||
Paul Ivanov
|
r13042 | |||
Zachary Sailer
|
r13045 | SUPPORTED_METHODS = (u'GET', u'PUT', u'PATCH', u'POST', u'DELETE') | ||
MinRK
|
r18749 | def location_url(self, path): | ||
MinRK
|
r17524 | """Return the full URL location of a file. | ||
MinRK
|
r17523 | |||
Zachary Sailer
|
r13045 | Parameters | ||
---------- | ||||
path : unicode | ||||
MinRK
|
r18749 | The API path of the file, such as "foo/bar.txt". | ||
Zachary Sailer
|
r13045 | """ | ||
MinRK
|
r13132 | return url_escape(url_path_join( | ||
MinRK
|
r18749 | self.base_url, 'api', 'contents', path | ||
MinRK
|
r13132 | )) | ||
Paul Ivanov
|
r13042 | |||
MinRK
|
r13129 | def _finish_model(self, model, location=True): | ||
"""Finish a JSON request with a model, setting relevant headers, etc.""" | ||||
if location: | ||||
MinRK
|
r18749 | location = self.location_url(model['path']) | ||
MinRK
|
r13129 | self.set_header('Location', location) | ||
self.set_header('Last-Modified', model['last_modified']) | ||||
Min RK
|
r19313 | self.set_header('Content-Type', 'application/json') | ||
MinRK
|
r13129 | self.finish(json.dumps(model, default=date_default)) | ||
MinRK
|
r17523 | |||
Paul Ivanov
|
r13042 | @web.authenticated | ||
Zachary Sailer
|
r13045 | @json_errors | ||
Min RK
|
r19595 | @gen.coroutine | ||
MinRK
|
r18749 | def get(self, path=''): | ||
MinRK
|
r17535 | """Return a model for a file or directory. | ||
Brian E. Granger
|
r13113 | |||
MinRK
|
r17535 | A directory model contains a list of models (without content) | ||
of the files and directories it contains. | ||||
MinRK
|
r13067 | """ | ||
MinRK
|
r17525 | path = path or '' | ||
Min RK
|
r19391 | type = self.get_query_argument('type', default=None) | ||
if type not in {None, 'directory', 'file', 'notebook'}: | ||||
raise web.HTTPError(400, u'Type %r is invalid' % type) | ||||
Thomas Kluyver
|
r18781 | |||
Scott Sanderson
|
r19349 | format = self.get_query_argument('format', default=None) | ||
Thomas Kluyver
|
r18788 | if format not in {None, 'text', 'base64'}: | ||
raise web.HTTPError(400, u'Format %r is invalid' % format) | ||||
Min RK
|
r20134 | content = self.get_query_argument('content', default='1') | ||
if content not in {'0', '1'}: | ||||
raise web.HTTPError(400, u'Content %r is invalid' % content) | ||||
content = int(content) | ||||
model = yield gen.maybe_future(self.contents_manager.get( | ||||
path=path, type=type, format=format, content=content, | ||||
)) | ||||
if model['type'] == 'directory' and content: | ||||
MinRK
|
r17529 | # group listing by type, then by name (case-insensitive) | ||
MinRK
|
r17535 | # FIXME: sorting should be done in the frontends | ||
MinRK
|
r17529 | model['content'].sort(key=sort_key) | ||
Min RK
|
r20134 | validate_model(model, expect_content=content) | ||
MinRK
|
r13129 | self._finish_model(model, location=False) | ||
Brian E. Granger
|
r10641 | |||
Paul Ivanov
|
r13042 | @web.authenticated | ||
MinRK
|
r13067 | @json_errors | ||
Min RK
|
r19595 | @gen.coroutine | ||
MinRK
|
r18749 | def patch(self, path=''): | ||
"""PATCH renames a file or directory without re-uploading content.""" | ||||
MinRK
|
r17524 | cm = self.contents_manager | ||
Zachary Sailer
|
r13045 | model = self.get_json_body() | ||
if model is None: | ||||
raise web.HTTPError(400, u'JSON body missing') | ||||
Min RK
|
r19595 | model = yield gen.maybe_future(cm.update(model, path)) | ||
Scott Sanderson
|
r19395 | validate_model(model, expect_content=False) | ||
MinRK
|
r13129 | self._finish_model(model) | ||
Min RK
|
r19595 | |||
@gen.coroutine | ||||
MinRK
|
r18749 | def _copy(self, copy_from, copy_to=None): | ||
Min RK
|
r18758 | """Copy a file, optionally specifying a target directory.""" | ||
MinRK
|
r18749 | self.log.info(u"Copying {copy_from} to {copy_to}".format( | ||
MinRK
|
r17535 | copy_from=copy_from, | ||
copy_to=copy_to or '', | ||||
)) | ||||
Min RK
|
r19595 | model = yield gen.maybe_future(self.contents_manager.copy(copy_from, copy_to)) | ||
MinRK
|
r13129 | self.set_status(201) | ||
Scott Sanderson
|
r19395 | validate_model(model, expect_content=False) | ||
MinRK
|
r13129 | self._finish_model(model) | ||
MinRK
|
r17523 | |||
Min RK
|
r19595 | @gen.coroutine | ||
MinRK
|
r18749 | def _upload(self, model, path): | ||
"""Handle upload of a new file to path""" | ||||
self.log.info(u"Uploading file to %s", path) | ||||
Min RK
|
r19595 | model = yield gen.maybe_future(self.contents_manager.new(model, path)) | ||
MinRK
|
r13129 | self.set_status(201) | ||
Scott Sanderson
|
r19395 | validate_model(model, expect_content=False) | ||
MinRK
|
r13129 | self._finish_model(model) | ||
Min RK
|
r19595 | |||
@gen.coroutine | ||||
Min RK
|
r18759 | def _new_untitled(self, path, type='', ext=''): | ||
"""Create a new, empty untitled entity""" | ||||
Min RK
|
r18758 | self.log.info(u"Creating new %s in %s", type or 'file', path) | ||
Min RK
|
r19595 | model = yield gen.maybe_future(self.contents_manager.new_untitled(path=path, type=type, ext=ext)) | ||
MinRK
|
r13129 | self.set_status(201) | ||
Scott Sanderson
|
r19395 | validate_model(model, expect_content=False) | ||
MinRK
|
r13129 | self._finish_model(model) | ||
Min RK
|
r19595 | |||
@gen.coroutine | ||||
MinRK
|
r18749 | def _save(self, model, path): | ||
MinRK
|
r17524 | """Save an existing file.""" | ||
MinRK
|
r18749 | self.log.info(u"Saving file at %s", path) | ||
Min RK
|
r19595 | model = yield gen.maybe_future(self.contents_manager.save(model, path)) | ||
Scott Sanderson
|
r19395 | validate_model(model, expect_content=False) | ||
MinRK
|
r18749 | self._finish_model(model) | ||
MinRK
|
r17523 | |||
Paul Ivanov
|
r13042 | @web.authenticated | ||
Zachary Sailer
|
r13045 | @json_errors | ||
Min RK
|
r19595 | @gen.coroutine | ||
MinRK
|
r18749 | def post(self, path=''): | ||
Min RK
|
r18758 | """Create a new file in the specified path. | ||
MinRK
|
r17523 | |||
Min RK
|
r18758 | POST creates new files. The server always decides on the name. | ||
MinRK
|
r17523 | |||
MinRK
|
r17524 | POST /api/contents/path | ||
MinRK
|
r18749 | New untitled, empty file or directory. | ||
MinRK
|
r17527 | POST /api/contents/path | ||
MinRK
|
r18749 | with body {"copy_from" : "/path/to/OtherNotebook.ipynb"} | ||
Thomas Kluyver
|
r13597 | New copy of OtherNotebook in path | ||
MinRK
|
r13074 | """ | ||
MinRK
|
r17523 | |||
MinRK
|
r17525 | cm = self.contents_manager | ||
if cm.file_exists(path): | ||||
Min RK
|
r18758 | raise web.HTTPError(400, "Cannot POST to files, use PUT instead.") | ||
MinRK
|
r17523 | |||
MinRK
|
r18749 | if not cm.dir_exists(path): | ||
MinRK
|
r17525 | raise web.HTTPError(404, "No such directory: %s" % path) | ||
MinRK
|
r13137 | model = self.get_json_body() | ||
MinRK
|
r17523 | |||
MinRK
|
r13137 | if model is not None: | ||
copy_from = model.get('copy_from') | ||||
Min RK
|
r18758 | ext = model.get('ext', '') | ||
Min RK
|
r18759 | type = model.get('type', '') | ||
MinRK
|
r18749 | if copy_from: | ||
Min RK
|
r19595 | yield self._copy(copy_from, path) | ||
MinRK
|
r13137 | else: | ||
Min RK
|
r19595 | yield self._new_untitled(path, type=type, ext=ext) | ||
MinRK
|
r13129 | else: | ||
Min RK
|
r19595 | yield self._new_untitled(path) | ||
Zachary Sailer
|
r12997 | |||
@web.authenticated | ||||
Zachary Sailer
|
r13045 | @json_errors | ||
Min RK
|
r19595 | @gen.coroutine | ||
MinRK
|
r18749 | def put(self, path=''): | ||
MinRK
|
r17524 | """Saves the file in the location specified by name and path. | ||
MinRK
|
r17523 | |||
Thomas Kluyver
|
r13597 | PUT is very similar to POST, but the requester specifies the name, | ||
whereas with POST, the server picks the name. | ||||
MinRK
|
r17523 | |||
MinRK
|
r17524 | PUT /api/contents/path/Name.ipynb | ||
Thomas Kluyver
|
r13597 | Save notebook at ``path/Name.ipynb``. Notebook structure is specified | ||
in `content` key of JSON request body. If content is not specified, | ||||
create a new empty notebook. | ||||
MinRK
|
r13129 | """ | ||
Zachary Sailer
|
r13045 | model = self.get_json_body() | ||
MinRK
|
r13137 | if model: | ||
Min RK
|
r18750 | if model.get('copy_from'): | ||
raise web.HTTPError(400, "Cannot copy with PUT, only POST") | ||||
Min RK
|
r19595 | exists = yield gen.maybe_future(self.contents_manager.file_exists(path)) | ||
if exists: | ||||
yield gen.maybe_future(self._save(model, path)) | ||||
MinRK
|
r13129 | else: | ||
Min RK
|
r19595 | yield gen.maybe_future(self._upload(model, path)) | ||
MinRK
|
r13129 | else: | ||
Min RK
|
r19595 | yield gen.maybe_future(self._new_untitled(path)) | ||
Brian E. Granger
|
r10641 | |||
@web.authenticated | ||||
Zachary Sailer
|
r13045 | @json_errors | ||
Min RK
|
r19595 | @gen.coroutine | ||
MinRK
|
r18749 | def delete(self, path=''): | ||
MinRK
|
r17524 | """delete a file in the given path""" | ||
cm = self.contents_manager | ||||
MinRK
|
r18749 | self.log.warn('delete %s', path) | ||
Min RK
|
r19595 | yield gen.maybe_future(cm.delete(path)) | ||
Brian E. Granger
|
r10641 | self.set_status(204) | ||
self.finish() | ||||
Paul Ivanov
|
r13042 | |||
Min RK
|
r21469 | class CheckpointsHandler(APIHandler): | ||
MinRK
|
r17523 | |||
Paul Ivanov
|
r13042 | SUPPORTED_METHODS = ('GET', 'POST') | ||
MinRK
|
r17523 | |||
Zachary Sailer
|
r13015 | @web.authenticated | ||
Zachary Sailer
|
r13045 | @json_errors | ||
Min RK
|
r19595 | @gen.coroutine | ||
MinRK
|
r18749 | def get(self, path=''): | ||
MinRK
|
r17524 | """get lists checkpoints for a file""" | ||
cm = self.contents_manager | ||||
Min RK
|
r19595 | checkpoints = yield gen.maybe_future(cm.list_checkpoints(path)) | ||
Zachary Sailer
|
r13045 | data = json.dumps(checkpoints, default=date_default) | ||
Paul Ivanov
|
r13042 | self.finish(data) | ||
MinRK
|
r17523 | |||
Zachary Sailer
|
r13015 | @web.authenticated | ||
Zachary Sailer
|
r13045 | @json_errors | ||
Min RK
|
r19595 | @gen.coroutine | ||
MinRK
|
r18749 | def post(self, path=''): | ||
Paul Ivanov
|
r13042 | """post creates a new checkpoint""" | ||
MinRK
|
r17524 | cm = self.contents_manager | ||
Min RK
|
r19595 | checkpoint = yield gen.maybe_future(cm.create_checkpoint(path)) | ||
Zachary Sailer
|
r13045 | data = json.dumps(checkpoint, default=date_default) | ||
MinRK
|
r17524 | location = url_path_join(self.base_url, 'api/contents', | ||
MinRK
|
r18749 | path, 'checkpoints', checkpoint['id']) | ||
MinRK
|
r13132 | self.set_header('Location', url_escape(location)) | ||
Thomas Kluyver
|
r13110 | self.set_status(201) | ||
Paul Ivanov
|
r13042 | self.finish(data) | ||
Min RK
|
r21469 | class ModifyCheckpointsHandler(APIHandler): | ||
MinRK
|
r17523 | |||
Paul Ivanov
|
r13042 | SUPPORTED_METHODS = ('POST', 'DELETE') | ||
MinRK
|
r17523 | |||
Paul Ivanov
|
r13042 | @web.authenticated | ||
Zachary Sailer
|
r13045 | @json_errors | ||
Min RK
|
r19595 | @gen.coroutine | ||
MinRK
|
r18749 | def post(self, path, checkpoint_id): | ||
MinRK
|
r17524 | """post restores a file from a checkpoint""" | ||
cm = self.contents_manager | ||||
Min RK
|
r19595 | yield gen.maybe_future(cm.restore_checkpoint(checkpoint_id, path)) | ||
Paul Ivanov
|
r13042 | self.set_status(204) | ||
self.finish() | ||||
MinRK
|
r17523 | |||
Paul Ivanov
|
r13042 | @web.authenticated | ||
Zachary Sailer
|
r13045 | @json_errors | ||
Min RK
|
r19595 | @gen.coroutine | ||
MinRK
|
r18749 | def delete(self, path, checkpoint_id): | ||
MinRK
|
r17524 | """delete clears a checkpoint for a given file""" | ||
cm = self.contents_manager | ||||
Min RK
|
r19595 | yield gen.maybe_future(cm.delete_checkpoint(checkpoint_id, path)) | ||
Paul Ivanov
|
r13042 | self.set_status(204) | ||
self.finish() | ||||
MinRK
|
r17523 | |||
MinRK
|
r18262 | |||
class NotebooksRedirectHandler(IPythonHandler): | ||||
"""Redirect /api/notebooks to /api/contents""" | ||||
SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH', 'POST', 'DELETE') | ||||
def get(self, path): | ||||
self.log.warn("/api/notebooks is deprecated, use /api/contents") | ||||
self.redirect(url_path_join( | ||||
self.base_url, | ||||
'api/contents', | ||||
path | ||||
)) | ||||
put = patch = post = delete = get | ||||
Brian E. Granger
|
r10647 | #----------------------------------------------------------------------------- | ||
# URL to handler mappings | ||||
#----------------------------------------------------------------------------- | ||||
Paul Ivanov
|
r13042 | |||
_checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)" | ||||
Brian E. Granger
|
r10647 | |||
default_handlers = [ | ||||
MinRK
|
r18749 | (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler), | ||
(r"/api/contents%s/checkpoints/%s" % (path_regex, _checkpoint_id_regex), | ||||
MinRK
|
r17524 | ModifyCheckpointsHandler), | ||
(r"/api/contents%s" % path_regex, ContentsHandler), | ||||
MinRK
|
r18262 | (r"/api/notebooks/?(.*)", NotebooksRedirectHandler), | ||
Brian E. Granger
|
r10647 | ] | ||