diff --git a/sardes/api/plugins.py b/sardes/api/plugins.py index df1625c1..72eee304 100644 --- a/sardes/api/plugins.py +++ b/sardes/api/plugins.py @@ -529,10 +529,11 @@ def show_plugin(self): This method is called by the mainwindow after it is shown. """ - is_visible = self.get_option('is_visible', False) - is_docked = self.get_option('is_docked', True) - if is_visible and not is_docked: - self.dockwindow.undock() + if self.dockwindow is not None: + is_visible = self.get_option('is_visible', False) + is_docked = self.get_option('is_docked', True) + if is_visible and not is_docked: + self.dockwindow.undock() def close_plugin(self): """ diff --git a/sardes/app/mainwindow.py b/sardes/app/mainwindow.py index ea6eafef..6bcaea2d 100644 --- a/sardes/app/mainwindow.py +++ b/sardes/app/mainwindow.py @@ -99,6 +99,10 @@ def __init__(self, splash=None, sys_capture_manager=None): self.db_connection_manager) print("Table models manager set up succesfully.") + # Setup the update manager. + from sardes.app.updates import UpdatesManager + self.updates_manager = UpdatesManager(parent=self) + self.setup() def set_splash(self, message): @@ -262,6 +266,15 @@ class Separator(object): triggered=self.confdialog.show ) + # Create show console action. + self.console_action = None + if self.console is not None: + self.console_action = create_action( + self, _('Sardes Console...'), icon='console', + shortcut='Ctrl+Shift+J', context=Qt.ApplicationShortcut, + triggered=self.console.show + ) + # Create the panes and toolbars menus and actions self.panes_menu = QMenu(_("Panes"), self) self.panes_menu.setIcon(get_icon('panes')) @@ -282,13 +295,6 @@ class Separator(object): triggered=self.reset_window_layout) # Create help related actions and menus. - self.console_action = None - if self.console is not None: - self.console_action = create_action( - self, _('Sardes Console...'), icon='console', - shortcut='Ctrl+Shift+J', context=Qt.ApplicationShortcut, - triggered=self.console.show - ) report_action = create_action( self, _('Report an issue...'), icon='bug', shortcut='Ctrl+Shift+R', context=Qt.ApplicationShortcut, @@ -299,6 +305,10 @@ class Separator(object): shortcut='Ctrl+Shift+I', context=Qt.ApplicationShortcut ) + update_action = create_action( + self, _('Check for updates...'), icon='update', + triggered=lambda: self.updates_manager.start_updates_check() + ) exit_action = create_action( self, _('Exit'), icon='exit', triggered=self.close, shortcut='Ctrl+Shift+Q', context=Qt.ApplicationShortcut @@ -306,11 +316,12 @@ class Separator(object): # Add the actions and menus to the options menu. options_menu_items = [ - self.lang_menu, preferences_action, Separator(), - self.panes_menu, self.toolbars_menu, + self.lang_menu, preferences_action, self.console_action, + Separator(), self.panes_menu, self.toolbars_menu, self.lock_dockwidgets_and_toolbars_action, self.reset_window_layout_action, Separator(), - self.console_action, report_action, about_action, exit_action + report_action, update_action, about_action, + Separator(), exit_action ] for item in options_menu_items: if isinstance(item, Separator): @@ -602,7 +613,8 @@ def setup_internal_plugins(self): def show(self): """ - Extend Qt method to call show_plugin on each installed plugin. + Extend base class method to perform specific actions of certain + plugins after the mainwindow has been shown. """ super().show() @@ -613,6 +625,8 @@ def show(self): if self.databases_plugin.get_option('auto_connect_to_database'): self.databases_plugin.connect_to_database() + self.updates_manager.start_updates_check(startup_check=True) + class ExceptHook(QObject): """ diff --git a/sardes/app/tests/test_updates.py b/sardes/app/tests/test_updates.py new file mode 100644 index 00000000..8c05f0de --- /dev/null +++ b/sardes/app/tests/test_updates.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © GWHAT Project Contributors +# https://github.com/jnsebgosselin/gwhat +# +# This file is part of GWHAT (Ground-Water Hydrograph Analysis Toolbox). +# Licensed under the terms of the GNU General Public License. +# ----------------------------------------------------------------------------- + +# ---- Standard imports +import os +os.environ['SARDES_PYTEST'] = 'True' + +# ---- Third parties imports +import pytest +from qtpy.QtCore import QTimer + +# ---- Local imports +from sardes.app.updates import UpdatesManager +from sardes.config.main import CONF + + +# ============================================================================= +# ---- Fixtures +# ============================================================================= +@pytest.fixture +def updates_manager(qtbot): + CONF.reset_to_defaults() + updates_manager = UpdatesManager() + assert updates_manager.dialog.isVisible() is False + yield updates_manager + + # To avoid: Thread: Destroyed while thread is still running. + qtbot.wait(300) + + +# ============================================================================= +# ---- Tests +# ============================================================================= +def test_update_available(updates_manager, qtbot, mocker): + """ + Assert that the worker to check for updates on the GitHub API is + working as expected when an update is available. + + Note that since we are forcing the current version to a not stable version, + the '1.1.0rc2 update should be proposed to the user. + """ + mocker.patch('sardes.app.updates.__version__', '1.1.0rc1') + mocker.patch( + 'sardes.app.updates.fetch_available_releases', + return_value=(['0.9.0', '1.1.0rc2', '1.0.0'], None) + ) + + def handle_dialog(on_startup: bool): + assert updates_manager.dialog.isVisible() is True + assert updates_manager.dialog.chkbox.isVisible() == on_startup + assert 'Sardes 1.1.0rc2 is available!' in updates_manager.dialog.text() + updates_manager.dialog.close() + + with qtbot.waitSignal(updates_manager.worker.sig_releases_fetched): + QTimer.singleShot(300, lambda: handle_dialog(on_startup=False)) + updates_manager.start_updates_check() + + # Test that this is working also as expected during STARTUP and mute + # the message on next startups. + with qtbot.waitSignal(updates_manager.worker.sig_releases_fetched): + updates_manager.dialog.chkbox.setChecked(True) + QTimer.singleShot(300, lambda: handle_dialog(on_startup=True)) + updates_manager.start_updates_check(startup_check=True) + + # Assert the update is muted as expected. + with qtbot.waitSignal(updates_manager.worker.sig_releases_fetched): + updates_manager.start_updates_check(startup_check=True) + assert updates_manager.dialog.isVisible() is False + + +def test_no_update_available(updates_manager, qtbot, mocker): + """ + Assert that the worker to check for updates on the GitHub API is + working as expected when no update is available. + + Note that since we are forcing the current version to a stable version, + the '1.1.0rc2 update should be proposed to the user. + """ + mocker.patch('sardes.app.updates.__version__', '1.1.0') + mocker.patch( + 'sardes.app.updates.fetch_available_releases', + return_value=(['0.9.0', '1.1.0rc2', '1.0.0'], None) + ) + + def handle_dialog(on_startup: bool): + assert updates_manager.dialog.isVisible() is True + assert updates_manager.dialog.chkbox.isVisible() == on_startup + assert 'is up to date' in updates_manager.dialog.text() + updates_manager.dialog.close() + + with qtbot.waitSignal(updates_manager.worker.sig_releases_fetched): + QTimer.singleShot(300, lambda: handle_dialog(on_startup=False)) + updates_manager.start_updates_check() + + # Test that the 'up-to-date' message is not shown during STARTUP. + with qtbot.waitSignal(updates_manager.worker.sig_releases_fetched): + updates_manager.start_updates_check(startup_check=True) + assert updates_manager.dialog.isVisible() is False + + +def test_update_error(updates_manager, qtbot, mocker): + """ + Assert that the worker to check for updates on the GitHub API is + working as expected when there is an error. + """ + mocker.patch( + 'sardes.app.updates.fetch_available_releases', + return_value=(['0.9.0', '1.1.0rc2', '1.0.0'], 'some error') + ) + + def handle_dialog(on_startup: bool): + assert updates_manager.dialog.isVisible() is True + assert updates_manager.dialog.chkbox.isVisible() == on_startup + assert 'some error' in updates_manager.dialog.text() + updates_manager.dialog.close() + + with qtbot.waitSignal(updates_manager.worker.sig_releases_fetched): + QTimer.singleShot(300, lambda: handle_dialog(on_startup=False)) + updates_manager.start_updates_check() + + # Test that the error message is not shown during STARTUP. + with qtbot.waitSignal(updates_manager.worker.sig_releases_fetched): + updates_manager.start_updates_check(startup_check=True) + assert updates_manager.dialog.isVisible() is False + + +if __name__ == "__main__": + pytest.main(['-x', __file__, '-v', '-rw']) diff --git a/sardes/app/updates.py b/sardes/app/updates.py new file mode 100644 index 00000000..ee8c09e1 --- /dev/null +++ b/sardes/app/updates.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © SARDES Project Contributors +# https://github.com/geo-stack/sardes +# +# This file is part of SARDES. +# Licensed under the terms of the GNU General Public License. +# +# Copyright (c) 2017 Spyder Project Contributors +# https://github.com/spyder-ide/spyder +# +# Some parts of this file is a derivative work of the Spyder project. +# Licensed under the terms of the MIT License. +# +# Copyright (C) 2013 The IPython Development Team +# https://github.com/ipython/ipython +# +# Some parts of this file is a derivative work of the IPython project. +# Licensed under the terms of the BSD License. +# ----------------------------------------------------------------------------- +from __future__ import annotations + +# ---- Standard imports +import re +from packaging.version import Version + +# ---- Third party imports +from qtpy.QtCore import QObject, Qt, QThread, Signal +from qtpy.QtWidgets import QApplication, QMessageBox, QCheckBox +import requests + +# ---- Local imports +from sardes import ( + __version__, __releases_url__, __releases_api__, + __namever__, __project_url__) +from sardes.config.icons import ( + get_icon, get_standard_iconsize, get_standard_icon) +from sardes.config.locale import _ +from sardes.config.main import CONF + + +class UpdatesManager(QObject): + """ + Self contained manager that checks if updates are available on GitHub + and displays the ressults in a message box. + """ + + def __init__(self, parent=None): + super().__init__() + + self._startup_check = False + self.dialog = UpdatesDialog(parent) + + self.thread = QThread() + + self.worker = WorkerUpdates() + self.worker.moveToThread(self.thread) + self.worker.sig_releases_fetched.connect( + self._receive_updates_check) + + self.thread.started.connect(self.worker.start) + + def start_updates_check(self, startup_check: bool = False): + """Check if updates are available.""" + self._startup_check = startup_check + self.thread.start() + + def _receive_updates_check(self, releases: list[str], error: str): + """Receive results from an update check.""" + self.thread.quit() + + update_available, latest_release = check_update_available( + __version__, releases) + muted_updates = CONF.get('main', 'muted_updates', []) + + if self._startup_check: + if update_available is False or error is not None: + return + for release in muted_updates: + if check_version(latest_release, release, '=='): + return + + if error is not None: + msg = error + icon = get_standard_icon('SP_MessageBoxWarning') + else: + if update_available: + icon = get_icon('update_blue') + msg = _( + "
Sardes {} is available!
" + "This new version can be downloaded from our " + "Releases page.
" + ).format(latest_release, __releases_url__) + else: + icon = get_icon('commit_changes') + url_m = __project_url__ + "/milestones" + url_t = __project_url__ + "/issues" + msg = _( + "{} is up to date
" + "Further information about Sardes releases are " + "available on our Releases page.
" + "The roadmap of the Sardes project can be consulted " + "on our Milestones page.
" + "Please help Sardes by reporting bugs or proposing " + "new features on our Issues Tracker.
" + ).format(__namever__, __releases_url__, url_m, url_t) + + if self._startup_check: + # Add some space between text and checkbox. + msg += "