diff --git a/jupyter_server/base/handlers.py b/jupyter_server/base/handlers.py index 770fff1866..2f9d0298b8 100644 --- a/jupyter_server/base/handlers.py +++ b/jupyter_server/base/handlers.py @@ -1188,6 +1188,19 @@ 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 +1218,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..f6c97ec469 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib +import re from itertools import starmap from tornado.gen import multi @@ -13,6 +14,8 @@ from .config import ExtensionConfigManager 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 @@ -291,6 +294,25 @@ 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..390a934552 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..62f03af2ce 100644 --- a/tests/extension/test_app.py +++ b/tests/extension/test_app.py @@ -191,3 +191,18 @@ 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" + }