Skip to content

Commit 67a2320

Browse files
edvilmeCopilot
andauthored
Add Jupyter notebook cell support via LSP 3.17 Notebook Document Sync (#742)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cf32834 commit 67a2320

7 files changed

Lines changed: 769 additions & 10 deletions

File tree

bundled/tool/lsp_server.py

Lines changed: 131 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import sysconfig
1616
import traceback
1717
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
18+
from urllib.parse import urlparse, urlunparse
1819

1920

2021
# **********************************************************
@@ -74,11 +75,55 @@ def update_environ_path() -> None:
7475
RUNNER = pathlib.Path(__file__).parent / "runner.py"
7576

7677
MAX_WORKERS = 5
78+
NOTEBOOK_SYNC_OPTIONS = lsp.NotebookDocumentSyncOptions(
79+
notebook_selector=[
80+
lsp.NotebookDocumentFilterWithNotebook(
81+
notebook="jupyter-notebook",
82+
cells=[
83+
lsp.NotebookCellLanguage(language="python"),
84+
],
85+
),
86+
lsp.NotebookDocumentFilterWithNotebook(
87+
notebook="interactive",
88+
cells=[
89+
lsp.NotebookCellLanguage(language="python"),
90+
],
91+
),
92+
],
93+
save=True,
94+
)
7795
LSP_SERVER = LanguageServer(
78-
name="pylint-server", version="v0.1.0", max_workers=MAX_WORKERS
96+
name="pylint-server",
97+
version="v0.1.0",
98+
max_workers=MAX_WORKERS,
99+
notebook_document_sync=NOTEBOOK_SYNC_OPTIONS,
79100
)
80101

81102

103+
def _get_document_path(document: str) -> str:
104+
"""Returns the filesystem path for a document.
105+
106+
Examples:
107+
file:///path/to/file.py -> /path/to/file.py
108+
vscode-notebook-cell:/path/to/notebook.ipynb#C00001 -> /path/to/notebook.ipynb
109+
"""
110+
if not document.startswith("file:"):
111+
parsed = urlparse(document)
112+
file_uri = urlunparse(
113+
(
114+
"file",
115+
parsed.netloc,
116+
parsed.path,
117+
parsed.params,
118+
parsed.query,
119+
"",
120+
)
121+
)
122+
if result := uris.to_fs_path(file_uri):
123+
return result
124+
return uris.to_fs_path(document) or document
125+
126+
82127
# **********************************************************
83128
# Tool specific code goes below this.
84129
# **********************************************************
@@ -143,8 +188,92 @@ def did_change(params: lsp.DidChangeTextDocumentParams) -> None:
143188
)
144189

145190

