Mon, 11 Jul 2022 16:42:50 +0200
Code Formatting
- added an interface to reformat Python source code with the 'Black' utility
--- a/docs/changelog Mon Jul 11 16:09:04 2022 +0200 +++ b/docs/changelog Mon Jul 11 16:42:50 2022 +0200 @@ -8,6 +8,8 @@ - Code Documentation Generator -- added capability to configure a start directory for the documentation generation process +- Code Formatting + -- added an interface to reformat Python source code with the 'Black' utility - setup Wizard -- added support for 'project_urls' -- added support for 'entry_points'
--- a/eric7.epj Mon Jul 11 16:09:04 2022 +0200 +++ b/eric7.epj Mon Jul 11 16:42:50 2022 +0200 @@ -61,7 +61,7 @@ "CopyrightMinFileSize": 0, "DocstringType": "eric", "EnabledCheckerCategories": "C, D, E, M, N, Y, W", - "ExcludeFiles": "*/ThirdParty/*, */coverage/*, */Ui_*.py, */Examples/*, */*_rc.py,*/pycodestyle.py,*/pyflakes/checker.py,*/mccabe.py,*/eradicate.py,*/ast_unparse.py,*/piplicenses.py", + "ExcludeFiles": "*/ThirdParty/*, */coverage/*, */Ui_*.py, */Examples/*, */pycodestyle.py,*/pyflakes/checker.py,*/mccabe.py,*/eradicate.py,*/ast_unparse.py,*/piplicenses.py", "ExcludeMessages": "C101,E203,E265,E266,E305,E402,M201,M301,M302,M303,M304,M305,M306,M307,M308,M311,M312,M313,M314,M315,M321,M701,M702,M811,M834,N802,N803,N807,N808,N821,W293,W503,Y119,Y401,Y402", "FixCodes": "", "FixIssues": false, @@ -270,6 +270,9 @@ "Ui_*.py": "__IGNORE__" }, "FORMS": [ + "src/eric7/CodeFormatting/BlackConfigurationDialog.ui", + "src/eric7/CodeFormatting/BlackDiffWidget.ui", + "src/eric7/CodeFormatting/BlackFormattingDialog.ui", "src/eric7/CondaInterface/CondaExecDialog.ui", "src/eric7/CondaInterface/CondaExportDialog.ui", "src/eric7/CondaInterface/CondaInfoDialog.ui", @@ -992,6 +995,13 @@ "scripts/uninstall-debugclients.py", "scripts/uninstall.py", "setup.py", + "src/__init__.py", + "src/eric7/CodeFormatting/BlackConfigurationDialog.py", + "src/eric7/CodeFormatting/BlackDiffWidget.py", + "src/eric7/CodeFormatting/BlackFormattingAction.py", + "src/eric7/CodeFormatting/BlackFormattingDialog.py", + "src/eric7/CodeFormatting/BlackUtilities.py", + "src/eric7/CodeFormatting/__init__.py", "src/eric7/CondaInterface/Conda.py", "src/eric7/CondaInterface/CondaExecDialog.py", "src/eric7/CondaInterface/CondaExportDialog.py", @@ -2389,7 +2399,7 @@ "TRANSLATIONSBINPATH": "", "UICPARAMS": { "Package": "", - "PackagesRoot": "eric7", + "PackagesRoot": "src/eric7", "RcSuffix": "" }, "VCS": "Mercurial",
--- a/pyproject.toml Mon Jul 11 16:09:04 2022 +0200 +++ b/pyproject.toml Mon Jul 11 16:42:50 2022 +0200 @@ -69,6 +69,7 @@ "cyclonedx-python-lib", "cyclonedx-bom", "trove-classifiers", + "black>=22.6.0", "pywin32>=1.0;platform_system=='Windows'", ] dynamic = ["version"]
--- a/scripts/install.py Mon Jul 11 16:09:04 2022 +0200 +++ b/scripts/install.py Mon Jul 11 16:42:50 2022 +0200 @@ -1632,6 +1632,7 @@ "cyclonedx-python-lib": ("cyclonedx", ""), "cyclonedx-bom": ("cyclonedx_py", ""), "trove-classifiers": ("trove_classifiers", ""), + "black": ("black", ">=22.6.0"), } if not ignorePyqt6Tools: optionalModulesList["qt6-applications"] = ("qt6_applications", "")
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/__init__.py Mon Jul 11 16:42:50 2022 +0200 @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Empty module to make this a package. +"""
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/CodeFormatting/BlackConfigurationDialog.py Mon Jul 11 16:42:50 2022 +0200 @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog to enter the parameters for a Black formatting run. +""" + +import contextlib +import copy +import pathlib + +import black +import tomlkit + +from PyQt6.QtCore import pyqtSlot, Qt +from PyQt6.QtGui import QFontMetricsF, QGuiApplication +from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QListWidgetItem + +from EricWidgets import EricMessageBox +from EricWidgets.EricApplication import ericApp + +from .Ui_BlackConfigurationDialog import Ui_BlackConfigurationDialog + +from . import BlackUtilities + + +class BlackConfigurationDialog(QDialog, Ui_BlackConfigurationDialog): + """ + Class implementing a dialog to enter the parameters for a Black formatting run. + """ + def __init__(self, withProject=True, parent=None): + """ + Constructor + + @param withProject flag indicating to look for project configurations + (defaults to True) + @type bool + @param parent reference to the parent widget (defaults to None) + @type QWidget (optional) + """ + super().__init__(parent) + self.setupUi(self) + + self.__project = ericApp().getObject("Project") if withProject else None + + indentTabWidth = ( + QFontMetricsF(self.excludeEdit.font()).horizontalAdvance(" ") * 2 + ) + self.excludeEdit.document().setIndentWidth(indentTabWidth) + self.excludeEdit.setTabStopDistance(indentTabWidth) + + self.__pyprojectData = {} + self.__projectData = {} + + self.__tomlButton = self.buttonBox.addButton( + self.tr("Generate TOML"), + QDialogButtonBox.ButtonRole.ActionRole + ) + self.__tomlButton.setToolTip(self.tr( + "Place a code snippet for 'pyproject.toml' into the clipboard." + )) + self.__tomlButton.clicked.connect(self.__createTomlSnippet) + + # setup the source combobox + self.sourceComboBox.addItem("", "") + if self.__project: + pyprojectPath = pathlib.Path( + self.__project.getProjectPath() + ) / "pyproject.toml" + if pyprojectPath.exists(): + with contextlib.suppress(tomlkit.exceptions.ParseError, OSError): + with pyprojectPath.open("r", encoding="utf-8") as f: + data = tomlkit.load(f) + config = data.get("tool", {}).get("black", {}) + if config: + self.__pyprojectData = { + k.replace("--", "").replace("-", "_"): v + for k, v in config.items() + } + self.sourceComboBox.addItem("pyproject.toml", "pyproject") + if self.__project.getData("OTHERTOOLSPARMS", "Black") is not None: + self.__projectData = copy.deepcopy( + self.__project.getData("OTHERTOOLSPARMS", "Black") + ) + self.sourceComboBox.addItem(self.tr("Project File"), "project") + self.sourceComboBox.addItem(self.tr("Defaults"), "default") + self.sourceComboBox.addItem(self.tr("Configuration Below"), "dialog") + + self.__populateTargetVersionsList() + + if self.__projectData: + source = self.__projectData.get("source", "") + self.sourceComboBox.setCurrentIndex(self.sourceComboBox.findData(source)) + + def __populateTargetVersionsList(self): + """ + Private method to populate the target versions list widget with checkable + Python version entries. + """ + targets = [ + (int(t[2]), int(t[3:]), t) + for t in dir(black.TargetVersion) + if t.startswith("PY") + ] + for target in sorted(targets): + itm = QListWidgetItem( + "Python {0}.{1}".format(target[0], target[1]), self.targetVersionsList + ) + itm.setData(Qt.ItemDataRole.UserRole, target[2]) + itm.setFlags(itm.flags() | Qt.ItemFlag.ItemIsUserCheckable) + itm.setCheckState(Qt.CheckState.Unchecked) + + def __loadConfiguration(self, configurationDict): + """ + Private method to load the configuration section with data of the given + dictionary. + + @param configurationDict reference to the data to be loaded + @type dict + """ + confDict = copy.deepcopy(BlackUtilities.getDefaultConfiguration()) + confDict.update(configurationDict) + + self.lineLengthSpinBox.setValue(int(confDict["line-length"])) + self.skipStringNormalCheckBox.setChecked(confDict["skip-string-normalization"]) + self.skipMagicCommaCheckBox.setChecked(confDict["skip-magic-trailing-comma"]) + self.excludeEdit.setPlainText(confDict["extend-exclude"]) + for row in range(self.targetVersionsList.count()): + itm = self.targetVersionsList.item(row) + itm.setCheckState( + Qt.CheckState.Checked + if itm.data(Qt.ItemDataRole.UserRole).lower() + in confDict["target-version"] else + Qt.CheckState.Unchecked + ) + + @pyqtSlot(str) + def on_sourceComboBox_currentTextChanged(self, selection): + """ + Private slot to handle the selection of a configuration source. + + @param selection text of the currently selected item + @type str + """ + self.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled( + bool(selection) + ) + + source = self.sourceComboBox.currentData() + if source == "pyproject": + self.__loadConfiguration(self.__pyprojectData) + elif source == "project": + self.__loadConfiguration(self.__projectData) + elif source == "default": + self.__loadConfiguration(BlackUtilities.getDefaultConfiguration()) + elif source == "dialog": + # just leave the current entries + pass + + @pyqtSlot() + def on_excludeEdit_textChanged(self): + """ + Private slot to enable the validate button depending on the exclude text. + """ + self.validateButton.setEnabled(bool(self.excludeEdit.toPlainText())) + + @pyqtSlot() + def on_validateButton_clicked(self): + """ + Private slot to validate the entered exclusion regular expression. + """ + regexp = self.excludeEdit.toPlainText() + valid, error = BlackUtilities.validateRegExp(regexp) + if valid: + EricMessageBox.information( + self, + self.tr("Validation"), + self.tr("""The exclusion expression is valid.""") + ) + else: + EricMessageBox.critical( + self, + self.tr("Validation Error"), + error + ) + + def __getTargetList(self): + """ + Private method to get the list of checked target versions. + + @return list of target versions + @rtype list of str + """ + targets = [] + for row in range(self.targetVersionsList.count()): + itm = self.targetVersionsList.item(row) + if itm.checkState() == Qt.CheckState.Checked: + targets.append(itm.data(Qt.ItemDataRole.UserRole).lower()) + + return targets + + @pyqtSlot() + def __createTomlSnippet(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. + """ + doc = tomlkit.document() + + black = tomlkit.table() + targetList = self.__getTargetList() + if targetList: + black["target-version"] = targetList + black["line-length"] = self.lineLengthSpinBox.value() + if self.skipStringNormalCheckBox.isChecked(): + black["skip-string-normalization"] = True + if self.skipMagicCommaCheckBox.isChecked(): + black["skip-magic-trailing-comma"] = True + + excludeRegexp = self.excludeEdit.toPlainText() + if excludeRegexp and BlackUtilities.validateRegExp(excludeRegexp)[0]: + black["extend-exclude"] = tomlkit.string( + "\n{0}\n".format(excludeRegexp.strip()), + literal=True, + multiline=True + ) + + doc["tool"] = tomlkit.table(is_super_table=True) + doc["tool"]["black"] = black + + QGuiApplication.clipboard().setText(tomlkit.dumps(doc)) + + EricMessageBox.information( + self, + self.tr("Create TOML snipper"), + self.tr("""The 'pyproject.toml' snippet was copied to the clipboard""" + """ successfully.""") + ) + + def getConfiguration(self): + """ + Public method to get the current configuration parameters. + + @return dictionary containing the configuration parameters + @rtype dict + """ + configuration = BlackUtilities.getDefaultConfiguration() + + configuration["source"] = self.sourceComboBox.currentData() + configuration["target-version"] = self.__getTargetList() + configuration["line-length"] = self.lineLengthSpinBox.value() + configuration["skip-string-normalization"] = ( + self.skipStringNormalCheckBox.isChecked() + ) + configuration["skip-magic-trailing-comma"] = ( + self.skipMagicCommaCheckBox.isChecked() + ) + configuration["extend-exclude"] = self.excludeEdit.toPlainText().strip() + + if self.__project: + self.__project.setData("OTHERTOOLSPARMS", "Black", configuration) + + return configuration
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/CodeFormatting/BlackConfigurationDialog.ui Mon Jul 11 16:42:50 2022 +0200 @@ -0,0 +1,223 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>BlackConfigurationDialog</class> + <widget class="QDialog" name="BlackConfigurationDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>600</width> + <height>500</height> + </rect> + </property> + <property name="windowTitle"> + <string>Black Configuration</string> + </property> + <property name="sizeGripEnabled"> + <bool>true</bool> + </property> + <layout class="QGridLayout" name="gridLayout_2"> + <item row="0" column="0"> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Configuration Source:</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QComboBox" name="sourceComboBox"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="toolTip"> + <string>Select the configuration source.</string> + </property> + </widget> + </item> + <item row="1" column="0" colspan="2"> + <widget class="QGroupBox" name="configurationGroup"> + <property name="title"> + <string>Configuration</string> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="0" column="0"> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Line Length:</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QSpinBox" name="lineLengthSpinBox"> + <property name="toolTip"> + <string>Enter the allowed maximum line length.</string> + </property> + <property name="minimum"> + <number>70</number> + </property> + <property name="maximum"> + <number>120</number> + </property> + <property name="value"> + <number>88</number> + </property> + </widget> + </item> + <item row="0" column="2" colspan="2"> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>387</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="label_3"> + <property name="text"> + <string>Target Versions:</string> + </property> + <property name="alignment"> + <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set> + </property> + </widget> + </item> + <item row="1" column="1" rowspan="2" colspan="2"> + <widget class="QListWidget" name="targetVersionsList"> + <property name="toolTip"> + <string>Select the python versions to be supported (none for auto-detection).</string> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::NoSelection</enum> + </property> + </widget> + </item> + <item row="2" column="3"> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>322</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item row="3" column="0" colspan="4"> + <widget class="QCheckBox" name="skipStringNormalCheckBox"> + <property name="toolTip"> + <string>Select to not normalize string quotes or prefixes.</string> + </property> + <property name="text"> + <string>Skip string normalization</string> + </property> + </widget> + </item> + <item row="4" column="0" colspan="4"> + <widget class="QCheckBox" name="skipMagicCommaCheckBox"> + <property name="toolTip"> + <string>Select to not use trailing commas as a reason to split lines.</string> + </property> + <property name="text"> + <string>Skip magic trailing comma</string> + </property> + </widget> + </item> + <item row="5" column="0"> + <widget class="QLabel" name="label_4"> + <property name="text"> + <string>Exclude:</string> + </property> + <property name="alignment"> + <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set> + </property> + </widget> + </item> + <item row="5" column="1" rowspan="2" colspan="3"> + <widget class="QPlainTextEdit" name="excludeEdit"> + <property name="toolTip"> + <string>Enter a regular expression that matches files and directories that should be excluded in addition to the default exclusions.</string> + </property> + </widget> + </item> + <item row="6" column="0"> + <widget class="QPushButton" name="validateButton"> + <property name="toolTip"> + <string>Press to validate the entered exclusion pattern.</string> + </property> + <property name="text"> + <string>Validate</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item row="2" column="0" colspan="2"> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="standardButtons"> + <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set> + </property> + </widget> + </item> + </layout> + </widget> + <tabstops> + <tabstop>sourceComboBox</tabstop> + <tabstop>lineLengthSpinBox</tabstop> + <tabstop>targetVersionsList</tabstop> + <tabstop>skipStringNormalCheckBox</tabstop> + <tabstop>skipMagicCommaCheckBox</tabstop> + <tabstop>excludeEdit</tabstop> + </tabstops> + <resources/> + <connections> + <connection> + <sender>buttonBox</sender> + <signal>accepted()</signal> + <receiver>BlackConfigurationDialog</receiver> + <slot>accept()</slot> + <hints> + <hint type="sourcelabel"> + <x>248</x> + <y>254</y> + </hint> + <hint type="destinationlabel"> + <x>157</x> + <y>274</y> + </hint> + </hints> + </connection> + <connection> + <sender>buttonBox</sender> + <signal>rejected()</signal> + <receiver>BlackConfigurationDialog</receiver> + <slot>reject()</slot> + <hints> + <hint type="sourcelabel"> + <x>316</x> + <y>260</y> + </hint> + <hint type="destinationlabel"> + <x>286</x> + <y>274</y> + </hint> + </hints> + </connection> + </connections> +</ui>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/CodeFormatting/BlackDiffWidget.py Mon Jul 11 16:42:50 2022 +0200 @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a window to show a unified diff.. +""" + +from PyQt6.QtWidgets import QWidget + +from .Ui_BlackDiffWidget import Ui_BlackDiffWidget + +from UI.DiffHighlighter import DiffHighlighter + +import Preferences + + +class BlackDiffWidget(QWidget, Ui_BlackDiffWidget): + """ + Class implementing a window to show a unified diff.. + """ + def __init__(self, parent=None): + """ + Constructor + + @param parent reference to the parent widget (defaults to None) + @type QWidget (optional) + """ + super().__init__(parent) + self.setupUi(self) + + font = Preferences.getEditorOtherFonts("MonospacedFont") + self.diffEdit.document().setDefaultFont(font) + + self.__highlighter = DiffHighlighter(self.diffEdit.document()) + self.__savedGeometry = None + + def showDiff(self, diff): + """ + Public method to show the given diff. + + @param diff text containing the unified diff + @type str + """ + self.diffEdit.clear() + self.__highlighter.regenerateRules() + + if diff: + self.diffEdit.setPlainText(diff) + else: + self.diffEdit.setPlainText(self.tr('There is no difference.')) + + if self.__savedGeometry is not None: + self.restoreGeometry(self.__savedGeometry) + + self.show() + + def closeEvent(self, evt): + """ + Protected slot implementing a close event handler. + + @param evt reference to the close event + @type QCloseEvent + """ + self.__savedGeometry = self.saveGeometry()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/CodeFormatting/BlackDiffWidget.ui Mon Jul 11 16:42:50 2022 +0200 @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>BlackDiffWidget</class> + <widget class="QWidget" name="BlackDiffWidget"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>700</width> + <height>700</height> + </rect> + </property> + <property name="windowTitle"> + <string>Reformatting Differences</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QPlainTextEdit" name="diffEdit"> + <property name="lineWrapMode"> + <enum>QPlainTextEdit::NoWrap</enum> + </property> + <property name="readOnly"> + <bool>true</bool> + </property> + <property name="tabStopDistance"> + <double>8.000000000000000</double> + </property> + </widget> + </item> + <item> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="standardButtons"> + <set>QDialogButtonBox::Close</set> + </property> + </widget> + </item> + </layout> + </widget> + <resources/> + <connections> + <connection> + <sender>buttonBox</sender> + <signal>rejected()</signal> + <receiver>BlackDiffWidget</receiver> + <slot>close()</slot> + <hints> + <hint type="sourcelabel"> + <x>447</x> + <y>680</y> + </hint> + <hint type="destinationlabel"> + <x>598</x> + <y>639</y> + </hint> + </hints> + </connection> + </connections> +</ui>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/CodeFormatting/BlackFormattingAction.py Mon Jul 11 16:42:50 2022 +0200 @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing an enum defining the various code formatting actions. +""" + +import enum + + +class BlackFormattingAction(enum.Enum): + """ + Class defining the various code formatting actions. + """ + Format = 0 + Check = 1 + Diff = 2
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/CodeFormatting/BlackFormattingDialog.py Mon Jul 11 16:42:50 2022 +0200 @@ -0,0 +1,403 @@ +# -*- 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))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/CodeFormatting/BlackFormattingDialog.ui Mon Jul 11 16:42:50 2022 +0200 @@ -0,0 +1,204 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>BlackFormattingDialog</class> + <widget class="QDialog" name="BlackFormattingDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>700</width> + <height>600</height> + </rect> + </property> + <property name="windowTitle"> + <string>Code Formatting with Black</string> + </property> + <property name="sizeGripEnabled"> + <bool>true</bool> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QTreeWidget" name="resultsList"> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="rootIsDecorated"> + <bool>false</bool> + </property> + <property name="itemsExpandable"> + <bool>false</bool> + </property> + <property name="sortingEnabled"> + <bool>true</bool> + </property> + <column> + <property name="text"> + <string>Status</string> + </property> + </column> + <column> + <property name="text"> + <string>File Name</string> + </property> + </column> + </widget> + </item> + <item> + <widget class="QGroupBox" name="groupBox"> + <property name="title"> + <string>Statistics</string> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="0" column="0"> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Total Files:</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QLabel" name="totalCountLabel"> + <property name="text"> + <string notr="true">0</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item row="0" column="2"> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>183</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item row="0" column="3"> + <widget class="QLabel" name="label_3"> + <property name="text"> + <string>Excluded:</string> + </property> + </widget> + </item> + <item row="0" column="4"> + <widget class="QLabel" name="excludedCountLabel"> + <property name="text"> + <string notr="true">0</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item row="0" column="5"> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>182</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item row="0" column="6"> + <widget class="QLabel" name="label_5"> + <property name="text"> + <string>Failures:</string> + </property> + </widget> + </item> + <item row="0" column="7"> + <widget class="QLabel" name="failuresCountLabel"> + <property name="text"> + <string notr="true">0</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="label_6"> + <property name="text"> + <string>Processed:</string> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QLabel" name="processedCountLabel"> + <property name="text"> + <string notr="true">0</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item row="1" column="3"> + <widget class="QLabel" name="reformattedLabel"> + <property name="text"> + <string>Reformatted:</string> + </property> + </widget> + </item> + <item row="1" column="4"> + <widget class="QLabel" name="reformattedCountLabel"> + <property name="text"> + <string notr="true">0</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item row="1" column="6"> + <widget class="QLabel" name="label_4"> + <property name="text"> + <string>Unchanged:</string> + </property> + </widget> + </item> + <item row="1" column="7"> + <widget class="QLabel" name="unchangedCountLabel"> + <property name="text"> + <string notr="true">0</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QProgressBar" name="progressBar"> + <property name="format"> + <string>%v/%m Files</string> + </property> + </widget> + </item> + <item> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="standardButtons"> + <set>QDialogButtonBox::Cancel|QDialogButtonBox::Close</set> + </property> + </widget> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/CodeFormatting/BlackUtilities.py Mon Jul 11 16:42:50 2022 +0200 @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing some utility functions for the Black based code formatting. +""" + +import re + +from PyQt6.QtCore import QCoreApplication + +import black + + +def getDefaultConfiguration(): + """ + Function to generate a default set of configuration parameters. + + @return dictionary containing the default parameters + @rtype dict + """ + return { + "target-version": set(), + "line-length": black.DEFAULT_LINE_LENGTH, + "skip-string-normalization": False, + "skip-magic-trailing-comma": False, + "extend-exclude": "", + "exclude": black.DEFAULT_EXCLUDES, # not shown in config dialog + "force-exclude": "", # not shown in config dialog + } + + +def compileRegExp(regexp): + """ + Function to compile a given regular expression. + + @param regexp regular expression to be compiled + @type str + @return compiled regular expression object + @rtype re.Pattern + """ + if "\n" in regexp: + # multi line regexp + regexp = f"(?x){regexp}" + compiled = re.compile(regexp) + return compiled + + +def validateRegExp(regexp): + """ + Function to validate a given regular expression. + + @param regexp regular expression to be validated + @type str + @return tuple containing a flag indicating validity and an error message + @rtype tuple of (bool, str) + """ + if regexp: + try: + compileRegExp(regexp) + return True, "" + except re.error as e: + return ( + False, + QCoreApplication.translate( + "BlackUtilities", + "Invalid regular expression: {0}" + ).format(str(e)) + ) + except IndexError: + return ( + False, + QCoreApplication.translate( + "BlackUtilities", + "Invalid regular expression: missing group name" + ) + ) + else: + return ( + False, + QCoreApplication.translate( + "BlackUtilities", + "A regular expression must be given." + ) + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/CodeFormatting/__init__.py Mon Jul 11 16:42:50 2022 +0200 @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Package implementing dialogs to perform some code formatting. + +The supported code formatting systems are these. +<ul> +<li><a href="https://github.com/psf/black">Black</a></li> +</ul +"""
--- a/src/eric7/JediInterface/AssistantJedi.py Mon Jul 11 16:09:04 2022 +0200 +++ b/src/eric7/JediInterface/AssistantJedi.py Mon Jul 11 16:42:50 2022 +0200 @@ -129,8 +129,7 @@ if menu is not None: checkAction = editor.getMenu("Checks").menuAction() act = menu.insertMenu(checkAction, self.__menu) - sep = menu.insertSeparator(checkAction) - self.__menuActions[editor] = [act, sep] + self.__menuActions[editor] = [act] editor.showMenu.connect(self.__editorShowMenu) def __disconnectEditor(self, editor):
--- a/src/eric7/Project/Project.py Mon Jul 11 16:09:04 2022 +0200 +++ b/src/eric7/Project/Project.py Mon Jul 11 16:42:50 2022 +0200 @@ -52,6 +52,8 @@ from Tasks.TasksFile import TasksFile +from CodeFormatting.BlackFormattingAction import BlackFormattingAction + class Project(QObject): """ @@ -2346,6 +2348,7 @@ self.menuMakeAct.setEnabled( self.pdata["MAKEPARAMS"]["MakeEnabled"]) self.menuOtherToolsAct.setEnabled(True) + self.menuFormattingAct.setEnabled(True) self.projectAboutToBeCreated.emit() @@ -3039,6 +3042,7 @@ self.menuMakeAct.setEnabled( self.pdata["MAKEPARAMS"]["MakeEnabled"]) self.menuOtherToolsAct.setEnabled(True) + self.menuFormattingAct.setEnabled(True) # open a project debugger properties file being quiet # about errors @@ -3296,6 +3300,7 @@ self.makeGrp.setEnabled(False) self.menuMakeAct.setEnabled(False) self.menuOtherToolsAct.setEnabled(False) + self.menuFormattingAct.setEnabled(False) self.__model.projectClosed() self.projectClosedHooks.emit() @@ -3505,10 +3510,14 @@ pwl = "" if self.pdata["SPELLWORDS"]: pwl = os.path.join(self.ppath, self.pdata["SPELLWORDS"]) + if not os.path.isfile(pwl): + pwl = "" pel = "" if self.pdata["SPELLEXCLUDES"]: pel = os.path.join(self.ppath, self.pdata["SPELLEXCLUDES"]) + if not os.path.isfile(pel): + pel = "" return (pwl, pel) @@ -3923,6 +3932,10 @@ """ self.actions = [] + ################################################################### + ## Project actions + ################################################################### + self.actGrp1 = createActionGroup(self) act = EricAction( @@ -3988,7 +4001,11 @@ )) self.saveasAct.triggered.connect(self.saveProjectAs) self.actions.append(self.saveasAct) - + + ################################################################### + ## Project management actions + ################################################################### + self.actGrp2 = createActionGroup(self) self.addFilesAct = EricAction( @@ -4128,7 +4145,11 @@ )) self.lexersAct.triggered.connect(self.__showLexerAssociations) self.actions.append(self.lexersAct) - + + ################################################################### + ## Project debug actions + ################################################################### + self.dbgActGrp = createActionGroup(self) act = EricAction( @@ -4193,8 +4214,12 @@ act.triggered.connect(self.__initDebugProperties) self.actions.append(act) + ################################################################### + ## Project session actions + ################################################################### + self.sessActGrp = createActionGroup(self) - + act = EricAction( self.tr('Load session'), self.tr('Load session'), 0, 0, @@ -4243,6 +4268,10 @@ act.triggered.connect(self.__deleteSession) self.actions.append(act) + ################################################################### + ## Project Tools - check actions + ################################################################### + self.chkGrp = createActionGroup(self) self.codeMetricsAct = EricAction( @@ -4285,6 +4314,10 @@ )) self.codeProfileAct.triggered.connect(self.__showProfileData) self.actions.append(self.codeProfileAct) + + ################################################################### + ## Project Tools - graphics actions + ################################################################### self.graphicsGrp = createActionGroup(self) @@ -4314,9 +4347,13 @@ )) self.loadDiagramAct.triggered.connect(self.__loadDiagram) self.actions.append(self.loadDiagramAct) - + + ################################################################### + ## Project Tools - plugin packaging actions + ################################################################### + self.pluginGrp = createActionGroup(self) - + self.pluginPkgListAct = EricAction( self.tr('Create Package List'), UI.PixmapCache.getIcon("pluginArchiveList"), @@ -4368,6 +4405,10 @@ self.pluginSArchiveAct.triggered.connect( self.__pluginCreateSnapshotArchives) self.actions.append(self.pluginSArchiveAct) + + ################################################################### + ## Project Tools - make actions + ################################################################### self.makeGrp = createActionGroup(self) @@ -4400,10 +4441,16 @@ lambda: self.__executeMake(questionOnly=True)) self.actions.append(self.makeTestAct) + ################################################################### + ## Project Tools - other tools actions + ################################################################### + + self.othersGrp = createActionGroup(self) + self.createSBOMAct = EricAction( self.tr('Create SBOM File'), self.tr('Create &SBOM File'), 0, 0, - self, 'project_create_sbom') + self.othersGrp, 'project_create_sbom') self.createSBOMAct.setStatusTip( self.tr("Create a SBOM file of the project dependencies.")) self.createSBOMAct.setWhatsThis(self.tr( @@ -4414,7 +4461,96 @@ )) self.createSBOMAct.triggered.connect(self.__createSBOMFile) self.actions.append(self.createSBOMAct) - + + ################################################################### + ## Project Tools - code formatting actions + ################################################################### + + self.blackFormattingGrp = createActionGroup(self) + + self.blackAboutAct = EricAction( + self.tr("About Black"), + self.tr("&Black"), + 0, 0, + self.blackFormattingGrp, "project_black_about" + ) + self.blackAboutAct.setStatusTip( + self.tr("Show some information about 'Black'.") + ) + self.blackAboutAct.setWhatsThis(self.tr( + "<b>Black</b>" + "<p>This shows some information about the installed 'Black' tool.</p>" + )) + self.blackAboutAct.triggered.connect(self.__aboutBlack) + self.actions.append(self.blackAboutAct) + font = self.blackAboutAct.font() + font.setBold(True) + self.blackAboutAct.setFont(font) + + self.blackFormatAct = EricAction( + self.tr("Format Code"), + self.tr("&Format Code"), + 0, 0, + self.blackFormattingGrp, "project_black_format_code" + ) + self.blackFormatAct.setStatusTip( + self.tr("Format the project sources with 'Black'.") + ) + self.blackFormatAct.setWhatsThis(self.tr( + "<b>Format Code</b>" + "<p>This shows a dialog to enter parameters for the formatting run and" + " reformats the project sources using 'Black'.</p>" + )) + self.blackFormatAct.triggered.connect( + lambda: self.__performFormatWithBlack(BlackFormattingAction.Format) + ) + self.actions.append(self.blackFormatAct) + + self.blackCheckFormattingAct = EricAction( + self.tr("Check Code Formatting"), + self.tr("&Check Code Formatting"), + 0, 0, + self.blackFormattingGrp, "project_black_check_code" + ) + self.blackCheckFormattingAct.setStatusTip( + self.tr( + "Check, if the project sources need to be reformatted with 'Black'." + ) + ) + self.blackCheckFormattingAct.setWhatsThis(self.tr( + "<b>Check Code Formatting</b>" + "<p>This shows a dialog to enter parameters for the format check run and" + " performs a check, if the project sources need to be reformatted using" + " 'Black'.</p>" + )) + self.blackCheckFormattingAct.triggered.connect( + lambda: self.__performFormatWithBlack(BlackFormattingAction.Check) + ) + self.actions.append(self.blackCheckFormattingAct) + + self.blackDiffFormattingAct = EricAction( + self.tr("Code Formatting Diff"), + self.tr("Code Formatting &Diff"), + 0, 0, + self.blackFormattingGrp, "project_black_diff_code" + ) + self.blackDiffFormattingAct.setStatusTip( + self.tr( + "Generate a unified diff of potential project source reformatting" + " with 'Black'." + ) + ) + self.blackDiffFormattingAct.setWhatsThis(self.tr( + "<b>Diff Code Formatting</b>" + "<p>This shows a dialog to enter parameters for the format diff run and" + " generates a unified diff of potential project source reformatting using" + " 'Black'.</p>" + )) + self.blackDiffFormattingAct.triggered.connect( + lambda: self.__performFormatWithBlack(BlackFormattingAction.Diff) + ) + self.actions.append(self.blackDiffFormattingAct) + self.closeAct.setEnabled(False) self.saveAct.setEnabled(False) self.saveasAct.setEnabled(False) @@ -4446,6 +4582,8 @@ self.vcsMenu.setEnabled(self.vcsSoftwareAvailable()) self.checksMenu = QMenu(self.tr('Chec&k'), toolsMenu) self.checksMenu.setTearOffEnabled(True) + self.formattingMenu = QMenu(self.tr("Code &Formatting"), toolsMenu) + self.formattingMenu.setTearOffEnabled(True) self.menuShow = QMenu(self.tr('Sho&w'), toolsMenu) self.graphicsMenu = QMenu(self.tr('&Diagrams'), toolsMenu) self.packagersMenu = QMenu(self.tr('Pac&kagers'), toolsMenu) @@ -4467,6 +4605,7 @@ "Packagers": self.packagersMenu, "Make": self.makeMenu, "OtherTools": self.othersMenu, + "Formatting": self.formattingMenu, } # connect the aboutToShow signals @@ -4482,6 +4621,7 @@ self.debuggerMenu.aboutToShow.connect(self.__showContextMenuDebugger) self.makeMenu.aboutToShow.connect(self.__showContextMenuMake) self.othersMenu.aboutToShow.connect(self.__showContextMenuOthers) + self.formattingMenu.aboutToShow.connect(self.__showContextMenuFormat) menu.aboutToShow.connect(self.__showMenu) # build the show menu @@ -4516,7 +4656,13 @@ # build the 'Other Tools' menu self.othersMenu.setTearOffEnabled(True) - self.othersMenu.addAction(self.createSBOMAct) + self.othersMenu.addActions(self.othersGrp.actions()) + self.othersMenu.addSeparator() + + # build the 'Code Formatting' menu + self.formattingMenu.setTearOffEnabled(True) + self.formattingMenu.addActions(self.blackFormattingGrp.actions()) + self.formattingMenu.addSeparator() # build the project main menu menu.setTearOffEnabled(True) @@ -4545,6 +4691,8 @@ toolsMenu.addSeparator() self.menuCheckAct = toolsMenu.addMenu(self.checksMenu) toolsMenu.addSeparator() + self.menuFormattingAct = toolsMenu.addMenu(self.formattingMenu) + toolsMenu.addSeparator() self.menuMakeAct = toolsMenu.addMenu(self.makeMenu) toolsMenu.addSeparator() self.menuDiagramAct = toolsMenu.addMenu(self.graphicsMenu) @@ -4566,6 +4714,7 @@ self.menuPackagersAct.setEnabled(False) self.menuMakeAct.setEnabled(False) self.menuOtherToolsAct.setEnabled(False) + self.menuFormattingAct.setEnabled(False) self.__menu = menu self.__toolsMenu = toolsMenu @@ -5956,7 +6105,7 @@ ######################################################################### ## Below are methods implementing the 'SBOM' support ######################################################################### - + def __showContextMenuOthers(self): """ Private slot called before the 'Other Tools' menu is shown. @@ -5971,6 +6120,63 @@ import CycloneDXInterface CycloneDXInterface.createCycloneDXFile("<project>") + + ######################################################################### + ## Below are methods implementing the 'Code Formatting' support + ######################################################################### + + def __showContextMenuFormat(self): + """ + Private slot called before the 'Code Formatting' menu is shown. + """ + self.showMenu.emit("Formatting", self.othersMenu) + + @pyqtSlot() + def __aboutBlack(self): + """ + Private slot to show some information about the installed 'Black' tool. + """ + import black + + EricMessageBox.information( + None, + self.tr("About Black"), + self.tr("""<p><b>Black Version {0}</b></p>""" + """<p><i>Black</i> is the uncompromising Python code""" + """ formatter.</p>""").format(black.__version__) + ) + + def __performFormatWithBlack(self, action): + """ + Private method to format the project sources using the 'Black' tool. + + Following actions are supported. + <ul> + <li>BlackFormattingAction.Format - the code reformatting is performed</li> + <li>BlackFormattingAction.Check - a check is performed, if code formatting + is necessary</li> + <li>BlackFormattingAction.Diff - a unified diff of potential code formatting + changes is generated</li> + </ul> + + @param action formatting operation to be performed + @type BlackFormattingAction + """ + from CodeFormatting.BlackConfigurationDialog import BlackConfigurationDialog + from CodeFormatting.BlackFormattingDialog import BlackFormattingDialog + + if ericApp().getObject("ViewManager").checkAllDirty(): + dlg = BlackConfigurationDialog(withProject=True) + if dlg.exec() == QDialog.DialogCode.Accepted: + config = dlg.getConfiguration() + + formattingDialog = BlackFormattingDialog( + config, + self.getProjectFiles("SOURCES", normalized=True), + project=self, + action=action + ) + formattingDialog.exec() # # eflag: noqa = M601
--- a/src/eric7/Project/ProjectSourcesBrowser.py Mon Jul 11 16:09:04 2022 +0200 +++ b/src/eric7/Project/ProjectSourcesBrowser.py Mon Jul 11 16:42:50 2022 +0200 @@ -30,6 +30,8 @@ import Utilities import UI.PixmapCache +from CodeFormatting.BlackFormattingAction import BlackFormattingAction + class ProjectSourcesBrowser(ProjectBaseBrowser): """ @@ -118,6 +120,21 @@ self.checksMenu = QMenu(self.tr('Check')) self.checksMenu.aboutToShow.connect(self.__showContextMenuCheck) + self.formattingMenu = QMenu(self.tr("Code Formatting")) + self.formattingMenu.addAction( + self.tr("Format Code"), + lambda: self.__performFormatWithBlack(BlackFormattingAction.Format) + ) + self.formattingMenu.addAction( + self.tr("Check Formatting"), + lambda: self.__performFormatWithBlack(BlackFormattingAction.Check) + ) + self.formattingMenu.addAction( + self.tr("Formatting Diff"), + lambda: self.__performFormatWithBlack(BlackFormattingAction.Diff) + ) + self.formattingMenu.aboutToShow.connect(self.__showContextMenuFormatting) + self.menuShow = QMenu(self.tr('Show')) self.menuShow.addAction( self.tr('Code metrics...'), self.__showCodeMetrics) @@ -181,10 +198,9 @@ self.sourceMenu.addAction( self.tr('Add source directory...'), self.__addSourceDirectory) self.sourceMenu.addSeparator() - act = self.sourceMenu.addMenu(self.graphicsMenu) - self.sourceMenu.addSeparator() + self.sourceMenu.addMenu(self.graphicsMenu) self.sourceMenu.addMenu(self.checksMenu) - self.sourceMenu.addSeparator() + self.sourceMenu.addMenu(self.formattingMenu) self.sourceMenuActions["Show"] = self.sourceMenu.addMenu(self.menuShow) self.sourceMenu.addSeparator() self.__startAct = self.sourceMenu.addMenu(self.__startMenu) @@ -262,6 +278,7 @@ self.multiMenuActions.append(act) self.multiMenu.addSeparator() self.multiMenu.addMenu(self.checksMenu) + self.multiMenu.addMenu(self.formattingMenu) self.multiMenu.addSeparator() self.multiMenu.addAction( self.tr('Expand all directories'), self._expandAllDirs) @@ -286,7 +303,6 @@ self.tr('Add source directory...'), self.__addSourceDirectory) self.dirMenu.addSeparator() act = self.dirMenu.addMenu(self.graphicsMenu) - self.dirMenu.addSeparator() self.dirMenu.addMenu(self.checksMenu) self.dirMenu.addSeparator() self.dirMenu.addAction( @@ -1175,3 +1191,54 @@ """ fn = self.model().item(self.currentIndex()).fileName() ericApp().getObject("DebugUI").doCoverage(False, script=fn) + + ########################################################################### + ## Methods for the Code Formatting submenu + ########################################################################### + + def __showContextMenuFormatting(self): + """ + Private slot called before the Code Formatting menu is shown. + """ + self.showMenu.emit("Formatting", self.formattingMenu) + + def __performFormatWithBlack(self, action): + """ + Private method to format the selected project sources using the 'Black' tool. + + Following actions are supported. + <ul> + <li>BlackFormattingAction.Format - the code reformatting is performed</li> + <li>BlackFormattingAction.Check - a check is performed, if code formatting + is necessary</li> + <li>BlackFormattingAction.Diff - a unified diff of potential code formatting + changes is generated</li> + </ul> + + @param action formatting operation to be performed + @type BlackFormattingAction + """ + from CodeFormatting.BlackConfigurationDialog import BlackConfigurationDialog + from CodeFormatting.BlackFormattingDialog import BlackFormattingDialog + + files = [ + itm.fileName() + for itm in self.getSelectedItems( + [BrowserFileItem, BrowserClassItem, BrowserMethodItem, + BrowserClassAttributeItem, BrowserImportItem] + ) + if itm.isPython3File() + ] + + if ericApp().getObject("ViewManager").checkAllDirty(): + dlg = BlackConfigurationDialog(withProject=True) + if dlg.exec() == QDialog.DialogCode.Accepted: + config = dlg.getConfiguration() + + formattingDialog = BlackFormattingDialog( + config, + files, + project=self.project, + action=action + ) + formattingDialog.exec()
--- a/src/eric7/QScintilla/Editor.py Mon Jul 11 16:09:04 2022 +0200 +++ b/src/eric7/QScintilla/Editor.py Mon Jul 11 16:42:50 2022 +0200 @@ -48,6 +48,8 @@ from UI import PythonDisViewer +from CodeFormatting.BlackFormattingAction import BlackFormattingAction + EditorAutoCompletionListID = 1 TemplateCompletionListID = 2 ReferencesListID = 3 @@ -816,10 +818,12 @@ self.menuShow = self.__initContextMenuShow() self.graphicsMenu = self.__initContextMenuGraphics() self.autocompletionMenu = self.__initContextMenuAutocompletion() + self.codeFormattingMenu = self.__initContextMenuFormatting() self.__menus["Checks"] = self.checksMenu self.__menus["Show"] = self.menuShow self.__menus["Graphics"] = self.graphicsMenu self.__menus["Autocompletion"] = self.autocompletionMenu + self.__menus["Formatting"] = self.codeFormattingMenu self.toolsMenu = self.__initContextMenuTools() self.__menus["Tools"] = self.toolsMenu self.eolMenu = self.__initContextMenuEol() @@ -917,9 +921,8 @@ self.menu.addMenu(self.resourcesMenu) else: self.menuActs["Check"] = self.menu.addMenu(self.checksMenu) - self.menu.addSeparator() + self.menuActs["Formatting"] = self.menu.addMenu(self.codeFormattingMenu) self.menuActs["Show"] = self.menu.addMenu(self.menuShow) - self.menu.addSeparator() self.menuActs["Diagrams"] = self.menu.addMenu(self.graphicsMenu) self.menu.addSeparator() self.menuActs["Tools"] = self.menu.addMenu(self.toolsMenu) @@ -992,6 +995,32 @@ menu.aboutToShow.connect(self.__showContextMenuChecks) return menu + def __initContextMenuFormatting(self): + """ + Private method used to setup the Code Formatting context sub menu. + + @return reference to the generated menu + @rtype QMenu + """ + menu = QMenu(self.tr("Code Formatting")) + + menu.addAction( + self.tr("Format Code"), + lambda: self.__performFormatWithBlack(BlackFormattingAction.Format) + ) + menu.addAction( + self.tr("Check Formatting"), + lambda: self.__performFormatWithBlack(BlackFormattingAction.Check) + ) + menu.addAction( + self.tr("Formatting Diff"), + lambda: self.__performFormatWithBlack(BlackFormattingAction.Diff) + ) + + menu.aboutToShow.connect(self.__showContextMenuFormatting) + + return menu + def __initContextMenuTools(self): """ Private method used to setup the Tools context sub menu. @@ -5799,7 +5828,14 @@ menu. """ self.showMenu.emit("Tools", self.toolsMenu, self) - + + def __showContextMenuFormatting(self): + """ + Private slot handling the aboutToShow signal of the code formatting context + menu. + """ + self.showMenu.emit("Formatting", self.codeFormattingMenu, self) + def __reopenWithEncodingMenuTriggered(self, act): """ Private method to handle the rereading of the file with a selected @@ -8874,7 +8910,12 @@ @param y y-value of mouse screen position @type int """ - if not self.isCallTipActive() and not self.isListActive(): + if ( + not self.isCallTipActive() + and not self.isListActive() + and not self.menu.isVisible() + and not self.spellingMenu.isVisible() + ): if self.__mouseHoverHelp is not None and pos > 0 and y > 0: line, index = self.lineIndexFromPosition(pos) if index > 0: @@ -8934,3 +8975,44 @@ self.__showingMouseHoverHelp = True else: self.__cancelMouseHoverHelp() + + ####################################################################### + ## Methods implementing the Black code formatting interface + ####################################################################### + + def __performFormatWithBlack(self, action): + """ + Private method to format the source code using the 'Black' tool. + + Following actions are supported. + <ul> + <li>BlackFormattingAction.Format - the code reformatting is performed</li> + <li>BlackFormattingAction.Check - a check is performed, if code formatting + is necessary</li> + <li>BlackFormattingAction.Diff - a unified diff of potential code formatting + changes is generated</li> + </ul> + + @param action formatting operation to be performed + @type BlackFormattingAction + """ + from CodeFormatting.BlackConfigurationDialog import BlackConfigurationDialog + from CodeFormatting.BlackFormattingDialog import BlackFormattingDialog + + if not self.isModified() or self.saveFile(): + withProject = ( + self.fileName and + self.project.isOpen() and + self.project.isProjectSource(self.fileName) + ) + dlg = BlackConfigurationDialog(withProject=withProject) + if dlg.exec() == QDialog.DialogCode.Accepted: + config = dlg.getConfiguration() + + formattingDialog = BlackFormattingDialog( + config, + [self.fileName], + project=self.project, + action=action + ) + formattingDialog.exec()
--- a/src/eric7/Utilities/__init__.py Mon Jul 11 16:09:04 2022 +0200 +++ b/src/eric7/Utilities/__init__.py Mon Jul 11 16:42:50 2022 +0200 @@ -15,6 +15,7 @@ import getpass import glob import os +import pathlib import re import shlex import subprocess # secok @@ -720,20 +721,24 @@ """ Function returning a path, that is using native separator characters. - @param path path to be converted (string) - @return path with converted separator characters (string) + @param path path to be converted + @type str + @return path with converted separator characters + @rtype str """ - return QDir.toNativeSeparators(path) + return str(pathlib.PurePath(path)) def fromNativeSeparators(path): """ Function returning a path, that is using "/" separator characters. - @param path path to be converted (string) - @return path with converted separator characters (string) + @param path path to be converted + @type str + @return path with converted separator characters + @rtype str """ - return QDir.fromNativeSeparators(path) + return pathlib.PurePath(path).as_posix() def normcasepath(path):