Skip to content

Commit 73cc5c7

Browse files
committed
add jupyter_events integration
1 parent b3c27c6 commit 73cc5c7

File tree

8 files changed

+236
-8
lines changed

8 files changed

+236
-8
lines changed

jupyter_server_documents/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111

1212
from .app import ServerDocsApp
13+
from .events import JSD_AWARENESS_EVENT_URI, JSD_ROOM_EVENT_URI
1314

1415

1516
def _jupyter_labextension_paths():

jupyter_server_documents/app.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .websockets import YRoomWebsocket
88
from .rooms.yroom_manager import YRoomManager
99
from .outputs import OutputsManager, outputs_handlers
10+
from .events import JSD_AWARENESS_EVENT_SCHEMA, JSD_ROOM_EVENT_SCHEMA
1011

1112
class ServerDocsApp(ExtensionApp):
1213
name = "jupyter_server_documents"
@@ -51,6 +52,10 @@ def initialize(self):
5152
super().initialize()
5253

5354
def initialize_settings(self):
55+
# Register event schemas
56+
self.serverapp.event_logger.register_event_schema(JSD_ROOM_EVENT_SCHEMA)
57+
self.serverapp.event_logger.register_event_schema(JSD_AWARENESS_EVENT_SCHEMA)
58+
5459
# Get YRoomManager arguments from server extension context.
5560
# We cannot access the 'file_id_manager' key immediately because server
5661
# extensions initialize in alphabetical order. 'jupyter_server_documents' <
@@ -65,10 +70,11 @@ def get_fileid_manager():
6570
self.settings["yroom_manager"] = YRoomManager(
6671
get_fileid_manager=get_fileid_manager,
6772
contents_manager=contents_manager,
73+
event_logger=self.serverapp.event_logger,
6874
loop=loop,
6975
log=log
7076
)
71-
77+
7278
# Initialize OutputsManager
7379
self.outputs_manager = self.outputs_manager_class(config=self.config)
7480
self.settings["outputs_manager"] = self.outputs_manager
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from pathlib import Path
2+
from typing import Literal
3+
4+
EVENTS_DIR = Path(__file__).parent
5+
6+
JSD_ROOM_EVENT_URI = "https://schema.jupyter.org/jupyter_server_documents/room/v1"
7+
JSD_AWARENESS_EVENT_URI = "https://schema.jupyter.org/jupyter_server_documents/awareness/v1"
8+
9+
JSD_ROOM_EVENT_SCHEMA = EVENTS_DIR / "room.yaml"
10+
JSD_AWARENESS_EVENT_SCHEMA = EVENTS_DIR / "awareness.yaml"
11+
12+
type EventLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
13+
14+
type RoomAction = Literal["initialize", "load", "save", "overwrite", "clean"]
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"$id": https://schema.jupyter.org/jupyter_server_documents/awareness/v1
2+
"$schema": "http://json-schema.org/draft-07/schema"
3+
version: "1"
4+
title: Collaborative awareness events
5+
personal-data: true
6+
description: |
7+
Awareness events emitted from server-side during a collaborative session.
8+
type: object
9+
required:
10+
- roomid
11+
- username
12+
- action
13+
properties:
14+
roomid:
15+
type: string
16+
description: |
17+
Room ID. Usually composed by the file type, format and ID.
18+
username:
19+
type: string
20+
description: |
21+
The name of the user who joined or left room.
22+
action:
23+
enum:
24+
- join
25+
- leave
26+
description: |
27+
Possible values:
28+
1. join
29+
2. leave
30+
msg:
31+
type: string
32+
description: |
33+
Optional event message.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"$id": https://schema.jupyter.org/jupyter_server_documents/room/v1
2+
"$schema": "http://json-schema.org/draft-07/schema"
3+
version: "1"
4+
title: Room events
5+
personal-data: true
6+
description: |
7+
An event emitted by a collaboration room.
8+
type: object
9+
required:
10+
- level
11+
- room
12+
- path
13+
properties:
14+
level:
15+
enum:
16+
- INFO
17+
- DEBUG
18+
- WARNING
19+
- ERROR
20+
- CRITICAL
21+
description: |
22+
Message type.
23+
room:
24+
type: string
25+
description: |
26+
Room ID. This is a composite ID that takes the format `{file_type}:{file_format}:{file_id}`.
27+
path:
28+
type: string
29+
description: |
30+
Path of the file.
31+
store:
32+
type: string
33+
description: |
34+
The store used to track the document history.
35+
action:
36+
enum:
37+
- initialize
38+
- load
39+
- save
40+
- overwrite
41+
- clean
42+
description: |
43+
Action performed in a room during a collaborative session.
44+
Possible values:
45+
1. initialize
46+
Initialize a room by loading the content from the contents manager or a store.
47+
2. load
48+
Load the content from the contents manager.
49+
3. save
50+
Save the content with the contents manager.
51+
4. overwrite
52+
Overwrite the content in a room with content from the contents manager.
53+
This can happen when multiple rooms access the same file or when a user
54+
modifies the file outside Jupyter Server (e.g. using a different app).
55+
5. clean
56+
Clean the room once is empty (aka there is no more users connected to it).
57+
msg:
58+
type: string
59+
description: |
60+
Event message.

