Skip to content

Commit b122d68

Browse files
committed
Workish version for RTC
1 parent 5d9752d commit b122d68

File tree

7 files changed

+256
-45
lines changed

7 files changed

+256
-45
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,4 @@ dmypy.json
100100

101101
# OSX files
102102
.DS_Store
103+
.jupyter_ystore.db

conftest.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
@pytest.fixture
66
def jp_server_config(jp_server_config):
7-
return {"ServerApp": {"jpserver_extensions": {"jupyter_server_nbmodel": True}}}
7+
return {"ServerApp": {"jpserver_extensions": {"jupyter_server_nbmodel": True, "jupyter_server_ydoc": True}}}

examples/basic.ipynb

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": null,
6+
"id": "044d1f3a-eaba-4d9a-a64c-c3ab985f29c7",
7+
"metadata": {},
8+
"outputs": [],
9+
"source": [
10+
"a = 1"
11+
]
12+
},
13+
{
14+
"cell_type": "code",
15+
"execution_count": null,
16+
"id": "99fbe184-4e8b-431e-a790-b6bb39401e87",
17+
"metadata": {},
18+
"outputs": [],
19+
"source": [
20+
"print(\"hello\")"
21+
]
22+
},
23+
{
24+
"cell_type": "code",
25+
"execution_count": null,
26+
"id": "b0b4663b-e3c4-4eb3-bbf6-ab90fcedbb4d",
27+
"metadata": {},
28+
"outputs": [],
29+
"source": [
30+
"from IPython.display import HTML\n",
31+
"\n",
32+
"HTML(\"<p><b>Jupyter</b> rocks</p>\")"
33+
]
34+
},
35+
{
36+
"cell_type": "code",
37+
"execution_count": null,
38+
"id": "6fb3ac3f-7633-4a7c-a2ba-688549f1d344",
39+
"metadata": {},
40+
"outputs": [],
41+
"source": [
42+
"1 / 0"
43+
]
44+
},
45+
{
46+
"cell_type": "code",
47+
"execution_count": null,
48+
"id": "d4be79ea-518a-4f82-859a-a8674612a717",
49+
"metadata": {},
50+
"outputs": [],
51+
"source": [
52+
"# FIXME\n",
53+
"from time import sleep\n",
54+
"\n",
55+
"for i in range(10):\n",
56+
" sleep(0.1)\n",
57+
" print(i)"
58+
]
59+
},
60+
{
61+
"cell_type": "code",
62+
"execution_count": null,
63+
"id": "e932c6fc-c453-4e04-92f3-c3748fb4ed8c",
64+
"metadata": {},
65+
"outputs": [],
66+
"source": [
67+
"# FIXME\n",
68+
"# input(\"Age: \")"
69+
]
70+
},
71+
{
72+
"cell_type": "code",
73+
"execution_count": null,
74+
"id": "57662578-d6d0-466a-ad89-33b2b2e28f29",
75+
"metadata": {},
76+
"outputs": [],
77+
"source": []
78+
}
79+
],
80+
"metadata": {
81+
"kernelspec": {
82+
"display_name": "Python 3 (ipykernel)",
83+
"language": "python",
84+
"name": "python3"
85+
},
86+
"language_info": {
87+
"codemirror_mode": {
88+
"name": "ipython",
89+
"version": 3
90+
},
91+
"file_extension": ".py",
92+
"mimetype": "text/x-python",
93+
"name": "python",
94+
"nbconvert_exporter": "python",
95+
"pygments_lexer": "ipython3",
96+
"version": "3.12.3"
97+
}
98+
},
99+
"nbformat": 4,
100+
"nbformat_minor": 5
101+
}

jupyter_server_config.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
c.YDocExtension.server_side_execution = True

jupyter_server_nbmodel/extension.py

+15-6
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
from __future__ import annotations
22

3-
import typing as t
4-
5-
import tornado
63
from jupyter_server.extension.application import ExtensionApp
74
from jupyter_server.services.kernels.handlers import _kernel_id_regex
85

96
from .handlers import ExecuteHandler
7+
from .log import get_logger
8+
9+
RTC_EXTENSIONAPP_NAME = "jupyter_server_ydoc"
1010

1111

1212
class Extension(ExtensionApp):
1313
name = "jupyter_server_nbmodel"
14-
handlers: t.ClassVar[list[tuple[str, tornado.web.RequestHandler]]] = [
15-
(f"/api/kernels/{_kernel_id_regex}/execute", ExecuteHandler)
16-
]
14+
15+
def initialize_handlers(self):
16+
rtc_extension = None
17+
rtc_extensions = self.serverapp.extension_manager.extension_apps.get(RTC_EXTENSIONAPP_NAME, set())
18+
n_extensions = len(rtc_extensions)
19+
if n_extensions:
20+
if n_extensions > 1:
21+
get_logger().warning("%i collaboration extensions found.", n_extensions)
22+
rtc_extension = next(iter(rtc_extensions))
23+
self.handlers.extend([
24+
(f"/api/kernels/{_kernel_id_regex}/execute", ExecuteHandler, { "ydoc_extension": rtc_extension })
25+
])

