Tue, 17 May 2022 17:23:07 +0200
Implemented the "Show Coverage" functionality and corrected the coverage related code in UnittestRunner.
--- a/eric7/DataViews/PyCoverageDialog.py Tue May 17 14:21:13 2022 +0200 +++ b/eric7/DataViews/PyCoverageDialog.py Tue May 17 17:23:07 2022 +0200 @@ -11,7 +11,7 @@ import os import time -from PyQt6.QtCore import pyqtSlot, Qt +from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt from PyQt6.QtWidgets import ( QDialog, QDialogButtonBox, QMenu, QHeaderView, QTreeWidgetItem, QApplication @@ -31,12 +31,17 @@ class PyCoverageDialog(QDialog, Ui_PyCoverageDialog): """ Class implementing a dialog to display the collected code coverage data. + + @signal openFile(str) emitted to open the given file in an editor """ + openFile = pyqtSignal(str) + def __init__(self, parent=None): """ Constructor - @param parent parent widget (QWidget) + @param parent parent widget + @type QWidget """ super().__init__(parent) self.setupUi(self) @@ -80,7 +85,9 @@ groups. @param lines list of integers + @type list of int @return string representing the list + @rtype str """ pairs = [] lines.sort() @@ -110,6 +117,9 @@ pair. @param pair pair of integers + @type tuple of (int, int + @return representation of the pair + @rtype str """ start, end = pair if start == end: @@ -124,12 +134,18 @@ """ Private method to create an entry in the result list. - @param file filename of file (string) - @param statements amount of statements (integer) - @param executed amount of executed statements (integer) - @param coverage percent of coverage (integer) - @param excluded list of excluded lines (string) - @param missing list of lines without coverage (string) + @param file filename of file + @type str + @param statements number of statements + @type int + @param executed number of executed statements + @type int + @param coverage percent of coverage + @type int + @param excluded list of excluded lines + @type str + @param missing list of lines without coverage + @type str """ itm = QTreeWidgetItem(self.resultList, [ file, @@ -146,14 +162,15 @@ font.setBold(True) for col in range(itm.columnCount()): itm.setFont(col, font) - + def start(self, cfn, fn): """ Public slot to start the coverage data evaluation. - @param cfn basename of the coverage file (string) + @param cfn basename of the coverage file + @type str @param fn file or list of files or directory to be checked - (string or list of strings) + @type str or list of str """ self.__cfn = cfn self.__fn = fn @@ -251,7 +268,7 @@ total_exceptions)) self.__finish() - + def __finish(self): """ Private slot called when the action finished or the user pressed the @@ -271,12 +288,13 @@ self.summaryList.header().resizeSections( QHeaderView.ResizeMode.ResizeToContents) self.summaryList.header().setStretchLastSection(True) - + def on_buttonBox_clicked(self, button): """ Private slot called by a button of the button box clicked. - @param button button that was clicked (QAbstractButton) + @param button button that was clicked + @type QAbstractButton """ if button == self.buttonBox.button( QDialogButtonBox.StandardButton.Close @@ -286,12 +304,13 @@ QDialogButtonBox.StandardButton.Cancel ): self.__finish() - + def __showContextMenu(self, coord): """ Private slot to show the context menu of the listview. - @param coord the position of the mouse pointer (QPoint) + @param coord position of the mouse pointer + @type QPoint """ itm = self.resultList.itemAt(coord) if itm: @@ -301,27 +320,32 @@ self.annotate.setEnabled(False) self.openAct.setEnabled(False) self.__menu.popup(self.mapToGlobal(coord)) - + def __openFile(self, itm=None): """ Private slot to open the selected file. - @param itm reference to the item to be opened (QTreeWidgetItem) + @param itm reference to the item to be opened + @type QTreeWidgetItem """ if itm is None: itm = self.resultList.currentItem() fn = itm.text(0) - vm = ericApp().getObject("ViewManager") - vm.openSourceFile(fn) - editor = vm.getOpenEditor(fn) - editor.codeCoverageShowAnnotations() - + try: + vm = ericApp().getObject("ViewManager") + vm.openSourceFile(fn) + editor = vm.getOpenEditor(fn) + editor.codeCoverageShowAnnotations(coverageFile=self.cfn) + except KeyError: + self.openFile.emit(fn) + + # TODO: Coverage.annotate is deprecated def __annotate(self): """ Private slot to handle the annotate context menu action. - This method produce an annotated coverage file of the + This method produces an annotated coverage file of the selected file. """ itm = self.resultList.currentItem() @@ -331,7 +355,8 @@ cover.exclude(self.excludeList[0]) cover.load() cover.annotate([fn], None, True) - + + # TODO: Coverage.annotate is deprecated def __annotateAll(self): """ Private slot to handle the annotate all context menu action. @@ -367,7 +392,7 @@ cover.annotate([file], None) # , True) progress.setValue(len(files)) - + def __erase(self): """ Private slot to handle the erase context menu action. @@ -382,7 +407,7 @@ self.reloadButton.setEnabled(False) self.resultList.clear() self.summaryList.clear() - + def __deleteAnnotated(self): """ Private slot to handle the delete annotated context menu action. @@ -394,7 +419,7 @@ for file in files: with contextlib.suppress(OSError): os.remove(file) - + @pyqtSlot() def on_reloadButton_clicked(self): """
--- a/eric7/Project/Project.py Tue May 17 14:21:13 2022 +0200 +++ b/eric7/Project/Project.py Tue May 17 17:23:07 2022 +0200 @@ -3363,6 +3363,9 @@ """ Public method to return the main script filename. + The normalized name is the name of the main script prepended with + the project path. + @param normalized flag indicating a normalized filename is wanted (boolean) @return filename of the projects main script (string)
--- a/eric7/QScintilla/Editor.py Tue May 17 14:21:13 2022 +0200 +++ b/eric7/QScintilla/Editor.py Tue May 17 17:23:07 2022 +0200 @@ -522,6 +522,9 @@ self.autosaveEnabled = Preferences.getEditor("AutosaveInterval") > 0 self.autosaveManuallyDisabled = False + # code coverage related attributes + self.__coverageFile = "" + self.__initContextMenu() self.__initContextMenuMargins() @@ -5662,6 +5665,8 @@ self.isPyFile() ) + coEnable |= bool(self.__coverageFile) + # now check for syntax errors if self.hasSyntaxErrors(): coEnable = False @@ -6052,6 +6057,10 @@ """ files = [] + if bool(self.__coverageFile): + # return the path of a previously used coverage file + return self.__coverageFile + # first check if the file belongs to a project and there is # a project coverage file if ( @@ -6107,6 +6116,7 @@ Private method to handle the code coverage context menu action. """ fn = self.__getCodeCoverageFile() + self.__coverageFile = fn if fn: from DataViews.PyCoverageDialog import PyCoverageDialog self.codecoverage = PyCoverageDialog() @@ -6120,16 +6130,27 @@ if self.showingNotcoveredMarkers: self.codeCoverageShowAnnotations(silent=True) - def codeCoverageShowAnnotations(self, silent=False): + def codeCoverageShowAnnotations(self, silent=False, coverageFile=None): """ Public method to handle the show code coverage annotations context menu action. - @param silent flag indicating to not show any dialog (boolean) + @param silent flag indicating to not show any dialog (defaults to + False) + @type bool (optional) + @param coverageFile path of the file containing the code coverage data + (defaults to None) + @type str (optional) """ self.__codeCoverageHideAnnotations() - fn = self.__getCodeCoverageFile() + fn = ( + coverageFile + if bool(coverageFile) else + self.__getCodeCoverageFile() + ) + self.__coverageFile = fn + if fn: from coverage import Coverage cover = Coverage(data_file=fn)
--- a/eric7/Testing/Interfaces/TestExecutorBase.py Tue May 17 14:21:13 2022 +0200 +++ b/eric7/Testing/Interfaces/TestExecutorBase.py Tue May 17 17:23:07 2022 +0200 @@ -60,6 +60,7 @@ failedOnly: bool # run failed tests only collectCoverage: bool # coverage collection flag eraseCoverage: bool # erase coverage data first + coverageFile: str # name of the coverage data file class TestExecutorBase(QObject):
--- a/eric7/Testing/Interfaces/UnittestExecutor.py Tue May 17 14:21:13 2022 +0200 +++ b/eric7/Testing/Interfaces/UnittestExecutor.py Tue May 17 17:23:07 2022 +0200 @@ -109,6 +109,9 @@ args.append("--cover") if config.eraseCoverage: args.append("--cover-erase") + if config.coverageFile: + args.append("--cover-file") + args.append(config.coverageFile) if config.failedOnly: args.append("--failed-only")
--- a/eric7/Testing/Interfaces/UnittestRunner.py Tue May 17 14:21:13 2022 +0200 +++ b/eric7/Testing/Interfaces/UnittestRunner.py Tue May 17 17:23:07 2022 +0200 @@ -286,12 +286,18 @@ if failfast: argv.remove("--failfast") - coverage = "--cover" in argv - if coverage: + collectCoverage = "--cover" in argv + if collectCoverage: argv.remove("--cover") coverageErase = "--cover-erase" in argv if coverageErase: argv.remove("--cover-erase") + if "--cover-file" in argv: + index = argv.index("--cover-file") + covDataFile = argv[index + 1] + del argv[index:index + 2] + else: + covDataFile = "" if argv and argv[0] == "--failed-only": if discover: @@ -315,6 +321,34 @@ elif discoveryStart: sys.path.insert(1, os.path.abspath(discoveryStart)) + # setup test coverage + if collectCoverage: + if not covDataFile: + if discover: + covname = os.path.join(discoveryStart, "test") + elif testFileName: + covname = os.path.splitext( + os.path.abspath(testFileName))[0] + else: + covname = "test" + covDataFile = "{0}.coverage".format(covname) + if not os.path.isabs(covDataFile): + covDataFile = os.path.abspath(covDataFile) + + sys.path.insert( + 2, + os.path.abspath(os.path.join( + os.path.dirname(__file__), "..", "..", "DebugClients", "Python" + )) + ) + from DebugClients.Python.coverage import Coverage + cover = Coverage(data_file=covDataFile) + if coverageErase: + cover.erase() + cover.start() + else: + cover = None + try: testLoader = unittest.TestLoader() if discover and not failed: @@ -354,33 +388,11 @@ } writer.write(collectedTests) - # setup test coverage - if coverage: - if discover: - covname = os.path.join(discoveryStart, "unittest") - elif testFileName: - covname = os.path.splitext( - os.path.abspath(testFileName))[0] - else: - covname = "unittest" - covDataFile = "{0}.coverage".format(covname) - if not os.path.isabs(covDataFile): - covDataFile = os.path.abspath(covDataFile) - - from DebugClients.Python.coverage import coverage as cov - cover = cov(data_file=covDataFile) - if coverageErase: - cover.erase() - else: - cover = None - testResult = EricTestResult(writer, failfast) startTestRun = getattr(testResult, 'startTestRun', None) if startTestRun is not None: startTestRun() try: - if cover: - cover.start() test.run(testResult) finally: if cover:
--- a/eric7/Testing/TestingWidget.py Tue May 17 14:21:13 2022 +0200 +++ b/eric7/Testing/TestingWidget.py Tue May 17 17:23:07 2022 +0200 @@ -50,8 +50,6 @@ STOPPED = 2 # test run finished -# TODO: add a "Show Coverage" function using PyCoverageDialog - class TestingWidget(QWidget, Ui_TestingWidget): """ Class implementing a widget to orchestrate unit test execution. @@ -102,6 +100,16 @@ self.testComboBox.lineEdit().setClearButtonEnabled(True) # create some more dialog buttons for orchestration + self.__showCoverageButton = self.buttonBox.addButton( + self.tr("Show Coverage..."), + QDialogButtonBox.ButtonRole.ActionRole) + self.__showCoverageButton.setToolTip( + self.tr("Show code coverage in a new dialog")) + self.__showCoverageButton.setWhatsThis(self.tr( + """<b>Show Coverage...</b>""" + """<p>This button opens a dialog containing the collected code""" + """ coverage data.</p>""")) + self.__startButton = self.buttonBox.addButton( self.tr("Start"), QDialogButtonBox.ButtonRole.ActionRole) @@ -160,6 +168,9 @@ self.__recentEnvironment = "" self.__failedTests = [] + self.__coverageFile = "" + self.__coverageDialog = None + self.__editors = [] self.__testExecutor = None @@ -477,6 +488,17 @@ self.__stopButton.setDefault( self.__mode == TestingWidgetModes.RUNNING) + # Code coverage button + self.__showCoverageButton.setEnabled( + self.__mode == TestingWidgetModes.STOPPED and + bool(self.__coverageFile) and + ( + (self.discoverCheckBox.isChecked() and + bool(self.discoveryPicker.currentText())) or + bool(self.testsuitePicker.currentText()) + ) + ) + # Close button self.buttonBox.button( QDialogButtonBox.StandardButton.Close @@ -517,7 +539,6 @@ self.__runCount = 0 self.__coverageFile = "" - # TODO: implement the handling of the 'Show Coverage' button self.sbLabel.setText(self.tr("Running")) self.tabWidget.setCurrentIndex(1) @@ -605,6 +626,8 @@ self.__stopTests() elif button == self.__startFailedButton: self.startTests(failedOnly=True) + elif button == self.__showCoverageButton: + self.__showCoverageDialog() @pyqtSlot(int) def on_venvComboBox_currentIndexChanged(self, index): @@ -703,11 +726,16 @@ self.sbLabel.setText(self.tr("Preparing Testsuite")) QCoreApplication.processEvents() + if self.__project: + mainScript = self.__project.getMainScript(True) + coverageFile = os.path.splitext(mainScript)[0] + ".coverage" + else: + coverageFile = "" interpreter = self.__venvManager.getVirtualenvInterpreter( self.__recentEnvironment) config = TestConfig( interpreter=interpreter, - discover=self.discoverCheckBox.isChecked(), + discover=discover, discoveryStart=discoveryStart, testFilename=testFileName, testName=testName, @@ -715,6 +743,7 @@ failedOnly=failedOnly, collectCoverage=self.coverageCheckBox.isChecked(), eraseCoverage=self.coverageEraseCheckBox.isChecked(), + coverageFile=coverageFile, ) self.__testExecutor = self.__frameworkRegistry.createExecutor( @@ -888,8 +917,25 @@ @type str """ self.__coverageFile = coverageFile + + @pyqtSlot() + def __showCoverageDialog(self): + """ + Private slot to show a code coverage dialog for the most recent test + run. + """ + if self.__coverageDialog is None: + from DataViews.PyCoverageDialog import PyCoverageDialog + self.__coverageDialog = PyCoverageDialog(self) + self.__coverageDialog.openFile.connect(self.__openEditor) - # TODO: implement the handling of the 'Show Coverage' button + if self.discoverCheckBox.isChecked(): + testDir = self.discoveryPicker.currentText() + else: + testDir = os.path.dirname(self.testsuitePicker.currentText()) + if testDir: + self.__coverageDialog.show() + self.__coverageDialog.start(self.__coverageFile, testDir) @pyqtSlot(str) def __setStatusLabel(self, statusText): @@ -936,7 +982,7 @@ else: self.__openEditor(filename, lineno) - def __openEditor(self, filename, linenumber): + def __openEditor(self, filename, linenumber=1): """ Private method to open an editor window for the given file. @@ -945,8 +991,8 @@ @param filename path of the file to be opened @type str - @param linenumber line number to place the cursor at - @type int + @param linenumber line number to place the cursor at (defaults to 1) + @type int (optional) """ from QScintilla.MiniEditor import MiniEditor editor = MiniEditor(filename, "Python3", self)