diff --git a/examples/DiffTreeWidget.py b/examples/DiffTreeWidget.py new file mode 100644 index 00000000..fa57a356 --- /dev/null +++ b/examples/DiffTreeWidget.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- + +""" +Simple use of DiffTreeWidget to display differences between structures of +nested dicts, lists, and arrays. +""" + +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + + +app = QtGui.QApplication([]) +A = { + 'a list': [1,2,2,4,5,6, {'nested1': 'aaaa', 'nested2': 'bbbbb'}, "seven"], + 'a dict': { + 'x': 1, + 'y': 2, + 'z': 'three' + }, + 'an array': np.random.randint(10, size=(40,10)), + #'a traceback': some_func1(), + #'a function': some_func1, + #'a class': pg.DataTreeWidget, +} + +B = { + 'a list': [1,2,3,4,5,5, {'nested1': 'aaaaa', 'nested2': 'bbbbb'}, "seven"], + 'a dict': { + 'x': 2, + 'y': 2, + 'z': 'three', + 'w': 5 + }, + 'another dict': {1:2, 2:3, 3:4}, + 'an array': np.random.randint(10, size=(40,10)), +} + +tree = pg.DiffTreeWidget() +tree.setData(A, B) +tree.show() +tree.setWindowTitle('pyqtgraph example: DiffTreeWidget') +tree.resize(1000, 800) + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() \ No newline at end of file diff --git a/pyqtgraph/__init__.py b/pyqtgraph/__init__.py index f8983455..de9fb278 100644 --- a/pyqtgraph/__init__.py +++ b/pyqtgraph/__init__.py @@ -240,6 +240,7 @@ from .widgets.VerticalLabel import * from .widgets.FeedbackButton import * from .widgets.ColorButton import * from .widgets.DataTreeWidget import * +from .widgets.DiffTreeWidget import * from .widgets.GraphicsView import * from .widgets.LayoutWidget import * from .widgets.TableWidget import * diff --git a/pyqtgraph/widgets/DataTreeWidget.py b/pyqtgraph/widgets/DataTreeWidget.py index f960cab9..39cb0d45 100644 --- a/pyqtgraph/widgets/DataTreeWidget.py +++ b/pyqtgraph/widgets/DataTreeWidget.py @@ -31,20 +31,35 @@ class DataTreeWidget(QtGui.QTreeWidget): """data should be a dictionary.""" self.clear() self.widgets = [] + self.nodes = {} self.buildTree(data, self.invisibleRootItem(), hideRoot=hideRoot) self.expandToDepth(3) self.resizeColumnToContents(0) - def buildTree(self, data, parent, name='', hideRoot=False): + def buildTree(self, data, parent, name='', hideRoot=False, path=()): if hideRoot: node = parent else: node = QtGui.QTreeWidgetItem([name, "", ""]) parent.addChild(node) + + # record the path to the node so it can be retrieved later + # (this is used by DiffTreeWidget) + self.nodes[path] = node typeStr, desc, childs, widget = self.parse(data) node.setText(1, typeStr) node.setText(2, desc) + + # Truncate description and add text box if needed + if len(desc) > 100: + desc = desc[:97] + '...' + if widget is None: + widget = QtGui.QPlainTextEdit(asUnicode(data)) + widget.setMaximumHeight(200) + widget.setReadOnly(True) + + # Add widget to new subnode if widget is not None: self.widgets.append(widget) subnode = QtGui.QTreeWidgetItem(["", "", ""]) @@ -52,8 +67,9 @@ class DataTreeWidget(QtGui.QTreeWidget): self.setItemWidget(subnode, 0, widget) self.setFirstItemColumnSpanned(subnode, True) - for name, data in childs.items(): - self.buildTree(data, node, asUnicode(name)) + # recurse to children + for key, data in childs.items(): + self.buildTree(data, node, asUnicode(key), path=path+(key,)) def parse(self, data): """ @@ -103,11 +119,6 @@ class DataTreeWidget(QtGui.QTreeWidget): widget.setReadOnly(True) else: desc = asUnicode(data) - if len(desc) > 100: - desc = desc[:97] + '...' - widget = QtGui.QPlainTextEdit(asUnicode(data)) - widget.setMaximumHeight(200) - widget.setReadOnly(True) return typeStr, desc, childs, widget \ No newline at end of file diff --git a/pyqtgraph/widgets/DiffTreeWidget.py b/pyqtgraph/widgets/DiffTreeWidget.py new file mode 100644 index 00000000..e3869da8 --- /dev/null +++ b/pyqtgraph/widgets/DiffTreeWidget.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +from ..Qt import QtGui, QtCore +from ..pgcollections import OrderedDict +from .DataTreeWidget import DataTreeWidget +from .. import functions as fn +import types, traceback +import numpy as np + +__all__ = ['DiffTreeWidget'] + + +class DiffTreeWidget(QtGui.QWidget): + """ + Widget for displaying differences between hierarchical python data structures + (eg, nested dicts, lists, and arrays) + """ + def __init__(self, parent=None, a=None, b=None): + QtGui.QWidget.__init__(self, parent) + self.layout = QtGui.QHBoxLayout() + self.setLayout(self.layout) + self.trees = [DataTreeWidget(self), DataTreeWidget(self)] + for t in self.trees: + self.layout.addWidget(t) + if a is not None: + self.setData(a, b) + + def setData(self, a, b): + """ + Set the data to be compared in this widget. + """ + self.data = (a, b) + self.trees[0].setData(a) + self.trees[1].setData(b) + + return self.compare(a, b) + + def compare(self, a, b, path=()): + """ + Compare data structure *a* to structure *b*. + + Return True if the objects match completely. + Otherwise, return a structure that describes the differences: + + { 'type': bool + 'len': bool, + 'str': bool, + 'shape': bool, + 'dtype': bool, + 'mask': array, + } + + + """ + bad = (255, 200, 200) + diff = [] + # generate typestr, desc, childs for each object + typeA, descA, childsA, _ = self.trees[0].parse(a) + typeB, descB, childsB, _ = self.trees[1].parse(b) + + if typeA != typeB: + self.setColor(path, 1, bad) + if descA != descB: + self.setColor(path, 2, bad) + + if isinstance(a, dict) and isinstance(b, dict): + keysA = set(a.keys()) + keysB = set(b.keys()) + for key in keysA - keysB: + self.setColor(path+(key,), 0, bad, tree=0) + for key in keysB - keysA: + self.setColor(path+(key,), 0, bad, tree=1) + for key in keysA & keysB: + self.compare(a[key], b[key], path+(key,)) + + elif isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)): + for i in range(max(len(a), len(b))): + if len(a) <= i: + self.setColor(path+(i,), 0, bad, tree=1) + elif len(b) <= i: + self.setColor(path+(i,), 0, bad, tree=0) + else: + self.compare(a[i], b[i], path+(i,)) + + elif isinstance(a, np.ndarray) and isinstance(b, np.ndarray) and a.shape == b.shape: + tableNodes = [tree.nodes[path].child(0) for tree in self.trees] + if a.dtype.fields is None and b.dtype.fields is None: + eq = self.compareArrays(a, b) + if not np.all(eq): + for n in tableNodes: + n.setBackground(0, fn.mkBrush(bad)) + #for i in np.argwhere(~eq): + + else: + for i,k in enumerate(info.dtype.fields.keys()): + eq = self.compareArrays(a[k], b[k]) + if not np.all(eq): + for n in tableNodes: + n.setBackground(0, fn.mkBrush(bad)) + #for j in np.argwhere(~eq): + + # dict: compare keys, then values where keys match + # list: + # array: compare elementwise for same shape + + def compareArrays(self, a, b): + intnan = -9223372036854775808 # happens when np.nan is cast to int + anans = np.isnan(a) | (a == intnan) + bnans = np.isnan(b) | (b == intnan) + eq = anans == bnans + mask = ~anans + eq[mask] = np.allclose(a[mask], b[mask]) + return eq + + def setColor(self, path, column, color, tree=None): + brush = fn.mkBrush(color) + + # Color only one tree if specified. + if tree is None: + trees = self.trees + else: + trees = [self.trees[tree]] + + for tree in trees: + item = tree.nodes[path] + item.setBackground(column, brush) + + def _compare(self, a, b): + """ + Compare data structure *a* to structure *b*. + """ + # Check test structures are the same + assert type(info) is type(expect) + if hasattr(info, '__len__'): + assert len(info) == len(expect) + + if isinstance(info, dict): + for k in info: + assert k in expect + for k in expect: + assert k in info + self.compare_results(info[k], expect[k]) + elif isinstance(info, list): + for i in range(len(info)): + self.compare_results(info[i], expect[i]) + elif isinstance(info, np.ndarray): + assert info.shape == expect.shape + assert info.dtype == expect.dtype + if info.dtype.fields is None: + intnan = -9223372036854775808 # happens when np.nan is cast to int + inans = np.isnan(info) | (info == intnan) + enans = np.isnan(expect) | (expect == intnan) + assert np.all(inans == enans) + mask = ~inans + assert np.allclose(info[mask], expect[mask]) + else: + for k in info.dtype.fields.keys(): + self.compare_results(info[k], expect[k]) + else: + try: + assert info == expect + except Exception: + raise NotImplementedError("Cannot compare objects of type %s" % type(info)) + \ No newline at end of file diff --git a/pyqtgraph/widgets/TableWidget.py b/pyqtgraph/widgets/TableWidget.py index 69085a20..db0b6ae2 100644 --- a/pyqtgraph/widgets/TableWidget.py +++ b/pyqtgraph/widgets/TableWidget.py @@ -151,7 +151,8 @@ class TableWidget(QtGui.QTableWidget): i += 1 self.setRow(i, [x for x in fn1(row)]) - if self._sorting and self.horizontalHeader().sortIndicatorSection() >= self.columnCount(): + if (self._sorting and self.horizontalHeadersSet and + self.horizontalHeader().sortIndicatorSection() >= self.columnCount()): self.sortByColumn(0, QtCore.Qt.AscendingOrder) def setEditable(self, editable=True):