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