diff --git a/spyder/plugins/application/container.py b/spyder/plugins/application/container.py index 4bc0e1955ec..6f48ffada9e 100644 --- a/spyder/plugins/application/container.py +++ b/spyder/plugins/application/container.py @@ -21,13 +21,15 @@ from qtpy.QtWidgets import QAction, QMessageBox, QPushButton # Local imports -from spyder import __docs_url__, __forum_url__, __trouble_url__ +from spyder import __docs_url__, __forum_url__, __trouble_url__, __version__ from spyder import dependencies from spyder.api.translations import get_translation 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 +from spyder.config.base import (get_conf_path, get_debug_level, is_pynsist, + running_in_mac_app) +from spyder.plugins.application.widgets.status import ApplicationUpdateStatus from spyder.plugins.console.api import ConsoleActions from spyder.utils.qthelpers import start_file, DialogManager from spyder.widgets.about import AboutDialog @@ -99,6 +101,13 @@ def __init__(self, name, plugin, parent=None): # ---- PluginMainContainer API # ------------------------------------------------------------------------- def setup(self): + + self.application_update_status = ApplicationUpdateStatus(parent=self) + self.application_update_status.sig_check_for_updates_requested.connect( + self.check_updates + ) + self.application_update_status.set_no_status() + # Compute dependencies in a thread to not block the interface. self.dependencies_thread = QThread(None) @@ -235,6 +244,7 @@ def show_windows_env_variables(self): # ---- Updates # ------------------------------------------------------------------------- + def _check_updates_ready(self): """Show results of the Spyder update checking process.""" @@ -278,11 +288,16 @@ def _check_updates_ready(self): box.exec_() check_updates = box.is_checked() else: + if update_available: - header = _("Spyder {} is available!

").format( - latest_release) + self.application_update_status.set_status_pending( + latest_release=latest_release) + + header = _("Spyder {} is available! " + "(you have {})

").format( + latest_release, __version__) footer = _( - "For more information visit our " + "For more information, visit our " "installation guide." ).format(url_i) if is_anaconda(): @@ -296,15 +311,25 @@ def _check_updates_ready(self): "conda update anaconda
" "conda install spyder={}

" ).format(latest_release) - else: + elif is_pynsist() or running_in_mac_app(): + box.setStandardButtons(QMessageBox.Yes | + QMessageBox.No) content = _( - "Click this link to " - "download it.

" - ).format(url_r) + "Would you like to automatically download and " + "install it?

