23. Model-View Programming with QAbstractTableModel

QAbstractTableModel enables you to display and edit tabular data in Qt applications.

List models organize the data as a one-dimenzional sequence of items, where each row represents a single, atomic element. In contrast, table models structure data in a two-dimensional grid, where each row represents a composite record or entity, and the columns within it hold individual fields or attributes.

In the previous chapter we used a simple text file as the data source: each file row was a single element representing a fictional company client. Here, we use a comma-separated (CSV) file where each row has four fields: client id, first name, last name, and profession. We follow the same progression as in the previous chapter:

  • read-only model
  • editable model
  • data mapping
  • resizable model

but focusing on columnar aspects.

23.1 Basic Read-Only Table Model

An icon of a clipboard-list1

You need to show data from a CSV file (data.csv) in a table view. The file contains company clients data, each row containing client id, first name, last name and profession. You decide to implement a QAbstractTableModel subclass using the CSV file as the data source.

To create a read-only table model:

 1 import csv
 2 from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
 3 
 4 # 1. Create a QAbstractTableModel subclass.
 5 #    We read the data from a csv file using Python's csv.reader
 6 #    Each row in a reader object is a list making self.csv_data
 7 #    a two-dimensional list suitable for use
 8 #    with QAbstractTableModel.
 9 
10 class CsvModel(QAbstractTableModel):
11     
12     def __init__(self, source, parent=None):
13         
14         super().__init__(parent)
15         
16         self.csv_data = []
17         with open(source) as csv_file:
18             reader = csv.reader(csv_file)
19             self.header = next(reader)
20             for row in reader:
21                 self.csv_data.append(row)
22 
23     # 2. Implement the rowCount() and columnCount() methods
24 
25     def rowCount(self, parent=QModelIndex()) -> int:
26         # Must return zero if parent is valid
27         if parent.isValid():
28             return 0
29         return len(self.csv_data)
30 
31     def columnCount(self, parent=QModelIndex()) -> int:
32         # Must return zero if parent is valid
33         if parent.isValid():
34             return 0
35         return 4
36     
37     # 3. Implement the data() method
38     
39     def data(self, index, role):
40         if role == Qt.ItemDataRole.DisplayRole:
41             return self.csv_data[index.row()][index.column()]
42         
43     # QTableView can have a header
44     # but implementing headerData() is still optional.
45 
46     def headerData(self, section, orientation, role):
47         if orientation == Qt.Orientation.Horizontal:
48             if role == Qt.ItemDataRole.DisplayRole:
49                 return self.header[section]
  1. Create a subclass of QAbstractTableModel named CsvModel and make external data available to it. We read the data from data.csv using Python’s csv reader and store it in a member variable named self.csv_data. csv_data is a list and csv reader’s rows are also lists which effectively makes csv_data a two-dimensional list suitable for use with QAbstractTableModel subclasses.

  2. Implement the rowCount() and the columnCount() methods. rowCount() is the length of the self.csv_data list and never changes since the model is read-only. columnCount() is hard-coded to return 4, the number of columns in the CSV file.

  3. Implement the data() method. data() expects two arguments: index, a QModelIndex instance and role, a member of the DisplayRole enumeration. In the method body we check if role is equal to DisplayRole and if it is we return the data that the index points to. Note that we need to use both index.row() and index.column() as the data has two dimensions. With this you have a functional QAbstracttableModel subclass.

 1 import sys
 2 from PySide6.QtWidgets import (QApplication,
 3     QWidget, QTableView, QVBoxLayout)
 4 from PySide6.QtTest import QAbstractItemModelTester
 5 from csvmodel import CsvModel
 6 
 7 
 8 class Window(QWidget):
 9     
10     def __init__(self):
11 
12         super().__init__()
13 
14         layout = QVBoxLayout()
15         self.setLayout(layout)
16         
17         # 4. Use the model:
18         #    Create a model instance, create a view instance
19         #    and and use view.setModel() to connect them.
20 
21         model = CsvModel('data.csv')
22         QAbstractItemModelTester(model,
23             QAbstractItemModelTester.FailureReportingMode.Warning)
24         
25         view = QTableView()
26         view.setModel(model)
27         view.resizeColumnsToContents()
28         layout.addWidget(view)
29 
30 
31 if __name__ == '__main__':
32 
33     app = QApplication(sys.argv)
34     main_window = Window()
35     main_window.show()
36     sys.exit(app.exec())
  1. In your main class (Window), create a CsvModel object, create a QTableView object and set its model using QTableView()setModel()

QAbstractTableModel subclasses are commonly used with QTableView but not necessarily so. QTableView is able to display table headers using the header data you provide to it in the model headerData() method but implementing headerData() is still not mandatory.

23.2 Making the Table Model Editable

