Merged with branch 'unittest' to get the changed functionality to the main development branch. eric7

Mon, 23 May 2022 17:24:39 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Mon, 23 May 2022 17:24:39 +0200
branch
eric7
changeset 9095
3dfdc23e8c49
parent 9088
b079ec4176db (current diff)
parent 9094
5ec66544085a (diff)
child 9096
f6f7d8f364c3

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.

eric ide

mercurial