" + ) + msg = header + content + footer box.setText(msg) box.set_check_visible(True) - box.show() + box.exec_() + + if box.result() == QMessageBox.Yes: + self.application_update_status.start_installation( + latest_release=latest_release) + elif(box.result() == QMessageBox.No): + self.application_update_status.set_status_pending( + latest_release=latest_release) check_updates = box.is_checked() elif feedback: msg = _("Spyder is up to date.") @@ -312,7 +337,9 @@ def _check_updates_ready(self): box.set_check_visible(False) box.exec_() check_updates = box.is_checked() - + self.application_update_status.set_no_status() + else: + self.application_update_status.set_no_status() # Update checkbox based on user interaction self.set_conf(option, check_updates) @@ -327,6 +354,7 @@ 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) + self.application_update_status.set_status_checking() if self.thread_updates is not None: self.thread_updates.quit() diff --git a/spyder/plugins/application/plugin.py b/spyder/plugins/application/plugin.py index 1be26f891ca..ed0a95b28d3 100644 --- a/spyder/plugins/application/plugin.py +++ b/spyder/plugins/application/plugin.py @@ -43,7 +43,7 @@ class Application(SpyderPluginV2): NAME = 'application' REQUIRES = [Plugins.Console, Plugins.Preferences] OPTIONAL = [Plugins.Help, Plugins.MainMenu, Plugins.Shortcuts, - Plugins.Editor] + Plugins.Editor, Plugins.StatusBar] CONTAINER_CLASS = ApplicationContainer CONF_SECTION = 'main' CONF_FILE = False @@ -101,7 +101,20 @@ 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 + 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 + 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) @@ -443,3 +456,7 @@ 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/__init__.py b/spyder/plugins/application/widgets/__init__.py new file mode 100644 index 00000000000..5a3f944db10 --- /dev/null +++ b/spyder/plugins/application/widgets/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Widgets for the Application plugin.""" diff --git a/spyder/plugins/application/widgets/install.py b/spyder/plugins/application/widgets/install.py new file mode 100644 index 00000000000..edf2dfe02c4 --- /dev/null +++ b/spyder/plugins/application/widgets/install.py @@ -0,0 +1,290 @@ +# -*- 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 +import tempfile +import threading +from urllib.request import urlretrieve + +# Third-party imports +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import (QDialog, QHBoxLayout, QMessageBox, + QLabel, QProgressBar, QPushButton, QVBoxLayout, + QWidget) + +# Local imports +from spyder import __version__ +from spyder.api.translations import get_translation +from spyder.utils.icon_manager import ima +from spyder.utils.programs import is_module_installed + +logger = logging.getLogger(__name__) + +# Localization +_ = get_translation('spyder') + +# Update installation process statuses +NO_STATUS = __version__ +DOWNLOADING_INSTALLER = _("Downloading update") +INSTALLING = _("Installing update") +FINISHED = _("Installation finished") +PENDING = _("Update available") +CHECKING = _("Checking for updates") +CANCELLED = _("Cancelled update") + +INSTALL_INFO_MESSAGES = { + DOWNLOADING_INSTALLER: _("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 UpdateInstallationCancelledException(Exception): + """Update installation was cancelled.""" + pass + + +class UpdateInstallation(QWidget): + """Update progress installation widget.""" + + def __init__(self, parent): + super().__init__(parent) + + # Left side + 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')) + progress_layout.addWidget(self._progress_bar, alignment=Qt.AlignLeft) + progress_layout.addWidget(self.cancel_button) + 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.""" + + # Signal to get the download progress + # int: Download progress + # int: Total download size + sig_download_progress = Signal(int, int) + + # Signal to get the current status of the update installation + # str: Status string + sig_installation_status = Signal(str, str) + + def __init__(self, parent): + + self.cancelled = False + self.status = NO_STATUS + self.thread_install_update = 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_install) + + # Show installation widget + self.setup() + + def setup(self): + """Setup visibility of widgets.""" + self._installation_widget.setVisible(True) + self.adjustSize() + + def cancel_install(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_thread_install_update() + self.setup() + self.accept() + return True + return False + + def continue_install(self): + """ + Continue the installation in progress by downloading the installer. + """ + reply = QMessageBox(icon=QMessageBox.Question, + text=_("Would you like to automatically download " + "and install the latest version of Spyder?" + "

