From 0732948c63d92bd95e04ba5b3758ab35b5c5a45e Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Sun, 10 Dec 2023 20:07:12 -0800 Subject: [PATCH 01/22] Remove application update mechanism from Application plugin and move required files to Update Manager plugin. --- spyder/config/main.py | 1 - spyder/plugins/application/confpage.py | 3 - spyder/plugins/application/container.py | 260 +----------------- spyder/plugins/application/plugin.py | 34 +-- spyder/plugins/statusbar/plugin.py | 4 +- .../updatemanager}/tests/__init__.py | 0 .../updatemanager}/tests/test_update.py | 0 .../updatemanager}/updates.py | 0 .../widgets/__init__.py | 0 .../widgets/install.py | 0 .../widgets/status.py | 0 spyder/workers/__init__.py | 7 - 12 files changed, 5 insertions(+), 304 deletions(-) rename spyder/{workers => plugins/updatemanager}/tests/__init__.py (100%) rename spyder/{workers => plugins/updatemanager}/tests/test_update.py (100%) rename spyder/{workers => plugins/updatemanager}/updates.py (100%) rename spyder/plugins/{application => updatemanager}/widgets/__init__.py (100%) rename spyder/plugins/{application => updatemanager}/widgets/install.py (100%) rename spyder/plugins/{application => updatemanager}/widgets/status.py (100%) delete mode 100644 spyder/workers/__init__.py diff --git a/spyder/config/main.py b/spyder/config/main.py index 1458e45564c..2a8480c3390 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -78,7 +78,6 @@ 'custom_margin': 0, 'use_custom_cursor_blinking': False, 'show_internal_errors': True, - 'check_updates_on_startup': True, 'cursor/width': 2, 'completion/size': (300, 180), 'report_error/remember_token': False, diff --git a/spyder/plugins/application/confpage.py b/spyder/plugins/application/confpage.py index b55a2083f58..0d7907b373a 100644 --- a/spyder/plugins/application/confpage.py +++ b/spyder/plugins/application/confpage.py @@ -65,8 +65,6 @@ def setup_page(self): prompt_box = newcb(_("Prompt when exiting"), 'prompt_on_exit') popup_console_box = newcb(_("Show internal Spyder errors to report " "them to Github"), 'show_internal_errors') - check_updates = newcb(_("Check for updates on startup"), - 'check_updates_on_startup') # Decide if it's possible to activate or not single instance mode # ??? Should we allow multiple instances for macOS? @@ -88,7 +86,6 @@ def setup_page(self): advanced_layout.addWidget(single_instance_box) advanced_layout.addWidget(prompt_box) advanced_layout.addWidget(popup_console_box) - advanced_layout.addWidget(check_updates) advanced_widget = QWidget() advanced_widget.setLayout(advanced_layout) diff --git a/spyder/plugins/application/container.py b/spyder/plugins/application/container.py index cce31fa770a..e51b88fafc8 100644 --- a/spyder/plugins/application/container.py +++ b/spyder/plugins/application/container.py @@ -12,12 +12,10 @@ # Standard library imports import os -import subprocess import sys import glob # Third party imports -from packaging.version import parse from qtpy.QtCore import Qt, QThread, QTimer, Signal, Slot from qtpy.QtGui import QGuiApplication from qtpy.QtWidgets import QAction, QMessageBox, QPushButton @@ -28,18 +26,13 @@ from spyder.api.translations import _ from spyder.api.widgets.main_container import PluginMainContainer from spyder.utils.installers import InstallerMissingDependencies -from spyder.config.utils import is_anaconda -from spyder.config.base import (get_conf_path, get_debug_level, - is_conda_based_app) -from spyder.plugins.application.widgets.status import ApplicationUpdateStatus +from spyder.config.base import get_conf_path, get_debug_level from spyder.plugins.console.api import ConsoleActions -from spyder.utils.conda import is_anaconda_pkg, get_spyder_conda_channel from spyder.utils.environ import UserEnvDialog from spyder.utils.qthelpers import start_file, DialogManager from spyder.widgets.about import AboutDialog from spyder.widgets.dependencies import DependenciesDialog from spyder.widgets.helperwidgets import MessageCheckBox -from spyder.workers.updates import WorkerUpdates class ApplicationPluginMenus: @@ -61,7 +54,6 @@ class ApplicationActions: SpyderDocumentationVideoAction = "spyder_documentation_video_action" SpyderTroubleshootingAction = "spyder_troubleshooting_action" SpyderDependenciesAction = "spyder_dependencies_action" - SpyderCheckUpdatesAction = "spyder_check_updates_action" SpyderSupportAction = "spyder_support_action" SpyderAbout = "spyder_about_action" @@ -94,9 +86,6 @@ def __init__(self, name, plugin, parent=None): self.current_dpi = None self.dpi_messagebox = None - # Keep track of the downloaded installer executable for updates - self.installer_path = None - # ---- PluginMainContainer API # ------------------------------------------------------------------------- def setup(self): @@ -107,19 +96,6 @@ def setup(self): # Attributes self.dialog_manager = DialogManager() - self.application_update_status = None - if is_conda_based_app(): - self.application_update_status = ApplicationUpdateStatus( - parent=self) - (self.application_update_status.sig_check_for_updates_requested - .connect(self.check_updates)) - (self.application_update_status.sig_install_on_close_requested - .connect(self.set_installer_path)) - self.application_update_status.set_no_status() - self.give_updates_feedback = False - self.thread_updates = None - self.worker_updates = None - self.updates_timer = None # Actions # Documentation actions @@ -155,10 +131,6 @@ def setup(self): _("Dependencies..."), triggered=self.show_dependencies, icon=self.create_icon('advanced')) - self.check_updates_action = self.create_action( - ApplicationActions.SpyderCheckUpdatesAction, - _("Check for updates..."), - triggered=self.check_updates) self.support_group_action = self.create_action( ApplicationActions.SpyderSupportAction, _("Spyder support..."), @@ -226,20 +198,10 @@ def update_actions(self): def on_close(self): """To call from Spyder when the plugin is closed.""" self.dialog_manager.close_all() - if self.updates_timer is not None: - self.updates_timer.stop() - if self.thread_updates is not None: - self.thread_updates.quit() - self.thread_updates.wait() if self.dependencies_thread is not None: self.dependencies_thread.quit() self.dependencies_thread.wait() - # Run installer after Spyder is closed - cmd = ('start' if os.name == 'nt' else 'open') - if self.installer_path: - subprocess.Popen(' '.join([cmd, self.installer_path]), shell=True) - @Slot() def show_about(self): """Show Spyder About dialog.""" @@ -251,226 +213,6 @@ def show_user_env_variables(self): """Show Windows current user environment variables.""" self.dialog_manager.show(UserEnvDialog(self)) - # ---- Updates - # ------------------------------------------------------------------------- - - def _check_updates_ready(self): - """Show results of the Spyder update checking process.""" - - # `feedback` = False is used on startup, so only positive feedback is - # given. `feedback` = True is used when after startup (when using the - # menu action, and gives feeback if updates are, or are not found. - feedback = self.give_updates_feedback - - # Get results from worker - update_available = self.worker_updates.update_available - latest_release = self.worker_updates.latest_release - error_msg = self.worker_updates.error - - url_i = 'https://docs.spyder-ide.org/current/installation.html' - - # Define the custom QMessageBox - box = MessageCheckBox(icon=QMessageBox.Information, - parent=self) - box.setWindowTitle(_("New Spyder version")) - box.setAttribute(Qt.WA_ShowWithoutActivating) - box.set_checkbox_text(_("Check for updates at startup")) - box.setStandardButtons(QMessageBox.Ok) - box.setDefaultButton(QMessageBox.Ok) - box.setTextFormat(Qt.RichText) - - # Adjust the checkbox depending on the stored configuration - option = 'check_updates_on_startup' - box.set_checked(self.get_conf(option)) - - header = _( - "

Spyder {} is available!


" - ).format(latest_release) - - if error_msg is not None: - box.setText(error_msg) - box.set_check_visible(False) - box.show() - if self.application_update_status: - self.application_update_status.set_no_status() - elif update_available: - if self.application_update_status: - self.application_update_status.set_status_pending( - latest_release) - - # Update using our installers - if parse(latest_release) >= parse("6.0.0"): - box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) - box.setDefaultButton(QMessageBox.Yes) - - if not is_conda_based_app(): - installers_url = url_i + "#standalone-installers" - msg = ( - header + - _("Would you like to automatically download and " - "install it using Spyder's installer?" - "

" - "We recommend our own installer " - "because it's more stable and makes updating easy. " - "This will leave your existing Spyder installation " - "untouched.").format(installers_url) - ) - else: - msg = ( - header + - _("Would you like to automatically download " - "and install it?") - ) - - box.setText(msg) - box.exec_() - if box.result() == QMessageBox.Yes: - self.application_update_status.start_installation( - latest_release=latest_release) - - # Manual update - if ( - not box.result() # The installer dialog was skipped - or ( - box.result() == QMessageBox.No - and not is_conda_based_app() - ) - ): - # Update-at-startup checkbox visible only if manual update - # is first message box - box.set_check_visible(not box.result()) - box.setStandardButtons(QMessageBox.Ok) - box.setDefaultButton(QMessageBox.Ok) - - msg = "" - if not box.result(): - msg += header - - if os.name == "nt": - if is_anaconda(): - msg += _("Run the following command or commands in " - "the Anaconda prompt to update manually:" - "

") - else: - msg += _("Run the following command in a cmd prompt " - "to update manually:

") - else: - if is_anaconda(): - msg += _("Run the following command or commands in a " - "terminal to update manually:

") - else: - msg += _("Run the following command in a terminal to " - "update manually:

") - - if is_anaconda(): - channel, __ = get_spyder_conda_channel() - is_pypi = channel == 'pypi' - - if is_anaconda_pkg() and not is_pypi: - msg += "conda update anaconda
" - - if is_pypi: - dont_mix_pip_conda_video = ( - "https://youtu.be/Ul79ihg41Rs" - ) - - msg += ( - "pip install --upgrade spyder" - "


" - ) - - msg += _( - "Important note: You installed Spyder with " - "pip in a Conda environment, which is not a good " - "idea. See our video for more " - "details about it." - ).format(dont_mix_pip_conda_video) - else: - if channel == 'pkgs/main': - channel = '' - else: - channel = f'-c {channel}' - - msg += ( - f"conda install {channel} " - f"spyder={latest_release}" - f"


" - ) - - msg += _( - "Important note: Since you installed " - "Spyder with Anaconda, please don't use pip " - "to update it as that will break your " - "installation." - ) - else: - msg += "pip install --upgrade spyder
" - - msg += _( - "

