src/eric7/CodeFormatting/BlackFormattingDialog.py

Wed, 13 Jul 2022 11:16:20 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Wed, 13 Jul 2022 11:16:20 +0200
branch
eric7
changeset 9220
e9e7eca7efee
parent 9214
bd28e56047d7
child 9221
bf71ee032bb4
permissions
-rw-r--r--

Black Formatting Dialog
- added capability to filter the list by

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

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

"""
Module implementing a dialog showing the code formatting progress and the result.
"""

import copy
import datetime
import pathlib

import black

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

from EricWidgets import EricMessageBox

from .Ui_BlackFormattingDialog import Ui_BlackFormattingDialog

from . import BlackUtilities
from .BlackDiffWidget import BlackDiffWidget
from .BlackFormattingAction import BlackFormattingAction

import Utilities


class BlackFormattingDialog(QDialog, Ui_BlackFormattingDialog):
    """
    Class implementing a dialog showing the code formatting progress and the result.
    """
    DataTypeRole = Qt.ItemDataRole.UserRole
    DataRole = Qt.ItemDataRole.UserRole + 1
    
    StatusColumn = 0
    FileNameColumn = 1
    
    def __init__(self, configuration, filesList, project=None,
                 action=BlackFormattingAction.Format, parent=None):
        """
        Constructor
        
        @param configuration dictionary containing the configuration parameters
        @type dict
        @param filesList list of absolute file paths to be processed
        @type list of str
        @param project reference to the project object (defaults to None)
        @type Project (optional)
        @param action action to be performed (defaults to BlackFormattingAction.Format)
        @type BlackFormattingAction (optional)
        @param parent reference to the parent widget (defaults to None)
        @type QWidget (optional)
        """
        super().__init__(parent)
        self.setupUi(self)
        
        self.progressBar.setMaximum(len(filesList))
        self.progressBar.setValue(0)
        
        self.resultsList.header().setSortIndicator(1, Qt.SortOrder.AscendingOrder)
        
        self.statisticsGroup.setVisible(False)
        
        self.__report = BlackReport(self)
        self.__report.check = action is BlackFormattingAction.Check
        self.__report.diff = action is BlackFormattingAction.Diff
        
        self.__config = copy.deepcopy(configuration)
        self.__project = project
        self.__action = action
        
        self.__cancelled = False
        self.__diffDialog = None
        
        self.__allFilter = self.tr("<all>")
        
        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(True)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setDefault(True)
        
        self.show()
        QCoreApplication.processEvents()
        
        self.__files = self.__filterFiles(filesList)
        self.__formatFiles()
    
    def __filterFiles(self, filesList):
        """
        Private method to filter the given list of files according the
        configuration parameters.
        
        @param filesList list of files
        @type list of str
        @return list of filtered files
        @rtype list of str
        """
        filterRegExps = [
            BlackUtilities.compileRegExp(self.__config[k])
            for k in ["force-exclude", "extend-exclude", "exclude"]
            if k in self.__config and bool(self.__config[k])
            and BlackUtilities.validateRegExp(self.__config[k])[0]
        ]
        
        files = []
        for file in filesList:
            file = Utilities.fromNativeSeparators(file)
            for filterRegExp in filterRegExps:
                filterMatch = filterRegExp.search(file)
                if filterMatch and filterMatch.group(0):
                    self.__report.path_ignored(file)
                    break
            else:
                files.append(file)
        
        return files
    
    def __resort(self):
        """
        Private method to resort the result list.
        """
        self.resultsList.sortItems(
            self.resultsList.sortColumn(),
            self.resultsList.header().sortIndicatorOrder())
    
    def __resizeColumns(self):
        """
        Private method to resize the columns of the result list.
        """
        self.resultsList.header().resizeSections(
            QHeaderView.ResizeMode.ResizeToContents)
        self.resultsList.header().setStretchLastSection(True)
    
    def __populateStatusFilterCombo(self):
        """
        Private method to populate the status filter combo box with allowed selections.
        """
        allowedSelections = set()
        for row in range(self.resultsList.topLevelItemCount()):
            allowedSelections.add(
                self.resultsList.topLevelItem(row).text(
                    BlackFormattingDialog.StatusColumn
                )
            )
        
        self.statusFilterComboBox.addItem(self.__allFilter)
        self.statusFilterComboBox.addItems(sorted(allowedSelections))
    
    def __finish(self):
        """
        Private method to perform some actions after the run was performed or canceled.
        """
        self.__resort()
        self.__resizeColumns()
        
        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True)
        
        self.progressBar.setVisible(False)
        
        self.__updateStatistics()
        self.__populateStatusFilterCombo()
    
    def __updateStatistics(self):
        """
        Private method to update the statistics about the recent formatting run and
        make them visible.
        """
        self.reformattedLabel.setText(
            self.tr("reformatted")
            if self.__action is BlackFormattingAction.Format else
            self.tr("would reformat")
        )
        
        total = self.progressBar.maximum()
        processed = total - self.__report.ignored_count
        
        self.totalCountLabel.setText("{0:n}".format(total))
        self.excludedCountLabel.setText("{0:n}".format(self.__report.ignored_count))
        self.failuresCountLabel.setText("{0:n}".format(self.__report.failure_count))
        self.processedCountLabel.setText("{0:n}".format(processed))
        self.reformattedCountLabel.setText("{0:n}".format(self.__report.change_count))
        self.unchangedCountLabel.setText("{0:n}".format(self.__report.same_count))
        
        self.statisticsGroup.setVisible(True)
    
    @pyqtSlot(QAbstractButton)
    def on_buttonBox_clicked(self, button):
        """
        Private slot to handle button presses of the dialog buttons.
        
        @param button reference to the pressed button
        @type QAbstractButton
        """
        if button == self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel):
            self.__cancelled = True
        elif button == self.buttonBox.button(QDialogButtonBox.StandardButton.Close):
            self.accept()
    
    @pyqtSlot(QTreeWidgetItem, int)
    def on_resultsList_itemDoubleClicked(self, item, column):
        """
        Private slot handling a double click of a result item.
        
        @param item reference to the double clicked item
        @type QTreeWidgetItem
        @param column column number that was double clicked
        @type int
        """
        dataType = item.data(0, BlackFormattingDialog.DataTypeRole)
        if dataType == "error":
            EricMessageBox.critical(
                self,
                self.tr("Formatting Failure"),
                self.tr(
                    "<p>Formatting failed due to this error.</p><p>{0}</p>"
                ).format(item.data(0, BlackFormattingDialog.DataRole))
            )
        elif dataType == "diff":
            if self.__diffDialog is None:
                self.__diffDialog = BlackDiffWidget()
            self.__diffDialog.showDiff(item.data(0, BlackFormattingDialog.DataRole))
    
    def addResultEntry(self, status, fileName, isError=False, data=None):
        """
        Public method to add an entry to the result list.
        
        @param status status of the operation
        @type str
        @param fileName name of the processed file
        @type str
        @param isError flag indicating that data contains an error message (defaults to
            False)
        @type bool (optional)
        @param data associated data (diff or error message) (defaults to None)
        @type str (optional)
        """
        if self.__project:
            fileName = self.__project.getRelativePath(fileName)
        
        itm = QTreeWidgetItem(self.resultsList, [status, fileName])
        if data:
            itm.setData(
                0,
                BlackFormattingDialog.DataTypeRole,
                "error" if isError else "diff"
            )
            itm.setData(0, BlackFormattingDialog.DataRole, data)
        
        self.progressBar.setValue(self.progressBar.value() + 1)
        
        QCoreApplication.processEvents()
    
    def __formatFiles(self):
        """
        Private method to format the list of files according the configuration.
        """
        writeBack = black.WriteBack.from_configuration(
            check=self.__action is BlackFormattingAction.Check,
            diff=self.__action is BlackFormattingAction.Diff
        )
        
        versions = (
            {
                black.TargetVersion[target.upper()]
                for target in self.__config["target-version"]
            }
            if self.__config["target-version"] else
            set()
        )
        
        mode = black.Mode(
            target_versions=versions,
            line_length=int(self.__config["line-length"]),
            string_normalization=not self.__config["skip-string-normalization"],
            magic_trailing_comma=not self.__config["skip-magic-trailing-comma"]
        )
        
        for file in self.__files:
            if self.__action is BlackFormattingAction.Diff:
                self.__diffFormatFile(
                    pathlib.Path(file),
                    fast=False,
                    mode=mode,
                    report=self.__report
                )
            else:
                black.reformat_one(
                    pathlib.Path(file),
                    fast=False,
                    write_back=writeBack,
                    mode=mode,
                    report=self.__report
                )
            
            if self.__cancelled:
                break
        
        self.__finish()
    
    def __diffFormatFile(self, src, fast, mode, report):
        """
        Private method to check, if the given files need to be reformatted, and generate
        a unified diff.
        
        @param src path of file to be checked
        @type pathlib.Path
        @param fast flag indicating fast operation
        @type bool
        @param mode code formatting options
        @type black.Mode
        @param report reference to the report object
        @type BlackReport
        """
        then = datetime.datetime.utcfromtimestamp(src.stat().st_mtime)
        with open(src, "rb") as buf:
            srcContents, _, _ = black.decode_bytes(buf.read())
        try:
            dstContents = black.format_file_contents(srcContents, fast=fast, mode=mode)
        except black.NothingChanged:
            report.done(src, black.Changed.NO)
            return
        
        fileName = str(src)
        if self.__project:
            fileName = self.__project.getRelativePath(fileName)
        
        now = datetime.datetime.utcnow()
        srcName = f"{fileName}\t{then} +0000"
        dstName = f"{fileName}\t{now} +0000"
        diffContents = black.diff(srcContents, dstContents, srcName, dstName)
        report.done(src, black.Changed.YES, diff=diffContents)
    
    def closeEvent(self, evt):
        """
        Protected slot implementing a close event handler.
        
        @param evt reference to the close event
        @type QCloseEvent
        """
        if self.__diffDialog is not None:
            self.__diffDialog.close()
        evt.accept()
    
    @pyqtSlot(str)
    def on_statusFilterComboBox_currentTextChanged(self, status):
        """
        Private slot handling the selection of a status for items to be shown.
        
        @param status selected status
        @type str
        """
        for row in range(self.resultsList.topLevelItemCount()):
            itm = self.resultsList.topLevelItem(row)
            itm.setHidden(
                status != self.__allFilter
                and itm.text(BlackFormattingDialog.StatusColumn) != status
            )


