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