For more information, visit our " - "installation guide." - ).format(url_i) - - box.setText(msg) - box.show() - elif feedback: - box.setText(_("Spyder is up to date.")) - box.show() - if self.application_update_status: - self.application_update_status.set_no_status() - else: - if self.application_update_status: - self.application_update_status.set_no_status() - - self.set_conf(option, box.is_checked()) - - # Enable check_updates_action after the thread has finished - self.check_updates_action.setDisabled(False) - - # Provide feeback when clicking menu if check on startup is on - self.give_updates_feedback = True - - @Slot() - def check_updates(self, startup=False): - """Check for spyder updates on github releases using a QThread.""" - # Disable check_updates_action while the thread is working - self.check_updates_action.setDisabled(True) - # !!! >>> Disable signals until alpha1 - if is_conda_based_app(): - self.application_update_status.blockSignals(True) - return - # !!! <<< Disable signals until alpha1 - if self.application_update_status: - self.application_update_status.set_status_checking() - - if self.thread_updates is not None: - self.thread_updates.quit() - self.thread_updates.wait() - - self.thread_updates = QThread(None) - self.worker_updates = WorkerUpdates(self, startup=startup) - self.worker_updates.sig_ready.connect(self._check_updates_ready) - self.worker_updates.sig_ready.connect(self.thread_updates.quit) - self.worker_updates.moveToThread(self.thread_updates) - self.thread_updates.started.connect(self.worker_updates.start) - - # Delay starting this check to avoid blocking the main window - # while loading. - # Fixes spyder-ide/spyder#15839 - if startup: - self.updates_timer = QTimer(self) - self.updates_timer.setInterval(60000) - self.updates_timer.setSingleShot(True) - self.updates_timer.timeout.connect(self.thread_updates.start) - self.updates_timer.start() - else: - self.thread_updates.start() - - @Slot(str) - def set_installer_path(self, installer_path): - """Set installer executable path to be run when closing.""" - self.installer_path = installer_path - # ---- Dependencies # ------------------------------------------------------------------------- def _set_dependencies(self): diff --git a/spyder/plugins/application/plugin.py b/spyder/plugins/application/plugin.py index 11c968f195a..503631f9620 100644 --- a/spyder/plugins/application/plugin.py +++ b/spyder/plugins/application/plugin.py @@ -23,7 +23,7 @@ from spyder.api.plugin_registration.decorators import ( on_plugin_available, on_plugin_teardown) from spyder.api.widgets.menus import SpyderMenu, MENU_SEPARATOR -from spyder.config.base import (DEV, get_module_path, get_debug_level, +from spyder.config.base import (get_module_path, get_debug_level, running_under_pytest) from spyder.plugins.application.confpage import ApplicationConfigPage from spyder.plugins.application.container import ( @@ -98,22 +98,8 @@ def on_editor_available(self): editor = self.get_plugin(Plugins.Editor) self.get_container().sig_load_log_file.connect(editor.load) - @on_plugin_available(plugin=Plugins.StatusBar) - def on_statusbar_available(self): - # Add status widget if created - if self.application_update_status: - statusbar = self.get_plugin(Plugins.StatusBar) - statusbar.add_status_widget(self.application_update_status) - # -------------------------- PLUGIN TEARDOWN ------------------------------ - @on_plugin_teardown(plugin=Plugins.StatusBar) - def on_statusbar_teardown(self): - # Remove status widget if created - if self.application_update_status: - statusbar = self.get_plugin(Plugins.StatusBar) - statusbar.remove_status_widget(self.application_update_status.ID) - @on_plugin_teardown(plugin=Plugins.Preferences) def on_preferences_teardown(self): preferences = self.get_plugin(Plugins.Preferences) @@ -147,11 +133,6 @@ def on_mainwindow_visible(self): if not running_under_pytest(): container.compute_dependencies() - # Check for updates - if DEV is None and self.get_conf('check_updates_on_startup'): - container.give_updates_feedback = False - container.check_updates(startup=True) - # Handle DPI scale and window changes to show a restart message. # Don't activate this functionality on macOS because it's being # triggered in the wrong situations. @@ -220,8 +201,7 @@ def _populate_help_menu_support_section(self): mainmenu = self.get_plugin(Plugins.MainMenu) for support_action in [ self.trouble_action, self.report_action, - self.dependencies_action, self.check_updates_action, - self.support_group_action]: + self.dependencies_action, self.support_group_action]: mainmenu.add_item_to_application_menu( support_action, menu_id=ApplicationMenus.Help, @@ -261,7 +241,6 @@ def _depopulate_help_menu_support_section(self): ApplicationActions.SpyderTroubleshootingAction, ConsoleActions.SpyderReportAction, ApplicationActions.SpyderDependenciesAction, - ApplicationActions.SpyderCheckUpdatesAction, ApplicationActions.SpyderSupportAction]: mainmenu.remove_item_from_application_menu( support_action, @@ -414,11 +393,6 @@ def dependencies_action(self): """Show Spyder's Dependencies dialog box.""" return self.get_container().dependencies_action - @property - def check_updates_action(self): - """Check if a new version of Spyder is available.""" - return self.get_container().check_updates_action - @property def support_group_action(self): """Open Spyder's Google support group in the browser.""" @@ -453,7 +427,3 @@ def report_action(self): def debug_logs_menu(self): return self.get_container().get_menu( ApplicationPluginMenus.DebugLogsMenu) - - @property - def application_update_status(self): - return self.get_container().application_update_status diff --git a/spyder/plugins/statusbar/plugin.py b/spyder/plugins/statusbar/plugin.py index 4d7d6ded8c1..f1736040f3d 100644 --- a/spyder/plugins/statusbar/plugin.py +++ b/spyder/plugins/statusbar/plugin.py @@ -46,7 +46,7 @@ class StatusBar(SpyderPluginV2): 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', 'eol_status', 'encoding_status', 'cursor_position_status', 'vcs_status', 'lsp_status', 'completion_status', - 'interpreter_status', 'application_update_status'} + 'interpreter_status'} # ---- SpyderPluginV2 API @staticmethod @@ -216,7 +216,7 @@ def _organize_status_widgets(self): 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', 'eol_status', 'encoding_status', 'cursor_position_status', 'vcs_status', 'lsp_status', 'completion_status', - 'interpreter_status', 'application_update_status'] + 'interpreter_status'] external_left = list(self.EXTERNAL_LEFT_WIDGETS.keys()) # Remove all widgets from the statusbar, except the external right diff --git a/spyder/workers/tests/__init__.py b/spyder/plugins/updatemanager/tests/__init__.py similarity index 100% rename from spyder/workers/tests/__init__.py rename to spyder/plugins/updatemanager/tests/__init__.py diff --git a/spyder/workers/tests/test_update.py b/spyder/plugins/updatemanager/tests/test_update.py similarity index 100% rename from spyder/workers/tests/test_update.py rename to spyder/plugins/updatemanager/tests/test_update.py diff --git a/spyder/workers/updates.py b/spyder/plugins/updatemanager/updates.py similarity index 100% rename from spyder/workers/updates.py rename to spyder/plugins/updatemanager/updates.py diff --git a/spyder/plugins/application/widgets/__init__.py b/spyder/plugins/updatemanager/widgets/__init__.py similarity index 100% rename from spyder/plugins/application/widgets/__init__.py rename to spyder/plugins/updatemanager/widgets/__init__.py diff --git a/spyder/plugins/application/widgets/install.py b/spyder/plugins/updatemanager/widgets/install.py similarity index 100% rename from spyder/plugins/application/widgets/install.py rename to spyder/plugins/updatemanager/widgets/install.py diff --git a/spyder/plugins/application/widgets/status.py b/spyder/plugins/updatemanager/widgets/status.py similarity index 100% rename from spyder/plugins/application/widgets/status.py rename to spyder/plugins/updatemanager/widgets/status.py diff --git a/spyder/workers/__init__.py b/spyder/workers/__init__.py deleted file mode 100644 index 839eae7ce43..00000000000 --- a/spyder/workers/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- From e45b82673af48dd3a81fdde1ddc3077b8a69265b Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Sun, 10 Dec 2023 20:14:52 -0800 Subject: [PATCH 02/22] Rename plugin files * updates.py -> workers.py * widgets/install.py -> widgets/update.py * tests/test_update.py -> tests/test_update_manager.py --- .../tests/{test_update.py => test_update_manager.py} | 0 spyder/plugins/updatemanager/widgets/{install.py => update.py} | 0 spyder/plugins/updatemanager/{updates.py => workers.py} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename spyder/plugins/updatemanager/tests/{test_update.py => test_update_manager.py} (100%) rename spyder/plugins/updatemanager/widgets/{install.py => update.py} (100%) rename spyder/plugins/updatemanager/{updates.py => workers.py} (100%) diff --git a/spyder/plugins/updatemanager/tests/test_update.py b/spyder/plugins/updatemanager/tests/test_update_manager.py similarity index 100% rename from spyder/plugins/updatemanager/tests/test_update.py rename to spyder/plugins/updatemanager/tests/test_update_manager.py diff --git a/spyder/plugins/updatemanager/widgets/install.py b/spyder/plugins/updatemanager/widgets/update.py similarity index 100% rename from spyder/plugins/updatemanager/widgets/install.py rename to spyder/plugins/updatemanager/widgets/update.py diff --git a/spyder/plugins/updatemanager/updates.py b/spyder/plugins/updatemanager/workers.py similarity index 100% rename from spyder/plugins/updatemanager/updates.py rename to spyder/plugins/updatemanager/workers.py From 27f463b20c1960ade078542dbfba1b34e6407539 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Mon, 30 Oct 2023 21:24:23 -0700 Subject: [PATCH 03/22] Initial commit for Update Manager Plugin --- setup.py | 1 + spyder/api/plugins/enum.py | 1 + spyder/config/main.py | 5 + spyder/plugins/statusbar/plugin.py | 4 +- spyder/plugins/updatemanager/__init__.py | 12 + spyder/plugins/updatemanager/api.py | 11 + spyder/plugins/updatemanager/confpage.py | 42 ++ spyder/plugins/updatemanager/container.py | 105 +++ spyder/plugins/updatemanager/plugin.py | 131 ++++ .../plugins/updatemanager/scripts/install.bat | 88 +++ .../plugins/updatemanager/scripts/install.sh | 60 ++ .../plugins/updatemanager/tests/__init__.py | 2 +- .../tests/test_update_manager.py | 174 +++-- .../plugins/updatemanager/widgets/__init__.py | 2 +- .../plugins/updatemanager/widgets/status.py | 118 ++- .../plugins/updatemanager/widgets/update.py | 709 ++++++++++++------ spyder/plugins/updatemanager/workers.py | 260 +++---- 17 files changed, 1206 insertions(+), 519 deletions(-) create mode 100644 spyder/plugins/updatemanager/__init__.py create mode 100644 spyder/plugins/updatemanager/api.py create mode 100644 spyder/plugins/updatemanager/confpage.py create mode 100644 spyder/plugins/updatemanager/container.py create mode 100644 spyder/plugins/updatemanager/plugin.py create mode 100644 spyder/plugins/updatemanager/scripts/install.bat create mode 100755 spyder/plugins/updatemanager/scripts/install.sh diff --git a/setup.py b/setup.py index 3286d73728f..f7f5dc3b908 100644 --- a/setup.py +++ b/setup.py @@ -312,6 +312,7 @@ def run(self): 'switcher = spyder.plugins.switcher.plugin:Switcher', 'toolbar = spyder.plugins.toolbar.plugin:Toolbar', 'tours = spyder.plugins.tours.plugin:Tours', + 'update_manager = spyder.plugins.updatemanager.plugin:UpdateManager', 'variable_explorer = spyder.plugins.variableexplorer.plugin:VariableExplorer', 'workingdir = spyder.plugins.workingdirectory.plugin:WorkingDirectory', ] diff --git a/spyder/api/plugins/enum.py b/spyder/api/plugins/enum.py index a74cccdfa94..9d68767ed33 100644 --- a/spyder/api/plugins/enum.py +++ b/spyder/api/plugins/enum.py @@ -41,6 +41,7 @@ class Plugins: Switcher = 'switcher' Toolbar = "toolbar" Tours = 'tours' + UpdateManager = 'update_manager' VariableExplorer = 'variable_explorer' WorkingDirectory = 'workingdir' diff --git a/spyder/config/main.py b/spyder/config/main.py index 2a8480c3390..c4cf64d951a 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -83,6 +83,11 @@ 'report_error/remember_token': False, 'show_dpi_message': True, }), + ('update_manager', + { + 'check_updates_on_startup': True, + 'check_stable_only': True, + }), ('toolbar', { 'enable': True, diff --git a/spyder/plugins/statusbar/plugin.py b/spyder/plugins/statusbar/plugin.py index f1736040f3d..bd3bd86e4e0 100644 --- a/spyder/plugins/statusbar/plugin.py +++ b/spyder/plugins/statusbar/plugin.py @@ -46,7 +46,7 @@ class StatusBar(SpyderPluginV2): 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', 'eol_status', 'encoding_status', 'cursor_position_status', 'vcs_status', 'lsp_status', 'completion_status', - 'interpreter_status'} + 'interpreter_status', 'update_manager_status'} # ---- SpyderPluginV2 API @staticmethod @@ -216,7 +216,7 @@ def _organize_status_widgets(self): 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', 'eol_status', 'encoding_status', 'cursor_position_status', 'vcs_status', 'lsp_status', 'completion_status', - 'interpreter_status'] + 'interpreter_status', 'update_manager_status'] external_left = list(self.EXTERNAL_LEFT_WIDGETS.keys()) # Remove all widgets from the statusbar, except the external right diff --git a/spyder/plugins/updatemanager/__init__.py b/spyder/plugins/updatemanager/__init__.py new file mode 100644 index 00000000000..2079205facb --- /dev/null +++ b/spyder/plugins/updatemanager/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +spyder.plugins.updatemanager +============================ + +Application Update Manager Plugin. +""" diff --git a/spyder/plugins/updatemanager/api.py b/spyder/plugins/updatemanager/api.py new file mode 100644 index 00000000000..8365533093f --- /dev/null +++ b/spyder/plugins/updatemanager/api.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Spyder update manager API. +""" + +from spyder.plugins.updatemanager.container import UpdateManagerActions diff --git a/spyder/plugins/updatemanager/confpage.py b/spyder/plugins/updatemanager/confpage.py new file mode 100644 index 00000000000..e1b73d6f5dc --- /dev/null +++ b/spyder/plugins/updatemanager/confpage.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +General entry in Preferences. + +For historical reasons (dating back to Spyder 2) the main class here is called +`MainConfigPage` and its associated entry in our config system is called +`main`. +""" + +from qtpy.QtWidgets import QGroupBox, QVBoxLayout + +from spyder.config.base import _ +from spyder.api.preferences import PluginConfigPage + + +class UpdateManagerConfigPage(PluginConfigPage): + def setup_page(self): + """Setup config page widgets and options.""" + updates_group = QGroupBox(_("Updates")) + check_updates = self.create_checkbox( + _("Check for updates on startup"), + 'check_updates_on_startup' + ) + stable_only = self.create_checkbox( + _("Check for stable releases only"), + 'check_stable_only' + ) + + updates_layout = QVBoxLayout() + updates_layout.addWidget(check_updates) + updates_layout.addWidget(stable_only) + updates_group.setLayout(updates_layout) + + vlayout = QVBoxLayout() + vlayout.addWidget(updates_group) + vlayout.addStretch(1) + self.setLayout(vlayout) diff --git a/spyder/plugins/updatemanager/container.py b/spyder/plugins/updatemanager/container.py new file mode 100644 index 00000000000..dc94cd5fa8f --- /dev/null +++ b/spyder/plugins/updatemanager/container.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Container Widget. + +Holds references for base actions in the Application of Spyder. +""" + +# Standard library imports +import logging + +# Third party imports +from qtpy.QtCore import Slot + +# Local imports +from spyder.api.translations import _ +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.plugins.updatemanager.widgets.status import UpdateManagerStatus +from spyder.plugins.updatemanager.widgets.update import ( + UpdateManagerWidget, NO_STATUS +) +from spyder.utils.qthelpers import DialogManager + +# Logger setup +logger = logging.getLogger(__name__) + + +# Actions +class UpdateManagerActions: + SpyderCheckUpdateAction = "spyder_check_update_action" + + +class UpdateManagerContainer(PluginMainContainer): + + def __init__(self, name, plugin, parent=None): + super().__init__(name, plugin, parent) + + self.install_on_close = False + + def setup(self): + self.dialog_manager = DialogManager() + self.update_manager = UpdateManagerWidget(parent=self) + self.update_manager_status = UpdateManagerStatus(parent=self) + + # Actions + self.check_update_action = self.create_action( + UpdateManagerActions.SpyderCheckUpdateAction, + _("Check for updates..."), + triggered=self.start_check_update + ) + + # Signals + self.update_manager.sig_set_status.connect(self.set_status) + self.update_manager.sig_disable_actions.connect( + self.check_update_action.setDisabled) + self.update_manager.sig_block_status_signals.connect( + self.update_manager_status.blockSignals) + self.update_manager.sig_download_progress.connect( + self.update_manager_status.set_download_progress) + self.update_manager.sig_install_on_close.connect( + self.set_install_on_close) + self.update_manager.sig_quit_requested.connect(self.sig_quit_requested) + + self.update_manager_status.sig_check_update.connect( + self.start_check_update) + self.update_manager_status.sig_start_update.connect(self.start_update) + self.update_manager_status.sig_show_progress_dialog.connect( + self.update_manager.show_progress_dialog) + + self.set_status(NO_STATUS) + + def update_actions(self): + pass + + def set_status(self, status, latest_version=None): + """Set Update Manager status""" + self.update_manager_status.set_value(status) + + @Slot() + def start_check_update(self, startup=False): + """Check for spyder updates.""" + self.update_manager.start_check_update(startup=startup) + + @Slot() + def start_update(self): + """Start the update process""" + self.update_manager.start_update() + + def set_install_on_close(self, install_on_close): + """Set whether start install on close.""" + self.install_on_close = install_on_close + + def on_close(self): + """To call from Spyder when the plugin is closed.""" + self.update_manager.cleanup_threads() + + # Run installer after Spyder is closed + if self.install_on_close: + self.update_manager.start_install() + + self.dialog_manager.close_all() diff --git a/spyder/plugins/updatemanager/plugin.py b/spyder/plugins/updatemanager/plugin.py new file mode 100644 index 00000000000..23ed346abeb --- /dev/null +++ b/spyder/plugins/updatemanager/plugin.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Update Manager Plugin. +""" + +# Local imports +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.translations import _ +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.config.base import DEV +from spyder.plugins.updatemanager.confpage import UpdateManagerConfigPage +from spyder.plugins.updatemanager.container import (UpdateManagerActions, + UpdateManagerContainer) +from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections + + +class UpdateManager(SpyderPluginV2): + NAME = 'update_manager' + REQUIRES = [Plugins.Preferences] + OPTIONAL = [Plugins.Help, Plugins.MainMenu, Plugins.Shortcuts, + Plugins.StatusBar] + CONTAINER_CLASS = UpdateManagerContainer + CONF_SECTION = 'update_manager' + CONF_FILE = False + CONF_WIDGET_CLASS = UpdateManagerConfigPage + CAN_BE_DISABLED = False + + @staticmethod + def get_name(): + return _('Update Manager') + + @classmethod + def get_icon(cls): + return cls.create_icon('genprefs') + + @staticmethod + def get_description(): + return _('Manage application updates.') + + # --------------------- PLUGIN INITIALIZATION ----------------------------- + + def on_initialize(self): + pass + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + # Register conf page + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + if self.is_plugin_enabled(Plugins.Shortcuts): + if self.is_plugin_available(Plugins.Shortcuts): + self._populate_help_menu() + else: + self._populate_help_menu() + + @on_plugin_available(plugin=Plugins.StatusBar) + def on_statusbar_available(self): + # Add status widget + statusbar = self.get_plugin(Plugins.StatusBar) + statusbar.add_status_widget(self.update_manager_status) + + # -------------------------- PLUGIN TEARDOWN ------------------------------ + + @on_plugin_teardown(plugin=Plugins.StatusBar) + def on_statusbar_teardown(self): + # Remove status widget if created + statusbar = self.get_plugin(Plugins.StatusBar) + statusbar.remove_status_widget(self.update_manager_status.ID) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + self._depopulate_help_menu() + + def on_close(self, _unused=True): + # The container is closed directly in the plugin registry + pass + + def on_mainwindow_visible(self): + """Actions after the mainwindow in visible.""" + container = self.get_container() + + # Check for updates on startup + if DEV is None and self.get_conf('check_updates_on_startup'): + container.start_check_updates(startup=True) + + # ---- Private API + # ------------------------------------------------------------------------ + def _populate_help_menu(self): + """Add update action and menu to the Help menu.""" + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.add_item_to_application_menu( + self.check_update_action, + menu_id=ApplicationMenus.Help, + section=HelpMenuSections.Support, + before_section=HelpMenuSections.ExternalDocumentation) + + @property + def _window(self): + return self.main.window() + + def _depopulate_help_menu(self): + """Remove update action from the Help main menu.""" + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + UpdateManagerActions.SpyderCheckUpdateAction, + menu_id=ApplicationMenus.Help) + + # ---- Public API + # ------------------------------------------------------------------------ + @property + def check_update_action(self): + """Check if a new version of Spyder is available.""" + return self.get_container().check_update_action + + @property + def update_manager_status(self): + return self.get_container().update_manager_status diff --git a/spyder/plugins/updatemanager/scripts/install.bat b/spyder/plugins/updatemanager/scripts/install.bat new file mode 100644 index 00000000000..ad26ae98f08 --- /dev/null +++ b/spyder/plugins/updatemanager/scripts/install.bat @@ -0,0 +1,88 @@ +:: This script updates or installs a new version of Spyder +@echo off + +:: Create variables from arguments +:parse +IF "%~1"=="" GOTO endparse +IF "%~1"=="-p" set prefix=%2 & SHIFT +IF "%~1"=="-i" set install_exe=%2 & SHIFT +IF "%~1"=="-c" set conda=%2 & SHIFT +IF "%~1"=="-v" set spy_ver=%2 & SHIFT +SHIFT +GOTO parse +:endparse + +:: Enforce encoding +chcp 65001>nul + +IF not "%conda%"=="" IF not "%spy_ver%"=="" ( + call :update_subroutine + call :launch_spyder + goto exit +) + +IF not "%install_exe%"=="" ( + call :install_subroutine + goto exit +) + +:exit +exit %ERRORLEVEL% + +:install_subroutine + echo Installing Spyder from: %install_exe% + + call :wait_for_spyder_quit + + :: Uninstall Spyder + for %%I in ("%prefix%\..\..") do set "conda_root=%%~fI" + + echo Install will proceed after the current Spyder version is uninstalled. + start %conda_root%\Uninstall-Spyder.exe + + :: Must wait for uninstaller to appear on tasklist + :wait_for_uninstall_start + tasklist /fi "ImageName eq Un_A.exe" /fo csv 2>NUL | find /i "Un_A.exe">NUL + IF "%ERRORLEVEL%"=="1" ( + timeout /t 1 /nobreak > nul + goto wait_for_uninstall_start + ) + echo Uninstall in progress... + + :wait_for_uninstall + timeout /t 1 /nobreak > nul + tasklist /fi "ImageName eq Un_A.exe" /fo csv 2>NUL | find /i "Un_A.exe">NUL + IF "%ERRORLEVEL%"=="0" goto wait_for_uninstall + echo Uninstall complete. + + start %install_exe% + goto :EOF + +:update_subroutine + echo Updating Spyder + + call :wait_for_spyder_quit + + %conda% install -p %prefix% -c conda-forge --override-channels -y spyder=%spy_ver% + set /P CONT=Press any key to exit... + goto :EOF + +:wait_for_spyder_quit + echo Waiting for Spyder to quit... + :loop + tasklist /fi "ImageName eq spyder.exe" /fo csv 2>NUL | find /i "spyder.exe">NUL + IF "%ERRORLEVEL%"=="0" ( + timeout /t 1 /nobreak > nul + goto loop + ) + echo Spyder is quit. + goto :EOF + +:launch_spyder + echo %prefix% | findstr /b "%USERPROFILE%" > nul && ( + set shortcut_root=%APPDATA% + ) || ( + set shortcut_root=%ALLUSERSPROFILE% + ) + start "" /B "%shortcut_root%\Microsoft\Windows\Start Menu\Programs\spyder\Spyder.lnk" + goto :EOF diff --git a/spyder/plugins/updatemanager/scripts/install.sh b/spyder/plugins/updatemanager/scripts/install.sh new file mode 100755 index 00000000000..381da1cc44e --- /dev/null +++ b/spyder/plugins/updatemanager/scripts/install.sh @@ -0,0 +1,60 @@ +#!/bin/bash -i +set -e +unset HISTFILE # Do not write to history with interactive shell + +while getopts "i:c:p:v:" option; do + case "$option" in + (i) install_exe=$OPTARG ;; + (c) conda=$OPTARG ;; + (p) prefix=$OPTARG ;; + (v) spy_ver=$OPTARG ;; + esac +done +shift $(($OPTIND - 1)) + +update_spyder(){ + $conda install -p $prefix -c conda-forge --override-channels -y spyder=$spy_ver + read -p "Press any key to exit..." +} + +launch_spyder(){ + if [[ "$OSTYPE" = "darwin"* ]]; then + shortcut=/Applications/Spyder.app + [[ "$prefix" = "$HOME"* ]] && open -a $HOME$shortcut || open -a $shortcut + elif [[ -n "$(which gtk-launch)" ]]; then + gtk-launch spyder_spyder + else + nohup $prefix/bin/spyder &>/dev/null & + fi +} + +install_spyder(){ + # First uninstall Spyder + uninstall_script="$prefix/../../uninstall-spyder.sh" + if [[ -f "$uninstall_script" ]]; then + echo "Uninstalling Spyder..." + $uninstall_script + fi + + # Run installer + [[ "$OSTYPE" = "darwin"* ]] && open $install_exe || sh $install_exe +} + +while [[ $(pgrep spyder 2> /dev/null) ]]; do + echo "Waiting for Spyder to quit..." + sleep 1 +done + +echo "Spyder quit." + +if [[ -e "$conda" && -d "$prefix" && -n "$spy_ver" ]]; then + update_spyder + launch_spyder +elif [[ -e "$install_exe" ]]; then + install_spyder +fi + +if [[ "$OSTYPE" = "darwin"* ]]; then + # Close the Terminal window that was opened for this process + osascript -e 'tell application "Terminal" to close first window' & +fi diff --git a/spyder/plugins/updatemanager/tests/__init__.py b/spyder/plugins/updatemanager/tests/__init__.py index 92b07bcf58c..f984ad47da2 100644 --- a/spyder/plugins/updatemanager/tests/__init__.py +++ b/spyder/plugins/updatemanager/tests/__init__.py @@ -6,4 +6,4 @@ # (see LICENSE.txt for details) # ----------------------------------------------------------------------------- -"""Tests for workers.""" +"""Tests for Update Manager Plugin.""" diff --git a/spyder/plugins/updatemanager/tests/test_update_manager.py b/spyder/plugins/updatemanager/tests/test_update_manager.py index eaaf9fc3f52..0e57ed91c7e 100644 --- a/spyder/plugins/updatemanager/tests/test_update_manager.py +++ b/spyder/plugins/updatemanager/tests/test_update_manager.py @@ -4,108 +4,144 @@ # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) -import sys +import os +import logging import pytest -from spyder.workers.updates import WorkerUpdates +from spyder.config.base import running_in_ci +from spyder.plugins.updatemanager import workers +from spyder.plugins.updatemanager.workers import WorkerUpdate +from spyder.plugins.updatemanager.widgets import update +from spyder.plugins.updatemanager.widgets.update import UpdateManagerWidget + +logging.basicConfig() + + +@pytest.fixture(autouse=True) +def capture_logging(caplog): + caplog.set_level(10, "spyder.plugins.updatemanager") + + +@pytest.fixture +def worker(): + return WorkerUpdate(None) + + +# ---- Test WorkerUpdate + +@pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"]) +def test_updates_appenv(qtbot, mocker, version): + """ + Test whether or not we offer updates for our installers according to the + current Spyder version. + + Uses UpdateManagerWidget in order to also test QThread. + """ + mocker.patch.object(update, "__version__", new=version) + # Do not execute start_update after check_update completes. + mocker.patch.object( + UpdateManagerWidget, "start_update", new=lambda x: None) + mocker.patch.object(workers, "__version__", new=version) + mocker.patch.object(workers, "is_anaconda", return_value=True) + mocker.patch.object(workers, "is_conda_based_app", return_value=True) + mocker.patch.object( + workers, "get_spyder_conda_channel", + return_value=("conda-forge", "https://conda.anaconda.org/conda-forge")) + + um = UpdateManagerWidget(None) + um.start_check_update() + qtbot.waitUntil(um.update_thread.isFinished) + + _update = um.update_worker.update_available + assert _update if version.split('.')[0] == '1' else not _update + # assert len(worker.releases) > 1 + assert len(um.update_worker.releases) > 1 -@pytest.mark.parametrize("is_anaconda", [True, False]) -@pytest.mark.parametrize("is_pypi", [True, False]) @pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"]) @pytest.mark.parametrize( - "spyder_conda_channel", [ + "channel", [ ("pkgs/main", "https://repo.anaconda.com/pkgs/main"), - ("conda-forge", "https://conda.anaconda.org/conda-forge") + ("conda-forge", "https://conda.anaconda.org/conda-forge"), + ("pypi", "https://conda.anaconda.org/pypi") ] ) -def test_updates(qtbot, mocker, is_anaconda, is_pypi, version, - spyder_conda_channel): +def test_updates_condaenv(qtbot, worker, mocker, version, channel): """ - Test whether or not we offer updates for Anaconda and PyPI according to the + Test whether or not we offer updates for our installers according to the current Spyder version. """ - mocker.patch( - "spyder.workers.updates.is_anaconda", - return_value=is_anaconda + mocker.patch.object(workers, "__version__", new=version) + mocker.patch.object(workers, "is_anaconda", return_value=True) + mocker.patch.object(workers, "is_conda_based_app", return_value=False) + mocker.patch.object( + workers, "get_spyder_conda_channel", return_value=channel ) - if is_anaconda: - if is_pypi: - channel = ("pypi", "https://conda.anaconda.org/pypi") - else: - channel = spyder_conda_channel + with qtbot.waitSignal(worker.sig_ready, timeout=5000): + worker.start() - mocker.patch( - "spyder.workers.updates.get_spyder_conda_channel", - return_value=channel - ) - - worker = WorkerUpdates(None, False, version=version) - worker.start() - - update = worker.update_available - assert update if version.split('.')[0] == '1' else not update + _update = worker.update_available + assert _update if version.split('.')[0] == '1' else not _update assert len(worker.releases) == 1 -@pytest.mark.skipif(sys.platform == 'darwin', reason="Fails frequently on Mac") @pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"]) -def test_updates_for_installers(qtbot, mocker, version): +def test_updates_pipenv(qtbot, worker, mocker, version): """ - Test whether or not we offer updates for our installers according to the - current Spyder version. + Test updates for pip installed Spyder """ - mocker.patch("spyder.workers.updates.is_anaconda", return_value=False) - mocker.patch("spyder.workers.updates.is_conda_based_app", - return_value=True) + mocker.patch.object(workers, "__version__", new=version) + mocker.patch.object(workers, "is_anaconda", return_value=False) + mocker.patch.object(workers, "is_conda_based_app", return_value=False) + mocker.patch.object( + workers, "get_spyder_conda_channel", + return_value=("pypi", "https://conda.anaconda.org/pypi") + ) - worker = WorkerUpdates(None, False, version=version) - worker.start() + with qtbot.waitSignal(worker.sig_ready, timeout=5000): + worker.start() - update = worker.update_available - assert update if version.split('.')[0] == '1' else not update - assert len(worker.releases) > 1 + _update = worker.update_available + assert _update if version.split('.')[0] == '1' else not _update + assert len(worker.releases) == 1 -def test_no_update_development(qtbot, mocker): - """Test we don't offer updates for development versions.""" - mocker.patch( - "spyder.workers.updates.get_spyder_conda_channel", - return_value=("pypi", "https://conda.anaconda.org/pypi") - ) +@pytest.mark.parametrize("release", ["4.0.1", "4.0.1a1"]) +@pytest.mark.parametrize("version", ["4.0.0a1", "4.0.0"]) +@pytest.mark.parametrize("stable_only", [True, False]) +def test_update_non_stable(qtbot, mocker, version, release, stable_only): + """Test we offer unstable updates.""" + mocker.patch.object(workers, "__version__", new=version) - worker = WorkerUpdates(None, False, version="3.3.2.dev0", - releases=['3.3.1']) - worker.start() - assert not worker.update_available + worker = WorkerUpdate(stable_only) + worker.releases = [release] + worker._check_update_available() + _update = worker.update_available + assert not _update if "a" in release and stable_only else _update -def test_update_pre_to_pre(qtbot, mocker): - """Test we offer updates between prereleases.""" - mocker.patch( - "spyder.workers.updates.get_spyder_conda_channel", - return_value=("pypi", "https://conda.anaconda.org/pypi") - ) - worker = WorkerUpdates(None, False, version="4.0.0a1", - releases=['4.0.0b5']) - worker.start() - assert worker.update_available +# ---- Test WorkerDownloadInstaller +@pytest.mark.skipif(not running_in_ci(), reason="Download only in CI") +def test_download(qtbot, mocker): + """ + Test download spyder installer. + Uses UpdateManagerWidget in order to also test QThread. + """ + um = UpdateManagerWidget(None) + um.latest_release = "6.0.0a2" + um._set_installer_path() + # Do not execute _start_install after download completes. + mocker.patch.object( + UpdateManagerWidget, "_confirm_install", new=lambda x: None) -def test_update_pre_to_final(qtbot, mocker): - """Test we offer updates from prereleases to the final versions.""" - mocker.patch( - "spyder.workers.updates.get_spyder_conda_channel", - return_value=("pypi", "https://conda.anaconda.org/pypi") - ) + um._start_download() + qtbot.waitUntil(um.download_thread.isFinished, timeout=60000) - worker = WorkerUpdates(None, False, version="4.0.0b3", - releases=['4.0.0']) - worker.start() - assert worker.update_available + assert os.path.exists(um.installer_path) if __name__ == "__main__": diff --git a/spyder/plugins/updatemanager/widgets/__init__.py b/spyder/plugins/updatemanager/widgets/__init__.py index 5a3f944db10..e35c9c7b676 100644 --- a/spyder/plugins/updatemanager/widgets/__init__.py +++ b/spyder/plugins/updatemanager/widgets/__init__.py @@ -5,4 +5,4 @@ # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) -"""Widgets for the Application plugin.""" +"""Widgets for the Update Manager plugin.""" diff --git a/spyder/plugins/updatemanager/widgets/status.py b/spyder/plugins/updatemanager/widgets/status.py index 051ed90b4c5..35fff755a53 100644 --- a/spyder/plugins/updatemanager/widgets/status.py +++ b/spyder/plugins/updatemanager/widgets/status.py @@ -20,10 +20,9 @@ from spyder.api.translations import _ from spyder.api.widgets.menus import SpyderMenu from spyder.api.widgets.status import StatusBarWidget -from spyder.config.base import is_conda_based_app -from spyder.plugins.application.widgets.install import ( - UpdateInstallerDialog, NO_STATUS, DOWNLOADING_INSTALLER, INSTALLING, - PENDING, CHECKING) +from spyder.plugins.updatemanager.widgets.update import ( + NO_STATUS, DOWNLOADING_INSTALLER, PENDING, + CHECKING, DOWNLOAD_FINISHED, INSTALL_ON_CLOSE) from spyder.utils.icon_manager import ima from spyder.utils.qthelpers import add_actions, create_action @@ -32,24 +31,25 @@ logger = logging.getLogger(__name__) -class ApplicationUpdateStatus(StatusBarWidget): - """Status bar widget for application update status.""" - BASE_TOOLTIP = _("Application update status") - ID = 'application_update_status' +class UpdateManagerStatus(StatusBarWidget): + """Status bar widget for update manager.""" + BASE_TOOLTIP = _("Update manager status") + ID = 'update_manager_status' - sig_check_for_updates_requested = Signal() - """ - Signal to request checking for updates. - """ + sig_check_update = Signal() + """Signal to request checking for updates.""" - sig_install_on_close_requested = Signal(str) + sig_start_update = Signal() + """Signal to start update process""" + + sig_show_progress_dialog = Signal(bool) """ - Signal to request running the downloaded installer on close. + Signal to show progress dialog Parameters ---------- - installer_path: str - Path to instal + show: bool + True to show, False to hide """ CUSTOM_WIDGET_CLASS = QLabel @@ -59,9 +59,6 @@ def __init__(self, parent): self.tooltip = self.BASE_TOOLTIP super().__init__(parent, show_spinner=True) - # Installation dialog - self.installer = UpdateInstallerDialog(self) - # Check for updates action menu self.menu = SpyderMenu(self) @@ -70,32 +67,24 @@ def __init__(self, parent): self.custom_widget.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # Signals - self.sig_clicked.connect(self.show_installation_dialog_or_menu) - - # Installer widget signals - self.installer.sig_download_progress.connect( - self.set_download_progress) - self.installer.sig_installation_status.connect( - self.set_value) - self.installer.sig_install_on_close_requested.connect( - self.sig_install_on_close_requested) + self.sig_clicked.connect(self.show_dialog_or_menu) def set_value(self, value): - """Return update installation state.""" - if value == DOWNLOADING_INSTALLER or value == INSTALLING: - self.tooltip = _("Update installation will continue in the " - "background.\n" - "Click here to show the installation " - "dialog again.") - if value == DOWNLOADING_INSTALLER: - self.spinner.hide() - self.spinner.stop() - self.custom_widget.show() - else: + """Set update manager status.""" + if value == DOWNLOADING_INSTALLER: + self.tooltip = _( + "Downloading update will continue in the background.\n" + "Click here to show the download dialog again." + ) + self.spinner.hide() + self.spinner.stop() + self.custom_widget.show() + elif value == CHECKING: + self.tooltip = self.BASE_TOOLTIP + if self.custom_widget: self.custom_widget.hide() - self.spinner.show() - self.spinner.start() - self.installer.show() + self.spinner.show() + self.spinner.start() elif value == PENDING: self.tooltip = value self.custom_widget.hide() @@ -111,7 +100,7 @@ def set_value(self, value): self.setVisible(True) self.update_tooltip() value = f"Spyder: {value}" - logger.debug(f"Application Update Status: {value}") + logger.debug(f"Update manager status: {value}") super().set_value(value) def get_tooltip(self): @@ -121,47 +110,24 @@ def get_tooltip(self): def get_icon(self): return ima.icon('spyder_about') - def start_installation(self, latest_release): - self.installer.start_installation(latest_release) - - def set_download_progress(self, current_value, total): - percentage_progress = 0 - if total > 0: - percentage_progress = round((current_value/total) * 100) - self.custom_widget.setText(f"{percentage_progress}%") - - def set_status_pending(self, latest_release): - self.set_value(PENDING) - self.installer.save_latest_release(latest_release) - - def set_status_checking(self): - self.set_value(CHECKING) - self.spinner.show() - self.spinner.start() - - def set_no_status(self): - self.set_value(NO_STATUS) - self.spinner.hide() - self.spinner.stop() + def set_download_progress(self, percent_progress): + """Set download progress in status bar""" + self.custom_widget.setText(f"{percent_progress}%") @Slot() - def show_installation_dialog_or_menu(self): - """Show installation dialog or menu.""" + def show_dialog_or_menu(self): + """Show download dialog or status bar menu.""" value = self.value.split(":")[-1].strip() - if ( - self.tooltip != self.BASE_TOOLTIP - and value != PENDING - and is_conda_based_app() - ): - self.installer.show() - elif value == PENDING and is_conda_based_app(): - self.installer.continue_installation() + if value == DOWNLOADING_INSTALLER: + self.sig_show_progress_dialog.emit(True) + elif value in (PENDING, DOWNLOAD_FINISHED, INSTALL_ON_CLOSE): + self.sig_start_update.emit() elif value == NO_STATUS: self.menu.clear() check_for_updates_action = create_action( self, text=_("Check for updates..."), - triggered=self.sig_check_for_updates_requested.emit + triggered=self.sig_check_update.emit ) add_actions(self.menu, [check_for_updates_action]) rect = self.contentsRect() diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py index 4d05efe25e4..a3ecc3d4bc5 100644 --- a/spyder/plugins/updatemanager/widgets/update.py +++ b/spyder/plugins/updatemanager/widgets/update.py @@ -4,25 +4,32 @@ # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) -"""Update installation widgets.""" +"""Update Manager widgets.""" # Standard library imports import logging import os +import os.path as osp +import sys import subprocess +from tempfile import gettempdir +import platform # Third-party imports -from qtpy.QtCore import Qt, QThread, Signal -from qtpy.QtWidgets import (QDialog, QHBoxLayout, QMessageBox, - QLabel, QProgressBar, QPushButton, QVBoxLayout, - QWidget) +from packaging.version import parse +from qtpy.QtCore import Qt, QThread, QTimer, Signal +from qtpy.QtWidgets import QMessageBox, QWidget, QProgressBar, QPushButton # Local imports from spyder import __version__ +from spyder.api.config.mixins import SpyderConfigurationAccessor from spyder.api.translations import _ from spyder.config.base import is_conda_based_app -from spyder.utils.icon_manager import ima -from spyder.workers.updates import WorkerDownloadInstaller +from spyder.config.utils import is_anaconda +from spyder.plugins.updatemanager.workers import (WorkerUpdate, + WorkerDownloadInstaller) +from spyder.utils.conda import find_conda, is_anaconda_pkg +from spyder.widgets.helperwidgets import MessageCheckBox # Logger setup logger = logging.getLogger(__name__) @@ -36,6 +43,7 @@ PENDING = _("Update available") CHECKING = _("Checking for updates") CANCELLED = _("Cancelled update") +INSTALL_ON_CLOSE = _("Install on close") INSTALL_INFO_MESSAGES = { DOWNLOADING_INSTALLER: _("Downloading Spyder {version}"), @@ -44,87 +52,52 @@ FINISHED: _("Finished installing Spyder {version}"), PENDING: _("Spyder {version} available to download"), CHECKING: _("Checking for new Spyder version"), - CANCELLED: _("Spyder update cancelled") + CANCELLED: _("Spyder update cancelled"), + INSTALL_ON_CLOSE: _("Install Spyder {version} on close") } +HEADER = _("

