diff --git a/IPython/html/base/handlers.py b/IPython/html/base/handlers.py index a52f1d6f9..2643c2cda 100644 --- a/IPython/html/base/handlers.py +++ b/IPython/html/base/handlers.py @@ -298,7 +298,7 @@ class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): @web.authenticated def get(self, path): if os.path.splitext(path)[1] == '.ipynb': - name = os.path.basename(path) + name = path.rsplit('/', 1)[-1] self.set_header('Content-Type', 'application/json') self.set_header('Content-Disposition','attachment; filename="%s"' % name) @@ -418,43 +418,42 @@ class ApiVersionHandler(IPythonHandler): # not authenticated, so give as few info as possible self.finish(json.dumps({"version":IPython.__version__})) + class TrailingSlashHandler(web.RequestHandler): """Simple redirect handler that strips trailing slashes This should be the first, highest priority handler. """ - SUPPORTED_METHODS = ['GET'] - def get(self): self.redirect(self.request.uri.rstrip('/')) + + post = put = get class FilesRedirectHandler(IPythonHandler): """Handler for redirecting relative URLs to the /files/ handler""" def get(self, path=''): cm = self.contents_manager - if cm.path_exists(path): + if cm.dir_exists(path): # it's a *directory*, redirect to /tree url = url_path_join(self.base_url, 'tree', path) else: orig_path = path # otherwise, redirect to /files parts = path.split('/') - path = '/'.join(parts[:-1]) - name = parts[-1] - if not cm.file_exists(name=name, path=path) and 'files' in parts: + if not cm.file_exists(path=path) and 'files' in parts: # redirect without files/ iff it would 404 # this preserves pre-2.0-style 'files/' links self.log.warn("Deprecated files/ URL: %s", orig_path) parts.remove('files') - path = '/'.join(parts[:-1]) + path = '/'.join(parts) - if not cm.file_exists(name=name, path=path): + if not cm.file_exists(path=path): raise web.HTTPError(404) - url = url_path_join(self.base_url, 'files', path, name) + url = url_path_join(self.base_url, 'files', path) url = url_escape(url) self.log.debug("Redirecting %s to %s", self.request.path, url) self.redirect(url) @@ -464,11 +463,9 @@ class FilesRedirectHandler(IPythonHandler): # URL pattern fragments for re-use #----------------------------------------------------------------------------- -path_regex = r"(?P(?:/.*)*)" -notebook_name_regex = r"(?P[^/]+\.ipynb)" -notebook_path_regex = "%s/%s" % (path_regex, notebook_name_regex) -file_name_regex = r"(?P[^/]+)" -file_path_regex = "%s/%s" % (path_regex, file_name_regex) +# path matches any number of `/foo[/bar...]` or just `/` or '' +path_regex = r"(?P(?:(?:/[^/]+)+|/?))" +notebook_path_regex = r"(?P(?:/[^/]+)+\.ipynb)" #----------------------------------------------------------------------------- # URL to handler mappings diff --git a/IPython/html/files/handlers.py b/IPython/html/files/handlers.py index 9e5cbe7e0..7adbacf50 100644 --- a/IPython/html/files/handlers.py +++ b/IPython/html/files/handlers.py @@ -17,13 +17,18 @@ class FilesHandler(IPythonHandler): @web.authenticated def get(self, path): - cm = self.settings['contents_manager'] + cm = self.contents_manager if cm.is_hidden(path): self.log.info("Refusing to serve hidden file, via 404 Error") raise web.HTTPError(404) - - path, name = os.path.split(path) - model = cm.get_model(name, path) + + path = path.strip('/') + if '/' in path: + _, name = path.rsplit('/', 1) + else: + name = path + + model = cm.get_model(path) if self.get_argument("download", False): self.set_header('Content-Disposition','attachment; filename="%s"' % name) diff --git a/IPython/html/nbconvert/handlers.py b/IPython/html/nbconvert/handlers.py index 9fa448260..f2e8c211f 100644 --- a/IPython/html/nbconvert/handlers.py +++ b/IPython/html/nbconvert/handlers.py @@ -76,12 +76,13 @@ class NbconvertFileHandler(IPythonHandler): SUPPORTED_METHODS = ('GET',) @web.authenticated - def get(self, format, path='', name=None): + def get(self, format, path): exporter = get_exporter(format, config=self.config, log=self.log) path = path.strip('/') - model = self.contents_manager.get_model(name=name, path=path) + model = self.contents_manager.get_model(path=path) + name = model['name'] self.set_header('Last-Modified', model['last_modified']) @@ -109,7 +110,7 @@ class NbconvertFileHandler(IPythonHandler): class NbconvertPostHandler(IPythonHandler): SUPPORTED_METHODS = ('POST',) - @web.authenticated + @web.authenticated def post(self, format): exporter = get_exporter(format, config=self.config) diff --git a/IPython/html/notebook/handlers.py b/IPython/html/notebook/handlers.py index 31e5ee7e2..f6e101209 100644 --- a/IPython/html/notebook/handlers.py +++ b/IPython/html/notebook/handlers.py @@ -17,18 +17,16 @@ from ..utils import url_escape class NotebookHandler(IPythonHandler): @web.authenticated - def get(self, path='', name=None): + def get(self, path): """get renders the notebook template if a name is given, or redirects to the '/files/' handler if the name is not given.""" path = path.strip('/') cm = self.contents_manager - if name is None: - raise web.HTTPError(500, "This shouldn't be accessible: %s" % self.request.uri) # a .ipynb filename was given - if not cm.file_exists(name, path): - raise web.HTTPError(404, u'Notebook does not exist: %s/%s' % (path, name)) - name = url_escape(name) + if not cm.file_exists(path): + raise web.HTTPError(404, u'Notebook does not exist: %s' % path) + name = url_escape(path.rsplit('/', 1)[-1]) path = url_escape(path) self.write(self.render_template('notebook.html', notebook_path=path, diff --git a/IPython/html/notebookapp.py b/IPython/html/notebookapp.py index b6b41bb53..0086ce7c3 100644 --- a/IPython/html/notebookapp.py +++ b/IPython/html/notebookapp.py @@ -187,9 +187,10 @@ class NotebookWebApplication(web.Application): return settings def init_handlers(self, settings): - # Load the (URL pattern, handler) tuples for each component. + """Load the (URL pattern, handler) tuples for each component.""" + + # Order matters. The first handler to match the URL will handle the request. handlers = [] - handlers.extend(load_handlers('base.handlers')) handlers.extend(load_handlers('tree.handlers')) handlers.extend(load_handlers('auth.login')) handlers.extend(load_handlers('auth.logout')) @@ -206,6 +207,8 @@ class NotebookWebApplication(web.Application): handlers.append( (r"/nbextensions/(.*)", FileFindHandler, {'path' : settings['nbextensions_path']}), ) + # register base handlers last + handlers.extend(load_handlers('base.handlers')) # set the URL that will be redirected from `/` handlers.append( (r'/?', web.RedirectHandler, { diff --git a/IPython/html/services/contents/filemanager.py b/IPython/html/services/contents/filemanager.py index 81e36c6d1..e99482908 100644 --- a/IPython/html/services/contents/filemanager.py +++ b/IPython/html/services/contents/filemanager.py @@ -61,27 +61,22 @@ class FileContentsManager(ContentsManager): except OSError as e: self.log.debug("copystat on %s failed", dest, exc_info=True) - def _get_os_path(self, name=None, path=''): - """Given a filename and API path, return its file system - path. + def _get_os_path(self, path): + """Given an API path, return its file system path. Parameters ---------- - name : string - A filename path : string The relative API path to the named file. Returns ------- path : string - API path to be evaluated relative to root_dir. + Native, absolute OS path to for a file. """ - if name is not None: - path = url_path_join(path, name) return to_os_path(path, self.root_dir) - def path_exists(self, path): + def dir_exists(self, path): """Does the API-style path refer to an extant directory? API-style wrapper for os.path.isdir @@ -112,25 +107,22 @@ class FileContentsManager(ContentsManager): Returns ------- - exists : bool - Whether the path is hidden. - + hidden : bool + Whether the path exists and is hidden. """ path = path.strip('/') os_path = self._get_os_path(path=path) return is_hidden(os_path, self.root_dir) - def file_exists(self, name, path=''): + def file_exists(self, path): """Returns True if the file exists, else returns False. API-style wrapper for os.path.isfile Parameters ---------- - name : string - The name of the file you are checking. path : string - The relative path to the file's directory (with '/' as separator) + The relative path to the file (with '/' as separator) Returns ------- @@ -138,20 +130,18 @@ class FileContentsManager(ContentsManager): Whether the file exists. """ path = path.strip('/') - nbpath = self._get_os_path(name, path=path) - return os.path.isfile(nbpath) + os_path = self._get_os_path(path) + return os.path.isfile(os_path) - def exists(self, name=None, path=''): - """Returns True if the path [and name] exists, else returns False. + def exists(self, path): + """Returns True if the path exists, else returns False. API-style wrapper for os.path.exists Parameters ---------- - name : string - The name of the file you are checking. path : string - The relative path to the file's directory (with '/' as separator) + The API path to the file (with '/' as separator) Returns ------- @@ -159,32 +149,31 @@ class FileContentsManager(ContentsManager): Whether the target exists. """ path = path.strip('/') - os_path = self._get_os_path(name, path=path) + os_path = self._get_os_path(path=path) return os.path.exists(os_path) - def _base_model(self, name, path=''): + def _base_model(self, path): """Build the common base of a contents model""" - os_path = self._get_os_path(name, path) + os_path = self._get_os_path(path) info = os.stat(os_path) last_modified = tz.utcfromtimestamp(info.st_mtime) created = tz.utcfromtimestamp(info.st_ctime) # Create the base model. model = {} - model['name'] = name + model['name'] = path.rsplit('/', 1)[-1] model['path'] = path model['last_modified'] = last_modified model['created'] = created model['content'] = None model['format'] = None - model['message'] = None return model - def _dir_model(self, name, path='', content=True): + def _dir_model(self, 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) + os_path = self._get_os_path(path) four_o_four = u'directory does not exist: %r' % os_path @@ -196,39 +185,43 @@ class FileContentsManager(ContentsManager): ) raise web.HTTPError(404, four_o_four) - if name is None: - if '/' in path: - path, name = path.rsplit('/', 1) - else: - name = '' - model = self._base_model(name, path) + model = self._base_model(path) model['type'] = 'directory' - dir_path = u'{}/{}'.format(path, name) if content: model['content'] = contents = [] - for os_path in glob.glob(self._get_os_path('*', dir_path)): - name = os.path.basename(os_path) + os_dir = self._get_os_path(path) + for name in os.listdir(os_dir): + os_path = os.path.join(os_dir, name) # skip over broken symlinks in listing if not os.path.exists(os_path): self.log.warn("%s doesn't exist", os_path) continue + elif not os.path.isfile(os_path) and not os.path.isdir(os_path): + self.log.debug("%s not a regular file", os_path) + continue 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)) + contents.append(self.get_model( + path='%s/%s' % (path, name), + content=False) + ) model['format'] = 'json' return model - def _file_model(self, name, path='', content=True): + def _file_model(self, path, content=True): """Build a model for a file if content is requested, include the file contents. UTF-8 text files will be unicode, binary files will be base64-encoded. """ - model = self._base_model(name, path) + model = self._base_model(path) model['type'] = 'file' if content: - os_path = self._get_os_path(name, path) + os_path = self._get_os_path(path) + if not os.path.isfile(os_path): + # could be FIFO + raise web.HTTPError(400, "Cannot get content of non-file %s" % os_path) with io.open(os_path, 'rb') as f: bcontent = f.read() try: @@ -241,34 +234,32 @@ class FileContentsManager(ContentsManager): return model - def _notebook_model(self, name, path='', content=True): + def _notebook_model(self, path, content=True): """Build a notebook model if content is requested, the notebook content will be populated as a JSON structure (not double-serialized) """ - model = self._base_model(name, path) + model = self._base_model(path) model['type'] = 'notebook' if content: - os_path = self._get_os_path(name, path) + os_path = self._get_os_path(path) with io.open(os_path, 'r', encoding='utf-8') as f: try: nb = nbformat.read(f, as_version=4) except Exception as e: raise web.HTTPError(400, u"Unreadable Notebook: %s %r" % (os_path, e)) - self.mark_trusted_cells(nb, name, path) + self.mark_trusted_cells(nb, path) model['content'] = nb model['format'] = 'json' self.validate_notebook_model(model) return model - def get_model(self, name, path='', content=True): - """ Takes a path and name for an entity and returns its model + def get_model(self, path, content=True): + """ Takes a path for an entity and returns its model Parameters ---------- - name : str - the name of the target path : str the API path that describes the relative path for the target @@ -280,32 +271,29 @@ class FileContentsManager(ContentsManager): """ path = path.strip('/') - if not self.exists(name=name, path=path): - raise web.HTTPError(404, u'No such file or directory: %s/%s' % (path, name)) + if not self.exists(path): + raise web.HTTPError(404, u'No such file or directory: %s' % path) - os_path = self._get_os_path(name, path) + os_path = self._get_os_path(path) if os.path.isdir(os_path): - model = self._dir_model(name, path, content) - elif name.endswith('.ipynb'): - model = self._notebook_model(name, path, content) + model = self._dir_model(path, content=content) + elif path.endswith('.ipynb'): + model = self._notebook_model(path, content=content) else: - model = self._file_model(name, path, content) + model = self._file_model(path, content=content) return model - def _save_notebook(self, os_path, model, name='', path=''): + def _save_notebook(self, os_path, model, path=''): """save a notebook file""" # Save the notebook file nb = nbformat.from_dict(model['content']) - self.check_and_sign(nb, name, path) - - if 'name' in nb['metadata']: - nb['metadata']['name'] = u'' + self.check_and_sign(nb, path) with atomic_writing(os_path, encoding='utf-8') as f: nbformat.write(nb, f, version=nbformat.NO_CONVERT) - def _save_file(self, os_path, model, name='', path=''): + def _save_file(self, os_path, model, path=''): """save a non-notebook file""" fmt = model.get('format', None) if fmt not in {'text', 'base64'}: @@ -322,7 +310,7 @@ class FileContentsManager(ContentsManager): with atomic_writing(os_path, text=False) as f: f.write(bcontent) - def _save_directory(self, os_path, model, name='', path=''): + def _save_directory(self, os_path, model, path=''): """create a directory""" if is_hidden(os_path, self.root_dir): raise web.HTTPError(400, u'Cannot create hidden directory %r' % os_path) @@ -333,7 +321,7 @@ class FileContentsManager(ContentsManager): else: self.log.debug("Directory %r already exists", os_path) - def save(self, model, name='', path=''): + def save(self, model, path=''): """Save the file model and return the model with no content.""" path = path.strip('/') @@ -343,24 +331,18 @@ class FileContentsManager(ContentsManager): raise web.HTTPError(400, u'No file content provided') # One checkpoint should always exist - if self.file_exists(name, path) and not self.list_checkpoints(name, path): - self.create_checkpoint(name, path) + if self.file_exists(path) and not self.list_checkpoints(path): + self.create_checkpoint(path) - new_path = model.get('path', path).strip('/') - new_name = model.get('name', name) - - if path != new_path or name != new_name: - self.rename(name, path, new_name, new_path) - - os_path = self._get_os_path(new_name, new_path) + os_path = self._get_os_path(path) self.log.debug("Saving %s", os_path) try: if model['type'] == 'notebook': - self._save_notebook(os_path, model, new_name, new_path) + self._save_notebook(os_path, model, path) elif model['type'] == 'file': - self._save_file(os_path, model, new_name, new_path) + self._save_file(os_path, model, path) elif model['type'] == 'directory': - self._save_directory(os_path, model, new_name, new_path) + self._save_directory(os_path, model, path) else: raise web.HTTPError(400, "Unhandled contents type: %s" % model['type']) except web.HTTPError: @@ -373,29 +355,28 @@ class FileContentsManager(ContentsManager): self.validate_notebook_model(model) validation_message = model.get('message', None) - model = self.get_model(new_name, new_path, content=False) + model = self.get_model(path, content=False) if validation_message: model['message'] = validation_message return model - def update(self, model, name, path=''): - """Update the file's path and/or name + def update(self, model, path): + """Update the file's path For use in PATCH requests, to enable renaming a file without re-uploading its contents. Only used for renaming at the moment. """ path = path.strip('/') - new_name = model.get('name', name) new_path = model.get('path', path).strip('/') - if path != new_path or name != new_name: - self.rename(name, path, new_name, new_path) - model = self.get_model(new_name, new_path, content=False) + if path != new_path: + self.rename(path, new_path) + model = self.get_model(new_path, content=False) return model - def delete(self, name, path=''): - """Delete file by name and path.""" + def delete(self, path): + """Delete file at path.""" path = path.strip('/') - os_path = self._get_os_path(name, path) + os_path = self._get_os_path(path) rm = os.unlink if os.path.isdir(os_path): listing = os.listdir(os_path) @@ -406,9 +387,9 @@ class FileContentsManager(ContentsManager): raise web.HTTPError(404, u'File does not exist: %s' % os_path) # clear checkpoints - for checkpoint in self.list_checkpoints(name, path): + for checkpoint in self.list_checkpoints(path): checkpoint_id = checkpoint['id'] - cp_path = self.get_checkpoint_path(checkpoint_id, name, path) + cp_path = self.get_checkpoint_path(checkpoint_id, path) if os.path.isfile(cp_path): self.log.debug("Unlinking checkpoint %s", cp_path) os.unlink(cp_path) @@ -420,57 +401,59 @@ class FileContentsManager(ContentsManager): self.log.debug("Unlinking file %s", os_path) rm(os_path) - def rename(self, old_name, old_path, new_name, new_path): + def rename(self, old_path, new_path): """Rename a file.""" old_path = old_path.strip('/') new_path = new_path.strip('/') - if new_name == old_name and new_path == old_path: + if new_path == old_path: return - new_os_path = self._get_os_path(new_name, new_path) - old_os_path = self._get_os_path(old_name, old_path) + new_os_path = self._get_os_path(new_path) + old_os_path = self._get_os_path(old_path) # Should we proceed with the move? - if os.path.isfile(new_os_path): - raise web.HTTPError(409, u'File with name already exists: %s' % new_os_path) + if os.path.exists(new_os_path): + raise web.HTTPError(409, u'File already exists: %s' % new_path) # Move the file try: shutil.move(old_os_path, new_os_path) except Exception as e: - raise web.HTTPError(500, u'Unknown error renaming file: %s %s' % (old_os_path, e)) + raise web.HTTPError(500, u'Unknown error renaming file: %s %s' % (old_path, e)) # Move the checkpoints - old_checkpoints = self.list_checkpoints(old_name, old_path) + old_checkpoints = self.list_checkpoints(old_path) for cp in old_checkpoints: checkpoint_id = cp['id'] - 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) + old_cp_path = self.get_checkpoint_path(checkpoint_id, old_path) + new_cp_path = self.get_checkpoint_path(checkpoint_id, new_path) if os.path.isfile(old_cp_path): self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path) shutil.move(old_cp_path, new_cp_path) # Checkpoint-related utilities - def get_checkpoint_path(self, checkpoint_id, name, path=''): + def get_checkpoint_path(self, checkpoint_id, path): """find the path to a checkpoint""" path = path.strip('/') + parent, name = ('/' + path).rsplit('/', 1) + parent = parent.strip('/') basename, ext = os.path.splitext(name) filename = u"{name}-{checkpoint_id}{ext}".format( name=basename, checkpoint_id=checkpoint_id, ext=ext, ) - os_path = self._get_os_path(path=path) + os_path = self._get_os_path(path=parent) cp_dir = os.path.join(os_path, self.checkpoint_dir) ensure_dir_exists(cp_dir) cp_path = os.path.join(cp_dir, filename) return cp_path - def get_checkpoint_model(self, checkpoint_id, name, path=''): + def get_checkpoint_model(self, checkpoint_id, path): """construct the info dict for a given checkpoint""" path = path.strip('/') - cp_path = self.get_checkpoint_path(checkpoint_id, name, path) + cp_path = self.get_checkpoint_path(checkpoint_id, path) stats = os.stat(cp_path) last_modified = tz.utcfromtimestamp(stats.st_mtime) info = dict( @@ -481,43 +464,45 @@ class FileContentsManager(ContentsManager): # public checkpoint API - def create_checkpoint(self, name, path=''): + def create_checkpoint(self, path): """Create a checkpoint from the current state of a file""" path = path.strip('/') - src_path = self._get_os_path(name, path) + if not self.file_exists(path): + raise web.HTTPError(404) + src_path = self._get_os_path(path) # only the one checkpoint ID: checkpoint_id = u"checkpoint" - cp_path = self.get_checkpoint_path(checkpoint_id, name, path) - self.log.debug("creating checkpoint for %s", name) + cp_path = self.get_checkpoint_path(checkpoint_id, path) + self.log.debug("creating checkpoint for %s", path) self._copy(src_path, cp_path) # return the checkpoint info - return self.get_checkpoint_model(checkpoint_id, name, path) + return self.get_checkpoint_model(checkpoint_id, path) - def list_checkpoints(self, name, path=''): + def list_checkpoints(self, path): """list the checkpoints for a given file This contents manager currently only supports one checkpoint per file. """ path = path.strip('/') checkpoint_id = "checkpoint" - os_path = self.get_checkpoint_path(checkpoint_id, name, path) + os_path = self.get_checkpoint_path(checkpoint_id, path) if not os.path.exists(os_path): return [] else: - return [self.get_checkpoint_model(checkpoint_id, name, path)] + return [self.get_checkpoint_model(checkpoint_id, path)] - def restore_checkpoint(self, checkpoint_id, name, path=''): + def restore_checkpoint(self, checkpoint_id, path): """restore a file to a checkpointed state""" path = path.strip('/') - self.log.info("restoring %s from checkpoint %s", name, checkpoint_id) - nb_path = self._get_os_path(name, path) - cp_path = self.get_checkpoint_path(checkpoint_id, name, path) + self.log.info("restoring %s from checkpoint %s", path, checkpoint_id) + nb_path = self._get_os_path(path) + cp_path = self.get_checkpoint_path(checkpoint_id, path) if not os.path.isfile(cp_path): self.log.debug("checkpoint file does not exist: %s", cp_path) raise web.HTTPError(404, - u'checkpoint does not exist: %s-%s' % (name, checkpoint_id) + u'checkpoint does not exist: %s@%s' % (path, checkpoint_id) ) # ensure notebook is readable (never restore from an unreadable notebook) if cp_path.endswith('.ipynb'): @@ -526,13 +511,13 @@ class FileContentsManager(ContentsManager): self._copy(cp_path, nb_path) self.log.debug("copying %s -> %s", cp_path, nb_path) - def delete_checkpoint(self, checkpoint_id, name, path=''): + def delete_checkpoint(self, checkpoint_id, path): """delete a file's checkpoint""" path = path.strip('/') - cp_path = self.get_checkpoint_path(checkpoint_id, name, path) + cp_path = self.get_checkpoint_path(checkpoint_id, path) if not os.path.isfile(cp_path): raise web.HTTPError(404, - u'Checkpoint does not exist: %s%s-%s' % (path, name, checkpoint_id) + u'Checkpoint does not exist: %s@%s' % (path, checkpoint_id) ) self.log.debug("unlinking %s", cp_path) os.unlink(cp_path) @@ -540,6 +525,10 @@ class FileContentsManager(ContentsManager): def info_string(self): return "Serving notebooks from local directory: %s" % self.root_dir - def get_kernel_path(self, name, path='', model=None): + def get_kernel_path(self, path, model=None): """Return the initial working dir a kernel associated with a given notebook""" - return os.path.join(self.root_dir, path) + if '/' in path: + parent_dir = path.rsplit('/', 1)[0] + else: + parent_dir = '' + return self._get_os_path(parent_dir) diff --git a/IPython/html/services/contents/handlers.py b/IPython/html/services/contents/handlers.py index a92d51bf9..162e890ea 100644 --- a/IPython/html/services/contents/handlers.py +++ b/IPython/html/services/contents/handlers.py @@ -10,9 +10,9 @@ from tornado import web from IPython.html.utils import url_path_join, url_escape from IPython.utils.jsonutil import date_default -from IPython.html.base.handlers import (IPythonHandler, json_errors, - file_path_regex, path_regex, - file_name_regex) +from IPython.html.base.handlers import ( + IPythonHandler, json_errors, path_regex, +) def sort_key(model): @@ -29,38 +29,36 @@ class ContentsHandler(IPythonHandler): SUPPORTED_METHODS = (u'GET', u'PUT', u'PATCH', u'POST', u'DELETE') - def location_url(self, name, path): + def location_url(self, path): """Return the full URL location of a file. Parameters ---------- - name : unicode - The base name of the file, such as "foo.ipynb". path : unicode - The API path of the file, such as "foo/bar". + The API path of the file, such as "foo/bar.txt". """ return url_escape(url_path_join( - self.base_url, 'api', 'contents', path, name + self.base_url, 'api', 'contents', path )) def _finish_model(self, model, location=True): """Finish a JSON request with a model, setting relevant headers, etc.""" if location: - location = self.location_url(model['name'], model['path']) + location = self.location_url(model['path']) self.set_header('Location', location) self.set_header('Last-Modified', model['last_modified']) self.finish(json.dumps(model, default=date_default)) @web.authenticated @json_errors - def get(self, path='', name=None): + def get(self, path=''): """Return a model for a file or directory. A directory model contains a list of models (without content) of the files and directories it contains. """ path = path or '' - model = self.contents_manager.get_model(name=name, path=path) + model = self.contents_manager.get_model(path=path) if model['type'] == 'directory': # group listing by type, then by name (case-insensitive) # FIXME: sorting should be done in the frontends @@ -69,112 +67,83 @@ class ContentsHandler(IPythonHandler): @web.authenticated @json_errors - def patch(self, path='', name=None): - """PATCH renames a notebook without re-uploading content.""" + def patch(self, path=''): + """PATCH renames a file or directory without re-uploading content.""" cm = self.contents_manager - if name is None: - raise web.HTTPError(400, u'Filename missing') model = self.get_json_body() if model is None: raise web.HTTPError(400, u'JSON body missing') - model = cm.update(model, name, path) + model = cm.update(model, path) self._finish_model(model) - def _copy(self, copy_from, path, copy_to=None): - """Copy a file, optionally specifying the new name. - """ - self.log.info(u"Copying {copy_from} to {path}/{copy_to}".format( + def _copy(self, copy_from, copy_to=None): + """Copy a file, optionally specifying a target directory.""" + self.log.info(u"Copying {copy_from} to {copy_to}".format( copy_from=copy_from, - path=path, copy_to=copy_to or '', )) - model = self.contents_manager.copy(copy_from, copy_to, path) + model = self.contents_manager.copy(copy_from, copy_to) self.set_status(201) self._finish_model(model) - def _upload(self, model, path, name=None): - """Handle upload of a new file - - If name specified, create it in path/name, - otherwise create a new untitled file in path. - """ - self.log.info(u"Uploading file to %s/%s", path, name or '') - if name: - model['name'] = name - - model = self.contents_manager.create_file(model, path) + def _upload(self, model, path): + """Handle upload of a new file to path""" + self.log.info(u"Uploading file to %s", path) + model = self.contents_manager.new(model, path) self.set_status(201) self._finish_model(model) - - def _create_empty_file(self, path, name=None, ext='.ipynb'): - """Create an empty file in path - - If name specified, create it in path/name. - """ - self.log.info(u"Creating new file in %s/%s", path, name or '') - model = {} - if name: - model['name'] = name - model = self.contents_manager.create_file(model, path=path, ext=ext) + + def _new_untitled(self, path, type='', ext=''): + """Create a new, empty untitled entity""" + self.log.info(u"Creating new %s in %s", type or 'file', path) + model = self.contents_manager.new_untitled(path=path, type=type, ext=ext) self.set_status(201) self._finish_model(model) - def _save(self, model, path, name): + def _save(self, model, path): """Save an existing file.""" - self.log.info(u"Saving file at %s/%s", path, name) - model = self.contents_manager.save(model, name, path) - if model['path'] != path.strip('/') or model['name'] != name: - # a rename happened, set Location header - location = True - else: - location = False - self._finish_model(model, location) + self.log.info(u"Saving file at %s", path) + model = self.contents_manager.save(model, path) + self._finish_model(model) @web.authenticated @json_errors - def post(self, path='', name=None): - """Create a new file or directory in the specified path. + def post(self, path=''): + """Create a new file in the specified path. - POST creates new files or directories. The server always decides on the name. + POST creates new files. The server always decides on the name. POST /api/contents/path - New untitled notebook in path. If content specified, upload a - notebook, otherwise start empty. + New untitled, empty file or directory. POST /api/contents/path - with body {"copy_from" : "OtherNotebook.ipynb"} + with body {"copy_from" : "/path/to/OtherNotebook.ipynb"} New copy of OtherNotebook in path """ - if name is not None: - path = u'{}/{}'.format(path, name) - cm = self.contents_manager if cm.file_exists(path): - raise web.HTTPError(400, "Cannot POST to existing files, use PUT instead.") + raise web.HTTPError(400, "Cannot POST to files, use PUT instead.") - if not cm.path_exists(path): + if not cm.dir_exists(path): raise web.HTTPError(404, "No such directory: %s" % path) model = self.get_json_body() if model is not None: copy_from = model.get('copy_from') - ext = model.get('ext', '.ipynb') - if model.get('content') is not None: - if copy_from: - raise web.HTTPError(400, "Can't upload and copy at the same time.") - self._upload(model, path) - elif copy_from: + ext = model.get('ext', '') + type = model.get('type', '') + if copy_from: self._copy(copy_from, path) else: - self._create_empty_file(path, ext=ext) + self._new_untitled(path, type=type, ext=ext) else: - self._create_empty_file(path) + self._new_untitled(path) @web.authenticated @json_errors - def put(self, path='', name=None): + def put(self, path=''): """Saves the file in the location specified by name and path. PUT is very similar to POST, but the requester specifies the name, @@ -184,39 +153,25 @@ class ContentsHandler(IPythonHandler): Save notebook at ``path/Name.ipynb``. Notebook structure is specified in `content` key of JSON request body. If content is not specified, create a new empty notebook. - PUT /api/contents/path/Name.ipynb - with JSON body:: - - { - "copy_from" : "[path/to/]OtherNotebook.ipynb" - } - - Copy OtherNotebook to Name """ - if name is None: - raise web.HTTPError(400, "name must be specified with PUT.") - model = self.get_json_body() if model: - copy_from = model.get('copy_from') - if copy_from: - if model.get('content'): - raise web.HTTPError(400, "Can't upload and copy at the same time.") - self._copy(copy_from, path, name) - elif self.contents_manager.file_exists(name, path): - self._save(model, path, name) + if model.get('copy_from'): + raise web.HTTPError(400, "Cannot copy with PUT, only POST") + if self.contents_manager.file_exists(path): + self._save(model, path) else: - self._upload(model, path, name) + self._upload(model, path) else: - self._create_empty_file(path, name) + self._new_untitled(path) @web.authenticated @json_errors - def delete(self, path='', name=None): + def delete(self, path=''): """delete a file in the given path""" cm = self.contents_manager - self.log.warn('delete %s:%s', path, name) - cm.delete(name, path) + self.log.warn('delete %s', path) + cm.delete(path) self.set_status(204) self.finish() @@ -227,22 +182,22 @@ class CheckpointsHandler(IPythonHandler): @web.authenticated @json_errors - def get(self, path='', name=None): + def get(self, path=''): """get lists checkpoints for a file""" cm = self.contents_manager - checkpoints = cm.list_checkpoints(name, path) + checkpoints = cm.list_checkpoints(path) data = json.dumps(checkpoints, default=date_default) self.finish(data) @web.authenticated @json_errors - def post(self, path='', name=None): + def post(self, path=''): """post creates a new checkpoint""" cm = self.contents_manager - checkpoint = cm.create_checkpoint(name, path) + checkpoint = cm.create_checkpoint(path) data = json.dumps(checkpoint, default=date_default) location = url_path_join(self.base_url, 'api/contents', - path, name, 'checkpoints', checkpoint['id']) + path, 'checkpoints', checkpoint['id']) self.set_header('Location', url_escape(location)) self.set_status(201) self.finish(data) @@ -254,19 +209,19 @@ class ModifyCheckpointsHandler(IPythonHandler): @web.authenticated @json_errors - def post(self, path, name, checkpoint_id): + def post(self, path, checkpoint_id): """post restores a file from a checkpoint""" cm = self.contents_manager - cm.restore_checkpoint(checkpoint_id, name, path) + cm.restore_checkpoint(checkpoint_id, path) self.set_status(204) self.finish() @web.authenticated @json_errors - def delete(self, path, name, checkpoint_id): + def delete(self, path, checkpoint_id): """delete clears a checkpoint for a given file""" cm = self.contents_manager - cm.delete_checkpoint(checkpoint_id, name, path) + cm.delete_checkpoint(checkpoint_id, path) self.set_status(204) self.finish() @@ -294,10 +249,9 @@ class NotebooksRedirectHandler(IPythonHandler): _checkpoint_id_regex = r"(?P[\w-]+)" default_handlers = [ - (r"/api/contents%s/checkpoints" % file_path_regex, CheckpointsHandler), - (r"/api/contents%s/checkpoints/%s" % (file_path_regex, _checkpoint_id_regex), + (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler), + (r"/api/contents%s/checkpoints/%s" % (path_regex, _checkpoint_id_regex), ModifyCheckpointsHandler), - (r"/api/contents%s" % file_path_regex, ContentsHandler), (r"/api/contents%s" % path_regex, ContentsHandler), (r"/api/notebooks/?(.*)", NotebooksRedirectHandler), ] diff --git a/IPython/html/services/contents/manager.py b/IPython/html/services/contents/manager.py index 6058160aa..359cbd151 100644 --- a/IPython/html/services/contents/manager.py +++ b/IPython/html/services/contents/manager.py @@ -33,14 +33,6 @@ class ContentsManager(LoggingConfigurable): - if unspecified, path defaults to '', indicating the root path. - name is also unicode, and refers to a specfic target: - - - unicode, not url-escaped - - must not contain '/' - - It refers to an individual filename - - It may refer to a directory name, - in the case of listing or creating directories. - """ notary = Instance(sign.NotebookNotary) @@ -69,7 +61,7 @@ class ContentsManager(LoggingConfigurable): # ContentsManager API part 1: methods that must be # implemented in subclasses. - def path_exists(self, path): + def dir_exists(self, path): """Does the API-style path (directory) actually exist? Like os.path.isdir @@ -105,8 +97,8 @@ class ContentsManager(LoggingConfigurable): """ raise NotImplementedError - def file_exists(self, name, path=''): - """Does a file exist at the given name and path? + def file_exists(self, path=''): + """Does a file exist at the given path? Like os.path.isfile @@ -126,15 +118,13 @@ class ContentsManager(LoggingConfigurable): """ raise NotImplementedError('must be implemented in a subclass') - def exists(self, name, path=''): - """Does a file or directory exist at the given name and path? + def exists(self, path): + """Does a file or directory exist at the given path? Like os.path.exists Parameters ---------- - name : string - The name of the file you are checking. path : string The relative path to the file's directory (with '/' as separator) @@ -143,17 +133,17 @@ class ContentsManager(LoggingConfigurable): exists : bool Whether the target exists. """ - return self.file_exists(name, path) or self.path_exists("%s/%s" % (path, name)) + return self.file_exists(path) or self.dir_exists(path) - def get_model(self, name, path='', content=True): + def get_model(self, path, content=True): """Get the model of a file or directory with or without content.""" raise NotImplementedError('must be implemented in a subclass') - def save(self, model, name, path=''): + def save(self, model, path): """Save the file or directory and return the model with no content.""" raise NotImplementedError('must be implemented in a subclass') - def update(self, model, name, path=''): + def update(self, model, path): """Update the file or directory and return the model with no content. For use in PATCH requests, to enable renaming a file without @@ -161,26 +151,26 @@ class ContentsManager(LoggingConfigurable): """ raise NotImplementedError('must be implemented in a subclass') - def delete(self, name, path=''): - """Delete file or directory by name and path.""" + def delete(self, path): + """Delete file or directory by path.""" raise NotImplementedError('must be implemented in a subclass') - def create_checkpoint(self, name, path=''): + def create_checkpoint(self, path): """Create a checkpoint of the current state of a file Returns a checkpoint_id for the new checkpoint. """ raise NotImplementedError("must be implemented in a subclass") - def list_checkpoints(self, name, path=''): + def list_checkpoints(self, path): """Return a list of checkpoints for a given file""" return [] - def restore_checkpoint(self, checkpoint_id, name, path=''): + def restore_checkpoint(self, checkpoint_id, path): """Restore a file from one of its checkpoints""" raise NotImplementedError("must be implemented in a subclass") - def delete_checkpoint(self, checkpoint_id, name, path=''): + def delete_checkpoint(self, checkpoint_id, path): """delete a checkpoint for a file""" raise NotImplementedError("must be implemented in a subclass") @@ -190,8 +180,12 @@ class ContentsManager(LoggingConfigurable): def info_string(self): return "Serving contents" - def get_kernel_path(self, name, path='', model=None): - """ Return the path to start kernel in """ + def get_kernel_path(self, path, model=None): + """Return the API path for the kernel + + KernelManagers can turn this value into a filesystem path, + or ignore it altogether. + """ return path def increment_filename(self, filename, path=''): @@ -214,7 +208,7 @@ class ContentsManager(LoggingConfigurable): for i in itertools.count(): name = u'{basename}{i}{ext}'.format(basename=basename, i=i, ext=ext) - if not self.file_exists(name, path): + if not self.exists(u'{}/{}'.format(path, name)): break return name @@ -223,85 +217,124 @@ class ContentsManager(LoggingConfigurable): try: validate(model['content']) except ValidationError as e: - model['message'] = 'Notebook Validation failed: {}:\n{}'.format( + model['message'] = u'Notebook Validation failed: {}:\n{}'.format( e.message, json.dumps(e.instance, indent=1, default=lambda obj: ''), ) return model - - def create_file(self, model=None, path='', ext='.ipynb'): - """Create a new file or directory and return its model with no content.""" + + def new_untitled(self, path='', type='', ext=''): + """Create a new untitled file or directory in path + + path must be a directory + + File extension can be specified. + + Use `new` to create files with a fully specified path (including filename). + """ + path = path.strip('/') + if not self.dir_exists(path): + raise HTTPError(404, 'No such directory: %s' % path) + + model = {} + if type: + model['type'] = type + + if ext == '.ipynb': + model.setdefault('type', 'notebook') + else: + model.setdefault('type', 'file') + + if model['type'] == 'directory': + untitled = self.untitled_directory + elif model['type'] == 'notebook': + untitled = self.untitled_notebook + ext = '.ipynb' + elif model['type'] == 'file': + untitled = self.untitled_file + else: + raise HTTPError(400, "Unexpected model type: %r" % model['type']) + + name = self.increment_filename(untitled + ext, path) + path = u'{0}/{1}'.format(path, name) + return self.new(model, path) + + def new(self, model=None, path=''): + """Create a new file or directory and return its model with no content. + + To create a new untitled entity in a directory, use `new_untitled`. + """ path = path.strip('/') if model is None: model = {} - if 'content' not in model and model.get('type', None) != 'directory': - if ext == '.ipynb': + + if path.endswith('.ipynb'): + model.setdefault('type', 'notebook') + else: + model.setdefault('type', 'file') + + # no content, not a directory, so fill out new-file model + if 'content' not in model and model['type'] != 'directory': + if model['type'] == 'notebook': model['content'] = new_notebook() - model['type'] = 'notebook' model['format'] = 'json' else: model['content'] = '' model['type'] = 'file' model['format'] = 'text' - if 'name' not in model: - if model['type'] == 'directory': - untitled = self.untitled_directory - elif model['type'] == 'notebook': - untitled = self.untitled_notebook - elif model['type'] == 'file': - untitled = self.untitled_file - else: - raise HTTPError(400, "Unexpected model type: %r" % model['type']) - model['name'] = self.increment_filename(untitled + ext, path) - - model['path'] = path - model = self.save(model, model['name'], model['path']) + + model = self.save(model, path) return model - def copy(self, from_name, to_name=None, path=''): + def copy(self, from_path, to_path=None): """Copy an existing file and return its new model. - If to_name not specified, increment `from_name-Copy#.ext`. + If to_path not specified, it will be the parent directory of from_path. + If to_path is a directory, filename will increment `from_path-Copy#.ext`. - copy_from can be a full path to a file, - or just a base name. If a base name, `path` is used. + from_path must be a full path to a file. """ - path = path.strip('/') - if '/' in from_name: - from_path, from_name = from_name.rsplit('/', 1) + path = from_path.strip('/') + if '/' in path: + from_dir, from_name = path.rsplit('/', 1) else: - from_path = path - model = self.get_model(from_name, from_path) + from_dir = '' + from_name = path + + model = self.get_model(path) + model.pop('path', None) + model.pop('name', None) if model['type'] == 'directory': raise HTTPError(400, "Can't copy directories") - if not to_name: + + if not to_path: + to_path = from_dir + if self.dir_exists(to_path): base, ext = os.path.splitext(from_name) copy_name = u'{0}-Copy{1}'.format(base, ext) - to_name = self.increment_filename(copy_name, path) - model['name'] = to_name - model['path'] = path - model = self.save(model, to_name, path) + to_name = self.increment_filename(copy_name, to_path) + to_path = u'{0}/{1}'.format(to_path, to_name) + + model = self.save(model, to_path) return model def log_info(self): self.log.info(self.info_string()) - def trust_notebook(self, name, path=''): + def trust_notebook(self, path): """Explicitly trust a notebook Parameters ---------- - name : string - The filename of the notebook path : string - The notebook's directory + The path of a notebook """ - model = self.get_model(name, path) + model = self.get_model(path) nb = model['content'] - self.log.warn("Trusting notebook %s/%s", path, name) + self.log.warn("Trusting notebook %s", path) self.notary.mark_cells(nb, True) - self.save(model, name, path) + self.save(model, path) - def check_and_sign(self, nb, name='', path=''): + def check_and_sign(self, nb, path=''): """Check for trusted cells, and sign the notebook. Called as a part of saving notebooks. @@ -310,17 +343,15 @@ class ContentsManager(LoggingConfigurable): ---------- nb : dict The notebook dict - name : string - The filename of the notebook (for logging) path : string - The notebook's directory (for logging) + The notebook's path (for logging) """ if self.notary.check_cells(nb): self.notary.sign(nb) else: - self.log.warn("Saving untrusted notebook %s/%s", path, name) + self.log.warn("Saving untrusted notebook %s", path) - def mark_trusted_cells(self, nb, name='', path=''): + def mark_trusted_cells(self, nb, path=''): """Mark cells as trusted if the notebook signature matches. Called as a part of loading notebooks. @@ -329,14 +360,12 @@ class ContentsManager(LoggingConfigurable): ---------- nb : dict The notebook object (in current nbformat) - name : string - The filename of the notebook (for logging) path : string - The notebook's directory (for logging) + The notebook's path (for logging) """ trusted = self.notary.check_signature(nb) if not trusted: - self.log.warn("Notebook %s/%s is not trusted", path, name) + self.log.warn("Notebook %s is not trusted", path) self.notary.mark_cells(nb, trusted) def should_list(self, name): diff --git a/IPython/html/services/contents/tests/test_contents_api.py b/IPython/html/services/contents/tests/test_contents_api.py index b62dbb39e..80ab66351 100644 --- a/IPython/html/services/contents/tests/test_contents_api.py +++ b/IPython/html/services/contents/tests/test_contents_api.py @@ -46,56 +46,59 @@ class API(object): def list(self, path='/'): return self._req('GET', path) - def read(self, name, path='/'): - return self._req('GET', url_path_join(path, name)) + def read(self, path): + return self._req('GET', path) - def create_untitled(self, path='/', ext=None): + def create_untitled(self, path='/', ext='.ipynb'): body = None if ext: body = json.dumps({'ext': ext}) return self._req('POST', path, body) - def upload_untitled(self, body, path='/'): - return self._req('POST', path, body) + def mkdir_untitled(self, path='/'): + return self._req('POST', path, json.dumps({'type': 'directory'})) - def copy_untitled(self, copy_from, path='/'): + def copy(self, copy_from, path='/'): body = json.dumps({'copy_from':copy_from}) return self._req('POST', path, body) - def create(self, name, path='/'): - return self._req('PUT', url_path_join(path, name)) + def create(self, path='/'): + return self._req('PUT', path) + + def upload(self, path, body): + return self._req('PUT', path, body) - def upload(self, name, body, path='/'): - return self._req('PUT', url_path_join(path, name), body) + def mkdir_untitled(self, path='/'): + return self._req('POST', path, json.dumps({'type': 'directory'})) - def mkdir(self, name, path='/'): - return self._req('PUT', url_path_join(path, name), json.dumps({'type': 'directory'})) + def mkdir(self, path='/'): + return self._req('PUT', path, json.dumps({'type': 'directory'})) - def copy(self, copy_from, copy_to, path='/'): + def copy_put(self, copy_from, path='/'): body = json.dumps({'copy_from':copy_from}) - return self._req('PUT', url_path_join(path, copy_to), body) + return self._req('PUT', path, body) - def save(self, name, body, path='/'): - return self._req('PUT', url_path_join(path, name), body) + def save(self, path, body): + return self._req('PUT', path, body) - def delete(self, name, path='/'): - return self._req('DELETE', url_path_join(path, name)) + def delete(self, path='/'): + return self._req('DELETE', path) - def rename(self, name, path, new_name): - body = json.dumps({'name': new_name}) - return self._req('PATCH', url_path_join(path, name), body) + def rename(self, path, new_path): + body = json.dumps({'path': new_path}) + return self._req('PATCH', path, body) - def get_checkpoints(self, name, path): - return self._req('GET', url_path_join(path, name, 'checkpoints')) + def get_checkpoints(self, path): + return self._req('GET', url_path_join(path, 'checkpoints')) - def new_checkpoint(self, name, path): - return self._req('POST', url_path_join(path, name, 'checkpoints')) + def new_checkpoint(self, path): + return self._req('POST', url_path_join(path, 'checkpoints')) - def restore_checkpoint(self, name, path, checkpoint_id): - return self._req('POST', url_path_join(path, name, 'checkpoints', checkpoint_id)) + def restore_checkpoint(self, path, checkpoint_id): + return self._req('POST', url_path_join(path, 'checkpoints', checkpoint_id)) - def delete_checkpoint(self, name, path, checkpoint_id): - return self._req('DELETE', url_path_join(path, name, 'checkpoints', checkpoint_id)) + def delete_checkpoint(self, path, checkpoint_id): + return self._req('DELETE', url_path_join(path, 'checkpoints', checkpoint_id)) class APITest(NotebookTestBase): """Test the kernels web service API""" @@ -131,8 +134,6 @@ class APITest(NotebookTestBase): self.blob = os.urandom(100) self.b64_blob = base64.encodestring(self.blob).decode('ascii') - - for d in (self.dirs + self.hidden_dirs): d.replace('/', os.sep) if not os.path.isdir(pjoin(nbdir, d)): @@ -178,12 +179,12 @@ class APITest(NotebookTestBase): nbs = notebooks_only(self.api.list(u'/unicodé/').json()) self.assertEqual(len(nbs), 1) self.assertEqual(nbs[0]['name'], 'innonascii.ipynb') - self.assertEqual(nbs[0]['path'], u'unicodé') + self.assertEqual(nbs[0]['path'], u'unicodé/innonascii.ipynb') nbs = notebooks_only(self.api.list('/foo/bar/').json()) self.assertEqual(len(nbs), 1) self.assertEqual(nbs[0]['name'], 'baz.ipynb') - self.assertEqual(nbs[0]['path'], 'foo/bar') + self.assertEqual(nbs[0]['path'], 'foo/bar/baz.ipynb') nbs = notebooks_only(self.api.list('foo').json()) self.assertEqual(len(nbs), 4) @@ -198,8 +199,11 @@ class APITest(NotebookTestBase): self.assertEqual(nbnames, expected) def test_list_dirs(self): + print(self.api.list().json()) dirs = dirs_only(self.api.list().json()) dir_names = {normalize('NFC', d['name']) for d in dirs} + print(dir_names) + print(self.top_level_dirs) self.assertEqual(dir_names, self.top_level_dirs) # Excluding hidden dirs def test_list_nonexistant_dir(self): @@ -208,8 +212,10 @@ class APITest(NotebookTestBase): def test_get_nb_contents(self): for d, name in self.dirs_nbs: - nb = self.api.read('%s.ipynb' % name, d+'/').json() + path = url_path_join(d, name + '.ipynb') + nb = self.api.read(path).json() self.assertEqual(nb['name'], u'%s.ipynb' % name) + self.assertEqual(nb['path'], path) self.assertEqual(nb['type'], 'notebook') self.assertIn('content', nb) self.assertEqual(nb['format'], 'json') @@ -220,12 +226,14 @@ class APITest(NotebookTestBase): def test_get_contents_no_such_file(self): # Name that doesn't exist - should be a 404 with assert_http_error(404): - self.api.read('q.ipynb', 'foo') + self.api.read('foo/q.ipynb') def test_get_text_file_contents(self): for d, name in self.dirs_nbs: - model = self.api.read(u'%s.txt' % name, d+'/').json() + path = url_path_join(d, name + '.txt') + model = self.api.read(path).json() self.assertEqual(model['name'], u'%s.txt' % name) + self.assertEqual(model['path'], path) self.assertIn('content', model) self.assertEqual(model['format'], 'text') self.assertEqual(model['type'], 'file') @@ -233,12 +241,14 @@ class APITest(NotebookTestBase): # Name that doesn't exist - should be a 404 with assert_http_error(404): - self.api.read('q.txt', 'foo') + self.api.read('foo/q.txt') def test_get_binary_file_contents(self): for d, name in self.dirs_nbs: - model = self.api.read(u'%s.blob' % name, d+'/').json() + path = url_path_join(d, name + '.blob') + model = self.api.read(path).json() self.assertEqual(model['name'], u'%s.blob' % name) + self.assertEqual(model['path'], path) self.assertIn('content', model) self.assertEqual(model['format'], 'base64') self.assertEqual(model['type'], 'file') @@ -247,66 +257,71 @@ class APITest(NotebookTestBase): # Name that doesn't exist - should be a 404 with assert_http_error(404): - self.api.read('q.txt', 'foo') + self.api.read('foo/q.txt') - def _check_created(self, resp, name, path, type='notebook'): + def _check_created(self, resp, path, type='notebook'): self.assertEqual(resp.status_code, 201) location_header = py3compat.str_to_unicode(resp.headers['Location']) - self.assertEqual(location_header, url_escape(url_path_join(u'/api/contents', path, name))) + self.assertEqual(location_header, url_escape(url_path_join(u'/api/contents', path))) rjson = resp.json() - self.assertEqual(rjson['name'], name) + self.assertEqual(rjson['name'], path.rsplit('/', 1)[-1]) self.assertEqual(rjson['path'], path) self.assertEqual(rjson['type'], type) isright = os.path.isdir if type == 'directory' else os.path.isfile assert isright(pjoin( self.notebook_dir.name, path.replace('/', os.sep), - name, )) def test_create_untitled(self): resp = self.api.create_untitled(path=u'å b') - self._check_created(resp, 'Untitled0.ipynb', u'å b') + self._check_created(resp, u'å b/Untitled0.ipynb') # Second time resp = self.api.create_untitled(path=u'å b') - self._check_created(resp, 'Untitled1.ipynb', u'å b') + self._check_created(resp, u'å b/Untitled1.ipynb') # And two directories down resp = self.api.create_untitled(path='foo/bar') - self._check_created(resp, 'Untitled0.ipynb', 'foo/bar') + self._check_created(resp, 'foo/bar/Untitled0.ipynb') def test_create_untitled_txt(self): resp = self.api.create_untitled(path='foo/bar', ext='.txt') - self._check_created(resp, 'untitled0.txt', 'foo/bar', type='file') + self._check_created(resp, 'foo/bar/untitled0.txt', type='file') - resp = self.api.read(path='foo/bar', name='untitled0.txt') + resp = self.api.read(path='foo/bar/untitled0.txt') model = resp.json() self.assertEqual(model['type'], 'file') self.assertEqual(model['format'], 'text') self.assertEqual(model['content'], '') - def test_upload_untitled(self): - nb = new_notebook() - nbmodel = {'content': nb, 'type': 'notebook'} - resp = self.api.upload_untitled(path=u'å b', - body=json.dumps(nbmodel)) - self._check_created(resp, 'Untitled0.ipynb', u'å b') - def test_upload(self): nb = new_notebook() nbmodel = {'content': nb, 'type': 'notebook'} - resp = self.api.upload(u'Upload tést.ipynb', path=u'å b', - body=json.dumps(nbmodel)) - self._check_created(resp, u'Upload tést.ipynb', u'å b') + path = u'å b/Upload tést.ipynb' + resp = self.api.upload(path, body=json.dumps(nbmodel)) + self._check_created(resp, path) + + def test_mkdir_untitled(self): + resp = self.api.mkdir_untitled(path=u'å b') + self._check_created(resp, u'å b/Untitled Folder0', type='directory') + + # Second time + resp = self.api.mkdir_untitled(path=u'å b') + self._check_created(resp, u'å b/Untitled Folder1', type='directory') + + # And two directories down + resp = self.api.mkdir_untitled(path='foo/bar') + self._check_created(resp, 'foo/bar/Untitled Folder0', type='directory') def test_mkdir(self): - resp = self.api.mkdir(u'New ∂ir', path=u'å b') - self._check_created(resp, u'New ∂ir', u'å b', type='directory') + path = u'å b/New ∂ir' + resp = self.api.mkdir(path) + self._check_created(resp, path, type='directory') def test_mkdir_hidden_400(self): with assert_http_error(400): - resp = self.api.mkdir(u'.hidden', path=u'å b') + resp = self.api.mkdir(u'å b/.hidden') def test_upload_txt(self): body = u'ünicode téxt' @@ -315,11 +330,11 @@ class APITest(NotebookTestBase): 'format' : 'text', 'type' : 'file', } - resp = self.api.upload(u'Upload tést.txt', path=u'å b', - body=json.dumps(model)) + path = u'å b/Upload tést.txt' + resp = self.api.upload(path, body=json.dumps(model)) # check roundtrip - resp = self.api.read(path=u'å b', name=u'Upload tést.txt') + resp = self.api.read(path) model = resp.json() self.assertEqual(model['type'], 'file') self.assertEqual(model['format'], 'text') @@ -333,13 +348,14 @@ class APITest(NotebookTestBase): 'format' : 'base64', 'type' : 'file', } - resp = self.api.upload(u'Upload tést.blob', path=u'å b', - body=json.dumps(model)) + path = u'å b/Upload tést.blob' + resp = self.api.upload(path, body=json.dumps(model)) # check roundtrip - resp = self.api.read(path=u'å b', name=u'Upload tést.blob') + resp = self.api.read(path) model = resp.json() self.assertEqual(model['type'], 'file') + self.assertEqual(model['path'], path) self.assertEqual(model['format'], 'base64') decoded = base64.decodestring(model['content'].encode('ascii')) self.assertEqual(decoded, body) @@ -350,45 +366,52 @@ class APITest(NotebookTestBase): nb.worksheets.append(ws) ws.cells.append(v2.new_code_cell(input='print("hi")')) nbmodel = {'content': nb, 'type': 'notebook'} - resp = self.api.upload(u'Upload tést.ipynb', path=u'å b', - body=json.dumps(nbmodel)) - self._check_created(resp, u'Upload tést.ipynb', u'å b') - resp = self.api.read(u'Upload tést.ipynb', u'å b') + path = u'å b/Upload tést.ipynb' + resp = self.api.upload(path, body=json.dumps(nbmodel)) + self._check_created(resp, path) + resp = self.api.read(path) data = resp.json() self.assertEqual(data['content']['nbformat'], 4) - def test_copy_untitled(self): - resp = self.api.copy_untitled(u'ç d.ipynb', path=u'å b') - self._check_created(resp, u'ç d-Copy0.ipynb', u'å b') - def test_copy(self): - resp = self.api.copy(u'ç d.ipynb', u'cøpy.ipynb', path=u'å b') - self._check_created(resp, u'cøpy.ipynb', u'å b') + resp = self.api.copy(u'å b/ç d.ipynb', u'unicodé') + self._check_created(resp, u'unicodé/ç d-Copy0.ipynb') + + resp = self.api.copy(u'å b/ç d.ipynb', u'å b') + self._check_created(resp, u'å b/ç d-Copy0.ipynb') def test_copy_path(self): - resp = self.api.copy(u'foo/a.ipynb', u'cøpyfoo.ipynb', path=u'å b') - self._check_created(resp, u'cøpyfoo.ipynb', u'å b') + resp = self.api.copy(u'foo/a.ipynb', u'å b') + self._check_created(resp, u'å b/a-Copy0.ipynb') + + def test_copy_put_400(self): + with assert_http_error(400): + resp = self.api.copy_put(u'å b/ç d.ipynb', u'å b/cøpy.ipynb') def test_copy_dir_400(self): # can't copy directories with assert_http_error(400): - resp = self.api.copy(u'å b', u'å c') + resp = self.api.copy(u'å b', u'foo') def test_delete(self): for d, name in self.dirs_nbs: - resp = self.api.delete('%s.ipynb' % name, d) + print('%r, %r' % (d, name)) + resp = self.api.delete(url_path_join(d, name + '.ipynb')) self.assertEqual(resp.status_code, 204) for d in self.dirs + ['/']: nbs = notebooks_only(self.api.list(d).json()) - self.assertEqual(len(nbs), 0) + print('------') + print(d) + print(nbs) + self.assertEqual(nbs, []) def test_delete_dirs(self): # depth-first delete everything, so we don't try to delete empty directories for name in sorted(self.dirs + ['/'], key=len, reverse=True): listing = self.api.list(name).json()['content'] for model in listing: - self.api.delete(model['name'], model['path']) + self.api.delete(model['path']) listing = self.api.list('/').json()['content'] self.assertEqual(listing, []) @@ -398,9 +421,10 @@ class APITest(NotebookTestBase): self.api.delete(u'å b') def test_rename(self): - resp = self.api.rename('a.ipynb', 'foo', 'z.ipynb') + resp = self.api.rename('foo/a.ipynb', 'foo/z.ipynb') self.assertEqual(resp.headers['Location'].split('/')[-1], 'z.ipynb') self.assertEqual(resp.json()['name'], 'z.ipynb') + self.assertEqual(resp.json()['path'], 'foo/z.ipynb') assert os.path.isfile(pjoin(self.notebook_dir.name, 'foo', 'z.ipynb')) nbs = notebooks_only(self.api.list('foo').json()) @@ -410,41 +434,31 @@ class APITest(NotebookTestBase): def test_rename_existing(self): with assert_http_error(409): - self.api.rename('a.ipynb', 'foo', 'b.ipynb') + self.api.rename('foo/a.ipynb', 'foo/b.ipynb') def test_save(self): - resp = self.api.read('a.ipynb', 'foo') + resp = self.api.read('foo/a.ipynb') nbcontent = json.loads(resp.text)['content'] nb = from_dict(nbcontent) nb.cells.append(new_markdown_cell(u'Created by test ³')) - nbmodel= {'name': 'a.ipynb', 'path':'foo', 'content': nb, 'type': 'notebook'} - resp = self.api.save('a.ipynb', path='foo', body=json.dumps(nbmodel)) + nbmodel= {'content': nb, 'type': 'notebook'} + resp = self.api.save('foo/a.ipynb', body=json.dumps(nbmodel)) nbfile = pjoin(self.notebook_dir.name, 'foo', 'a.ipynb') with io.open(nbfile, 'r', encoding='utf-8') as f: newnb = read(f, as_version=4) self.assertEqual(newnb.cells[0].source, u'Created by test ³') - nbcontent = self.api.read('a.ipynb', 'foo').json()['content'] + nbcontent = self.api.read('foo/a.ipynb').json()['content'] newnb = from_dict(nbcontent) self.assertEqual(newnb.cells[0].source, u'Created by test ³') - # Save and rename - nbmodel= {'name': 'a2.ipynb', 'path':'foo/bar', 'content': nb, 'type': 'notebook'} - resp = self.api.save('a.ipynb', path='foo', body=json.dumps(nbmodel)) - saved = resp.json() - self.assertEqual(saved['name'], 'a2.ipynb') - self.assertEqual(saved['path'], 'foo/bar') - assert os.path.isfile(pjoin(self.notebook_dir.name,'foo','bar','a2.ipynb')) - assert not os.path.isfile(pjoin(self.notebook_dir.name, 'foo', 'a.ipynb')) - with assert_http_error(404): - self.api.read('a.ipynb', 'foo') def test_checkpoints(self): - resp = self.api.read('a.ipynb', 'foo') - r = self.api.new_checkpoint('a.ipynb', 'foo') + resp = self.api.read('foo/a.ipynb') + r = self.api.new_checkpoint('foo/a.ipynb') self.assertEqual(r.status_code, 201) cp1 = r.json() self.assertEqual(set(cp1), {'id', 'last_modified'}) @@ -456,26 +470,26 @@ class APITest(NotebookTestBase): hcell = new_markdown_cell('Created by test') nb.cells.append(hcell) # Save - nbmodel= {'name': 'a.ipynb', 'path':'foo', 'content': nb, 'type': 'notebook'} - resp = self.api.save('a.ipynb', path='foo', body=json.dumps(nbmodel)) + nbmodel= {'content': nb, 'type': 'notebook'} + resp = self.api.save('foo/a.ipynb', body=json.dumps(nbmodel)) # List checkpoints - cps = self.api.get_checkpoints('a.ipynb', 'foo').json() + cps = self.api.get_checkpoints('foo/a.ipynb').json() self.assertEqual(cps, [cp1]) - nbcontent = self.api.read('a.ipynb', 'foo').json()['content'] + nbcontent = self.api.read('foo/a.ipynb').json()['content'] nb = from_dict(nbcontent) self.assertEqual(nb.cells[0].source, 'Created by test') # Restore cp1 - r = self.api.restore_checkpoint('a.ipynb', 'foo', cp1['id']) + r = self.api.restore_checkpoint('foo/a.ipynb', cp1['id']) self.assertEqual(r.status_code, 204) - nbcontent = self.api.read('a.ipynb', 'foo').json()['content'] + nbcontent = self.api.read('foo/a.ipynb').json()['content'] nb = from_dict(nbcontent) self.assertEqual(nb.cells, []) # Delete cp1 - r = self.api.delete_checkpoint('a.ipynb', 'foo', cp1['id']) + r = self.api.delete_checkpoint('foo/a.ipynb', cp1['id']) self.assertEqual(r.status_code, 204) - cps = self.api.get_checkpoints('a.ipynb', 'foo').json() + cps = self.api.get_checkpoints('foo/a.ipynb').json() self.assertEqual(cps, []) diff --git a/IPython/html/services/contents/tests/test_manager.py b/IPython/html/services/contents/tests/test_manager.py index 1d419bfa7..11a75e068 100644 --- a/IPython/html/services/contents/tests/test_manager.py +++ b/IPython/html/services/contents/tests/test_manager.py @@ -42,7 +42,7 @@ class TestFileContentsManager(TestCase): with TemporaryDirectory() as td: root = td fm = FileContentsManager(root_dir=root) - path = fm._get_os_path('test.ipynb', '/path/to/notebook/') + path = fm._get_os_path('/path/to/notebook/test.ipynb') rel_path_list = '/path/to/notebook/test.ipynb'.split('/') fs_path = os.path.join(fm.root_dir, *rel_path_list) self.assertEqual(path, fs_path) @@ -53,7 +53,7 @@ class TestFileContentsManager(TestCase): self.assertEqual(path, fs_path) fm = FileContentsManager(root_dir=root) - path = fm._get_os_path('test.ipynb', '////') + path = fm._get_os_path('////test.ipynb') fs_path = os.path.join(fm.root_dir, 'test.ipynb') self.assertEqual(path, fs_path) @@ -64,8 +64,8 @@ class TestFileContentsManager(TestCase): root = td os.mkdir(os.path.join(td, subd)) fm = FileContentsManager(root_dir=root) - cp_dir = fm.get_checkpoint_path('cp', 'test.ipynb', '/') - cp_subdir = fm.get_checkpoint_path('cp', 'test.ipynb', '/%s/' % subd) + cp_dir = fm.get_checkpoint_path('cp', 'test.ipynb') + cp_subdir = fm.get_checkpoint_path('cp', '/%s/test.ipynb' % subd) self.assertNotEqual(cp_dir, cp_subdir) self.assertEqual(cp_dir, os.path.join(root, fm.checkpoint_dir, cp_name)) self.assertEqual(cp_subdir, os.path.join(root, subd, fm.checkpoint_dir, cp_name)) @@ -101,46 +101,58 @@ class TestContentsManager(TestCase): def new_notebook(self): cm = self.contents_manager - model = cm.create_file() + model = cm.new_untitled(type='notebook') name = model['name'] path = model['path'] - full_model = cm.get_model(name, path) + full_model = cm.get_model(path) nb = full_model['content'] self.add_code_cell(nb) - cm.save(full_model, name, path) + cm.save(full_model, path) return nb, name, path - def test_create_file(self): + def test_new_untitled(self): cm = self.contents_manager # Test in root directory - model = cm.create_file() + model = cm.new_untitled(type='notebook') assert isinstance(model, dict) self.assertIn('name', model) self.assertIn('path', model) + self.assertIn('type', model) + self.assertEqual(model['type'], 'notebook') self.assertEqual(model['name'], 'Untitled0.ipynb') - self.assertEqual(model['path'], '') + self.assertEqual(model['path'], 'Untitled0.ipynb') # Test in sub-directory - sub_dir = '/foo/' - self.make_dir(cm.root_dir, 'foo') - model = cm.create_file(None, sub_dir) + model = cm.new_untitled(type='directory') assert isinstance(model, dict) self.assertIn('name', model) self.assertIn('path', model) - self.assertEqual(model['name'], 'Untitled0.ipynb') - self.assertEqual(model['path'], sub_dir.strip('/')) + self.assertIn('type', model) + self.assertEqual(model['type'], 'directory') + self.assertEqual(model['name'], 'Untitled Folder0') + self.assertEqual(model['path'], 'Untitled Folder0') + sub_dir = model['path'] + + model = cm.new_untitled(path=sub_dir) + assert isinstance(model, dict) + self.assertIn('name', model) + self.assertIn('path', model) + self.assertIn('type', model) + self.assertEqual(model['type'], 'file') + self.assertEqual(model['name'], 'untitled0') + self.assertEqual(model['path'], '%s/untitled0' % sub_dir) def test_get(self): cm = self.contents_manager # Create a notebook - model = cm.create_file() + model = cm.new_untitled(type='notebook') name = model['name'] path = model['path'] # Check that we 'get' on the notebook we just created - model2 = cm.get_model(name, path) + model2 = cm.get_model(path) assert isinstance(model2, dict) self.assertIn('name', model2) self.assertIn('path', model2) @@ -150,14 +162,14 @@ class TestContentsManager(TestCase): # Test in sub-directory sub_dir = '/foo/' self.make_dir(cm.root_dir, 'foo') - model = cm.create_file(None, sub_dir) - model2 = cm.get_model(name, sub_dir) + model = cm.new_untitled(path=sub_dir, ext='.ipynb') + model2 = cm.get_model(sub_dir + name) assert isinstance(model2, dict) self.assertIn('name', model2) self.assertIn('path', model2) self.assertIn('content', model2) self.assertEqual(model2['name'], 'Untitled0.ipynb') - self.assertEqual(model2['path'], sub_dir.strip('/')) + self.assertEqual(model2['path'], '{0}/{1}'.format(sub_dir.strip('/'), name)) @dec.skip_win32 def test_bad_symlink(self): @@ -165,7 +177,7 @@ class TestContentsManager(TestCase): path = 'test bad symlink' os_path = self.make_dir(cm.root_dir, path) - file_model = cm.create_file(path=path, ext='.txt') + file_model = cm.new_untitled(path=path, ext='.txt') # create a broken symlink os.symlink("target", os.path.join(os_path, "bad symlink")) @@ -175,16 +187,17 @@ class TestContentsManager(TestCase): @dec.skip_win32 def test_good_symlink(self): cm = self.contents_manager - path = 'test good symlink' - os_path = self.make_dir(cm.root_dir, path) + parent = 'test good symlink' + name = 'good symlink' + path = '{0}/{1}'.format(parent, name) + os_path = self.make_dir(cm.root_dir, parent) - file_model = cm.create_file(path=path, ext='.txt') + file_model = cm.new(path=parent + '/zfoo.txt') # create a good symlink - os.symlink(file_model['name'], os.path.join(os_path, "good symlink")) - symlink_model = cm.get_model(name="good symlink", path=path, content=False) - - dir_model = cm.get_model(path) + os.symlink(file_model['name'], os.path.join(os_path, name)) + symlink_model = cm.get_model(path, content=False) + dir_model = cm.get_model(parent) self.assertEqual( sorted(dir_model['content'], key=lambda x: x['name']), [symlink_model, file_model], @@ -193,53 +206,54 @@ class TestContentsManager(TestCase): def test_update(self): cm = self.contents_manager # Create a notebook - model = cm.create_file() + model = cm.new_untitled(type='notebook') name = model['name'] path = model['path'] # Change the name in the model for rename - model['name'] = 'test.ipynb' - model = cm.update(model, name, path) + model['path'] = 'test.ipynb' + model = cm.update(model, path) assert isinstance(model, dict) self.assertIn('name', model) self.assertIn('path', model) self.assertEqual(model['name'], 'test.ipynb') # Make sure the old name is gone - self.assertRaises(HTTPError, cm.get_model, name, path) + self.assertRaises(HTTPError, cm.get_model, path) # Test in sub-directory # Create a directory and notebook in that directory sub_dir = '/foo/' self.make_dir(cm.root_dir, 'foo') - model = cm.create_file(None, sub_dir) + model = cm.new_untitled(path=sub_dir, type='notebook') name = model['name'] path = model['path'] # Change the name in the model for rename - model['name'] = 'test_in_sub.ipynb' - model = cm.update(model, name, path) + d = path.rsplit('/', 1)[0] + new_path = model['path'] = d + '/test_in_sub.ipynb' + model = cm.update(model, path) assert isinstance(model, dict) self.assertIn('name', model) self.assertIn('path', model) self.assertEqual(model['name'], 'test_in_sub.ipynb') - self.assertEqual(model['path'], sub_dir.strip('/')) + self.assertEqual(model['path'], new_path) # Make sure the old name is gone - self.assertRaises(HTTPError, cm.get_model, name, path) + self.assertRaises(HTTPError, cm.get_model, path) def test_save(self): cm = self.contents_manager # Create a notebook - model = cm.create_file() + model = cm.new_untitled(type='notebook') name = model['name'] path = model['path'] # Get the model with 'content' - full_model = cm.get_model(name, path) + full_model = cm.get_model(path) # Save the notebook - model = cm.save(full_model, name, path) + model = cm.save(full_model, path) assert isinstance(model, dict) self.assertIn('name', model) self.assertIn('path', model) @@ -250,18 +264,18 @@ class TestContentsManager(TestCase): # Create a directory and notebook in that directory sub_dir = '/foo/' self.make_dir(cm.root_dir, 'foo') - model = cm.create_file(None, sub_dir) + model = cm.new_untitled(path=sub_dir, type='notebook') name = model['name'] path = model['path'] - model = cm.get_model(name, path) + model = cm.get_model(path) # Change the name in the model for rename - model = cm.save(model, name, path) + model = cm.save(model, path) assert isinstance(model, dict) self.assertIn('name', model) self.assertIn('path', model) self.assertEqual(model['name'], 'Untitled0.ipynb') - self.assertEqual(model['path'], sub_dir.strip('/')) + self.assertEqual(model['path'], 'foo/Untitled0.ipynb') def test_delete(self): cm = self.contents_manager @@ -269,36 +283,38 @@ class TestContentsManager(TestCase): nb, name, path = self.new_notebook() # Delete the notebook - cm.delete(name, path) + cm.delete(path) # Check that a 'get' on the deleted notebook raises and error - self.assertRaises(HTTPError, cm.get_model, name, path) + self.assertRaises(HTTPError, cm.get_model, path) def test_copy(self): cm = self.contents_manager - path = u'å b' + parent = u'å b' name = u'nb √.ipynb' - os.mkdir(os.path.join(cm.root_dir, path)) - orig = cm.create_file({'name' : name}, path=path) + path = u'{0}/{1}'.format(parent, name) + os.mkdir(os.path.join(cm.root_dir, parent)) + orig = cm.new(path=path) # copy with unspecified name - copy = cm.copy(name, path=path) + copy = cm.copy(path) self.assertEqual(copy['name'], orig['name'].replace('.ipynb', '-Copy0.ipynb')) # copy with specified name - copy2 = cm.copy(name, u'copy 2.ipynb', path=path) + copy2 = cm.copy(path, u'å b/copy 2.ipynb') self.assertEqual(copy2['name'], u'copy 2.ipynb') + self.assertEqual(copy2['path'], u'å b/copy 2.ipynb') def test_trust_notebook(self): cm = self.contents_manager nb, name, path = self.new_notebook() - untrusted = cm.get_model(name, path)['content'] + untrusted = cm.get_model(path)['content'] assert not cm.notary.check_cells(untrusted) # print(untrusted) - cm.trust_notebook(name, path) - trusted = cm.get_model(name, path)['content'] + cm.trust_notebook(path) + trusted = cm.get_model(path)['content'] # print(trusted) assert cm.notary.check_cells(trusted) @@ -306,13 +322,13 @@ class TestContentsManager(TestCase): cm = self.contents_manager nb, name, path = self.new_notebook() - cm.mark_trusted_cells(nb, name, path) + cm.mark_trusted_cells(nb, path) for cell in nb.cells: if cell.cell_type == 'code': assert not cell.metadata.trusted - cm.trust_notebook(name, path) - nb = cm.get_model(name, path)['content'] + cm.trust_notebook(path) + nb = cm.get_model(path)['content'] for cell in nb.cells: if cell.cell_type == 'code': assert cell.metadata.trusted @@ -321,12 +337,12 @@ class TestContentsManager(TestCase): cm = self.contents_manager nb, name, path = self.new_notebook() - cm.mark_trusted_cells(nb, name, path) - cm.check_and_sign(nb, name, path) + cm.mark_trusted_cells(nb, path) + cm.check_and_sign(nb, path) assert not cm.notary.check_signature(nb) - cm.trust_notebook(name, path) - nb = cm.get_model(name, path)['content'] - cm.mark_trusted_cells(nb, name, path) - cm.check_and_sign(nb, name, path) + cm.trust_notebook(path) + nb = cm.get_model(path)['content'] + cm.mark_trusted_cells(nb, path) + cm.check_and_sign(nb, path) assert cm.notary.check_signature(nb) diff --git a/IPython/html/services/sessions/handlers.py b/IPython/html/services/sessions/handlers.py index d88aa9656..9d0a5e400 100644 --- a/IPython/html/services/sessions/handlers.py +++ b/IPython/html/services/sessions/handlers.py @@ -35,10 +35,6 @@ class SessionRootHandler(IPythonHandler): model = self.get_json_body() if model is None: raise web.HTTPError(400, "No JSON data provided") - try: - name = model['notebook']['name'] - except KeyError: - raise web.HTTPError(400, "Missing field in JSON data: notebook.name") try: path = model['notebook']['path'] except KeyError: @@ -50,11 +46,11 @@ class SessionRootHandler(IPythonHandler): kernel_name = None # Check to see if session exists - if sm.session_exists(name=name, path=path): - model = sm.get_session(name=name, path=path) + if sm.session_exists(path=path): + model = sm.get_session(path=path) else: try: - model = sm.create_session(name=name, path=path, kernel_name=kernel_name) + model = sm.create_session(path=path, kernel_name=kernel_name) except NoSuchKernel: msg = ("The '%s' kernel is not available. Please pick another " "suitable kernel instead, or install that kernel." % kernel_name) @@ -92,8 +88,6 @@ class SessionHandler(IPythonHandler): changes = {} if 'notebook' in model: notebook = model['notebook'] - if 'name' in notebook: - changes['name'] = notebook['name'] if 'path' in notebook: changes['path'] = notebook['path'] diff --git a/IPython/html/services/sessions/sessionmanager.py b/IPython/html/services/sessions/sessionmanager.py index fc1674b3c..4f05bf172 100644 --- a/IPython/html/services/sessions/sessionmanager.py +++ b/IPython/html/services/sessions/sessionmanager.py @@ -21,7 +21,7 @@ class SessionManager(LoggingConfigurable): # Session database initialized below _cursor = None _connection = None - _columns = {'session_id', 'name', 'path', 'kernel_id'} + _columns = {'session_id', 'path', 'kernel_id'} @property def cursor(self): @@ -29,7 +29,7 @@ class SessionManager(LoggingConfigurable): if self._cursor is None: self._cursor = self.connection.cursor() self._cursor.execute("""CREATE TABLE session - (session_id, name, path, kernel_id)""") + (session_id, path, kernel_id)""") return self._cursor @property @@ -44,9 +44,9 @@ class SessionManager(LoggingConfigurable): """Close connection once SessionManager closes""" self.cursor.close() - def session_exists(self, name, path): + def session_exists(self, path): """Check to see if the session for a given notebook exists""" - self.cursor.execute("SELECT * FROM session WHERE name=? AND path=?", (name, path)) + self.cursor.execute("SELECT * FROM session WHERE path=?", (path,)) reply = self.cursor.fetchone() if reply is None: return False @@ -57,17 +57,17 @@ class SessionManager(LoggingConfigurable): "Create a uuid for a new session" return unicode_type(uuid.uuid4()) - def create_session(self, name=None, path=None, kernel_name=None): + def create_session(self, path=None, kernel_name=None): """Creates a session and returns its model""" session_id = self.new_session_id() # allow nbm to specify kernels cwd - kernel_path = self.contents_manager.get_kernel_path(name=name, path=path) + kernel_path = self.contents_manager.get_kernel_path(path=path) kernel_id = self.kernel_manager.start_kernel(path=kernel_path, kernel_name=kernel_name) - return self.save_session(session_id, name=name, path=path, + return self.save_session(session_id, path=path, kernel_id=kernel_id) - def save_session(self, session_id, name=None, path=None, kernel_id=None): + def save_session(self, session_id, path=None, kernel_id=None): """Saves the items for the session with the given session_id Given a session_id (and any other of the arguments), this method @@ -78,10 +78,8 @@ class SessionManager(LoggingConfigurable): ---------- session_id : str uuid for the session; this method must be given a session_id - name : str - the .ipynb notebook name that started the session path : str - the path to the named notebook + the path for the given notebook kernel_id : str a uuid for the kernel associated with this session @@ -90,8 +88,8 @@ class SessionManager(LoggingConfigurable): model : dict a dictionary of the session model """ - self.cursor.execute("INSERT INTO session VALUES (?,?,?,?)", - (session_id, name, path, kernel_id) + self.cursor.execute("INSERT INTO session VALUES (?,?,?)", + (session_id, path, kernel_id) ) return self.get_session(session_id=session_id) @@ -105,7 +103,7 @@ class SessionManager(LoggingConfigurable): ---------- **kwargs : keyword argument must be given one of the keywords and values from the session database - (i.e. session_id, name, path, kernel_id) + (i.e. session_id, path, kernel_id) Returns ------- @@ -182,7 +180,6 @@ class SessionManager(LoggingConfigurable): model = { 'id': row['session_id'], 'notebook': { - 'name': row['name'], 'path': row['path'] }, 'kernel': self.kernel_manager.kernel_model(row['kernel_id']) diff --git a/IPython/html/services/sessions/tests/test_sessionmanager.py b/IPython/html/services/sessions/tests/test_sessionmanager.py index 6383a0bad..36980bd6a 100644 --- a/IPython/html/services/sessions/tests/test_sessionmanager.py +++ b/IPython/html/services/sessions/tests/test_sessionmanager.py @@ -32,24 +32,24 @@ class TestSessionManager(TestCase): def test_get_session(self): sm = SessionManager(kernel_manager=DummyMKM()) - session_id = sm.create_session(name='test.ipynb', path='/path/to/', + session_id = sm.create_session(path='/path/to/test.ipynb', kernel_name='bar')['id'] model = sm.get_session(session_id=session_id) expected = {'id':session_id, - 'notebook':{'name':u'test.ipynb', 'path': u'/path/to/'}, + 'notebook':{'path': u'/path/to/test.ipynb'}, 'kernel': {'id':u'A', 'name': 'bar'}} self.assertEqual(model, expected) def test_bad_get_session(self): # Should raise error if a bad key is passed to the database. sm = SessionManager(kernel_manager=DummyMKM()) - session_id = sm.create_session(name='test.ipynb', path='/path/to/', + session_id = sm.create_session(path='/path/to/test.ipynb', kernel_name='foo')['id'] self.assertRaises(TypeError, sm.get_session, bad_id=session_id) # Bad keyword def test_get_session_dead_kernel(self): sm = SessionManager(kernel_manager=DummyMKM()) - session = sm.create_session(name='test1.ipynb', path='/path/to/1/', kernel_name='python') + session = sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python') # kill the kernel sm.kernel_manager.shutdown_kernel(session['kernel']['id']) with self.assertRaises(KeyError): @@ -61,24 +61,33 @@ class TestSessionManager(TestCase): def test_list_sessions(self): sm = SessionManager(kernel_manager=DummyMKM()) sessions = [ - sm.create_session(name='test1.ipynb', path='/path/to/1/', kernel_name='python'), - sm.create_session(name='test2.ipynb', path='/path/to/2/', kernel_name='python'), - sm.create_session(name='test3.ipynb', path='/path/to/3/', kernel_name='python'), + sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python'), + sm.create_session(path='/path/to/2/test2.ipynb', kernel_name='python'), + sm.create_session(path='/path/to/3/test3.ipynb', kernel_name='python'), ] sessions = sm.list_sessions() - expected = [{'id':sessions[0]['id'], 'notebook':{'name':u'test1.ipynb', - 'path': u'/path/to/1/'}, 'kernel':{'id':u'A', 'name':'python'}}, - {'id':sessions[1]['id'], 'notebook': {'name':u'test2.ipynb', - 'path': u'/path/to/2/'}, 'kernel':{'id':u'B', 'name':'python'}}, - {'id':sessions[2]['id'], 'notebook':{'name':u'test3.ipynb', - 'path': u'/path/to/3/'}, 'kernel':{'id':u'C', 'name':'python'}}] + expected = [ + { + 'id':sessions[0]['id'], + 'notebook':{'path': u'/path/to/1/test1.ipynb'}, + 'kernel':{'id':u'A', 'name':'python'} + }, { + 'id':sessions[1]['id'], + 'notebook': {'path': u'/path/to/2/test2.ipynb'}, + 'kernel':{'id':u'B', 'name':'python'} + }, { + 'id':sessions[2]['id'], + 'notebook':{'path': u'/path/to/3/test3.ipynb'}, + 'kernel':{'id':u'C', 'name':'python'} + } + ] self.assertEqual(sessions, expected) def test_list_sessions_dead_kernel(self): sm = SessionManager(kernel_manager=DummyMKM()) sessions = [ - sm.create_session(name='test1.ipynb', path='/path/to/1/', kernel_name='python'), - sm.create_session(name='test2.ipynb', path='/path/to/2/', kernel_name='python'), + sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python'), + sm.create_session(path='/path/to/2/test2.ipynb', kernel_name='python'), ] # kill one of the kernels sm.kernel_manager.shutdown_kernel(sessions[0]['kernel']['id']) @@ -87,8 +96,7 @@ class TestSessionManager(TestCase): { 'id': sessions[1]['id'], 'notebook': { - 'name': u'test2.ipynb', - 'path': u'/path/to/2/', + 'path': u'/path/to/2/test2.ipynb', }, 'kernel': { 'id': u'B', @@ -100,41 +108,47 @@ class TestSessionManager(TestCase): def test_update_session(self): sm = SessionManager(kernel_manager=DummyMKM()) - session_id = sm.create_session(name='test.ipynb', path='/path/to/', + session_id = sm.create_session(path='/path/to/test.ipynb', kernel_name='julia')['id'] - sm.update_session(session_id, name='new_name.ipynb') + sm.update_session(session_id, path='/path/to/new_name.ipynb') model = sm.get_session(session_id=session_id) expected = {'id':session_id, - 'notebook':{'name':u'new_name.ipynb', 'path': u'/path/to/'}, + 'notebook':{'path': u'/path/to/new_name.ipynb'}, 'kernel':{'id':u'A', 'name':'julia'}} self.assertEqual(model, expected) def test_bad_update_session(self): # try to update a session with a bad keyword ~ raise error sm = SessionManager(kernel_manager=DummyMKM()) - session_id = sm.create_session(name='test.ipynb', path='/path/to/', + session_id = sm.create_session(path='/path/to/test.ipynb', kernel_name='ir')['id'] self.assertRaises(TypeError, sm.update_session, session_id=session_id, bad_kw='test.ipynb') # Bad keyword def test_delete_session(self): sm = SessionManager(kernel_manager=DummyMKM()) sessions = [ - sm.create_session(name='test1.ipynb', path='/path/to/1/', kernel_name='python'), - sm.create_session(name='test2.ipynb', path='/path/to/2/', kernel_name='python'), - sm.create_session(name='test3.ipynb', path='/path/to/3/', kernel_name='python'), + sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python'), + sm.create_session(path='/path/to/2/test2.ipynb', kernel_name='python'), + sm.create_session(path='/path/to/3/test3.ipynb', kernel_name='python'), ] sm.delete_session(sessions[1]['id']) new_sessions = sm.list_sessions() - expected = [{'id':sessions[0]['id'], 'notebook':{'name':u'test1.ipynb', - 'path': u'/path/to/1/'}, 'kernel':{'id':u'A', 'name':'python'}}, - {'id':sessions[2]['id'], 'notebook':{'name':u'test3.ipynb', - 'path': u'/path/to/3/'}, 'kernel':{'id':u'C', 'name':'python'}}] + expected = [{ + 'id': sessions[0]['id'], + 'notebook': {'path': u'/path/to/1/test1.ipynb'}, + 'kernel': {'id':u'A', 'name':'python'} + }, { + 'id': sessions[2]['id'], + 'notebook': {'path': u'/path/to/3/test3.ipynb'}, + 'kernel': {'id':u'C', 'name':'python'} + } + ] self.assertEqual(new_sessions, expected) def test_bad_delete_session(self): # try to delete a session that doesn't exist ~ raise error sm = SessionManager(kernel_manager=DummyMKM()) - sm.create_session(name='test.ipynb', path='/path/to/', kernel_name='python') + sm.create_session(path='/path/to/test.ipynb', kernel_name='python') self.assertRaises(TypeError, sm.delete_session, bad_kwarg='23424') # Bad keyword self.assertRaises(web.HTTPError, sm.delete_session, session_id='23424') # nonexistant diff --git a/IPython/html/services/sessions/tests/test_sessions_api.py b/IPython/html/services/sessions/tests/test_sessions_api.py index 4090c3488..e721201e2 100644 --- a/IPython/html/services/sessions/tests/test_sessions_api.py +++ b/IPython/html/services/sessions/tests/test_sessions_api.py @@ -38,13 +38,13 @@ class SessionAPI(object): def get(self, id): return self._req('GET', id) - def create(self, name, path, kernel_name='python'): - body = json.dumps({'notebook': {'name':name, 'path':path}, + def create(self, path, kernel_name='python'): + body = json.dumps({'notebook': {'path':path}, 'kernel': {'name': kernel_name}}) return self._req('POST', '', body) - def modify(self, id, name, path): - body = json.dumps({'notebook': {'name':name, 'path':path}}) + def modify(self, id, path): + body = json.dumps({'notebook': {'path':path}}) return self._req('PATCH', id, body) def delete(self, id): @@ -78,12 +78,11 @@ class SessionAPITest(NotebookTestBase): sessions = self.sess_api.list().json() self.assertEqual(len(sessions), 0) - resp = self.sess_api.create('nb1.ipynb', 'foo') + resp = self.sess_api.create('foo/nb1.ipynb') self.assertEqual(resp.status_code, 201) newsession = resp.json() self.assertIn('id', newsession) - self.assertEqual(newsession['notebook']['name'], 'nb1.ipynb') - self.assertEqual(newsession['notebook']['path'], 'foo') + self.assertEqual(newsession['notebook']['path'], 'foo/nb1.ipynb') self.assertEqual(resp.headers['Location'], '/api/sessions/{0}'.format(newsession['id'])) sessions = self.sess_api.list().json() @@ -95,7 +94,7 @@ class SessionAPITest(NotebookTestBase): self.assertEqual(got, newsession) def test_delete(self): - newsession = self.sess_api.create('nb1.ipynb', 'foo').json() + newsession = self.sess_api.create('foo/nb1.ipynb').json() sid = newsession['id'] resp = self.sess_api.delete(sid) @@ -108,10 +107,9 @@ class SessionAPITest(NotebookTestBase): self.sess_api.get(sid) def test_modify(self): - newsession = self.sess_api.create('nb1.ipynb', 'foo').json() + newsession = self.sess_api.create('foo/nb1.ipynb').json() sid = newsession['id'] - changed = self.sess_api.modify(sid, 'nb2.ipynb', '').json() + changed = self.sess_api.modify(sid, 'nb2.ipynb').json() self.assertEqual(changed['id'], sid) - self.assertEqual(changed['notebook']['name'], 'nb2.ipynb') - self.assertEqual(changed['notebook']['path'], '') + self.assertEqual(changed['notebook']['path'], 'nb2.ipynb') diff --git a/IPython/html/static/base/js/utils.js b/IPython/html/static/base/js/utils.js index 16f9a7068..a974166a0 100644 --- a/IPython/html/static/base/js/utils.js +++ b/IPython/html/static/base/js/utils.js @@ -272,11 +272,11 @@ define([ } else { line = "background-color: "; } - line = line + "rgb(" + r + "," + g + "," + b + ");" - if ( !attrs["style"] ) { - attrs["style"] = line; + line = line + "rgb(" + r + "," + g + "," + b + ");"; + if ( !attrs.style ) { + attrs.style = line; } else { - attrs["style"] += " " + line; + attrs.style += " " + line; } } } @@ -285,7 +285,7 @@ define([ function ansispan(str) { // ansispan function adapted from github.com/mmalecki/ansispan (MIT License) // regular ansi escapes (using the table above) - var is_open = false + var is_open = false; return str.replace(/\033\[(0?[01]|22|39)?([;\d]+)?m/g, function(match, prefix, pattern) { if (!pattern) { // [(01|22|39|)m close spans @@ -313,7 +313,7 @@ define([ return span + ">"; } }); - }; + } // Transform ANSI color escape codes into HTML tags with css // classes listed in the above ansi_colormap object. The actual color used @@ -392,6 +392,18 @@ define([ return url; }; + var url_path_split = function (path) { + // Like os.path.split for URLs. + // Always returns two strings, the directory path and the base filename + + var idx = path.lastIndexOf('/'); + if (idx === -1) { + return ['', path]; + } else { + return [ path.slice(0, idx), path.slice(idx + 1) ]; + } + }; + var parse_url = function (url) { // an `a` element with an href allows attr-access to the parsed segments of a URL // a = parse_url("http://localhost:8888/path/name#hash") @@ -577,7 +589,7 @@ define([ wrapped_error.xhr_status = status; wrapped_error.xhr_error = error; return wrapped_error; - } + }; var utils = { regex_split : regex_split, @@ -588,6 +600,7 @@ define([ points_to_pixels : points_to_pixels, get_body_data : get_body_data, parse_url : parse_url, + url_path_split : url_path_split, url_path_join : url_path_join, url_join_encode : url_join_encode, encode_uri_components : encode_uri_components, diff --git a/IPython/html/static/notebook/js/main.js b/IPython/html/static/notebook/js/main.js index 486267de9..6c4a6a663 100644 --- a/IPython/html/static/notebook/js/main.js +++ b/IPython/html/static/notebook/js/main.js @@ -151,6 +151,6 @@ require([ IPython.tooltip = notebook.tooltip; events.trigger('app_initialized.NotebookApp'); - notebook.load_notebook(common_options.notebook_name, common_options.notebook_path); + notebook.load_notebook(common_options.notebook_path); }); diff --git a/IPython/html/static/notebook/js/menubar.js b/IPython/html/static/notebook/js/menubar.js index 04779b758..0b54e9008 100644 --- a/IPython/html/static/notebook/js/menubar.js +++ b/IPython/html/static/notebook/js/menubar.js @@ -2,13 +2,14 @@ // Distributed under the terms of the Modified BSD License. define([ - 'base/js/namespace', 'jquery', + 'base/js/namespace', + 'base/js/dialog', 'base/js/utils', 'notebook/js/tour', 'bootstrap', 'moment', -], function(IPython, $, utils, tour, bootstrap, moment) { +], function($, IPython, dialog, utils, tour, bootstrap, moment) { "use strict"; var MenuBar = function (selector, options) { @@ -89,14 +90,14 @@ define([ this.element.find('#new_notebook').click(function () { // Create a new notebook in the same path as the current // notebook's path. - that.contents.new(that.notebook.notebook_path, null, { - ext: ".ipynb", + var parent = utils.url_path_split(that.notebook.notebook_path)[0]; + that.contents.new_untitled(parent, { + type: "notebook", extra_settings: {async: false}, // So we can open a new window afterwards success: function (data) { window.open( utils.url_join_encode( - that.base_url, 'notebooks', - data.path, data.name + that.base_url, 'notebooks', data.path ), '_blank'); }, error: function(error) { diff --git a/IPython/html/static/notebook/js/notebook.js b/IPython/html/static/notebook/js/notebook.js index 9312a44fd..21bcba24b 100644 --- a/IPython/html/static/notebook/js/notebook.js +++ b/IPython/html/static/notebook/js/notebook.js @@ -212,13 +212,13 @@ define([ }); this.events.on('kernel_ready.Kernel', function(event, data) { - var kinfo = data.kernel.info_reply + var kinfo = data.kernel.info_reply; var langinfo = kinfo.language_info || {}; if (!langinfo.name) langinfo.name = kinfo.language; that.metadata.language_info = langinfo; // Mode 'null' should be plain, unhighlighted text. - var cm_mode = langinfo.codemirror_mode || langinfo.language || 'null' + var cm_mode = langinfo.codemirror_mode || langinfo.language || 'null'; that.set_codemirror_mode(cm_mode); }); @@ -1029,7 +1029,7 @@ define([ text = ''; } // metadata - target_cell.metadata = source_cell.metadata + target_cell.metadata = source_cell.metadata; // We must show the editor before setting its contents target_cell.unrender(); target_cell.set_text(text); @@ -1231,8 +1231,6 @@ define([ * @method split_cell */ Notebook.prototype.split_cell = function () { - var mdc = textcell.MarkdownCell; - var rc = textcell.RawCell; var cell = this.get_selected_cell(); if (cell.is_splittable()) { var texta = cell.get_pre_cursor(); @@ -1251,8 +1249,6 @@ define([ * @method merge_cell_above */ Notebook.prototype.merge_cell_above = function () { - var mdc = textcell.MarkdownCell; - var rc = textcell.RawCell; var index = this.get_selected_index(); var cell = this.get_cell(index); var render = cell.rendered; @@ -1288,8 +1284,6 @@ define([ * @method merge_cell_below */ Notebook.prototype.merge_cell_below = function () { - var mdc = textcell.MarkdownCell; - var rc = textcell.RawCell; var index = this.get_selected_index(); var cell = this.get_cell(index); var render = cell.rendered; @@ -1523,9 +1517,9 @@ define([ } this.codemirror_mode = newmode; codecell.CodeCell.options_default.cm_config.mode = newmode; - modename = newmode.mode || newmode.name || newmode; + var modename = newmode.mode || newmode.name || newmode; - that = this; + var that = this; utils.requireCodeMirrorMode(modename, function () { $.map(that.get_cells(), function(cell, i) { if (cell.cell_type === 'code'){ @@ -1547,7 +1541,6 @@ define([ * @method start_session */ Notebook.prototype.start_session = function (kernel_name) { - var that = this; if (this._session_starting) { throw new session.SessionAlreadyStarting(); } @@ -1629,7 +1622,6 @@ define([ Notebook.prototype.execute_cell = function () { // mode = shift, ctrl, alt var cell = this.get_selected_cell(); - var cell_index = this.find_cell_index(cell); cell.execute(); this.command_mode(); @@ -1758,7 +1750,9 @@ define([ * @param {String} name A new name for this notebook */ Notebook.prototype.set_notebook_name = function (name) { + var parent = utils.url_path_split(this.notebook_path)[0]; this.notebook_name = name; + this.notebook_path = utils.url_path_join(parent, name); }; /** @@ -1795,6 +1789,7 @@ define([ // Save the metadata and name. this.metadata = content.metadata; this.notebook_name = data.name; + this.notebook_path = data.path; var trusted = true; // Trigger an event changing the kernel spec - this will set the default @@ -1807,7 +1802,7 @@ define([ if (this.metadata.language_info !== undefined) { var langinfo = this.metadata.language_info; // Mode 'null' should be plain, unhighlighted text. - var cm_mode = langinfo.codemirror_mode || langinfo.language || 'null' + var cm_mode = langinfo.codemirror_mode || langinfo.language || 'null'; this.set_codemirror_mode(cm_mode); } @@ -1900,8 +1895,6 @@ define([ Notebook.prototype.save_notebook = function (extra_settings) { // Create a JSON model to be sent to the server. var model = { - name : this.notebook_name, - path : this.notebook_path, type : "notebook", content : this.toJSON() }; @@ -1909,11 +1902,11 @@ define([ var start = new Date().getTime(); var that = this; - this.contents.save(this.notebook_path, this.notebook_name, model, { + this.contents.save(this.notebook_path, model, { extra_settings: extra_settings, success: $.proxy(this.save_notebook_success, this, start), error: function (error) { - that.events.trigger('notebook_save_failed.Notebook'); + that.events.trigger('notebook_save_failed.Notebook', error); } }); }; @@ -2031,15 +2024,15 @@ define([ Notebook.prototype.copy_notebook = function(){ var base_url = this.base_url; - this.contents.copy(this.notebook_path, null, this.notebook_name, { + var parent = utils.url_path_split(this.notebook_path)[0]; + this.contents.copy(this.notebook_path, parent, { // synchronous so we can open a new window on success extra_settings: {async: false}, success: function (data) { window.open(utils.url_join_encode( - base_url, 'notebooks', data.path, data.name + base_url, 'notebooks', data.path ), '_blank'); - }, - error : utils.log_ajax_error + } }); }; @@ -2049,11 +2042,13 @@ define([ } var that = this; - this.contents.rename(this.notebook_path, this.notebook_name, - this.notebook_path, new_name, { + var parent = utils.url_path_split(this.notebook_path)[0]; + var new_path = utils.url_path_join(parent, new_name); + this.contents.rename(this.notebook_path, new_path, { success: function (json) { - var name = that.notebook_name = json.name; - that.session.rename_notebook(name, json.path); + that.notebook_name = json.name; + that.notebook_path = json.path; + that.session.rename_notebook(json.path); that.events.trigger('notebook_renamed.Notebook', json); }, error: $.proxy(this.rename_error, this) @@ -2061,7 +2056,7 @@ define([ }; Notebook.prototype.delete = function () { - this.contents.delete(this.notebook_name, this.notebook_path); + this.contents.delete(this.notebook_path); }; Notebook.prototype.rename_error = function (error) { @@ -2100,13 +2095,13 @@ define([ * Request a notebook's data from the server. * * @method load_notebook - * @param {String} notebook_name and path A notebook to load + * @param {String} notebook_path A notebook to load */ - Notebook.prototype.load_notebook = function (notebook_name, notebook_path) { - this.notebook_name = notebook_name; + Notebook.prototype.load_notebook = function (notebook_path) { this.notebook_path = notebook_path; + this.notebook_name = utils.url_path_split(this.notebook_path)[1]; this.events.trigger('notebook_loading.Notebook'); - this.contents.load(notebook_path, notebook_name, { + this.contents.get(notebook_path, { success: $.proxy(this.load_notebook_success, this), error: $.proxy(this.load_notebook_error, this) }); @@ -2121,7 +2116,7 @@ define([ * @param {Object} data JSON representation of a notebook */ Notebook.prototype.load_notebook_success = function (data) { - var failed; + var failed, msg; try { this.fromJSON(data); } catch (e) { @@ -2146,12 +2141,11 @@ define([ } if (data.message) { - var msg; if (failed) { - msg = "The notebook also failed validation:" + msg = "The notebook also failed validation:"; } else { msg = "An invalid notebook may not function properly." + - " The validation error was:" + " The validation error was:"; } body.append($("