146-
def _linting_helper(document: workspace.TextDocument) -> list[lsp.Diagnostic]:
191+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_OPEN)
192+
def notebook_did_open(params: lsp.DidOpenNotebookDocumentParams) -> None:
193+
"""Run diagnostics on each code cell when a notebook is opened."""
194+
nb = LSP_SERVER.workspace.get_notebook_document(
195+
notebook_uri=params.notebook_document.uri
196+
)
197+
if nb is None:
198+
return
199+
for cell in nb.cells:
200+
if cell.kind == lsp.NotebookCellKind.Code and cell.document is not None:
201+
_lint_notebook_cell(cell.document)
202+
203+
204+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_CHANGE)
205+
def notebook_did_change(params: lsp.DidChangeNotebookDocumentParams) -> None:
206+
"""Re-lint cells whose text changed or that were newly added."""
207+
if params.change is None or params.change.cells is None:
208+
return
209+
210+
for cell_content in params.change.cells.text_content or []:
211+
if cell_content.document:
212+
_lint_notebook_cell(cell_content.document.uri)
213+
214+
structure = params.change.cells.structure
215+
if structure and structure.did_open:
216+
for cell_document in structure.did_open:
217+
_lint_notebook_cell(cell_document.uri)
218+
219+
if structure and structure.did_close:
220+
for cell_document in structure.did_close:
221+
_clear_notebook_cell_diagnostics(cell_document.uri)
222+
223+
224+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_SAVE)
225+
def notebook_did_save(params: lsp.DidSaveNotebookDocumentParams) -> None:
226+
"""Re-lint all cells when a notebook is saved."""
227+
nb = LSP_SERVER.workspace.get_notebook_document(
228+
notebook_uri=params.notebook_document.uri
229+
)
230+
if nb is None:
231+
return
232+
for cell in nb.cells:
233+
if cell.kind == lsp.NotebookCellKind.Code and cell.document is not None:
234+
_lint_notebook_cell(cell.document)
235+
236+
237+
@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_CLOSE)
238+
def notebook_did_close(params: lsp.DidCloseNotebookDocumentParams) -> None:
239+
"""Clear diagnostics for all cells when the notebook is closed."""
240+
for cell_doc in params.cell_text_documents:
241+
_clear_notebook_cell_diagnostics(cell_doc.uri)
242+
243+
244+
def _lint_notebook_cell(cell_uri: str) -> None:
245+
"""Lint a single notebook cell and publish its diagnostics."""
246+
document = LSP_SERVER.workspace.get_text_document(cell_uri)
247+
if document is None:
248+
return
249+
# Update path as pygls generates an invalid path.
250+
# TODO: Remove when fixed. # pylint: disable=fixme
251+
document.path = _get_document_path(cell_uri)
252+
# Linting is only supported for python cells in notebooks.
253+
if document.language_id != "python":
254+
return
255+
diagnostics: list[lsp.Diagnostic] = _linting_helper(document, is_notebook=True)
256+
LSP_SERVER.text_document_publish_diagnostics(
257+
lsp.PublishDiagnosticsParams(uri=cell_uri, diagnostics=diagnostics)
258+
)
259+
260+
261+
def _clear_notebook_cell_diagnostics(cell_uri: str) -> None:
262+
"""Clear diagnostics for a single notebook cell."""
263+
LSP_SERVER.text_document_publish_diagnostics(
264+
lsp.PublishDiagnosticsParams(uri=cell_uri, diagnostics=[])
265+
)
266+
267+
268+
def _linting_helper(
269+
document: workspace.TextDocument, is_notebook: bool = False
270+
) -> list[lsp.Diagnostic]:
147271
try:
272+
# Skip notebook cells — they are linted via _lint_notebook_cell which
273+
# passes cell content directly, not the notebook file path.
274+
if not is_notebook and str(document.uri).startswith("vscode-notebook-cell"):
275+
return []
276+
148277
# Notify the client that linting has started for this document.
149278
LSP_SERVER.protocol.notify(
150279
"pylint/lintingStarted",
@@ -776,10 +905,6 @@ def _run_tool_on_document(
776905
log_warning("See `pylint.enabled` in settings.json to enabling linting.")
777906
return None
778907

779-
if str(document.uri).startswith("vscode-notebook-cell"):
780-
log_warning(f"Skipping notebook cells [Not Supported]: {str(document.uri)}")
781-
return None
782-
783908
if utils.is_stdlib_file(document.path):
784909
log_warning(
785910
f"Skipping standard library file (stdlib excluded): {document.path}"

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@
4747
"activationEvents": [
4848
"onLanguage:python",
4949
"workspaceContains:.pylintrc",
50-
"workspaceContains:*.py"
50+
"workspaceContains:*.py",
51+
"onNotebook:jupyter-notebook",
52+
"onNotebook:interactive"
5153
],
5254
"main": "./dist/extension.js",
5355
"l10n": "./l10n",

src/test/python_tests/lsp_test_client/session.py

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

147+
def notify_notebook_did_open(self, params):
148+
"""Sends notebookDocument/didOpen notification to LSP Server."""
149+
self._send_notification("notebookDocument/didOpen", params=params)
150+
151+
def notify_notebook_did_change(self, params):
152+
"""Sends notebookDocument/didChange notification to LSP Server."""
153+
self._send_notification("notebookDocument/didChange", params=params)
154+
155+
def notify_notebook_did_save(self, params):
156+
"""Sends notebookDocument/didSave notification to LSP Server."""
157+
self._send_notification("notebookDocument/didSave", params=params)
158+
159+
def notify_notebook_did_close(self, params):
160+
"""Sends notebookDocument/didClose notification to LSP Server."""
161+
self._send_notification("notebookDocument/didClose", params=params)
162+
147163
def text_document_formatting(self, formatting_params):
148164
"""Sends text document format request to LSP server."""
149165
fut = self._send_request("textDocument/formatting", params=formatting_params)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": null,
6+
"id": "01a29052",
7+
"metadata": {},
8+
"outputs": [],
9+
"source": [
10+
"x = 1"
11+
]
12+
},
13+
{
14+
"cell_type": "markdown",
15+
"id": "36edf88f",
16+
"metadata": {},
17+
"source": [
18+
"# heading"
19+
]
20+
},
21+
{
22+
"cell_type": "code",
23+
"execution_count": null,
24+
"id": "fe27813e",
25+
"metadata": {},
26+
"outputs": [],
27+
"source": [
28+
"y = 2"
29+
]
30+
}
31+
],
32+
"metadata": {
33+
"language_info": {
34+
"name": "python"
35+
}
36+
},
37+
"nbformat": 4,
38+
"nbformat_minor": 5
39+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": null,
6+
"id": "b95324b3",
7+
"metadata": {},
8+
"outputs": [],
9+
"source": [
10+
"import os"
11+
]
12+
},
13+
{
14+
"cell_type": "markdown",
15+
"id": "3f4c8e46",
16+
"metadata": {},
17+
"source": [
18+
"# heading"
19+
]
20+
},
21+
{
22+
"cell_type": "code",
23+
"execution_count": null,
24+
"id": "30277226",
25+
"metadata": {},
26+
"outputs": [],
27+
"source": [
28+
"y = 2"
29+
]
30+
}
31+
],
32+
"metadata": {
33+
"language_info": {
34+
"name": "python"
35+
}
36+
},
37+
"nbformat": 4,
38+
"nbformat_minor": 5
39+
}

