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