diff --git a/spyder/api/plugins/old_api.py b/spyder/api/plugins/old_api.py index 22ea49578ae..d86c3df1a6d 100644 --- a/spyder/api/plugins/old_api.py +++ b/spyder/api/plugins/old_api.py @@ -321,11 +321,43 @@ def register_shortcut(self, qaction_or_qshortcut, context, name, This is useful if the action is added to a toolbar and users hover it to see what it does. """ - super(BasePluginWidget, self)._register_shortcut( + self.main.register_shortcut( qaction_or_qshortcut, context, name, - add_shortcut_to_tip) + add_shortcut_to_tip, + self.CONF_SECTION) + + def unregister_shortcut(self, qaction_or_qshortcut, context, name, + add_shortcut_to_tip=False): + """ + Unregister a shortcut associated to a QAction or a QShortcut to + Spyder main application. + + Parameters + ---------- + qaction_or_qshortcut: QAction or QShortcut + QAction to register the shortcut for or QShortcut. + context: str + Name of the plugin this shortcut applies to. For instance, + if you pass 'Editor' as context, the shortcut will only + work when the editor is focused. + Note: You can use '_' if you want the shortcut to be work + for the entire application. + name: str + Name of the action the shortcut refers to (e.g. 'Debug + exit'). + add_shortcut_to_tip: bool + If True, the shortcut is added to the action's tooltip. + This is useful if the action is added to a toolbar and + users hover it to see what it does. + """ + self.main.unregister_shortcut( + qaction_or_qshortcut, + context, + name, + add_shortcut_to_tip, + self.CONF_SECTION) def register_widget_shortcuts(self, widget): """ @@ -346,6 +378,24 @@ def register_widget_shortcuts(self, widget): for qshortcut, context, name in widget.get_shortcut_data(): self.register_shortcut(qshortcut, context, name) + def unregister_widget_shortcuts(self, widget): + """ + Unregister shortcuts defined by a plugin's widget. + + Parameters + ---------- + widget: QWidget + Widget to register shortcuts for. + + Notes + ----- + The widget interface must have a method called + `get_shortcut_data` for this to work. Please see + `spyder/widgets/findreplace.py` for an example. + """ + for qshortcut, context, name in widget.get_shortcut_data(): + self.unregister_shortcut(qshortcut, context, name) + def get_color_scheme(self): """ Get the current color scheme. diff --git a/spyder/app/mainwindow.py b/spyder/app/mainwindow.py index bda93a8c2ed..0a0ddc6dace 100644 --- a/spyder/app/mainwindow.py +++ b/spyder/app/mainwindow.py @@ -1270,6 +1270,18 @@ def register_shortcut(self, qaction_or_qshortcut, context, name, plugin_name=plugin_name, ) + def unregister_shortcut(self, qaction_or_qshortcut, context, name, + add_shortcut_to_tip=True, plugin_name=None): + shortcuts = self.get_plugin(Plugins.Shortcuts, error=False) + if shortcuts: + shortcuts.unregister_shortcut( + qaction_or_qshortcut, + context, + name, + add_shortcut_to_tip=add_shortcut_to_tip, + plugin_name=plugin_name, + ) + # --- Other def update_source_menu(self): """Update source menu options that vary dynamically.""" diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index 97772240411..09a5c4351d6 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -11,6 +11,7 @@ """ # Standard library imports +import gc import os import os.path as osp import psutil @@ -350,6 +351,9 @@ def main_window(request, tmpdir, qtbot): # Close editor related elements window.editor.close_all_files() + # force close all files + while window.editor.editorstacks[0].close_file(force=True): + pass for editorwindow in window.editor.editorwindows: editorwindow.close() editorstack = window.editor.get_current_editorstack() @@ -530,6 +534,74 @@ def test_single_instance_and_edit_magic(main_window, qtbot, tmpdir): main_window.editor.close_file() +@pytest.mark.slow +@pytest.mark.use_introspection +def test_leaks(main_window, qtbot): + """ + Test leaks in mainwindow when closing a file or a console. + + Many other ways of leaking exist but are not covered here. + """ + # Wait until the window is fully up + shell = main_window.ipyconsole.get_current_shellwidget() + qtbot.waitUntil( + lambda: shell._prompt_html is not None, timeout=SHELL_TIMEOUT) + + # Count initial objects + # Only one of each should be present, but because of many leaks, + # this is most likely not the case. Here only closing is tested + shell.wait_all_shutdown() + gc.collect() + objects = gc.get_objects() + n_code_editor_init = 0 + for o in objects: + if type(o).__name__ == "CodeEditor": + n_code_editor_init += 1 + n_shell_init = 0 + for o in objects: + if type(o).__name__ == "ShellWidget": + n_shell_init += 1 + del objects + + # Open a second file and console + main_window.editor.new() + main_window.ipyconsole.create_new_client() + # Do something interesting in the new window + code_editor = main_window.editor.get_focus_widget() + # Show an error in the editor + code_editor.set_text("aaa") + del code_editor + + shell = main_window.ipyconsole.get_current_shellwidget() + qtbot.waitUntil( + lambda: shell._prompt_html is not None, timeout=SHELL_TIMEOUT) + with qtbot.waitSignal(shell.executed): + shell.execute("%debug print()") + + # Close all files and consoles + main_window.editor.close_all_files() + main_window.ipyconsole.restart() + + # Wait until the shells are closed + shell = main_window.ipyconsole.get_current_shellwidget() + shell.wait_all_shutdown() + # Count final objects + gc.collect() + objects = gc.get_objects() + n_code_editor = 0 + for o in objects: + if type(o).__name__ == "CodeEditor": + n_code_editor += 1 + n_shell = 0 + for o in objects: + if type(o).__name__ == "ShellWidget": + n_shell += 1 + + # Make sure no new objects have been created + assert n_shell <= n_shell_init + assert n_code_editor <= n_code_editor_init + + @pytest.mark.slow def test_lock_action(main_window, qtbot): """Test the lock interface action.""" diff --git a/spyder/config/main.py b/spyder/config/main.py index 0d7654fb991..2155163f02b 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -330,7 +330,6 @@ ('completions', { 'enable': True, - 'kite_call_to_action': False, 'enable_code_snippets': True, 'completions_wait_for_ms': 200, 'enabled_providers': {}, @@ -640,4 +639,4 @@ # or if you want to *rename* options, then you need to do a MAJOR update in # version, e.g. from 3.0.0 to 4.0.0 # 3. You don't need to touch this value if you're just adding a new option -CONF_VERSION = '70.4.0' +CONF_VERSION = '71.0.0' diff --git a/spyder/plugins/base.py b/spyder/plugins/base.py index 158d0076580..976793e9291 100644 --- a/spyder/plugins/base.py +++ b/spyder/plugins/base.py @@ -465,16 +465,6 @@ def _setup(self): self.sig_update_plugin_title.connect(self._update_plugin_title) self.setWindowTitle(self.get_plugin_title()) - def _register_shortcut(self, qaction_or_qshortcut, context, name, - add_shortcut_to_tip=False): - """Register a shortcut associated to a QAction or QShortcut.""" - self.main.register_shortcut( - qaction_or_qshortcut, - context, - name, - add_shortcut_to_tip, - self.CONF_SECTION) - def _get_color_scheme(self): """Get the current color scheme.""" return get_color_scheme(CONF.get('appearance', 'selected')) diff --git a/spyder/plugins/completion/plugin.py b/spyder/plugins/completion/plugin.py index 3a9139290b2..e816a65b236 100644 --- a/spyder/plugins/completion/plugin.py +++ b/spyder/plugins/completion/plugin.py @@ -18,6 +18,7 @@ import os from pkg_resources import parse_version, iter_entry_points from typing import List, Union +import weakref # Third-party imports from qtpy.QtCore import QMutex, QMutexLocker, QTimer, Slot, Signal @@ -1020,7 +1021,7 @@ def send_request(self, language: str, req_type: str, req: dict): self.requests[req_id] = { 'language': language, 'req_type': req_type, - 'response_instance': req['response_instance'], + 'response_instance': weakref.ref(req['response_instance']), 'sources': {}, 'timed_out': False, } @@ -1239,7 +1240,7 @@ def skip_and_reply(self, req_id: int): """ request_responses = self.requests[req_id] req_type = request_responses['req_type'] - response_instance = id(request_responses['response_instance']) + response_instance = id(request_responses['response_instance']()) do_send = True # This is necessary to prevent sending completions for old requests @@ -1248,7 +1249,7 @@ def skip_and_reply(self, req_id: int): max_req_id = max( [key for key, item in self.requests.items() if item['req_type'] == req_type - and id(item['response_instance']) == response_instance] + and id(item['response_instance']()) == response_instance] or [-1]) do_send = (req_id == max_req_id) @@ -1266,7 +1267,7 @@ def gather_and_reply(self, request_responses: dict): """ req_type = request_responses['req_type'] req_id_responses = request_responses['sources'] - response_instance = request_responses['response_instance'] + response_instance = request_responses['response_instance']() logger.debug('Gather responses for {0}'.format(req_type)) if req_type == CompletionRequestTypes.DOCUMENT_COMPLETION: @@ -1275,7 +1276,8 @@ def gather_and_reply(self, request_responses: dict): responses = self.gather_responses(req_type, req_id_responses) try: - response_instance.handle_response(req_type, responses) + if response_instance: + response_instance.handle_response(req_type, responses) except RuntimeError: # This is triggered when a codeeditor instance has been # removed before the response can be processed. diff --git a/spyder/plugins/completion/providers/kite/widgets/__init__.py b/spyder/plugins/completion/providers/kite/widgets/__init__.py index 0e076f99fd6..7a2ee828b67 100644 --- a/spyder/plugins/completion/providers/kite/widgets/__init__.py +++ b/spyder/plugins/completion/providers/kite/widgets/__init__.py @@ -8,4 +8,3 @@ from .messagebox import KiteInstallationErrorMessage from .status import KiteStatusWidget -from .calltoaction import KiteCallToAction diff --git a/spyder/plugins/completion/providers/kite/widgets/calltoaction.py b/spyder/plugins/completion/providers/kite/widgets/calltoaction.py deleted file mode 100644 index 100bffa4fed..00000000000 --- a/spyder/plugins/completion/providers/kite/widgets/calltoaction.py +++ /dev/null @@ -1,145 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -from qtpy.QtCore import Qt -from qtpy.QtWidgets import (QLabel, QVBoxLayout, QFrame, - QHBoxLayout, QPushButton) - -from spyder.api.config.mixins import SpyderConfigurationAccessor -from spyder.config.base import _ -from spyder.plugins.completion.providers.kite.bloomfilter import ( - KiteBloomFilter) -from spyder.plugins.completion.providers.kite.parsing import ( - find_returning_function_path) -from spyder.plugins.completion.providers.kite.utils.status import ( - check_if_kite_installed) -from spyder.plugins.completion.providers.fallback.actor import ( - FALLBACK_COMPLETION) -from spyder.utils.palette import QStylePalette - - -COVERAGE_MESSAGE = ( - _("No completions found." - " Get completions for this case and more by installing Kite.") -) - - -class KiteCallToAction(QFrame, SpyderConfigurationAccessor): - CONF_SECTION = 'completions' - - def __init__(self, textedit, ancestor): - super(KiteCallToAction, self).__init__(ancestor) - self.textedit = textedit - - self.setFrameStyle(QFrame.StyledPanel | QFrame.Plain) - self.setAutoFillBackground(True) - self.setWindowFlags(Qt.SubWindow | Qt.FramelessWindowHint) - self.setFocusPolicy(Qt.NoFocus) - self.setObjectName("kite-call-to-action") - self.setStyleSheet(self.styleSheet() + - ("#kite-call-to-action " - "{{ border: 1px solid; " - " border-color: {border_color}; " - " border-radius: 4px;}} " - "#kite-call-to-action:hover " - "{{ border:1px solid {border}; }}").format( - border_color=QStylePalette.COLOR_BACKGROUND_4, - border=QStylePalette.COLOR_ACCENT_4)) - - # sub-layout: horizontally aligned links - actions = QFrame(self) - actions_layout = QHBoxLayout() - actions_layout.setContentsMargins(5, 5, 5, 5) - actions_layout.setSpacing(10) - actions_layout.addStretch() - actions.setLayout(actions_layout) - - self._install_button = QPushButton(_("Install Kite")) - self._dismiss_button = QPushButton(_("Dismiss Forever")) - self._install_button.clicked.connect(self._install_kite) - self._dismiss_button.clicked.connect(self._dismiss_forever) - actions_layout.addWidget(self._install_button) - actions_layout.addWidget(self._dismiss_button) - - # main layout: message + horizontally aligned links - main_layout = QVBoxLayout() - main_layout.setContentsMargins(5, 5, 5, 5) - self.setLayout(main_layout) - self.label = QLabel(self) - self.label.setWordWrap(True) - main_layout.addWidget(self.label) - main_layout.addWidget(actions) - main_layout.addStretch() - - self._enabled = self.get_conf('kite_call_to_action') - self._escaped = False - self.hide() - - is_kite_installed, __ = check_if_kite_installed() - - if is_kite_installed: - self._dismiss_forever() - - def handle_key_press(self, event): - key = event.key() - if not self._is_valid_ident_key(key): - self.hide() - self._escaped = key == Qt.Key_Escape - - def handle_mouse_press(self, event): - self.hide() - - def handle_processed_completions(self, completions): - if not self.get_conf('kite_call_to_action'): - return - - installers_available = self.get_conf( - ('provider_configuration', 'kite', 'values', - 'installers_available')) - - if not installers_available: - return - - if self._escaped: - return - if not self.textedit.completion_widget.isHidden(): - return - if any(c['provider'] != FALLBACK_COMPLETION for c in completions): - return - - # check if we should show the CTA, based on Kite support - text = self.textedit.get_text('sof', 'eof') - offset = self.textedit.get_position('cursor') - - fn_path = find_returning_function_path(text, offset, u'\u2029') - if fn_path is None: - return - if not KiteBloomFilter.is_valid_path(fn_path): - return - - self.label.setText(COVERAGE_MESSAGE) - self.resize(self.sizeHint()) - self.show() - self.textedit.position_widget_at_cursor(self) - self.raise_() - - def _is_valid_ident_key(self, key): - is_upper = ord('A') <= key <= ord('Z') - is_lower = ord('a') <= key <= ord('z') - is_digit = ord('0') <= key <= ord('9') - is_under = key == ord('_') - return is_upper or is_lower or is_digit or is_under - - def _dismiss_forever(self): - self.hide() - self._enabled = False - self.set_conf('kite_call_to_action', False) - - def _install_kite(self): - self.hide() - self._enabled = False - kite = self.parent().completions.get_provider('kite') - kite.show_kite_installation() diff --git a/spyder/plugins/completion/providers/languageserver/provider.py b/spyder/plugins/completion/providers/languageserver/provider.py index 16152ec4d03..e8959e13a2a 100644 --- a/spyder/plugins/completion/providers/languageserver/provider.py +++ b/spyder/plugins/completion/providers/languageserver/provider.py @@ -148,6 +148,8 @@ def __del__(self): try: if self.clients_hearbeat[language] is not None: self.clients_hearbeat[language].stop() + self.clients_hearbeat[language].setParent(None) + del self.clients_hearbeat[language] except (TypeError, KeyError, RuntimeError): pass @@ -184,11 +186,12 @@ def restart_lsp(self, language: str, force=False): self.clients_restart_timers[language] = None try: self.clients_hearbeat[language].stop() + self.clients_hearbeat[language].setParent(None) + del self.clients_hearbeat[language] client['instance'].disconnect() client['instance'].stop() except (TypeError, KeyError, RuntimeError): pass - self.clients_hearbeat[language] = None self.report_lsp_down(language) def create_statusbar(self, parent): @@ -261,6 +264,8 @@ def handle_lsp_down(self, language): and not running_under_pytest()): try: self.clients_hearbeat[language].stop() + self.clients_hearbeat[language].setParent(None) + del self.clients_hearbeat[language] except KeyError: pass logger.info("Automatic restart for {}...".format(language)) @@ -464,12 +469,15 @@ def start_completion_services_for_language(self, language): started = language_client['status'] == self.RUNNING - # Start client heartbeat - timer = QTimer(self) - self.clients_hearbeat[language] = timer - timer.setInterval(self.TIME_HEARTBEAT) - timer.timeout.connect(functools.partial(self.check_heartbeat, language)) - timer.start() + if language not in self.clients_hearbeat: + # completion_services for language is already running + # Start client heartbeat + timer = QTimer(self) + self.clients_hearbeat[language] = timer + timer.setInterval(self.TIME_HEARTBEAT) + timer.timeout.connect(functools.partial( + self.check_heartbeat, language)) + timer.start() if language_client['status'] == self.STOPPED: config = language_client['config'] @@ -659,6 +667,8 @@ def stop_completion_services_for_language(self, language): try: if self.clients_hearbeat[language] is not None: self.clients_hearbeat[language].stop() + self.clients_hearbeat[language].setParent(None) + del self.clients_hearbeat[language] except (TypeError, KeyError, RuntimeError): pass language_client['instance'].stop() diff --git a/spyder/plugins/completion/providers/languageserver/providers/document.py b/spyder/plugins/completion/providers/languageserver/providers/document.py index 659515d1233..ac2427b7087 100644 --- a/spyder/plugins/completion/providers/languageserver/providers/document.py +++ b/spyder/plugins/completion/providers/languageserver/providers/document.py @@ -309,7 +309,7 @@ def document_did_close(self, params): if id(codeeditor) == id(editor): idx = i break - if idx > 0: + if idx >= 0: editors.pop(idx) if len(editors) == 0: diff --git a/spyder/plugins/editor/plugin.py b/spyder/plugins/editor/plugin.py index 6977f10d0e5..6efe9cef04a 100644 --- a/spyder/plugins/editor/plugin.py +++ b/spyder/plugins/editor/plugin.py @@ -1332,9 +1332,7 @@ def update_font(self): completion_size = CONF.get('main', 'completion/size') for finfo in editorstack.data: comp_widget = finfo.editor.completion_widget - kite_call_to_action = finfo.editor.kite_call_to_action comp_widget.setup_appearance(completion_size, font) - kite_call_to_action.setFont(font) def set_ancestor(self, ancestor): """ @@ -1348,7 +1346,6 @@ def set_ancestor(self, ancestor): for editorstack in self.editorstacks: for finfo in editorstack.data: comp_widget = finfo.editor.completion_widget - kite_call_to_action = finfo.editor.kite_call_to_action # This is necessary to catch an error when the plugin is # undocked and docked back, and (probably) a completion is @@ -1356,7 +1353,6 @@ def set_ancestor(self, ancestor): # Fixes spyder-ide/spyder#17486 try: comp_widget.setParent(ancestor) - kite_call_to_action.setParent(ancestor) except RuntimeError: pass diff --git a/spyder/plugins/editor/utils/decoration.py b/spyder/plugins/editor/utils/decoration.py index 4417a89f139..a77eb182c24 100644 --- a/spyder/plugins/editor/utils/decoration.py +++ b/spyder/plugins/editor/utils/decoration.py @@ -135,11 +135,15 @@ def _update(self): NOTE: Update TextDecorations to use editor font, using a different font family and point size could cause unwanted behaviors. """ + editor = self.editor + if editor is None: + return + try: - font = self.editor.font() + font = editor.font() # Get the current visible block numbers - first, last = self.editor.get_buffer_block_numbers() + first, last = editor.get_buffer_block_numbers() # Update visible decorations visible_decorations = [] @@ -166,7 +170,7 @@ def _update(self): decoration.format.setFontFamily(font.family()) decoration.format.setFontPointSize(font.pointSize()) - self.editor.setExtraSelections(visible_decorations) + editor.setExtraSelections(visible_decorations) except RuntimeError: # This is needed to fix spyder-ide/spyder#9173. return diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py index 7094309a60e..998597e9a3d 100644 --- a/spyder/plugins/editor/widgets/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor.py @@ -56,7 +56,6 @@ QMenuOnlyForEnter, EditorExtensionsManager, SnippetsExtension) -from spyder.plugins.completion.providers.kite.widgets import KiteCallToAction from spyder.plugins.completion.api import (CompletionRequestTypes, TextDocumentSyncKind, DiagnosticSeverity) @@ -599,7 +598,6 @@ def __init__(self, parent=None): # re-use parent of completion_widget (usually the main window) completion_parent = self.completion_widget.parent() - self.kite_call_to_action = KiteCallToAction(self, completion_parent) # Some events should not be triggered during undo/redo # such as line stripping @@ -1522,12 +1520,9 @@ def sort_key(completion): self.completion_widget.show_list( completion_list, position, automatic) - - self.kite_call_to_action.handle_processed_completions(completions) except RuntimeError: # This is triggered when a codeeditor instance was removed # before the response can be processed. - self.kite_call_to_action.hide_coverage_cta() return except Exception: self.log_lsp_handle_errors('Error when processing completions') @@ -4612,8 +4607,6 @@ def keyPressEvent(self, event): event.ignore() self.sig_key_pressed.emit(event) - self.kite_call_to_action.handle_key_press(event) - key = event.key() text = to_text_string(event.text()) has_selection = self.has_selected_text() @@ -5304,7 +5297,6 @@ def leaveEvent(self, event): def mousePressEvent(self, event): """Override Qt method.""" self.hide_tooltip() - self.kite_call_to_action.handle_mouse_press(event) ctrl = event.modifiers() & Qt.ControlModifier alt = event.modifiers() & Qt.AltModifier diff --git a/spyder/plugins/editor/widgets/editor.py b/spyder/plugins/editor/widgets/editor.py index 5b72d994f92..68952315162 100644 --- a/spyder/plugins/editor/widgets/editor.py +++ b/spyder/plugins/editor/widgets/editor.py @@ -1636,7 +1636,13 @@ def close_file(self, index=None, force=False): filename = self.data[index].filename self.remove_from_data(index) - finfo.editor.notify_close() + editor = finfo.editor + editor.notify_close() + editor.setParent(None) + editor.completion_widget.setParent(None) + if self.parent(): + # Can be None in tests + self.get_plugin().unregister_widget_shortcuts(editor) # We pass self object ID as a QString, because otherwise it would # depend on the platform: long for 64bit, int for 32bit. Replacing diff --git a/spyder/plugins/editor/widgets/tests/test_editorsplitter.py b/spyder/plugins/editor/widgets/tests/test_editorsplitter.py index 51503cefdcd..558c21dcdc5 100644 --- a/spyder/plugins/editor/widgets/tests/test_editorsplitter.py +++ b/spyder/plugins/editor/widgets/tests/test_editorsplitter.py @@ -70,12 +70,10 @@ def register_editorstack(editorstack): editorstack.register_completion_capabilities(capabilities, 'python') def clone(editorstack, template=None): - # editorstack.clone_from(template) - editor_stack = EditorStack(None, []) - editor_stack.set_find_widget(Mock()) - editor_stack.set_io_actions(Mock(), Mock(), Mock(), Mock()) + editorstack.set_find_widget(Mock()) + editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock()) # Emulate "cloning" - editorsplitter.editorstack.new('test.py', 'utf-8', text) + editorstack.new('test.py', 'utf-8', text) mock_plugin = Mock() editorsplitter = EditorSplitter( diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index 17df1a897a5..45d90a522f5 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -392,7 +392,7 @@ def update_font(self): def on_close(self, cancelable=False): """Perform actions when plugin is closed""" self.get_widget().mainwindow_close = True - return self.get_widget().close_clients() + return self.get_widget().close_all_clients() def on_mainwindow_visible(self): self.create_new_client(give_focus=False) diff --git a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py index ec16b524c0b..2ebf1417833 100644 --- a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py +++ b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py @@ -1282,26 +1282,27 @@ def test_set_elapsed_time(ipyconsole, qtbot): client = ipyconsole.get_current_client() # Show time label. - ipyconsole.get_widget().set_show_elapsed_time_current_client(True) + main_widget = ipyconsole.get_widget() + main_widget.set_show_elapsed_time_current_client(True) # Set time to 2 minutes ago. client.t0 -= 120 with qtbot.waitSignal(client.timer.timeout, timeout=5000): ipyconsole.get_widget().set_client_elapsed_time(client) - assert ('00:02:00' in client.time_label.text() or - '00:02:01' in client.time_label.text()) + assert ('00:02:00' in main_widget.time_label.text() or + '00:02:01' in main_widget.time_label.text()) # Wait for a second to pass, to ensure timer is counting up with qtbot.waitSignal(client.timer.timeout, timeout=5000): pass - assert ('00:02:01' in client.time_label.text() or - '00:02:02' in client.time_label.text()) + assert ('00:02:01' in main_widget.time_label.text() or + '00:02:02' in main_widget.time_label.text()) # Make previous time later than current time. client.t0 += 2000 with qtbot.waitSignal(client.timer.timeout, timeout=5000): pass - assert '00:00:00' in client.time_label.text() + assert '00:00:00' in main_widget.time_label.text() client.timer.timeout.disconnect(client.show_time) diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 846f84497f4..5978d121cd3 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -81,6 +81,7 @@ class ClientWidget(QWidget, SaveHistoryMixin, SpyderWidgetMixin): sig_append_to_history_requested = Signal(str, str) sig_execution_state_changed = Signal() + sig_time_label = Signal(str) CONF_SECTION = 'ipython_console' SEPARATOR = '{0}## ---({1})---'.format(os.linesep*2, time.ctime()) @@ -98,7 +99,6 @@ def __init__(self, parent, id_, given_name=None, give_focus=True, options_button=None, - time_label=None, show_elapsed_time=False, reset_warning=True, ask_before_restart=True, @@ -126,7 +126,6 @@ def __init__(self, parent, id_, # --- Other attrs self.context_menu_actions = context_menu_actions - self.time_label = time_label self.options_button = options_button self.history = [] self.allow_rename = True @@ -644,6 +643,25 @@ def set_color_scheme(self, color_scheme, reset=True): except AttributeError: pass + def close_client(self, is_last_client): + """Close the client.""" + # Needed to handle a RuntimeError. See spyder-ide/spyder#5568. + try: + # Close client + self.stop_button_click_handler() + except RuntimeError: + pass + + # Disconnect timer needed to update elapsed time + try: + self.timer.timeout.disconnect(self.show_time) + except (RuntimeError, TypeError): + pass + + self.shutdown(is_last_client) + self.close() + self.setParent(None) + def shutdown(self, is_last_client): """Shutdown connection and kernel if needed.""" self.dialog_manager.close_all() @@ -899,8 +917,6 @@ def show_env(self, env): def show_time(self, end=False): """Text to show in time_label.""" - if self.time_label is None: - return elapsed_time = time.monotonic() - self.t0 # System time changed to past date, so reset start. @@ -919,9 +935,9 @@ def show_time(self, end=False): "" % (color, time.strftime(fmt, time.gmtime(elapsed_time))) if self.show_elapsed_time: - self.time_label.setText(text) + self.sig_time_label.emit(text) else: - self.time_label.setText("") + self.sig_time_label.emit("") @Slot(bool) def set_show_elapsed_time(self, state): diff --git a/spyder/plugins/ipythonconsole/widgets/debugging.py b/spyder/plugins/ipythonconsole/widgets/debugging.py index 695b9ce3287..dc456ffd0d8 100644 --- a/spyder/plugins/ipythonconsole/widgets/debugging.py +++ b/spyder/plugins/ipythonconsole/widgets/debugging.py @@ -10,6 +10,7 @@ """ # Standard library imports +import atexit import pdb import re @@ -220,6 +221,8 @@ def shutdown(self): if self._pdb_history_file is not None: try: self._pdb_history_file.save_thread.stop() + # Now that it was called, no need to call it at exit + atexit.unregister(self._pdb_history_file.save_thread.stop) except AttributeError: pass diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index ada17eeb05e..c4a22e4595f 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -401,7 +401,7 @@ def __init__(self, name=None, plugin=None, parent=None): def on_close(self): self.mainwindow_close = True - self.close_clients() + self.close_all_clients() # ---- PluginMainWidget API and settings handling # ------------------------------------------------------------------------ @@ -1436,7 +1436,6 @@ def create_new_client(self, give_focus=True, filename='', is_cython=False, interpreter_versions=self.interpreter_versions(), connection_file=cf, context_menu_actions=self.context_menu_actions, - time_label=self.time_label, show_elapsed_time=show_elapsed_time, reset_warning=reset_warning, given_name=given_name, @@ -1575,7 +1574,6 @@ def create_client_for_kernel(self, connection_file, hostname, sshkey, interpreter_versions=self.interpreter_versions(), connection_file=connection_file, context_menu_actions=self.context_menu_actions, - time_label=self.time_label, hostname=hostname, is_external_kernel=is_external_kernel, is_spyder_kernel=known_spyder_kernel, @@ -1865,6 +1863,9 @@ def register_client(self, client, give_focus=True): # Connect client execution state to be reflected in the interface client.sig_execution_state_changed.connect(self.update_actions) + # Show time label + client.sig_time_label.connect(self.time_label.setText) + def close_client(self, index=None, client=None, ask_recursive=True): """Close client tab from index or widget (or close current tab)""" if not self.tabwidget.count(): @@ -1883,19 +1884,6 @@ def close_client(self, index=None, client=None, ask_recursive=True): if index is not None: client = self.tabwidget.widget(index) - # Needed to handle a RuntimeError. See spyder-ide/spyder#5568. - try: - # Close client - client.stop_button_click_handler() - except RuntimeError: - pass - - # Disconnect timer needed to update elapsed time - try: - client.timer.timeout.disconnect(client.show_time) - except (RuntimeError, TypeError): - pass - # Check if related clients or kernels are opened # and eventually ask before closing them if not self.mainwindow_close and ask_recursive: @@ -1919,13 +1907,11 @@ def close_client(self, index=None, client=None, ask_recursive=True): if close_all == QMessageBox.Yes: self.close_related_clients(client) - last_client = len(self.get_related_clients(client)) == 0 - client.shutdown(last_client) - client.close() - # Note: client index may have changed after closing related widgets self.tabwidget.removeTab(self.tabwidget.indexOf(client)) self.clients.remove(client) + is_last_client = len(self.get_related_clients(client)) == 0 + client.close_client(is_last_client) # This is needed to prevent that hanged consoles make reference # to an index that doesn't exist. See spyder-ide/spyder#4881 @@ -1940,7 +1926,7 @@ def close_client(self, index=None, client=None, ask_recursive=True): if not self.tabwidget.count() and self.create_new_client_if_empty: self.create_new_client() - def close_clients(self): + def close_all_clients(self): """ Perform close actions for each running client. @@ -1949,17 +1935,16 @@ def close_clients(self): bool If the closing action was succesful. """ - open_clients = self.clients.copy() - for client in self.clients: - last_client = ( - len(self.get_related_clients(client, open_clients)) == 0) - client.shutdown(last_client) - client.close() - open_clients.remove(client) + while self.clients: + client = self.clients.pop() + is_last_client = len(self.get_related_clients(client)) == 0 + client.close_client(is_last_client) + # Close all closing shellwidgets. ShellWidget.wait_all_shutdown() # Close cached kernel self.close_cached_kernel() + self.filenames = [] return True def get_client_index_from_id(self, client_id): diff --git a/spyder/plugins/outlineexplorer/widgets.py b/spyder/plugins/outlineexplorer/widgets.py index 3d5ddf4391a..bce7680e4d8 100644 --- a/spyder/plugins/outlineexplorer/widgets.py +++ b/spyder/plugins/outlineexplorer/widgets.py @@ -347,14 +347,15 @@ def toggle_fullpath_mode(self, state): @on_conf_change(option='show_all_files') def toggle_show_all_files(self, state): self.show_all_files = state - if self.current_editor is not None: - editor_id = self.editor_ids[self.current_editor] + current_editor = self.current_editor + if current_editor is not None: + editor_id = self.editor_ids[current_editor] item = self.editor_items[editor_id].node self.__hide_or_show_root_items(item) self.__sort_toplevel_items() if self.show_all_files is False: self.root_item_selected( - self.editor_items[self.editor_ids[self.current_editor]]) + self.editor_items[self.editor_ids[current_editor]]) self.do_follow_cursor() @on_conf_change(option='show_comments') diff --git a/spyder/plugins/plots/widgets/main_widget.py b/spyder/plugins/plots/widgets/main_widget.py index 03dfb172470..6cd826b18fe 100644 --- a/spyder/plugins/plots/widgets/main_widget.py +++ b/spyder/plugins/plots/widgets/main_widget.py @@ -308,6 +308,7 @@ def create_new_widget(self, shellwidget): def close_widget(self, fig_browser): fig_browser.close() + fig_browser.setParent(None) def switch_widget(self, fig_browser, old_fig_browser): option_keys = [('auto_fit_plotting', True), diff --git a/spyder/plugins/variableexplorer/widgets/main_widget.py b/spyder/plugins/variableexplorer/widgets/main_widget.py index 3a945fdb837..8d0687cf820 100644 --- a/spyder/plugins/variableexplorer/widgets/main_widget.py +++ b/spyder/plugins/variableexplorer/widgets/main_widget.py @@ -484,6 +484,7 @@ def create_new_widget(self, shellwidget): def close_widget(self, nsb): nsb.close() + nsb.setParent(None) def import_data(self, filenames=None): """