Use new treemodel for extract dialog.

* src/vorta/views/extract_dialog.py: Add `ParseThread`, `ExtractTree` and `parse_json_lines`.

* src/vorta/views/extract_dialog.py (ExtractTree.__init__): Adjust signature.

* src/vorta/borg/list_archive.py (BorgListArchiveJob.prepare): Adjust format to
	include additional attributes.

* src/vorta/views/archive_tab.py (ArchiveTab): Parse extract data using `ParseThread` and
	open `ExtractDialog` afterwards.

* tests/test_archives.py (test_archive_extract): Update tests.

* src/vorta/borg/extract.py (BorgExtractJob.prepare): Adjust signature.
	Handling of the data isn't implemented yet.

* tests/test_extract.py
This commit is contained in:
real-yfprojects 2022-05-07 12:04:35 +02:00
parent 71aed9ba0e
commit 71af54f59b
No known key found for this signature in database
GPG Key ID: 00F630DFDEE25747
9 changed files with 6141 additions and 162 deletions

View File

@ -55,7 +55,11 @@
</layout>
</item>
<item>
<widget class="QTreeView" name="treeView"/>
<widget class="QTreeView" name="treeView">
<attribute name="headerMinimumSectionSize">
<number>10</number>
</attribute>
</widget>
</item>
<item>
<widget class="QLabel" name="label">

View File

@ -1,3 +1,5 @@
from vorta.views.extract_dialog import ExtractTree
from .borg_job import BorgJob
@ -13,7 +15,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, selected_files: ExtractTree, destination_folder):
ret = super().prepare(profile)
if not ret['ok']:
return ret
@ -22,7 +24,8 @@ class BorgExtractJob(BorgJob):
cmd = ['borg', 'extract', '--list', '--info', '--log-json']
cmd.append(f'{profile.repo.url}::{archive_name}')
for s in selected_files:
for s in selected_files: # TODO
cmd.append(s)
ret['ok'] = True

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

@ -26,13 +26,12 @@ 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 DiffResultDialog
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
from .diff_result import DiffTree, ParseThread
uifile = get_asset('UI/archivetab.ui')
ArchiveTabUI, ArchiveTabBase = uic.loadUiType(uifile)
@ -687,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()
@ -711,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):
@ -871,8 +892,9 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
model = DiffTree()
self._t = ParseThread(result['data'], result['params']['json_lines'],
model)
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()

View File

@ -62,7 +62,7 @@ class ParseThread(QThread):
class DiffResultDialog(DiffResultBase, DiffResultUI):
"""Display the results of `borg diff`."""
def __init__(self, archive_newer: str, archive_older: str,
def __init__(self, archive_newer, archive_older,
model: 'DiffTree'):
"""Init."""
super().__init__()

View File

@ -1,58 +1,69 @@
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.QtCore import QDateTime, QLocale, QModelIndex, Qt, QThread
from PyQt5.QtGui import QColor
from PyQt5.QtWidgets import QDialogButtonBox, QHeaderView, QPushButton
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 .partials.treemodel import (FileSystemItem, FileTreeModel, 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)
view.setModel(self.model)
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)
# add extract button to button box
@ -60,10 +71,13 @@ class ExtractDialog(ExtractDialogBase, ExtractDialogUI):
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.buttonBox.rejected.connect(self.close)
self.buttonBox.accepted.connect(self.accept)
@ -72,37 +86,462 @@ class ExtractDialog(ExtractDialogBase, ExtractDialogUI):
super().retranslateUi(dialog)
# setupUi calls retranslateUi
if hasattr(self, 'extractButton'):
if hasattr(self, "extractButton"):
self.extractButton.setText(self.tr("Extract"))
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 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):
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
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: FileSystemItem[FileData]) -> 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: FileSystemItem[FileData] = 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: FileSystemItem[FileData] = 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 parent.data.checked_children:
self.setData(index.parent(), Qt.CheckState.PartiallyChecked, role)
else:
self.setData(index.parent(), 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

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')

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