Skip to content

Commit

Permalink
Merge pull request #22364 from ccordoba12/sync-console-and-editor-envs
Browse files Browse the repository at this point in the history
PR: Sync the IPython console current env with the one used in the Editor for completions
  • Loading branch information
ccordoba12 authored Aug 22, 2024
2 parents ac00af1 + 22873aa commit ab77b22
Show file tree
Hide file tree
Showing 12 changed files with 191 additions and 81 deletions.
31 changes: 18 additions & 13 deletions .github/scripts/install.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
#!/bin/bash -ex

# Auxiliary functions
install_spyder_kernels() {
echo "Installing subrepo version of spyder-kernels in "$1"..."

pushd external-deps/spyder-kernels

if [ "$OS" = "win" ]; then
# `conda run` fails on Windows without a clear reason
/c/Miniconda/envs/"$1"/python -m pip install -q .
else
conda run -n "$1" python -m pip install -q .
fi

popd
}

# Install gdb
if [ "$USE_GDB" = "true" ]; then
micromamba install gdb -c conda-forge -q -y
Expand Down Expand Up @@ -65,23 +81,12 @@ fi

# Create environment for Jedi environment tests
conda create -n jedi-test-env -q -y python=3.9 flask
install_spyder_kernels jedi-test-env
conda list -n jedi-test-env

# Create environment to test conda env activation before launching a kernel
conda create -n spytest-ž -q -y -c conda-forge python=3.9

# Install subrepo version of Spyder-kernels in that env
pushd external-deps/spyder-kernels

if [ "$OS" = "win" ]; then
# `conda run` fails on Windows without a clear reason
/c/Miniconda/envs/spytest-ž/python -m pip install .
else
conda run -n spytest-ž python -m pip install .
fi

popd

install_spyder_kernels spytest-ž
conda list -n spytest-ž

# Install pyenv on Linux systems
Expand Down
2 changes: 1 addition & 1 deletion spyder/api/shellconnect/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ def set_shellwidget(self, shellwidget):
self.hide()
else:
self.show()
self.update_status(status)
self.current_shellwidget = shellwidget
self.update_status(status)

def add_shellwidget(self, shellwidget):
"""Actions to take when adding a shellwidget."""
Expand Down
42 changes: 24 additions & 18 deletions spyder/app/tests/test_mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -3344,44 +3344,50 @@ def test_preferences_shortcut_reset_regression(main_window, qtbot):

@pytest.mark.order(1)
@flaky(max_runs=3)
@pytest.mark.order(before="test_PYTHONPATH_in_consoles")
@pytest.mark.skipif(not is_anaconda(), reason='Only works with Anaconda')
@pytest.mark.skipif(not running_in_ci(), reason='Only works on CIs')
def test_preferences_change_interpreter(qtbot, main_window):
"""Test that on main interpreter change signal is emitted."""
@pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason="Only works on Linux on CIs but passes locally"
)
def test_change_lsp_interpreter(qtbot, main_window):
"""
Test that the LSP Python interpreter changes when switching consoles for
different envs.
"""
# Wait until the window is fully up
shell = main_window.ipyconsole.get_current_shellwidget()
qtbot.waitUntil(
lambda: shell.spyder_kernel_ready and shell._prompt_html is not None,
timeout=SHELL_TIMEOUT,
)

# Check original pyls configuration
# Check original pylsp configuration
lsp = main_window.completions.get_provider('lsp')
config = lsp.generate_python_config()
jedi = config['configurations']['pylsp']['plugins']['jedi']
assert jedi['environment'] is sys.executable
assert jedi['environment'] == sys.executable
assert jedi['extra_paths'] == []

# Get conda env to use
conda_env = get_list_conda_envs()['Conda: jedi-test-env'][0]
# Get new interpreter to use
new_interpreter = get_list_conda_envs()['Conda: jedi-test-env'][0]

# Change main interpreter on preferences
dlg, index, page = preferences_dialog_helper(
qtbot, main_window, 'main_interpreter'
)
page.cus_exec_radio.radiobutton.setChecked(True)
page.cus_exec_combo.combobox.setCurrentText(conda_env)

mi_container = main_window.main_interpreter.get_container()
# Create console for new interpreter
ipyconsole = main_window.ipyconsole
with qtbot.waitSignal(
mi_container.sig_interpreter_changed, timeout=5000, raising=True
ipyconsole.sig_interpreter_changed, timeout=SHELL_TIMEOUT, raising=True
):
dlg.ok_btn.animateClick()
ipyconsole.get_widget().create_environment_client(
"jedi-test-env",
new_interpreter
)

# Check updated pyls configuration
# Check updated pylsp configuration
qtbot.wait(1000) # Account for debounced timeout when setting interpreter
config = lsp.generate_python_config()
jedi = config['configurations']['pylsp']['plugins']['jedi']
assert jedi['environment'] == conda_env
assert jedi['environment'] == new_interpreter
assert jedi['extra_paths'] == []


