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