Skip to content

Commit 13a57b8

Browse files
committed
Merge branch 'develop' into bugfix-support-init-opts
2 parents 173d3eb + b864c4f commit 13a57b8

10 files changed

+331
-237
lines changed

pylsp/_utils.py

-1
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,6 @@ def format_docstring(
209209
if markup_kind == "markdown":
210210
try:
211211
value = docstring_to_markdown.convert(contents)
212-
return {"kind": "markdown", "value": value}
213212
except docstring_to_markdown.UnknownFormatError:
214213
# try to escape the Markdown syntax instead:
215214
value = escape_markdown(contents)

pylsp/hookspecs.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def pylsp_commands(config, workspace):
2525

2626

2727
@hookspec
28-
def pylsp_completions(config, workspace, document, position):
28+
def pylsp_completions(config, workspace, document, position, ignored_names):
2929
pass
3030

3131

@@ -127,3 +127,8 @@ def pylsp_settings(config):
127127
@hookspec(firstresult=True)
128128
def pylsp_signature_help(config, workspace, document, position):
129129
pass
130+
131+
132+
@hookspec
133+
def pylsp_workspace_configuration_changed(config, workspace):
134+
pass

pylsp/plugins/rope_autoimport.py

+23-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright 2022- Python Language Server Contributors.
22

33
import logging
4-
from typing import Any, Dict, Generator, List, Optional, Set
4+
from typing import Any, Dict, Generator, List, Optional, Set, Union
55

66
import parso
77
from jedi import Script
@@ -153,7 +153,11 @@ def get_names(script: Script) -> Set[str]:
153153

154154
@hookimpl
155155
def pylsp_completions(
156-
config: Config, workspace: Workspace, document: Document, position
156+
config: Config,
157+
workspace: Workspace,
158+
document: Document,
159+
position,
160+
ignored_names: Union[Set[str], None],
157161
):
158162
"""Get autoimport suggestions."""
159163
line = document.lines[position["line"]]
@@ -164,7 +168,9 @@ def pylsp_completions(
164168
word = word_node.value
165169
log.debug(f"autoimport: searching for word: {word}")
166170
rope_config = config.settings(document_path=document.path).get("rope", {})
167-
ignored_names: Set[str] = get_names(document.jedi_script(use_document_path=True))
171+
ignored_names: Set[str] = ignored_names or get_names(
172+
document.jedi_script(use_document_path=True)
173+
)
168174
autoimport = workspace._rope_autoimport(rope_config)
169175
suggestions = list(autoimport.search_full(word, ignored_names=ignored_names))
170176
results = list(
@@ -238,3 +244,17 @@ def pylsp_document_did_open(config: Config, workspace: Workspace):
238244
def pylsp_document_did_save(config: Config, workspace: Workspace, document: Document):
239245
"""Update the names associated with this document."""
240246
_reload_cache(config, workspace, [document])
247+
248+
249+
@hookimpl
250+
def pylsp_workspace_configuration_changed(config: Config, workspace: Workspace):
251+
"""
252+
Initialize autoimport if it has been enabled through a
253+
workspace/didChangeConfiguration message from the frontend.
254+
255+
Generates the cache for local and global items.
256+
"""
257+
if config.plugin_settings("rope_autoimport").get("enabled", False):
258+
_reload_cache(config, workspace)
259+
else:
260+
log.debug("autoimport: Skipping cache reload.")

pylsp/python_lsp.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,16 @@ def code_lens(self, doc_uri):
394394
return flatten(self._hook("pylsp_code_lens", doc_uri))
395395

396396
def completions(self, doc_uri, position):
397-
completions = self._hook("pylsp_completions", doc_uri, position=position)
397+
workspace = self._match_uri_to_workspace(doc_uri)
398+
document = workspace.get_document(doc_uri)
399+
ignored_names = None
400+
if isinstance(document, Cell):
401+
# We need to get the ignored names from the whole notebook document
402+
notebook_document = workspace.get_maybe_document(document.notebook_uri)
403+
ignored_names = notebook_document.jedi_names(doc_uri)
404+
completions = self._hook(
405+
"pylsp_completions", doc_uri, position=position, ignored_names=ignored_names
406+
)
398407
return {"isIncomplete": False, "items": flatten(completions)}
399408

400409
def completion_item_resolve(self, completion_item):
@@ -766,6 +775,7 @@ def m_workspace__did_change_configuration(self, settings=None):
766775
self.config.update((settings or {}).get("pylsp", {}))
767776
for workspace in self.workspaces.values():
768777
workspace.update_config(settings)
778+
self._hook("pylsp_workspace_configuration_changed")
769779
for doc_uri in workspace.documents:
770780
self.lint(doc_uri, is_saved=False)
771781

pylsp/workspace.py

+31-1
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,11 @@ def update_document(self, doc_uri, change, version=None):
167167
def update_config(self, settings):
168168
self._config.update((settings or {}).get("pylsp", {}))
169169
for doc_uri in self.documents:
170-
self.get_document(doc_uri).update_config(settings)
170+
if isinstance(document := self.get_document(doc_uri), Notebook):
171+
# Notebook documents don't have a config. The config is
172+
# handled at the cell level.
173+
return
174+
document.update_config(settings)
171175

172176
def apply_edit(self, edit):
173177
return self._endpoint.request(self.M_APPLY_EDIT, {"edit": edit})
@@ -591,6 +595,7 @@ def __init__(
591595
self.version = version
592596
self.cells = cells or []
593597
self.metadata = metadata or {}
598+
self._lock = RLock()
594599

595600
def __str__(self):
596601
return "Notebook with URI '%s'" % str(self.uri)
@@ -621,6 +626,31 @@ def cell_data(self):
621626
offset += num_lines
622627
return cell_data
623628

629+
@lock
630+
def jedi_names(
631+
self,
632+
up_to_cell_uri: Optional[str] = None,
633+
all_scopes=False,
634+
definitions=True,
635+
references=False,
636+
):
637+
"""
638+
Get the names in the notebook up to a certain cell.
639+
640+
Parameters
641+
----------
642+
up_to_cell_uri: str, optional
643+
The cell uri to stop at. If None, all cells are considered.
644+
"""
645+
names = set()
646+
for cell in self.cells:
647+
cell_uri = cell["document"]
648+
cell_document = self.workspace.get_cell_document(cell_uri)
649+
names.update(cell_document.jedi_names(all_scopes, definitions, references))
650+
if cell_uri == up_to_cell_uri:
651+
break
652+
return set(name.name for name in names)
653+
624654

625655
class Cell(Document):
626656
"""

test/fixtures.py

+13-7
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
from io import StringIO
66
from unittest.mock import MagicMock
77

8-
from test.test_utils import ClientServerPair
8+
from test.test_utils import ClientServerPair, CALL_TIMEOUT_IN_SECONDS
99

1010
import pytest
11+
import pylsp_jsonrpc
12+
1113
from pylsp_jsonrpc.dispatchers import MethodDispatcher
1214
from pylsp_jsonrpc.endpoint import Endpoint
1315
from pylsp_jsonrpc.exceptions import JsonRpcException
@@ -24,7 +26,6 @@
2426
def main():
2527
print sys.stdin.read()
2628
"""
27-
CALL_TIMEOUT_IN_SECONDS = 30
2829

2930

3031
class FakeEditorMethodsMixin:
@@ -175,8 +176,13 @@ def client_server_pair():
175176

176177
yield (client_server_pair_obj.client, client_server_pair_obj.server)
177178

178-
shutdown_response = client_server_pair_obj.client._endpoint.request(
179-
"shutdown"
180-
).result(timeout=CALL_TIMEOUT_IN_SECONDS)
181-
assert shutdown_response is None
182-
client_server_pair_obj.client._endpoint.notify("exit")
179+
try:
180+
shutdown_response = client_server_pair_obj.client._endpoint.request(
181+
"shutdown"
182+
).result(timeout=CALL_TIMEOUT_IN_SECONDS)
183+
assert shutdown_response is None
184+
client_server_pair_obj.client._endpoint.notify("exit")
185+
except pylsp_jsonrpc.exceptions.JsonRpcInvalidParams:
186+
# SQLite objects created in a thread can only be used in that same thread.
187+
# This exeception is raised when testing rope autoimport.
188+
client_server_pair_obj.client._endpoint.notify("exit")

test/plugins/test_autoimport.py

+91-39
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
# Copyright 2022- Python Language Server Contributors.
22

3-
from typing import Dict, List
4-
from unittest.mock import Mock
3+
from typing import Any, Dict, List
4+
from unittest.mock import Mock, patch
5+
6+
from test.test_notebook_document import wait_for_condition
7+
from test.test_utils import send_initialize_request, send_notebook_did_open
58

69
import jedi
710
import parso
811
import pytest
912

10-
from pylsp import lsp, uris
13+
from pylsp import IS_WIN, lsp, uris
1114
from pylsp.config.config import Config
1215
from pylsp.plugins.rope_autoimport import _get_score, _should_insert, get_names
1316
from pylsp.plugins.rope_autoimport import (
@@ -16,9 +19,17 @@
1619
from pylsp.plugins.rope_autoimport import pylsp_initialize
1720
from pylsp.workspace import Workspace
1821

22+
1923
DOC_URI = uris.from_fs_path(__file__)
2024

2125

26+
def contains_autoimport(suggestion: Dict[str, Any], module: str) -> bool:
27+
"""Checks if `suggestion` contains an autoimport for `module`."""
28+
return suggestion.get("label", "") == module and "import" in suggestion.get(
29+
"detail", ""
30+
)
31+
32+
2233
@pytest.fixture(scope="session")
2334
def autoimport_workspace(tmp_path_factory) -> Workspace:
2435
"Special autoimport workspace. Persists across sessions to make in-memory sqlite3 database fast."
@@ -39,7 +50,9 @@ def completions(config: Config, autoimport_workspace: Workspace, request):
3950
com_position = {"line": 0, "character": position}
4051
autoimport_workspace.put_document(DOC_URI, source=document)
4152
doc = autoimport_workspace.get_document(DOC_URI)
42-
yield pylsp_autoimport_completions(config, autoimport_workspace, doc, com_position)
53+
yield pylsp_autoimport_completions(
54+
config, autoimport_workspace, doc, com_position, None
55+
)
4356
autoimport_workspace.rm_document(DOC_URI)
4457

4558

@@ -141,45 +154,13 @@ def test_autoimport_defined_name(config, workspace):
141154
com_position = {"line": 1, "character": 3}
142155
workspace.put_document(DOC_URI, source=document)
143156
doc = workspace.get_document(DOC_URI)
144-
completions = pylsp_autoimport_completions(config, workspace, doc, com_position)
157+
completions = pylsp_autoimport_completions(
158+
config, workspace, doc, com_position, None
159+
)
145160
workspace.rm_document(DOC_URI)
146161
assert not check_dict({"label": "List"}, completions)
147162

148163

149-
# This test has several large issues.
150-
# 1. autoimport relies on its sources being written to disk. This makes testing harder
151-
# 2. the hook doesn't handle removed files
152-
# 3. The testing framework cannot access the actual autoimport object so it cannot clear the cache
153-
# def test_autoimport_update_module(config: Config, workspace: Workspace):
154-
# document2 = "SomethingYouShouldntWrite = 1"
155-
# document = """SomethingYouShouldntWrit"""
156-
# com_position = {
157-
# "line": 0,
158-
# "character": 3,
159-
# }
160-
# doc2_path = workspace.root_path + "/test_file_no_one_should_write_to.py"
161-
# if os.path.exists(doc2_path):
162-
# os.remove(doc2_path)
163-
# DOC2_URI = uris.from_fs_path(doc2_path)
164-
# workspace.put_document(DOC_URI, source=document)
165-
# doc = workspace.get_document(DOC_URI)
166-
# completions = pylsp_autoimport_completions(config, workspace, doc, com_position)
167-
# assert len(completions) == 0
168-
# with open(doc2_path, "w") as f:
169-
# f.write(document2)
170-
# workspace.put_document(DOC2_URI, source=document2)
171-
# doc2 = workspace.get_document(DOC2_URI)
172-
# pylsp_document_did_save(config, workspace, doc2)
173-
# assert check_dict({"label": "SomethingYouShouldntWrite"}, completions)
174-
# workspace.put_document(DOC2_URI, source="\n")
175-
# doc2 = workspace.get_document(DOC2_URI)
176-
# os.remove(doc2_path)
177-
# pylsp_document_did_save(config, workspace, doc2)
178-
# completions = pylsp_autoimport_completions(config, workspace, doc, com_position)
179-
# assert len(completions) == 0
180-
# workspace.rm_document(DOC_URI)
181-
182-
183164
class TestShouldInsert:
184165
def test_dot(self):
185166
assert not should_insert("""str.""", 4)
@@ -233,3 +214,74 @@ class sfa:
233214
"""
234215
results = get_names(jedi.Script(code=source))
235216
assert results == set(["blah", "bleh", "e", "hello", "someone", "sfa", "a", "b"])
217+
218+
219+
@pytest.mark.skipif(IS_WIN, reason="Flaky on Windows")
220+
def test_autoimport_for_notebook_document(
221+
client_server_pair,
222+
):
223+
client, server = client_server_pair
224+
send_initialize_request(client)
225+
226+
with patch.object(server._endpoint, "notify") as mock_notify:
227+
# Expectations:
228+
# 1. We receive an autoimport suggestion for "os" in the first cell because
229+
# os is imported after that.
230+
# 2. We don't receive an autoimport suggestion for "os" in the second cell because it's
231+
# already imported in the second cell.
232+
# 3. We don't receive an autoimport suggestion for "os" in the third cell because it's
233+
# already imported in the second cell.
234+
# 4. We receive an autoimport suggestion for "sys" because it's not already imported
235+
send_notebook_did_open(client, ["os", "import os\nos", "os", "sys"])
236+
wait_for_condition(lambda: mock_notify.call_count >= 3)
237+
238+
server.m_workspace__did_change_configuration(
239+
settings={
240+
"pylsp": {"plugins": {"rope_autoimport": {"enabled": True, "memory": True}}}
241+
}
242+
)
243+
rope_autoimport_settings = server.workspace._config.plugin_settings(
244+
"rope_autoimport"
245+
)
246+
assert rope_autoimport_settings.get("enabled", False) is True
247+
assert rope_autoimport_settings.get("memory", False) is True
248+
249+
# 1.
250+
suggestions = server.completions("cell_1_uri", {"line": 0, "character": 2}).get(
251+
"items"
252+
)
253+
assert any(
254+
suggestion
255+
for suggestion in suggestions
256+
if contains_autoimport(suggestion, "os")
257+
)
258+
259+
# 2.
260+
suggestions = server.completions("cell_2_uri", {"line": 1, "character": 2}).get(
261+
"items"
262+
)
263+
assert not any(
264+
suggestion
265+
for suggestion in suggestions
266+
if contains_autoimport(suggestion, "os")
267+
)
268+
269+
# 3.
270+
suggestions = server.completions("cell_3_uri", {"line": 0, "character": 2}).get(
271+
"items"
272+
)
273+
assert not any(
274+
suggestion
275+
for suggestion in suggestions
276+
if contains_autoimport(suggestion, "os")
277+
)
278+
279+
# 4.
280+
suggestions = server.completions("cell_4_uri", {"line": 0, "character": 3}).get(
281+
"items"
282+
)
283+
assert any(
284+
suggestion
285+
for suggestion in suggestions
286+
if contains_autoimport(suggestion, "sys")
287+
)

test/test_language_server.py

+2-9
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import time
66
import sys
77

8-
from test.test_utils import ClientServerPair
8+
from test.test_utils import ClientServerPair, send_initialize_request
99

1010
from flaky import flaky
1111
from pylsp_jsonrpc.exceptions import JsonRpcMethodNotFound
@@ -73,14 +73,7 @@ def test_not_exit_without_check_parent_process_flag(
7373
client_server_pair,
7474
):
7575
client, _ = client_server_pair
76-
response = client._endpoint.request(
77-
"initialize",
78-
{
79-
"processId": 1234,
80-
"rootPath": os.path.dirname(__file__),
81-
"initializationOptions": {},
82-
},
83-
).result(timeout=CALL_TIMEOUT_IN_SECONDS)
76+
response = send_initialize_request(client)
8477
assert "capabilities" in response
8578

8679

0 commit comments

Comments
 (0)