diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index bc2b7a1f249..5bc1fb10765 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -523,7 +523,8 @@ def rename_client_tab(self, client, given_name): self.get_widget().rename_client_tab(client, given_name) def create_new_client(self, give_focus=True, filename='', is_cython=False, - is_pylab=False, is_sympy=False, given_name=None): + is_pylab=False, is_sympy=False, given_name=None, + path_to_custom_interpreter=None): """ Create a new client. @@ -546,6 +547,10 @@ def create_new_client(self, give_focus=True, filename='', is_cython=False, given_name : str, optional Initial name displayed in the tab of the client. The default is None. + path_to_custom_interpreter : str, optional + Path to a custom interpreter the client should use regardless of + the interpreter selected in Spyder Preferences. + The default is None. Returns ------- @@ -557,7 +562,8 @@ def create_new_client(self, give_focus=True, filename='', is_cython=False, is_cython=is_cython, is_pylab=is_pylab, is_sympy=is_sympy, - given_name=given_name) + given_name=given_name, + path_to_custom_interpreter=path_to_custom_interpreter) def create_client_for_file(self, filename, is_cython=False): """ diff --git a/spyder/plugins/ipythonconsole/tests/conftest.py b/spyder/plugins/ipythonconsole/tests/conftest.py index 42059b41f70..fca37597a80 100644 --- a/spyder/plugins/ipythonconsole/tests/conftest.py +++ b/spyder/plugins/ipythonconsole/tests/conftest.py @@ -156,6 +156,15 @@ def __getattr__(self, attr): cython_client = request.node.get_closest_marker('cython_client') is_cython = True if cython_client else False + # Start a specific env client if requested + environment_client = request.node.get_closest_marker( + 'environment_client') + given_name = None + path_to_custom_interpreter = None + if environment_client: + given_name = 'spytest-ž' + path_to_custom_interpreter = get_conda_test_env()[1] + # Use an external interpreter if requested external_interpreter = request.node.get_closest_marker( 'external_interpreter') @@ -197,9 +206,13 @@ def get_plugin(name): debugger.on_ipython_console_available() console.on_initialize() console._register() - console.create_new_client(is_pylab=is_pylab, - is_sympy=is_sympy, - is_cython=is_cython) + console.create_new_client( + is_pylab=is_pylab, + is_sympy=is_sympy, + is_cython=is_cython, + given_name=given_name, + path_to_custom_interpreter=path_to_custom_interpreter + ) window.setCentralWidget(console.get_widget()) # Set exclamation mark to True diff --git a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py index 2f13af8c46d..644ac0501a9 100644 --- a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py +++ b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py @@ -249,6 +249,35 @@ def test_cython_client(ipyconsole, qtbot): assert 'Error' not in control.toPlainText() +@flaky(max_runs=3) +@pytest.mark.order(1) +@pytest.mark.environment_client +@pytest.mark.skipif(not is_anaconda(), reason='Only works with Anaconda') +@pytest.mark.skipif(not running_in_ci(), reason='Only works on CIs') +@pytest.mark.skipif(not os.name == 'nt', reason='Works reliably on Windows') +def test_environment_client(ipyconsole, qtbot): + """ + Test that when creating a console for a specific conda environment, the + environment is activated before a kernel is created for it. + """ + # Wait until the window is fully up + shell = ipyconsole.get_current_shellwidget() + + # Check console name + client = ipyconsole.get_current_client() + client.get_name() == "spytest-ž 1/A" + + # Get conda activation environment variable + with qtbot.waitSignal(shell.executed): + shell.execute( + "import os; conda_prefix = os.environ.get('CONDA_PREFIX')" + ) + + expected_output = get_conda_test_env()[0].replace('\\', '/') + output = shell.get_value('conda_prefix').replace('\\', '/') + assert expected_output == output + + @flaky(max_runs=3) def test_tab_rename_for_slaves(ipyconsole, qtbot): """Test slave clients are renamed correctly.""" diff --git a/spyder/plugins/ipythonconsole/utils/kernelspec.py b/spyder/plugins/ipythonconsole/utils/kernelspec.py index fcf1ffaf358..014f0bba631 100644 --- a/spyder/plugins/ipythonconsole/utils/kernelspec.py +++ b/spyder/plugins/ipythonconsole/utils/kernelspec.py @@ -97,12 +97,13 @@ class SpyderKernelSpec(KernelSpec, SpyderConfigurationAccessor): CONF_SECTION = 'ipython_console' def __init__(self, is_cython=False, is_pylab=False, - is_sympy=False, **kwargs): + is_sympy=False, path_to_custom_interpreter=None, + **kwargs): super(SpyderKernelSpec, self).__init__(**kwargs) self.is_cython = is_cython self.is_pylab = is_pylab self.is_sympy = is_sympy - + self.path_to_custom_interpreter = path_to_custom_interpreter self.display_name = 'Python 3 (Spyder)' self.language = 'python3' self.resource_dir = '' @@ -111,10 +112,15 @@ def __init__(self, is_cython=False, is_pylab=False, def argv(self): """Command to start kernels""" # Python interpreter used to start kernels - if self.get_conf('default', section='main_interpreter'): + if ( + self.get_conf('default', section='main_interpreter') + and not self.path_to_custom_interpreter + ): pyexec = get_python_executable() else: pyexec = self.get_conf('executable', section='main_interpreter') + if self.path_to_custom_interpreter: + pyexec = self.path_to_custom_interpreter if not has_spyder_kernels(pyexec): raise SpyderKernelError( ERROR_SPYDER_KERNEL_INSTALLED.format( @@ -186,7 +192,8 @@ def env(self): # Environment variables that we need to pass to the kernel env_vars.update({ - 'SPY_EXTERNAL_INTERPRETER': not default_interpreter, + 'SPY_EXTERNAL_INTERPRETER': (not default_interpreter + or self.path_to_custom_interpreter), 'SPY_UMR_ENABLED': self.get_conf( 'umr/enabled', section='main_interpreter'), 'SPY_UMR_VERBOSE': self.get_conf( diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 461e012089f..9a1a479b8cb 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -98,7 +98,8 @@ def __init__(self, parent, id_, give_focus=True, options_button=None, handlers={}, - initial_cwd=None): + initial_cwd=None, + forcing_custom_interpreter=False): super(ClientWidget, self).__init__(parent) SaveHistoryMixin.__init__(self, get_conf_path('history.py')) @@ -108,6 +109,7 @@ def __init__(self, parent, id_, self.menu_actions = menu_actions self.given_name = given_name self.initial_cwd = initial_cwd + self.forcing_custom_interpreter = forcing_custom_interpreter # --- Other attrs self.kernel_handler = None @@ -518,7 +520,8 @@ def get_name(self): # Adding id to name client_id = self.id_['int_id'] + u'/' + self.id_['str_id'] name = name + u' ' + client_id - elif self.given_name in ["Pylab", "SymPy", "Cython"]: + elif (self.given_name in ["Pylab", "SymPy", "Cython"] or + self.forcing_custom_interpreter): client_id = self.id_['int_id'] + u'/' + self.id_['str_id'] name = self.given_name + u' ' + client_id else: diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index 4f736b7534d..3000f438472 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -41,8 +41,10 @@ KernelConnectionDialog, PageControlWidget) from spyder.plugins.ipythonconsole.widgets.mixins import CachedKernelMixin from spyder.utils import encoding, programs, sourcecode +from spyder.utils.envs import get_list_envs from spyder.utils.misc import get_error_match, remove_backslashes from spyder.utils.palette import QStylePalette +from spyder.utils.workers import WorkerManager from spyder.widgets.browser import FrameWebView from spyder.widgets.findreplace import FindReplace from spyder.widgets.tabs import Tabs @@ -64,6 +66,7 @@ class IPythonConsoleWidgetActions: CreateCythonClient = 'create cython client' CreateSymPyClient = 'create cympy client' CreatePyLabClient = 'create pylab client' + CreateNewClientEnvironment = 'create environment client' # Current console actions ClearConsole = 'Clear shell' @@ -94,6 +97,7 @@ class IPythonConsoleWidgetActions: class IPythonConsoleWidgetOptionsMenus: SpecialConsoles = 'special_consoles_submenu' Documentation = 'documentation_submenu' + EnvironmentConsoles = 'environment_consoles_submenu' class IPythonConsoleWidgetOptionsMenuSections: @@ -268,6 +272,8 @@ def __init__(self, name=None, plugin=None, parent=None): self.interrupt_action = None self.initial_conf_options = self.get_conf_options() self.registered_spyder_kernel_handlers = {} + self.envs = {} + self.default_interpreter = sys.executable # Disable infowidget if requested by the user self.enable_infowidget = True @@ -351,6 +357,12 @@ def __init__(self, name=None, plugin=None, parent=None): # Initial value for the current working directory self._current_working_directory = get_home_dir() + # Worker to compute envs in a thread + self._worker_manager = WorkerManager(max_threads=1) + + # Update the list of envs at startup + self.get_envs() + def on_close(self): self.mainwindow_close = True self.close_all_clients() @@ -488,12 +500,22 @@ def setup(self): # --- Setting options menu options_menu = self.get_options_menu() + + self.console_environment_menu = self.create_menu( + IPythonConsoleWidgetOptionsMenus.EnvironmentConsoles, + _('New console in environment') + ) + stylesheet = qstylizer.style.StyleSheet() + stylesheet["QMenu"]["menu-scrollable"].setValue("1") + self.console_environment_menu.setStyleSheet(stylesheet.toString()) + self.special_console_menu = self.create_menu( IPythonConsoleWidgetOptionsMenus.SpecialConsoles, - _('Special consoles')) + _('New special console')) for item in [ self.create_client_action, + self.console_environment_menu, self.special_console_menu, self.connect_to_kernel_action]: self.add_item_to_menu( @@ -542,6 +564,9 @@ def setup(self): triggered=self.create_cython_client, ) + self.console_environment_menu.aboutToShow.connect( + self.update_environment_menu) + for item in [ create_pylab_action, create_sympy_action, @@ -579,6 +604,7 @@ def setup(self): for item in [ self.create_client_action, + self.console_environment_menu, self.special_console_menu, self.connect_to_kernel_action]: self.add_item_to_menu( @@ -650,6 +676,49 @@ def update_actions(self): self.syspath_action.setEnabled(not error_or_loading) self.show_time_action.setEnabled(not error_or_loading) + def get_envs(self): + """ + Get the list of environments/interpreters in a worker. + """ + self._worker_manager.terminate_all() + worker = self._worker_manager.create_python_worker(get_list_envs) + worker.sig_finished.connect(self.update_envs) + worker.start() + + def update_envs(self, worker, output, error): + """Update the list of environments in the system.""" + if output is not None: + self.envs.update(**output) + + def update_environment_menu(self): + """ + Update context menu submenu with entries for available interpreters. + """ + self.get_envs() + self.console_environment_menu.clear_actions() + for env_key, env_info in self.envs.items(): + env_name = env_key.split()[-1] + path_to_interpreter, python_version = env_info + action = self.create_action( + name=env_key, + text=f'{env_key} ({python_version})', + icon=self.create_icon('ipython_console'), + triggered=( + lambda checked, env_name=env_name, + path_to_interpreter=path_to_interpreter: + self.create_environment_client( + env_name, + path_to_interpreter + ) + ), + overwrite=True + ) + self.add_item_to_menu( + action, + menu=self.console_environment_menu + ) + self.console_environment_menu._render() + # ---- GUI options @on_conf_change(section='help', option='connect/ipython_console') def change_clients_help_connection(self, value): @@ -1269,9 +1338,12 @@ def config_options(self): cfg._merge(spy_cfg) return cfg - def interpreter_versions(self): + def interpreter_versions(self, path_to_custom_interpreter=None): """Python and IPython versions used by clients""" - if self.get_conf('default', section='main_interpreter'): + if ( + self.get_conf('default', section='main_interpreter') + and not path_to_custom_interpreter + ): from IPython.core import release versions = dict( python_version=sys.version, @@ -1281,6 +1353,8 @@ def interpreter_versions(self): import subprocess versions = {} pyexec = self.get_conf('executable', section='main_interpreter') + if path_to_custom_interpreter: + pyexec = path_to_custom_interpreter py_cmd = u'%s -c "import sys; print(sys.version)"' % pyexec ipy_cmd = ( u'%s -c "import IPython.core.release as r; print(r.version)"' @@ -1346,11 +1420,13 @@ def get_current_shellwidget(self): @Slot(bool) @Slot(str) @Slot(bool, str) + @Slot(bool, str, str) @Slot(bool, bool) @Slot(bool, str, bool) def create_new_client(self, give_focus=True, filename='', is_cython=False, is_pylab=False, is_sympy=False, given_name=None, - cache=True, initial_cwd=None): + cache=True, initial_cwd=None, + path_to_custom_interpreter=None): """Create a new client""" self.master_clients += 1 client_id = dict(int_id=str(self.master_clients), @@ -1363,12 +1439,14 @@ def create_new_client(self, give_focus=True, filename='', is_cython=False, additional_options=self.additional_options( is_pylab=is_pylab, is_sympy=is_sympy), - interpreter_versions=self.interpreter_versions(), + interpreter_versions=self.interpreter_versions( + path_to_custom_interpreter), context_menu_actions=self.context_menu_actions, given_name=given_name, give_focus=give_focus, handlers=self.registered_spyder_kernel_handlers, initial_cwd=initial_cwd, + forcing_custom_interpreter=path_to_custom_interpreter is not None ) # Add client to widget @@ -1380,7 +1458,8 @@ def create_new_client(self, give_focus=True, filename='', is_cython=False, kernel_spec = SpyderKernelSpec( is_cython=is_cython, is_pylab=is_pylab, - is_sympy=is_sympy + is_sympy=is_sympy, + path_to_custom_interpreter=path_to_custom_interpreter ) try: @@ -1481,6 +1560,15 @@ def create_cython_client(self): """Force creation of Cython client""" self.create_new_client(is_cython=True, given_name="Cython") + def create_environment_client( + self, environment, path_to_custom_interpreter + ): + """Create a client for a Python environment.""" + self.create_new_client( + given_name=environment, + path_to_custom_interpreter=path_to_custom_interpreter + ) + @Slot(str) def create_client_from_path(self, path): """Create a client with its cwd pointing to path.""" diff --git a/spyder/plugins/maininterpreter/confpage.py b/spyder/plugins/maininterpreter/confpage.py index 3634887789f..c760336998c 100644 --- a/spyder/plugins/maininterpreter/confpage.py +++ b/spyder/plugins/maininterpreter/confpage.py @@ -66,8 +66,8 @@ def setup_page(self): # Python executable Group pyexec_group = QGroupBox(_("Python interpreter")) pyexec_bg = QButtonGroup(pyexec_group) - pyexec_label = QLabel(_("Select the Python interpreter for all Spyder " - "consoles")) + pyexec_label = QLabel(_("Select the Python interpreter used for " + "default Spyder consoles and code completion")) self.def_exec_radio = self.create_radiobutton( _("Default (i.e. the same as Spyder's)"), 'default', diff --git a/spyder/plugins/maininterpreter/widgets/status.py b/spyder/plugins/maininterpreter/widgets/status.py index 8f055fa8cc8..90139e81ad3 100644 --- a/spyder/plugins/maininterpreter/widgets/status.py +++ b/spyder/plugins/maininterpreter/widgets/status.py @@ -18,9 +18,8 @@ # Local imports from spyder.api.widgets.status import BaseTimerStatus from spyder.config.base import is_pynsist, running_in_mac_app -from spyder.utils.conda import get_list_conda_envs +from spyder.utils.envs import get_list_envs from spyder.utils.programs import get_interpreter_info -from spyder.utils.pyenv import get_list_pyenv_envs from spyder.utils.workers import WorkerManager @@ -106,19 +105,6 @@ def _get_env_dir(self, interpreter): else: return osp.dirname(osp.dirname(interpreter)) - def _get_envs(self): - """Get the list of environments in the system.""" - # Compute info of default interpreter to have it available in - # case we need to switch to it. This will avoid lags when - # doing that in get_value. - if self.default_interpreter not in self.path_to_env: - self._get_env_info(self.default_interpreter) - - # Get envs - conda_env = get_list_conda_envs() - pyenv_env = get_list_pyenv_envs() - return {**conda_env, **pyenv_env} - def _get_env_info(self, path): """Get environment information.""" path = path.lower() if os.name == 'nt' else path @@ -165,7 +151,17 @@ def get_envs(self): date. """ self._worker_manager.terminate_all() - worker = self._worker_manager.create_python_worker(self._get_envs) + + # Compute info of default interpreter to have it available in + # case we need to switch to it. This will avoid lags when + # doing that in get_value. + if self.default_interpreter not in self.path_to_env: + default_worker = self._worker_manager.create_python_worker( + self._get_env_info, + self.default_interpreter + ) + default_worker.start() + worker = self._worker_manager.create_python_worker(get_list_envs) worker.sig_finished.connect(self.update_envs) worker.start() diff --git a/spyder/utils/envs.py b/spyder/utils/envs.py new file mode 100644 index 00000000000..2cb72773865 --- /dev/null +++ b/spyder/utils/envs.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Python environments general utilities +""" + +from spyder.utils.conda import get_list_conda_envs +from spyder.utils.pyenv import get_list_pyenv_envs + + +def get_list_envs(): + """ + Get the list of environments in the system. + + Currently detected conda and pyenv based environments. + """ + conda_env = get_list_conda_envs() + pyenv_env = get_list_pyenv_envs() + + return {**conda_env, **pyenv_env}