Expand Down
6 changes: 3 additions & 3 deletions spyder/plugins/completion/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,9 +1073,9 @@ def python_path_update(self, previous_path, new_path):
"""
pass

@Slot()
def main_interpreter_changed(self):
"""Handle changes on the main Python interpreter of Spyder."""
@Slot(str)
def interpreter_changed(self, interpreter):
"""Handle changes to the Python interpreter used for completions."""
pass

def file_opened_closed_or_updated(self, filename: str, language: str):
Expand Down
49 changes: 38 additions & 11 deletions spyder/plugins/completion/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,11 @@ class CompletionPlugin(SpyderPluginV2):
CONF_SECTION = 'completions'
REQUIRES = [Plugins.Preferences]
OPTIONAL = [
Plugins.MainInterpreter,
Plugins.MainMenu,
Plugins.IPythonConsole,
Plugins.PythonpathManager,
Plugins.StatusBar,
Plugins.MainInterpreter,
]

CONF_FILE = False
Expand Down Expand Up @@ -135,9 +136,15 @@ class CompletionPlugin(SpyderPluginV2):
New PythonPath settings.
"""

sig_interpreter_changed = Signal()
_sig_interpreter_changed = Signal(str)
"""
This signal is used to report changes on the main Python interpreter.
This private signal is used to handle changes in the Python interpreter
done by other plugins.
Parameters
----------
path: str
Path to the new interpreter.
"""

sig_language_completions_available = Signal(dict, str)
Expand Down Expand Up @@ -275,10 +282,13 @@ def on_preferences_available(self):
@on_plugin_available(plugin=Plugins.MainInterpreter)
def on_maininterpreter_available(self):
maininterpreter = self.get_plugin(Plugins.MainInterpreter)
mi_container = maininterpreter.get_container()

mi_container.sig_interpreter_changed.connect(
self.sig_interpreter_changed)
# This will allow people to change the interpreter used for completions
# if they disable the IPython console.
if not self.is_plugin_enabled(Plugins.IPythonConsole):
maininterpreter.sig_interpreter_changed.connect(
self._sig_interpreter_changed
)

@on_plugin_available(plugin=Plugins.StatusBar)
def on_statusbar_available(self):
Expand All @@ -305,6 +315,13 @@ def on_pythonpath_manager_available(self):
pythonpath_manager.sig_pythonpath_changed.connect(
self.sig_pythonpath_changed)

@on_plugin_available(plugin=Plugins.IPythonConsole)
def on_ipython_console_available(self):
ipyconsole = self.get_plugin(Plugins.IPythonConsole)
ipyconsole.sig_interpreter_changed.connect(
self._sig_interpreter_changed
)

@on_plugin_teardown(plugin=Plugins.Preferences)
def on_preferences_teardown(self):
preferences = self.get_plugin(Plugins.Preferences)
Expand All @@ -313,10 +330,12 @@ def on_preferences_teardown(self):
@on_plugin_teardown(plugin=Plugins.MainInterpreter)
def on_maininterpreter_teardown(self):
maininterpreter = self.get_plugin(Plugins.MainInterpreter)
mi_container = maininterpreter.get_container()

mi_container.sig_interpreter_changed.disconnect(
self.sig_interpreter_changed)
# We only connect to this signal if the IPython console is not enabled
if not self.is_plugin_enabled(Plugins.IPythonConsole):
maininterpreter.sig_interpreter_changed.disconnect(
self._sig_interpreter_changed
)

@on_plugin_teardown(plugin=Plugins.StatusBar)
def on_statusbar_teardown(self):
Expand Down Expand Up @@ -355,6 +374,13 @@ def on_pythonpath_manager_teardown(self):
pythonpath_manager.sig_pythonpath_changed.disconnect(
self.sig_pythonpath_changed)

@on_plugin_teardown(plugin=Plugins.IPythonConsole)
def on_ipython_console_teardowm(self):
ipyconsole = self.get_plugin(Plugins.IPythonConsole)
ipyconsole.sig_interpreter_changed.disconnect(
self._sig_interpreter_changed
)

# ---- Public API
def stop_all_providers(self):
"""Stop all running completion providers."""
Expand Down Expand Up @@ -779,8 +805,9 @@ def connect_provider_signals(self, provider_instance):

self.sig_pythonpath_changed.connect(
provider_instance.python_path_update)
self.sig_interpreter_changed.connect(
provider_instance.main_interpreter_changed)
self._sig_interpreter_changed.connect(
provider_instance.interpreter_changed
)

def _instantiate_and_register_provider(
self, Provider: SpyderCompletionProvider):
Expand Down
38 changes: 22 additions & 16 deletions spyder/plugins/completion/providers/languageserver/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from qtpy.QtCore import Signal, Slot, QTimer
from qtpy.QtWidgets import QMessageBox
from qtpy import PYSIDE2, PYSIDE6
from superqt.utils import qdebounced

