DESCRIPTION
I have a pyqt5 UI which has a QTableWidget with a dynamic row count; there is a button that adds rows. When a row is added, some of the cells contain QSpinBox(s) and one contains a QComboBox. The program also has a Save and a Restore QPushButton(s) that when selected, saves down all the widgets to an ini file located next to the py file. The save and restore methods, created by @eyllanesc and found here are pretty much universally used from what I have found on SO.

PROBLEM
The code below saves down the QSpinBox(s) and QComboBox fine. They are in the ini file. The restore function does not recover these widgets in to the QTableWidget. The row count is recovered, but no input text or widgets are inside the cells they were placed in.

WHAT I HAVE TRIED

  • I named the dynamically created widgets with a prefix_<row>_<column> name. I tried creating these in the initialisation of the UI, thinking there might be a link to the qApp.allWidgets() and the startup, but this didn't work. I was just guessing.
  • Reading @zythyr's post here, I thought I might have to add the widget to the QTableWidget in a parent<>child sort of arrangement (this is outside my knowledge), but the QTableWidget doesn't have the addWidget() method. I then tried *.setParent on the QComboBox (commented out in code below) and that didn't work either.

QUESTION
How do you save and restore user-input (typed in empty cell) data as well as QWidgets (namely QSpinBox and QComboBox) that are in a QTableWidget?

CODE

from PyQt5.QtGui import QPixmap
from PyQt5.QtCore import QSettings, QFileInfo
from PyQt5.QtWidgets import (QApplication, qApp, QMainWindow, QWidget, 
                             QVBoxLayout, QTableWidget, QTableWidgetItem, 
                             QHeaderView, QPushButton, QLineEdit, QSpinBox,
                             QComboBox)


def value_is_valid(val):
    #https://stackoverflow.com/a/60028282/4988010
    if isinstance(val, QPixmap):
        return not val.isNull()
    return True

def restore(settings):
    #https://stackoverflow.com/a/60028282/4988010
    finfo = QFileInfo(settings.fileName())

    if finfo.exists() and finfo.isFile():
        for w in qApp.allWidgets():
            if w.objectName():
                mo = w.metaObject()
                for i in range(mo.propertyCount()):
                    prop = mo.property(i)
                    name = prop.name()
                    last_value = w.property(name)
                    key = "{}/{}".format(w.objectName(), name)
                    if not settings.contains(key):
                        continue
                    val = settings.value(key, type=type(last_value),)
                    if (
                        val != last_value
                        and value_is_valid(val)
                        and prop.isValid()
                        and prop.isWritable()
                    ):
                        w.setProperty(name, val)

def save(settings):
    #https://stackoverflow.com/a/60028282/4988010
    for w in qApp.allWidgets():
        if w.objectName():
            mo = w.metaObject()
            for i in range(mo.propertyCount()):
                prop = mo.property(i)
                name = prop.name()
                key = "{}/{}".format(w.objectName(), name)
                val = w.property(name)
                if value_is_valid(val) and prop.isValid() and prop.isWritable():
                    settings.setValue(key, w.property(name))


# custom spin box allowing for optional maximum           
class CustomSpinBox(QSpinBox):
    def __init__(self, sb_value=1, sb_step=1, sb_min=1, *args):
        super(CustomSpinBox, self).__init__()

        self.setValue(sb_value)
        self.setSingleStep(sb_step)
        self.setMinimum(sb_min)
        if len(args) > 0:
            sb_max = args[0]
            self.setMaximum(sb_max)


