diff --git a/spyder/plugins/tests/test_editor_introspection.py b/spyder/plugins/tests/test_editor_introspection.py new file mode 100644 index 00000000000..419cf5def00 --- /dev/null +++ b/spyder/plugins/tests/test_editor_introspection.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# +"""Tests for the Editor plugin.""" + +# Third party imports +import pytest +import os +import os.path as osp +from flaky import flaky + +try: + from unittest.mock import Mock +except ImportError: + from mock import Mock # Python 2 + +from qtpy.QtWidgets import QWidget, QApplication +from qtpy.QtCore import Qt + +from spyder.utils.introspection.jedi_plugin import JEDI_010 +from spyder.utils.qthelpers import qapplication + +# Location of this file +LOCATION = osp.realpath(osp.join(os.getcwd(), osp.dirname(__file__))) + + +@pytest.fixture +def setup_editor(qtbot, monkeypatch): + """Set up the Editor plugin.""" + app = qapplication() + os.environ['SPY_TEST_USE_INTROSPECTION'] = 'True' + monkeypatch.setattr('spyder.dependencies', Mock()) + from spyder.plugins.editor import Editor + + monkeypatch.setattr('spyder.plugins.editor.add_actions', Mock()) + + class MainMock(QWidget): + def __getattr__(self, attr): + if attr.endswith('actions'): + return [] + else: + return Mock() + + def get_spyder_pythonpath(*args): + return [] + + editor = Editor(MainMock()) + qtbot.addWidget(editor) + editor.show() + editor.new(fname="test.py", text="") + editor.introspector.set_editor_widget(editor.editorstacks[0]) + + yield editor, qtbot + # teardown + os.environ['SPY_TEST_USE_INTROSPECTION'] = 'False' + editor.introspector.plugin_manager.close() + + +@pytest.mark.skipif(os.environ.get('CI', None) is not None, + reason="This test fails too much in the CI :(") +@pytest.mark.skipif(not JEDI_010, + reason="This feature is only supported in jedy >= 0.10") +def test_introspection(setup_editor): + """Validate changing path in introspection plugins.""" + editor, qtbot = setup_editor + code_editor = editor.get_focus_widget() + completion = code_editor.completion_widget + + # Set cursor to start + code_editor.go_to_line(1) + + # Complete fr --> from + qtbot.keyClicks(code_editor, 'fr') + qtbot.wait(5000) + + # press tab and get completions + with qtbot.waitSignal(completion.sig_show_completions, + timeout=5000) as sig: + qtbot.keyPress(code_editor, Qt.Key_Tab) + assert "from" in sig.args[0] + + # enter should accept first completion + qtbot.keyPress(completion, Qt.Key_Enter, delay=1000) + assert code_editor.toPlainText() == 'from\n' + + # Modify PYTHONPATH + editor.introspector.change_extra_path([LOCATION]) + qtbot.wait(10000) + + # Type 'from test' and try to get completion + with qtbot.waitSignal(completion.sig_show_completions, + timeout=10000) as sig: + qtbot.keyClicks(code_editor, ' test_') + qtbot.keyPress(code_editor, Qt.Key_Tab) + assert "test_editor_introspection" in sig.args[0] diff --git a/spyder/utils/introspection/jedi_plugin.py b/spyder/utils/introspection/jedi_plugin.py index 1ff5a7da66e..7e14245070d 100644 --- a/spyder/utils/introspection/jedi_plugin.py +++ b/spyder/utils/introspection/jedi_plugin.py @@ -30,6 +30,8 @@ except ImportError: jedi = None +JEDI_010 = programs.is_module_installed('jedi', '>=0.10.0') + class JediPlugin(IntrospectionPlugin): """ @@ -172,8 +174,13 @@ def get_jedi_object(self, func_name, info, use_filename=True): filename = None try: - script = jedi.Script(info['source_code'], info['line_num'], - info['column'], filename) + if JEDI_010: + script = jedi.api.Script(info['source_code'], info['line_num'], + info['column'], filename, + sys_path=info['sys_path']) + else: + script = jedi.api.Script(info['source_code'], info['line_num'], + info['column'], filename) func = getattr(script, func_name) val = func() except Exception as e: diff --git a/spyder/utils/introspection/manager.py b/spyder/utils/introspection/manager.py index 8b595e2a0f4..9b2ca7136c5 100644 --- a/spyder/utils/introspection/manager.py +++ b/spyder/utils/introspection/manager.py @@ -8,6 +8,7 @@ from __future__ import print_function from collections import OrderedDict import time +import sys # Third party imports from qtpy.QtCore import QObject, QTimer, Signal @@ -43,13 +44,13 @@ class PluginManager(QObject): introspection_complete = Signal(object) - def __init__(self, executable, extra_path=None): + def __init__(self, executable): super(PluginManager, self).__init__() plugins = OrderedDict() for name in PLUGINS: try: - plugin = PluginClient(name, executable, extra_path=extra_path) + plugin = PluginClient(name, executable) plugin.run() except Exception as e: debug_print('Introspection Plugin Failed: %s' % name) @@ -163,13 +164,16 @@ class IntrospectionManager(QObject): send_to_help = Signal(str, str, str, str, bool) edit_goto = Signal(str, int, str) - def __init__(self, executable=None, extra_path=None): + def __init__(self, executable=None, extra_path=[]): super(IntrospectionManager, self).__init__() self.editor_widget = None self.pending = None + self.sys_path = sys.path[:] self.extra_path = extra_path + if self.extra_path: + self.sys_path.extend(extra_path) self.executable = executable - self.plugin_manager = PluginManager(executable, extra_path) + self.plugin_manager = PluginManager(executable) self.plugin_manager.introspection_complete.connect( self._introspection_complete) @@ -178,14 +182,15 @@ def change_executable(self, executable): self._restart_plugin() def change_extra_path(self, extra_path): + """Change extra_path and update sys_path.""" if extra_path != self.extra_path: self.extra_path = extra_path - self._restart_plugin() + self.sys_path = sys.path[:] + self.sys_path.extend(extra_path) def _restart_plugin(self): self.plugin_manager.close() - self.plugin_manager = PluginManager(self.executable, - extra_path=self.extra_path) + self.plugin_manager = PluginManager(self.executable) self.plugin_manager.introspection_complete.connect( self._introspection_complete) @@ -204,6 +209,7 @@ def _get_code_info(self, name, position=None, **kwargs): kwargs['editor'] = editor kwargs['finfo'] = finfo kwargs['editor_widget'] = self.editor_widget + kwargs['sys_path'] = self.sys_path return CodeInfo(name, finfo.get_source_code(), position, finfo.filename, editor.is_python_like, in_comment_or_string, diff --git a/spyder/utils/introspection/plugin_client.py b/spyder/utils/introspection/plugin_client.py index 3ae8066a557..0c863a130ae 100644 --- a/spyder/utils/introspection/plugin_client.py +++ b/spyder/utils/introspection/plugin_client.py @@ -41,8 +41,7 @@ class AsyncClient(QObject): received = Signal(object) def __init__(self, target, executable=None, name=None, - extra_args=None, libs=None, cwd=None, env=None, - extra_path=None): + extra_args=None, libs=None, cwd=None, env=None): super(AsyncClient, self).__init__() self.executable = executable or sys.executable self.extra_args = extra_args @@ -51,7 +50,6 @@ def __init__(self, target, executable=None, name=None, self.libs = libs self.cwd = cwd self.env = env - self.extra_path = extra_path self.is_initialized = False self.closing = False self.notifier = None @@ -90,13 +88,6 @@ def run(self): python_path = osp.pathsep.join([python_path, path]) except ImportError: pass - if self.extra_path: - try: - python_path = osp.pathsep.join([python_path] + - self.extra_path) - except Exception as e: - debug_print("Error when adding extra_path to plugin env") - debug_print(e) env.append("PYTHONPATH=%s" % python_path) if self.env: env.update(self.env) @@ -211,10 +202,10 @@ class PluginClient(AsyncClient): def __init__(self, plugin_name, executable=None, env=None, extra_path=None): cwd = os.path.dirname(__file__) - super(PluginClient, self).__init__('plugin_server.py', - executable=executable, cwd=cwd, env=env, - extra_args=[plugin_name], libs=[plugin_name], - extra_path=extra_path) + super(PluginClient, self).__init__( + 'plugin_server.py', + executable=executable, cwd=cwd, env=env, + extra_args=[plugin_name], libs=[plugin_name]) self.name = plugin_name diff --git a/spyder/utils/introspection/rope_plugin.py b/spyder/utils/introspection/rope_plugin.py index 479780e8f91..881f31ec5fa 100644 --- a/spyder/utils/introspection/rope_plugin.py +++ b/spyder/utils/introspection/rope_plugin.py @@ -81,6 +81,9 @@ def get_completions(self, info): source_code = info['source_code'] offset = info['position'] + # Set python path into rope project + self.project.prefs.set('python_path', info['sys_path']) + # Prevent Rope from returning import completions because # it can't handle them. Only Jedi can do it! lines = sourcecode.split_source(source_code[:offset]) @@ -89,12 +92,13 @@ def get_completions(self, info): and not ';' in last_line: return [] - if PY2: - filename = filename.encode('utf-8') - else: - #TODO: test if this is working without any further change in - # Python 3 with a user account containing unicode characters - pass + if filename is not None: + if PY2: + filename = filename.encode('utf-8') + else: + # TODO: test if this is working without any further change in + # Python 3 with a user account containing unicode characters + pass try: resource = rope.base.libutils.path_to_resource(self.project, filename) @@ -124,12 +128,16 @@ def get_info(self, info): source_code = info['source_code'] offset = info['position'] - if PY2: - filename = filename.encode('utf-8') - else: - #TODO: test if this is working without any further change in - # Python 3 with a user account containing unicode characters - pass + # Set python path into rope project + self.project.prefs.set('python_path', info['sys_path']) + + if filename is not None: + if PY2: + filename = filename.encode('utf-8') + else: + # TODO: test if this is working without any further change in + # Python 3 with a user account containing unicode characters + pass try: resource = rope.base.libutils.path_to_resource(self.project, filename) @@ -215,12 +223,15 @@ def get_definition(self, info): source_code = info['source_code'] offset = info['position'] - if PY2: - filename = filename.encode('utf-8') - else: - #TODO: test if this is working without any further change in - # Python 3 with a user account containing unicode characters - pass + # Set python path into rope project + self.project.prefs.set('python_path', info['sys_path']) + if filename is not None: + if PY2: + filename = filename.encode('utf-8') + else: + # TODO: test if this is working without any further change in + # Python 3 with a user account containing unicode characters + pass try: resource = rope.base.libutils.path_to_resource(self.project, filename) diff --git a/spyder/utils/introspection/tests/test_jedi_plugin.py b/spyder/utils/introspection/tests/test_jedi_plugin.py index 5c5395961d8..a46f274307a 100644 --- a/spyder/utils/introspection/tests/test_jedi_plugin.py +++ b/spyder/utils/introspection/tests/test_jedi_plugin.py @@ -8,6 +8,8 @@ from textwrap import dedent import pytest +import os +import os.path as osp from spyder.utils.introspection.manager import CodeInfo from spyder.utils.introspection import jedi_plugin @@ -27,6 +29,8 @@ except ImportError: matplotlib = None +LOCATION = osp.realpath(osp.join(os.getcwd(), osp.dirname(__file__))) + p = jedi_plugin.JediPlugin() p.load_plugin() @@ -98,5 +102,13 @@ def test_matplotlib_fig_returns(): assert ('add_axes', 'function') in completions +def test_completions_custom_path(): + source_code = dedent('import test_') + completions = p.get_completions(CodeInfo('completions', source_code, + len(source_code), + sys_path=[LOCATION])) + assert ('test_jedi_plugin', 'module') in completions + + if __name__ == '__main__': pytest.main() diff --git a/spyder/utils/introspection/tests/test_manager.py b/spyder/utils/introspection/tests/test_manager.py new file mode 100644 index 00000000000..d8abb1d98ac --- /dev/null +++ b/spyder/utils/introspection/tests/test_manager.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# +""" +Tests for manager.py +""" + +# Standard library imports +import sys + +# Test library imports +import pytest + +# Local imports +from spyder.utils.introspection.manager import IntrospectionManager + + +@pytest.fixture +def introspector_manager(): + """Create a basic instrospection manager.""" + introspector = IntrospectionManager() + + return introspector + + +def test_introspector_manager_extra_path(introspector_manager): + """Test adding of extra path. + + Extra path is used for adding spyder_path to plugin clients. + """ + introspector = introspector_manager + extra_path = ['/some/dummy/path'] + + assert set(introspector.sys_path) == set(sys.path) + + # Add extra path + introspector.change_extra_path(extra_path) + assert set(sys.path).issubset(set(introspector.sys_path)) + assert set(extra_path).issubset(set(introspector.sys_path)) + + # Remove extra path + introspector.change_extra_path([]) + print(introspector.sys_path) + assert set(sys.path).issubset(set(introspector.sys_path)) + assert not set(extra_path).issubset(set(introspector.sys_path)) diff --git a/spyder/utils/introspection/tests/test_plugin_client.py b/spyder/utils/introspection/tests/test_plugin_client.py index ed88682ab46..8effc6fed0c 100644 --- a/spyder/utils/introspection/tests/test_plugin_client.py +++ b/spyder/utils/introspection/tests/test_plugin_client.py @@ -5,9 +5,6 @@ # """Tests for plugin_client.py.""" -# Standard library imports -import os.path as osp - # Test library imports import pytest @@ -24,19 +21,5 @@ def test_plugin_client(qtbot, plugin_name): assert plugin -@pytest.mark.parametrize("plugin_name", PLUGINS) -def test_plugin_client_extra_path(qtbot, plugin_name): - """Test adding of extra path. - - Extra path is used for adding spyder_path to plugin clients. - """ - extra_path = '/some/dummy/path' - - plugin = PluginClient(plugin_name=plugin_name, extra_path=[extra_path]) - plugin.run() - python_path = plugin.process.processEnvironment().value('PYTHONPATH') - assert extra_path in python_path.split(osp.pathsep) - - if __name__ == "__main__": pytest.main() diff --git a/spyder/utils/introspection/tests/test_rope_plugin.py b/spyder/utils/introspection/tests/test_rope_plugin.py index 6c1b5ecca0f..475331b062c 100644 --- a/spyder/utils/introspection/tests/test_rope_plugin.py +++ b/spyder/utils/introspection/tests/test_rope_plugin.py @@ -8,10 +8,14 @@ from textwrap import dedent import pytest +import os +import os.path as osp from spyder.utils.introspection.manager import CodeInfo from spyder.utils.introspection import rope_plugin +LOCATION = osp.realpath(osp.join(os.getcwd(), osp.dirname(__file__))) + p = rope_plugin.RopePlugin() p.load_plugin() @@ -44,6 +48,14 @@ def test_get_completions_2(): assert not completions +def test_get_completions_custom_path(): + source_code = "import test_rope_plugin; test_" + completions = p.get_completions(CodeInfo('completions', source_code, + len(source_code), + sys_path=[LOCATION])) + assert ('test_rope_plugin', 'module') in completions + + def test_get_definition(): source_code = "import os; os.walk" path, line_nr = p.get_definition(CodeInfo('definition', source_code, diff --git a/spyder/utils/introspection/utils.py b/spyder/utils/introspection/utils.py index 5d34c2468d3..dc124e3609b 100644 --- a/spyder/utils/introspection/utils.py +++ b/spyder/utils/introspection/utils.py @@ -35,13 +35,15 @@ class CodeInfo(object): re.UNICODE) def __init__(self, name, source_code, position, filename=None, - is_python_like=False, in_comment_or_string=False, **kwargs): + is_python_like=False, in_comment_or_string=False, + sys_path=None, **kwargs): self.__dict__.update(kwargs) self.name = name self.filename = filename self.source_code = source_code self.is_python_like = is_python_like self.in_comment_or_string = in_comment_or_string + self.sys_path = sys_path self.position = position diff --git a/spyder/widgets/sourcecode/base.py b/spyder/widgets/sourcecode/base.py index 5eab6039280..dbcc423b3d4 100644 --- a/spyder/widgets/sourcecode/base.py +++ b/spyder/widgets/sourcecode/base.py @@ -51,6 +51,9 @@ def insert_text_to(cursor, text, fmt): class CompletionWidget(QListWidget): """Completion list widget""" + + sig_show_completions = Signal(object) + def __init__(self, parent, ancestor): QListWidget.__init__(self, ancestor) self.setWindowFlags(Qt.SubWindow | Qt.FramelessWindowHint) @@ -146,6 +149,9 @@ def show_list(self, completion_list, automatic=True): # to update the displayed list: self.update_current() + # signal used for testing + self.sig_show_completions.emit(completion_list) + def hide(self): QListWidget.hide(self) self.textedit.setFocus()