diff --git a/bundled/tool/lsp_server.py b/bundled/tool/lsp_server.py index c0236ba8..92f42bce 100644 --- a/bundled/tool/lsp_server.py +++ b/bundled/tool/lsp_server.py @@ -15,6 +15,7 @@ import uuid from dataclasses import dataclass from typing import Any, Dict, List, Optional, Sequence +from urllib.parse import urlparse, urlunparse # ********************************************************** @@ -75,11 +76,49 @@ def update_environ_path() -> None: GLOBAL_SETTINGS = {} MAX_WORKERS = 5 -LSP_SERVER = LanguageServer(name="Mypy", version="v0.1.0", max_workers=MAX_WORKERS) +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, +) +LSP_SERVER = LanguageServer( + name="Mypy", + version="v0.1.0", + max_workers=MAX_WORKERS, + notebook_document_sync=NOTEBOOK_SYNC_OPTIONS, +) DMYPY_ARGS = {} DMYPY_STATUS_FILE_ROOT = None + +def _get_document_path(document: TextDocument) -> str: + """Returns the filesystem path for a document. + + 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:"): + parsed = urlparse(document.uri) + file_uri = urlunparse(("file", *parsed[1:-1], "")) + if result := uris.to_fs_path(file_uri): + return result + return document.path + + # ********************************************************** # Tool specific code goes below this. # ********************************************************** @@ -183,6 +222,70 @@ def did_close(params: lsp.DidCloseTextDocumentParams) -> None: _clear_diagnostics(document) +@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_OPEN) +def notebook_did_open(params: lsp.DidOpenNotebookDocumentParams) -> None: + """Run diagnostics on each code cell when a notebook is opened.""" + 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) + _linting_helper(document) + + +@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_CHANGE) +def notebook_did_change(params: lsp.DidChangeNotebookDocumentParams) -> None: + """Re-lint cells whose text changed or that were newly added.""" + if params.change is None or params.change.cells is None: + return + + for cell_content in params.change.cells.text_content or []: + document = LSP_SERVER.workspace.get_text_document(cell_content.document.uri) + _linting_helper(document) + + structure = params.change.cells.structure + if structure and structure.did_open: + for cell_doc in structure.did_open: + if cell_doc.language_id != "python": + continue + document = LSP_SERVER.workspace.get_text_document(cell_doc.uri) + _linting_helper(document) + + if structure and structure.did_close: + for cell_doc in 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: + """Re-lint all cells when a notebook is saved.""" + 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) + _linting_helper(document) + + +@LSP_SERVER.feature(lsp.NOTEBOOK_DOCUMENT_DID_CLOSE) +def notebook_did_close(params: lsp.DidCloseNotebookDocumentParams) -> None: + """Clear diagnostics for all cells when the notebook is closed.""" + for cell_doc in params.cell_text_documents: + LSP_SERVER.text_document_publish_diagnostics( + lsp.PublishDiagnosticsParams(uri=cell_doc.uri, diagnostics=[]) + ) + + def _is_empty_diagnostics( filepath: str, results: Optional[Dict[str, str | None]] ) -> bool: @@ -210,12 +313,6 @@ def _linting_helper(document: TextDocument) -> None: # deep copy here to prevent accidentally updating global settings. settings = copy.deepcopy(_get_settings_by_document(document)) - if str(document.uri).startswith("vscode-notebook-cell"): - # We don't support running mypy on notebook cells. - log_warning(f"Skipping notebook cells [Not Supported]: {str(document.uri)}") - _clear_diagnostics(document) - return None - if settings["reportingScope"] == "file" and utils.is_stdlib_file(document.path): log_warning( f"Skipping standard library file (stdlib excluded): {document.path}" @@ -290,6 +387,7 @@ def _linting_helper(document: TextDocument) -> None: message=f"Linting failed with error:\r\n{traceback.format_exc()}", ) ) + _clear_diagnostics(document) return [] diff --git a/package.json b/package.json index da9a627f..d814eecc 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ }, "activationEvents": [ "onLanguage:python", - "workspaceContains:mypy.ini" + "workspaceContains:mypy.ini", + "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 060577d4..0b7708cc 100644 --- a/src/test/python_tests/lsp_test_client/session.py +++ b/src/test/python_tests/lsp_test_client/session.py @@ -142,6 +142,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 format 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 00000000..1a4e7305 --- /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 437e2392..90af14da 100644 --- a/src/test/python_tests/test_get_cwd.py +++ b/src/test/python_tests/test_get_cwd.py @@ -25,13 +25,10 @@ def feature(self, *args, **kwargs): def command(self, *args, **kwargs): return lambda f: f - def show_message_log(self, *args, **kwargs): - pass - - def show_message(self, *args, **kwargs): + def window_log_message(self, *args, **kwargs): pass - def window_log_message(self, *args, **kwargs): + def window_show_message(self, *args, **kwargs): pass mock_server = types.ModuleType("pygls.lsp.server") @@ -53,6 +50,10 @@ def window_log_message(self, *args, **kwargs): "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 [ @@ -61,10 +62,19 @@ def window_log_message(self, *args, **kwargs): "DidCloseTextDocumentParams", "DidOpenTextDocumentParams", "DidSaveTextDocumentParams", + "DidChangeNotebookDocumentParams", + "DidCloseNotebookDocumentParams", + "DidOpenNotebookDocumentParams", + "DidSaveNotebookDocumentParams", "DocumentFormattingParams", "InitializeParams", "LogMessageParams", + "NotebookCellKind", + "NotebookCellLanguage", + "NotebookDocumentFilterWithNotebook", + "NotebookDocumentSyncOptions", "Position", + "PublishDiagnosticsParams", "Range", "TextEdit", ]: diff --git a/src/test/python_tests/test_notebook.py b/src/test/python_tests/test_notebook.py new file mode 100644 index 00000000..e04162ad --- /dev/null +++ b/src/test/python_tests/test_notebook.py @@ -0,0 +1,415 @@ +# 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. +""" + +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 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. + """ + 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_initialize_defaults()) + + 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) + # 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.""" + 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_initialize_defaults()) + + # 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) + 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.""" + 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_initialize_defaults()) + + # 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) + 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_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_initialize_defaults()) + + # 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.""" + 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_initialize_defaults()) + + # Open notebook and wait for the initial diagnostics to arrive + open_done = Event() + + def _open_handler(_params): + open_done.set() + + ls_session.set_notification_callback(session.PUBLISH_DIAGNOSTICS, _open_handler) + + 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, + } + ], + } + ) + + open_done.wait(TIMEOUT) + + # Now set up a fresh callback for the close notification + 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}"