Skip to content

Commit 166c61d

Browse files
committed
add start hook to basic extensions and include documentation
1 parent 955d891 commit 166c61d

File tree

6 files changed

+141
-15
lines changed

6 files changed

+141
-15
lines changed

docs/source/developers/extensions.rst

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,29 @@ 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+
6891
Making an extension discoverable
6992
--------------------------------
7093

@@ -117,6 +140,7 @@ An ExtensionApp:
117140
- has an entrypoint, ``jupyter <name>``.
118141
- can serve static content from the ``/static/<name>/`` endpoint.
119142
- can add new endpoints to the Jupyter Server.
143+
- can start asynchronous tasks after the server has started.
120144

121145
The basic structure of an ExtensionApp is shown below:
122146

@@ -156,6 +180,11 @@ The basic structure of an ExtensionApp is shown below:
156180
...
157181
# Change the jinja templating environment
158182
183+
async def _start_jupyter_server_extension(self):
184+
...
185+
# Extend this method to start any (e.g. async) tasks
186+
# after the main Server's Event Loop is running.
187+
159188
async def stop_extension(self):
160189
...
161190
# Perform any required shut down steps
@@ -171,6 +200,7 @@ Methods
171200
* ``initialize_settings()``: adds custom settings to the Tornado Web Application.
172201
* ``initialize_handlers()``: appends handlers to the Tornado Web Application.
173202
* ``initialize_templates()``: initialize the templating engine (e.g. jinja2) for your frontend.
203+
* ``_start_jupyter_server_extension()``: enables the extension to start (async) tasks _after_ the server's main Event Loop has started.
174204
* ``stop_extension()``: called on server shut down.
175205

176206
Properties
@@ -320,6 +350,42 @@ pointing at the ``load_classic_server_extension`` method:
320350
If the extension is enabled, the extension will be loaded when the server starts.
321351

322352

353+
Starting asynchronous tasks from an ExtensionApp
354+
------------------------------------------------
355+
356+
.. versionadded:: 2.15.0
357+
358+
359+
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.
360+
361+
Here is a basic (pseudo) code example:
362+
363+
.. code-block:: python
364+
365+
import asyncio
366+
import time
367+
368+
369+
async def log_time_periodically(log, dt=1):
370+
"""Log the current time from a periodic loop."""
371+
while True:
372+
current_time = time.time()
373+
log.info(current_time)
374+
await sleep(dt)
375+
376+
377+
class MyExtension(ExtensionApp):
378+
...
379+
380+
async def _start_jupyter_server_extension(self):
381+
self.my_background_task = asyncio.create_task(
382+
log_time_periodically(self.log)
383+
)
384+
385+
async def stop_extension(self):
386+
self.my_background_task.cancel()
387+
388+
323389
Distributing a server extension
324390
===============================
325391

jupyter_server/extension/application.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -446,9 +446,6 @@ def start(self):
446446
assert self.serverapp is not None
447447
self.serverapp.start()
448448

449-
async def start_extension(self):
450-
"""An async hook to start e.g. tasks after the server's event loop is running."""
451-
452449
def current_activity(self):
453450
"""Return a list of activity happening in this extension."""
454451
return
@@ -478,6 +475,18 @@ def _load_jupyter_server_extension(cls, serverapp):
478475
extension.initialize()
479476
return extension
480477

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+
481490
@classmethod
482491
def load_classic_server_extension(cls, serverapp):
483492
"""Enables extension to be loaded as classic Notebook (jupyter/notebook) extension."""

jupyter_server/extension/manager.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,20 @@ def _get_loader(self):
119119
loader = get_loader(loc)
120120
return loader
121121

122+
def _get_starter(self):
123+
"""Get a linker."""
124+
if self.app:
125+
linker = self.app._start_jupyter_server_extension
126+
else:
127+
linker = getattr(
128+
self.module,
129+
# Search for a _start_jupyter_extension
130+
"_start_jupyter_server_extension",
131+
# Otherwise return a dummy function.
132+
lambda serverapp: None,
133+
)
134+
return linker
135+
122136
def validate(self):
123137
"""Check that both a linker and loader exists."""
124138
try:
@@ -150,6 +164,13 @@ def load(self, serverapp):
150164
loader = self._get_loader()
151165
return loader(serverapp)
152166

