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 new file mode 100644 index 0000000000..91fe4c5214 --- /dev/null +++ b/docs/source/examples/Notebook/Distributing Jupyter Extensions as Python Packages.ipynb @@ -0,0 +1,280 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Distributing Jupyter Extensions as Python Packages" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Overview\n", + "### How can the notebook be extended?\n", + "The Jupyter Notebook client and server application are both deeply customizable. Their behavior can be extended by creating, respectively:\n", + "\n", + "- nbextension: a notebook extension\n", + " - a single JS file, or directory of JavaScript, Cascading StyleSheets, etc. that contain at\n", + " minimum a JavaScript module packaged as an\n", + " [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`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Why create a Python package for Jupyter extensions?\n", + "Since it is rare to have a server extension that does not have any frontend components (an nbextension), for convenience and consistency, all these client and server extensions with their assets can be packaged and versioned together as a Python package with a few simple commands. This makes installing the package of extensions easier and less error-prone for the user. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Installation of Jupyter Extensions\n", + "### Install a Python package containing Jupyter Extensions\n", + "There are several ways that you may get a Python package containing Jupyter Extensions. Commonly, you will use a package manager for your system:\n", + "```shell\n", + "pip install helpful_package\n", + "# or\n", + "conda install helpful_package\n", + "# or\n", + "apt-get install helpful_package\n", + "\n", + "# where 'helpful_package' is a Python package containing one or more Jupyter Extensions\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Enable a Server Extension\n", + "\n", + "The simplest case would be to enable a server extension which has no frontend components. \n", + "\n", + "A `pip` user that wants their configuration stored in their home directory would type the following command:\n", + "```shell\n", + "jupyter serverextension enable --py helpful_package\n", + "```\n", + "\n", + "Alternatively, a `virtualenv` or `conda` user can pass `--sys-prefix` which keeps their environment isolated and reproducible. For example:\n", + "```shell\n", + "# Make sure that your virtualenv or conda environment is activated\n", + "[source] activate my-environment\n", + "\n", + "jupyter serverextension enable --py helpful_package --sys-prefix\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Install the nbextension assets" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If a package also has an nbextension with frontend assets that must be available (but not neccessarily enabled by default), install these assets with the following command:\n", + "```shell\n", + "jupyter nbextension install --py helpful_package # or --sys-prefix if using virtualenv or conda\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Enable nbextension assets\n", + "If a package has assets that should be loaded every time a Jupyter app (e.g. lab, notebook, dashboard, terminal) is loaded in the browser, the following command can be used to enable the nbextension:\n", + "```shell\n", + "jupyter nbextension enable --py helpful_package # or --sys-prefix if using virtualenv or conda\n", + "```" + ] + }, + { + "cell_type": "markdown", + "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", + "\n", + "```shell\n", + "jupyter nbextension list\n", + "jupyter serverextension list\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Additional resources on creating and distributing packages \n", + "\n", + "> Of course, in addition to the files listed, there are number of other files one needs to build a proper package. Here are some good resources:\n", + "- [The Hitchhiker's Guide to Packaging](http://the-hitchhikers-guide-to-packaging.readthedocs.org/en/latest/quickstart.html)\n", + "- [Repository Structure and Python](http://www.kennethreitz.org/essays/repository-structure-and-python) by Kenneth Reitz\n", + "\n", + "> How you distribute them, too, is important:\n", + "- [Packaging and Distributing Projects](http://python-packaging-user-guide.readthedocs.org/en/latest/distributing/)\n", + "- [conda: Building packages](http://conda.pydata.org/docs/building/build.html)\n", + "\n", + "> Here are some tools to get you started:\n", + "- [generator-nbextension](https://github.com/Anaconda-Server/generator-nbextension)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example - Server extension" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Creating a Python package with a server extension\n", + "\n", + "Here is an example of a python module which contains a server extension directly on itself. It has this directory structure:\n", + "```\n", + "- setup.py\n", + "- MANIFEST.in\n", + "- my_module/\n", + " - __init__.py\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Defining the server extension\n", + "This example shows that the server extension and its `load_jupyter_server_extension` function are defined in the `__init__.py` file.\n", + "#### `my_module/__init__.py`\n", + "\n", + "```python\n", + "def _jupyter_server_extension_paths():\n", + " return [{\n", + " \"module\": \"my_module\"\n", + " }]\n", + "\n", + "\n", + "def load_jupyter_server_extension(nbapp):\n", + " nbapp.log.info(\"my module enabled!\")\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Install and enable the server extension\n", + "Which a user can install with:\n", + "```\n", + "jupyter serverextension enable --py my_module [--sys-prefix]\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example - Server extension and nbextension" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Creating a Python package with a server extension and nbextension\n", + "Here is another server extension, with a front-end module. It assumes this directory structure:\n", + "\n", + "```\n", + "- setup.py\n", + "- MANIFEST.in\n", + "- my_fancy_module/\n", + " - __init__.py\n", + " - static/\n", + " index.js\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "collapsed": true + }, + "source": [ + "### Defining the server extension and nbextension\n", + "This example again shows that the server extension and its `load_jupyter_server_extension` function are defined in the `__init__.py` file. This time, there is also a function `_jupyter_nbextension_path` for the nbextension.\n", + "\n", + "#### `my_fancy_module/__init__.py`\n", + "\n", + "```python\n", + "def _jupyter_server_extension_paths():\n", + " return [{\n", + " \"module\": \"my_fancy_module\"\n", + " }]\n", + "\n", + "# Jupyter Extension points\n", + "def _jupyter_nbextension_paths():\n", + " return [dict(\n", + " section=\"notebook\",\n", + " # the path is relative to the `my_fancy_module` directory\n", + " src=\"static\",\n", + " # directory in the `nbextension/` namespace\n", + " dest=\"my_fancy_module\",\n", + " # _also_ in the `nbextension/` namespace\n", + " require=\"my_fancy_module/index\")]\n", + "\n", + "def load_jupyter_server_extension(nbapp):\n", + " nbapp.log.info(\"my module enabled!\")\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Install and enable the server extension and nbextension\n", + "\n", + "The user can install and enable the extensions with the following set of commands:\n", + "```\n", + "jupyter nbextension install --py my_fancy_module [--sys-prefix|--user]\n", + "jupyter nbextension enable --py my_fancy_module [--sys-prefix|--system]\n", + "jupyter serverextension enable --py my_fancy_module [--sys-prefix|--system]\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.5.1" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/docs/source/extending/frontend_extensions.rst b/docs/source/extending/frontend_extensions.rst index e23dfdd533..3b55d7f11d 100644 --- a/docs/source/extending/frontend_extensions.rst +++ b/docs/source/extending/frontend_extensions.rst @@ -202,10 +202,12 @@ the prefix. For the action name, the following guidelines should be considered: Installing and enabling extensions ---------------------------------- -You can install your nbextension with the command: +You can install your nbextension with the command:: - jupyter nbextension install path/to/my_extension/ + jupyter nbextension install path/to/my_extension/ [--user|--sys-prefix] +The default installation is system-wide. You can use ``--user`` to do a per-user installation, +or ``--sys-prefix`` to install to Python's prefix (e.g. in a virtual or conda environment). 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`). @@ -214,11 +216,15 @@ 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: +notebook interface to load it. You can do that with another command:: - jupyter nbextension enable my_extension/main + jupyter nbextension enable my_extension/main [--sys-prefix] 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. + +.. versionchanged:: 4.2 + + Added ``--sys-prefix`` argument diff --git a/docs/source/index.rst b/docs/source/index.rst index 08c3ac69e0..b7af3dfa08 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -36,8 +36,9 @@ The Jupyter notebook public_server security frontend_config - extending/index - + examples/Notebook/rstversions/Distributing Jupyter Extensions as Python Packages.rst + extending/index.rst + .. toctree:: :maxdepth: 1 :caption: Developer Documentation diff --git a/notebook/nbextensions.py b/notebook/nbextensions.py index de40733469..9f259daf90 100644 --- a/notebook/nbextensions.py +++ b/notebook/nbextensions.py @@ -11,7 +11,7 @@ import sys import tarfile import zipfile -from os.path import basename, join as pjoin +from os.path import basename, join as pjoin, normpath try: from urllib.parse import urlparse # Py3 @@ -20,63 +20,42 @@ from urlparse import urlparse from urllib import urlretrieve -from jupyter_core.paths import jupyter_data_dir, jupyter_path, SYSTEM_JUPYTER_PATH +from jupyter_core.paths import ( + jupyter_data_dir, jupyter_config_dir, jupyter_config_path, + SYSTEM_JUPYTER_PATH, ENV_JUPYTER_PATH, ENV_CONFIG_PATH, SYSTEM_CONFIG_PATH +) from ipython_genutils.path import ensure_dir_exists from ipython_genutils.py3compat import string_types, cast_unicode_py2 from ipython_genutils.tempdir import TemporaryDirectory from ._version import __version__ -class ArgumentConflict(ValueError): - pass +from traitlets.config.manager import BaseJSONConfigManager +from traitlets.utils.importstring import import_item +from tornado.log import LogFormatter -def _should_copy(src, dest, verbose=1): - """should a file be copied?""" - if not os.path.exists(dest): - return True - if os.stat(src).st_mtime - os.stat(dest).st_mtime > 1e-6: - # we add a fudge factor to work around a bug in python 2.x - # that was fixed in python 3.x: http://bugs.python.org/issue12904 - if verbose >= 2: - print("%s is out of date" % dest) - return True - if verbose >= 2: - print("%s is up to date" % dest) - return False +# Constants for pretty print extension listing function. +# Window doesn't support coloring in the commandline +GREEN_ENABLED = '\033[32m enabled \033[0m' if os.name != 'nt' else 'enabled ' +RED_DISABLED = '\033[31mdisabled\033[0m' if os.name != 'nt' else 'disabled' +DEPRECATED_ARGUMENT = object() -def _maybe_copy(src, dest, verbose=1): - """copy a file if it needs updating""" - if _should_copy(src, dest, verbose): - if verbose >= 1: - print("copying %s -> %s" % (src, dest)) - shutil.copy2(src, dest) +NBCONFIG_SECTIONS = ['common', 'notebook', 'tree', 'edit', 'terminal'] -def _safe_is_tarfile(path): - """safe version of is_tarfile, return False on IOError""" - try: - return tarfile.is_tarfile(path) - except IOError: - return False +GREEN_OK = '\033[32mOK\033[0m' if os.name != 'nt' else 'ok' +RED_X = '\033[31m X\033[0m' if os.name != 'nt' else ' X' +#------------------------------------------------------------------------------ +# Public API +#------------------------------------------------------------------------------ -def _get_nbext_dir(nbextensions_dir=None, user=False, prefix=None): - """Return the nbextension directory specified""" - if sum(map(bool, [user, prefix, nbextensions_dir])) > 1: - raise ArgumentConflict("Cannot specify more than one of user, prefix, or nbextensions_dir.") - if user: - nbext = pjoin(jupyter_data_dir(), u'nbextensions') - else: - if prefix: - nbext = pjoin(prefix, 'share', 'jupyter', 'nbextensions') - elif nbextensions_dir: - nbext = nbextensions_dir - else: - nbext = pjoin(SYSTEM_JUPYTER_PATH[0], 'nbextensions') - return nbext + +class ArgumentConflict(ValueError): + pass -def check_nbextension(files, user=False, prefix=None, nbextensions_dir=None): +def check_nbextension(files, user=False, prefix=None, nbextensions_dir=None, sys_prefix=False): """Check whether nbextension files have been installed Returns True if all files are found, False if any are missing. @@ -87,15 +66,17 @@ def check_nbextension(files, user=False, prefix=None, nbextensions_dir=None): files : list(paths) a list of relative paths within nbextensions. user : bool [default: False] - Whether to check the user's .ipython/nbextensions directory. + Whether to check the user's .jupyter/nbextensions directory. Otherwise check a system-wide install (e.g. /usr/local/share/jupyter/nbextensions). prefix : str [optional] Specify install prefix, if it should differ from default (e.g. /usr/local). Will check prefix/share/jupyter/nbextensions nbextensions_dir : str [optional] Specify absolute path of nbextensions directory explicitly. + sys_prefix : bool [default: False] + Install into the sys.prefix, i.e. environment """ - nbext = _get_nbext_dir(nbextensions_dir, user, prefix) + nbext = _get_nbextension_dir(user=user, sys_prefix=sys_prefix, prefix=prefix, nbextensions_dir=nbextensions_dir) # make sure nbextensions dir exists if not os.path.exists(nbext): return False @@ -107,7 +88,11 @@ def check_nbextension(files, user=False, prefix=None, nbextensions_dir=None): return all(os.path.exists(pjoin(nbext, f)) for f in files) -def install_nbextension(path, overwrite=False, symlink=False, user=False, prefix=None, nbextensions_dir=None, destination=None, verbose=1): +def install_nbextension(path, overwrite=False, symlink=False, + user=False, prefix=None, nbextensions_dir=None, + destination=None, verbose=DEPRECATED_ARGUMENT, + logger=None, sys_prefix=False + ): """Install a Javascript extension for the notebook Stages files and/or directories into the nbextensions directory. @@ -140,11 +125,17 @@ def install_nbextension(path, overwrite=False, symlink=False, user=False, prefix name the nbextension is installed to. For example, if destination is 'foo', then the source file will be installed to 'nbextensions/foo', regardless of the source name. This cannot be specified if an archive is given as the source. - verbose : int [default: 1] - Set verbosity level. The default is 1, where file actions are printed. - set verbose=2 for more output, or verbose=0 for silence. + logger : Jupyter logger [optional] + Logger instance to use """ - nbext = _get_nbext_dir(nbextensions_dir, user, prefix) + if verbose != DEPRECATED_ARGUMENT: + import warnings + warnings.warn("`install_nbextension`'s `verbose` parameter is deprecated, it will have no effects and will be removed in Notebook 5.0", DeprecationWarning) + + # the actual path to which we eventually installed + full_dest = None + + nbext = _get_nbextension_dir(user=user, sys_prefix=sys_prefix, prefix=prefix, nbextensions_dir=nbextensions_dir) # make sure nbextensions dir exists ensure_dir_exists(nbext) @@ -164,18 +155,19 @@ def install_nbextension(path, overwrite=False, symlink=False, user=False, prefix with TemporaryDirectory() as td: filename = urlparse(path).path.split('/')[-1] local_path = os.path.join(td, filename) - if verbose >= 1: - print("downloading %s to %s" % (path, local_path)) + if logger: + logger.info("Downloading: %s -> %s" % (path, local_path)) urlretrieve(path, local_path) # now install from the local copy - install_nbextension(local_path, overwrite=overwrite, symlink=symlink, nbextensions_dir=nbext, destination=destination, verbose=verbose) + full_dest = install_nbextension(local_path, overwrite=overwrite, symlink=symlink, + nbextensions_dir=nbext, destination=destination, logger=logger) elif path.endswith('.zip') or _safe_is_tarfile(path): if symlink: raise ValueError("Cannot symlink from archives") if destination: raise ValueError("Cannot give destination for archives") - if verbose >= 1: - print("extracting %s to %s" % (path, nbext)) + if logger: + logger.info("Extracting: %s -> %s" % (path, nbext)) if path.endswith('.zip'): archive = zipfile.ZipFile(path) @@ -183,14 +175,16 @@ def install_nbextension(path, overwrite=False, symlink=False, user=False, prefix archive = tarfile.open(path) archive.extractall(nbext) archive.close() + # TODO: what to do here + full_dest = None else: if not destination: destination = basename(path) destination = cast_unicode_py2(destination) - full_dest = pjoin(nbext, destination) + full_dest = normpath(pjoin(nbext, destination)) if overwrite and os.path.lexists(full_dest): - if verbose >= 1: - print("removing %s" % full_dest) + if logger: + logger.info("Removing: %s" % full_dest) if os.path.isdir(full_dest) and not os.path.islink(full_dest): shutil.rmtree(full_dest) else: @@ -199,60 +193,460 @@ def install_nbextension(path, overwrite=False, symlink=False, user=False, prefix if symlink: path = os.path.abspath(path) if not os.path.exists(full_dest): - if verbose >= 1: - print("symlink %s -> %s" % (full_dest, path)) + if logger: + logger.info("Symlinking: %s -> %s" % (full_dest, path)) os.symlink(path, full_dest) elif os.path.isdir(path): path = pjoin(os.path.abspath(path), '') # end in path separator for parent, dirs, files in os.walk(path): dest_dir = pjoin(full_dest, parent[len(path):]) if not os.path.exists(dest_dir): - if verbose >= 2: - print("making directory %s" % dest_dir) + if logger: + logger.info("Making directory: %s" % dest_dir) os.makedirs(dest_dir) for file in files: src = pjoin(parent, file) - # print("%r, %r" % (dest_dir, file)) dest_file = pjoin(dest_dir, file) - _maybe_copy(src, dest_file, verbose) + _maybe_copy(src, dest_file, logger=logger) else: src = path - _maybe_copy(src, full_dest, verbose) + _maybe_copy(src, full_dest, logger=logger) + + return full_dest + + +def install_nbextension_python(module, overwrite=False, symlink=False, + user=False, sys_prefix=False, prefix=None, nbextensions_dir=None, logger=None): + """Install an nbextension bundled in a Python package. + + Returns a list of installed/updated directories. + + See install_nbextension for parameter information.""" + m, nbexts = _get_nbextension_metadata(module) + base_path = os.path.split(m.__file__)[0] + + full_dests = [] + + for nbext in nbexts: + src = os.path.join(base_path, nbext['src']) + dest = nbext['dest'] + + if logger: + logger.info("Installing %s -> %s" % (src, dest)) + full_dest = install_nbextension( + src, overwrite=overwrite, symlink=symlink, + user=user, sys_prefix=sys_prefix, prefix=prefix, nbextensions_dir=nbextensions_dir, + destination=dest, logger=logger + ) + validate_nbextension_python(nbext, full_dest, logger) + full_dests.append(full_dest) + + return full_dests + + +def uninstall_nbextension(dest, require, user=False, sys_prefix=False, prefix=None, + nbextensions_dir=None, logger=None): + """Uninstall a Javascript extension of the notebook + + Removes staged files and/or directories in the nbextensions directory and + removes the extension from the frontend config. + + Parameters + ---------- + + dest : str + path to file, directory, zip or tarball archive, or URL to install + name the nbextension is installed to. For example, if destination is 'foo', then + the source file will be installed to 'nbextensions/foo', regardless of the source name. + This cannot be specified if an archive is given as the source. + require : str + require.js path used to load the extension + user : bool [default: False] + Whether to install to the user's nbextensions directory. + Otherwise do a system-wide install (e.g. /usr/local/share/jupyter/nbextensions). + prefix : str [optional] + Specify install prefix, if it should differ from default (e.g. /usr/local). + Will install to ``/share/jupyter/nbextensions`` + nbextensions_dir : str [optional] + Specify absolute path of nbextensions directory explicitly. + logger : Jupyter logger [optional] + Logger instance to use + """ + nbext = _get_nbextension_dir(user=user, sys_prefix=sys_prefix, prefix=prefix, nbextensions_dir=nbextensions_dir) + dest = cast_unicode_py2(dest) + full_dest = pjoin(nbext, dest) + if os.path.lexists(full_dest): + if logger: + logger.info("Removing: %s" % full_dest) + if os.path.isdir(full_dest) and not os.path.islink(full_dest): + shutil.rmtree(full_dest) + else: + os.remove(full_dest) + + # Look through all of the config sections making sure that the nbextension + # doesn't exist. + config_dir = os.path.join(_get_config_dir(user=user, sys_prefix=sys_prefix), 'nbconfig') + cm = BaseJSONConfigManager(config_dir=config_dir) + for section in NBCONFIG_SECTIONS: + cm.update(section, {"load_extensions": {require: None}}) + + +def uninstall_nbextension_python(module, + user=False, sys_prefix=False, prefix=None, nbextensions_dir=None, + logger=None): + """Uninstall an nbextension bundled in a Python package. + + See parameters of `install_nbextension_python` + """ + m, nbexts = _get_nbextension_metadata(module) + for nbext in nbexts: + dest = nbext['dest'] + require = nbext['require'] + if logger: + logger.info("Uninstalling {} {}".format(dest, require)) + uninstall_nbextension(dest, require, user=user, sys_prefix=sys_prefix, + prefix=prefix, nbextensions_dir=nbextensions_dir, logger=logger) + + +def _set_nbextension_state(section, require, state, + user=True, sys_prefix=False, logger=None): + """Set whether the section's frontend should require the named nbextension + + Returns True if the final state is the one requested. + + Parameters + ---------- + section : string + The section of the server to change, one of NBCONFIG_SECTIONS + require : string + An importable AMD module inside the nbextensions static path + state : bool + The state in which to leave the extension + user : bool [default: True] + Whether to update the user's .jupyter/nbextensions 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("{} {} extension {}...".format( + "Enabling" if state else "Disabling", + section, + require + )) + cm.update(section, {"load_extensions": {require: state}}) + + validate_nbextension(require, logger=logger) + + return cm.get(section).get(require) == state + + +def _set_nbextension_state_python(state, module, user, sys_prefix, + logger=None): + """Enable or disable some nbextensions stored in a Python package + + Returns a list of whether the state was achieved (i.e. changed, or was + already right) + + Parameters + ---------- + + state : Bool + Whether the extensions should be enabled + module : str + Importable Python module exposing the + magic-named `_jupyter_nbextension_paths` function + user : bool + Whether to enable in the user's nbextensions directory. + sys_prefix : bool + Enable/disable in the sys.prefix, i.e. environment + logger : Jupyter logger [optional] + Logger instance to use + """ + m, nbexts = _get_nbextension_metadata(module) + return [_set_nbextension_state(section=nbext["section"], + require=nbext["require"], + state=state, + user=user, sys_prefix=sys_prefix, + logger=logger) + for nbext in nbexts] + + +def enable_nbextension(section, require, user=True, sys_prefix=False, + logger=None): + """Enable a named nbextension + + Returns True if the final state is the one requested. + + Parameters + ---------- + + section : string + The section of the server to change, one of NBCONFIG_SECTIONS + require : string + An importable AMD module inside the nbextensions static path + user : bool [default: True] + Whether to enable in the user's nbextensions 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_nbextension_state(section=section, require=require, + state=True, + user=user, sys_prefix=sys_prefix, + logger=logger) + + +def disable_nbextension(section, require, user=True, sys_prefix=False, + logger=None): + """Disable a named nbextension + + Returns True if the final state is the one requested. + + Parameters + ---------- + + section : string + The section of the server to change, one of NBCONFIG_SECTIONS + require : string + An importable AMD module inside the nbextensions static path + user : bool [default: True] + Whether to enable in the user's nbextensions 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_nbextension_state(section=section, require=require, + state=False, + user=user, sys_prefix=sys_prefix, + logger=logger) + + +def enable_nbextension_python(module, user=True, sys_prefix=False, + logger=None): + """Enable some nbextensions associated with a Python module. + + Returns a list of whether the state was achieved (i.e. changed, or was + already right) + + Parameters + ---------- + + module : str + Importable Python module exposing the + magic-named `_jupyter_nbextension_paths` function + user : bool [default: True] + Whether to enable in the user's nbextensions 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_nbextension_state_python(True, module, user, sys_prefix, + logger=logger) + + +def disable_nbextension_python(module, user=True, sys_prefix=False, + logger=None): + """Disable some nbextensions associated with a Python module. + + Returns True if the final state is the one requested. + + Parameters + ---------- + + module : str + Importable Python module exposing the + magic-named `_jupyter_nbextension_paths` function + user : bool [default: True] + Whether to enable in the user's nbextensions directory. + sys_prefix : bool [default: False] + Whether to enable in the sys.prefix, i.e. environment + logger : Jupyter logger [optional] + Logger instance to use + """ + return _set_nbextension_state_python(False, module, user, sys_prefix, + logger=logger) + + +def validate_nbextension(require, logger=None): + """Validate a named nbextension. + + Looks across all of the nbextension directories. + + Returns a list of warnings. + + require : str + require.js path used to load the extension + logger : Jupyter logger [optional] + Logger instance to use + """ + warnings = [] + infos = [] + + js_exists = False + for exts in _nbextension_dirs(): + # Does the Javascript entrypoint actually exist on disk? + js = u"{}.js".format(os.path.join(exts, *require.split("/"))) + js_exists = os.path.exists(js) + if js_exists: + break + + require_tmpl = u" - require? {} {}" + if js_exists: + infos.append(require_tmpl.format(GREEN_OK, require)) + else: + warnings.append(require_tmpl.format(RED_X, require)) + + if logger: + if warnings: + logger.warn(u" - Validating: problems found:") + map(logger.warn, warnings) + map(logger.info, infos) + else: + logger.info(u" - Validating: {}".format(GREEN_OK)) + + return warnings + + +def validate_nbextension_python(spec, full_dest, logger=None): + """Assess the health of an installed nbextension + + Returns a list of warnings. + + Parameters + ---------- + + spec : dict + A single entry of _jupyter_nbextension_paths(): + [{ + 'section': 'notebook', + 'src': 'mockextension', + 'dest': '_mockdestination', + 'require': '_mockdestination/index' + }] + full_dest : str + The on-disk location of the installed nbextension: this should end + with `nbextensions/` + logger : Jupyter logger [optional] + Logger instance to use + """ + infos = [] + warnings = [] + + section = spec.get("section", None) + if section in NBCONFIG_SECTIONS: + infos.append(u" {} section: {}".format(GREEN_OK, section)) + else: + warnings.append(u" {} section: {}".format(RED_X, section)) + + require = spec.get("require", None) + if require is not None: + require_path = os.path.join( + full_dest[0:-len(spec["dest"])], + u"{}.js".format(require)) + if os.path.exists(require_path): + infos.append(u" {} require: {}".format(GREEN_OK, require_path)) + else: + warnings.append(u" {} require: {}".format(RED_X, require_path)) + + if logger: + if warnings: + logger.warn("- Validating: problems found:") + [logger.warn(warning) for warning in warnings] + [logger.info(info) for info in infos] + logger.warn(u"Full spec: {}".format(spec)) + else: + logger.info(u"- Validating: {}".format(GREEN_OK)) + + return warnings + #---------------------------------------------------------------------- -# install nbextension app +# Applications #---------------------------------------------------------------------- -from traitlets import Bool, Enum, Unicode +from traitlets import Bool, Unicode, Any from jupyter_core.application import JupyterApp -flags = { +_base_flags = {} +_base_flags.update(JupyterApp.flags) +_base_flags.pop("y", None) +_base_flags.pop("generate-config", None) +_base_flags.update({ + "user" : ({ + "BaseNBExtensionApp" : { + "user" : True, + }}, "Apply the operation only for the given user" + ), + "system" : ({ + "BaseNBExtensionApp" : { + "user" : False, + "sys_prefix": False, + }}, "Apply the operation system-wide" + ), + "sys-prefix" : ({ + "BaseNBExtensionApp" : { + "sys_prefix" : True, + }}, "Use sys.prefix as the prefix for installing nbextensions (for environments, packaging)" + ), + "py" : ({ + "BaseNBExtensionApp" : { + "python" : True, + }}, "Install from a Python package" + ) +}) +_base_flags['python'] = _base_flags['py'] + +class BaseNBExtensionApp(JupyterApp): + """Base nbextension installer app""" + _log_formatter_cls = LogFormatter + flags = _base_flags + version = __version__ + + user = Bool(False, config=True, help="Whether to do a user install") + sys_prefix = Bool(False, config=True, help="Use the sys.prefix as the prefix") + python = Bool(False, config=True, help="Install from a Python package") + + # Remove for 5.0... + verbose = Any(None, config=True, help="DEPRECATED: Verbosity level") + + def _verbose_changed(self): + """Warn about verbosity changes""" + import warnings + warnings.warn("`verbose` traits of `{}` has been deprecated, has no effects and will be removed in notebook 5.0.".format(type(self).__name__), DeprecationWarning) + + def _log_format_default(self): + """A default format for messages""" + return "%(message)s" + + +flags = {} +flags.update(_base_flags) +flags.update({ "overwrite" : ({ "InstallNBExtensionApp" : { "overwrite" : True, }}, "Force overwrite of existing files" ), - "debug" : ({ - "InstallNBExtensionApp" : { - "verbose" : 2, - }}, "Extra output" - ), - "quiet" : ({ - "InstallNBExtensionApp" : { - "verbose" : 0, - }}, "Minimal output" - ), "symlink" : ({ "InstallNBExtensionApp" : { "symlink" : True, }}, "Create symlink instead of copying files" ), - "user" : ({ - "InstallNBExtensionApp" : { - "user" : True, - }}, "Install to the user's IPython directory" - ), -} +}) + flags['s'] = flags['symlink'] aliases = { @@ -261,14 +655,13 @@ def install_nbextension(path, overwrite=False, symlink=False, user=False, prefix "destination" : "InstallNBExtensionApp.destination", } -class InstallNBExtensionApp(JupyterApp): +class InstallNBExtensionApp(BaseNBExtensionApp): """Entry point for installing notebook extensions""" - version = __version__ description = """Install Jupyter notebook extensions Usage - jupyter nbextension install path/url + jupyter nbextension install path|url [--user|--sys-prefix] This copies a file or a folder into the Jupyter nbextensions directory. If a URL is given, it will be downloaded. @@ -285,132 +678,468 @@ class InstallNBExtensionApp(JupyterApp): overwrite = Bool(False, config=True, help="Force overwrite of existing files") symlink = Bool(False, config=True, help="Create symlinks instead of copying files") - user = Bool(False, config=True, help="Whether to do a user install") + prefix = Unicode('', config=True, help="Installation prefix") nbextensions_dir = Unicode('', config=True, help="Full path to nbextensions dir (probably use prefix or user)") destination = Unicode('', config=True, help="Destination for the copy or symlink") - verbose = Enum((0,1,2), default_value=1, config=True, - help="Verbosity level" - ) def _config_file_name_default(self): + """The default config file name.""" return 'jupyter_notebook_config' def install_extensions(self): + """Perform the installation of nbextension(s)""" if len(self.extra_args)>1: raise ValueError("only one nbextension allowed at a time. Call multiple times to install multiple extensions.") - install_nbextension(self.extra_args[0], - overwrite=self.overwrite, - symlink=self.symlink, - verbose=self.verbose, - user=self.user, - prefix=self.prefix, - destination=self.destination, - nbextensions_dir=self.nbextensions_dir, - ) - + + install = install_nbextension_python if self.python else install_nbextension + + full_dests = install(self.extra_args[0], + overwrite=self.overwrite, + symlink=self.symlink, + user=self.user, + sys_prefix=self.sys_prefix, + prefix=self.prefix, + nbextensions_dir=self.nbextensions_dir, + logger=self.log) + + if full_dests: + self.log.info( + u"\nTo initialize this nbextension in the browser every time" + " the notebook (or other app) loads:\n\n" + " jupyter nbextension enable {}{}{}{}\n".format( + self.extra_args[0] if self.python else "", + " --user" if self.user else "", + " --py" if self.python else "", + " --sys-prefix" if self.sys_prefix else "" + ) + ) + def start(self): + """Perform the App's function as configured""" if not self.extra_args: - for nbext in jupyter_path('nbextensions'): - if os.path.exists(nbext): - print("Notebook extensions in %s:" % nbext) - for ext in os.listdir(nbext): - print(u" %s" % ext) + sys.exit('Please specify an nbextension to install') else: try: self.install_extensions() except ArgumentConflict as e: - print(str(e), file=sys.stderr) - self.exit(1) + sys.exit(str(e)) -class EnableNBExtensionApp(JupyterApp): - name = "jupyter nbextension enable" +class UninstallNBExtensionApp(BaseNBExtensionApp): + """Entry point for uninstalling notebook extensions""" + version = __version__ + description = """Uninstall Jupyter notebook extensions + + Usage + + jupyter nbextension uninstall path/url path/url/entrypoint + jupyter nbextension uninstall --py pythonPackageName + + This uninstalls an nbextension. + """ + + examples = """ + jupyter nbextension uninstall dest/dir dest/dir/extensionjs + jupyter nbextension uninstall --py extensionPyPackage + """ + aliases = {'section': 'ToggleNBExtensionApp.section'} + + + prefix = Unicode('', config=True, help="Installation prefix") + nbextensions_dir = Unicode('', config=True, help="Full path to nbextensions dir (probably use prefix or user)") + destination = Unicode('', config=True, help="Destination for the copy or symlink") + + def _config_file_name_default(self): + """The default config file name.""" + return 'jupyter_notebook_config' + + def uninstall_extensions(self): + """Uninstall some nbextensions""" + kwargs = { + 'user': self.user, + 'sys_prefix': self.sys_prefix, + 'prefix': self.prefix, + 'nbextensions_dir': self.nbextensions_dir, + 'logger': self.log + } + + arg_count = 1 if self.python else 2 + if len(self.extra_args)>arg_count: + raise ValueError("only one nbextension allowed at a time. Call multiple times to uninstall multiple extensions.") + if len(self.extra_args) 1: - sys.exit('Please specify one extension at a time') - - self.enable_nbextension(self.extra_args[0]) + sys.exit('Please specify one nbextension/package at a time') + if self.python: + self.toggle_nbextension_python(self.extra_args[0]) + else: + self.toggle_nbextension(self.extra_args[0]) -class DisableNBExtensionApp(JupyterApp): - name = "jupyter nbextension disable" - version = __version__ - description = "Remove the configuration to automatically load an extension" +class EnableNBExtensionApp(ToggleNBExtensionApp): + """An App that enables nbextensions""" + name = "jupyter nbextension enable" + description = """ + Enable an nbextension in frontend configuration. + + Usage + jupyter nbextension enable [--system|--sys-prefix] + """ + _toggle_value = True - section = Unicode('notebook', config=True, - help=("Which config section to remove the extension from. " - "This should match the one it was previously added to.") - ) - aliases = {'section': 'DisableNBExtensionApp.section', - } +class DisableNBExtensionApp(ToggleNBExtensionApp): + """An App that disables nbextensions""" + name = "jupyter nbextension disable" + description = """ + Enable an nbextension in frontend configuration. + + Usage + jupyter nbextension disable [--system|--sys-prefix] + """ + _toggle_value = None - def _config_file_name_default(self): - return 'jupyter_notebook_config' - def disable_nbextension(self, name): - # Local import to avoid circular import issue on Py 2 - from .services.config import ConfigManager - cm = ConfigManager(parent=self, config=self.config) - if name not in cm.get(self.section).get('load_extensions', {}): - sys.exit('{} is not enabled in section {}'.format(name, self.section)) - # We're using a dict as a set - updating with None removes the key - cm.update(self.section, {"load_extensions": {name: None}}) +class ListNBExtensionsApp(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 nbextensions:") + + for config_dir in config_dirs: + head = u' config dir: {}'.format(config_dir) + head_shown = False + cm = BaseJSONConfigManager(parent=self, config_dir=config_dir) + for section in NBCONFIG_SECTIONS: + data = cm.get(section) + if 'load_extensions' in data: + if not head_shown: + # only show heading if there is an nbextension here + print(head) + head_shown = True + print(u' {} section'.format(section)) + + for require, enabled in data['load_extensions'].items(): + print(u' {} {}'.format( + require, + GREEN_ENABLED if enabled else RED_DISABLED)) + if enabled: + validate_nbextension(require, logger=self.log) + def start(self): - if not self.extra_args: - sys.exit('No extensions specified') - elif len(self.extra_args) > 1: - sys.exit('Please specify one extension at a time') + """Perform the App's functions as configured""" + self.list_nbextensions() + - self.disable_nbextension(self.extra_args[0]) +_examples = """ +jupyter nbextension list # list all configured nbextensions +jupyter nbextension install --py # install an nbextension from a Python package +jupyter nbextension enable --py # enable all nbextensions in a Python package +jupyter nbextension disable --py # disable all nbextensions in a Python package +jupyter nbextension uninstall --py # uninstall an nbextension in a Python package +""" -class NBExtensionApp(JupyterApp): +class NBExtensionApp(BaseNBExtensionApp): + """Base jupyter nbextension command entry point""" name = "jupyter nbextension" version = __version__ description = "Work with Jupyter notebook extensions" + examples = _examples subcommands = dict( - install=(InstallNBExtensionApp, - """Install notebook extensions""" - ), - enable=(EnableNBExtensionApp, "Enable a notebook extension"), - disable=(DisableNBExtensionApp, "Disable a notebook extension"), + install=(InstallNBExtensionApp,"Install an nbextension"), + enable=(EnableNBExtensionApp, "Enable an nbextension"), + disable=(DisableNBExtensionApp, "Disable an nbextension"), + uninstall=(UninstallNBExtensionApp, "Uninstall an nbextension"), + list=(ListNBExtensionsApp, "List nbextensions") ) def start(self): + """Perform the App's functions as configured""" super(NBExtensionApp, self).start() # The above should have called a subcommand and raised NoStart; if we - # get here, it didn't, so we should print a message. + # 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 = NBExtensionApp.launch_instance +#------------------------------------------------------------------------------ +# Private API +#------------------------------------------------------------------------------ + + +def _should_copy(src, dest, logger=None): + """Should a file be copied, if it doesn't exist, or is newer? + + Returns whether the file needs to be updated. + + Parameters + ---------- + + src : string + A path that should exist from which to copy a file + src : string + A path that might exist to which to copy a file + logger : Jupyter logger [optional] + Logger instance to use + """ + if not os.path.exists(dest): + return True + if os.stat(src).st_mtime - os.stat(dest).st_mtime > 1e-6: + # we add a fudge factor to work around a bug in python 2.x + # that was fixed in python 3.x: http://bugs.python.org/issue12904 + if logger: + logger.warn("Out of date: %s" % dest) + return True + if logger: + logger.info("Up to date: %s" % dest) + return False + + +def _maybe_copy(src, dest, logger=None): + """Copy a file if it needs updating. + + Parameters + ---------- + + src : string + A path that should exist from which to copy a file + src : string + A path that might exist to which to copy a file + logger : Jupyter logger [optional] + Logger instance to use + """ + if _should_copy(src, dest, logger=logger): + if logger: + logger.info("Copying: %s -> %s" % (src, dest)) + shutil.copy2(src, dest) + + +def _safe_is_tarfile(path): + """Safe version of is_tarfile, return False on IOError. + + Returns whether the file exists and is a tarfile. + + Parameters + ---------- + + path : string + A path that might not exist and or be a tarfile + """ + try: + return tarfile.is_tarfile(path) + except IOError: + return False + + +def _get_nbextension_dir(user=False, sys_prefix=False, prefix=None, nbextensions_dir=None): + """Return the nbextension directory specified + + Parameters + ---------- + + user : bool [default: False] + Get the user's .jupyter/nbextensions directory + sys_prefix : bool [default: False] + Get sys.prefix, i.e. ~/.envs/my-env/share/jupyter/nbextensions + prefix : str [optional] + Get custom prefix + nbextensions_dir : str [optional] + Get what you put in + """ + if sum(map(bool, [user, prefix, nbextensions_dir, sys_prefix])) > 1: + raise ArgumentConflict("cannot specify more than one of user, sys_prefix, prefix, or nbextensions_dir") + if user: + nbext = pjoin(jupyter_data_dir(), u'nbextensions') + elif sys_prefix: + nbext = pjoin(ENV_JUPYTER_PATH[0], u'nbextensions') + elif prefix: + nbext = pjoin(prefix, 'share', 'jupyter', 'nbextensions') + elif nbextensions_dir: + nbext = nbextensions_dir + else: + nbext = pjoin(SYSTEM_JUPYTER_PATH[0], 'nbextensions') + return nbext + + +def _nbextension_dirs(): + """The possible locations of nbextensions. + + Returns a list of known base extension locations + """ + return [ + pjoin(jupyter_data_dir(), u'nbextensions'), + pjoin(ENV_JUPYTER_PATH[0], u'nbextensions'), + pjoin(SYSTEM_JUPYTER_PATH[0], 'nbextensions') + ] + + +def _get_config_dir(user=False, sys_prefix=False): + """Get the location of config files for the current context + + Returns the string to the enviornment + + Parameters + ---------- + + user : bool [default: False] + Get the user's .jupyter config directory + sys_prefix : bool [default: False] + Get sys.prefix, i.e. ~/.envs/my-env/etc/jupyter + """ + user = False if sys_prefix else user + if user and sys_prefix: + raise ArgumentConflict("Cannot specify more than one of user or sys_prefix") + if user: + nbext = jupyter_config_dir() + elif sys_prefix: + nbext = ENV_CONFIG_PATH[0] + else: + nbext = SYSTEM_CONFIG_PATH[0] + return nbext + + +def _get_nbextension_metadata(module): + """Get the list of nbextension paths associated with a Python module. + + Returns a tuple of (the module, [{ + 'section': 'notebook', + 'src': 'mockextension', + 'dest': '_mockdestination', + 'require': '_mockdestination/index' + }]) + + Parameters + ---------- + + module : str + Importable Python module exposing the + magic-named `_jupyter_nbextension_paths` function + """ + m = import_item(module) + if not hasattr(m, '_jupyter_nbextension_paths'): + raise KeyError('The Python module {} is not a valid nbextension'.format(module)) + nbexts = m._jupyter_nbextension_paths() + return m, nbexts + + +def _read_config_data(user=False, sys_prefix=False): + """Get the config for the current context + + Returns the string to the enviornment + + Parameters + ---------- + + user : bool [default: False] + Get the user's .jupyter config directory + sys_prefix : bool [default: False] + Get sys.prefix, i.e. ~/.envs/my-env/etc/jupyter + """ + config_dir = _get_config_dir(user=user, sys_prefix=sys_prefix) + config_man = BaseJSONConfigManager(config_dir=config_dir) + return config_man.get('jupyter_notebook_config') + + +def _write_config_data(data, user=False, sys_prefix=False): + """Update the config for the current context + + Parameters + ---------- + data : object + An object which can be accepted by ConfigManager.update + user : bool [default: False] + Get the user's .jupyter config directory + sys_prefix : bool [default: False] + Get sys.prefix, i.e. ~/.envs/my-env/etc/jupyter + """ + config_dir = _get_config_dir(user=user, sys_prefix=sys_prefix) + config_man = BaseJSONConfigManager(config_dir=config_dir) + config_man.update('jupyter_notebook_config', data) + + if __name__ == '__main__': main() - diff --git a/notebook/notebookapp.py b/notebook/notebookapp.py index ddca04e361..2d95d46a93 100644 --- a/notebook/notebookapp.py +++ b/notebook/notebookapp.py @@ -218,7 +218,7 @@ def init_settings(self, ipython_app, kernel_manager, contents_manager, iopub_msg_rate_limit=ipython_app.iopub_msg_rate_limit, iopub_data_rate_limit=ipython_app.iopub_data_rate_limit, rate_limit_window=ipython_app.rate_limit_window, - + # authentication cookie_secret=ipython_app.cookie_secret, login_url=url_path_join(base_url,'/login'), @@ -820,9 +820,18 @@ def _notebook_dir_changed(self, name, old, new): self.config.FileContentsManager.root_dir = new self.config.MappingKernelManager.root_dir = new + # TODO: Remove me in notebook 5.0 server_extensions = List(Unicode(), config=True, - help=("Python modules to load as notebook server extensions. " - "This is an experimental API, and may change in future releases.") + help=("DEPRECATED use the nbserver_extensions dict instead") + ) + def _server_extensions_changed(self, name, old, new): + self.log.warning("server_extensions is deprecated, use nbserver_extensions") + self.server_extensions = new + + nbserver_extensions = Dict({}, config=True, + help=("Dict of Python modules to load as notebook server extensions." + "Entry values can be used to enable and disable the loading of" + "the extensions.") ) reraise_server_extension_failures = Bool( @@ -1061,17 +1070,26 @@ def init_server_extensions(self): The extension API is experimental, and may change in future releases. """ + + # TODO: Remove me in notebook 5.0 for modulename in self.server_extensions: - try: - mod = importlib.import_module(modulename) - func = getattr(mod, 'load_jupyter_server_extension', None) - if func is not None: - func(self) - except Exception: - if self.reraise_server_extension_failures: - raise - self.log.warning("Error loading server extension %s", modulename, - exc_info=True) + # Don't override disable state of the extension if it already exist + # in the new traitlet + if not modulename in self.nbserver_extensions: + self.nbserver_extensions[modulename] = True + + for modulename in self.nbserver_extensions: + if self.nbserver_extensions[modulename]: + try: + mod = importlib.import_module(modulename) + func = getattr(mod, 'load_jupyter_server_extension', None) + if func is not None: + func(self) + except Exception: + if self.reraise_server_extension_failures: + raise + self.log.warning("Error loading server extension %s", modulename, + exc_info=True) @catch_config_error def initialize(self, argv=None): diff --git a/notebook/serverextensions.py b/notebook/serverextensions.py new file mode 100644 index 0000000000..1b9a1888e6 --- /dev/null +++ b/notebook/serverextensions.py @@ -0,0 +1,341 @@ +# coding: utf-8 +"""Utilities for installing server extensions for the notebook""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +from __future__ import print_function + +import importlib +import sys + + +from jupyter_core.paths import jupyter_config_path +from ._version import __version__ +from .nbextensions import ( + JupyterApp, BaseNBExtensionApp, _get_config_dir, + GREEN_ENABLED, RED_DISABLED, + GREEN_OK, RED_X, +) + +from traitlets import Bool +from traitlets.utils.importstring import import_item +from traitlets.config.manager import BaseJSONConfigManager + + +# ------------------------------------------------------------------------------ +# Public API +# ------------------------------------------------------------------------------ +class ArgumentConflict(ValueError): + pass + + +def toggle_serverextension_python(import_name, enabled=None, parent=None, + user=True, sys_prefix=False, logger=None): + """Toggle a server extension. + + By default, toggles the extension in the system-wide Jupyter configuration + location (e.g. /usr/local/etc/jupyter). + + Parameters + ---------- + + import_name : str + Importable Python module (dotted-notation) exposing the magic-named + `load_jupyter_server_extension` function + enabled : bool [default: None] + Toggle state for the extension. Set to None to toggle, True to enable, + and False to disable the extension. + parent : Configurable [default: None] + user : bool [default: True] + Toggle in the user's configuration location (e.g. ~/.jupyter). + sys_prefix : bool [default: False] + Toggle in the current Python environment's configuration location + (e.g. ~/.envs/my-env/etc/jupyter). Will override `user`. + logger : Jupyter logger [optional] + Logger instance to use + """ + user = False if sys_prefix else user + config_dir = _get_config_dir(user=user, sys_prefix=sys_prefix) + cm = BaseJSONConfigManager(parent=parent, config_dir=config_dir) + cfg = cm.get("jupyter_notebook_config") + server_extensions = ( + cfg.setdefault("NotebookApp", {}) + .setdefault("nbserver_extensions", {}) + ) + + old_enabled = server_extensions.get(import_name, None) + new_enabled = enabled if enabled is not None else not old_enabled + + if logger: + if new_enabled: + logger.info(u"Enabling: %s" % (import_name)) + else: + logger.info(u"Disabling: %s" % (import_name)) + + server_extensions[import_name] = new_enabled + + if logger: + logger.info(u"- Writing config: {}".format(config_dir)) + + cm.update("jupyter_notebook_config", cfg) + + if new_enabled: + validate_serverextension(import_name, logger) + + +def validate_serverextension(import_name, logger=None): + """Assess the health of an installed server extension + + Returns a list of validation warnings. + + Parameters + ---------- + + import_name : str + Importable Python module (dotted-notation) exposing the magic-named + `load_jupyter_server_extension` function + logger : Jupyter logger [optional] + Logger instance to use + """ + + warnings = [] + infos = [] + + func = None + + if logger: + logger.info(" - Validating...") + + try: + mod = importlib.import_module(import_name) + func = getattr(mod, 'load_jupyter_server_extension', None) + except Exception: + logger.warning("Error loading server extension %s", import_name) + + import_msg = u" {} is {} importable?" + if func is not None: + infos.append(import_msg.format(GREEN_OK, import_name)) + else: + warnings.append(import_msg.format(RED_X, import_name)) + + post_mortem = u" {} {} {}" + if logger: + if warnings: + [logger.info(info) for info in infos] + [logger.warn(warning) for warning in warnings] + else: + logger.info(post_mortem.format(import_name, "", GREEN_OK)) + + return warnings + + +# ---------------------------------------------------------------------- +# Applications +# ---------------------------------------------------------------------- + +flags = {} +flags.update(JupyterApp.flags) +flags.pop("y", None) +flags.pop("generate-config", None) +flags.update({ + "user" : ({ + "ToggleServerExtensionApp" : { + "user" : True, + }}, "Perform the operation for the current user" + ), + "system" : ({ + "ToggleServerExtensionApp" : { + "user" : False, + "sys_prefix": False, + }}, "Perform the operation system-wide" + ), + "sys-prefix" : ({ + "ToggleServerExtensionApp" : { + "sys_prefix" : True, + }}, "Use sys.prefix as the prefix for installing server extensions" + ), + "py" : ({ + "ToggleServerExtensionApp" : { + "python" : True, + }}, "Install from a Python package" + ), +}) +flags['python'] = flags['py'] + + +class ToggleServerExtensionApp(BaseNBExtensionApp): + """A base class for enabling/disabling extensions""" + name = "jupyter serverextension enable/disable" + description = "Enable/disable a server extension using frontend configuration files." + + aliases = {} + flags = flags + + user = Bool(True, config=True, help="Whether to do a user install") + sys_prefix = Bool(False, config=True, help="Use the sys.prefix as the prefix") + python = Bool(False, config=True, help="Install from a Python package") + + def toggle_server_extension(self, import_name): + """Change the status of a named server extension. + + Uses the value of `self._toggle_value`. + + Parameters + --------- + + import_name : str + Importable Python module (dotted-notation) exposing the magic-named + `load_jupyter_server_extension` function + """ + toggle_serverextension_python( + import_name, self._toggle_value, parent=self, user=self.user, + sys_prefix=self.sys_prefix, logger=self.log) + + def toggle_server_extension_python(self, package): + """Change the status of some server extensions in a Python package. + + Uses the value of `self._toggle_value`. + + Parameters + --------- + + package : str + Importable Python module exposing the + magic-named `_jupyter_server_extension_paths` function + """ + m, server_exts = _get_server_extension_metadata(package) + for server_ext in server_exts: + module = server_ext['module'] + self.toggle_server_extension(module) + + def start(self): + """Perform the App's actions as configured""" + if not self.extra_args: + sys.exit('Please specify a server extension/package to enable or disable') + for arg in self.extra_args: + if self.python: + self.toggle_server_extension_python(arg) + else: + self.toggle_server_extension(arg) + + +class EnableServerExtensionApp(ToggleServerExtensionApp): + """An App that enables (and validates) Server Extensions""" + name = "jupyter serverextension enable" + description = """ + Enable a serverextension in configuration. + + Usage + jupyter serverextension enable [--system|--sys-prefix] + """ + _toggle_value = True + + +class DisableServerExtensionApp(ToggleServerExtensionApp): + """An App that disables Server Extensions""" + name = "jupyter serverextension disable" + description = """ + Disable a serverextension in configuration. + + Usage + jupyter serverextension disable [--system|--sys-prefix] + """ + _toggle_value = False + + +class ListServerExtensionsApp(BaseNBExtensionApp): + """An App that lists (and validates) Server Extensions""" + name = "jupyter serverextension list" + version = __version__ + description = "List all server extensions known by the configuration system" + + def list_server_extensions(self): + """List all enabled and disabled server extensions, by config path + + Enabled extensions are validated, potentially generating warnings. + """ + config_dirs = jupyter_config_path() + for config_dir in config_dirs: + cm = BaseJSONConfigManager(parent=self, config_dir=config_dir) + data = cm.get("jupyter_notebook_config") + server_extensions = ( + data.setdefault("NotebookApp", {}) + .setdefault("nbserver_extensions", {}) + ) + if server_extensions: + print(u'config dir: {}'.format(config_dir)) + for import_name, enabled in server_extensions.items(): + print(u' {} {}'.format( + import_name, + GREEN_ENABLED if enabled else RED_DISABLED)) + validate_serverextension(import_name, self.log) + + def start(self): + """Perform the App's actions as configured""" + self.list_server_extensions() + + +_examples = """ +jupyter serverextension list # list all configured server extensions +jupyter serverextension enable --py # enable all server extensions in a Python package +jupyter serverextension disable --py # disable all server extensions in a Python package +""" + + +class ServerExtensionApp(BaseNBExtensionApp): + """Root level server extension app""" + name = "jupyter serverextension" + version = __version__ + description = "Work with Jupyter server extensions" + examples = _examples + + subcommands = dict( + enable=(EnableServerExtensionApp, "Enable an server extension"), + disable=(DisableServerExtensionApp, "Disable an server extension"), + list=(ListServerExtensionsApp, "List server extensions") + ) + + def start(self): + """Perform the App's actions as configured""" + super(ServerExtensionApp, 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 = ServerExtensionApp.launch_instance + +# ------------------------------------------------------------------------------ +# Private API +# ------------------------------------------------------------------------------ + + +def _get_server_extension_metadata(module): + """Load server extension metadata from a module. + + Returns a tuple of ( + the package as loaded + a list of server extension specs: [ + { + "module": "mockextension" + } + ] + ) + + Parameters + ---------- + + module : str + Importable Python module exposing the + magic-named `_jupyter_server_extension_paths` function + """ + m = import_item(module) + if not hasattr(m, '_jupyter_server_extension_paths'): + raise KeyError(u'The Python module {} does not include any valid server extensions'.format(module)) + return m, m._jupyter_server_extension_paths() + +if __name__ == '__main__': + main() diff --git a/notebook/services/config/manager.py b/notebook/services/config/manager.py index 2399f86054..600c0c4c1a 100644 --- a/notebook/services/config/manager.py +++ b/notebook/services/config/manager.py @@ -5,13 +5,46 @@ import os.path -from traitlets.config.manager import BaseJSONConfigManager -from jupyter_core.paths import jupyter_config_dir -from traitlets import Unicode +from traitlets.config.manager import BaseJSONConfigManager, recursive_update +from jupyter_core.paths import jupyter_config_dir, jupyter_config_path +from traitlets import Unicode, Instance, List +from traitlets.config import LoggingConfigurable -class ConfigManager(BaseJSONConfigManager): + +class ConfigManager(LoggingConfigurable): """Config Manager used for storing notebook frontend config""" - - config_dir = Unicode(config=True) - def _config_dir_default(self): + + # Public API + + def get(self, section_name): + """Get the config from all config sections.""" + config = {} + for p in self.read_config_path: + cm = BaseJSONConfigManager(config_dir=p) + recursive_update(config, cm.get(section_name)) + return config + + def set(self, section_name, data): + """Set the config only to the user's config.""" + return self.write_config_manager.set(section_name, data) + + def update(self, section_name, new_data): + """Update the config only to the user's config.""" + return self.write_config_manager.update(section_name, new_data) + + # Private API + + read_config_path = List(Unicode()) + def _read_config_path_default(self): + return [os.path.join(p, 'nbconfig') for p in jupyter_config_path()] + + write_config_dir = Unicode() + def _write_config_dir_default(self): return os.path.join(jupyter_config_dir(), 'nbconfig') + + write_config_manager = Instance(BaseJSONConfigManager) + def _write_config_manager_default(self): + return BaseJSONConfigManager(config_dir=self.write_config_dir) + + def _write_config_dir_changed(self, name, old, new): + self.write_config_manager = BaseJSONConfigManager(config_dir=self.write_config_dir) diff --git a/notebook/static/base/js/utils.js b/notebook/static/base/js/utils.js index f4514180c0..4ce55278d7 100644 --- a/notebook/static/base/js/utils.js +++ b/notebook/static/base/js/utils.js @@ -45,11 +45,27 @@ define([ * @return {Promise} that resolves to a list of loaded module handles. */ var load_extensions = function () { + console.log('load_extensions', arguments); return Promise.all(Array.prototype.map.call(arguments, load_extension)).catch(function(err) { console.error("Failed to load extension" + (err.requireModules.length>1?'s':'') + ":", err.requireModules, err); }); }; + /** + * Return a list of extensions that should be active + * The config for nbextensions comes in as a dict where keys are + * nbextensions paths and the values are a bool indicating if it + * should be active. This returns a list of nbextension paths + * where the value is true + */ + function filter_extensions(nbext_config) { + var active = []; + Object.keys(nbext_config).forEach(function (nbext) { + if (nbext_config[nbext]) {active.push(nbext);} + }); + return active; + } + /** * Wait for a config section to load, and then load the extensions specified * in a 'load_extensions' key inside it. @@ -57,9 +73,8 @@ define([ function load_extensions_from_config(section) { section.loaded.then(function() { if (section.data.load_extensions) { - var nbextension_paths = Object.getOwnPropertyNames( - section.data.load_extensions); - load_extensions.apply(this, nbextension_paths); + var active = filter_extensions(section.data.load_extensions); + load_extensions.apply(this, active); } }); } @@ -827,7 +842,7 @@ define([ var format_datetime = function(date) { var text = moment(date).fromNow(); return text === 'a few seconds ago' ? 'seconds ago' : text; - } + }; var datetime_sort_helper = function(a, b, order) { if (moment(a).isBefore(moment(b))) { @@ -837,11 +852,12 @@ define([ } else { return (order == 1) ? 1 : -1; } - } + }; var utils = { load_extension: load_extension, load_extensions: load_extensions, + filter_extensions: filter_extensions, load_extensions_from_config: load_extensions_from_config, regex_split : regex_split, uuid : uuid, diff --git a/notebook/static/notebook/js/main.js b/notebook/static/notebook/js/main.js index f5621f6b74..b800d6f00b 100644 --- a/notebook/static/notebook/js/main.js +++ b/notebook/static/notebook/js/main.js @@ -66,6 +66,8 @@ require([ // compat with old IPython, remove for IPython > 3.0 window.CodeMirror = CodeMirror; + // Setup all of the config related things + var common_options = { ws_url : utils.get_body_data("wsUrl"), base_url : utils.get_body_data("baseUrl"), @@ -77,6 +79,9 @@ require([ config_section.load(); var common_config = new configmod.ConfigSection('common', common_options); common_config.load(); + + // Instantiate the main objects + var page = new page.Page(); var pager = new pager.Pager('div#pager', { events: events}); @@ -88,7 +93,7 @@ require([ var save_widget = new savewidget.SaveWidget('span#save_widget', { events: events, keyboard_manager: keyboard_manager}); - acts.extend_env({save_widget:save_widget}) + acts.extend_env({save_widget:save_widget}); var contents = new contentsModule.Contents({ base_url: common_options.base_url, common_config: common_config @@ -179,7 +184,8 @@ require([ enumerable: true, configurable: false }); - + + // Now actually load nbextensions utils.load_extensions_from_config(config_section); utils.load_extensions_from_config(common_config); notebook.load_notebook(common_options.notebook_path); diff --git a/notebook/static/terminal/js/main.js b/notebook/static/terminal/js/main.js index 6e7a22eb0d..56a11fde56 100644 --- a/notebook/static/terminal/js/main.js +++ b/notebook/static/terminal/js/main.js @@ -18,6 +18,9 @@ require([ requirejs(['custom/custom'], function() {}); page = new page.Page(); + var config = new configmod.ConfigSection('terminal', + {base_url: utils.get_body_data('baseUrl')}); + config.load(); var common_config = new configmod.ConfigSection('common', {base_url: utils.get_body_data('baseUrl')}); common_config.load(); @@ -32,7 +35,8 @@ require([ var ws_url = location.protocol.replace('http', 'ws') + "//" + location.host + base_url + ws_path; - var header = $("#header")[0] + var header = $("#header")[0]; + function calculate_size() { var height = $(window).height() - header.offsetHeight; var width = $('#terminado-container').width(); @@ -49,6 +53,7 @@ require([ page.show_site(); + utils.load_extensions_from_config(config); utils.load_extensions_from_config(common_config); window.onresize = function() { diff --git a/notebook/static/tree/js/main.js b/notebook/static/tree/js/main.js index d45ecaa056..5d9eae96dc 100644 --- a/notebook/static/tree/js/main.js +++ b/notebook/static/tree/js/main.js @@ -32,10 +32,8 @@ require([ "use strict"; requirejs(['custom/custom'], function() {}); - IPython.NotebookList = notebooklist.NotebookList; + // Setup all of the config related things - page = new page.Page(); - var common_options = { base_url: utils.get_body_data("baseUrl"), notebook_path: utils.get_body_data("notebookPath"), @@ -46,6 +44,10 @@ require([ var common_config = new config.ConfigSection('common', common_options); common_config.load(); + // Instantiate the main objects + + page = new page.Page(); + var session_list = new sesssionlist.SesssionList($.extend({ events: events}, common_options)); @@ -53,6 +55,7 @@ require([ base_url: common_options.base_url, common_config: common_config }); + IPython.NotebookList = notebooklist.NotebookList; var notebook_list = new notebooklist.NotebookList('#notebook_list', $.extend({ contents: contents, session_list: session_list}, @@ -141,6 +144,8 @@ require([ IPython.new_notebook_widget = new_buttons; events.trigger('app_initialized.DashboardApp'); + + // Now actually load nbextensions utils.load_extensions_from_config(cfg); utils.load_extensions_from_config(common_config); diff --git a/notebook/templates/notebook.html b/notebook/templates/notebook.html index 31fd510f65..9d3588e200 100644 --- a/notebook/templates/notebook.html +++ b/notebook/templates/notebook.html @@ -30,7 +30,6 @@ data-ws-url="{{ws_url | urlencode}}" data-notebook-name="{{notebook_name | urlencode}}" data-notebook-path="{{notebook_path | urlencode}}" - {% endblock %} diff --git a/notebook/templates/tree.html b/notebook/templates/tree.html index bb3f33d9b4..b064b691c9 100644 --- a/notebook/templates/tree.html +++ b/notebook/templates/tree.html @@ -8,7 +8,6 @@ data-base-url="{{base_url | urlencode}}" data-notebook-path="{{notebook_path | urlencode}}" data-terminals-available="{{terminals_available}}" - {% endblock %} diff --git a/notebook/tests/mockextension/index.js b/notebook/tests/mockextension/index.js new file mode 100644 index 0000000000..0475609d40 --- /dev/null +++ b/notebook/tests/mockextension/index.js @@ -0,0 +1 @@ +console.log('z'); diff --git a/notebook/tests/test_nbextensions.py b/notebook/tests/test_nbextensions.py index 7feea2d8c9..e71c2b3f81 100644 --- a/notebook/tests/test_nbextensions.py +++ b/notebook/tests/test_nbextensions.py @@ -6,7 +6,6 @@ import glob import os -import re import sys import tarfile import zipfile @@ -23,7 +22,14 @@ from ipython_genutils import py3compat from ipython_genutils.tempdir import TemporaryDirectory from notebook import nbextensions -from notebook.nbextensions import install_nbextension, check_nbextension +from notebook.nbextensions import (install_nbextension, check_nbextension, + enable_nbextension, disable_nbextension, + install_nbextension_python, uninstall_nbextension_python, + enable_nbextension_python, disable_nbextension_python, _get_config_dir, + validate_nbextension, validate_nbextension_python +) + +from traitlets.config.manager import BaseJSONConfigManager def touch(file, mtime=None): @@ -47,7 +53,7 @@ def tempdir(self): def setUp(self): self.tempdirs = [] - src = self.src = self.tempdir() + self.src = self.tempdir() self.files = files = [ pjoin(u'ƒile'), pjoin(u'∂ir', u'ƒile1'), @@ -197,7 +203,7 @@ def test_update_file(self): install_nbextension(src) self.assert_installed(fname) dest = pjoin(self.system_nbext, fname) - old_mtime = os.stat(dest).st_mtime + os.stat(dest).st_mtime with open(src, 'w') as f: f.write('overwrite') touch(src, mtime + 10) @@ -225,7 +231,7 @@ def test_quiet(self): stderr = StringIO() with patch.object(sys, 'stdout', stdout), \ patch.object(sys, 'stderr', stderr): - install_nbextension(self.src, verbose=0) + install_nbextension(self.src) self.assertEqual(stdout.getvalue(), '') self.assertEqual(stderr.getvalue(), '') @@ -343,3 +349,130 @@ def test_install_destination_bad(self): with self.assertRaises(ValueError): install_nbextension(zsrc, destination='foo') + + def test_nbextension_enable(self): + with TemporaryDirectory() as d: + f = u'ƒ.js' + src = pjoin(d, f) + touch(src) + install_nbextension(src, user=True) + enable_nbextension(section='notebook', require=u'ƒ') + + config_dir = os.path.join(_get_config_dir(user=True), 'nbconfig') + cm = BaseJSONConfigManager(config_dir=config_dir) + enabled = cm.get('notebook').get('load_extensions', {}).get(u'ƒ', False) + assert enabled + + def test_nbextension_disable(self): + self.test_nbextension_enable() + disable_nbextension(section='notebook', require=u'ƒ') + + config_dir = os.path.join(_get_config_dir(user=True), 'nbconfig') + cm = BaseJSONConfigManager(config_dir=config_dir) + enabled = cm.get('notebook').get('load_extensions', {}).get(u'ƒ', False) + assert not enabled + + + def _mock_extension_spec_meta(self, section='notebook'): + return { + 'section': section, + 'src': 'mockextension', + 'dest': '_mockdestination', + 'require': '_mockdestination/index' + } + + def _inject_mock_extension(self, section='notebook'): + outer_file = __file__ + + meta = self._mock_extension_spec_meta(section) + + class mock(): + __file__ = outer_file + + @staticmethod + def _jupyter_nbextension_paths(): + return [meta] + + import sys + sys.modules['mockextension'] = mock + + def test_nbextensionpy_files(self): + self._inject_mock_extension() + install_nbextension_python('mockextension') + + assert check_nbextension('_mockdestination/index.js') + assert check_nbextension(['_mockdestination/index.js']) + + def test_nbextensionpy_user_files(self): + self._inject_mock_extension() + install_nbextension_python('mockextension', user=True) + + assert check_nbextension('_mockdestination/index.js', user=True) + assert check_nbextension(['_mockdestination/index.js'], user=True) + + def test_nbextensionpy_uninstall_files(self): + self._inject_mock_extension() + install_nbextension_python('mockextension', user=True) + uninstall_nbextension_python('mockextension', user=True) + + assert not check_nbextension('_mockdestination/index.js') + assert not check_nbextension(['_mockdestination/index.js']) + + def test_nbextensionpy_enable(self): + self._inject_mock_extension('notebook') + install_nbextension_python('mockextension', user=True) + enable_nbextension_python('mockextension') + + config_dir = os.path.join(_get_config_dir(user=True), 'nbconfig') + cm = BaseJSONConfigManager(config_dir=config_dir) + enabled = cm.get('notebook').get('load_extensions', {}).get('_mockdestination/index', False) + assert enabled + + def test_nbextensionpy_disable(self): + self._inject_mock_extension('notebook') + install_nbextension_python('mockextension', user=True) + enable_nbextension_python('mockextension') + disable_nbextension_python('mockextension', user=True) + + config_dir = os.path.join(_get_config_dir(user=True), 'nbconfig') + cm = BaseJSONConfigManager(config_dir=config_dir) + enabled = cm.get('notebook').get('load_extensions', {}).get('_mockdestination/index', False) + assert not enabled + + def test_nbextensionpy_validate(self): + self._inject_mock_extension('notebook') + + paths = install_nbextension_python('mockextension', user=True) + enable_nbextension_python('mockextension') + + meta = self._mock_extension_spec_meta() + warnings = validate_nbextension_python(meta, paths[0]) + self.assertEqual([], warnings, warnings) + + def test_nbextensionpy_validate_bad(self): + # Break the metadata (correct file will still be copied) + self._inject_mock_extension('notebook') + + paths = install_nbextension_python('mockextension', user=True) + + enable_nbextension_python('mockextension') + + meta = self._mock_extension_spec_meta() + meta.update(require="bad-require") + + warnings = validate_nbextension_python(meta, paths[0]) + self.assertNotEqual([], warnings, warnings) + + def test_nbextension_validate(self): + # Break the metadata (correct file will still be copied) + self._inject_mock_extension('notebook') + + install_nbextension_python('mockextension', user=True) + enable_nbextension_python('mockextension') + + warnings = validate_nbextension("_mockdestination/index") + self.assertEqual([], warnings, warnings) + + def test_nbextension_validate_bad(self): + warnings = validate_nbextension("this-doesn't-exist") + self.assertNotEqual([], warnings, warnings) diff --git a/notebook/tests/test_serverextensions.py b/notebook/tests/test_serverextensions.py new file mode 100644 index 0000000000..3791ab8740 --- /dev/null +++ b/notebook/tests/test_serverextensions.py @@ -0,0 +1,43 @@ +from unittest import TestCase + +from traitlets.config.manager import BaseJSONConfigManager + +from notebook.serverextensions import toggle_serverextension_python +from notebook.nbextensions import _get_config_dir + + +class TestInstallServerExtension(TestCase): + def _inject_mock_extension(self): + outer_file = __file__ + + class mock(): + __file__ = outer_file + + @staticmethod + def _jupyter_server_extension_paths(): + return [{ + 'module': '_mockdestination/index' + }] + + import sys + sys.modules['mockextension'] = mock + + def _get_config(self, user=True): + cm = BaseJSONConfigManager(config_dir=_get_config_dir(user)) + data = cm.get("jupyter_notebook_config") + return data.get("NotebookApp", {}).get("nbserver_extensions", {}) + + def test_enable(self): + self._inject_mock_extension() + toggle_serverextension_python('mockextension', True) + + config = self._get_config() + assert config['mockextension'] + + def test_disable(self): + self._inject_mock_extension() + toggle_serverextension_python('mockextension', True) + toggle_serverextension_python('mockextension', False) + + config = self._get_config() + assert not config['mockextension'] diff --git a/notebook/tree/handlers.py b/notebook/tree/handlers.py index 35b6d0ab0f..6734d3b504 100644 --- a/notebook/tree/handlers.py +++ b/notebook/tree/handlers.py @@ -37,6 +37,7 @@ def generate_page_title(self, path): def get(self, path=''): path = path.strip('/') cm = self.contents_manager + if cm.dir_exists(path=path): if cm.is_hidden(path): self.log.info("Refusing to serve hidden directory, via 404 Error") diff --git a/scripts/jupyter-serverextension b/scripts/jupyter-serverextension new file mode 100644 index 0000000000..79b0b38e88 --- /dev/null +++ b/scripts/jupyter-serverextension @@ -0,0 +1,6 @@ +#!/usr/bin/env python + +from notebook.serverextensions import main + +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py index ffe7cb3a30..46a8f0b94d 100755 --- a/setup.py +++ b/setup.py @@ -182,6 +182,7 @@ 'console_scripts': [ 'jupyter-notebook = notebook.notebookapp:main', 'jupyter-nbextension = notebook.nbextensions:main', + 'jupyter-serverextension = notebook.serverextensions:main', ] } setup_args.pop('scripts', None)