From 0ec0021727d00c971750ad0dd74b14941615465b Mon Sep 17 00:00:00 2001 From: Dominik Demuth Date: Sat, 8 Apr 2023 13:28:13 +0000 Subject: [PATCH] appimage-starter (#42) create program launcher; reduced size of appimage; download of appimages working(?) Co-authored-by: Dominik Demuth Reviewed-on: https://gitea.pkm.physik.tu-darmstadt.de/IPKM/nmreval/pulls/42 --- AppImageBuilder.yml | 20 ++++-- src/gui_qt/_py/basewindow.py | 6 +- src/gui_qt/lib/starter.py | 95 ++++++++++++++++++++++++++ src/gui_qt/lib/utils.py | 114 +++++++++++++------------------- src/gui_qt/main/mainwindow.py | 7 +- src/nmreval/configs.py | 4 ++ src/resources/_ui/basewindow.ui | 6 ++ 7 files changed, 176 insertions(+), 76 deletions(-) create mode 100644 src/gui_qt/lib/starter.py diff --git a/AppImageBuilder.yml b/AppImageBuilder.yml index 6052ed5..dae00b6 100644 --- a/AppImageBuilder.yml +++ b/AppImageBuilder.yml @@ -42,22 +42,30 @@ AppDir: - python3.9-minimal - python3-numpy - python3-scipy - # - python3-matplotlib - # - python-matplotlib-data - python3-bsddb3 - python3-h5py - python3-pyqt5 - python3-pyqtgraph - - python3-requests - - python3-urllib3 -# - python3-tk exclude: + # lots of qt stuff we do not use + - libqt5designer5 + - libqt5help5 + - libqt5network5 + - libqt5sql5 + - libqt5test5 + - libqt5xml5 + - qtbase5-dev-tools + - qtchooser + - pyqt5-dev-tools + - qtchooser - libavahi-client3 - libavahi-common-data - libavahi-common3 + - libwacom2 + - libwacom-common after_bundle: | echo "MONSTER SED FOLLOWING...(uncomment if needed for mpl-data)" - #sed -i s,\'/usr/share/matplotlib/mpl-data\',"f\"\{os.environ.get\('APPDIR'\,'/'\)\}/usr/share/matplotlib/mpl-data\"", ${TARGET_APPDIR}/usr/lib/python3/dist-packages/matplotlib/__init__.py + # sed -i s,\'/usr/share/matplotlib/mpl-data\',"f\"\{os.environ.get\('APPDIR'\,'/'\)\}/usr/share/matplotlib/mpl-data\"", ${TARGET_APPDIR}/usr/lib/python3/dist-packages/matplotlib/__init__.py runtime: version: "continuous" env: diff --git a/src/gui_qt/_py/basewindow.py b/src/gui_qt/_py/basewindow.py index f680529..07a2cc1 100644 --- a/src/gui_qt/_py/basewindow.py +++ b/src/gui_qt/_py/basewindow.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'src/resources/_ui/basewindow.ui' # -# Created by: PyQt5 UI code generator 5.15.7 +# Created by: PyQt5 UI code generator 5.15.9 # # 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. @@ -358,6 +358,8 @@ class Ui_BaseWindow(object): self.actionBugs.setObjectName("actionBugs") self.actionShow_error_log = QtWidgets.QAction(BaseWindow) self.actionShow_error_log.setObjectName("actionShow_error_log") + self.actionCreate_starter = QtWidgets.QAction(BaseWindow) + self.actionCreate_starter.setObjectName("actionCreate_starter") self.menuSave.addAction(self.actionSave) self.menuSave.addAction(self.actionExportGraphic) self.menuSave.addAction(self.action_save_fit_parameter) @@ -422,6 +424,7 @@ class Ui_BaseWindow(object): self.menuOptions.addSeparator() self.menuOptions.addAction(self.action_colorcycle) self.menuOptions.addAction(self.actionConfiguration) + self.menuOptions.addAction(self.actionCreate_starter) self.menuView.addAction(self.actionTile) self.menuView.addAction(self.actionCascade_windows) self.menuWindow.addAction(self.actionNew_window) @@ -612,6 +615,7 @@ class Ui_BaseWindow(object): self.action_draw_object.setText(_translate("BaseWindow", "Draw objects...")) self.actionBugs.setText(_translate("BaseWindow", "Bugs! Problems! Wishes!")) self.actionShow_error_log.setText(_translate("BaseWindow", "Show error log")) + self.actionCreate_starter.setText(_translate("BaseWindow", "Create starter..")) from ..data.datawidget.datawidget import DataWidget from ..data.integral_widget import IntegralWidget from ..data.point_select import PointSelectWidget diff --git a/src/gui_qt/lib/starter.py b/src/gui_qt/lib/starter.py new file mode 100644 index 0000000..748c379 --- /dev/null +++ b/src/gui_qt/lib/starter.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import re +from shutil import copyfile +from pathlib import Path +import os +from configparser import ConfigParser +from importlib.resources import path as resource_path + +from nmreval.configs import config_paths +from nmreval.lib.logger import logger + + +def make_starter(app_file: str | None): + if app_file is not None: + make_starter_appimage(Path(app_file)) + else: + make_starter_src() + + +def make_starter_appimage(app_file: Path): + new_path = Path.home() / '.local' / 'bin' / app_file.name + + if app_file != new_path: + app_file.rename(new_path) + + create_desktop_file(new_path) + + +def make_starter_src(): + home = Path.home() + p = Path.home() + for p in Path(__file__).parents: + if p.stem == 'src': + break + elif p == home: + break + + success = p != Path.home() + if success: + bin_path = p.with_name('bin') / 'evaluate.py' + success = bin_path.exists() + + if not success: + logger.warning('Location of evaluate.py could not be determined') + return False + + create_desktop_file(bin_path) + + +def create_desktop_file(new_path: Path): + logo_path = config_paths() / 'logo.png' + if not logo_path.exists(): + with resource_path('resources', 'logo.png') as fp: + copyfile(fp, logo_path) + desktop_entry = f"""\ +[Desktop Entry] +Name=NMReval +Comment=Best program ever (maybe) +Exec={new_path} +Icon={logo_path} +Type=Application +Terminal=false +Categories=Science;NumericalAnalysis;Physics;DataVisualization;Other; +NoDisplay=false +""" + file_name = 'pkm.vogel.nmreval.desktop' + with Path('~/.local/share/applications/', file_name).expanduser().open('w') as f: + f.write(desktop_entry) + + desktop_dir = get_xkg_user_dirs('desktop') + if desktop_dir is not None: + desk_file = Path(desktop_dir, file_name) + with desk_file.open('w') as f: + f.write(desktop_entry) + + desk_file.chmod(0o755) + + +def get_xkg_user_dirs(dir_type: str) -> str | None: + xdg_conf_home = os.getenv('XDG_CONFIG_HOME') or str(Path.home() / '.config') + + with Path(xdg_conf_home, 'user-dirs.dirs').open('r') as f: + conf_string = '[XDG_USER_DIRS]\n' + f.read() + conf_string = re.sub(r'\$HOME', str(Path.home()), conf_string) + conf_string = re.sub('"', '', conf_string) + + config = ConfigParser() + config.read_string(conf_string) + + return config['XDG_USER_DIRS'].get(f'xdg_{dir_type}_dir') + + +if __name__ == '__main__': + make_starter() diff --git a/src/gui_qt/lib/utils.py b/src/gui_qt/lib/utils.py index 436e656..d2c1680 100644 --- a/src/gui_qt/lib/utils.py +++ b/src/gui_qt/lib/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations -from functools import lru_cache +import os +import urllib.request from os import getenv, stat from os.path import exists import hashlib @@ -8,8 +9,7 @@ import subprocess from datetime import datetime from contextlib import contextmanager from pathlib import Path - -import requests +from urllib.error import HTTPError from numpy import linspace from scipy.interpolate import interp1d @@ -64,7 +64,7 @@ class RdBuCMap: class UpdateDialog(QtWidgets.QDialog): - startDownload = QtCore.pyqtSignal(list) + startDownload = QtCore.pyqtSignal(tuple) def __init__(self, filename: str = None, parent=None): super().__init__(parent=parent) @@ -74,7 +74,6 @@ class UpdateDialog(QtWidgets.QDialog): filename = getenv('APPIMAGE') self._appfile = filename - self.success = False self.updater = Updater() self.thread = QtCore.QThread(self) @@ -144,24 +143,20 @@ class UpdateDialog(QtWidgets.QDialog): @QtCore.pyqtSlot() def update_appimage(self): if self._appfile is None: - args = [self.updater.zsync_url] + args = (self.updater.zsync_url,) else: # this breaks the download for some reason - args = ['-i', self._appfile, self.updater.zsync_url] - - args = [self.updater.zsync_url] + args = (self.updater.zsync_url, self._appfile) self.dialog_button.setEnabled(False) self.startDownload.emit(args) self.status.show() - @QtCore.pyqtSlot(int) - def finish_update(self, retcode: int): - # print('finished with', retcode) - self.success = retcode == 0 + @QtCore.pyqtSlot(int, str) + def finish_update(self, retcode: int, file_loc: str): if retcode == 0: - self.status.setText('Download complete.') + 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) @@ -171,44 +166,49 @@ class UpdateDialog(QtWidgets.QDialog): self.thread.quit() self.thread.wait() - if self.success: - appname = self.updater.get_zsync()[2] - if self._appfile is not None: - appimage_path = appname - old_version = Path(self._appfile).rename(self._appfile+'.old') - appimage_path = Path(appimage_path).replace(self._appfile) - else: - appimage_path = Path().cwd() / appname - # rename to version-agnostic name - appimage_path = appimage_path.rename('NMReval.AppImage') - appimage_path.chmod(appimage_path.stat().st_mode | 73) # 73 = 0o111 = a+x - - _ = QtWidgets.QMessageBox.information(self, 'Complete', - f'New AppImage available at
{appimage_path}') - super().closeEvent(evt) class Downloader(QtCore.QObject): started = QtCore.pyqtSignal() - finished = QtCore.pyqtSignal(int) + finished = QtCore.pyqtSignal(int, str) progressChanged = QtCore.pyqtSignal(str) - @QtCore.pyqtSlot(list) - def run_download(self, args: list[str]): - logger.info(f'Download with args {args}') - process = subprocess.Popen(['zsync'] + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=1, universal_newlines=True) - while True: - nextline = process.stdout.readline().strip() - if nextline: - self.progressChanged.emit(nextline) + @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' - # line = process.stderr.readline().strip() + if new_file.exists(): + os.rename(new_file, new_file.with_suffix('.AppImage.old')) - if process.poll() is not None: - break + try: + with urllib.request.urlopen(appimage_location) as response: + with new_file.open('wb') as f: + f.write(response.read()) - self.finished.emit(process.returncode) + 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: @@ -220,7 +220,6 @@ class Updater: return f'https://{Updater.host}/{Updater.version}.AppImage.zsync' @staticmethod - @lru_cache(3) def get_zsync(): url_zsync = f'https://{Updater.host}/{Updater.version}.AppImage.zsync' m_time_zsync = None @@ -228,11 +227,10 @@ class Updater: zsync_file = None filename = None try: - response = requests.get(url_zsync) - if response.status_code == 200: - zsync_file = response.content - else: - logger.error(f'Request for zsync returned code {response.status_code}') + 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}') @@ -287,23 +285,3 @@ class Updater: return None, m_time_file, m_time_zsync else: return checksum_file != checksum_zsync, m_time_file, m_time_zsync - - -def open_bug_report(): - form_entries = { - 'description': 'Please state the nature of the medical emergency.', - 'title': 'Everything is awesome?', - 'assign[0]': 'dominik', - 'subscribers[0]': 'dominik', - 'tag': 'nmreval', - 'priority': 'normal', - 'status': 'open', - } - full_url = 'https://chaos3.fkp.physik.tu-darmstadt.de/maniphest/task/edit/?' - - for k, v in form_entries.items(): - full_url += f'{k}={v}&' - full_url.replace(' ', '+') - - import webbrowser - webbrowser.open(full_url) diff --git a/src/gui_qt/main/mainwindow.py b/src/gui_qt/main/mainwindow.py index fc2f079..c5979bf 100644 --- a/src/gui_qt/main/mainwindow.py +++ b/src/gui_qt/main/mainwindow.py @@ -23,6 +23,7 @@ from ..io.fcbatchreader import QFCReader from ..io.filedialog import * from ..lib import get_icon, make_action_icons from ..lib.pg_objects import RegionItem +from ..lib.starter import make_starter from ..math.evaluation import QEvalDialog from ..math.interpol import InterpolDialog from ..math.mean_dialog import QMeanTimes @@ -30,7 +31,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, open_bug_report, Updater +from ..lib.utils import UpdateDialog, Updater class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): @@ -1059,3 +1060,7 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): self.status.setText('Autosave...') NMRWriter(self.management.graphs, self.management.data).export(self.__backup_path) self.status.setText('') + + @QtCore.pyqtSlot(name='on_actionCreate_starter_triggered') + def create_starter(self): + make_starter(os.getenv('APPIMAGE')) diff --git a/src/nmreval/configs.py b/src/nmreval/configs.py index 51b674e..abdf2a3 100644 --- a/src/nmreval/configs.py +++ b/src/nmreval/configs.py @@ -17,9 +17,13 @@ def check_for_config(make=True): conf_path.mkdir(parents=True) cwd = pathlib.Path(__file__).parent copyfile(cwd / 'models' / 'usermodels.py', conf_path / 'usermodels.py') + with resource_path('resources', 'Default.agr') as fp: copyfile(fp, conf_path / 'Default.agr') + with resource_path('resources', 'logo.png') as fp: + copyfile(fp, conf_path / 'logo.png') + else: raise e diff --git a/src/resources/_ui/basewindow.ui b/src/resources/_ui/basewindow.ui index 5edcf51..0935eb0 100644 --- a/src/resources/_ui/basewindow.ui +++ b/src/resources/_ui/basewindow.ui @@ -263,6 +263,7 @@ + @@ -999,6 +1000,11 @@ Show error log + + + Create starter.. + +