").text( msg @@ -2192,7 +2186,7 @@ define([ src = " a newer notebook format "; } - var msg = "This notebook has been converted from" + src + + msg = "This notebook has been converted from" + src + "(v"+orig_nbformat+") to the current notebook " + "format (v"+nbmodel.nbformat+"). The next time you save this notebook, the " + "current notebook format will be used."; @@ -2219,7 +2213,7 @@ define([ var that = this; var orig_vs = 'v' + nbmodel.nbformat + '.' + orig_nbformat_minor; var this_vs = 'v' + nbmodel.nbformat + '.' + this.nbformat_minor; - var msg = "This notebook is version " + orig_vs + ", but we only fully support up to " + + msg = "This notebook is version " + orig_vs + ", but we only fully support up to " + this_vs + ". You can still work with this notebook, but some features " + "introduced in later notebook versions may not be available."; @@ -2270,7 +2264,7 @@ define([ Notebook.prototype.load_notebook_error = function (error) { this.events.trigger('notebook_load_failed.Notebook', error); var msg; - if (error.name = utils.XHR_ERROR && error.xhr.status === 500) { + if (error.name === utils.XHR_ERROR && error.xhr.status === 500) { utils.log_ajax_error(error.xhr, error.xhr_status, error.xhr_error); msg = "An unknown error occurred while loading this notebook. " + "This version can load notebook formats " + @@ -2330,10 +2324,10 @@ define([ */ Notebook.prototype.list_checkpoints = function () { var that = this; - this.contents.list_checkpoints(this.notebook_path, this.notebook_name, { + this.contents.list_checkpoints(this.notebook_path, { success: $.proxy(this.list_checkpoints_success, this), error: function(error) { - that.events.trigger('list_checkpoints_failed.Notebook'); + that.events.trigger('list_checkpoints_failed.Notebook', error); } }); }; @@ -2362,10 +2356,10 @@ define([ */ Notebook.prototype.create_checkpoint = function () { var that = this; - this.contents.create_checkpoint(this.notebook_path, this.notebook_name, { + this.contents.create_checkpoint(this.notebook_path, { success: $.proxy(this.create_checkpoint_success, this), error: function (error) { - that.events.trigger('checkpoint_failed.Notebook'); + that.events.trigger('checkpoint_failed.Notebook', error); } }); }; @@ -2432,11 +2426,11 @@ define([ Notebook.prototype.restore_checkpoint = function (checkpoint) { this.events.trigger('notebook_restoring.Notebook', checkpoint); var that = this; - this.contents.restore_checkpoint(this.notebook_path, this.notebook_name, + this.contents.restore_checkpoint(this.notebook_path, checkpoint, { success: $.proxy(this.restore_checkpoint_success, this), error: function (error) { - that.events.trigger('checkpoint_restore_failed.Notebook'); + that.events.trigger('checkpoint_restore_failed.Notebook', error); } }); }; @@ -2448,7 +2442,7 @@ define([ */ Notebook.prototype.restore_checkpoint_success = function () { this.events.trigger('checkpoint_restored.Notebook'); - this.load_notebook(this.notebook_name, this.notebook_path); + this.load_notebook(this.notebook_path); }; /** @@ -2460,7 +2454,7 @@ define([ Notebook.prototype.delete_checkpoint = function (checkpoint) { this.events.trigger('notebook_restoring.Notebook', checkpoint); var that = this; - this.contents.delete_checkpoint(this.notebook_path, this.notebook_name, + this.contents.delete_checkpoint(this.notebook_path, checkpoint, { success: $.proxy(this.delete_checkpoint_success, this), error: function (error) { @@ -2476,7 +2470,7 @@ define([ */ Notebook.prototype.delete_checkpoint_success = function () { this.events.trigger('checkpoint_deleted.Notebook'); - this.load_notebook(this.notebook_name, this.notebook_path); + this.load_notebook(this.notebook_path); }; diff --git a/IPython/html/static/notebook/js/savewidget.js b/IPython/html/static/notebook/js/savewidget.js index 72cddf927..f2cfb5c56 100644 --- a/IPython/html/static/notebook/js/savewidget.js +++ b/IPython/html/static/notebook/js/savewidget.js @@ -122,14 +122,12 @@ define([ SaveWidget.prototype.update_address_bar = function(){ var base_url = this.notebook.base_url; - var nbname = this.notebook.notebook_name; var path = this.notebook.notebook_path; - var state = {path : path, name: nbname}; + var state = {path : path}; window.history.replaceState(state, "", utils.url_join_encode( base_url, "notebooks", - path, - nbname) + path) ); }; @@ -199,7 +197,7 @@ define([ $.proxy(that._regularly_update_checkpoint_date, that), t + 1000 ); - } + }; var tdelta = Math.ceil(new Date()-this._checkpoint_date); // update regularly for the first 6hours and show diff --git a/IPython/html/static/services/contents.js b/IPython/html/static/services/contents.js index d229b0b74..481015c42 100644 --- a/IPython/html/static/services/contents.js +++ b/IPython/html/static/services/contents.js @@ -29,8 +29,9 @@ define([ // An error representing the result of attempting to delete a non-empty // directory. this.message = 'A directory must be empty before being deleted.'; - } - Contents.DirectoryNotEmptyError.prototype = new Error; + }; + + Contents.DirectoryNotEmptyError.prototype = Object.create(Error.prototype); Contents.DirectoryNotEmptyError.prototype.name = Contents.DIRECTORY_NOT_EMPTY_ERROR; @@ -54,29 +55,28 @@ define([ */ Contents.prototype.create_basic_error_handler = function(callback) { if (!callback) { - return function(xhr, status, error) { }; + return utils.log_ajax_error; } return function(xhr, status, error) { callback(utils.wrap_ajax_error(xhr, status, error)); }; - } + }; /** * File Functions (including notebook operations) */ /** - * Load a file. + * Get a file. * * Calls success with file JSON model, or error with error. * - * @method load_notebook + * @method get * @param {String} path - * @param {String} name * @param {Function} success * @param {Function} error */ - Contents.prototype.load = function (path, name, options) { + Contents.prototype.get = function (path, options) { // We do the call with settings so we can set cache to false. var settings = { processData : false, @@ -86,32 +86,29 @@ define([ success : options.success, error : this.create_basic_error_handler(options.error) }; - var url = this.api_url(path, name); + var url = this.api_url(path); $.ajax(url, settings); }; /** - * Creates a new notebook file at the specified directory path. + * Creates a new untitled file or directory in the specified directory path. * - * @method scroll_to_cell - * @param {String} path The path to create the new notebook at - * @param {String} name Name for new file. Chosen by server if unspecified. + * @method new + * @param {String} path: the directory in which to create the new file/directory * @param {Object} options: - * ext: file extension to use if name unspecified + * ext: file extension to use + * type: model type to create ('notebook', 'file', or 'directory') */ - Contents.prototype.new = function(path, name, options) { - var method, data; - if (name) { - method = "PUT"; - } else { - method = "POST"; - data = JSON.stringify({ext: options.ext || ".ipynb"}); - } + Contents.prototype.new_untitled = function(path, options) { + var data = JSON.stringify({ + ext: options.ext, + type: options.type + }); var settings = { processData : false, - type : method, + type : "POST", data: data, dataType : "json", success : options.success || function() {}, @@ -123,9 +120,8 @@ define([ $.ajax(this.api_url(path), settings); }; - Contents.prototype.delete = function(name, path, options) { + Contents.prototype.delete = function(path, options) { var error_callback = options.error || function() {}; - var that = this; var settings = { processData : false, type : "DELETE", @@ -140,12 +136,12 @@ define([ error_callback(utils.wrap_ajax_error(xhr, status, error)); } }; - var url = this.api_url(path, name); + var url = this.api_url(path); $.ajax(url, settings); }; - Contents.prototype.rename = function(path, name, new_path, new_name, options) { - var data = {name: new_name, path: new_path}; + Contents.prototype.rename = function(path, new_path, options) { + var data = {path: new_path}; var settings = { processData : false, type : "PATCH", @@ -155,11 +151,11 @@ define([ success : options.success || function() {}, error : this.create_basic_error_handler(options.error) }; - var url = this.api_url(path, name); + var url = this.api_url(path); $.ajax(url, settings); }; - Contents.prototype.save = function(path, name, model, options) { + Contents.prototype.save = function(path, model, options) { // We do the call with settings so we can set cache to false. var settings = { processData : false, @@ -172,24 +168,19 @@ define([ if (options.extra_settings) { $.extend(settings, options.extra_settings); } - var url = this.api_url(path, name); + var url = this.api_url(path); $.ajax(url, settings); }; - Contents.prototype.copy = function(to_path, to_name, from, options) { - var url, method; - if (to_name) { - url = this.api_url(to_path, to_name); - method = "PUT"; - } else { - url = this.api_url(to_path); - method = "POST"; - } + Contents.prototype.copy = function(from_file, to_dir, options) { + // Copy a file into a given directory via POST + // The server will select the name of the copied file + var url = this.api_url(to_dir); var settings = { processData : false, - type: method, - data: JSON.stringify({copy_from: from}), + type: "POST", + data: JSON.stringify({copy_from: from_file}), dataType : "json", success: options.success || function() {}, error: this.create_basic_error_handler(options.error) @@ -204,8 +195,8 @@ define([ * Checkpointing Functions */ - Contents.prototype.create_checkpoint = function(path, name, options) { - var url = this.api_url(path, name, 'checkpoints'); + Contents.prototype.create_checkpoint = function(path, options) { + var url = this.api_url(path, 'checkpoints'); var settings = { type : "POST", success: options.success || function() {}, @@ -214,8 +205,8 @@ define([ $.ajax(url, settings); }; - Contents.prototype.list_checkpoints = function(path, name, options) { - var url = this.api_url(path, name, 'checkpoints'); + Contents.prototype.list_checkpoints = function(path, options) { + var url = this.api_url(path, 'checkpoints'); var settings = { type : "GET", success: options.success, @@ -224,8 +215,8 @@ define([ $.ajax(url, settings); }; - Contents.prototype.restore_checkpoint = function(path, name, checkpoint_id, options) { - var url = this.api_url(path, name, 'checkpoints', checkpoint_id); + Contents.prototype.restore_checkpoint = function(path, checkpoint_id, options) { + var url = this.api_url(path, 'checkpoints', checkpoint_id); var settings = { type : "POST", success: options.success || function() {}, @@ -234,8 +225,8 @@ define([ $.ajax(url, settings); }; - Contents.prototype.delete_checkpoint = function(path, name, checkpoint_id, options) { - var url = this.api_url(path, name, 'checkpoints', checkpoint_id); + Contents.prototype.delete_checkpoint = function(path, checkpoint_id, options) { + var url = this.api_url(path, 'checkpoints', checkpoint_id); var settings = { type : "DELETE", success: options.success || function() {}, @@ -255,10 +246,8 @@ define([ * representing individual files or directories. Each dictionary has * the keys: * type: "notebook" or "directory" - * name: the name of the file or directory * created: created date * last_modified: last modified dat - * path: the path * @method list_notebooks * @param {String} path The path to list notebooks in * @param {Function} load_callback called with list of notebooks on success diff --git a/IPython/html/static/services/sessions/session.js b/IPython/html/static/services/sessions/session.js index 5b2316d75..22ba9fc97 100644 --- a/IPython/html/static/services/sessions/session.js +++ b/IPython/html/static/services/sessions/session.js @@ -15,7 +15,6 @@ define([ * all other operations, the kernel object should be used. * * Options should include: - * - notebook_name: the notebook name * - notebook_path: the path (not including name) to the notebook * - kernel_name: the type of kernel (e.g. python3) * - base_url: the root url of the notebook server @@ -28,7 +27,6 @@ define([ var Session = function (options) { this.id = null; this.notebook_model = { - name: options.notebook_name, path: options.notebook_path }; this.kernel_model = { @@ -154,15 +152,11 @@ define([ * undefined, then they will not be changed. * * @function rename_notebook - * @param {string} [name] - new notebook name - * @param {string} [path] - new path to notebook + * @param {string} [path] - new notebook path * @param {function} [success] - function executed on ajax success * @param {function} [error] - functon executed on ajax error */ - Session.prototype.rename_notebook = function (name, path, success, error) { - if (name !== undefined) { - this.notebook_model.name = name; - } + Session.prototype.rename_notebook = function (path, success, error) { if (path !== undefined) { this.notebook_model.path = path; } @@ -208,7 +202,6 @@ define([ * fresh. If options are given, they can include any of the * following: * - * - notebook_name - the name of the notebook * - notebook_path - the path to the notebook * - kernel_name - the name (type) of the kernel * @@ -220,9 +213,6 @@ define([ Session.prototype.restart = function (options, success, error) { var that = this; var start = function () { - if (options && options.notebook_name) { - that.notebook_model.name = options.notebook_name; - } if (options && options.notebook_path) { that.notebook_model.path = options.notebook_path; } @@ -238,8 +228,8 @@ define([ // Helper functions /** - * Get the data model for the session, which includes the notebook - * (name and path) and kernel (name and id). + * Get the data model for the session, which includes the notebook path + * and kernel (name and id). * * @function _get_model * @returns {Object} - the data model @@ -266,7 +256,6 @@ define([ this.session_url = utils.url_join_encode(this.session_service_url, this.id); } if (data && data.notebook) { - this.notebook_model.name = data.notebook.name; this.notebook_model.path = data.notebook.path; } if (data && data.kernel) { diff --git a/IPython/html/static/tree/js/main.js b/IPython/html/static/tree/js/main.js index 2396ad8db..25ce90510 100644 --- a/IPython/html/static/tree/js/main.js +++ b/IPython/html/static/tree/js/main.js @@ -2,8 +2,9 @@ // Distributed under the terms of the Modified BSD License. require([ - 'base/js/namespace', 'jquery', + 'base/js/namespace', + 'base/js/dialog', 'base/js/events', 'base/js/page', 'base/js/utils', @@ -19,18 +20,20 @@ require([ 'bootstrap', 'custom/custom', ], function( - IPython, - $, + $, + IPython, + dialog, events, - page, - utils, - contents, + page, + utils, + contents_service, notebooklist, clusterlist, sesssionlist, kernellist, terminallist, loginwidget){ + "use strict"; page = new page.Page(); @@ -38,36 +41,37 @@ require([ base_url: utils.get_body_data("baseUrl"), notebook_path: utils.get_body_data("notebookPath"), }; - session_list = new sesssionlist.SesssionList($.extend({ + var session_list = new sesssionlist.SesssionList($.extend({ events: events}, common_options)); - contents = new contents.Contents($.extend({ + var contents = new contents_service.Contents($.extend({ events: events}, common_options)); - notebook_list = new notebooklist.NotebookList('#notebook_list', $.extend({ + var notebook_list = new notebooklist.NotebookList('#notebook_list', $.extend({ contents: contents, session_list: session_list}, common_options)); - cluster_list = new clusterlist.ClusterList('#cluster_list', common_options); - kernel_list = new kernellist.KernelList('#running_list', $.extend({ + var cluster_list = new clusterlist.ClusterList('#cluster_list', common_options); + var kernel_list = new kernellist.KernelList('#running_list', $.extend({ session_list: session_list}, common_options)); + var terminal_list; if (utils.get_body_data("terminalsAvailable") === "True") { terminal_list = new terminallist.TerminalList('#terminal_list', common_options); } - login_widget = new loginwidget.LoginWidget('#login_widget', common_options); + var login_widget = new loginwidget.LoginWidget('#login_widget', common_options); $('#new_notebook').click(function (e) { - contents.new(common_options.notebook_path, null, { - ext: ".ipynb", + contents.new_untitled(common_options.notebook_path, { + type: "notebook", extra_settings: {async: false}, // So we can open a new window afterwards success: function (data) { window.open( utils.url_join_encode( common_options.base_url, 'notebooks', - data.path, data.name + data.path ), '_blank'); }, error: function(error) { diff --git a/IPython/html/static/tree/js/notebooklist.js b/IPython/html/static/tree/js/notebooklist.js index b36116720..678fceb5a 100644 --- a/IPython/html/static/tree/js/notebooklist.js +++ b/IPython/html/static/tree/js/notebooklist.js @@ -100,7 +100,7 @@ define([ }; reader.onerror = function (event) { var item = $(event.target).data('item'); - var name = item.data('name') + var name = item.data('name'); item.remove(); dialog.modal({ title : 'Failed to read file', @@ -141,7 +141,7 @@ define([ }; NotebookList.prototype.load_list = function () { - var that = this + var that = this; this.contents.list_contents(that.notebook_path, { success: $.proxy(this.draw_notebook_list, this), error: function(error) { @@ -177,7 +177,7 @@ define([ model = { type: 'directory', name: '..', - path: path, + path: utils.url_path_split(path)[0], }; this.add_link(model, item); offset += 1; @@ -240,8 +240,7 @@ define([ utils.url_join_encode( this.base_url, uri_prefix, - path, - name + path ) ); // directory nav doesn't open new tabs @@ -311,7 +310,6 @@ define([ }; NotebookList.prototype.add_delete_button = function (item) { - var new_buttons = $('').addClass("btn-group pull-right"); var notebooklist = this; var delete_button = $("