1
0
forked from IPKM/nmreval

issue126-backup (#198)

reworked autosave, closes #126; update with restart
This commit is contained in:
Dominik Demuth 2024-01-03 12:30:04 +00:00
parent 58e86f4abc
commit 73bdc71a83
7 changed files with 568 additions and 306 deletions

View File

@ -2,6 +2,7 @@
import sys import sys
import pathlib import pathlib
import os
sys.path.append(str(pathlib.Path(__file__).absolute().parent.parent / 'src')) sys.path.append(str(pathlib.Path(__file__).absolute().parent.parent / 'src'))
from nmreval.configs import check_for_config from nmreval.configs import check_for_config
@ -16,12 +17,45 @@ from nmreval.lib.logger import handle_exception
sys.excepthook = handle_exception sys.excepthook = handle_exception
from gui_qt import App 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.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() mplQt.show()
sys.exit(app.exec()) sys.exit(app.exec())

View File

@ -1,8 +1,8 @@
# -*- coding: utf-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 # 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. # run again. Do not edit this file unless you know what you are doing.

207
src/gui_qt/lib/backup.py Normal file
View File

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

View File

@ -1,4 +1,3 @@
import sys
from pathlib import Path from pathlib import Path
from .codeeditor import _make_textformats from .codeeditor import _make_textformats
@ -6,12 +5,14 @@ from ..Qt import QtWidgets, QtCore, QtGui
from nmreval.configs import config_paths from nmreval.configs import config_paths
STYLES = {'INFO': _make_textformats('black'), STYLES = {
'INFO': _make_textformats('black'),
'WARNING': _make_textformats('blue'), 'WARNING': _make_textformats('blue'),
'ERROR': _make_textformats('red', 'bold'), 'ERROR': _make_textformats('red', 'bold'),
'DEBUG': _make_textformats('black', 'italic'), 'DEBUG': _make_textformats('black', 'italic'),
'file': _make_textformats('red', 'italic'), 'file': _make_textformats('red', 'italic'),
'PyError': _make_textformats('red', 'bold-italic')} 'PyError': _make_textformats('red', 'bold-italic'),
}
class LogHighlighter(QtGui.QSyntaxHighlighter): class LogHighlighter(QtGui.QSyntaxHighlighter):

288
src/gui_qt/lib/update.py Normal file
View File

@ -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 += '<p>Could not determine if this version is newer, please update manually (if necessary).</p>'
dialog_bttns = QtWidgets.QDialogButtonBox.Close
elif is_updateble:
label_text += '<p>Different version available. Press Ok to download this version, Cancel to ignore.</p>'
dialog_bttns = QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
else:
label_text += '<p>Version may be already up-to-date.</p>'
dialog_bttns = QtWidgets.QDialogButtonBox.Close
if m_time_zsync is None:
label_text += '<p>Creation date of remote version is unknown.</p>'
else:
label_text += f'<p>Date of most recent AppImage: {m_time_zsync.strftime("%d %B %Y %H:%M")}</p>'
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'<p>Date of used AppImage: {m_time_file.strftime("%d %B %Y %H:%M")}</p>'
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())

View File

@ -1,20 +1,9 @@
from __future__ import annotations 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 contextlib import contextmanager
from pathlib import Path
from urllib.error import HTTPError
from numpy import linspace from numpy import linspace
from scipy.interpolate import interp1d from scipy.interpolate import interp1d
from nmreval.lib.logger import logger
from ..Qt import QtGui, QtWidgets, QtCore from ..Qt import QtGui, QtWidgets, QtCore
@ -63,225 +52,3 @@ class RdBuCMap:
return col 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 += '<p>Could not determine if this version is newer, please update manually (if necessary).</p>'
dialog_bttns = QtWidgets.QDialogButtonBox.Close
elif is_updateble:
label_text += '<p>Newer version available. Press Ok to download new version, Cancel to ignore.</p>'
dialog_bttns = QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
else:
label_text += '<p>Version may be already up-to-date.</p>'
dialog_bttns = QtWidgets.QDialogButtonBox.Close
if m_time_zsync is None:
label_text += '<p>Creation date of remote version is unknown.</p>'
else:
label_text += f'<p>Date of most recent AppImage: {m_time_zsync.strftime("%d %B %Y %H:%M")}</p>'
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'<p>Date of used AppImage: {m_time_file.strftime("%d %B %Y %H:%M")}</p>'
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 <p><em>{file_loc}</em>.</p>')
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

