Mon, 23 May 2022 17:24:39 +0200
Merged with branch 'unittest' to get the changed functionality to the main development branch.
--- a/docs/changelog Sat May 21 19:49:34 2022 +0200 +++ b/docs/changelog Mon May 23 17:24:39 2022 +0200 @@ -15,6 +15,7 @@ - Testing -- reworked the former unittest interface to allow to support testing frameworks other than "unittest" + -- implemented support for the "unittest" and "pytest" frameworks - Wizards -- extended the QInputDialog wizard to support the 'getMultiLineText()' function
--- a/eric7/DataViews/PyCoverageDialog.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/DataViews/PyCoverageDialog.py Mon May 23 17:24:39 2022 +0200 @@ -173,12 +173,25 @@ @param fn file or list of files or directory to be checked @type str or list of str """ + # initialize the dialog + self.resultList.clear() + self.summaryList.clear() + self.cancelled = False + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setEnabled(False) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Cancel).setEnabled(True) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Cancel).setDefault(True) + self.__cfn = cfn self.__fn = fn - self.basename = os.path.splitext(cfn)[0] - - self.cfn = "{0}.coverage".format(self.basename) + self.cfn = ( + cfn + if cfn.endswith(".coverage") else + "{0}.coverage".format(os.path.splitext(cfn)[0]) + ) if isinstance(fn, list): files = fn @@ -441,20 +454,11 @@ """ Private slot to reload the coverage info. """ - self.resultList.clear() - self.summaryList.clear() self.reload = True excludePattern = self.excludeCombo.currentText() if excludePattern in self.excludeList: self.excludeList.remove(excludePattern) self.excludeList.insert(0, excludePattern) - self.cancelled = False - self.buttonBox.button( - QDialogButtonBox.StandardButton.Close).setEnabled(False) - self.buttonBox.button( - QDialogButtonBox.StandardButton.Cancel).setEnabled(True) - self.buttonBox.button( - QDialogButtonBox.StandardButton.Cancel).setDefault(True) self.start(self.__cfn, self.__fn) @pyqtSlot(QTreeWidgetItem, int)
--- a/eric7/EricNetwork/EricJsonStreamReader.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/EricNetwork/EricJsonStreamReader.py Mon May 23 17:24:39 2022 +0200 @@ -105,6 +105,7 @@ Private slot handling a disconnect of the writer. """ if self.__connection is not None: + self.__receiveJson() # read all buffered data first self.__connection.close() self.__connection = None @@ -114,10 +115,8 @@ """ Private slot handling received data from the writer. """ - connection = self.__connection - - while connection and connection.canReadLine(): - dataStr = connection.readLine() + while self.__connection and self.__connection.canReadLine(): + dataStr = self.__connection.readLine() jsonLine = bytes(dataStr).decode("utf-8", 'backslashreplace') #- print("JSON Reader ({0}): {1}".format(self.__name, jsonLine))
--- a/eric7/EricWidgets/EricPlainTextDialog.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/EricWidgets/EricPlainTextDialog.py Mon May 23 17:24:39 2022 +0200 @@ -18,16 +18,18 @@ """ Class implementing a dialog to show some plain text. """ - def __init__(self, title="", text="", parent=None): + def __init__(self, title="", text="", readOnly=True, parent=None): """ Constructor - @param title title of the window - @type str - @param text text to be shown - @type str - @param parent reference to the parent widget - @type QWidget + @param title title of the dialog (defaults to "") + @type str (optional) + @param text text to be shown (defaults to "") + @type str (optional) + @param readOnly flag indicating a read-only dialog (defaults to True) + @type bool (optional) + @param parent reference to the parent widget (defaults to None) + @type QWidget (optional) """ super().__init__(parent) self.setupUi(self) @@ -39,6 +41,7 @@ self.setWindowTitle(title) self.textEdit.setPlainText(text) + self.textEdit.setReadOnly(readOnly) @pyqtSlot() def on_copyButton_clicked(self): @@ -48,3 +51,12 @@ txt = self.textEdit.toPlainText() cb = QGuiApplication.clipboard() cb.setText(txt) + + def toPlainText(self): + """ + Public method to get the plain text. + + @return contents of the plain text edit + @rtype str + """ + return self.textEdit.toPlainText()
--- a/eric7/EricWidgets/EricPlainTextDialog.ui Sat May 21 19:49:34 2022 +0200 +++ b/eric7/EricWidgets/EricPlainTextDialog.ui Mon May 23 17:24:39 2022 +0200 @@ -6,8 +6,8 @@ <rect> <x>0</x> <y>0</y> - <width>500</width> - <height>400</height> + <width>650</width> + <height>600</height> </rect> </property> <property name="windowTitle">
--- a/eric7/Project/Project.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/Project/Project.py Mon May 23 17:24:39 2022 +0200 @@ -3367,8 +3367,9 @@ the project path. @param normalized flag indicating a normalized filename is wanted - (boolean) - @return filename of the projects main script (string) + @type bool + @return filename of the projects main script + @rtype str """ if self.pdata["MAINSCRIPT"]: if normalized: @@ -3376,15 +3377,16 @@ else: return self.pdata["MAINSCRIPT"] else: - return None + return "" def getSources(self, normalized=False): """ Public method to return the source script files. @param normalized flag indicating a normalized filename is wanted - (boolean) - @return list of the projects scripts (list of string) + @type bool + @return list of the projects scripts + @rtype list of str """ return self.getProjectFiles("SOURCES", normalized=normalized) @@ -5068,14 +5070,7 @@ " current project. Aborting")) return - # determine name of coverage file to be used - files = [] - for filename in [fn] + Utilities.getTestFileNames(fn): - basename = os.path.splitext(filename)[0] - f = "{0}.coverage".format(basename) - if os.path.isfile(f): - files.append(f) - + files = Utilities.getCoverageFileNames(fn) if files: if len(files) > 1: fn, ok = QInputDialog.getItem( @@ -5113,14 +5108,7 @@ " current project. Aborting")) return - # determine name of profile file to be used - files = [] - for filename in [fn] + Utilities.getTestFileNames(fn): - basename = os.path.splitext(filename)[0] - f = "{0}.profile".format(basename) - if os.path.isfile(f): - files.append(f) - + files = Utilities.getProfileFileNames(fn) if files: if len(files) > 1: fn, ok = QInputDialog.getItem( @@ -5146,22 +5134,17 @@ Private slot called before the show menu is shown. """ fn = self.getMainScript(True) - if fn is not None: - filenames = [os.path.splitext(f)[0] - for f in [fn] + Utilities.getTestFileNames(fn)] - self.codeProfileAct.setEnabled(any([ - os.path.isfile("{0}.profile".format(f)) - for f in filenames - ])) - self.codeCoverageAct.setEnabled( - self.isPy3Project() and any([ - os.path.isfile("{0}.coverage".format(f)) - for f in filenames - ]) - ) - else: - self.codeProfileAct.setEnabled(False) - self.codeCoverageAct.setEnabled(False) + if not fn: + fn = self.getProjectPath() + + self.codeProfileAct.setEnabled( + self.isPy3Project() and + bool(Utilities.getProfileFileName(fn)) + ) + self.codeCoverageAct.setEnabled( + self.isPy3Project() and + bool(Utilities.getCoverageFileNames(fn)) + ) self.showMenu.emit("Show", self.menuShow)
--- a/eric7/Project/ProjectSourcesBrowser.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/Project/ProjectSourcesBrowser.py Mon May 23 17:24:39 2022 +0200 @@ -755,33 +755,26 @@ # a project coverage file fn = self.project.getMainScript(True) if fn is not None: - filenames = [os.path.splitext(f)[0] - for f in [fn] + Utilities.getTestFileNames(fn)] - prEnable = any([ - os.path.isfile("{0}.profile".format(f)) - for f in filenames - ]) + prEnable = ( + self.project.isPy3Project() and + bool(Utilities.getProfileFileNames(fn)) + ) coEnable = ( self.project.isPy3Project() and - any([ - os.path.isfile("{0}.coverage".format(f)) - for f in filenames - ]) + bool(Utilities.getCoverageFileNames(fn)) ) # now check the selected item itm = self.model().item(self.currentIndex()) fn = itm.fileName() if fn is not None: - basename = os.path.splitext(fn)[0] - prEnable = ( - prEnable or - os.path.isfile("{0}.profile".format(basename)) + prEnable |= ( + itm.isPython3File() and + bool(Utilities.getProfileFileNames(fn)) ) - coEnable = ( - (coEnable or - os.path.isfile("{0}.coverage".format(basename))) and - itm.isPython3File() + coEnable |= ( + itm.isPython3File() and + bool(Utilities.getCoverageFileName(fn)) ) self.profileMenuAction.setEnabled(prEnable) @@ -985,25 +978,17 @@ fn = itm.fileName() pfn = self.project.getMainScript(True) - files = [] + files = set() if pfn is not None: - for filename in [pfn] + Utilities.getTestFileNames(pfn): - basename = os.path.splitext(filename)[0] - f = "{0}.coverage".format(basename) - if os.path.isfile(f): - files.append(f) + files |= set(Utilities.getCoverageFileNames(pfn)) if fn is not None: - for filename in [fn] + Utilities.getTestFileNames(fn): - basename = os.path.splitext(filename)[0] - f = "{0}.coverage".format(basename) - if os.path.isfile(f): - files.append(f) + files |= set(Utilities.getCoverageFileNames(fn)) - if files: + if list(files): if len(files) > 1: - pfn, ok = QInputDialog.getItem( + cfn, ok = QInputDialog.getItem( None, self.tr("Code Coverage"), self.tr("Please select a coverage file"), @@ -1012,14 +997,14 @@ if not ok: return else: - pfn = files[0] + cfn = files[0] else: return from DataViews.PyCoverageDialog import PyCoverageDialog self.codecoverage = PyCoverageDialog() self.codecoverage.show() - self.codecoverage.start(pfn, fn) + self.codecoverage.start(cfn, fn) def __showProfileData(self): """ @@ -1029,23 +1014,15 @@ fn = itm.fileName() pfn = self.project.getMainScript(True) - files = [] + files = set() if pfn is not None: - for filename in [pfn] + Utilities.getTestFileNames(pfn): - basename = os.path.splitext(filename)[0] - f = "{0}.profile".format(basename) - if os.path.isfile(f): - files.append(f) + files |= set(Utilities.getProfileFileNames(pfn)) if fn is not None: - for filename in [fn] + Utilities.getTestFileNames(fn): - basename = os.path.splitext(filename)[0] - f = "{0}.profile".format(basename) - if os.path.isfile(f): - files.append(f) - - if files: + files |= set(Utilities.getProfileFileNames(fn)) + + if list(files): if len(files) > 1: pfn, ok = QInputDialog.getItem( None,
--- a/eric7/QScintilla/Editor.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/QScintilla/Editor.py Mon May 23 17:24:39 2022 +0200 @@ -5632,35 +5632,25 @@ ): fn = self.project.getMainScript(True) if fn is not None: - filenames = [os.path.splitext(f)[0] - for f in [fn] + Utilities.getTestFileNames(fn)] - prEnable = any([ - os.path.isfile("{0}.profile".format(f)) - for f in filenames - ]) + prEnable = ( + self.project.isPy3Project() and + bool(Utilities.getProfileFileNames(fn)) + ) coEnable = ( self.project.isPy3Project() and - any([ - os.path.isfile("{0}.coverage".format(f)) - for f in filenames - ]) + bool(Utilities.getCoverageFileNames(fn)) ) # now check ourselves fn = self.getFileName() if fn is not None: - filenames = [os.path.splitext(f)[0] - for f in [fn] + Utilities.getTestFileNames(fn)] - prEnable |= any([ - os.path.isfile("{0}.profile".format(f)) - for f in filenames - ]) + prEnable |= ( + self.project.isPy3Project() and + bool(Utilities.getProfileFileName(fn)) + ) coEnable |= ( self.project.isPy3Project() and - any([ - os.path.isfile("{0}.coverage".format(f)) - for f in filenames - ]) + bool(Utilities.getCoverageFileName(fn)) ) coEnable |= bool(self.__coverageFile) @@ -6051,9 +6041,10 @@ Private method to get the file name of the file containing coverage info. - @return file name of the coverage file (string) - """ - files = [] + @return file name of the coverage file + @rtype str + """ + files = set() if bool(self.__coverageFile): # return the path of a previously used coverage file @@ -6065,29 +6056,19 @@ self.project.isOpen() and self.project.isProjectSource(self.fileName) ): - fn = self.project.getMainScript(True) - if fn is not None: - for filename in [fn] + Utilities.getTestFileNames(fn): - basename = os.path.splitext(filename)[0] - f = "{0}.coverage".format(basename) - if os.path.isfile(f): - files.append(f) + pfn = self.project.getMainScript(True) + if pfn is not None: + files |= set(Utilities.getCoverageFileNames(pfn)) # now check, if there are coverage files belonging to ourselves fn = self.getFileName() if fn is not None: - for filename in [fn] + Utilities.getTestFileNames(fn): - basename = os.path.splitext(filename)[0] - f = "{0}.coverage".format(basename) - if os.path.isfile(f): - files.append(f) - - # make the list unique - files = list(set(files)) - + files |= set(Utilities.getCoverageFileNames(fn)) + + files = list(files) if files: if len(files) > 1: - fn, ok = QInputDialog.getItem( + cfn, ok = QInputDialog.getItem( self, self.tr("Code Coverage"), self.tr("Please select a coverage file"), @@ -6096,11 +6077,11 @@ if not ok: return "" else: - fn = files[0] - else: - fn = None - - return fn + cfn = files[0] + else: + cfn = None + + return cfn def __showCodeCoverage(self): """ @@ -6242,7 +6223,7 @@ """ Private method to handle the show profile data context menu action. """ - files = [] + files = set() # first check if the file belongs to a project and there is # a project profile file @@ -6252,24 +6233,14 @@ ): fn = self.project.getMainScript(True) if fn is not None: - for filename in [fn] + Utilities.getTestFileNames(fn): - basename = os.path.splitext(filename)[0] - f = "{0}.profile".format(basename) - if os.path.isfile(f): - files.append(f) + files |= set(Utilities.getProfileFileNames(fn)) # now check, if there are profile files belonging to ourselves fn = self.getFileName() if fn is not None: - for filename in [fn] + Utilities.getTestFileNames(fn): - basename = os.path.splitext(filename)[0] - f = "{0}.profile".format(basename) - if os.path.isfile(f): - files.append(f) - - # make the list unique - files = list(set(files)) - + files |= set(Utilities.getProfileFileNames(fn)) + + files = list(files) if files: if len(files) > 1: fn, ok = QInputDialog.getItem(
--- a/eric7/Testing/Interfaces/PytestExecutor.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/Testing/Interfaces/PytestExecutor.py Mon May 23 17:24:39 2022 +0200 @@ -11,12 +11,13 @@ import json import os -from PyQt6.QtCore import QProcess +from PyQt6.QtCore import pyqtSlot, QProcess -from .TestExecutorBase import TestExecutorBase +from EricNetwork.EricJsonStreamReader import EricJsonReader + +from .TestExecutorBase import TestExecutorBase, TestResult, TestResultCategory -# TODO: implement 'pytest' support in PytestExecutor class PytestExecutor(TestExecutorBase): """ Class implementing the executor for the 'pytest' framework. @@ -26,6 +27,25 @@ runner = os.path.join(os.path.dirname(__file__), "PytestRunner.py") + def __init__(self, testWidget): + """ + Constructor + + @param testWidget reference to the unit test widget + @type TestingWidget + """ + super().__init__(testWidget) + + self.__statusDisplayMapping = { + "failed": self.tr("Failure"), + "skipped": self.tr("Skipped"), + "xfailed": self.tr("Expected Failure"), + "xpassed": self.tr("Unexpected Success"), + "passed": self.tr("Success"), + } + + self.__config = None + def getVersions(self, interpreter): """ Public method to get the test framework version and version information @@ -49,3 +69,242 @@ return json.loads(line) return {} + + def hasCoverage(self, interpreter): + """ + Public method to get the test framework version and version information + of its installed plugins. + + @param interpreter interpreter to be used for the test + @type str + @return flag indicating the availability of coverage functionality + @rtype bool + """ + versions = self.getVersions(interpreter) + if "plugins" in versions: + return any(plugin["name"] == "pytest-cov" + for plugin in versions["plugins"]) + + return False + + def createArguments(self, config): + """ + Public method to create the arguments needed to start the test process. + + @param config configuration for the test execution + @type TestConfig + @return list of process arguments + @rtype list of str + """ + # + # collectCoverage: --cov= + --cov-report= to suppress report generation + # eraseCoverage: --cov-append if eraseCoverage is False + # coverageFile + args = [ + PytestExecutor.runner, + "runtest", + self.reader.address(), + str(self.reader.port()), + "--quiet", + ] + + if config.failFast: + args.append("--exitfirst") + + if config.failedOnly: + args.append("--last-failed") + else: + args.append("--cache-clear") + + if config.collectCoverage: + args.extend([ + "--cov=.", + "--cov-report=" + ]) + if not config.eraseCoverage: + args.append("--cov-append") + + if config.testFilename: + if config.testName: + args.append("{0}::{1}".format( + config.testFilename, + config.testName.replace(".", "::") + )) + else: + args.append(config.testFilename) + + return args + + def start(self, config, pythonpath): + """ + Public method to start the testing process. + + @param config configuration for the test execution + @type TestConfig + @param pythonpath list of directories to be added to the Python path + @type list of str + """ + self.reader = EricJsonReader(name="Unittest Reader", parent=self) + self.reader.dataReceived.connect(self.__processData) + + self.__config = config + + if config.discoveryStart: + pythonpath.insert(0, os.path.abspath(config.discoveryStart)) + elif config.testFilename: + pythonpath.insert( + 0, os.path.abspath(os.path.dirname(config.testFilename))) + + if config.discover: + self.__rootdir = config.discoveryStart + elif config.testFilename: + self.__rootdir = os.path.dirname(config.testFilename) + else: + self.__rootdir = "" + + super().start(config, pythonpath) + + def finished(self): + """ + Public method handling the unit test process been finished. + + This method should read the results (if necessary) and emit the signal + testFinished. + """ + if self.__config.collectCoverage: + self.coverageDataSaved.emit( + os.path.join(self.__rootdir, ".coverage")) + + self.__config = None + + self.reader.close() + + output = self.readAllOutput() + self.testFinished.emit([], output) + + @pyqtSlot(object) + def __processData(self, data): + """ + Private slot to process the received data. + + @param data data object received + @type dict + """ + # test configuration + if data["event"] == "config": + self.__rootdir = data["root"] + + # error collecting tests + elif data["event"] == "collecterror": + name = self.__normalizeModuleName(data["nodeid"]) + self.collectError.emit([(name, data["report"])]) + + # tests collected + elif data["event"] == "collected": + self.collected.emit([ + (data["nodeid"], + self.__nodeid2testname(data["nodeid"]), + "") + ]) + + # test started + elif data["event"] == "starttest": + self.startTest.emit( + (data["nodeid"], + self.__nodeid2testname(data["nodeid"]), + "") + ) + + # test result + elif data["event"] == "result": + if data["status"] in ("failed", "xpassed") or data["with_error"]: + category = TestResultCategory.FAIL + elif data["status"] in ("passed", "xfailed"): + category = TestResultCategory.OK + else: + category = TestResultCategory.SKIP + + status = ( + self.tr("Error") + if data["with_error"] else + self.__statusDisplayMapping[data["status"]] + ) + + message = data.get("message", "") + extraText = data.get("report", "") + reportPhase = data.get("report_phase") + if reportPhase in ("setup", "teardown"): + message = ( + self.tr("ERROR at {0}: {1}", "phase, message") + .format(reportPhase, message) + ) + extraText = ( + self.tr("ERROR at {0}: {1}", "phase, extra text") + .format(reportPhase, extraText) + ) + sections = data.get("sections", []) + if sections: + extraText += "\n" + for heading, text in sections: + extraText += "----- {0} -----\n{1}".format(heading, text) + + duration = data.get("duration_s", None) + if duration: + # convert to ms + duration *= 1000 + + filename = data["filename"] + if self.__rootdir: + filename = os.path.join(self.__rootdir, filename) + + self.testResult.emit(TestResult( + category=category, + status=status, + name=self.__nodeid2testname(data["nodeid"]), + id=data["nodeid"], + description="", + message=message, + extra=extraText.rstrip().splitlines(), + duration=duration, + filename=filename, + lineno=data.get("linenumber", 0) + 1, + # pytest reports 0-based line numbers + )) + + # test run finished + elif data["event"] == "finished": + self.testRunFinished.emit(data["tests"], data["duration_s"]) + + def __normalizeModuleName(self, name): + r""" + Private method to convert a module name reported by pytest to Python + conventions. + + This method strips the extensions '.pyw' and '.py' first and replaces + '/' and '\' thereafter. + + @param name module name reported by pytest + @type str + @return module name iaw. Python conventions + @rtype str + """ + return (name + .replace(".pyw", "") + .replace(".py", "") + .replace("/", ".") + .replace("\\", ".")) + + def __nodeid2testname(self, nodeid): + """ + Private method to convert a nodeid to a test name. + + @param nodeid nodeid to be converted + @type str + @return test name + @rtype str + """ + module, name = nodeid.split("::", 1) + module = self.__normalizeModuleName(module) + name = name.replace("::", ".") + testname, name = "{0}.{1}".format(module, name).rsplit(".", 1) + return "{0} ({1})".format(name, testname)
--- a/eric7/Testing/Interfaces/PytestRunner.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/Testing/Interfaces/PytestRunner.py Mon May 23 17:24:39 2022 +0200 @@ -8,9 +8,14 @@ """ import json +import os import sys +import time -# TODO: implement 'pytest' support in PytestRunner +sys.path.insert( + 2, + os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +) class GetPluginVersionsPlugin(): @@ -51,13 +56,208 @@ return self.versions +class EricPlugin(): + """ + Class implementing a pytest plugin which reports the data in a format + suitable for the PytestExecutor. + """ + def __init__(self, writer): + """ + Constructor + + @param writer reference to the object to write the results to + @type EricJsonWriter + """ + self.__writer = writer + + self.__testsRun = 0 + + def __initializeReportData(self): + """ + Private method to initialize attributes for data collection. + """ + self.__status = '---' + self.__duration = 0 + self.__report = [] + self.__reportPhase = "" + self.__sections = [] + self.__hadError = False + self.__wasSkipped = False + self.__wasXfail = False + + def pytest_report_header(self, config, startdir): + """ + Public method called by pytest before any reporting. + + @param config reference to the configuration object + @type Config + @param startdir starting directory + @type LocalPath + """ + self.__writer.write({ + "event": "config", + "root": str(config.rootdir) + }) + + def pytest_collectreport(self, report): + """ + Public method called by pytest after the tests have been collected. + + @param report reference to the report object + @type CollectReport + """ + if report.outcome == "failed": + self.__writer.write({ + "event": "collecterror", + "nodeid": report.nodeid, + "report": str(report.longrepr), + }) + + def pytest_itemcollected(self, item): + """ + Public malled by pytest after a test item has been collected. + + @param item reference to the collected test item + @type Item + """ + self.__writer.write({ + "event": "collected", + "nodeid": item.nodeid, + "name": item.name, + }) + + def pytest_runtest_logstart(self, nodeid, location): + """ + Public method called by pytest before running a test. + + @param nodeid node id of the test item + @type str + @param location tuple containing the file name, the line number and + the test name + @type tuple of (str, int, str) + """ + self.__testsRun += 1 + + self.__writer.write({ + "event": "starttest", + "nodeid": nodeid, + }) + + self.__initializeReportData() + + def pytest_runtest_logreport(self, report): + """ + Public method called by pytest when a test phase (setup, call and + teardown) has been completed. + + @param report reference to the test report object + @type TestReport + """ + if report.when == "call": + self.__status = report.outcome + self.__duration = report.duration + else: + if report.outcome == "failed": + self.__hadError = True + elif report.outcome == "skipped": + self.__wasSkipped = True + + if hasattr(report, "wasxfail"): + self.__wasXfail = True + self.__report.append(report.wasxfail) + self.__reportPhase = report.when + + self.__sections = report.sections + + if report.longrepr: + self.__reportPhase = report.when + if ( + hasattr(report.longrepr, "reprcrash") and + report.longrepr.reprcrash is not None + ): + self.__report.append( + report.longrepr.reprcrash.message) + if isinstance(report.longrepr, tuple): + self.__report.append(report.longrepr[2]) + elif isinstance(report.longrepr, str): + self.__report.append(report.longrepr) + else: + self.__report.append(str(report.longrepr)) + + def pytest_runtest_logfinish(self, nodeid, location): + """ + Public method called by pytest after a test has been completed. + + @param nodeid node id of the test item + @type str + @param location tuple containing the file name, the line number and + the test name + @type tuple of (str, int, str) + """ + if self.__wasXfail: + self.__status = ( + "xpassed" + if self.__status == "passed" else + "xfailed" + ) + elif self.__wasSkipped: + self.__status = "skipped" + + data = { + "event": "result", + "status": self.__status, + "with_error": self.__hadError, + "sections": self.__sections, + "duration_s": self.__duration, + "nodeid": nodeid, + "filename": location[0], + "linenumber": location[1], + "report_phase": self.__reportPhase, + } + if self.__report: + messageLines = self.__report[0].rstrip().splitlines() + data["message"] = messageLines[0] + data["report"] = "\n".join(self.__report) + + self.__writer.write(data) + + def pytest_sessionstart(self, session): + """ + Public method called by pytest before performing collection and + entering the run test loop. + + @param session reference to the session object + @type Session + """ + self.__totalStartTime = time.monotonic_ns() + self.__testsRun = 0 + + def pytest_sessionfinish(self, session, exitstatus): + """ + Public method called by pytest after the whole test run finished. + + @param session reference to the session object + @type Session + @param exitstatus exit status + @type int or ExitCode + """ + stopTime = time.monotonic_ns() + duration = (stopTime - self.__totalStartTime) / 1_000_000_000 # s + + self.__writer.write({ + "event": "finished", + "duration_s": duration, + "tests": self.__testsRun, + }) + + def getVersions(): """ Function to determine the framework version and versions of all available plugins. """ try: - import pytest # __IGNORE_WARNING__ + import pytest versions = { "name": "pytest", "version": pytest.__version__, @@ -88,6 +288,14 @@ elif command == "versions": getVersions() + elif command == "runtest": + import pytest + from EricNetwork.EricJsonStreamWriter import EricJsonWriter + writer = EricJsonWriter(sys.argv[2], int(sys.argv[3])) + pytest.main(sys.argv[4:], plugins=[EricPlugin(writer)]) + writer.close() + sys.exit(0) + sys.exit(42) #
--- a/eric7/Testing/Interfaces/TestExecutorBase.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/Testing/Interfaces/TestExecutorBase.py Mon May 23 17:24:39 2022 +0200 @@ -150,6 +150,22 @@ return {} + def hasCoverage(self, interpreter): + """ + Public method to get the test framework version and version information + of its installed plugins. + + @param interpreter interpreter to be used for the test + @type str + @return flag indicating the availability of coverage functionality + @rtype bool + @exception NotImplementedError this method needs to be implemented by + derived classes + """ + raise NotImplementedError + + return False + def createArguments(self, config): """ Public method to create the arguments needed to start the test process.
--- a/eric7/Testing/Interfaces/UnittestExecutor.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/Testing/Interfaces/UnittestExecutor.py Mon May 23 17:24:39 2022 +0200 @@ -79,6 +79,18 @@ return {} + def hasCoverage(self, interpreter): + """ + Public method to get the test framework version and version information + of its installed plugins. + + @param interpreter interpreter to be used for the test + @type str + @return flag indicating the availability of coverage functionality + @rtype bool + """ + return True + def createArguments(self, config): """ Public method to create the arguments needed to start the test process. @@ -118,10 +130,9 @@ if config.testFilename: args.append(config.testFilename) args.extend(self.__testWidget.getFailedTests()) - - elif config.testFilename and config.testName: + elif config.testFilename: args.append(config.testFilename) - args.append(config.testName) + args.append(config.testName if config.testName else "suite") return args @@ -178,10 +189,8 @@ # test result elif data["event"] == "result": filename, lineno = None, None - tracebackLines = [] - if "traceback" in data: - # get the error info - tracebackLines = data["traceback"].splitlines() + tracebackLines = data.get("traceback", "").splitlines() + if tracebackLines: # find the last entry matching the pattern for index in range(len(tracebackLines) - 1, -1, -1): fmatch = re.search(r'File "(.*?)", line (\d*?),.*', @@ -192,12 +201,9 @@ filename = fmatch.group(1) lineno = int(fmatch.group(2)) - if "shortmsg" in data: - message = data["shortmsg"] - elif tracebackLines: + message = data.get("shortmsg", "") + if not message and tracebackLines: message = tracebackLines[-1].split(":", 1)[1].strip() - else: - message = "" self.testResult.emit(TestResult( category=self.__statusCategoryMapping[data["status"]], @@ -207,12 +213,10 @@ description=data["description"], message=message, extra=tracebackLines, - duration=( - data["duration_ms"] if "duration_ms" in data else None - ), + duration=data.get("duration_ms", None), filename=filename, lineno=lineno, - subtestResult=data["subtest"] if "subtest" in data else False + subtestResult=data.get("subtest", False) )) # test run finished
--- a/eric7/Testing/Interfaces/UnittestRunner.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/Testing/Interfaces/UnittestRunner.py Mon May 23 17:24:39 2022 +0200 @@ -13,7 +13,6 @@ import time import unittest - sys.path.insert( 2, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
--- a/eric7/Testing/Interfaces/__init__.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/Testing/Interfaces/__init__.py Mon May 23 17:24:39 2022 +0200 @@ -7,21 +7,21 @@ Package containg the various test framework interfaces. """ -#from .PytestExecutor import PytestExecutor +from .PytestExecutor import PytestExecutor from .UnittestExecutor import UnittestExecutor Frameworks = ( UnittestExecutor, -# PytestExecutor, + PytestExecutor, ) FrameworkNames = { "MicroPython": ( UnittestExecutor.name, -# PytestExecutor.name, + PytestExecutor.name, ), "Python3": ( UnittestExecutor.name, -# PytestExecutor.name, + PytestExecutor.name, ), }
--- a/eric7/Testing/TestingWidget.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/Testing/TestingWidget.py Mon May 23 17:24:39 2022 +0200 @@ -100,6 +100,16 @@ self.testComboBox.lineEdit().setClearButtonEnabled(True) # create some more dialog buttons for orchestration + self.__showLogButton = self.buttonBox.addButton( + self.tr("Show Output..."), + QDialogButtonBox.ButtonRole.ActionRole) + self.__showLogButton.setToolTip( + self.tr("Show the output of the test runner process")) + self.__showLogButton.setWhatsThis(self.tr( + """<b>Show Output...</b""" + """<p>This button opens a dialog containing the output of the""" + """ test runner process of the most recent run.</p>""")) + self.__showCoverageButton = self.buttonBox.addButton( self.tr("Show Coverage..."), QDialogButtonBox.ButtonRole.ActionRole) @@ -158,6 +168,7 @@ self.__populateVenvComboBox) self.__venvManager.virtualEnvironmentChanged.connect( self.__populateVenvComboBox) + ericApp().registerObject("VirtualEnvManager", self.__venvManager) self.__project = None @@ -173,10 +184,9 @@ self.__editors = [] self.__testExecutor = None + self.__recentLog = "" # connect some signals - self.frameworkComboBox.currentIndexChanged.connect( - self.__resetResults) self.discoveryPicker.editTextChanged.connect( self.__resetResults) self.testsuitePicker.editTextChanged.connect( @@ -330,6 +340,9 @@ self.discoverCheckBox.setChecked(forProject or not bool(testFile)) + if forProject: + self.__projectOpened() + self.tabWidget.setCurrentIndex(0) @pyqtSlot(str) @@ -495,13 +508,16 @@ 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()) - ) + ( + (self.discoverCheckBox.isChecked() and + bool(self.discoveryPicker.currentText())) or + bool(self.testsuitePicker.currentText()) + ) ) + # Log output button + self.__showLogButton.setEnabled(bool(self.__recentLog)) + # Close button self.buttonBox.button( QDialogButtonBox.StandardButton.Close @@ -631,6 +647,8 @@ self.startTests(failedOnly=True) elif button == self.__showCoverageButton: self.__showCoverageDialog() + elif button == self.__showLogButton: + self.__showLogOutput() @pyqtSlot(int) def on_venvComboBox_currentIndexChanged(self, index): @@ -644,6 +662,41 @@ self.__updateButtonBoxButtons() self.versionsButton.setEnabled(bool(self.venvComboBox.currentText())) + + self.__updateCoverage() + + @pyqtSlot(int) + def on_frameworkComboBox_currentIndexChanged(self, index): + """ + Private slot handling the selection of a test framework. + + @param index index of the selected framework + @type int + """ + self.__resetResults() + self.__updateCoverage() + + @pyqtSlot() + def __updateCoverage(self): + """ + Private slot to update the state of the coverage checkbox depending on + the selected framework's capabilities. + """ + hasCoverage = False + + venvName = self.venvComboBox.currentText() + if venvName: + framework = self.frameworkComboBox.currentText() + if framework: + interpreter = self.__venvManager.getVirtualenvInterpreter( + venvName) + executor = self.__frameworkRegistry.createExecutor( + framework, self) + hasCoverage = executor.hasCoverage(interpreter) + + self.coverageCheckBox.setEnabled(hasCoverage) + if not hasCoverage: + self.coverageCheckBox.setChecked(False) @pyqtSlot() def on_versionsButton_clicked(self): @@ -699,6 +752,8 @@ if self.__mode == TestingWidgetModes.RUNNING: return + self.__recentLog = "" + self.__recentEnvironment = self.venvComboBox.currentText() self.__recentFramework = self.frameworkComboBox.currentText() @@ -723,8 +778,6 @@ testName = self.testComboBox.currentText() if testName: self.__insertTestName(testName) - if testFileName and not testName: - testName = "suite" self.sbLabel.setText(self.tr("Preparing Testsuite")) QCoreApplication.processEvents() @@ -781,9 +834,9 @@ """ Private slot handling the 'collected' signal of the executor. - @param testNames list of tuples containing the test id and test name - of collected tests - @type list of tuple of (str, str) + @param testNames list of tuples containing the test id, the test name + and a description of collected tests + @type list of tuple of (str, str, str) """ testResults = [ TestResult( @@ -794,9 +847,9 @@ message=desc, ) for id, name, desc in testNames ] - self.__resultsModel.setTestResults(testResults) + self.__resultsModel.addTestResults(testResults) - self.__totalCount = len(testResults) + self.__totalCount += len(testResults) self.__updateProgress() @pyqtSlot(list) @@ -877,8 +930,12 @@ @param output string containing the test process output (if any) @type str """ + self.__recentLog = output + self.__setStoppedMode() self.__testExecutor = None + + self.__adjustPendingState() @pyqtSlot(int, float) def __testRunFinished(self, noTests, duration): @@ -915,6 +972,21 @@ """ self.__resultsModel.clear() + def __adjustPendingState(self): + """ + Private method to change the status indicator of all still pending + tests to "not run". + """ + newResults = [] + for result in self.__resultsModel.getTestResults(): + if result.category == TestResultCategory.PENDING: + result.category = TestResultCategory.SKIP + result.status = self.tr("not run") + newResults.append(result) + + if newResults: + self.__resultsModel.updateTestResults(newResults) + @pyqtSlot(str) def __coverageData(self, coverageFile): """ @@ -936,14 +1008,27 @@ self.__coverageDialog = PyCoverageDialog(self) self.__coverageDialog.openFile.connect(self.__openEditor) - if self.discoverCheckBox.isChecked(): - testDir = self.discoveryPicker.currentText() - else: - testDir = os.path.dirname(self.testsuitePicker.currentText()) + testDir = ( + self.discoveryPicker.currentText() + if self.discoverCheckBox.isChecked() else + os.path.dirname(self.testsuitePicker.currentText()) + ) if testDir: self.__coverageDialog.show() self.__coverageDialog.start(self.__coverageFile, testDir) + @pyqtSlot() + def __showLogOutput(self): + """ + Private slot to show the output of the most recent test run. + """ + from EricWidgets.EricPlainTextDialog import EricPlainTextDialog + dlg = EricPlainTextDialog( + title=self.tr("Test Run Output"), + text=self.__recentLog + ) + dlg.exec() + @pyqtSlot(str) def __setStatusLabel(self, statusText): """
--- a/eric7/Testing/__init__.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/Testing/__init__.py Mon May 23 17:24:39 2022 +0200 @@ -31,4 +31,4 @@ @return flag indicating support @rtype bool """ - return language in FrameworkNames.keys() + return language in FrameworkNames
--- a/eric7/Utilities/__init__.py Sat May 21 19:49:34 2022 +0200 +++ b/eric7/Utilities/__init__.py Mon May 23 17:24:39 2022 +0200 @@ -1323,6 +1323,86 @@ ] +def getCoverageFileNames(fn): + """ + Function to build a list of coverage data file names. + + @param fn file name basis to be used for the coverage data file + @type str + @return list of existing coverage data files + @rtype list of str + """ + files = [] + for filename in [fn, os.path.dirname(fn) + os.sep] + getTestFileNames(fn): + f = getCoverageFileName(filename) + if f: + files.append(f) + return files + + +def getCoverageFileName(fn, mustExist=True): + """ + Function to build a file name for a coverage data file. + + @param fn file name basis to be used for the coverage data file name + @type str + @param mustExist flag indicating to check that the file exists (defaults + to True) + @type bool (optional) + @return coverage data file name + @rtype str + """ + basename = os.path.splitext(fn)[0] + filename = "{0}.coverage".format(basename) + if mustExist: + if os.path.isfile(filename): + return filename + else: + return "" + else: + return filename + + +def getProfileFileNames(fn): + """ + Function to build a list of profile data file names. + + @param fn file name basis to be used for the profile data file + @type str + @return list of existing profile data files + @rtype list of str + """ + files = [] + for filename in [fn, os.path.dirname(fn) + os.sep] + getTestFileNames(fn): + f = getProfileFileName(filename) + if f: + files.append(f) + return files + + +def getProfileFileName(fn, mustExist=True): + """ + Function to build a file name for a profile data file. + + @param fn file name basis to be used for the profile data file name + @type str + @param mustExist flag indicating to check that the file exists (defaults + to True) + @type bool (optional) + @return profile data file name + @rtype str + """ + basename = os.path.splitext(fn)[0] + filename = "{0}.profile".format(basename) + if mustExist: + if os.path.isfile(filename): + return filename + else: + return "" + else: + return filename + + def parseOptionString(s): """ Function used to convert an option string into a list of options.