From f56921fe96ab3f3f2f02b31c677ec66e44984ffe Mon Sep 17 00:00:00 2001 From: mycir <113249299+mycir@users.noreply.github.com> Date: Mon, 18 Nov 2024 15:41:31 +0000 Subject: [PATCH 1/4] Fixes #2120 --- src/vorta/application.py | 18 ++++++++++++++++-- src/vorta/borg/create.py | 22 +++++++++++++++------- src/vorta/scheduler.py | 6 +++--- src/vorta/views/main_window.py | 15 ++++++++++++++- 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/vorta/application.py b/src/vorta/application.py index 17ec6752a..46ba1addb 100644 --- a/src/vorta/application.py +++ b/src/vorta/application.py @@ -2,6 +2,7 @@ import os import sys from pathlib import Path +from threading import Thread from typing import Any, Dict, List, Tuple from PyQt6 import QtCore @@ -42,6 +43,9 @@ class VortaApp(QtSingleApplication): backup_log_event = QtCore.pyqtSignal(str, dict) backup_progress_event = QtCore.pyqtSignal(str) check_failed_event = QtCore.pyqtSignal(dict) + create_backup_event = QtCore.pyqtSignal() + pre_backup_event = QtCore.pyqtSignal(int) + post_backup_event = QtCore.pyqtSignal(int, bool) def __init__(self, args_raw, single_app=False): super().__init__(str(APP_ID), args_raw) @@ -84,6 +88,9 @@ def __init__(self, args_raw, single_app=False): self.message_received_event.connect(self.message_received_event_response) self.check_failed_event.connect(self.check_failed_response) self.backup_log_event.connect(self.react_to_log) + self.create_backup_event.connect(lambda: Thread(target=self.create_backup_action).start()) + self.pre_backup_event.connect(self.pre_backup_event_response) + self.post_backup_event.connect(self.post_backup_event_response) self.aboutToQuit.connect(self.quit_app_action) self.set_borg_details_action() if sys.platform == 'darwin': @@ -94,7 +101,7 @@ def create_backups_cmdline(self, profile_name): if profile is not None: if profile.repo is None: logger.warning(f"Add a repository to {profile_name}") - self.create_backup_action(profile_id=profile.id) + Thread(target=self.create_backup_action, kwargs={'profile_id': profile.id}).start() else: logger.warning(f"Invalid profile name {profile_name}") @@ -110,7 +117,7 @@ def create_backup_action(self, profile_id=None): profile_id = self.main_window.current_profile.id profile = BackupProfileModel.get(id=profile_id) - msg = BorgCreateJob.prepare(profile) + msg = BorgCreateJob.prepare(profile, app=self) if msg['ok']: job = BorgCreateJob(msg['cmd'], msg, profile.repo.id) self.jobs_manager.add_job(job) @@ -146,6 +153,13 @@ def backup_cancelled_event_response(self): self.jobs_manager.cancel_all_jobs() self.tray.set_tray_icon() + def pre_backup_event_response(self, pid): + self.tray.set_tray_icon(active=True) + + def post_backup_event_response(self, pid, active=False): + if not active: + self.tray.set_tray_icon() + def message_received_event_response(self, message): if message == "open main window": self.open_main_window_action() diff --git a/src/vorta/borg/create.py b/src/vorta/borg/create.py index 171265c04..56160ee95 100644 --- a/src/vorta/borg/create.py +++ b/src/vorta/borg/create.py @@ -53,7 +53,7 @@ def process_result(self, result): self.app.backup_log_event.emit('', {}) self.app.backup_progress_event.emit(f"[{self.params['profile_name']}] {self.tr('Backup finished.')}") - def progress_event(self, fmt): + def progress_event(self, fmt=None): self.app.backup_progress_event.emit(f"[{self.params['profile_name']}] {fmt}") def started_event(self): @@ -63,12 +63,13 @@ def started_event(self): def finished_event(self, result): self.app.backup_finished_event.emit(result) self.result.emit(result) - self.pre_post_backup_cmd(self.params, cmd='post_backup_cmd', returncode=result['returncode']) + self.pre_post_backup_cmd(self.params, context='post_backup_cmd', app=self.app, returncode=result['returncode']) @classmethod - def pre_post_backup_cmd(cls, params, cmd='pre_backup_cmd', returncode=0): - cmd = getattr(params['profile'], cmd) + def pre_post_backup_cmd(cls, params, context='pre_backup_cmd', app=None, returncode=0): + cmd = getattr(params['profile'], context) if cmd: + profile_name = getattr(params['profile'], 'name') env = { **os.environ.copy(), 'repo_url': params['repo'].url, @@ -76,13 +77,20 @@ def pre_post_backup_cmd(cls, params, cmd='pre_backup_cmd', returncode=0): 'profile_slug': params['profile'].slug(), 'returncode': str(returncode), } - proc = subprocess.run(cmd, shell=True, env=env) + proc = subprocess.Popen(cmd, shell=True, env=env) + if context.startswith('pre'): + app.backup_progress_event.emit(f"[{profile_name}] {trans_late('messages', 'Waiting to start backup')}") + app.pre_backup_event.emit(proc.pid) + else: + app.post_backup_event.emit(proc.pid, True) + proc.wait() + app.post_backup_event.emit(None, False) return proc.returncode else: return 0 # 0 if no command was run. @classmethod - def prepare(cls, profile): + def prepare(cls, profile, app=None): """ `borg create` is called from different places and needs some preparation. Centralize it here and return the required arguments to the caller. @@ -133,7 +141,7 @@ def prepare(cls, profile): ret['repo'] = profile.repo # Run user-supplied pre-backup command - if cls.pre_post_backup_cmd(ret) != 0: + if cls.pre_post_backup_cmd(ret, app=app) != 0: ret['message'] = trans_late('messages', 'Pre-backup command returned non-zero exit code.') return ret diff --git a/src/vorta/scheduler.py b/src/vorta/scheduler.py index 9ff19681c..01aada310 100644 --- a/src/vorta/scheduler.py +++ b/src/vorta/scheduler.py @@ -301,7 +301,7 @@ def set_timer_for_profile(self, profile_id: int): profile.name, profile_id, ) - self.create_backup(profile_id) + threading.Thread(target=self.create_backup, args=(profile_id,)).start() finally: self.lock.acquire() # with-statement will try to release @@ -337,7 +337,7 @@ def set_timer_for_profile(self, profile_id: int): timer = QTimer() timer.setSingleShot(True) timer.setInterval(int(timer_ms)) - timer.timeout.connect(lambda: self.create_backup(profile_id)) + timer.timeout.connect(lambda: threading.Thread(target=self.create_backup, args=(profile_id,)).start()) timer.start() self.timers[profile_id] = { @@ -410,7 +410,7 @@ def create_backup(self, profile_id): self.tr('Starting background backup for %s.') % profile.name, level='info', ) - msg = BorgCreateJob.prepare(profile) + msg = BorgCreateJob.prepare(profile, app=self.app) if msg['ok']: logger.info('Preparation for backup successful.') msg['category'] = 'scheduled' diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index a0bb988ea..19aac78da 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -88,7 +88,7 @@ def __init__(self, parent=None): self.miscTab.refresh_archive.connect(self.archiveTab.populate_from_profile) self.miscButton.clicked.connect(self.toggle_misc_visibility) - self.createStartBtn.clicked.connect(self.app.create_backup_action) + self.createStartBtn.clicked.connect(self.app.create_backup_event.emit) self.cancelButton.clicked.connect(self.app.backup_cancelled_event.emit) QShortcut(QKeySequence("Ctrl+W"), self).activated.connect(self.on_close_window) @@ -99,6 +99,8 @@ def __init__(self, parent=None): self.app.backup_log_event.connect(self.set_log) self.app.backup_progress_event.connect(self.set_progress) self.app.backup_cancelled_event.connect(self.backup_cancelled_event) + self.app.pre_backup_event.connect(self.pre_backup_event) + self.app.post_backup_event.connect(self.post_backup_event) # Init profile list self.populate_profile_selector() @@ -339,6 +341,17 @@ def backup_cancelled_event(self): self.set_log(self.tr('Task cancelled')) self.archiveTab.cancel_action() + def pre_backup_event(self, pid): + self._toggle_buttons(create_enabled=False) + self.set_log(self.tr(f"Running pre backup commands [PID: {pid}]")) + + def post_backup_event(self, pid, active=True): + if active: + self.set_log(self.tr(f"Running post backup commands [PID: {pid}]")) + else: + self._toggle_buttons(create_enabled=True) + self.set_log('') + def closeEvent(self, event): # Save window state in SettingsModel SettingsModel.update({SettingsModel.str_value: str(self.width())}).where( From 84fbf14dde9e0b895a56c93776efea98d4bde04e Mon Sep 17 00:00:00 2001 From: mycir <113249299+mycir@users.noreply.github.com> Date: Fri, 22 Nov 2024 10:40:49 +0000 Subject: [PATCH 2/4] Fixes #2120 (ammended) --- src/vorta/application.py | 10 +++----- src/vorta/borg/create.py | 2 +- src/vorta/scheduler.py | 7 ++--- src/vorta/utils.py | 47 +++++++++++++++++++++++++++++++++- src/vorta/views/main_window.py | 2 +- 5 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/vorta/application.py b/src/vorta/application.py index 46ba1addb..d4fc83fe8 100644 --- a/src/vorta/application.py +++ b/src/vorta/application.py @@ -2,7 +2,6 @@ import os import sys from pathlib import Path -from threading import Thread from typing import Any, Dict, List, Tuple from PyQt6 import QtCore @@ -21,7 +20,7 @@ from vorta.store.connection import cleanup_db from vorta.store.models import BackupProfileModel, SettingsModel from vorta.tray_menu import TrayMenu -from vorta.utils import borg_compat, parse_args +from vorta.utils import borg_compat, parse_args, AsyncRunner from vorta.views.main_window import MainWindow logger = logging.getLogger(__name__) @@ -43,7 +42,6 @@ class VortaApp(QtSingleApplication): backup_log_event = QtCore.pyqtSignal(str, dict) backup_progress_event = QtCore.pyqtSignal(str) check_failed_event = QtCore.pyqtSignal(dict) - create_backup_event = QtCore.pyqtSignal() pre_backup_event = QtCore.pyqtSignal(int) post_backup_event = QtCore.pyqtSignal(int, bool) @@ -88,7 +86,6 @@ def __init__(self, args_raw, single_app=False): self.message_received_event.connect(self.message_received_event_response) self.check_failed_event.connect(self.check_failed_response) self.backup_log_event.connect(self.react_to_log) - self.create_backup_event.connect(lambda: Thread(target=self.create_backup_action).start()) self.pre_backup_event.connect(self.pre_backup_event_response) self.post_backup_event.connect(self.post_backup_event_response) self.aboutToQuit.connect(self.quit_app_action) @@ -101,7 +98,7 @@ def create_backups_cmdline(self, profile_name): if profile is not None: if profile.repo is None: logger.warning(f"Add a repository to {profile_name}") - Thread(target=self.create_backup_action, kwargs={'profile_id': profile.id}).start() + self.create_backup_action(profile_id=profile.id) else: logger.warning(f"Invalid profile name {profile_name}") @@ -112,7 +109,8 @@ def quit_app_action(self): del self.tray cleanup_db() - def create_backup_action(self, profile_id=None): + @AsyncRunner + def create_backup_action(self, profile_id=None, app=None): if not profile_id: profile_id = self.main_window.current_profile.id diff --git a/src/vorta/borg/create.py b/src/vorta/borg/create.py index 56160ee95..37b1ff503 100644 --- a/src/vorta/borg/create.py +++ b/src/vorta/borg/create.py @@ -53,7 +53,7 @@ def process_result(self, result): self.app.backup_log_event.emit('', {}) self.app.backup_progress_event.emit(f"[{self.params['profile_name']}] {self.tr('Backup finished.')}") - def progress_event(self, fmt=None): + def progress_event(self, fmt): self.app.backup_progress_event.emit(f"[{self.params['profile_name']}] {fmt}") def started_event(self): diff --git a/src/vorta/scheduler.py b/src/vorta/scheduler.py index 01aada310..805468a3e 100644 --- a/src/vorta/scheduler.py +++ b/src/vorta/scheduler.py @@ -19,7 +19,7 @@ from vorta.i18n import translate from vorta.notifications import VortaNotifications from vorta.store.models import BackupProfileModel, EventLogModel -from vorta.utils import borg_compat +from vorta.utils import borg_compat, AsyncRunner logger = logging.getLogger(__name__) @@ -301,7 +301,7 @@ def set_timer_for_profile(self, profile_id: int): profile.name, profile_id, ) - threading.Thread(target=self.create_backup, args=(profile_id,)).start() + self.create_backup(profile_id) finally: self.lock.acquire() # with-statement will try to release @@ -337,7 +337,7 @@ def set_timer_for_profile(self, profile_id: int): timer = QTimer() timer.setSingleShot(True) timer.setInterval(int(timer_ms)) - timer.timeout.connect(lambda: threading.Thread(target=self.create_backup, args=(profile_id,)).start()) + timer.timeout.connect(lambda: self.create_backup(profile_id)) timer.start() self.timers[profile_id] = { @@ -389,6 +389,7 @@ def next_job_for_profile(self, profile_id: int) -> ScheduleStatus: return ScheduleStatus(ScheduleStatusType.UNSCHEDULED) return ScheduleStatus(job['type'], time=job.get('dt')) + @AsyncRunner def create_backup(self, profile_id): notifier = VortaNotifications.pick() profile = BackupProfileModel.get_or_none(id=profile_id) diff --git a/src/vorta/utils.py b/src/vorta/utils.py index fbe1e43fd..d9e3907cd 100644 --- a/src/vorta/utils.py +++ b/src/vorta/utils.py @@ -14,7 +14,7 @@ import psutil from PyQt6 import QtCore -from PyQt6.QtCore import QFileInfo, QThread, pyqtSignal +from PyQt6.QtCore import QFileInfo, QObject, QThread, pyqtSignal from PyQt6.QtWidgets import QApplication, QFileDialog, QSystemTrayIcon from vorta.borg._compatibility import BorgCompatibility @@ -31,6 +31,51 @@ _network_status_monitor = None +class AsyncRunner(QObject): + ''' + Wrapper to run functions asynchronously from GUI thread, based on + https://gist.github.com/andgineer/026a617528c5740da24ec984ac282ee6#file-universal_decorator-py + + NB Only apply it to void functions, otherwise return values will be lost. + ''' + runner_thread = None + + def __init__(self, orig_func): + super(AsyncRunner, self).__init__() + self.orig_func = orig_func + self.__name__ = "AsyncRunner" + + def __call__(self, *args): + return self.orig_func(*args) + + def __get__(self, wrapped_instance, owner): + return AsyncRunner.Helper(self, wrapped_instance) + + class Helper(QObject): + def __init__(self, decorator_instance, wrapped_instance): + super(AsyncRunner.Helper, self).__init__() + self.decorator_instance = decorator_instance + self.wrapped_instance = wrapped_instance + + def __call__(self, *args, **kwargs): + global runner_thread + runner_thread = AsyncRunner.Runner(self.decorator_instance, self.wrapped_instance, *args, **kwargs) + runner_thread.start() + + class Runner(QtCore.QThread): + def __init__(self, decorator_instance, wrapped_instance, *args, **kwargs): + QtCore.QThread.__init__(self) + self.decorator_instance = decorator_instance + self.wrapped_instance = wrapped_instance + self.args = args + self.kwargs = kwargs + + def run(self): + self.decorator_instance(self.wrapped_instance, *self.args, **self.kwargs) + self.terminate() + self.wait() + + class FilePathInfoAsync(QThread): signal = pyqtSignal(str, str, str) diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index 19aac78da..e9b395a75 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -88,7 +88,7 @@ def __init__(self, parent=None): self.miscTab.refresh_archive.connect(self.archiveTab.populate_from_profile) self.miscButton.clicked.connect(self.toggle_misc_visibility) - self.createStartBtn.clicked.connect(self.app.create_backup_event.emit) + self.createStartBtn.clicked.connect(self.app.create_backup_action) self.cancelButton.clicked.connect(self.app.backup_cancelled_event.emit) QShortcut(QKeySequence("Ctrl+W"), self).activated.connect(self.on_close_window) From 420be650c040e8cd6c9f560ffb7ecad412098b5b Mon Sep 17 00:00:00 2001 From: mycir <113249299+mycir@users.noreply.github.com> Date: Fri, 22 Nov 2024 10:40:49 +0000 Subject: [PATCH 3/4] Fixes #2120 (ammended) --- src/vorta/utils.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/vorta/utils.py b/src/vorta/utils.py index d9e3907cd..92c6c37c8 100644 --- a/src/vorta/utils.py +++ b/src/vorta/utils.py @@ -58,9 +58,13 @@ def __init__(self, decorator_instance, wrapped_instance): self.wrapped_instance = wrapped_instance def __call__(self, *args, **kwargs): - global runner_thread - runner_thread = AsyncRunner.Runner(self.decorator_instance, self.wrapped_instance, *args, **kwargs) - runner_thread.start() + self.runner = AsyncRunner.Runner(self.decorator_instance, self.wrapped_instance, *args, **kwargs) + self.runner.finished.connect(self.runner_finished) + self.runner.start() + + def runner_finished(self): + self.runner.wait(100) + self.runner = None class Runner(QtCore.QThread): def __init__(self, decorator_instance, wrapped_instance, *args, **kwargs): @@ -72,8 +76,11 @@ def __init__(self, decorator_instance, wrapped_instance, *args, **kwargs): def run(self): self.decorator_instance(self.wrapped_instance, *self.args, **self.kwargs) +<<<<<<< HEAD self.terminate() self.wait() +======= +>>>>>>> d7d1488 (Fixes #2120 (ammended)) class FilePathInfoAsync(QThread): From 5ade6ff5c5d6ca5b5f3e376684925d7fb16672a4 Mon Sep 17 00:00:00 2001 From: mycir <113249299+mycir@users.noreply.github.com> Date: Mon, 16 Dec 2024 14:40:16 +0000 Subject: [PATCH 4/4] Fixes #2120 (remove conflict markers) --- src/vorta/utils.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/vorta/utils.py b/src/vorta/utils.py index 92c6c37c8..da17092da 100644 --- a/src/vorta/utils.py +++ b/src/vorta/utils.py @@ -76,11 +76,6 @@ def __init__(self, decorator_instance, wrapped_instance, *args, **kwargs): def run(self): self.decorator_instance(self.wrapped_instance, *self.args, **self.kwargs) -<<<<<<< HEAD - self.terminate() - self.wait() -======= ->>>>>>> d7d1488 (Fixes #2120 (ammended)) class FilePathInfoAsync(QThread):