filemanager.py
531 lines
| 18.7 KiB
| text/x-python
|
PythonLexer
|
r17524 | """A contents manager that uses the local file system for storage.""" | ||
|
r4609 | |||
|
r16486 | # Copyright (c) IPython Development Team. | ||
# Distributed under the terms of the Modified BSD License. | ||||
|
r4484 | |||
|
r17525 | import base64 | ||
|
r6030 | import io | ||
|
r4484 | import os | ||
|
r4624 | import glob | ||
|
r10497 | import shutil | ||
|
r12984 | |||
|
r4484 | from tornado import web | ||
|
r17524 | from .manager import ContentsManager | ||
|
r14907 | from IPython.nbformat import current | ||
|
r16486 | from IPython.utils.path import ensure_dir_exists | ||
|
r15526 | from IPython.utils.traitlets import Unicode, Bool, TraitError | ||
|
r15420 | from IPython.utils.py3compat import getcwd | ||
|
r11145 | from IPython.utils import tz | ||
|
r17537 | from IPython.html.utils import is_hidden, to_os_path, url_path_join | ||
|
r4484 | |||
|
r17524 | class FileContentsManager(ContentsManager): | ||
|
r17523 | |||
|
r17524 | root_dir = Unicode(getcwd(), config=True) | ||
|
r17523 | |||
|
r17537 | save_script = Bool(False, config=True, help='DEPRECATED, IGNORED') | ||
def _save_script_changed(self): | ||||
self.log.warn(""" | ||||
Automatically saving notebooks as scripts has been removed. | ||||
Use `ipython nbconvert --to python [notebook]` instead. | ||||
""") | ||||
|
r17524 | def _root_dir_changed(self, name, old, new): | ||
"""Do a bit of validation of the root_dir.""" | ||||
|
r15420 | if not os.path.isabs(new): | ||
# If we receive a non-absolute path, make it absolute. | ||||
|
r17524 | self.root_dir = os.path.abspath(new) | ||
|
r15420 | return | ||
|
r17537 | if not os.path.isdir(new): | ||
|
r17524 | raise TraitError("%r is not a directory" % new) | ||
|
r17523 | |||
|
r16447 | checkpoint_dir = Unicode('.ipynb_checkpoints', config=True, | ||
|
r17529 | help="""The directory name in which to keep file checkpoints | ||
|
r17523 | |||
|
r17529 | This is a path relative to the file's own directory. | ||
|
r17523 | |||
|
r16447 | By default, it is .ipynb_checkpoints | ||
|
r10497 | """ | ||
) | ||||
|
r17523 | |||
|
r15827 | def _copy(self, src, dest): | ||
"""copy src to dest | ||||
|
r17523 | |||
|
r15865 | like shutil.copy2, but log errors in copystat | ||
|
r15827 | """ | ||
|
r15865 | shutil.copyfile(src, dest) | ||
try: | ||||
shutil.copystat(src, dest) | ||||
except OSError as e: | ||||
self.log.debug("copystat on %s failed", dest, exc_info=True) | ||||
|
r17523 | |||
|
r17525 | def _get_os_path(self, name=None, path=''): | ||
|
r17535 | """Given a filename and API path, return its file system | ||
|
r17525 | path. | ||
Parameters | ||||
---------- | ||||
name : string | ||||
A filename | ||||
path : string | ||||
|
r17535 | The relative API path to the named file. | ||
|
r17525 | |||
Returns | ||||
------- | ||||
path : string | ||||
API path to be evaluated relative to root_dir. | ||||
""" | ||||
if name is not None: | ||||
|
r17537 | path = url_path_join(path, name) | ||
|
r17525 | return to_os_path(path, self.root_dir) | ||
|
r12997 | |||
|
r13070 | def path_exists(self, path): | ||
|
r17525 | """Does the API-style path refer to an extant directory? | ||
|
r17523 | |||
|
r17535 | API-style wrapper for os.path.isdir | ||
|
r13070 | Parameters | ||
---------- | ||||
path : string | ||||
The path to check. This is an API path (`/` separated, | ||||
|
r17524 | relative to root_dir). | ||
|
r17523 | |||
|
r13070 | Returns | ||
------- | ||||
exists : bool | ||||
Whether the path is indeed a directory. | ||||
""" | ||||
|
r13078 | path = path.strip('/') | ||
|
r15420 | os_path = self._get_os_path(path=path) | ||
|
r13070 | return os.path.isdir(os_path) | ||
|
r15097 | |||
def is_hidden(self, path): | ||||
"""Does the API style path correspond to a hidden directory or file? | ||||
|
r17523 | |||
|
r15097 | Parameters | ||
---------- | ||||
path : string | ||||
The path to check. This is an API path (`/` separated, | ||||
|
r17524 | relative to root_dir). | ||
|
r17523 | |||
|
r15097 | Returns | ||
------- | ||||
exists : bool | ||||
Whether the path is hidden. | ||||
|
r17523 | |||
|
r15097 | """ | ||
path = path.strip('/') | ||||
|
r15420 | os_path = self._get_os_path(path=path) | ||
|
r17524 | return is_hidden(os_path, self.root_dir) | ||
|
r15097 | |||
|
r17525 | def file_exists(self, name, path=''): | ||
"""Returns True if the file exists, else returns False. | ||||
|
r13070 | |||
|
r17535 | API-style wrapper for os.path.isfile | ||
|
r13070 | Parameters | ||
---------- | ||||
name : string | ||||
|
r17525 | The name of the file you are checking. | ||
|
r13070 | path : string | ||
|
r17525 | The relative path to the file's directory (with '/' as separator) | ||
|
r13070 | |||
Returns | ||||
------- | ||||
|
r17535 | exists : bool | ||
Whether the file exists. | ||||
|
r13070 | """ | ||
|
r17525 | path = path.strip('/') | ||
nbpath = self._get_os_path(name, path=path) | ||||
return os.path.isfile(nbpath) | ||||
|
r13070 | |||
|
r17525 | def exists(self, name=None, path=''): | ||
"""Returns True if the path [and name] exists, else returns False. | ||||
|
r13046 | |||
|
r17535 | API-style wrapper for os.path.exists | ||
|
r13032 | Parameters | ||
---------- | ||||
name : string | ||||
|
r17524 | The name of the file you are checking. | ||
|
r13032 | path : string | ||
|
r17524 | The relative path to the file's directory (with '/' as separator) | ||
|
r13046 | |||
|
r13032 | Returns | ||
------- | ||||
|
r17535 | exists : bool | ||
Whether the target exists. | ||||
|
r13032 | """ | ||
|
r13078 | path = path.strip('/') | ||
|
r17525 | os_path = self._get_os_path(name, path=path) | ||
return os.path.exists(os_path) | ||||
|
r12984 | |||
|
r17525 | def _base_model(self, name, path=''): | ||
"""Build the common base of a contents model""" | ||||
|
r15420 | os_path = self._get_os_path(name, path) | ||
|
r15069 | info = os.stat(os_path) | ||
last_modified = tz.utcfromtimestamp(info.st_mtime) | ||||
created = tz.utcfromtimestamp(info.st_ctime) | ||||
|
r17529 | # Create the base model. | ||
|
r17525 | model = {} | ||
|
r15069 | model['name'] = name | ||
model['path'] = path | ||||
model['last_modified'] = last_modified | ||||
model['created'] = created | ||||
|
r17525 | model['content'] = None | ||
model['format'] = None | ||||
return model | ||||
def _dir_model(self, name, path='', content=True): | ||||
"""Build a model for a directory | ||||
if content is requested, will include a listing of the directory | ||||
""" | ||||
os_path = self._get_os_path(name, path) | ||||
|
r17537 | four_o_four = u'directory does not exist: %r' % os_path | ||
|
r17525 | if not os.path.isdir(os_path): | ||
|
r17537 | raise web.HTTPError(404, four_o_four) | ||
|
r17525 | elif is_hidden(os_path, self.root_dir): | ||
|
r17537 | self.log.info("Refusing to serve hidden directory %r, via 404 Error", | ||
os_path | ||||
) | ||||
raise web.HTTPError(404, four_o_four) | ||||
|
r17525 | |||
if name is None: | ||||
if '/' in path: | ||||
path, name = path.rsplit('/', 1) | ||||
else: | ||||
name = '' | ||||
model = self._base_model(name, path) | ||||
|
r15069 | model['type'] = 'directory' | ||
|
r17525 | dir_path = u'{}/{}'.format(path, name) | ||
if content: | ||||
|
r17529 | model['content'] = contents = [] | ||
|
r17525 | for os_path in glob.glob(self._get_os_path('*', dir_path)): | ||
name = os.path.basename(os_path) | ||||
if self.should_list(name) and not is_hidden(os_path, self.root_dir): | ||||
contents.append(self.get_model(name=name, path=dir_path, content=False)) | ||||
|
r17527 | model['format'] = 'json' | ||
|
r17525 | |||
|
r15069 | return model | ||
|
r17525 | def _file_model(self, name, path='', content=True): | ||
"""Build a model for a file | ||||
|
r17523 | |||
|
r17525 | if content is requested, include the file contents. | ||
|
r17529 | UTF-8 text files will be unicode, binary files will be base64-encoded. | ||
|
r17525 | """ | ||
model = self._base_model(name, path) | ||||
model['type'] = 'file' | ||||
if content: | ||||
os_path = self._get_os_path(name, path) | ||||
|
r17537 | with io.open(os_path, 'rb') as f: | ||
bcontent = f.read() | ||||
|
r17525 | try: | ||
|
r17537 | model['content'] = bcontent.decode('utf8') | ||
|
r17525 | except UnicodeError as e: | ||
|
r17537 | model['content'] = base64.encodestring(bcontent).decode('ascii') | ||
model['format'] = 'base64' | ||||
|
r17525 | else: | ||
model['format'] = 'text' | ||||
return model | ||||
|
r17523 | |||
|
r17525 | |||
def _notebook_model(self, name, path='', content=True): | ||||
"""Build a notebook model | ||||
if content is requested, the notebook content will be populated | ||||
as a JSON structure (not double-serialized) | ||||
|
r13048 | """ | ||
|
r17525 | model = self._base_model(name, path) | ||
model['type'] = 'notebook' | ||||
if content: | ||||
os_path = self._get_os_path(name, path) | ||||
with io.open(os_path, 'r', encoding='utf-8') as f: | ||||
try: | ||||
nb = current.read(f, u'json') | ||||
except Exception as e: | ||||
raise web.HTTPError(400, u"Unreadable Notebook: %s %s" % (os_path, e)) | ||||
self.mark_trusted_cells(nb, name, path) | ||||
model['content'] = nb | ||||
model['format'] = 'json' | ||||
return model | ||||
|
r13046 | |||
|
r17525 | def get_model(self, name, path='', content=True): | ||
""" Takes a path and name for an entity and returns its model | ||||
|
r17523 | |||
|
r13048 | Parameters | ||
---------- | ||||
name : str | ||||
|
r17525 | the name of the target | ||
|
r13048 | path : str | ||
|
r17535 | the API path that describes the relative path for the target | ||
|
r17523 | |||
|
r13048 | Returns | ||
------- | ||||
model : dict | ||||
|
r17525 | the contents model. If content=True, returns the contents | ||
of the file or directory as well. | ||||
|
r13048 | """ | ||
|
r13078 | path = path.strip('/') | ||
|
r17525 | |||
if not self.exists(name=name, path=path): | ||||
raise web.HTTPError(404, u'No such file or directory: %s/%s' % (path, name)) | ||||
|
r15420 | os_path = self._get_os_path(name, path) | ||
|
r17525 | if os.path.isdir(os_path): | ||
model = self._dir_model(name, path, content) | ||||
elif name.endswith('.ipynb'): | ||||
model = self._notebook_model(name, path, content) | ||||
else: | ||||
model = self._file_model(name, path, content) | ||||
|
r13046 | return model | ||
|
r12997 | |||
|
r17527 | def _save_notebook(self, os_path, model, name='', path=''): | ||
|
r17529 | """save a notebook file""" | ||
|
r17527 | # Save the notebook file | ||
nb = current.to_notebook_json(model['content']) | ||||
self.check_and_sign(nb, name, path) | ||||
if 'name' in nb['metadata']: | ||||
nb['metadata']['name'] = u'' | ||||
with io.open(os_path, 'w', encoding='utf-8') as f: | ||||
current.write(nb, f, u'json') | ||||
def _save_file(self, os_path, model, name='', path=''): | ||||
|
r17529 | """save a non-notebook file""" | ||
|
r17527 | fmt = model.get('format', None) | ||
if fmt not in {'text', 'base64'}: | ||||
raise web.HTTPError(400, "Must specify format of file contents as 'text' or 'base64'") | ||||
try: | ||||
content = model['content'] | ||||
if fmt == 'text': | ||||
bcontent = content.encode('utf8') | ||||
else: | ||||
b64_bytes = content.encode('ascii') | ||||
bcontent = base64.decodestring(b64_bytes) | ||||
except Exception as e: | ||||
raise web.HTTPError(400, u'Encoding error saving %s: %s' % (os_path, e)) | ||||
with io.open(os_path, 'wb') as f: | ||||
f.write(bcontent) | ||||
def _save_directory(self, os_path, model, name='', path=''): | ||||
|
r17529 | """create a directory""" | ||
|
r17537 | if is_hidden(os_path, self.root_dir): | ||
raise web.HTTPError(400, u'Cannot create hidden directory %r' % os_path) | ||||
|
r17527 | if not os.path.exists(os_path): | ||
os.mkdir(os_path) | ||||
elif not os.path.isdir(os_path): | ||||
raise web.HTTPError(400, u'Not a directory: %s' % (os_path)) | ||||
|
r17537 | else: | ||
self.log.debug("Directory %r already exists", os_path) | ||||
|
r17527 | |||
|
r17524 | def save(self, model, name='', path=''): | ||
|
r17527 | """Save the file model and return the model with no content.""" | ||
|
r13078 | path = path.strip('/') | ||
|
r13046 | |||
|
r17527 | if 'type' not in model: | ||
raise web.HTTPError(400, u'No file type provided') | ||||
|
r17532 | if 'content' not in model and model['type'] != 'directory': | ||
raise web.HTTPError(400, u'No file content provided') | ||||
|
r15093 | |||
|
r13245 | # One checkpoint should always exist | ||
|
r17524 | if self.file_exists(name, path) and not self.list_checkpoints(name, path): | ||
|
r13245 | self.create_checkpoint(name, path) | ||
|
r13046 | |||
|
r13067 | new_path = model.get('path', path).strip('/') | ||
|
r13046 | new_name = model.get('name', name) | ||
if path != new_path or name != new_name: | ||||
|
r17524 | self.rename(name, path, new_name, new_path) | ||
|
r11052 | |||
|
r15420 | os_path = self._get_os_path(new_name, new_path) | ||
|
r17527 | self.log.debug("Saving %s", os_path) | ||
|
r4484 | try: | ||
|
r17527 | if model['type'] == 'notebook': | ||
self._save_notebook(os_path, model, new_name, new_path) | ||||
elif model['type'] == 'file': | ||||
self._save_file(os_path, model, new_name, new_path) | ||||
elif model['type'] == 'directory': | ||||
self._save_directory(os_path, model, new_name, new_path) | ||||
else: | ||||
raise web.HTTPError(400, "Unhandled contents type: %s" % model['type']) | ||||
except web.HTTPError: | ||||
raise | ||||
|
r5709 | except Exception as e: | ||
|
r17527 | raise web.HTTPError(400, u'Unexpected error while saving file: %s %s' % (os_path, e)) | ||
|
r8180 | |||
|
r17525 | model = self.get_model(new_name, new_path, content=False) | ||
|
r13046 | return model | ||
|
r17524 | def update(self, model, name, path=''): | ||
|
r17535 | """Update the file's path and/or name | ||
For use in PATCH requests, to enable renaming a file without | ||||
re-uploading its contents. Only used for renaming at the moment. | ||||
""" | ||||
|
r13078 | path = path.strip('/') | ||
|
r13046 | new_name = model.get('name', name) | ||
|
r13078 | new_path = model.get('path', path).strip('/') | ||
|
r13046 | if path != new_path or name != new_name: | ||
|
r17524 | self.rename(name, path, new_name, new_path) | ||
|
r17525 | model = self.get_model(new_name, new_path, content=False) | ||
|
r13046 | return model | ||
|
r17524 | def delete(self, name, path=''): | ||
"""Delete file by name and path.""" | ||||
|
r13078 | path = path.strip('/') | ||
|
r15420 | os_path = self._get_os_path(name, path) | ||
|
r17530 | rm = os.unlink | ||
if os.path.isdir(os_path): | ||||
listing = os.listdir(os_path) | ||||
# don't delete non-empty directories (checkpoints dir doesn't count) | ||||
|
r17537 | if listing and listing != [self.checkpoint_dir]: | ||
|
r17530 | raise web.HTTPError(400, u'Directory %s not empty' % os_path) | ||
elif not os.path.isfile(os_path): | ||||
|
r17524 | raise web.HTTPError(404, u'File does not exist: %s' % os_path) | ||
|
r17523 | |||
|
r10497 | # clear checkpoints | ||
|
r13051 | for checkpoint in self.list_checkpoints(name, path): | ||
|
r13122 | checkpoint_id = checkpoint['id'] | ||
|
r13046 | cp_path = self.get_checkpoint_path(checkpoint_id, name, path) | ||
if os.path.isfile(cp_path): | ||||
self.log.debug("Unlinking checkpoint %s", cp_path) | ||||
os.unlink(cp_path) | ||||
|
r17523 | |||
|
r17530 | if os.path.isdir(os_path): | ||
self.log.debug("Removing directory %s", os_path) | ||||
shutil.rmtree(os_path) | ||||
else: | ||||
self.log.debug("Unlinking file %s", os_path) | ||||
rm(os_path) | ||||
|
r4484 | |||
|
r17524 | def rename(self, old_name, old_path, new_name, new_path): | ||
"""Rename a file.""" | ||||
|
r13078 | old_path = old_path.strip('/') | ||
new_path = new_path.strip('/') | ||||
|
r13046 | if new_name == old_name and new_path == old_path: | ||
return | ||||
|
r17523 | |||
|
r15420 | new_os_path = self._get_os_path(new_name, new_path) | ||
old_os_path = self._get_os_path(old_name, old_path) | ||||
|
r13046 | |||
# Should we proceed with the move? | ||||
|
r13051 | if os.path.isfile(new_os_path): | ||
|
r17535 | raise web.HTTPError(409, u'File with name already exists: %s' % new_os_path) | ||
|
r13046 | |||
|
r17524 | # Move the file | ||
|
r13046 | try: | ||
|
r16212 | shutil.move(old_os_path, new_os_path) | ||
|
r13051 | except Exception as e: | ||
|
r17524 | raise web.HTTPError(500, u'Unknown error renaming file: %s %s' % (old_os_path, e)) | ||
|
r13046 | |||
# Move the checkpoints | ||||
old_checkpoints = self.list_checkpoints(old_name, old_path) | ||||
for cp in old_checkpoints: | ||||
|
r13122 | checkpoint_id = cp['id'] | ||
|
r13056 | old_cp_path = self.get_checkpoint_path(checkpoint_id, old_name, old_path) | ||
new_cp_path = self.get_checkpoint_path(checkpoint_id, new_name, new_path) | ||||
|
r13046 | if os.path.isfile(old_cp_path): | ||
self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path) | ||||
|
r16212 | shutil.move(old_cp_path, new_cp_path) | ||
|
r13046 | |||
|
r10497 | # Checkpoint-related utilities | ||
|
r17523 | |||
|
r13078 | def get_checkpoint_path(self, checkpoint_id, name, path=''): | ||
|
r13046 | """find the path to a checkpoint""" | ||
|
r13078 | path = path.strip('/') | ||
|
r17524 | basename, ext = os.path.splitext(name) | ||
|
r10777 | filename = u"{name}-{checkpoint_id}{ext}".format( | ||
|
r13244 | name=basename, | ||
|
r10497 | checkpoint_id=checkpoint_id, | ||
|
r17524 | ext=ext, | ||
|
r10497 | ) | ||
|
r16447 | os_path = self._get_os_path(path=path) | ||
cp_dir = os.path.join(os_path, self.checkpoint_dir) | ||||
|
r16486 | ensure_dir_exists(cp_dir) | ||
|
r16447 | cp_path = os.path.join(cp_dir, filename) | ||
|
r13046 | return cp_path | ||
|
r13078 | def get_checkpoint_model(self, checkpoint_id, name, path=''): | ||
|
r10500 | """construct the info dict for a given checkpoint""" | ||
|
r13078 | path = path.strip('/') | ||
|
r13046 | cp_path = self.get_checkpoint_path(checkpoint_id, name, path) | ||
stats = os.stat(cp_path) | ||||
|
r11145 | last_modified = tz.utcfromtimestamp(stats.st_mtime) | ||
|
r10500 | info = dict( | ||
|
r13122 | id = checkpoint_id, | ||
|
r10500 | last_modified = last_modified, | ||
) | ||||
return info | ||||
|
r17523 | |||
|
r10497 | # public checkpoint API | ||
|
r17523 | |||
|
r13078 | def create_checkpoint(self, name, path=''): | ||
|
r17524 | """Create a checkpoint from the current state of a file""" | ||
|
r13078 | path = path.strip('/') | ||
|
r17524 | src_path = self._get_os_path(name, path) | ||
|
r10500 | # only the one checkpoint ID: | ||
|
r10777 | checkpoint_id = u"checkpoint" | ||
|
r13046 | cp_path = self.get_checkpoint_path(checkpoint_id, name, path) | ||
|
r17529 | self.log.debug("creating checkpoint for %s", name) | ||
|
r17524 | self._copy(src_path, cp_path) | ||
|
r17523 | |||
|
r10500 | # return the checkpoint info | ||
|
r13046 | return self.get_checkpoint_model(checkpoint_id, name, path) | ||
|
r17523 | |||
|
r13078 | def list_checkpoints(self, name, path=''): | ||
|
r17524 | """list the checkpoints for a given file | ||
|
r17523 | |||
|
r17524 | This contents manager currently only supports one checkpoint per file. | ||
|
r10497 | """ | ||
|
r13078 | path = path.strip('/') | ||
|
r12984 | checkpoint_id = "checkpoint" | ||
|
r16447 | os_path = self.get_checkpoint_path(checkpoint_id, name, path) | ||
if not os.path.exists(os_path): | ||||
|
r10497 | return [] | ||
|
r10500 | else: | ||
|
r13046 | return [self.get_checkpoint_model(checkpoint_id, name, path)] | ||
|
r17523 | |||
|
r13078 | def restore_checkpoint(self, checkpoint_id, name, path=''): | ||
|
r17524 | """restore a file to a checkpointed state""" | ||
|
r13078 | path = path.strip('/') | ||
|
r17524 | self.log.info("restoring %s from checkpoint %s", name, checkpoint_id) | ||
|
r15420 | nb_path = self._get_os_path(name, path) | ||
|
r13046 | cp_path = self.get_checkpoint_path(checkpoint_id, name, path) | ||
|
r10497 | if not os.path.isfile(cp_path): | ||
|
r10500 | self.log.debug("checkpoint file does not exist: %s", cp_path) | ||
|
r10497 | raise web.HTTPError(404, | ||
|
r17524 | u'checkpoint does not exist: %s-%s' % (name, checkpoint_id) | ||
|
r10497 | ) | ||
# ensure notebook is readable (never restore from an unreadable notebook) | ||||
|
r17524 | if cp_path.endswith('.ipynb'): | ||
with io.open(cp_path, 'r', encoding='utf-8') as f: | ||||
current.read(f, u'json') | ||||
|
r15827 | self._copy(cp_path, nb_path) | ||
|
r10497 | self.log.debug("copying %s -> %s", cp_path, nb_path) | ||
|
r17523 | |||
|
r13078 | def delete_checkpoint(self, checkpoint_id, name, path=''): | ||
|
r17524 | """delete a file's checkpoint""" | ||
|
r13078 | path = path.strip('/') | ||
|
r13046 | cp_path = self.get_checkpoint_path(checkpoint_id, name, path) | ||
if not os.path.isfile(cp_path): | ||||
|
r10497 | raise web.HTTPError(404, | ||
|
r17524 | u'Checkpoint does not exist: %s%s-%s' % (path, name, checkpoint_id) | ||
|
r10497 | ) | ||
|
r13046 | self.log.debug("unlinking %s", cp_path) | ||
os.unlink(cp_path) | ||||
|
r17523 | |||
|
r10019 | def info_string(self): | ||
|
r17524 | return "Serving notebooks from local directory: %s" % self.root_dir | ||
|
r16052 | |||
def get_kernel_path(self, name, path='', model=None): | ||||
|
r17524 | """Return the initial working dir a kernel associated with a given notebook""" | ||
return os.path.join(self.root_dir, path) | ||||