pythonjsonpyside6qtreeviewqabstractitemmodel

Python Qt6 Updating Model Based on View Edits


I am attempting to create a mvc app that can take a JSON file, make a TreeView from it, allow the user to make edits, then save the edits to a JSON file. I am having trouble understanding how to update the underlying model based on the visual edits made from the corresponding view, so I can then save those edits to the JSON file.

I copied most of the code from https://doc.qt.io/qtforpython-6/examples/example_widgets_itemviews_jsonmodel.html, and changed very little (only the setData() and flags() methods).

Below is the code I am using:

import json
import sys
from typing import Any, List, Dict, Union

from PySide6.QtWidgets import QTreeView, QApplication, QHeaderView
from PySide6.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt, QFileInfo
import PySide6.QtGui as QtGui

class TreeItem:
    """A Json item corresponding to a line in QTreeView"""

    def __init__(self, parent: "TreeItem" = None):
        self._parent = parent
        self._key = ""
        self._value = ""
        self._value_type = None
        self._children = []

    def appendChild(self, item: "TreeItem"):
        """Add item as a child"""
        self._children.append(item)

    def child(self, row: int) -> "TreeItem":
        """Return the child of the current item from the given row"""
        return self._children[row]

    def parent(self) -> "TreeItem":
        """Return the parent of the current item"""
        return self._parent

    def childCount(self) -> int:
        """Return the number of children of the current item"""
        return len(self._children)

    def row(self) -> int:
        """Return the row where the current item occupies in the parent"""
        return self._parent._children.index(self) if self._parent else 0

    @property
    def key(self) -> str:
        """Return the key name"""
        return self._key

    @key.setter
    def key(self, key: str):
        """Set key name of the current item"""
        self._key = key

    @property
    def value(self) -> str:
        """Return the value name of the current item"""
        return self._value

    @value.setter
    def value(self, value: str):
        """Set value name of the current item"""
        self._value = value

    @property
    def value_type(self):
        """Return the python type of the item's value."""
        return self._value_type

    @value_type.setter
    def value_type(self, value):
        """Set the python type of the item's value."""
        self._value_type = value

    @classmethod
    def load(
        cls, value: Union[List, Dict], parent: "TreeItem" = None, sort=True
    ) -> "TreeItem":
        """Create a 'root' TreeItem from a nested list or a nested dictonary

        Examples:
            with open("file.json") as file:
                data = json.dump(file)
                root = TreeItem.load(data)

        This method is a recursive function that calls itself.

        Returns:
            TreeItem: TreeItem
        """
        rootItem = TreeItem(parent)
        rootItem.key = "root"

        if isinstance(value, dict):
            items = sorted(value.items()) if sort else value.items()

            for key, value in items:
                child = cls.load(value, rootItem)
                child.key = key
                child.value_type = type(value)
                rootItem.appendChild(child)

        elif isinstance(value, list):
            for index, value in enumerate(value):
                child = cls.load(value, rootItem)
                child.key = index
                child.value_type = type(value)
                rootItem.appendChild(child)

        else:
            rootItem.value = value
            rootItem.value_type = type(value)

        return rootItem


class JsonModel(QAbstractItemModel):
    """ An editable model of Json data """

    def __init__(self, parent: QObject = None):
        super().__init__(parent)

        self._rootItem = TreeItem()
        self._headers = ("key", "value")

    def clear(self):
        """ Clear data from the model """
        self.load({})

    def load(self, document: dict):
        """Load model from a nested dictionary returned by json.loads()

        Arguments:
            document (dict): JSON-compatible dictionary
        """

        assert isinstance(
            document, (dict, list, tuple)
        ), "`document` must be of dict, list or tuple, " f"not {type(document)}"

        self.beginResetModel()

        self._rootItem = TreeItem.load(document)
        self._rootItem.value_type = type(document)

        self.endResetModel()

        return True

    def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> Any:
        """Override from QAbstractItemModel

        Return data from a json item according index and role

        """
        if not index.isValid():
            return None

        item = index.internalPointer()

        if role == Qt.DisplayRole:
            if index.column() == 0:
                return item.key

            if index.column() == 1:
                return item.value

        elif role == Qt.EditRole:
            if index.column() == 1:
                return item.value

    def setData(self, index: QModelIndex, value: Any, role: Qt.ItemDataRole):
        """Override from QAbstractItemModel

        Set json item according index and role

        Args:
            index (QModelIndex)
            value (Any)
            role (Qt.ItemDataRole)

        """
        item = index.internalPointer()
        item.value = str(value)

        self.dataChanged.emit(index, index, [Qt.EditRole])

        return True

    def headerData(
        self, section: int, orientation: Qt.Orientation, role: Qt.ItemDataRole
    ):
        """Override from QAbstractItemModel

        For the JsonModel, it returns only data for columns (orientation = Horizontal)

        """
        if role != Qt.DisplayRole:
            return None

        if orientation == Qt.Horizontal:
            return self._headers[section]

    def index(self, row: int, column: int, parent=QModelIndex()) -> QModelIndex:
        """Override from QAbstractItemModel

        Return index according row, column and parent

        """
        if not self.hasIndex(row, column, parent):
            return QModelIndex()

        if not parent.isValid():
            parentItem = self._rootItem
        else:
            parentItem = parent.internalPointer()

        childItem = parentItem.child(row)
        if childItem:
            return self.createIndex(row, column, childItem)
        else:
            return QModelIndex()

    def parent(self, index: QModelIndex) -> QModelIndex:
        """Override from QAbstractItemModel

        Return parent index of index

        """

        if not index.isValid():
            return QModelIndex()

        childItem = index.internalPointer()
        parentItem = childItem.parent()

        if parentItem == self._rootItem:
            return QModelIndex()

        return self.createIndex(parentItem.row(), 0, parentItem)

    def rowCount(self, parent=QModelIndex()):
        """Override from QAbstractItemModel

        Return row count from parent index
        """
        if parent.column() > 0:
            return 0

        if not parent.isValid():
            parentItem = self._rootItem
        else:
            parentItem = parent.internalPointer()

        return parentItem.childCount()

    def columnCount(self, parent=QModelIndex()):
        """Override from QAbstractItemModel

        Return column number. For the model, it always return 2 columns
        """
        return 2

    def flags(self, index: QModelIndex) -> Qt.ItemFlags:
        """Override from QAbstractItemModel

        Return flags of index
        """
        flags = super(JsonModel, self).flags(index)

        #if index.column() == 1:
        return Qt.ItemIsEditable | flags
        #else:
        #    return flags

    def to_json(self, item=None):

        if item is None:
            item = self._rootItem

        nchild = item.childCount()

        if item.value_type is dict:
            document = {}
            for i in range(nchild):
                ch = item.child(i)
                document[ch.key] = self.to_json(ch)
            return document

        elif item.value_type == list:
            document = []
            for i in range(nchild):
                ch = item.child(i)
                document.append(self.to_json(ch))
            return document

        else:
            return item.value


    def save(self):
        print(model.to_json())
        with open("example.json", 'w') as f:
            json.dump(model.to_json(), f)
        