class MainWindow(QMainWindow):
    
    # save settings alongside *py file
    settings = QSettings("temp.ini", QSettings.IniFormat)
    
    def __init__(self):
        super().__init__()
        self.initUI()
        self.initSignals()
        
    def initUI(self):
        # standar UI stuff
        self.setObjectName('MainWindow')
        self.setWindowTitle('Program Title')
        self.setGeometry(400, 400, 400, 100)
        wid = QWidget(self)
        self.setCentralWidget(wid)
        
        # create some widgets
        self.pb_add_row = QPushButton('Add Row')
        self.pb_remove_row = QPushButton('Remove Selected Row')
        self.pb_save = QPushButton('Save')
        self.pb_restore = QPushButton('Restore')
        
        self.le = QLineEdit()
        self.le.setObjectName('le')
        
        self.tbl = QTableWidget()
        self.tbl.setObjectName('r_tbl')
        header = self.tbl.horizontalHeader()
        self.tbl.setRowCount(0)
        self.tbl.setColumnCount(4)
        input_header = ['Label', 'X', 'Y', 'Comment']
        self.tbl.setHorizontalHeaderLabels(input_header)
        header.setSectionResizeMode(QHeaderView.Stretch)
        
        # add widgets to UI
        self.vbox = QVBoxLayout()
        self.vbox.addWidget(self.le)
        self.vbox.addWidget(self.tbl)
        self.vbox.addWidget(self.pb_add_row)
        self.vbox.addWidget(self.pb_remove_row)
        self.vbox.addWidget(self.pb_save)
        self.vbox.addWidget(self.pb_restore)
        wid.setLayout(self.vbox)
        
        # restore previous settings from *.ini file
        restore(self.settings)
    
    # pb signals
    def initSignals(self):
        self.pb_add_row.clicked.connect(self.pb_add_row_clicked)
        self.pb_remove_row.clicked.connect(self.pb_remove_row_clicked)
        self.pb_save.clicked.connect(self.pb_save_clicked)
        self.pb_restore.clicked.connect(self.pb_restore_clicked)
        
    # add a new row to the end - add spin boxes and a combobox
    def pb_add_row_clicked(self):
        current_row_count = self.tbl.rowCount()
        row_count = current_row_count + 1
        self.tbl.setRowCount(row_count)
        
        self.sb_1 = CustomSpinBox(1, 1, 1)
        self.sb_1.setObjectName(f'sb_{row_count}_1')
        self.sb_2 = CustomSpinBox(3, 1, 2, 6)
        self.sb_2.setObjectName(f'sb_{row_count}_2')
        self.cmb = QComboBox()
        choices = ['choice_1', 'choice_2', 'choice_3']
        self.cmb.addItems(choices)
        self.cmb.setObjectName(f'cmb_{row_count}_0')
        
        self.tbl.setCellWidget(current_row_count, 0, self.cmb)
        self.tbl.setCellWidget(current_row_count, 1, self.sb_1)
        self.tbl.setCellWidget(current_row_count, 2, self.sb_2)
        
        #self.cmb.setParent(self.tbl) # <<<< this didn't work   

    def pb_remove_row_clicked(self):
        self.tbl.removeRow(self.tbl.currentRow()) 
        
    def pb_save_clicked(self):
        print(f'{self.pb_save.text()} clicked')
        save(self.settings)
        
    def pb_restore_clicked(self):
        print(f'{self.pb_restore.text()} clicked')
        restore(self.settings)


if __name__ == "__main__":
    app = QApplication([])
    window = MainWindow()
    window.show()
    app.exec_()

EDIT1 ...removed as it didn't help and just made it more confusing...

EDIT2
Excluding the widgets, I have worked out how to use QSettings to save and restore user-entered cell data from a QTableWidget. Hope the following code helps someone. I have no doubt it could be done better and I welcome improvement suggestions. I'll update if I can work out the addition of widgets (QSpinBoxes and QComboBoxes) in to to cells.

import sys
from PyQt5.QtCore import QSettings
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget,
                             QVBoxLayout, QTableWidget, QTableWidgetItem, 
                             QHeaderView, QPushButton)

