Files
clever-show/Server/config_editor_models.py
2020-01-24 14:18:33 +03:00

828 lines
26 KiB
Python

import pickle
import logging
from ast import literal_eval
from functools import partial
from copy import deepcopy
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt, pyqtSlot
from PyQt5.QtGui import QCursor
from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu, QAction, QMessageBox, QInputDialog, QFileDialog
import config_editor
import sys
import os, inspect # Add parent dir to PATH to import messaging_lib
current_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
parent_dir = os.path.dirname(current_dir)
sys.path.insert(0, parent_dir)
import config
states_colors = {
'normal': Qt.white,
'unchanged': Qt.blue,
'default': Qt.cyan,
'edited': Qt.yellow,
'added': Qt.green,
'deleted': Qt.red,
}
StateRole = 999
TypeRole = 998
def convert_type(data):
try:
data = literal_eval(data) if data else None
except (SyntaxError, ValueError):
data = str(data)
return data
class ConfigModelItem:
def __init__(self, values=(None, None, None, None), item_type='option',
state='normal', default=None, parent=None):
self.spec_default = default
self.itemData = list(values)
self.state = state
self.type = item_type
if isinstance(self.data(1), list):
self.type = 'list'
self.default_values = deepcopy(self.itemData)
self.default_state = state
self.childItems = []
self.parentItem = parent
self.setup_type()
if self.parentItem is not None:
self.parentItem.appendChild(self)
def setup_type(self):
if self.type == 'section':
self.itemData[1:1] = ('<section>',)
self.spec_default = self.data(1)
elif self.type == 'list':
self._setup_list(self.get_list_items())
def _get_list_spec(self):
data = self.data(1)
comments = self.data(2)
if comments:
try:
raw_spec = comments.split('\n')[-1].split()[1:]
if raw_spec[0] == '__list__': # and len(raw_spec[1:]) == len(data):
return raw_spec[1:]
except IndexError:
pass
return list(map(str, range(len(data))))
def get_list_items(self):
spec = self._get_list_spec()
values = self.data(1)
if isinstance(self.spec_default, list):
defaults = self.spec_default
else:
defaults = (None, )*len(spec)
self.itemData[1] = '<list: {}>'.format(' '.join(spec))
# self.spec_default = self.itemData[1]
for key, value, default in zip(spec, values, defaults):
yield ConfigModelItem((key, value, None, None), item_type='list_item',
state=self.state, default=default)
def _setup_list(self, items): # use only at initialization
for child in items:
self.appendChild(child)
@property
def is_section(self): # probably deprecated
return self.type == 'section'
def appendChild(self, item):
self.childItems.append(item)
item.parentItem = self
def addChildren(self, items, row):
if row == -1:
row = 0
self.childItems[row:row] = items
for item in items:
item.parentItem = self
def child(self, row):
return self.childItems[row]
def childCount(self):
return len(self.childItems)
def columnCount(self):
return len(self.itemData)
def data(self, column):
try:
return self.itemData[column]
except IndexError:
return None
def set_data(self, data, column):
old_data = self.data(column)
if old_data is None:
data = convert_type(data)
if data == '<list>':
data = []
try:
self.itemData[column] = data
except IndexError:
return False
if old_data != data:
self.set_state('edited')
self.check_state()
return True
def check_state(self):
if self.spec_default is not None and self.data(1) == self.spec_default \
and self.data(0) == self.default_values[0]:
self.set_state('default')
def set_state(self, state):
if self.state == 'unchanged' and state == 'default':
return
if self.state == 'added' and state in ('edited', 'unchanged', 'default', 'normal'):
return
self.state = state
for child in self.childItems:
child.set_state(state)
if state == 'edited':
self.parentItem.state = state
def set_type(self, item_type):
self.type = item_type
def parent(self):
return self.parentItem
def row(self):
if self.parentItem is not None:
return self.parentItem.childItems.index(self)
return 0
def removeChild(self, position):
if position < 0 or position > len(self.childItems):
return False
child = self.childItems.pop(position)
child.parentItem = None
return True
def __repr__(self):
return str(self.itemData)
def ensure_unique_names(item, include_self=True):
name = item.data(0)
siblings_names = [child.data(0) for child in item.parent().childItems]
if not include_self:
siblings_names.remove(name)
while name in siblings_names:
if '_copy' in name:
spl = name.split('_copy')
num = int(spl[1]) if spl[1] else 0
num += 1
name = spl[0] + '_copy' + str(num)
else:
name = name + '_copy'
item.set_data(name, 0)
class ConfigModel(QtCore.QAbstractItemModel):
def __init__(self, parent=None, widget=None,
headers=("Option", "Value", 'Comment', 'Inline Comment')):
super(ConfigModel, self).__init__(parent)
self.widget = widget
self.rootItem = ConfigModelItem(headers)
self.do_color = True
self.initial_comment = ''
self.final_comment = ''
@QtCore.pyqtSlot(int)
def enable_color(self, value):
self.do_color = value
self.dataChanged.emit(QtCore.QModelIndex(), QtCore.QModelIndex(), (Qt.BackgroundRole, ))
def headerData(self, section, orientation, role):
if role == Qt.DisplayRole and orientation == Qt.Horizontal:
return self.rootItem.data(section)
def columnCount(self, parent):
return self.rootItem.columnCount()
def rowCount(self, parent):
if parent.column() > 0:
return 0
if not parent.isValid():
parentItem = self.rootItem
else:
parentItem = parent.internalPointer()
return parentItem.childCount()
def childrenIndexes(self, parent):
column = parent.column()
parent = self.index(parent.row(), 0, parent.parent())
for i in range(self.rowCount(parent)):
yield self.index(i, column, parent)
def index(self, row, column, parent):
if not self.hasIndex(row, column, parent):
return QtCore.QModelIndex()
parentItem = self.nodeFromIndex(parent)
childItem = parentItem.child(row)
if childItem:
return self.createIndex(row, column, childItem)
else:
return QtCore.QModelIndex()
def parent(self, index):
if not index.isValid():
return QtCore.QModelIndex()
childItem = index.internalPointer()
if not isinstance(childItem, ConfigModelItem):
print(childItem, index.column()), # index.row(), index.parent().internalPointer())
return QtCore.QModelIndex()
parentItem = childItem.parent()
if parentItem == self.rootItem: #or parentItem is None:
return QtCore.QModelIndex()
return self.createIndex(parentItem.row(), 0, parentItem)
def modifyCol(self, index, col):
return self.index(index.row(), col, index.parent())
def nodeFromIndex(self, index):
if index.isValid():
return index.internalPointer()
return self.rootItem
def data(self, index, role):
if not index.isValid():
return None
item = index.internalPointer()
if role == Qt.DisplayRole or role == Qt.EditRole:
return item.data(index.column())
if role == Qt.BackgroundRole and self.do_color:
return QtGui.QBrush(states_colors[item.state])
if role == StateRole:
return item.state
if role == TypeRole:
return item.type
return None
def setData(self, index, value, role=Qt.EditRole):
if not index.isValid():
return False
item = index.internalPointer()
if role == Qt.EditRole:
column = index.column()
if column == 0 and value != item.data(column):
if not self.widget.edit_caution():
return False
item.set_data(value, column)
if column == 0:
ensure_unique_names(item, include_self=False)
elif column == 1 and isinstance(item.data(1), (list, tuple)) \
and item.type not in ('list', 'list_item'):
item.set_type('list')
self.insertItems(0, list(item.get_list_items()), index)
self.widget.ui.config_view.expandAll()
elif role == StateRole:
item.set_state(value)
elif role == TypeRole:
# if value != item.type and value == 'list': # when list is created:
# pass
item.set_type(value)
self.dataChanged.emit(index, index, (role,))
return True
def flags(self, index):
if not index.isValid():
return QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsDropEnabled # Qt.NoItemFlags
item = index.internalPointer()
flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
if index.column() == 0:
if item.type != 'list_item':
flags |= int(QtCore.Qt.ItemIsDragEnabled)
if item.type == 'section':
flags |= int(QtCore.Qt.ItemIsDropEnabled)
not_section = not (index.column() > 0 and item.type == 'section')
not_list_item = not (index.column() > 1 and item.type == 'list_item')
not_list_val = not (index.column() == 1 and item.type == 'list')
if not_section and not_list_item and not_list_val:
flags |= Qt.ItemIsEditable
return flags
def supportedDropActions(self):
return QtCore.Qt.CopyAction | QtCore.Qt.MoveAction
def mimeTypes(self):
return ['app/configitem', 'text/xml']
def mimeData(self, indexes):
mimedata = QtCore.QMimeData()
index = indexes[0]
mimedata.setData('app/configitem', pickle.dumps(self.nodeFromIndex(index)))
return mimedata
def dropMimeData(self, mimedata, action, row, column, parentIndex):
if action == Qt.IgnoreAction:
return True
droppedNode = deepcopy(pickle.loads(mimedata.data('app/configitem')))
self.insertItems(row, [droppedNode], parentIndex)
self.dataChanged.emit(parentIndex, parentIndex)
self.widget.ui.config_view.expandAll()
if action & Qt.CopyAction:
return False # to not delete original item
return True
def removeRows(self, row, count, parent):
self.beginRemoveRows(parent, row, row + count - 1)
parentItem = self.nodeFromIndex(parent)
for _ in range(count):
parentItem.removeChild(row)
self.endRemoveRows()
return True
def removeRow(self, index):
parent = index.parent()
self.beginRemoveRows(parent, index.row(), index.row())
parentItem = self.nodeFromIndex(parent)
parentItem.removeChild(index.row())
self.endRemoveRows()
return True
def insertItems(self, row, items, parentIndex):
parent = self.nodeFromIndex(parentIndex)
self.beginInsertRows(parentIndex, row, row + len(items) - 1) # parentIndex or QtCore.QModelIndex()
parent.addChildren(items, row)
self.endInsertRows()
self.update_all()
return True
def update_all(self):
self.dataChanged.emit(QtCore.QModelIndex(), QtCore.QModelIndex())
def dict_setup(self, data: dict, parent=None, convert_types=False):
if parent is None:
parent = self.rootItem
for key, value in data.items():
if isinstance(value, dict):
item = ConfigModelItem((key,), parent=parent, item_type='section')
self.dict_setup(value, parent=item)
else:
if convert_types:
value = convert_type(value)
parent.appendChild(ConfigModelItem((key, value, '', '')))
def config_dict_setup(self, data: dict, convert_types=False, parent=None):
if parent is None:
parent = self.rootItem
self.initial_comment = '\n'.join(data.pop('initial_comment', ['']))
self.final_comment = '\n'.join(data.pop('final_comment', ['']))
for key, item in data.items():
if item.get('__option__', False):
value = item['value']
if convert_types:
value = convert_type(value)
default = item['default']
comments = '\n'.join(item['comments']) or ''
inline_comment = item['inline_comment'] or ''
if item['unchanged']:
state = 'unchanged'
elif value == default:
state = 'default'
else:
state = 'normal'
parent.appendChild(ConfigModelItem((key, value, comments, inline_comment),
state=state, default=default))
else:
section = ConfigModelItem((key,), parent=parent, item_type='section')
self.config_dict_setup(item, convert_types=convert_types, parent=section)
def to_dict(self, parent=None) -> dict:
if parent is None:
parent = self.rootItem
data = {}
for item in parent.childItems:
item_name, item_data = item.data(0), item.data(1)
if item.is_section:
data[item_name] = self.to_dict(item)
else:
data[item_name] = item_data
return data
def to_config_dict(self, parent=None) -> dict:
data = {}
if parent is None:
parent = self.rootItem
data['initial_comment'] = self.initial_comment.split('\n')
data['final_comment'] = self.final_comment.split('\n')
for item in parent.childItems:
key = item.data(0)
if item.is_section:
d = self.to_config_dict(item)
if d: # to prevent empty sections
data[key] = d
elif item.state not in ('unchanged', 'deleted'):
if item.type == 'list':
value = [child.data(1) for child in item.childItems]
else:
value = item.data(1)
d = {'__option__': True,
'value': value,
# 'default': item.default,
# 'unchanged': False,
'comments': (item.data(2) or '').split('\n'),
'inline_comment': item.data(3) or ''
}
data[key] = d
return data
@property
def dict(self):
return self.to_dict()
class ConfigTreeWidget(QTreeView):
def __init__(self):
QTreeView.__init__(self)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.open_menu)
self.setSelectionMode(self.SingleSelection)
# self.setSelectionBehavior(self.SelectItems)
self.setDragDropMode(QAbstractItemView.DragDrop)
self.setDefaultDropAction(Qt.MoveAction)
self.setDragEnabled(True)
self.setAcceptDrops(True)
self.setDropIndicatorShown(True)
self.setAnimated(True)
def open_menu(self, point):
index = self.indexAt(point)
item = index.internalPointer()
menu = QMenu()
duplicate = QAction("Duplicate")
duplicate.triggered.connect(partial(self.duplicate, index))
menu.addAction(duplicate)
exclude = QAction("Toggle exclude")
exclude.triggered.connect(partial(self.exclude, index))
menu.addAction(exclude)
remove = QAction("Remove from config")
remove.triggered.connect(partial(self.remove, index))
menu.addAction(remove)
menu.addSeparator()
clear = QAction("Clear item value")
clear.triggered.connect(partial(self.reset_item, index, 'clear_value'))
menu.addAction(clear)
reset_default = QAction("Reset value to default")
reset_default.triggered.connect(partial(self.reset_item, index, 'default'))
menu.addAction(reset_default)
reset_all = QAction("Reset all changes")
reset_all.triggered.connect(partial(self.reset_item, index, 'all'))
menu.addAction(reset_all)
menu.addSeparator()
add_option = QAction("Add option")
add_option.triggered.connect(partial(self.add_item, index, False))
menu.addAction(add_option)
add_section = QAction("Add section")
add_section.triggered.connect(partial(self.add_item, index, True))
menu.addAction(add_section)
if item is None:
clear.setDisabled(True)
reset_all.setDisabled(True)
reset_default.setDisabled(True)
duplicate.setDisabled(True)
remove.setDisabled(True)
exclude.setDisabled(True)
else:
if item.type in ('list', 'list_item'):
add_section.setDisabled(True)
if item.type == 'list':
clear.setDisabled(True) # Temporary, cuz buggg
# if item.type == 'section':
# clear.setDisabled(True)
menu.exec_(QCursor.pos())
def duplicate(self, index):
item = deepcopy(index.internalPointer())
item.set_state('added')
ensure_unique_names(item)
self.model().insertItems(index.row() + 1, [item], index.parent())
self.expandAll() # fix not expanded duplicated section
def remove(self, index):
self.model().removeRow(index)
def exclude(self, index):
item = self.model().nodeFromIndex(index)
if item.state == 'deleted':
self.model().setData(index, item.default_state, StateRole)
else:
self.model().setData(index, 'deleted', StateRole)
def add_item(self, index, is_section):
parentItem = self.model().nodeFromIndex(index)
if parentItem.type in ('list', 'list_item'):
item_type = 'list_item'
else:
item_type = 'section' if is_section else 'option'
prompt = 'Enter {} name'.format(item_type.replace('_', ' '))
text, ok = QInputDialog.getText(self, prompt, prompt)
if not ok:
return
if parentItem.type in ('list', 'section'): # to append at first index in section or list
row = 0
parent = index
else:
row = index.row()
parent = index.parent()
if row == -1: # to append at last position e.g. at root
row = parentItem.childCount()
else:
row += 1 # to append under current position
item = ConfigModelItem((text, None, '', ''), item_type=item_type, state='added')
self.model().insertItems(row, [item], parent)
ensure_unique_names(item, include_self=False)
# parent.internalPointer().set_state('edited')
self.expandAll()
def reset_item(self, index, reset_type):
item = index.internalPointer()
model = self.model()
itemdataindex = model.modifyCol(index, 1)
if reset_type == 'all':
for i, default in enumerate(item.default_values):
model.setData(model.modifyCol(index, i), default)
model.setData(index, item.default_state, role=StateRole)
elif reset_type == 'default':
# if item.type == 'list' and \
# not isinstance(item.spec_default, (list, tuple)):
# self.reset_item(item, 'clear_value')
model.setData(itemdataindex, item.spec_default)
if item.default_state == 'unchanged':
model.setData(index, 'unchanged', role=StateRole)
elif reset_type == 'clear_value':
model.setData(itemdataindex, None)
# if model.data(itemdataindex, TypeRole) == 'list':
# model.removeRows(0, item.childCount(), index)
# model.setData(index, 'option', role=TypeRole)
# return
for child in model.childrenIndexes(index):
self.reset_item(child, reset_type)
class ConfigDialog(QtWidgets.QDialog):
copter_editor_signal = QtCore.pyqtSignal(object, object)
def __init__(self, parent=None):
super(ConfigDialog, self).__init__(parent)
self.ui = config_editor.Ui_config_dialog()
self.model = ConfigModel(widget=self)
self.setupUi()
self.copter_editor_signal.connect(self._call_copter_dialog)
def setupModel(self, data, pure_dict=False, convert_types=False):
if pure_dict:
self.model.dict_setup(data, convert_types=convert_types)
else:
self.model.config_dict_setup(data, convert_types=convert_types)
self.ui.config_view.expandAll()
self.ui.config_view.resizeColumnToContents(0)
self.ui.config_view.resizeColumnToContents(1)
def setupUi(self):
self.ui.setupUi(self)
self.ui.config_view = ConfigTreeWidget()
self.ui.config_view.setObjectName("config_view")
self.ui.config_view.setModel(self.model)
self.ui.gridLayout.addWidget(self.ui.config_view, 0, 0, 1, 1)
self.ui.config_view.expandAll()
self.ui.do_coloring.stateChanged.connect(self.model.enable_color)
# self.ui.delete_button.pressed.connect(self.remove_selected)
# index = self.config_view.selectedIndexes()[0]
def edit_caution(self):
reply = QMessageBox().warning(self, "Editing caution",
"Are you sure you want to edit section/option name? "
"Proceed with caution!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
)
return reply == QMessageBox.Yes
@pyqtSlot()
def run(self):
self.show()
self.exec()
return self.result()
def validation_loop(self, cfg, configspec=None): # modifies cfg object
while True:
if not self.run():
return False
try:
cfg.load_from_dict(self.model.to_config_dict(), configspec=configspec)
except config.ValidationError as error:
msg = "Can not validate. Proceed with editing? Errors: \n" + "\n".join(error.flatten_errors())
reply = QMessageBox.warning(self, "Validation error!", msg, QMessageBox.Yes | QMessageBox.Cancel)
if reply == QMessageBox.Cancel:
return False
else:
return True
def call_copter_dialog(self, client, value):
self.copter_editor_signal.emit(client, value)
@pyqtSlot(object, object)
def _call_copter_dialog(self, client, value):
logging.info("Opening dialog")
config_dict, spec_dict = value["config"], value["configspec"]
cfg = config.ConfigManager()
cfg.load_from_dict(config_dict, spec_dict)
self.setupModel(config_dict)
if not self.validation_loop(cfg, spec_dict):
return False
edited_dict = self.model.to_config_dict()
client.send_message("config", {"config": edited_dict, "mode": "rewrite"})
if self.ui.do_restart.isChecked():
client.send_message("service_restart", {"name": "clever-show"})
return True
def call_standalone_dialog(self):
path = QFileDialog.getOpenFileName(self, "Select configuration or specification file",
filter="Config and spec files (*.ini)")[0]
if not path:
return False
cfg = config.ConfigManager()
try:
cfg.load_from_file(path)
except ValueError as error: # When file do not exist or not validated properly
QMessageBox.warning(self, "Error while opening file!",
"Config cannot be opened or validated: {}".format(error))
return False
self.setupModel(cfg.full_dict, convert_types=(not cfg.validated))
self.ui.do_restart.setDisabled(True)
filename = cfg.config.filename
validation_path = path if cfg.config.filename is None else cfg.config.filename
validation_path = validation_path if cfg.validated else None
if not self.validation_loop(cfg, validation_path):
return False
if filename is None:
save_path = QFileDialog.getSaveFileName(self, "Save configuration file",
filter="Config files (*.ini)")[0]
if not save_path:
return False
else:
save_path = filename
cfg.config.filename = save_path
cfg.write()
if __name__ == '__main__':
def except_hook(cls, exception, traceback):
print(cls, exception, traceback)
sys.__excepthook__(cls, exception, traceback)
sys.excepthook = except_hook
app = QtWidgets.QApplication(sys.argv)
ui = ConfigDialog()
ui.call_standalone_dialog()
# d = {'section': {'opt': 1, "opt222": 'text'}}
# ui.setupModel(d, pure_dict=True)
# ui.show()
# app.exec()
# print(ui.model.to_config_dict())