VultureChecker/VultureCheckerDialog.py

Sat, 23 Dec 2023 15:48:46 +0100

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Sat, 23 Dec 2023 15:48:46 +0100
branch
eric7
changeset 106
d3ef69537ed1
parent 104
cefe2b00fede
child 108
b6622920a278
permissions
-rw-r--r--

Updated copyright for 2024.

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

# Copyright (c) 2015 - 2024 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing a dialog to show the vulture check results.
"""

import contextlib
import fnmatch
import os

from PyQt6.QtCore import Qt, QTimer, pyqtSlot
from PyQt6.QtWidgets import (
    QAbstractButton,
    QApplication,
    QDialog,
    QDialogButtonBox,
    QHeaderView,
    QMenu,
    QTreeWidgetItem,
)

from eric7 import Preferences, Utilities
from eric7.EricWidgets.EricApplication import ericApp

try:
    from eric7.SystemUtilities.FileSystemUtilities import direntries
except ImportError:
    # imports for eric < 23.1
    from eric7.Utilities import direntries

from .Ui_VultureCheckerDialog import Ui_VultureCheckerDialog


class VultureItem:
    """
    Class to hold the name, type, confidence and location of defined code.
    """

    def __init__(self, name, typ, filename, firstLineno, lastLineno, confidence):
        """
        Constructor

        @param name item name
        @type str
        @param typ item type
        @type str
        @param filename name of the file containing item
        @type str
        @param firstLineno first line number
        @type int
        @param lastLineno last line number
        @type int
        @param confidence confidence level
        @type int
        """
        self.name = name
        self.typ = typ
        self.filename = filename
        self.first_lineno = firstLineno
        self.last_lineno = lastLineno
        self.confidence = confidence


class VultureCheckerDialog(QDialog, Ui_VultureCheckerDialog):
    """
    Class implementing a dialog to show the vulture check results.
    """

    FilePathRole = Qt.ItemDataRole.UserRole + 1
    TypeRole = Qt.ItemDataRole.UserRole + 2

    def __init__(self, vultureService, parent=None):
        """
        Constructor

        @param vultureService reference to the service
        @type VulturePlugin
        @param parent reference to the parent widget
        @type QWidget
        """
        super().__init__(parent)
        self.setupUi(self)
        self.setWindowFlags(Qt.WindowType.Window)

        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setDefault(True)

        self.resultList.headerItem().setText(self.resultList.columnCount(), "")

        self.__menu = QMenu(self)
        self.__whiteListAct = self.__menu.addAction(
            self.tr("Add to Whitelist"), self.__whiteList
        )
        self.__menu.addSeparator()
        self.__menu.addAction(self.tr("Edit Whitelist"), self.__editWhiteList)
        self.__menu.addSeparator()
        self.__collapseAct = self.__menu.addAction(
            self.tr("Collapse all"), self.__resultCollapse
        )
        self.__expandAct = self.__menu.addAction(
            self.tr("Expand all"), self.__resultExpand
        )
        self.resultList.customContextMenuRequested.connect(self.__showContextMenu)

        self.vultureService = vultureService
        self.vultureService.analysisDone.connect(self.__processResult)
        self.vultureService.error.connect(self.__processError)
        self.vultureService.batchFinished.connect(self.__batchFinished)

        self.cancelled = False
        self.__batch = False

        self.__project = ericApp().getObject("Project")
        self.__finished = True
        self.__errorItem = None
        self.__data = None
        self.__slotsAreUsed = True

        self.__fileList = []
        self.filterFrame.setVisible(False)

        self.__translatedTypes = {
            "property": self.tr("Property"),
            "function": self.tr("Function"),
            "method": self.tr("Method"),
            "attribute": self.tr("Attribute"),
            "variable": self.tr("Variable"),
            "class": self.tr("Class"),
            "import": self.tr("Import"),
        }

    def __createErrorItem(self, filename, message):
        """
        Private slot to create a new error item in the result list.

        @param filename name of the file
        @type str
        @param message error message
        @type str
        """
        if self.__errorItem is None:
            self.__errorItem = QTreeWidgetItem(self.resultList, [self.tr("Errors")])
            self.__errorItem.setExpanded(True)
            self.__errorItem.setForeground(0, Qt.GlobalColor.red)

        msg = "{0} ({1})".format(self.__project.getRelativePath(filename), message)
        if not self.resultList.findItems(msg, Qt.MatchFlag.MatchExactly):
            itm = QTreeWidgetItem(self.__errorItem, [msg])
            itm.setForeground(0, Qt.GlobalColor.red)
            itm.setFirstColumnSpanned(True)

    def prepare(self, fileList, project):
        """
        Public method to prepare the dialog with a list of filenames.

        @param fileList list of filenames
        @type list of str
        @param project reference to the project object
        @type Project
        """
        self.__fileList = fileList[:]
        self.__project = project

        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True)

        self.filterFrame.setVisible(True)

        self.__data = self.__project.getData("CHECKERSPARMS", "Vulture")
        if self.__data is None:
            self.__data = {}
        if "ExcludeFiles" not in self.__data:
            self.__data["ExcludeFiles"] = ""
        if "WhiteLists" not in self.__data:
            self.__data["WhiteLists"] = {
                "property": [],
                "function": [],
                "attribute": [],
                "variable": [],
                "class": [],
                "import": [],
                "__patterns__": [
                    "on_*",
                    "visit_*",
                ],
            }
        if "method" not in self.__data["WhiteLists"]:
            self.__data["WhiteLists"]["method"] = []
        if "import" not in self.__data["WhiteLists"]:
            self.__data["WhiteLists"]["import"] = []

        self.excludeFilesEdit.setText(self.__data["ExcludeFiles"])

    def start(self, fn):
        """
        Public slot to start the code metrics determination.

        @param fn file or list of files or directory to show
            the code metrics for
        @type str or list of str
        """
        self.cancelled = False
        self.__errorItem = None
        self.resultList.clear()

        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(True)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setDefault(True)
        QApplication.processEvents()

        self.__prepareResultLists()

        if isinstance(fn, list):
            self.files = fn
        elif os.path.isdir(fn):
            self.files = []
            extensions = set(Preferences.getPython("Python3Extensions"))
            for ext in extensions:
                self.files.extend(direntries(fn, True, "*{0}".format(ext), 0))
        else:
            self.files = [fn]
        self.files.sort()
        # check for missing files
        for f in self.files[:]:
            if not os.path.exists(f):
                self.files.remove(f)

        if len(self.files) > 0:
            # disable updates of the list for speed
            self.resultList.setUpdatesEnabled(False)
            self.resultList.setSortingEnabled(False)

            self.checkProgress.setMaximum(len(self.files))
            self.checkProgress.setVisible(len(self.files) > 1)
            QApplication.processEvents()

            # now go through all the files
            self.progress = 0
            if len(self.files) == 1:
                self.__batch = False
                self.vultureCheck()
            else:
                self.__batch = True
                self.vultureCheckBatch()

    def vultureCheck(self):
        """
        Public method to start a vulture check for one Python file.

        The results are reported to the __processResult slot.
        """
        if not self.files:
            self.checkProgress.setMaximum(1)
            self.checkProgress.setValue(1)
            self.__finish()
            return

        self.filename = self.files.pop(0)
        self.checkProgress.setValue(self.progress)
        QApplication.processEvents()

        if self.cancelled:
            return

        try:
            self.source = Utilities.readEncodedFile(self.filename)[0]
            self.source = Utilities.normalizeCode(self.source)
        except (OSError, UnicodeError) as msg:
            self.__createErrorItem(self.filename, str(msg).rstrip())
            self.progress += 1
            # Continue with next file
            self.vultureCheck()
            return

        self.__finished = False
        self.vultureService.vultureCheck(None, self.filename, self.source)

    def vultureCheckBatch(self):
        """
        Public method to start a vulture check batch job.

        The results are reported to the __processResult slot.
        """
        argumentsList = []
        for progress, filename in enumerate(self.files, start=1):
            self.checkProgress.setValue(progress)
            QApplication.processEvents()

            try:
                source = Utilities.readEncodedFile(filename)[0]
                source = Utilities.normalizeCode(source)
            except (OSError, UnicodeError) as msg:
                self.__createErrorItem(filename, str(msg).rstrip())
                continue

            argumentsList.append((filename, source))

        # reset the progress bar to the checked files
        self.checkProgress.setValue(self.progress)
        QApplication.processEvents()

        self.__finished = False
        self.vultureService.vultureCheckBatch(argumentsList)

    def __batchFinished(self):
        """
        Private slot handling the completion of a batch job.
        """
        self.checkProgress.setMaximum(1)
        self.checkProgress.setValue(1)
        self.__finish()

    def __processError(self, fn, msg):
        """
        Private slot to process an error indication from the service.

        @param fn filename of the file
        @type str
        @param msg error message
        @type str
        """
        self.__createErrorItem(fn, msg)

    def __processResult(self, fn, result):
        """
        Private slot called after performing a vulture analysis on one file.

        @param fn filename of the file
        @type str
        @param result result dict
        @type dict
        """
        if self.__finished:
            return

        # Check if it's the requested file, otherwise ignore signal if not
        # in batch mode
        if not self.__batch and fn != self.filename:
            return

        if "error" in result:
            self.__createErrorItem(fn, result["error"])
        else:
            self.__storeResult(result)

        self.progress += 1

        self.checkProgress.setValue(self.progress)
        QApplication.processEvents()

        if not self.__batch:
            self.vultureCheck()

    def __finish(self):
        """
        Private slot called when the action finished or the user pressed the
        cancel button.
        """
        if not self.__finished:
            self.__finished = True

            if not self.cancelled:
                self.__createResultItems()

            # reenable updates of the list
            self.resultList.setSortingEnabled(True)
            self.resultList.sortItems(0, Qt.SortOrder.AscendingOrder)
            self.resultList.setUpdatesEnabled(True)

            self.cancelled = True
            self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(
                True
            )
            self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(
                False
            )
            self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(
                True
            )

            self.resultList.header().resizeSections(
                QHeaderView.ResizeMode.ResizeToContents
            )
            self.resultList.header().setStretchLastSection(True)
            self.resultList.header().setSectionResizeMode(
                QHeaderView.ResizeMode.Interactive
            )

            self.checkProgress.setVisible(False)

            if self.resultList.topLevelItemCount() == 0:
                itm = QTreeWidgetItem(
                    self.resultList, [self.tr("No unused code found.")]
                )
                itm.setFirstColumnSpanned(True)

    def __cancel(self):
        """
        Private method to cancel the current check run.
        """
        self.cancelled = True
        if self.__batch:
            self.vultureService.cancelVultureCheckBatch()
            QTimer.singleShot(1000, self.__finish)
        else:
            self.__finish()

    def closeEvent(self, evt):
        """
        Protected method to handle a close event.

        @param evt reference to the close event
        @type QCloseEvent
        """
        self.__cancel()

    @pyqtSlot(QAbstractButton)
    def on_buttonBox_clicked(self, button):
        """
        Private slot called by a button of the button box clicked.

        @param button button that was clicked
        @type QAbstractButton
        """
        if button == self.buttonBox.button(QDialogButtonBox.StandardButton.Close):
            self.close()
        elif button == self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel):
            self.__cancel()

    @pyqtSlot()
    def on_startButton_clicked(self):
        """
        Private slot to start a code metrics run.
        """
        fileList = self.__fileList[:]

        filterString = self.excludeFilesEdit.text()
        if filterString != self.__data["ExcludeFiles"]:
            self.__data["ExcludeFiles"] = filterString
            self.__project.setData("CHECKERSPARMS", "Vulture", self.__data)
        filterList = [f.strip() for f in filterString.split(",") if f.strip()]
        if filterList:
            for fileFilter in filterList:
                fileList = [f for f in fileList if not fnmatch.fnmatch(f, fileFilter)]

        self.start(fileList)

    def clear(self):
        """
        Public method to clear all results.
        """
        self.resultList.clear()

    @pyqtSlot(QTreeWidgetItem, int)
    def on_resultList_itemActivated(self, item, column):
        """
        Private slot to handle the activation of a result item.

        @param item reference to the activated item
        @type QTreeWidgetItem
        @param column column the item was activated in
        @type int
        """
        if item.parent() is not None:
            filename = item.data(0, self.FilePathRole)
            try:
                lineno = int(item.text(0))
            except ValueError:
                lineno = 1
            if filename:
                vm = ericApp().getObject("ViewManager")
                vm.openSourceFile(filename, lineno)

    def __prepareResultLists(self):
        """
        Private method to prepare the result lists.
        """
        self.__unusedAttrs = []
        self.__unusedClasses = []
        self.__unusedFuncs = []
        self.__unusedMethods = []
        self.__unusedImports = []
        self.__unusedProps = []
        self.__unusedVars = []

    def __storeResult(self, result):
        """
        Private method to store the result of an analysis.

        @param result result dictionary
        @type dict
        """
        self.__unusedAttrs.extend(
            self.__filteredList(
                [self.__dict2Item(d) for d in result["UnusedAttributes"]]
            )
        )
        self.__unusedClasses.extend(
            self.__filteredList([self.__dict2Item(d) for d in result["UnusedClasses"]])
        )
        self.__unusedFuncs.extend(
            self.__filteredList(
                [self.__dict2Item(d) for d in result["UnusedFunctions"]]
            )
        )
        self.__unusedMethods.extend(
            self.__filteredList([self.__dict2Item(d) for d in result["UnusedMethods"]])
        )
        self.__unusedImports.extend(
            self.__filteredList([self.__dict2Item(d) for d in result["UnusedImports"]])
        )
        self.__unusedProps.extend(
            self.__filteredList(
                [self.__dict2Item(d) for d in result["UnusedProperties"]]
            )
        )
        self.__unusedVars.extend(
            self.__filteredList(
                [self.__dict2Item(d) for d in result["UnusedVariables"]]
            )
        )

    def __dict2Item(self, d):
        """
        Private method to convert an item dictionary to a vulture item.

        @param d item dictionary
        @type dict
        @return vulture item
        @rtype VultureItem
        """
        return VultureItem(
            d["name"],
            d["type"],
            d["file"],
            d["first_line"],
            d["last_line"],
            confidence=d["confidence"],
        )

    def __filteredList(self, itemList):
        """
        Private method to filter a list against the whitelist patterns
        returning items not matching the whitelist.

        @param itemList list of items to be filtered
        @type list of VultureItem
        @return list of filtered items
        @rtype list of VultureItem
        """
        filteredList = itemList
        for pattern in self.__data["WhiteLists"]["__patterns__"]:
            filteredList = [
                item
                for item in filteredList
                if not fnmatch.fnmatchcase(item.name, pattern)
            ]
        return filteredList  # __IGNORE_WARNING_M834__

    def __filterUnusedItems(self, unused, whitelistName):
        """
        Private method to get a list of unused items.

        @param unused list of unused items
        @type list of VultureItem
        @param whitelistName name of the whitelist to use as a filter
        @type str
        @return list of unused items
        @rtype list of VultureItem
        """
        return [
            item
            for item in set(unused)
            if item.name not in self.__data["WhiteLists"][whitelistName]
        ]

    def __filterUnusedFunctions(self):
        """
        Private method to get the list of unused functions.

        @return list of unused functions
        @rtype list of VultureItem
        """
        return self.__filterUnusedItems(self.__unusedFuncs, "function")

    def __filterUnusedMethods(self):
        """
        Private method to get the list of unused methods.

        @return list of unused methods
        @rtype list of VultureItem
        """
        return self.__filterUnusedItems(self.__unusedMethods, "method")

    def __filterUnusedClasses(self):
        """
        Private method to get the list of unused classes.

        @return list of unused classes
        @rtype list of VultureItem
        """
        return self.__filterUnusedItems(self.__unusedClasses, "class")

    def __filterUnusedImports(self):
        """
        Private method to get a list of unused imports.

        @return list of unused imports
        @rtype list of VultureItem
        """
        return self.__filterUnusedItems(self.__unusedImports, "import")

    def __filterUnusedProperties(self):
        """
        Private method to get the list of unused properties.

        @return list of unused properties
        @rtype list of VultureItem
        """
        return self.__filterUnusedItems(self.__unusedProps, "property")

    def __filterUnusedVariables(self):
        """
        Private method to get the list of unused variables.

        @return list of unused variables
        @rtype list of VultureItem
        """
        return self.__filterUnusedItems(self.__unusedVars, "variable")

    def __filterUnusedAttributes(self):
        """
        Private method to get the list of unused attributes.

        @return list of unused attributes
        @rtype list of VultureItem
        """
        return self.__filterUnusedItems(self.__unusedAttrs, "attribute")

    def __createResultItems(self):
        """
        Private method to populate the list with the analysis result.
        """
        lastFileItem = None
        lastFileName = ""
        items = (
            self.__filterUnusedFunctions()
            + self.__filterUnusedMethods()
            + self.__filterUnusedClasses()
            + self.__filterUnusedImports()
            + self.__filterUnusedProperties()
            + self.__filterUnusedVariables()
            + self.__filterUnusedAttributes()
        )
        for item in sorted(items, key=lambda item: item.filename):
            if lastFileItem is None or lastFileName != item.filename:
                lastFileItem = self.__createFileItem(item.filename)
                lastFileName = item.filename

            self.__createResultItem(lastFileItem, item)

    def __createResultItem(self, parent, item):
        """
        Private method to create a result item.

        @param parent reference to the parent item
        @type QTreeWidgetItem
        @param item reference to the item
        @type VultureItem
        """
        try:
            translatedType = self.__translatedTypes[item.typ]
        except KeyError:
            translatedType = item.typ
        itm = QTreeWidgetItem(
            parent,
            [
                "{0:6d}".format(item.first_lineno),
                item.name,
                "{0:3d}%".format(item.confidence),
                translatedType,
            ],
        )
        itm.setData(0, self.FilePathRole, item.filename)
        itm.setData(0, self.TypeRole, item.typ)
        itm.setTextAlignment(0, Qt.AlignmentFlag.AlignRight)  # line no
        itm.setTextAlignment(2, Qt.AlignmentFlag.AlignRight)  # confidence

    def __createFileItem(self, filename):
        """
        Private method to create a file item.

        @param filename file name for the item
        @type str
        @return reference to the created item
        @rtype QTreeWidgetItem
        """
        itm = QTreeWidgetItem(
            self.resultList, [self.__project.getRelativePath(filename)]
        )
        itm.setData(0, self.FilePathRole, filename)
        itm.setExpanded(True)
        itm.setFirstColumnSpanned(True)

        return itm

    def __showContextMenu(self, coord):
        """
        Private slot to show the context menu of the listview.

        @param coord the position of the mouse pointer
        @type QPoint
        """
        topLevelPresent = self.resultList.topLevelItemCount() > 0
        self.__collapseAct.setEnabled(topLevelPresent)
        self.__expandAct.setEnabled(topLevelPresent)

        self.__whiteListAct.setEnabled(len(self.__getSelectedNonFileItems()) != 0)

        self.__menu.popup(self.resultList.mapToGlobal(coord))

    def __resultCollapse(self):
        """
        Private slot to collapse all entries of the resultlist.
        """
        for index in range(self.resultList.topLevelItemCount()):
            self.resultList.topLevelItem(index).setExpanded(False)

    def __resultExpand(self):
        """
        Private slot to expand all entries of the resultlist.
        """
        for index in range(self.resultList.topLevelItemCount()):
            self.resultList.topLevelItem(index).setExpanded(True)

    def __getSelectedNonFileItems(self):
        """
        Private method to get a list of selected non file items.

        @return list of selected non file items
        @rtype list of QTreeWidgetItem
        """
        return [i for i in self.resultList.selectedItems() if i.parent() is not None]

    def __editWhiteList(self):
        """
        Private slot to edit the whitelist.
        """
        from .EditWhiteListDialog import EditWhiteListDialog

        dlg = EditWhiteListDialog(self.__data["WhiteLists"])
        if dlg.exec() == QDialog.DialogCode.Accepted:
            whitelists = dlg.getWhiteLists()
            self.__storeWhiteLists(whitelists)

    def __whiteList(self):
        """
        Private slot to add entries to the whitelist.
        """
        whitelists = {}
        for key in self.__data["WhiteLists"]:
            whitelists[key] = self.__data["WhiteLists"][key][:]
        for itm in self.__getSelectedNonFileItems():
            with contextlib.suppress(KeyError):
                whitelists[itm.data(0, self.TypeRole)].append(itm.text(1))
            # remove the item from the result list
            pitm = itm.parent()
            pitm.removeChild(itm)
            del itm
            if pitm.childCount() == 0:
                self.resultList.takeTopLevelItem(
                    self.resultList.indexOfTopLevelItem(pitm)
                )
                del pitm
        self.__storeWhiteLists(whitelists)

    def __storeWhiteLists(self, whitelists):
        """
        Private method to store the new whitelists, if they have changed.

        @param whitelists dictionary of lists of whitelisted names
        @type dict of list of str
        """
        changed = False
        for key in whitelists:
            whitelist = list(set(whitelists[key]))
            with contextlib.suppress(KeyError):
                if sorted(whitelist) != sorted(self.__data["WhiteLists"][key]):
                    self.__data["WhiteLists"][key] = whitelist[:]
                    changed = True

        if changed:
            self.__project.setData("CHECKERSPARMS", "Vulture", self.__data)

eric ide

mercurial