Spyder {} is available!


") +URL_I = 'https://docs.spyder-ide.org/current/installation.html' +TMPDIR = osp.join(gettempdir(), 'spyder') -class UpdateInstallation(QWidget): - """Update progress installation widget.""" - def __init__(self, parent): - super().__init__(parent) - action_layout = QVBoxLayout() - progress_layout = QHBoxLayout() - self._progress_widget = QWidget(self) - self._progress_widget.setFixedHeight(50) - self._progress_bar = QProgressBar(self) - self._progress_bar.setFixedWidth(180) - self.cancel_button = QPushButton() - self.cancel_button.setIcon(ima.icon('DialogCloseButton')) - self.cancel_button.setFixedHeight(25) - self.cancel_button.setFixedWidth(25) - progress_layout.addWidget(self._progress_bar, alignment=Qt.AlignLeft) - progress_layout.addWidget(self.cancel_button) - progress_layout.setAlignment(Qt.AlignVCenter) - self._progress_widget.setLayout(progress_layout) - - self._progress_label = QLabel(_('Downloading')) - - self.install_info = QLabel( - _("Downloading Spyder update
")) - - button_layout = QHBoxLayout() - self.ok_button = QPushButton(_('OK')) - button_layout.addStretch() - button_layout.addWidget(self.ok_button) - button_layout.addStretch() - action_layout.addStretch() - action_layout.addWidget(self._progress_label) - action_layout.addWidget(self._progress_widget) - action_layout.addWidget(self.install_info) - action_layout.addSpacing(10) - action_layout.addLayout(button_layout) - action_layout.addStretch() - - # Layout - general_layout = QHBoxLayout() - general_layout.addLayout(action_layout) - - self.setLayout(general_layout) - - def update_installation_status(self, status, latest_version): - """Update installation status (downloading, installing, finished).""" - self._progress_label.setText(status) - self.install_info.setText(INSTALL_INFO_MESSAGES[status].format( - version=latest_version)) - if status == INSTALLING: - self._progress_bar.setRange(0, 0) - self.cancel_button.setEnabled(False) - - def update_installation_progress(self, current_value, total): - """Update installation progress bar.""" - self._progress_bar.setMaximum(total) - self._progress_bar.setValue(current_value) +class UpdateManagerWidget(QWidget, SpyderConfigurationAccessor): + """Check for updates widget""" + + sig_disable_actions = Signal(bool) + """ + Signal to disable plugin actions during check for update + + Parameters + ---------- + disable: bool + True to disable, False to re-enable + """ + sig_block_status_signals = Signal(bool) + """ + Signal to block signals from update manager status during + check for update -class UpdateInstallerDialog(QDialog): - """Update installer dialog.""" + Parameters + ---------- + block: bool + True to block, False to unblock + """ - sig_download_progress = Signal(int, int) + sig_download_progress = Signal(int) """ - Signal to get the download progress. + Signal to send the download progress. Parameters ---------- - current_value: int - Size of the data downloaded until now. - total: int - Total size of the file expected to be downloaded. + percent_progress: int + Percent of the data downloaded until now. """ - sig_installation_status = Signal(str, str) + sig_set_status = Signal(str, str) """ - Signal to get the current status of the update installation. + Signal to set the status of update manager. Parameters ---------- @@ -134,184 +107,482 @@ class UpdateInstallerDialog(QDialog): Latest release version detected. """ - sig_install_on_close_requested = Signal(str) + sig_install_on_close = Signal(bool) """ - Signal to request running the downloaded installer on close. + Signal to request running the install process on close. Parameters ---------- - installer_path: str - Path to the installer executable. + install_on_close: bool + Whether to install on close. + """ + + sig_quit_requested = Signal() + """ + This signal can be emitted to request the main application to quit. """ def __init__(self, parent): + super().__init__(parent) + self.CONF_SECTION = parent.CONF_SECTION if parent else 'update_manager' + + self.startup = None + self.update_thread = None + self.update_worker = None + self.update_timer = None + self.latest_release = None + self.major_update = None + self.cancelled = False - self.status = NO_STATUS self.download_thread = None self.download_worker = None + self.progress_dialog = None self.installer_path = None + self.installer_size_path = None - super().__init__(parent) - self.setWindowFlags(Qt.Dialog | Qt.MSWindowsFixedSizeDialogHint) - self._parent = parent - self._installation_widget = UpdateInstallation(self) - self.latest_release_version = "" - - # Layout - installer_layout = QVBoxLayout() - installer_layout.addWidget(self._installation_widget) - self.setLayout(installer_layout) - - # Signals - self.sig_download_progress.connect( - self._installation_widget.update_installation_progress) - self.sig_installation_status.connect( - self._installation_widget.update_installation_status) - - self._installation_widget.ok_button.clicked.connect( - self.close_installer) - self._installation_widget.cancel_button.clicked.connect( - self.cancel_installation) - - # Show installation widget - self.setup() - - def reject(self): - """Reimplemented Qt method.""" - on_installation_widget = self._installation_widget.isVisible() - if on_installation_widget: - self.close_installer() + # ---- General + + def set_status(self, status=NO_STATUS): + """Set the update manager status.""" + self.sig_set_status.emit(status, self.latest_release) + + def cleanup_threads(self): + """Clean up QThreads""" + if self.update_timer is not None: + self.update_timer.stop() + if self.update_thread is not None: + self.update_thread.quit() + self.update_thread.wait() + if self.download_thread is not None: + self.download_worker.cancelled = True + self.download_thread.quit() + self.download_thread.wait() + + # ---- Check Update + + def start_check_update(self, startup=False): + """ + Check for spyder updates using a QThread. + + Update actions are disabled in the menubar and statusbar while + checking for updates + + If startup is True, then checking for updates is delayed 1 min; + actions are disabled during this time as well. + """ + logger.debug(f"Checking for updates. startup = {startup}.") + + # Disable check_update_action while the thread is working + self.sig_disable_actions.emit(True) + + self.startup = startup + self.cleanup_threads() + + self.update_thread = QThread(None) + self.update_worker = WorkerUpdate(self.get_conf('check_stable_only')) + self.update_worker.sig_ready.connect(self._process_check_update) + self.update_worker.sig_ready.connect(self.update_thread.quit) + self.update_worker.sig_ready.connect( + lambda: self.sig_disable_actions.emit(False) + ) + self.update_worker.moveToThread(self.update_thread) + self.update_thread.started.connect(lambda: self.set_status(CHECKING)) + self.update_thread.started.connect(self.update_worker.start) + + # Delay starting this check to avoid blocking the main window + # while loading. + # Fixes spyder-ide/spyder#15839 + if self.startup: + self.update_timer = QTimer(self) + self.update_timer.setInterval(60000) + self.update_timer.setSingleShot(True) + self.sig_block_status_signals.emit(True) + self.update_timer.timeout.connect( + lambda: self.sig_block_status_signals.emit(False)) + self.update_timer.timeout.connect(self.update_thread.start) + self.update_timer.start() + else: + # Otherwise, start immediately + self.update_thread.start() + + def _process_check_update(self): + """Process the results of check update.""" + # Get results from worker + update_available = self.update_worker.update_available + error_msg = self.update_worker.error + self.latest_release = self.update_worker.latest_release + self.major_update = ( + parse(__version__).major < parse(self.latest_release).major + ) + self._set_installer_path() + + # Always set status, regardless of error, DEV, or startup + self.set_status(PENDING if update_available else NO_STATUS) + + # self.startup = True is used on startup, so only positive feedback is + # given. self.startup = False is used after startup when using the menu + # action, and gives feeback if updates are, or are not found. + if ( + self.startup and # startup and... + ('dev' in __version__ # current version is dev + or error_msg is not None # or there is an error + or not update_available) # or no updates available + ): + # Do not alert the user to anything + pass + elif error_msg is not None: + error_messagebox(self, error_msg) + elif update_available: + self.start_update() + else: + info_messagebox(self, _("Spyder is up to date."), checkbox=True) + + def _set_installer_path(self): + """Set the emp file path for the downloaded installer""" + if os.name == 'nt': + plat, ext = 'Windows', 'exe' + if sys.platform == 'darwin': + plat, ext = 'macOS', 'pkg' + if sys.platform.startswith('linux'): + plat, ext = 'Linux', 'sh' + mach = platform.machine().lower().replace("amd64", "x86_64") + fname = f'Spyder-{self.latest_release}-{plat}-{mach}.{ext}' + + dirname = osp.join(TMPDIR, 'updates', self.latest_release) + self.installer_path = osp.join(dirname, fname) + self.installer_size_path = osp.join(dirname, "size") + + # ---- Download Update + + def _verify_installer_path(self): + if ( + osp.exists(self.installer_path) + and osp.exists(self.installer_size_path) + ): + with open(self.installer_size_path, "r") as f: + size = f.read().strip() + return size == osp.getsize(self.installer_path) else: - super().reject() + return False - def setup(self): - """Setup visibility of widgets.""" - self._installation_widget.setVisible(True) - self.adjustSize() + def start_update(self): + """ + Start the update process + + Request input from user whether to download the installer; upon + affirmation, proceed with download then to confirm install. - def save_latest_release(self, latest_release_version): - self.latest_release_version = latest_release_version + If the installer is already downloaded, proceed to confirm install. + """ + if self._verify_installer_path(): + self.set_status(DOWNLOAD_FINISHED) + self._confirm_install() + elif not is_conda_based_app(): + msg = _( + "Would you like to automatically download and " + "install it using Spyder's installer?" + "

" + "We recommend our own installer " + "because it's more stable and makes updating easy. " + "This will leave your existing Spyder installation " + "untouched." + ).format(URL_I + "#standalone-installers") + box = confirm_messagebox( + self, msg, version=self.latest_release, checkbox=True + ) + if box.result() == QMessageBox.Yes: + self._start_download() + else: + manual_update_messagebox( + self, self.latest_release, self.update_worker.channel) + elif self.major_update: + msg = _("Would you like to automatically download " + "and install it?") + box = confirm_messagebox( + self, msg, version=self.latest_release, checkbox=True + ) + if box.result() == QMessageBox.Yes: + self._start_download() + else: + # Minor release for conda-based application will update with conda + self._confirm_install() - def start_installation(self, latest_release_version): - """Start downloading the update and set downloading status.""" - self.latest_release_version = latest_release_version + def _start_download(self): + """ + Start downloading the installer in a QThread + and set downloading status. + """ self.cancelled = False - self._change_update_installation_status( - status=DOWNLOADING_INSTALLER) - self.download_thread = QThread(None) self.download_worker = WorkerDownloadInstaller( - self, self.latest_release_version) - self.download_worker.sig_ready.connect(self.confirm_installation) + self.latest_release, self.installer_path, self.installer_size_path) + + self.sig_disable_actions.emit(True) + self.set_status(DOWNLOADING_INSTALLER) + + self.progress_dialog = ProgressDialog( + self, _("Downloading Spyder {} ...").format(self.latest_release) + ) + self.progress_dialog.cancel.clicked.connect(self._cancel_download) + + self.download_thread = QThread(None) + self.download_worker.sig_ready.connect(self._confirm_install) self.download_worker.sig_ready.connect(self.download_thread.quit) + self.download_worker.sig_ready.connect( + lambda: self.sig_disable_actions.emit(False) + ) self.download_worker.sig_download_progress.connect( - self.sig_download_progress.emit) + self._update_download_progress) self.download_worker.moveToThread(self.download_thread) self.download_thread.started.connect(self.download_worker.start) self.download_thread.start() - def cancel_installation(self): + def show_progress_dialog(self, show=True): + """Show download progress if previously hidden""" + if self.progress_dialog is not None: + if show: + self.progress_dialog.show() + else: + self.progress_dialog.hide() + + def _update_download_progress(self, progress, total): + """Update download progress in dialog and status bar""" + if self.progress_dialog is not None: + self.progress_dialog.update_progress(progress, total) + if progress == total: + self.progress_dialog.accept() + + percent_progress = 0 + if total > 0: + percent_progress = round((progress / total) * 100) + self.sig_download_progress.emit(percent_progress) + + def _cancel_download(self): """Cancel the installation in progress.""" - reply = QMessageBox.critical( - self._parent, 'Spyder', - _('Do you really want to cancel the Spyder update installation?'), - QMessageBox.Yes, QMessageBox.No) - if reply == QMessageBox.Yes: + self.download_worker.paused = True + msg = _('Do you really want to cancel the download?') + box = confirm_messagebox(self, msg, critical=True) + if box.result() == QMessageBox.Yes: self.cancelled = True - self._cancel_download() - self.finish_installation() - return True - return False - - def continue_installation(self): - """ - Continue the installation in progress. - - Download the installer if needed or prompt to install. - """ - reply = QMessageBox(icon=QMessageBox.Question, - text=_("Would you like to update Spyder to " - "the latest version?" - "

"), - parent=self._parent) - reply.setWindowTitle("Spyder") - reply.setAttribute(Qt.WA_ShowWithoutActivating) - reply.setStandardButtons(QMessageBox.Yes | QMessageBox.No) - reply.exec_() - if reply.result() == QMessageBox.Yes: - self.start_installation(self.latest_release_version) + self.cleanup_threads() + self.set_status(PENDING) else: - self._change_update_installation_status(status=PENDING) + self.progress_dialog.show() + self.download_worker.paused = False - def confirm_installation(self, installer_path): + def _confirm_install(self): """ - Ask users if they want to proceed with the installer execution. + Ask users if they want to proceed with the install immediately + or on close. """ if self.cancelled: return - self._change_update_installation_status(status=DOWNLOAD_FINISHED) - self.installer_path = installer_path - msg_box = QMessageBox( - icon=QMessageBox.Question, - text=_("Would you like to proceed with the installation?

"), - parent=self._parent - ) - msg_box.setWindowTitle(_("Spyder update")) - msg_box.setAttribute(Qt.WA_ShowWithoutActivating) - if os.name == 'nt' and is_conda_based_app(): - # Only add yes button for Windows installer - # since it has the logic to restart Spyder - yes_button = msg_box.addButton(QMessageBox.Yes) - else: - yes_button = None - after_closing_button = msg_box.addButton( - _("After closing"), QMessageBox.YesRole) - msg_box.addButton(QMessageBox.No) - msg_box.exec_() - - if msg_box.clickedButton() == yes_button: - self._change_update_installation_status(status=INSTALLING) - cmd = ('start' if os.name == 'nt' else 'open') - if self.installer_path: - subprocess.Popen( - ' '.join([cmd, self.installer_path]), - shell=True - ) - self._change_update_installation_status(status=PENDING) - elif msg_box.clickedButton() == after_closing_button: - self.sig_install_on_close_requested.emit(self.installer_path) - self._change_update_installation_status(status=PENDING) + + if self.download_worker: + if self.download_worker.error: + # If download error, do not proceed with install + self.progress_dialog.reject() + self.set_status(PENDING) + error_messagebox(self, self.download_worker.error) + return + else: + self.set_status(DOWNLOAD_FINISHED) + + msg = _("Would you like to install it?") + box = confirm_messagebox( + self, msg, version=self.latest_release, on_close=True) + if box.result() == QMessageBox.Yes: + self.sig_install_on_close.emit(True) + self.sig_quit_requested.emit() + elif box.result() == 0: # 0 is result of 3rd push-button + self.sig_install_on_close.emit(True) + self.set_status(INSTALL_ON_CLOSE) + + def start_install(self): + """Install from downloaded installer or update through conda.""" + + # Install script + script = osp.abspath(__file__ + '/../../scripts/install.' + + ('bat' if os.name == 'nt' else 'sh')) + + # Sub command + sub_cmd = [script, '-p', sys.prefix] + if osp.exists(self.installer_path): + # Run downloaded installer + sub_cmd.extend(['-i', self.installer_path]) + elif self.latest_release is not None: + # Update with conda + sub_cmd.extend(['-c', find_conda(), '-v', self.latest_release]) + + # Final command assembly + if os.name == 'nt': + cmd = ['start', '"Update Spyder"'] + sub_cmd + elif sys.platform == 'darwin': + # Terminal cannot accept a command with arguments therefore + # create a temporary script + tmpdir = osp.join(gettempdir(), 'spyder') + tmpscript = osp.join(tmpdir, 'tmp_install.sh') + os.makedirs(tmpdir, exist_ok=True) + with open(tmpscript, 'w') as f: + f.write(' '.join(sub_cmd)) + os.chmod(tmpscript, 0o711) # set executable permissions + + cmd = ['open', '-b', 'com.apple.terminal', tmpscript] else: - self._change_update_installation_status(status=PENDING) + cmd = ['gnome-terminal', '--window', '--'] + sub_cmd - def finish_installation(self): - """Handle finished installation.""" - self.setup() - self.accept() + subprocess.Popen(' '.join(cmd), shell=True) - def close_installer(self): - """Close the installation dialog.""" - if ( - self.status == FINISHED - or self.status == CANCELLED - ): - self.finish_installation() - else: - self.hide() - - def _change_update_installation_status(self, status=NO_STATUS): - """Set the installation status.""" - logger.debug(f"Installation status: {status}") - self.status = status - if status == DOWNLOAD_FINISHED: - self.close_installer() - elif status == FINISHED or status == PENDING: - self.finish_installation() - self.sig_installation_status.emit( - self.status, self.latest_release_version) - def _cancel_download(self): - self._change_update_installation_status(status=CANCELLED) - self.download_worker.cancelled = True - self.download_thread.quit() - self.download_thread.wait() - self._change_update_installation_status(status=PENDING) +class UpdateMessageBox(QMessageBox): + def __init__(self, icon=None, text=None, parent=None): + super().__init__(icon=icon, text=text, parent=parent) + self.setWindowModality(Qt.NonModal) + self.setWindowTitle(_("Spyder Update Manager")) + self.setTextFormat(Qt.RichText) + + +class UpdateMessageCheckBox(MessageCheckBox): + def __init__(self, icon=None, text=None, parent=None): + super().__init__(icon=icon, text=text, parent=parent) + self.setWindowTitle(_("Spyder Update Manager")) + self.setTextFormat(Qt.RichText) + self._parent = parent + self.set_checkbox_text(_("Check for updates at startup")) + self.option = 'check_updates_on_startup' + self.accepted.connect(self.accept) # ??? Why is the signal necessary? + if self._parent is not None: + self.set_checked(parent.get_conf(self.option)) + + def accept(self): + if self._parent is not None: + self._parent.set_conf(self.option, self.is_checked()) + + +class ProgressDialog(UpdateMessageBox): + """Update progress installation dialog.""" + + def __init__(self, parent, text): + super().__init__(icon=QMessageBox.NoIcon, text=text, parent=parent) + + self._progress_bar = QProgressBar(self) + self._progress_bar.setMinimumWidth(250) + self._progress_bar.setFixedHeight(15) + + layout = self.layout() + layout.addWidget(self._progress_bar, 1, 1) + + self.cancel = QPushButton(_("Cancel")) + self.okay = QPushButton(_("OK")) + self.addButton(self.okay, QMessageBox.YesRole) + self.addButton(self.cancel, QMessageBox.NoRole) + self.setDefaultButton(self.okay) + + self.show() + + def update_progress(self, progress, total): + """Update installation progress bar.""" + self._progress_bar.setMaximum(total) + self._progress_bar.setValue(progress) + + +def error_messagebox(parent, error_msg): + box = UpdateMessageBox( + icon=QMessageBox.Warning, text=error_msg, parent=parent) + box.setStandardButtons(QMessageBox.Ok) + box.setDefaultButton(QMessageBox.Ok) + box.show() + return box + + +def info_messagebox(parent, message, version=None, checkbox=False): + box_class = UpdateMessageCheckBox if checkbox else UpdateMessageBox + message = HEADER.format(version) + message if version else message + box = box_class(icon=QMessageBox.Information, text=message, parent=parent) + box.setStandardButtons(QMessageBox.Ok) + box.setDefaultButton(QMessageBox.Ok) + box.show() + return box + + +def confirm_messagebox(parent, message, version=None, critical=False, + checkbox=False, on_close=False): + box_class = UpdateMessageCheckBox if checkbox else UpdateMessageBox + message = HEADER.format(version) + message if version else message + box = box_class( + icon=QMessageBox.Critical if critical else QMessageBox.Question, + text=message, parent=parent) + box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + box.setDefaultButton(QMessageBox.Yes) + if on_close: + box.addButton(_("After closing"), QMessageBox.YesRole) + box.exec() + return box + + +def manual_update_messagebox(parent, latest_release, channel): + msg = "" + if os.name == "nt": + if is_anaconda(): + msg += _("Run the following command or commands in " + "the Anaconda prompt to update manually:" + "

") + else: + msg += _("Run the following command in a cmd prompt " + "to update manually:

") + else: + if is_anaconda(): + msg += _("Run the following command or commands in a " + "terminal to update manually:

") + else: + msg += _("Run the following command in a terminal to " + "update manually:

") + + if is_anaconda(): + is_pypi = channel == 'pypi' + + if is_anaconda_pkg() and not is_pypi: + msg += "conda update anaconda
" + + if is_pypi: + dont_mix_pip_conda_video = ( + "https://youtu.be/Ul79ihg41Rs" + ) + + msg += ( + "pip install --upgrade spyder" + "


" + ) + + msg += _( + "Important note: You installed Spyder with " + "pip in a Conda environment, which is not a good " + "idea. See our video for more " + "details about it." + ).format(dont_mix_pip_conda_video) + else: + if channel == 'pkgs/main': + channel = '' + else: + channel = f'-c {channel}' + + msg += ( + f"conda install {channel} " + f"spyder={latest_release}" + f"


" + ) + + msg += _( + "Important note: Since you installed " + "Spyder with Anaconda, please don't use pip " + "to update it as that will break your " + "installation." + ) + else: + msg += "pip install --upgrade spyder
" + + msg += _( + "

For more information, visit our " + "installation guide." + ).format(URL_I) + + info_messagebox(parent, msg) diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py index a3643bcae25..9e059c07ddb 100644 --- a/spyder/plugins/updatemanager/workers.py +++ b/spyder/plugins/updatemanager/workers.py @@ -8,7 +8,7 @@ import logging import os import os.path as osp -import tempfile +from time import sleep import traceback # Third party imports @@ -18,10 +18,10 @@ # Local imports from spyder import __version__ -from spyder.config.base import _, is_conda_based_app, is_stable_version +from spyder.config.base import _, is_stable_version, is_conda_based_app from spyder.config.utils import is_anaconda from spyder.utils.conda import get_spyder_conda_channel -from spyder.utils.programs import check_version, is_module_installed +from spyder.utils.programs import check_version # Logger setup logger = logging.getLogger(__name__) @@ -53,7 +53,7 @@ class UpdateDownloadIncompleteError(Exception): pass -class WorkerUpdates(QObject): +class WorkerUpdate(QObject): """ Worker that checks for releases using either the Anaconda default channels or the Github Releases page without @@ -62,102 +62,84 @@ class WorkerUpdates(QObject): """ sig_ready = Signal() - def __init__(self, parent, startup, version="", releases=None): - QObject.__init__(self) - self._parent = parent - self.error = None + def __init__(self, stable_only): + super().__init__() + self.stable_only = stable_only self.latest_release = None - self.startup = startup - self.releases = releases - - if not version: - self.version = __version__ - else: - self.version = version - - def check_update_available(self): - """ - Check if there is an update available. - - It takes as parameters the current version of Spyder and a list of - valid cleaned releases in chronological order. - Example: ['2.3.2', '2.3.3' ...] or with github ['2.3.4', '2.3.3' ...] - """ - # Don't perform any check for development versions or we were unable to - # detect releases. - if 'dev' in self.version or not self.releases: - return (False, self.latest_release) + self.releases = None + self.update_available = None + self.error = None + self.channel = None + def _check_update_available(self): + """Checks if there is an update available from releases.""" # Filter releases - if is_stable_version(self.version): - releases = [r for r in self.releases if is_stable_version(r)] - else: - releases = [r for r in self.releases - if not is_stable_version(r) or r in self.version] + releases = self.releases.copy() + if self.stable_only: + # Only use stable releases + releases = [r for r in releases if is_stable_version(r)] + logger.debug(f"Available versions: {self.releases}") - latest_release = releases[-1] + self.latest_release = releases[-1] if releases else __version__ + self.update_available = check_version(__version__, + self.latest_release, '<') - return (check_version(self.version, latest_release, '<'), - latest_release) + logger.debug(f"Update available: {self.update_available}") + logger.debug(f"Latest release: {self.latest_release}") def start(self): """Main method of the worker.""" + self.error = None + self.latest_release = None self.update_available = False - self.latest_release = __version__ - error_msg = None pypi_url = "https://pypi.org/pypi/spyder/json" if is_conda_based_app(): - self.url = ('https://api.github.com/repos/' - 'spyder-ide/spyder/releases') + url = ('https://api.github.com/repos/spyder-ide/spyder/releases') elif is_anaconda(): - channel, channel_url = get_spyder_conda_channel() + self.channel, channel_url = get_spyder_conda_channel() - if channel_url is None: - return - elif channel == "pypi": - self.url = pypi_url + if channel_url is None or self.channel == "pypi": + url = pypi_url else: - self.url = channel_url + '/channeldata.json' + url = channel_url + '/channeldata.json' else: - self.url = pypi_url + url = pypi_url + logger.info(f"Checking for updates from {url}") try: - logger.debug(f"Checking for updates from {self.url}") - page = requests.get(self.url) + page = requests.get(url) page.raise_for_status() data = page.json() - if is_conda_based_app(): - if self.releases is None: + if self.releases is None: + if is_conda_based_app(): self.releases = [ item['tag_name'].replace('v', '') for item in data ] self.releases = list(reversed(self.releases)) - elif is_anaconda() and self.url != pypi_url: - if self.releases is None: + elif is_anaconda() and url != pypi_url: spyder_data = data['packages'].get('spyder') if spyder_data: self.releases = [spyder_data["version"]] - else: - if self.releases is None: + else: self.releases = [data['info']['version']] - result = self.check_update_available() - self.update_available, self.latest_release = result + self._check_update_available() except SSLError as err: error_msg = SSL_ERROR_MSG - logger.debug(err, stack_info=True) + logger.exception(err) except ConnectionError as err: error_msg = CONNECT_ERROR_MSG - logger.debug(err, stack_info=True) + logger.exception(err) except HTTPError as err: - error_msg = HTTP_ERROR_MSG.format(status_code=page.status_code) - logger.debug(err, stack_info=True) + error_msg = HTTP_ERROR_MSG.format(page.status_code) + logger.exception(err) except Exception as err: error = traceback.format_exc() - formatted_error = error.replace('\n', '
').replace(' ', ' ') + formatted_error = (error.replace('\n', '
') + .replace(' ', ' ')) error_msg = _( 'It was not possible to check for Spyder updates due to the ' @@ -165,36 +147,24 @@ def start(self): '

' '{}' ).format(formatted_error) - logger.debug(err, stack_info=True) - - # Don't show dialog when starting up spyder and an error occur - if not (self.startup and error_msg is not None): + logger.exception(err) + finally: self.error = error_msg - try: - self.sig_ready.emit() - except RuntimeError: - pass + self.sig_ready.emit() class WorkerDownloadInstaller(QObject): """ - Worker that donwloads standalone installers for Windows - and MacOS without blocking the Spyder user interface. - """ - - sig_ready = Signal(str) + Worker that donwloads standalone installers for Windows, macOS, + and Linux without blocking the Spyder user interface. """ - Signal to inform that the worker has finished successfully. - Parameters - ---------- - installer_path: str - Path where the downloaded installer is located. - """ + sig_ready = Signal() + """Signal to inform that the worker has finished successfully.""" sig_download_progress = Signal(int, int) """ - Signal to get the download progress. + Signal to send the download progress. Parameters ---------- @@ -204,98 +174,87 @@ class WorkerDownloadInstaller(QObject): Total size of the file expected to be downloaded. """ - def __init__(self, parent, latest_release_version): - QObject.__init__(self) - self._parent = parent - self.latest_release_version = latest_release_version + def __init__(self, latest_release, installer_path, installer_size_path): + super().__init__() + self.latest_release = latest_release + self.installer_path = installer_path + self.installer_size_path = installer_size_path self.error = None self.cancelled = False - self.installer_path = None + self.paused = False - def _progress_reporter(self, block_number, read_size, total_size): + def _progress_reporter(self, progress, total_size): """Calculate download progress and notify.""" - progress = 0 - if total_size > 0: - progress = block_number * read_size + self.sig_download_progress.emit(progress, total_size) + + while self.paused and not self.cancelled: + sleep(0.5) + if self.cancelled: raise UpdateDownloadCancelledException() - self.sig_download_progress.emit(progress, total_size) def _download_installer(self): - """Donwload latest Spyder standalone installer executable.""" - tmpdir = tempfile.gettempdir() - is_full_installer = (is_module_installed('numpy') or - is_module_installed('pandas')) - if os.name == 'nt': - name = 'Spyder_64bit_{}.exe'.format('full' if is_full_installer - else 'lite') - else: - name = 'Spyder{}.dmg'.format('' if is_full_installer else '-Lite') - - url = ('https://github.com/spyder-ide/spyder/releases/latest/' - f'download/{name}') - dir_path = osp.join(tmpdir, 'spyder', 'updates') - os.makedirs(dir_path, exist_ok=True) - installer_dir_path = osp.join( - dir_path, self.latest_release_version) - os.makedirs(installer_dir_path, exist_ok=True) - for file in os.listdir(dir_path): - if file not in [__version__, self.latest_release_version]: - remove = osp.join(dir_path, file) - os.remove(remove) - - installer_path = osp.join(installer_dir_path, name) - self.installer_path = installer_path + """Donwload Spyder installer.""" + url = ( + 'https://github.com/spyder-ide/spyder/releases/download/' + f'v{self.latest_release}/{osp.basename(self.installer_path)}' + ) + logger.info(f"Downloading installer from {url} " + f"to {self.installer_path}") - if osp.isfile(installer_path): - # Installer already downloaded - logger.info(f"{installer_path} already downloaded") - self._progress_reporter(1, 1, 1) - return + dirname = osp.dirname(self.installer_path) + os.makedirs(dirname, exist_ok=True) - logger.debug(f"Downloading installer from {url} to {installer_path}") with requests.get(url, stream=True) as r: - with open(installer_path, 'wb') as f: + r.raise_for_status() + size = -1 + if "content-length" in r.headers: + size = int(r.headers["content-length"]) + self._progress_reporter(0, size) + + with open(self.installer_path, 'wb') as f: chunk_size = 8 * 1024 - size = -1 size_read = 0 - chunk_num = 0 - - if "content-length" in r.headers: - size = int(r.headers["content-length"]) - - self._progress_reporter(chunk_num, chunk_size, size) - for chunk in r.iter_content(chunk_size=chunk_size): size_read += len(chunk) f.write(chunk) - chunk_num += 1 - self._progress_reporter(chunk_num, chunk_size, size) + self._progress_reporter(size_read, size) - if size >= 0 and size_read < size: - raise UpdateDownloadIncompleteError( - "Download incomplete: retrieved only " - f"{size_read} out of {size} bytes." - ) + if size_read == size: + logger.info('Download successfully completed.') + with open(self.installer_size_path, "w") as f: + f.write(str(size)) + else: + raise UpdateDownloadIncompleteError( + "Download incomplete: retrieved only " + f"{size_read} out of {size} bytes." + ) + + def _clean_installer_path(self): + """Remove downloaded file""" + if osp.exists(self.installer_path): + os.remove(self.installer_path) + if osp.exists(self.installer_size_path): + os.remove(self.installer_size_path) def start(self): - """Main method of the WorkerDownloadInstaller worker.""" + """Main method of the worker.""" error_msg = None try: self._download_installer() except UpdateDownloadCancelledException: - if self.installer_path: - os.remove(self.installer_path) - return + logger.info("Download cancelled") + self._clean_installer_path() except SSLError as err: error_msg = SSL_ERROR_MSG - logger.debug(err, stack_info=True) + logger.exception(err) except ConnectionError as err: error_msg = CONNECT_ERROR_MSG - logger.debug(err, stack_info=True) + logger.exception(err) except Exception as err: error = traceback.format_exc() - formatted_error = error.replace('\n', '
').replace(' ', ' ') + formatted_error = (error.replace('\n', '
') + .replace(' ', ' ')) error_msg = _( 'It was not possible to download the installer due to the ' @@ -303,9 +262,8 @@ def start(self): '

' '{}' ).format(formatted_error) - logger.debug(err, stack_info=True) - self.error = error_msg - try: - self.sig_ready.emit(self.installer_path) - except RuntimeError: - pass + logger.exception(err) + self._clean_installer_path() + finally: + self.error = error_msg + self.sig_ready.emit() From 4f25a57978d7e003eadd10ce87cd3f2045e06664 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Mon, 13 Nov 2023 21:41:12 -0800 Subject: [PATCH 04/22] Do not check for updates on startup if development version --- spyder/plugins/updatemanager/plugin.py | 7 ++++++- spyder/plugins/updatemanager/widgets/update.py | 3 +-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/updatemanager/plugin.py b/spyder/plugins/updatemanager/plugin.py index 23ed346abeb..d3ad933fa8e 100644 --- a/spyder/plugins/updatemanager/plugin.py +++ b/spyder/plugins/updatemanager/plugin.py @@ -9,6 +9,7 @@ """ # Local imports +from spyder import __version__ from spyder.api.plugins import Plugins, SpyderPluginV2 from spyder.api.translations import _ from spyder.api.plugin_registration.decorators import ( @@ -94,7 +95,11 @@ def on_mainwindow_visible(self): container = self.get_container() # Check for updates on startup - if DEV is None and self.get_conf('check_updates_on_startup'): + if ( + DEV is None # Not bootstrap + and 'dev' not in __version__ # Not dev version + and self.get_conf('check_updates_on_startup') + ): container.start_check_updates(startup=True) # ---- Private API diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py index a3ecc3d4bc5..8d2d87b394f 100644 --- a/spyder/plugins/updatemanager/widgets/update.py +++ b/spyder/plugins/updatemanager/widgets/update.py @@ -224,8 +224,7 @@ def _process_check_update(self): # action, and gives feeback if updates are, or are not found. if ( self.startup and # startup and... - ('dev' in __version__ # current version is dev - or error_msg is not None # or there is an error + (error_msg is not None # there is an error or not update_available) # or no updates available ): # Do not alert the user to anything From e10fea167ec8350b0df35caa900b7ca688d7156d Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Mon, 13 Nov 2023 21:42:54 -0800 Subject: [PATCH 05/22] Fix issue where versions were not sorted in release order --- spyder/plugins/updatemanager/workers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py index 9e059c07ddb..2f44d625e30 100644 --- a/spyder/plugins/updatemanager/workers.py +++ b/spyder/plugins/updatemanager/workers.py @@ -12,6 +12,7 @@ import traceback # Third party imports +from packaging.version import parse from qtpy.QtCore import QObject, Signal import requests from requests.exceptions import ConnectionError, HTTPError, SSLError @@ -96,7 +97,7 @@ def start(self): pypi_url = "https://pypi.org/pypi/spyder/json" if is_conda_based_app(): - url = ('https://api.github.com/repos/spyder-ide/spyder/releases') + url = 'https://api.github.com/repos/spyder-ide/spyder/releases' elif is_anaconda(): self.channel, channel_url = get_spyder_conda_channel() @@ -118,13 +119,13 @@ def start(self): self.releases = [ item['tag_name'].replace('v', '') for item in data ] - self.releases = list(reversed(self.releases)) elif is_anaconda() and url != pypi_url: spyder_data = data['packages'].get('spyder') if spyder_data: self.releases = [spyder_data["version"]] else: self.releases = [data['info']['version']] + self.releases.sort(key=parse) self._check_update_available() except SSLError as err: From 55f7960587570598fb1c84e2746665cdbcc7f8eb Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Mon, 13 Nov 2023 21:47:29 -0800 Subject: [PATCH 06/22] Set latest_release, installer_path, and compute major_release in start_update. This ensures that they are assigned only if there is an update available and after error is processed. Otherwise, compute major_release can raise error before error_msg is processed. --- spyder/plugins/updatemanager/widgets/update.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py index 8d2d87b394f..13809c9fc82 100644 --- a/spyder/plugins/updatemanager/widgets/update.py +++ b/spyder/plugins/updatemanager/widgets/update.py @@ -131,7 +131,6 @@ def __init__(self, parent): self.update_worker = None self.update_timer = None self.latest_release = None - self.major_update = None self.cancelled = False self.download_thread = None @@ -210,11 +209,6 @@ def _process_check_update(self): # Get results from worker update_available = self.update_worker.update_available error_msg = self.update_worker.error - self.latest_release = self.update_worker.latest_release - self.major_update = ( - parse(__version__).major < parse(self.latest_release).major - ) - self._set_installer_path() # Always set status, regardless of error, DEV, or startup self.set_status(PENDING if update_available else NO_STATUS) @@ -273,6 +267,12 @@ def start_update(self): If the installer is already downloaded, proceed to confirm install. """ + self.latest_release = self.update_worker.latest_release + self._set_installer_path() + major_update = ( + parse(__version__).major < parse(self.latest_release).major + ) + if self._verify_installer_path(): self.set_status(DOWNLOAD_FINISHED) self._confirm_install() @@ -294,7 +294,7 @@ def start_update(self): else: manual_update_messagebox( self, self.latest_release, self.update_worker.channel) - elif self.major_update: + elif major_update: msg = _("Would you like to automatically download " "and install it?") box = confirm_messagebox( From 8dfe4fa255c27a25d2a1b5d22a2e102dbea162f3 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Wed, 15 Nov 2023 19:47:45 -0800 Subject: [PATCH 07/22] Fix typo that prevented checking for updates at startup. --- spyder/plugins/updatemanager/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/updatemanager/plugin.py b/spyder/plugins/updatemanager/plugin.py index d3ad933fa8e..01931fa0489 100644 --- a/spyder/plugins/updatemanager/plugin.py +++ b/spyder/plugins/updatemanager/plugin.py @@ -100,7 +100,7 @@ def on_mainwindow_visible(self): and 'dev' not in __version__ # Not dev version and self.get_conf('check_updates_on_startup') ): - container.start_check_updates(startup=True) + container.start_check_update(startup=True) # ---- Private API # ------------------------------------------------------------------------ From 08a859482394c9c6f1c87916d516201a0d455ca8 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 16 Nov 2023 15:57:43 -0800 Subject: [PATCH 08/22] Use warning instead of exception or error logging level. exception and error logging levels trigger Spyder's Issue reporter. --- spyder/plugins/updatemanager/workers.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py index 2f44d625e30..a9c7bf6c3b5 100644 --- a/spyder/plugins/updatemanager/workers.py +++ b/spyder/plugins/updatemanager/workers.py @@ -130,13 +130,13 @@ def start(self): self._check_update_available() except SSLError as err: error_msg = SSL_ERROR_MSG - logger.exception(err) + logger.warning(err, exc_info=err) except ConnectionError as err: error_msg = CONNECT_ERROR_MSG - logger.exception(err) + logger.warning(err, exc_info=err) except HTTPError as err: error_msg = HTTP_ERROR_MSG.format(page.status_code) - logger.exception(err) + logger.warning(err, exc_info=err) except Exception as err: error = traceback.format_exc() formatted_error = (error.replace('\n', '
') @@ -148,7 +148,7 @@ def start(self): '

