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 = _("
conda update anaconda
pip install --upgrade spyder
"
+ "conda install {channel} "
+ f"spyder={latest_release}"
+ f"
pip install --upgrade spyder