Search code examples
pythonpython-3.xpyside2

PySide2 QDialog possible bug


I have the code below.

Under PyQt5 all the button clicks work as expected. Under PySide2 they do not.

Any explanation, is this a PySide2 bug?

import os
import sys
import xlrd
from PySide2 import QtCore, QtGui, QtWidgets
# from PyQt5 import QtCore, QtGui, QtWidgets


class ExcelDialog(QtWidgets.QDialog):

    def __init__(self, excel_file=None, items=()):
        """
        Constructor
        :param excel_file: excel file to list
        :param items: items to show if the file is none
        """

        QtWidgets.QDialog.__init__(self)

        self.setObjectName("ExcelSelectionDialog")
        self.resize(272, 229)
        self.setMaximumSize(QtCore.QSize(272, 229))
        self.setModal(True)
        self.verticalLayout = QtWidgets.QVBoxLayout(self)
        self.verticalLayout.setContentsMargins(1, 1, 1, 1)
        self.verticalLayout.setObjectName("verticalLayout")
        self.sheets_list = QtWidgets.QListWidget(self)
        self.sheets_list.setFrameShape(QtWidgets.QFrame.StyledPanel)
        self.sheets_list.setObjectName("sheets_list")
        self.verticalLayout.addWidget(self.sheets_list)
        self.frame = QtWidgets.QFrame(self)
        self.frame.setFrameShape(QtWidgets.QFrame.NoFrame)
        self.frame.setFrameShadow(QtWidgets.QFrame.Raised)
        self.frame.setObjectName("frame")
        self.horizontalLayout = QtWidgets.QHBoxLayout(self.frame)
        self.horizontalLayout.setContentsMargins(1, 1, 1, 1)
        self.horizontalLayout.setObjectName("horizontalLayout")
        spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
        self.horizontalLayout.addItem(spacerItem)
        self.cancelButton = QtWidgets.QPushButton(self.frame)
        self.cancelButton.setObjectName("cancelButton")
        self.horizontalLayout.addWidget(self.cancelButton)
        self.acceptButton = QtWidgets.QPushButton(self.frame)
        self.acceptButton.setObjectName("acceptButton")
        self.horizontalLayout.addWidget(self.acceptButton)
        self.verticalLayout.addWidget(self.frame)

        self.retranslateUi(self)
        QtCore.QMetaObject.connectSlotsByName(self)

        # click
        self.acceptButton.clicked.connect(self.accepted)
        self.cancelButton.clicked.connect(self.rejected)
        self.sheets_list.doubleClicked.connect(self.accepted)

        self.excel_sheet = None

        self.sheet_names = list()
        if excel_file is not None:
            if os.path.exists(excel_file):
                self.fill_from_file(excel_file=excel_file)
            else:
                self.sheets_list.addItems(items)
        else:
            self.sheets_list.addItems(items)

    def fill_from_file(self, excel_file):
        """

        :param excel_file:
        :return:
        """
        if excel_file is not None:
            xls = xlrd.open_workbook(excel_file, on_demand=True)
            self.sheet_names = xls.sheet_names()
            self.sheets_list.addItems(self.sheet_names)

            if len(self.sheet_names) > 0:
                self.excel_sheet = 0

    def accepted(self):
        """

        :return:
        """
        if len(self.sheets_list.selectedIndexes()):
            self.excel_sheet = self.sheets_list.selectedIndexes()[0].row()
        print('Accepted: self.excel_sheet: ', self.excel_sheet)

        self.close()

    def rejected(self):
        """

        :return:
        """
        print('Rejected: self.excel_sheet: ', self.excel_sheet)
        self.close()

    def retranslateUi(self, ExcelSelectionDialog):
        """

        :param ExcelSelectionDialog:
        :return:
        """
        ExcelSelectionDialog.setWindowTitle(QtWidgets.QApplication.translate("ExcelSelectionDialog", "Excel sheet selection", None, -1))
        self.cancelButton.setText(QtWidgets.QApplication.translate("ExcelSelectionDialog", "Cancel", None, -1))
        self.acceptButton.setText(QtWidgets.QApplication.translate("ExcelSelectionDialog", "Accept", None, -1))


if __name__ == "__main__":
    excel_file = None
    app = QtWidgets.QApplication(sys.argv)
    window = ExcelDialog(excel_file, items=['A', 'B', 'C'])
    window.show()
    sys.exit(app.exec_())

BTW, I'm on PySide 5.12.3


Solution

  • It seems a bug, in my answer I will try to analyze what is happening.

    First of all I have simplified the MCVE to the following:

    from PySide2 import QtCore, QtGui, QtWidgets
    # from PyQt5 import QtCore, QtGui, QtWidgets
    
    
    class Dialog(QtWidgets.QDialog):
        def __init__(self, parent=None):
            super(Dialog, self).__init__(parent)
    
            list_widget = QtWidgets.QListWidget()
            list_widget.addItems(list("ABC"))
    
            accept_button = QtWidgets.QPushButton("Accept")
            cancel_button = QtWidgets.QPushButton("Cancel")
    
            lay = QtWidgets.QVBoxLayout(self)
            hlay = QtWidgets.QHBoxLayout()
            hlay.addStretch()
            hlay.addWidget(accept_button)
            hlay.addWidget(cancel_button)
            lay.addWidget(list_widget)
            lay.addLayout(hlay)
    
            accept_button.clicked.connect(self.accepted)
            cancel_button.clicked.connect(self.rejected)
    
        def accepted(self):
            print("accepted")
    
        def rejected(self):
            print("rejected")
    

    Another thing to keep in mind is that QDialog has a signal called accepted() that is causing this strange behavior. Also keep in mind that the connection can be made between a signal with a callable, with a slot and another signal.

    My hypothesis is that PySide2 first makes the connection with the slots and signals before making the connections with the normal functions of python, and that is verified using the following code:

        # ...
        # create connections
        QtCore.QObject.connect(self, QtCore.SIGNAL("accepted()"), self, QtCore.SLOT("accepted_test()"))
        QtCore.QObject.connect(self, QtCore.SIGNAL("rejected()"), self, QtCore.SLOT("rejected_test()"))
    
    def accepted_test(self):
        print("accepted_test")
    
    def rejected_test(self):
        print("rejected_test")
    
    def accepted(self):
        print("accepted")
    
    def rejected(self):
        print("rejected")
    # ...
    

    If you press the Accept and Cancel buttons you get:

    accepted_test
    rejected_test
    

    On the other hand, PyQt5 does not seem to have the same hierarchy, so it prefers the connection with the python function.

    Indicate that a bug is subjective because in the docs is not explicitly stated and depends on the behavior that each one expects, maybe this is planned by PySide2 since for them it is correct, and the same for PyQt5.

    There is a workaround for this case: make accepted and rejected part of the QMetaObject using the decorator @QtCore.Slot():

        # ...
        accept_button.clicked.connect(self.accepted)
        cancel_button.clicked.connect(self.rejected)
    
    @QtCore.Slot()
    def accepted(self):
        print("accepted")
    
    @QtCore.Slot()
    def rejected(self):
        print("rejected")
    # ...
    

    But my personal recommendation that can be considered as good practice is not to create methods that already use the base class.