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