' '{}' ).format(formatted_error) - logger.exception(err) + logger.warning(err, exc_info=err) finally: self.error = error_msg self.sig_ready.emit() @@ -248,10 +248,10 @@ def start(self): self._clean_installer_path() except SSLError as err: error_msg = SSL_ERROR_MSG - logger.exception(err) + logger.warning(err, exc_info=err) except ConnectionError as err: error_msg = CONNECT_ERROR_MSG - logger.exception(err) + logger.warning(err, exc_info=err) except Exception as err: error = traceback.format_exc() formatted_error = (error.replace('\n', '
') @@ -263,7 +263,7 @@ def start(self): '

' '{}' ).format(formatted_error) - logger.exception(err) + logger.warning(err, exc_info=err) self._clean_installer_path() finally: self.error = error_msg From d2b875ac36ba963cf3473dfc22c1114bbf6a0b83 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 16 Nov 2023 16:34:11 -0800 Subject: [PATCH 09/22] Fix issue where download size verification was not int type --- spyder/plugins/updatemanager/widgets/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py index 13809c9fc82..25ba6734fc8 100644 --- a/spyder/plugins/updatemanager/widgets/update.py +++ b/spyder/plugins/updatemanager/widgets/update.py @@ -253,7 +253,7 @@ def _verify_installer_path(self): and osp.exists(self.installer_size_path) ): with open(self.installer_size_path, "r") as f: - size = f.read().strip() + size = int(f.read().strip()) return size == osp.getsize(self.installer_path) else: return False From b9958f12ffd86c359eec237d0ea8b545c50cb4fc Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Sun, 26 Nov 2023 07:52:09 -0800 Subject: [PATCH 10/22] Fix KeyError when applying string format --- spyder/plugins/updatemanager/workers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py index a9c7bf6c3b5..011e7951f0c 100644 --- a/spyder/plugins/updatemanager/workers.py +++ b/spyder/plugins/updatemanager/workers.py @@ -135,7 +135,7 @@ def start(self): error_msg = CONNECT_ERROR_MSG logger.warning(err, exc_info=err) except HTTPError as err: - error_msg = HTTP_ERROR_MSG.format(page.status_code) + error_msg = HTTP_ERROR_MSG.format(status_code=page.status_code) logger.warning(err, exc_info=err) except Exception as err: error = traceback.format_exc() From 5bb28e3166d2aa8f3c6d8fa59c8111dc590a2a6e Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:37:04 -0800 Subject: [PATCH 11/22] Do not check for custom_widget after initialization. UpdateManagerStatus will always have custom_widget and spinner attributes after initialization. --- spyder/plugins/updatemanager/widgets/status.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spyder/plugins/updatemanager/widgets/status.py b/spyder/plugins/updatemanager/widgets/status.py index 35fff755a53..67080dbbecc 100644 --- a/spyder/plugins/updatemanager/widgets/status.py +++ b/spyder/plugins/updatemanager/widgets/status.py @@ -81,8 +81,7 @@ def set_value(self, value): self.custom_widget.show() elif value == CHECKING: self.tooltip = self.BASE_TOOLTIP - if self.custom_widget: - self.custom_widget.hide() + self.custom_widget.hide() self.spinner.show() self.spinner.start() elif value == PENDING: From 28ff22fcda5f13ca88ae1bd1def327798949cd99 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:52:00 -0800 Subject: [PATCH 12/22] Use cb suffix for checkbox object names --- spyder/plugins/updatemanager/confpage.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/spyder/plugins/updatemanager/confpage.py b/spyder/plugins/updatemanager/confpage.py index e1b73d6f5dc..10faf6e77a7 100644 --- a/spyder/plugins/updatemanager/confpage.py +++ b/spyder/plugins/updatemanager/confpage.py @@ -5,11 +5,7 @@ # (see spyder/__init__.py for details) """ -General entry in Preferences. - -For historical reasons (dating back to Spyder 2) the main class here is called -`MainConfigPage` and its associated entry in our config system is called -`main`. +Update manager Preferences configuration page. """ from qtpy.QtWidgets import QGroupBox, QVBoxLayout @@ -22,18 +18,18 @@ class UpdateManagerConfigPage(PluginConfigPage): def setup_page(self): """Setup config page widgets and options.""" updates_group = QGroupBox(_("Updates")) - check_updates = self.create_checkbox( + check_update_cb = self.create_checkbox( _("Check for updates on startup"), 'check_updates_on_startup' ) - stable_only = self.create_checkbox( + stable_only_cb = self.create_checkbox( _("Check for stable releases only"), 'check_stable_only' ) updates_layout = QVBoxLayout() - updates_layout.addWidget(check_updates) - updates_layout.addWidget(stable_only) + updates_layout.addWidget(check_update_cb) + updates_layout.addWidget(stable_only_cb) updates_group.setLayout(updates_layout) vlayout = QVBoxLayout() From dd7376f67992baf57856e87138ec716f61f8efe6 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:52:53 -0800 Subject: [PATCH 13/22] Set status to NO_STATUS upon applying change to check_stable_only --- spyder/plugins/updatemanager/confpage.py | 6 ++++++ spyder/plugins/updatemanager/plugin.py | 1 + spyder/plugins/updatemanager/widgets/status.py | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/spyder/plugins/updatemanager/confpage.py b/spyder/plugins/updatemanager/confpage.py index 10faf6e77a7..280cde428c1 100644 --- a/spyder/plugins/updatemanager/confpage.py +++ b/spyder/plugins/updatemanager/confpage.py @@ -36,3 +36,9 @@ def setup_page(self): vlayout.addWidget(updates_group) vlayout.addStretch(1) self.setLayout(vlayout) + + def apply_settings(self): + if 'check_stable_only' in self.changed_options: + self.plugin.update_manager_status.set_no_status() + + return set(self.changed_options) diff --git a/spyder/plugins/updatemanager/plugin.py b/spyder/plugins/updatemanager/plugin.py index 01931fa0489..e96f8c40af7 100644 --- a/spyder/plugins/updatemanager/plugin.py +++ b/spyder/plugins/updatemanager/plugin.py @@ -133,4 +133,5 @@ def check_update_action(self): @property def update_manager_status(self): + """Get Update manager statusbar widget""" return self.get_container().update_manager_status diff --git a/spyder/plugins/updatemanager/widgets/status.py b/spyder/plugins/updatemanager/widgets/status.py index 67080dbbecc..0773c2ad703 100644 --- a/spyder/plugins/updatemanager/widgets/status.py +++ b/spyder/plugins/updatemanager/widgets/status.py @@ -102,6 +102,10 @@ def set_value(self, value): logger.debug(f"Update manager status: {value}") super().set_value(value) + def set_no_status(self): + """Convenience method to set status to NO_STATUS""" + self.set_value(NO_STATUS) + def get_tooltip(self): """Reimplementation to get a dynamic tooltip.""" return self.tooltip From 623e67d5854df81bf7f2a57a8a6f31eb4a2618f5 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Tue, 28 Nov 2023 17:56:20 -0800 Subject: [PATCH 14/22] Bump major version of configuration because an option is removed from main section. --- spyder/config/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/config/main.py b/spyder/config/main.py index c4cf64d951a..98dcc64bc35 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -667,4 +667,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 = '81.0.0' +CONF_VERSION = '82.0.0' From 3f89181d0b0c52000dbc32cdc9bfdd2e4862453b Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Tue, 28 Nov 2023 17:58:27 -0800 Subject: [PATCH 15/22] Apply suggestion from code review. --- spyder/plugins/updatemanager/tests/test_update_manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/spyder/plugins/updatemanager/tests/test_update_manager.py b/spyder/plugins/updatemanager/tests/test_update_manager.py index 0e57ed91c7e..647120abca6 100644 --- a/spyder/plugins/updatemanager/tests/test_update_manager.py +++ b/spyder/plugins/updatemanager/tests/test_update_manager.py @@ -55,7 +55,6 @@ def test_updates_appenv(qtbot, mocker, version): _update = um.update_worker.update_available assert _update if version.split('.')[0] == '1' else not _update - # assert len(worker.releases) > 1 assert len(um.update_worker.releases) > 1 From 4c14e30c17f12698158890412d6067000791b7f0 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Tue, 12 Dec 2023 09:49:01 -0800 Subject: [PATCH 16/22] Apply suggestions from code review --- spyder/plugins/updatemanager/container.py | 27 +++-- spyder/plugins/updatemanager/plugin.py | 21 ++-- .../plugins/updatemanager/scripts/install.bat | 6 + .../plugins/updatemanager/scripts/install.sh | 10 ++ .../tests/test_update_manager.py | 48 +++++--- .../plugins/updatemanager/widgets/status.py | 24 ++-- .../plugins/updatemanager/widgets/update.py | 103 ++++++++++-------- spyder/plugins/updatemanager/workers.py | 35 ++++-- 8 files changed, 177 insertions(+), 97 deletions(-) diff --git a/spyder/plugins/updatemanager/container.py b/spyder/plugins/updatemanager/container.py index dc94cd5fa8f..33086ec073b 100644 --- a/spyder/plugins/updatemanager/container.py +++ b/spyder/plugins/updatemanager/container.py @@ -21,7 +21,8 @@ from spyder.api.widgets.main_container import PluginMainContainer from spyder.plugins.updatemanager.widgets.status import UpdateManagerStatus from spyder.plugins.updatemanager.widgets.update import ( - UpdateManagerWidget, NO_STATUS + UpdateManagerWidget, + NO_STATUS ) from spyder.utils.qthelpers import DialogManager @@ -41,6 +42,8 @@ def __init__(self, name, plugin, parent=None): self.install_on_close = False + # ---- PluginMainContainer API + # ------------------------------------------------------------------------- def setup(self): self.dialog_manager = DialogManager() self.update_manager = UpdateManagerWidget(parent=self) @@ -76,6 +79,18 @@ def setup(self): def update_actions(self): pass + def on_close(self): + """To call from Spyder when the plugin is closed.""" + self.update_manager.cleanup_threads() + + # Run installer after Spyder is closed + if self.install_on_close: + self.update_manager.start_install() + + self.dialog_manager.close_all() + + # --- Public API + # ------------------------------------------------------------------------- def set_status(self, status, latest_version=None): """Set Update Manager status""" self.update_manager_status.set_value(status) @@ -93,13 +108,3 @@ def start_update(self): def set_install_on_close(self, install_on_close): """Set whether start install on close.""" self.install_on_close = install_on_close - - def on_close(self): - """To call from Spyder when the plugin is closed.""" - self.update_manager.cleanup_threads() - - # Run installer after Spyder is closed - if self.install_on_close: - self.update_manager.start_install() - - self.dialog_manager.close_all() diff --git a/spyder/plugins/updatemanager/plugin.py b/spyder/plugins/updatemanager/plugin.py index e96f8c40af7..fb299de9c09 100644 --- a/spyder/plugins/updatemanager/plugin.py +++ b/spyder/plugins/updatemanager/plugin.py @@ -13,25 +13,30 @@ from spyder.api.plugins import Plugins, SpyderPluginV2 from spyder.api.translations import _ from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) + on_plugin_available, + on_plugin_teardown +) from spyder.config.base import DEV from spyder.plugins.updatemanager.confpage import UpdateManagerConfigPage -from spyder.plugins.updatemanager.container import (UpdateManagerActions, - UpdateManagerContainer) +from spyder.plugins.updatemanager.container import ( + UpdateManagerActions, + UpdateManagerContainer +) from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections class UpdateManager(SpyderPluginV2): NAME = 'update_manager' REQUIRES = [Plugins.Preferences] - OPTIONAL = [Plugins.Help, Plugins.MainMenu, Plugins.Shortcuts, - Plugins.StatusBar] + OPTIONAL = [Plugins.MainMenu, Plugins.StatusBar] CONTAINER_CLASS = UpdateManagerContainer CONF_SECTION = 'update_manager' CONF_FILE = False CONF_WIDGET_CLASS = UpdateManagerConfigPage CAN_BE_DISABLED = False + # ---- SpyderPluginV2 API + # ------------------------------------------------------------------------- @staticmethod def get_name(): return _('Update Manager') @@ -44,8 +49,7 @@ def get_icon(cls): def get_description(): return _('Manage application updates.') - # --------------------- PLUGIN INITIALIZATION ----------------------------- - + # ---- Plugin initialization def on_initialize(self): pass @@ -69,8 +73,7 @@ def on_statusbar_available(self): statusbar = self.get_plugin(Plugins.StatusBar) statusbar.add_status_widget(self.update_manager_status) - # -------------------------- PLUGIN TEARDOWN ------------------------------ - + # ---- Plugin teardown @on_plugin_teardown(plugin=Plugins.StatusBar) def on_statusbar_teardown(self): # Remove status widget if created diff --git a/spyder/plugins/updatemanager/scripts/install.bat b/spyder/plugins/updatemanager/scripts/install.bat index ad26ae98f08..e0c8297cddd 100644 --- a/spyder/plugins/updatemanager/scripts/install.bat +++ b/spyder/plugins/updatemanager/scripts/install.bat @@ -59,7 +59,13 @@ exit %ERRORLEVEL% goto :EOF :update_subroutine + echo ========================================================= echo Updating Spyder + echo --------------- + echo + echo IMPORTANT: Do not close this window until it has finished + echo ========================================================= + echo call :wait_for_spyder_quit diff --git a/spyder/plugins/updatemanager/scripts/install.sh b/spyder/plugins/updatemanager/scripts/install.sh index 381da1cc44e..4d21ed7351e 100755 --- a/spyder/plugins/updatemanager/scripts/install.sh +++ b/spyder/plugins/updatemanager/scripts/install.sh @@ -13,6 +13,15 @@ done shift $(($OPTIND - 1)) update_spyder(){ + cat < 1 @@ -68,8 +73,8 @@ def test_updates_appenv(qtbot, mocker, version): ) def test_updates_condaenv(qtbot, worker, mocker, version, channel): """ - Test whether or not we offer updates for our installers according to the - current Spyder version. + Test whether or not we offer updates for conda installed Spyder according + to the current version. """ mocker.patch.object(workers, "__version__", new=version) mocker.patch.object(workers, "is_anaconda", return_value=True) @@ -81,16 +86,17 @@ def test_updates_condaenv(qtbot, worker, mocker, version, channel): with qtbot.waitSignal(worker.sig_ready, timeout=5000): worker.start() - _update = worker.update_available - assert _update if version.split('.')[0] == '1' else not _update + update_available = worker.update_available + if version.split('.')[0] == '1': + assert update_available + else: + assert not update_available assert len(worker.releases) == 1 @pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"]) def test_updates_pipenv(qtbot, worker, mocker, version): - """ - Test updates for pip installed Spyder - """ + """Test updates for pip installed Spyder.""" mocker.patch.object(workers, "__version__", new=version) mocker.patch.object(workers, "is_anaconda", return_value=False) mocker.patch.object(workers, "is_conda_based_app", return_value=False) @@ -102,8 +108,11 @@ def test_updates_pipenv(qtbot, worker, mocker, version): with qtbot.waitSignal(worker.sig_ready, timeout=5000): worker.start() - _update = worker.update_available - assert _update if version.split('.')[0] == '1' else not _update + update_available = worker.update_available + if version.split('.')[0] == '1': + assert update_available + else: + assert not update_available assert len(worker.releases) == 1 @@ -118,24 +127,31 @@ def test_update_non_stable(qtbot, mocker, version, release, stable_only): worker.releases = [release] worker._check_update_available() - _update = worker.update_available - assert not _update if "a" in release and stable_only else _update + update_available = worker.update_available + if "a" in release and stable_only: + assert not update_available + else: + assert update_available # ---- Test WorkerDownloadInstaller +@pytest.mark.skip(reason="Re-enable when alternate repo is available") @pytest.mark.skipif(not running_in_ci(), reason="Download only in CI") def test_download(qtbot, mocker): """ Test download spyder installer. + Uses UpdateManagerWidget in order to also test QThread. """ um = UpdateManagerWidget(None) um.latest_release = "6.0.0a2" um._set_installer_path() + # Do not execute _start_install after download completes. mocker.patch.object( - UpdateManagerWidget, "_confirm_install", new=lambda x: None) + UpdateManagerWidget, "_confirm_install", new=lambda x: None + ) um._start_download() qtbot.waitUntil(um.download_thread.isFinished, timeout=60000) diff --git a/spyder/plugins/updatemanager/widgets/status.py b/spyder/plugins/updatemanager/widgets/status.py index 0773c2ad703..7d7468635cd 100644 --- a/spyder/plugins/updatemanager/widgets/status.py +++ b/spyder/plugins/updatemanager/widgets/status.py @@ -21,8 +21,13 @@ from spyder.api.widgets.menus import SpyderMenu from spyder.api.widgets.status import StatusBarWidget from spyder.plugins.updatemanager.widgets.update import ( - NO_STATUS, DOWNLOADING_INSTALLER, PENDING, - CHECKING, DOWNLOAD_FINISHED, INSTALL_ON_CLOSE) + CHECKING, + DOWNLOAD_FINISHED, + DOWNLOADING_INSTALLER, + INSTALL_ON_CLOSE, + NO_STATUS, + PENDING +) from spyder.utils.icon_manager import ima from spyder.utils.qthelpers import add_actions, create_action @@ -33,23 +38,23 @@ class UpdateManagerStatus(StatusBarWidget): """Status bar widget for update manager.""" - BASE_TOOLTIP = _("Update manager status") + BASE_TOOLTIP = _("Application update status") ID = 'update_manager_status' sig_check_update = Signal() """Signal to request checking for updates.""" sig_start_update = Signal() - """Signal to start update process""" + """Signal to start the update process""" sig_show_progress_dialog = Signal(bool) """ - Signal to show progress dialog + Signal to show the progress dialog. Parameters ---------- show: bool - True to show, False to hide + True to show, False to hide. """ CUSTOM_WIDGET_CLASS = QLabel @@ -73,7 +78,7 @@ def set_value(self, value): """Set update manager status.""" if value == DOWNLOADING_INSTALLER: self.tooltip = _( - "Downloading update will continue in the background.\n" + "Downloading the update will continue in the background.\n" "Click here to show the download dialog again." ) self.spinner.hide() @@ -96,6 +101,7 @@ def set_value(self, value): if self.spinner: self.spinner.hide() self.spinner.stop() + self.setVisible(True) self.update_tooltip() value = f"Spyder: {value}" @@ -132,9 +138,11 @@ def show_dialog_or_menu(self): text=_("Check for updates..."), triggered=self.sig_check_update.emit ) + add_actions(self.menu, [check_for_updates_action]) rect = self.contentsRect() os_height = 7 if os.name == 'nt' else 12 pos = self.mapToGlobal( - rect.topLeft() + QPoint(-10, -rect.height() - os_height)) + rect.topLeft() + QPoint(-10, -rect.height() - os_height) + ) self.menu.popup(pos) diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py index 25ba6734fc8..7287af3dee6 100644 --- a/spyder/plugins/updatemanager/widgets/update.py +++ b/spyder/plugins/updatemanager/widgets/update.py @@ -12,7 +12,6 @@ import os.path as osp import sys import subprocess -from tempfile import gettempdir import platform # Third-party imports @@ -26,9 +25,12 @@ from spyder.api.translations import _ from spyder.config.base import is_conda_based_app from spyder.config.utils import is_anaconda -from spyder.plugins.updatemanager.workers import (WorkerUpdate, - WorkerDownloadInstaller) +from spyder.plugins.updatemanager.workers import ( + WorkerUpdate, + WorkerDownloadInstaller +) from spyder.utils.conda import find_conda, is_anaconda_pkg +from spyder.utils.programs import get_temp_dir, is_program_installed from spyder.widgets.helperwidgets import MessageCheckBox # Logger setup @@ -45,44 +47,34 @@ CANCELLED = _("Cancelled update") INSTALL_ON_CLOSE = _("Install on close") -INSTALL_INFO_MESSAGES = { - DOWNLOADING_INSTALLER: _("Downloading Spyder {version}"), - DOWNLOAD_FINISHED: _("Finished downloading Spyder {version}"), - INSTALLING: _("Installing Spyder {version}"), - FINISHED: _("Finished installing Spyder {version}"), - PENDING: _("Spyder {version} available to download"), - CHECKING: _("Checking for new Spyder version"), - CANCELLED: _("Spyder update cancelled"), - INSTALL_ON_CLOSE: _("Install Spyder {version} on close") -} - HEADER = _("

