Skip to content

Commit 1361a37

Browse files
Zsailerafshin
andauthored
Basic Event Bus (#820)
Co-authored-by: Afshin Taylor Darian <[email protected]>
1 parent d376780 commit 1361a37

File tree

13 files changed

+245
-0
lines changed

13 files changed

+245
-0
lines changed

jupyter_server/base/handlers.py

+4
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,10 @@ def kernel_spec_manager(self):
340340
def config_manager(self):
341341
return self.settings["config_manager"]
342342

343+
@property
344+
def event_bus(self):
345+
return self.settings["event_bus"]
346+
343347
# ---------------------------------------------------------------
344348
# CORS
345349
# ---------------------------------------------------------------

jupyter_server/serverapp.py

+22
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
AsyncContentsManager,
121121
ContentsManager,
122122
)
123+
from jupyter_server.services.events.bus import EventBus
123124
from jupyter_server.services.kernels.kernelmanager import (
124125
AsyncMappingKernelManager,
125126
MappingKernelManager,
@@ -164,6 +165,7 @@
164165
sessions=["jupyter_server.services.sessions.handlers"],
165166
shutdown=["jupyter_server.services.shutdown"],
166167
view=["jupyter_server.view.handlers"],
168+
events=["jupyter_server.services.events.handlers"],
167169
)
168170

