Skip to content

Commit 9a6b536

Browse files
Copilotedvilmerchiodo
authored
Add Jupyter notebook cell support via LSP 3.17 Notebook Document Sync (#265)
* Initial plan * Add Jupyter notebook cell support via LSP 3.17 Notebook Document Sync Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> * Remove vscode-isort PR reference link from README notebook section Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> * Filter newly-added cells by kind in notebook_did_change; add test Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com> * Fix _get_document_path URI check and remove unused _collect_diagnostics Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com> * Use urllib.parse for URI handling in _get_document_path Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> * Avoid private _replace in urlunparse; simplify cell-kind filter to URI set Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com> Co-authored-by: Eduardo Villalpando Mello <eduardo.villalpando.mello@gmail.com> Co-authored-by: Eduardo Villalpando Mello <eduardovil@microsoft.com>
1 parent a2ad356 commit 9a6b536

7 files changed

Lines changed: 655 additions & 12 deletions

File tree

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,45 @@ 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+
74113
## Building and Run the extension
75114

76115
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: 131 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import sys
1212
import sysconfig
1313
import traceback
14+
import urllib.parse
1415
from typing import Any, Optional, Sequence
1516

1617

@@ -47,9 +48,25 @@ def update_sys_path(path_to_add: str, strategy: str) -> None:
4748
RUNNER = pathlib.Path(__file__).parent / "lsp_runner.py"
4849

4950
MAX_WORKERS = 5
51+
NOTEBOOK_SYNC_OPTIONS = lsp.NotebookDocumentSyncOptions(
52+
notebook_selector=[
53+
lsp.NotebookDocumentFilterWithNotebook(
54+
notebook="jupyter-notebook",
55+
cells=[lsp.NotebookCellLanguage(language="python")],
56+
),
57+
lsp.NotebookDocumentFilterWithNotebook(
58+
notebook="interactive",
59+
cells=[lsp.NotebookCellLanguage(language="python")],
60+
),
61+
],
62+
save=True,
63+
)
5064
# TODO: Update the language server name and version.
51-
LSP_SERVER = LanguageServer(
52-
name="<pytool-display-name>", version="<server version>", max_workers=MAX_WORKERS
65+
LSP_SERVER = server.LanguageServer(
66+
name="<pytool-display-name>",
67+
version="<server version>",
68+
max_workers=MAX_WORKERS,
69+
notebook_document_sync=NOTEBOOK_SYNC_OPTIONS,
5370
)
5471

5572

@@ -119,7 +136,113 @@ def did_close(params: lsp.DidCloseTextDocumentParams) -> None:
119136
)
120137

121138

122-
def _linting_helper(document: workspace.TextDocument) -> list[lsp.Diagnostic]:
139+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_OPEN)
140+
def notebook_did_open(params: lsp.DidOpenNotebookDocumentParams) -> None:
141+
"""LSP handler for notebookDocument/didOpen request."""
142+
nb = LSP_SERVER.workspace.get_notebook_document(
143+
notebook_uri=params.notebook_document.uri
144+
)
145+
if nb is None:
146+
return
147+
for cell in nb.cells:
148+
if cell.kind != lsp.NotebookCellKind.Code or cell.document is None:
149+
continue
150+
document = LSP_SERVER.workspace.get_text_document(cell.document)
151+
diagnostics: list[lsp.Diagnostic] = _linting_helper(document)
152+
LSP_SERVER.text_document_publish_diagnostics(
153+
lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics)
154+
)
155+
156+
157+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_CHANGE)
158+
def notebook_did_change(params: lsp.DidChangeNotebookDocumentParams) -> None:
159+
"""LSP handler for notebookDocument/didChange request."""
160+
nb = LSP_SERVER.workspace.get_notebook_document(
161+
notebook_uri=params.notebook_document.uri
162+
)
163+
if nb is None:
164+
return
165+
166+
change = params.change
167+
# Re-lint cells whose text content changed.
168+
if change.cells and change.cells.text_content:
169+
for text_change in change.cells.text_content:
170+
document = LSP_SERVER.workspace.get_text_document(
171+
text_change.document.uri
172+
)
173+
diagnostics: list[lsp.Diagnostic] = _linting_helper(document)
174+
LSP_SERVER.text_document_publish_diagnostics(
175+
lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics)
176+
)
177+
178+
# Lint newly added cells (code cells only).
179+
if change.cells and change.cells.structure and change.cells.structure.did_open:
180+
code_cell_uris = {
181+
cell.document
182+
for cell in nb.cells
183+
if cell.kind == lsp.NotebookCellKind.Code and cell.document is not None
184+
}
185+
for cell_doc in change.cells.structure.did_open:
186+
if cell_doc.uri not in code_cell_uris:
187+
continue
188+
document = LSP_SERVER.workspace.get_text_document(cell_doc.uri)
189+
diagnostics = _linting_helper(document)
190+
LSP_SERVER.text_document_publish_diagnostics(
191+
lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics)
192+
)
193+
194+
# Clear diagnostics for removed cells.
195+
if change.cells and change.cells.structure and change.cells.structure.did_close:
196+
for cell_doc in change.cells.structure.did_close:
197+
LSP_SERVER.text_document_publish_diagnostics(
198+
lsp.PublishDiagnosticsParams(uri=cell_doc.uri, diagnostics=[])
199+
)
200+
201+
202+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_SAVE)
203+
def notebook_did_save(params: lsp.DidSaveNotebookDocumentParams) -> None:
204+
"""LSP handler for notebookDocument/didSave request."""
205+
nb = LSP_SERVER.workspace.get_notebook_document(
206+
notebook_uri=params.notebook_document.uri
207+
)
208+
if nb is None:
209+
return
210+
for cell in nb.cells:
211+
if cell.kind != lsp.NotebookCellKind.Code or cell.document is None:
212+
continue
213+
document = LSP_SERVER.workspace.get_text_document(cell.document)
214+
diagnostics: list[lsp.Diagnostic] = _linting_helper(document)
215+
LSP_SERVER.text_document_publish_diagnostics(
216+
lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics)
217+
)
218+
219+
220+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_CLOSE)
221+
def notebook_did_close(params: lsp.DidCloseNotebookDocumentParams) -> None:
222+
"""LSP handler for notebookDocument/didClose request."""
223+
for cell_doc in params.cell_text_documents:
224+
LSP_SERVER.text_document_publish_diagnostics(
225+
lsp.PublishDiagnosticsParams(uri=cell_doc.uri, diagnostics=[])
226+
)
227+
228+
229+
def _get_document_path(document: workspace.Document) -> str:
230+
"""Returns the file path for a document, handling notebook cell URIs.
231+
232+
Examples:
233+
file:///path/to/file.py -> /path/to/file.py
234+
vscode-notebook-cell:/path/to/notebook.ipynb#C00001 -> /path/to/notebook.ipynb
235+
"""
236+
parsed = urllib.parse.urlparse(document.uri)
237+
if parsed.scheme == "vscode-notebook-cell":
238+
file_uri = urllib.parse.urlunparse(
239+
("file", parsed.netloc, parsed.path, parsed.params, parsed.query, "")
240+
)
241+
return uris.to_fs_path(file_uri)
242+
return uris.to_fs_path(document.uri)
243+
244+
245+
def _linting_helper(document: workspace.Document) -> list[lsp.Diagnostic]:
123246
# TODO: Determine if your tool supports passing file content via stdin.
124247
# If you want to support linting on change then your tool will need to
125248
# support linting over stdin to be effective. Read, and update
@@ -443,12 +566,11 @@ def _run_tool_on_document(
443566
"""
444567
if extra_args is None:
445568
extra_args = []
446-
if str(document.uri).startswith("vscode-notebook-cell"):
447-
# TODO: Decide on if you want to skip notebook cells.
448-
# Skip notebook cells
449-
return None
569+
# TODO: Notebook cells are now supported via the notebookDocument/ handlers.
570+
# If you want to customize notebook cell handling, update the notebook handlers above.
450571

451-
if utils.is_stdlib_file(document.path):
572+
document_path = _get_document_path(document)
573+
if utils.is_stdlib_file(document_path):
452574
# TODO: Decide on if you want to skip standard library files.
453575
# Skip standard library python files.
454576
return None
@@ -493,7 +615,7 @@ def _run_tool_on_document(
493615
# set use_stdin to False, or provide path, what ever is appropriate for your tool.
494616
argv += []
495617
else:
496-
argv += [document.path]
618+
argv += [document_path]
497619

498620
if use_path:
499621
# 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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,32 @@ def window_show_message(self, *args, **kwargs):
4141

4242
mock_uris = types.ModuleType("pygls.uris")
4343
mock_uris.from_fs_path = lambda p: "file://" + p
44+
mock_uris.to_fs_path = lambda u: u.replace("file://", "")
4445

4546
mock_lsp = types.ModuleType("lsprotocol.types")
4647
for _name in [
4748
"TEXT_DOCUMENT_DID_OPEN", "TEXT_DOCUMENT_DID_SAVE", "TEXT_DOCUMENT_DID_CLOSE",
4849
"TEXT_DOCUMENT_FORMATTING", "INITIALIZE", "EXIT", "SHUTDOWN",
50+
"NOTEBOOK_DOCUMENT_DID_OPEN", "NOTEBOOK_DOCUMENT_DID_CHANGE",
51+
"NOTEBOOK_DOCUMENT_DID_SAVE", "NOTEBOOK_DOCUMENT_DID_CLOSE",
4952
]:
5053
setattr(mock_lsp, _name, _name)
5154
for _name in [
5255
"Diagnostic", "DiagnosticSeverity", "DidCloseTextDocumentParams",
5356
"DidOpenTextDocumentParams", "DidSaveTextDocumentParams",
5457
"DocumentFormattingParams", "InitializeParams", "Position", "Range", "TextEdit",
55-
"PublishDiagnosticsParams", "LogMessageParams", "ShowMessageParams",
58+
"DidOpenNotebookDocumentParams", "DidChangeNotebookDocumentParams",
59+
"DidSaveNotebookDocumentParams", "DidCloseNotebookDocumentParams",
60+
"NotebookCellLanguage", "NotebookDocumentFilterWithNotebook",
61+
"NotebookDocumentSyncOptions", "PublishDiagnosticsParams",
5662
]:
57-
setattr(mock_lsp, _name, type(_name, (), {}))
63+
def _make_stub(name):
64+
def __init__(self, *args, **kwargs):
65+
pass
66+
return type(name, (), {"__init__": __init__})
67+
setattr(mock_lsp, _name, _make_stub(_name))
5868
mock_lsp.MessageType = type("MessageType", (), {"Log": 4, "Error": 1, "Warning": 2, "Info": 3})
69+
mock_lsp.NotebookCellKind = type("NotebookCellKind", (), {"Code": 2})
5970

6071
for _mod_name, _mod in [
6172
("pygls", types.ModuleType("pygls")),

0 commit comments

Comments
 (0)