diff --git a/.travis.yml b/.travis.yml index 99ae90699..7463d827e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,6 @@ # http://travis-ci.org/#!/ipython/ipython language: python +group: edge cache: directories: - ~/.cache/bower diff --git a/docs/source/extending/frontend_extensions.rst b/docs/source/extending/frontend_extensions.rst new file mode 100644 index 000000000..e23dfdd53 --- /dev/null +++ b/docs/source/extending/frontend_extensions.rst @@ -0,0 +1,224 @@ +Custom front-end extensions +=========================== + +This describes the basic steps to write a JavaScript extension for the Jupyter +notebook front-end. This allows you to customize the behaviour of the various +pages like the dashboard, the notebook, or the text editor. + +The structure of a front-end extension +-------------------------------------- + +.. note:: + + The notebook front-end and Javascript API are not stable, and are subject + to a lot of changes. Any extension written for the current notebook is + almost guaranteed to break in the next release. + +.. _AMD module: https://en.wikipedia.org/wiki/Asynchronous_module_definition + +A front-end extension is a JavaScript file that defines an `AMD module`_ +which exposes at least a function called ``load_ipython_extension``, which +takes no arguments. We will not get into the details of what each of these +terms consists of yet, but here is the minimal code needed for a working +extension: + +.. code:: javascript + + // file my_extension/main.js + + define(function(){ + + function load_ipython_extension(){ + console.info('this is my first extension'); + } + + return { + load_ipython_extension: load_ipython_extension + }; + }); + +.. note:: + + Although for historical reasons the function is called + ``load_ipython_extension``, it does apply to the Jupyter notebook in + general, and will work regardless of the kernel in use. + +If you are familiar with JavaScript, you can use this template to require any +Jupyter module and modify its configuration, or do anything else in client-side +Javascript. Your extension will be loaded at the right time during the notebook +page initialisation for you to set up a listener for the various events that +the page can trigger. + +You might want access to the current instances of the various Jupyter notebook +components on the page, as opposed to the classes defined in the modules. The +current instances are exposed by a module named ``base/js/namespace``. If you +plan on accessing instances on the page, you should ``require`` this module +rather than accessing the global variable ``Jupyter``, which will be removed in +future. The following example demonstrates how to access the current notebook +instance: + +.. code:: javascript + + // file my_extension/main.js + + define([ + 'base/js/namespace' + ], function( + Jupyter + ) { + function load_ipython_extension() { + console.log( + 'This is the current notebook application instance:', + Jupyter.notebook + ); + } + + return { + load_ipython_extension: load_ipython_extension + }; + }); + + +Modifying key bindings +---------------------- + +One of the abilities of extensions is to modify key bindings, although once +again this is an API which is not guaranteed to be stable. However, custom key +bindings are frequently requested, and are helpful to increase accessibility, +so in the following we show how to access them. + +Here is an example of an extension that will unbind the shortcut ``0,0`` in +command mode, which normally restarts the kernel, and bind ``0,0,0`` in its +place: + +.. code:: javascript + + // file my_extension/main.js + + define([ + 'base/js/namespace' + ], function( + Jupyter + ) { + + function load_ipython_extension() { + Jupyter.keyboard_manager.command_shortcuts.remove_shortcut('0,0'); + Jupyter.keyboard_manager.command_shortcuts.add_shortcut('0,0,0', 'jupyter-notebook:restart-kernel'); + } + + return { + load_ipython_extension: load_ipython_extension + }; + }); + +.. note:: + + The standard keybindings might not work correctly on non-US keyboards. + Unfortunately, this is a limitation of browser implementations and the + status of keyboard event handling on the web in general. We appreciate your + feedback if you have issues binding keys, or have any ideas to help improve + the situation. + +You can see that I have used the **action name** +``jupyter-notebook:restart-kernel`` to bind the new shortcut. There is no API +yet to access the list of all available *actions*, though the following in the +JavaScript console of your browser on a notebook page should give you an idea +of what is available: + +.. code:: javascript + + Object.keys(require('base/js/namespace').actions._actions); + +In this example, we changed a keyboard shortcut in **command mode**; you +can also customize keyboard shortcuts in **edit mode**. +However, most of the keyboard shortcuts in edit mode are handled by CodeMirror, +which supports custom key bindings via a completely different API. + + +Defining and registering your own actions +----------------------------------------- + +As part of your front-end extension, you may wish to define actions, which can +be attached to toolbar buttons, or called from the command palette. Here is an +example of an extension that defines a (not very useful!) action to show an +alert, and adds a toolabr button using the full action name: + +.. code:: javascript + + // file my_extension/main.js + + define([ + 'base/js/namespace' + ], function( + Jupyter + ) { + function load_ipython_extension() { + + var handler = function () { + alert('this is an alert from my_extension!'); + }; + + var action = { + icon: 'fa-comment-o', // a font-awesome class used on buttons, etc + help : 'Show an alert', + help_index : 'zz', + handler : handler + }; + var prefix = 'my_extension'; + var action_name = 'show-alert'; + + var full_action_name = Jupyter.actions.register(action, name, prefix); // returns 'my_extension:show-alert' + Jupyter.toolbar.add_buttons_group([full_action_name]); + } + + return { + load_ipython_extension: load_ipython_extension + }; + }); + +Every action needs a name, which, when joined with its prefix to make the full +action name, should be unique. Built-in actions, like the +``jupyter-notebook:restart-kernel`` we bound in the earlier +`Modifying key bindings`_ example, use the prefix ``jupyter-notebook``. For +actions defined in an extension, it makes sense to use the extension name as +the prefix. For the action name, the following guidelines should be considered: + +.. adapted from notebook/static/notebook/js/actions.js +* First pick a noun and a verb for the action. For example, if the action is + "restart kernel," the verb is "restart" and the noun is "kernel". +* Omit terms like "selected" and "active" by default, so "delete-cell", rather + than "delete-selected-cell". Only provide a scope like "-all-" if it is other + than the default "selected" or "active" scope. +* If an action has a secondary action, separate the secondary action with + "-and-", so "restart-kernel-and-clear-output". +* Use above/below or previous/next to indicate spatial and sequential + relationships. +* Don't ever use before/after as they have a temporal connotation that is + confusing when used in a spatial context. +* For dialogs, use a verb that indicates what the dialog will accomplish, such + as "confirm-restart-kernel". + + +Installing and enabling extensions +---------------------------------- + +You can install your nbextension with the command: + + jupyter nbextension install path/to/my_extension/ + +Where my_extension is the directory containing the Javascript files. +This will copy it to a Jupyter data directory (the exact location is platform +dependent - see :ref:`jupyter_path`). + +For development, you can use the ``--symlink`` flag to symlink your extension +rather than copying it, so there's no need to reinstall after changes. + +To use your extension, you'll also need to **enable** it, which tells the +notebook interface to load it. You can do that with another command: + + jupyter nbextension enable my_extension/main + +The argument refers to the Javascript module containing your +``load_ipython_extension`` function, which is ``my_extension/main.js`` in this +example. There is a corresponding ``disable`` command to stop using an +extension without uninstalling it. diff --git a/notebook/log.py b/notebook/log.py index eecd39a00..dab330cb4 100644 --- a/notebook/log.py +++ b/notebook/log.py @@ -43,6 +43,6 @@ def log_request(handler): msg = msg + ' referer={referer}' if status >= 500 and status != 502: # log all headers if it caused an error - log_method(json.dumps(request.headers, indent=2)) + log_method(json.dumps(dict(request.headers), indent=2)) log_method(msg.format(**ns)) diff --git a/notebook/notebookapp.py b/notebook/notebookapp.py index c9badd0b8..2c1d392d7 100644 --- a/notebook/notebookapp.py +++ b/notebook/notebookapp.py @@ -120,7 +120,7 @@ class DeprecationHandler(IPythonHandler): def get(self, url_path): self.set_header("Content-Type", 'text/javascript') self.finish(""" - console.warn('`/static/widgets/js` is deprecated. Use `/nbextensions/widgets/widgets/js` instead.'); + console.warn('`/static/widgets/js` is deprecated. Use `nbextensions/widgets/widgets/js` instead.'); define(['%s'], function(x) { return x; }); """ % url_path_join('nbextensions', 'widgets', 'widgets', url_path.rstrip('.js'))) self.log.warn('Deprecated widget Javascript path /static/widgets/js/*.js was used') diff --git a/notebook/services/contents/tests/test_manager.py b/notebook/services/contents/tests/test_manager.py index 3ff2c805f..326280219 100644 --- a/notebook/services/contents/tests/test_manager.py +++ b/notebook/services/contents/tests/test_manager.py @@ -6,6 +6,7 @@ import os import sys import time from contextlib import contextmanager +from itertools import combinations from nose import SkipTest from tornado.web import HTTPError @@ -188,6 +189,41 @@ class TestFileContentsManager(TestCase): class TestContentsManager(TestCase): + @contextmanager + def assertRaisesHTTPError(self, status, msg=None): + msg = msg or "Should have raised HTTPError(%i)" % status + try: + yield + except HTTPError as e: + self.assertEqual(e.status_code, status) + else: + self.fail(msg) + + def make_populated_dir(self, api_path): + cm = self.contents_manager + + self.make_dir(api_path) + + cm.new(path="/".join([api_path, "nb.ipynb"])) + cm.new(path="/".join([api_path, "file.txt"])) + + def check_populated_dir_files(self, api_path): + dir_model = self.contents_manager.get(api_path) + + self.assertEqual(dir_model['path'], api_path) + self.assertEqual(dir_model['type'], "directory") + + for entry in dir_model['content']: + if entry['type'] == "directory": + continue + elif entry['type'] == "file": + self.assertEqual(entry['name'], "file.txt") + complete_path = "/".join([api_path, "file.txt"]) + self.assertEqual(entry["path"], complete_path) + elif entry['type'] == "notebook": + self.assertEqual(entry['name'], "nb.ipynb") + complete_path = "/".join([api_path, "nb.ipynb"]) + self.assertEqual(entry["path"], complete_path) def setUp(self): self._temp_dir = TemporaryDirectory() @@ -460,6 +496,55 @@ class TestContentsManager(TestCase): # Check that a 'get' on the deleted notebook raises and error self.assertRaises(HTTPError, cm.get, path) + def test_rename(self): + cm = self.contents_manager + # Create a new notebook + nb, name, path = self.new_notebook() + + # Rename the notebook + cm.rename(path, "changed_path") + + # Attempting to get the notebook under the old name raises an error + self.assertRaises(HTTPError, cm.get, path) + # Fetching the notebook under the new name is successful + assert isinstance(cm.get("changed_path"), dict) + + # Ported tests on nested directory renaming from pgcontents + all_dirs = ['foo', 'bar', 'foo/bar', 'foo/bar/foo', 'foo/bar/foo/bar'] + unchanged_dirs = all_dirs[:2] + changed_dirs = all_dirs[2:] + + for _dir in all_dirs: + self.make_populated_dir(_dir) + self.check_populated_dir_files(_dir) + + # Renaming to an existing directory should fail + for src, dest in combinations(all_dirs, 2): + with self.assertRaisesHTTPError(409): + cm.rename(src, dest) + + # Creating a notebook in a non_existant directory should fail + with self.assertRaisesHTTPError(404): + cm.new_untitled("foo/bar_diff", ext=".ipynb") + + cm.rename("foo/bar", "foo/bar_diff") + + # Assert that unchanged directories remain so + for unchanged in unchanged_dirs: + self.check_populated_dir_files(unchanged) + + # Assert changed directories can no longer be accessed under old names + for changed_dirname in changed_dirs: + with self.assertRaisesHTTPError(404): + cm.get(changed_dirname) + + new_dirname = changed_dirname.replace("foo/bar", "foo/bar_diff", 1) + + self.check_populated_dir_files(new_dirname) + + # Created a notebook in the renamed directory should work + cm.new_untitled("foo/bar_diff", ext=".ipynb") + def test_delete_root(self): cm = self.contents_manager with self.assertRaises(HTTPError) as err: diff --git a/notebook/services/kernels/handlers.py b/notebook/services/kernels/handlers.py index 19bc20315..0da36714f 100644 --- a/notebook/services/kernels/handlers.py +++ b/notebook/services/kernels/handlers.py @@ -25,12 +25,15 @@ class MainKernelHandler(APIHandler): @web.authenticated @json_errors + @gen.coroutine def get(self): km = self.kernel_manager - self.finish(json.dumps(km.list_kernels())) + kernels = yield gen.maybe_future(km.list_kernels()) + self.finish(json.dumps(kernels)) @web.authenticated @json_errors + @gen.coroutine def post(self): km = self.kernel_manager model = self.get_json_body() @@ -41,7 +44,7 @@ class MainKernelHandler(APIHandler): else: model.setdefault('name', km.default_kernel_name) - kernel_id = km.start_kernel(kernel_name=model['name']) + kernel_id = yield gen.maybe_future(km.start_kernel(kernel_name=model['name'])) model = km.kernel_model(kernel_id) location = url_path_join(self.base_url, 'api', 'kernels', url_escape(kernel_id)) self.set_header('Location', location) @@ -61,9 +64,10 @@ class KernelHandler(APIHandler): @web.authenticated @json_errors + @gen.coroutine def delete(self, kernel_id): km = self.kernel_manager - km.shutdown_kernel(kernel_id) + yield gen.maybe_future(km.shutdown_kernel(kernel_id)) self.set_status(204) self.finish() diff --git a/notebook/services/kernels/kernelmanager.py b/notebook/services/kernels/kernelmanager.py index d08d2ba8d..ccf2738a7 100644 --- a/notebook/services/kernels/kernelmanager.py +++ b/notebook/services/kernels/kernelmanager.py @@ -62,7 +62,8 @@ class MappingKernelManager(MultiKernelManager): while not os.path.isdir(os_path) and os_path != self.root_dir: os_path = os.path.dirname(os_path) return os_path - + + @gen.coroutine def start_kernel(self, kernel_id=None, path=None, **kwargs): """Start a kernel for a session and return its kernel_id. @@ -82,8 +83,9 @@ class MappingKernelManager(MultiKernelManager): if kernel_id is None: if path is not None: kwargs['cwd'] = self.cwd_for_path(path) - kernel_id = super(MappingKernelManager, self).start_kernel( - **kwargs) + kernel_id = yield gen.maybe_future( + super(MappingKernelManager, self).start_kernel(**kwargs) + ) self.log.info("Kernel started: %s" % kernel_id) self.log.debug("Kernel args: %r" % kwargs) # register callback for failed auto-restart @@ -94,12 +96,13 @@ class MappingKernelManager(MultiKernelManager): else: self._check_kernel_id(kernel_id) self.log.info("Using existing kernel: %s" % kernel_id) - return kernel_id + # py2-compat + raise gen.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) + return super(MappingKernelManager, self).shutdown_kernel(kernel_id, now=now) def restart_kernel(self, kernel_id): """Restart a kernel by kernel_id""" diff --git a/notebook/services/sessions/handlers.py b/notebook/services/sessions/handlers.py index 4c46c45df..b7a7f11de 100644 --- a/notebook/services/sessions/handlers.py +++ b/notebook/services/sessions/handlers.py @@ -8,7 +8,7 @@ Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-16%3A- import json -from tornado import web +from tornado import gen, web from ...base.handlers import APIHandler, json_errors from jupyter_client.jsonutil import date_default @@ -20,20 +20,20 @@ class SessionRootHandler(APIHandler): @web.authenticated @json_errors + @gen.coroutine def get(self): # Return a list of running sessions sm = self.session_manager - sessions = sm.list_sessions() + sessions = yield gen.maybe_future(sm.list_sessions()) self.finish(json.dumps(sessions, default=date_default)) @web.authenticated @json_errors + @gen.coroutine def post(self): # Creates a new session #(unless a session already exists for the named nb) sm = self.session_manager - cm = self.contents_manager - km = self.kernel_manager model = self.get_json_body() if model is None: @@ -49,11 +49,13 @@ class SessionRootHandler(APIHandler): kernel_name = None # Check to see if session exists - if sm.session_exists(path=path): - model = sm.get_session(path=path) + exists = yield gen.maybe_future(sm.session_exists(path=path)) + if exists: + model = yield gen.maybe_future(sm.get_session(path=path)) else: try: - model = sm.create_session(path=path, kernel_name=kernel_name) + model = yield gen.maybe_future( + sm.create_session(path=path, kernel_name=kernel_name)) except NoSuchKernel: msg = ("The '%s' kernel is not available. Please pick another " "suitable kernel instead, or install that kernel." % kernel_name) @@ -73,14 +75,16 @@ class SessionHandler(APIHandler): @web.authenticated @json_errors + @gen.coroutine 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) + model = yield gen.maybe_future(sm.get_session(session_id=session_id)) self.finish(json.dumps(model, default=date_default)) @web.authenticated @json_errors + @gen.coroutine def patch(self, session_id): # Currently, this handler is strictly for renaming notebooks sm = self.session_manager @@ -93,17 +97,18 @@ class SessionHandler(APIHandler): if 'path' in notebook: changes['path'] = notebook['path'] - sm.update_session(session_id, **changes) - model = sm.get_session(session_id=session_id) + yield gen.maybe_future(sm.update_session(session_id, **changes)) + model = yield gen.maybe_future(sm.get_session(session_id=session_id)) self.finish(json.dumps(model, default=date_default)) @web.authenticated @json_errors + @gen.coroutine def delete(self, session_id): # Deletes the session with given session_id sm = self.session_manager try: - sm.delete_session(session_id) + yield gen.maybe_future(sm.delete_session(session_id)) except KeyError: # the kernel was deleted but the session wasn't! raise web.HTTPError(410, "Kernel deleted before session") diff --git a/notebook/services/sessions/sessionmanager.py b/notebook/services/sessions/sessionmanager.py index b75732087..125940408 100644 --- a/notebook/services/sessions/sessionmanager.py +++ b/notebook/services/sessions/sessionmanager.py @@ -6,7 +6,7 @@ import uuid import sqlite3 -from tornado import web +from tornado import gen, web from traitlets.config.configurable import LoggingConfigurable from ipython_genutils.py3compat import unicode_type @@ -39,10 +39,16 @@ class SessionManager(LoggingConfigurable): self._connection = sqlite3.connect(':memory:') self._connection.row_factory = sqlite3.Row return self._connection - + + def close(self): + """Close the sqlite connection""" + if self._cursor is not None: + self._cursor.close() + self._cursor = None + def __del__(self): """Close connection once SessionManager closes""" - self.cursor.close() + self.close() def session_exists(self, path): """Check to see if the session for a given notebook exists""" @@ -56,17 +62,22 @@ class SessionManager(LoggingConfigurable): def new_session_id(self): "Create a uuid for a new session" return unicode_type(uuid.uuid4()) - + + @gen.coroutine def create_session(self, path=None, kernel_name=None): """Creates a session and returns its model""" session_id = self.new_session_id() # allow nbm to specify kernels cwd kernel_path = self.contents_manager.get_kernel_path(path=path) - kernel_id = self.kernel_manager.start_kernel(path=kernel_path, - kernel_name=kernel_name) - return self.save_session(session_id, path=path, - kernel_id=kernel_id) - + kernel_id = yield gen.maybe_future( + self.kernel_manager.start_kernel(path=kernel_path, kernel_name=kernel_name) + ) + result = yield gen.maybe_future( + self.save_session(session_id, path=path, kernel_id=kernel_id) + ) + # py2-compat + raise gen.Return(result) + def save_session(self, session_id, path=None, kernel_id=None): """Saves the items for the session with the given session_id diff --git a/notebook/services/sessions/tests/test_sessionmanager.py b/notebook/services/sessions/tests/test_sessionmanager.py index d1382ed1d..0b31d97a4 100644 --- a/notebook/services/sessions/tests/test_sessionmanager.py +++ b/notebook/services/sessions/tests/test_sessionmanager.py @@ -2,7 +2,8 @@ from unittest import TestCase -from tornado import web +from tornado import gen, web +from tornado.ioloop import IOLoop from ..sessionmanager import SessionManager from notebook.services.kernels.kernelmanager import MappingKernelManager @@ -37,11 +38,27 @@ class TestSessionManager(TestCase): kernel_manager=DummyMKM(), contents_manager=ContentsManager(), ) - + self.loop = IOLoop() + + def tearDown(self): + self.loop.close(all_fds=True) + + def create_sessions(self, *kwarg_list): + @gen.coroutine + def co_add(): + sessions = [] + for kwargs in kwarg_list: + session = yield self.sm.create_session(**kwargs) + sessions.append(session) + raise gen.Return(sessions) + return self.loop.run_sync(co_add) + + def create_session(self, **kwargs): + return self.create_sessions(kwargs)[0] + def test_get_session(self): sm = self.sm - session_id = sm.create_session(path='/path/to/test.ipynb', - kernel_name='bar')['id'] + session_id = self.create_session(path='/path/to/test.ipynb', kernel_name='bar')['id'] model = sm.get_session(session_id=session_id) expected = {'id':session_id, 'notebook':{'path': u'/path/to/test.ipynb'}, @@ -51,13 +68,13 @@ class TestSessionManager(TestCase): def test_bad_get_session(self): # Should raise error if a bad key is passed to the database. sm = self.sm - session_id = sm.create_session(path='/path/to/test.ipynb', + session_id = self.create_session(path='/path/to/test.ipynb', kernel_name='foo')['id'] self.assertRaises(TypeError, sm.get_session, bad_id=session_id) # Bad keyword def test_get_session_dead_kernel(self): sm = self.sm - session = sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python') + session = self.create_session(path='/path/to/1/test1.ipynb', kernel_name='python') # kill the kernel sm.kernel_manager.shutdown_kernel(session['kernel']['id']) with self.assertRaises(KeyError): @@ -68,11 +85,12 @@ class TestSessionManager(TestCase): def test_list_sessions(self): sm = self.sm - sessions = [ - sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python'), - sm.create_session(path='/path/to/2/test2.ipynb', kernel_name='python'), - sm.create_session(path='/path/to/3/test3.ipynb', kernel_name='python'), - ] + sessions = self.create_sessions( + dict(path='/path/to/1/test1.ipynb', kernel_name='python'), + dict(path='/path/to/2/test2.ipynb', kernel_name='python'), + dict(path='/path/to/3/test3.ipynb', kernel_name='python'), + ) + sessions = sm.list_sessions() expected = [ { @@ -93,10 +111,10 @@ class TestSessionManager(TestCase): def test_list_sessions_dead_kernel(self): sm = self.sm - sessions = [ - sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python'), - sm.create_session(path='/path/to/2/test2.ipynb', kernel_name='python'), - ] + sessions = self.create_sessions( + dict(path='/path/to/1/test1.ipynb', kernel_name='python'), + dict(path='/path/to/2/test2.ipynb', kernel_name='python'), + ) # kill one of the kernels sm.kernel_manager.shutdown_kernel(sessions[0]['kernel']['id']) listed = sm.list_sessions() @@ -116,7 +134,7 @@ class TestSessionManager(TestCase): def test_update_session(self): sm = self.sm - session_id = sm.create_session(path='/path/to/test.ipynb', + session_id = self.create_session(path='/path/to/test.ipynb', kernel_name='julia')['id'] sm.update_session(session_id, path='/path/to/new_name.ipynb') model = sm.get_session(session_id=session_id) @@ -128,17 +146,17 @@ class TestSessionManager(TestCase): def test_bad_update_session(self): # try to update a session with a bad keyword ~ raise error sm = self.sm - session_id = sm.create_session(path='/path/to/test.ipynb', + session_id = self.create_session(path='/path/to/test.ipynb', kernel_name='ir')['id'] self.assertRaises(TypeError, sm.update_session, session_id=session_id, bad_kw='test.ipynb') # Bad keyword def test_delete_session(self): sm = self.sm - sessions = [ - sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python'), - sm.create_session(path='/path/to/2/test2.ipynb', kernel_name='python'), - sm.create_session(path='/path/to/3/test3.ipynb', kernel_name='python'), - ] + sessions = self.create_sessions( + dict(path='/path/to/1/test1.ipynb', kernel_name='python'), + dict(path='/path/to/2/test2.ipynb', kernel_name='python'), + dict(path='/path/to/3/test3.ipynb', kernel_name='python'), + ) sm.delete_session(sessions[1]['id']) new_sessions = sm.list_sessions() expected = [{ @@ -156,7 +174,7 @@ class TestSessionManager(TestCase): def test_bad_delete_session(self): # try to delete a session that doesn't exist ~ raise error sm = self.sm - sm.create_session(path='/path/to/test.ipynb', kernel_name='python') + self.create_session(path='/path/to/test.ipynb', kernel_name='python') self.assertRaises(TypeError, sm.delete_session, bad_kwarg='23424') # Bad keyword self.assertRaises(web.HTTPError, sm.delete_session, session_id='23424') # nonexistant diff --git a/notebook/static/base/js/dialog.js b/notebook/static/base/js/dialog.js index 14212bfa5..769817410 100644 --- a/notebook/static/base/js/dialog.js +++ b/notebook/static/base/js/dialog.js @@ -75,6 +75,8 @@ define(function(require) { var footer = $("
").addClass("modal-footer"); + var default_button; + for (var label in options.buttons) { var btn_opts = options.buttons[label]; var button = $("