diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 49a54f2926..a23cbbf4ec 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -69,7 +69,7 @@ To run the Python tests, use:: If you want coverage statistics as well, you can run:: - py.test --cov notebook -v --pyargs jupyter_server + py.test --cov notebook -v Building the Documentation -------------------------- @@ -82,7 +82,7 @@ containing all the necessary packages (except pandoc), use:: conda env create -f docs/environment.yml source activate server_docs # Linux and OS X - activate notebook_docs # Windows + activate server_docs # Windows .. _conda environment: https://conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html#creating-an-environment-from-an-environment-yml-file diff --git a/jupyter_server/extension/__init__.py b/jupyter_server/extension/__init__.py index 988374916a..e69de29bb2 100644 --- a/jupyter_server/extension/__init__.py +++ b/jupyter_server/extension/__init__.py @@ -1,2 +0,0 @@ -from .application import ExtensionApp -from .handler import ExtensionHandler \ No newline at end of file diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 6237f09f6a..dacd7998b8 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -418,7 +418,6 @@ def load_jupyter_server_extension(cls, serverapp, argv=[], **kwargs): extension.initialize(serverapp, argv=argv) return extension - @classmethod def launch_instance(cls, argv=None, **kwargs): """Launch the extension like an application. Initializes+configs a stock server diff --git a/jupyter_server/extensions.py b/jupyter_server/extension/serverextension.py similarity index 52% rename from jupyter_server/extensions.py rename to jupyter_server/extension/serverextension.py index 172aaf53f7..de2b752a89 100644 --- a/jupyter_server/extensions.py +++ b/jupyter_server/extension/serverextension.py @@ -1,126 +1,150 @@ # coding: utf-8 -"""Utilities for installing server extensions""" +"""Utilities for installing extensions""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from __future__ import print_function - -import importlib +import os import sys - -from jupyter_core.paths import jupyter_config_path -from ._version import __version__ -from .config_manager import BaseJSONConfigManager -from .extensions_base import ( - BaseExtensionApp, _get_config_dir, GREEN_ENABLED, RED_DISABLED, GREEN_OK, RED_X -) -from traitlets import Bool +import importlib +from tornado.log import LogFormatter +from traitlets import Bool, Any from traitlets.utils.importstring import import_item -# ------------------------------------------------------------------------------ -# Public API -# ------------------------------------------------------------------------------ - -def toggle_serverextension_python(import_name, enabled=None, parent=None, - user=True, sys_prefix=False, logger=None): - """Toggle a server extension. +from jupyter_core.application import JupyterApp +from jupyter_core.paths import ( + jupyter_config_dir, + jupyter_config_path, + ENV_CONFIG_PATH, + SYSTEM_CONFIG_PATH +) +from jupyter_server._version import __version__ +from jupyter_server.config_manager import BaseJSONConfigManager - By default, toggles the extension in the system-wide Jupyter configuration - location (e.g. /usr/local/etc/jupyter). - Parameters - ---------- +class ArgumentConflict(ValueError): + pass - 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_server_config") - server_extensions = ( - cfg.setdefault("ServerApp", {}) - .setdefault("jpserver_extensions", {}) +_base_flags = {} +_base_flags.update(JupyterApp.flags) +_base_flags.pop("y", None) +_base_flags.pop("generate-config", None) +_base_flags.update({ + "user" : ({ + "BaseExtensionApp" : { + "user" : True, + }}, "Apply the operation only for the given user" + ), + "system" : ({ + "BaseExtensionApp" : { + "user" : False, + "sys_prefix": False, + }}, "Apply the operation system-wide" + ), + "sys-prefix" : ({ + "BaseExtensionApp" : { + "sys_prefix" : True, + }}, "Use sys.prefix as the prefix for installing extensions (for environments, packaging)" + ), + "py" : ({ + "BaseExtensionApp" : { + "python" : True, + }}, "Install from a Python package" ) +}) +_base_flags['python'] = _base_flags['py'] - 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)) +_base_aliases = {} +_base_aliases.update(JupyterApp.aliases) - server_extensions[import_name] = new_enabled - if logger: - logger.info(u"- Writing config: {}".format(config_dir)) +class BaseExtensionApp(JupyterApp): + """Base extension installer app""" + _log_formatter_cls = LogFormatter + flags = _base_flags + aliases = _base_aliases + version = __version__ - cm.update("jupyter_server_config", cfg) + user = Bool(False, config=True, help="Whether to do a user install") + sys_prefix = Bool(True, config=True, help="Use the sys.prefix as the prefix") + python = Bool(False, config=True, help="Install from a Python package") - if new_enabled: - validate_serverextension(import_name, logger) + def _log_format_default(self): + """A default format for messages""" + return "%(message)s" -def validate_serverextension(import_name, logger=None): - """Assess the health of an installed server extension +def _get_config_dir(user=False, sys_prefix=False): + """Get the location of config files for the current context - Returns a list of validation warnings. + Returns the string to the enviornment 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 + 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: + extdir = jupyter_config_dir() + elif sys_prefix: + extdir = ENV_CONFIG_PATH[0] + else: + extdir = SYSTEM_CONFIG_PATH[0] + return extdir - warnings = [] - infos = [] - func = None +# Constants for pretty print extension listing function. +# Window doesn't support coloring in the commandline +GREEN_ENABLED = '\033[32menabled\033[0m' if os.name != 'nt' else 'enabled' +RED_DISABLED = '\033[31mdisabled\033[0m' if os.name != 'nt' else 'disabled' +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 +# ------------------------------------------------------------------------------ + +class ExtensionValidationError(Exception): pass - if logger: - logger.info(" - Validating...") +def validate_server_extension(import_name): + """Tries to import the extension module. + Raises a validation error if module is not found. + """ try: mod = importlib.import_module(import_name) - func = getattr(mod, 'load_jupyter_server_extension', None) + func = getattr(mod, 'load_jupyter_server_extension') version = getattr(mod, '__version__', '') - 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, version, GREEN_OK)) - - return warnings - + return mod, func, version + # If the extension does not exist, raise an exception + except ImportError: + raise ExtensionValidationError('{} is not importable.'.format(import_name)) + # If the extension does not have a `load_jupyter_server_extension` function, raise exception. + except AttributeError: + raise ExtensionValidationError('Found module "{}" but cannot load it.'.format(import_name)) + + +def toggle_server_extension_python(import_name, enabled=None, parent=None, user=False, sys_prefix=True): + """Toggle the boolean setting for a given server extension + in a Jupyter config file. + """ + sys_prefix = False if user else sys_prefix + config_dir = _get_config_dir(user=user, sys_prefix=sys_prefix) + cm = BaseJSONConfigManager(parent=parent, config_dir=config_dir) + cfg = cm.get("jupyter_server_config") + server_extensions = ( + cfg.setdefault("ServerApp", {}) + .setdefault("jpserver_extensions", {}) + ) + old_enabled = server_extensions.get(import_name, None) + new_enabled = enabled if enabled is not None else not old_enabled + server_extensions[import_name] = new_enabled + cm.update("jupyter_server_config", cfg) # ---------------------------------------------------------------------- # Applications @@ -158,14 +182,17 @@ def validate_serverextension(import_name, logger=None): class ToggleServerExtensionApp(BaseExtensionApp): """A base class for enabling/disabling extensions""" - name = "jupyter serverextension enable/disable" + name = "jupyter server extension enable/disable" description = "Enable/disable a server extension using frontend configuration files." 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") + user = Bool(False, config=True, help="Whether to do a user install") + sys_prefix = Bool(True, config=True, help="Use the sys.prefix as the prefix") python = Bool(False, config=True, help="Install from a Python package") + _toggle_value = Bool() + _toggle_pre_message = '' + _toggle_post_message = '' def toggle_server_extension(self, import_name): """Change the status of a named server extension. @@ -179,9 +206,26 @@ def toggle_server_extension(self, import_name): 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) + try: + self.log.info("{}: {}".format(self._toggle_pre_message.capitalize(), import_name)) + # Validate the server extension. + self.log.info(" - Validating {}...".format(import_name)) + _, __, version = validate_server_extension(import_name) + + # Toggle the server extension to active. + toggle_server_extension_python( + import_name, + self._toggle_value, + parent=self, + user=self.user, + sys_prefix=self.sys_prefix + ) + self.log.info(" {} {} {}".format(import_item, version, GREEN_OK)) + + # If successful, let's log. + self.log.info(" - Extension successfully {}.".format(self._toggle_post_message)) + except ExtensionValidationError as err: + self.log.info(" {} Validation failed: {}".format(RED_X, err)) def toggle_server_extension_python(self, package): """Change the status of some server extensions in a Python package. @@ -195,7 +239,7 @@ def toggle_server_extension_python(self, package): Importable Python module exposing the magic-named `_jupyter_server_extension_paths` function """ - m, server_exts = _get_server_extension_metadata(package) + _, server_exts = _get_server_extension_metadata(package) for server_ext in server_exts: module = server_ext['module'] self.toggle_server_extension(module) @@ -213,31 +257,35 @@ def start(self): class EnableServerExtensionApp(ToggleServerExtensionApp): """An App that enables (and validates) Server Extensions""" - name = "jupyter serverextension enable" + name = "jupyter server extension enable" description = """ - Enable a serverextension in configuration. + Enable a server extension in configuration. Usage - jupyter serverextension enable [--system|--sys-prefix] + jupyter server extension enable [--system|--sys-prefix] """ _toggle_value = True + _toggle_pre_message = "enabling" + _toggle_post_message = "enabled" class DisableServerExtensionApp(ToggleServerExtensionApp): """An App that disables Server Extensions""" - name = "jupyter serverextension disable" + name = "jupyter server extension disable" description = """ - Disable a serverextension in configuration. + Disable a server extension in configuration. Usage - jupyter serverextension disable [--system|--sys-prefix] + jupyter server extension disable [--system|--sys-prefix] """ _toggle_value = False + _toggle_pre_message = "disabling" + _toggle_post_message = "disabled" class ListServerExtensionsApp(BaseExtensionApp): """An App that lists (and validates) Server Extensions""" - name = "jupyter serverextension list" + name = "jupyter server extension list" version = __version__ description = "List all server extensions known by the configuration system" @@ -255,12 +303,18 @@ def list_server_extensions(self): .setdefault("jpserver_extensions", {}) ) if server_extensions: - print(u'config dir: {}'.format(config_dir)) + self.log.info(u'config dir: {}'.format(config_dir)) for import_name, enabled in server_extensions.items(): - print(u' {} {}'.format( + self.log.info(u' {} {}'.format( import_name, GREEN_ENABLED if enabled else RED_DISABLED)) - validate_serverextension(import_name, self.log) + try: + self.log.info(" - Validating {}...".format(import_name)) + _, __, version = validate_server_extension(import_name) + self.log.info(" {} {} {}".format(import_name, version, GREEN_OK)) + + except ExtensionValidationError as err: + self.log.warn(" {} {}".format(RED_X, err)) def start(self): """Perform the App's actions as configured""" @@ -268,15 +322,15 @@ def start(self): _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 +jupyter server extension list # list all configured server extensions +jupyter server extension enable --py # enable all server extensions in a Python package +jupyter server extension disable --py # disable all server extensions in a Python package """ class ServerExtensionApp(BaseExtensionApp): """Root level server extension app""" - name = "jupyter serverextension" + name = "jupyter server extension" version = __version__ description = "Work with Jupyter server extensions" examples = _examples @@ -303,7 +357,6 @@ def start(self): # Private API # ------------------------------------------------------------------------------ - def _get_server_extension_metadata(module): """Load server extension metadata from a module. diff --git a/jupyter_server/extensions_base.py b/jupyter_server/extensions_base.py deleted file mode 100644 index d4dfa6fca8..0000000000 --- a/jupyter_server/extensions_base.py +++ /dev/null @@ -1,96 +0,0 @@ -# coding: utf-8 -"""Utilities for installing extensions""" - -# Copyright (c) Jupyter Development Team. -# Distributed under the terms of the Modified BSD License. - -import os -from tornado.log import LogFormatter -from traitlets import Bool, Any -from jupyter_core.application import JupyterApp -from jupyter_core.paths import ( - jupyter_config_dir, ENV_CONFIG_PATH, SYSTEM_CONFIG_PATH -) -from ._version import __version__ - -class ArgumentConflict(ValueError): - pass - -_base_flags = {} -_base_flags.update(JupyterApp.flags) -_base_flags.pop("y", None) -_base_flags.pop("generate-config", None) -_base_flags.update({ - "user" : ({ - "BaseExtensionApp" : { - "user" : True, - }}, "Apply the operation only for the given user" - ), - "system" : ({ - "BaseExtensionApp" : { - "user" : False, - "sys_prefix": False, - }}, "Apply the operation system-wide" - ), - "sys-prefix" : ({ - "BaseExtensionApp" : { - "sys_prefix" : True, - }}, "Use sys.prefix as the prefix for installing extensions (for environments, packaging)" - ), - "py" : ({ - "BaseExtensionApp" : { - "python" : True, - }}, "Install from a Python package" - ) -}) -_base_flags['python'] = _base_flags['py'] - -_base_aliases = {} -_base_aliases.update(JupyterApp.aliases) - - -class BaseExtensionApp(JupyterApp): - """Base extension installer app""" - _log_formatter_cls = LogFormatter - flags = _base_flags - aliases = _base_aliases - 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") - - def _log_format_default(self): - """A default format for messages""" - return "%(message)s" - -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: - extdir = jupyter_config_dir() - elif sys_prefix: - extdir = ENV_CONFIG_PATH[0] - else: - extdir = SYSTEM_CONFIG_PATH[0] - return extdir - -# 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' -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' diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index f626097c12..9ff6d280ca 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -102,6 +102,8 @@ from ._tz import utcnow, utcfromtimestamp from .utils import url_path_join, check_pid, url_escape, urljoin, pathname2url +from jupyter_server.extension.serverextension import ServerExtensionApp + #----------------------------------------------------------------------------- # Module globals #----------------------------------------------------------------------------- @@ -566,6 +568,7 @@ class ServerApp(JupyterApp): list=(JupyterServerListApp, JupyterServerListApp.description.splitlines()[0]), stop=(JupyterServerStopApp, JupyterServerStopApp.description.splitlines()[0]), password=(JupyterPasswordApp, JupyterPasswordApp.description.splitlines()[0]), + extension=(ServerExtensionApp, ServerExtensionApp.description.splitlines()[0]), ) # A list of services whose handlers will be exposed. @@ -1482,7 +1485,7 @@ def init_server_extension_config(self): manager = ConfigManager(read_config_path=config_path) section = manager.get(self.config_file_name) extensions = section.get('ServerApp', {}).get('jpserver_extensions', {}) - + for modulename, enabled in sorted(extensions.items()): if modulename not in self.jpserver_extensions: self.config.ServerApp.jpserver_extensions.update({modulename: enabled}) diff --git a/setup.py b/setup.py index 190ce5c1a6..1e5d636f9f 100755 --- a/setup.py +++ b/setup.py @@ -92,14 +92,13 @@ ], extras_require = { 'test': ['nose', 'coverage', 'requests', 'nose_warnings_filters', - 'pytest', 'pytest-cov', 'pytest-tornasync'], + 'pytest', 'pytest-cov', 'pytest-tornasync', 'pytest-console-scripts'], 'test:sys_platform == "win32"': ['nose-exclude'], }, python_requires = '>=3.5', entry_points = { 'console_scripts': [ 'jupyter-server = jupyter_server.serverapp:main', - 'jupyter-extension = jupyter_server.extensions:main', 'jupyter-bundlerextension = jupyter_server.bundler.bundlerextensions:main', ] }, diff --git a/tests/conftest.py b/tests/conftest.py index ecf8c0040c..225fc8907b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,7 @@ from traitlets.config import Config import jupyter_core.paths +import jupyter_server.extension.serverextension from jupyter_server.serverapp import ServerApp from jupyter_server.utils import url_path_join @@ -73,6 +74,10 @@ def expected_http_error(error, expected_code, expected_message=None): config_dir = pytest.fixture(lambda tmp_path: mkdir(tmp_path, 'config')) runtime_dir = pytest.fixture(lambda tmp_path: mkdir(tmp_path, 'runtime')) root_dir = pytest.fixture(lambda tmp_path: mkdir(tmp_path, 'root_dir')) +system_jupyter_path = pytest.fixture(lambda tmp_path: mkdir(tmp_path, 'share', 'jupyter')) +env_jupyter_path = pytest.fixture(lambda tmp_path: mkdir(tmp_path, 'env', 'share', 'jupyter')) +system_config_path = pytest.fixture(lambda tmp_path: mkdir(tmp_path, 'etc', 'jupyter')) +env_config_path = pytest.fixture(lambda tmp_path: mkdir(tmp_path, 'env', 'etc', 'jupyter')) argv = pytest.fixture(lambda: []) @pytest.fixture @@ -83,7 +88,11 @@ def environ( data_dir, config_dir, runtime_dir, - root_dir + root_dir, + system_jupyter_path, + system_config_path, + env_jupyter_path, + env_config_path ): monkeypatch.setenv('HOME', str(home_dir)) monkeypatch.setenv('PYTHONPATH', os.pathsep.join(sys.path)) @@ -91,10 +100,10 @@ def environ( monkeypatch.setenv('JUPYTER_CONFIG_DIR', str(config_dir)) monkeypatch.setenv('JUPYTER_DATA_DIR', str(data_dir)) monkeypatch.setenv('JUPYTER_RUNTIME_DIR', str(runtime_dir)) - monkeypatch.setattr(jupyter_core.paths, 'SYSTEM_JUPYTER_PATH', [str(mkdir(tmp_path, 'share', 'jupyter'))]) - monkeypatch.setattr(jupyter_core.paths, 'ENV_JUPYTER_PATH', [str(mkdir(tmp_path, 'env', 'share', 'jupyter'))]) - monkeypatch.setattr(jupyter_core.paths, 'SYSTEM_CONFIG_PATH', [str(mkdir(tmp_path, 'etc', 'jupyter'))]) - monkeypatch.setattr(jupyter_core.paths, 'ENV_CONFIG_PATH', [str(mkdir(tmp_path, 'env', 'etc', 'jupyter'))]) + monkeypatch.setattr(jupyter_core.paths, 'SYSTEM_JUPYTER_PATH', [str(system_jupyter_path)]) + monkeypatch.setattr(jupyter_core.paths, 'ENV_JUPYTER_PATH', [str(env_jupyter_path)]) + monkeypatch.setattr(jupyter_core.paths, 'SYSTEM_CONFIG_PATH', [str(system_config_path)]) + monkeypatch.setattr(jupyter_core.paths, 'ENV_CONFIG_PATH', [str(env_config_path)]) @pytest.fixture diff --git a/tests/extension/conftest.py b/tests/extension/conftest.py index a9475c0fdd..1dbf2170d2 100644 --- a/tests/extension/conftest.py +++ b/tests/extension/conftest.py @@ -1,10 +1,15 @@ +import sys import pytest from traitlets import Unicode + +from jupyter_core import paths +from jupyter_server.extension import serverextension +from jupyter_server.extension.serverextension import _get_config_dir from jupyter_server.extension.application import ExtensionApp from jupyter_server.extension.handler import ExtensionHandler -# --------------- Build a mock extension -------------- +# ----------------- Mock Extension App ---------------------- class MockExtensionHandler(ExtensionHandler): @@ -12,23 +17,46 @@ def get(self): self.finish(self.config.mock_trait) -class MockExtension(ExtensionApp): - extension_name = 'mock' +class MockExtensionApp(ExtensionApp): + extension_name = 'mockextension' mock_trait = Unicode('mock trait', config=True) + loaded = False + def initialize_handlers(self): self.handlers.append(('/mock', MockExtensionHandler)) + self.loaded = True + + @staticmethod + def _jupyter_server_extension_paths(): + return [{ + 'module': '_mockdestination/index' + }] + + +@pytest.fixture +def extension_environ(env_config_path, monkeypatch): + monkeypatch.setattr(serverextension, 'ENV_CONFIG_PATH', [str(env_config_path)]) + monkeypatch.setattr(serverextension, 'ENV_CONFIG_PATH', [str(env_config_path)]) @pytest.fixture def config_file(config_dir): - f = config_dir.joinpath('jupyter_mock_config.py') - f.write_text("c.MockExtension.mock_trait ='config from file'") + f = config_dir.joinpath('jupyter_mockextension_config.py') + f.write_text("c.MockExtensionApp.mock_trait ='config from file'") return f @pytest.fixture def extended_serverapp(serverapp): - m = MockExtension() + m = MockExtensionApp() m.initialize(serverapp) - return m \ No newline at end of file + return m + + +@pytest.fixture +def inject_mock_extension(environ, extension_environ): + def ext(modulename='mockextension'): + sys.modules[modulename] = e = MockExtensionApp() + return e + return ext diff --git a/tests/extension/test_app.py b/tests/extension/test_app.py index e843deea48..160acb3a87 100644 --- a/tests/extension/test_app.py +++ b/tests/extension/test_app.py @@ -3,11 +3,11 @@ from jupyter_server.serverapp import ServerApp from jupyter_server.extension.application import ExtensionApp -from .conftest import MockExtension +from .conftest import MockExtensionApp def test_instance_creation(): - mock_extension = MockExtension() + mock_extension = MockExtensionApp() assert mock_extension.static_paths == [] assert mock_extension.template_paths == [] assert mock_extension.settings == {} @@ -15,7 +15,7 @@ def test_instance_creation(): def test_initialize(serverapp): - mock_extension = MockExtension() + mock_extension = MockExtensionApp() mock_extension.initialize(serverapp) # Check that settings and handlers were added to the mock extension. assert isinstance(mock_extension.serverapp, ServerApp) @@ -38,7 +38,7 @@ def test_initialize(serverapp): def test_instance_creation_with_instance_args(trait_name, trait_value): kwarg = {} kwarg.setdefault(trait_name, trait_value) - mock_extension = MockExtension(**kwarg) + mock_extension = MockExtensionApp(**kwarg) assert getattr(mock_extension, trait_name) == trait_value @@ -50,9 +50,9 @@ def test_instance_creation_with_argv(serverapp, trait_name, trait_value): kwarg = {} kwarg.setdefault(trait_name, trait_value) argv = [ - '--MockExtension.{name}={value}'.format(name=trait_name, value=trait_value) + '--MockExtensionApp.{name}={value}'.format(name=trait_name, value=trait_value) ] - mock_extension = MockExtension() + mock_extension = MockExtensionApp() mock_extension.initialize(serverapp, argv=argv) assert getattr(mock_extension, trait_name) == trait_value @@ -60,7 +60,7 @@ def test_instance_creation_with_argv(serverapp, trait_name, trait_value): def test_extensionapp_load_config_file(config_file, serverapp, extended_serverapp): # Assert default config_file_paths is the same in the app and extension. assert extended_serverapp.config_file_paths == serverapp.config_file_paths - assert extended_serverapp.config_file_name == 'jupyter_mock_config' + assert extended_serverapp.config_file_name == 'jupyter_mockextension_config' assert extended_serverapp.config_dir == serverapp.config_dir # Assert that the trait is updated by config file assert extended_serverapp.mock_trait == 'config from file' diff --git a/tests/extension/test_entrypoint.py b/tests/extension/test_entrypoint.py new file mode 100644 index 0000000000..e07bb00c4f --- /dev/null +++ b/tests/extension/test_entrypoint.py @@ -0,0 +1,31 @@ +import pytest + +from jupyter_core import paths +from jupyter_server.extension import serverextension + +# All test coroutines will be treated as marked. +pytestmark = pytest.mark.script_launch_mode('subprocess') + + +def test_server_extension_list(environ, script_runner): + ret = script_runner.run('jupyter', 'server', 'extension', 'list') + assert ret.success + + +def test_server_extension_enable(environ, inject_mock_extension, script_runner): + # 'mock' is not a valid extension The entry point should complete + # but print to sterr. + inject_mock_extension() + extension_name = 'mockextension' + ret = script_runner.run('jupyter', 'server', 'extension', 'enable', extension_name) + assert ret.success + assert 'Enabling: {}'.format(extension_name) in ret.stderr + + +def test_server_extension_disable(environ, script_runner): + # 'mock' is not a valid extension The entry point should complete + # but print to sterr. + extension_name = 'mockextension' + ret = script_runner.run('jupyter', 'server', 'extension', 'disable', extension_name) + assert ret.success + assert 'Disabling: {}'.format(extension_name) in ret.stderr diff --git a/tests/extension/test_api.py b/tests/extension/test_handler.py similarity index 83% rename from tests/extension/test_api.py rename to tests/extension/test_handler.py index 8b23a01d63..b8cc8714b6 100644 --- a/tests/extension/test_api.py +++ b/tests/extension/test_handler.py @@ -1,7 +1,7 @@ import pytest from jupyter_server.serverapp import ServerApp -from .conftest import MockExtension +from .conftest import MockExtensionApp # ------------------ Start tests ------------------- @@ -16,7 +16,7 @@ async def test_handler(fetch, extended_serverapp): async def test_handler_setting(fetch, serverapp): # Configure trait in Mock Extension. - m = MockExtension(mock_trait='test mock trait') + m = MockExtensionApp(mock_trait='test mock trait') m.initialize(serverapp) # Test that the extension trait was picked up by the webapp. @@ -30,8 +30,8 @@ async def test_handler_setting(fetch, serverapp): async def test_handler_argv(fetch, serverapp): # Configure trait in Mock Extension. - m = MockExtension() - argv = ['--MockExtension.mock_trait="test mock trait"'] + m = MockExtensionApp() + argv = ['--MockExtensionApp.mock_trait="test mock trait"'] m.initialize(serverapp, argv=argv) # Test that the extension trait was picked up by the webapp. diff --git a/tests/extension/test_serverextension.py b/tests/extension/test_serverextension.py new file mode 100644 index 0000000000..3c71eb694f --- /dev/null +++ b/tests/extension/test_serverextension.py @@ -0,0 +1,124 @@ +import sys +import pytest +from collections import OrderedDict + +from types import SimpleNamespace + +from traitlets.tests.utils import check_help_all_output + +from ..conftest import mkdir + +from jupyter_server.serverapp import ServerApp +from jupyter_server.extension import serverextension +from jupyter_server.extension.serverextension import ( + validate_server_extension, + toggle_server_extension_python, + _get_config_dir +) +from jupyter_server.config_manager import BaseJSONConfigManager + + +def test_help_output(): + check_help_all_output('jupyter_server.extension.serverextension') + check_help_all_output('jupyter_server.extension.serverextension', ['enable']) + check_help_all_output('jupyter_server.extension.serverextension', ['disable']) + check_help_all_output('jupyter_server.extension.serverextension', ['install']) + check_help_all_output('jupyter_server.extension.serverextension', ['uninstall']) + + +def get_config(sys_prefix=True): + cm = BaseJSONConfigManager(config_dir=_get_config_dir(sys_prefix=sys_prefix)) + data = cm.get("jupyter_server_config") + return data.get("ServerApp", {}).get("jpserver_extensions", {}) + + +def test_enable(inject_mock_extension): + inject_mock_extension() + toggle_server_extension_python('mockextension', True) + config = get_config() + assert config['mockextension'] + + +def test_disable(inject_mock_extension): + inject_mock_extension() + toggle_server_extension_python('mockextension', True) + toggle_server_extension_python('mockextension', False) + + config = get_config() + assert not config['mockextension'] + + +def test_merge_config( + env_config_path, + inject_mock_extension, + configurable_serverapp + ): + # enabled at sys level + inject_mock_extension('mockext_sys') + validate_server_extension('mockext_sys') + # enabled at sys, disabled at user + inject_mock_extension('mockext_both') + validate_server_extension('mockext_both') + # enabled at user + inject_mock_extension('mockext_user') + validate_server_extension('mockext_user') + # enabled at Python + inject_mock_extension('mockext_py') + validate_server_extension('mockext_py') + + # Toggle each extension module with a JSON config file + # at the sys-prefix config dir. + toggle_server_extension_python('mockext_sys', enabled=True, sys_prefix=True) + toggle_server_extension_python('mockext_user', enabled=True, user=True) + + # Write this configuration in two places, sys-prefix and user. + # sys-prefix supercedes users, so the extension should be disabled + # when these two configs merge. + toggle_server_extension_python('mockext_both', enabled=True, user=True) + toggle_server_extension_python('mockext_both', enabled=False, sys_prefix=True) + + # Enable the last extension, mockext_py, using the CLI interface. + app = configurable_serverapp( + config_dir=str(env_config_path), + argv=['--ServerApp.jpserver_extensions={"mockext_py":True}'] + ) + # Verify that extensions are enabled and merged properly. + extensions = app.jpserver_extensions + assert extensions['mockext_user'] + assert extensions['mockext_sys'] + assert extensions['mockext_py'] + # Merging should causes this extension to be disabled. + assert not extensions['mockext_both'] + + +@pytest.fixture +def ordered_server_extensions(): + mockextension1 = SimpleNamespace() + mockextension2 = SimpleNamespace() + + def load_jupyter_server_extension(obj): + obj.mockI = True + obj.mock_shared = 'I' + + mockextension1.load_jupyter_server_extension = load_jupyter_server_extension + + def load_jupyter_server_extension(obj): + obj.mockII = True + obj.mock_shared = 'II' + + mockextension2.load_jupyter_server_extension = load_jupyter_server_extension + + sys.modules['mockextension2'] = mockextension2 + sys.modules['mockextension1'] = mockextension1 + + +def test_load_ordered(ordered_server_extensions): + app = ServerApp() + app.jpserver_extensions = OrderedDict([('mockextension2',True),('mockextension1',True)]) + + app.init_server_extensions() + + assert app.mockII is True, "Mock II should have been loaded" + assert app.mockI is True, "Mock I should have been loaded" + assert app.mock_shared == 'II', "Mock II should be loaded after Mock I" + diff --git a/tests/test_extensions.py b/tests/test_extensions.py deleted file mode 100644 index 5cd11ed6bf..0000000000 --- a/tests/test_extensions.py +++ /dev/null @@ -1,157 +0,0 @@ -import sys -import pytest -from collections import OrderedDict - -from types import SimpleNamespace - -from traitlets.tests.utils import check_help_all_output - -from .conftest import mkdir - -from jupyter_core import paths -from jupyter_server.serverapp import ServerApp -from jupyter_server import extensions, extensions_base -from jupyter_server.extensions import toggle_serverextension_python, _get_config_dir -from jupyter_server.config_manager import BaseJSONConfigManager - - -def test_help_output(): - check_help_all_output('jupyter_server.extensions') - check_help_all_output('jupyter_server.extensions', ['enable']) - check_help_all_output('jupyter_server.extensions', ['disable']) - check_help_all_output('jupyter_server.extensions', ['install']) - check_help_all_output('jupyter_server.extensions', ['uninstall']) - - -outer_file = __file__ - - -@pytest.fixture -def environ( - monkeypatch, - tmp_path, - data_dir, - config_dir, - ): - system_data_dir = tmp_path / 'system_data' - system_config_dir = tmp_path / 'system_config' - system_path = [str(system_data_dir)] - system_config_path = [str(system_config_dir)] - - # Set global environments variable - monkeypatch.setenv('JUPYTER_CONFIG_DIR', str(config_dir)) - monkeypatch.setenv('JUPYTER_DATA_DIR', str(data_dir)) - - # Set paths for each extension. - for mod in (paths,): - monkeypatch.setattr(mod, 'SYSTEM_JUPYTER_PATH', system_path) - monkeypatch.setattr(mod, 'ENV_JUPYTER_PATH', []) - for mod in (paths, extensions_base): - monkeypatch.setattr(mod, 'SYSTEM_CONFIG_PATH', system_config_path) - monkeypatch.setattr(mod, 'ENV_CONFIG_PATH', []) - - assert paths.jupyter_config_path() == [str(config_dir)] + system_config_path - assert extensions_base._get_config_dir(user=False) == str(system_config_dir) - assert paths.jupyter_path() == [str(data_dir)] + system_path - - -class MockExtensionModule(object): - __file__ = outer_file - - @staticmethod - def _jupyter_server_extension_paths(): - return [{ - 'module': '_mockdestination/index' - }] - - loaded = False - - def load_jupyter_server_extension(self, app): - self.loaded = True - - -def get_config(user=True): - cm = BaseJSONConfigManager(config_dir=_get_config_dir(user)) - data = cm.get("jupyter_server_config") - return data.get("ServerApp", {}).get("jpserver_extensions", {}) - - -@pytest.fixture -def inject_mock_extension(environ): - def ext(modulename='mockextension'): - sys.modules[modulename] = e = MockExtensionModule() - return e - return ext - - -def test_enable(inject_mock_extension): - inject_mock_extension() - toggle_serverextension_python('mockextension', True) - config = get_config() - assert config['mockextension'] - - -def test_disable(inject_mock_extension): - inject_mock_extension() - toggle_serverextension_python('mockextension', True) - toggle_serverextension_python('mockextension', False) - - config = get_config() - assert not config['mockextension'] - - -def test_merge_config(inject_mock_extension): - # enabled at sys level - mock_sys = inject_mock_extension('mockext_sys') - # enabled at sys, disabled at user - mock_both = inject_mock_extension('mockext_both') - # enabled at user - mock_user = inject_mock_extension('mockext_user') - # enabled at Python - mock_py = inject_mock_extension('mockext_py') - - toggle_serverextension_python('mockext_sys', enabled=True, user=False) - toggle_serverextension_python('mockext_user', enabled=True, user=True) - toggle_serverextension_python('mockext_both', enabled=True, user=False) - toggle_serverextension_python('mockext_both', enabled=False, user=True) - - app = ServerApp(jpserver_extensions={'mockext_py': True}) - app.init_server_extension_config() - app.init_server_extensions() - - assert mock_user.loaded - assert mock_sys.loaded - assert mock_py.loaded - assert not mock_both.loaded - - -@pytest.fixture -def ordered_server_extensions(): - mockextension1 = SimpleNamespace() - mockextension2 = SimpleNamespace() - - def load_jupyter_server_extension(obj): - obj.mockI = True - obj.mock_shared = 'I' - - mockextension1.load_jupyter_server_extension = load_jupyter_server_extension - - def load_jupyter_server_extension(obj): - obj.mockII = True - obj.mock_shared = 'II' - - mockextension2.load_jupyter_server_extension = load_jupyter_server_extension - - sys.modules['mockextension2'] = mockextension2 - sys.modules['mockextension1'] = mockextension1 - - -def test_load_ordered(ordered_server_extensions): - app = ServerApp() - app.jpserver_extensions = OrderedDict([('mockextension2',True),('mockextension1',True)]) - - app.init_server_extensions() - - assert app.mockII is True, "Mock II should have been loaded" - assert app.mockI is True, "Mock I should have been loaded" - assert app.mock_shared == 'II', "Mock II should be loaded after Mock I"