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 || \ 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 1458e45564c..98dcc64bc35 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -78,12 +78,16 @@ '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, 'show_dpi_message': True, }), + ('update_manager', + { + 'check_updates_on_startup': True, + 'check_stable_only': True, + }), ('toolbar', { 'enable': True, @@ -663,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' diff --git a/spyder/plugins/application/confpage.py b/spyder/plugins/application/confpage.py index b55a2083f58..74017b0dc1a 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,8 +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_updates = newcb(_("Check for updates on startup"), - 'check_updates_on_startup') + 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? @@ -88,7 +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_updates) + advanced_layout.addWidget(check_update_cb) + advanced_layout.addWidget(stable_only_cb) advanced_widget = QWidget() advanced_widget.setLayout(advanced_layout) @@ -272,6 +282,14 @@ 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 ('update_manager', '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/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..d6915189ab1 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 ( @@ -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 @@ -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/application/widgets/install.py b/spyder/plugins/application/widgets/install.py deleted file mode 100644 index 4d05efe25e4..00000000000 --- a/spyder/plugins/application/widgets/install.py +++ /dev/null @@ -1,317 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Update installation widgets.""" - -# Standard library imports -import logging -import os -import subprocess - -# Third-party imports -from qtpy.QtCore import Qt, QThread, Signal -from qtpy.QtWidgets import (QDialog, QHBoxLayout, QMessageBox, - QLabel, QProgressBar, QPushButton, QVBoxLayout, - QWidget) - -# Local imports -from spyder import __version__ -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 - -# Logger setup -logger = logging.getLogger(__name__) - -# Update installation process statuses -NO_STATUS = __version__ -DOWNLOADING_INSTALLER = _("Downloading update") -DOWNLOAD_FINISHED = _("Download finished") -INSTALLING = _("Installing update") -FINISHED = _("Installation finished") -PENDING = _("Update available") -CHECKING = _("Checking for updates") -CANCELLED = _("Cancelled update") - -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") -} - - -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 UpdateInstallerDialog(QDialog): - """Update installer dialog.""" - - sig_download_progress = Signal(int, int) - """ - Signal to get 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. - """ - - sig_installation_status = Signal(str, str) - """ - Signal to get the current status of the update installation. - - Parameters - ---------- - status: str - Status string. - latest_release: str - Latest release version detected. - """ - - sig_install_on_close_requested = Signal(str) - """ - Signal to request running the downloaded installer on close. - - Parameters - ---------- - installer_path: str - Path to the installer executable. - """ - - def __init__(self, parent): - self.cancelled = False - self.status = NO_STATUS - self.download_thread = None - self.download_worker = None - self.installer_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() - else: - super().reject() - - def setup(self): - """Setup visibility of widgets.""" - self._installation_widget.setVisible(True) - self.adjustSize() - - def save_latest_release(self, latest_release_version): - self.latest_release_version = latest_release_version - - def start_installation(self, latest_release_version): - """Start downloading the update and set downloading status.""" - self.latest_release_version = latest_release_version - 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.download_worker.sig_ready.connect(self.download_thread.quit) - self.download_worker.sig_download_progress.connect( - self.sig_download_progress.emit) - self.download_worker.moveToThread(self.download_thread) - self.download_thread.started.connect(self.download_worker.start) - self.download_thread.start() - - def cancel_installation(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.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) - else: - self._change_update_installation_status(status=PENDING) - - def confirm_installation(self, installer_path): - """ - Ask users if they want to proceed with the installer execution. - """ - 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) - else: - self._change_update_installation_status(status=PENDING) - - def finish_installation(self): - """Handle finished installation.""" - self.setup() - self.accept() - - 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) diff --git a/spyder/plugins/application/widgets/status.py b/spyder/plugins/application/widgets/status.py deleted file mode 100644 index 051ed90b4c5..00000000000 --- a/spyder/plugins/application/widgets/status.py +++ /dev/null @@ -1,171 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Status widget for Spyder updates. -""" - -# Standard library imports -import logging -import os - -# Third party imports -from qtpy.QtCore import QPoint, Qt, Signal, Slot -from qtpy.QtWidgets import QLabel - -# Local imports -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.utils.icon_manager import ima -from spyder.utils.qthelpers import add_actions, create_action - - -# Setup logger -logger = logging.getLogger(__name__) - - -class ApplicationUpdateStatus(StatusBarWidget): - """Status bar widget for application update status.""" - BASE_TOOLTIP = _("Application update status") - ID = 'application_update_status' - - sig_check_for_updates_requested = Signal() - """ - Signal to request checking for updates. - """ - - sig_install_on_close_requested = Signal(str) - """ - Signal to request running the downloaded installer on close. - - Parameters - ---------- - installer_path: str - Path to instal - """ - - CUSTOM_WIDGET_CLASS = QLabel - - 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) - - # Set aligment attributes for custom widget to match default label - # values - 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) - - 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: - self.custom_widget.hide() - self.spinner.show() - self.spinner.start() - self.installer.show() - elif value == PENDING: - self.tooltip = value - self.custom_widget.hide() - self.spinner.hide() - self.spinner.stop() - else: - self.tooltip = self.BASE_TOOLTIP - if self.custom_widget: - self.custom_widget.hide() - if self.spinner: - self.spinner.hide() - self.spinner.stop() - self.setVisible(True) - self.update_tooltip() - value = f"Spyder: {value}" - logger.debug(f"Application Update Status: {value}") - super().set_value(value) - - def get_tooltip(self): - """Reimplementation to get a dynamic tooltip.""" - return self.tooltip - - 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() - - @Slot() - def show_installation_dialog_or_menu(self): - """Show installation dialog or 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() - 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 - ) - 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)) - self.menu.popup(pos) diff --git a/spyder/plugins/statusbar/plugin.py b/spyder/plugins/statusbar/plugin.py index 4d7d6ded8c1..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', 'application_update_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', 'application_update_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/container.py b/spyder/plugins/updatemanager/container.py new file mode 100644 index 00000000000..33086ec073b --- /dev/null +++ b/spyder/plugins/updatemanager/container.py @@ -0,0 +1,110 @@ +# -*- 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 + + # ---- PluginMainContainer API + # ------------------------------------------------------------------------- + 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 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) + + @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 diff --git a/spyder/plugins/updatemanager/plugin.py b/spyder/plugins/updatemanager/plugin.py new file mode 100644 index 00000000000..39e6c4cb8e7 --- /dev/null +++ b/spyder/plugins/updatemanager/plugin.py @@ -0,0 +1,138 @@ +# -*- 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 import __version__ +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.container import ( + UpdateManagerActions, + UpdateManagerContainer +) +from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections + + +class UpdateManager(SpyderPluginV2): + NAME = 'update_manager' + REQUIRES = [Plugins.Preferences] + OPTIONAL = [Plugins.MainMenu, Plugins.StatusBar] + CONTAINER_CLASS = UpdateManagerContainer + CONF_SECTION = 'update_manager' + CONF_FILE = False + CAN_BE_DISABLED = False + + # ---- SpyderPluginV2 API + # ------------------------------------------------------------------------- + @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 # Not bootstrap + and 'dev' not in __version__ # Not dev version + and self.get_conf('check_updates_on_startup') + ): + container.start_check_update(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): + """Get Update manager statusbar widget""" + 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..e87c86d43af --- /dev/null +++ b/spyder/plugins/updatemanager/scripts/install.bat @@ -0,0 +1,96 @@ +:: 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 + +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 + 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..9b7b42c37c3 --- /dev/null +++ b/spyder/plugins/updatemanager/scripts/install.sh @@ -0,0 +1,72 @@ +#!/bin/bash -i + +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..." + echo "" + $uninstall_script + [[ $? > 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 +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/workers/tests/__init__.py b/spyder/plugins/updatemanager/tests/__init__.py similarity index 88% rename from spyder/workers/tests/__init__.py rename to spyder/plugins/updatemanager/tests/__init__.py index 92b07bcf58c..f984ad47da2 100644 --- a/spyder/workers/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 new file mode 100644 index 00000000000..ad26237643e --- /dev/null +++ b/spyder/plugins/updatemanager/tests/test_update_manager.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +import os +import logging + +import pytest + +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_available = um.update_worker.update_available + if version.split('.')[0] == '1': + assert update_available + else: + assert not update_available + assert len(um.update_worker.releases) > 1 + + +@pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"]) +@pytest.mark.parametrize( + "channel", [ + ("pkgs/main", "https://repo.anaconda.com/pkgs/main"), + ("conda-forge", "https://conda.anaconda.org/conda-forge"), + ("pypi", "https://conda.anaconda.org/pypi") + ] +) +def test_updates_condaenv(qtbot, worker, mocker, version, channel): + """ + 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) + mocker.patch.object(workers, "is_conda_based_app", return_value=False) + mocker.patch.object( + workers, "get_spyder_conda_channel", return_value=channel + ) + + with qtbot.waitSignal(worker.sig_ready, timeout=5000): + worker.start() + + 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.""" + 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") + ) + + with qtbot.waitSignal(worker.sig_ready, timeout=5000): + worker.start() + + 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("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 = WorkerUpdate(stable_only) + worker.releases = [release] + worker._check_update_available() + + 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 + ) + + um._start_download() + qtbot.waitUntil(um.download_thread.isFinished, timeout=60000) + + assert os.path.exists(um.installer_path) + + +if __name__ == "__main__": + pytest.main() diff --git a/spyder/plugins/application/widgets/__init__.py b/spyder/plugins/updatemanager/widgets/__init__.py similarity index 77% rename from spyder/plugins/application/widgets/__init__.py rename to spyder/plugins/updatemanager/widgets/__init__.py index 5a3f944db10..e35c9c7b676 100644 --- a/spyder/plugins/application/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 new file mode 100644 index 00000000000..7d7468635cd --- /dev/null +++ b/spyder/plugins/updatemanager/widgets/status.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Status widget for Spyder updates. +""" + +# Standard library imports +import logging +import os + +# Third party imports +from qtpy.QtCore import QPoint, Qt, Signal, Slot +from qtpy.QtWidgets import QLabel + +# Local imports +from spyder.api.translations import _ +from spyder.api.widgets.menus import SpyderMenu +from spyder.api.widgets.status import StatusBarWidget +from spyder.plugins.updatemanager.widgets.update import ( + 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 + + +# Setup logger +logger = logging.getLogger(__name__) + + +class UpdateManagerStatus(StatusBarWidget): + """Status bar widget for update manager.""" + 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 the update process""" + + sig_show_progress_dialog = Signal(bool) + """ + Signal to show the progress dialog. + + Parameters + ---------- + show: bool + True to show, False to hide. + """ + + CUSTOM_WIDGET_CLASS = QLabel + + def __init__(self, parent): + + self.tooltip = self.BASE_TOOLTIP + super().__init__(parent, show_spinner=True) + + # Check for updates action menu + self.menu = SpyderMenu(self) + + # Set aligment attributes for custom widget to match default label + # values + self.custom_widget.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + # Signals + self.sig_clicked.connect(self.show_dialog_or_menu) + + def set_value(self, value): + """Set update manager status.""" + if value == DOWNLOADING_INSTALLER: + self.tooltip = _( + "Downloading the 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 + self.custom_widget.hide() + self.spinner.show() + self.spinner.start() + elif value == PENDING: + self.tooltip = value + self.custom_widget.hide() + self.spinner.hide() + self.spinner.stop() + else: + self.tooltip = self.BASE_TOOLTIP + if self.custom_widget: + self.custom_widget.hide() + if self.spinner: + self.spinner.hide() + self.spinner.stop() + + self.setVisible(True) + self.update_tooltip() + value = f"Spyder: {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 + + def get_icon(self): + return ima.icon('spyder_about') + + def set_download_progress(self, percent_progress): + """Set download progress in status bar""" + self.custom_widget.setText(f"{percent_progress}%") + + @Slot() + def show_dialog_or_menu(self): + """Show download dialog or status bar menu.""" + value = self.value.split(":")[-1].strip() + 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_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) + ) + self.menu.popup(pos) diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py new file mode 100644 index 00000000000..7287af3dee6 --- /dev/null +++ b/spyder/plugins/updatemanager/widgets/update.py @@ -0,0 +1,604 @@ +# -*- coding: utf-8 -*- + +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Update Manager widgets.""" + +# Standard library imports +import logging +import os +import os.path as osp +import sys +import subprocess +import platform + +# Third-party imports +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.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.utils.programs import get_temp_dir, is_program_installed +from spyder.widgets.helperwidgets import MessageCheckBox + +# Logger setup +logger = logging.getLogger(__name__) + +# Update installation process statuses +NO_STATUS = __version__ +DOWNLOADING_INSTALLER = _("Downloading update") +DOWNLOAD_FINISHED = _("Download finished") +INSTALLING = _("Installing update") +FINISHED = _("Installation finished") +PENDING = _("Update available") +CHECKING = _("Checking for updates") +CANCELLED = _("Cancelled update") +INSTALL_ON_CLOSE = _("Install on close") + +HEADER = _("

Spyder {} is available!


") +URL_I = 'https://docs.spyder-ide.org/current/installation.html' + + +class UpdateManagerWidget(QWidget, SpyderConfigurationAccessor): + """Check for updates widget.""" + + CONF_SECTION = "update_manager" + + 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. + + Parameters + ---------- + block: bool + True to block, False to unblock. + """ + + sig_download_progress = Signal(int) + """ + Signal to send the download progress. + + Parameters + ---------- + percent_progress: int + Percent of the data downloaded until now. + """ + + sig_set_status = Signal(str, str) + """ + Signal to set the status of update manager. + + Parameters + ---------- + status: str + Status string. + latest_release: str + Latest release version detected. + """ + + sig_install_on_close = Signal(bool) + """ + Signal to request running the install process on close. + + Parameters + ---------- + 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.startup = None + self.update_thread = None + self.update_worker = None + self.update_timer = None + self.latest_release = None + + self.cancelled = False + self.download_thread = None + self.download_worker = None + self.progress_dialog = None + self.installer_path = None + self.installer_size_path = None + + # ---- 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 + + # 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... + (error_msg is not None # 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 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(get_temp_dir(), '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 = int(f.read().strip()) + return size == osp.getsize(self.installer_path) + else: + return False + + 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. + + 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() + 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, _('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 + ) + elif major_update: + msg = _("Would you like to automatically download " + "and install it?") + box = confirm_messagebox( + self, msg, _('Spyder update'), + 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_download(self): + """ + Start downloading the installer in a QThread + and set downloading status. + """ + self.cancelled = False + self.download_worker = WorkerDownloadInstaller( + 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._update_download_progress + ) + self.download_worker.moveToThread(self.download_thread) + self.download_thread.started.connect(self.download_worker.start) + self.download_thread.start() + + 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.""" + self.download_worker.paused = True + msg = _('Do you really want to cancel the download?') + box = confirm_messagebox( + self, msg, _('Spyder download'), critical=True + ) + if box.result() == QMessageBox.Yes: + self.cancelled = True + self.cleanup_threads() + self.set_status(PENDING) + else: + self.progress_dialog.show() + self.download_worker.paused = False + + def _confirm_install(self): + """ + Ask users if they want to proceed with the install immediately + or on close. + """ + if self.cancelled: + return + + 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, + _('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() + 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 + 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: + 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) + + +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.setTextFormat(Qt.RichText) + + +class UpdateMessageCheckBox(MessageCheckBox): + def __init__(self, icon=None, text=None, parent=None): + super().__init__(icon=icon, text=text, parent=parent) + 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.setWindowTitle(_("Spyder update")) + + 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.setWindowTitle(_("Spyder update error")) + 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.setWindowTitle(_("New Spyder version")) + box.setStandardButtons(QMessageBox.Ok) + box.setDefaultButton(QMessageBox.Ok) + box.show() + return box + + +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 + ) + box.setWindowTitle(title) + 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 new file mode 100644 index 00000000000..773eef09e41 --- /dev/null +++ b/spyder/plugins/updatemanager/workers.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +# Standard library imports +import logging +import os +import os.path as osp +from time import sleep +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 + +# Local imports +from spyder import __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 + +# Logger setup +logger = logging.getLogger(__name__) + +CONNECT_ERROR_MSG = _( + 'Unable to connect to the Spyder update service.' + '

Make sure your connection is working properly.' +) + +HTTP_ERROR_MSG = _( + 'HTTP error {status_code} when checking for updates.' + '

Make sure your connection is working properly,' + 'and try again later.' +) + +SSL_ERROR_MSG = _( + 'SSL certificate verification failed while checking for Spyder updates.' + '

Please contact your network administrator for assistance.' +) + + +class UpdateDownloadCancelledException(Exception): + """Download for installer to update was cancelled.""" + pass + + +class UpdateDownloadIncompleteError(Exception): + """Error occured while downloading file""" + pass + + +class WorkerUpdate(QObject): + """ + Worker that checks for releases using either the Anaconda + default channels or the Github Releases page without + blocking the Spyder user interface, in case of connection + issues. + """ + sig_ready = Signal() + + def __init__(self, stable_only): + super().__init__() + self.stable_only = stable_only + self.latest_release = None + self.releases = None + self.update_available = False + self.error = None + self.channel = None + + def _check_update_available(self): + """Checks if there is an update available from releases.""" + # Filter releases + 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}") + + self.latest_release = releases[-1] if releases else __version__ + 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}") + + def start(self): + """Main method of the worker.""" + self.error = None + self.latest_release = None + self.update_available = False + error_msg = None + pypi_url = "https://pypi.org/pypi/spyder/json" + + if is_conda_based_app(): + url = 'https://api.github.com/repos/spyder-ide/spyder/releases' + elif is_anaconda(): + self.channel, channel_url = get_spyder_conda_channel() + + if channel_url is None: + return + elif self.channel == "pypi": + url = pypi_url + else: + url = channel_url + '/channeldata.json' + else: + url = pypi_url + + logger.info(f"Checking for updates from {url}") + try: + page = requests.get(url) + page.raise_for_status() + data = page.json() + + if self.releases is None: + if is_conda_based_app(): + self.releases = [ + item['tag_name'].replace('v', '') for item in data + ] + 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: + error_msg = SSL_ERROR_MSG + logger.warning(err, exc_info=err) + except ConnectionError as err: + error_msg = CONNECT_ERROR_MSG + logger.warning(err, exc_info=err) + except HTTPError as err: + 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() + formatted_error = ( + error.replace('\n', '
') + .replace(' ', ' ') + ) + + error_msg = _( + 'It was not possible to check for Spyder updates due to the ' + 'following error:' + '

' + '{}' + ).format(formatted_error) + logger.warning(err, exc_info=err) + finally: + self.error = error_msg + try: + self.sig_ready.emit() + except RuntimeError: + pass + + +class WorkerDownloadInstaller(QObject): + """ + Worker that donwloads standalone installers for Windows, macOS, + and Linux without blocking the Spyder user interface. + """ + + sig_ready = Signal() + """Signal to inform that the worker has finished successfully.""" + + sig_download_progress = Signal(int, int) + """ + 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. + """ + + 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.paused = False + + def _progress_reporter(self, progress, total_size): + """Calculate download progress and notify.""" + self.sig_download_progress.emit(progress, total_size) + + while self.paused and not self.cancelled: + sleep(0.5) + + if self.cancelled: + raise UpdateDownloadCancelledException() + + def _download_installer(self): + """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}") + + dirname = osp.dirname(self.installer_path) + os.makedirs(dirname, exist_ok=True) + + with requests.get(url, stream=True) as r: + 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_read = 0 + for chunk in r.iter_content(chunk_size=chunk_size): + size_read += len(chunk) + f.write(chunk) + self._progress_reporter(size_read, size) + + 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 worker.""" + error_msg = None + try: + self._download_installer() + except UpdateDownloadCancelledException: + logger.info("Download cancelled") + self._clean_installer_path() + except SSLError as err: + error_msg = SSL_ERROR_MSG + logger.warning(err, exc_info=err) + except ConnectionError as err: + error_msg = CONNECT_ERROR_MSG + logger.warning(err, exc_info=err) + except Exception as err: + error = traceback.format_exc() + formatted_error = ( + error.replace('\n', '
') + .replace(' ', ' ') + ) + + error_msg = _( + 'It was not possible to download the installer due to the ' + 'following error:' + '

' + '{}' + ).format(formatted_error) + logger.warning(err, exc_info=err) + self._clean_installer_path() + finally: + self.error = error_msg + try: + self.sig_ready.emit() + except RuntimeError: + pass 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) -# ----------------------------------------------------------------------------- diff --git a/spyder/workers/tests/test_update.py b/spyder/workers/tests/test_update.py deleted file mode 100644 index eaaf9fc3f52..00000000000 --- a/spyder/workers/tests/test_update.py +++ /dev/null @@ -1,112 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -import sys - -import pytest - -from spyder.workers.updates import WorkerUpdates - - -@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", [ - ("pkgs/main", "https://repo.anaconda.com/pkgs/main"), - ("conda-forge", "https://conda.anaconda.org/conda-forge") - ] -) -def test_updates(qtbot, mocker, is_anaconda, is_pypi, version, - spyder_conda_channel): - """ - Test whether or not we offer updates for Anaconda and PyPI according to the - current Spyder version. - """ - mocker.patch( - "spyder.workers.updates.is_anaconda", - return_value=is_anaconda - ) - - if is_anaconda: - if is_pypi: - channel = ("pypi", "https://conda.anaconda.org/pypi") - else: - channel = spyder_conda_channel - - 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 - 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): - """ - 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=False) - mocker.patch("spyder.workers.updates.is_conda_based_app", - return_value=True) - - worker = WorkerUpdates(None, False, version=version) - worker.start() - - 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") - ) - - worker = WorkerUpdates(None, False, version="3.3.2.dev0", - releases=['3.3.1']) - worker.start() - assert not worker.update_available - - -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 - - -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") - ) - - worker = WorkerUpdates(None, False, version="4.0.0b3", - releases=['4.0.0']) - worker.start() - assert worker.update_available - - -if __name__ == "__main__": - pytest.main() diff --git a/spyder/workers/updates.py b/spyder/workers/updates.py deleted file mode 100644 index a3643bcae25..00000000000 --- a/spyder/workers/updates.py +++ /dev/null @@ -1,311 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -# Standard library imports -import logging -import os -import os.path as osp -import tempfile -import traceback - -# Third party imports -from qtpy.QtCore import QObject, Signal -import requests -from requests.exceptions import ConnectionError, HTTPError, SSLError - -# Local imports -from spyder import __version__ -from spyder.config.base import _, is_conda_based_app, is_stable_version -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 - -# Logger setup -logger = logging.getLogger(__name__) - -CONNECT_ERROR_MSG = _( - 'Unable to connect to the Spyder update service.' - '

Make sure your connection is working properly.' -) - -HTTP_ERROR_MSG = _( - 'HTTP error {status_code} when checking for updates.' - '

Make sure your connection is working properly,' - 'and try again later.' -) - -SSL_ERROR_MSG = _( - 'SSL certificate verification failed while checking for Spyder updates.' - '

Please contact your network administrator for assistance.' -) - - -class UpdateDownloadCancelledException(Exception): - """Download for installer to update was cancelled.""" - pass - - -class UpdateDownloadIncompleteError(Exception): - """Error occured while downloading file""" - pass - - -class WorkerUpdates(QObject): - """ - Worker that checks for releases using either the Anaconda - default channels or the Github Releases page without - blocking the Spyder user interface, in case of connection - issues. - """ - sig_ready = Signal() - - def __init__(self, parent, startup, version="", releases=None): - QObject.__init__(self) - self._parent = parent - self.error = None - 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) - - # 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] - - latest_release = releases[-1] - - return (check_version(self.version, latest_release, '<'), - latest_release) - - def start(self): - """Main method of the worker.""" - 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') - elif is_anaconda(): - channel, channel_url = get_spyder_conda_channel() - - if channel_url is None: - return - elif channel == "pypi": - self.url = pypi_url - else: - self.url = channel_url + '/channeldata.json' - else: - self.url = pypi_url - - try: - logger.debug(f"Checking for updates from {self.url}") - page = requests.get(self.url) - page.raise_for_status() - data = page.json() - - if is_conda_based_app(): - if self.releases is None: - 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: - spyder_data = data['packages'].get('spyder') - if spyder_data: - self.releases = [spyder_data["version"]] - else: - if self.releases is None: - self.releases = [data['info']['version']] - - result = self.check_update_available() - self.update_available, self.latest_release = result - except SSLError as err: - error_msg = SSL_ERROR_MSG - logger.debug(err, stack_info=True) - except ConnectionError as err: - error_msg = CONNECT_ERROR_MSG - logger.debug(err, stack_info=True) - except HTTPError as err: - error_msg = HTTP_ERROR_MSG.format(status_code=page.status_code) - logger.debug(err, stack_info=True) - except Exception as err: - error = traceback.format_exc() - formatted_error = error.replace('\n', '
').replace(' ', ' ') - - error_msg = _( - 'It was not possible to check for Spyder updates due to the ' - 'following error:' - '

' - '{}' - ).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): - self.error = error_msg - try: - self.sig_ready.emit() - except RuntimeError: - pass - - -class WorkerDownloadInstaller(QObject): - """ - Worker that donwloads standalone installers for Windows - and MacOS without blocking the Spyder user interface. - """ - - sig_ready = Signal(str) - """ - Signal to inform that the worker has finished successfully. - - Parameters - ---------- - installer_path: str - Path where the downloaded installer is located. - """ - - sig_download_progress = Signal(int, int) - """ - Signal to get 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. - """ - - def __init__(self, parent, latest_release_version): - QObject.__init__(self) - self._parent = parent - self.latest_release_version = latest_release_version - self.error = None - self.cancelled = False - self.installer_path = None - - def _progress_reporter(self, block_number, read_size, total_size): - """Calculate download progress and notify.""" - progress = 0 - if total_size > 0: - progress = block_number * read_size - 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 - - if osp.isfile(installer_path): - # Installer already downloaded - logger.info(f"{installer_path} already downloaded") - self._progress_reporter(1, 1, 1) - return - - 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: - 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) - - if size >= 0 and size_read < size: - raise UpdateDownloadIncompleteError( - "Download incomplete: retrieved only " - f"{size_read} out of {size} bytes." - ) - - def start(self): - """Main method of the WorkerDownloadInstaller worker.""" - error_msg = None - try: - self._download_installer() - except UpdateDownloadCancelledException: - if self.installer_path: - os.remove(self.installer_path) - return - except SSLError as err: - error_msg = SSL_ERROR_MSG - logger.debug(err, stack_info=True) - except ConnectionError as err: - error_msg = CONNECT_ERROR_MSG - logger.debug(err, stack_info=True) - except Exception as err: - error = traceback.format_exc() - formatted_error = error.replace('\n', '
').replace(' ', ' ') - - error_msg = _( - 'It was not possible to download the installer due to the ' - 'following error:' - '

' - '{}' - ).format(formatted_error) - logger.debug(err, stack_info=True) - self.error = error_msg - try: - self.sig_ready.emit(self.installer_path) - except RuntimeError: - pass