Just as for a list model, to make a table model editable you need to implement its setData() method and modify its flags() method to return the itemIsEditable flag for each item.

An icon of a clipboard-list1

You need to make your CSV file-backed model, that contains company clients data, editable.

To make a table model editable:

 1 import csv
 2 from PySide6.QtCore import (QAbstractTableModel,
 3     QModelIndex, Qt)
 4 
 5 # 1. Create a QAbstractTableModel subclass
 6 #    same as in the read-only subclass example.
 7 
 8 class CsvModel(QAbstractTableModel):
 9     
10     def __init__(self, source, parent=None):
11         
12         super().__init__(parent)
13         
14         self.csv_data = []
15         with open(source) as csv_file:
16             reader = csv.reader(csv_file)
17             self.header = next(reader)
18             for row in reader:
19                 self.csv_data.append(row)
20 
21     # 2. Implement rowCount(), columnCount() and data()
22 
23     def rowCount(self, parent=QModelIndex()):
24         # If parent is valid rowCount() must return zero
25         if parent.isValid():
26             return 0
27         return len(self.csv_data)
28     
29     def columnCount(self, parent=QModelIndex()):
30         # If parent is valid columnCount() must return zero
31         if parent.isValid():
32             return 0
33         return 4
34     
35     def data(self, index, role):
36         if role in (Qt.ItemDataRole.DisplayRole,
37                     Qt.ItemDataRole.EditRole):
38             return self.csv_data[index.row()][index.column()]
39 
40     # 3. Implement setData()
41     
42     def setData(self, index, value,
43                 role: int = Qt.ItemDataRole.EditRole) -> bool:
44         if role == Qt.ItemDataRole.EditRole:
45             if self.csv_data[index.row()][index.column()] != value:
46                 self.csv_data[index.row()][index.column()] = value
47                 self.dataChanged.emit(index, index)
48                 return True
49             return False
50         return False
51     
52     # 4. Implement flags()
53     
54     def flags(self, index) -> Qt.ItemFlag:
55         if not index.isValid():
56             return Qt.ItemFlags()
57         return (super().flags(index) |
58                     Qt.ItemFlag.ItemIsEditable)
59 
60     # QTableViews can have a header
61 
62     def headerData(self, section, orientation, role):
63         if orientation == Qt.Orientation.Horizontal:
64             if role == Qt.ItemDataRole.DisplayRole:
65                 return self.header[section]
  1. Create a QAbstractTableModel subclass.

  2. Implement the rowCount(), columnCount() and data() methods. The implementation is the same as in the read-only model example.

  3. Implement the setData() method. setData() accepts three arguments:

    • index, a QModelIndex object that you will use to get the coordinates of the data point to be changed,
    • value which is the new data value and
    • role, one of the Qt.ItemDataRole enumeration members, in most cases EditRole.

    In the method body check if role is equal to EditRole, check if the new data is actually different from the current data, change the data and emit the dataChanged() signal.

  4. Implement the flags() method. In this method you return the item flags given its index. In the example all items (ie. fields) are flagged as selectable, enabled and editable. This does not have to be the case. For instance, if you had a model based on a SQL table you would flag the primary key fields as selectable only, making only the primary keys read-only.

 1 import sys
 2 from PySide6.QtCore import Qt
 3 from PySide6.QtWidgets import (QApplication,
 4     QWidget, QTableView, QVBoxLayout)
 5 from PySide6.QtTest import QAbstractItemModelTester
 6 from csvmodel import CsvModel
 7 
 8 
 9 class Window(QWidget):
10     
11     def __init__(self):
12 
13         super().__init__()
14 
15         layout = QVBoxLayout()
16         self.setLayout(layout)
17         
18         # 5. Use the model
19         #    Create the model, create the view
20         #    and assign the model to the view.
21 
22         model = CsvModel('data.csv')
23         QAbstractItemModelTester(model,
24             QAbstractItemModelTester.FailureReportingMode.Warning)
25         view = QTableView()
26         view.setModel(model)
27         view.resizeColumnsToContents()
28         layout.addWidget(view)
29         
30         model.dataChanged.connect(self.on_data_changed)
31         
32     def on_data_changed(self, topLeft, bottomRight, roles):
33         print(f'Model changed, r: {topLeft.row()}, c: {topLeft.column()}')
34         data = topLeft.model().data(topLeft, Qt.ItemDataRole.DisplayRole)
35         print(f'Data: {data}')
36 
37 
38 if __name__ == '__main__':
39 
40     app = QApplication(sys.argv)
41     main_window = Window()
42     main_window.show()
43     sys.exit(app.exec())
  1. Then, in the main class, create a model instance, create a QTableView instance and use QTableView.setModel() to connect the two.

23.3 Using Data Widget Mapper with Table Models

We have already seen how to use a data-widget mapper in the previous chapter where we mapped a single line edit to a list model row. Here, we expand the example to map several widgets to a table model cells.

