Skip to content

Commit c67a46b

Browse files
ZsailerDarshan808
andauthored
Add async start hook to ExtensionApp API (#1417)
Co-authored-by: Darshan808 <[email protected]>
1 parent 641e8fc commit c67a46b

File tree

9 files changed

+185
-4
lines changed

9 files changed

+185
-4
lines changed

docs/source/developers/extensions.rst

+76
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,33 @@ Then add this handler to Jupyter Server's Web Application through the ``_load_ju
6565
serverapp.web_app.add_handlers(".*$", handlers)
6666
6767
68+
Starting asynchronous tasks from an extension
69+
---------------------------------------------
70+
71+
.. versionadded:: 2.15.0
72+
73+
Jupyter Server offers a simple API for starting asynchronous tasks from a server extension. This is useful for calling
74+
async tasks after the event loop is running.
75+
76+
The function should be named ``_start_jupyter_server_extension`` and found next to the ``_load_jupyter_server_extension`` function.
77+
78+
Here is basic example:
79+
80+
.. code-block:: python
81+
82+
import asyncio
83+
84+
async def _start_jupyter_server_extension(serverapp: jupyter_server.serverapp.ServerApp):
85+
"""
86+
This function is called after the server's event loop is running.
87+
"""
88+
await asyncio.sleep(.1)
89+
90+
.. note:: The server startup banner (displaying server info and access URLs) is printed before starting asynchronous tasks, so those tasks might still be running even after the banner appears.
91+
92+
.. WARNING: This note is also present in the "Starting asynchronous tasks from an ExtensionApp" section.
93+
If you update it here, please update it there as well.
94+
6895
Making an extension discoverable
6996
--------------------------------
7097

@@ -117,6 +144,7 @@ An ExtensionApp:
117144
- has an entrypoint, ``jupyter <name>``.
118145
- can serve static content from the ``/static/<name>/`` endpoint.
119146
- can add new endpoints to the Jupyter Server.
147+
- can start asynchronous tasks after the server has started.
120148

121149
The basic structure of an ExtensionApp is shown below:
122150

@@ -156,6 +184,11 @@ The basic structure of an ExtensionApp is shown below:
156184
...
157185
# Change the jinja templating environment
158186
187+
async def _start_jupyter_server_extension(self):
188+
...
189+
# Extend this method to start any (e.g. async) tasks
190+
# after the main Server's Event Loop is running.
191+
159192
async def stop_extension(self):
160193
...
161194
# Perform any required shut down steps
@@ -171,6 +204,7 @@ Methods
171204
* ``initialize_settings()``: adds custom settings to the Tornado Web Application.
172205
* ``initialize_handlers()``: appends handlers to the Tornado Web Application.
173206
* ``initialize_templates()``: initialize the templating engine (e.g. jinja2) for your frontend.
207+
* ``_start_jupyter_server_extension()``: enables the extension to start (async) tasks _after_ the server's main Event Loop has started.
174208
* ``stop_extension()``: called on server shut down.
175209

176210
Properties
@@ -320,6 +354,48 @@ pointing at the ``load_classic_server_extension`` method:
320354
If the extension is enabled, the extension will be loaded when the server starts.
321355

322356

357+
Starting asynchronous tasks from an ExtensionApp
358+
------------------------------------------------
359+
360+
.. versionadded:: 2.15.0
361+
362+
363+
An ``ExtensionApp`` can start asynchronous tasks after Jupyter Server's event loop is started by overriding its
364+
``_start_jupyter_server_extension()`` method. This can be helpful for setting up e.g. background tasks.
365+
366+
Here is a basic (pseudo) code example:
367+
368+
.. code-block:: python
369+
370+
import asyncio
371+
import time
372+
373+
374+
async def log_time_periodically(log, dt=1):
375+
"""Log the current time from a periodic loop."""
376+
while True:
377+
current_time = time.time()
378+
log.info(current_time)
379+
await sleep(dt)
380+
381+
382+
class MyExtension(ExtensionApp):
383+
...
384+
385+
async def _start_jupyter_server_extension(self):
386+
self.my_background_task = asyncio.create_task(
387+
log_time_periodically(self.log)
388+
)
389+
390+
async def stop_extension(self):
391+
self.my_background_task.cancel()
392+
393+
.. note:: The server startup banner (displaying server info and access URLs) is printed before starting asynchronous tasks, so those tasks might still be running even after the banner appears.
394+
395+
.. WARNING: This note is also present in the "Starting asynchronous tasks from an extension" section.
396+
If you update it here, please update it there as well.
397+
398+
323399
Distributing a server extension
324400
===============================
325401

examples/simple/pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66
name = "jupyter-server-example"
77
description = "Jupyter Server Example"
88
readme = "README.md"
9-
license = "MIT"
9+
license = "BSD-3-Clause"
1010
requires-python = ">=3.9"
1111
dependencies = [
1212
"jinja2",

jupyter_server/extension/application.py

+12
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,18 @@ def _load_jupyter_server_extension(cls, serverapp):
475475
extension.initialize()
476476
return extension
477477

478+
async def _start_jupyter_server_extension(self, serverapp):
479+
"""
480+
An async hook to start e.g. tasks from the extension after
481+
the server's event loop is running.
482+
483+
Override this method (no need to call `super()`) to
484+
start (async) tasks from an extension.
485+
486+
This is useful for starting e.g. background tasks from
487+
an extension.
488+
"""
489+
478490
@classmethod
479491
def load_classic_server_extension(cls, serverapp):
480492
"""Enables extension to be loaded as classic Notebook (jupyter/notebook) extension."""

jupyter_server/extension/manager.py

+58-1
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ def _get_loader(self):
119119
loader = get_loader(loc)
120120
return loader
121121

122+
def _get_starter(self):
123+
"""Get a starter function."""
124+
if self.app:
125+
linker = self.app._start_jupyter_server_extension
126+
else:
127+
128+
async def _noop_start(serverapp):
129+
return
130+
131+
linker = getattr(
132+
self.module,
133+
# Search for a _start_jupyter_extension
134+
"_start_jupyter_server_extension",
135+
# Otherwise return a no-op function.
136+
_noop_start,
137+
)
138+
return linker
139+
122140
def validate(self):
123141
"""Check that both a linker and loader exists."""
124142
try:
@@ -150,6 +168,13 @@ def load(self, serverapp):
150168
loader = self._get_loader()
151169
return loader(serverapp)
152170

171+
async def start(self, serverapp):
172+
"""Call's the extensions 'start' hook where it can
173+
start (possibly async) tasks _after_ the event loop is running.
174+
"""
175+
starter = self._get_starter()
176+
return await starter(serverapp)
177+
153178

154179
class ExtensionPackage(LoggingConfigurable):
155180
"""An API for interfacing with a Jupyter Server extension package.
@@ -222,6 +247,11 @@ def load_point(self, point_name, serverapp):
222247
point = self.extension_points[point_name]
223248
return point.load(serverapp)
224249

250+
async def start_point(self, point_name, serverapp):
251+
"""Load an extension point."""
252+
point = self.extension_points[point_name]
253+
return await point.start(serverapp)
254+
225255
def link_all_points(self, serverapp):
226256
"""Link all extension points."""
227257
for point_name in self.extension_points:
@@ -231,9 +261,14 @@ def load_all_points(self, serverapp):
231261
"""Load all extension points."""
232262
return [self.load_point(point_name, serverapp) for point_name in self.extension_points]
233263

264+
async def start_all_points(self, serverapp):
265+
"""Load all extension points."""
266+
for point_name in self.extension_points:
267+
await self.start_point(point_name, serverapp)
268+
234269

235270
class ExtensionManager(LoggingConfigurable):
236-
"""High level interface for findind, validating,
271+
"""High level interface for finding, validating,
237272
linking, loading, and managing Jupyter Server extensions.
238273
239274
Usage:
@@ -367,6 +402,22 @@ def load_extension(self, name):
367402
else:
368403
self.log.info("%s | extension was successfully loaded.", name)
369404

405+
async def start_extension(self, name):
406+
"""Start an extension by name."""
407+
extension = self.extensions.get(name)
408+
409+
if extension and extension.enabled:
410+
try:
411+
await extension.start_all_points(self.serverapp)
412+
except Exception as e:
413+
if self.serverapp and self.serverapp.reraise_server_extension_failures:
414+
raise
415+
self.log.warning(
416+
"%s | extension failed starting with message: %r", name, e, exc_info=True
417+
)
418+
else:
419+
self.log.debug("%s | extension was successfully started.", name)
420+
370421
async def stop_extension(self, name, apps):
371422
"""Call the shutdown hooks in the specified apps."""
372423
for app in apps:
@@ -392,6 +443,12 @@ def load_all_extensions(self):
392443
for name in self.sorted_extensions:
393444
self.load_extension(name)
394445

446+
async def start_all_extensions(self):
447+
"""Start all enabled extensions."""
448+
# Sort the extension names to enforce deterministic loading
449+
# order.
450+
await multi([self.start_extension(name) for name in self.sorted_extensions])
451+
395452
async def stop_all_extensions(self):
396453
"""Call the shutdown hooks in all extensions."""
397454
await multi(list(starmap(self.stop_extension, sorted(dict(self.extension_apps).items()))))

jupyter_server/serverapp.py

+12
Original file line numberDiff line numberDiff line change
@@ -3164,6 +3164,7 @@ def start_ioloop(self) -> None:
31643164
pc = ioloop.PeriodicCallback(lambda: None, 5000)
31653165
pc.start()
31663166
try:
3167+
self.io_loop.add_callback(self._post_start)
31673168
self.io_loop.start()
31683169
except KeyboardInterrupt:
31693170
self.log.info(_i18n("Interrupted..."))
@@ -3172,6 +3173,17 @@ def init_ioloop(self) -> None:
31723173
"""init self.io_loop so that an extension can use it by io_loop.call_later() to create background tasks"""
31733174
self.io_loop = ioloop.IOLoop.current()
31743175

3176+
async def _post_start(self):
3177+
"""Add an async hook to start tasks after the event loop is running.
3178+
3179+
This will also attempt to start all tasks found in
3180+
the `start_extension` method in Extension Apps.
3181+
"""
3182+
try:
3183+
await self.extension_manager.start_all_extensions()
3184+
except Exception as err:
3185+
self.log.error(err)
3186+
31753187
def start(self) -> None:
31763188
"""Start the Jupyter server app, after initialization
31773189

tests/extension/mockextensions/app.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from jupyter_events import EventLogger
66
from jupyter_events.schema_registry import SchemaRegistryException
77
from tornado import web
8-
from traitlets import List, Unicode
8+
from traitlets import Bool, List, Unicode
99

1010
from jupyter_server.base.handlers import JupyterHandler
1111
from jupyter_server.extension.application import ExtensionApp, ExtensionAppJinjaMixin
@@ -56,6 +56,7 @@ class MockExtensionApp(ExtensionAppJinjaMixin, ExtensionApp):
5656
static_paths = [STATIC_PATH] # type:ignore[assignment]
5757
mock_trait = Unicode("mock trait", config=True)
5858
loaded = False
59+
started = Bool(False)
5960

6061
serverapp_config = {
6162
"jpserver_extensions": {
@@ -64,6 +65,9 @@ class MockExtensionApp(ExtensionAppJinjaMixin, ExtensionApp):
6465
}
6566
}
6667

68+
async def _start_jupyter_server_extension(self, serverapp):
69+
self.started = True
70+
6771
@staticmethod
6872
def get_extension_package():
6973
return "tests.extension.mockextensions"
@@ -96,6 +100,9 @@ def initialize_handlers(self):
96100
self.handlers.append(("/mock_error_notemplate", MockExtensionErrorHandler))
97101
self.loaded = True
98102

103+
async def _start_jupyter_server_extension(self, serverapp):
104+
self.started = True
105+
99106

100107
if __name__ == "__main__":
101108
MockExtensionApp.launch_instance()

tests/extension/mockextensions/mock1.py

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""A mock extension named `mock1` for testing purposes."""
2+
23
# by the test functions.
4+
import asyncio
35

46

57
def _jupyter_server_extension_paths():
@@ -9,3 +11,8 @@ def _jupyter_server_extension_paths():
911
def _load_jupyter_server_extension(serverapp):
1012
serverapp.mockI = True
1113
serverapp.mock_shared = "I"
14+
15+
16+
async def _start_jupyter_server_extension(serverapp):
17+
await asyncio.sleep(0.1)
18+
serverapp.mock1_started = True

tests/extension/test_app.py

+9
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ async def test_load_parallel_extensions(monkeypatch, jp_environ):
140140
assert exts["tests.extension.mockextensions"]
141141

142142

143+
async def test_start_extension(jp_serverapp, mock_extension):
144+
await jp_serverapp._post_start()
145+
assert mock_extension.started
146+
assert hasattr(
147+
jp_serverapp, "mock1_started"
148+
), "Failed because the `_start_jupyter_server_extension` function in 'mock1.py' was never called"
149+
assert jp_serverapp.mock1_started
150+
151+
143152
async def test_stop_extension(jp_serverapp, caplog):
144153
"""Test the stop_extension method.
145154

tests/test_serverapp.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -607,8 +607,9 @@ def test_running_server_info(jp_serverapp):
607607

608608

609609
@pytest.mark.parametrize("should_exist", [True, False])
610-
def test_browser_open_files(jp_configurable_serverapp, should_exist, caplog):
610+
async def test_browser_open_files(jp_configurable_serverapp, should_exist, caplog):
611611
app = jp_configurable_serverapp(no_browser_open_file=not should_exist)
612+
await app._post_start()
612613
assert os.path.exists(app.browser_open_file) == should_exist
613614
url = urljoin("file:", pathname2url(app.browser_open_file))
614615
url_messages = [rec.message for rec in caplog.records if url in rec.message]

0 commit comments

Comments
 (0)