Search code examples
qtableviewpyside6qabstracttablemodel

Memory Leak in Pyside6 QAbstractTableModel


I have a PySide6 application using an MVC structure. It utilizies two threads. The main thread is the Qt Event loop(GUI). The Model utilizes an asyncio event loop which runs in the other thread. The Model handles all the IO for the application. When new data is imported into the application, the Model emits it as a DataFrame via a Signal.

I have a window(AllPurposeTable) which contains an instance of QTableView. This QTableView is connected to an instance of AllPurposeTableModel. AllPurposeTableModel subclasses QAbstractTableModel. It has a Slot that is connected to the Signal in the Model.

After the data is received in on_data_update(), the dataChanged() method must be called to inform the QTableView that the data has changed and to update the view

This entire flow structure works as expected. New data comes in, and it correctly updates the view. However, the memory usage in this Python process continues to rise as long as the AllPurposeTable window is open.

The Slot function reassigns self.df correctly(which is proven with the print(self.df) line. Memory usage stays constant when this update repeatedly happens

When dataChanged() is emitted, the memory usage increases consistently and I can't figure out why. I have attempted multiple implementations of the necessary methods in QAbstractTableModel with no luck. No errors appear during any of this. Where is this excess memory being allocated and how can I prevent it?

import sys
import os
import asyncio
import numpy as np
import pandas as pd
from datetime import datetime
from typing import Any

from PySide6.QtCore import QThread, QObject, Signal, QAbstractTableModel, QModelIndex, Slot
from PySide6.QtWidgets import QApplication, QMainWindow

from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
    QMetaObject, QObject, QPoint, QRect,
    QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
    QCursor, QFont, QFontDatabase, QGradient,
    QIcon, QImage, QKeySequence, QLinearGradient,
    QPainter, QPalette, QPixmap, QRadialGradient,
    QTransform)
from PySide6.QtWidgets import (QApplication, QGridLayout, QHeaderView, QMainWindow,
    QMenu, QMenuBar, QSizePolicy, QStatusBar,
    QTableView, QWidget)