"), + 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_update(self.latest_release_version) + else: + self._change_update_installation_status(status=PENDING) + + def finished_installation(self, status): + """Handle finished installation.""" + if status == FINISHED or status == PENDING: + self.setup() + self.accept() + + def close_installer(self): + """Close the installation dialog.""" + if (self.status == FINISHED + or self.status == CANCELLED): + self.setup() + self.accept() + else: + self.hide() + + def reject(self): + """Reimplemented Qt method.""" + on_installation_widget = self._installation_widget.isVisible() + if on_installation_widget: + self.close_installer() + else: + super(UpdateInstallerDialog, self).reject() + + def _change_update_installation_status(self, status=NO_STATUS): + """Set the installation status.""" + logger.debug(f"Installation status: {status}") + self.status = status + self.finished_installation(self.status) + self.sig_installation_status.emit(self.status, + self.latest_release_version) + + def _progress_reporter(self, block_number, read_size, total_size): + progress = 0 + if total_size > 0: + progress = block_number * read_size + if self.cancelled: + raise UpdateInstallationCancelledException() + else: + self.sig_download_progress.emit(progress, total_size) + + def cancel_thread_install_update(self): + self._change_update_installation_status(status=CANCELLED) + self.thread_install_update.join() + + def _download_install(self): + try: + logger.debug("Downloading 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 = os.path.join(tmpdir, 'spyder', 'updates') + os.makedirs(dir_path, exist_ok=True) + installer_dir_path = os.path.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 = os.path.join(dir_path, file) + os.remove(remove) + + installer_path = os.path.join(installer_dir_path, name) + if (not os.path.isfile(installer_path)): + logger.debug( + f"Downloading installer from {url} to {installer_path}") + download = urlretrieve(url, + installer_path, + reporthook=self._progress_reporter) + self._change_update_installation_status(status=INSTALLING) + cmd = ('start' if os.name == 'nt' else 'open') + subprocess.run(' '.join([cmd, installer_path]), shell=True) + + except UpdateInstallationCancelledException: + self._change_update_installation_status(status=CANCELLED) + finally: + self._change_update_installation_status(status=PENDING) + + def save_latest_release(self, latest_release_version): + self.latest_release_version = latest_release_version + + def start_installation_update(self, latest_release_version): + """Start the installation update thread and set downloading status.""" + self.latest_release_version = latest_release_version + self.cancelled = False + self._change_update_installation_status( + status=DOWNLOADING_INSTALLER) + self.thread_install_update = threading.Thread( + target=self._download_install) + self.thread_install_update.start() diff --git a/spyder/plugins/application/widgets/status.py b/spyder/plugins/application/widgets/status.py new file mode 100644 index 00000000000..e00c74479b8 --- /dev/null +++ b/spyder/plugins/application/widgets/status.py @@ -0,0 +1,125 @@ +# -*- 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, Signal, Slot +from qtpy.QtWidgets import QMenu + +# Local imports +from spyder.api.widgets.status import StatusBarWidget +from spyder.config.base import _, is_pynsist, running_in_mac_app +from spyder.plugins.application.widgets.install import ( + UpdateInstallerDialog, NO_STATUS, DOWNLOADING_INSTALLER, INSTALLING, + FINISHED, PENDING, CHECKING, CANCELLED) +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import add_actions, create_action + +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() + + 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 = QMenu(self) + + self.sig_clicked.connect(self.show_installation_dialog_or_menu) + + self.installer.sig_installation_status.connect( + self.set_value) + + 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.") + self.spinner.show() + self.spinner.start() + self.installer.show() + + elif value == PENDING: + self.tooltip = value + self.spinner.hide() + self.spinner.stop() + else: + self.tooltip = self.BASE_TOOLTIP + 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_update(latest_release) + + def set_status_pending(self, latest_release): + self.set_value(PENDING) + self.installer.save_latest_release(latest_release) + self.spinner.hide() + self.spinner.stop() + + 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 ((not self.tooltip == self.BASE_TOOLTIP + and not value == PENDING) + and (is_pynsist() or running_in_mac_app())): + self.installer.show() + elif (value == PENDING and + (is_pynsist() or running_in_mac_app())): + self.installer.continue_install() + 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 b35d350727b..956c380fe70 100644 --- a/spyder/plugins/statusbar/plugin.py +++ b/spyder/plugins/statusbar/plugin.py @@ -49,7 +49,8 @@ class StatusBar(SpyderPluginV2): INTERNAL_WIDGETS_IDS = { 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', 'eol_status', 'encoding_status', 'cursor_position_status', - 'vcs_status', 'lsp_status', 'kite_status', 'completion_status'} + 'vcs_status', 'lsp_status', 'kite_status', 'completion_status', + 'interpreter_status', 'application_update_status'} # ---- SpyderPluginV2 API @staticmethod @@ -216,7 +217,8 @@ def _organize_status_widgets(self): internal_layout = [ 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', 'eol_status', 'encoding_status', 'cursor_position_status', - 'vcs_status', 'lsp_status', 'kite_status', 'completion_status'] + 'vcs_status', 'lsp_status', 'kite_status', 'completion_status', + 'interpreter_status', 'application_update_status'] external_left = list(self.EXTERNAL_LEFT_WIDGETS.keys()) # Remove all widgets from the statusbar, except the external right