Skip to content

Commit 955d891

Browse files
committed
Add async start hook to ExtensionApp API
1 parent af1342d commit 955d891

File tree

6 files changed

+95
-56
lines changed

6 files changed

+95
-56
lines changed

jupyter_server/extension/application.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,9 @@ 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+
449452
def current_activity(self):
450453
"""Return a list of activity happening in this extension."""
451454
return

jupyter_server/extension/manager.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,16 @@ def load_extension(self, name):
367367
else:
368368
self.log.info("%s | extension was successfully loaded.", name)
369369

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)
374+
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)
379+
370380
async def stop_extension(self, name, apps):
371381
"""Call the shutdown hooks in the specified apps."""
372382
for app in apps:
@@ -392,6 +402,10 @@ def load_all_extensions(self):
392402
for name in self.sorted_extensions:
393403
self.load_extension(name)
394404

405+
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()))))
408+
395409
async def stop_all_extensions(self):
396410
"""Call the shutdown hooks in all extensions."""
397411
await multi(list(starmap(self.stop_extension, sorted(dict(self.extension_apps).items()))))

jupyter_server/serverapp.py

Lines changed: 66 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2999,6 +2999,72 @@ def start_app(self) -> None:
29992999
)
30003000
self.exit(1)
30013001

3002+
self.write_server_info_file()
3003+
3004+
if not self.no_browser_open_file:
3005+
self.write_browser_open_files()
3006+
3007+
# Handle the browser opening.
3008+
if self.open_browser and not self.sock:
3009+
self.launch_browser()
3010+
3011+
async def _cleanup(self) -> None:
3012+
"""General cleanup of files, extensions and kernels created
3013+
by this instance ServerApp.
3014+
"""
3015+
self.remove_server_info_file()
3016+
self.remove_browser_open_files()
3017+
await self.cleanup_extensions()
3018+
await self.cleanup_kernels()
3019+
try:
3020+
await self.kernel_websocket_connection_class.close_all() # type:ignore[attr-defined]
3021+
except AttributeError:
3022+
# This can happen in two different scenarios:
3023+
#
3024+
# 1. During tests, where the _cleanup method is invoked without
3025+
# the corresponding initialize method having been invoked.
3026+
# 2. If the provided `kernel_websocket_connection_class` does not
3027+
# implement the `close_all` class method.
3028+
#
3029+
# In either case, we don't need to do anything and just want to treat
3030+
# the raised error as a no-op.
3031+
pass
3032+
if getattr(self, "kernel_manager", None):
3033+
self.kernel_manager.__del__()
3034+
if getattr(self, "session_manager", None):
3035+
self.session_manager.close()
3036+
if hasattr(self, "http_server"):
3037+
# Stop a server if its set.
3038+
self.http_server.stop()
3039+
3040+
def start_ioloop(self) -> None:
3041+
"""Start the IO Loop."""
3042+
if sys.platform.startswith("win"):
3043+
# add no-op to wake every 5s
3044+
# to handle signals that may be ignored by the inner loop
3045+
pc = ioloop.PeriodicCallback(lambda: None, 5000)
3046+
pc.start()
3047+
try:
3048+
self.io_loop.add_callback(self._post_start)
3049+
self.io_loop.start()
3050+
except KeyboardInterrupt:
3051+
self.log.info(_i18n("Interrupted..."))
3052+
3053+
def init_ioloop(self) -> None:
3054+
"""init self.io_loop so that an extension can use it by io_loop.call_later() to create background tasks"""
3055+
self.io_loop = ioloop.IOLoop.current()
3056+
3057+
async def _post_start(self):
3058+
"""Add an async hook to start tasks after the event loop is running.
3059+
3060+
This will also attempt to start all tasks found in
3061+
the `start_extension` method in Extension Apps.
3062+
"""
3063+
try:
3064+
await self.extension_manager.start_all_extensions()
3065+
except Exception as err:
3066+
self.log.error(err)
3067+
30023068
info = self.log.info
30033069
for line in self.running_server_info(kernel_count=False).split("\n"):
30043070
info(line)
@@ -3017,15 +3083,6 @@ def start_app(self) -> None:
30173083
)
30183084
)
30193085

