Tue, 07 Nov 2023 15:17:48 +0100
Implemented the first iteration of the pyright typing checker interface.
# -*- 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