Skip to content

Commit 90894f6

Browse files
authored
Add mypy type checking for rooms/ module (#133)
* fix all mypy errors under rooms/ * add typing-checks workflow * rename CI job * add type annotation to _rooms_by_id
1 parent cdc24bf commit 90894f6

File tree

5 files changed

+99
-33
lines changed

5 files changed

+99
-33
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Typing Checks
2+
3+
on:
4+
push:
5+
# IMPORTANT: update these paths when we expand type-checking coverage
6+
branches: [main]
7+
paths:
8+
- 'jupyter_server_documents/rooms/**'
9+
- '.github/workflows/typing-checks.yml'
10+
pull_request:
11+
branches: [main]
12+
paths:
13+
- 'jupyter_server_documents/rooms/**'
14+
- '.github/workflows/typing-checks.yml'
15+
16+
concurrency:
17+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
18+
cancel-in-progress: true
19+
20+
jobs:
21+
python:
22+
name: 'Python'
23+
runs-on: ubuntu-latest
24+
strategy:
25+
matrix:
26+
python-version: ['3.10', '3.11', '3.12', '3.13']
27+
28+
steps:
29+
- name: Checkout
30+
uses: actions/checkout@v4
31+
32+
- name: Base Setup
33+
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
34+
with:
35+
python_version: ${{ matrix.python-version }}
36+
37+
- name: Install dependencies
38+
run: |
39+
python -m pip install mypy
40+
python -m pip install -e .[test]
41+
42+
- name: Type-check `rooms` module
43+
run: |
44+
mypy jupyter_server_documents/rooms

jupyter_server_documents/rooms/yroom.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@
1717

1818
if TYPE_CHECKING:
1919
import logging
20-
from typing import Coroutine, Literal, Tuple, Any
20+
from typing import Callable, Coroutine, Literal, Tuple, Any
2121
from .yroom_manager import YRoomManager
22-
from jupyter_server_fileid.manager import BaseFileIdManager
22+
from jupyter_server_fileid.manager import BaseFileIdManager # type: ignore
2323
from jupyter_server.services.contents.manager import ContentsManager
24-
from pycrdt import TransactionEvent
24+
from pycrdt import TransactionEvent, Subscription
2525
from ..outputs.manager import OutputsManager
2626

2727
class YRoom(LoggingConfigurable):
@@ -136,7 +136,7 @@ class YRoom(LoggingConfigurable):
136136
documentation for more info.
137137
"""
138138

139-
_jupyter_ydoc_observers: dict[str, callable[[str, Any], Any]]
139+
_jupyter_ydoc_observers: dict[str, Callable[[str, Any], Any]]
140140
"""
141141
Dictionary of JupyterYDoc observers added by consumers of this room.
142142
@@ -167,10 +167,10 @@ class YRoom(LoggingConfigurable):
167167
`self._message_queue.put_nowait(None)`.
168168
"""
169169

170-
_awareness_subscription: pycrdt.Subscription
170+
_awareness_subscription: str
171171
"""Subscription to awareness changes."""
172172

173-
_ydoc_subscription: pycrdt.Subscription
173+
_ydoc_subscription: Subscription
174174
"""Subscription to YDoc changes."""
175175

176176
_stopped: bool
@@ -346,6 +346,8 @@ async def get_jupyter_ydoc(self) -> YBaseDoc:
346346
message = "There is no Jupyter ydoc for global awareness scenario"
347347
self.log.error(message)
348348
raise Exception(message)
349+
if self._jupyter_ydoc is None:
350+
raise RuntimeError("Jupyter YDoc is not available")
349351
if self.file_api:
350352
await self.file_api.until_content_loaded
351353
return self._jupyter_ydoc
@@ -428,7 +430,7 @@ def handle_message(self, client_id: str, message: bytes) -> None:
428430

429431
# Determine message type & subtype from header
430432
message_type = message[0]
431-
sync_message_subtype = "*"
433+
sync_message_subtype = -1 # invalid sentinel value
432434
# message subtypes only exist on sync messages, hence this condition
433435
if message_type == YMessageType.SYNC and len(message) >= 2:
434436
sync_message_subtype = message[1]
@@ -585,7 +587,7 @@ def _on_ydoc_update(self, event: TransactionEvent) -> None:
585587
self._broadcast_message(message, message_type="SyncUpdate")
586588

587589

588-
def observe_jupyter_ydoc(self, observer: callable[[str, Any], Any]) -> str:
590+
def observe_jupyter_ydoc(self, observer: Callable[[str, Any], Any]) -> str:
589591
"""
590592
Adds an observer callback to the JupyterYDoc that fires on change.
591593
The callback should accept 2 arguments:
@@ -604,8 +606,9 @@ def observe_jupyter_ydoc(self, observer: callable[[str, Any], Any]) -> str:
604606
Returns an `observer_id: str` that can be passed to
605607
`unobserve_jupyter_ydoc()` to remove the observer.
606608
"""
607-
observer_id = uuid.uuid4()
609+
observer_id = str(uuid.uuid4())
608610
self._jupyter_ydoc_observers[observer_id] = observer
611+
return observer_id
609612

610613

611614
def unobserve_jupyter_ydoc(self, observer_id: str):
@@ -745,7 +748,10 @@ def _on_awareness_update(self, type: str, changes: tuple[dict[str, Any], Any]) -
745748
self.log.debug(f"awareness update, updated_clients={updated_clients}")
746749
state = self._awareness.encode_awareness_update(updated_clients)
747750
message = pycrdt.create_awareness_message(state)
748-
self.log.debug(f"awareness update, message={message}")
751+
# !r ensures binary messages show as `b'...'` instead of being decoded
752+
# into jargon in log statements.
753+
# https://docs.python.org/3/library/string.html#format-string-syntax
754+
self.log.debug(f"awareness update, message={message!r}")
749755
self._broadcast_message(message, "AwarenessUpdate")
750756

751757

@@ -827,8 +833,11 @@ def stop(self, close_code: int = 1001, immediately: bool = False) -> None:
827833
self._message_queue.get_nowait()
828834
self._message_queue.task_done()
829835
else:
830-
client_id, message = self._message_queue.get_nowait()
831-
self.handle_message(client_id, message)
836+
queue_item = self._message_queue.get_nowait()
837+
if queue_item is not None:
838+
client_id, message = queue_item
839+
self.handle_message(client_id, message)
840+
self._message_queue.task_done()
832841

833842
# Stop the `_process_message_queue` task by enqueueing `None`
834843
self._message_queue.put_nowait(None)
@@ -924,8 +933,9 @@ def restart(self, close_code: int = 1001, immediately: bool = False) -> None:
924933
self.clients.restart()
925934

926935
# Restart `YRoomFileAPI` & reload the document
927-
self.file_api.restart()
928-
self.file_api.load_content_into(self._jupyter_ydoc)
936+
if self.file_api is not None and self._jupyter_ydoc is not None:
937+
self.file_api.restart()
938+
self.file_api.load_content_into(self._jupyter_ydoc)
929939

930940
# Restart `_process_message_queue()` task
931941
asyncio.create_task(self._process_message_queue())
@@ -952,16 +962,16 @@ def should_ignore_state_update(event: pycrdt.MapEvent) -> bool:
952962
# `False` immediately if:
953963
# - a key was updated to a value different from the previous value
954964
# - a key was added with a value different from the previous value
955-
for key in event.keys.keys():
956-
update_info = event.keys[key]
965+
for key in getattr(event, 'keys', {}).keys():
966+
update_info = getattr(event, 'keys', {})[key]
957967
action = update_info.get('action', None)
958968
if action == 'update':
959969
old_value = update_info.get('oldValue', None)
960970
new_value = update_info.get('newValue', None)
961971
if old_value != new_value:
962972
return False
963973
elif action == "add":
964-
old_value = event.target.get(key, None)
974+
old_value = getattr(event, 'target', {}).get(key, None)
965975
new_value = update_info.get('newValue', None)
966976
if old_value != new_value:
967977
return False

jupyter_server_documents/rooms/yroom_events_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22
from jupyter_events import EventLogger
3-
from jupyter_server_fileid.manager import BaseFileIdManager
3+
from jupyter_server_fileid.manager import BaseFileIdManager # type: ignore
44
from traitlets.config import LoggingConfigurable
55
from typing import TYPE_CHECKING
66

jupyter_server_documents/rooms/yroom_file_api.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
if TYPE_CHECKING:
1313
from typing import Any, Coroutine, Literal
1414
from .yroom import YRoom
15-
from jupyter_server_fileid.manager import BaseFileIdManager
15+
from jupyter_server_fileid.manager import BaseFileIdManager # type: ignore
1616
from jupyter_server.services.contents.manager import ContentsManager
1717
from ..outputs.manager import OutputsManager
1818

@@ -56,7 +56,6 @@ class YRoomFileAPI(LoggingConfigurable):
5656

5757
# See `filemanager.py` in `jupyter_server` for references on supported file
5858
# formats & file types.
59-
room_id: str
6059
file_format: Literal["text", "base64"]
6160
file_type: Literal["file", "notebook"]
6261
file_id: str

jupyter_server_documents/rooms/yroom_manager.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
from __future__ import annotations
22

33
from .yroom import YRoom
4-
from typing import TYPE_CHECKING
4+
from typing import cast, TYPE_CHECKING
55
import asyncio
66
import traitlets
77
from traitlets.config import LoggingConfigurable
8-
from jupyter_server_fileid.manager import BaseFileIdManager
8+
from jupyter_server_fileid.manager import BaseFileIdManager # type: ignore
99

1010
from ..outputs.manager import OutputsManager
1111

1212
if TYPE_CHECKING:
1313
import logging
14+
from typing import Set
1415
from jupyter_server.extension.application import ExtensionApp
1516
from jupyter_server.services.contents.manager import ContentsManager
1617
from jupyter_events import EventLogger
@@ -53,7 +54,7 @@ class YRoomManager(LoggingConfigurable):
5354
this declaration only hints the type for type checkers.
5455
"""
5556

56-
_rooms_by_id: dict[str, YRoom] = traitlets.Dict(default_value={})
57+
_rooms_by_id: traitlets.Dict[str, YRoom] = traitlets.Dict(default_value={})
5758
"""
5859
Dictionary of active `YRoom` instances, keyed by room ID. Rooms are never
5960
deleted from this dictionary.
@@ -62,11 +63,12 @@ class YRoomManager(LoggingConfigurable):
6263
out-of-band. See #116.
6364
"""
6465

65-
_inactive_rooms: set[str] = traitlets.Set()
66+
_inactive_rooms = traitlets.Set()
6667
"""
67-
Set of room IDs that were marked inactive on the last iteration of
68-
`_watch_rooms()`. If a room is inactive and its ID is present in this set,
69-
then the room should be restarted as it has been inactive for >10 seconds.
68+
Set of room IDs (as strings) that were marked inactive on the last iteration
69+
of `_watch_rooms()`. If a room is inactive and its ID is present in this
70+
set, then the room should be restarted as it has been inactive for >10
71+
seconds.
7072
"""
7173

7274
_watch_rooms_task: asyncio.Task | None
@@ -84,23 +86,34 @@ def __init__(self, *args, **kwargs):
8486

8587
@property
8688
def fileid_manager(self) -> BaseFileIdManager:
89+
if self.parent.serverapp is None:
90+
raise RuntimeError("ServerApp is not available")
8791
manager = self.parent.serverapp.web_app.settings.get("file_id_manager", None)
8892
assert isinstance(manager, BaseFileIdManager)
8993
return manager
9094

9195

9296
@property
9397
def contents_manager(self) -> ContentsManager:
98+
if self.parent.serverapp is None:
99+
raise RuntimeError("ServerApp is not available")
94100
return self.parent.serverapp.contents_manager
95101

96102

97103
@property
98104
def event_logger(self) -> EventLogger:
99-
return self.parent.serverapp.event_logger
105+
if self.parent.serverapp is None:
106+
raise RuntimeError("ServerApp is not available")
107+
event_logger = self.parent.serverapp.event_logger
108+
if event_logger is None:
109+
raise RuntimeError("Event logger is not available")
110+
return event_logger
100111

101112

102113
@property
103114
def outputs_manager(self) -> OutputsManager:
115+
if not hasattr(self.parent, 'outputs_manager'):
116+
raise RuntimeError("Outputs manager is not available")
104117
return self.parent.outputs_manager
105118

106119

@@ -156,17 +169,17 @@ def delete_room(self, room_id: str) -> None:
156169
"""
157170
yroom = self._rooms_by_id.pop(room_id, None)
158171
if not yroom:
159-
return
172+
return None
160173

161174
self.log.info(f"Stopping YRoom '{room_id}'.")
162175
try:
163176
yroom.stop()
164-
return True
177+
return None
165178
except Exception as e:
166179
self.log.exception(
167180
f"Exception raised when stopping YRoom '{room_id}: "
168181
)
169-
return False
182+
return None
170183

171184

172185
async def _watch_rooms(self) -> None:
@@ -274,9 +287,9 @@ async def stop(self) -> None:
274287

275288
# Define task that deletes the room and waits until the content is saved
276289
async def delete_then_save(room_id: str, room: YRoom):
277-
ret = self.delete_room(room_id)
290+
self.delete_room(room_id)
278291
await room.until_saved
279-
return ret
292+
return None
280293

281294
# Delete all rooms concurrently using `delete_then_save()`
282295
for room_id, room in self._rooms_by_id.items():

0 commit comments

Comments
 (0)