PyQt6/PySide6: Building an Account Book Frontend

Preface

Recently being idle at home, I came across a friend @studentWheat who shared a Tkinter-based account book. I decided to collaborate with him to improve the program.

Screenshots:
MainWindow

dlgAdd

stat

Backend

The backend was mainly developed by my friend. Here’s the core code:
src/api.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
from collections import defaultdict

class ApiError(RuntimeError):
    pass

def openFile(filename):
    '''
    Open file.
    File format: 4 lines per record for date, event type, money delta, and note.
    Such as:
    (file.example, encoding=utf-8)
      (Record 1)
        (ln 1) date1
        (ln 2) event_type1
        (ln 3) money_delta1
        (ln 4) note1
      (Record 2)
        (ln 5) date2
        (ln 6) event_type2
        (ln 7) money_delta2
        (ln 8) note2
    @param filename: File name.
    Returns: data in the format [[date1, event_type1, money_delta1, note1], ...]
    '''
    with open(filename, 'r', encoding='utf-8') as f:
        res = []
        while date := f.readline():
            if (etype := f.readline()) and (mdelta := f.readline()) and (note := f.readline()):
                res.append([date.rstrip('\n'), etype.rstrip('\n'), mdelta.rstrip('\n'), note.rstrip('\n')])
            else:
                raise ApiError('Unexpected EOF at ' + filename)
        return res

def saveFile(filename, data): # Save
    '''
    Save with the same format mentioned in openFile().
    @param filename: File name.
    @param data: Data with the same format returned in openFile().
    '''
    with open(filename, 'w', encoding='utf-8') as f:
        for line in data:
            print(*line, sep='\n', file=f)

def query(data, key):
    return [record for record in data if any(key in x for x in record)] if key else data

def total(data):
    in_total = out_total = 0
    for _, _, mdelta, _ in data:
        mdelta = int(mdelta)
        if mdelta < 0:
            out_total -= mdelta
        else:
            in_total += mdelta
    return in_total, out_total

def totalByEvent(data):
    cnt = defaultdict(lambda: [0, 0])
    for _, event, mdelta, _ in data:
        mdelta = int(mdelta)
        if mdelta < 0:
            cnt[event][1] -= mdelta
        else:
            cnt[event][0] += mdelta
    return cnt

def totalByDate(data):
    cnt = defaultdict(lambda: [0, 0])
    for date, _, mdelta, _ in data:
        mdelta = int(mdelta)
        if mdelta < 0:
            cnt[date][1] -= mdelta
        else:
            cnt[date][0] += mdelta
    return cnt

For details, see https://blog.csdn.net/qq_67190987/article/details/125918530.

Frontend

As stated in the title, we used Qt6+Python with two framework options (PyQt6 and PySide6). Here we chose PySide6.

Resource Preparation

Store all image resources under src/icons:
Image

Designer Window Layout

Use Qt Designer to create windows:

QMainWindow

QDialog

Dependency Installation

Create requirements.txt with:

1
PySide6>=6.3.1

Then run in cmd:

1
pip install -r requirements.txt

Resource and UI Compilation

Compile files using pyside6-uic and pyside6-rcc. Output structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
AccountBook
└─src
    β”‚  dlgAdd.ui
    β”‚  dlgCharts.ui
    β”‚  MainWindow.ui
    β”‚  res.qrc
    β”‚  res_rc.py
    β”‚  ui_dlgAdd.py
    β”‚  ui_dlgCharts.py
    β”‚  ui_dlgHelp.py
    β”‚  ui_MainWindow.py

Code Implementation

src/dlgAdd.py
“Add Account” window - a simple QDialog instance.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from PySide6.QtWidgets import *
from PySide6.QtCore import QDate, QRegularExpression
from PySide6.QtGui import QRegularExpressionValidator
from ui_dlgAdd import Ui_Dialog

