Skip to content

Commit a6db20d

Browse files
authored
Merge pull request #832 from Zsailer/jupyter_events
2 parents ff85055 + d66b50e commit a6db20d

File tree

7 files changed

+145
-11
lines changed

7 files changed

+145
-11
lines changed

jupyter_client/__init__.py

+5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
"""Client-side implementations of the Jupyter protocol"""
2+
import pathlib
3+
24
from ._version import __version__ # noqa
35
from ._version import protocol_version # noqa
46
from ._version import protocol_version_info # noqa
57
from ._version import version_info # noqa
68

9+
JUPYTER_CLIENT_EVENTS_URI = "https://events.jupyter.org/jupyter_client"
10+
DEFAULT_EVENTS_SCHEMA_PATH = pathlib.Path(__file__).parent / "event_schemas"
11+
712
try:
813
from .asynchronous import AsyncKernelClient # noqa
914
from .blocking import BlockingKernelClient # noqa
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"$id": https://events.jupyter.org/jupyter_client/kernel_manager/v1
2+
version: 1
3+
title: Kernel Manager Events
4+
description: |
5+
Record actions on kernels by the KernelManager.
6+
type: object
7+
required:
8+
- kernel_id
9+
- action
10+
properties:
11+
kernel_id:
12+
oneOf:
13+
- type: string
14+
- type: "null"
15+
description: The kernel's unique ID.
16+
action:
17+
enum:
18+
- pre_start
19+
- launch
20+
- post_start
21+
- interrupt
22+
- restart
23+
- kill
24+
- request_shutdown
25+
- finish_shutdown
26+
- cleanup_resources
27+
- restart_started
28+
- restart_finished
29+
- shutdown_started
30+
- shutdown_finished
31+
description: |
32+
Action performed by the KernelManager API.
33+
caller:
34+
type: string
35+
enum:
36+
- kernel_manager

jupyter_client/manager.py

+36
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from enum import Enum
1616

1717
import zmq
18+
from jupyter_events import EventLogger # type: ignore[import]
1819
from traitlets import Any
1920
from traitlets import Bool
2021
from traitlets import default
@@ -33,6 +34,8 @@
3334
from .provisioning import KernelProvisionerFactory as KPF
3435
from .utils import ensure_async
3536
from .utils import run_sync
37+
from jupyter_client import DEFAULT_EVENTS_SCHEMA_PATH
38+
from jupyter_client import JUPYTER_CLIENT_EVENTS_URI
3639
from jupyter_client import KernelClient
3740
from jupyter_client import kernelspec
3841

@@ -91,6 +94,27 @@ class KernelManager(ConnectionFileMixin):
9194
This version starts kernels with Popen.
9295
"""
9396

97+
event_schema_id = JUPYTER_CLIENT_EVENTS_URI + "/kernel_manager/v1"
98+
event_logger = Instance(EventLogger).tag(config=True)
99+
100+
@default("event_logger")
101+
def _default_event_logger(self):
102+
if self.parent and hasattr(self.parent, "event_logger"):
103+
return self.parent.event_logger
104+
else:
105+
# If parent does not have an event logger, create one.
106+
logger = EventLogger()
107+
schema_path = DEFAULT_EVENTS_SCHEMA_PATH / "kernel_manager" / "v1.yaml"
108+
logger.register_event_schema(schema_path)
109+
return logger
110+
111+
def _emit(self, *, action: str) -> None:
112+
"""Emit event using the core event schema from Jupyter Server's Contents Manager."""
113+
self.event_logger.emit(
114+
schema_id=self.event_schema_id,
115+
data={"action": action, "kernel_id": self.kernel_id, "caller": "kernel_manager"},
116+
)
117+
94118
_ready: t.Union[Future, CFuture]
95119

96120
def __init__(self, *args, **kwargs):
@@ -308,6 +332,7 @@ async def _async_launch_kernel(self, kernel_cmd: t.List[str], **kw: t.Any) -> No
308332
assert self.provisioner.has_process
309333
# Provisioner provides the connection information. Load into kernel manager and write file.
310334
self._force_connection_info(connection_info)
335+
self._emit(action="launch")
311336

312337
_launch_kernel = run_sync(_async_launch_kernel)
313338

@@ -350,6 +375,7 @@ async def _async_pre_start_kernel(
350375
)
351376
kw = await self.provisioner.pre_launch(**kw)
352377
kernel_cmd = kw.pop('cmd')
378+
self._emit(action="pre_start")
353379
return kernel_cmd, kw
354380

355381
pre_start_kernel = run_sync(_async_pre_start_kernel)
@@ -366,6 +392,7 @@ async def _async_post_start_kernel(self, **kw: t.Any) -> None:
366392
self._connect_control_socket()
367393
assert self.provisioner is not None
368394
await self.provisioner.post_launch(**kw)
395+
self._emit(action="post_start")
369396