# Local imports
from spyder.api.config.decorators import on_conf_change
Expand Down Expand Up @@ -126,6 +127,9 @@ class LanguageServerProvider(SpyderCompletionProvider):
def __init__(self, parent, config):
SpyderCompletionProvider.__init__(self, parent, config)

# To keep track of the current interpreter used for completions
self._interpreter = sys.executable

self.clients = {}
self.clients_restart_count = {}
self.clients_restart_timers = {}
Expand Down Expand Up @@ -549,9 +553,23 @@ def python_path_update(self, path_dict, new_path_dict):
logger.debug("Update server's sys.path")
self.update_lsp_configuration(python_only=True)

@Slot()
def main_interpreter_changed(self):
self.update_lsp_configuration(python_only=True)
@qdebounced(timeout=600)
def interpreter_changed(self, interpreter: str):
"""
Handle Python interperter changes from other plugins.
Notes
-----
- This method is debounced to prevent sending too many requests to the
server when switching IPython consoles for different envs in quick
succession.
- The timeout corresponds more or less to the time it takes to switch
back and forth between two consoles.
"""
if interpreter != self._interpreter:
logger.debug(f"LSP interpreter changed to {interpreter}")
self._interpreter = interpreter
self.update_lsp_configuration(python_only=True)

def file_opened_closed_or_updated(self, filename: str, language: str):
self.sig_call_statusbar.emit(
Expand All @@ -577,11 +595,6 @@ def on_pythonpath_option_update(self, value):
if running_under_pytest():
self.update_lsp_configuration(python_only=True)

@on_conf_change(section='main_interpreter',
option=['default', 'custom_interpreter'])
def on_main_interpreter_change(self, option, value):
self.update_lsp_configuration()

def update_lsp_configuration(self, python_only=False):
"""
Update server configuration after changes done by the user
Expand Down Expand Up @@ -794,16 +807,9 @@ def generate_python_config(self):
# Jedi configuration
env_vars = os.environ.copy() # Ensure env is indepependent of PyLSP's
env_vars.pop('PYTHONPATH', None)
if self.get_conf('default', section='main_interpreter'):
# If not explicitly set, jedi uses PyLSP's sys.path instead of
# sys.executable's sys.path. This may be a bug in jedi.
environment = sys.executable
else:
environment = self.get_conf('executable',
section='main_interpreter')

jedi = {
'environment': environment,
'environment': self._interpreter,
'extra_paths': self.get_conf('spyder_pythonpath',
section='pythonpath_manager',
default=[]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,11 @@
LOCATION = osp.realpath(osp.join(os.getcwd(), osp.dirname(__file__)))


def set_executable_config_helper(completion_plugin, executable=None):
def set_executable_helper(completion_plugin, executable=None):
if executable is None:
completion_plugin.set_conf('executable', sys.executable,
'main_interpreter')
completion_plugin.set_conf('default', True, 'main_interpreter')
completion_plugin.set_conf('custom', False, 'main_interpreter')
completion_plugin._sig_interpreter_changed.emit(sys.executable)
else:
completion_plugin.set_conf('executable', executable,
'main_interpreter')
completion_plugin.set_conf('default', False, 'main_interpreter')
completion_plugin._sig_interpreter_changed.emit(executable)


@pytest.mark.order(1)
Expand Down Expand Up @@ -1019,8 +1014,9 @@ def spam():
@flaky(max_runs=20)
@pytest.mark.skipif(not is_anaconda(), reason='Requires conda to work')
@pytest.mark.skipif(not running_in_ci(), reason="Only meant for CIs")
@pytest.mark.skipif(not sys.platform.startswith('linux'),
reason="Works reliably on Linux")
@pytest.mark.skipif(
not sys.platform.startswith('linux'), reason="Works reliably on Linux"
)
def test_completions_environment(completions_codeeditor, qtbot, tmpdir):
"""
Exercise code completions when using another Jedi environment, i.e. a
Expand All @@ -1045,7 +1041,7 @@ def test_completions_environment(completions_codeeditor, qtbot, tmpdir):
# Set interpreter that has Flask and check we can provide completions for
# it
code_editor.set_text('')
set_executable_config_helper(completion_plugin, py_exe)
set_executable_helper(completion_plugin, py_exe)
completion_plugin.after_configuration_update([])
qtbot.wait(5000)

Expand All @@ -1059,7 +1055,7 @@ def test_completions_environment(completions_codeeditor, qtbot, tmpdir):
assert "flask" in [x['label'] for x in sig.args[0]]
assert code_editor.toPlainText() == 'import flask'

set_executable_config_helper(completion_plugin)
set_executable_helper(completion_plugin)
completion_plugin.after_configuration_update([])
qtbot.wait(5000)

Expand Down
Loading

0 comments on commit ab77b22

Please sign in to comment.