##// END OF EJS Templates
Merge pull request #7312 from quantopian/refactor-contents-update...
Min RK -
r19714:649baa24 merge
parent child Browse files
Show More
@@ -1,696 +1,683 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 if model['format'] == 'base64':
361 default_mime = 'application/octet-stream'
361 default_mime = 'application/octet-stream'
362
362
363 model['mimetype'] = mimetypes.guess_type(os_path)[0] or default_mime
363 model['mimetype'] = mimetypes.guess_type(os_path)[0] or default_mime
364
364
365 return model
365 return model
366
366
367
367
368 def _notebook_model(self, path, content=True):
368 def _notebook_model(self, path, content=True):
369 """Build a notebook model
369 """Build a notebook model
370
370
371 if content is requested, the notebook content will be populated
371 if content is requested, the notebook content will be populated
372 as a JSON structure (not double-serialized)
372 as a JSON structure (not double-serialized)
373 """
373 """
374 model = self._base_model(path)
374 model = self._base_model(path)
375 model['type'] = 'notebook'
375 model['type'] = 'notebook'
376 if content:
376 if content:
377 os_path = self._get_os_path(path)
377 os_path = self._get_os_path(path)
378 with self.open(os_path, 'r', encoding='utf-8') as f:
378 with self.open(os_path, 'r', encoding='utf-8') as f:
379 try:
379 try:
380 nb = nbformat.read(f, as_version=4)
380 nb = nbformat.read(f, as_version=4)
381 except Exception as e:
381 except Exception as e:
382 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))
383 self.mark_trusted_cells(nb, path)
383 self.mark_trusted_cells(nb, path)
384 model['content'] = nb
384 model['content'] = nb
385 model['format'] = 'json'
385 model['format'] = 'json'
386 self.validate_notebook_model(model)
386 self.validate_notebook_model(model)
387 return model
387 return model
388
388
389 def get(self, path, content=True, type=None, format=None):
389 def get(self, path, content=True, type=None, format=None):
390 """ Takes a path for an entity and returns its model
390 """ Takes a path for an entity and returns its model
391
391
392 Parameters
392 Parameters
393 ----------
393 ----------
394 path : str
394 path : str
395 the API path that describes the relative path for the target
395 the API path that describes the relative path for the target
396 content : bool
396 content : bool
397 Whether to include the contents in the reply
397 Whether to include the contents in the reply
398 type : str, optional
398 type : str, optional
399 The requested type - 'file', 'notebook', or 'directory'.
399 The requested type - 'file', 'notebook', or 'directory'.
400 Will raise HTTPError 400 if the content doesn't match.
400 Will raise HTTPError 400 if the content doesn't match.
401 format : str, optional
401 format : str, optional
402 The requested format for file contents. 'text' or 'base64'.
402 The requested format for file contents. 'text' or 'base64'.
403 Ignored if this returns a notebook or directory model.
403 Ignored if this returns a notebook or directory model.
404
404
405 Returns
405 Returns
406 -------
406 -------
407 model : dict
407 model : dict
408 the contents model. If content=True, returns the contents
408 the contents model. If content=True, returns the contents
409 of the file or directory as well.
409 of the file or directory as well.
410 """
410 """
411 path = path.strip('/')
411 path = path.strip('/')
412
412
413 if not self.exists(path):
413 if not self.exists(path):
414 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)
415
415
416 os_path = self._get_os_path(path)
416 os_path = self._get_os_path(path)
417 if os.path.isdir(os_path):
417 if os.path.isdir(os_path):
418 if type not in (None, 'directory'):
418 if type not in (None, 'directory'):
419 raise web.HTTPError(400,
419 raise web.HTTPError(400,
420 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')
421 model = self._dir_model(path, content=content)
421 model = self._dir_model(path, content=content)
422 elif type == 'notebook' or (type is None and path.endswith('.ipynb')):
422 elif type == 'notebook' or (type is None and path.endswith('.ipynb')):
423 model = self._notebook_model(path, content=content)
423 model = self._notebook_model(path, content=content)
424 else:
424 else:
425 if type == 'directory':
425 if type == 'directory':
426 raise web.HTTPError(400,
426 raise web.HTTPError(400,
427 u'%s is not a directory', reason='bad type')
427 u'%s is not a directory', reason='bad type')
428 model = self._file_model(path, content=content, format=format)
428 model = self._file_model(path, content=content, format=format)
429 return model
429 return model
430
430
431 def _save_notebook(self, os_path, model, path=''):
431 def _save_notebook(self, os_path, model, path=''):
432 """save a notebook file"""
432 """save a notebook file"""
433 # Save the notebook file
433 # Save the notebook file
434 nb = nbformat.from_dict(model['content'])
434 nb = nbformat.from_dict(model['content'])
435
435
436 self.check_and_sign(nb, path)
436 self.check_and_sign(nb, path)
437
437
438 with self.atomic_writing(os_path, encoding='utf-8') as f:
438 with self.atomic_writing(os_path, encoding='utf-8') as f:
439 nbformat.write(nb, f, version=nbformat.NO_CONVERT)
439 nbformat.write(nb, f, version=nbformat.NO_CONVERT)
440
440
441 def _save_file(self, os_path, model, path=''):
441 def _save_file(self, os_path, model, path=''):
442 """save a non-notebook file"""
442 """save a non-notebook file"""
443 fmt = model.get('format', None)
443 fmt = model.get('format', None)
444 if fmt not in {'text', 'base64'}:
444 if fmt not in {'text', 'base64'}:
445 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'")
446 try:
446 try:
447 content = model['content']
447 content = model['content']
448 if fmt == 'text':
448 if fmt == 'text':
449 bcontent = content.encode('utf8')
449 bcontent = content.encode('utf8')
450 else:
450 else:
451 b64_bytes = content.encode('ascii')
451 b64_bytes = content.encode('ascii')
452 bcontent = base64.decodestring(b64_bytes)
452 bcontent = base64.decodestring(b64_bytes)
453 except Exception as e:
453 except Exception as e:
454 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))
455 with self.atomic_writing(os_path, text=False) as f:
455 with self.atomic_writing(os_path, text=False) as f:
456 f.write(bcontent)
456 f.write(bcontent)
457
457
458 def _save_directory(self, os_path, model, path=''):
458 def _save_directory(self, os_path, model, path=''):
459 """create a directory"""
459 """create a directory"""
460 if is_hidden(os_path, self.root_dir):
460 if is_hidden(os_path, self.root_dir):
461 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)
462 if not os.path.exists(os_path):
462 if not os.path.exists(os_path):
463 with self.perm_to_403():
463 with self.perm_to_403():
464 os.mkdir(os_path)
464 os.mkdir(os_path)
465 elif not os.path.isdir(os_path):
465 elif not os.path.isdir(os_path):
466 raise web.HTTPError(400, u'Not a directory: %s' % (os_path))
466 raise web.HTTPError(400, u'Not a directory: %s' % (os_path))
467 else:
467 else:
468 self.log.debug("Directory %r already exists", os_path)
468 self.log.debug("Directory %r already exists", os_path)
469
469
470 def save(self, model, path=''):
470 def save(self, model, path=''):
471 """Save the file model and return the model with no content."""
471 """Save the file model and return the model with no content."""
472 path = path.strip('/')
472 path = path.strip('/')
473
473
474 if 'type' not in model:
474 if 'type' not in model:
475 raise web.HTTPError(400, u'No file type provided')
475 raise web.HTTPError(400, u'No file type provided')
476 if 'content' not in model and model['type'] != 'directory':
476 if 'content' not in model and model['type'] != 'directory':
477 raise web.HTTPError(400, u'No file content provided')
477 raise web.HTTPError(400, u'No file content provided')
478
478
479 self.run_pre_save_hook(model=model, path=path)
479 self.run_pre_save_hook(model=model, path=path)
480
480
481 # One checkpoint should always exist
481 # One checkpoint should always exist
482 if self.file_exists(path) and not self.list_checkpoints(path):
482 if self.file_exists(path) and not self.list_checkpoints(path):
483 self.create_checkpoint(path)
483 self.create_checkpoint(path)
484
484
485 os_path = self._get_os_path(path)
485 os_path = self._get_os_path(path)
486 self.log.debug("Saving %s", os_path)
486 self.log.debug("Saving %s", os_path)
487 try:
487 try:
488 if model['type'] == 'notebook':
488 if model['type'] == 'notebook':
489 self._save_notebook(os_path, model, path)
489 self._save_notebook(os_path, model, path)
490 elif model['type'] == 'file':
490 elif model['type'] == 'file':
491 self._save_file(os_path, model, path)
491 self._save_file(os_path, model, path)
492 elif model['type'] == 'directory':
492 elif model['type'] == 'directory':
493 self._save_directory(os_path, model, path)
493 self._save_directory(os_path, model, path)
494 else:
494 else:
495 raise web.HTTPError(400, "Unhandled contents type: %s" % model['type'])
495 raise web.HTTPError(400, "Unhandled contents type: %s" % model['type'])
496 except web.HTTPError:
496 except web.HTTPError:
497 raise
497 raise
498 except Exception as e:
498 except Exception as e:
499 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)
500 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))
501
501
502 validation_message = None
502 validation_message = None
503 if model['type'] == 'notebook':
503 if model['type'] == 'notebook':
504 self.validate_notebook_model(model)
504 self.validate_notebook_model(model)
505 validation_message = model.get('message', None)
505 validation_message = model.get('message', None)
506
506
507 model = self.get(path, content=False)
507 model = self.get(path, content=False)
508 if validation_message:
508 if validation_message:
509 model['message'] = validation_message
509 model['message'] = validation_message
510
510
511 self.run_post_save_hook(model=model, os_path=os_path)
511 self.run_post_save_hook(model=model, os_path=os_path)
512
512
513 return model
513 return model
514
514
515 def update(self, model, path):
516 """Update the file's path
517
518 For use in PATCH requests, to enable renaming a file without
519 re-uploading its contents. Only used for renaming at the moment.
520 """
521 path = path.strip('/')
522 new_path = model.get('path', path).strip('/')
523 if path != new_path:
524 self.rename(path, new_path)
525 model = self.get(new_path, content=False)
526 return model
527
528 def delete(self, path):
515 def delete(self, path):
529 """Delete file at path."""
516 """Delete file at path."""
530 path = path.strip('/')
517 path = path.strip('/')
531 os_path = self._get_os_path(path)
518 os_path = self._get_os_path(path)
532 rm = os.unlink
519 rm = os.unlink
533 if os.path.isdir(os_path):
520 if os.path.isdir(os_path):
534 listing = os.listdir(os_path)
521 listing = os.listdir(os_path)
535 # don't delete non-empty directories (checkpoints dir doesn't count)
522 # don't delete non-empty directories (checkpoints dir doesn't count)
536 if listing and listing != [self.checkpoint_dir]:
523 if listing and listing != [self.checkpoint_dir]:
537 raise web.HTTPError(400, u'Directory %s not empty' % os_path)
524 raise web.HTTPError(400, u'Directory %s not empty' % os_path)
538 elif not os.path.isfile(os_path):
525 elif not os.path.isfile(os_path):
539 raise web.HTTPError(404, u'File does not exist: %s' % os_path)
526 raise web.HTTPError(404, u'File does not exist: %s' % os_path)
540
527
541 # clear checkpoints
528 # clear checkpoints
542 for checkpoint in self.list_checkpoints(path):
529 for checkpoint in self.list_checkpoints(path):
543 checkpoint_id = checkpoint['id']
530 checkpoint_id = checkpoint['id']
544 cp_path = self.get_checkpoint_path(checkpoint_id, path)
531 cp_path = self.get_checkpoint_path(checkpoint_id, path)
545 if os.path.isfile(cp_path):
532 if os.path.isfile(cp_path):
546 self.log.debug("Unlinking checkpoint %s", cp_path)
533 self.log.debug("Unlinking checkpoint %s", cp_path)
547 with self.perm_to_403():
534 with self.perm_to_403():
548 rm(cp_path)
535 rm(cp_path)
549
536
550 if os.path.isdir(os_path):
537 if os.path.isdir(os_path):
551 self.log.debug("Removing directory %s", os_path)
538 self.log.debug("Removing directory %s", os_path)
552 with self.perm_to_403():
539 with self.perm_to_403():
553 shutil.rmtree(os_path)
540 shutil.rmtree(os_path)
554 else:
541 else:
555 self.log.debug("Unlinking file %s", os_path)
542 self.log.debug("Unlinking file %s", os_path)
556 with self.perm_to_403():
543 with self.perm_to_403():
557 rm(os_path)
544 rm(os_path)
558
545
559 def rename(self, old_path, new_path):
546 def rename(self, old_path, new_path):
560 """Rename a file."""
547 """Rename a file."""
561 old_path = old_path.strip('/')
548 old_path = old_path.strip('/')
562 new_path = new_path.strip('/')
549 new_path = new_path.strip('/')
563 if new_path == old_path:
550 if new_path == old_path:
564 return
551 return
565
552
566 new_os_path = self._get_os_path(new_path)
553 new_os_path = self._get_os_path(new_path)
567 old_os_path = self._get_os_path(old_path)
554 old_os_path = self._get_os_path(old_path)
568
555
569 # Should we proceed with the move?
556 # Should we proceed with the move?
570 if os.path.exists(new_os_path):
557 if os.path.exists(new_os_path):
571 raise web.HTTPError(409, u'File already exists: %s' % new_path)
558 raise web.HTTPError(409, u'File already exists: %s' % new_path)
572
559
573 # Move the file
560 # Move the file
574 try:
561 try:
575 with self.perm_to_403():
562 with self.perm_to_403():
576 shutil.move(old_os_path, new_os_path)
563 shutil.move(old_os_path, new_os_path)
577 except web.HTTPError:
564 except web.HTTPError:
578 raise
565 raise
579 except Exception as e:
566 except Exception as e:
580 raise web.HTTPError(500, u'Unknown error renaming file: %s %s' % (old_path, e))
567 raise web.HTTPError(500, u'Unknown error renaming file: %s %s' % (old_path, e))
581
568
582 # Move the checkpoints
569 # Move the checkpoints
583 old_checkpoints = self.list_checkpoints(old_path)
570 old_checkpoints = self.list_checkpoints(old_path)
584 for cp in old_checkpoints:
571 for cp in old_checkpoints:
585 checkpoint_id = cp['id']
572 checkpoint_id = cp['id']
586 old_cp_path = self.get_checkpoint_path(checkpoint_id, old_path)
573 old_cp_path = self.get_checkpoint_path(checkpoint_id, old_path)
587 new_cp_path = self.get_checkpoint_path(checkpoint_id, new_path)
574 new_cp_path = self.get_checkpoint_path(checkpoint_id, new_path)
588 if os.path.isfile(old_cp_path):
575 if os.path.isfile(old_cp_path):
589 self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path)
576 self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path)
590 with self.perm_to_403():
577 with self.perm_to_403():
591 shutil.move(old_cp_path, new_cp_path)
578 shutil.move(old_cp_path, new_cp_path)
592
579
593 # Checkpoint-related utilities
580 # Checkpoint-related utilities
594
581
595 def get_checkpoint_path(self, checkpoint_id, path):
582 def get_checkpoint_path(self, checkpoint_id, path):
596 """find the path to a checkpoint"""
583 """find the path to a checkpoint"""
597 path = path.strip('/')
584 path = path.strip('/')
598 parent, name = ('/' + path).rsplit('/', 1)
585 parent, name = ('/' + path).rsplit('/', 1)
599 parent = parent.strip('/')
586 parent = parent.strip('/')
600 basename, ext = os.path.splitext(name)
587 basename, ext = os.path.splitext(name)
601 filename = u"{name}-{checkpoint_id}{ext}".format(
588 filename = u"{name}-{checkpoint_id}{ext}".format(
602 name=basename,
589 name=basename,
603 checkpoint_id=checkpoint_id,
590 checkpoint_id=checkpoint_id,
604 ext=ext,
591 ext=ext,
605 )
592 )
606 os_path = self._get_os_path(path=parent)
593 os_path = self._get_os_path(path=parent)
607 cp_dir = os.path.join(os_path, self.checkpoint_dir)
594 cp_dir = os.path.join(os_path, self.checkpoint_dir)
608 with self.perm_to_403():
595 with self.perm_to_403():
609 ensure_dir_exists(cp_dir)
596 ensure_dir_exists(cp_dir)
610 cp_path = os.path.join(cp_dir, filename)
597 cp_path = os.path.join(cp_dir, filename)
611 return cp_path
598 return cp_path
612
599
613 def get_checkpoint_model(self, checkpoint_id, path):
600 def get_checkpoint_model(self, checkpoint_id, path):
614 """construct the info dict for a given checkpoint"""
601 """construct the info dict for a given checkpoint"""
615 path = path.strip('/')
602 path = path.strip('/')
616 cp_path = self.get_checkpoint_path(checkpoint_id, path)
603 cp_path = self.get_checkpoint_path(checkpoint_id, path)
617 stats = os.stat(cp_path)
604 stats = os.stat(cp_path)
618 last_modified = tz.utcfromtimestamp(stats.st_mtime)
605 last_modified = tz.utcfromtimestamp(stats.st_mtime)
619 info = dict(
606 info = dict(
620 id = checkpoint_id,
607 id = checkpoint_id,
621 last_modified = last_modified,
608 last_modified = last_modified,
622 )
609 )
623 return info
610 return info
624
611
625 # public checkpoint API
612 # public checkpoint API
626
613
627 def create_checkpoint(self, path):
614 def create_checkpoint(self, path):
628 """Create a checkpoint from the current state of a file"""
615 """Create a checkpoint from the current state of a file"""
629 path = path.strip('/')
616 path = path.strip('/')
630 if not self.file_exists(path):
617 if not self.file_exists(path):
631 raise web.HTTPError(404)
618 raise web.HTTPError(404)
632 src_path = self._get_os_path(path)
619 src_path = self._get_os_path(path)
633 # only the one checkpoint ID:
620 # only the one checkpoint ID:
634 checkpoint_id = u"checkpoint"
621 checkpoint_id = u"checkpoint"
635 cp_path = self.get_checkpoint_path(checkpoint_id, path)
622 cp_path = self.get_checkpoint_path(checkpoint_id, path)
636 self.log.debug("creating checkpoint for %s", path)
623 self.log.debug("creating checkpoint for %s", path)
637 with self.perm_to_403():
624 with self.perm_to_403():
638 self._copy(src_path, cp_path)
625 self._copy(src_path, cp_path)
639
626
640 # return the checkpoint info
627 # return the checkpoint info
641 return self.get_checkpoint_model(checkpoint_id, path)
628 return self.get_checkpoint_model(checkpoint_id, path)
642
629
643 def list_checkpoints(self, path):
630 def list_checkpoints(self, path):
644 """list the checkpoints for a given file
631 """list the checkpoints for a given file
645
632
646 This contents manager currently only supports one checkpoint per file.
633 This contents manager currently only supports one checkpoint per file.
647 """
634 """
648 path = path.strip('/')
635 path = path.strip('/')
649 checkpoint_id = "checkpoint"
636 checkpoint_id = "checkpoint"
650 os_path = self.get_checkpoint_path(checkpoint_id, path)
637 os_path = self.get_checkpoint_path(checkpoint_id, path)
651 if not os.path.exists(os_path):
638 if not os.path.exists(os_path):
652 return []
639 return []
653 else:
640 else:
654 return [self.get_checkpoint_model(checkpoint_id, path)]
641 return [self.get_checkpoint_model(checkpoint_id, path)]
655
642
656
643
657 def restore_checkpoint(self, checkpoint_id, path):
644 def restore_checkpoint(self, checkpoint_id, path):
658 """restore a file to a checkpointed state"""
645 """restore a file to a checkpointed state"""
659 path = path.strip('/')
646 path = path.strip('/')
660 self.log.info("restoring %s from checkpoint %s", path, checkpoint_id)
647 self.log.info("restoring %s from checkpoint %s", path, checkpoint_id)
661 nb_path = self._get_os_path(path)
648 nb_path = self._get_os_path(path)
662 cp_path = self.get_checkpoint_path(checkpoint_id, path)
649 cp_path = self.get_checkpoint_path(checkpoint_id, path)
663 if not os.path.isfile(cp_path):
650 if not os.path.isfile(cp_path):
664 self.log.debug("checkpoint file does not exist: %s", cp_path)
651 self.log.debug("checkpoint file does not exist: %s", cp_path)
665 raise web.HTTPError(404,
652 raise web.HTTPError(404,
666 u'checkpoint does not exist: %s@%s' % (path, checkpoint_id)
653 u'checkpoint does not exist: %s@%s' % (path, checkpoint_id)
667 )
654 )
668 # ensure notebook is readable (never restore from an unreadable notebook)
655 # ensure notebook is readable (never restore from an unreadable notebook)
669 if cp_path.endswith('.ipynb'):
656 if cp_path.endswith('.ipynb'):
670 with self.open(cp_path, 'r', encoding='utf-8') as f:
657 with self.open(cp_path, 'r', encoding='utf-8') as f:
671 nbformat.read(f, as_version=4)
658 nbformat.read(f, as_version=4)
672 self.log.debug("copying %s -> %s", cp_path, nb_path)
659 self.log.debug("copying %s -> %s", cp_path, nb_path)
673 with self.perm_to_403():
660 with self.perm_to_403():
674 self._copy(cp_path, nb_path)
661 self._copy(cp_path, nb_path)
675
662
676 def delete_checkpoint(self, checkpoint_id, path):
663 def delete_checkpoint(self, checkpoint_id, path):
677 """delete a file's checkpoint"""
664 """delete a file's checkpoint"""
678 path = path.strip('/')
665 path = path.strip('/')
679 cp_path = self.get_checkpoint_path(checkpoint_id, path)
666 cp_path = self.get_checkpoint_path(checkpoint_id, path)
680 if not os.path.isfile(cp_path):
667 if not os.path.isfile(cp_path):
681 raise web.HTTPError(404,
668 raise web.HTTPError(404,
682 u'Checkpoint does not exist: %s@%s' % (path, checkpoint_id)
669 u'Checkpoint does not exist: %s@%s' % (path, checkpoint_id)
683 )
670 )
684 self.log.debug("unlinking %s", cp_path)
671 self.log.debug("unlinking %s", cp_path)
685 os.unlink(cp_path)
672 os.unlink(cp_path)
686
673
687 def info_string(self):
674 def info_string(self):
688 return "Serving notebooks from local directory: %s" % self.root_dir
675 return "Serving notebooks from local directory: %s" % self.root_dir
689
676
690 def get_kernel_path(self, path, model=None):
677 def get_kernel_path(self, path, model=None):
691 """Return the initial API path of a kernel associated with a given notebook"""
678 """Return the initial API path of a kernel associated with a given notebook"""
692 if '/' in path:
679 if '/' in path:
693 parent_dir = path.rsplit('/', 1)[0]
680 parent_dir = path.rsplit('/', 1)[0]
694 else:
681 else:
695 parent_dir = ''
682 parent_dir = ''
696 return parent_dir
683 return parent_dir
@@ -1,428 +1,433 b''
1 """A base class for contents managers."""
1 """A base class for contents managers."""
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 from fnmatch import fnmatch
6 from fnmatch import fnmatch
7 import itertools
7 import itertools
8 import json
8 import json
9 import os
9 import os
10 import re
10 import re
11
11
12 from tornado.web import HTTPError
12 from tornado.web import HTTPError
13
13
14 from IPython.config.configurable import LoggingConfigurable
14 from IPython.config.configurable import LoggingConfigurable
15 from IPython.nbformat import sign, validate, ValidationError
15 from IPython.nbformat import sign, validate, ValidationError
16 from IPython.nbformat.v4 import new_notebook
16 from IPython.nbformat.v4 import new_notebook
17 from IPython.utils.importstring import import_item
17 from IPython.utils.importstring import import_item
18 from IPython.utils.traitlets import Instance, Unicode, List, Any, TraitError
18 from IPython.utils.traitlets import Instance, Unicode, List, Any, TraitError
19 from IPython.utils.py3compat import string_types
19 from IPython.utils.py3compat import string_types
20
20
21 copy_pat = re.compile(r'\-Copy\d*\.')
21 copy_pat = re.compile(r'\-Copy\d*\.')
22
22
23 class ContentsManager(LoggingConfigurable):
23 class ContentsManager(LoggingConfigurable):
24 """Base class for serving files and directories.
24 """Base class for serving files and directories.
25
25
26 This serves any text or binary file,
26 This serves any text or binary file,
27 as well as directories,
27 as well as directories,
28 with special handling for JSON notebook documents.
28 with special handling for JSON notebook documents.
29
29
30 Most APIs take a path argument,
30 Most APIs take a path argument,
31 which is always an API-style unicode path,
31 which is always an API-style unicode path,
32 and always refers to a directory.
32 and always refers to a directory.
33
33
34 - unicode, not url-escaped
34 - unicode, not url-escaped
35 - '/'-separated
35 - '/'-separated
36 - leading and trailing '/' will be stripped
36 - leading and trailing '/' will be stripped
37 - if unspecified, path defaults to '',
37 - if unspecified, path defaults to '',
38 indicating the root path.
38 indicating the root path.
39
39
40 """
40 """
41
41
42 notary = Instance(sign.NotebookNotary)
42 notary = Instance(sign.NotebookNotary)
43 def _notary_default(self):
43 def _notary_default(self):
44 return sign.NotebookNotary(parent=self)
44 return sign.NotebookNotary(parent=self)
45
45
46 hide_globs = List(Unicode, [
46 hide_globs = List(Unicode, [
47 u'__pycache__', '*.pyc', '*.pyo',
47 u'__pycache__', '*.pyc', '*.pyo',
48 '.DS_Store', '*.so', '*.dylib', '*~',
48 '.DS_Store', '*.so', '*.dylib', '*~',
49 ], config=True, help="""
49 ], config=True, help="""
50 Glob patterns to hide in file and directory listings.
50 Glob patterns to hide in file and directory listings.
51 """)
51 """)
52
52
53 untitled_notebook = Unicode("Untitled", config=True,
53 untitled_notebook = Unicode("Untitled", config=True,
54 help="The base name used when creating untitled notebooks."
54 help="The base name used when creating untitled notebooks."
55 )
55 )
56
56
57 untitled_file = Unicode("untitled", config=True,
57 untitled_file = Unicode("untitled", config=True,
58 help="The base name used when creating untitled files."
58 help="The base name used when creating untitled files."
59 )
59 )
60
60
61 untitled_directory = Unicode("Untitled Folder", config=True,
61 untitled_directory = Unicode("Untitled Folder", config=True,
62 help="The base name used when creating untitled directories."
62 help="The base name used when creating untitled directories."
63 )
63 )
64
64
65 pre_save_hook = Any(None, config=True,
65 pre_save_hook = Any(None, config=True,
66 help="""Python callable or importstring thereof
66 help="""Python callable or importstring thereof
67
67
68 To be called on a contents model prior to save.
68 To be called on a contents model prior to save.
69
69
70 This can be used to process the structure,
70 This can be used to process the structure,
71 such as removing notebook outputs or other side effects that
71 such as removing notebook outputs or other side effects that
72 should not be saved.
72 should not be saved.
73
73
74 It will be called as (all arguments passed by keyword):
74 It will be called as (all arguments passed by keyword):
75
75
76 hook(path=path, model=model, contents_manager=self)
76 hook(path=path, model=model, contents_manager=self)
77
77
78 model: the model to be saved. Includes file contents.
78 model: the model to be saved. Includes file contents.
79 modifying this dict will affect the file that is stored.
79 modifying this dict will affect the file that is stored.
80 path: the API path of the save destination
80 path: the API path of the save destination
81 contents_manager: this ContentsManager instance
81 contents_manager: this ContentsManager instance
82 """
82 """
83 )
83 )
84 def _pre_save_hook_changed(self, name, old, new):
84 def _pre_save_hook_changed(self, name, old, new):
85 if new and isinstance(new, string_types):
85 if new and isinstance(new, string_types):
86 self.pre_save_hook = import_item(self.pre_save_hook)
86 self.pre_save_hook = import_item(self.pre_save_hook)
87 elif new:
87 elif new:
88 if not callable(new):
88 if not callable(new):
89 raise TraitError("pre_save_hook must be callable")
89 raise TraitError("pre_save_hook must be callable")
90
90
91 def run_pre_save_hook(self, model, path, **kwargs):
91 def run_pre_save_hook(self, model, path, **kwargs):
92 """Run the pre-save hook if defined, and log errors"""
92 """Run the pre-save hook if defined, and log errors"""
93 if self.pre_save_hook:
93 if self.pre_save_hook:
94 try:
94 try:
95 self.log.debug("Running pre-save hook on %s", path)
95 self.log.debug("Running pre-save hook on %s", path)
96 self.pre_save_hook(model=model, path=path, contents_manager=self, **kwargs)
96 self.pre_save_hook(model=model, path=path, contents_manager=self, **kwargs)
97 except Exception:
97 except Exception:
98 self.log.error("Pre-save hook failed on %s", path, exc_info=True)
98 self.log.error("Pre-save hook failed on %s", path, exc_info=True)
99
99
100 # ContentsManager API part 1: methods that must be
100 # ContentsManager API part 1: methods that must be
101 # implemented in subclasses.
101 # implemented in subclasses.
102
102
103 def dir_exists(self, path):
103 def dir_exists(self, path):
104 """Does the API-style path (directory) actually exist?
104 """Does the API-style path (directory) actually exist?
105
105
106 Like os.path.isdir
106 Like os.path.isdir
107
107
108 Override this method in subclasses.
108 Override this method in subclasses.
109
109
110 Parameters
110 Parameters
111 ----------
111 ----------
112 path : string
112 path : string
113 The path to check
113 The path to check
114
114
115 Returns
115 Returns
116 -------
116 -------
117 exists : bool
117 exists : bool
118 Whether the path does indeed exist.
118 Whether the path does indeed exist.
119 """
119 """
120 raise NotImplementedError
120 raise NotImplementedError
121
121
122 def is_hidden(self, path):
122 def is_hidden(self, path):
123 """Does the API style path correspond to a hidden directory or file?
123 """Does the API style path correspond to a hidden directory or file?
124
124
125 Parameters
125 Parameters
126 ----------
126 ----------
127 path : string
127 path : string
128 The path to check. This is an API path (`/` separated,
128 The path to check. This is an API path (`/` separated,
129 relative to root dir).
129 relative to root dir).
130
130
131 Returns
131 Returns
132 -------
132 -------
133 hidden : bool
133 hidden : bool
134 Whether the path is hidden.
134 Whether the path is hidden.
135
135
136 """
136 """
137 raise NotImplementedError
137 raise NotImplementedError
138
138
139 def file_exists(self, path=''):
139 def file_exists(self, path=''):
140 """Does a file exist at the given path?
140 """Does a file exist at the given path?
141
141
142 Like os.path.isfile
142 Like os.path.isfile
143
143
144 Override this method in subclasses.
144 Override this method in subclasses.
145
145
146 Parameters
146 Parameters
147 ----------
147 ----------
148 name : string
148 name : string
149 The name of the file you are checking.
149 The name of the file you are checking.
150 path : string
150 path : string
151 The relative path to the file's directory (with '/' as separator)
151 The relative path to the file's directory (with '/' as separator)
152
152
153 Returns
153 Returns
154 -------
154 -------
155 exists : bool
155 exists : bool
156 Whether the file exists.
156 Whether the file exists.
157 """
157 """
158 raise NotImplementedError('must be implemented in a subclass')
158 raise NotImplementedError('must be implemented in a subclass')
159
159
160 def exists(self, path):
160 def exists(self, path):
161 """Does a file or directory exist at the given path?
161 """Does a file or directory exist at the given path?
162
162
163 Like os.path.exists
163 Like os.path.exists
164
164
165 Parameters
165 Parameters
166 ----------
166 ----------
167 path : string
167 path : string
168 The relative path to the file's directory (with '/' as separator)
168 The relative path to the file's directory (with '/' as separator)
169
169
170 Returns
170 Returns
171 -------
171 -------
172 exists : bool
172 exists : bool
173 Whether the target exists.
173 Whether the target exists.
174 """
174 """
175 return self.file_exists(path) or self.dir_exists(path)
175 return self.file_exists(path) or self.dir_exists(path)
176
176
177 def get(self, path, content=True, type=None, format=None):
177 def get(self, path, content=True, type=None, format=None):
178 """Get the model of a file or directory with or without content."""
178 """Get the model of a file or directory with or without content."""
179 raise NotImplementedError('must be implemented in a subclass')
179 raise NotImplementedError('must be implemented in a subclass')
180
180
181 def save(self, model, path):
181 def save(self, model, path):
182 """Save the file or directory and return the model with no content.
182 """Save the file or directory and return the model with no content.
183
183
184 Save implementations should call self.run_pre_save_hook(model=model, path=path)
184 Save implementations should call self.run_pre_save_hook(model=model, path=path)
185 prior to writing any data.
185 prior to writing any data.
186 """
186 """
187 raise NotImplementedError('must be implemented in a subclass')
187 raise NotImplementedError('must be implemented in a subclass')
188
188
189 def update(self, model, path):
190 """Update the file or directory and return the model with no content.
191
192 For use in PATCH requests, to enable renaming a file without
193 re-uploading its contents. Only used for renaming at the moment.
194 """
195 raise NotImplementedError('must be implemented in a subclass')
196
197 def delete(self, path):
189 def delete(self, path):
198 """Delete file or directory by path."""
190 """Delete file or directory by path."""
199 raise NotImplementedError('must be implemented in a subclass')
191 raise NotImplementedError('must be implemented in a subclass')
200
192
201 def create_checkpoint(self, path):
193 def create_checkpoint(self, path):
202 """Create a checkpoint of the current state of a file
194 """Create a checkpoint of the current state of a file
203
195
204 Returns a checkpoint_id for the new checkpoint.
196 Returns a checkpoint_id for the new checkpoint.
205 """
197 """
206 raise NotImplementedError("must be implemented in a subclass")
198 raise NotImplementedError("must be implemented in a subclass")
207
199
208 def list_checkpoints(self, path):
200 def list_checkpoints(self, path):
209 """Return a list of checkpoints for a given file"""
201 """Return a list of checkpoints for a given file"""
210 return []
202 return []
211
203
212 def restore_checkpoint(self, checkpoint_id, path):
204 def restore_checkpoint(self, checkpoint_id, path):
213 """Restore a file from one of its checkpoints"""
205 """Restore a file from one of its checkpoints"""
214 raise NotImplementedError("must be implemented in a subclass")
206 raise NotImplementedError("must be implemented in a subclass")
215
207
216 def delete_checkpoint(self, checkpoint_id, path):
208 def delete_checkpoint(self, checkpoint_id, path):
217 """delete a checkpoint for a file"""
209 """delete a checkpoint for a file"""
218 raise NotImplementedError("must be implemented in a subclass")
210 raise NotImplementedError("must be implemented in a subclass")
219
211
220 # ContentsManager API part 2: methods that have useable default
212 # ContentsManager API part 2: methods that have useable default
221 # implementations, but can be overridden in subclasses.
213 # implementations, but can be overridden in subclasses.
222
214
215 def update(self, model, path):
216 """Update the file's path
217
218 For use in PATCH requests, to enable renaming a file without
219 re-uploading its contents. Only used for renaming at the moment.
220 """
221 path = path.strip('/')
222 new_path = model.get('path', path).strip('/')
223 if path != new_path:
224 self.rename(path, new_path)
225 model = self.get(new_path, content=False)
226 return model
227
223 def info_string(self):
228 def info_string(self):
224 return "Serving contents"
229 return "Serving contents"
225
230
226 def get_kernel_path(self, path, model=None):
231 def get_kernel_path(self, path, model=None):
227 """Return the API path for the kernel
232 """Return the API path for the kernel
228
233
229 KernelManagers can turn this value into a filesystem path,
234 KernelManagers can turn this value into a filesystem path,
230 or ignore it altogether.
235 or ignore it altogether.
231
236
232 The default value here will start kernels in the directory of the
237 The default value here will start kernels in the directory of the
233 notebook server. FileContentsManager overrides this to use the
238 notebook server. FileContentsManager overrides this to use the
234 directory containing the notebook.
239 directory containing the notebook.
235 """
240 """
236 return ''
241 return ''
237
242
238 def increment_filename(self, filename, path='', insert=''):
243 def increment_filename(self, filename, path='', insert=''):
239 """Increment a filename until it is unique.
244 """Increment a filename until it is unique.
240
245
241 Parameters
246 Parameters
242 ----------
247 ----------
243 filename : unicode
248 filename : unicode
244 The name of a file, including extension
249 The name of a file, including extension
245 path : unicode
250 path : unicode
246 The API path of the target's directory
251 The API path of the target's directory
247
252
248 Returns
253 Returns
249 -------
254 -------
250 name : unicode
255 name : unicode
251 A filename that is unique, based on the input filename.
256 A filename that is unique, based on the input filename.
252 """
257 """
253 path = path.strip('/')
258 path = path.strip('/')
254 basename, ext = os.path.splitext(filename)
259 basename, ext = os.path.splitext(filename)
255 for i in itertools.count():
260 for i in itertools.count():
256 if i:
261 if i:
257 insert_i = '{}{}'.format(insert, i)
262 insert_i = '{}{}'.format(insert, i)
258 else:
263 else:
259 insert_i = ''
264 insert_i = ''
260 name = u'{basename}{insert}{ext}'.format(basename=basename,
265 name = u'{basename}{insert}{ext}'.format(basename=basename,
261 insert=insert_i, ext=ext)
266 insert=insert_i, ext=ext)
262 if not self.exists(u'{}/{}'.format(path, name)):
267 if not self.exists(u'{}/{}'.format(path, name)):
263 break
268 break
264 return name
269 return name
265
270
266 def validate_notebook_model(self, model):
271 def validate_notebook_model(self, model):
267 """Add failed-validation message to model"""
272 """Add failed-validation message to model"""
268 try:
273 try:
269 validate(model['content'])
274 validate(model['content'])
270 except ValidationError as e:
275 except ValidationError as e:
271 model['message'] = u'Notebook Validation failed: {}:\n{}'.format(
276 model['message'] = u'Notebook Validation failed: {}:\n{}'.format(
272 e.message, json.dumps(e.instance, indent=1, default=lambda obj: '<UNKNOWN>'),
277 e.message, json.dumps(e.instance, indent=1, default=lambda obj: '<UNKNOWN>'),
273 )
278 )
274 return model
279 return model
275
280
276 def new_untitled(self, path='', type='', ext=''):
281 def new_untitled(self, path='', type='', ext=''):
277 """Create a new untitled file or directory in path
282 """Create a new untitled file or directory in path
278
283
279 path must be a directory
284 path must be a directory
280
285
281 File extension can be specified.
286 File extension can be specified.
282
287
283 Use `new` to create files with a fully specified path (including filename).
288 Use `new` to create files with a fully specified path (including filename).
284 """
289 """
285 path = path.strip('/')
290 path = path.strip('/')
286 if not self.dir_exists(path):
291 if not self.dir_exists(path):
287 raise HTTPError(404, 'No such directory: %s' % path)
292 raise HTTPError(404, 'No such directory: %s' % path)
288
293
289 model = {}
294 model = {}
290 if type:
295 if type:
291 model['type'] = type
296 model['type'] = type
292
297
293 if ext == '.ipynb':
298 if ext == '.ipynb':
294 model.setdefault('type', 'notebook')
299 model.setdefault('type', 'notebook')
295 else:
300 else:
296 model.setdefault('type', 'file')
301 model.setdefault('type', 'file')
297
302
298 insert = ''
303 insert = ''
299 if model['type'] == 'directory':
304 if model['type'] == 'directory':
300 untitled = self.untitled_directory
305 untitled = self.untitled_directory
301 insert = ' '
306 insert = ' '
302 elif model['type'] == 'notebook':
307 elif model['type'] == 'notebook':
303 untitled = self.untitled_notebook
308 untitled = self.untitled_notebook
304 ext = '.ipynb'
309 ext = '.ipynb'
305 elif model['type'] == 'file':
310 elif model['type'] == 'file':
306 untitled = self.untitled_file
311 untitled = self.untitled_file
307 else:
312 else:
308 raise HTTPError(400, "Unexpected model type: %r" % model['type'])
313 raise HTTPError(400, "Unexpected model type: %r" % model['type'])
309
314
310 name = self.increment_filename(untitled + ext, path, insert=insert)
315 name = self.increment_filename(untitled + ext, path, insert=insert)
311 path = u'{0}/{1}'.format(path, name)
316 path = u'{0}/{1}'.format(path, name)
312 return self.new(model, path)
317 return self.new(model, path)
313
318
314 def new(self, model=None, path=''):
319 def new(self, model=None, path=''):
315 """Create a new file or directory and return its model with no content.
320 """Create a new file or directory and return its model with no content.
316
321
317 To create a new untitled entity in a directory, use `new_untitled`.
322 To create a new untitled entity in a directory, use `new_untitled`.
318 """
323 """
319 path = path.strip('/')
324 path = path.strip('/')
320 if model is None:
325 if model is None:
321 model = {}
326 model = {}
322
327
323 if path.endswith('.ipynb'):
328 if path.endswith('.ipynb'):
324 model.setdefault('type', 'notebook')
329 model.setdefault('type', 'notebook')
325 else:
330 else:
326 model.setdefault('type', 'file')
331 model.setdefault('type', 'file')
327
332
328 # no content, not a directory, so fill out new-file model
333 # no content, not a directory, so fill out new-file model
329 if 'content' not in model and model['type'] != 'directory':
334 if 'content' not in model and model['type'] != 'directory':
330 if model['type'] == 'notebook':
335 if model['type'] == 'notebook':
331 model['content'] = new_notebook()
336 model['content'] = new_notebook()
332 model['format'] = 'json'
337 model['format'] = 'json'
333 else:
338 else:
334 model['content'] = ''
339 model['content'] = ''
335 model['type'] = 'file'
340 model['type'] = 'file'
336 model['format'] = 'text'
341 model['format'] = 'text'
337
342
338 model = self.save(model, path)
343 model = self.save(model, path)
339 return model
344 return model
340
345
341 def copy(self, from_path, to_path=None):
346 def copy(self, from_path, to_path=None):
342 """Copy an existing file and return its new model.
347 """Copy an existing file and return its new model.
343
348
344 If to_path not specified, it will be the parent directory of from_path.
349 If to_path not specified, it will be the parent directory of from_path.
345 If to_path is a directory, filename will increment `from_path-Copy#.ext`.
350 If to_path is a directory, filename will increment `from_path-Copy#.ext`.
346
351
347 from_path must be a full path to a file.
352 from_path must be a full path to a file.
348 """
353 """
349 path = from_path.strip('/')
354 path = from_path.strip('/')
350 if to_path is not None:
355 if to_path is not None:
351 to_path = to_path.strip('/')
356 to_path = to_path.strip('/')
352
357
353 if '/' in path:
358 if '/' in path:
354 from_dir, from_name = path.rsplit('/', 1)
359 from_dir, from_name = path.rsplit('/', 1)
355 else:
360 else:
356 from_dir = ''
361 from_dir = ''
357 from_name = path
362 from_name = path
358
363
359 model = self.get(path)
364 model = self.get(path)
360 model.pop('path', None)
365 model.pop('path', None)
361 model.pop('name', None)
366 model.pop('name', None)
362 if model['type'] == 'directory':
367 if model['type'] == 'directory':
363 raise HTTPError(400, "Can't copy directories")
368 raise HTTPError(400, "Can't copy directories")
364
369
365 if to_path is None:
370 if to_path is None:
366 to_path = from_dir
371 to_path = from_dir
367 if self.dir_exists(to_path):
372 if self.dir_exists(to_path):
368 name = copy_pat.sub(u'.', from_name)
373 name = copy_pat.sub(u'.', from_name)
369 to_name = self.increment_filename(name, to_path, insert='-Copy')
374 to_name = self.increment_filename(name, to_path, insert='-Copy')
370 to_path = u'{0}/{1}'.format(to_path, to_name)
375 to_path = u'{0}/{1}'.format(to_path, to_name)
371
376
372 model = self.save(model, to_path)
377 model = self.save(model, to_path)
373 return model
378 return model
374
379
375 def log_info(self):
380 def log_info(self):
376 self.log.info(self.info_string())
381 self.log.info(self.info_string())
377
382
378 def trust_notebook(self, path):
383 def trust_notebook(self, path):
379 """Explicitly trust a notebook
384 """Explicitly trust a notebook
380
385
381 Parameters
386 Parameters
382 ----------
387 ----------
383 path : string
388 path : string
384 The path of a notebook
389 The path of a notebook
385 """
390 """
386 model = self.get(path)
391 model = self.get(path)
387 nb = model['content']
392 nb = model['content']
388 self.log.warn("Trusting notebook %s", path)
393 self.log.warn("Trusting notebook %s", path)
389 self.notary.mark_cells(nb, True)
394 self.notary.mark_cells(nb, True)
390 self.save(model, path)
395 self.save(model, path)
391
396
392 def check_and_sign(self, nb, path=''):
397 def check_and_sign(self, nb, path=''):
393 """Check for trusted cells, and sign the notebook.
398 """Check for trusted cells, and sign the notebook.
394
399
395 Called as a part of saving notebooks.
400 Called as a part of saving notebooks.
396
401
397 Parameters
402 Parameters
398 ----------
403 ----------
399 nb : dict
404 nb : dict
400 The notebook dict
405 The notebook dict
401 path : string
406 path : string
402 The notebook's path (for logging)
407 The notebook's path (for logging)
403 """
408 """
404 if self.notary.check_cells(nb):
409 if self.notary.check_cells(nb):
405 self.notary.sign(nb)
410 self.notary.sign(nb)
406 else:
411 else:
407 self.log.warn("Saving untrusted notebook %s", path)
412 self.log.warn("Saving untrusted notebook %s", path)
408
413
409 def mark_trusted_cells(self, nb, path=''):
414 def mark_trusted_cells(self, nb, path=''):
410 """Mark cells as trusted if the notebook signature matches.
415 """Mark cells as trusted if the notebook signature matches.
411
416
412 Called as a part of loading notebooks.
417 Called as a part of loading notebooks.
413
418
414 Parameters
419 Parameters
415 ----------
420 ----------
416 nb : dict
421 nb : dict
417 The notebook object (in current nbformat)
422 The notebook object (in current nbformat)
418 path : string
423 path : string
419 The notebook's path (for logging)
424 The notebook's path (for logging)
420 """
425 """
421 trusted = self.notary.check_signature(nb)
426 trusted = self.notary.check_signature(nb)
422 if not trusted:
427 if not trusted:
423 self.log.warn("Notebook %s is not trusted", path)
428 self.log.warn("Notebook %s is not trusted", path)
424 self.notary.mark_cells(nb, trusted)
429 self.notary.mark_cells(nb, trusted)
425
430
426 def should_list(self, name):
431 def should_list(self, name):
427 """Should this file/directory name be displayed in a listing?"""
432 """Should this file/directory name be displayed in a listing?"""
428 return not any(fnmatch(name, glob) for glob in self.hide_globs)
433 return not any(fnmatch(name, glob) for glob in self.hide_globs)
General Comments 0
You need to be logged in to leave comments. Login now