Skip to content

Commit b864c4f

Browse files
authored
Ignore notebook names on cell completion for autoimport (#466)
1 parent 2f2c0e8 commit b864c4f

9 files changed

+228
-261
lines changed

pylsp/hookspecs.py

+1-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

pylsp/plugins/rope_autoimport.py

+9-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(

pylsp/python_lsp.py

+10-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):

pylsp/workspace.py

+26
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,7 @@ def __init__(
595595
self.version = version
596596
self.cells = cells or []
597597
self.metadata = metadata or {}
598+
self._lock = RLock()
598599

599600
def __str__(self):
600601
return "Notebook with URI '%s'" % str(self.uri)
@@ -625,6 +626,31 @@ def cell_data(self):
625626
offset += num_lines
626627
return cell_data
627628

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+
628654

629655
class Cell(Document):
630656
"""

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)