pythonpyqt5

pyqt5 application crashes on changing QtableView entry attached to a QComboBox


In this working example, I have a SQL Lite db ("in memory"). The QtableView is setup on a QSortFilterProxyModel with one of the column, "product" which can be filtered via user selection through a QComboBox.

If the values in the "product" column of the table is edited, then the items of the QComboBox are updated using pyqtSignal . This ensures that the Combobox items are always updated to the unique contents of the product column.

The app works fine as long as the user edits values in the table when no filter selection is made with the dropdown. As soon as you filter a product in using the combobox and alter its name in the tableview the program hangs and crashes without an error traceback.

I think it may be something to do with dataChanged signal that the proxymodel is emitting . This may be leading to a recursion issue. Tried self.setUpdatesEnabled(False), blockSignals on proxmodel, delaying its update signal qWait etc. but the issue was not resolved

import sys
import re
from functools import partial

from PyQt5 import QtCore
from PyQt5.QtWidgets import QApplication, QMainWindow, QComboBox, QTableView, QVBoxLayout, QWidget
from PyQt5.QtSql import QSqlDatabase, QSqlTableModel
from PyQt5.QtCore import Qt, QSortFilterProxyModel
from PyQt5.QtTest import QTest

class AppDemo(QMainWindow):

    # Custom Signals
    UpdateComboList = QtCore.pyqtSignal()
    UpdateComboListFlag = QtCore.pyqtSignal()
    UpdateFilterList = QtCore.pyqtSignal()

    def __init__(self):
        super().__init__()

        # Set up the main widget
        self.main_widget = QWidget()
        self.setCentralWidget(self.main_widget)
        self.layout = QVBoxLayout()
        self.main_widget.setLayout(self.layout)

        # Create and set up the QComboBox
        self.combo_box = QComboBox()
        self.layout.addWidget(self.combo_box)

        # Set up the QTableView
        self.table_view = QTableView()
        self.layout.addWidget(self.table_view)

        # Set up the database
        self.setup_database()

        # Set the model for the QTableView
        self.model = QSqlTableModel()
        self.model.setTable("MainTable")
        self.model.select()
        # Model Sorting
        proxymodel = MultiFilterProxyModel()
        proxymodel.setSourceModel(self.model)
        proxymodel.setDynamicSortFilter(True)
        self.table_view.setModel(proxymodel)
        self.table_view.setSortingEnabled(True)
        
        self.setFixedWidth(1000)
        self.setFixedHeight(300)

        # Get name of headers
        header_name = []
        for x in range(proxymodel.columnCount()):
            header_name.append(proxymodel.headerData(x, Qt.Horizontal)) 
        # Setup items for ComboBox 
        header_elem = "product" 
        rows = proxymodel.rowCount()
        col_indx = header_name.index(header_elem)
        region_list = self.row_items(rows, col_indx)
        region_list = list(dict.fromkeys(region_list))  # Remove duplicates
        self.combo_box.addItems(region_list)
        self.combo_box.currentIndexChanged[str].connect(
            partial(self.filter_table_view, col_indx, proxymodel, header_elem)
        )
        self.model.dataChanged.connect(
            partial(self.update_combo_items, header_name, proxymodel, "product")
        )



    def setup_database(self):
        # Connect to an in-memory SQLite database
        self.db = QSqlDatabase.addDatabase("QSQLITE")
        self.db.setDatabaseName(":memory:")
        if not self.db.open():
            print("Unable to open data source")

        query = self.db.exec()

        # Create table 
        query.exec(
            """
            CREATE TABLE MainTable (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT,
                age INTEGER,
                product TEXT,
                price REAL
            )
            """
        )

        # Insert sample data into the table
        query.exec("INSERT INTO MainTable (name, age, product, price) VALUES ('John Doe', 30, 'Product1', 9.99)")
        query.exec("INSERT INTO MainTable (name, age, product, price) VALUES ('Jane Smith', 28, 'Product2', 19.99)")
        query.exec("INSERT INTO MainTable (name, age, product, price) VALUES ('Dave Ryan', 55, 'Product1', 11.0)")
    
    def row_items(self, rows, col_indx):
        rows = self.model.rowCount() # Don't depend on proxy model
        cb_field_list = []
        for i in range(rows):
            value = self.model.index(i, col_indx)
            cb_field_list.append(value.data())
        # Detect empty elements and replace multiple with a single one
        # Remove empty elements
        if "" in cb_field_list:
            empty_elems = True
        else:
            empty_elems = False

        cb_field_list = list(filter(None, cb_field_list))
        if empty_elems:
            cb_field_list.append("")
        # Insert additional element
        cb_field_list.append("All")

        return cb_field_list 
    
    def filter_table_view(self, col_indx, proxymodel, header_elem):
        
        #rows = proxymodel.rowCount()
        # QTest.qWait(500)
        
        if header_elem == "product":
            cbbx_item = self.combo_box.currentText()


        if cbbx_item == "All" or cbbx_item == "":
              proxymodel.setFilterByColumn(col_indx, "")
        else:
            proxymodel.setFilterByColumn(col_indx, cbbx_item)

        self.UpdateComboList.emit()
    
    def update_combo_items(self, header_name, proxymodel, header_elem, indx1, indx2):
        # rows = proxymodel.rowCount()
        rows = self.model.rowCount() # Don't depend on proxy model
        col_indx =indx2.column()

        region_list = self.row_items(rows, col_indx)
        region_list = list(dict.fromkeys(region_list))  # Remove duplicates

        if header_name[col_indx] == "product":
            cbbx_item = self.combo_box.currentText()
            self.combo_box.blockSignals(True)
            self.combo_box.clear()
            self.combo_box.addItems(region_list)
            self.combo_box.setCurrentText(cbbx_item)
            self.combo_box.blockSignals(False)