class Ui_AllPurposeTable(object):
    def setupUi(self, AllPurposeTable):
        if not AllPurposeTable.objectName():
            AllPurposeTable.setObjectName(u"AllPurposeTable")
        AllPurposeTable.resize(581, 348)
        self.actionQuiote = QAction(AllPurposeTable)
        self.actionQuiote.setObjectName(u"actionQuiote")
        self.actionRows = QAction(AllPurposeTable)
        self.actionRows.setObjectName(u"actionRows")
        self.action_edit_columns_showhide = QAction(AllPurposeTable)
        self.action_edit_columns_showhide.setObjectName(u"action_edit_columns_showhide")
        self.action_edit_columns_rename = QAction(AllPurposeTable)
        self.action_edit_columns_rename.setObjectName(u"action_edit_columns_rename")
        self.action_edit_columns_adddelete = QAction(AllPurposeTable)
        self.action_edit_columns_adddelete.setObjectName(u"action_edit_columns_adddelete")
        self.action_loaddata_data = QAction(AllPurposeTable)
        self.action_loaddata_data.setObjectName(u"action_loaddata_data")
        self.actionEdit_Cells = QAction(AllPurposeTable)
        self.actionEdit_Cells.setObjectName(u"actionEdit_Cells")
        self.action_file_settings_open = QAction(AllPurposeTable)
        self.action_file_settings_open.setObjectName(u"action_file_settings_open")
        self.action_file_settings_save = QAction(AllPurposeTable)
        self.action_file_settings_save.setObjectName(u"action_file_settings_save")
        self.action_file_settings_saveas = QAction(AllPurposeTable)
        self.action_file_settings_saveas.setObjectName(u"action_file_settings_saveas")
        self.action_file_data_load = QAction(AllPurposeTable)
        self.action_file_data_load.setObjectName(u"action_file_data_load")
        self.action_file_data_save = QAction(AllPurposeTable)
        self.action_file_data_save.setObjectName(u"action_file_data_save")
        self.action_file_data_saveas = QAction(AllPurposeTable)
        self.action_file_data_saveas.setObjectName(u"action_file_data_saveas")
        self.action_edit_columns_datatypes = QAction(AllPurposeTable)
        self.action_edit_columns_datatypes.setObjectName(u"action_edit_columns_datatypes")
        self.centralwidget = QWidget(AllPurposeTable)
        self.centralwidget.setObjectName(u"centralwidget")
        self.gridLayout_2 = QGridLayout(self.centralwidget)
        self.gridLayout_2.setObjectName(u"gridLayout_2")
        self.gridLayout = QGridLayout()
        self.gridLayout.setObjectName(u"gridLayout")
        self.table_view = QTableView(self.centralwidget)
        self.table_view.setObjectName(u"table_view")

        self.gridLayout.addWidget(self.table_view, 0, 0, 1, 1)


        self.gridLayout_2.addLayout(self.gridLayout, 0, 0, 1, 1)

        AllPurposeTable.setCentralWidget(self.centralwidget)
        self.menubar = QMenuBar(AllPurposeTable)
        self.menubar.setObjectName(u"menubar")
        self.menubar.setGeometry(QRect(0, 0, 581, 21))
        self.menu_file = QMenu(self.menubar)
        self.menu_file.setObjectName(u"menu_file")
        self.menu_file_settings = QMenu(self.menu_file)
        self.menu_file_settings.setObjectName(u"menu_file_settings")
        self.menu_file_data = QMenu(self.menu_file)
        self.menu_file_data.setObjectName(u"menu_file_data")
        self.menu_edit = QMenu(self.menubar)
        self.menu_edit.setObjectName(u"menu_edit")
        self.menu_columns = QMenu(self.menu_edit)
        self.menu_columns.setObjectName(u"menu_columns")
        self.menu_view = QMenu(self.menubar)
        self.menu_view.setObjectName(u"menu_view")
        self.menu_help = QMenu(self.menubar)
        self.menu_help.setObjectName(u"menu_help")
        AllPurposeTable.setMenuBar(self.menubar)
        self.statusbar = QStatusBar(AllPurposeTable)
        self.statusbar.setObjectName(u"statusbar")
        AllPurposeTable.setStatusBar(self.statusbar)

        self.menubar.addAction(self.menu_file.menuAction())
        self.menubar.addAction(self.menu_edit.menuAction())
        self.menubar.addAction(self.menu_view.menuAction())
        self.menubar.addAction(self.menu_help.menuAction())
        self.menu_file.addAction(self.menu_file_data.menuAction())
        self.menu_file.addAction(self.menu_file_settings.menuAction())
        self.menu_file.addAction(self.actionQuiote)
        self.menu_file_settings.addAction(self.action_file_settings_open)
        self.menu_file_settings.addAction(self.action_file_settings_save)
        self.menu_file_settings.addAction(self.action_file_settings_saveas)
        self.menu_file_data.addAction(self.action_file_data_load)
        self.menu_file_data.addAction(self.action_file_data_save)
        self.menu_file_data.addAction(self.action_file_data_saveas)
        self.menu_edit.addAction(self.menu_columns.menuAction())
        self.menu_columns.addAction(self.action_edit_columns_showhide)
        self.menu_columns.addAction(self.action_edit_columns_rename)
        self.menu_columns.addAction(self.action_edit_columns_adddelete)
        self.menu_columns.addAction(self.action_edit_columns_datatypes)

        self.retranslateUi(AllPurposeTable)

        QMetaObject.connectSlotsByName(AllPurposeTable)
    # setupUi

    def retranslateUi(self, AllPurposeTable):
        AllPurposeTable.setWindowTitle(QCoreApplication.translate("AllPurposeTable", u"Table", None))
        self.actionQuiote.setText(QCoreApplication.translate("AllPurposeTable", u"Quit", None))
        self.actionRows.setText(QCoreApplication.translate("AllPurposeTable", u"Rows", None))
        self.action_edit_columns_showhide.setText(QCoreApplication.translate("AllPurposeTable", u"Show/Hide", None))
        self.action_edit_columns_rename.setText(QCoreApplication.translate("AllPurposeTable", u"Rename", None))
        self.action_edit_columns_adddelete.setText(QCoreApplication.translate("AllPurposeTable", u"Add/Delete", None))
        self.action_loaddata_data.setText(QCoreApplication.translate("AllPurposeTable", u"Load Data", None))
        self.actionEdit_Cells.setText(QCoreApplication.translate("AllPurposeTable", u"Edit Cells", None))
        self.action_file_settings_open.setText(QCoreApplication.translate("AllPurposeTable", u"Open", None))
        self.action_file_settings_save.setText(QCoreApplication.translate("AllPurposeTable", u"Save", None))
        self.action_file_settings_saveas.setText(QCoreApplication.translate("AllPurposeTable", u"Save As...", None))
        self.action_file_data_load.setText(QCoreApplication.translate("AllPurposeTable", u"Load", None))
        self.action_file_data_save.setText(QCoreApplication.translate("AllPurposeTable", u"Save", None))
        self.action_file_data_saveas.setText(QCoreApplication.translate("AllPurposeTable", u"Save As...", None))
        self.action_edit_columns_datatypes.setText(QCoreApplication.translate("AllPurposeTable", u"Data Types", None))
        self.menu_file.setTitle(QCoreApplication.translate("AllPurposeTable", u"File", None))
        self.menu_file_settings.setTitle(QCoreApplication.translate("AllPurposeTable", u"Settings", None))
        self.menu_file_data.setTitle(QCoreApplication.translate("AllPurposeTable", u"Data", None))
        self.menu_edit.setTitle(QCoreApplication.translate("AllPurposeTable", u"Edit", None))
        self.menu_columns.setTitle(QCoreApplication.translate("AllPurposeTable", u"Columns", None))
        self.menu_view.setTitle(QCoreApplication.translate("AllPurposeTable", u"View", None))
        self.menu_help.setTitle(QCoreApplication.translate("AllPurposeTable", u"Help", None))
    # retranslateUi


