├── .gitignore ├── LICENSE ├── README.md ├── example.py ├── qt_json_view ├── __init__.py ├── datatypes.py ├── delegate.py ├── model.py └── view.py ├── setup.py └── tests └── test_model.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Paul Schweizer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Qt JSON View 2 | 3 | This widget allows to display and edit JSON-serializable data in a Qt view. The system is easily extensible with custom types. 4 | 5 | An example to get you started is [here](example.py). 6 | 7 | ![Qt JSON View](qt-json-view.png) 8 | 9 | ## Overview 10 | 11 | A provided JSON-serializable dict or list is [converted](qt_json_view/model.py#L18) to a [JsonModel](qt_json_view/model.py#L6) derived from QStandardItemModel. 12 | During conversion, each entry in the source data is mapped to a [DataType](qt_json_view/datatypes.py#L11). 13 | The [DataType](qt_json_view/datatypes.py#L11) defines how the entry is added to the [JsonModel](qt_json_view/model.py#L6), how it is serialized. The [JsonDelegate](qt_json_view/delegate.py#L6) draws on the optional [DataType](qt_json_view/datatypes.py#L11) implementations to display the item. The [DataType](qt_json_view/datatypes.py#L11) can also define custom right-click [QActions](qt_json_view/datatypes.py#L24) for the item. 14 | The [JsonModel](qt_json_view/model.py#L6) can then be [serialized](qt_json_view/model.py#L29) back into a dictionary after editing. 15 | 16 | ## DataTypes 17 | 18 | A number of data types are already implemented, but it is easy to implement and inject your own on the fly, please see section below. 19 | 20 | **Standard JSON Types:** 21 | 22 | * [NoneType](qt_json_view/datatypes.py#L85): None 23 | * [BoolType](qt_json_view/datatypes.py#L108): bool 24 | * [IntType](qt_json_view/datatypes.py#L115): int 25 | * [FloatType](qt_json_view/datatypes.py#L122): float 26 | * [StrType](qt_json_view/datatypes.py#L129): str and unicode 27 | * [ListType](qt_json_view/datatypes.py#L156): list 28 | * [DictType](qt_json_view/datatypes.py#L194): dict 29 | 30 | **Custom Types:** 31 | 32 | * [UrlType](qt_json_view/datatypes.py#L344): Detects urls and provides an "Explore ..." action opening the web browser. 33 | * [FilepathType](qt_json_view/datatypes.py#L362): Detects file paths and provides an "Explore ..." action opening the file browser 34 | * [RangeType](qt_json_view/datatypes.py#L235): A range is displayed in one row and has to be a dict in the form of, both floats and ints are allowed and displayed accordingly: 35 | ```json 36 | { 37 | "start": 0, 38 | "end": 100, 39 | "step": 2.5 40 | } 41 | ``` 42 | 43 | * [ChoicesType](qt_json_view/datatypes.py#L380): The user can choose from a range of choices. It is shown as a combobox. The data has to be a dict in the form: 44 | ```json 45 | { 46 | "value": "A", 47 | "choices": ["A", "B", "C"] 48 | } 49 | ``` 50 | 51 | ### Implement custom DataTypes 52 | 53 | Subclass the [DataType](qt_json_view/datatypes.py#L11) base class and implement what you need, at least the [matches](qt_json_view/datatypes.py#L16) method. 54 | Then inject an instance of your DataType into [datatypes.DATA_TYPES](qt_json_view/datatypes.py#L433) so it is found when the model is initialized. 55 | Make sure to inject it at the right position in the list [datatypes.DATA_TYPES](qt_json_view/datatypes.py#L433) list since the model uses the first match it finds. 56 | 57 | ```python 58 | from qt_json_view import datatypes 59 | 60 | class TestType(object): 61 | 62 | def matches(self, data): 63 | if data == "TEST": 64 | return True 65 | return False 66 | 67 | idx = [i for i in datatypes.DATA_TYPES if isinstance(i, datatypes.StrType)][0] 68 | datatypes.DATA_TYPES.insert(idx, TestType()) 69 | ``` 70 | 71 | ## View 72 | 73 | The [JsonView](qt_json_view/view.py) is a QTreeView with the delegate.JsonDelegate. 74 | 75 | ## Model 76 | 77 | The [JsonModel](qt_json_view/model.py) is a QStandardItemModel. It can be initialized from a JSON-serializable object and serialized to a JSON-serializable object. 78 | 79 | ## Filtering 80 | 81 | The [JsonSortFilterProxyModel](qt_json_view/model.py#L41) is a QSortFilterProxyModel extended to filter through the entire tree. 82 | 83 | ## Delegate 84 | 85 | The [JsonDelegate](qt_json_view/delegate.py) draws on the DataTypes of the items to determine how they are drawn. The [DataType](qt_json_view/datatypes.py#L11) uses the paint, createEditor and setModelData methods if they are available on the DataType. 86 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import json 3 | import os 4 | 5 | from Qt import QtWidgets 6 | 7 | from qt_json_view import model 8 | from qt_json_view.model import JsonModel 9 | from qt_json_view.view import JsonView 10 | 11 | 12 | dict_data = { 13 | # Defaut JSON Types 14 | # 15 | 'none': None, 16 | 'bool': True, 17 | 'int': 666, 18 | 'float': 1.23, 19 | 'list1': [ 20 | 1, 21 | 2, 22 | 3, 23 | { 24 | 'd': [ 25 | 4, 5, 6 26 | ] 27 | } 28 | ], 29 | 'dict': { 30 | 'key': 'value', 31 | 'another_dict': { 32 | 'a': 'b' 33 | } 34 | }, 35 | 36 | # Custom Types 37 | # 38 | 'url (http)': 'http://www.python.com', 39 | 'url (https)': 'https://www.python.com', 40 | 'url (file)': 'file://{0}'.format(__file__), 41 | 'filepath (folder)': os.path.dirname(__file__), 42 | 'filepath (file)': __file__, 43 | 'choice (str)': { 44 | 'value': 'A', 45 | 'choices': ['A', 'B', 'C'] 46 | }, 47 | 'choice (int)': { 48 | 'value': 1, 49 | 'choices': [1, 2, 3] 50 | }, 51 | 'choice None': { 52 | 'value': None, 53 | 'choices': ['A', 'B'] 54 | }, 55 | 'range': { 56 | 'start': 0, 57 | 'end': 100, 58 | 'step': 0.5 59 | } 60 | } 61 | 62 | 63 | if __name__ == '__main__': 64 | app = QtWidgets.QApplication([]) 65 | 66 | widget = QtWidgets.QWidget() 67 | widget.setLayout(QtWidgets.QVBoxLayout()) 68 | 69 | filter_widget = QtWidgets.QWidget() 70 | filter_widget.setLayout(QtWidgets.QHBoxLayout()) 71 | 72 | filter_label = QtWidgets.QLabel('Filter:') 73 | filter_widget.layout().addWidget(filter_label) 74 | 75 | filter_text = QtWidgets.QLineEdit() 76 | filter_widget.layout().addWidget(filter_text) 77 | 78 | filter_column = QtWidgets.QComboBox() 79 | filter_column.addItems(['Key', 'Value']) 80 | filter_widget.layout().addWidget(filter_column) 81 | 82 | sort_button = QtWidgets.QPushButton('Sort') 83 | filter_widget.layout().addWidget(sort_button) 84 | 85 | widget.layout().addWidget(filter_widget) 86 | 87 | view = JsonView() 88 | widget.layout().addWidget(view) 89 | 90 | button = QtWidgets.QPushButton('Serialize') 91 | widget.layout().addWidget(button) 92 | 93 | proxy = model.JsonSortFilterProxyModel() 94 | 95 | model = JsonModel(data=dict_data, editable_keys=True, editable_values=True) 96 | proxy.setSourceModel(model) 97 | view.setModel(proxy) 98 | 99 | def filter_(): 100 | proxy.setFilterKeyColumn(filter_column.currentIndex()) 101 | proxy.setFilterRegExp(filter_text.text()) 102 | view.expandAll() 103 | 104 | def serialize(): 105 | print(json.dumps(model.serialize(), indent=2)) 106 | 107 | button.clicked.connect(serialize) 108 | filter_text.textChanged.connect(filter_) 109 | filter_column.currentIndexChanged.connect(filter_) 110 | sort_button.clicked.connect(partial(proxy.sort, 0)) 111 | 112 | widget.show() 113 | view.expandAll() 114 | 115 | app.exec_() 116 | -------------------------------------------------------------------------------- /qt_json_view/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PaulSchweizer/qt-json-view/4fc759cc90fefc8ef34974471492e284f55bb77d/qt_json_view/__init__.py -------------------------------------------------------------------------------- /qt_json_view/datatypes.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import webbrowser 4 | from collections import OrderedDict 5 | from functools import partial 6 | 7 | import six 8 | from Qt import QtCore, QtGui, QtWidgets 9 | 10 | TypeRole = QtCore.Qt.UserRole + 1 11 | SchemaRole = QtCore.Qt.UserRole + 2 12 | 13 | 14 | class DataType(object): 15 | """Base class for data types.""" 16 | 17 | COLOR = QtCore.Qt.white 18 | INACTIVE_COLOR = QtCore.Qt.lightGray 19 | 20 | DEFAULT = None 21 | ITEM = QtGui.QStandardItem 22 | 23 | def matches(self, data): 24 | """Logic to define whether the given data matches this type.""" 25 | raise NotImplementedError 26 | 27 | def empty_container(self): 28 | """Return an empty container object for the children of this type.""" 29 | raise NotImplementedError 30 | 31 | def next(self, model, data, parent): 32 | """Implement if this data type has to add child items to itself.""" 33 | pass 34 | 35 | def actions(self, index): 36 | """Re-implement to return custom QActions.""" 37 | model = index.model() 38 | copy = QtWidgets.QAction('Copy', None) 39 | copy.triggered.connect(partial(self.copy, index)) 40 | actions = [copy] 41 | if isinstance(model, QtCore.QAbstractProxyModel): 42 | index = model.mapToSource(index) 43 | model = model.sourceModel() 44 | if model.editable_values and index.data(SchemaRole).get('editable', True): 45 | reset = QtWidgets.QAction('Reset', None) 46 | reset.triggered.connect(partial(self.reset, index)) 47 | actions.append(reset) 48 | return actions 49 | 50 | def paint(self, delegate, painter, option, index): 51 | """Optionally re-implement for use by the delegate.""" 52 | raise NotImplementedError 53 | 54 | def createEditor(self, delegate, parent, option, index): 55 | """Optionally re-implement for use by the delegate.""" 56 | raise NotImplementedError 57 | 58 | def reset(self, index): 59 | model = index.model() 60 | if isinstance(model, QtCore.QAbstractProxyModel): 61 | index = model.mapToSource(index) 62 | model = model.sourceModel() 63 | schema = index.data(SchemaRole) 64 | default = schema.get("default", self.__class__.DEFAULT) 65 | model.itemFromIndex(index).setData(default, QtCore.Qt.DisplayRole) 66 | 67 | def copy(self, index): 68 | """Put the given display value into the clipboard.""" 69 | model = index.model() 70 | if isinstance(model, QtCore.QAbstractProxyModel): 71 | index = model.mapToSource(index) 72 | model = model.sourceModel() 73 | value = str(index.data(QtCore.Qt.DisplayRole)) 74 | QtWidgets.QApplication.clipboard().setText(value) 75 | 76 | def setModelData(self, delegate, editor, model, index): 77 | """Optionally re-implement for use by the delegate.""" 78 | if isinstance(model, QtCore.QAbstractProxyModel): 79 | index = model.mapToSource(index) 80 | model = model.sourceModel() 81 | return_value = super(delegate.__class__, delegate).setModelData(editor, model, index) 82 | model.data_object.update(model.serialize()) 83 | return return_value 84 | 85 | def serialize(self, model, item, data, parent): 86 | """Serialize this data type.""" 87 | value_item = parent.child(item.row(), 1) 88 | value = value_item.data(QtCore.Qt.DisplayRole) 89 | if isinstance(data, dict): 90 | key_item = parent.child(item.row(), 0) 91 | key = key_item.data(QtCore.Qt.DisplayRole) 92 | data[key] = value 93 | elif isinstance(data, list): 94 | data.append(value) 95 | 96 | def key_item(self, key, model, datatype=None, editable=True): 97 | """Create an item for the key column for this data type.""" 98 | item = QtGui.QStandardItem(key) 99 | item.setData(datatype, TypeRole) 100 | item.setData(datatype.__class__.__name__, QtCore.Qt.ToolTipRole) 101 | item.setData( 102 | QtGui.QBrush(datatype.COLOR), QtCore.Qt.ForegroundRole) 103 | item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) 104 | if editable and model.editable_keys: 105 | item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable) 106 | return item 107 | 108 | def value_item(self, value, model, key=None): 109 | """Create an item for the value column for this data type.""" 110 | display_value = value 111 | item = self.ITEM() 112 | item.setData(display_value, QtCore.Qt.DisplayRole) 113 | item.setData(value, QtCore.Qt.UserRole) 114 | item.setData(self, TypeRole) 115 | item.setData(QtGui.QBrush(self.COLOR), QtCore.Qt.ForegroundRole) 116 | 117 | schema = model.current_schema.get(key, {}) 118 | item.setData(schema, SchemaRole) 119 | item.setData(schema.get('tooltip', self.__class__.__name__), 120 | QtCore.Qt.ToolTipRole) 121 | item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) 122 | if model.editable_values and schema.get('editable', True): 123 | item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable) 124 | else: 125 | item.setData(QtGui.QBrush(self.INACTIVE_COLOR), QtCore.Qt.ForegroundRole) 126 | return item 127 | 128 | def clicked(self, parent, index, pos, rect): 129 | pass 130 | 131 | def hovered(self, index, pos, rect): 132 | pass 133 | 134 | 135 | # ----------------------------------------------------------------------------- 136 | # Default Types 137 | # ----------------------------------------------------------------------------- 138 | 139 | 140 | class NoneType(DataType): 141 | """None""" 142 | 143 | def matches(self, data): 144 | return data is None 145 | 146 | def value_item(self, value, model, key=None): 147 | item = super(NoneType, self).value_item(value, model, key) 148 | item.setData('None', QtCore.Qt.DisplayRole) 149 | return item 150 | 151 | def serialize(self, model, item, data, parent): 152 | value_item = parent.child(item.row(), 1) 153 | value = value_item.data(QtCore.Qt.DisplayRole) 154 | value = value if value != 'None' else None 155 | if isinstance(data, dict): 156 | key_item = parent.child(item.row(), 0) 157 | key = key_item.data(QtCore.Qt.DisplayRole) 158 | data[key] = value 159 | elif isinstance(data, list): 160 | data.append(value) 161 | 162 | 163 | class StrType(DataType): 164 | """Strings and unicodes""" 165 | 166 | DEFAULT = "" 167 | 168 | def matches(self, data): 169 | return isinstance(data, six.string_types) 170 | 171 | 172 | class IntType(DataType): 173 | """Integers""" 174 | 175 | DEFAULT = 0 176 | 177 | def matches(self, data): 178 | return isinstance(data, int) and not isinstance(data, bool) 179 | 180 | 181 | class FloatType(DataType): 182 | """Floats""" 183 | 184 | DEFAULT = 0.0 185 | 186 | def matches(self, data): 187 | return isinstance(data, float) 188 | 189 | 190 | class BoolType(DataType): 191 | """Bools are displayed as checkable items with a check box.""" 192 | 193 | DEFAULT = False 194 | 195 | def matches(self, data): 196 | return isinstance(data, bool) 197 | 198 | def paint(self, delegate, painter, option, index): 199 | option.rect.adjust(20, 0, 0, 0) 200 | super(delegate.__class__, delegate).paint(painter, option, index) 201 | painter.save() 202 | checked = bool(index.model().data(index, QtCore.Qt.DisplayRole)) 203 | options = QtWidgets.QStyleOptionButton() 204 | options.rect = option.rect.adjusted(-20, 0, 0, 0) 205 | options.state |= QtWidgets.QStyle.State_Active 206 | options.state |= QtWidgets.QStyle.State_On if checked else QtWidgets.QStyle.State_Off 207 | options.state |= ( 208 | QtWidgets.QStyle.State_Enabled if index.flags() & QtCore.Qt.ItemIsEditable 209 | else QtWidgets.QStyle.State_ReadOnly) 210 | QtWidgets.QApplication.style().drawControl(QtWidgets.QStyle.CE_CheckBox, options, painter) 211 | painter.restore() 212 | 213 | def clicked(self, parent, index, pos, rect): 214 | if not index.flags() & QtCore.Qt.ItemIsEditable: 215 | return 216 | model = index.model() 217 | if isinstance(model, QtCore.QAbstractProxyModel): 218 | index = model.mapToSource(index) 219 | model = model.sourceModel() 220 | item = model.itemFromIndex(index) 221 | if pos.x() - rect.x() < 18: 222 | item.setData(not item.data(QtCore.Qt.DisplayRole), QtCore.Qt.DisplayRole) 223 | model.data_object.update(model.serialize()) 224 | 225 | def createEditor(self, delegate, parent, option, index): 226 | pass 227 | 228 | 229 | class ListType(DataType): 230 | """Lists""" 231 | 232 | def matches(self, data): 233 | return isinstance(data, list) 234 | 235 | def empty_container(self): 236 | return [] 237 | 238 | def next(self, model, data, parent): 239 | for i, value in enumerate(data): 240 | type_ = match_type(value, key=i, schema=model.current_schema) 241 | key_item = type_.key_item( 242 | str(i), datatype=type_, editable=False, model=model) 243 | value_item = type_.value_item(value, model=model, key=i) 244 | parent.appendRow([key_item, value_item]) 245 | type_.next(model, data=value, parent=key_item) 246 | if model.prev_schemas: 247 | model.current_schema = model.prev_schemas.pop(-1) 248 | 249 | def value_item(self, value, model, key): 250 | item = QtGui.QStandardItem() 251 | font = QtWidgets.QApplication.instance().font() 252 | font.setItalic(True) 253 | item.setData(font, QtCore.Qt.FontRole) 254 | item.setData(QtGui.QBrush(QtCore.Qt.lightGray), QtCore.Qt.ForegroundRole) 255 | item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) 256 | schema = model.current_schema.get(key, {}) 257 | item.setData(schema.get('tooltip', self.__class__.__name__), 258 | QtCore.Qt.ToolTipRole) 259 | return item 260 | 261 | def key_item(self, key, model, datatype=None, editable=True): 262 | """Create an item for the key column for this data type.""" 263 | item = super(ListType, self).key_item(key, model, datatype, editable) 264 | model.prev_schemas.append(model.current_schema) 265 | model.current_schema = model.current_schema.get(key, {}).get('properties', {}) 266 | return item 267 | 268 | def serialize(self, model, item, data, parent): 269 | key_item = parent.child(item.row(), 0) 270 | if key_item: 271 | if isinstance(data, dict): 272 | key = key_item.data(QtCore.Qt.DisplayRole) 273 | data[key] = self.empty_container() 274 | data = data[key] 275 | elif isinstance(data, list): 276 | new_data = self.empty_container() 277 | data.append(new_data) 278 | data = new_data 279 | for row in range(item.rowCount()): 280 | child_item = item.child(row, 0) 281 | type_ = child_item.data(TypeRole) 282 | type_.serialize( 283 | model=self, item=child_item, data=data, parent=item) 284 | 285 | 286 | class DictType(DataType): 287 | """Dictionaries""" 288 | 289 | def matches(self, data): 290 | return isinstance(data, dict) 291 | 292 | def empty_container(self): 293 | return {} 294 | 295 | def next(self, model, data, parent): 296 | for key, value in data.items(): 297 | type_ = match_type(value, key=key, schema=model.current_schema) 298 | key_item = type_.key_item(key, datatype=type_, model=model) 299 | value_item = type_.value_item(value, model, key) 300 | parent.appendRow([key_item, value_item]) 301 | type_.next(model, data=value, parent=key_item) 302 | if model.prev_schemas: 303 | model.current_schema = model.prev_schemas.pop(-1) 304 | 305 | def key_item(self, key, model, datatype=None, editable=True): 306 | """Create an item for the key column for this data type.""" 307 | item = super(DictType, self).key_item(key, model, datatype, editable) 308 | model.prev_schemas.append(model.current_schema) 309 | model.current_schema = model.current_schema.get(key, {}).get('properties', {}) 310 | return item 311 | 312 | def value_item(self, value, model, key): 313 | item = QtGui.QStandardItem() 314 | font = QtWidgets.QApplication.instance().font() 315 | font.setItalic(True) 316 | item.setData(font, QtCore.Qt.FontRole) 317 | item.setData(QtGui.QBrush(QtCore.Qt.lightGray), QtCore.Qt.ForegroundRole) 318 | item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) 319 | schema = model.current_schema.get(key, {}) 320 | item.setData(schema.get('tooltip', self.__class__.__name__), 321 | QtCore.Qt.ToolTipRole) 322 | return item 323 | 324 | def serialize(self, model, item, data, parent): 325 | key_item = parent.child(item.row(), 0) 326 | if key_item: 327 | if isinstance(data, dict): 328 | key = key_item.data(QtCore.Qt.DisplayRole) 329 | data[key] = self.empty_container() 330 | data = data[key] 331 | elif isinstance(data, list): 332 | new_data = self.empty_container() 333 | data.append(new_data) 334 | data = new_data 335 | for row in range(item.rowCount()): 336 | child_item = item.child(row, 0) 337 | type_ = child_item.data(TypeRole) 338 | type_.serialize(model=self, item=child_item, data=data, parent=item) 339 | 340 | 341 | class AnyType(DataType): 342 | 343 | def matches(self, data): 344 | return True 345 | 346 | def value_item(self, value, model, key): 347 | item = super(AnyType, self).value_item(str(value), model, key) 348 | item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) 349 | return item 350 | 351 | 352 | # ----------------------------------------------------------------------------- 353 | # Derived Types 354 | # ----------------------------------------------------------------------------- 355 | 356 | 357 | class OrderedDictType(DictType): 358 | """Ordered Dictionaries""" 359 | 360 | def matches(self, data): 361 | return isinstance(data, OrderedDict) 362 | 363 | def empty_container(self): 364 | return OrderedDict() 365 | 366 | 367 | class RangeType(DataType): 368 | """A range, shown as three spinboxes next to each other. 369 | 370 | A range is defined as a dict with start, end and step keys. 371 | It supports both floats and ints. 372 | """ 373 | 374 | KEYS = ['start', 'end', 'step'] 375 | DEFAULT = [0, 1, 1] 376 | 377 | def matches(self, data): 378 | if isinstance(data, dict) and len(data) == 3: 379 | if all([True if k in self.KEYS else False for k in data.keys()]): 380 | return True 381 | return False 382 | 383 | def paint(self, delegate, painter, option, index): 384 | data = index.data(QtCore.Qt.UserRole) 385 | 386 | painter.save() 387 | 388 | painter.setPen(QtGui.QPen(index.data(QtCore.Qt.ForegroundRole).color())) 389 | metrics = painter.fontMetrics() 390 | spinbox_option = QtWidgets.QStyleOptionSpinBox() 391 | start_rect = QtCore.QRect(option.rect) 392 | start_rect.setWidth(start_rect.width() / 3.0) 393 | spinbox_option.rect = start_rect 394 | spinbox_option.frame = True 395 | spinbox_option.state = option.state 396 | spinbox_option.buttonSymbols = QtWidgets.QAbstractSpinBox.NoButtons 397 | for i, key in enumerate(self.KEYS): 398 | if i > 0: 399 | spinbox_option.rect.adjust( 400 | spinbox_option.rect.width(), 0, 401 | spinbox_option.rect.width(), 0) 402 | QtWidgets.QApplication.style().drawComplexControl( 403 | QtWidgets.QStyle.CC_SpinBox, spinbox_option, painter) 404 | value = str(data[key]) 405 | value_rect = QtCore.QRectF( 406 | spinbox_option.rect.adjusted(6, 1, -2, -2)) 407 | value = metrics.elidedText( 408 | value, QtCore.Qt.ElideRight, value_rect.width() - 20) 409 | painter.drawText(value_rect, value) 410 | 411 | painter.restore() 412 | 413 | def createEditor(self, delegate, parent, option, index): 414 | data = index.data(QtCore.Qt.UserRole) 415 | wid = QtWidgets.QWidget(parent) 416 | wid.setLayout(QtWidgets.QHBoxLayout(parent)) 417 | wid.layout().setContentsMargins(0, 0, 0, 0) 418 | wid.layout().setSpacing(0) 419 | 420 | start = data['start'] 421 | end = data['end'] 422 | step = data['step'] 423 | 424 | if isinstance(start, float): 425 | start_spinbox = QtWidgets.QDoubleSpinBox(wid) 426 | else: 427 | start_spinbox = QtWidgets.QSpinBox(wid) 428 | 429 | if isinstance(end, float): 430 | end_spinbox = QtWidgets.QDoubleSpinBox(wid) 431 | else: 432 | end_spinbox = QtWidgets.QSpinBox(wid) 433 | 434 | if isinstance(step, float): 435 | step_spinbox = QtWidgets.QDoubleSpinBox(wid) 436 | else: 437 | step_spinbox = QtWidgets.QSpinBox(wid) 438 | 439 | start_spinbox.setRange(-16777215, 16777215) 440 | end_spinbox.setRange(-16777215, 16777215) 441 | step_spinbox.setRange(-16777215, 16777215) 442 | start_spinbox.setValue(start) 443 | end_spinbox.setValue(end) 444 | step_spinbox.setValue(step) 445 | wid.layout().addWidget(start_spinbox) 446 | wid.layout().addWidget(end_spinbox) 447 | wid.layout().addWidget(step_spinbox) 448 | return wid 449 | 450 | def setModelData(self, delegate, editor, model, index): 451 | if isinstance(model, QtCore.QAbstractProxyModel): 452 | index = model.mapToSource(index) 453 | model = model.sourceModel() 454 | data = index.data(QtCore.Qt.UserRole) 455 | data['start'] = editor.layout().itemAt(0).widget().value() 456 | data['end'] = editor.layout().itemAt(1).widget().value() 457 | data['step'] = editor.layout().itemAt(2).widget().value() 458 | model.itemFromIndex(index).setData(data, QtCore.Qt.UserRole) 459 | model.data_object.update(model.serialize()) 460 | 461 | def value_item(self, value, model, key=None): 462 | """Item representing a value.""" 463 | value_item = super(RangeType, self).value_item(None, model, key) 464 | value_item.setData(value, QtCore.Qt.UserRole) 465 | return value_item 466 | 467 | def serialize(self, model, item, data, parent): 468 | value_item = parent.child(item.row(), 1) 469 | value = value_item.data(QtCore.Qt.UserRole) 470 | if isinstance(data, dict): 471 | key_item = parent.child(item.row(), 0) 472 | key = key_item.data(QtCore.Qt.DisplayRole) 473 | data[key] = value 474 | elif isinstance(data, list): 475 | data.append(value) 476 | 477 | def reset(self, index): 478 | model = index.model() 479 | if isinstance(model, QtCore.QAbstractProxyModel): 480 | index = model.mapToSource(index) 481 | model = model.sourceModel() 482 | schema = index.data(SchemaRole) 483 | default = schema.get("default", self.__class__.DEFAULT) 484 | 485 | data = index.data(QtCore.Qt.UserRole) 486 | data['start'] = default[0] 487 | data['end'] = default[1] 488 | data['step'] = default[2] 489 | 490 | model.itemFromIndex(index).setData(data, QtCore.Qt.UserRole) 491 | 492 | def copy(self, index): 493 | """Put the given display value into the clipboard.""" 494 | model = index.model() 495 | if isinstance(model, QtCore.QAbstractProxyModel): 496 | index = model.mapToSource(index) 497 | model = model.sourceModel() 498 | value = str(index.data(QtCore.Qt.UserRole)) 499 | QtWidgets.QApplication.clipboard().setText(value) 500 | 501 | 502 | class UrlType(DataType): 503 | """Provide a link to urls.""" 504 | 505 | REGEX = re.compile(r'(?:https?):\/\/|(?:file):\/\/') 506 | 507 | def matches(self, data): 508 | if isinstance(data, six.string_types): 509 | if self.REGEX.match(data) is not None: 510 | return True 511 | return False 512 | 513 | def actions(self, index): 514 | actions = super(UrlType, self).actions(index) 515 | explore = QtWidgets.QAction('Explore ...', None) 516 | explore.triggered.connect( 517 | partial(self._explore, index.data(QtCore.Qt.DisplayRole))) 518 | actions.append(explore) 519 | return actions 520 | 521 | def createEditor(self, delegate, parent, option, index): 522 | """Show a button to browse to the url.""" 523 | value = index.data(QtCore.Qt.DisplayRole) 524 | pos = QtGui.QCursor().pos() 525 | popup = QtWidgets.QWidget(parent=parent) 526 | popup.setWindowFlags(QtCore.Qt.Popup) 527 | popup.setLayout(QtWidgets.QHBoxLayout(popup)) 528 | button = QtWidgets.QPushButton("Explore: {0}".format(value)) 529 | button.clicked.connect(partial(self._explore, value)) 530 | button.clicked.connect(popup.close) 531 | button.setFlat(True) 532 | button.setCursor(QtCore.Qt.PointingHandCursor) 533 | button.setIcon(index.data(QtCore.Qt.DecorationRole)) 534 | font = QtWidgets.QApplication.instance().font() 535 | font.setUnderline(True) 536 | button.setFont(font) 537 | popup.layout().addWidget(button) 538 | metrics = QtWidgets.QApplication.instance().fontMetrics() 539 | width = metrics.width(button.text()) 540 | height = metrics.xHeight() 541 | popup.setGeometry(pos.x(), pos.y(), width, height) 542 | popup.show() 543 | 544 | model = index.model() 545 | if isinstance(model, QtCore.QAbstractProxyModel): 546 | index = model.mapToSource(index) 547 | model = model.sourceModel() 548 | 549 | if model.editable_values: 550 | if index.data(SchemaRole).get("editable", True): 551 | return super(delegate.__class__, delegate).createEditor( 552 | parent, option, index) 553 | 554 | def value_item(self, value, model, key=None): 555 | """Create an item for the value column for this data type.""" 556 | item = super(UrlType, self).value_item(value, model, key) 557 | font = QtWidgets.QApplication.instance().font() 558 | font.setUnderline(True) 559 | item.setData(font, QtCore.Qt.FontRole) 560 | icon = QtWidgets.QApplication.instance().style().standardIcon( 561 | QtWidgets.QApplication.instance().style().SP_DriveNetIcon) 562 | item.setData(icon, QtCore.Qt.DecorationRole) 563 | item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable) 564 | return item 565 | 566 | def _explore(self, url): 567 | """Open the url""" 568 | webbrowser.open(url) 569 | 570 | 571 | class FilepathType(UrlType): 572 | """Files and paths can be opened.""" 573 | 574 | REGEX = re.compile(r'(\/.*)|([A-Z]:\\.*)') 575 | 576 | def matches(self, data): 577 | if isinstance(data, six.string_types): 578 | if self.REGEX.match(data) is not None: 579 | return True 580 | return False 581 | 582 | def actions(self, index): 583 | actions = super(UrlType, self).actions(index) 584 | explore_path = QtWidgets.QAction('Explore Path ...', None) 585 | actions.append(explore_path) 586 | path = index.data(QtCore.Qt.DisplayRole) 587 | if os.path.isfile(path): 588 | open_file = QtWidgets.QAction('Open File ...', None) 589 | actions.append(open_file) 590 | open_file.triggered.connect(partial(self._explore, path)) 591 | path = os.path.dirname(path) 592 | explore_path.triggered.connect(partial(self._explore, path)) 593 | return actions 594 | 595 | def value_item(self, value, model, key=None): 596 | """Create an item for the value column for this data type.""" 597 | item = super(FilepathType, self).value_item(value, model, key) 598 | if os.path.isfile(value): 599 | icon = QtWidgets.QApplication.instance().style().standardIcon( 600 | QtWidgets.QApplication.instance().style().SP_FileIcon) 601 | elif os.path.isdir(value): 602 | icon = QtWidgets.QApplication.instance().style().standardIcon( 603 | QtWidgets.QApplication.instance().style().SP_DirIcon) 604 | else: 605 | return item 606 | item.setData(icon, QtCore.Qt.DecorationRole) 607 | return item 608 | 609 | 610 | class ChoiceType(DataType): 611 | """A combobox that allows for a number of choices. 612 | 613 | The data has to be a dict with a value and a choices key. 614 | { 615 | "value": "A", 616 | "choices": ["A", "B", "C"] 617 | } 618 | """ 619 | 620 | KEYS = ['value', 'choices'] 621 | 622 | def matches(self, data): 623 | if isinstance(data, dict) and len(data) == 2: 624 | if all([True if k in self.KEYS else False for k in data.keys()]): 625 | return True 626 | return False 627 | 628 | def createEditor(self, delegate, parent, option, index): 629 | data = index.data(QtCore.Qt.UserRole) 630 | cbx = QtWidgets.QComboBox(parent) 631 | cbx.addItems([str(d) for d in data['choices']]) 632 | cbx.setCurrentIndex(cbx.findText(str(data['value']))) 633 | return cbx 634 | 635 | def setModelData(self, delegate, editor, model, index): 636 | if isinstance(model, QtCore.QAbstractProxyModel): 637 | index = model.mapToSource(index) 638 | model = model.sourceModel() 639 | data = index.data(QtCore.Qt.UserRole) 640 | data['value'] = data['choices'][editor.currentIndex()] 641 | model.itemFromIndex(index).setData(data['value'] , QtCore.Qt.DisplayRole) 642 | model.itemFromIndex(index).setData(data, QtCore.Qt.UserRole) 643 | model.data_object.update(model.serialize()) 644 | 645 | def value_item(self, value, model, key=None): 646 | """Item representing a value.""" 647 | item = super(ChoiceType, self).value_item(value['value'], model, key) 648 | item.setData(value, QtCore.Qt.UserRole) 649 | return item 650 | 651 | def serialize(self, model, item, data, parent): 652 | value_item = parent.child(item.row(), 1) 653 | value = value_item.data(QtCore.Qt.UserRole) 654 | value['value'] = value_item.data(QtCore.Qt.DisplayRole) 655 | if isinstance(data, dict): 656 | key_item = parent.child(item.row(), 0) 657 | key = key_item.data(QtCore.Qt.DisplayRole) 658 | data[key] = value 659 | elif isinstance(data, list): 660 | data.append(value) 661 | 662 | 663 | # Add any custom DataType to this list 664 | # 665 | DATA_TYPES = [ 666 | NoneType(), 667 | UrlType(), 668 | FilepathType(), 669 | StrType(), 670 | IntType(), 671 | FloatType(), 672 | BoolType(), 673 | ListType(), 674 | RangeType(), 675 | ChoiceType(), 676 | OrderedDictType(), 677 | DictType(), 678 | AnyType() 679 | ] 680 | 681 | 682 | def match_type(data, key=None, schema=None): 683 | """Try to match the given data object to a DataType""" 684 | 685 | if key and schema: 686 | type_cls = schema.get(key, {}).get("type", None) 687 | if type_cls is not None: 688 | for type_ in DATA_TYPES: 689 | if isinstance(type_, type_cls): 690 | return type_ 691 | new_type = type_cls() 692 | DATA_TYPES.append(new_type) 693 | return new_type 694 | 695 | for type_ in DATA_TYPES: 696 | if type_.matches(data): 697 | return type_ 698 | return AnyType() 699 | -------------------------------------------------------------------------------- /qt_json_view/delegate.py: -------------------------------------------------------------------------------- 1 | from Qt import QtWidgets, QtCore 2 | 3 | from qt_json_view.datatypes import DataType, TypeRole 4 | 5 | 6 | class JsonDelegate(QtWidgets.QStyledItemDelegate): 7 | """Display the data based on the definitions on the DataTypes.""" 8 | 9 | def sizeHint(self, option, index): 10 | return QtCore.QSize(option.rect.width(), 20) 11 | 12 | def paint(self, painter, option, index): 13 | """Use method from the data type or fall back to the default.""" 14 | if index.column() == 0: 15 | return super(JsonDelegate, self).paint(painter, option, index) 16 | type_ = index.data(TypeRole) 17 | if type_ is not None: 18 | try: 19 | return type_.paint(self, painter, option, index) 20 | except NotImplementedError: 21 | pass 22 | return super(JsonDelegate, self).paint(painter, option, index) 23 | 24 | def createEditor(self, parent, option, index): 25 | """Use method from the data type or fall back to the default.""" 26 | if index.column() == 0: 27 | return super(JsonDelegate, self).createEditor( 28 | parent, option, index) 29 | try: 30 | return index.data(TypeRole).createEditor(self, parent, option, index) 31 | except NotImplementedError: 32 | return super(JsonDelegate, self).createEditor( 33 | parent, option, index) 34 | 35 | def setModelData(self, editor, model, index): 36 | """Use method from the data type or fall back to the default.""" 37 | if index.column() == 0: 38 | return super(JsonDelegate, self).setModelData(editor, model, index) 39 | try: 40 | return index.data(TypeRole).setModelData(self, editor, model, index) 41 | except NotImplementedError: 42 | return super(JsonDelegate, self).setModelData(editor, model, index) 43 | -------------------------------------------------------------------------------- /qt_json_view/model.py: -------------------------------------------------------------------------------- 1 | from Qt import QtGui, QtCore 2 | from collections import OrderedDict 3 | 4 | from qt_json_view import datatypes 5 | 6 | from qt_json_view.datatypes import match_type, TypeRole, ListType, DictType, SchemaRole 7 | 8 | 9 | class JsonModel(QtGui.QStandardItemModel): 10 | """Represent JSON-serializable data.""" 11 | 12 | NON_DEFAULT_COLOR = QtCore.Qt.yellow 13 | 14 | def __init__( 15 | self, 16 | parent=None, 17 | data=None, 18 | editable_keys=False, 19 | editable_values=False, 20 | schema=None): 21 | super(JsonModel, self).__init__(parent=parent) 22 | self.data_object = data 23 | self.schema = schema 24 | if data is not None: 25 | self.init(data, editable_keys, editable_values, schema) 26 | 27 | def init(self, data, editable_keys=False, editable_values=False, schema=None): 28 | """Convert the data to items and populate the model.""" 29 | self.clear() 30 | self.setHorizontalHeaderLabels(['Key', 'Value']) 31 | self.data_object = data 32 | self.editable_keys = editable_keys 33 | self.editable_values = editable_values 34 | self.schema = schema or {} 35 | self.current_schema = self.schema 36 | self.prev_schemas = [] 37 | parent = self.invisibleRootItem() 38 | type_ = match_type(data) 39 | parent.setData(type_, TypeRole) 40 | type_.next(model=self, data=data, parent=parent) 41 | 42 | def serialize(self): 43 | """Assemble the model back into a dict or list.""" 44 | parent = self.invisibleRootItem() 45 | type_ = parent.data(TypeRole) 46 | data = type_.empty_container() 47 | type_.serialize(model=self, item=parent, data=data, parent=parent) 48 | return data 49 | 50 | def data(self, index, role): 51 | if index.column() == 1 and role == QtCore.Qt.ForegroundRole: 52 | schema = index.data(SchemaRole) or {} 53 | default = schema.get('default') 54 | if default is not None and default != index.data(QtCore.Qt.DisplayRole): 55 | return QtGui.QBrush(self.NON_DEFAULT_COLOR) 56 | 57 | return super(JsonModel, self).data(index, role) 58 | 59 | 60 | class JsonSortFilterProxyModel(QtCore.QSortFilterProxyModel): 61 | """Show ALL occurences by keeping the parents of each occurence visible.""" 62 | 63 | def __init__(self, parent=None): 64 | super(JsonSortFilterProxyModel, self).__init__(parent=parent) 65 | self.keep_children = False 66 | 67 | def filterAcceptsRow(self, sourceRow, sourceParent): 68 | """Accept the row if the parent has been accepted.""" 69 | index = self.sourceModel().index(sourceRow, self.filterKeyColumn(), sourceParent) 70 | return self.accept_index(index) 71 | 72 | def accept_index(self, index): 73 | if index.isValid(): 74 | text = str(index.data(self.filterRole())) 75 | if self.filterRegExp().indexIn(text) >= 0: 76 | return True 77 | if self.keep_children: 78 | parent = index.parent() 79 | while parent.isValid(): 80 | parent_text = str(parent.data(self.filterRole())) 81 | if self.filterRegExp().indexIn(parent_text) >= 0: 82 | return True 83 | parent = parent.parent() 84 | for row in range(index.model().rowCount(index)): 85 | if self.accept_index(index.model().index(row, self.filterKeyColumn(), index)): 86 | return True 87 | return False 88 | -------------------------------------------------------------------------------- /qt_json_view/view.py: -------------------------------------------------------------------------------- 1 | from Qt import QtCore, QtWidgets, QtGui 2 | 3 | from qt_json_view import delegate 4 | from qt_json_view.datatypes import TypeRole 5 | 6 | 7 | class JsonView(QtWidgets.QTreeView): 8 | """Tree to display the JsonModel.""" 9 | 10 | def __init__(self, parent=None): 11 | super(JsonView, self).__init__(parent=parent) 12 | self.setMouseTracking(True) 13 | self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) 14 | self.customContextMenuRequested.connect(self._menu) 15 | self.setItemDelegate(delegate.JsonDelegate()) 16 | ctrl_c = QtWidgets.QShortcut( 17 | QtGui.QKeySequence(self.tr("Ctrl+c")), self) 18 | ctrl_c.activated.connect(self.copy) 19 | self.clicked.connect(self._on_clicked) 20 | 21 | def _menu(self, position): 22 | """Show the actions of the DataType (if any).""" 23 | menu = QtWidgets.QMenu() 24 | actions = self.actions() 25 | 26 | expand_all = QtWidgets.QAction("Expand All", self) 27 | expand_all.triggered.connect(self.expandAll) 28 | actions.append(expand_all) 29 | collapse_all = QtWidgets.QAction("Collapse All", self) 30 | collapse_all.triggered.connect(self.collapseAll) 31 | actions.append(collapse_all) 32 | 33 | index = self.indexAt(position) 34 | data = index.data(TypeRole) 35 | if data is not None: 36 | actions += data.actions(index) 37 | for action in actions: 38 | menu.addAction(action) 39 | menu.exec_(self.viewport().mapToGlobal(position)) 40 | 41 | def copy(self): 42 | """Copy the currently selected value to the clipboard.""" 43 | for index in self.selectedIndexes(): 44 | if index.column() == 1: 45 | model = self.model() 46 | if isinstance(model, QtCore.QAbstractProxyModel): 47 | index = model.mapToSource(index) 48 | model = model.sourceModel() 49 | type_ = index.data(TypeRole) 50 | if type_ is not None: 51 | type_.copy(index) 52 | return 53 | 54 | def _on_clicked(self, index): 55 | if index.column() == 1: 56 | type_ = index.data(TypeRole) 57 | if type_ is not None: 58 | pos = self.mapFromGlobal(QtGui.QCursor().pos()) 59 | type_.clicked(self, index, pos, self.visualRect(index)) 60 | 61 | def mouseMoveEvent(self, event): 62 | index = self.indexAt(event.pos()) 63 | if index.column() == 1: 64 | type_ = index.data(TypeRole) 65 | if type_ is not None: 66 | type_.hovered(index, event.pos(), self.visualRect(index)) 67 | event.accept() 68 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup( 4 | name="qt-json-view", 5 | version="0.0.1", 6 | author="Paul Schweizer", 7 | description="A Qt widget for viewing json files as tree models", 8 | classifiers=[ 9 | "Programming Language :: Python", 10 | ], 11 | ) 12 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | sys.path.append(os.path.dirname(os.path.dirname(__file__))) 4 | 5 | 6 | from qt_json_view import datatypes, model 7 | 8 | 9 | def test_none(): 10 | assert datatypes.NoneType().matches(None) 11 | 12 | 13 | def test_str(): 14 | assert datatypes.StrType().matches('string') 15 | assert datatypes.StrType().matches(u'unicode') 16 | 17 | 18 | def test_int(): 19 | assert datatypes.IntType().matches(1) 20 | assert not datatypes.IntType().matches(True) 21 | assert not datatypes.IntType().matches(False) 22 | 23 | 24 | def test_float(): 25 | assert datatypes.FloatType().matches(1.234) 26 | 27 | 28 | def test_bool(): 29 | assert datatypes.BoolType().matches(True) 30 | assert datatypes.BoolType().matches(False) 31 | assert not datatypes.BoolType().matches(1.0) 32 | assert not datatypes.BoolType().matches(0) 33 | assert not datatypes.BoolType().matches(1) 34 | 35 | 36 | def test_list(): 37 | assert datatypes.ListType().matches(['1', 2, 3.0]) 38 | 39 | 40 | def test_dict(): 41 | assert datatypes.DictType().matches({'a': 'b'}) 42 | 43 | 44 | def test_range(): 45 | assert datatypes.RangeType().matches({ 46 | 'start': 0, 'end': 100, 'step': 1 47 | }) 48 | assert datatypes.RangeType().matches({ 49 | 'start': 0.1, 'end': 100, 'step': 0.5 50 | }) 51 | assert not datatypes.RangeType().matches({ 52 | 'start': 0, 'end': 100 53 | }) 54 | 55 | 56 | def test_url(): 57 | assert datatypes.UrlType().matches('http://www.python.com') 58 | assert datatypes.UrlType().matches(u'https://www.python.com') 59 | assert datatypes.UrlType().matches(u'file:///some/file/path') 60 | assert datatypes.UrlType().matches(u'file://C:\\some-file.txt') 61 | 62 | 63 | def test_filepath(): 64 | assert datatypes.FilepathType().matches('/some/file/path') 65 | assert datatypes.FilepathType().matches('C:\\') 66 | assert datatypes.FilepathType().matches(u'/some/file.txt') 67 | assert datatypes.FilepathType().matches(u'C:\\some-file.txt') 68 | 69 | 70 | def test_choices(): 71 | assert datatypes.ChoicesType().matches({'value': 1, 'choices': [1, 2, 3, 4]}) 72 | assert datatypes.ChoicesType().matches({'value': 'A', 'choices': ['A', 'B', 'C']}) 73 | assert datatypes.ChoicesType().matches({'value': None, 'choices': ['A', 'B', 'C']}) 74 | 75 | 76 | DICT_DATA = { 77 | 'none': None, 78 | 'bool': True, 79 | 'int': 666, 80 | 'float': 1.23, 81 | 'list1': [ 82 | 1, 83 | 2 84 | ], 85 | 'dict': { 86 | 'key': 'value', 87 | 'another_dict': { 88 | 'a': 'b' 89 | } 90 | }, 91 | 92 | # Custom types 93 | # 94 | 'choice (str)': { 95 | 'value': 'A', 96 | 'choices': ['A', 'B', 'C'] 97 | }, 98 | 'choice (int)': { 99 | 'value': 1, 100 | 'choices': [1, 2, 3] 101 | }, 102 | 'choice None': { 103 | 'value': None, 104 | 'choices': ['A', 'B'] 105 | }, 106 | 'range': { 107 | 'start': 0, 108 | 'end': 100, 109 | 'step': 0.5 110 | }, 111 | 112 | 'http': 'http://www.python.com', 113 | 'https': 'https://www.python.com', 114 | 'url (file)': 'file://{0}'.format(__file__), 115 | 'filepath folder': os.path.dirname(__file__), 116 | 'filepath file': __file__ 117 | } 118 | 119 | LIST_DATA = [ 120 | None, 121 | True, 122 | 0, 123 | 1, 124 | 2, 125 | [ 126 | 'A', 127 | 'B' 128 | ], 129 | { 130 | 'value': 'A', 131 | 'choices': ['A', 'B', 'C'] 132 | }, 133 | { 134 | 'start': 0, 135 | 'end': 100, 136 | 'step': 0.5 137 | }, 138 | 'http://www.python.com', 139 | 'https://www.python.com', 140 | 'file://{0}'.format(__file__), 141 | os.path.dirname(__file__), 142 | __file__, 143 | DICT_DATA 144 | ] 145 | 146 | 147 | def test_serialize_model(): 148 | json_model = model.JsonModel(data=DICT_DATA, editable_keys=True, editable_values=True) 149 | serialized = json_model.serialize() 150 | assert DICT_DATA == serialized 151 | 152 | json_model = model.JsonModel(data=LIST_DATA, editable_keys=True, editable_values=True) 153 | serialized = json_model.serialize() 154 | assert LIST_DATA == serialized 155 | --------------------------------------------------------------------------------