##// END OF EJS Templates
BUG: Sanitize to_path in ContentsManager.copy....
Scott Sanderson -
Show More
@@ -1,425 +1,428 b''
1 """A base class for contents managers."""
1 """A base class for contents managers."""
2
2
3 # Copyright (c) IPython Development Team.
3 # Copyright (c) IPython Development Team.
4 # Distributed under the terms of the Modified BSD License.
4 # Distributed under the terms of the Modified BSD License.
5
5
6 from fnmatch import fnmatch
6 from fnmatch import fnmatch
7 import itertools
7 import itertools
8 import json
8 import json
9 import os
9 import os
10 import re
10 import re
11
11
12 from tornado.web import HTTPError
12 from tornado.web import HTTPError
13
13
14 from IPython.config.configurable import LoggingConfigurable
14 from IPython.config.configurable import LoggingConfigurable
15 from IPython.nbformat import sign, validate, ValidationError
15 from IPython.nbformat import sign, validate, ValidationError
16 from IPython.nbformat.v4 import new_notebook
16 from IPython.nbformat.v4 import new_notebook
17 from IPython.utils.importstring import import_item
17 from IPython.utils.importstring import import_item
18 from IPython.utils.traitlets import Instance, Unicode, List, Any, TraitError
18 from IPython.utils.traitlets import Instance, Unicode, List, Any, TraitError
19 from IPython.utils.py3compat import string_types
19 from IPython.utils.py3compat import string_types
20
20
21 copy_pat = re.compile(r'\-Copy\d*\.')
21 copy_pat = re.compile(r'\-Copy\d*\.')
22
22
23 class ContentsManager(LoggingConfigurable):
23 class ContentsManager(LoggingConfigurable):
24 """Base class for serving files and directories.
24 """Base class for serving files and directories.
25
25
26 This serves any text or binary file,
26 This serves any text or binary file,
27 as well as directories,
27 as well as directories,
28 with special handling for JSON notebook documents.
28 with special handling for JSON notebook documents.
29
29
30 Most APIs take a path argument,
30 Most APIs take a path argument,
31 which is always an API-style unicode path,
31 which is always an API-style unicode path,
32 and always refers to a directory.
32 and always refers to a directory.
33
33
34 - unicode, not url-escaped
34 - unicode, not url-escaped
35 - '/'-separated
35 - '/'-separated
36 - leading and trailing '/' will be stripped
36 - leading and trailing '/' will be stripped
37 - if unspecified, path defaults to '',
37 - if unspecified, path defaults to '',
38 indicating the root path.
38 indicating the root path.
39
39
40 """
40 """
41
41
42 notary = Instance(sign.NotebookNotary)
42 notary = Instance(sign.NotebookNotary)
43 def _notary_default(self):
43 def _notary_default(self):
44 return sign.NotebookNotary(parent=self)
44 return sign.NotebookNotary(parent=self)
45
45
46 hide_globs = List(Unicode, [
46 hide_globs = List(Unicode, [
47 u'__pycache__', '*.pyc', '*.pyo',
47 u'__pycache__', '*.pyc', '*.pyo',
48 '.DS_Store', '*.so', '*.dylib', '*~',
48 '.DS_Store', '*.so', '*.dylib', '*~',
49 ], config=True, help="""
49 ], config=True, help="""
50 Glob patterns to hide in file and directory listings.
50 Glob patterns to hide in file and directory listings.
51 """)
51 """)
52
52
53 untitled_notebook = Unicode("Untitled", config=True,
53 untitled_notebook = Unicode("Untitled", config=True,
54 help="The base name used when creating untitled notebooks."
54 help="The base name used when creating untitled notebooks."
55 )
55 )
56
56
57 untitled_file = Unicode("untitled", config=True,
57 untitled_file = Unicode("untitled", config=True,
58 help="The base name used when creating untitled files."
58 help="The base name used when creating untitled files."
59 )
59 )
60
60
61 untitled_directory = Unicode("Untitled Folder", config=True,
61 untitled_directory = Unicode("Untitled Folder", config=True,
62 help="The base name used when creating untitled directories."
62 help="The base name used when creating untitled directories."
63 )
63 )
64
64
65 pre_save_hook = Any(None, config=True,
65 pre_save_hook = Any(None, config=True,
66 help="""Python callable or importstring thereof
66 help="""Python callable or importstring thereof
67
67
68 To be called on a contents model prior to save.
68 To be called on a contents model prior to save.
69
69
70 This can be used to process the structure,
70 This can be used to process the structure,
71 such as removing notebook outputs or other side effects that
71 such as removing notebook outputs or other side effects that
72 should not be saved.
72 should not be saved.
73
73
74 It will be called as (all arguments passed by keyword):
74 It will be called as (all arguments passed by keyword):
75
75
76 hook(path=path, model=model, contents_manager=self)
76 hook(path=path, model=model, contents_manager=self)
77
77
78 model: the model to be saved. Includes file contents.
78 model: the model to be saved. Includes file contents.
79 modifying this dict will affect the file that is stored.
79 modifying this dict will affect the file that is stored.
80 path: the API path of the save destination
80 path: the API path of the save destination
81 contents_manager: this ContentsManager instance
81 contents_manager: this ContentsManager instance
82 """
82 """
83 )
83 )
84 def _pre_save_hook_changed(self, name, old, new):
84 def _pre_save_hook_changed(self, name, old, new):
85 if new and isinstance(new, string_types):
85 if new and isinstance(new, string_types):
86 self.pre_save_hook = import_item(self.pre_save_hook)
86 self.pre_save_hook = import_item(self.pre_save_hook)
87 elif new:
87 elif new:
88 if not callable(new):
88 if not callable(new):
89 raise TraitError("pre_save_hook must be callable")
89 raise TraitError("pre_save_hook must be callable")
90
90
91 def run_pre_save_hook(self, model, path, **kwargs):
91 def run_pre_save_hook(self, model, path, **kwargs):
92 """Run the pre-save hook if defined, and log errors"""
92 """Run the pre-save hook if defined, and log errors"""
93 if self.pre_save_hook:
93 if self.pre_save_hook:
94 try:
94 try:
95 self.log.debug("Running pre-save hook on %s", path)
95 self.log.debug("Running pre-save hook on %s", path)
96 self.pre_save_hook(model=model, path=path, contents_manager=self, **kwargs)
96 self.pre_save_hook(model=model, path=path, contents_manager=self, **kwargs)
97 except Exception:
97 except Exception:
98 self.log.error("Pre-save hook failed on %s", path, exc_info=True)
98 self.log.error("Pre-save hook failed on %s", path, exc_info=True)
99
99
100 # ContentsManager API part 1: methods that must be
100 # ContentsManager API part 1: methods that must be
101 # implemented in subclasses.
101 # implemented in subclasses.
102
102
103 def dir_exists(self, path):
103 def dir_exists(self, path):
104 """Does the API-style path (directory) actually exist?
104 """Does the API-style path (directory) actually exist?
105
105
106 Like os.path.isdir
106 Like os.path.isdir
107
107
108 Override this method in subclasses.
108 Override this method in subclasses.
109
109
110 Parameters
110 Parameters
111 ----------
111 ----------
112 path : string
112 path : string
113 The path to check
113 The path to check
114
114
115 Returns
115 Returns
116 -------
116 -------
117 exists : bool
117 exists : bool
118 Whether the path does indeed exist.
118 Whether the path does indeed exist.
119 """
119 """
120 raise NotImplementedError
120 raise NotImplementedError
121
121
122 def is_hidden(self, path):
122 def is_hidden(self, path):
123 """Does the API style path correspond to a hidden directory or file?
123 """Does the API style path correspond to a hidden directory or file?
124
124
125 Parameters
125 Parameters
126 ----------
126 ----------
127 path : string
127 path : string
128 The path to check. This is an API path (`/` separated,
128 The path to check. This is an API path (`/` separated,
129 relative to root dir).
129 relative to root dir).
130
130
131 Returns
131 Returns
132 -------
132 -------
133 hidden : bool
133 hidden : bool
134 Whether the path is hidden.
134 Whether the path is hidden.
135
135
136 """
136 """
137 raise NotImplementedError
137 raise NotImplementedError
138
138
139 def file_exists(self, path=''):
139 def file_exists(self, path=''):
140 """Does a file exist at the given path?
140 """Does a file exist at the given path?
141
141
142 Like os.path.isfile
142 Like os.path.isfile
143
143
144 Override this method in subclasses.
144 Override this method in subclasses.
145
145
146 Parameters
146 Parameters
147 ----------
147 ----------
148 name : string
148 name : string
149 The name of the file you are checking.
149 The name of the file you are checking.
150 path : string
150 path : string
151 The relative path to the file's directory (with '/' as separator)
151 The relative path to the file's directory (with '/' as separator)
152
152
153 Returns
153 Returns
154 -------
154 -------
155 exists : bool
155 exists : bool
156 Whether the file exists.
156 Whether the file exists.
157 """
157 """
158 raise NotImplementedError('must be implemented in a subclass')
158 raise NotImplementedError('must be implemented in a subclass')
159
159
160 def exists(self, path):
160 def exists(self, path):
161 """Does a file or directory exist at the given path?
161 """Does a file or directory exist at the given path?
162
162
163 Like os.path.exists
163 Like os.path.exists
164
164
165 Parameters
165 Parameters
166 ----------
166 ----------
167 path : string
167 path : string
168 The relative path to the file's directory (with '/' as separator)
168 The relative path to the file's directory (with '/' as separator)
169
169
170 Returns
170 Returns
171 -------
171 -------
172 exists : bool
172 exists : bool
173 Whether the target exists.
173 Whether the target exists.
174 """
174 """
175 return self.file_exists(path) or self.dir_exists(path)
175 return self.file_exists(path) or self.dir_exists(path)
176
176
177 def get(self, path, content=True, type=None, format=None):
177 def get(self, path, content=True, type=None, format=None):
178 """Get the model of a file or directory with or without content."""
178 """Get the model of a file or directory with or without content."""
179 raise NotImplementedError('must be implemented in a subclass')
179 raise NotImplementedError('must be implemented in a subclass')
180
180
181 def save(self, model, path):
181 def save(self, model, path):
182 """Save the file or directory and return the model with no content.
182 """Save the file or directory and return the model with no content.
183
183
184 Save implementations should call self.run_pre_save_hook(model=model, path=path)
184 Save implementations should call self.run_pre_save_hook(model=model, path=path)
185 prior to writing any data.
185 prior to writing any data.
186 """
186 """
187 raise NotImplementedError('must be implemented in a subclass')
187 raise NotImplementedError('must be implemented in a subclass')
188
188
189 def update(self, model, path):
189 def update(self, model, path):
190 """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.
191
191
192 For use in PATCH requests, to enable renaming a file without
192 For use in PATCH requests, to enable renaming a file without
193 re-uploading its contents. Only used for renaming at the moment.
193 re-uploading its contents. Only used for renaming at the moment.
194 """
194 """
195 raise NotImplementedError('must be implemented in a subclass')
195 raise NotImplementedError('must be implemented in a subclass')
196
196
197 def delete(self, path):
197 def delete(self, path):
198 """Delete file or directory by path."""
198 """Delete file or directory by path."""
199 raise NotImplementedError('must be implemented in a subclass')
199 raise NotImplementedError('must be implemented in a subclass')
200
200
201 def create_checkpoint(self, path):
201 def create_checkpoint(self, path):
202 """Create a checkpoint of the current state of a file
202 """Create a checkpoint of the current state of a file
203
203
204 Returns a checkpoint_id for the new checkpoint.
204 Returns a checkpoint_id for the new checkpoint.
205 """
205 """
206 raise NotImplementedError("must be implemented in a subclass")
206 raise NotImplementedError("must be implemented in a subclass")
207
207
208 def list_checkpoints(self, path):
208 def list_checkpoints(self, path):
209 """Return a list of checkpoints for a given file"""
209 """Return a list of checkpoints for a given file"""
210 return []
210 return []
211
211
212 def restore_checkpoint(self, checkpoint_id, path):
212 def restore_checkpoint(self, checkpoint_id, path):
213 """Restore a file from one of its checkpoints"""
213 """Restore a file from one of its checkpoints"""
214 raise NotImplementedError("must be implemented in a subclass")
214 raise NotImplementedError("must be implemented in a subclass")
215
215
216 def delete_checkpoint(self, checkpoint_id, path):
216 def delete_checkpoint(self, checkpoint_id, path):
217 """delete a checkpoint for a file"""
217 """delete a checkpoint for a file"""
218 raise NotImplementedError("must be implemented in a subclass")
218 raise NotImplementedError("must be implemented in a subclass")
219
219
220 # ContentsManager API part 2: methods that have useable default
220 # ContentsManager API part 2: methods that have useable default
221 # implementations, but can be overridden in subclasses.
221 # implementations, but can be overridden in subclasses.
222
222
223 def info_string(self):
223 def info_string(self):
224 return "Serving contents"
224 return "Serving contents"
225
225
226 def get_kernel_path(self, path, model=None):
226 def get_kernel_path(self, path, model=None):
227 """Return the API path for the kernel
227 """Return the API path for the kernel
228
228
229 KernelManagers can turn this value into a filesystem path,
229 KernelManagers can turn this value into a filesystem path,
230 or ignore it altogether.
230 or ignore it altogether.
231
231
232 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
233 notebook server. FileContentsManager overrides this to use the
233 notebook server. FileContentsManager overrides this to use the
234 directory containing the notebook.
234 directory containing the notebook.
235 """
235 """
236 return ''
236 return ''
237
237
238 def increment_filename(self, filename, path='', insert=''):
238 def increment_filename(self, filename, path='', insert=''):
239 """Increment a filename until it is unique.
239 """Increment a filename until it is unique.
240
240
241 Parameters
241 Parameters
242 ----------
242 ----------
243 filename : unicode
243 filename : unicode
244 The name of a file, including extension
244 The name of a file, including extension
245 path : unicode
245 path : unicode
246 The API path of the target's directory
246 The API path of the target's directory
247
247
248 Returns
248 Returns
249 -------
249 -------
250 name : unicode
250 name : unicode
251 A filename that is unique, based on the input filename.
251 A filename that is unique, based on the input filename.
252 """
252 """
253 path = path.strip('/')
253 path = path.strip('/')
254 basename, ext = os.path.splitext(filename)
254 basename, ext = os.path.splitext(filename)
255 for i in itertools.count():
255 for i in itertools.count():
256 if i:
256 if i:
257 insert_i = '{}{}'.format(insert, i)
257 insert_i = '{}{}'.format(insert, i)
258 else:
258 else:
259 insert_i = ''
259 insert_i = ''
260 name = u'{basename}{insert}{ext}'.format(basename=basename,
260 name = u'{basename}{insert}{ext}'.format(basename=basename,
261 insert=insert_i, ext=ext)
261 insert=insert_i, ext=ext)
262 if not self.exists(u'{}/{}'.format(path, name)):
262 if not self.exists(u'{}/{}'.format(path, name)):
263 break
263 break
264 return name
264 return name
265
265
266 def validate_notebook_model(self, model):
266 def validate_notebook_model(self, model):
267 """Add failed-validation message to model"""
267 """Add failed-validation message to model"""
268 try:
268 try:
269 validate(model['content'])
269 validate(model['content'])
270 except ValidationError as e:
270 except ValidationError as e:
271 model['message'] = u'Notebook Validation failed: {}:\n{}'.format(
271 model['message'] = u'Notebook Validation failed: {}:\n{}'.format(
272 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>'),
273 )
273 )
274 return model
274 return model
275
275
276 def new_untitled(self, path='', type='', ext=''):
276 def new_untitled(self, path='', type='', ext=''):
277 """Create a new untitled file or directory in path
277 """Create a new untitled file or directory in path
278
278
279 path must be a directory
279 path must be a directory
280
280
281 File extension can be specified.
281 File extension can be specified.
282
282
283 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).
284 """
284 """
285 path = path.strip('/')
285 path = path.strip('/')
286 if not self.dir_exists(path):
286 if not self.dir_exists(path):
287 raise HTTPError(404, 'No such directory: %s' % path)
287 raise HTTPError(404, 'No such directory: %s' % path)
288
288
289 model = {}
289 model = {}
290 if type:
290 if type:
291 model['type'] = type
291 model['type'] = type
292
292
293 if ext == '.ipynb':
293 if ext == '.ipynb':
294 model.setdefault('type', 'notebook')
294 model.setdefault('type', 'notebook')
295 else:
295 else:
296 model.setdefault('type', 'file')
296 model.setdefault('type', 'file')
297
297
298 insert = ''
298 insert = ''
299 if model['type'] == 'directory':
299 if model['type'] == 'directory':
300 untitled = self.untitled_directory
300 untitled = self.untitled_directory
301 insert = ' '
301 insert = ' '
302 elif model['type'] == 'notebook':
302 elif model['type'] == 'notebook':
303 untitled = self.untitled_notebook
303 untitled = self.untitled_notebook
304 ext = '.ipynb'
304 ext = '.ipynb'
305 elif model['type'] == 'file':
305 elif model['type'] == 'file':
306 untitled = self.untitled_file
306 untitled = self.untitled_file
307 else:
307 else:
308 raise HTTPError(400, "Unexpected model type: %r" % model['type'])
308 raise HTTPError(400, "Unexpected model type: %r" % model['type'])
309
309
310 name = self.increment_filename(untitled + ext, path, insert=insert)
310 name = self.increment_filename(untitled + ext, path, insert=insert)
311 path = u'{0}/{1}'.format(path, name)
311 path = u'{0}/{1}'.format(path, name)
312 return self.new(model, path)
312 return self.new(model, path)
313
313
314 def new(self, model=None, path=''):
314 def new(self, model=None, path=''):
315 """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.
316
316
317 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`.
318 """
318 """
319 path = path.strip('/')
319 path = path.strip('/')
320 if model is None:
320 if model is None:
321 model = {}
321 model = {}
322
322
323 if path.endswith('.ipynb'):
323 if path.endswith('.ipynb'):
324 model.setdefault('type', 'notebook')
324 model.setdefault('type', 'notebook')
325 else:
325 else:
326 model.setdefault('type', 'file')
326 model.setdefault('type', 'file')
327
327
328 # no content, not a directory, so fill out new-file model
328 # no content, not a directory, so fill out new-file model
329 if 'content' not in model and model['type'] != 'directory':
329 if 'content' not in model and model['type'] != 'directory':
330 if model['type'] == 'notebook':
330 if model['type'] == 'notebook':
331 model['content'] = new_notebook()
331 model['content'] = new_notebook()
332 model['format'] = 'json'
332 model['format'] = 'json'
333 else:
333 else:
334 model['content'] = ''
334 model['content'] = ''
335 model['type'] = 'file'
335 model['type'] = 'file'
336 model['format'] = 'text'
336 model['format'] = 'text'
337
337
338 model = self.save(model, path)
338 model = self.save(model, path)
339 return model
339 return model
340
340
341 def copy(self, from_path, to_path=None):
341 def copy(self, from_path, to_path=None):
342 """Copy an existing file and return its new model.
342 """Copy an existing file and return its new model.
343
343
344 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.
345 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`.
346
346
347 from_path must be a full path to a file.
347 from_path must be a full path to a file.
348 """
348 """
349 path = from_path.strip('/')
349 path = from_path.strip('/')
350 if to_path is not None:
351 to_path = to_path.strip('/')
352
350 if '/' in path:
353 if '/' in path:
351 from_dir, from_name = path.rsplit('/', 1)
354 from_dir, from_name = path.rsplit('/', 1)
352 else:
355 else:
353 from_dir = ''
356 from_dir = ''
354 from_name = path
357 from_name = path
355
358
356 model = self.get(path)
359 model = self.get(path)
357 model.pop('path', None)
360 model.pop('path', None)
358 model.pop('name', None)
361 model.pop('name', None)
359 if model['type'] == 'directory':
362 if model['type'] == 'directory':
360 raise HTTPError(400, "Can't copy directories")
363 raise HTTPError(400, "Can't copy directories")
361
364
362 if not to_path:
365 if not to_path:
363 to_path = from_dir
366 to_path = from_dir
364 if self.dir_exists(to_path):
367 if self.dir_exists(to_path):
365 name = copy_pat.sub(u'.', from_name)
368 name = copy_pat.sub(u'.', from_name)
366 to_name = self.increment_filename(name, to_path, insert='-Copy')
369 to_name = self.increment_filename(name, to_path, insert='-Copy')
367 to_path = u'{0}/{1}'.format(to_path, to_name)
370 to_path = u'{0}/{1}'.format(to_path, to_name)
368
371
369 model = self.save(model, to_path)
372 model = self.save(model, to_path)
370 return model
373 return model
371
374
372 def log_info(self):
375 def log_info(self):
373 self.log.info(self.info_string())
376 self.log.info(self.info_string())
374
377
375 def trust_notebook(self, path):
378 def trust_notebook(self, path):
376 """Explicitly trust a notebook
379 """Explicitly trust a notebook
377
380
378 Parameters
381 Parameters
379 ----------
382 ----------
380 path : string
383 path : string
381 The path of a notebook
384 The path of a notebook
382 """
385 """
383 model = self.get(path)
386 model = self.get(path)
384 nb = model['content']
387 nb = model['content']
385 self.log.warn("Trusting notebook %s", path)
388 self.log.warn("Trusting notebook %s", path)
386 self.notary.mark_cells(nb, True)
389 self.notary.mark_cells(nb, True)
387 self.save(model, path)
390 self.save(model, path)
388
391
389 def check_and_sign(self, nb, path=''):
392 def check_and_sign(self, nb, path=''):
390 """Check for trusted cells, and sign the notebook.
393 """Check for trusted cells, and sign the notebook.
391
394
392 Called as a part of saving notebooks.
395 Called as a part of saving notebooks.
393
396
394 Parameters
397 Parameters
395 ----------
398 ----------
396 nb : dict
399 nb : dict
397 The notebook dict
400 The notebook dict
398 path : string
401 path : string
399 The notebook's path (for logging)
402 The notebook's path (for logging)
400 """
403 """
401 if self.notary.check_cells(nb):
404 if self.notary.check_cells(nb):
402 self.notary.sign(nb)
405 self.notary.sign(nb)
403 else:
406 else:
404 self.log.warn("Saving untrusted notebook %s", path)
407 self.log.warn("Saving untrusted notebook %s", path)
405
408
406 def mark_trusted_cells(self, nb, path=''):
409 def mark_trusted_cells(self, nb, path=''):
407 """Mark cells as trusted if the notebook signature matches.
410 """Mark cells as trusted if the notebook signature matches.
408
411
409 Called as a part of loading notebooks.
412 Called as a part of loading notebooks.
410
413
411 Parameters
414 Parameters
412 ----------
415 ----------
413 nb : dict
416 nb : dict
414 The notebook object (in current nbformat)
417 The notebook object (in current nbformat)
415 path : string
418 path : string
416 The notebook's path (for logging)
419 The notebook's path (for logging)
417 """
420 """
418 trusted = self.notary.check_signature(nb)
421 trusted = self.notary.check_signature(nb)
419 if not trusted:
422 if not trusted:
420 self.log.warn("Notebook %s is not trusted", path)
423 self.log.warn("Notebook %s is not trusted", path)
421 self.notary.mark_cells(nb, trusted)
424 self.notary.mark_cells(nb, trusted)
422
425
423 def should_list(self, name):
426 def should_list(self, name):
424 """Should this file/directory name be displayed in a listing?"""
427 """Should this file/directory name be displayed in a listing?"""
425 return not any(fnmatch(name, glob) for glob in self.hide_globs)
428 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