class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        if not MainWindow.objectName():
            MainWindow.setObjectName(u"MainWindow")
        MainWindow.resize(198, 132)
        self.action_open_allpurposetable = QAction(MainWindow)
        self.action_open_allpurposetable.setObjectName(u"action_open_allpurposetable")
        self.centralwidget = QWidget(MainWindow)
        self.centralwidget.setObjectName(u"centralwidget")
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QMenuBar(MainWindow)
        self.menubar.setObjectName(u"menubar")
        self.menubar.setGeometry(QRect(0, 0, 198, 21))
        self.menuOpen = QMenu(self.menubar)
        self.menuOpen.setObjectName(u"menuOpen")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QStatusBar(MainWindow)
        self.statusbar.setObjectName(u"statusbar")
        MainWindow.setStatusBar(self.statusbar)

        self.menubar.addAction(self.menuOpen.menuAction())
        self.menuOpen.addAction(self.action_open_allpurposetable)

        self.retranslateUi(MainWindow)

        QMetaObject.connectSlotsByName(MainWindow)
    # setupUi

    def retranslateUi(self, MainWindow):
        MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"MainWindow", None))
        self.action_open_allpurposetable.setText(QCoreApplication.translate("MainWindow", u"All Purpose Table", None))
        self.menuOpen.setTitle(QCoreApplication.translate("MainWindow", u"Open", None))
    # retranslateUi


class ModelUpdateThread(QThread):
    def __init__(self, model):
        super().__init__()
        self.model = model

    # Override from QThread
    def run(self):
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        task = loop.create_task(self.model.update_model(loop))
        loop.run_until_complete(task)


class Controller(QApplication):
    def __init__(self, sys_argv):
        super().__init__(sys_argv)

        self.model = Model()
        self.model_update_thread = ModelUpdateThread(self.model)
        self.view = View(self, self.model)

        self.model_update_thread.start()


class Model(QObject):
    dataframe_update_signal = Signal(pd.DataFrame)

    def __init__(self):
        super().__init__()
        self.df = None

        self.loop = None

    async def update_data(self):
        """
        Generate DataFrame with random data
        """
        while True:
            data_values = np.random.randint(0, 1000, size=(100, 10))
            df = pd.DataFrame(data_values, columns=list('ABCDEFGHIJ'))
            self.df = df

            self.dataframe_update_signal.emit(self.df)

            await asyncio.sleep(1)

    async def update_model(self, loop):
        """
        This is the main coroutine that is executed in the model update thread
        """
        self.loop = loop

        tasks = [asyncio.create_task(coro()) for coro in (self.update_data,)]

        await asyncio.wait(tasks)