Spyder {} is available!


") URL_I = 'https://docs.spyder-ide.org/current/installation.html' -TMPDIR = osp.join(gettempdir(), 'spyder') class UpdateManagerWidget(QWidget, SpyderConfigurationAccessor): - """Check for updates widget""" + """Check for updates widget.""" + + CONF_SECTION = "update_manager" sig_disable_actions = Signal(bool) """ - Signal to disable plugin actions during check for update + Signal to disable plugin actions during check for update. Parameters ---------- disable: bool - True to disable, False to re-enable + True to disable, False to re-enable. """ sig_block_status_signals = Signal(bool) """ Signal to block signals from update manager status during - check for update + check for update. Parameters ---------- block: bool - True to block, False to unblock + True to block, False to unblock. """ sig_download_progress = Signal(int) @@ -124,7 +116,6 @@ class UpdateManagerWidget(QWidget, SpyderConfigurationAccessor): def __init__(self, parent): super().__init__(parent) - self.CONF_SECTION = parent.CONF_SECTION if parent else 'update_manager' self.startup = None self.update_thread = None @@ -161,10 +152,10 @@ def cleanup_threads(self): def start_check_update(self, startup=False): """ - Check for spyder updates using a QThread. + Check for Spyder updates using a QThread. Update actions are disabled in the menubar and statusbar while - checking for updates + checking for updates. If startup is True, then checking for updates is delayed 1 min; actions are disabled during this time as well. @@ -197,7 +188,8 @@ def start_check_update(self, startup=False): self.update_timer.setSingleShot(True) self.sig_block_status_signals.emit(True) self.update_timer.timeout.connect( - lambda: self.sig_block_status_signals.emit(False)) + lambda: self.sig_block_status_signals.emit(False) + ) self.update_timer.timeout.connect(self.update_thread.start) self.update_timer.start() else: @@ -215,7 +207,7 @@ def _process_check_update(self): # self.startup = True is used on startup, so only positive feedback is # given. self.startup = False is used after startup when using the menu - # action, and gives feeback if updates are, or are not found. + # action, and gives feeback if updates are or are not found. if ( self.startup and # startup and... (error_msg is not None # there is an error @@ -231,17 +223,18 @@ def _process_check_update(self): info_messagebox(self, _("Spyder is up to date."), checkbox=True) def _set_installer_path(self): - """Set the emp file path for the downloaded installer""" + """Set the temp file path for the downloaded installer.""" if os.name == 'nt': plat, ext = 'Windows', 'exe' if sys.platform == 'darwin': plat, ext = 'macOS', 'pkg' if sys.platform.startswith('linux'): plat, ext = 'Linux', 'sh' + mach = platform.machine().lower().replace("amd64", "x86_64") fname = f'Spyder-{self.latest_release}-{plat}-{mach}.{ext}' - dirname = osp.join(TMPDIR, 'updates', self.latest_release) + dirname = osp.join(get_temp_dir(), 'updates', self.latest_release) self.installer_path = osp.join(dirname, fname) self.installer_size_path = osp.join(dirname, "size") @@ -286,19 +279,23 @@ def start_update(self): "This will leave your existing Spyder installation " "untouched." ).format(URL_I + "#standalone-installers") + box = confirm_messagebox( - self, msg, version=self.latest_release, checkbox=True + self, msg, _('Spyder update'), + version=self.latest_release, checkbox=True ) if box.result() == QMessageBox.Yes: self._start_download() else: manual_update_messagebox( - self, self.latest_release, self.update_worker.channel) + self, self.latest_release, self.update_worker.channel + ) elif major_update: msg = _("Would you like to automatically download " "and install it?") box = confirm_messagebox( - self, msg, version=self.latest_release, checkbox=True + self, msg, _('Spyder update'), + version=self.latest_release, checkbox=True ) if box.result() == QMessageBox.Yes: self._start_download() @@ -313,7 +310,8 @@ def _start_download(self): """ self.cancelled = False self.download_worker = WorkerDownloadInstaller( - self.latest_release, self.installer_path, self.installer_size_path) + self.latest_release, self.installer_path, self.installer_size_path + ) self.sig_disable_actions.emit(True) self.set_status(DOWNLOADING_INSTALLER) @@ -330,7 +328,8 @@ def _start_download(self): lambda: self.sig_disable_actions.emit(False) ) self.download_worker.sig_download_progress.connect( - self._update_download_progress) + self._update_download_progress + ) self.download_worker.moveToThread(self.download_thread) self.download_thread.started.connect(self.download_worker.start) self.download_thread.start() @@ -359,7 +358,9 @@ def _cancel_download(self): """Cancel the installation in progress.""" self.download_worker.paused = True msg = _('Do you really want to cancel the download?') - box = confirm_messagebox(self, msg, critical=True) + box = confirm_messagebox( + self, msg, _('Spyder download'), critical=True + ) if box.result() == QMessageBox.Yes: self.cancelled = True self.cleanup_threads() @@ -388,7 +389,12 @@ def _confirm_install(self): msg = _("Would you like to install it?") box = confirm_messagebox( - self, msg, version=self.latest_release, on_close=True) + self, + msg, + _('Spyder install'), + version=self.latest_release, + on_close=True + ) if box.result() == QMessageBox.Yes: self.sig_install_on_close.emit(True) self.sig_quit_requested.emit() @@ -418,16 +424,23 @@ def start_install(self): elif sys.platform == 'darwin': # Terminal cannot accept a command with arguments therefore # create a temporary script - tmpdir = osp.join(gettempdir(), 'spyder') - tmpscript = osp.join(tmpdir, 'tmp_install.sh') - os.makedirs(tmpdir, exist_ok=True) + tmpscript = osp.join(get_temp_dir(), 'tmp_install.sh') with open(tmpscript, 'w') as f: f.write(' '.join(sub_cmd)) os.chmod(tmpscript, 0o711) # set executable permissions cmd = ['open', '-b', 'com.apple.terminal', tmpscript] else: - cmd = ['gnome-terminal', '--window', '--'] + sub_cmd + programs = [ + {'cmd': 'gnome-terminal', 'exe-opt': '--window --'}, + {'cmd': 'konsole', 'exe-opt': '-e'}, + {'cmd': 'xfce4-terminal', 'exe-opt': '-x'}, + {'cmd': 'xterm', 'exe-opt': '-e'} + ] + for program in programs: + if is_program_installed(program['cmd']): + cmd = [program['cmd'], program['exe-opt']] + sub_cmd + break subprocess.Popen(' '.join(cmd), shell=True) @@ -436,14 +449,12 @@ class UpdateMessageBox(QMessageBox): def __init__(self, icon=None, text=None, parent=None): super().__init__(icon=icon, text=text, parent=parent) self.setWindowModality(Qt.NonModal) - self.setWindowTitle(_("Spyder Update Manager")) self.setTextFormat(Qt.RichText) class UpdateMessageCheckBox(MessageCheckBox): def __init__(self, icon=None, text=None, parent=None): super().__init__(icon=icon, text=text, parent=parent) - self.setWindowTitle(_("Spyder Update Manager")) self.setTextFormat(Qt.RichText) self._parent = parent self.set_checkbox_text(_("Check for updates at startup")) @@ -462,6 +473,7 @@ class ProgressDialog(UpdateMessageBox): def __init__(self, parent, text): super().__init__(icon=QMessageBox.NoIcon, text=text, parent=parent) + self.setWindowTitle(_("Spyder update")) self._progress_bar = QProgressBar(self) self._progress_bar.setMinimumWidth(250) @@ -486,7 +498,9 @@ def update_progress(self, progress, total): def error_messagebox(parent, error_msg): box = UpdateMessageBox( - icon=QMessageBox.Warning, text=error_msg, parent=parent) + icon=QMessageBox.Warning, text=error_msg, parent=parent + ) + box.setWindowTitle(_("Spyder update error")) box.setStandardButtons(QMessageBox.Ok) box.setDefaultButton(QMessageBox.Ok) box.show() @@ -497,19 +511,22 @@ def info_messagebox(parent, message, version=None, checkbox=False): box_class = UpdateMessageCheckBox if checkbox else UpdateMessageBox message = HEADER.format(version) + message if version else message box = box_class(icon=QMessageBox.Information, text=message, parent=parent) + box.setWindowTitle(_("New Spyder version")) box.setStandardButtons(QMessageBox.Ok) box.setDefaultButton(QMessageBox.Ok) box.show() return box -def confirm_messagebox(parent, message, version=None, critical=False, +def confirm_messagebox(parent, message, title, version=None, critical=False, checkbox=False, on_close=False): box_class = UpdateMessageCheckBox if checkbox else UpdateMessageBox message = HEADER.format(version) + message if version else message box = box_class( icon=QMessageBox.Critical if critical else QMessageBox.Question, - text=message, parent=parent) + text=message, parent=parent + ) + box.setWindowTitle(title) box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) box.setDefaultButton(QMessageBox.Yes) if on_close: diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py index 011e7951f0c..773eef09e41 100644 --- a/spyder/plugins/updatemanager/workers.py +++ b/spyder/plugins/updatemanager/workers.py @@ -68,7 +68,7 @@ def __init__(self, stable_only): self.stable_only = stable_only self.latest_release = None self.releases = None - self.update_available = None + self.update_available = False self.error = None self.channel = None @@ -82,8 +82,11 @@ def _check_update_available(self): logger.debug(f"Available versions: {self.releases}") self.latest_release = releases[-1] if releases else __version__ - self.update_available = check_version(__version__, - self.latest_release, '<') + self.update_available = check_version( + __version__, + self.latest_release, + '<' + ) logger.debug(f"Update available: {self.update_available}") logger.debug(f"Latest release: {self.latest_release}") @@ -101,7 +104,9 @@ def start(self): elif is_anaconda(): self.channel, channel_url = get_spyder_conda_channel() - if channel_url is None or self.channel == "pypi": + if channel_url is None: + return + elif self.channel == "pypi": url = pypi_url else: url = channel_url + '/channeldata.json' @@ -139,8 +144,10 @@ def start(self): logger.warning(err, exc_info=err) except Exception as err: error = traceback.format_exc() - formatted_error = (error.replace('\n', '
') - .replace(' ', ' ')) + formatted_error = ( + error.replace('\n', '
') + .replace(' ', ' ') + ) error_msg = _( 'It was not possible to check for Spyder updates due to the ' @@ -151,7 +158,10 @@ def start(self): logger.warning(err, exc_info=err) finally: self.error = error_msg - self.sig_ready.emit() + try: + self.sig_ready.emit() + except RuntimeError: + pass class WorkerDownloadInstaller(QObject): @@ -254,8 +264,10 @@ def start(self): logger.warning(err, exc_info=err) except Exception as err: error = traceback.format_exc() - formatted_error = (error.replace('\n', '
') - .replace(' ', ' ')) + formatted_error = ( + error.replace('\n', '
') + .replace(' ', ' ') + ) error_msg = _( 'It was not possible to download the installer due to the ' @@ -267,4 +279,7 @@ def start(self): self._clean_installer_path() finally: self.error = error_msg - self.sig_ready.emit() + try: + self.sig_ready.emit() + except RuntimeError: + pass From 7b6b1a93afb211c398dc448fa3de362cb5de45e1 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Wed, 13 Dec 2023 08:57:40 -0800 Subject: [PATCH 17/22] Restore check for update preference to Application preference pane * Restore "Check for updates on startup" check box to Application plugin preference page. * Add "Check for stable releases only" check box to Application plugin preference page. --- spyder/plugins/application/confpage.py | 18 ++++++++++ spyder/plugins/updatemanager/confpage.py | 44 ------------------------ spyder/plugins/updatemanager/plugin.py | 2 -- 3 files changed, 18 insertions(+), 46 deletions(-) delete mode 100644 spyder/plugins/updatemanager/confpage.py diff --git a/spyder/plugins/application/confpage.py b/spyder/plugins/application/confpage.py index 0d7907b373a..e014e147859 100644 --- a/spyder/plugins/application/confpage.py +++ b/spyder/plugins/application/confpage.py @@ -23,6 +23,7 @@ from spyder.config.base import (_, DISABLED_LANGUAGES, LANGUAGE_CODES, is_conda_based_app, save_lang_conf) +from spyder.api.plugins import Plugins from spyder.api.preferences import PluginConfigPage from spyder.py3compat import to_text_string @@ -65,6 +66,16 @@ def setup_page(self): prompt_box = newcb(_("Prompt when exiting"), 'prompt_on_exit') popup_console_box = newcb(_("Show internal Spyder errors to report " "them to Github"), 'show_internal_errors') + check_update_cb = newcb( + _("Check for updates on startup"), + 'check_updates_on_startup', + section='update_manager' + ) + stable_only_cb = newcb( + _("Check for stable releases only"), + 'check_stable_only', + section='update_manager' + ) # Decide if it's possible to activate or not single instance mode # ??? Should we allow multiple instances for macOS? @@ -86,6 +97,8 @@ def setup_page(self): advanced_layout.addWidget(single_instance_box) advanced_layout.addWidget(prompt_box) advanced_layout.addWidget(popup_console_box) + advanced_layout.addWidget(check_update_cb) + advanced_layout.addWidget(stable_only_cb) advanced_widget = QWidget() advanced_widget.setLayout(advanced_layout) @@ -269,6 +282,11 @@ def apply_settings(self, options): self.set_option( 'high_dpi_custom_scale_factors', scale_factors_text) self.changed_options.add('high_dpi_custom_scale_factors') + + um = self.plugin.get_plugin(Plugins.UpdateManager, error=False) + if um and 'check_stable_only' in self.changed_options: + um.update_manager_status.set_no_status() + self.plugin.apply_settings() def _save_lang(self): diff --git a/spyder/plugins/updatemanager/confpage.py b/spyder/plugins/updatemanager/confpage.py deleted file mode 100644 index 280cde428c1..00000000000 --- a/spyder/plugins/updatemanager/confpage.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Update manager Preferences configuration page. -""" - -from qtpy.QtWidgets import QGroupBox, QVBoxLayout - -from spyder.config.base import _ -from spyder.api.preferences import PluginConfigPage - - -class UpdateManagerConfigPage(PluginConfigPage): - def setup_page(self): - """Setup config page widgets and options.""" - updates_group = QGroupBox(_("Updates")) - check_update_cb = self.create_checkbox( - _("Check for updates on startup"), - 'check_updates_on_startup' - ) - stable_only_cb = self.create_checkbox( - _("Check for stable releases only"), - 'check_stable_only' - ) - - updates_layout = QVBoxLayout() - updates_layout.addWidget(check_update_cb) - updates_layout.addWidget(stable_only_cb) - updates_group.setLayout(updates_layout) - - vlayout = QVBoxLayout() - vlayout.addWidget(updates_group) - vlayout.addStretch(1) - self.setLayout(vlayout) - - def apply_settings(self): - if 'check_stable_only' in self.changed_options: - self.plugin.update_manager_status.set_no_status() - - return set(self.changed_options) diff --git a/spyder/plugins/updatemanager/plugin.py b/spyder/plugins/updatemanager/plugin.py index fb299de9c09..39e6c4cb8e7 100644 --- a/spyder/plugins/updatemanager/plugin.py +++ b/spyder/plugins/updatemanager/plugin.py @@ -17,7 +17,6 @@ on_plugin_teardown ) from spyder.config.base import DEV -from spyder.plugins.updatemanager.confpage import UpdateManagerConfigPage from spyder.plugins.updatemanager.container import ( UpdateManagerActions, UpdateManagerContainer @@ -32,7 +31,6 @@ class UpdateManager(SpyderPluginV2): CONTAINER_CLASS = UpdateManagerContainer CONF_SECTION = 'update_manager' CONF_FILE = False - CONF_WIDGET_CLASS = UpdateManagerConfigPage CAN_BE_DISABLED = False # ---- SpyderPluginV2 API From 38f861e69907298760fba1bfaf445223a6845f63 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 14 Dec 2023 12:55:38 -0800 Subject: [PATCH 18/22] Use GITHUB_TOKEN with test steps in testing workflows to avoid error: "HTTPError: 403 Client Error: rate limit exceeded for url: https://api.github.com/repos/spyder-ide/spyder/releases" --- .github/workflows/test-linux.yml | 4 ++++ .github/workflows/test-mac.yml | 2 ++ .github/workflows/test-win.yml | 2 ++ 3 files changed, 8 insertions(+) diff --git a/.github/workflows/test-linux.yml b/.github/workflows/test-linux.yml index 974ffd672b5..a38ccf72750 100644 --- a/.github/workflows/test-linux.yml +++ b/.github/workflows/test-linux.yml @@ -140,9 +140,13 @@ jobs: - name: Run tests with gdb if: env.USE_GDB == 'true' shell: bash -l {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: xvfb-run --auto-servernum gdb -return-child-result -batch -ex r -ex py-bt --args python runtests.py -s - name: Run tests shell: bash -l {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | rm -f pytest_log.txt # Must remove any log file from a previous run .github/scripts/run_tests.sh || \ diff --git a/.github/workflows/test-mac.yml b/.github/workflows/test-mac.yml index d7d292139b8..66d4f5a7a79 100644 --- a/.github/workflows/test-mac.yml +++ b/.github/workflows/test-mac.yml @@ -110,6 +110,8 @@ jobs: run: check-manifest - name: Run tests shell: bash -l {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | rm -f pytest_log.txt # Must remove any log file from a previous run .github/scripts/run_tests.sh || \ diff --git a/.github/workflows/test-win.yml b/.github/workflows/test-win.yml index 95e4f0fcb85..6602ebde184 100644 --- a/.github/workflows/test-win.yml +++ b/.github/workflows/test-win.yml @@ -134,6 +134,8 @@ jobs: run: check-manifest - name: Run tests shell: bash -l {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | rm -f pytest_log.txt # Must remove any log file from a previous run .github/scripts/run_tests.sh || \ From a5300bebd0a90a6845d517f7224720f6266e85d8 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 14 Dec 2023 18:35:28 -0800 Subject: [PATCH 19/22] Add UpdateManager to Application.OPTIONAL attribute --- spyder/plugins/application/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/application/plugin.py b/spyder/plugins/application/plugin.py index 503631f9620..d6915189ab1 100644 --- a/spyder/plugins/application/plugin.py +++ b/spyder/plugins/application/plugin.py @@ -38,7 +38,7 @@ class Application(SpyderPluginV2): NAME = 'application' REQUIRES = [Plugins.Console, Plugins.Preferences] OPTIONAL = [Plugins.Help, Plugins.MainMenu, Plugins.Shortcuts, - Plugins.Editor, Plugins.StatusBar] + Plugins.Editor, Plugins.StatusBar, Plugins.UpdateManager] CONTAINER_CLASS = ApplicationContainer CONF_SECTION = 'main' CONF_FILE = False From 2e27a02f3b7269ca1b0b60ff68b9563353cc719e Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 14 Dec 2023 21:38:04 -0800 Subject: [PATCH 20/22] Fix issue where 'check_stable_only' was incorrectly compared to self.changed_options --- spyder/plugins/application/confpage.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spyder/plugins/application/confpage.py b/spyder/plugins/application/confpage.py index e014e147859..74017b0dc1a 100644 --- a/spyder/plugins/application/confpage.py +++ b/spyder/plugins/application/confpage.py @@ -284,7 +284,10 @@ def apply_settings(self, options): self.changed_options.add('high_dpi_custom_scale_factors') um = self.plugin.get_plugin(Plugins.UpdateManager, error=False) - if um and 'check_stable_only' in self.changed_options: + if ( + um + and ('update_manager', 'check_stable_only') in self.changed_options + ): um.update_manager_status.set_no_status() self.plugin.apply_settings() From f5ca4a0b3fb820bf86d1cec61e4c8c10da20dd01 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Thu, 14 Dec 2023 21:42:02 -0800 Subject: [PATCH 21/22] Display CLI notice when updating or installing, not just when updating. For macOS and Linux, do not run installer if uninstaller is cancelled or fails. --- .../plugins/updatemanager/scripts/install.bat | 14 +++++++----- .../plugins/updatemanager/scripts/install.sh | 22 ++++++++++--------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/spyder/plugins/updatemanager/scripts/install.bat b/spyder/plugins/updatemanager/scripts/install.bat index e0c8297cddd..9d80d90ee92 100644 --- a/spyder/plugins/updatemanager/scripts/install.bat +++ b/spyder/plugins/updatemanager/scripts/install.bat @@ -15,6 +15,14 @@ GOTO parse :: Enforce encoding chcp 65001>nul +echo ========================================================= +echo Updating Spyder +echo --------------- +echo +echo IMPORTANT: Do not close this window until it has finished +echo ========================================================= +echo + IF not "%conda%"=="" IF not "%spy_ver%"=="" ( call :update_subroutine call :launch_spyder @@ -59,13 +67,7 @@ exit %ERRORLEVEL% goto :EOF :update_subroutine - echo ========================================================= echo Updating Spyder - echo --------------- - echo - echo IMPORTANT: Do not close this window until it has finished - echo ========================================================= - echo call :wait_for_spyder_quit diff --git a/spyder/plugins/updatemanager/scripts/install.sh b/spyder/plugins/updatemanager/scripts/install.sh index 4d21ed7351e..9b7b42c37c3 100755 --- a/spyder/plugins/updatemanager/scripts/install.sh +++ b/spyder/plugins/updatemanager/scripts/install.sh @@ -1,5 +1,5 @@ #!/bin/bash -i -set -e + unset HISTFILE # Do not write to history with interactive shell while getopts "i:c:p:v:" option; do @@ -13,15 +13,6 @@ done shift $(($OPTIND - 1)) update_spyder(){ - cat < 0 ]] && return fi # Run installer [[ "$OSTYPE" = "darwin"* ]] && open $install_exe || sh $install_exe } +cat < /dev/null) ]]; do echo "Waiting for Spyder to quit..." sleep 1 From 2d9d5dec17470069d5c27888a36884960560f924 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Fri, 15 Dec 2023 13:11:23 -0800 Subject: [PATCH 22/22] Fix issue in installer batch script where echo reported echo state rather than a blank line. --- spyder/plugins/updatemanager/scripts/install.bat | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/updatemanager/scripts/install.bat b/spyder/plugins/updatemanager/scripts/install.bat index 9d80d90ee92..e87c86d43af 100644 --- a/spyder/plugins/updatemanager/scripts/install.bat +++ b/spyder/plugins/updatemanager/scripts/install.bat @@ -18,10 +18,10 @@ chcp 65001>nul echo ========================================================= echo Updating Spyder echo --------------- -echo +echo. echo IMPORTANT: Do not close this window until it has finished echo ========================================================= -echo +echo. IF not "%conda%"=="" IF not "%spy_ver%"=="" ( call :update_subroutine