From 166c61d6b6422be437eaf202c1962067a9f63df2 Mon Sep 17 00:00:00 2001 From: Zach Sailer Date: Thu, 25 Apr 2024 12:31:19 -0700 Subject: [PATCH] add start hook to basic extensions and include documentation --- docs/source/developers/extensions.rst | 66 +++++++++++++++++++++++++ jupyter_server/extension/application.py | 15 ++++-- jupyter_server/extension/manager.py | 62 ++++++++++++++++++----- tests/extension/mockextensions/app.py | 2 +- tests/extension/mockextensions/mock1.py | 7 +++ tests/extension/test_app.py | 4 ++ 6 files changed, 141 insertions(+), 15 deletions(-) diff --git a/docs/source/developers/extensions.rst b/docs/source/developers/extensions.rst index be454b26e6..363795faf4 100644 --- a/docs/source/developers/extensions.rst +++ b/docs/source/developers/extensions.rst @@ -65,6 +65,29 @@ Then add this handler to Jupyter Server's Web Application through the ``_load_ju serverapp.web_app.add_handlers(".*$", handlers) +Starting asynchronous tasks from an extension +--------------------------------------------- + +.. versionadded:: 2.15.0 + +Jupyter Server offers a simple API for starting asynchronous tasks from a server extension. This is useful for calling +async tasks after the event loop is running. + +The function should be named ``_start_jupyter_server_extension`` and found next to the ``_load_jupyter_server_extension`` function. + +Here is basic example: + +.. code-block:: python + + import asyncio + + async def _start_jupyter_server_extension(serverapp: jupyter_server.serverapp.ServerApp): + """ + This function is called after the server's event loop is running. + """ + await asyncio.sleep(.1) + + Making an extension discoverable -------------------------------- @@ -117,6 +140,7 @@ An ExtensionApp: - has an entrypoint, ``jupyter ``. - can serve static content from the ``/static//`` endpoint. - can add new endpoints to the Jupyter Server. + - can start asynchronous tasks after the server has started. The basic structure of an ExtensionApp is shown below: @@ -156,6 +180,11 @@ The basic structure of an ExtensionApp is shown below: ... # Change the jinja templating environment + async def _start_jupyter_server_extension(self): + ... + # Extend this method to start any (e.g. async) tasks + # after the main Server's Event Loop is running. + async def stop_extension(self): ... # Perform any required shut down steps @@ -171,6 +200,7 @@ Methods * ``initialize_settings()``: adds custom settings to the Tornado Web Application. * ``initialize_handlers()``: appends handlers to the Tornado Web Application. * ``initialize_templates()``: initialize the templating engine (e.g. jinja2) for your frontend. +* ``_start_jupyter_server_extension()``: enables the extension to start (async) tasks _after_ the server's main Event Loop has started. * ``stop_extension()``: called on server shut down. Properties @@ -320,6 +350,42 @@ pointing at the ``load_classic_server_extension`` method: If the extension is enabled, the extension will be loaded when the server starts. +Starting asynchronous tasks from an ExtensionApp +------------------------------------------------ + +.. versionadded:: 2.15.0 + + +An ``ExtensionApp`` can start asynchronous tasks after Jupyter Server's event loop is started by overriding its ``_start_jupyter_server_extension()`` method. This can be helpful for setting up e.g. background tasks. + +Here is a basic (pseudo) code example: + +.. code-block:: python + + import asyncio + import time + + + async def log_time_periodically(log, dt=1): + """Log the current time from a periodic loop.""" + while True: + current_time = time.time() + log.info(current_time) + await sleep(dt) + + + class MyExtension(ExtensionApp): + ... + + async def _start_jupyter_server_extension(self): + self.my_background_task = asyncio.create_task( + log_time_periodically(self.log) + ) + + async def stop_extension(self): + self.my_background_task.cancel() + + Distributing a server extension =============================== diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 73067c6659..698d920801 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -446,9 +446,6 @@ def start(self): assert self.serverapp is not None self.serverapp.start() - async def start_extension(self): - """An async hook to start e.g. tasks after the server's event loop is running.""" - def current_activity(self): """Return a list of activity happening in this extension.""" return @@ -478,6 +475,18 @@ def _load_jupyter_server_extension(cls, serverapp): extension.initialize() return extension + async def _start_jupyter_server_extension(self, serverapp): + """ + An async hook to start e.g. tasks from the extension after + the server's event loop is running. + + Override this method (no need to call `super()`) to + start (async) tasks from an extension. + + This is useful for starting e.g. background tasks from + an extension. + """ + @classmethod def load_classic_server_extension(cls, serverapp): """Enables extension to be loaded as classic Notebook (jupyter/notebook) extension.""" diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py index 4cdcb9d9a6..f51be19ed7 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -119,6 +119,20 @@ def _get_loader(self): loader = get_loader(loc) return loader + def _get_starter(self): + """Get a linker.""" + if self.app: + linker = self.app._start_jupyter_server_extension + else: + linker = getattr( + self.module, + # Search for a _start_jupyter_extension + "_start_jupyter_server_extension", + # Otherwise return a dummy function. + lambda serverapp: None, + ) + return linker + def validate(self): """Check that both a linker and loader exists.""" try: @@ -150,6 +164,13 @@ def load(self, serverapp): loader = self._get_loader() return loader(serverapp) + def start(self, serverapp): + """Call's the extensions 'start' hook where it can + start (possibly async) tasks _after_ the event loop is running. + """ + starter = self._get_starter() + return starter(serverapp) + class ExtensionPackage(LoggingConfigurable): """An API for interfacing with a Jupyter Server extension package. @@ -222,6 +243,11 @@ def load_point(self, point_name, serverapp): point = self.extension_points[point_name] return point.load(serverapp) + def start_point(self, point_name, serverapp): + """Load an extension point.""" + point = self.extension_points[point_name] + return point.start(serverapp) + def link_all_points(self, serverapp): """Link all extension points.""" for point_name in self.extension_points: @@ -231,9 +257,14 @@ def load_all_points(self, serverapp): """Load all extension points.""" return [self.load_point(point_name, serverapp) for point_name in self.extension_points] + async def start_all_points(self, serverapp): + """Load all extension points.""" + for point_name in self.extension_points: + await self.start_point(point_name, serverapp) + class ExtensionManager(LoggingConfigurable): - """High level interface for findind, validating, + """High level interface for finding, validating, linking, loading, and managing Jupyter Server extensions. Usage: @@ -367,15 +398,21 @@ def load_extension(self, name): else: self.log.info("%s | extension was successfully loaded.", name) - async def start_extension(self, name, apps): - """Call the start hooks in the specified apps.""" - for app in apps: - self.log.debug("%s | extension app %r starting", name, app.name) + async def start_extension(self, name): + """Start an extension by name.""" + extension = self.extensions.get(name) + + if extension and extension.enabled: try: - await app.start_extension() - self.log.debug("%s | extension app %r started", name, app.name) - except Exception as err: - self.log.error(err) + await extension.start_all_points(self.serverapp) + except Exception as e: + if self.serverapp and self.serverapp.reraise_server_extension_failures: + raise + self.log.warning( + "%s | extension failed starting with message: %r", name, e, exc_info=True + ) + else: + self.log.info("%s | extension was successfully started.", name) async def stop_extension(self, name, apps): """Call the shutdown hooks in the specified apps.""" @@ -403,8 +440,11 @@ def load_all_extensions(self): self.load_extension(name) async def start_all_extensions(self): - """Call the start hooks in all extensions.""" - await multi(list(starmap(self.start_extension, sorted(dict(self.extension_apps).items())))) + """Start all enabled extensions.""" + # Sort the extension names to enforce deterministic loading + # order. + for name in self.sorted_extensions: + await self.start_extension(name) async def stop_all_extensions(self): """Call the shutdown hooks in all extensions.""" diff --git a/tests/extension/mockextensions/app.py b/tests/extension/mockextensions/app.py index 52248d8dc7..e71824fc90 100644 --- a/tests/extension/mockextensions/app.py +++ b/tests/extension/mockextensions/app.py @@ -72,7 +72,7 @@ def initialize_handlers(self): self.handlers.append(("/mock_template", MockExtensionTemplateHandler)) self.loaded = True - async def start_extension(self): + async def _start_jupyter_server_extension(self, serverapp): self.started = True diff --git a/tests/extension/mockextensions/mock1.py b/tests/extension/mockextensions/mock1.py index 6113ab2fc5..bd73cdfa6a 100644 --- a/tests/extension/mockextensions/mock1.py +++ b/tests/extension/mockextensions/mock1.py @@ -1,5 +1,7 @@ """A mock extension named `mock1` for testing purposes.""" + # by the test functions. +import asyncio def _jupyter_server_extension_paths(): @@ -9,3 +11,8 @@ def _jupyter_server_extension_paths(): def _load_jupyter_server_extension(serverapp): serverapp.mockI = True serverapp.mock_shared = "I" + + +async def _start_jupyter_server_extension(serverapp): + await asyncio.sleep(0.1) + serverapp.mock1_started = True diff --git a/tests/extension/test_app.py b/tests/extension/test_app.py index 148eda2336..95aa14ff15 100644 --- a/tests/extension/test_app.py +++ b/tests/extension/test_app.py @@ -143,6 +143,10 @@ async def test_load_parallel_extensions(monkeypatch, jp_environ): async def test_start_extension(jp_serverapp, mock_extension): await jp_serverapp._post_start() assert mock_extension.started + assert hasattr( + jp_serverapp, "mock1_started" + ), "Failed because the `_start_jupyter_server_extension` function in 'mock1.py' was never called" + assert jp_serverapp.mock1_started async def test_stop_extension(jp_serverapp, caplog):