class View(QObject):
    def __init__(self, controller, model):
        super().__init__()
        self.controller = controller
        self.model = model

        self.open_windows = {'main_window': MainWindow(self, self.controller, self.model)}
        self.open_windows['main_window'].show()


class MainWindow(QMainWindow, Ui_MainWindow):
    def __init__(self, parent, controller, model):
        super().__init__()
        self.parent = parent
        self.controller = controller
        self.model = model

        self.setupUi(self)

        self.action_open_allpurposetable.triggered.connect(self.open_allpurposetable)

    def open_allpurposetable(self):
        self.parent.open_windows['demo_table'] = AllPurposeTable(self, self.model, self.parent, self.controller,
                                                                 self.model.df)
        self.parent.open_windows['demo_table'].show()


class AllPurposeTable(QMainWindow, Ui_AllPurposeTable):
    def __init__(self, parent, model, view, controller, df=None):
        super().__init__()
        self.parent = parent
        self.model = model
        self.view = view
        self.controller = controller
        self.df = df

        self.setupUi(self)

        self.table_model = None

        self.initialize_table_model()

    # This initializes the table model which is then presented by the view
    def initialize_table_model(self):
        if self.df is not None:
            self.table_model = AllPurposeTableModel(self, self.model, self.df)
            self.table_view.setModel(self.table_model)


class AllPurposeTableModel(QAbstractTableModel):
    def __init__(self, parent, model, df):
        super().__init__()
        self.parent = parent
        self.model = model
        self.df = df

        self.model.dataframe_update_signal.connect(self.on_data_update)

    @Slot(str)
    def on_data_update(self, df):
        """
        THIS IS WHERE THE PROBLEM IS
        """
        self.df = df

        # The data is correctly updating
        print(self.df)

        # MEMORY LEAK HERE
        self.dataChanged.emit(QModelIndex(), QModelIndex(), Qt.DisplayRole)

    # Override from QAbstractItemModel
    def rowCount(self, parent=QModelIndex()) -> int:
        if parent == QModelIndex():
            return self.df.shape[0]
        return 0

    # Override from QAbstractItemModel
    def columnCount(self, parent=QModelIndex()) -> int:
        if parent == QModelIndex():
            return self.df.shape[1]
        return 0

    # Override from QAbstractItemModel
    def data(self, index: QModelIndex, role=Qt.ItemDataRole) -> Any:
        if index.isValid():
            if role == Qt.DisplayRole:
                value = self.df.iloc[index.row(), index.column()]

                if isinstance(value, (np.bool_, bool)):
                    return str(value)
                if isinstance(value, (int, np.integer)):
                    return str('{:,}'.format(value))
                if isinstance(value, float):
                    return str(round(value, 2))
                if isinstance(value, datetime):
                    return value.strftime('%Y-%m-%d %H:%M:%S')
                if isinstance(value, list):
                    if isinstance(value[0], (int, np.integer)):
                        return ','.join(list(map(str, value)))
                    if isinstance(value[0], str):
                        return ','.join(value)
                if isinstance(value, str):
                    return value

            if role == Qt.BackgroundRole:
                return

            if role == Qt.TextAlignmentRole:
                return

            return None


if __name__ == '__main__':
    print('PID = {}'.format(os.getpid()))
    app = Controller(sys.argv)
    sys.exit(app.exec())

Solution

  • A fix was released yesterday 3/6/2023 which can be found here https://codereview.qt-project.org/c/pyside/pyside-setup/+/464629 ...The issue was related to an enumeration handler in PySide. The "forgiveness mode" was not correctly garbage collecting. To solve this issue immediately, simply change Qt.DisplayRole to Qt.ItemDataRole.DisplayRole(do this for all enumerations in your code). I have tested this on multiple examples and everything looks good now.

    Example of the updated data() method...

        def data(self, index: QModelIndex, role=Qt.ItemDataRole) -> Any:
            if index.isValid():
    
                if role == Qt.ItemDataRole.DisplayRole:
                    return
    
                if role == Qt.ItemDataRole.BackgroundRole:
                    return
    
                if role == Qt.ItemDataRole.TextAlignmentRole:
                    return
    
                return None