class MainWindow(QMainWindow):
    # save settings alongside *py file
    settings = QSettings("temp.ini", QSettings.IniFormat)
     
    def __init__(self):
        super().__init__()
        self.initUI()
        self.initSignals()
        self.restore_settings()
        
    def initUI(self):
        # standar UI stuff
        self.setObjectName('MainWindow')
        self.setWindowTitle('Program Title')
        self.setGeometry(400, 400, 500, 300)
        wid = QWidget(self)
        self.setCentralWidget(wid)
        
        # create some widgets
        self.pb_add_row = QPushButton('Add Row')
        self.pb_remove_row = QPushButton('Remove Selected Row')
        self.pb_save = QPushButton('Save')
        self.pb_restore = QPushButton('Restore')
        self.tbl = QTableWidget(0, 4, self)
        
        # config up the table        
        header = self.tbl.horizontalHeader()
        input_header = ['Label', 'X', 'Y', 'Comment']
        self.tbl.setHorizontalHeaderLabels(input_header)
        header.setSectionResizeMode(QHeaderView.Stretch)
        
        # add widgets to UI
        self.vbox = QVBoxLayout()
        self.vbox.addWidget(self.tbl)
        self.vbox.addWidget(self.pb_add_row)
        self.vbox.addWidget(self.pb_remove_row)
        self.vbox.addWidget(self.pb_save)
        self.vbox.addWidget(self.pb_restore)
        wid.setLayout(self.vbox)
            
    # pb signals
    def initSignals(self):#
        self.pb_add_row.clicked.connect(self.pb_add_row_clicked)
        self.pb_remove_row.clicked.connect(self.pb_remove_row_clicked)
        self.pb_save.clicked.connect(self.pb_save_clicked)
        self.pb_restore.clicked.connect(self.pb_restore_clicked)

    # reads in the ini file adn re-generate the table contents
    def restore_settings(self):
        self.setting_value = self.settings.value('table')
        self.setting_row = self.settings.value('rows')
        self.setting_col = self.settings.value('columns')
        print(f'RESTORE: {self.setting_value}')
        
        # change the table row/columns, create a dictionary out of the saved table
        try:
            self.tbl.setRowCount(int(self.setting_row))
            self.tbl.setColumnCount(int(self.setting_col))
            self.my_dict = dict(eval(self.setting_value))
        except TypeError:
            print(f'RESTORE: No ini file, resulting in no rows/columns')
        
        # loop over each table cell and populate with old values
        for row in range(self.tbl.rowCount()):
            for col in range(self.tbl.columnCount()):
                try:
                    if col == 0: self.tbl.setItem(row, col, QTableWidgetItem(self.my_dict['Label'][row]))
                    if col == 1: self.tbl.setItem(row, col, QTableWidgetItem(self.my_dict['X'][row]))
                    if col == 2: self.tbl.setItem(row, col, QTableWidgetItem(self.my_dict['Y'][row]))
                    if col == 3: self.tbl.setItem(row, col, QTableWidgetItem(self.my_dict['Comment'][row]))                              
                except IndexError:
                    print(f'INDEX ERROR')          
        
    # add a new row to the end 
    def pb_add_row_clicked(self):
        current_row_count = self.tbl.rowCount()
        row_count = current_row_count + 1
        self.tbl.setRowCount(row_count)

    # remove selected row
    def pb_remove_row_clicked(self):
        self.tbl.removeRow(self.tbl.currentRow())
    
    # save the table contents and table row/column to the ini file
    def pb_save_clicked(self):
        # create an empty dictionary
        self.tbl_dict = {'Label':[], 'X':[], 'Y':[], 'Comment':[]}
        
        # loop over the cells and add to the table
        for column in range(self.tbl.columnCount()):
            for row in range(self.tbl.rowCount()):
                itm = self.tbl.item(row, column)
                try:
                    text = itm.text()
                except AttributeError: # happens when the cell is empty
                    text = ''
                if column == 0: self.tbl_dict['Label'].append(text)
                if column == 1: self.tbl_dict['X'].append(text)
                if column == 2: self.tbl_dict['Y'].append(text)
                if column == 3: self.tbl_dict['Comment'].append(text)
        
        # write values to ini file      
        self.settings.setValue('table', str(self.tbl_dict))
        self.settings.setValue('rows', self.tbl.rowCount())
        self.settings.setValue('columns', self.tbl.columnCount())
        print(f'WRITE: {self.tbl_dict}')
        
    def pb_restore_clicked(self):
        self.restore_settings()

    def closeEvent(self, event):
        self.pb_save_clicked()

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    app.exec_()
2

There are 2 best solutions below

0
McRae On

CHALLENGE
I am sure there is a better way than this and I challenge those who are well versed in Qt (pyqt5) to find a better solution.

ANSWER
In the mean-time, I hope the below helps someone as I could not find this information anywhere. I can't really read the C++ Qt stuff and I am no pyqt5 Harry Potter, so this took some effort.