class MultiFilterMode:
    AND = 0
    OR = 1

class MultiFilterProxyModel(QSortFilterProxyModel):
    def __init__(self, *args, **kwargs):
        QSortFilterProxyModel.__init__(self, *args, **kwargs)
        self.column_filters = {}
        self.filters = {}
        self.multi_filter_mode = MultiFilterMode.AND

    def setFilterByColumn(self, column, regex):
        if isinstance(regex, str):
            regex = re.compile(regex)
        self.filters[column] = regex
        self.invalidateFilter()

    def clearFilter(
        self,
        column,
    ):

        del self.filters[column]
        self.invalidateFilter()

    def clearFilters(self):
        self.filters = {}
        self.invalidateFilter()

    def filterAcceptsRow(self, source_row, source_parent):
        if not self.filters:
            return True

        results = []
        for key, regex in self.filters.items():
            text = ""
            index = self.sourceModel().index(source_row, key, source_parent)
            if index.isValid():
                text = self.sourceModel().data(index, Qt.DisplayRole)
                if text is None:
                    text = ""
            results.append(regex.match(text))

        if self.multi_filter_mode == MultiFilterMode.OR:
            return any(results)
        return all(results)
if __name__ == "__main__": 
    app = QApplication(sys.argv)
    demo = AppDemo()
    demo.show()
    sys.exit(app.exec_())

Solution

  • The problem is most probably caused by setDynamicSortFilter(True):

    Note that you should not update the source model through the proxy model when dynamicSortFilter is true.

    While the documentation doesn't completely address the cause of the problem (nor it mentions risking freeze/crash), the reason is probably based on the dynamic sorting/filtering that would invalidate the index that is being edited before the action is complete on the Qt side, including delegate and view handling of what should happen after the editing.

    Since the index should be maintained valid throughout the whole process until it's finished, the intermediate invalidation probably causes some memory address problems.

    One possibility is to temporarily disable the filter by overriding setData() on the proxy:

    class MultiFilterProxyModel(QSortFilterProxyModel):
        ...
        def setData(self, index, value, role=Qt.EditRole):
            isDynamic = self.dynamicSortFilter()
            if isDynamic:
                self.setDynamicSortFilter(False)
            res = super().setData(index, value, role)
            if isDynamic:
                self.setDynamicSortFilter(True)
            return res
    

    I'm not 100% sure of the above, though. In case you see further problems, you may consider using an internal QTimer that resets the filter after returning setData():

    class MultiFilterProxyModel(QSortFilterProxyModel):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.column_filters = {}
            self.filters = {}
            self.multi_filter_mode = MultiFilterMode.AND
            self.dynamicTimer = QTimer(self, singleShot=True, 
                interval=0, timeout=self.resetDynamic)
    
        def resetDynamic(self):
            self.setDynamicSortFilter(True)
    
        def setData(self, index, value, role=Qt.EditRole):
            if self.dynamicSortFilter():
                self.setDynamicSortFilter(False)
                self.dynamicTimer.start()
            return super().setData(index, value, role)