class BlackReport(black.Report):
    """
    Class extending the black Report to work with our dialog.
    """
    def __init__(self, dialog):
        """
        Constructor
        
        @param dialog reference to the result dialog
        @type QDialog
        """
        super().__init__()
        
        self.ignored_count = 0
        
        self.__dialog = dialog
    
    def done(self, src, changed, diff=""):
        """
        Public method to handle the end of a reformat.
        
        @param src name of the processed file
        @type pathlib.Path
        @param changed change status
        @type black.Changed
        @param diff unified diff of potential changes (defaults to "")
        @type str
        """
        if changed is black.Changed.YES:
            status = (
                QCoreApplication.translate("BlackFormattingDialog", "would reformat")
                if self.check or self.diff else
                QCoreApplication.translate("BlackFormattingDialog", "reformatted")
            )
            self.change_count += 1
        
        elif changed is black.Changed.NO:
            status = QCoreApplication.translate("BlackFormattingDialog", "unchanged")
            self.same_count += 1
        
        elif changed is black.Changed.CACHED:
            status = QCoreApplication.translate("BlackFormattingDialog", "unmodified")
            self.same_count += 1
        
        if self.diff:
            self.__dialog.addResultEntry(status, str(src), data=diff)
        else:
            self.__dialog.addResultEntry(status, str(src))
    
    def failed(self, src, message):
        """
        Public method to handle a reformat failure.
        
        @param src name of the processed file
        @type pathlib.Path
        @param message error message
        @type str
        """
        status = QCoreApplication.translate("BlackFormattingDialog", "failed")
        self.failure_count += 1
        
        self.__dialog.addResultEntry(status, str(src), isError=True, data=message)
    
    def path_ignored(self, src, message=""):
        """
        Public method handling an ignored path.
        
        @param src name of the processed file
        @type pathlib.Path or str
        @param message ignore message (default to "")
        @type str (optional)
        """
        status = QCoreApplication.translate("BlackFormattingDialog", "ignored")
        self.ignored_count += 1
        
        self.__dialog.addResultEntry(status, str(src))

eric ide

mercurial