diff --git a/IPython/frontend/html/notebook/azurenbmanager.py b/IPython/frontend/html/notebook/azurenbmanager.py new file mode 100644 index 0000000..9e251c9 --- /dev/null +++ b/IPython/frontend/html/notebook/azurenbmanager.py @@ -0,0 +1,140 @@ +"""A notebook manager that uses Azure blob storage. + +Authors: + +* Brian Granger +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2012 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 datetime + +import azure +from azure.storage import BlobService + +from tornado import web + +from .basenbmanager import BaseNotebookManager +from IPython.nbformat import current +from IPython.utils.traitlets import Unicode, Instance + + +#----------------------------------------------------------------------------- +# Classes +#----------------------------------------------------------------------------- + +class AzureNotebookManager(BaseNotebookManager): + + account_name = Unicode('', config=True, help='Azure storage account name.') + account_key = Unicode('', config=True, help='Azure storage account key.') + container = Unicode('', config=True, help='Container name for notebooks.') + + blob_service_host_base = Unicode('.blob.core.windows.net', config=True, + help='The basename for the blob service URL. If running on the preview site this ' + 'will be .blob.core.azure-preview.com.') + def _blob_service_host_base_changed(self, new): + self._update_service_host_base(new) + + blob_service = Instance('azure.storage.BlobService') + def _blob_service_default(self): + return BlobService(account_name=self.account_name, account_key=self.account_key) + + def __init__(self, **kwargs): + super(BaseNotebookManager,self).__init__(**kwargs) + self._update_service_host_base(self.blob_service_host_base) + self._create_container() + + def _update_service_host_base(self, shb): + azure.BLOB_SERVICE_HOST_BASE = shb + + def _create_container(self): + self.blob_service.create_container(self.container) + + def load_notebook_names(self): + """On startup load the notebook ids and names from Azure. + + The blob names are the notebook ids and the notebook names are stored + as blob metadata. + """ + self.mapping = {} + blobs = self.blob_service.list_blobs(self.container) + ids = [blob.name for blob in blobs] + + for id in ids: + md = self.blob_service.get_blob_metadata(self.container, id) + name = md['x-ms-meta-nbname'] + self.mapping[id] = name + + def list_notebooks(self): + """List all notebooks in the container. + + This version uses `self.mapping` as the authoritative notebook list. + """ + data = [dict(notebook_id=id,name=name) for id, name in self.mapping.items()] + data = sorted(data, key=lambda item: item['name']) + return data + + def read_notebook_object(self, notebook_id): + """Get the object representation of a notebook by notebook_id.""" + if not self.notebook_exists(notebook_id): + raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + try: + s = self.blob_service.get_blob(self.container, notebook_id) + except: + raise web.HTTPError(500, u'Notebook cannot be read.') + try: + # v1 and v2 and json in the .ipynb files. + nb = current.reads(s, u'json') + except: + raise web.HTTPError(500, u'Unreadable JSON notebook.') + # Todo: The last modified should actually be saved in the notebook document. + # We are just using the current datetime until that is implemented. + last_modified = datetime.datetime.utcnow() + return last_modified, nb + + def write_notebook_object(self, nb, notebook_id=None): + """Save an existing notebook object by notebook_id.""" + try: + new_name = nb.metadata.name + except AttributeError: + raise web.HTTPError(400, u'Missing notebook name') + + if notebook_id is None: + notebook_id = self.new_notebook_id(new_name) + + if notebook_id not in self.mapping: + raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + + try: + data = current.writes(nb, u'json') + except Exception as e: + raise web.HTTPError(400, u'Unexpected error while saving notebook: %s' % e) + + metadata = {'nbname': new_name} + try: + self.blob_service.put_blob(self.container, notebook_id, data, 'BlockBlob', x_ms_meta_name_values=metadata) + except Exception as e: + raise web.HTTPError(400, u'Unexpected error while saving notebook: %s' % e) + + self.mapping[notebook_id] = new_name + return notebook_id + + def delete_notebook(self, notebook_id): + """Delete notebook by notebook_id.""" + if not self.notebook_exists(notebook_id): + raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + try: + self.blob_service.delete_blob(self.container, notebook_id) + except Exception as e: + raise web.HTTPError(400, u'Unexpected error while deleting notebook: %s' % e) + else: + self.delete_notebook_id(notebook_id) diff --git a/IPython/frontend/html/notebook/basenbmanager.py b/IPython/frontend/html/notebook/basenbmanager.py new file mode 100644 index 0000000..b9a72c3 --- /dev/null +++ b/IPython/frontend/html/notebook/basenbmanager.py @@ -0,0 +1,181 @@ +"""A base class notebook manager. + +Authors: + +* Brian Granger +""" + +#----------------------------------------------------------------------------- +# 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. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import uuid + +from tornado import web + +from IPython.config.configurable import LoggingConfigurable +from IPython.nbformat import current +from IPython.utils.traitlets import List, Dict + +#----------------------------------------------------------------------------- +# Classes +#----------------------------------------------------------------------------- + +class BaseNotebookManager(LoggingConfigurable): + + 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. + + This should be called once immediately after the notebook manager + is created to load the existing notebooks into the mapping in + memory. + """ + self.list_notebooks() + + def list_notebooks(self): + """List all notebooks. + + This returns a list of dicts, each of the form:: + + dict(notebook_id=notebook,name=name) + + This list of dicts should be sorted by name:: + + data = sorted(data, key=lambda item: item['name']) + """ + 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.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.""" + 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. + """ + raise NotImplementedError('must be implemented in a subclass') + + def delete_notebook(self, notebook_id): + """Delete notebook by notebook_id.""" + raise NotImplementedError('must be implemented in a subclass') + + def increment_filename(self, name): + """Increment a filename to make it unique. + + 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. + """ + 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 diff --git a/IPython/frontend/html/notebook/notebookmanager.py b/IPython/frontend/html/notebook/filenbmanager.py similarity index 61% rename from IPython/frontend/html/notebook/notebookmanager.py rename to IPython/frontend/html/notebook/filenbmanager.py index 279b692..eb0c062 100644 --- a/IPython/frontend/html/notebook/notebookmanager.py +++ b/IPython/frontend/html/notebook/filenbmanager.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. @@ -19,20 +19,19 @@ Authors: import datetime import io import os -import uuid import glob from tornado import web -from IPython.config.configurable import LoggingConfigurable +from .basenbmanager import BaseNotebookManager from IPython.nbformat import current -from IPython.utils.traitlets import Unicode, List, Dict, Bool, TraitError +from IPython.utils.traitlets import Unicode, Dict, Bool, TraitError #----------------------------------------------------------------------------- # Classes #----------------------------------------------------------------------------- -class NotebookManager(LoggingConfigurable): +class FileNotebookManager(BaseNotebookManager): notebook_dir = Unicode(os.getcwdu(), config=True, help=""" The directory to use for notebooks. @@ -59,24 +58,21 @@ class NotebookManager(LoggingConfigurable): ) filename_ext = Unicode(u'.ipynb') - allowed_formats = List([u'json',u'py']) - # Map notebook_ids to notebook names - mapping = Dict() # Map notebook names to notebook_ids rev_mapping = Dict() - def list_notebooks(self): - """List all notebooks in the notebook dir. - - This returns a list of dicts of the form:: - - dict(notebook_id=notebook,name=name) - """ + 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 = [os.path.splitext(os.path.basename(name))[0] 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: @@ -90,30 +86,20 @@ class NotebookManager(LoggingConfigurable): def new_notebook_id(self, name): """Generate a new notebook_id for a name and store its mappings.""" - # 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 + notebook_id = super(BaseNotebookManager, 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 only. This doesn't delete the actual notebook.""" + """Delete a notebook's id in the mapping.""" + super(BaseNotebookManager, self).delete_notebook_id(notebook_id) name = self.mapping[notebook_id] - del self.mapping[notebook_id] del self.rev_mapping[name] def notebook_exists(self, notebook_id): """Does a notebook exist?""" - if notebook_id not in self.mapping: + exists = super(BaseNotebookManager, 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) @@ -132,22 +118,7 @@ class NotebookManager(LoggingConfigurable): path = os.path.join(self.notebook_dir, filename) return path - 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.get_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.get('name','notebook') - return last_modified, name, data - - def get_notebook_object(self, notebook_id): + def read_notebook_object(self, notebook_id): """Get the NotebookNode representation of a notebook by notebook_id.""" path = self.find_path(notebook_id) if not os.path.isfile(path): @@ -165,60 +136,27 @@ class NotebookManager(LoggingConfigurable): nb.metadata.name = os.path.splitext(os.path.basename(path))[0] return last_modified, nb - 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.new_notebook_id(name) - self.save_notebook_object(notebook_id, 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) - + def write_notebook_object(self, nb, notebook_id=None): + """Save an existing notebook object by notebook_id.""" try: - nb = current.reads(data.decode('utf-8'), format) - except: - raise web.HTTPError(400, u'Invalid JSON data') + new_name = nb.metadata.name + except AttributeError: + raise web.HTTPError(400, u'Missing notebook name') - if name is not None: - nb.metadata.name = name - self.save_notebook_object(notebook_id, nb) + if notebook_id is None: + notebook_id = self.new_notebook_id(new_name) - def save_notebook_object(self, notebook_id, nb): - """Save an existing notebook object by notebook_id.""" if notebook_id not in self.mapping: raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + old_name = self.mapping[notebook_id] - try: - new_name = nb.metadata.name - except AttributeError: - raise web.HTTPError(400, u'Missing notebook name') path = self.get_path_by_name(new_name) try: with open(path,'w') as f: current.write(nb, f, u'json') except Exception as e: raise web.HTTPError(400, u'Unexpected error while saving notebook: %s' % e) + # save .py script as well if self.save_script: pypath = os.path.splitext(path)[0] + '.py' @@ -228,6 +166,7 @@ class NotebookManager(LoggingConfigurable): 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: old_path = self.get_path_by_name(old_name) if os.path.isfile(old_path): @@ -239,6 +178,8 @@ class NotebookManager(LoggingConfigurable): self.mapping[notebook_id] = new_name self.rev_mapping[new_name] = notebook_id del self.rev_mapping[old_name] + + return notebook_id def delete_notebook(self, notebook_id): """Delete notebook by notebook_id.""" @@ -263,24 +204,4 @@ class NotebookManager(LoggingConfigurable): break else: i = i+1 - return path, name - - def new_notebook(self): - """Create a new notebook and return its notebook_id.""" - path, name = self.increment_filename('Untitled') - notebook_id = self.new_notebook_id(name) - metadata = current.new_metadata(name=name) - nb = current.new_notebook(metadata=metadata) - with open(path,'w') as f: - current.write(nb, f, u'json') - return notebook_id - - def copy_notebook(self, notebook_id): - """Copy an existing notebook and return its notebook_id.""" - last_mod, nb = self.get_notebook_object(notebook_id) - name = nb.metadata.name + '-Copy' - path, name = self.increment_filename(name) - nb.metadata.name = name - notebook_id = self.new_notebook_id(name) - self.save_notebook_object(notebook_id, nb) - return notebook_id + return name diff --git a/IPython/frontend/html/notebook/notebookapp.py b/IPython/frontend/html/notebook/notebookapp.py index 13f13c9..4fd525c 100644 --- a/IPython/frontend/html/notebook/notebookapp.py +++ b/IPython/frontend/html/notebook/notebookapp.py @@ -66,7 +66,11 @@ from IPython.zmq.ipkernel import ( aliases as ipkernel_aliases, IPKernelApp ) -from IPython.utils.traitlets import Dict, Unicode, Integer, List, Enum, Bool +from IPython.utils.importstring import import_item +from IPython.utils.traitlets import ( + Dict, Unicode, Integer, List, Enum, Bool, + DottedObjectName +) from IPython.utils import py3compat from IPython.utils.path import filefind @@ -404,6 +408,10 @@ class NotebookApp(BaseIPythonApplication): else: self.log.info("Using MathJax: %s", new) + notebook_manager_class = DottedObjectName('IPython.frontend.html.notebook.notebookmanager.NotebookManager', + config=True, + help='The notebook manager class to use.') + def parse_command_line(self, argv=None): super(NotebookApp, self).parse_command_line(argv) if argv is None: @@ -430,9 +438,10 @@ class NotebookApp(BaseIPythonApplication): config=self.config, log=self.log, kernel_argv=self.kernel_argv, connection_dir = self.profile_dir.security_dir, ) - self.notebook_manager = NotebookManager(config=self.config, log=self.log) + kls = import_item(self.notebook_manager_class) + self.notebook_manager = kls(config=self.config, log=self.log) self.log.info("Serving notebooks from %s", self.notebook_manager.notebook_dir) - self.notebook_manager.list_notebooks() + self.notebook_manager.load_notebook_names() self.cluster_manager = ClusterManager(config=self.config, log=self.log) self.cluster_manager.update_profiles() diff --git a/IPython/frontend/html/notebook/tests/test_nbmanager.py b/IPython/frontend/html/notebook/tests/test_nbmanager.py index 41290d9..d151386 100644 --- a/IPython/frontend/html/notebook/tests/test_nbmanager.py +++ b/IPython/frontend/html/notebook/tests/test_nbmanager.py @@ -7,28 +7,28 @@ from tempfile import NamedTemporaryFile from IPython.utils.tempdir import TemporaryDirectory from IPython.utils.traitlets import TraitError -from IPython.frontend.html.notebook.notebookmanager import NotebookManager +from IPython.frontend.html.notebook.filenbmanager import FileNotebookManager class TestNotebookManager(TestCase): def test_nb_dir(self): with TemporaryDirectory() as td: - km = NotebookManager(notebook_dir=td) - self.assertEqual(km.notebook_dir, td) + km = FileNotebookManager(notebook_dir=td) + self.assertEquals(km.notebook_dir, td) def test_create_nb_dir(self): with TemporaryDirectory() as td: nbdir = os.path.join(td, 'notebooks') - km = NotebookManager(notebook_dir=nbdir) - self.assertEqual(km.notebook_dir, nbdir) + km = FileNotebookManager(notebook_dir=nbdir) + self.assertEquals(km.notebook_dir, nbdir) def test_missing_nb_dir(self): with TemporaryDirectory() as td: nbdir = os.path.join(td, 'notebook', 'dir', 'is', 'missing') - self.assertRaises(TraitError, NotebookManager, notebook_dir=nbdir) + self.assertRaises(TraitError, FileNotebookManager, notebook_dir=nbdir) def test_invalid_nb_dir(self): with NamedTemporaryFile() as tf: - self.assertRaises(TraitError, NotebookManager, notebook_dir=tf.name) + self.assertRaises(TraitError, FileNotebookManager, notebook_dir=tf.name)