Skip to content

Commit 93f7fa6

Browse files
committed
Add async start hook to ExtensionApp API
1 parent e544fa1 commit 93f7fa6

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
@@ -3061,6 +3061,72 @@ def start_app(self) -> None:
30613061
)
30623062
self.exit(1)
30633063

3064+
self.write_server_info_file()
3065+
3066+
if not self.no_browser_open_file:
3067+
self.write_browser_open_files()
3068+
3069+
# Handle the browser opening.
3070+
if self.open_browser and not self.sock:
3071+
self.launch_browser()
3072+
3073+
async def _cleanup(self) -> None:
3074+
"""General cleanup of files, extensions and kernels created
3075+
by this instance ServerApp.
3076+
"""
3077+
self.remove_server_info_file()
3078+
self.remove_browser_open_files()
3079+
await self.cleanup_extensions()
3080+
await self.cleanup_kernels()
3081+
try:
3082+
await self.kernel_websocket_connection_class.close_all() # type:ignore[attr-defined]
3083+
except AttributeError:
3084+
# This can happen in two different scenarios:
3085+
#
3086+
# 1. During tests, where the _cleanup method is invoked without
3087+
# the corresponding initialize method having been invoked.
3088+
# 2. If the provided `kernel_websocket_connection_class` does not
3089+
# implement the `close_all` class method.
3090+
#
3091+
# In either case, we don't need to do anything and just want to treat
3092+
# the raised error as a no-op.
3093+
pass
3094+
if getattr(self, "kernel_manager", None):
3095+
self.kernel_manager.__del__()
3096+
if getattr(self, "session_manager", None):
3097+
self.session_manager.close()
3098+
if hasattr(self, "http_server"):
3099+
# Stop a server if its set.
3100+
self.http_server.stop()
3101+
3102+
def start_ioloop(self) -> None:
3103+
"""Start the IO Loop."""
3104+
if sys.platform.startswith("win"):
3105+
# add no-op to wake every 5s
3106+
# to handle signals that may be ignored by the inner loop
3107+
pc = ioloop.PeriodicCallback(lambda: None, 5000)
3108+
pc.start()
3109+
try:
3110+
self.io_loop.add_callback(self._post_start)
3111+
self.io_loop.start()
3112+
except KeyboardInterrupt:
3113+
self.log.info(_i18n("Interrupted..."))
3114+
3115+
def init_ioloop(self) -> None:
3116+
"""init self.io_loop so that an extension can use it by io_loop.call_later() to create background tasks"""
3117+
self.io_loop = ioloop.IOLoop.current()
3118+
3119+
async def _post_start(self):
3120+
"""Add an async hook to start tasks after the event loop is running.
3121+
3122+
This will also attempt to start all tasks found in
3123+
the `start_extension` method in Extension Apps.
3124+
"""
3125+
try:
3126+
await self.extension_manager.start_all_extensions()
3127+
except Exception as err:
3128+
self.log.error(err)
3129+
30643130
info = self.log.info
30653131
for line in self.running_server_info(kernel_count=False).split("\n"):
30663132
info(line)
@@ -3079,15 +3145,6 @@ def start_app(self) -> None:
30793145
)
30803146
)
30813147

3082-
self.write_server_info_file()
3083-
3084-
if not self.no_browser_open_file:
3085-
self.write_browser_open_files()
3086-
3087-
# Handle the browser opening.
3088-
if self.open_browser and not self.sock:
3089-
self.launch_browser()
3090-
30913148
if self.identity_provider.token and self.identity_provider.token_generated:
30923149
# log full URL with generated token, so there's a copy/pasteable link
30933150
# with auth info.
@@ -3128,51 +3185,6 @@ def start_app(self) -> None:
31283185

31293186
self.log.critical("\n".join(message))
31303187

3131-
async def _cleanup(self) -> None:
3132-
"""General cleanup of files, extensions and kernels created
3133-
by this instance ServerApp.
3134-
"""
3135-
self.remove_server_info_file()
3136-
self.remove_browser_open_files()
3137-
await self.cleanup_extensions()
3138-
await self.cleanup_kernels()
3139-
try:
3140-
await self.kernel_websocket_connection_class.close_all() # type:ignore[attr-defined]
3141-
except AttributeError:
3142-
# This can happen in two different scenarios:
3143-
#
3144-
# 1. During tests, where the _cleanup method is invoked without
3145-
# the corresponding initialize method having been invoked.
3146-
# 2. If the provided `kernel_websocket_connection_class` does not
3147-
# implement the `close_all` class method.
3148-
#
3149-
# In either case, we don't need to do anything and just want to treat
3150-
# the raised error as a no-op.
3151-
pass
3152-
if getattr(self, "kernel_manager", None):
3153-
self.kernel_manager.__del__()
3154-
if getattr(self, "session_manager", None):
3155-
self.session_manager.close()
3156-
if hasattr(self, "http_server"):
3157-
# Stop a server if its set.
3158-
self.http_server.stop()
3159-
3160-
def start_ioloop(self) -> None:
3161-
"""Start the IO Loop."""
3162-
if sys.platform.startswith("win"):
3163-
# add no-op to wake every 5s
3164-
# to handle signals that may be ignored by the inner loop
3165-
pc = ioloop.PeriodicCallback(lambda: None, 5000)
3166-
pc.start()
3167-
try:
3168-
self.io_loop.start()
3169-
except KeyboardInterrupt:
3170-
self.log.info(_i18n("Interrupted..."))
3171-
3172-
def init_ioloop(self) -> None:
3173-
"""init self.io_loop so that an extension can use it by io_loop.call_later() to create background tasks"""
3174-
self.io_loop = ioloop.IOLoop.current()
3175-
31763188
def start(self) -> None:
31773189
"""Start the Jupyter server app, after initialization
31783190

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
@@ -139,6 +139,11 @@ async def test_load_parallel_extensions(monkeypatch, jp_environ):
139139
assert exts["tests.extension.mockextensions"]
140140

141141

142+
async def test_start_extension(jp_serverapp, mock_extension):
143+
await jp_serverapp._post_start()
144+
assert mock_extension.started
145+
146+
142147
async def test_stop_extension(jp_serverapp, caplog):
143148
"""Test the stop_extension method.
144149

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)