View File

@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import datetime
import os import os
import re import re
import time
from pathlib import Path from pathlib import Path
from numpy import geomspace, linspace from numpy import geomspace, linspace
@ -33,7 +33,7 @@ from ..math.smooth import QSmooth
from ..nmr.coupling_calc import QCoupCalcDialog from ..nmr.coupling_calc import QCoupCalcDialog
from ..nmr.t1_from_tau import QRelaxCalc from ..nmr.t1_from_tau import QRelaxCalc
from .._py.basewindow import Ui_BaseWindow from .._py.basewindow import Ui_BaseWindow
from ..lib.utils import UpdateDialog, Updater from ..lib.update import UpdateDialog
class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow):
@ -42,7 +42,7 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow):
save_ses_sig = QtCore.pyqtSignal(str) save_ses_sig = QtCore.pyqtSignal(str)
rest_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) super().__init__(parent=parents)
if path is None: if path is None:
@ -75,21 +75,16 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow):
self._init_gui() self._init_gui()
self._init_signals() 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 = QtCore.QTimer()
self.fit_timer.setInterval(500) self.fit_timer.setInterval(500)
self.fit_timer.timeout.connect( 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): def _init_gui(self):
self.setupUi(self) self.setupUi(self)
@ -108,8 +103,6 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow):
self.fitlim_button.setIcon(get_icon('fit_region')) self.fitlim_button.setIcon(get_icon('fit_region'))
self.toolBar_fit.addWidget(self.fitlim_button) self.toolBar_fit.addWidget(self.fitlim_button)
# self.area.dragEnterEvent = self.dragEnterEvent
while self.tabWidget.count() > 2: while self.tabWidget.count() > 2:
self.tabWidget.removeTab(self.tabWidget.count()-1) self.tabWidget.removeTab(self.tabWidget.count()-1)
@ -130,12 +123,15 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow):
self.fitregion = RegionItem() self.fitregion = RegionItem()
self._fit_plot_id = None self._fit_plot_id = None
self.setGeometry(QtWidgets.QStyle.alignedRect(QtCore.Qt.LeftToRight, QtCore.Qt.AlignCenter, self.setGeometry(QtWidgets.QStyle.alignedRect(
self.size(), QtWidgets.qApp.desktop().availableGeometry())) QtCore.Qt.LayoutDirection.LeftToRight,
QtCore.Qt.AlignmentFlag.AlignCenter,
self.size(),
QtWidgets.qApp.desktop().availableGeometry()),
)
self.datawidget.management = self.management self.datawidget.management = self.management
self.integralwidget.management = self.management self.integralwidget.management = self.management
# self.drawingswidget.graphs = self.management.graphs
self.ac_group = QtWidgets.QActionGroup(self) self.ac_group = QtWidgets.QActionGroup(self)
self.ac_group.addAction(self.action_lm_fit) 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.menuData.insertAction(self.actionRedo, self.actionUndo)
self.action_save_fit_parameter.triggered.connect(self.save_fit_parameter) self.action_save_fit_parameter.triggered.connect(self.save_fit_parameter)
# noinspection PyUnresolvedReferences
self.ac_group2.triggered.connect(self.change_fit_limits) self.ac_group2.triggered.connect(self.change_fit_limits)
self.t1action.triggered.connect(lambda: self._show_tab('t1_temp')) 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.actionNext_window.triggered.connect(lambda: self.area.activateNextSubWindow())
self.actionPrevious.triggered.connect(lambda: self.area.activatePreviousSubWindow()) 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.triggered.connect(lambda: self.management.apply('norm', ('max',)))
self.action_norm_max_abs.triggered.connect(lambda: self.management.apply('norm', ('maxabs',))) 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',))) 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): def item_from_graph(self, item, graph_id):
self.management.graphs[graph_id].remove_external(item) self.management.graphs[graph_id].remove_external(item)
def closeEvent(self, evt):
# self._write_settings()
self.close()
@QtCore.pyqtSlot(int) @QtCore.pyqtSlot(int)
def request_data(self, idx): def request_data(self, idx):
idd = self.datawidget.get_indexes(idx=idx-1) 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 = QUsermodelEditor(config_paths() / 'usermodels.py', parent=self)
self.editor.modelsChanged.connect(lambda: self.fit_dialog.read_and_load_functions()) 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() self.editor.show()
@QtCore.pyqtSlot(list) @QtCore.pyqtSlot(list)
@ -1029,9 +1020,10 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow):
@staticmethod @staticmethod
@QtCore.pyqtSlot(name='on_actionDocumentation_triggered') @QtCore.pyqtSlot(name='on_actionDocumentation_triggered')
def open_doc(): def open_doc():
docpath = '/autohome/dominik/auswerteprogramm3/doc/_build/html/index.html' pass
import webbrowser # docpath = '/autohome/dominik/auswerteprogramm3/doc/_build/html/index.html'
webbrowser.open(docpath) # import webbrowser
# webbrowser.open(docpath)
def dropEvent(self, evt): def dropEvent(self, evt):
if evt.mimeData().hasUrls(): if evt.mimeData().hasUrls():
@ -1060,13 +1052,13 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow):
if self.sender() == self.actionLife: if self.sender() == self.actionLife:
from ..lib.gol import QGameOfLife from ..lib.gol import QGameOfLife
game = QGameOfLife(parent=self) game = QGameOfLife(parent=self)
game.setWindowModality(QtCore.Qt.NonModal) game.setWindowModality(QtCore.Qt.WindowModality.NonModal)
game.show() game.show()
elif self.sender() == self.actionMine: elif self.sender() == self.actionMine:
from ..lib.stuff import QMines from ..lib.stuff import QMines
game = QMines(parent=self) game = QMines(parent=self)
game.setWindowModality(QtCore.Qt.NonModal) game.setWindowModality(QtCore.Qt.WindowModality.NonModal)
game.show() game.show()
else: else:
@ -1094,9 +1086,6 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow):
def close(self): def close(self):
write_state({'History': {'recent path': str(self.path)}}) write_state({'History': {'recent path': str(self.path)}})
# remove backup file when closing
self.__backup_path.unlink(missing_ok=True)
super().close() super().close()
def read_state(self): def read_state(self):
@ -1120,40 +1109,16 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow):
QLog(parent=self).show() QLog(parent=self).show()
def _autosave(self): def autosave(self) -> bool:
# TODO better separate thread may it takes some time to save
self.status.setText('Autosave...') self.status.setText('Autosave...')
success = NMRWriter(self.management.graphs, self.management.data).export(self.__backup_path.with_suffix('.nmr.0')) success = NMRWriter(self.management.graphs, self.management.data).export(self.__backup_path.with_suffix('.nmr.0'))
if success: if success:
self.__backup_path.with_suffix('.nmr.0').rename(self.__backup_path) self.__backup_path.with_suffix('.nmr.0').rename(self.__backup_path)
self.status.setText('') self.status.setText('')
def check_for_backup(self): return success
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
@QtCore.pyqtSlot(name='on_actionCreate_starter_triggered') @QtCore.pyqtSlot(name='on_actionCreate_starter_triggered')
def create_starter(self): def create_starter(self):