Improve the extract and diff dialog. By @real-yfprojects (#1219)

This commit is contained in:
Manu 2022-07-25 22:16:24 +02:00 committed by GitHub
commit d8e4a93cdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 8760 additions and 452 deletions

View File

@ -3,9 +3,10 @@ import signal
import sys
from peewee import SqliteDatabase
from vorta._version import __version__
from vorta.i18n import trans_late, translate
from vorta.config import SETTINGS_DIR
from vorta.i18n import trans_late, translate
from vorta.log import init_logger, logger
from vorta.store.connection import init_db
from vorta.updater import get_updater
@ -15,6 +16,7 @@ from vorta.utils import parse_args
def main():
def exception_handler(type, value, tb):
from traceback import format_exception
from PyQt5.QtWidgets import QMessageBox
logger.critical("Uncaught exception, file a report at https://github.com/borgbase/vorta/issues/new",
exc_info=(type, value, tb))

View File

@ -88,8 +88,6 @@ class VortaApp(QtSingleApplication):
if sys.platform == 'darwin':
self.check_darwin_permissions()
self.installEventFilter(self)
def create_backups_cmdline(self, profile_name):
profile = BackupProfileModel.get_or_none(name=profile_name)
if profile is not None:
@ -99,17 +97,6 @@ class VortaApp(QtSingleApplication):
else:
logger.warning(f"Invalid profile name {profile_name}")
def eventFilter(self, source, event):
if event.type() == QtCore.QEvent.ApplicationPaletteChange and isinstance(source, MainWindow):
self.main_window.set_icons()
self.main_window.repoTab.set_icons()
self.main_window.archiveTab.set_icons()
self.main_window.scheduleTab.set_icons()
self.main_window.sourceTab.set_icons()
if event.type() == QtCore.QEvent.ApplicationPaletteChange and source == self.tray.contextMenu():
self.tray.set_tray_icon()
return False
def quit_app_action(self):
self.backup_cancelled_event.emit()
del self.main_window

View File

@ -16,24 +16,14 @@
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="spacing">
<number>4</number>
</property>
<property name="topMargin">
<number>10</number>
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Difference between</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="archiveNameLabel_1">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>nyx2.local-2018-11-16T09:49:58 </string>
</property>
@ -42,18 +32,12 @@
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>and</string>
<string></string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="archiveNameLabel_2">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>nyx2.local-2018-10-16T09:49:58 </string>
</property>
@ -72,6 +56,63 @@
</property>
</spacer>
</item>
<item>
<widget class="QToolButton" name="bFoldersOnTop">
<property name="focusPolicy">
<enum>Qt::NoFocus</enum>
</property>
<property name="toolTip">
<string>Keep folders on top when sorting</string>
</property>
<property name="text">
<string>Folders On Top</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBoxDisplayMode">
<property name="toolTip">
<string>Set display mode of diff view</string>
</property>
<property name="sizeAdjustPolicy">
<enum>QComboBox::AdjustToContents</enum>
</property>
<item>
<property name="text">
<string>Tree</string>
</property>
</item>
<item>
<property name="text">
<string>Tree, simplified</string>
</property>
</item>
<item>
<property name="text">
<string>Flat</string>
</property>
</item>
</widget>
</item>
<item>
<widget class="QToolButton" name="bCollapseAll">
<property name="focusPolicy">
<enum>Qt::NoFocus</enum>
</property>
<property name="toolTip">
<string>Collapse All</string>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
@ -83,22 +124,9 @@
<number>10</number>
</property>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="okButton">
<property name="text">
<string>Ok</string>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Close</set>
</property>
</widget>
</item>
@ -106,6 +134,10 @@
</item>
</layout>
</widget>
<tabstops>
<tabstop>treeView</tabstop>
<tabstop>comboBoxDisplayMode</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>

View File

@ -52,16 +52,65 @@
</property>
</spacer>
</item>
<item>
<widget class="QToolButton" name="bFoldersOnTop">
<property name="focusPolicy">
<enum>Qt::NoFocus</enum>
</property>
<property name="toolTip">
<string>Keep folders on top when sorting</string>
</property>
<property name="text">
<string>Folders On Top</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBoxDisplayMode">
<property name="toolTip">
<string>Set display mode of diff view</string>
</property>
<property name="sizeAdjustPolicy">
<enum>QComboBox::AdjustToContents</enum>
</property>
<item>
<property name="text">
<string>Tree</string>
</property>
</item>
<item>
<property name="text">
<string>Tree, simplified</string>
</property>
</item>
</widget>
</item>
<item>
<widget class="QToolButton" name="bCollapseAll">
<property name="focusPolicy">
<enum>Qt::NoFocus</enum>
</property>
<property name="toolTip">
<string>Collapse All</string>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTreeView" name="treeView"/>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Note: If you select a top-level folder and deselect its children, they will still be restored.</string>
</property>
<widget class="QTreeView" name="treeView">
<attribute name="headerMinimumSectionSize">
<number>10</number>
</attribute>
</widget>
</item>
<item>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 512 512" version="1.1" id="svg3" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs7" />
<!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path d="m 255.985,368 c -8.188,0 -16.38,-3.125 -22.62,-9.375 l -160,-160 c -12.5,-12.5 -12.5,-32.75 0,-45.25 12.5,-12.5 32.75,-12.5 45.25,0 l 137.37,137.425 137.4,-137.4 c 12.5,-12.5 32.75,-12.5 45.25,0 12.5,12.5 12.5,32.75 0,45.25 l -160,160 c -6.25,6.25 -14.45,9.35 -22.65,9.35 z" id="path2" style="fill:#000000;fill-opacity:1;stroke:none" />
</svg>

After

Width:  |  Height:  |  Size: 735 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 512 512" version="1.1" id="svg3" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
<!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path fill="#000000" d="m 416.025,367.925 c -8.188,0 -16.38,-3.125 -22.62,-9.375 l -137.38,-137.325 -137.4,137.4 c -12.5,12.5 -32.75,12.5 -45.25,0 -12.5,-12.5 -12.5,-32.75 0,-45.25 l 160,-160 c 12.5,-12.5 32.75,-12.5 45.25,0 l 160,160 c 12.5,12.5 12.5,32.75 0,45.25 -6.2,6.2 -14.4,9.3 -22.6,9.3 z" id="path2" style="fill:#000000;fill-opacity:1;stroke:none;stroke-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 742 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path fill="#000000" d="M 4 4 L 4 28 L 18 28 L 18 27 L 5 27 L 5 14 L 10 14 L 13 11 L 27 11 L 27 16 L 28 16 L 28 7 L 18 7 L 15 4 L 4 4 z M 23 16 L 18 21 L 18.707031 21.707031 L 22.5 17.914062 L 22.5 28.003906 L 23.5 28.003906 L 23.5 17.914062 L 27.292969 21.707031 L 28 21 L 24 17 L 23 16 z " id="path71" class="ColorScheme-Text" />
</svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="#000000"
d="M 2 2 L 2 7 L 7 7 L 7 2 L 2 2 z M 8 2 L 8 3 L 14 3 L 14 2 L 8 2 z M 3 3 L 6 3 L 6 6 L 3 6 L 3 3 z M 8 5 L 8 6 L 14 6 L 14 5 L 8 5 z M 2 9 L 2 14 L 7 14 L 7 9 L 2 9 z M 8 9 L 8 10 L 12 10 L 12 9 L 8 9 z M 8 12 L 8 13 L 12 13 L 12 12 L 8 12 z "
class="ColorScheme-Text"
/>
</svg>

After

Width:  |  Height:  |  Size: 381 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="#000000"
d="M 2 2 L 2 7 L 6 7 L 6 12 L 7 12 L 9 12 L 9 14 L 14 14 L 14 9 L 9 9 L 9 11 L 7 11 L 7 7 L 7 5 L 9 5 L 9 7 L 14 7 L 14 2 L 9 2 L 9 4 L 7 4 L 7 2 L 2 2 z M 3 3 L 6 3 L 6 6 L 3 6 L 3 3 z M 10 3 L 13 3 L 13 6 L 10 6 L 10 3 z "
class="ColorScheme-Text"
/>
</svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@ -1,3 +1,10 @@
import tempfile
from PyQt5.QtCore import QModelIndex, Qt
from vorta.views.extract_dialog import ExtractTree, FileData
from vorta.views.partials.treemodel import FileSystemItem, path_to_str
from .borg_job import BorgJob
@ -13,7 +20,7 @@ class BorgExtractJob(BorgJob):
self.app.backup_progress_event.emit(self.tr('Restored files from archive.'))
@classmethod
def prepare(cls, profile, archive_name, selected_files, destination_folder):
def prepare(cls, profile, archive_name, model: ExtractTree, destination_folder):
ret = super().prepare(profile)
if not ret['ok']:
return ret
@ -22,8 +29,31 @@ class BorgExtractJob(BorgJob):
cmd = ['borg', 'extract', '--list', '--info', '--log-json']
cmd.append(f'{profile.repo.url}::{archive_name}')
for s in selected_files:
cmd.append(s)
# process selected items
# all items will be excluded beside the one actively selected in the
# dialog.
# Unselected (and excluded) parent folders will be restored by borg
# but without the metadata stored in the archive.
pattern_file = tempfile.NamedTemporaryFile('w', delete=False)
pattern_file.write("P fm\n")
indexes = [QModelIndex()]
while indexes:
index = indexes.pop()
for i in range(model.rowCount(index)):
new_index = model.index(i, 0, index)
indexes.append(new_index)
item: FileSystemItem[FileData] = new_index.internalPointer()
if item.data.checkstate == Qt.CheckState.Checked:
pattern_file.write("+ " + path_to_str(item.path) + "\n")
pattern_file.write("- *\n")
pattern_file.flush()
pattern_file.close() # wont delete temp file
cmd.extend(['--patterns-from', pattern_file.name])
ret['ok'] = True
ret['cmd'] = cmd
@ -31,5 +61,5 @@ class BorgExtractJob(BorgJob):
return ret
def process_result(self, result):
def process_result(self, result: dict):
pass

View File

@ -21,7 +21,9 @@ class BorgListArchiveJob(BorgJob):
ret['archive_name'] = archive_name
ret['cmd'] = [
'borg', 'list', '--info', '--log-json', '--json-lines',
'--format', "{size:8d}{TAB}{mtime}{TAB}{path}{NL}",
'--format',
# fields to include in json output
"{mode}{user}{group}{size}{mtime}{path}{source}{health}{NL}",
f'{profile.repo.url}::{archive_name}']
ret['ok'] = True

View File

@ -1,13 +1,14 @@
import os
from PyQt5.QtWidgets import QMenu, QSystemTrayIcon
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QApplication, QMenu, QSystemTrayIcon
from vorta.store.models import BackupProfileModel
from vorta.utils import get_asset
class TrayMenu(QSystemTrayIcon):
def __init__(self, parent=None):
def __init__(self, parent: QApplication = None):
QSystemTrayIcon.__init__(self, parent)
self.app = parent
self.set_tray_icon()
@ -19,6 +20,7 @@ class TrayMenu(QSystemTrayIcon):
self.setContextMenu(menu)
self.activated.connect(self.on_activation)
self.app.paletteChanged.connect(lambda p: self.set_tray_icon())
self.setVisible(True)
self.show()

View File

@ -26,8 +26,9 @@ from vorta.borg.umount import BorgUmountJob
from vorta.store.models import ArchiveModel, BackupProfileMixin
from vorta.utils import (choose_file_dialog, format_archive_name, get_asset,
get_mount_points, pretty_bytes)
from vorta.views.diff_result import DiffResult
from vorta.views.extract_dialog import ExtractDialog
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.source_tab import SizeItem
from vorta.views.utils import get_colored_icon
@ -118,6 +119,9 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
self.selected_archives = None
self.set_icons()
# Connect to palette change
self.app.paletteChanged.connect(lambda p: self.set_icons())
def set_icons(self):
"Used when changing between light- and dark mode"
self.bCheck.setIcon(get_colored_icon('check-circle'))
@ -682,6 +686,9 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
profile.save()
def extract_action(self):
"""
Open a dialog for choosing what to extract from the selected archive.
"""
profile = self.profile()
row_selected = self.archiveTable.selectionModel().selectedRows()
@ -706,35 +713,54 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
self._set_status(self.tr('Select an archive to restore first.'))
def extract_list_result(self, result):
"""Process the contents of the archive to extract."""
self._set_status('')
if result['returncode'] == 0:
def process_result():
def receive():
extraction_folder = dialog.selectedFiles()
if extraction_folder:
params = BorgExtractJob.prepare(
self.profile(), archive.name, window.selected, extraction_folder[0])
if params['ok']:
self._toggle_all_buttons(False)
job = BorgExtractJob(params['cmd'], params, self.profile().repo.id)
job.updated.connect(self.mountErrors.setText)
job.result.connect(self.extract_archive_result)
self.app.jobs_manager.add_job(job)
else:
self._set_status(params['message'])
dialog = choose_file_dialog(self, self.tr("Choose Extraction Point"), want_folder=True)
dialog.open(receive)
archive = ArchiveModel.get(name=result['params']['archive_name'])
window = ExtractDialog(result['data'], archive)
self._toggle_all_buttons(True)
window.setParent(self, QtCore.Qt.Sheet)
self._window = window # for testing
window.show()
window.accepted.connect(process_result)
model = ExtractTree()
self._set_status(self.tr("Processing archive contents"))
self._t = extract_dialog.ParseThread(result['data'],
model)
self._t.finished.connect(lambda: self.extract_show_dialog(archive,
model))
self._t.start()
def extract_show_dialog(self, archive, model):
"""Show the dialog for choosing the archive contents to extract."""
self._set_status('')
def process_result():
def receive():
extraction_folder = dialog.selectedFiles()
if extraction_folder:
params = BorgExtractJob.prepare(self.profile(),
archive.name,
model,
extraction_folder[0])
if params['ok']:
self._toggle_all_buttons(False)
job = BorgExtractJob(params['cmd'], params,
self.profile().repo.id)
job.updated.connect(self.mountErrors.setText)
job.result.connect(self.extract_archive_result)
self.app.jobs_manager.add_job(job)
else:
self._set_status(params['message'])
dialog = choose_file_dialog(self,
self.tr("Choose Extraction Point"),
want_folder=True)
dialog.open(receive)
window = ExtractDialog(archive, model)
self._toggle_all_buttons(True)
window.setParent(self, QtCore.Qt.Sheet)
self._window = window # for testing
window.show()
window.accepted.connect(process_result)
def extract_archive_result(self, result):
"""Finished extraction."""
self._toggle_all_buttons(True)
def cell_double_clicked(self, row, column):
@ -862,12 +888,29 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
name=result['params']['archive_name_newer'])
archive_older = ArchiveModel.get(
name=result['params']['archive_name_older'])
window = DiffResult(result['data'], archive_newer, archive_older,
result['params']['json_lines'])
self._toggle_all_buttons(True)
window.setParent(self, QtCore.Qt.Sheet)
self._resultwindow = window # for testing
window.show()
self._set_status(self.tr("Processing diff results."))
model = DiffTree()
self._t = diff_result.ParseThread(result['data'],
result['params']['json_lines'],
model)
self._t.finished.connect(lambda: self.show_diff_result(
archive_newer, archive_older, model))
self._t.start()
def show_diff_result(self, archive_newer, archive_older, model):
self._t = None
# show dialog
self._toggle_all_buttons(True)
self._set_status('')
window = DiffResultDialog(archive_newer, archive_older, model)
window.setParent(self)
window.setWindowFlags(Qt.WindowType.Window)
window.setWindowModality(Qt.WindowModality.NonModal)
self._resultwindow = window # for testing
window.show()
def rename_action(self):
profile = self.profile()

