PyrightChecker/PyrightCheckerDialog.py

Thu, 04 Jan 2024 11:42:31 +0100

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Thu, 04 Jan 2024 11:42:31 +0100
branch
eric7
changeset 13
3a1f3fcfaf31
parent 11
55bc88e0aea0
child 15
e01d64ca960f
permissions
-rw-r--r--

Adjusted some code for eric7 24.2 and newer.

# -*- coding: utf-8 -*-

# Copyright (c) 2023 - 2024 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.SystemUtilities import PythonUtilities

from .Ui_PyrightCheckerDialog import Ui_PyrightCheckerDialog

try:
    from eric7.QScintilla.Editor import EditorWarningKind

    SeverityForEditor = {
        "error": EditorWarningKind.Error,
        "information": EditorWarningKind.Info,
        "warning": EditorWarningKind.Code,
    }
except ImportError:
    # backward compatibility for eric < 24.2
    from eric7.QScintilla.Editor import Editor

    SeverityForEditor = {"warning": Editor.WarningCode}
    try:
        SeverityForEditor["error"] = Editor.WarningError
    except AttributeError:
        SeverityForEditor["error"] = Editor.WarningCode
    try:
        SeverityForEditor["information"] = Editor.WarningInfo
    except AttributeError:
        SeverityForEditor["information"] = Editor.WarningCode

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=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=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