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