An icon of a clipboard-list1

You add a form to the GUI to make editing your CSV file-backed model user-friendly.

To use a data-widget mapper with a table model:

 1 import csv
 2 from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
 3 
 4 # 1. Create the model class
 5 
 6 class CsvModel(QAbstractTableModel):
 7     
 8     def __init__(self, source, parent=None):
 9         
10         super().__init__(parent)
11         
12         self.csv_data = []
13         with open(source) as csv_file:
14             reader = csv.reader(csv_file)
15             self.header = next(reader)
16             for row in reader:
17                 self.csv_data.append(row)
18                 
19     def rowCount(self, parent=QModelIndex()) -> int:
20         if parent.isValid():
21             return 0
22         return len(self.csv_data)
23     
24     def columnCount(self, parent=QModelIndex()) -> int:
25         if parent.isValid():
26             return 0
27         return 4
28     
29     def data(self, index, role):
30         if role in (Qt.ItemDataRole.DisplayRole,
31                     Qt.ItemDataRole.EditRole):
32             return self.csv_data[index.row()][index.column()]
33 
34     # Editable models implement setData() and flags()
35     
36     def setData(self, index, value,
37                 role = Qt.ItemDataRole.EditRole) -> bool:
38         if role == Qt.ItemDataRole.EditRole:
39             if self.csv_data[index.row()][index.column()] != value:
40                 self.csv_data[index.row()][index.column()] = value
41                 self.dataChanged.emit(index, index)
42                 return True
43             return False
44         return False
45     
46     def flags(self, index) -> Qt.ItemFlag:
47         if not index.isValid():
48             return Qt.ItemFlags()
49         return super().flags(index) | Qt.ItemFlag.ItemIsEditable
50 
51     def headerData(self, section, orientation, role):
52         if orientation == Qt.Orientation.Horizontal:
53             if role == Qt.ItemDataRole.DisplayRole:
54                 return self.header[section]
  1. Create the model class. It is the same editable table model as in the previous section.

  2. Create the widgets. Each column in our tabele model (first name, last name and occupation) contains string data so we create three line edits, one for each column. We also add a Submit button to let the user submit changes to the current table row and add all widgets to a form layout.

  3. Create a data-widget mapper object and add the widgets to it. Create a QDataWidgetMapper object and set the CsvModel as its model. Map each line edit to a CsvModel column using addMapping() and synchronize the data in the mapper widgets with the table view currently selected row.

Now, when the user changes the data in the data-widget mapper widgets and presses the Submit button, the model is updated and the changes are propagated to the view.

23.4 Resizable Table Model

Just as with a list model, to make a table model resizable you need to implement two methods:

  • insertRows()
  • removeRows()
An icon of a clipboard-list1

You need to let the users append, insert, and remove rows from your CSV file-backed model.

To create a resizable table model:

 1 import csv
 2 from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
 3 
 4 # 1. Create the model.
 5 
 6 class CsvModel(QAbstractTableModel):
 7     
 8     def __init__(self, source, parent=None):
 9         
10         super().__init__(parent)
11         
12         self.csv_data = []
13         with open(source) as csv_file:
14             reader = csv.reader(csv_file)
15             self.header = next(reader)
16             for row in reader:
17                 self.csv_data.append(row)
18                 
19     def rowCount(self, parent=QModelIndex()):
20         if parent.isValid():
21             return 0
22         return len(self.csv_data)
23     
24     def columnCount(self, parent=QModelIndex()):
25         if parent.isValid():
26             return 0
27         return 4
28     
29     def data(self, index, role):
30         if role in (Qt.ItemDataRole.DisplayRole,
31                     Qt.ItemDataRole.EditRole):
32             return self.csv_data[index.row()][index.column()]
33 
34     # Editable models implement setData() and flags()
35     
36     def setData(self, index, value, role):
37         if role == Qt.ItemDataRole.EditRole:
38             if self.csv_data[index.row()][index.column()] != value:
39                 self.csv_data[index.row()][index.column()] = value
40                 self.dataChanged.emit(index, index)
41                 return True
42             return False
43         return False
44     
45     def flags(self, index):
46         if not index.isValid():
47             return Qt.ItemFlags()
48         return (super().flags(index) |
49             Qt.ItemFlags.ItemIsEditable)
50     
51     def insertRows(self, row, count, parent=QModelIndex()):
52         if 0 <= row <= self.rowCount():
53             self.beginInsertRows(parent, row, row)
54             self.csv_data.insert(row, ['', '', '', ''])
55             self.endInsertRows()
56             return True
57         else:
58             return False
59         
60     def removeRows(self, row, count, parent=QModelIndex()):
61         if 0 <= row < len(self.csv_data):
62             self.beginRemoveRows(parent, row, row)
63             self.csv_data[row:row + 1] = []
64             self.endRemoveRows()
65             return True
66         else:
67             return False
68 
69     def headerData(self, section, orientation, role):
70         if orientation == Qt.Orientation.Horizontal:
71             if role == Qt.ItemDataRole.DisplayRole:
72                 return self.header[section]
  1. Create the model. Create a subclass of QAbstractTableModel and store the CSV data in the self.csv_data instance field. You already implemented rowCount(), columnCount() and data() for your read-only model; and setData() and flags() to make it editable. To make the model resizable:
    • implement insertRows(): check if row is within acceptable range, and insert and empty row in self.csv_data, guarding the insertion with beginInsertRows() and endInsertRows() calls. Return true on success, and false otherwise.
    • implement removeRows(). The line self.csv_data[row:row + 1] = [] removes the single element at index row from the list by assigning an empty list to that one-element slice, also guarder with beginRemoveRows() and endRemoveRows(). Return true on success, and false otherwise.