167+
def start(self, serverapp):
168+
"""Call's the extensions 'start' hook where it can
169+
start (possibly async) tasks _after_ the event loop is running.
170+
"""
171+
starter = self._get_starter()
172+
return starter(serverapp)
173+
153174

154175
class ExtensionPackage(LoggingConfigurable):
155176
"""An API for interfacing with a Jupyter Server extension package.
@@ -222,6 +243,11 @@ def load_point(self, point_name, serverapp):
222243
point = self.extension_points[point_name]
223244
return point.load(serverapp)
224245

246+
def start_point(self, point_name, serverapp):
247+
"""Load an extension point."""
248+
point = self.extension_points[point_name]
249+
return point.start(serverapp)
250+
225251
def link_all_points(self, serverapp):
226252
"""Link all extension points."""
227253
for point_name in self.extension_points:
@@ -231,9 +257,14 @@ def load_all_points(self, serverapp):
231257
"""Load all extension points."""
232258
return [self.load_point(point_name, serverapp) for point_name in self.extension_points]
233259

260+
async def start_all_points(self, serverapp):
261+
"""Load all extension points."""
262+
for point_name in self.extension_points:
263+
await self.start_point(point_name, serverapp)
264+
234265

235266
class ExtensionManager(LoggingConfigurable):
236-
"""High level interface for findind, validating,
267+
"""High level interface for finding, validating,
237268
linking, loading, and managing Jupyter Server extensions.
238269
239270
Usage:
@@ -367,15 +398,21 @@ def load_extension(self, name):
367398
else:
368399
self.log.info("%s | extension was successfully loaded.", name)
369400

370-
async def start_extension(self, name, apps):
371-
"""Call the start hooks in the specified apps."""
372-
for app in apps:
373-
self.log.debug("%s | extension app %r starting", name, app.name)
401+
async def start_extension(self, name):
402+
"""Start an extension by name."""
403+
extension = self.extensions.get(name)
404+
405+
if extension and extension.enabled:
374406
try:
375-
await app.start_extension()
376-
self.log.debug("%s | extension app %r started", name, app.name)
377-
except Exception as err:
378-
self.log.error(err)
407+
await extension.start_all_points(self.serverapp)
408+
except Exception as e:
409+
if self.serverapp and self.serverapp.reraise_server_extension_failures:
410+
raise
411+
self.log.warning(
412+
"%s | extension failed starting with message: %r", name, e, exc_info=True
413+
)
414+
else:
415+
self.log.info("%s | extension was successfully started.", name)
379416

380417
async def stop_extension(self, name, apps):
381418
"""Call the shutdown hooks in the specified apps."""
@@ -403,8 +440,11 @@ def load_all_extensions(self):
403440
self.load_extension(name)
404441

405442
async def start_all_extensions(self):
406-
"""Call the start hooks in all extensions."""
407-
await multi(list(starmap(self.start_extension, sorted(dict(self.extension_apps).items()))))
443+
"""Start all enabled extensions."""
444+
# Sort the extension names to enforce deterministic loading
445+
# order.
446+
for name in self.sorted_extensions:
447+
await self.start_extension(name)
408448

409449
async def stop_all_extensions(self):
410450
"""Call the shutdown hooks in all extensions."""

tests/extension/mockextensions/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def initialize_handlers(self):
7272
self.handlers.append(("/mock_template", MockExtensionTemplateHandler))
7373
self.loaded = True
7474

75-
async def start_extension(self):
75+
async def _start_jupyter_server_extension(self, serverapp):
7676
self.started = True
7777

7878

tests/extension/mockextensions/mock1.py

Lines changed: 7 additions & 0 deletions
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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ async def test_load_parallel_extensions(monkeypatch, jp_environ):
143143
async def test_start_extension(jp_serverapp, mock_extension):
144144
await jp_serverapp._post_start()
145145
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
146150

147151

148152
async def test_stop_extension(jp_serverapp, caplog):

0 commit comments

Comments
 (0)