169171
# Added for backwards compatibility from classic notebook server.
@@ -207,6 +209,7 @@ def __init__(
207209
session_manager,
208210
kernel_spec_manager,
209211
config_manager,
212+
event_bus,
210213
extra_services,
211214
log,
212215
base_url,
@@ -242,6 +245,7 @@ def __init__(
242245
session_manager,
243246
kernel_spec_manager,
244247
config_manager,
248+
event_bus,
245249
extra_services,
246250
log,
247251
base_url,
@@ -263,6 +267,7 @@ def init_settings(
263267
session_manager,
264268
kernel_spec_manager,
265269
config_manager,
270+
event_bus,
266271
extra_services,
267272
log,
268273
base_url,
@@ -354,6 +359,7 @@ def init_settings(
354359
config_manager=config_manager,
355360
authorizer=authorizer,
356361
identity_provider=identity_provider,
362+
event_bus=event_bus,
357363
# handlers
358364
extra_services=extra_services,
359365
# Jupyter stuff
@@ -769,6 +775,7 @@ class ServerApp(JupyterApp):
769775
GatewaySessionManager,
770776
GatewayClient,
771777
Authorizer,
778+
EventBus,
772779
]
773780

774781
subcommands = dict(
@@ -794,6 +801,7 @@ class ServerApp(JupyterApp):
794801
"sessions",
795802
"shutdown",
796803
"view",
804+
"events",
797805
)
798806

799807
_log_formatter_cls = LogFormatter
@@ -1561,6 +1569,12 @@ def _default_kernel_spec_manager_class(self):
15611569
),
15621570
)
15631571

1572+
event_bus = Instance(
1573+
EventBus,
1574+
allow_none=True,
1575+
help="An EventBus for emitting structured event data from Jupyter Server and extensions.",
1576+
)
1577+
15641578
info_file = Unicode()
15651579

15661580
@default("info_file")
@@ -1906,6 +1920,10 @@ def init_logging(self):
19061920
logger.parent = self.log
19071921
logger.setLevel(self.log.level)
19081922

1923+
def init_eventbus(self):
1924+
"""Initialize the Event Bus."""
1925+
self.event_bus = EventBus.instance(parent=self)
1926+
19091927
def init_webapp(self):
19101928
"""initialize tornado webapp"""
19111929
self.tornado_settings["allow_origin"] = self.allow_origin
@@ -1970,6 +1988,7 @@ def init_webapp(self):
19701988
self.session_manager,
19711989
self.kernel_spec_manager,
19721990
self.config_manager,
1991+
self.event_bus,
19731992
self.extra_services,
19741993
self.log,
19751994
self.base_url,
@@ -2436,6 +2455,7 @@ def initialize(
24362455
if find_extensions:
24372456
self.find_server_extensions()
24382457
self.init_logging()
2458+
self.init_eventbus()
24392459
self.init_server_extensions()
24402460

24412461
# Special case the starter extension and load
@@ -2762,6 +2782,8 @@ async def _cleanup(self):
27622782
await self.cleanup_kernels()
27632783
if getattr(self, "session_manager", None):
27642784
self.session_manager.close()
2785+
if getattr(self, "event_bus", None):
2786+
self.event_bus.clear_instance()
27652787

27662788
def start_ioloop(self):
27672789
"""Start the IO Loop."""

jupyter_server/services/events/__init__.py

Whitespace-only changes.

jupyter_server/services/events/bus.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""An EventBus for use in the Jupyter server.
2+
3+
.. versionadded:: 2.0
4+
"""
5+
# Copyright (c) Jupyter Development Team.
6+
# Distributed under the terms of the Modified BSD License.
7+
from jupyter_telemetry.eventlog import EventLog
8+
from traitlets.config import SingletonConfigurable
9+
10+
11+
class EventBus(EventLog, SingletonConfigurable):
12+
"""A singleton eventlog that behaves as an event
13+
bus for emitting Jupyter Server (and extension)
14+
event data.
15+
"""
+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""A Websocket Handler for emitting Jupyter server events.
2+
3+
.. versionadded:: 2.0
4+
"""
5+
import logging
6+
7+
from jupyter_telemetry.eventlog import _skip_message
8+
from pythonjsonlogger import jsonlogger
9+
from tornado import web, websocket
10+
11+
from jupyter_server.base.handlers import JupyterHandler
12+
13+
AUTH_RESOURCE = "events"
14+
15+
16+
class WebSocketLoggingHandler(logging.Handler):
17+
"""Python logging handler that routes records to a Tornado websocket."""
18+
19+
def __init__(self, websocket, *args, **kwargs):
20+
super().__init__(*args, **kwargs)
21+
self.websocket = websocket
22+
23+
def emit(self, record):
24+
"""Emit the message across the websocket"""
25+
self.websocket.write_message(record.msg)
26+
27+
28+
class SubscribeWebsocket(
29+
JupyterHandler,
30+
websocket.WebSocketHandler,
31+
):
32+
"""Websocket handler for subscribing to events"""
33+
34+
auth_resource = AUTH_RESOURCE
35+
36+
def pre_get(self):
37+
"""Handles authentication/authorization when
38+
attempting to subscribe to events emitted by
39+
Jupyter Server's eventbus.
40+
"""
41+
# authenticate the request before opening the websocket
42+
user = self.current_user
43+
if user is None:
44+
self.log.warning("Couldn't authenticate WebSocket connection")
45+
raise web.HTTPError(403)
46+
47+
# authorize the user.
48+
if not self.authorizer.is_authorized(self, user, "execute", "events"):
49+
raise web.HTTPError(403)
50+
51+
async def get(self, *args, **kwargs):
52+
self.pre_get()
53+
res = super().get(*args, **kwargs)
54+
await res
55+
56+
@property
57+
def event_bus(self):
58+
"""Jupyter Server's event bus that emits structured event data."""
59+
return self.settings["event_bus"]
60+
61+
def open(self):
62+
"""Routes events that are emitted by Jupyter Server's
63+
EventBus to a WebSocket client in the browser.
64+
"""
65+
self.logging_handler = WebSocketLoggingHandler(self)
66+
# Add a JSON formatter to the handler.
67+
formatter = jsonlogger.JsonFormatter(json_serializer=_skip_message)
68+
self.logging_handler.setFormatter(formatter)
69+
# To do: add an eventlog.add_handler method to jupyter_telemetry.
70+
self.event_bus.log.addHandler(self.logging_handler)
71+
self.event_bus.handlers.append(self.logging_handler)
72+
73+
def on_close(self):
74+
self.event_bus.log.removeHandler(self.logging_handler)
75+
self.event_bus.handlers.remove(self.logging_handler)
76+
77+
78+
default_handlers = [
79+
(r"/api/events/subscribe", SubscribeWebsocket),
80+
]

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ dependencies = [
4242
"tornado>=6.1.0",
4343
"traitlets>=5.1",
4444
"websocket-client",
45+
"jupyter_telemetry"
4546
]
4647

4748
[project.readme]

tests/services/events/__init__.py

Whitespace-only changes.

tests/services/events/mock_event.yaml

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
$id: event.mock.jupyter.com/message
2+
version: 1
3+
title: Message
4+
description: |
5+
Emit a message
6+
type: object
7+
properties:
8+
event_message:
9+
title: Event Messages
10+
description: |
11+
Mock event message to read.
12+
required:
13+
- event_message
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from .mock_extension import _load_jupyter_server_extension # noqa: F401
2+
3+
# Function that makes these extensions discoverable
4+
# by the test functions.
5+
6+
7+
def _jupyter_server_extension_points():
8+
return [
9+
{"module": "tests.services.events.mockextension"},
10+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import pathlib
2+
3+
from jupyter_server.base.handlers import JupyterHandler
4+
from jupyter_server.utils import url_path_join
5+
6+
7+
class MockEventHandler(JupyterHandler):
8+
def get(self):
9+
# Emit an event.
10+
self.event_bus.record_event(
11+
schema_name="event.mockextension.jupyter.com/message",
12+
version=1,
13+
event={"event_message": "Hello world, from mock extension!"},
14+
)
15+
16+
17+
def _load_jupyter_server_extension(serverapp):
18+
# Register a schema with the EventBus
19+
schema_file = pathlib.Path(__file__).parent / "mock_extension_event.yaml"
20+
serverapp.event_bus.register_schema_file(schema_file)
21+
serverapp.web_app.add_handlers(
22+
".*$", [(url_path_join(serverapp.base_url, "/mock/event"), MockEventHandler)]
23+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
$id: event.mockextension.jupyter.com/message
2+
version: 1
3+
title: Message
4+
description: |
5+
Emit a message
6+
type: object
7+
properties:
8+
event_message:
9+
title: Event Message
10+
description: |
11+
Mock event message to read.
12+
required:
13+
- event_message

tests/services/events/test_api.py

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import json
2+
import pathlib
3+
4+
import pytest
5+
6+
7+
@pytest.fixture
8+
def event_bus(jp_serverapp):
9+
event_bus = jp_serverapp.event_bus
10+
# Register the event schema defined in this directory.
11+
schema_file = pathlib.Path(__file__).parent / "mock_event.yaml"
12+
event_bus.register_schema_file(schema_file)
13+
#
14+
event_bus.allowed_schemas = ["event.mock.jupyter.com/message"]
15+
return event_bus
16+
17+
18+
async def test_subscribe_websocket(jp_ws_fetch, event_bus):
19+
# Open a websocket connection.
20+
ws = await jp_ws_fetch("/api/events/subscribe")
21+
22+
event_bus.record_event(
23+
schema_name="event.mock.jupyter.com/message",
24+
version=1,
25+
event={"event_message": "Hello, world!"},
26+
)
27+
message = await ws.read_message()
28+
event_data = json.loads(message)
29+
# Close websocket
30+
ws.close()
31+
32+
assert event_data.get("event_message") == "Hello, world!"
+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import json
2+
3+
import pytest
4+
5+
6+
@pytest.fixture
7+
def jp_server_config():
8+
config = {
9+
"ServerApp": {
10+
"jpserver_extensions": {"tests.services.events.mockextension": True},
11+
},
12+
"EventBus": {"allowed_schemas": ["event.mockextension.jupyter.com/message"]},
13+
}
14+
return config
15+
16+
17+
async def test_subscribe_websocket(jp_ws_fetch, jp_fetch):
18+
# Open an event listener websocket
19+
ws = await jp_ws_fetch("/api/events/subscribe")
20+
21+
# Hit the extension endpoint that emits an event
22+
await jp_fetch("/mock/event")
23+
24+
# Check the event listener for a message
25+
message = await ws.read_message()
26+
event_data = json.loads(message)
27+
28+
# Close websocket
29+
ws.close()
30+
31+
# Verify that an event message was received.
32+
assert event_data.get("event_message") == "Hello world, from mock extension!"

0 commit comments

Comments
 (0)