diff --git a/docs/source/examples/Notebook/Distributing Jupyter Extensions as Python Packages.ipynb b/docs/source/examples/Notebook/Distributing Jupyter Extensions as Python Packages.ipynb index d14cb4f50..e3f8bead0 100644 --- a/docs/source/examples/Notebook/Distributing Jupyter Extensions as Python Packages.ipynb +++ b/docs/source/examples/Notebook/Distributing Jupyter Extensions as Python Packages.ipynb @@ -21,7 +21,9 @@ " [AMD modules](https://en.wikipedia.org/wiki/Asynchronous_module_definition)\n", " that exports a function `load_ipython_extension`\n", "- server extension: an importable Python module\n", - " - that implements `load_jupyter_server_extension`" + " - that implements `load_jupyter_server_extension`\n", + "- bundler extension: an importable Python module with generated File -> Download as / Deploy as menu item trigger\n", + " - that implements `bundle`" ] }, { @@ -105,11 +107,12 @@ "metadata": {}, "source": [ "## Did it work? Check by listing Jupyter Extensions.\n", - "After running one or more extension installation steps, you can list what is presently known about nbextensions or server extension. The following commands will list which extensions are available, whether they are enabled, and other extension details:\n", + "After running one or more extension installation steps, you can list what is presently known about nbextensions, server extensions, or bundler extensions. The following commands will list which extensions are available, whether they are enabled, and other extension details:\n", "\n", "```shell\n", "jupyter nbextension list\n", "jupyter serverextension list\n", + "jupyter bundlerextension list\n", "```" ] }, @@ -255,6 +258,98 @@ "jupyter serverextension enable --py my_fancy_module [--sys-prefix|--system]\n", "```" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example - Bundler extension" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Creating a Python package with a bundlerextension\n", + "\n", + "Here is a bundler extension that adds a *Download as -> Notebook Tarball (tar.gz)* option to the notebook *File* menu. It assumes this directory structure:\n", + "\n", + "```\n", + "- setup.py\n", + "- MANIFEST.in\n", + "- my_tarball_bundler/\n", + " - __init__.py\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Defining the bundler extension\n", + "\n", + "This example shows that the bundler extension and its `bundle` function are defined in the `__init__.py` file.\n", + "\n", + "#### `my_tarball_bundler/__init__.py`\n", + "\n", + "```python\n", + "import tarfile\n", + "import io\n", + "import os\n", + "import nbformat\n", + "\n", + "def _jupyter_bundlerextension_paths():\n", + " \"\"\"Declare bundler extensions provided by this package.\"\"\"\n", + " return [{\n", + " # unique bundler name\n", + " \"name\": \"tarball_bundler\",\n", + " # module containing bundle function\n", + " \"module_name\": \"my_tarball_bundler\",\n", + " # human-redable menu item label\n", + " \"label\" : \"Notebook Tarball (tar.gz)\",\n", + " # group under 'deploy' or 'download' menu\n", + " \"group\" : \"download\",\n", + " }]\n", + "\n", + "\n", + "def bundle(handler, model):\n", + " \"\"\"Create a compressed tarball containing the notebook document.\n", + " \n", + " Parameters\n", + " ----------\n", + " handler : tornado.web.RequestHandler\n", + " Handler that serviced the bundle request\n", + " model : dict\n", + " Notebook model from the configured ContentManager\n", + " \"\"\"\n", + " notebook_filename = model['name']\n", + " notebook_content = nbformat.writes(model['content']).encode('utf-8')\n", + " notebook_name = os.path.splitext(notebook_filename)[0]\n", + " tar_filename = '{}.tar.gz'.format(notebook_name)\n", + " \n", + " info = tarfile.TarInfo(notebook_filename)\n", + " info.size = len(notebook_content)\n", + "\n", + " with io.BytesIO() as tar_buffer:\n", + " with tarfile.open(tar_filename, \"w:gz\", fileobj=tar_buffer) as tar:\n", + " tar.addfile(info, io.BytesIO(notebook_content))\n", + " \n", + " # Set headers to trigger browser download\n", + " handler.set_header('Content-Disposition',\n", + " 'attachment; filename=\"{}\"'.format(tar_filename))\n", + " handler.set_header('Content-Type', 'application/gzip')\n", + " \n", + " # Return the buffer value as the response\n", + " handler.finish(tar_buffer.getvalue())\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "See [Extending the Notebook](../../extending) for more documentation about writing nbextensions, server extensions, and bundler extensions." + ] } ], "metadata": { @@ -277,5 +372,5 @@ } }, "nbformat": 4, - "nbformat_minor": 0 + "nbformat_minor": 1 } diff --git a/docs/source/extending/bundler_extensions.rst b/docs/source/extending/bundler_extensions.rst new file mode 100644 index 000000000..f4dc9f948 --- /dev/null +++ b/docs/source/extending/bundler_extensions.rst @@ -0,0 +1,142 @@ +Custom bundler extensions +========================= + +The notebook server supports the writing of *bundler extensions* that transform, package, and download/deploy notebook files. As a developer, you need only write a single Python function to implement a bundler. The notebook server automatically generates a *File -> Download as* or *File -> Deploy as* menu item in the notebook front-end to trigger your bundler. + +Here are some examples of what you can implement using bundler extensions: + +* Convert a notebook file to a HTML document and publish it as a post on a blog site +* Create a snapshot of the current notebook environment and bundle that definition plus notebook into a zip download +* `Deploy a notebook as a standalone, interactive dashboard `_ + +To implement a bundler extension, you must do all of the following: + +* Declare bundler extension metadata in your Python package +* Write a `bundle` function that responds to bundle requests +* Instruct your users on how to enable/disable your bundler extension + +The following sections describe these steps in detail. + +Declaring bundler metadata +-------------------------- + +You must provide information about the bundler extension(s) your package provides by implementing a `_jupyter_bundlerextensions_paths` function. This function can reside anywhere in your package so long as it can be imported when enabling the bundler extension. (See :ref:`enabling-bundlers`.) + +.. code:: python + + # in mypackage.hello_bundler + + def _jupyter_bundlerextension_paths(): + """Example "hello world" bundler extension""" + return [{ + 'name': 'hello_bundler', # unique bundler name + 'label': 'Hello Bundler', # human-redable menu item label + 'module_name': 'mypackage.hello_bundler', # module containing bundle() + 'group': 'deploy' # group under 'deploy' or 'download' menu + }] + +Note that the return value is a list. By returning multiple dictionaries in the list, you allow users to enable/disable sets of bundlers all at once. + +Writing the `bundle` function +----------------------------- + +At runtime, a menu item with the given label appears either in the *File -> Deploy as* or *File -> Download as* menu depending on the `group` value in your metadata. When a user clicks the menu item, a new browser tab opens and notebook server invokes a `bundle` function in the `module_name` specified in the metadata. + +You must implement a `bundle` function that matches the signature of the following example: + +.. code:: python + + # in mypackage.hello_bundler + + def bundle(handler, model): + """Transform, convert, bundle, etc. the notebook referenced by the given + model. + + Then issue a Tornado web response using the `handler` to redirect + the user's browser, download a file, show a HTML page, etc. This function + must finish the handler response before returning either explicitly or by + raising an exception. + + Parameters + ---------- + handler : tornado.web.RequestHandler + Handler that serviced the bundle request + model : dict + Notebook model from the configured ContentManager + """ + handler.finish('I bundled {}!'.format(model['path'])) + +Your `bundle` function is free to do whatever it wants with the request and respond in any manner. For example, it may read additional query parameters from the request, issue a redirect to another site, run a local process (e.g., `nbconvert`), make a HTTP request to another service, etc. + +The caller of the `bundle` function is `@tornado.gen.coroutine` decorated and wraps its call with `torando.gen.maybe_future`. This behavior means you may handle the web request synchronously, as in the example above, or asynchronously using `@tornado.gen.coroutine` and `yield`, as in the example below. + +.. code:: python + + from tornado import gen + + @gen.coroutine + def bundle(handler, model): + # simulate a long running IO op (e.g., deploying to a remote host) + yield gen.sleep(10) + + # now respond + handler.finish('I spent 10 seconds bundling {}!'.format(model['path'])) + +You should prefer the second, asynchronous approach when your bundle operation is long-running and would otherwise block the notebook server main loop if handled synchronously. + +For more details about the data flow from menu item click to bundle function invocation, see :ref:`bundler-details`. + +.. _enabling-bundlers: + +Enabling/disabling bundler extensions +------------------------------------- + +The notebook server includes a command line interface (CLI) for enabling and disabling bundler extensions. + +You should document the basic commands for enabling and disabling your bundler. One possible command for enabling the `hello_bundler` example is the following: + +.. code:: bash + + jupyter bundlerextension enable --py mypackage.hello_bundler --sys-prefix + +The above updates the notebook configuration file in the current conda/virtualenv environment (`--sys-prefix`) with the metadata returned by the `mypackage.hellow_bundler._jupyter_bundlerextension_paths` function. + +The corresponding command to later disable the bundler extension is the following: + +.. code:: bash + + jupyter bundlerextension disable --py mypackage.hello_bundler --sys-prefix + +For more help using the `bundlerextension` subcommand, run the following. + +.. code:: bash + + jupyter bundlerextension --help + +The output describes options for listing enabled bundlers, configuring bundlers for single users, configuring bundlers system-wide, etc. + +Example: IPython Notebook bundle (.zip) +--------------------------------------- + +The `hello_bundler` example in this documentation is simplisitic in the name of brevity. For more meaningful examples, see `notebook/bundler/zip_bundler.py` and `notebook/bundler/tarball_bundler.py`. You can enable them to try them like so: + +.. code:: bash + + jupyter bundlerextension enable --py notebook.bundler.zip_bundler --sys-prefix + jupyter bundlerextension enable --py notebook.bundler.tarball_bundler --sys-prefix + +.. _bundler-details: + +Bundler invocation details +-------------------------- + +Support for bundler extensions comes from Python modules in `notebook/bundler` and JavaScript in `notebook/static/notebook/js/menubar.js`. The flow of data between the various components proceeds roughly as follows: + +1. User opens a notebook document +2. Notebook front-end JavaScript loads notebook configuration +3. Bundler front-end JS creates menu items for all bundler extensions in the config +4. User clicks a bundler menu item +5. JS click handler opens a new browser window/tab to `/bundle/?bundler=` (i.e., a HTTP GET request) +6. Bundle handler validates the notebook path and bundler `name` +7. Bundle handler delegates the request to the `bundle` function in the bundler's `module_name` +8. `bundle` function finishes the HTTP request \ No newline at end of file diff --git a/docs/source/extending/handlers.rst b/docs/source/extending/handlers.rst index 11f8d33f7..119f1e304 100644 --- a/docs/source/extending/handlers.rst +++ b/docs/source/extending/handlers.rst @@ -124,4 +124,4 @@ following: References: 1. `Peter Parente's -Mindtrove `__ +Mindtrove `__ diff --git a/docs/source/extending/index.rst b/docs/source/extending/index.rst index 888efd2f4..4298132e4 100644 --- a/docs/source/extending/index.rst +++ b/docs/source/extending/index.rst @@ -14,3 +14,4 @@ override the notebook's defaults with your own custom behavior. handlers frontend_extensions keymaps + bundler_extensions diff --git a/notebook/bundler/__init__.py b/notebook/bundler/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/notebook/bundler/__main__.py b/notebook/bundler/__main__.py new file mode 100644 index 000000000..cde186dbb --- /dev/null +++ b/notebook/bundler/__main__.py @@ -0,0 +1,7 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +from .bundlerextensions import main + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/notebook/bundler/bundlerextensions.py b/notebook/bundler/bundlerextensions.py new file mode 100644 index 000000000..b77172afb --- /dev/null +++ b/notebook/bundler/bundlerextensions.py @@ -0,0 +1,308 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import sys +import os + +from ..nbextensions import (BaseNBExtensionApp, _get_config_dir, + GREEN_ENABLED, RED_DISABLED) +from .._version import __version__ + +from jupyter_core.paths import jupyter_config_path + +from traitlets.config.manager import BaseJSONConfigManager +from traitlets.utils.importstring import import_item +from traitlets import Bool + +BUNDLER_SECTION = "notebook" +BUNDLER_SUBSECTION = "bundlerextensions" + +def _get_bundler_metadata(module): + """Gets the list of bundlers associated with a Python package. + + Returns a tuple of (the module, [{ + 'name': 'unique name of the bundler', + 'label': 'file menu item label for the bundler', + 'module_name': 'dotted package/module name containing the bundler', + 'group': 'download or deploy parent menu item' + }]) + + Parameters + ---------- + + module : str + Importable Python module exposing the + magic-named `_jupyter_bundlerextension_paths` function + """ + m = import_item(module) + if not hasattr(m, '_jupyter_bundlerextension_paths'): + raise KeyError('The Python module {} does not contain a valid bundlerextension'.format(module)) + bundlers = m._jupyter_bundlerextension_paths() + return m, bundlers + +def _set_bundler_state(name, label, module_name, group, state, + user=True, sys_prefix=False, logger=None): + """Set whether a bundler is enabled or disabled. + + Returns True if the final state is the one requested. + + Parameters + ---------- + name : string + Unique name of the bundler + label : string + Human-readable label for the bundler menu item in the notebook UI + module_name : string + Dotted module/package name containing the bundler + group : string + 'download' or 'deploy' indicating the parent menu containing the label + state : bool + The state in which to leave the extension + user : bool [default: True] + Whether to update the user's .jupyter/nbconfig directory + sys_prefix : bool [default: False] + Whether to update the sys.prefix, i.e. environment. Will override + `user`. + logger : Jupyter logger [optional] + Logger instance to use + """ + user = False if sys_prefix else user + config_dir = os.path.join( + _get_config_dir(user=user, sys_prefix=sys_prefix), 'nbconfig') + cm = BaseJSONConfigManager(config_dir=config_dir) + + if logger: + logger.info("{} {} bundler {}...".format( + "Enabling" if state else "Disabling", + name, + module_name + )) + + if state: + cm.update(BUNDLER_SECTION, { + BUNDLER_SUBSECTION: { + name: { + "label": label, + "module_name": module_name, + "group" : group + } + } + }) + else: + cm.update(BUNDLER_SECTION, { + BUNDLER_SUBSECTION: { + name: None + } + }) + + return (cm.get(BUNDLER_SECTION) + .get(BUNDLER_SUBSECTION, {}) + .get(name) is not None) == state + + +def _set_bundler_state_python(state, module, user, sys_prefix, logger=None): + """Enables or disables bundlers defined in a Python package. + + Returns a list of whether the state was achieved for each bundler. + + Parameters + ---------- + state : Bool + Whether the extensions should be enabled + module : str + Importable Python module exposing the + magic-named `_jupyter_bundlerextension_paths` function + user : bool + Whether to enable in the user's nbconfig directory. + sys_prefix : bool + Enable/disable in the sys.prefix, i.e. environment + logger : Jupyter logger [optional] + Logger instance to use + """ + m, bundlers = _get_bundler_metadata(module) + return [_set_bundler_state(name=bundler["name"], + label=bundler["label"], + module_name=bundler["module_name"], + group=bundler["group"], + state=state, + user=user, sys_prefix=sys_prefix, + logger=logger) + for bundler in bundlers] + +def enable_bundler_python(module, user=True, sys_prefix=False, logger=None): + """Enables bundlers defined in a Python package. + + Returns whether each bundle defined in the packaged was enabled or not. + + Parameters + ---------- + module : str + Importable Python module exposing the + magic-named `_jupyter_bundlerextension_paths` function + user : bool [default: True] + Whether to enable in the user's nbconfig directory. + sys_prefix : bool [default: False] + Whether to enable in the sys.prefix, i.e. environment. Will override + `user` + logger : Jupyter logger [optional] + Logger instance to use + """ + return _set_bundler_state_python(True, module, user, sys_prefix, + logger=logger) + +def disable_bundler_python(module, user=True, sys_prefix=False, logger=None): + """Disables bundlers defined in a Python package. + + Returns whether each bundle defined in the packaged was enabled or not. + + Parameters + ---------- + module : str + Importable Python module exposing the + magic-named `_jupyter_bundlerextension_paths` function + user : bool [default: True] + Whether to enable in the user's nbconfig directory. + sys_prefix : bool [default: False] + Whether to enable in the sys.prefix, i.e. environment. Will override + `user` + logger : Jupyter logger [optional] + Logger instance to use + """ + return _set_bundler_state_python(False, module, user, sys_prefix, + logger=logger) + +class ToggleBundlerExtensionApp(BaseNBExtensionApp): + """A base class for apps that enable/disable bundlerextensions""" + name = "jupyter bundlerextension enable/disable" + version = __version__ + description = "Enable/disable a bundlerextension in configuration." + + user = Bool(True, config=True, help="Apply the configuration only for the current user (default)") + + _toggle_value = None + + def _config_file_name_default(self): + """The default config file name.""" + return 'jupyter_notebook_config' + + def toggle_bundler_python(self, module): + """Toggle some extensions in an importable Python module. + + Returns a list of booleans indicating whether the state was changed as + requested. + + Parameters + ---------- + module : str + Importable Python module exposing the + magic-named `_jupyter_bundlerextension_paths` function + """ + toggle = (enable_bundler_python if self._toggle_value + else disable_bundler_python) + return toggle(module, + user=self.user, + sys_prefix=self.sys_prefix, + logger=self.log) + + def start(self): + if not self.extra_args: + sys.exit('Please specify an bundlerextension/package to enable or disable') + elif len(self.extra_args) > 1: + sys.exit('Please specify one bundlerextension/package at a time') + if self.python: + self.toggle_bundler_python(self.extra_args[0]) + else: + raise NotImplementedError('Cannot install bundlers from non-Python packages') + +class EnableBundlerExtensionApp(ToggleBundlerExtensionApp): + """An App that enables bundlerextensions""" + name = "jupyter bundlerextension enable" + description = """ + Enable a bundlerextension in frontend configuration. + + Usage + jupyter bundlerextension enable [--system|--sys-prefix] + """ + _toggle_value = True + +class DisableBundlerExtensionApp(ToggleBundlerExtensionApp): + """An App that disables bundlerextensions""" + name = "jupyter bundlerextension disable" + description = """ + Disable a bundlerextension in frontend configuration. + + Usage + jupyter bundlerextension disable [--system|--sys-prefix] + """ + _toggle_value = None + + +class ListBundlerExtensionApp(BaseNBExtensionApp): + """An App that lists and validates nbextensions""" + name = "jupyter nbextension list" + version = __version__ + description = "List all nbextensions known by the configuration system" + + def list_nbextensions(self): + """List all the nbextensions""" + config_dirs = [os.path.join(p, 'nbconfig') for p in jupyter_config_path()] + + print("Known bundlerextensions:") + + for config_dir in config_dirs: + head = u' config dir: {}'.format(config_dir) + head_shown = False + + cm = BaseJSONConfigManager(parent=self, config_dir=config_dir) + data = cm.get('notebook') + if 'bundlerextensions' in data: + if not head_shown: + # only show heading if there is an nbextension here + print(head) + head_shown = True + + for bundler_id, info in data['bundlerextensions'].items(): + label = info.get('label') + module = info.get('module_name') + if label is None or module is None: + msg = u' {} {}'.format(bundler_id, RED_DISABLED) + else: + msg = u' "{}" from {} {}'.format( + label, module, GREEN_ENABLED + ) + print(msg) + + def start(self): + """Perform the App's functions as configured""" + self.list_nbextensions() + + +class BundlerExtensionApp(BaseNBExtensionApp): + """Base jupyter bundlerextension command entry point""" + name = "jupyter bundlerextension" + version = __version__ + description = "Work with Jupyter bundler extensions" + examples = """ +jupyter bundlerextension list # list all configured bundlers +jupyter bundlerextension enable --py # enable all bundlers in a Python package +jupyter bundlerextension disable --py # disable all bundlers in a Python package +""" + + subcommands = dict( + enable=(EnableBundlerExtensionApp, "Enable a bundler extension"), + disable=(DisableBundlerExtensionApp, "Disable a bundler extension"), + list=(ListBundlerExtensionApp, "List bundler extensions") + ) + + def start(self): + """Perform the App's functions as configured""" + super(BundlerExtensionApp, self).start() + + # The above should have called a subcommand and raised NoStart; if we + # get here, it didn't, so we should self.log.info a message. + subcmds = ", ".join(sorted(self.subcommands)) + sys.exit("Please supply at least one subcommand: %s" % subcmds) + +main = BundlerExtensionApp.launch_instance + +if __name__ == '__main__': + main() diff --git a/notebook/bundler/handlers.py b/notebook/bundler/handlers.py new file mode 100644 index 000000000..4734372ac --- /dev/null +++ b/notebook/bundler/handlers.py @@ -0,0 +1,83 @@ +"""Tornado handler for bundling notebooks.""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +from . import tools +from notebook.utils import url2path +from notebook.base.handlers import IPythonHandler +from notebook.services.config import ConfigManager +from ipython_genutils.importstring import import_item +from tornado import web, gen + + +class BundlerHandler(IPythonHandler): + def initialize(self): + """Make tools module available on the handler instance for compatibility + with existing bundler API and ease of reference.""" + self.tools = tools + + def get_bundler(self, bundler_id): + """ + Get bundler metadata from config given a bundler ID. + + Parameters + ---------- + bundler_id: str + Unique bundler ID within the notebook/bundlerextensions config section + + Returns + ------- + dict + Bundler metadata with label, group, and module_name attributes + + + Raises + ------ + KeyError + If the bundler ID is unknown + """ + cm = ConfigManager() + return cm.get('notebook').get('bundlerextensions', {})[bundler_id] + + @web.authenticated + @gen.coroutine + def get(self, path): + """Bundle the given notebook. + + Parameters + ---------- + path: str + Path to the notebook (path parameter) + bundler: str + Bundler ID to use (query parameter) + """ + bundler_id = self.get_query_argument('bundler') + model = self.contents_manager.get(path=url2path(path)) + + try: + bundler = self.get_bundler(bundler_id) + except KeyError: + raise web.HTTPError(400, 'Bundler %s not enabled' % bundler_id) + + module_name = bundler['module_name'] + try: + # no-op in python3, decode error in python2 + module_name = str(module_name) + except UnicodeEncodeError: + # Encode unicode as utf-8 in python2 else import_item fails + module_name = module_name.encode('utf-8') + + try: + bundler_mod = import_item(module_name) + except ImportError: + raise web.HTTPError(500, 'Could not import bundler %s ' % bundler_id) + + # Let the bundler respond in any way it sees fit and assume it will + # finish the request + yield gen.maybe_future(bundler_mod.bundle(self, model)) + +_bundler_id_regex = r'(?P[A-Za-z0-9_]+)' + +default_handlers = [ + (r"/bundle/(.*)", BundlerHandler) +] diff --git a/notebook/bundler/tarball_bundler.py b/notebook/bundler/tarball_bundler.py new file mode 100644 index 000000000..e513dbf3b --- /dev/null +++ b/notebook/bundler/tarball_bundler.py @@ -0,0 +1,48 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import os +import io +import tarfile +import nbformat + +def _jupyter_bundlerextension_paths(): + """Metadata for notebook bundlerextension""" + return [{ + # unique bundler name + "name": "tarball_bundler", + # module containing bundle function + "module_name": "notebook.bundler.tarball_bundler", + # human-redable menu item label + "label" : "Notebook Tarball (tar.gz)", + # group under 'deploy' or 'download' menu + "group" : "download", + }] + +def bundle(handler, model): + """Create a compressed tarball containing the notebook document. + + Parameters + ---------- + handler : tornado.web.RequestHandler + Handler that serviced the bundle request + model : dict + Notebook model from the configured ContentManager + """ + notebook_filename = model['name'] + notebook_content = nbformat.writes(model['content']).encode('utf-8') + notebook_name = os.path.splitext(notebook_filename)[0] + tar_filename = '{}.tar.gz'.format(notebook_name) + + info = tarfile.TarInfo(notebook_filename) + info.size = len(notebook_content) + + with io.BytesIO() as tar_buffer: + with tarfile.open(tar_filename, "w:gz", fileobj=tar_buffer) as tar: + tar.addfile(info, io.BytesIO(notebook_content)) + + handler.set_header('Content-Disposition', + 'attachment; filename="{}"'.format(tar_filename)) + handler.set_header('Content-Type', 'application/gzip') + + # Return the buffer value as the response + handler.finish(tar_buffer.getvalue()) diff --git a/notebook/bundler/tests/__init__.py b/notebook/bundler/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/notebook/bundler/tests/resources/another_subdir/test_file.txt b/notebook/bundler/tests/resources/another_subdir/test_file.txt new file mode 100644 index 000000000..597cd83d4 --- /dev/null +++ b/notebook/bundler/tests/resources/another_subdir/test_file.txt @@ -0,0 +1 @@ +Used to test globbing. \ No newline at end of file diff --git a/notebook/bundler/tests/resources/empty.ipynb b/notebook/bundler/tests/resources/empty.ipynb new file mode 100644 index 000000000..bbdd6febf --- /dev/null +++ b/notebook/bundler/tests/resources/empty.ipynb @@ -0,0 +1,6 @@ +{ + "nbformat_minor": 0, + "cells": [], + "nbformat": 4, + "metadata": {} +} \ No newline at end of file diff --git a/notebook/bundler/tests/resources/subdir/subsubdir/.gitkeep b/notebook/bundler/tests/resources/subdir/subsubdir/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/notebook/bundler/tests/resources/subdir/test_file.txt b/notebook/bundler/tests/resources/subdir/test_file.txt new file mode 100644 index 000000000..597cd83d4 --- /dev/null +++ b/notebook/bundler/tests/resources/subdir/test_file.txt @@ -0,0 +1 @@ +Used to test globbing. \ No newline at end of file diff --git a/notebook/bundler/tests/test_bundler_api.py b/notebook/bundler/tests/test_bundler_api.py new file mode 100644 index 000000000..542044b44 --- /dev/null +++ b/notebook/bundler/tests/test_bundler_api.py @@ -0,0 +1,84 @@ +"""Test the bundlers API.""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import io +import requests +from os.path import join as pjoin + +from notebook.utils import url_path_join +from notebook.tests.launchnotebook import NotebookTestBase +from nbformat import write +from nbformat.v4 import ( + new_notebook, new_markdown_cell, new_code_cell, new_output, +) + +try: + from unittest.mock import patch +except ImportError: + from mock import patch # py3 + +def bundle(handler, model): + """Bundler test stub. Echo the notebook path.""" + handler.finish(model['path']) + +class BundleAPITest(NotebookTestBase): + """Test the bundlers web service API""" + @classmethod + def setup_class(cls): + """Make a test notebook. Borrowed from nbconvert test. Assumes the class + teardown will clean it up in the end.""" + super(BundleAPITest, cls).setup_class() + nbdir = cls.notebook_dir.name + + nb = new_notebook() + + nb.cells.append(new_markdown_cell(u'Created by test')) + cc1 = new_code_cell(source=u'print(2*6)') + cc1.outputs.append(new_output(output_type="stream", text=u'12')) + nb.cells.append(cc1) + + with io.open(pjoin(nbdir, 'testnb.ipynb'), 'w', + encoding='utf-8') as f: + write(nb, f, version=4) + + def test_missing_bundler_arg(self): + """Should respond with 400 error about missing bundler arg""" + resp = requests.get(url_path_join(self.base_url(), 'bundle', 'fake.ipynb')) + self.assertEqual(resp.status_code, 400) + self.assertIn('Missing argument bundler', resp.text) + + def test_notebook_not_found(self): + """Shoudl respond with 404 error about missing notebook""" + resp = requests.get(url_path_join(self.base_url(), 'bundle', 'fake.ipynb'), + params={'bundler': 'fake_bundler'}) + self.assertEqual(resp.status_code, 404) + self.assertIn('Not Found', resp.text) + + def test_bundler_not_enabled(self): + """Should respond with 400 error about disabled bundler""" + resp = requests.get(url_path_join(self.base_url(), 'bundle', 'testnb.ipynb'), + params={'bundler': 'fake_bundler'}) + self.assertEqual(resp.status_code, 400) + self.assertIn('Bundler fake_bundler not enabled', resp.text) + + def test_bundler_import_error(self): + """Should respond with 500 error about failure to load bundler module""" + with patch('notebook.bundler.handlers.BundlerHandler.get_bundler') as mock: + mock.return_value = {'module_name': 'fake_module'} + resp = requests.get(url_path_join(self.base_url(), 'bundle', 'testnb.ipynb'), + params={'bundler': 'fake_bundler'}) + mock.assert_called_with('fake_bundler') + self.assertEqual(resp.status_code, 500) + self.assertIn('Could not import bundler fake_bundler', resp.text) + + def test_bundler_invoke(self): + """Should respond with 200 and output from test bundler stub""" + with patch('notebook.bundler.handlers.BundlerHandler.get_bundler') as mock: + mock.return_value = {'module_name': 'notebook.bundler.tests.test_bundler_api'} + resp = requests.get(url_path_join(self.base_url(), 'bundle', 'testnb.ipynb'), + params={'bundler': 'stub_bundler'}) + mock.assert_called_with('stub_bundler') + self.assertEqual(resp.status_code, 200) + self.assertIn('testnb.ipynb', resp.text) \ No newline at end of file diff --git a/notebook/bundler/tests/test_bundler_tools.py b/notebook/bundler/tests/test_bundler_tools.py new file mode 100644 index 000000000..0c93c440c --- /dev/null +++ b/notebook/bundler/tests/test_bundler_tools.py @@ -0,0 +1,124 @@ +"""Test the bundler tools.""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import unittest +import os +import shutil +import tempfile +import notebook.bundler.tools as tools + +HERE = os.path.abspath(os.path.dirname(__file__)) + +class TestBundlerTools(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_get_no_cell_references(self): + '''Should find no references in a regular HTML comment.''' + no_references = tools.get_cell_reference_patterns({'source':'''!<-- +a +b +c +-->''', 'cell_type':'markdown'}) + self.assertEqual(len(no_references), 0) + + def test_get_cell_reference_patterns_comment_multiline(self): + '''Should find two references and ignore a comment within an HTML comment.''' + cell = {'cell_type':'markdown', 'source':''''''} + references = tools.get_cell_reference_patterns(cell) + self.assertTrue('a' in references and 'b/' in references, str(references)) + self.assertEqual(len(references), 2, str(references)) + + def test_get_cell_reference_patterns_comment_trailing_filename(self): + '''Should find three references within an HTML comment.''' + cell = {'cell_type':'markdown', 'source':''''''} + references = tools.get_cell_reference_patterns(cell) + self.assertTrue('a' in references and 'b/' in references and 'c' in references, str(references)) + self.assertEqual(len(references), 3, str(references)) + + def test_get_cell_reference_patterns_precode(self): + '''Should find no references in a fenced code block in a *code* cell.''' + self.assertTrue(tools.get_cell_reference_patterns) + no_references = tools.get_cell_reference_patterns({'source':'''``` +foo +bar +baz +``` +''', 'cell_type':'code'}) + self.assertEqual(len(no_references), 0) + + def test_get_cell_reference_patterns_precode_mdcomment(self): + '''Should find two references and ignore a comment in a fenced code block.''' + cell = {'cell_type':'markdown', 'source':'''``` +a +b/ +#comment +```'''} + references = tools.get_cell_reference_patterns(cell) + self.assertTrue('a' in references and 'b/' in references, str(references)) + self.assertEqual(len(references), 2, str(references)) + + def test_get_cell_reference_patterns_precode_backticks(self): + '''Should find three references in a fenced code block.''' + cell = {'cell_type':'markdown', 'source':'''```c +a +b/ +#comment +```'''} + references = tools.get_cell_reference_patterns(cell) + self.assertTrue('a' in references and 'b/' in references and 'c' in references, str(references)) + self.assertEqual(len(references), 3, str(references)) + + def test_glob_dir(self): + '''Should expand to single file in the resources/ subfolder.''' + self.assertIn('resources/empty.ipynb', + tools.expand_references(HERE, ['resources/empty.ipynb'])) + + def test_glob_subdir(self): + '''Should expand to all files in the resources/ subfolder.''' + self.assertIn('resources/empty.ipynb', + tools.expand_references(HERE, ['resources/'])) + + def test_glob_splat(self): + '''Should expand to all contents under this test/ directory.''' + globs = tools.expand_references(HERE, ['*']) + self.assertIn('test_bundler_tools.py', globs, globs) + self.assertIn('resources', globs, globs) + + def test_glob_splatsplat_in_middle(self): + '''Should expand to test_file.txt deep under this test/ directory.''' + globs = tools.expand_references(HERE, ['resources/**/test_file.txt']) + self.assertIn('resources/subdir/test_file.txt', globs, globs) + + def test_glob_splatsplat_trailing(self): + '''Should expand to all descendants of this test/ directory.''' + globs = tools.expand_references(HERE, ['resources/**']) + self.assertIn('resources/empty.ipynb', globs, globs) + self.assertIn('resources/subdir/test_file.txt', globs, globs) + + def test_glob_splatsplat_leading(self): + '''Should expand to test_file.txt under any path.''' + globs = tools.expand_references(HERE, ['**/test_file.txt']) + self.assertIn('resources/subdir/test_file.txt', globs, globs) + self.assertIn('resources/another_subdir/test_file.txt', globs, globs) + + def test_copy_filelist(self): + '''Should copy select files from source to destination''' + globs = tools.expand_references(HERE, ['**/test_file.txt']) + tools.copy_filelist(HERE, self.tmp, globs) + self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'resources/subdir/test_file.txt'))) + self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'resources/another_subdir/test_file.txt'))) + self.assertFalse(os.path.isfile(os.path.join(self.tmp, 'resources/empty.ipynb'))) diff --git a/notebook/bundler/tests/test_bundlerextension.py b/notebook/bundler/tests/test_bundlerextension.py new file mode 100644 index 000000000..b50966f0c --- /dev/null +++ b/notebook/bundler/tests/test_bundlerextension.py @@ -0,0 +1,76 @@ +"""Test the bundlerextension CLI.""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import os +import shutil +import unittest + +try: + from unittest.mock import patch +except ImportError: + from mock import patch # py2 + +from ipython_genutils.tempdir import TemporaryDirectory +from ipython_genutils import py3compat + +from traitlets.config.manager import BaseJSONConfigManager +from traitlets.tests.utils import check_help_all_output + +import notebook.nbextensions as nbextensions +from ..bundlerextensions import (_get_config_dir, enable_bundler_python, + disable_bundler_python) + +def test_help_output(): + check_help_all_output('notebook.bundler.bundlerextensions') + check_help_all_output('notebook.bundler.bundlerextensions', ['enable']) + check_help_all_output('notebook.bundler.bundlerextensions', ['disable']) + +class TestBundlerExtensionCLI(unittest.TestCase): + """Tests the bundlerextension CLI against the example zip_bundler.""" + def setUp(self): + """Build an isolated config environment.""" + td = TemporaryDirectory() + + self.test_dir = py3compat.cast_unicode(td.name) + self.data_dir = os.path.join(self.test_dir, 'data') + self.config_dir = os.path.join(self.test_dir, 'config') + self.system_data_dir = os.path.join(self.test_dir, 'system_data') + self.system_path = [self.system_data_dir] + + # Use temp directory, not real user or system config paths + self.patch_env = patch.dict('os.environ', { + 'JUPYTER_CONFIG_DIR': self.config_dir, + 'JUPYTER_DATA_DIR': self.data_dir, + }) + self.patch_env.start() + self.patch_system_path = patch.object(nbextensions, + 'SYSTEM_JUPYTER_PATH', self.system_path) + self.patch_system_path.start() + + def tearDown(self): + """Remove the test config environment.""" + shutil.rmtree(self.test_dir, ignore_errors=True) + self.patch_env.stop() + self.patch_system_path.stop() + + def test_enable(self): + """Should add the bundler to the notebook configuration.""" + enable_bundler_python('notebook.bundler.zip_bundler') + + config_dir = os.path.join(_get_config_dir(user=True), 'nbconfig') + cm = BaseJSONConfigManager(config_dir=config_dir) + bundlers = cm.get('notebook').get('bundlerextensions', {}) + self.assertEqual(len(bundlers), 1) + self.assertIn('notebook_zip_download', bundlers) + + def test_disable(self): + """Should remove the bundler from the notebook configuration.""" + self.test_enable() + disable_bundler_python('notebook.bundler.zip_bundler') + + config_dir = os.path.join(_get_config_dir(user=True), 'nbconfig') + cm = BaseJSONConfigManager(config_dir=config_dir) + bundlers = cm.get('notebook').get('bundlerextensions', {}) + self.assertEqual(len(bundlers), 0) diff --git a/notebook/bundler/tools.py b/notebook/bundler/tools.py new file mode 100644 index 000000000..82fbc67e0 --- /dev/null +++ b/notebook/bundler/tools.py @@ -0,0 +1,218 @@ +"""Set of common tools to aid bundler implementations.""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import os +import shutil +import errno +import nbformat +import fnmatch +import glob + +def get_file_references(abs_nb_path, version): + """Gets a list of files referenced either in Markdown fenced code blocks + or in HTML comments from the notebook. Expands patterns expressed in + gitignore syntax (https://git-scm.com/docs/gitignore). Returns the + fully expanded list of filenames relative to the notebook dirname. + + Parameters + ---------- + abs_nb_path: str + Absolute path of the notebook on disk + version: int + Version of the notebook document format to use + + Returns + ------- + list + Filename strings relative to the notebook path + """ + ref_patterns = get_reference_patterns(abs_nb_path, version) + expanded = expand_references(os.path.dirname(abs_nb_path), ref_patterns) + return expanded + +def get_reference_patterns(abs_nb_path, version): + """Gets a list of reference patterns either in Markdown fenced code blocks + or in HTML comments from the notebook. + + Parameters + ---------- + abs_nb_path: str + Absolute path of the notebook on disk + version: int + Version of the notebook document format to use + + Returns + ------- + list + Pattern strings from the notebook + """ + notebook = nbformat.read(abs_nb_path, version) + referenced_list = [] + for cell in notebook.cells: + references = get_cell_reference_patterns(cell) + if references: + referenced_list = referenced_list + references + return referenced_list + +def get_cell_reference_patterns(cell): + ''' + Retrieves the list of references from a single notebook cell. Looks for + fenced code blocks or HTML comments in Markdown cells, e.g., + + ``` + some.csv + foo/ + !foo/bar + ``` + + or + + + + Parameters + ---------- + cell: dict + Notebook cell object + + Returns + ------- + list + Reference patterns found in the cell + ''' + referenced = [] + # invisible after execution: unrendered HTML comment + if cell.get('cell_type').startswith('markdown') and cell.get('source').startswith(''): + break + # Trying to go out of the current directory leads to + # trouble when deploying + if line.find('../') < 0 and not line.startswith('#'): + referenced.append(line) + # visible after execution: rendered as a code element within a pre element + elif cell.get('cell_type').startswith('markdown') and cell.get('source').find('```') >= 0: + source = cell.get('source') + offset = source.find('```') + lines = source[offset + len('```'):].splitlines() + for line in lines: + if line.startswith('```'): + break + # Trying to go out of the current directory leads to + # trouble when deploying + if line.find('../') < 0 and not line.startswith('#'): + referenced.append(line) + + # Clean out blank references + return [ref for ref in referenced if ref.strip()] + +def expand_references(root_path, references): + """Expands a set of reference patterns by evaluating them against the + given root directory. Expansions are performed against patterns + expressed in the same manner as in gitignore + (https://git-scm.com/docs/gitignore). + + NOTE: Temporarily changes the current working directory when called. + + Parameters + ---------- + root_path: str + Assumed root directory for the patterns + references: list + Reference patterns from get_reference_patterns + + Returns + ------- + list + Filename strings relative to the root path + """ + globbed = [] + negations = [] + must_walk = [] + for pattern in references: + if pattern and pattern.find('/') < 0: + # simple shell glob + cwd = os.getcwd() + os.chdir(root_path) + if pattern.startswith('!'): + negations = negations + glob.glob(pattern[1:]) + else: + globbed = globbed + glob.glob(pattern) + os.chdir(cwd) + elif pattern: + must_walk.append(pattern) + + for pattern in must_walk: + pattern_is_negation = pattern.startswith('!') + if pattern_is_negation: + testpattern = pattern[1:] + else: + testpattern = pattern + for root, _, filenames in os.walk(root_path): + for filename in filenames: + joined = os.path.join(root[len(root_path) + 1:], filename) + if testpattern.endswith('/'): + if joined.startswith(testpattern): + if pattern_is_negation: + negations.append(joined) + else: + globbed.append(joined) + elif testpattern.find('**') >= 0: + # path wildcard + ends = testpattern.split('**') + if len(ends) == 2: + if joined.startswith(ends[0]) and joined.endswith(ends[1]): + if pattern_is_negation: + negations.append(joined) + else: + globbed.append(joined) + else: + # segments should be respected + if fnmatch.fnmatch(joined, testpattern): + if pattern_is_negation: + negations.append(joined) + else: + globbed.append(joined) + + for negated in negations: + try: + globbed.remove(negated) + except ValueError as err: + pass + return set(globbed) + +def copy_filelist(src, dst, src_relative_filenames): + """Copies the given list of files, relative to src, into dst, creating + directories along the way as needed and ignore existence errors. + Skips any files that do not exist. Does not create empty directories + from src in dst. + + Parameters + ---------- + src: str + Root of the source directory + dst: str + Root of the destination directory + src_relative_filenames: list + Filenames relative to src + """ + for filename in src_relative_filenames: + # Only consider the file if it exists in src + if os.path.isfile(os.path.join(src, filename)): + parent_relative = os.path.dirname(filename) + if parent_relative: + # Make sure the parent directory exists + parent_dst = os.path.join(dst, parent_relative) + try: + os.makedirs(parent_dst) + except OSError as exc: + if exc.errno == errno.EEXIST: + pass + else: + raise exc + shutil.copy2(os.path.join(src, filename), os.path.join(dst, filename)) \ No newline at end of file diff --git a/notebook/bundler/zip_bundler.py b/notebook/bundler/zip_bundler.py new file mode 100644 index 000000000..2fd228a0d --- /dev/null +++ b/notebook/bundler/zip_bundler.py @@ -0,0 +1,60 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import os +import io +import zipfile +import notebook.bundler.tools as tools + +def _jupyter_bundlerextension_paths(): + """Metadata for notebook bundlerextension""" + return [{ + 'name': 'notebook_zip_download', + 'label': 'IPython Notebook bundle (.zip)', + 'module_name': 'notebook.bundler.zip_bundler', + 'group': 'download' + }] + +def bundle(handler, model): + """Create a zip file containing the original notebook and files referenced + from it. Retain the referenced files in paths relative to the notebook. + Return the zip as a file download. + + Assumes the notebook and other files are all on local disk. + + Parameters + ---------- + handler : tornado.web.RequestHandler + Handler that serviced the bundle request + model : dict + Notebook model from the configured ContentManager + """ + abs_nb_path = os.path.join(handler.settings['contents_manager'].root_dir, + model['path']) + notebook_filename = model['name'] + notebook_name = os.path.splitext(notebook_filename)[0] + + # Headers + zip_filename = os.path.splitext(notebook_name)[0] + '.zip' + handler.set_header('Content-Disposition', + 'attachment; filename="%s"' % zip_filename) + handler.set_header('Content-Type', 'application/zip') + + # Get associated files + ref_filenames = tools.get_file_references(abs_nb_path, 4) + + # Prepare the zip file + zip_buffer = io.BytesIO() + zipf = zipfile.ZipFile(zip_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) + zipf.write(abs_nb_path, notebook_filename) + + notebook_dir = os.path.dirname(abs_nb_path) + for nb_relative_filename in ref_filenames: + # Build absolute path to file on disk + abs_fn = os.path.join(notebook_dir, nb_relative_filename) + # Store file under path relative to notebook + zipf.write(abs_fn, nb_relative_filename) + + zipf.close() + + # Return the buffer value as the response + handler.finish(zip_buffer.getvalue()) \ No newline at end of file diff --git a/notebook/notebookapp.py b/notebook/notebookapp.py index 7b7f0d587..c81b05495 100755 --- a/notebook/notebookapp.py +++ b/notebook/notebookapp.py @@ -260,6 +260,7 @@ class NotebookWebApplication(web.Application): handlers.extend(load_handlers('files.handlers')) handlers.extend(load_handlers('notebook.handlers')) handlers.extend(load_handlers('nbconvert.handlers')) + handlers.extend(load_handlers('bundler.handlers')) handlers.extend(load_handlers('kernelspecs.handlers')) handlers.extend(load_handlers('edit.handlers')) handlers.extend(load_handlers('services.api.handlers')) diff --git a/notebook/static/notebook/js/main.js b/notebook/static/notebook/js/main.js index dc6af6edc..de3def780 100644 --- a/notebook/static/notebook/js/main.js +++ b/notebook/static/notebook/js/main.js @@ -141,7 +141,8 @@ require([ events: events, save_widget: save_widget, quick_help: quick_help, - actions: acts}, + actions: acts, + config: config_section}, common_options)); var notification_area = new notificationarea.NotebookNotificationArea( '#notification_area', { diff --git a/notebook/static/notebook/js/menubar.js b/notebook/static/notebook/js/menubar.js index 04984803a..e82ffbe7d 100644 --- a/notebook/static/notebook/js/menubar.js +++ b/notebook/static/notebook/js/menubar.js @@ -29,6 +29,7 @@ define([ * base_url : string * notebook_path : string * notebook_name : string + * config: ConfigSection instance */ options = options || {}; this.base_url = options.base_url || utils.get_body_data("baseUrl"); @@ -40,6 +41,7 @@ define([ this.save_widget = options.save_widget; this.quick_help = options.quick_help; this.actions = options.actions; + this.config = options.config; try { this.tour = new tour.Tour(this.notebook, this.events); @@ -51,6 +53,7 @@ define([ if (this.selector !== undefined) { this.element = $(selector); this.style(); + this.add_bundler_items(); this.bind_events(); } }; @@ -66,6 +69,64 @@ define([ } ); }; + + MenuBar.prototype.add_bundler_items = function() { + var that = this; + this.config.loaded.then(function() { + var bundlers = that.config.data.bundlerextensions; + if(bundlers) { + // Stable sort the keys to ensure menu items don't hop around + var ids = Object.keys(bundlers).sort() + ids.forEach(function(bundler_id) { + var bundler = bundlers[bundler_id]; + var group = that.element.find('#'+bundler.group+'_menu') + + // Validate menu item metadata + if(!group.length) { + console.warn('unknown group', bundler.group, 'for bundler ID', bundler_id, '; skipping'); + return; + } else if(!bundler.label) { + console.warn('no label for bundler ID', bundler_id, '; skipping'); + return; + } + + // Insert menu item into correct group, click handler + group.parent().removeClass('hidden'); + var $li = $('
  • ') + .appendTo(group); + $('') + .attr('href', '#') + .text(bundler.label) + .appendTo($li) + .on('click', that._bundle.bind(that, bundler_id)) + .appendTo($li); + }); + } + }); + }; + + MenuBar.prototype._new_window = function(url) { + var w = window.open('', IPython._target); + if (this.notebook.dirty && this.notebook.writable) { + this.notebook.save_notebook().then(function() { + w.location = url; + }); + } else { + w.location = url; + } + }; + + MenuBar.prototype._bundle = function(bundler_id) { + // Read notebook path and base url here in case they change + var notebook_path = utils.encode_uri_components(this.notebook.notebook_path); + var url = utils.url_path_join( + this.base_url, + 'bundle', + notebook_path + ) + '?bundler=' + utils.encode_uri_components(bundler_id); + + this._new_window(url); + }; MenuBar.prototype._nbconvert = function (format, download) { download = download || false; @@ -77,14 +138,7 @@ define([ notebook_path ) + "?download=" + download.toString(); - var w = window.open('', IPython._target); - if (this.notebook.dirty && this.notebook.writable) { - this.notebook.save_notebook().then(function() { - w.location = url; - }); - } else { - w.location = url; - } + this._new_window(url); }; MenuBar.prototype._size_header = function() { @@ -154,7 +208,6 @@ define([ that._nbconvert('script', true); }); - this.events.on('trust_changed.Notebook', function (event, trusted) { if (trusted) { that.element.find('#trust_notebook') diff --git a/notebook/templates/notebook.html b/notebook/templates/notebook.html index f8e4f3c07..8ac71de66 100644 --- a/notebook/templates/notebook.html +++ b/notebook/templates/notebook.html @@ -103,7 +103,7 @@ data-notebook-path="{{notebook_path | urlencode}}"