jupyter_server_nbmodel/handlers.py

+100-9
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import json
24
import typing as t
35
from http import HTTPStatus
@@ -10,11 +12,28 @@
1012

1113
from .log import get_logger
1214

15+
if t.TYPE_CHECKING:
16+
try:
17+
import pycrdt as y
18+
import jupyter_server_ydoc
19+
from jupyter_ydoc.ynotebook import YNotebook
20+
except ImportError:
21+
# optional dependencies
22+
...
23+
1324

1425
class ExecuteHandler(ExtensionHandlerMixin, APIHandler):
15-
def initialize(self, name: str, *args: t.Any, **kwargs: t.Any) -> None:
26+
def initialize(
27+
self,
28+
name: str,
29+
ydoc_extension: "jupyter_server_ydoc.app.YDocExtension" | None,
30+
*args: t.Any,
31+
**kwargs: t.Any,
32+
) -> None:
1633
super().initialize(name, *args, **kwargs)
34+
self._ydoc = ydoc_extension
1735
self._outputs = []
36+
self._ycell: y.Map | None = None
1837

1938
@tornado.web.authenticated
2039
async def post(self, kernel_id: str) -> None:
@@ -33,8 +52,59 @@ async def post(self, kernel_id: str) -> None:
3352
cell_id (str): to-execute cell identifier
3453
"""
3554
body = self.get_json_body()
36-
# FIXME support loading from RTC
37-
snippet = body["code"]
55+
56+
snippet = body.get("code")
57+
# From RTC model
58+
if snippet is None:
59+
document_id = body.get("document_id")
60+
cell_id = body.get("cell_id")
61+
62+
if document_id is None or cell_id is None:
63+
msg = "Either code or document_id and cell_id must be defined in the request body."
64+
get_logger().error(msg)
65+
raise tornado.web.HTTPError(
66+
status_code=HTTPStatus.BAD_REQUEST,
67+
reason=msg,
68+
)
69+
70+
if self._ydoc is None:
71+
msg = "jupyter-collaboration extension is not installed on the server."
72+
get_logger().error(msg)
73+
raise tornado.web.HTTPError(
74+
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, reason=msg
75+
)
76+
77+
notebook: YNotebook = await self._ydoc.get_document(document_id=document_id, copy=False)
78+
79+
if notebook is None:
80+
msg = f"Document with ID {document_id} not found."
81+
get_logger().error(msg)
82+
raise tornado.web.HTTPError(status_code=HTTPStatus.NOT_FOUND, reason=msg)
83+
84+
ycells = filter(lambda c: c["id"] == cell_id, notebook.ycells)
85+
try:
86+
self._ycell = next(ycells)
87+
except StopIteration:
88+
msg = f"Cell with ID {cell_id} not found in document {document_id}."
89+
get_logger().error(msg)
90+
raise tornado.web.HTTPError(status_code=HTTPStatus.NOT_FOUND, reason=msg) # noqa: B904
91+
else:
92+
# Check if there is more than one cell
93+
try:
94+
next(ycells)
95+
except StopIteration:
96+
get_logger().warning("Multiple cells have the same ID '%s'.", cell_id)
97+
98+
if self._ycell["cell_type"] != "code":
99+
msg = f"Cell with ID {cell_id} of document {document_id} is not of type code."
100+
get_logger().error(msg)
101+
raise tornado.web.HTTPError(
102+
status_code=HTTPStatus.BAD_REQUEST,
103+
reason=msg,
104+
)
105+
106+
snippet = str(self._ycell["source"])
107+
38108
try:
39109
km = self.kernel_manager.get_kernel(kernel_id)
40110
except KeyError as e:
@@ -44,7 +114,13 @@ async def post(self, kernel_id: str) -> None:
44114

45115
client = km.client()
46116

117+
if self._ycell is not None:
118+
# Reset cell
119+
del self._ycell["outputs"][:]
120+
self._ycell["execution_count"] = None
121+
47122
# FIXME set the username of client.session to server user
123+
# FIXME we don't check if the session is consistent (aka the kernel is linked to the document) - should we?
48124
try:
49125
reply = await ensure_async(
50126
client.execute_interactive(
@@ -56,22 +132,37 @@ async def post(self, kernel_id: str) -> None:
56132

57133
reply_content = reply["content"]
58134

59-
self.finish({
60-
"status": reply_content["status"],
61-
"execution_count": reply_content["execution_count"],
62-
# FIXME quid for buffers
63-
"outputs": json.dumps(self._outputs)
64-
})
135+
if self._ycell is not None:
136+
self._ycell["execution_count"] = reply_content["execution_count"]
137+
138+
self.finish(
139+
{
140+
"status": reply_content["status"],
141+
"execution_count": reply_content["execution_count"],
142+
# FIXME quid for buffers
143+
"outputs": json.dumps(self._outputs),
144+
}
145+
)
146+
65147
finally:
66148
self._outputs.clear()
149+
self._ycell = None
67150
del client
68151

69152
def _output_hook(self, msg) -> None:
70153
msg_type = msg["header"]["msg_type"]
71154
if msg_type in ("display_data", "stream", "execute_result", "error"):
155+
# FIXME support for version
72156
output = nbformat.v4.output_from_msg(msg)
73157
get_logger().info("Got an output. %s", output)
74158
self._outputs.append(output)
75159

160+
if self._ycell is not None:
161+
# FIXME support for 'stream'
162+
outputs = self._ycell["outputs"]
163+
with outputs.doc.transaction():
164+
outputs.append(output)
165+
166+
76167
def _stdin_hook(self, msg) -> None:
77168
get_logger().info("Code snippet execution is waiting for an input.")

pyproject.toml

+37-29
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,32 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "jupyter_server_nbmodel"
7-
authors = [{name = "Datalayer"}]
7+
authors = [{ name = "Datalayer" }]
88
dynamic = ["version"]
99
readme = "README.md"
1010
requires-python = ">=3.8"
1111
keywords = ["Jupyter", "Extension"]
1212
classifiers = [
13-
"License :: OSI Approved :: BSD License",
14-
"Programming Language :: Python",
15-
"Programming Language :: Python :: 3",
16-
"Programming Language :: Python :: 3.8",
17-
"Programming Language :: Python :: 3.9",
18-
"Programming Language :: Python :: 3.10",
19-
"Programming Language :: Python :: 3.11",
20-
"Programming Language :: Python :: 3.12",
21-
"Framework :: Jupyter",
13+
"License :: OSI Approved :: BSD License",
14+
"Programming Language :: Python",
15+
"Programming Language :: Python :: 3",
16+
"Programming Language :: Python :: 3.8",
17+
"Programming Language :: Python :: 3.9",
18+
"Programming Language :: Python :: 3.10",
19+
"Programming Language :: Python :: 3.11",
20+
"Programming Language :: Python :: 3.12",
21+
"Framework :: Jupyter",
2222
]
2323
dependencies = ["jupyter_server>=1.6,<3"]
2424

2525
[project.optional-dependencies]
26-
test = [
27-
"pytest~=7.0",
28-
"pytest-jupyter[server]>=0.6",
29-
"pytest-timeout",
30-
]
31-
lint = [
32-
"mdformat>0.7",
33-
"mdformat-gfm>=0.3.5",
34-
"ruff>=0.4.0"
35-
]
26+
rtc = ["jupyterlab>=4.2.0", "jupyter_collaboration>=3.0.0a0"]
27+
test = ["pytest~=7.0", "pytest-jupyter[server]>=0.6", "pytest-timeout"]
28+
lint = ["mdformat>0.7", "mdformat-gfm>=0.3.5", "ruff>=0.4.0"]
3629
typing = ["mypy>=0.990"]
3730

3831
[project.license]
39-
file="LICENSE"
32+
file = "LICENSE"
4033

4134
[project.urls]
4235
Home = "https://github.com/datalayer/jupyter-server-nbmodel"
@@ -77,16 +70,31 @@ skip-string-normalization = true
7770
target-version = "py38"
7871
line-length = 100
7972
select = [
80-
"A", "B", "C", "E", "F", "FBT", "I", "N", "Q", "RUF", "S", "T",
81-
"UP", "W", "YTT",
73+
"A",
74+
"B",
75+
"C",
76+
"E",
77+
"F",
78+
"FBT",
79+
"I",
80+
"N",
81+
"Q",
82+
"RUF",
83+
"S",
84+
"T",
85+
"UP",
86+
"W",
87+
"YTT",
8288
]
8389
ignore = [
84-
# Q000 Single quotes found but double quotes preferred
85-
"Q000",
86-
# FBT001 Boolean positional arg in function definition
87-
"FBT001", "FBT002", "FBT003",
88-
# C901 `foo` is too complex (12)
89-
"C901",
90+
# Q000 Single quotes found but double quotes preferred
91+
"Q000",
92+
# FBT001 Boolean positional arg in function definition
93+
"FBT001",
94+
"FBT002",
95+
"FBT003",
96+
# C901 `foo` is too complex (12)
97+
"C901",
9098
]
9199

92100
[tool.ruff.per-file-ignores]

0 commit comments

Comments
 (0)