The general gist here, is the widgets were never saved. The values were saved to a dictionary and then to the QSettings ini file as a string. On boot, the ini file was parsed and the table rebuilt - rows and columns. The widgets were then built from scratch and re-inserted in to the table. The dictionary from the ini file was then parsed and the values applied to the widgets or the blank cells as required.

WISH
I really wish there was a method inline with the save / restore methods as per OP links. I found that if I included those save / restore methods in the init, it sometimes messed the table up completely. As a result, for my greater program, it looks like I am going to have to save and restore all settings manually.

MRE CODE

import sys

from PyQt5.QtCore import QSettings
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget,
                             QVBoxLayout, QTableWidget, QTableWidgetItem, 
                             QHeaderView, QPushButton, QComboBox, QSpinBox)


# custom spin box allowing for optional maximum           
class CustomSpinBox(QSpinBox):
    def __init__(self, sb_value=1, sb_step=1, sb_min=1, *args):
        super(CustomSpinBox, self).__init__()

        self.setValue(sb_value)
        self.setSingleStep(sb_step)
        self.setMinimum(sb_min)
        if len(args) > 0:
            sb_max = args[0]
            self.setMaximum(sb_max)


class MainWindow(QMainWindow):
    # save settings alongside *py file
    settings = QSettings("temp.ini", QSettings.IniFormat)
     
    def __init__(self):
        super().__init__()
        self.initUI()
        self.initSignals()
        self.restore_settings()
        
    def initUI(self):
        # standar UI stuff
        self.setObjectName('MainWindow')
        self.setWindowTitle('Program Title')
        self.setGeometry(400, 400, 500, 300)
        wid = QWidget(self)
        self.setCentralWidget(wid)
        
        # create some widgets
        self.pb_add_row = QPushButton('Add Row')
        self.pb_remove_row = QPushButton('Remove Selected Row')
        self.pb_save = QPushButton('Save')
        self.pb_restore = QPushButton('Restore')
        self.tbl = QTableWidget(0, 4, self)
        
        # config up the table        
        header = self.tbl.horizontalHeader()
        input_header = ['Label', 'X', 'Y', 'Comment']
        self.tbl.setHorizontalHeaderLabels(input_header)
        header.setSectionResizeMode(QHeaderView.Stretch)
        
        # name the table for QSettings
        self.tbl.setObjectName('input_table')
        
        # add widgets to UI
        self.vbox = QVBoxLayout()
        self.vbox.addWidget(self.tbl)
        self.vbox.addWidget(self.pb_add_row)
        self.vbox.addWidget(self.pb_remove_row)
        self.vbox.addWidget(self.pb_save)
        wid.setLayout(self.vbox)
        
        # create an empty dictionary to house the table widgets 
        self.table_widgets = {'cmb':[], 'sb_1':[], 'sb_2':[]}
        # combobox values
        self.choices = ['choice_1', 'choice_2', 'choice_3']
            
    # pb signals
    def initSignals(self):#
        self.pb_add_row.clicked.connect(self.pb_add_row_clicked)
        self.pb_remove_row.clicked.connect(self.pb_remove_row_clicked)
        self.pb_save.clicked.connect(self.pb_save_clicked)

    # reads in the ini file and re-generate the table contents
    def restore_settings(self):
        try:
            self.setting_tbl = self.settings.value('table')
            self.setting_row = self.settings.value('rows')
            self.setting_col = self.settings.value('columns')
            self.my_dict = dict(eval(self.setting_tbl))
            
            # need to rebuild the table first
            self.tbl.setRowCount(int(self.setting_row))
            self.tbl.setColumnCount(int(self.setting_col))
            
            print(f'RESTORE: row:{self.setting_row} and col:{self.setting_col} and table:{self.setting_tbl}')
            
            # probably don't need to build and return values from the dictionary
            for row in range(int(self.setting_row)):
                self.table_widgets['cmb'].append(QComboBox())
                self.table_widgets['sb_1'].append(CustomSpinBox(1, 1, 1))
                self.table_widgets['sb_2'].append(CustomSpinBox(3, 1, 2, 6))
                    
                self.table_widgets['cmb'][row].addItems(self.choices)  

                self.tbl.setCellWidget(row, 0, self.table_widgets['cmb'][row])
                self.tbl.setCellWidget(row, 1, self.table_widgets['sb_1'][row])
                self.tbl.setCellWidget(row, 2, self.table_widgets['sb_2'][row])
                
                self.tbl.cellWidget(row, 0).setCurrentText(self.my_dict['Label'][row])
                self.tbl.cellWidget(row, 1).setValue(self.my_dict['X'][row])
                self.tbl.cellWidget(row, 2).setValue(self.my_dict['Y'][row])
                self.tbl.setItem(row, 3, QTableWidgetItem(self.my_dict['Comment'][row]))
                
        except TypeError:
            print('NO INI FILE PRESENT')

        
    # add a new row to the end 
    def pb_add_row_clicked(self):
        current_row_count = self.tbl.rowCount()
        row_count = current_row_count + 1
        self.tbl.setRowCount(row_count)
        self.table_widgets['cmb'].append(QComboBox())
        self.table_widgets['sb_1'].append(CustomSpinBox(1, 1, 1))
        self.table_widgets['sb_2'].append(CustomSpinBox(3, 1, 2, 6))
        self.table_widgets['cmb'][-1].addItems(self.choices)  
        self.tbl.setCellWidget(current_row_count, 0, self.table_widgets['cmb'][current_row_count])
        self.tbl.setCellWidget(current_row_count, 1, self.table_widgets['sb_1'][current_row_count])
        self.tbl.setCellWidget(current_row_count, 2, self.table_widgets['sb_2'][current_row_count])
    
    # save the table contents and table row/column to the ini file
    def pb_save_clicked(self):
        #save(self.settings)

        # create an empty dictionary
        self.tbl_dict = {'Label':[], 'X':[], 'Y':[], 'Comment':[]}
        
        # loop over the cells and add to the dictionary
        for row in range(self.tbl.rowCount()):
            
            cmb_text = self.tbl.cellWidget(row, 0).currentText()
            sb_1_value = self.tbl.cellWidget(row, 1).value()
            sb_2_value = self.tbl.cellWidget(row, 2).value()
            comment_text = self.tbl.item(row, 3)
 
            try:
                comment_text = comment_text.text()
            except AttributeError: # happens when the cell is empty or a widget
                comment_text = ''
                
            self.tbl_dict['Label'].append(cmb_text)
            self.tbl_dict['X'].append(sb_1_value)
            self.tbl_dict['Y'].append(sb_2_value)
            self.tbl_dict['Comment'].append(comment_text)
        
        # write values to ini file      
        self.settings.setValue('table', str(self.tbl_dict))
        self.settings.setValue('rows', self.tbl.rowCount())
        self.settings.setValue('columns', self.tbl.columnCount())
        print(f'WRITE TO INI FILE: {self.tbl_dict}')

    # remove selected row
    def pb_remove_row_clicked(self):
        self.tbl.removeRow(self.tbl.currentRow())

    def closeEvent(self, event):
        self.pb_save_clicked()

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    app.exec_()
0
James I On

I'm not well versed in PyQt by any means, but if I'm interpreting your problem correctly I believe PyQtConfig may help since nothing else has been suggested for some time. Here is the github version link.

Tucked away in a dark corner of github, this is not nearly as popular as it should be. A snippet from the introduction:

The core of the API is a ConfigManager instance that holds configuration settings (either as a Python dict, or a QSettings instance) and provides standard methods to get and set values.

Configuration parameters can have Qt widgets attached as handlers. Once attached the widget and the configuration value will be kept in sync. Setting the value on the ConfigManager will update any attached widgets and changes to the value on the widget will be reflected immediately in the ConfigManager. Qt signals are emitted on each update.

This should allow you to save the state of widgets and I would imagine the widgets inside them separately. However, you will need to map the necessary events to update the config file, then build the widgets "up". If it's still clearing the cache--as it seems to act similar to a dict--when pulling from the config file, this functionality might be easier if other languages were used.

Integrating database storage with java fetching methods might be overkill, but SQL for python would be a middle ground. You can have tables for parent and child widgets as well as parameters, then link them by unique id's. Using python to fetch these then build the widget on startup would be a great alternative, and would actively keep widgets organized. There is also the added benefit of being able to more easily take the application online at some point with MySQL or Oracle.