Then, in the main window:

 1 import sys
 2 from PySide6.QtCore import Qt
 3 from PySide6.QtTest import QAbstractItemModelTester
 4 from PySide6.QtWidgets import (QApplication, QWidget,
 5     QTableView, QVBoxLayout, QPushButton)
 6 from csvmodel import CsvModel
 7 
 8 
 9 class Window(QWidget):
10     
11     def __init__(self):
12 
13         super().__init__()
14 
15         layout = QVBoxLayout()
16         self.setLayout(layout)
17         
18         # 2. Create the model and view objects.
19 
20         self.model = CsvModel('data.csv')
21         QAbstractItemModelTester(self.model,
22             QAbstractItemModelTester.FailureReportingMode.Warning)
23         self.view = QTableView()
24         self.view.setModel(self.model)
25         self.model.rowsInserted.connect(self.on_rows_inserted)
26         self.view.resizeColumnsToContents()
27         
28         # 3. Add the Insert, Append and Remove buttons.
29 
30         self.insert_button = QPushButton('Insert new')
31         self.insert_button.clicked.connect(self.on_insert)
32         
33         self.append_button = QPushButton('Append new')
34         self.append_button.clicked.connect(self.on_append)
35         
36         self.remove_button = QPushButton('Remove current')
37         self.remove_button.clicked.connect(self.on_remove)
38         
39         layout.addWidget(self.view)
40         layout.addWidget(self.insert_button)
41         layout.addWidget(self.append_button)
42         layout.addWidget(self.remove_button)
43         
44         self.model.dataChanged.connect(self.on_data_changed)
45         
46     def on_insert(self):
47         row = self.view.currentIndex().row()
48         self.model.insertRows(row, 1)
49         
50     def on_append(self):
51         row = self.model.rowCount()
52         self.model.insertRows(row, 1)
53         index = self.model.index(row, 0)
54         self.view.scrollTo(index)
55     
56     def on_remove(self):
57         row = self.view.currentIndex().row()
58         self.model.removeRows(row, 1)
59         
60     def on_rows_inserted(self, parent, first, last):
61         index = self.model.index(first, 0)
62         if index.isValid():
63             self.view.setCurrentIndex(index)
64         
65     def on_data_changed(self, topLeft, bottomRight, roles):
66         print(f'Model changed, r: {topLeft.row()}, c: {topLeft.column()}')
67         data = topLeft.model().data(topLeft, Qt.ItemDataRole.DisplayRole)
68         print(f'Data: {data}')
69            
70 
71 if __name__ == '__main__':
72 
73     app = QApplication(sys.argv)
74     main_window = Window()
75     main_window.show()
76     sys.exit(app.exec())
  1. Create the model and view objects. Create a CsvModel object, initializing it with the file that contains the client data; Create a QTableView object and set the CSV model as its model.

  2. Add the Insert, Append, and Remove buttons:

    • When the Insert button is pressed, get the view current index row and call insertRows() with it to insert and empty row above the current row.
    • When the Append button is pressed, get the next available row number and call insertRows() with it to append an empty row to the model.
    • When the Remove row is pressed, get the current view index and and call removeRows() with its row to remove the current row from the model.

    Note that both in insertRows() and removeRows() we cheat a bit and assume that only one row can be inserted or removed at a time. To allow for multiple rows insertion and deletion, you need to adjust the range passed to beginInsertRows() and beginRemoveRows() and update the insert and remove logic.

    Also note that insertRows() does not let you pass initial data to it and that we just insert empty strings as the data. If you need to insert some initial data, use setData() after the insertion completes or extend the model with a custom method (e.g. insertRowsWithData()) that accepts data parameters and calls the standard insertRows() internally.

    Lastly, the documentation mentions that you can provide your own API for altering or removing the data as long as you call beginInsertRow() or beginRemoveRows() to notify other components that the model has changed.