Skip to content

Add Jedi support for extra paths and different environment handling #680

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Nov 16, 2019
Merged
3 changes: 3 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ jobs:
- image: "python:3.5-stretch"
steps:
- checkout
# To test Jedi environments
- run: python3 -m venv /tmp/pyenv
- run: /tmp/pyenv/bin/python -m pip install loghub
- run: pip install -e .[all] .[test]
- run: py.test -v test/

Expand Down
7 changes: 4 additions & 3 deletions pyls/python_ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,10 @@ def m_initialize(self, processId=None, rootUri=None, rootPath=None, initializati

self.workspaces.pop(self.root_uri, None)
self.root_uri = rootUri
self.workspace = Workspace(rootUri, self._endpoint)
self.workspaces[rootUri] = self.workspace
self.config = config.Config(rootUri, initializationOptions or {},
processId, _kwargs.get('capabilities', {}))
self.workspace = Workspace(rootUri, self._endpoint, self.config)
self.workspaces[rootUri] = self.workspace
self._dispatchers = self._hook('pyls_dispatchers')
self._hook('pyls_initialize')

Expand Down Expand Up @@ -355,6 +355,7 @@ def m_workspace__did_change_configuration(self, settings=None):
self.config.update((settings or {}).get('pyls', {}))
for workspace_uri in self.workspaces:
workspace = self.workspaces[workspace_uri]
workspace.update_config(self.config)
for doc_uri in workspace.documents:
self.lint(doc_uri, is_saved=False)

Expand All @@ -365,7 +366,7 @@ def m_workspace__did_change_workspace_folders(self, added=None, removed=None, **

for added_info in added:
added_uri = added_info['uri']
self.workspaces[added_uri] = Workspace(added_uri, self._endpoint)
self.workspaces[added_uri] = Workspace(added_uri, self._endpoint, self.config)

# Migrate documents that are on the root workspace and have a better
# match now
Expand Down
64 changes: 55 additions & 9 deletions pyls/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ class Workspace(object):
M_APPLY_EDIT = 'workspace/applyEdit'
M_SHOW_MESSAGE = 'window/showMessage'

def __init__(self, root_uri, endpoint):
def __init__(self, root_uri, endpoint, config=None):
self._config = config
self._root_uri = root_uri
self._endpoint = endpoint
self._root_uri_scheme = uris.urlparse(self._root_uri)[0]
self._root_path = uris.to_fs_path(self._root_uri)
self._docs = {}

# Cache jedi environments
self._environments = {}

# Whilst incubating, keep rope private
self.__rope = None
self.__rope_config = None
Expand Down Expand Up @@ -77,6 +81,11 @@ def update_document(self, doc_uri, change, version=None):
self._docs[doc_uri].apply_change(change)
self._docs[doc_uri].version = version

def update_config(self, config):
self._config = config
for doc_uri in self.documents:
self.get_document(doc_uri).update_config(config)

def apply_edit(self, edit):
return self._endpoint.request(self.M_APPLY_EDIT, {'edit': edit})

Expand All @@ -97,17 +106,21 @@ def _create_document(self, doc_uri, source=None, version=None):
doc_uri, source=source, version=version,
extra_sys_path=self.source_roots(path),
rope_project_builder=self._rope_project_builder,
config=self._config, workspace=self,
)


class Document(object):

def __init__(self, uri, source=None, version=None, local=True, extra_sys_path=None, rope_project_builder=None):
def __init__(self, uri, source=None, version=None, local=True, extra_sys_path=None, rope_project_builder=None,
config=None, workspace=None):
self.uri = uri
self.version = version
self.path = uris.to_fs_path(uri)
self.filename = os.path.basename(self.path)

self._config = config
self._workspace = workspace
self._local = local
self._source = source
self._extra_sys_path = extra_sys_path or []
Expand All @@ -131,6 +144,9 @@ def source(self):
return f.read()
return self._source

def update_config(self, config):
self._config = config

def apply_change(self, change):
"""Apply a change to the document."""
text = change['text']
Expand Down Expand Up @@ -197,28 +213,58 @@ def word_at_position(self, position):
return m_start[0] + m_end[-1]

def jedi_names(self, all_scopes=False, definitions=True, references=False):
environment_path = None
if self._config:
jedi_settings = self._config.plugin_settings('jedi', document_path=self.path)
environment_path = jedi_settings.get('environment')
environment = self.get_enviroment(environment_path) if environment_path else None

return jedi.api.names(
source=self.source, path=self.path, all_scopes=all_scopes,
definitions=definitions, references=references
definitions=definitions, references=references, environment=environment,
)

def jedi_script(self, position=None):
extra_paths = []
environment_path = None

if self._config:
jedi_settings = self._config.plugin_settings('jedi', document_path=self.path)
environment_path = jedi_settings.get('environment')
extra_paths = jedi_settings.get('extra_paths') or []

sys_path = self.sys_path(environment_path) + extra_paths
environment = self.get_enviroment(environment_path) if environment_path else None

kwargs = {
'source': self.source,
'path': self.path,
'sys_path': self.sys_path()
'sys_path': sys_path,
'environment': environment,
}

if position:
kwargs['line'] = position['line'] + 1
kwargs['column'] = _utils.clip_column(position['character'], self.lines, position['line'])

return jedi.Script(**kwargs)

def sys_path(self):
def get_enviroment(self, environment_path=None):
# TODO(gatesn): #339 - make better use of jedi environments, they seem pretty powerful
if environment_path is None:
environment = jedi.api.environment.get_cached_default_environment()
else:
if environment_path in self._workspace._environments:
environment = self._workspace._environments[environment_path]
else:
environment = jedi.api.environment.create_environment(path=environment_path, safe=False)
self._workspace._environments[environment_path] = environment

return environment

def sys_path(self, environment_path=None):
# Copy our extra sys path
path = list(self._extra_sys_path)

# TODO(gatesn): #339 - make better use of jedi environments, they seem pretty powerful
environment = jedi.api.environment.get_cached_default_environment()
environment = self.get_enviroment(environment_path=environment_path)
path.extend(environment.get_sys_path())

return path
67 changes: 66 additions & 1 deletion test/plugins/test_completion.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Copyright 2017 Palantir Technologies, Inc.
from distutils.version import LooseVersion
import os
import sys

from test.test_utils import MockWorkspace
import jedi
import pytest

Expand All @@ -9,10 +12,13 @@
from pyls.plugins.jedi_completion import pyls_completions as pyls_jedi_completions
from pyls.plugins.rope_completion import pyls_completions as pyls_rope_completions


PY2 = sys.version[0] == '2'
LINUX = sys.platform.startswith('linux')
CI = os.environ.get('CI')
LOCATION = os.path.realpath(
os.path.join(os.getcwd(), os.path.dirname(__file__))
)

DOC_URI = uris.from_fs_path(__file__)
DOC = """import os
print os.path.isabs("/tmp")
Expand Down Expand Up @@ -200,3 +206,62 @@ def test_multistatement_snippet(config):
position = {'line': 0, 'character': len(document)}
completions = pyls_jedi_completions(config, doc, position)
assert completions[0]['insertText'] == 'date(${1:year}, ${2:month}, ${3:day})$0'


def test_jedi_completion_extra_paths(config, tmpdir):
# Create a tempfile with some content and pass to extra_paths
temp_doc_content = '''
def spam():
pass
'''
p = tmpdir.mkdir("extra_path")
extra_paths = [str(p)]
p = p.join("foo.py")
p.write(temp_doc_content)

# Content of doc to test completion
doc_content = """import foo
foo.s"""
doc = Document(DOC_URI, doc_content)

# After 'foo.s' without extra paths
com_position = {'line': 1, 'character': 5}
completions = pyls_jedi_completions(config, doc, com_position)
assert completions is None

# Update config extra paths
config.update({'plugins': {'jedi': {'extra_paths': extra_paths}}})
doc.update_config(config)

# After 'foo.s' with extra paths
com_position = {'line': 1, 'character': 5}
completions = pyls_jedi_completions(config, doc, com_position)
assert completions[0]['label'] == 'spam()'


@pytest.mark.skipif(PY2 or not LINUX or not CI, reason="tested on linux and python 3 only")
def test_jedi_completion_environment(config):
# Content of doc to test completion
doc_content = '''import logh
'''
doc = Document(DOC_URI, doc_content, workspace=MockWorkspace())

# After 'import logh' with default environment
com_position = {'line': 0, 'character': 11}

assert os.path.isdir('/tmp/pyenv/')

config.update({'plugins': {'jedi': {'environment': None}}})
doc.update_config(config)
completions = pyls_jedi_completions(config, doc, com_position)
assert completions is None

# Update config extra environment
env_path = '/tmp/pyenv/bin/python'
config.update({'plugins': {'jedi': {'environment': env_path}}})
doc.update_config(config)

# After 'import logh' with new environment
completions = pyls_jedi_completions(config, doc, com_position)
assert completions[0]['label'] == 'loghub'
assert 'changelog generator' in completions[0]['documentation'].lower()
49 changes: 37 additions & 12 deletions test/plugins/test_symbols.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
# Copyright 2017 Palantir Technologies, Inc.
import os
import sys

from test.test_utils import MockWorkspace
import pytest

from pyls import uris
from pyls.plugins.symbols import pyls_document_symbols
from pyls.lsp import SymbolKind
from pyls.workspace import Document


PY2 = sys.version[0] == '2'
LINUX = sys.platform.startswith('linux')
CI = os.environ.get('CI')
DOC_URI = uris.from_fs_path(__file__)
DOC = """import sys

Expand All @@ -21,6 +31,23 @@ def main(x):
"""


def helper_check_symbols_all_scope(symbols):
# All eight symbols (import sys, a, B, __init__, x, y, main, y)
assert len(symbols) == 8

def sym(name):
return [s for s in symbols if s['name'] == name][0]

# Check we have some sane mappings to VSCode constants
assert sym('a')['kind'] == SymbolKind.Variable
assert sym('B')['kind'] == SymbolKind.Class
assert sym('__init__')['kind'] == SymbolKind.Function
assert sym('main')['kind'] == SymbolKind.Function

# Not going to get too in-depth here else we're just testing Jedi
assert sym('a')['location']['range']['start'] == {'line': 2, 'character': 0}


def test_symbols(config):
doc = Document(DOC_URI, DOC)
config.update({'plugins': {'jedi_symbols': {'all_scopes': False}}})
Expand Down Expand Up @@ -49,18 +76,16 @@ def sym(name):
def test_symbols_all_scopes(config):
doc = Document(DOC_URI, DOC)
symbols = pyls_document_symbols(config, doc)
helper_check_symbols_all_scope(symbols)

# All eight symbols (import sys, a, B, __init__, x, y, main, y)
assert len(symbols) == 8

def sym(name):
return [s for s in symbols if s['name'] == name][0]

# Check we have some sane mappings to VSCode constants
assert sym('a')['kind'] == SymbolKind.Variable
assert sym('B')['kind'] == SymbolKind.Class
assert sym('__init__')['kind'] == SymbolKind.Function
assert sym('main')['kind'] == SymbolKind.Function
@pytest.mark.skipif(PY2 or not LINUX or not CI, reason="tested on linux and python 3 only")
def test_symbols_all_scopes_with_jedi_environment(config):
doc = Document(DOC_URI, DOC, workspace=MockWorkspace())

# Not going to get too in-depth here else we're just testing Jedi
assert sym('a')['location']['range']['start'] == {'line': 2, 'character': 0}
# Update config extra environment
env_path = '/tmp/pyenv/bin/python'
config.update({'plugins': {'jedi': {'environment': env_path}}})
doc.update_config(config)
symbols = pyls_document_symbols(config, doc)
helper_check_symbols_all_scope(symbols)
14 changes: 14 additions & 0 deletions test/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
# Copyright 2017 Palantir Technologies, Inc.
import time
import sys

import mock

from pyls import _utils


class MockWorkspace(object):
"""Mock workspace used by tests that use jedi environment."""

def __init__(self):
"""Mock workspace used by tests that use jedi environment."""
self._environments = {}

# This is to avoid pyling tests of the variable not being used
sys.stdout.write(str(self._environments))


def test_debounce():
interval = 0.1
obj = mock.Mock()
Expand Down
10 changes: 10 additions & 0 deletions vscode-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@
},
"uniqueItems": true
},
"pyls.plugins.jedi.extra_paths": {
"type": "array",
"default": [],
"description": "Define extra paths for jedi.Script."
},
"pyls.plugins.jedi.environment": {
"type": "string",
"default": null,
"description": "Define environment for jedi.Script and Jedi.names."
},
"pyls.plugins.jedi_completion.enabled": {
"type": "boolean",
"default": true,
Expand Down