diff --git a/.github/workflows/test-linux.yml b/.github/workflows/test-linux.yml
index 974ffd672b5..a38ccf72750 100644
--- a/.github/workflows/test-linux.yml
+++ b/.github/workflows/test-linux.yml
@@ -140,9 +140,13 @@ jobs:
- name: Run tests with gdb
if: env.USE_GDB == 'true'
shell: bash -l {0}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: xvfb-run --auto-servernum gdb -return-child-result -batch -ex r -ex py-bt --args python runtests.py -s
- name: Run tests
shell: bash -l {0}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
rm -f pytest_log.txt # Must remove any log file from a previous run
.github/scripts/run_tests.sh || \
diff --git a/.github/workflows/test-mac.yml b/.github/workflows/test-mac.yml
index d7d292139b8..66d4f5a7a79 100644
--- a/.github/workflows/test-mac.yml
+++ b/.github/workflows/test-mac.yml
@@ -110,6 +110,8 @@ jobs:
run: check-manifest
- name: Run tests
shell: bash -l {0}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
rm -f pytest_log.txt # Must remove any log file from a previous run
.github/scripts/run_tests.sh || \
diff --git a/.github/workflows/test-win.yml b/.github/workflows/test-win.yml
index 95e4f0fcb85..6602ebde184 100644
--- a/.github/workflows/test-win.yml
+++ b/.github/workflows/test-win.yml
@@ -134,6 +134,8 @@ jobs:
run: check-manifest
- name: Run tests
shell: bash -l {0}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
rm -f pytest_log.txt # Must remove any log file from a previous run
.github/scripts/run_tests.sh || \
diff --git a/setup.py b/setup.py
index 3286d73728f..f7f5dc3b908 100644
--- a/setup.py
+++ b/setup.py
@@ -312,6 +312,7 @@ def run(self):
'switcher = spyder.plugins.switcher.plugin:Switcher',
'toolbar = spyder.plugins.toolbar.plugin:Toolbar',
'tours = spyder.plugins.tours.plugin:Tours',
+ 'update_manager = spyder.plugins.updatemanager.plugin:UpdateManager',
'variable_explorer = spyder.plugins.variableexplorer.plugin:VariableExplorer',
'workingdir = spyder.plugins.workingdirectory.plugin:WorkingDirectory',
]
diff --git a/spyder/api/plugins/enum.py b/spyder/api/plugins/enum.py
index a74cccdfa94..9d68767ed33 100644
--- a/spyder/api/plugins/enum.py
+++ b/spyder/api/plugins/enum.py
@@ -41,6 +41,7 @@ class Plugins:
Switcher = 'switcher'
Toolbar = "toolbar"
Tours = 'tours'
+ UpdateManager = 'update_manager'
VariableExplorer = 'variable_explorer'
WorkingDirectory = 'workingdir'
diff --git a/spyder/config/main.py b/spyder/config/main.py
index 1458e45564c..98dcc64bc35 100644
--- a/spyder/config/main.py
+++ b/spyder/config/main.py
@@ -78,12 +78,16 @@
'custom_margin': 0,
'use_custom_cursor_blinking': False,
'show_internal_errors': True,
- 'check_updates_on_startup': True,
'cursor/width': 2,
'completion/size': (300, 180),
'report_error/remember_token': False,
'show_dpi_message': True,
}),
+ ('update_manager',
+ {
+ 'check_updates_on_startup': True,
+ 'check_stable_only': True,
+ }),
('toolbar',
{
'enable': True,
@@ -663,4 +667,4 @@
# or if you want to *rename* options, then you need to do a MAJOR update in
# version, e.g. from 3.0.0 to 4.0.0
# 3. You don't need to touch this value if you're just adding a new option
-CONF_VERSION = '81.0.0'
+CONF_VERSION = '82.0.0'
diff --git a/spyder/plugins/application/confpage.py b/spyder/plugins/application/confpage.py
index b55a2083f58..74017b0dc1a 100644
--- a/spyder/plugins/application/confpage.py
+++ b/spyder/plugins/application/confpage.py
@@ -23,6 +23,7 @@
from spyder.config.base import (_, DISABLED_LANGUAGES, LANGUAGE_CODES,
is_conda_based_app, save_lang_conf)
+from spyder.api.plugins import Plugins
from spyder.api.preferences import PluginConfigPage
from spyder.py3compat import to_text_string
@@ -65,8 +66,16 @@ def setup_page(self):
prompt_box = newcb(_("Prompt when exiting"), 'prompt_on_exit')
popup_console_box = newcb(_("Show internal Spyder errors to report "
"them to Github"), 'show_internal_errors')
- check_updates = newcb(_("Check for updates on startup"),
- 'check_updates_on_startup')
+ check_update_cb = newcb(
+ _("Check for updates on startup"),
+ 'check_updates_on_startup',
+ section='update_manager'
+ )
+ stable_only_cb = newcb(
+ _("Check for stable releases only"),
+ 'check_stable_only',
+ section='update_manager'
+ )
# Decide if it's possible to activate or not single instance mode
# ??? Should we allow multiple instances for macOS?
@@ -88,7 +97,8 @@ def setup_page(self):
advanced_layout.addWidget(single_instance_box)
advanced_layout.addWidget(prompt_box)
advanced_layout.addWidget(popup_console_box)
- advanced_layout.addWidget(check_updates)
+ advanced_layout.addWidget(check_update_cb)
+ advanced_layout.addWidget(stable_only_cb)
advanced_widget = QWidget()
advanced_widget.setLayout(advanced_layout)
@@ -272,6 +282,14 @@ def apply_settings(self, options):
self.set_option(
'high_dpi_custom_scale_factors', scale_factors_text)
self.changed_options.add('high_dpi_custom_scale_factors')
+
+ um = self.plugin.get_plugin(Plugins.UpdateManager, error=False)
+ if (
+ um
+ and ('update_manager', 'check_stable_only') in self.changed_options
+ ):
+ um.update_manager_status.set_no_status()
+
self.plugin.apply_settings()
def _save_lang(self):
diff --git a/spyder/plugins/application/container.py b/spyder/plugins/application/container.py
index cce31fa770a..e51b88fafc8 100644
--- a/spyder/plugins/application/container.py
+++ b/spyder/plugins/application/container.py
@@ -12,12 +12,10 @@
# Standard library imports
import os
-import subprocess
import sys
import glob
# Third party imports
-from packaging.version import parse
from qtpy.QtCore import Qt, QThread, QTimer, Signal, Slot
from qtpy.QtGui import QGuiApplication
from qtpy.QtWidgets import QAction, QMessageBox, QPushButton
@@ -28,18 +26,13 @@
from spyder.api.translations import _
from spyder.api.widgets.main_container import PluginMainContainer
from spyder.utils.installers import InstallerMissingDependencies
-from spyder.config.utils import is_anaconda
-from spyder.config.base import (get_conf_path, get_debug_level,
- is_conda_based_app)
-from spyder.plugins.application.widgets.status import ApplicationUpdateStatus
+from spyder.config.base import get_conf_path, get_debug_level
from spyder.plugins.console.api import ConsoleActions
-from spyder.utils.conda import is_anaconda_pkg, get_spyder_conda_channel
from spyder.utils.environ import UserEnvDialog
from spyder.utils.qthelpers import start_file, DialogManager
from spyder.widgets.about import AboutDialog
from spyder.widgets.dependencies import DependenciesDialog
from spyder.widgets.helperwidgets import MessageCheckBox
-from spyder.workers.updates import WorkerUpdates
class ApplicationPluginMenus:
@@ -61,7 +54,6 @@ class ApplicationActions:
SpyderDocumentationVideoAction = "spyder_documentation_video_action"
SpyderTroubleshootingAction = "spyder_troubleshooting_action"
SpyderDependenciesAction = "spyder_dependencies_action"
- SpyderCheckUpdatesAction = "spyder_check_updates_action"
SpyderSupportAction = "spyder_support_action"
SpyderAbout = "spyder_about_action"
@@ -94,9 +86,6 @@ def __init__(self, name, plugin, parent=None):
self.current_dpi = None
self.dpi_messagebox = None
- # Keep track of the downloaded installer executable for updates
- self.installer_path = None
-
# ---- PluginMainContainer API
# -------------------------------------------------------------------------
def setup(self):
@@ -107,19 +96,6 @@ def setup(self):
# Attributes
self.dialog_manager = DialogManager()
- self.application_update_status = None
- if is_conda_based_app():
- self.application_update_status = ApplicationUpdateStatus(
- parent=self)
- (self.application_update_status.sig_check_for_updates_requested
- .connect(self.check_updates))
- (self.application_update_status.sig_install_on_close_requested
- .connect(self.set_installer_path))
- self.application_update_status.set_no_status()
- self.give_updates_feedback = False
- self.thread_updates = None
- self.worker_updates = None
- self.updates_timer = None
# Actions
# Documentation actions
@@ -155,10 +131,6 @@ def setup(self):
_("Dependencies..."),
triggered=self.show_dependencies,
icon=self.create_icon('advanced'))
- self.check_updates_action = self.create_action(
- ApplicationActions.SpyderCheckUpdatesAction,
- _("Check for updates..."),
- triggered=self.check_updates)
self.support_group_action = self.create_action(
ApplicationActions.SpyderSupportAction,
_("Spyder support..."),
@@ -226,20 +198,10 @@ def update_actions(self):
def on_close(self):
"""To call from Spyder when the plugin is closed."""
self.dialog_manager.close_all()
- if self.updates_timer is not None:
- self.updates_timer.stop()
- if self.thread_updates is not None:
- self.thread_updates.quit()
- self.thread_updates.wait()
if self.dependencies_thread is not None:
self.dependencies_thread.quit()
self.dependencies_thread.wait()
- # Run installer after Spyder is closed
- cmd = ('start' if os.name == 'nt' else 'open')
- if self.installer_path:
- subprocess.Popen(' '.join([cmd, self.installer_path]), shell=True)
-
@Slot()
def show_about(self):
"""Show Spyder About dialog."""
@@ -251,226 +213,6 @@ def show_user_env_variables(self):
"""Show Windows current user environment variables."""
self.dialog_manager.show(UserEnvDialog(self))
- # ---- Updates
- # -------------------------------------------------------------------------
-
- def _check_updates_ready(self):
- """Show results of the Spyder update checking process."""
-
- # `feedback` = False is used on startup, so only positive feedback is
- # given. `feedback` = True is used when after startup (when using the
- # menu action, and gives feeback if updates are, or are not found.
- feedback = self.give_updates_feedback
-
- # Get results from worker
- update_available = self.worker_updates.update_available
- latest_release = self.worker_updates.latest_release
- error_msg = self.worker_updates.error
-
- url_i = 'https://docs.spyder-ide.org/current/installation.html'
-
- # Define the custom QMessageBox
- box = MessageCheckBox(icon=QMessageBox.Information,
- parent=self)
- box.setWindowTitle(_("New Spyder version"))
- box.setAttribute(Qt.WA_ShowWithoutActivating)
- box.set_checkbox_text(_("Check for updates at startup"))
- box.setStandardButtons(QMessageBox.Ok)
- box.setDefaultButton(QMessageBox.Ok)
- box.setTextFormat(Qt.RichText)
-
- # Adjust the checkbox depending on the stored configuration
- option = 'check_updates_on_startup'
- box.set_checked(self.get_conf(option))
-
- header = _(
- "
Spyder {} is available!
"
- ).format(latest_release)
-
- if error_msg is not None:
- box.setText(error_msg)
- box.set_check_visible(False)
- box.show()
- if self.application_update_status:
- self.application_update_status.set_no_status()
- elif update_available:
- if self.application_update_status:
- self.application_update_status.set_status_pending(
- latest_release)
-
- # Update using our installers
- if parse(latest_release) >= parse("6.0.0"):
- box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
- box.setDefaultButton(QMessageBox.Yes)
-
- if not is_conda_based_app():
- installers_url = url_i + "#standalone-installers"
- msg = (
- header +
- _("Would you like to automatically download and "
- "install it using Spyder's installer?"
- "
"
- "We recommend our own installer "
- "because it's more stable and makes updating easy. "
- "This will leave your existing Spyder installation "
- "untouched.").format(installers_url)
- )
- else:
- msg = (
- header +
- _("Would you like to automatically download "
- "and install it?")
- )
-
- box.setText(msg)
- box.exec_()
- if box.result() == QMessageBox.Yes:
- self.application_update_status.start_installation(
- latest_release=latest_release)
-
- # Manual update
- if (
- not box.result() # The installer dialog was skipped
- or (
- box.result() == QMessageBox.No
- and not is_conda_based_app()
- )
- ):
- # Update-at-startup checkbox visible only if manual update
- # is first message box
- box.set_check_visible(not box.result())
- box.setStandardButtons(QMessageBox.Ok)
- box.setDefaultButton(QMessageBox.Ok)
-
- msg = ""
- if not box.result():
- msg += header
-
- if os.name == "nt":
- if is_anaconda():
- msg += _("Run the following command or commands in "
- "the Anaconda prompt to update manually:"
- "
")
- else:
- msg += _("Run the following command in a cmd prompt "
- "to update manually:
")
- else:
- if is_anaconda():
- msg += _("Run the following command or commands in a "
- "terminal to update manually:
")
- else:
- msg += _("Run the following command in a terminal to "
- "update manually:
")
-
- if is_anaconda():
- channel, __ = get_spyder_conda_channel()
- is_pypi = channel == 'pypi'
-
- if is_anaconda_pkg() and not is_pypi:
- msg += "conda update anaconda
"
-
- if is_pypi:
- dont_mix_pip_conda_video = (
- "https://youtu.be/Ul79ihg41Rs"
- )
-
- msg += (
- "pip install --upgrade spyder
"
- "
"
- )
-
- msg += _(
- "Important note: You installed Spyder with "
- "pip in a Conda environment, which is not a good "
- "idea. See our video for more "
- "details about it."
- ).format(dont_mix_pip_conda_video)
- else:
- if channel == 'pkgs/main':
- channel = ''
- else:
- channel = f'-c {channel}'
-
- msg += (
- f"conda install {channel} "
- f"spyder={latest_release}"
- f"
"
- )
-
- msg += _(
- "Important note: Since you installed "
- "Spyder with Anaconda, please don't use pip "
- "to update it as that will break your "
- "installation."
- )
- else:
- msg += "pip install --upgrade spyder
"
-
- msg += _(
- "
For more information, visit our "
- "installation guide."
- ).format(url_i)
-
- box.setText(msg)
- box.show()
- elif feedback:
- box.setText(_("Spyder is up to date."))
- box.show()
- if self.application_update_status:
- self.application_update_status.set_no_status()
- else:
- if self.application_update_status:
- self.application_update_status.set_no_status()
-
- self.set_conf(option, box.is_checked())
-
- # Enable check_updates_action after the thread has finished
- self.check_updates_action.setDisabled(False)
-
- # Provide feeback when clicking menu if check on startup is on
- self.give_updates_feedback = True
-
- @Slot()
- def check_updates(self, startup=False):
- """Check for spyder updates on github releases using a QThread."""
- # Disable check_updates_action while the thread is working
- self.check_updates_action.setDisabled(True)
- # !!! >>> Disable signals until alpha1
- if is_conda_based_app():
- self.application_update_status.blockSignals(True)
- return
- # !!! <<< Disable signals until alpha1
- if self.application_update_status:
- self.application_update_status.set_status_checking()
-
- if self.thread_updates is not None:
- self.thread_updates.quit()
- self.thread_updates.wait()
-
- self.thread_updates = QThread(None)
- self.worker_updates = WorkerUpdates(self, startup=startup)
- self.worker_updates.sig_ready.connect(self._check_updates_ready)
- self.worker_updates.sig_ready.connect(self.thread_updates.quit)
- self.worker_updates.moveToThread(self.thread_updates)
- self.thread_updates.started.connect(self.worker_updates.start)
-
- # Delay starting this check to avoid blocking the main window
- # while loading.
- # Fixes spyder-ide/spyder#15839
- if startup:
- self.updates_timer = QTimer(self)
- self.updates_timer.setInterval(60000)
- self.updates_timer.setSingleShot(True)
- self.updates_timer.timeout.connect(self.thread_updates.start)
- self.updates_timer.start()
- else:
- self.thread_updates.start()
-
- @Slot(str)
- def set_installer_path(self, installer_path):
- """Set installer executable path to be run when closing."""
- self.installer_path = installer_path
-
# ---- Dependencies
# -------------------------------------------------------------------------
def _set_dependencies(self):
diff --git a/spyder/plugins/application/plugin.py b/spyder/plugins/application/plugin.py
index 11c968f195a..d6915189ab1 100644
--- a/spyder/plugins/application/plugin.py
+++ b/spyder/plugins/application/plugin.py
@@ -23,7 +23,7 @@
from spyder.api.plugin_registration.decorators import (
on_plugin_available, on_plugin_teardown)
from spyder.api.widgets.menus import SpyderMenu, MENU_SEPARATOR
-from spyder.config.base import (DEV, get_module_path, get_debug_level,
+from spyder.config.base import (get_module_path, get_debug_level,
running_under_pytest)
from spyder.plugins.application.confpage import ApplicationConfigPage
from spyder.plugins.application.container import (
@@ -38,7 +38,7 @@ class Application(SpyderPluginV2):
NAME = 'application'
REQUIRES = [Plugins.Console, Plugins.Preferences]
OPTIONAL = [Plugins.Help, Plugins.MainMenu, Plugins.Shortcuts,
- Plugins.Editor, Plugins.StatusBar]
+ Plugins.Editor, Plugins.StatusBar, Plugins.UpdateManager]
CONTAINER_CLASS = ApplicationContainer
CONF_SECTION = 'main'
CONF_FILE = False
@@ -98,22 +98,8 @@ def on_editor_available(self):
editor = self.get_plugin(Plugins.Editor)
self.get_container().sig_load_log_file.connect(editor.load)
- @on_plugin_available(plugin=Plugins.StatusBar)
- def on_statusbar_available(self):
- # Add status widget if created
- if self.application_update_status:
- statusbar = self.get_plugin(Plugins.StatusBar)
- statusbar.add_status_widget(self.application_update_status)
-
# -------------------------- PLUGIN TEARDOWN ------------------------------
- @on_plugin_teardown(plugin=Plugins.StatusBar)
- def on_statusbar_teardown(self):
- # Remove status widget if created
- if self.application_update_status:
- statusbar = self.get_plugin(Plugins.StatusBar)
- statusbar.remove_status_widget(self.application_update_status.ID)
-
@on_plugin_teardown(plugin=Plugins.Preferences)
def on_preferences_teardown(self):
preferences = self.get_plugin(Plugins.Preferences)
@@ -147,11 +133,6 @@ def on_mainwindow_visible(self):
if not running_under_pytest():
container.compute_dependencies()
- # Check for updates
- if DEV is None and self.get_conf('check_updates_on_startup'):
- container.give_updates_feedback = False
- container.check_updates(startup=True)
-
# Handle DPI scale and window changes to show a restart message.
# Don't activate this functionality on macOS because it's being
# triggered in the wrong situations.
@@ -220,8 +201,7 @@ def _populate_help_menu_support_section(self):
mainmenu = self.get_plugin(Plugins.MainMenu)
for support_action in [
self.trouble_action, self.report_action,
- self.dependencies_action, self.check_updates_action,
- self.support_group_action]:
+ self.dependencies_action, self.support_group_action]:
mainmenu.add_item_to_application_menu(
support_action,
menu_id=ApplicationMenus.Help,
@@ -261,7 +241,6 @@ def _depopulate_help_menu_support_section(self):
ApplicationActions.SpyderTroubleshootingAction,
ConsoleActions.SpyderReportAction,
ApplicationActions.SpyderDependenciesAction,
- ApplicationActions.SpyderCheckUpdatesAction,
ApplicationActions.SpyderSupportAction]:
mainmenu.remove_item_from_application_menu(
support_action,
@@ -414,11 +393,6 @@ def dependencies_action(self):
"""Show Spyder's Dependencies dialog box."""
return self.get_container().dependencies_action
- @property
- def check_updates_action(self):
- """Check if a new version of Spyder is available."""
- return self.get_container().check_updates_action
-
@property
def support_group_action(self):
"""Open Spyder's Google support group in the browser."""
@@ -453,7 +427,3 @@ def report_action(self):
def debug_logs_menu(self):
return self.get_container().get_menu(
ApplicationPluginMenus.DebugLogsMenu)
-
- @property
- def application_update_status(self):
- return self.get_container().application_update_status
diff --git a/spyder/plugins/application/widgets/install.py b/spyder/plugins/application/widgets/install.py
deleted file mode 100644
index 4d05efe25e4..00000000000
--- a/spyder/plugins/application/widgets/install.py
+++ /dev/null
@@ -1,317 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# Copyright © Spyder Project Contributors
-# Licensed under the terms of the MIT License
-# (see spyder/__init__.py for details)
-
-"""Update installation widgets."""
-
-# Standard library imports
-import logging
-import os
-import subprocess
-
-# Third-party imports
-from qtpy.QtCore import Qt, QThread, Signal
-from qtpy.QtWidgets import (QDialog, QHBoxLayout, QMessageBox,
- QLabel, QProgressBar, QPushButton, QVBoxLayout,
- QWidget)
-
-# Local imports
-from spyder import __version__
-from spyder.api.translations import _
-from spyder.config.base import is_conda_based_app
-from spyder.utils.icon_manager import ima
-from spyder.workers.updates import WorkerDownloadInstaller
-
-# Logger setup
-logger = logging.getLogger(__name__)
-
-# Update installation process statuses
-NO_STATUS = __version__
-DOWNLOADING_INSTALLER = _("Downloading update")
-DOWNLOAD_FINISHED = _("Download finished")
-INSTALLING = _("Installing update")
-FINISHED = _("Installation finished")
-PENDING = _("Update available")
-CHECKING = _("Checking for updates")
-CANCELLED = _("Cancelled update")
-
-INSTALL_INFO_MESSAGES = {
- DOWNLOADING_INSTALLER: _("Downloading Spyder {version}"),
- DOWNLOAD_FINISHED: _("Finished downloading Spyder {version}"),
- INSTALLING: _("Installing Spyder {version}"),
- FINISHED: _("Finished installing Spyder {version}"),
- PENDING: _("Spyder {version} available to download"),
- CHECKING: _("Checking for new Spyder version"),
- CANCELLED: _("Spyder update cancelled")
-}
-
-
-class UpdateInstallation(QWidget):
- """Update progress installation widget."""
-
- def __init__(self, parent):
- super().__init__(parent)
- action_layout = QVBoxLayout()
- progress_layout = QHBoxLayout()
- self._progress_widget = QWidget(self)
- self._progress_widget.setFixedHeight(50)
- self._progress_bar = QProgressBar(self)
- self._progress_bar.setFixedWidth(180)
- self.cancel_button = QPushButton()
- self.cancel_button.setIcon(ima.icon('DialogCloseButton'))
- self.cancel_button.setFixedHeight(25)
- self.cancel_button.setFixedWidth(25)
- progress_layout.addWidget(self._progress_bar, alignment=Qt.AlignLeft)
- progress_layout.addWidget(self.cancel_button)
- progress_layout.setAlignment(Qt.AlignVCenter)
- self._progress_widget.setLayout(progress_layout)
-
- self._progress_label = QLabel(_('Downloading'))
-
- self.install_info = QLabel(
- _("Downloading Spyder update
"))
-
- button_layout = QHBoxLayout()
- self.ok_button = QPushButton(_('OK'))
- button_layout.addStretch()
- button_layout.addWidget(self.ok_button)
- button_layout.addStretch()
- action_layout.addStretch()
- action_layout.addWidget(self._progress_label)
- action_layout.addWidget(self._progress_widget)
- action_layout.addWidget(self.install_info)
- action_layout.addSpacing(10)
- action_layout.addLayout(button_layout)
- action_layout.addStretch()
-
- # Layout
- general_layout = QHBoxLayout()
- general_layout.addLayout(action_layout)
-
- self.setLayout(general_layout)
-
- def update_installation_status(self, status, latest_version):
- """Update installation status (downloading, installing, finished)."""
- self._progress_label.setText(status)
- self.install_info.setText(INSTALL_INFO_MESSAGES[status].format(
- version=latest_version))
- if status == INSTALLING:
- self._progress_bar.setRange(0, 0)
- self.cancel_button.setEnabled(False)
-
- def update_installation_progress(self, current_value, total):
- """Update installation progress bar."""
- self._progress_bar.setMaximum(total)
- self._progress_bar.setValue(current_value)
-
-
-class UpdateInstallerDialog(QDialog):
- """Update installer dialog."""
-
- sig_download_progress = Signal(int, int)
- """
- Signal to get the download progress.
-
- Parameters
- ----------
- current_value: int
- Size of the data downloaded until now.
- total: int
- Total size of the file expected to be downloaded.
- """
-
- sig_installation_status = Signal(str, str)
- """
- Signal to get the current status of the update installation.
-
- Parameters
- ----------
- status: str
- Status string.
- latest_release: str
- Latest release version detected.
- """
-
- sig_install_on_close_requested = Signal(str)
- """
- Signal to request running the downloaded installer on close.
-
- Parameters
- ----------
- installer_path: str
- Path to the installer executable.
- """
-
- def __init__(self, parent):
- self.cancelled = False
- self.status = NO_STATUS
- self.download_thread = None
- self.download_worker = None
- self.installer_path = None
-
- super().__init__(parent)
- self.setWindowFlags(Qt.Dialog | Qt.MSWindowsFixedSizeDialogHint)
- self._parent = parent
- self._installation_widget = UpdateInstallation(self)
- self.latest_release_version = ""
-
- # Layout
- installer_layout = QVBoxLayout()
- installer_layout.addWidget(self._installation_widget)
- self.setLayout(installer_layout)
-
- # Signals
- self.sig_download_progress.connect(
- self._installation_widget.update_installation_progress)
- self.sig_installation_status.connect(
- self._installation_widget.update_installation_status)
-
- self._installation_widget.ok_button.clicked.connect(
- self.close_installer)
- self._installation_widget.cancel_button.clicked.connect(
- self.cancel_installation)
-
- # Show installation widget
- self.setup()
-
- def reject(self):
- """Reimplemented Qt method."""
- on_installation_widget = self._installation_widget.isVisible()
- if on_installation_widget:
- self.close_installer()
- else:
- super().reject()
-
- def setup(self):
- """Setup visibility of widgets."""
- self._installation_widget.setVisible(True)
- self.adjustSize()
-
- def save_latest_release(self, latest_release_version):
- self.latest_release_version = latest_release_version
-
- def start_installation(self, latest_release_version):
- """Start downloading the update and set downloading status."""
- self.latest_release_version = latest_release_version
- self.cancelled = False
- self._change_update_installation_status(
- status=DOWNLOADING_INSTALLER)
- self.download_thread = QThread(None)
- self.download_worker = WorkerDownloadInstaller(
- self, self.latest_release_version)
- self.download_worker.sig_ready.connect(self.confirm_installation)
- self.download_worker.sig_ready.connect(self.download_thread.quit)
- self.download_worker.sig_download_progress.connect(
- self.sig_download_progress.emit)
- self.download_worker.moveToThread(self.download_thread)
- self.download_thread.started.connect(self.download_worker.start)
- self.download_thread.start()
-
- def cancel_installation(self):
- """Cancel the installation in progress."""
- reply = QMessageBox.critical(
- self._parent, 'Spyder',
- _('Do you really want to cancel the Spyder update installation?'),
- QMessageBox.Yes, QMessageBox.No)
- if reply == QMessageBox.Yes:
- self.cancelled = True
- self._cancel_download()
- self.finish_installation()
- return True
- return False
-
- def continue_installation(self):
- """
- Continue the installation in progress.
-
- Download the installer if needed or prompt to install.
- """
- reply = QMessageBox(icon=QMessageBox.Question,
- text=_("Would you like to update Spyder to "
- "the latest version?"
- "
"),
- parent=self._parent)
- reply.setWindowTitle("Spyder")
- reply.setAttribute(Qt.WA_ShowWithoutActivating)
- reply.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
- reply.exec_()
- if reply.result() == QMessageBox.Yes:
- self.start_installation(self.latest_release_version)
- else:
- self._change_update_installation_status(status=PENDING)
-
- def confirm_installation(self, installer_path):
- """
- Ask users if they want to proceed with the installer execution.
- """
- if self.cancelled:
- return
- self._change_update_installation_status(status=DOWNLOAD_FINISHED)
- self.installer_path = installer_path
- msg_box = QMessageBox(
- icon=QMessageBox.Question,
- text=_("Would you like to proceed with the installation?
"),
- parent=self._parent
- )
- msg_box.setWindowTitle(_("Spyder update"))
- msg_box.setAttribute(Qt.WA_ShowWithoutActivating)
- if os.name == 'nt' and is_conda_based_app():
- # Only add yes button for Windows installer
- # since it has the logic to restart Spyder
- yes_button = msg_box.addButton(QMessageBox.Yes)
- else:
- yes_button = None
- after_closing_button = msg_box.addButton(
- _("After closing"), QMessageBox.YesRole)
- msg_box.addButton(QMessageBox.No)
- msg_box.exec_()
-
- if msg_box.clickedButton() == yes_button:
- self._change_update_installation_status(status=INSTALLING)
- cmd = ('start' if os.name == 'nt' else 'open')
- if self.installer_path:
- subprocess.Popen(
- ' '.join([cmd, self.installer_path]),
- shell=True
- )
- self._change_update_installation_status(status=PENDING)
- elif msg_box.clickedButton() == after_closing_button:
- self.sig_install_on_close_requested.emit(self.installer_path)
- self._change_update_installation_status(status=PENDING)
- else:
- self._change_update_installation_status(status=PENDING)
-
- def finish_installation(self):
- """Handle finished installation."""
- self.setup()
- self.accept()
-
- def close_installer(self):
- """Close the installation dialog."""
- if (
- self.status == FINISHED
- or self.status == CANCELLED
- ):
- self.finish_installation()
- else:
- self.hide()
-
- def _change_update_installation_status(self, status=NO_STATUS):
- """Set the installation status."""
- logger.debug(f"Installation status: {status}")
- self.status = status
- if status == DOWNLOAD_FINISHED:
- self.close_installer()
- elif status == FINISHED or status == PENDING:
- self.finish_installation()
- self.sig_installation_status.emit(
- self.status, self.latest_release_version)
-
- def _cancel_download(self):
- self._change_update_installation_status(status=CANCELLED)
- self.download_worker.cancelled = True
- self.download_thread.quit()
- self.download_thread.wait()
- self._change_update_installation_status(status=PENDING)
diff --git a/spyder/plugins/application/widgets/status.py b/spyder/plugins/application/widgets/status.py
deleted file mode 100644
index 051ed90b4c5..00000000000
--- a/spyder/plugins/application/widgets/status.py
+++ /dev/null
@@ -1,171 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright © Spyder Project Contributors
-# Licensed under the terms of the MIT License
-# (see spyder/__init__.py for details)
-
-"""
-Status widget for Spyder updates.
-"""
-
-# Standard library imports
-import logging
-import os
-
-# Third party imports
-from qtpy.QtCore import QPoint, Qt, Signal, Slot
-from qtpy.QtWidgets import QLabel
-
-# Local imports
-from spyder.api.translations import _
-from spyder.api.widgets.menus import SpyderMenu
-from spyder.api.widgets.status import StatusBarWidget
-from spyder.config.base import is_conda_based_app
-from spyder.plugins.application.widgets.install import (
- UpdateInstallerDialog, NO_STATUS, DOWNLOADING_INSTALLER, INSTALLING,
- PENDING, CHECKING)
-from spyder.utils.icon_manager import ima
-from spyder.utils.qthelpers import add_actions, create_action
-
-
-# Setup logger
-logger = logging.getLogger(__name__)
-
-
-class ApplicationUpdateStatus(StatusBarWidget):
- """Status bar widget for application update status."""
- BASE_TOOLTIP = _("Application update status")
- ID = 'application_update_status'
-
- sig_check_for_updates_requested = Signal()
- """
- Signal to request checking for updates.
- """
-
- sig_install_on_close_requested = Signal(str)
- """
- Signal to request running the downloaded installer on close.
-
- Parameters
- ----------
- installer_path: str
- Path to instal
- """
-
- CUSTOM_WIDGET_CLASS = QLabel
-
- def __init__(self, parent):
-
- self.tooltip = self.BASE_TOOLTIP
- super().__init__(parent, show_spinner=True)
-
- # Installation dialog
- self.installer = UpdateInstallerDialog(self)
-
- # Check for updates action menu
- self.menu = SpyderMenu(self)
-
- # Set aligment attributes for custom widget to match default label
- # values
- self.custom_widget.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
-
- # Signals
- self.sig_clicked.connect(self.show_installation_dialog_or_menu)
-
- # Installer widget signals
- self.installer.sig_download_progress.connect(
- self.set_download_progress)
- self.installer.sig_installation_status.connect(
- self.set_value)
- self.installer.sig_install_on_close_requested.connect(
- self.sig_install_on_close_requested)
-
- def set_value(self, value):
- """Return update installation state."""
- if value == DOWNLOADING_INSTALLER or value == INSTALLING:
- self.tooltip = _("Update installation will continue in the "
- "background.\n"
- "Click here to show the installation "
- "dialog again.")
- if value == DOWNLOADING_INSTALLER:
- self.spinner.hide()
- self.spinner.stop()
- self.custom_widget.show()
- else:
- self.custom_widget.hide()
- self.spinner.show()
- self.spinner.start()
- self.installer.show()
- elif value == PENDING:
- self.tooltip = value
- self.custom_widget.hide()
- self.spinner.hide()
- self.spinner.stop()
- else:
- self.tooltip = self.BASE_TOOLTIP
- if self.custom_widget:
- self.custom_widget.hide()
- if self.spinner:
- self.spinner.hide()
- self.spinner.stop()
- self.setVisible(True)
- self.update_tooltip()
- value = f"Spyder: {value}"
- logger.debug(f"Application Update Status: {value}")
- super().set_value(value)
-
- def get_tooltip(self):
- """Reimplementation to get a dynamic tooltip."""
- return self.tooltip
-
- def get_icon(self):
- return ima.icon('spyder_about')
-
- def start_installation(self, latest_release):
- self.installer.start_installation(latest_release)
-
- def set_download_progress(self, current_value, total):
- percentage_progress = 0
- if total > 0:
- percentage_progress = round((current_value/total) * 100)
- self.custom_widget.setText(f"{percentage_progress}%")
-
- def set_status_pending(self, latest_release):
- self.set_value(PENDING)
- self.installer.save_latest_release(latest_release)
-
- def set_status_checking(self):
- self.set_value(CHECKING)
- self.spinner.show()
- self.spinner.start()
-
- def set_no_status(self):
- self.set_value(NO_STATUS)
- self.spinner.hide()
- self.spinner.stop()
-
- @Slot()
- def show_installation_dialog_or_menu(self):
- """Show installation dialog or menu."""
- value = self.value.split(":")[-1].strip()
- if (
- self.tooltip != self.BASE_TOOLTIP
- and value != PENDING
- and is_conda_based_app()
- ):
- self.installer.show()
- elif value == PENDING and is_conda_based_app():
- self.installer.continue_installation()
- elif value == NO_STATUS:
- self.menu.clear()
- check_for_updates_action = create_action(
- self,
- text=_("Check for updates..."),
- triggered=self.sig_check_for_updates_requested.emit
- )
- add_actions(self.menu, [check_for_updates_action])
- rect = self.contentsRect()
- os_height = 7 if os.name == 'nt' else 12
- pos = self.mapToGlobal(
- rect.topLeft() + QPoint(-10, -rect.height() - os_height))
- self.menu.popup(pos)
diff --git a/spyder/plugins/statusbar/plugin.py b/spyder/plugins/statusbar/plugin.py
index 4d7d6ded8c1..bd3bd86e4e0 100644
--- a/spyder/plugins/statusbar/plugin.py
+++ b/spyder/plugins/statusbar/plugin.py
@@ -46,7 +46,7 @@ class StatusBar(SpyderPluginV2):
'clock_status', 'cpu_status', 'memory_status', 'read_write_status',
'eol_status', 'encoding_status', 'cursor_position_status',
'vcs_status', 'lsp_status', 'completion_status',
- 'interpreter_status', 'application_update_status'}
+ 'interpreter_status', 'update_manager_status'}
# ---- SpyderPluginV2 API
@staticmethod
@@ -216,7 +216,7 @@ def _organize_status_widgets(self):
'clock_status', 'cpu_status', 'memory_status', 'read_write_status',
'eol_status', 'encoding_status', 'cursor_position_status',
'vcs_status', 'lsp_status', 'completion_status',
- 'interpreter_status', 'application_update_status']
+ 'interpreter_status', 'update_manager_status']
external_left = list(self.EXTERNAL_LEFT_WIDGETS.keys())
# Remove all widgets from the statusbar, except the external right
diff --git a/spyder/plugins/updatemanager/__init__.py b/spyder/plugins/updatemanager/__init__.py
new file mode 100644
index 00000000000..2079205facb
--- /dev/null
+++ b/spyder/plugins/updatemanager/__init__.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+spyder.plugins.updatemanager
+============================
+
+Application Update Manager Plugin.
+"""
diff --git a/spyder/plugins/updatemanager/api.py b/spyder/plugins/updatemanager/api.py
new file mode 100644
index 00000000000..8365533093f
--- /dev/null
+++ b/spyder/plugins/updatemanager/api.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+Spyder update manager API.
+"""
+
+from spyder.plugins.updatemanager.container import UpdateManagerActions
diff --git a/spyder/plugins/updatemanager/container.py b/spyder/plugins/updatemanager/container.py
new file mode 100644
index 00000000000..33086ec073b
--- /dev/null
+++ b/spyder/plugins/updatemanager/container.py
@@ -0,0 +1,110 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+Container Widget.
+
+Holds references for base actions in the Application of Spyder.
+"""
+
+# Standard library imports
+import logging
+
+# Third party imports
+from qtpy.QtCore import Slot
+
+# Local imports
+from spyder.api.translations import _
+from spyder.api.widgets.main_container import PluginMainContainer
+from spyder.plugins.updatemanager.widgets.status import UpdateManagerStatus
+from spyder.plugins.updatemanager.widgets.update import (
+ UpdateManagerWidget,
+ NO_STATUS
+)
+from spyder.utils.qthelpers import DialogManager
+
+# Logger setup
+logger = logging.getLogger(__name__)
+
+
+# Actions
+class UpdateManagerActions:
+ SpyderCheckUpdateAction = "spyder_check_update_action"
+
+
+class UpdateManagerContainer(PluginMainContainer):
+
+ def __init__(self, name, plugin, parent=None):
+ super().__init__(name, plugin, parent)
+
+ self.install_on_close = False
+
+ # ---- PluginMainContainer API
+ # -------------------------------------------------------------------------
+ def setup(self):
+ self.dialog_manager = DialogManager()
+ self.update_manager = UpdateManagerWidget(parent=self)
+ self.update_manager_status = UpdateManagerStatus(parent=self)
+
+ # Actions
+ self.check_update_action = self.create_action(
+ UpdateManagerActions.SpyderCheckUpdateAction,
+ _("Check for updates..."),
+ triggered=self.start_check_update
+ )
+
+ # Signals
+ self.update_manager.sig_set_status.connect(self.set_status)
+ self.update_manager.sig_disable_actions.connect(
+ self.check_update_action.setDisabled)
+ self.update_manager.sig_block_status_signals.connect(
+ self.update_manager_status.blockSignals)
+ self.update_manager.sig_download_progress.connect(
+ self.update_manager_status.set_download_progress)
+ self.update_manager.sig_install_on_close.connect(
+ self.set_install_on_close)
+ self.update_manager.sig_quit_requested.connect(self.sig_quit_requested)
+
+ self.update_manager_status.sig_check_update.connect(
+ self.start_check_update)
+ self.update_manager_status.sig_start_update.connect(self.start_update)
+ self.update_manager_status.sig_show_progress_dialog.connect(
+ self.update_manager.show_progress_dialog)
+
+ self.set_status(NO_STATUS)
+
+ def update_actions(self):
+ pass
+
+ def on_close(self):
+ """To call from Spyder when the plugin is closed."""
+ self.update_manager.cleanup_threads()
+
+ # Run installer after Spyder is closed
+ if self.install_on_close:
+ self.update_manager.start_install()
+
+ self.dialog_manager.close_all()
+
+ # --- Public API
+ # -------------------------------------------------------------------------
+ def set_status(self, status, latest_version=None):
+ """Set Update Manager status"""
+ self.update_manager_status.set_value(status)
+
+ @Slot()
+ def start_check_update(self, startup=False):
+ """Check for spyder updates."""
+ self.update_manager.start_check_update(startup=startup)
+
+ @Slot()
+ def start_update(self):
+ """Start the update process"""
+ self.update_manager.start_update()
+
+ def set_install_on_close(self, install_on_close):
+ """Set whether start install on close."""
+ self.install_on_close = install_on_close
diff --git a/spyder/plugins/updatemanager/plugin.py b/spyder/plugins/updatemanager/plugin.py
new file mode 100644
index 00000000000..39e6c4cb8e7
--- /dev/null
+++ b/spyder/plugins/updatemanager/plugin.py
@@ -0,0 +1,138 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+Update Manager Plugin.
+"""
+
+# Local imports
+from spyder import __version__
+from spyder.api.plugins import Plugins, SpyderPluginV2
+from spyder.api.translations import _
+from spyder.api.plugin_registration.decorators import (
+ on_plugin_available,
+ on_plugin_teardown
+)
+from spyder.config.base import DEV
+from spyder.plugins.updatemanager.container import (
+ UpdateManagerActions,
+ UpdateManagerContainer
+)
+from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections
+
+
+class UpdateManager(SpyderPluginV2):
+ NAME = 'update_manager'
+ REQUIRES = [Plugins.Preferences]
+ OPTIONAL = [Plugins.MainMenu, Plugins.StatusBar]
+ CONTAINER_CLASS = UpdateManagerContainer
+ CONF_SECTION = 'update_manager'
+ CONF_FILE = False
+ CAN_BE_DISABLED = False
+
+ # ---- SpyderPluginV2 API
+ # -------------------------------------------------------------------------
+ @staticmethod
+ def get_name():
+ return _('Update Manager')
+
+ @classmethod
+ def get_icon(cls):
+ return cls.create_icon('genprefs')
+
+ @staticmethod
+ def get_description():
+ return _('Manage application updates.')
+
+ # ---- Plugin initialization
+ def on_initialize(self):
+ pass
+
+ @on_plugin_available(plugin=Plugins.Preferences)
+ def on_preferences_available(self):
+ # Register conf page
+ preferences = self.get_plugin(Plugins.Preferences)
+ preferences.register_plugin_preferences(self)
+
+ @on_plugin_available(plugin=Plugins.MainMenu)
+ def on_main_menu_available(self):
+ if self.is_plugin_enabled(Plugins.Shortcuts):
+ if self.is_plugin_available(Plugins.Shortcuts):
+ self._populate_help_menu()
+ else:
+ self._populate_help_menu()
+
+ @on_plugin_available(plugin=Plugins.StatusBar)
+ def on_statusbar_available(self):
+ # Add status widget
+ statusbar = self.get_plugin(Plugins.StatusBar)
+ statusbar.add_status_widget(self.update_manager_status)
+
+ # ---- Plugin teardown
+ @on_plugin_teardown(plugin=Plugins.StatusBar)
+ def on_statusbar_teardown(self):
+ # Remove status widget if created
+ statusbar = self.get_plugin(Plugins.StatusBar)
+ statusbar.remove_status_widget(self.update_manager_status.ID)
+
+ @on_plugin_teardown(plugin=Plugins.Preferences)
+ def on_preferences_teardown(self):
+ preferences = self.get_plugin(Plugins.Preferences)
+ preferences.deregister_plugin_preferences(self)
+
+ @on_plugin_teardown(plugin=Plugins.MainMenu)
+ def on_main_menu_teardown(self):
+ self._depopulate_help_menu()
+
+ def on_close(self, _unused=True):
+ # The container is closed directly in the plugin registry
+ pass
+
+ def on_mainwindow_visible(self):
+ """Actions after the mainwindow in visible."""
+ container = self.get_container()
+
+ # Check for updates on startup
+ if (
+ DEV is None # Not bootstrap
+ and 'dev' not in __version__ # Not dev version
+ and self.get_conf('check_updates_on_startup')
+ ):
+ container.start_check_update(startup=True)
+
+ # ---- Private API
+ # ------------------------------------------------------------------------
+ def _populate_help_menu(self):
+ """Add update action and menu to the Help menu."""
+ mainmenu = self.get_plugin(Plugins.MainMenu)
+ mainmenu.add_item_to_application_menu(
+ self.check_update_action,
+ menu_id=ApplicationMenus.Help,
+ section=HelpMenuSections.Support,
+ before_section=HelpMenuSections.ExternalDocumentation)
+
+ @property
+ def _window(self):
+ return self.main.window()
+
+ def _depopulate_help_menu(self):
+ """Remove update action from the Help main menu."""
+ mainmenu = self.get_plugin(Plugins.MainMenu)
+ mainmenu.remove_item_from_application_menu(
+ UpdateManagerActions.SpyderCheckUpdateAction,
+ menu_id=ApplicationMenus.Help)
+
+ # ---- Public API
+ # ------------------------------------------------------------------------
+ @property
+ def check_update_action(self):
+ """Check if a new version of Spyder is available."""
+ return self.get_container().check_update_action
+
+ @property
+ def update_manager_status(self):
+ """Get Update manager statusbar widget"""
+ return self.get_container().update_manager_status
diff --git a/spyder/plugins/updatemanager/scripts/install.bat b/spyder/plugins/updatemanager/scripts/install.bat
new file mode 100644
index 00000000000..e87c86d43af
--- /dev/null
+++ b/spyder/plugins/updatemanager/scripts/install.bat
@@ -0,0 +1,96 @@
+:: This script updates or installs a new version of Spyder
+@echo off
+
+:: Create variables from arguments
+:parse
+IF "%~1"=="" GOTO endparse
+IF "%~1"=="-p" set prefix=%2 & SHIFT
+IF "%~1"=="-i" set install_exe=%2 & SHIFT
+IF "%~1"=="-c" set conda=%2 & SHIFT
+IF "%~1"=="-v" set spy_ver=%2 & SHIFT
+SHIFT
+GOTO parse
+:endparse
+
+:: Enforce encoding
+chcp 65001>nul
+
+echo =========================================================
+echo Updating Spyder
+echo ---------------
+echo.
+echo IMPORTANT: Do not close this window until it has finished
+echo =========================================================
+echo.
+
+IF not "%conda%"=="" IF not "%spy_ver%"=="" (
+ call :update_subroutine
+ call :launch_spyder
+ goto exit
+)
+
+IF not "%install_exe%"=="" (
+ call :install_subroutine
+ goto exit
+)
+
+:exit
+exit %ERRORLEVEL%
+
+:install_subroutine
+ echo Installing Spyder from: %install_exe%
+
+ call :wait_for_spyder_quit
+
+ :: Uninstall Spyder
+ for %%I in ("%prefix%\..\..") do set "conda_root=%%~fI"
+
+ echo Install will proceed after the current Spyder version is uninstalled.
+ start %conda_root%\Uninstall-Spyder.exe
+
+ :: Must wait for uninstaller to appear on tasklist
+ :wait_for_uninstall_start
+ tasklist /fi "ImageName eq Un_A.exe" /fo csv 2>NUL | find /i "Un_A.exe">NUL
+ IF "%ERRORLEVEL%"=="1" (
+ timeout /t 1 /nobreak > nul
+ goto wait_for_uninstall_start
+ )
+ echo Uninstall in progress...
+
+ :wait_for_uninstall
+ timeout /t 1 /nobreak > nul
+ tasklist /fi "ImageName eq Un_A.exe" /fo csv 2>NUL | find /i "Un_A.exe">NUL
+ IF "%ERRORLEVEL%"=="0" goto wait_for_uninstall
+ echo Uninstall complete.
+
+ start %install_exe%
+ goto :EOF
+
+:update_subroutine
+ echo Updating Spyder
+
+ call :wait_for_spyder_quit
+
+ %conda% install -p %prefix% -c conda-forge --override-channels -y spyder=%spy_ver%
+ set /P CONT=Press any key to exit...
+ goto :EOF
+
+:wait_for_spyder_quit
+ echo Waiting for Spyder to quit...
+ :loop
+ tasklist /fi "ImageName eq spyder.exe" /fo csv 2>NUL | find /i "spyder.exe">NUL
+ IF "%ERRORLEVEL%"=="0" (
+ timeout /t 1 /nobreak > nul
+ goto loop
+ )
+ echo Spyder is quit.
+ goto :EOF
+
+:launch_spyder
+ echo %prefix% | findstr /b "%USERPROFILE%" > nul && (
+ set shortcut_root=%APPDATA%
+ ) || (
+ set shortcut_root=%ALLUSERSPROFILE%
+ )
+ start "" /B "%shortcut_root%\Microsoft\Windows\Start Menu\Programs\spyder\Spyder.lnk"
+ goto :EOF
diff --git a/spyder/plugins/updatemanager/scripts/install.sh b/spyder/plugins/updatemanager/scripts/install.sh
new file mode 100755
index 00000000000..9b7b42c37c3
--- /dev/null
+++ b/spyder/plugins/updatemanager/scripts/install.sh
@@ -0,0 +1,72 @@
+#!/bin/bash -i
+
+unset HISTFILE # Do not write to history with interactive shell
+
+while getopts "i:c:p:v:" option; do
+ case "$option" in
+ (i) install_exe=$OPTARG ;;
+ (c) conda=$OPTARG ;;
+ (p) prefix=$OPTARG ;;
+ (v) spy_ver=$OPTARG ;;
+ esac
+done
+shift $(($OPTIND - 1))
+
+update_spyder(){
+ $conda install -p $prefix -c conda-forge --override-channels -y spyder=$spy_ver
+ read -p "Press any key to exit..."
+}
+
+launch_spyder(){
+ if [[ "$OSTYPE" = "darwin"* ]]; then
+ shortcut=/Applications/Spyder.app
+ [[ "$prefix" = "$HOME"* ]] && open -a $HOME$shortcut || open -a $shortcut
+ elif [[ -n "$(which gtk-launch)" ]]; then
+ gtk-launch spyder_spyder
+ else
+ nohup $prefix/bin/spyder &>/dev/null &
+ fi
+}
+
+install_spyder(){
+ # First uninstall Spyder
+ uninstall_script="$prefix/../../uninstall-spyder.sh"
+ if [[ -f "$uninstall_script" ]]; then
+ echo "Uninstalling Spyder..."
+ echo ""
+ $uninstall_script
+ [[ $? > 0 ]] && return
+ fi
+
+ # Run installer
+ [[ "$OSTYPE" = "darwin"* ]] && open $install_exe || sh $install_exe
+}
+
+cat < /dev/null) ]]; do
+ echo "Waiting for Spyder to quit..."
+ sleep 1
+done
+
+echo "Spyder quit."
+
+if [[ -e "$conda" && -d "$prefix" && -n "$spy_ver" ]]; then
+ update_spyder
+ launch_spyder
+elif [[ -e "$install_exe" ]]; then
+ install_spyder
+fi
+
+if [[ "$OSTYPE" = "darwin"* ]]; then
+ # Close the Terminal window that was opened for this process
+ osascript -e 'tell application "Terminal" to close first window' &
+fi
diff --git a/spyder/workers/tests/__init__.py b/spyder/plugins/updatemanager/tests/__init__.py
similarity index 88%
rename from spyder/workers/tests/__init__.py
rename to spyder/plugins/updatemanager/tests/__init__.py
index 92b07bcf58c..f984ad47da2 100644
--- a/spyder/workers/tests/__init__.py
+++ b/spyder/plugins/updatemanager/tests/__init__.py
@@ -6,4 +6,4 @@
# (see LICENSE.txt for details)
# -----------------------------------------------------------------------------
-"""Tests for workers."""
+"""Tests for Update Manager Plugin."""
diff --git a/spyder/plugins/updatemanager/tests/test_update_manager.py b/spyder/plugins/updatemanager/tests/test_update_manager.py
new file mode 100644
index 00000000000..ad26237643e
--- /dev/null
+++ b/spyder/plugins/updatemanager/tests/test_update_manager.py
@@ -0,0 +1,163 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+import os
+import logging
+
+import pytest
+
+from spyder.config.base import running_in_ci
+from spyder.plugins.updatemanager import workers
+from spyder.plugins.updatemanager.workers import WorkerUpdate
+from spyder.plugins.updatemanager.widgets import update
+from spyder.plugins.updatemanager.widgets.update import UpdateManagerWidget
+
+logging.basicConfig()
+
+
+@pytest.fixture(autouse=True)
+def capture_logging(caplog):
+ caplog.set_level(10, "spyder.plugins.updatemanager")
+
+
+@pytest.fixture
+def worker():
+ return WorkerUpdate(None)
+
+
+# ---- Test WorkerUpdate
+
+@pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"])
+def test_updates_appenv(qtbot, mocker, version):
+ """
+ Test whether or not we offer updates for our installers according to the
+ current Spyder version.
+
+ Uses UpdateManagerWidget in order to also test QThread.
+ """
+ mocker.patch.object(update, "__version__", new=version)
+ # Do not execute start_update after check_update completes.
+ mocker.patch.object(
+ UpdateManagerWidget, "start_update", new=lambda x: None
+ )
+ mocker.patch.object(workers, "__version__", new=version)
+ mocker.patch.object(workers, "is_anaconda", return_value=True)
+ mocker.patch.object(workers, "is_conda_based_app", return_value=True)
+ mocker.patch.object(
+ workers, "get_spyder_conda_channel",
+ return_value=("conda-forge", "https://conda.anaconda.org/conda-forge")
+ )
+
+ um = UpdateManagerWidget(None)
+ um.start_check_update()
+ qtbot.waitUntil(um.update_thread.isFinished)
+
+ update_available = um.update_worker.update_available
+ if version.split('.')[0] == '1':
+ assert update_available
+ else:
+ assert not update_available
+ assert len(um.update_worker.releases) > 1
+
+
+@pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"])
+@pytest.mark.parametrize(
+ "channel", [
+ ("pkgs/main", "https://repo.anaconda.com/pkgs/main"),
+ ("conda-forge", "https://conda.anaconda.org/conda-forge"),
+ ("pypi", "https://conda.anaconda.org/pypi")
+ ]
+)
+def test_updates_condaenv(qtbot, worker, mocker, version, channel):
+ """
+ Test whether or not we offer updates for conda installed Spyder according
+ to the current version.
+ """
+ mocker.patch.object(workers, "__version__", new=version)
+ mocker.patch.object(workers, "is_anaconda", return_value=True)
+ mocker.patch.object(workers, "is_conda_based_app", return_value=False)
+ mocker.patch.object(
+ workers, "get_spyder_conda_channel", return_value=channel
+ )
+
+ with qtbot.waitSignal(worker.sig_ready, timeout=5000):
+ worker.start()
+
+ update_available = worker.update_available
+ if version.split('.')[0] == '1':
+ assert update_available
+ else:
+ assert not update_available
+ assert len(worker.releases) == 1
+
+
+@pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"])
+def test_updates_pipenv(qtbot, worker, mocker, version):
+ """Test updates for pip installed Spyder."""
+ mocker.patch.object(workers, "__version__", new=version)
+ mocker.patch.object(workers, "is_anaconda", return_value=False)
+ mocker.patch.object(workers, "is_conda_based_app", return_value=False)
+ mocker.patch.object(
+ workers, "get_spyder_conda_channel",
+ return_value=("pypi", "https://conda.anaconda.org/pypi")
+ )
+
+ with qtbot.waitSignal(worker.sig_ready, timeout=5000):
+ worker.start()
+
+ update_available = worker.update_available
+ if version.split('.')[0] == '1':
+ assert update_available
+ else:
+ assert not update_available
+ assert len(worker.releases) == 1
+
+
+@pytest.mark.parametrize("release", ["4.0.1", "4.0.1a1"])
+@pytest.mark.parametrize("version", ["4.0.0a1", "4.0.0"])
+@pytest.mark.parametrize("stable_only", [True, False])
+def test_update_non_stable(qtbot, mocker, version, release, stable_only):
+ """Test we offer unstable updates."""
+ mocker.patch.object(workers, "__version__", new=version)
+
+ worker = WorkerUpdate(stable_only)
+ worker.releases = [release]
+ worker._check_update_available()
+
+ update_available = worker.update_available
+ if "a" in release and stable_only:
+ assert not update_available
+ else:
+ assert update_available
+
+
+# ---- Test WorkerDownloadInstaller
+
+@pytest.mark.skip(reason="Re-enable when alternate repo is available")
+@pytest.mark.skipif(not running_in_ci(), reason="Download only in CI")
+def test_download(qtbot, mocker):
+ """
+ Test download spyder installer.
+
+ Uses UpdateManagerWidget in order to also test QThread.
+ """
+ um = UpdateManagerWidget(None)
+ um.latest_release = "6.0.0a2"
+ um._set_installer_path()
+
+ # Do not execute _start_install after download completes.
+ mocker.patch.object(
+ UpdateManagerWidget, "_confirm_install", new=lambda x: None
+ )
+
+ um._start_download()
+ qtbot.waitUntil(um.download_thread.isFinished, timeout=60000)
+
+ assert os.path.exists(um.installer_path)
+
+
+if __name__ == "__main__":
+ pytest.main()
diff --git a/spyder/plugins/application/widgets/__init__.py b/spyder/plugins/updatemanager/widgets/__init__.py
similarity index 77%
rename from spyder/plugins/application/widgets/__init__.py
rename to spyder/plugins/updatemanager/widgets/__init__.py
index 5a3f944db10..e35c9c7b676 100644
--- a/spyder/plugins/application/widgets/__init__.py
+++ b/spyder/plugins/updatemanager/widgets/__init__.py
@@ -5,4 +5,4 @@
# Licensed under the terms of the MIT License
# (see spyder/__init__.py for details)
-"""Widgets for the Application plugin."""
+"""Widgets for the Update Manager plugin."""
diff --git a/spyder/plugins/updatemanager/widgets/status.py b/spyder/plugins/updatemanager/widgets/status.py
new file mode 100644
index 00000000000..7d7468635cd
--- /dev/null
+++ b/spyder/plugins/updatemanager/widgets/status.py
@@ -0,0 +1,148 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+Status widget for Spyder updates.
+"""
+
+# Standard library imports
+import logging
+import os
+
+# Third party imports
+from qtpy.QtCore import QPoint, Qt, Signal, Slot
+from qtpy.QtWidgets import QLabel
+
+# Local imports
+from spyder.api.translations import _
+from spyder.api.widgets.menus import SpyderMenu
+from spyder.api.widgets.status import StatusBarWidget
+from spyder.plugins.updatemanager.widgets.update import (
+ CHECKING,
+ DOWNLOAD_FINISHED,
+ DOWNLOADING_INSTALLER,
+ INSTALL_ON_CLOSE,
+ NO_STATUS,
+ PENDING
+)
+from spyder.utils.icon_manager import ima
+from spyder.utils.qthelpers import add_actions, create_action
+
+
+# Setup logger
+logger = logging.getLogger(__name__)
+
+
+class UpdateManagerStatus(StatusBarWidget):
+ """Status bar widget for update manager."""
+ BASE_TOOLTIP = _("Application update status")
+ ID = 'update_manager_status'
+
+ sig_check_update = Signal()
+ """Signal to request checking for updates."""
+
+ sig_start_update = Signal()
+ """Signal to start the update process"""
+
+ sig_show_progress_dialog = Signal(bool)
+ """
+ Signal to show the progress dialog.
+
+ Parameters
+ ----------
+ show: bool
+ True to show, False to hide.
+ """
+
+ CUSTOM_WIDGET_CLASS = QLabel
+
+ def __init__(self, parent):
+
+ self.tooltip = self.BASE_TOOLTIP
+ super().__init__(parent, show_spinner=True)
+
+ # Check for updates action menu
+ self.menu = SpyderMenu(self)
+
+ # Set aligment attributes for custom widget to match default label
+ # values
+ self.custom_widget.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
+
+ # Signals
+ self.sig_clicked.connect(self.show_dialog_or_menu)
+
+ def set_value(self, value):
+ """Set update manager status."""
+ if value == DOWNLOADING_INSTALLER:
+ self.tooltip = _(
+ "Downloading the update will continue in the background.\n"
+ "Click here to show the download dialog again."
+ )
+ self.spinner.hide()
+ self.spinner.stop()
+ self.custom_widget.show()
+ elif value == CHECKING:
+ self.tooltip = self.BASE_TOOLTIP
+ self.custom_widget.hide()
+ self.spinner.show()
+ self.spinner.start()
+ elif value == PENDING:
+ self.tooltip = value
+ self.custom_widget.hide()
+ self.spinner.hide()
+ self.spinner.stop()
+ else:
+ self.tooltip = self.BASE_TOOLTIP
+ if self.custom_widget:
+ self.custom_widget.hide()
+ if self.spinner:
+ self.spinner.hide()
+ self.spinner.stop()
+
+ self.setVisible(True)
+ self.update_tooltip()
+ value = f"Spyder: {value}"
+ logger.debug(f"Update manager status: {value}")
+ super().set_value(value)
+
+ def set_no_status(self):
+ """Convenience method to set status to NO_STATUS"""
+ self.set_value(NO_STATUS)
+
+ def get_tooltip(self):
+ """Reimplementation to get a dynamic tooltip."""
+ return self.tooltip
+
+ def get_icon(self):
+ return ima.icon('spyder_about')
+
+ def set_download_progress(self, percent_progress):
+ """Set download progress in status bar"""
+ self.custom_widget.setText(f"{percent_progress}%")
+
+ @Slot()
+ def show_dialog_or_menu(self):
+ """Show download dialog or status bar menu."""
+ value = self.value.split(":")[-1].strip()
+ if value == DOWNLOADING_INSTALLER:
+ self.sig_show_progress_dialog.emit(True)
+ elif value in (PENDING, DOWNLOAD_FINISHED, INSTALL_ON_CLOSE):
+ self.sig_start_update.emit()
+ elif value == NO_STATUS:
+ self.menu.clear()
+ check_for_updates_action = create_action(
+ self,
+ text=_("Check for updates..."),
+ triggered=self.sig_check_update.emit
+ )
+
+ add_actions(self.menu, [check_for_updates_action])
+ rect = self.contentsRect()
+ os_height = 7 if os.name == 'nt' else 12
+ pos = self.mapToGlobal(
+ rect.topLeft() + QPoint(-10, -rect.height() - os_height)
+ )
+ self.menu.popup(pos)
diff --git a/spyder/plugins/updatemanager/widgets/update.py b/spyder/plugins/updatemanager/widgets/update.py
new file mode 100644
index 00000000000..7287af3dee6
--- /dev/null
+++ b/spyder/plugins/updatemanager/widgets/update.py
@@ -0,0 +1,604 @@
+# -*- coding: utf-8 -*-
+
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""Update Manager widgets."""
+
+# Standard library imports
+import logging
+import os
+import os.path as osp
+import sys
+import subprocess
+import platform
+
+# Third-party imports
+from packaging.version import parse
+from qtpy.QtCore import Qt, QThread, QTimer, Signal
+from qtpy.QtWidgets import QMessageBox, QWidget, QProgressBar, QPushButton
+
+# Local imports
+from spyder import __version__
+from spyder.api.config.mixins import SpyderConfigurationAccessor
+from spyder.api.translations import _
+from spyder.config.base import is_conda_based_app
+from spyder.config.utils import is_anaconda
+from spyder.plugins.updatemanager.workers import (
+ WorkerUpdate,
+ WorkerDownloadInstaller
+)
+from spyder.utils.conda import find_conda, is_anaconda_pkg
+from spyder.utils.programs import get_temp_dir, is_program_installed
+from spyder.widgets.helperwidgets import MessageCheckBox
+
+# Logger setup
+logger = logging.getLogger(__name__)
+
+# Update installation process statuses
+NO_STATUS = __version__
+DOWNLOADING_INSTALLER = _("Downloading update")
+DOWNLOAD_FINISHED = _("Download finished")
+INSTALLING = _("Installing update")
+FINISHED = _("Installation finished")
+PENDING = _("Update available")
+CHECKING = _("Checking for updates")
+CANCELLED = _("Cancelled update")
+INSTALL_ON_CLOSE = _("Install on close")
+
+HEADER = _("Spyder {} is available!
")
+URL_I = 'https://docs.spyder-ide.org/current/installation.html'
+
+
+class UpdateManagerWidget(QWidget, SpyderConfigurationAccessor):
+ """Check for updates widget."""
+
+ CONF_SECTION = "update_manager"
+
+ sig_disable_actions = Signal(bool)
+ """
+ Signal to disable plugin actions during check for update.
+
+ Parameters
+ ----------
+ disable: bool
+ True to disable, False to re-enable.
+ """
+
+ sig_block_status_signals = Signal(bool)
+ """
+ Signal to block signals from update manager status during
+ check for update.
+
+ Parameters
+ ----------
+ block: bool
+ True to block, False to unblock.
+ """
+
+ sig_download_progress = Signal(int)
+ """
+ Signal to send the download progress.
+
+ Parameters
+ ----------
+ percent_progress: int
+ Percent of the data downloaded until now.
+ """
+
+ sig_set_status = Signal(str, str)
+ """
+ Signal to set the status of update manager.
+
+ Parameters
+ ----------
+ status: str
+ Status string.
+ latest_release: str
+ Latest release version detected.
+ """
+
+ sig_install_on_close = Signal(bool)
+ """
+ Signal to request running the install process on close.
+
+ Parameters
+ ----------
+ install_on_close: bool
+ Whether to install on close.
+ """
+
+ sig_quit_requested = Signal()
+ """
+ This signal can be emitted to request the main application to quit.
+ """
+
+ def __init__(self, parent):
+ super().__init__(parent)
+
+ self.startup = None
+ self.update_thread = None
+ self.update_worker = None
+ self.update_timer = None
+ self.latest_release = None
+
+ self.cancelled = False
+ self.download_thread = None
+ self.download_worker = None
+ self.progress_dialog = None
+ self.installer_path = None
+ self.installer_size_path = None
+
+ # ---- General
+
+ def set_status(self, status=NO_STATUS):
+ """Set the update manager status."""
+ self.sig_set_status.emit(status, self.latest_release)
+
+ def cleanup_threads(self):
+ """Clean up QThreads"""
+ if self.update_timer is not None:
+ self.update_timer.stop()
+ if self.update_thread is not None:
+ self.update_thread.quit()
+ self.update_thread.wait()
+ if self.download_thread is not None:
+ self.download_worker.cancelled = True
+ self.download_thread.quit()
+ self.download_thread.wait()
+
+ # ---- Check Update
+
+ def start_check_update(self, startup=False):
+ """
+ Check for Spyder updates using a QThread.
+
+ Update actions are disabled in the menubar and statusbar while
+ checking for updates.
+
+ If startup is True, then checking for updates is delayed 1 min;
+ actions are disabled during this time as well.
+ """
+ logger.debug(f"Checking for updates. startup = {startup}.")
+
+ # Disable check_update_action while the thread is working
+ self.sig_disable_actions.emit(True)
+
+ self.startup = startup
+ self.cleanup_threads()
+
+ self.update_thread = QThread(None)
+ self.update_worker = WorkerUpdate(self.get_conf('check_stable_only'))
+ self.update_worker.sig_ready.connect(self._process_check_update)
+ self.update_worker.sig_ready.connect(self.update_thread.quit)
+ self.update_worker.sig_ready.connect(
+ lambda: self.sig_disable_actions.emit(False)
+ )
+ self.update_worker.moveToThread(self.update_thread)
+ self.update_thread.started.connect(lambda: self.set_status(CHECKING))
+ self.update_thread.started.connect(self.update_worker.start)
+
+ # Delay starting this check to avoid blocking the main window
+ # while loading.
+ # Fixes spyder-ide/spyder#15839
+ if self.startup:
+ self.update_timer = QTimer(self)
+ self.update_timer.setInterval(60000)
+ self.update_timer.setSingleShot(True)
+ self.sig_block_status_signals.emit(True)
+ self.update_timer.timeout.connect(
+ lambda: self.sig_block_status_signals.emit(False)
+ )
+ self.update_timer.timeout.connect(self.update_thread.start)
+ self.update_timer.start()
+ else:
+ # Otherwise, start immediately
+ self.update_thread.start()
+
+ def _process_check_update(self):
+ """Process the results of check update."""
+ # Get results from worker
+ update_available = self.update_worker.update_available
+ error_msg = self.update_worker.error
+
+ # Always set status, regardless of error, DEV, or startup
+ self.set_status(PENDING if update_available else NO_STATUS)
+
+ # self.startup = True is used on startup, so only positive feedback is
+ # given. self.startup = False is used after startup when using the menu
+ # action, and gives feeback if updates are or are not found.
+ if (
+ self.startup and # startup and...
+ (error_msg is not None # there is an error
+ or not update_available) # or no updates available
+ ):
+ # Do not alert the user to anything
+ pass
+ elif error_msg is not None:
+ error_messagebox(self, error_msg)
+ elif update_available:
+ self.start_update()
+ else:
+ info_messagebox(self, _("Spyder is up to date."), checkbox=True)
+
+ def _set_installer_path(self):
+ """Set the temp file path for the downloaded installer."""
+ if os.name == 'nt':
+ plat, ext = 'Windows', 'exe'
+ if sys.platform == 'darwin':
+ plat, ext = 'macOS', 'pkg'
+ if sys.platform.startswith('linux'):
+ plat, ext = 'Linux', 'sh'
+
+ mach = platform.machine().lower().replace("amd64", "x86_64")
+ fname = f'Spyder-{self.latest_release}-{plat}-{mach}.{ext}'
+
+ dirname = osp.join(get_temp_dir(), 'updates', self.latest_release)
+ self.installer_path = osp.join(dirname, fname)
+ self.installer_size_path = osp.join(dirname, "size")
+
+ # ---- Download Update
+
+ def _verify_installer_path(self):
+ if (
+ osp.exists(self.installer_path)
+ and osp.exists(self.installer_size_path)
+ ):
+ with open(self.installer_size_path, "r") as f:
+ size = int(f.read().strip())
+ return size == osp.getsize(self.installer_path)
+ else:
+ return False
+
+ def start_update(self):
+ """
+ Start the update process
+
+ Request input from user whether to download the installer; upon
+ affirmation, proceed with download then to confirm install.
+
+ If the installer is already downloaded, proceed to confirm install.
+ """
+ self.latest_release = self.update_worker.latest_release
+ self._set_installer_path()
+ major_update = (
+ parse(__version__).major < parse(self.latest_release).major
+ )
+
+ if self._verify_installer_path():
+ self.set_status(DOWNLOAD_FINISHED)
+ self._confirm_install()
+ elif not is_conda_based_app():
+ msg = _(
+ "Would you like to automatically download and "
+ "install it using Spyder's installer?"
+ "
"
+ "We recommend our own installer "
+ "because it's more stable and makes updating easy. "
+ "This will leave your existing Spyder installation "
+ "untouched."
+ ).format(URL_I + "#standalone-installers")
+
+ box = confirm_messagebox(
+ self, msg, _('Spyder update'),
+ version=self.latest_release, checkbox=True
+ )
+ if box.result() == QMessageBox.Yes:
+ self._start_download()
+ else:
+ manual_update_messagebox(
+ self, self.latest_release, self.update_worker.channel
+ )
+ elif major_update:
+ msg = _("Would you like to automatically download "
+ "and install it?")
+ box = confirm_messagebox(
+ self, msg, _('Spyder update'),
+ version=self.latest_release, checkbox=True
+ )
+ if box.result() == QMessageBox.Yes:
+ self._start_download()
+ else:
+ # Minor release for conda-based application will update with conda
+ self._confirm_install()
+
+ def _start_download(self):
+ """
+ Start downloading the installer in a QThread
+ and set downloading status.
+ """
+ self.cancelled = False
+ self.download_worker = WorkerDownloadInstaller(
+ self.latest_release, self.installer_path, self.installer_size_path
+ )
+
+ self.sig_disable_actions.emit(True)
+ self.set_status(DOWNLOADING_INSTALLER)
+
+ self.progress_dialog = ProgressDialog(
+ self, _("Downloading Spyder {} ...").format(self.latest_release)
+ )
+ self.progress_dialog.cancel.clicked.connect(self._cancel_download)
+
+ self.download_thread = QThread(None)
+ self.download_worker.sig_ready.connect(self._confirm_install)
+ self.download_worker.sig_ready.connect(self.download_thread.quit)
+ self.download_worker.sig_ready.connect(
+ lambda: self.sig_disable_actions.emit(False)
+ )
+ self.download_worker.sig_download_progress.connect(
+ self._update_download_progress
+ )
+ self.download_worker.moveToThread(self.download_thread)
+ self.download_thread.started.connect(self.download_worker.start)
+ self.download_thread.start()
+
+ def show_progress_dialog(self, show=True):
+ """Show download progress if previously hidden"""
+ if self.progress_dialog is not None:
+ if show:
+ self.progress_dialog.show()
+ else:
+ self.progress_dialog.hide()
+
+ def _update_download_progress(self, progress, total):
+ """Update download progress in dialog and status bar"""
+ if self.progress_dialog is not None:
+ self.progress_dialog.update_progress(progress, total)
+ if progress == total:
+ self.progress_dialog.accept()
+
+ percent_progress = 0
+ if total > 0:
+ percent_progress = round((progress / total) * 100)
+ self.sig_download_progress.emit(percent_progress)
+
+ def _cancel_download(self):
+ """Cancel the installation in progress."""
+ self.download_worker.paused = True
+ msg = _('Do you really want to cancel the download?')
+ box = confirm_messagebox(
+ self, msg, _('Spyder download'), critical=True
+ )
+ if box.result() == QMessageBox.Yes:
+ self.cancelled = True
+ self.cleanup_threads()
+ self.set_status(PENDING)
+ else:
+ self.progress_dialog.show()
+ self.download_worker.paused = False
+
+ def _confirm_install(self):
+ """
+ Ask users if they want to proceed with the install immediately
+ or on close.
+ """
+ if self.cancelled:
+ return
+
+ if self.download_worker:
+ if self.download_worker.error:
+ # If download error, do not proceed with install
+ self.progress_dialog.reject()
+ self.set_status(PENDING)
+ error_messagebox(self, self.download_worker.error)
+ return
+ else:
+ self.set_status(DOWNLOAD_FINISHED)
+
+ msg = _("Would you like to install it?")
+ box = confirm_messagebox(
+ self,
+ msg,
+ _('Spyder install'),
+ version=self.latest_release,
+ on_close=True
+ )
+ if box.result() == QMessageBox.Yes:
+ self.sig_install_on_close.emit(True)
+ self.sig_quit_requested.emit()
+ elif box.result() == 0: # 0 is result of 3rd push-button
+ self.sig_install_on_close.emit(True)
+ self.set_status(INSTALL_ON_CLOSE)
+
+ def start_install(self):
+ """Install from downloaded installer or update through conda."""
+
+ # Install script
+ script = osp.abspath(__file__ + '/../../scripts/install.' +
+ ('bat' if os.name == 'nt' else 'sh'))
+
+ # Sub command
+ sub_cmd = [script, '-p', sys.prefix]
+ if osp.exists(self.installer_path):
+ # Run downloaded installer
+ sub_cmd.extend(['-i', self.installer_path])
+ elif self.latest_release is not None:
+ # Update with conda
+ sub_cmd.extend(['-c', find_conda(), '-v', self.latest_release])
+
+ # Final command assembly
+ if os.name == 'nt':
+ cmd = ['start', '"Update Spyder"'] + sub_cmd
+ elif sys.platform == 'darwin':
+ # Terminal cannot accept a command with arguments therefore
+ # create a temporary script
+ tmpscript = osp.join(get_temp_dir(), 'tmp_install.sh')
+ with open(tmpscript, 'w') as f:
+ f.write(' '.join(sub_cmd))
+ os.chmod(tmpscript, 0o711) # set executable permissions
+
+ cmd = ['open', '-b', 'com.apple.terminal', tmpscript]
+ else:
+ programs = [
+ {'cmd': 'gnome-terminal', 'exe-opt': '--window --'},
+ {'cmd': 'konsole', 'exe-opt': '-e'},
+ {'cmd': 'xfce4-terminal', 'exe-opt': '-x'},
+ {'cmd': 'xterm', 'exe-opt': '-e'}
+ ]
+ for program in programs:
+ if is_program_installed(program['cmd']):
+ cmd = [program['cmd'], program['exe-opt']] + sub_cmd
+ break
+
+ subprocess.Popen(' '.join(cmd), shell=True)
+
+
+class UpdateMessageBox(QMessageBox):
+ def __init__(self, icon=None, text=None, parent=None):
+ super().__init__(icon=icon, text=text, parent=parent)
+ self.setWindowModality(Qt.NonModal)
+ self.setTextFormat(Qt.RichText)
+
+
+class UpdateMessageCheckBox(MessageCheckBox):
+ def __init__(self, icon=None, text=None, parent=None):
+ super().__init__(icon=icon, text=text, parent=parent)
+ self.setTextFormat(Qt.RichText)
+ self._parent = parent
+ self.set_checkbox_text(_("Check for updates at startup"))
+ self.option = 'check_updates_on_startup'
+ self.accepted.connect(self.accept) # ??? Why is the signal necessary?
+ if self._parent is not None:
+ self.set_checked(parent.get_conf(self.option))
+
+ def accept(self):
+ if self._parent is not None:
+ self._parent.set_conf(self.option, self.is_checked())
+
+
+class ProgressDialog(UpdateMessageBox):
+ """Update progress installation dialog."""
+
+ def __init__(self, parent, text):
+ super().__init__(icon=QMessageBox.NoIcon, text=text, parent=parent)
+ self.setWindowTitle(_("Spyder update"))
+
+ self._progress_bar = QProgressBar(self)
+ self._progress_bar.setMinimumWidth(250)
+ self._progress_bar.setFixedHeight(15)
+
+ layout = self.layout()
+ layout.addWidget(self._progress_bar, 1, 1)
+
+ self.cancel = QPushButton(_("Cancel"))
+ self.okay = QPushButton(_("OK"))
+ self.addButton(self.okay, QMessageBox.YesRole)
+ self.addButton(self.cancel, QMessageBox.NoRole)
+ self.setDefaultButton(self.okay)
+
+ self.show()
+
+ def update_progress(self, progress, total):
+ """Update installation progress bar."""
+ self._progress_bar.setMaximum(total)
+ self._progress_bar.setValue(progress)
+
+
+def error_messagebox(parent, error_msg):
+ box = UpdateMessageBox(
+ icon=QMessageBox.Warning, text=error_msg, parent=parent
+ )
+ box.setWindowTitle(_("Spyder update error"))
+ box.setStandardButtons(QMessageBox.Ok)
+ box.setDefaultButton(QMessageBox.Ok)
+ box.show()
+ return box
+
+
+def info_messagebox(parent, message, version=None, checkbox=False):
+ box_class = UpdateMessageCheckBox if checkbox else UpdateMessageBox
+ message = HEADER.format(version) + message if version else message
+ box = box_class(icon=QMessageBox.Information, text=message, parent=parent)
+ box.setWindowTitle(_("New Spyder version"))
+ box.setStandardButtons(QMessageBox.Ok)
+ box.setDefaultButton(QMessageBox.Ok)
+ box.show()
+ return box
+
+
+def confirm_messagebox(parent, message, title, version=None, critical=False,
+ checkbox=False, on_close=False):
+ box_class = UpdateMessageCheckBox if checkbox else UpdateMessageBox
+ message = HEADER.format(version) + message if version else message
+ box = box_class(
+ icon=QMessageBox.Critical if critical else QMessageBox.Question,
+ text=message, parent=parent
+ )
+ box.setWindowTitle(title)
+ box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
+ box.setDefaultButton(QMessageBox.Yes)
+ if on_close:
+ box.addButton(_("After closing"), QMessageBox.YesRole)
+ box.exec()
+ return box
+
+
+def manual_update_messagebox(parent, latest_release, channel):
+ msg = ""
+ if os.name == "nt":
+ if is_anaconda():
+ msg += _("Run the following command or commands in "
+ "the Anaconda prompt to update manually:"
+ "
")
+ else:
+ msg += _("Run the following command in a cmd prompt "
+ "to update manually:
")
+ else:
+ if is_anaconda():
+ msg += _("Run the following command or commands in a "
+ "terminal to update manually:
")
+ else:
+ msg += _("Run the following command in a terminal to "
+ "update manually:
")
+
+ if is_anaconda():
+ is_pypi = channel == 'pypi'
+
+ if is_anaconda_pkg() and not is_pypi:
+ msg += "conda update anaconda
"
+
+ if is_pypi:
+ dont_mix_pip_conda_video = (
+ "https://youtu.be/Ul79ihg41Rs"
+ )
+
+ msg += (
+ "pip install --upgrade spyder
"
+ "
"
+ )
+
+ msg += _(
+ "Important note: You installed Spyder with "
+ "pip in a Conda environment, which is not a good "
+ "idea. See our video for more "
+ "details about it."
+ ).format(dont_mix_pip_conda_video)
+ else:
+ if channel == 'pkgs/main':
+ channel = ''
+ else:
+ channel = f'-c {channel}'
+
+ msg += (
+ f"conda install {channel} "
+ f"spyder={latest_release}"
+ f"
"
+ )
+
+ msg += _(
+ "Important note: Since you installed "
+ "Spyder with Anaconda, please don't use pip "
+ "to update it as that will break your "
+ "installation."
+ )
+ else:
+ msg += "pip install --upgrade spyder
"
+
+ msg += _(
+ "
For more information, visit our "
+ "installation guide."
+ ).format(URL_I)
+
+ info_messagebox(parent, msg)
diff --git a/spyder/plugins/updatemanager/workers.py b/spyder/plugins/updatemanager/workers.py
new file mode 100644
index 00000000000..773eef09e41
--- /dev/null
+++ b/spyder/plugins/updatemanager/workers.py
@@ -0,0 +1,285 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+# Standard library imports
+import logging
+import os
+import os.path as osp
+from time import sleep
+import traceback
+
+# Third party imports
+from packaging.version import parse
+from qtpy.QtCore import QObject, Signal
+import requests
+from requests.exceptions import ConnectionError, HTTPError, SSLError
+
+# Local imports
+from spyder import __version__
+from spyder.config.base import _, is_stable_version, is_conda_based_app
+from spyder.config.utils import is_anaconda
+from spyder.utils.conda import get_spyder_conda_channel
+from spyder.utils.programs import check_version
+
+# Logger setup
+logger = logging.getLogger(__name__)
+
+CONNECT_ERROR_MSG = _(
+ 'Unable to connect to the Spyder update service.'
+ '
Make sure your connection is working properly.'
+)
+
+HTTP_ERROR_MSG = _(
+ 'HTTP error {status_code} when checking for updates.'
+ '
Make sure your connection is working properly,'
+ 'and try again later.'
+)
+
+SSL_ERROR_MSG = _(
+ 'SSL certificate verification failed while checking for Spyder updates.'
+ '
Please contact your network administrator for assistance.'
+)
+
+
+class UpdateDownloadCancelledException(Exception):
+ """Download for installer to update was cancelled."""
+ pass
+
+
+class UpdateDownloadIncompleteError(Exception):
+ """Error occured while downloading file"""
+ pass
+
+
+class WorkerUpdate(QObject):
+ """
+ Worker that checks for releases using either the Anaconda
+ default channels or the Github Releases page without
+ blocking the Spyder user interface, in case of connection
+ issues.
+ """
+ sig_ready = Signal()
+
+ def __init__(self, stable_only):
+ super().__init__()
+ self.stable_only = stable_only
+ self.latest_release = None
+ self.releases = None
+ self.update_available = False
+ self.error = None
+ self.channel = None
+
+ def _check_update_available(self):
+ """Checks if there is an update available from releases."""
+ # Filter releases
+ releases = self.releases.copy()
+ if self.stable_only:
+ # Only use stable releases
+ releases = [r for r in releases if is_stable_version(r)]
+ logger.debug(f"Available versions: {self.releases}")
+
+ self.latest_release = releases[-1] if releases else __version__
+ self.update_available = check_version(
+ __version__,
+ self.latest_release,
+ '<'
+ )
+
+ logger.debug(f"Update available: {self.update_available}")
+ logger.debug(f"Latest release: {self.latest_release}")
+
+ def start(self):
+ """Main method of the worker."""
+ self.error = None
+ self.latest_release = None
+ self.update_available = False
+ error_msg = None
+ pypi_url = "https://pypi.org/pypi/spyder/json"
+
+ if is_conda_based_app():
+ url = 'https://api.github.com/repos/spyder-ide/spyder/releases'
+ elif is_anaconda():
+ self.channel, channel_url = get_spyder_conda_channel()
+
+ if channel_url is None:
+ return
+ elif self.channel == "pypi":
+ url = pypi_url
+ else:
+ url = channel_url + '/channeldata.json'
+ else:
+ url = pypi_url
+
+ logger.info(f"Checking for updates from {url}")
+ try:
+ page = requests.get(url)
+ page.raise_for_status()
+ data = page.json()
+
+ if self.releases is None:
+ if is_conda_based_app():
+ self.releases = [
+ item['tag_name'].replace('v', '') for item in data
+ ]
+ elif is_anaconda() and url != pypi_url:
+ spyder_data = data['packages'].get('spyder')
+ if spyder_data:
+ self.releases = [spyder_data["version"]]
+ else:
+ self.releases = [data['info']['version']]
+ self.releases.sort(key=parse)
+
+ self._check_update_available()
+ except SSLError as err:
+ error_msg = SSL_ERROR_MSG
+ logger.warning(err, exc_info=err)
+ except ConnectionError as err:
+ error_msg = CONNECT_ERROR_MSG
+ logger.warning(err, exc_info=err)
+ except HTTPError as err:
+ error_msg = HTTP_ERROR_MSG.format(status_code=page.status_code)
+ logger.warning(err, exc_info=err)
+ except Exception as err:
+ error = traceback.format_exc()
+ formatted_error = (
+ error.replace('\n', '
')
+ .replace(' ', ' ')
+ )
+
+ error_msg = _(
+ 'It was not possible to check for Spyder updates due to the '
+ 'following error:'
+ '
'
+ '{}'
+ ).format(formatted_error)
+ logger.warning(err, exc_info=err)
+ finally:
+ self.error = error_msg
+ try:
+ self.sig_ready.emit()
+ except RuntimeError:
+ pass
+
+
+class WorkerDownloadInstaller(QObject):
+ """
+ Worker that donwloads standalone installers for Windows, macOS,
+ and Linux without blocking the Spyder user interface.
+ """
+
+ sig_ready = Signal()
+ """Signal to inform that the worker has finished successfully."""
+
+ sig_download_progress = Signal(int, int)
+ """
+ Signal to send the download progress.
+
+ Parameters
+ ----------
+ current_value: int
+ Size of the data downloaded until now.
+ total: int
+ Total size of the file expected to be downloaded.
+ """
+
+ def __init__(self, latest_release, installer_path, installer_size_path):
+ super().__init__()
+ self.latest_release = latest_release
+ self.installer_path = installer_path
+ self.installer_size_path = installer_size_path
+ self.error = None
+ self.cancelled = False
+ self.paused = False
+
+ def _progress_reporter(self, progress, total_size):
+ """Calculate download progress and notify."""
+ self.sig_download_progress.emit(progress, total_size)
+
+ while self.paused and not self.cancelled:
+ sleep(0.5)
+
+ if self.cancelled:
+ raise UpdateDownloadCancelledException()
+
+ def _download_installer(self):
+ """Donwload Spyder installer."""
+ url = (
+ 'https://github.com/spyder-ide/spyder/releases/download/'
+ f'v{self.latest_release}/{osp.basename(self.installer_path)}'
+ )
+ logger.info(f"Downloading installer from {url} "
+ f"to {self.installer_path}")
+
+ dirname = osp.dirname(self.installer_path)
+ os.makedirs(dirname, exist_ok=True)
+
+ with requests.get(url, stream=True) as r:
+ r.raise_for_status()
+ size = -1
+ if "content-length" in r.headers:
+ size = int(r.headers["content-length"])
+ self._progress_reporter(0, size)
+
+ with open(self.installer_path, 'wb') as f:
+ chunk_size = 8 * 1024
+ size_read = 0
+ for chunk in r.iter_content(chunk_size=chunk_size):
+ size_read += len(chunk)
+ f.write(chunk)
+ self._progress_reporter(size_read, size)
+
+ if size_read == size:
+ logger.info('Download successfully completed.')
+ with open(self.installer_size_path, "w") as f:
+ f.write(str(size))
+ else:
+ raise UpdateDownloadIncompleteError(
+ "Download incomplete: retrieved only "
+ f"{size_read} out of {size} bytes."
+ )
+
+ def _clean_installer_path(self):
+ """Remove downloaded file"""
+ if osp.exists(self.installer_path):
+ os.remove(self.installer_path)
+ if osp.exists(self.installer_size_path):
+ os.remove(self.installer_size_path)
+
+ def start(self):
+ """Main method of the worker."""
+ error_msg = None
+ try:
+ self._download_installer()
+ except UpdateDownloadCancelledException:
+ logger.info("Download cancelled")
+ self._clean_installer_path()
+ except SSLError as err:
+ error_msg = SSL_ERROR_MSG
+ logger.warning(err, exc_info=err)
+ except ConnectionError as err:
+ error_msg = CONNECT_ERROR_MSG
+ logger.warning(err, exc_info=err)
+ except Exception as err:
+ error = traceback.format_exc()
+ formatted_error = (
+ error.replace('\n', '
')
+ .replace(' ', ' ')
+ )
+
+ error_msg = _(
+ 'It was not possible to download the installer due to the '
+ 'following error:'
+ '
'
+ '{}'
+ ).format(formatted_error)
+ logger.warning(err, exc_info=err)
+ self._clean_installer_path()
+ finally:
+ self.error = error_msg
+ try:
+ self.sig_ready.emit()
+ except RuntimeError:
+ pass
diff --git a/spyder/workers/__init__.py b/spyder/workers/__init__.py
deleted file mode 100644
index 839eae7ce43..00000000000
--- a/spyder/workers/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-# -*- coding: utf-8 -*-
-# -----------------------------------------------------------------------------
-# Copyright (c) 2009- Spyder Project Contributors
-#
-# Distributed under the terms of the MIT License
-# (see spyder/__init__.py for details)
-# -----------------------------------------------------------------------------
diff --git a/spyder/workers/tests/test_update.py b/spyder/workers/tests/test_update.py
deleted file mode 100644
index eaaf9fc3f52..00000000000
--- a/spyder/workers/tests/test_update.py
+++ /dev/null
@@ -1,112 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright © Spyder Project Contributors
-# Licensed under the terms of the MIT License
-# (see spyder/__init__.py for details)
-
-import sys
-
-import pytest
-
-from spyder.workers.updates import WorkerUpdates
-
-
-@pytest.mark.parametrize("is_anaconda", [True, False])
-@pytest.mark.parametrize("is_pypi", [True, False])
-@pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"])
-@pytest.mark.parametrize(
- "spyder_conda_channel", [
- ("pkgs/main", "https://repo.anaconda.com/pkgs/main"),
- ("conda-forge", "https://conda.anaconda.org/conda-forge")
- ]
-)
-def test_updates(qtbot, mocker, is_anaconda, is_pypi, version,
- spyder_conda_channel):
- """
- Test whether or not we offer updates for Anaconda and PyPI according to the
- current Spyder version.
- """
- mocker.patch(
- "spyder.workers.updates.is_anaconda",
- return_value=is_anaconda
- )
-
- if is_anaconda:
- if is_pypi:
- channel = ("pypi", "https://conda.anaconda.org/pypi")
- else:
- channel = spyder_conda_channel
-
- mocker.patch(
- "spyder.workers.updates.get_spyder_conda_channel",
- return_value=channel
- )
-
- worker = WorkerUpdates(None, False, version=version)
- worker.start()
-
- update = worker.update_available
- assert update if version.split('.')[0] == '1' else not update
- assert len(worker.releases) == 1
-
-
-@pytest.mark.skipif(sys.platform == 'darwin', reason="Fails frequently on Mac")
-@pytest.mark.parametrize("version", ["1.0.0", "1000.0.0"])
-def test_updates_for_installers(qtbot, mocker, version):
- """
- Test whether or not we offer updates for our installers according to the
- current Spyder version.
- """
- mocker.patch("spyder.workers.updates.is_anaconda", return_value=False)
- mocker.patch("spyder.workers.updates.is_conda_based_app",
- return_value=True)
-
- worker = WorkerUpdates(None, False, version=version)
- worker.start()
-
- update = worker.update_available
- assert update if version.split('.')[0] == '1' else not update
- assert len(worker.releases) > 1
-
-
-def test_no_update_development(qtbot, mocker):
- """Test we don't offer updates for development versions."""
- mocker.patch(
- "spyder.workers.updates.get_spyder_conda_channel",
- return_value=("pypi", "https://conda.anaconda.org/pypi")
- )
-
- worker = WorkerUpdates(None, False, version="3.3.2.dev0",
- releases=['3.3.1'])
- worker.start()
- assert not worker.update_available
-
-
-def test_update_pre_to_pre(qtbot, mocker):
- """Test we offer updates between prereleases."""
- mocker.patch(
- "spyder.workers.updates.get_spyder_conda_channel",
- return_value=("pypi", "https://conda.anaconda.org/pypi")
- )
-
- worker = WorkerUpdates(None, False, version="4.0.0a1",
- releases=['4.0.0b5'])
- worker.start()
- assert worker.update_available
-
-
-def test_update_pre_to_final(qtbot, mocker):
- """Test we offer updates from prereleases to the final versions."""
- mocker.patch(
- "spyder.workers.updates.get_spyder_conda_channel",
- return_value=("pypi", "https://conda.anaconda.org/pypi")
- )
-
- worker = WorkerUpdates(None, False, version="4.0.0b3",
- releases=['4.0.0'])
- worker.start()
- assert worker.update_available
-
-
-if __name__ == "__main__":
- pytest.main()
diff --git a/spyder/workers/updates.py b/spyder/workers/updates.py
deleted file mode 100644
index a3643bcae25..00000000000
--- a/spyder/workers/updates.py
+++ /dev/null
@@ -1,311 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright © Spyder Project Contributors
-# Licensed under the terms of the MIT License
-# (see spyder/__init__.py for details)
-
-# Standard library imports
-import logging
-import os
-import os.path as osp
-import tempfile
-import traceback
-
-# Third party imports
-from qtpy.QtCore import QObject, Signal
-import requests
-from requests.exceptions import ConnectionError, HTTPError, SSLError
-
-# Local imports
-from spyder import __version__
-from spyder.config.base import _, is_conda_based_app, is_stable_version
-from spyder.config.utils import is_anaconda
-from spyder.utils.conda import get_spyder_conda_channel
-from spyder.utils.programs import check_version, is_module_installed
-
-# Logger setup
-logger = logging.getLogger(__name__)
-
-CONNECT_ERROR_MSG = _(
- 'Unable to connect to the Spyder update service.'
- '
Make sure your connection is working properly.'
-)
-
-HTTP_ERROR_MSG = _(
- 'HTTP error {status_code} when checking for updates.'
- '
Make sure your connection is working properly,'
- 'and try again later.'
-)
-
-SSL_ERROR_MSG = _(
- 'SSL certificate verification failed while checking for Spyder updates.'
- '
Please contact your network administrator for assistance.'
-)
-
-
-class UpdateDownloadCancelledException(Exception):
- """Download for installer to update was cancelled."""
- pass
-
-
-class UpdateDownloadIncompleteError(Exception):
- """Error occured while downloading file"""
- pass
-
-
-class WorkerUpdates(QObject):
- """
- Worker that checks for releases using either the Anaconda
- default channels or the Github Releases page without
- blocking the Spyder user interface, in case of connection
- issues.
- """
- sig_ready = Signal()
-
- def __init__(self, parent, startup, version="", releases=None):
- QObject.__init__(self)
- self._parent = parent
- self.error = None
- self.latest_release = None
- self.startup = startup
- self.releases = releases
-
- if not version:
- self.version = __version__
- else:
- self.version = version
-
- def check_update_available(self):
- """
- Check if there is an update available.
-
- It takes as parameters the current version of Spyder and a list of
- valid cleaned releases in chronological order.
- Example: ['2.3.2', '2.3.3' ...] or with github ['2.3.4', '2.3.3' ...]
- """
- # Don't perform any check for development versions or we were unable to
- # detect releases.
- if 'dev' in self.version or not self.releases:
- return (False, self.latest_release)
-
- # Filter releases
- if is_stable_version(self.version):
- releases = [r for r in self.releases if is_stable_version(r)]
- else:
- releases = [r for r in self.releases
- if not is_stable_version(r) or r in self.version]
-
- latest_release = releases[-1]
-
- return (check_version(self.version, latest_release, '<'),
- latest_release)
-
- def start(self):
- """Main method of the worker."""
- self.update_available = False
- self.latest_release = __version__
-
- error_msg = None
- pypi_url = "https://pypi.org/pypi/spyder/json"
-
- if is_conda_based_app():
- self.url = ('https://api.github.com/repos/'
- 'spyder-ide/spyder/releases')
- elif is_anaconda():
- channel, channel_url = get_spyder_conda_channel()
-
- if channel_url is None:
- return
- elif channel == "pypi":
- self.url = pypi_url
- else:
- self.url = channel_url + '/channeldata.json'
- else:
- self.url = pypi_url
-
- try:
- logger.debug(f"Checking for updates from {self.url}")
- page = requests.get(self.url)
- page.raise_for_status()
- data = page.json()
-
- if is_conda_based_app():
- if self.releases is None:
- self.releases = [
- item['tag_name'].replace('v', '') for item in data
- ]
- self.releases = list(reversed(self.releases))
- elif is_anaconda() and self.url != pypi_url:
- if self.releases is None:
- spyder_data = data['packages'].get('spyder')
- if spyder_data:
- self.releases = [spyder_data["version"]]
- else:
- if self.releases is None:
- self.releases = [data['info']['version']]
-
- result = self.check_update_available()
- self.update_available, self.latest_release = result
- except SSLError as err:
- error_msg = SSL_ERROR_MSG
- logger.debug(err, stack_info=True)
- except ConnectionError as err:
- error_msg = CONNECT_ERROR_MSG
- logger.debug(err, stack_info=True)
- except HTTPError as err:
- error_msg = HTTP_ERROR_MSG.format(status_code=page.status_code)
- logger.debug(err, stack_info=True)
- except Exception as err:
- error = traceback.format_exc()
- formatted_error = error.replace('\n', '
').replace(' ', ' ')
-
- error_msg = _(
- 'It was not possible to check for Spyder updates due to the '
- 'following error:'
- '
'
- '{}'
- ).format(formatted_error)
- logger.debug(err, stack_info=True)
-
- # Don't show dialog when starting up spyder and an error occur
- if not (self.startup and error_msg is not None):
- self.error = error_msg
- try:
- self.sig_ready.emit()
- except RuntimeError:
- pass
-
-
-class WorkerDownloadInstaller(QObject):
- """
- Worker that donwloads standalone installers for Windows
- and MacOS without blocking the Spyder user interface.
- """
-
- sig_ready = Signal(str)
- """
- Signal to inform that the worker has finished successfully.
-
- Parameters
- ----------
- installer_path: str
- Path where the downloaded installer is located.
- """
-
- sig_download_progress = Signal(int, int)
- """
- Signal to get the download progress.
-
- Parameters
- ----------
- current_value: int
- Size of the data downloaded until now.
- total: int
- Total size of the file expected to be downloaded.
- """
-
- def __init__(self, parent, latest_release_version):
- QObject.__init__(self)
- self._parent = parent
- self.latest_release_version = latest_release_version
- self.error = None
- self.cancelled = False
- self.installer_path = None
-
- def _progress_reporter(self, block_number, read_size, total_size):
- """Calculate download progress and notify."""
- progress = 0
- if total_size > 0:
- progress = block_number * read_size
- if self.cancelled:
- raise UpdateDownloadCancelledException()
- self.sig_download_progress.emit(progress, total_size)
-
- def _download_installer(self):
- """Donwload latest Spyder standalone installer executable."""
- tmpdir = tempfile.gettempdir()
- is_full_installer = (is_module_installed('numpy') or
- is_module_installed('pandas'))
- if os.name == 'nt':
- name = 'Spyder_64bit_{}.exe'.format('full' if is_full_installer
- else 'lite')
- else:
- name = 'Spyder{}.dmg'.format('' if is_full_installer else '-Lite')
-
- url = ('https://github.com/spyder-ide/spyder/releases/latest/'
- f'download/{name}')
- dir_path = osp.join(tmpdir, 'spyder', 'updates')
- os.makedirs(dir_path, exist_ok=True)
- installer_dir_path = osp.join(
- dir_path, self.latest_release_version)
- os.makedirs(installer_dir_path, exist_ok=True)
- for file in os.listdir(dir_path):
- if file not in [__version__, self.latest_release_version]:
- remove = osp.join(dir_path, file)
- os.remove(remove)
-
- installer_path = osp.join(installer_dir_path, name)
- self.installer_path = installer_path
-
- if osp.isfile(installer_path):
- # Installer already downloaded
- logger.info(f"{installer_path} already downloaded")
- self._progress_reporter(1, 1, 1)
- return
-
- logger.debug(f"Downloading installer from {url} to {installer_path}")
- with requests.get(url, stream=True) as r:
- with open(installer_path, 'wb') as f:
- chunk_size = 8 * 1024
- size = -1
- size_read = 0
- chunk_num = 0
-
- if "content-length" in r.headers:
- size = int(r.headers["content-length"])
-
- self._progress_reporter(chunk_num, chunk_size, size)
-
- for chunk in r.iter_content(chunk_size=chunk_size):
- size_read += len(chunk)
- f.write(chunk)
- chunk_num += 1
- self._progress_reporter(chunk_num, chunk_size, size)
-
- if size >= 0 and size_read < size:
- raise UpdateDownloadIncompleteError(
- "Download incomplete: retrieved only "
- f"{size_read} out of {size} bytes."
- )
-
- def start(self):
- """Main method of the WorkerDownloadInstaller worker."""
- error_msg = None
- try:
- self._download_installer()
- except UpdateDownloadCancelledException:
- if self.installer_path:
- os.remove(self.installer_path)
- return
- except SSLError as err:
- error_msg = SSL_ERROR_MSG
- logger.debug(err, stack_info=True)
- except ConnectionError as err:
- error_msg = CONNECT_ERROR_MSG
- logger.debug(err, stack_info=True)
- except Exception as err:
- error = traceback.format_exc()
- formatted_error = error.replace('\n', '
').replace(' ', ' ')
-
- error_msg = _(
- 'It was not possible to download the installer due to the '
- 'following error:'
- '
'
- '{}'
- ).format(formatted_error)
- logger.debug(err, stack_info=True)
- self.error = error_msg
- try:
- self.sig_ready.emit(self.installer_path)
- except RuntimeError:
- pass