Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions rascal2/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
198 changes: 198 additions & 0 deletions rascal2/dialogs/check_update_dialog.py
Original file line number Diff line number Diff line change
@@ -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 <a href="{RELEASES_URL}">{RELEASES_URL}</a>.<br/><br/>'
)
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()
19 changes: 13 additions & 6 deletions rascal2/ui/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"")))

Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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."""
Expand Down
5 changes: 3 additions & 2 deletions rascal2/widgets/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from PyQt6 import QtGui, QtWidgets

from rascal2 import RASCAL2_VERSION
from rascal2.config import LOGGER


class CustomStreamHandler(logging.StreamHandler):
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
numpy==2.2.6
packaging==25.0
PyInstaller==6.9.0
PyQt6==6.7.1
PyQt6-Qt6==6.7.3
Expand Down
6 changes: 5 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
81 changes: 81 additions & 0 deletions tests/dialogs/test_check_update_dialog.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion tests/ui/test_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading