From 73bdc71a837469c646dc28e1968e1476a9b72d3a Mon Sep 17 00:00:00 2001 From: Dominik Demuth Date: Wed, 3 Jan 2024 12:30:04 +0000 Subject: [PATCH] issue126-backup (#198) reworked autosave, closes #126; update with restart --- bin/evaluate.py | 38 ++++- src/gui_qt/_py/basewindow.py | 4 +- src/gui_qt/lib/backup.py | 207 ++++++++++++++++++++++++ src/gui_qt/lib/logger.py | 15 +- src/gui_qt/lib/update.py | 288 ++++++++++++++++++++++++++++++++++ src/gui_qt/lib/utils.py | 233 --------------------------- src/gui_qt/main/mainwindow.py | 89 ++++------- 7 files changed, 568 insertions(+), 306 deletions(-) create mode 100644 src/gui_qt/lib/backup.py create mode 100644 src/gui_qt/lib/update.py diff --git a/bin/evaluate.py b/bin/evaluate.py index a9692dd..0478b37 100755 --- a/bin/evaluate.py +++ b/bin/evaluate.py @@ -2,6 +2,7 @@ import sys import pathlib +import os sys.path.append(str(pathlib.Path(__file__).absolute().parent.parent / 'src')) from nmreval.configs import check_for_config @@ -16,12 +17,45 @@ from nmreval.lib.logger import handle_exception sys.excepthook = handle_exception from gui_qt import App +from gui_qt.Qt import QtCore -app = App(['Team Rocket FTW!']) +app = App(['NMReval']) from gui_qt.main.mainwindow import NMRMainWindow +from gui_qt.lib.backup import BackupManager + + +def do_autosave(): + # autosave and update timestamp in db + success = mplQt.autosave() + if success: + backuping.update_last_save() + +# autosave stuff: keep track of instance and their backup files +backuping = BackupManager() + +# look for autosaves in DB without running programs +files = backuping.search_unsaved() + +# tell everyone what autosave files belongs to this process +pid = os.getpid() +bck_name = backuping.create_entry(pid) +mplQt = NMRMainWindow(bck_file=bck_name) + +# one manual autosave to create the file +do_autosave() + +# load all selected autosaves to program +for f in files: + mplQt.management.load_files(f) + f.unlink() + +timer = QtCore.QTimer() +timer.timeout.connect(do_autosave) +timer.start(3 * 60 * 1000) + +app.aboutToQuit.connect(backuping.close) -mplQt = NMRMainWindow() mplQt.show() sys.exit(app.exec()) diff --git a/src/gui_qt/_py/basewindow.py b/src/gui_qt/_py/basewindow.py index e1534f4..5c0bff4 100644 --- a/src/gui_qt/_py/basewindow.py +++ b/src/gui_qt/_py/basewindow.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'src/resources/_ui/basewindow.ui' +# Form implementation generated from reading ui file 'resources/_ui/basewindow.ui' # -# Created by: PyQt5 UI code generator 5.15.9 +# Created by: PyQt5 UI code generator 5.15.10 # # WARNING: Any manual changes made to this file will be lost when pyuic5 is # run again. Do not edit this file unless you know what you are doing. diff --git a/src/gui_qt/lib/backup.py b/src/gui_qt/lib/backup.py new file mode 100644 index 0000000..29ed6f3 --- /dev/null +++ b/src/gui_qt/lib/backup.py @@ -0,0 +1,207 @@ +import os +import sqlite3 +from datetime import datetime +from pathlib import Path + +from nmreval.configs import config_paths +from ..Qt import QtCore, QtWidgets + +DB_FILE = '/tmp/nmreval.db' + + +class BackupManager(QtCore.QObject): + def __init__(self): + super().__init__() + self.create_table() + self._pid = None + + def create_table(self): + con = sqlite3.connect(DB_FILE) + con.execute( + "CREATE TABLE IF NOT EXISTS sessions " + "(pid INTEGER NOT NULL, backup_file TEXT NOT NULL, last_save TEXT);" + ) + + def create_entry(self, pid: int): + backup_path = config_paths() / f'autosave_{datetime.now().strftime("%Y-%m-%d_%H%M%S")}_{pid}.nmr' + + con = sqlite3.connect(DB_FILE) + con.execute('INSERT INTO sessions VALUES(?, ?, ?);', + (pid, str(backup_path), None)) + con.commit() + con.close() + self._pid = pid + + return backup_path + + def update_last_save(self): + con = sqlite3.connect(DB_FILE) + con.execute( + 'UPDATE sessions SET last_save = ? WHERE pid = ?', + (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), self._pid) + ) + con.commit() + con.close() + + def search_unsaved(self): + con = sqlite3.connect(DB_FILE) + cursor = con.cursor() + res = cursor.execute('SELECT sessions.* FROM sessions;') + con.commit() + data = res.fetchall() + con.close() + + missing_processes = [] + + for pid, fname, save_date in data: + try: + os.kill(pid, 0) + except ProcessLookupError: + if Path(fname).exists(): + missing_processes.append((pid, fname, save_date)) + else: + # remove entries without valid file + self.remove_row(pid) + + if missing_processes: + msg = QLonelyBackupWindow() + msg.add_files(missing_processes) + msg.exec() + toberead = msg.recover + + for pid in toberead[0]: + self.remove_file(pid) + + for pid in toberead[1]: + self.remove_row(pid) + + return list(toberead[1].values()) + + return [] + + def remove_row(self, pid): + con = sqlite3.connect(DB_FILE) + con.execute('DELETE FROM sessions WHERE pid = ?', (pid,)) + con.commit() + con.close() + + def remove_file(self, pid: int = None): + if pid is None: + pid = self._pid + + con = sqlite3.connect(DB_FILE) + cursor = con.cursor() + + # remove backup file + res = cursor.execute('SELECT sessions.backup_file FROM sessions WHERE pid = ?', (pid,)) + con.commit() + + fname = Path(res.fetchone()[0]) + con.close() + + fname.unlink(missing_ok=True) + + # because autosave backups in a *.nmr.0 file and then moves it to *.nmr we look also for this one. + fname.with_suffix('.nmr.0').unlink(missing_ok=True) + + # after removal of file also remove entry + self.remove_row(pid) + + def delete_db_if_empty(self): + con = sqlite3.connect(DB_FILE) + cursor = con.cursor() + res = cursor.execute('SELECT COUNT(sessions.pid) FROM sessions GROUP BY sessions.pid;') + con.commit() + + remaining_processes = res.fetchone() + con.close() + if remaining_processes is None: + Path(DB_FILE).unlink() + + def close(self): + self.remove_file() + self.delete_db_if_empty() + + +class QLonelyBackupWindow(QtWidgets.QDialog): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.recover = [[], {}] # list of pid to delete, dict of files to read {pid: file} + + self.setWindowTitle('Adopt a file!') + self.resize(720, 320) + + layout = QtWidgets.QVBoxLayout(self) + self.label = QtWidgets.QLabel(self) + self.label.setText('Abandoned backup file(s) looking for a loving home!\n' + '(Files will all be loaded to same instance)') + layout.addWidget(self.label) + + self.table = QtWidgets.QTableWidget(self) + self.table.setColumnCount(4) + self.table.setHorizontalHeaderLabels(['File name', 'Last saved', 'File size', 'Action']) + self.table.setGridStyle(QtCore.Qt.PenStyle.DashLine) + # self.table.horizontalHeader().setStretchLastSection(True) + self.table.verticalHeader().setVisible(False) + self.table.horizontalHeader().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) + layout.addWidget(self.table) + + self.buttons = QtWidgets.QDialogButtonBox(self) + self.buttons.setOrientation(QtCore.Qt.Orientation.Horizontal) + self.buttons.setStandardButtons(QtWidgets.QDialogButtonBox.Ok) + layout.addWidget(self.buttons) + + self.buttons.accepted.connect(self.accept) + + def add_files(self, entries): + self.table.setRowCount(len(entries)) + for i, (pid, path, date) in enumerate(entries): + path = Path(path) + item1 = QtWidgets.QTableWidgetItem(path.name) + item1.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled) + item1.setData(QtCore.Qt.ItemDataRole.UserRole, pid) + item1.setData(QtCore.Qt.ItemDataRole.UserRole+1, path) + self.table.setItem(i, 0, item1) + + item2 = QtWidgets.QTableWidgetItem(date) + item2.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled) + self.table.setItem(i, 1, item2) + + size = path.stat().st_size + size_cnt = 0 + while size > 1024: + # make file size human-readable + size /= 1024 + size_cnt += 1 + if size_cnt == 5: + break + + byte = ['bytes', 'kB', 'MiB', 'GiB', 'TiB', 'PiB'][size_cnt] + + item3 = QtWidgets.QTableWidgetItem(f'{size:.2f} {byte}') + item3.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled) + self.table.setItem(i, 2, item3) + + cw = QtWidgets.QComboBox(self) + cw.addItems(['Load', 'Delete', 'Keep for later']) + self.table.setCellWidget(i, 3, cw) + + self.table.resizeColumnsToContents() + + def accept(self): + for i in range(self.table.rowCount()): + decision = self.table.cellWidget(i, 3).currentIndex() + item = self.table.item(i, 0) + pid = item.data(QtCore.Qt.ItemDataRole.UserRole) + if decision == 0: + # load file + self.recover[1][pid] = item.data(QtCore.Qt.ItemDataRole.UserRole+1) + elif decision == 1: + # delete + self.recover[0].append(pid) + else: + # do nothing + pass + + super().accept() + diff --git a/src/gui_qt/lib/logger.py b/src/gui_qt/lib/logger.py index 43b6bb1..0570d84 100644 --- a/src/gui_qt/lib/logger.py +++ b/src/gui_qt/lib/logger.py @@ -1,4 +1,3 @@ -import sys from pathlib import Path from .codeeditor import _make_textformats @@ -6,12 +5,14 @@ from ..Qt import QtWidgets, QtCore, QtGui from nmreval.configs import config_paths -STYLES = {'INFO': _make_textformats('black'), - 'WARNING': _make_textformats('blue'), - 'ERROR': _make_textformats('red', 'bold'), - 'DEBUG': _make_textformats('black', 'italic'), - 'file': _make_textformats('red', 'italic'), - 'PyError': _make_textformats('red', 'bold-italic')} +STYLES = { + 'INFO': _make_textformats('black'), + 'WARNING': _make_textformats('blue'), + 'ERROR': _make_textformats('red', 'bold'), + 'DEBUG': _make_textformats('black', 'italic'), + 'file': _make_textformats('red', 'italic'), + 'PyError': _make_textformats('red', 'bold-italic'), +} class LogHighlighter(QtGui.QSyntaxHighlighter): diff --git a/src/gui_qt/lib/update.py b/src/gui_qt/lib/update.py new file mode 100644 index 0000000..4534693 --- /dev/null +++ b/src/gui_qt/lib/update.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import hashlib +import os +import subprocess +import urllib.request +from datetime import datetime + +from os import getenv, stat +from os.path import exists +from pathlib import Path +from urllib.error import HTTPError + +from PyQt5 import QtWidgets, QtCore + +from nmreval.lib.logger import logger + + +class UpdateDialog(QtWidgets.QDialog): + startDownload = QtCore.pyqtSignal(tuple) + restartSignal = QtCore.pyqtSignal() + + def __init__(self, filename: str = None, parent=None): + super().__init__(parent=parent) + self._init_ui() + + if filename is None: + filename = getenv('APPIMAGE') + self._appfile = filename + + self.updater = Updater() + + self.thread = QtCore.QThread(self) + self.thread.start() + self.helper = Downloader() + self.startDownload.connect(self.helper.run_download) + self.helper.progressChanged.connect(self.status.setText) + self.helper.finished.connect(self.finish_update) + self.helper.started.connect(self.status.show) + self.helper.moveToThread(self.thread) + + self.look_for_updates(self._appfile) + + def _init_ui(self): + self.setWindowTitle('Updates') + + layout = QtWidgets.QVBoxLayout() + + self.label = QtWidgets.QLabel() + layout.addWidget(self.label) + + layout.addSpacing(10) + + self.status = QtWidgets.QLabel() + self.status.hide() + layout.addWidget(self.status) + layout.addSpacing(10) + + self.dialog_button = QtWidgets.QDialogButtonBox() + self.dialog_button.accepted.connect(self.update_appimage) + self.dialog_button.rejected.connect(self.close) + layout.addWidget(self.dialog_button) + + self.setLayout(layout) + + def look_for_updates(self, filename=None): + logger.info(f'Looking for updates, compare to file {filename}') + # Download zsync file of latest Appimage, look for SHA-1 hash and compare with hash of AppImage + is_updateble, m_time_file, m_time_zsync = self.updater.get_update_information(filename) + + label_text = '' + + if is_updateble is None: + label_text += '

Could not determine if this version is newer, please update manually (if necessary).

' + dialog_bttns = QtWidgets.QDialogButtonBox.Close + elif is_updateble: + label_text += '

Different version available. Press Ok to download this version, Cancel to ignore.

' + dialog_bttns = QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + else: + label_text += '

Version may be already up-to-date.

' + dialog_bttns = QtWidgets.QDialogButtonBox.Close + + if m_time_zsync is None: + label_text += '

Creation date of remote version is unknown.

' + else: + label_text += f'

Date of most recent AppImage: {m_time_zsync.strftime("%d %B %Y %H:%M")}

' + + if m_time_file is None: + label_text += 'No AppImage file found, press Ok to download latest version.' + dialog_bttns = QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Close + else: + label_text += f'

Date of used AppImage: {m_time_file.strftime("%d %B %Y %H:%M")}

' + + self.label.setText(label_text) + self.dialog_button.setStandardButtons(dialog_bttns) + + @QtCore.pyqtSlot() + def update_appimage(self): + if self._appfile is None: + args = (self.updater.zsync_url,) + else: + # this breaks the download for some reason + args = (self.updater.zsync_url, self._appfile) + + self.dialog_button.setEnabled(False) + + self.startDownload.emit(args) + self.status.show() + + @QtCore.pyqtSlot(int, str) + def finish_update(self, retcode: int, file_loc: str): + restart = QRestartWindow(state=retcode, file_loc=file_loc, parent=self) + res = restart.exec() + + self.close() + + if res == QtWidgets.QMessageBox.Ok: + self.restartSignal.emit() + + def closeEvent(self, evt): + self.thread.quit() + self.thread.wait() + + super().closeEvent(evt) + + +class QRestartWindow(QtWidgets.QMessageBox): + def __init__(self, state: int, file_loc: str, parent=None): + super().__init__(parent=parent) + self._appfile = file_loc + + if state: + self.setText('Download failed') + self.setDetailedText(f'Status code of failure is {state}') + self.setStandardButtons(QtWidgets.QMessageBox.Close) + self.setIcon(QtWidgets.QMessageBox.Warning) + else: + self.setText('Download completed!') + self.setInformativeText("Press Restart to use new AppImage") + self.setDetailedText(f'Location of AppImage: {file_loc}') + + self.setIcon(QtWidgets.QMessageBox.Information) + + self.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Close) + restart_button = self.button(QtWidgets.QMessageBox.Ok) + restart_button.setText('Restart') + + self.buttonClicked.connect(self.maybe_close) + + def maybe_close(self): + if self.clickedButton() == self.button(QtWidgets.QMessageBox.Ok): + app = QtWidgets.QApplication.instance() + app.quit() + subprocess.Popen(self._appfile) + + +class Downloader(QtCore.QObject): + started = QtCore.pyqtSignal() + finished = QtCore.pyqtSignal(int, str) + progressChanged = QtCore.pyqtSignal(str) + + @QtCore.pyqtSlot(tuple) + def run_download(self, args: tuple[str]): + status = 0 + appimage_location = args[0][:-6] + logger.info(f'Download {appimage_location}') + if len(args) == 2: + new_file = Path(args[1]) + else: + new_file = Path.home() / 'Downloads' / 'NMReval-latest-x86_64.AppImage' + + if new_file.exists(): + os.rename(new_file, new_file.with_suffix('.AppImage.old')) + + try: + with urllib.request.urlopen(appimage_location) as response: + with new_file.open('wb') as f: + f.write(response.read()) + + new_file.chmod(0o755) + except HTTPError as e: + logger.exception(f'Download failed with {e}') + status = 3 + except Exception as e: + logger.exception(f'Download failed with {e.args}') + status = 1 + + if status != 0: + logger.warning('Download failed, restore previous AppImage') + try: + os.rename(new_file.with_suffix('.AppImage.old'), new_file) + except FileNotFoundError: + pass + # zsync does not support https + + self.finished.emit(status, str(new_file)) + + +class Updater: + host = 'gitea.pkm.physik.tu-darmstadt.de/api/packages/IPKM/generic/NMReval/latest/' + version = 'NMReval-latest-x86_64' + + @property + def zsync_url(self): + return f'https://{Updater.host}/{Updater.version}.AppImage.zsync' + + @staticmethod + def get_zsync(): + url_zsync = f'https://{Updater.host}/{Updater.version}.AppImage.zsync' + m_time_zsync = None + checksum_zsync = None + zsync_file = None + filename = None + try: + with urllib.request.urlopen(url_zsync) as response: + zsync_file = response.read() + except HTTPError as e: + logger.error(f'Request for zsync returned code {e}') + except Exception as e: + logger.exception(f'Download of zsync failed with exception {e.args}') + + if zsync_file is not None: + for line in zsync_file.split(b'\n'): + try: + kw, val = line.split(b': ') + time_string = str(val, encoding='utf-8') + time_format = '%a, %d %b %Y %H:%M:%S %z' + if kw == b'MTime': + try: + m_time_zsync = datetime.strptime(time_string, time_format).astimezone(None) + except ValueError: + logger.warning(f'zsync time "{time_string}" does not match "{time_format}"') + elif kw == b'SHA-1': + checksum_zsync = str(val, encoding='utf-8') + elif kw == b'Filename': + filename = str(val, encoding='utf-8') + + except ValueError: + # stop when empty line is reached + break + + return m_time_zsync, checksum_zsync, filename + + @staticmethod + def get_appimage_info(filename: str): + if filename is None: + return None, None + + if not exists(filename): + return None, None + + stat_mtime = stat(filename).st_mtime + m_time_file = datetime.fromtimestamp(stat_mtime).replace(microsecond=0) + with open(filename, 'rb') as f: + checksum_file = hashlib.sha1(f.read()).hexdigest() + if checksum_file is None: + logger.warning('No checksum for AppImage calculated') + + return m_time_file, checksum_file + + @staticmethod + def get_update_information(filename: str) -> tuple[(bool | None), datetime, datetime]: + m_time_zsync, checksum_zsync, appname = Updater.get_zsync() + m_time_file, checksum_file = Updater.get_appimage_info(filename) + + logger.debug(f'zsync information {m_time_zsync}, {checksum_zsync}, {appname}') + logger.debug(f'file information {m_time_file}, {checksum_file}') + + if not ((checksum_file is not None) and (checksum_zsync is not None)): + return None, m_time_file, m_time_zsync + else: + return checksum_file != checksum_zsync, m_time_file, m_time_zsync + + +if __name__ == '__main__': + import sys + from gui_qt import App + + app = App(['Team Rocket FTW!']) + + updater = UpdateDialog() + updater.show() + + sys.exit(app.exec()) + + + + diff --git a/src/gui_qt/lib/utils.py b/src/gui_qt/lib/utils.py index 1cff792..2f06820 100644 --- a/src/gui_qt/lib/utils.py +++ b/src/gui_qt/lib/utils.py @@ -1,20 +1,9 @@ from __future__ import annotations -import os -import urllib.request -from os import getenv, stat -from os.path import exists -import hashlib -import subprocess -from datetime import datetime from contextlib import contextmanager -from pathlib import Path -from urllib.error import HTTPError from numpy import linspace from scipy.interpolate import interp1d -from nmreval.lib.logger import logger - from ..Qt import QtGui, QtWidgets, QtCore @@ -63,225 +52,3 @@ class RdBuCMap: return col -class UpdateDialog(QtWidgets.QDialog): - startDownload = QtCore.pyqtSignal(tuple) - - def __init__(self, filename: str = None, parent=None): - super().__init__(parent=parent) - self._init_ui() - - if filename is None: - filename = getenv('APPIMAGE') - self._appfile = filename - - self.updater = Updater() - - self.thread = QtCore.QThread(self) - self.thread.start() - self.helper = Downloader() - self.startDownload.connect(self.helper.run_download) - self.helper.progressChanged.connect(self.status.setText) - self.helper.finished.connect(self.finish_update) - self.helper.started.connect(self.status.show) - self.helper.moveToThread(self.thread) - - self.look_for_updates(self._appfile) - - def _init_ui(self): - self.setWindowTitle('Updates') - - layout = QtWidgets.QVBoxLayout() - - self.label = QtWidgets.QLabel() - layout.addWidget(self.label) - - layout.addSpacing(10) - - self.status = QtWidgets.QLabel() - self.status.hide() - layout.addWidget(self.status) - layout.addSpacing(10) - - self.dialog_button = QtWidgets.QDialogButtonBox() - self.dialog_button.accepted.connect(self.update_appimage) - self.dialog_button.rejected.connect(self.close) - layout.addWidget(self.dialog_button) - - self.setLayout(layout) - - def look_for_updates(self, filename=None): - logger.info(f'Looking for updates, compare to file {filename}') - # Download zsync file of latest Appimage, look for SHA-1 hash and compare with hash of AppImage - is_updateble, m_time_file, m_time_zsync = self.updater.get_update_information(filename) - - label_text = '' - - if is_updateble is None: - label_text += '

Could not determine if this version is newer, please update manually (if necessary).

' - dialog_bttns = QtWidgets.QDialogButtonBox.Close - elif is_updateble: - label_text += '

Newer version available. Press Ok to download new version, Cancel to ignore.

' - dialog_bttns = QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel - else: - label_text += '

Version may be already up-to-date.

' - dialog_bttns = QtWidgets.QDialogButtonBox.Close - - if m_time_zsync is None: - label_text += '

Creation date of remote version is unknown.

' - else: - label_text += f'

Date of most recent AppImage: {m_time_zsync.strftime("%d %B %Y %H:%M")}

' - - if m_time_file is None: - label_text += 'No AppImage file found, press Ok to download latest version.' - dialog_bttns = QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Close - else: - label_text += f'

Date of used AppImage: {m_time_file.strftime("%d %B %Y %H:%M")}

' - - self.label.setText(label_text) - self.dialog_button.setStandardButtons(dialog_bttns) - - @QtCore.pyqtSlot() - def update_appimage(self): - if self._appfile is None: - args = (self.updater.zsync_url,) - else: - # this breaks the download for some reason - args = (self.updater.zsync_url, self._appfile) - - self.dialog_button.setEnabled(False) - - self.startDownload.emit(args) - self.status.show() - - @QtCore.pyqtSlot(int, str) - def finish_update(self, retcode: int, file_loc: str): - if retcode == 0: - self.status.setText(f'Download complete.New AppImage lies in

{file_loc}.

') - else: - self.status.setText(f'Download failed :( with return code {retcode}.') - self.dialog_button.setStandardButtons(QtWidgets.QDialogButtonBox.Close) - self.dialog_button.setEnabled(True) - - def closeEvent(self, evt): - self.thread.quit() - self.thread.wait() - - super().closeEvent(evt) - - -class Downloader(QtCore.QObject): - started = QtCore.pyqtSignal() - finished = QtCore.pyqtSignal(int, str) - progressChanged = QtCore.pyqtSignal(str) - - @QtCore.pyqtSlot(tuple) - def run_download(self, args: tuple[str]): - status = 0 - appimage_location = args[0][:-6] - logger.info(f'Download {appimage_location}') - if len(args) == 2: - new_file = Path(args[1]) - else: - new_file = Path.home() / 'Downloads' / 'NMReval-latest-x86_64.AppImage' - - if new_file.exists(): - os.rename(new_file, new_file.with_suffix('.AppImage.old')) - - try: - with urllib.request.urlopen(appimage_location) as response: - with new_file.open('wb') as f: - f.write(response.read()) - - new_file.chmod(0o755) - except HTTPError as e: - logger.exception(f'Download failed with {e}') - status = 3 - except Exception as e: - logger.exception(f'Download failed with {e.args}') - status = 1 - - if status != 0: - logger.warning('Download failed, restore previous AppImage') - try: - os.rename(new_file.with_suffix('.AppImage.old'), new_file) - except FileNotFoundError: - pass - # zsync does not support https - - self.finished.emit(status, str(new_file)) - - -class Updater: - host = 'gitea.pkm.physik.tu-darmstadt.de/api/packages/IPKM/generic/NMReval/latest/' - version = 'NMReval-latest-x86_64' - - @property - def zsync_url(self): - return f'https://{Updater.host}/{Updater.version}.AppImage.zsync' - - @staticmethod - def get_zsync(): - url_zsync = f'https://{Updater.host}/{Updater.version}.AppImage.zsync' - m_time_zsync = None - checksum_zsync = None - zsync_file = None - filename = None - try: - with urllib.request.urlopen(url_zsync) as response: - zsync_file = response.read() - except HTTPError as e: - logger.error(f'Request for zsync returned code {e}') - except Exception as e: - logger.exception(f'Download of zsync failed with exception {e.args}') - - if zsync_file is not None: - for line in zsync_file.split(b'\n'): - try: - kw, val = line.split(b': ') - time_string = str(val, encoding='utf-8') - time_format = '%a, %d %b %Y %H:%M:%S %z' - if kw == b'MTime': - try: - m_time_zsync = datetime.strptime(time_string, time_format).astimezone(None) - except ValueError: - logger.warning(f'zsync time "{time_string}" does not match "{time_format}"') - elif kw == b'SHA-1': - checksum_zsync = str(val, encoding='utf-8') - elif kw == b'Filename': - filename = str(val, encoding='utf-8') - - except ValueError: - # stop when empty line is reached - break - - return m_time_zsync, checksum_zsync, filename - - @staticmethod - def get_appimage_info(filename: str): - if filename is None: - return None, None - - if not exists(filename): - return None, None - - stat_mtime = stat(filename).st_mtime - m_time_file = datetime.fromtimestamp(stat_mtime).replace(microsecond=0) - with open(filename, 'rb') as f: - checksum_file = hashlib.sha1(f.read()).hexdigest() - if checksum_file is None: - logger.warning('No checksum for AppImage calculated') - - return m_time_file, checksum_file - - @staticmethod - def get_update_information(filename: str) -> tuple[(bool | None), datetime, datetime]: - m_time_zsync, checksum_zsync, appname = Updater.get_zsync() - m_time_file, checksum_file = Updater.get_appimage_info(filename) - - logger.debug(f'zsync information {m_time_zsync}, {checksum_zsync}, {appname}') - logger.debug(f'file information {m_time_file}, {checksum_file}') - - if not ((checksum_file is not None) and (checksum_zsync is not None)): - return None, m_time_file, m_time_zsync - else: - return checksum_file != checksum_zsync, m_time_file, m_time_zsync diff --git a/src/gui_qt/main/mainwindow.py b/src/gui_qt/main/mainwindow.py index 87d6e2c..39fdbea 100644 --- a/src/gui_qt/main/mainwindow.py +++ b/src/gui_qt/main/mainwindow.py @@ -1,8 +1,8 @@ from __future__ import annotations -import datetime import os import re +import time from pathlib import Path from numpy import geomspace, linspace @@ -33,7 +33,7 @@ from ..math.smooth import QSmooth from ..nmr.coupling_calc import QCoupCalcDialog from ..nmr.t1_from_tau import QRelaxCalc from .._py.basewindow import Ui_BaseWindow -from ..lib.utils import UpdateDialog, Updater +from ..lib.update import UpdateDialog class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): @@ -42,7 +42,7 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): save_ses_sig = QtCore.pyqtSignal(str) rest_ses_sig = QtCore.pyqtSignal(str) - def __init__(self, parents=None, path=None): + def __init__(self, parents=None, path=None, bck_file=None): super().__init__(parent=parents) if path is None: @@ -75,21 +75,16 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): self._init_gui() self._init_signals() - if os.getenv('APPIMAGE') is not None: - if Updater.get_update_information(os.getenv('APPIMAGE'))[0]: - self.look_for_update() - - self.check_for_backup() - - self.__timer = QtCore.QTimer() - self.__backup_path = config_paths() / f'{datetime.datetime.now().strftime("%Y-%m-%d_%H%M%S")}.nmr' - self.__timer.start(3*60*1000) # every three minutes - self.__timer.timeout.connect(self._autosave) - self.fit_timer = QtCore.QTimer() self.fit_timer.setInterval(500) self.fit_timer.timeout.connect( - lambda: self.status.setText(f'Fit running... ({self.management.fitter.step} evaluations)')) + lambda: self.status.setText(f'Fit running... ({self.management.fitter.step} evaluations)') + ) + + self.__backup_path = pathlib.Path(bck_file) + if os.getenv('APPIMAGE'): + # ignore AppImages if not running from AppImage + self.look_for_update() def _init_gui(self): self.setupUi(self) @@ -108,8 +103,6 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): self.fitlim_button.setIcon(get_icon('fit_region')) self.toolBar_fit.addWidget(self.fitlim_button) - # self.area.dragEnterEvent = self.dragEnterEvent - while self.tabWidget.count() > 2: self.tabWidget.removeTab(self.tabWidget.count()-1) @@ -130,12 +123,15 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): self.fitregion = RegionItem() self._fit_plot_id = None - self.setGeometry(QtWidgets.QStyle.alignedRect(QtCore.Qt.LeftToRight, QtCore.Qt.AlignCenter, - self.size(), QtWidgets.qApp.desktop().availableGeometry())) + self.setGeometry(QtWidgets.QStyle.alignedRect( + QtCore.Qt.LayoutDirection.LeftToRight, + QtCore.Qt.AlignmentFlag.AlignCenter, + self.size(), + QtWidgets.qApp.desktop().availableGeometry()), + ) self.datawidget.management = self.management self.integralwidget.management = self.management - # self.drawingswidget.graphs = self.management.graphs self.ac_group = QtWidgets.QActionGroup(self) self.ac_group.addAction(self.action_lm_fit) @@ -161,6 +157,7 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): self.menuData.insertAction(self.actionRedo, self.actionUndo) self.action_save_fit_parameter.triggered.connect(self.save_fit_parameter) + # noinspection PyUnresolvedReferences self.ac_group2.triggered.connect(self.change_fit_limits) self.t1action.triggered.connect(lambda: self._show_tab('t1_temp')) @@ -235,8 +232,6 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): self.actionNext_window.triggered.connect(lambda: self.area.activateNextSubWindow()) self.actionPrevious.triggered.connect(lambda: self.area.activatePreviousSubWindow()) - self.closeSignal.connect(self.close) - self.action_norm_max.triggered.connect(lambda: self.management.apply('norm', ('max',))) self.action_norm_max_abs.triggered.connect(lambda: self.management.apply('norm', ('maxabs',))) self.action_norm_first.triggered.connect(lambda: self.management.apply('norm', ('first',))) @@ -864,10 +859,6 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): def item_from_graph(self, item, graph_id): self.management.graphs[graph_id].remove_external(item) - def closeEvent(self, evt): - # self._write_settings() - self.close() - @QtCore.pyqtSlot(int) def request_data(self, idx): idd = self.datawidget.get_indexes(idx=idx-1) @@ -995,7 +986,7 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): self.editor = QUsermodelEditor(config_paths() / 'usermodels.py', parent=self) self.editor.modelsChanged.connect(lambda: self.fit_dialog.read_and_load_functions()) - self.editor.setWindowModality(QtCore.Qt.ApplicationModal) + self.editor.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) self.editor.show() @QtCore.pyqtSlot(list) @@ -1029,9 +1020,10 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): @staticmethod @QtCore.pyqtSlot(name='on_actionDocumentation_triggered') def open_doc(): - docpath = '/autohome/dominik/auswerteprogramm3/doc/_build/html/index.html' - import webbrowser - webbrowser.open(docpath) + pass + # docpath = '/autohome/dominik/auswerteprogramm3/doc/_build/html/index.html' + # import webbrowser + # webbrowser.open(docpath) def dropEvent(self, evt): if evt.mimeData().hasUrls(): @@ -1060,13 +1052,13 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): if self.sender() == self.actionLife: from ..lib.gol import QGameOfLife game = QGameOfLife(parent=self) - game.setWindowModality(QtCore.Qt.NonModal) + game.setWindowModality(QtCore.Qt.WindowModality.NonModal) game.show() elif self.sender() == self.actionMine: from ..lib.stuff import QMines game = QMines(parent=self) - game.setWindowModality(QtCore.Qt.NonModal) + game.setWindowModality(QtCore.Qt.WindowModality.NonModal) game.show() else: @@ -1094,9 +1086,6 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): def close(self): write_state({'History': {'recent path': str(self.path)}}) - # remove backup file when closing - self.__backup_path.unlink(missing_ok=True) - super().close() def read_state(self): @@ -1120,40 +1109,16 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): QLog(parent=self).show() - def _autosave(self): - # TODO better separate thread may it takes some time to save + def autosave(self) -> bool: self.status.setText('Autosave...') success = NMRWriter(self.management.graphs, self.management.data).export(self.__backup_path.with_suffix('.nmr.0')) + if success: self.__backup_path.with_suffix('.nmr.0').rename(self.__backup_path) self.status.setText('') - def check_for_backup(self): - backups = [] - for filename in config_paths().glob('*.nmr'): - try: - backups.append((filename, datetime.datetime.strptime(filename.stem, "%Y-%m-%d_%H%M%S"))) - except ValueError: - continue - - if backups: - backup_by_date = sorted(backups, key=lambda x: x[1]) - msg = QtWidgets.QMessageBox.information( - self, 'Backup found', - f'{len(backups)} backup files in directory {backup_by_date[-1][0].parent} found.\n\n' - f'Latest backup date: {backup_by_date[-1][1]}\n\n' - f'Press Ok to load, Cancel to delete backup, Close to do nothing.', - QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel | QtWidgets.QMessageBox.Close - ) - - if msg == QtWidgets.QMessageBox.Ok: - self.management.load_files([str(backup_by_date[-1][0])]) - backup_by_date[-1][0].unlink() - elif msg == QtWidgets.QMessageBox.Cancel: - backup_by_date[-1][0].unlink() - else: - pass + return success @QtCore.pyqtSlot(name='on_actionCreate_starter_triggered') def create_starter(self):