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