diff --git a/CHANGELOG b/CHANGELOG index 3a32d2b..d3cd41a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,5 @@ -0.15.6 +0.16.0 + - feat: check for updates when starting the DCOR-Aid GUI - fix: when checking resource existence for a dataset, do not call "package_show" for every single resource - fix: use `verifiable_files` instead of `verified_files` in error message diff --git a/dcoraid/gui/main.py b/dcoraid/gui/main.py index 5c8b012..49a5f4c 100644 --- a/dcoraid/gui/main.py +++ b/dcoraid/gui/main.py @@ -17,11 +17,12 @@ from ..api import APIOutdatedError from ..common import ConnectionTimeoutErrors from ..dbmodel import APIInterrogator, DBExtract -from .._version import version as __version__ +from .._version import __version__ from .api import get_ckan_api from .preferences import PreferencesDialog from .status_widget import StatusWidget +from . import updater from .wizard import SetupWizard file_manager = ExitStack() @@ -44,6 +45,9 @@ def __init__(self, *args, **kwargs): application will print the version after initialization and exit. """ + self._update_thread = None + self._update_worker = None + # Settings are stored in the .ini file format. Even though # `self.settings` may return integer/bool in the same session, # in the next session, it will reliably return strings. Lists @@ -115,6 +119,10 @@ def __init__(self, *args, **kwargs): # User has not done anything yet self.on_wizard() + # check for updates + do_update = int(self.settings.value("check for updates", 1)) + self.on_action_check_update(do_update) + self.show() self.raise_() @@ -148,6 +156,50 @@ def on_action_about(self): "DCOR-Aid {}".format(__version__), about_text) + @QtCore.pyqtSlot(bool) + def on_action_check_update(self, b): + self.settings.setValue("check for updates", int(b)) + if b and self._update_thread is None: + self._update_thread = QtCore.QThread() + self._update_worker = updater.UpdateWorker() + self._update_worker.moveToThread(self._update_thread) + self._update_worker.finished.connect(self._update_thread.quit) + self._update_worker.data_ready.connect( + self.on_action_check_update_finished) + self._update_thread.start() + + ghrepo = "DCOR-dev/DCOR-Aid" + + QtCore.QMetaObject.invokeMethod( + self._update_worker, + 'processUpdate', + QtCore.Qt.ConnectionType.QueuedConnection, + QtCore.Q_ARG(str, __version__), + QtCore.Q_ARG(str, ghrepo), + ) + + def on_action_check_update_finished(self, mdict): + # cleanup + self._update_thread.quit() + self._update_thread.wait() + self._update_worker = None + self._update_thread = None + # display message box + ver = mdict["version"] + web = mdict["releases url"] + dlb = mdict["binary url"] + msg = QtWidgets.QMessageBox() + msg.setWindowTitle(f"DCOR-Aid {ver} available!") + msg.setTextFormat(QtCore.Qt.TextFormat.RichText) + text = f"You can install DCOR-Aid {ver} " + if dlb is not None: + text += 'from a direct download. '.format(dlb) + else: + text += 'by running `pip install --upgrade dcoraid`. ' + text += 'Visit the official release page!'.format(web) + msg.setText(text) + msg.exec() + @QtCore.pyqtSlot() def on_action_software(self): libs = [dclab, diff --git a/dcoraid/gui/updater.py b/dcoraid/gui/updater.py new file mode 100644 index 0000000..e0fd39c --- /dev/null +++ b/dcoraid/gui/updater.py @@ -0,0 +1,91 @@ +import json +import os +import struct +import sys +import traceback +import urllib.request + +from dclab.external.packaging import parse as parse_version +from PyQt5 import QtCore + + +class UpdateWorker(QtCore.QObject): + finished = QtCore.pyqtSignal() + data_ready = QtCore.pyqtSignal(dict) + + @QtCore.pyqtSlot(str, str) + def processUpdate(self, version, ghrepo): + mdict = check_release(ghrepo, version) + if mdict["update available"]: + self.data_ready.emit(mdict) + self.finished.emit() + + +def check_for_update(version, ghrepo): + thread = QtCore.QThread() + obj = UpdateWorker() + obj.moveToThread(thread) + obj.finished.connect(thread.quit) + thread.start() + + QtCore.QMetaObject.invokeMethod(obj, 'processUpdate', + QtCore.Qt.ConnectionType.QueuedConnection, + QtCore.Q_ARG(str, version), + QtCore.Q_ARG(str, ghrepo), + ) + + +def check_release(ghrepo="user/repo", version=None, timeout=20): + """Check GitHub repository for latest release""" + url = "https://api.github.com/repos/{}/releases/latest".format(ghrepo) + if "GITHUB_TOKEN" in os.environ: + hdr = {'authorization': os.environ["GITHUB_TOKEN"]} + else: + hdr = {} + web = "https://github.com/{}/releases".format(ghrepo) + errors = None # error messages (str) + update = False # whether an update is available + binary = None # download link to binary file + new_version = None # string identifying new version + + try: + req = urllib.request.Request(url, headers=hdr) + data = urllib.request.urlopen(req, timeout=timeout).read() + except BaseException: + errors = traceback.format_exc() + else: + j = json.loads(data) + + newversion = j["tag_name"] + + if version is not None: + new = parse_version(newversion) + old = parse_version(version) + if new > old: + update = True + new_version = newversion + if hasattr(sys, "frozen"): + # determine which binary URL we need + if sys.platform == "win32": + nbit = 8 * struct.calcsize("P") + if nbit == 32: + dlid = "win_32bit_setup.exe" + else: + dlid = "win_64bit_setup.exe" + elif sys.platform == "darwin": + dlid = ".pkg" + else: + dlid = False + # search for binary download file + if dlid: + for a in j["assets"]: + if a["browser_download_url"].count(dlid): + binary = a["browser_download_url"] + break + mdict = {"releases url": web, + "binary url": binary, + "version": new_version, + "update available": update, + "errors": errors, + } + return mdict diff --git a/tests/conftest.py b/tests/conftest.py index 33cfcfa..ea0fe4c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,7 +50,8 @@ def pytest_configure(config): QtCore.QSettings.setDefaultFormat(QtCore.QSettings.IniFormat) settings = QtCore.QSettings() settings.setIniCodec("utf-8") - settings.value("user scenario", "dcor-dev") + settings.setValue("check for updates", 0) + settings.setValue("user scenario", "dcor-dev") settings.setValue("auth/server", "dcor-dev.mpl.mpg.de") settings.setValue("auth/api key", common.get_api_key()) settings.setValue("debug/without timers", "1") diff --git a/tests/test_gui_updater.py b/tests/test_gui_updater.py new file mode 100644 index 0000000..dcc0848 --- /dev/null +++ b/tests/test_gui_updater.py @@ -0,0 +1,25 @@ +import socket + +import pytest +from dcoraid.gui import updater + + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.connect(("www.python.org", 80)) + NET_AVAILABLE = True + except socket.gaierror: + # no internet + NET_AVAILABLE = False + + +@pytest.mark.skipif(not NET_AVAILABLE, reason="No network connection!") +def test_update_basic(): + mdict = updater.check_release(ghrepo="DCOR-dev/DCOR-Aid", + version="0.1.0a1") + assert mdict["errors"] is None + assert mdict["update available"] + mdict = updater.check_release(ghrepo="DCOR-dev/DCOR-Aid", + version="8472.0.0") + assert mdict["errors"] is None + assert not mdict["update available"]