##// END OF EJS Templates
Merge pull request #7136 from minrk/octet-stream...
Thomas Kluyver -
r19369:8730e4d0 merge
parent child Browse files
Show More
@@ -1,619 +1,622
1 """A contents manager that uses the local file system for storage."""
1 """A contents manager that uses the local file system for storage."""
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 base64
6 import base64
7 import errno
7 import errno
8 import io
8 import io
9 import os
9 import os
10 import shutil
10 import shutil
11 from contextlib import contextmanager
11 from contextlib import contextmanager
12 import mimetypes
12 import mimetypes
13
13
14 from tornado import web
14 from tornado import web
15
15
16 from .manager import ContentsManager
16 from .manager import ContentsManager
17 from IPython import nbformat
17 from IPython import nbformat
18 from IPython.utils.io import atomic_writing
18 from IPython.utils.io import atomic_writing
19 from IPython.utils.path import ensure_dir_exists
19 from IPython.utils.path import ensure_dir_exists
20 from IPython.utils.traitlets import Unicode, Bool, TraitError
20 from IPython.utils.traitlets import Unicode, Bool, TraitError
21 from IPython.utils.py3compat import getcwd, str_to_unicode
21 from IPython.utils.py3compat import getcwd, str_to_unicode
22 from IPython.utils import tz
22 from IPython.utils import tz
23 from IPython.html.utils import is_hidden, to_os_path, to_api_path
23 from IPython.html.utils import is_hidden, to_os_path, to_api_path
24
24
25
25
26 class FileContentsManager(ContentsManager):
26 class FileContentsManager(ContentsManager):
27
27
28 root_dir = Unicode(config=True)
28 root_dir = Unicode(config=True)
29
29
30 def _root_dir_default(self):
30 def _root_dir_default(self):
31 try:
31 try:
32 return self.parent.notebook_dir
32 return self.parent.notebook_dir
33 except AttributeError:
33 except AttributeError:
34 return getcwd()
34 return getcwd()
35
35
36 @contextmanager
36 @contextmanager
37 def perm_to_403(self, os_path=''):
37 def perm_to_403(self, os_path=''):
38 """context manager for turning permission errors into 403"""
38 """context manager for turning permission errors into 403"""
39 try:
39 try:
40 yield
40 yield
41 except OSError as e:
41 except OSError as e:
42 if e.errno in {errno.EPERM, errno.EACCES}:
42 if e.errno in {errno.EPERM, errno.EACCES}:
43 # make 403 error message without root prefix
43 # make 403 error message without root prefix
44 # this may not work perfectly on unicode paths on Python 2,
44 # this may not work perfectly on unicode paths on Python 2,
45 # but nobody should be doing that anyway.
45 # but nobody should be doing that anyway.
46 if not os_path:
46 if not os_path:
47 os_path = str_to_unicode(e.filename or 'unknown file')
47 os_path = str_to_unicode(e.filename or 'unknown file')
48 path = to_api_path(os_path, self.root_dir)
48 path = to_api_path(os_path, self.root_dir)
49 raise web.HTTPError(403, u'Permission denied: %s' % path)
49 raise web.HTTPError(403, u'Permission denied: %s' % path)
50 else:
50 else:
51 raise
51 raise
52
52
53 @contextmanager
53 @contextmanager
54 def open(self, os_path, *args, **kwargs):
54 def open(self, os_path, *args, **kwargs):
55 """wrapper around io.open that turns permission errors into 403"""
55 """wrapper around io.open that turns permission errors into 403"""
56 with self.perm_to_403(os_path):
56 with self.perm_to_403(os_path):
57 with io.open(os_path, *args, **kwargs) as f:
57 with io.open(os_path, *args, **kwargs) as f:
58 yield f
58 yield f
59
59
60 @contextmanager
60 @contextmanager
61 def atomic_writing(self, os_path, *args, **kwargs):
61 def atomic_writing(self, os_path, *args, **kwargs):
62 """wrapper around atomic_writing that turns permission errors into 403"""
62 """wrapper around atomic_writing that turns permission errors into 403"""
63 with self.perm_to_403(os_path):
63 with self.perm_to_403(os_path):
64 with atomic_writing(os_path, *args, **kwargs) as f:
64 with atomic_writing(os_path, *args, **kwargs) as f:
65 yield f
65 yield f
66
66
67 save_script = Bool(False, config=True, help='DEPRECATED, IGNORED')
67 save_script = Bool(False, config=True, help='DEPRECATED, IGNORED')
68 def _save_script_changed(self):
68 def _save_script_changed(self):
69 self.log.warn("""
69 self.log.warn("""
70 Automatically saving notebooks as scripts has been removed.
70 Automatically saving notebooks as scripts has been removed.
71 Use `ipython nbconvert --to python [notebook]` instead.
71 Use `ipython nbconvert --to python [notebook]` instead.
72 """)
72 """)
73
73
74 def _root_dir_changed(self, name, old, new):
74 def _root_dir_changed(self, name, old, new):
75 """Do a bit of validation of the root_dir."""
75 """Do a bit of validation of the root_dir."""
76 if not os.path.isabs(new):
76 if not os.path.isabs(new):
77 # If we receive a non-absolute path, make it absolute.
77 # If we receive a non-absolute path, make it absolute.
78 self.root_dir = os.path.abspath(new)
78 self.root_dir = os.path.abspath(new)
79 return
79 return
80 if not os.path.isdir(new):
80 if not os.path.isdir(new):
81 raise TraitError("%r is not a directory" % new)
81 raise TraitError("%r is not a directory" % new)
82
82
83 checkpoint_dir = Unicode('.ipynb_checkpoints', config=True,
83 checkpoint_dir = Unicode('.ipynb_checkpoints', config=True,
84 help="""The directory name in which to keep file checkpoints
84 help="""The directory name in which to keep file checkpoints
85
85
86 This is a path relative to the file's own directory.
86 This is a path relative to the file's own directory.
87
87
88 By default, it is .ipynb_checkpoints
88 By default, it is .ipynb_checkpoints
89 """
89 """
90 )
90 )
91
91
92 def _copy(self, src, dest):
92 def _copy(self, src, dest):
93 """copy src to dest
93 """copy src to dest
94
94
95 like shutil.copy2, but log errors in copystat
95 like shutil.copy2, but log errors in copystat
96 """
96 """
97 shutil.copyfile(src, dest)
97 shutil.copyfile(src, dest)
98 try:
98 try:
99 shutil.copystat(src, dest)
99 shutil.copystat(src, dest)
100 except OSError as e:
100 except OSError as e:
101 self.log.debug("copystat on %s failed", dest, exc_info=True)
101 self.log.debug("copystat on %s failed", dest, exc_info=True)
102
102
103 def _get_os_path(self, path):
103 def _get_os_path(self, path):
104 """Given an API path, return its file system path.
104 """Given an API path, return its file system path.
105
105
106 Parameters
106 Parameters
107 ----------
107 ----------
108 path : string
108 path : string
109 The relative API path to the named file.
109 The relative API path to the named file.
110
110
111 Returns
111 Returns
112 -------
112 -------
113 path : string
113 path : string
114 Native, absolute OS path to for a file.
114 Native, absolute OS path to for a file.
115 """
115 """
116 return to_os_path(path, self.root_dir)
116 return to_os_path(path, self.root_dir)
117
117
118 def dir_exists(self, path):
118 def dir_exists(self, path):
119 """Does the API-style path refer to an extant directory?
119 """Does the API-style path refer to an extant directory?
120
120
121 API-style wrapper for os.path.isdir
121 API-style wrapper for os.path.isdir
122
122
123 Parameters
123 Parameters
124 ----------
124 ----------
125 path : string
125 path : string
126 The path to check. This is an API path (`/` separated,
126 The path to check. This is an API path (`/` separated,
127 relative to root_dir).
127 relative to root_dir).
128
128
129 Returns
129 Returns
130 -------
130 -------
131 exists : bool
131 exists : bool
132 Whether the path is indeed a directory.
132 Whether the path is indeed a directory.
133 """
133 """
134 path = path.strip('/')
134 path = path.strip('/')
135 os_path = self._get_os_path(path=path)
135 os_path = self._get_os_path(path=path)
136 return os.path.isdir(os_path)
136 return os.path.isdir(os_path)
137
137
138 def is_hidden(self, path):
138 def is_hidden(self, path):
139 """Does the API style path correspond to a hidden directory or file?
139 """Does the API style path correspond to a hidden directory or file?
140
140
141 Parameters
141 Parameters
142 ----------
142 ----------
143 path : string
143 path : string
144 The path to check. This is an API path (`/` separated,
144 The path to check. This is an API path (`/` separated,
145 relative to root_dir).
145 relative to root_dir).
146
146
147 Returns
147 Returns
148 -------
148 -------
149 hidden : bool
149 hidden : bool
150 Whether the path exists and is hidden.
150 Whether the path exists and is hidden.
151 """
151 """
152 path = path.strip('/')
152 path = path.strip('/')
153 os_path = self._get_os_path(path=path)
153 os_path = self._get_os_path(path=path)
154 return is_hidden(os_path, self.root_dir)
154 return is_hidden(os_path, self.root_dir)
155
155
156 def file_exists(self, path):
156 def file_exists(self, path):
157 """Returns True if the file exists, else returns False.
157 """Returns True if the file exists, else returns False.
158
158
159 API-style wrapper for os.path.isfile
159 API-style wrapper for os.path.isfile
160
160
161 Parameters
161 Parameters
162 ----------
162 ----------
163 path : string
163 path : string
164 The relative path to the file (with '/' as separator)
164 The relative path to the file (with '/' as separator)
165
165
166 Returns
166 Returns
167 -------
167 -------
168 exists : bool
168 exists : bool
169 Whether the file exists.
169 Whether the file exists.
170 """
170 """
171 path = path.strip('/')
171 path = path.strip('/')
172 os_path = self._get_os_path(path)
172 os_path = self._get_os_path(path)
173 return os.path.isfile(os_path)
173 return os.path.isfile(os_path)
174
174
175 def exists(self, path):
175 def exists(self, path):
176 """Returns True if the path exists, else returns False.
176 """Returns True if the path exists, else returns False.
177
177
178 API-style wrapper for os.path.exists
178 API-style wrapper for os.path.exists
179
179
180 Parameters
180 Parameters
181 ----------
181 ----------
182 path : string
182 path : string
183 The API path to the file (with '/' as separator)
183 The API path to the file (with '/' as separator)
184
184
185 Returns
185 Returns
186 -------
186 -------
187 exists : bool
187 exists : bool
188 Whether the target exists.
188 Whether the target exists.
189 """
189 """
190 path = path.strip('/')
190 path = path.strip('/')
191 os_path = self._get_os_path(path=path)
191 os_path = self._get_os_path(path=path)
192 return os.path.exists(os_path)
192 return os.path.exists(os_path)
193
193
194 def _base_model(self, path):
194 def _base_model(self, path):
195 """Build the common base of a contents model"""
195 """Build the common base of a contents model"""
196 os_path = self._get_os_path(path)
196 os_path = self._get_os_path(path)
197 info = os.stat(os_path)
197 info = os.stat(os_path)
198 last_modified = tz.utcfromtimestamp(info.st_mtime)
198 last_modified = tz.utcfromtimestamp(info.st_mtime)
199 created = tz.utcfromtimestamp(info.st_ctime)
199 created = tz.utcfromtimestamp(info.st_ctime)
200 # Create the base model.
200 # Create the base model.
201 model = {}
201 model = {}
202 model['name'] = path.rsplit('/', 1)[-1]
202 model['name'] = path.rsplit('/', 1)[-1]
203 model['path'] = path
203 model['path'] = path
204 model['last_modified'] = last_modified
204 model['last_modified'] = last_modified
205 model['created'] = created
205 model['created'] = created
206 model['content'] = None
206 model['content'] = None
207 model['format'] = None
207 model['format'] = None
208 model['mimetype'] = None
208 model['mimetype'] = None
209 try:
209 try:
210 model['writable'] = os.access(os_path, os.W_OK)
210 model['writable'] = os.access(os_path, os.W_OK)
211 except OSError:
211 except OSError:
212 self.log.error("Failed to check write permissions on %s", os_path)
212 self.log.error("Failed to check write permissions on %s", os_path)
213 model['writable'] = False
213 model['writable'] = False
214 return model
214 return model
215
215
216 def _dir_model(self, path, content=True):
216 def _dir_model(self, path, content=True):
217 """Build a model for a directory
217 """Build a model for a directory
218
218
219 if content is requested, will include a listing of the directory
219 if content is requested, will include a listing of the directory
220 """
220 """
221 os_path = self._get_os_path(path)
221 os_path = self._get_os_path(path)
222
222
223 four_o_four = u'directory does not exist: %r' % path
223 four_o_four = u'directory does not exist: %r' % path
224
224
225 if not os.path.isdir(os_path):
225 if not os.path.isdir(os_path):
226 raise web.HTTPError(404, four_o_four)
226 raise web.HTTPError(404, four_o_four)
227 elif is_hidden(os_path, self.root_dir):
227 elif is_hidden(os_path, self.root_dir):
228 self.log.info("Refusing to serve hidden directory %r, via 404 Error",
228 self.log.info("Refusing to serve hidden directory %r, via 404 Error",
229 os_path
229 os_path
230 )
230 )
231 raise web.HTTPError(404, four_o_four)
231 raise web.HTTPError(404, four_o_four)
232
232
233 model = self._base_model(path)
233 model = self._base_model(path)
234 model['type'] = 'directory'
234 model['type'] = 'directory'
235 if content:
235 if content:
236 model['content'] = contents = []
236 model['content'] = contents = []
237 os_dir = self._get_os_path(path)
237 os_dir = self._get_os_path(path)
238 for name in os.listdir(os_dir):
238 for name in os.listdir(os_dir):
239 os_path = os.path.join(os_dir, name)
239 os_path = os.path.join(os_dir, name)
240 # skip over broken symlinks in listing
240 # skip over broken symlinks in listing
241 if not os.path.exists(os_path):
241 if not os.path.exists(os_path):
242 self.log.warn("%s doesn't exist", os_path)
242 self.log.warn("%s doesn't exist", os_path)
243 continue
243 continue
244 elif not os.path.isfile(os_path) and not os.path.isdir(os_path):
244 elif not os.path.isfile(os_path) and not os.path.isdir(os_path):
245 self.log.debug("%s not a regular file", os_path)
245 self.log.debug("%s not a regular file", os_path)
246 continue
246 continue
247 if self.should_list(name) and not is_hidden(os_path, self.root_dir):
247 if self.should_list(name) and not is_hidden(os_path, self.root_dir):
248 contents.append(self.get(
248 contents.append(self.get(
249 path='%s/%s' % (path, name),
249 path='%s/%s' % (path, name),
250 content=False)
250 content=False)
251 )
251 )
252
252
253 model['format'] = 'json'
253 model['format'] = 'json'
254
254
255 return model
255 return model
256
256
257 def _file_model(self, path, content=True, format=None):
257 def _file_model(self, path, content=True, format=None):
258 """Build a model for a file
258 """Build a model for a file
259
259
260 if content is requested, include the file contents.
260 if content is requested, include the file contents.
261
261
262 format:
262 format:
263 If 'text', the contents will be decoded as UTF-8.
263 If 'text', the contents will be decoded as UTF-8.
264 If 'base64', the raw bytes contents will be encoded as base64.
264 If 'base64', the raw bytes contents will be encoded as base64.
265 If not specified, try to decode as UTF-8, and fall back to base64
265 If not specified, try to decode as UTF-8, and fall back to base64
266 """
266 """
267 model = self._base_model(path)
267 model = self._base_model(path)
268 model['type'] = 'file'
268 model['type'] = 'file'
269
269
270 os_path = self._get_os_path(path)
270 os_path = self._get_os_path(path)
271 model['mimetype'] = mimetypes.guess_type(os_path)[0] or 'text/plain'
272
271
273 if content:
272 if content:
274 if not os.path.isfile(os_path):
273 if not os.path.isfile(os_path):
275 # could be FIFO
274 # could be FIFO
276 raise web.HTTPError(400, "Cannot get content of non-file %s" % os_path)
275 raise web.HTTPError(400, "Cannot get content of non-file %s" % os_path)
277 with self.open(os_path, 'rb') as f:
276 with self.open(os_path, 'rb') as f:
278 bcontent = f.read()
277 bcontent = f.read()
279
278
280 if format != 'base64':
279 if format != 'base64':
281 try:
280 try:
282 model['content'] = bcontent.decode('utf8')
281 model['content'] = bcontent.decode('utf8')
283 except UnicodeError as e:
282 except UnicodeError as e:
284 if format == 'text':
283 if format == 'text':
285 raise web.HTTPError(400, "%s is not UTF-8 encoded" % path)
284 raise web.HTTPError(400, "%s is not UTF-8 encoded" % path)
286 else:
285 else:
287 model['format'] = 'text'
286 model['format'] = 'text'
287 default_mime = 'text/plain'
288
288
289 if model['content'] is None:
289 if model['content'] is None:
290 model['content'] = base64.encodestring(bcontent).decode('ascii')
290 model['content'] = base64.encodestring(bcontent).decode('ascii')
291 model['format'] = 'base64'
291 model['format'] = 'base64'
292 default_mime = 'application/octet-stream'
293
294 model['mimetype'] = mimetypes.guess_type(os_path)[0] or default_mime
292
295
293 return model
296 return model
294
297
295
298
296 def _notebook_model(self, path, content=True):
299 def _notebook_model(self, path, content=True):
297 """Build a notebook model
300 """Build a notebook model
298
301
299 if content is requested, the notebook content will be populated
302 if content is requested, the notebook content will be populated
300 as a JSON structure (not double-serialized)
303 as a JSON structure (not double-serialized)
301 """
304 """
302 model = self._base_model(path)
305 model = self._base_model(path)
303 model['type'] = 'notebook'
306 model['type'] = 'notebook'
304 if content:
307 if content:
305 os_path = self._get_os_path(path)
308 os_path = self._get_os_path(path)
306 with self.open(os_path, 'r', encoding='utf-8') as f:
309 with self.open(os_path, 'r', encoding='utf-8') as f:
307 try:
310 try:
308 nb = nbformat.read(f, as_version=4)
311 nb = nbformat.read(f, as_version=4)
309 except Exception as e:
312 except Exception as e:
310 raise web.HTTPError(400, u"Unreadable Notebook: %s %r" % (os_path, e))
313 raise web.HTTPError(400, u"Unreadable Notebook: %s %r" % (os_path, e))
311 self.mark_trusted_cells(nb, path)
314 self.mark_trusted_cells(nb, path)
312 model['content'] = nb
315 model['content'] = nb
313 model['format'] = 'json'
316 model['format'] = 'json'
314 self.validate_notebook_model(model)
317 self.validate_notebook_model(model)
315 return model
318 return model
316
319
317 def get(self, path, content=True, type_=None, format=None):
320 def get(self, path, content=True, type_=None, format=None):
318 """ Takes a path for an entity and returns its model
321 """ Takes a path for an entity and returns its model
319
322
320 Parameters
323 Parameters
321 ----------
324 ----------
322 path : str
325 path : str
323 the API path that describes the relative path for the target
326 the API path that describes the relative path for the target
324 content : bool
327 content : bool
325 Whether to include the contents in the reply
328 Whether to include the contents in the reply
326 type_ : str, optional
329 type_ : str, optional
327 The requested type - 'file', 'notebook', or 'directory'.
330 The requested type - 'file', 'notebook', or 'directory'.
328 Will raise HTTPError 400 if the content doesn't match.
331 Will raise HTTPError 400 if the content doesn't match.
329 format : str, optional
332 format : str, optional
330 The requested format for file contents. 'text' or 'base64'.
333 The requested format for file contents. 'text' or 'base64'.
331 Ignored if this returns a notebook or directory model.
334 Ignored if this returns a notebook or directory model.
332
335
333 Returns
336 Returns
334 -------
337 -------
335 model : dict
338 model : dict
336 the contents model. If content=True, returns the contents
339 the contents model. If content=True, returns the contents
337 of the file or directory as well.
340 of the file or directory as well.
338 """
341 """
339 path = path.strip('/')
342 path = path.strip('/')
340
343
341 if not self.exists(path):
344 if not self.exists(path):
342 raise web.HTTPError(404, u'No such file or directory: %s' % path)
345 raise web.HTTPError(404, u'No such file or directory: %s' % path)
343
346
344 os_path = self._get_os_path(path)
347 os_path = self._get_os_path(path)
345 if os.path.isdir(os_path):
348 if os.path.isdir(os_path):
346 if type_ not in (None, 'directory'):
349 if type_ not in (None, 'directory'):
347 raise web.HTTPError(400,
350 raise web.HTTPError(400,
348 u'%s is a directory, not a %s' % (path, type_))
351 u'%s is a directory, not a %s' % (path, type_))
349 model = self._dir_model(path, content=content)
352 model = self._dir_model(path, content=content)
350 elif type_ == 'notebook' or (type_ is None and path.endswith('.ipynb')):
353 elif type_ == 'notebook' or (type_ is None and path.endswith('.ipynb')):
351 model = self._notebook_model(path, content=content)
354 model = self._notebook_model(path, content=content)
352 else:
355 else:
353 if type_ == 'directory':
356 if type_ == 'directory':
354 raise web.HTTPError(400,
357 raise web.HTTPError(400,
355 u'%s is not a directory')
358 u'%s is not a directory')
356 model = self._file_model(path, content=content, format=format)
359 model = self._file_model(path, content=content, format=format)
357 return model
360 return model
358
361
359 def _save_notebook(self, os_path, model, path=''):
362 def _save_notebook(self, os_path, model, path=''):
360 """save a notebook file"""
363 """save a notebook file"""
361 # Save the notebook file
364 # Save the notebook file
362 nb = nbformat.from_dict(model['content'])
365 nb = nbformat.from_dict(model['content'])
363
366
364 self.check_and_sign(nb, path)
367 self.check_and_sign(nb, path)
365
368
366 with self.atomic_writing(os_path, encoding='utf-8') as f:
369 with self.atomic_writing(os_path, encoding='utf-8') as f:
367 nbformat.write(nb, f, version=nbformat.NO_CONVERT)
370 nbformat.write(nb, f, version=nbformat.NO_CONVERT)
368
371
369 def _save_file(self, os_path, model, path=''):
372 def _save_file(self, os_path, model, path=''):
370 """save a non-notebook file"""
373 """save a non-notebook file"""
371 fmt = model.get('format', None)
374 fmt = model.get('format', None)
372 if fmt not in {'text', 'base64'}:
375 if fmt not in {'text', 'base64'}:
373 raise web.HTTPError(400, "Must specify format of file contents as 'text' or 'base64'")
376 raise web.HTTPError(400, "Must specify format of file contents as 'text' or 'base64'")
374 try:
377 try:
375 content = model['content']
378 content = model['content']
376 if fmt == 'text':
379 if fmt == 'text':
377 bcontent = content.encode('utf8')
380 bcontent = content.encode('utf8')
378 else:
381 else:
379 b64_bytes = content.encode('ascii')
382 b64_bytes = content.encode('ascii')
380 bcontent = base64.decodestring(b64_bytes)
383 bcontent = base64.decodestring(b64_bytes)
381 except Exception as e:
384 except Exception as e:
382 raise web.HTTPError(400, u'Encoding error saving %s: %s' % (os_path, e))
385 raise web.HTTPError(400, u'Encoding error saving %s: %s' % (os_path, e))
383 with self.atomic_writing(os_path, text=False) as f:
386 with self.atomic_writing(os_path, text=False) as f:
384 f.write(bcontent)
387 f.write(bcontent)
385
388
386 def _save_directory(self, os_path, model, path=''):
389 def _save_directory(self, os_path, model, path=''):
387 """create a directory"""
390 """create a directory"""
388 if is_hidden(os_path, self.root_dir):
391 if is_hidden(os_path, self.root_dir):
389 raise web.HTTPError(400, u'Cannot create hidden directory %r' % os_path)
392 raise web.HTTPError(400, u'Cannot create hidden directory %r' % os_path)
390 if not os.path.exists(os_path):
393 if not os.path.exists(os_path):
391 with self.perm_to_403():
394 with self.perm_to_403():
392 os.mkdir(os_path)
395 os.mkdir(os_path)
393 elif not os.path.isdir(os_path):
396 elif not os.path.isdir(os_path):
394 raise web.HTTPError(400, u'Not a directory: %s' % (os_path))
397 raise web.HTTPError(400, u'Not a directory: %s' % (os_path))
395 else:
398 else:
396 self.log.debug("Directory %r already exists", os_path)
399 self.log.debug("Directory %r already exists", os_path)
397
400
398 def save(self, model, path=''):
401 def save(self, model, path=''):
399 """Save the file model and return the model with no content."""
402 """Save the file model and return the model with no content."""
400 path = path.strip('/')
403 path = path.strip('/')
401
404
402 if 'type' not in model:
405 if 'type' not in model:
403 raise web.HTTPError(400, u'No file type provided')
406 raise web.HTTPError(400, u'No file type provided')
404 if 'content' not in model and model['type'] != 'directory':
407 if 'content' not in model and model['type'] != 'directory':
405 raise web.HTTPError(400, u'No file content provided')
408 raise web.HTTPError(400, u'No file content provided')
406
409
407 # One checkpoint should always exist
410 # One checkpoint should always exist
408 if self.file_exists(path) and not self.list_checkpoints(path):
411 if self.file_exists(path) and not self.list_checkpoints(path):
409 self.create_checkpoint(path)
412 self.create_checkpoint(path)
410
413
411 os_path = self._get_os_path(path)
414 os_path = self._get_os_path(path)
412 self.log.debug("Saving %s", os_path)
415 self.log.debug("Saving %s", os_path)
413 try:
416 try:
414 if model['type'] == 'notebook':
417 if model['type'] == 'notebook':
415 self._save_notebook(os_path, model, path)
418 self._save_notebook(os_path, model, path)
416 elif model['type'] == 'file':
419 elif model['type'] == 'file':
417 self._save_file(os_path, model, path)
420 self._save_file(os_path, model, path)
418 elif model['type'] == 'directory':
421 elif model['type'] == 'directory':
419 self._save_directory(os_path, model, path)
422 self._save_directory(os_path, model, path)
420 else:
423 else:
421 raise web.HTTPError(400, "Unhandled contents type: %s" % model['type'])
424 raise web.HTTPError(400, "Unhandled contents type: %s" % model['type'])
422 except web.HTTPError:
425 except web.HTTPError:
423 raise
426 raise
424 except Exception as e:
427 except Exception as e:
425 self.log.error(u'Error while saving file: %s %s', path, e, exc_info=True)
428 self.log.error(u'Error while saving file: %s %s', path, e, exc_info=True)
426 raise web.HTTPError(500, u'Unexpected error while saving file: %s %s' % (path, e))
429 raise web.HTTPError(500, u'Unexpected error while saving file: %s %s' % (path, e))
427
430
428 validation_message = None
431 validation_message = None
429 if model['type'] == 'notebook':
432 if model['type'] == 'notebook':
430 self.validate_notebook_model(model)
433 self.validate_notebook_model(model)
431 validation_message = model.get('message', None)
434 validation_message = model.get('message', None)
432
435
433 model = self.get(path, content=False)
436 model = self.get(path, content=False)
434 if validation_message:
437 if validation_message:
435 model['message'] = validation_message
438 model['message'] = validation_message
436 return model
439 return model
437
440
438 def update(self, model, path):
441 def update(self, model, path):
439 """Update the file's path
442 """Update the file's path
440
443
441 For use in PATCH requests, to enable renaming a file without
444 For use in PATCH requests, to enable renaming a file without
442 re-uploading its contents. Only used for renaming at the moment.
445 re-uploading its contents. Only used for renaming at the moment.
443 """
446 """
444 path = path.strip('/')
447 path = path.strip('/')
445 new_path = model.get('path', path).strip('/')
448 new_path = model.get('path', path).strip('/')
446 if path != new_path:
449 if path != new_path:
447 self.rename(path, new_path)
450 self.rename(path, new_path)
448 model = self.get(new_path, content=False)
451 model = self.get(new_path, content=False)
449 return model
452 return model
450
453
451 def delete(self, path):
454 def delete(self, path):
452 """Delete file at path."""
455 """Delete file at path."""
453 path = path.strip('/')
456 path = path.strip('/')
454 os_path = self._get_os_path(path)
457 os_path = self._get_os_path(path)
455 rm = os.unlink
458 rm = os.unlink
456 if os.path.isdir(os_path):
459 if os.path.isdir(os_path):
457 listing = os.listdir(os_path)
460 listing = os.listdir(os_path)
458 # don't delete non-empty directories (checkpoints dir doesn't count)
461 # don't delete non-empty directories (checkpoints dir doesn't count)
459 if listing and listing != [self.checkpoint_dir]:
462 if listing and listing != [self.checkpoint_dir]:
460 raise web.HTTPError(400, u'Directory %s not empty' % os_path)
463 raise web.HTTPError(400, u'Directory %s not empty' % os_path)
461 elif not os.path.isfile(os_path):
464 elif not os.path.isfile(os_path):
462 raise web.HTTPError(404, u'File does not exist: %s' % os_path)
465 raise web.HTTPError(404, u'File does not exist: %s' % os_path)
463
466
464 # clear checkpoints
467 # clear checkpoints
465 for checkpoint in self.list_checkpoints(path):
468 for checkpoint in self.list_checkpoints(path):
466 checkpoint_id = checkpoint['id']
469 checkpoint_id = checkpoint['id']
467 cp_path = self.get_checkpoint_path(checkpoint_id, path)
470 cp_path = self.get_checkpoint_path(checkpoint_id, path)
468 if os.path.isfile(cp_path):
471 if os.path.isfile(cp_path):
469 self.log.debug("Unlinking checkpoint %s", cp_path)
472 self.log.debug("Unlinking checkpoint %s", cp_path)
470 with self.perm_to_403():
473 with self.perm_to_403():
471 rm(cp_path)
474 rm(cp_path)
472
475
473 if os.path.isdir(os_path):
476 if os.path.isdir(os_path):
474 self.log.debug("Removing directory %s", os_path)
477 self.log.debug("Removing directory %s", os_path)
475 with self.perm_to_403():
478 with self.perm_to_403():
476 shutil.rmtree(os_path)
479 shutil.rmtree(os_path)
477 else:
480 else:
478 self.log.debug("Unlinking file %s", os_path)
481 self.log.debug("Unlinking file %s", os_path)
479 with self.perm_to_403():
482 with self.perm_to_403():
480 rm(os_path)
483 rm(os_path)
481
484
482 def rename(self, old_path, new_path):
485 def rename(self, old_path, new_path):
483 """Rename a file."""
486 """Rename a file."""
484 old_path = old_path.strip('/')
487 old_path = old_path.strip('/')
485 new_path = new_path.strip('/')
488 new_path = new_path.strip('/')
486 if new_path == old_path:
489 if new_path == old_path:
487 return
490 return
488
491
489 new_os_path = self._get_os_path(new_path)
492 new_os_path = self._get_os_path(new_path)
490 old_os_path = self._get_os_path(old_path)
493 old_os_path = self._get_os_path(old_path)
491
494
492 # Should we proceed with the move?
495 # Should we proceed with the move?
493 if os.path.exists(new_os_path):
496 if os.path.exists(new_os_path):
494 raise web.HTTPError(409, u'File already exists: %s' % new_path)
497 raise web.HTTPError(409, u'File already exists: %s' % new_path)
495
498
496 # Move the file
499 # Move the file
497 try:
500 try:
498 with self.perm_to_403():
501 with self.perm_to_403():
499 shutil.move(old_os_path, new_os_path)
502 shutil.move(old_os_path, new_os_path)
500 except web.HTTPError:
503 except web.HTTPError:
501 raise
504 raise
502 except Exception as e:
505 except Exception as e:
503 raise web.HTTPError(500, u'Unknown error renaming file: %s %s' % (old_path, e))
506 raise web.HTTPError(500, u'Unknown error renaming file: %s %s' % (old_path, e))
504
507
505 # Move the checkpoints
508 # Move the checkpoints
506 old_checkpoints = self.list_checkpoints(old_path)
509 old_checkpoints = self.list_checkpoints(old_path)
507 for cp in old_checkpoints:
510 for cp in old_checkpoints:
508 checkpoint_id = cp['id']
511 checkpoint_id = cp['id']
509 old_cp_path = self.get_checkpoint_path(checkpoint_id, old_path)
512 old_cp_path = self.get_checkpoint_path(checkpoint_id, old_path)
510 new_cp_path = self.get_checkpoint_path(checkpoint_id, new_path)
513 new_cp_path = self.get_checkpoint_path(checkpoint_id, new_path)
511 if os.path.isfile(old_cp_path):
514 if os.path.isfile(old_cp_path):
512 self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path)
515 self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path)
513 with self.perm_to_403():
516 with self.perm_to_403():
514 shutil.move(old_cp_path, new_cp_path)
517 shutil.move(old_cp_path, new_cp_path)
515
518
516 # Checkpoint-related utilities
519 # Checkpoint-related utilities
517
520
518 def get_checkpoint_path(self, checkpoint_id, path):
521 def get_checkpoint_path(self, checkpoint_id, path):
519 """find the path to a checkpoint"""
522 """find the path to a checkpoint"""
520 path = path.strip('/')
523 path = path.strip('/')
521 parent, name = ('/' + path).rsplit('/', 1)
524 parent, name = ('/' + path).rsplit('/', 1)
522 parent = parent.strip('/')
525 parent = parent.strip('/')
523 basename, ext = os.path.splitext(name)
526 basename, ext = os.path.splitext(name)
524 filename = u"{name}-{checkpoint_id}{ext}".format(
527 filename = u"{name}-{checkpoint_id}{ext}".format(
525 name=basename,
528 name=basename,
526 checkpoint_id=checkpoint_id,
529 checkpoint_id=checkpoint_id,
527 ext=ext,
530 ext=ext,
528 )
531 )
529 os_path = self._get_os_path(path=parent)
532 os_path = self._get_os_path(path=parent)
530 cp_dir = os.path.join(os_path, self.checkpoint_dir)
533 cp_dir = os.path.join(os_path, self.checkpoint_dir)
531 with self.perm_to_403():
534 with self.perm_to_403():
532 ensure_dir_exists(cp_dir)
535 ensure_dir_exists(cp_dir)
533 cp_path = os.path.join(cp_dir, filename)
536 cp_path = os.path.join(cp_dir, filename)
534 return cp_path
537 return cp_path
535
538
536 def get_checkpoint_model(self, checkpoint_id, path):
539 def get_checkpoint_model(self, checkpoint_id, path):
537 """construct the info dict for a given checkpoint"""
540 """construct the info dict for a given checkpoint"""
538 path = path.strip('/')
541 path = path.strip('/')
539 cp_path = self.get_checkpoint_path(checkpoint_id, path)
542 cp_path = self.get_checkpoint_path(checkpoint_id, path)
540 stats = os.stat(cp_path)
543 stats = os.stat(cp_path)
541 last_modified = tz.utcfromtimestamp(stats.st_mtime)
544 last_modified = tz.utcfromtimestamp(stats.st_mtime)
542 info = dict(
545 info = dict(
543 id = checkpoint_id,
546 id = checkpoint_id,
544 last_modified = last_modified,
547 last_modified = last_modified,
545 )
548 )
546 return info
549 return info
547
550
548 # public checkpoint API
551 # public checkpoint API
549
552
550 def create_checkpoint(self, path):
553 def create_checkpoint(self, path):
551 """Create a checkpoint from the current state of a file"""
554 """Create a checkpoint from the current state of a file"""
552 path = path.strip('/')
555 path = path.strip('/')
553 if not self.file_exists(path):
556 if not self.file_exists(path):
554 raise web.HTTPError(404)
557 raise web.HTTPError(404)
555 src_path = self._get_os_path(path)
558 src_path = self._get_os_path(path)
556 # only the one checkpoint ID:
559 # only the one checkpoint ID:
557 checkpoint_id = u"checkpoint"
560 checkpoint_id = u"checkpoint"
558 cp_path = self.get_checkpoint_path(checkpoint_id, path)
561 cp_path = self.get_checkpoint_path(checkpoint_id, path)
559 self.log.debug("creating checkpoint for %s", path)
562 self.log.debug("creating checkpoint for %s", path)
560 with self.perm_to_403():
563 with self.perm_to_403():
561 self._copy(src_path, cp_path)
564 self._copy(src_path, cp_path)
562
565
563 # return the checkpoint info
566 # return the checkpoint info
564 return self.get_checkpoint_model(checkpoint_id, path)
567 return self.get_checkpoint_model(checkpoint_id, path)
565
568
566 def list_checkpoints(self, path):
569 def list_checkpoints(self, path):
567 """list the checkpoints for a given file
570 """list the checkpoints for a given file
568
571
569 This contents manager currently only supports one checkpoint per file.
572 This contents manager currently only supports one checkpoint per file.
570 """
573 """
571 path = path.strip('/')
574 path = path.strip('/')
572 checkpoint_id = "checkpoint"
575 checkpoint_id = "checkpoint"
573 os_path = self.get_checkpoint_path(checkpoint_id, path)
576 os_path = self.get_checkpoint_path(checkpoint_id, path)
574 if not os.path.exists(os_path):
577 if not os.path.exists(os_path):
575 return []
578 return []
576 else:
579 else:
577 return [self.get_checkpoint_model(checkpoint_id, path)]
580 return [self.get_checkpoint_model(checkpoint_id, path)]
578
581
579
582
580 def restore_checkpoint(self, checkpoint_id, path):
583 def restore_checkpoint(self, checkpoint_id, path):
581 """restore a file to a checkpointed state"""
584 """restore a file to a checkpointed state"""
582 path = path.strip('/')
585 path = path.strip('/')
583 self.log.info("restoring %s from checkpoint %s", path, checkpoint_id)
586 self.log.info("restoring %s from checkpoint %s", path, checkpoint_id)
584 nb_path = self._get_os_path(path)
587 nb_path = self._get_os_path(path)
585 cp_path = self.get_checkpoint_path(checkpoint_id, path)
588 cp_path = self.get_checkpoint_path(checkpoint_id, path)
586 if not os.path.isfile(cp_path):
589 if not os.path.isfile(cp_path):
587 self.log.debug("checkpoint file does not exist: %s", cp_path)
590 self.log.debug("checkpoint file does not exist: %s", cp_path)
588 raise web.HTTPError(404,
591 raise web.HTTPError(404,
589 u'checkpoint does not exist: %s@%s' % (path, checkpoint_id)
592 u'checkpoint does not exist: %s@%s' % (path, checkpoint_id)
590 )
593 )
591 # ensure notebook is readable (never restore from an unreadable notebook)
594 # ensure notebook is readable (never restore from an unreadable notebook)
592 if cp_path.endswith('.ipynb'):
595 if cp_path.endswith('.ipynb'):
593 with self.open(cp_path, 'r', encoding='utf-8') as f:
596 with self.open(cp_path, 'r', encoding='utf-8') as f:
594 nbformat.read(f, as_version=4)
597 nbformat.read(f, as_version=4)
595 self.log.debug("copying %s -> %s", cp_path, nb_path)
598 self.log.debug("copying %s -> %s", cp_path, nb_path)
596 with self.perm_to_403():
599 with self.perm_to_403():
597 self._copy(cp_path, nb_path)
600 self._copy(cp_path, nb_path)
598
601
599 def delete_checkpoint(self, checkpoint_id, path):
602 def delete_checkpoint(self, checkpoint_id, path):
600 """delete a file's checkpoint"""
603 """delete a file's checkpoint"""
601 path = path.strip('/')
604 path = path.strip('/')
602 cp_path = self.get_checkpoint_path(checkpoint_id, path)
605 cp_path = self.get_checkpoint_path(checkpoint_id, path)
603 if not os.path.isfile(cp_path):
606 if not os.path.isfile(cp_path):
604 raise web.HTTPError(404,
607 raise web.HTTPError(404,
605 u'Checkpoint does not exist: %s@%s' % (path, checkpoint_id)
608 u'Checkpoint does not exist: %s@%s' % (path, checkpoint_id)
606 )
609 )
607 self.log.debug("unlinking %s", cp_path)
610 self.log.debug("unlinking %s", cp_path)
608 os.unlink(cp_path)
611 os.unlink(cp_path)
609
612
610 def info_string(self):
613 def info_string(self):
611 return "Serving notebooks from local directory: %s" % self.root_dir
614 return "Serving notebooks from local directory: %s" % self.root_dir
612
615
613 def get_kernel_path(self, path, model=None):
616 def get_kernel_path(self, path, model=None):
614 """Return the initial API path of a kernel associated with a given notebook"""
617 """Return the initial API path of a kernel associated with a given notebook"""
615 if '/' in path:
618 if '/' in path:
616 parent_dir = path.rsplit('/', 1)[0]
619 parent_dir = path.rsplit('/', 1)[0]
617 else:
620 else:
618 parent_dir = ''
621 parent_dir = ''
619 return parent_dir
622 return parent_dir
General Comments 0
You need to be logged in to leave comments. Login now