From 1274dc14b623df8d21582566120a0fefe207adc5 2013-10-23 01:45:30 From: Min RK Date: 2013-10-23 01:45:30 Subject: [PATCH] Merge pull request #4303 from ipython/multidir Add multidirectory support for the Notebook. Major change to URL schemes in the notebook server, documented in [IPEP 16](https://github.com/ipython/ipython/wiki/IPEP-16%3A-Notebook-multi-directory-dashboard-and-URL-mapping). --- diff --git a/.travis.yml b/.travis.yml index fcbc3f4..5f7cd05 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ python: - 3.3 before_install: - easy_install -q pyzmq - - pip install jinja2 sphinx pygments tornado + - pip install jinja2 sphinx pygments tornado requests - sudo apt-get install pandoc install: - python setup.py install -q diff --git a/IPython/config/application.py b/IPython/config/application.py index 18b3659..c2e5bd9 100644 --- a/IPython/config/application.py +++ b/IPython/config/application.py @@ -38,6 +38,7 @@ from IPython.utils.traitlets import ( ) from IPython.utils.importstring import import_item from IPython.utils.text import indent, wrap_paragraphs, dedent +from IPython.utils import py3compat #----------------------------------------------------------------------------- # function for re-wrapping a helpstring @@ -457,7 +458,7 @@ class Application(SingletonConfigurable): def parse_command_line(self, argv=None): """Parse the command line arguments.""" argv = sys.argv[1:] if argv is None else argv - self.argv = list(argv) + self.argv = [ py3compat.cast_unicode(arg) for arg in argv ] if argv and argv[0] == 'help': # turn `ipython help notebook` into `ipython notebook -h` diff --git a/IPython/consoleapp.py b/IPython/consoleapp.py index dddcb8a..2935553 100644 --- a/IPython/consoleapp.py +++ b/IPython/consoleapp.py @@ -45,6 +45,7 @@ from IPython.kernel.zmq.kernelapp import ( kernel_aliases, IPKernelApp ) +from IPython.kernel.zmq.pylab.config import InlineBackend from IPython.kernel.zmq.session import Session, default_secure from IPython.kernel.zmq.zmqshell import ZMQInteractiveShell from IPython.kernel.connect import ConnectionFileMixin @@ -110,14 +111,7 @@ aliases.update(app_aliases) # IPythonConsole #----------------------------------------------------------------------------- -classes = [IPKernelApp, ZMQInteractiveShell, KernelManager, ProfileDir, Session] - -try: - from IPython.kernel.zmq.pylab.backend_inline import InlineBackend -except ImportError: - pass -else: - classes.append(InlineBackend) +classes = [IPKernelApp, ZMQInteractiveShell, KernelManager, ProfileDir, Session, InlineBackend] class IPythonConsoleApp(ConnectionFileMixin): name = 'ipython-console-mixin' diff --git a/IPython/html/base/handlers.py b/IPython/html/base/handlers.py index a237a20..9ca0d25 100644 --- a/IPython/html/base/handlers.py +++ b/IPython/html/base/handlers.py @@ -19,12 +19,16 @@ Authors: import datetime import email.utils +import functools import hashlib +import json import logging import mimetypes import os import stat +import sys import threading +import traceback from tornado import web from tornado import websocket @@ -37,6 +41,11 @@ except ImportError: from IPython.config import Application from IPython.external.decorator import decorator from IPython.utils.path import filefind +from IPython.utils.jsonutil import date_default + +# UF_HIDDEN is a stat flag not defined in the stat module. +# It is used by BSD to indicate hidden files. +UF_HIDDEN = getattr(stat, 'UF_HIDDEN', 32768) #----------------------------------------------------------------------------- # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary! @@ -214,7 +223,11 @@ class IPythonHandler(AuthenticatedHandler): return self.settings['cluster_manager'] @property - def project(self): + def session_manager(self): + return self.settings['session_manager'] + + @property + def project_dir(self): return self.notebook_manager.notebook_dir #--------------------------------------------------------------- @@ -240,12 +253,100 @@ class IPythonHandler(AuthenticatedHandler): use_less=self.use_less, ) + def get_json_body(self): + """Return the body of the request as JSON data.""" + if not self.request.body: + return None + # Do we need to call body.decode('utf-8') here? + body = self.request.body.strip().decode(u'utf-8') + try: + model = json.loads(body) + except Exception: + self.log.debug("Bad JSON: %r", body) + self.log.error("Couldn't parse JSON", exc_info=True) + raise web.HTTPError(400, u'Invalid JSON in body of request') + return model + + class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): """static files should only be accessible when logged in""" @web.authenticated def get(self, path): + if os.path.splitext(path)[1] == '.ipynb': + name = os.path.basename(path) + self.set_header('Content-Type', 'application/json') + self.set_header('Content-Disposition','attachment; filename="%s"' % name) + return web.StaticFileHandler.get(self, path) + + def validate_absolute_path(self, root, absolute_path): + """Validate and return the absolute path. + + Requires tornado 3.1 + + Adding to tornado's own handling, forbids the serving of hidden files. + """ + abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path) + abs_root = os.path.abspath(root) + self.forbid_hidden(abs_root, abs_path) + return abs_path + + def forbid_hidden(self, absolute_root, absolute_path): + """Raise 403 if a file is hidden or contained in a hidden directory. + + Hidden is determined by either name starting with '.' + or the UF_HIDDEN flag as reported by stat + """ + inside_root = absolute_path[len(absolute_root):] + if any(part.startswith('.') for part in inside_root.split(os.sep)): + raise web.HTTPError(403) + + # check UF_HIDDEN on any location up to root + path = absolute_path + while path and path.startswith(absolute_root) and path != absolute_root: + st = os.stat(path) + if getattr(st, 'st_flags', 0) & UF_HIDDEN: + raise web.HTTPError(403) + path = os.path.dirname(path) + + return absolute_path + + +def json_errors(method): + """Decorate methods with this to return GitHub style JSON errors. + + This should be used on any JSON API on any handler method that can raise HTTPErrors. + + This will grab the latest HTTPError exception using sys.exc_info + and then: + + 1. Set the HTTP status code based on the HTTPError + 2. Create and return a JSON body with a message field describing + the error in a human readable form. + """ + @functools.wraps(method) + def wrapper(self, *args, **kwargs): + try: + result = method(self, *args, **kwargs) + except web.HTTPError as e: + status = e.status_code + message = e.log_message + self.set_status(e.status_code) + self.finish(json.dumps(dict(message=message))) + except Exception: + self.log.error("Unhandled error in API request", exc_info=True) + status = 500 + message = "Unknown server error" + t, value, tb = sys.exc_info() + self.set_status(status) + tb_text = ''.join(traceback.format_exception(t, value, tb)) + reply = dict(message=message, traceback=tb_text) + self.finish(json.dumps(reply)) + else: + return result + return wrapper + #----------------------------------------------------------------------------- @@ -266,7 +367,7 @@ class FileFindHandler(web.StaticFileHandler): if isinstance(path, basestring): path = [path] self.roots = tuple( - os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path + os.path.abspath(os.path.expanduser(p)) + os.sep for p in path ) self.default_filename = default_filename @@ -284,7 +385,7 @@ class FileFindHandler(web.StaticFileHandler): # os.path.abspath strips a trailing / # it needs to be temporarily added back for requests to root/ - if not (abspath + os.path.sep).startswith(roots): + if not (abspath + os.sep).startswith(roots): raise HTTPError(403, "%s is not in root static directory", path) cls._static_paths[path] = abspath @@ -339,7 +440,7 @@ class FileFindHandler(web.StaticFileHandler): if if_since >= modified: self.set_status(304) return - + with open(abspath, "rb") as file: data = file.read() hasher = hashlib.sha1() @@ -369,7 +470,7 @@ class FileFindHandler(web.StaticFileHandler): if isinstance(static_paths, basestring): static_paths = [static_paths] roots = tuple( - os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths + os.path.abspath(os.path.expanduser(p)) + os.sep for p in static_paths ) try: @@ -403,13 +504,26 @@ class FileFindHandler(web.StaticFileHandler): ``static_url_prefix`` removed. The return value should be filesystem path relative to ``static_path``. """ - if os.path.sep != "/": - url_path = url_path.replace("/", os.path.sep) + if os.sep != "/": + url_path = url_path.replace("/", os.sep) return url_path +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('/')) + #----------------------------------------------------------------------------- # URL to handler mappings #----------------------------------------------------------------------------- -default_handlers = [] +default_handlers = [ + (r".*/", TrailingSlashHandler) +] diff --git a/IPython/html/notebook/handlers.py b/IPython/html/notebook/handlers.py index 7107a8f..f9666a1 100644 --- a/IPython/html/notebook/handlers.py +++ b/IPython/html/notebook/handlers.py @@ -17,75 +17,67 @@ Authors: #----------------------------------------------------------------------------- import os +import json + from tornado import web HTTPError = web.HTTPError from ..base.handlers import IPythonHandler -from ..utils import url_path_join +from ..services.notebooks.handlers import _notebook_path_regex, _path_regex +from ..utils import url_path_join, url_escape, url_unescape +from urllib import quote #----------------------------------------------------------------------------- # Handlers #----------------------------------------------------------------------------- -class NewHandler(IPythonHandler): - - @web.authenticated - def get(self): - notebook_id = self.notebook_manager.new_notebook() - self.redirect(url_path_join(self.base_project_url, notebook_id)) - - -class NamedNotebookHandler(IPythonHandler): +class NotebookHandler(IPythonHandler): @web.authenticated - def get(self, notebook_id): + def get(self, path='', name=None): + """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('/') nbm = self.notebook_manager - if not nbm.notebook_exists(notebook_id): - raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + if name is None: + raise web.HTTPError(500, "This shouldn't be accessible: %s" % self.request.uri) + + # a .ipynb filename was given + if not nbm.notebook_exists(name, path): + raise web.HTTPError(404, u'Notebook does not exist: %s/%s' % (path, name)) + name = url_escape(name) + path = url_escape(path) self.write(self.render_template('notebook.html', - project=self.project, - notebook_id=notebook_id, + project=self.project_dir, + notebook_path=path, + notebook_name=name, kill_kernel=False, mathjax_url=self.mathjax_url, ) ) - class NotebookRedirectHandler(IPythonHandler): - - @web.authenticated - def get(self, notebook_name): - # strip trailing .ipynb: - notebook_name = os.path.splitext(notebook_name)[0] - notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '') - if notebook_id: - url = url_path_join(self.settings.get('base_project_url', '/'), notebook_id) - return self.redirect(url) + def get(self, path=''): + nbm = self.notebook_manager + if nbm.path_exists(path): + # it's a *directory*, redirect to /tree + url = url_path_join(self.base_project_url, 'tree', path) else: - raise HTTPError(404) - - -class NotebookCopyHandler(IPythonHandler): - - @web.authenticated - def get(self, notebook_id): - notebook_id = self.notebook_manager.copy_notebook(notebook_id) - self.redirect(url_path_join(self.base_project_url, notebook_id)) - + # otherwise, redirect to /files + # TODO: This should check if it's actually a file + url = url_path_join(self.base_project_url, 'files', path) + url = url_escape(url) + self.log.debug("Redirecting %s to %s", self.request.path, url) + self.redirect(url) #----------------------------------------------------------------------------- # URL to handler mappings #----------------------------------------------------------------------------- -_notebook_id_regex = r"(?P\w+-\w+-\w+-\w+-\w+)" -_notebook_name_regex = r"(?P.+\.ipynb)" - default_handlers = [ - (r"/new", NewHandler), - (r"/%s" % _notebook_id_regex, NamedNotebookHandler), - (r"/%s" % _notebook_name_regex, NotebookRedirectHandler), - (r"/%s/copy" % _notebook_id_regex, NotebookCopyHandler), - + (r"/notebooks%s" % _notebook_path_regex, NotebookHandler), + (r"/notebooks%s" % _path_regex, NotebookRedirectHandler), ] + diff --git a/IPython/html/notebookapp.py b/IPython/html/notebookapp.py index 206473c..d6ec358 100644 --- a/IPython/html/notebookapp.py +++ b/IPython/html/notebookapp.py @@ -65,6 +65,7 @@ from .services.kernels.kernelmanager import MappingKernelManager from .services.notebooks.nbmanager import NotebookManager from .services.notebooks.filenbmanager import FileNotebookManager from .services.clusters.clustermanager import ClusterManager +from .services.sessions.sessionmanager import SessionManager from .base.handlers import AuthenticatedFileHandler, FileFindHandler @@ -127,19 +128,19 @@ def load_handlers(name): class NotebookWebApplication(web.Application): def __init__(self, ipython_app, kernel_manager, notebook_manager, - cluster_manager, log, - base_project_url, settings_overrides): + cluster_manager, session_manager, log, base_project_url, + settings_overrides): settings = self.init_settings( ipython_app, kernel_manager, notebook_manager, cluster_manager, - log, base_project_url, settings_overrides) + session_manager, log, base_project_url, settings_overrides) handlers = self.init_handlers(settings) super(NotebookWebApplication, self).__init__(handlers, **settings) def init_settings(self, ipython_app, kernel_manager, notebook_manager, - cluster_manager, log, - base_project_url, settings_overrides): + cluster_manager, session_manager, log, base_project_url, + settings_overrides): # Python < 2.6.5 doesn't accept unicode keys in f(**kwargs), and # base_project_url will always be unicode, which will in turn # make the patterns unicode, and ultimately result in unicode @@ -168,7 +169,8 @@ class NotebookWebApplication(web.Application): kernel_manager=kernel_manager, notebook_manager=notebook_manager, cluster_manager=cluster_manager, - + session_manager=session_manager, + # IPython stuff nbextensions_path = ipython_app.nbextensions_path, mathjax_url=ipython_app.mathjax_url, @@ -192,6 +194,7 @@ class NotebookWebApplication(web.Application): handlers.extend(load_handlers('services.kernels.handlers')) handlers.extend(load_handlers('services.notebooks.handlers')) handlers.extend(load_handlers('services.clusters.handlers')) + handlers.extend(load_handlers('services.sessions.handlers')) handlers.extend([ (r"/files/(.*)", AuthenticatedFileHandler, {'path' : settings['notebook_manager'].notebook_dir}), (r"/nbextensions/(.*)", FileFindHandler, {'path' : settings['nbextensions_path']}), @@ -497,13 +500,16 @@ class NotebookApp(BaseIPythonApplication): super(NotebookApp, self).parse_command_line(argv) if self.extra_args: - f = os.path.abspath(self.extra_args[0]) + arg0 = self.extra_args[0] + f = os.path.abspath(arg0) + self.argv.remove(arg0) + if not os.path.exists(f): + self.log.critical("No such file or directory: %s", f) + self.exit(1) if os.path.isdir(f): - nbdir = f - else: + self.config.FileNotebookManager.notebook_dir = f + elif os.path.isfile(f): self.file_to_run = f - nbdir = os.path.dirname(f) - self.config.NotebookManager.notebook_dir = nbdir def init_kernel_argv(self): """construct the kernel arguments""" @@ -523,7 +529,7 @@ class NotebookApp(BaseIPythonApplication): ) kls = import_item(self.notebook_manager_class) self.notebook_manager = kls(parent=self, log=self.log) - self.notebook_manager.load_notebook_names() + self.session_manager = SessionManager(parent=self, log=self.log) self.cluster_manager = ClusterManager(parent=self, log=self.log) self.cluster_manager.update_profiles() @@ -535,14 +541,17 @@ class NotebookApp(BaseIPythonApplication): # hook up tornado 3's loggers to our app handlers for name in ('access', 'application', 'general'): - logging.getLogger('tornado.%s' % name).handlers = self.log.handlers + logger = logging.getLogger('tornado.%s' % name) + logger.propagate = False + logger.setLevel(self.log.level) + logger.handlers = self.log.handlers def init_webapp(self): """initialize tornado webapp and httpserver""" self.web_app = NotebookWebApplication( - self, self.kernel_manager, self.notebook_manager, - self.cluster_manager, self.log, - self.base_project_url, self.webapp_settings, + self, self.kernel_manager, self.notebook_manager, + self.cluster_manager, self.session_manager, + self.log, self.base_project_url, self.webapp_settings ) if self.certfile: ssl_options = dict(certfile=self.certfile) @@ -726,12 +735,22 @@ class NotebookApp(BaseIPythonApplication): except webbrowser.Error as e: self.log.warn('No web browser found: %s.' % e) browser = None - - if self.file_to_run: - name, _ = os.path.splitext(os.path.basename(self.file_to_run)) - url = self.notebook_manager.rev_mapping.get(name, '') + + nbdir = os.path.abspath(self.notebook_manager.notebook_dir) + f = self.file_to_run + if f and f.startswith(nbdir): + f = f[len(nbdir):] + else: + self.log.warn( + "Probably won't be able to open notebook %s " + "because it is not in notebook_dir %s", + f, nbdir, + ) + + if os.path.isfile(self.file_to_run): + url = url_path_join('notebooks', f) else: - url = '' + url = url_path_join('tree', f) if browser: b = lambda : browser.open("%s://%s:%i%s%s" % (proto, ip, self.port, self.base_project_url, url), new=2) diff --git a/IPython/html/services/kernels/handlers.py b/IPython/html/services/kernels/handlers.py index 8f959f9..5c73876 100644 --- a/IPython/html/services/kernels/handlers.py +++ b/IPython/html/services/kernels/handlers.py @@ -22,8 +22,9 @@ from tornado import web from zmq.utils import jsonapi from IPython.utils.jsonutil import date_default +from IPython.html.utils import url_path_join, url_escape -from ...base.handlers import IPythonHandler +from ...base.handlers import IPythonHandler, json_errors from ...base.zmqhandlers import AuthenticatedZMQStreamHandler #----------------------------------------------------------------------------- @@ -34,26 +35,37 @@ from ...base.zmqhandlers import AuthenticatedZMQStreamHandler class MainKernelHandler(IPythonHandler): @web.authenticated + @json_errors def get(self): km = self.kernel_manager - self.finish(jsonapi.dumps(km.list_kernel_ids())) + self.finish(jsonapi.dumps(km.list_kernels(self.ws_url))) @web.authenticated + @json_errors def post(self): km = self.kernel_manager - nbm = self.notebook_manager - notebook_id = self.get_argument('notebook', default=None) - kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir) - data = {'ws_url':self.ws_url,'kernel_id':kernel_id} - self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) - self.finish(jsonapi.dumps(data)) + kernel_id = km.start_kernel() + model = km.kernel_model(kernel_id, self.ws_url) + location = url_path_join(self.base_kernel_url, 'api', 'kernels', kernel_id) + self.set_header('Location', url_escape(location)) + self.set_status(201) + self.finish(jsonapi.dumps(model)) class KernelHandler(IPythonHandler): - SUPPORTED_METHODS = ('DELETE') + SUPPORTED_METHODS = ('DELETE', 'GET') @web.authenticated + @json_errors + def get(self, kernel_id): + km = self.kernel_manager + km._check_kernel_id(kernel_id) + model = km.kernel_model(kernel_id, self.ws_url) + self.finish(jsonapi.dumps(model)) + + @web.authenticated + @json_errors def delete(self, kernel_id): km = self.kernel_manager km.shutdown_kernel(kernel_id) @@ -64,6 +76,7 @@ class KernelHandler(IPythonHandler): class KernelActionHandler(IPythonHandler): @web.authenticated + @json_errors def post(self, kernel_id, action): km = self.kernel_manager if action == 'interrupt': @@ -71,9 +84,9 @@ class KernelActionHandler(IPythonHandler): self.set_status(204) if action == 'restart': km.restart_kernel(kernel_id) - data = {'ws_url':self.ws_url, 'kernel_id':kernel_id} - self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) - self.write(jsonapi.dumps(data)) + model = km.kernel_model(kernel_id, self.ws_url) + self.set_header('Location', '{0}api/kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.write(jsonapi.dumps(model)) self.finish() @@ -173,10 +186,10 @@ _kernel_id_regex = r"(?P\w+-\w+-\w+-\w+-\w+)" _kernel_action_regex = r"(?Prestart|interrupt)" default_handlers = [ - (r"/kernels", MainKernelHandler), - (r"/kernels/%s" % _kernel_id_regex, KernelHandler), - (r"/kernels/%s/%s" % (_kernel_id_regex, _kernel_action_regex), KernelActionHandler), - (r"/kernels/%s/iopub" % _kernel_id_regex, IOPubHandler), - (r"/kernels/%s/shell" % _kernel_id_regex, ShellHandler), - (r"/kernels/%s/stdin" % _kernel_id_regex, StdinHandler) + (r"/api/kernels", MainKernelHandler), + (r"/api/kernels/%s" % _kernel_id_regex, KernelHandler), + (r"/api/kernels/%s/%s" % (_kernel_id_regex, _kernel_action_regex), KernelActionHandler), + (r"/api/kernels/%s/iopub" % _kernel_id_regex, IOPubHandler), + (r"/api/kernels/%s/shell" % _kernel_id_regex, ShellHandler), + (r"/api/kernels/%s/stdin" % _kernel_id_regex, StdinHandler) ] diff --git a/IPython/html/services/kernels/kernelmanager.py b/IPython/html/services/kernels/kernelmanager.py index 93ba04d..3da9399 100644 --- a/IPython/html/services/kernels/kernelmanager.py +++ b/IPython/html/services/kernels/kernelmanager.py @@ -35,56 +35,29 @@ class MappingKernelManager(MultiKernelManager): return "IPython.kernel.ioloop.IOLoopKernelManager" kernel_argv = List(Unicode) - - _notebook_mapping = Dict() #------------------------------------------------------------------------- # Methods for managing kernels and sessions #------------------------------------------------------------------------- - def kernel_for_notebook(self, notebook_id): - """Return the kernel_id for a notebook_id or None.""" - return self._notebook_mapping.get(notebook_id) - - def set_kernel_for_notebook(self, notebook_id, kernel_id): - """Associate a notebook with a kernel.""" - if notebook_id is not None: - self._notebook_mapping[notebook_id] = kernel_id - - def notebook_for_kernel(self, kernel_id): - """Return the notebook_id for a kernel_id or None.""" - for notebook_id, kid in self._notebook_mapping.iteritems(): - if kernel_id == kid: - return notebook_id - return None - - def delete_mapping_for_kernel(self, kernel_id): - """Remove the kernel/notebook mapping for kernel_id.""" - notebook_id = self.notebook_for_kernel(kernel_id) - if notebook_id is not None: - del self._notebook_mapping[notebook_id] - def _handle_kernel_died(self, kernel_id): """notice that a kernel died""" self.log.warn("Kernel %s died, removing from map.", kernel_id) - self.delete_mapping_for_kernel(kernel_id) self.remove_kernel(kernel_id) - def start_kernel(self, notebook_id=None, **kwargs): - """Start a kernel for a notebook an return its kernel_id. + def start_kernel(self, kernel_id=None, **kwargs): + """Start a kernel for a session an return its kernel_id. Parameters ---------- - notebook_id : uuid - The uuid of the notebook to associate the new kernel with. If this - is not None, this kernel will be persistent whenever the notebook - requests a kernel. + kernel_id : uuid + The uuid to associate the new kernel with. If this + is not None, this kernel will be persistent whenever it is + requested. """ - kernel_id = self.kernel_for_notebook(notebook_id) if kernel_id is None: kwargs['extra_arguments'] = self.kernel_argv kernel_id = super(MappingKernelManager, self).start_kernel(**kwargs) - self.set_kernel_for_notebook(notebook_id, kernel_id) self.log.info("Kernel started: %s" % kernel_id) self.log.debug("Kernel args: %r" % kwargs) # register callback for failed auto-restart @@ -93,18 +66,33 @@ class MappingKernelManager(MultiKernelManager): 'dead', ) else: + self._check_kernel_id(kernel_id) self.log.info("Using existing kernel: %s" % kernel_id) - return kernel_id def shutdown_kernel(self, kernel_id, now=False): """Shutdown a kernel by kernel_id""" + self._check_kernel_id(kernel_id) super(MappingKernelManager, self).shutdown_kernel(kernel_id, now=now) - self.delete_mapping_for_kernel(kernel_id) + + def kernel_model(self, kernel_id, ws_url): + """Return a dictionary of kernel information described in the + JSON standard model.""" + self._check_kernel_id(kernel_id) + model = {"id":kernel_id, "ws_url": ws_url} + return model + + def list_kernels(self, ws_url): + """Returns a list of kernel_id's of kernels running.""" + kernels = [] + kernel_ids = super(MappingKernelManager, self).list_kernel_ids() + for kernel_id in kernel_ids: + model = self.kernel_model(kernel_id, ws_url) + kernels.append(model) + return kernels # override _check_kernel_id to raise 404 instead of KeyError def _check_kernel_id(self, kernel_id): """Check a that a kernel_id exists and raise 404 if not.""" if kernel_id not in self: raise web.HTTPError(404, u'Kernel does not exist: %s' % kernel_id) - diff --git a/IPython/html/services/kernels/tests/__init__.py b/IPython/html/services/kernels/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/IPython/html/services/kernels/tests/__init__.py diff --git a/IPython/html/services/kernels/tests/test_kernels_api.py b/IPython/html/services/kernels/tests/test_kernels_api.py new file mode 100644 index 0000000..53afb74 --- /dev/null +++ b/IPython/html/services/kernels/tests/test_kernels_api.py @@ -0,0 +1,124 @@ +"""Test the kernels service API.""" + + +import os +import sys +import json + +import requests + +from IPython.html.utils import url_path_join +from IPython.html.tests.launchnotebook import NotebookTestBase, assert_http_error + +class KernelAPI(object): + """Wrapper for kernel REST API requests""" + def __init__(self, base_url): + self.base_url = base_url + + def _req(self, verb, path, body=None): + response = requests.request(verb, + url_path_join(self.base_url, 'api/kernels', path), data=body) + + if 400 <= response.status_code < 600: + try: + response.reason = response.json()['message'] + except: + pass + response.raise_for_status() + + return response + + def list(self): + return self._req('GET', '') + + def get(self, id): + return self._req('GET', id) + + def start(self): + return self._req('POST', '') + + def shutdown(self, id): + return self._req('DELETE', id) + + def interrupt(self, id): + return self._req('POST', url_path_join(id, 'interrupt')) + + def restart(self, id): + return self._req('POST', url_path_join(id, 'restart')) + +class KernelAPITest(NotebookTestBase): + """Test the kernels web service API""" + def setUp(self): + self.kern_api = KernelAPI(self.base_url()) + + def tearDown(self): + for k in self.kern_api.list().json(): + self.kern_api.shutdown(k['id']) + + def test__no_kernels(self): + """Make sure there are no kernels running at the start""" + kernels = self.kern_api.list().json() + self.assertEqual(kernels, []) + + def test_main_kernel_handler(self): + # POST request + r = self.kern_api.start() + kern1 = r.json() + self.assertEqual(r.headers['location'], '/api/kernels/' + kern1['id']) + self.assertEqual(r.status_code, 201) + self.assertIsInstance(kern1, dict) + + # GET request + r = self.kern_api.list() + self.assertEqual(r.status_code, 200) + assert isinstance(r.json(), list) + self.assertEqual(r.json()[0]['id'], kern1['id']) + + # create another kernel and check that they both are added to the + # list of kernels from a GET request + kern2 = self.kern_api.start().json() + assert isinstance(kern2, dict) + r = self.kern_api.list() + kernels = r.json() + self.assertEqual(r.status_code, 200) + assert isinstance(kernels, list) + self.assertEqual(len(kernels), 2) + + # Interrupt a kernel + r = self.kern_api.interrupt(kern2['id']) + self.assertEqual(r.status_code, 204) + + # Restart a kernel + r = self.kern_api.restart(kern2['id']) + self.assertEqual(r.headers['Location'], '/api/kernels/'+kern2['id']) + rekern = r.json() + self.assertEqual(rekern['id'], kern2['id']) + self.assertIn('ws_url', rekern) + + def test_kernel_handler(self): + # GET kernel with given id + kid = self.kern_api.start().json()['id'] + r = self.kern_api.get(kid) + kern1 = r.json() + self.assertEqual(r.status_code, 200) + assert isinstance(kern1, dict) + self.assertIn('id', kern1) + self.assertIn('ws_url', kern1) + self.assertEqual(kern1['id'], kid) + + # Request a bad kernel id and check that a JSON + # message is returned! + bad_id = '111-111-111-111-111' + with assert_http_error(404, 'Kernel does not exist: ' + bad_id): + self.kern_api.get(bad_id) + + # DELETE kernel with id + r = self.kern_api.shutdown(kid) + self.assertEqual(r.status_code, 204) + kernels = self.kern_api.list().json() + self.assertEqual(kernels, []) + + # Request to delete a non-existent kernel id + bad_id = '111-111-111-111-111' + with assert_http_error(404, 'Kernel does not exist: ' + bad_id): + self.kern_api.shutdown(bad_id) diff --git a/IPython/html/services/notebooks/filenbmanager.py b/IPython/html/services/notebooks/filenbmanager.py index ddb6835..e87a2cc 100644 --- a/IPython/html/services/notebooks/filenbmanager.py +++ b/IPython/html/services/notebooks/filenbmanager.py @@ -3,6 +3,7 @@ Authors: * Brian Granger +* Zach Sailer """ #----------------------------------------------------------------------------- @@ -16,12 +17,11 @@ Authors: # Imports #----------------------------------------------------------------------------- -import datetime import io +import itertools import os import glob import shutil -from unicodedata import normalize from tornado import web @@ -70,290 +70,340 @@ class FileNotebookManager(NotebookManager): os.mkdir(new) except: raise TraitError("Couldn't create checkpoint dir %r" % new) - - filename_ext = Unicode(u'.ipynb') - # Map notebook names to notebook_ids - rev_mapping = Dict() - - def get_notebook_names(self): - """List all notebook names in the notebook dir.""" - names = glob.glob(os.path.join(self.notebook_dir, - '*' + self.filename_ext)) - names = [normalize('NFC', os.path.splitext(os.path.basename(name))[0]) + def get_notebook_names(self, path=''): + """List all notebook names in the notebook dir and path.""" + path = path.strip('/') + if not os.path.isdir(self.get_os_path(path=path)): + raise web.HTTPError(404, 'Directory not found: ' + path) + names = glob.glob(self.get_os_path('*'+self.filename_ext, path)) + names = [os.path.basename(name) for name in names] return names - def list_notebooks(self): - """List all notebooks in the notebook dir.""" - names = self.get_notebook_names() - - data = [] - for name in names: - if name not in self.rev_mapping: - notebook_id = self.new_notebook_id(name) - else: - notebook_id = self.rev_mapping[name] - data.append(dict(notebook_id=notebook_id,name=name)) - data = sorted(data, key=lambda item: item['name']) - return data - - def new_notebook_id(self, name): - """Generate a new notebook_id for a name and store its mappings.""" - notebook_id = super(FileNotebookManager, self).new_notebook_id(name) - self.rev_mapping[name] = notebook_id - return notebook_id - - def delete_notebook_id(self, notebook_id): - """Delete a notebook's id in the mapping.""" - name = self.mapping[notebook_id] - super(FileNotebookManager, self).delete_notebook_id(notebook_id) - del self.rev_mapping[name] - - def notebook_exists(self, notebook_id): - """Does a notebook exist?""" - exists = super(FileNotebookManager, self).notebook_exists(notebook_id) - if not exists: - return False - path = self.get_path_by_name(self.mapping[notebook_id]) - return os.path.isfile(path) - - def get_name(self, notebook_id): - """get a notebook name, raising 404 if not found""" - try: - name = self.mapping[notebook_id] - except KeyError: - raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + def increment_filename(self, basename, path='', ext='.ipynb'): + """Return a non-used filename of the form basename.""" + path = path.strip('/') + for i in itertools.count(): + name = u'{basename}{i}{ext}'.format(basename=basename, i=i, ext=ext) + os_path = self.get_os_path(name, path) + if not os.path.isfile(os_path): + break return name - def get_path(self, notebook_id): - """Return a full path to a notebook given its notebook_id.""" - name = self.get_name(notebook_id) - return self.get_path_by_name(name) + def path_exists(self, path): + """Does the API-style path (directory) actually exist? + + Parameters + ---------- + path : string + The path to check. This is an API path (`/` separated, + relative to base notebook-dir). + + Returns + ------- + exists : bool + Whether the path is indeed a directory. + """ + path = path.strip('/') + os_path = self.get_os_path(path=path) + return os.path.isdir(os_path) + + def get_os_path(self, name=None, path=''): + """Given a notebook name and a URL path, return its file system + path. + + Parameters + ---------- + name : string + The name of a notebook file with the .ipynb extension + path : string + The relative URL path (with '/' as separator) to the named + notebook. - def get_path_by_name(self, name): - """Return a full path to a notebook given its name.""" - filename = name + self.filename_ext - path = os.path.join(self.notebook_dir, filename) + Returns + ------- + path : string + A file system path that combines notebook_dir (location where + server started), the relative path, and the filename with the + current operating system's url. + """ + parts = path.strip('/').split('/') + parts = [p for p in parts if p != ''] # remove duplicate splits + if name is not None: + parts.append(name) + path = os.path.join(self.notebook_dir, *parts) return path - def read_notebook_object_from_path(self, path): - """read a notebook object from a path""" - info = os.stat(path) + def notebook_exists(self, name, path=''): + """Returns a True if the notebook exists. Else, returns False. + + Parameters + ---------- + name : string + The name of the notebook you are checking. + path : string + The relative path to the notebook (with '/' as separator) + + Returns + ------- + bool + """ + path = path.strip('/') + nbpath = self.get_os_path(name, path=path) + return os.path.isfile(nbpath) + + def list_notebooks(self, path): + """Returns a list of dictionaries that are the standard model + for all notebooks in the relative 'path'. + + Parameters + ---------- + path : str + the URL path that describes the relative path for the + listed notebooks + + Returns + ------- + notebooks : list of dicts + a list of the notebook models without 'content' + """ + path = path.strip('/') + notebook_names = self.get_notebook_names(path) + notebooks = [] + for name in notebook_names: + model = self.get_notebook_model(name, path, content=False) + notebooks.append(model) + notebooks = sorted(notebooks, key=lambda item: item['name']) + return notebooks + + def get_notebook_model(self, name, path='', content=True): + """ Takes a path and name for a notebook and returns it's model + + Parameters + ---------- + name : str + the name of the notebook + path : str + the URL path that describes the relative path for + the notebook + + Returns + ------- + model : dict + the notebook model. If contents=True, returns the 'contents' + dict in the model as well. + """ + path = path.strip('/') + if not self.notebook_exists(name=name, path=path): + raise web.HTTPError(404, u'Notebook does not exist: %s' % name) + os_path = self.get_os_path(name, path) + info = os.stat(os_path) last_modified = tz.utcfromtimestamp(info.st_mtime) - with open(path,'r') as f: - s = f.read() - try: - # v1 and v2 and json in the .ipynb files. - nb = current.reads(s, u'json') - except ValueError as e: - msg = u"Unreadable Notebook: %s" % e - raise web.HTTPError(400, msg, reason=msg) - return last_modified, nb - - def read_notebook_object(self, notebook_id): - """Get the Notebook representation of a notebook by notebook_id.""" - path = self.get_path(notebook_id) - if not os.path.isfile(path): - raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) - last_modified, nb = self.read_notebook_object_from_path(path) - # Always use the filename as the notebook name. - # Eventually we will get rid of the notebook name in the metadata - # but for now, that name is just an empty string. Until the notebooks - # web service knows about names in URLs we still pass the name - # back to the web app using the metadata though. - nb.metadata.name = os.path.splitext(os.path.basename(path))[0] - return last_modified, nb - - def write_notebook_object(self, nb, notebook_id=None): - """Save an existing notebook object by notebook_id.""" - try: - new_name = normalize('NFC', nb.metadata.name) - except AttributeError: - raise web.HTTPError(400, u'Missing notebook name') + created = tz.utcfromtimestamp(info.st_ctime) + # Create the notebook model. + model ={} + model['name'] = name + model['path'] = path + model['last_modified'] = last_modified + model['created'] = created + if content is True: + with io.open(os_path, 'r', encoding='utf-8') as f: + try: + nb = current.read(f, u'json') + except Exception as e: + raise web.HTTPError(400, u"Unreadable Notebook: %s %s" % (os_path, e)) + model['content'] = nb + return model - if notebook_id is None: - notebook_id = self.new_notebook_id(new_name) + def save_notebook_model(self, model, name='', path=''): + """Save the notebook model and return the model with no content.""" + path = path.strip('/') - if notebook_id not in self.mapping: - raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + if 'content' not in model: + raise web.HTTPError(400, u'No notebook JSON data provided') - old_name = self.mapping[notebook_id] - old_checkpoints = self.list_checkpoints(notebook_id) - path = self.get_path_by_name(new_name) + new_path = model.get('path', path).strip('/') + new_name = model.get('name', name) - # Right before we save the notebook, we write an empty string as the - # notebook name in the metadata. This is to prepare for removing - # this attribute entirely post 1.0. The web app still uses the metadata - # name for now. - nb.metadata.name = u'' + if path != new_path or name != new_name: + self.rename_notebook(name, path, new_name, new_path) + # Save the notebook file + os_path = self.get_os_path(new_name, new_path) + nb = current.to_notebook_json(model['content']) + if 'name' in nb['metadata']: + nb['metadata']['name'] = u'' try: - self.log.debug("Autosaving notebook %s", path) - with open(path,'w') as f: + self.log.debug("Autosaving notebook %s", os_path) + with io.open(os_path, 'w', encoding='utf-8') as f: current.write(nb, f, u'json') except Exception as e: - raise web.HTTPError(400, u'Unexpected error while autosaving notebook: %s' % e) + raise web.HTTPError(400, u'Unexpected error while autosaving notebook: %s %s' % (os_path, e)) - # save .py script as well + # Save .py script as well if self.save_script: - pypath = os.path.splitext(path)[0] + '.py' - self.log.debug("Writing script %s", pypath) + py_path = os.path.splitext(os_path)[0] + '.py' + self.log.debug("Writing script %s", py_path) try: - with io.open(pypath,'w', encoding='utf-8') as f: - current.write(nb, f, u'py') + with io.open(py_path, 'w', encoding='utf-8') as f: + current.write(model, f, u'py') except Exception as e: - raise web.HTTPError(400, u'Unexpected error while saving notebook as script: %s' % e) - - # remove old files if the name changed - if old_name != new_name: - # update mapping - self.mapping[notebook_id] = new_name - self.rev_mapping[new_name] = notebook_id - del self.rev_mapping[old_name] - - # remove renamed original, if it exists - old_path = self.get_path_by_name(old_name) - if os.path.isfile(old_path): - self.log.debug("unlinking notebook %s", old_path) - os.unlink(old_path) - - # cleanup old script, if it exists - if self.save_script: - old_pypath = os.path.splitext(old_path)[0] + '.py' - if os.path.isfile(old_pypath): - self.log.debug("unlinking script %s", old_pypath) - os.unlink(old_pypath) - - # rename checkpoints to follow file - for cp in old_checkpoints: - checkpoint_id = cp['checkpoint_id'] - old_cp_path = self.get_checkpoint_path_by_name(old_name, checkpoint_id) - new_cp_path = self.get_checkpoint_path_by_name(new_name, checkpoint_id) - if os.path.isfile(old_cp_path): - self.log.debug("renaming checkpoint %s -> %s", old_cp_path, new_cp_path) - os.rename(old_cp_path, new_cp_path) - - return notebook_id + raise web.HTTPError(400, u'Unexpected error while saving notebook as script: %s %s' % (py_path, e)) + + model = self.get_notebook_model(new_name, new_path, content=False) + return model - def delete_notebook(self, notebook_id): - """Delete notebook by notebook_id.""" - nb_path = self.get_path(notebook_id) - if not os.path.isfile(nb_path): - raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + def update_notebook_model(self, model, name, path=''): + """Update the notebook's path and/or name""" + 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_notebook(name, path, new_name, new_path) + model = self.get_notebook_model(new_name, new_path, content=False) + return model + + def delete_notebook_model(self, name, path=''): + """Delete notebook by name and path.""" + path = path.strip('/') + os_path = self.get_os_path(name, path) + if not os.path.isfile(os_path): + raise web.HTTPError(404, u'Notebook does not exist: %s' % os_path) # clear checkpoints - for checkpoint in self.list_checkpoints(notebook_id): - checkpoint_id = checkpoint['checkpoint_id'] - path = self.get_checkpoint_path(notebook_id, checkpoint_id) - self.log.debug(path) - if os.path.isfile(path): - self.log.debug("unlinking checkpoint %s", path) - os.unlink(path) + for checkpoint in self.list_checkpoints(name, path): + checkpoint_id = checkpoint['id'] + cp_path = self.get_checkpoint_path(checkpoint_id, name, path) + if os.path.isfile(cp_path): + self.log.debug("Unlinking checkpoint %s", cp_path) + os.unlink(cp_path) - self.log.debug("unlinking notebook %s", nb_path) - os.unlink(nb_path) - self.delete_notebook_id(notebook_id) + self.log.debug("Unlinking notebook %s", os_path) + os.unlink(os_path) - def increment_filename(self, basename): - """Return a non-used filename of the form basename. + def rename_notebook(self, old_name, old_path, new_name, new_path): + """Rename a notebook.""" + old_path = old_path.strip('/') + new_path = new_path.strip('/') + if new_name == old_name and new_path == old_path: + return - This searches through the filenames (basename0, basename1, ...) - until is find one that is not already being used. It is used to - create Untitled and Copy names that are unique. - """ - i = 0 - while True: - name = u'%s%i' % (basename,i) - path = self.get_path_by_name(name) - if not os.path.isfile(path): - break - else: - i = i+1 - return name - + new_os_path = self.get_os_path(new_name, new_path) + old_os_path = self.get_os_path(old_name, old_path) + + # Should we proceed with the move? + if os.path.isfile(new_os_path): + raise web.HTTPError(409, u'Notebook with name already exists: %s' % new_os_path) + if self.save_script: + old_py_path = os.path.splitext(old_os_path)[0] + '.py' + new_py_path = os.path.splitext(new_os_path)[0] + '.py' + if os.path.isfile(new_py_path): + raise web.HTTPError(409, u'Python script with name already exists: %s' % new_py_path) + + # Move the notebook file + try: + os.rename(old_os_path, new_os_path) + except Exception as e: + raise web.HTTPError(500, u'Unknown error renaming notebook: %s %s' % (old_os_path, e)) + + # Move the checkpoints + old_checkpoints = self.list_checkpoints(old_name, 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) + if os.path.isfile(old_cp_path): + self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path) + os.rename(old_cp_path, new_cp_path) + + # Move the .py script + if self.save_script: + os.rename(old_py_path, new_py_path) + # Checkpoint-related utilities - def get_checkpoint_path_by_name(self, name, checkpoint_id): - """Return a full path to a notebook checkpoint, given its name and checkpoint id.""" + def get_checkpoint_path(self, checkpoint_id, name, path=''): + """find the path to a checkpoint""" + path = path.strip('/') filename = u"{name}-{checkpoint_id}{ext}".format( name=name, checkpoint_id=checkpoint_id, ext=self.filename_ext, ) - path = os.path.join(self.checkpoint_dir, filename) - return path - - def get_checkpoint_path(self, notebook_id, checkpoint_id): - """find the path to a checkpoint""" - name = self.get_name(notebook_id) - return self.get_checkpoint_path_by_name(name, checkpoint_id) - - def get_checkpoint_info(self, notebook_id, checkpoint_id): + cp_path = os.path.join(path, self.checkpoint_dir, filename) + return cp_path + + def get_checkpoint_model(self, checkpoint_id, name, path=''): """construct the info dict for a given checkpoint""" - path = self.get_checkpoint_path(notebook_id, checkpoint_id) - stats = os.stat(path) + path = path.strip('/') + cp_path = self.get_checkpoint_path(checkpoint_id, name, path) + stats = os.stat(cp_path) last_modified = tz.utcfromtimestamp(stats.st_mtime) info = dict( - checkpoint_id = checkpoint_id, + id = checkpoint_id, last_modified = last_modified, ) - return info # public checkpoint API - def create_checkpoint(self, notebook_id): + def create_checkpoint(self, name, path=''): """Create a checkpoint from the current state of a notebook""" - nb_path = self.get_path(notebook_id) + path = path.strip('/') + nb_path = self.get_os_path(name, path) # only the one checkpoint ID: checkpoint_id = u"checkpoint" - cp_path = self.get_checkpoint_path(notebook_id, checkpoint_id) - self.log.debug("creating checkpoint for notebook %s", notebook_id) + cp_path = self.get_checkpoint_path(checkpoint_id, name, path) + self.log.debug("creating checkpoint for notebook %s", name) if not os.path.exists(self.checkpoint_dir): os.mkdir(self.checkpoint_dir) shutil.copy2(nb_path, cp_path) # return the checkpoint info - return self.get_checkpoint_info(notebook_id, checkpoint_id) + return self.get_checkpoint_model(checkpoint_id, name, path) - def list_checkpoints(self, notebook_id): + def list_checkpoints(self, name, path=''): """list the checkpoints for a given notebook This notebook manager currently only supports one checkpoint per notebook. """ - checkpoint_id = u"checkpoint" - path = self.get_checkpoint_path(notebook_id, checkpoint_id) + path = path.strip('/') + checkpoint_id = "checkpoint" + path = self.get_checkpoint_path(checkpoint_id, name, path) if not os.path.exists(path): return [] else: - return [self.get_checkpoint_info(notebook_id, checkpoint_id)] + return [self.get_checkpoint_model(checkpoint_id, name, path)] - def restore_checkpoint(self, notebook_id, checkpoint_id): + def restore_checkpoint(self, checkpoint_id, name, path=''): """restore a notebook to a checkpointed state""" - self.log.info("restoring Notebook %s from checkpoint %s", notebook_id, checkpoint_id) - nb_path = self.get_path(notebook_id) - cp_path = self.get_checkpoint_path(notebook_id, checkpoint_id) + path = path.strip('/') + self.log.info("restoring Notebook %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) if not os.path.isfile(cp_path): self.log.debug("checkpoint file does not exist: %s", cp_path) raise web.HTTPError(404, - u'Notebook checkpoint does not exist: %s-%s' % (notebook_id, checkpoint_id) + u'Notebook checkpoint does not exist: %s-%s' % (name, checkpoint_id) ) # ensure notebook is readable (never restore from an unreadable notebook) - last_modified, nb = self.read_notebook_object_from_path(cp_path) + with io.open(cp_path, 'r', encoding='utf-8') as f: + nb = current.read(f, u'json') shutil.copy2(cp_path, nb_path) self.log.debug("copying %s -> %s", cp_path, nb_path) - def delete_checkpoint(self, notebook_id, checkpoint_id): + def delete_checkpoint(self, checkpoint_id, name, path=''): """delete a notebook's checkpoint""" - path = self.get_checkpoint_path(notebook_id, checkpoint_id) - if not os.path.isfile(path): + path = path.strip('/') + cp_path = self.get_checkpoint_path(checkpoint_id, name, path) + if not os.path.isfile(cp_path): raise web.HTTPError(404, - u'Notebook checkpoint does not exist: %s-%s' % (notebook_id, checkpoint_id) + u'Notebook checkpoint does not exist: %s%s-%s' % (path, name, checkpoint_id) ) - self.log.debug("unlinking %s", path) - os.unlink(path) + self.log.debug("unlinking %s", cp_path) + os.unlink(cp_path) def info_string(self): return "Serving notebooks from local directory: %s" % self.notebook_dir diff --git a/IPython/html/services/notebooks/handlers.py b/IPython/html/services/notebooks/handlers.py index 30d5cad..e52e559 100644 --- a/IPython/html/services/notebooks/handlers.py +++ b/IPython/html/services/notebooks/handlers.py @@ -6,7 +6,7 @@ Authors: """ #----------------------------------------------------------------------------- -# Copyright (C) 2008-2011 The IPython Development Team +# Copyright (C) 2011 The IPython Development Team # # Distributed under the terms of the BSD License. The full license is in # the file COPYING, distributed as part of this software. @@ -16,74 +16,193 @@ Authors: # Imports #----------------------------------------------------------------------------- -from tornado import web +import json -from zmq.utils import jsonapi +from tornado import web +from IPython.html.utils import url_path_join, url_escape from IPython.utils.jsonutil import date_default -from ...base.handlers import IPythonHandler +from IPython.html.base.handlers import IPythonHandler, json_errors #----------------------------------------------------------------------------- # Notebook web service handlers #----------------------------------------------------------------------------- -class NotebookRootHandler(IPythonHandler): - @web.authenticated - def get(self): - nbm = self.notebook_manager - km = self.kernel_manager - files = nbm.list_notebooks() - for f in files : - f['kernel_id'] = km.kernel_for_notebook(f['notebook_id']) - self.finish(jsonapi.dumps(files)) +class NotebookHandler(IPythonHandler): - @web.authenticated - def post(self): - nbm = self.notebook_manager - body = self.request.body.strip() - format = self.get_argument('format', default='json') - name = self.get_argument('name', default=None) - if body: - notebook_id = nbm.save_new_notebook(body, name=name, format=format) - else: - notebook_id = nbm.new_notebook() - self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id)) - self.finish(jsonapi.dumps(notebook_id)) + SUPPORTED_METHODS = (u'GET', u'PUT', u'PATCH', u'POST', u'DELETE') + def notebook_location(self, name, path=''): + """Return the full URL location of a notebook based. + + Parameters + ---------- + name : unicode + The base name of the notebook, such as "foo.ipynb". + path : unicode + The URL path of the notebook. + """ + return url_escape(url_path_join( + self.base_project_url, 'api', 'notebooks', path, name + )) -class NotebookHandler(IPythonHandler): + def _finish_model(self, model, location=True): + """Finish a JSON request with a model, setting relevant headers, etc.""" + if location: + location = self.notebook_location(model['name'], 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): + """Return a Notebook or list of notebooks. - SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE') + * GET with path and no notebook name lists notebooks in a directory + * GET with path and notebook name returns notebook JSON + """ + nbm = self.notebook_manager + # Check to see if a notebook name was given + if name is None: + # List notebooks in 'path' + notebooks = nbm.list_notebooks(path) + self.finish(json.dumps(notebooks, default=date_default)) + return + # get and return notebook representation + model = nbm.get_notebook_model(name, path) + self._finish_model(model, location=False) @web.authenticated - def get(self, notebook_id): + @json_errors + def patch(self, path='', name=None): + """PATCH renames a notebook without re-uploading content.""" nbm = self.notebook_manager - format = self.get_argument('format', default='json') - last_mod, name, data = nbm.get_notebook(notebook_id, format) + if name is None: + raise web.HTTPError(400, u'Notebook name missing') + model = self.get_json_body() + if model is None: + raise web.HTTPError(400, u'JSON body missing') + model = nbm.update_notebook_model(model, name, path) + self._finish_model(model) + + def _copy_notebook(self, copy_from, path, copy_to=None): + """Copy a notebook in path, optionally specifying the new name. - if format == u'json': - self.set_header('Content-Type', 'application/json') - self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name) - elif format == u'py': - self.set_header('Content-Type', 'application/x-python') - self.set_header('Content-Disposition','attachment; filename="%s.py"' % name) - self.set_header('Last-Modified', last_mod) - self.finish(data) + Only support copying within the same directory. + """ + self.log.info(u"Copying notebook from %s/%s to %s/%s", + path, copy_from, + path, copy_to or '', + ) + model = self.notebook_manager.copy_notebook(copy_from, copy_to, path) + self.set_status(201) + self._finish_model(model) + + def _upload_notebook(self, model, path, name=None): + """Upload a notebook + + If name specified, create it in path/name. + """ + self.log.info(u"Uploading notebook to %s/%s", path, name or '') + if name: + model['name'] = name + + model = self.notebook_manager.create_notebook_model(model, path) + self.set_status(201) + self._finish_model(model) + + def _create_empty_notebook(self, path, name=None): + """Create an empty notebook in path + + If name specified, create it in path/name. + """ + self.log.info(u"Creating new notebook in %s/%s", path, name or '') + model = {} + if name: + model['name'] = name + model = self.notebook_manager.create_notebook_model(model, path=path) + self.set_status(201) + self._finish_model(model) + + def _save_notebook(self, model, path, name): + """Save an existing notebook.""" + self.log.info(u"Saving notebook at %s/%s", path, name) + model = self.notebook_manager.save_notebook_model(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) + + @web.authenticated + @json_errors + def post(self, path='', name=None): + """Create a new notebook in the specified path. + + POST creates new notebooks. The server always decides on the notebook name. + + POST /api/notebooks/path : new untitled notebook in path + If content specified, upload a notebook, otherwise start empty. + POST /api/notebooks/path?copy=OtherNotebook.ipynb : new copy of OtherNotebook in path + """ + + if name is not None: + raise web.HTTPError(400, "Only POST to directories. Use PUT for full names.") + + model = self.get_json_body() + + if model is not None: + 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_notebook(copy_from, path) + else: + self._upload_notebook(model, path) + else: + self._create_empty_notebook(path) @web.authenticated - def put(self, notebook_id): - nbm = self.notebook_manager - format = self.get_argument('format', default='json') - name = self.get_argument('name', default=None) - nbm.save_notebook(notebook_id, self.request.body, name=name, format=format) - self.set_status(204) - self.finish() + @json_errors + def put(self, path='', name=None): + """Saves the notebook in the location specified by name and path. + + PUT /api/notebooks/path/Name.ipynb : 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/notebooks/path/Name.ipynb?copy=OtherNotebook.ipynb : copy OtherNotebook to Name + + POST and PUT are basically the same. The only difference: + + - with POST, server always picks the name, with PUT the requester does + """ + if name is None: + raise web.HTTPError(400, "Only PUT to full names. Use POST for directories.") + + 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_notebook(copy_from, path, name) + elif self.notebook_manager.notebook_exists(name, path): + self._save_notebook(model, path, name) + else: + self._upload_notebook(model, path, name) + else: + self._create_empty_notebook(path, name) @web.authenticated - def delete(self, notebook_id): - self.notebook_manager.delete_notebook(notebook_id) + @json_errors + def delete(self, path='', name=None): + """delete the notebook in the given notebook path""" + nbm = self.notebook_manager + nbm.delete_notebook_model(name, path) self.set_status(204) self.finish() @@ -93,23 +212,25 @@ class NotebookCheckpointsHandler(IPythonHandler): SUPPORTED_METHODS = ('GET', 'POST') @web.authenticated - def get(self, notebook_id): + @json_errors + def get(self, path='', name=None): """get lists checkpoints for a notebook""" nbm = self.notebook_manager - checkpoints = nbm.list_checkpoints(notebook_id) - data = jsonapi.dumps(checkpoints, default=date_default) + checkpoints = nbm.list_checkpoints(name, path) + data = json.dumps(checkpoints, default=date_default) self.finish(data) @web.authenticated - def post(self, notebook_id): + @json_errors + def post(self, path='', name=None): """post creates a new checkpoint""" nbm = self.notebook_manager - checkpoint = nbm.create_checkpoint(notebook_id) - data = jsonapi.dumps(checkpoint, default=date_default) - self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format( - self.base_project_url, notebook_id, checkpoint['checkpoint_id'] - )) - + checkpoint = nbm.create_checkpoint(name, path) + data = json.dumps(checkpoint, default=date_default) + location = url_path_join(self.base_project_url, 'api/notebooks', + path, name, 'checkpoints', checkpoint['id']) + self.set_header('Location', url_escape(location)) + self.set_status(201) self.finish(data) @@ -118,39 +239,40 @@ class ModifyNotebookCheckpointsHandler(IPythonHandler): SUPPORTED_METHODS = ('POST', 'DELETE') @web.authenticated - def post(self, notebook_id, checkpoint_id): + @json_errors + def post(self, path, name, checkpoint_id): """post restores a notebook from a checkpoint""" nbm = self.notebook_manager - nbm.restore_checkpoint(notebook_id, checkpoint_id) + nbm.restore_checkpoint(checkpoint_id, name, path) self.set_status(204) self.finish() @web.authenticated - def delete(self, notebook_id, checkpoint_id): + @json_errors + def delete(self, path, name, checkpoint_id): """delete clears a checkpoint for a given notebook""" nbm = self.notebook_manager - nbm.delte_checkpoint(notebook_id, checkpoint_id) + nbm.delete_checkpoint(checkpoint_id, name, path) self.set_status(204) self.finish() - - + #----------------------------------------------------------------------------- # URL to handler mappings #----------------------------------------------------------------------------- -_notebook_id_regex = r"(?P\w+-\w+-\w+-\w+-\w+)" +_path_regex = r"(?P(?:/.*)*)" _checkpoint_id_regex = r"(?P[\w-]+)" +_notebook_name_regex = r"(?P[^/]+\.ipynb)" +_notebook_path_regex = "%s/%s" % (_path_regex, _notebook_name_regex) default_handlers = [ - (r"/notebooks", NotebookRootHandler), - (r"/notebooks/%s" % _notebook_id_regex, NotebookHandler), - (r"/notebooks/%s/checkpoints" % _notebook_id_regex, NotebookCheckpointsHandler), - (r"/notebooks/%s/checkpoints/%s" % (_notebook_id_regex, _checkpoint_id_regex), - ModifyNotebookCheckpointsHandler - ), + (r"/api/notebooks%s/checkpoints" % _notebook_path_regex, NotebookCheckpointsHandler), + (r"/api/notebooks%s/checkpoints/%s" % (_notebook_path_regex, _checkpoint_id_regex), + ModifyNotebookCheckpointsHandler), + (r"/api/notebooks%s" % _notebook_path_regex, NotebookHandler), + (r"/api/notebooks%s" % _path_regex, NotebookHandler), ] - diff --git a/IPython/html/services/notebooks/nbmanager.py b/IPython/html/services/notebooks/nbmanager.py index 0a9321f..568c6a5 100644 --- a/IPython/html/services/notebooks/nbmanager.py +++ b/IPython/html/services/notebooks/nbmanager.py @@ -3,6 +3,7 @@ Authors: * Brian Granger +* Zach Sailer """ #----------------------------------------------------------------------------- @@ -17,9 +18,6 @@ Authors: #----------------------------------------------------------------------------- import os -import uuid - -from tornado import web from IPython.config.configurable import LoggingConfigurable from IPython.nbformat import current @@ -38,14 +36,33 @@ class NotebookManager(LoggingConfigurable): # Right now we use this attribute in a number of different places and # we are going to have to disentangle all of this. notebook_dir = Unicode(os.getcwdu(), config=True, help=""" - The directory to use for notebooks. - """) + The directory to use for notebooks. + """) + + filename_ext = Unicode(u'.ipynb') + + def path_exists(self, path): + """Does the API-style path (directory) actually exist? + + Override this method in subclasses. + + Parameters + ---------- + path : string + The + + Returns + ------- + exists : bool + Whether the path does indeed exist. + """ + raise NotImplementedError + def _notebook_dir_changed(self, name, old, new): - """do a bit of validation of the notebook dir""" + """Do a bit of validation of the notebook dir.""" if not os.path.isabs(new): # If we receive a non-absolute path, make it absolute. - abs_new = os.path.abspath(new) - self.notebook_dir = abs_new + self.notebook_dir = os.path.abspath(new) return if os.path.exists(new) and not os.path.isdir(new): raise TraitError("notebook dir %r is not a directory" % new) @@ -56,22 +73,22 @@ class NotebookManager(LoggingConfigurable): except: raise TraitError("Couldn't create notebook dir %r" % new) - allowed_formats = List([u'json',u'py']) - - # Map notebook_ids to notebook names - mapping = Dict() - - def load_notebook_names(self): - """Load the notebook names into memory. + # Main notebook API - This should be called once immediately after the notebook manager - is created to load the existing notebooks into the mapping in - memory. + def increment_filename(self, basename, path=''): + """Increment a notebook filename without the .ipynb to make it unique. + + Parameters + ---------- + basename : unicode + The name of a notebook without the ``.ipynb`` file extension. + path : unicode + The URL path of the notebooks directory """ - self.list_notebooks() + return basename - def list_notebooks(self): - """List all notebooks. + def list_notebooks(self, path=''): + """Return a list of notebook dicts without content. This returns a list of dicts, each of the form:: @@ -83,147 +100,69 @@ class NotebookManager(LoggingConfigurable): """ raise NotImplementedError('must be implemented in a subclass') - - def new_notebook_id(self, name): - """Generate a new notebook_id for a name and store its mapping.""" - # TODO: the following will give stable urls for notebooks, but unless - # the notebooks are immediately redirected to their new urls when their - # filemname changes, nasty inconsistencies result. So for now it's - # disabled and instead we use a random uuid4() call. But we leave the - # logic here so that we can later reactivate it, whhen the necessary - # url redirection code is written. - #notebook_id = unicode(uuid.uuid5(uuid.NAMESPACE_URL, - # 'file://'+self.get_path_by_name(name).encode('utf-8'))) - - notebook_id = unicode(uuid.uuid4()) - self.mapping[notebook_id] = name - return notebook_id - - def delete_notebook_id(self, notebook_id): - """Delete a notebook's id in the mapping. - - This doesn't delete the actual notebook, only its entry in the mapping. - """ - del self.mapping[notebook_id] - - def notebook_exists(self, notebook_id): - """Does a notebook exist?""" - return notebook_id in self.mapping - - def get_notebook(self, notebook_id, format=u'json'): - """Get the representation of a notebook in format by notebook_id.""" - format = unicode(format) - if format not in self.allowed_formats: - raise web.HTTPError(415, u'Invalid notebook format: %s' % format) - last_modified, nb = self.read_notebook_object(notebook_id) - kwargs = {} - if format == 'json': - # don't split lines for sending over the wire, because it - # should match the Python in-memory format. - kwargs['split_lines'] = False - data = current.writes(nb, format, **kwargs) - name = nb.metadata.get('name','notebook') - return last_modified, name, data - - def read_notebook_object(self, notebook_id): - """Get the object representation of a notebook by notebook_id.""" + def get_notebook_model(self, name, path='', content=True): + """Get the notebook model with or without content.""" raise NotImplementedError('must be implemented in a subclass') - def save_new_notebook(self, data, name=None, format=u'json'): - """Save a new notebook and return its notebook_id. - - If a name is passed in, it overrides any values in the notebook data - and the value in the data is updated to use that value. - """ - if format not in self.allowed_formats: - raise web.HTTPError(415, u'Invalid notebook format: %s' % format) - - try: - nb = current.reads(data.decode('utf-8'), format) - except: - raise web.HTTPError(400, u'Invalid JSON data') - - if name is None: - try: - name = nb.metadata.name - except AttributeError: - raise web.HTTPError(400, u'Missing notebook name') - nb.metadata.name = name - - notebook_id = self.write_notebook_object(nb) - return notebook_id - - def save_notebook(self, notebook_id, data, name=None, format=u'json'): - """Save an existing notebook by notebook_id.""" - if format not in self.allowed_formats: - raise web.HTTPError(415, u'Invalid notebook format: %s' % format) - - try: - nb = current.reads(data.decode('utf-8'), format) - except: - raise web.HTTPError(400, u'Invalid JSON data') - - if name is not None: - nb.metadata.name = name - self.write_notebook_object(nb, notebook_id) - - def write_notebook_object(self, nb, notebook_id=None): - """Write a notebook object and return its notebook_id. - - If notebook_id is None, this method should create a new notebook_id. - If notebook_id is not None, this method should check to make sure it - exists and is valid. - """ + def save_notebook_model(self, model, name, path=''): + """Save the notebook model and return the model with no content.""" raise NotImplementedError('must be implemented in a subclass') - def delete_notebook(self, notebook_id): - """Delete notebook by notebook_id.""" + def update_notebook_model(self, model, name, path=''): + """Update the notebook model and return the model with no content.""" raise NotImplementedError('must be implemented in a subclass') - def increment_filename(self, name): - """Increment a filename to make it unique. + def delete_notebook_model(self, name, path=''): + """Delete notebook by name and path.""" + raise NotImplementedError('must be implemented in a subclass') - This exists for notebook stores that must have unique names. When a notebook - is created or copied this method constructs a unique filename, typically - by appending an integer to the name. + def create_notebook_model(self, model=None, path=''): + """Create a new untitled notebook and return its model with no content.""" + path = path.strip('/') + if model is None: + model = {} + if 'content' not in model: + metadata = current.new_metadata(name=u'') + model['content'] = current.new_notebook(metadata=metadata) + if 'name' not in model: + model['name'] = self.increment_filename('Untitled', path) + + model['path'] = path + model = self.save_notebook_model(model, model['name'], model['path']) + return model + + def copy_notebook(self, from_name, to_name=None, path=''): + """Copy an existing notebook and return its new model. + + If to_name not specified, increment `from_name-Copy#.ipynb`. """ - return name - - def new_notebook(self): - """Create a new notebook and return its notebook_id.""" - name = self.increment_filename('Untitled') - metadata = current.new_metadata(name=name) - nb = current.new_notebook(metadata=metadata) - notebook_id = self.write_notebook_object(nb) - return notebook_id - - def copy_notebook(self, notebook_id): - """Copy an existing notebook and return its notebook_id.""" - last_mod, nb = self.read_notebook_object(notebook_id) - name = nb.metadata.name + '-Copy' - name = self.increment_filename(name) - nb.metadata.name = name - notebook_id = self.write_notebook_object(nb) - return notebook_id + path = path.strip('/') + model = self.get_notebook_model(from_name, path) + if not to_name: + base = os.path.splitext(from_name)[0] + '-Copy' + to_name = self.increment_filename(base, path) + model['name'] = to_name + model = self.save_notebook_model(model, to_name, path) + return model # Checkpoint-related - def create_checkpoint(self, notebook_id): + def create_checkpoint(self, name, path=''): """Create a checkpoint of the current state of a notebook Returns a checkpoint_id for the new checkpoint. """ raise NotImplementedError("must be implemented in a subclass") - def list_checkpoints(self, notebook_id): + def list_checkpoints(self, name, path=''): """Return a list of checkpoints for a given notebook""" return [] - def restore_checkpoint(self, notebook_id, checkpoint_id): + def restore_checkpoint(self, checkpoint_id, name, path=''): """Restore a notebook from one of its checkpoints""" raise NotImplementedError("must be implemented in a subclass") - def delete_checkpoint(self, notebook_id, checkpoint_id): + def delete_checkpoint(self, checkpoint_id, name, path=''): """delete a checkpoint for a notebook""" raise NotImplementedError("must be implemented in a subclass") @@ -232,4 +171,3 @@ class NotebookManager(LoggingConfigurable): def info_string(self): return "Serving notebooks" - diff --git a/IPython/html/services/notebooks/tests/test_nbmanager.py b/IPython/html/services/notebooks/tests/test_nbmanager.py index 54aa0e9..2aeb276 100644 --- a/IPython/html/services/notebooks/tests/test_nbmanager.py +++ b/IPython/html/services/notebooks/tests/test_nbmanager.py @@ -1,26 +1,31 @@ +# coding: utf-8 """Tests for the notebook manager.""" import os + +from tornado.web import HTTPError from unittest import TestCase from tempfile import NamedTemporaryFile from IPython.utils.tempdir import TemporaryDirectory from IPython.utils.traitlets import TraitError +from IPython.html.utils import url_path_join from ..filenbmanager import FileNotebookManager +from ..nbmanager import NotebookManager -class TestNotebookManager(TestCase): +class TestFileNotebookManager(TestCase): def test_nb_dir(self): with TemporaryDirectory() as td: - km = FileNotebookManager(notebook_dir=td) - self.assertEqual(km.notebook_dir, td) + fm = FileNotebookManager(notebook_dir=td) + self.assertEqual(fm.notebook_dir, td) def test_create_nb_dir(self): with TemporaryDirectory() as td: nbdir = os.path.join(td, 'notebooks') - km = FileNotebookManager(notebook_dir=nbdir) - self.assertEqual(km.notebook_dir, nbdir) + fm = FileNotebookManager(notebook_dir=nbdir) + self.assertEqual(fm.notebook_dir, nbdir) def test_missing_nb_dir(self): with TemporaryDirectory() as td: @@ -31,4 +36,195 @@ class TestNotebookManager(TestCase): with NamedTemporaryFile() as tf: self.assertRaises(TraitError, FileNotebookManager, notebook_dir=tf.name) + def test_get_os_path(self): + # full filesystem path should be returned with correct operating system + # separators. + with TemporaryDirectory() as td: + nbdir = os.path.join(td, 'notebooks') + fm = FileNotebookManager(notebook_dir=nbdir) + path = fm.get_os_path('test.ipynb', '/path/to/notebook/') + rel_path_list = '/path/to/notebook/test.ipynb'.split('/') + fs_path = os.path.join(fm.notebook_dir, *rel_path_list) + self.assertEqual(path, fs_path) + + fm = FileNotebookManager(notebook_dir=nbdir) + path = fm.get_os_path('test.ipynb') + fs_path = os.path.join(fm.notebook_dir, 'test.ipynb') + self.assertEqual(path, fs_path) + + fm = FileNotebookManager(notebook_dir=nbdir) + path = fm.get_os_path('test.ipynb', '////') + fs_path = os.path.join(fm.notebook_dir, 'test.ipynb') + self.assertEqual(path, fs_path) + +class TestNotebookManager(TestCase): + + def make_dir(self, abs_path, rel_path): + """make subdirectory, rel_path is the relative path + to that directory from the location where the server started""" + os_path = os.path.join(abs_path, rel_path) + try: + os.makedirs(os_path) + except OSError: + print "Directory already exists." + + def test_create_notebook_model(self): + with TemporaryDirectory() as td: + # Test in root directory + nm = FileNotebookManager(notebook_dir=td) + model = nm.create_notebook_model() + assert isinstance(model, dict) + self.assertIn('name', model) + self.assertIn('path', model) + self.assertEqual(model['name'], 'Untitled0.ipynb') + self.assertEqual(model['path'], '') + + # Test in sub-directory + sub_dir = '/foo/' + self.make_dir(nm.notebook_dir, 'foo') + model = nm.create_notebook_model(None, sub_dir) + 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('/')) + + def test_get_notebook_model(self): + with TemporaryDirectory() as td: + # Test in root directory + # Create a notebook + nm = FileNotebookManager(notebook_dir=td) + model = nm.create_notebook_model() + name = model['name'] + path = model['path'] + + # Check that we 'get' on the notebook we just created + model2 = nm.get_notebook_model(name, path) + assert isinstance(model2, dict) + self.assertIn('name', model2) + self.assertIn('path', model2) + self.assertEqual(model['name'], name) + self.assertEqual(model['path'], path) + # Test in sub-directory + sub_dir = '/foo/' + self.make_dir(nm.notebook_dir, 'foo') + model = nm.create_notebook_model(None, sub_dir) + model2 = nm.get_notebook_model(name, sub_dir) + 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('/')) + + def test_update_notebook_model(self): + with TemporaryDirectory() as td: + # Test in root directory + # Create a notebook + nm = FileNotebookManager(notebook_dir=td) + model = nm.create_notebook_model() + name = model['name'] + path = model['path'] + + # Change the name in the model for rename + model['name'] = 'test.ipynb' + model = nm.update_notebook_model(model, name, 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, nm.get_notebook_model, name, path) + + # Test in sub-directory + # Create a directory and notebook in that directory + sub_dir = '/foo/' + self.make_dir(nm.notebook_dir, 'foo') + model = nm.create_notebook_model(None, sub_dir) + name = model['name'] + path = model['path'] + + # Change the name in the model for rename + model['name'] = 'test_in_sub.ipynb' + model = nm.update_notebook_model(model, name, 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('/')) + + # Make sure the old name is gone + self.assertRaises(HTTPError, nm.get_notebook_model, name, path) + + def test_save_notebook_model(self): + with TemporaryDirectory() as td: + # Test in the root directory + # Create a notebook + nm = FileNotebookManager(notebook_dir=td) + model = nm.create_notebook_model() + name = model['name'] + path = model['path'] + + # Get the model with 'content' + full_model = nm.get_notebook_model(name, path) + + # Save the notebook + model = nm.save_notebook_model(full_model, name, path) + assert isinstance(model, dict) + self.assertIn('name', model) + self.assertIn('path', model) + self.assertEqual(model['name'], name) + self.assertEqual(model['path'], path) + + # Test in sub-directory + # Create a directory and notebook in that directory + sub_dir = '/foo/' + self.make_dir(nm.notebook_dir, 'foo') + model = nm.create_notebook_model(None, sub_dir) + name = model['name'] + path = model['path'] + model = nm.get_notebook_model(name, path) + + # Change the name in the model for rename + model = nm.save_notebook_model(model, name, 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('/')) + + def test_delete_notebook_model(self): + with TemporaryDirectory() as td: + # Test in the root directory + # Create a notebook + nm = FileNotebookManager(notebook_dir=td) + model = nm.create_notebook_model() + name = model['name'] + path = model['path'] + + # Delete the notebook + nm.delete_notebook_model(name, path) + + # Check that a 'get' on the deleted notebook raises and error + self.assertRaises(HTTPError, nm.get_notebook_model, name, path) + + def test_copy_notebook(self): + with TemporaryDirectory() as td: + # Test in the root directory + # Create a notebook + nm = FileNotebookManager(notebook_dir=td) + path = u'å b' + name = u'nb √.ipynb' + os.mkdir(os.path.join(td, path)) + orig = nm.create_notebook_model({'name' : name}, path=path) + + # copy with unspecified name + copy = nm.copy_notebook(name, path=path) + self.assertEqual(copy['name'], orig['name'].replace('.ipynb', '-Copy0.ipynb')) + + # copy with specified name + copy2 = nm.copy_notebook(name, u'copy 2.ipynb', path=path) + self.assertEqual(copy2['name'], u'copy 2.ipynb') + diff --git a/IPython/html/services/notebooks/tests/test_notebooks_api.py b/IPython/html/services/notebooks/tests/test_notebooks_api.py new file mode 100644 index 0000000..33238bc --- /dev/null +++ b/IPython/html/services/notebooks/tests/test_notebooks_api.py @@ -0,0 +1,318 @@ +# coding: utf-8 +"""Test the notebooks webservice API.""" + +import io +import json +import os +import shutil +from unicodedata import normalize + +pjoin = os.path.join + +import requests + +from IPython.html.utils import url_path_join, url_escape +from IPython.html.tests.launchnotebook import NotebookTestBase, assert_http_error +from IPython.nbformat import current +from IPython.nbformat.current import (new_notebook, write, read, new_worksheet, + new_heading_cell, to_notebook_json) +from IPython.nbformat import v2 +from IPython.utils import py3compat +from IPython.utils.data import uniq_stable + + +class NBAPI(object): + """Wrapper for notebook API calls.""" + def __init__(self, base_url): + self.base_url = base_url + + def _req(self, verb, path, body=None): + response = requests.request(verb, + url_path_join(self.base_url, 'api/notebooks', path), + data=body, + ) + response.raise_for_status() + return response + + def list(self, path='/'): + return self._req('GET', path) + + def read(self, name, path='/'): + return self._req('GET', url_path_join(path, name)) + + def create_untitled(self, path='/'): + return self._req('POST', path) + + def upload_untitled(self, body, path='/'): + return self._req('POST', path, body) + + def copy_untitled(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 upload(self, name, body, path='/'): + return self._req('PUT', url_path_join(path, name), body) + + def copy(self, copy_from, copy_to, path='/'): + body = json.dumps({'copy_from':copy_from}) + return self._req('PUT', url_path_join(path, copy_to), body) + + def save(self, name, body, path='/'): + return self._req('PUT', url_path_join(path, name), body) + + def delete(self, name, path='/'): + return self._req('DELETE', url_path_join(path, name)) + + def rename(self, name, path, new_name): + body = json.dumps({'name': new_name}) + return self._req('PATCH', url_path_join(path, name), body) + + def get_checkpoints(self, name, path): + return self._req('GET', url_path_join(path, name, 'checkpoints')) + + def new_checkpoint(self, name, path): + return self._req('POST', url_path_join(path, name, 'checkpoints')) + + def restore_checkpoint(self, name, path, checkpoint_id): + return self._req('POST', url_path_join(path, name, 'checkpoints', checkpoint_id)) + + def delete_checkpoint(self, name, path, checkpoint_id): + return self._req('DELETE', url_path_join(path, name, 'checkpoints', checkpoint_id)) + +class APITest(NotebookTestBase): + """Test the kernels web service API""" + dirs_nbs = [('', 'inroot'), + ('Directory with spaces in', 'inspace'), + (u'unicodé', 'innonascii'), + ('foo', 'a'), + ('foo', 'b'), + ('foo', 'name with spaces'), + ('foo', u'unicodé'), + ('foo/bar', 'baz'), + (u'å b', u'ç d') + ] + + dirs = uniq_stable([d for (d,n) in dirs_nbs]) + del dirs[0] # remove '' + + def setUp(self): + nbdir = self.notebook_dir.name + + for d in self.dirs: + d.replace('/', os.sep) + if not os.path.isdir(pjoin(nbdir, d)): + os.mkdir(pjoin(nbdir, d)) + + for d, name in self.dirs_nbs: + d = d.replace('/', os.sep) + with io.open(pjoin(nbdir, d, '%s.ipynb' % name), 'w') as f: + nb = new_notebook(name=name) + write(nb, f, format='ipynb') + + self.nb_api = NBAPI(self.base_url()) + + def tearDown(self): + nbdir = self.notebook_dir.name + + for dname in ['foo', 'Directory with spaces in', u'unicodé', u'å b']: + shutil.rmtree(pjoin(nbdir, dname), ignore_errors=True) + + if os.path.isfile(pjoin(nbdir, 'inroot.ipynb')): + os.unlink(pjoin(nbdir, 'inroot.ipynb')) + + def test_list_notebooks(self): + nbs = self.nb_api.list().json() + self.assertEqual(len(nbs), 1) + self.assertEqual(nbs[0]['name'], 'inroot.ipynb') + + nbs = self.nb_api.list('/Directory with spaces in/').json() + self.assertEqual(len(nbs), 1) + self.assertEqual(nbs[0]['name'], 'inspace.ipynb') + + nbs = self.nb_api.list(u'/unicodé/').json() + self.assertEqual(len(nbs), 1) + self.assertEqual(nbs[0]['name'], 'innonascii.ipynb') + self.assertEqual(nbs[0]['path'], u'unicodé') + + nbs = self.nb_api.list('/foo/bar/').json() + self.assertEqual(len(nbs), 1) + self.assertEqual(nbs[0]['name'], 'baz.ipynb') + self.assertEqual(nbs[0]['path'], 'foo/bar') + + nbs = self.nb_api.list('foo').json() + self.assertEqual(len(nbs), 4) + nbnames = { normalize('NFC', n['name']) for n in nbs } + expected = [ u'a.ipynb', u'b.ipynb', u'name with spaces.ipynb', u'unicodé.ipynb'] + expected = { normalize('NFC', name) for name in expected } + self.assertEqual(nbnames, expected) + + def test_list_nonexistant_dir(self): + with assert_http_error(404): + self.nb_api.list('nonexistant') + + def test_get_contents(self): + for d, name in self.dirs_nbs: + nb = self.nb_api.read('%s.ipynb' % name, d+'/').json() + self.assertEqual(nb['name'], u'%s.ipynb' % name) + self.assertIn('content', nb) + self.assertIn('metadata', nb['content']) + self.assertIsInstance(nb['content']['metadata'], dict) + + # Name that doesn't exist - should be a 404 + with assert_http_error(404): + self.nb_api.read('q.ipynb', 'foo') + + def _check_nb_created(self, resp, name, path): + 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/notebooks', path, name))) + self.assertEqual(resp.json()['name'], name) + assert os.path.isfile(pjoin( + self.notebook_dir.name, + path.replace('/', os.sep), + name, + )) + + def test_create_untitled(self): + resp = self.nb_api.create_untitled(path=u'å b') + self._check_nb_created(resp, 'Untitled0.ipynb', u'å b') + + # Second time + resp = self.nb_api.create_untitled(path=u'å b') + self._check_nb_created(resp, 'Untitled1.ipynb', u'å b') + + # And two directories down + resp = self.nb_api.create_untitled(path='foo/bar') + self._check_nb_created(resp, 'Untitled0.ipynb', 'foo/bar') + + def test_upload_untitled(self): + nb = new_notebook(name='Upload test') + nbmodel = {'content': nb} + resp = self.nb_api.upload_untitled(path=u'å b', + body=json.dumps(nbmodel)) + self._check_nb_created(resp, 'Untitled0.ipynb', u'å b') + + def test_upload(self): + nb = new_notebook(name=u'ignored') + nbmodel = {'content': nb} + resp = self.nb_api.upload(u'Upload tést.ipynb', path=u'å b', + body=json.dumps(nbmodel)) + self._check_nb_created(resp, u'Upload tést.ipynb', u'å b') + + def test_upload_v2(self): + nb = v2.new_notebook() + ws = v2.new_worksheet() + nb.worksheets.append(ws) + ws.cells.append(v2.new_code_cell(input='print("hi")')) + nbmodel = {'content': nb} + resp = self.nb_api.upload(u'Upload tést.ipynb', path=u'å b', + body=json.dumps(nbmodel)) + self._check_nb_created(resp, u'Upload tést.ipynb', u'å b') + resp = self.nb_api.read(u'Upload tést.ipynb', u'å b') + data = resp.json() + self.assertEqual(data['content']['nbformat'], current.nbformat) + self.assertEqual(data['content']['orig_nbformat'], 2) + + def test_copy_untitled(self): + resp = self.nb_api.copy_untitled(u'ç d.ipynb', path=u'å b') + self._check_nb_created(resp, u'ç d-Copy0.ipynb', u'å b') + + def test_copy(self): + resp = self.nb_api.copy(u'ç d.ipynb', u'cøpy.ipynb', path=u'å b') + self._check_nb_created(resp, u'cøpy.ipynb', u'å b') + + def test_delete(self): + for d, name in self.dirs_nbs: + resp = self.nb_api.delete('%s.ipynb' % name, d) + self.assertEqual(resp.status_code, 204) + + for d in self.dirs + ['/']: + nbs = self.nb_api.list(d).json() + self.assertEqual(len(nbs), 0) + + def test_rename(self): + resp = self.nb_api.rename('a.ipynb', 'foo', 'z.ipynb') + self.assertEqual(resp.headers['Location'].split('/')[-1], 'z.ipynb') + self.assertEqual(resp.json()['name'], 'z.ipynb') + assert os.path.isfile(pjoin(self.notebook_dir.name, 'foo', 'z.ipynb')) + + nbs = self.nb_api.list('foo').json() + nbnames = set(n['name'] for n in nbs) + self.assertIn('z.ipynb', nbnames) + self.assertNotIn('a.ipynb', nbnames) + + def test_save(self): + resp = self.nb_api.read('a.ipynb', 'foo') + nbcontent = json.loads(resp.text)['content'] + nb = to_notebook_json(nbcontent) + ws = new_worksheet() + nb.worksheets = [ws] + ws.cells.append(new_heading_cell(u'Created by test ³')) + + nbmodel= {'name': 'a.ipynb', 'path':'foo', 'content': nb} + resp = self.nb_api.save('a.ipynb', path='foo', 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, format='ipynb') + self.assertEqual(newnb.worksheets[0].cells[0].source, + u'Created by test ³') + nbcontent = self.nb_api.read('a.ipynb', 'foo').json()['content'] + newnb = to_notebook_json(nbcontent) + self.assertEqual(newnb.worksheets[0].cells[0].source, + u'Created by test ³') + + # Save and rename + nbmodel= {'name': 'a2.ipynb', 'path':'foo/bar', 'content': nb} + resp = self.nb_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.nb_api.read('a.ipynb', 'foo') + + def test_checkpoints(self): + resp = self.nb_api.read('a.ipynb', 'foo') + r = self.nb_api.new_checkpoint('a.ipynb', 'foo') + self.assertEqual(r.status_code, 201) + cp1 = r.json() + self.assertEqual(set(cp1), {'id', 'last_modified'}) + self.assertEqual(r.headers['Location'].split('/')[-1], cp1['id']) + + # Modify it + nbcontent = json.loads(resp.text)['content'] + nb = to_notebook_json(nbcontent) + ws = new_worksheet() + nb.worksheets = [ws] + hcell = new_heading_cell('Created by test') + ws.cells.append(hcell) + # Save + nbmodel= {'name': 'a.ipynb', 'path':'foo', 'content': nb} + resp = self.nb_api.save('a.ipynb', path='foo', body=json.dumps(nbmodel)) + + # List checkpoints + cps = self.nb_api.get_checkpoints('a.ipynb', 'foo').json() + self.assertEqual(cps, [cp1]) + + nbcontent = self.nb_api.read('a.ipynb', 'foo').json()['content'] + nb = to_notebook_json(nbcontent) + self.assertEqual(nb.worksheets[0].cells[0].source, 'Created by test') + + # Restore cp1 + r = self.nb_api.restore_checkpoint('a.ipynb', 'foo', cp1['id']) + self.assertEqual(r.status_code, 204) + nbcontent = self.nb_api.read('a.ipynb', 'foo').json()['content'] + nb = to_notebook_json(nbcontent) + self.assertEqual(nb.worksheets, []) + + # Delete cp1 + r = self.nb_api.delete_checkpoint('a.ipynb', 'foo', cp1['id']) + self.assertEqual(r.status_code, 204) + cps = self.nb_api.get_checkpoints('a.ipynb', 'foo').json() + self.assertEqual(cps, []) + diff --git a/IPython/html/services/sessions/__init__.py b/IPython/html/services/sessions/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/IPython/html/services/sessions/__init__.py diff --git a/IPython/html/services/sessions/handlers.py b/IPython/html/services/sessions/handlers.py new file mode 100644 index 0000000..5f8c139 --- /dev/null +++ b/IPython/html/services/sessions/handlers.py @@ -0,0 +1,127 @@ +"""Tornado handlers for the sessions web service. + +Authors: + +* Zach Sailer +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2013 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import json + +from tornado import web + +from ...base.handlers import IPythonHandler, json_errors +from IPython.utils.jsonutil import date_default +from IPython.html.utils import url_path_join, url_escape + +#----------------------------------------------------------------------------- +# Session web service handlers +#----------------------------------------------------------------------------- + + +class SessionRootHandler(IPythonHandler): + + @web.authenticated + @json_errors + def get(self): + # Return a list of running sessions + sm = self.session_manager + sessions = sm.list_sessions() + self.finish(json.dumps(sessions, default=date_default)) + + @web.authenticated + @json_errors + def post(self): + # Creates a new session + #(unless a session already exists for the named nb) + sm = self.session_manager + nbm = self.notebook_manager + km = self.kernel_manager + 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: name") + try: + path = model['notebook']['path'] + except KeyError: + raise web.HTTPError(400, "Missing field in JSON data: path") + # Check to see if session exists + if sm.session_exists(name=name, path=path): + model = sm.get_session(name=name, path=path) + else: + kernel_id = km.start_kernel(cwd=nbm.notebook_dir) + model = sm.create_session(name=name, path=path, kernel_id=kernel_id, ws_url=self.ws_url) + location = url_path_join(self.base_kernel_url, 'api', 'sessions', model['id']) + self.set_header('Location', url_escape(location)) + self.set_status(201) + self.finish(json.dumps(model, default=date_default)) + +class SessionHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'PATCH', 'DELETE') + + @web.authenticated + @json_errors + def get(self, session_id): + # Returns the JSON model for a single session + sm = self.session_manager + model = sm.get_session(session_id=session_id) + self.finish(json.dumps(model, default=date_default)) + + @web.authenticated + @json_errors + def patch(self, session_id): + # Currently, this handler is strictly for renaming notebooks + sm = self.session_manager + model = self.get_json_body() + if model is None: + raise web.HTTPError(400, "No JSON data provided") + changes = {} + if 'notebook' in model: + notebook = model['notebook'] + if 'name' in notebook: + changes['name'] = notebook['name'] + if 'path' in notebook: + changes['path'] = notebook['path'] + + sm.update_session(session_id, **changes) + model = sm.get_session(session_id=session_id) + self.finish(json.dumps(model, default=date_default)) + + @web.authenticated + @json_errors + def delete(self, session_id): + # Deletes the session with given session_id + sm = self.session_manager + km = self.kernel_manager + session = sm.get_session(session_id=session_id) + sm.delete_session(session_id) + km.shutdown_kernel(session['kernel']['id']) + self.set_status(204) + self.finish() + + +#----------------------------------------------------------------------------- +# URL to handler mappings +#----------------------------------------------------------------------------- + +_session_id_regex = r"(?P\w+-\w+-\w+-\w+-\w+)" + +default_handlers = [ + (r"/api/sessions/%s" % _session_id_regex, SessionHandler), + (r"/api/sessions", SessionRootHandler) +] + diff --git a/IPython/html/services/sessions/sessionmanager.py b/IPython/html/services/sessions/sessionmanager.py new file mode 100644 index 0000000..30d8477 --- /dev/null +++ b/IPython/html/services/sessions/sessionmanager.py @@ -0,0 +1,201 @@ +"""A base class session manager. + +Authors: + +* Zach Sailer +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2013 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import uuid +import sqlite3 + +from tornado import web + +from IPython.config.configurable import LoggingConfigurable + +#----------------------------------------------------------------------------- +# Classes +#----------------------------------------------------------------------------- + +class SessionManager(LoggingConfigurable): + + # Session database initialized below + _cursor = None + _connection = None + _columns = {'session_id', 'name', 'path', 'kernel_id', 'ws_url'} + + @property + def cursor(self): + """Start a cursor and create a database called 'session'""" + if self._cursor is None: + self._cursor = self.connection.cursor() + self._cursor.execute("""CREATE TABLE session + (session_id, name, path, kernel_id, ws_url)""") + return self._cursor + + @property + def connection(self): + """Start a database connection""" + if self._connection is None: + self._connection = sqlite3.connect(':memory:') + self._connection.row_factory = self.row_factory + return self._connection + + def __del__(self): + """Close connection once SessionManager closes""" + self.cursor.close() + + def session_exists(self, name, path): + """Check to see if the session for a given notebook exists""" + self.cursor.execute("SELECT * FROM session WHERE name=? AND path=?", (name, path)) + reply = self.cursor.fetchone() + if reply is None: + return False + else: + return True + + def new_session_id(self): + "Create a uuid for a new session" + return unicode(uuid.uuid4()) + + def create_session(self, name=None, path=None, kernel_id=None, ws_url=None): + """Creates a session and returns its model""" + session_id = self.new_session_id() + return self.save_session(session_id, name=name, path=path, kernel_id=kernel_id, ws_url=ws_url) + + def save_session(self, session_id, name=None, path=None, kernel_id=None, ws_url=None): + """Saves the items for the session with the given session_id + + Given a session_id (and any other of the arguments), this method + creates a row in the sqlite session database that holds the information + for a session. + + Parameters + ---------- + 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 + kernel_id : str + a uuid for the kernel associated with this session + ws_url : str + the websocket url + + Returns + ------- + model : dict + a dictionary of the session model + """ + self.cursor.execute("INSERT INTO session VALUES (?,?,?,?,?)", + (session_id, name, path, kernel_id, ws_url) + ) + return self.get_session(session_id=session_id) + + def get_session(self, **kwargs): + """Returns the model for a particular session. + + Takes a keyword argument and searches for the value in the session + database, then returns the rest of the session's info. + + Parameters + ---------- + **kwargs : keyword argument + must be given one of the keywords and values from the session database + (i.e. session_id, name, path, kernel_id, ws_url) + + Returns + ------- + model : dict + returns a dictionary that includes all the information from the + session described by the kwarg. + """ + if not kwargs: + raise TypeError("must specify a column to query") + + conditions = [] + for column in kwargs.keys(): + if column not in self._columns: + raise TypeError("No such column: %r", column) + conditions.append("%s=?" % column) + + query = "SELECT * FROM session WHERE %s" % (' AND '.join(conditions)) + + self.cursor.execute(query, kwargs.values()) + model = self.cursor.fetchone() + if model is None: + q = [] + for key, value in kwargs.items(): + q.append("%s=%r" % (key, value)) + + raise web.HTTPError(404, u'Session not found: %s' % (', '.join(q))) + return model + + def update_session(self, session_id, **kwargs): + """Updates the values in the session database. + + Changes the values of the session with the given session_id + with the values from the keyword arguments. + + Parameters + ---------- + session_id : str + a uuid that identifies a session in the sqlite3 database + **kwargs : str + the key must correspond to a column title in session database, + and the value replaces the current value in the session + with session_id. + """ + self.get_session(session_id=session_id) + + if not kwargs: + # no changes + return + + sets = [] + for column in kwargs.keys(): + if column not in self._columns: + raise TypeError("No such column: %r" % column) + sets.append("%s=?" % column) + query = "UPDATE session SET %s WHERE session_id=?" % (', '.join(sets)) + self.cursor.execute(query, kwargs.values() + [session_id]) + + @staticmethod + def row_factory(cursor, row): + """Takes sqlite database session row and turns it into a dictionary""" + row = sqlite3.Row(cursor, row) + model = { + 'id': row['session_id'], + 'notebook': { + 'name': row['name'], + 'path': row['path'] + }, + 'kernel': { + 'id': row['kernel_id'], + 'ws_url': row['ws_url'] + } + } + return model + + def list_sessions(self): + """Returns a list of dictionaries containing all the information from + the session database""" + c = self.cursor.execute("SELECT * FROM session") + return list(c.fetchall()) + + def delete_session(self, session_id): + """Deletes the row in the session database with given session_id""" + # Check that session exists before deleting + self.get_session(session_id=session_id) + self.cursor.execute("DELETE FROM session WHERE session_id=?", (session_id,)) diff --git a/IPython/html/services/sessions/tests/__init__.py b/IPython/html/services/sessions/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/IPython/html/services/sessions/tests/__init__.py diff --git a/IPython/html/services/sessions/tests/test_sessionmanager.py b/IPython/html/services/sessions/tests/test_sessionmanager.py new file mode 100644 index 0000000..2aff898 --- /dev/null +++ b/IPython/html/services/sessions/tests/test_sessionmanager.py @@ -0,0 +1,83 @@ +"""Tests for the session manager.""" + +from unittest import TestCase + +from tornado import web + +from ..sessionmanager import SessionManager + +class TestSessionManager(TestCase): + + def test_get_session(self): + sm = SessionManager() + session_id = sm.new_session_id() + sm.save_session(session_id=session_id, name='test.ipynb', path='/path/to/', kernel_id='5678', ws_url='ws_url') + model = sm.get_session(session_id=session_id) + expected = {'id':session_id, 'notebook':{'name':u'test.ipynb', 'path': u'/path/to/'}, 'kernel':{'id':u'5678', 'ws_url':u'ws_url'}} + self.assertEqual(model, expected) + + def test_bad_get_session(self): + # Should raise error if a bad key is passed to the database. + sm = SessionManager() + session_id = sm.new_session_id() + sm.save_session(session_id=session_id, name='test.ipynb', path='/path/to/', kernel_id='5678', ws_url='ws_url') + self.assertRaises(TypeError, sm.get_session, bad_id=session_id) # Bad keyword + + def test_list_sessions(self): + sm = SessionManager() + session_id1 = sm.new_session_id() + session_id2 = sm.new_session_id() + session_id3 = sm.new_session_id() + sm.save_session(session_id=session_id1, name='test1.ipynb', path='/path/to/1/', kernel_id='5678', ws_url='ws_url') + sm.save_session(session_id=session_id2, name='test2.ipynb', path='/path/to/2/', kernel_id='5678', ws_url='ws_url') + sm.save_session(session_id=session_id3, name='test3.ipynb', path='/path/to/3/', kernel_id='5678', ws_url='ws_url') + sessions = sm.list_sessions() + expected = [{'id':session_id1, 'notebook':{'name':u'test1.ipynb', + 'path': u'/path/to/1/'}, 'kernel':{'id':u'5678', 'ws_url': 'ws_url'}}, + {'id':session_id2, 'notebook': {'name':u'test2.ipynb', + 'path': u'/path/to/2/'}, 'kernel':{'id':u'5678', 'ws_url': 'ws_url'}}, + {'id':session_id3, 'notebook':{'name':u'test3.ipynb', + 'path': u'/path/to/3/'}, 'kernel':{'id':u'5678', 'ws_url': 'ws_url'}}] + self.assertEqual(sessions, expected) + + def test_update_session(self): + sm = SessionManager() + session_id = sm.new_session_id() + sm.save_session(session_id=session_id, name='test.ipynb', path='/path/to/', kernel_id=None, ws_url='ws_url') + sm.update_session(session_id, kernel_id='5678') + sm.update_session(session_id, name='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/'}, 'kernel':{'id':u'5678', 'ws_url': 'ws_url'}} + self.assertEqual(model, expected) + + def test_bad_update_session(self): + # try to update a session with a bad keyword ~ raise error + sm = SessionManager() + session_id = sm.new_session_id() + sm.save_session(session_id=session_id, name='test.ipynb', path='/path/to/', kernel_id='5678', ws_url='ws_url') + self.assertRaises(TypeError, sm.update_session, session_id=session_id, bad_kw='test.ipynb') # Bad keyword + + def test_delete_session(self): + sm = SessionManager() + session_id1 = sm.new_session_id() + session_id2 = sm.new_session_id() + session_id3 = sm.new_session_id() + sm.save_session(session_id=session_id1, name='test1.ipynb', path='/path/to/1/', kernel_id='5678', ws_url='ws_url') + sm.save_session(session_id=session_id2, name='test2.ipynb', path='/path/to/2/', kernel_id='5678', ws_url='ws_url') + sm.save_session(session_id=session_id3, name='test3.ipynb', path='/path/to/3/', kernel_id='5678', ws_url='ws_url') + sm.delete_session(session_id2) + sessions = sm.list_sessions() + expected = [{'id':session_id1, 'notebook':{'name':u'test1.ipynb', + 'path': u'/path/to/1/'}, 'kernel':{'id':u'5678', 'ws_url': 'ws_url'}}, + {'id':session_id3, 'notebook':{'name':u'test3.ipynb', + 'path': u'/path/to/3/'}, 'kernel':{'id':u'5678', 'ws_url': 'ws_url'}}] + self.assertEqual(sessions, expected) + + def test_bad_delete_session(self): + # try to delete a session that doesn't exist ~ raise error + sm = SessionManager() + session_id = sm.new_session_id() + sm.save_session(session_id=session_id, name='test.ipynb', path='/path/to/', kernel_id='5678', ws_url='ws_url') + 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 new file mode 100644 index 0000000..363d68b --- /dev/null +++ b/IPython/html/services/sessions/tests/test_sessions_api.py @@ -0,0 +1,107 @@ +"""Test the sessions web service API.""" + +import io +import os +import json +import requests +import shutil + +pjoin = os.path.join + +from IPython.html.utils import url_path_join +from IPython.html.tests.launchnotebook import NotebookTestBase, assert_http_error +from IPython.nbformat.current import new_notebook, write + +class SessionAPI(object): + """Wrapper for notebook API calls.""" + def __init__(self, base_url): + self.base_url = base_url + + def _req(self, verb, path, body=None): + response = requests.request(verb, + url_path_join(self.base_url, 'api/sessions', path), data=body) + + if 400 <= response.status_code < 600: + try: + response.reason = response.json()['message'] + except: + pass + response.raise_for_status() + + return response + + def list(self): + return self._req('GET', '') + + def get(self, id): + return self._req('GET', id) + + def create(self, name, path): + body = json.dumps({'notebook': {'name':name, 'path':path}}) + return self._req('POST', '', body) + + def modify(self, id, name, path): + body = json.dumps({'notebook': {'name':name, 'path':path}}) + return self._req('PATCH', id, body) + + def delete(self, id): + return self._req('DELETE', id) + +class SessionAPITest(NotebookTestBase): + """Test the sessions web service API""" + def setUp(self): + nbdir = self.notebook_dir.name + os.mkdir(pjoin(nbdir, 'foo')) + + with io.open(pjoin(nbdir, 'foo', 'nb1.ipynb'), 'w') as f: + nb = new_notebook(name='nb1') + write(nb, f, format='ipynb') + + self.sess_api = SessionAPI(self.base_url()) + + def tearDown(self): + for session in self.sess_api.list().json(): + self.sess_api.delete(session['id']) + shutil.rmtree(pjoin(self.notebook_dir.name, 'foo')) + + def test_create(self): + sessions = self.sess_api.list().json() + self.assertEqual(len(sessions), 0) + + resp = self.sess_api.create('nb1.ipynb', 'foo') + 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(resp.headers['Location'], '/api/sessions/{0}'.format(newsession['id'])) + + sessions = self.sess_api.list().json() + self.assertEqual(sessions, [newsession]) + + # Retrieve it + sid = newsession['id'] + got = self.sess_api.get(sid).json() + self.assertEqual(got, newsession) + + def test_delete(self): + newsession = self.sess_api.create('nb1.ipynb', 'foo').json() + sid = newsession['id'] + + resp = self.sess_api.delete(sid) + self.assertEqual(resp.status_code, 204) + + sessions = self.sess_api.list().json() + self.assertEqual(sessions, []) + + with assert_http_error(404): + self.sess_api.get(sid) + + def test_modify(self): + newsession = self.sess_api.create('nb1.ipynb', 'foo').json() + sid = newsession['id'] + + 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'], '') diff --git a/IPython/html/static/base/js/utils.js b/IPython/html/static/base/js/utils.js index 068590a..8d06cec 100644 --- a/IPython/html/static/base/js/utils.js +++ b/IPython/html/static/base/js/utils.js @@ -366,6 +366,36 @@ IPython.utils = (function (IPython) { return Math.floor(points*pixel_per_point); }; + + var url_path_join = function () { + // join a sequence of url components with '/' + var url = ''; + for (var i = 0; i < arguments.length; i++) { + if (arguments[i] === '') { + continue; + } + if (url.length > 0 && url[url.length-1] != '/') { + url = url + '/' + arguments[i]; + } else { + url = url + arguments[i]; + } + } + return url; + }; + + + var splitext = function (filename) { + // mimic Python os.path.splitext + // Returns ['base', '.ext'] + var idx = filename.lastIndexOf('.'); + if (idx > 0) { + return [filename.slice(0, idx), filename.slice(idx)]; + } else { + return [filename, '']; + } + } + + // http://stackoverflow.com/questions/2400935/browser-detection-in-javascript var browser = (function() { var N= navigator.appName, ua= navigator.userAgent, tem; @@ -384,7 +414,9 @@ IPython.utils = (function (IPython) { fixCarriageReturn : fixCarriageReturn, autoLinkUrls : autoLinkUrls, points_to_pixels : points_to_pixels, - browser : browser + url_path_join : url_path_join, + splitext : splitext, + browser : browser }; }(IPython)); diff --git a/IPython/html/static/notebook/js/codecell.js b/IPython/html/static/notebook/js/codecell.js index 32ea289..b823fa2 100644 --- a/IPython/html/static/notebook/js/codecell.js +++ b/IPython/html/static/notebook/js/codecell.js @@ -66,7 +66,6 @@ var IPython = (function (IPython) { this.input_prompt_number = null; this.collapsed = false; this.cell_type = "code"; - this.last_msg_id = null; var cm_overwrite_options = { @@ -244,9 +243,6 @@ var IPython = (function (IPython) { this.output_area.clear_output(); this.set_input_prompt('*'); this.element.addClass("running"); - if (this.last_msg_id) { - this.kernel.clear_callbacks_for_msg(this.last_msg_id); - } var callbacks = { 'execute_reply': $.proxy(this._handle_execute_reply, this), 'output': $.proxy(this.output_area.handle_output, this.output_area), @@ -442,4 +438,4 @@ var IPython = (function (IPython) { IPython.CodeCell = CodeCell; return IPython; -}(IPython)); +}(IPython)); \ No newline at end of file diff --git a/IPython/html/static/notebook/js/main.js b/IPython/html/static/notebook/js/main.js index ec25f23..d273bd1 100644 --- a/IPython/html/static/notebook/js/main.js +++ b/IPython/html/static/notebook/js/main.js @@ -46,16 +46,24 @@ function (marked) { $('#ipython-main-app').addClass('border-box-sizing'); $('div#notebook_panel').addClass('border-box-sizing'); - var baseProjectUrl = $('body').data('baseProjectUrl') + var baseProjectUrl = $('body').data('baseProjectUrl'); + var notebookPath = $('body').data('notebookPath'); + var notebookName = $('body').data('notebookName'); + notebookName = decodeURIComponent(notebookName); + notebookPath = decodeURIComponent(notebookPath); + console.log(notebookName); + if (notebookPath == 'None'){ + notebookPath = ""; + } IPython.page = new IPython.Page(); IPython.layout_manager = new IPython.LayoutManager(); IPython.pager = new IPython.Pager('div#pager', 'div#pager_splitter'); IPython.quick_help = new IPython.QuickHelp(); IPython.login_widget = new IPython.LoginWidget('span#login_widget',{baseProjectUrl:baseProjectUrl}); - IPython.notebook = new IPython.Notebook('div#notebook',{baseProjectUrl:baseProjectUrl}); + IPython.notebook = new IPython.Notebook('div#notebook',{baseProjectUrl:baseProjectUrl, notebookPath:notebookPath, notebookName:notebookName}); IPython.save_widget = new IPython.SaveWidget('span#save_widget'); - IPython.menubar = new IPython.MenuBar('#menubar',{baseProjectUrl:baseProjectUrl}) + IPython.menubar = new IPython.MenuBar('#menubar',{baseProjectUrl:baseProjectUrl, notebookPath: notebookPath}) IPython.toolbar = new IPython.MainToolBar('#maintoolbar-container') IPython.tooltip = new IPython.Tooltip() IPython.notification_area = new IPython.NotificationArea('#notification_area') @@ -91,7 +99,7 @@ function (marked) { $([IPython.events]).on('notebook_loaded.Notebook', first_load); $([IPython.events]).trigger('app_initialized.NotebookApp'); - IPython.notebook.load_notebook($('body').data('notebookId')); + IPython.notebook.load_notebook(notebookName, notebookPath); if (marked) { marked.setOptions({ diff --git a/IPython/html/static/notebook/js/maintoolbar.js b/IPython/html/static/notebook/js/maintoolbar.js index 7a66e94..b266229 100644 --- a/IPython/html/static/notebook/js/maintoolbar.js +++ b/IPython/html/static/notebook/js/maintoolbar.js @@ -112,7 +112,7 @@ var IPython = (function (IPython) { label : 'Interrupt', icon : 'icon-stop', callback : function () { - IPython.notebook.kernel.interrupt(); + IPython.notebook.session.interrupt_kernel(); } } ],'run_int'); diff --git a/IPython/html/static/notebook/js/menubar.js b/IPython/html/static/notebook/js/menubar.js index fee79d6..38a67b9 100644 --- a/IPython/html/static/notebook/js/menubar.js +++ b/IPython/html/static/notebook/js/menubar.js @@ -18,9 +18,11 @@ var IPython = (function (IPython) { "use strict"; + + var utils = IPython.utils; /** - * A MenuBar Class to generate the menubar of IPython noteboko + * A MenuBar Class to generate the menubar of IPython notebook * @Class MenuBar * * @constructor @@ -34,8 +36,8 @@ var IPython = (function (IPython) { * does not support change for now is set through this option */ var MenuBar = function (selector, options) { - var options = options || {}; - if(options.baseProjectUrl!= undefined){ + options = options || {}; + if (options.baseProjectUrl !== undefined) { this._baseProjectUrl = options.baseProjectUrl; } this.selector = selector; @@ -50,7 +52,12 @@ var IPython = (function (IPython) { return this._baseProjectUrl || $('body').data('baseProjectUrl'); }; - + MenuBar.prototype.notebookPath = function() { + var path = $('body').data('notebookPath'); + path = decodeURIComponent(path); + return path; + }; + MenuBar.prototype.style = function () { this.element.addClass('border-box-sizing'); this.element.find("li").click(function (event, ui) { @@ -67,40 +74,64 @@ var IPython = (function (IPython) { // File var that = this; this.element.find('#new_notebook').click(function () { - window.open(that.baseProjectUrl()+'new'); + IPython.notebook.new_notebook(); }); this.element.find('#open_notebook').click(function () { - window.open(that.baseProjectUrl()); - }); - this.element.find('#rename_notebook').click(function () { - IPython.save_widget.rename_notebook(); + window.open(utils.url_path_join( + that.baseProjectUrl(), + 'tree', + that.notebookPath() + )); }); this.element.find('#copy_notebook').click(function () { - var notebook_id = IPython.notebook.get_notebook_id(); - var url = that.baseProjectUrl() + notebook_id + '/copy'; - window.open(url,'_blank'); + IPython.notebook.copy_notebook(); return false; }); - this.element.find('#save_checkpoint').click(function () { - IPython.notebook.save_checkpoint(); - }); - this.element.find('#restore_checkpoint').click(function () { - }); this.element.find('#download_ipynb').click(function () { - var notebook_id = IPython.notebook.get_notebook_id(); - var url = that.baseProjectUrl() + 'notebooks/' + - notebook_id + '?format=json'; + var notebook_name = IPython.notebook.get_notebook_name(); + if (IPython.notebook.dirty) { + IPython.notebook.save_notebook({async : false}); + } + + var url = utils.url_path_join( + that.baseProjectUrl(), + 'files', + that.notebookPath(), + notebook_name + '.ipynb' + ); window.location.assign(url); }); + + /* FIXME: download-as-py doesn't work right now + * We will need nbconvert hooked up to get this back + this.element.find('#download_py').click(function () { - var notebook_id = IPython.notebook.get_notebook_id(); - var url = that.baseProjectUrl() + 'notebooks/' + - notebook_id + '?format=py'; + var notebook_name = IPython.notebook.get_notebook_name(); + if (IPython.notebook.dirty) { + IPython.notebook.save_notebook({async : false}); + } + var url = utils.url_path_join( + that.baseProjectUrl(), + 'api/notebooks', + that.notebookPath(), + notebook_name + '.ipynb?format=py&download=True' + ); window.location.assign(url); }); + + */ + + this.element.find('#rename_notebook').click(function () { + IPython.save_widget.rename_notebook(); + }); + this.element.find('#save_checkpoint').click(function () { + IPython.notebook.save_checkpoint(); + }); + this.element.find('#restore_checkpoint').click(function () { + }); this.element.find('#kill_and_exit').click(function () { - IPython.notebook.kernel.kill(); - setTimeout(function(){window.close();}, 200); + IPython.notebook.session.delete(); + setTimeout(function(){window.close();}, 500); }); // Edit this.element.find('#cut_cell').click(function () { @@ -216,7 +247,7 @@ var IPython = (function (IPython) { }); // Kernel this.element.find('#int_kernel').click(function () { - IPython.notebook.kernel.interrupt(); + IPython.notebook.session.interrupt_kernel(); }); this.element.find('#restart_kernel').click(function () { IPython.notebook.restart_kernel(); @@ -240,7 +271,7 @@ var IPython = (function (IPython) { MenuBar.prototype.update_restore_checkpoint = function(checkpoints) { var ul = this.element.find("#restore_checkpoint").find("ul"); ul.empty(); - if (! checkpoints || checkpoints.length == 0) { + if (!checkpoints || checkpoints.length === 0) { ul.append( $("
  • ") .addClass("disabled") @@ -250,7 +281,7 @@ var IPython = (function (IPython) { ) ); return; - }; + } checkpoints.map(function (checkpoint) { var d = new Date(checkpoint.last_modified); diff --git a/IPython/html/static/notebook/js/notebook.js b/IPython/html/static/notebook/js/notebook.js index d76a763..ac10cef 100644 --- a/IPython/html/static/notebook/js/notebook.js +++ b/IPython/html/static/notebook/js/notebook.js @@ -1,5 +1,5 @@ //---------------------------------------------------------------------------- -// Copyright (C) 2008-2011 The IPython Development Team +// Copyright (C) 2011 The IPython Development Team // // Distributed under the terms of the BSD License. The full license is in // the file COPYING, distributed as part of this software. @@ -26,11 +26,13 @@ var IPython = (function (IPython) { var Notebook = function (selector, options) { var options = options || {}; this._baseProjectUrl = options.baseProjectUrl; - + this.notebook_path = options.notebookPath; + this.notebook_name = options.notebookName; this.element = $(selector); this.element.scroll(); this.element.data("notebook", this); this.next_prompt_number = 1; + this.session = null; this.kernel = null; this.clipboard = null; this.undelete_backup = null; @@ -49,8 +51,6 @@ var IPython = (function (IPython) { // single worksheet for now this.worksheet_metadata = {}; this.control_key_active = false; - this.notebook_id = null; - this.notebook_name = null; this.notebook_name_blacklist_re = /[\/\\:]/; this.nbformat = 3 // Increment this when changing the nbformat this.nbformat_minor = 0 // Increment this when changing the nbformat @@ -78,6 +78,18 @@ var IPython = (function (IPython) { return this._baseProjectUrl || $('body').data('baseProjectUrl'); }; + Notebook.prototype.notebookName = function() { + var name = $('body').data('notebookName'); + name = decodeURIComponent(name); + return name; + }; + + Notebook.prototype.notebookPath = function() { + var path = $('body').data('notebookPath'); + path = decodeURIComponent(path); + return path + }; + /** * Create an HTML and CSS representation of the notebook. * @@ -299,7 +311,7 @@ var IPython = (function (IPython) { return false; } else if (event.which === 73 && that.control_key_active) { // Interrupt kernel = i - that.kernel.interrupt(); + that.session.interrupt_kernel(); that.control_key_active = false; return false; } else if (event.which === 190 && that.control_key_active) { @@ -362,7 +374,7 @@ var IPython = (function (IPython) { // TODO: Make killing the kernel configurable. var kill_kernel = false; if (kill_kernel) { - that.kernel.kill(); + that.session.kill_kernel(); } // if we are autosaving, trigger an autosave on nav-away. // still warn, because if we don't the autosave may fail. @@ -1372,27 +1384,34 @@ var IPython = (function (IPython) { this.get_selected_cell().toggle_line_numbers(); }; - // Kernel related things + // Session related things /** - * Start a new kernel and set it on each code cell. + * Start a new session and set it on each code cell. * - * @method start_kernel + * @method start_session + */ + Notebook.prototype.start_session = function () { + this.session = new IPython.Session(this.notebook_name, this.notebook_path, this); + this.session.start($.proxy(this._session_started, this)); + }; + + + /** + * Once a session is started, link the code cells to the kernel + * */ - Notebook.prototype.start_kernel = function () { - var base_url = $('body').data('baseKernelUrl') + "kernels"; - this.kernel = new IPython.Kernel(base_url); - this.kernel.start({notebook: this.notebook_id}); - // Now that the kernel has been created, tell the CodeCells about it. + Notebook.prototype._session_started = function(){ + this.kernel = this.session.kernel; var ncells = this.ncells(); for (var i=0; i 1) { + if (content.worksheets.length > 1) { IPython.dialog.modal({ title : "Multiple worksheets", body : "This notebook has " + data.worksheets.length + " worksheets, " + @@ -1652,28 +1663,38 @@ var IPython = (function (IPython) { * * @method save_notebook */ - Notebook.prototype.save_notebook = function () { - // We may want to move the name/id/nbformat logic inside toJSON? - var data = this.toJSON(); - data.metadata.name = this.notebook_name; - data.nbformat = this.nbformat; - data.nbformat_minor = this.nbformat_minor; - + Notebook.prototype.save_notebook = function (extra_settings) { + // Create a JSON model to be sent to the server. + var model = {}; + model.name = this.notebook_name; + model.path = this.notebook_path; + model.content = this.toJSON(); + model.content.nbformat = this.nbformat; + model.content.nbformat_minor = this.nbformat_minor; // time the ajax call for autosave tuning purposes. var start = new Date().getTime(); - // We do the call with settings so we can set cache to false. var settings = { processData : false, cache : false, type : "PUT", - data : JSON.stringify(data), + data : JSON.stringify(model), headers : {'Content-Type': 'application/json'}, success : $.proxy(this.save_notebook_success, this, start), error : $.proxy(this.save_notebook_error, this) }; + if (extra_settings) { + for (var key in extra_settings) { + settings[key] = extra_settings[key]; + } + } $([IPython.events]).trigger('notebook_saving.Notebook'); - var url = this.baseProjectUrl() + 'notebooks/' + this.notebook_id; + var url = utils.url_path_join( + this.baseProjectUrl(), + 'api/notebooks', + this.notebookPath(), + this.notebook_name + ); $.ajax(url, settings); }; @@ -1727,16 +1748,137 @@ var IPython = (function (IPython) { Notebook.prototype.save_notebook_error = function (xhr, status, error_msg) { $([IPython.events]).trigger('notebook_save_failed.Notebook'); }; + + Notebook.prototype.new_notebook = function(){ + var path = this.notebookPath(); + var base_project_url = this.baseProjectUrl(); + var settings = { + processData : false, + cache : false, + type : "POST", + dataType : "json", + async : false, + success : function (data, status, xhr){ + var notebook_name = data.name; + window.open( + utils.url_path_join( + base_project_url, + 'notebooks', + path, + notebook_name + ), + '_blank' + ); + } + }; + var url = utils.url_path_join( + base_project_url, + 'api/notebooks', + path + ); + $.ajax(url,settings); + }; + + + Notebook.prototype.copy_notebook = function(){ + var path = this.notebookPath(); + var base_project_url = this.baseProjectUrl(); + var settings = { + processData : false, + cache : false, + type : "POST", + dataType : "json", + data : JSON.stringify({copy_from : this.notebook_name}), + async : false, + success : function (data, status, xhr) { + window.open(utils.url_path_join( + base_project_url, + 'notebooks', + data.path, + data.name + ), '_blank'); + } + }; + var url = utils.url_path_join( + base_project_url, + 'api/notebooks', + path + ); + $.ajax(url,settings); + }; + + Notebook.prototype.rename = function (nbname) { + var that = this; + var data = {name: nbname + '.ipynb'}; + var settings = { + processData : false, + cache : false, + type : "PATCH", + data : JSON.stringify(data), + dataType: "json", + headers : {'Content-Type': 'application/json'}, + success : $.proxy(that.rename_success, this), + error : $.proxy(that.rename_error, this) + }; + $([IPython.events]).trigger('rename_notebook.Notebook', data); + var url = utils.url_path_join( + this.baseProjectUrl(), + 'api/notebooks', + this.notebookPath(), + this.notebook_name + ); + $.ajax(url, settings); + }; + + Notebook.prototype.rename_success = function (json, status, xhr) { + this.notebook_name = json.name + var name = this.notebook_name + var path = json.path + this.session.rename_notebook(name, path); + $([IPython.events]).trigger('notebook_renamed.Notebook', json); + } + + Notebook.prototype.rename_error = function (json, status, xhr) { + var that = this; + var dialog = $('
    ').append( + $("

    ").addClass("rename-message") + .html('This notebook name already exists.') + ) + IPython.dialog.modal({ + title: "Notebook Rename Error!", + body: dialog, + buttons : { + "Cancel": {}, + "OK": { + class: "btn-primary", + click: function () { + IPython.save_widget.rename_notebook(); + }} + }, + open : function (event, ui) { + var that = $(this); + // Upon ENTER, click the OK button. + that.find('input[type="text"]').keydown(function (event, ui) { + if (event.which === utils.keycodes.ENTER) { + that.find('.btn-primary').first().click(); + } + }); + that.find('input[type="text"]').focus(); + } + }); + } + /** * Request a notebook's data from the server. * * @method load_notebook - * @param {String} notebook_id A notebook to load + * @param {String} notebook_name and path A notebook to load */ - Notebook.prototype.load_notebook = function (notebook_id) { + Notebook.prototype.load_notebook = function (notebook_name, notebook_path) { var that = this; - this.notebook_id = notebook_id; + this.notebook_name = notebook_name; + this.notebook_path = notebook_path; // We do the call with settings so we can set cache to false. var settings = { processData : false, @@ -1747,7 +1889,12 @@ var IPython = (function (IPython) { error : $.proxy(this.load_notebook_error,this), }; $([IPython.events]).trigger('notebook_loading.Notebook'); - var url = this.baseProjectUrl() + 'notebooks/' + this.notebook_id; + var url = utils.url_path_join( + this._baseProjectUrl, + 'api/notebooks', + this.notebookPath(), + this.notebook_name + ); $.ajax(url, settings); }; @@ -1805,12 +1952,13 @@ var IPython = (function (IPython) { } - // Create the kernel after the notebook is completely loaded to prevent + // Create the session after the notebook is completely loaded to prevent // code execution upon loading, which is a security risk. - this.start_kernel(); + if (this.session == null) { + this.start_session(); + } // load our checkpoint list IPython.notebook.list_checkpoints(); - $([IPython.events]).trigger('notebook_loaded.Notebook'); }; @@ -1861,7 +2009,7 @@ var IPython = (function (IPython) { var found = false; for (var i = 0; i < this.checkpoints.length; i++) { var existing = this.checkpoints[i]; - if (existing.checkpoint_id == checkpoint.checkpoint_id) { + if (existing.id == checkpoint.id) { found = true; this.checkpoints[i] = checkpoint; break; @@ -1879,7 +2027,13 @@ var IPython = (function (IPython) { * @method list_checkpoints */ Notebook.prototype.list_checkpoints = function () { - var url = this.baseProjectUrl() + 'notebooks/' + this.notebook_id + '/checkpoints'; + var url = utils.url_path_join( + this.baseProjectUrl(), + 'api/notebooks', + this.notebookPath(), + this.notebook_name, + 'checkpoints' + ); $.get(url).done( $.proxy(this.list_checkpoints_success, this) ).fail( @@ -1924,7 +2078,13 @@ var IPython = (function (IPython) { * @method create_checkpoint */ Notebook.prototype.create_checkpoint = function () { - var url = this.baseProjectUrl() + 'notebooks/' + this.notebook_id + '/checkpoints'; + var url = utils.url_path_join( + this.baseProjectUrl(), + 'api/notebooks', + this.notebookPath(), + this.notebook_name, + 'checkpoints' + ); $.post(url).done( $.proxy(this.create_checkpoint_success, this) ).fail( @@ -1989,7 +2149,7 @@ var IPython = (function (IPython) { Revert : { class : "btn-danger", click : function () { - that.restore_checkpoint(checkpoint.checkpoint_id); + that.restore_checkpoint(checkpoint.id); } }, Cancel : {} @@ -2004,8 +2164,15 @@ var IPython = (function (IPython) { * @param {String} checkpoint ID */ Notebook.prototype.restore_checkpoint = function (checkpoint) { - $([IPython.events]).trigger('checkpoint_restoring.Notebook', checkpoint); - var url = this.baseProjectUrl() + 'notebooks/' + this.notebook_id + '/checkpoints/' + checkpoint; + $([IPython.events]).trigger('notebook_restoring.Notebook', checkpoint); + var url = utils.url_path_join( + this.baseProjectUrl(), + 'api/notebooks', + this.notebookPath(), + this.notebook_name, + 'checkpoints', + checkpoint + ); $.post(url).done( $.proxy(this.restore_checkpoint_success, this) ).fail( @@ -2023,7 +2190,7 @@ var IPython = (function (IPython) { */ Notebook.prototype.restore_checkpoint_success = function (data, status, xhr) { $([IPython.events]).trigger('checkpoint_restored.Notebook'); - this.load_notebook(this.notebook_id); + this.load_notebook(this.notebook_name, this.notebook_path); }; /** @@ -2045,8 +2212,15 @@ var IPython = (function (IPython) { * @param {String} checkpoint ID */ Notebook.prototype.delete_checkpoint = function (checkpoint) { - $([IPython.events]).trigger('checkpoint_deleting.Notebook', checkpoint); - var url = this.baseProjectUrl() + 'notebooks/' + this.notebook_id + '/checkpoints/' + checkpoint; + $([IPython.events]).trigger('notebook_restoring.Notebook', checkpoint); + var url = utils.url_path_join( + this.baseProjectUrl(), + 'api/notebooks', + this.notebookPath(), + this.notebook_name, + 'checkpoints', + checkpoint + ); $.ajax(url, { type: 'DELETE', success: $.proxy(this.delete_checkpoint_success, this), @@ -2064,7 +2238,7 @@ var IPython = (function (IPython) { */ Notebook.prototype.delete_checkpoint_success = function (data, status, xhr) { $([IPython.events]).trigger('checkpoint_deleted.Notebook', data); - this.load_notebook(this.notebook_id); + this.load_notebook(this.notebook_name, this.notebook_path); }; /** @@ -2086,4 +2260,3 @@ var IPython = (function (IPython) { return IPython; }(IPython)); - diff --git a/IPython/html/static/notebook/js/savewidget.js b/IPython/html/static/notebook/js/savewidget.js index 6fee780..56e62f3 100644 --- a/IPython/html/static/notebook/js/savewidget.js +++ b/IPython/html/static/notebook/js/savewidget.js @@ -46,6 +46,11 @@ var IPython = (function (IPython) { that.update_notebook_name(); that.update_document_title(); }); + $([IPython.events]).on('notebook_renamed.Notebook', function () { + that.update_notebook_name(); + that.update_document_title(); + that.update_address_bar(); + }); $([IPython.events]).on('notebook_save_failed.Notebook', function () { that.set_save_status('Autosave Failed!'); }); @@ -90,8 +95,7 @@ var IPython = (function (IPython) { ); return false; } else { - IPython.notebook.set_notebook_name(new_name); - IPython.notebook.save_notebook(); + IPython.notebook.rename(new_name); } }} }, @@ -120,6 +124,17 @@ var IPython = (function (IPython) { var nbname = IPython.notebook.get_notebook_name(); document.title = nbname; }; + + SaveWidget.prototype.update_address_bar = function(){ + var nbname = IPython.notebook.notebook_name; + var path = IPython.notebook.notebookPath(); + var state = {path : utils.url_path_join(path,nbname)}; + window.history.replaceState(state, "", utils.url_path_join( + "/notebooks", + path, + nbname) + ); + } SaveWidget.prototype.set_save_status = function (msg) { diff --git a/IPython/html/static/notebook/js/tooltip.js b/IPython/html/static/notebook/js/tooltip.js index d7e6abf..9c3679e 100644 --- a/IPython/html/static/notebook/js/tooltip.js +++ b/IPython/html/static/notebook/js/tooltip.js @@ -225,8 +225,8 @@ var IPython = (function (IPython) { var callbacks = { 'object_info_reply': $.proxy(this._show, this) } - var oir_token = this.extract_oir_token(line) - cell.kernel.object_info_request(oir_token, callbacks); + var oir_token = this.extract_oir_token(line); + var msg_id = cell.kernel.object_info_request(oir_token, callbacks); } // make an imediate completion request diff --git a/IPython/html/static/services/kernels/js/kernel.js b/IPython/html/static/services/kernels/js/kernel.js index 5400743..48afd2f 100644 --- a/IPython/html/static/services/kernels/js/kernel.js +++ b/IPython/html/static/services/kernels/js/kernel.js @@ -73,12 +73,12 @@ var IPython = (function (IPython) { * @method start */ Kernel.prototype.start = function (params) { - var that = this; + params = params || {}; if (!this.running) { var qs = $.param(params); var url = this.base_url + '?' + qs; $.post(url, - $.proxy(that._kernel_started,that), + $.proxy(this._kernel_started, this), 'json' ); }; @@ -94,12 +94,11 @@ var IPython = (function (IPython) { */ Kernel.prototype.restart = function () { $([IPython.events]).trigger('status_restarting.Kernel', {kernel: this}); - var that = this; if (this.running) { this.stop_channels(); - var url = this.kernel_url + "/restart"; + var url = utils.url_path_join(this.kernel_url, "restart"); $.post(url, - $.proxy(that._kernel_started, that), + $.proxy(this._kernel_started, this), 'json' ); }; @@ -107,9 +106,9 @@ var IPython = (function (IPython) { Kernel.prototype._kernel_started = function (json) { - console.log("Kernel started: ", json.kernel_id); + console.log("Kernel started: ", json.id); this.running = true; - this.kernel_id = json.kernel_id; + this.kernel_id = json.id; var ws_url = json.ws_url; if (ws_url.match(/wss?:\/\//) == null) { // trailing 's' in https will become wss for secure web sockets @@ -117,14 +116,14 @@ var IPython = (function (IPython) { ws_url = prot + location.host + ws_url; }; this.ws_url = ws_url; - this.kernel_url = this.base_url + "/" + this.kernel_id; + this.kernel_url = utils.url_path_join(this.base_url, this.kernel_id); this.start_channels(); }; Kernel.prototype._websocket_closed = function(ws_url, early) { this.stop_channels(); - $([IPython.events]).trigger('websocket_closed.Kernel', + $([IPython.events]).trigger('websocket_closed.Kernel', {ws_url: ws_url, kernel: this, early: early} ); }; diff --git a/IPython/html/static/services/sessions/js/session.js b/IPython/html/static/services/sessions/js/session.js new file mode 100644 index 0000000..0a2f587 --- /dev/null +++ b/IPython/html/static/services/sessions/js/session.js @@ -0,0 +1,117 @@ +//---------------------------------------------------------------------------- +// Copyright (C) 2013 The IPython Development Team +// +// Distributed under the terms of the BSD License. The full license is in +// the file COPYING, distributed as part of this software. +//---------------------------------------------------------------------------- + +//============================================================================ +// Notebook +//============================================================================ + +var IPython = (function (IPython) { + "use strict"; + + var utils = IPython.utils; + + var Session = function(notebook_name, notebook_path, notebook){ + this.kernel = null; + this.id = null; + this.name = notebook_name; + this.path = notebook_path; + this.notebook = notebook; + this._baseProjectUrl = notebook.baseProjectUrl(); + }; + + Session.prototype.start = function(callback) { + var that = this; + var model = { + notebook : { + name : this.name, + path : this.path + } + }; + var settings = { + processData : false, + cache : false, + type : "POST", + data: JSON.stringify(model), + dataType : "json", + success : function (data, status, xhr) { + that._handle_start_success(data); + if (callback) { + callback(data, status, xhr); + } + }, + }; + var url = utils.url_path_join(this._baseProjectUrl, 'api/sessions'); + $.ajax(url, settings); + }; + + Session.prototype.rename_notebook = function (name, path) { + this.name = name; + this.path = path; + var model = { + notebook : { + name : this.name, + path : this.path + } + }; + var settings = { + processData : false, + cache : false, + type : "PATCH", + data: JSON.stringify(model), + dataType : "json", + }; + var url = utils.url_path_join(this._baseProjectUrl, 'api/sessions', this.id); + $.ajax(url, settings); + }; + + Session.prototype.delete = function() { + var settings = { + processData : false, + cache : false, + type : "DELETE", + dataType : "json", + }; + var url = utils.url_path_join(this._baseProjectUrl, 'api/sessions', this.id); + $.ajax(url, settings); + }; + + // Kernel related things + /** + * Create the Kernel object associated with this Session. + * + * @method _handle_start_success + */ + Session.prototype._handle_start_success = function (data, status, xhr) { + this.id = data.id; + var base_url = utils.url_path_join($('body').data('baseKernelUrl'), "api/kernels"); + this.kernel = new IPython.Kernel(base_url); + this.kernel._kernel_started(data.kernel); + }; + + /** + * Prompt the user to restart the IPython kernel. + * + * @method restart_kernel + */ + Session.prototype.restart_kernel = function () { + this.kernel.restart(); + }; + + Session.prototype.interrupt_kernel = function() { + this.kernel.interrupt(); + }; + + + Session.prototype.kill_kernel = function() { + this.kernel.kill(); + }; + + IPython.Session = Session; + + return IPython; + +}(IPython)); diff --git a/IPython/html/static/tree/js/clusterlist.js b/IPython/html/static/tree/js/clusterlist.js index 9ce3b9a..8be3b30 100644 --- a/IPython/html/static/tree/js/clusterlist.js +++ b/IPython/html/static/tree/js/clusterlist.js @@ -1,5 +1,5 @@ //---------------------------------------------------------------------------- -// Copyright (C) 2008-2011 The IPython Development Team +// Copyright (C) 2011 The IPython Development Team // // Distributed under the terms of the BSD License. The full license is in // the file COPYING, distributed as part of this software. @@ -10,6 +10,9 @@ //============================================================================ var IPython = (function (IPython) { + "use strict"; + + var utils = IPython.utils; var ClusterList = function (selector) { this.selector = selector; @@ -48,14 +51,14 @@ var IPython = (function (IPython) { dataType : "json", success : $.proxy(this.load_list_success, this) }; - var url = this.baseProjectUrl() + 'clusters'; + var url = utils.url_path_join(this.baseProjectUrl(), 'clusters'); $.ajax(url, settings); }; ClusterList.prototype.clear_list = function () { this.element.children('.list_item').remove(); - } + }; ClusterList.prototype.load_list_success = function (data, status, xhr) { this.clear_list(); @@ -66,7 +69,7 @@ var IPython = (function (IPython) { item.update_state(data[i]); element.data('item', item); this.element.append(element); - }; + } }; @@ -81,10 +84,9 @@ var IPython = (function (IPython) { }; - ClusterItem.prototype.style = function () { this.element.addClass('list_item').addClass("row-fluid"); - } + }; ClusterItem.prototype.update_state = function (data) { this.data = data; @@ -92,9 +94,8 @@ var IPython = (function (IPython) { this.state_running(); } else if (data.status === 'stopped') { this.state_stopped(); - }; - - } + } + }; ClusterItem.prototype.state_stopped = function () { @@ -132,13 +133,18 @@ var IPython = (function (IPython) { that.update_state(data); }, error : function (data, status, xhr) { - status_col.html("error starting cluster") + status_col.html("error starting cluster"); } }; status_col.html('starting'); - var url = that.baseProjectUrl() + 'clusters/' + that.data.profile + '/start'; + var url = utils.url_path_join( + that.baseProjectUrl(), + 'clusters', + that.data.profile, + 'start' + ); $.ajax(url, settings); - }; + } }); }; @@ -169,11 +175,16 @@ var IPython = (function (IPython) { }, error : function (data, status, xhr) { console.log('error',data); - status_col.html("error stopping cluster") + status_col.html("error stopping cluster"); } }; - status_col.html('stopping') - var url = that.baseProjectUrl() + 'clusters/' + that.data.profile + '/stop'; + status_col.html('stopping'); + var url = utils.url_path_join( + that.baseProjectUrl(), + 'clusters', + that.data.profile, + 'stop' + ); $.ajax(url, settings); }); }; diff --git a/IPython/html/static/tree/js/main.js b/IPython/html/static/tree/js/main.js index cbf14d3..ddcd9da 100644 --- a/IPython/html/static/tree/js/main.js +++ b/IPython/html/static/tree/js/main.js @@ -13,10 +13,11 @@ $(document).ready(function () { IPython.page = new IPython.Page(); - $('#new_notebook').click(function (e) { - window.open($('body').data('baseProjectUrl')+'new'); + + $('#new_notebook').button().click(function (e) { + IPython.notebook_list.new_notebook($('body').data('baseProjectUrl')) }); - + IPython.notebook_list = new IPython.NotebookList('#notebook_list'); IPython.cluster_list = new IPython.ClusterList('#cluster_list'); IPython.login_widget = new IPython.LoginWidget('#login_widget'); @@ -30,14 +31,14 @@ $(document).ready(function () { //refresh immediately , then start interval if($('.upload_button').length == 0) { - IPython.notebook_list.load_list(); + IPython.notebook_list.load_sessions(); IPython.cluster_list.load_list(); } if (!interval_id){ interval_id = setInterval(function(){ if($('.upload_button').length == 0) { - IPython.notebook_list.load_list(); + IPython.notebook_list.load_sessions(); IPython.cluster_list.load_list(); } }, time_refresh*1000); diff --git a/IPython/html/static/tree/js/notebooklist.js b/IPython/html/static/tree/js/notebooklist.js index a0b8840..8536ae5 100644 --- a/IPython/html/static/tree/js/notebooklist.js +++ b/IPython/html/static/tree/js/notebooklist.js @@ -1,5 +1,5 @@ //---------------------------------------------------------------------------- -// Copyright (C) 2008-2011 The IPython Development Team +// Copyright (C) 2011 The IPython Development Team // // Distributed under the terms of the BSD License. The full license is in // the file COPYING, distributed as part of this software. @@ -10,6 +10,9 @@ //============================================================================ var IPython = (function (IPython) { + "use strict"; + + var utils = IPython.utils; var NotebookList = function (selector) { this.selector = selector; @@ -18,12 +21,18 @@ var IPython = (function (IPython) { this.style(); this.bind_events(); } + this.notebooks_list = []; + this.sessions = {}; }; NotebookList.prototype.baseProjectUrl = function () { - return $('body').data('baseProjectUrl') + return $('body').data('baseProjectUrl'); }; + NotebookList.prototype.notebookPath = function() { + return $('body').data('notebookPath'); + }; + NotebookList.prototype.style = function () { $('#notebook_toolbar').addClass('list_toolbar'); $('#drag_info').addClass('toolbar_info'); @@ -54,19 +63,18 @@ var IPython = (function (IPython) { files = event.originalEvent.dataTransfer.files; } else { - files = event.originalEvent.target.files + files = event.originalEvent.target.files; } - for (var i = 0, f; f = files[i]; i++) { + for (var i = 0; i < files.length; i++) { + var f = files[i]; var reader = new FileReader(); reader.readAsText(f); - var fname = f.name.split('.'); - var nbname = fname.slice(0,-1).join('.'); - var nbformat = fname.slice(-1)[0]; - if (nbformat === 'ipynb') {nbformat = 'json';}; - if (nbformat === 'py' || nbformat === 'json') { + var name_and_ext = utils.splitext(f.name); + var nbname = name_and_ext[0]; + var file_ext = name_and_ext[-1]; + if (file_ext === '.ipynb') { var item = that.new_notebook_item(0); that.add_name_input(nbname, item); - item.data('nbformat', nbformat); // Store the notebook item in the reader so we can use it later // to know which item it belongs to. $(reader).data('item', item); @@ -75,15 +83,56 @@ var IPython = (function (IPython) { that.add_notebook_data(event.target.result, nbitem); that.add_upload_button(nbitem); }; - }; + } else { + var dialog = 'Uploaded notebooks must be .ipynb files'; + IPython.dialog.modal({ + title : 'Invalid file type', + body : dialog, + buttons : {'OK' : {'class' : 'btn-primary'}} + }); + } } return false; - }; + }; NotebookList.prototype.clear_list = function () { this.element.children('.list_item').remove(); }; + NotebookList.prototype.load_sessions = function(){ + var that = this; + var settings = { + processData : false, + cache : false, + type : "GET", + dataType : "json", + success : $.proxy(that.sessions_loaded, this) + }; + var url = this.baseProjectUrl() + 'api/sessions'; + $.ajax(url,settings); + }; + + + NotebookList.prototype.sessions_loaded = function(data){ + this.sessions = {}; + var len = data.length; + if (len > 0) { + for (var i=0; i') .text(message) - ) + ); } - for (var i=0; i").text("Shutdown").addClass("btn btn-mini"). click(function (e) { @@ -193,11 +250,15 @@ var IPython = (function (IPython) { cache : false, type : "DELETE", dataType : "json", - success : function (data, status, xhr) { - that.load_list(); + success : function () { + that.load_sessions(); } }; - var url = that.baseProjectUrl() + 'kernels/'+kernel; + var url = utils.url_path_join( + that.baseProjectUrl(), + 'api/sessions', + session + ); $.ajax(url, settings); return false; }); @@ -216,7 +277,6 @@ var IPython = (function (IPython) { // data because the outer scopes values change as we iterate through the loop. var parent_item = that.parents('div.list_item'); var nbname = parent_item.data('nbname'); - var notebook_id = parent_item.data('notebook_id'); var message = 'Are you sure you want to permanently delete the notebook: ' + nbname + '?'; IPython.dialog.modal({ title : "Delete notebook", @@ -234,7 +294,12 @@ var IPython = (function (IPython) { parent_item.remove(); } }; - var url = notebooklist.baseProjectUrl() + 'notebooks/' + notebook_id; + var url = utils.url_path_join( + notebooklist.baseProjectUrl(), + 'api/notebooks', + notebooklist.notebookPath(), + nbname + '.ipynb' + ); $.ajax(url, settings); } }, @@ -252,30 +317,34 @@ var IPython = (function (IPython) { var upload_button = $('