From 13fb7fc55b0db4dd008503ac846d54461fba0faf Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Sun, 15 Oct 2023 23:01:53 +0200 Subject: [PATCH 01/16] Make workspace/didChangeConfig work with notebook documents --- pylsp/workspace.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pylsp/workspace.py b/pylsp/workspace.py index 27af5f83..4ec37a4a 100644 --- a/pylsp/workspace.py +++ b/pylsp/workspace.py @@ -5,6 +5,7 @@ import logging from contextlib import contextmanager import os +from pydoc import doc import re import uuid import functools @@ -167,7 +168,11 @@ def update_document(self, doc_uri, change, version=None): def update_config(self, settings): self._config.update((settings or {}).get("pylsp", {})) for doc_uri in self.documents: - self.get_document(doc_uri).update_config(settings) + if isinstance(document := self.get_document(doc_uri), Notebook): + # Notebook documents don't have a config. The config is + # handled on the cell level. + return + document.update_config(settings) def apply_edit(self, edit): return self._endpoint.request(self.M_APPLY_EDIT, {"edit": edit}) From 7f77bade7dc121cfd3aade8d6af11c2c53b77426 Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Sun, 15 Oct 2023 23:35:36 +0200 Subject: [PATCH 02/16] add unit test --- test/test_notebook_document.py | 57 ++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/test/test_notebook_document.py b/test/test_notebook_document.py index 6050b58f..15f187d3 100644 --- a/test/test_notebook_document.py +++ b/test/test_notebook_document.py @@ -2,11 +2,11 @@ import os import time -from unittest.mock import patch, call +from unittest.mock import patch, call from test.fixtures import CALL_TIMEOUT_IN_SECONDS - import pytest +from pylsp.workspace import Notebook from pylsp import IS_WIN from pylsp.lsp import NotebookCellKind @@ -37,6 +37,59 @@ def test_initialize(client_server_pair): assert isinstance(selector, list) +@pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") +def test_workspace_did_change_configuration(client_server_pair): + """Test that we can update a workspace config w/o error when a notebook is open.""" + client, server = client_server_pair + client._endpoint.request( + "initialize", + { + "processId": 1234, + "rootPath": os.path.dirname(__file__), + }, + ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + assert server.workspace is not None + + with patch.object(server._endpoint, "notify") as mock_notify: + client._endpoint.notify( + "notebookDocument/didOpen", + { + "notebookDocument": { + "uri": "notebook_uri", + "notebookType": "jupyter-notebook", + "cells": [ + { + "kind": NotebookCellKind.Code, + "document": "cell_1_uri", + }, + ], + }, + "cellTextDocuments": [ + { + "uri": "cell_1_uri", + "languageId": "python", + "text": "", + }, + ], + }, + ) + wait_for_condition(lambda: mock_notify.call_count >= 1) + assert isinstance(server.workspace.get_document("notebook_uri"), Notebook) + assert len(server.workspace.documents) == 2 + + server.workspace.update_config( + {"pylsp": {"plugins": {"flake8": {"enabled": True}}}} + ) + + assert server.config.plugin_settings("flake8").get("enabled") is True + assert ( + server.workspace.get_document("cell_1_uri") + ._config.plugin_settings("flake8") + .get("enabled") + is True + ) + + @pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") def test_notebook_document__did_open( client_server_pair, From a6ba8cd79dd61fa83cde7ef0e8a78d5d3e020a09 Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Sun, 15 Oct 2023 23:37:07 +0200 Subject: [PATCH 03/16] fix lint issue --- pylsp/workspace.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pylsp/workspace.py b/pylsp/workspace.py index 4ec37a4a..a08e09c6 100644 --- a/pylsp/workspace.py +++ b/pylsp/workspace.py @@ -5,7 +5,6 @@ import logging from contextlib import contextmanager import os -from pydoc import doc import re import uuid import functools From 42c575580503ec91f7d43e28b26b73ea8e99f617 Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Mon, 16 Oct 2023 17:59:05 +0200 Subject: [PATCH 04/16] wip --- pylsp/plugins/rope_autoimport.py | 6 +++++- pylsp/workspace.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pylsp/plugins/rope_autoimport.py b/pylsp/plugins/rope_autoimport.py index c13907a4..8a63c714 100644 --- a/pylsp/plugins/rope_autoimport.py +++ b/pylsp/plugins/rope_autoimport.py @@ -205,6 +205,7 @@ def _reload_cache( ): memory: bool = config.plugin_settings("rope_autoimport").get("memory", False) rope_config = config.settings().get("rope", {}) + log.debug("[_reload_cache] rope_config = %s", rope_config) autoimport = workspace._rope_autoimport(rope_config, memory) task_handle = PylspTaskHandle(workspace) resources: Optional[List[Resource]] = ( @@ -212,6 +213,7 @@ def _reload_cache( if files is None else [document._rope_resource(rope_config) for document in files] ) + log.debug("[_reload_cache] resources = %s", resources) autoimport.generate_cache(task_handle=task_handle, resources=resources) autoimport.generate_modules_cache(task_handle=task_handle) @@ -241,7 +243,7 @@ def pylsp_document_did_save(config: Config, workspace: Workspace, document: Docu @hookimpl -def pylsp_workspace_configuration_chaged(config: Config, workspace: Workspace): +def pylsp_workspace_configuration_changed(config: Config, workspace: Workspace): """ Initialize autoimport if it has been enabled through a workspace/didChangeConfiguration message from the frontend. @@ -250,3 +252,5 @@ def pylsp_workspace_configuration_chaged(config: Config, workspace: Workspace): """ if config.plugin_settings("rope_autoimport").get("enabled", False): _reload_cache(config, workspace) + else: + log.debug("autoimport: Skipping cache reload.") diff --git a/pylsp/workspace.py b/pylsp/workspace.py index a08e09c6..dc47c1d9 100644 --- a/pylsp/workspace.py +++ b/pylsp/workspace.py @@ -78,8 +78,10 @@ def _rope_project_builder(self, rope_config): rope_folder = rope_config.get("ropeFolder") if rope_folder: self.__rope = Project(self._root_path, ropefolder=rope_folder) + log.debug("[_rope_project_builder] root_path = %s, ropenfolder = %s", self._root_path, rope_folder) else: self.__rope = Project(self._root_path) + log.debug("[_rope_project_builder] root_path = %s, ropenfolder = '.ropeproject'", (self._root_path)) self.__rope.prefs.set( "extension_modules", rope_config.get("extensionModules", []) ) From 535f2ed45b868c25906a930de58d2ffaea5a8bc0 Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Tue, 17 Oct 2023 18:26:07 +0200 Subject: [PATCH 05/16] remove log.debug --- pylsp/plugins/rope_autoimport.py | 2 -- pylsp/workspace.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/pylsp/plugins/rope_autoimport.py b/pylsp/plugins/rope_autoimport.py index 8a63c714..6c4784aa 100644 --- a/pylsp/plugins/rope_autoimport.py +++ b/pylsp/plugins/rope_autoimport.py @@ -205,7 +205,6 @@ def _reload_cache( ): memory: bool = config.plugin_settings("rope_autoimport").get("memory", False) rope_config = config.settings().get("rope", {}) - log.debug("[_reload_cache] rope_config = %s", rope_config) autoimport = workspace._rope_autoimport(rope_config, memory) task_handle = PylspTaskHandle(workspace) resources: Optional[List[Resource]] = ( @@ -213,7 +212,6 @@ def _reload_cache( if files is None else [document._rope_resource(rope_config) for document in files] ) - log.debug("[_reload_cache] resources = %s", resources) autoimport.generate_cache(task_handle=task_handle, resources=resources) autoimport.generate_modules_cache(task_handle=task_handle) diff --git a/pylsp/workspace.py b/pylsp/workspace.py index dc47c1d9..a08e09c6 100644 --- a/pylsp/workspace.py +++ b/pylsp/workspace.py @@ -78,10 +78,8 @@ def _rope_project_builder(self, rope_config): rope_folder = rope_config.get("ropeFolder") if rope_folder: self.__rope = Project(self._root_path, ropefolder=rope_folder) - log.debug("[_rope_project_builder] root_path = %s, ropenfolder = %s", self._root_path, rope_folder) else: self.__rope = Project(self._root_path) - log.debug("[_rope_project_builder] root_path = %s, ropenfolder = '.ropeproject'", (self._root_path)) self.__rope.prefs.set( "extension_modules", rope_config.get("extensionModules", []) ) From b59ed85f2cde8c0fb23fe62bccb0f67d6ef33b6c Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Wed, 18 Oct 2023 01:05:47 +0200 Subject: [PATCH 06/16] rope autoimport - ignore names from the notebook document when doing completions on a cell --- pylsp/hookspecs.py | 2 +- pylsp/plugins/rope_autoimport.py | 14 +++-- pylsp/python_lsp.py | 13 ++++- pylsp/workspace.py | 10 ++++ test/fixtures.py | 19 ++++--- test/plugins/test_autoimport.py | 89 ++++++++++++++++++-------------- test/test_notebook_document.py | 7 +-- test/test_utils.py | 57 ++++++++++++++++++++ 8 files changed, 155 insertions(+), 56 deletions(-) diff --git a/pylsp/hookspecs.py b/pylsp/hookspecs.py index d732b1d0..9c9cf387 100644 --- a/pylsp/hookspecs.py +++ b/pylsp/hookspecs.py @@ -25,7 +25,7 @@ def pylsp_commands(config, workspace): @hookspec -def pylsp_completions(config, workspace, document, position): +def pylsp_completions(config, workspace, document, position, ignored_names): pass diff --git a/pylsp/plugins/rope_autoimport.py b/pylsp/plugins/rope_autoimport.py index 6c4784aa..6997b594 100644 --- a/pylsp/plugins/rope_autoimport.py +++ b/pylsp/plugins/rope_autoimport.py @@ -1,7 +1,7 @@ # Copyright 2022- Python Language Server Contributors. import logging -from typing import Any, Dict, Generator, List, Optional, Set +from typing import Any, Dict, Generator, List, Optional, Set, Union import parso from jedi import Script @@ -153,7 +153,11 @@ def get_names(script: Script) -> Set[str]: @hookimpl def pylsp_completions( - config: Config, workspace: Workspace, document: Document, position + config: Config, + workspace: Workspace, + document: Document, + position, + ignored_names: Union[Set[str], None], ): """Get autoimport suggestions.""" line = document.lines[position["line"]] @@ -164,9 +168,13 @@ def pylsp_completions( word = word_node.value log.debug(f"autoimport: searching for word: {word}") rope_config = config.settings(document_path=document.path).get("rope", {}) - ignored_names: Set[str] = get_names(document.jedi_script(use_document_path=True)) + ignored_names: Set[str] = ignored_names or get_names( + document.jedi_script(use_document_path=True) + ) + log.debug("autoimport: ignored names: %s", ignored_names) autoimport = workspace._rope_autoimport(rope_config) suggestions = list(autoimport.search_full(word, ignored_names=ignored_names)) + log.debug("autoimport: suggestions: %s", suggestions) results = list( sorted( _process_statements(suggestions, document.uri, word, autoimport, document), diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py index 4c3ea0d2..222239a0 100644 --- a/pylsp/python_lsp.py +++ b/pylsp/python_lsp.py @@ -394,7 +394,18 @@ def code_lens(self, doc_uri): return flatten(self._hook("pylsp_code_lens", doc_uri)) def completions(self, doc_uri, position): - completions = self._hook("pylsp_completions", doc_uri, position=position) + workspace = self._match_uri_to_workspace(doc_uri) + document = workspace.get_document(doc_uri) + ignored_names = None + if isinstance(document, Cell): + log.debug("Getting ignored names from notebook document") + # We need to get the ignored names from the whole notebook document + notebook_document = workspace.get_maybe_document(document.notebook_uri) + ignored_names = notebook_document.jedi_names() + log.debug("Got ignored names from notebook document: %s", ignored_names) + completions = self._hook( + "pylsp_completions", doc_uri, position=position, ignored_names=ignored_names + ) return {"isIncomplete": False, "items": flatten(completions)} def completion_item_resolve(self, completion_item): diff --git a/pylsp/workspace.py b/pylsp/workspace.py index a08e09c6..09559004 100644 --- a/pylsp/workspace.py +++ b/pylsp/workspace.py @@ -595,6 +595,7 @@ def __init__( self.version = version self.cells = cells or [] self.metadata = metadata or {} + self._lock = RLock() def __str__(self): return "Notebook with URI '%s'" % str(self.uri) @@ -625,6 +626,15 @@ def cell_data(self): offset += num_lines return cell_data + @lock + def jedi_names(self, all_scopes=False, definitions=True, references=False): + """Get all names to ignore from all cells.""" + names = set() + for cell in self.cells: + cell_document = self.workspace.get_cell_document(cell["document"]) + names.update(cell_document.jedi_names(all_scopes, definitions, references)) + return set(name.name for name in names) + class Cell(Document): """ diff --git a/test/fixtures.py b/test/fixtures.py index 03d0f824..b1485eac 100644 --- a/test/fixtures.py +++ b/test/fixtures.py @@ -5,7 +5,8 @@ from io import StringIO from unittest.mock import MagicMock -from test.test_utils import ClientServerPair +from test.test_utils import ClientServerPair, CALL_TIMEOUT_IN_SECONDS +import pylsp_jsonrpc import pytest from pylsp_jsonrpc.dispatchers import MethodDispatcher @@ -24,7 +25,6 @@ def main(): print sys.stdin.read() """ -CALL_TIMEOUT_IN_SECONDS = 30 class FakeEditorMethodsMixin: @@ -175,8 +175,13 @@ def client_server_pair(): yield (client_server_pair_obj.client, client_server_pair_obj.server) - shutdown_response = client_server_pair_obj.client._endpoint.request( - "shutdown" - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) - assert shutdown_response is None - client_server_pair_obj.client._endpoint.notify("exit") + try: + shutdown_response = client_server_pair_obj.client._endpoint.request( + "shutdown" + ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + assert shutdown_response is None + client_server_pair_obj.client._endpoint.notify("exit") + except pylsp_jsonrpc.exceptions.JsonRpcInvalidParams: + # SQLite objects created in a thread can only be used in that same thread. + # This exeception is raised when testing rope autoimport. + client_server_pair_obj.client._endpoint.notify("exit") diff --git a/test/plugins/test_autoimport.py b/test/plugins/test_autoimport.py index dbb6f7a4..43feccdb 100644 --- a/test/plugins/test_autoimport.py +++ b/test/plugins/test_autoimport.py @@ -1,13 +1,18 @@ # Copyright 2022- Python Language Server Contributors. from typing import Dict, List -from unittest.mock import Mock +from unittest.mock import Mock, patch + +from test.test_notebook_document import wait_for_condition +from test.test_utils import send_initialize_request, send_notebook_did_open import jedi import parso import pytest -from pylsp import lsp, uris +from pylsp import IS_WIN, uris +from pylsp import lsp + from pylsp.config.config import Config from pylsp.plugins.rope_autoimport import _get_score, _should_insert, get_names from pylsp.plugins.rope_autoimport import ( @@ -16,6 +21,7 @@ from pylsp.plugins.rope_autoimport import pylsp_initialize from pylsp.workspace import Workspace + DOC_URI = uris.from_fs_path(__file__) @@ -39,7 +45,9 @@ def completions(config: Config, autoimport_workspace: Workspace, request): com_position = {"line": 0, "character": position} autoimport_workspace.put_document(DOC_URI, source=document) doc = autoimport_workspace.get_document(DOC_URI) - yield pylsp_autoimport_completions(config, autoimport_workspace, doc, com_position) + yield pylsp_autoimport_completions( + config, autoimport_workspace, doc, com_position, None + ) autoimport_workspace.rm_document(DOC_URI) @@ -141,45 +149,13 @@ def test_autoimport_defined_name(config, workspace): com_position = {"line": 1, "character": 3} workspace.put_document(DOC_URI, source=document) doc = workspace.get_document(DOC_URI) - completions = pylsp_autoimport_completions(config, workspace, doc, com_position) + completions = pylsp_autoimport_completions( + config, workspace, doc, com_position, None + ) workspace.rm_document(DOC_URI) assert not check_dict({"label": "List"}, completions) -# This test has several large issues. -# 1. autoimport relies on its sources being written to disk. This makes testing harder -# 2. the hook doesn't handle removed files -# 3. The testing framework cannot access the actual autoimport object so it cannot clear the cache -# def test_autoimport_update_module(config: Config, workspace: Workspace): -# document2 = "SomethingYouShouldntWrite = 1" -# document = """SomethingYouShouldntWrit""" -# com_position = { -# "line": 0, -# "character": 3, -# } -# doc2_path = workspace.root_path + "/test_file_no_one_should_write_to.py" -# if os.path.exists(doc2_path): -# os.remove(doc2_path) -# DOC2_URI = uris.from_fs_path(doc2_path) -# workspace.put_document(DOC_URI, source=document) -# doc = workspace.get_document(DOC_URI) -# completions = pylsp_autoimport_completions(config, workspace, doc, com_position) -# assert len(completions) == 0 -# with open(doc2_path, "w") as f: -# f.write(document2) -# workspace.put_document(DOC2_URI, source=document2) -# doc2 = workspace.get_document(DOC2_URI) -# pylsp_document_did_save(config, workspace, doc2) -# assert check_dict({"label": "SomethingYouShouldntWrite"}, completions) -# workspace.put_document(DOC2_URI, source="\n") -# doc2 = workspace.get_document(DOC2_URI) -# os.remove(doc2_path) -# pylsp_document_did_save(config, workspace, doc2) -# completions = pylsp_autoimport_completions(config, workspace, doc, com_position) -# assert len(completions) == 0 -# workspace.rm_document(DOC_URI) - - class TestShouldInsert: def test_dot(self): assert not should_insert("""str.""", 4) @@ -233,3 +209,40 @@ class sfa: """ results = get_names(jedi.Script(code=source)) assert results == set(["blah", "bleh", "e", "hello", "someone", "sfa", "a", "b"]) + + +@pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") +def test_autoimport_for_notebook_document( + client_server_pair, +): + client, server = client_server_pair + send_initialize_request(client) + + with patch.object(server._endpoint, "notify") as mock_notify: + send_notebook_did_open(client, ["import os", "os", "sys"]) + wait_for_condition(lambda: mock_notify.call_count >= 3) + + server.m_workspace__did_change_configuration( + settings={ + "pylsp": {"plugins": {"rope_autoimport": {"enabled": True, "memory": True}}} + } + ) + rope_autoimport_settings = server.workspace._config.plugin_settings( + "rope_autoimport" + ) + assert rope_autoimport_settings.get("enabled", False) is True + assert rope_autoimport_settings.get("memory", False) is True + + suggestions = server.completions("cell_1_uri", {"line": 0, "character": 2}).get( + "items" + ) + assert not any( + suggestion for suggestion in suggestions if suggestion.get("label", "") == "os" + ) + + suggestions = server.completions("cell_2_uri", {"line": 0, "character": 3}).get( + "items" + ) + assert any( + suggestion for suggestion in suggestions if suggestion.get("label", "") == "sys" + ) diff --git a/test/test_notebook_document.py b/test/test_notebook_document.py index 15f187d3..a268ea55 100644 --- a/test/test_notebook_document.py +++ b/test/test_notebook_document.py @@ -4,7 +4,7 @@ import time from unittest.mock import patch, call -from test.fixtures import CALL_TIMEOUT_IN_SECONDS +from test.test_utils import CALL_TIMEOUT_IN_SECONDS import pytest from pylsp.workspace import Notebook @@ -29,7 +29,6 @@ def test_initialize(client_server_pair): { "processId": 1234, "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, }, ).result(timeout=CALL_TIMEOUT_IN_SECONDS) assert server.workspace is not None @@ -100,7 +99,6 @@ def test_notebook_document__did_open( { "processId": 1234, "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, }, ).result(timeout=CALL_TIMEOUT_IN_SECONDS) @@ -264,7 +262,6 @@ def test_notebook_document__did_change( { "processId": 1234, "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, }, ).result(timeout=CALL_TIMEOUT_IN_SECONDS) @@ -536,7 +533,6 @@ def test_notebook__did_close( { "processId": 1234, "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, }, ).result(timeout=CALL_TIMEOUT_IN_SECONDS) @@ -608,7 +604,6 @@ def test_notebook_definition(client_server_pair): { "processId": 1234, "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, }, ).result(timeout=CALL_TIMEOUT_IN_SECONDS) diff --git a/test/test_utils.py b/test/test_utils.py index bc2782dc..98451182 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -6,15 +6,72 @@ import sys from threading import Thread import time +from typing import List from unittest import mock from flaky import flaky from docstring_to_markdown import UnknownFormatError +from build.lib.pylsp.lsp import NotebookCellKind from pylsp import _utils from pylsp.python_lsp import PythonLSPServer, start_io_lang_server +CALL_TIMEOUT_IN_SECONDS = 30 + + +def send_notebook_did_open(client, cells: List[str]): + """ + Sends a notebookDocument/didOpen notification with the given python cells. + + The notebook has the uri "notebook_uri" and the cells have the uris + "cell_0_uri", "cell_1_uri", etc. + """ + client._endpoint.notify( + "notebookDocument/didOpen", notebook_with_python_cells(cells) + ) + + +def notebook_with_python_cells(cells: List[str]): + """ + Create a notebook document with the given python cells. + + The notebook has the uri "notebook_uri" and the cells have the uris + "cell_0_uri", "cell_1_uri", etc. + """ + return { + "notebookDocument": { + "uri": "notebook_uri", + "notebookType": "jupyter-notebook", + "cells": [ + { + "kind": NotebookCellKind.Code, + "document": f"cell_{i}_uri", + } + for i in range(len(cells)) + ], + }, + "cellTextDocuments": [ + { + "uri": f"cell_{i}_uri", + "languageId": "python", + "text": cell, + } + for i, cell in enumerate(cells) + ], + } + + +def send_initialize_request(client): + client._endpoint.request( + "initialize", + { + "processId": 1234, + "rootPath": os.path.dirname(__file__), + }, + ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + + def start(obj): obj.start() From ec3b92e6b2cacbe91f41f6a05fe21722bd059f14 Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Wed, 18 Oct 2023 01:12:03 +0200 Subject: [PATCH 07/16] remove log.debug --- pylsp/plugins/rope_autoimport.py | 2 -- pylsp/python_lsp.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/pylsp/plugins/rope_autoimport.py b/pylsp/plugins/rope_autoimport.py index 6997b594..1caab35d 100644 --- a/pylsp/plugins/rope_autoimport.py +++ b/pylsp/plugins/rope_autoimport.py @@ -171,10 +171,8 @@ def pylsp_completions( ignored_names: Set[str] = ignored_names or get_names( document.jedi_script(use_document_path=True) ) - log.debug("autoimport: ignored names: %s", ignored_names) autoimport = workspace._rope_autoimport(rope_config) suggestions = list(autoimport.search_full(word, ignored_names=ignored_names)) - log.debug("autoimport: suggestions: %s", suggestions) results = list( sorted( _process_statements(suggestions, document.uri, word, autoimport, document), diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py index 222239a0..c39f837e 100644 --- a/pylsp/python_lsp.py +++ b/pylsp/python_lsp.py @@ -398,11 +398,9 @@ def completions(self, doc_uri, position): document = workspace.get_document(doc_uri) ignored_names = None if isinstance(document, Cell): - log.debug("Getting ignored names from notebook document") # We need to get the ignored names from the whole notebook document notebook_document = workspace.get_maybe_document(document.notebook_uri) ignored_names = notebook_document.jedi_names() - log.debug("Got ignored names from notebook document: %s", ignored_names) completions = self._hook( "pylsp_completions", doc_uri, position=position, ignored_names=ignored_names ) From 964ce19b0e8bb47b5f6d0793b0d69d29d3605dc9 Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Wed, 18 Oct 2023 01:22:31 +0200 Subject: [PATCH 08/16] correct import of NotebookCellKind --- test/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_utils.py b/test/test_utils.py index 98451182..d5640021 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -11,7 +11,7 @@ from flaky import flaky from docstring_to_markdown import UnknownFormatError -from build.lib.pylsp.lsp import NotebookCellKind +from pylsp.lsp import NotebookCellKind from pylsp import _utils from pylsp.python_lsp import PythonLSPServer, start_io_lang_server From 914cd9fb4ec676e2f9cc3cc7d790f246249d6bfc Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Wed, 18 Oct 2023 10:40:36 +0200 Subject: [PATCH 09/16] clean up rope test --- test/plugins/test_autoimport.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/plugins/test_autoimport.py b/test/plugins/test_autoimport.py index 43feccdb..d8f75860 100644 --- a/test/plugins/test_autoimport.py +++ b/test/plugins/test_autoimport.py @@ -10,8 +10,7 @@ import parso import pytest -from pylsp import IS_WIN, uris -from pylsp import lsp +from pylsp import IS_WIN, lsp, uris from pylsp.config.config import Config from pylsp.plugins.rope_autoimport import _get_score, _should_insert, get_names @@ -236,6 +235,7 @@ def test_autoimport_for_notebook_document( suggestions = server.completions("cell_1_uri", {"line": 0, "character": 2}).get( "items" ) + # We don't receive an autoimport suggestion for "os" because it's already imported assert not any( suggestion for suggestion in suggestions if suggestion.get("label", "") == "os" ) @@ -243,6 +243,7 @@ def test_autoimport_for_notebook_document( suggestions = server.completions("cell_2_uri", {"line": 0, "character": 3}).get( "items" ) + # We receive an autoimport suggestion for "sys" because it's not already imported assert any( suggestion for suggestion in suggestions if suggestion.get("label", "") == "sys" ) From ae2a1f0334d237e3cf65ef07ac4a39023f1e7881 Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Wed, 18 Oct 2023 10:56:08 +0200 Subject: [PATCH 10/16] clean up other unit tests --- test/plugins/test_autoimport.py | 4 +- test/test_language_server.py | 11 +- test/test_notebook_document.py | 208 +++----------------------------- test/test_utils.py | 10 +- 4 files changed, 23 insertions(+), 210 deletions(-) diff --git a/test/plugins/test_autoimport.py b/test/plugins/test_autoimport.py index d8f75860..db9f0f77 100644 --- a/test/plugins/test_autoimport.py +++ b/test/plugins/test_autoimport.py @@ -232,7 +232,7 @@ def test_autoimport_for_notebook_document( assert rope_autoimport_settings.get("enabled", False) is True assert rope_autoimport_settings.get("memory", False) is True - suggestions = server.completions("cell_1_uri", {"line": 0, "character": 2}).get( + suggestions = server.completions("cell_2_uri", {"line": 0, "character": 2}).get( "items" ) # We don't receive an autoimport suggestion for "os" because it's already imported @@ -240,7 +240,7 @@ def test_autoimport_for_notebook_document( suggestion for suggestion in suggestions if suggestion.get("label", "") == "os" ) - suggestions = server.completions("cell_2_uri", {"line": 0, "character": 3}).get( + suggestions = server.completions("cell_3_uri", {"line": 0, "character": 3}).get( "items" ) # We receive an autoimport suggestion for "sys" because it's not already imported diff --git a/test/test_language_server.py b/test/test_language_server.py index 280a62fa..401b1ceb 100644 --- a/test/test_language_server.py +++ b/test/test_language_server.py @@ -5,7 +5,7 @@ import time import sys -from test.test_utils import ClientServerPair +from test.test_utils import ClientServerPair, send_initialize_request from flaky import flaky from pylsp_jsonrpc.exceptions import JsonRpcMethodNotFound @@ -73,14 +73,7 @@ def test_not_exit_without_check_parent_process_flag( client_server_pair, ): client, _ = client_server_pair - response = client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + response = send_initialize_request(client) assert "capabilities" in response diff --git a/test/test_notebook_document.py b/test/test_notebook_document.py index f5ec4ce0..a9c5e35f 100644 --- a/test/test_notebook_document.py +++ b/test/test_notebook_document.py @@ -1,10 +1,11 @@ # Copyright 2021- Python Language Server Contributors. -import os import time from unittest.mock import patch, call from test.fixtures import CALL_TIMEOUT_IN_SECONDS +from test.test_utils import send_initialize_request, send_notebook_did_open + import pytest from pylsp.workspace import Notebook @@ -24,13 +25,7 @@ def wait_for_condition(condition, timeout=CALL_TIMEOUT_IN_SECONDS): @pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") def test_initialize(client_server_pair): client, server = client_server_pair - response = client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + response = send_initialize_request(client) assert server.workspace is not None selector = response["capabilities"]["notebookDocumentSync"]["notebookSelector"] assert isinstance(selector, list) @@ -40,13 +35,7 @@ def test_initialize(client_server_pair): def test_workspace_did_change_configuration(client_server_pair): """Test that we can update a workspace config w/o error when a notebook is open.""" client, server = client_server_pair - client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + send_initialize_request(client) assert server.workspace is not None with patch.object(server._endpoint, "notify") as mock_notify: @@ -94,73 +83,12 @@ def test_notebook_document__did_open( client_server_pair, ): client, server = client_server_pair - client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + send_initialize_request(client) with patch.object(server._endpoint, "notify") as mock_notify: - client._endpoint.notify( - "notebookDocument/didOpen", - { - "notebookDocument": { - "uri": "notebook_uri", - "notebookType": "jupyter-notebook", - "cells": [ - { - "kind": NotebookCellKind.Code, - "document": "cell_1_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_2_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_3_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_4_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_5_uri", - }, - ], - }, - # Test as many edge cases as possible for the diagnostics message - "cellTextDocuments": [ - { - "uri": "cell_1_uri", - "languageId": "python", - "text": "", - }, - { - "uri": "cell_2_uri", - "languageId": "python", - "text": "\n", - }, - { - "uri": "cell_3_uri", - "languageId": "python", - "text": "\nimport sys\n\nabc\n\n", - }, - { - "uri": "cell_4_uri", - "languageId": "python", - "text": "x", - }, - { - "uri": "cell_5_uri", - "languageId": "python", - "text": "y\n", - }, - ], - }, + # Test as many edge cases as possible for the diagnostics messages + send_notebook_did_open( + client, ["", "\n", "\nimport sys\n\nabc\n\n", "x", "y\n"] ) wait_for_condition(lambda: mock_notify.call_count >= 5) expected_call_args = [ @@ -257,47 +185,11 @@ def test_notebook_document__did_change( client_server_pair, ): client, server = client_server_pair - client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + send_initialize_request(client) # Open notebook with patch.object(server._endpoint, "notify") as mock_notify: - client._endpoint.notify( - "notebookDocument/didOpen", - { - "notebookDocument": { - "uri": "notebook_uri", - "notebookType": "jupyter-notebook", - "cells": [ - { - "kind": NotebookCellKind.Code, - "document": "cell_1_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_2_uri", - }, - ], - }, - "cellTextDocuments": [ - { - "uri": "cell_1_uri", - "languageId": "python", - "text": "import sys", - }, - { - "uri": "cell_2_uri", - "languageId": "python", - "text": "", - }, - ], - }, - ) + send_notebook_did_open(client, ["import sys", ""]) wait_for_condition(lambda: mock_notify.call_count >= 2) assert len(server.workspace.documents) == 3 for uri in ["cell_1_uri", "cell_2_uri", "notebook_uri"]: @@ -528,47 +420,11 @@ def test_notebook__did_close( client_server_pair, ): client, server = client_server_pair - client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + send_initialize_request(client) # Open notebook with patch.object(server._endpoint, "notify") as mock_notify: - client._endpoint.notify( - "notebookDocument/didOpen", - { - "notebookDocument": { - "uri": "notebook_uri", - "notebookType": "jupyter-notebook", - "cells": [ - { - "kind": NotebookCellKind.Code, - "document": "cell_1_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_2_uri", - }, - ], - }, - "cellTextDocuments": [ - { - "uri": "cell_1_uri", - "languageId": "python", - "text": "import sys", - }, - { - "uri": "cell_2_uri", - "languageId": "python", - "text": "", - }, - ], - }, - ) + send_notebook_did_open(client, ["import sys", ""]) wait_for_condition(lambda: mock_notify.call_count >= 2) assert len(server.workspace.documents) == 3 for uri in ["cell_1_uri", "cell_2_uri", "notebook_uri"]: @@ -599,47 +455,11 @@ def test_notebook__did_close( @pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") def test_notebook_definition(client_server_pair): client, server = client_server_pair - client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + send_initialize_request(client) # Open notebook with patch.object(server._endpoint, "notify") as mock_notify: - client._endpoint.notify( - "notebookDocument/didOpen", - { - "notebookDocument": { - "uri": "notebook_uri", - "notebookType": "jupyter-notebook", - "cells": [ - { - "kind": NotebookCellKind.Code, - "document": "cell_1_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_2_uri", - }, - ], - }, - "cellTextDocuments": [ - { - "uri": "cell_1_uri", - "languageId": "python", - "text": "y=2\nx=1", - }, - { - "uri": "cell_2_uri", - "languageId": "python", - "text": "x", - }, - ], - }, - ) + send_notebook_did_open(client, ["y=2\nx=1", "x"]) # wait for expected diagnostics messages wait_for_condition(lambda: mock_notify.call_count >= 2) assert len(server.workspace.documents) == 3 diff --git a/test/test_utils.py b/test/test_utils.py index d5640021..b1b6ad2c 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -25,7 +25,7 @@ def send_notebook_did_open(client, cells: List[str]): Sends a notebookDocument/didOpen notification with the given python cells. The notebook has the uri "notebook_uri" and the cells have the uris - "cell_0_uri", "cell_1_uri", etc. + "cell_1_uri", "cell_2_uri", etc. """ client._endpoint.notify( "notebookDocument/didOpen", notebook_with_python_cells(cells) @@ -37,7 +37,7 @@ def notebook_with_python_cells(cells: List[str]): Create a notebook document with the given python cells. The notebook has the uri "notebook_uri" and the cells have the uris - "cell_0_uri", "cell_1_uri", etc. + "cell_1_uri", "cell_2_uri", etc. """ return { "notebookDocument": { @@ -46,14 +46,14 @@ def notebook_with_python_cells(cells: List[str]): "cells": [ { "kind": NotebookCellKind.Code, - "document": f"cell_{i}_uri", + "document": f"cell_{i+1}_uri", } for i in range(len(cells)) ], }, "cellTextDocuments": [ { - "uri": f"cell_{i}_uri", + "uri": f"cell_{i+1}_uri", "languageId": "python", "text": cell, } @@ -63,7 +63,7 @@ def notebook_with_python_cells(cells: List[str]): def send_initialize_request(client): - client._endpoint.request( + return client._endpoint.request( "initialize", { "processId": 1234, From 71b210b5459756eb1da090408253ffe8c1009fe2 Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Wed, 18 Oct 2023 11:44:22 +0200 Subject: [PATCH 11/16] support only seraching for jedi names up to a certain cell --- pylsp/python_lsp.py | 2 +- pylsp/workspace.py | 19 ++++++++++-- test/plugins/test_autoimport.py | 55 ++++++++++++++++++++++++++++----- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py index c39f837e..760ad974 100644 --- a/pylsp/python_lsp.py +++ b/pylsp/python_lsp.py @@ -400,7 +400,7 @@ def completions(self, doc_uri, position): if isinstance(document, Cell): # We need to get the ignored names from the whole notebook document notebook_document = workspace.get_maybe_document(document.notebook_uri) - ignored_names = notebook_document.jedi_names() + ignored_names = notebook_document.jedi_names(doc_uri) completions = self._hook( "pylsp_completions", doc_uri, position=position, ignored_names=ignored_names ) diff --git a/pylsp/workspace.py b/pylsp/workspace.py index 0516fe0e..d40c1e12 100644 --- a/pylsp/workspace.py +++ b/pylsp/workspace.py @@ -627,12 +627,25 @@ def cell_data(self): return cell_data @lock - def jedi_names(self, all_scopes=False, definitions=True, references=False): - """Get all names to ignore from all cells.""" + def jedi_names( + self, + up_to_cell_uri: Optional[str] = None, + all_scopes=False, + definitions=True, + references=False, + ): + """ + Get the names in the notebook up to a certain cell. + + :param up_to_cell_uri: The cell uri to stop at. If None, all cells are considered. + """ names = set() for cell in self.cells: - cell_document = self.workspace.get_cell_document(cell["document"]) + cell_uri = cell["document"] + cell_document = self.workspace.get_cell_document(cell_uri) names.update(cell_document.jedi_names(all_scopes, definitions, references)) + if cell_uri == up_to_cell_uri: + break return set(name.name for name in names) diff --git a/test/plugins/test_autoimport.py b/test/plugins/test_autoimport.py index db9f0f77..51841cbe 100644 --- a/test/plugins/test_autoimport.py +++ b/test/plugins/test_autoimport.py @@ -1,6 +1,6 @@ # Copyright 2022- Python Language Server Contributors. -from typing import Dict, List +from typing import Any, Dict, List from unittest.mock import Mock, patch from test.test_notebook_document import wait_for_condition @@ -24,6 +24,13 @@ DOC_URI = uris.from_fs_path(__file__) +def contains_autoimport(suggestion: Dict[str, Any], module: str) -> bool: + """Checks if `suggestion` contains an autoimport for `module`.""" + return suggestion.get("label", "") == module and "import" in suggestion.get( + "detail", "" + ) + + @pytest.fixture(scope="session") def autoimport_workspace(tmp_path_factory) -> Workspace: "Special autoimport workspace. Persists across sessions to make in-memory sqlite3 database fast." @@ -218,7 +225,15 @@ def test_autoimport_for_notebook_document( send_initialize_request(client) with patch.object(server._endpoint, "notify") as mock_notify: - send_notebook_did_open(client, ["import os", "os", "sys"]) + # Expectations: + # 1. We receive an autoimport suggestion for "os" in the first cell because + # os is imported after that. + # 2. We don't receive an autoimport suggestion for "os" in the second cell because it's + # already imported in the second cell. + # 3. We don't receive an autoimport suggestion for "os" in the third cell because it's + # already imported in the second cell. + # 4. We receive an autoimport suggestion for "sys" because it's not already imported + send_notebook_did_open(client, ["os", "import os\nos", "os", "sys"]) wait_for_condition(lambda: mock_notify.call_count >= 3) server.m_workspace__did_change_configuration( @@ -232,18 +247,42 @@ def test_autoimport_for_notebook_document( assert rope_autoimport_settings.get("enabled", False) is True assert rope_autoimport_settings.get("memory", False) is True - suggestions = server.completions("cell_2_uri", {"line": 0, "character": 2}).get( + # 1. + suggestions = server.completions("cell_1_uri", {"line": 0, "character": 2}).get( + "items" + ) + assert any( + suggestion + for suggestion in suggestions + if contains_autoimport(suggestion, "os") + ) + + # 2. + suggestions = server.completions("cell_2_uri", {"line": 1, "character": 2}).get( + "items" + ) + assert not any( + suggestion + for suggestion in suggestions + if contains_autoimport(suggestion, "os") + ) + + # 3. + suggestions = server.completions("cell_3_uri", {"line": 0, "character": 2}).get( "items" ) - # We don't receive an autoimport suggestion for "os" because it's already imported assert not any( - suggestion for suggestion in suggestions if suggestion.get("label", "") == "os" + suggestion + for suggestion in suggestions + if contains_autoimport(suggestion, "os") ) - suggestions = server.completions("cell_3_uri", {"line": 0, "character": 3}).get( + # 4. + suggestions = server.completions("cell_4_uri", {"line": 0, "character": 3}).get( "items" ) - # We receive an autoimport suggestion for "sys" because it's not already imported assert any( - suggestion for suggestion in suggestions if suggestion.get("label", "") == "sys" + suggestion + for suggestion in suggestions + if contains_autoimport(suggestion, "sys") ) From 6dbb80ec2b5a9511fc739954eff1676489798bd4 Mon Sep 17 00:00:00 2001 From: tkrabel-db <91616041+tkrabel-db@users.noreply.github.com> Date: Wed, 18 Oct 2023 18:58:03 +0200 Subject: [PATCH 12/16] Update pylsp/workspace.py Co-authored-by: Carlos Cordoba --- pylsp/workspace.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pylsp/workspace.py b/pylsp/workspace.py index d40c1e12..fb524c71 100644 --- a/pylsp/workspace.py +++ b/pylsp/workspace.py @@ -637,7 +637,10 @@ def jedi_names( """ Get the names in the notebook up to a certain cell. - :param up_to_cell_uri: The cell uri to stop at. If None, all cells are considered. + Parameters + ---------- + up_to_cell_uri: str, optional + The cell uri to stop at. If None, all cells are considered. """ names = set() for cell in self.cells: From 9a11ca647f648a0f5b905a9cc4f238b6a183ad5c Mon Sep 17 00:00:00 2001 From: tkrabel-db <91616041+tkrabel-db@users.noreply.github.com> Date: Wed, 18 Oct 2023 18:58:15 +0200 Subject: [PATCH 13/16] Update test/plugins/test_autoimport.py Co-authored-by: Carlos Cordoba --- test/plugins/test_autoimport.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/plugins/test_autoimport.py b/test/plugins/test_autoimport.py index 51841cbe..b1c46775 100644 --- a/test/plugins/test_autoimport.py +++ b/test/plugins/test_autoimport.py @@ -11,7 +11,6 @@ import pytest from pylsp import IS_WIN, lsp, uris - from pylsp.config.config import Config from pylsp.plugins.rope_autoimport import _get_score, _should_insert, get_names from pylsp.plugins.rope_autoimport import ( From 1950b1b7ec576cbebd3546c5fe1b81b214ae8243 Mon Sep 17 00:00:00 2001 From: tkrabel-db <91616041+tkrabel-db@users.noreply.github.com> Date: Wed, 18 Oct 2023 18:58:27 +0200 Subject: [PATCH 14/16] Update test/test_notebook_document.py Co-authored-by: Carlos Cordoba --- test/test_notebook_document.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/test_notebook_document.py b/test/test_notebook_document.py index a9c5e35f..ff61ec9f 100644 --- a/test/test_notebook_document.py +++ b/test/test_notebook_document.py @@ -1,10 +1,9 @@ # Copyright 2021- Python Language Server Contributors. import time - from unittest.mock import patch, call -from test.fixtures import CALL_TIMEOUT_IN_SECONDS -from test.test_utils import send_initialize_request, send_notebook_did_open + +from test.test_utils import CALL_TIMEOUT_IN_SECONDS, send_initialize_request, send_notebook_did_open import pytest from pylsp.workspace import Notebook From 6bf386ee88268aaad5861cb662635f556884aa7e Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Wed, 18 Oct 2023 19:01:21 +0200 Subject: [PATCH 15/16] black --- test/test_notebook_document.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/test_notebook_document.py b/test/test_notebook_document.py index ff61ec9f..c63d2791 100644 --- a/test/test_notebook_document.py +++ b/test/test_notebook_document.py @@ -3,7 +3,11 @@ import time from unittest.mock import patch, call -from test.test_utils import CALL_TIMEOUT_IN_SECONDS, send_initialize_request, send_notebook_did_open +from test.test_utils import ( + CALL_TIMEOUT_IN_SECONDS, + send_initialize_request, + send_notebook_did_open, +) import pytest from pylsp.workspace import Notebook From 76efe2a3d9a5a04881b23b3db5e1aee214e8d547 Mon Sep 17 00:00:00 2001 From: Tobias Krabel Date: Wed, 18 Oct 2023 21:06:25 +0200 Subject: [PATCH 16/16] move imports --- test/fixtures.py | 3 ++- test/test_utils.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/test/fixtures.py b/test/fixtures.py index b1485eac..ed6206af 100644 --- a/test/fixtures.py +++ b/test/fixtures.py @@ -6,9 +6,10 @@ from unittest.mock import MagicMock from test.test_utils import ClientServerPair, CALL_TIMEOUT_IN_SECONDS -import pylsp_jsonrpc import pytest +import pylsp_jsonrpc + from pylsp_jsonrpc.dispatchers import MethodDispatcher from pylsp_jsonrpc.endpoint import Endpoint from pylsp_jsonrpc.exceptions import JsonRpcException diff --git a/test/test_utils.py b/test/test_utils.py index b1b6ad2c..fb4a8b81 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -11,9 +11,9 @@ from flaky import flaky from docstring_to_markdown import UnknownFormatError -from pylsp.lsp import NotebookCellKind from pylsp import _utils +from pylsp.lsp import NotebookCellKind from pylsp.python_lsp import PythonLSPServer, start_io_lang_server