From c885af0441551ceaea961e001f74d8124a2f5212 Mon Sep 17 00:00:00 2001 From: Zachary Sailer Date: Mon, 20 Apr 2020 09:31:56 -0700 Subject: [PATCH] Enable ServerApp to discover ExtensionApps (and their config). (#180) * enable extensionapp config to be discoverable from serveapp * add some comments for future devs * allow None in non-ExtensionApp extensions * adjust tests to capture changes * minor bug fixes * renamed config pytest fixture * standardize extension loading mechanism * pass serverapp and extesnionapp to extensionapp handlers * use static url prefix for static paths * iniitalize all enabled extension, then load later * split extension initialization and loading * Upgrade examples to align on discovery branch * Polish examples * Launch example via python module * Avoid to run initialisation methods twice * Add main for simple_ext2 and simple_ext11 * minor changes to extension toggler * adding some comments throughout the code * move all CLI handling to the ServerApp * remove old traits from extensionapp * update tests * update tests with changes to extensionapp * fix examples entrypoint * add test dependency: pytest-lazy-fixture * unpin pytest * import lazyfixture directly due to changes in pytest * drop pytest-lazy-fixture * cleaner error handling in init_server_extension * minor clean up * minor fixes after review * add underscore as prefix to extension function * remove load_jupyter_server_extension from examples * minor typo in example comment Co-authored-by: Eric Charles --- examples/simple/README.md | 35 ++- examples/simple/jupyter_simple_ext1_config.py | 1 + examples/simple/package.json | 2 +- examples/simple/simple_ext1/__init__.py | 10 +- examples/simple/simple_ext1/__main__.py | 4 + examples/simple/simple_ext1/application.py | 8 +- examples/simple/simple_ext11/__init__.py | 12 +- examples/simple/simple_ext11/__main__.py | 4 + examples/simple/simple_ext11/application.py | 12 +- examples/simple/simple_ext2/__init__.py | 10 +- examples/simple/simple_ext2/__main__.py | 4 + examples/simple/simple_ext2/application.py | 6 +- jupyter_server/base/handlers.py | 24 +- jupyter_server/extension/application.py | 255 ++++++++---------- jupyter_server/extension/handler.py | 27 +- jupyter_server/extension/serverextension.py | 156 +++++++---- jupyter_server/pytest_plugin.py | 32 +-- jupyter_server/serverapp.py | 175 +++++++++--- setup.py | 3 +- tests/extension/conftest.py | 85 +----- tests/extension/mockextensions/__init__.py | 24 ++ tests/extension/mockextensions/app.py | 41 +++ tests/extension/mockextensions/mock1.py | 16 ++ tests/extension/mockextensions/mock2.py | 16 ++ tests/extension/mockextensions/mock3.py | 6 + .../extension/mockextensions/mockext_both.py | 16 ++ tests/extension/mockextensions/mockext_py.py | 16 ++ tests/extension/mockextensions/mockext_sys.py | 16 ++ .../extension/mockextensions/mockext_user.py | 16 ++ tests/extension/test_app.py | 97 +++---- tests/extension/test_entrypoint.py | 33 ++- tests/extension/test_handler.py | 54 ++-- tests/extension/test_serverextension.py | 134 +++++---- tests/services/contents/test_config.py | 2 +- tests/services/kernels/test_config.py | 2 +- 35 files changed, 838 insertions(+), 516 deletions(-) create mode 100644 examples/simple/simple_ext1/__main__.py create mode 100644 examples/simple/simple_ext11/__main__.py create mode 100644 examples/simple/simple_ext2/__main__.py create mode 100644 tests/extension/mockextensions/__init__.py create mode 100644 tests/extension/mockextensions/app.py create mode 100644 tests/extension/mockextensions/mock1.py create mode 100644 tests/extension/mockextensions/mock2.py create mode 100644 tests/extension/mockextensions/mock3.py create mode 100644 tests/extension/mockextensions/mockext_both.py create mode 100644 tests/extension/mockextensions/mockext_py.py create mode 100644 tests/extension/mockextensions/mockext_sys.py create mode 100644 tests/extension/mockextensions/mockext_user.py diff --git a/examples/simple/README.md b/examples/simple/README.md index 7caa527479..45f4d8130b 100644 --- a/examples/simple/README.md +++ b/examples/simple/README.md @@ -67,6 +67,12 @@ open http://localhost:8888/simple_ext1/redirect open http://localhost:8888/static/simple_ext1/favicon.ico ``` +You can also start the server extension with python modules. + +```bash +python -m simple_ext1 +``` + ## Extension 1 and Extension 2 The following command starts both the `simple_ext1` and `simple_ext2` extensions. @@ -90,9 +96,12 @@ open http://localhost:8888/simple_ext2/params/test?var1=foo Optionally, you can copy `simple_ext1.json` and `simple_ext2.json` configuration to your env `etc` folder and start only Extension 1, which will also start Extension 2. ```bash -pip uninstall -y jupyter_simple_ext && \ +pip uninstall -y jupyter_server_example && \ python setup.py install && \ cp -r ./etc $(dirname $(which jupyter))/.. +``` + +```bash # Start the jupyter server extension simple_ext1, it will also load simple_ext2 because of load_other_extensions = True.. # When you invoke with the entrypoint, the default url will be opened in your browser. jupyter simple-ext1 @@ -102,18 +111,20 @@ jupyter simple-ext1 Stop any running server (with `CTRL+C`) and start with additional configuration on the command line. -The provided settings via CLI will override the configuration that reside in the files (`jupyter_simple_ext1_config.py`...) +The provided settings via CLI will override the configuration that reside in the files (`jupyter_server_example1_config.py`...) ```bash jupyter simple-ext1 --SimpleApp1.configA="ConfigA from command line" ``` -Check the log, it should return on startup something like the following base on the trait you have defined in the CLI and in the `jupyter_simple_ext1_config.py`. +Check the log, it should return on startup print the Config object. + +The content of the Config is based on the trait you have defined via the `CLI` and in the `jupyter_server_example1_config.py`. ``` [SimpleApp1] Config {'SimpleApp1': {'configA': 'ConfigA from file', 'configB': 'ConfigB from file', 'configC': 'ConfigC from file'}} [SimpleApp1] Config {'SimpleApp1': {'configA': 'ConfigA from file', 'configB': 'ConfigB from file', 'configC': 'ConfigC from file'}} -[SimpleApp2] WARNING | Config option `configD` not recognized by `SimpleApp2`. Did you mean `config_file`? +[SimpleApp2] WARNING | Config option `configD` not recognized by `SimpleApp2`. Did you mean one of: `configA, configB, configC`? [SimpleApp2] Config {'SimpleApp2': {'configD': 'ConfigD from file'}} [SimpleApp1] Config {'SimpleApp1': {'configA': 'ConfigA from command line', 'configB': 'ConfigB from file', 'configC': 'ConfigC from file'}} ``` @@ -133,27 +144,31 @@ Try with the above links to check that only Extension 2 is responding (Extension `Extension 11` extends `Extension 1` and brings a few more configs. -Run `jupyter simple-ext11 --generate-config && vi ~/.jupyter/jupyter_config.py`. - -> TODO `--generate-config` returns an exception `"The ExtensionApp has not ServerApp "` +```bash +# TODO `--generate-config` returns an exception `"The ExtensionApp has not ServerApp "` +jupyter simple-ext11 --generate-config && vi ~/.jupyter/jupyter_config.py`. +``` The generated configuration should contains the following. ```bash -TBD +# TODO ``` The `hello`, `ignore_js` and `simple11_dir` are traits defined on the SimpleApp11 class. It also implements additional flags and aliases for these traits. -+ The `--hello` flag will log on startup `Hello Simple11 - You have provided the --hello flag or defined a c.SimpleApp1.hello == True`. -+ The `--simple11-dir` alias will set `SimpleExt11.simple11_dir` settings. +- The `--hello` flag will log on startup `Hello Simple11 - You have provided the --hello flag or defined a c.SimpleApp1.hello == True` +- The `ignore_js` flag +- The `--simple11-dir` alias will set `SimpleExt11.simple11_dir` settings Stop any running server and then start the simple-ext11. ```bash jupyter simple-ext11 --hello --simple11-dir any_folder +# You can also launch with a module +python -m simple_ext11 --hello # TODO FIX the following command, simple11 does not work launching with jpserver_extensions parameter. jupyter server --ServerApp.jpserver_extensions="{'simple_ext11': True}" --hello --simple11-dir any_folder ``` diff --git a/examples/simple/jupyter_simple_ext1_config.py b/examples/simple/jupyter_simple_ext1_config.py index bf4624a486..7139e046a7 100644 --- a/examples/simple/jupyter_simple_ext1_config.py +++ b/examples/simple/jupyter_simple_ext1_config.py @@ -1,3 +1,4 @@ c.SimpleApp1.configA = 'ConfigA from file' c.SimpleApp1.configB = 'ConfigB from file' c.SimpleApp1.configC = 'ConfigC from file' +c.SimpleApp1.configD = 'ConfigD from file' diff --git a/examples/simple/package.json b/examples/simple/package.json index a1b2132753..37f76bad67 100644 --- a/examples/simple/package.json +++ b/examples/simple/package.json @@ -1,5 +1,5 @@ { - "name": "jupyter-simple-ext", + "name": "jupyter-server-example", "version": "0.0.1", "private": true, "scripts": { diff --git a/examples/simple/simple_ext1/__init__.py b/examples/simple/simple_ext1/__init__.py index cf3068ed96..4416f53792 100644 --- a/examples/simple/simple_ext1/__init__.py +++ b/examples/simple/simple_ext1/__init__.py @@ -1,8 +1,8 @@ from .application import SimpleApp1 -def _jupyter_server_extension_paths(): - return [ - {'module': 'simple_ext1'} - ] -load_jupyter_server_extension = SimpleApp1.load_jupyter_server_extension +def _jupyter_server_extension_paths(): + return [{ + 'module': 'simple_ext1.application', + 'app': SimpleApp1 + }] \ No newline at end of file diff --git a/examples/simple/simple_ext1/__main__.py b/examples/simple/simple_ext1/__main__.py new file mode 100644 index 0000000000..6ca6f5d746 --- /dev/null +++ b/examples/simple/simple_ext1/__main__.py @@ -0,0 +1,4 @@ +from .application import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/simple/simple_ext1/application.py b/examples/simple/simple_ext1/application.py index 3ba0d18599..20a9aebc75 100644 --- a/examples/simple/simple_ext1/application.py +++ b/examples/simple/simple_ext1/application.py @@ -1,19 +1,19 @@ import os, jinja2 from traitlets import Unicode from jupyter_server.extension.application import ExtensionApp, ExtensionAppJinjaMixin -from .handlers import (DefaultHandler, RedirectHandler, +from .handlers import (DefaultHandler, RedirectHandler, ParameterHandler, TemplateHandler, TypescriptHandler, ErrorHandler) DEFAULT_STATIC_FILES_PATH = os.path.join(os.path.dirname(__file__), "static") DEFAULT_TEMPLATE_FILES_PATH = os.path.join(os.path.dirname(__file__), "templates") class SimpleApp1(ExtensionAppJinjaMixin, ExtensionApp): - + # The name of the extension. extension_name = "simple_ext1" - # Te url that your extension will serve its homepage. - default_url = '/simple_ext1/default' + # The url that your extension will serve its homepage. + extension_url = '/simple_ext1/default' # Should your extension expose other server extensions when launched directly? load_other_extensions = True diff --git a/examples/simple/simple_ext11/__init__.py b/examples/simple/simple_ext11/__init__.py index cf3068ed96..c9f9bbe1eb 100644 --- a/examples/simple/simple_ext11/__init__.py +++ b/examples/simple/simple_ext11/__init__.py @@ -1,8 +1,10 @@ -from .application import SimpleApp1 +from .application import SimpleApp11 + def _jupyter_server_extension_paths(): return [ - {'module': 'simple_ext1'} - ] - -load_jupyter_server_extension = SimpleApp1.load_jupyter_server_extension + { + 'module': 'simple_ext11.application', + 'app': SimpleApp11 + } + ] \ No newline at end of file diff --git a/examples/simple/simple_ext11/__main__.py b/examples/simple/simple_ext11/__main__.py new file mode 100644 index 0000000000..6ca6f5d746 --- /dev/null +++ b/examples/simple/simple_ext11/__main__.py @@ -0,0 +1,4 @@ +from .application import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/simple/simple_ext11/application.py b/examples/simple/simple_ext11/application.py index d67a211fd9..bc04a16a5c 100644 --- a/examples/simple/simple_ext11/application.py +++ b/examples/simple/simple_ext11/application.py @@ -13,12 +13,12 @@ class SimpleApp11(SimpleApp1): aliases.update({ 'simple11-dir': 'SimpleApp11.simple11_dir', }) - + # The name of the extension. extension_name = "simple_ext11" # Te url that your extension will serve its homepage. - default_url = '/simple_ext11/default' + extension_url = '/simple_ext11/default' # Local path to static files directory. static_paths = [ @@ -37,12 +37,12 @@ class SimpleApp11(SimpleApp1): hello = Bool(False, config=True, - help='Say hello', + help='Say hello', ) ignore_js = Bool(False, config=True, - help='Ignore Javascript', + help='Ignore Javascript', ) @observe('ignore_js') @@ -56,8 +56,8 @@ def simple11_dir_formatted(self): def initialize_settings(self): self.log.info('hello: {}'.format(self.hello)) - if self.config['hello'] == True: - self.log.info("Hello Simple11 - You have provided the --hello flag or defined 'c.SimpleApp1.hello == True' in jupyter_server_config.py") + if self.hello == True: + self.log.info("Hello Simple11: You have launched with --hello flag or defined 'c.SimpleApp1.hello == True' in your config file") self.log.info('ignore_js: {}'.format(self.ignore_js)) super().initialize_settings() diff --git a/examples/simple/simple_ext2/__init__.py b/examples/simple/simple_ext2/__init__.py index f9d5935153..8c47ed420f 100644 --- a/examples/simple/simple_ext2/__init__.py +++ b/examples/simple/simple_ext2/__init__.py @@ -1,8 +1,10 @@ from .application import SimpleApp2 + def _jupyter_server_extension_paths(): return [ - {'module': 'simple_ext2'}, - ] - -load_jupyter_server_extension = SimpleApp2.load_jupyter_server_extension + { + 'module': 'simple_ext2.application', + 'app': SimpleApp2 + }, + ] \ No newline at end of file diff --git a/examples/simple/simple_ext2/__main__.py b/examples/simple/simple_ext2/__main__.py new file mode 100644 index 0000000000..6ca6f5d746 --- /dev/null +++ b/examples/simple/simple_ext2/__main__.py @@ -0,0 +1,4 @@ +from .application import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/simple/simple_ext2/application.py b/examples/simple/simple_ext2/application.py index 2c26ca9c0a..7251da7900 100644 --- a/examples/simple/simple_ext2/application.py +++ b/examples/simple/simple_ext2/application.py @@ -7,15 +7,15 @@ DEFAULT_TEMPLATE_FILES_PATH = os.path.join(os.path.dirname(__file__), "templates") class SimpleApp2(ExtensionAppJinjaMixin, ExtensionApp): - + # The name of the extension. extension_name = "simple_ext2" # Te url that your extension will serve its homepage. - default_url = '/simple_ext2' + extension_url = '/simple_ext2' # Should your extension expose other server extensions when launched directly? - load_other_extensions = False + load_other_extensions = True # Local path to static files directory. static_paths = [ diff --git a/jupyter_server/base/handlers.py b/jupyter_server/base/handlers.py index 1d9bbb3cd5..75467718c8 100755 --- a/jupyter_server/base/handlers.py +++ b/jupyter_server/base/handlers.py @@ -154,7 +154,7 @@ def cookie_name(self): self.request.host )) return self.settings.get('cookie_name', default_cookie_name) - + @property def logged_in(self): """Is a user currently logged in?""" @@ -203,23 +203,23 @@ def log(self): def jinja_template_vars(self): """User-supplied values to supply to jinja templates.""" return self.settings.get('jinja_template_vars', {}) - + #--------------------------------------------------------------- # URLs #--------------------------------------------------------------- - + @property def version_hash(self): """The version hash to use for cache hints for static files""" return self.settings.get('version_hash', '') - + @property def mathjax_url(self): url = self.settings.get('mathjax_url', '') if not url or url_is_absolute(url): return url return url_path_join(self.base_url, url) - + @property def mathjax_config(self): return self.settings.get('mathjax_config', 'TeX-AMS-MML_HTMLorMML-full,Safe') @@ -241,11 +241,11 @@ def contents_js_source(self): self.log.debug("Using contents: %s", self.settings.get('contents_js_source', 'services/contents')) return self.settings.get('contents_js_source', 'services/contents') - + #--------------------------------------------------------------- # Manager objects #--------------------------------------------------------------- - + @property def kernel_manager(self): return self.settings['kernel_manager'] @@ -253,15 +253,15 @@ def kernel_manager(self): @property def contents_manager(self): return self.settings['contents_manager'] - + @property def session_manager(self): return self.settings['session_manager'] - + @property def terminal_manager(self): return self.settings['terminal_manager'] - + @property def kernel_spec_manager(self): return self.settings['kernel_spec_manager'] @@ -273,7 +273,7 @@ def config_manager(self): #--------------------------------------------------------------- # CORS #--------------------------------------------------------------- - + @property def allow_origin(self): """Normal Access-Control-Allow-Origin""" @@ -310,7 +310,7 @@ def set_default_headers(self): if self.allow_credentials: self.set_header("Access-Control-Allow-Credentials", 'true') - + def set_attachment_header(self, filename): """Set Content-Disposition: attachment header diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 2d935d7bbc..bcec46657e 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -4,32 +4,26 @@ from jinja2 import Environment, FileSystemLoader from traitlets import ( - Unicode, - List, + Unicode, + List, Dict, - Bool, - default, + default, validate ) from traitlets.config import Config from jupyter_core.application import JupyterApp -from jupyter_server.serverapp import ServerApp, aliases, flags +from jupyter_server.serverapp import ServerApp from jupyter_server.transutils import _ from jupyter_server.utils import url_path_join from .handler import ExtensionHandlerMixin -# Remove alias for nested classes in ServerApp. -# Nested classes are not allowed in ExtensionApp. -try: - aliases.pop('transport') -except KeyError: - pass -#----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- # Util functions and classes. -#----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- + def _preparse_for_subcommand(Application, argv): """Preparse command line to look for subcommands. @@ -50,8 +44,8 @@ def _preparse_for_subcommand(Application, argv): def _preparse_for_stopping_flags(Application, argv): - """Looks for 'help', 'version', and 'generate-config; commands - in command line. If found, raises the help and version of + """Looks for 'help', 'version', and 'generate-config; commands + in command line. If found, raises the help and version of current Application. This is useful for traitlets applications that have to parse @@ -88,14 +82,16 @@ def _preparse_for_stopping_flags(Application, argv): class ExtensionAppJinjaMixin: """Use Jinja templates for HTML templates on top of an ExtensionApp.""" - + jinja2_options = Dict( help=_("""Options to pass to the jinja2 environment for this - extension. """) ).tag(config=True) def _prepare_templates(self): + # Get templates defined in a subclass. + self.initialize_templates() + # Add templates to web app settings if extension has templates. if len(self.template_paths) > 0: self.settings.update({ @@ -104,44 +100,42 @@ def _prepare_templates(self): # Create a jinja environment for logging html templates. self.jinja2_env = Environment( - loader=FileSystemLoader(self.template_paths), + loader=FileSystemLoader(self.template_paths), extensions=['jinja2.ext.i18n'], autoescape=True, **self.jinja2_options ) - # Get templates defined in a subclass. - self.initialize_templates() # Add the jinja2 environment for this extension to the tornado settings. self.settings.update( { - "{}_jinja2_env".format(self.extension_name): self.jinja2_env + "{}_jinja2_env".format(self.extension_name): self.jinja2_env } ) -#----------------------------------------------------------------------------- -# Aliases and Flags -#----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- +# ExtensionApp +# ----------------------------------------------------------------------------- -flags['no-browser']=( - {'ExtensionApp' : {'open_browser' : True}}, - _("Prevent the opening of the default url in the browser.") -) -#----------------------------------------------------------------------------- +class JupyterServerExtensionException(Exception): + """Exception class for raising for Server extensions errors.""" + +# ----------------------------------------------------------------------------- # ExtensionApp -#----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- + class ExtensionApp(JupyterApp): """Base class for configurable Jupyter Server Extension Applications. ExtensionApp subclasses can be initialized two ways: - 1. Extension is listed as a jpserver_extension, and ServerApp calls - its load_jupyter_server_extension classmethod. This is the + 1. Extension is listed as a jpserver_extension, and ServerApp calls + its load_jupyter_server_extension classmethod. This is the classic way of loading a server extension. 2. Extension is launched directly by calling its `launch_instance` - class method. This method can be set as a entry_point in + class method. This method can be set as a entry_point in the extensions setup.py """ # Subclasses should override this trait. Tells the server if @@ -176,16 +170,14 @@ def _validate_extension_name(self, proposal): return value raise ValueError("Extension name must be a string, found {type}.".format(type=type(value))) + # Extension URL sets the default landing page for this extension. + extension_url = "/" + # Extension can configure the ServerApp from the command-line classes = [ ServerApp, ] - aliases = aliases - flags = flags - - subcommands = {} - @property def static_url_prefix(self): return "/static/{extension_name}/".format( @@ -193,13 +185,13 @@ def static_url_prefix(self): static_paths = List(Unicode(), help="""paths to search for serving static files. - + This allows adding javascript/css to be available from the notebook server machine, or overriding individual files in the IPython """ ).tag(config=True) - template_paths = List(Unicode(), + template_paths = List(Unicode(), help=_("""Paths to search for serving jinja templates. Can be used to override templates from notebook.templates.""") @@ -213,62 +205,12 @@ def static_url_prefix(self): help=_("""Handlers appended to the server.""") ).tag(config=True) - def _config_dir_default(self): - """Point the config directory at the server's config_dir by default.""" - try: - return self.serverapp.config_dir - except AttributeError: - raise AttributeError( - "The ExtensionApp has not ServerApp " - "initialized. Try `.initialize_server()`." - ) - def _config_file_name_default(self): """The default config file name.""" if not self.extension_name: return '' return 'jupyter_{}_config'.format(self.extension_name.replace('-','_')) - default_url = Unicode('/', config=True, - help=_("The default URL to redirect to from `/`") - ) - - open_browser = Bool( - True, - help=_("Should the extension open a browser window?") - ) - - custom_display_url = Unicode(u'', config=True, - help=_("""Override URL shown to users. - - Replace actual URL, including protocol, address, port and base URL, - with the given value when displaying URL to the users. Do not change - the actual connection URL. If authentication token is enabled, the - token is added to the custom URL automatically. - - This option is intended to be used when the URL to display to the user - cannot be determined reliably by the Jupyter server (proxified - or containerized setups for example).""") - ) - - @default('custom_display_url') - def _default_custom_display_url(self): - """URL to display to the user.""" - # Get url from server. - url = url_path_join(self.serverapp.base_url, self.default_url) - return self.serverapp.get_url(self.serverapp.ip, url) - - def _write_browser_open_file(self, url, fh): - """Use to hijacks the server's browser-open file and open at - the extension's homepage. - """ - # Ignore server's url - del url - path = url_path_join(self.serverapp.base_url, self.default_url) - url = self.serverapp.get_url(path=path, token=self.serverapp.token) - jinja2_env = self.serverapp.web_app.settings['jinja2_env'] - template = jinja2_env.get_template('browser-open.html') - fh.write(template.render(open_url=url)) def initialize_settings(self): """Override this method to add handling of settings.""" @@ -284,7 +226,7 @@ def initialize_templates(self): def _prepare_config(self): """Builds a Config object from the extension's traits and passes - the object to the webapp's settings as `_config`. + the object to the webapp's settings as `_config`. """ traits = self.class_own_traits().keys() self.extension_config = Config({t: getattr(self, t) for t in traits}) @@ -298,6 +240,7 @@ def _prepare_settings(self): # Add static and template paths to settings. self.settings.update({ "{}_static_paths".format(self.extension_name): self.static_paths, + "{}".format(self.extension_name): self }) # Get setting defined by subclass using initialize_settings method. @@ -318,12 +261,12 @@ def _prepare_handlers(self): # Build url pattern including base_url pattern = url_path_join(webapp.settings['base_url'], handler_items[0]) handler = handler_items[1] - + # Get handler kwargs, if given kwargs = {} if issubclass(handler, ExtensionHandlerMixin): kwargs['extension_name'] = self.extension_name - try: + try: kwargs.update(handler_items[2]) except IndexError: pass @@ -334,12 +277,12 @@ def _prepare_handlers(self): # Add static endpoint for this extension, if static paths are given. if len(self.static_paths) > 0: # Append the extension's static directory to server handlers. - static_url = url_path_join("/static", self.extension_name, "(.*)") - + static_url = url_path_join(self.static_url_prefix, "(.*)") + # Construct handler. handler = ( - static_url, - webapp.settings['static_handler_class'], + static_url, + webapp.settings['static_handler_class'], {'path': self.static_paths} ) new_handlers.append(handler) @@ -354,34 +297,75 @@ def _prepare_templates(self): }) self.initialize_templates() - @staticmethod - def initialize_server(argv=[], load_other_extensions=True, **kwargs): - """Get an instance of the Jupyter Server.""" - # Get a jupyter server instance - serverapp = ServerApp.instance(**kwargs) - # Initialize ServerApp config. - # Parses the command line looking for - # ServerApp configuration. + @classmethod + def initialize_server(cls, argv=[], load_other_extensions=True, **kwargs): + """Creates an instance of ServerApp where this extension is enabled + (superceding disabling found in other config from files). + + This is necessary when launching the ExtensionApp directly from + the `launch_instance` classmethod. + """ + # The ExtensionApp needs to add itself as enabled extension + # to the jpserver_extensions trait, so that the ServerApp + # initializes it. + config = Config({ + "ServerApp": { + "jpserver_extensions": {cls.extension_name: True}, + "open_browser": True, + "default_url": cls.extension_url + } + }) + serverapp = ServerApp.instance(**kwargs, argv=[], config=config) serverapp.initialize(argv=argv, load_extensions=load_other_extensions) return serverapp - def initialize(self, serverapp, argv=[]): - """Initialize the extension app. - - This method: - - Loads the extension's config from file - - Updates the extension's config from argv - - Initializes templates environment - - Passes settings to webapp - - Appends handlers to webapp. + def link_to_serverapp(self, serverapp): + """Link the ExtensionApp to an initialized ServerApp. + + The ServerApp is stored as an attribute and config + is exchanged between ServerApp and `self` in case + the command line contains traits for the ExtensionApp + or the ExtensionApp's config files have server + settings. """ - # Initialize ServerApp. self.serverapp = serverapp - - # Initialize the extension application - super(ExtensionApp, self).initialize(argv=argv) + # Load config from an ExtensionApp's config files. + self.load_config_file() + # ServerApp's config might have picked up + # CLI config for the ExtensionApp. We call + # update_config to update ExtensionApp's + # traits with these values found in ServerApp's + # config. + # ServerApp config ---> ExtensionApp traits + self.update_config(self.serverapp.config) + # Use ExtensionApp's CLI parser to find any extra + # args that passed through ServerApp and + # now belong to ExtensionApp. + self.parse_command_line(self.serverapp.extra_args) + # If any config should be passed upstream to the + # ServerApp, do it here. + # i.e. ServerApp traits <--- ExtensionApp config + self.serverapp.update_config(self.config) + + def initialize(self): + """Initialize the extension app. The + corresponding server app and webapp should already + be initialized by this step. + + 1) Appends Handlers to the ServerApp, + 2) Passes config and settings from ExtensionApp + to the Tornado web application + 3) Points Tornado Webapp to templates and + static assets. + """ + if not hasattr(self, 'serverapp'): + msg = ( + "This extension has no attribute `serverapp`. " + "Try calling `.link_to_serverapp()` before calling " + "`.initialize()`." + ) + raise JupyterServerExtensionException(msg) - # Initialize config, settings, templates, and handlers. self._prepare_config() self._prepare_templates() self._prepare_settings() @@ -389,18 +373,10 @@ def initialize(self, serverapp, argv=[]): def start(self): """Start the underlying Jupyter server. - + Server should be started after extension is initialized. """ super(ExtensionApp, self).start() - # Override the browser open file to - # Override the server's display url to show extension's display URL. - self.serverapp.custom_display_url = self.custom_display_url - # Override the server's default option and open a broswer window. - self.serverapp.open_browser = self.open_browser - # Hijack the server's browser-open file to land on - # the extensions home page. - self.serverapp._write_browser_open_file = self._write_browser_open_file # Start the server. self.serverapp.start() @@ -411,18 +387,22 @@ def stop(self): self.serverapp.clear_instance() @classmethod - def load_jupyter_server_extension(cls, serverapp, argv=[], **kwargs): + def _load_jupyter_server_extension(cls, serverapp): """Initialize and configure this extension, then add the extension's settings and handlers to the server's web application. """ - # Configure and initialize extension. - extension = cls() - extension.initialize(serverapp, argv=argv) + try: + # Get loaded extension from serverapp. + extension = serverapp._enabled_extensions[cls.extension_name] + except KeyError: + extension = cls() + extension.link_to_serverapp(serverapp) + extension.initialize() return extension @classmethod def launch_instance(cls, argv=None, **kwargs): - """Launch the extension like an application. Initializes+configs a stock server + """Launch the extension like an application. Initializes+configs a stock server and appends the extension to the server. Then starts the server and routes to extension's landing page. """ @@ -439,10 +419,10 @@ def launch_instance(cls, argv=None, **kwargs): # Check for help, version, and generate-config arguments # before initializing server to make sure these # arguments trigger actions from the extension not the server. - _preparse_for_stopping_flags(cls, args) + _preparse_for_stopping_flags(cls, args) # Get a jupyter server instance. serverapp = cls.initialize_server( - argv=args, + argv=args, load_other_extensions=cls.load_other_extensions ) # Log if extension is blocking other extensions from loading. @@ -451,7 +431,4 @@ def launch_instance(cls, argv=None, **kwargs): "{ext_name} is running without loading " "other extensions.".format(ext_name=cls.extension_name) ) - - extension = cls.load_jupyter_server_extension(serverapp, argv=args, **kwargs) - # Start the ioloop. - extension.start() \ No newline at end of file + serverapp.start() \ No newline at end of file diff --git a/jupyter_server/extension/handler.py b/jupyter_server/extension/handler.py index 7febbb9c72..c60c566fa0 100644 --- a/jupyter_server/extension/handler.py +++ b/jupyter_server/extension/handler.py @@ -3,7 +3,7 @@ class ExtensionHandlerJinjaMixin: - """Mixin class for ExtensionApp handlers that use jinja templating for + """Mixin class for ExtensionApp handlers that use jinja templating for template rendering. """ def get_template(self, name): @@ -12,19 +12,28 @@ def get_template(self, name): return self.settings[env].get_template(name) -class ExtensionHandlerMixin(): - """Base class for Jupyter server extension handlers. +class ExtensionHandlerMixin: + """Base class for Jupyter server extension handlers. - Subclasses can serve static files behind a namespaced - endpoint: "/static//" + Subclasses can serve static files behind a namespaced + endpoint: "/static//" This allows multiple extensions to serve static files under - their own namespace and avoid intercepting requests for - other extensions. + their own namespace and avoid intercepting requests for + other extensions. """ def initialize(self, extension_name): self.extension_name = extension_name + @property + def extensionapp(self): + return self.settings[self.extension_name] + + @property + def serverapp(self): + key = "serverapp" + return self.settings[key] + @property def config(self): return self.settings["{}_config".format(self.extension_name)] @@ -44,8 +53,8 @@ def static_path(self): def static_url(self, path, include_host=None, **kwargs): """Returns a static URL for the given relative static file path. - This method requires you set the ``{extension_name}_static_path`` - setting in your extension (which specifies the root directory + This method requires you set the ``{extension_name}_static_path`` + setting in your extension (which specifies the root directory of your static files). This method returns a versioned url (by default appending ``?v=``), which allows the static files to be diff --git a/jupyter_server/extension/serverextension.py b/jupyter_server/extension/serverextension.py index de2b752a89..755920c53f 100644 --- a/jupyter_server/extension/serverextension.py +++ b/jupyter_server/extension/serverextension.py @@ -13,15 +13,39 @@ from jupyter_core.application import JupyterApp from jupyter_core.paths import ( - jupyter_config_dir, - jupyter_config_path, - ENV_CONFIG_PATH, + 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 +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": "import.path.to.extension" + } + ] + ) + + 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() + + class ArgumentConflict(ValueError): pass @@ -73,10 +97,11 @@ 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 + Returns the string to the environment Parameters ---------- @@ -109,24 +134,68 @@ def _get_config_dir(user=False, sys_prefix=False): # Public API # ------------------------------------------------------------------------------ +class ExtensionLoadingError(Exception): pass + + class ExtensionValidationError(Exception): pass -def validate_server_extension(import_name): - """Tries to import the extension module. - Raises a validation error if module is not found. + +def _get_load_jupyter_server_extension(obj): + """Looks for load_jupyter_server_extension as an attribute + of the object or module. """ try: - mod = importlib.import_module(import_name) - func = getattr(mod, 'load_jupyter_server_extension') - version = getattr(mod, '__version__', '') - return mod, func, version + func = getattr(obj, '_load_jupyter_server_extension') + except AttributeError: + func = getattr(obj, 'load_jupyter_server_extension') + except: + raise ExtensionLoadingError("_load_jupyter_server_extension function was not found.") + return func + + +def validate_server_extension(extension_name): + """Validates that you can import the extension module, + gather all extension metadata, and find `load_jupyter_server_extension` + functions for each extension. + + Raises a validation error if extensions cannot be found. + + Parameter + --------- + extension_module: module + The extension module (first value) returned by _get_server_extension_metadata + + extension_metadata : list + The list (second value) returned by _get_server_extension_metadata + + Returns + ------- + version : str + Extension version. + """ # If the extension does not exist, raise an exception + try: + mod, metadata = _get_server_extension_metadata(extension_name) + version = getattr(mod, '__version__', '') except ImportError: - raise ExtensionValidationError('{} is not importable.'.format(import_name)) + raise ExtensionValidationError('{} is not importable.'.format(extension_name)) + + try: + for item in metadata: + extapp = item.get('app', None) + extloc = item.get('module', None) + if extapp and extloc: + func = _get_load_jupyter_server_extension(extapp) + elif extloc: + extmod = importlib.import_module(extloc) + func = _get_load_jupyter_server_extension(extmod) + else: + raise AttributeError # 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)) + raise ExtensionValidationError('Found "{}" module but cannot load it.'.format(extension_name)) + return version def toggle_server_extension_python(import_name, enabled=None, parent=None, user=False, sys_prefix=True): @@ -184,7 +253,7 @@ class ToggleServerExtensionApp(BaseExtensionApp): """A base class for enabling/disabling extensions""" name = "jupyter server extension enable/disable" description = "Enable/disable a server extension using frontend configuration files." - + flags = flags user = Bool(False, config=True, help="Whether to do a user install") @@ -193,7 +262,7 @@ class ToggleServerExtensionApp(BaseExtensionApp): _toggle_value = Bool() _toggle_pre_message = '' _toggle_post_message = '' - + def toggle_server_extension(self, import_name): """Change the status of a named server extension. @@ -210,17 +279,17 @@ def toggle_server_extension(self, import_name): 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) + version = validate_server_extension(import_name) # Toggle the server extension to active. toggle_server_extension_python( - import_name, - self._toggle_value, - parent=self, + import_name, + self._toggle_value, + parent=self, user=self.user, sys_prefix=self.sys_prefix ) - self.log.info(" {} {} {}".format(import_item, version, GREEN_OK)) + self.log.info(" {} {} {}".format(import_name, version, GREEN_OK)) # If successful, let's log. self.log.info(" - Extension successfully {}.".format(self._toggle_post_message)) @@ -260,7 +329,7 @@ class EnableServerExtensionApp(ToggleServerExtensionApp): name = "jupyter server extension enable" description = """ Enable a server extension in configuration. - + Usage jupyter server extension enable [--system|--sys-prefix] """ @@ -274,7 +343,7 @@ class DisableServerExtensionApp(ToggleServerExtensionApp): name = "jupyter server extension disable" description = """ Disable a server extension in configuration. - + Usage jupyter server extension disable [--system|--sys-prefix] """ @@ -295,6 +364,8 @@ def list_server_extensions(self): Enabled extensions are validated, potentially generating warnings. """ config_dirs = jupyter_config_path() + + # Iterate over all locations where extensions might be named. for config_dir in config_dirs: cm = BaseJSONConfigManager(parent=self, config_dir=config_dir) data = cm.get("jupyter_server_config") @@ -304,14 +375,18 @@ def list_server_extensions(self): ) if server_extensions: self.log.info(u'config dir: {}'.format(config_dir)) - for import_name, enabled in server_extensions.items(): + + # Iterate over packages listed in jpserver_extensions. + for pkg_name, enabled in server_extensions.items(): + # Attempt to get extension metadata + _, __ = _get_server_extension_metadata(pkg_name) self.log.info(u' {} {}'.format( - import_name, + pkg_name, GREEN_ENABLED if enabled else RED_DISABLED)) try: - self.log.info(" - Validating {}...".format(import_name)) - _, __, version = validate_server_extension(import_name) - self.log.info(" {} {} {}".format(import_name, version, GREEN_OK)) + self.log.info(" - Validating {}...".format(pkg_name)) + version = validate_server_extension(pkg_name) + self.log.info(" {} {} {}".format(pkg_name, version, GREEN_OK)) except ExtensionValidationError as err: self.log.warn(" {} {}".format(RED_X, err)) @@ -353,33 +428,6 @@ def start(self): 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/jupyter_server/pytest_plugin.py b/jupyter_server/pytest_plugin.py index 5cd5c4dcd1..7bc6842b50 100644 --- a/jupyter_server/pytest_plugin.py +++ b/jupyter_server/pytest_plugin.py @@ -47,7 +47,7 @@ def mkdir(tmp_path, *parts): return path -config = pytest.fixture(lambda: {}) +server_config = pytest.fixture(lambda: {}) home_dir = pytest.fixture(lambda tmp_path: mkdir(tmp_path, "home")) data_dir = pytest.fixture(lambda tmp_path: mkdir(tmp_path, "data")) config_dir = pytest.fixture(lambda tmp_path: mkdir(tmp_path, "config")) @@ -67,7 +67,7 @@ def mkdir(tmp_path, *parts): some_resource = u"The very model of a modern major general" sample_kernel_json = { 'argv':['cat', '{connection_file}'], - 'display_name':'Test kernel', + 'display_name': 'Test kernel', } argv = pytest.fixture(lambda: []) @@ -88,7 +88,7 @@ def environ( ): monkeypatch.setenv("HOME", str(home_dir)) monkeypatch.setenv("PYTHONPATH", os.pathsep.join(sys.path)) - monkeypatch.setenv("JUPYTER_NO_CONFIG", "1") + # monkeypatch.setenv("JUPYTER_NO_CONFIG", "1") monkeypatch.setenv("JUPYTER_CONFIG_DIR", str(config_dir)) monkeypatch.setenv("JUPYTER_DATA_DIR", str(data_dir)) monkeypatch.setenv("JUPYTER_RUNTIME_DIR", str(runtime_dir)) @@ -106,23 +106,24 @@ def environ( def extension_environ(env_config_path, monkeypatch): """Monkeypatch a Jupyter Extension's config path into each test's environment variable""" monkeypatch.setattr(serverextension, "ENV_CONFIG_PATH", [str(env_config_path)]) - monkeypatch.setattr(serverextension, "ENV_CONFIG_PATH", [str(env_config_path)]) -@pytest.fixture +@pytest.fixture(scope='function') def configurable_serverapp( - environ, http_port, tmp_path, home_dir, data_dir, config_dir, runtime_dir, root_dir, io_loop + environ, + http_port, + tmp_path, + root_dir, + io_loop, + server_config, + **kwargs ): def serverapp( - config={}, + config=server_config, argv=[], environ=environ, http_port=http_port, tmp_path=tmp_path, - home_dir=home_dir, - data_dir=data_dir, - config_dir=config_dir, - runtime_dir=runtime_dir, root_dir=root_dir, **kwargs ): @@ -131,12 +132,11 @@ def serverapp( token = hexlify(os.urandom(4)).decode("ascii") url_prefix = "/" app = ServerApp.instance( + # Set the log level to debug for testing purposes + log_level='DEBUG', port=http_port, port_retries=0, open_browser=False, - config_dir=str(config_dir), - data_dir=str(data_dir), - runtime_dir=str(runtime_dir), root_dir=str(root_dir), base_url=url_prefix, config=c, @@ -160,8 +160,8 @@ def serverapp( @pytest.fixture -def serverapp(configurable_serverapp, config, argv): - app = configurable_serverapp(config=config, argv=argv) +def serverapp(configurable_serverapp, server_config, argv): + app = configurable_serverapp(config=server_config, argv=argv) yield app app.remove_server_info_file() app.remove_browser_open_file() diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 575e78eeb1..4efce6f256 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -34,6 +34,7 @@ import webbrowser import urllib +from types import ModuleType from base64 import encodebytes from jinja2 import Environment, FileSystemLoader @@ -103,7 +104,11 @@ from ._tz import utcnow, utcfromtimestamp from .utils import url_path_join, check_pid, url_escape, urljoin, pathname2url -from jupyter_server.extension.serverextension import ServerExtensionApp +from jupyter_server.extension.serverextension import ( + ServerExtensionApp, + _get_server_extension_metadata, + _get_load_jupyter_server_extension +) #----------------------------------------------------------------------------- # Module globals @@ -274,6 +279,7 @@ def init_settings(self, jupyter_app, kernel_manager, contents_manager, server_root_dir=root_dir, jinja2_env=env, terminals_available=False, # Set later if terminals are available + serverapp=self ) # allow custom overrides for the tornado web app. @@ -440,7 +446,7 @@ def shutdown_server(server_info, timeout=5, log=None): class JupyterServerStopApp(JupyterApp): version = __version__ - description="Stop currently running Jupyter server for a given port" + description = "Stop currently running Jupyter server for a given port" port = Integer(8888, config=True, help="Port of the server to be killed. Default 8888") @@ -555,8 +561,13 @@ class ServerApp(JupyterApp): This launches a Tornado-based Jupyter Server.""") examples = _examples + # This trait is used to track _enabled_extensions. It should remain hidden + # and not configurable. + _enabled_extensions = {} + flags = Dict(flags) aliases = Dict(aliases) + classes = [ KernelManager, Session, MappingKernelManager, KernelSpecManager, ContentsManager, FileContentsManager, NotebookNotary, @@ -1336,6 +1347,7 @@ def init_webapp(self): ) if self.ssl_options.get('ca_certs', False): self.ssl_options.setdefault('cert_reqs', ssl.CERT_REQUIRED) + ssl_options = self.ssl_options self.login_handler_class.validate_security(self, ssl_options=self.ssl_options) @@ -1367,7 +1379,7 @@ def display_url(self): @property def connection_url(self): ip = self.ip if self.ip else 'localhost' - return self.get_url(ip=ip) + return self.get_url(ip=ip, path=self.base_url) def get_url(self, ip=None, path=None, token=None): """Build a url for the application with reasonable defaults.""" @@ -1470,22 +1482,37 @@ def init_components(self): # TODO: this should still check, but now we use bower, not git submodule pass - def init_server_extension_config(self): - """Consolidate server extensions specified by all configs. - - The resulting list is stored on self.jpserver_extensions and updates config object. - - The extension API is experimental, and may change in future releases. + def init_server_extensions(self): + """ + Searches Jupyter paths for jpserver_extensions and captures + metadata for all enabled extensions. + + If an extension's metadata includes an 'app' key, + the value must be a subclass of ExtensionApp. An instance + of the class will be created at this step. The config for + this instance will inherit the ServerApp's config object + and load its own config. """ + # Step 1: Walk through all config files looking for jpserver_extensions. + # + # Each extension will likely have a JSON config file enabling itself in + # the "jupyter_server_config.d" directory. Find each of these and + # merge there results in order of precedence. + # # Load server extensions with ConfigManager. # This enables merging on keys, which we want for extension enabling. # Regular config loading only merges at the class level, - # so each level (user > env > system) clobbers the previous. + # so each level (system > env > user ... opposite of jupyter/notebook) + # clobbers the previous. config_path = jupyter_config_path() if self.config_dir not in config_path: # add self.config_dir to the front, if set manually config_path.insert(0, self.config_dir) - manager = ConfigManager(read_config_path=config_path) + # Flip the order of ordered_config_path to system > env > user. + # This is different that jupyter/notebook. See the Jupyter + # Enhancement Proposal 29 (Jupyter Server) for more information. + reversed_config_path = config_path[::-1] + manager = ConfigManager(read_config_path=reversed_config_path) section = manager.get(self.config_file_name) extensions = section.get('ServerApp', {}).get('jpserver_extensions', {}) @@ -1494,7 +1521,69 @@ def init_server_extension_config(self): self.config.ServerApp.jpserver_extensions.update({modulename: enabled}) self.jpserver_extensions.update({modulename: enabled}) - def init_server_extensions(self): + # Step 2: Load extension metadata for enabled extensions, load config for + # enabled ExtensionApps, and store enabled extension metadata in the + # _enabled_extensions attribute. + # + # The _enabled_extensions trait will be used by `load_server_extensions` + # to call each extensions `_load_jupyter_server_extension` method + # after the ServerApp's Web application object is created. + for module_name, enabled in sorted(self.jpserver_extensions.items()): + if enabled: + metadata_list = [] + try: + # Load the metadata for this enabled extension. This will + # be a list of extension points, each having their own + # path to a `_load_jupyter_server_extensions()`function. + # Important note: a single extension can have *multiple* + # `_load_jupyter_server_extension` functions defined, hence + # _get_server_extension_metadata returns a list of metadata. + mod, metadata_list = _get_server_extension_metadata(module_name) + except KeyError: + # A KeyError suggests that the module does not have a + # _jupyter_server_extension-path. + log_msg = _( + "Error loading server extensions in " + "{module_name} module. There is no `_jupyter_server_extension_paths` " + "defined at the root of the extension module. Check " + "with the author of the extension to ensure this function " + "is added.".format(module_name=module_name) + ) + self.log.warning(log_msg) + + for metadata in metadata_list: + # Is this extension point an ExtensionApp? + # If "app" is not None, then the extension should be an ExtensionApp + # Otherwise use the 'module' key to locate the + #`_load_jupyter_server_extension` function. + extapp = metadata.get('app', None) + extloc = metadata.get('module', None) + if extapp and extloc: + # Verify that object found is a subclass of ExtensionApp. + from .extension.application import ExtensionApp + if not issubclass(extapp, ExtensionApp): + raise TypeError(extapp.__name__ + " must be a subclass of ExtensionApp.") + # ServerApp creates an instance of the + # ExtensionApp here to load any ServerApp configuration + # that might live in the Extension's config file. + app = extapp() + app.link_to_serverapp(self) + # Build a new list where we + self._enabled_extensions[app.extension_name] = app + elif extloc: + extmod = importlib.import_module(extloc) + func = _get_load_jupyter_server_extension(extmod) + self._enabled_extensions[extloc] = extmod + else: + log_msg = _( + "{module_name} is missing critical metadata. Check " + "that _jupyter_server_extension_paths returns `app` " + "and/or `module` as keys".format(module_name=module_name) + ) + self.log.warn(log_msg) + + + def load_server_extensions(self): """Load any extensions specified by config. Import the module, then call the load_jupyter_server_extension function, @@ -1502,23 +1591,39 @@ def init_server_extensions(self): The extension API is experimental, and may change in future releases. """ - # Initialize extensions - for modulename, enabled in sorted(self.jpserver_extensions.items()): - if enabled: - try: - mod = importlib.import_module(modulename) - func = getattr(mod, 'load_jupyter_server_extension', None) - if func is not None: - func(self) - # Add debug log for loaded extensions. - self.log.debug("%s is enabled and loaded." % modulename) - else: - self.log.warning("%s is enabled but no `load_jupyter_server_extension` function was found" % modulename) - except Exception: - if self.reraise_server_extension_failures: - raise - self.log.warning(_("Error loading server extension %s"), modulename, - exc_info=True) + + # Load all enabled extensions. + for extkey, extension in sorted(self._enabled_extensions.items()): + if isinstance(extension, ModuleType): + log_msg = ( + "Extension from {extloc} module enabled and " + "loaded".format(extloc=extkey) + ) + else: + log_msg = ( + "Extension {extension_name} enabled and " + "loaded".format(extension_name=extension.extension_name) + ) + # Find the extension loading function. + try: + # This function was prefixed with an underscore in in v1.0 + # because this shouldn't be a public API for most extensions. + func = getattr(extension, '_load_jupyter_server_extension') + func(self) + self.log.debug(log_msg) + except AttributeError: + # For backwards compatibility, we will still look for non + # underscored loading functions. + func = getattr(extension, 'load_jupyter_server_extension') + func(self) + self.log.debug(log_msg) + except: + warn_msg = _( + "{extkey} is enabled but no " + "`_load_jupyter_server_extension` function " + "was found.".format(extkey=extkey) + ) + self.log.warning(warn_msg) def init_mime_overrides(self): # On some Windows machines, an application has registered incorrect @@ -1661,13 +1766,19 @@ def initialize(self, argv=None, load_extensions=True, new_httpserver=True): Application. This will set the http_server attribute of this class. """ self._init_asyncio_patch() + # Parse command line, load ServerApp config files, + # and update ServerApp config. super(ServerApp, self).initialize(argv) + + # Then, use extensions' config loading mechanism to + # update config. ServerApp config takes precedence. + if load_extensions: + self.init_server_extensions() + # Initialize all components of the ServerApp. self.init_logging() if self._dispatching: return self.init_configurables() - if load_extensions: - self.init_server_extension_config() self.init_components() self.init_webapp() if new_httpserver: @@ -1675,7 +1786,7 @@ def initialize(self, argv=None, load_extensions=True, new_httpserver=True): self.init_terminals() self.init_signal() if load_extensions: - self.init_server_extensions() + self.load_server_extensions() self.init_mime_overrides() self.init_shutdown_no_activity() diff --git a/setup.py b/setup.py index c2a4ca58d2..2697fad271 100755 --- a/setup.py +++ b/setup.py @@ -98,7 +98,8 @@ ], extras_require = { 'test': ['nose', 'coverage', 'requests', 'nose_warnings_filters', - 'pytest==5.3.2', 'pytest-cov', 'pytest-tornasync', 'pytest-console-scripts'], + 'pytest', 'pytest-cov', 'pytest-tornasync', + 'pytest-console-scripts'], 'test:sys_platform == "win32"': ['nose-exclude'], }, python_requires = '>=3.5', diff --git a/tests/extension/conftest.py b/tests/extension/conftest.py index 28e419ad45..677f1e55de 100644 --- a/tests/extension/conftest.py +++ b/tests/extension/conftest.py @@ -1,55 +1,6 @@ -import sys import pytest -from traitlets import Unicode - -from jupyter_core import paths -from jupyter_server.base.handlers import JupyterHandler -from jupyter_server.extension import serverextension -from jupyter_server.extension.serverextension import _get_config_dir -from jupyter_server.extension.application import ExtensionApp, ExtensionAppJinjaMixin -from jupyter_server.extension.handler import ExtensionHandlerMixin, ExtensionHandlerJinjaMixin - -# ----------------- Mock Extension App ---------------------- - -class MockExtensionHandler(ExtensionHandlerMixin, JupyterHandler): - - def get(self): - self.finish(self.config.mock_trait) - - -class MockExtensionTemplateHandler(ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler): - - def get(self): - self.write(self.render_template("index.html")) - - -class MockExtensionApp(ExtensionAppJinjaMixin, ExtensionApp): - extension_name = 'mockextension' - mock_trait = Unicode('mock trait', config=True) - - loaded = False - - def initialize_handlers(self): - self.handlers.append(('/mock', MockExtensionHandler)) - self.handlers.append(('/mock_template', MockExtensionTemplateHandler)) - self.loaded = True - - @staticmethod - def _jupyter_server_extension_paths(): - return [{ - 'module': '_mockdestination/index' - }] - -@pytest.fixture -def make_mock_extension_app(template_dir): - def _make_mock_extension_app(**kwargs): - kwargs.setdefault('template_paths', [str(template_dir)]) - return MockExtensionApp(**kwargs) - - # TODO Should the index template creation be only be done only once? - index = template_dir.joinpath("index.html") - index.write_text(""" +mock_html = """ @@ -68,34 +19,24 @@ def _make_mock_extension_app(**kwargs): {% block after_site %} {% endblock after_site %} -""") - return _make_mock_extension_app + +""" @pytest.fixture -def config_file(config_dir): - """""" - f = config_dir.joinpath("jupyter_mockextension_config.py") - f.write_text("c.MockExtensionApp.mock_trait ='config from file'") - return f +def mock_template(template_dir): + index = template_dir.joinpath('index.html') + index.write_text(mock_html) @pytest.fixture -def extended_serverapp(serverapp, make_mock_extension_app): - """""" - m = make_mock_extension_app() - m.initialize(serverapp) - return m +def enabled_extensions(serverapp): + return serverapp._enabled_extensions @pytest.fixture -def inject_mock_extension(environ, extension_environ, make_mock_extension_app): - """Fixture that can be used to inject a mock Jupyter Server extension into the tests namespace. - - Usage: inject_mock_extension({'extension_name': ExtensionClass}) - """ - def ext(modulename="mockextension"): - sys.modules[modulename] = e = make_mock_extension_app() - return e - - return ext +def config_file(config_dir): + """""" + f = config_dir.joinpath("jupyter_mockextension_config.py") + f.write_text("c.MockExtensionApp.mock_trait ='config from file'") + return f \ No newline at end of file diff --git a/tests/extension/mockextensions/__init__.py b/tests/extension/mockextensions/__init__.py new file mode 100644 index 0000000000..702dc3b123 --- /dev/null +++ b/tests/extension/mockextensions/__init__.py @@ -0,0 +1,24 @@ +"""A mock extension module with a list of extensions +to load in various tests. +""" +from .app import MockExtensionApp + + +# Function that makes these extensions discoverable +# by the test functions. +def _jupyter_server_extension_paths(): + return [ + { + 'module': 'tests.extension.mockextensions.app', + 'app': MockExtensionApp + }, + { + 'module': 'tests.extension.mockextensions.mock1' + }, + { + 'module': 'tests.extension.mockextensions.mock2' + }, + { + 'module': 'tests.extension.mockextensions.mock3' + } + ] diff --git a/tests/extension/mockextensions/app.py b/tests/extension/mockextensions/app.py new file mode 100644 index 0000000000..06a5cc0fd0 --- /dev/null +++ b/tests/extension/mockextensions/app.py @@ -0,0 +1,41 @@ +from traitlets import Unicode, List + +from jupyter_server.base.handlers import JupyterHandler +from jupyter_server.extension.application import ( + ExtensionApp, + ExtensionAppJinjaMixin +) +from jupyter_server.extension.handler import ( + ExtensionHandlerMixin, + ExtensionHandlerJinjaMixin +) + + +class MockExtensionHandler(ExtensionHandlerMixin, JupyterHandler): + + def get(self): + self.finish(self.config.mock_trait) + + +class MockExtensionTemplateHandler( + ExtensionHandlerJinjaMixin, + ExtensionHandlerMixin, + JupyterHandler + ): + + def get(self): + self.write(self.render_template("index.html")) + + +class MockExtensionApp(ExtensionAppJinjaMixin, ExtensionApp): + + extension_name = 'mockextension' + template_paths = List().tag(config=True) + mock_trait = Unicode('mock trait', config=True) + loaded = False + + def initialize_handlers(self): + self.handlers.append(('/mock', MockExtensionHandler)) + self.handlers.append(('/mock_template', MockExtensionTemplateHandler)) + self.loaded = True + diff --git a/tests/extension/mockextensions/mock1.py b/tests/extension/mockextensions/mock1.py new file mode 100644 index 0000000000..9c83271b7f --- /dev/null +++ b/tests/extension/mockextensions/mock1.py @@ -0,0 +1,16 @@ +"""A mock extension named `mock1` for testing purposes. +""" + + +# by the test functions. +def _jupyter_server_extension_paths(): + return [ + { + 'module': 'tests.extension.mockextensions.mock1' + } + ] + + +def _load_jupyter_server_extension(serverapp): + serverapp.mockI = True + serverapp.mock_shared = 'I' diff --git a/tests/extension/mockextensions/mock2.py b/tests/extension/mockextensions/mock2.py new file mode 100644 index 0000000000..1e44bff706 --- /dev/null +++ b/tests/extension/mockextensions/mock2.py @@ -0,0 +1,16 @@ +"""A mock extension named `mock2` for testing purposes. +""" + + +# by the test functions. +def _jupyter_server_extension_paths(): + return [ + { + 'module': 'tests.extension.mockextensions.mock2' + } + ] + + +def _load_jupyter_server_extension(serverapp): + serverapp.mockII = True + serverapp.mock_shared = 'II' diff --git a/tests/extension/mockextensions/mock3.py b/tests/extension/mockextensions/mock3.py new file mode 100644 index 0000000000..233f492a60 --- /dev/null +++ b/tests/extension/mockextensions/mock3.py @@ -0,0 +1,6 @@ +"""A mock extension named `mock3` for testing purposes. +""" + + +def _load_jupyter_server_extension(serverapp): + pass \ No newline at end of file diff --git a/tests/extension/mockextensions/mockext_both.py b/tests/extension/mockextensions/mockext_both.py new file mode 100644 index 0000000000..6fe762cc7f --- /dev/null +++ b/tests/extension/mockextensions/mockext_both.py @@ -0,0 +1,16 @@ +"""A mock extension named `mockext_both` for testing purposes. +""" + + +# Function that makes these extensions discoverable +# by the test functions. +def _jupyter_server_extension_paths(): + return [ + { + 'module': 'tests.extension.mockextensions.mockext_both' + } + ] + + +def _load_jupyter_server_extension(serverapp): + pass \ No newline at end of file diff --git a/tests/extension/mockextensions/mockext_py.py b/tests/extension/mockextensions/mockext_py.py new file mode 100644 index 0000000000..9f40b7725b --- /dev/null +++ b/tests/extension/mockextensions/mockext_py.py @@ -0,0 +1,16 @@ +"""A mock extension named `mockext_py` for testing purposes. +""" + + +# Function that makes these extensions discoverable +# by the test functions. +def _jupyter_server_extension_paths(): + return [ + { + 'module': 'tests.extension.mockextensions.mockext_py' + } + ] + + +def _load_jupyter_server_extension(serverapp): + pass \ No newline at end of file diff --git a/tests/extension/mockextensions/mockext_sys.py b/tests/extension/mockextensions/mockext_sys.py new file mode 100644 index 0000000000..f5504790b0 --- /dev/null +++ b/tests/extension/mockextensions/mockext_sys.py @@ -0,0 +1,16 @@ +"""A mock extension named `mockext_py` for testing purposes. +""" + + +# Function that makes these extensions discoverable +# by the test functions. +def _jupyter_server_extension_paths(): + return [ + { + 'module': 'tests.extension.mockextensions.mockext_sys' + } + ] + + +def _load_jupyter_server_extension(serverapp): + pass \ No newline at end of file diff --git a/tests/extension/mockextensions/mockext_user.py b/tests/extension/mockextensions/mockext_user.py new file mode 100644 index 0000000000..f078b587d7 --- /dev/null +++ b/tests/extension/mockextensions/mockext_user.py @@ -0,0 +1,16 @@ +"""A mock extension named `mockext_user` for testing purposes. +""" + + +# Function that makes these extensions discoverable +# by the test functions. +def _jupyter_server_extension_paths(): + return [ + { + 'module': 'tests.extension.mockextensions.mockext_user' + } + ] + + +def _load_jupyter_server_extension(serverapp): + pass \ No newline at end of file diff --git a/tests/extension/test_app.py b/tests/extension/test_app.py index 1cfd94137a..feaf299bc5 100644 --- a/tests/extension/test_app.py +++ b/tests/extension/test_app.py @@ -1,64 +1,67 @@ import pytest - from jupyter_server.serverapp import ServerApp -from jupyter_server.extension.application import ExtensionApp -def test_instance_creation(make_mock_extension_app, template_dir): - mock_extension = make_mock_extension_app() - assert mock_extension.static_paths == [] - assert mock_extension.template_paths == [str(template_dir)] - assert mock_extension.settings == {} - assert mock_extension.handlers == [] +@pytest.fixture +def server_config(request, template_dir): + config = { + "ServerApp": { + "jpserver_extensions": { + "tests.extension.mockextensions": True + }, + }, + "MockExtensionApp": { + "template_paths": [ + str(template_dir) + ], + "log_level": 'DEBUG' + } + } + return config + +@pytest.fixture +def mock_extension(enabled_extensions): + return enabled_extensions["mockextension"] -def test_initialize(serverapp, make_mock_extension_app): - mock_extension = make_mock_extension_app() - mock_extension.initialize(serverapp) + +def test_initialize(mock_extension, template_dir): # Check that settings and handlers were added to the mock extension. assert isinstance(mock_extension.serverapp, ServerApp) - assert len(mock_extension.settings) > 0 assert len(mock_extension.handlers) > 0 - - -traits = [ - ('static_paths', ['test']), - ('template_paths', ['test']), - ('custom_display_url', '/test_custom_url'), - ('default_url', '/test_url') -] - - -@pytest.mark.parametrize( - 'trait_name,trait_value', - traits -) -def test_instance_creation_with_instance_args(trait_name, trait_value, make_mock_extension_app): - kwarg = {} - kwarg.setdefault(trait_name, trait_value) - mock_extension = make_mock_extension_app(**kwarg) - assert getattr(mock_extension, trait_name) == trait_value + assert mock_extension.template_paths == [str(template_dir)] @pytest.mark.parametrize( - 'trait_name,trait_value', - traits + 'trait_name, trait_value, argv', + ( + [ + 'mock_trait', + 'test mock trait', + ['--MockExtensionApp.mock_trait="test mock trait"'] + ], + ) ) -def test_instance_creation_with_argv(serverapp, trait_name, trait_value, make_mock_extension_app): - kwarg = {} - kwarg.setdefault(trait_name, trait_value) - argv = [ - '--MockExtensionApp.{name}={value}'.format(name=trait_name, value=trait_value) - ] - mock_extension = make_mock_extension_app() - mock_extension.initialize(serverapp, argv=argv) - assert getattr(mock_extension, trait_name) == trait_value +def test_instance_creation_with_argv( + serverapp, + trait_name, + trait_value, + enabled_extensions +): + extension = enabled_extensions['mockextension'] + assert getattr(extension, trait_name) == trait_value -def test_extensionapp_load_config_file(config_file, serverapp, extended_serverapp): +def test_extensionapp_load_config_file( + extension_environ, + config_file, + enabled_extensions, + serverapp, +): + extension = enabled_extensions["mockextension"] # 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_mockextension_config' - assert extended_serverapp.config_dir == serverapp.config_dir + assert extension.config_file_paths == serverapp.config_file_paths + assert extension.config_dir == serverapp.config_dir + assert extension.config_file_name == 'jupyter_mockextension_config' # Assert that the trait is updated by config file - assert extended_serverapp.mock_trait == 'config from file' + assert extension.mock_trait == 'config from file' diff --git a/tests/extension/test_entrypoint.py b/tests/extension/test_entrypoint.py index 14fa0f6ae8..49c436aa21 100644 --- a/tests/extension/test_entrypoint.py +++ b/tests/extension/test_entrypoint.py @@ -1,24 +1,34 @@ +import os 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') + ret = script_runner.run( + 'jupyter', + 'server', + 'extension', + 'list', + env=os.environ + ) assert ret.success -def test_server_extension_enable(environ, inject_mock_extension, script_runner): +def test_server_extension_enable(environ, script_runner): # 'mock' is not a valid extension The entry point should complete # but print to sterr. extension_name = "mockextension" - inject_mock_extension() - - ret = script_runner.run("jupyter", "server", "extension", "enable", extension_name) + ret = script_runner.run( + "jupyter", + "server", + "extension", + "enable", + extension_name, + env=os.environ + ) assert ret.success assert 'Enabling: {}'.format(extension_name) in ret.stderr @@ -27,6 +37,13 @@ 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) + ret = script_runner.run( + 'jupyter', + 'server', + 'extension', + 'disable', + extension_name, + env=os.environ + ) assert ret.success assert 'Disabling: {}'.format(extension_name) in ret.stderr diff --git a/tests/extension/test_handler.py b/tests/extension/test_handler.py index f980ab8965..86dd72d5ea 100644 --- a/tests/extension/test_handler.py +++ b/tests/extension/test_handler.py @@ -1,10 +1,23 @@ import pytest -from jupyter_server.serverapp import ServerApp -# ------------------ Start tests ------------------- +@pytest.fixture +def server_config(template_dir): + return { + "ServerApp": { + "jpserver_extensions": { + "tests.extension.mockextensions": True + } + }, + "MockExtensionApp": { + "template_paths": [ + str(template_dir) + ] + } + } -async def test_handler(fetch, extended_serverapp): + +async def test_handler(fetch): r = await fetch( 'mock', method='GET' @@ -13,7 +26,7 @@ async def test_handler(fetch, extended_serverapp): assert r.body.decode() == 'mock trait' -async def test_handler_template(fetch, extended_serverapp): +async def test_handler_template(fetch, mock_template): r = await fetch( 'mock_template', method='GET' @@ -21,11 +34,24 @@ async def test_handler_template(fetch, extended_serverapp): assert r.code == 200 -async def test_handler_setting(fetch, serverapp, make_mock_extension_app): - # Configure trait in Mock Extension. - m = make_mock_extension_app(mock_trait='test mock trait') - m.initialize(serverapp) - +@pytest.mark.parametrize( + 'server_config', + [ + { + "ServerApp": { + "jpserver_extensions": { + "tests.extension.mockextensions": True + } + }, + "MockExtensionApp": { + # Change a trait in the MockExtensionApp using + # the following config value. + "mock_trait": "test mock trait" + } + } + ] +) +async def test_handler_setting(fetch): # Test that the extension trait was picked up by the webapp. r = await fetch( 'mock', @@ -35,12 +61,10 @@ async def test_handler_setting(fetch, serverapp, make_mock_extension_app): assert r.body.decode() == 'test mock trait' -async def test_handler_argv(fetch, serverapp, make_mock_extension_app): - # Configure trait in Mock Extension. - m = make_mock_extension_app() - argv = ['--MockExtensionApp.mock_trait="test mock trait"'] - m.initialize(serverapp, argv=argv) - +@pytest.mark.parametrize( + 'argv', (['--MockExtensionApp.mock_trait="test mock trait"'],) +) +async def test_handler_argv(fetch): # Test that the extension trait was picked up by the webapp. r = await fetch( 'mock', diff --git a/tests/extension/test_serverextension.py b/tests/extension/test_serverextension.py index 3ff969d82c..69cabf6542 100644 --- a/tests/extension/test_serverextension.py +++ b/tests/extension/test_serverextension.py @@ -1,15 +1,7 @@ -import sys import pytest from collections import OrderedDict - -from types import SimpleNamespace - from traitlets.tests.utils import check_help_all_output -from ..utils 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, @@ -32,93 +24,93 @@ def get_config(sys_prefix=True): return data.get("ServerApp", {}).get("jpserver_extensions", {}) -def test_enable(inject_mock_extension): - inject_mock_extension() - toggle_server_extension_python('mockextension', True) +def test_enable(): + toggle_server_extension_python('mock1', True) config = get_config() - assert config['mockextension'] + assert config['mock1'] -def test_disable(inject_mock_extension): - inject_mock_extension() - toggle_server_extension_python('mockextension', True) - toggle_server_extension_python('mockextension', False) +def test_disable(): + toggle_server_extension_python('mock1', True) + toggle_server_extension_python('mock1', False) config = get_config() - assert not config['mockextension'] + assert not config['mock1'] def test_merge_config( - env_config_path, - inject_mock_extension, - configurable_serverapp - ): + env_config_path, + configurable_serverapp, + extension_environ +): # enabled at sys level - inject_mock_extension('mockext_sys') - validate_server_extension('mockext_sys') + validate_server_extension('tests.extension.mockextensions.mockext_sys') # enabled at sys, disabled at user - inject_mock_extension('mockext_both') - validate_server_extension('mockext_both') + validate_server_extension('tests.extension.mockextensions.mockext_both') # enabled at user - inject_mock_extension('mockext_user') - validate_server_extension('mockext_user') + validate_server_extension('tests.extension.mockextensions.mockext_user') # enabled at Python - inject_mock_extension('mockext_py') - validate_server_extension('mockext_py') + validate_server_extension('tests.extension.mockextensions.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) + toggle_server_extension_python( + 'tests.extension.mockextensions.mockext_sys', + enabled=True, + sys_prefix=True + ) + toggle_server_extension_python( + 'tests.extension.mockextensions.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) + toggle_server_extension_python( + 'tests.extension.mockextensions.mockext_both', + enabled=True, + user=True + ) + toggle_server_extension_python( + 'tests.extension.mockextensions.mockext_both', + enabled=False, + sys_prefix=True + ) + + arg = "--ServerApp.jpserver_extensions={{'{mockext_py}': True}}".format( + mockext_py='tests.extension.mockextensions.mockext_py' + ) # 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}'] + argv=[arg] ) # Verify that extensions are enabled and merged properly. extensions = app.jpserver_extensions - assert extensions['mockext_user'] - assert extensions['mockext_sys'] - assert extensions['mockext_py'] + assert extensions['tests.extension.mockextensions.mockext_user'] + assert extensions['tests.extension.mockextensions.mockext_sys'] + assert extensions['tests.extension.mockextensions.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" - + assert not extensions['tests.extension.mockextensions.mockext_both'] + + +@pytest.mark.parametrize( + 'server_config', + [ + { + "ServerApp": { + "jpserver_extensions": OrderedDict([ + ('tests.extension.mockextensions.mock2', True), + ('tests.extension.mockextensions.mock1', True) + ]) + } + } + ] +) +def test_load_ordered(serverapp): + assert serverapp.mockII is True, "Mock II should have been loaded" + assert serverapp.mockI is True, "Mock I should have been loaded" + assert serverapp.mock_shared == 'II', "Mock II should be loaded after Mock I" diff --git a/tests/services/contents/test_config.py b/tests/services/contents/test_config.py index a427861dd3..c5813f259e 100644 --- a/tests/services/contents/test_config.py +++ b/tests/services/contents/test_config.py @@ -5,7 +5,7 @@ @pytest.fixture -def config(): +def server_config(): return {'FileContentsManager': {'checkpoints_class': GenericFileCheckpoints}} diff --git a/tests/services/kernels/test_config.py b/tests/services/kernels/test_config.py index b8234cb5a2..ef6bd7709e 100644 --- a/tests/services/kernels/test_config.py +++ b/tests/services/kernels/test_config.py @@ -3,7 +3,7 @@ @pytest.fixture -def config(): +def server_config(): return Config({ 'ServerApp': { 'MappingKernelManager': {