From 4666461f2ee4583707bfeb5e9e61e71857cd220f Mon Sep 17 00:00:00 2001 From: Zsailer Date: Fri, 28 Feb 2020 11:11:23 -0800 Subject: [PATCH 01/33] enable extensionapp config to be discoverable from serveapp --- jupyter_server/extension/application.py | 95 ++++++++++++------------- jupyter_server/serverapp.py | 22 +++++- 2 files changed, 65 insertions(+), 52 deletions(-) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 2d935d7bbc..dfe12f02bd 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -4,11 +4,11 @@ from jinja2 import Environment, FileSystemLoader from traitlets import ( - Unicode, - List, + Unicode, + List, Dict, Bool, - default, + default, validate ) from traitlets.config import Config @@ -50,8 +50,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,7 +88,7 @@ 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. @@ -104,7 +104,7 @@ 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 @@ -116,7 +116,7 @@ def _prepare_templates(self): # 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 } ) @@ -137,11 +137,11 @@ 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,13 +176,20 @@ def _validate_extension_name(self, proposal): return value raise ValueError("Extension name must be a string, found {type}.".format(type=type(value))) + @classmethod + def _jupyter_server_extension_paths(cls): + return [{ + "module": cls.__module__.split('.')[0], + "app": cls + }] + # Extension can configure the ServerApp from the command-line classes = [ ServerApp, ] - aliases = aliases - flags = flags + # aliases = aliases + # flags = flags subcommands = {} @@ -193,13 +200,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,16 +220,6 @@ 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: @@ -251,7 +248,7 @@ def _config_file_name_default(self): or containerized setups for example).""") ) - @default('custom_display_url') + @default('custom_display_url') def _default_custom_display_url(self): """URL to display to the user.""" # Get url from server. @@ -259,7 +256,7 @@ def _default_custom_display_url(self): 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 + """Use to hijacks the server's browser-open file and open at the extension's homepage. """ # Ignore server's url @@ -284,7 +281,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}) @@ -318,12 +315,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 @@ -335,11 +332,11 @@ def _prepare_handlers(self): if len(self.static_paths) > 0: # Append the extension's static directory to server handlers. static_url = url_path_join("/static", self.extension_name, "(.*)") - + # 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,20 +351,23 @@ def _prepare_templates(self): }) self.initialize_templates() - @staticmethod - def initialize_server(argv=[], load_other_extensions=True, **kwargs): + @classmethod + def initialize_server(cls, argv=[], load_other_extensions=True, **kwargs): """Get an instance of the Jupyter Server.""" # Get a jupyter server instance serverapp = ServerApp.instance(**kwargs) + # Add extension to jpserver_extensions trait in server. + mod = cls._jupyter_server_extension_paths()[0]['module'] + serverapp.jpserver_extensions.update({mod: True}) # Initialize ServerApp config. - # Parses the command line looking for + # Parses the command line looking for # ServerApp configuration. 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 @@ -377,9 +377,6 @@ def initialize(self, serverapp, argv=[]): """ # Initialize ServerApp. self.serverapp = serverapp - - # Initialize the extension application - super(ExtensionApp, self).initialize(argv=argv) # Initialize config, settings, templates, and handlers. self._prepare_config() @@ -389,11 +386,11 @@ 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 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. @@ -411,18 +408,18 @@ 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) + extension = cls(parent=serverapp) + extension.initialize(serverapp) 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 +436,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. @@ -452,6 +449,6 @@ def launch_instance(cls, argv=None, **kwargs): "other extensions.".format(ext_name=cls.extension_name) ) - extension = cls.load_jupyter_server_extension(serverapp, argv=args, **kwargs) + extension = cls.load_jupyter_server_extension(serverapp, **kwargs) # Start the ioloop. extension.start() \ No newline at end of file diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 575e78eeb1..ff4225958d 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -103,7 +103,7 @@ 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 #----------------------------------------------------------------------------- # Module globals @@ -1494,6 +1494,17 @@ def init_server_extension_config(self): self.config.ServerApp.jpserver_extensions.update({modulename: enabled}) self.jpserver_extensions.update({modulename: enabled}) + # Load configuration from ExtensionApp's they affect + # the ServerApp class. + for modulename in sorted(self.jpserver_extensions): + _, metadata = _get_server_extension_metadata(modulename) + app_obj = metadata[0].get('app', None) + if issubclass(app_obj, JupyterApp): + app = app_obj(parent=self) + app.update_config(app.config) + app.load_config_file() + self.update_config(app.config) + def init_server_extensions(self): """Load any extensions specified by config. @@ -1661,13 +1672,18 @@ 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_extension_config() + # 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: From f4e6c41896a7e1ec44168be68a06dddad9ab5d26 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Fri, 28 Feb 2020 11:27:09 -0800 Subject: [PATCH 02/33] add some comments for future devs --- jupyter_server/serverapp.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index ff4225958d..a68023ca27 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1500,9 +1500,18 @@ def init_server_extension_config(self): _, metadata = _get_server_extension_metadata(modulename) app_obj = metadata[0].get('app', None) if issubclass(app_obj, JupyterApp): + # Initialize extension app app = app_obj(parent=self) + # Update the app's config, setting + # any traits given by the serverapp's + # parsed command line args. app.update_config(app.config) + # Load any config from an extension's + # config file and make update the + # app's config again. app.load_config_file() + # Pass any relevant config to the + # serverapp's (parent) config. self.update_config(app.config) def init_server_extensions(self): From 5a19dcdb0ce7787ddbb165ba38b6c2ddfa774466 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Fri, 28 Feb 2020 11:57:33 -0800 Subject: [PATCH 03/33] allow None in non-ExtensionApp extensions --- jupyter_server/serverapp.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index a68023ca27..11d910465d 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1499,7 +1499,9 @@ def init_server_extension_config(self): for modulename in sorted(self.jpserver_extensions): _, metadata = _get_server_extension_metadata(modulename) app_obj = metadata[0].get('app', None) - if issubclass(app_obj, JupyterApp): + if app_obj: + if not issubclass(app_obj, JupyterApp): + raise TypeError(abb_obj.__name__ + "must be a subclass of JupyterApp") # Initialize extension app app = app_obj(parent=self) # Update the app's config, setting From 9b1a5c501ef9e48f52c9cd572dd90349b196d43f Mon Sep 17 00:00:00 2001 From: Zsailer Date: Fri, 28 Feb 2020 13:29:55 -0800 Subject: [PATCH 04/33] adjust tests to capture changes --- jupyter_server/extension/application.py | 9 ++++++++- jupyter_server/serverapp.py | 2 +- tests/extension/conftest.py | 6 +++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index dfe12f02bd..a68fc57c88 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -283,6 +283,7 @@ def _prepare_config(self): """Builds a Config object from the extension's traits and passes the object to the webapp's settings as `_config`. """ + self.update_config(self.config) traits = self.class_own_traits().keys() self.extension_config = Config({t: getattr(self, t) for t in traits}) self.settings['{}_config'.format(self.extension_name)] = self.extension_config @@ -365,7 +366,7 @@ def initialize_server(cls, argv=[], load_other_extensions=True, **kwargs): serverapp.initialize(argv=argv, load_extensions=load_other_extensions) return serverapp - def initialize(self, serverapp, argv=[]): + def initialize(self, serverapp, argv=None): """Initialize the extension app. This method: @@ -378,6 +379,12 @@ def initialize(self, serverapp, argv=[]): # Initialize ServerApp. self.serverapp = serverapp + # If argv is given, parse and update config. + if argv: + self.parse_command_line(argv) + # Load config from file. + self.load_config_file() + # Initialize config, settings, templates, and handlers. self._prepare_config() self._prepare_templates() diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 11d910465d..bd8be7700b 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1501,7 +1501,7 @@ def init_server_extension_config(self): app_obj = metadata[0].get('app', None) if app_obj: if not issubclass(app_obj, JupyterApp): - raise TypeError(abb_obj.__name__ + "must be a subclass of JupyterApp") + raise TypeError(abb_obj.__name__ + " must be a subclass of JupyterApp.") # Initialize extension app app = app_obj(parent=self) # Update the app's config, setting diff --git a/tests/extension/conftest.py b/tests/extension/conftest.py index 28e419ad45..11d212acd4 100644 --- a/tests/extension/conftest.py +++ b/tests/extension/conftest.py @@ -42,13 +42,13 @@ def _jupyter_server_extension_paths(): }] @pytest.fixture -def make_mock_extension_app(template_dir): +def make_mock_extension_app(template_dir, config_dir): def _make_mock_extension_app(**kwargs): kwargs.setdefault('template_paths', [str(template_dir)]) - return MockExtensionApp(**kwargs) + return MockExtensionApp(config_dir=str(config_dir), **kwargs) # TODO Should the index template creation be only be done only once? - index = template_dir.joinpath("index.html") + index = template_dir.joinpath("index.html") index.write_text(""" From 45222f42cf9bff08d55796b4ab0c6dbdaaafd0e2 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 3 Mar 2020 11:38:26 -0800 Subject: [PATCH 05/33] minor bug fixes --- jupyter_server/extension/application.py | 5 +++-- jupyter_server/pytest_plugin.py | 10 +++++----- jupyter_server/serverapp.py | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index a68fc57c88..1437d97b17 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -91,7 +91,6 @@ class ExtensionAppJinjaMixin: jinja2_options = Dict( help=_("""Options to pass to the jinja2 environment for this - extension. """) ).tag(config=True) @@ -283,6 +282,7 @@ def _prepare_config(self): """Builds a Config object from the extension's traits and passes the object to the webapp's settings as `_config`. """ + # Verify all traits are up-to-date with config self.update_config(self.config) traits = self.class_own_traits().keys() self.extension_config = Config({t: getattr(self, t) for t in traits}) @@ -382,6 +382,7 @@ def initialize(self, serverapp, argv=None): # If argv is given, parse and update config. if argv: self.parse_command_line(argv) + # Load config from file. self.load_config_file() @@ -421,7 +422,7 @@ def load_jupyter_server_extension(cls, serverapp): """ # Configure and initialize extension. extension = cls(parent=serverapp) - extension.initialize(serverapp) + extension.initialize(serverapp=serverapp) return extension @classmethod diff --git a/jupyter_server/pytest_plugin.py b/jupyter_server/pytest_plugin.py index 5cd5c4dcd1..a181ef28ae 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")) @@ -111,10 +111,10 @@ def extension_environ(env_config_path, monkeypatch): @pytest.fixture 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, home_dir, data_dir, config_dir, runtime_dir, root_dir, io_loop, server_config, **kwargs ): def serverapp( - config={}, + config=server_config, argv=[], environ=environ, http_port=http_port, @@ -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 bd8be7700b..d114c38f2f 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1322,7 +1322,6 @@ def init_webapp(self): self.ssl_options['ca_certs'] = self.client_ca if not self.ssl_options: # could be an empty dict or None - # None indicates no SSL config self.ssl_options = None else: # SSL may be missing, so only import it if it's to be used @@ -1336,6 +1335,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) From 4fcc50f932f63a67605a08b7d1c70515086e7888 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 3 Mar 2020 11:49:42 -0800 Subject: [PATCH 06/33] renamed config pytest fixture --- tests/services/contents/test_config.py | 2 +- tests/services/kernels/test_config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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': { From e6f8d519d566beaa14204b2e52c20e3110ad0251 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 3 Mar 2020 16:08:22 -0800 Subject: [PATCH 07/33] standardize extension loading mechanism --- jupyter_server/extension/application.py | 5 +- jupyter_server/serverapp.py | 96 +++++++++++++++++-------- tests/extension/conftest.py | 7 +- tests/extension/test_serverextension.py | 44 ++++++------ 4 files changed, 96 insertions(+), 56 deletions(-) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 1437d97b17..a01c148ec0 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -178,7 +178,7 @@ def _validate_extension_name(self, proposal): @classmethod def _jupyter_server_extension_paths(cls): return [{ - "module": cls.__module__.split('.')[0], + "module": cls.extension_name, "app": cls }] @@ -358,8 +358,7 @@ def initialize_server(cls, argv=[], load_other_extensions=True, **kwargs): # Get a jupyter server instance serverapp = ServerApp.instance(**kwargs) # Add extension to jpserver_extensions trait in server. - mod = cls._jupyter_server_extension_paths()[0]['module'] - serverapp.jpserver_extensions.update({mod: True}) + serverapp.jpserver_extensions.update({cls.extension_name: True}) # Initialize ServerApp config. # Parses the command line looking for # ServerApp configuration. diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index d114c38f2f..feee311f87 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1322,6 +1322,7 @@ def init_webapp(self): self.ssl_options['ca_certs'] = self.client_ca if not self.ssl_options: # could be an empty dict or None + # None indicates no SSL config self.ssl_options = None else: # SSL may be missing, so only import it if it's to be used @@ -1497,24 +1498,27 @@ def init_server_extension_config(self): # Load configuration from ExtensionApp's they affect # the ServerApp class. for modulename in sorted(self.jpserver_extensions): - _, metadata = _get_server_extension_metadata(modulename) - app_obj = metadata[0].get('app', None) - if app_obj: - if not issubclass(app_obj, JupyterApp): - raise TypeError(abb_obj.__name__ + " must be a subclass of JupyterApp.") - # Initialize extension app - app = app_obj(parent=self) - # Update the app's config, setting - # any traits given by the serverapp's - # parsed command line args. - app.update_config(app.config) - # Load any config from an extension's - # config file and make update the - # app's config again. - app.load_config_file() - # Pass any relevant config to the - # serverapp's (parent) config. - self.update_config(app.config) + # Get extension metadata from jupyter extension paths. + _, metadata_list = _get_server_extension_metadata(modulename) + # If this extension is an ExtensionApp, load it's config. + for metadata in metadata_list: + app_obj = metadata.get('app', None) + if app_obj: + if not issubclass(app_obj, JupyterApp): + raise TypeError(abb_obj.__name__ + " must be a subclass of JupyterApp.") + # Initialize extension app + app = app_obj(parent=self) + # Update the app's config, setting + # any traits given by the serverapp's + # parsed command line args. + app.update_config(app.config) + # Load any config from an extension's + # config file and make update the + # app's config again. + app.load_config_file() + # Pass any relevant config to the + # serverapp's (parent) config. + self.update_config(app.config) def init_server_extensions(self): """Load any extensions specified by config. @@ -1525,23 +1529,57 @@ 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()): + for module_name, enabled in sorted(self.jpserver_extensions.items()): if enabled: + # Look for extensions on jupyter_server_extension_paths 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: + mod, metadata_list = _get_server_extension_metadata(module_name) + except KeyError: + log_msg = _( + "Error loading server extension " + "{module_name}. There is no `_jupyter_server_extension_path` " + "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) + except: if self.reraise_server_extension_failures: raise - self.log.warning(_("Error loading server extension %s"), modulename, + self.log.warning(_("Error loading server extension %s"), module_name, exc_info=True) + for metadata in metadata_list: + # Check if this server extension is a ExtensionApp object. + extapp = metadata.get('app', None) + extmod = metadata.get('module', None) + # Load the extension module. + if extapp: + extapp.load_jupyter_server_extension(self) + log_msg = ( + "{module_name} is enabled and " + "loaded".format(module_name=module_name) + ) + self.log.debug(log_msg) + elif extmod: + func = getattr(extmod, 'load_jupyter_server_extension', None) + if func is not None: + func(self) + # Add debug log for loaded extensions. + log_msg = _( + "{module_name} is enabled and " + "loaded".format(module_name=module_name) + ) + self.log.debug(log_msg) + else: + log_msg = _( + "{module_name} is enabled but no " + "`load_jupyter_server_extension` function " + "was found.".format(module_name=module_name) + ) + self.log.warning(log_msg) + + def init_mime_overrides(self): # On some Windows machines, an application has registered incorrect # mimetypes in the registry. diff --git a/tests/extension/conftest.py b/tests/extension/conftest.py index 11d212acd4..0fcfcd5ff5 100644 --- a/tests/extension/conftest.py +++ b/tests/extension/conftest.py @@ -35,10 +35,11 @@ def initialize_handlers(self): self.handlers.append(('/mock_template', MockExtensionTemplateHandler)) self.loaded = True - @staticmethod - def _jupyter_server_extension_paths(): + @classmethod + def _jupyter_server_extension_paths(cls): return [{ - 'module': '_mockdestination/index' + 'module': '_mockdestination/index', + 'app': cls }] @pytest.fixture diff --git a/tests/extension/test_serverextension.py b/tests/extension/test_serverextension.py index 3ff969d82c..64411d299b 100644 --- a/tests/extension/test_serverextension.py +++ b/tests/extension/test_serverextension.py @@ -91,34 +91,36 @@ def test_merge_config( assert not extensions['mockext_both'] -@pytest.fixture -def ordered_server_extensions(): - mockextension1 = SimpleNamespace() - mockextension2 = SimpleNamespace() +# ### NEED TO REFACTOR - def load_jupyter_server_extension(obj): - obj.mockI = True - obj.mock_shared = 'I' +# @pytest.fixture +# def ordered_server_extensions(): +# mockextension1 = SimpleNamespace() +# mockextension2 = SimpleNamespace() - mockextension1.load_jupyter_server_extension = load_jupyter_server_extension +# def load_jupyter_server_extension(obj): +# obj.mockI = True +# obj.mock_shared = 'I' - def load_jupyter_server_extension(obj): - obj.mockII = True - obj.mock_shared = 'II' +# mockextension1.load_jupyter_server_extension = load_jupyter_server_extension - mockextension2.load_jupyter_server_extension = load_jupyter_server_extension +# def load_jupyter_server_extension(obj): +# obj.mockII = True +# obj.mock_shared = 'II' - sys.modules['mockextension2'] = mockextension2 - sys.modules['mockextension1'] = mockextension1 +# 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() +# def test_load_ordered(ordered_server_extensions): +# app = ServerApp() +# app.jpserver_extensions = OrderedDict([('mockextension2',True),('mockextension1',True)]) - 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" +# 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" From e3c3c6b55290ff7c1522ccfcae7d7404551fd79f Mon Sep 17 00:00:00 2001 From: Zsailer Date: Fri, 6 Mar 2020 08:57:07 -0800 Subject: [PATCH 08/33] pass serverapp and extesnionapp to extensionapp handlers --- jupyter_server/extension/application.py | 13 ++++------ jupyter_server/extension/handler.py | 32 ++++++++++++++++--------- jupyter_server/serverapp.py | 7 +++++- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index a01c148ec0..ca9292586a 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -95,6 +95,9 @@ class ExtensionAppJinjaMixin: ).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({ @@ -109,8 +112,6 @@ def _prepare_templates(self): **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( @@ -175,13 +176,6 @@ def _validate_extension_name(self, proposal): return value raise ValueError("Extension name must be a string, found {type}.".format(type=type(value))) - @classmethod - def _jupyter_server_extension_paths(cls): - return [{ - "module": cls.extension_name, - "app": cls - }] - # Extension can configure the ServerApp from the command-line classes = [ ServerApp, @@ -296,6 +290,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. diff --git a/jupyter_server/extension/handler.py b/jupyter_server/extension/handler.py index 7febbb9c72..3d71fe791e 100644 --- a/jupyter_server/extension/handler.py +++ b/jupyter_server/extension/handler.py @@ -3,28 +3,38 @@ 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): + def get_template(self, name, *ns): """Return the jinja template object for a given name""" env = '{}_jinja2_env'.format(self.extension_name) - return self.settings[env].get_template(name) + return self.settings[env].get_template(name, *ns) -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): + key = "{extension_name}".format(extension_name=self.extension_name) + return self.settings[key] + + @property + def serverapp(self): + key = "serverapp" + return self.settings[key] + @property def config(self): return self.settings["{}_config".format(self.extension_name)] @@ -44,8 +54,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/serverapp.py b/jupyter_server/serverapp.py index feee311f87..1091071074 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -274,6 +274,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. @@ -1531,10 +1532,13 @@ def init_server_extensions(self): # Initialize extensions for module_name, enabled in sorted(self.jpserver_extensions.items()): if enabled: - # Look for extensions on jupyter_server_extension_paths + # Search for server extensions by loading each extension module + # and looking for the `_jupyter_server_extension_paths` function. try: 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 extension " "{module_name}. There is no `_jupyter_server_extension_path` " @@ -1549,6 +1553,7 @@ def init_server_extensions(self): self.log.warning(_("Error loading server extension %s"), module_name, exc_info=True) + for metadata in metadata_list: # Check if this server extension is a ExtensionApp object. extapp = metadata.get('app', None) From 93b28945f3c1867c8671bc9fd3db8869132a14ae Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 10 Mar 2020 16:08:07 -0700 Subject: [PATCH 09/33] use static url prefix for static paths --- jupyter_server/base/handlers.py | 24 ++++++++++++------------ jupyter_server/extension/application.py | 2 +- jupyter_server/extension/handler.py | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) 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 ca9292586a..4f0c9f73b5 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -327,7 +327,7 @@ 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 = ( diff --git a/jupyter_server/extension/handler.py b/jupyter_server/extension/handler.py index 3d71fe791e..b5aca4721f 100644 --- a/jupyter_server/extension/handler.py +++ b/jupyter_server/extension/handler.py @@ -6,10 +6,10 @@ class ExtensionHandlerJinjaMixin: """Mixin class for ExtensionApp handlers that use jinja templating for template rendering. """ - def get_template(self, name, *ns): + def get_template(self, name): """Return the jinja template object for a given name""" env = '{}_jinja2_env'.format(self.extension_name) - return self.settings[env].get_template(name, *ns) + return self.settings[env].get_template(name) class ExtensionHandlerMixin: From 5b8132e1dbc67fd573ad9c6063ce3724a279ea2d Mon Sep 17 00:00:00 2001 From: Zsailer Date: Wed, 11 Mar 2020 14:50:07 -0700 Subject: [PATCH 10/33] iniitalize all enabled extension, then load later --- jupyter_server/serverapp.py | 117 +++++++++++++++++++----------------- 1 file changed, 61 insertions(+), 56 deletions(-) diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 1091071074..117be1b7f1 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 @@ -1496,40 +1497,8 @@ def init_server_extension_config(self): self.config.ServerApp.jpserver_extensions.update({modulename: enabled}) self.jpserver_extensions.update({modulename: enabled}) - # Load configuration from ExtensionApp's they affect - # the ServerApp class. - for modulename in sorted(self.jpserver_extensions): - # Get extension metadata from jupyter extension paths. - _, metadata_list = _get_server_extension_metadata(modulename) - # If this extension is an ExtensionApp, load it's config. - for metadata in metadata_list: - app_obj = metadata.get('app', None) - if app_obj: - if not issubclass(app_obj, JupyterApp): - raise TypeError(abb_obj.__name__ + " must be a subclass of JupyterApp.") - # Initialize extension app - app = app_obj(parent=self) - # Update the app's config, setting - # any traits given by the serverapp's - # parsed command line args. - app.update_config(app.config) - # Load any config from an extension's - # config file and make update the - # app's config again. - app.load_config_file() - # Pass any relevant config to the - # serverapp's (parent) config. - self.update_config(app.config) - - def init_server_extensions(self): - """Load any extensions specified by config. - - Import the module, then call the load_jupyter_server_extension function, - if one exists. - - The extension API is experimental, and may change in future releases. - """ - # Initialize extensions + # Discover ExtensionApp's to load their config that might affect ServerApp. + self.enabled_extensions = {} for module_name, enabled in sorted(self.jpserver_extensions.items()): if enabled: # Search for server extensions by loading each extension module @@ -1540,8 +1509,8 @@ def init_server_extensions(self): # A KeyError suggests that the module does not have a # _jupyter_server_extension-path. log_msg = _( - "Error loading server extension " - "{module_name}. There is no `_jupyter_server_extension_path` " + "Error loading server extensions in " + "{module_name} module. There is no `_jupyter_server_extension_path` " "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) @@ -1550,40 +1519,76 @@ def init_server_extensions(self): except: if self.reraise_server_extension_failures: raise - self.log.warning(_("Error loading server extension %s"), module_name, + self.log.warning(_("Error loading server extension module %s"), module_name, exc_info=True) - for metadata in metadata_list: # Check if this server extension is a ExtensionApp object. extapp = metadata.get('app', None) - extmod = metadata.get('module', None) + extloc = metadata.get('module', None) # Load the extension module. - if extapp: - extapp.load_jupyter_server_extension(self) - log_msg = ( - "{module_name} is enabled and " - "loaded".format(module_name=module_name) - ) - self.log.debug(log_msg) - elif extmod: + if extapp and extloc: + if not issubclass(extapp, JupyterApp): + raise TypeError(extapp.__name__ + " must be a subclass of JupyterApp.") + # Initialize extension app + app = extapp(parent=self) + # Update the app's config, setting + # any traits given by the serverapp's + # parsed command line args. + app.update_config(app.config) + # Load any config from an extension's + # config file and make update the + # app's config again. + app.load_config_file() + # Pass any relevant config to the + # serverapp's (parent) config. + self.update_config(app.config) + self.enabled_extensions[extloc] = extapp + elif extloc: + extmod = importlib.import_module(extloc) func = getattr(extmod, 'load_jupyter_server_extension', None) - if func is not None: - func(self) - # Add debug log for loaded extensions. - log_msg = _( - "{module_name} is enabled and " - "loaded".format(module_name=module_name) - ) - self.log.debug(log_msg) - else: + if func is None: log_msg = _( "{module_name} is enabled but no " "`load_jupyter_server_extension` function " "was found.".format(module_name=module_name) ) self.log.warning(log_msg) + else: + 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 init_server_extensions(self): + """Load any extensions specified by config. + + Import the module, then call the load_jupyter_server_extension function, + if one exists. + + The extension API is experimental, and may change in future releases. + """ + # Load all enabled extensions. + for extloc, extension in sorted(self.enabled_extensions.items()): + if isinstance(extension, ModuleType): + log_msg = ( + "Extension from {extloc} module enabled and " + "loaded".format(extloc=extloc) + ) + else: + log_msg = ( + "Extension {extension_name} enabled and " + "loaded".format(extension_name=extension.extension_name) + ) + func = getattr(extension, 'load_jupyter_server_extension') + func(self) + self.log.debug(log_msg) def init_mime_overrides(self): # On some Windows machines, an application has registered incorrect From f3730de72febba2812201fb2f827a97928d382ad Mon Sep 17 00:00:00 2001 From: Zsailer Date: Mon, 16 Mar 2020 15:00:13 -0700 Subject: [PATCH 11/33] split extension initialization and loading --- jupyter_server/extension/application.py | 4 +- jupyter_server/extension/serverextension.py | 78 ++++++++-------- jupyter_server/serverapp.py | 98 ++++++++++----------- 3 files changed, 90 insertions(+), 90 deletions(-) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 4f0c9f73b5..3beecedede 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -414,8 +414,8 @@ 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(parent=serverapp) + # Get loaded extension from serverapp. + extension = serverapp.enabled_extensions[cls.extension_name] extension.initialize(serverapp=serverapp) return extension diff --git a/jupyter_server/extension/serverextension.py b/jupyter_server/extension/serverextension.py index de2b752a89..f73d4d21a2 100644 --- a/jupyter_server/extension/serverextension.py +++ b/jupyter_server/extension/serverextension.py @@ -13,15 +13,44 @@ 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": "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() + + class ArgumentConflict(ValueError): pass @@ -113,7 +142,7 @@ class ExtensionValidationError(Exception): pass def validate_server_extension(import_name): - """Tries to import the extension module. + """Tries to import the extension module. Raises a validation error if module is not found. """ try: @@ -184,7 +213,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 +222,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. @@ -214,9 +243,9 @@ def toggle_server_extension(self, 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 ) @@ -260,7 +289,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 +303,7 @@ class DisableServerExtensionApp(ToggleServerExtensionApp): name = "jupyter server extension disable" description = """ Disable a server extension in configuration. - + Usage jupyter server extension disable [--system|--sys-prefix] """ @@ -353,33 +382,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/serverapp.py b/jupyter_server/serverapp.py index 117be1b7f1..7270876ce2 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1473,7 +1473,7 @@ def init_components(self): # TODO: this should still check, but now we use bower, not git submodule pass - def init_server_extension_config(self): + def init_server_extensions(self): """Consolidate server extensions specified by all configs. The resulting list is stored on self.jpserver_extensions and updates config object. @@ -1505,6 +1505,47 @@ def init_server_extension_config(self): # and looking for the `_jupyter_server_extension_paths` function. try: mod, metadata_list = _get_server_extension_metadata(module_name) + for metadata in metadata_list: + # Check if this server extension is a ExtensionApp object. + extapp = metadata.get('app', None) + extloc = metadata.get('module', None) + # Load the extension module. + if extapp and extloc: + if not issubclass(extapp, JupyterApp): + raise TypeError(extapp.__name__ + " must be a subclass of JupyterApp.") + # Initialize extension app + app = extapp(parent=self) + # Update the app's config, setting + # any traits given by the serverapp's + # parsed command line args. + app.update_config(app.config) + # Load any config from an extension's + # config file and make update the + # app's config again. + app.load_config_file() + # Pass any relevant config to the + # serverapp's (parent) config. + self.update_config(app.config) + self.enabled_extensions[app.extension_name] = app + elif extloc: + extmod = importlib.import_module(extloc) + func = getattr(extmod, 'load_jupyter_server_extension', None) + if func is None: + log_msg = _( + "{module_name} is enabled but no " + "`load_jupyter_server_extension` function " + "was found.".format(module_name=module_name) + ) + self.log.warning(log_msg) + else: + 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) except KeyError: # A KeyError suggests that the module does not have a # _jupyter_server_extension-path. @@ -1519,53 +1560,10 @@ def init_server_extension_config(self): except: if self.reraise_server_extension_failures: raise - self.log.warning(_("Error loading server extension module %s"), module_name, + self.log.warning(_("Error loading server extension module: %s"), module_name, exc_info=True) - for metadata in metadata_list: - # Check if this server extension is a ExtensionApp object. - extapp = metadata.get('app', None) - extloc = metadata.get('module', None) - # Load the extension module. - if extapp and extloc: - if not issubclass(extapp, JupyterApp): - raise TypeError(extapp.__name__ + " must be a subclass of JupyterApp.") - # Initialize extension app - app = extapp(parent=self) - # Update the app's config, setting - # any traits given by the serverapp's - # parsed command line args. - app.update_config(app.config) - # Load any config from an extension's - # config file and make update the - # app's config again. - app.load_config_file() - # Pass any relevant config to the - # serverapp's (parent) config. - self.update_config(app.config) - self.enabled_extensions[extloc] = extapp - elif extloc: - extmod = importlib.import_module(extloc) - func = getattr(extmod, 'load_jupyter_server_extension', None) - if func is None: - log_msg = _( - "{module_name} is enabled but no " - "`load_jupyter_server_extension` function " - "was found.".format(module_name=module_name) - ) - self.log.warning(log_msg) - else: - 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 init_server_extensions(self): + def load_server_extensions(self): """Load any extensions specified by config. Import the module, then call the load_jupyter_server_extension function, @@ -1575,11 +1573,11 @@ def init_server_extensions(self): """ # Load all enabled extensions. - for extloc, extension in sorted(self.enabled_extensions.items()): + for extkey, extension in sorted(self.enabled_extensions.items()): if isinstance(extension, ModuleType): log_msg = ( "Extension from {extloc} module enabled and " - "loaded".format(extloc=extloc) + "loaded".format(extloc=extkey) ) else: log_msg = ( @@ -1737,7 +1735,7 @@ def initialize(self, argv=None, load_extensions=True, new_httpserver=True): # Then, use extensions' config loading mechanism to # update config. ServerApp config takes precedence. if load_extensions: - self.init_server_extension_config() + self.init_server_extensions() # Initialize all components of the ServerApp. self.init_logging() if self._dispatching: @@ -1750,7 +1748,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() From 9cc906d19c73b70915ea13213d12c7b277145df5 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Fri, 20 Mar 2020 15:51:51 +0100 Subject: [PATCH 12/33] Upgrade examples to align on discovery branch --- examples/simple/simple_ext1/__init__.py | 7 ++++--- examples/simple/simple_ext1/application.py | 2 +- examples/simple/simple_ext11/__init__.py | 9 ++++++--- examples/simple/simple_ext2/__init__.py | 5 ++++- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/examples/simple/simple_ext1/__init__.py b/examples/simple/simple_ext1/__init__.py index cf3068ed96..6970cfbc4b 100644 --- a/examples/simple/simple_ext1/__init__.py +++ b/examples/simple/simple_ext1/__init__.py @@ -1,8 +1,9 @@ from .application import SimpleApp1 def _jupyter_server_extension_paths(): - return [ - {'module': 'simple_ext1'} - ] + return [{ + 'module': 'simple_ext1', + 'app': SimpleApp1 + }] load_jupyter_server_extension = SimpleApp1.load_jupyter_server_extension diff --git a/examples/simple/simple_ext1/application.py b/examples/simple/simple_ext1/application.py index 3ba0d18599..1bbe5fd2c5 100644 --- a/examples/simple/simple_ext1/application.py +++ b/examples/simple/simple_ext1/application.py @@ -8,7 +8,7 @@ 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" diff --git a/examples/simple/simple_ext11/__init__.py b/examples/simple/simple_ext11/__init__.py index cf3068ed96..cd664b7bca 100644 --- a/examples/simple/simple_ext11/__init__.py +++ b/examples/simple/simple_ext11/__init__.py @@ -1,8 +1,11 @@ -from .application import SimpleApp1 +from .application import SimpleApp11 def _jupyter_server_extension_paths(): return [ - {'module': 'simple_ext1'} + { + 'module': 'simple_ext11', + 'app': SimpleApp11 + } ] -load_jupyter_server_extension = SimpleApp1.load_jupyter_server_extension +load_jupyter_server_extension = SimpleApp11.load_jupyter_server_extension diff --git a/examples/simple/simple_ext2/__init__.py b/examples/simple/simple_ext2/__init__.py index f9d5935153..443bf59dbf 100644 --- a/examples/simple/simple_ext2/__init__.py +++ b/examples/simple/simple_ext2/__init__.py @@ -2,7 +2,10 @@ def _jupyter_server_extension_paths(): return [ - {'module': 'simple_ext2'}, + { + 'module': 'simple_ext2', + 'app': SimpleApp2, + }, ] load_jupyter_server_extension = SimpleApp2.load_jupyter_server_extension From b447855c7875a8b76d4901a3ac6ecbbfbe8f5bae Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sat, 21 Mar 2020 10:47:01 +0100 Subject: [PATCH 13/33] Polish examples --- examples/simple/README.md | 25 ++++++++++++------- examples/simple/jupyter_simple_ext1_config.py | 1 + examples/simple/package.json | 2 +- examples/simple/setup.py | 4 +-- examples/simple/simple_ext11/application.py | 4 +-- examples/simple/simple_ext2/__init__.py | 2 +- examples/simple/simple_ext2/application.py | 2 +- 7 files changed, 24 insertions(+), 16 deletions(-) diff --git a/examples/simple/README.md b/examples/simple/README.md index 7caa527479..214271cd27 100644 --- a/examples/simple/README.md +++ b/examples/simple/README.md @@ -90,9 +90,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 +105,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,9 +138,10 @@ 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. @@ -147,8 +153,9 @@ The `hello`, `ignore_js` and `simple11_dir` are traits defined on the SimpleApp1 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. 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/setup.py b/examples/simple/setup.py index 061cc3a792..a0fcf7fef2 100755 --- a/examples/simple/setup.py +++ b/examples/simple/setup.py @@ -23,9 +23,9 @@ def add_data_files(path): return data_files setuptools.setup( - name = 'jupyter_simple_ext', + name = 'jupyter_server_example', version = VERSION, - description = 'Jupyter Simple Extension', + description = 'Jupyter Server Example', long_description = open('README.md').read(), packages = find_packages(), python_requires = '>=3.5', diff --git a/examples/simple/simple_ext11/application.py b/examples/simple/simple_ext11/application.py index d67a211fd9..82815e29d6 100644 --- a/examples/simple/simple_ext11/application.py +++ b/examples/simple/simple_ext11/application.py @@ -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 443bf59dbf..035b219c6a 100644 --- a/examples/simple/simple_ext2/__init__.py +++ b/examples/simple/simple_ext2/__init__.py @@ -4,7 +4,7 @@ def _jupyter_server_extension_paths(): return [ { 'module': 'simple_ext2', - 'app': SimpleApp2, + 'app': SimpleApp2 }, ] diff --git a/examples/simple/simple_ext2/application.py b/examples/simple/simple_ext2/application.py index 2c26ca9c0a..7880835d42 100644 --- a/examples/simple/simple_ext2/application.py +++ b/examples/simple/simple_ext2/application.py @@ -15,7 +15,7 @@ class SimpleApp2(ExtensionAppJinjaMixin, ExtensionApp): default_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 = [ From 5e404d1f94141067d87fdbe1b47f149993238a30 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sat, 21 Mar 2020 17:31:40 +0100 Subject: [PATCH 14/33] Launch example via python module --- examples/simple/README.md | 6 ++++++ examples/simple/simple_ext1/__main__.py | 4 ++++ 2 files changed, 10 insertions(+) create mode 100644 examples/simple/simple_ext1/__main__.py diff --git a/examples/simple/README.md b/examples/simple/README.md index 214271cd27..267b1b5ac8 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. diff --git a/examples/simple/simple_ext1/__main__.py b/examples/simple/simple_ext1/__main__.py new file mode 100644 index 0000000000..3a1b11694d --- /dev/null +++ b/examples/simple/simple_ext1/__main__.py @@ -0,0 +1,4 @@ +from .application import main + +if __name__ is '__main__': + main() From 8f3d517ad1c0ad3184c957a73d70f9f6166c787f Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sat, 21 Mar 2020 17:54:56 +0100 Subject: [PATCH 15/33] Avoid to run initialisation methods twice --- examples/simple/simple_ext1/__main__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/simple/simple_ext1/__main__.py b/examples/simple/simple_ext1/__main__.py index 3a1b11694d..e31114fd8d 100644 --- a/examples/simple/simple_ext1/__main__.py +++ b/examples/simple/simple_ext1/__main__.py @@ -1,4 +1,7 @@ -from .application import main +from traitlets import Dict + +from jupyter_server.serverapp import launch_new_instance, ServerApp if __name__ is '__main__': - main() + ServerApp.jpserver_extensions = Dict({'simple_ext1': True}) + launch_new_instance() From d1803e7453d17954f3d56092b2a0f61a85ffde27 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sun, 22 Mar 2020 08:52:37 +0100 Subject: [PATCH 16/33] Add main for simple_ext2 and simple_ext11 --- examples/simple/README.md | 4 +++- examples/simple/simple_ext1/__main__.py | 4 ++-- examples/simple/simple_ext11/__main__.py | 11 +++++++++++ examples/simple/simple_ext2/__main__.py | 10 ++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 examples/simple/simple_ext11/__main__.py create mode 100644 examples/simple/simple_ext2/__main__.py diff --git a/examples/simple/README.md b/examples/simple/README.md index 267b1b5ac8..45f4d8130b 100644 --- a/examples/simple/README.md +++ b/examples/simple/README.md @@ -152,7 +152,7 @@ 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. @@ -167,6 +167,8 @@ 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/simple_ext1/__main__.py b/examples/simple/simple_ext1/__main__.py index e31114fd8d..49559d543a 100644 --- a/examples/simple/simple_ext1/__main__.py +++ b/examples/simple/simple_ext1/__main__.py @@ -1,7 +1,7 @@ from traitlets import Dict -from jupyter_server.serverapp import launch_new_instance, ServerApp +from jupyter_server.serverapp import ServerApp if __name__ is '__main__': ServerApp.jpserver_extensions = Dict({'simple_ext1': True}) - launch_new_instance() + ServerApp.launch_instance() diff --git a/examples/simple/simple_ext11/__main__.py b/examples/simple/simple_ext11/__main__.py new file mode 100644 index 0000000000..ead12e6293 --- /dev/null +++ b/examples/simple/simple_ext11/__main__.py @@ -0,0 +1,11 @@ +from traitlets import Dict + +from jupyter_server.serverapp import ServerApp + +if __name__ is '__main__': + ServerApp.jpserver_extensions = Dict({ + 'simple_ext1': False, + 'simple_ext2': False, + 'simple_ext11': True, + }) + ServerApp.launch_instance() diff --git a/examples/simple/simple_ext2/__main__.py b/examples/simple/simple_ext2/__main__.py new file mode 100644 index 0000000000..7a7559f958 --- /dev/null +++ b/examples/simple/simple_ext2/__main__.py @@ -0,0 +1,10 @@ +from traitlets import Dict + +from jupyter_server.serverapp import ServerApp + +if __name__ is '__main__': + ServerApp.jpserver_extensions = Dict({ + 'simple_ext1': True, + 'simple_ext2': True + }) + ServerApp.launch_instance() From 3634d083d66401e2f59883ea59b33cf0bb7440e4 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 24 Mar 2020 15:04:55 -0700 Subject: [PATCH 17/33] minor changes to extension toggler --- jupyter_server/extension/serverextension.py | 73 ++++++++++++++------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/jupyter_server/extension/serverextension.py b/jupyter_server/extension/serverextension.py index f73d4d21a2..a0e408b8b0 100644 --- a/jupyter_server/extension/serverextension.py +++ b/jupyter_server/extension/serverextension.py @@ -29,22 +29,17 @@ def _get_server_extension_metadata(module): the package as loaded a list of server extension specs: [ { - "module": "mockextension" + "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)) @@ -102,6 +97,7 @@ 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 @@ -141,21 +137,48 @@ def _get_config_dir(user=False, sys_prefix=False): 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 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 = importlib.import_module(import_name) - func = getattr(mod, 'load_jupyter_server_extension') + mod, metadata = _get_server_extension_metadata(extension_name) version = getattr(mod, '__version__', '') - return mod, func, version - # If the extension does not exist, raise an exception 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 = extapp.load_jupyter_server_extension + elif extloc: + extmod = importlib.import_module(extloc) + func = extmod.load_jupyter_server_extension + 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): @@ -239,7 +262,7 @@ 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( @@ -249,7 +272,7 @@ def toggle_server_extension(self, import_name): 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)) @@ -324,6 +347,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") @@ -333,14 +358,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)) From fd882ed60bfae1e54a213cca8a84987437c45619 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Thu, 26 Mar 2020 16:10:18 -0700 Subject: [PATCH 18/33] adding some comments throughout the code --- jupyter_server/extension/application.py | 29 ++++++---- jupyter_server/serverapp.py | 75 ++++++++++++++++++------- 2 files changed, 73 insertions(+), 31 deletions(-) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 3beecedede..1e7022d063 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -181,8 +181,8 @@ def _validate_extension_name(self, proposal): ServerApp, ] - # aliases = aliases - # flags = flags + aliases = aliases + flags = flags subcommands = {} @@ -349,14 +349,21 @@ def _prepare_templates(self): @classmethod def initialize_server(cls, argv=[], load_other_extensions=True, **kwargs): - """Get an instance of the Jupyter Server.""" - # Get a jupyter server instance - serverapp = ServerApp.instance(**kwargs) - # Add extension to jpserver_extensions trait in server. - serverapp.jpserver_extensions.update({cls.extension_name: True}) - # Initialize ServerApp config. - # Parses the command line looking for - # ServerApp configuration. + """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} + } + }) + serverapp = ServerApp.instance(**kwargs, argv=[], config=config) serverapp.initialize(argv=argv, load_extensions=load_other_extensions) return serverapp @@ -415,7 +422,7 @@ def load_jupyter_server_extension(cls, serverapp): settings and handlers to the server's web application. """ # Get loaded extension from serverapp. - extension = serverapp.enabled_extensions[cls.extension_name] + extension = serverapp._enabled_extensions[cls.extension_name] extension.initialize(serverapp=serverapp) return extension diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 7270876ce2..b1736839e3 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -557,6 +557,9 @@ 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 = [ @@ -1474,12 +1477,21 @@ def init_components(self): pass def init_server_extensions(self): - """Consolidate server extensions specified by all configs. - - The resulting list is stored on self.jpserver_extensions and updates config object. + """ + Searches Jupyter paths for jpserver_extensions and captures + metadata for all enabled extensions. - The extension API is experimental, and may change in future releases. + If an extension's metadata includes an 'app' key, + the value should be a subclass of ExtensionApp and an instance + will be created. 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, @@ -1497,36 +1509,56 @@ def init_server_extensions(self): self.config.ServerApp.jpserver_extensions.update({modulename: enabled}) self.jpserver_extensions.update({modulename: enabled}) - # Discover ExtensionApp's to load their config that might affect ServerApp. - self.enabled_extensions = {} + # 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: - # Search for server extensions by loading each extension module - # and looking for the `_jupyter_server_extension_paths` function. 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. mod, metadata_list = _get_server_extension_metadata(module_name) for metadata in metadata_list: - # Check if this server extension is a ExtensionApp object. + # Is this extension point an ExtensionApp? + # If "app" is not None, then the extension should be an ExtensionApp + # If "app" is None, then the extension must be a module + # where we'll find a `_load_jupyter_server_extension` function. + # If so, create + # an instance of the App and load its config into the + # ServerApp parent. extapp = metadata.get('app', None) extloc = metadata.get('module', None) - # Load the extension module. if extapp and extloc: - if not issubclass(extapp, JupyterApp): - raise TypeError(extapp.__name__ + " must be a subclass of JupyterApp.") - # Initialize extension app + # 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 needs to create an instance of the + # ExtensionApp here and pass ServerApp as the + # parent class, so that the instance shares + # ServerApp's config. app = extapp(parent=self) - # Update the app's config, setting - # any traits given by the serverapp's - # parsed command line args. + # Update the app's config so that any command + # line config parsed by ServerApp is passed + # to the ExtensionApp app.update_config(app.config) # Load any config from an extension's - # config file and make update the + # config file and update the # app's config again. app.load_config_file() # Pass any relevant config to the # serverapp's (parent) config. self.update_config(app.config) - self.enabled_extensions[app.extension_name] = app + # Build a new list where we + self._enabled_extensions[app.extension_name] = app elif extloc: extmod = importlib.import_module(extloc) func = getattr(extmod, 'load_jupyter_server_extension', None) @@ -1538,7 +1570,7 @@ def init_server_extensions(self): ) self.log.warning(log_msg) else: - self.enabled_extensions[extloc] = extmod + self._enabled_extensions[extloc] = extmod else: log_msg = _( "{module_name} is missing critical metadata. Check " @@ -1573,7 +1605,7 @@ def load_server_extensions(self): """ # Load all enabled extensions. - for extkey, extension in sorted(self.enabled_extensions.items()): + for extkey, extension in sorted(self._enabled_extensions.items()): if isinstance(extension, ModuleType): log_msg = ( "Extension from {extloc} module enabled and " @@ -1731,7 +1763,10 @@ def initialize(self, argv=None, load_extensions=True, new_httpserver=True): self._init_asyncio_patch() # Parse command line, load ServerApp config files, # and update ServerApp config. + print(f"During serverapp.initialize: {self.jpserver_extensions}") super(ServerApp, self).initialize(argv) + print(f"after serverapp.initialize super: {self.jpserver_extensions}") + # Then, use extensions' config loading mechanism to # update config. ServerApp config takes precedence. if load_extensions: From f85491443bdcc42423c0b6bb529e7306877cbc0b Mon Sep 17 00:00:00 2001 From: Zsailer Date: Fri, 27 Mar 2020 13:38:54 -0700 Subject: [PATCH 19/33] move all CLI handling to the ServerApp --- jupyter_server/extension/application.py | 173 +++++++++++++----------- jupyter_server/serverapp.py | 32 ++--- 2 files changed, 99 insertions(+), 106 deletions(-) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 1e7022d063..0ddeacdedb 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -121,13 +121,11 @@ def _prepare_templates(self): ) #----------------------------------------------------------------------------- -# 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 @@ -181,11 +179,6 @@ def _validate_extension_name(self, proposal): ServerApp, ] - aliases = aliases - flags = flags - - subcommands = {} - @property def static_url_prefix(self): return "/static/{extension_name}/".format( @@ -219,46 +212,43 @@ def _config_file_name_default(self): 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)) + extension_url = "/" + + # default_url = Unicode('/', config=True, + # help=_("The default URL to redirect to from `/`") + # ) + + # 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.""" @@ -276,6 +266,8 @@ def _prepare_config(self): """Builds a Config object from the extension's traits and passes the object to the webapp's settings as `_config`. """ + # Make sure the ServerApp receives any config. + self.serverapp.update_config(self.config) # Verify all traits are up-to-date with config self.update_config(self.config) traits = self.class_own_traits().keys() @@ -360,34 +352,58 @@ def initialize_server(cls, argv=[], load_other_extensions=True, **kwargs): # initializes it. config = Config({ "ServerApp": { - "jpserver_extensions": {cls.extension_name: True} + "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=None): - """Initialize the extension app. + def link_to_serverapp(self, serverapp): + """Link the ExtensionApp to an initialized ServerApp. - 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. + This adds a serverapp. """ - # Initialize ServerApp. self.serverapp = serverapp - - # If argv is given, parse and update config. - if argv: - self.parse_command_line(argv) - - # Load config from 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) + # Load config from an ExtensionApp's config files. + # If any config should be passed upstream to the + # ServerApp, do it here. self.load_config_file() + # 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() @@ -399,14 +415,6 @@ def start(self): 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() @@ -421,9 +429,13 @@ 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. """ - # Get loaded extension from serverapp. - extension = serverapp._enabled_extensions[cls.extension_name] - extension.initialize(serverapp=serverapp) + 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 @@ -457,7 +469,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, **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/serverapp.py b/jupyter_server/serverapp.py index b1736839e3..79225dbad6 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1373,7 +1373,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.""" @@ -1529,11 +1529,8 @@ def init_server_extensions(self): for metadata in metadata_list: # Is this extension point an ExtensionApp? # If "app" is not None, then the extension should be an ExtensionApp - # If "app" is None, then the extension must be a module - # where we'll find a `_load_jupyter_server_extension` function. - # If so, create - # an instance of the App and load its config into the - # ServerApp parent. + # 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: @@ -1541,22 +1538,11 @@ def init_server_extensions(self): from .extension.application import ExtensionApp if not issubclass(extapp, ExtensionApp): raise TypeError(extapp.__name__ + " must be a subclass of ExtensionApp.") - # ServerApp needs to create an instance of the - # ExtensionApp here and pass ServerApp as the - # parent class, so that the instance shares - # ServerApp's config. - app = extapp(parent=self) - # Update the app's config so that any command - # line config parsed by ServerApp is passed - # to the ExtensionApp - app.update_config(app.config) - # Load any config from an extension's - # config file and update the - # app's config again. - app.load_config_file() - # Pass any relevant config to the - # serverapp's (parent) config. - self.update_config(app.config) + # 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: @@ -1763,9 +1749,7 @@ def initialize(self, argv=None, load_extensions=True, new_httpserver=True): self._init_asyncio_patch() # Parse command line, load ServerApp config files, # and update ServerApp config. - print(f"During serverapp.initialize: {self.jpserver_extensions}") super(ServerApp, self).initialize(argv) - print(f"after serverapp.initialize super: {self.jpserver_extensions}") # Then, use extensions' config loading mechanism to # update config. ServerApp config takes precedence. From ec755ae3bd961202dda17cca0f0636c56b14f814 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Mon, 30 Mar 2020 08:16:16 -0700 Subject: [PATCH 20/33] remove old traits from extensionapp --- jupyter_server/extension/application.py | 46 +++++-------------------- 1 file changed, 8 insertions(+), 38 deletions(-) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 0ddeacdedb..6e77743710 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -174,6 +174,9 @@ 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, @@ -212,43 +215,6 @@ def _config_file_name_default(self): return '' return 'jupyter_{}_config'.format(self.extension_name.replace('-','_')) - extension_url = "/" - - # default_url = Unicode('/', config=True, - # help=_("The default URL to redirect to from `/`") - # ) - - # 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.""" @@ -364,7 +330,11 @@ def initialize_server(cls, argv=[], load_other_extensions=True, **kwargs): def link_to_serverapp(self, serverapp): """Link the ExtensionApp to an initialized ServerApp. - This adds a 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. """ self.serverapp = serverapp # ServerApp's config might have picked up From 67ca746bcb3abeaa44424e51d1bbb09d49e04309 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Thu, 2 Apr 2020 08:49:53 -0700 Subject: [PATCH 21/33] update tests --- jupyter_server/extension/serverextension.py | 21 +++- jupyter_server/pytest_plugin.py | 2 +- jupyter_server/serverapp.py | 50 +++++--- tests/extension/conftest.py | 84 ++----------- tests/extension/mockextensions/__init__.py | 24 ++++ tests/extension/mockextensions/app.py | 31 +++++ tests/extension/mockextensions/mock1.py | 16 +++ tests/extension/mockextensions/mock2.py | 16 +++ tests/extension/mockextensions/mock3.py | 5 + .../extension/mockextensions/mockext_both.py | 17 +++ tests/extension/mockextensions/mockext_py.py | 15 +++ tests/extension/mockextensions/mockext_sys.py | 14 +++ .../extension/mockextensions/mockext_user.py | 14 +++ tests/extension/test_app.py | 115 ++++++++++-------- tests/extension/test_entrypoint.py | 20 ++- tests/extension/test_handler.py | 25 +++- tests/extension/test_serverextension.py | 109 ++++++++--------- 17 files changed, 364 insertions(+), 214 deletions(-) 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/jupyter_server/extension/serverextension.py b/jupyter_server/extension/serverextension.py index a0e408b8b0..c27755abdb 100644 --- a/jupyter_server/extension/serverextension.py +++ b/jupyter_server/extension/serverextension.py @@ -134,9 +134,26 @@ def _get_config_dir(user=False, sys_prefix=False): # Public API # ------------------------------------------------------------------------------ +class ExtensionLoadingError(Exception): pass + + class ExtensionValidationError(Exception): pass + +def _get_load_jupyter_server_extension(obj): + """Looks for load_jupyter_server_extension as an attribute + of the object or module. + """ + try: + 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` @@ -169,10 +186,10 @@ def validate_server_extension(extension_name): extapp = item.get('app', None) extloc = item.get('module', None) if extapp and extloc: - func = extapp.load_jupyter_server_extension + func = _get_load_jupyter_server_extension(extapp) elif extloc: extmod = importlib.import_module(extloc) - func = extmod.load_jupyter_server_extension + func = _get_load_jupyter_server_extension(extmod) else: raise AttributeError # If the extension does not have a `load_jupyter_server_extension` function, raise exception. diff --git a/jupyter_server/pytest_plugin.py b/jupyter_server/pytest_plugin.py index a181ef28ae..116240a382 100644 --- a/jupyter_server/pytest_plugin.py +++ b/jupyter_server/pytest_plugin.py @@ -111,7 +111,7 @@ def extension_environ(env_config_path, monkeypatch): @pytest.fixture def configurable_serverapp( - environ, http_port, tmp_path, home_dir, data_dir, config_dir, runtime_dir, root_dir, io_loop, server_config, **kwargs + environ, extension_environ, http_port, tmp_path, home_dir, data_dir, config_dir, runtime_dir, root_dir, io_loop, server_config, **kwargs ): def serverapp( config=server_config, diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 79225dbad6..6c0e11e614 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -104,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, _get_server_extension_metadata +from jupyter_server.extension.serverextension import ( + ServerExtensionApp, + _get_server_extension_metadata, + _get_load_jupyter_server_extension +) #----------------------------------------------------------------------------- # Module globals @@ -1547,16 +1551,17 @@ def init_server_extensions(self): self._enabled_extensions[app.extension_name] = app elif extloc: extmod = importlib.import_module(extloc) - func = getattr(extmod, 'load_jupyter_server_extension', None) - if func is None: - log_msg = _( - "{module_name} is enabled but no " - "`load_jupyter_server_extension` function " - "was found.".format(module_name=module_name) - ) - self.log.warning(log_msg) - else: - self._enabled_extensions[extloc] = extmod + func = _get_load_jupyter_server_extension(extmod) + self._enabled_extensions[extloc] = extmod + + # if func is None: + # log_msg = _( + # "{module_name} is enabled but no " + # "`load_jupyter_server_extension` function " + # "was found.".format(module_name=module_name) + # ) + # self.log.warning(log_msg) + # else: else: log_msg = _( "{module_name} is missing critical metadata. Check " @@ -1602,9 +1607,26 @@ def load_server_extensions(self): "Extension {extension_name} enabled and " "loaded".format(extension_name=extension.extension_name) ) - func = getattr(extension, 'load_jupyter_server_extension') - func(self) - self.log.debug(log_msg) + # 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 diff --git a/tests/extension/conftest.py b/tests/extension/conftest.py index 0fcfcd5ff5..03967d9d10 100644 --- a/tests/extension/conftest.py +++ b/tests/extension/conftest.py @@ -1,56 +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 - - @classmethod - def _jupyter_server_extension_paths(cls): - return [{ - 'module': '_mockdestination/index', - 'app': cls - }] - -@pytest.fixture -def make_mock_extension_app(template_dir, config_dir): - def _make_mock_extension_app(**kwargs): - kwargs.setdefault('template_paths', [str(template_dir)]) - return MockExtensionApp(config_dir=str(config_dir), **kwargs) - - # TODO Should the index template creation be only be done only once? - index = template_dir.joinpath("index.html") - index.write_text(""" +mock_html = """ @@ -69,34 +19,16 @@ 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 - - -@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 enabled_extensions(serverapp): + return serverapp._enabled_extensions diff --git a/tests/extension/mockextensions/__init__.py b/tests/extension/mockextensions/__init__.py new file mode 100644 index 0000000000..99caa1f508 --- /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': 'mockextension', + 'app': MockExtensionApp + }, + { + 'module': 'mock1' + }, + { + 'module': 'mock2' + }, + { + 'module': 'mock3' + } + ] diff --git a/tests/extension/mockextensions/app.py b/tests/extension/mockextensions/app.py new file mode 100644 index 0000000000..ed1f0c5aad --- /dev/null +++ b/tests/extension/mockextensions/app.py @@ -0,0 +1,31 @@ +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..0b18171c6a --- /dev/null +++ b/tests/extension/mockextensions/mock3.py @@ -0,0 +1,5 @@ +"""A mock extension named `mock3` for testing purposes. +""" + +def _load_jupyter_server_extension(): + 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..bfa215948d --- /dev/null +++ b/tests/extension/mockextensions/mockext_both.py @@ -0,0 +1,17 @@ +"""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(): + 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..618721cfe7 --- /dev/null +++ b/tests/extension/mockextensions/mockext_py.py @@ -0,0 +1,15 @@ +"""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(): + 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..6f629e6703 --- /dev/null +++ b/tests/extension/mockextensions/mockext_sys.py @@ -0,0 +1,14 @@ +"""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(): + 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..d52a5e74e6 --- /dev/null +++ b/tests/extension/mockextensions/mockext_user.py @@ -0,0 +1,14 @@ +"""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(): + pass \ No newline at end of file diff --git a/tests/extension/test_app.py b/tests/extension/test_app.py index 1cfd94137a..d7e9fbbf0f 100644 --- a/tests/extension/test_app.py +++ b/tests/extension/test_app.py @@ -4,61 +4,74 @@ 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(template_dir): + return { + "ServerApp": { + "jpserver_extensions": { + "tests.extension.mockextensions": True + } + }, + "MockExtensionApp": { + "template_paths": [ + str(template_dir) + ] + } + } + +@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 + assert mock_extension.template_paths == [str(template_dir)] + + + + + +# 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, mock_extension): +# kwarg = {} +# kwarg.setdefault(trait_name, trait_value) +# mock_extension = make_mock_extension_app(**kwarg) +# assert getattr(mock_extension, trait_name) == trait_value + + +# @pytest.mark.parametrize( +# 'trait_name,trait_value', +# traits +# ) +# 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 -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 - - -@pytest.mark.parametrize( - 'trait_name,trait_value', - traits -) -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_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_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' +# 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_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 index 14fa0f6ae8..1bd2f62238 100644 --- a/tests/extension/test_entrypoint.py +++ b/tests/extension/test_entrypoint.py @@ -12,13 +12,17 @@ def test_server_extension_list(environ, script_runner): 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 + ) assert ret.success assert 'Enabling: {}'.format(extension_name) in ret.stderr @@ -27,6 +31,12 @@ 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 + ) 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..8238711299 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 ------------------- - -async def test_handler(fetch, extended_serverapp): +@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): 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): r = await fetch( 'mock_template', method='GET' @@ -21,7 +34,7 @@ async def test_handler_template(fetch, extended_serverapp): assert r.code == 200 -async def test_handler_setting(fetch, serverapp, make_mock_extension_app): +async def test_handler_setting(fetch, serverapp): # Configure trait in Mock Extension. m = make_mock_extension_app(mock_trait='test mock trait') m.initialize(serverapp) diff --git a/tests/extension/test_serverextension.py b/tests/extension/test_serverextension.py index 64411d299b..62135442a7 100644 --- a/tests/extension/test_serverextension.py +++ b/tests/extension/test_serverextension.py @@ -32,95 +32,86 @@ 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 ): # 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'] - - -# ### NEED TO REFACTOR - -# @pytest.fixture -# def ordered_server_extensions(): -# mockextension1 = SimpleNamespace() -# mockextension2 = SimpleNamespace() + assert not extensions['tests.extension.mockextensions.mockext_both'] -# 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" +def test_load_ordered(configurable_serverapp): + app = configurable_serverapp( + jpserver_extensions=OrderedDict([ + ('tests.extension.mockextensions.mock2', True), + ('tests.extension.mockextensions.mock1', True) + ]) + ) + 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" From 258fbf5b3f687bc5fb074d5f8d8c5e6a5ee33bb6 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 7 Apr 2020 11:16:39 -0700 Subject: [PATCH 22/33] update tests with changes to extensionapp --- examples/simple/simple_ext1/application.py | 4 +- examples/simple/simple_ext11/application.py | 8 +- examples/simple/simple_ext2/application.py | 4 +- jupyter_server/extension/application.py | 32 +++---- jupyter_server/extension/serverextension.py | 2 +- jupyter_server/pytest_plugin.py | 24 ++--- jupyter_server/serverapp.py | 27 +++--- tests/extension/conftest.py | 8 ++ tests/extension/mockextensions/__init__.py | 8 +- tests/extension/mockextensions/app.py | 16 +++- tests/extension/mockextensions/mock3.py | 3 +- .../extension/mockextensions/mockext_both.py | 5 +- tests/extension/mockextensions/mockext_py.py | 3 +- tests/extension/mockextensions/mockext_sys.py | 4 +- .../extension/mockextensions/mockext_user.py | 4 +- tests/extension/test_app.py | 90 +++++++++---------- tests/extension/test_entrypoint.py | 2 - tests/extension/test_handler.py | 54 ++++++----- tests/extension/test_serverextension.py | 43 +++++---- 19 files changed, 178 insertions(+), 163 deletions(-) diff --git a/examples/simple/simple_ext1/application.py b/examples/simple/simple_ext1/application.py index 1bbe5fd2c5..980bde0d46 100644 --- a/examples/simple/simple_ext1/application.py +++ b/examples/simple/simple_ext1/application.py @@ -1,7 +1,7 @@ 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") @@ -13,7 +13,7 @@ class SimpleApp1(ExtensionAppJinjaMixin, ExtensionApp): extension_name = "simple_ext1" # Te url that your extension will serve its homepage. - default_url = '/simple_ext1/default' + 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/application.py b/examples/simple/simple_ext11/application.py index 82815e29d6..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') diff --git a/examples/simple/simple_ext2/application.py b/examples/simple/simple_ext2/application.py index 7880835d42..9528ac6cac 100644 --- a/examples/simple/simple_ext2/application.py +++ b/examples/simple/simple_ext2/application.py @@ -7,12 +7,12 @@ 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_name = '/simple_ext2' # Should your extension expose other server extensions when launched directly? load_other_extensions = True diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 6e77743710..262c750e9d 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -7,7 +7,6 @@ Unicode, List, Dict, - Bool, default, validate ) @@ -15,21 +14,16 @@ 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. @@ -120,16 +114,18 @@ def _prepare_templates(self): } ) -#----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- # ExtensionApp -#----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- + class JupyterServerExtensionException(Exception): """Exception class for raising for Server extensions errors.""" -#----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- # ExtensionApp -#----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- + class ExtensionApp(JupyterApp): """Base class for configurable Jupyter Server Extension Applications. @@ -344,14 +340,14 @@ def link_to_serverapp(self, serverapp): # 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) # Load config from an ExtensionApp's config files. # If any config should be passed upstream to the # ServerApp, do it here. self.load_config_file() + # 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) # i.e. ServerApp traits <--- ExtensionApp config self.serverapp.update_config(self.config) diff --git a/jupyter_server/extension/serverextension.py b/jupyter_server/extension/serverextension.py index c27755abdb..755920c53f 100644 --- a/jupyter_server/extension/serverextension.py +++ b/jupyter_server/extension/serverextension.py @@ -101,7 +101,7 @@ def _log_format_default(self): 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 ---------- diff --git a/jupyter_server/pytest_plugin.py b/jupyter_server/pytest_plugin.py index 116240a382..7bc6842b50 100644 --- a/jupyter_server/pytest_plugin.py +++ b/jupyter_server/pytest_plugin.py @@ -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,12 +106,17 @@ 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, extension_environ, http_port, tmp_path, home_dir, data_dir, config_dir, runtime_dir, root_dir, io_loop, server_config, **kwargs + environ, + http_port, + tmp_path, + root_dir, + io_loop, + server_config, + **kwargs ): def serverapp( config=server_config, @@ -119,10 +124,6 @@ def serverapp( 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, diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 6c0e11e614..ca74785d3e 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -446,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") @@ -1486,9 +1486,10 @@ def init_server_extensions(self): metadata for all enabled extensions. If an extension's metadata includes an 'app' key, - the value should be a subclass of ExtensionApp and an instance - will be created. The config for this instance will inherit - the ServerApp's config object and load its own config. + 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. # @@ -1499,12 +1500,17 @@ def init_server_extensions(self): # 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', {}) @@ -1553,15 +1559,6 @@ def init_server_extensions(self): extmod = importlib.import_module(extloc) func = _get_load_jupyter_server_extension(extmod) self._enabled_extensions[extloc] = extmod - - # if func is None: - # log_msg = _( - # "{module_name} is enabled but no " - # "`load_jupyter_server_extension` function " - # "was found.".format(module_name=module_name) - # ) - # self.log.warning(log_msg) - # else: else: log_msg = _( "{module_name} is missing critical metadata. Check " diff --git a/tests/extension/conftest.py b/tests/extension/conftest.py index 03967d9d10..677f1e55de 100644 --- a/tests/extension/conftest.py +++ b/tests/extension/conftest.py @@ -32,3 +32,11 @@ def mock_template(template_dir): @pytest.fixture def enabled_extensions(serverapp): return serverapp._enabled_extensions + + +@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 \ No newline at end of file diff --git a/tests/extension/mockextensions/__init__.py b/tests/extension/mockextensions/__init__.py index 99caa1f508..702dc3b123 100644 --- a/tests/extension/mockextensions/__init__.py +++ b/tests/extension/mockextensions/__init__.py @@ -9,16 +9,16 @@ def _jupyter_server_extension_paths(): return [ { - 'module': 'mockextension', + 'module': 'tests.extension.mockextensions.app', 'app': MockExtensionApp }, { - 'module': 'mock1' + 'module': 'tests.extension.mockextensions.mock1' }, { - 'module': 'mock2' + 'module': 'tests.extension.mockextensions.mock2' }, { - 'module': 'mock3' + 'module': 'tests.extension.mockextensions.mock3' } ] diff --git a/tests/extension/mockextensions/app.py b/tests/extension/mockextensions/app.py index ed1f0c5aad..06a5cc0fd0 100644 --- a/tests/extension/mockextensions/app.py +++ b/tests/extension/mockextensions/app.py @@ -1,8 +1,14 @@ 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 +from jupyter_server.extension.application import ( + ExtensionApp, + ExtensionAppJinjaMixin +) +from jupyter_server.extension.handler import ( + ExtensionHandlerMixin, + ExtensionHandlerJinjaMixin +) class MockExtensionHandler(ExtensionHandlerMixin, JupyterHandler): @@ -11,7 +17,11 @@ def get(self): self.finish(self.config.mock_trait) -class MockExtensionTemplateHandler(ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler): +class MockExtensionTemplateHandler( + ExtensionHandlerJinjaMixin, + ExtensionHandlerMixin, + JupyterHandler + ): def get(self): self.write(self.render_template("index.html")) diff --git a/tests/extension/mockextensions/mock3.py b/tests/extension/mockextensions/mock3.py index 0b18171c6a..233f492a60 100644 --- a/tests/extension/mockextensions/mock3.py +++ b/tests/extension/mockextensions/mock3.py @@ -1,5 +1,6 @@ """A mock extension named `mock3` for testing purposes. """ -def _load_jupyter_server_extension(): + +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 index bfa215948d..6fe762cc7f 100644 --- a/tests/extension/mockextensions/mockext_both.py +++ b/tests/extension/mockextensions/mockext_both.py @@ -1,6 +1,7 @@ """A mock extension named `mockext_both` for testing purposes. """ + # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_paths(): @@ -11,7 +12,5 @@ def _jupyter_server_extension_paths(): ] - - -def _load_jupyter_server_extension(): +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 index 618721cfe7..9f40b7725b 100644 --- a/tests/extension/mockextensions/mockext_py.py +++ b/tests/extension/mockextensions/mockext_py.py @@ -1,6 +1,7 @@ """A mock extension named `mockext_py` for testing purposes. """ + # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_paths(): @@ -11,5 +12,5 @@ def _jupyter_server_extension_paths(): ] -def _load_jupyter_server_extension(): +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 index 6f629e6703..f5504790b0 100644 --- a/tests/extension/mockextensions/mockext_sys.py +++ b/tests/extension/mockextensions/mockext_sys.py @@ -1,5 +1,7 @@ """A mock extension named `mockext_py` for testing purposes. """ + + # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_paths(): @@ -10,5 +12,5 @@ def _jupyter_server_extension_paths(): ] -def _load_jupyter_server_extension(): +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 index d52a5e74e6..f078b587d7 100644 --- a/tests/extension/mockextensions/mockext_user.py +++ b/tests/extension/mockextensions/mockext_user.py @@ -1,5 +1,7 @@ """A mock extension named `mockext_user` for testing purposes. """ + + # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_paths(): @@ -10,5 +12,5 @@ def _jupyter_server_extension_paths(): ] -def _load_jupyter_server_extension(): +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 d7e9fbbf0f..feaf299bc5 100644 --- a/tests/extension/test_app.py +++ b/tests/extension/test_app.py @@ -1,23 +1,24 @@ import pytest - from jupyter_server.serverapp import ServerApp -from jupyter_server.extension.application import ExtensionApp @pytest.fixture -def server_config(template_dir): - return { +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): @@ -31,47 +32,36 @@ def test_initialize(mock_extension, template_dir): assert mock_extension.template_paths == [str(template_dir)] - - - -# 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, mock_extension): -# kwarg = {} -# kwarg.setdefault(trait_name, trait_value) -# mock_extension = make_mock_extension_app(**kwarg) -# assert getattr(mock_extension, trait_name) == trait_value - - -# @pytest.mark.parametrize( -# 'trait_name,trait_value', -# traits -# ) -# 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_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_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' +@pytest.mark.parametrize( + '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, + enabled_extensions +): + extension = enabled_extensions['mockextension'] + assert getattr(extension, trait_name) == trait_value + + +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 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 extension.mock_trait == 'config from file' diff --git a/tests/extension/test_entrypoint.py b/tests/extension/test_entrypoint.py index 1bd2f62238..6b55ec2778 100644 --- a/tests/extension/test_entrypoint.py +++ b/tests/extension/test_entrypoint.py @@ -1,7 +1,5 @@ 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') diff --git a/tests/extension/test_handler.py b/tests/extension/test_handler.py index 8238711299..c7c5ff8e82 100644 --- a/tests/extension/test_handler.py +++ b/tests/extension/test_handler.py @@ -4,17 +4,17 @@ @pytest.fixture def server_config(template_dir): return { - "ServerApp": { - "jpserver_extensions": { - "tests.extension.mockextensions": True + "ServerApp": { + "jpserver_extensions": { + "tests.extension.mockextensions": True + } + }, + "MockExtensionApp": { + "template_paths": [ + str(template_dir) + ] } - }, - "MockExtensionApp": { - "template_paths": [ - str(template_dir) - ] } - } async def test_handler(fetch): @@ -26,7 +26,7 @@ async def test_handler(fetch): assert r.body.decode() == 'mock trait' -async def test_handler_template(fetch): +async def test_handler_template(fetch, mock_template): r = await fetch( 'mock_template', method='GET' @@ -34,11 +34,25 @@ async def test_handler_template(fetch): assert r.code == 200 -async def test_handler_setting(fetch, serverapp): - # 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": { + "template_paths": [ + pytest.lazy_fixture('template_dir') + ], + "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', @@ -48,12 +62,10 @@ async def test_handler_setting(fetch, serverapp): 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 62135442a7..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, @@ -47,9 +39,10 @@ def test_disable(): def test_merge_config( - env_config_path, - configurable_serverapp - ): + env_config_path, + configurable_serverapp, + extension_environ +): # enabled at sys level validate_server_extension('tests.extension.mockextensions.mockext_sys') # enabled at sys, disabled at user @@ -104,14 +97,20 @@ def test_merge_config( assert not extensions['tests.extension.mockextensions.mockext_both'] -def test_load_ordered(configurable_serverapp): - app = configurable_serverapp( - jpserver_extensions=OrderedDict([ - ('tests.extension.mockextensions.mock2', True), - ('tests.extension.mockextensions.mock1', True) - ]) - ) - 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" - +@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" From 693b6f5365b8fb2fe5361b5dc90a31f13a8dd9df Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 7 Apr 2020 11:37:25 -0700 Subject: [PATCH 23/33] fix examples entrypoint --- examples/simple/simple_ext1/__main__.py | 9 +++------ examples/simple/simple_ext11/__main__.py | 13 +++---------- examples/simple/simple_ext2/__main__.py | 12 +++--------- jupyter_server/serverapp.py | 2 ++ 4 files changed, 11 insertions(+), 25 deletions(-) diff --git a/examples/simple/simple_ext1/__main__.py b/examples/simple/simple_ext1/__main__.py index 49559d543a..6ca6f5d746 100644 --- a/examples/simple/simple_ext1/__main__.py +++ b/examples/simple/simple_ext1/__main__.py @@ -1,7 +1,4 @@ -from traitlets import Dict +from .application import main -from jupyter_server.serverapp import ServerApp - -if __name__ is '__main__': - ServerApp.jpserver_extensions = Dict({'simple_ext1': True}) - ServerApp.launch_instance() +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/simple/simple_ext11/__main__.py b/examples/simple/simple_ext11/__main__.py index ead12e6293..6ca6f5d746 100644 --- a/examples/simple/simple_ext11/__main__.py +++ b/examples/simple/simple_ext11/__main__.py @@ -1,11 +1,4 @@ -from traitlets import Dict +from .application import main -from jupyter_server.serverapp import ServerApp - -if __name__ is '__main__': - ServerApp.jpserver_extensions = Dict({ - 'simple_ext1': False, - 'simple_ext2': False, - 'simple_ext11': True, - }) - ServerApp.launch_instance() +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/simple/simple_ext2/__main__.py b/examples/simple/simple_ext2/__main__.py index 7a7559f958..6ca6f5d746 100644 --- a/examples/simple/simple_ext2/__main__.py +++ b/examples/simple/simple_ext2/__main__.py @@ -1,10 +1,4 @@ -from traitlets import Dict +from .application import main -from jupyter_server.serverapp import ServerApp - -if __name__ is '__main__': - ServerApp.jpserver_extensions = Dict({ - 'simple_ext1': True, - 'simple_ext2': True - }) - ServerApp.launch_instance() +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index ca74785d3e..115653a168 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -564,8 +564,10 @@ class ServerApp(JupyterApp): # 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, From e77d958621f92ebe80a6e55f1fd765f73b98b78c Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 7 Apr 2020 11:48:56 -0700 Subject: [PATCH 24/33] add test dependency: pytest-lazy-fixture --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c2a4ca58d2..1fdb2f861b 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==5.3.2', 'pytest-cov', 'pytest-tornasync', + 'pytest-console-scripts', 'pytest-lazy-fixture'], 'test:sys_platform == "win32"': ['nose-exclude'], }, python_requires = '>=3.5', From 64be87ab6f477ee8c7edff47684f33ef6293da5e Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 7 Apr 2020 12:48:23 -0700 Subject: [PATCH 25/33] unpin pytest --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1fdb2f861b..88fc2c3e3e 100755 --- a/setup.py +++ b/setup.py @@ -98,7 +98,7 @@ ], extras_require = { 'test': ['nose', 'coverage', 'requests', 'nose_warnings_filters', - 'pytest==5.3.2', 'pytest-cov', 'pytest-tornasync', + 'pytest', 'pytest-cov', 'pytest-tornasync', 'pytest-console-scripts', 'pytest-lazy-fixture'], 'test:sys_platform == "win32"': ['nose-exclude'], }, From 2692a710c47e43f8b002ccf445f6fca1cea636e7 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 7 Apr 2020 12:54:58 -0700 Subject: [PATCH 26/33] import lazyfixture directly due to changes in pytest --- tests/extension/test_handler.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/extension/test_handler.py b/tests/extension/test_handler.py index c7c5ff8e82..5f5b5a019f 100644 --- a/tests/extension/test_handler.py +++ b/tests/extension/test_handler.py @@ -1,4 +1,5 @@ import pytest +from pytest_lazyfixture import lazy_fixture @pytest.fixture @@ -45,8 +46,10 @@ async def test_handler_template(fetch, mock_template): }, "MockExtensionApp": { "template_paths": [ - pytest.lazy_fixture('template_dir') + lazy_fixture('template_dir') ], + # Change a trait in the MockExtensionApp using + # the following config value. "mock_trait": "test mock trait" } } From e56bae9064d0f42b000da90c48556dceeec48c82 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 7 Apr 2020 13:11:50 -0700 Subject: [PATCH 27/33] drop pytest-lazy-fixture --- setup.py | 2 +- tests/extension/test_handler.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 88fc2c3e3e..2697fad271 100755 --- a/setup.py +++ b/setup.py @@ -99,7 +99,7 @@ extras_require = { 'test': ['nose', 'coverage', 'requests', 'nose_warnings_filters', 'pytest', 'pytest-cov', 'pytest-tornasync', - 'pytest-console-scripts', 'pytest-lazy-fixture'], + 'pytest-console-scripts'], 'test:sys_platform == "win32"': ['nose-exclude'], }, python_requires = '>=3.5', diff --git a/tests/extension/test_handler.py b/tests/extension/test_handler.py index 5f5b5a019f..86dd72d5ea 100644 --- a/tests/extension/test_handler.py +++ b/tests/extension/test_handler.py @@ -1,5 +1,4 @@ import pytest -from pytest_lazyfixture import lazy_fixture @pytest.fixture @@ -45,9 +44,6 @@ async def test_handler_template(fetch, mock_template): } }, "MockExtensionApp": { - "template_paths": [ - lazy_fixture('template_dir') - ], # Change a trait in the MockExtensionApp using # the following config value. "mock_trait": "test mock trait" From 32b7210d0df35d3912c898c4a0eef7bd7aaeafd4 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 7 Apr 2020 13:58:23 -0700 Subject: [PATCH 28/33] cleaner error handling in init_server_extension --- jupyter_server/serverapp.py | 67 ++++++++++++++---------------- tests/extension/test_entrypoint.py | 15 +++++-- 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 115653a168..7687978835 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1538,36 +1538,6 @@ def init_server_extensions(self): # `_load_jupyter_server_extension` functions defined, hence # _get_server_extension_metadata returns a list. mod, metadata_list = _get_server_extension_metadata(module_name) - 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) except KeyError: # A KeyError suggests that the module does not have a # _jupyter_server_extension-path. @@ -1579,11 +1549,38 @@ def init_server_extensions(self): "is added.".format(module_name=module_name) ) self.log.warning(log_msg) - except: - if self.reraise_server_extension_failures: - raise - self.log.warning(_("Error loading server extension module: %s"), module_name, - exc_info=True) + + 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. diff --git a/tests/extension/test_entrypoint.py b/tests/extension/test_entrypoint.py index 6b55ec2778..49c436aa21 100644 --- a/tests/extension/test_entrypoint.py +++ b/tests/extension/test_entrypoint.py @@ -1,3 +1,4 @@ +import os import pytest @@ -6,7 +7,13 @@ 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 @@ -19,7 +26,8 @@ def test_server_extension_enable(environ, script_runner): "server", "extension", "enable", - extension_name + extension_name, + env=os.environ ) assert ret.success assert 'Enabling: {}'.format(extension_name) in ret.stderr @@ -34,7 +42,8 @@ def test_server_extension_disable(environ, script_runner): 'server', 'extension', 'disable', - extension_name + extension_name, + env=os.environ ) assert ret.success assert 'Disabling: {}'.format(extension_name) in ret.stderr From 3de641109d3803e6a84aebfaa8eaef94aadd1fbb Mon Sep 17 00:00:00 2001 From: Zsailer Date: Tue, 7 Apr 2020 14:07:36 -0700 Subject: [PATCH 29/33] minor clean up --- jupyter_server/extension/application.py | 4 ---- jupyter_server/extension/handler.py | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 262c750e9d..5ca38504f7 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -228,10 +228,6 @@ def _prepare_config(self): """Builds a Config object from the extension's traits and passes the object to the webapp's settings as `_config`. """ - # Make sure the ServerApp receives any config. - self.serverapp.update_config(self.config) - # Verify all traits are up-to-date with config - self.update_config(self.config) traits = self.class_own_traits().keys() self.extension_config = Config({t: getattr(self, t) for t in traits}) self.settings['{}_config'.format(self.extension_name)] = self.extension_config diff --git a/jupyter_server/extension/handler.py b/jupyter_server/extension/handler.py index b5aca4721f..c60c566fa0 100644 --- a/jupyter_server/extension/handler.py +++ b/jupyter_server/extension/handler.py @@ -27,8 +27,7 @@ def initialize(self, extension_name): @property def extensionapp(self): - key = "{extension_name}".format(extension_name=self.extension_name) - return self.settings[key] + return self.settings[self.extension_name] @property def serverapp(self): From 24deb30f914ada074191bdf396cd9be4fb30a9df Mon Sep 17 00:00:00 2001 From: Zsailer Date: Wed, 8 Apr 2020 20:02:34 -0700 Subject: [PATCH 30/33] minor fixes after review --- examples/simple/simple_ext2/application.py | 2 +- jupyter_server/extension/application.py | 8 ++++---- jupyter_server/serverapp.py | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/simple/simple_ext2/application.py b/examples/simple/simple_ext2/application.py index 9528ac6cac..7251da7900 100644 --- a/examples/simple/simple_ext2/application.py +++ b/examples/simple/simple_ext2/application.py @@ -12,7 +12,7 @@ class SimpleApp2(ExtensionAppJinjaMixin, ExtensionApp): extension_name = "simple_ext2" # Te url that your extension will serve its homepage. - extension_name = '/simple_ext2' + extension_url = '/simple_ext2' # Should your extension expose other server extensions when launched directly? load_other_extensions = True diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 5ca38504f7..d1284dd2e5 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -329,6 +329,8 @@ def link_to_serverapp(self, serverapp): settings. """ self.serverapp = serverapp + # 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 @@ -336,14 +338,12 @@ def link_to_serverapp(self, serverapp): # config. # ServerApp config ---> ExtensionApp traits self.update_config(self.serverapp.config) - # Load config from an ExtensionApp's config files. - # If any config should be passed upstream to the - # ServerApp, do it here. - self.load_config_file() # 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) diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 7687978835..9dd643e525 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1530,13 +1530,14 @@ def init_server_extensions(self): # 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. + # _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 From 65d659f4db1e35f5bd22256b19adfa323113014d Mon Sep 17 00:00:00 2001 From: Zsailer Date: Thu, 9 Apr 2020 12:56:10 -0700 Subject: [PATCH 31/33] add underscore as prefix to extension function --- jupyter_server/extension/application.py | 2 +- jupyter_server/serverapp.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index d1284dd2e5..bcec46657e 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -387,7 +387,7 @@ def stop(self): self.serverapp.clear_instance() @classmethod - def load_jupyter_server_extension(cls, serverapp): + 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. """ diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 9dd643e525..4efce6f256 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1544,7 +1544,7 @@ def init_server_extensions(self): # _jupyter_server_extension-path. log_msg = _( "Error loading server extensions in " - "{module_name} module. There is no `_jupyter_server_extension_path` " + "{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) From c66af6c7b5802d557a5f8574b41c31feaba36069 Mon Sep 17 00:00:00 2001 From: Zsailer Date: Fri, 10 Apr 2020 09:15:51 -0700 Subject: [PATCH 32/33] remove load_jupyter_server_extension from examples --- examples/simple/simple_ext1/__init__.py | 7 +++---- examples/simple/simple_ext11/__init__.py | 7 +++---- examples/simple/simple_ext2/__init__.py | 7 +++---- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/examples/simple/simple_ext1/__init__.py b/examples/simple/simple_ext1/__init__.py index 6970cfbc4b..4416f53792 100644 --- a/examples/simple/simple_ext1/__init__.py +++ b/examples/simple/simple_ext1/__init__.py @@ -1,9 +1,8 @@ from .application import SimpleApp1 + def _jupyter_server_extension_paths(): return [{ - 'module': 'simple_ext1', + 'module': 'simple_ext1.application', 'app': SimpleApp1 - }] - -load_jupyter_server_extension = SimpleApp1.load_jupyter_server_extension + }] \ No newline at end of file diff --git a/examples/simple/simple_ext11/__init__.py b/examples/simple/simple_ext11/__init__.py index cd664b7bca..c9f9bbe1eb 100644 --- a/examples/simple/simple_ext11/__init__.py +++ b/examples/simple/simple_ext11/__init__.py @@ -1,11 +1,10 @@ from .application import SimpleApp11 + def _jupyter_server_extension_paths(): return [ { - 'module': 'simple_ext11', + 'module': 'simple_ext11.application', 'app': SimpleApp11 } - ] - -load_jupyter_server_extension = SimpleApp11.load_jupyter_server_extension + ] \ No newline at end of file diff --git a/examples/simple/simple_ext2/__init__.py b/examples/simple/simple_ext2/__init__.py index 035b219c6a..8c47ed420f 100644 --- a/examples/simple/simple_ext2/__init__.py +++ b/examples/simple/simple_ext2/__init__.py @@ -1,11 +1,10 @@ from .application import SimpleApp2 + def _jupyter_server_extension_paths(): return [ { - 'module': 'simple_ext2', + 'module': 'simple_ext2.application', 'app': SimpleApp2 }, - ] - -load_jupyter_server_extension = SimpleApp2.load_jupyter_server_extension + ] \ No newline at end of file From 9ca9cb29a8020b71c6ac77bcb0ed2adc6755d22f Mon Sep 17 00:00:00 2001 From: Zsailer Date: Mon, 20 Apr 2020 09:19:19 -0700 Subject: [PATCH 33/33] minor typo in example comment --- examples/simple/simple_ext1/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/simple/simple_ext1/application.py b/examples/simple/simple_ext1/application.py index 980bde0d46..20a9aebc75 100644 --- a/examples/simple/simple_ext1/application.py +++ b/examples/simple/simple_ext1/application.py @@ -12,7 +12,7 @@ class SimpleApp1(ExtensionAppJinjaMixin, ExtensionApp): # The name of the extension. extension_name = "simple_ext1" - # Te url that your extension will serve its homepage. + # The url that your extension will serve its homepage. extension_url = '/simple_ext1/default' # Should your extension expose other server extensions when launched directly?