if __name__ == "__main__":

    app = QApplication(sys.argv)
    view = QTreeView()
    model = JsonModel()

    view.setModel(model)

    json_path = "example.json"

    with open(json_path) as file:
        document = json.load(file)
        model.load(document)

    view.show()
    view.header().setSectionResizeMode(0, QHeaderView.Stretch)
    view.setAlternatingRowColors(True)
    view.resize(500, 300)
    view.shortcut1 = QtGui.QShortcut(QtGui.QKeySequence('Shift+S'), view)
    view.shortcut1.activated.connect(lambda : model.save())
    app.exec()

The code above creates the treeView just fine, and I can make edits no problem. Upon pressing Shift+S (per the key shortcut event), it attempts to save the model. However, since the changes made visually were on the view, the model that it saves is the exact same model that it loads in from JSON. All of the edits I made on the view are lost when I close the application.

How can I send the visual edits that I made on the view (updating the columns, values etc) to the model, that will then store it in the JSON, so that next time it loads in the JSON data the changes appear?

Edit1: I discovered that it does save changes to JSON like I expected, but only if they are made to items that do not have children or parents. I am interested in saving all changes, regardless of whether they have parents/children or not.


Solution

  • You're using a wrong data structure, since you're only considering that each key may only have one of the following:

    This type of structure is not compatible with the basic dict data structure and, in general, for a mapping, which is the same data structure used for json: since a mapping is just a key and value pair, this means that the value of a key (a parent) can only be a further parent or a value.

    While it may be possible to serialize such a structure using "custom pickling", AFAIK json doesn't provide this possibility, and the only solution is to use a differently nested structure: similarly to the TreeItem object, each key of the dictionary must have differently stored fields within its data structure.

    For example:

    {
        "some key": {
            "value": "this is the first key value",
            "children": [
                "foo", "bar"
            ]
        {,
        "another key": {
            "value": "this is another key value",
            "children": {
                "grandChild1": ["hello", "world"],
                "grandChild2": ["stack", "overflow"]
            }
        }
    }
    

    This obviously means that both the source and target json files must respect that structure, and the model will therefore load/save data accordingly.

    In the case above, if a value is a dict, it will create an item with the "name" set on its secondary field, and further children based on the related key/value pair. If it's a list, it will create children for each item.

    Another, more accurate approach, would actually and always use a dict as the item reference (including top level items), with the name got from the key, each parent value set with a related "value" key, and children contents with further dictionaries using the same recursive structure:

    {
        "some key": {
            "value": "this is the first key value",
            "children": {
                "foo": {"value": "foo value"}, 
                "bar": {"value": "bar value"}
            }
        {,
        "another key": {
            "value": "this is another key value",
            "children": {
                "grandChild1": {
                    "value": "grandChild1 parent"
                    "children": {
                        {"hello": {}},
                        {"world": {}}
                    }
                },
                "grandChild2": {
                    "value": "grandChild2 parent"
                    "children": {
                        "stack": {}},
                        "overflow": {"value": "what?"}
                    }
                },
            }
        }
    }
    

    In the case above:

    This obviously complicate things in implementation and data storage, but it's also the only way to store further data of parent items, other than their children. If that's what you want, then that's what you need to do.