From 82eda48e4bbf193aff44f57230ff323d44856c8d Mon Sep 17 00:00:00 2001
From: Oliver Sanders <oliver.sanders@metoffice.gov.uk>
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"
     }