From 5fc0a1c248652fa988beef22938ff159a5134006 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:04:39 +0000 Subject: [PATCH 1/7] Initial plan From 7be45fdddb6f3c360cbcf067f1f5bd158f24ff65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:11:07 +0000 Subject: [PATCH 2/7] Add Jupyter notebook cell support via LSP 3.17 Notebook Document Sync Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> --- README.md | 41 +++ bundled/tool/lsp_server.py | 126 ++++++- package.json | 4 +- .../python_tests/lsp_test_client/session.py | 16 + .../test_data/sample1/sample.ipynb | 27 ++ src/test/python_tests/test_get_cwd.py | 14 +- src/test/python_tests/test_notebook.py | 341 ++++++++++++++++++ 7 files changed, 560 insertions(+), 9 deletions(-) create mode 100644 src/test/python_tests/test_data/sample1/sample.ipynb create mode 100644 src/test/python_tests/test_notebook.py diff --git a/README.md b/README.md index a0f1a18..0f9078a 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,47 @@ References, to other extension created by our team using the template: - Implementation showing how to handle Formatting. [Black Formatter](https://github.com/microsoft/vscode-black-formatter/tree/main/bundled/tool) - Implementation showing how to handle Code Actions. [isort](https://github.com/microsoft/vscode-isort/blob/main/bundled/tool) +## Jupyter Notebook Support + +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). + +### How it works + +The server declares `NotebookDocumentSyncOptions` (in `lsp_server.py`) that tell the client which notebook types and cell languages to synchronize: + +```python +NOTEBOOK_SYNC_OPTIONS = lsp.NotebookDocumentSyncOptions( + notebook_selector=[ + lsp.NotebookDocumentFilterWithNotebook( + notebook="jupyter-notebook", + cells=[lsp.NotebookCellLanguage(language="python")], + ), + lsp.NotebookDocumentFilterWithNotebook( + notebook="interactive", + cells=[lsp.NotebookCellLanguage(language="python")], + ), + ], + save=True, +) +``` + +Four notebook lifecycle handlers are registered: + +- `notebookDocument/didOpen` — diagnostics are published for every Python code cell when a notebook is opened. +- `notebookDocument/didChange` — diagnostics are updated for cells whose text changed, new cells are linted, and removed cells have their diagnostics cleared. +- `notebookDocument/didSave` — all Python code cells are re-linted on save. +- `notebookDocument/didClose` — diagnostics are cleared for all cells when the notebook is closed. + +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. + +### Customizing notebook support + +- 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. +- To restrict which cell languages are supported, update the `cells` list in `NOTEBOOK_SYNC_OPTIONS`. +- To change the linting/formatting behavior per cell, update the individual handlers. + +See [microsoft/vscode-isort#565](https://github.com/microsoft/vscode-isort/pull/565) for a reference implementation of notebook support in a production extension. + ## Building and Run the extension Run the `Debug Extension and Python` configuration form VS Code. That should build and debug the extension in host window. diff --git a/bundled/tool/lsp_server.py b/bundled/tool/lsp_server.py index b610d3f..4afe316 100644 --- a/bundled/tool/lsp_server.py +++ b/bundled/tool/lsp_server.py @@ -46,9 +46,25 @@ def update_sys_path(path_to_add: str, strategy: str) -> None: RUNNER = pathlib.Path(__file__).parent / "lsp_runner.py" MAX_WORKERS = 5 +NOTEBOOK_SYNC_OPTIONS = lsp.NotebookDocumentSyncOptions( + notebook_selector=[ + lsp.NotebookDocumentFilterWithNotebook( + notebook="jupyter-notebook", + cells=[lsp.NotebookCellLanguage(language="python")], + ), + lsp.NotebookDocumentFilterWithNotebook( + notebook="interactive", + cells=[lsp.NotebookCellLanguage(language="python")], + ), + ], + save=True, +) # TODO: Update the language server name and version. LSP_SERVER = server.LanguageServer( - name="", version="", max_workers=MAX_WORKERS + name="", + version="", + max_workers=MAX_WORKERS, + notebook_document_sync=NOTEBOOK_SYNC_OPTIONS, ) @@ -112,6 +128,103 @@ def did_close(params: lsp.DidCloseTextDocumentParams) -> None: LSP_SERVER.publish_diagnostics(document.uri, []) +@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_OPEN) +def notebook_did_open(params: lsp.DidOpenNotebookDocumentParams) -> None: + """LSP handler for notebookDocument/didOpen request.""" + nb = LSP_SERVER.workspace.get_notebook_document( + notebook_uri=params.notebook_document.uri + ) + if nb is None: + return + for cell in nb.cells: + if cell.kind != lsp.NotebookCellKind.Code or cell.document is None: + continue + document = LSP_SERVER.workspace.get_text_document(cell.document) + diagnostics: list[lsp.Diagnostic] = _linting_helper(document) + LSP_SERVER.text_document_publish_diagnostics( + lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics) + ) + + +@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_CHANGE) +def notebook_did_change(params: lsp.DidChangeNotebookDocumentParams) -> None: + """LSP handler for notebookDocument/didChange request.""" + nb = LSP_SERVER.workspace.get_notebook_document( + notebook_uri=params.notebook_document.uri + ) + if nb is None: + return + + change = params.change + # Re-lint cells whose text content changed. + if change.cells and change.cells.text_content: + for text_change in change.cells.text_content: + document = LSP_SERVER.workspace.get_text_document( + text_change.document.uri + ) + diagnostics: list[lsp.Diagnostic] = _linting_helper(document) + LSP_SERVER.text_document_publish_diagnostics( + lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics) + ) + + # Lint newly added cells. + if change.cells and change.cells.structure and change.cells.structure.did_open: + for cell_doc in change.cells.structure.did_open: + document = LSP_SERVER.workspace.get_text_document(cell_doc.uri) + diagnostics = _linting_helper(document) + LSP_SERVER.text_document_publish_diagnostics( + lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics) + ) + + # Clear diagnostics for removed cells. + if change.cells and change.cells.structure and change.cells.structure.did_close: + for cell_doc in change.cells.structure.did_close: + LSP_SERVER.text_document_publish_diagnostics( + lsp.PublishDiagnosticsParams(uri=cell_doc.uri, diagnostics=[]) + ) + + +@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_SAVE) +def notebook_did_save(params: lsp.DidSaveNotebookDocumentParams) -> None: + """LSP handler for notebookDocument/didSave request.""" + nb = LSP_SERVER.workspace.get_notebook_document( + notebook_uri=params.notebook_document.uri + ) + if nb is None: + return + for cell in nb.cells: + if cell.kind != lsp.NotebookCellKind.Code or cell.document is None: + continue + document = LSP_SERVER.workspace.get_text_document(cell.document) + diagnostics: list[lsp.Diagnostic] = _linting_helper(document) + LSP_SERVER.text_document_publish_diagnostics( + lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics) + ) + + +@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_CLOSE) +def notebook_did_close(params: lsp.DidCloseNotebookDocumentParams) -> None: + """LSP handler for notebookDocument/didClose request.""" + for cell_doc in params.cell_text_documents: + LSP_SERVER.text_document_publish_diagnostics( + lsp.PublishDiagnosticsParams(uri=cell_doc.uri, diagnostics=[]) + ) + + +def _get_document_path(document: workspace.Document) -> str: + """Returns the file path for a document, handling notebook cell URIs. + + Examples: + file:///path/to/file.py -> /path/to/file.py + vscode-notebook-cell:/path/to/notebook.ipynb#C00001 -> /path/to/notebook.ipynb + """ + if not document.uri.startswith("file:"): + return uris.to_fs_path( + document.uri.split("#")[0].replace("vscode-notebook-cell:", "file:", 1) + ) + return uris.to_fs_path(document.uri) + + def _linting_helper(document: workspace.Document) -> list[lsp.Diagnostic]: # TODO: Determine if your tool supports passing file content via stdin. # If you want to support linting on change then your tool will need to @@ -436,12 +549,11 @@ def _run_tool_on_document( """ if extra_args is None: extra_args = [] - if str(document.uri).startswith("vscode-notebook-cell"): - # TODO: Decide on if you want to skip notebook cells. - # Skip notebook cells - return None + # TODO: Notebook cells are now supported via the notebookDocument/ handlers. + # If you want to customize notebook cell handling, update the notebook handlers above. - if utils.is_stdlib_file(document.path): + document_path = _get_document_path(document) + if utils.is_stdlib_file(document_path): # TODO: Decide on if you want to skip standard library files. # Skip standard library python files. return None @@ -486,7 +598,7 @@ def _run_tool_on_document( # set use_stdin to False, or provide path, what ever is appropriate for your tool. argv += [] else: - argv += [document.path] + argv += [document_path] if use_path: # This mode is used when running executables. diff --git a/package.json b/package.json index bed4ceb..3c416ab 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,9 @@ }, "activationEvents": [ "onLanguage:python", - "workspaceContains:*.py" + "workspaceContains:*.py", + "onNotebook:jupyter-notebook", + "onNotebook:interactive" ], "main": "./dist/extension.js", "scripts": { diff --git a/src/test/python_tests/lsp_test_client/session.py b/src/test/python_tests/lsp_test_client/session.py index 2694fcc..96719c6 100644 --- a/src/test/python_tests/lsp_test_client/session.py +++ b/src/test/python_tests/lsp_test_client/session.py @@ -141,6 +141,22 @@ def notify_did_close(self, did_close_params): """Sends did close notification to LSP Server.""" self._send_notification("textDocument/didClose", params=did_close_params) + def notify_notebook_did_open(self, params): + """Sends notebookDocument/didOpen notification to LSP Server.""" + self._send_notification("notebookDocument/didOpen", params=params) + + def notify_notebook_did_change(self, params): + """Sends notebookDocument/didChange notification to LSP Server.""" + self._send_notification("notebookDocument/didChange", params=params) + + def notify_notebook_did_save(self, params): + """Sends notebookDocument/didSave notification to LSP Server.""" + self._send_notification("notebookDocument/didSave", params=params) + + def notify_notebook_did_close(self, params): + """Sends notebookDocument/didClose notification to LSP Server.""" + self._send_notification("notebookDocument/didClose", params=params) + def text_document_formatting(self, formatting_params): """Sends text document references request to LSP server.""" fut = self._send_request("textDocument/formatting", params=formatting_params) diff --git a/src/test/python_tests/test_data/sample1/sample.ipynb b/src/test/python_tests/test_data/sample1/sample.ipynb new file mode 100644 index 0000000..1a4e730 --- /dev/null +++ b/src/test/python_tests/test_data/sample1/sample.ipynb @@ -0,0 +1,27 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "cell1", + "metadata": {}, + "outputs": [], + "source": [ + "x = 1\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/test/python_tests/test_get_cwd.py b/src/test/python_tests/test_get_cwd.py index eed83d9..38b9f48 100644 --- a/src/test/python_tests/test_get_cwd.py +++ b/src/test/python_tests/test_get_cwd.py @@ -35,20 +35,32 @@ def show_message(self, *args, **kwargs): mock_uris = types.ModuleType("pygls.uris") mock_uris.from_fs_path = lambda p: "file://" + p + mock_uris.to_fs_path = lambda u: u.replace("file://", "") mock_lsp = types.ModuleType("lsprotocol.types") for _name in [ "TEXT_DOCUMENT_DID_OPEN", "TEXT_DOCUMENT_DID_SAVE", "TEXT_DOCUMENT_DID_CLOSE", "TEXT_DOCUMENT_FORMATTING", "INITIALIZE", "EXIT", "SHUTDOWN", + "NOTEBOOK_DOCUMENT_DID_OPEN", "NOTEBOOK_DOCUMENT_DID_CHANGE", + "NOTEBOOK_DOCUMENT_DID_SAVE", "NOTEBOOK_DOCUMENT_DID_CLOSE", ]: setattr(mock_lsp, _name, _name) for _name in [ "Diagnostic", "DiagnosticSeverity", "DidCloseTextDocumentParams", "DidOpenTextDocumentParams", "DidSaveTextDocumentParams", "DocumentFormattingParams", "InitializeParams", "Position", "Range", "TextEdit", + "DidOpenNotebookDocumentParams", "DidChangeNotebookDocumentParams", + "DidSaveNotebookDocumentParams", "DidCloseNotebookDocumentParams", + "NotebookCellLanguage", "NotebookDocumentFilterWithNotebook", + "NotebookDocumentSyncOptions", "PublishDiagnosticsParams", ]: - setattr(mock_lsp, _name, type(_name, (), {})) + def _make_stub(name): + def __init__(self, *args, **kwargs): + pass + return type(name, (), {"__init__": __init__}) + setattr(mock_lsp, _name, _make_stub(_name)) mock_lsp.MessageType = type("MessageType", (), {"Log": 4, "Error": 1, "Warning": 2, "Info": 3}) + mock_lsp.NotebookCellKind = type("NotebookCellKind", (), {"Code": 2}) for _mod_name, _mod in [ ("pygls", types.ModuleType("pygls")), diff --git a/src/test/python_tests/test_notebook.py b/src/test/python_tests/test_notebook.py new file mode 100644 index 0000000..e628ef8 --- /dev/null +++ b/src/test/python_tests/test_notebook.py @@ -0,0 +1,341 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +Tests for Jupyter notebook cell support over LSP. + +These are template-style example tests that demonstrate how to validate notebook +cell diagnostics. Adapt the expected diagnostics to match your tool's output. +""" + +import os +from threading import Event + +from .lsp_test_client import constants, defaults, session, utils + +TIMEOUT = 10 # seconds + + +def _make_notebook_uri(notebook_path: str) -> str: + """Returns a 'file:' URI for a notebook path.""" + return utils.as_uri(notebook_path) + + +def _make_cell_uri(notebook_path: str, cell_id: str) -> str: + """Returns a 'vscode-notebook-cell:' URI for a notebook cell. + + Args: + notebook_path: Absolute path to the .ipynb file. + cell_id: Fragment identifier for the cell (e.g. 'W0sZmlsZQ%3D%3D0'). + """ + nb_uri = utils.as_uri(notebook_path) + # Replace 'file:' scheme with 'vscode-notebook-cell:' + cell_uri = nb_uri.replace("file:", "vscode-notebook-cell:", 1) + return f"{cell_uri}#{cell_id}" + + +def _collect_diagnostics(ls_session, count: int, timeout: int = TIMEOUT) -> list: + """Collect up to *count* publishDiagnostics notifications within *timeout* seconds. + + Returns a list of params dicts, one per notification received. + """ + collected = [] + done = Event() + + def _handler(params): + collected.append(params) + if len(collected) >= count: + done.set() + + ls_session.set_notification_callback(session.PUBLISH_DIAGNOSTICS, _handler) + done.wait(timeout) + return collected + + +def test_notebook_did_open(): + """Diagnostics are published for each code cell when a notebook is opened. + + This test sends a notebookDocument/didOpen notification for a notebook with + one code cell and verifies that a publishDiagnostics notification is received + for that cell's URI. + + TODO: Update the expected diagnostics to match your tool's output. + """ + nb_path = str(constants.TEST_DATA / "sample1" / "sample.ipynb") + nb_uri = _make_notebook_uri(nb_path) + cell_id = "cell1" + cell_uri = _make_cell_uri(nb_path, cell_id) + cell_contents = "x = 1\n" + + with session.LspSession() as ls_session: + ls_session.initialize(defaults.VSCODE_DEFAULT_INITIALIZE) + + done = Event() + received = [] + + def _handler(params): + received.append(params) + done.set() + + ls_session.set_notification_callback(session.PUBLISH_DIAGNOSTICS, _handler) + + ls_session.notify_notebook_did_open( + { + "notebookDocument": { + "uri": nb_uri, + "notebookType": "jupyter-notebook", + "version": 1, + "metadata": {}, + "cells": [ + { + "kind": 2, # Code cell + "document": cell_uri, + "metadata": {}, + "executionSummary": None, + } + ], + }, + "cellTextDocuments": [ + { + "uri": cell_uri, + "languageId": "python", + "version": 1, + "text": cell_contents, + } + ], + } + ) + + done.wait(TIMEOUT) + + # TODO: Add your tool-specific assertion on `received`. + # For now, just verify we got a diagnostics notification for the cell. + assert any( + r.get("uri") == cell_uri for r in received + ), f"Expected diagnostics for {cell_uri!r}, got: {received}" + + +def test_notebook_did_change_text_content(): + """Diagnostics update when the text content of a cell changes. + + TODO: Update the expected diagnostics to match your tool's output. + """ + nb_path = str(constants.TEST_DATA / "sample1" / "sample.ipynb") + nb_uri = _make_notebook_uri(nb_path) + cell_id = "cell1" + cell_uri = _make_cell_uri(nb_path, cell_id) + initial_contents = "x = 1\n" + updated_contents = "y = 2\n" + + with session.LspSession() as ls_session: + ls_session.initialize(defaults.VSCODE_DEFAULT_INITIALIZE) + + # Open notebook first + ls_session.notify_notebook_did_open( + { + "notebookDocument": { + "uri": nb_uri, + "notebookType": "jupyter-notebook", + "version": 1, + "metadata": {}, + "cells": [ + { + "kind": 2, + "document": cell_uri, + "metadata": {}, + "executionSummary": None, + } + ], + }, + "cellTextDocuments": [ + { + "uri": cell_uri, + "languageId": "python", + "version": 1, + "text": initial_contents, + } + ], + } + ) + + done = Event() + received = [] + + def _handler(params): + received.append(params) + done.set() + + ls_session.set_notification_callback(session.PUBLISH_DIAGNOSTICS, _handler) + + # Send a change with updated text content + ls_session.notify_notebook_did_change( + { + "notebookDocument": { + "uri": nb_uri, + "version": 2, + }, + "change": { + "metadata": None, + "cells": { + "structure": None, + "data": None, + "textContent": [ + { + "document": {"uri": cell_uri, "version": 2}, + "changes": [ + { + "text": updated_contents, + } + ], + } + ], + }, + }, + } + ) + + done.wait(TIMEOUT) + + # TODO: Add your tool-specific assertion on `received`. + assert any( + r.get("uri") == cell_uri for r in received + ), f"Expected diagnostics for {cell_uri!r}, got: {received}" + + +def test_notebook_did_save(): + """All code cells are re-linted when a notebook is saved. + + TODO: Update the expected diagnostics to match your tool's output. + """ + nb_path = str(constants.TEST_DATA / "sample1" / "sample.ipynb") + nb_uri = _make_notebook_uri(nb_path) + cell_id = "cell1" + cell_uri = _make_cell_uri(nb_path, cell_id) + cell_contents = "x = 1\n" + + with session.LspSession() as ls_session: + ls_session.initialize(defaults.VSCODE_DEFAULT_INITIALIZE) + + # Open notebook first + ls_session.notify_notebook_did_open( + { + "notebookDocument": { + "uri": nb_uri, + "notebookType": "jupyter-notebook", + "version": 1, + "metadata": {}, + "cells": [ + { + "kind": 2, + "document": cell_uri, + "metadata": {}, + "executionSummary": None, + } + ], + }, + "cellTextDocuments": [ + { + "uri": cell_uri, + "languageId": "python", + "version": 1, + "text": cell_contents, + } + ], + } + ) + + done = Event() + received = [] + + def _handler(params): + received.append(params) + done.set() + + ls_session.set_notification_callback(session.PUBLISH_DIAGNOSTICS, _handler) + + ls_session.notify_notebook_did_save( + { + "notebookDocument": { + "uri": nb_uri, + "version": 1, + } + } + ) + + done.wait(TIMEOUT) + + # TODO: Add your tool-specific assertion on `received`. + assert any( + r.get("uri") == cell_uri for r in received + ), f"Expected diagnostics for {cell_uri!r}, got: {received}" + + +def test_notebook_did_close(): + """Diagnostics are cleared for all cells when a notebook is closed. + + TODO: Update the expected diagnostics to match your tool's output. + """ + nb_path = str(constants.TEST_DATA / "sample1" / "sample.ipynb") + nb_uri = _make_notebook_uri(nb_path) + cell_id = "cell1" + cell_uri = _make_cell_uri(nb_path, cell_id) + cell_contents = "x = 1\n" + + with session.LspSession() as ls_session: + ls_session.initialize(defaults.VSCODE_DEFAULT_INITIALIZE) + + # Open notebook first + ls_session.notify_notebook_did_open( + { + "notebookDocument": { + "uri": nb_uri, + "notebookType": "jupyter-notebook", + "version": 1, + "metadata": {}, + "cells": [ + { + "kind": 2, + "document": cell_uri, + "metadata": {}, + "executionSummary": None, + } + ], + }, + "cellTextDocuments": [ + { + "uri": cell_uri, + "languageId": "python", + "version": 1, + "text": cell_contents, + } + ], + } + ) + + done = Event() + received = [] + + def _handler(params): + received.append(params) + done.set() + + ls_session.set_notification_callback(session.PUBLISH_DIAGNOSTICS, _handler) + + ls_session.notify_notebook_did_close( + { + "notebookDocument": { + "uri": nb_uri, + "version": 1, + }, + "cellTextDocuments": [ + {"uri": cell_uri} + ], + } + ) + + done.wait(TIMEOUT) + + # Diagnostics should be cleared (empty list) for the cell URI + assert any( + r.get("uri") == cell_uri and r.get("diagnostics") == [] + for r in received + ), f"Expected empty diagnostics for {cell_uri!r}, got: {received}" From b3f93c93eae1a0cfc7ca9f72da7f1a2b1bde042d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:21:34 +0000 Subject: [PATCH 3/7] Remove vscode-isort PR reference link from README notebook section Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 0f9078a..1be399b 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,6 @@ The `_get_document_path` helper resolves `vscode-notebook-cell:` URIs back to th - To restrict which cell languages are supported, update the `cells` list in `NOTEBOOK_SYNC_OPTIONS`. - To change the linting/formatting behavior per cell, update the individual handlers. -See [microsoft/vscode-isort#565](https://github.com/microsoft/vscode-isort/pull/565) for a reference implementation of notebook support in a production extension. - ## Building and Run the extension Run the `Debug Extension and Python` configuration form VS Code. That should build and debug the extension in host window. From b30acabf6b6ca81ee007d1f3cf95d12b8574baf0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:34:40 +0000 Subject: [PATCH 4/7] Filter newly-added cells by kind in notebook_did_change; add test Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com> --- bundled/tool/lsp_server.py | 10 ++- src/test/python_tests/test_notebook.py | 103 +++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/bundled/tool/lsp_server.py b/bundled/tool/lsp_server.py index 4afe316..f194f4c 100644 --- a/bundled/tool/lsp_server.py +++ b/bundled/tool/lsp_server.py @@ -167,9 +167,17 @@ def notebook_did_change(params: lsp.DidChangeNotebookDocumentParams) -> None: lsp.PublishDiagnosticsParams(uri=document.uri, diagnostics=diagnostics) ) - # Lint newly added cells. + # Lint newly added cells (code cells only). if change.cells and change.cells.structure and change.cells.structure.did_open: + # Build a URI→cell map so we can check each cell's kind before linting. + cell_kind_by_uri = { + cell.document: cell.kind + for cell in nb.cells + if cell.document is not None + } for cell_doc in change.cells.structure.did_open: + if cell_kind_by_uri.get(cell_doc.uri) != lsp.NotebookCellKind.Code: + continue document = LSP_SERVER.workspace.get_text_document(cell_doc.uri) diagnostics = _linting_helper(document) LSP_SERVER.text_document_publish_diagnostics( diff --git a/src/test/python_tests/test_notebook.py b/src/test/python_tests/test_notebook.py index e628ef8..316ce46 100644 --- a/src/test/python_tests/test_notebook.py +++ b/src/test/python_tests/test_notebook.py @@ -269,6 +269,109 @@ def _handler(params): ), f"Expected diagnostics for {cell_uri!r}, got: {received}" +def test_notebook_did_change_new_cell_kind_filter(): + """Diagnostics are only published for newly added code cells, not markdown cells. + + When a notebook change adds both a code cell and a markdown cell via + structure.did_open, only the code cell should receive diagnostics. + """ + nb_path = str(constants.TEST_DATA / "sample1" / "sample.ipynb") + nb_uri = _make_notebook_uri(nb_path) + code_cell_id = "cell_code" + md_cell_id = "cell_md" + code_cell_uri = _make_cell_uri(nb_path, code_cell_id) + md_cell_uri = _make_cell_uri(nb_path, md_cell_id) + + with session.LspSession() as ls_session: + ls_session.initialize(defaults.VSCODE_DEFAULT_INITIALIZE) + + # Open an initially empty notebook + ls_session.notify_notebook_did_open( + { + "notebookDocument": { + "uri": nb_uri, + "notebookType": "jupyter-notebook", + "version": 1, + "metadata": {}, + "cells": [], + }, + "cellTextDocuments": [], + } + ) + + received = [] + done = Event() + + def _handler(params): + received.append(params) + done.set() + + ls_session.set_notification_callback(session.PUBLISH_DIAGNOSTICS, _handler) + + # Add both a code cell (kind=2) and a markdown cell (kind=1) at once + ls_session.notify_notebook_did_change( + { + "notebookDocument": { + "uri": nb_uri, + "version": 2, + }, + "change": { + "metadata": None, + "cells": { + "structure": { + "array": { + "start": 0, + "deleteCount": 0, + "cells": [ + { + "kind": 2, # Code + "document": code_cell_uri, + "metadata": {}, + "executionSummary": None, + }, + { + "kind": 1, # Markdown + "document": md_cell_uri, + "metadata": {}, + "executionSummary": None, + }, + ], + }, + "didOpen": [ + { + "uri": code_cell_uri, + "languageId": "python", + "version": 1, + "text": "x = 1\n", + }, + { + "uri": md_cell_uri, + "languageId": "markdown", + "version": 1, + "text": "# heading\n", + }, + ], + "didClose": None, + }, + "data": None, + "textContent": None, + }, + }, + } + ) + + done.wait(TIMEOUT) + + # The code cell should receive diagnostics; the markdown cell must not. + uris_with_diagnostics = {r.get("uri") for r in received} + assert code_cell_uri in uris_with_diagnostics, ( + f"Expected diagnostics for code cell {code_cell_uri!r}, got: {received}" + ) + assert md_cell_uri not in uris_with_diagnostics, ( + f"Markdown cell {md_cell_uri!r} should not receive diagnostics, got: {received}" + ) + + def test_notebook_did_close(): """Diagnostics are cleared for all cells when a notebook is closed. From abac61b21c1fc72ee28b3e9cb377d2349f3a2f11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:37:19 +0000 Subject: [PATCH 5/7] Fix _get_document_path URI check and remove unused _collect_diagnostics Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com> --- bundled/tool/lsp_server.py | 2 +- src/test/python_tests/test_notebook.py | 18 ------------------ 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/bundled/tool/lsp_server.py b/bundled/tool/lsp_server.py index f194f4c..19d3ebd 100644 --- a/bundled/tool/lsp_server.py +++ b/bundled/tool/lsp_server.py @@ -226,7 +226,7 @@ def _get_document_path(document: workspace.Document) -> str: file:///path/to/file.py -> /path/to/file.py vscode-notebook-cell:/path/to/notebook.ipynb#C00001 -> /path/to/notebook.ipynb """ - if not document.uri.startswith("file:"): + if document.uri.startswith("vscode-notebook-cell:"): return uris.to_fs_path( document.uri.split("#")[0].replace("vscode-notebook-cell:", "file:", 1) ) diff --git a/src/test/python_tests/test_notebook.py b/src/test/python_tests/test_notebook.py index 316ce46..5ae47fe 100644 --- a/src/test/python_tests/test_notebook.py +++ b/src/test/python_tests/test_notebook.py @@ -33,24 +33,6 @@ def _make_cell_uri(notebook_path: str, cell_id: str) -> str: return f"{cell_uri}#{cell_id}" -def _collect_diagnostics(ls_session, count: int, timeout: int = TIMEOUT) -> list: - """Collect up to *count* publishDiagnostics notifications within *timeout* seconds. - - Returns a list of params dicts, one per notification received. - """ - collected = [] - done = Event() - - def _handler(params): - collected.append(params) - if len(collected) >= count: - done.set() - - ls_session.set_notification_callback(session.PUBLISH_DIAGNOSTICS, _handler) - done.wait(timeout) - return collected - - def test_notebook_did_open(): """Diagnostics are published for each code cell when a notebook is opened. From 36ebd990788e7724d1ad2f7689851cb977696c96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:44:05 +0000 Subject: [PATCH 6/7] Use urllib.parse for URI handling in _get_document_path Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> --- bundled/tool/lsp_server.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bundled/tool/lsp_server.py b/bundled/tool/lsp_server.py index 19d3ebd..f8d330e 100644 --- a/bundled/tool/lsp_server.py +++ b/bundled/tool/lsp_server.py @@ -11,6 +11,7 @@ import sys import sysconfig import traceback +import urllib.parse from typing import Any, Optional, Sequence @@ -226,10 +227,10 @@ def _get_document_path(document: workspace.Document) -> str: file:///path/to/file.py -> /path/to/file.py vscode-notebook-cell:/path/to/notebook.ipynb#C00001 -> /path/to/notebook.ipynb """ - if document.uri.startswith("vscode-notebook-cell:"): - return uris.to_fs_path( - document.uri.split("#")[0].replace("vscode-notebook-cell:", "file:", 1) - ) + parsed = urllib.parse.urlparse(document.uri) + if parsed.scheme == "vscode-notebook-cell": + file_uri = urllib.parse.urlunparse(parsed._replace(scheme="file", fragment="")) + return uris.to_fs_path(file_uri) return uris.to_fs_path(document.uri) From eccfea2b0bce47434946127608880450dacf0042 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:57:24 +0000 Subject: [PATCH 7/7] Avoid private _replace in urlunparse; simplify cell-kind filter to URI set Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> --- bundled/tool/lsp_server.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bundled/tool/lsp_server.py b/bundled/tool/lsp_server.py index f8d330e..33771b2 100644 --- a/bundled/tool/lsp_server.py +++ b/bundled/tool/lsp_server.py @@ -170,14 +170,13 @@ def notebook_did_change(params: lsp.DidChangeNotebookDocumentParams) -> None: # Lint newly added cells (code cells only). if change.cells and change.cells.structure and change.cells.structure.did_open: - # Build a URI→cell map so we can check each cell's kind before linting. - cell_kind_by_uri = { - cell.document: cell.kind + code_cell_uris = { + cell.document for cell in nb.cells - if cell.document is not None + if cell.kind == lsp.NotebookCellKind.Code and cell.document is not None } for cell_doc in change.cells.structure.did_open: - if cell_kind_by_uri.get(cell_doc.uri) != lsp.NotebookCellKind.Code: + if cell_doc.uri not in code_cell_uris: continue document = LSP_SERVER.workspace.get_text_document(cell_doc.uri) diagnostics = _linting_helper(document) @@ -229,7 +228,9 @@ def _get_document_path(document: workspace.Document) -> str: """ parsed = urllib.parse.urlparse(document.uri) if parsed.scheme == "vscode-notebook-cell": - file_uri = urllib.parse.urlunparse(parsed._replace(scheme="file", fragment="")) + file_uri = urllib.parse.urlunparse( + ("file", parsed.netloc, parsed.path, parsed.params, parsed.query, "") + ) return uris.to_fs_path(file_uri) return uris.to_fs_path(document.uri)