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