diff --git a/setup.py b/setup.py index 37c2b88c4b7..81f58a4c3d0 100644 --- a/setup.py +++ b/setup.py @@ -311,6 +311,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 a1eb2262ba8..56225fa3512 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -84,6 +84,11 @@ 'report_error/remember_token': False, 'show_dpi_message': True, }), + ('update_manager', + { + 'check_updates_on_startup': True, + 'check_stable_only': True, + }), ('toolbar', { 'enable': True, diff --git a/spyder/plugins/statusbar/plugin.py b/spyder/plugins/statusbar/plugin.py index f1736040f3d..bd3bd86e4e0 100644 --- a/spyder/plugins/statusbar/plugin.py +++ b/spyder/plugins/statusbar/plugin.py @@ -46,7 +46,7 @@ class StatusBar(SpyderPluginV2): 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', 'eol_status', 'encoding_status', 'cursor_position_status', 'vcs_status', 'lsp_status', 'completion_status', - 'interpreter_status'} + 'interpreter_status', 'update_manager_status'} # ---- SpyderPluginV2 API @staticmethod @@ -216,7 +216,7 @@ def _organize_status_widgets(self): 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', 'eol_status', 'encoding_status', 'cursor_position_status', 'vcs_status', 'lsp_status', 'completion_status', - 'interpreter_status'] + 'interpreter_status', 'update_manager_status'] external_left = list(self.EXTERNAL_LEFT_WIDGETS.keys()) # Remove all widgets from the statusbar, except the external right diff --git a/spyder/plugins/updatemanager/__init__.py b/spyder/plugins/updatemanager/__init__.py new file mode 100644 index 00000000000..2079205facb --- /dev/null +++ b/spyder/plugins/updatemanager/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +spyder.plugins.updatemanager +============================ + +Application Update Manager Plugin. +""" diff --git a/spyder/plugins/updatemanager/api.py b/spyder/plugins/updatemanager/api.py new file mode 100644 index 00000000000..8365533093f --- /dev/null +++ b/spyder/plugins/updatemanager/api.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Spyder update manager API. +""" + +from spyder.plugins.updatemanager.container import UpdateManagerActions diff --git a/spyder/plugins/updatemanager/confpage.py b/spyder/plugins/updatemanager/confpage.py new file mode 100644 index 00000000000..e1b73d6f5dc --- /dev/null +++ b/spyder/plugins/updatemanager/confpage.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +General entry in Preferences. + +For historical reasons (dating back to Spyder 2) the main class here is called +`MainConfigPage` and its associated entry in our config system is called +`main`. +""" + +from qtpy.QtWidgets import QGroupBox, QVBoxLayout + +from spyder.config.base import _ +from spyder.api.preferences import PluginConfigPage + + +class UpdateManagerConfigPage(PluginConfigPage): + def setup_page(self): + """Setup config page widgets and options.""" + updates_group = QGroupBox(_("Updates")) + check_updates = self.create_checkbox( + _("Check for updates on startup"), + 'check_updates_on_startup' + ) + stable_only = self.create_checkbox( + _("Check for stable releases only"), + 'check_stable_only' + ) + + updates_layout = QVBoxLayout() + updates_layout.addWidget(check_updates) + updates_layout.addWidget(stable_only) + updates_group.setLayout(updates_layout) + + vlayout = QVBoxLayout() + vlayout.addWidget(updates_group) + vlayout.addStretch(1) + self.setLayout(vlayout) diff --git a/spyder/plugins/updatemanager/container.py b/spyder/plugins/updatemanager/container.py new file mode 100644 index 00000000000..dc94cd5fa8f --- /dev/null +++ b/spyder/plugins/updatemanager/container.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Container Widget. + +Holds references for base actions in the Application of Spyder. +""" + +# Standard library imports +import logging + +# Third party imports +from qtpy.QtCore import Slot + +# Local imports +from spyder.api.translations import _ +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.plugins.updatemanager.widgets.status import UpdateManagerStatus +from spyder.plugins.updatemanager.widgets.update import ( + UpdateManagerWidget, NO_STATUS +) +from spyder.utils.qthelpers import DialogManager + +# Logger setup +logger = logging.getLogger(__name__) + + +# Actions +class UpdateManagerActions: + SpyderCheckUpdateAction = "spyder_check_update_action" + + +class UpdateManagerContainer(PluginMainContainer): + + def __init__(self, name, plugin, parent=None): + super().__init__(name, plugin, parent) + + self.install_on_close = False + + def setup(self): + self.dialog_manager = DialogManager() + self.update_manager = UpdateManagerWidget(parent=self) + self.update_manager_status = UpdateManagerStatus(parent=self) + + # Actions + self.check_update_action = self.create_action( + UpdateManagerActions.SpyderCheckUpdateAction, + _("Check for updates..."), + triggered=self.start_check_update + ) + + # Signals + self.update_manager.sig_set_status.connect(self.set_status) + self.update_manager.sig_disable_actions.connect( + self.check_update_action.setDisabled) + self.update_manager.sig_block_status_signals.connect( + self.update_manager_status.blockSignals) + self.update_manager.sig_download_progress.connect( + self.update_manager_status.set_download_progress) + self.update_manager.sig_install_on_close.connect( + self.set_install_on_close) + self.update_manager.sig_quit_requested.connect(self.sig_quit_requested) + + self.update_manager_status.sig_check_update.connect( + self.start_check_update) + self.update_manager_status.sig_start_update.connect(self.start_update) + self.update_manager_status.sig_show_progress_dialog.connect( + self.update_manager.show_progress_dialog) + + self.set_status(NO_STATUS) + + def update_actions(self): + pass + + def set_status(self, status, latest_version=None): + """Set Update Manager status""" + self.update_manager_status.set_value(status) + + @Slot() + def start_check_update(self, startup=False): + """Check for spyder updates.""" + self.update_manager.start_check_update(startup=startup) + + @Slot() + def start_update(self): + """Start the update process""" + self.update_manager.start_update() + + def set_install_on_close(self, install_on_close): + """Set whether start install on close.""" + self.install_on_close = install_on_close + + def on_close(self): + """To call from Spyder when the plugin is closed.""" + self.update_manager.cleanup_threads() + + # Run installer after Spyder is closed + if self.install_on_close: + self.update_manager.start_install() + + self.dialog_manager.close_all() diff --git a/spyder/plugins/updatemanager/plugin.py b/spyder/plugins/updatemanager/plugin.py new file mode 100644 index 00000000000..23ed346abeb --- /dev/null +++ b/spyder/plugins/updatemanager/plugin.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Update Manager Plugin. +""" + +# Local imports +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.translations import _ +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.config.base import DEV +from spyder.plugins.updatemanager.confpage import UpdateManagerConfigPage +from spyder.plugins.updatemanager.container import (UpdateManagerActions, + UpdateManagerContainer) +from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections + + +class UpdateManager(SpyderPluginV2): + NAME = 'update_manager' + REQUIRES = [Plugins.Preferences] + OPTIONAL = [Plugins.Help, Plugins.MainMenu, Plugins.Shortcuts, + Plugins.StatusBar] + CONTAINER_CLASS = UpdateManagerContainer + CONF_SECTION = 'update_manager' + CONF_FILE = False + CONF_WIDGET_CLASS = UpdateManagerConfigPage + CAN_BE_DISABLED = False + + @staticmethod + def get_name(): + return _('Update Manager') + + @classmethod + def get_icon(cls): + return cls.create_icon('genprefs') + + @staticmethod + def get_description(): + return _('Manage application updates.') + + # --------------------- PLUGIN INITIALIZATION ----------------------------- + + def on_initialize(self): + pass + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + # Register conf page + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + if self.is_plugin_enabled(Plugins.Shortcuts): + if self.is_plugin_available(Plugins.Shortcuts): + self._populate_help_menu() + else: + self._populate_help_menu() + + @on_plugin_available(plugin=Plugins.StatusBar) + def on_statusbar_available(self): + # Add status widget + statusbar = self.get_plugin(Plugins.StatusBar) + statusbar.add_status_widget(self.update_manager_status) + + # -------------------------- PLUGIN TEARDOWN ------------------------------ + + @on_plugin_teardown(plugin=Plugins.StatusBar) + def on_statusbar_teardown(self): + # Remove status widget if created + statusbar = self.get_plugin(Plugins.StatusBar) + statusbar.remove_status_widget(self.update_manager_status.ID) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + self._depopulate_help_menu() + + def on_close(self, _unused=True): + # The container is closed directly in the plugin registry + pass + + def on_mainwindow_visible(self): + """Actions after the mainwindow in visible.""" + container = self.get_container() + + # Check for updates on startup + if DEV is None and self.get_conf('check_updates_on_startup'): + container.start_check_updates(startup=True) + + # ---- Private API + # ------------------------------------------------------------------------ + def _populate_help_menu(self): + """Add update action and menu to the Help menu.""" + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.add_item_to_application_menu( + self.check_update_action, + menu_id=ApplicationMenus.Help, + section=HelpMenuSections.Support, + before_section=HelpMenuSections.ExternalDocumentation) + + @property + def _window(self): + return self.main.window() + + def _depopulate_help_menu(self): + """Remove update action from the Help main menu.""" + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + UpdateManagerActions.SpyderCheckUpdateAction, + menu_id=ApplicationMenus.Help) + + # ---- Public API + # ------------------------------------------------------------------------ + @property + def check_update_action(self): + """Check if a new version of Spyder is available.""" + return self.get_container().check_update_action + + @property + def update_manager_status(self): + return self.get_container().update_manager_status diff --git a/spyder/plugins/updatemanager/scripts/install.bat b/spyder/plugins/updatemanager/scripts/install.bat new file mode 100644 index 00000000000..ad26ae98f08 --- /dev/null +++ b/spyder/plugins/updatemanager/scripts/install.bat @@ -0,0 +1,88 @@ +:: This script updates or installs a new version of Spyder +@echo off + +:: Create variables from arguments +:parse +IF "%~1"=="" GOTO endparse +IF "%~1"=="-p" set prefix=%2 & SHIFT +IF "%~1"=="-i" set install_exe=%2 & SHIFT +IF "%~1"=="-c" set conda=%2 & SHIFT +IF "%~1"=="-v" set spy_ver=%2 & SHIFT +SHIFT +GOTO parse +:endparse + +:: Enforce encoding +chcp 65001>nul + +IF not "%conda%"=="" IF not "%spy_ver%"=="" ( + call :update_subroutine + call :launch_spyder + goto exit +) + +IF not "%install_exe%"=="" ( + call :install_subroutine + goto exit +) + +:exit +exit %ERRORLEVEL% + +:install_subroutine + echo Installing Spyder from: %install_exe% + + call :wait_for_spyder_quit + + :: Uninstall Spyder + for %%I in ("%prefix%\..\..") do set "conda_root=%%~fI" + + echo Install will proceed after the current Spyder version is uninstalled. + start %conda_root%\Uninstall-Spyder.exe + + :: Must wait for uninstaller to appear on tasklist + :wait_for_uninstall_start + tasklist /fi "ImageName eq Un_A.exe" /fo csv 2>NUL | find /i "Un_A.exe">NUL + IF "%ERRORLEVEL%"=="1" ( + timeout /t 1 /nobreak > nul + goto wait_for_uninstall_start + ) + echo Uninstall in progress... + + :wait_for_uninstall + timeout /t 1 /nobreak > nul + tasklist /fi "ImageName eq Un_A.exe" /fo csv 2>NUL | find /i "Un_A.exe">NUL + IF "%ERRORLEVEL%"=="0" goto wait_for_uninstall + echo Uninstall complete. + + start %install_exe% + goto :EOF + +:update_subroutine + echo Updating Spyder + + call :wait_for_spyder_quit + + %conda% install -p %prefix% -c conda-forge --override-channels -y spyder=%spy_ver% + set /P CONT=Press any key to exit... + goto :EOF + +:wait_for_spyder_quit + echo Waiting for Spyder to quit... + :loop + tasklist /fi "ImageName eq spyder.exe" /fo csv 2>NUL | find /i "spyder.exe">NUL + IF "%ERRORLEVEL%"=="0" ( + timeout /t 1 /nobreak > nul + goto loop + ) + echo Spyder is quit. + goto :EOF + +:launch_spyder + echo %prefix% | findstr /b "%USERPROFILE%" > nul && ( + set shortcut_root=%APPDATA% + ) || ( + set shortcut_root=%ALLUSERSPROFILE% + ) + start "" /B "%shortcut_root%\Microsoft\Windows\Start Menu\Programs\spyder\Spyder.lnk" + goto :EOF diff --git a/spyder/plugins/updatemanager/scripts/install.sh b/spyder/plugins/updatemanager/scripts/install.sh new file mode 100755 index 00000000000..381da1cc44e --- /dev/null +++ b/spyder/plugins/updatemanager/scripts/install.sh @@ -0,0 +1,60 @@ +#!/bin/bash -i +set -e +unset HISTFILE # Do not write to history with interactive shell + +while getopts "i:c:p:v:" option; do + case "$option" in + (i) install_exe=$OPTARG ;; + (c) conda=$OPTARG ;; + (p) prefix=$OPTARG ;; + (v) spy_ver=$OPTARG ;; + esac +done +shift $(($OPTIND - 1)) + +update_spyder(){ + $conda install -p $prefix -c conda-forge --override-channels -y spyder=$spy_ver + read -p "Press any key to exit..." +} + +launch_spyder(){ + if [[ "$OSTYPE" = "darwin"* ]]; then + shortcut=/Applications/Spyder.app + [[ "$prefix" = "$HOME"* ]] && open -a $HOME$shortcut || open -a $shortcut + elif [[ -n "$(which gtk-launch)" ]]; then + gtk-launch spyder_spyder + else + nohup $prefix/bin/spyder &>/dev/null & + fi +} + +install_spyder(){ + # First uninstall Spyder + uninstall_script="$prefix/../../uninstall-spyder.sh" + if [[ -f "$uninstall_script" ]]; then + echo "Uninstalling Spyder..." + $uninstall_script + fi + + # Run installer + [[ "$OSTYPE" = "darwin"* ]] && open $install_exe || sh $install_exe +} + +while [[ $(pgrep spyder 2> /dev/null) ]]; do + echo "Waiting for Spyder to quit..." + sleep 1 +done + +echo "Spyder quit." + +if [[ -e "$conda" && -d "$prefix" && -n "$spy_ver" ]]; then + update_spyder + launch_spyder +elif [[ -e "$install_exe" ]]; then + install_spyder +fi + +if [[ "$OSTYPE" = "darwin"* ]]; then + # Close the Terminal window that was opened for this process + osascript -e 'tell application "Terminal" to close first window' & +fi diff --git a/spyder/plugins/updatemanager/tests/__init__.py b/spyder/plugins/updatemanager/tests/__init__.py new file mode 100644 index 00000000000..f984ad47da2 --- /dev/null +++ b/spyder/plugins/updatemanager/tests/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see LICENSE.txt for details) +# ----------------------------------------------------------------------------- + +"""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..74f09e5900c --- /dev/null +++ b/spyder/plugins/updatemanager/tests/test_update_manager.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +import os + +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 + + +@pytest.fixture +def worker(): + return WorkerUpdate(None) + + +# ---- Test WorkerUpdate + +@pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"]) +def test_updates_appenv(qtbot, mocker, version): + """ + Test whether or not we offer updates for our installers according to the + current Spyder version. + + Uses UpdateManagerWidget in order to also test QThread. + """ + mocker.patch.object(update, "__version__", new=version) + # Do not execute start_update after check_update completes. + mocker.patch.object( + UpdateManagerWidget, "start_update", new=lambda x: None) + mocker.patch.object(workers, "__version__", new=version) + mocker.patch.object(workers, "is_anaconda", return_value=True) + mocker.patch.object(workers, "is_conda_based_app", return_value=True) + mocker.patch.object( + workers, "get_spyder_conda_channel", + return_value=("conda-forge", "https://conda.anaconda.org/conda-forge")) + + um = UpdateManagerWidget(None) + um.start_check_update() + qtbot.waitUntil(um.update_thread.isFinished) + + _update = um.update_worker.update_available + assert _update if version.split('.')[0] == '1' else not _update + # assert len(worker.releases) > 1 + assert len(um.update_worker.releases) > 1 + + +@pytest.mark.parametrize("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 our installers according to the + current Spyder 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 = worker.update_available + assert _update if version.split('.')[0] == '1' else not _update + 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, tiemout=5000): + worker.start() + + _update = worker.update_available + assert _update if version.split('.')[0] == '1' else not _update + 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 = worker.update_available + assert not _update if "a" in release and stable_only else _update + + +# ---- Test WorkerDownloadInstaller + +@pytest.mark.skipif(not running_in_ci(), reason="Download only in CI") +def test_download(qtbot, mocker): + """ + Test download spyder installer. + Uses UpdateManagerWidget in order to also test QThread. + """ + um = UpdateManagerWidget(None) + um.latest_release = "6.0.0a2" + um._set_installer_path() + # Do not execute _start_install after download completes. + mocker.patch.object( + UpdateManagerWidget, "_confirm_install", new=lambda x: None) + + 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/updatemanager/widgets/__init__.py b/spyder/plugins/updatemanager/widgets/__init__.py new file mode 100644 index 00000000000..e35c9c7b676 --- /dev/null +++ b/spyder/plugins/updatemanager/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 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..3e2ef45fbfc --- /dev/null +++ b/spyder/plugins/updatemanager/widgets/status.py @@ -0,0 +1,136 @@ +# -*- 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 QMenu, QLabel + +# Local imports +from spyder.api.translations import _ +from spyder.api.widgets.status import StatusBarWidget +from spyder.plugins.updatemanager.widgets.update import ( + NO_STATUS, DOWNLOADING_INSTALLER, PENDING, + CHECKING, DOWNLOAD_FINISHED, INSTALL_ON_CLOSE) +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 = _("Update manager status") + ID = 'update_manager_status' + + sig_check_update = Signal() + """Signal to request checking for updates.""" + + sig_start_update = Signal() + """Signal to start update process""" + + sig_show_progress_dialog = Signal(bool) + """ + Signal to show 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 = QMenu(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 update will continue in the background.\n" + "Click here to show the download dialog again." + ) + self.spinner.hide() + self.spinner.stop() + self.custom_widget.show() + elif value == CHECKING: + self.tooltip = self.BASE_TOOLTIP + if self.custom_widget: + self.custom_widget.hide() + self.spinner.show() + self.spinner.start() + 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 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..44511659146 --- /dev/null +++ b/spyder/plugins/updatemanager/widgets/update.py @@ -0,0 +1,584 @@ +# -*- 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 +from tempfile import gettempdir +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.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") + +INSTALL_INFO_MESSAGES = { + DOWNLOADING_INSTALLER: _("Downloading Spyder {version}"), + DOWNLOAD_FINISHED: _("Finished downloading Spyder {version}"), + INSTALLING: _("Installing Spyder {version}"), + FINISHED: _("Finished installing Spyder {version}"), + PENDING: _("Spyder {version} available to download"), + CHECKING: _("Checking for new Spyder version"), + CANCELLED: _("Spyder update cancelled"), + INSTALL_ON_CLOSE: _("Install Spyder {version} on close") +} + +HEADER = _("

