Fri, 21 Apr 2023 16:45:32 +0200
Create Code Dialog
- Fixed a situation where the helper processes actually worked but their exit status was changed by Qt (see issue 462).
# -*- coding: utf-8 -*- # Copyright (c) 2007 - 2023 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing a dialog to generate code for a Qt5 dialog. """ import contextlib import json import os from PyQt6.QtCore import ( QMetaObject, QProcess, QProcessEnvironment, QRegularExpression, QSortFilterProxyModel, Qt, pyqtSlot, ) from PyQt6.QtGui import QBrush, QColor, QStandardItem, QStandardItemModel from PyQt6.QtWidgets import QDialog, QDialogButtonBox from eric7 import Preferences from eric7.EricWidgets import EricMessageBox from eric7.EricWidgets.EricApplication import ericApp from eric7.Globals import getConfig from eric7.SystemUtilities import PythonUtilities from eric7.Utilities import ModuleParser from .NewDialogClassDialog import NewDialogClassDialog from .Ui_CreateDialogCodeDialog import Ui_CreateDialogCodeDialog pyqtSignatureRole = Qt.ItemDataRole.UserRole + 1 pythonSignatureRole = Qt.ItemDataRole.UserRole + 2 returnTypeRole = Qt.ItemDataRole.UserRole + 3 parameterTypesListRole = Qt.ItemDataRole.UserRole + 4 parameterNamesListRole = Qt.ItemDataRole.UserRole + 5 class CreateDialogCodeDialog(QDialog, Ui_CreateDialogCodeDialog): """ Class implementing a dialog to generate code for a Qt5 dialog. """ DialogClasses = { "QDialog", "QWidget", "QMainWindow", "QWizard", "QWizardPage", "QDockWidget", "QFrame", "QGroupBox", "QScrollArea", "QMdiArea", "QTabWidget", "QToolBox", "QStackedWidget", } Separator = 25 * "=" def __init__(self, formName, project, parent=None): """ Constructor @param formName name of the file containing the form (string) @param project reference to the project object @param parent parent widget if the dialog (QWidget) """ super().__init__(parent) self.setupUi(self) self.okButton = self.buttonBox.button(QDialogButtonBox.StandardButton.Ok) self.slotsView.header().hide() self.project = project self.formFile = formName filename, ext = os.path.splitext(self.formFile) self.srcFile = "{0}{1}".format( filename, self.project.getDefaultSourceExtension() ) self.slotsModel = QStandardItemModel() self.proxyModel = QSortFilterProxyModel() self.proxyModel.setDynamicSortFilter(True) self.proxyModel.setSourceModel(self.slotsModel) self.slotsView.setModel(self.proxyModel) # initialize some member variables self.__initError = False self.__module = None packagesRoot = self.project.getUicParameter("PackagesRoot") if packagesRoot: self.packagesPath = os.path.join( self.project.getProjectPath(), packagesRoot ) else: self.packagesPath = self.project.getProjectPath() if os.path.exists(self.srcFile): vm = ericApp().getObject("ViewManager") ed = vm.getOpenEditor(self.srcFile) if ed and not ed.checkDirty(): self.__initError = True return with contextlib.suppress(ImportError): splitExt = os.path.splitext(self.srcFile) exts = [splitExt[1]] if len(splitExt) == 2 else None self.__module = ModuleParser.readModule( self.srcFile, extensions=exts, caching=False ) if self.__module is not None: self.filenameEdit.setText(self.srcFile) classesList = [] vagueClassesList = [] for cls in list(self.__module.classes.values()): if not set(cls.super).isdisjoint(CreateDialogCodeDialog.DialogClasses): classesList.append(cls.name) else: vagueClassesList.append(cls.name) classesList.sort() self.classNameCombo.addItems(classesList) if vagueClassesList: if classesList: self.classNameCombo.addItem(CreateDialogCodeDialog.Separator) self.classNameCombo.addItems(sorted(vagueClassesList)) if ( os.path.exists(self.srcFile) and self.__module is not None and self.classNameCombo.count() == 0 ): self.__initError = True EricMessageBox.critical( self, self.tr("Create Dialog Code"), self.tr( """The file <b>{0}</b> exists but does not contain""" """ any classes.""" ).format(self.srcFile), ) self.okButton.setEnabled(self.classNameCombo.count() > 0) self.__updateSlotsModel() def initError(self): """ Public method to determine, if there was an initialzation error. @return flag indicating an initialzation error (boolean) """ return self.__initError def __runUicLoadUi(self, command): """ Private method to run the UicLoadUi.py script with the given command and return the output. @param command uic command to be run @type str @return tuple of process output and error flag @rtype tuple of (str, bool) """ venvManager = ericApp().getObject("VirtualEnvManager") projectType = self.project.getProjectType() venvName = self.project.getProjectVenv(resolveDebugger=False) if not venvName: # no project specific environment, try a type specific one if projectType in ("PyQt5", "PySide2"): venvName = Preferences.getQt("PyQtVenvName") elif projectType in ("PyQt6", "E7Plugin", "PySide6"): venvName = Preferences.getQt("PyQt6VenvName") interpreter = venvManager.getVirtualenvInterpreter(venvName) execPath = venvManager.getVirtualenvExecPath(venvName) if not interpreter: interpreter = PythonUtilities.getPythonExecutable() env = QProcessEnvironment.systemEnvironment() if execPath: if env.contains("PATH"): env.insert("PATH", os.pathsep.join([execPath, env.value("PATH")])) else: env.insert("PATH", execPath) if projectType in ("PyQt5", "PySide2"): loadUi = os.path.join(os.path.dirname(__file__), "UicLoadUi5.py") elif projectType in ("PyQt6", "E7Plugin", "PySide6"): loadUi = os.path.join(os.path.dirname(__file__), "UicLoadUi6.py") args = [ loadUi, command, self.formFile, self.packagesPath, ] uicText = "" ok = False proc = QProcess() proc.setWorkingDirectory(self.packagesPath) proc.setProcessEnvironment(env) proc.start(interpreter, args) started = proc.waitForStarted(5000) finished = proc.waitForFinished(30000) if started and finished: output = proc.readAllStandardError() outText = str(output, "utf-8", "replace") if "@@eric_start@@" in outText: # it is something we sent via UicLoadUi[56].py outText = outText.split("@@eric_start@@")[1] ok = True elif "@@eric_error@@" in outText: # it is something we sent via UicLoadUi[56].py outText = outText.split("@@eric_error@@")[1] ok = False else: ok = False if "@@eric_end@@" in outText: # it is something we sent via UicLoadUi[56].py outText = outText.split("@@eric_end@@")[0] if ok: uicText = outText.strip() else: EricMessageBox.critical( self, self.tr("uic error"), self.tr( """<p>There was an error loading the form <b>{0}</b>""" """.</p><p>{1}</p>""" ).format(self.formFile, outText), ) else: EricMessageBox.critical( self, self.tr("uic error"), self.tr( """<p>The project specific Python interpreter <b>{0}</b>""" """ could not be started or did not finish within 30""" """ seconds.</p>""" ).format(interpreter), ) return uicText, ok def __objectName(self): """ Private method to get the object name of a form. @return object name @rtype str """ objectName = "" output, ok = self.__runUicLoadUi("object_name") if ok and output: objectName = output return objectName def __className(self): """ Private method to get the class name of a form. @return class name @rtype str """ className = "" output, ok = self.__runUicLoadUi("class_name") if ok and output: className = output return className def __signatures(self): """ Private slot to get the signatures. @return list of signatures (list of strings) """ if self.__module is None: return [] signatures = [] clsName = self.classNameCombo.currentText() if clsName: cls = self.__module.classes[clsName] for meth in list(cls.methods.values()): if meth.name.startswith("on_"): if meth.pyqtSignature is not None: sig = ", ".join( [ bytes(QMetaObject.normalizedType(t)).decode() for t in meth.pyqtSignature.split(",") ] ) signatures.append("{0}({1})".format(meth.name, sig)) else: signatures.append(meth.name) return signatures def __mapType(self, type_): """ Private method to map a type as reported by Qt's meta object to the correct Python type. @param type_ type as reported by Qt (QByteArray) @return mapped Python type (string) """ mapped = bytes(type_).decode() # I. always check for * mapped = mapped.replace("*", "") # 1. check for const mapped = mapped.replace("const ", "") # 2. replace QString and QStringList mapped = mapped.replace("QStringList", "list").replace("QString", "str") # 3. replace double by float mapped = mapped.replace("double", "float") return mapped def __updateSlotsModel(self): """ Private slot to update the slots tree display. """ self.filterEdit.clear() output, ok = self.__runUicLoadUi("signatures") if ok and output: try: objectsList = json.loads(output.strip()) signatureList = self.__signatures() self.slotsModel.clear() self.slotsModel.setHorizontalHeaderLabels([""]) for objectDict in objectsList: itm = QStandardItem( "{0} ({1})".format(objectDict["name"], objectDict["class_name"]) ) self.slotsModel.appendRow(itm) for methodDict in objectDict["methods"]: itm2 = QStandardItem(methodDict["signature"]) itm.appendRow(itm2) if self.__module is not None and ( methodDict["methods"][0] in signatureList or methodDict["methods"][1] in signatureList ): itm2.setFlags(Qt.ItemFlag.ItemIsEnabled) itm2.setCheckState(Qt.CheckState.Checked) if ericApp().usesDarkPalette(): itm2.setForeground(QBrush(QColor("#75bfff"))) else: itm2.setForeground(QBrush(Qt.GlobalColor.blue)) continue itm2.setData(methodDict["pyqt_signature"], pyqtSignatureRole) itm2.setData( methodDict["python_signature"], pythonSignatureRole ) itm2.setData(methodDict["return_type"], returnTypeRole) itm2.setData( methodDict["parameter_types"], parameterTypesListRole ) itm2.setData( methodDict["parameter_names"], parameterNamesListRole ) itm2.setFlags( Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable ) itm2.setCheckState(Qt.CheckState.Unchecked) self.slotsView.sortByColumn(0, Qt.SortOrder.AscendingOrder) except json.JSONDecodeError as err: EricMessageBox.critical( self, self.tr("Update Slots List"), self.tr( "<p>The update of the slots list failed because invalid data" " was received.</p><p>Error: {0}</p><p>Data: {1}</p>" ).format(str(err), output), ) def __generateCode(self): """ Private slot to generate Python code as requested by the user. """ if self.project.getProjectLanguage() != "Python3": EricMessageBox.critical( self, self.tr("Code Generation"), self.tr( """<p>Code generation for project language""" """ "{0}" is not supported.</p>""" ).format(self.project.getProjectLanguage()), ) return # init some variables sourceImpl = [] appendAtIndex = -1 indentStr = " " slotsCode = [] if self.__module is None: # new file try: if self.project.getProjectType() == "PySide2": tmplName = os.path.join( getConfig("ericCodeTemplatesDir"), "impl_pyside2.py.tmpl" ) elif self.project.getProjectType() == "PySide6": tmplName = os.path.join( getConfig("ericCodeTemplatesDir"), "impl_pyside6.py.tmpl" ) elif self.project.getProjectType() == "PyQt5": tmplName = os.path.join( getConfig("ericCodeTemplatesDir"), "impl_pyqt5.py.tmpl" ) elif self.project.getProjectType() in ["PyQt6", "E7Plugin"]: tmplName = os.path.join( getConfig("ericCodeTemplatesDir"), "impl_pyqt6.py.tmpl" ) else: EricMessageBox.critical( self, self.tr("Code Generation"), self.tr( """<p>No code template file available for""" """ project type "{0}".</p>""" ).format(self.project.getProjectType()), ) return with open(tmplName, "r", encoding="utf-8") as tmplFile: template = tmplFile.read() except OSError as why: EricMessageBox.critical( self, self.tr("Code Generation"), self.tr( """<p>Could not open the code template file""" """ "{0}".</p><p>Reason: {1}</p>""" ).format(tmplName, str(why)), ) return objName = self.__objectName() if objName: template = ( template.replace( "$FORMFILE$", os.path.splitext(os.path.basename(self.formFile))[0], ) .replace("$FORMCLASS$", objName) .replace("$CLASSNAME$", self.classNameCombo.currentText()) .replace("$SUPERCLASS$", self.__className()) ) sourceImpl = template.splitlines(True) appendAtIndex = -1 # determine indent string for line in sourceImpl: if line.lstrip().startswith("def __init__"): indentStr = line.replace(line.lstrip(), "") break else: # extend existing file try: with open(self.srcFile, "r", encoding="utf-8") as srcFile: sourceImpl = srcFile.readlines() if not sourceImpl[-1].endswith("\n"): sourceImpl[-1] = "{0}{1}".format(sourceImpl[-1], "\n") except OSError as why: EricMessageBox.critical( self, self.tr("Code Generation"), self.tr( """<p>Could not open the source file "{0}".</p>""" """<p>Reason: {1}</p>""" ).format(self.srcFile, str(why)), ) return cls = self.__module.classes[self.classNameCombo.currentText()] if cls.endlineno == len(sourceImpl) or cls.endlineno == -1: appendAtIndex = -1 # delete empty lines at end while not sourceImpl[-1].strip(): del sourceImpl[-1] else: appendAtIndex = cls.endlineno - 1 while not sourceImpl[appendAtIndex].strip(): appendAtIndex -= 1 appendAtIndex += 1 # determine indent string for line in sourceImpl[cls.lineno : cls.endlineno + 1]: if line.lstrip().startswith("def __init__"): indentStr = line.replace(line.lstrip(), "") break # do the coding stuff pyqtSignatureFormat = ( "@Slot({0})" if self.project.getProjectType() in ("PySide2", "PySide6") else "@pyqtSlot({0})" ) for row in range(self.slotsModel.rowCount()): topItem = self.slotsModel.item(row) for childRow in range(topItem.rowCount()): child = topItem.child(childRow) if child.checkState() == Qt.CheckState.Checked and ( child.flags() & Qt.ItemFlag.ItemIsUserCheckable == Qt.ItemFlag.ItemIsUserCheckable ): slotsCode.append("\n") slotsCode.append( "{0}{1}\n".format( indentStr, pyqtSignatureFormat.format(child.data(pyqtSignatureRole)), ) ) slotsCode.append( "{0}def {1}:\n".format( indentStr, child.data(pythonSignatureRole) ) ) indentStr2 = indentStr * 2 slotsCode.append('{0}"""\n'.format(indentStr2)) slotsCode.append( "{0}Slot documentation goes here.\n".format(indentStr2) ) if child.data(returnTypeRole) or child.data(parameterTypesListRole): slotsCode.append("\n") if child.data(parameterTypesListRole): for name, type_ in zip( child.data(parameterNamesListRole), child.data(parameterTypesListRole), ): slotsCode.append( "{0}@param {1} DESCRIPTION\n".format( indentStr2, name ) ) slotsCode.append( "{0}@type {1}\n".format(indentStr2, type_) ) if child.data(returnTypeRole): slotsCode.append( "{0}@returns DESCRIPTION\n".format(indentStr2) ) slotsCode.append( "{0}@rtype {1}\n".format( indentStr2, child.data(returnTypeRole) ) ) slotsCode.append('{0}"""\n'.format(indentStr2)) slotsCode.append( "{0}# {1}: not implemented yet\n".format(indentStr2, "TODO") ) slotsCode.append( "{0}raise NotImplementedError\n".format(indentStr2) ) if appendAtIndex == -1: sourceImpl.extend(slotsCode) else: sourceImpl[appendAtIndex:appendAtIndex] = slotsCode # write the new code newline = None if self.project.useSystemEol() else self.project.getEolString() fn = self.filenameEdit.text() try: with open(fn, "w", encoding="utf-8", newline=newline) as srcFile: srcFile.write("".join(sourceImpl)) except OSError as why: EricMessageBox.critical( self, self.tr("Code Generation"), self.tr( """<p>Could not write the source file "{0}".</p>""" """<p>Reason: {1}</p>""" ).format(fn, str(why)), ) return self.project.appendFile(fn) @pyqtSlot(int) def on_classNameCombo_activated(self, index): """ Private slot to handle the activated signal of the classname combo. @param index index of the activated item (integer) """ if self.classNameCombo.currentText() == CreateDialogCodeDialog.Separator: self.okButton.setEnabled(False) self.filterEdit.clear() self.slotsModel.clear() self.slotsModel.setHorizontalHeaderLabels([""]) else: self.okButton.setEnabled(True) self.__updateSlotsModel() def on_filterEdit_textChanged(self, text): """ Private slot called, when thext of the filter edit has changed. @param text changed text (string) """ rx = QRegularExpression( text, QRegularExpression.PatternOption.CaseInsensitiveOption ) self.proxyModel.setFilterRegularExpression(rx) @pyqtSlot() def on_newButton_clicked(self): """ Private slot called to enter the data for a new dialog class. """ path, file = os.path.split(self.srcFile) objName = self.__objectName() if objName: dlg = NewDialogClassDialog(objName, file, path, self) if dlg.exec() == QDialog.DialogCode.Accepted: className, fileName = dlg.getData() self.classNameCombo.clear() self.classNameCombo.addItem(className) self.srcFile = fileName self.filenameEdit.setText(self.srcFile) self.__module = None self.okButton.setEnabled(self.classNameCombo.count() > 0) def on_buttonBox_clicked(self, button): """ Private slot to handle the buttonBox clicked signal. @param button reference to the button that was clicked (QAbstractButton) """ if button == self.okButton: self.__generateCode() self.accept()