Skip to content

Commit d724e6c

Browse files
committed
Merged PR posit-dev/positron-python#265: Prefer namespace completions
Merge pull request #265 from posit-dev/prefer-namespace-completions Prefer namespace completions -------------------- Commit message for posit-dev/positron-python@8359792: test that namespace completions are preferred -------------------- Commit message for posit-dev/positron-python@1d3855a: prefer completions using the user's namespace over static analysis Relates to #601. Authored-by: Wasim Lorgat <[email protected]> Signed-off-by: Wasim Lorgat <[email protected]>
1 parent 4de9845 commit d724e6c

File tree

3 files changed

+149
-21
lines changed

3 files changed

+149
-21
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#
2+
# Copyright (C) 2023 Posit Software, PBC. All rights reserved.
3+
#
4+
5+
from jedi import cache
6+
from jedi.api import Interpreter
7+
from jedi.api.interpreter import MixedModuleContext
8+
from jedi.file_io import KnownContentFileIO
9+
from jedi.inference.value import ModuleValue
10+
11+
12+
class PositronMixedModuleContext(MixedModuleContext):
13+
"""
14+
Like `jedi.api.interpreter.MixedModuleContext` but prefers values from the user's namespace over
15+
static analysis. See the `PositronInterpreter` docs for more details.
16+
"""
17+
18+
def get_filters(self, until_position=None, origin_scope=None):
19+
filters = super().get_filters(until_position, origin_scope)
20+
21+
# Store the first filter – which corresponds to static analysis of the source code.
22+
merged_filter = next(filters)
23+
24+
# Yield the remaining filters – which correspond to the user's namespaces.
25+
yield from filters
26+
27+
# Finally, yield the first filter.
28+
yield merged_filter
29+
30+
31+
class PositronInterpreter(Interpreter):
32+
"""
33+
Like `jedi.Interpreter` but prefers values from the user's namespace over static analysis.
34+
35+
For example, given the namespace: `{"x": {"a": 0}}`, and the code:
36+
37+
```
38+
x = {"b": 0}
39+
x['
40+
```
41+
42+
Completing the line `x['` should return `a` and not `b`.
43+
"""
44+
45+
@cache.memoize_method
46+
def _get_module_context(self):
47+
if self.path is None:
48+
file_io = None
49+
else:
50+
file_io = KnownContentFileIO(self.path, self._code)
51+
tree_module_value = ModuleValue(
52+
self._inference_state,
53+
self._module_node,
54+
file_io=file_io,
55+
string_names=("__main__",),
56+
code_lines=self._code_lines,
57+
)
58+
# --- Start Positron ---
59+
return PositronMixedModuleContext(
60+
tree_module_value,
61+
self.namespaces,
62+
)
63+
# --- End Positron ---

extensions/positron-python/pythonFiles/positron/positron_jedilsp.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
from pygls.workspace import Document
8989

9090
from .help import ShowTopicRequest
91+
from .jedi import PositronInterpreter
9192

