appimage-starter (#42)
create program launcher; reduced size of appimage; download of appimages working(?) Co-authored-by: Dominik Demuth <dominik.demuth@physik.tu-darmstadt.de> Reviewed-on: #42
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
							
								
								
									
										95
									
								
								src/gui_qt/lib/starter.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/gui_qt/lib/starter.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
| @@ -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 <p><em>{file_loc}</em>.</p>') | ||||
|         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<br>{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) | ||||
|   | ||||
| @@ -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')) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user