├── requirements.txt ├── requirements-lock.txt ├── .gitignore ├── setup.py ├── LICENSE ├── README.md ├── example └── simple.py └── src └── dicttreemodel.py /requirements.txt: -------------------------------------------------------------------------------- 1 | pyqt5 2 | -------------------------------------------------------------------------------- /requirements-lock.txt: -------------------------------------------------------------------------------- 1 | PyQt5==5.12.1 2 | PyQt5-sip==4.19.15 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | /.idea 4 | /venv 5 | /env 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup( 5 | name="dict-treemodel-qt5", 6 | description="QT5 TreeModel for dict, list and other python types", 7 | version="0.0.1", 8 | author="Frederik Schumacher", 9 | package_dir={ 10 | "": "src" 11 | }, 12 | packages=[ 13 | "dicttreemodel" 14 | ], 15 | install_requires=[ 16 | "PyQt5>=5.12.1" 17 | ], 18 | license="UNLICENSE", 19 | url="https://github.com/fre-sch/dict-treemodel-qt5", 20 | classifiers=[ 21 | "Programming Language :: Python :: 3", 22 | "License :: Public Domain", 23 | "Operating System :: OS Independent", 24 | ], 25 | ) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dict-treemodel-qt5 2 | 3 | A QT5 TreeModel to display nested dict, list and other Python types in a 4 | QTreeView. 5 | 6 | ## Example 7 | 8 | ```python 9 | # coding: utf-8 10 | import sys 11 | from PyQt5.QtWidgets import QApplication, QMainWindow, QTreeView 12 | from dicttreemodel import TreeModel 13 | 14 | 15 | class MainWindow(QMainWindow): 16 | def __init__(self, model): 17 | super().__init__() 18 | self.setWindowTitle("Treeview for nested dict/list") 19 | self.setGeometry(300, 300, 600, 800) 20 | tree_view = QTreeView() 21 | tree_view.setModel(model) 22 | tree_view.expandAll() 23 | tree_view.resizeColumnToContents(0) 24 | self.setCentralWidget(tree_view) 25 | 26 | 27 | data = { 28 | "students": [ 29 | { 30 | "id": 1, 31 | "first_name": "Alex", 32 | "last_name": "Alligator", 33 | "courses": [ 34 | { 35 | "id": "CS010", 36 | "title": "Computer Sciences Introduction" 37 | }, 38 | { 39 | "id": "MTH010", 40 | "title": "Math Introduction" 41 | } 42 | ] 43 | } 44 | ] 45 | } 46 | 47 | if __name__ == '__main__': 48 | app = QApplication(sys.argv) 49 | model = TreeModel(data) 50 | window = MainWindow(model) 51 | window.show() 52 | sys.exit(app.exec_()) 53 | ``` 54 | 55 | ## Example adapter 56 | 57 | Define two new classes and define an adapter that creates TreeNodes for both. 58 | 59 | Given some models in `model.py`: 60 | 61 | ```python 62 | from dataclasses import dataclass 63 | 64 | @dataclass 65 | class Course: 66 | id: str 67 | title: str 68 | 69 | @dataclass 70 | class Student: 71 | id: int 72 | first_name: str 73 | last_name: str 74 | courses: list 75 | ``` 76 | 77 | Define TreeNode adapters for the models: 78 | 79 | ```python 80 | from model import Course, Student 81 | from dicttreemodel import TreeNode 82 | 83 | course_attrs = ("id", "title") 84 | student_attrs = ("id", "first_name", "last_name", "courses") 85 | 86 | @TreeNode.adapter 87 | def course_model_adapter(cls, parent, value): 88 | if not isinstance(value, Course): 89 | raise cls.Unacceptable() 90 | return [cls(attr, getattr(value, attr), parent, i) 91 | for i, attr in enumerate(course_attrs)] 92 | 93 | @TreeNode.adapter 94 | def student_model_adapter(cls, parent, value): 95 | if not isinstance(value, Student): 96 | raise cls.Unacceptable() 97 | return [cls(attr, getattr(value, attr), parent, i) 98 | for i, attr in enumerate(student_attrs)] 99 | ``` 100 | 101 | Since dataclasses are used, it's possible to write a more generic adapter and 102 | fall back to the builtin Mapping adapter since dataclass instances can be 103 | converted to dict. 104 | 105 | ```python 106 | from dataclasses import asdict, is_dataclass 107 | from dicttreemodel import TreeNode 108 | 109 | @TreeNode.adapter 110 | def dataclass_adapter(cls, parent, value): 111 | if not is_dataclass(value): 112 | raise cls.Unacceptable() 113 | return cls.adapt(parent, asdict(value)) 114 | ``` 115 | -------------------------------------------------------------------------------- /example/simple.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import sys 3 | from random import randint, sample 4 | 5 | from PyQt5.QtWidgets import QApplication, QMainWindow, QTreeView 6 | 7 | from dicttreemodel import TreeModel 8 | 9 | songs = [ 10 | { 11 | "ID": "1", 12 | "songs": "Blurred Lines" 13 | }, 14 | { 15 | "ID": "2", 16 | "songs": "Nobody wants to be lonely" 17 | }, 18 | { 19 | "ID": "3", 20 | "songs": "How you remind me" 21 | }, 22 | { 23 | "ID": "4", 24 | "songs": "Knocking on heavens door" 25 | }, 26 | { 27 | "ID": "5", 28 | "songs": "Move to Miami" 29 | }, 30 | { 31 | "ID": "6", 32 | "songs": "Blurred Lines" 33 | }, 34 | { 35 | "ID": "7", 36 | "songs": "Beggin" 37 | }, 38 | { 39 | "ID": "8", 40 | "songs": "Brasil" 41 | }, 42 | { 43 | "ID": "9", 44 | "songs": "Rock this party" 45 | }, 46 | { 47 | "ID": "10", 48 | "songs": "Mambo No. 5" 49 | } 50 | ] 51 | 52 | 53 | data = { 54 | "members": [ 55 | { 56 | "ID": "1", 57 | "job_title": "Associate Professor", 58 | "mail": "Barry_Pond247@gompie.com", 59 | "first_name": "Barry", 60 | "last_name": "Pond", 61 | "favorite_songs": sample(songs, k=randint(1, 4)) 62 | }, 63 | { 64 | "ID": "2", 65 | "job_title": "Loan Officer", 66 | "mail": "Fred_Ellis1472@typill.biz", 67 | "first_name": "Fred", 68 | "last_name": "Ellis", 69 | "favorite_songs": sample(songs, k=randint(1, 4)) 70 | }, 71 | { 72 | "ID": "3", 73 | "job_title": "Call Center Representative", 74 | "mail": "Mason_Allcott3362@tonsy.org", 75 | "first_name": "Mason", 76 | "last_name": "Allcott", 77 | "favorite_songs": sample(songs, k=randint(1, 4)) 78 | }, 79 | { 80 | "ID": "4", 81 | "job_title": "Electrician", 82 | "mail": "Ruth_Lloyd9545@deons.tech", 83 | "first_name": "Ruth", 84 | "last_name": "Lloyd", 85 | "favorite_songs": sample(songs, k=randint(1, 4)) 86 | }, 87 | { 88 | "ID": "5", 89 | "job_title": "Banker", 90 | "mail": "Benjamin_Thomson9293@elnee.tech", 91 | "first_name": "Benjamin", 92 | "last_name": "Thomson", 93 | "favorite_songs": sample(songs, k=randint(1, 4)) 94 | }, 95 | { 96 | "ID": "6", 97 | "job_title": "Electrician", 98 | "mail": "Barney_Phillips7310@sveldo.biz", 99 | "first_name": "Barney", 100 | "last_name": "Phillips", 101 | "favorite_songs": sample(songs, k=randint(1, 4)) 102 | }, 103 | { 104 | "ID": "7", 105 | "job_title": "Biologist", 106 | "mail": "Rylee_Woodcock482@extex.org", 107 | "first_name": "Rylee", 108 | "last_name": "Woodcock", 109 | "favorite_songs": sample(songs, k=randint(1, 4)) 110 | }, 111 | { 112 | "ID": "8", 113 | "job_title": "Front Desk Coordinator", 114 | "mail": "Ryan_Exton3112@yahoo.com", 115 | "first_name": "Ryan", 116 | "last_name": "Exton", 117 | "favorite_songs": sample(songs, k=randint(1, 4)) 118 | }, 119 | { 120 | "ID": "9", 121 | "job_title": "Auditor", 122 | "mail": "Tom_Knott1259@fuliss.net", 123 | "first_name": "Tom", 124 | "last_name": "Knott", 125 | "favorite_songs": sample(songs, k=randint(1, 4)) 126 | }, 127 | { 128 | "ID": "10", 129 | "job_title": "CNC Operator", 130 | "mail": "Stacy_Ross987@dionrab.com", 131 | "first_name": "Stacy", 132 | "last_name": "Ross", 133 | "favorite_songs": sample(songs, k=randint(1, 4)) 134 | } 135 | ] 136 | } 137 | 138 | 139 | class MainWindow(QMainWindow): 140 | def __init__(self, model): 141 | super().__init__() 142 | self.setWindowTitle("Treeview for nested dict/list") 143 | self.setGeometry(300, 300, 600, 800) 144 | tree_view = QTreeView() 145 | tree_view.setModel(model) 146 | tree_view.expandAll() 147 | tree_view.resizeColumnToContents(0) 148 | self.setCentralWidget(tree_view) 149 | 150 | 151 | if __name__ == '__main__': 152 | app = QApplication(sys.argv) 153 | model = TreeModel(data) 154 | window = MainWindow(model) 155 | window.show() 156 | sys.exit(app.exec_()) 157 | -------------------------------------------------------------------------------- /src/dicttreemodel.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from collections import Sequence, Mapping, Iterable 3 | from functools import partial 4 | 5 | from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt 6 | 7 | 8 | def is_char_sequence(value) -> bool: 9 | """ 10 | In most instances testing for Sequence or Iterable, these string types are undesirable. 11 | """ 12 | return isinstance(value, (bytes, bytearray, str)) 13 | 14 | 15 | def is_sequence(value) -> bool: 16 | """ 17 | Is value a sequence (and also not a string type). 18 | """ 19 | return isinstance(value, Sequence) and not is_char_sequence(value) 20 | 21 | 22 | def is_iterable(value) -> bool: 23 | """ 24 | Is value an iterable (and also not a string or mapping type). 25 | """ 26 | return ( 27 | isinstance(value, Iterable) 28 | and not is_char_sequence(value) 29 | and not isinstance(value, Mapping) 30 | ) 31 | 32 | 33 | class TreeNode: 34 | """ 35 | TreeNode adapts Python data types to QTreeModel. 36 | 37 | QTreeView expects child nodes in the the tree to 'know' about their parents. 38 | Basic Python data types (dicts, lists, strings, etc) don't have references 39 | to their parents. TreeNode wraps plain data types to keep a reference to 40 | the parent. 41 | 42 | QTreeView also expects child nodes to be indexed per parent, or tree level. 43 | While this could be done with `list.index(item)` for lists, it's more 44 | complex for mapping types, and it's also inefficient. 45 | So TreeNode also stores the index per parent. 46 | 47 | The consequence is TreeNode isn't a dynamic adapter, ie. changes in the 48 | underlying data are not automatically reflected in the TreeNodes, the 49 | TreeModel and finally not immediately visible in the TreeView. To view 50 | changes, the tree must be rebuilt. 51 | 52 | Provide adapter callables to convert Python types to TreeNode. Comes with 53 | adapters for Iterable and Mapping. See ref:`TreeNode.adapter` for more. 54 | """ 55 | 56 | class Unacceptable(Exception): 57 | """ 58 | TreeNode adapters must raise this exception for types they don't handle. 59 | """ 60 | pass 61 | 62 | adapters = [] 63 | 64 | def __init__(self, key, value, parent=None, row=0): 65 | self.key = key 66 | self.value = self.adapt(self, value) 67 | self.parent = parent 68 | self.row = row 69 | 70 | @property 71 | def has_children(self): 72 | return is_sequence(self.value) 73 | 74 | def __len__(self): 75 | if self.has_children: 76 | return len(self.value) 77 | return 1 78 | 79 | def __getitem__(self, idx): 80 | if self.has_children: 81 | return self.value[idx] 82 | 83 | def data(self, col): 84 | if col == 0: 85 | return self.key 86 | elif col == 1: 87 | return self.value 88 | 89 | @classmethod 90 | def adapt(cls, parent, value): 91 | for adapter in cls.adapters: 92 | try: 93 | return adapter(cls, parent, value) 94 | except cls.Unacceptable: 95 | continue 96 | return value 97 | 98 | @classmethod 99 | def adapter(cls, fn): 100 | """ 101 | Decorator to add value-to-TreeNode adapters. 102 | Adapters must return a list of TreeNode, or must raise 103 | TreeNode.Unacceptable for types they don't adapt. 104 | 105 | Adapters have a signature of: 106 | 107 | ```python 108 | def adapter(cls: Type[TreeNode], 109 | parent: TreeNode, 110 | value: Any) -> Sequence[TreeNode]: 111 | pass 112 | ``` 113 | 114 | Example adapter for Iterable (builtin): 115 | 116 | ```python 117 | @TreeNode.adapter 118 | def iterable_adapter(cls, parent, value): 119 | if not is_iterable(value): 120 | raise cls.Unacceptable() 121 | return [cls(i, value, parent, i) 122 | for i, value in enumerate(value)] 123 | ``` 124 | 125 | Example adapter for Mapping (builtin): 126 | 127 | ```python 128 | @TreeNode.adapter 129 | def mapping_adapter(cls, parent, value): 130 | if not isinstance(value, Mapping): 131 | raise cls.Unacceptable() 132 | return [cls(key, value, parent, i) 133 | for i, (key, value) in enumerate(value.items())] 134 | ``` 135 | """ 136 | cls.adapters.append(fn) 137 | return fn 138 | 139 | 140 | @TreeNode.adapter 141 | def iterable_adapter(cls, parent, value): 142 | """ 143 | TreeNode adapter for Iterable (excluding Mappings and string types). 144 | """ 145 | if not is_iterable(value): 146 | raise cls.Unacceptable() 147 | return [cls(i, item_value, parent, i) 148 | for i, item_value in enumerate(value)] 149 | 150 | 151 | @TreeNode.adapter 152 | def mapping_adapter(cls, parent, value): 153 | """ 154 | TreeNode adapter for Mapping. 155 | """ 156 | if not isinstance(value, Mapping): 157 | raise cls.Unacceptable() 158 | return [cls(item_key, item_value, parent, i) 159 | for i, (item_key, item_value) in enumerate(value.items())] 160 | 161 | 162 | class TreeModel(QAbstractItemModel): 163 | COLUMN_HEADERS = ("Key", "Value") 164 | 165 | def __init__(self, data, parent_widget=None): 166 | super().__init__(parent_widget) 167 | self.root = TreeNode("__root__", data, None) 168 | 169 | def check_for_root(self, parent: QModelIndex): 170 | return self.root if not parent.isValid() else parent.internalPointer() 171 | 172 | def columnCount(self, parent: QModelIndex=None): 173 | return len(self.COLUMN_HEADERS) 174 | 175 | def headerData(self, section, orient, role=None): 176 | if orient == Qt.Horizontal and role == Qt.DisplayRole: 177 | return self.COLUMN_HEADERS[section] 178 | 179 | def rowCount(self, parent: QModelIndex): 180 | node = self.check_for_root(parent) 181 | return len(node) 182 | 183 | def index(self, row, col, parent: QModelIndex): 184 | node = self.check_for_root(parent) 185 | child = node[row] 186 | return self.createIndex(row, col, child) if child else QModelIndex() 187 | 188 | def parent(self, index: QModelIndex): 189 | if not index.isValid(): 190 | return QModelIndex() 191 | child = index.internalPointer() 192 | parent = child.parent 193 | if parent is self.root: 194 | return QModelIndex() 195 | return self.createIndex(parent.row, 0, parent) 196 | 197 | def hasChildren(self, parent: QModelIndex): 198 | node = self.check_for_root(parent) 199 | return node is not None and node.has_children 200 | 201 | def data(self, index: QModelIndex, role): 202 | if index.isValid() and role == Qt.DisplayRole: 203 | node = index.internalPointer() 204 | return node.data(index.column()) 205 | --------------------------------------------------------------------------------