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