From 82eda48e4bbf193aff44f57230ff323d44856c8d Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 4 Nov 2024 10:58:21 +0000 Subject: [PATCH 1/2] extensions_manager: add extension_web_apps interface * Add an interface for listing extension applications that provide a default URL (i.e. extensions which provide a web application). * Add an endpoint for querying this interface. * Partially addresses #1414 by allowing Jupyter web applications to query for the existence of other Jupyter web applications. --- jupyter_server/base/handlers.py | 18 ++++++++++++++++++ jupyter_server/extension/manager.py | 22 ++++++++++++++++++++++ tests/extension/mockextensions/app.py | 1 + tests/extension/test_app.py | 14 ++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/jupyter_server/base/handlers.py b/jupyter_server/base/handlers.py index 770fff1866..872078de4b 100644 --- a/jupyter_server/base/handlers.py +++ b/jupyter_server/base/handlers.py @@ -1188,6 +1188,23 @@ def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None] return super().get(path, include_body) +class ExtensionAppsHandler(JupyterHandler): + """Return Jupyter Server extension web applications.""" + + @allow_unauthenticated + def get(self) -> None: + self.set_header("Content-Type", "application/json") + if self.serverapp: + self.finish( + json.dumps( + self.serverapp.extension_manager.extension_web_apps() + ) + ) + else: + # self.serverapp can be None + raise web.HTTPError(500, 'Server has not started correctly.') + + # ----------------------------------------------------------------------------- # URL pattern fragments for reuse # ----------------------------------------------------------------------------- @@ -1205,4 +1222,5 @@ def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None] (r"api", APIVersionHandler), (r"/(robots\.txt|favicon\.ico)", PublicStaticFileHandler), (r"/metrics", PrometheusMetricsHandler), + (r"/extensions", ExtensionAppsHandler), ] diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py index b8c52ca9e5..3bd440748e 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -4,6 +4,7 @@ import importlib from itertools import starmap +import re from tornado.gen import multi from traitlets import Any, Bool, Dict, HasTraits, Instance, List, Unicode, default, observe @@ -14,6 +15,9 @@ from .utils import ExtensionMetadataError, ExtensionModuleNotFound, get_loader, get_metadata +RE_SLASH = x = re.compile(r'/+') # match any number of slashes + + class ExtensionPoint(HasTraits): """A simple API for connecting to a Jupyter Server extension point defined by metadata and importable from a Python package. @@ -291,6 +295,24 @@ def extension_apps(self): for name, extension in self.extensions.items() } + @property + def extension_web_apps(self): + """Return Jupyter Server extension web applications. + + Some Jupyter Server extensions provide web applications + (e.g. Jupyter Lab), other's don't (e.g. Jupyter LSP). + + This returns a mapping of {extension_name: web_app_endpoint} for all + extensions which provide a default_url (i.e. a web application). + """ + return { + app.name: RE_SLASH.sub('/', f'{self.serverapp.base_url}/{app.default_url}') + for extension_apps in self.serverapp.extension_manager.extension_apps.values() + # filter out extensions that do not provide a default_url OR + # set it to the root endpoint. + for app in extension_apps if getattr(app, 'default_url', '/') != '/' + } + @property def extension_points(self): """Return mapping of extension point names and ExtensionPoint objects.""" diff --git a/tests/extension/mockextensions/app.py b/tests/extension/mockextensions/app.py index 26f38464cd..a221eb2f5c 100644 --- a/tests/extension/mockextensions/app.py +++ b/tests/extension/mockextensions/app.py @@ -50,6 +50,7 @@ class MockExtensionApp(ExtensionAppJinjaMixin, ExtensionApp): static_paths = [STATIC_PATH] # type:ignore[assignment] mock_trait = Unicode("mock trait", config=True) loaded = False + default_url = '/mockextension' serverapp_config = {"jpserver_extensions": {"tests.extension.mockextensions.mock1": True}} diff --git a/tests/extension/test_app.py b/tests/extension/test_app.py index d1add54344..e99b30d185 100644 --- a/tests/extension/test_app.py +++ b/tests/extension/test_app.py @@ -191,3 +191,17 @@ async def test_events(jp_serverapp, jp_fetch): stream.truncate(0) stream.seek(0) assert output["msg"] == "Hello, world!" + + +async def test_extension_web_apps(jp_serverapp): + jp_serverapp.extension_manager.load_all_extensions() + + # there should be (at least) two extension applications + assert set(jp_serverapp.extension_manager.extension_apps) == { + 'tests.extension.mockextensions', 'jupyter_server_terminals' + } + + # but only one extension web application + assert jp_serverapp.extension_manager.extension_web_apps == { + 'mockextension': '/a%40b/mockextension' + } From e073fce9330902fe036d9dbdfe54c7ce43617347 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 12:56:27 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- jupyter_server/base/handlers.py | 8 ++------ jupyter_server/extension/manager.py | 10 +++++----- tests/extension/mockextensions/app.py | 2 +- tests/extension/test_app.py | 5 +++-- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/jupyter_server/base/handlers.py b/jupyter_server/base/handlers.py index 872078de4b..2f9d0298b8 100644 --- a/jupyter_server/base/handlers.py +++ b/jupyter_server/base/handlers.py @@ -1195,14 +1195,10 @@ class ExtensionAppsHandler(JupyterHandler): def get(self) -> None: self.set_header("Content-Type", "application/json") if self.serverapp: - self.finish( - json.dumps( - self.serverapp.extension_manager.extension_web_apps() - ) - ) + self.finish(json.dumps(self.serverapp.extension_manager.extension_web_apps())) else: # self.serverapp can be None - raise web.HTTPError(500, 'Server has not started correctly.') + raise web.HTTPError(500, "Server has not started correctly.") # ----------------------------------------------------------------------------- diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py index 3bd440748e..f6c97ec469 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -3,8 +3,8 @@ from __future__ import annotations import importlib -from itertools import starmap import re +from itertools import starmap from tornado.gen import multi from traitlets import Any, Bool, Dict, HasTraits, Instance, List, Unicode, default, observe @@ -14,8 +14,7 @@ from .config import ExtensionConfigManager from .utils import ExtensionMetadataError, ExtensionModuleNotFound, get_loader, get_metadata - -RE_SLASH = x = re.compile(r'/+') # match any number of slashes +RE_SLASH = x = re.compile(r"/+") # match any number of slashes class ExtensionPoint(HasTraits): @@ -306,11 +305,12 @@ def extension_web_apps(self): extensions which provide a default_url (i.e. a web application). """ return { - app.name: RE_SLASH.sub('/', f'{self.serverapp.base_url}/{app.default_url}') + app.name: RE_SLASH.sub("/", f"{self.serverapp.base_url}/{app.default_url}") for extension_apps in self.serverapp.extension_manager.extension_apps.values() # filter out extensions that do not provide a default_url OR # set it to the root endpoint. - for app in extension_apps if getattr(app, 'default_url', '/') != '/' + for app in extension_apps + if getattr(app, "default_url", "/") != "/" } @property diff --git a/tests/extension/mockextensions/app.py b/tests/extension/mockextensions/app.py index a221eb2f5c..390a934552 100644 --- a/tests/extension/mockextensions/app.py +++ b/tests/extension/mockextensions/app.py @@ -50,7 +50,7 @@ class MockExtensionApp(ExtensionAppJinjaMixin, ExtensionApp): static_paths = [STATIC_PATH] # type:ignore[assignment] mock_trait = Unicode("mock trait", config=True) loaded = False - default_url = '/mockextension' + default_url = "/mockextension" serverapp_config = {"jpserver_extensions": {"tests.extension.mockextensions.mock1": True}} diff --git a/tests/extension/test_app.py b/tests/extension/test_app.py index e99b30d185..62f03af2ce 100644 --- a/tests/extension/test_app.py +++ b/tests/extension/test_app.py @@ -198,10 +198,11 @@ async def test_extension_web_apps(jp_serverapp): # there should be (at least) two extension applications assert set(jp_serverapp.extension_manager.extension_apps) == { - 'tests.extension.mockextensions', 'jupyter_server_terminals' + "tests.extension.mockextensions", + "jupyter_server_terminals", } # but only one extension web application assert jp_serverapp.extension_manager.extension_web_apps == { - 'mockextension': '/a%40b/mockextension' + "mockextension": "/a%40b/mockextension" }