Spyder {} is available!


") +URL_I = 'https://docs.spyder-ide.org/current/installation.html' +TMPDIR = osp.join(gettempdir(), 'spyder') + + +class UpdateManagerWidget(QWidget, SpyderConfigurationAccessor): + """Check for updates widget""" + + 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.CONF_SECTION = parent.CONF_SECTION if parent else 'update_manager' + + self.startup = None + self.update_thread = None + self.update_worker = None + self.update_timer = None + self.latest_release = None + self.major_update = None + + self.cancelled = False + self.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.wait() + + # ---- Check Update + + def start_check_update(self, startup=False): + """ + Check for spyder updates using a QThread. + + Update actions are disabled in the menubar and statusbar while + checking for updates + + If startup is True, then checking for updates is delayed 1 min; + actions are disabled during this time as well. + """ + logger.debug(f"Checking for updates. startup = {startup}.") + + # Disable check_update_action while the thread is working + self.sig_disable_actions.emit(True) + + self.startup = startup + self.cleanup_threads() + + self.update_thread = QThread(None) + self.update_worker = WorkerUpdate(self.get_conf('check_stable_only')) + self.update_worker.sig_ready.connect(self._process_check_update) + self.update_worker.sig_ready.connect(self.update_thread.quit) + self.update_worker.sig_ready.connect( + lambda: self.sig_disable_actions.emit(False) + ) + self.update_worker.moveToThread(self.update_thread) + self.update_thread.started.connect(lambda: self.set_status(CHECKING)) + self.update_thread.started.connect(self.update_worker.start) + + # Delay starting this check to avoid blocking the main window + # while loading. + # Fixes spyder-ide/spyder#15839 + if self.startup: + self.update_timer = QTimer(self) + self.update_timer.setInterval(60000) + self.update_timer.setSingleShot(True) + self.sig_block_status_signals.emit(True) + self.update_timer.timeout.connect( + lambda: self.sig_block_status_signals.emit(False)) + self.update_timer.timeout.connect(self.update_thread.start) + self.update_timer.start() + else: + # Otherwise, start immediately + self.update_thread.start() + + def _process_check_update(self): + """Process the results of check update.""" + # Get results from worker + update_available = self.update_worker.update_available + error_msg = self.update_worker.error + self.latest_release = self.update_worker.latest_release + self.major_update = ( + parse(__version__).major < parse(self.latest_release).major + ) + self._set_installer_path() + + # Always set status, regardless of error, DEV, or startup + self.set_status(PENDING if update_available else NO_STATUS) + + # self.startup = True is used on startup, so only positive feedback is + # given. self.startup = False is used after startup when using the menu + # action, and gives feeback if updates are, or are not found. + if ( + self.startup and # startup and... + ('dev' in __version__ # current version is dev + or error_msg is not None # or there is an error + or not update_available) # or no updates available + ): + # Do not alert the user to anything + pass + elif error_msg is not None: + error_messagebox(self, error_msg) + elif update_available: + self.start_update() + else: + info_messagebox(self, _("Spyder is up to date."), checkbox=True) + + def _set_installer_path(self): + """Set the emp file path for the downloaded installer""" + if os.name == 'nt': + plat, ext = 'Windows', 'exe' + if sys.platform == 'darwin': + plat, ext = 'macOS', 'pkg' + if sys.platform.startswith('linux'): + plat, ext = 'Linux', 'sh' + mach = platform.machine().lower().replace("amd64", "x86_64") + fname = f'Spyder-{self.latest_release}-{plat}-{mach}.{ext}' + + dirname = osp.join(TMPDIR, 'updates', self.latest_release) + self.installer_path = osp.join(dirname, fname) + self.installer_size_path = osp.join(dirname, "size") + + # ---- Download Update + + def _verify_installer_path(self): + if ( + osp.exists(self.installer_path) + and osp.exists(self.installer_size_path) + ): + with open(self.installer_size_path, "r") as f: + size = f.read().strip() + return size == osp.getsize(self.installer_path) + else: + 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. + """ + if self._verify_installer_path(): + self.set_status(DOWNLOAD_FINISHED) + self._confirm_install() + elif not is_conda_based_app(): + msg = _( + "Would you like to automatically download and " + "install it using Spyder's installer?" + "

" + "We recommend our own installer " + "because it's more stable and makes updating easy. " + "This will leave your existing Spyder installation " + "untouched." + ).format(URL_I + "#standalone-installers") + box = confirm_messagebox( + self, msg, version=self.latest_release, checkbox=True + ) + if box.result() == QMessageBox.Yes: + self._start_download() + else: + manual_update_messagebox( + self, self.latest_release, self.update_worker.channel) + elif self.major_update: + msg = _("Would you like to automatically download " + "and install it?") + box = confirm_messagebox( + self, msg, version=self.latest_release, checkbox=True + ) + if box.result() == QMessageBox.Yes: + self._start_download() + else: + # Minor release for conda-based application will update with conda + self._confirm_install() + + def _start_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.""" + msg = _('Do you really want to cancel the download?') + box = confirm_messagebox(self, msg, critical=True) + if box.result() == QMessageBox.Yes: + self.cancelled = True + self.download_worker.cancelled = True + self.cleanup_threads() + self.set_status(PENDING) + + 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, version=self.latest_release, on_close=True) + if box.result() == QMessageBox.Yes: + self.sig_install_on_close.emit(True) + self.sig_quit_requested.emit() + elif box.result() == 0: # 0 is result of 3rd push-button + self.sig_install_on_close.emit(True) + self.set_status(INSTALL_ON_CLOSE) + + def start_install(self): + """Install from downloaded installer or update through conda.""" + + # Install script + script = osp.abspath(__file__ + '/../../scripts/install.' + + ('bat' if os.name == 'nt' else 'sh')) + + # Sub command + sub_cmd = [script, '-p', sys.prefix] + if osp.exists(self.installer_path): + # Run downloaded installer + sub_cmd.extend(['-i', self.installer_path]) + elif self.latest_release is not None: + # Update with conda + sub_cmd.extend(['-c', find_conda(), '-v', self.latest_release]) + + # Final command assembly + if os.name == 'nt': + cmd = ['start', '"Update Spyder"'] + sub_cmd + elif sys.platform == 'darwin': + # Terminal cannot accept a command with arguments therefore + # create a temporary script + tmpdir = osp.join(gettempdir(), 'spyder') + tmpscript = osp.join(tmpdir, 'tmp_install.sh') + os.makedirs(tmpdir, exist_ok=True) + with open(tmpscript, 'w') as f: + f.write(' '.join(sub_cmd)) + os.chmod(tmpscript, 0o711) # set executable permissions + + cmd = ['open', '-b', 'com.apple.terminal', tmpscript] + else: + cmd = ['gnome-terminal', '--window', '--'] + sub_cmd + + 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.setWindowTitle(_("Spyder Update Manager")) + self.setTextFormat(Qt.RichText) + + +class UpdateMessageCheckBox(MessageCheckBox): + def __init__(self, icon=None, text=None, parent=None): + super().__init__(icon=icon, text=text, parent=parent) + self.setWindowTitle(_("Spyder Update Manager")) + self.setTextFormat(Qt.RichText) + self._parent = parent + self.set_checkbox_text(_("Check for updates at startup")) + self.option = 'check_updates_on_startup' + self.accepted.connect(self.accept) # ??? Why is the signal necessary? + if self._parent is not None: + self.set_checked(parent.get_conf(self.option)) + + def accept(self): + if self._parent is not None: + self._parent.set_conf(self.option, self.is_checked()) + + +class ProgressDialog(UpdateMessageBox): + """Update progress installation dialog.""" + + def __init__(self, parent, text): + super().__init__(icon=QMessageBox.NoIcon, text=text, parent=parent) + + self._progress_bar = QProgressBar(self) + self._progress_bar.setMinimumWidth(250) + self._progress_bar.setFixedHeight(15) + + layout = self.layout() + layout.addWidget(self._progress_bar, 1, 1) + + self.cancel = QPushButton(_("Cancel")) + self.okay = QPushButton(_("OK")) + self.addButton(self.okay, QMessageBox.YesRole) + self.addButton(self.cancel, QMessageBox.NoRole) + self.setDefaultButton(self.okay) + + self.show() + + def update_progress(self, progress, total): + """Update installation progress bar.""" + self._progress_bar.setMaximum(total) + self._progress_bar.setValue(progress) + + +def error_messagebox(parent, error_msg): + box = UpdateMessageBox( + icon=QMessageBox.Warning, text=error_msg, parent=parent) + box.setStandardButtons(QMessageBox.Ok) + box.setDefaultButton(QMessageBox.Ok) + box.show() + return box + + +def info_messagebox(parent, message, version=None, checkbox=False): + box_class = UpdateMessageCheckBox if checkbox else UpdateMessageBox + message = HEADER.format(version) + message if version else message + box = box_class(icon=QMessageBox.Information, text=message, parent=parent) + box.setStandardButtons(QMessageBox.Ok) + box.setDefaultButton(QMessageBox.Ok) + box.show() + return box + + +def confirm_messagebox(parent, message, version=None, critical=False, + checkbox=False, on_close=False): + box_class = UpdateMessageCheckBox if checkbox else UpdateMessageBox + message = HEADER.format(version) + message if version else message + box = box_class( + icon=QMessageBox.Critical if critical else QMessageBox.Question, + text=message, parent=parent) + box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + box.setDefaultButton(QMessageBox.Yes) + if on_close: + box.addButton(_("After closing"), QMessageBox.YesRole) + box.exec() + return box + + +def manual_update_messagebox(parent, latest_release, channel): + msg = "" + if os.name == "nt": + if is_anaconda(): + msg += _("Run the following command or commands in " + "the Anaconda prompt to update manually:" + "

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

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

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

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


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


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

For more information, visit our " + "installation guide." + ).format(URL_I) + + info_messagebox(parent, msg) diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py new file mode 100644 index 00000000000..b310c62349d --- /dev/null +++ b/spyder/plugins/updatemanager/workers.py @@ -0,0 +1,266 @@ +# -*- 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 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_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 = None + self.error = None + self.channel = None + + def _check_update_available(self): + """Checks if there is an update available from releases.""" + logger.debug("Checking releases for available updates.") + + # 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 or self.channel == "pypi": + url = pypi_url + else: + url = channel_url + '/channeldata.json' + else: + url = pypi_url + + logger.debug(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 + ] + self.releases = list(reversed(self.releases)) + elif is_anaconda() and url != pypi_url: + spyder_data = data['packages'].get('spyder') + if spyder_data: + self.releases = [spyder_data["version"]] + else: + self.releases = [data['info']['version']] + + self._check_update_available() + 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(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) + finally: + self.error = error_msg + self.sig_ready.emit() + + +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 + + def _progress_reporter(self, progress, total_size): + """Calculate download progress and notify.""" + self.sig_download_progress.emit(progress, total_size) + + 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.debug(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.debug('Download successfully completed.') + with open(self.installer_size_file, "w") as f: + f.write(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_file): + os.remove(self.installer_size_file) + + def start(self): + """Main method of the worker.""" + logger.debug("Starting WorkerDownloadInstaller.") + error_msg = None + try: + self._download_installer() + except UpdateDownloadCancelledException: + self._clean_installer_path() + 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._clean_installer_path() + finally: + self.error = error_msg + self.sig_ready.emit()