From f31f1c945fba1281fc89a10fab95d20774f90f6a Mon Sep 17 00:00:00 2001 From: Stephen Nneji Date: Tue, 26 May 2026 13:40:18 +0100 Subject: [PATCH] Add check update dialog --- rascal2/app.py | 1 + rascal2/dialogs/check_update_dialog.py | 198 ++++++++++++++++++++++ rascal2/ui/view.py | 19 ++- rascal2/widgets/terminal.py | 5 +- requirements.txt | 2 + tests/conftest.py | 6 +- tests/dialogs/test_check_update_dialog.py | 81 +++++++++ tests/ui/test_view.py | 2 +- tests/utils.py | 37 ++++ 9 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 rascal2/dialogs/check_update_dialog.py create mode 100644 tests/dialogs/test_check_update_dialog.py diff --git a/rascal2/app.py b/rascal2/app.py index 7fddba1e..283192d6 100644 --- a/rascal2/app.py +++ b/rascal2/app.py @@ -34,6 +34,7 @@ def ui_execute(splash): app.setStyleSheet(style) window = MainWindowView() + window.check_update_dialog.check(silent=True) window.show() splash.finish(window) diff --git a/rascal2/dialogs/check_update_dialog.py b/rascal2/dialogs/check_update_dialog.py new file mode 100644 index 00000000..6bd4a94a --- /dev/null +++ b/rascal2/dialogs/check_update_dialog.py @@ -0,0 +1,198 @@ +import json +import logging +import urllib.request +from urllib.error import HTTPError, URLError + +from packaging.version import Version +from PyQt6 import QtCore, QtWidgets + +from rascal2 import RASCAL2_VERSION +from rascal2.core.worker import Worker +from rascal2.settings import get_global_settings + +RELEASES_URL = "https://github.com/RascalSoftware/RasCAL-2/releases" +UPDATE_URL = "https://api.github.com/repos/RascalSoftware/RasCAL-2/releases/latest" + + +def get_check_on_start_setting(): + """Get the `check_update_on_start` setting. + + When loaded from file, QSetting will not cast back to boolean so this function + ensures the returned setting is a boolean. + + Returns + ------- + check_on_start: bool + Indicates if updates should be checked for on startup + + """ + check_on_start = get_global_settings().value("check_update_on_start", True) + if isinstance(check_on_start, str): + check_on_start = check_on_start.lower() == "true" + return check_on_start + + +class CheckUpdateDialog(QtWidgets.QDialog): + """Dialog for checking software updates. + + Parameters + ---------- + parent : QtWidgets.QWidget + The parent of this widget. + """ + + def __init__(self, parent): + super().__init__(parent) + + self.startup = False + self.parent = parent + self.setFixedSize(400, 200) + self.setWindowTitle("Check for Update") + + main_layout = QtWidgets.QVBoxLayout() + self.setLayout(main_layout) + + self.stack = QtWidgets.QStackedLayout() + main_layout.addLayout(self.stack) + self.stack.addWidget(QtWidgets.QWidget()) + self.stack.addWidget(QtWidgets.QWidget()) + + progress_bar = QtWidgets.QProgressBar() + progress_bar.setTextVisible(False) + progress_bar.setMinimum(0) + progress_bar.setMaximum(0) + + message = QtWidgets.QLabel("Checking the Internet for Updates") + message.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + sub_layout = QtWidgets.QVBoxLayout() + sub_layout.addStretch(1) + sub_layout.addWidget(progress_bar) + sub_layout.addWidget(message) + sub_layout.addStretch(1) + widget = self.stack.widget(0) + widget.setLayout(sub_layout) + + self.result = QtWidgets.QLabel("") + self.result.setWordWrap(True) + self.result.setOpenExternalLinks(True) + self.result.setTextFormat(QtCore.Qt.TextFormat.RichText) + self.result.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + checkbox = QtWidgets.QCheckBox("Check for updates on startup") + checkbox.setChecked(get_check_on_start_setting()) + checkbox.stateChanged.connect( + lambda state: get_global_settings().setValue( + "check_update_on_start", state == QtCore.Qt.CheckState.Checked.value + ) + ) + + sub_layout = QtWidgets.QVBoxLayout() + sub_layout.addStretch(1) + sub_layout.addWidget(self.result) + sub_layout.addSpacing(10) + sub_layout.addWidget(checkbox) + sub_layout.addStretch(1) + widget = self.stack.widget(1) + widget.setLayout(sub_layout) + + button_layout = QtWidgets.QHBoxLayout() + button_layout.addStretch(1) + close_button = QtWidgets.QPushButton("Close") + close_button.clicked.connect(self.close) + button_layout.addWidget(close_button) + main_layout.addLayout(button_layout) + + self.worker = Worker(self.check_helper, []) + self.worker.job_succeeded.connect(self.on_success) + self.worker.job_failed.connect(self.on_failure) + + def check(self, startup=False): + """Asynchronous check for new release using the GitHub release API. + + The user is notified when update is found, not found or an error occurred. If startup is true, the user will + only be notified if update is found. + + Parameters + ---------- + startup : bool + indicates the check is happening at startup. + """ + if startup and not get_check_on_start_setting(): + return + + self.startup = startup + if not startup: + self.stack.setCurrentIndex(0) + self.show() + self.worker.start() + + def check_helper(self): + """Check for the latest release version on the GitHub repository. + + Returns + ------- + startup : str + The latest version tag. + """ + with urllib.request.urlopen(UPDATE_URL) as response: + tag_name = json.loads(response.read()).get("tag_name") + + return tag_name + + def on_success(self, latest_version): + """Report the version found after successful check. + + Parameters + ---------- + latest_version : str + version tag. + """ + if latest_version and Version(latest_version) > Version(RASCAL2_VERSION): + self.update_message( + f"A new version ({latest_version}) of RasCAL-2 is available. Download " + f'the installer from {RELEASES_URL}.

' + ) + self.show() # Always tell user of new version even if startup + + else: + if not self.startup: + self.update_message("You are running the latest version of RasCAL-2.\n") + + def on_failure(self, exception): + """Log and report error after failed check. + + Parameters + ---------- + exception: : Exception + An exception which occurred when checking for update. + """ + logging.error("An error occurred while checking for updates", exc_info=exception) + if self.startup: + return + + if isinstance(exception, HTTPError): + self.update_message("You are running the latest version of RasCAL-2.\n") + elif isinstance(exception, URLError): + self.update_message( + "An error occurred when attempting to connect to the update server. " + "Check your internet connection and/or firewall and try again.\n" + ) + else: + self.update_message(f"An unexpected error occurred when checking for updates: {exception}\n") + + def update_message(self, message): + """Update UI and show the given message. + + Parameters + ---------- + message: : str + A message to display. + """ + self.stack.setCurrentIndex(1) + self.result.setText(message) + + def closeEvent(self, event): + if self.worker.isRunning(): + self.worker.terminate() + event.accept() diff --git a/rascal2/ui/view.py b/rascal2/ui/view.py index 54ec00a3..d1ebcc77 100644 --- a/rascal2/ui/view.py +++ b/rascal2/ui/view.py @@ -5,6 +5,7 @@ from rascal2.config import SETTINGS from rascal2.core.enums import UnsavedReply from rascal2.dialogs.about_dialog import AboutDialog +from rascal2.dialogs.check_update_dialog import CheckUpdateDialog from rascal2.dialogs.settings_dialog import SettingsDialog from rascal2.dialogs.startup_dialog import PROJECT_FILES, LoadDialog, LoadR1Dialog, NewProjectDialog, StartupDialog from rascal2.paths import EXAMPLES_PATH, EXAMPLES_TEMP_PATH, path_for @@ -54,6 +55,7 @@ def __init__(self): self.startup_dlg = StartUpWidget(self) self.setCentralWidget(self.startup_dlg) + self.check_update_dialog = CheckUpdateDialog(self) self.about_dialog = AboutDialog(self) self.restoreGeometry(get_global_settings().value("window_geometry", bytearray(b""))) @@ -176,11 +178,14 @@ def create_actions(self): self.toggle_slider_action.setEnabled(False) self.disabled_elements.append(self.toggle_slider_action) - open_about_action = QtGui.QAction("&About", self) - open_about_action.setStatusTip(f"About {MAIN_WINDOW_TITLE}") - open_about_action.triggered.connect(self.open_about_info) - open_about_action.setMenuRole(QtGui.QAction.MenuRole.AboutQtRole) - self.open_about_action = open_about_action + self.check_update_action = QtGui.QAction("&Check for Updates", self) + self.check_update_action.setStatusTip("Check the internet for software updates") + self.check_update_action.triggered.connect(lambda: self.check_update_dialog.check()) + + self.open_about_action = QtGui.QAction("&About", self) + self.open_about_action.setStatusTip(f"About {MAIN_WINDOW_TITLE}") + self.open_about_action.triggered.connect(self.open_about_info) + self.open_about_action.setMenuRole(QtGui.QAction.MenuRole.AboutQtRole) self.exit_action = QtGui.QAction("E&xit", self) self.exit_action.setStatusTip(f"Quit {MAIN_WINDOW_TITLE}") @@ -252,8 +257,10 @@ def create_menus(self): tools_menu.addAction(self.clear_terminal_action) help_menu = main_menu.addMenu("&Help") - help_menu.addAction(self.open_about_action) help_menu.addAction(self.open_help_action) + help_menu.addSeparator() + help_menu.addAction(self.check_update_action) + help_menu.addAction(self.open_about_action) def toggle_sliders(self): """Toggle sliders for the fitted parameters in project class view.""" diff --git a/rascal2/widgets/terminal.py b/rascal2/widgets/terminal.py index 71185824..be33eb36 100644 --- a/rascal2/widgets/terminal.py +++ b/rascal2/widgets/terminal.py @@ -5,6 +5,7 @@ from PyQt6 import QtGui, QtWidgets from rascal2 import RASCAL2_VERSION +from rascal2.config import LOGGER class CustomStreamHandler(logging.StreamHandler): @@ -56,11 +57,11 @@ def __init__(self): def add_stream_handler(self): """Add terminal as a stream for the log handler.""" - logger = logging.getLogger() + # logger = logging.getLogger() log_term_handler = CustomStreamHandler(stream=self) term_formatting = logging.Formatter("%(levelname)s - %(message)s") log_term_handler.setFormatter(term_formatting) - logger.addHandler(log_term_handler) + LOGGER.addHandler(log_term_handler) def write(self, text: str): """Append plain text to the terminal. diff --git a/requirements.txt b/requirements.txt index 652a092c..e3e74db2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +numpy==2.2.6 +packaging==25.0 PyInstaller==6.9.0 PyQt6==6.7.1 PyQt6-Qt6==6.7.3 diff --git a/tests/conftest.py b/tests/conftest.py index ff31b498..7ab45044 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,7 +26,11 @@ def mock_setting(request): ini_file = Path(tmp_dir.name) / "settings.ini" GLOBAL_SETTING = QtCore.QSettings(str(ini_file), QtCore.QSettings.Format.IniFormat) setting_patch = [] - for target in ["rascal2.ui.view.get_global_settings", "rascal2.settings.get_global_settings"]: + for target in [ + "rascal2.ui.view.get_global_settings", + "rascal2.settings.get_global_settings", + "rascal2.dialogs.check_update_dialog.get_global_settings", + ]: setting_patch.append(patch(target, return_value=GLOBAL_SETTING)) setting_patch[-1].start() diff --git a/tests/dialogs/test_check_update_dialog.py b/tests/dialogs/test_check_update_dialog.py new file mode 100644 index 00000000..f47d1a9c --- /dev/null +++ b/tests/dialogs/test_check_update_dialog.py @@ -0,0 +1,81 @@ +from unittest.mock import Mock, patch +from urllib.error import HTTPError, URLError + +import pytest +from PyQt6 import QtWidgets + +from rascal2.dialogs.check_update_dialog import CheckUpdateDialog +from tests.utils import TestWorker + + +@pytest.fixture +def update_dialog(): + with ( + patch("rascal2.dialogs.check_update_dialog.logging", autospec=True) as log_mock, + patch("rascal2.dialogs.check_update_dialog.Worker", TestWorker), + ): + dialog = CheckUpdateDialog(QtWidgets.QMainWindow()) + dialog.show = Mock() + dialog.logger = log_mock + + yield dialog + + +@patch("rascal2.dialogs.check_update_dialog.urllib.request.urlopen", autospec=True) +def test_check_for_update(urlopen_mock, update_dialog, global_setting): + global_setting.setValue("check_update_on_start", False) + update_dialog.check(True) + urlopen_mock.assert_not_called() + + test_version = "2.0.0" + with patch("rascal2.dialogs.check_update_dialog.RASCAL2_VERSION", test_version): + global_setting.setValue("check_update_on_start", True) + urlopen_mock.return_value.__enter__.return_value.read.return_value = '{"tag_name":""}' + update_dialog.check(True) + update_dialog.show.assert_not_called() # No tag so no update + urlopen_mock.return_value.__enter__.return_value.read.return_value = '{"tag_name":"2.0.0"}' + update_dialog.check(True) + update_dialog.show.assert_not_called() # same tag so no update + urlopen_mock.return_value.__enter__.return_value.read.return_value = '{"tag_name":"3.0.0"}' + update_dialog.check(True) + update_dialog.show.assert_called_once() # same tag so no update + + urlopen_mock.return_value.__enter__.return_value.read.return_value = '{"tag_name":"0.0.0a"}' + update_dialog.worker.isRunning = Mock(return_value=True) + update_dialog.worker.terminate = Mock() + update_dialog.check() + assert update_dialog.result.text() == "You are running the latest version of RasCAL-2.\n" + assert update_dialog.show.call_count == 2 + update_dialog.close() + update_dialog.worker.terminate.assert_called() + + +@patch("rascal2.dialogs.check_update_dialog.urllib.request.urlopen", autospec=True) +def test_check_update_exception(urlopen_mock, update_dialog, global_setting): + global_setting.setValue("check_update_on_start", "True") + urlopen_mock.return_value.__enter__.return_value.read.return_value = '{"tag_name":"2.0.0"}' + update_dialog.worker.side_effect = HTTPError("", 400, "", {}, None) + update_dialog.check(True) + assert update_dialog.result.text() == "" + assert update_dialog.logger.error.call_count == 1 + assert update_dialog.show.call_count == 0 + + update_dialog.check() + assert update_dialog.result.text() == "You are running the latest version of RasCAL-2.\n" + assert update_dialog.logger.error.call_count == 2 + assert update_dialog.show.call_count == 1 + + update_dialog.worker.side_effect = URLError("") + update_dialog.check() + assert update_dialog.result.text() == ( + "An error occurred when attempting to connect to the update server. " + "Check your internet connection and/or firewall and try again.\n" + ) + assert update_dialog.logger.error.call_count == 3 + assert update_dialog.show.call_count == 2 + + update_dialog.worker.side_effect = ValueError("blah") + update_dialog.check() + assert update_dialog.result.text() == "An unexpected error occurred when checking for updates: blah\n" + assert update_dialog.logger.error.call_count == 4 + assert update_dialog.show.call_count == 3 diff --git a/tests/ui/test_view.py b/tests/ui/test_view.py index e9754f8a..e8493658 100644 --- a/tests/ui/test_view.py +++ b/tests/ui/test_view.py @@ -189,7 +189,7 @@ def test_menu_element_present(test_view, submenu_name): ("&Edit", ["&Undo", "&Redo", "Undo &History"]), ("&Windows", ["Tile Windows", "Reset to Default", "Save Current Window Positions"]), ("&Tools", ["Show &Sliders", "", "Clear Terminal"]), - ("&Help", ["&About", "&Help"]), + ("&Help", ["&Help", "", "&Check for Updates", "&About"]), ], ) def test_help_menu_actions_present(test_view, submenu_name, action_names_and_layout): diff --git a/tests/utils.py b/tests/utils.py index 0a5fd8a9..0124b231 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import numpy as np import ratapi.outputs @@ -90,3 +92,38 @@ def check_bayes_fields_equal(actual_results, expected_results) -> None: ) assert (actual_results.chain == expected_results.chain).all() + + +class TestSignal: + def __init__(self): + self.call = Mock() + + def connect(self, call): + self.call = call + + def emit(self, *args): + self.call(*args) + + +class TestWorker: + side_effect = None + + def __init__(self, call, args, side_effect=None): + self.finished = TestSignal() + self.job_failed = TestSignal() + self.job_succeeded = TestSignal() + self.call = call + self.args = args + self.side_effect = side_effect + self.add_failed_args = False + + def start(self): + result = self.call(*self.args) + if self.side_effect is not None: + if not self.add_failed_args: + self.job_failed.emit(self.side_effect) + else: + self.job_failed.emit(self.side_effect, self.args) + else: + self.job_succeeded.emit(result) + self.finished.emit()