Skip to content

Commit 7be45fd

Browse files
Copilotedvilme
andcommitted
Add Jupyter notebook cell support via LSP 3.17 Notebook Document Sync
Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com>
1 parent 5fc0a1c commit 7be45fd

7 files changed

Lines changed: 560 additions & 9 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,47 @@ References, to other extension created by our team using the template:
7171
- Implementation showing how to handle Formatting. [Black Formatter](https://github.com/microsoft/vscode-black-formatter/tree/main/bundled/tool)
7272
- Implementation showing how to handle Code Actions. [isort](https://github.com/microsoft/vscode-isort/blob/main/bundled/tool)
7373

74+
## Jupyter Notebook Support
75+
76+
This template includes built-in support for linting and formatting Python cells inside Jupyter notebooks (`.ipynb` files) and the VS Code Interactive Window, following the [LSP 3.17 Notebook Document Sync specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#notebookDocument_synchronization).
77+
78+
### How it works
79+
80+
The server declares `NotebookDocumentSyncOptions` (in `lsp_server.py`) that tell the client which notebook types and cell languages to synchronize:
81+
82+
```python
83+
NOTEBOOK_SYNC_OPTIONS = lsp.NotebookDocumentSyncOptions(
84+
notebook_selector=[
85+
lsp.NotebookDocumentFilterWithNotebook(
86+
notebook="jupyter-notebook",
87+
cells=[lsp.NotebookCellLanguage(language="python")],
88+
),
89+
lsp.NotebookDocumentFilterWithNotebook(
90+
notebook="interactive",
91+
cells=[lsp.NotebookCellLanguage(language="python")],
92+
),
93+
],
94+
save=True,
95+
)
96+
```
97+
98+
Four notebook lifecycle handlers are registered:
99+
100+
- `notebookDocument/didOpen` — diagnostics are published for every Python code cell when a notebook is opened.
101+
- `notebookDocument/didChange` — diagnostics are updated for cells whose text changed, new cells are linted, and removed cells have their diagnostics cleared.
102+
- `notebookDocument/didSave` — all Python code cells are re-linted on save.
103+
- `notebookDocument/didClose` — diagnostics are cleared for all cells when the notebook is closed.
104+
105+
The `_get_document_path` helper resolves `vscode-notebook-cell:` URIs back to the parent notebook's filesystem path, so your tool receives a valid path even when processing a cell document.
106+
107+
### Customizing notebook support
108+
109+
- To disable notebook support entirely, remove the `notebook_document_sync=NOTEBOOK_SYNC_OPTIONS` argument from the `LanguageServer` constructor and delete the four `notebook_did_*` handlers.
110+
- To restrict which cell languages are supported, update the `cells` list in `NOTEBOOK_SYNC_OPTIONS`.
111+
- To change the linting/formatting behavior per cell, update the individual handlers.
112+
113+
See [microsoft/vscode-isort#565](https://github.com/microsoft/vscode-isort/pull/565) for a reference implementation of notebook support in a production extension.
114+
74115
## Building and Run the extension
75116

76117
Run the `Debug Extension and Python` configuration form VS Code. That should build and debug the extension in host window.

bundled/tool/lsp_server.py

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,25 @@ def update_sys_path(path_to_add: str, strategy: str) -> None:
4646
RUNNER = pathlib.Path(__file__).parent / "lsp_runner.py"
4747

4848
MAX_WORKERS = 5
49+
NOTEBOOK_SYNC_OPTIONS = lsp.NotebookDocumentSyncOptions(
50+
notebook_selector=[
51+
lsp.NotebookDocumentFilterWithNotebook(
52+
notebook="jupyter-notebook",
53+
cells=[lsp.NotebookCellLanguage(language="python")],
54+
),
55+
lsp.NotebookDocumentFilterWithNotebook(
56+
notebook="interactive",
57+
cells=[lsp.NotebookCellLanguage(language="python")],
58+
),
59+
],
60+
save=True,
61+
)
4962
# TODO: Update the language server name and version.
5063
LSP_SERVER = server.LanguageServer(
51-
name="<pytool-display-name>", version="<server version>", max_workers=MAX_WORKERS
64+
name="<pytool-display-name>",
65+
version="<server version>",
66+
max_workers=MAX_WORKERS,
67+
notebook_document_sync=NOTEBOOK_SYNC_OPTIONS,
5268
)
5369

5470

@@ -112,6 +128,103 @@ def did_close(params: lsp.DidCloseTextDocumentParams) -> None:
112128
LSP_SERVER.publish_diagnostics(document.uri, [])
113129

114130

131+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_OPEN)
132+
def notebook_did_open(params: lsp.DidOpenNotebookDocumentParams) -> None:
133+
"""LSP handler for notebookDocument/didOpen request."""
134+
nb = LSP_SERVER.workspace.get_notebook_document(
135+
notebook_uri=params.notebook_document.uri
136+
)
137+
if nb is None:
138+
return
139+
for cell in nb.cells:
140+
if cell.kind != lsp.NotebookCellKind.Code or cell.document is None:
141+
continue
142+
document = LSP_SERVER.workspace.get_text_document(cell.document)
143+
diagnostics: list[lsp.Diagnostic] = _linting_helper(document)
144+
LSP_SERVER.text_document_publish_diagnostics(
145+
lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics)
146+
)
147+
148+
149+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_CHANGE)
150+
def notebook_did_change(params: lsp.DidChangeNotebookDocumentParams) -> None:
151+
"""LSP handler for notebookDocument/didChange request."""
152+
nb = LSP_SERVER.workspace.get_notebook_document(
153+
notebook_uri=params.notebook_document.uri
154+
)
155+
if nb is None:
156+
return
157+
158+
change = params.change
159+
# Re-lint cells whose text content changed.
160+
if change.cells and change.cells.text_content:
161+
for text_change in change.cells.text_content:
162+
document = LSP_SERVER.workspace.get_text_document(
163+
text_change.document.uri
164+
)
165+
diagnostics: list[lsp.Diagnostic] = _linting_helper(document)
166+
LSP_SERVER.text_document_publish_diagnostics(
167+
lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics)
168+
)
169+
170+
# Lint newly added cells.
171+
if change.cells and change.cells.structure and change.cells.structure.did_open:
172+
for cell_doc in change.cells.structure.did_open:
173+
document = LSP_SERVER.workspace.get_text_document(cell_doc.uri)
174+
diagnostics = _linting_helper(document)
175+
LSP_SERVER.text_document_publish_diagnostics(
176+
lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics)
177+
)
178+
179+
# Clear diagnostics for removed cells.
180+
if change.cells and change.cells.structure and change.cells.structure.did_close:
181+
for cell_doc in change.cells.structure.did_close:
182+
LSP_SERVER.text_document_publish_diagnostics(
183+
lsp.PublishDiagnosticsParams(uri=cell_doc.uri, diagnostics=[])
184+
)
185+
186+
187+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_SAVE)
188+
def notebook_did_save(params: lsp.DidSaveNotebookDocumentParams) -> None:
189+
"""LSP handler for notebookDocument/didSave request."""
190+
nb = LSP_SERVER.workspace.get_notebook_document(
191+
notebook_uri=params.notebook_document.uri
192+
)
193+
if nb is None:
194+
return
195+
for cell in nb.cells:
196+
if cell.kind != lsp.NotebookCellKind.Code or cell.document is None:
197+
continue
198+
document = LSP_SERVER.workspace.get_text_document(cell.document)
199+
diagnostics: list[lsp.Diagnostic] = _linting_helper(document)
200+
LSP_SERVER.text_document_publish_diagnostics(
201+
lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics)
202+
)
203+
204+
205+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_CLOSE)
206+
def notebook_did_close(params: lsp.DidCloseNotebookDocumentParams) -> None:
207+
"""LSP handler for notebookDocument/didClose request."""
208+
for cell_doc in params.cell_text_documents:
209+
LSP_SERVER.text_document_publish_diagnostics(
210+
lsp.PublishDiagnosticsParams(uri=cell_doc.uri, diagnostics=[])
211+
)
212+
213+
214+
def _get_document_path(document: workspace.Document) -> str:
215+
"""Returns the file path for a document, handling notebook cell URIs.
216+
217+
Examples:
218+
file:///path/to/file.py -> /path/to/file.py
219+
vscode-notebook-cell:/path/to/notebook.ipynb#C00001 -> /path/to/notebook.ipynb
220+
"""
221+
if not document.uri.startswith("file:"):
222+
return uris.to_fs_path(
223+
document.uri.split("#")[0].replace("vscode-notebook-cell:", "file:", 1)
224+
)
225+
return uris.to_fs_path(document.uri)
226+
227+
115228
def _linting_helper(document: workspace.Document) -> list[lsp.Diagnostic]:
116229
# TODO: Determine if your tool supports passing file content via stdin.
117230
# If you want to support linting on change then your tool will need to
@@ -436,12 +549,11 @@ def _run_tool_on_document(
436549
"""
437550
if extra_args is None:
438551
extra_args = []
439-
if str(document.uri).startswith("vscode-notebook-cell"):
440-
# TODO: Decide on if you want to skip notebook cells.
441-
# Skip notebook cells
442-
return None
552+
# TODO: Notebook cells are now supported via the notebookDocument/ handlers.
553+
# If you want to customize notebook cell handling, update the notebook handlers above.
443554

444-
if utils.is_stdlib_file(document.path):
555+
document_path = _get_document_path(document)
556+
if utils.is_stdlib_file(document_path):
445557
# TODO: Decide on if you want to skip standard library files.
446558
# Skip standard library python files.
447559
return None
@@ -486,7 +598,7 @@ def _run_tool_on_document(
486598
# set use_stdin to False, or provide path, what ever is appropriate for your tool.
487599
argv += []
488600
else:
489-
argv += [document.path]
601+
argv += [document_path]
490602

491603
if use_path:
492604
# This mode is used when running executables.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@
4545
},
4646
"activationEvents": [
4747
"onLanguage:python",
48-
"workspaceContains:*.py"
48+
"workspaceContains:*.py",
49+
"onNotebook:jupyter-notebook",
50+
"onNotebook:interactive"
4951
],
5052
"main": "./dist/extension.js",
5153
"scripts": {

src/test/python_tests/lsp_test_client/session.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ def notify_did_close(self, did_close_params):
141141
"""Sends did close notification to LSP Server."""
142142
self._send_notification("textDocument/didClose", params=did_close_params)
143143

144+
def notify_notebook_did_open(self, params):
145+
"""Sends notebookDocument/didOpen notification to LSP Server."""
146+
self._send_notification("notebookDocument/didOpen", params=params)
147+
148+
def notify_notebook_did_change(self, params):
149+
"""Sends notebookDocument/didChange notification to LSP Server."""
150+
self._send_notification("notebookDocument/didChange", params=params)
151+
152+
def notify_notebook_did_save(self, params):
153+
"""Sends notebookDocument/didSave notification to LSP Server."""
154+
self._send_notification("notebookDocument/didSave", params=params)
155+
156+
def notify_notebook_did_close(self, params):
157+
"""Sends notebookDocument/didClose notification to LSP Server."""
158+
self._send_notification("notebookDocument/didClose", params=params)
159+
144160
def text_document_formatting(self, formatting_params):
145161
"""Sends text document references request to LSP server."""
146162
fut = self._send_request("textDocument/formatting", params=formatting_params)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": null,
6+
"id": "cell1",
7+
"metadata": {},
8+
"outputs": [],
9+
"source": [
10+
"x = 1\n"
11+
]
12+
}
13+
],
14+
"metadata": {
15+
"kernelspec": {
16+
"display_name": "Python 3",
17+
"language": "python",
18+
"name": "python3"
19+
},
20+
"language_info": {
21+
"name": "python",
22+
"version": "3.9.0"
23+
}
24+
},
25+
"nbformat": 4,
26+
"nbformat_minor": 5
27+
}

src/test/python_tests/test_get_cwd.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,32 @@ def show_message(self, *args, **kwargs):
3535

3636
mock_uris = types.ModuleType("pygls.uris")
3737
mock_uris.from_fs_path = lambda p: "file://" + p
38+
mock_uris.to_fs_path = lambda u: u.replace("file://", "")
3839

3940
mock_lsp = types.ModuleType("lsprotocol.types")
4041
for _name in [
4142
"TEXT_DOCUMENT_DID_OPEN", "TEXT_DOCUMENT_DID_SAVE", "TEXT_DOCUMENT_DID_CLOSE",
4243
"TEXT_DOCUMENT_FORMATTING", "INITIALIZE", "EXIT", "SHUTDOWN",
44+
"NOTEBOOK_DOCUMENT_DID_OPEN", "NOTEBOOK_DOCUMENT_DID_CHANGE",
45+
"NOTEBOOK_DOCUMENT_DID_SAVE", "NOTEBOOK_DOCUMENT_DID_CLOSE",
4346
]:
4447
setattr(mock_lsp, _name, _name)
4548
for _name in [
4649
"Diagnostic", "DiagnosticSeverity", "DidCloseTextDocumentParams",
4750
"DidOpenTextDocumentParams", "DidSaveTextDocumentParams",
4851
"DocumentFormattingParams", "InitializeParams", "Position", "Range", "TextEdit",
52+
"DidOpenNotebookDocumentParams", "DidChangeNotebookDocumentParams",
53+
"DidSaveNotebookDocumentParams", "DidCloseNotebookDocumentParams",
54+
"NotebookCellLanguage", "NotebookDocumentFilterWithNotebook",
55+
"NotebookDocumentSyncOptions", "PublishDiagnosticsParams",
4956
]:
50-
setattr(mock_lsp, _name, type(_name, (), {}))
57+
def _make_stub(name):
58+
def __init__(self, *args, **kwargs):
59+
pass
60+
return type(name, (), {"__init__": __init__})
61+
setattr(mock_lsp, _name, _make_stub(_name))
5162
mock_lsp.MessageType = type("MessageType", (), {"Log": 4, "Error": 1, "Warning": 2, "Info": 3})
63+
mock_lsp.NotebookCellKind = type("NotebookCellKind", (), {"Code": 2})
5264

5365
for _mod_name, _mod in [
5466
("pygls", types.ModuleType("pygls")),

0 commit comments

Comments
 (0)