3020-
self.write_server_info_file()
3021-
3022-
if not self.no_browser_open_file:
3023-
self.write_browser_open_files()
3024-
3025-
# Handle the browser opening.
3026-
if self.open_browser and not self.sock:
3027-
self.launch_browser()
3028-
30293086
if self.identity_provider.token and self.identity_provider.token_generated:
30303087
# log full URL with generated token, so there's a copy/pasteable link
30313088
# with auth info.
@@ -3066,51 +3123,6 @@ def start_app(self) -> None:
30663123

30673124
self.log.critical("\n".join(message))
30683125

3069-
async def _cleanup(self) -> None:
3070-
"""General cleanup of files, extensions and kernels created
3071-
by this instance ServerApp.
3072-
"""
3073-
self.remove_server_info_file()
3074-
self.remove_browser_open_files()
3075-
await self.cleanup_extensions()
3076-
await self.cleanup_kernels()
3077-
try:
3078-
await self.kernel_websocket_connection_class.close_all() # type:ignore[attr-defined]
3079-
except AttributeError:
3080-
# This can happen in two different scenarios:
3081-
#
3082-
# 1. During tests, where the _cleanup method is invoked without
3083-
# the corresponding initialize method having been invoked.
3084-
# 2. If the provided `kernel_websocket_connection_class` does not
3085-
# implement the `close_all` class method.
3086-
#
3087-
# In either case, we don't need to do anything and just want to treat
3088-
# the raised error as a no-op.
3089-
pass
3090-
if getattr(self, "kernel_manager", None):
3091-
self.kernel_manager.__del__()
3092-
if getattr(self, "session_manager", None):
3093-
self.session_manager.close()
3094-
if hasattr(self, "http_server"):
3095-
# Stop a server if its set.
3096-
self.http_server.stop()
3097-
3098-
def start_ioloop(self) -> None:
3099-
"""Start the IO Loop."""
3100-
if sys.platform.startswith("win"):
3101-
# add no-op to wake every 5s
3102-
# to handle signals that may be ignored by the inner loop
3103-
pc = ioloop.PeriodicCallback(lambda: None, 5000)
3104-
pc.start()
3105-
try:
3106-
self.io_loop.start()
3107-
except KeyboardInterrupt:
3108-
self.log.info(_i18n("Interrupted..."))
3109-
3110-
def init_ioloop(self) -> None:
3111-
"""init self.io_loop so that an extension can use it by io_loop.call_later() to create background tasks"""
3112-
self.io_loop = ioloop.IOLoop.current()
3113-
31143126
def start(self) -> None:
31153127
"""Start the Jupyter server app, after initialization
31163128

tests/extension/mockextensions/app.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from jupyter_events import EventLogger
66
from jupyter_events.schema_registry import SchemaRegistryException
7-
from traitlets import List, Unicode
7+
from traitlets import Bool, List, Unicode
88

99
from jupyter_server.base.handlers import JupyterHandler
1010
from jupyter_server.extension.application import ExtensionApp, ExtensionAppJinjaMixin
@@ -50,6 +50,7 @@ class MockExtensionApp(ExtensionAppJinjaMixin, ExtensionApp):
5050
static_paths = [STATIC_PATH] # type:ignore[assignment]
5151
mock_trait = Unicode("mock trait", config=True)
5252
loaded = False
53+
started = Bool(False)
5354

5455
serverapp_config = {"jpserver_extensions": {"tests.extension.mockextensions.mock1": True}}
5556

@@ -71,6 +72,9 @@ def initialize_handlers(self):
7172
self.handlers.append(("/mock_template", MockExtensionTemplateHandler))
7273
self.loaded = True
7374

75+
async def start_extension(self):
76+
self.started = True
77+
7478

7579
if __name__ == "__main__":
7680
MockExtensionApp.launch_instance()

tests/extension/test_app.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ 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+
147+
143148
async def test_stop_extension(jp_serverapp, caplog):
144149
"""Test the stop_extension method.
145150

tests/test_serverapp.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,8 +606,9 @@ def test_running_server_info(jp_serverapp):
606606

607607

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

0 commit comments

Comments
 (0)