File diff suppressed because it is too large Load Diff

View File

@ -1,108 +1,688 @@
import enum
import json
import os
import logging
from dataclasses import dataclass
from datetime import datetime
from pathlib import PurePath
from typing import Optional
from PyQt5 import uic
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDialogButtonBox, QHeaderView, QPushButton
from PyQt5.QtCore import (QDateTime, QLocale, QMimeData, QModelIndex, QPoint,
Qt, QThread, QUrl)
from PyQt5.QtGui import QColor, QKeySequence
from PyQt5.QtWidgets import (QApplication, QDialogButtonBox, QHeaderView,
QMenu, QPushButton, QShortcut)
from vorta.utils import get_asset, get_dict_from_list, nested_dict
from vorta.views.partials.tree_view import TreeModel
from vorta.utils import get_asset, pretty_bytes, uses_dark_mode
from vorta.views.utils import get_colored_icon
from .partials.treemodel import (FileSystemItem, FileTreeModel,
FileTreeSortProxyModel, path_to_str,
relative_path)
uifile = get_asset("UI/extractdialog.ui")
ExtractDialogUI, ExtractDialogBase = uic.loadUiType(uifile)
logger = logging.getLogger(__name__)
class ParseThread(QThread):
"""A thread parsing diff results."""
def __init__(self, fs_data: str, model, parent=None):
"""Init."""
super().__init__(parent)
self.model = model
self.fs_data = fs_data
def run(self) -> None:
"""Do the work"""
# handle case of a single line of result, which will already be a dict
if isinstance(self.fs_data, dict):
lines = [self.fs_data]
else:
lines = [json.loads(line) for line in self.fs_data.split("\n") if line]
parse_json_lines(lines, self.model)
class ExtractDialog(ExtractDialogBase, ExtractDialogUI):
def __init__(self, fs_data, archive):
"""
Show the contents of an archive and allow choosing what to extract.
"""
def __init__(self, archive, model):
"""Init."""
super().__init__()
self.setupUi(self)
nested_file_list = nested_dict()
self.selected = set()
def parse_json_line(data):
size = data["size"]
# python >= 3.7
# modified = datetime.fromisoformat(data["mtime"]).ctime()
# python < 3.7
try:
modified = datetime.strptime(data["mtime"], "%Y-%m-%dT%H:%M:%S.%f").ctime()
except ValueError:
modified = datetime.strptime(data["mtime"], "%Y-%m-%dT%H:%M:%S").ctime()
dirpath, name = os.path.split(data["path"])
# add to nested dict of folders to find nested dirs.
d = get_dict_from_list(nested_file_list, dirpath.split("/"))
if name not in d:
d[name] = {}
return size, modified, name, dirpath, data["type"]
# handle case of a single line of result, which will already be a dict
lines = [fs_data] if isinstance(fs_data, dict) else \
[json.loads(line) for line in fs_data.split('\n') if line]
files_with_attributes = [parse_json_line(line) for line in lines]
model = ExtractTree(files_with_attributes, nested_file_list, self.selected)
self.model = model
self.model.setParent(self)
view = self.treeView
view.setAlternatingRowColors(True)
view.setUniformRowHeights(True) # Allows for scrolling optimizations.
view.setModel(model)
# custom context menu
self.treeView.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu)
self.treeView.customContextMenuRequested.connect(
self.treeview_context_menu)
# add sort proxy model
self.sortproxy = ExtractSortProxyModel(self)
self.sortproxy.setSourceModel(self.model)
view.setModel(self.sortproxy)
self.sortproxy.sorted.connect(self.slot_sorted)
view.setSortingEnabled(True)
# header
header = view.header()
header.setStretchLastSection(False)
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
header.setSectionResizeMode(0, QHeaderView.Stretch)
# shortcuts
shortcut_copy = QShortcut(QKeySequence.StandardKey.Copy, self.treeView)
shortcut_copy.activated.connect(self.copy_item)
# add extract button to button box
self.extractButton = QPushButton(self)
self.extractButton.setObjectName("extractButton")
self.extractButton.setText(self.tr("Extract"))
self.buttonBox.addButton(self.extractButton,
QDialogButtonBox.ButtonRole.AcceptRole)
self.buttonBox.addButton(
self.extractButton, QDialogButtonBox.ButtonRole.AcceptRole
)
self.archiveNameLabel.setText(f"{archive.name}, {archive.time}")
# connect signals
self.comboBoxDisplayMode.currentIndexChanged.connect(
self.change_display_mode)
self.bFoldersOnTop.toggled.connect(self.sortproxy.keepFoldersOnTop)
self.bCollapseAll.clicked.connect(self.treeView.collapseAll)
self.buttonBox.rejected.connect(self.close)
self.buttonBox.accepted.connect(self.accept)
self.set_icons()
# Connect to palette change
QApplication.instance().paletteChanged.connect(
lambda p: self.set_icons())
def retranslateUi(self, dialog):
"""Retranslate strings in ui."""
super().retranslateUi(dialog)
# setupUi calls retranslateUi
if hasattr(self, 'extractButton'):
if hasattr(self, "extractButton"):
self.extractButton.setText(self.tr("Extract"))
def set_icons(self):
"""Set or update the icons in the right color scheme."""
self.bFoldersOnTop.setIcon(get_colored_icon('folder-on-top'))
self.bCollapseAll.setIcon(get_colored_icon('angle-up-solid'))
self.comboBoxDisplayMode.setItemIcon(0, get_colored_icon("view-list-tree"))
self.comboBoxDisplayMode.setItemIcon(1, get_colored_icon("view-list-tree"))
class ExtractTree(TreeModel):
def __init__(
self,
files_with_attributes,
nested_file_list,
selected_files_folders,
parent=None,
):
super().__init__(
files_with_attributes, nested_file_list, selected_files_folders, parent
def slot_sorted(self, column, order):
"""React to the tree view being sorted."""
# reveal selection
selectedRows = self.treeView.selectionModel().selectedRows()
if selectedRows:
self.treeView.scrollTo(selectedRows[0])
def copy_item(self, index: QModelIndex = None):
"""
Copy an item path to the clipboard.
Copies the first selected item if no index is specified.
"""
if index is None or (not index.isValid()):
indexes = self.treeView.selectionModel().selectedRows()
if not indexes:
return
index = indexes[0]
index = self.sortproxy.mapToSource(index)
item: ExtractFileItem = index.internalPointer()
path = PurePath('/', *item.path)
data = QMimeData()
data.setUrls([QUrl(path.as_uri())])
data.setText(str(path))
QApplication.clipboard().setMimeData(data)
def change_display_mode(self, selection: int):
"""
Change the display mode of the tree view
The `selection` parameter specifies the index of the selected mode in
`comboBoxDisplayMode`.
"""
if selection == 0:
mode = FileTreeModel.DisplayMode.TREE
elif selection == 1:
mode = FileTreeModel.DisplayMode.SIMPLIFIED_TREE
else:
raise Exception(
"Unknown item in comboBoxDisplayMode with index {}".format(
selection))
self.model.setMode(mode)
def treeview_context_menu(self, pos: QPoint):
"""Display a context menu for `treeView`."""
index = self.treeView.indexAt(pos)
if not index.isValid():
# popup only for items
return
menu = QMenu(self.treeView)
menu.addAction(get_colored_icon('copy'), self.tr("Copy"),
lambda: self.copy_item(index))
if self.model.getMode() != self.model.DisplayMode.FLAT:
menu.addSeparator()
menu.addAction(get_colored_icon('angle-down-solid'),
self.tr("Expand recursively"),
lambda: self.treeView.expandRecursively(index))
menu.popup(self.treeView.viewport().mapToGlobal(pos))
def parse_json_lines(lines, model: "ExtractTree"):
"""Parse json output of `borg list`."""
for item in lines:
path = PurePath(item["path"])
size = item["size"]
mode = item["mode"]
file_type = FileType(mode[0])
user = item["user"]
group = item["group"]
health = item["healthy"]
source_path = item["source"] if "source" in item else None
# For python >= 3.7 this would work
# modified = datetime.fromisoformat(item["mtime"]).ctime()
# for python == 3.6 this must do the job
# try:
# modified = datetime.strptime(item["mtime"], "%Y-%m-%dT%H:%M:%S.%f")
# except ValueError:
# modified = datetime.strptime(item["mtime"], "%Y-%m-%dT%H:%M:%S")
modified = QDateTime.fromString(item["mtime"], Qt.DateFormat.ISODateWithMs)
model.addItem(
(
path,
FileData(
file_type, size, mode, user, group, health, modified, source_path
),
)
)
def data(self, index, role):
# ---- Sorting ---------------------------------------------------------------
class ExtractSortProxyModel(FileTreeSortProxyModel):
"""
Sort a ExtractTree model.
"""
def choose_data(self, index: QModelIndex):
"""Choose the data of index used for comparison."""
item: ExtractFileItem = index.internalPointer()
column = index.column()
if column == 0:
# file name
return self.extract_path(index)
elif column == 1:
return item.data.last_modified
elif column == 2:
return item.data.size
else:
return item.data.health
# ---- ExtractTree -----------------------------------------------------------
class FileType(enum.Enum):
"""File type of an item inside a borg archive."""
FILE = "-"
DIRECTORY = "d"
SYMBOLIC_LINK = "l"
LINK = SYMBOLIC_LINK
HARD_LINK = "h"
FIFO = "p"
SOCKET = "s"
CHRDEV = "c"
BLKDEV = "b"
@dataclass
class FileData:
"""The data linked to a item inside a borg archive."""
file_type: FileType
size: int
mode: str
user: str
group: str
health: bool
last_modified: QDateTime
source_path: Optional[str] = None # only relevant for links
checkstate: int = 0 # whether to extract the file (0, 1 or 2)
checked_children: int = 0 # number of children checked
ExtractFileItem = FileSystemItem[FileData]
class ExtractTree(FileTreeModel[FileData]):
"""The file tree model for diff results."""
def _make_filesystemitem(self, path, data):
return super()._make_filesystemitem(path, data)
def _merge_data(self, item, data):
if data:
logger.debug("Overriding data for {}".format(path_to_str(item.path)))
return super()._merge_data(item, data)
def _flat_filter(self, item):
"""
Return whether an item is part of the flat model representation.
The item's data might have not been set yet.
"""
return item.data and not item.children
def _simplify_filter(self, item: ExtractFileItem) -> bool:
"""
Return whether an item may be merged in simplified mode.
Allows simplification for every item.
"""
return True
def _process_child(self, child):
"""
Process a new child.
This can make some changes to the child's data like
setting a default value if the child's data is None.
This can also update the data of the parent.
This must emit `dataChanged` if data is changed.
Parameters
----------
child : FileSystemItem
The child that was added.
"""
parent = child._parent
if not child.data:
child.data = FileData(
FileType.DIRECTORY, 0, "", "", "", True, datetime.now()
)
if child.data.size != 0:
# update size
size = child.data.size
def add_size(parent):
if parent is self.root:
return
if parent.data is None:
raise Exception(
"Item {} without data".format(path_to_str(parent.path))
)
else:
parent.data.size += size
# update parent
parent = parent._parent
if parent:
add_size(parent)
add_size(parent)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
"""
Returns the number of columns for the children of the given parent.
This corresponds to the number of data (column) entries shown
for each item in the tree view.
Parameters
----------
parent : QModelIndex, optional
The index of the parent, by default QModelIndex()
Returns
-------
int
The number of rows.
"""
# name, last modified, size, health
return 4
def headerData(
self,
section: int,
orientation: Qt.Orientation,
role: int = Qt.ItemDataRole.DisplayRole,
):
"""
Get the data for the given role and section in the given header.
The header is identified by its orientation.
For horizontal headers, the section number corresponds to
the column number. Similarly, for vertical headers,
the section number corresponds to the row number.
Parameters
----------
section : int
The row or column number.
orientation : Qt.Orientation
The orientation of the header.
role : int, optional
The data role, by default Qt.ItemDataRole.DisplayRole
Returns
-------Improve
Any
The data for the specified header section.
"""
if (
orientation == Qt.Orientation.Horizontal
and role == Qt.ItemDataRole.DisplayRole
):
if section == 0:
return self.tr("Name")
elif section == 1:
return self.tr("Last Modified")
elif section == 2:
return self.tr("Size")
elif section == 3:
return self.tr("Health")
return None
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
"""
Get the data for the given role and index.
The indexes internal pointer references the corresponding
`FileSystemItem`.
Parameters
----------
index : QModelIndex
The index of the item.
role : int, optional
The data role, by default Qt.ItemDataRole.DisplayRole
Returns
-------
Any
The data, return None if no data is available for the role.
"""
if not index.isValid():
return None
item: ExtractFileItem = index.internalPointer()
column = index.column()
if role == Qt.ItemDataRole.DisplayRole:
if column == 0:
# name
if self.mode == self.DisplayMode.FLAT:
return path_to_str(item.path)
if self.mode == self.DisplayMode.SIMPLIFIED_TREE:
parent = index.parent()
if parent == QModelIndex():
return path_to_str(relative_path(self.root.path, item.path))
return path_to_str(
relative_path(parent.internalPointer().path, item.path)
)
# standard tree mode
return item.subpath
elif column == 1:
# last modified
return QLocale.system().toString(
item.data.last_modified, QLocale.FormatType.ShortFormat
)
elif column == 2:
# size
return pretty_bytes(item.data.size)
else:
# health
return
if role == Qt.ItemDataRole.BackgroundRole and column == 3:
# health indicator
if item.data.health:
return QColor(Qt.green) if uses_dark_mode() else QColor(Qt.darkGreen)
else:
return QColor(Qt.green) if uses_dark_mode() else QColor(Qt.darkGreen)
if role == Qt.ItemDataRole.ToolTipRole:
if column == 0:
# name column -> display fullpath
return path_to_str(item.path)
# info/data tooltip -> no real size limitation
tooltip_template = (
"{name}\n"
+ "\n"
+ "{filetype}\n"
+ "{permissions}\n"
+ "{user} {group}\n"
+ "Modified: {last_modified}\n"
+ "Health: {health}\n"
)
# format
if item.data.file_type == FileType.FILE:
filetype = self.tr("File")
elif item.data.file_type == FileType.DIRECTORY:
filetype = self.tr("Directory")
elif item.data.file_type == FileType.LINK:
filetype = self.tr("Symbolic link")
elif item.data.file_type == FileType.FIFO:
filetype = self.tr("FIFO pipe")
elif item.data.file_type == FileType.HARD_LINK:
filetype = self.tr("Hard link")
elif item.data.file_type == FileType.SOCKET:
filetype = self.tr("Socket")
elif item.data.file_type == FileType.BLKDEV:
filetype = self.tr("Block special file")
elif item.data.file_type == FileType.CHRDEV:
filetype = self.tr("Character special file")
else:
raise Exception("Unknown filetype {}".format(item.data.file_type))
modified = QLocale.system().toString(item.data.last_modified)
if item.data.health:
health = self.tr("healthy")
else:
health = self.tr("broken")
tooltip = tooltip_template.format(
name=item.path[-1],
filetype=filetype,
permissions=item.data.mode,
user=item.data.user,
group=item.data.group,
last_modified=modified,
health=health,
)
if item.data.source_path:
tooltip += self.tr("Linked to: {}").format(item.data.source_path)
return tooltip
if role == Qt.ItemDataRole.CheckStateRole and column == 0:
return item.data.checkstate
def setData(
self, index: QModelIndex, value, role: int = Qt.ItemDataRole.DisplayRole
) -> bool:
"""
Sets the role data for the item at index to value.
Returns true if successful; otherwise returns false.
The dataChanged() signal should be emitted if the data was
successfully set.
"""
if role != Qt.ItemDataRole.CheckStateRole:
return False
item: ExtractFileItem = index.internalPointer()
if value == item.data.checkstate:
return True
super_index = index.parent()
if super_index == QModelIndex():
super_item = self.root
else:
super_item: ExtractFileItem = super_index.internalPointer()
parent = item._parent
while parent != super_item:
if value == Qt.CheckState.Unchecked:
# must have been one of the others previously
parent.data.checked_children -= 1
elif item.data.checkstate == Qt.CheckState.Unchecked: # old value
# change from partially checked to checked
# or the other way around does not change this count
parent.data.checked_children += 1
if parent.data.checked_children:
parent.data.checkstate = Qt.CheckState.PartiallyChecked
else:
parent.data.checkstate = Qt.CheckState.Unchecked
parent = parent._parent
if super_index != QModelIndex():
if value == Qt.CheckState.Unchecked:
# must have been one of the others previously
super_item.data.checked_children -= 1
elif item.data.checkstate == Qt.CheckState.Unchecked:
# change from partially checked to checked
# or the other way around does not change this count
super_item.data.checked_children += 1
# update parent's state and possibly the parent's parent's state
if super_item.data.checked_children:
self.setData(super_index, Qt.CheckState.PartiallyChecked, role)
else:
self.setData(super_index, Qt.CheckState.Unchecked, role)
# update state of the children without changing their parents' states
if value != Qt.CheckState.PartiallyChecked:
self.set_checkstate_recursively(index, value)
# update this item's state
item.data.checkstate = value
self.dataChanged.emit(index, index, (role,))
return True
def set_checkstate_recursively(self, index: QModelIndex, value: Qt.CheckState):
"""
Set the checkstate of the children of an index recursively.
Parameters
----------
index : QModelIndex
The parent index to start with.
value : Qt.CheckState
The state to set.
"""
number_children = self.rowCount(index)
if not number_children:
return
index.internalPointer().data.checked_children = (
0 if value == Qt.CheckState.Unchecked else number_children
)
item = index.internalPointer()
for i in range(number_children):
child = index.child(i, 0)
child_item: ExtractFileItem = child.internalPointer()
child_item.data.checkstate = value
if role == Qt.DisplayRole:
return item.data(index.column())
elif role == Qt.CheckStateRole and index.column() == 0:
return item.getCheckedState()
else:
return None
# set state of hidden items
parent = child_item._parent
while parent != item:
# hidden parent must have 1 child
parent.data.checked_children = (
0 if value == Qt.CheckState.Unchecked
else self.rowCount(child)
)
parent.data.checkstate = value
def flags(self, index):
if not index.isValid():
return Qt.NoItemFlags
parent = parent._parent
return Qt.ItemIsEnabled | Qt.ItemIsUserCheckable
# set state of this child's children
self.set_checkstate_recursively(child, value)
self.dataChanged.emit(
index.child(0, 0),
index.child(0, number_children - 1),
(Qt.ItemDataRole.CheckStateRole,),
)
def flags(self, index: QModelIndex):
"""
Returns the item flags for the given index.
The base class implementation returns a combination of flags
that enables the item (ItemIsEnabled) and
allows it to be selected (ItemIsSelectable).
Parameters
----------
index : QModelIndex
The index.
Returns
-------
Qt.ItemFlags
The flags.
"""
flags = super().flags(index)
if index.column() == 0:
flags |= Qt.ItemFlag.ItemIsUserCheckable
return flags

View File

@ -0,0 +1,985 @@
"""
Implementation of a tree model for use with `QTreeView` based on (file) paths.
"""
import bisect
import enum
import os.path as osp
from functools import reduce
from pathlib import PurePath
from typing import (Generic, List, Optional, Sequence, Tuple, TypeVar, Union,
overload)
from PyQt5.QtCore import (QAbstractItemModel, QModelIndex, QObject,
QSortFilterProxyModel, Qt, pyqtSignal)
#: A representation of a path
Path = Tuple[str, ...]
PathLike = Union[Path, Sequence[str]]
def relative_path(p1: PathLike, p2: PathLike) -> Path:
"""Get p2 relative to p1."""
if len(p2) <= len(p1):
return ()
return tuple(p2[len(p1):])
def path_to_str(path: PathLike) -> str:
"""Return a string representation of a path."""
if not path:
return ''
return osp.join(*path)
#: Type of FileSystemItem's data
T = TypeVar('T')
FileSystemItemLike = Union[Tuple[Union[PurePath, Path], Optional[T]],
'FileSystemItem']
#: Default return value
A = TypeVar('A')
class FileSystemItem(Generic[T]):
"""
An item in the virtual file system.
..warning::
Do not edit `children` manually. Always use `add` or `remove` or
`sort`.
Attributes
----------
path : Path
The path of this item.
data : Any
The data belonging to this item.
children : List[FileSystemItem]
The children of this item.
_subpath : str
The subpath of this item relative to its parent.
_parent : FileSystemItem or None
The parent of the item.
"""
__slots__ = ['path', 'children', 'data', '_parent', 'subpath']
def __init__(self, path: PathLike, data: T):
"""Init."""
self.path: Path = tuple(path)
self.data = data
self.subpath: str = None
self.children: List[FileSystemItem[T]] = []
self._parent: Optional[FileSystemItem[T]] = None
# @property
# def subpath(self) -> str:
# """
# Get the name of the item which is the subpath relative to its parent.
# """
# return self.path[-1]
# @property
# def children(self):
# """Get an iterable view of the item's children."""
# return self.child_map.values()
def add(self,
child: 'FileSystemItem[T]',
_subpath: str = None,
_check: bool = True):
"""
Add a child.
The parameters starting with an underscore exist for performance
reasons only. They should only be used if the operations that these
parameters toggle were performed already.
Parameters
----------
child : FileSystemItem
The child to add.
_subpath : str, optional
Precalculated subpath, default is None.
_check : bool, optional
Whether to check for children with the same subpath (using `get`).
"""
if _subpath is not None:
child.subpath = _subpath
else:
child.subpath = path_to_str(relative_path(self.path, child.path))
i = bisect.bisect(self.children, child)
# check for a child with the same subpath
if _check and len(self.children) > i - 1 >= 0 \
and self.children[i - 1].subpath == child.subpath:
raise RuntimeError(
"The subpath must be unique to a parent's children.")
# add
child._parent = self
self.children.insert(i, child)
def addChildren(self, children: List['FileSystemItem[T]']):
"""
Add a list of children.
Parameters
----------
children : List[FileSystemItem]
The children to add.
"""
for child in children:
self.add(child)
@overload
def remove(self, subpath: str) -> None:
pass
@overload
def remove(self, index: int) -> None:
pass
@overload
def remove(self, child: 'FileSystemItem[T]') -> None:
pass
def remove(self, child_subpath_index):
"""
Remove the given children.
The index or child to remove must be in the list
else an error will be raised.
Parameters
----------
child_or_index : FileSystemItem or int
The instance to remove or its index in `children`.
Raises
------
ValueError
The given item is not a child of this one.
IndexError
The given index is not a valid one.
"""
if isinstance(child_subpath_index, FileSystemItem):
child = child_subpath_index
i = bisect.bisect_left(self.children, child)
if i < len(self.children) and self.children[i] == child:
del self.children[i]
elif isinstance(child_subpath_index, str):
subpath = child_subpath_index
i = bisect.bisect_left(self.children, subpath)
if i < len(self.children) and self.children[i].subpath == subpath:
del self.children[i]
elif isinstance(child_subpath_index, int):
i = child_subpath_index
del self.children[i]
else:
raise TypeError(
"First argument passed to `{}.remove` has invalid type {}".
format(
type(self).__name__,
type(child_subpath_index).__name__))
def __getitem__(self, index: int):
"""
Get a an item.
This allows accessing the attributes in the same manner for instances
of this type and instances of `FileSystemItemLike`.
"""
if index == 0:
return self.path
elif index == 1:
return self.data
else:
raise IndexError("Index {} out of range(0, 2)".format(index))
def get(
self,
subpath: str,
default: Optional[A] = None
) -> Union[Tuple[int, 'FileSystemItem[T]'], Optional[A]]:
"""
Find direct child with given subpath.
Parameters
----------
subpath : str
The items subpath relative to this.
default : Any, optional
The item to return if the child wasn't found, default None.
Returns
-------
Tuple[int, FileSystemItem] or None
The index and item if found else `default`.
"""
i = bisect.bisect_left(self.children, subpath)
if i < len(self.children):
child = self.children[i]
if child.subpath == subpath:
return i, child
return default
def get_path(self, path: PathLike) -> Optional['FileSystemItem[T]']:
"""
Get the item with the given subpath relative to this item.
Parameters
----------
path : Path
The subpath.
"""
def walk(fsi, pp):
if fsi is None:
return None
res = fsi.get(pp)
if not res:
return None
i, item = res
return item
fsi = reduce(walk, path, self)
return fsi
def __repr__(self):
"""Get a string representation."""
return "FileSystemItem<'{}', '{}', {}, {}>".format(
self.path,
self.subpath,
self.data,
[c.subpath for c in self.children],
)
def __lt__(self, other):
"""Lower than for bisect sorting."""
if isinstance(other, FileSystemItem):
return self.subpath < other.subpath
if isinstance(other, (list, tuple)):
for s, o in zip(self.path, other):
if s != o:
return s < o
else:
return len(self.path) < len(other)
else:
return self.subpath < other
def __gt__(self, other):
"""Greater than for bisect sorting."""
if isinstance(other, FileSystemItem):
return self.subpath > other.subpath
if isinstance(other, (list, tuple)):
for s, o in zip(self.path, other):
if s != o:
return s > o
else:
return len(self.path) > len(other)
else:
return self.subpath > other
class FileTreeModel(QAbstractItemModel, Generic[T]):
"""
FileTreeModel managing a virtual file system with variable file data.
Attributes
----------
mode: DisplayMode
The tree display mode of the model.
Methods
-------
_make_filesystemitem(path, data, children)
Construct a `FileSystemItem`.
_merge_data(item, data)
Add the given data to the item.
_flat_filter
Return whether an item is part of the flat model representation.
flags
columnCount
headerData
"""
class DisplayMode(enum.Enum):
"""
The tree display modes available for the model.
"""
#: normal file tree
TREE = enum.auto()
#: combine items in the tree having a single child with that child
SIMPLIFIED_TREE = enum.auto()
#: simple list of items
FLAT = enum.auto()
def __init__(self, parent=None):
"""Init."""
super().__init__(parent)
self.root: FileSystemItem[T] = FileSystemItem([], None)
#: mode
self.mode: 'FileTreeModel.DisplayMode' = self.DisplayMode.TREE
#: flat representation of the tree
self._flattened: List[FileSystemItem] = []
def addItems(self, items: List[FileSystemItemLike[T]]):
"""
Add file system items to the model.
This method can be used for populating the model.
Calls `addItem` for each item.
Parameters
----------
items : List[FileSystemItemLike]
The items.
"""
for item in items:
self.addItem(item)
def addItem(self, item: FileSystemItemLike[T]):
"""
Add a file system item to the model.
Parameters
----------
item : FileSystemItemLike
The item.
"""
self.beginResetModel()
path = item[0]
data = item[1]
if isinstance(path, PurePath):
path = path.parts
def child(tup, subpath):
fsi, i = tup
i += 1
return self._addChild(fsi, path[:i], subpath, None), i
fsi, dummy = reduce(child, path[:-1], (self.root, 0))
self._addChild(fsi, path, path[-1], data)
self.endResetModel()
def _addChild(self, item: FileSystemItem[T], path: PathLike,
path_part: str, data: Optional[T]) -> FileSystemItem[T]:
"""
Add a child to an item.
This is called by `addItem` in a reduce statement. It should
add a new child with the given attributes to the given item.
This implementation provides a reasonable default, most subclasses
wont need to override this method. The implementation should make use
of `_make_filesystemitem`, `_merge_data`, `_add_children`.
Parameters
----------
item : FileSystemItem
The item to add a new child to.
path : PathLike
The path of the new child.
path_part : str
The subpath of the new child relative to `item`.
data : Any or None
The data of the new child.
children : Any or None
The initial children of the item.
Returns
-------
FileSystemItem
[description]
"""
res = item.get(path_part)
if res:
i, child = res
if data is not None:
self._merge_data(child, data)
else:
child = self._make_filesystemitem(path, data)
if self._flat_filter(child):
i = bisect.bisect(self._flattened, child.path)
self._flattened.insert(i, child)
item.add(child, _subpath=path_part, _check=False)
# update parent data
self._process_child(child)
return child
def _make_filesystemitem(self, path: PathLike,
data: Optional[T]) -> FileSystemItem[T]:
"""
Construct a `FileSystemItem`.
The attributes are the attributes of a `FileSystemItemLike`.
This implementation already provides reasonable default that
subclasses can be used.
..warning:: Do always call `_addChild` to add a child to an item.
Parameters
----------
path : PathLike
The path of the item.
data : Any or None
The data.
children : Any or None
The initial children.
Returns
-------
FileSystemItem
The FileSystemItem for the internal tree structure.
"""
return FileSystemItem(path, data)
def _process_child(self, child: FileSystemItem[T]):
"""
Process a new child.
This can make some changes to the child's data like
setting a default value if the child's data is None.
This can also update the data of the parent.
Parameters
----------
child : FileSystemItem
The child that was added.
"""
pass # Does nothing
def _merge_data(self, item: FileSystemItem[T], data: Optional[T]):
"""
Add the given data to the item.
This method is called by `_addChild` which in turn is called by
`addItem`. It gets an item in the virtual file system that was
added again with the given data. The data may be None.
Parameters
----------
item : FileSystemItem
The item to merge the data in.
data : Any or None
The data to add.
"""
if not item.data:
item.data = data
def removeItem(self, path: Union[PurePath, PathLike]) -> None:
"""
Remove an item from the model.
Parameters
----------
path : PathLike or PurePath
The path of the item to remove.
"""
if isinstance(path, PurePath):
path = path.parts
if not path:
return
self.beginResetModel()
parent = self.getItem(path[:-1])
if not parent:
return
res = parent.get(path[-1])
if not res:
return
i, item = res
# remove item and its children in flat representation
items_to_remove: List[FileSystemItem] = [item]
while items_to_remove:
to_remove = items_to_remove.pop()
fi = bisect.bisect_left(self._flattened, to_remove.path)
if fi < len(self._flattened) and self._flattened[fi] is to_remove:
del self._flattened[fi]
items_to_remove.extend(to_remove.children)
# remove item from tree representation
parent.remove(i)
self.endResetModel()
def setMode(self, value: 'DisplayMode'):
"""
Set the display mode of the tree model.
In TREE mode (default) the tree will be displayed as is.
In SIMPLIFIED_TREE items will simplify the tree by combining
items with their single child if they posess only one.
In FLAT mode items will be displayed as a simple list. The items
shown can be filtered by `_flat_filter`.
Parameters
----------
value : bool
The new value for the attribute.
See also
--------
getMode: Get the current mode.
_flat_filter
"""
if value == self.mode:
return # nothing to do
self.beginResetModel()
self.mode = value
self.endResetModel()
def getMode(self) -> bool:
"""
Get the display mode set.
Returns
-------
DisplayMode
The current value.
See also
--------
setMode : Set the mode.
"""
return self.mode
def _flat_filter(self, item: FileSystemItem[T]) -> bool:
"""
Return whether an item is part of the flat model representation.
"""
return True
def _simplify_filter(self, item: FileSystemItem[T]) -> bool:
"""
Return whether an item may be merged in simplified mode.
"""
return True
def getItem(
self, path: Union[PurePath,
PathLike]) -> Optional[FileSystemItem[T]]:
"""
Get the item with the given path.
Parameters
----------
path : PathLike or PurePath
The path of the item.
Returns
-------
Optional[FileSystemItem]
[description]
"""
if isinstance(path, PurePath):
path = path.parts
return self.root.get_path(path)
def data(self,
index: QModelIndex,
role: int = Qt.ItemDataRole.DisplayRole):
"""
Get the data for the given role and index.
The indexes internal pointer references the corresponding
`FileSystemItem`.
Parameters
----------
index : QModelIndex
The index of the item.
role : int, optional
The data role, by default Qt.ItemDataRole.DisplayRole
Returns
-------
Any
The data, return None if no data is available for the role.
"""
return super().data(index, role)
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
"""
Returns the number of rows under the given parent.
When the parent is valid it means that rowCount is returning
the number of children of parent.
Parameters
----------
parent : QModelIndex, optional
The index of the parent item, by default QModelIndex()
Returns
-------
int
The number of children.
"""
if parent.column() > 0:
return 0 # Only the first column has children
# flat mode
if self.mode == self.DisplayMode.FLAT:
if not parent.isValid():
return len(self._flattened)
return 0
# tree mode
if not parent.isValid():
parent_item: FileSystemItem = self.root
else:
parent_item = parent.internalPointer()
return len(parent_item.children)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
"""
Returns the number of columns for the children of the given parent.
This corresponds to the number of data (column) entries shown
for each item in the tree view.
Parameters
----------
parent : QModelIndex, optional
The index of the parent, by default QModelIndex()
Returns
-------
int
The number of rows.
"""
raise NotImplementedError("Method `columnCount` of FileTreeModel" +
" must be implemented by subclasses.")
def indexPath(self, path: Union[PurePath, PathLike]) -> QModelIndex:
"""
Construct a `QModelIndex` for the given path.
If `combine` is enabled, the closest indexed parent path is returned.
Parameters
----------
path : PurePath or PathLike
The path to the item the index should point to.
Returns
-------
QModelIndex
The requested index.
"""
if isinstance(path, PurePath):
path = path.parts
# flat mode
if self.mode == self.DisplayMode.FLAT:
i = bisect.bisect_left(self._flattened, path)
if i < len(self._flattened) and self._flattened[i].path == path:
return self.index(i, 0)
return QModelIndex()
# tree mode
simplified = self.mode == self.DisplayMode.SIMPLIFIED_TREE
def step(tup, subpath):
index, i, item = tup
if not item:
return index, None
r, child = item.get(subpath)
if not child:
return QModelIndex(), None
if i <= -1:
i = r
if (simplified and len(child.children) == 1
and self._simplify_filter(child)):
return index, i, child
index = self.index(i if simplified else r, 0, index)
return index, -1, child
index, i, item = reduce(step, path, (QModelIndex(), -1, self.root))
return index
def index(self, row: int, column: int,
parent: QModelIndex = QModelIndex()) -> QModelIndex:
"""
Construct a `QModelIndex`.
Returns the index of the item in the model specified by
the given row, column and parent index.
Parameters
----------
row : int
column : int
parent : QModelIndex, optional
Returns
-------
QModelIndex
The requested index.
"""
# different behavior in flat and treemode
if self.mode == self.DisplayMode.FLAT:
if (0 <= row < len(self._flattened)
and 0 <= column < self.columnCount(parent)):
return self.createIndex(row, column, self._flattened[row])
return QModelIndex()
# valid index?
if not parent.isValid():
parent_item: FileSystemItem[T] = self.root
else:
parent_item = parent.internalPointer()
item = list(parent_item.children)[row]
if self.mode == self.DisplayMode.SIMPLIFIED_TREE:
# combine items with a single child with that child
while len(item.children) == 1 and self._simplify_filter(item):
item = item.children[0]
if (0 <= row < len(parent_item.children)
and 0 <= column < self.columnCount(parent)):
return self.createIndex(row, column, item)
return QModelIndex()
@overload
def parent(self, child: QModelIndex) -> QModelIndex:
pass
@overload
def parent(self) -> QObject:
pass
def parent(self, child=None):
"""
Returns the parent of the model item with the given index.
If the item has no parent, an invalid QModelIndex is returned.
A common convention used in models that expose tree data structures
is that only items in the first column have children.
For that case, when reimplementing this function in a subclass
the column of the returned QModelIndex would be 0.
Parameters
----------
child : QModelIndex
The index of the child item.
Returns
-------
QModelIndex
The index of the parent item.
"""
# overloaded variant to retrieve parent of model
if child is None:
return super().parent()
# variant to retrieve parent for data item
if not child.isValid():
return QModelIndex()
# different behavior in tree and flat mode
if self.mode == self.DisplayMode.FLAT:
return QModelIndex() # in flat mode their are no parents
child_item: FileSystemItem[T] = child.internalPointer()
parent_item = child_item._parent
if self.mode == self.DisplayMode.SIMPLIFIED_TREE:
# combine items with a single child with the child
while (parent_item is not self.root # do not call filter with root
and len(parent_item.children) == 1
and self._simplify_filter(parent_item)):
parent_item = parent_item._parent
if parent_item is self.root:
# Never return root item since it shouldn't be displayed
return QModelIndex()
row, item = parent_item._parent.get(parent_item.subpath)
return self.createIndex(row, 0, parent_item)
def flags(self, index: QModelIndex) -> Qt.ItemFlags:
"""
Returns the item flags for the given index.
The base class implementation returns a combination of flags
that enables the item (ItemIsEnabled) and
allows it to be selected (ItemIsSelectable).
Parameters
----------
index : QModelIndex
The index.
Returns
-------
Qt.ItemFlags
The flags.
"""
return super().flags(index)
def headerData(self,
section: int,
orientation: Qt.Orientation,
role: int = Qt.ItemDataRole.DisplayRole):
"""
Get the data for the given role and section in the given header.
The header is identified by its orientation.
For horizontal headers, the section number corresponds to
the column number. Similarly, for vertical headers,
the section number corresponds to the row number.
Parameters
----------
section : int
The row or column number.
orientation : Qt.Orientation
The orientation of the header.
role : int, optional
The data role, by default Qt.ItemDataRole.DisplayRole
Returns
-------
Any
The data for the specified header section.
"""
return super().headerData(section, orientation, role)
class FileTreeSortProxyModel(QSortFilterProxyModel):
"""
Sort a FileTreeModel.
"""
sorted = pyqtSignal(int, Qt.SortOrder)
def __init__(self, parent=None) -> None:
"""Init."""
super().__init__(parent)
self.folders_on_top = False
def keepFoldersOnTop(self, value: bool = None) -> bool:
"""
Set or get whether folders are kept on top when sorting.
Parameters
----------
value : bool, optional
The new value, by default None
Returns
-------
bool
The value of the attribute.
"""
if value is not None and value != self.folders_on_top:
self.folders_on_top = value
# resort
self.setDynamicSortFilter(False)
self.sort(self.sortColumn(), self.sortOrder())
self.setDynamicSortFilter(True)
return self.folders_on_top
def extract_path(self, index: QModelIndex):
"""Get the path to compare for a given index."""
item: FileSystemItem = index.internalPointer()
model: FileTreeModel = self.sourceModel()
# name
if model.mode == FileTreeModel.DisplayMode.FLAT:
return path_to_str(item.path)
if model.mode == FileTreeModel.DisplayMode.SIMPLIFIED_TREE:
parent = index.parent()
if parent == QModelIndex():
path = relative_path(model.root.path, item.path)
else:
path = relative_path(parent.internalPointer().path,
item.path)
return path[0] if path else ''
# standard tree mode
return item.subpath
def choose_data(self, index: QModelIndex):
"""Choose the data of index used for comparison."""
raise NotImplementedError("Method `choose_data` of " +
"FileTreeSortProxyModel" +
" must be implemented by subclasses.")
def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool:
"""
Return whether the item of `left` is lower than the one of `right`.
Parameters
----------
left : QModelIndex
The index left of the `<`.
right : QModelIndex
The index right of the `<`.
Returns
-------
bool
Whether left is lower than right.
"""
if self.folders_on_top:
item1 = left.internalPointer()
item2 = right.internalPointer()
ch1 = bool(len(item1.children))
ch2 = bool(len(item2.children))
if ch1 ^ ch2:
if self.sortOrder() == Qt.SortOrder.AscendingOrder:
return ch1
return ch2
data1 = self.choose_data(left)
data2 = self.choose_data(right)
return data1 < data2

View File

@ -68,6 +68,9 @@ class RepoTab(RepoBase, RepoUI, BackupProfileMixin):
self.populate_from_profile()
self.set_icons()
# Connect to palette change
QApplication.instance().paletteChanged.connect(lambda p: self.set_icons())
def set_icons(self):
self.bAddSSHKey.setIcon(get_colored_icon("plus"))
self.bAddRepo.setIcon(get_colored_icon("plus"))

View File

@ -96,6 +96,9 @@ class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin):
# Connect to schedule update
self.app.scheduler.schedule_changed.connect(lambda pid: self.draw_next_scheduled_backup())
# Connect to palette change
self.app.paletteChanged.connect(lambda p: self.set_icons())
def on_scheduler_change(self, _):
profile = self.profile()
# Save scheduler settings, apply new scheduler and display next task for profile.

