PyrightChecker/PyrightCheckerDialog.py

changeset 1
191e9ec72893
child 11
55bc88e0aea0
--- /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

eric ide

mercurial