diff --git a/docs/source/developers/extensions.rst b/docs/source/developers/extensions.rst index 2a9d1e4064..dba73dea59 100644 --- a/docs/source/developers/extensions.rst +++ b/docs/source/developers/extensions.rst @@ -128,7 +128,7 @@ The basic structure of an ExtensionApp is shown below: # -------------- Required traits -------------- name = "myextension" - extension_url = "/myextension" + default_url = "/myextension" load_other_extensions = True # --- ExtensionApp traits you can configure --- @@ -167,7 +167,7 @@ Methods Properties * ``name``: the name of the extension -* ``extension_url``: the default url for this extension—i.e. the landing page for this extension when launched from the CLI. +* ``default_url``: the default url for this extension—i.e. the landing page for this extension when launched from the CLI. * ``load_other_extensions``: a boolean enabling/disabling other extensions when launching this extension directly. ``ExtensionApp`` request handlers @@ -302,13 +302,13 @@ To make your extension executable from anywhere on your system, point an entry-p ``ExtensionApp`` as a classic Notebook server extension ------------------------------------------------------- -An extension that extends ``ExtensionApp`` should still work with the old Tornado server from the classic Jupyter Notebook. The ``ExtensionApp`` class +An extension that extends ``ExtensionApp`` should still work with the old Tornado server from the classic Jupyter Notebook. The ``ExtensionApp`` class provides a method, ``load_classic_server_extension``, that handles the extension initialization. Simply define a ``load_jupyter_server_extension`` reference -pointing at the ``load_classic_server_extension`` method: +pointing at the ``load_classic_server_extension`` method: .. code-block:: python - # This is typically defined in the root `__init__.py` + # This is typically defined in the root `__init__.py` # file of the extension package. load_jupyter_server_extension = MyExtensionApp.load_classic_server_extension @@ -483,7 +483,7 @@ There are a few key steps to make this happen: .. code-block:: python def load_jupyter_server_extension(nb_server_app): - + web_app = nb_server_app.web_app host_pattern = '.*$' base_url = web_app.settings['base_url'] @@ -495,50 +495,50 @@ There are a few key steps to make this happen: # Favicon redirects. favicon_redirects = [ - ( - url_path_join(base_url, "/static/favicons/favicon.ico"), + ( + url_path_join(base_url, "/static/favicons/favicon.ico"), RedirectHandler, {"url": url_path_join(serverapp.base_url, "static/base/images/favicon.ico") ), ( - url_path_join(base_url, "/static/favicons/favicon-busy-1.ico"), + url_path_join(base_url, "/static/favicons/favicon-busy-1.ico"), RedirectHandler, {"url": url_path_join(serverapp.base_url, "static/base/images/favicon-busy-1.ico")} ), ( - url_path_join(base_url, "/static/favicons/favicon-busy-2.ico"), + url_path_join(base_url, "/static/favicons/favicon-busy-2.ico"), RedirectHandler, {"url": url_path_join(serverapp.base_url, "static/base/images/favicon-busy-2.ico")} ), ( - url_path_join(base_url, "/static/favicons/favicon-busy-3.ico"), + url_path_join(base_url, "/static/favicons/favicon-busy-3.ico"), RedirectHandler, {"url": url_path_join(serverapp.base_url, "static/base/images/favicon-busy-3.ico")} ), ( - url_path_join(base_url, "/static/favicons/favicon-file.ico"), + url_path_join(base_url, "/static/favicons/favicon-file.ico"), RedirectHandler, {"url": url_path_join(serverapp.base_url, "static/base/images/favicon-file.ico")} ), ( - url_path_join(base_url, "/static/favicons/favicon-notebook.ico"), + url_path_join(base_url, "/static/favicons/favicon-notebook.ico"), RedirectHandler, {"url": url_path_join(serverapp.base_url, "static/base/images/favicon-notebook.ico")} ), ( - url_path_join(base_url, "/static/favicons/favicon-terminal.ico"), + url_path_join(base_url, "/static/favicons/favicon-terminal.ico"), RedirectHandler, {"url": url_path_join(serverapp.base_url, "static/base/images/favicon-terminal.ico")} ), ( - url_path_join(base_url, "/static/logo/logo.png"), + url_path_join(base_url, "/static/logo/logo.png"), RedirectHandler, {"url": url_path_join(serverapp.base_url, "static/base/images/logo.png")} ), ] web_app.add_handlers( - host_pattern, + host_pattern, custom_handlers + favicon_redirects ) diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 21294e59eb..34906e2faf 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -4,6 +4,7 @@ from jinja2 import Environment, FileSystemLoader +from traitlets.config import Config from traitlets import ( HasTraits, Unicode, @@ -12,7 +13,6 @@ Bool, default ) -from traitlets.config import Config from tornado.log import LogFormatter from tornado.web import RedirectHandler @@ -186,6 +186,9 @@ def get_extension_point(cls): def _default_url(self): return self.extension_url + # Is this linked to a serverapp yet? + _linked = Bool(False) + # Extension can configure the ServerApp from the command-line classes = [ ServerApp, @@ -196,9 +199,6 @@ def _default_url(self): _log_formatter_cls = LogFormatter - # Whether this app is the starter app - _is_starter_app = False - @default('log_level') def _default_log_level(self): return logging.INFO @@ -333,14 +333,14 @@ def _prepare_templates(self): }) self.initialize_templates() - @classmethod - def _jupyter_server_config(cls): + def _jupyter_server_config(self): base_config = { "ServerApp": { - "jpserver_extensions": {cls.get_extension_package(): True}, + "default_url": self.default_url, + "open_browser": self.open_browser } } - base_config["ServerApp"].update(cls.serverapp_config) + base_config["ServerApp"].update(self.serverapp_config) return base_config def _link_jupyter_server_extension(self, serverapp): @@ -351,6 +351,10 @@ def _link_jupyter_server_extension(self, serverapp): the command line contains traits for the ExtensionApp or the ExtensionApp's config files have server settings. + + Note, the ServerApp has not initialized the Tornado + Web Application yet, so do not try to affect the + `web_app` attribute. """ self.serverapp = serverapp # Load config from an ExtensionApp's config files. @@ -370,23 +374,8 @@ def _link_jupyter_server_extension(self, serverapp): # ServerApp, do it here. # i.e. ServerApp traits <--- ExtensionApp config self.serverapp.update_config(self.config) - - @classmethod - def initialize_server(cls, argv=[], load_other_extensions=True, **kwargs): - """Creates an instance of ServerApp where this extension is enabled - (superceding disabling found in other config from files). - - This is necessary when launching the ExtensionApp directly from - the `launch_instance` classmethod. - """ - # The ExtensionApp needs to add itself as enabled extension - # to the jpserver_extensions trait, so that the ServerApp - # initializes it. - config = Config(cls._jupyter_server_config()) - serverapp = ServerApp.instance(**kwargs, argv=[], config=config) - cls._is_starter_app = True - serverapp.initialize(argv=argv, find_extensions=load_other_extensions) - return serverapp + # Acknowledge that this extension has been linked. + self._linked = True def initialize(self): """Initialize the extension app. The @@ -440,12 +429,7 @@ def _load_jupyter_server_extension(cls, serverapp): except KeyError: extension = cls() extension._link_jupyter_server_extension(serverapp) - if cls._is_starter_app: - serverapp._starter_app = extension extension.initialize() - # Set the serverapp's default url to the extension's url. - if cls._is_starter_app: - serverapp.default_url = extension.default_url return extension @classmethod @@ -478,6 +462,24 @@ def load_classic_server_extension(cls, serverapp): ]) extension.initialize() + @classmethod + def initialize_server(cls, argv=[], load_other_extensions=True, **kwargs): + """Creates an instance of ServerApp and explicitly sets + this extension to enabled=True (i.e. superceding disabling + found in other config from files). + + The `launch_instance` method uses this method to initialize + and start a server. + """ + serverapp = ServerApp.instance( + jpserver_extensions={cls.get_extension_package(): True}, **kwargs) + serverapp.initialize( + argv=argv, + starter_extension=cls.name, + find_extensions=cls.load_other_extensions, + ) + return serverapp + @classmethod def launch_instance(cls, argv=None, **kwargs): """Launch the extension like an application. Initializes+configs a stock server @@ -489,27 +491,29 @@ def launch_instance(cls, argv=None, **kwargs): args = sys.argv[1:] # slice out extension config. else: args = argv - # Check for subcommands + + # Handle all "stops" that could happen before + # continuing to launch a server+extension. subapp = _preparse_for_subcommand(cls, args) if subapp: subapp.start() - else: - # 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) - # Get a jupyter server instance. - serverapp = cls.initialize_server( - argv=args, - load_other_extensions=cls.load_other_extensions + return + + # 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) + + serverapp = cls.initialize_server(argv=args) + + # Log if extension is blocking other extensions from loading. + if not cls.load_other_extensions: + serverapp.log.info( + "{ext_name} is running without loading " + "other extensions.".format(ext_name=cls.name) ) - # Log if extension is blocking other extensions from loading. - if not cls.load_other_extensions: - serverapp.log.info( - "{ext_name} is running without loading " - "other extensions.".format(ext_name=cls.name) - ) - try: - serverapp.start() - except NoStart: - pass + # Start the server. + try: + serverapp.start() + except NoStart: + pass \ No newline at end of file diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py index aab4c594f4..e28f7e1747 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -1,11 +1,13 @@ import importlib -from traitlets.config import LoggingConfigurable +from traitlets.config import LoggingConfigurable, Config + from traitlets import ( HasTraits, Dict, Unicode, Bool, + Any, validate ) @@ -21,12 +23,10 @@ class ExtensionPoint(HasTraits): """A simple API for connecting to a Jupyter Server extension point defined by metadata and importable from a Python package. """ - metadata = Dict() + _linked = Bool(False) + _app = Any(None, allow_none=True) - def __init__(self, *args, **kwargs): - # Store extension points that have been linked. - self._app = None - super().__init__(*args, **kwargs) + metadata = Dict() @validate('metadata') def _valid_metadata(self, proposed): @@ -54,6 +54,13 @@ def _valid_metadata(self, proposed): @property def linked(self): + """Has this extension point been linked to the server. + + Will pull from ExtensionApp's trait, if this point + is an instance of ExtensionApp. + """ + if self.app: + return self.app._linked return self._linked @property @@ -61,6 +68,16 @@ def app(self): """If the metadata includes an `app` field""" return self._app + @property + def config(self): + """Return any configuration provided by this extension point.""" + if self.app: + return self.app._jupyter_server_config() + # At some point, we might want to add logic to load config from + # disk when extensions don't use ExtensionApp. + else: + return {} + @property def module_name(self): """Name of the Python package module where the extension's @@ -119,8 +136,11 @@ def link(self, serverapp): This looks for a `_link_jupyter_server_extension` function in the extension's module or ExtensionApp class. """ - linker = self._get_linker() - return linker(serverapp) + if not self.linked: + linker = self._get_linker() + linker(serverapp) + # Store this extension as already linked. + self._linked = True def load(self, serverapp): """Load the extension in a Jupyter ServerApp object. diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 10c284974b..f09c4d22a8 100755 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -956,25 +956,6 @@ def _default_allow_remote(self): """ ) - # The name of the app that started this server (if not started directly). - # It is sometimes important to know if + which another app (say a server extension) - # started the serverapp to properly configure some traits. - # This trait should not be configured by users. It will likely be set by ExtensionApp. - _starter_app = Instance(JupyterApp, allow_none=True) - - @validate('_starter_app') - def _validate_starter_app(self, proposal): - # Check that a previous server extension isn't named yet - value = proposal["value"] - if self._starter_app != None: - raise TraitError("Another extension was already named as the starter_server_extension.") - return value - - @property - def starter_app(self): - """Get the Extension that started this server.""" - return self._starter_app - open_browser = Bool(False, config=True, help="""Whether to open in a browser after starting. The specific browser used is platform dependent and @@ -983,31 +964,6 @@ def starter_app(self): (ServerApp.browser) configuration option. """) - - def _handle_browser_opening(self): - """This method handles whether a browser should be opened. - By default, Jupyter Server doesn't try to open an browser. However, - it's many server extensions might want to open the browser by default. - This essentially toggles the default value for open_browser. - - From a UX perspective, this needs to be surfaced to the user. The default - behavior of Jupyter Server switches, which can be confusing. - """ - # If the server was started by another application, use that applications - # trait for the open_browser trait. If that trait is not given, ignore - if self.starter_app: - try: - if self.starter_app.open_browser: - self.launch_browser() - # If the starter_app doesn't have an open_browser trait, ignore - # move on and don't start a browser. - except AttributeError: - pass - else: - if self.open_browser: - self.launch_browser() - - browser = Unicode(u'', config=True, help="""Specify what command to use to invoke a web browser when starting the server. If not specified, the @@ -1351,6 +1307,17 @@ def _update_server_extensions(self, change): config=True ) + _starter_app = Instance( + default_value=None, + allow_none=True, + klass='jupyter_server.extension.application.ExtensionApp' + ) + + @property + def starter_app(self): + """Get the Extension that started this server.""" + return self._starter_app + def parse_command_line(self, argv=None): super(ServerApp, self).parse_command_line(argv) @@ -1781,7 +1748,7 @@ def _init_asyncio_patch(): ) @catch_config_error - def initialize(self, argv=None, find_extensions=True, new_httpserver=True): + def initialize(self, argv=None, find_extensions=True, new_httpserver=True, starter_extension=None): """Initialize the Server application class, configurables, web application, and http server. Parameters @@ -1797,11 +1764,14 @@ def initialize(self, argv=None, find_extensions=True, new_httpserver=True): new_httpserver: bool If True, a tornado HTTPServer instance will be created and configured for the Server Web Application. This will set the http_server attribute of this class. + + starter_extension: str + If given, it references the name of an extension point that started the Server. + We will try to load configuration from extension point """ # Parse command line, load ServerApp config files, # and update ServerApp config. - super(ServerApp, self).initialize(argv) - # Initialize all components of the ServerApp. + super(ServerApp, self).initialize(argv=argv) if self._dispatching: return # Then, use extensions' config loading mechanism to @@ -1810,6 +1780,19 @@ def initialize(self, argv=None, find_extensions=True, new_httpserver=True): self.find_server_extensions() self.init_logging() self.init_server_extensions() + + # Special case the starter extension and load + # any server configuration is provides. + if starter_extension: + # Configure ServerApp based on named extension. + point = self.extension_manager.extension_points[starter_extension] + # Set starter_app property. + if point.app: + self._starter_app = point.app + # Load any configuration that comes from the Extension point. + self.update_config(Config(point.config)) + + # Initialize other pieces of the server. self.init_resources() self.init_configurables() self.init_components() @@ -1979,7 +1962,8 @@ def start_app(self): self.write_browser_open_file() # Handle the browser opening. - self._handle_browser_opening() + if self.open_browser: + self.launch_browser() if self.token and self._token_generated: # log full URL with generated token, so there's a copy/pasteable link diff --git a/jupyter_server/utils.py b/jupyter_server/utils.py index f083476e6f..26b6d61754 100644 --- a/jupyter_server/utils.py +++ b/jupyter_server/utils.py @@ -225,4 +225,4 @@ def wrapped(): # just return a Future, hoping that it will be awaited result = asyncio.ensure_future(maybe_async) return result - return wrapped() + return wrapped() \ No newline at end of file diff --git a/tests/extension/mockextensions/app.py b/tests/extension/mockextensions/app.py index ac6706a6dc..8c422ae3c6 100644 --- a/tests/extension/mockextensions/app.py +++ b/tests/extension/mockextensions/app.py @@ -13,6 +13,15 @@ STATIC_PATH = os.path.join(os.path.dirname(__file__), "static") +# Function that makes these extensions discoverable +# by the test functions. +def _jupyter_server_extension_points(): + return [ + { + 'module': __name__, + 'app': MockExtensionApp + } + ] class MockExtensionHandler(ExtensionHandlerMixin, JupyterHandler): @@ -41,4 +50,8 @@ class MockExtensionApp(ExtensionAppJinjaMixin, ExtensionApp): def initialize_handlers(self): self.handlers.append(('/mock', MockExtensionHandler)) self.handlers.append(('/mock_template', MockExtensionTemplateHandler)) - self.loaded = True \ No newline at end of file + self.loaded = True + + +if __name__ == "__main__": + MockExtensionApp.launch_instance() diff --git a/tests/extension/test_launch.py b/tests/extension/test_launch.py new file mode 100644 index 0000000000..55deb2ac11 --- /dev/null +++ b/tests/extension/test_launch.py @@ -0,0 +1,95 @@ +"""Test launching Jupyter Server Applications +through as ExtensionApp launch_instance. +""" +import os +import sys +import time +import pytest +import subprocess +import requests +from binascii import hexlify + + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture +def port(): + return 9999 + + +@pytest.fixture +def token(): + return hexlify(os.urandom(4)).decode("ascii") + + +@pytest.fixture +def auth_header(token): + return { + 'Authorization': 'token %s' % token + } + + +def wait_up(url, interval=0.1, check=None): + while True: + try: + r = requests.get(url) + except Exception: + if check: + assert check() + #print("waiting for %s" % url) + time.sleep(interval) + else: + break + + +@pytest.fixture +def launch_instance(request, port, token): + def _run_in_subprocess(argv=[]): + + def _kill_extension_app(): + try: + process.terminate() + except OSError: + # Already dead. + pass + process.wait(10) + + process = subprocess.Popen([ + sys.executable, '-m', + 'mockextensions.app', + f'--port={port}', + '--ip=127.0.0.1', + '--no-browser', + f'--ServerApp.token={token}', + *argv, + ], cwd=HERE) + + request.addfinalizer(_kill_extension_app) + url = f'http://127.0.0.1:{port}' + wait_up(url, check=lambda: process.poll() is None) + return process + + return _run_in_subprocess + + +@pytest.fixture +def fetch(port, auth_header): + def _get(endpoint): + url = f"http://127.0.0.1:{port}" + endpoint + return requests.get(url, headers=auth_header) + return _get + + +def test_launch_instance(launch_instance, fetch): + launch_instance() + r = fetch('/mock') + assert r.status_code == 200 + + +def test_base_url(launch_instance, fetch): + launch_instance(['--ServerApp.base_url=/foo']) + r = fetch("/foo/mock") + assert r.status_code == 200 + +