370397
post_start_kernel = run_sync(_async_post_start_kernel)
371398

@@ -401,6 +428,7 @@ async def _async_request_shutdown(self, restart: bool = False) -> None:
401428
assert self.provisioner is not None
402429
await self.provisioner.shutdown_requested(restart=restart)
403430
self._shutdown_status = _ShutdownStatus.ShutdownRequest
431+
self._emit(action="request_shutdown")
404432

405433
request_shutdown = run_sync(_async_request_shutdown)
406434

@@ -442,6 +470,7 @@ async def _async_finish_shutdown(
442470
if self.has_kernel:
443471
assert self.provisioner is not None
444472
await self.provisioner.wait()
473+
self._emit(action="finish_shutdown")
445474

446475
finish_shutdown = run_sync(_async_finish_shutdown)
447476

@@ -459,6 +488,7 @@ async def _async_cleanup_resources(self, restart: bool = False) -> None:
459488

460489
if self.provisioner:
461490
await self.provisioner.cleanup(restart=restart)
491+
self._emit(action="cleanup_resources")
462492

463493
cleanup_resources = run_sync(_async_cleanup_resources)
464494

@@ -481,6 +511,7 @@ async def _async_shutdown_kernel(self, now: bool = False, restart: bool = False)
481511
Will this kernel be restarted after it is shutdown. When this
482512
is True, connection files will not be cleaned up.
483513
"""
514+
self._emit(action="shutdown_started")
484515
self.shutting_down = True # Used by restarter to prevent race condition
485516
# Stop monitoring for restarting while we shutdown.
486517
self.stop_restarter()
@@ -498,6 +529,7 @@ async def _async_shutdown_kernel(self, now: bool = False, restart: bool = False)
498529
await ensure_async(self.finish_shutdown(restart=restart))
499530

500531
await ensure_async(self.cleanup_resources(restart=restart))
532+
self._emit(action="shutdown_finished")
501533

502534
shutdown_kernel = run_sync(_async_shutdown_kernel)
503535

@@ -528,6 +560,7 @@ async def _async_restart_kernel(
528560
Any options specified here will overwrite those used to launch the
529561
kernel.
530562
"""
563+
self._emit(action="restart_started")
531564
if self._launch_args is None:
532565
raise RuntimeError("Cannot restart the kernel. No previous call to 'start_kernel'.")
533566

@@ -540,6 +573,7 @@ async def _async_restart_kernel(
540573
# Start new kernel.
541574
self._launch_args.update(kw)
542575
await ensure_async(self.start_kernel(**self._launch_args))
576+
self._emit(action="restart_finished")
543577

544578
restart_kernel = run_sync(_async_restart_kernel)
545579

@@ -576,6 +610,7 @@ async def _async_kill_kernel(self, restart: bool = False) -> None:
576610
# Process is no longer alive, wait and clear
577611
if self.has_kernel:
578612
await self.provisioner.wait()
613+
self._emit(action="kill")
579614

580615
_kill_kernel = run_sync(_async_kill_kernel)
581616

@@ -597,6 +632,7 @@ async def _async_interrupt_kernel(self) -> None:
597632
self.session.send(self._control_socket, msg)
598633
else:
599634
raise RuntimeError("Cannot interrupt kernel. No kernel is running!")
635+
self._emit(action="interrupt")
600636

601637
interrupt_kernel = run_sync(_async_interrupt_kernel)
602638

pyproject.toml

+3-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies = [
3131
"pyzmq>=23.0",
3232
"tornado>=6.2",
3333
"traitlets",
34+
"jupyter_events>=0.5.0"
3435
]
3536

3637
[[project.authors]]
@@ -56,9 +57,10 @@ test = [
5657
"mypy",
5758
"pre-commit",
5859
"pytest",
59-
"pytest-asyncio>=0.18",
60+
"pytest-asyncio>=0.19",
6061
"pytest-cov",
6162
"pytest-timeout",
63+
"jupyter_events[test]"
6264
]
6365
doc = [
6466
"ipykernel",

tests/conftest.py

+3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
pjoin = os.path.join
1717

1818

19+
pytest_plugins = ["jupyter_events.pytest_plugin"]
20+
21+
1922
# Handle resource limit
2023
# Ensure a minimal soft limit of DEFAULT_SOFT if the current hard limit is at least that much.
2124
if resource is not None:

tests/test_kernelmanager.py

+52-10
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .utils import AsyncKMSubclass
1919
from .utils import SyncKMSubclass
2020
from jupyter_client import AsyncKernelManager
21+
from jupyter_client import DEFAULT_EVENTS_SCHEMA_PATH
2122
from jupyter_client import KernelManager
2223
from jupyter_client.manager import _ShutdownStatus
2324
from jupyter_client.manager import start_new_async_kernel
@@ -92,14 +93,14 @@ def start_kernel():
9293

9394

9495
@pytest.fixture
95-
def km(config):
96-
km = KernelManager(config=config)
96+
def km(config, jp_event_logger):
97+
km = KernelManager(config=config, event_logger=jp_event_logger)
9798
return km
9899

99100

100101
@pytest.fixture
101-
def km_subclass(config):
102-
km = SyncKMSubclass(config=config)
102+
def km_subclass(config, jp_event_logger):
103+
km = SyncKMSubclass(config=config, event_logger=jp_event_logger)
103104
return km
104105

105106

@@ -112,15 +113,36 @@ def zmq_context():
112113
ctx.term()
113114

114115

116+
@pytest.fixture
117+
def jp_event_schemas():
118+
return [DEFAULT_EVENTS_SCHEMA_PATH / "kernel_manager" / "v1.yaml"]
119+
120+
121+
@pytest.fixture
122+
def check_emitted_events(jp_read_emitted_events):
123+
"""Check the given events where emitted"""
124+
125+
def _(*expected_list):
126+
read_events = jp_read_emitted_events()
127+
events = [e for e in read_events if e["caller"] == "kernel_manager"]
128+
# Ensure that the number of read events match the expected events.
129+
assert len(events) == len(expected_list)
130+
# Loop through the events and make sure they are in order of expected.
131+
for i, action in enumerate(expected_list):
132+
assert "action" in events[i] and action == events[i]["action"]
133+
134+
return _
135+
136+
115137
@pytest.fixture(params=[AsyncKernelManager, AsyncKMSubclass])
116-
def async_km(request, config):
117-
km = request.param(config=config)
138+
def async_km(request, config, jp_event_logger):
139+
km = request.param(config=config, event_logger=jp_event_logger)
118140
return km
119141

120142

121143
@pytest.fixture
122-
def async_km_subclass(config):
123-
km = AsyncKMSubclass(config=config)
144+
def async_km_subclass(config, jp_event_logger):
145+
km = AsyncKMSubclass(config=config, event_logger=jp_event_logger)
124146
return km
125147

126148

@@ -193,18 +215,35 @@ async def test_async_signal_kernel_subprocesses(self, name, install, expected):
193215

194216

195217
class TestKernelManager:
196-
def test_lifecycle(self, km):
218+
def test_lifecycle(self, km, jp_read_emitted_events, check_emitted_events):
197219
km.start_kernel(stdout=PIPE, stderr=PIPE)
220+
check_emitted_events("pre_start", "launch", "post_start")
198221
kc = km.client()
199222
assert km.is_alive()
200223
is_done = km.ready.done()
201224
assert is_done
202225
km.restart_kernel(now=True)
226+
check_emitted_events(
227+
"restart_started",
228+
"shutdown_started",
229+
"interrupt",
230+
"kill",
231+
"cleanup_resources",
232+
"shutdown_finished",
233+
"pre_start",
234+
"launch",
235+
"post_start",
236+
"restart_finished",
237+
)
203238
assert km.is_alive()
204239
km.interrupt_kernel()
240+
check_emitted_events("interrupt")
205241
assert isinstance(km, KernelManager)
206242
kc.stop_channels()
207243
km.shutdown_kernel(now=True)
244+
check_emitted_events(
245+
"shutdown_started", "interrupt", "kill", "cleanup_resources", "shutdown_finished"
246+
)
208247
assert km.context.closed
209248

210249
def test_get_connect_info(self, km):
@@ -448,7 +487,10 @@ def execute(cmd):
448487

449488
@pytest.mark.asyncio
450489
class TestAsyncKernelManager:
451-
async def test_lifecycle(self, async_km):
490+
async def test_lifecycle(
491+
self,
492+
async_km,
493+
):
452494
await async_km.start_kernel(stdout=PIPE, stderr=PIPE)
453495
is_alive = await async_km.is_alive()
454496
assert is_alive

tests/test_manager.py

+10
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,13 @@ def test_connection_file_real_path():
3232
km._launch_args = {}
3333
cmds = km.format_kernel_cmd()
3434
assert cmds[4] == "foobar"
35+
36+
37+
def test_kernel_manager_event_logger(jp_event_handler, jp_read_emitted_events):
38+
action = "pre_start"
39+
km = KernelManager()
40+
km.event_logger.register_handler(jp_event_handler)
41+
km._emit(action=action)
42+
output = jp_read_emitted_events()[0]
43+
assert "kernel_id" in output and output["kernel_id"] is None
44+
assert "action" in output and output["action"] == action

0 commit comments

Comments
 (0)