jupyter_server_documents/rooms/yroom.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from pycrdt import YMessageType, YSyncMessageType as YSyncMessageSubtype
99
from jupyter_server_documents.ydocs import ydocs as jupyter_ydoc_classes
1010
from jupyter_ydoc.ybasedoc import YBaseDoc
11+
from jupyter_events import EventLogger
1112
from tornado.websocket import WebSocketHandler
1213
from .yroom_file_api import YRoomFileAPI
14+
from .yroom_events_api import YRoomEventsAPI
1315

1416
if TYPE_CHECKING:
1517
from typing import Literal, Tuple, Any
@@ -32,7 +34,7 @@ class YRoom:
3234

3335
file_api: YRoomFileAPI | None
3436
"""
35-
The `YRoomFileAPI` instance for this room. This is set to `None` if & only
37+
The `YRoomFileAPI` instance for this room. This is set to `None` only
3638
if `self.room_id == "JupyterLab:globalAwareness"`.
3739
3840
The file API provides `load_ydoc_content()` for loading the YDoc content
@@ -41,8 +43,16 @@ class YRoom:
4143
out-of-band changes.
4244
"""
4345

46+
events_api: YRoomEventsAPI | None
47+
"""
48+
A `YRoomEventsAPI` instance for this room that provides methods for emitting
49+
events through the `jupyter_events.EventLogger` singleton. This is set to
50+
`None` only if `self.room_id == "JupyterLab:globalAwareness"`.
51+
"""
52+
4453
_jupyter_ydoc: YBaseDoc | None
4554
"""JupyterYDoc"""
55+
4656
_ydoc: pycrdt.Doc
4757
"""Ydoc"""
4858
_awareness: pycrdt.Awareness
@@ -78,6 +88,7 @@ def __init__(
7888
loop: asyncio.AbstractEventLoop,
7989
fileid_manager: BaseFileIdManager,
8090
contents_manager: AsyncContentsManager | ContentsManager,
91+
event_logger: EventLogger
8192
):
8293
# Bind instance attributes
8394
self.room_id = room_id
@@ -91,14 +102,18 @@ def __init__(
91102
self._ydoc = pycrdt.Doc()
92103
self._awareness = pycrdt.Awareness(ydoc=self._ydoc)
93104

94-
# If this room is providing global awareness, set `file_api` and
95-
# `_jupyter_ydoc` to `None` as the YDoc is unused.
105+
# If this room is providing global awareness, set unused optional
106+
# attributes to `None`.
96107
if self.room_id == "JupyterLab:globalAwareness":
97108
self.file_api = None
98109
self._jupyter_ydoc = None
110+
self.events_api = None
99111
else:
100-
# Otherwise, initialize `_jupyter_ydoc` and `file_api`.
112+
# Otherwise, initialize optional attributes for document rooms
113+
# Initialize JupyterYDoc
101114
self._jupyter_ydoc = self._init_jupyter_ydoc()
115+
116+
# Initialize YRoomFileAPI, start loading content
102117
self.file_api = YRoomFileAPI(
103118
room_id=self.room_id,
104119
jupyter_ydoc=self._jupyter_ydoc,
@@ -108,12 +123,18 @@ def __init__(
108123
contents_manager=self._contents_manager,
109124
on_outofband_change=self.reload_ydoc
110125
)
111-
112-
# Load the YDoc content after initializing
113126
self.file_api.load_ydoc_content()
114127

115128
# Attach Jupyter YDoc observer to automatically save on change
116129
self._jupyter_ydoc.observe(self._on_jupyter_ydoc_update)
130+
131+
# Initialize YRoomEventsAPI
132+
self.events_api = YRoomEventsAPI(
133+
event_logger=event_logger,
134+
fileid_manager=fileid_manager,
135+
room_id=self.room_id,
136+
log=self.log,
137+
)
117138

118139
# Start observers on `self.ydoc` and `self.awareness` to ensure new
119140
# updates are always broadcast to all clients.
@@ -131,6 +152,18 @@ def __init__(
131152

132153
# Log notification that room is ready
133154
self.log.info(f"Room '{self.room_id}' initialized.")
155+
156+
# Emit events if defined
157+
if self.events_api:
158+
# Emit 'initialize' event
159+
self.events_api.emit_room_event("initialize")
160+
161+
# Emit 'load' event once content is loaded
162+
assert self.file_api
163+
async def emit_load_event():
164+
await self.file_api.ydoc_content_loaded.wait()
165+
self.events_api.emit_room_event("load")
166+
self._loop.create_task(emit_load_event())
134167

135168

136169
def _init_jupyter_ydoc(self) -> YBaseDoc:
@@ -154,7 +187,7 @@ def _init_jupyter_ydoc(self) -> YBaseDoc:
154187
jupyter_ydoc_classes.get(file_type, jupyter_ydoc_classes["file"])
155188
)
156189

157-
# Initialize Jupyter YDoc, add an observer to save it on change, return
190+
# Initialize Jupyter YDoc and return it
158191
jupyter_ydoc = JupyterYDocClass(ydoc=self._ydoc, awareness=self._awareness)
159192
return jupyter_ydoc
160193

@@ -602,6 +635,10 @@ def reload_ydoc(self) -> None:
602635
)
603636
self._jupyter_ydoc.observe(self._on_jupyter_ydoc_update)
604637

638+
# Emit 'overwrite' event as the YDoc content has been overwritten
639+
if self.events_api:
640+
self.events_api.emit_room_event("overwrite")
641+
605642

606643
async def stop(self) -> None:
607644
"""
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from jupyter_events import EventLogger
2+
from ..events import JSD_ROOM_EVENT_URI, EventLevel, RoomAction
3+
from typing import Optional
4+
from jupyter_server_fileid.manager import BaseFileIdManager
5+
from logging import Logger
6+
7+
8+
class YRoomEventsAPI:
9+
"""
10+
Class that provides an API to emit events to the
11+
`jupyter_events.EventLogger` singleton in `jupyter_server`.
12+
13+
JSD room and awareness events have the same structure as
14+
`jupyter_collaboration` v4 session and awareness events. The only difference
15+
is that JSD emits to a different schema ID/URI. This is defined in the `$id`
16+
property of the corresponding event schema.
17+
18+
The event schemas must be registered via
19+
`event_logger.register_event_schema()` in advance. This should be done when
20+
the server extension initializes.
21+
"""
22+
23+
_event_logger: EventLogger
24+
_fileid_manager: BaseFileIdManager
25+
room_id: str
26+
log: Logger
27+
28+
def __init__(self, event_logger: EventLogger, fileid_manager: BaseFileIdManager, room_id: str, log: Logger):
29+
self._event_logger = event_logger
30+
self._fileid_manager = fileid_manager
31+
self.room_id = room_id
32+
self.log = log
33+
34+
def emit_room_event(
35+
self,
36+
action: RoomAction,
37+
level: Optional[EventLevel] = "INFO"
38+
):
39+
"""
40+
Emits a room event. This method is guaranteed to log any caught
41+
exceptions and never raise them to the `YRoom`.
42+
"""
43+
try:
44+
path = self._get_path()
45+
event_data = {
46+
"level": level,
47+
"room": self.room_id,
48+
"path": path,
49+
"action": action
50+
}
51+
self._event_logger.emit(schema_id=JSD_ROOM_EVENT_URI, data=event_data)
52+
except:
53+
self.log.exception("Exception occurred when emitting a room event.")
54+
55+
def emit_awareness_event(self):
56+
"""
57+
TODO
58+
"""
59+
pass
60+
61+
62+
def _get_path(self) -> str:
63+
"""
64+
Returns the relative path to the file by querying the FileIdManager. The
65+
path is relative to the `ServerApp.root_dir` configurable trait.
66+
"""
67+
# Query for the path from the file ID in the room ID
68+
file_id = self.room_id.split(":")[-1]
69+
rel_path = self._fileid_manager.get_path(file_id)
70+
71+
# Raise exception if the path could not be found, then return
72+
assert rel_path is not None
73+
return rel_path

jupyter_server_documents/rooms/yroom_manager.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import logging
99
from jupyter_server_fileid.manager import BaseFileIdManager
1010
from jupyter_server.services.contents.manager import AsyncContentsManager, ContentsManager
11+
from jupyter_events import EventLogger
1112

1213
class YRoomManager():
1314
_rooms_by_id: dict[str, YRoom]
@@ -17,12 +18,14 @@ def __init__(
1718
*,
1819
get_fileid_manager: callable[[], BaseFileIdManager],
1920
contents_manager: AsyncContentsManager | ContentsManager,
21+
event_logger: EventLogger,
2022
loop: asyncio.AbstractEventLoop,
2123
log: logging.Logger,
2224
):
2325
# Bind instance attributes
2426
self._get_fileid_manager = get_fileid_manager
2527
self.contents_manager = contents_manager
28+
self.event_logger = event_logger
2629
self.loop = loop
2730
self.log = log
2831

@@ -54,6 +57,7 @@ def get_room(self, room_id: str) -> YRoom | None:
5457
loop=self.loop,
5558
fileid_manager=self.fileid_manager,
5659
contents_manager=self.contents_manager,
60+
event_logger=self.event_logger,
5761
)
5862
self._rooms_by_id[room_id] = yroom
5963
return yroom

0 commit comments

Comments
 (0)