class dlgAdd(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.ui = Ui_Dialog()
        self.ui.setupUi(self)
        self.ui.dateEdit.setDate(QDate.currentDate())
        self.ui.moneyEdit.setValidator(QRegularExpressionValidator(QRegularExpression(r'(\+|\-)[1-9]+[0-9]*')))
        self.ui.buttonBox.button(QDialogButtonBox.Ok).setText('Confirm')
        self.ui.buttonBox.button(QDialogButtonBox.Cancel).setText('Cancel')
    
    def getRow(self):
        date = self.ui.dateEdit.text()
        event = self.ui.eventEdit.text()
        money = self.ui.moneyEdit.text()
        note = self.ui.noteEdit.text()
        return [date, event, money, note]

    def accept(self):
        if not self.ui.eventEdit.text():
            QMessageBox.critical(self, "Error", "Event cannot be empty. Please fill in.")
            return
        if self.ui.moneyEdit.text() in ('', '+', '-'):
            QMessageBox.critical(self, "Error", "Amount cannot be empty. Please fill in.")
            return
        return super().accept()

src/dlgCharts.py
Chart display window using QtCharts (usage differs slightly from PyQt5/PySide2). More chart types may be added later.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
from bisect import bisect_left, bisect_right

from PySide6.QtCore import QDate, Qt
from PySide6.QtWidgets import *
from PySide6.QtCharts import QBarCategoryAxis, QBarSeries, QBarSet, QChart, QChartView, QValueAxis

from api import total, totalByDate, totalByEvent
from ui_dlgCharts import Ui_Dialog

class dlgCharts(QDialog):
    def __init__(self, data, parent=None):
        super().__init__(parent)
        self.ui = Ui_Dialog()
        self.ui.setupUi(self)
        self.showMaximized()

        self.data = data
        minDate = QDate.fromString(data[0][0], 'yyyy/MM/dd')
        maxDate = QDate.fromString(data[-1][0], 'yyyy/MM/dd')
        self.ui.startDateEdit.setDateRange(minDate, maxDate)
        self.ui.endDateEdit.setDateRange(minDate, maxDate)
        self.ui.startDateEdit.setDate(minDate)
        self.ui.endDateEdit.setDate(maxDate)

        self.__update_totalChart(*total(data))
        self.__update_eventChart(totalByEvent(data))
        self.__update_dateChart(totalByDate(data))

        self.ui.startDateEdit.editingFinished.connect(self.__updateCharts)
        self.ui.endDateEdit.editingFinished.connect(self.__updateCharts)

    @staticmethod
    def createChart(chartView: QChartView, title, xAxis, yAxisList):
        chart = QChart()
        chart.setTitle(title)
        chart.setAnimationOptions(QChart.SeriesAnimations)

        series = QBarSeries()
        for axisName, data in yAxisList:
            barSet = QBarSet(axisName)
            barSet.append(data)
            series.append(barSet)
        chart.addSeries(series)

        axisX = QBarCategoryAxis()
        axisX.append(xAxis)
        chart.addAxis(axisX, Qt.AlignBottom)
        series.attachAxis(axisX)

        axisY = QValueAxis()
        axisY.setLabelFormat('%d')
        chart.addAxis(axisY, Qt.AlignLeft)
        series.attachAxis(axisY)

        chartView.setChart(chart)

    def __update_totalChart(self, total_in, total_out):
        self.createChart(
            chartView = self.ui.totalView,
            title     = 'Total Income & Expense',
            xAxis     = ['Income', 'Expense'], 
            yAxisList = [
                ('Amount', [total_in, total_out])
            ]
        )

    def __update_eventChart(self, events):
        self.createChart(
            chartView = self.ui.eventView,
            title     = 'Category Statistics',
            xAxis     = list(events.keys()),
            yAxisList = [
                ('Income', list(map(lambda x: x[0], events.values()))),
                ('Expense', list(map(lambda x: x[1], events.values())))
            ]
        )

    def __update_dateChart(self, dates):
        self.createChart(
            chartView = self.ui.dateView,
            title     = 'Daily Statistics',
            xAxis     = list(dates.keys()),
            yAxisList = [
                ('Income', list(map(lambda x: x[0], dates.values()))),
                ('Expense', list(map(lambda x: x[1], dates.values())))
            ]
        )

    def __updateCharts(self):
        startDate = self.ui.startDateEdit.text()
        endDate = self.ui.endDateEdit.text()
        left = bisect_left(self.data, startDate, key=lambda x: x[0])
        right = bisect_right(self.data, endDate, key=lambda x: x[0])
        data = self.data[left:right]
        self.__update_totalChart(*total(data))
        self.__update_eventChart(totalByEvent(data))
        self.__update_dateChart(totalByDate(data))

src/main.py
Main program handling the main window. The most complex part is QTableView with search and sorting features.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
import sys
from bisect import insort_right
from functools import partial
from os.path import basename
from webbrowser import open_new_tab

from PySide6.QtWidgets import *
from PySide6.QtCore import Slot, QDate
from PySide6.QtGui import QStandardItem, QStandardItemModel

from api import ApiError, openFile, query, saveFile
from dlgAdd import dlgAdd
from dlgCharts import dlgCharts
from ui_dlgHelp import Ui_Dialog as Ui_dlgHelp
from ui_MainWindow import Ui_MainWindow

# Version info
VERSION = '1.0.1'
CHANNEL = 'stable'
BUILD_DATE = '2022-07-01'
FULL_VERSION = f'{VERSION}-{CHANNEL} ({BUILD_DATE}) on {sys.platform}'

app = QApplication(sys.argv)

class AccountBookMainWindow(QMainWindow):
    version_str = 'Account Book ' + VERSION
    unsaved_tip = '*'
    SUPPORTED_FILTERS = 'Account Book Files(*.abf);;Text Files(*.txt);;All Files(*.*)'

    def __init__(self, parent=None):
        # Initialize window
        super().__init__(parent)
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)
        self.setWindowTitle('Account Book ' + VERSION)
        self.labStatus = QLabel(self)
        self.ui.statusBar.addWidget(self.labStatus)

        # Initialize table
        self.model = QStandardItemModel(0, 4, self)
        self.model.setHorizontalHeaderLabels(['Date', 'Event', 'Amount', 'Note'])
        self.ui.table.setModel(self.model)
        self.ui.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)

        self.__data = []
        self.on_actFile_New_triggered()
        self.ui.actEdit_Remove.setEnabled(False)

        # Connect slots
        self.ui.table.selectionModel().selectionChanged.connect(self.__selectionChanged)
        self.model.itemChanged.connect(self.__itemChanged)

    def __updateTable(self, data):
        self.model.itemChanged.disconnect(self.__itemChanged)
        self.model.setRowCount(len(data))
        for row in range(len(data)):
            for col in range(len(data[row])):
                self.model.setItem(row, col, QStandardItem(data[row][col]))
        self.model.itemChanged.connect(self.__itemChanged)

    def __openFile(self, filename):
        try:
            self.__data = openFile(filename)
        except IOError:
            QMessageBox.critical(self, 'Error', 'Failed to open file. Please try again.')
        except ApiError:
            QMessageBox.critical(self, 'Error', 'File format error. Check file integrity.')
        except Exception as e:
            QMessageBox.critical(self, 'Error', 'Unknown error: ' + str(e.with_traceback()))
        else:
            self.ui.searchEdit.clear()
            self.__key = ''
            self.__updateTable(self.__data)
            self.labStatus.setText(filename)
            self.setWindowTitle(self.version_str)
            self.__filename = filename

    def __saveFile(self, filename):
        try:
            saveFile(filename, self.__data)
        except IOError:
            QMessageBox.critical(self, 'Error', 'Failed to save file. Please try again.')
        except Exception as e:
            QMessageBox.critical(self, 'Error', 'Unknown error: ' + str(e.with_traceback()))
        else:
            self.labStatus.setText('Saved: ' + filename)
            self.setWindowTitle(self.version_str)
            self.__filename = filename

    @Slot()
    def on_actFile_New_triggered(self):
        self.__filename = self.__key = ''
        self.setWindowTitle(self.unsaved_tip + self.version_str)
        self.labStatus.setText('New File')
        self.model.setRowCount(0)
        self.__data.clear()

    @Slot()
    def on_actFile_Open_triggered(self):
        filename, _ = QFileDialog.getOpenFileName(self, 'Open', filter=self.SUPPORTED_FILTERS)
        if filename:
            self.__openFile(filename)

    @Slot()
    def on_actFile_Save_triggered(self):
        if self.__filename:
            self.__saveFile(self.__filename)
        else:
            filename, _ = QFileDialog.getSaveFileName(self, 'Save', filter=self.SUPPORTED_FILTERS)
            if filename:
                self.__saveFile(filename)

    @Slot()
    def on_actFile_SaveAs_triggered(self):
        filename, _ = QFileDialog.getSaveFileName(self, 'Save As', filter=self.SUPPORTED_FILTERS)
        if filename:
            self.__saveFile(filename)

    @Slot()
    def on_actHelp_About_triggered(self):
        dialog = QDialog(self)
        ui = Ui_dlgHelp()
        ui.setupUi(dialog)
        for link in (ui.githubLink, ui.giteeLink, ui.licenseLink, ui.readmeLink):
            link.clicked.connect(partial(open_new_tab, link.description()))
        ui.labVersion.setText('Version: ' + FULL_VERSION)
        ui.btnUpdate.clicked.connect(partial(open_new_tab, 'https://github.com/GoodCoder666/AccountBook/releases'))
        dialog.exec()

    @Slot()
    def on_actHelp_AboutQt_triggered(self):
        QMessageBox.aboutQt(self, 'About Qt')

    @Slot()
    def on_actEdit_Add_triggered(self):
        dialog = dlgAdd(self)
        if dialog.exec() == QDialog.Accepted:
            row = dialog.getRow()
            insort_right(self.__data, row)
            self.__updateTable(query(self.__data, self.__key))
            self.setWindowTitle(self.unsaved_tip + self.version_str)

    @Slot()
    def on_actEdit_Remove_triggered(self):
        rows = list(set(map(lambda idx: idx.row(), self.ui.table.selectedIndexes())))
        for row in rows:
            self.__data.remove([self.model.item(row, col).text() for col in range(self.model.columnCount())])
        self.model.itemChanged.disconnect(self.__itemChanged)
        self.model.removeRows(rows[0], len(rows))
        self.model.itemChanged.connect(self.__itemChanged)
        self.setWindowTitle(self.unsaved_tip + self.version_str)

    def __selectionChanged(self):
        self.ui.actEdit_Remove.setEnabled(self.ui.table.selectionModel().hasSelection())

    def __itemChanged(self, item: QStandardItem):
        i, j, new = item.row(), item.column(), item.text()
        if (old := self.__data[i][j]) == new: return
        if j == 0 and not QDate.fromString(new, 'yyyy/MM/dd').isValid():
            QMessageBox.critical(self, 'Error', 'Invalid date format.')
            self.model.itemChanged.disconnect(self.__itemChanged)
            item.setText(old)
            self.model.itemChanged.connect(self.__itemChanged)
            return
        row = self.__data.pop(i)
        row[j] = new
        insort_right(self.__data, row)
        self.__updateTable(query(self.__data, self.__key))
        self.setWindowTitle(self.unsaved_tip + self.version_str)

    @Slot()
    def on_searchEdit_textChanged(self):
        self.__key = self.ui.searchEdit.text()
        self.__updateTable(query(self.__data, self.__key))

    @Slot()
    def on_actStat_Show_triggered(self):
        if self.__data:
            dlgCharts(self.__data, self).exec()
        else:
            QMessageBox.information(self, 'Info',
Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy