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()