src/test/python_tests/test_get_cwd.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ def feature(self, *args, **kwargs):
2323
def command(self, *args, **kwargs):
2424
return lambda f: f
2525

26-
def show_message_log(self, *args, **kwargs):
26+
def window_log_message(self, *args, **kwargs):
2727
pass
2828

29-
def show_message(self, *args, **kwargs):
29+
def window_show_message(self, *args, **kwargs):
3030
pass
3131

3232
mock_server = types.ModuleType("pygls.server")
@@ -56,6 +56,10 @@ def show_message(self, *args, **kwargs):
5656
"INITIALIZE",
5757
"EXIT",
5858
"SHUTDOWN",
59+
"NOTEBOOK_DOCUMENT_DID_OPEN",
60+
"NOTEBOOK_DOCUMENT_DID_CHANGE",
61+
"NOTEBOOK_DOCUMENT_DID_SAVE",
62+
"NOTEBOOK_DOCUMENT_DID_CLOSE",
5963
]:
6064
setattr(mock_lsp, _name, _name)
6165
for _name in [
@@ -65,16 +69,28 @@ def show_message(self, *args, **kwargs):
6569
"DidOpenTextDocumentParams",
6670
"DidSaveTextDocumentParams",
6771
"DidChangeTextDocumentParams",
72+
"DidChangeNotebookDocumentParams",
73+
"DidCloseNotebookDocumentParams",
74+
"DidOpenNotebookDocumentParams",
75+
"DidSaveNotebookDocumentParams",
6876
"DocumentFormattingParams",
6977
"InitializeParams",
78+
"NotebookCellKind",
79+
"NotebookCellLanguage",
80+
"NotebookDocumentFilterWithNotebook",
81+
"NotebookDocumentSyncOptions",
7082
"Position",
7183
"Range",
7284
"TextEdit",
7385
"CodeAction",
7486
"CodeActionParams",
7587
"PublishDiagnosticsParams",
7688
]:
77-
setattr(mock_lsp, _name, type(_name, (), {}))
89+
setattr(
90+
mock_lsp,
91+
_name,
92+
type(_name, (), {"__init__": lambda self, *args, **kw: None}),
93+
)
7894
mock_lsp.MessageType = type(
7995
"MessageType", (), {"Log": 4, "Error": 1, "Warning": 2, "Info": 3}
8096
)

0 commit comments

Comments
 (0)