From 504be709347104c0bb08e77b5fc9635acb9ded66 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 16 Mar 2024 17:59:44 +0000 Subject: [PATCH] Avoid changing GUI in QThread --- src/vorta/views/archive_tab.py | 82 +++++++++-- src/vorta/views/main_window.py | 4 +- src/vorta/views/schedule_tab.py | 36 ++--- .../views/workers/archive_table_worker.py | 134 ------------------ .../views/workers/mount_points_worker.py | 73 ++++++++++ src/vorta/views/workers/wifi_list_worker.py | 79 +++++------ 6 files changed, 200 insertions(+), 208 deletions(-) delete mode 100644 src/vorta/views/workers/archive_table_worker.py create mode 100644 src/vorta/views/workers/mount_points_worker.py diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index 6938265e3..696e92dd4 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -1,6 +1,7 @@ import logging import sys from typing import Dict, Optional +from datetime import timedelta from PyQt6 import QtCore, uic from PyQt6.QtCore import QItemSelectionModel, QMimeData, QPoint, Qt, pyqtSlot @@ -36,13 +37,15 @@ borg_compat, choose_file_dialog, format_archive_name, - get_asset + get_asset, + pretty_bytes, + find_best_unit_for_sizes ) from vorta.views import diff_result, extract_dialog from vorta.views.diff_result import DiffResultDialog, DiffTree from vorta.views.extract_dialog import ExtractDialog, ExtractTree -from vorta.views.utils import get_colored_icon -from vorta.views.workers.archive_table_worker import PopulateArchiveTableAsync +from vorta.views.utils import get_colored_icon, SizeItem +from vorta.views.workers.mount_points_worker import MountPointsWorker uifile = get_asset('UI/archivetab.ui') ArchiveTabUI, ArchiveTabBase = uic.loadUiType(uifile) @@ -234,23 +237,85 @@ def _toggle_all_buttons(self, enabled=True): # Restore states self.on_selection_change() + def set_mount_points(self, mount_points, repo_mounts): + if len(repo_mounts) == 0: + return + + archives = [s for s in self.profile().repo.archives.select().order_by(ArchiveModel.time.desc())] + + # if no archive's name can be found in self.mount_points, then hide the mount point column + if not any(a.name in mount_points for a in archives): + return + else: + self.archiveTable.showColumn(3) + self.repo_mount_point = repo_mounts[0] + + for row, archive in enumerate(archives): + mount_point = self.mount_points.get(archive.name) + if mount_point is not None: + item = QTableWidgetItem(mount_point) + self.archiveTable.setItem(row, 3, item) + def populate_from_profile(self): """Populate archive list and prune settings from profile.""" + self.archiveTable.blockSignals(True) profile = self.profile() if profile.repo is not None: - if profile.repo.name: repo_name = f"{profile.repo.name} ({profile.repo.url})" else: repo_name = profile.repo.url self.toolBox.setItemText(0, self.tr('Archives for {}').format(repo_name)) - populateArchiveTableWorker = PopulateArchiveTableAsync(profile, self.mount_points, self.archiveTable) + populateArchiveTableWorker = MountPointsWorker(profile.repo.url) self.workers.append(populateArchiveTableWorker) # preserve worker reference + populateArchiveTableWorker.signal.connect(self.set_mount_points) populateArchiveTableWorker.start() + self.archiveTable.hideColumn(3) + + archives = [s for s in profile.repo.archives.select().order_by(ArchiveModel.time.desc())] + + sorting = self.archiveTable.isSortingEnabled() + self.archiveTable.setSortingEnabled(False) + best_unit = find_best_unit_for_sizes((a.size for a in archives), precision=SIZE_DECIMAL_DIGITS) + for row, archive in enumerate(archives): + self.archiveTable.insertRow(row) + + formatted_time = archive.time.strftime('%Y-%m-%d %H:%M') + self.archiveTable.setItem(row, 0, QTableWidgetItem(formatted_time)) - if self.remaining_refresh_archives == 0: - self._toggle_all_buttons(enabled=True) + # format units based on user settings for 'dynamic' or 'fixed' units + fixed_unit = best_unit if SettingsModel.get(key='enable_fixed_units').value else None + size = pretty_bytes(archive.size, fixed_unit=fixed_unit, precision=SIZE_DECIMAL_DIGITS) + self.archiveTable.setItem(row, 1, SizeItem(size)) + + if archive.duration is not None: + formatted_duration = str(timedelta(seconds=round(archive.duration))) + else: + formatted_duration = '' + + self.archiveTable.setItem(row, 2, QTableWidgetItem(formatted_duration)) + self.archiveTable.setItem(row, 4, QTableWidgetItem(archive.name)) + + if archive.trigger == 'scheduled': + item = QTableWidgetItem(get_colored_icon('clock-o'), '') + item.setToolTip(self.tr('Scheduled')) + self.archiveTable.setItem(row, 5, item) + elif archive.trigger == 'user': + item = QTableWidgetItem(get_colored_icon('user'), '') + item.setToolTip(self.tr('User initiated')) + item.setTextAlignment(Qt.AlignmentFlag.AlignRight) + self.archiveTable.setItem(row, 5, item) + + self.archiveTable.setRowCount(len(archives)) + self.archiveTable.setSortingEnabled(sorting) + item = self.archiveTable.item(0, 0) + self.archiveTable.scrollToItem(item) + + self.archiveTable.selectionModel().clearSelection() + + if self.remaining_refresh_archives == 0: + self._toggle_all_buttons(enabled=True) else: self.mount_points = {} self.archiveTable.setRowCount(0) @@ -261,13 +326,14 @@ def populate_from_profile(self): self.prunePrefixTemplate.setText(profile.prune_prefix) # Populate pruning options from database - profile = self.profile() for i in self.prune_intervals: getattr(self, f'prune_{i}').setValue(getattr(profile, f'prune_{i}')) getattr(self, f'prune_{i}').valueChanged.connect(self.save_prune_setting) self.prune_keep_within.setText(profile.prune_keep_within) self.prune_keep_within.editingFinished.connect(self.save_prune_setting) + self.archiveTable.blockSignals(False) + def on_selection_change(self, selected=None, deselected=None): """ React to a change of the selection of the archiveTableView. diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index 24b3350c2..ba8466fa8 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -183,11 +183,11 @@ def profile_selection_changed_action(self, index): logger.info('step 1') self.archiveTab.populate_from_profile() logger.info('step 2') - self.repoTab.populate_from_profile() # 1s + self.repoTab.populate_from_profile() logger.info('step 3') self.sourceTab.populate_from_profile() logger.info('step 4') - self.scheduleTab.populate_from_profile() #1s + self.scheduleTab.populate_from_profile() logger.info('step 5') SettingsModel.update({SettingsModel.str_value: self.current_profile.id}).where( SettingsModel.key == 'previous_profile_id' diff --git a/src/vorta/views/schedule_tab.py b/src/vorta/views/schedule_tab.py index fd0f19bfa..898dc47a6 100644 --- a/src/vorta/views/schedule_tab.py +++ b/src/vorta/views/schedule_tab.py @@ -13,7 +13,7 @@ from vorta.scheduler import ScheduleStatusType from vorta.store.models import BackupProfileMixin, EventLogModel, WifiSettingModel from vorta.utils import get_asset -from vorta.views.workers.wifi_list_worker import PopulateWifiAsync +from vorta.views.workers.wifi_list_worker import WifiListWorker from vorta.views.utils import get_colored_icon uifile = get_asset('UI/scheduletab.ui') @@ -173,12 +173,27 @@ def populate_from_profile(self): else: self.createCmdLineEdit.setEnabled(False) - populateWifiWorker = PopulateWifiAsync(profile, self.wifiListWidget) - self.workers.append(populateWifiWorker) # preserve reference - populateWifiWorker.start() + wifiListWorker = WifiListWorker(profile.id) + self.workers.append(wifiListWorker) # preserve reference + wifiListWorker.signal.connect(self.set_wifi_list) + wifiListWorker.start() + self.populate_logs() self.draw_next_scheduled_backup() + def set_wifi_list(self, wifi_list): + self.wifiListWidget.clear() + for wifi in wifi_list: + item = QListWidgetItem() + item.setText(wifi.ssid) + item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + if wifi.allowed: + item.setCheckState(QtCore.Qt.CheckState.Checked) + else: + item.setCheckState(QtCore.Qt.CheckState.Unchecked) + self.wifiListWidget.addItem(item) + + def draw_next_scheduled_backup(self): status = self.app.scheduler.next_job_for_profile(self.profile().id) if status.type in ( @@ -195,19 +210,6 @@ def draw_next_scheduled_backup(self): self.nextBackupDateTimeLabel.setText(text) self.nextBackupDateTimeLabel.repaint() - # def populate_wifi(self): - # self.wifiListWidget.clear() - # for wifi in get_sorted_wifis(self.profile()): - # item = QListWidgetItem() - # item.setText(wifi.ssid) - # item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) - # if wifi.allowed: - # item.setCheckState(QtCore.Qt.CheckState.Checked) - # else: - # item.setCheckState(QtCore.Qt.CheckState.Unchecked) - # self.wifiListWidget.addItem(item) - # self.wifiListWidget.itemChanged.connect(self.save_wifi_item) - def save_wifi_item(self, item): db_item = WifiSettingModel.get(ssid=item.text(), profile=self.profile().id) db_item.allowed = item.checkState() == Qt.CheckState.Checked diff --git a/src/vorta/views/workers/archive_table_worker.py b/src/vorta/views/workers/archive_table_worker.py deleted file mode 100644 index a918f7cf6..000000000 --- a/src/vorta/views/workers/archive_table_worker.py +++ /dev/null @@ -1,134 +0,0 @@ - -from datetime import timedelta - -import psutil -from PyQt6.QtCore import QThread, Qt -from PyQt6.QtWidgets import QTableWidgetItem - -from vorta.store.models import ArchiveModel, SettingsModel -from vorta.views.utils import get_colored_icon, SizeItem -from vorta.utils import borg_compat, pretty_bytes, find_best_unit_for_sizes, SHELL_PATTERN_ELEMENT - -SIZE_DECIMAL_DIGITS = 1 - - -class PopulateArchiveTableAsync(QThread): - def __init__(self, profile, mount_points, archiveTable): - QThread.__init__(self) - self.profile = profile - self.mount_points = mount_points - self.archiveTable = archiveTable - - def run(self): - # get mount points - self.mount_points, repo_mount_points = get_mount_points(self.profile.repo.url) - if repo_mount_points: - self.repo_mount_point = repo_mount_points[0] - - archives = [s for s in self.profile.repo.archives.select().order_by(ArchiveModel.time.desc())] - - # if no archive's name can be found in self.mount_points, then hide the mount point column - if not any(a.name in self.mount_points for a in archives): - self.archiveTable.hideColumn(3) - else: - self.archiveTable.showColumn(3) - - sorting = self.archiveTable.isSortingEnabled() - self.archiveTable.setSortingEnabled(False) - best_unit = find_best_unit_for_sizes((a.size for a in archives), precision=SIZE_DECIMAL_DIGITS) - for row, archive in enumerate(archives): - self.archiveTable.insertRow(row) - - formatted_time = archive.time.strftime('%Y-%m-%d %H:%M') - self.archiveTable.setItem(row, 0, QTableWidgetItem(formatted_time)) - - # format units based on user settings for 'dynamic' or 'fixed' units - fixed_unit = best_unit if SettingsModel.get(key='enable_fixed_units').value else None - size = pretty_bytes(archive.size, fixed_unit=fixed_unit, precision=SIZE_DECIMAL_DIGITS) - self.archiveTable.setItem(row, 1, SizeItem(size)) - - if archive.duration is not None: - formatted_duration = str(timedelta(seconds=round(archive.duration))) - else: - formatted_duration = '' - - self.archiveTable.setItem(row, 2, QTableWidgetItem(formatted_duration)) - - mount_point = self.mount_points.get(archive.name) - if mount_point is not None: - item = QTableWidgetItem(mount_point) - self.archiveTable.setItem(row, 3, item) - - self.archiveTable.setItem(row, 4, QTableWidgetItem(archive.name)) - - if archive.trigger == 'scheduled': - item = QTableWidgetItem(get_colored_icon('clock-o'), '') - item.setToolTip(self.tr('Scheduled')) - self.archiveTable.setItem(row, 5, item) - elif archive.trigger == 'user': - item = QTableWidgetItem(get_colored_icon('user'), '') - item.setToolTip(self.tr('User initiated')) - item.setTextAlignment(Qt.AlignmentFlag.AlignRight) - self.archiveTable.setItem(row, 5, item) - - self.archiveTable.setRowCount(len(archives)) - self.archiveTable.setSortingEnabled(sorting) - item = self.archiveTable.item(0, 0) - self.archiveTable.scrollToItem(item) - - self.archiveTable.selectionModel().clearSelection() - - -def get_mount_points(repo_url): - mount_points = {} - repo_mounts = [] - for proc in psutil.process_iter(): - try: - name = proc.name() - if name == 'borg' or name.startswith('python'): - if 'mount' not in proc.cmdline(): - continue - - if borg_compat.check('V2'): - # command line syntax: - # `borg mount -r (-a )` - cmd = proc.cmdline() - if repo_url in cmd: - i = cmd.index(repo_url) - if len(cmd) > i + 1: - mount_point = cmd[i + 1] - - # Archive mount? - ao = '-a' in cmd - if ao or '--match-archives' in cmd: - i = cmd.index('-a' if ao else '--match-archives') - if len(cmd) >= i + 1 and not SHELL_PATTERN_ELEMENT.search(cmd[i + 1]): - mount_points[mount_point] = cmd[i + 1] - else: - repo_mounts.append(mount_point) - else: - for idx, parameter in enumerate(proc.cmdline()): - if parameter.startswith(repo_url): - # mount from this repo - - # The borg mount command specifies that the mount_point - # parameter comes after the archive name - if len(proc.cmdline()) > idx + 1: - mount_point = proc.cmdline()[idx + 1] - - # archive or full mount? - if parameter[len(repo_url) :].startswith('::'): - archive_name = parameter[len(repo_url) + 2 :] - mount_points[archive_name] = mount_point - break - else: - # repo mount point - repo_mounts.append(mount_point) - - except (psutil.ZombieProcess, psutil.AccessDenied, psutil.NoSuchProcess): - # Getting process details may fail (e.g. zombie process on macOS) - # or because the process is owned by another user. - # Also see https://github.com/giampaolo/psutil/issues/783 - continue - - return mount_points, repo_mounts diff --git a/src/vorta/views/workers/mount_points_worker.py b/src/vorta/views/workers/mount_points_worker.py new file mode 100644 index 000000000..9d88bc371 --- /dev/null +++ b/src/vorta/views/workers/mount_points_worker.py @@ -0,0 +1,73 @@ + + +import psutil +from PyQt6.QtCore import QThread, Qt, pyqtSignal +from PyQt6.QtWidgets import QTableWidgetItem + +from vorta.store.models import ArchiveModel, SettingsModel +from vorta.views.utils import get_colored_icon, SizeItem +from vorta.utils import borg_compat, pretty_bytes, find_best_unit_for_sizes, SHELL_PATTERN_ELEMENT + +SIZE_DECIMAL_DIGITS = 1 + + +class MountPointsWorker(QThread): + signal = pyqtSignal(dict, list) + + def __init__(self, repo_url): + QThread.__init__(self) + self.repo_url = repo_url + + def run(self): + mount_points = {} + repo_mounts = [] + for proc in psutil.process_iter(): + try: + name = proc.name() + if name == 'borg' or name.startswith('python'): + if 'mount' not in proc.cmdline(): + continue + + if borg_compat.check('V2'): + # command line syntax: + # `borg mount -r (-a )` + cmd = proc.cmdline() + if self.repo_url in cmd: + i = cmd.index(self.repo_url) + if len(cmd) > i + 1: + mount_point = cmd[i + 1] + + # Archive mount? + ao = '-a' in cmd + if ao or '--match-archives' in cmd: + i = cmd.index('-a' if ao else '--match-archives') + if len(cmd) >= i + 1 and not SHELL_PATTERN_ELEMENT.search(cmd[i + 1]): + mount_points[mount_point] = cmd[i + 1] + else: + repo_mounts.append(mount_point) + else: + for idx, parameter in enumerate(proc.cmdline()): + if parameter.startswith(self.repo_url): + # mount from this repo + + # The borg mount command specifies that the mount_point + # parameter comes after the archive name + if len(proc.cmdline()) > idx + 1: + mount_point = proc.cmdline()[idx + 1] + + # archive or full mount? + if parameter[len(self.repo_url) :].startswith('::'): + archive_name = parameter[len(self.repo_url) + 2 :] + mount_points[archive_name] = mount_point + break + else: + # repo mount point + repo_mounts.append(mount_point) + + except (psutil.ZombieProcess, psutil.AccessDenied, psutil.NoSuchProcess): + # Getting process details may fail (e.g. zombie process on macOS) + # or because the process is owned by another user. + # Also see https://github.com/giampaolo/psutil/issues/783 + continue + + self.signal.emit(mount_points, repo_mounts) diff --git a/src/vorta/views/workers/wifi_list_worker.py b/src/vorta/views/workers/wifi_list_worker.py index 8c19f9427..b94afbe3e 100644 --- a/src/vorta/views/workers/wifi_list_worker.py +++ b/src/vorta/views/workers/wifi_list_worker.py @@ -1,59 +1,44 @@ import logging -from PyQt6 import QtCore -from PyQt6.QtCore import QThread, pyqtSignal, Qt -from PyQt6.QtWidgets import QListWidgetItem +from PyQt6.QtCore import QThread, pyqtSignal from vorta.utils import get_network_status_monitor from vorta.store.models import WifiSettingModel logger = logging.getLogger(__name__) -class PopulateWifiAsync(QThread): - def __init__(self, profile, wifiListWidget): +class WifiListWorker(QThread): + signal = pyqtSignal(list) + + def __init__(self, profile_id): QThread.__init__(self) - self.profile = profile - self.wifiListWidget = wifiListWidget + self.profile_id = profile_id def run(self): - self.wifiListWidget.clear() - for wifi in get_sorted_wifis(self.profile): - item = QListWidgetItem() - item.setText(wifi.ssid) - item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) - if wifi.allowed: - item.setCheckState(QtCore.Qt.CheckState.Checked) - else: - item.setCheckState(QtCore.Qt.CheckState.Unchecked) - self.wifiListWidget.addItem(item) - - -def get_sorted_wifis(profile): - """ - Get Wifi networks known to the OS (only current one on macOS) and - merge with networks from other profiles. Update last connected time. - """ - - - # Pull networks known to OS and all other backup profiles - system_wifis = get_network_status_monitor().get_known_wifis() - from_other_profiles = WifiSettingModel.select().where(WifiSettingModel.profile != profile.id).execute() - - for wifi in list(from_other_profiles) + system_wifis: - db_wifi, created = WifiSettingModel.get_or_create( - ssid=wifi.ssid, - profile=profile.id, - defaults={'last_connected': wifi.last_connected, 'allowed': True}, + """ + Get Wifi networks known to the OS (only current one on macOS) and + merge with networks from other profiles. Update last connected time. + """ + + # Pull networks known to OS and all other backup profiles + system_wifis = get_network_status_monitor().get_known_wifis() + from_other_profiles = WifiSettingModel.select().where(WifiSettingModel.profile != self.profile_id).execute() + + for wifi in list(from_other_profiles) + system_wifis: + db_wifi, created = WifiSettingModel.get_or_create( + ssid=wifi.ssid, + profile=self.profile_id, + defaults={'last_connected': wifi.last_connected, 'allowed': True}, + ) + + # Update last connected time + if not created and db_wifi.last_connected != wifi.last_connected: + db_wifi.last_connected = wifi.last_connected + db_wifi.save() + + # Finally return list of networks and settings for that profile + self.signal.emit( + WifiSettingModel.select() + .where(WifiSettingModel.profile == self.profile_id) + .order_by(-WifiSettingModel.last_connected) ) - - # Update last connected time - if not created and db_wifi.last_connected != wifi.last_connected: - db_wifi.last_connected = wifi.last_connected - db_wifi.save() - - # Finally return list of networks and settings for that profile - return ( - WifiSettingModel.select() - .where(WifiSettingModel.profile == profile.id) - .order_by(-WifiSettingModel.last_connected) - )