File diff suppressed because it is too large Load Diff

View File

@ -152,8 +152,8 @@ def test_archive_extract(qapp, qtbot, mocker, borg_json_output):
qtbot.waitUntil(lambda: hasattr(tab, '_window'), **pytest._wait_defaults)
# qtbot.waitUntil(lambda: tab._window == qapp.activeWindow(), **pytest._wait_defaults)
assert tab._window.treeView.model().rootItem.childItems[0].data(0) == 'Users'
tab._window.treeView.model().rootItem.childItems[0].load_children()
model = tab._window.model
assert model.root.children[0].subpath == 'home'
assert tab._window.archiveNameLabel.text().startswith('test-archive, 2000')

View File

@ -1,9 +1,13 @@
from pathlib import PurePath
import pytest
from PyQt5.QtCore import QItemSelectionModel
import vorta.borg
import vorta.utils
import vorta.views.archive_tab
from vorta.views.diff_result import (ChangeType, DiffData, DiffTree, FileType,
parse_diff_json, parse_diff_lines)
@pytest.mark.parametrize('json_mock_file,folder_root', [
@ -20,6 +24,14 @@ def test_archive_diff(qapp, qtbot, mocker, borg_json_output, json_mock_file, fol
popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0)
mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result)
compat = vorta.utils.borg_compat
def check(feature_name):
if feature_name == 'DIFF_JSON_LINES':
return False
return vorta.utils.BorgCompatibility.check(compat, feature_name)
mocker.patch.object(vorta.utils.borg_compat, 'check', check)
selection_model: QItemSelectionModel = tab.archiveTable.selectionModel()
model = tab.archiveTable.model()
@ -33,78 +45,181 @@ def test_archive_diff(qapp, qtbot, mocker, borg_json_output, json_mock_file, fol
qtbot.waitUntil(lambda: hasattr(tab, '_resultwindow'), **pytest._wait_defaults)
assert tab._resultwindow.treeView.model().rootItem.childItems[0].data(0) == folder_root
tab._resultwindow.treeView.model().rootItem.childItems[0].load_children()
model = tab._resultwindow.treeView.model().sourceModel()
assert model.root.children[0].subpath == folder_root
assert tab._resultwindow.archiveNameLabel_1.text() == 'test-archive'
tab._resultwindow.accept()
@pytest.mark.parametrize('line, expected', [
('changed link some/changed/link',
(0, 'changed', 'link', 'some/changed', '-')),
(' +77.8 kB -77.8 kB some/changed/file',
(77800, 'modified', 'file', 'some/changed', '-')),
(' +77.8 kB -77.8 kB [-rw-rw-rw- -> -rw-r--r--] some/changed/file',
(77800, '[-rw-rw-rw- -> -rw-r--r--]', 'file', 'some/changed', '-')),
('[-rw-rw-rw- -> -rw-r--r--] some/changed/file',
(0, '[-rw-rw-rw- -> -rw-r--r--]', 'file', 'some/changed', '-')),
@pytest.mark.parametrize(
'line, expected',
[
('changed link some/changed/link',
('some/changed/link', FileType.LINK, ChangeType.CHANGED_LINK, 0, 0,
None, None, None)),
(' +77.8 kB -77.8 kB some/changed/file',
('some/changed/file', FileType.FILE, ChangeType.MODIFIED, 2 * 77800,
0, None, None,
(77800, 77800))),
(' +77.8 kB -77.8 kB [-rw-rw-rw- -> -rw-r--r--] some/changed/file',
('some/changed/file', FileType.FILE, ChangeType.MODIFIED, 2 * 77800, 0,
('-rw-rw-rw-', '-rw-r--r--'), None, (77800, 77800))),
('[-rw-rw-rw- -> -rw-r--r--] some/changed/file',
('some/changed/file', FileType.FILE, ChangeType.MODE, 0, 0,
('-rw-rw-rw-', '-rw-r--r--'), None, None)),
('added directory some/changed/dir',
('some/changed/dir', FileType.DIRECTORY, ChangeType.ADDED, 0, 0, None,
None, None)),
('removed directory some/changed/dir',
('some/changed/dir', FileType.DIRECTORY, ChangeType.REMOVED_DIR, 0, 0,
None, None, None)),
('added directory some/changed/dir',
(0, 'added', 'dir', 'some/changed', 'd')),
('removed directory some/changed/dir',
(0, 'removed', 'dir', 'some/changed', 'd')),
# Example from https://github.com/borgbase/vorta/issues/521
('[user:user -> nfsnobody:nfsnobody] home/user/arrays/test.txt',
('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER, 0, 0,
None, ('user', 'user', 'nfsnobody', 'nfsnobody'), None)),
# Example from https://github.com/borgbase/vorta/issues/521
('[user:user -> nfsnobody:nfsnobody] home/user/arrays/test.txt',
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
# Very short owner change, to check stripping whitespace from file path
('[a:a -> b:b] home/user/arrays/test.txt',
('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER, 0, 0,
None, ('a', 'a', 'b', 'b'), None)),
# Very short owner change, to check stripping whitespace from file path
('[a:a -> b:b] home/user/arrays/test.txt',
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
# All file-related changes in one test
(' +77.8 kB -77.8 kB [user:user -> nfsnobody:nfsnobody] [-rw-rw-rw- -> -rw-r--r--] home/user/arrays/test.txt',
(77800, '[-rw-rw-rw- -> -rw-r--r--]', 'test.txt', 'home/user/arrays', '-')),
])
# All file-related changes in one test
(' +77.8 kB -800 B [user:user -> nfsnobody:nfsnobody] [-rw-rw-rw- -> -rw-r--r--] home/user/arrays/test.txt',
('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER,
77800 + 800, 77000, ('-rw-rw-rw-', '-rw-r--r--'),
('user', 'user', 'nfsnobody', 'nfsnobody'), (77800, 800))),
])
def test_archive_diff_parser(line, expected):
files_with_attributes, nested_file_list = vorta.views.diff_result.parse_diff_lines([line])
assert files_with_attributes == [expected]
model = DiffTree()
model.setMode(model.DisplayMode.FLAT)
parse_diff_lines([line], model)
assert model.rowCount() == 1
item = model.index(0, 0).internalPointer()
assert item.path == PurePath(expected[0]).parts
assert item.data == DiffData(*expected[1:])
@pytest.mark.parametrize('line, expected', [
({'path': 'some/changed/link', 'changes': [{'type': 'changed link'}]},
(0, 'changed', 'link', 'some/changed', '-')),
({'path': 'some/changed/file', 'changes': [{'type': 'modified', 'added': 77800, 'removed': 77800}]},
(77800, 'modified', 'file', 'some/changed', '-')),
({'path': 'some/changed/file', 'changes': [{'type': 'modified', 'added': 77800, 'removed': 77800},
{'type': 'mode', 'old_mode': '-rw-rw-rw-', 'new_mode': '-rw-r--r--'}]},
(77800, '[-rw-rw-rw- -> -rw-r--r--]', 'file', 'some/changed', '-')),
({'path': 'some/changed/file', 'changes': [{'type': 'mode', 'old_mode': '-rw-rw-rw-', 'new_mode': '-rw-r--r--'}]},
(0, '[-rw-rw-rw- -> -rw-r--r--]', 'file', 'some/changed', '-')),
({'path': 'some/changed/dir', 'changes': [{'type': 'added directory'}]},
(0, 'added', 'dir', 'some/changed', 'd')),
({'path': 'some/changed/dir', 'changes': [{'type': 'removed directory'}]},
(0, 'removed', 'dir', 'some/changed', 'd')),
@pytest.mark.parametrize(
'line, expected',
[
({
'path': 'some/changed/link',
'changes': [{
'type': 'changed link'
}]
}, ('some/changed/link', FileType.LINK, ChangeType.CHANGED_LINK, 0, 0,
None, None, None)),
({
'path': 'some/changed/file',
'changes': [{
'type': 'modified',
'added': 77800,
'removed': 77800
}]
}, ('some/changed/file', FileType.FILE, ChangeType.MODIFIED, 2 * 77800,
0, None, None, (77800, 77800))),
({
'path':
'some/changed/file',
'changes': [{
'type': 'modified',
'added': 77800,
'removed': 800
}, {
'type': 'mode',
'old_mode': '-rw-rw-rw-',
'new_mode': '-rw-r--r--'
}]
}, ('some/changed/file', FileType.FILE, ChangeType.MODIFIED,
77800 + 800, 77000, ('-rw-rw-rw-', '-rw-r--r--'), None,
(77800, 800))),
({
'path':
'some/changed/file',
'changes': [{
'type': 'mode',
'old_mode': '-rw-rw-rw-',
'new_mode': '-rw-r--r--'
}]
}, ('some/changed/file', FileType.FILE, ChangeType.MODE, 0, 0,
('-rw-rw-rw-', '-rw-r--r--'), None, None)),
({
'path': 'some/changed/dir',
'changes': [{
'type': 'added directory'
}]
}, ('some/changed/dir', FileType.DIRECTORY, ChangeType.ADDED, 0, 0,
None, None, None)),
({
'path': 'some/changed/dir',
'changes': [{
'type': 'removed directory'
}]
}, ('some/changed/dir', FileType.DIRECTORY, ChangeType.REMOVED_DIR, 0,
0, None, None, None)),
# Example from https://github.com/borgbase/vorta/issues/521
({'path': 'home/user/arrays/test.txt', 'changes': [{'type': 'owner', 'old_user': 'user', 'new_user': 'nfsnobody',
'old_group': 'user', 'new_group': 'nfsnobody'}]},
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
# Example from https://github.com/borgbase/vorta/issues/521
({
'path':
'home/user/arrays/test.txt',
'changes': [{
'type': 'owner',
'old_user': 'user',
'new_user': 'nfsnobody',
'old_group': 'user',
'new_group': 'nfsnobody'
}]
}, ('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER, 0, 0,
None, ('user', 'user', 'nfsnobody', 'nfsnobody'), None)),
# Very short owner change, to check stripping whitespace from file path
({'path': 'home/user/arrays/test.txt', 'changes': [{'type': 'owner', 'old_user': 'a', 'new_user': 'b',
'old_group': 'a', 'new_group': 'b'}]},
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
# Very short owner change, to check stripping whitespace from file path
({
'path':
'home/user/arrays/test.txt',
'changes': [{
'type': 'owner',
'old_user': 'a',
'new_user': 'b',
'old_group': 'a',
'new_group': 'b'
}]
}, ('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER, 0, 0,
None, ('a', 'a', 'b', 'b'), None)),
# All file-related changes in one test
({'path': 'home/user/arrays/test.txt', 'changes': [{'type': 'modified', 'added': 77800, 'removed': 77800},
{'type': 'mode', 'old_mode': '-rw-rw-rw-',
'new_mode': '-rw-r--r--'},
{'type': 'owner', 'old_user': 'user', 'new_user': 'nfsnobody',
'old_group': 'user', 'new_group': 'nfsnobody'}]},
(77800, '[-rw-rw-rw- -> -rw-r--r--]', 'test.txt', 'home/user/arrays', '-')),
])
# All file-related changes in one test
({
'path':
'home/user/arrays/test.txt',
'changes': [{
'type': 'modified',
'added': 77800,
'removed': 77800
}, {
'type': 'mode',
'old_mode': '-rw-rw-rw-',
'new_mode': '-rw-r--r--'
}, {
'type': 'owner',
'old_user': 'user',
'new_user': 'nfsnobody',
'old_group': 'user',
'new_group': 'nfsnobody'
}]
}, ('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER,
2 * 77800, 0, ('-rw-rw-rw-', '-rw-r--r--'),
('user', 'user', 'nfsnobody', 'nfsnobody'), (77800, 77800))),
])
def test_archive_diff_json_parser(line, expected):
files_with_attributes, _nested_file_list = vorta.views.diff_result.parse_diff_json_lines([line])
assert files_with_attributes == [expected]
model = DiffTree()
model.setMode(model.DisplayMode.FLAT)
parse_diff_json([line], model)
assert model.rowCount() == 1
item = model.index(0, 0).internalPointer()
assert item.path == PurePath(expected[0]).parts
assert item.data == DiffData(*expected[1:])

181
tests/test_extract.py Normal file
View File

@ -0,0 +1,181 @@
from PyQt5.QtCore import QModelIndex, Qt
import vorta.borg
from vorta.views.extract_dialog import (ExtractTree, FileData, FileType,
parse_json_lines)
from vorta.views.partials.treemodel import FileSystemItem
def prepare_borg(mocker, borg_json_output):
stdout, stderr = borg_json_output("list_archive")
popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0)
mocker.patch.object(vorta.borg.borg_job, "Popen", return_value=popen_result)
default = {
"mode": "drwxrwxr-x",
"user": "theuser",
"group": "theuser",
"uid": 1000,
"gid": 1000,
"path": "",
"healthy": True,
"source": "",
"linktarget": "",
"flags": None,
"mtime": "2022-05-13T14:33:57.305797",
"size": 0,
}
def updated(path, values):
d = default.copy()
d.update(values)
d["path"] = path
return d
def test_parser():
"""Test creating a tree with correct data from json lines."""
lines = [
updated("a", {}),
updated("a/b", {"mode": "-rwxrwxr-x"}),
updated("a/b/c", {}),
updated("a/b/d", {}),
updated("a/a", {}),
updated("a/a/a", {}),
updated("a/a/b", {}),
updated("a/a/c", {"healthy": False}),
updated("a/a/d", {}),
updated("a/a/e", {}),
]
model = ExtractTree()
parse_json_lines(lines, model)
index = model.indexPath(("a", "b"))
assert index != QModelIndex()
assert index.internalPointer().data.file_type == FileType.FILE
item = model.getItem(("a", "a", "c"))
assert item
assert item.data.health is False
def select(model, index):
model.setData(index, Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
def deselect(model, index):
model.setData(index, Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
def test_selection():
"""Test selecting items for extraction."""
lines = [
updated("a", {}),
updated("a/b", {}),
updated("a/b/c", {}),
updated("a/b/d", {}),
updated("a/a", {}),
updated("a/a/a", {}),
updated("a/a/b", {}),
updated("a/a/c", {}),
updated("a/a/d", {}),
updated("a/a/e", {}),
updated("c", {}),
]
model = ExtractTree()
parse_json_lines(lines, model)
# Test select
ic = model.index(1, 0, QModelIndex())
c: FileSystemItem[FileData] = ic.internalPointer()
select(model, ic)
assert c.data.checkstate == 2
assert c.data.checked_children == 0
# Test deselect
deselect(model, ic)
assert c.data.checkstate == 0
assert c.data.checked_children == 0
# Test select parent as well as children
ia = model.index(0, 0, QModelIndex())
a: FileSystemItem[FileData] = ia.internalPointer()
aa = model.getItem(("a", "a"))
aab = model.getItem(("a", "a", "b"))
ab = model.getItem(("a", "b"))
abc = model.getItem(("a", "b", "c"))
select(model, ia)
assert a.data.checkstate
assert a.data.checked_children == 2
assert a.data.checked_children == 2
assert aab.data.checkstate
assert ab.data.checkstate
assert aa.data.checkstate
assert aa.data.checked_children == 5
# Test deselect item as well as children with selected parent
iab = model.indexPath(("a", "b"))
deselect(model, iab)
assert a.data.checkstate == 1
assert aa.data.checkstate == 2
assert ab.data.checkstate == 0
assert abc.data.checkstate == 0
assert a.data.checked_children == 1
assert ab.data.checked_children == 0
# Test deselect item and children
deselect(model, ia)
assert a.data.checkstate == 0
assert aa.data.checkstate == 0
assert ab.data.checkstate == 0
assert a.data.checked_children == 0
assert aa.data.checked_children == 0
# Test select child with partially selected parent
iaac = model.indexPath(("a", "a", "c"))
select(model, ia)
deselect(model, iab)
deselect(model, iaac)
select(model, iab)
select(model, iaac)
assert a.data.checkstate == 1
assert aa.data.checkstate == 1
assert ab.data.checkstate == 2
assert a.data.checked_children == 2
assert ab.data.checked_children == 2
assert aa.data.checked_children == 5
# Test deselect all children with selected parent
iaa = model.indexPath(("a", "a"))
deselect(model, iaa)
deselect(model, iab)
assert a.data.checkstate == 0
assert a.data.checked_children == 0
# Test select child with deselected parent
select(model, iaac)
assert a.data.checkstate == 1
assert ab.data.checkstate == 0
assert aa.data.checkstate == 1
assert a.data.checked_children == 1
assert ab.data.checked_children == 0
assert aa.data.checked_children == 1
select(model, iaa)
assert a.data.checkstate == 1
select(model, iab)
assert a.data.checkstate == 1

326
tests/test_treemodel.py Normal file
View File

@ -0,0 +1,326 @@
from pathlib import PurePath
import pytest
from PyQt5.QtCore import QModelIndex
from vorta.views.partials.treemodel import FileSystemItem, FileTreeModel
class TreeModelImp(FileTreeModel):
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 1
def _make_filesystemitem(self, path, data):
return super()._make_filesystemitem(path, data)
def _merge_data(self, item, data):
return super()._merge_data(item, data)
class TestFileSystemItem:
def test_tuple(self):
item = FileSystemItem(PurePath('test').parts, 0)
assert item[0] == item.path
assert item[1] == item.data
def test_add(self):
item = FileSystemItem(PurePath('test').parts, 0)
assert len(item.children) == 0
child = FileSystemItem(PurePath('test/hello').parts, 4)
item.add(child)
assert len(item.children) == 1
assert item.children[0] == child
assert child.subpath == 'hello'
assert child._parent == item
child = FileSystemItem(PurePath('test/hello').parts, 8)
with pytest.raises(RuntimeError):
# may not add a child with the same subpath
item.add(child)
def test_remove(self):
item = FileSystemItem(PurePath('test').parts, 0)
child1 = FileSystemItem(PurePath('test/a').parts, 4)
child2 = FileSystemItem(PurePath('test/b').parts, 3)
child3 = FileSystemItem(PurePath('test/c').parts, 2)
item.add(child1)
item.add(child2)
item.add(child3)
assert len(item.children) == 3
# test remove subpath
item.remove('b')
assert len(item.children) == 2
assert child2 not in item.children
# test remove item
item.remove(child3)
assert len(item.children) == 1
assert child3 not in item.children
def test_get(self):
item = FileSystemItem(PurePath('test').parts, 0)
child1 = FileSystemItem(PurePath('test/a').parts, 4)
child2 = FileSystemItem(PurePath('test/b').parts, 3)
child3 = FileSystemItem(PurePath('test/c').parts, 2)
item.add(child1)
item.add(child2)
item.add(child3)
# test get inexistent subpath
assert item.get('unknown') is None
assert item.get('unknown', default='default') == 'default'
# get subpath
res = item.get('a')
assert res is not None
assert res[1] == child1
res = item.get('b')
assert res is not None
assert res[1] == child2
# get subpath of empty list
assert child1.get('a') is None
def test_get_subpath(self):
item = FileSystemItem(('test',), 0)
child1 = FileSystemItem(PurePath('test/a').parts, 4)
child2 = FileSystemItem(PurePath('test/b').parts, 3)
child3 = FileSystemItem(PurePath('test/c').parts, 2)
item.add(child1)
item.add(child2)
item.add(child3)
child11 = FileSystemItem(PurePath('test/a/aa').parts, 4)
child1.add(child11)
assert item.get_path(PurePath('a/aa').parts) is child11
assert item.get_path(('b',)) is child2
class TestFileTreeModel:
def test_basic_setup(self):
model = TreeModelImp()
assert model.rowCount() == 0
# test FileTreeModel.addItem
model.addItem((('test',), 0))
assert model.rowCount() == 1
item = model.getItem(('test',))
assert item is not None
assert item.subpath == 'test'
assert len(item.children) == 0
assert item.data == 0 # test id
model.addItem((PurePath('/hello'), 1))
model.addItem(FileSystemItem(PurePath('/hello/test').parts, 2))
assert model.rowCount() == 2
item = model.getItem(('/',))
assert item is not None
assert item.subpath == '/'
assert len(item.children) == 1
item = model.getItem(PurePath('/hello/test').parts)
assert item is not None and item.data == 2
# test adding Item to existing parent
model.addItem((PurePath('test/subtest'), 3))
assert model.rowCount() == 2
item = model.getItem(PurePath('test/subtest').parts)
assert item is not None and item.data == 3
# test parent
assert (model.parent(model.indexPath(
PurePath('test/subtest'))) == model.indexPath(PurePath('test')))
# test index
item1 = model.getItem(('test',))
item2 = model.getItem(PurePath('test/subtest'))
index1 = model.index(1, 0)
assert index1.internalPointer() == item1
assert index1 == model.indexPath(PurePath('test'))
index2 = model.index(0, 0, index1)
assert index2.internalPointer() == item2
assert index2 == model.indexPath(PurePath('test/subtest'))
# test rowCount
assert model.rowCount() == 2
assert model.rowCount(index1) == 1
# test remove
model.removeItem(('test',))
assert model.rowCount() == 1
assert item1 not in model.root.children
model.removeItem(PurePath('/hello/test').parts)
assert model.rowCount() == 1
assert model.getItem(PurePath('/hello/test')) is None
item3 = model.getItem(PurePath('/hello'))
assert len(item3.children) == 0
def test_root(self):
model = TreeModelImp()
assert model.getItem(PurePath()) == model.root
def test_flat(self):
items = [
(PurePath('a'), 0),
(PurePath('a/a'), 1),
(PurePath('a/c'), 3),
(PurePath('a/b/a'), 4),
(PurePath('a/b/b'), 5),
(PurePath('a/b'), 2),
(PurePath('b'), 6),
(PurePath('b/a'), 7),
(PurePath('b/b'), 8),
]
model = TreeModelImp()
model.addItems(items)
# test flat representation
model.setMode(model.DisplayMode.FLAT)
assert model.rowCount() == len(items)
assert model.parent(model.index(4, 0)) == QModelIndex()
assert model.rowCount(model.index(3, 0)) == 0
item = model.getItem(PurePath('a/b/a'))
assert item is not None and item.data == 4
# test flat add
model.addItem((PurePath('a/a/a'), 10))
assert model.rowCount() == len(items) + 1
item = model.getItem(PurePath('a/a/a'))
assert item is not None and item.data == 10
# test flat remove
model.removeItem(PurePath('a/a/a'))
assert model.rowCount() == len(items)
assert item not in model._flattened
# test flat indexPath
index = model.indexPath(PurePath('a/b'))
assert index.internalPointer().data == 2
assert model._flattened[index.row()].data == 2
assert index.parent() == QModelIndex()
# test
model.setMode(model.DisplayMode.TREE)
assert model.rowCount() == 2
def test_simplified_tree(self):
items = [
(PurePath('a'), 0),
(PurePath('a/a'), 1),
(PurePath('a/c'), 3),
(PurePath('a/b/a'), 4),
(PurePath('a/b/b'), 5),
(PurePath('a/b'), 2),
(PurePath('b'), 6),
(PurePath('b/a'), 7),
(PurePath('b/b'), 8),
(PurePath('c'), 9),
(PurePath('c/a'), 10),
(PurePath('c/a/b'), 11),
(PurePath('c/a/b/c'), 12),
(PurePath('c/a/b/a'), 13),
(PurePath('c/a/b/a/b/c'), 14),
(PurePath('c/a/b/b/c/a'), 15),
]
model = TreeModelImp()
model.addItems(items)
# test tree representation
model.setMode(model.DisplayMode.SIMPLIFIED_TREE)
assert model.rowCount() == 3
a = model.index(0, 0)
assert model.rowCount(a) == 3
ab = model.index(1, 0, a)
assert model.rowCount(ab) == 2
assert model.parent(ab) == a
# test combined representation
cab = model.index(2, 0)
assert model.rowCount(cab) == 3
assert cab.internalPointer().data == 11
assert model.rowCount(cab) == 3
assert model.parent(cab) == QModelIndex()
cabc = model.index(2, 0, cab)
cababc = model.index(0, 0, cab)
cabbca = model.index(1, 0, cab)
assert cababc.internalPointer().data == 14
assert model.parent(cababc).internalId() == cab.internalId()
assert cabbca.internalPointer().data == 15
assert model.parent(cabbca).internalId() == cab.internalId()
assert cabc.internalPointer().data == 12
# test combined add
model.addItem((PurePath('a/a/a'), 30))
aaa = model.index(0, 0, a)
assert aaa.internalPointer().data == 30
assert model.rowCount(aaa) == 0
model.addItem((PurePath('c/a/a'), 31))
ca = model.index(2, 0)
assert ca.internalPointer().data == 10
assert ca.parent() == QModelIndex()
assert model.rowCount(ca) == 2
caa = model.index(0, 0, ca)
assert caa.internalPointer().data == 31
assert caa.parent().internalId() == ca.internalId()
# test combined remove
model.removeItem(PurePath('a/a/a').parts)
aa = model.index(0, 0, a)
assert aa.internalPointer().data == 1
assert model.rowCount(aa) == 0
model.removeItem(PurePath('c/a/a'))
cab = model.index(2, 0)
assert cab.internalPointer().data == 11
assert model.rowCount(cab) == 3
# test combined indexPath
index = model.indexPath(PurePath('a/b'))
assert index.internalPointer().data == 2
assert index.parent() == a
index = model.indexPath(PurePath('c/a/b'))
assert model.parent(index) == QModelIndex()
assert index.internalPointer().data == 11
assert model.rowCount(index) == 3
index = model.indexPath(PurePath('c/a'))
assert index == QModelIndex()
# test mode change
model.setMode(model.DisplayMode.TREE)
assert model.rowCount() == 3