From 8e363a1d0b37481f8a18646b0be5c763daea61c2 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Tue, 30 Jan 2024 13:20:54 -0800 Subject: [PATCH 01/36] Update UI with toggle button to prioritize spyder_pythonpath with respect to sys.path. --- spyder/config/main.py | 1 + .../plugins/pythonpath/widgets/pathmanager.py | 43 ++++++++++++++++--- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/spyder/config/main.py b/spyder/config/main.py index 90a7359c6d4..e275eb63869 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -108,6 +108,7 @@ ('pythonpath_manager', { 'spyder_pythonpath': [], + 'prioritize': False, }), ('quick_layouts', { diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index c54032449d5..5f57fc2b53a 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -44,13 +44,14 @@ class PathManagerToolbuttons: AddPath = 'add_path' RemovePath = 'remove_path' ExportPaths = 'export_paths' + Prioritize = 'prioritize' class PathManager(QDialog, SpyderWidgetMixin): """Path manager dialog.""" redirect_stdio = Signal(bool) - sig_path_changed = Signal(object) + sig_path_changed = Signal(object, bool) # This is required for our tests CONF_SECTION = 'pythonpath_manager' @@ -79,6 +80,8 @@ def __init__(self, parent, path=None, project_path=None, self.system_path = () self.user_path = [] + self.original_prioritize = None + # This is necessary to run our tests if self.path: self.update_paths(system_path=get_system_pythonpath()) @@ -91,6 +94,7 @@ def __init__(self, parent, path=None, project_path=None, self.movedown_button = None self.movebottom_button = None self.export_button = None + self.prioritize_button = None self.user_header = None self.project_header = None self.system_header = None @@ -108,6 +112,9 @@ def __init__(self, parent, path=None, project_path=None, self.setWindowIcon(self.create_icon('pythonpath')) self.resize(500, 400) self.export_button.setVisible(os.name == 'nt' and sync) + self.prioritize_button.setChecked( + self.get_conf('prioritize', default=False) + ) # Description description = QLabel( @@ -190,12 +197,20 @@ def _setup_right_toolbar(self): icon=self.create_icon('fileexport'), triggered=self.export_pythonpath, tip=_("Export to PYTHONPATH environment variable")) + self.prioritize_button = self.create_toolbutton( + PathManagerToolbuttons.Prioritize, + icon=self.create_icon('first_page'), + option='prioritize', + triggered=self.prioritize, + tip=_("Place PYTHONPATH at the front of sys.path")) + self.prioritize_button.setCheckable(True) self.selection_widgets = [self.movetop_button, self.moveup_button, self.movedown_button, self.movebottom_button] return ( [self.add_button, self.remove_button] + - self.selection_widgets + [self.export_button] + self.selection_widgets + [self.export_button] + + [self.prioritize_button] ) def _create_item(self, path): @@ -334,6 +349,7 @@ def setup(self): self.listwidget.setCurrentRow(0) self.original_path_dict = self.get_path_dict() + self.original_prioritize = self.get_conf('prioritize', default=False) self.refresh() @Slot() @@ -462,7 +478,7 @@ def refresh(self): # Enable remove button only for user paths self.remove_button.setEnabled( - not current_item in self.headers + current_item not in self.headers and (self.editable_top_row <= row <= self.editable_bottom_row) ) @@ -471,6 +487,7 @@ def refresh(self): # Ok button only enabled if actual changes occur self.button_ok.setEnabled( self.original_path_dict != self.get_path_dict() + or self.original_prioritize != self.prioritize_button.isChecked() ) @Slot() @@ -601,6 +618,10 @@ def move_to(self, absolute=None, relative=None): self.user_path = self.get_user_path() self.refresh() + def prioritize(self): + """Toggle prioritize setting.""" + self.refresh() + def current_row(self): """Returns the current row of the list.""" return self.listwidget.currentRow() @@ -631,14 +652,21 @@ def _update_system_path(self): system paths are different. """ if self.system_path != self.get_conf('system_path', default=()): - self.sig_path_changed.emit(self.get_path_dict()) + self.sig_path_changed.emit( + self.get_path_dict(), + self.get_conf('prioritize', default=False) + ) self.set_conf('system_path', self.system_path) def accept(self): """Override Qt method.""" path_dict = self.get_path_dict() - if self.original_path_dict != path_dict: - self.sig_path_changed.emit(path_dict) + prioritize = self.prioritize_button.isChecked() + if ( + self.original_path_dict != path_dict + or self.original_prioritize != prioritize + ): + self.sig_path_changed.emit(path_dict, prioritize) super().accept() def reject(self): @@ -661,7 +689,8 @@ def test(): project_path=tuple(sys.path[-2:]), ) - def callback(path_dict): + def callback(path_dict, prioritize): + sys.stdout.write(f"prioritize: {prioritize}\n") sys.stdout.write(str(path_dict)) dlg.sig_path_changed.connect(callback) From 5282abc6362a3547ac1c07bd91cdf2397cbc8758 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Tue, 6 Feb 2024 08:44:19 -0800 Subject: [PATCH 02/36] Add path priority to pythonpath_manager plugin container --- spyder/plugins/pythonpath/container.py | 37 +++++++++++++++---- .../plugins/pythonpath/widgets/pathmanager.py | 2 + 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index 84baa967280..951dabb55d9 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -41,6 +41,7 @@ def __init__(self, *args, **kwargs): self.path = () self.not_active_path = () self.project_path = () + self.prioritize = None # ---- PluginMainContainer API # ------------------------------------------------------------------------- @@ -175,7 +176,10 @@ def _load_pythonpath(self): name for name in not_active_paths if osp.isdir(name) ) - def _save_paths(self, new_path_dict): + # Load prioritize + self.prioritize = self.get_conf('prioritize', default=False) + + def _save_paths(self, new_path_dict, new_prioritize): """ Save tuples for all paths and not active ones to config system and update their associated attributes. @@ -183,21 +187,37 @@ def _save_paths(self, new_path_dict): `new_path_dict` is an OrderedDict that has the new paths as keys and the state as values. The state is `True` for active and `False` for inactive. + + `prioritize` is a boolean indicating whether paths should be + prioritized over sys.path. """ path = tuple(p for p in new_path_dict) not_active_path = tuple( p for p in new_path_dict if not new_path_dict[p] ) + old_spyder_pythonpath = self.get_spyder_pythonpath() # Don't set options unless necessary if path != self.path: + logger.debug(f"Saving path: {path}") self.set_conf('path', path) self.path = path if not_active_path != self.not_active_path: + logger.debug(f"Saving inactive paths: {not_active_path}") self.set_conf('not_active_path', not_active_path) self.not_active_path = not_active_path + if new_prioritize != self.prioritize: + logger.debug(f"Saving prioritize: {new_prioritize}") + self.set_conf('prioritize', new_prioritize) + self.prioritize = new_prioritize + + new_spyder_pythonpath = self.get_spyder_pythonpath() + if new_spyder_pythonpath != old_spyder_pythonpath: + logger.debug(f"Saving Spyder pythonpath: {new_spyder_pythonpath}") + self.set_conf('spyder_pythonpath', new_spyder_pythonpath) + def _get_spyder_pythonpath_dict(self): """ Return Spyder PYTHONPATH plus project path as dictionary of paths. @@ -220,7 +240,7 @@ def _get_spyder_pythonpath_dict(self): return path_dict - def _update_python_path(self, new_path_dict=None): + def _update_python_path(self, new_path_dict=None, new_prioritize=None): """ Update Python path on language server and kernels. @@ -228,19 +248,20 @@ def _update_python_path(self, new_path_dict=None): """ # Load existing path plus project path old_path_dict_p = self._get_spyder_pythonpath_dict() + old_prioritize = self.prioritize # Save new path - if new_path_dict is not None: - self._save_paths(new_path_dict) + if new_path_dict is not None or new_prioritize is not None: + self._save_paths(new_path_dict, new_prioritize) # Load new path plus project path new_path_dict_p = self._get_spyder_pythonpath_dict() # Do not notify observers unless necessary - if new_path_dict_p != old_path_dict_p: - pypath = self.get_spyder_pythonpath() - logger.debug(f"Update Pythonpath to {pypath}") - self.set_conf('spyder_pythonpath', pypath) + if ( + new_path_dict_p != old_path_dict_p + or new_prioritize != old_prioritize + ): self.sig_pythonpath_changed.emit(old_path_dict_p, new_path_dict_p) def _migrate_to_config_options(self): diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index 5f57fc2b53a..28cde3d0f0a 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -651,6 +651,8 @@ def _update_system_path(self): Request to update path values on main window if current and previous system paths are different. """ + # !!! If system path changed, then all changes made by user will be + # applied even if though the user cancelled or closed the widget. if self.system_path != self.get_conf('system_path', default=()): self.sig_path_changed.emit( self.get_path_dict(), From 714b3fb8d0d89e5f79c1be720a86362ec310f461 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Wed, 31 Jan 2024 09:18:10 -0800 Subject: [PATCH 03/36] Add path priority to pythonpath_manager sig_pythonpath_changed signal --- spyder/plugins/pythonpath/container.py | 12 +++++++++--- spyder/plugins/pythonpath/plugin.py | 5 ++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index 951dabb55d9..bb372225400 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -34,7 +34,7 @@ class PythonpathActions: # ----------------------------------------------------------------------------- class PythonpathContainer(PluginMainContainer): - sig_pythonpath_changed = Signal(object, object) + sig_pythonpath_changed = Signal(object, object, bool) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -100,9 +100,13 @@ def update_active_project_path(self, path): # New path new_path_dict_p = self._get_spyder_pythonpath_dict() + prioritize = self.get_conf('prioritize', default=False) + # Update path self.set_conf('spyder_pythonpath', self.get_spyder_pythonpath()) - self.sig_pythonpath_changed.emit(old_path_dict_p, new_path_dict_p) + self.sig_pythonpath_changed.emit( + old_path_dict_p, new_path_dict_p, prioritize + ) def show_path_manager(self): """Show path manager dialog.""" @@ -262,7 +266,9 @@ def _update_python_path(self, new_path_dict=None, new_prioritize=None): new_path_dict_p != old_path_dict_p or new_prioritize != old_prioritize ): - self.sig_pythonpath_changed.emit(old_path_dict_p, new_path_dict_p) + self.sig_pythonpath_changed.emit( + old_path_dict_p, new_path_dict_p, new_prioritize + ) def _migrate_to_config_options(self): """ diff --git a/spyder/plugins/pythonpath/plugin.py b/spyder/plugins/pythonpath/plugin.py index 879bedd1eb9..e13dc4e76a1 100644 --- a/spyder/plugins/pythonpath/plugin.py +++ b/spyder/plugins/pythonpath/plugin.py @@ -34,7 +34,7 @@ class PythonpathManager(SpyderPluginV2): CONF_SECTION = NAME CONF_FILE = False - sig_pythonpath_changed = Signal(object, object) + sig_pythonpath_changed = Signal(object, object, bool) """ This signal is emitted when there is a change in the Pythonpath handled by Spyder. @@ -50,6 +50,9 @@ class PythonpathManager(SpyderPluginV2): new_path_dict: OrderedDict New Pythonpath dictionary. + prioritize + Whether to prioritize Pythonpath in sys.path + See Also -------- :py:meth:`.PythonpathContainer._get_spyder_pythonpath_dict` From f41077368c08a52eeb28065d29e6588a7d6cf47b Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Tue, 6 Feb 2024 08:45:00 -0800 Subject: [PATCH 04/36] Add path priority to IPython Console plugin Remove SPY_PYTHONPATH; run update_syspath on setup_spyder_kernel. I think this would be much cleaner if the the emitted signal carried old/new spyder_pythonpath instead of the dictionary. I don't know of any plugin listening for sig_pythonpath_changed that requires the dictionary version. --- spyder/plugins/ipythonconsole/plugin.py | 8 +++++--- spyder/plugins/ipythonconsole/utils/kernelspec.py | 7 ------- .../plugins/ipythonconsole/widgets/main_widget.py | 4 ++-- spyder/plugins/ipythonconsole/widgets/shell.py | 14 ++++++++++++-- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index cd7374b3789..630793ed7be 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -496,7 +496,7 @@ def on_main_menu_teardown(self): mainmenu.remove_item_from_application_menu( IPythonConsoleWidgetMenus.Documentation, menu_id=ApplicationMenus.Help - ) + ) @on_plugin_teardown(plugin=Plugins.Editor) def on_editor_teardown(self): @@ -982,7 +982,7 @@ def save_working_directory(self, dirname): """ self.get_widget().save_working_directory(dirname) - def update_path(self, path_dict, new_path_dict): + def update_path(self, path_dict, new_path_dict, prioritize): """ Update path on consoles. @@ -995,12 +995,14 @@ def update_path(self, path_dict, new_path_dict): Corresponds to the previous state of the PYTHONPATH. new_path_dict : dict Corresponds to the new state of the PYTHONPATH. + prioritize : bool + Whether to prioritize PYTHONPATH in sys.path Returns ------- None. """ - self.get_widget().update_path(path_dict, new_path_dict) + self.get_widget().update_path(path_dict, new_path_dict, prioritize) def restart(self): """ diff --git a/spyder/plugins/ipythonconsole/utils/kernelspec.py b/spyder/plugins/ipythonconsole/utils/kernelspec.py index b29383fd136..428429fc0a3 100644 --- a/spyder/plugins/ipythonconsole/utils/kernelspec.py +++ b/spyder/plugins/ipythonconsole/utils/kernelspec.py @@ -171,12 +171,6 @@ def env(self): # Do not pass PYTHONPATH to kernels directly, spyder-ide/spyder#13519 env_vars.pop('PYTHONPATH', None) - # List of paths declared by the user, plus project's path, to - # add to PYTHONPATH - pathlist = self.get_conf( - 'spyder_pythonpath', default=[], section='pythonpath_manager') - pypath = os.pathsep.join(pathlist) - # List of modules to exclude from our UMR umr_namelist = self.get_conf( 'umr/namelist', section='main_interpreter') @@ -198,7 +192,6 @@ def env(self): 'SPY_JEDI_O': self.get_conf('jedi_completer'), 'SPY_TESTING': running_under_pytest() or get_safe_mode(), 'SPY_HIDE_CMD': self.get_conf('hide_cmd_windows'), - 'SPY_PYTHONPATH': pypath, # This env var avoids polluting the OS default temp directory with # files generated by `conda run`. It's restored/removed in the # kernel after initialization. diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index 5fd06d36a78..ccbc2d2cd3b 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -2447,13 +2447,13 @@ def on_working_directory_changed(self, dirname): if dirname and osp.isdir(dirname): self.sig_current_directory_changed.emit(dirname) - def update_path(self, path_dict, new_path_dict): + def update_path(self, path_dict, new_path_dict, prioritize): """Update path on consoles.""" logger.debug("Update sys.path in all console clients") for client in self.clients: shell = client.shellwidget if shell is not None: - shell.update_syspath(path_dict, new_path_dict) + shell.update_syspath(path_dict, new_path_dict, prioritize) def get_active_project_path(self): """Get the active project path.""" diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index 2a8ee8d7475..e856184a92e 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -408,6 +408,16 @@ def setup_spyder_kernel(self): self.send_spyder_kernel_configuration() + # Update sys.path + paths = self.get_conf( + "spyder_pythonpath", section="pythonpath_manager" + ) + prioritize = self.get_conf( + "prioritize", section="pythonpath_manager" + ) + path_dict = {path: True for path in paths} + self.update_syspath(path_dict, path_dict, prioritize) + run_lines = self.get_conf('startup/run_lines') if run_lines: self.execute(run_lines, hidden=True) @@ -707,14 +717,14 @@ def set_color_scheme(self, color_scheme, reset=True): "color scheme", "dark" if not dark_color else "light" ) - def update_syspath(self, path_dict, new_path_dict): + def update_syspath(self, path_dict, new_path_dict, prioritize): """Update sys.path contents in the kernel.""" # Prevent error when the kernel is not available and users open/close # projects or use the Python path manager. # Fixes spyder-ide/spyder#21563 if self.kernel_handler is not None: self.call_kernel(interrupt=True, blocking=False).update_syspath( - path_dict, new_path_dict + path_dict, new_path_dict, prioritize ) def request_syspath(self): From 8db76e468189171de2605804614310b2a1e462b5 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Mon, 5 Feb 2024 21:26:08 -0800 Subject: [PATCH 05/36] Add path priority to completions language server --- spyder/config/lsp.py | 1 + spyder/plugins/completion/api.py | 6 +++-- spyder/plugins/completion/plugin.py | 6 +++-- .../providers/languageserver/provider.py | 27 +++++++++---------- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/spyder/config/lsp.py b/spyder/config/lsp.py index b391d006bd8..4194654b21b 100644 --- a/spyder/config/lsp.py +++ b/spyder/config/lsp.py @@ -79,6 +79,7 @@ 'environment': None, 'extra_paths': [], 'env_vars': None, + 'prioritize': False, # Until we have a graphical way for users to add modules to # this option 'auto_import_modules': [ diff --git a/spyder/plugins/completion/api.py b/spyder/plugins/completion/api.py index f9de9a37526..6465850b7a2 100644 --- a/spyder/plugins/completion/api.py +++ b/spyder/plugins/completion/api.py @@ -1059,8 +1059,8 @@ def project_path_update(self, project_path: str, update_kind: str, """ pass - @Slot(object, object) - def python_path_update(self, previous_path, new_path): + @Slot(object, object, bool) + def python_path_update(self, previous_path, new_path, prioritize): """ Handle Python path updates on Spyder. @@ -1070,6 +1070,8 @@ def python_path_update(self, previous_path, new_path): Dictionary containing the previous Python path values. new_path: Dict Dictionary containing the current Python path values. + prioritize + Whether to prioritize Python path values in sys.path """ pass diff --git a/spyder/plugins/completion/plugin.py b/spyder/plugins/completion/plugin.py index e519b58de24..d721d670dfa 100644 --- a/spyder/plugins/completion/plugin.py +++ b/spyder/plugins/completion/plugin.py @@ -124,9 +124,9 @@ class CompletionPlugin(SpyderPluginV2): Name of the completion client. """ - sig_pythonpath_changed = Signal(object, object) + sig_pythonpath_changed = Signal(object, object, bool) """ - This signal is used to receive changes on the PythonPath. + This signal is used to receive changes on the PYTHONPATH. Parameters ---------- @@ -134,6 +134,8 @@ class CompletionPlugin(SpyderPluginV2): Previous PythonPath settings. new_path: dict New PythonPath settings. + prioritize + Whether to prioritize PYTHONPATH in sys.path """ _sig_interpreter_changed = Signal(str) diff --git a/spyder/plugins/completion/providers/languageserver/provider.py b/spyder/plugins/completion/providers/languageserver/provider.py index e5d9f90e987..ed165384a92 100644 --- a/spyder/plugins/completion/providers/languageserver/provider.py +++ b/spyder/plugins/completion/providers/languageserver/provider.py @@ -377,7 +377,6 @@ def project_path_update(self, project_path, update_kind, projects): self.stop_completion_services_for_language(language) self.start_completion_services_for_language(language) - def report_server_error(self, error): """Report server errors in our error report dialog.""" error_data = dict( @@ -532,26 +531,22 @@ def shutdown(self): for language in self.clients: self.stop_completion_services_for_language(language) - @Slot(object, object) - def python_path_update(self, path_dict, new_path_dict): + @Slot(object, object, bool) + def python_path_update(self, path_dict, new_path_dict, prioritize): """ Update server configuration after a change in Spyder's Python path. `path_dict` corresponds to the previous state of the Python path. `new_path_dict` corresponds to the new state of the Python path. + `prioritize` determines whether to prioritize Python path in sys.path. """ - # If path_dict and new_path_dict are the same, it means the change - # was generated by opening or closing a project. In that case, we - # don't need to request an update because that's done through the - # addition/deletion of workspaces. - update = True - if path_dict == new_path_dict: - update = False - - if update: - logger.debug("Update server's sys.path") - self.update_lsp_configuration(python_only=True) + # Opening/closing a project will create a diff between path_dict + # and new_path_dict, but we don't know if prioritize changed. + # sig_pythonpath_changed is only emitted if there is a change so we + # should always update the confguration when this method is called. + logger.debug("Update server's sys.path") + self.update_lsp_configuration(python_only=True) @qdebounced(timeout=600) def interpreter_changed(self, interpreter: str): @@ -806,13 +801,15 @@ def generate_python_config(self): # Jedi configuration env_vars = os.environ.copy() # Ensure env is indepependent of PyLSP's - env_vars.pop('PYTHONPATH', None) jedi = { 'environment': self._interpreter, 'extra_paths': self.get_conf('spyder_pythonpath', section='pythonpath_manager', default=[]), + 'prioritize': self.get_conf('prioritize', + section='pythonpath_manager', + default=False), 'env_vars': env_vars, } jedi_completion = { From ee3c6b8b9f4f2bb5e482bacbd936842e5d08bbee Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Tue, 6 Feb 2024 00:06:40 -0800 Subject: [PATCH 06/36] Add test for prioritize button state --- .../plugins/pythonpath/widgets/tests/test_pathmanager.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py b/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py index f6e73a0e384..12070be0e95 100644 --- a/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py @@ -277,6 +277,14 @@ def test_buttons_state(qtbot, pathmanager, tmpdir): pathmanager.remove_path(True) assert not pathmanager.button_ok.isEnabled() + # Check prioritize button + assert pathmanager.prioritize_button.isEnabled() + assert not pathmanager.prioritize_button.isChecked() + pathmanager.prioritize_button.animateClick() + qtbot.waitUntil(pathmanager.prioritize_button.isChecked) + assert pathmanager.prioritize_button.isChecked() + assert pathmanager.button_ok.isEnabled() + if __name__ == "__main__": pytest.main([os.path.basename(__file__)]) From 8dd33bdfc7098ebfe662a1f6215364dee940f3e0 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Tue, 6 Feb 2024 07:52:24 -0800 Subject: [PATCH 07/36] Update ipythonconsole plugin tests test_ipythoncosonle.py had many failures on latest master; attempting CI=1 skipped many tests but hangs on test_pdb_ignore_lib[True] --- spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py | 2 -- .../plugins/ipythonconsole/widgets/tests/test_kernelconnect.py | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py b/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py index 0503af6a6a0..35b1b187f8a 100644 --- a/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py +++ b/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py @@ -36,9 +36,7 @@ def test_kernel_pypath(tmpdir, default_interpreter): kernel_spec = SpyderKernelSpec() # Check that PYTHONPATH is not in our kernelspec - # and pypath is in SPY_PYTHONPATH assert 'PYTHONPATH' not in kernel_spec.env - assert pypath in kernel_spec.env['SPY_PYTHONPATH'] # Restore default values CONF.set('main_interpreter', 'default', True) diff --git a/spyder/plugins/ipythonconsole/widgets/tests/test_kernelconnect.py b/spyder/plugins/ipythonconsole/widgets/tests/test_kernelconnect.py index dea1641bb93..5ee9220ec16 100644 --- a/spyder/plugins/ipythonconsole/widgets/tests/test_kernelconnect.py +++ b/spyder/plugins/ipythonconsole/widgets/tests/test_kernelconnect.py @@ -130,6 +130,7 @@ def test_connection_dialog_remembers_input_with_ssh_passphrase( assert new_dlg.pn.text() == str(pytest.pn) assert new_dlg.kf.text() == pytest.kf if not running_in_ci(): + # !!! This fails on latest master... assert new_dlg.kfp.text() == pytest.kfp @@ -182,6 +183,7 @@ def test_connection_dialog_remembers_input_with_password( assert new_dlg.un.text() == pytest.un assert new_dlg.pn.text() == str(pytest.pn) if not running_in_ci(): + # !!! This fails on latest master... assert new_dlg.pw.text() == pytest.pw From 15fe2ca53c5ec91fe5860d0166939d5bfde7f329 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Mon, 12 Feb 2024 15:14:56 -0800 Subject: [PATCH 08/36] Change sig_pythonpath_changed arguments from dictionary to list of strings. --- .../providers/languageserver/provider.py | 10 +++++----- spyder/plugins/ipythonconsole/plugin.py | 8 ++++---- .../ipythonconsole/widgets/main_widget.py | 4 ++-- .../plugins/ipythonconsole/widgets/shell.py | 7 +++---- spyder/plugins/pythonpath/container.py | 20 +++++++++---------- 5 files changed, 23 insertions(+), 26 deletions(-) diff --git a/spyder/plugins/completion/providers/languageserver/provider.py b/spyder/plugins/completion/providers/languageserver/provider.py index ed165384a92..7480011d8a8 100644 --- a/spyder/plugins/completion/providers/languageserver/provider.py +++ b/spyder/plugins/completion/providers/languageserver/provider.py @@ -532,17 +532,17 @@ def shutdown(self): self.stop_completion_services_for_language(language) @Slot(object, object, bool) - def python_path_update(self, path_dict, new_path_dict, prioritize): + def python_path_update(self, old_path, new_path, prioritize): """ Update server configuration after a change in Spyder's Python path. - `path_dict` corresponds to the previous state of the Python path. - `new_path_dict` corresponds to the new state of the Python path. + `old_path` corresponds to the previous state of the Python path. + `new_path` corresponds to the new state of the Python path. `prioritize` determines whether to prioritize Python path in sys.path. """ - # Opening/closing a project will create a diff between path_dict - # and new_path_dict, but we don't know if prioritize changed. + # Opening/closing a project will create a diff between old_path + # and new_path, but we don't know if prioritize changed. # sig_pythonpath_changed is only emitted if there is a change so we # should always update the confguration when this method is called. logger.debug("Update server's sys.path") diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index 630793ed7be..b655c47a3c3 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -982,7 +982,7 @@ def save_working_directory(self, dirname): """ self.get_widget().save_working_directory(dirname) - def update_path(self, path_dict, new_path_dict, prioritize): + def update_path(self, old_path, new_path, prioritize): """ Update path on consoles. @@ -991,9 +991,9 @@ def update_path(self, path_dict, new_path_dict, prioritize): Parameters ---------- - path_dict : dict + old_path : list of str Corresponds to the previous state of the PYTHONPATH. - new_path_dict : dict + new_path : list of str Corresponds to the new state of the PYTHONPATH. prioritize : bool Whether to prioritize PYTHONPATH in sys.path @@ -1002,7 +1002,7 @@ def update_path(self, path_dict, new_path_dict, prioritize): ------- None. """ - self.get_widget().update_path(path_dict, new_path_dict, prioritize) + self.get_widget().update_path(old_path, new_path, prioritize) def restart(self): """ diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index ccbc2d2cd3b..2399a9fb699 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -2447,13 +2447,13 @@ def on_working_directory_changed(self, dirname): if dirname and osp.isdir(dirname): self.sig_current_directory_changed.emit(dirname) - def update_path(self, path_dict, new_path_dict, prioritize): + def update_path(self, old_path, new_path, prioritize): """Update path on consoles.""" logger.debug("Update sys.path in all console clients") for client in self.clients: shell = client.shellwidget if shell is not None: - shell.update_syspath(path_dict, new_path_dict, prioritize) + shell.update_syspath(old_path, new_path, prioritize) def get_active_project_path(self): """Get the active project path.""" diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index e856184a92e..fa650e72288 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -415,8 +415,7 @@ def setup_spyder_kernel(self): prioritize = self.get_conf( "prioritize", section="pythonpath_manager" ) - path_dict = {path: True for path in paths} - self.update_syspath(path_dict, path_dict, prioritize) + self.update_syspath(paths, paths, prioritize) run_lines = self.get_conf('startup/run_lines') if run_lines: @@ -717,14 +716,14 @@ def set_color_scheme(self, color_scheme, reset=True): "color scheme", "dark" if not dark_color else "light" ) - def update_syspath(self, path_dict, new_path_dict, prioritize): + def update_syspath(self, path, new_path, prioritize): """Update sys.path contents in the kernel.""" # Prevent error when the kernel is not available and users open/close # projects or use the Python path manager. # Fixes spyder-ide/spyder#21563 if self.kernel_handler is not None: self.call_kernel(interrupt=True, blocking=False).update_syspath( - path_dict, new_path_dict, prioritize + path, new_path, prioritize ) def request_syspath(self): diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index bb372225400..30cb2ed0cb2 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -91,22 +91,20 @@ def update_active_project_path(self, path): path = (path,) # Old path - old_path_dict_p = self._get_spyder_pythonpath_dict() + old_path = self.get_spyder_pythonpath() # Change project path self.project_path = path self.path_manager_dialog.project_path = path # New path - new_path_dict_p = self._get_spyder_pythonpath_dict() + new_path = self.get_spyder_pythonpath() prioritize = self.get_conf('prioritize', default=False) # Update path - self.set_conf('spyder_pythonpath', self.get_spyder_pythonpath()) - self.sig_pythonpath_changed.emit( - old_path_dict_p, new_path_dict_p, prioritize - ) + self.set_conf('spyder_pythonpath', new_path) + self.sig_pythonpath_changed.emit(old_path, new_path, prioritize) def show_path_manager(self): """Show path manager dialog.""" @@ -248,10 +246,10 @@ def _update_python_path(self, new_path_dict=None, new_prioritize=None): """ Update Python path on language server and kernels. - The new_path_dict should not include the project path. + The `new_path_dict` should not include the project path. """ # Load existing path plus project path - old_path_dict_p = self._get_spyder_pythonpath_dict() + old_path = self.get_spyder_pythonpath() old_prioritize = self.prioritize # Save new path @@ -259,15 +257,15 @@ def _update_python_path(self, new_path_dict=None, new_prioritize=None): self._save_paths(new_path_dict, new_prioritize) # Load new path plus project path - new_path_dict_p = self._get_spyder_pythonpath_dict() + new_path = self.get_spyder_pythonpath() # Do not notify observers unless necessary if ( - new_path_dict_p != old_path_dict_p + new_path != old_path or new_prioritize != old_prioritize ): self.sig_pythonpath_changed.emit( - old_path_dict_p, new_path_dict_p, new_prioritize + old_path, new_path, new_prioritize ) def _migrate_to_config_options(self): From 610470cacab611acf02b77679965a3dcf6460f38 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Wed, 28 Feb 2024 09:18:08 -0800 Subject: [PATCH 09/36] Add system_paths and user_paths to pythonpath_manager configuration --- spyder/config/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spyder/config/main.py b/spyder/config/main.py index e275eb63869..c16d94250c1 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -109,6 +109,8 @@ { 'spyder_pythonpath': [], 'prioritize': False, + 'system_paths': {}, + 'user_paths': {}, }), ('quick_layouts', { From 1e55fa1e015549017a5d6efab1ca0027a77322f6 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Wed, 28 Feb 2024 10:28:13 -0800 Subject: [PATCH 10/36] Convert (path, project_path, not_active_path) to (user_paths, project_paths, system_paths) and dictionary type --- .../plugins/pythonpath/widgets/pathmanager.py | 146 +++++++++--------- 1 file changed, 74 insertions(+), 72 deletions(-) diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index 28cde3d0f0a..26a273c9afd 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -25,7 +25,7 @@ from spyder.api.widgets.dialogs import SpyderDialogButtonBox from spyder.api.widgets.mixins import SpyderWidgetMixin from spyder.config.base import _ -from spyder.plugins.pythonpath.utils import check_path, get_system_pythonpath +from spyder.plugins.pythonpath.utils import check_path from spyder.utils.environ import get_user_env, set_user_env from spyder.utils.misc import getcwd_or_home from spyder.utils.stylesheet import ( @@ -56,8 +56,8 @@ class PathManager(QDialog, SpyderWidgetMixin): # This is required for our tests CONF_SECTION = 'pythonpath_manager' - def __init__(self, parent, path=None, project_path=None, - not_active_path=None, sync=True): + def __init__(self, parent, user_paths=None, project_paths=None, + system_paths=None, sync=True): """Path manager dialog.""" if PYQT5 or PYQT6: super().__init__(parent, class_parent=parent) @@ -65,27 +65,22 @@ def __init__(self, parent, path=None, project_path=None, QDialog.__init__(self, parent) SpyderWidgetMixin.__init__(self, class_parent=parent) - assert isinstance(path, (tuple, type(None))) + assert isinstance(user_paths, (OrderedDict, type(None))) # Style # NOTE: This needs to be here so all buttons are styled correctly self.setStyleSheet(self._stylesheet) - self.path = path or () - self.project_path = project_path or () - self.not_active_path = not_active_path or () + self.user_paths = user_paths or OrderedDict() + self.project_paths = project_paths or OrderedDict() + self.system_paths = system_paths or OrderedDict() self.last_path = getcwd_or_home() self.original_path_dict = None - self.system_path = () self.user_path = [] self.original_prioritize = None - # This is necessary to run our tests - if self.path: - self.update_paths(system_path=get_system_pythonpath()) - # Widgets self.add_button = None self.remove_button = None @@ -213,19 +208,16 @@ def _setup_right_toolbar(self): [self.prioritize_button] ) - def _create_item(self, path): + def _create_item(self, path, active): """Helper to create a new list item.""" item = QListWidgetItem(path) - if path in self.project_path: + if path in self.project_paths: item.setFlags(Qt.NoItemFlags | Qt.ItemIsUserCheckable) item.setCheckState(Qt.Checked) - elif path in self.not_active_path: - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Unchecked) else: item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Checked) + item.setCheckState(Qt.Checked if active else Qt.Unchecked) return item @@ -282,9 +274,9 @@ def editable_bottom_row(self): bottom_row = 0 if self.project_header: - bottom_row += len(self.project_path) + 1 + bottom_row += len(self.project_paths) + 1 if self.user_header: - bottom_row += len(self.user_path) + bottom_row += len(self.get_user_paths()) return bottom_row @@ -294,7 +286,7 @@ def editable_top_row(self): top_row = 0 if self.project_header: - top_row += len(self.project_path) + 1 + top_row += len(self.project_paths) + 1 if self.user_header: top_row += 1 @@ -309,7 +301,7 @@ def setup(self): self.system_header = None # Project path - if self.project_path: + if self.project_paths: self.project_header, project_widget = ( self._create_header(_("Project path")) ) @@ -317,12 +309,12 @@ def setup(self): self.listwidget.addItem(self.project_header) self.listwidget.setItemWidget(self.project_header, project_widget) - for path in self.project_path: - item = self._create_item(path) + for path, active in self.project_paths.items(): + item = self._create_item(path, active) self.listwidget.addItem(item) # Paths added by the user - if self.user_path: + if self.user_paths: self.user_header, user_widget = ( self._create_header(_("User paths")) ) @@ -330,12 +322,12 @@ def setup(self): self.listwidget.addItem(self.user_header) self.listwidget.setItemWidget(self.user_header, user_widget) - for path in self.user_path: - item = self._create_item(path) + for path, active in self.user_paths.items(): + item = self._create_item(path, active) self.listwidget.addItem(item) # System path - if self.system_path: + if self.system_paths: self.system_header, system_widget = ( self._create_header(_("System PYTHONPATH")) ) @@ -343,12 +335,11 @@ def setup(self): self.listwidget.addItem(self.system_header) self.listwidget.setItemWidget(self.system_header, system_widget) - for path in self.system_path: - item = self._create_item(path) + for path, active in self.system_paths.items(): + item = self._create_item(path, active) self.listwidget.addItem(item) self.listwidget.setCurrentRow(0) - self.original_path_dict = self.get_path_dict() self.original_prioritize = self.get_conf('prioritize', default=False) self.refresh() @@ -401,50 +392,60 @@ def export_pythonpath(self): env['PYTHONPATH'] = list(ppath) set_user_env(env, parent=self) - def get_path_dict(self, project_path=False): - """ - Return an ordered dict with the path entries as keys and the active - state as the value. + def get_user_paths(self): + """Get current user paths as displayed on listwidget.""" + paths = OrderedDict() - If `project_path` is True, its entries are also included. - """ - odict = OrderedDict() + if self.user_header is None: + return paths + + is_user_path = False for row in range(self.listwidget.count()): item = self.listwidget.item(row) - path = item.text() - if item not in self.headers: - if path in self.project_path and not project_path: - continue - odict[path] = item.checkState() == Qt.Checked + if item in (self.project_header, self.system_header): + is_user_path = False + continue + if item is self.user_header: + is_user_path = True + continue + if not is_user_path: + continue + + paths.update({item.text(): item.checkState() == Qt.Checked}) + + return paths - return odict + def get_system_paths(self): + """Get current system paths as displayed on listwidget.""" + paths = OrderedDict() - def get_user_path(self): - """Get current user path as displayed on listwidget.""" - user_path = [] + if self.system_header is None: + return paths + + is_sys_path = False for row in range(self.listwidget.count()): item = self.listwidget.item(row) - path = item.text() - if item not in self.headers: - if path not in (self.project_path + self.system_path): - user_path.append(path) + if item in (self.project_header, self.user_header): + is_sys_path = False + continue + if item is self.system_header: + is_sys_path = True + continue + if not is_sys_path: + continue + + paths.update({item.text(): item.checkState() == Qt.Checked}) - return user_path + return paths - def update_paths(self, path=None, not_active_path=None, system_path=None): + def update_paths(self, user_paths=None, project_paths=None, system_paths=None): """Update path attributes.""" - if path is not None: - self.path = path - if not_active_path is not None: - self.not_active_path = not_active_path - if system_path is not None: - self.system_path = system_path - - previous_system_path = self.get_conf('system_path', ()) - self.user_path = [ - path for path in self.path - if path not in (self.system_path + previous_system_path) - ] + if user_paths is not None: + self.user_paths = user_paths + if project_paths is not None: + self.project_paths = project_paths + if system_paths is not None: + self.system_paths = system_paths def refresh(self): """Refresh toolbar widgets.""" @@ -486,7 +487,8 @@ def refresh(self): # Ok button only enabled if actual changes occur self.button_ok.setEnabled( - self.original_path_dict != self.get_path_dict() + self.user_paths != self.get_user_paths() + or self.system_paths != self.get_system_paths() or self.original_prioritize != self.prioritize_button.isChecked() ) @@ -508,7 +510,7 @@ def add_path(self, directory=None): directory = osp.abspath(directory) self.last_path = directory - if directory in self.get_path_dict(): + if directory in self.user_paths: item = self.listwidget.findItems(directory, Qt.MatchExactly)[0] item.setCheckState(Qt.Checked) answer = QMessageBox.question( @@ -543,7 +545,7 @@ def add_path(self, directory=None): ) # Add new path - item = self._create_item(directory) + item = self._create_item(directory, True) self.listwidget.insertItem(self.editable_top_row, item) self.listwidget.setCurrentRow(self.editable_top_row) @@ -653,12 +655,12 @@ def _update_system_path(self): """ # !!! If system path changed, then all changes made by user will be # applied even if though the user cancelled or closed the widget. - if self.system_path != self.get_conf('system_path', default=()): + if self.system_paths != self.get_conf('system_paths', default=()): self.sig_path_changed.emit( self.get_path_dict(), self.get_conf('prioritize', default=False) ) - self.set_conf('system_path', self.system_path) + self.set_conf('system_paths', self.system_paths) def accept(self): """Override Qt method.""" @@ -687,8 +689,8 @@ def test(): _ = qapplication() dlg = PathManager( None, - path=tuple(sys.path[:1]), - project_path=tuple(sys.path[-2:]), + user_paths={p: True for p in sys.path[:1]}, + project_paths={p: True for p in sys.path[-2:]}, ) def callback(path_dict, prioritize): From 0178abc23433a7ee7eea40751b2451a8fac5aedb Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Wed, 28 Feb 2024 10:54:06 -0800 Subject: [PATCH 11/36] Only set user_paths, project_paths, system_paths, and prioritize in update_paths method and call setup in update-paths method. This will allow the container to instantiate the PathManager widget before providing paths. Paths will not be retrieved or determined within the widget, only passed to it by the container. --- .../plugins/pythonpath/widgets/pathmanager.py | 64 +++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index 26a273c9afd..52d00c122a6 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -56,8 +56,7 @@ class PathManager(QDialog, SpyderWidgetMixin): # This is required for our tests CONF_SECTION = 'pythonpath_manager' - def __init__(self, parent, user_paths=None, project_paths=None, - system_paths=None, sync=True): + def __init__(self, parent, sync=True): """Path manager dialog.""" if PYQT5 or PYQT6: super().__init__(parent, class_parent=parent) @@ -65,22 +64,14 @@ def __init__(self, parent, user_paths=None, project_paths=None, QDialog.__init__(self, parent) SpyderWidgetMixin.__init__(self, class_parent=parent) - assert isinstance(user_paths, (OrderedDict, type(None))) - # Style # NOTE: This needs to be here so all buttons are styled correctly self.setStyleSheet(self._stylesheet) - self.user_paths = user_paths or OrderedDict() - self.project_paths = project_paths or OrderedDict() - self.system_paths = system_paths or OrderedDict() - self.last_path = getcwd_or_home() self.original_path_dict = None self.user_path = [] - self.original_prioritize = None - # Widgets self.add_button = None self.remove_button = None @@ -107,9 +98,6 @@ def __init__(self, parent, user_paths=None, project_paths=None, self.setWindowIcon(self.create_icon('pythonpath')) self.resize(500, 400) self.export_button.setVisible(os.name == 'nt' and sync) - self.prioritize_button.setChecked( - self.get_conf('prioritize', default=False) - ) # Description description = QLabel( @@ -145,9 +133,6 @@ def __init__(self, parent, user_paths=None, project_paths=None, self.bbox.accepted.connect(self.accept) self.bbox.rejected.connect(self.reject) - # Setup - self.setup() - # ---- Private methods # ------------------------------------------------------------------------- def _add_buttons_to_layout(self, widgets, layout): @@ -196,7 +181,7 @@ def _setup_right_toolbar(self): PathManagerToolbuttons.Prioritize, icon=self.create_icon('first_page'), option='prioritize', - triggered=self.prioritize, + triggered=self.refresh, tip=_("Place PYTHONPATH at the front of sys.path")) self.prioritize_button.setCheckable(True) @@ -339,8 +324,10 @@ def setup(self): item = self._create_item(path, active) self.listwidget.addItem(item) + # Prioritize + self.prioritize_button.setChecked(self.prioritize) + self.listwidget.setCurrentRow(0) - self.original_prioritize = self.get_conf('prioritize', default=False) self.refresh() @Slot() @@ -438,14 +425,26 @@ def get_system_paths(self): return paths - def update_paths(self, user_paths=None, project_paths=None, system_paths=None): - """Update path attributes.""" - if user_paths is not None: - self.user_paths = user_paths - if project_paths is not None: - self.project_paths = project_paths - if system_paths is not None: - self.system_paths = system_paths + def update_paths( + self, + project_paths=OrderedDict(), + user_paths=OrderedDict(), + system_paths=OrderedDict(), + prioritize=False + ): + """Update path attributes. + + These attributes should only be set in this method and upon activating + the dialog. They should remain fixed while the dialog is active and are + used to compare with what is shown in the listwidget in order to detect + changes. + """ + self.project_paths = project_paths + self.user_paths = user_paths + self.system_paths = system_paths + self.prioritize = prioritize + + self.setup() def refresh(self): """Refresh toolbar widgets.""" @@ -489,7 +488,7 @@ def refresh(self): self.button_ok.setEnabled( self.user_paths != self.get_user_paths() or self.system_paths != self.get_system_paths() - or self.original_prioritize != self.prioritize_button.isChecked() + or self.prioritize != self.prioritize_button.isChecked() ) @Slot() @@ -620,10 +619,6 @@ def move_to(self, absolute=None, relative=None): self.user_path = self.get_user_path() self.refresh() - def prioritize(self): - """Toggle prioritize setting.""" - self.refresh() - def current_row(self): """Returns the current row of the list.""" return self.listwidget.currentRow() @@ -689,8 +684,11 @@ def test(): _ = qapplication() dlg = PathManager( None, - user_paths={p: True for p in sys.path[:1]}, - project_paths={p: True for p in sys.path[-2:]}, + ) + dlg.update_paths( + user_paths={p: True for p in sys.path[1:-2]}, + project_paths={p: True for p in sys.path[:1]}, + system_paths={p: True for p in sys.path[-2:]} ) def callback(path_dict, prioritize): From bc39541c59bfafa5ea85cb6a8eb7928ba05222c7 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Wed, 28 Feb 2024 11:02:04 -0800 Subject: [PATCH 12/36] Send new user paths, system paths, and prioritize back to container. These will be dictionaries and the container will handle updating the pythonpath_manager configuration and assembling the final spyder_pythonpath. There is no need for _update_system_path method because the container will handle updates to the underlying system path. Again, the widget will only handle user-interactive changes. --- .../plugins/pythonpath/widgets/pathmanager.py | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index 52d00c122a6..9ce7a14cd20 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -51,7 +51,7 @@ class PathManager(QDialog, SpyderWidgetMixin): """Path manager dialog.""" redirect_stdio = Signal(bool) - sig_path_changed = Signal(object, bool) + sig_path_changed = Signal(object, object, bool) # This is required for our tests CONF_SECTION = 'pythonpath_manager' @@ -69,7 +69,6 @@ def __init__(self, parent, sync=True): self.setStyleSheet(self._stylesheet) self.last_path = getcwd_or_home() - self.original_path_dict = None self.user_path = [] # Widgets @@ -643,37 +642,27 @@ def count(self): # ---- Qt methods # ------------------------------------------------------------------------- - def _update_system_path(self): - """ - Request to update path values on main window if current and previous - system paths are different. - """ - # !!! If system path changed, then all changes made by user will be - # applied even if though the user cancelled or closed the widget. - if self.system_paths != self.get_conf('system_paths', default=()): - self.sig_path_changed.emit( - self.get_path_dict(), - self.get_conf('prioritize', default=False) - ) - self.set_conf('system_paths', self.system_paths) - def accept(self): """Override Qt method.""" - path_dict = self.get_path_dict() - prioritize = self.prioritize_button.isChecked() - if ( - self.original_path_dict != path_dict - or self.original_prioritize != prioritize - ): - self.sig_path_changed.emit(path_dict, prioritize) + self.sig_path_changed.emit( + self.get_user_paths(), + self.get_system_paths(), + self.prioritize_button.isChecked() + ) super().accept() def reject(self): - self._update_system_path() + # Send back original paths (system_paths may be updated) + self.sig_path_changed.emit( + self.user_paths, self.system_paths, self.prioritize + ) super().reject() def closeEvent(self, event): - self._update_system_path() + # Send back original paths (system_paths may be updated) + self.sig_path_changed.emit( + self.user_paths, self.system_paths, self.prioritize + ) super().closeEvent(event) @@ -691,9 +680,17 @@ def test(): system_paths={p: True for p in sys.path[-2:]} ) - def callback(path_dict, prioritize): - sys.stdout.write(f"prioritize: {prioritize}\n") - sys.stdout.write(str(path_dict)) + def callback(user_paths, system_paths, prioritize): + sys.stdout.write(f"Prioritize: {prioritize}") + sys.stdout.write("\n---- User paths ----\n") + sys.stdout.write( + '\n'.join([f'{k}: {v}' for k, v in user_paths.items()]) + ) + sys.stdout.write("\n---- System paths ----\n") + sys.stdout.write( + '\n'.join([f'{k}: {v}' for k, v in system_paths.items()]) + ) + sys.stdout.write('\n') dlg.sig_path_changed.connect(callback) sys.exit(dlg.exec_()) From 88628f0a4aa4883d726e388cb1d6626592b377f0 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Wed, 28 Feb 2024 11:30:46 -0800 Subject: [PATCH 13/36] Remove superfluous user_path attribute --- spyder/plugins/pythonpath/widgets/pathmanager.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index 9ce7a14cd20..bbba4d5eace 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -69,7 +69,6 @@ def __init__(self, parent, sync=True): self.setStyleSheet(self._stylesheet) self.last_path = getcwd_or_home() - self.user_path = [] # Widgets self.add_button = None @@ -508,7 +507,7 @@ def add_path(self, directory=None): directory = osp.abspath(directory) self.last_path = directory - if directory in self.user_paths: + if directory in self.get_user_paths(): item = self.listwidget.findItems(directory, Qt.MatchExactly)[0] item.setCheckState(Qt.Checked) answer = QMessageBox.question( @@ -516,7 +515,7 @@ def add_path(self, directory=None): _("Add path"), _("This directory is already included in the list." "
" - "Do you want to move it to the top of it?"), + "Do you want to move it to the top of the list?"), QMessageBox.Yes | QMessageBox.No) if answer == QMessageBox.Yes: @@ -546,8 +545,6 @@ def add_path(self, directory=None): item = self._create_item(directory, True) self.listwidget.insertItem(self.editable_top_row, item) self.listwidget.setCurrentRow(self.editable_top_row) - - self.user_path.insert(0, directory) else: answer = QMessageBox.warning( self, @@ -584,15 +581,11 @@ def remove_path(self, force=False): QMessageBox.Yes | QMessageBox.No) if force or answer == QMessageBox.Yes: - # Remove current item from user_path - item = self.listwidget.currentItem() - self.user_path.remove(item.text()) - # Remove selected item from view self.listwidget.takeItem(self.listwidget.currentRow()) # Remove user header if there are no more user paths - if len(self.user_path) == 0: + if len(self.get_user_paths()) == 0: self.listwidget.takeItem( self.listwidget.row(self.user_header)) @@ -615,7 +608,6 @@ def move_to(self, absolute=None, relative=None): self.listwidget.insertItem(new_index, item) self.listwidget.setCurrentRow(new_index) - self.user_path = self.get_user_path() self.refresh() def current_row(self): From 13d15080071f1bb69d4205edf29d7c4bab6a3b42 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Wed, 28 Feb 2024 14:18:47 -0800 Subject: [PATCH 14/36] Remove algorithm to save system PYTHONPATH. This will be done in the container instead. --- .../plugins/pythonpath/widgets/pathmanager.py | 32 +++---------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index bbba4d5eace..f6ee1472801 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -26,7 +26,6 @@ from spyder.api.widgets.mixins import SpyderWidgetMixin from spyder.config.base import _ from spyder.plugins.pythonpath.utils import check_path -from spyder.utils.environ import get_user_env, set_user_env from spyder.utils.misc import getcwd_or_home from spyder.utils.stylesheet import ( AppStyle, @@ -52,6 +51,7 @@ class PathManager(QDialog, SpyderWidgetMixin): redirect_stdio = Signal(bool) sig_path_changed = Signal(object, object, bool) + sig_export_pythonpath = Signal(object, object, bool) # This is required for our tests CONF_SECTION = 'pythonpath_manager' @@ -350,32 +350,10 @@ def export_pythonpath(self): if answer == QMessageBox.Cancel: return - env = get_user_env() - - # This doesn't include the project path because it's a transient - # directory, i.e. only used in Spyder and during specific - # circumstances. - active_path = [k for k, v in self.get_path_dict().items() if v] - - if answer == QMessageBox.Yes: - ppath = active_path - else: - ppath = env.get('PYTHONPATH', []) - if not isinstance(ppath, list): - ppath = [ppath] - - ppath = [p for p in ppath if p not in active_path] - ppath = ppath + active_path - - os.environ['PYTHONPATH'] = os.pathsep.join(ppath) - - # Update widget so changes are reflected on it immediately - self.update_paths(system_path=tuple(ppath)) - self.set_conf('system_path', tuple(ppath)) - self.setup() - - env['PYTHONPATH'] = list(ppath) - set_user_env(env, parent=self) + self.sig_export_pythonpath( + self.get_user_paths(), self.get_system_paths(), + answer == QMessageBox.Yes + ) def get_user_paths(self): """Get current user paths as displayed on listwidget.""" From d000632f9d6172d7476eacfb21e1d5002042ebf1 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Wed, 28 Feb 2024 19:00:37 -0800 Subject: [PATCH 15/36] Simplify get_user_paths and get_system_paths --- .../plugins/pythonpath/widgets/pathmanager.py | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index f6ee1472801..0b88c8c51bb 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -362,18 +362,13 @@ def get_user_paths(self): if self.user_header is None: return paths - is_user_path = False - for row in range(self.listwidget.count()): - item = self.listwidget.item(row) - if item in (self.project_header, self.system_header): - is_user_path = False - continue - if item is self.user_header: - is_user_path = True - continue - if not is_user_path: - continue + start = self.listwidget.row(self.user_header) + 1 + stop = self.listwidget.count() + if self.system_header is not None: + stop = self.listwidget.row(self.system_header) + for row in range(start, stop): + item = self.listwidget.item(row) paths.update({item.text(): item.checkState() == Qt.Checked}) return paths @@ -385,18 +380,9 @@ def get_system_paths(self): if self.system_header is None: return paths - is_sys_path = False - for row in range(self.listwidget.count()): + start = self.listwidget.row(self.system_header) + 1 + for row in range(start, self.listwidget.count()): item = self.listwidget.item(row) - if item in (self.project_header, self.user_header): - is_sys_path = False - continue - if item is self.system_header: - is_sys_path = True - continue - if not is_sys_path: - continue - paths.update({item.text(): item.checkState() == Qt.Checked}) return paths From 5229eee4a4d0b873b04dce1ada4a996df602ed7b Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 29 Feb 2024 10:04:57 -0800 Subject: [PATCH 16/36] Update container attributes (path, not_active_path, project_path, prioritize) -> (_user_paths, _system_paths, _project_paths, _prioritize, _spyder_pythonpath). Path lists are now OrderedDict * Simplifies _load_pythonpath -> _load_paths * Move migration method from setup to _load_paths --- spyder/plugins/pythonpath/container.py | 81 ++++++++++---------------- 1 file changed, 30 insertions(+), 51 deletions(-) diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index 30cb2ed0cb2..0abaee848ca 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -38,24 +38,12 @@ class PythonpathContainer(PluginMainContainer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.path = () - self.not_active_path = () - self.project_path = () - self.prioritize = None # ---- PluginMainContainer API # ------------------------------------------------------------------------- def setup(self): - - # Migrate from old conf files to config options - if self.get_conf('paths_in_conf_files', default=True): - self._migrate_to_config_options() - - # Load Python path - self._load_pythonpath() - - # Save current Pythonpath at startup so plugins can use it afterwards - self.set_conf('spyder_pythonpath', self.get_spyder_pythonpath()) + # Load Python paths + self._load_paths() # Path manager dialog self.path_manager_dialog = PathManager(parent=self, sync=True) @@ -135,51 +123,42 @@ def get_spyder_pythonpath(self): # ---- Private API # ------------------------------------------------------------------------- - def _load_pythonpath(self): - """Load Python paths.""" - # Get current system PYTHONPATH - system_path = get_system_pythonpath() - - # Get previous system PYTHONPATH - previous_system_path = self.get_conf('system_path', default=()) + def _load_paths(self): + """Load Python paths. - # Load all paths - paths = [] - previous_paths = self.get_conf('path') - for path in previous_paths: - # Path was removed since last time or it's not a directory - # anymore - if not osp.isdir(path): - continue - - # Path was removed from system path - if path in previous_system_path and path not in system_path: - continue - - paths.append(path) + The attributes _project_paths, _user_paths, _system_paths, _prioritize, + and _spyder_pythonpath, are initialize here and should be updated only + in _save_paths. They are only used to detect changes. + """ + self._project_paths = OrderedDict() + self._user_paths = OrderedDict() + self._system_paths = OrderedDict() + self._prioritize = False + self._spyder_pythonpath = [] + + # Get user paths. Check migration from old conf files + user_paths = self._migrate_to_config_options() + if user_paths is None: + user_paths = self.get_conf('user_paths', {}) + user_paths = OrderedDict(user_paths) - self.path = tuple(paths) + # Get current system PYTHONPATH + system_paths = self._get_system_paths() - # Update path option. This avoids loading paths that were removed in - # this session in later ones. - self.set_conf('path', self.path) + # Get prioritize + prioritize = self.get_conf('prioritize', False) - # Update system path so that path_manager_dialog can work with its - # latest contents. - self.set_conf('system_path', system_path) + self._save_paths(user_paths, system_paths, prioritize) - # Add system path - if system_path: - self.path = self.path + system_path + def _get_system_paths(self): + system_paths = get_system_pythonpath() + conf_system_paths = self.get_conf('system_paths', {}) - # Load not active paths - not_active_paths = self.get_conf('not_active_path') - self.not_active_path = tuple( - name for name in not_active_paths if osp.isdir(name) + system_paths = OrderedDict( + {p: conf_system_paths.get(p, True) for p in system_paths} ) - # Load prioritize - self.prioritize = self.get_conf('prioritize', default=False) + return system_paths def _save_paths(self, new_path_dict, new_prioritize): """ From a8ed2d56fb15ac5c8ca8f81026e377ddcd1336fe Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 29 Feb 2024 10:40:07 -0800 Subject: [PATCH 17/36] Revise configuration migration method. * Promptly exits if remnants of old configuration are not present * Removes remnants of old configuration if present * Constructs user paths from old configuration remnants --- spyder/plugins/pythonpath/container.py | 49 +++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index 0abaee848ca..c97ab271a3c 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -9,6 +9,7 @@ from collections import OrderedDict import logging +import os import os.path as osp from qtpy.QtCore import Signal @@ -256,17 +257,57 @@ def _migrate_to_config_options(self): """ path_file = get_conf_path('path') not_active_path_file = get_conf_path('not_active_path') + config_path = self.get_conf('path', None) + config_not_active_path = self.get_conf('not_active_path', None) + paths_in_conf_files = self.get_conf('paths_in_conf_files', None) + system_path = self.get_conf('system_path', None) + + if ( + not osp.isfile(path_file) + and not osp.isfile(not_active_path_file) + and config_path is not None + and config_not_active_path is not None + and paths_in_conf_files is not None + and system_path is not None + ): + # The configuration does not need to be updated + return None path = [] + not_active_path = [] + + # Get path from file if osp.isfile(path_file): with open(path_file, 'r', encoding='utf-8') as f: path = f.read().splitlines() + os.remove(path_file) - not_active_path = [] + # Get inactive paths from file if osp.isfile(not_active_path_file): with open(not_active_path_file, 'r', encoding='utf-8') as f: not_active_path = f.read().splitlines() + os.remove(not_active_path_file) + + # Get path from config; supercedes paths from file + if config_path is not None: + path = config_path + self.remove_conf('path') + + # Get inactive path from config; supercedes paths from file + if config_not_active_path is not None: + not_active_path = config_not_active_path + self.remove_conf('not_active_path') + + if paths_in_conf_files is not None: + self.remove_conf('paths_in_conf_files') + + # Get system path + if system_path is not None: + self.remove_conf('system_path') + + # path config has all user and system paths; only want user paths + user_paths = { + p: p not in not_active_path for p in path if p not in system_path + } - self.set_conf('path', tuple(path)) - self.set_conf('not_active_path', tuple(not_active_path)) - self.set_conf('paths_in_conf_files', False) + return user_paths From 4c5ae4cc955545a2cce1e92830d14cfbc2d0373d Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 29 Feb 2024 10:58:05 -0800 Subject: [PATCH 18/36] Revise _save_paths * Configuration keys and private attributes for user paths, system paths, prioritize, and spyder_pythonpath are set conditionally in this method and nowhere else. * sig_pythonpath_changed is conditionally emitted from this method and nowhere else. This signal now sends only the spyder_pythonpath and prioritize, not the old spyder_pythonpath. Subscribers should update accordingly. --- spyder/plugins/pythonpath/container.py | 105 ++++++++++--------------- 1 file changed, 41 insertions(+), 64 deletions(-) diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index c97ab271a3c..f03f64756c8 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -35,7 +35,7 @@ class PythonpathActions: # ----------------------------------------------------------------------------- class PythonpathContainer(PluginMainContainer): - sig_pythonpath_changed = Signal(object, object, bool) + sig_pythonpath_changed = Signal(object, bool) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -49,7 +49,7 @@ def setup(self): # Path manager dialog self.path_manager_dialog = PathManager(parent=self, sync=True) self.path_manager_dialog.sig_path_changed.connect( - self._update_python_path) + self._save_paths) self.path_manager_dialog.redirect_stdio.connect( self.sig_redirect_stdio_requested) @@ -64,10 +64,6 @@ def setup(self): def update_actions(self): pass - def on_close(self): - # Save current system path to detect changes next time Spyder starts - self.set_conf('system_path', get_system_pythonpath()) - # ---- Public API # ------------------------------------------------------------------------- def update_active_project_path(self, path): @@ -161,44 +157,51 @@ def _get_system_paths(self): return system_paths - def _save_paths(self, new_path_dict, new_prioritize): + def _save_paths(self, user_paths=None, system_paths=None, prioritize=None): """ - Save tuples for all paths and not active ones to config system and - update their associated attributes. - - `new_path_dict` is an OrderedDict that has the new paths as keys and - the state as values. The state is `True` for active and `False` for - inactive. + Save user and system path dictionaries to config and prioritize to + config. Each dictionary key is a path and the value is the active + state. + `user_paths` is user paths. `system_paths` is system paths, and `prioritize` is a boolean indicating whether paths should be - prioritized over sys.path. + prepended (True) or appended (False) to sys.path. """ - path = tuple(p for p in new_path_dict) - not_active_path = tuple( - p for p in new_path_dict if not new_path_dict[p] - ) - old_spyder_pythonpath = self.get_spyder_pythonpath() + assert isinstance(user_paths, (type(None), OrderedDict)) + assert isinstance(system_paths, (type(None), OrderedDict)) + assert isinstance(prioritize, (type(None), bool)) + + emit = False # Don't set options unless necessary - if path != self.path: - logger.debug(f"Saving path: {path}") - self.set_conf('path', path) - self.path = path - - if not_active_path != self.not_active_path: - logger.debug(f"Saving inactive paths: {not_active_path}") - self.set_conf('not_active_path', not_active_path) - self.not_active_path = not_active_path - - if new_prioritize != self.prioritize: - logger.debug(f"Saving prioritize: {new_prioritize}") - self.set_conf('prioritize', new_prioritize) - self.prioritize = new_prioritize - - new_spyder_pythonpath = self.get_spyder_pythonpath() - if new_spyder_pythonpath != old_spyder_pythonpath: - logger.debug(f"Saving Spyder pythonpath: {new_spyder_pythonpath}") - self.set_conf('spyder_pythonpath', new_spyder_pythonpath) + if user_paths is not None and user_paths != self._user_paths: + logger.debug(f"Saving user paths: {user_paths}") + self.set_conf('user_paths', dict(user_paths)) + self._user_paths = user_paths + + if system_paths is not None and system_paths != self._system_paths: + logger.debug(f"Saving system paths: {system_paths}") + self.set_conf('system_paths', dict(system_paths)) + self._system_paths = system_paths + + if prioritize is not None and prioritize != self._prioritize: + logger.debug(f"Saving prioritize: {prioritize}") + self.set_conf('prioritize', prioritize) + self._prioritize = prioritize + emit = True + + spyder_pythonpath = self.get_spyder_pythonpath() + if spyder_pythonpath != self._spyder_pythonpath: + logger.debug(f"Saving Spyder pythonpath: {spyder_pythonpath}") + self.set_conf('spyder_pythonpath', spyder_pythonpath) + self._spyder_pythonpath = spyder_pythonpath + emit = True + + # Only emit signal if spyder_pythonpath or prioritize changed + if emit: + self.sig_pythonpath_changed.emit( + self._spyder_pythonpath, self._prioritize + ) def _get_spyder_pythonpath_dict(self): """ @@ -222,32 +225,6 @@ def _get_spyder_pythonpath_dict(self): return path_dict - def _update_python_path(self, new_path_dict=None, new_prioritize=None): - """ - Update Python path on language server and kernels. - - The `new_path_dict` should not include the project path. - """ - # Load existing path plus project path - old_path = self.get_spyder_pythonpath() - old_prioritize = self.prioritize - - # Save new path - if new_path_dict is not None or new_prioritize is not None: - self._save_paths(new_path_dict, new_prioritize) - - # Load new path plus project path - new_path = self.get_spyder_pythonpath() - - # Do not notify observers unless necessary - if ( - new_path != old_path - or new_prioritize != old_prioritize - ): - self.sig_pythonpath_changed.emit( - old_path, new_path, new_prioritize - ) - def _migrate_to_config_options(self): """ Migrate paths saved in the `path` and `not_active_path` files located From 286c260c56f82ea80ec883ff1a2ab3fb0016741f Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 29 Feb 2024 11:02:18 -0800 Subject: [PATCH 19/36] Simplify get_spyder_pythonpath. spyder_pythonpath is now straightforwardly constructed from project, user, and system paths attributes. --- spyder/plugins/pythonpath/container.py | 34 +++++--------------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index f03f64756c8..8dc42f4a67b 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -111,12 +111,12 @@ def show_path_manager(self): self.path_manager_dialog.setFocus() def get_spyder_pythonpath(self): - """ - Return active Spyder PYTHONPATH plus project path as a list of paths. - """ - path_dict = self._get_spyder_pythonpath_dict() - path = [k for k, v in path_dict.items() if v] - return path + """Return active Spyder PYTHONPATH as a list of paths.""" + # Place project path first so that modules developed in a + # project are not shadowed by those present in other paths. + all_paths = self._project_paths | self._user_paths | self._system_paths + + return [p for p, v in all_paths.items() if v] # ---- Private API # ------------------------------------------------------------------------- @@ -203,28 +203,6 @@ def _save_paths(self, user_paths=None, system_paths=None, prioritize=None): self._spyder_pythonpath, self._prioritize ) - def _get_spyder_pythonpath_dict(self): - """ - Return Spyder PYTHONPATH plus project path as dictionary of paths. - - The returned ordered dictionary has the paths as keys and the state - as values. The state is `True` for active and `False` for inactive. - - Example: - OrderedDict([('/some/path, True), ('/some/other/path, False)]) - """ - path_dict = OrderedDict() - - # Make project path to be the first one so that modules developed in a - # project are not shadowed by those present in other paths. - for path in self.project_path: - path_dict[path] = True - - for path in self.path: - path_dict[path] = path not in self.not_active_path - - return path_dict - def _migrate_to_config_options(self): """ Migrate paths saved in the `path` and `not_active_path` files located From 08d05ead052e6f794ec7a99f576632599507718c Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 29 Feb 2024 11:11:02 -0800 Subject: [PATCH 20/36] Simplify update_active_project_path. sig_pythonpath_changed is emitted in _save_paths if spyder_pythonpath is changed. --- spyder/plugins/pythonpath/container.py | 30 +++++++++----------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index 8dc42f4a67b..394982706af 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -67,29 +67,19 @@ def update_actions(self): # ---- Public API # ------------------------------------------------------------------------- def update_active_project_path(self, path): - """Update active project path.""" + """Update active project path. + + _project_paths is initialized and set here, and nowhere else. + """ + self._project_paths = OrderedDict() if path is None: - logger.debug("Update Pythonpath because project was closed") - path = () + logger.debug("Update Spyder PYTHONPATH because project was closed") else: - logger.debug(f"Add to Pythonpath project's path -> {path}") - path = (path,) - - # Old path - old_path = self.get_spyder_pythonpath() - - # Change project path - self.project_path = path - self.path_manager_dialog.project_path = path - - # New path - new_path = self.get_spyder_pythonpath() - - prioritize = self.get_conf('prioritize', default=False) + logger.debug(f"Add project paths to Spyder PYTHONPATH: {path}") + path = [path] if isinstance(path, str) else path + self._project_paths.update({p: True for p in path}) - # Update path - self.set_conf('spyder_pythonpath', new_path) - self.sig_pythonpath_changed.emit(old_path, new_path, prioritize) + self._save_paths() def show_path_manager(self): """Show path manager dialog.""" From 0477dfac413cfa29208131ff27300a4e640336d2 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 29 Feb 2024 11:12:56 -0800 Subject: [PATCH 21/36] Update show_path_manager method. Note that PathManager.setup is called in PathManager.updat_paths --- spyder/plugins/pythonpath/container.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index 394982706af..2f136acd673 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -82,18 +82,24 @@ def update_active_project_path(self, path): self._save_paths() def show_path_manager(self): - """Show path manager dialog.""" + """Show path manager dialog. + + Send the most up-to-date system paths to the dialog in case they have + changed. But do not _save_paths until after the dialog exits, in order + to consolodate possible changes and avoid emitting multiple signals. + This requires that the dialog return its original paths on cancel or + close. + """ # Do not update paths or run setup if widget is already open, - # see spyder-ide/spyder#20808 + # see spyder-ide/spyder#20808. if not self.path_manager_dialog.isVisible(): - # Set main attributes saved here self.path_manager_dialog.update_paths( - self.path, self.not_active_path, get_system_pythonpath() + project_paths=self._project_paths, + user_paths=self._user_paths, + system_paths=self._get_system_paths(), + prioritize=self._prioritize ) - # Setup its contents again - self.path_manager_dialog.setup() - # Show and give it focus self.path_manager_dialog.show() self.path_manager_dialog.activateWindow() From 80a57d0dab40811e2f19e2edd68d5868563019d9 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 29 Feb 2024 11:53:55 -0800 Subject: [PATCH 22/36] Propagate changes to sig_pythonpath_changed to pythonpath plugin --- spyder/plugins/pythonpath/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/pythonpath/plugin.py b/spyder/plugins/pythonpath/plugin.py index e13dc4e76a1..950be5bf1d0 100644 --- a/spyder/plugins/pythonpath/plugin.py +++ b/spyder/plugins/pythonpath/plugin.py @@ -34,7 +34,7 @@ class PythonpathManager(SpyderPluginV2): CONF_SECTION = NAME CONF_FILE = False - sig_pythonpath_changed = Signal(object, object, bool) + sig_pythonpath_changed = Signal(object, bool) """ This signal is emitted when there is a change in the Pythonpath handled by Spyder. From 2cbcf6b6d4141e55feb10d822b874a3f04abe9d7 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 29 Feb 2024 11:55:00 -0800 Subject: [PATCH 23/36] Propagate changes to sig_pythonpath_changed to ipythonconsole plugin. Note that spyder-kernels must be updated to accommodate. --- spyder/plugins/ipythonconsole/plugin.py | 6 ++---- spyder/plugins/ipythonconsole/widgets/main_widget.py | 4 ++-- spyder/plugins/ipythonconsole/widgets/shell.py | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index b655c47a3c3..831722fecec 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -982,7 +982,7 @@ def save_working_directory(self, dirname): """ self.get_widget().save_working_directory(dirname) - def update_path(self, old_path, new_path, prioritize): + def update_path(self, new_path, prioritize): """ Update path on consoles. @@ -991,8 +991,6 @@ def update_path(self, old_path, new_path, prioritize): Parameters ---------- - old_path : list of str - Corresponds to the previous state of the PYTHONPATH. new_path : list of str Corresponds to the new state of the PYTHONPATH. prioritize : bool @@ -1002,7 +1000,7 @@ def update_path(self, old_path, new_path, prioritize): ------- None. """ - self.get_widget().update_path(old_path, new_path, prioritize) + self.get_widget().update_path(new_path, prioritize) def restart(self): """ diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index 2399a9fb699..cc2947d20cc 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -2447,13 +2447,13 @@ def on_working_directory_changed(self, dirname): if dirname and osp.isdir(dirname): self.sig_current_directory_changed.emit(dirname) - def update_path(self, old_path, new_path, prioritize): + def update_path(self, new_path, prioritize): """Update path on consoles.""" logger.debug("Update sys.path in all console clients") for client in self.clients: shell = client.shellwidget if shell is not None: - shell.update_syspath(old_path, new_path, prioritize) + shell.update_syspath(new_path, prioritize) def get_active_project_path(self): """Get the active project path.""" diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index fa650e72288..bd897763c34 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -415,7 +415,7 @@ def setup_spyder_kernel(self): prioritize = self.get_conf( "prioritize", section="pythonpath_manager" ) - self.update_syspath(paths, paths, prioritize) + self.update_syspath(paths, prioritize) run_lines = self.get_conf('startup/run_lines') if run_lines: @@ -716,14 +716,14 @@ def set_color_scheme(self, color_scheme, reset=True): "color scheme", "dark" if not dark_color else "light" ) - def update_syspath(self, path, new_path, prioritize): + def update_syspath(self, new_paths, prioritize): """Update sys.path contents in the kernel.""" # Prevent error when the kernel is not available and users open/close # projects or use the Python path manager. # Fixes spyder-ide/spyder#21563 if self.kernel_handler is not None: self.call_kernel(interrupt=True, blocking=False).update_syspath( - path, new_path, prioritize + new_paths, prioritize ) def request_syspath(self): From 1efdf882d953d93feabcb511e43063ab93d96e70 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 29 Feb 2024 11:56:26 -0800 Subject: [PATCH 24/36] Propagate changes to sig_pythonpath_changed to completions plugin. --- spyder/plugins/completion/api.py | 8 +++----- spyder/plugins/completion/plugin.py | 6 ++---- .../completion/providers/languageserver/provider.py | 12 +++++++----- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/spyder/plugins/completion/api.py b/spyder/plugins/completion/api.py index 6465850b7a2..65cdedb99e6 100644 --- a/spyder/plugins/completion/api.py +++ b/spyder/plugins/completion/api.py @@ -1059,16 +1059,14 @@ def project_path_update(self, project_path: str, update_kind: str, """ pass - @Slot(object, object, bool) - def python_path_update(self, previous_path, new_path, prioritize): + @Slot(object, bool) + def python_path_update(self, new_path, prioritize): """ Handle Python path updates on Spyder. Parameters ---------- - previous_path: Dict - Dictionary containing the previous Python path values. - new_path: Dict + new_path: list of str Dictionary containing the current Python path values. prioritize Whether to prioritize Python path values in sys.path diff --git a/spyder/plugins/completion/plugin.py b/spyder/plugins/completion/plugin.py index d721d670dfa..a77f66aa995 100644 --- a/spyder/plugins/completion/plugin.py +++ b/spyder/plugins/completion/plugin.py @@ -124,15 +124,13 @@ class CompletionPlugin(SpyderPluginV2): Name of the completion client. """ - sig_pythonpath_changed = Signal(object, object, bool) + sig_pythonpath_changed = Signal(object, bool) """ This signal is used to receive changes on the PYTHONPATH. Parameters ---------- - prev_path: dict - Previous PythonPath settings. - new_path: dict + new_path: list of str New PythonPath settings. prioritize Whether to prioritize PYTHONPATH in sys.path diff --git a/spyder/plugins/completion/providers/languageserver/provider.py b/spyder/plugins/completion/providers/languageserver/provider.py index 7480011d8a8..250053342e1 100644 --- a/spyder/plugins/completion/providers/languageserver/provider.py +++ b/spyder/plugins/completion/providers/languageserver/provider.py @@ -531,13 +531,12 @@ def shutdown(self): for language in self.clients: self.stop_completion_services_for_language(language) - @Slot(object, object, bool) - def python_path_update(self, old_path, new_path, prioritize): + @Slot(object, bool) + def python_path_update(self, new_path, prioritize): """ Update server configuration after a change in Spyder's Python path. - `old_path` corresponds to the previous state of the Python path. `new_path` corresponds to the new state of the Python path. `prioritize` determines whether to prioritize Python path in sys.path. """ @@ -584,8 +583,11 @@ def on_pyls_spyder_configuration_change(self, option, value): def on_code_snippets_enabled_disabled(self, value): self.update_lsp_configuration() - @on_conf_change(section='pythonpath_manager', option='spyder_pythonpath') - def on_pythonpath_option_update(self, value): + @on_conf_change( + section='pythonpath_manager', + option=['spyder_pythonpath', 'prioritize'] + ) + def on_pythonpath_option_update(self, option, value): # This is only useful to run some self-contained tests if running_under_pytest(): self.update_lsp_configuration(python_only=True) From 20d016f7bb485ff4cd3662084e8c41ec907aeebe Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:18:04 -0800 Subject: [PATCH 25/36] Update main window test --- spyder/app/tests/test_mainwindow.py | 6 ++-- .../widgets/tests/test_pathmanager.py | 31 +++++++++++-------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index cca085d4f94..767108fd6aa 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -11,6 +11,7 @@ """ # Standard library imports +from collections import OrderedDict import gc import os import os.path as osp @@ -6408,9 +6409,10 @@ def test_PYTHONPATH_in_consoles(main_window, qtbot, tmp_path, # users user_dir = tmp_path / 'user_dir' user_dir.mkdir() + user_paths = OrderedDict({str(user_dir): True}) if os.name != "nt": - assert ppm.get_container().path == () - ppm.get_container().path = (str(user_dir),) + ppm.get_container().path + assert ppm.get_container()._spyder_pythonpath == [] + ppm.get_container()._save_paths(user_paths=user_paths) # Open Pythonpath dialog to detect sys_dir ppm.show_path_manager() diff --git a/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py b/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py index 12070be0e95..d6674fa2f0a 100644 --- a/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py @@ -8,6 +8,7 @@ Tests for pathmanager.py """ # Standard library imports +from collections import OrderedDict import sys import os @@ -25,20 +26,24 @@ @pytest.fixture def pathmanager(qtbot, request): """Set up PathManager.""" - path, project_path, not_active_path = request.param - widget = pathmanager_mod.PathManager( - None, - path=tuple(path), - project_path=tuple(project_path), - not_active_path=tuple(not_active_path)) + user_paths, project_paths, system_paths = request.param + + widget = pathmanager_mod.PathManager(None) + widget.update_paths( + user_paths=OrderedDict({p: True for p in user_paths}), + project_paths=OrderedDict({p: True for p in project_paths}), + system_paths=OrderedDict({p: True for p in system_paths}) + ) widget.show() qtbot.addWidget(widget) return widget -@pytest.mark.parametrize('pathmanager', - [(sys.path[:-10], sys.path[-10:], ())], - indirect=True) +@pytest.mark.parametrize( + 'pathmanager', + [(sys.path[:-10], sys.path[-10:], ())], + indirect=True +) def test_pathmanager(pathmanager, qtbot): """Run PathManager test""" pathmanager.show() @@ -207,7 +212,7 @@ def test_add_repeated_item(qtbot, pathmanager, tmpdir): pathmanager.add_path(dir2) pathmanager.add_path(dir3) pathmanager.set_row_check_state(2, Qt.Unchecked) - assert not all(pathmanager.get_path_dict().values()) + assert not all(pathmanager.get_user_paths().values()) def interact_message_box(): messagebox = pathmanager.findChild(QMessageBox) @@ -222,12 +227,12 @@ def interact_message_box(): timer.timeout.connect(interact_message_box) timer.start(500) pathmanager.add_path(dir2) - print(pathmanager.get_path_dict()) + print(pathmanager.get_user_paths()) # Back to main thread assert pathmanager.count() == 4 - assert list(pathmanager.get_path_dict().keys())[0] == dir2 - assert all(pathmanager.get_path_dict().values()) + assert list(pathmanager.get_user_paths().keys())[0] == dir2 + assert all(pathmanager.get_user_paths().values()) @pytest.mark.parametrize('pathmanager', From ece51ad51b81c04614d7faa3f0f026093fcf96e9 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Sat, 2 Mar 2024 20:35:12 -0800 Subject: [PATCH 26/36] Update export_pythonpath --- .../plugins/pythonpath/widgets/pathmanager.py | 55 ++++++++++++++----- .../widgets/tests/test_pathmanager.py | 3 +- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index 0b88c8c51bb..ae931139ba1 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -26,6 +26,7 @@ from spyder.api.widgets.mixins import SpyderWidgetMixin from spyder.config.base import _ from spyder.plugins.pythonpath.utils import check_path +from spyder.utils.environ import get_user_env, set_user_env from spyder.utils.misc import getcwd_or_home from spyder.utils.stylesheet import ( AppStyle, @@ -51,7 +52,6 @@ class PathManager(QDialog, SpyderWidgetMixin): redirect_stdio = Signal(bool) sig_path_changed = Signal(object, object, bool) - sig_export_pythonpath = Signal(object, object, bool) # This is required for our tests CONF_SECTION = 'pythonpath_manager' @@ -333,6 +333,15 @@ def export_pythonpath(self): """ Export to PYTHONPATH environment variable Only apply to: current user. + + If the user chooses to clear the contents of the system PYTHONPATH, + then the active user paths are prepended to active system paths and + the resulting list is saved to the system PYTHONPATH. Inactive system + paths are discarded. If the user chooses not to clear the contents of + the system PYTHONPATH, then the new system PYTHONPATH comprises the + inactive system paths + active user paths + active system paths, and + inactive system paths remain inactive. With either choice, inactive + user paths are retained in the user paths and remain inactive. """ answer = QMessageBox.question( self, @@ -350,9 +359,24 @@ def export_pythonpath(self): if answer == QMessageBox.Cancel: return - self.sig_export_pythonpath( - self.get_user_paths(), self.get_system_paths(), - answer == QMessageBox.Yes + user_paths = self.get_user_paths() + active_user_paths = OrderedDict({p: v for p, v in user_paths.items() if v}) + new_user_paths = OrderedDict({p: v for p, v in user_paths.items() if not v}) + + system_paths = self.get_system_paths() + active_system_paths = OrderedDict({p: v for p, v in system_paths.items() if v}) + inactive_system_paths = OrderedDict({p: v for p, v in system_paths.items() if not v}) + + new_system_paths = active_user_paths | active_system_paths + if answer == QMessageBox.No: + new_system_paths = inactive_system_paths | new_system_paths + + env = get_user_env() + env['PYTHONPATH'] = list(new_system_paths.keys()) + set_user_env(env, parent=self) + + self.update_paths( + user_paths=new_user_paths, system_paths=new_system_paths ) def get_user_paths(self): @@ -389,10 +413,10 @@ def get_system_paths(self): def update_paths( self, - project_paths=OrderedDict(), - user_paths=OrderedDict(), - system_paths=OrderedDict(), - prioritize=False + project_paths=None, + user_paths=None, + system_paths=None, + prioritize=None ): """Update path attributes. @@ -401,10 +425,14 @@ def update_paths( used to compare with what is shown in the listwidget in order to detect changes. """ - self.project_paths = project_paths - self.user_paths = user_paths - self.system_paths = system_paths - self.prioritize = prioritize + if project_paths is not None: + self.project_paths = project_paths + if user_paths is not None: + self.user_paths = user_paths + if system_paths is not None: + self.system_paths = system_paths + if prioritize is not None: + self.prioritize = prioritize self.setup() @@ -633,7 +661,8 @@ def test(): dlg.update_paths( user_paths={p: True for p in sys.path[1:-2]}, project_paths={p: True for p in sys.path[:1]}, - system_paths={p: True for p in sys.path[-2:]} + system_paths={p: True for p in sys.path[-2:]}, + prioritize=False ) def callback(user_paths, system_paths, prioritize): diff --git a/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py b/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py index d6674fa2f0a..b3cfdb86084 100644 --- a/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py @@ -32,7 +32,8 @@ def pathmanager(qtbot, request): widget.update_paths( user_paths=OrderedDict({p: True for p in user_paths}), project_paths=OrderedDict({p: True for p in project_paths}), - system_paths=OrderedDict({p: True for p in system_paths}) + system_paths=OrderedDict({p: True for p in system_paths}), + prioritize=False ) widget.show() qtbot.addWidget(widget) From d73eb3d5117e0b722a7a89f739927f6d39232c4f Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 7 Mar 2024 13:46:32 -0800 Subject: [PATCH 27/36] Update widget icon. Icon and tooltip are changed to reflect current state. --- spyder/plugins/pythonpath/widgets/pathmanager.py | 10 ++++++++-- spyder/utils/icon_manager.py | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index ae931139ba1..d1685f16e19 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -177,10 +177,9 @@ def _setup_right_toolbar(self): tip=_("Export to PYTHONPATH environment variable")) self.prioritize_button = self.create_toolbutton( PathManagerToolbuttons.Prioritize, - icon=self.create_icon('first_page'), option='prioritize', triggered=self.refresh, - tip=_("Place PYTHONPATH at the front of sys.path")) + ) self.prioritize_button.setCheckable(True) self.selection_widgets = [self.movetop_button, self.moveup_button, @@ -472,6 +471,13 @@ def refresh(self): and (self.editable_top_row <= row <= self.editable_bottom_row) ) + if self.prioritize_button.isChecked(): + self.prioritize_button.setIcon(self.create_icon('prepend')) + self.prioritize_button.setToolTip(_("Paths are prpended to sys.path")) + else: + self.prioritize_button.setIcon(self.create_icon('append')) + self.prioritize_button.setToolTip(_("Paths are appended to sys.path")) + self.export_button.setEnabled(self.listwidget.count() > 0) # Ok button only enabled if actual changes occur diff --git a/spyder/utils/icon_manager.py b/spyder/utils/icon_manager.py index 9289d879a48..e0a1373121c 100644 --- a/spyder/utils/icon_manager.py +++ b/spyder/utils/icon_manager.py @@ -288,6 +288,8 @@ def __init__(self): '1uparrow': [('mdi.arrow-up',), {'color': self.MAIN_FG_COLOR}], '2downarrow': [('mdi.arrow-collapse-down',), {'color': self.MAIN_FG_COLOR}], '1downarrow': [('mdi.arrow-down',), {'color': self.MAIN_FG_COLOR}], + 'prepend': [('mdi.arrow-collapse-left',), {'color': self.MAIN_FG_COLOR}], + 'append': [('mdi.arrow-collapse-right',), {'color': self.MAIN_FG_COLOR}], 'undock': [('mdi.open-in-new',), {'color': self.MAIN_FG_COLOR}], 'close_pane': [('mdi.window-close',), {'color': self.MAIN_FG_COLOR}], 'toolbar_ext_button': [('mdi.dots-horizontal',), {'color': self.MAIN_FG_COLOR}], From 378d8781d4cdc4d1f4f3f27987a07141a0fe218e Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 16 May 2024 12:31:45 -0700 Subject: [PATCH 28/36] Apply suggestions from code review Co-authored-by: Jitse Niesen Typographical errors. Improved docstring clarity --- spyder/plugins/pythonpath/container.py | 16 +++++++++++----- spyder/plugins/pythonpath/plugin.py | 12 +++--------- spyder/plugins/pythonpath/widgets/pathmanager.py | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index 2f136acd673..7163a934693 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -69,8 +69,10 @@ def update_actions(self): def update_active_project_path(self, path): """Update active project path. - _project_paths is initialized and set here, and nowhere else. + _project_paths is initialized in _load_paths, but set in this method + and nowhere else. """ + # _project_paths should be reset whenever it is updated. self._project_paths = OrderedDict() if path is None: logger.debug("Update Spyder PYTHONPATH because project was closed") @@ -86,11 +88,11 @@ def show_path_manager(self): Send the most up-to-date system paths to the dialog in case they have changed. But do not _save_paths until after the dialog exits, in order - to consolodate possible changes and avoid emitting multiple signals. + to consolidate possible changes and avoid emitting multiple signals. This requires that the dialog return its original paths on cancel or close. """ - # Do not update paths or run setup if widget is already open, + # Do not update paths if widget is already open, # see spyder-ide/spyder#20808. if not self.path_manager_dialog.isVisible(): self.path_manager_dialog.update_paths( @@ -120,8 +122,9 @@ def _load_paths(self): """Load Python paths. The attributes _project_paths, _user_paths, _system_paths, _prioritize, - and _spyder_pythonpath, are initialize here and should be updated only - in _save_paths. They are only used to detect changes. + and _spyder_pythonpath, are initialized here. All but _project_paths + should be updated only in _save_paths. They are only used to detect + changes. """ self._project_paths = OrderedDict() self._user_paths = OrderedDict() @@ -162,6 +165,9 @@ def _save_paths(self, user_paths=None, system_paths=None, prioritize=None): `user_paths` is user paths. `system_paths` is system paths, and `prioritize` is a boolean indicating whether paths should be prepended (True) or appended (False) to sys.path. + + sig_pythonpath_changed is emitted from this method, and nowhere else, + on condition that _spyder_pythonpath changed. """ assert isinstance(user_paths, (type(None), OrderedDict)) assert isinstance(system_paths, (type(None), OrderedDict)) diff --git a/spyder/plugins/pythonpath/plugin.py b/spyder/plugins/pythonpath/plugin.py index 950be5bf1d0..0926fcacd46 100644 --- a/spyder/plugins/pythonpath/plugin.py +++ b/spyder/plugins/pythonpath/plugin.py @@ -41,17 +41,11 @@ class PythonpathManager(SpyderPluginV2): Parameters ---------- - old_path_dict: OrderedDict - Previous Pythonpath ordered dictionary. Its keys correspond to the - project, user and system paths declared by users or detected by Spyder, - and its values are their state (i.e. True for enabled and False for - disabled). - - new_path_dict: OrderedDict - New Pythonpath dictionary. + new_path_list: list of str + New list of PYTHONPATH paths. prioritize - Whether to prioritize Pythonpath in sys.path + Whether to prioritize PYTHONPATH in sys.path See Also -------- diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index d1685f16e19..31a8c2a96fd 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -473,7 +473,7 @@ def refresh(self): if self.prioritize_button.isChecked(): self.prioritize_button.setIcon(self.create_icon('prepend')) - self.prioritize_button.setToolTip(_("Paths are prpended to sys.path")) + self.prioritize_button.setToolTip(_("Paths are prepended to sys.path")) else: self.prioritize_button.setIcon(self.create_icon('append')) self.prioritize_button.setToolTip(_("Paths are appended to sys.path")) From b6ea0b33927e26732acdff3f46053e0716d02108 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Sat, 18 May 2024 15:47:24 -0700 Subject: [PATCH 29/36] Apply suggestions from python-lsp-server code review --- spyder/config/lsp.py | 2 +- .../plugins/completion/providers/languageserver/provider.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spyder/config/lsp.py b/spyder/config/lsp.py index 4194654b21b..4410e90b99b 100644 --- a/spyder/config/lsp.py +++ b/spyder/config/lsp.py @@ -79,7 +79,7 @@ 'environment': None, 'extra_paths': [], 'env_vars': None, - 'prioritize': False, + 'prioritize_extra_paths': False, # Until we have a graphical way for users to add modules to # this option 'auto_import_modules': [ diff --git a/spyder/plugins/completion/providers/languageserver/provider.py b/spyder/plugins/completion/providers/languageserver/provider.py index 250053342e1..936ec663d1d 100644 --- a/spyder/plugins/completion/providers/languageserver/provider.py +++ b/spyder/plugins/completion/providers/languageserver/provider.py @@ -809,9 +809,9 @@ def generate_python_config(self): 'extra_paths': self.get_conf('spyder_pythonpath', section='pythonpath_manager', default=[]), - 'prioritize': self.get_conf('prioritize', - section='pythonpath_manager', - default=False), + 'prioritize_extra_paths': self.get_conf( + 'prioritize', section='pythonpath_manager', default=False + ), 'env_vars': env_vars, } jedi_completion = { From fb6a7f2bb3d81a3fbee67cb595b7f8027e052607 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Sun, 26 May 2024 00:38:58 -0700 Subject: [PATCH 30/36] Python 3.8 does not support | operator on OrderedDict. The desired affect is project paths | user paths | system paths, where the paths are in that order and are overwritten in that order. System paths cannot overwrite user paths, which cannot overwrite project paths, i.e we cannot just do project_paths.update(user_paths) etc. --- spyder/plugins/pythonpath/container.py | 9 ++++++--- spyder/plugins/pythonpath/widgets/pathmanager.py | 9 +++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index 7163a934693..965ae03d97d 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -110,9 +110,12 @@ def show_path_manager(self): def get_spyder_pythonpath(self): """Return active Spyder PYTHONPATH as a list of paths.""" - # Place project path first so that modules developed in a - # project are not shadowed by those present in other paths. - all_paths = self._project_paths | self._user_paths | self._system_paths + # Desired behavior is project_paths | user_paths | system_paths, but + # Python 3.8 does not support | operator for OrderedDict. + all_paths = OrderedDict(reversed(self._system_paths.items())) + all_paths.update(reversed(self._user_paths.items())) + all_paths.update(reversed(self._project_paths.items())) + all_paths = OrderedDict(reversed(all_paths.items())) return [p for p, v in all_paths.items() if v] diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index 31a8c2a96fd..e8f51cb645f 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -366,9 +366,14 @@ def export_pythonpath(self): active_system_paths = OrderedDict({p: v for p, v in system_paths.items() if v}) inactive_system_paths = OrderedDict({p: v for p, v in system_paths.items() if not v}) - new_system_paths = active_user_paths | active_system_paths + # Desired behavior is active_user | active_system, but Python 3.8 does + # not support | operator for OrderedDict. + new_system_paths = OrderedDict(reversed(active_system_paths.items())) + new_system_paths.update(reversed(active_user_paths.items())) if answer == QMessageBox.No: - new_system_paths = inactive_system_paths | new_system_paths + # Desired behavior is inactive_system | active_user | active_system + new_system_paths.update(reversed(inactive_system_paths.items())) + new_system_paths = OrderedDict(reversed(new_system_paths.items())) env = get_user_env() env['PYTHONPATH'] = list(new_system_paths.keys()) From 16df844f63ac2d73f5cfd6905498988dcaab4d4e Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Fri, 11 Oct 2024 15:08:33 -0700 Subject: [PATCH 31/36] Do not emit sig_path_changed on closeEvent or reject. --- spyder/plugins/pythonpath/widgets/pathmanager.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index e8f51cb645f..41d0512d548 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -647,17 +647,11 @@ def accept(self): super().accept() def reject(self): - # Send back original paths (system_paths may be updated) - self.sig_path_changed.emit( - self.user_paths, self.system_paths, self.prioritize - ) + # ??? Do we need this? super().reject() def closeEvent(self, event): - # Send back original paths (system_paths may be updated) - self.sig_path_changed.emit( - self.user_paths, self.system_paths, self.prioritize - ) + # ??? Do we need this? super().closeEvent(event) From 82c0f3e588db5257ddee557a0a318f4ea5acfb54 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Sat, 12 Oct 2024 07:05:03 -0700 Subject: [PATCH 32/36] Fix issue where user_widget was not defined if self.user_header was defined but not visible. This occurs when all paths are removed and then a new path is added, causing an error. Now the header is always destroyed and recreated, avoiding situation where the header is not visible. --- spyder/plugins/pythonpath/widgets/pathmanager.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index 41d0512d548..98f0f619b5d 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -533,8 +533,6 @@ def add_path(self, directory=None): ) self.headers.append(self.user_header) - # Add header if not visible - if self.listwidget.row(self.user_header) < 0: if self.editable_top_row > 0: header_row = self.editable_top_row - 1 else: @@ -590,7 +588,10 @@ def remove_path(self, force=False): # Remove user header if there are no more user paths if len(self.get_user_paths()) == 0: self.listwidget.takeItem( - self.listwidget.row(self.user_header)) + self.listwidget.row(self.user_header) + ) + self.headers.remove(self.user_header) + self.user_header = None # Refresh widget self.refresh() From 263fe16ced732d8ead00efb46bd332b76db61971 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Sat, 12 Oct 2024 07:09:03 -0700 Subject: [PATCH 33/36] Only update system paths on Spyder startup, not every time the pythonpath manager widget is invoked. If the system paths have changed since last widget invocation, then the user may not be aware and there is no indication in the widget that there has been a change. Furthermore, canceling the widget may still result in a change to the pythonpath, which would be inconsistent with the cancel action. --- spyder/plugins/pythonpath/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/pythonpath/container.py b/spyder/plugins/pythonpath/container.py index 965ae03d97d..6badd44eea9 100644 --- a/spyder/plugins/pythonpath/container.py +++ b/spyder/plugins/pythonpath/container.py @@ -98,7 +98,7 @@ def show_path_manager(self): self.path_manager_dialog.update_paths( project_paths=self._project_paths, user_paths=self._user_paths, - system_paths=self._get_system_paths(), + system_paths=self._system_paths, prioritize=self._prioritize ) From ba1ec769e8d192a69fa5839974576667f308692b Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Sat, 12 Oct 2024 17:42:29 -0700 Subject: [PATCH 34/36] Add import path functionality. Rather than automatically updating the system paths, provide mechanism for user to do so. --- .../plugins/pythonpath/widgets/pathmanager.py | 68 +++++++++++++++---- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/spyder/plugins/pythonpath/widgets/pathmanager.py b/spyder/plugins/pythonpath/widgets/pathmanager.py index 98f0f619b5d..e9344b1067d 100644 --- a/spyder/plugins/pythonpath/widgets/pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/pathmanager.py @@ -25,7 +25,7 @@ from spyder.api.widgets.dialogs import SpyderDialogButtonBox from spyder.api.widgets.mixins import SpyderWidgetMixin from spyder.config.base import _ -from spyder.plugins.pythonpath.utils import check_path +from spyder.plugins.pythonpath.utils import check_path, get_system_pythonpath from spyder.utils.environ import get_user_env, set_user_env from spyder.utils.misc import getcwd_or_home from spyder.utils.stylesheet import ( @@ -43,6 +43,7 @@ class PathManagerToolbuttons: MoveToBottom = 'move_to_bottom' AddPath = 'add_path' RemovePath = 'remove_path' + ImportPaths = 'import_paths' ExportPaths = 'export_paths' Prioritize = 'prioritize' @@ -170,6 +171,11 @@ def _setup_right_toolbar(self): tip=_('Remove path'), icon=self.create_icon('editclear'), triggered=lambda x: self.remove_path()) + self.import_button = self.create_toolbutton( + PathManagerToolbuttons.ImportPaths, + tip=_('Import from PYTHONPATH environment variable'), + icon=self.create_icon('fileimport'), + triggered=lambda x: self.import_paths()) self.export_button = self.create_toolbutton( PathManagerToolbuttons.ExportPaths, icon=self.create_icon('fileexport'), @@ -186,7 +192,7 @@ def _setup_right_toolbar(self): self.movedown_button, self.movebottom_button] return ( [self.add_button, self.remove_button] + - self.selection_widgets + [self.export_button] + + self.selection_widgets + [self.import_button, self.export_button] + [self.prioritize_button] ) @@ -248,6 +254,23 @@ def _stylesheet(self): return css.toString() + def _setup_system_paths(self, paths): + """Add system paths, creating system header if necessary""" + if not paths: + return + + if not self.system_header: + self.system_header, system_widget = ( + self._create_header(_("System PYTHONPATH")) + ) + self.headers.append(self.system_header) + self.listwidget.addItem(self.system_header) + self.listwidget.setItemWidget(self.system_header, system_widget) + + for path, active in paths.items(): + item = self._create_item(path, active) + self.listwidget.addItem(item) + # ---- Public methods # ------------------------------------------------------------------------- @property @@ -308,18 +331,8 @@ def setup(self): item = self._create_item(path, active) self.listwidget.addItem(item) - # System path - if self.system_paths: - self.system_header, system_widget = ( - self._create_header(_("System PYTHONPATH")) - ) - self.headers.append(self.system_header) - self.listwidget.addItem(self.system_header) - self.listwidget.setItemWidget(self.system_header, system_widget) - - for path, active in self.system_paths.items(): - item = self._create_item(path, active) - self.listwidget.addItem(item) + # System paths + self._setup_system_paths(self.system_paths) # Prioritize self.prioritize_button.setChecked(self.prioritize) @@ -596,6 +609,33 @@ def remove_path(self, force=False): # Refresh widget self.refresh() + @Slot() + def import_paths(self): + """Import PYTHONPATH from environment.""" + current_system_paths = self.get_system_paths() + system_paths = get_system_pythonpath() + + # Inherit active state from current system paths + system_paths = OrderedDict( + {p: current_system_paths.get(p, True) for p in system_paths} + ) + + # Remove system paths + if self.system_header: + header_row = self.listwidget.row(self.system_header) + for row in range(self.listwidget.count(), header_row, -1): + self.listwidget.takeItem(row) + + # Also remove system header + if not system_paths: + self.listwidget.takeItem(header_row) + self.headers.remove(self.system_header) + self.system_header = None + + self._setup_system_paths(system_paths) + + self.refresh() + def move_to(self, absolute=None, relative=None): """Move items of list widget.""" index = self.listwidget.currentRow() From a7688870ecf19b0d90523c265b4dee2ee49dfe6e Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Sat, 12 Oct 2024 17:49:42 -0700 Subject: [PATCH 35/36] git subrepo pull --branch=ppm-syspath --remote=https://github.com/mrclary/spyder-kernels.git --update --force external-deps/spyder-kernels subrepo: subdir: "external-deps/spyder-kernels" merged: "9babbc72f" upstream: origin: "https://github.com/mrclary/spyder-kernels.git" branch: "ppm-syspath" commit: "9babbc72f" git-subrepo: version: "0.4.9" origin: "???" commit: "???" --- external-deps/spyder-kernels/.gitrepo | 8 +-- .../spyder_kernels/console/kernel.py | 53 ++++++++++----- .../spyder_kernels/console/start.py | 29 ++++---- .../console/tests/test_console_kernel.py | 67 +++++++++++++------ .../customize/spydercustomize.py | 14 ---- 5 files changed, 103 insertions(+), 68 deletions(-) diff --git a/external-deps/spyder-kernels/.gitrepo b/external-deps/spyder-kernels/.gitrepo index 67bef785e7c..4548453d49a 100644 --- a/external-deps/spyder-kernels/.gitrepo +++ b/external-deps/spyder-kernels/.gitrepo @@ -4,9 +4,9 @@ ; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme ; [subrepo] - remote = https://github.com/spyder-ide/spyder-kernels.git - branch = master - commit = f9fc2f62179a98cf582b44306a9f9225506ec532 - parent = 3d037175c3ae4bb20f1366a6e5446ae064ed6306 + remote = https://github.com/mrclary/spyder-kernels.git + branch = ppm-syspath + commit = 9babbc72ff531504c93a52a41e963f203ac6716a + parent = 855143290575418e0f3ad5d9100e8e3c482389a8 method = merge cmdver = 0.4.9 diff --git a/external-deps/spyder-kernels/spyder_kernels/console/kernel.py b/external-deps/spyder-kernels/spyder_kernels/console/kernel.py index ad16c08475a..07c3927d66a 100644 --- a/external-deps/spyder-kernels/spyder_kernels/console/kernel.py +++ b/external-deps/spyder-kernels/spyder_kernels/console/kernel.py @@ -92,6 +92,11 @@ def __init__(self, *args, **kwargs): # To save the python env info self.pythonenv_info: PythonEnvInfo = {} + # Store original sys.path. Kernels are started with PYTHONPATH + # removed from environment variables, so this will never have + # user paths and should be clean. + self._sys_path = sys.path.copy() + @property def kernel_info(self): # Used for checking correct version by spyder @@ -744,27 +749,43 @@ def set_special_kernel(self, special): raise NotImplementedError(f"{special}") @comm_handler - def update_syspath(self, path_dict, new_path_dict): + def update_syspath(self, new_path, prioritize): """ Update the PYTHONPATH of the kernel. - `path_dict` and `new_path_dict` have the paths as keys and the state - as values. The state is `True` for active and `False` for inactive. - - `path_dict` corresponds to the previous state of the PYTHONPATH. - `new_path_dict` corresponds to the new state of the PYTHONPATH. + Parameters + ---------- + new_path: list of str + List of PYTHONPATH paths. + prioritize: bool + Whether to place PYTHONPATH paths at the front (True) or + back (False) of sys.path. + + + Notes + ----- + A copy of sys.path is made at instantiation, which should be clean, + so we can just prepend/append to the copy without having to explicitly + remove old user paths. PYTHONPATH can just be overwritten. """ - # Remove old paths - for path in path_dict: - while path in sys.path: - sys.path.remove(path) - - # Add new paths - pypath = [path for path, active in new_path_dict.items() if active] - if pypath: - sys.path.extend(pypath) - os.environ.update({'PYTHONPATH': os.pathsep.join(pypath)}) + if new_path is not None: + # Overwrite PYTHONPATH + os.environ.update({'PYTHONPATH': os.pathsep.join(new_path)}) + + # Add new paths to original sys.path + if prioritize: + sys.path[:] = new_path + self._sys_path + + # Ensure current directory is always first to imitate Python + # standard behavior + if '' in sys.path: + sys.path.remove('') + sys.path.insert(0, '') + else: + sys.path[:] = self._sys_path + new_path else: + # Restore original sys.path and remove PYTHONPATH + sys.path[:] = self._sys_path os.environ.pop('PYTHONPATH', None) @comm_handler diff --git a/external-deps/spyder-kernels/spyder_kernels/console/start.py b/external-deps/spyder-kernels/spyder_kernels/console/start.py index 600b656c02e..ce1440e2b76 100644 --- a/external-deps/spyder-kernels/spyder_kernels/console/start.py +++ b/external-deps/spyder-kernels/spyder_kernels/console/start.py @@ -16,6 +16,14 @@ import sys import site +# Remove current directory from sys.path to prevent kernel crashes when people +# name Python files or modules with the same name as standard library modules. +# See spyder-ide/spyder#8007 +# Inject it back into sys.path after all imports in this module but +# before the kernel is initialized +while '' in sys.path: + sys.path.remove('') + # Third-party imports from traitlets import DottedObjectName @@ -29,13 +37,6 @@ def import_spydercustomize(): parent = osp.dirname(here) customize_dir = osp.join(parent, 'customize') - # Remove current directory from sys.path to prevent kernel - # crashes when people name Python files or modules with - # the same name as standard library modules. - # See spyder-ide/spyder#8007 - while '' in sys.path: - sys.path.remove('') - # Import our customizations site.addsitedir(customize_dir) import spydercustomize # noqa @@ -46,6 +47,7 @@ def import_spydercustomize(): except ValueError: pass + def kernel_config(): """Create a config object with IPython kernel options.""" from IPython.core.application import get_ipython_dir @@ -153,13 +155,6 @@ def main(): # Import our customizations into the kernel import_spydercustomize() - # Remove current directory from sys.path to prevent kernel - # crashes when people name Python files or modules with - # the same name as standard library modules. - # See spyder-ide/spyder#8007 - while '' in sys.path: - sys.path.remove('') - # Main imports from ipykernel.kernelapp import IPKernelApp from spyder_kernels.console.kernel import SpyderKernel @@ -192,6 +187,12 @@ def close(self): kernel.config = kernel_config() except: pass + + # Re-add current working directory path into sys.path after all of the + # import statements, but before initializing the kernel. + if '' not in sys.path: + sys.path.insert(0, '') + kernel.initialize() # Set our own magics diff --git a/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py b/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py index f6f58c39b61..ba748dcdb4e 100644 --- a/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py +++ b/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py @@ -77,13 +77,15 @@ def setup_kernel(cmd): ) # wait for connection file to exist, timeout after 5s tic = time.time() - while not os.path.exists(connection_file) \ - and kernel.poll() is None \ - and time.time() < tic + SETUP_TIMEOUT: + while ( + not os.path.exists(connection_file) + and kernel.poll() is None + and time.time() < tic + SETUP_TIMEOUT + ): time.sleep(0.1) if kernel.poll() is not None: - o,e = kernel.communicate() + o, e = kernel.communicate() raise IOError("Kernel failed to start:\n%s" % e) if not os.path.exists(connection_file): @@ -229,7 +231,7 @@ def kernel(request): 'True_' ], 'minmax': False, - 'filter_on':True + 'filter_on': True } # Teardown @@ -468,8 +470,11 @@ def test_is_defined(kernel): def test_get_doc(kernel): """Test to get object documentation dictionary.""" objtxt = 'help' - assert ("Define the builtin 'help'" in kernel.get_doc(objtxt)['docstring'] or - "Define the built-in 'help'" in kernel.get_doc(objtxt)['docstring']) + assert ( + "Define the builtin 'help'" in kernel.get_doc(objtxt)['docstring'] + or "Define the built-in 'help'" in kernel.get_doc(objtxt)['docstring'] + ) + def test_get_source(kernel): """Test to get object source.""" @@ -507,7 +512,7 @@ def test_cwd_in_sys_path(): with setup_kernel(cmd) as client: reply = client.execute_interactive( "import sys; sys_path = sys.path", - user_expressions={'output':'sys_path'}, timeout=TIMEOUT) + user_expressions={'output': 'sys_path'}, timeout=TIMEOUT) # Transform value obtained through user_expressions user_expressions = reply['content']['user_expressions'] @@ -518,6 +523,21 @@ def test_cwd_in_sys_path(): assert '' in value +def test_prioritize(kernel): + """Test that user path priority is honored in sys.path.""" + syspath = kernel.get_syspath() + append_path = ['/test/append/path'] + prepend_path = ['/test/prepend/path'] + + kernel.update_syspath(append_path, prioritize=False) + new_syspath = kernel.get_syspath() + assert new_syspath == syspath + append_path + + kernel.update_syspath(prepend_path, prioritize=True) + new_syspath = kernel.get_syspath() + assert new_syspath == prepend_path + syspath + + @flaky(max_runs=3) def test_multiprocessing(tmpdir): """ @@ -701,8 +721,10 @@ def test_runfile(tmpdir): assert content['found'] # Run code file `u` with current namespace - msg = client.execute_interactive("%runfile {} --current-namespace" - .format(repr(str(u))), timeout=TIMEOUT) + msg = client.execute_interactive( + "%runfile {} --current-namespace".format(repr(str(u))), + timeout=TIMEOUT + ) content = msg['content'] # Verify that the variable `result3` is defined @@ -727,7 +749,9 @@ def test_runfile(tmpdir): sys.platform == 'darwin' and sys.version_info[:2] == (3, 8), reason="Fails on Mac with Python 3.8") def test_np_threshold(kernel): - """Test that setting Numpy threshold doesn't make the Variable Explorer slow.""" + """ + Test that setting Numpy threshold doesn't make the Variable Explorer slow. + """ cmd = "from spyder_kernels.console import start; start.main()" @@ -786,7 +810,9 @@ def test_np_threshold(kernel): while "data" not in msg['content']: msg = client.get_shell_msg(timeout=TIMEOUT) content = msg['content']['data']['text/plain'] - assert "{'float_kind': Date: Tue, 15 Oct 2024 00:52:53 -0700 Subject: [PATCH 36/36] Update unit tests * Test system PYTHONPATH import in test_pathmanager instead of test_mainwindow * Move restore_user_env fixture from app/tests/conftest.py to utils/tests/conftest.py * Ensure that the user environment script runs on posix while testing --- spyder/app/tests/conftest.py | 21 +--- spyder/app/tests/test_mainwindow.py | 23 +---- .../widgets/tests/test_pathmanager.py | 95 +++++++++++-------- spyder/utils/environ.py | 6 +- spyder/utils/tests/conftest.py | 31 ++++++ spyder/utils/tests/test_environ.py | 14 +-- 6 files changed, 103 insertions(+), 87 deletions(-) create mode 100644 spyder/utils/tests/conftest.py diff --git a/spyder/app/tests/conftest.py b/spyder/app/tests/conftest.py index f67a426208f..920662c141e 100755 --- a/spyder/app/tests/conftest.py +++ b/spyder/app/tests/conftest.py @@ -27,15 +27,13 @@ from spyder.api.plugin_registration.registry import PLUGIN_REGISTRY from spyder.api.plugins import Plugins from spyder.app import start -from spyder.config.base import get_home_dir, running_in_ci +from spyder.config.base import get_home_dir from spyder.config.manager import CONF from spyder.plugins.ipythonconsole.utils.kernelspec import SpyderKernelSpec from spyder.plugins.projects.api import EmptyProject from spyder.plugins.run.api import RunActions, StoredRunConfigurationExecutor from spyder.plugins.toolbar.api import ApplicationToolbars from spyder.utils import encoding -from spyder.utils.environ import (get_user_env, set_user_env, - amend_user_shell_init) # ============================================================================= # ---- Constants @@ -624,20 +622,3 @@ def threads_condition(): CONF.reset_manager() PLUGIN_REGISTRY.reset() raise - - -@pytest.fixture -def restore_user_env(): - """Set user environment variables and restore upon test exit""" - if not running_in_ci(): - pytest.skip("Skipped because not in CI.") - - if os.name == "nt": - orig_env = get_user_env() - - yield - - if os.name == "nt": - set_user_env(orig_env) - else: - amend_user_shell_init(restore=True) diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index 767108fd6aa..dbbbb892680 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -85,7 +85,6 @@ RunExecutionParameters, ExtendedRunExecutionParameters, WorkingDirOpts, WorkingDirSource, RunContext) from spyder.py3compat import qbytearray_to_str, to_text_string -from spyder.utils.environ import set_user_env from spyder.utils.conda import get_list_conda_envs from spyder.utils.misc import remove_backslashes, rename_file from spyder.utils.clipboard_helper import CLIPBOARD_HELPER @@ -6385,8 +6384,7 @@ def test_switch_to_plugin(main_window, qtbot): @flaky(max_runs=5) -def test_PYTHONPATH_in_consoles(main_window, qtbot, tmp_path, - restore_user_env): +def test_PYTHONPATH_in_consoles(main_window, qtbot, tmp_path): """ Test that PYTHONPATH is passed to IPython consoles under different scenarios. @@ -6400,11 +6398,6 @@ def test_PYTHONPATH_in_consoles(main_window, qtbot, tmp_path, # Main variables ppm = main_window.get_plugin(Plugins.PythonpathManager) - # Add a directory to PYTHONPATH - sys_dir = tmp_path / 'sys_dir' - sys_dir.mkdir() - set_user_env({"PYTHONPATH": str(sys_dir)}) - # Add a directory to the current list of paths to simulate a path added by # users user_dir = tmp_path / 'user_dir' @@ -6412,25 +6405,17 @@ def test_PYTHONPATH_in_consoles(main_window, qtbot, tmp_path, user_paths = OrderedDict({str(user_dir): True}) if os.name != "nt": assert ppm.get_container()._spyder_pythonpath == [] - ppm.get_container()._save_paths(user_paths=user_paths) - - # Open Pythonpath dialog to detect sys_dir - ppm.show_path_manager() - qtbot.wait(500) - - # Check we're showing two headers - assert len(ppm.path_manager_dialog.headers) == 2 # Check the PPM emits the right signal after closing the dialog with qtbot.waitSignal(ppm.sig_pythonpath_changed, timeout=1000): - ppm.path_manager_dialog.close() + ppm.get_container()._save_paths(user_paths=user_paths) # Check directories were added to sys.path in the right order with qtbot.waitSignal(shell.executed, timeout=2000): shell.execute("import sys; sys_path = sys.path") sys_path = shell.get_value("sys_path") - assert sys_path[-2:] == [str(user_dir), str(sys_dir)] + assert sys_path[-1:] == [str(user_dir)] # Create new console ipyconsole.create_new_client() @@ -6443,7 +6428,7 @@ def test_PYTHONPATH_in_consoles(main_window, qtbot, tmp_path, shell1.execute("import sys; sys_path = sys.path") sys_path = shell1.get_value("sys_path") - assert sys_path[-2:] == [str(user_dir), str(sys_dir)] + assert sys_path[-1:] == [str(user_dir)] # Check that disabling a path from the PPM removes it from sys.path in all # consoles diff --git a/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py b/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py index b3cfdb86084..c8010e091a2 100644 --- a/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py +++ b/spyder/plugins/pythonpath/widgets/tests/test_pathmanager.py @@ -18,7 +18,9 @@ from qtpy.QtWidgets import QMessageBox, QPushButton # Local imports +from spyder.utils.environ import get_user_env, set_user_env from spyder.utils.programs import is_module_installed +from spyder.utils.tests.conftest import restore_user_env from spyder.plugins.pythonpath.utils import check_path from spyder.plugins.pythonpath.widgets import pathmanager as pathmanager_mod @@ -41,19 +43,42 @@ def pathmanager(qtbot, request): @pytest.mark.parametrize( - 'pathmanager', - [(sys.path[:-10], sys.path[-10:], ())], - indirect=True + 'pathmanager', [(sys.path[:-10], sys.path[-10:], ())], indirect=True ) -def test_pathmanager(pathmanager, qtbot): +def test_pathmanager(qtbot, pathmanager): """Run PathManager test""" pathmanager.show() assert pathmanager -@pytest.mark.parametrize('pathmanager', - [(sys.path[:-10], sys.path[-10:], ())], - indirect=True) +@pytest.mark.parametrize('pathmanager', [((), (), ())], indirect=True) +def test_import_PYTHONPATH(qtbot, pathmanager, tmp_path, restore_user_env): + """ + Test that PYTHONPATH is imported. + """ + + # Add a directory to PYTHONPATH environment variable + sys_dir = tmp_path / 'sys_dir' + sys_dir.mkdir() + set_user_env({"PYTHONPATH": str(sys_dir)}) + + # Open Pythonpath dialog + pathmanager.show() + qtbot.wait(500) + + assert len(pathmanager.headers) == 0 + assert pathmanager.get_system_paths() == OrderedDict() + + # Import PYTHONPATH from environment + pathmanager.import_paths() + assert len(pathmanager.headers) == 1 + + assert pathmanager.get_system_paths() == OrderedDict({str(sys_dir): True}) + + +@pytest.mark.parametrize( + 'pathmanager', [(sys.path[:-10], sys.path[-10:], ())], indirect=True +) def test_check_uncheck_path(pathmanager): """ Test that checking and unchecking a path in the PathManager correctly @@ -66,20 +91,16 @@ def test_check_uncheck_path(pathmanager): assert item.checkState() == Qt.Checked -@pytest.mark.skipif(os.name != 'nt' or not is_module_installed('win32con'), - reason=("This feature is not applicable for Unix " - "systems and pywin32 is needed")) -@pytest.mark.parametrize('pathmanager', - [(['p1', 'p2', 'p3'], ['p4', 'p5', 'p6'], [])], - indirect=True) -def test_export_to_PYTHONPATH(pathmanager, mocker): - # Import here to prevent an ImportError when testing on unix systems - from spyder.utils.environ import (get_user_env, set_user_env, - listdict2envdict) - - # Store PYTHONPATH original state - env = get_user_env() - original_pathlist = env.get('PYTHONPATH', []) +@pytest.mark.skipif( + os.name != 'nt' or not is_module_installed('win32con'), + reason=("This feature is not applicable for Unix " + "systems and pywin32 is needed") +) +@pytest.mark.parametrize( + 'pathmanager', [(['p1', 'p2', 'p3'], ['p4', 'p5', 'p6'], [])], + indirect=True +) +def test_export_to_PYTHONPATH(pathmanager, mocker, restore_user_env): # Mock the dialog window and answer "Yes" to clear contents of PYTHONPATH # before adding Spyder's path list @@ -113,14 +134,10 @@ def test_export_to_PYTHONPATH(pathmanager, mocker): env = get_user_env() assert env['PYTHONPATH'] == expected_pathlist - # Restore PYTHONPATH to its original state - env['PYTHONPATH'] = original_pathlist - set_user_env(listdict2envdict(env)) - -@pytest.mark.parametrize('pathmanager', - [(sys.path[:-10], sys.path[-10:], ())], - indirect=True) +@pytest.mark.parametrize( + 'pathmanager', [(sys.path[:-10], sys.path[-10:], ())], indirect=True +) def test_invalid_directories(qtbot, pathmanager): """Check [site/dist]-packages are invalid paths.""" if os.name == 'nt': @@ -143,9 +160,9 @@ def interact_message_box(): pathmanager.add_path(path) -@pytest.mark.parametrize('pathmanager', - [(('/spam', '/bar'), ('/foo', ), ())], - indirect=True) +@pytest.mark.parametrize( + 'pathmanager', [(('/spam', '/bar'), ('/foo', ), ())], indirect=True +) def test_remove_item_and_reply_no(qtbot, pathmanager): """Check that the item is not removed after answering 'No'.""" pathmanager.show() @@ -169,9 +186,9 @@ def interact_message_box(): assert pathmanager.count() == count -@pytest.mark.parametrize('pathmanager', - [(('/spam', '/bar'), ('/foo', ), ())], - indirect=True) +@pytest.mark.parametrize( + 'pathmanager', [(('/spam', '/bar'), ('/foo', ), ())], indirect=True +) def test_remove_item_and_reply_yes(qtbot, pathmanager): """Check that the item is indeed removed after answering 'Yes'.""" pathmanager.show() @@ -196,9 +213,7 @@ def interact_message_box(): assert pathmanager.count() == (count - 1) -@pytest.mark.parametrize('pathmanager', - [((), (), ())], - indirect=True) +@pytest.mark.parametrize('pathmanager', [((), (), ())], indirect=True) def test_add_repeated_item(qtbot, pathmanager, tmpdir): """ Check behavior when an unchecked item that is already on the list is added. @@ -236,9 +251,9 @@ def interact_message_box(): assert all(pathmanager.get_user_paths().values()) -@pytest.mark.parametrize('pathmanager', - [(('/spam', '/bar'), ('/foo', ), ())], - indirect=True) +@pytest.mark.parametrize( + 'pathmanager', [(('/spam', '/bar'), ('/foo', ), ())], indirect=True +) def test_buttons_state(qtbot, pathmanager, tmpdir): """Check buttons are enabled/disabled based on items and position.""" pathmanager.show() diff --git a/spyder/utils/environ.py b/spyder/utils/environ.py index 5b1d36606ed..74c9ea3b388 100644 --- a/spyder/utils/environ.py +++ b/spyder/utils/environ.py @@ -27,7 +27,9 @@ from qtpy.QtWidgets import QMessageBox # Local imports -from spyder.config.base import _, running_in_ci, get_conf_path +from spyder.config.base import ( + _, running_in_ci, get_conf_path, running_under_pytest +) from spyder.widgets.collectionseditor import CollectionsEditor from spyder.utils.icon_manager import ima from spyder.utils.programs import run_shell_command @@ -111,7 +113,7 @@ def get_user_environment_variables(): # We only need to do this if Spyder was **not** launched from a # terminal. Otherwise, it'll inherit the env vars present in it. # Fixes spyder-ide/spyder#22415 - if not launched_from_terminal: + if not launched_from_terminal or running_under_pytest(): try: user_env_script = _get_user_env_script() proc = run_shell_command(user_env_script, env={}, text=True) diff --git a/spyder/utils/tests/conftest.py b/spyder/utils/tests/conftest.py new file mode 100644 index 00000000000..361d6442845 --- /dev/null +++ b/spyder/utils/tests/conftest.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Standard library imports +import os + +# Third-party imports +import pytest + +# Local imports +from spyder.config.base import running_in_ci +from spyder.utils.environ import ( + get_user_env, set_user_env, amend_user_shell_init +) + + +@pytest.fixture +def restore_user_env(): + """Set user environment variables and restore upon test exit""" + if not running_in_ci(): + pytest.skip("Skipped because not in CI.") + + if os.name == "nt": + orig_env = get_user_env() + + yield + + if os.name == "nt": + set_user_env(orig_env) + else: + amend_user_shell_init(restore=True) diff --git a/spyder/utils/tests/test_environ.py b/spyder/utils/tests/test_environ.py index 277f2418be6..d4f771d9848 100644 --- a/spyder/utils/tests/test_environ.py +++ b/spyder/utils/tests/test_environ.py @@ -18,15 +18,15 @@ from qtpy.QtCore import QTimer # Local imports -from spyder.utils.environ import (get_user_environment_variables, - UserEnvDialog, amend_user_shell_init) +from spyder.utils.environ import ( + get_user_environment_variables, UserEnvDialog, amend_user_shell_init +) from spyder.utils.test import close_message_box -from spyder.app.tests.conftest import restore_user_env @pytest.fixture def environ_dialog(qtbot): - "Setup the Environment variables Dialog." + """Setup the Environment variables Dialog.""" QTimer.singleShot(1000, lambda: close_message_box(qtbot)) dialog = UserEnvDialog() qtbot.addWidget(dialog) @@ -44,8 +44,10 @@ def test_get_user_environment_variables(): @pytest.mark.skipif(os.name == "nt", reason="Does not apply to Windows") def test_get_user_env_newline(restore_user_env): - # Test variable value with newline characters. - # Regression test for spyder-ide#20097 + """ + Test variable value with newline characters. + Regression test for spyder-ide#20097. + """ text = "myfunc() { echo hello;\n echo world\n}\nexport -f myfunc" amend_user_shell_init(text) user_env = get_user_environment_variables()