diff -r 1b1bf094c013 -r 191e9ec72893 PyrightChecker/PyrightCheckerDialog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/PyrightChecker/PyrightCheckerDialog.py Tue Nov 07 15:17:48 2023 +0100 @@ -0,0 +1,670 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the pyright type checker dialog. +""" + +import contextlib +import copy +import json +import os + +import tomlkit + +from PyQt6.QtCore import QProcess, Qt, pyqtSlot +from PyQt6.QtGui import QGuiApplication +from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QTreeWidgetItem + +from eric7 import Preferences +from eric7.EricWidgets import EricMessageBox +from eric7.EricWidgets.EricApplication import ericApp +from eric7.QScintilla.Editor import Editor +from eric7.SystemUtilities import PythonUtilities + +from .Ui_PyrightCheckerDialog import Ui_PyrightCheckerDialog + + +class PyrightCheckerDialog(QDialog, Ui_PyrightCheckerDialog): + """ + Class documentation goes here. + """ + + filenameRole = Qt.ItemDataRole.UserRole + 1 + severityRole = Qt.ItemDataRole.UserRole + 2 + startRole = Qt.ItemDataRole.UserRole + 3 + endRole = Qt.ItemDataRole.UserRole + 4 + + def __init__(self, plugin, parent=None): + """ + Constructor + + @param plugin reference to the plugin object + @type PyrightPlugin + @param parent reference to the parent widget (defaults to None) + @type QWidget (optional) + """ + super().__init__(parent) + self.setupUi(self) + self.setWindowFlags(Qt.WindowType.Window) + + self.__plugin = plugin + + self.__severityMapping = { + "error": self.tr("Error"), + "warning": self.tr("Warning"), + "information": self.tr("Information"), + } + + self.__severityForEditor = {"warning": Editor.WarningCode} + try: + self.__severityForEditor["error"] = Editor.WarningError + except AttributeError: + self.__severityForEditor["error"] = Editor.WarningCode + try: + self.__severityForEditor["information"] = Editor.WarningInfo + except AttributeError: + self.__severityForEditor["information"] = Editor.WarningCode + + self.__exitCodeMapping = { + 0: self.tr("No issues detected"), + 1: self.tr("Issues detected"), + 2: self.tr("Fatal error occurred with no errors or warnings reported"), + 3: self.tr("Config file could not be read or parsed"), + 4: self.tr("Illegal command-line parameters specified"), + } + + self.platformComboBox.addItem("", "") + self.platformComboBox.addItem("Linux", "Linux") + self.platformComboBox.addItem("macOS", "Darwin") + self.platformComboBox.addItem("Windows", "Windows") + + self.versionComboBox.addItems(["", "3.8", "3.9", "3.10", "3.11", "3.12"]) + + self.__dirOrFileList = [] + self.__project = None + self.__forProject = False + self.__process = None + self.__hasResults = False + + self.showButton.setEnabled(False) + self.tomlButton.setEnabled(False) + self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False) + + self.resultList.headerItem().setText(self.resultList.columnCount(), "") + self.resultList.header().setSortIndicator(0, Qt.SortOrder.AscendingOrder) + + self.__colors = {} + + self.on_loadDefaultButton_clicked() + + self.mainWidget.setCurrentWidget(self.configureTab) + + def __resort(self): + """ + Private method to resort the tree. + """ + self.resultList.sortItems( + self.resultList.sortColumn(), self.resultList.header().sortIndicatorOrder() + ) + + def __createResultItem(self, result): + """ + Private method to create an entry in the result list. + + @param result dictionary containing check result data + @type dict + """ + # step 1: search the file entry or create it + filePath = ( + self.__project.getRelativePath(result["file"]) + if self.__forProject + else result["file"] + ) + fileItems = self.resultList.findItems(filePath, Qt.MatchFlag.MatchExactly, 0) + if fileItems: + fileItem = fileItems[0] + else: + fileItem = QTreeWidgetItem(self.resultList, [filePath]) + fileItem.setFirstColumnSpanned(True) + fileItem.setExpanded(True) + fileItem.setData(0, self.filenameRole, result["file"]) + + itm = QTreeWidgetItem( + fileItem, + [ + "{0:6}".format(result["range"]["start"]["line"] + 1), + self.__severityMapping[result["severity"]], + result["message"], + ], + ) + + itm.setTextAlignment( + 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + itm.setTextAlignment( + 1, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter + ) + itm.setTextAlignment(2, Qt.AlignmentFlag.AlignVCenter) + + itm.setData(0, self.filenameRole, result["file"]) + itm.setData(0, self.severityRole, result["severity"]) + itm.setData(0, self.startRole, result["range"]["start"]) + itm.setData(0, self.endRole, result["range"]["end"]) + + with contextlib.suppress(KeyError): + itm.setData( + 1, Qt.ItemDataRole.ForegroundRole, self.__colors[result["severity"]][0] + ) + itm.setData( + 1, Qt.ItemDataRole.BackgroundRole, self.__colors[result["severity"]][1] + ) + + def __updateSummary(self, summary): + """ + Private method to update the summary data of the dialog. + + @param summary dictionary containing the summary data + @type dict + """ + self.filesLabel.setText(str(summary["filesAnalyzed"])) + self.errorLabel.setText(str(summary["errorCount"])) + self.warningLabel.setText(str(summary["warningCount"])) + self.infoLabel.setText(str(summary["informationCount"])) + + def __processResult(self, result): + """ + Private method to process the pyright result. + + @param result dictionary containing the type checking result. + @type dict + """ + # 1. update the severity color mapping + self.__colors = { + # tuple of foreground and background colors + "error": ( + Preferences.getEditorColour("AnnotationsErrorForeground"), + Preferences.getEditorColour("AnnotationsErrorBackground"), + ), + "warning": ( + Preferences.getEditorColour("AnnotationsWarningForeground"), + Preferences.getEditorColour("AnnotationsWarningBackground"), + ), + } + with contextlib.suppress(KeyError): + # eric-ide before 23.12 doesn't have this color + self.__colors["information"] = ( + Preferences.getEditorColour("AnnotationsInfoForeground"), + Preferences.getEditorColour("AnnotationsInfoBackground"), + ) + + # 2. set pyright version + try: + self.pyrightLabel.setText(result["version"]) + except KeyError: + self.pyrightLabel.setText(self.tr("unknown")) + + # 3. create result items + if result["exitCode"] == 1: + self.__hasResults = True + for diagnostic in result["generalDiagnostics"]: + self.__createResultItem(diagnostic) + else: + itm = QTreeWidgetItem( + self.resultList, self.__exitCodeMapping[result["exitCode"]] + ) + itm.setFirstColumnSpanned(True) + + for col in range(self.resultList.columnCount()): + self.resultList.resizeColumnToContents(col) + self.resultList.header().setStretchLastSection(True) + self.__resort() + self.resultList.setSortingEnabled(True) + + # 4. set summary information + self.__updateSummary(result["summary"]) + + self.showButton.setEnabled(self.__hasResults) + self.mainWidget.setCurrentWidget(self.resultsTab) + + def getDefaults(self): + """ + Public method to get a dictionary containing the default values. + + @return dictionary containing the default values + @rtype dict + """ + defaults = { + "PythonPlatform": "", + "PythonVersion": "", + "SkipUnannotated": False, + } + + return defaults + + def prepare(self, project): + """ + Public method to prepare the dialog with a list of filenames. + + @param project reference to the project object + @type Project + """ + self.__project = project + self.__forProject = True + self.__dirOrFileList = [self.__project.getProjectPath()] + + self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True) + self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True) + + defaultParameters = self.getDefaults() + self.__data = self.__project.getData("CHECKERSPARMS", "PyrightChecker") + if self.__data is None: + # initialize the data structure + self.__data = copy.deepcopy(defaultParameters) + else: + for key in defaultParameters: + if key not in self.__data: + self.__data[key] = defaultParameters[key] + + self.platformComboBox.setCurrentIndex( + self.platformComboBox.findData(self.__data["PythonPlatform"]) + ) + self.versionComboBox.setCurrentText(self.__data["PythonVersion"]) + self.skipUnannotatedCheckBox.setChecked(self.__data["SkipUnannotated"]) + + self.tomlButton.setEnabled(True) + + self.mainWidget.setCurrentWidget(self.configureTab) + + def start(self, files=None, save=False): + """ + Public method to start a pyright type checking run. + + @param files list of files to be checked (defaults to None) + @type list of str (optional) + @param save flag indicating to save the given file/file list/directory + @type bool + """ + self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False) + self.startButton.setEnabled(False) + self.restartButton.setEnabled(False) + self.showButton.setEnabled(False) + + if not files and not self.__forProject: + EricMessageBox.critical( + self, + self.tr("pyright Type Checking"), + self.tr( + """pyright type checking has to be performed for individual""" + """ files or a project but neither was given. Aborting...""" + ), + ) + return + + if files and save: + self.__dirOrFileList = files + + interpreter = PythonUtilities.getPythonExecutable() + args = ["-m", "pyright", "--outputjson"] + + pythonPlatform = self.platformComboBox.currentData() + if pythonPlatform: + args.extend(["--pythonplatform", pythonPlatform]) + pythonVersion = self.versionComboBox.currentText() + if pythonVersion: + args.extend(["--pythonversion", pythonVersion]) + if self.skipUnannotatedCheckBox.isChecked(): + args.append("--skipunannotated") + if self.__forProject: + args.extend(["--project", self.__project.getProjectPath()]) + args.extend(files) + + self.__process = QProcess(self) + self.__process.readyReadStandardError.connect(self.__readError) + self.__process.finished.connect(self.__pyrightProcessFinished) + self.__process.start(interpreter, args) + + @pyqtSlot() + def __readError(self): + """ + Private slot to get the output of the error channel and show it to the user. + """ + errorMsg = str(self.__process.readAllStandardError(), encoding="utf-8") + EricMessageBox.critical( + self, + self.tr("pyright Type Checking"), + self.tr( + "<p>The pyright type checking run failed.</p><p>Reason: {0}</p>" + ).format(errorMsg), + ) + + @pyqtSlot(int, QProcess.ExitStatus) + def __pyrightProcessFinished(self, exitCode, exitStatus): + """ + Private slot to process the pyright result. + + @param exitCode exit code of the pyright process + @type int + @param exitStatus exit status + @type QProcess.ExitStatus + """ + self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True) + self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True) + self.showButton.setEnabled(True) + self.startButton.setEnabled(True) + self.restartButton.setEnabled(True) + + if exitStatus != QProcess.ExitStatus.NormalExit: + EricMessageBox.critical( + self, + self.tr("pyright Type Checking"), + self.tr( + "<p>The pyright type checking process did not end normally.</p>" + ), + ) + return + + output = str(self.__process.readAllStandardOutput(), encoding="utf-8") + try: + resultDict = json.loads(output) + except json.JSONDecodeError as err: + EricMessageBox.critical( + self, + self.tr("pyright Type Checking"), + self.tr( + "<p>The pyright type checking process did not return valid" + " JSON data.</p><p>Issue: {0}</p>" + ).format(str(err)), + ) + return + + resultDict["exitCode"] = exitCode + self.__processResult(resultDict) + + @pyqtSlot(QTreeWidgetItem, int) + def on_resultList_itemActivated(self, item, column): + """ + Private slot to handle the activation of an item. + + @param item reference to the activated item + @type QTreeWidgetItem + @param column column the item was activated in + @type int + """ + if self.__hasResults and item.parent(): + fn = os.path.abspath(item.data(0, self.filenameRole)) + start = item.data(0, self.startRole) + severity = item.data(0, self.severityRole) + + vm = ericApp().getObject("ViewManager") + vm.openSourceFile(fn, lineno=start["line"] + 1, pos=start["character"] + 1) + editor = vm.getOpenEditor(fn) + + editor.toggleWarning( + start["line"] + 1, + start["character"] + 1, + True, + item.text(2), + warningType=self.__severityForEditor[severity], + ) + + editor.updateVerticalScrollBar() + + @pyqtSlot() + def on_restartButton_clicked(self): + """ + Private slot to restart the configured check. + """ + self.on_startButton_clicked() + + @pyqtSlot() + def on_showButton_clicked(self): + """ + Private slot to handle the "Show" button press. + """ + vm = ericApp().getObject("ViewManager") + + selectedIndexes = [] + for index in range(self.resultList.topLevelItemCount()): + if self.resultList.topLevelItem(index).isSelected(): + selectedIndexes.append(index) + if len(selectedIndexes) == 0: + selectedIndexes = list(range(self.resultList.topLevelItemCount())) + + for index in selectedIndexes: + itm = self.resultList.topLevelItem(index) + fn = os.path.abspath(itm.data(0, self.filenameRole)) + vm.openSourceFile(fn, 1) + editor = vm.getOpenEditor(fn) + self.__clearEditorErrors(editor) + for cindex in range(itm.childCount()): + citm = itm.child(cindex) + start = citm.data(0, self.startRole) + severity = citm.data(0, self.severityRole) + editor.toggleWarning( + start["line"] + 1, + start["character"] + 1, + True, + citm.text(2), + warningType=self.__severityForEditor[severity], + ) + + @pyqtSlot() + def on_startButton_clicked(self): + """ + Private slot to start the pyright type checking run. + """ + if self.__forProject: + data = { + "PythonPlatform": self.platformComboBox.currentData(), + "PythonVersion": self.versionComboBox.currentText(), + "SkipUnannotated": self.skipUnannotatedCheckBox.isChecked(), + } + if json.dumps(data, sort_keys=True) != json.dumps( + self.__data, sort_keys=True + ): + self.__data = data + self.__project.setData("CHECKERSPARMS", "PyrightChecker", self.__data) + + self.__clearErrors() + self.__clear() + self.start(self.__dirOrFileList) + + def __clear(self): + """ + Private method to clear the dialog. + """ + self.resultList.clear() + self.__hasResults = False + + def __clearErrors(self, files=None): + """ + Private method to clear all warning markers of open editors to be + checked. + + @param files list of files to be checked (defaults to None + @type list of str (optional + """ + vm = ericApp().getObject("ViewManager") + openFiles = vm.getOpenFilenames() + if files is not None: + # filter out the files checked + openFiles = [f for f in openFiles if f in files] + for file in openFiles: + editor = vm.getOpenEditor(file) + try: + editor.clearInfoWarnings() + editor.clearErrorWarnings() + editor.clearCodeWarnings() + except AttributeError: + # eric before 23.12 + editor.clearFlakesWarnings() + + def __clearEditorErrors(self, editor): + """ + Private method to clear all warning markers of an editor. + + @param editor reference to the editor to be cleared + @type Editor + """ + try: + editor.clearInfoWarnings() + editor.clearErrorWarnings() + editor.clearCodeWarnings() + except AttributeError: + # eric before 23.12 + editor.clearFlakesWarnings() + + ############################################################################ + ## Methods for storing, loading and resetting the default values. ## + ############################################################################ + + @pyqtSlot() + def on_loadDefaultButton_clicked(self): + """ + Private slot to load the default configuration values. + """ + defaultParameters = self.getDefaults() + settings = Preferences.getSettings() + + self.platformComboBox.setCurrentIndex( + self.platformComboBox.findData( + settings.value( + self.__plugin.PreferencesKey + "/PythonPlatform", + defaultParameters["PythonPlatform"], + ) + ) + ) + self.versionComboBox.setCurrentText( + settings.value( + self.__plugin.PreferencesKey + "/PythonVersion", + defaultParameters["PythonVersion"], + ) + ) + self.skipUnannotatedCheckBox.setChecked( + Preferences.toBool( + settings.value( + self.__plugin.PreferencesKey + "/SkipUnannotated", + defaultParameters["SkipUnannotated"], + ) + ) + ) + + @pyqtSlot() + def on_storeDefaultButton_clicked(self): + """ + Private slot to store the current configuration values as + default values. + """ + settings = Preferences.getSettings() + + settings.setValue( + self.__plugin.PreferencesKey + "/PythonPlatform", + self.platformComboBox.currentData(), + ) + settings.setValue( + self.__plugin.PreferencesKey + "/PythonVersion", + self.versionComboBox.currentText(), + ) + settings.setValue( + self.__plugin.PreferencesKey + "/SkipUnannotated", + self.skipUnannotatedCheckBox.isChecked(), + ) + + @pyqtSlot() + def on_resetDefaultButton_clicked(self): + """ + Private slot to reset the configuration values to their default values. + """ + defaultParameters = self.getDefaults() + settings = Preferences.getSettings() + + settings.setValue( + self.__plugin.PreferencesKey + "/PythonPlatform", + defaultParameters["PythonPlatform"], + ) + settings.setValue( + self.__plugin.PreferencesKey + "/PythonVersion", + defaultParameters["PythonVersion"], + ) + settings.setValue( + self.__plugin.PreferencesKey + "/SkipUnannotated", + defaultParameters["SkipUnannotated"], + ) + + # Update UI with default values + self.on_loadDefaultButton_clicked() + + @pyqtSlot() + def on_tomlButton_clicked(self): + """ + Private slot to generate a TOML snippet of the current configuration. + + Note: Only non-default values are included in this snippet. + + The code snippet is copied to the clipboard and may be placed inside the + 'pyproject.toml' file. + """ + if not self.__forProject or self.__project is None: + EricMessageBox.critical( + self, + self.tr("Create TOML snippet"), + self.tr( + "The creation of a 'pyproject.toml' snippet is only available" + " when in project mode. Aborting..." + ), + ) + return + + configDict = self.__getConfigurationDict() + + pyrightTable = tomlkit.table() + for key, value in configDict.items(): + pyrightTable[key] = value + + doc = tomlkit.document() + doc["tool"] = tomlkit.table(is_super_table=True) + doc["tool"]["pyright"] = pyrightTable + + QGuiApplication.clipboard().setText(tomlkit.dumps(doc)) + + EricMessageBox.information( + self, + self.tr("Create TOML snippet"), + self.tr("""The 'pyproject.toml' snippet was copied to the clipboard."""), + ) + + def __getConfigurationDict(self): + """ + Private method to assemble and return a dictionary containing the entered + non-default configuration parameters. + + The configuration dictionary is amended with some common parameters not + accessible via the configuration tab. + + @return dictionary containing the non-default configuration parameters + @rtype dict + """ + configDict = {} + + srcDir = self.__project.getProjectData("SOURCESDIR") + configDict["include"] = [srcDir] if srcDir else [] + + configDict["exclude"] = [ + "*/node_modules", + "**/__pycache__", + "**/Ui_*.py", + "**/.*", + ] + + pythonVersion = self.versionComboBox.currentText() + if pythonVersion: + configDict["pythonVersion"] = pythonVersion + + pythonPlatform = self.platformComboBox.currentData() + if pythonPlatform: + configDict["pythonPlatform"] = pythonPlatform + + return configDict