Mon, 11 Jul 2022 16:42:50 +0200
Code Formatting
- added an interface to reformat Python source code with the 'Black' utility
# -*- 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 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.__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.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 __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() def __updateStatistics(self): """ Private method to update the statistics about the recent formatting run. """ 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)) @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() 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))