9293
if TYPE_CHECKING:
9394
from .positron_ipkernel import PositronIPyKernel
@@ -675,4 +676,4 @@ def interpreter(
675676
if kernel is not None:
676677
namespaces.append(kernel.get_user_ns())
677678

678-
return Interpreter(document.source, namespaces, path=document.path, project=project)
679+
return PositronInterpreter(document.source, namespaces, path=document.path, project=project)
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,53 @@
1-
from typing import Any, Dict, Optional, Tuple
1+
from typing import Any, Dict, Callable, List, Optional, Tuple
22
from unittest.mock import Mock
3-
from urllib.parse import unquote, urlparse
43

54
import pytest
5+
from IPython.terminal.interactiveshell import TerminalInteractiveShell
66
from jedi import Project
7-
from lsprotocol.types import Position, TextDocumentIdentifier
7+
from positron.positron_ipkernel import PositronIPyKernel
8+
from pygls.workspace.text_document import TextDocument
9+
from lsprotocol.types import (
10+
CompletionParams,
11+
MarkupKind,
12+
Position,
13+
TextDocumentIdentifier,
14+
)
815

916
from positron.help import ShowTopicRequest
10-
from positron.positron_jedilsp import HelpTopicParams, positron_help_topic_request
17+
from positron.positron_jedilsp import (
18+
HelpTopicParams,
19+
positron_completion,
20+
positron_help_topic_request,
21+
)
22+
1123

24+
@pytest.fixture
25+
def mock_server(kernel: PositronIPyKernel) -> Callable[[str, str], Mock]:
26+
"""
27+
Minimum interface for a pylgs server to support LSP unit tests.
28+
"""
1229

13-
def mock_server(uri: str, source: str, namespace: Dict[str, Any]) -> Mock:
14-
document = Mock()
15-
document.path = unquote(urlparse(uri).path)
16-
document.source = source
30+
# Return a function that returns a mock server rather than an instantiated mock server,
31+
# since uri and source change between tests.
32+
def inner(uri: str, source: str) -> Mock:
33+
server = Mock()
34+
server.client_capabilities.text_document.completion.completion_item.documentation_format = (
35+
list(MarkupKind)
36+
)
37+
server.initialization_options.completion.disable_snippets = False
38+
server.initialization_options.completion.resolve_eagerly = False
39+
server.initialization_options.completion.ignore_patterns = []
40+
server.kernel = kernel
41+
server.project = Project("")
42+
server.workspace.get_document.return_value = TextDocument(uri, source)
1743

18-
server = Mock()
19-
server.kernel.get_user_ns.return_value = namespace
20-
server.project = Project("")
21-
server.workspace.get_document.return_value = document
44+
return server
2245

23-
return server
46+
return inner
2447

2548

2649
@pytest.mark.parametrize(
27-
("source", "position", "namespace", "topic"),
50+
("source", "position", "namespace", "expected_topic"),
2851
[
2952
# An unknown variable should not be resolved.
3053
("x", (0, 0), {}, None),
@@ -33,12 +56,53 @@ def mock_server(uri: str, source: str, namespace: Dict[str, Any]) -> Mock:
3356
],
3457
)
3558
def test_positron_help_topic_request(
36-
source: str, position: Tuple[int, int], namespace: Dict[str, Any], topic: Optional[str]
59+
mock_server: Mock,
60+
shell: TerminalInteractiveShell,
61+
source: str,
62+
position: Tuple[int, int],
63+
namespace: Dict[str, Any],
64+
expected_topic: Optional[str],
3765
) -> None:
66+
shell.user_ns.update(namespace)
67+
3868
params = HelpTopicParams(TextDocumentIdentifier("file:///foo.py"), Position(*position))
39-
server = mock_server(params.text_document.uri, source, namespace)
40-
actual = positron_help_topic_request(server, params)
41-
if topic is None:
42-
assert actual is None
69+
server = mock_server(params.text_document.uri, source)
70+
71+
topic = positron_help_topic_request(server, params)
72+
73+
if expected_topic is None:
74+
assert topic is None
4375
else:
44-
assert actual == ShowTopicRequest(topic)
76+
assert topic == ShowTopicRequest(expected_topic)
77+
78+
79+
@pytest.mark.parametrize(
80+
("source", "position", "namespace", "expected_completions"),
81+
[
82+
# When completions match a variable defined in the source _and_ a variable in the user's namespace,
83+
# prefer the namespace variable.
84+
('x = {"a": 0}\nx["', (1, 3), {"x": {"b": 0}}, ['"b"']),
85+
],
86+
)
87+
def test_positron_completion(
88+
mock_server: Mock,
89+
shell: TerminalInteractiveShell,
90+
source: str,
91+
position: Tuple[int, int],
92+
namespace: Dict[str, Any],
93+
expected_completions: List[str],
94+
) -> None:
95+
shell.user_ns.update(namespace)
96+
97+
params = CompletionParams(TextDocumentIdentifier("file:///foo.py"), Position(*position))
98+
server = mock_server(params.text_document.uri, source)
99+
100+
completion_list = positron_completion(server, params)
101+
102+
assert completion_list is not None, "No completions returned"
103+
104+
# TODO: This is actually a bug, we shouldn't be returning magic completions when completing dict keys.
105+
completions = [item for item in completion_list.items if not item.label.startswith("%")]
106+
107+
completion_labels = [item.label for item in completions]
108+
assert completion_labels == expected_completions, "Unexpected completion labels"

0 commit comments

Comments
 (0)